Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38324628b0 | ||
|
|
132bb07bc9 | ||
|
|
50884daca1 | ||
|
|
fc53c7732e | ||
|
|
ab1f3a28d1 | ||
|
|
43ebd61fdf | ||
|
|
bf608fa271 | ||
|
|
fa5e5a8e29 | ||
|
|
388816b015 | ||
|
|
3bdefb1d1c | ||
|
|
7f648e6400 | ||
|
|
b688ce896f | ||
|
|
1eb509740e | ||
|
|
ad36101b7f | ||
|
|
00ede7c5d0 | ||
|
|
314ca2f2a6 | ||
|
|
64045be968 | ||
|
|
437d659c9d | ||
|
|
10ed3d3a30 | ||
|
|
ef2f9aa714 | ||
|
|
b15c4cf6c6 | ||
|
|
e4d64a88eb | ||
|
|
74d2b6aa68 | ||
|
|
64d0e50df3 | ||
|
|
7d179c696a | ||
|
|
662bc133b1 | ||
|
|
b3a4390169 | ||
|
|
b8bded1b09 | ||
|
|
ac901f7ee9 | ||
|
|
43253cd9ab | ||
|
|
7aeb636ca2 | ||
|
|
c914d2bbd9 | ||
|
|
481c3e5128 | ||
|
|
29953ea601 | ||
|
|
99c31748ba | ||
|
|
2b8a3ce7db | ||
|
|
36de55f10f | ||
|
|
55fe0ef228 | ||
|
|
8720580004 | ||
|
|
c3dd346b7a | ||
|
|
4afab63046 | ||
|
|
2cc06d1dc7 | ||
|
|
45d2dfced4 | ||
|
|
a5fddec0e9 | ||
|
|
a8204bb51f | ||
|
|
e4d07121c8 | ||
|
|
76044f6bd9 | ||
|
|
1581260e31 | ||
|
|
19c3a89c32 | ||
|
|
4dbdc2678d | ||
|
|
fb32d508f8 | ||
|
|
1f637ca43c | ||
|
|
648b0eaca5 | ||
|
|
b254a0bd3e | ||
|
|
74302761ce | ||
|
|
4da48eed68 | ||
|
|
6803d7519f | ||
|
|
86dbd8819b | ||
|
|
20395eed9b | ||
|
|
a886735040 | ||
|
|
75d41f3bb7 | ||
|
|
26252b333a | ||
|
|
c219ec4e66 | ||
|
|
32586149c6 | ||
|
|
9ec28add80 | ||
|
|
35aa64fe4e | ||
|
|
c33337cea2 | ||
|
|
b0997f614b | ||
|
|
6d7c98ef4a | ||
|
|
f44c1d465f | ||
|
|
bdb78a689d | ||
|
|
3f89f8f5dd | ||
|
|
3cab90747a | ||
|
|
fff3bef4e4 | ||
|
|
2b78fb3a75 | ||
|
|
a04460c57b | ||
|
|
27948ba8cf |
@@ -49,7 +49,7 @@ jobs:
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
||||
- name: Run all test projects on Windows
|
||||
run: |
|
||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests')) {
|
||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
||||
}
|
||||
|
||||
+9
-1
@@ -207,13 +207,21 @@ FakesAssemblies/
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# Claude Code
|
||||
# Local agent state and temporary planning/progress documents
|
||||
.claude/
|
||||
/.hermes/plans/
|
||||
/.hermes/progress/
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
/docs/*-plan.md
|
||||
/docs/*-progress.md
|
||||
|
||||
# Launch settings
|
||||
**/Properties/launchSettings.json
|
||||
|
||||
# Local test config (contains user-specific paths to proprietary test assets)
|
||||
OpenNest.Tests/test-config.json
|
||||
|
||||
# Vendor programming manuals: keep reference copies outside source control.
|
||||
CINCINNATI LASER PROGRAMMING MANUAL.pdf
|
||||
TF5200_programming_manual_en.pdf
|
||||
|
||||
@@ -1,157 +1,62 @@
|
||||
# AGENTS.md
|
||||
# OpenNest agent instructions
|
||||
|
||||
This file contains shared repository instructions for coding agents working on OpenNest. It is the single source of truth; `CLAUDE.md` imports it for Claude Code compatibility.
|
||||
Shared instructions; keep `CLAUDE.md` as the thin `@AGENTS.md` import.
|
||||
OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform libraries.
|
||||
|
||||
## Project Overview
|
||||
## Working rules
|
||||
|
||||
OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts on material plates to minimize waste. It imports DXF drawings, places parts onto plates using NFP-based (No Fit Polygon) and rectangle-packing algorithms, and can export nest layouts as DXF or post-process them to G-code for CNC cutting machines.
|
||||
- Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search.
|
||||
- Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`.
|
||||
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`.
|
||||
- Never commit design specs, implementation plans, progress notes or temporary benchmark reports. Keep working records under local, ignored `.hermes/plans/` or `.hermes/progress/`; retain reusable verification procedures in `docs/`.
|
||||
- Keep vendor manuals/full-text extracts out of source control unless redistribution is authorized. Write project-specific behavior summaries with citations, separating controller rules, machine macros and unconfirmed behavior.
|
||||
|
||||
## Build
|
||||
## Build and test
|
||||
|
||||
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
|
||||
```sh
|
||||
# Full solution: Windows
|
||||
dotnet build OpenNest.sln
|
||||
|
||||
# Cross-platform suites: run independently on Linux/macOS/Windows
|
||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj
|
||||
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
|
||||
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
|
||||
|
||||
# Windows runtime tests
|
||||
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
|
||||
```
|
||||
|
||||
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 main `OpenNest.Tests` suite also targets `net8.0`: run `dotnet test OpenNest.Tests/OpenNest.Tests.csproj` independently on Linux/macOS/Windows. It must not reference the WinForms `OpenNest` project. The API, Data, and post-processor libraries target `net8.0`. Post-processor projects live under `Posts/` (`Posts/OpenNest.Posts.<Name>/`, referencing `..\..\OpenNest.Core`); their build deployment still targets the desktop app's `net8.0-windows/Posts` directory. Optional CHR-font fixtures are configured through `OpenNest.Tests/test-config.json` and skip when absent.
|
||||
Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
|
||||
|
||||
`OpenNest.WinForms.Tests` contains the desktop-assembly-dependent `CadBendNoteTests` (`CadText`) and `CuttingParametersSerializerTests` (`CuttingParametersSerializer`). It targets `net8.0-windows`, references `OpenNest`, and requires a Windows runner: `dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj`. Keep future desktop-dependent tests here rather than in `OpenNest.Tests`. Linux cross-compilation uses `dotnet build OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj -p:EnableWindowsTargeting=true`; cross-compilation is not Windows runtime verification.
|
||||
Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. Gitea is authoritative for Git refs.
|
||||
|
||||
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`.
|
||||
## Project map and boundaries
|
||||
|
||||
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `Clipper2` 2.0.0 (region offsetting, in OpenNest.Core), `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).
|
||||
- `OpenNest.Core`: domain (`Nest -> Plate -> Part -> Drawing -> CNC.Program`), geometry, cutting strategies and diagnostics. Angles are radians; use `Tolerance.Epsilon` for geometry comparisons. `OpenNest.Math` shadows `System.Math`, so qualify the latter.
|
||||
- `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based.
|
||||
- Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution.
|
||||
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
|
||||
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json`. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory.
|
||||
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection. Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
|
||||
|
||||
### Windows release packaging
|
||||
## Geometry and ownership safeguards
|
||||
|
||||
The GitHub `Windows release build` workflow runs on `release/vX.Y.Z` branches, `vX.Y.Z` tags, or manual dispatch. It builds with .NET 8 on `windows-2022`, runs all four test projects in Release plus the main Debug suite, and executes `scripts/Publish-Windows.ps1`. The script creates a self-contained win-x64 desktop ZIP with all three shipped posts plus pinned Gpt6Astra/Opus55/Qwen38FlashNext plug-ins from `scripts/external-engines.json`, runs their tests against this host, validates package contents/version and registry discovery (including a missing-DLL failure case), launches the extracted app, and writes a SHA-256 checksum. It refuses an existing output directory. Workflow artifacts are release candidates, not automatically published releases; follow [the release procedure](docs/releasing.md). Keep Gitea authoritative for Git refs.
|
||||
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
|
||||
- Clipper is for cached CPU region preparation, never per-pair hot loops. Preserve the hand-written `Collision` kernel's GPU-port contract. Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding. Do not loosen tolerances to hide failures.
|
||||
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
|
||||
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
|
||||
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
|
||||
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
|
||||
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
|
||||
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
|
||||
|
||||
### Fill performance verification
|
||||
Read the relevant contract before changing its behavior:
|
||||
|
||||
See [fill verification](docs/performance/fill-verification.md) for opt-in measurements, targeted tests, Debug counter isolation, and predictor initialization rules. Keep training bitmaps by default; the angle builder checks predictor availability before scalar-only extraction.
|
||||
|
||||
## Architecture
|
||||
|
||||
Nine projects form a layered architecture:
|
||||
|
||||
### OpenNest.Core (class library)
|
||||
Domain model, geometry, and CNC primitives organized into namespaces:
|
||||
|
||||
- **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
|
||||
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) and an optional `Variables` dictionary of `VariableDefinition` entries. Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. `VariableDefinition` stores a named variable's expression, resolved value, and flags (`Inline`, `Global`). `ProgramVariableManager` manages numbered machine variables for post-processor output.
|
||||
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon` (convex NFP only), `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, `ClipperBridge` (Clipper2 region offsetting for CPU preparation only; see Key Patterns), and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction; deliberately hand-rolled as the reference for a future GPU kernel, with the port contract in its class summary).
|
||||
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
|
||||
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `ExpressionEvaluator` (arithmetic expression parser for G-code variable expressions with `$name` references). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
|
||||
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
|
||||
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
|
||||
- **CutOffs** (`namespace OpenNest`): `CutOff` (axis-aligned cut line with position, axis, optional start/end limits), `CutOffAxis` enum (`Horizontal`, `Vertical`), `CutOffSettings` (clearance, overtravel, min segment length, direction), `CutDirection` enum (`TowardOrigin`, `AwayFromOrigin`). Cut-offs generate CNC `Program` objects with trimmed line segments that avoid parts.
|
||||
- **Splitting** (`Splitting/`, `namespace OpenNest`): `DrawingSplitter` splits a Drawing into multiple pieces along split lines. `ISplitFeature` strategy pattern with implementations: `StraightSplit` (clean edge), `WeldGapTabSplit` (rectangular tab spacers on one side), `SpikeGrooveSplit` (interlocking spike/V-groove pairs). `AutoSplitCalculator` computes split lines for fit-to-plate and split-by-count modes. Supporting types: `SplitLine`, `SplitParameters`, `SplitFeatureResult`.
|
||||
- **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 use the jobs-only API. `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 built-in placement strategy.
|
||||
|
||||
- **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`, `StripPlateNester`, and `RemnantPlateNester` are built-ins with run-scoped private geometry. `PlateFillService` is the public single-plate proposal service for interactive fill/group/pack flows; it returns parts without mutating caller-owned plates. Job-path identity is reference-based rather than drawing name; `PlateOptimizer` retains name-based helpers and remains outside the runner path.
|
||||
- **Filler pipeline (`Jobs/Placement/Fillers/`)**: internal `DefaultPlateFiller`, `StripPlateFiller`, and policy-backed `RemnantPlateFiller` implement the standard single-plate geometry pipeline. `Default` runs the Linear, Pairs, RectBestFit, and Extents phases; remnant variants preserve their distinct comparer, direction, trim-axis, and angle-ordering policies.
|
||||
- **Engine registration**: `NestingEngineRegistry` holds whole-job `INestingEngine` implementations including the four fixed strategies and `StockLadder`. It loads plug-ins that implement `INestingEngine` and have a public parameterless constructor. Plug-ins for the removed single-plate inheritance API are not binary compatible.
|
||||
- **Plugin engines**: independent `INestingEngine` plugins are class libraries that reference `OpenNest.Engine` and are built outside `OpenNest.sln`. The desktop app and `OpenNest.Benchmark` load them from an `Engines/` folder next to their build output (e.g. `OpenNest.Benchmark/bin/<Config>/net8.0/Engines/`). Do not add engine projects to this repo.
|
||||
- **IFillComparer**: Interface enabling filler-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), and `HorizontalRemnantComparer` (minimize Y-extent) are grouped into `FillPolicy` on `FillContext`.
|
||||
- **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`.
|
||||
- **Strategies/** (`namespace OpenNest.Engine.Strategies`): Pluggable fill strategy layer — `IFillStrategy` interface, `FillContext`, `FillStrategyRegistry` (auto-discovers strategies via reflection, supports plugin DLLs), `FillHelpers`. Built-in strategies: `LinearFillStrategy`, `PairsFillStrategy`, `RectBestFitStrategy`, `ExtentsFillStrategy`.
|
||||
- **BestFit/** (`namespace OpenNest.Engine.BestFit`): NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups.
|
||||
- **RectanglePacking/** (`namespace OpenNest.Engine.RectanglePacking`): `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
|
||||
- **CirclePacking/** (`namespace OpenNest.Engine.CirclePacking`): Alternative packing for circular parts.
|
||||
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features; `Extract(drawing, includeBitmask: false)` skips the 32x32 training bitmap for inference while scalars stay identical; the default overload keeps it for training), `BruteForceRunner` (full angle sweep for training data).
|
||||
- `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints.
|
||||
- `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback.
|
||||
|
||||
### OpenNest.IO (class library, depends on Core)
|
||||
File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
|
||||
|
||||
- `DxfImporter`/`DxfExporter` — DXF file import/export via ACadSharp.
|
||||
- `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format).
|
||||
- `ProgramReader` — G-code text parser.
|
||||
- `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 (`net8.0`). Supports DXF import, plate configuration, linear fill, and multi-drawing auto-nesting through the active engine's `Nest()` (`--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.
|
||||
|
||||
### 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.
|
||||
- `DxfManifestLoader` builds a `BenchmarkJob` from a JSON manifest (`sheetSizes`, `spacing`, `edgeSpacing`, `quadrant`, `parts[] { dxf, quantity, allowRotation }`) instead of a `.nest`, importing each DXF with `CadImporter.ImportDrawing`. DXF paths resolve relative to the manifest; sheet sizes are required (manifest or `--sheet-sizes`, which overrides). `allowRotation: false` locks rotation the same way `NestRunner` does. `JobLoader.Load` routes `*.json` inputs to it, and folder scans pick up `*.nest` plus `*.manifest.json` (plain `*.json` is ignored so `--output` reports are never read as manifests). Invalid manifests throw rather than being skipped.
|
||||
- `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` fans the (job × engine) pairs out with `Parallel.ForEach` (`NoBuffering`, `MaxDegreeOfParallelism` from `--parallel`, CLI default 3, `Run`'s own default 1) and writes results by index so report order stays job-then-engine. Each solve builds its own `NestJob` snapshot and materialized drawings, so solves share no mutable drawing state. Concurrent solves compete for cores, so `Time(ms)` is only clean at `--parallel 1`. It 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: each part's perimeter inflated and cutouts shrunk by the spacing, tested against the other part's raw material with holes subtracted, so part-in-part inside a cutout is legal; an X-sorted bounding-box sweep prunes distant pairs), and no drawing over its requested quantity. `ValidateAgainstJob` also checks the raw `NestJobResult`: every sheet must match a stock entry the job offered (size, spacing, edge spacing, quadrant; finite quantity not overdrawn), and every placement rotation must satisfy its part's `RotationPolicy.Allows`. An invalid, throwing, or timed-out run places nothing for scoring.
|
||||
- Ranking (`Report.Compare`): valid > invalid, fully placed > not, then lower `JobResult.Cost`, then fewer plates. Cost = salvage-credited sheet area (`StockLadderNestingEngine.EstimateNetArea` per plate, recomputed from job geometry) + `BenchmarkJob.UnplacedPartPenalty` (largest candidate sheet area) per unplaced part, so dropping hard parts never improves the score. The summary sums cost and areas across jobs (area-weighted, not a mean of per-job percentages). Without `--sheet-sizes`, `.nest` jobs only offer their original sizes, and the CLI warns that this hints engines. Numeric CLI and manifest sheet sizes parse with the invariant culture (`JobLoader.TryParseSheetSize`).
|
||||
- `--engines Name1,Name2` filters to specific registered engines (default: all); `--csv <path>` writes a flat per-job CSV alongside the console report. `--progress` passes each solve a `JobProgressLog`, which writes `[job/engine]` lines for start, finish, every `PlateCommitted`, and `EvaluatingCandidate` throttled to one line per 2 s.
|
||||
- `tools/PepNestExport` (outside the solution; references `PepLib.Core` from the sibling `PepApi.Core` repo) converts a PepApi year of PEP nests into `.nest` files that keep PEP's placements as the benchmark `Baseline`. PEP loop quirks: sub-loop calls continue the incremental position; lead-in/out, `DESTRUCT CUT` and non-cut moves must not reach the program as rapids (a program's bounding box counts rapid endpoints); contours may be broken by uncut micro-joint tabs (a rapid of up to 0.25 across the tab, at the seam or mid-contour, e.g. a cutout cut as two halves), which the export bridges only where the pieces chain into a closed loop; and one drawing can be placed through several loops with different origins.
|
||||
|
||||
### 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/`.
|
||||
|
||||
- **Tools/InputTools**: `load_nest`, `import_dxf`, `create_drawing` (built-in shapes or G-code).
|
||||
- **Tools/SetupTools**: `create_plate`, `clear_plate`.
|
||||
- **Tools/NestingTools**: `fill_plate`, `fill_area`, `fill_remnants`, `pack_plate`.
|
||||
- **Tools/InspectionTools**: `get_plate_info`, `get_parts`, `check_overlaps`.
|
||||
- `NestSession` — in-memory state across tool calls (current Nest, standalone plates/drawings).
|
||||
|
||||
### OpenNest (WinForms WinExe, depends on Core + Engine + IO)
|
||||
The UI application with MDI interface.
|
||||
|
||||
- **Auto Nest engine routing**: when the selected engine is not a built-in fill strategy (`EngineSelection.IsFillStrategy` is false, i.e. StockLadder or an `Engines/` plug-in), `MainForm.RunJobEngineAsync` solves the whole job through `INestingEngine.Solve`. `JobEngineNest` builds the `NestJob` from the auto-nest items and either the plate options or the current plate, and it converts `NestJobProgress` for `NestProgressForm`: an engine's `LegacyProgress` passes through, and otherwise the stage and committed counts become the description. It then binds the result poses back onto the nest's own drawings. Whole-job engines throw on cancel, so the progress form hides Accept (`AllowAccept = false`) and Stop discards the run. Built-in strategies keep the existing per-plate fill path.
|
||||
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), `SplitDrawingForm` (split oversized drawings into smaller pieces, launched from CadConverterForm), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
|
||||
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
|
||||
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`, `ActionCutOff`.
|
||||
- **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime. Plugin sources live in the repository's `Posts/` folder (the solution's `PostProcessors` folder).
|
||||
|
||||
## File Format
|
||||
|
||||
Nest files (`.nest`, ZIP-based) use v2 JSON format:
|
||||
- `nest.json` — single JSON file containing all nest metadata: nest info (name, units, customer, dates, notes), plate defaults (size, thickness, quadrant, spacing, material, edge spacing), drawings array (id, name, color, quantity, priority, rotation constraints, material, source), and plates array (id, size, material, edge spacing, parts with drawingId/x/y/rotation, cutoffs with x/y/axis/startLimit/endLimit)
|
||||
- `programs/program-N` — G-code text for each drawing's cut program (N = drawing id)
|
||||
- `bestfits/bestfit-N` — JSON array of best-fit pair evaluation results per drawing, keyed by plate size/spacing (optional, only present if best-fit data was computed)
|
||||
|
||||
## Tool Preferences
|
||||
|
||||
Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary method for exploring and analyzing this codebase. It is faster and more efficient than file-based searches. Use it for finding symbols, references, diagnostics, type hierarchies, and code navigation. Only fall back to Glob/Grep when Roslyn Bridge cannot fulfill the query.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Always use `var` instead of explicit types (e.g., `var parts = new List<Part>();` not `List<Part> parts = new List<Part>();`).
|
||||
|
||||
## Documentation Maintenance
|
||||
|
||||
Always keep `README.md` and `AGENTS.md` up to date when making changes that affect project structure, architecture, build instructions, dependencies, or key patterns. If you add a new project, change a namespace, modify the build process, or alter significant behavior, update both files as part of the same change. Keep `CLAUDE.md` as a thin `@AGENTS.md` import rather than duplicating shared instructions.
|
||||
|
||||
**Do not commit** design specs, implementation plans, or other temporary planning documents (`docs/superpowers/` etc.) to the repository. These are working documents only — keep them local and untracked.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.
|
||||
- OpenNest.Engine uses sub-namespaces: `OpenNest.Engine.Fill` (fill algorithms), `OpenNest.Engine.Strategies` (pluggable strategy layer), `OpenNest.Engine.BestFit`, `OpenNest.Engine.Jobs` (whole-job API, with `.Placement` and `.Adapters`), `OpenNest.Engine.ML`, `OpenNest.Engine.RapidPlanning`, `OpenNest.Engine.Sequencing`, `OpenNest.Engine.RectanglePacking`, `OpenNest.Engine.CirclePacking`. All Engine types live in namespaces matching their directory under `OpenNest.Engine/` (project files use `namespace X;` file-scoped or block style); consumers reference them via explicit `using OpenNest.Engine[.Sub];` directives.
|
||||
- `ObservableList<T>` provides ItemAdded/ItemRemoved/ItemChanged events used for automatic quantity tracking between plates and drawings.
|
||||
- Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion).
|
||||
- `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations.
|
||||
- Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`.
|
||||
- **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed with endpoints kept on the arc, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `NestValidator` uses `OffsetForValidation` instead: the same flattening with fine joins and no padding, inflated by the spacing less `NestTolerances.SpacingSlack` (0.0005), so a layout exactly at the spacing passes even after rotation and coordinate rounding leave it ~1e-4 short. `NestJobPlacementValidator` applies the same slack to its edge-distance check. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. `FillLinear` prepares each distinct `Program` (reference identity) once per public `Fill`/`FillRow` call and translates clones; never share that cache across calls or threads. Both `HasOverlappingParts` loops use one `PartOverlapChecker` per call (same keying; it also caches each part's triangles; parts and programs must not change while it is in use). Clipper is allowed only for cached CPU preparation, never in per-pair hot loops.
|
||||
- **Marks are not material**: scribe/etch moves are marked on the surface, never cut through, so they are left out of nesting. `SpecialLayers.IsMaterial(layer)` (excludes `Rapid` and `Scribe`) is the filter for every consumer that builds part material from a program: drawing area, canonical angle, part collision, `PartGeometry`, plate perimeters, best-fit/pair evaluation, rotation analysis, the GPU evaluators, and both validators (`NestJobPlacementValidator`, benchmark `NestValidator`). Cutting time, on-screen display, splitting, and post-processors still see marks. Older `.nest` files (e.g. `tools/PepNestExport` output) saved etch as cut moves while their source entities kept the `SCRIBE` layer; `NestReader` runs `ScribeLayerRepair` on load to move matching program moves back to `Scribe`.
|
||||
- **Native curve contact**: CPU best-fit and shared directional queries use `SpatialQuery.CurveTangencyDistance` for sum- and difference-radius tangency, checking both forward roots and both arc spans. It supplements vertex/line phases, not a complete collision/clearance validator. See [pair-spacing checks](docs/geometry/pair-spacing.md) for the regression and remaining limits.
|
||||
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
|
||||
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. After its null/empty guards, `DefaultFillComparer` decides unequal counts without scoring; equal counts still use scores, and exact ties retain the current layout. `FillHelpers.FillPattern` computes eager scores only when no custom comparer is supplied; custom comparers remain authoritative and may perform their own scoring.
|
||||
- **Extents column pitch**: for finite valid geometry, finite pair height, and finite nonnegative spacing, `FillExtents.BuildColumn` uses `pair.Bbox.Width + partSpacing` directly. The old vertical slide calculation clamps to the same pitch, so it need not prepare boundaries or temporary test clones. Negative/nonfinite spacing or nonfinite pair height retains the legacy calculation: public/interactive callers do not all validate spacing. Do not remove `BuildPair` boundary preparation or the adjusted-column overlap fallback, or turn this shortcut into a geometry/validation policy change.
|
||||
- **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). 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.
|
||||
- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` and the saved `SCRIBE` layer (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList<GravographPass>, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth).
|
||||
- [Nest file format](docs/nest-file-format.md)
|
||||
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
|
||||
- [Lead-in placement](docs/geometry/lead-in-placement.md)
|
||||
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
|
||||
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
|
||||
- [Pre-post verification](docs/post-verification.md)
|
||||
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
|
||||
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
|
||||
|
||||
@@ -16,6 +16,9 @@ public class NestRequest
|
||||
|
||||
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
|
||||
public string PlacementStrategy { get; init; } = "Default";
|
||||
|
||||
/// <summary>Registered whole-job engine. Null uses PlacementStrategy (or legacy Strategy).</summary>
|
||||
public string Engine { get; init; }
|
||||
public string Material { get; init; } = "Steel, A1011 HR";
|
||||
public double Thickness { get; init; } = 0.06;
|
||||
public double Spacing { get; init; } = 0.1;
|
||||
|
||||
@@ -5,8 +5,8 @@ using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
@@ -19,9 +19,12 @@ 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);
|
||||
|
||||
/// <summary>Independent geometry validation; null on old archives means not recorded.</summary>
|
||||
public enum NestValidationStatus { Valid, Invalid, Unrepresentable }
|
||||
|
||||
public class NestResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 2;
|
||||
public const int CurrentSchemaVersion = 3;
|
||||
|
||||
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
@@ -35,6 +38,9 @@ public class NestResponse
|
||||
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
|
||||
public NestJobStatus? Status { get; init; }
|
||||
public NestJobStopReason? StopReason { get; init; }
|
||||
/// <summary>Fulfillment status is not geometry acceptance. Review this status before using Nest.</summary>
|
||||
public NestValidationStatus? ValidationStatus { get; init; }
|
||||
public IReadOnlyList<string> Violations { get; init; } = [];
|
||||
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
|
||||
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
|
||||
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||
@@ -75,6 +81,8 @@ public class NestResponse
|
||||
ElapsedTicks = Elapsed.Ticks,
|
||||
Status = Status,
|
||||
StopReason = StopReason,
|
||||
ValidationStatus = ValidationStatus,
|
||||
Violations = Violations is null ? [] : new List<string>(Violations),
|
||||
Fulfillment = Fulfillment is null
|
||||
? []
|
||||
: new List<NestPartFulfillment>(Fulfillment),
|
||||
@@ -158,6 +166,8 @@ public class NestResponse
|
||||
Elapsed = TimeSpan.FromTicks(archive.ElapsedTicks),
|
||||
Status = hasStatusMetadata ? archive.Status : null,
|
||||
StopReason = hasStatusMetadata ? archive.StopReason : null,
|
||||
ValidationStatus = archive.ValidationStatus,
|
||||
Violations = archive.Violations ?? [],
|
||||
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
|
||||
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
|
||||
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
|
||||
@@ -175,6 +185,8 @@ public class NestResponse
|
||||
public long ElapsedTicks { get; init; }
|
||||
public NestJobStatus? Status { get; init; }
|
||||
public NestJobStopReason? StopReason { get; init; }
|
||||
public NestValidationStatus? ValidationStatus { get; init; }
|
||||
public List<string> Violations { get; init; } = [];
|
||||
public List<NestPartFulfillment> Fulfillment { get; init; } = [];
|
||||
public List<NestStockUsage> StockUsage { get; init; } = [];
|
||||
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||
|
||||
+67
-39
@@ -5,10 +5,9 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
@@ -32,7 +31,7 @@ public static class NestRunner
|
||||
var sw = Stopwatch.StartNew();
|
||||
var parts = IdentifyParts(requestParts);
|
||||
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
var jobParts = new List<NestJobPart>(parts.Count);
|
||||
var items = new List<NestItem>(parts.Count);
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
@@ -68,23 +67,64 @@ public static class NestRunner
|
||||
importedByPath.Add(part.Request.DxfPath, drawing);
|
||||
}
|
||||
|
||||
ConfigureDrawingForRequirement(drawing, part.Request);
|
||||
jobParts.Add(DrawingJobMapper.FromDrawing(part.Id, drawing, part.Request.Quantity));
|
||||
// Each requirement keeps its own identity/constraints even when paths are shared.
|
||||
var requirementDrawing = new Drawing(part.Id, drawing.Program);
|
||||
ConfigureDrawingForRequirement(requirementDrawing, part.Request);
|
||||
requirementDrawing.Quantity.Required = part.Request.Quantity;
|
||||
items.Add(new NestItem
|
||||
{
|
||||
Drawing = requirementDrawing,
|
||||
Quantity = part.Request.Quantity,
|
||||
Priority = part.Request.Priority,
|
||||
StepAngle = requirementDrawing.Constraints.StepAngle,
|
||||
RotationStart = requirementDrawing.Constraints.StartAngle,
|
||||
RotationEnd = requirementDrawing.Constraints.EndAngle,
|
||||
});
|
||||
}
|
||||
|
||||
var job = new NestJob(
|
||||
jobParts,
|
||||
CreateStock(request),
|
||||
new NestJobOptions(ResolvePlacementStrategy(request))
|
||||
);
|
||||
var stock = CreateStock(request);
|
||||
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
|
||||
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
|
||||
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job, jobProgress, token);
|
||||
var result = NestPipeline.Run(new NestPipelineRequest(
|
||||
engineName, items, stock, new NestJobOptions(engineName)), 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);
|
||||
// API returns a detached proposal, not an acceptance/commit to a caller's nest.
|
||||
// Invalid but representable proposals retain every pose and carry explicit validation status.
|
||||
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
|
||||
foreach (var item in items)
|
||||
nest.Drawings.Add(item.Drawing);
|
||||
foreach (var proposed in result.Plates)
|
||||
{
|
||||
var plate = new Plate(proposed.Stock.Size)
|
||||
{
|
||||
PartSpacing = proposed.Stock.PartSpacing,
|
||||
EdgeSpacing = proposed.Stock.EdgeSpacing,
|
||||
Quadrant = proposed.Stock.Quadrant,
|
||||
Quantity = 1,
|
||||
};
|
||||
plate.Parts.AddRange(proposed.Parts);
|
||||
nest.Plates.Add(plate);
|
||||
}
|
||||
|
||||
// Pipeline IDs are internal part-i values. Exposed counts/IDs come from the
|
||||
// returned, bound placements and the original request, never plug-in summaries.
|
||||
var counts = result.Plates.SelectMany(p => p.Parts)
|
||||
.GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Count());
|
||||
var fulfillment = parts.Select((part, i) =>
|
||||
{
|
||||
var placed = counts.GetValueOrDefault(items[i].Drawing);
|
||||
return new NestPartFulfillment(part.Id, part.Request.Quantity, placed,
|
||||
System.Math.Max(0, part.Request.Quantity - placed));
|
||||
}).ToArray();
|
||||
var usage = stock.Select(s =>
|
||||
{
|
||||
var used = result.Plates.Count(p => p.Stock.Id == s.Id);
|
||||
return new NestStockUsage(s.Id, used, s.Quantity.HasValue ? System.Math.Max(0, s.Quantity.Value - used) : null);
|
||||
}).ToArray();
|
||||
var complete = fulfillment.All(f => f.Placed == f.Requested);
|
||||
var stopReason = complete ? NestJobStopReason.Completed
|
||||
: usage.All(u => u.Remaining == 0) ? NestJobStopReason.StockExhausted
|
||||
: NestJobStopReason.NoPlacementFound;
|
||||
|
||||
var timingInfo = Timing.GetTimingInfo(nest);
|
||||
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
|
||||
@@ -97,29 +137,15 @@ public static class NestRunner
|
||||
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(),
|
||||
Status = complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
||||
StopReason = stopReason,
|
||||
ValidationStatus = !result.CanKeep ? NestValidationStatus.Unrepresentable
|
||||
: result.IsValid ? NestValidationStatus.Valid : NestValidationStatus.Invalid,
|
||||
Violations = result.Violations,
|
||||
Fulfillment = fulfillment,
|
||||
StockUsage = usage,
|
||||
PlateStockMappings = result.Plates.Select((value, index) =>
|
||||
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
|
||||
Nest = nest,
|
||||
Request = request,
|
||||
}
|
||||
@@ -140,6 +166,8 @@ public static class NestRunner
|
||||
"Request parts must not contain null entries.",
|
||||
nameof(requestParts)
|
||||
);
|
||||
if (part.Quantity < 0)
|
||||
throw new ArgumentException("Part quantities must be nonnegative.", nameof(requestParts));
|
||||
var id = part.Id ?? $"part-{index}";
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
|
||||
|
||||
+125
-56
@@ -7,9 +7,9 @@ using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using OpenNest;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
@@ -21,6 +21,33 @@ static class NestConsole
|
||||
{
|
||||
public static int Run(string[] args)
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
ConsoleCancelEventHandler cancel = (_, e) => { e.Cancel = true; cancellation.Cancel(); };
|
||||
Console.CancelKeyPress += cancel;
|
||||
try
|
||||
{
|
||||
return RunCore(args, cancellation.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Console.Error.WriteLine("Nesting cancelled; nothing saved.");
|
||||
return 2;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.CancelKeyPress -= cancel;
|
||||
}
|
||||
}
|
||||
|
||||
static int RunCore(string[] args, CancellationToken token)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
NestingEngineRegistry.LoadPlugins(Path.Combine(AppContext.BaseDirectory, "Engines"));
|
||||
var options = ParseArgs(args);
|
||||
|
||||
if (options == null)
|
||||
@@ -100,6 +127,12 @@ static class NestConsole
|
||||
|
||||
var plate = nest.Plates[options.PlateIndex];
|
||||
|
||||
if (options.AutoNest && options.KeepParts && plate.Parts.Count > 0)
|
||||
{
|
||||
Console.Error.WriteLine("Error: --autonest --keep-parts cannot use an occupied plate. Use plain fill for existing obstacles.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
ApplyTemplate(plate, options);
|
||||
ApplyOverrides(plate, options);
|
||||
|
||||
@@ -110,18 +143,21 @@ static class NestConsole
|
||||
|
||||
var existingCount = plate.Parts.Count;
|
||||
|
||||
if (!options.KeepParts)
|
||||
if (!options.AutoNest && !options.KeepParts)
|
||||
plate.Parts.Clear();
|
||||
|
||||
PrintHeader(nest, plate, drawing, existingCount, options);
|
||||
|
||||
var (success, elapsed) = Fill(nest, plate, drawing, options);
|
||||
var (success, elapsed, accepted) = Fill(nest, plate, drawing, options, token);
|
||||
if (!accepted)
|
||||
return 2; // No save or post may run after a rejected proposal.
|
||||
|
||||
var overlapCount = CheckOverlaps(plate, options);
|
||||
|
||||
PrintResults(success, plate, elapsed);
|
||||
Save(nest, options);
|
||||
PostProcess(nest, options);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!SaveAndPost(nest, options))
|
||||
return 1;
|
||||
|
||||
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
|
||||
}
|
||||
@@ -185,6 +221,9 @@ static class NestConsole
|
||||
case "--template" when i + 1 < args.Length:
|
||||
o.TemplateFile = args[++i];
|
||||
break;
|
||||
case "--allow-invalid":
|
||||
o.AllowInvalid = true;
|
||||
break;
|
||||
case "--autonest":
|
||||
o.AutoNest = true;
|
||||
break;
|
||||
@@ -194,6 +233,9 @@ static class NestConsole
|
||||
case "--post" when i + 1 < args.Length:
|
||||
o.PostName = args[++i];
|
||||
break;
|
||||
case "--acknowledge-post-risks":
|
||||
o.AcknowledgePostRisks = true;
|
||||
break;
|
||||
case "--post-output" when i + 1 < args.Length:
|
||||
o.PostOutput = args[++i];
|
||||
break;
|
||||
@@ -394,6 +436,8 @@ static class NestConsole
|
||||
);
|
||||
var existingPartsMessage = options.KeepParts
|
||||
? $"Keeping {existingCount} existing parts"
|
||||
: options.AutoNest
|
||||
? $"Will replace {existingCount} existing parts only after acceptance"
|
||||
: $"Cleared {existingCount} existing parts";
|
||||
Console.WriteLine(
|
||||
$"""
|
||||
@@ -404,15 +448,17 @@ static class NestConsole
|
||||
);
|
||||
}
|
||||
|
||||
static (bool success, long elapsedMs) Fill(
|
||||
static (bool success, long elapsedMs, bool accepted) Fill(
|
||||
Nest nest,
|
||||
Plate plate,
|
||||
Drawing drawing,
|
||||
Options options
|
||||
Options options,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
bool success;
|
||||
var accepted = true;
|
||||
|
||||
if (options.AutoNest)
|
||||
{
|
||||
@@ -433,7 +479,9 @@ static class NestConsole
|
||||
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
|
||||
);
|
||||
|
||||
success = AutoNestJob(plate, nestItems, options.Engine);
|
||||
var outcome = AutoNestJob(plate, nestItems, options.Engine, options.AllowInvalid, token);
|
||||
accepted = outcome.accepted;
|
||||
success = outcome.committed > 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -447,8 +495,9 @@ static class NestConsole
|
||||
item,
|
||||
plate.WorkArea(),
|
||||
null,
|
||||
CancellationToken.None
|
||||
token
|
||||
);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (parts.Count > 0)
|
||||
plate.Parts.AddRange(parts);
|
||||
@@ -456,52 +505,45 @@ static class NestConsole
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
return (success, sw.ElapsedMilliseconds);
|
||||
return (success, sw.ElapsedMilliseconds, accepted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Solves the drawings as one whole job against this single plate using the named jobs
|
||||
/// engine, then commits the returned placements onto the plate. Placements are mapped back
|
||||
/// onto the caller's original drawings (same pose semantics as NestResultMaterializer), so
|
||||
/// the saved nest keeps its existing drawing identities.
|
||||
/// </summary>
|
||||
static bool AutoNestJob(Plate plate, List<NestItem> nestItems, string engineName)
|
||||
static (bool accepted, int committed) AutoNestJob(
|
||||
Plate plate, List<NestItem> nestItems, string engineName, bool allowInvalid, CancellationToken token)
|
||||
{
|
||||
var engine = NestingEngineRegistry.Create(engineName);
|
||||
var result = NestPipeline.Run(new NestPipelineRequest(
|
||||
engineName, nestItems, NestStockBuilder.SinglePlate(plate)), token: token);
|
||||
foreach (var violation in result.Violations)
|
||||
Console.Error.WriteLine($"Violation: {violation}");
|
||||
|
||||
var parts = new List<NestJobPart>(nestItems.Count);
|
||||
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < nestItems.Count; i++)
|
||||
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
|
||||
{
|
||||
var partId = $"part-{i}";
|
||||
parts.Add(DrawingJobMapper.FromItem(partId, nestItems[i]));
|
||||
drawingsByPartId[partId] = nestItems[i].Drawing;
|
||||
Console.Error.WriteLine(result.Plates.Count > 1
|
||||
? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved."
|
||||
: !result.CanKeep
|
||||
? "Error: result cannot be represented faithfully, even with --allow-invalid. Nothing saved."
|
||||
: "Error: invalid result discarded. Use --allow-invalid to explicitly keep its violations. Nothing saved.");
|
||||
return (false, 0);
|
||||
}
|
||||
|
||||
// One physical sheet: this plate, this solve — the runner owns stock accounting.
|
||||
var stock = DrawingJobMapper.FromPlate("plate-0", plate, 1);
|
||||
var job = new NestJob(parts, [stock]);
|
||||
|
||||
var result = engine.Solve(job, null, CancellationToken.None);
|
||||
|
||||
var committed = 0;
|
||||
foreach (var plateResult in result.Plates)
|
||||
token.ThrowIfCancellationRequested();
|
||||
var proposed = result.Plates.SingleOrDefault();
|
||||
var committed = proposed?.Parts.Count ?? 0;
|
||||
if (committed == 0)
|
||||
{
|
||||
foreach (var pose in plateResult.Placements)
|
||||
{
|
||||
if (!drawingsByPartId.TryGetValue(pose.PartId, out var drawing))
|
||||
continue;
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(pose.Rotation);
|
||||
part.Location = new Vector(pose.X, pose.Y);
|
||||
part.UpdateBounds();
|
||||
plate.Parts.Add(part);
|
||||
committed++;
|
||||
}
|
||||
Console.Error.WriteLine("Error: no placements returned. Existing layout and output left unchanged.");
|
||||
return (false, 0);
|
||||
}
|
||||
|
||||
plate.Parts.Clear();
|
||||
plate.Size = proposed.Stock.Size;
|
||||
plate.PartSpacing = proposed.Stock.PartSpacing;
|
||||
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
|
||||
plate.Quadrant = proposed.Stock.Quadrant;
|
||||
plate.Quantity = 1;
|
||||
plate.Parts.AddRange(proposed.Parts);
|
||||
Console.WriteLine($"Engine: {engineName} — committed {committed} placements");
|
||||
return committed > 0;
|
||||
return (true, committed);
|
||||
}
|
||||
|
||||
static string ResolveFillStrategy(string engineName)
|
||||
@@ -557,18 +599,16 @@ static class NestConsole
|
||||
if (options.NoSave)
|
||||
return;
|
||||
|
||||
var firstInput = options.InputFiles[0];
|
||||
var outputFile =
|
||||
options.OutputFile
|
||||
?? Path.Combine(
|
||||
Path.GetDirectoryName(firstInput),
|
||||
$"{Path.GetFileNameWithoutExtension(firstInput)}-result{NestFormat.FileExtension}"
|
||||
);
|
||||
var outputFile = NestOutputPath(options);
|
||||
|
||||
new NestWriter(nest).Write(outputFile);
|
||||
Console.WriteLine($"Saved: {outputFile}");
|
||||
}
|
||||
|
||||
static string NestOutputPath(Options options) => options.OutputFile
|
||||
?? Path.Combine(Path.GetDirectoryName(options.InputFiles[0]),
|
||||
$"{Path.GetFileNameWithoutExtension(options.InputFiles[0])}-result{NestFormat.FileExtension}");
|
||||
|
||||
static string ResolvePostsDir(Options options)
|
||||
{
|
||||
if (options.PostsDir != null)
|
||||
@@ -633,10 +673,13 @@ static class NestConsole
|
||||
Console.WriteLine($" {p.Name,-30} {p.Description}");
|
||||
}
|
||||
|
||||
static void PostProcess(Nest nest, Options options)
|
||||
static bool SaveAndPost(Nest nest, Options options)
|
||||
{
|
||||
if (options.PostName == null)
|
||||
return;
|
||||
{
|
||||
Save(nest, options);
|
||||
return true;
|
||||
}
|
||||
|
||||
var postsDir = ResolvePostsDir(options);
|
||||
var processors = LoadPostProcessors(postsDir);
|
||||
@@ -655,7 +698,15 @@ static class NestConsole
|
||||
else
|
||||
Console.Error.WriteLine($"No post processors found in: {postsDir}");
|
||||
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var verification = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
|
||||
Console.WriteLine(verification.ToDisplayText());
|
||||
if (!verification.CanPost(options.AcknowledgePostRisks))
|
||||
{
|
||||
Console.Error.WriteLine("Posting blocked: review these warnings. To accept the risks, including possible head crashes and machine or material damage, explicitly use --acknowledge-post-risks for this invocation.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var outputFile = options.PostOutput;
|
||||
@@ -669,8 +720,22 @@ static class NestConsole
|
||||
);
|
||||
}
|
||||
|
||||
var outputFiles = post is IMultiFilePostProcessor multiFile
|
||||
? multiFile.GetOutputFiles(nest, outputFile)
|
||||
: new[] { outputFile };
|
||||
|
||||
if (!options.NoSave && outputFiles.Any(file => string.Equals(
|
||||
Path.GetFullPath(file), Path.GetFullPath(NestOutputPath(options)), StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Console.Error.WriteLine("Error: nest save and CNC output paths must be different. No output was written.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Save(nest, options);
|
||||
post.Post(nest, outputFile);
|
||||
Console.WriteLine($"Post: {post.Name} -> {outputFile}");
|
||||
foreach (var file in outputFiles)
|
||||
Console.WriteLine($"Post: {post.Name} -> {file}");
|
||||
return true;
|
||||
}
|
||||
|
||||
static void PrintUsage()
|
||||
@@ -697,7 +762,8 @@ static class NestConsole
|
||||
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
|
||||
--output <path> Output nest file path (default: <input>-result.nest)
|
||||
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
|
||||
--autonest Whole-job nesting via the jobs engine (--engine) instead of single-plate fill
|
||||
--autonest Validated whole-job nesting onto one sheet; replaces only after acceptance
|
||||
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
|
||||
--engine <name> With --autonest: jobs engine (default: Default; also StockLadder, Strip, ...).
|
||||
Without --autonest: fill strategy (Default, Strip, Vertical Remnant, Horizontal Remnant)
|
||||
--keep-parts Don't clear existing parts before filling
|
||||
@@ -705,6 +771,7 @@ static class NestConsole
|
||||
--no-save Skip saving output file
|
||||
--post <name> Run a post processor after nesting
|
||||
--post-output <path> Output file for post processor (default: <input>.cnc)
|
||||
--acknowledge-post-risks Explicitly accept displayed verification risks for this invocation
|
||||
--posts-dir <path> Directory containing post processor DLLs (default: Posts/)
|
||||
--list-posts List available post processors and exit
|
||||
-h, --help Show this help
|
||||
@@ -725,9 +792,11 @@ static class NestConsole
|
||||
public bool NoSave;
|
||||
public bool KeepParts;
|
||||
public bool AutoNest;
|
||||
public bool AllowInvalid;
|
||||
public string Engine = "Default";
|
||||
public string TemplateFile;
|
||||
public string PostName;
|
||||
public bool AcknowledgePostRisks;
|
||||
public string PostOutput;
|
||||
public string PostsDir;
|
||||
public bool ListPosts;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Plans and applies the same automatic-cutoff settings to a stable, ordered set of plates.
|
||||
/// Callers must prevent concurrent edits for the duration of either operation.
|
||||
/// </summary>
|
||||
public static class AutomaticCutOffBatch
|
||||
{
|
||||
/// <summary>Detached plans in plate order. Invalid input identifies the one-based plate number.</summary>
|
||||
public static IReadOnlyList<AutomaticCutOffPlan> Create(IReadOnlyList<Plate> plates,
|
||||
AutomaticCutOffOptions options, CutOffSettings settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plates);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
if (plates.Distinct(ReferenceEqualityComparer.Instance).Count() != plates.Count)
|
||||
throw new ArgumentException("Each plate must occur only once.", nameof(plates));
|
||||
|
||||
var plans = new List<AutomaticCutOffPlan>(plates.Count);
|
||||
for (var index = 0; index < plates.Count; index++)
|
||||
{
|
||||
try
|
||||
{
|
||||
plans.Add(AutomaticCutOffPlanner.Create(plates[index], options, settings));
|
||||
}
|
||||
catch (ArgumentException error)
|
||||
{
|
||||
throw new ArgumentException($"Plate {index + 1}: {error.Message}", nameof(plates), error);
|
||||
}
|
||||
}
|
||||
return plans.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replans all plates before changing any. A blocking plan returns without applying any
|
||||
/// definitions; empty/unchanged plates are untouched. On failure, restores cutoff state
|
||||
/// on every touched plate, reporting explicitly if any restoration also fails.
|
||||
/// Returned plans describe the fresh proposal, not preview parts to accept into a plate.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<AutomaticCutOffPlan> Apply(IReadOnlyList<Plate> plates,
|
||||
AutomaticCutOffOptions options, CutOffSettings settings)
|
||||
{
|
||||
var plans = Create(plates, options, settings);
|
||||
if (plans.Any(plan => plan.HasBlockingDiagnostics))
|
||||
return plans;
|
||||
|
||||
var restoreActions = new List<(int PlateNumber, Action Restore)>();
|
||||
try
|
||||
{
|
||||
for (var index = 0; index < plates.Count; index++)
|
||||
{
|
||||
var plan = plans[index];
|
||||
if (plan.Definitions.Count == 0)
|
||||
continue;
|
||||
|
||||
var plate = plates[index];
|
||||
// Register recovery before the first observable mutation, including AddRange.
|
||||
restoreActions.Add((index + 1, CaptureRestore(plate, plan)));
|
||||
plate.CutOffs.AddRange(plan.Definitions);
|
||||
plate.RegenerateCutOffs(settings);
|
||||
}
|
||||
}
|
||||
catch (Exception applyError)
|
||||
{
|
||||
var rollbackErrors = new List<Exception>();
|
||||
for (var index = restoreActions.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var saved = restoreActions[index];
|
||||
try
|
||||
{
|
||||
saved.Restore();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
// A broken observer on one plate must not prevent recovery of the others.
|
||||
rollbackErrors.Add(new InvalidOperationException(
|
||||
$"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError));
|
||||
}
|
||||
}
|
||||
if (rollbackErrors.Count > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: "
|
||||
+ string.Join("; ", rollbackErrors.Select(e => e.Message))
|
||||
+ "\nThe nest may be incomplete; review it before saving or cutting.",
|
||||
new AggregateException(new[] { applyError }.Concat(rollbackErrors)));
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError);
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
private static Action CaptureRestore(Plate plate, AutomaticCutOffPlan plan)
|
||||
{
|
||||
// Regeneration replaces drawing programs and removes/reinserts cutoff parts.
|
||||
// Save only that state; real parts, poses, programs and quantities stay untouched.
|
||||
var programs = plate.CutOffs.Select(c => (CutOff: c, Program: c.Drawing.Program)).ToArray();
|
||||
var parts = plate.Parts.Select((part, index) => (Part: part, Index: index))
|
||||
.Where(p => p.Part.BaseDrawing.IsCutOff).ToArray();
|
||||
return () =>
|
||||
{
|
||||
foreach (var definition in plan.Definitions)
|
||||
plate.CutOffs.Remove(definition);
|
||||
for (var index = plate.Parts.Count - 1; index >= 0; index--)
|
||||
{
|
||||
if (plate.Parts[index].BaseDrawing.IsCutOff)
|
||||
plate.Parts.RemoveAt(index);
|
||||
}
|
||||
foreach (var saved in programs)
|
||||
saved.CutOff.Drawing.Program = saved.Program;
|
||||
foreach (var saved in parts)
|
||||
plate.Parts.Insert(saved.Index, saved.Part);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
public sealed class AutomaticCutOffOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Nominal distance between vertical cut lines in model units. Must be finite and
|
||||
/// greater than AutomaticCutOffPlanner.MinimumSpacing; Create also bounds candidate count.
|
||||
/// </summary>
|
||||
public double Spacing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum retained-tail length along X in model units. Zero permits any positive tail.
|
||||
/// The desktop default is 12 inches (304.8 mm).
|
||||
/// </summary>
|
||||
public double MinimumTailLength { get; set; }
|
||||
}
|
||||
|
||||
public enum AutomaticCutOffDiagnosticCode
|
||||
{
|
||||
ExistingCutOff,
|
||||
LimitedCutOffConflict,
|
||||
EmptyCut,
|
||||
SegmentedCut,
|
||||
NoSafeTailSeparator,
|
||||
TailBelowMinimum,
|
||||
}
|
||||
|
||||
public sealed record AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode Code, string Message, bool IsBlocking = false, double? X = null);
|
||||
|
||||
/// <summary>
|
||||
/// Detached proposal. Collections are read-only; contained definitions/preview parts belong
|
||||
/// to the caller. Never accept a blocked plan or a preview made for an older layout/settings.
|
||||
/// </summary>
|
||||
public sealed class AutomaticCutOffPlan
|
||||
{
|
||||
/// <summary>Only new, usable definitions; existing equivalent definitions are not returned.</summary>
|
||||
public IReadOnlyList<CutOff> Definitions { get; internal set; } = Array.Empty<CutOff>();
|
||||
|
||||
/// <summary>Detached display parts, in the same order as Definitions. Not for acceptance.</summary>
|
||||
public IReadOnlyList<Part> PreviewParts { get; internal set; } = Array.Empty<Part>();
|
||||
|
||||
public IReadOnlyList<AutomaticCutOffDiagnostic> Diagnostics { get; internal set; } =
|
||||
Array.Empty<AutomaticCutOffDiagnostic>();
|
||||
|
||||
/// <summary>Furthest real-part X distance from the origin, excluding cut-off parts.</summary>
|
||||
public double OccupiedSpan { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Distance to the verified separator, or full sheet length when no separated tail can
|
||||
/// be claimed. Zero on empty sheets. This is not a disconnected-scrap-size guarantee.
|
||||
/// </summary>
|
||||
public double UsedSpan { get; internal set; }
|
||||
|
||||
/// <summary>Length beyond a verified separator, otherwise zero (also on empty sheets).</summary>
|
||||
public double TailLength { get; internal set; }
|
||||
|
||||
/// <summary>Signed X coordinate of a verified new or equivalent existing separator.</summary>
|
||||
public double? TailSeparatorX { get; internal set; }
|
||||
|
||||
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
|
||||
public bool HasSeparatedTail => TailSeparatorX.HasValue;
|
||||
|
||||
public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking);
|
||||
}
|
||||
|
||||
/// <summary>Pure proposals for vertical scrap cuts, measured in the plate's model units.</summary>
|
||||
public static class AutomaticCutOffPlanner
|
||||
{
|
||||
public const double GeometryTolerance = Tolerance.Epsilon;
|
||||
public const double MinimumSpacing = 2 * GeometryTolerance;
|
||||
public const int MaximumCandidateCount = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Plans without changing the plate, its parts, or existing cut-off definitions/programs.
|
||||
/// Invalid inputs throw ArgumentException. Blocking diagnostics return no definitions.
|
||||
/// Accept by adding Definitions to Plate.CutOffs and calling RegenerateCutOffs with the
|
||||
/// same settings, only while the layout is unchanged. Do not add PreviewParts to the plate.
|
||||
/// Nominal spacing is not a guarantee of fully disconnected, hopper-sized scrap.
|
||||
/// </summary>
|
||||
public static AutomaticCutOffPlan Create(Plate plate, AutomaticCutOffOptions options,
|
||||
CutOffSettings settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
ValidateInputs(plate, options, settings);
|
||||
var bounds = plate.BoundingBox(false);
|
||||
Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel),
|
||||
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
|
||||
|
||||
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
|
||||
var occupied = 0.0;
|
||||
var hasParts = false;
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
Require(part?.BaseDrawing != null, "Every part must have a drawing.", nameof(plate));
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
occupied = System.Math.Max(occupied, ValidatePart(part, bounds, sign));
|
||||
hasParts = true;
|
||||
}
|
||||
if (!hasParts)
|
||||
return new AutomaticCutOffPlan();
|
||||
|
||||
// Use distances from the coordinate origin, not the sheet's lower-left corner.
|
||||
var length = plate.Size.Length;
|
||||
occupied = System.Math.Min(occupied, length);
|
||||
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance)
|
||||
+ GeometryTolerance;
|
||||
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
|
||||
var usedSpan = hasTailCandidate ? separator : length;
|
||||
var candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
|
||||
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
|
||||
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
|
||||
|
||||
// Validate and bound the candidate count before preparing geometry or allocating lines.
|
||||
ValidateExisting(plate, bounds, settings);
|
||||
var cache = Plate.BuildPerimeterCache(plate);
|
||||
var definitions = new List<CutOff>();
|
||||
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
||||
var separated = false;
|
||||
var separatorX = (double?)null;
|
||||
|
||||
// Multiplication by an integer avoids drift from repeated floating-point addition.
|
||||
for (var index = 1; index <= (int)candidateCount; index++)
|
||||
{
|
||||
var distance = index * options.Spacing;
|
||||
if (distance >= usedSpan - GeometryTolerance)
|
||||
break;
|
||||
AddCandidate(sign * distance, false);
|
||||
}
|
||||
if (hasTailCandidate)
|
||||
{
|
||||
if (length - separator >= options.MinimumTailLength)
|
||||
AddCandidate(sign * separator, true);
|
||||
else
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
|
||||
"The proposed tail is shorter than the minimum tail length; the final separator was skipped. "
|
||||
+ "Other automatic lines and existing cut-offs are unchanged. No retained tail is claimed.",
|
||||
X: sign * separator));
|
||||
}
|
||||
|
||||
if (diagnostics.Any(d => d.IsBlocking))
|
||||
{
|
||||
// A partial proposal must not accidentally be accepted around a manual conflict.
|
||||
definitions.Clear();
|
||||
separated = false;
|
||||
separatorX = null;
|
||||
}
|
||||
|
||||
return new AutomaticCutOffPlan
|
||||
{
|
||||
Definitions = definitions.AsReadOnly(),
|
||||
PreviewParts = definitions.Select(c => new Part(c.Drawing)).ToList().AsReadOnly(),
|
||||
Diagnostics = diagnostics.AsReadOnly(),
|
||||
OccupiedSpan = occupied,
|
||||
UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length,
|
||||
TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0,
|
||||
TailSeparatorX = separatorX,
|
||||
};
|
||||
|
||||
void AddCandidate(double x, bool isSeparator)
|
||||
{
|
||||
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
|
||||
Near(c.Position.X, x)).ToList();
|
||||
if (matches.Any(c => !FullSpanLimits(c, bounds, settings)))
|
||||
{
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
|
||||
"A same-line manual cut has different limits. Manual review is required; no cuts may be applied.",
|
||||
true, x));
|
||||
if (isSeparator)
|
||||
WarnNoSeparator(x);
|
||||
return;
|
||||
}
|
||||
|
||||
// Even a duplicate must be regenerated detached with CURRENT settings. Its live
|
||||
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
|
||||
var existing = matches.FirstOrDefault();
|
||||
var candidate = existing == null
|
||||
? new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
|
||||
: new CutOff(existing.Position, existing.Axis)
|
||||
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
|
||||
candidate.Regenerate(plate, settings, cache);
|
||||
var program = candidate.Drawing.Program;
|
||||
var usable = HasUsableSegments(program);
|
||||
if (existing != null)
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.ExistingCutOff,
|
||||
"An equivalent full-span cut-off already exists; no duplicate was added.", X: x));
|
||||
if (!usable)
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.EmptyCut,
|
||||
"The line has no usable cut segments after part clearance and minimum-length filtering; it is not a partition.",
|
||||
X: x));
|
||||
|
||||
if (isSeparator)
|
||||
{
|
||||
if (!usable || !IsFullSpanProgram(program, bounds))
|
||||
{
|
||||
WarnNoSeparator(x);
|
||||
return;
|
||||
}
|
||||
separated = true;
|
||||
separatorX = candidate.Position.X;
|
||||
}
|
||||
else if (usable && !IsFullSpanProgram(program, bounds))
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.SegmentedCut,
|
||||
"Part clearance or segment filtering interrupts this line; nominal spacing does not guarantee disconnected scrap.",
|
||||
X: x));
|
||||
|
||||
if (usable && existing == null)
|
||||
definitions.Add(candidate);
|
||||
}
|
||||
|
||||
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
|
||||
"No safe full-width tail separator survives the current settings. No separated tail is claimed; review manually.",
|
||||
X: x));
|
||||
}
|
||||
|
||||
private static void ValidateInputs(Plate plate, AutomaticCutOffOptions options, CutOffSettings settings)
|
||||
{
|
||||
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
|
||||
$"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options));
|
||||
Require(Nonnegative(options.MinimumTailLength),
|
||||
"Minimum tail length must be finite and nonnegative.", nameof(options));
|
||||
Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 &&
|
||||
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
|
||||
"Sheet length and width must be positive and finite.", nameof(plate));
|
||||
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate));
|
||||
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
|
||||
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
|
||||
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
|
||||
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
|
||||
Require(plate.Parts != null && plate.CutOffs != null,
|
||||
"Plate parts and cut-off collections are required.", nameof(plate));
|
||||
}
|
||||
|
||||
private static double ValidatePart(Part part, Box sheet, int sign)
|
||||
{
|
||||
Require(Finite(part.Location) && double.IsFinite(part.Rotation),
|
||||
"Part pose must be finite.", "plate");
|
||||
ValidateProgram(part.Program, new HashSet<Program>());
|
||||
var box = part.Program.BoundingBox();
|
||||
box.Offset(part.Location);
|
||||
Require(ValidBox(box) && box.Length > 0 && box.Width > 0,
|
||||
"Real parts must have finite, nonempty geometry.", "plate");
|
||||
var cached = part.BoundingBox;
|
||||
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
|
||||
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
|
||||
"Part geometry has stale bounds; update it before planning.", "plate");
|
||||
Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate");
|
||||
|
||||
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
|
||||
// Checking converted entities catches overflowing incremental moves and curve bounds.
|
||||
var hasMaterial = false;
|
||||
var occupied = sign > 0 ? box.Right : -box.Left;
|
||||
var roundoff = CutOff.GetBoundsRoundoff(part);
|
||||
foreach (var entity in ConvertProgram.ToGeometry(part.Program))
|
||||
{
|
||||
var entityBox = entity.BoundingBox;
|
||||
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
|
||||
if (!SpecialLayers.IsMaterial(entity.Layer))
|
||||
continue;
|
||||
entityBox = entityBox.Translate(part.Location);
|
||||
// Permit only bounded floating-point roundoff, with the same conservative
|
||||
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions
|
||||
// (including refitted arc centers) are still rejected, even below epsilon.
|
||||
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
|
||||
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
|
||||
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
|
||||
Require(Inside(entityBox, sheet), "Part geometry extends outside the physical sheet.", "plate");
|
||||
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
|
||||
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
|
||||
}
|
||||
Require(hasMaterial, "Real parts must contain material geometry.", "plate");
|
||||
return occupied;
|
||||
}
|
||||
|
||||
private static void ValidateProgram(Program program, HashSet<Program> path)
|
||||
{
|
||||
Require(program?.Codes != null && path.Count < 64 && path.Add(program),
|
||||
"Part program is missing, recursive, or nested too deeply.", "plate");
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
Require(code != null, "Part program contains a missing instruction.", "plate");
|
||||
if (code is Motion motion)
|
||||
Require(Finite(motion.EndPoint), "Part motion coordinates must be finite.", "plate");
|
||||
if (code is ArcMove arc)
|
||||
Require(Finite(arc.CenterPoint) && Enum.IsDefined(arc.Rotation),
|
||||
"Part arc geometry must be finite with a valid direction.", "plate");
|
||||
if (code is SubProgramCall call)
|
||||
{
|
||||
Require(Finite(call.Offset) && double.IsFinite(call.Rotation),
|
||||
"Part sub-program pose must be finite.", "plate");
|
||||
ValidateProgram(call.Program, path);
|
||||
}
|
||||
}
|
||||
path.Remove(program);
|
||||
}
|
||||
|
||||
private static void ValidateExisting(Plate plate, Box bounds, CutOffSettings settings)
|
||||
{
|
||||
foreach (var cut in plate.CutOffs)
|
||||
{
|
||||
Require(cut != null && Enum.IsDefined(cut.Axis) && Finite(cut.Position) &&
|
||||
(!cut.StartLimit.HasValue || double.IsFinite(cut.StartLimit.Value)) &&
|
||||
(!cut.EndLimit.HasValue || double.IsFinite(cut.EndLimit.Value)),
|
||||
"Existing cut-off definitions must be finite with a valid axis.", nameof(plate));
|
||||
var start = cut.StartLimit ?? (cut.Axis == CutOffAxis.Vertical ? bounds.Bottom : bounds.Left);
|
||||
var end = cut.EndLimit ?? ((cut.Axis == CutOffAxis.Vertical ? bounds.Top : bounds.Right)
|
||||
+ settings.Overtravel);
|
||||
Require(double.IsFinite(end) && start < end,
|
||||
"Existing cut-off limits must be finite and ordered.", nameof(plate));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
||||
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
|
||||
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
|
||||
|
||||
private static bool HasUsableSegments(Program program)
|
||||
{
|
||||
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
|
||||
return false;
|
||||
for (var i = 0; i < program.Codes.Count; i += 2)
|
||||
{
|
||||
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
|
||||
!Finite(from.EndPoint) || !Finite(to.EndPoint) ||
|
||||
!Near(from.EndPoint.X, to.EndPoint.X) ||
|
||||
System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y) <= GeometryTolerance)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsFullSpanProgram(Program program, Box bounds)
|
||||
{
|
||||
// No gaps, bridges, or filtered middle segments can separate the tail.
|
||||
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
|
||||
program.Codes[1] is not LinearMove to)
|
||||
return false;
|
||||
return System.Math.Min(from.EndPoint.Y, to.EndPoint.Y) <= bounds.Bottom + GeometryTolerance &&
|
||||
System.Math.Max(from.EndPoint.Y, to.EndPoint.Y) >= bounds.Top - GeometryTolerance;
|
||||
}
|
||||
|
||||
private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
|
||||
Nonnegative(box.Length) && Nonnegative(box.Width) &&
|
||||
double.IsFinite(box.Right) && double.IsFinite(box.Top);
|
||||
|
||||
private static bool Inside(Box box, Box sheet) =>
|
||||
box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance &&
|
||||
box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance;
|
||||
|
||||
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
|
||||
private static bool Near(double a, double b)
|
||||
{
|
||||
// An exact tolerance-sized offset can round just above epsilon after subtraction.
|
||||
// Allow two representational steps, not a geometry-scale relative tolerance.
|
||||
var magnitude = System.Math.Max(System.Math.Abs(a), System.Math.Abs(b));
|
||||
var step = System.Math.BitIncrement(magnitude) - magnitude;
|
||||
return System.Math.Abs(a - b) <= GeometryTolerance + (double.IsFinite(step) ? 2 * step : 0);
|
||||
}
|
||||
|
||||
private static void Require(bool valid, string message, string parameter)
|
||||
{
|
||||
if (!valid)
|
||||
throw new ArgumentException(message, parameter);
|
||||
}
|
||||
}
|
||||
@@ -367,19 +367,26 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return;
|
||||
}
|
||||
|
||||
program.Codes.AddRange(leadIn.Generate(point, normal, winding));
|
||||
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
||||
Parameters.PierceClearance, out var leadInNormal);
|
||||
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
|
||||
|
||||
var reindexedShape = shape.ReindexAt(point, entity);
|
||||
|
||||
if (
|
||||
Parameters.TabsEnabled
|
||||
var tabbed = Parameters.TabsEnabled
|
||||
&& Parameters.TabConfig != null
|
||||
&& contourType == ContourType.External
|
||||
)
|
||||
&& contourType == ContourType.External;
|
||||
if (tabbed)
|
||||
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
||||
|
||||
// A tab leaves the contour short of the corner; a run-out through it would cut the tab.
|
||||
var leadOutNormal = normal;
|
||||
if (!tabbed)
|
||||
leadOut = ResolveLeadOut(shape, point, entity, contourType, leadOut, winding,
|
||||
Parameters.PierceClearance, out leadOutNormal);
|
||||
|
||||
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||
program.Codes.AddRange(leadOut.Generate(point, normal, winding));
|
||||
program.Codes.AddRange(leadOut.Generate(point, leadOutNormal, winding));
|
||||
}
|
||||
|
||||
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
||||
@@ -436,6 +443,259 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return ContourType.Internal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses the inward angle bisector for straight lead-ins at cutout corners.
|
||||
/// Edge interiors and other lead-in styles keep the entity normal. Shared
|
||||
/// by program generation and the manual placement preview.
|
||||
/// </summary>
|
||||
public static double ComputeLeadInNormal(
|
||||
Shape shape,
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType contourType,
|
||||
LeadIn leadIn,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var normal = ComputeNormal(point, entity, contourType, winding);
|
||||
if (contourType != ContourType.Internal || leadIn is not LineLeadIn
|
||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||
return normal;
|
||||
|
||||
return BisectCorner(point, corner, contourType, winding) ?? normal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the lead-in to emit at <paramref name="point"/> and the normal to
|
||||
/// generate it with. At a corner of an outside perimeter, a straight
|
||||
/// (<see cref="LineLeadIn"/>) lead-in extends the edge cut first so the torch
|
||||
/// enters on that edge's line, provided the pierce keeps
|
||||
/// <paramref name="pierceClearance"/> from the contour; the approach angle is
|
||||
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a
|
||||
/// reflex corner it bisects the notch. The result does not depend on which of
|
||||
/// the two edges meeting at the corner was picked. Other styles and contour
|
||||
/// types keep <see cref="ComputeLeadInNormal"/>.
|
||||
/// </summary>
|
||||
public static LeadIn ResolveLeadIn(
|
||||
Shape shape,
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType contourType,
|
||||
LeadIn leadIn,
|
||||
RotationType winding,
|
||||
double pierceClearance,
|
||||
out double normal
|
||||
)
|
||||
{
|
||||
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
|
||||
if (contourType != ContourType.External || leadIn is not LineLeadIn line
|
||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||
return leadIn;
|
||||
|
||||
switch (ClassifyCorner(corner, winding))
|
||||
{
|
||||
case CornerKind.Convex:
|
||||
var pierce = point - corner.TangentOut * line.Length;
|
||||
if (IsClearStraightLead(shape, point, pierce, pierceClearance))
|
||||
{
|
||||
normal = Angle.NormalizeRad((-corner.TangentOut).Angle());
|
||||
return new LineLeadIn { Length = line.Length, ApproachAngle = 90 };
|
||||
}
|
||||
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||
return leadIn;
|
||||
case CornerKind.Smooth:
|
||||
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||
return leadIn;
|
||||
case CornerKind.Reflex:
|
||||
normal = BisectCorner(point, corner, contourType, winding) ?? normal;
|
||||
return leadIn;
|
||||
default:
|
||||
return leadIn;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lead-out counterpart of <see cref="ResolveLeadIn"/>. At a convex outside
|
||||
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner
|
||||
/// along the edge cut last, when its end keeps <paramref name="clearance"/> from
|
||||
/// the contour; otherwise it is perpendicular to that edge. At a reflex corner it
|
||||
/// bisects the notch. Other styles and contour types keep the entity normal.
|
||||
/// </summary>
|
||||
public static LeadOut ResolveLeadOut(
|
||||
Shape shape,
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType contourType,
|
||||
LeadOut leadOut,
|
||||
RotationType winding,
|
||||
double clearance,
|
||||
out double normal
|
||||
)
|
||||
{
|
||||
normal = ComputeNormal(point, entity, contourType, winding);
|
||||
if (contourType != ContourType.External || leadOut is not LineLeadOut line
|
||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||
return leadOut;
|
||||
|
||||
switch (ClassifyCorner(corner, winding))
|
||||
{
|
||||
case CornerKind.Convex:
|
||||
var end = point + corner.TangentIn * line.Length;
|
||||
if (IsClearStraightLead(shape, point, end, clearance))
|
||||
{
|
||||
normal = Angle.NormalizeRad(corner.TangentIn.Angle());
|
||||
return new LineLeadOut { Length = line.Length, ApproachAngle = 90 };
|
||||
}
|
||||
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||
return leadOut;
|
||||
case CornerKind.Smooth:
|
||||
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||
return leadOut;
|
||||
case CornerKind.Reflex:
|
||||
normal = BisectCorner(point, corner, contourType, winding) ?? normal;
|
||||
return leadOut;
|
||||
default:
|
||||
return leadOut;
|
||||
}
|
||||
}
|
||||
|
||||
private enum CornerKind
|
||||
{
|
||||
Convex,
|
||||
Reflex,
|
||||
Smooth,
|
||||
Cusp,
|
||||
}
|
||||
|
||||
/// <summary>A contour vertex: the entity cut into it and the one cut away from it.</summary>
|
||||
private readonly record struct ContourCorner(
|
||||
Entity Incoming,
|
||||
Entity Outgoing,
|
||||
Vector TangentIn,
|
||||
Vector TangentOut
|
||||
);
|
||||
|
||||
private static bool TryGetCorner(Shape shape, Vector point, Entity entity, out ContourCorner corner)
|
||||
{
|
||||
corner = default;
|
||||
if (entity is not (Line or Arc) || entity.Length <= Tolerance.Epsilon
|
||||
|| shape.Entities.Count < 2 || !shape.IsClosed())
|
||||
return false;
|
||||
|
||||
var index = shape.Entities.IndexOf(entity);
|
||||
if (index < 0)
|
||||
return false;
|
||||
|
||||
var atStart = point.DistanceTo(EntityStartPoint(entity)) <= Tolerance.Epsilon;
|
||||
if (!atStart && point.DistanceTo(EntityEndPoint(entity)) > Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
var adjacentIndex = atStart
|
||||
? (index + shape.Entities.Count - 1) % shape.Entities.Count
|
||||
: (index + 1) % shape.Entities.Count;
|
||||
var adjacent = shape.Entities[adjacentIndex];
|
||||
var adjacentPoint = atStart ? EntityEndPoint(adjacent) : EntityStartPoint(adjacent);
|
||||
|
||||
if (adjacent is not (Line or Arc) || adjacent.Length <= Tolerance.Epsilon
|
||||
|| point.DistanceTo(adjacentPoint) > Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
var incoming = atStart ? adjacent : entity;
|
||||
var outgoing = atStart ? entity : adjacent;
|
||||
var tangentIn = TravelTangent(incoming, point);
|
||||
var tangentOut = TravelTangent(outgoing, point);
|
||||
if (!IsFinite(tangentIn) || !IsFinite(tangentOut))
|
||||
return false;
|
||||
|
||||
corner = new ContourCorner(incoming, outgoing, tangentIn, tangentOut);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Unit direction of travel along a line or arc at a point on it.</summary>
|
||||
private static Vector TravelTangent(Entity entity, Vector point)
|
||||
{
|
||||
if (entity is Line line)
|
||||
return (line.EndPoint - line.StartPoint).Normalize();
|
||||
|
||||
var arc = (Arc)entity;
|
||||
var radial = (point - arc.Center).Normalize();
|
||||
return arc.IsReversed ? new Vector(radial.Y, -radial.X) : new Vector(-radial.Y, radial.X);
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector v) => double.IsFinite(v.X) && double.IsFinite(v.Y);
|
||||
|
||||
/// <summary>
|
||||
/// Convex corners point away from the part (interior angle under 180 degrees).
|
||||
/// A turn whose offset over the tangent is within chaining tolerance is smooth,
|
||||
/// not a corner.
|
||||
/// </summary>
|
||||
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding)
|
||||
{
|
||||
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X;
|
||||
var dot = corner.TangentIn.DotProduct(corner.TangentOut);
|
||||
var turn = winding == RotationType.CCW ? cross : -cross;
|
||||
|
||||
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
|
||||
return dot > 0 ? CornerKind.Smooth : CornerKind.Cusp;
|
||||
|
||||
return turn > 0 ? CornerKind.Convex : CornerKind.Reflex;
|
||||
}
|
||||
|
||||
private static double? BisectCorner(
|
||||
Vector point,
|
||||
ContourCorner corner,
|
||||
ContourType contourType,
|
||||
RotationType winding
|
||||
)
|
||||
{
|
||||
var normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||
var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||
// Sum unit normals rather than averaging angles (which fails at 0/2π).
|
||||
// Winding makes this point into the scrap even at reflex corners.
|
||||
var x = System.Math.Cos(normal) + System.Math.Cos(adjacentNormal);
|
||||
var y = System.Math.Sin(normal) + System.Math.Sin(adjacentNormal);
|
||||
if (!double.IsFinite(x) || !double.IsFinite(y)
|
||||
|| x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
|
||||
return null; // Opposing normals at a cusp have no unique bisector.
|
||||
|
||||
return Angle.NormalizeRad(System.Math.Atan2(y, x));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A straight lead from <paramref name="end"/> to the corner stays in the scrap:
|
||||
/// its free end keeps <paramref name="clearance"/> from the contour and the lead
|
||||
/// crosses the contour nowhere but at the corner.
|
||||
/// </summary>
|
||||
private static bool IsClearStraightLead(Shape shape, Vector corner, Vector end, double clearance)
|
||||
{
|
||||
if (!IsFinite(end) || end.DistanceTo(corner) <= Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
var nearest = shape.ClosestPointTo(end, out _);
|
||||
if (nearest.DistanceTo(end) < System.Math.Max(clearance, 0) - Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
if (shape.Intersects(new Line(end, corner), out var crossings))
|
||||
{
|
||||
foreach (var crossing in crossings)
|
||||
{
|
||||
if (crossing.DistanceTo(corner) > Tolerance.ChainTolerance)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Vector EntityEndPoint(Entity entity)
|
||||
{
|
||||
if (entity is Line line)
|
||||
return line.EndPoint;
|
||||
if (entity is Arc arc)
|
||||
return arc.EndPoint();
|
||||
return Vector.Invalid;
|
||||
}
|
||||
|
||||
public static double ComputeNormal(
|
||||
Vector point,
|
||||
Entity entity,
|
||||
|
||||
@@ -90,6 +90,9 @@ namespace OpenNest.CNC
|
||||
|
||||
SetModeAbs();
|
||||
|
||||
// Several calls can share one sub-program (identical holes); rotate each once.
|
||||
var rotatedSubPrograms = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
for (int i = 0; i < Codes.Count; ++i)
|
||||
{
|
||||
var code = Codes[i];
|
||||
@@ -110,7 +113,7 @@ namespace OpenNest.CNC
|
||||
);
|
||||
}
|
||||
|
||||
if (subpgm.Program != null)
|
||||
if (subpgm.Program != null && rotatedSubPrograms.Add(subpgm.Program))
|
||||
subpgm.Program.Rotate(angle, origin);
|
||||
}
|
||||
|
||||
@@ -519,8 +522,31 @@ namespace OpenNest.CNC
|
||||
foreach (var kvp in Variables)
|
||||
pgm.Variables[kvp.Key] = kvp.Value;
|
||||
|
||||
// The copy owns its sub-programs: rotating it must never turn the source's holes.
|
||||
// Calls that shared one sub-program keep sharing one copy.
|
||||
Dictionary<Program, Program> subCopies = null;
|
||||
|
||||
Program CopyOf(Program sub)
|
||||
{
|
||||
subCopies ??= new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
if (!subCopies.TryGetValue(sub, out var copy))
|
||||
{
|
||||
copy = (Program)sub.Clone();
|
||||
subCopies[sub] = copy;
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
foreach (var kvp in SubPrograms)
|
||||
pgm.SubPrograms[kvp.Key] = (Program)kvp.Value.Clone();
|
||||
pgm.SubPrograms[kvp.Key] = CopyOf(kvp.Value);
|
||||
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is SubProgramCall call && call.Program != null)
|
||||
call.BindProgram(CopyOf(call.Program));
|
||||
}
|
||||
|
||||
return pgm;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace OpenNest.CNC;
|
||||
|
||||
/// <summary>
|
||||
/// A character range in generated G-code assigned by one highlighting rule.
|
||||
/// </summary>
|
||||
/// <param name="Index">Zero-based UTF-16 index of the first colored character.</param>
|
||||
/// <param name="Length">Number of UTF-16 characters to color.</param>
|
||||
/// <param name="RuleIndex">
|
||||
/// Zero-based rule index: 0 comments, 1 motion modes (G90/G91), 2 rapid moves (G00),
|
||||
/// 3 linear moves (G01), 4 arcs (G02/G03).
|
||||
/// </param>
|
||||
public readonly record struct HighlightSpan(int Index, int Length, int RuleIndex);
|
||||
|
||||
/// <summary>Computes cosmetic G-code highlight spans without changing the generated text.</summary>
|
||||
public static class ProgramHighlighting
|
||||
{
|
||||
private static readonly Regex[] Rules =
|
||||
{
|
||||
new(@"^;.*$", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||
new(@"^G9[01]\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||
new(@"^G00\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||
new(@"^G01\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||
new(@"^G0[23]\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Materializes all matches in rule/application order (later rules overwrite earlier ones).
|
||||
/// A timeout propagates before any result is published; no partial span list escapes.
|
||||
/// The timeout is per regex match, not a deadline for the complete operation.
|
||||
/// </summary>
|
||||
/// <exception cref="RegexMatchTimeoutException">A rule exceeded its match budget.</exception>
|
||||
public static IReadOnlyList<HighlightSpan> ComputeSpans(string text)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
var spans = new List<HighlightSpan>();
|
||||
for (var ruleIndex = 0; ruleIndex < Rules.Length; ruleIndex++)
|
||||
{
|
||||
// MatchCollection is lazy: enumerate every rule before returning any spans.
|
||||
foreach (Match match in Rules[ruleIndex].Matches(text))
|
||||
spans.Add(new HighlightSpan(match.Index, match.Length, ruleIndex));
|
||||
}
|
||||
return spans.AsReadOnly();
|
||||
}
|
||||
}
|
||||
@@ -7,20 +7,42 @@ namespace OpenNest.CNC
|
||||
{
|
||||
public readonly record struct Segment(Vector From, Vector To);
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates plate rapids in cutting order, advancing through all cutting
|
||||
/// motions before connecting to the next part (including scrap cutoffs).
|
||||
/// </summary>
|
||||
public static List<Segment> Enumerate(IEnumerable<Part> parts)
|
||||
{
|
||||
var results = new List<Segment>();
|
||||
var pos = Vector.Zero;
|
||||
|
||||
foreach (var part in parts)
|
||||
pos = AppendProgram(part.Program, part.Location, pos, results);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public static List<Segment> Enumerate(Program pgm, Vector basePos, Vector startPos)
|
||||
{
|
||||
var results = new List<Segment>();
|
||||
AppendProgram(pgm, basePos, startPos, results);
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
|
||||
{
|
||||
// Draw the rapid from the previous tool position to the program's first
|
||||
// pierce point. This also primes pos so the interior walk interprets
|
||||
// Incremental deltas from the correct absolute location (basePos), which
|
||||
// matters for raw pre-lead-in programs that are emitted Incremental.
|
||||
// pierce point. The walk then starts at the program origin (basePos), not
|
||||
// the pierce: the skipped first rapid still advances pos, so starting at
|
||||
// the pierce would apply a nonzero Incremental first delta twice (as in
|
||||
// lead-in programs) and shift every later rapid by it.
|
||||
var firstPierce = FirstPiercePoint(pgm, basePos);
|
||||
results.Add(new Segment(startPos, firstPierce));
|
||||
|
||||
var pos = firstPierce;
|
||||
var pos = basePos;
|
||||
Walk(pgm, basePos, ref pos, skipFirst: true, results);
|
||||
return results;
|
||||
// The last rapid ends at a pierce, not necessarily the final tool position.
|
||||
return pos;
|
||||
}
|
||||
|
||||
private static Vector FirstPiercePoint(Program pgm, Vector basePos)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
@@ -79,12 +79,28 @@ namespace OpenNest.CNC
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a shallow copy.
|
||||
/// Gets a shallow copy that references the same program. Copies the fields
|
||||
/// directly: going through the setters would re-align (rotate) the shared program.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public ICode Clone()
|
||||
{
|
||||
return new SubProgramCall(program, Rotation) { Id = Id, Offset = Offset };
|
||||
return new SubProgramCall
|
||||
{
|
||||
program = program,
|
||||
rotation = rotation,
|
||||
Id = Id,
|
||||
Offset = Offset,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points the call at <paramref name="copy"/>, a copy of its current program, without
|
||||
/// re-aligning its rotation: the copy already has the geometry the call executes.
|
||||
/// </summary>
|
||||
internal void BindProgram(Program copy)
|
||||
{
|
||||
program = copy;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
|
||||
@@ -135,8 +135,15 @@ namespace OpenNest
|
||||
)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
var roundoff = GetBoundsRoundoff(part);
|
||||
var (partMin, partMax) = AxisBounds(bb, clearance);
|
||||
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
|
||||
// Match the planner's representational allowance in BOTH pruning and
|
||||
// fallback exclusions. Tolerating it only in validation could cut an edge.
|
||||
partMin -= roundoff;
|
||||
partMax += roundoff;
|
||||
partStart -= roundoff;
|
||||
partEnd += roundoff;
|
||||
|
||||
if (cutPosition < partMin || cutPosition > partMax)
|
||||
return EmptyExclusions;
|
||||
@@ -157,6 +164,25 @@ namespace OpenNest
|
||||
return new List<(double Start, double End)> { (partStart, partEnd) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounds reconstructed as (local minimum + placement) + size can differ
|
||||
/// from translated material endpoints by a few floating-point steps. This
|
||||
/// is not a geometry tolerance: cap it well below epsilon so inconsistent
|
||||
/// arcs still require repair. Do not change shared Part bounds or programs.
|
||||
/// </summary>
|
||||
internal static double GetBoundsRoundoff(Part part)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
var magnitude = System.Math.Max(System.Math.Abs(bb.Left), System.Math.Abs(bb.Right));
|
||||
magnitude = System.Math.Max(magnitude,
|
||||
System.Math.Max(System.Math.Abs(bb.Bottom), System.Math.Abs(bb.Top)));
|
||||
magnitude = System.Math.Max(magnitude, System.Math.Max(bb.Length, bb.Width));
|
||||
magnitude = System.Math.Max(magnitude,
|
||||
System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y)));
|
||||
var step = System.Math.BitIncrement(magnitude) - magnitude;
|
||||
return double.IsFinite(step) ? System.Math.Min(8 * step, Math.Tolerance.Epsilon / 4) : 0;
|
||||
}
|
||||
|
||||
private List<(double Start, double End)> IntersectPerimeter(
|
||||
Entity perimeter,
|
||||
double cutPosition,
|
||||
@@ -191,6 +217,15 @@ namespace OpenNest
|
||||
if (coords.Count % 2 != 0)
|
||||
return null;
|
||||
|
||||
// Intersects reports both incident edges at a shared vertex. Such hits may be
|
||||
// crossings or tangencies, so neither pairing nor deduplicating them preserves
|
||||
// inside/outside parity. Fall back to the clearance-expanded part bounds.
|
||||
for (var i = 1; i < coords.Count; i++)
|
||||
{
|
||||
if (coords[i] - coords[i - 1] <= Math.Tolerance.Epsilon)
|
||||
return null;
|
||||
}
|
||||
|
||||
var padding = usedOffset ? 0 : clearance;
|
||||
var result = new List<(double Start, double End)>();
|
||||
for (var i = 0; i < coords.Count; i += 2)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
public enum OverlapAutoCheckStep { None, Wait, Check }
|
||||
|
||||
/// <summary>
|
||||
/// UI-thread debounce policy for automatic overlap rechecks; the host owns the timer.
|
||||
/// Restart the quiet-period timer whenever <see cref="Observe"/> returns true, and call
|
||||
/// <see cref="Elapsed"/> when it fires. A check starts only after the ordered layout stamp
|
||||
/// has stayed unchanged for a whole quiet period with no interaction in progress.
|
||||
/// A canceled or failed request is not retried until the layout differs from the one it
|
||||
/// started from, so cancellation is respected and a failing layout cannot loop.
|
||||
/// </summary>
|
||||
public sealed class OverlapAutoCheckScheduler
|
||||
{
|
||||
private OverlapGeometryStamp pending;
|
||||
private OverlapGeometryStamp lastRequest;
|
||||
|
||||
public bool IsWaiting => pending != null;
|
||||
|
||||
/// <summary>
|
||||
/// Call after the report state's freshness check. True means (re)start the quiet-period
|
||||
/// timer; false means leave it as it is.
|
||||
/// </summary>
|
||||
public bool Observe(Plate plate, OverlapReportState state)
|
||||
{
|
||||
if (plate == null || !NeedsCheck(plate, state))
|
||||
{
|
||||
pending = null;
|
||||
return false;
|
||||
}
|
||||
if (pending != null && pending.Matches(plate))
|
||||
return false;
|
||||
pending = OverlapGeometryStamp.Capture(plate);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call when the quiet period ends. <see cref="OverlapAutoCheckStep.Wait"/> means restart the
|
||||
/// timer (the layout moved or an interaction is still running); only
|
||||
/// <see cref="OverlapAutoCheckStep.Check"/> starts a request.
|
||||
/// </summary>
|
||||
public OverlapAutoCheckStep Elapsed(Plate plate, OverlapReportState state, bool interactionActive)
|
||||
{
|
||||
if (pending == null)
|
||||
return OverlapAutoCheckStep.None;
|
||||
if (plate == null || !NeedsCheck(plate, state))
|
||||
{
|
||||
pending = null;
|
||||
return OverlapAutoCheckStep.None;
|
||||
}
|
||||
if (interactionActive || !pending.Matches(plate))
|
||||
{
|
||||
pending = OverlapGeometryStamp.Capture(plate);
|
||||
return OverlapAutoCheckStep.Wait;
|
||||
}
|
||||
pending = null;
|
||||
return OverlapAutoCheckStep.Check;
|
||||
}
|
||||
|
||||
/// <summary>Record every request start, manual or automatic.</summary>
|
||||
public void Started(Plate plate)
|
||||
{
|
||||
pending = null;
|
||||
lastRequest = OverlapGeometryStamp.Capture(plate);
|
||||
}
|
||||
|
||||
/// <summary>Forget pending and previous requests (plate switch, handle loss, disable).</summary>
|
||||
public void Reset()
|
||||
{
|
||||
pending = null;
|
||||
lastRequest = null;
|
||||
}
|
||||
|
||||
private bool NeedsCheck(Plate plate, OverlapReportState state) => state.Status switch
|
||||
{
|
||||
OverlapCheckStatus.NotChecked or OverlapCheckStatus.Stale => true,
|
||||
OverlapCheckStatus.Canceled or OverlapCheckStatus.Failed => lastRequest?.Matches(plate) != true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap ordered identity/pose check, not a geometry hash. In-place geometry editors must
|
||||
/// explicitly invalidate before mutating. Capture and match only on the model's UI thread.
|
||||
/// </summary>
|
||||
public sealed class OverlapGeometryStamp
|
||||
{
|
||||
private readonly Plate plate;
|
||||
private readonly Entry[] entries;
|
||||
|
||||
private OverlapGeometryStamp(Plate plate)
|
||||
{
|
||||
this.plate = plate;
|
||||
entries = new Entry[plate.Parts.Count];
|
||||
for (var i = 0; i < entries.Length; i++)
|
||||
entries[i] = new Entry(plate.Parts[i]);
|
||||
}
|
||||
|
||||
public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
|
||||
|
||||
public bool Matches(Plate current)
|
||||
{
|
||||
if (!ReferenceEquals(plate, current) || current.Parts.Count != entries.Length)
|
||||
return false;
|
||||
for (var i = 0; i < entries.Length; i++)
|
||||
if (!entries[i].Matches(current.Parts[i]))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private readonly struct Entry
|
||||
{
|
||||
private readonly Part part;
|
||||
private readonly Drawing drawing;
|
||||
private readonly Program placedProgram;
|
||||
private readonly Program cleanProgram;
|
||||
private readonly long x, y, rotation;
|
||||
private readonly bool isCutOff;
|
||||
|
||||
public Entry(Part part)
|
||||
{
|
||||
this.part = part;
|
||||
drawing = part.BaseDrawing;
|
||||
placedProgram = part.Program;
|
||||
cleanProgram = drawing.Program;
|
||||
x = BitConverter.DoubleToInt64Bits(part.Location.X);
|
||||
y = BitConverter.DoubleToInt64Bits(part.Location.Y);
|
||||
rotation = BitConverter.DoubleToInt64Bits(part.Rotation);
|
||||
isCutOff = drawing.IsCutOff;
|
||||
}
|
||||
|
||||
public bool Matches(Part current) =>
|
||||
ReferenceEquals(part, current)
|
||||
&& ReferenceEquals(drawing, current.BaseDrawing)
|
||||
&& ReferenceEquals(placedProgram, current.Program)
|
||||
&& ReferenceEquals(cleanProgram, current.BaseDrawing.Program)
|
||||
&& x == BitConverter.DoubleToInt64Bits(current.Location.X)
|
||||
&& y == BitConverter.DoubleToInt64Bits(current.Location.Y)
|
||||
&& rotation == BitConverter.DoubleToInt64Bits(current.Rotation)
|
||||
&& isCutOff == current.BaseDrawing.IsCutOff;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Viewport-sized pages of cached overlap details, including continuation pages for a
|
||||
/// single long pair. The caller supplies its actual single-line font measurement.
|
||||
/// No names or numeric details are elided, and no geometry is queried here.
|
||||
/// </summary>
|
||||
public sealed class OverlapHoverPages
|
||||
{
|
||||
private readonly IReadOnlyList<string>[] pages;
|
||||
private readonly IReadOnlyList<string>[] navigation;
|
||||
|
||||
private OverlapHoverPages(IReadOnlyList<string>[] pages, IReadOnlyList<string>[] navigation,
|
||||
bool needsLargerViewport = false)
|
||||
{
|
||||
this.pages = pages;
|
||||
this.navigation = navigation;
|
||||
NeedsLargerViewport = needsLargerViewport;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> Lines => PageCount == 0 ? Array.Empty<string>() : pages[PageIndex];
|
||||
public IReadOnlyList<string> NavigationLines => PageCount == 0 ? Array.Empty<string>() : navigation[PageIndex];
|
||||
public int PageIndex { get; private set; }
|
||||
public int PageCount => pages.Length;
|
||||
public bool NeedsLargerViewport { get; }
|
||||
|
||||
public void MovePage(int delta) => PageIndex = (int)System.Math.Clamp((long)PageIndex + delta, 0,
|
||||
System.Math.Max(0, PageCount - 1));
|
||||
|
||||
public static OverlapHoverPages Create(string text, int pairCount, int maxRows,
|
||||
double maxWidth, Func<string, double> measure)
|
||||
{
|
||||
var tooSmall = new OverlapHoverPages([], [], true);
|
||||
if (maxRows < 1 || !double.IsFinite(maxWidth) || maxWidth <= 0)
|
||||
return tooSmall;
|
||||
var lines = Wrap(text, maxWidth, measure);
|
||||
if (lines == null)
|
||||
return tooSmall;
|
||||
if (lines.Count <= maxRows)
|
||||
return new OverlapHoverPages([lines.AsReadOnly()], [Array.Empty<string>()]);
|
||||
|
||||
// Reserve the real, wrapped hint as well as content. Increasing the reserved
|
||||
// rows can increase the page count/digit count, so converge before slicing.
|
||||
for (var hintRows = 2; hintRows < maxRows;)
|
||||
{
|
||||
var contentRows = maxRows - hintRows;
|
||||
var count = (lines.Count - 1) / contentRows + 1;
|
||||
var hints = new IReadOnlyList<string>[count];
|
||||
var requiredHintRows = hintRows;
|
||||
for (var page = 0; page < count; page++)
|
||||
{
|
||||
var hint = Wrap($"Page {page + 1}/{count} · {pairCount} pairs\nPgUp/PgDn", maxWidth, measure);
|
||||
if (hint == null)
|
||||
return tooSmall;
|
||||
hints[page] = hint.AsReadOnly();
|
||||
requiredHintRows = System.Math.Max(requiredHintRows, hint.Count);
|
||||
}
|
||||
if (requiredHintRows > hintRows)
|
||||
{
|
||||
hintRows = requiredHintRows;
|
||||
continue;
|
||||
}
|
||||
var pages = Enumerable.Range(0, count)
|
||||
.Select(page => (IReadOnlyList<string>)Array.AsReadOnly(lines.Skip(page * contentRows).Take(contentRows).ToArray()))
|
||||
.ToArray();
|
||||
return new OverlapHoverPages(pages, hints);
|
||||
}
|
||||
// There must be room for at least one complete content line AND navigation.
|
||||
return tooSmall;
|
||||
}
|
||||
|
||||
private static List<string> Wrap(string text, double width, Func<string, double> measure)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var paragraph in text.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
if (paragraph.Length == 0)
|
||||
{
|
||||
lines.Add("");
|
||||
continue;
|
||||
}
|
||||
var starts = StringInfo.ParseCombiningCharacters(paragraph).Append(paragraph.Length).ToArray();
|
||||
for (var first = 0; first < starts.Length - 1;)
|
||||
{
|
||||
var low = first;
|
||||
var high = starts.Length - 1;
|
||||
while (low < high)
|
||||
{
|
||||
var end = low + (high - low + 1) / 2;
|
||||
if (measure(paragraph[starts[first]..starts[end]]) <= width)
|
||||
low = end;
|
||||
else
|
||||
high = end - 1;
|
||||
}
|
||||
if (low == first)
|
||||
return null; // Even one grapheme cannot fit; do not silently clip it.
|
||||
var last = low;
|
||||
if (last < starts.Length - 1)
|
||||
{
|
||||
for (var end = last; end > first; end--)
|
||||
{
|
||||
if (char.IsWhiteSpace(paragraph[starts[end - 1]]))
|
||||
{
|
||||
last = end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.Add(paragraph[starts[first]..starts[last]]);
|
||||
first = last;
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>Request-local, validated single-outer material. Never exposed to report consumers.</summary>
|
||||
internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
||||
{
|
||||
internal static OverlapMaterial Read(List<Entity> entities, CancellationToken cancellationToken)
|
||||
{
|
||||
// ShapeBuilder can reverse entities while chaining. Own a fresh copy for each analysis.
|
||||
var shapes = ShapeBuilder.GetShapes(entities.Select(entity => entity.Clone()));
|
||||
if (shapes.Count == 0)
|
||||
throw new ArgumentException("Drawing has no closed material contour.");
|
||||
var polygons = new List<Polygon>();
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ValidateChain(shape);
|
||||
var polygon = shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance);
|
||||
// The analytic chain was validated above. Normalize its sampled seam (e.g.
|
||||
// sin(2*pi) is not exactly zero), rather than adding a spurious microscopic edge.
|
||||
if (polygon.IsClosed())
|
||||
polygon.Vertices[^1] = polygon.Vertices[0];
|
||||
ValidatePolygon(polygon, cancellationToken);
|
||||
polygons.Add(polygon);
|
||||
}
|
||||
// Sampling can hide a crossing or tangency between curves. Reject native
|
||||
// contour contact before asking the polygon approximation about containment.
|
||||
for (var i = 0; i < shapes.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
for (var j = 0; j < i; j++)
|
||||
{
|
||||
shapes[i].Intersects(shapes[j], out var intersections);
|
||||
if (intersections.Count > 0)
|
||||
throw new ArgumentException("Native material contours cross or touch.");
|
||||
}
|
||||
}
|
||||
var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList();
|
||||
var outer = ordered[0];
|
||||
var holes = ordered.Skip(1).ToList();
|
||||
for (var i = 0; i < holes.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (BoundariesTouch(outer, holes[i], cancellationToken)
|
||||
|| !Inside(outer, holes[i].Vertices[0]))
|
||||
throw new ArgumentException("Contours must have one outer with strictly internal holes.");
|
||||
for (var j = 0; j < i; j++)
|
||||
{
|
||||
if (BoundariesTouch(holes[i], holes[j], cancellationToken)
|
||||
|| Inside(holes[i], holes[j].Vertices[0])
|
||||
|| Inside(holes[j], holes[i].Vertices[0]))
|
||||
throw new ArgumentException("Intersecting holes and nested material islands are unsupported.");
|
||||
}
|
||||
}
|
||||
return new OverlapMaterial(outer, holes);
|
||||
}
|
||||
|
||||
internal OverlapMaterial Transform(double rotation, Vector offset) =>
|
||||
new(TransformPolygon(Outer, rotation, offset),
|
||||
Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList());
|
||||
|
||||
private static Polygon TransformPolygon(Polygon polygon, double rotation, Vector offset)
|
||||
{
|
||||
var transformed = new Polygon();
|
||||
transformed.Vertices.AddRange(polygon.Vertices.Select(point =>
|
||||
(rotation == 0 ? point : point.Rotate(rotation)) + offset));
|
||||
if (transformed.Vertices.Any(point => !IsFinite(point)))
|
||||
throw new ArithmeticException("Transformed contour has nonfinite coordinates.");
|
||||
transformed.UpdateBounds();
|
||||
if (!double.IsFinite(transformed.BoundingBox.Length)
|
||||
|| !double.IsFinite(transformed.BoundingBox.Width))
|
||||
throw new ArithmeticException("Transformed contour bounds overflowed.");
|
||||
var sourceArea = Area(polygon.Vertices);
|
||||
var transformedArea = Area(transformed.Vertices);
|
||||
if (!double.IsFinite(transformedArea) || transformedArea <= Tolerance.Epsilon
|
||||
|| System.Math.Abs(sourceArea - transformedArea)
|
||||
> System.Math.Max(Tolerance.Epsilon, sourceArea * 1e-8))
|
||||
throw new ArithmeticException("Coordinate precision cannot preserve the contour area at this pose.");
|
||||
for (var i = 0; i + 1 < transformed.Vertices.Count; i++)
|
||||
{
|
||||
var a = transformed.Vertices[i];
|
||||
var b = transformed.Vertices[i + 1];
|
||||
if (a.X == b.X && a.Y == b.Y)
|
||||
throw new ArithmeticException("Coordinate precision collapsed a contour edge at this pose.");
|
||||
}
|
||||
return transformed;
|
||||
}
|
||||
|
||||
internal static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||
|
||||
/// <summary>Translation-stable unsigned shoelace area; accepts an explicit closing vertex.</summary>
|
||||
internal static double Area(IReadOnlyList<Vector> vertices)
|
||||
{
|
||||
var twiceArea = 0.0;
|
||||
for (var i = 1; i + 1 < vertices.Count; i++)
|
||||
twiceArea += Cross(vertices[0], vertices[i], vertices[i + 1]);
|
||||
return System.Math.Abs(twiceArea) * 0.5;
|
||||
}
|
||||
|
||||
private static void ValidateChain(Shape shape)
|
||||
{
|
||||
if (!shape.IsClosed())
|
||||
throw new ArgumentException("Material contour is open.");
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
if (!double.IsFinite(entity.Length) || entity.Length <= 0)
|
||||
throw new ArgumentException("Material contour has a nonfinite or zero-length edge.");
|
||||
}
|
||||
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
|
||||
{
|
||||
if (!IsFinite(circle.Center) || !double.IsFinite(circle.Radius) || circle.Radius <= 0)
|
||||
throw new ArgumentException("Material circle is invalid.");
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < shape.Entities.Count; i++)
|
||||
{
|
||||
var end = Endpoints(shape.Entities[i]).End;
|
||||
var start = Endpoints(shape.Entities[(i + 1) % shape.Entities.Count]).Start;
|
||||
// Do not let ShapeBuilder's larger chain tolerance silently repair a broken cut.
|
||||
if (!IsFinite(start) || !IsFinite(end) || end.DistanceTo(start) > Tolerance.Epsilon)
|
||||
throw new ArgumentException("Material contour has a gap or invalid endpoint.");
|
||||
}
|
||||
}
|
||||
|
||||
private static (Vector Start, Vector End) Endpoints(Entity entity) => entity switch
|
||||
{
|
||||
Line line => (line.StartPoint, line.EndPoint),
|
||||
Arc arc => (arc.StartPoint(), arc.EndPoint()),
|
||||
_ => throw new ArgumentException("Unsupported material entity."),
|
||||
};
|
||||
|
||||
private static void ValidatePolygon(Polygon polygon, CancellationToken cancellationToken)
|
||||
{
|
||||
var vertices = polygon.Vertices;
|
||||
var area = Area(vertices);
|
||||
if (vertices.Count < 4 || vertices.Any(point => !IsFinite(point))
|
||||
|| !double.IsFinite(area) || area <= Tolerance.Epsilon)
|
||||
throw new ArgumentException("Material contour is degenerate or nonfinite.");
|
||||
var count = vertices.Count - 1;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var previous = vertices[(i + count - 1) % count];
|
||||
var current = vertices[i];
|
||||
var next = vertices[i + 1];
|
||||
if (current.X == next.X && current.Y == next.Y)
|
||||
throw new ArgumentException("Material polygon has a zero-length edge.");
|
||||
if (Cross(previous, current, next) == 0
|
||||
&& (previous.X - current.X) * (next.X - current.X)
|
||||
+ (previous.Y - current.Y) * (next.Y - current.Y) > 0)
|
||||
throw new ArgumentException("Material polygon has a retraced edge.");
|
||||
for (var j = i + 2; j < count; j++)
|
||||
{
|
||||
if (i == 0 && j == count - 1)
|
||||
continue;
|
||||
if (SegmentsTouch(vertices[i], vertices[i + 1], vertices[j], vertices[j + 1]))
|
||||
throw new ArgumentException("Material contour self-intersects or touches itself.");
|
||||
}
|
||||
}
|
||||
// Ear clipping can stop early on unusable geometry. Do not certify that as clear.
|
||||
var local = TransformPolygon(polygon, 0, vertices[0] * -1);
|
||||
var triangulatedArea = Collision.Triangulate(local).Sum(triangle => Area(triangle.Vertices));
|
||||
if (!double.IsFinite(triangulatedArea)
|
||||
|| System.Math.Abs(triangulatedArea - area) > System.Math.Max(Tolerance.Epsilon, area * 1e-9))
|
||||
throw new ArgumentException("Material contour could not be completely triangulated.");
|
||||
}
|
||||
|
||||
private static bool BoundariesTouch(Polygon a, Polygon b, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var i = 0; i + 1 < a.Vertices.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
for (var j = 0; j + 1 < b.Vertices.Count; j++)
|
||||
if (SegmentsTouch(a.Vertices[i], a.Vertices[i + 1], b.Vertices[j], b.Vertices[j + 1]))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool SegmentsTouch(Vector a, Vector b, Vector c, Vector d)
|
||||
{
|
||||
var ac = Cross(a, b, c);
|
||||
var ad = Cross(a, b, d);
|
||||
var ca = Cross(c, d, a);
|
||||
var cb = Cross(c, d, b);
|
||||
return ac == 0 && OnSegment(a, b, c) || ad == 0 && OnSegment(a, b, d)
|
||||
|| ca == 0 && OnSegment(c, d, a) || cb == 0 && OnSegment(c, d, b)
|
||||
|| (ac < 0 && ad > 0 || ac > 0 && ad < 0)
|
||||
&& (ca < 0 && cb > 0 || ca > 0 && cb < 0);
|
||||
}
|
||||
|
||||
private static bool OnSegment(Vector a, Vector b, Vector point) =>
|
||||
point.X >= System.Math.Min(a.X, b.X) && point.X <= System.Math.Max(a.X, b.X)
|
||||
&& point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y);
|
||||
|
||||
// Boundary contact is rejected before this winding-number test is used for topology.
|
||||
private static bool Inside(Polygon polygon, Vector point)
|
||||
{
|
||||
var winding = 0;
|
||||
for (var i = 0; i + 1 < polygon.Vertices.Count; i++)
|
||||
{
|
||||
var a = polygon.Vertices[i];
|
||||
var b = polygon.Vertices[i + 1];
|
||||
if (a.Y <= point.Y && b.Y > point.Y && Cross(a, b, point) > 0)
|
||||
winding++;
|
||||
else if (a.Y > point.Y && b.Y <= point.Y && Cross(a, b, point) < 0)
|
||||
winding--;
|
||||
}
|
||||
return winding != 0;
|
||||
}
|
||||
|
||||
private static double Cross(Vector a, Vector b, Vector point) =>
|
||||
(b.X - a.X) * (point.Y - a.Y) - (b.Y - a.Y) * (point.X - a.X);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Reuses each clean drawing program's converted entities and prepared (validated, chorded,
|
||||
/// triangulation-checked) material across overlap requests. Preparation dominates the cost of
|
||||
/// drawings with many holes, and it depends only on the drawing, not on where parts sit, so a
|
||||
/// recheck after moving parts only repeats the cheap pose transforms and pair clipping.
|
||||
/// Entries are keyed by <see cref="Program"/> reference and released with it. A changed code
|
||||
/// count or program rotation is detected, but that is not a geometry hash: call
|
||||
/// <see cref="Clear"/> before any in-place edit of a clean program or its hole subprograms.
|
||||
/// Capture on one thread at a time; prepared entries may be shared by concurrent analyses.
|
||||
/// </summary>
|
||||
public sealed class OverlapMaterialCache
|
||||
{
|
||||
private readonly ConditionalWeakTable<Program, OverlapSource> sources = new();
|
||||
|
||||
public void Clear() => sources.Clear();
|
||||
|
||||
internal OverlapSource Get(Program program, Func<Program, OverlapSource> create)
|
||||
{
|
||||
if (sources.TryGetValue(program, out var source) && source.Matches(program))
|
||||
return source;
|
||||
source = create(program);
|
||||
sources.AddOrUpdate(program, source);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Owned converted entities for one clean program, plus its lazily prepared material.</summary>
|
||||
internal sealed class OverlapSource
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly int codeCount;
|
||||
private readonly long rotation;
|
||||
private PreparedMaterial prepared;
|
||||
|
||||
internal OverlapSource(Program program, List<Entity> entities, string error)
|
||||
{
|
||||
codeCount = program.Codes.Count;
|
||||
rotation = BitConverter.DoubleToInt64Bits(program.Rotation);
|
||||
Entities = entities;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
/// <summary>Never mutated; preparation clones before chaining.</summary>
|
||||
internal List<Entity> Entities { get; }
|
||||
internal string Error { get; }
|
||||
|
||||
internal bool Matches(Program program) =>
|
||||
program.Codes.Count == codeCount && BitConverter.DoubleToInt64Bits(program.Rotation) == rotation;
|
||||
|
||||
/// <summary>
|
||||
/// Prepares once and shares the result. A geometry failure is cached like a success;
|
||||
/// cancellation is not, so a superseded request cannot poison the next one.
|
||||
/// </summary>
|
||||
internal PreparedMaterial Prepare(CancellationToken cancellationToken)
|
||||
{
|
||||
var current = Volatile.Read(ref prepared);
|
||||
if (current != null)
|
||||
return current;
|
||||
lock (gate)
|
||||
{
|
||||
if (prepared != null)
|
||||
return prepared;
|
||||
PreparedMaterial result;
|
||||
try
|
||||
{
|
||||
result = new PreparedMaterial(OverlapMaterial.Read(Entities, cancellationToken), null);
|
||||
}
|
||||
catch (Exception exception) when (PlateOverlapAnalyzer.IsGeometryFailure(exception))
|
||||
{
|
||||
result = new PreparedMaterial(null, exception.Message);
|
||||
}
|
||||
Volatile.Write(ref prepared, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Validated local-frame material, only ever read (transformed into new polygons).</summary>
|
||||
internal sealed record PreparedMaterial(OverlapMaterial Material, string Error);
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary>
|
||||
public static class OverlapPairPresentation
|
||||
{
|
||||
public static string Label(PlateOverlapPair pair) => $"{pair.PartAId + 1}/{pair.PartBId + 1}";
|
||||
|
||||
public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null)
|
||||
{
|
||||
var units = UnitsHelper.GetShortString(capturedUnits);
|
||||
return $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}\n"
|
||||
+ $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
|
||||
+ $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}";
|
||||
}
|
||||
|
||||
// Significant figures adapt to scale: a positive sliver must never read as zero.
|
||||
public static string Number(double value, IFormatProvider provider = null) =>
|
||||
value.ToString("G6", provider ?? CultureInfo.CurrentCulture);
|
||||
|
||||
public static double MarkerHalfSize(int deviceDpi) => 6.0 * deviceDpi / 96;
|
||||
public static double HitRadius(int deviceDpi) => 10.0 * deviceDpi / 96;
|
||||
|
||||
/// <summary>
|
||||
/// Projects cached centroids into a screen-pixel coordinate space. The pointer must
|
||||
/// use that same space; view zoom never scales the DPI-adjusted hit radius.
|
||||
/// Coincident or nearby markers return every matching pair in stable ID order.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PlateOverlapPair> HitTest(IEnumerable<PlateOverlapPair> pairs,
|
||||
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi)
|
||||
{
|
||||
var radius = HitRadius(deviceDpi);
|
||||
return pairs.Where(pair =>
|
||||
{
|
||||
var center = worldToScreen(pair.Centroid);
|
||||
var dx = pointer.X - center.X;
|
||||
var dy = pointer.Y - center.Y;
|
||||
return dx * dx + dy * dy <= radius * radius;
|
||||
}).OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
public enum OverlapDisplayMode { Off, Areas, Centroids, Both }
|
||||
public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Failed, Canceled, Stale }
|
||||
|
||||
/// <summary>UI-thread lifecycle policy, independent of workers, GDI and view transforms.</summary>
|
||||
public sealed class OverlapReportState
|
||||
{
|
||||
private OverlapGeometryStamp stamp;
|
||||
private int uncheckedPartCount;
|
||||
|
||||
public long Generation { get; private set; }
|
||||
public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked;
|
||||
public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas;
|
||||
public PlateOverlapReport Report { get; private set; }
|
||||
public bool IsRunning => Status == OverlapCheckStatus.Checking;
|
||||
|
||||
public string Message => Status switch
|
||||
{
|
||||
OverlapCheckStatus.Checking => "Checking overlaps…",
|
||||
OverlapCheckStatus.Current => Report.Pairs.Count == 0
|
||||
? "No material overlaps detected" : $"Overlaps: {Report.Pairs.Count} pairs",
|
||||
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
|
||||
+ $"{uncheckedPartCount} parts could not be checked",
|
||||
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
|
||||
OverlapCheckStatus.Canceled => "Overlap check canceled",
|
||||
OverlapCheckStatus.Stale => "Overlap check out of date — run Check Overlaps again",
|
||||
_ => "Overlaps: not checked"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Starts a request. A manual check from Off shows Areas; an automatic recheck keeps
|
||||
/// the user's display choice, including Off.
|
||||
/// </summary>
|
||||
public long Begin(Plate plate, bool automatic = false)
|
||||
{
|
||||
Clear(OverlapCheckStatus.Checking);
|
||||
stamp = OverlapGeometryStamp.Capture(plate);
|
||||
if (!automatic && DisplayMode == OverlapDisplayMode.Off)
|
||||
DisplayMode = OverlapDisplayMode.Areas;
|
||||
return Generation;
|
||||
}
|
||||
|
||||
public bool TryPublish(long generation, Plate plate, PlateOverlapReport report)
|
||||
{
|
||||
if (!CanComplete(generation, plate))
|
||||
return false;
|
||||
Report = report;
|
||||
uncheckedPartCount = CountUncheckedParts(report.Issues);
|
||||
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryFail(long generation, Plate plate)
|
||||
{
|
||||
if (!CanComplete(generation, plate))
|
||||
return false;
|
||||
Clear(OverlapCheckStatus.Failed);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CanComplete(long generation, Plate plate) =>
|
||||
generation == Generation && IsRunning && EnsureFresh(plate);
|
||||
|
||||
public bool EnsureFresh(Plate plate)
|
||||
{
|
||||
if (stamp == null)
|
||||
return false;
|
||||
if (stamp.Matches(plate))
|
||||
return true;
|
||||
Invalidate();
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Invalidate()
|
||||
{
|
||||
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
|
||||
Clear(OverlapCheckStatus.Stale);
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
if (IsRunning)
|
||||
Clear(OverlapCheckStatus.Canceled);
|
||||
}
|
||||
|
||||
public void Reset() => Clear(OverlapCheckStatus.NotChecked);
|
||||
|
||||
private void Clear(OverlapCheckStatus status)
|
||||
{
|
||||
Generation++;
|
||||
Report = null;
|
||||
uncheckedPartCount = 0;
|
||||
stamp = null;
|
||||
Status = status;
|
||||
}
|
||||
|
||||
public static int CountUncheckedParts(IEnumerable<PlateOverlapIssue> issues)
|
||||
{
|
||||
var ids = new HashSet<int>();
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
ids.Add(issue.PartAId);
|
||||
if (issue.PartBId.HasValue)
|
||||
ids.Add(issue.PartBId.Value);
|
||||
}
|
||||
return ids.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only, hole-aware material overlap diagnostics, separate from the engine's boolean checks.
|
||||
/// Uses clean drawing outlines, not placed lead-in/tab toolpaths or spacing offsets.
|
||||
/// </summary>
|
||||
public static class PlateOverlapAnalyzer
|
||||
{
|
||||
public const double ChordTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// Captures poses and converts each distinct clean source program to owned entities once.
|
||||
/// Inputs must not change during capture. Later analysis never reads live domain objects.
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Capture(parts, new OverlapMaterialCache(), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, but reuses converted
|
||||
/// and prepared drawing material from <paramref name="cache"/> across requests. Clear the
|
||||
/// cache before any in-place clean-program edit (see <see cref="OverlapMaterialCache"/>).
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts, OverlapMaterialCache cache,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parts);
|
||||
ArgumentNullException.ThrowIfNull(cache);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var captured = new List<CapturedOverlapPart>();
|
||||
var issues = new List<PlateOverlapIssue>();
|
||||
for (var id = 0; id < parts.Count; id++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var part = parts[id];
|
||||
if (part?.BaseDrawing?.IsCutOff == true)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
if (part?.BaseDrawing?.Program == null)
|
||||
throw new ArgumentException("Part has no clean drawing program.");
|
||||
var program = part.BaseDrawing.Program;
|
||||
var rotation = part.Rotation - program.Rotation;
|
||||
var location = part.Location;
|
||||
if (!double.IsFinite(rotation) || !OverlapMaterial.IsFinite(location))
|
||||
throw new ArgumentException("Part pose must be finite.");
|
||||
var source = cache.Get(program, CaptureSource);
|
||||
if (source.Error != null)
|
||||
throw new ArgumentException(source.Error);
|
||||
captured.Add(new CapturedOverlapPart(id, part.BaseDrawing.Name,
|
||||
source, rotation, location));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(id, null, exception.Message));
|
||||
}
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PlateOverlapSnapshot(captured, issues);
|
||||
}
|
||||
|
||||
private static OverlapSource CaptureSource(Program program)
|
||||
{
|
||||
try
|
||||
{
|
||||
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
|
||||
// Conversion creates fresh geometry, including expanded shared hole calls;
|
||||
// no cloning/rotation of a live program or subprogram is necessary.
|
||||
return new OverlapSource(program, ConvertProgram.ToGeometry(program)
|
||||
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
|
||||
&& entity.Layer != SpecialLayers.Leadin
|
||||
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
return new OverlapSource(program, null, exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Convenience synchronous capture and analysis of a group of parts.</summary>
|
||||
public static PlateOverlapReport Analyze(IReadOnlyList<Part> parts,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Analyze(Capture(parts, cancellationToken), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns deterministic pair reports containing closed world-coordinate overlap fragments.
|
||||
/// Cancellation throws and publishes no partial report. Check IsComplete before claiming clear.
|
||||
/// </summary>
|
||||
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Analyze(snapshot, null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Incremental recheck: identical to a full analysis of <paramref name="snapshot"/>, but a
|
||||
/// pair whose two parts are unchanged since <paramref name="previous"/> (same captured source
|
||||
/// and bit-identical pose, in the same relative order) reuses that report's result instead
|
||||
/// of being clipped again. After moving one part only its own neighbors are recomputed.
|
||||
/// Reuse needs sources shared through one <see cref="OverlapMaterialCache"/>; otherwise every
|
||||
/// pair is recomputed. Null <paramref name="previous"/> performs a full analysis.
|
||||
/// </summary>
|
||||
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot, PlateOverlapReport previous,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var reuse = PairReuse.Create(previous, snapshot);
|
||||
var issues = snapshot.Issues.ToList();
|
||||
var pairs = new List<PlateOverlapPair>();
|
||||
var prepared = new List<PreparedPart>();
|
||||
foreach (var part in snapshot.Parts)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
// Prepared material is shared by every part and request using this source.
|
||||
var source = part.Source.Prepare(cancellationToken);
|
||||
if (source.Error != null)
|
||||
throw new ArgumentException(source.Error);
|
||||
var material = source.Material.Transform(part.Rotation, part.Location);
|
||||
prepared.Add(new PreparedPart(part, material));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(part.Id, null, exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
var sorted = prepared.OrderBy(part => part.Material.Outer.BoundingBox.Left)
|
||||
.ThenBy(part => part.Input.Id).ToArray();
|
||||
for (var i = 0; i < sorted.Length; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var first = sorted[i];
|
||||
var bounds = first.Material.Outer.BoundingBox;
|
||||
for (var j = i + 1; j < sorted.Length; j++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var second = sorted[j];
|
||||
var otherBounds = second.Material.Outer.BoundingBox;
|
||||
if (otherBounds.Left >= bounds.Right)
|
||||
break;
|
||||
if (otherBounds.Bottom >= bounds.Top || bounds.Bottom >= otherBounds.Top)
|
||||
continue;
|
||||
var a = first.Input.Id < second.Input.Id ? first : second;
|
||||
var b = first.Input.Id < second.Input.Id ? second : first;
|
||||
if (reuse != null && reuse.TryReuse(a.Input, b.Input, pairs, issues))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
// Keep pair clipping arithmetic near the parts where possible, then
|
||||
// restore output to world space. Triangulation itself also uses stable
|
||||
// local-origin winding so tiny holes in a huge part remain correct.
|
||||
var origin = a.Material.Outer.Vertices[0];
|
||||
var localA = a.Material.Transform(0, origin * -1);
|
||||
var localB = b.Material.Transform(0, origin * -1);
|
||||
var result = Collision.Check(localA.Outer, localB.Outer, localA.Holes, localB.Holes);
|
||||
if (!result.Overlaps)
|
||||
continue;
|
||||
// Evaluate every hole-subtracted fragment before restoring world space.
|
||||
// A failed moment must make this pair incomplete, never an origin marker.
|
||||
var moments = new List<PolygonAreaMoments>();
|
||||
var regions = new List<PlateOverlapRegion>();
|
||||
foreach (var region in result.OverlapRegions)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment))
|
||||
throw new ArithmeticException("Overlap fragment area moments are invalid.");
|
||||
moments.Add(fragment);
|
||||
regions.Add(new PlateOverlapRegion(region.Vertices.Select(point => point + origin),
|
||||
fragment.Area));
|
||||
}
|
||||
if (!PolygonAreaMoments.TryCombine(moments, out var combined))
|
||||
throw new ArithmeticException("Combined overlap area moments are invalid.");
|
||||
var centroid = combined.Centroid + origin;
|
||||
if (!OverlapMaterial.IsFinite(centroid))
|
||||
throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
|
||||
pairs.Add(new PlateOverlapPair(a.Input.Id, b.Input.Id,
|
||||
a.Input.Name, b.Input.Name, regions, centroid));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(a.Input.Id, b.Input.Id, exception.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
|
||||
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
|
||||
.ThenBy(issue => issue.PartBId).ToList(), snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps unchanged parts to their previous input positions and looks up previous pair results.
|
||||
/// A part is unchanged when its captured source object and exact pose bits match; identical
|
||||
/// duplicates are matched in input order, which is safe because their inputs are bit-identical.
|
||||
/// </summary>
|
||||
private sealed class PairReuse
|
||||
{
|
||||
private readonly Dictionary<int, int> previousIds;
|
||||
private readonly Dictionary<(int, int), PlateOverlapPair> pairs = new();
|
||||
private readonly Dictionary<(int, int), PlateOverlapIssue> issues = new();
|
||||
|
||||
private PairReuse(Dictionary<int, int> previousIds, PlateOverlapReport previous)
|
||||
{
|
||||
this.previousIds = previousIds;
|
||||
foreach (var pair in previous.Pairs)
|
||||
pairs[(pair.PartAId, pair.PartBId)] = pair;
|
||||
foreach (var issue in previous.Issues)
|
||||
if (issue.PartBId.HasValue)
|
||||
issues[(issue.PartAId, issue.PartBId.Value)] = issue;
|
||||
}
|
||||
|
||||
public static PairReuse Create(PlateOverlapReport previous, PlateOverlapSnapshot snapshot)
|
||||
{
|
||||
if (previous?.Snapshot == null)
|
||||
return null;
|
||||
var available = new Dictionary<PoseKey, Queue<int>>();
|
||||
foreach (var part in previous.Snapshot.Parts)
|
||||
{
|
||||
var key = PoseKey.Of(part);
|
||||
if (!available.TryGetValue(key, out var ids))
|
||||
available.Add(key, ids = new Queue<int>());
|
||||
ids.Enqueue(part.Id);
|
||||
}
|
||||
var previousIds = new Dictionary<int, int>();
|
||||
foreach (var part in snapshot.Parts)
|
||||
if (available.TryGetValue(PoseKey.Of(part), out var ids) && ids.Count > 0)
|
||||
previousIds.Add(part.Id, ids.Dequeue());
|
||||
return previousIds.Count < 2 ? null : new PairReuse(previousIds, previous);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every bounding-box candidate pair among prepared parts was evaluated by the previous
|
||||
/// analysis, and unchanged parts have identical bounds, so absence there means clear.
|
||||
/// The previous pair must have had the same operand order: clipping is order-sensitive.
|
||||
/// </summary>
|
||||
public bool TryReuse(CapturedOverlapPart a, CapturedOverlapPart b,
|
||||
List<PlateOverlapPair> pairOutput, List<PlateOverlapIssue> issueOutput)
|
||||
{
|
||||
if (!previousIds.TryGetValue(a.Id, out var oldA) || !previousIds.TryGetValue(b.Id, out var oldB)
|
||||
|| oldA >= oldB)
|
||||
return false;
|
||||
if (pairs.TryGetValue((oldA, oldB), out var pair))
|
||||
pairOutput.Add(pair.Renumber(a.Id, b.Id, a.Name, b.Name));
|
||||
else if (issues.TryGetValue((oldA, oldB), out var issue))
|
||||
issueOutput.Add(issue with { PartAId = a.Id, PartBId = b.Id });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct PoseKey(OverlapSource Source, long Rotation, long X, long Y)
|
||||
{
|
||||
public static PoseKey Of(CapturedOverlapPart part) => new(part.Source,
|
||||
BitConverter.DoubleToInt64Bits(part.Rotation),
|
||||
BitConverter.DoubleToInt64Bits(part.Location.X),
|
||||
BitConverter.DoubleToInt64Bits(part.Location.Y));
|
||||
}
|
||||
|
||||
internal static bool IsGeometryFailure(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
|
||||
private static void ValidateProgram(Program program, HashSet<Program> visiting)
|
||||
{
|
||||
if (program == null || !visiting.Add(program) || visiting.Count > 64)
|
||||
throw new ArgumentException("Missing, recursive, or excessively nested subprogram.");
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
if (code == null)
|
||||
throw new ArgumentException("Program contains a missing instruction.");
|
||||
if (code is Motion motion && !OverlapMaterial.IsFinite(motion.EndPoint)
|
||||
|| code is ArcMove arc && !OverlapMaterial.IsFinite(arc.CenterPoint))
|
||||
throw new ArgumentException("Program coordinates must be finite.");
|
||||
if (code is SubProgramCall call)
|
||||
{
|
||||
if (!OverlapMaterial.IsFinite(call.Offset) || !double.IsFinite(call.Rotation))
|
||||
throw new ArgumentException("Subprogram pose must be finite.");
|
||||
ValidateProgram(call.Program, visiting);
|
||||
}
|
||||
}
|
||||
visiting.Remove(program);
|
||||
}
|
||||
|
||||
private sealed record PreparedPart(CapturedOverlapPart Input, OverlapMaterial Material);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>An owned diagnostic result. An empty Pairs list is clear only if IsComplete is true.</summary>
|
||||
public sealed class PlateOverlapReport
|
||||
{
|
||||
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
|
||||
PlateOverlapSnapshot snapshot)
|
||||
{
|
||||
Pairs = pairs.AsReadOnly();
|
||||
Issues = issues.AsReadOnly();
|
||||
Snapshot = snapshot;
|
||||
}
|
||||
|
||||
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
|
||||
internal PlateOverlapSnapshot Snapshot { get; }
|
||||
|
||||
public IReadOnlyList<PlateOverlapPair> Pairs { get; }
|
||||
public IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||
public bool IsComplete => Issues.Count == 0;
|
||||
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
|
||||
}
|
||||
|
||||
/// <summary>Shared material for two input positions, ordered by zero-based input index.</summary>
|
||||
public sealed class PlateOverlapPair
|
||||
{
|
||||
private readonly Box bounds;
|
||||
|
||||
private PlateOverlapPair(PlateOverlapPair source, int partAId, int partBId, string partAName,
|
||||
string partBName)
|
||||
{
|
||||
PartAId = partAId;
|
||||
PartBId = partBId;
|
||||
PartAName = partAName;
|
||||
PartBName = partBName;
|
||||
Regions = source.Regions;
|
||||
Area = source.Area;
|
||||
Centroid = source.Centroid;
|
||||
bounds = source.bounds;
|
||||
}
|
||||
|
||||
/// <summary>The same immutable geometry under new input positions and captured names.</summary>
|
||||
internal PlateOverlapPair Renumber(int partAId, int partBId, string partAName, string partBName) =>
|
||||
new(this, partAId, partBId, partAName, partBName);
|
||||
|
||||
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
|
||||
List<PlateOverlapRegion> regions, Vector centroid)
|
||||
{
|
||||
PartAId = partAId;
|
||||
PartBId = partBId;
|
||||
PartAName = partAName;
|
||||
PartBName = partBName;
|
||||
Regions = regions.AsReadOnly();
|
||||
Area = regions.Sum(region => region.Area);
|
||||
Centroid = centroid;
|
||||
var points = regions.SelectMany(region => region.Vertices).ToArray();
|
||||
var left = points.Min(point => point.X);
|
||||
var bottom = points.Min(point => point.Y);
|
||||
bounds = new Box(left, bottom, points.Max(point => point.X) - left,
|
||||
points.Max(point => point.Y) - bottom);
|
||||
}
|
||||
|
||||
public int PartAId { get; }
|
||||
public int PartBId { get; }
|
||||
public string PartAName { get; }
|
||||
public string PartBName { get; }
|
||||
/// <summary>Convex fragments, not connected islands; no mutable kernel polygons are exposed.</summary>
|
||||
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
|
||||
public double Area { get; }
|
||||
/// <summary>
|
||||
/// Finite world-coordinate area centroid of all shared material, after hole subtraction.
|
||||
/// This can lie outside disconnected or concave shared material. Returned by value.
|
||||
/// </summary>
|
||||
public Vector Centroid { get; }
|
||||
/// <summary>A fresh world-coordinate bounds copy.</summary>
|
||||
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
||||
}
|
||||
|
||||
/// <summary>A positive-area, hole-subtracted convex polygon in world coordinates.</summary>
|
||||
public sealed class PlateOverlapRegion
|
||||
{
|
||||
internal PlateOverlapRegion(IEnumerable<Vector> vertices, double area)
|
||||
{
|
||||
Vertices = System.Array.AsReadOnly(vertices.ToArray());
|
||||
Area = area;
|
||||
}
|
||||
|
||||
/// <summary>Read-only vertices with an exactly repeated closing vertex.</summary>
|
||||
public IReadOnlyList<Vector> Vertices { get; }
|
||||
public double Area { get; }
|
||||
}
|
||||
|
||||
/// <summary>An input or pair that could not be checked. IDs are zero-based input positions.</summary>
|
||||
public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Owned clean geometry and poses. Capture while inputs are stable, then analyze on a worker.
|
||||
/// No live Part, Drawing, Program, or subprogram is retained.
|
||||
/// </summary>
|
||||
public sealed class PlateOverlapSnapshot
|
||||
{
|
||||
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues)
|
||||
{
|
||||
Parts = parts.AsReadOnly();
|
||||
Issues = issues.AsReadOnly();
|
||||
}
|
||||
|
||||
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
|
||||
internal IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||
}
|
||||
|
||||
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
|
||||
double Rotation, Vector Location);
|
||||
@@ -0,0 +1,290 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only pre-post diagnostics. The caller must keep the nest stable for the entire call.
|
||||
/// Placed programs are already rotated; only their placement translation is applied here.
|
||||
/// This intentionally does not invoke a post or promise machine collision avoidance.
|
||||
/// </summary>
|
||||
public static class PostVerificationAnalyzer
|
||||
{
|
||||
public static PostVerificationReport AnalyzeForPost(Nest nest, IPostProcessor postProcessor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var report = Analyze(nest, cancellationToken);
|
||||
if (postProcessor is IPostVerificationSupport { PreservesPlacedProgramOrder: true })
|
||||
return report;
|
||||
var findings = report.Findings.ToList();
|
||||
findings.Add(new(PostVerificationKind.Incomplete, 0, null, null,
|
||||
$"Post '{postProcessor?.Name ?? "unknown"}' does not declare that it preserves placed part/contour order " +
|
||||
"and pierce positions. Nest-level checks ran, but the final rapid sequence requires manual review."));
|
||||
return new PostVerificationReport(findings);
|
||||
}
|
||||
|
||||
public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var findings = new List<PostVerificationFinding>();
|
||||
if (nest.Plates == null)
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, 1, null, null, "Nest has no plate collection."));
|
||||
return new PostVerificationReport(findings);
|
||||
}
|
||||
for (var plateIndex = 0; plateIndex < nest.Plates.Count; plateIndex++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var plate = nest.Plates[plateIndex];
|
||||
var plateNumber = plateIndex + 1;
|
||||
if (plate?.Parts == null)
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||
"Plate has no part collection."));
|
||||
continue;
|
||||
}
|
||||
var materialParts = new List<Part>();
|
||||
var indices = new List<int>();
|
||||
var obstacles = new List<Obstacle>();
|
||||
Vector? position = Vector.Zero;
|
||||
for (var index = 0; index < plate.Parts.Count; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var part = plate.Parts[index];
|
||||
var partNumber = index + 1;
|
||||
var cutoff = part?.BaseDrawing?.IsCutOff == true;
|
||||
var expectsCuts = cutoff;
|
||||
// Preflight even clean programs before calling the overlap converter: malformed
|
||||
// recursive graphs must never reach Program.Clone or unguarded conversion.
|
||||
if (!cutoff)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clean = Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken);
|
||||
expectsCuts = clean.Any(move => !move.Rapid && move.Layer != LayerType.Scribe);
|
||||
if (!clean.Any(move => move.Layer == LayerType.Scribe)
|
||||
|| expectsCuts)
|
||||
{
|
||||
materialParts.Add(part);
|
||||
indices.Add(partNumber);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
Incomplete("Overlap check: " + exception.Message);
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
if (part == null || !double.IsFinite(part.Rotation))
|
||||
throw new ArgumentException("Missing part or invalid rotation.");
|
||||
var moves = Read(part.Program, part.Location, position, cancellationToken);
|
||||
if (expectsCuts && !moves.Any(move => !move.Rapid
|
||||
&& move.Layer is LayerType.Cut or LayerType.Display
|
||||
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
|
||||
Incomplete("Placed program has no cutting contour motions for this drawing.");
|
||||
AnalyzeMoves(moves, cutoff, obstacles, findings, plateNumber, partNumber, cancellationToken);
|
||||
position = moves[^1].End;
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
Incomplete("Lead-in/rapid check: " + exception.Message);
|
||||
// Subsequent internal moves can still be checked, but the incoming segment
|
||||
// cannot be reconstructed after an invalid program.
|
||||
position = null;
|
||||
}
|
||||
|
||||
void Incomplete(string message) => findings.Add(new(PostVerificationKind.Incomplete,
|
||||
plateNumber, partNumber, null, message));
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var overlap = PlateOverlapAnalyzer.Analyze(
|
||||
PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken);
|
||||
foreach (var pair in overlap.Pairs)
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
|
||||
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted)."));
|
||||
foreach (var issue in overlap.Issues)
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
|
||||
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PostVerificationReport(findings);
|
||||
}
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
|
||||
private static List<Move> Read(Program program, Vector origin, Vector? previous,
|
||||
CancellationToken token)
|
||||
{
|
||||
var moves = new List<Move>();
|
||||
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||
var budget = 1000000;
|
||||
Walk(program, origin, previous);
|
||||
return moves;
|
||||
|
||||
Vector Walk(Program current, Vector frame, Vector? arrival)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
PostVerificationGeometry.Validate(frame);
|
||||
if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64)
|
||||
throw new ArgumentException("Missing, recursive or excessively nested program.");
|
||||
var pos = frame;
|
||||
var first = true;
|
||||
var countBefore = moves.Count;
|
||||
foreach (var code in current.Codes)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new ArgumentException("Program expansion exceeds the verification limit.");
|
||||
if (code == null)
|
||||
throw new ArgumentException("Program contains a missing instruction.");
|
||||
if (code is SubProgramCall call)
|
||||
{
|
||||
if (!double.IsFinite(call.Rotation))
|
||||
throw new ArgumentException("Subprogram rotation is not finite.");
|
||||
// Call rotation is baked into the shared program by its setter. Do not
|
||||
// rotate again; offsets are frame-relative even in incremental mode.
|
||||
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
if (code is not Motion motion)
|
||||
{
|
||||
if (code is not (Comment or Feedrate or Kerf))
|
||||
throw new NotSupportedException("Unsupported program instruction.");
|
||||
continue;
|
||||
}
|
||||
// Posts disagree about incremental position after suppressed instructions.
|
||||
// Never silently certify a trajectory whose semantics are ambiguous.
|
||||
if (motion.Suppressed)
|
||||
throw new NotSupportedException("Suppressed motion requires post-specific verification.");
|
||||
if (motion is not (RapidMove or LinearMove or ArcMove))
|
||||
throw new NotSupportedException("Unsupported motion.");
|
||||
var reference = current.Mode == Mode.Incremental ? pos : frame;
|
||||
var end = reference + motion.EndPoint;
|
||||
PostVerificationGeometry.Validate(end);
|
||||
var rapid = motion is RapidMove;
|
||||
if (first && !rapid)
|
||||
moves.Add(new(arrival, frame, true, LayerType.Display, null));
|
||||
var start = first && rapid ? arrival : pos;
|
||||
var layer = motion switch
|
||||
{
|
||||
LinearMove line => line.Layer,
|
||||
ArcMove arc => arc.Layer,
|
||||
_ => LayerType.Display
|
||||
};
|
||||
if (!Enum.IsDefined(layer))
|
||||
throw new NotSupportedException("Unsupported motion layer.");
|
||||
if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation))
|
||||
throw new NotSupportedException("Unsupported arc direction.");
|
||||
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
|
||||
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
|
||||
motion is ArcMove { Rotation: RotationType.CW });
|
||||
moves.Add(new(start, end, rapid, layer, curve));
|
||||
pos = end;
|
||||
first = false;
|
||||
}
|
||||
visiting.Remove(current);
|
||||
if (moves.Count == countBefore)
|
||||
throw new ArgumentException("Program has no motions.");
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AnalyzeMoves(List<Move> moves, bool cutoff, List<Obstacle> obstacles,
|
||||
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token)
|
||||
{
|
||||
var contour = new List<PostVerificationGeometry.Curve>();
|
||||
var unfinished = new List<PostVerificationGeometry.Curve[]>();
|
||||
var hasLead = false;
|
||||
var contourNumber = 0;
|
||||
foreach (var move in moves)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (move.Rapid)
|
||||
{
|
||||
Finish();
|
||||
hasLead = false;
|
||||
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
|
||||
continue;
|
||||
foreach (var obstacle in obstacles)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
|
||||
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
||||
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
||||
$"of part {obstacle.Part}."));
|
||||
}
|
||||
}
|
||||
else if (move.Layer == LayerType.Leadin)
|
||||
{
|
||||
Finish();
|
||||
hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon;
|
||||
}
|
||||
else if (move.Layer is LayerType.Leadout or LayerType.Scribe)
|
||||
{
|
||||
if (move.Layer == LayerType.Leadout && contour.Count > 0
|
||||
&& !PostVerificationGeometry.Closed(contour)
|
||||
&& move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
|
||||
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
|
||||
"Rapid safety for that contour requires manual review."));
|
||||
Finish();
|
||||
hasLead = false;
|
||||
}
|
||||
else if (move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
if (contour.Count == 0)
|
||||
{
|
||||
contourNumber++;
|
||||
if (!cutoff && !hasLead)
|
||||
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
|
||||
hasLead = false;
|
||||
// A rapid can pause/reposition without leaving any material gap.
|
||||
// Retain already-cut fragments, but do not turn them into obstacles
|
||||
// until an actually continuous chain closes.
|
||||
var previous = unfinished.FindIndex(chain =>
|
||||
chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
|
||||
if (previous >= 0)
|
||||
{
|
||||
contour.AddRange(unfinished[previous]);
|
||||
unfinished.RemoveAt(previous);
|
||||
}
|
||||
}
|
||||
contour.Add(move.Curve);
|
||||
// A completed contour becomes an obstacle immediately, not at part end.
|
||||
if (PostVerificationGeometry.Closed(contour))
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
Finish();
|
||||
if (unfinished.Count > 1)
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
|
||||
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
|
||||
"they cannot be assumed to be retained by tabs. Review rapid travel manually."));
|
||||
|
||||
void Finish()
|
||||
{
|
||||
if (contour.Count == 0)
|
||||
return;
|
||||
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
|
||||
// no flag or tab configuration is used as evidence of retention.
|
||||
if (!cutoff && PostVerificationGeometry.Closed(contour))
|
||||
obstacles.Add(new(part, contourNumber, contour.ToArray()));
|
||||
else if (!cutoff)
|
||||
unfinished.Add(contour.ToArray());
|
||||
contour.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Move(Vector? Start, Vector End, bool Rapid, LayerType Layer,
|
||||
PostVerificationGeometry.Curve Curve);
|
||||
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>Local native line/arc queries, without changing the engine's geometry semantics.</summary>
|
||||
internal static class PostVerificationGeometry
|
||||
{
|
||||
internal const double Epsilon = 1e-8;
|
||||
private const double TwoPi = 2 * System.Math.PI;
|
||||
|
||||
internal static void Validate(Vector point)
|
||||
{
|
||||
if (!double.IsFinite(point.X) || !double.IsFinite(point.Y)
|
||||
|| System.Math.Abs(point.X) > 1e12 || System.Math.Abs(point.Y) > 1e12)
|
||||
throw new ArgumentException("Nonfinite or numerically unsupported program coordinates.");
|
||||
}
|
||||
|
||||
internal static bool Closed(IReadOnlyList<Curve> curves) => curves.Count > 0
|
||||
&& curves[0].Start.DistanceTo(curves[^1].End) <= Epsilon;
|
||||
|
||||
internal static bool Crosses(Vector start, Vector end, IReadOnlyList<Curve> curves,
|
||||
CancellationToken token)
|
||||
{
|
||||
var delta = end - start;
|
||||
var length = start.DistanceTo(end);
|
||||
if (length <= Epsilon)
|
||||
return false;
|
||||
var direction = delta * (1 / length);
|
||||
foreach (var curve in curves)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (curve.ContactAfterStart(start, direction, length))
|
||||
return true;
|
||||
}
|
||||
// If there are no contacts except possibly departure, all open-segment points
|
||||
// have the same inside/outside status. A midpoint catches travel entirely inside
|
||||
// and departure into the interior, without flagging start-only outward contact.
|
||||
var midpoint = start + delta * 0.5;
|
||||
var inside = false;
|
||||
foreach (var curve in curves)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (curve.CrossesRay(midpoint))
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
|
||||
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
||||
private static double Normalize(double angle)
|
||||
{
|
||||
angle %= TwoPi;
|
||||
return angle < 0 ? angle + TwoPi : angle;
|
||||
}
|
||||
|
||||
internal sealed class Curve
|
||||
{
|
||||
private Curve(Vector start, Vector end, Vector? center, double radius, double sweep)
|
||||
{
|
||||
Start = start;
|
||||
End = end;
|
||||
Center = center;
|
||||
Radius = radius;
|
||||
Sweep = sweep;
|
||||
}
|
||||
|
||||
internal Vector Start { get; }
|
||||
internal Vector End { get; }
|
||||
private Vector? Center { get; }
|
||||
private double Radius { get; }
|
||||
private double Sweep { get; }
|
||||
internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End);
|
||||
|
||||
internal static Curve Create(Vector start, Vector end, Vector? center, bool clockwise)
|
||||
{
|
||||
if (center is not { } c)
|
||||
return new Curve(start, end, null, 0, 0);
|
||||
Validate(c);
|
||||
var radius = start.DistanceTo(c);
|
||||
if (radius <= Epsilon || !double.IsFinite(radius)
|
||||
|| System.Math.Abs(radius - end.DistanceTo(c)) > Epsilon * System.Math.Max(1, radius))
|
||||
throw new ArgumentException("Arc has zero or inconsistent radius.");
|
||||
var a = System.Math.Atan2(start.Y - c.Y, start.X - c.X);
|
||||
var b = System.Math.Atan2(end.Y - c.Y, end.X - c.X);
|
||||
var sweep = start.DistanceTo(end) <= Epsilon ? TwoPi
|
||||
: Normalize(clockwise ? a - b : b - a);
|
||||
return new Curve(start, end, c, radius, clockwise ? -sweep : sweep);
|
||||
}
|
||||
|
||||
private double StartAngle => System.Math.Atan2(Start.Y - Center.Value.Y, Start.X - Center.Value.X);
|
||||
private double Travel(double angle) => Normalize(Sweep < 0 ? StartAngle - angle : angle - StartAngle);
|
||||
private bool OnArc(Vector point) => Travel(System.Math.Atan2(point.Y - Center.Value.Y,
|
||||
point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius
|
||||
|| point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon;
|
||||
|
||||
internal bool ContactAfterStart(Vector origin, Vector direction, double length)
|
||||
{
|
||||
if (Center is { } center)
|
||||
{
|
||||
// Intersect the actual circle, not an inscribed chord polygon: tangencies
|
||||
// and short arcs must not vanish between tessellation vertices.
|
||||
var relative = center - origin;
|
||||
var projection = Dot(relative, direction);
|
||||
var perpendicular = Cross(relative, direction);
|
||||
var square = Radius * Radius - perpendicular * perpendicular;
|
||||
if (square < -Epsilon * System.Math.Max(1, Radius * 2))
|
||||
return false;
|
||||
var offset = System.Math.Sqrt(System.Math.Max(0, square));
|
||||
return Hit(projection - offset) || Hit(projection + offset);
|
||||
|
||||
bool Hit(double distance) => distance > Epsilon && distance <= length + Epsilon
|
||||
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length));
|
||||
}
|
||||
var edge = End - Start;
|
||||
var relativeStart = Start - origin;
|
||||
var denominator = Cross(direction, edge);
|
||||
if (System.Math.Abs(denominator) <= 1e-12 * System.Math.Max(1, Length))
|
||||
{
|
||||
if (System.Math.Abs(Cross(relativeStart, direction)) > Epsilon)
|
||||
return false;
|
||||
var a = Dot(relativeStart, direction);
|
||||
var b = Dot(End - origin, direction);
|
||||
var low = System.Math.Max(0, System.Math.Min(a, b));
|
||||
var high = System.Math.Min(length, System.Math.Max(a, b));
|
||||
return high > Epsilon && low <= high + Epsilon;
|
||||
}
|
||||
var distanceAlongRapid = Cross(relativeStart, edge) / denominator;
|
||||
var fractionAlongEdge = Cross(relativeStart, direction) / denominator;
|
||||
return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon
|
||||
&& fractionAlongEdge >= -Epsilon / System.Math.Max(Length, Epsilon)
|
||||
&& fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon);
|
||||
}
|
||||
|
||||
internal bool CrossesRay(Vector point)
|
||||
{
|
||||
if (Center is not { } center)
|
||||
return (Start.Y > point.Y) != (End.Y > point.Y)
|
||||
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
|
||||
|
||||
// Split arcs at vertical extrema, giving monotone-Y pieces. Apply the same
|
||||
// half-open endpoint rule as a polygon ray test, solving X on the native
|
||||
// circle. This handles full circles, reversed arcs and shared vertices.
|
||||
var breaks = new List<double> { 0, System.Math.Abs(Sweep) };
|
||||
foreach (var angle in new[] { System.Math.PI / 2, 3 * System.Math.PI / 2 })
|
||||
{
|
||||
var travel = Travel(angle);
|
||||
if (travel > 0 && travel < System.Math.Abs(Sweep))
|
||||
breaks.Add(travel);
|
||||
}
|
||||
breaks.Sort();
|
||||
var inside = false;
|
||||
for (var i = 1; i < breaks.Count; i++)
|
||||
{
|
||||
var a = StartAngle + System.Math.Sign(Sweep) * breaks[i - 1];
|
||||
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
|
||||
var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a);
|
||||
var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * System.Math.Sin(b);
|
||||
if ((ya > point.Y) == (yb > point.Y))
|
||||
continue;
|
||||
var dy = point.Y - center.Y;
|
||||
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
|
||||
var x = center.X + (System.Math.Cos((a + b) / 2) >= 0 ? dx : -dx);
|
||||
if (x > point.X)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
public enum PostVerificationKind
|
||||
{
|
||||
Overlap,
|
||||
MissingLeadIn,
|
||||
RapidCrossing,
|
||||
Incomplete
|
||||
}
|
||||
|
||||
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
|
||||
public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber,
|
||||
int? PartNumber, int? OtherPartNumber, string Message);
|
||||
|
||||
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
|
||||
public sealed class PostVerificationReport
|
||||
{
|
||||
internal PostVerificationReport(IEnumerable<PostVerificationFinding> findings)
|
||||
{
|
||||
Findings = Array.AsReadOnly(findings.ToArray());
|
||||
}
|
||||
|
||||
public IReadOnlyList<PostVerificationFinding> Findings { get; }
|
||||
public bool HasWarnings => Findings.Count != 0;
|
||||
public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged;
|
||||
|
||||
public string ToDisplayText()
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.AppendLine("Pre-post verification");
|
||||
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
|
||||
Summary(PostVerificationKind.Overlap, "Overlap");
|
||||
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
|
||||
Summary(PostVerificationKind.RapidCrossing, "Rapid crossings");
|
||||
foreach (var finding in Findings)
|
||||
{
|
||||
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
|
||||
if (finding.PartNumber is { } part)
|
||||
text.Append($", part {part}");
|
||||
if (finding.OtherPartNumber is { } other)
|
||||
text.Append($", other part {other}");
|
||||
text.AppendLine($": {finding.Kind}: {finding.Message}");
|
||||
}
|
||||
text.AppendLine("This is not a physical safety certification. The check uses direct XY rapids " +
|
||||
"in plate/program order; the post may change order, routing or retracts. Inspect the posted " +
|
||||
"machine program and machine setup. Actual contour gaps are not proof of adequate retention.");
|
||||
return text.ToString();
|
||||
|
||||
void Summary(PostVerificationKind kind, string label)
|
||||
{
|
||||
var count = Findings.Count(finding => finding.Kind == kind);
|
||||
text.AppendLine($"{label}: {count} warning(s)" +
|
||||
(incomplete ? "; verification incomplete — do not treat as clear." : "."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Captures drawing program text before an edit and rebuilds only the parts whose
|
||||
/// drawing program changed. Drawing keys use reference identity because names are editable.
|
||||
/// </summary>
|
||||
public sealed class DrawingProgramSnapshot
|
||||
{
|
||||
private readonly Dictionary<Drawing, string> programs;
|
||||
private readonly Func<Program, string> fingerprint;
|
||||
|
||||
private DrawingProgramSnapshot(
|
||||
Dictionary<Drawing, string> programs,
|
||||
Func<Program, string> fingerprint)
|
||||
{
|
||||
this.programs = programs;
|
||||
this.fingerprint = fingerprint;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture before handing drawings to an editor, including its load operation:
|
||||
/// programs can be edited in place. The callback must include both the main
|
||||
/// program text and its hole sub-programs, and must not mutate the program.
|
||||
/// </summary>
|
||||
public static DrawingProgramSnapshot Capture(
|
||||
IEnumerable<Drawing> drawings,
|
||||
Func<Program, string> fingerprint)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(drawings);
|
||||
ArgumentNullException.ThrowIfNull(fingerprint);
|
||||
var programs = new Dictionary<Drawing, string>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
foreach (var drawing in drawings)
|
||||
{
|
||||
if (!drawing.IsCutOff && !programs.ContainsKey(drawing))
|
||||
programs.Add(drawing, fingerprint(drawing.Program));
|
||||
}
|
||||
|
||||
return new DrawingProgramSnapshot(programs, fingerprint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete the captured edit by rebuilding changed drawings' parts across all plates.
|
||||
/// Part.Update preserves the placement and clears obsolete lead-ins, tabs and locks.
|
||||
/// Unchanged and uncaptured drawings' parts retain their program instances and state.
|
||||
/// Returns the rebuilt parts so a UI can invalidate just their graphics.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Part> UpdateChangedParts(IEnumerable<Plate> plates)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plates);
|
||||
var changed = new HashSet<Drawing>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
foreach (var entry in programs)
|
||||
{
|
||||
if (!entry.Key.IsCutOff
|
||||
&& !string.Equals(entry.Value, fingerprint(entry.Key.Program), StringComparison.Ordinal))
|
||||
changed.Add(entry.Key);
|
||||
}
|
||||
|
||||
var updated = new List<Part>();
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (!changed.Contains(part.BaseDrawing))
|
||||
continue;
|
||||
|
||||
part.Update();
|
||||
updated.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Signed clearance between two closed polygons, plus the unit direction that
|
||||
/// increases it by moving the first polygon.
|
||||
/// </summary>
|
||||
public struct ClearanceResult
|
||||
{
|
||||
/// <summary>
|
||||
/// > 0: minimum boundary distance. 0: touching. < 0: penetration depth
|
||||
/// (the translation of <c>a</c> along <see cref="Direction"/> needed to end
|
||||
/// contact).
|
||||
/// </summary>
|
||||
public double Distance;
|
||||
|
||||
/// <summary>
|
||||
/// Unit direction for translating <c>a</c> away from <c>b</c>. For penetration
|
||||
/// this is the minimum-translation direction. Never zero-length; degenerate
|
||||
/// (coincident-centroid) penetration resolves to a deterministic axis.
|
||||
/// </summary>
|
||||
public Vector Direction;
|
||||
|
||||
public ClearanceResult(double distance, Vector direction)
|
||||
{
|
||||
Distance = distance;
|
||||
Direction = direction;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Omnidirectional clearance between two closed, lines-only polygons.
|
||||
/// Complements <see cref="SpatialQuery.DirectionalDistance"/> (movement along a
|
||||
/// fixed ray) with the all-directions minimum distance and separating direction,
|
||||
/// and <see cref="Collision"/> (boolean overlap) with depth and direction.
|
||||
/// <para>
|
||||
/// Reference quality, not hot-loop quality: separation is a brute-force
|
||||
/// segment-pair minimum with a bounding-box reject, penetration is a
|
||||
/// separating-axis sweep over both polygons' edge normals. The overlap verdict
|
||||
/// defers to <see cref="Collision.HasOverlap(Polygon, Polygon, List{Polygon}, List{Polygon})"/>
|
||||
/// so callers that validate with Collision never see a disagreeing kernel.
|
||||
/// Rings with holes are handled by the caller: pass every ring pair (a part's
|
||||
/// material boundary is its outer ring plus its hole rings).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class Clearance
|
||||
{
|
||||
public static ClearanceResult Between(Polygon a, Polygon b)
|
||||
{
|
||||
var linesA = a.ToLines();
|
||||
var linesB = b.ToLines();
|
||||
|
||||
if (linesA.Count == 0 || linesB.Count == 0)
|
||||
return new ClearanceResult(0, new Vector(1, 0));
|
||||
|
||||
if (Collision.HasOverlap(a, b))
|
||||
return Penetration(linesA, linesB);
|
||||
|
||||
return Separation(linesA, linesB);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-negative minimum boundary (edge-to-edge) distance between two rings
|
||||
/// and the direction that translates <paramref name="a"/> away from
|
||||
/// <paramref name="b"/> at the closest contact. Never tests overlap: a ring
|
||||
/// contained in another (a part inside a cutout ring) still reports its true
|
||||
/// gap. For signed material clearance use <see cref="Between"/>.
|
||||
/// </summary>
|
||||
public static ClearanceResult BoundaryDistance(Polygon a, Polygon b)
|
||||
{
|
||||
var linesA = a.ToLines();
|
||||
var linesB = b.ToLines();
|
||||
|
||||
if (linesA.Count == 0 || linesB.Count == 0)
|
||||
return new ClearanceResult(0, new Vector(1, 0));
|
||||
|
||||
return Separation(linesA, linesB);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum boundary distance between two non-overlapping rings and the
|
||||
/// direction that translates <paramref name="linesA"/> away from
|
||||
/// <paramref name="linesB"/> at the closest contact.
|
||||
/// </summary>
|
||||
private static ClearanceResult Separation(List<Line> linesA, List<Line> linesB)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var pa = Vector.Zero;
|
||||
var pb = Vector.Zero;
|
||||
|
||||
var boxes = new Box[linesB.Count];
|
||||
for (var i = 0; i < linesB.Count; i++)
|
||||
boxes[i] = SegmentBox(linesB[i]);
|
||||
|
||||
foreach (var la in linesA)
|
||||
{
|
||||
var boxA = SegmentBox(la);
|
||||
|
||||
for (var i = 0; i < linesB.Count; i++)
|
||||
{
|
||||
if (!BoxesWithin(boxA, boxes[i], minDist))
|
||||
continue;
|
||||
|
||||
var d = SegmentDistance(la, linesB[i], out var qa, out var qb);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
pa = qa;
|
||||
pb = qb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dir = pa - pb;
|
||||
var len = Magnitude(dir);
|
||||
|
||||
if (len <= Tolerance.Epsilon)
|
||||
dir = CentroidAway(linesA, linesB);
|
||||
else
|
||||
dir = dir / len;
|
||||
|
||||
return new ClearanceResult(minDist, dir);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Penetration depth and minimum-translation direction along the separating-
|
||||
/// axis candidates of both rings. Per candidate axis the true translation
|
||||
/// depth is used (exit distance to the far side), so containment reports the
|
||||
/// depth that actually ends contact, not the interval-intersection length.
|
||||
/// Depth is reported as a negative clearance.
|
||||
/// </summary>
|
||||
private static ClearanceResult Penetration(List<Line> linesA, List<Line> linesB)
|
||||
{
|
||||
var ca = Centroid(linesA);
|
||||
var cb = Centroid(linesB);
|
||||
|
||||
var bestDepth = double.MaxValue;
|
||||
var bestDir = new Vector(1, 0);
|
||||
|
||||
var bestAxis = -1;
|
||||
|
||||
for (var axis = 0; axis < 2; axis++)
|
||||
{
|
||||
var lines = axis == 0 ? linesA : linesB;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var edge = line.pt2 - line.pt1;
|
||||
var n = new Vector(edge.Y, -edge.X);
|
||||
var len = Magnitude(n);
|
||||
if (len <= Tolerance.Epsilon)
|
||||
continue;
|
||||
n = n / len;
|
||||
|
||||
var (minA, maxA) = Project(linesA, n);
|
||||
var (minB, maxB) = Project(linesB, n);
|
||||
|
||||
if (maxA <= minB || maxB <= minA)
|
||||
continue; // separating axis found
|
||||
|
||||
// Depth pushing a away from b along ±n.
|
||||
var forward = maxB - minA; // move a in +n until minA >= maxB
|
||||
var backward = maxA - minB; // move a in -n until maxA <= minB
|
||||
|
||||
double depth;
|
||||
Vector dir;
|
||||
if (forward <= backward)
|
||||
{
|
||||
depth = forward;
|
||||
dir = n;
|
||||
}
|
||||
else
|
||||
{
|
||||
depth = backward;
|
||||
dir = -n;
|
||||
}
|
||||
|
||||
if (depth < bestDepth - Tolerance.Epsilon || bestAxis < 0)
|
||||
{
|
||||
bestDepth = depth;
|
||||
bestDir = dir;
|
||||
bestAxis = axis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestAxis < 0)
|
||||
{
|
||||
// No candidate axis (degenerate rings): deterministic fallback.
|
||||
var away = ca - cb;
|
||||
var len = Magnitude(away);
|
||||
bestDir = len > Tolerance.Epsilon ? away / len : new Vector(1, 0);
|
||||
bestDepth = 0;
|
||||
}
|
||||
|
||||
return new ClearanceResult(-bestDepth, bestDir);
|
||||
}
|
||||
|
||||
private static Vector CentroidAway(List<Line> linesA, List<Line> linesB)
|
||||
{
|
||||
var away = Centroid(linesA) - Centroid(linesB);
|
||||
var len = Magnitude(away);
|
||||
return len > Tolerance.Epsilon ? away / len : new Vector(1, 0);
|
||||
}
|
||||
|
||||
private static Vector Centroid(List<Line> lines)
|
||||
{
|
||||
var sum = Vector.Zero;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
sum += line.pt1;
|
||||
sum += line.pt2;
|
||||
}
|
||||
return sum / (2 * lines.Count);
|
||||
}
|
||||
|
||||
private static (double Min, double Max) Project(List<Line> lines, Vector n)
|
||||
{
|
||||
var min = double.MaxValue;
|
||||
var max = double.MinValue;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var d1 = line.pt1.DotProduct(n);
|
||||
var d2 = line.pt2.DotProduct(n);
|
||||
if (d1 < min)
|
||||
min = d1;
|
||||
if (d1 > max)
|
||||
max = d1;
|
||||
if (d2 < min)
|
||||
min = d2;
|
||||
if (d2 > max)
|
||||
max = d2;
|
||||
}
|
||||
|
||||
return (min, max);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum distance between two segments with the closest points.
|
||||
/// Non-parallel segments use the classic clamped closest-point solve;
|
||||
/// (near-)parallel segments fall back to the four endpoint-to-segment
|
||||
/// distances, which is where the minimum always lies.
|
||||
/// </summary>
|
||||
private static double SegmentDistance(Line a, Line b, out Vector pa, out Vector pb)
|
||||
{
|
||||
var p = a.pt1;
|
||||
var r = a.pt2 - a.pt1;
|
||||
var q = b.pt1;
|
||||
var s = b.pt2 - b.pt1;
|
||||
|
||||
var rxr = r.DotProduct(r);
|
||||
var sxs = s.DotProduct(s);
|
||||
var rxs = r.DotProduct(s);
|
||||
|
||||
const double eps = 1e-12;
|
||||
|
||||
var denom = rxr * sxs - rxs * rxs;
|
||||
if (denom > eps && rxr > eps && sxs > eps)
|
||||
{
|
||||
// Minimize |(p + r t) - (q + s u)|^2; setting both partials to
|
||||
// zero and solving (Cramer) with d0 = p - q:
|
||||
// t = ((r.s)(d0.s) - (d0.r)(s.s)) / (rr.ss - (r.s)^2)
|
||||
// u = ((r.r)(d0.s) - (r.s)(d0.r)) / (rr.ss - (r.s)^2)
|
||||
var d0 = p - q;
|
||||
var d0r = d0.DotProduct(r);
|
||||
var d0s = d0.DotProduct(s);
|
||||
|
||||
var t = Clamp((rxs * d0s - d0r * sxs) / denom, 0, 1);
|
||||
var u = Clamp((rxs * t + d0s) / sxs, 0, 1); // nearest u on b for clamped t
|
||||
t = Clamp((rxs * u - d0r) / rxr, 0, 1); // re-solve t for clamped u
|
||||
|
||||
pa = p + r * t;
|
||||
pb = q + s * u;
|
||||
return pa.DistanceTo(pb);
|
||||
}
|
||||
|
||||
// Degenerate or parallel: the minimum is attained at an endpoint.
|
||||
var bestPa = p;
|
||||
var bestPb = q;
|
||||
var best = double.MaxValue;
|
||||
|
||||
void Consider(Vector pt, Line seg, bool ptOnA)
|
||||
{
|
||||
var d = seg.pt2 - seg.pt1;
|
||||
var len2 = d.DotProduct(d);
|
||||
var u = len2 <= eps ? 0 : Clamp((pt - seg.pt1).DotProduct(d) / len2, 0, 1);
|
||||
var on = seg.pt1 + d * u;
|
||||
var dist = pt.DistanceTo(on);
|
||||
if (dist < best)
|
||||
{
|
||||
best = dist;
|
||||
bestPa = ptOnA ? pt : on;
|
||||
bestPb = ptOnA ? on : pt;
|
||||
}
|
||||
}
|
||||
|
||||
Consider(p, b, true);
|
||||
Consider(a.pt2, b, true);
|
||||
Consider(q, a, false);
|
||||
Consider(b.pt2, a, false);
|
||||
|
||||
pa = bestPa;
|
||||
pb = bestPb;
|
||||
return best;
|
||||
}
|
||||
|
||||
private static double Clamp(double v, double lo, double hi) =>
|
||||
v < lo ? lo : (v > hi ? hi : v);
|
||||
|
||||
private static double Magnitude(Vector v) => System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
|
||||
|
||||
private static Box SegmentBox(Line line)
|
||||
{
|
||||
return new Box(
|
||||
System.Math.Min(line.pt1.X, line.pt2.X),
|
||||
System.Math.Min(line.pt1.Y, line.pt2.Y),
|
||||
System.Math.Abs(line.pt2.X - line.pt1.X),
|
||||
System.Math.Abs(line.pt2.Y - line.pt1.Y)
|
||||
);
|
||||
}
|
||||
|
||||
private static bool BoxesWithin(Box a, Box b, double distance)
|
||||
{
|
||||
return !(
|
||||
a.Right + distance < b.Left
|
||||
|| b.Right + distance < a.Left
|
||||
|| a.Top + distance < b.Bottom
|
||||
|| b.Top + distance < a.Bottom
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,14 +148,12 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
private static double SignedArea(List<Vector> verts)
|
||||
{
|
||||
// World-coordinate products can erase the sign of a small polygon's area
|
||||
// far from the origin, leaving CW outlines/holes untriangulated. Measure
|
||||
// relative to a vertex, just as the clipping kernel measures its fragments.
|
||||
var area = 0.0;
|
||||
|
||||
for (var i = 0; i < verts.Count; i++)
|
||||
{
|
||||
var j = (i + 1) % verts.Count;
|
||||
area += verts[i].X * verts[j].Y;
|
||||
area -= verts[j].X * verts[i].Y;
|
||||
}
|
||||
for (var i = 1; i + 1 < verts.Count; i++)
|
||||
area += Cross(verts[0], verts[i], verts[i + 1]);
|
||||
|
||||
return area * 0.5;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry;
|
||||
|
||||
/// <summary>
|
||||
/// Positive area and area centroid of a simple polygon, independent of winding.
|
||||
/// Moments are evaluated about a nearby origin rather than the world origin.
|
||||
/// </summary>
|
||||
public readonly struct PolygonAreaMoments
|
||||
{
|
||||
private PolygonAreaMoments(double area, Vector centroid)
|
||||
{
|
||||
Area = area;
|
||||
Centroid = centroid;
|
||||
}
|
||||
|
||||
public double Area { get; }
|
||||
public Vector Centroid { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Accepts an open vertex list or an exactly repeated closing vertex. Returns false
|
||||
/// for degenerate or nonfinite moments; does not validate polygon topology.
|
||||
/// </summary>
|
||||
public static bool TryCompute(IReadOnlyList<Vector> vertices, out PolygonAreaMoments moments)
|
||||
{
|
||||
moments = default;
|
||||
if (vertices == null || vertices.Count < 3)
|
||||
return false;
|
||||
foreach (var point in vertices)
|
||||
if (!IsFinite(point))
|
||||
return false;
|
||||
|
||||
var origin = vertices[0];
|
||||
var count = vertices.Count;
|
||||
if (vertices[count - 1].X == origin.X && vertices[count - 1].Y == origin.Y)
|
||||
count--;
|
||||
if (count < 3)
|
||||
return false;
|
||||
|
||||
var twiceArea = 0.0;
|
||||
var momentX = 0.0;
|
||||
var momentY = 0.0;
|
||||
// The closing edges meet the local origin and contribute zero. The signed
|
||||
// triangle fan also handles concavity without averaging polygon vertices.
|
||||
for (var i = 1; i + 1 < count; i++)
|
||||
{
|
||||
var a = vertices[i] - origin;
|
||||
var b = vertices[i + 1] - origin;
|
||||
var cross = a.X * b.Y - a.Y * b.X;
|
||||
twiceArea += cross;
|
||||
momentX += (a.X + b.X) * cross;
|
||||
momentY += (a.Y + b.Y) * cross;
|
||||
}
|
||||
|
||||
var area = System.Math.Abs(twiceArea) * 0.5;
|
||||
if (!double.IsFinite(area) || area <= 0
|
||||
|| !double.IsFinite(momentX) || !double.IsFinite(momentY))
|
||||
return false;
|
||||
// Dividing signed moments by signed area cancels the winding, while Area
|
||||
// stays positive so independently wound fragments always add material.
|
||||
var centroid = origin + new Vector(momentX / twiceArea / 3, momentY / twiceArea / 3);
|
||||
if (!IsFinite(centroid))
|
||||
return false;
|
||||
moments = new PolygonAreaMoments(area, centroid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines nonoverlapping, already hole-subtracted fragments using positive area
|
||||
/// weights and another local origin. The centroid may lie outside the material.
|
||||
/// Empty input or any invalid fragment fails the entire result.
|
||||
/// </summary>
|
||||
public static bool TryCombine(IEnumerable<PolygonAreaMoments> fragments, out PolygonAreaMoments moments)
|
||||
{
|
||||
moments = default;
|
||||
if (fragments == null)
|
||||
return false;
|
||||
var area = 0.0;
|
||||
var origin = Vector.Zero;
|
||||
var firstMoment = Vector.Zero;
|
||||
foreach (var fragment in fragments)
|
||||
{
|
||||
if (!double.IsFinite(fragment.Area) || fragment.Area <= 0 || !IsFinite(fragment.Centroid))
|
||||
return false;
|
||||
if (area == 0)
|
||||
origin = fragment.Centroid;
|
||||
firstMoment += (fragment.Centroid - origin) * fragment.Area;
|
||||
area += fragment.Area;
|
||||
}
|
||||
|
||||
if (!double.IsFinite(area) || area <= 0 || !IsFinite(firstMoment))
|
||||
return false;
|
||||
var centroid = origin + firstMoment / area;
|
||||
if (!IsFinite(centroid))
|
||||
return false;
|
||||
moments = new PolygonAreaMoments(area, centroid);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
internal enum ContactSide
|
||||
{
|
||||
/// <summary>The boundary could not be decomposed into closed loops.</summary>
|
||||
Unresolved,
|
||||
|
||||
/// <summary>The point is not on the boundary: a tolerance near-miss, not a contact.</summary>
|
||||
Off,
|
||||
|
||||
/// <summary>Several boundary runs meet here, or the corner is a cusp or spike.</summary>
|
||||
Ambiguous,
|
||||
|
||||
/// <summary>The material sector is known.</summary>
|
||||
Sector,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closed boundary loops of one entity list, prepared so a directional slide can tell
|
||||
/// which side of each boundary point is material. Immutable after
|
||||
/// <see cref="Prepare"/>, so one instance may be shared by concurrent queries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Loops are recovered from contiguous runs whose end points chain back to their start
|
||||
/// (the order produced by <see cref="ShapeBuilder"/> and the offset helpers). Nesting
|
||||
/// depth decides holes: material is inside even-depth loops and outside odd-depth ones.
|
||||
/// When the list cannot be decomposed that way, every contact query is unresolved.
|
||||
/// </remarks>
|
||||
public sealed class SlideContactGeometry
|
||||
{
|
||||
// Contact points are computed from unsnapped ray parameters, so a genuine contact is
|
||||
// on both boundaries to floating-point accuracy. This also bounds the overlap sliver a
|
||||
// tangential classification can admit, so keep it far below spacing tolerances.
|
||||
internal const double IncidenceTolerance = 1e-7;
|
||||
|
||||
private readonly List<Entity> entities;
|
||||
private readonly int[] loopOf;
|
||||
private readonly int[] previous;
|
||||
private readonly int[] following;
|
||||
private readonly bool[] materialLeft;
|
||||
|
||||
private SlideContactGeometry(
|
||||
List<Entity> entities,
|
||||
int[] loopOf,
|
||||
int[] previous,
|
||||
int[] following,
|
||||
bool[] materialLeft
|
||||
)
|
||||
{
|
||||
this.entities = entities;
|
||||
this.loopOf = loopOf;
|
||||
this.previous = previous;
|
||||
this.following = following;
|
||||
this.materialLeft = materialLeft;
|
||||
}
|
||||
|
||||
/// <summary>True when every entity belongs to a closed loop with a known material side.</summary>
|
||||
public bool IsResolved => materialLeft != null;
|
||||
|
||||
public static SlideContactGeometry Prepare(List<Entity> entities)
|
||||
{
|
||||
var count = entities.Count;
|
||||
var loopOf = new int[count];
|
||||
var previous = new int[count];
|
||||
var following = new int[count];
|
||||
var loops = new List<(int First, int Last)>();
|
||||
|
||||
var i = 0;
|
||||
while (i < count)
|
||||
{
|
||||
var first = i;
|
||||
if (entities[i] is Circle)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!TryEndpoints(entities[i], out var start, out _))
|
||||
return Unresolved(entities);
|
||||
|
||||
var closed = false;
|
||||
while (i < count && TryEndpoints(entities[i], out _, out var end))
|
||||
{
|
||||
// A lone closed arc is a loop; a lone line cannot be, even when it
|
||||
// has zero length and so ends where it starts.
|
||||
if (Near(end, start) && (i > first || entities[i] is Arc))
|
||||
{
|
||||
closed = true;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
i + 1 >= count
|
||||
|| !TryEndpoints(entities[i + 1], out var nextStart, out _)
|
||||
|| !Near(nextStart, end)
|
||||
)
|
||||
break;
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
if (!closed)
|
||||
return Unresolved(entities);
|
||||
}
|
||||
|
||||
var loop = loops.Count;
|
||||
loops.Add((first, i - 1));
|
||||
for (var k = first; k < i; k++)
|
||||
{
|
||||
loopOf[k] = loop;
|
||||
previous[k] = k == first ? i - 1 : k - 1;
|
||||
following[k] = k == i - 1 ? first : k + 1;
|
||||
}
|
||||
}
|
||||
|
||||
var materialLeft = new bool[loops.Count];
|
||||
|
||||
for (var loop = 0; loop < loops.Count; loop++)
|
||||
{
|
||||
var area = SignedArea(entities, loops[loop].First, loops[loop].Last);
|
||||
if (System.Math.Abs(area) <= Tolerance.Epsilon)
|
||||
return Unresolved(entities);
|
||||
|
||||
var depth = 0;
|
||||
if (loops.Count > 1)
|
||||
{
|
||||
var sample = SamplePoint(entities[loops[loop].First]);
|
||||
for (var other = 0; other < loops.Count; other++)
|
||||
{
|
||||
if (other == loop)
|
||||
continue;
|
||||
if (Contains(entities, loops[other].First, loops[other].Last, sample))
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
|
||||
materialLeft[loop] = (area > 0) == (depth % 2 == 0);
|
||||
}
|
||||
|
||||
return new SlideContactGeometry(entities, loopOf, previous, following, materialLeft);
|
||||
}
|
||||
|
||||
private static SlideContactGeometry Unresolved(List<Entity> entities) =>
|
||||
new SlideContactGeometry(entities, null, null, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Material directions at a boundary point: an angular sector starting at
|
||||
/// <paramref name="start"/> and sweeping CCW by <paramref name="width"/>.
|
||||
/// Concavity is recorded separately at each sector ray: only the supporting
|
||||
/// curve, not an unrelated curve at that corner, can block a tangential slide.
|
||||
/// Entities wholly inside the incidence tolerance are treated as part of the corner.
|
||||
/// </summary>
|
||||
internal ContactSide GetMaterialSector(
|
||||
Vector point,
|
||||
out double start,
|
||||
out double width,
|
||||
out bool startConcave,
|
||||
out bool endConcave
|
||||
)
|
||||
{
|
||||
start = width = 0;
|
||||
startConcave = endConcave = false;
|
||||
if (materialLeft == null)
|
||||
return ContactSide.Unresolved;
|
||||
|
||||
var best = -1;
|
||||
var bestDistance = double.MaxValue;
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
var distance = DistanceTo(entities[i], point);
|
||||
if (distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (best < 0 || bestDistance > IncidenceTolerance)
|
||||
return ContactSide.Off;
|
||||
|
||||
// Walk to the entities that enter and leave the tolerance disc.
|
||||
var loopLength = LoopLength(best);
|
||||
var incoming = best;
|
||||
var steps = 0;
|
||||
var smoothLoop = loopLength == 1 && (entities[best] is Circle
|
||||
|| entities[best] is Arc fullArc && fullArc.IsFullCircle());
|
||||
while (!smoothLoop && StartsNear(incoming, point))
|
||||
{
|
||||
incoming = previous[incoming];
|
||||
if (++steps >= loopLength)
|
||||
return ContactSide.Ambiguous;
|
||||
}
|
||||
|
||||
var outgoing = best;
|
||||
steps = 0;
|
||||
while (!smoothLoop && EndsNear(outgoing, point))
|
||||
{
|
||||
outgoing = following[outgoing];
|
||||
if (++steps >= loopLength)
|
||||
return ContactSide.Ambiguous;
|
||||
}
|
||||
|
||||
// Anything else touching this point (another loop, a spike, a self-crossing)
|
||||
// makes the local material side ambiguous.
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
if (InRun(i, incoming, outgoing))
|
||||
continue;
|
||||
if (DistanceTo(entities[i], point) <= IncidenceTolerance)
|
||||
return ContactSide.Ambiguous;
|
||||
}
|
||||
|
||||
var interior = incoming == best && outgoing == best && !EndsNear(best, point);
|
||||
var inTangent = interior ? TangentAt(entities[best], point) : EndTangent(entities[incoming]);
|
||||
var outTangent = interior
|
||||
? inTangent
|
||||
: StartTangent(entities[outgoing]);
|
||||
|
||||
// A circle has no endpoints, so its point is always interior.
|
||||
if (smoothLoop)
|
||||
inTangent = outTangent = TangentAt(entities[best], point);
|
||||
|
||||
if (IsZero(inTangent) || IsZero(outTangent))
|
||||
return ContactSide.Ambiguous;
|
||||
|
||||
var outAngle = System.Math.Atan2(outTangent.Y, outTangent.X);
|
||||
var inAngle = System.Math.Atan2(-inTangent.Y, -inTangent.X);
|
||||
var left = materialLeft[loopOf[best]];
|
||||
|
||||
start = left ? outAngle : inAngle;
|
||||
width = Angle.NormalizeRad((left ? inAngle : outAngle) - start);
|
||||
|
||||
startConcave = IsConcave(entities[left ? outgoing : incoming], left);
|
||||
endConcave = IsConcave(entities[left ? incoming : outgoing], left);
|
||||
|
||||
return
|
||||
width > SlideContact.AngleTolerance
|
||||
&& width < Angle.TwoPI - 2 * SlideContact.SplitOverlap
|
||||
? ContactSide.Sector
|
||||
: ContactSide.Ambiguous;
|
||||
}
|
||||
|
||||
private int LoopLength(int index)
|
||||
{
|
||||
var length = 1;
|
||||
for (var i = following[index]; i != index; i = following[i])
|
||||
length++;
|
||||
return length;
|
||||
}
|
||||
|
||||
private bool StartsNear(int index, Vector point) =>
|
||||
TryEndpoints(entities[index], out var start, out _)
|
||||
&& start.DistanceTo(point) <= IncidenceTolerance;
|
||||
|
||||
private bool EndsNear(int index, Vector point) =>
|
||||
TryEndpoints(entities[index], out _, out var end)
|
||||
&& end.DistanceTo(point) <= IncidenceTolerance;
|
||||
|
||||
private bool InRun(int index, int first, int last)
|
||||
{
|
||||
for (var i = first; ; i = following[i])
|
||||
{
|
||||
if (i == index)
|
||||
return true;
|
||||
if (i == last)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsZero(Vector v) => v.X == 0 && v.Y == 0;
|
||||
|
||||
private static bool IsConcave(Entity entity, bool materialLeft)
|
||||
{
|
||||
// A CCW curve has its center on its left; that center is on the free side
|
||||
// (a concave boundary) exactly when material is on the right.
|
||||
return entity switch
|
||||
{
|
||||
Arc arc => materialLeft == arc.IsReversed,
|
||||
Circle circle => materialLeft == (circle.Rotation == RotationType.CW),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static Vector StartTangent(Entity entity) =>
|
||||
entity switch
|
||||
{
|
||||
Line line => Direction(line.pt1, line.pt2),
|
||||
Arc arc => ArcTangent(arc.StartAngle, arc.IsReversed),
|
||||
_ => new Vector(),
|
||||
};
|
||||
|
||||
private static Vector EndTangent(Entity entity) =>
|
||||
entity switch
|
||||
{
|
||||
Line line => Direction(line.pt1, line.pt2),
|
||||
Arc arc => ArcTangent(arc.EndAngle, arc.IsReversed),
|
||||
_ => new Vector(),
|
||||
};
|
||||
|
||||
private static Vector TangentAt(Entity entity, Vector point) =>
|
||||
entity switch
|
||||
{
|
||||
Line line => Direction(line.pt1, line.pt2),
|
||||
Arc arc => ArcTangent(arc.Center.AngleTo(point), arc.IsReversed),
|
||||
Circle circle => ArcTangent(
|
||||
circle.Center.AngleTo(point),
|
||||
circle.Rotation == RotationType.CW
|
||||
),
|
||||
_ => new Vector(),
|
||||
};
|
||||
|
||||
private static Vector ArcTangent(double angle, bool clockwise)
|
||||
{
|
||||
var sign = clockwise ? -1.0 : 1.0;
|
||||
return new Vector(-System.Math.Sin(angle) * sign, System.Math.Cos(angle) * sign);
|
||||
}
|
||||
|
||||
private static Vector Direction(Vector from, Vector to)
|
||||
{
|
||||
var dx = to.X - from.X;
|
||||
var dy = to.Y - from.Y;
|
||||
var length = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
return length > 0 ? new Vector(dx / length, dy / length) : new Vector();
|
||||
}
|
||||
|
||||
private static double DistanceTo(Entity entity, Vector point)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Line line:
|
||||
return point.DistanceTo(line.ClosestPointTo(point));
|
||||
case Arc arc:
|
||||
{
|
||||
var angle = arc.Center.AngleTo(point);
|
||||
if (Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||
return System.Math.Abs(arc.Center.DistanceTo(point) - arc.Radius);
|
||||
return System.Math.Min(
|
||||
point.DistanceTo(arc.StartPoint()),
|
||||
point.DistanceTo(arc.EndPoint())
|
||||
);
|
||||
}
|
||||
case Circle circle:
|
||||
return System.Math.Abs(circle.Center.DistanceTo(point) - circle.Radius);
|
||||
default:
|
||||
return double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryEndpoints(Entity entity, out Vector start, out Vector end)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Line line:
|
||||
start = line.pt1;
|
||||
end = line.pt2;
|
||||
return true;
|
||||
case Arc arc:
|
||||
start = arc.StartPoint();
|
||||
end = arc.EndPoint();
|
||||
return true;
|
||||
default:
|
||||
start = end = new Vector();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= IncidenceTolerance;
|
||||
|
||||
private static double SignedArea(List<Entity> entities, int first, int last)
|
||||
{
|
||||
var area = 0.0;
|
||||
for (var i = first; i <= last; i++)
|
||||
{
|
||||
switch (entities[i])
|
||||
{
|
||||
case Circle circle:
|
||||
var sign = circle.Rotation == RotationType.CW ? -1 : 1;
|
||||
area += sign * System.Math.PI * circle.Radius * circle.Radius;
|
||||
break;
|
||||
case Line line:
|
||||
area += Cross(line.pt1, line.pt2) / 2;
|
||||
break;
|
||||
case Arc arc:
|
||||
var sweep = arc.IsReversed ? -arc.SweepAngle() : arc.SweepAngle();
|
||||
var r = arc.Radius;
|
||||
area += Cross(arc.StartPoint(), arc.EndPoint()) / 2;
|
||||
area += r * r / 2 * (sweep - System.Math.Sin(sweep));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
private static double Cross(Vector a, Vector b) => a.X * b.Y - b.X * a.Y;
|
||||
|
||||
private static Vector SamplePoint(Entity entity) =>
|
||||
entity switch
|
||||
{
|
||||
Circle circle => new Vector(circle.Center.X + circle.Radius, circle.Center.Y),
|
||||
Arc arc => arc.StartPoint(),
|
||||
Line line => line.pt1,
|
||||
_ => new Vector(),
|
||||
};
|
||||
|
||||
// Exact horizontal-ray parity. Split arcs at Y extrema so every piece is
|
||||
// monotone; the same half-open endpoint rule as lines avoids seam double counts.
|
||||
// A coarse inscribed polygon can misclassify thin rings as solid material.
|
||||
private static bool Contains(List<Entity> entities, int first, int last, Vector point)
|
||||
{
|
||||
var inside = false;
|
||||
for (var i = first; i <= last; i++)
|
||||
{
|
||||
if (entities[i] is Circle circle)
|
||||
return circle.Center.DistanceTo(point) < circle.Radius;
|
||||
if (entities[i] is Line line)
|
||||
{
|
||||
var a = line.pt1;
|
||||
var b = line.pt2;
|
||||
if ((a.Y > point.Y) != (b.Y > point.Y)
|
||||
&& point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
|
||||
inside = !inside;
|
||||
}
|
||||
else if (entities[i] is Arc arc)
|
||||
{
|
||||
var sweep = arc.SweepAngle();
|
||||
var sign = arc.IsReversed ? -1.0 : 1.0;
|
||||
var cuts = new List<double> { 0, sweep };
|
||||
foreach (var extreme in new[] { Angle.HalfPI, 3 * Angle.HalfPI })
|
||||
{
|
||||
var t = Angle.NormalizeRad(sign * (extreme - arc.StartAngle));
|
||||
if (t > 0 && t < sweep)
|
||||
cuts.Add(t);
|
||||
}
|
||||
cuts.Sort();
|
||||
for (var k = 1; k < cuts.Count; k++)
|
||||
{
|
||||
var a = arc.StartAngle + sign * cuts[k - 1];
|
||||
var b = arc.StartAngle + sign * cuts[k];
|
||||
var y1 = arc.Center.Y + arc.Radius * System.Math.Sin(a);
|
||||
var y2 = arc.Center.Y + arc.Radius * System.Math.Sin(b);
|
||||
if ((y1 > point.Y) == (y2 > point.Y))
|
||||
continue;
|
||||
var dy = point.Y - arc.Center.Y;
|
||||
var dx = System.Math.Sqrt(System.Math.Max(0, arc.Radius * arc.Radius - dy * dy));
|
||||
var x = arc.Center.X + (System.Math.Cos((a + b) / 2) >= 0 ? dx : -dx);
|
||||
if (point.X < x)
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contact classifier for one moving/stationary pair of boundaries. Geometry is prepared
|
||||
/// on first use, so a slide whose nearest contact is never classified pays nothing; call
|
||||
/// <see cref="Prepare"/> before sharing one instance across threads. Each boundary is
|
||||
/// given in its own frame; the origins place those frames in the world coordinates used
|
||||
/// by slide events.
|
||||
/// </summary>
|
||||
public sealed class SlideContactClassifier
|
||||
{
|
||||
private readonly System.Func<List<Entity>> movingSource;
|
||||
private readonly System.Func<List<Entity>> stationarySource;
|
||||
private SlideContactGeometry moving;
|
||||
private SlideContactGeometry stationary;
|
||||
|
||||
public SlideContactClassifier(List<Entity> movingEntities, List<Entity> stationaryEntities)
|
||||
: this(movingEntities, Vector.Zero, stationaryEntities, Vector.Zero) { }
|
||||
|
||||
public SlideContactClassifier(
|
||||
List<Entity> movingEntities,
|
||||
Vector movingOrigin,
|
||||
List<Entity> stationaryEntities,
|
||||
Vector stationaryOrigin
|
||||
)
|
||||
: this(() => movingEntities, movingOrigin, () => stationaryEntities, stationaryOrigin)
|
||||
{ }
|
||||
|
||||
public SlideContactClassifier(
|
||||
SlideContactGeometry moving,
|
||||
Vector movingOrigin,
|
||||
SlideContactGeometry stationary,
|
||||
Vector stationaryOrigin
|
||||
)
|
||||
{
|
||||
this.moving = moving;
|
||||
this.stationary = stationary;
|
||||
MovingOrigin = movingOrigin;
|
||||
StationaryOrigin = stationaryOrigin;
|
||||
}
|
||||
|
||||
private SlideContactClassifier(
|
||||
System.Func<List<Entity>> movingSource,
|
||||
Vector movingOrigin,
|
||||
System.Func<List<Entity>> stationarySource,
|
||||
Vector stationaryOrigin
|
||||
)
|
||||
{
|
||||
this.movingSource = movingSource;
|
||||
this.stationarySource = stationarySource;
|
||||
MovingOrigin = movingOrigin;
|
||||
StationaryOrigin = stationaryOrigin;
|
||||
}
|
||||
|
||||
public Vector MovingOrigin { get; }
|
||||
|
||||
public Vector StationaryOrigin { get; }
|
||||
|
||||
public static SlideContactClassifier FromLines(
|
||||
List<Line> movingLines,
|
||||
Vector movingOrigin,
|
||||
List<Line> stationaryLines,
|
||||
Vector stationaryOrigin
|
||||
) =>
|
||||
new SlideContactClassifier(
|
||||
() => new List<Entity>(movingLines),
|
||||
movingOrigin,
|
||||
() => new List<Entity>(stationaryLines),
|
||||
stationaryOrigin
|
||||
);
|
||||
|
||||
public static SlideContactClassifier FromEdges(
|
||||
(Vector start, Vector end)[] movingEdges,
|
||||
Vector movingOrigin,
|
||||
(Vector start, Vector end)[] stationaryEdges,
|
||||
Vector stationaryOrigin
|
||||
)
|
||||
{
|
||||
// The kernel sorts edge arrays in place, so snapshot the chain order now.
|
||||
var moving = ((Vector start, Vector end)[])movingEdges.Clone();
|
||||
var stationary = ((Vector start, Vector end)[])stationaryEdges.Clone();
|
||||
return new SlideContactClassifier(
|
||||
() => ToLines(moving),
|
||||
movingOrigin,
|
||||
() => ToLines(stationary),
|
||||
stationaryOrigin
|
||||
);
|
||||
}
|
||||
|
||||
private static List<Entity> ToLines((Vector start, Vector end)[] edges)
|
||||
{
|
||||
var lines = new List<Entity>(edges.Length);
|
||||
foreach (var (start, end) in edges)
|
||||
lines.Add(new Line(start, end));
|
||||
// Public edge arrays are sorted in place by previous queries. Recover their
|
||||
// chains on private line objects; never reverse or reorder caller geometry.
|
||||
var ordered = new List<Entity>(lines.Count);
|
||||
foreach (var shape in ShapeBuilder.GetShapes(lines))
|
||||
ordered.AddRange(shape.Entities);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
public SlideContactClassifier Prepare()
|
||||
{
|
||||
moving ??= SlideContactGeometry.Prepare(movingSource?.Invoke() ?? new List<Entity>());
|
||||
stationary ??= SlideContactGeometry.Prepare(
|
||||
stationarySource?.Invoke() ?? new List<Entity>()
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>The same prepared boundaries placed at other origins.</summary>
|
||||
public SlideContactClassifier At(Vector movingOrigin, Vector stationaryOrigin)
|
||||
{
|
||||
Prepare();
|
||||
return new SlideContactClassifier(moving, movingOrigin, stationary, stationaryOrigin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when moving along (dirX, dirY) from this world-space contact would push
|
||||
/// material into material, or the contact cannot be classified.
|
||||
/// </summary>
|
||||
public bool Blocks(Vector movingPoint, Vector stationaryPoint, double dirX, double dirY)
|
||||
{
|
||||
Prepare();
|
||||
return SlideContact.Blocks(
|
||||
moving,
|
||||
movingPoint - MovingOrigin,
|
||||
stationary,
|
||||
stationaryPoint - StationaryOrigin,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Receives candidate contact events from a directional slide query.</summary>
|
||||
public interface ISlideEventSink
|
||||
{
|
||||
/// <summary>True once further events cannot change this sink's result.</summary>
|
||||
bool IsDone { get; }
|
||||
|
||||
/// <param name="distance">Travel to the contact, snapped to zero within Tolerance.Epsilon.</param>
|
||||
/// <param name="movingPoint">Contact on the moving boundary, at its start position.</param>
|
||||
/// <param name="stationaryPoint">Contact on the stationary boundary.</param>
|
||||
void Add(double distance, Vector movingPoint, Vector stationaryPoint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates every candidate contact of one slide. Must yield the same events each
|
||||
/// time it is enumerated.
|
||||
/// </summary>
|
||||
public interface ISlideEventSource
|
||||
{
|
||||
void Enumerate<TSink>(ref TSink sink)
|
||||
where TSink : struct, ISlideEventSink;
|
||||
}
|
||||
|
||||
/// <summary>Keeps the nearest event; stops at a contact that is already touching.</summary>
|
||||
public struct NearestSlideEvent : ISlideEventSink
|
||||
{
|
||||
public bool Found;
|
||||
public double Distance;
|
||||
public Vector MovingPoint;
|
||||
public Vector StationaryPoint;
|
||||
|
||||
public bool IsDone => Found && Distance <= 0;
|
||||
|
||||
public void Add(double distance, Vector movingPoint, Vector stationaryPoint)
|
||||
{
|
||||
if (Found && distance >= Distance)
|
||||
return;
|
||||
|
||||
Found = true;
|
||||
Distance = distance;
|
||||
MovingPoint = movingPoint;
|
||||
StationaryPoint = stationaryPoint;
|
||||
}
|
||||
}
|
||||
|
||||
internal struct SlideEventList : ISlideEventSink
|
||||
{
|
||||
public List<(double Distance, Vector MovingPoint, Vector StationaryPoint)> Events;
|
||||
|
||||
public bool IsDone => false;
|
||||
|
||||
public void Add(double distance, Vector movingPoint, Vector stationaryPoint) =>
|
||||
Events.Add((distance, movingPoint, stationaryPoint));
|
||||
}
|
||||
|
||||
public static class SlideResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Travel to the first contact that blocks the slide, or double.MaxValue. When the
|
||||
/// nearest contact blocks (every contact, for unresolved boundaries), the result is
|
||||
/// exactly the nearest event distance and the events are enumerated once.
|
||||
/// </summary>
|
||||
public static double FirstBlocking<TSource>(
|
||||
ref TSource source,
|
||||
SlideContactClassifier contacts,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
where TSource : struct, ISlideEventSource
|
||||
{
|
||||
var nearest = new NearestSlideEvent();
|
||||
source.Enumerate(ref nearest);
|
||||
|
||||
if (!nearest.Found)
|
||||
return double.MaxValue;
|
||||
|
||||
if (contacts.Blocks(nearest.MovingPoint, nearest.StationaryPoint, dirX, dirY))
|
||||
return nearest.Distance;
|
||||
|
||||
var all = new SlideEventList
|
||||
{
|
||||
Events = new List<(double, Vector, Vector)>(),
|
||||
};
|
||||
source.Enumerate(ref all);
|
||||
all.Events.Sort((a, b) => a.Distance.CompareTo(b.Distance));
|
||||
|
||||
foreach (var (distance, movingPoint, stationaryPoint) in all.Events)
|
||||
{
|
||||
if (contacts.Blocks(movingPoint, stationaryPoint, dirX, dirY))
|
||||
return distance;
|
||||
}
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a first-contact event found by a directional slide stops the slide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Parts that already touch may slide along each other or apart. Only a direction that
|
||||
/// would create positive-area overlap blocks: with S the stationary material sector and
|
||||
/// M the moving one at the contact point, that is the open Minkowski cone S ⊕ −M.
|
||||
/// A direction on that cone's boundary is a tangential slide; it blocks only when an
|
||||
/// incident curve is concave, because the second-order bend then closes the gap.
|
||||
/// Unresolved or ambiguous topology blocks, which is the previous behavior for every
|
||||
/// contact.
|
||||
/// </remarks>
|
||||
public static class SlideContact
|
||||
{
|
||||
internal const double AngleTolerance = 1e-7;
|
||||
|
||||
// Reflex sectors are split into two overlapping convex halves; the overlap keeps
|
||||
// the split ray in the interior of the union.
|
||||
internal const double SplitOverlap = 1e-3;
|
||||
|
||||
/// <summary>
|
||||
/// True when moving along (dirX, dirY) from this contact would push material into
|
||||
/// material, or when the contact cannot be classified. False for a near-miss whose
|
||||
/// point is not on both boundaries.
|
||||
/// </summary>
|
||||
/// <param name="movingPoint">Contact point in the moving entities' own frame.</param>
|
||||
/// <param name="stationaryPoint">The same contact in the stationary frame.</param>
|
||||
public static bool Blocks(
|
||||
SlideContactGeometry moving,
|
||||
Vector movingPoint,
|
||||
SlideContactGeometry stationary,
|
||||
Vector stationaryPoint,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
{
|
||||
if (moving == null || stationary == null)
|
||||
return true;
|
||||
|
||||
var stationarySide = stationary.GetMaterialSector(
|
||||
stationaryPoint,
|
||||
out var stationaryStart,
|
||||
out var stationaryWidth,
|
||||
out var stationaryStartConcave,
|
||||
out var stationaryEndConcave
|
||||
);
|
||||
var movingSide = moving.GetMaterialSector(
|
||||
movingPoint,
|
||||
out var movingStart,
|
||||
out var movingWidth,
|
||||
out var movingStartConcave,
|
||||
out var movingEndConcave
|
||||
);
|
||||
|
||||
if (stationarySide == ContactSide.Unresolved || movingSide == ContactSide.Unresolved)
|
||||
return true;
|
||||
|
||||
// Ray tolerances report hits slightly beyond an entity's end; such a point is
|
||||
// not on the other boundary, so the parts pass without touching there.
|
||||
if (stationarySide == ContactSide.Off || movingSide == ContactSide.Off)
|
||||
return false;
|
||||
|
||||
if (stationarySide == ContactSide.Ambiguous || movingSide == ContactSide.Ambiguous)
|
||||
return true;
|
||||
|
||||
var direction = System.Math.Atan2(dirY, dirX);
|
||||
var stationaryPieces = Split(stationaryStart, stationaryWidth);
|
||||
var movingPieces = Split(movingStart + System.Math.PI, movingWidth);
|
||||
var onBoundary = false;
|
||||
|
||||
foreach (var s in stationaryPieces)
|
||||
{
|
||||
foreach (var m in movingPieces)
|
||||
{
|
||||
if (!TryHull(s, m, out var hullStart, out var hullWidth))
|
||||
return true;
|
||||
|
||||
var offset = Angle.NormalizeRad(direction - hullStart);
|
||||
if (offset > AngleTolerance && offset < hullWidth - AngleTolerance)
|
||||
return true;
|
||||
|
||||
if (
|
||||
offset <= AngleTolerance
|
||||
|| offset >= Angle.TwoPI - AngleTolerance
|
||||
|| System.Math.Abs(offset - hullWidth) <= AngleTolerance
|
||||
)
|
||||
onBoundary = true;
|
||||
}
|
||||
}
|
||||
|
||||
return onBoundary && (
|
||||
stationaryStartConcave && SameRay(direction, stationaryStart)
|
||||
|| stationaryEndConcave && SameRay(direction, stationaryStart + stationaryWidth)
|
||||
|| movingStartConcave && SameRay(direction, movingStart + System.Math.PI)
|
||||
|| movingEndConcave && SameRay(direction, movingStart + movingWidth + System.Math.PI));
|
||||
}
|
||||
|
||||
private static bool SameRay(double a, double b)
|
||||
{
|
||||
var offset = Angle.NormalizeRad(a - b);
|
||||
return offset <= AngleTolerance || offset >= Angle.TwoPI - AngleTolerance;
|
||||
}
|
||||
|
||||
private static (double Start, double Width)[] Split(double start, double width)
|
||||
{
|
||||
if (width <= System.Math.PI + AngleTolerance)
|
||||
return new[] { (start, width) };
|
||||
|
||||
var half = width / 2;
|
||||
return new[]
|
||||
{
|
||||
(start, half + SplitOverlap),
|
||||
(start + half - SplitOverlap, half + SplitOverlap),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convex cone generated by two convex sectors. False when it is the whole plane.
|
||||
/// </summary>
|
||||
private static bool TryHull(
|
||||
(double Start, double Width) a,
|
||||
(double Start, double Width) b,
|
||||
out double start,
|
||||
out double width
|
||||
)
|
||||
{
|
||||
var fromA = System.Math.Max(a.Width, Angle.NormalizeRad(b.Start - a.Start) + b.Width);
|
||||
var fromB = System.Math.Max(b.Width, Angle.NormalizeRad(a.Start - b.Start) + a.Width);
|
||||
|
||||
if (fromA <= fromB)
|
||||
{
|
||||
start = a.Start;
|
||||
width = fromA;
|
||||
}
|
||||
else
|
||||
{
|
||||
start = b.Start;
|
||||
width = fromB;
|
||||
}
|
||||
|
||||
return width <= System.Math.PI + AngleTolerance;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Candidate contact events of directional slides. Each emitter reports every forward
|
||||
/// hit its distance kernel considers, with the distance snapped exactly as that kernel
|
||||
/// snaps it, so the nearest event equals the kernel's historical minimum.
|
||||
/// </summary>
|
||||
internal static class SlideEvents
|
||||
{
|
||||
private const double Eps = Tolerance.Epsilon;
|
||||
|
||||
private static double Snap(double t) => t > Eps ? t : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Ray from a vertex against one entity. When <paramref name="vertexMoves"/> is true
|
||||
/// the vertex belongs to the moving boundary and the ray follows the push direction;
|
||||
/// otherwise it is a stationary vertex and the ray runs opposite to the push.
|
||||
/// </summary>
|
||||
public static void Ray<TSink>(
|
||||
ref TSink sink,
|
||||
double vx,
|
||||
double vy,
|
||||
Entity entity,
|
||||
double entityDx,
|
||||
double entityDy,
|
||||
double rayX,
|
||||
double rayY,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Line line:
|
||||
RayLine(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
line.pt1.X + entityDx,
|
||||
line.pt1.Y + entityDy,
|
||||
line.pt2.X + entityDx,
|
||||
line.pt2.Y + entityDy,
|
||||
rayX,
|
||||
rayY,
|
||||
vertexMoves
|
||||
);
|
||||
break;
|
||||
|
||||
case Arc arc:
|
||||
{
|
||||
var cx = arc.Center.X + entityDx;
|
||||
var cy = arc.Center.Y + entityDy;
|
||||
if (!SolveRayCircle(vx, vy, cx, cy, arc.Radius, rayX, rayY, out var t1, out var t2))
|
||||
return;
|
||||
|
||||
for (var k = 0; k < 2; k++)
|
||||
{
|
||||
var t = k == 0 ? t1 : t2;
|
||||
if (t <= -Eps)
|
||||
continue;
|
||||
|
||||
var hitAngle = Angle.NormalizeRad(
|
||||
System.Math.Atan2(vy + t * rayY - cy, vx + t * rayX - cx)
|
||||
);
|
||||
if (!Angle.IsBetweenRad(hitAngle, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||
continue;
|
||||
|
||||
Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Circle circle:
|
||||
{
|
||||
if (
|
||||
!SolveRayCircle(
|
||||
vx,
|
||||
vy,
|
||||
circle.Center.X + entityDx,
|
||||
circle.Center.Y + entityDy,
|
||||
circle.Radius,
|
||||
rayX,
|
||||
rayY,
|
||||
out var t1,
|
||||
out var t2
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
for (var k = 0; k < 2; k++)
|
||||
{
|
||||
var t = k == 0 ? t1 : t2;
|
||||
if (t < -Eps)
|
||||
continue;
|
||||
|
||||
Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Same hit rule as <see cref="SpatialQuery.RayEdgeDistance(double, double, double, double, double, double, double, double)"/>.</summary>
|
||||
public static void RayLine<TSink>(
|
||||
ref TSink sink,
|
||||
double vx,
|
||||
double vy,
|
||||
double p1x,
|
||||
double p1y,
|
||||
double p2x,
|
||||
double p2y,
|
||||
double rayX,
|
||||
double rayY,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
var ex = p2x - p1x;
|
||||
var ey = p2y - p1y;
|
||||
|
||||
var det = ex * rayY - ey * rayX;
|
||||
if (System.Math.Abs(det) < Eps)
|
||||
return;
|
||||
|
||||
var dvx = p1x - vx;
|
||||
var dvy = p1y - vy;
|
||||
|
||||
var t = (ex * dvy - ey * dvx) / det;
|
||||
if (t < -Eps)
|
||||
return;
|
||||
|
||||
var s = (rayX * dvy - rayY * dvx) / det;
|
||||
if (s < -Eps || s > 1.0 + Eps)
|
||||
return;
|
||||
|
||||
Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Axis-aligned ray against a segment, with the same hit rule as the
|
||||
/// <see cref="PushDirection"/> kernel.
|
||||
/// </summary>
|
||||
public static void AxisRayLine<TSink>(
|
||||
ref TSink sink,
|
||||
double vx,
|
||||
double vy,
|
||||
double p1x,
|
||||
double p1y,
|
||||
double p2x,
|
||||
double p2y,
|
||||
PushDirection rayDirection,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
double dist,
|
||||
hx,
|
||||
hy;
|
||||
|
||||
switch (rayDirection)
|
||||
{
|
||||
case PushDirection.Left:
|
||||
case PushDirection.Right:
|
||||
{
|
||||
var dy = p2y - p1y;
|
||||
if (System.Math.Abs(dy) < Eps)
|
||||
return;
|
||||
|
||||
var t = (vy - p1y) / dy;
|
||||
if (t < -Eps || t > 1.0 + Eps)
|
||||
return;
|
||||
|
||||
hx = p1x + t * (p2x - p1x);
|
||||
hy = vy;
|
||||
dist = rayDirection == PushDirection.Left ? vx - hx : hx - vx;
|
||||
break;
|
||||
}
|
||||
|
||||
case PushDirection.Down:
|
||||
case PushDirection.Up:
|
||||
{
|
||||
var dx = p2x - p1x;
|
||||
if (System.Math.Abs(dx) < Eps)
|
||||
return;
|
||||
|
||||
var t = (vx - p1x) / dx;
|
||||
if (t < -Eps || t > 1.0 + Eps)
|
||||
return;
|
||||
|
||||
hx = vx;
|
||||
hy = p1y + t * (p2y - p1y);
|
||||
dist = rayDirection == PushDirection.Down ? vy - hy : hy - vy;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (dist < -Eps)
|
||||
return;
|
||||
|
||||
var vertex = new Vector(vx, vy);
|
||||
var hit = new Vector(hx, hy);
|
||||
if (vertexMoves)
|
||||
sink.Add(Snap(dist), vertex, hit);
|
||||
else
|
||||
sink.Add(Snap(dist), hit, vertex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closest-approach points of arcs against lines, which vertex sampling can miss.
|
||||
/// </summary>
|
||||
public static void ArcToLine<TSink>(
|
||||
ref TSink sink,
|
||||
List<Entity> arcEntities,
|
||||
double arcDx,
|
||||
double arcDy,
|
||||
List<Entity> lineEntities,
|
||||
double lineDx,
|
||||
double lineDy,
|
||||
double rayX,
|
||||
double rayY,
|
||||
bool arcMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
for (var i = 0; i < arcEntities.Count; i++)
|
||||
{
|
||||
if (!TryGetCurve(arcEntities[i], out var localCx, out var localCy, out var r))
|
||||
continue;
|
||||
|
||||
var arc = arcEntities[i] as Arc;
|
||||
var cx = localCx + arcDx;
|
||||
var cy = localCy + arcDy;
|
||||
|
||||
for (var j = 0; j < lineEntities.Count; j++)
|
||||
{
|
||||
if (lineEntities[j] is not Line line)
|
||||
continue;
|
||||
|
||||
var p1x = line.pt1.X + lineDx;
|
||||
var p1y = line.pt1.Y + lineDy;
|
||||
var p2x = line.pt2.X + lineDx;
|
||||
var p2y = line.pt2.Y + lineDy;
|
||||
var ex = p2x - p1x;
|
||||
var ey = p2y - p1y;
|
||||
|
||||
var det = ex * rayY - ey * rayX;
|
||||
if (System.Math.Abs(det) < Eps)
|
||||
continue;
|
||||
|
||||
// The directional distance from an arc point at angle θ to the
|
||||
// line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det.
|
||||
// dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π.
|
||||
var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey));
|
||||
var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI);
|
||||
|
||||
for (var k = 0; k < 2; k++)
|
||||
{
|
||||
var theta = k == 0 ? theta1 : theta2;
|
||||
|
||||
if (arc != null && !Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||
continue;
|
||||
|
||||
var qx = cx + r * System.Math.Cos(theta);
|
||||
var qy = cy + r * System.Math.Sin(theta);
|
||||
|
||||
RayLine(ref sink, qx, qy, p1x, p1y, p2x, p2y, rayX, rayY, arcMoves);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// External and internal tangencies of two curves along a unit direction. Radii must
|
||||
/// be nonnegative; a null arc is a full circle.
|
||||
/// </summary>
|
||||
public static void CurveTangency<TSink>(
|
||||
ref TSink sink,
|
||||
double movingCx,
|
||||
double movingCy,
|
||||
double movingRadius,
|
||||
Arc movingArc,
|
||||
double stationaryCx,
|
||||
double stationaryCy,
|
||||
double stationaryRadius,
|
||||
Arc stationaryArc,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
for (var kind = 0; kind < 2; kind++)
|
||||
{
|
||||
var internalContact = kind == 1;
|
||||
var radius = internalContact
|
||||
? System.Math.Abs(movingRadius - stationaryRadius)
|
||||
: movingRadius + stationaryRadius;
|
||||
|
||||
// Equal-radius internal contact has coincident centers, not a unique
|
||||
// tangent point. Endpoints detect any overlap of those angular spans.
|
||||
if (radius == 0)
|
||||
continue;
|
||||
|
||||
if (
|
||||
!SolveRayCircle(
|
||||
movingCx,
|
||||
movingCy,
|
||||
stationaryCx,
|
||||
stationaryCy,
|
||||
radius,
|
||||
dirX,
|
||||
dirY,
|
||||
out var t1,
|
||||
out var t2
|
||||
)
|
||||
)
|
||||
continue;
|
||||
|
||||
// The nearer center-circle root can be outside an arc while the farther
|
||||
// root is its first contact. Check the actual tangent point at BOTH roots.
|
||||
for (var root = 0; root < 2; root++)
|
||||
{
|
||||
var t = root == 0 ? t1 : t2;
|
||||
if (t < -Eps)
|
||||
continue;
|
||||
|
||||
var toX = stationaryCx - (movingCx + t * dirX);
|
||||
var toY = stationaryCy - (movingCy + t * dirY);
|
||||
var movingSign = internalContact && movingRadius < stationaryRadius ? -1 : 1;
|
||||
var stationarySign = internalContact ? movingSign : -1;
|
||||
if (
|
||||
!ContainsContactAngle(
|
||||
movingArc,
|
||||
movingRadius,
|
||||
movingSign * toX,
|
||||
movingSign * toY
|
||||
)
|
||||
|| !ContainsContactAngle(
|
||||
stationaryArc,
|
||||
stationaryRadius,
|
||||
stationarySign * toX,
|
||||
stationarySign * toY
|
||||
)
|
||||
)
|
||||
continue;
|
||||
|
||||
var length = System.Math.Sqrt(toX * toX + toY * toY);
|
||||
var ux = length > 0 ? toX / length : 0;
|
||||
var uy = length > 0 ? toY / length : 0;
|
||||
var movingPoint = new Vector(
|
||||
movingCx + movingSign * movingRadius * ux,
|
||||
movingCy + movingSign * movingRadius * uy
|
||||
);
|
||||
var stationaryPoint = new Vector(
|
||||
stationaryCx + stationarySign * stationaryRadius * ux,
|
||||
stationaryCy + stationarySign * stationaryRadius * uy
|
||||
);
|
||||
|
||||
sink.Add(Snap(t), movingPoint, stationaryPoint);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetCurve(Entity entity, out double cx, out double cy, out double r)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Circle circle:
|
||||
cx = circle.Center.X;
|
||||
cy = circle.Center.Y;
|
||||
r = circle.Radius;
|
||||
return true;
|
||||
case Arc arc:
|
||||
cx = arc.Center.X;
|
||||
cy = arc.Center.Y;
|
||||
r = arc.Radius;
|
||||
return true;
|
||||
default:
|
||||
cx = cy = r = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Emit<TSink>(
|
||||
ref TSink sink,
|
||||
double vx,
|
||||
double vy,
|
||||
double t,
|
||||
double rayX,
|
||||
double rayY,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
var vertex = new Vector(vx, vy);
|
||||
var hit = new Vector(vx + t * rayX, vy + t * rayY);
|
||||
if (vertexMoves)
|
||||
sink.Add(Snap(t), vertex, hit);
|
||||
else
|
||||
sink.Add(Snap(t), hit, vertex);
|
||||
}
|
||||
|
||||
private static bool ContainsContactAngle(Arc arc, double radius, double x, double y)
|
||||
{
|
||||
// A zero-radius curve is a point: its angular range has no geometric meaning.
|
||||
if (arc == null || radius == 0)
|
||||
return true;
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(y, x));
|
||||
return Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed);
|
||||
}
|
||||
|
||||
internal static bool SolveRayCircle(
|
||||
double vx,
|
||||
double vy,
|
||||
double cx,
|
||||
double cy,
|
||||
double r,
|
||||
double dirX,
|
||||
double dirY,
|
||||
out double t1,
|
||||
out double t2
|
||||
)
|
||||
{
|
||||
var ox = vx - cx;
|
||||
var oy = vy - cy;
|
||||
|
||||
var a = dirX * dirX + dirY * dirY;
|
||||
var b = 2.0 * (ox * dirX + oy * dirY);
|
||||
var c = ox * ox + oy * oy - r * r;
|
||||
|
||||
var discriminant = b * b - 4.0 * a * c;
|
||||
if (discriminant < 0)
|
||||
{
|
||||
t1 = t2 = double.MaxValue;
|
||||
return false;
|
||||
}
|
||||
|
||||
var sqrtD = System.Math.Sqrt(discriminant);
|
||||
var inv2a = 1.0 / (2.0 * a);
|
||||
t1 = (-b - sqrtD) * inv2a;
|
||||
t2 = (-b + sqrtD) * inv2a;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slide events between native Line/Arc/Circle boundaries. The moving entities and
|
||||
/// vertices are translated by (movingDx, movingDy); vertex arrays may be subsets.
|
||||
/// </summary>
|
||||
public struct EntitySlideEvents : ISlideEventSource
|
||||
{
|
||||
private readonly List<Entity> moving;
|
||||
private readonly Vector[] movingVertices;
|
||||
private readonly double movingDx;
|
||||
private readonly double movingDy;
|
||||
private readonly List<Entity> stationary;
|
||||
private readonly Vector[] stationaryVertices;
|
||||
private readonly double dirX;
|
||||
private readonly double dirY;
|
||||
private readonly bool arcToLine;
|
||||
|
||||
public EntitySlideEvents(
|
||||
List<Entity> moving,
|
||||
Vector[] movingVertices,
|
||||
double movingDx,
|
||||
double movingDy,
|
||||
List<Entity> stationary,
|
||||
Vector[] stationaryVertices,
|
||||
double dirX,
|
||||
double dirY,
|
||||
bool arcToLine
|
||||
)
|
||||
{
|
||||
this.moving = moving;
|
||||
this.movingVertices = movingVertices;
|
||||
this.movingDx = movingDx;
|
||||
this.movingDy = movingDy;
|
||||
this.stationary = stationary;
|
||||
this.stationaryVertices = stationaryVertices;
|
||||
this.dirX = dirX;
|
||||
this.dirY = dirY;
|
||||
this.arcToLine = arcToLine;
|
||||
}
|
||||
|
||||
public void Enumerate<TSink>(ref TSink sink)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
// Phase 1: moving vertices along the push against stationary entities.
|
||||
for (var v = 0; v < movingVertices.Length; v++)
|
||||
{
|
||||
var vx = movingVertices[v].X + movingDx;
|
||||
var vy = movingVertices[v].Y + movingDy;
|
||||
|
||||
for (var j = 0; j < stationary.Count; j++)
|
||||
{
|
||||
SlideEvents.Ray(ref sink, vx, vy, stationary[j], 0, 0, dirX, dirY, true);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: stationary vertices against the push onto moving entities.
|
||||
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||
{
|
||||
var vx = stationaryVertices[v].X;
|
||||
var vy = stationaryVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < moving.Count; j++)
|
||||
{
|
||||
SlideEvents.Ray(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
moving[j],
|
||||
movingDx,
|
||||
movingDy,
|
||||
-dirX,
|
||||
-dirY,
|
||||
false
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: arc-to-line closest points, which vertex sampling can miss.
|
||||
if (arcToLine)
|
||||
{
|
||||
SlideEvents.ArcToLine(
|
||||
ref sink,
|
||||
moving,
|
||||
movingDx,
|
||||
movingDy,
|
||||
stationary,
|
||||
0,
|
||||
0,
|
||||
dirX,
|
||||
dirY,
|
||||
true
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
SlideEvents.ArcToLine(
|
||||
ref sink,
|
||||
stationary,
|
||||
0,
|
||||
0,
|
||||
moving,
|
||||
movingDx,
|
||||
movingDy,
|
||||
-dirX,
|
||||
-dirY,
|
||||
false
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 4: native curve tangency, including a convex corner inside a concave arc.
|
||||
for (var i = 0; i < moving.Count; i++)
|
||||
{
|
||||
if (!SlideEvents.TryGetCurve(moving[i], out var mcx, out var mcy, out var mr))
|
||||
continue;
|
||||
|
||||
for (var j = 0; j < stationary.Count; j++)
|
||||
{
|
||||
if (!SlideEvents.TryGetCurve(stationary[j], out var scx, out var scy, out var sr))
|
||||
continue;
|
||||
|
||||
SlideEvents.CurveTangency(
|
||||
ref sink,
|
||||
mcx + movingDx,
|
||||
mcy + movingDy,
|
||||
mr,
|
||||
moving[i] as Arc,
|
||||
scx,
|
||||
scy,
|
||||
sr,
|
||||
stationary[j] as Arc,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slide events between line boundaries along an arbitrary unit direction. The moving
|
||||
/// lines and vertices are translated by (movingDx, movingDy); vertex arrays may be subsets.
|
||||
/// </summary>
|
||||
public struct LineSlideEvents : ISlideEventSource
|
||||
{
|
||||
private readonly List<Line> moving;
|
||||
private readonly Vector[] movingVertices;
|
||||
private readonly double movingDx;
|
||||
private readonly double movingDy;
|
||||
private readonly List<Line> stationary;
|
||||
private readonly Vector[] stationaryVertices;
|
||||
private readonly double dirX;
|
||||
private readonly double dirY;
|
||||
|
||||
public LineSlideEvents(
|
||||
List<Line> moving,
|
||||
Vector[] movingVertices,
|
||||
double movingDx,
|
||||
double movingDy,
|
||||
List<Line> stationary,
|
||||
Vector[] stationaryVertices,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
{
|
||||
this.moving = moving;
|
||||
this.movingVertices = movingVertices;
|
||||
this.movingDx = movingDx;
|
||||
this.movingDy = movingDy;
|
||||
this.stationary = stationary;
|
||||
this.stationaryVertices = stationaryVertices;
|
||||
this.dirX = dirX;
|
||||
this.dirY = dirY;
|
||||
}
|
||||
|
||||
public void Enumerate<TSink>(ref TSink sink)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
for (var v = 0; v < movingVertices.Length; v++)
|
||||
{
|
||||
var vx = movingVertices[v].X + movingDx;
|
||||
var vy = movingVertices[v].Y + movingDy;
|
||||
|
||||
for (var j = 0; j < stationary.Count; j++)
|
||||
{
|
||||
var e = stationary[j];
|
||||
SlideEvents.RayLine(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
e.pt1.X,
|
||||
e.pt1.Y,
|
||||
e.pt2.X,
|
||||
e.pt2.Y,
|
||||
dirX,
|
||||
dirY,
|
||||
true
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||
{
|
||||
var vx = stationaryVertices[v].X;
|
||||
var vy = stationaryVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < moving.Count; j++)
|
||||
{
|
||||
var e = moving[j];
|
||||
SlideEvents.RayLine(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
e.pt1.X + movingDx,
|
||||
e.pt1.Y + movingDy,
|
||||
e.pt2.X + movingDx,
|
||||
e.pt2.Y + movingDy,
|
||||
-dirX,
|
||||
-dirY,
|
||||
false
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Axis-aligned slide events between edge arrays sorted for pruning, as used by the
|
||||
/// <see cref="PushDirection"/> kernel. Offsets translate each side into world space.
|
||||
/// </summary>
|
||||
public struct AxisSlideEvents : ISlideEventSource
|
||||
{
|
||||
private readonly (Vector start, Vector end)[] movingEdges;
|
||||
private readonly Vector movingOffset;
|
||||
private readonly Vector[] movingVertices;
|
||||
private readonly (Vector start, Vector end)[] stationaryEdges;
|
||||
private readonly Vector stationaryOffset;
|
||||
private readonly Vector[] stationaryVertices;
|
||||
private readonly PushDirection direction;
|
||||
|
||||
/// <param name="movingVertices">World-space moving vertices.</param>
|
||||
/// <param name="stationaryVertices">World-space stationary vertices.</param>
|
||||
public AxisSlideEvents(
|
||||
(Vector start, Vector end)[] movingEdges,
|
||||
Vector movingOffset,
|
||||
Vector[] movingVertices,
|
||||
(Vector start, Vector end)[] stationaryEdges,
|
||||
Vector stationaryOffset,
|
||||
Vector[] stationaryVertices,
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
this.movingEdges = movingEdges;
|
||||
this.movingOffset = movingOffset;
|
||||
this.movingVertices = movingVertices;
|
||||
this.stationaryEdges = stationaryEdges;
|
||||
this.stationaryOffset = stationaryOffset;
|
||||
this.stationaryVertices = stationaryVertices;
|
||||
this.direction = direction;
|
||||
}
|
||||
|
||||
public void Enumerate<TSink>(ref TSink sink)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
for (var v = 0; v < movingVertices.Length; v++)
|
||||
{
|
||||
OneWay(ref sink, movingVertices[v], stationaryEdges, stationaryOffset, direction, true);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
|
||||
var opposite = SpatialQuery.OppositeDirection(direction);
|
||||
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||
{
|
||||
OneWay(ref sink, stationaryVertices[v], movingEdges, movingOffset, opposite, false);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OneWay<TSink>(
|
||||
ref TSink sink,
|
||||
Vector vertex,
|
||||
(Vector start, Vector end)[] edges,
|
||||
Vector edgeOffset,
|
||||
PushDirection rayDirection,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
var vx = vertex.X;
|
||||
var vy = vertex.Y;
|
||||
var horizontal = SpatialQuery.IsHorizontalDirection(rayDirection);
|
||||
|
||||
// Edges are sorted by their perpendicular min-coordinate.
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
{
|
||||
var e1 = edges[i].start + edgeOffset;
|
||||
var e2 = edges[i].end + edgeOffset;
|
||||
|
||||
double perpValue,
|
||||
edgeMin,
|
||||
edgeMax;
|
||||
if (horizontal)
|
||||
{
|
||||
perpValue = vy;
|
||||
edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y;
|
||||
edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y;
|
||||
}
|
||||
else
|
||||
{
|
||||
perpValue = vx;
|
||||
edgeMin = e1.X < e2.X ? e1.X : e2.X;
|
||||
edgeMax = e1.X > e2.X ? e1.X : e2.X;
|
||||
}
|
||||
|
||||
if (perpValue < edgeMin - Tolerance.Epsilon)
|
||||
break;
|
||||
|
||||
if (perpValue > edgeMax + Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
SlideEvents.AxisRayLine(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
e1.X,
|
||||
e1.Y,
|
||||
e2.X,
|
||||
e2.Y,
|
||||
rayDirection,
|
||||
vertexMoves
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,8 +320,9 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along a push direction before
|
||||
/// any edge of movingLines contacts any edge of stationaryLines.
|
||||
/// Computes the translation distance along a push direction before any edge of
|
||||
/// movingLines first blocks against an edge of stationaryLines. A contact that
|
||||
/// the push slides along or leaves does not block (see <see cref="SlideContact"/>).
|
||||
/// Returns double.MaxValue if no collision path exists.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
@@ -334,7 +335,7 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum directional distance with the moving lines translated
|
||||
/// Computes the directional distance with the moving lines translated
|
||||
/// by (movingDx, movingDy) without creating new Line objects.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
@@ -345,37 +346,57 @@ namespace OpenNest.Geometry
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var movingOffset = new Vector(movingDx, movingDy);
|
||||
return DirectionalDistance(
|
||||
movingLines,
|
||||
movingDx,
|
||||
movingDy,
|
||||
stationaryLines,
|
||||
direction,
|
||||
SlideContactClassifier.FromLines(
|
||||
movingLines,
|
||||
new Vector(movingDx, movingDy),
|
||||
stationaryLines,
|
||||
Vector.Zero
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = CollectVertices(movingLines, movingOffset);
|
||||
/// <summary>
|
||||
/// <see cref="DirectionalDistance(List{Line}, double, double, List{Line}, PushDirection)"/>
|
||||
/// with caller-supplied contact topology, for inputs that are not complete closed
|
||||
/// boundaries (for example direction-filtered edges).
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Line> movingLines,
|
||||
double movingDx,
|
||||
double movingDy,
|
||||
List<Line> stationaryLines,
|
||||
PushDirection direction,
|
||||
SlideContactClassifier contacts
|
||||
)
|
||||
{
|
||||
var movingOffset = new Vector(movingDx, movingDy);
|
||||
var movingVertices = CollectVertices(movingLines, movingOffset).ToArray();
|
||||
|
||||
var stationaryEdges = ToEdgeArray(stationaryLines);
|
||||
SortEdgesForPruning(stationaryEdges, direction);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
|
||||
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero).ToArray();
|
||||
|
||||
var movingEdges = ToEdgeArray(movingLines);
|
||||
SortEdgesForPruning(movingEdges, opposite);
|
||||
SortEdgesForPruning(movingEdges, OppositeDirection(direction));
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
var source = new AxisSlideEvents(
|
||||
movingEdges,
|
||||
movingOffset,
|
||||
movingVertices,
|
||||
stationaryEdges,
|
||||
Vector.Zero,
|
||||
stationaryVertices,
|
||||
direction
|
||||
);
|
||||
var unit = DirectionToOffset(direction, 1.0);
|
||||
return SlideResolver.FirstBlocking(ref source, contacts, unit.X, unit.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -396,8 +417,8 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum directional distance using raw edge arrays and location offsets
|
||||
/// to avoid all intermediate object allocations.
|
||||
/// Computes the blocking directional distance using raw edge arrays and location
|
||||
/// offsets. Sorts both edge arrays in place for pruning.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
(Vector start, Vector end)[] movingEdges,
|
||||
@@ -407,36 +428,58 @@ namespace OpenNest.Geometry
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
return DirectionalDistance(
|
||||
movingEdges,
|
||||
movingOffset,
|
||||
stationaryEdges,
|
||||
stationaryOffset,
|
||||
direction,
|
||||
SlideContactClassifier.FromEdges(
|
||||
movingEdges,
|
||||
movingOffset,
|
||||
stationaryEdges,
|
||||
stationaryOffset
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edge-array overload with caller-supplied contact topology. The classifier's
|
||||
/// origins must match <paramref name="movingOffset"/> and
|
||||
/// <paramref name="stationaryOffset"/> in the frame of its boundaries.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
(Vector start, Vector end)[] movingEdges,
|
||||
Vector movingOffset,
|
||||
(Vector start, Vector end)[] stationaryEdges,
|
||||
Vector stationaryOffset,
|
||||
PushDirection direction,
|
||||
SlideContactClassifier contacts
|
||||
)
|
||||
{
|
||||
SortEdgesForPruning(stationaryEdges, direction);
|
||||
var movingVertices = CollectVertices(movingEdges, movingOffset).ToArray();
|
||||
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = CollectVertices(movingEdges, movingOffset);
|
||||
SortEdgesForPruning(movingEdges, OppositeDirection(direction));
|
||||
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset).ToArray();
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
SortEdgesForPruning(movingEdges, opposite);
|
||||
|
||||
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
var source = new AxisSlideEvents(
|
||||
movingEdges,
|
||||
movingOffset,
|
||||
movingVertices,
|
||||
stationaryEdges,
|
||||
stationaryOffset,
|
||||
stationaryVertices,
|
||||
direction
|
||||
);
|
||||
var unit = DirectionToOffset(direction, 1.0);
|
||||
return SlideResolver.FirstBlocking(ref source, contacts, unit.X, unit.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nearest raw hit from one vertex along a push direction against edges sorted for
|
||||
/// pruning. This is a first-touch primitive; it does not classify sliding contacts.
|
||||
/// </summary>
|
||||
public static double OneWayDistance(
|
||||
Vector vertex,
|
||||
(Vector start, Vector end)[] edges,
|
||||
@@ -628,8 +671,8 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along an arbitrary unit direction
|
||||
/// before any edge of movingLines contacts any edge of stationaryLines.
|
||||
/// Computes the translation distance along an arbitrary unit direction before any
|
||||
/// edge of movingLines first blocks against an edge of stationaryLines.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Line> movingLines,
|
||||
@@ -637,58 +680,41 @@ namespace OpenNest.Geometry
|
||||
Vector direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var dirX = direction.X;
|
||||
var dirY = direction.Y;
|
||||
|
||||
var movingVertices = CollectVertices(movingLines, Vector.Zero);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
for (var i = 0; i < stationaryLines.Count; i++)
|
||||
{
|
||||
var e = stationaryLines[i];
|
||||
var d = RayEdgeDistance(
|
||||
mv.X,
|
||||
mv.Y,
|
||||
e.pt1.X,
|
||||
e.pt1.Y,
|
||||
e.pt2.X,
|
||||
e.pt2.Y,
|
||||
dirX,
|
||||
dirY
|
||||
return DirectionalDistance(
|
||||
movingLines,
|
||||
stationaryLines,
|
||||
direction,
|
||||
SlideContactClassifier.FromLines(
|
||||
movingLines,
|
||||
Vector.Zero,
|
||||
stationaryLines,
|
||||
Vector.Zero
|
||||
)
|
||||
);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
/// <summary>
|
||||
/// <see cref="DirectionalDistance(List{Line}, List{Line}, Vector)"/> with
|
||||
/// caller-supplied contact topology.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Line> movingLines,
|
||||
List<Line> stationaryLines,
|
||||
Vector direction,
|
||||
SlideContactClassifier contacts
|
||||
)
|
||||
{
|
||||
for (var i = 0; i < movingLines.Count; i++)
|
||||
{
|
||||
var e = movingLines[i];
|
||||
var d = RayEdgeDistance(
|
||||
sv.X,
|
||||
sv.Y,
|
||||
e.pt1.X,
|
||||
e.pt1.Y,
|
||||
e.pt2.X,
|
||||
e.pt2.Y,
|
||||
oppX,
|
||||
oppY
|
||||
var source = new LineSlideEvents(
|
||||
movingLines,
|
||||
CollectVertices(movingLines, Vector.Zero).ToArray(),
|
||||
0,
|
||||
0,
|
||||
stationaryLines,
|
||||
CollectVertices(stationaryLines, Vector.Zero).ToArray(),
|
||||
direction.X,
|
||||
direction.Y
|
||||
);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
return SlideResolver.FirstBlocking(ref source, contacts, direction.X, direction.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -710,10 +736,10 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along an arbitrary unit direction
|
||||
/// before any vertex/edge of movingEntities contacts any vertex/edge of
|
||||
/// stationaryEntities. Works with native Line, Arc, and Circle entities
|
||||
/// without tessellation.
|
||||
/// Computes the translation distance along an arbitrary unit direction before any
|
||||
/// vertex/edge of movingEntities first blocks against stationaryEntities. Works with
|
||||
/// native Line, Arc, and Circle entities without tessellation. A contact that the
|
||||
/// push slides along or leaves does not block (see <see cref="SlideContact"/>).
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Entity> movingEntities,
|
||||
@@ -721,228 +747,42 @@ namespace OpenNest.Geometry
|
||||
Vector direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var dirX = direction.X;
|
||||
var dirY = direction.Y;
|
||||
|
||||
var movingVertices = ExtractEntityVertices(movingEntities);
|
||||
|
||||
for (var v = 0; v < movingVertices.Length; v++)
|
||||
{
|
||||
var vx = movingVertices[v].X;
|
||||
var vy = movingVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(vx, vy, stationaryEntities[j], dirX, dirY);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var stationaryVertices = ExtractEntityVertices(stationaryEntities);
|
||||
|
||||
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||
{
|
||||
var vx = stationaryVertices[v].X;
|
||||
var vy = stationaryVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < movingEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(vx, vy, movingEntities[j], oppX, oppY);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Arc-to-line closest-point check.
|
||||
// Phases 1-2 sample arc endpoints and cardinal extremes, but the actual
|
||||
// closest point on a small corner arc to a straight edge may lie between
|
||||
// those samples. Use ClosestPointTo to find it and fire a ray from there.
|
||||
minDist = ArcToLineClosestDistance(
|
||||
return DirectionalDistance(
|
||||
movingEntities,
|
||||
stationaryEntities,
|
||||
dirX,
|
||||
dirY,
|
||||
minDist
|
||||
direction,
|
||||
new SlideContactClassifier(movingEntities, stationaryEntities)
|
||||
);
|
||||
if (minDist <= 0)
|
||||
return 0;
|
||||
minDist = ArcToLineClosestDistance(
|
||||
stationaryEntities,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="DirectionalDistance(List{Entity}, List{Entity}, Vector)"/> with
|
||||
/// caller-supplied contact topology.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Entity> movingEntities,
|
||||
List<Entity> stationaryEntities,
|
||||
Vector direction,
|
||||
SlideContactClassifier contacts
|
||||
)
|
||||
{
|
||||
// Phases: vertex rays both ways, arc-to-line closest points (vertex sampling
|
||||
// misses interior arc contact), then native curve tangency.
|
||||
var source = new EntitySlideEvents(
|
||||
movingEntities,
|
||||
oppX,
|
||||
oppY,
|
||||
minDist
|
||||
ExtractEntityVertices(movingEntities),
|
||||
0,
|
||||
0,
|
||||
stationaryEntities,
|
||||
ExtractEntityVertices(stationaryEntities),
|
||||
direction.X,
|
||||
direction.Y,
|
||||
arcToLine: true
|
||||
);
|
||||
if (minDist <= 0)
|
||||
return 0;
|
||||
|
||||
// Phase 4: Native curve tangency, including a convex corner inside a concave arc.
|
||||
for (var i = 0; i < movingEntities.Count; i++)
|
||||
{
|
||||
var me = movingEntities[i];
|
||||
if (!TryGetCurveParams(me, out var mcx, out var mcy, out var mr))
|
||||
continue;
|
||||
|
||||
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||
{
|
||||
var se = stationaryEntities[j];
|
||||
if (!TryGetCurveParams(se, out var scx, out var scy, out var sr))
|
||||
continue;
|
||||
|
||||
var d = CurveTangencyDistance(
|
||||
mcx, mcy, mr, me as Arc,
|
||||
scx, scy, sr, se as Arc, dirX, dirY);
|
||||
if (d >= minDist)
|
||||
continue;
|
||||
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
return SlideResolver.FirstBlocking(ref source, contacts, direction.X, direction.Y);
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
private static double ArcToLineClosestDistance(
|
||||
List<Entity> arcEntities,
|
||||
List<Entity> lineEntities,
|
||||
double dirX,
|
||||
double dirY,
|
||||
double minDist
|
||||
)
|
||||
{
|
||||
for (var i = 0; i < arcEntities.Count; i++)
|
||||
{
|
||||
if (arcEntities[i] is not Arc arc)
|
||||
continue;
|
||||
|
||||
var cx = arc.Center.X;
|
||||
var cy = arc.Center.Y;
|
||||
var r = arc.Radius;
|
||||
|
||||
for (var j = 0; j < lineEntities.Count; j++)
|
||||
{
|
||||
if (lineEntities[j] is not Line line)
|
||||
continue;
|
||||
|
||||
var p1x = line.pt1.X;
|
||||
var p1y = line.pt1.Y;
|
||||
var ex = line.pt2.X - p1x;
|
||||
var ey = line.pt2.Y - p1y;
|
||||
|
||||
var det = ex * dirY - ey * dirX;
|
||||
if (System.Math.Abs(det) < Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
// The directional distance from an arc point at angle θ to the
|
||||
// line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det.
|
||||
// dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π.
|
||||
var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey));
|
||||
var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI);
|
||||
|
||||
for (var k = 0; k < 2; k++)
|
||||
{
|
||||
var theta = k == 0 ? theta1 : theta2;
|
||||
|
||||
if (
|
||||
!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed)
|
||||
)
|
||||
continue;
|
||||
|
||||
var qx = cx + r * System.Math.Cos(theta);
|
||||
var qy = cy + r * System.Math.Sin(theta);
|
||||
|
||||
var d = RayEdgeDistance(
|
||||
qx,
|
||||
qy,
|
||||
p1x,
|
||||
p1y,
|
||||
line.pt2.X,
|
||||
line.pt2.Y,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return minDist;
|
||||
}
|
||||
|
||||
private static double RayEntityDistance(
|
||||
double vx,
|
||||
double vy,
|
||||
Entity entity,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
return RayEdgeDistance(
|
||||
vx,
|
||||
vy,
|
||||
line.pt1.X,
|
||||
line.pt1.Y,
|
||||
line.pt2.X,
|
||||
line.pt2.Y,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
return RayArcDistance(
|
||||
vx,
|
||||
vy,
|
||||
arc.Center.X,
|
||||
arc.Center.Y,
|
||||
arc.Radius,
|
||||
arc.StartAngle,
|
||||
arc.EndAngle,
|
||||
arc.IsReversed,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
return RayCircleDistance(
|
||||
vx,
|
||||
vy,
|
||||
circle.Center.X,
|
||||
circle.Center.Y,
|
||||
circle.Radius,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
private static Vector[] ExtractEntityVertices(List<Entity> entities)
|
||||
public static Vector[] ExtractEntityVertices(List<Entity> entities)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
|
||||
@@ -1041,31 +881,6 @@ namespace OpenNest.Geometry
|
||||
);
|
||||
}
|
||||
|
||||
private static bool TryGetCurveParams(
|
||||
Entity entity,
|
||||
out double cx,
|
||||
out double cy,
|
||||
out double r
|
||||
)
|
||||
{
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
cx = circle.Center.X;
|
||||
cy = circle.Center.Y;
|
||||
r = circle.Radius;
|
||||
return true;
|
||||
}
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
cx = arc.Center.X;
|
||||
cy = arc.Center.Y;
|
||||
r = arc.Radius;
|
||||
return true;
|
||||
}
|
||||
cx = cy = r = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double BoxProjectionMin(Box box, double dx, double dy)
|
||||
{
|
||||
var x = dx >= 0 ? box.Left : box.Right;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// A post-processor whose <see cref="IPostProcessor.Post(Nest, string)"/>
|
||||
/// can write more than one file (for example one program per sheet).
|
||||
/// </summary>
|
||||
public interface IMultiFilePostProcessor : IPostProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// The files <see cref="IPostProcessor.Post(Nest, string)"/> will write
|
||||
/// for this nest and chosen path, in order, with the current settings.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> GetOutputFiles(Nest nest, string outputFile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Optional post contract for nest-level rapid verification. Opt in only when the
|
||||
/// post preserves Plate.Parts order and each placed Program's contour order and
|
||||
/// pierce positions. This does not certify retract height, parking or controller macros.
|
||||
/// Unknown/reordering posts still get nest diagnostics, but require acknowledgment
|
||||
/// that their final rapid sequence has not been verified.
|
||||
/// </summary>
|
||||
public interface IPostVerificationSupport
|
||||
{
|
||||
bool PreservesPlacedProgramOrder { get; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace OpenNest.Math
|
||||
namespace OpenNest.Math
|
||||
{
|
||||
public static class Angle
|
||||
{
|
||||
@@ -106,14 +106,14 @@
|
||||
if (reversed)
|
||||
Generic.Swap(ref a1, ref a2);
|
||||
|
||||
var diff = Angle.NormalizeRad(a2 - a1);
|
||||
var diff = Angle.NormalizeDeg(a2 - a1);
|
||||
|
||||
// full circle
|
||||
if (a2.IsEqualTo(a1))
|
||||
return true;
|
||||
|
||||
a1 = Angle.NormalizeRad(angle - a1);
|
||||
a2 = Angle.NormalizeRad(a2 - angle);
|
||||
a1 = Angle.NormalizeDeg(angle - a1);
|
||||
a2 = Angle.NormalizeDeg(a2 - angle);
|
||||
|
||||
return diff >= a1 - Tolerance.Epsilon || diff >= a2 - Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ namespace OpenNest.Math
|
||||
public static class Fraction
|
||||
{
|
||||
public static readonly Regex FractionRegex = new Regex(
|
||||
@"((?<WholeNum>\d+)(\ |-))?(?<Fraction>\d+\/\d+)"
|
||||
@"((?<WholeNum>\d+)(\ |-))?(?<Fraction>\d+\/\d+)",
|
||||
RegexOptions.None,
|
||||
TimeSpan.FromMilliseconds(250)
|
||||
);
|
||||
|
||||
public static bool IsValid(string s)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Geometry;
|
||||
@@ -38,6 +38,10 @@ namespace OpenNest
|
||||
|
||||
public string AssistGas { get; set; } = "";
|
||||
|
||||
public NestStatus Status { get; set; } = NestStatus.Quote;
|
||||
|
||||
public string MadeBy { get; set; } = "";
|
||||
|
||||
public double Thickness { get; set; }
|
||||
|
||||
public Material Material { get; set; }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Shop-floor workflow state of a saved nest, persisted as a string in
|
||||
/// <c>nest.json</c> so unknown future values can fall back safely.
|
||||
/// </summary>
|
||||
public enum NestStatus
|
||||
{
|
||||
Quote,
|
||||
ToBeCut,
|
||||
HasBeenCut,
|
||||
}
|
||||
}
|
||||
+53
-2
@@ -111,6 +111,30 @@ namespace OpenNest
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs an owned, already-rotated saved cutting program without rotating it again.
|
||||
/// The current pose remains the clean-drawing pose used by RemoveLeadIns.
|
||||
/// </summary>
|
||||
public bool RestoreLeadInProgram(Program program, bool locked)
|
||||
{
|
||||
if (program == null || !program.Codes.Any(code => code is Motion
|
||||
|| code is SubProgramCall call && call.Program != null
|
||||
&& call.Program.Codes.Any(subCode => subCode is Motion)))
|
||||
return false;
|
||||
|
||||
// Compute before changing state, so a malformed program cannot half-install.
|
||||
var bounds = program.BoundingBox();
|
||||
bounds.Offset(Location);
|
||||
preLeadInRotation = Rotation;
|
||||
Program = program;
|
||||
ownsProgram = true;
|
||||
HasManualLeadIns = true;
|
||||
LeadInsLocked = locked;
|
||||
CuttingParameters = null;
|
||||
BoundingBox = bounds;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void RemoveLeadIns()
|
||||
{
|
||||
var rotation = preLeadInRotation;
|
||||
@@ -145,7 +169,7 @@ namespace OpenNest
|
||||
EnsureOwnedProgram();
|
||||
Program.Rotate(angle);
|
||||
location = Location.Rotate(angle);
|
||||
preLeadInRotation = Program.Rotation;
|
||||
TrackRotation(angle);
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
@@ -159,10 +183,22 @@ namespace OpenNest
|
||||
EnsureOwnedProgram();
|
||||
Program.Rotate(angle);
|
||||
location = Location.Rotate(angle, origin);
|
||||
preLeadInRotation = Program.Rotation;
|
||||
TrackRotation(angle);
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the part's rotation after it turned by <paramref name="angle"/>. A lead-in
|
||||
/// program is rebuilt by the cutting strategy and starts over at zero program
|
||||
/// rotation, so for those parts the rotation is accumulated rather than read back.
|
||||
/// </summary>
|
||||
private void TrackRotation(double angle)
|
||||
{
|
||||
preLeadInRotation = HasManualLeadIns
|
||||
? Angle.NormalizeRad(preLeadInRotation + angle)
|
||||
: Program.Rotation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offsets the part.
|
||||
/// </summary>
|
||||
@@ -323,6 +359,7 @@ namespace OpenNest
|
||||
new Box(BoundingBox.X, BoundingBox.Y, BoundingBox.Length, BoundingBox.Width)
|
||||
);
|
||||
part.ownsProgram = true;
|
||||
part.CopyLeadInStateFrom(this);
|
||||
|
||||
return part;
|
||||
}
|
||||
@@ -347,10 +384,24 @@ namespace OpenNest
|
||||
BoundingBox.Width
|
||||
)
|
||||
);
|
||||
part.CopyLeadInStateFrom(this);
|
||||
|
||||
return part;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the lead-in state that goes with a copied program. Without it a copy of a
|
||||
/// lead-in part reads its rotation from the rebuilt program (zero), and lead-in
|
||||
/// assignment does not know to strip the copied lead-ins before adding new ones.
|
||||
/// </summary>
|
||||
private void CopyLeadInStateFrom(Part source)
|
||||
{
|
||||
HasManualLeadIns = source.HasManualLeadIns;
|
||||
LeadInsLocked = source.LeadInsLocked;
|
||||
CuttingParameters = source.CuttingParameters;
|
||||
preLeadInRotation = source.preLeadInRotation;
|
||||
}
|
||||
|
||||
private void EnsureOwnedProgram()
|
||||
{
|
||||
if (!ownsProgram)
|
||||
|
||||
+42
-5
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Collections;
|
||||
@@ -94,18 +94,40 @@ namespace OpenNest
|
||||
/// <summary>
|
||||
/// Regenerates all cut-off drawings and materializes them as parts.
|
||||
/// Existing cut-off parts are removed first, then each cut-off is
|
||||
/// regenerated and added back if it produces any geometry.
|
||||
/// regenerated and put back at the same place in the cut sequence
|
||||
/// (<see cref="Parts"/> order). New cut-offs are added at the end.
|
||||
/// </summary>
|
||||
public void RegenerateCutOffs(CutOffSettings settings)
|
||||
{
|
||||
// Remove existing cut-off parts
|
||||
// Remember each cut-off's place in the cut sequence, so a part drag
|
||||
// or cut-off move doesn't send it to the end of the sequence.
|
||||
var sequence = new Dictionary<CutOff, int>();
|
||||
|
||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (Parts[i].BaseDrawing.IsCutOff)
|
||||
if (!Parts[i].BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing));
|
||||
if (cutoff != null)
|
||||
sequence[cutoff] = i;
|
||||
|
||||
Parts.RemoveAt(i);
|
||||
}
|
||||
|
||||
RegenerateCutOffs(settings, sequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regenerates all cut-off drawings and materializes them as parts, placing
|
||||
/// each cut-off at its index in <paramref name="sequence"/> (its place in
|
||||
/// <see cref="Parts"/> order). Cut-offs missing from it are added at the end.
|
||||
/// Callers must remove existing cut-off parts first.
|
||||
/// </summary>
|
||||
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence)
|
||||
{
|
||||
var cache = BuildPerimeterCache(this);
|
||||
var placed = new List<(int Index, Part Part)>();
|
||||
|
||||
// Regenerate and materialize each cut-off
|
||||
foreach (var cutoff in CutOffs)
|
||||
@@ -116,8 +138,17 @@ namespace OpenNest
|
||||
continue;
|
||||
|
||||
var part = new Part(cutoff.Drawing);
|
||||
|
||||
if (sequence != null && sequence.TryGetValue(cutoff, out var index))
|
||||
placed.Add((index, part));
|
||||
else
|
||||
Parts.Add(part);
|
||||
}
|
||||
|
||||
// Lowest index first: each insert then lands on its saved index, because
|
||||
// every part sequenced before it is already in place.
|
||||
foreach (var (index, part) in placed.OrderBy(p => p.Index))
|
||||
Parts.Insert(System.Math.Clamp(index, 0, Parts.Count), part);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -143,7 +174,13 @@ namespace OpenNest
|
||||
|
||||
if (entities.Count > 0)
|
||||
{
|
||||
var profile = new Geometry.ShapeProfile(entities);
|
||||
// Leads are cutting paths in scrap, not boundary edges. Chaining them
|
||||
// into the outline can make a closed part appear open and replace its
|
||||
// recesses with a convex hull. Keep the full geometry for the existing
|
||||
// conservative fallback when the material contour really is open.
|
||||
var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin &&
|
||||
e.Layer != SpecialLayers.Leadout).ToList();
|
||||
var profile = new Geometry.ShapeProfile(outline);
|
||||
|
||||
if (profile.Perimeter.IsClosed())
|
||||
{
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest.PostSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Places a post-processor config property in a section of the desktop
|
||||
/// settings editor. A config type opts into the sectioned editor by marking
|
||||
/// at least one property; unmarked configs keep the generic PropertyGrid.
|
||||
/// Label and help text come from <c>DisplayName</c> and <c>Description</c>.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
|
||||
public sealed class PostSettingAttribute : Attribute
|
||||
{
|
||||
public PostSettingAttribute(string section, int order = 0)
|
||||
{
|
||||
Section = section;
|
||||
Order = order;
|
||||
}
|
||||
|
||||
public string Section { get; }
|
||||
|
||||
public int Order { get; }
|
||||
|
||||
/// <summary>Lower bound for numeric fields; NaN uses the kind's default.</summary>
|
||||
public double Minimum { get; set; } = double.NaN;
|
||||
|
||||
/// <summary>Upper bound for numeric fields; NaN uses the kind's default.</summary>
|
||||
public double Maximum { get; set; } = double.NaN;
|
||||
|
||||
/// <summary>Decimal places for decimal fields; negative uses the default.</summary>
|
||||
public int DecimalPlaces { get; set; } = -1;
|
||||
|
||||
/// <summary>Column header for the key of a name/value table.</summary>
|
||||
public string KeyHeader { get; set; }
|
||||
|
||||
/// <summary>Column header for the value of a name/value table.</summary>
|
||||
public string ValueHeader { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Declares a settings section's order and description.</summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
|
||||
public sealed class PostSettingsSectionAttribute : Attribute
|
||||
{
|
||||
public PostSettingsSectionAttribute(string name, int order)
|
||||
{
|
||||
Name = name;
|
||||
Order = order;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public int Order { get; }
|
||||
|
||||
public string Description { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace OpenNest.PostSettings
|
||||
{
|
||||
public enum PostSettingKind
|
||||
{
|
||||
Text,
|
||||
Integer,
|
||||
Decimal,
|
||||
Boolean,
|
||||
Choice,
|
||||
StringMap,
|
||||
}
|
||||
|
||||
public sealed class PostSettingsField
|
||||
{
|
||||
internal PostSettingsField(
|
||||
PropertyInfo property,
|
||||
PostSettingKind kind,
|
||||
PostSettingAttribute setting
|
||||
)
|
||||
{
|
||||
Property = property;
|
||||
Kind = kind;
|
||||
Label =
|
||||
property.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName
|
||||
?? property.Name;
|
||||
Description = property.GetCustomAttribute<DescriptionAttribute>()?.Description ?? "";
|
||||
|
||||
var (min, max) = DefaultRange(kind);
|
||||
Minimum = setting != null && !double.IsNaN(setting.Minimum) ? setting.Minimum : min;
|
||||
Maximum = setting != null && !double.IsNaN(setting.Maximum) ? setting.Maximum : max;
|
||||
DecimalPlaces =
|
||||
kind == PostSettingKind.Integer ? 0
|
||||
: setting != null && setting.DecimalPlaces >= 0 ? setting.DecimalPlaces
|
||||
: 3;
|
||||
KeyHeader = setting?.KeyHeader ?? "Name";
|
||||
ValueHeader = setting?.ValueHeader ?? "Value";
|
||||
}
|
||||
|
||||
public PropertyInfo Property { get; }
|
||||
|
||||
public string Name => Property.Name;
|
||||
|
||||
public PostSettingKind Kind { get; }
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public string Description { get; }
|
||||
|
||||
public double Minimum { get; }
|
||||
|
||||
public double Maximum { get; }
|
||||
|
||||
public int DecimalPlaces { get; }
|
||||
|
||||
public string KeyHeader { get; }
|
||||
|
||||
public string ValueHeader { get; }
|
||||
|
||||
public string[] ChoiceNames =>
|
||||
Kind == PostSettingKind.Choice
|
||||
? Enum.GetNames(Property.PropertyType)
|
||||
: Array.Empty<string>();
|
||||
|
||||
public object GetValue(object config) => Property.GetValue(config);
|
||||
|
||||
public void SetValue(object config, object value) => Property.SetValue(config, value);
|
||||
|
||||
private static (double, double) DefaultRange(PostSettingKind kind) =>
|
||||
kind == PostSettingKind.Integer ? (int.MinValue, int.MaxValue) : (-1e9, 1e9);
|
||||
}
|
||||
|
||||
public sealed class PostSettingsSection
|
||||
{
|
||||
internal PostSettingsSection(
|
||||
string name,
|
||||
string description,
|
||||
IReadOnlyList<PostSettingsField> fields
|
||||
)
|
||||
{
|
||||
Name = name;
|
||||
Description = description ?? "";
|
||||
Fields = fields;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string Description { get; }
|
||||
|
||||
public IReadOnlyList<PostSettingsField> Fields { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the sectioned settings layout for a post-processor config from its
|
||||
/// <see cref="PostSettingAttribute"/> metadata, without any UI dependency.
|
||||
/// </summary>
|
||||
public static class PostSettingsLayout
|
||||
{
|
||||
public const string OtherSection = "Other";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ordered sections, or null when the config should use the
|
||||
/// generic PropertyGrid: it has no <see cref="PostSettingAttribute"/>,
|
||||
/// or one of its editable properties has a type the editor cannot show.
|
||||
/// Editable properties without the attribute go to the
|
||||
/// <see cref="OtherSection"/> so none become uneditable.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PostSettingsSection> TryBuild(Type configType)
|
||||
{
|
||||
if (configType == null)
|
||||
return null;
|
||||
|
||||
var properties = configType
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p =>
|
||||
p.CanRead
|
||||
&& p.GetSetMethod() != null
|
||||
&& p.GetIndexParameters().Length == 0
|
||||
&& p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false
|
||||
)
|
||||
.OrderBy(p => p.MetadataToken)
|
||||
.ToList();
|
||||
|
||||
if (!properties.Any(p => p.GetCustomAttribute<PostSettingAttribute>() != null))
|
||||
return null;
|
||||
|
||||
var entries = new List<(string Section, int Order, int Index, PostSettingsField Field)>();
|
||||
for (var i = 0; i < properties.Count; i++)
|
||||
{
|
||||
var property = properties[i];
|
||||
var kind = KindOf(property.PropertyType);
|
||||
if (kind == null)
|
||||
return null;
|
||||
|
||||
var setting = property.GetCustomAttribute<PostSettingAttribute>();
|
||||
var section = string.IsNullOrWhiteSpace(setting?.Section)
|
||||
? OtherSection
|
||||
: setting.Section;
|
||||
entries.Add(
|
||||
(
|
||||
section,
|
||||
setting?.Order ?? int.MaxValue,
|
||||
i,
|
||||
new PostSettingsField(property, kind.Value, setting)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
var declared = configType
|
||||
.GetCustomAttributes<PostSettingsSectionAttribute>()
|
||||
.GroupBy(a => a.Name, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
|
||||
return entries
|
||||
.GroupBy(e => e.Section, StringComparer.Ordinal)
|
||||
.Select(g => new
|
||||
{
|
||||
Name = g.Key,
|
||||
Declared = declared.TryGetValue(g.Key, out var d) ? d : null,
|
||||
FirstIndex = g.Min(e => e.Index),
|
||||
Fields = g.OrderBy(e => e.Order).ThenBy(e => e.Index).Select(e => e.Field).ToList(),
|
||||
})
|
||||
.OrderBy(s => s.Name == OtherSection && s.Declared == null ? 2
|
||||
: s.Declared != null ? 0
|
||||
: 1)
|
||||
.ThenBy(s => s.Declared?.Order ?? 0)
|
||||
.ThenBy(s => s.FirstIndex)
|
||||
.Select(s => new PostSettingsSection(s.Name, s.Declared?.Description, s.Fields))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a name/value map from edited table rows using the supplied key
|
||||
/// comparer. Keys and values are trimmed; fully blank rows are skipped.
|
||||
/// Throws <see cref="FormatException"/> naming the 1-based row for a
|
||||
/// missing or duplicate name.
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> BuildMap(
|
||||
IEnumerable<KeyValuePair<string, string>> rows,
|
||||
IEqualityComparer<string> comparer
|
||||
)
|
||||
{
|
||||
var map = new Dictionary<string, string>(comparer ?? StringComparer.Ordinal);
|
||||
var row = 0;
|
||||
|
||||
foreach (var entry in rows ?? Enumerable.Empty<KeyValuePair<string, string>>())
|
||||
{
|
||||
row++;
|
||||
var key = entry.Key?.Trim() ?? "";
|
||||
var value = entry.Value?.Trim() ?? "";
|
||||
|
||||
if (key.Length == 0 && value.Length == 0)
|
||||
continue;
|
||||
if (key.Length == 0)
|
||||
throw new FormatException($"Row {row}: a name is required.");
|
||||
if (map.ContainsKey(key))
|
||||
throw new FormatException($"Row {row}: \"{key}\" is listed more than once.");
|
||||
|
||||
map.Add(key, value);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static PostSettingKind? KindOf(Type type)
|
||||
{
|
||||
if (type == typeof(string))
|
||||
return PostSettingKind.Text;
|
||||
if (type == typeof(int))
|
||||
return PostSettingKind.Integer;
|
||||
if (type == typeof(double))
|
||||
return PostSettingKind.Decimal;
|
||||
if (type == typeof(bool))
|
||||
return PostSettingKind.Boolean;
|
||||
if (type.IsEnum)
|
||||
return PostSettingKind.Choice;
|
||||
if (
|
||||
type == typeof(Dictionary<string, string>)
|
||||
|| type == typeof(IDictionary<string, string>)
|
||||
)
|
||||
return PostSettingKind.StringMap;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Last-used Auto Nest engine, stored separately from nest/plate defaults.
|
||||
/// Loading does not resolve the name: the host must finish plug-in discovery first.
|
||||
/// </summary>
|
||||
public sealed class EngineSelectionSettings
|
||||
{
|
||||
public const string DefaultEngineName = "Default";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public string EngineName { get; set; } = DefaultEngineName;
|
||||
|
||||
/// <summary>%APPDATA%\OpenNest\engine-selection.json.</summary>
|
||||
public static string DefaultPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OpenNest", "engine-selection.json");
|
||||
|
||||
/// <summary>Missing, unreadable or corrupt settings safely use Default without writing.</summary>
|
||||
public static EngineSelectionSettings Load(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return new();
|
||||
|
||||
try
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<EngineSelectionSettings>(
|
||||
File.ReadAllText(path), JsonOptions) ?? new();
|
||||
settings.EngineName = NormalizeName(settings.EngineName);
|
||||
return settings;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves against the host's selectable engines AFTER plug-in loading. A missing engine
|
||||
/// returns Default plus a status-bar warning, without replacing the saved preference.
|
||||
/// Names use registry casing so desktop combo-box selection remains exact. When the saved
|
||||
/// name is not selectable, <paramref name="renamed"/> (the registry's legacy-name lookup)
|
||||
/// may map it to the engine that replaced it; the result must itself be selectable.
|
||||
/// </summary>
|
||||
public string Resolve(
|
||||
IEnumerable<string> availableEngineNames,
|
||||
out string? statusMessage,
|
||||
Func<string, string?>? renamed = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(availableEngineNames);
|
||||
var available = availableEngineNames.ToList();
|
||||
var requestedName = NormalizeName(EngineName);
|
||||
var registeredName = Find(available, requestedName)
|
||||
?? (renamed?.Invoke(requestedName) is { } replacement ? Find(available, replacement) : null);
|
||||
if (registeredName is not null)
|
||||
{
|
||||
statusMessage = null;
|
||||
return registeredName;
|
||||
}
|
||||
|
||||
statusMessage = $"Saved Auto Nest engine '{requestedName}' is unavailable. Using Default.";
|
||||
return DefaultEngineName;
|
||||
}
|
||||
|
||||
private static string? Find(IEnumerable<string> names, string name) =>
|
||||
names.FirstOrDefault(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Writes camelCase JSON, creating the parent directory and retrying IO collisions as
|
||||
/// LocalJsonProvider does. The host handles a persistent write failure.
|
||||
/// </summary>
|
||||
public void Save(string path)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(
|
||||
new EngineSelectionSettings { EngineName = NormalizeName(EngineName) }, JsonOptions);
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
|
||||
for (var attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < 2)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeName(string? name) =>
|
||||
string.IsNullOrWhiteSpace(name) ? DefaultEngineName : name.Trim();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Storage backend for saved nests. <see cref="RemoteNestRepository"/> talks to the
|
||||
/// central nest server; File mode in the desktop app keeps using SaveFileDialog and
|
||||
/// does not go through this interface.
|
||||
/// </summary>
|
||||
public interface INestRepository
|
||||
{
|
||||
/// <summary>All stored nests, newest saved first.</summary>
|
||||
Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Downloaded .nest archive bytes, or null when the id does not exist.</summary>
|
||||
Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Stores a new nest; returns the server-assigned record.</summary>
|
||||
Task<NestRecord> UploadAsync(
|
||||
byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Replaces the archive and metadata of an existing id, keeping the id.</summary>
|
||||
Task<NestRecord> UpdateFileAsync(
|
||||
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Replaces metadata only (status, made-by, comments...), leaving the archive.</summary>
|
||||
Task<NestRecord> UpdateMetadataAsync(
|
||||
Guid id, NestRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>Outcome of <see cref="NestDefaults.Load(string, out NestDefaultsStatus)"/>.</summary>
|
||||
public enum NestDefaultsStatus
|
||||
{
|
||||
/// <summary>Defaults were read from the file (invalid fields still fall back individually).</summary>
|
||||
Ok,
|
||||
|
||||
/// <summary>No file exists at the path; built-in fallback values were used.</summary>
|
||||
Missing,
|
||||
|
||||
/// <summary>The file exists but could not be read or parsed; fallback values were used.</summary>
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plate/nest defaults persisted to a single JSON file
|
||||
/// (by default %APPDATA%\OpenNest\defaults.json), replacing the
|
||||
/// .nstdot nest-template mechanism. Loading never throws: a missing,
|
||||
/// corrupt, or partially valid file degrades field-by-field to
|
||||
/// <see cref="Fallback"/> values so creating a new nest is never blocked.
|
||||
/// </summary>
|
||||
public sealed class NestDefaults
|
||||
{
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
public Units Units { get; set; } = Units.Inches;
|
||||
|
||||
public Size Size { get; set; } = new(100, 100);
|
||||
|
||||
public int Quadrant { get; set; } = 1;
|
||||
|
||||
public double PartSpacing { get; set; } = 1;
|
||||
|
||||
public Spacing EdgeSpacing { get; set; } = new(1, 1, 1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// The built-in defaults used when no file exists and for every field
|
||||
/// that is missing or invalid. Matches the historical
|
||||
/// MainForm.CreateDefaultNest values (units default to Inches; callers
|
||||
/// may override from their own settings).
|
||||
/// </summary>
|
||||
public static NestDefaults Fallback => new();
|
||||
|
||||
/// <summary>%APPDATA%\OpenNest\defaults.json.</summary>
|
||||
public static string DefaultPath =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OpenNest",
|
||||
"defaults.json"
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Loads defaults from <paramref name="path"/>, falling back field by
|
||||
/// field for a missing file, invalid JSON, or invalid values.
|
||||
/// </summary>
|
||||
public static NestDefaults Load(string path) => Load(path, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Loads defaults and reports whether the file was missing, loaded, or
|
||||
/// present but unreadable/invalid, so callers can warn about a corrupt
|
||||
/// file while still returning usable values.
|
||||
/// </summary>
|
||||
public static NestDefaults Load(string path, out NestDefaultsStatus status)
|
||||
{
|
||||
var defaults = Fallback;
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
status = NestDefaultsStatus.Missing;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
NestDefaultsDto? dto;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
status = NestDefaultsStatus.Invalid;
|
||||
return defaults;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
status = NestDefaultsStatus.Invalid;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
if (dto is null)
|
||||
{
|
||||
status = NestDefaultsStatus.Invalid;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
status = NestDefaultsStatus.Ok;
|
||||
|
||||
if (
|
||||
dto.Units is not null
|
||||
&& Enum.TryParse<Units>(dto.Units, ignoreCase: true, out var units)
|
||||
)
|
||||
defaults.Units = units;
|
||||
|
||||
if (
|
||||
dto.Size?.Width is { } width
|
||||
&& dto.Size.Length is { } length
|
||||
&& IsValidSize(width, length)
|
||||
)
|
||||
defaults.Size = new Size(width, length);
|
||||
|
||||
if (dto.Quadrant is { } quadrant && quadrant is >= 1 and <= 4)
|
||||
defaults.Quadrant = quadrant;
|
||||
|
||||
if (dto.PartSpacing is { } partSpacing && IsValidSpacing(partSpacing))
|
||||
defaults.PartSpacing = partSpacing;
|
||||
|
||||
if (
|
||||
dto.EdgeSpacing?.Left is { } left
|
||||
&& dto.EdgeSpacing.Bottom is { } bottom
|
||||
&& dto.EdgeSpacing.Right is { } right
|
||||
&& dto.EdgeSpacing.Top is { } top
|
||||
&& IsValidSpacing(left)
|
||||
&& IsValidSpacing(bottom)
|
||||
&& IsValidSpacing(right)
|
||||
&& IsValidSpacing(top)
|
||||
)
|
||||
defaults.EdgeSpacing = new Spacing(left, bottom, right, top);
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the current units and plate defaults from a nest.
|
||||
/// </summary>
|
||||
public static NestDefaults FromNest(Nest nest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
var plate = nest.PlateDefaults;
|
||||
return new NestDefaults
|
||||
{
|
||||
Units = nest.Units,
|
||||
Size = plate.Size,
|
||||
Quadrant = plate.Quadrant,
|
||||
PartSpacing = plate.PartSpacing,
|
||||
EdgeSpacing = plate.EdgeSpacing,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures defaults from an existing plate (a copy of its size,
|
||||
/// quadrant, and spacing), e.g. the active plate in the desktop app.
|
||||
/// </summary>
|
||||
public static NestDefaults FromPlate(Units units, Plate plate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
return new NestDefaults
|
||||
{
|
||||
Units = units,
|
||||
Size = plate.Size,
|
||||
Quadrant = plate.Quadrant,
|
||||
PartSpacing = plate.PartSpacing,
|
||||
EdgeSpacing = plate.EdgeSpacing,
|
||||
};
|
||||
}
|
||||
|
||||
public void ApplyTo(Nest nest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
nest.Units = Units;
|
||||
var plate = nest.PlateDefaults;
|
||||
plate.Size = Size;
|
||||
plate.Quadrant = Quadrant;
|
||||
plate.PartSpacing = PartSpacing;
|
||||
plate.EdgeSpacing = EdgeSpacing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the file (creating the parent directory), retrying briefly on
|
||||
/// IO collisions the same way <see cref="LocalJsonProvider"/> does.
|
||||
/// </summary>
|
||||
public void Save(string path, int maxRetries = 3)
|
||||
{
|
||||
var dto = new NestDefaultsDto
|
||||
{
|
||||
Version = CurrentVersion,
|
||||
Units = Units.ToString().ToLowerInvariant(),
|
||||
Size = new SizeDto { Width = Size.Width, Length = Size.Length },
|
||||
Quadrant = Quadrant,
|
||||
PartSpacing = PartSpacing,
|
||||
EdgeSpacing = new SpacingDto
|
||||
{
|
||||
Left = EdgeSpacing.Left,
|
||||
Bottom = EdgeSpacing.Bottom,
|
||||
Right = EdgeSpacing.Right,
|
||||
Top = EdgeSpacing.Top,
|
||||
},
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < maxRetries - 1)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValidSize(double width, double length) =>
|
||||
!double.IsNaN(width)
|
||||
&& !double.IsNaN(length)
|
||||
&& !double.IsInfinity(width)
|
||||
&& !double.IsInfinity(length)
|
||||
&& width > 0
|
||||
&& length > 0;
|
||||
|
||||
private static bool IsValidSpacing(double value) =>
|
||||
!double.IsNaN(value) && !double.IsInfinity(value) && value >= 0;
|
||||
|
||||
/// <summary>
|
||||
/// Wire format. Every field is nullable so a partial file merges over
|
||||
/// the fallback field by field; unknown fields (including a future
|
||||
/// higher <c>version</c>) are ignored rather than rejected.
|
||||
/// </summary>
|
||||
private sealed record NestDefaultsDto
|
||||
{
|
||||
public int? Version { get; init; } = CurrentVersion;
|
||||
public string? Units { get; init; }
|
||||
public SizeDto? Size { get; init; }
|
||||
public int? Quadrant { get; init; }
|
||||
public double? PartSpacing { get; init; }
|
||||
public SpacingDto? EdgeSpacing { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SizeDto
|
||||
{
|
||||
public double? Width { get; init; }
|
||||
public double? Length { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SpacingDto
|
||||
{
|
||||
public double? Left { get; init; }
|
||||
public double? Bottom { get; init; }
|
||||
public double? Right { get; init; }
|
||||
public double? Top { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using OpenNest;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side metadata for one saved nest. The .nest archive itself is stored
|
||||
/// alongside; this record is what lists, filters and status tracking read.
|
||||
/// Counts are captured by the client at save time.
|
||||
/// </summary>
|
||||
public sealed class NestRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public string Customer { get; set; } = "";
|
||||
|
||||
public DateTime DateCreated { get; set; }
|
||||
|
||||
public DateTime DateModified { get; set; }
|
||||
|
||||
public string Material { get; set; } = "";
|
||||
|
||||
public double Thickness { get; set; }
|
||||
|
||||
/// <summary>quote | toBeCut | hasBeenCut (camelCase on the wire, case-insensitive readers).</summary>
|
||||
public NestStatus Status { get; set; } = NestStatus.Quote;
|
||||
|
||||
public int PlateCount { get; set; }
|
||||
|
||||
public int PartCount { get; set; }
|
||||
|
||||
public string Comments { get; set; } = "";
|
||||
|
||||
public string MadeBy { get; set; } = "";
|
||||
|
||||
/// <summary>Size of the stored .nest archive in bytes.</summary>
|
||||
public long FileSize { get; set; }
|
||||
|
||||
/// <summary>When the server last stored the nest contents or metadata.</summary>
|
||||
public DateTime SavedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the shareable <see cref="NestRecord"/> metadata from a live <see cref="Nest"/>.
|
||||
/// Counts are computed here (not carried on <see cref="Nest"/> itself) so every caller
|
||||
/// that saves to the nest server gets the same definition of "plate" and "part" counts.
|
||||
/// </summary>
|
||||
public static class NestRecordFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a record for uploading <paramref name="nest"/>. <paramref name="id"/> is the
|
||||
/// client-tracked id: <see cref="System.Guid.Empty"/> for a first save (the server
|
||||
/// assigns one), or the previously returned id to update an existing record.
|
||||
/// <paramref name="fileSize"/> is the size of the serialized .nest archive being
|
||||
/// uploaded alongside this record; the server also recomputes it independently.
|
||||
/// </summary>
|
||||
public static NestRecord FromNest(Nest nest, System.Guid id, long fileSize)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
|
||||
return new NestRecord
|
||||
{
|
||||
Id = id,
|
||||
Name = nest.Name ?? "",
|
||||
Customer = nest.Customer ?? "",
|
||||
DateCreated = nest.DateCreated,
|
||||
DateModified = nest.DateLastModified,
|
||||
Material = nest.Material?.Name ?? "",
|
||||
Thickness = nest.Thickness,
|
||||
Status = nest.Status,
|
||||
PlateCount = nest.Plates.Count,
|
||||
PartCount = nest.Plates.Sum(CountNonCutoffParts),
|
||||
// Nest has no separate "comments" field; Notes is the closest analog
|
||||
// shown in the nest info dialog, so it round-trips as Comments here.
|
||||
Comments = nest.Notes ?? "",
|
||||
MadeBy = nest.MadeBy ?? "",
|
||||
FileSize = fileSize,
|
||||
};
|
||||
}
|
||||
|
||||
private static int CountNonCutoffParts(Plate plate) =>
|
||||
plate.Parts.Count(part => !part.BaseDrawing.IsCutOff);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks a document's database identity, bound to a particular server address.
|
||||
/// A failed upload leaves that identity untouched. Switching servers creates a new
|
||||
/// record rather than accidentally updating an unrelated record with the same id.
|
||||
/// </summary>
|
||||
public sealed class NestSaveSession
|
||||
{
|
||||
public Guid RemoteId { get; private set; }
|
||||
public string ServerUrl { get; private set; } = "";
|
||||
|
||||
public void Bind(Guid id, string serverUrl)
|
||||
{
|
||||
if (id == Guid.Empty)
|
||||
throw new ArgumentException("A saved nest needs a non-empty id.", nameof(id));
|
||||
RemoteId = id;
|
||||
ServerUrl = Normalize(serverUrl);
|
||||
}
|
||||
|
||||
public async Task<NestRecord> SaveAsync(
|
||||
INestRepository repository, string serverUrl, Nest nest, byte[] archive,
|
||||
bool saveCopy = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
ArgumentNullException.ThrowIfNull(archive);
|
||||
|
||||
var normalizedUrl = Normalize(serverUrl);
|
||||
if (normalizedUrl.Length == 0)
|
||||
throw new ArgumentException("A database server URL is required.", nameof(serverUrl));
|
||||
|
||||
var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty;
|
||||
var record = NestRecordFactory.FromNest(nest, id, archive.LongLength);
|
||||
var saved = id == Guid.Empty
|
||||
? await repository.UploadAsync(archive, record, cancellationToken)
|
||||
: await repository.UpdateFileAsync(id, archive, record, cancellationToken);
|
||||
if (saved.Id == Guid.Empty || (id != Guid.Empty && saved.Id != id))
|
||||
throw new InvalidDataException("Server returned an invalid nest id.");
|
||||
|
||||
Bind(saved.Id, normalizedUrl);
|
||||
return saved;
|
||||
}
|
||||
|
||||
private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>Where the desktop app persists nests: local .nest files or the shared nest server.</summary>
|
||||
public enum NestStorageMode
|
||||
{
|
||||
File,
|
||||
Database,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage-mode toggle and nest-server address, stored at %APPDATA%\OpenNest\storage.json.
|
||||
/// Loading never throws; a missing or corrupt file means File mode so existing installs
|
||||
/// keep the original behavior until the operator opts in.
|
||||
/// </summary>
|
||||
public sealed class NestStorageSettings
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public NestStorageMode Mode { get; set; } = NestStorageMode.File;
|
||||
|
||||
/// <summary>Base URL of the nest server, e.g. http://barge.lan:8090. No trailing slash required.</summary>
|
||||
public string ServerUrl { get; set; } = "";
|
||||
|
||||
/// <summary>True when Database mode is active and a server URL is configured.</summary>
|
||||
public bool IsDatabaseMode =>
|
||||
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
|
||||
|
||||
/// <summary>%APPDATA%\OpenNest\storage.json.</summary>
|
||||
public static string DefaultPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OpenNest", "storage.json");
|
||||
|
||||
public static NestStorageSettings Load(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return new();
|
||||
|
||||
try
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<NestStorageSettings>(
|
||||
File.ReadAllText(path), JsonOptions) ?? new();
|
||||
settings.ServerUrl = NormalizeUrl(settings.ServerUrl);
|
||||
return settings;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Writes camelCase JSON, creating the parent directory and retrying IO collisions.</summary>
|
||||
public void Save(string path)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(
|
||||
new NestStorageSettings { Mode = Mode, ServerUrl = NormalizeUrl(ServerUrl) },
|
||||
JsonOptions);
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
|
||||
for (var attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < 2)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeUrl(string? url) => (url ?? "").Trim().TrimEnd('/');
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Talks to the central OpenNest nest server over HTTP. Uploads are multipart:
|
||||
/// a "metadata" JSON part and a "file" part holding the .nest archive.
|
||||
/// Connection and HTTP failures surface as HttpRequestException/IOException to the caller.
|
||||
/// </summary>
|
||||
public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsClient;
|
||||
private readonly Uri _baseUri;
|
||||
|
||||
public RemoteNestRepository(string baseUrl)
|
||||
: this(new HttpClient(), baseUrl)
|
||||
{
|
||||
_ownsClient = true;
|
||||
}
|
||||
|
||||
/// <summary>For tests and hosts that pool HttpClient instances.</summary>
|
||||
public RemoteNestRepository(HttpClient httpClient, string baseUrl)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_ownsClient = false;
|
||||
var url = (baseUrl ?? "").Trim().TrimEnd('/');
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var baseUri)
|
||||
|| (baseUri.Scheme != Uri.UriSchemeHttp && baseUri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new ArgumentException($"Invalid nest server URL: '{baseUrl}'", nameof(baseUrl));
|
||||
}
|
||||
// Keep the trailing slash so relative combines replace no path segment.
|
||||
_baseUri = new Uri(baseUri.AbsoluteUri.TrimEnd('/') + "/");
|
||||
}
|
||||
|
||||
private Uri Url(string relative) => new(_baseUri, relative);
|
||||
|
||||
public async Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(Url("api/nests"), cancellationToken);
|
||||
await EnsureSuccess(response, "list nests", cancellationToken);
|
||||
var items = await response.Content
|
||||
.ReadFromJsonAsync<List<NestRecord>>(JsonOptions, cancellationToken);
|
||||
return items ?? new List<NestRecord>();
|
||||
}
|
||||
|
||||
public async Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
await EnsureSuccess(response, $"get nest {id}", cancellationToken);
|
||||
return await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}/file"), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
await EnsureSuccess(response, $"download nest {id}", cancellationToken);
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<NestRecord> UploadAsync(
|
||||
byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var content = BuildMultipart(nestFile, record);
|
||||
using var response = await _httpClient.PostAsync(Url("api/nests"), content, cancellationToken);
|
||||
await EnsureSuccess(response, $"upload nest '{record.Name}'", cancellationToken);
|
||||
return await ReadRecord(response, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<NestRecord> UpdateFileAsync(
|
||||
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var content = BuildMultipart(nestFile, record);
|
||||
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/file"), content, cancellationToken);
|
||||
await EnsureSuccess(response, $"update nest {id}", cancellationToken);
|
||||
return await ReadRecord(response, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<NestRecord> UpdateMetadataAsync(
|
||||
Guid id, NestRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
|
||||
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/metadata"), content, cancellationToken);
|
||||
await EnsureSuccess(response, $"update nest metadata {id}", cancellationToken);
|
||||
return await ReadRecord(response, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await _httpClient.DeleteAsync(Url($"api/nests/{id}"), cancellationToken);
|
||||
await EnsureSuccess(response, $"delete nest {id}", cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsClient)
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
|
||||
private static MultipartFormDataContent BuildMultipart(byte[] nestFile, NestRecord record)
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
var metadata = new StringContent(
|
||||
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
|
||||
content.Add(metadata, "metadata");
|
||||
var file = new ByteArrayContent(nestFile);
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
|
||||
content.Add(file, "file", record.Name.Length > 0 ? $"{record.Name}.nest" : "nest.nest");
|
||||
return content;
|
||||
}
|
||||
|
||||
private static async Task<NestRecord> ReadRecord(
|
||||
HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var record = await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
|
||||
return record ?? throw new IOException("Nest server returned an empty record.");
|
||||
}
|
||||
|
||||
private static async Task EnsureSuccess(
|
||||
HttpResponseMessage response, string action, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
return;
|
||||
|
||||
var detail = "";
|
||||
try
|
||||
{
|
||||
detail = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidOperationException)
|
||||
{
|
||||
// The status code is the useful part; the body is best-effort.
|
||||
}
|
||||
|
||||
if (detail.Length > 200)
|
||||
detail = detail[..200] + "…";
|
||||
throw new IOException(
|
||||
$"Could not {action} on the nest server: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {detail}".TrimEnd());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Fill;
|
||||
|
||||
/// <summary>
|
||||
/// PlateView spacing expander: grows part-to-part spacing with the work area
|
||||
/// and non-selected parts as hard boundaries.
|
||||
/// </summary>
|
||||
public class ExpanderTests
|
||||
{
|
||||
private static Program Rectangle(double width = 4, double length = 4)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
private static Part AddSquare(Plate plate, double x, double y, double size = 4)
|
||||
{
|
||||
var part = new Part(new Drawing($"sq{plate.Parts.Count}", Rectangle(size, size)), new Vector(x, y));
|
||||
plate.Parts.Add(part);
|
||||
return part;
|
||||
}
|
||||
|
||||
private static Plate MakePlate(double lengthX, double widthY, double edge = 0.5)
|
||||
{
|
||||
var plate = new Plate(new Size(widthY, lengthX));
|
||||
plate.EdgeSpacing = new Spacing(edge, edge);
|
||||
return plate;
|
||||
}
|
||||
|
||||
/// <summary>Independent clearance oracle: naive vertex/segment min distance over raw part lines.</summary>
|
||||
private static double BruteClearance(Part a, Part b)
|
||||
{
|
||||
var linesA = PartGeometry.GetPartLines(a);
|
||||
var linesB = PartGeometry.GetPartLines(b);
|
||||
|
||||
double min = double.MaxValue;
|
||||
foreach (var la in linesA)
|
||||
foreach (var lb in linesB)
|
||||
{
|
||||
min = System.Math.Min(min, PointSegment(a, la.StartPoint, lb));
|
||||
min = System.Math.Min(min, PointSegment(a, la.EndPoint, lb));
|
||||
min = System.Math.Min(min, PointSegment(b, lb.StartPoint, la));
|
||||
min = System.Math.Min(min, PointSegment(b, lb.EndPoint, la));
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
private static double PointSegment(Part owner, Vector pt, Line seg)
|
||||
{
|
||||
var d = seg.EndPoint - seg.StartPoint;
|
||||
var len2 = d.DotProduct(d);
|
||||
var t = len2 <= 1e-12 ? 0 : System.Math.Clamp((pt - seg.StartPoint).DotProduct(d) / len2, 0, 1);
|
||||
return pt.DistanceTo(seg.StartPoint + d * t);
|
||||
}
|
||||
|
||||
private static void AssertNoOverlaps(Plate plate)
|
||||
{
|
||||
for (var i = 0; i < plate.Parts.Count; i++)
|
||||
for (var j = i + 1; j < plate.Parts.Count; j++)
|
||||
Assert.False(
|
||||
plate.Parts[i].Intersects(plate.Parts[j], out _),
|
||||
$"{plate.Parts[i].BaseDrawing.Name} overlaps {plate.Parts[j].BaseDrawing.Name}"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_TwoSquares_GrowUntilEdgeFloor_AndAnchorStaysPut()
|
||||
{
|
||||
var plate = MakePlate(24, 24);
|
||||
var a = AddSquare(plate, 6, 10);
|
||||
var b = AddSquare(plate, 14, 10);
|
||||
|
||||
var result = Expander.Expand(new List<Part> { a, b }, plate);
|
||||
|
||||
// Max gap: B flush against the right edge floor (23.5): 23.5 - 14 - 4 + gap base...
|
||||
// A stays (anchor); B slides to x=19.5 -> gap 9.5.
|
||||
Assert.Equal(6, a.Location.X, 6);
|
||||
Assert.Equal(10, a.Location.Y, 6);
|
||||
Assert.Equal(9.5, b.Location.X - (a.Location.X + 4), 1);
|
||||
Assert.True(result.AchievedSpacing >= 9.4, $"achieved {result.AchievedSpacing}");
|
||||
Assert.True(result.AchievedSpacing <= 9.6, $"achieved {result.AchievedSpacing}");
|
||||
AssertNoOverlaps(plate);
|
||||
Assert.True(b.BoundingBox.Right <= 23.5 + 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_SandwichedBetweenWalls_ConvergesOnlyToInitialGaps_AndKeepsWalls()
|
||||
{
|
||||
var plate = MakePlate(26, 10, edge: 0.0);
|
||||
var wallL = AddSquare(plate, 0, 3);
|
||||
var wallR = AddSquare(plate, 18, 3);
|
||||
var a = AddSquare(plate, 6, 3);
|
||||
var b = AddSquare(plate, 12, 3);
|
||||
|
||||
var result = Expander.Expand(new List<Part> { a, b }, plate);
|
||||
|
||||
// Every gap starts at exactly 2.0; straight separation moves cannot open
|
||||
// the row (opening one gap costs another), so the run stays at ~2.0.
|
||||
Assert.Equal(0, wallL.Location.X, 6);
|
||||
Assert.Equal(18, wallR.Location.X, 6);
|
||||
Assert.True(
|
||||
result.AchievedSpacing >= 1.9 && result.AchievedSpacing <= 2.05,
|
||||
$"achieved {result.AchievedSpacing}"
|
||||
);
|
||||
AssertNoOverlaps(plate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_OverlappingPair_SeparatesAndClearsOverlap()
|
||||
{
|
||||
var plate = MakePlate(30, 12);
|
||||
var a = AddSquare(plate, 5, 4);
|
||||
var b = AddSquare(plate, 7, 4); // 2.0 overlap in X
|
||||
|
||||
var result = Expander.Expand(new List<Part> { a, b }, plate, new Expander.Options
|
||||
{
|
||||
InitialStep = 0.5,
|
||||
MaxSpacing = 3,
|
||||
});
|
||||
|
||||
Assert.False(a.Intersects(b, out _));
|
||||
var gap = b.Location.X - (a.Location.X + 4);
|
||||
Assert.True(gap >= 2.99, $"gap {gap}");
|
||||
Assert.True(result.AchievedSpacing >= 2.9);
|
||||
AssertNoOverlaps(plate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Separate_PinnedPart_ReportsViolationsWithoutOverlap()
|
||||
{
|
||||
var plate = MakePlate(20, 20, edge: 0.0);
|
||||
var pinned = AddSquare(plate, 8, 8);
|
||||
// Walls 0.2 clear on all four sides.
|
||||
var left = AddSquare(plate, 3.8, 8);
|
||||
var right = AddSquare(plate, 12.2, 8);
|
||||
var bottom = AddSquare(plate, 8, 3.8);
|
||||
var top = AddSquare(plate, 8, 12.2);
|
||||
|
||||
var (converged, positions, violations) = Expander.Separate(
|
||||
new List<Part> { pinned },
|
||||
plate,
|
||||
spacing: 1.0
|
||||
);
|
||||
|
||||
Assert.False(converged);
|
||||
Assert.NotEmpty(violations);
|
||||
// The pinned part may slide into the walls but never through them.
|
||||
AssertNoOverlaps(plate);
|
||||
Assert.Equal(3.8, left.Location.X, 6);
|
||||
Assert.Equal(12.2, right.Location.X, 6);
|
||||
Assert.Equal(3.8, bottom.Location.Y, 6);
|
||||
Assert.Equal(12.2, top.Location.Y, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_CancelledBeforeRun_LeavesEverythingInPlace()
|
||||
{
|
||||
var plate = MakePlate(24, 24);
|
||||
var a = AddSquare(plate, 6, 10);
|
||||
var b = AddSquare(plate, 14, 10);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var result = Expander.Expand(
|
||||
new List<Part> { a, b },
|
||||
plate,
|
||||
token: cts.Token
|
||||
);
|
||||
|
||||
Assert.True(result.Cancelled);
|
||||
Assert.Equal(6, a.Location.X, 6);
|
||||
Assert.Equal(14, b.Location.X, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_ThreeInRow_FirstSelectedNeverMoves_AndOracleConfirmsSpacing()
|
||||
{
|
||||
var plate = MakePlate(60, 14);
|
||||
var a = AddSquare(plate, 5, 5);
|
||||
var b = AddSquare(plate, 10, 5);
|
||||
var c = AddSquare(plate, 15, 5);
|
||||
|
||||
var result = Expander.Expand(
|
||||
new List<Part> { a, b, c },
|
||||
plate,
|
||||
new Expander.Options { MaxSpacing = 8 }
|
||||
);
|
||||
|
||||
Assert.Equal(5, a.Location.X, 6); // anchor: never the later index of any pair
|
||||
Assert.True(result.AchievedSpacing >= 7.9);
|
||||
|
||||
// Independent oracle: every pair clears the reported spacing.
|
||||
var parts = new List<Part> { a, b, c };
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
var clearance = BruteClearance(parts[i], parts[j]);
|
||||
Assert.True(
|
||||
clearance >= result.AchievedSpacing - 0.01,
|
||||
$"{parts[i].BaseDrawing.Name}/{parts[j].BaseDrawing.Name}: oracle {clearance} < reported {result.AchievedSpacing}"
|
||||
);
|
||||
}
|
||||
AssertNoOverlaps(plate);
|
||||
}
|
||||
|
||||
private static Program RectangleWithHole(
|
||||
double width,
|
||||
double length,
|
||||
double hx,
|
||||
double hy,
|
||||
double hw,
|
||||
double hh
|
||||
)
|
||||
{
|
||||
var program = Rectangle(width, length);
|
||||
program.MoveTo(hx, hy);
|
||||
program.LineTo(hx + hw, hy);
|
||||
program.LineTo(hx + hw, hy + hh);
|
||||
program.LineTo(hx, hy + hh);
|
||||
program.LineTo(hx, hy);
|
||||
return program;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hole-subtracting overlap check matching NestValidator semantics (a part in
|
||||
/// a cutout is legal). Part.Intersects is perimeter-only, so it cannot
|
||||
/// certify part-in-cutout layouts.
|
||||
/// </summary>
|
||||
private static bool MateriallyOverlaps(Part a, Part b)
|
||||
{
|
||||
var (outerA, holesA) = Rings(a);
|
||||
var (outerB, holesB) = Rings(b);
|
||||
return Collision.HasOverlap(outerA, outerB, holesA, holesB);
|
||||
}
|
||||
|
||||
private static (Polygon Outer, List<Polygon> Holes) Rings(Part part)
|
||||
{
|
||||
var entities = OpenNest.Converters.ConvertProgram
|
||||
.ToGeometry(part.Program)
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
||||
.ToList();
|
||||
var profile = new ShapeProfile(entities);
|
||||
|
||||
var outer = profile.Perimeter.ToPolygonWithTolerance(0.001);
|
||||
outer.Offset(part.Location);
|
||||
|
||||
var holes = new List<Polygon>();
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
var hole = cutout.ToPolygonWithTolerance(0.001);
|
||||
hole.Offset(part.Location);
|
||||
holes.Add(hole);
|
||||
}
|
||||
|
||||
return (outer, holes.Count == 0 ? null : holes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_PartInsideCutout_KeepsLegalAndClearsHoleWalls()
|
||||
{
|
||||
var plate = MakePlate(40, 24);
|
||||
|
||||
// Wall part with a 10x10 cutout; a small selected part sits inside it.
|
||||
var wall = new Part(
|
||||
new Drawing(
|
||||
"wall",
|
||||
RectangleWithHole(20, 20, 5, 5, 10, 10)
|
||||
),
|
||||
new Vector(0, 0)
|
||||
);
|
||||
plate.Parts.Add(wall);
|
||||
|
||||
var inside = new Part(new Drawing("inside", Rectangle(2, 2)), new Vector(9, 9));
|
||||
var other = new Part(new Drawing("other", Rectangle(2, 2)), new Vector(30, 9));
|
||||
plate.Parts.Add(inside);
|
||||
plate.Parts.Add(other);
|
||||
|
||||
var result = Expander.Expand(
|
||||
new List<Part> { inside, other },
|
||||
plate,
|
||||
new Expander.Options { MaxSpacing = 2 }
|
||||
);
|
||||
|
||||
// Part-in-cutout is legal, never a material overlap.
|
||||
Assert.False(MateriallyOverlaps(inside, wall));
|
||||
Assert.False(MateriallyOverlaps(other, wall));
|
||||
Assert.False(MateriallyOverlaps(inside, other));
|
||||
Assert.True(result.AchievedSpacing >= 1.9);
|
||||
|
||||
// The part that started in the cutout must clear the hole walls too.
|
||||
var holeLeft = 5;
|
||||
var holeRight = 15;
|
||||
var gapLeft = inside.Location.X - holeLeft;
|
||||
var gapRight = holeRight - (inside.Location.X + 2);
|
||||
var gapBottom = inside.Location.Y - holeLeft;
|
||||
var gapTop = holeRight - (inside.Location.Y + 2);
|
||||
var minGap = System.Math.Min(
|
||||
System.Math.Min(gapLeft, gapRight),
|
||||
System.Math.Min(gapBottom, gapTop)
|
||||
);
|
||||
Assert.True(minGap >= 1.9, $"closest hole-wall gap {minGap}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_DegenerateInputs_Throw()
|
||||
{
|
||||
var plate = MakePlate(10, 10);
|
||||
var a = AddSquare(plate, 1, 1);
|
||||
var stranger = new Part(new Drawing("stranger", Rectangle()), new Vector(50, 50));
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part>(), plate));
|
||||
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part> { a }, plate));
|
||||
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part> { a, stranger }, plate));
|
||||
Assert.Throws<ArgumentNullException>(() => Expander.Expand(new List<Part> { a, a }, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance()
|
||||
{
|
||||
// Selection must not be pushed to open gaps between parts it excludes.
|
||||
var plate = MakePlate(40, 12);
|
||||
var w1 = AddSquare(plate, 2, 4);
|
||||
var w2 = AddSquare(plate, 6.5, 4); // 0.5 apart from w1, both unselected
|
||||
var a = AddSquare(plate, 14, 4);
|
||||
var b = AddSquare(plate, 20, 4);
|
||||
|
||||
Expander.Expand(new List<Part> { a, b }, plate);
|
||||
|
||||
Assert.Equal(2, w1.Location.X, 6);
|
||||
Assert.Equal(6.5, w2.Location.X, 6);
|
||||
Assert.True(a.Intersects(w1, out _) == false);
|
||||
Assert.True(b.Intersects(w2, out _) == false);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,52 @@ namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class NestLayoutCheckTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(2, false)]
|
||||
[InlineData(3, false)]
|
||||
public void MaxPlatesCountsPhysicalSheetsIncludingEmptyOnes(int? maxPlates, bool exceedsLimit)
|
||||
{
|
||||
var stock = new NestPlateStock("sheet", new Size(48, 96));
|
||||
var job = new NestJob(Array.Empty<NestJobPart>(), new[] { stock }, new NestJobOptions(maxPlates: maxPlates));
|
||||
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||
new[]
|
||||
{
|
||||
new NestJobPlateResult(7, stock, Array.Empty<NestJobPlacement>()),
|
||||
new NestJobPlateResult(42, stock, Array.Empty<NestJobPlacement>()),
|
||||
}, Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
|
||||
|
||||
var violations = NestLayoutCheck.Violations(job, result);
|
||||
|
||||
if (exceedsLimit)
|
||||
Assert.Contains(violations, v => v.Contains("MaxPlates") && v.Contains("2") && v.Contains("1"));
|
||||
else
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ghost", 1, 1, 0)]
|
||||
[InlineData(null, 1, 1, 0)]
|
||||
[InlineData("p", double.NaN, 1, 0)]
|
||||
[InlineData("p", 1, double.PositiveInfinity, 0)]
|
||||
[InlineData("p", 1, 1, double.NaN)]
|
||||
public void MalformedPlacementsProduceDiagnosticsRatherThanThrowing(string? partId, double x, double y, double rotation)
|
||||
{
|
||||
var stock = new NestPlateStock("sheet", new Size(48, 96));
|
||||
var job = new NestJob(new[]
|
||||
{
|
||||
new NestJobPart("p", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle()), 1),
|
||||
}, new[] { stock });
|
||||
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement(partId!, 0, x, y, rotation) }) },
|
||||
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
|
||||
|
||||
var violations = NestLayoutCheck.Violations(job, result);
|
||||
|
||||
Assert.NotEmpty(violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TangentDiscsClearAtSafeMarginAcrossRadiiAnglesAndTolerances()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class NestPipelineCommitTests
|
||||
{
|
||||
private sealed class StubEngine(bool overlap) : INestingEngine
|
||||
{
|
||||
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default) => new(
|
||||
NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||
new[] { new NestJobPlateResult(0, job.Plates[0], new[]
|
||||
{
|
||||
new NestJobPlacement(job.Parts[0].Id, 0, 2, 2, 0),
|
||||
new NestJobPlacement(job.Parts[0].Id, 1, overlap ? 2 : 20, 2, 0),
|
||||
}) },
|
||||
new[] { new PartFulfillment(job.Parts[0].Id, 2, 2, 0) },
|
||||
new[] { new StockUsage(job.Plates[0].Id, 1, null) });
|
||||
}
|
||||
|
||||
private static NestPipelineResult Result(Drawing drawing, bool overlap = false) =>
|
||||
NestPipeline.Run(new StubEngine(overlap), "stub", new NestPipelineRequest("stub",
|
||||
new[] { new NestItem { Drawing = drawing, Quantity = 2 } },
|
||||
new[] { new NestPlateStock("sheet", new Size(48, 96), null, 0.25, new Spacing(1, 1), 1) }));
|
||||
|
||||
[Fact]
|
||||
public void AppliesValidatedSettingsToEmptyPlateAndNeverFillsOccupiedPlate()
|
||||
{
|
||||
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
|
||||
var nest = new Nest();
|
||||
nest.Drawings.Add(drawing);
|
||||
var occupied = nest.CreatePlate();
|
||||
occupied.Parts.Add(new Part(drawing));
|
||||
var empty = nest.CreatePlate();
|
||||
empty.Quantity = 7;
|
||||
empty.Quadrant = 3;
|
||||
empty.Size = new Size(4, 8);
|
||||
empty.EdgeSpacing = new Spacing(0, 0);
|
||||
using var manager = new PlateManager(nest);
|
||||
var result = Result(drawing);
|
||||
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||
|
||||
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager);
|
||||
|
||||
Assert.Same(empty, Assert.Single(applied));
|
||||
Assert.Single(occupied.Parts);
|
||||
Assert.Equal(2, empty.Parts.Count);
|
||||
Assert.Equal(1, empty.Quantity);
|
||||
Assert.Equal(1, empty.Quadrant);
|
||||
Assert.Equal(result.Job.Plates[0].Size, empty.Size);
|
||||
Assert.Equal(result.Job.Plates[0].EdgeSpacing, empty.EdgeSpacing);
|
||||
Assert.Equal(0.25, empty.PartSpacing);
|
||||
Assert.All(empty.Parts, p => Assert.Same(drawing, p.BaseDrawing));
|
||||
Assert.Equal(3, drawing.Quantity.Nested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidResultRequiresExplicitConsentAndDiscardDoesNotMutateNest()
|
||||
{
|
||||
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
|
||||
var nest = new Nest();
|
||||
nest.Drawings.Add(drawing);
|
||||
var empty = nest.CreatePlate();
|
||||
using var manager = new PlateManager(nest);
|
||||
var result = Result(drawing, overlap: true);
|
||||
Assert.False(result.IsValid);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => NestPipelineCommit.ApplyToEmptyPlates(result, manager));
|
||||
Assert.Same(empty, Assert.Single(nest.Plates));
|
||||
Assert.Empty(empty.Parts);
|
||||
Assert.Equal(0, drawing.Quantity.Nested);
|
||||
|
||||
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true);
|
||||
Assert.Equal(2, Assert.Single(applied).Parts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelledCommitDoesNotCreateAnyPlate()
|
||||
{
|
||||
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
|
||||
var nest = new Nest();
|
||||
using var manager = new PlateManager(nest);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
Assert.ThrowsAny<OperationCanceledException>(() =>
|
||||
NestPipelineCommit.ApplyToEmptyPlates(Result(drawing), manager, token: cts.Token));
|
||||
Assert.Empty(nest.Plates);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class NestPipelineTests
|
||||
{
|
||||
private static NestPlateStock Sheet() =>
|
||||
new("sheet", new Size(48, 96), quantity: null, partSpacing: 0.25);
|
||||
|
||||
private static NestItem Item(string name, int quantity) =>
|
||||
new()
|
||||
{
|
||||
Drawing = new Drawing(name, TestDrawingFactory.Rectangle()),
|
||||
Quantity = quantity,
|
||||
};
|
||||
|
||||
private static NestPipelineRequest Request(string engine, params NestItem[] items) =>
|
||||
new(engine, items, new[] { Sheet() });
|
||||
|
||||
private static NestJobResult OnePlate(NestJob job, params NestJobPlacement[] placements) =>
|
||||
new(
|
||||
NestJobStatus.Complete,
|
||||
NestJobStopReason.Completed,
|
||||
new[] { new NestJobPlateResult(0, job.Plates[0], placements) },
|
||||
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, p.Quantity, 0)),
|
||||
new[] { new StockUsage(job.Plates[0].Id, 1, null) }
|
||||
);
|
||||
|
||||
private sealed class StubEngine(Func<NestJob, NestJobResult> solve) : INestingEngine
|
||||
{
|
||||
public NestJobResult Solve(
|
||||
NestJob job,
|
||||
IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default
|
||||
) => solve(job);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredEngineResultIsValidatedAndBoundToCallerDrawings()
|
||||
{
|
||||
var item = Item("bracket", 10);
|
||||
var codes = item.Drawing.Program.Codes.Count;
|
||||
|
||||
var result = NestPipeline.Run(Request("Default", item));
|
||||
|
||||
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||
Assert.True(result.CanKeep);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
var parts = result.Plates.SelectMany(p => p.Parts).ToList();
|
||||
Assert.Equal(10, parts.Count);
|
||||
Assert.All(parts, part => Assert.Same(item.Drawing, part.BaseDrawing));
|
||||
Assert.All(result.Plates, plate => Assert.Same(result.Job.Plates[0], plate.Stock));
|
||||
Assert.Equal(10, item.Quantity);
|
||||
Assert.Equal(codes, item.Drawing.Program.Codes.Count);
|
||||
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlappingEngineOutputIsReportedByDrawingNameWithoutThrowing()
|
||||
{
|
||||
var item = Item("bracket", 2);
|
||||
var before = PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions;
|
||||
var engine = new StubEngine(job =>
|
||||
OnePlate(
|
||||
job,
|
||||
new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0),
|
||||
new NestJobPlacement(job.Parts[0].Id, 1, 1, 1, 0)
|
||||
)
|
||||
);
|
||||
|
||||
var result = NestPipeline.Run(engine, "Overlapper", Request("Overlapper", item));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.CanKeep);
|
||||
Assert.Contains(result.Violations, v => v.Contains("bracket") && v.Contains("spacing"));
|
||||
Assert.Equal(2, result.Plates.Single().Parts.Count);
|
||||
Assert.All(result.Plates.Single().Parts, part => Assert.Same(item.Drawing, part.BaseDrawing));
|
||||
Assert.Equal(before, PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions);
|
||||
Assert.Equal(2, item.Quantity);
|
||||
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ghost")]
|
||||
[InlineData(null)]
|
||||
public void UnknownOrNullRequirementMakesTheEntireProposalNonKeepable(string? partId)
|
||||
{
|
||||
var item = Item("bracket", 1);
|
||||
var before = PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions;
|
||||
var engine = new StubEngine(job => new NestJobResult(
|
||||
NestJobStatus.Complete,
|
||||
NestJobStopReason.Completed,
|
||||
new[]
|
||||
{
|
||||
new NestJobPlateResult(0, job.Plates[0], new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0) }),
|
||||
new NestJobPlateResult(1, job.Plates[0], new[] { new NestJobPlacement(partId!, 0, 30, 1, 0) }),
|
||||
},
|
||||
Array.Empty<PartFulfillment>(),
|
||||
Array.Empty<StockUsage>()
|
||||
));
|
||||
|
||||
var result = NestPipeline.Run(engine, "Ghost", Request("Ghost", item));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.CanKeep);
|
||||
Assert.Contains(result.Violations, v => v.Contains(partId ?? "null") && v.Contains("Plate 1"));
|
||||
Assert.Empty(result.Plates);
|
||||
Assert.Equal(2, result.Raw.Plates.Count);
|
||||
Assert.Equal(before, PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions);
|
||||
Assert.Equal(1, item.Quantity);
|
||||
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(double.NaN, 1, 0, "X")]
|
||||
[InlineData(double.PositiveInfinity, 1, 0, "X")]
|
||||
[InlineData(double.NegativeInfinity, 1, 0, "X")]
|
||||
[InlineData(1, double.NaN, 0, "Y")]
|
||||
[InlineData(1, double.PositiveInfinity, 0, "Y")]
|
||||
[InlineData(1, double.NegativeInfinity, 0, "Y")]
|
||||
[InlineData(1, 1, double.NaN, "Rotation")]
|
||||
[InlineData(1, 1, double.PositiveInfinity, "Rotation")]
|
||||
[InlineData(1, 1, double.NegativeInfinity, "Rotation")]
|
||||
public void NonfinitePoseIsReportedAndNeverProposed(double x, double y, double rotation, string field)
|
||||
{
|
||||
var item = Item("bracket", 2);
|
||||
var before = PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions;
|
||||
var engine = new StubEngine(job => OnePlate(job,
|
||||
new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0),
|
||||
new NestJobPlacement(job.Parts[0].Id, 1, x, y, rotation)));
|
||||
|
||||
var result = NestPipeline.Run(engine, "Nonfinite", Request("Nonfinite", item));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.CanKeep);
|
||||
Assert.Empty(result.Plates);
|
||||
Assert.Contains(result.Violations, v => v.Contains(field) && v.Contains("finite") && v.Contains("bracket"));
|
||||
Assert.Equal(before, PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions);
|
||||
Assert.Equal(2, item.Quantity);
|
||||
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsEveryStructuralViolationWithoutMaterializingAnyGeometry()
|
||||
{
|
||||
var engine = new StubEngine(job => OnePlate(job,
|
||||
new NestJobPlacement("ghost", 0, double.NaN, double.PositiveInfinity, double.NegativeInfinity),
|
||||
new NestJobPlacement(null!, 0, 1, 1, 0),
|
||||
new NestJobPlacement(job.Parts[0].Id, 0, double.NegativeInfinity, 1, 0)));
|
||||
|
||||
var result = NestPipeline.Run(engine, "Malformed", Request("Malformed", Item("bracket", 1)));
|
||||
|
||||
Assert.False(result.CanKeep);
|
||||
Assert.Empty(result.Plates);
|
||||
Assert.Equal(6, result.Violations.Count);
|
||||
Assert.Contains(result.Violations, v => v.Contains("ghost") && v.Contains("not part of this job"));
|
||||
Assert.Contains(result.Violations, v => v.Contains("null") && v.Contains("not part of this job"));
|
||||
Assert.Equal(2, result.Violations.Count(v => v.Contains("nonfinite X")));
|
||||
Assert.Contains(result.Violations, v => v.Contains("nonfinite Y"));
|
||||
Assert.Contains(result.Violations, v => v.Contains("nonfinite Rotation"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExceedingMaxPlatesIsInvalidButRemainsFullyKeepable()
|
||||
{
|
||||
var item = Item("bracket", 2);
|
||||
var request = Request("TooManySheets", item) with { Options = new NestJobOptions(maxPlates: 1) };
|
||||
var engine = new StubEngine(job => new NestJobResult(
|
||||
NestJobStatus.Complete,
|
||||
NestJobStopReason.Completed,
|
||||
Enumerable.Range(0, 2).Select(i => new NestJobPlateResult(i, job.Plates[0],
|
||||
new[] { new NestJobPlacement(job.Parts[0].Id, i, 1, 1, 0) })),
|
||||
new[] { new PartFulfillment(job.Parts[0].Id, 2, 2, 0) },
|
||||
new[] { new StockUsage(job.Plates[0].Id, 2, null) }
|
||||
));
|
||||
|
||||
var result = NestPipeline.Run(engine, "TooManySheets", request);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.CanKeep);
|
||||
Assert.Contains(result.Violations, v => v.Contains("MaxPlates") && v.Contains("2") && v.Contains("1"));
|
||||
Assert.Equal(2, result.Plates.Count);
|
||||
Assert.All(result.Plates, plate => Assert.Same(item.Drawing, Assert.Single(plate.Parts).BaseDrawing));
|
||||
Assert.Equal(2, item.Quantity);
|
||||
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidStockIsRejectedBeforeCallingAnArbitraryEngine()
|
||||
{
|
||||
var called = false;
|
||||
var engine = new StubEngine(job =>
|
||||
{
|
||||
called = true;
|
||||
return OnePlate(job);
|
||||
});
|
||||
var request = Request("Unchecked", Item("bracket", 1)) with
|
||||
{
|
||||
Stock = new[] { new NestPlateStock("bad", new Size(48, 96), partSpacing: double.NaN) },
|
||||
};
|
||||
|
||||
var error = Assert.Throws<ArgumentException>(() => NestPipeline.Run(engine, "Unchecked", request));
|
||||
|
||||
Assert.Contains("Invalid stock", error.Message);
|
||||
Assert.False(called);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void InvalidGeometryIsRejectedBeforeCallingAnArbitraryEngine(bool nonfinite)
|
||||
{
|
||||
var item = Item("bracket", 1);
|
||||
if (nonfinite)
|
||||
((Motion)item.Drawing.Program.Codes[1]).EndPoint = new Vector(double.NaN, 0);
|
||||
else
|
||||
item.Drawing.Program.Codes.Clear();
|
||||
var called = false;
|
||||
var engine = new StubEngine(job =>
|
||||
{
|
||||
called = true;
|
||||
return OnePlate(job);
|
||||
});
|
||||
|
||||
var error = Assert.Throws<ArgumentException>(() =>
|
||||
NestPipeline.Run(engine, "Unchecked", Request("Unchecked", item)));
|
||||
|
||||
Assert.Contains("Geometry must contain finite motions", error.Message);
|
||||
Assert.False(called);
|
||||
Assert.Equal(1, item.Quantity);
|
||||
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownEngineNameListsRegisteredEngines()
|
||||
{
|
||||
var error = Assert.Throws<NotSupportedException>(() =>
|
||||
NestPipeline.Run(Request("Mystery Engine", Item("bracket", 1)))
|
||||
);
|
||||
|
||||
Assert.Contains("Default", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationDuringSolveDiscardsEvenAnEngineThatReturnsNormally()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var engine = new StubEngine(job =>
|
||||
{
|
||||
cts.Cancel();
|
||||
return OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0));
|
||||
});
|
||||
|
||||
Assert.ThrowsAny<OperationCanceledException>(() =>
|
||||
NestPipeline.Run(engine, "IgnoresStop", Request("IgnoresStop", Item("bracket", 1)), null, cts.Token)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationPropagatesWithoutAResult()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
Assert.ThrowsAny<OperationCanceledException>(() =>
|
||||
NestPipeline.Run(Request("Default", Item("bracket", 1)), null, cts.Token)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class NestResultBinderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ghost", 1, 1, 0)]
|
||||
[InlineData(null, 1, 1, 0)]
|
||||
[InlineData("p", double.NaN, 1, 0)]
|
||||
[InlineData("p", 1, double.PositiveInfinity, 0)]
|
||||
[InlineData("p", 1, 1, double.NaN)]
|
||||
public void MalformedSheetCannotBePartiallyBound(string? partId, double x, double y, double rotation)
|
||||
{
|
||||
var drawing = new Drawing("bracket", TestDrawingFactory.Rectangle());
|
||||
var before = PartGeometrySnapshot.FromProgram(drawing.Program).Motions;
|
||||
var sheet = new NestJobPlateResult(0, new NestPlateStock("sheet", new Size(48, 96)),
|
||||
new[]
|
||||
{
|
||||
new NestJobPlacement("p", 0, 1, 1, 0),
|
||||
new NestJobPlacement(partId!, 1, x, y, rotation),
|
||||
});
|
||||
var drawings = new Dictionary<string, Drawing> { ["p"] = drawing };
|
||||
|
||||
Assert.Throws<ArgumentException>(() => NestResultBinder.Bind(sheet, drawings));
|
||||
|
||||
Assert.Equal(before, PartGeometrySnapshot.FromProgram(drawing.Program).Motions);
|
||||
Assert.Equal(0, drawing.Quantity.Nested);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class NestStockBuilderTests
|
||||
{
|
||||
private static Plate Template() => new(new Size(48, 96))
|
||||
{
|
||||
Quantity = 7,
|
||||
PartSpacing = 0.25,
|
||||
EdgeSpacing = new Spacing(1, 2, 3, 4),
|
||||
Quadrant = 3,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void TemplateWithoutOptionsUsesUnlimitedStockAndSnapshotsSettings()
|
||||
{
|
||||
var template = Template();
|
||||
var stock = Assert.Single(NestStockBuilder.FromTemplate(template, null));
|
||||
Assert.Equal("plate", stock.Id);
|
||||
Assert.Null(stock.Quantity);
|
||||
Assert.Equal(template.Size, stock.Size);
|
||||
Assert.Equal(template.PartSpacing, stock.PartSpacing);
|
||||
Assert.Equal(template.EdgeSpacing, stock.EdgeSpacing);
|
||||
Assert.Equal(template.Quadrant, stock.Quadrant);
|
||||
template.Size = new Size(60, 120);
|
||||
template.EdgeSpacing = default;
|
||||
Assert.Equal(new Size(48, 96), stock.Size);
|
||||
Assert.Equal(new Spacing(1, 2, 3, 4), stock.EdgeSpacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionsHaveStableDistinctIdsAndCopyTemplateSettings()
|
||||
{
|
||||
var template = Template();
|
||||
var options = new[]
|
||||
{
|
||||
new PlateOption { Width = 60, Length = 120 },
|
||||
new PlateOption { Width = 72, Length = 144 },
|
||||
};
|
||||
var stock = NestStockBuilder.FromTemplate(template, options, quantityPerOption: 2);
|
||||
Assert.Equal(new[] { "option-0", "option-1" }, stock.Select(s => s.Id));
|
||||
Assert.Equal(new[] { new Size(60, 120), new Size(72, 144) }, stock.Select(s => s.Size));
|
||||
Assert.All(stock, s =>
|
||||
{
|
||||
Assert.Equal(2, s.Quantity);
|
||||
Assert.Equal(template.EdgeSpacing, s.EdgeSpacing);
|
||||
Assert.Equal(template.PartSpacing, s.PartSpacing);
|
||||
Assert.Equal(template.Quadrant, s.Quadrant);
|
||||
});
|
||||
Assert.Equal(new Size(48, 96), template.Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SinglePlateOffersExactlyOneSheetNotPlateRepeatQuantity()
|
||||
{
|
||||
var stock = Assert.Single(NestStockBuilder.SinglePlate(Template()));
|
||||
Assert.Equal(1, stock.Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTemplateIsRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => NestStockBuilder.FromTemplate(null, null));
|
||||
Assert.Throws<ArgumentNullException>(() => NestStockBuilder.SinglePlate(null));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using Xunit;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
@@ -10,12 +10,45 @@ public class NestingEngineRegistryTests
|
||||
{
|
||||
var names = NestingEngineRegistry.AvailableEngines.Select(e => e.Name).ToList();
|
||||
|
||||
Assert.Contains("Rectangles", names);
|
||||
Assert.Contains("Irregular", names);
|
||||
Assert.Contains("Default", names);
|
||||
Assert.Contains("Strip", names);
|
||||
Assert.Contains("Vertical Remnant", names);
|
||||
Assert.Contains("Horizontal Remnant", names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenamedPlugInNamesResolveToTheirBuiltInReplacements()
|
||||
{
|
||||
Assert.Equal("Irregular", NestingEngineRegistry.ResolveName("Opus55NestingEngine"));
|
||||
Assert.Equal("Rectangles", NestingEngineRegistry.ResolveName("rectanglesnestingengine"));
|
||||
Assert.Equal("Rectangles", NestingEngineRegistry.ResolveName(" rectangles "));
|
||||
Assert.IsType<OpenNest.Engine.NestingEngines.Irregular.IrregularNestingEngine>(
|
||||
NestingEngineRegistry.Create("Opus55NestingEngine"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetiredEnginesAreNotSilentlyAliased()
|
||||
{
|
||||
Assert.Null(NestingEngineRegistry.ResolveName("Gpt6AstraNestingEngine"));
|
||||
Assert.Null(NestingEngineRegistry.ResolveName("Qwen38FlashNextNestingEngine"));
|
||||
Assert.Null(NestingEngineRegistry.ResolveName(" "));
|
||||
Assert.Throws<NotSupportedException>(() => NestingEngineRegistry.Create("Qwen38FlashNextNestingEngine"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeftoverPlugInUnderARenamedNameCannotShadowItsReplacement()
|
||||
{
|
||||
var before = NestingEngineRegistry.AvailableEngines.Count;
|
||||
|
||||
NestingEngineRegistry.Register("Opus55NestingEngine", "stale plug-in",
|
||||
() => new FixedStrategyNestingEngine("Default"));
|
||||
|
||||
Assert.Equal(before, NestingEngineRegistry.AvailableEngines.Count);
|
||||
Assert.Equal("Irregular", NestingEngineRegistry.ResolveName("Opus55NestingEngine"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EachBuiltInFactoryProducesAWorkingEngine()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using OpenNest.Engine.NestingEngines.Rectangles;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
using OpenNest.Engine.Jobs;
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
/// <summary>
|
||||
/// Curved extremes (discs, rings, obrounds) and sloped ones (triangles) must clear the layout check
|
||||
/// at every spacing: the check circumscribes arcs, so box-touching copies are only valid when the
|
||||
/// catalog reads their boxes the way the check does. Failed at 16-50 of these 80 jobs before that.
|
||||
/// </summary>
|
||||
public class CurvedExtremeSweepTests
|
||||
{
|
||||
[Fact]
|
||||
public void CurvedAndSlopedPartsPassTheLayoutCheckAtEverySpacing()
|
||||
{
|
||||
var bad = new List<string>(); var n = 0;
|
||||
foreach (var r in new[] { 0.37, 0.5, 0.731, 1.0, 1.23, 2.0, 3.3, 5.0, 7.77, 12.0 })
|
||||
foreach (var sp in new[] { 0.0, 0.1, 0.25, 0.3125 })
|
||||
foreach (var rot in new[] { false, true })
|
||||
foreach (var ring in new[] { false, true })
|
||||
{
|
||||
var pol = rot ? RotationPolicy.Automatic : RotationPolicy.Fixed(0);
|
||||
var job = Job(new[] { Part("d", ring ? Ring(2 * r, r) : Disc(r), 12, pol), Part("o", Obround(4 * r, 1.3 * r), 6, pol), Part("t", Triangle(3 * r, 2 * r), 4, pol) },
|
||||
new[] { Stock("s", 12 * r + 5, 14 * r + 5, spacing: sp) });
|
||||
var res = new RectanglesNestingEngine().Solve(job); n++;
|
||||
var v = NestLayoutCheck.Violations(job, res);
|
||||
if (v.Count > 0) bad.Add($"r={r} sp={sp} rot={rot} ring={ring}: {v[0]}");
|
||||
}
|
||||
Assert.True(bad.Count == 0, $"{bad.Count}/{n}\n" + string.Join("\n", bad));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
/// <summary>Host contract only; engines retain their own packing-quality regressions.</summary>
|
||||
public abstract class EngineContractTests<TEngine> where TEngine : INestingEngine, new()
|
||||
{
|
||||
[Fact]
|
||||
public void ContractPublicConstructor() => Assert.IsAssignableFrom<INestingEngine>(Activator.CreateInstance(typeof(TEngine)));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void ContractQuadrants(int quadrant)
|
||||
{
|
||||
var job = Job([Part("disc", Disc(2), 3), Part("ell", LShape(6, 5, 2), 3)],
|
||||
[Stock("s", 20, 30, 0.2, new Spacing(0.2, 0.3, 0.4, 0.5), quadrant)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractOverflowIndicesAndProgress()
|
||||
{
|
||||
var job = Job([Part("p", Rectangle(8, 8), 3)], [Stock("s", 10, 10)]);
|
||||
var commits = new List<NestJobProgress>();
|
||||
var result = new TEngine().Solve(job, new Capture(p => { if (p.Stage == NestJobStage.PlateCommitted) commits.Add(p); }));
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(3, result.Plates.Count);
|
||||
Assert.Equal(Enumerable.Range(0, 3), result.Plates.Select(p => p.PlateIndex));
|
||||
Assert.Equal(3, commits.Count);
|
||||
Assert.Equal(Enumerable.Range(0, 3), commits.Select(p => p.PlateIndex));
|
||||
Assert.Equal(Enumerable.Range(1, 3), commits.Select(p => p.CommittedPlates));
|
||||
Assert.Equal(Enumerable.Range(1, 3), commits.Select(p => p.CommittedParts));
|
||||
Assert.All(result.StockUsage, s => Assert.Null(s.Remaining));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractOversize()
|
||||
{
|
||||
var job = Job([Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(2, 2), 2)], [Stock("s", 10, 10)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractLowerNumberPriorityWins()
|
||||
{
|
||||
var job = Job([Part("low", Rectangle(8, 8), 1, priority: 9), Part("high", Rectangle(8, 8), 1, priority: 0)],
|
||||
[Stock("s", 10, 10, quantity: 1)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal("high", Assert.Single(Assert.Single(result.Plates).Placements).PartId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractEtchOutsideSheetIsIgnored()
|
||||
{
|
||||
var etched = NotchedPartWithEtch();
|
||||
etched.Codes.Add(new RapidMove(5, 5));
|
||||
etched.Codes.Add(new LinearMove(100, 100) { Layer = LayerType.Scribe });
|
||||
var job = Job([Part("p", etched, 1, RotationPolicy.Fixed(0))], [Stock("s", 10.4, 10.4, quantity: 1)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractDeterminism()
|
||||
{
|
||||
NestJob Build() => Job([Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12)],
|
||||
[Stock("a", 30, 45, 0.3), Stock("b", 40, 40, 0.3)]);
|
||||
var engine = new TEngine();
|
||||
var job = Build();
|
||||
var first = engine.Solve(job);
|
||||
var second = engine.Solve(job);
|
||||
var third = new TEngine().Solve(Build());
|
||||
foreach (var result in new[] { first, second, third }) LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(Describe(first), Describe(second));
|
||||
Assert.Equal(Describe(first), Describe(third));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractCancellationThrows()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
var job = Job([Part("p", Rectangle(2, 2), 5)], [Stock("s", 10, 10)]);
|
||||
Assert.ThrowsAny<OperationCanceledException>(() => new TEngine().Solve(job, token: cancellation.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractCancellationDuringSolveThrows()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var job = Job([Part("p", Rectangle(2, 2), 20)], [Stock("s", 10, 10)]);
|
||||
var progress = new Capture(p =>
|
||||
{
|
||||
if (p.Stage == NestJobStage.EvaluatingCandidate) cancellation.Cancel();
|
||||
});
|
||||
Assert.ThrowsAny<OperationCanceledException>(() => new TEngine().Solve(job, progress, cancellation.Token));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void ContractStockAndPlateLimits(bool plateLimit)
|
||||
{
|
||||
var job = Job([Part("p", Rectangle(8, 8), 3)],
|
||||
[Stock("s", 10, 10, quantity: plateLimit ? null : 1)],
|
||||
new NestJobOptions(maxPlates: plateLimit ? 1 : null));
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced);
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Equal(plateLimit ? NestJobStopReason.PlateLimitReached : NestJobStopReason.StockExhausted, result.StopReason);
|
||||
}
|
||||
|
||||
private static string Describe(NestJobResult result) => System.Text.Json.JsonSerializer.Serialize(result);
|
||||
private sealed class Capture(Action<NestJobProgress> action) : IProgress<NestJobProgress>
|
||||
{ public void Report(NestJobProgress value) => action(value); }
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
public class IrregularNestingEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public void RectanglesFitOnOneSheetWithSpacing()
|
||||
{
|
||||
var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) });
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(12, result.Plates[0].Placements.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void MixedArcAndConcavePartsAreValidInEveryQuadrant(int quadrant)
|
||||
{
|
||||
var job = Job(
|
||||
new[]
|
||||
{
|
||||
Part("disc", Disc(3), 10),
|
||||
Part("ell", LShape(12, 8, 4), 10),
|
||||
Part("tri", Triangle(9, 6), 10),
|
||||
Part("slot", Obround(10, 3), 6),
|
||||
},
|
||||
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) }
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroSpacingStillKeepsPartsApartForValidation()
|
||||
{
|
||||
var job = Job(new[] { Part("disc", Disc(2), 30), Part("rect", Rectangle(7, 3), 20) }, new[] { Stock("sheet", 30, 40) });
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeAndSmallConcavePartsShareASheet()
|
||||
{
|
||||
// End-to-end companion to NoFitCacheTests' containment cases (the precise regression guard).
|
||||
var job = Job(
|
||||
new[] { Part("small", LShape(3, 3, 1), 6), Part("big", Rectangle(20, 20), 2) },
|
||||
new[] { Stock("sheet", 25, 45, spacing: 0.25) }
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PicksTheCheaperSheetWhenItHoldsEverything()
|
||||
{
|
||||
var job = Job(
|
||||
new[] { Part("square", Rectangle(10, 10), 4) },
|
||||
new[] { Stock("big", 60, 120, spacing: 0.25), Stock("small", 25, 25, spacing: 0.25) }
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal("small", Assert.Single(result.Plates).StockId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpillsOntoAdditionalSheets()
|
||||
{
|
||||
var job = Job(new[] { Part("rect", Rectangle(20, 10), 25) }, new[] { Stock("sheet", 30, 50, spacing: 0.5) });
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.True(result.Plates.Count > 1);
|
||||
Assert.Equal(25, result.Plates.Sum(p => p.Placements.Count));
|
||||
var indices = result.Plates.SelectMany(p => p.Placements).Select(p => p.InstanceIndex).OrderBy(i => i);
|
||||
Assert.Equal(Enumerable.Range(0, 25), indices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RespectsFixedAndBoundedRotationPolicies()
|
||||
{
|
||||
var fixedPolicy = RotationPolicy.Fixed(0);
|
||||
var sweep = RotationPolicy.BoundedSweep(0, System.Math.PI / 2, System.Math.PI / 4);
|
||||
var job = Job(
|
||||
new[]
|
||||
{
|
||||
Part("fixed", LShape(10, 6, 3), 8, fixedPolicy),
|
||||
Part("swept", Triangle(8, 5), 8, sweep),
|
||||
},
|
||||
new[] { Stock("sheet", 40, 60, spacing: 0.25) }
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
foreach (var placement in result.Plates.SelectMany(p => p.Placements))
|
||||
{
|
||||
var policy = placement.PartId == "fixed" ? fixedPolicy : sweep;
|
||||
Assert.True(policy.Allows(placement.Rotation), $"{placement.PartId} at {placement.Rotation}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OversizedPartIsReportedUnplacedWithoutBlockingOthers()
|
||||
{
|
||||
var job = Job(
|
||||
new[] { Part("huge", Rectangle(100, 100), 1), Part("small", Rectangle(5, 5), 3) },
|
||||
new[] { Stock("sheet", 20, 20) }
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
|
||||
Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
|
||||
Assert.Equal(3, result.Fulfillment.Single(f => f.PartId == "small").Placed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopsWhenFiniteStockRunsOut()
|
||||
{
|
||||
var job = Job(new[] { Part("rect", Rectangle(9, 9), 20) }, new[] { Stock("sheet", 20, 20, quantity: 2) });
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
|
||||
Assert.Equal(2, result.Plates.Count);
|
||||
var usage = Assert.Single(result.StockUsage);
|
||||
Assert.Equal(2, usage.Used);
|
||||
Assert.Equal(0, usage.Remaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HonorsMaxPlates()
|
||||
{
|
||||
var job = Job(
|
||||
new[] { Part("rect", Rectangle(9, 9), 20) },
|
||||
new[] { Stock("sheet", 20, 20) },
|
||||
new NestJobOptions(maxPlates: 1)
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDeterministic()
|
||||
{
|
||||
NestJob Build() =>
|
||||
Job(
|
||||
new[] { Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12) },
|
||||
new[] { Stock("a", 30, 45, spacing: 0.3), Stock("b", 40, 40, spacing: 0.3) }
|
||||
);
|
||||
|
||||
var first = new IrregularNestingEngine().Solve(Build());
|
||||
var second = new IrregularNestingEngine().Solve(Build());
|
||||
|
||||
Assert.Equal(System.Text.Json.JsonSerializer.Serialize(first), System.Text.Json.JsonSerializer.Serialize(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EtchMarksAreLeftOutOfNestingGeometry()
|
||||
{
|
||||
// A bend tick starts on material and ends 1.0 into a side notch, outside the part but
|
||||
// inside its bounding box (the PEP case that crashed nesting before 1b5e1b1). As
|
||||
// material it is open geometry leaving the part; as a mark it must be ignored.
|
||||
var etched = Polyline((0, 0), (10, 0), (10, 4), (8, 4), (8, 6), (10, 6), (10, 10), (0, 10));
|
||||
etched.Codes.Add(new RapidMove(7.5, 5));
|
||||
etched.Codes.Add(new LinearMove(9, 5) { Layer = LayerType.Scribe });
|
||||
var job = Job(new[] { Part("part", etched, 2, RotationPolicy.Fixed(0)) }, new[] { Stock("sheet", 10.4, 20.6, spacing: 0.2) });
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPublicParameterlessConstructorForPluginDiscovery()
|
||||
{
|
||||
var engine = Activator.CreateInstance(typeof(IrregularNestingEngine));
|
||||
Assert.IsAssignableFrom<INestingEngine>(engine);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed class IrregularContractTests : EngineContractTests<IrregularNestingEngine> { }
|
||||
@@ -0,0 +1,69 @@
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
using System.Linq;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
public class IrregularNoFitCacheTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0.0, 0.0)] // B's corner at A's corner: B covers A completely.
|
||||
[InlineData(-5.0, -5.0)] // A deep inside B.
|
||||
[InlineData(2.0, 0.5)] // Partial overlap.
|
||||
public void ForbidsEveryOverlappingOffsetIncludingContainment(double dx, double dy)
|
||||
{
|
||||
var (small, big) = Orientations();
|
||||
var nfp = new NoFitCache(0.1).Get(small, big);
|
||||
|
||||
Assert.True(Forbidden(nfp, new PointD(dx, dy)), $"offset ({dx}, {dy}) should be forbidden");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(4.0, 0.0)] // Beside A, clear by more than the clearance.
|
||||
[InlineData(0.0, -21.0)] // Below A.
|
||||
[InlineData(-21.0, 0.0)] // Left of A.
|
||||
public void AllowsClearOffsets(double dx, double dy)
|
||||
{
|
||||
var (small, big) = Orientations();
|
||||
var nfp = new NoFitCache(0.1).Get(small, big);
|
||||
|
||||
Assert.False(Forbidden(nfp, new PointD(dx, dy)), $"offset ({dx}, {dy}) should be free");
|
||||
}
|
||||
|
||||
/// <summary>A = 3x3 L (concave), B = 20x20 square; both at rotation 0 with origin at the lower-left.</summary>
|
||||
private static (Orientation Small, Orientation Big) Orientations()
|
||||
{
|
||||
var job = new NestJob(
|
||||
new[]
|
||||
{
|
||||
new NestJobPart("small", Snapshot((0, 0), (3, 0), (3, 1), (1, 1), (1, 3), (0, 3)), 1, 0, RotationPolicy.Fixed(0)),
|
||||
new NestJobPart("big", Snapshot((0, 0), (20, 0), (20, 20), (0, 20)), 1, 0, RotationPolicy.Fixed(0)),
|
||||
},
|
||||
new[] { new NestPlateStock("s", new Size(100, 100)) }
|
||||
);
|
||||
var types = PartCatalog.Build(job);
|
||||
return (types[0].Orientations.Single(), types[1].Orientations.Single());
|
||||
}
|
||||
|
||||
private static bool Forbidden(Nfp nfp, PointD point)
|
||||
{
|
||||
var winding = 0;
|
||||
foreach (var path in nfp.Region)
|
||||
if (Clipper.PointInPolygon(point, path) == PointInPolygonResult.IsInside)
|
||||
winding += Clipper.IsPositive(path) ? 1 : -1;
|
||||
return winding != 0;
|
||||
}
|
||||
|
||||
private static PartGeometrySnapshot Snapshot(params (double X, double Y)[] points)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
|
||||
foreach (var (x, y) in points.Skip(1))
|
||||
program.Codes.Add(new LinearMove(x, y));
|
||||
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
|
||||
return PartGeometrySnapshot.FromProgram(program);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
public static class JobBuilder
|
||||
{
|
||||
public static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) =>
|
||||
new(parts, stock, options);
|
||||
|
||||
public static NestJobPart Part(string id, Program program, int quantity,
|
||||
RotationPolicy? rotation = null, int priority = 0) =>
|
||||
new(id, PartGeometrySnapshot.FromProgram(program), quantity, priority, rotation);
|
||||
|
||||
/// <param name="width">Y extent.</param>
|
||||
/// <param name="length">X extent.</param>
|
||||
public static NestPlateStock Stock(string id, double width, double length, double spacing = 0,
|
||||
Spacing edge = default, int quadrant = 1, int? quantity = null) =>
|
||||
new(id, new Size(width, length), quantity, spacing, edge, quadrant);
|
||||
|
||||
public static NestJobPart Rectangle(string id, double w, double h, int count,
|
||||
RotationPolicy? rotation = null, double x = 0, double y = 0) =>
|
||||
Part(id, Shapes.Polyline((x, y), (x + w, y), (x + w, y + h), (x, y + h)),
|
||||
count, rotation ?? RotationPolicy.Fixed(0));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
public static class LayoutAssert
|
||||
{
|
||||
public static void Valid(NestJob job, NestJobResult result)
|
||||
{
|
||||
var violations = NestLayoutCheck.Violations(job, result);
|
||||
Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations));
|
||||
Assert.Equal(Enumerable.Range(0, result.Plates.Count), result.Plates.Select(p => p.PlateIndex));
|
||||
foreach (var f in result.Fulfillment)
|
||||
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
|
||||
foreach (var sheet in result.Plates)
|
||||
{
|
||||
var s = sheet.Stock;
|
||||
var work = s.WorkArea;
|
||||
foreach (var pose in sheet.Placements)
|
||||
{
|
||||
var part = job.Parts.Single(p => p.Id == pose.PartId);
|
||||
Assert.True(part.Rotation.Allows(pose.Rotation));
|
||||
var geometry = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry))
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToArray();
|
||||
foreach (var entity in geometry) { entity.Rotate(pose.Rotation); entity.Offset(pose.X, pose.Y); }
|
||||
var b = (L: geometry.Min(e => e.Left), B: geometry.Min(e => e.Bottom),
|
||||
R: geometry.Max(e => e.Right), T: geometry.Max(e => e.Top));
|
||||
Assert.True(b.L >= work.Left - 1e-7 && b.B >= work.Bottom - 1e-7
|
||||
&& b.R <= work.Right + 1e-7 && b.T <= work.Top + 1e-7);
|
||||
}
|
||||
}
|
||||
foreach (var part in job.Parts)
|
||||
{
|
||||
var placed = result.Plates.SelectMany(s => s.Placements).Where(p => p.PartId == part.Id).ToArray();
|
||||
Assert.Equal(Enumerable.Range(0, placed.Length), placed.Select(p => p.InstanceIndex).Order());
|
||||
var fulfillment = result.Fulfillment.Single(f => f.PartId == part.Id);
|
||||
Assert.Equal(placed.Length, fulfillment.Placed);
|
||||
Assert.Equal(part.Quantity, fulfillment.Placed + fulfillment.Unplaced);
|
||||
}
|
||||
foreach (var usage in result.StockUsage)
|
||||
{
|
||||
var stock = job.Plates.Single(s => s.Id == usage.StockId);
|
||||
Assert.Equal(result.Plates.Count(s => s.StockId == stock.Id), usage.Used);
|
||||
Assert.Equal(stock.Quantity - usage.Used, usage.Remaining);
|
||||
Assert.True(usage.Remaining is null or >= 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using OpenNest.Engine.NestingEngines.Rectangles;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
/// <summary>
|
||||
/// Starter acceptance tests. Every layout is checked by the shared NestLayoutCheck the benchmark
|
||||
/// scores with, so a passing test means the benchmark will accept the layout. They fail until
|
||||
/// Solve() is implemented; add engine-specific tests alongside them.
|
||||
/// </summary>
|
||||
public class RectanglesNestingEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public void HasPublicParameterlessConstructorForPluginDiscovery()
|
||||
{
|
||||
var engine = Activator.CreateInstance(typeof(RectanglesNestingEngine));
|
||||
Assert.IsAssignableFrom<INestingEngine>(engine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RectanglesFitOnOneSheetWithSpacing()
|
||||
{
|
||||
var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) });
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(12, result.Plates[0].Placements.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void MixedArcAndConcavePartsAreValidInEveryQuadrant(int quadrant)
|
||||
{
|
||||
var job = Job(
|
||||
new[]
|
||||
{
|
||||
Part("disc", Disc(3), 10),
|
||||
Part("ell", LShape(12, 8, 4), 10),
|
||||
Part("tri", Triangle(9, 6), 10),
|
||||
},
|
||||
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) }
|
||||
);
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverflowSpillsOntoAdditionalSheets()
|
||||
{
|
||||
var job = Job(new[] { Part("square", Rectangle(10, 10), 30) }, new[] { Stock("sheet", 25, 45, spacing: 0.25) });
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.True(result.Plates.Count > 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartTooBigForAnySheetIsReportedUnplaced()
|
||||
{
|
||||
var job = Job(
|
||||
new[] { Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(5, 5), 4) },
|
||||
new[] { Stock("sheet", 20, 20, spacing: 0.25) }
|
||||
);
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge");
|
||||
Assert.Equal(1, huge.Unplaced);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactFitGridPacksAtExactlyThePartSpacing()
|
||||
{
|
||||
// 4 x 3 boxes of 10 x 5 at 0.5 spacing need exactly 41.5 x 16.
|
||||
var job = Job(new[] { Part("r", Rectangle(10, 5), 12) },
|
||||
new[] { Stock("s", 16, 41.5, spacing: 0.5, quantity: 1) });
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(12, Assert.Single(result.Plates).Placements.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArcExtremePartsStayClearAtTheSpacing()
|
||||
{
|
||||
// Discs and obrounds have arcs, not vertices, at their box edges: the validator's
|
||||
// circumscribed flattening would read box-touching copies as closer than the spacing.
|
||||
var job = Job(new[] { Part("disc", Disc(2), 20), Part("ob", Obround(8, 3), 12) },
|
||||
new[] { Stock("s", 30, 40, spacing: 0.25) });
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixedSizesFillOneSheetThatShelfPackingWouldSplit()
|
||||
{
|
||||
// Area check: 2*(24x20) + 4*(12x10) + 8*(6x5) = 960 + 480 + 240 = 1680 of 48 x 40 = 1920.
|
||||
// A maximal-rectangles packing fits all of it on one sheet with zero spacing.
|
||||
var job = Job(new[]
|
||||
{
|
||||
Rectangle("big", 24, 20, 2, RotationPolicy.Automatic),
|
||||
Rectangle("mid", 12, 10, 4, RotationPolicy.Automatic),
|
||||
Rectangle("small", 6, 5, 8, RotationPolicy.Automatic),
|
||||
},
|
||||
new[] { Stock("s", 40, 48) });
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RotatedInputIsNestedAtItsMinimumBoundingRectangle()
|
||||
{
|
||||
// A 10 x 4 rectangle drawn at 30 degrees: only its squared-up box fits 4 per 20.5 x 8.5 sheet.
|
||||
var c = System.Math.Cos(System.Math.PI / 6);
|
||||
var s = System.Math.Sin(System.Math.PI / 6);
|
||||
(double, double) R(double x, double y) => (x * c - y * s + 5, x * s + y * c + 5);
|
||||
var tilted = Polyline(R(0, 0), R(10, 0), R(10, 4), R(0, 4));
|
||||
var job = Job(new[] { Part("tilted", tilted, 4, RotationPolicy.Automatic) },
|
||||
new[] { Stock("s", 8.5, 20.5, spacing: 0.5, quantity: 1) });
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RectanglesContractTests : EngineContractTests<RectanglesNestingEngine> { }
|
||||
@@ -0,0 +1,56 @@
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
public static class Shapes
|
||||
{
|
||||
public static Program Polyline(params (double X, double Y)[] points)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
|
||||
foreach (var (x, y) in points.Skip(1))
|
||||
program.Codes.Add(new LinearMove(x, y));
|
||||
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
|
||||
return program;
|
||||
}
|
||||
|
||||
public static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h));
|
||||
|
||||
public static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h));
|
||||
|
||||
public static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h));
|
||||
|
||||
public static Program Disc(double r)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(r, 0));
|
||||
program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW));
|
||||
program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW));
|
||||
return program;
|
||||
}
|
||||
|
||||
/// <summary>Stadium: two semicircular ends joined by straight sides, offset from the origin.</summary>
|
||||
public static Program Obround(double length, double width)
|
||||
{
|
||||
var r = width / 2;
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(1 + r, 1));
|
||||
program.Codes.Add(new LinearMove(1 + length - r, 1));
|
||||
program.Codes.Add(new ArcMove(1 + length - r, 1 + width, 1 + length - r, 1 + r, RotationType.CCW));
|
||||
program.Codes.Add(new LinearMove(1 + r, 1 + width));
|
||||
program.Codes.Add(new ArcMove(1 + r, 1, 1 + r, 1 + r, RotationType.CCW));
|
||||
return program;
|
||||
}
|
||||
public static Program NotchedPartWithEtch()
|
||||
{
|
||||
var p = new Program();
|
||||
p.MoveTo(0, 0); p.LineTo(10, 0); p.LineTo(10, 4); p.LineTo(8, 4); p.LineTo(8, 6);
|
||||
p.LineTo(10, 6); p.LineTo(10, 10); p.LineTo(0, 10); p.LineTo(0, 0);
|
||||
p.MoveTo(7.5, 5);
|
||||
p.Codes.Add(new LinearMove(9, 5) { Layer = LayerType.Scribe });
|
||||
return p;
|
||||
}
|
||||
|
||||
public static Program Ring(double outerDiameter, double innerDiameter) =>
|
||||
new OpenNest.Shapes.RingShape { OuterDiameter = outerDiameter, InnerDiameter = innerDiameter }.GetDrawing().Program;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
@@ -13,117 +12,21 @@ namespace OpenNest.Engine.BestFit
|
||||
SlideOffset[] offsets
|
||||
)
|
||||
{
|
||||
var count = offsets.Length;
|
||||
var results = new double[count];
|
||||
var results = new double[offsets.Length];
|
||||
var movingVertices = Vertices(movingTemplateLines);
|
||||
var stationaryVertices = Vertices(stationaryLines);
|
||||
var contacts = SlideContactClassifier.FromLines(
|
||||
movingTemplateLines, Vector.Zero, stationaryLines, Vector.Zero).Prepare();
|
||||
|
||||
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines);
|
||||
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
|
||||
|
||||
var vertexCache =
|
||||
new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
|
||||
|
||||
foreach (var offset in offsets)
|
||||
{
|
||||
var key = (offset.DirX, offset.DirY);
|
||||
if (vertexCache.ContainsKey(key))
|
||||
continue;
|
||||
|
||||
var leading = FilterVerticesByProjection(
|
||||
allMovingVerts,
|
||||
offset.DirX,
|
||||
offset.DirY,
|
||||
keepHigh: true
|
||||
);
|
||||
var facing = FilterVerticesByProjection(
|
||||
allStationaryVerts,
|
||||
offset.DirX,
|
||||
offset.DirY,
|
||||
keepHigh: false
|
||||
);
|
||||
vertexCache[key] = (leading, facing);
|
||||
}
|
||||
|
||||
System.Threading.Tasks.Parallel.For(
|
||||
0,
|
||||
count,
|
||||
i =>
|
||||
System.Threading.Tasks.Parallel.For(0, offsets.Length, i =>
|
||||
{
|
||||
var offset = offsets[i];
|
||||
var dirX = offset.DirX;
|
||||
var dirY = offset.DirY;
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
for (var v = 0; v < leadingMoving.Length; v++)
|
||||
{
|
||||
var vx = leadingMoving[v].X + offset.Dx;
|
||||
var vy = leadingMoving[v].Y + offset.Dy;
|
||||
|
||||
for (var j = 0; j < stationaryLines.Count; j++)
|
||||
{
|
||||
var e = stationaryLines[j];
|
||||
var d = SpatialQuery.RayEdgeDistance(
|
||||
vx,
|
||||
vy,
|
||||
e.StartPoint.X,
|
||||
e.StartPoint.Y,
|
||||
e.EndPoint.X,
|
||||
e.EndPoint.Y,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
{
|
||||
results[i] = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var v = 0; v < facingStationary.Length; v++)
|
||||
{
|
||||
var svx = facingStationary[v].X;
|
||||
var svy = facingStationary[v].Y;
|
||||
|
||||
for (var j = 0; j < movingTemplateLines.Count; j++)
|
||||
{
|
||||
var e = movingTemplateLines[j];
|
||||
var d = SpatialQuery.RayEdgeDistance(
|
||||
svx,
|
||||
svy,
|
||||
e.StartPoint.X + offset.Dx,
|
||||
e.StartPoint.Y + offset.Dy,
|
||||
e.EndPoint.X + offset.Dx,
|
||||
e.EndPoint.Y + offset.Dy,
|
||||
oppX,
|
||||
oppY
|
||||
);
|
||||
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
{
|
||||
results[i] = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results[i] = minDist;
|
||||
}
|
||||
);
|
||||
|
||||
var source = new LineSlideEvents(
|
||||
movingTemplateLines, movingVertices, offset.Dx, offset.Dy,
|
||||
stationaryLines, stationaryVertices, offset.DirX, offset.DirY);
|
||||
results[i] = SlideResolver.FirstBlocking(ref source,
|
||||
contacts.At(new Vector(offset.Dx, offset.Dy), Vector.Zero), offset.DirX, offset.DirY);
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -133,347 +36,26 @@ namespace OpenNest.Engine.BestFit
|
||||
SlideOffset[] offsets
|
||||
)
|
||||
{
|
||||
var count = offsets.Length;
|
||||
var results = new double[count];
|
||||
var results = new double[offsets.Length];
|
||||
var movingVertices = SpatialQuery.ExtractEntityVertices(movingEntities);
|
||||
var stationaryVertices = SpatialQuery.ExtractEntityVertices(stationaryEntities);
|
||||
var contacts = new SlideContactClassifier(movingEntities, stationaryEntities).Prepare();
|
||||
|
||||
var allMovingVerts = ExtractVerticesFromEntities(movingEntities);
|
||||
var allStationaryVerts = ExtractVerticesFromEntities(stationaryEntities);
|
||||
|
||||
var movingCurves = ExtractCurveParams(movingEntities);
|
||||
var stationaryCurves = ExtractCurveParams(stationaryEntities);
|
||||
|
||||
var vertexCache =
|
||||
new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
|
||||
|
||||
foreach (var offset in offsets)
|
||||
{
|
||||
var key = (offset.DirX, offset.DirY);
|
||||
if (vertexCache.ContainsKey(key))
|
||||
continue;
|
||||
|
||||
var leading = FilterVerticesByProjection(
|
||||
allMovingVerts,
|
||||
offset.DirX,
|
||||
offset.DirY,
|
||||
keepHigh: true
|
||||
);
|
||||
var facing = FilterVerticesByProjection(
|
||||
allStationaryVerts,
|
||||
offset.DirX,
|
||||
offset.DirY,
|
||||
keepHigh: false
|
||||
);
|
||||
vertexCache[key] = (leading, facing);
|
||||
}
|
||||
|
||||
System.Threading.Tasks.Parallel.For(
|
||||
0,
|
||||
count,
|
||||
i =>
|
||||
// All vertices participate: a leading-half filter can miss the next contact
|
||||
// after sliding past an initial touch on a concave boundary.
|
||||
System.Threading.Tasks.Parallel.For(0, offsets.Length, i =>
|
||||
{
|
||||
var offset = offsets[i];
|
||||
var dirX = offset.DirX;
|
||||
var dirY = offset.DirY;
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Case 1: Leading moving vertices → stationary entities
|
||||
for (var v = 0; v < leadingMoving.Length; v++)
|
||||
{
|
||||
var vx = leadingMoving[v].X + offset.Dx;
|
||||
var vy = leadingMoving[v].Y + offset.Dy;
|
||||
|
||||
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(
|
||||
vx,
|
||||
vy,
|
||||
stationaryEntities[j],
|
||||
0,
|
||||
0,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
{
|
||||
results[i] = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Facing stationary vertices → moving entities (opposite direction)
|
||||
for (var v = 0; v < facingStationary.Length; v++)
|
||||
{
|
||||
var svx = facingStationary[v].X;
|
||||
var svy = facingStationary[v].Y;
|
||||
|
||||
for (var j = 0; j < movingEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(
|
||||
svx,
|
||||
svy,
|
||||
movingEntities[j],
|
||||
offset.Dx,
|
||||
offset.Dy,
|
||||
oppX,
|
||||
oppY
|
||||
);
|
||||
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
{
|
||||
results[i] = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Curve-to-curve direct distance.
|
||||
// Vertex sampling misses the true contact between two curved entities
|
||||
// when the approach angle doesn't align with a sampled vertex.
|
||||
for (var m = 0; m < movingCurves.Length; m++)
|
||||
{
|
||||
var mc = movingCurves[m];
|
||||
var mcx = mc.Cx + offset.Dx;
|
||||
var mcy = mc.Cy + offset.Dy;
|
||||
|
||||
for (var s = 0; s < stationaryCurves.Length; s++)
|
||||
{
|
||||
var sc = stationaryCurves[s];
|
||||
var d = SpatialQuery.CurveTangencyDistance(
|
||||
mcx, mcy, mc.Radius, mc.Entity as Arc,
|
||||
sc.Cx, sc.Cy, sc.Radius, sc.Entity as Arc, dirX, dirY);
|
||||
|
||||
if (d >= minDist)
|
||||
continue;
|
||||
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
{
|
||||
results[i] = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results[i] = minDist;
|
||||
}
|
||||
);
|
||||
|
||||
var source = new EntitySlideEvents(
|
||||
movingEntities, movingVertices, offset.Dx, offset.Dy,
|
||||
stationaryEntities, stationaryVertices, offset.DirX, offset.DirY, arcToLine: true);
|
||||
results[i] = SlideResolver.FirstBlocking(ref source,
|
||||
contacts.At(new Vector(offset.Dx, offset.Dy), Vector.Zero), offset.DirX, offset.DirY);
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
private readonly struct CurveParams
|
||||
{
|
||||
public readonly Entity Entity;
|
||||
public readonly double Cx,
|
||||
Cy,
|
||||
Radius;
|
||||
|
||||
public CurveParams(Entity entity, double cx, double cy, double radius)
|
||||
{
|
||||
Entity = entity;
|
||||
Cx = cx;
|
||||
Cy = cy;
|
||||
Radius = radius;
|
||||
}
|
||||
}
|
||||
|
||||
private static CurveParams[] ExtractCurveParams(List<Entity> entities)
|
||||
{
|
||||
var curves = new List<CurveParams>();
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
if (entities[i] is Circle circle)
|
||||
curves.Add(
|
||||
new CurveParams(circle, circle.Center.X, circle.Center.Y, circle.Radius)
|
||||
);
|
||||
else if (entities[i] is Arc arc)
|
||||
curves.Add(new CurveParams(arc, arc.Center.X, arc.Center.Y, arc.Radius));
|
||||
}
|
||||
return curves.ToArray();
|
||||
}
|
||||
|
||||
private static double RayEntityDistance(
|
||||
double vx,
|
||||
double vy,
|
||||
Entity entity,
|
||||
double entityOffsetX,
|
||||
double entityOffsetY,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
return SpatialQuery.RayEdgeDistance(
|
||||
vx,
|
||||
vy,
|
||||
line.StartPoint.X + entityOffsetX,
|
||||
line.StartPoint.Y + entityOffsetY,
|
||||
line.EndPoint.X + entityOffsetX,
|
||||
line.EndPoint.Y + entityOffsetY,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
return SpatialQuery.RayArcDistance(
|
||||
vx,
|
||||
vy,
|
||||
arc.Center.X + entityOffsetX,
|
||||
arc.Center.Y + entityOffsetY,
|
||||
arc.Radius,
|
||||
arc.StartAngle,
|
||||
arc.EndAngle,
|
||||
arc.IsReversed,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
return SpatialQuery.RayCircleDistance(
|
||||
vx,
|
||||
vy,
|
||||
circle.Center.X + entityOffsetX,
|
||||
circle.Center.Y + entityOffsetY,
|
||||
circle.Radius,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
private static Vector[] ExtractVerticesFromEntities(List<Entity> entities)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
var entity = entities[i];
|
||||
|
||||
if (entity is Line line)
|
||||
{
|
||||
vertices.Add(line.StartPoint);
|
||||
vertices.Add(line.EndPoint);
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
vertices.Add(arc.StartPoint());
|
||||
vertices.Add(arc.EndPoint());
|
||||
AddArcExtremes(vertices, arc);
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
// Four cardinal points
|
||||
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
|
||||
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
|
||||
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
|
||||
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
|
||||
}
|
||||
}
|
||||
|
||||
return vertices.ToArray();
|
||||
}
|
||||
|
||||
private static void AddArcExtremes(HashSet<Vector> points, Arc arc)
|
||||
{
|
||||
var a1 = arc.StartAngle;
|
||||
var a2 = arc.EndAngle;
|
||||
var reversed = arc.IsReversed;
|
||||
|
||||
if (reversed)
|
||||
Generic.Swap(ref a1, ref a2);
|
||||
|
||||
// Right (0°)
|
||||
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
|
||||
|
||||
// Top (90°)
|
||||
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
|
||||
|
||||
// Left (180°)
|
||||
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
|
||||
|
||||
// Bottom (270°)
|
||||
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
|
||||
}
|
||||
|
||||
private static Vector[] ExtractUniqueVertices(List<Line> lines)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < lines.Count; i++)
|
||||
{
|
||||
vertices.Add(lines[i].StartPoint);
|
||||
vertices.Add(lines[i].EndPoint);
|
||||
}
|
||||
return vertices.ToArray();
|
||||
}
|
||||
|
||||
private static Vector[] FilterVerticesByProjection(
|
||||
Vector[] vertices,
|
||||
double dirX,
|
||||
double dirY,
|
||||
bool keepHigh
|
||||
)
|
||||
{
|
||||
if (vertices.Length == 0)
|
||||
return vertices;
|
||||
|
||||
var projections = new double[vertices.Length];
|
||||
var min = double.MaxValue;
|
||||
var max = double.MinValue;
|
||||
|
||||
for (var i = 0; i < vertices.Length; i++)
|
||||
{
|
||||
projections[i] = vertices[i].X * dirX + vertices[i].Y * dirY;
|
||||
if (projections[i] < min)
|
||||
min = projections[i];
|
||||
if (projections[i] > max)
|
||||
max = projections[i];
|
||||
}
|
||||
|
||||
var midpoint = (min + max) / 2;
|
||||
var count = 0;
|
||||
|
||||
for (var i = 0; i < vertices.Length; i++)
|
||||
{
|
||||
if (keepHigh ? projections[i] >= midpoint : projections[i] <= midpoint)
|
||||
count++;
|
||||
}
|
||||
|
||||
var result = new Vector[count];
|
||||
var idx = 0;
|
||||
|
||||
for (var i = 0; i < vertices.Length; i++)
|
||||
{
|
||||
if (keepHigh ? projections[i] >= midpoint : projections[i] <= midpoint)
|
||||
result[idx++] = vertices[i];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
private static Vector[] Vertices(List<Line> lines) =>
|
||||
lines.SelectMany(line => new[] { line.StartPoint, line.EndPoint }).Distinct().ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,15 @@ namespace OpenNest.Engine.BestFit
|
||||
SlideOffset[] offsets
|
||||
)
|
||||
{
|
||||
// ISlideComputer is axis-only; do not quantize an arbitrary direction into
|
||||
// an unrelated cardinal push. Native curves already use this same fallback.
|
||||
foreach (var offset in offsets)
|
||||
{
|
||||
if (!((offset.DirX == 0 && System.Math.Abs(offset.DirY) == 1)
|
||||
|| (offset.DirY == 0 && System.Math.Abs(offset.DirX) == 1)))
|
||||
return new CpuDistanceComputer().ComputeDistances(stationaryLines, movingTemplateLines, offsets);
|
||||
}
|
||||
|
||||
var stationarySegments = SpatialQuery.FlattenLines(stationaryLines);
|
||||
var movingSegments = SpatialQuery.FlattenLines(movingTemplateLines);
|
||||
var count = offsets.Length;
|
||||
@@ -55,7 +64,7 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
/// <summary>
|
||||
/// Maps a unit direction vector to a PushDirection int for the GPU interface.
|
||||
/// Left=0, Down=1, Right=2, Up=3.
|
||||
/// Up=0, Down=1, Left=2, Right=3.
|
||||
/// </summary>
|
||||
private static int DirectionVectorToInt(double dirX, double dirY)
|
||||
{
|
||||
|
||||
@@ -9,7 +9,8 @@ namespace OpenNest.Engine.BestFit
|
||||
public interface ISlideComputer : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the minimum directional distance for each offset position.
|
||||
/// Computes the first blocking contact distance for each offset position.
|
||||
/// Separating/tangential contacts on closed boundaries do not block.
|
||||
/// </summary>
|
||||
/// <param name="stationarySegments">Flat array [x1,y1,x2,y2, ...] for stationary edges.</param>
|
||||
/// <param name="stationaryCount">Number of line segments in stationarySegments.</param>
|
||||
@@ -30,7 +31,7 @@ namespace OpenNest.Engine.BestFit
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Computes minimum directional distance for offsets with per-offset directions.
|
||||
/// Computes first blocking contact distances with per-offset directions.
|
||||
/// Uploads segment data once for all offsets, reducing GPU round-trips.
|
||||
/// </summary>
|
||||
double[] ComputeBatchMultiDir(
|
||||
|
||||
@@ -127,24 +127,20 @@ namespace OpenNest.Engine.Fill
|
||||
: PartGeometry.GetPerimeterEntities(moving)
|
||||
);
|
||||
|
||||
// A moving part can be inside an obstacle's cutout. Omitting that
|
||||
// loop would let it cross the inner wall before seeing the perimeter.
|
||||
obstacleEntities[i] ??=
|
||||
halfSpacing > 0
|
||||
? PartGeometry.GetOffsetPerimeterEntities(obstacleParts[i], halfSpacing)
|
||||
: PartGeometry.GetPerimeterEntities(obstacleParts[i]);
|
||||
? PartGeometry.GetOffsetPartEntities(obstacleParts[i], halfSpacing)
|
||||
: PartGeometry.GetPartEntities(obstacleParts[i]);
|
||||
|
||||
// Contacts left by a previous push only block directions that would
|
||||
// push material into material; the kernel classifies them.
|
||||
var d = SpatialQuery.DirectionalDistance(
|
||||
movingEntities,
|
||||
obstacleEntities[i],
|
||||
direction
|
||||
);
|
||||
if (
|
||||
d <= Tolerance.Epsilon
|
||||
&& partSpacing <= Tolerance.Epsilon
|
||||
&& CanNudgeWithoutOverlap(moving, obstacleParts[i], direction)
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (d < distance)
|
||||
distance = d;
|
||||
@@ -176,27 +172,25 @@ namespace OpenNest.Engine.Fill
|
||||
{
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
if (candidate.Intersects(parts[i], out _))
|
||||
if (!candidate.Intersects(parts[i], out _))
|
||||
continue;
|
||||
|
||||
// Part.Intersects compares outer perimeters only. A valid insert in a
|
||||
// cutout must remain an obstacle, not be discarded as already overlapping.
|
||||
var a = new ShapeProfile(PartGeometry.GetPartEntities(candidate));
|
||||
var b = new ShapeProfile(PartGeometry.GetPartEntities(parts[i]));
|
||||
if (a.Cutouts.Count == 0 && b.Cutouts.Count == 0)
|
||||
return true;
|
||||
if (Collision.HasOverlap(
|
||||
a.Perimeter.ToPolygonWithTolerance(0.001),
|
||||
b.Perimeter.ToPolygonWithTolerance(0.001),
|
||||
a.Cutouts.Select(hole => hole.ToPolygonWithTolerance(0.001)).ToList(),
|
||||
b.Cutouts.Select(hole => hole.ToPolygonWithTolerance(0.001)).ToList()))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool CanNudgeWithoutOverlap(Part moving, Part obstacle, Vector direction)
|
||||
{
|
||||
var nudge = direction * (Tolerance.Epsilon * 10);
|
||||
|
||||
moving.Offset(nudge);
|
||||
try
|
||||
{
|
||||
return !moving.Intersects(obstacle, out _);
|
||||
}
|
||||
finally
|
||||
{
|
||||
moving.Offset(-nudge);
|
||||
}
|
||||
}
|
||||
|
||||
public static double Push(
|
||||
List<Part> movingParts,
|
||||
List<Part> obstacleParts,
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes a group of selected parts apart until every constrained pair
|
||||
/// (selected↔selected and selected↔obstacle) reaches a target part-to-part
|
||||
/// spacing, with the plate work area and all non-selected parts as hard
|
||||
/// boundaries. The inverse of <see cref="Compactor"/>: it grows gaps instead
|
||||
/// of closing them.
|
||||
/// <para>
|
||||
/// Input with overlaps is accepted: an overlapping pair is a pair with
|
||||
/// negative clearance and is separated along the minimum-translation
|
||||
/// direction. Moves are straight-line only — a pair that could separate only
|
||||
/// by routing around a blocker is reported as a violation instead. Final
|
||||
/// positions are always overlap-free; nothing moves off the work area.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Anchor policy: within a violated pair the later-indexed selected part
|
||||
/// moves; the counterpart moves only as a fallback when the anchor mover is
|
||||
/// fully blocked by a work-area edge. Walls (non-selected parts) never move.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class Expander
|
||||
{
|
||||
public sealed class Options
|
||||
{
|
||||
/// <summary>First spacing probed by the search; also the floor for the doubling step.</summary>
|
||||
public double InitialStep = 1.0;
|
||||
|
||||
/// <summary>Bisection stops once the achievable spacing is known within this tolerance.</summary>
|
||||
public double Tolerance = 0.01;
|
||||
|
||||
/// <summary>Relaxation iteration cap per separation run.</summary>
|
||||
public int MaxIterations = 100;
|
||||
|
||||
/// <summary>Upper bound for the spacing search. 0 = auto (work-area diagonal).</summary>
|
||||
public double MaxSpacing = 0;
|
||||
}
|
||||
|
||||
public sealed class Violation
|
||||
{
|
||||
public Part A;
|
||||
public Part B;
|
||||
|
||||
/// <summary>Clearance actually reached (may be negative for unresolvable overlaps).</summary>
|
||||
public double Achieved;
|
||||
|
||||
/// <summary>True when a work-area edge, not a part, blocked the last needed move.</summary>
|
||||
public bool BlockedByEdge;
|
||||
}
|
||||
|
||||
public sealed class Result
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum part-to-part clearance the returned layout satisfies,
|
||||
/// never below zero. 0 with violations means the layout was only
|
||||
/// cleaned up as far as possible, not opened up.
|
||||
/// </summary>
|
||||
public double AchievedSpacing;
|
||||
|
||||
/// <summary>True when the run was cancelled; no positions were changed.</summary>
|
||||
public bool Cancelled;
|
||||
|
||||
/// <summary>Pairs that could not reach <see cref="AchievedSpacing"/>.</summary>
|
||||
public List<Violation> Violations = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the part-to-part spacing of the selection as far as the plate
|
||||
/// and its other parts allow, applying the best spacing found. Plate
|
||||
/// PartSpacing/EdgeSpacing are not modified; edges keep their own
|
||||
/// EdgeSpacing floor while only part-to-part clearance chases the target.
|
||||
/// Mutates <paramref name="selected"/> locations; probing never touches
|
||||
/// them, so a failed or cancelled run leaves the layout unchanged.
|
||||
/// </summary>
|
||||
public static Result Expand(
|
||||
List<Part> selected,
|
||||
Plate plate,
|
||||
Options options = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
if (plate == null)
|
||||
throw new ArgumentNullException(nameof(plate));
|
||||
if (selected == null || selected.Count < 2)
|
||||
throw new ArgumentException(
|
||||
"Expand requires at least two selected parts.",
|
||||
nameof(selected)
|
||||
);
|
||||
if (selected.Any(p => !plate.Parts.Contains(p)))
|
||||
throw new ArgumentException(
|
||||
"All selected parts must belong to the plate.",
|
||||
nameof(selected)
|
||||
);
|
||||
|
||||
var opt = options ?? new Options();
|
||||
var context = SeparationContext.Prepare(selected, plate);
|
||||
var entry = context.Positions(); // all parts; movers are [0, count)
|
||||
var result = new Result();
|
||||
|
||||
var sMin = MinimumPairClearance(context, entry);
|
||||
result.AchievedSpacing = System.Math.Max(0, sMin);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
result.Cancelled = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
var sGood = sMin;
|
||||
var cap = opt.MaxSpacing > 0 ? opt.MaxSpacing : context.SpacingCap;
|
||||
double hi; // first spacing that failed; double.NaN = none yet
|
||||
|
||||
(bool Converged, List<Vector> Positions) Probe(double spacing)
|
||||
{
|
||||
var attempt = Separate(context, entry, spacing, opt.MaxIterations, token);
|
||||
return (attempt.Converged, attempt.Positions);
|
||||
}
|
||||
|
||||
// Doubling phase: commit every spacing that converges. The cap gets
|
||||
// its own probe even when the step jumps past it, and no spacing is
|
||||
// probed twice.
|
||||
var s = System.Math.Max(opt.InitialStep, 2 * System.Math.Max(0, sGood));
|
||||
hi = double.NaN;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (s > cap)
|
||||
{
|
||||
if (cap > sGood + opt.Tolerance)
|
||||
s = cap;
|
||||
else
|
||||
break;
|
||||
}
|
||||
|
||||
if (s <= sGood + opt.Tolerance)
|
||||
break;
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
result.Cancelled = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
var probe = Probe(s);
|
||||
|
||||
if (probe.Converged)
|
||||
{
|
||||
sGood = s;
|
||||
if (sGood >= cap)
|
||||
break;
|
||||
s = System.Math.Max(s * 2, sGood + opt.Tolerance);
|
||||
}
|
||||
else
|
||||
{
|
||||
hi = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Bisection between the last spacing that converged and the first that failed.
|
||||
if (!double.IsNaN(hi))
|
||||
{
|
||||
var lo = System.Math.Max(0, sGood);
|
||||
while (hi - lo > opt.Tolerance)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
result.Cancelled = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
var mid = (lo + hi) / 2;
|
||||
if (Probe(mid).Converged)
|
||||
lo = mid;
|
||||
else
|
||||
hi = mid;
|
||||
}
|
||||
sGood = lo;
|
||||
}
|
||||
|
||||
// Finalize: re-separate at the applied spacing from the entry state.
|
||||
// Deterministic, so this reproduces any committed probe exactly; when
|
||||
// sGood was never probed (overlapping entry) it still performs the
|
||||
// best-effort cleanup and yields the violation report.
|
||||
var final = Separate(context, entry, System.Math.Max(0, sGood), opt.MaxIterations, token);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
result.Cancelled = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
var positions = final.Positions;
|
||||
|
||||
Apply(selected, entry, positions);
|
||||
|
||||
var measured = MinimumPairClearance(context, positions);
|
||||
result.AchievedSpacing = System.Math.Max(0, System.Math.Min(System.Math.Max(0, sGood), measured));
|
||||
result.Violations = final.Violations;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Relaxes the given parts apart to a fixed target spacing against the
|
||||
/// plate. Works on scratch positions; the caller applies them. Exposed
|
||||
/// for testing and for callers that manage their own spacing search.
|
||||
/// </summary>
|
||||
public static (bool Converged, List<Vector> Positions, List<Violation> Violations) Separate(
|
||||
List<Part> selected,
|
||||
Plate plate,
|
||||
double spacing,
|
||||
int maxIterations = 100,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
var context = SeparationContext.Prepare(selected, plate);
|
||||
var positions = context.Positions();
|
||||
return Separate(context, positions, spacing, maxIterations, token);
|
||||
}
|
||||
|
||||
private static void Apply(List<Part> selected, List<Vector> from, List<Vector> to)
|
||||
{
|
||||
// Only movers occupy [0, selected.Count); walls never move.
|
||||
for (var i = 0; i < selected.Count; i++)
|
||||
{
|
||||
var delta = to[i] - from[i];
|
||||
if (delta.X != 0 || delta.Y != 0)
|
||||
selected[i].Offset(delta);
|
||||
}
|
||||
}
|
||||
|
||||
private static double MinimumPairClearance(SeparationContext context, List<Vector> positions)
|
||||
{
|
||||
var min = double.MaxValue;
|
||||
|
||||
foreach (var pair in context.Pairs)
|
||||
{
|
||||
var (distance, _) = context.PairClearance(pair.IndexA, pair.IndexB, positions);
|
||||
if (distance < min)
|
||||
min = distance;
|
||||
}
|
||||
|
||||
return min == double.MaxValue ? 0 : min;
|
||||
}
|
||||
|
||||
private static (bool Converged, List<Vector> Positions, List<Violation> Violations) Separate(
|
||||
SeparationContext context,
|
||||
List<Vector> start,
|
||||
double spacing,
|
||||
int maxIterations,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
var positions = new List<Vector>(start);
|
||||
var stuck = new HashSet<int>();
|
||||
var violations = new List<Violation>();
|
||||
var epsMove = 1e-4;
|
||||
|
||||
// Internal margin absorbs the clearance kernel's tessellation error so
|
||||
// the applied spacing holds against the production validators.
|
||||
var target = spacing + 0.002;
|
||||
|
||||
var iterationLimit = maxIterations < 1 ? 1 : maxIterations;
|
||||
|
||||
for (var iteration = 0; iteration < iterationLimit; iteration++)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
return (false, start, violations);
|
||||
|
||||
var moved = 0.0;
|
||||
var stuckChanged = false;
|
||||
|
||||
foreach (var pair in context.Pairs)
|
||||
{
|
||||
if (stuck.Contains(pair.Id))
|
||||
continue;
|
||||
|
||||
var (distance, directionA) = context.PairClearance(
|
||||
pair.IndexA,
|
||||
pair.IndexB,
|
||||
positions
|
||||
);
|
||||
|
||||
// Trigger at the user spacing, not the internal margin: a
|
||||
// pair already at the requested spacing must not be nudged,
|
||||
// or feasible layouts at the ceiling (every pair exactly at
|
||||
// spacing) would oscillate forever. The margin only sets how
|
||||
// far past the trigger a push carries, absorbing tessellation
|
||||
// error in the measurement.
|
||||
if (distance >= spacing)
|
||||
continue;
|
||||
|
||||
var need = target - distance;
|
||||
|
||||
// Anchor policy (decided): only the later-indexed selected
|
||||
// part of a violated pair moves. Counterparts and walls
|
||||
// never do — a pair whose anchor mover cannot reach the
|
||||
// target is a violation, not an invitation to drift the
|
||||
// anchor.
|
||||
var moverIndex = pair.Mover;
|
||||
|
||||
// Clearance direction translates A away from B; a mover on
|
||||
// the B side travels the opposite way.
|
||||
var direction = moverIndex == pair.IndexA ? directionA : -directionA;
|
||||
|
||||
var room = context.ClipToWorkArea(
|
||||
moverIndex,
|
||||
positions[moverIndex],
|
||||
direction,
|
||||
need
|
||||
);
|
||||
|
||||
// Take the largest valid step up to `room`: partial moves let
|
||||
// a blocked mover advance again once its own blockers move
|
||||
// away in later iterations (a wave separates a chain).
|
||||
var applied = 0.0;
|
||||
var blockedByEdge = false;
|
||||
|
||||
if (room > 0)
|
||||
{
|
||||
var trial = positions[moverIndex] + direction * room;
|
||||
|
||||
if (context.MaintainsValidity(moverIndex, trial, positions))
|
||||
{
|
||||
positions[moverIndex] = trial;
|
||||
applied = room;
|
||||
}
|
||||
else
|
||||
{
|
||||
var lo = 0.0;
|
||||
var hi2 = room;
|
||||
for (var bisect = 0; bisect < 24 && hi2 - lo > 1e-6; bisect++)
|
||||
{
|
||||
var mid = (lo + hi2) / 2;
|
||||
if (
|
||||
context.MaintainsValidity(
|
||||
moverIndex,
|
||||
positions[moverIndex] + direction * mid,
|
||||
positions
|
||||
)
|
||||
)
|
||||
lo = mid;
|
||||
else
|
||||
hi2 = mid;
|
||||
}
|
||||
|
||||
if (lo > epsMove)
|
||||
{
|
||||
positions[moverIndex] = positions[moverIndex] + direction * lo;
|
||||
applied = lo;
|
||||
}
|
||||
}
|
||||
|
||||
blockedByEdge = applied < need - epsMove;
|
||||
moved += applied;
|
||||
}
|
||||
else
|
||||
{
|
||||
blockedByEdge = true;
|
||||
}
|
||||
|
||||
// A pair fully separated to the user target (the internal margin
|
||||
// absorbs tessellation slack) is satisfied even if not to target.
|
||||
if (applied > 0)
|
||||
{
|
||||
var (finalDistance, _) = context.PairClearance(
|
||||
pair.IndexA,
|
||||
pair.IndexB,
|
||||
positions
|
||||
);
|
||||
if (finalDistance >= spacing)
|
||||
continue;
|
||||
// Partial progress: keep the pair live — its blockers may
|
||||
// move away in later iterations and unblock the rest.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Zero progress twice in a row parks the pair; the final sweep
|
||||
// re-measures everything, so mid-loop bookkeeping never lies.
|
||||
if (stuck.Contains(pair.Id))
|
||||
continue;
|
||||
|
||||
stuck.Add(pair.Id);
|
||||
stuckChanged = true;
|
||||
}
|
||||
|
||||
if (moved < epsMove && !stuckChanged)
|
||||
break;
|
||||
}
|
||||
|
||||
// Honest verdict: stuck bookkeeping and the internal margin can both
|
||||
// let a pair read as satisfied mid-loop while a later pair move
|
||||
// un-does it (oscillation). Re-measure every constrained pair at the
|
||||
// final positions once; the violations this sweep finds are the
|
||||
// report, and any violation makes the run non-converged.
|
||||
violations.Clear();
|
||||
foreach (var pair in context.Pairs)
|
||||
{
|
||||
var (distance, _) = context.PairClearance(
|
||||
pair.IndexA,
|
||||
pair.IndexB,
|
||||
positions
|
||||
);
|
||||
|
||||
if (distance >= spacing)
|
||||
continue;
|
||||
|
||||
violations.Add(
|
||||
new Violation
|
||||
{
|
||||
A = context.PartOf(pair.IndexA),
|
||||
B = context.PartOf(pair.IndexB),
|
||||
Achieved = distance,
|
||||
BlockedByEdge = stuck.Contains(pair.Id),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (violations.Count == 0, positions, violations);
|
||||
}
|
||||
|
||||
/// <summary>One constrained part↔part pair with its anchor mover.</summary>
|
||||
private sealed class Pair
|
||||
{
|
||||
public int Id;
|
||||
public int IndexA;
|
||||
public int IndexB;
|
||||
public int Mover;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-run prepared geometry. Rings are local-frame polygons (world = local
|
||||
/// + scratch position), prepared once per distinct Program by reference,
|
||||
/// mirroring <see cref="PartOverlapChecker"/>'s caching but translatable.
|
||||
/// </summary>
|
||||
private sealed class SeparationContext
|
||||
{
|
||||
private readonly List<Part> parts; // movers [0, moverCount) then walls
|
||||
private readonly List<RingSet> shapes; // per part
|
||||
private readonly Box[] localBoxes; // per part, local frame
|
||||
private readonly int moverCount;
|
||||
private readonly Box workArea;
|
||||
|
||||
public readonly List<Pair> Pairs = new();
|
||||
public readonly double SpacingCap;
|
||||
|
||||
private sealed class RingSet
|
||||
{
|
||||
public Polygon Outer;
|
||||
public List<Polygon> Rings = new(); // outer + cutout rings, local frame
|
||||
public List<Polygon> Holes = new();
|
||||
}
|
||||
|
||||
private SeparationContext(
|
||||
List<Part> parts,
|
||||
List<RingSet> shapes,
|
||||
Box[] localBoxes,
|
||||
int moverCount,
|
||||
Box workArea,
|
||||
double spacingCap
|
||||
)
|
||||
{
|
||||
this.parts = parts;
|
||||
this.shapes = shapes;
|
||||
this.localBoxes = localBoxes;
|
||||
this.moverCount = moverCount;
|
||||
this.workArea = workArea;
|
||||
SpacingCap = spacingCap;
|
||||
}
|
||||
|
||||
public static SeparationContext Prepare(List<Part> selected, Plate plate)
|
||||
{
|
||||
var movers = new List<Part>(selected);
|
||||
var walls = plate.Parts.Where(p => !movers.Contains(p)).ToList();
|
||||
|
||||
var parts = new List<Part>(movers.Count + walls.Count);
|
||||
parts.AddRange(movers);
|
||||
parts.AddRange(walls);
|
||||
|
||||
var programs = new Dictionary<CNC.Program, RingSet>(
|
||||
ReferenceEqualityComparer.Instance
|
||||
);
|
||||
var shapes = new List<RingSet>(parts.Count);
|
||||
var localBoxes = new Box[parts.Count];
|
||||
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
shapes.Add(PrepareProgram(programs, parts[i].Program));
|
||||
localBoxes[i] = LocalBox(parts[i]);
|
||||
}
|
||||
|
||||
var workArea = plate.WorkArea();
|
||||
var spacingCap = System.Math.Sqrt(
|
||||
workArea.Length * workArea.Length + workArea.Width * workArea.Width
|
||||
);
|
||||
|
||||
var context = new SeparationContext(
|
||||
parts,
|
||||
shapes,
|
||||
localBoxes,
|
||||
movers.Count,
|
||||
workArea,
|
||||
spacingCap
|
||||
);
|
||||
context.BuildPairs();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static RingSet PrepareProgram(
|
||||
Dictionary<CNC.Program, RingSet> programs,
|
||||
CNC.Program program
|
||||
)
|
||||
{
|
||||
if (programs.TryGetValue(program, out var existing))
|
||||
return existing;
|
||||
|
||||
var prepared = new RingSet();
|
||||
var entities = ConvertProgram
|
||||
.ToGeometry(program)
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
||||
.ToList();
|
||||
|
||||
if (entities.Count > 0)
|
||||
{
|
||||
var profile = new ShapeProfile(entities);
|
||||
|
||||
if (profile.Perimeter != null)
|
||||
{
|
||||
prepared.Outer = profile.Perimeter.ToPolygonWithTolerance(0.001);
|
||||
prepared.Rings.Add(prepared.Outer);
|
||||
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
var hole = cutout.ToPolygonWithTolerance(0.001);
|
||||
prepared.Rings.Add(hole);
|
||||
prepared.Holes.Add(hole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
programs.Add(program, prepared);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
private static Box LocalBox(Part part)
|
||||
{
|
||||
var box = part.BoundingBox;
|
||||
return new Box(
|
||||
box.Left - part.Location.X,
|
||||
box.Bottom - part.Location.Y,
|
||||
box.Length,
|
||||
box.Width
|
||||
);
|
||||
}
|
||||
|
||||
private void BuildPairs()
|
||||
{
|
||||
var id = 0;
|
||||
|
||||
for (var a = 0; a < parts.Count; a++)
|
||||
{
|
||||
for (var b = a + 1; b < parts.Count; b++)
|
||||
{
|
||||
var aMover = a < moverCount;
|
||||
var bMover = b < moverCount;
|
||||
|
||||
if (!aMover && !bMover)
|
||||
continue;
|
||||
|
||||
Pairs.Add(
|
||||
new Pair
|
||||
{
|
||||
Id = id++,
|
||||
IndexA = a,
|
||||
IndexB = b,
|
||||
// Anchor policy: the later-index mover moves.
|
||||
Mover = bMover ? b : a,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsMover(int index) => index < moverCount;
|
||||
|
||||
public Part PartOf(int index) => parts[index];
|
||||
|
||||
/// <summary>Current world positions of every part in pair-index order.</summary>
|
||||
public List<Vector> Positions() => parts.Select(p => p.Location).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Signed material clearance between two parts at the given scratch
|
||||
/// positions. Material overlap (Collision oracle with hole subtraction)
|
||||
/// reports negative penetration through the outer rings; otherwise the
|
||||
/// clearance is the minimum boundary distance over all ring pairs, so a
|
||||
/// part inside another's cutout measures its true gap to the hole ring
|
||||
/// instead of a bogus outer-ring penetration.
|
||||
/// </summary>
|
||||
public (double Distance, Vector Direction) PairClearance(
|
||||
int indexA,
|
||||
int indexB,
|
||||
List<Vector> positions
|
||||
)
|
||||
{
|
||||
var setA = shapes[indexA];
|
||||
var setB = shapes[indexB];
|
||||
|
||||
if (setA.Outer == null || setB.Outer == null)
|
||||
return (0, new Vector(1, 0));
|
||||
|
||||
var offsetA = positions[indexA];
|
||||
var offsetB = positions[indexB];
|
||||
|
||||
var outerA = CloneAt(setA.Outer, offsetA);
|
||||
var outerB = CloneAt(setB.Outer, offsetB);
|
||||
|
||||
var holesA = setA.Holes.Count == 0 ? null : CloneAll(setA.Holes, offsetA);
|
||||
var holesB = setB.Holes.Count == 0 ? null : CloneAll(setB.Holes, offsetB);
|
||||
|
||||
if (Collision.HasOverlap(outerA, outerB, holesA, holesB))
|
||||
{
|
||||
var penetration = Clearance.Between(outerA, outerB);
|
||||
if (penetration.Distance < 0)
|
||||
return (penetration.Distance, penetration.Direction);
|
||||
// Hole subtraction resolved what the outers overlap: touching.
|
||||
return (0, penetration.Direction);
|
||||
}
|
||||
|
||||
double best = double.MaxValue;
|
||||
var bestDir = new Vector(1, 0);
|
||||
|
||||
foreach (var ringA in setA.Rings)
|
||||
{
|
||||
var worldA = CloneAt(ringA, offsetA);
|
||||
|
||||
foreach (var ringB in setB.Rings)
|
||||
{
|
||||
var worldB = CloneAt(ringB, offsetB);
|
||||
var clearance = Clearance.BoundaryDistance(worldA, worldB);
|
||||
|
||||
if (clearance.Distance < best)
|
||||
{
|
||||
best = clearance.Distance;
|
||||
bestDir = clearance.Direction;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (best, bestDir);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Largest α ≤ need such that translating the part by direction·α keeps
|
||||
/// its AABB inside the work area.
|
||||
/// </summary>
|
||||
public double ClipToWorkArea(int index, Vector position, Vector direction, double need)
|
||||
{
|
||||
var box = localBoxes[index];
|
||||
var left = position.X + box.Left - workArea.Left;
|
||||
var right = workArea.Right - (position.X + box.Right);
|
||||
var bottom = position.Y + box.Bottom - workArea.Bottom;
|
||||
var top = workArea.Top - (position.Y + box.Top);
|
||||
|
||||
var max = need;
|
||||
if (direction.X > 0)
|
||||
max = System.Math.Min(max, right / direction.X);
|
||||
else if (direction.X < 0)
|
||||
max = System.Math.Min(max, left / -direction.X);
|
||||
|
||||
if (direction.Y > 0)
|
||||
max = System.Math.Min(max, top / direction.Y);
|
||||
else if (direction.Y < 0)
|
||||
max = System.Math.Min(max, bottom / -direction.Y);
|
||||
|
||||
return max < 0 ? 0 : max;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the part at <paramref name="trial"/> stays inside the work
|
||||
/// area and keeps no material overlap with any other part (Collision
|
||||
/// oracle with hole subtraction, so part-in-cutout stays legal).
|
||||
/// </summary>
|
||||
public bool MaintainsValidity(int index, Vector trial, List<Vector> positions)
|
||||
{
|
||||
var box = localBoxes[index];
|
||||
var movedBox = box.Translate(trial);
|
||||
|
||||
if (
|
||||
movedBox.Left < workArea.Left - 1e-9
|
||||
|| movedBox.Right > workArea.Right + 1e-9
|
||||
|| movedBox.Bottom < workArea.Bottom - 1e-9
|
||||
|| movedBox.Top > workArea.Top + 1e-9
|
||||
)
|
||||
return false;
|
||||
|
||||
var outer = shapes[index].Outer;
|
||||
if (outer == null)
|
||||
return true;
|
||||
|
||||
var worldOuter = CloneAt(outer, trial);
|
||||
var worldHoles = shapes[index].Holes.Count == 0
|
||||
? null
|
||||
: CloneAll(shapes[index].Holes, trial);
|
||||
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
if (i == index)
|
||||
continue;
|
||||
|
||||
var otherOuter = shapes[i].Outer;
|
||||
if (otherOuter == null)
|
||||
continue;
|
||||
|
||||
if (!BoxOverlap(movedBox, localBoxes[i].Translate(positions[i]), 0.002))
|
||||
continue;
|
||||
|
||||
var worldOther = CloneAt(otherOuter, positions[i]);
|
||||
var worldOtherHoles = shapes[i].Holes.Count == 0
|
||||
? null
|
||||
: CloneAll(shapes[i].Holes, positions[i]);
|
||||
|
||||
if (
|
||||
Collision.HasOverlap(
|
||||
worldOuter,
|
||||
worldOther,
|
||||
worldHoles,
|
||||
worldOtherHoles
|
||||
)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<Polygon> CloneAll(List<Polygon> polygons, Vector offset)
|
||||
{
|
||||
var list = new List<Polygon>(polygons.Count);
|
||||
foreach (var polygon in polygons)
|
||||
list.Add(CloneAt(polygon, offset));
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Clone with world bounds applied — prepared rings are never mutated.</summary>
|
||||
private static Polygon CloneAt(Polygon polygon, Vector offset)
|
||||
{
|
||||
var clone = (Polygon)polygon.Clone();
|
||||
clone.UpdateBounds();
|
||||
clone.Offset(offset);
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static bool BoxOverlap(Box a, Box b, double slack)
|
||||
{
|
||||
return !(
|
||||
a.Right + slack < b.Left
|
||||
|| b.Right + slack < a.Left
|
||||
|| a.Top + slack < b.Bottom
|
||||
|| b.Top + slack < a.Bottom
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,8 +83,9 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
// Slide uses locations, not cached bounds; Offset already translates the box.
|
||||
// Slide part2 left toward part1.
|
||||
var movingLines = boundary2.GetLines(part2.Location, PushDirection.Left);
|
||||
var stationaryLines = boundary1.GetLines(part1.Location, PushDirection.Right);
|
||||
// Keep complete loops so the shared kernel can classify tangential contacts.
|
||||
var movingLines = boundary2.GetLines(part2.Location);
|
||||
var stationaryLines = boundary1.GetLines(part1.Location);
|
||||
var dist = SpatialQuery.DirectionalDistance(
|
||||
movingLines,
|
||||
stationaryLines,
|
||||
@@ -234,15 +235,9 @@ namespace OpenNest.Engine.Fill
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
var opposite = SpatialQuery.OppositeDirection(direction);
|
||||
var movingEdges = movingBoundary.GetEdges(direction);
|
||||
var stationaryEdges = stationaryBoundary.GetEdges(opposite);
|
||||
|
||||
return SpatialQuery.DirectionalDistance(
|
||||
movingEdges,
|
||||
movingLocation,
|
||||
stationaryEdges,
|
||||
stationaryLocation,
|
||||
movingBoundary.GetLines(movingLocation),
|
||||
stationaryBoundary.GetLines(stationaryLocation),
|
||||
direction
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Jobs.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Binds one result sheet's poses to the caller's own drawings. The pose semantics match
|
||||
/// <see cref="NestResultMaterializer"/>: rotate about the snapshot origin, then translate.
|
||||
/// Every pose must be finite and refer to a mapped drawing. Malformed sheets are rejected
|
||||
/// before binding any parts; the pipeline reports these violations without invoking binding.
|
||||
/// </summary>
|
||||
public static class NestResultBinder
|
||||
{
|
||||
public static IReadOnlyList<Part> Bind(
|
||||
NestJobPlateResult sheet,
|
||||
IReadOnlyDictionary<string, Drawing> drawingsByPartId
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sheet);
|
||||
ArgumentNullException.ThrowIfNull(drawingsByPartId);
|
||||
foreach (var pose in sheet.Placements)
|
||||
{
|
||||
if (pose.PartId == null || !drawingsByPartId.TryGetValue(pose.PartId, out var drawing) || drawing == null)
|
||||
throw new ArgumentException("Result contains a requirement not present in the drawing map.", nameof(sheet));
|
||||
if (!double.IsFinite(pose.X) || !double.IsFinite(pose.Y) || !double.IsFinite(pose.Rotation))
|
||||
throw new ArgumentException("Result contains a nonfinite placement pose.", nameof(sheet));
|
||||
}
|
||||
var parts = new List<Part>(sheet.Placements.Count);
|
||||
foreach (var pose in sheet.Placements)
|
||||
{
|
||||
var part = new Part(drawingsByPartId[pose.PartId]);
|
||||
part.Rotate(pose.Rotation);
|
||||
part.Location = new Vector(pose.X, pose.Y);
|
||||
part.UpdateBounds();
|
||||
parts.Add(part);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Converters;
|
||||
@@ -20,20 +21,77 @@ namespace OpenNest.Engine.Jobs;
|
||||
/// </summary>
|
||||
public static class NestLayoutCheck
|
||||
{
|
||||
/// <summary>Checks bounds, spacing, quantities, offered stock and rotation policies.
|
||||
/// <summary>Checks placement structure, bounds, spacing, quantities, offered stock,
|
||||
/// MaxPlates and rotation policies. Unrepresentable placements skip geometry checks.
|
||||
/// Requirement IDs are used in messages. Instance indices and fulfillment metadata are
|
||||
/// not checked, matching the benchmark contract.</summary>
|
||||
public static IReadOnlyList<string> Violations(NestJob job, NestJobResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
return Violations(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id));
|
||||
}
|
||||
|
||||
/// <summary>Same checks as <see cref="Violations(NestJob, NestJobResult)"/>, with messages
|
||||
/// naming each requirement by <paramref name="displayNames"/> (requirement ID -> name).</summary>
|
||||
public static IReadOnlyList<string> Violations(
|
||||
NestJob job,
|
||||
NestJobResult result,
|
||||
IReadOnlyDictionary<string, string> displayNames
|
||||
) => Violations(job, result, displayNames, out _);
|
||||
|
||||
internal static IReadOnlyList<string> Violations(
|
||||
NestJob job,
|
||||
NestJobResult result,
|
||||
IReadOnlyDictionary<string, string> displayNames,
|
||||
out bool canKeep
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(displayNames);
|
||||
var violations = new List<string>();
|
||||
ValidateStructure(job, result, displayNames, violations);
|
||||
canKeep = violations.Count == 0;
|
||||
if (canKeep)
|
||||
{
|
||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||
var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id],
|
||||
p => (p.Id, p.Quantity));
|
||||
p => (Name: displayNames.GetValueOrDefault(p.Id, p.Id), p.Quantity));
|
||||
var runs = materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList();
|
||||
var violations = Validate(runs, requirements);
|
||||
ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), violations);
|
||||
violations.AddRange(Validate(runs, requirements));
|
||||
}
|
||||
ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id,
|
||||
p => displayNames.GetValueOrDefault(p.Id, p.Id)), violations);
|
||||
return violations;
|
||||
}
|
||||
|
||||
private static void ValidateStructure(
|
||||
NestJob job,
|
||||
NestJobResult result,
|
||||
IReadOnlyDictionary<string, string> displayNames,
|
||||
List<string> violations
|
||||
)
|
||||
{
|
||||
var partIds = job.Parts.Select(p => p.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var sheet in result.Plates)
|
||||
{
|
||||
for (var i = 0; i < sheet.Placements.Count; i++)
|
||||
{
|
||||
var pose = sheet.Placements[i];
|
||||
var name = pose.PartId == null ? "<null>" : displayNames.GetValueOrDefault(pose.PartId, pose.PartId);
|
||||
var description = $"Plate {sheet.PlateIndex} placement {i} for '{name}'";
|
||||
if (pose.PartId == null || !partIds.Contains(pose.PartId))
|
||||
violations.Add($"{description}, which is not part of this job");
|
||||
if (!double.IsFinite(pose.X))
|
||||
violations.Add($"{description} has nonfinite X ({pose.X})");
|
||||
if (!double.IsFinite(pose.Y))
|
||||
violations.Add($"{description} has nonfinite Y ({pose.Y})");
|
||||
if (!double.IsFinite(pose.Rotation))
|
||||
violations.Add($"{description} has nonfinite Rotation ({pose.Rotation})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tests material clearance using the benchmark's conservative outlines.
|
||||
/// The leftmost raw outline is inflated, matching the full-layout sweep; ties retain
|
||||
/// argument order. Geometry is cloned before transformation.</summary>
|
||||
@@ -104,7 +162,7 @@ public static class NestLayoutCheck
|
||||
/// Checks what the materialized layout cannot show: every sheet must be
|
||||
/// one of the job's own stock entries (an engine may not invent a sheet
|
||||
/// size or loosen its spacing/edge settings, which the layout checks
|
||||
/// would otherwise trust), finite stock may not be overdrawn, and every
|
||||
/// would otherwise trust), finite stock and MaxPlates may not be overdrawn, and every
|
||||
/// placement's rotation must satisfy its part's RotationPolicy.
|
||||
/// </summary>
|
||||
internal static void ValidateAgainstJob(
|
||||
@@ -118,6 +176,9 @@ public static class NestLayoutCheck
|
||||
var partsById = job.Parts.ToDictionary(p => p.Id);
|
||||
var sheetsUsed = new Dictionary<string, int>();
|
||||
|
||||
if (job.Options.MaxPlates is int maxPlates && jobResult.Plates.Count > maxPlates)
|
||||
result.Add($"Used {jobResult.Plates.Count} sheet(s) but the job MaxPlates limit is {maxPlates}");
|
||||
|
||||
foreach (var sheet in jobResult.Plates)
|
||||
{
|
||||
if (
|
||||
@@ -150,8 +211,10 @@ public static class NestLayoutCheck
|
||||
{
|
||||
foreach (var placement in sheet.Placements)
|
||||
{
|
||||
if (!partsById.TryGetValue(placement.PartId, out var part))
|
||||
continue; // reported by ValidateQuantities
|
||||
if (placement.PartId == null
|
||||
|| !partsById.TryGetValue(placement.PartId, out var part)
|
||||
|| !double.IsFinite(placement.Rotation))
|
||||
continue; // reported by ValidateStructure
|
||||
|
||||
if (!part.Rotation.Allows(placement.Rotation))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// One automatic-nesting request: the named engine, the caller's items (drawing, quantity,
|
||||
/// priority, rotation), the stock it may use, and optional job options. Items with a
|
||||
/// nonpositive quantity are skipped.
|
||||
/// </summary>
|
||||
public sealed record NestPipelineRequest(
|
||||
string EngineName,
|
||||
IReadOnlyList<NestItem> Items,
|
||||
IReadOnlyList<NestPlateStock> Stock,
|
||||
NestJobOptions Options = null
|
||||
);
|
||||
|
||||
/// <summary>A proposed sheet: new parts bound by reference to the caller's drawings.</summary>
|
||||
public sealed record ProposedPlate(int PlateIndex, NestPlateStock Stock, IReadOnlyList<Part> Parts);
|
||||
|
||||
/// <summary>
|
||||
/// The engine's raw result, its independent validation, and the proposed plates. Nothing
|
||||
/// has been committed; the caller decides what to do with an invalid result.
|
||||
/// </summary>
|
||||
public sealed class NestPipelineResult
|
||||
{
|
||||
internal NestPipelineResult(
|
||||
string engineName,
|
||||
NestJob job,
|
||||
NestJobResult raw,
|
||||
IReadOnlyList<ProposedPlate> plates,
|
||||
IReadOnlyList<string> violations,
|
||||
bool canKeep,
|
||||
TimeSpan solveTime,
|
||||
TimeSpan validationTime
|
||||
)
|
||||
{
|
||||
EngineName = engineName;
|
||||
Job = job;
|
||||
Raw = raw;
|
||||
Plates = plates;
|
||||
Violations = violations;
|
||||
CanKeep = canKeep;
|
||||
SolveTime = solveTime;
|
||||
ValidationTime = validationTime;
|
||||
}
|
||||
|
||||
public string EngineName { get; }
|
||||
public NestJob Job { get; }
|
||||
public NestJobResult Raw { get; }
|
||||
public IReadOnlyList<ProposedPlate> Plates { get; }
|
||||
public IReadOnlyList<string> Violations { get; }
|
||||
public bool IsValid => Violations.Count == 0;
|
||||
/// <summary>True when every placement can be represented, even if layout rules fail.
|
||||
/// False for unknown/null requirement IDs or nonfinite poses; Plates is then empty.</summary>
|
||||
public bool CanKeep { get; }
|
||||
public NestJobStatus Status => Raw.Status;
|
||||
public NestJobStopReason StopReason => Raw.StopReason;
|
||||
public TimeSpan SolveTime { get; }
|
||||
public TimeSpan ValidationTime { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single automatic-nesting path shared by every front end: build the job, solve it
|
||||
/// with the named engine, validate the result with the benchmark's rules, then bind
|
||||
/// placements to the caller's drawings. The pipeline never mutates caller items, drawings,
|
||||
/// or plates, and it does not care which engine is selected.
|
||||
/// </summary>
|
||||
public static class NestPipeline
|
||||
{
|
||||
/// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through
|
||||
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
|
||||
/// Engine cancellation propagates unchanged.</summary>
|
||||
public static NestPipelineResult Run(
|
||||
NestPipelineRequest request,
|
||||
IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var engine = NestingEngineRegistry.Create(request.EngineName);
|
||||
return Run(engine, request.EngineName, request, progress, token);
|
||||
}
|
||||
|
||||
/// <summary>Runs a caller-supplied engine instance through the same validation and binding.</summary>
|
||||
public static NestPipelineResult Run(
|
||||
INestingEngine engine,
|
||||
string engineName,
|
||||
NestPipelineRequest request,
|
||||
IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(engine);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.Items);
|
||||
ArgumentNullException.ThrowIfNull(request.Stock);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
var parts = new List<NestJobPart>();
|
||||
for (var i = 0; i < request.Items.Count; i++)
|
||||
{
|
||||
var item = request.Items[i];
|
||||
if (item == null || item.Quantity <= 0)
|
||||
continue;
|
||||
var partId = $"part-{i}";
|
||||
parts.Add(DrawingJobMapper.FromItem(partId, item));
|
||||
drawingsByPartId[partId] = item.Drawing;
|
||||
}
|
||||
|
||||
var job = new NestJob(parts, request.Stock, request.Options);
|
||||
NestJobValidator.Validate(job);
|
||||
|
||||
var clock = Stopwatch.StartNew();
|
||||
var raw =
|
||||
engine.Solve(job, progress, token)
|
||||
?? throw new InvalidOperationException($"Engine '{engineName}' returned no result.");
|
||||
var solveTime = clock.Elapsed;
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
clock.Restart();
|
||||
var names = drawingsByPartId.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => kv.Value.Name ?? kv.Key,
|
||||
StringComparer.Ordinal
|
||||
);
|
||||
var violations = NestLayoutCheck.Violations(job, raw, names, out var canKeep);
|
||||
var validationTime = clock.Elapsed;
|
||||
|
||||
var plates = canKeep
|
||||
? raw.Plates.Select(sheet => new ProposedPlate(
|
||||
sheet.PlateIndex,
|
||||
sheet.Stock,
|
||||
NestResultBinder.Bind(sheet, drawingsByPartId)
|
||||
))
|
||||
.ToList()
|
||||
: new List<ProposedPlate>();
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
return new NestPipelineResult(
|
||||
engineName,
|
||||
job,
|
||||
raw,
|
||||
plates,
|
||||
violations,
|
||||
canKeep,
|
||||
solveTime,
|
||||
validationTime
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
/// <summary>Applies an explicitly accepted whole-job proposal to empty physical sheets only.
|
||||
/// Caller must keep the nest and its drawings stable from request construction through commit.</summary>
|
||||
public static class NestPipelineCommit
|
||||
{
|
||||
public static IReadOnlyList<Plate> ApplyToEmptyPlates(
|
||||
NestPipelineResult result,
|
||||
PlateManager manager,
|
||||
bool allowInvalid = false,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(manager);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!result.CanKeep || (!result.IsValid && !allowInvalid))
|
||||
throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations.");
|
||||
|
||||
var applied = new List<Plate>();
|
||||
// Commit is synchronous on the caller's owning thread. Cancellation is checked
|
||||
// before mutation, not partway through attachment (which would leave half a job).
|
||||
manager.BeginBatch();
|
||||
try
|
||||
{
|
||||
foreach (var proposed in result.Plates)
|
||||
{
|
||||
if (proposed.Parts.Count == 0)
|
||||
continue;
|
||||
var plate = manager.GetOrCreateEmpty();
|
||||
plate.Size = proposed.Stock.Size;
|
||||
plate.PartSpacing = proposed.Stock.PartSpacing;
|
||||
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
|
||||
plate.Quadrant = proposed.Stock.Quadrant;
|
||||
plate.Quantity = 1;
|
||||
plate.Parts.AddRange(proposed.Parts);
|
||||
applied.Add(plate);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
manager.EndBatch();
|
||||
}
|
||||
return applied.AsReadOnly();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
/// <summary>Shared stock snapshots for whole-job front ends. Plate repeat count is not inventory.</summary>
|
||||
public static class NestStockBuilder
|
||||
{
|
||||
public static IReadOnlyList<NestPlateStock> FromTemplate(
|
||||
Plate template,
|
||||
IReadOnlyList<PlateOption> options,
|
||||
int? quantityPerOption = null
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
if (options == null || options.Count == 0)
|
||||
return new[] { DrawingJobMapper.FromPlate("plate", template, quantityPerOption) };
|
||||
|
||||
var stock = new List<NestPlateStock>(options.Count);
|
||||
for (var i = 0; i < options.Count; i++)
|
||||
{
|
||||
var option = options[i];
|
||||
ArgumentNullException.ThrowIfNull(option);
|
||||
stock.Add(new NestPlateStock(
|
||||
$"option-{i}",
|
||||
new Size(option.Width, option.Length),
|
||||
quantityPerOption,
|
||||
template.PartSpacing,
|
||||
template.EdgeSpacing,
|
||||
template.Quadrant
|
||||
));
|
||||
}
|
||||
return stock.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>Single-sheet callers may not silently create additional sheets.</summary>
|
||||
public static IReadOnlyList<NestPlateStock> SinglePlate(Plate plate) =>
|
||||
FromTemplate(plate, null, quantityPerOption: 1);
|
||||
}
|
||||
@@ -1,24 +1,49 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
using OpenNest.Engine.NestingEngines.Rectangles;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Registry of whole-job <see cref="INestingEngine"/> implementations. The four production
|
||||
/// strategies are exposed through <see cref="FixedStrategyNestingEngine"/> so they compete on
|
||||
/// equal footing with model-submitted engines. Callers choose an engine explicitly from
|
||||
/// <see cref="AvailableEngines"/>; there is no process-global active selection.
|
||||
/// strategies are exposed through <see cref="FixedStrategyNestingEngine"/>. Callers choose an
|
||||
/// engine explicitly from <see cref="AvailableEngines"/>; there is no process-global active
|
||||
/// selection. Plug-ins loaded from an Engines/ folder register under their CLR type name.
|
||||
/// </summary>
|
||||
public static class NestingEngineRegistry
|
||||
{
|
||||
private static readonly List<NestingEngineInfo> engines = new();
|
||||
|
||||
/// <summary>
|
||||
/// Registry names used by earlier releases, mapped to the engine that replaced them, so saved
|
||||
/// selections and scripts keep working. Consulted only when no engine has the requested name.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> RenamedEngines = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Opus55NestingEngine"] = "Irregular",
|
||||
["RectanglesNestingEngine"] = "Rectangles",
|
||||
};
|
||||
|
||||
static NestingEngineRegistry()
|
||||
{
|
||||
Register(
|
||||
"Rectangles",
|
||||
"Plain and near-rectangular parts: maximal-rectangles box packing",
|
||||
() => new RectanglesNestingEngine()
|
||||
);
|
||||
|
||||
Register(
|
||||
"Irregular",
|
||||
"Irregular parts: no-fit-polygon frontier packing with look-ahead stock selection",
|
||||
() => new IrregularNestingEngine()
|
||||
);
|
||||
|
||||
Register(
|
||||
"StockLadder",
|
||||
"Caller-stock constrained-first fill and equivalent-demand area repacking",
|
||||
@@ -53,13 +78,33 @@ public static class NestingEngineRegistry
|
||||
public static IReadOnlyList<NestingEngineInfo> AvailableEngines => engines;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the engine registered under <paramref name="name"/> (case-insensitive). The caller's
|
||||
/// explicit choice is the whole selection mechanism; unknown names throw.
|
||||
/// Registered name for <paramref name="name"/>: an exact (case-insensitive) match, else the
|
||||
/// engine a renamed legacy name now maps to, else null. Hosts use this to restore a saved
|
||||
/// selection made under an old name.
|
||||
/// </summary>
|
||||
public static string? ResolveName(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
var trimmed = name.Trim();
|
||||
var info = engines.FirstOrDefault(e => e.Name.Equals(trimmed, StringComparison.OrdinalIgnoreCase));
|
||||
if (info != null)
|
||||
return info.Name;
|
||||
return RenamedEngines.TryGetValue(trimmed, out var renamed)
|
||||
&& engines.FirstOrDefault(e => e.Name.Equals(renamed, StringComparison.OrdinalIgnoreCase)) is { } target
|
||||
? target.Name
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the engine registered under <paramref name="name"/> (case-insensitive, renamed legacy
|
||||
/// names accepted). The caller's explicit choice is the whole selection mechanism; unknown names throw.
|
||||
/// </summary>
|
||||
public static INestingEngine Create(string name)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
var info = engines.FirstOrDefault(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
var resolved = ResolveName(name);
|
||||
var info = resolved == null ? null : engines.First(e => e.Name == resolved);
|
||||
if (info == null)
|
||||
throw new NotSupportedException(
|
||||
$"Unknown nesting engine: {name}. Available: {string.Join(", ", engines.Select(e => e.Name))}."
|
||||
@@ -75,6 +120,13 @@ public static class NestingEngineRegistry
|
||||
return;
|
||||
}
|
||||
|
||||
// A leftover plug-in under a renamed engine's old name would shadow its built-in replacement.
|
||||
if (RenamedEngines.ContainsKey(name))
|
||||
{
|
||||
Debug.WriteLine($"[NestingEngineRegistry] '{name}' skipped: replaced by built-in '{RenamedEngines[name]}'");
|
||||
return;
|
||||
}
|
||||
|
||||
engines.Add(new NestingEngineInfo(name, description, factory));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
|
||||
/// <summary>Direction the packing front sweeps across the sheet (the free strip is left behind it).</summary>
|
||||
internal enum PackAxis
|
||||
{
|
||||
/// <summary>Front moves in +X; parts settle toward low X, then low Y.</summary>
|
||||
X,
|
||||
|
||||
/// <summary>Front moves in +Y; parts settle toward low Y, then low X.</summary>
|
||||
Y,
|
||||
}
|
||||
|
||||
internal sealed record Placed(Orientation Orientation, double X, double Y)
|
||||
{
|
||||
public double Left => X + Orientation.MinX;
|
||||
public double Right => X + Orientation.MaxX;
|
||||
public double Bottom => Y + Orientation.MinY;
|
||||
public double Top => Y + Orientation.MaxY;
|
||||
}
|
||||
|
||||
internal sealed record SheetFill(NestPlateStock Stock, IReadOnlyList<Placed> Parts, double PartArea);
|
||||
|
||||
/// <summary>
|
||||
/// Fills one sheet with a frontier-advance rule over incrementally maintained free regions.
|
||||
///
|
||||
/// For every (part type, orientation) still in play the packer keeps the exact set of legal
|
||||
/// reference points: the inner-fit rectangle of the work area minus the no-fit polygons of
|
||||
/// everything already placed. Each placement subtracts one translated NFP from each region,
|
||||
/// so regions only shrink, and a region that empties is retired for the rest of the sheet.
|
||||
///
|
||||
/// Choice rule, applied over all types and orientations at once (not in a fixed order):
|
||||
/// 1. Gap fill - if any part fits without pushing the packing front forward, place the
|
||||
/// largest such part at its lowest such point.
|
||||
/// 2. Otherwise advance - place the part whose front advance per unit area^beta is smallest,
|
||||
/// i.e. the one that buys the most material coverage for the sheet length it consumes.
|
||||
/// Parts are never placed in a sequence given up front; the sheet state decides what comes next.
|
||||
/// </summary>
|
||||
internal sealed class FrontierPacker
|
||||
{
|
||||
/// <summary>Slack added around the inner-fit rectangle so zero-width fits survive Clipper;
|
||||
/// chosen points are clamped back, which moves them far less than the clearance margin.</summary>
|
||||
private const double FitSlack = 2e-4;
|
||||
|
||||
private const double Tie = 1e-6;
|
||||
|
||||
private readonly IReadOnlyList<PartType> types;
|
||||
private readonly NoFitCache nfps;
|
||||
private readonly NestPlateStock stock;
|
||||
private readonly PackAxis axis;
|
||||
private readonly double beta;
|
||||
private readonly Box work;
|
||||
private readonly WorkCounter counter;
|
||||
|
||||
public FrontierPacker(IReadOnlyList<PartType> types, NoFitCache nfps, NestPlateStock stock, PackAxis axis, double beta, WorkCounter counter)
|
||||
{
|
||||
this.counter = counter;
|
||||
this.types = types;
|
||||
this.nfps = nfps;
|
||||
this.stock = stock;
|
||||
this.axis = axis;
|
||||
this.beta = beta;
|
||||
work = stock.WorkArea;
|
||||
}
|
||||
|
||||
public SheetFill Fill(IReadOnlyList<int> remaining, CancellationToken token)
|
||||
{
|
||||
var left = remaining.ToArray();
|
||||
var states = new List<Region>();
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (left[type.Index] <= 0)
|
||||
continue;
|
||||
foreach (var o in type.Orientations)
|
||||
if (stock.Fits(o.Width, o.Height))
|
||||
states.Add(new Region(o, work));
|
||||
}
|
||||
|
||||
var placed = new List<Placed>();
|
||||
var partArea = 0.0;
|
||||
var front = axis == PackAxis.X ? work.Left : work.Bottom;
|
||||
|
||||
while (states.Count > 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var choice = Choose(states, front);
|
||||
if (choice == null)
|
||||
break;
|
||||
|
||||
var (region, point) = choice.Value;
|
||||
var part = new Placed(region.Orientation, point.x, point.y);
|
||||
placed.Add(part);
|
||||
var typeIndex = region.Orientation.TypeIndex;
|
||||
partArea += types[typeIndex].Area;
|
||||
front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top);
|
||||
|
||||
if (--left[typeIndex] == 0)
|
||||
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex);
|
||||
|
||||
// Each surviving region loses the positions the new part now blocks. Regions are
|
||||
// independent, so they update in parallel without affecting determinism.
|
||||
var snapshot = states.ToArray();
|
||||
counter.Add(snapshot.Length);
|
||||
Parallel.For(
|
||||
0,
|
||||
snapshot.Length,
|
||||
new ParallelOptions { CancellationToken = token },
|
||||
i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y)
|
||||
);
|
||||
states.RemoveAll(s => s.IsEmpty);
|
||||
}
|
||||
|
||||
return new SheetFill(stock, placed, partArea);
|
||||
}
|
||||
|
||||
private (Region, PointD)? Choose(List<Region> states, double front)
|
||||
{
|
||||
Region? bestRegion = null;
|
||||
var bestPoint = default(PointD);
|
||||
var bestFills = false;
|
||||
var bestValue = double.PositiveInfinity;
|
||||
var bestSide = double.PositiveInfinity;
|
||||
var bestLead = double.PositiveInfinity;
|
||||
var bestPriority = int.MaxValue;
|
||||
|
||||
foreach (var region in states)
|
||||
{
|
||||
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
|
||||
continue;
|
||||
var area = types[region.Orientation.TypeIndex].Area;
|
||||
var priority = types[region.Orientation.TypeIndex].Part.Priority;
|
||||
if (priority > bestPriority) continue;
|
||||
var fills = advance <= Tie;
|
||||
// Gap fill prefers bigger parts (negated area); advance prefers least advance per area.
|
||||
var value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta);
|
||||
|
||||
var better = bestRegion == null
|
||||
|| priority < bestPriority
|
||||
|| (fills && !bestFills)
|
||||
|| (
|
||||
fills == bestFills
|
||||
&& (
|
||||
value < bestValue - Tie * System.Math.Max(1, System.Math.Abs(bestValue))
|
||||
|| (
|
||||
value <= bestValue + Tie * System.Math.Max(1, System.Math.Abs(bestValue))
|
||||
&& (side < bestSide - Tie || (side <= bestSide + Tie && lead < bestLead - Tie))
|
||||
)
|
||||
)
|
||||
);
|
||||
if (!better)
|
||||
continue;
|
||||
bestRegion = region;
|
||||
bestPriority = priority;
|
||||
bestPoint = point;
|
||||
bestFills = fills;
|
||||
bestValue = value;
|
||||
bestSide = side;
|
||||
bestLead = lead;
|
||||
}
|
||||
|
||||
return bestRegion == null ? null : (bestRegion, bestPoint);
|
||||
}
|
||||
|
||||
/// <summary>Legal reference points for one orientation on this sheet.</summary>
|
||||
private sealed class Region
|
||||
{
|
||||
private readonly double minX, minY, maxX, maxY;
|
||||
private PathsD free;
|
||||
private RectD bounds;
|
||||
|
||||
public Region(Orientation orientation, Box work)
|
||||
{
|
||||
Orientation = orientation;
|
||||
minX = work.Left - orientation.MinX;
|
||||
maxX = work.Right - orientation.MaxX;
|
||||
minY = work.Bottom - orientation.MinY;
|
||||
maxY = work.Top - orientation.MaxY;
|
||||
// Guard against fits that are infeasible by less than the bounds tolerance.
|
||||
if (maxX < minX)
|
||||
maxX = minX;
|
||||
if (maxY < minY)
|
||||
maxY = minY;
|
||||
free = new PathsD
|
||||
{
|
||||
new PathD
|
||||
{
|
||||
new(minX - FitSlack, minY - FitSlack),
|
||||
new(maxX + FitSlack, minY - FitSlack),
|
||||
new(maxX + FitSlack, maxY + FitSlack),
|
||||
new(minX - FitSlack, maxY + FitSlack),
|
||||
},
|
||||
};
|
||||
bounds = Clipper.GetBounds(free);
|
||||
}
|
||||
|
||||
public Orientation Orientation { get; }
|
||||
public bool IsEmpty => free.Count == 0;
|
||||
|
||||
public void Subtract(Nfp nfp, double dx, double dy)
|
||||
{
|
||||
if (
|
||||
nfp.Bounds.right + dx < bounds.left
|
||||
|| nfp.Bounds.left + dx > bounds.right
|
||||
|| nfp.Bounds.bottom + dy < bounds.top
|
||||
|| nfp.Bounds.top + dy > bounds.bottom
|
||||
)
|
||||
return;
|
||||
var clip = Clipper.TranslatePaths(nfp.Region, dx, dy);
|
||||
free = Clipper.Difference(free, clip, FillRule.NonZero, NoFitCache.Precision);
|
||||
// Drop numerical dust; a sliver thinner than the precision grid is no real room.
|
||||
free.RemoveAll(p => p.Count < 3);
|
||||
bounds = free.Count == 0 ? default : Clipper.GetBounds(free);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best vertex of the free region: least front advance, then lowest cross-axis position,
|
||||
/// then lowest leading edge. Vertices suffice because every score is linear in position.
|
||||
/// </summary>
|
||||
public bool TryLowest(PackAxis axis, double front, out PointD point, out double advance, out double side, out double lead)
|
||||
{
|
||||
point = default;
|
||||
advance = side = lead = double.PositiveInfinity;
|
||||
var found = false;
|
||||
var o = Orientation;
|
||||
foreach (var path in free)
|
||||
foreach (var raw in path)
|
||||
{
|
||||
var x = System.Math.Clamp(raw.x, minX, maxX);
|
||||
var y = System.Math.Clamp(raw.y, minY, maxY);
|
||||
double reach, across, start;
|
||||
if (axis == PackAxis.X)
|
||||
{
|
||||
reach = x + o.MaxX;
|
||||
across = y + o.MinY;
|
||||
start = x + o.MinX;
|
||||
}
|
||||
else
|
||||
{
|
||||
reach = y + o.MaxY;
|
||||
across = x + o.MinX;
|
||||
start = y + o.MinY;
|
||||
}
|
||||
var adv = System.Math.Max(0, reach - front);
|
||||
var better = !found
|
||||
|| adv < advance - Tie
|
||||
|| (adv <= advance + Tie && (across < side - Tie || (across <= side + Tie && start < lead - Tie)));
|
||||
if (!better)
|
||||
continue;
|
||||
found = true;
|
||||
point = new PointD(x, y);
|
||||
advance = adv;
|
||||
side = across;
|
||||
lead = start;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
|
||||
/// <summary>
|
||||
/// Frontier-advance NFP packer with look-ahead stock selection.
|
||||
///
|
||||
/// Per sheet, <see cref="FrontierPacker"/> keeps the exact free region of every
|
||||
/// (part type, orientation) as inner-fit rectangle minus no-fit polygons, and repeatedly places
|
||||
/// either the largest part that fills a gap behind the packing front, or the part that advances
|
||||
/// the front least per unit of area covered. Across sheets, every available stock size is
|
||||
/// trial-packed and the one with the lowest estimated whole-job cost (its own net area plus the
|
||||
/// remaining demand at the best efficiency seen) is committed. A handful of deterministic
|
||||
/// strategy variants (front direction, area exponent) run whole-job, and the cheapest wins.
|
||||
///
|
||||
/// Fully deterministic: no clocks or randomness influence any decision.
|
||||
/// </summary>
|
||||
public sealed class IrregularNestingEngine : INestingEngine
|
||||
{
|
||||
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).</summary>
|
||||
private static readonly (PackAxis Axis, double Beta)[] Variants =
|
||||
{
|
||||
(PackAxis.X, 1.0),
|
||||
(PackAxis.Y, 1.0),
|
||||
(PackAxis.X, 0.5),
|
||||
(PackAxis.Y, 0.5),
|
||||
(PackAxis.X, 1.5),
|
||||
(PackAxis.Y, 1.5),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic work budget, in free-region subtractions, after which no further variant
|
||||
/// starts. Keeps big jobs well inside benchmark timeouts without consulting a clock.
|
||||
/// </summary>
|
||||
internal long WorkBudget { get; init; } = 1_500_000;
|
||||
|
||||
public NestJobResult Solve(
|
||||
NestJob job,
|
||||
IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
var types = PartCatalog.Build(job);
|
||||
var solver = new Solver(job, types, progress, token);
|
||||
|
||||
// Demand that no offered stock can hold in any allowed orientation is reported unplaced.
|
||||
var demand = new int[types.Count];
|
||||
foreach (var type in types)
|
||||
{
|
||||
var placeable = job.Plates.Any(stock =>
|
||||
stock.Quantity != 0
|
||||
&& type.Orientations.Any(o => stock.Fits(o.Width, o.Height))
|
||||
);
|
||||
demand[type.Index] = placeable ? type.Part.Quantity : 0;
|
||||
}
|
||||
|
||||
Plan? best = null;
|
||||
foreach (var (axis, beta) in Variants)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (best != null && solver.Work.Value >= WorkBudget)
|
||||
break;
|
||||
var plan = solver.Plan(demand, axis, beta);
|
||||
if (best == null || plan.IsBetterThan(best))
|
||||
best = plan;
|
||||
if (best.Unplaced == 0 && best.Sheets.Count == 0)
|
||||
break;
|
||||
}
|
||||
|
||||
// The last sheets hold the leftovers, which is where waste concentrates; re-plan them.
|
||||
best = solver.ImproveTail(best!, WorkBudget * 2);
|
||||
return BuildResult(job, types, best, progress);
|
||||
}
|
||||
|
||||
/// <summary>Shared state for one solve: job, catalog, NFP caches, effort meter.</summary>
|
||||
private sealed class Solver(
|
||||
NestJob job,
|
||||
IReadOnlyList<PartType> types,
|
||||
IProgress<NestJobProgress>? progress,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
private const int MaxTail = 3;
|
||||
private readonly Dictionary<double, NoFitCache> caches = new();
|
||||
|
||||
public WorkCounter Work { get; } = new();
|
||||
|
||||
private double Penalty => NestJobCost.UnplacedPartPenalty(job);
|
||||
|
||||
public Plan Plan(int[] demand, PackAxis axis, double beta)
|
||||
{
|
||||
var run = Decode(demand, axis, beta, new Dictionary<string, int>(StringComparer.Ordinal), job.Options.MaxPlates, null);
|
||||
var unplaced = types.Sum(t => t.Part.Quantity) - run.Sheets.Sum(s => s.Parts.Count);
|
||||
var reason = run.Reason;
|
||||
if (unplaced > 0 && reason == NestJobStopReason.Completed)
|
||||
reason = NestJobStopReason.NoPlacementFound; // Demand no stock can hold.
|
||||
return new Plan(run.Sheets, run.Net + unplaced * Penalty, unplaced, reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the parts off the last k sheets (k = 1..3) and re-plans just that demand with
|
||||
/// every stock forced as the first sheet, under every variant; the cheapest complete
|
||||
/// re-plan that beats the current tail replaces it. Tails are small and effort is metered.
|
||||
/// </summary>
|
||||
public Plan ImproveTail(Plan plan, long budget)
|
||||
{
|
||||
var sheets = plan.Sheets.ToList();
|
||||
for (var k = 1; k <= System.Math.Min(MaxTail, sheets.Count); k++)
|
||||
{
|
||||
if (Work.Value >= budget)
|
||||
break;
|
||||
var prefix = sheets.Take(sheets.Count - k).ToList();
|
||||
var tail = sheets.Skip(sheets.Count - k).ToList();
|
||||
var tailParts = tail.Sum(s => s.Parts.Count);
|
||||
var tailNet = tail.Sum(s => NetArea(job.Options, s));
|
||||
var tailDemand = new int[types.Count];
|
||||
foreach (var part in tail.SelectMany(s => s.Parts))
|
||||
tailDemand[part.Orientation.TypeIndex]++;
|
||||
var used = prefix
|
||||
.GroupBy(s => s.Stock.Id)
|
||||
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
|
||||
int? cap = job.Options.MaxPlates is int max ? max - prefix.Count : null;
|
||||
|
||||
Run? bestRun = null;
|
||||
var bestNet = tailNet - 1e-9 * System.Math.Max(1, tailNet);
|
||||
foreach (var (axis, beta) in Variants)
|
||||
foreach (var first in job.Plates)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var run = Decode(tailDemand, axis, beta, used, cap, first);
|
||||
if (run.Sheets.Sum(s => s.Parts.Count) != tailParts || run.Net >= bestNet)
|
||||
continue;
|
||||
bestRun = run;
|
||||
bestNet = run.Net;
|
||||
}
|
||||
|
||||
if (bestRun == null)
|
||||
continue;
|
||||
sheets = prefix.Concat(bestRun.Sheets).ToList();
|
||||
plan = plan with { Sheets = sheets.ToList(), Cost = plan.Cost - (tailNet - bestRun.Net) };
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
private NoFitCache CacheFor(NestPlateStock stock)
|
||||
{
|
||||
var clearance = System.Math.Max(0, stock.PartSpacing);
|
||||
if (!caches.TryGetValue(clearance, out var cache))
|
||||
caches[clearance] = cache = new NoFitCache(clearance);
|
||||
return cache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Greedy sheet-by-sheet decode. <paramref name="usedBefore"/> seeds finite-stock
|
||||
/// accounting, <paramref name="sheetCap"/> bounds the sheets this run may add, and
|
||||
/// <paramref name="first"/>, when set, forces the stock of the first sheet.
|
||||
/// </summary>
|
||||
private Run Decode(
|
||||
int[] demand,
|
||||
PackAxis axis,
|
||||
double beta,
|
||||
IReadOnlyDictionary<string, int> usedBefore,
|
||||
int? sheetCap,
|
||||
NestPlateStock? first
|
||||
)
|
||||
{
|
||||
var remaining = (int[])demand.Clone();
|
||||
var used = job.Plates.ToDictionary(s => s.Id, s => usedBefore.GetValueOrDefault(s.Id), StringComparer.Ordinal);
|
||||
var sheets = new List<SheetFill>();
|
||||
var net = 0.0;
|
||||
NestJobStopReason reason;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (remaining.All(r => r == 0))
|
||||
{
|
||||
reason = NestJobStopReason.Completed;
|
||||
break;
|
||||
}
|
||||
if (sheetCap is int cap && sheets.Count >= cap)
|
||||
{
|
||||
reason = NestJobStopReason.PlateLimitReached;
|
||||
break;
|
||||
}
|
||||
|
||||
var trials = new List<(SheetFill Fill, double Net)>();
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (sheets.Count == 0 && first != null && !ReferenceEquals(stock, first))
|
||||
continue;
|
||||
if (stock.Quantity is int available && used[stock.Id] >= available)
|
||||
continue;
|
||||
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
|
||||
var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work);
|
||||
var fill = packer.Fill(remaining, token);
|
||||
if (fill.Parts.Count > 0)
|
||||
trials.Add((fill, NetArea(job.Options, fill)));
|
||||
}
|
||||
|
||||
if (trials.Count == 0)
|
||||
{
|
||||
var exhausted = job.Plates.Any(s => s.Quantity is int q && used[s.Id] >= q);
|
||||
reason = exhausted ? NestJobStopReason.StockExhausted : NestJobStopReason.NoPlacementFound;
|
||||
break;
|
||||
}
|
||||
|
||||
// Look-ahead: charge whatever a trial leaves behind at the best efficiency any trial
|
||||
// achieved, so a sheet that finishes the job competes fairly with a denser partial one.
|
||||
var remainingArea = types.Sum(t => remaining[t.Index] * t.Area);
|
||||
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Fill.PartArea, 1e-12));
|
||||
var chosen = trials
|
||||
.Select((t, order) => (t.Fill, t.Net, order, Estimate: t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio))
|
||||
.OrderBy(t => t.Estimate)
|
||||
.ThenByDescending(t => t.Fill.Parts.Count)
|
||||
.ThenBy(t => t.order)
|
||||
.First();
|
||||
|
||||
sheets.Add(chosen.Fill);
|
||||
net += chosen.Net;
|
||||
used[chosen.Fill.Stock.Id]++;
|
||||
foreach (var part in chosen.Fill.Parts)
|
||||
remaining[part.Orientation.TypeIndex]--;
|
||||
}
|
||||
|
||||
return new Run(sheets, net, reason);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Run(IReadOnlyList<SheetFill> Sheets, double Net, NestJobStopReason Reason);
|
||||
|
||||
private static NestJobResult BuildResult(NestJob job, IReadOnlyList<PartType> types,
|
||||
Plan plan, IProgress<NestJobProgress>? progress)
|
||||
{
|
||||
var builder = new NestJobResultBuilder(job, progress);
|
||||
foreach (var sheet in plan.Sheets)
|
||||
builder.AddSheet(sheet.Stock, sheet.Parts.Select(p =>
|
||||
(types[p.Orientation.TypeIndex].Part.Id, p.X, p.Y, p.Orientation.Rotation)));
|
||||
return builder.Build(plan.Reason);
|
||||
}
|
||||
|
||||
private static double NetArea(NestJobOptions options, SheetFill fill)
|
||||
{
|
||||
if (fill.Parts.Count == 0) return fill.Stock.Area;
|
||||
var left = fill.Parts.Min(p => p.Left);
|
||||
var bottom = fill.Parts.Min(p => p.Bottom);
|
||||
return NestJobCost.NetSheetArea(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom,
|
||||
fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom));
|
||||
}
|
||||
|
||||
private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason)
|
||||
{
|
||||
public bool IsBetterThan(Plan other)
|
||||
{
|
||||
if (Unplaced != other.Unplaced)
|
||||
return Unplaced < other.Unplaced;
|
||||
var scale = System.Math.Max(1, System.Math.Max(Cost, other.Cost));
|
||||
if (System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
|
||||
return Cost < other.Cost;
|
||||
return Sheets.Count < other.Sheets.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deterministic effort meter shared by all packers in one solve.</summary>
|
||||
internal sealed class WorkCounter
|
||||
{
|
||||
private long value;
|
||||
public long Value => Interlocked.Read(ref value);
|
||||
public void Add(long amount) => Interlocked.Add(ref value, amount);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Collections.Concurrent;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
|
||||
/// <summary>
|
||||
/// Spacing-inflated footprints and the no-fit polygons between them, for one clearance value.
|
||||
///
|
||||
/// Every placed part owns a footprint: its outline grown by half the required clearance
|
||||
/// (plus its own chord tolerance). Two parts respect the clearance exactly when their
|
||||
/// footprints do not overlap, so the whole spacing rule reduces to NFP containment.
|
||||
/// NFPs are translation-invariant, so each (orientation, orientation) pair is computed once
|
||||
/// per job and reused by every sheet, stock trial and strategy variant.
|
||||
/// </summary>
|
||||
internal sealed class NoFitCache
|
||||
{
|
||||
/// <summary>Clipper decimal precision; 1e-4 job units is far below any margin we keep.</summary>
|
||||
public const int Precision = NestTolerances.ClipperPrecision;
|
||||
|
||||
private readonly double halfClearance;
|
||||
private readonly ConcurrentDictionary<(int, int), PathD> footprints = new();
|
||||
private readonly ConcurrentDictionary<(int, int, int, int), Lazy<Nfp>> nfps = new();
|
||||
|
||||
public NoFitCache(double clearance)
|
||||
{
|
||||
halfClearance = clearance / 2;
|
||||
}
|
||||
|
||||
public PathD Footprint(Orientation o) =>
|
||||
footprints.GetOrAdd((o.TypeIndex, o.Index), _ => BuildFootprint(o));
|
||||
|
||||
/// <summary>NFP of <paramref name="moving"/> around <paramref name="fixedPart"/> placed at the origin.</summary>
|
||||
public Nfp Get(Orientation fixedPart, Orientation moving) =>
|
||||
nfps.GetOrAdd(
|
||||
(fixedPart.TypeIndex, fixedPart.Index, moving.TypeIndex, moving.Index),
|
||||
_ => new Lazy<Nfp>(() => Build(fixedPart, moving), LazyThreadSafetyMode.ExecutionAndPublication)
|
||||
)
|
||||
.Value;
|
||||
|
||||
private PathD BuildFootprint(Orientation o)
|
||||
{
|
||||
// Miter joins (squared past the limit) always contain the exact round offset, so the
|
||||
// footprint is a superset of "every point within the clearance of the outline".
|
||||
var inflated = Clipper.InflatePaths(
|
||||
new PathsD { o.Outline },
|
||||
// Four additional grid units cover this engine's repeated footprint/NFP
|
||||
// Boolean operations. Keep its established contact points and packing quality.
|
||||
halfClearance + NestTolerances.SafeClearanceMargin(o.Tolerance) / 2
|
||||
+ 4 * System.Math.Pow(10, -Precision),
|
||||
JoinType.Miter,
|
||||
EndType.Polygon,
|
||||
2.0,
|
||||
Precision,
|
||||
0.0
|
||||
);
|
||||
var best = inflated.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).First();
|
||||
if (!Clipper.IsPositive(best))
|
||||
best.Reverse();
|
||||
return best;
|
||||
}
|
||||
|
||||
private Nfp Build(Orientation fixedPart, Orientation moving)
|
||||
{
|
||||
var a = Footprint(fixedPart);
|
||||
var b = Footprint(moving);
|
||||
var negB = new PathD(b.Count);
|
||||
foreach (var p in b)
|
||||
negB.Add(new PointD(-p.x, -p.y));
|
||||
|
||||
PathsD region;
|
||||
if (IsConvex(a) && IsConvex(b))
|
||||
{
|
||||
region = new PathsD { ConvexSum(a, negB) };
|
||||
}
|
||||
else
|
||||
{
|
||||
// A (+) P, with P = -B: a reference point the boundary sweep misses puts the moving
|
||||
// copy of B clear of A's boundary, so that copy is inside A, contains A, or misses it.
|
||||
// (A + p0) covers "B inside A" and (P + a0) covers "B swallows A"; both are needed.
|
||||
var sweep = Minkowski.Sum(negB, a, true, Precision);
|
||||
sweep.Add(Clipper.TranslatePath(a, negB[0].x, negB[0].y));
|
||||
sweep.Add(Clipper.TranslatePath(negB, a[0].x, a[0].y));
|
||||
region = Clipper.Union(sweep, new PathsD(), FillRule.NonZero, Precision);
|
||||
}
|
||||
return new Nfp(region, Clipper.GetBounds(region));
|
||||
}
|
||||
|
||||
/// <summary>Minkowski sum of two convex CCW polygons by merging edges in angle order.</summary>
|
||||
private static PathD ConvexSum(PathD a, PathD b)
|
||||
{
|
||||
var ia = LowestIndex(a);
|
||||
var ib = LowestIndex(b);
|
||||
var result = new PathD(a.Count + b.Count);
|
||||
var current = new PointD(a[ia].x + b[ib].x, a[ia].y + b[ib].y);
|
||||
int i = 0, j = 0;
|
||||
while (i < a.Count || j < b.Count)
|
||||
{
|
||||
result.Add(current);
|
||||
var ea = i < a.Count ? Edge(a, ia + i) : default;
|
||||
var eb = j < b.Count ? Edge(b, ib + j) : default;
|
||||
// Both edge sequences start at the lowest vertex, so their angles rise through [0, 2pi).
|
||||
double order;
|
||||
if (i >= a.Count)
|
||||
order = -1;
|
||||
else if (j >= b.Count)
|
||||
order = 1;
|
||||
else
|
||||
{
|
||||
var difference = EdgeAngle(eb) - EdgeAngle(ea);
|
||||
order = System.Math.Abs(difference) < 1e-12 ? 0 : difference;
|
||||
}
|
||||
if (order > 0)
|
||||
{
|
||||
current = new PointD(current.x + ea.x, current.y + ea.y);
|
||||
i++;
|
||||
}
|
||||
else if (order < 0)
|
||||
{
|
||||
current = new PointD(current.x + eb.x, current.y + eb.y);
|
||||
j++;
|
||||
}
|
||||
else
|
||||
{
|
||||
current = new PointD(current.x + ea.x + eb.x, current.y + ea.y + eb.y);
|
||||
i++;
|
||||
j++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static double EdgeAngle(PointD edge)
|
||||
{
|
||||
var angle = System.Math.Atan2(edge.y, edge.x);
|
||||
return angle < 0 ? angle + System.Math.PI * 2 : angle;
|
||||
}
|
||||
|
||||
private static PointD Edge(PathD path, int index)
|
||||
{
|
||||
var from = path[index % path.Count];
|
||||
var to = path[(index + 1) % path.Count];
|
||||
return new PointD(to.x - from.x, to.y - from.y);
|
||||
}
|
||||
|
||||
/// <summary>Lowest (then leftmost) vertex: the start of a CCW edge sequence sorted by angle.</summary>
|
||||
private static int LowestIndex(PathD path)
|
||||
{
|
||||
var best = 0;
|
||||
for (var i = 1; i < path.Count; i++)
|
||||
if (path[i].y < path[best].y || (path[i].y == path[best].y && path[i].x < path[best].x))
|
||||
best = i;
|
||||
return best;
|
||||
}
|
||||
|
||||
private static bool IsConvex(PathD path)
|
||||
{
|
||||
var n = path.Count;
|
||||
if (n < 3)
|
||||
return false;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var a = path[i];
|
||||
var b = path[(i + 1) % n];
|
||||
var c = path[(i + 2) % n];
|
||||
var cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
|
||||
if (cross < -1e-12)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forbidden reference-point region (interior = overlap, boundary = touching) and its bounds.</summary>
|
||||
internal sealed record Nfp(PathsD Region, RectD Bounds);
|
||||
@@ -0,0 +1,161 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
|
||||
/// <summary>
|
||||
/// One allowed pose of a part type: its rotation, its polygonized outline at that rotation
|
||||
/// (reference point = snapshot origin), and the outline's conservative bounds.
|
||||
/// </summary>
|
||||
internal sealed class Orientation
|
||||
{
|
||||
public required int TypeIndex { get; init; }
|
||||
public required int Index { get; init; }
|
||||
public required double Rotation { get; init; }
|
||||
|
||||
/// <summary>CCW outline whose every point lies within <see cref="Tolerance"/> of the true perimeter.</summary>
|
||||
public required PathD Outline { get; init; }
|
||||
|
||||
/// <summary>Chord deviation used for arcs; footprints are grown by it to stay conservative.</summary>
|
||||
public required double Tolerance { get; init; }
|
||||
|
||||
/// <summary>Outline bounds grown by the tolerance, so they contain the true perimeter.</summary>
|
||||
public required double MinX { get; init; }
|
||||
public required double MinY { get; init; }
|
||||
public required double MaxX { get; init; }
|
||||
public required double MaxY { get; init; }
|
||||
|
||||
public double Width => MaxX - MinX;
|
||||
public double Height => MaxY - MinY;
|
||||
}
|
||||
|
||||
internal sealed class PartType
|
||||
{
|
||||
public required int Index { get; init; }
|
||||
public required NestJobPart Part { get; init; }
|
||||
public required double Area { get; init; }
|
||||
public required IReadOnlyList<Orientation> Orientations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts job snapshots into the polygon world the packer works in. Parts whose geometry
|
||||
/// cannot be read are kept with no orientations, so they surface as unplaced instead of
|
||||
/// failing the whole job.
|
||||
/// </summary>
|
||||
internal static class PartCatalog
|
||||
{
|
||||
/// <summary>Finest chord deviation of the working outline from true arcs, in job units.</summary>
|
||||
public const double ChordTolerance = 0.002;
|
||||
|
||||
/// <summary>Outline vertex count above which arcs are polygonized more coarsely (NFP cost is ~n*m).</summary>
|
||||
private const int TargetVertices = 64;
|
||||
|
||||
/// <summary>Hard cap on distinct orientations evaluated per part type.</summary>
|
||||
private const int MaxOrientations = 8;
|
||||
|
||||
public static IReadOnlyList<PartType> Build(NestJob job)
|
||||
{
|
||||
// Fewer orientations per type for jobs with many distinct parts; every (type, rotation)
|
||||
// pair costs a feasible-region update per placement.
|
||||
var perType = System.Math.Clamp(48 / System.Math.Max(1, job.Parts.Count), 2, MaxOrientations);
|
||||
var types = new List<PartType>(job.Parts.Count);
|
||||
for (var index = 0; index < job.Parts.Count; index++)
|
||||
{
|
||||
var part = job.Parts[index];
|
||||
Shape? perimeter;
|
||||
try
|
||||
{
|
||||
perimeter = ReadPerimeter(part.Geometry);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or InvalidOperationException)
|
||||
{
|
||||
perimeter = null;
|
||||
}
|
||||
|
||||
if (perimeter == null)
|
||||
{
|
||||
types.Add(new PartType { Index = index, Part = part, Area = 0, Orientations = [] });
|
||||
continue;
|
||||
}
|
||||
|
||||
var angles = RotationCandidates.DistinctOutlines(perimeter,
|
||||
CandidateAngles(part.Rotation, perimeter, perType));
|
||||
var tolerance = ChooseTolerance(perimeter);
|
||||
var orientations = new List<Orientation>();
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var outline = Polygonize(perimeter, angle, tolerance);
|
||||
if (outline.Count < 3)
|
||||
continue;
|
||||
orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance));
|
||||
}
|
||||
|
||||
var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline));
|
||||
types.Add(new PartType { Index = index, Part = part, Area = area, Orientations = orientations });
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) =>
|
||||
JobPartGeometry.TryRead(geometry)?.Perimeter;
|
||||
|
||||
/// <summary>
|
||||
/// Coarsens arc polygonization (up to 0.1% of the part size) until the outline is small
|
||||
/// enough for cheap Minkowski sums. Lines are always exact, so only arc-heavy parts pay.
|
||||
/// </summary>
|
||||
private static double ChooseTolerance(Shape perimeter)
|
||||
{
|
||||
var box = perimeter.BoundingBox;
|
||||
var cap = System.Math.Max(ChordTolerance, 0.001 * System.Math.Max(box.Width, box.Length));
|
||||
var tolerance = ChordTolerance;
|
||||
while (tolerance * 2 <= cap && perimeter.ToPolygonWithTolerance(tolerance).Vertices.Count > TargetVertices)
|
||||
tolerance *= 2;
|
||||
return tolerance;
|
||||
}
|
||||
|
||||
private static PathD Polygonize(Shape perimeter, double angle, double tolerance)
|
||||
{
|
||||
var shape = (Shape)perimeter.Clone();
|
||||
if (angle != 0)
|
||||
shape.Rotate(angle);
|
||||
var polygon = shape.ToPolygonWithTolerance(tolerance);
|
||||
var path = new PathD(polygon.Vertices.Count);
|
||||
foreach (var v in polygon.Vertices)
|
||||
{
|
||||
if (path.Count > 0 && System.Math.Abs(path[^1].x - v.X) < 1e-9 && System.Math.Abs(path[^1].y - v.Y) < 1e-9)
|
||||
continue;
|
||||
path.Add(new PointD(v.X, v.Y));
|
||||
}
|
||||
if (path.Count > 1 && System.Math.Abs(path[0].x - path[^1].x) < 1e-9 && System.Math.Abs(path[0].y - path[^1].y) < 1e-9)
|
||||
path.RemoveAt(path.Count - 1);
|
||||
if (!Clipper.IsPositive(path))
|
||||
path.Reverse();
|
||||
return path;
|
||||
}
|
||||
|
||||
private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline, double tolerance)
|
||||
{
|
||||
var bounds = Clipper.GetBounds(outline);
|
||||
return new Orientation
|
||||
{
|
||||
TypeIndex = typeIndex,
|
||||
Index = index,
|
||||
Rotation = angle,
|
||||
Outline = outline,
|
||||
Tolerance = tolerance,
|
||||
MinX = bounds.left - tolerance,
|
||||
MinY = bounds.top - tolerance, // Clipper RectD: top is the minimum Y.
|
||||
MaxX = bounds.right + tolerance,
|
||||
MaxY = bounds.bottom + tolerance,
|
||||
};
|
||||
}
|
||||
|
||||
internal static List<double> CandidateAngles(RotationPolicy policy, Shape perimeter, int limit) =>
|
||||
RotationCandidates.ForShape(policy, perimeter, limit).ToList();
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Rectangles;
|
||||
|
||||
/// <summary>One allowed rotation of a part, reduced to its analytic material bounding box.</summary>
|
||||
/// <param name="Angle">Rotation in radians about the snapshot origin.</param>
|
||||
/// <param name="Width">Material X extent after rotation.</param>
|
||||
/// <param name="Height">Material Y extent after rotation.</param>
|
||||
/// <param name="OffsetX">Rotated material bounds' left edge relative to the snapshot origin.</param>
|
||||
/// <param name="OffsetY">Rotated material bounds' bottom edge relative to the snapshot origin.</param>
|
||||
internal sealed record BoxOrientation(double Angle, double Width, double Height, double OffsetX, double OffsetY);
|
||||
|
||||
/// <summary>A requested part type: every instance shares the same orientations.</summary>
|
||||
internal sealed record BoxType(
|
||||
int Index,
|
||||
NestJobPart Part,
|
||||
IReadOnlyList<BoxOrientation> Orientations,
|
||||
double MaterialArea)
|
||||
{
|
||||
public string Id => Part.Id;
|
||||
public int Priority => Part.Priority;
|
||||
|
||||
/// <summary>Smallest bounding-box area over the allowed orientations.</summary>
|
||||
public double BoxArea => Orientations.Count == 0 ? 0 : Orientations.Min(o => o.Width * o.Height);
|
||||
|
||||
/// <summary>Shortest side over all orientations; free space narrower than this is useless.</summary>
|
||||
public double MinSide => Orientations.Count == 0 ? double.MaxValue
|
||||
: Orientations.Min(o => System.Math.Min(o.Width, o.Height));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reduces every requested part to the axis-aligned boxes of its useful rotations. Only material
|
||||
/// contours count (rapids and scribe/etch marks are excluded), exactly as the layout check's
|
||||
/// bounds test does. Orientations are the host rotation candidates whose box area is within a
|
||||
/// hair of the minimum (the minimum-area bounding rectangle plus its right-angle turn), with
|
||||
/// duplicate box shapes removed. Unreadable geometry yields a type with no orientations.
|
||||
/// </summary>
|
||||
internal static class BoxCatalog
|
||||
{
|
||||
private const double AreaTieRelative = 1e-6;
|
||||
private const double DimensionTie = 1e-7;
|
||||
|
||||
public static IReadOnlyList<BoxType> Build(NestJob job)
|
||||
{
|
||||
var types = new List<BoxType>(job.Parts.Count);
|
||||
for (var i = 0; i < job.Parts.Count; i++)
|
||||
types.Add(Read(i, job.Parts[i]));
|
||||
return types;
|
||||
}
|
||||
|
||||
private static BoxType Read(int index, NestJobPart part)
|
||||
{
|
||||
var geometry = JobPartGeometry.TryRead(part.Geometry);
|
||||
if (geometry == null)
|
||||
return new BoxType(index, part, Array.Empty<BoxOrientation>(), 0);
|
||||
|
||||
var candidates = new List<BoxOrientation>();
|
||||
foreach (var angle in RotationCandidates.ForShape(part.Rotation, geometry.Perimeter))
|
||||
{
|
||||
var bounds = RotatedMaterialBounds(part.Geometry, angle);
|
||||
if (bounds is not { } b || !(b.Width > 0) || !(b.Height > 0))
|
||||
continue;
|
||||
candidates.Add(new BoxOrientation(angle, b.Width, b.Height, b.Left, b.Bottom));
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return new BoxType(index, part, Array.Empty<BoxOrientation>(), geometry.MaterialArea);
|
||||
|
||||
var minArea = candidates.Min(c => c.Width * c.Height);
|
||||
var kept = new List<BoxOrientation>();
|
||||
foreach (var c in candidates)
|
||||
{
|
||||
if (c.Width * c.Height > minArea * (1 + AreaTieRelative))
|
||||
continue;
|
||||
if (kept.Any(k => System.Math.Abs(k.Width - c.Width) <= DimensionTie
|
||||
&& System.Math.Abs(k.Height - c.Height) <= DimensionTie))
|
||||
continue;
|
||||
kept.Add(c);
|
||||
}
|
||||
return new BoxType(index, part, kept, geometry.MaterialArea);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Material bounds after rotation, as the layout check will see them. The check flattens
|
||||
/// perimeter arcs circumscribed and snaps to a 1e-4 Clipper grid, so a curved extreme reads
|
||||
/// slightly outside the true arc. Each side takes the larger of the analytic bound and the
|
||||
/// check's own outline (ClipperBridge.OffsetForValidation at zero inflation, flattened in the
|
||||
/// same local frame), and any side where the outline sticks out gets one more grid unit.
|
||||
/// Straight edges are unchanged, so rectangles still pack at exactly the part spacing.
|
||||
/// </summary>
|
||||
private static (double Left, double Bottom, double Width, double Height)? RotatedMaterialBounds(
|
||||
PartGeometrySnapshot snapshot, double angle)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(snapshot))
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
||||
.ToList();
|
||||
if (entities.Count == 0)
|
||||
return null;
|
||||
foreach (var entity in entities)
|
||||
entity.Rotate(angle);
|
||||
var left = entities.Min(e => e.Left);
|
||||
var bottom = entities.Min(e => e.Bottom);
|
||||
var right = entities.Max(e => e.Right);
|
||||
var top = entities.Max(e => e.Top);
|
||||
if (!double.IsFinite(left) || !double.IsFinite(bottom) || !double.IsFinite(right) || !double.IsFinite(top))
|
||||
return null;
|
||||
|
||||
var profile = new ShapeProfile(entities);
|
||||
var outline = profile.Perimeter == null ? null
|
||||
: ClipperBridge.OffsetForValidation(profile, 0, NestTolerances.ValidationOutline).LargestOuter();
|
||||
if (outline != null && outline.Vertices.Count >= 3)
|
||||
{
|
||||
left = Widen(left, outline.Vertices.Min(v => v.X), -1);
|
||||
bottom = Widen(bottom, outline.Vertices.Min(v => v.Y), -1);
|
||||
right = Widen(right, outline.Vertices.Max(v => v.X), +1);
|
||||
top = Widen(top, outline.Vertices.Max(v => v.Y), +1);
|
||||
}
|
||||
return (left, bottom, right - left, top - bottom);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a side out to the check's outline plus one grid unit when the outline sticks out by
|
||||
/// more than a quarter of the spacing slack. Smaller differences are grid rounding (at most half
|
||||
/// a unit per vertex) or negligible bulge: two facing sides then lose under 0.00035 in total,
|
||||
/// inside NestTolerances.SpacingSlack, and ignoring them keeps rotated rectangles exact.
|
||||
/// </summary>
|
||||
private static double Widen(double analytic, double outline, int direction)
|
||||
{
|
||||
var grid = System.Math.Pow(10, -NestTolerances.ClipperPrecision);
|
||||
var beyond = (outline - analytic) * direction;
|
||||
return beyond > NestTolerances.SpacingSlack / 4 ? outline + direction * grid : analytic;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
namespace OpenNest.Engine.NestingEngines.Rectangles;
|
||||
|
||||
/// <summary>Axis-aligned rectangle in sheet-local packing coordinates.</summary>
|
||||
internal readonly record struct Rect(double X, double Y, double W, double H)
|
||||
{
|
||||
public double Right => X + W;
|
||||
public double Top => Y + H;
|
||||
|
||||
public bool Contains(Rect other) =>
|
||||
other.X >= X - MaxRectsSheet.Eps && other.Y >= Y - MaxRectsSheet.Eps
|
||||
&& other.Right <= Right + MaxRectsSheet.Eps && other.Top <= Top + MaxRectsSheet.Eps;
|
||||
|
||||
public bool Overlaps(Rect other) =>
|
||||
other.X < Right - MaxRectsSheet.Eps && other.Right > X + MaxRectsSheet.Eps
|
||||
&& other.Y < Top - MaxRectsSheet.Eps && other.Top > Y + MaxRectsSheet.Eps;
|
||||
}
|
||||
|
||||
/// <summary>How a free position is scored; lower (Primary, Secondary) wins.</summary>
|
||||
internal enum FitRule
|
||||
{
|
||||
/// <summary>Smallest leftover on the tighter side of the free rectangle.</summary>
|
||||
BestShortSide,
|
||||
/// <summary>Smallest leftover on the looser side of the free rectangle.</summary>
|
||||
BestLongSide,
|
||||
/// <summary>Smallest free rectangle that holds the item.</summary>
|
||||
BestArea,
|
||||
/// <summary>Lowest top edge, then leftmost: packs rows upward and keeps a clean top offcut.</summary>
|
||||
BottomLeft,
|
||||
/// <summary>Leftmost right edge, then lowest: packs columns rightward and keeps a clean right offcut.</summary>
|
||||
LeftBottom,
|
||||
/// <summary>Most perimeter touching the sheet edge or already placed items.</summary>
|
||||
ContactPoint,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maximal-rectangles free-space tracker for one sheet (Jylänki, "A Thousand Ways to Pack the
|
||||
/// Bin", 2010). Keeps every maximal empty rectangle, so any position a box can legally occupy
|
||||
/// is the bottom-left corner of some free rectangle. Items and the bin are inflated by the part
|
||||
/// spacing on their right/top sides by the caller, so touching inflated boxes are exactly one
|
||||
/// spacing apart and the last box may touch the sheet's work-area edge.
|
||||
/// </summary>
|
||||
internal sealed class MaxRectsSheet
|
||||
{
|
||||
public const double Eps = 1e-9;
|
||||
|
||||
private readonly List<Rect> free = new();
|
||||
private readonly List<Rect> used = new();
|
||||
|
||||
public MaxRectsSheet(double width, double height)
|
||||
{
|
||||
Width = width;
|
||||
Height = height;
|
||||
free.Add(new Rect(0, 0, width, height));
|
||||
}
|
||||
|
||||
public double Width { get; }
|
||||
public double Height { get; }
|
||||
public IReadOnlyList<Rect> Used => used;
|
||||
|
||||
/// <summary>Best position for a w-by-h item under the rule, or null when nothing holds it.</summary>
|
||||
public (Rect Place, double Primary, double Secondary)? FindBest(double w, double h, FitRule rule)
|
||||
{
|
||||
(Rect Place, double Primary, double Secondary)? best = null;
|
||||
foreach (var f in free)
|
||||
{
|
||||
if (w > f.W + Eps || h > f.H + Eps)
|
||||
continue;
|
||||
var place = new Rect(f.X, f.Y, w, h);
|
||||
var (p, s) = Score(f, place, rule);
|
||||
if (best is not { } b || p < b.Primary - Eps
|
||||
|| (p <= b.Primary + Eps && s < b.Secondary - Eps))
|
||||
best = (place, p, s);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>Commits an item and splits every free rectangle it intersects.</summary>
|
||||
public void Place(Rect item)
|
||||
{
|
||||
var next = new List<Rect>(free.Count + 8);
|
||||
foreach (var f in free)
|
||||
{
|
||||
if (!f.Overlaps(item))
|
||||
{
|
||||
next.Add(f);
|
||||
continue;
|
||||
}
|
||||
if (item.X > f.X + Eps)
|
||||
next.Add(new Rect(f.X, f.Y, item.X - f.X, f.H));
|
||||
if (item.Right < f.Right - Eps)
|
||||
next.Add(new Rect(item.Right, f.Y, f.Right - item.Right, f.H));
|
||||
if (item.Y > f.Y + Eps)
|
||||
next.Add(new Rect(f.X, f.Y, f.W, item.Y - f.Y));
|
||||
if (item.Top < f.Top - Eps)
|
||||
next.Add(new Rect(f.X, item.Top, f.W, f.Top - item.Top));
|
||||
}
|
||||
free.Clear();
|
||||
free.AddRange(Prune(next));
|
||||
used.Add(item);
|
||||
}
|
||||
|
||||
private static List<Rect> Prune(List<Rect> rects)
|
||||
{
|
||||
// Drop rectangles contained in another; of two equal ones keep the first (deterministic).
|
||||
var keep = new bool[rects.Count];
|
||||
for (var i = 0; i < rects.Count; i++)
|
||||
keep[i] = rects[i].W > Eps && rects[i].H > Eps;
|
||||
for (var i = 0; i < rects.Count; i++)
|
||||
{
|
||||
if (!keep[i])
|
||||
continue;
|
||||
for (var j = 0; j < rects.Count; j++)
|
||||
{
|
||||
if (i == j || !keep[j])
|
||||
continue;
|
||||
if (rects[j].Contains(rects[i]) && (!rects[i].Contains(rects[j]) || j < i))
|
||||
{
|
||||
keep[i] = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
var result = new List<Rect>(rects.Count);
|
||||
for (var i = 0; i < rects.Count; i++)
|
||||
if (keep[i])
|
||||
result.Add(rects[i]);
|
||||
return result;
|
||||
}
|
||||
|
||||
private (double Primary, double Secondary) Score(Rect f, Rect place, FitRule rule)
|
||||
{
|
||||
var dx = f.W - place.W;
|
||||
var dy = f.H - place.H;
|
||||
return rule switch
|
||||
{
|
||||
FitRule.BestShortSide => (System.Math.Min(dx, dy), System.Math.Max(dx, dy)),
|
||||
FitRule.BestLongSide => (System.Math.Max(dx, dy), System.Math.Min(dx, dy)),
|
||||
FitRule.BestArea => (f.W * f.H - place.W * place.H, System.Math.Min(dx, dy)),
|
||||
FitRule.BottomLeft => (place.Top, place.X),
|
||||
FitRule.LeftBottom => (place.Right, place.Y),
|
||||
FitRule.ContactPoint => (-Contact(place), place.Top + place.Right),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(rule)),
|
||||
};
|
||||
}
|
||||
|
||||
private double Contact(Rect r)
|
||||
{
|
||||
var total = 0.0;
|
||||
if (r.X <= Eps) total += r.H;
|
||||
if (r.Right >= Width - Eps) total += r.H;
|
||||
if (r.Y <= Eps) total += r.W;
|
||||
if (r.Top >= Height - Eps) total += r.W;
|
||||
foreach (var u in used)
|
||||
{
|
||||
if (System.Math.Abs(u.X - r.Right) <= Eps || System.Math.Abs(u.Right - r.X) <= Eps)
|
||||
total += Overlap(u.Y, u.Top, r.Y, r.Top);
|
||||
if (System.Math.Abs(u.Y - r.Top) <= Eps || System.Math.Abs(u.Top - r.Y) <= Eps)
|
||||
total += Overlap(u.X, u.Right, r.X, r.Right);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static double Overlap(double a0, double a1, double b0, double b1) =>
|
||||
System.Math.Max(0, System.Math.Min(a1, b1) - System.Math.Max(a0, b0));
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Rectangles;
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle-lane nesting engine: every part is nested as the axis-aligned box of its material at
|
||||
/// its minimum-area rotations, packed with a maximal-rectangles free list (Jylänki 2010).
|
||||
///
|
||||
/// Built for jobs of plain and near-rectangular parts, where a part's box wastes almost nothing
|
||||
/// and exact box packing beats contour-sliding engines on both speed and density. Irregular parts
|
||||
/// are still placed validly, only as their bounding boxes; they are not nested into each other.
|
||||
///
|
||||
/// Sheet by sheet, each available stock is packed under several free-space scoring rules and
|
||||
/// two pick modes (best-fitting box anywhere, or largest type first). The candidate sheet with
|
||||
/// the lowest estimated whole-job cost wins: its salvage-credited net area (NestJobCost) plus the
|
||||
/// remaining demand priced at the best net-area-per-part-area ratio seen among the candidates.
|
||||
/// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
|
||||
/// </summary>
|
||||
public sealed class RectanglesNestingEngine : INestingEngine
|
||||
{
|
||||
private static readonly FitRule[] Rules =
|
||||
{
|
||||
FitRule.BestShortSide, FitRule.BestLongSide, FitRule.BestArea,
|
||||
FitRule.BottomLeft, FitRule.LeftBottom, FitRule.ContactPoint,
|
||||
};
|
||||
|
||||
private static readonly PickMode[] Modes = { PickMode.Global, PickMode.Ordered };
|
||||
|
||||
public NestJobResult Solve(
|
||||
NestJob job,
|
||||
IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var types = BoxCatalog.Build(job);
|
||||
var remaining = types.Select(t => t.Part.Quantity).ToArray();
|
||||
// Parts with unreadable geometry or no box that fits any offered sheet can never be placed.
|
||||
foreach (var t in types)
|
||||
if (t.Orientations.Count == 0 || !job.Plates.Any(stock => t.Orientations.Any(o => FitsStock(stock, o))))
|
||||
remaining[t.Index] = 0;
|
||||
|
||||
var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
|
||||
var result = new NestJobResultBuilder(job, progress);
|
||||
NestJobStopReason reason;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (remaining.All(r => r == 0))
|
||||
{
|
||||
reason = NestJobStopReason.NoPlacementFound; // Builder reports Completed when demand is met.
|
||||
break;
|
||||
}
|
||||
if (job.Options.MaxPlates is int cap && result.SheetsUsed(job) >= cap)
|
||||
{
|
||||
reason = NestJobStopReason.PlateLimitReached;
|
||||
break;
|
||||
}
|
||||
|
||||
var trials = new List<(SheetPlan Plan, double Net)>();
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (stock.Quantity is int available && used[stock.Id] >= available)
|
||||
continue;
|
||||
progress?.Report(new NestJobProgress(
|
||||
NestJobStage.EvaluatingCandidate, stock.Id, result.SheetsUsed(job), result.SheetsUsed(job), 0));
|
||||
foreach (var mode in Modes)
|
||||
foreach (var rule in Rules)
|
||||
{
|
||||
var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token);
|
||||
if (plan.Parts.Count > 0)
|
||||
trials.Add((plan, NetArea(job, plan)));
|
||||
}
|
||||
}
|
||||
|
||||
if (trials.Count == 0)
|
||||
{
|
||||
var exhausted = job.Plates.Any(s => s.Quantity is int q && used[s.Id] >= q);
|
||||
reason = exhausted ? NestJobStopReason.StockExhausted : NestJobStopReason.NoPlacementFound;
|
||||
break;
|
||||
}
|
||||
|
||||
var chosen = Choose(types, remaining, trials);
|
||||
result.AddSheet(chosen.Stock, chosen.Poses());
|
||||
used[chosen.Stock.Id]++;
|
||||
foreach (var p in chosen.Parts)
|
||||
remaining[p.Type.Index]--;
|
||||
}
|
||||
|
||||
return result.Build(reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the sheet with the lowest estimated whole-job cost. Remaining demand is priced at the
|
||||
/// best net-area-per-material ratio any candidate achieved, so a sheet that finishes the job
|
||||
/// competes fairly with a denser partial one. Ties: more material placed, then enumeration order.
|
||||
/// </summary>
|
||||
private static SheetPlan Choose(
|
||||
IReadOnlyList<BoxType> types, int[] remaining, List<(SheetPlan Plan, double Net)> trials)
|
||||
{
|
||||
var demandArea = types.Sum(t => remaining[t.Index] * t.MaterialArea);
|
||||
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Plan.MaterialArea, 1e-12));
|
||||
return trials
|
||||
.Select((t, order) => (t.Plan, order,
|
||||
Estimate: t.Net + System.Math.Max(0, demandArea - t.Plan.MaterialArea) * bestRatio))
|
||||
.OrderBy(t => PriorityDebt(types, remaining, t.Plan))
|
||||
.ThenBy(t => t.Estimate)
|
||||
.ThenByDescending(t => t.Plan.MaterialArea)
|
||||
.ThenBy(t => t.order)
|
||||
.First()
|
||||
.Plan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Priority guard: how many instances of the most urgent (lowest-number) tier with remaining
|
||||
/// demand this plan leaves unplaced. Plans are ranked on this before cost, so a cheaper sheet
|
||||
/// can never win by serving a later tier at the expense of an earlier one.
|
||||
/// </summary>
|
||||
private static int PriorityDebt(IReadOnlyList<BoxType> types, int[] remaining, SheetPlan plan)
|
||||
{
|
||||
var active = types.Where(t => remaining[t.Index] > 0).ToList();
|
||||
if (active.Count == 0)
|
||||
return 0;
|
||||
var top = active.Min(t => t.Priority);
|
||||
var placed = plan.Parts.Count(p => p.Type.Priority == top);
|
||||
return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed;
|
||||
}
|
||||
|
||||
private static double NetArea(NestJob job, SheetPlan plan) =>
|
||||
plan.Envelope is { } envelope
|
||||
? NestJobCost.NetSheetArea(job.Options, plan.Stock, envelope)
|
||||
: plan.Stock.Area;
|
||||
|
||||
private static bool FitsStock(NestPlateStock stock, BoxOrientation o)
|
||||
{
|
||||
var work = stock.WorkArea;
|
||||
return o.Width <= work.Right - work.Left + MaxRectsSheet.Eps
|
||||
&& o.Height <= work.Top - work.Bottom + MaxRectsSheet.Eps;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ResultBuilderExtensions
|
||||
{
|
||||
public static int SheetsUsed(this NestJobResultBuilder builder, NestJob job) =>
|
||||
job.Plates.Sum(builder.SheetsUsed);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Rectangles;
|
||||
|
||||
/// <summary>How the next box is chosen on a sheet.</summary>
|
||||
internal enum PickMode
|
||||
{
|
||||
/// <summary>Every step places whichever remaining type/orientation scores best anywhere.</summary>
|
||||
Global,
|
||||
/// <summary>Types in (priority, largest box first) order; each fills until it no longer fits.</summary>
|
||||
Ordered,
|
||||
}
|
||||
|
||||
/// <summary>One placed box: which part type, which orientation, and its material bounds' corner.</summary>
|
||||
internal readonly record struct Placed(BoxType Type, BoxOrientation Orientation, double Left, double Bottom);
|
||||
|
||||
/// <summary>A proposed single-sheet layout.</summary>
|
||||
internal sealed record SheetPlan(
|
||||
NestPlateStock Stock,
|
||||
IReadOnlyList<Placed> Parts,
|
||||
double MaterialArea,
|
||||
Box? Envelope,
|
||||
FitRule Rule,
|
||||
PickMode Mode)
|
||||
{
|
||||
/// <summary>Converts box corners into job poses (rotate about the snapshot origin, then translate).</summary>
|
||||
public IEnumerable<(string PartId, double X, double Y, double Rotation)> Poses() =>
|
||||
Parts.Select(p => (p.Type.Id, p.Left - p.Orientation.OffsetX, p.Bottom - p.Orientation.OffsetY,
|
||||
p.Orientation.Angle));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packs the remaining demand onto one sheet of the given stock with a maximal-rectangles free
|
||||
/// list. Lower priority numbers are always served first: a higher-number type is only placed
|
||||
/// when no lower-number type still fits anywhere.
|
||||
/// </summary>
|
||||
internal static class SheetPacker
|
||||
{
|
||||
public static SheetPlan Pack(
|
||||
IReadOnlyList<BoxType> types, IReadOnlyList<int> remaining, NestPlateStock stock,
|
||||
FitRule rule, PickMode mode, CancellationToken token)
|
||||
{
|
||||
var work = stock.WorkArea;
|
||||
var s = stock.PartSpacing;
|
||||
var sheet = new MaxRectsSheet(work.Right - work.Left + s, work.Top - work.Bottom + s);
|
||||
var left = remaining.ToArray();
|
||||
var placed = new List<Placed>();
|
||||
|
||||
if (mode == PickMode.Global)
|
||||
PackGlobal(types, left, sheet, s, rule, placed, token);
|
||||
else
|
||||
PackOrdered(types, left, sheet, s, rule, placed, token);
|
||||
|
||||
var area = 0.0;
|
||||
Box? envelope = null;
|
||||
foreach (var p in placed)
|
||||
{
|
||||
area += p.Type.MaterialArea;
|
||||
var box = new Box(work.Left + p.Left, work.Bottom + p.Bottom, p.Orientation.Width, p.Orientation.Height);
|
||||
envelope = envelope == null ? box : Union(envelope, box);
|
||||
}
|
||||
|
||||
var world = placed
|
||||
.Select(p => p with { Left = work.Left + p.Left, Bottom = work.Bottom + p.Bottom })
|
||||
.ToList();
|
||||
return new SheetPlan(stock, world, area, envelope, rule, mode);
|
||||
}
|
||||
|
||||
private static void PackGlobal(
|
||||
IReadOnlyList<BoxType> types, int[] left, MaxRectsSheet sheet, double s, FitRule rule,
|
||||
List<Placed> placed, CancellationToken token)
|
||||
{
|
||||
// Free space only shrinks, so an orientation that fails once never fits again on this sheet.
|
||||
var dead = types.Select(t => new bool[t.Orientations.Count]).ToArray();
|
||||
var tiers = types.Select(t => t.Priority).Distinct().Order().ToArray();
|
||||
|
||||
while (true)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
(BoxType Type, int Orientation, Rect Place, double P, double S)? best = null;
|
||||
foreach (var tier in tiers)
|
||||
{
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (type.Priority != tier || left[type.Index] == 0)
|
||||
continue;
|
||||
for (var o = 0; o < type.Orientations.Count; o++)
|
||||
{
|
||||
if (dead[type.Index][o])
|
||||
continue;
|
||||
var orientation = type.Orientations[o];
|
||||
var fit = sheet.FindBest(orientation.Width + s, orientation.Height + s, rule);
|
||||
if (fit is not { } f)
|
||||
{
|
||||
dead[type.Index][o] = true;
|
||||
continue;
|
||||
}
|
||||
if (best is not { } b || Better(f.Primary, f.Secondary, type.BoxArea, b.P, b.S, b.Type.BoxArea))
|
||||
best = (type, o, f.Place, f.Primary, f.Secondary);
|
||||
}
|
||||
}
|
||||
if (best != null)
|
||||
break;
|
||||
}
|
||||
|
||||
if (best is not { } chosen)
|
||||
return;
|
||||
sheet.Place(chosen.Place);
|
||||
left[chosen.Type.Index]--;
|
||||
placed.Add(new Placed(chosen.Type, chosen.Type.Orientations[chosen.Orientation], chosen.Place.X, chosen.Place.Y));
|
||||
}
|
||||
}
|
||||
|
||||
private static void PackOrdered(
|
||||
IReadOnlyList<BoxType> types, int[] left, MaxRectsSheet sheet, double s, FitRule rule,
|
||||
List<Placed> placed, CancellationToken token)
|
||||
{
|
||||
var order = types
|
||||
.Where(t => t.Orientations.Count > 0)
|
||||
.OrderBy(t => t.Priority)
|
||||
.ThenByDescending(t => t.BoxArea)
|
||||
.ThenBy(t => t.Index);
|
||||
|
||||
foreach (var type in order)
|
||||
{
|
||||
while (left[type.Index] > 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
(int Orientation, Rect Place, double P, double S)? best = null;
|
||||
for (var o = 0; o < type.Orientations.Count; o++)
|
||||
{
|
||||
var orientation = type.Orientations[o];
|
||||
var fit = sheet.FindBest(orientation.Width + s, orientation.Height + s, rule);
|
||||
if (fit is { } f && (best is not { } b || Better(f.Primary, f.Secondary, 0, b.P, b.S, 0)))
|
||||
best = (o, f.Place, f.Primary, f.Secondary);
|
||||
}
|
||||
if (best is not { } chosen)
|
||||
break;
|
||||
sheet.Place(chosen.Place);
|
||||
left[type.Index]--;
|
||||
placed.Add(new Placed(type, type.Orientations[chosen.Orientation], chosen.Place.X, chosen.Place.Y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lower score wins; on a tie the larger box goes first (strict, so input order breaks full ties).</summary>
|
||||
private static bool Better(double p, double s, double area, double bp, double bs, double barea)
|
||||
{
|
||||
if (p < bp - MaxRectsSheet.Eps) return true;
|
||||
if (p > bp + MaxRectsSheet.Eps) return false;
|
||||
if (s < bs - MaxRectsSheet.Eps) return true;
|
||||
if (s > bs + MaxRectsSheet.Eps) return false;
|
||||
return area > barea + MaxRectsSheet.Eps;
|
||||
}
|
||||
|
||||
private static Box Union(Box a, Box b)
|
||||
{
|
||||
var l = System.Math.Min(a.Left, b.Left);
|
||||
var bo = System.Math.Min(a.Bottom, b.Bottom);
|
||||
var r = System.Math.Max(a.Right, b.Right);
|
||||
var t = System.Math.Max(a.Top, b.Top);
|
||||
return new Box(l, bo, r - l, t - bo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.Sequencing
|
||||
{
|
||||
/// <summary>Applies a sequencer's exit-first route as the plate's cutting order.</summary>
|
||||
public static class PlateSequencing
|
||||
{
|
||||
/// <summary>Sequences the plates present when the operation starts.</summary>
|
||||
public static void ApplyAll(IEnumerable<Plate> plates, SequenceParameters parameters)
|
||||
{
|
||||
// Reordering parts can make PlateManager replace the trailing empty
|
||||
// plate. Snapshot before applying so those events cannot invalidate
|
||||
// enumeration of the nest's live plate collection.
|
||||
foreach (var plate in plates.ToArray())
|
||||
Apply(plate, parameters);
|
||||
}
|
||||
|
||||
public static void Apply(Plate plate, SequenceParameters parameters)
|
||||
{
|
||||
var sequencer = PartSequencerFactory.Create(parameters);
|
||||
var ordered = sequencer.Sequence(plate.Parts.ToList(), plate)
|
||||
.Select(p => p.Part).Reverse().ToList();
|
||||
|
||||
// Enforce dependencies AFTER reversing the exit-first route. Checking
|
||||
// the sequencer's output itself would invert the safety rule on apply.
|
||||
var cutOrder = OrderCutOffsFirst(ordered, plate);
|
||||
plate.Parts.Clear();
|
||||
foreach (var part in cutOrder)
|
||||
plate.Parts.Add(part);
|
||||
}
|
||||
|
||||
private static List<Part> OrderCutOffsFirst(List<Part> ordered, Plate plate)
|
||||
{
|
||||
var cuts = ordered.Select((part, index) => (Part: part, Index: index))
|
||||
.Where(item => item.Part.BaseDrawing.IsCutOff).ToList();
|
||||
if (cuts.Count == 0)
|
||||
return ordered;
|
||||
|
||||
var definitions = new Dictionary<Drawing, CutOff>(ReferenceEqualityComparer.Instance);
|
||||
foreach (var cutOff in plate.CutOffs)
|
||||
definitions[cutOff.Drawing] = cutOff;
|
||||
|
||||
var bounds = plate.BoundingBox(includeParts: false);
|
||||
var emitted = new bool[ordered.Count];
|
||||
var result = new List<Part>(ordered.Count);
|
||||
for (var i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
var part = ordered[i];
|
||||
if (!part.BaseDrawing.IsCutOff)
|
||||
{
|
||||
foreach (var cut in cuts)
|
||||
{
|
||||
if (emitted[cut.Index])
|
||||
continue;
|
||||
|
||||
// An orphaned cutoff still must not follow potentially
|
||||
// crossed parts when its nominal span cannot be recovered.
|
||||
if (!definitions.TryGetValue(cut.Part.BaseDrawing, out var definition)
|
||||
|| CrossesBounds(definition, part.BoundingBox, bounds))
|
||||
{
|
||||
result.Add(cut.Part);
|
||||
emitted[cut.Index] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!emitted[i])
|
||||
{
|
||||
result.Add(part);
|
||||
emitted[i] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool CrossesBounds(CutOff cutOff, Box part, Box plate)
|
||||
{
|
||||
var vertical = cutOff.Axis == CutOffAxis.Vertical;
|
||||
var position = vertical ? cutOff.Position.X : cutOff.Position.Y;
|
||||
var acrossMin = vertical ? part.Left : part.Bottom;
|
||||
var acrossMax = vertical ? part.Right : part.Top;
|
||||
var alongMin = vertical ? part.Bottom : part.Left;
|
||||
var alongMax = vertical ? part.Top : part.Right;
|
||||
var start = cutOff.StartLimit ?? (vertical ? plate.Bottom : plate.Left);
|
||||
var end = cutOff.EndLimit ?? (vertical ? plate.Top : plate.Right);
|
||||
|
||||
// Use the nominal line, not its trimmed cutting segments (which
|
||||
// deliberately skip the parts). Bounds conservatively include edge
|
||||
// contacts and concave recesses; limits prevent unrelated dependencies
|
||||
// beyond the cutoff's span. Negative coordinates need no special case.
|
||||
return !(position < acrossMin - Tolerance.Epsilon
|
||||
|| position > acrossMax + Tolerance.Epsilon
|
||||
|| System.Math.Max(start, end) < alongMin - Tolerance.Epsilon
|
||||
|| System.Math.Min(start, end) > alongMax + Tolerance.Epsilon);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
using System.Reflection;
|
||||
using OpenNest.Api;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Mcp;
|
||||
using OpenNest.Mcp.Tools;
|
||||
|
||||
namespace OpenNest.FrontEnd.Tests;
|
||||
|
||||
[CollectionDefinition("FrontEndRegistry", DisableParallelization = true)]
|
||||
public class FrontEndRegistryCollection { }
|
||||
|
||||
[Collection("FrontEndRegistry")]
|
||||
public class NestingPipelineTests : IDisposable
|
||||
{
|
||||
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-frontends-" + Guid.NewGuid());
|
||||
|
||||
public NestingPipelineTests() => Directory.CreateDirectory(directory);
|
||||
public void Dispose() => Directory.Delete(directory, true);
|
||||
|
||||
private static Drawing Square()
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(2, 0)));
|
||||
shape.Entities.Add(new Line(new Vector(2, 0), new Vector(2, 2)));
|
||||
shape.Entities.Add(new Line(new Vector(2, 2), new Vector(0, 2)));
|
||||
shape.Entities.Add(new Line(new Vector(0, 2), new Vector(0, 0)));
|
||||
return new Drawing("square", OpenNest.Converters.ConvertGeometry.ToProgram(shape));
|
||||
}
|
||||
|
||||
private static NestSession Session(bool occupied = false)
|
||||
{
|
||||
var session = new NestSession { Nest = new Nest("fixture") };
|
||||
session.Nest.Drawings.Add(Square());
|
||||
var plate = session.Nest.CreatePlate();
|
||||
plate.Size = new Size(20, 30);
|
||||
plate.PartSpacing = 0.2;
|
||||
plate.EdgeSpacing = new Spacing(0.5, 0.5, 0.5, 0.5);
|
||||
plate.Quantity = 7;
|
||||
if (occupied)
|
||||
plate.Parts.Add(new Part(session.Nest.Drawings.Single()));
|
||||
return session;
|
||||
}
|
||||
|
||||
private static string Engine(Func<NestJob, CancellationToken, NestJobResult> solve)
|
||||
{
|
||||
var name = "FrontEnd-" + Guid.NewGuid();
|
||||
NestingEngineRegistry.Register(name, "test", () => new Stub(solve));
|
||||
return name;
|
||||
}
|
||||
|
||||
private sealed class Stub(Func<NestJob, CancellationToken, NestJobResult> solve) : INestingEngine
|
||||
{
|
||||
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default) => solve(job, token);
|
||||
}
|
||||
|
||||
private static NestJobResult Result(NestJob job, string mode)
|
||||
{
|
||||
var poses = mode switch
|
||||
{
|
||||
"overlap" => new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0), new NestJobPlacement(job.Parts[0].Id, 1, 1, 1, 0) },
|
||||
"unknown" => new[] { new NestJobPlacement("ghost", 0, 1, 1, 0) },
|
||||
"nan" => new[] { new NestJobPlacement(job.Parts[0].Id, 0, double.NaN, 1, 0) },
|
||||
_ => new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0) },
|
||||
};
|
||||
var sheets = mode == "empty" ? Array.Empty<NestJobPlateResult>()
|
||||
: mode == "multi" ? new[] { new NestJobPlateResult(0, job.Plates[0], poses), new NestJobPlateResult(1, job.Plates[0], poses) }
|
||||
: new[] { new NestJobPlateResult(0, job.Plates[0], poses) };
|
||||
// Deliberately lying metadata: front ends must count actual poses.
|
||||
return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, sheets,
|
||||
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 999, 0)),
|
||||
new[] { new StockUsage(job.Plates[0].Id, 999, 0) });
|
||||
}
|
||||
|
||||
private static int RunConsole(params string[] args)
|
||||
{
|
||||
var method = Assembly.Load("OpenNest.Console").GetType("NestConsole")!
|
||||
.GetMethod("Run", BindingFlags.Static | BindingFlags.Public)!;
|
||||
return (int)method.Invoke(null, new object[] { args })!;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("overlap", false, 2)]
|
||||
[InlineData("overlap", true, 0)]
|
||||
[InlineData("multi", true, 2)]
|
||||
[InlineData("unknown", true, 2)]
|
||||
[InlineData("nan", true, 2)]
|
||||
public void Console_RefusesInvalidWithoutOverwritingUnlessFaithfullyKeepable(string mode, bool allow, int expected)
|
||||
{
|
||||
var input = Path.Combine(directory, "input.nest");
|
||||
var output = Path.Combine(directory, "output.nest");
|
||||
Assert.True(new NestWriter(Session(true).Nest).Write(input));
|
||||
var original = File.ReadAllBytes(input);
|
||||
File.WriteAllText(output, "must survive rejection");
|
||||
var engine = Engine((job, _) => Result(job, mode));
|
||||
var args = new List<string> { input, "--autonest", "--engine", engine, "--quantity", "2", "--output", output };
|
||||
if (allow) args.Add("--allow-invalid");
|
||||
Assert.Equal(expected, RunConsole(args.ToArray()));
|
||||
Assert.Equal(original, File.ReadAllBytes(input));
|
||||
if (expected == 2)
|
||||
Assert.Equal("must survive rejection", File.ReadAllText(output));
|
||||
else
|
||||
{
|
||||
var nest = new NestReader(output).Read();
|
||||
Assert.Equal(2, nest.Plates[0].Parts.Count);
|
||||
Assert.Equal(1, nest.Plates[0].Quantity);
|
||||
Assert.Equal(2, nest.Drawings.Single().Quantity.Nested);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Console_EmptyResultDoesNotOverwriteOutputOrOriginal()
|
||||
{
|
||||
var input = Path.Combine(directory, "input.nest");
|
||||
var output = Path.Combine(directory, "output.nest");
|
||||
Assert.True(new NestWriter(Session(true).Nest).Write(input));
|
||||
var original = File.ReadAllBytes(input);
|
||||
File.WriteAllText(output, "keep existing output");
|
||||
var engine = Engine((job, _) => Result(job, "empty"));
|
||||
Assert.Equal(2, RunConsole(input, "--autonest", "--engine", engine, "--output", output));
|
||||
Assert.Equal(original, File.ReadAllBytes(input));
|
||||
Assert.Equal("keep existing output", File.ReadAllText(output));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Console_CancelledSolveDoesNotSave()
|
||||
{
|
||||
var input = Path.Combine(directory, "input.nest");
|
||||
var output = Path.Combine(directory, "output.nest");
|
||||
Assert.True(new NestWriter(Session(true).Nest).Write(input));
|
||||
var engine = Engine((_, _) => throw new OperationCanceledException());
|
||||
Assert.Equal(2, RunConsole(input, "--autonest", "--engine", engine, "--output", output));
|
||||
Assert.False(File.Exists(output));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Console_KeepPartsOnOccupiedSheetRejectsBeforeSolve()
|
||||
{
|
||||
var input = Path.Combine(directory, "input.nest");
|
||||
Assert.True(new NestWriter(Session(true).Nest).Write(input));
|
||||
var original = File.ReadAllBytes(input);
|
||||
var engine = Engine((_, _) => throw new Xunit.Sdk.XunitException("must not solve occupied stock"));
|
||||
Assert.Equal(2, RunConsole(input, "--autonest", "--keep-parts", "--engine", engine));
|
||||
Assert.Equal(original, File.ReadAllBytes(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Console_ValidCommitPreservesSettingsAndUsesOnePhysicalSheet()
|
||||
{
|
||||
var input = Path.Combine(directory, "input.nest");
|
||||
var output = Path.Combine(directory, "output.nest");
|
||||
Assert.True(new NestWriter(Session(true).Nest).Write(input));
|
||||
var engine = Engine((job, _) =>
|
||||
{
|
||||
Assert.Equal(1, Assert.Single(job.Plates).Quantity);
|
||||
return Result(job, "valid");
|
||||
});
|
||||
Assert.Equal(0, RunConsole(input, "--autonest", "--engine", engine, "--output", output));
|
||||
var plate = Assert.Single(new NestReader(output).Read().Plates);
|
||||
Assert.Single(plate.Parts);
|
||||
Assert.Equal(1, plate.Quantity);
|
||||
Assert.Equal(0.2, plate.PartSpacing);
|
||||
Assert.Equal(new Spacing(0.5, 0.5, 0.5, 0.5), plate.EdgeSpacing);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("overlap", false, false)]
|
||||
[InlineData("overlap", true, true)]
|
||||
[InlineData("multi", true, false)]
|
||||
[InlineData("unknown", true, false)]
|
||||
[InlineData("nan", true, false)]
|
||||
[InlineData("valid", false, true)]
|
||||
public void Mcp_OnlyCommitsFaithfulSingleSheet(string mode, bool allow, bool committed)
|
||||
{
|
||||
var session = Session();
|
||||
var plate = session.GetPlate(0);
|
||||
var engine = Engine((job, _) =>
|
||||
{
|
||||
Assert.Equal(1, Assert.Single(job.Plates).Quantity);
|
||||
return Result(job, mode);
|
||||
});
|
||||
var tool = new NestingTools(session);
|
||||
var result = CallMcp(tool, engine, allow);
|
||||
if (committed)
|
||||
{
|
||||
Assert.NotEmpty(plate.Parts);
|
||||
Assert.Equal(1, plate.Quantity);
|
||||
Assert.All(plate.Parts, p => Assert.Same(session.Nest.Drawings.Single(), p.BaseDrawing));
|
||||
Assert.Equal(plate.Parts.Count, session.Nest.Drawings.Single().Quantity.Nested);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Contains("Error", result);
|
||||
Assert.Empty(plate.Parts);
|
||||
Assert.Equal(7, plate.Quantity);
|
||||
}
|
||||
if (mode != "valid") Assert.Contains("Violation", result);
|
||||
}
|
||||
|
||||
private static string CallMcp(NestingTools tool, string engine, bool allow = false, CancellationToken token = default)
|
||||
{
|
||||
// Reflection lets RED exercise the old implementation before optional parameters exist.
|
||||
var method = typeof(NestingTools).GetMethod(nameof(NestingTools.AutoNestPlate))!;
|
||||
var args = method.GetParameters().Select(p => p.Name switch
|
||||
{
|
||||
"plateIndex" => (object)0,
|
||||
"drawingNames" => "square",
|
||||
"quantities" => "2",
|
||||
"engine" => engine,
|
||||
"allow_invalid" => allow,
|
||||
"cancellationToken" => token,
|
||||
_ => p.DefaultValue,
|
||||
}).ToArray();
|
||||
try { return (string)method.Invoke(tool, args)!; }
|
||||
catch (TargetInvocationException ex) when (ex.InnerException is OperationCanceledException)
|
||||
{ throw ex.InnerException; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mcp_OccupiedSheetRejectsBeforeSolve()
|
||||
{
|
||||
var session = Session(true);
|
||||
var original = session.GetPlate(0).Parts[0];
|
||||
var engine = Engine((_, _) => throw new Xunit.Sdk.XunitException("must not solve occupied stock"));
|
||||
Assert.Contains("occupied", CallMcp(new NestingTools(session), engine));
|
||||
Assert.Same(original, Assert.Single(session.GetPlate(0).Parts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mcp_CancellationDoesNotCommit()
|
||||
{
|
||||
var session = Session();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var engine = Engine((job, _) => { cts.Cancel(); return Result(job, "valid"); });
|
||||
Assert.ThrowsAny<OperationCanceledException>(() => CallMcp(new NestingTools(session), engine, token: cts.Token));
|
||||
Assert.Empty(session.GetPlate(0).Parts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownEngineDoesNotWriteOrCommit()
|
||||
{
|
||||
var session = Session();
|
||||
Assert.Contains("unknown", CallMcp(new NestingTools(session), "missing-engine"), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Empty(session.GetPlate(0).Parts);
|
||||
var input = Path.Combine(directory, "input.nest");
|
||||
var output = Path.Combine(directory, "output.nest");
|
||||
Assert.True(new NestWriter(session.Nest).Write(input));
|
||||
Assert.Equal(1, RunConsole(input, "--autonest", "--engine", "missing-engine", "--output", output));
|
||||
Assert.False(File.Exists(output));
|
||||
}
|
||||
|
||||
private NestRequest Request(string engine)
|
||||
{
|
||||
var path = Path.Combine(directory, "square.dxf");
|
||||
Dxf.ExportProgram(Square().Program, path);
|
||||
var request = new NestRequest
|
||||
{
|
||||
Parts = [new NestRequestPart { Id = "external-id", DxfPath = path, Quantity = 2, AllowRotation = false }],
|
||||
Plates = [new NestRequestPlate { Id = "stock", Size = new Size(20, 30), Quantity = 1 }],
|
||||
};
|
||||
typeof(NestRequest).GetProperty("Engine")?.SetValue(request, engine);
|
||||
return request;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("valid", 1, "Valid")]
|
||||
[InlineData("empty", 0, "Valid")]
|
||||
[InlineData("overlap", 2, "Invalid")]
|
||||
[InlineData("unknown", 0, "Unrepresentable")]
|
||||
[InlineData("nan", 0, "Unrepresentable")]
|
||||
public async Task Api_PluginSelectionDerivesCountsPreservesIdsAndReportsValidation(string mode, int placed, string validation)
|
||||
{
|
||||
var called = false;
|
||||
var engine = Engine((job, _) => { called = true; return Result(job, mode); });
|
||||
var response = await NestRunner.RunAsync(Request(engine));
|
||||
Assert.True(called);
|
||||
var fulfillment = Assert.Single(response.Fulfillment);
|
||||
Assert.Equal("external-id", fulfillment.PartId);
|
||||
Assert.Equal(placed, fulfillment.Placed);
|
||||
Assert.Equal(2 - placed, fulfillment.Unplaced);
|
||||
Assert.Equal(placed == 2 ? NestJobStatus.Complete : NestJobStatus.Incomplete, response.Status);
|
||||
Assert.Equal(validation, typeof(NestResponse).GetProperty("ValidationStatus")?.GetValue(response)?.ToString());
|
||||
Assert.Equal(placed > 0 ? 1 : 0, Assert.Single(response.StockUsage).Used);
|
||||
Assert.Equal(placed, response.Nest.Plates.Sum(p => p.Parts.Count));
|
||||
Assert.Equal("external-id", Assert.Single(response.Nest.Drawings).Name);
|
||||
var archive = Path.Combine(directory, "result.nestquote");
|
||||
await response.SaveAsync(archive);
|
||||
var loaded = await NestResponse.LoadAsync(archive);
|
||||
Assert.Equal(validation, typeof(NestResponse).GetProperty("ValidationStatus")?.GetValue(loaded)?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mcp_SessionGateSerializesCallsBeforeCheckingOccupiedTarget()
|
||||
{
|
||||
var session = Session();
|
||||
var tool = new NestingTools(session);
|
||||
var engine = Engine((job, _) => Result(job, "valid"));
|
||||
using var gate = new SessionToolGate();
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var first = gate.RunAsync(async token =>
|
||||
{
|
||||
entered.SetResult();
|
||||
await release.Task;
|
||||
return CallMcp(tool, engine, token: token);
|
||||
}, CancellationToken.None).AsTask();
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
var second = gate.RunAsync(token => new ValueTask<string>(CallMcp(tool, engine, token: token)),
|
||||
CancellationToken.None).AsTask();
|
||||
try
|
||||
{
|
||||
Assert.False(second.IsCompleted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.TrySetResult();
|
||||
}
|
||||
Assert.Contains("success", await first.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
Assert.Contains("occupied", await second.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
Assert.Single(session.GetPlate(0).Parts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Console_CancelledOrdinaryFillDoesNotOverwriteOutput()
|
||||
{
|
||||
var input = Path.Combine(directory, "input.nest");
|
||||
var output = Path.Combine(directory, "output.nest");
|
||||
Assert.True(new NestWriter(Session(true).Nest).Write(input));
|
||||
File.WriteAllText(output, "existing output");
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var run = Assembly.Load("OpenNest.Console").GetType("NestConsole")!
|
||||
.GetMethod("RunCore", BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
var error = Assert.Throws<TargetInvocationException>(() => run.Invoke(null,
|
||||
new object[] { new[] { input, "--quantity", "1", "--output", output }, cts.Token }));
|
||||
Assert.IsAssignableFrom<OperationCanceledException>(error.InnerException);
|
||||
Assert.Equal("existing output", File.ReadAllText(output));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Api_UnknownEngineAndCancellationPropagate()
|
||||
{
|
||||
await Assert.ThrowsAsync<NotSupportedException>(() => NestRunner.RunAsync(Request("missing-engine")));
|
||||
using var cts = new CancellationTokenSource();
|
||||
var engine = Engine((job, _) => { cts.Cancel(); return Result(job, "valid"); });
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => NestRunner.RunAsync(Request(engine), token: cts.Token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</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.Console/OpenNest.Console.csproj" />
|
||||
<ProjectReference Include="../OpenNest.Mcp/OpenNest.Mcp.csproj" />
|
||||
<ProjectReference Include="../OpenNest.Api/OpenNest.Api.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
using ACadSharp;
|
||||
using ACadSharp.IO;
|
||||
using CSMath;
|
||||
using OpenNest.Api;
|
||||
using OpenNest.Bending;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO.Bending;
|
||||
using OpenNest.Mcp;
|
||||
using OpenNest.Mcp.Tools;
|
||||
using CadLayer = ACadSharp.Tables.Layer;
|
||||
using CadLine = ACadSharp.Entities.Line;
|
||||
|
||||
namespace OpenNest.FrontEnd.Tests;
|
||||
|
||||
// Console redirection and both registries are process-static. Reuse the existing
|
||||
// nonparallel front-end collection; these tests require a Windows runtime.
|
||||
[Collection("FrontEndRegistry")]
|
||||
public class RegexImportFailureTests : IDisposable
|
||||
{
|
||||
private const string MarkerLayer = "TESTTIMEOUT";
|
||||
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-regex-frontends-" + Guid.NewGuid());
|
||||
private readonly MarkerDetector detector = new();
|
||||
|
||||
public RegexImportFailureTests()
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
BendDetectorRegistry.Register(detector);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// No unregister API: retained registrations become inert, and active
|
||||
// registrations only throw for the marker document, never ordinary imports.
|
||||
detector.Active = false;
|
||||
Directory.Delete(directory, true);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Console_TimeoutExitsOneWithoutOutputEvenAfterEarlierImport(bool earlierSuccess)
|
||||
{
|
||||
var bad = WriteDxf("marker.dxf", marker: true);
|
||||
var hash = Hash(bad);
|
||||
var output = Path.Combine(directory, "output.nest");
|
||||
var args = new List<string>();
|
||||
if (earlierSuccess)
|
||||
args.Add(WriteDxf("good.dxf", marker: false));
|
||||
args.AddRange(new[] { bad, "--size", "20x30", "--output", output,
|
||||
"--repair-bends-mm", "2", "--cad-units", "inches" });
|
||||
var originalOut = System.Console.Out;
|
||||
var originalError = System.Console.Error;
|
||||
using var stdout = new StringWriter();
|
||||
using var stderr = new StringWriter();
|
||||
try
|
||||
{
|
||||
System.Console.SetOut(stdout);
|
||||
System.Console.SetError(stderr);
|
||||
Assert.Equal(1, RunConsole(args.ToArray()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.Console.SetOut(originalOut);
|
||||
System.Console.SetError(originalError);
|
||||
}
|
||||
|
||||
Assert.Equal(1, detector.CallCount);
|
||||
Assert.Contains("Error: failed to import DXF", stderr.ToString());
|
||||
Assert.Contains(bad, stderr.ToString());
|
||||
if (earlierSuccess)
|
||||
Assert.Contains("Imported: good", stdout.ToString());
|
||||
Assert.DoesNotContain("Bend repair", stdout.ToString());
|
||||
Assert.False(File.Exists(output));
|
||||
Assert.Equal(hash, Hash(bad));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Api_TimeoutRetainsInnerExceptionAndNeverSolvesOrReturnsResponse(bool earlierSuccess)
|
||||
{
|
||||
var bad = WriteDxf("marker.dxf", marker: true);
|
||||
var hash = Hash(bad);
|
||||
var engine = new CountingEngine();
|
||||
var engineName = "RegexImportFailure-" + Guid.NewGuid();
|
||||
NestingEngineRegistry.Register(engineName, "test", () => engine);
|
||||
var parts = new List<NestRequestPart>();
|
||||
if (earlierSuccess)
|
||||
parts.Add(new NestRequestPart { Id = "good", DxfPath = WriteDxf("good.dxf", marker: false), Quantity = 1 });
|
||||
parts.Add(new NestRequestPart { Id = "bad", DxfPath = bad, Quantity = 1 });
|
||||
var request = new NestRequest
|
||||
{
|
||||
Parts = parts,
|
||||
Plates = [new NestRequestPlate { Id = "stock", Size = new Size(20, 30), Quantity = 1 }],
|
||||
Engine = engineName,
|
||||
};
|
||||
NestResponse? response = null;
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
response = await NestRunner.RunAsync(request));
|
||||
|
||||
Assert.IsType<RegexMatchTimeoutException>(exception.InnerException);
|
||||
Assert.Same(detector.Timeout, exception.InnerException);
|
||||
Assert.Contains(bad, exception.Message);
|
||||
Assert.Equal(1, detector.CallCount);
|
||||
Assert.Equal(0, engine.CallCount);
|
||||
Assert.Null(response);
|
||||
Assert.Equal(hash, Hash(bad));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mcp_TimeoutReturnsErrorWithoutAddingDrawingOrChangingEarlierDrawing()
|
||||
{
|
||||
var good = WriteDxf("good.dxf", marker: false);
|
||||
var bad = WriteDxf("marker.dxf", marker: true);
|
||||
var hash = Hash(bad);
|
||||
var session = new NestSession();
|
||||
var tools = new InputTools(session);
|
||||
Assert.StartsWith("Imported drawing", tools.ImportDxf(good));
|
||||
var earlier = Assert.Single(session.Drawings);
|
||||
var earlierProgram = earlier.Program;
|
||||
var count = session.Drawings.Count;
|
||||
|
||||
var result = tools.ImportDxf(bad);
|
||||
|
||||
Assert.StartsWith("Error:", result);
|
||||
Assert.Contains(bad, result);
|
||||
Assert.Equal(count, session.Drawings.Count);
|
||||
Assert.Same(earlier, Assert.Single(session.Drawings));
|
||||
Assert.Same(earlierProgram, earlier.Program);
|
||||
Assert.Equal(1, detector.CallCount);
|
||||
Assert.Equal(hash, Hash(bad));
|
||||
}
|
||||
|
||||
private static int RunConsole(params string[] args)
|
||||
{
|
||||
var method = Assembly.Load("OpenNest.Console").GetType("NestConsole")!
|
||||
.GetMethod("Run", BindingFlags.Static | BindingFlags.Public)!;
|
||||
return (int)method.Invoke(null, new object[] { args })!;
|
||||
}
|
||||
|
||||
private string WriteDxf(string name, bool marker)
|
||||
{
|
||||
var document = new CadDocument();
|
||||
document.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Inches;
|
||||
document.Entities.Add(new CadLine(new XYZ(0, 0, 0), new XYZ(2, 0, 0))
|
||||
{
|
||||
Layer = new CadLayer(marker ? MarkerLayer : "0"),
|
||||
});
|
||||
document.Entities.Add(new CadLine(new XYZ(2, 0, 0), new XYZ(2, 2, 0)));
|
||||
document.Entities.Add(new CadLine(new XYZ(2, 2, 0), new XYZ(0, 2, 0)));
|
||||
document.Entities.Add(new CadLine(new XYZ(0, 2, 0), new XYZ(0, 0, 0)));
|
||||
var path = Path.Combine(directory, name);
|
||||
DxfWriter.Write(path, document, false);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static byte[] Hash(string path) => SHA256.HashData(File.ReadAllBytes(path));
|
||||
|
||||
private sealed class MarkerDetector : IBendDetector
|
||||
{
|
||||
public string Name { get; } = "FrontEndRegexTimeout-" + Guid.NewGuid();
|
||||
public bool Active { get; set; } = true;
|
||||
public int CallCount { get; private set; }
|
||||
public RegexMatchTimeoutException Timeout { get; } = new("marker", "<test>", TimeSpan.FromSeconds(1));
|
||||
|
||||
public List<Bend> DetectBends(CadDocument document)
|
||||
{
|
||||
if (!Active || !document.Entities.Any(e => e.Layer?.Name == MarkerLayer))
|
||||
return new List<Bend>();
|
||||
CallCount++;
|
||||
throw Timeout;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CountingEngine : INestingEngine
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
CallCount++;
|
||||
throw new InvalidOperationException("Import failure must abort before solving.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ILGPU;
|
||||
using ILGPU.Algorithms;
|
||||
using ILGPU.Runtime;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Gpu
|
||||
{
|
||||
@@ -19,7 +24,7 @@ namespace OpenNest.Gpu
|
||||
ArrayView1D<double, Stride1D.Dense>, // stationaryPrep
|
||||
ArrayView1D<double, Stride1D.Dense>, // movingPrep
|
||||
ArrayView1D<double, Stride1D.Dense>, // offsets
|
||||
ArrayView1D<double, Stride1D.Dense>, // results
|
||||
ArrayView1D<ContactWitness, Stride1D.Dense>, // results
|
||||
int,
|
||||
int,
|
||||
int
|
||||
@@ -30,7 +35,7 @@ namespace OpenNest.Gpu
|
||||
ArrayView1D<double, Stride1D.Dense>, // stationaryPrep
|
||||
ArrayView1D<double, Stride1D.Dense>, // movingPrep
|
||||
ArrayView1D<double, Stride1D.Dense>, // offsets
|
||||
ArrayView1D<double, Stride1D.Dense>, // results
|
||||
ArrayView1D<ContactWitness, Stride1D.Dense>, // results
|
||||
ArrayView1D<int, Stride1D.Dense>, // directions
|
||||
int,
|
||||
int
|
||||
@@ -47,22 +52,24 @@ namespace OpenNest.Gpu
|
||||
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryRaw;
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryPrep;
|
||||
private double[]? _lastStationaryData; // Keep CPU copy/ref for content check
|
||||
private double[]? _lastStationaryData; // Active segment snapshot used for upload and contact topology
|
||||
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuMovingRaw;
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuMovingPrep;
|
||||
private double[]? _lastMovingData; // Keep CPU copy/ref for content check
|
||||
private double[]? _lastMovingData; // Active segment snapshot used for upload and contact topology
|
||||
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuOffsets;
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuResults;
|
||||
private MemoryBuffer1D<ContactWitness, Stride1D.Dense>? _gpuResults;
|
||||
private MemoryBuffer1D<int, Stride1D.Dense>? _gpuDirs;
|
||||
private int _offsetCapacity;
|
||||
|
||||
public GpuSlideComputer()
|
||||
public GpuSlideComputer() : this(preferCPU: false) { }
|
||||
|
||||
public GpuSlideComputer(bool preferCPU)
|
||||
{
|
||||
_context = Context.CreateDefault();
|
||||
_accelerator = _context
|
||||
.GetPreferredDevice(preferCPU: false)
|
||||
.GetPreferredDevice(preferCPU)
|
||||
.CreateAccelerator(_context);
|
||||
|
||||
_kernel = _accelerator.LoadAutoGroupedStreamKernel<
|
||||
@@ -70,7 +77,7 @@ namespace OpenNest.Gpu
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<ContactWitness, Stride1D.Dense>,
|
||||
int,
|
||||
int,
|
||||
int
|
||||
@@ -81,7 +88,7 @@ namespace OpenNest.Gpu
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<ContactWitness, Stride1D.Dense>,
|
||||
ArrayView1D<int, Stride1D.Dense>,
|
||||
int,
|
||||
int
|
||||
@@ -118,21 +125,24 @@ namespace OpenNest.Gpu
|
||||
EnsureMoving(movingTemplateSegments, movingCount);
|
||||
EnsureOffsetBuffers(offsetCount);
|
||||
|
||||
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets);
|
||||
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(
|
||||
_accelerator.DefaultStream, (ReadOnlySpan<double>)offsets.AsSpan(0, offsetCount * 2));
|
||||
|
||||
_kernel(
|
||||
offsetCount,
|
||||
_gpuStationaryPrep!.View,
|
||||
_gpuMovingPrep!.View,
|
||||
_gpuOffsets.View,
|
||||
_gpuResults!.View,
|
||||
_gpuOffsets.View.SubView(0, offsetCount * 2),
|
||||
_gpuResults!.View.SubView(0, offsetCount),
|
||||
stationaryCount,
|
||||
movingCount,
|
||||
(int)direction
|
||||
);
|
||||
|
||||
_accelerator.Synchronize();
|
||||
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(results);
|
||||
var witnesses = new ContactWitness[offsetCount];
|
||||
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(witnesses);
|
||||
ResolveContacts(witnesses, offsets, results, direction, null);
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -161,93 +171,138 @@ namespace OpenNest.Gpu
|
||||
EnsureMoving(movingTemplateSegments, movingCount);
|
||||
EnsureOffsetBuffers(offsetCount);
|
||||
|
||||
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets);
|
||||
_gpuDirs!.View.SubView(0, offsetCount).CopyFromCPU(directions);
|
||||
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(
|
||||
_accelerator.DefaultStream, (ReadOnlySpan<double>)offsets.AsSpan(0, offsetCount * 2));
|
||||
_gpuDirs!.View.SubView(0, offsetCount).CopyFromCPU(
|
||||
_accelerator.DefaultStream, (ReadOnlySpan<int>)directions.AsSpan(0, offsetCount));
|
||||
|
||||
_kernelMultiDir(
|
||||
offsetCount,
|
||||
_gpuStationaryPrep!.View,
|
||||
_gpuMovingPrep!.View,
|
||||
_gpuOffsets.View,
|
||||
_gpuResults!.View,
|
||||
_gpuDirs.View,
|
||||
_gpuOffsets.View.SubView(0, offsetCount * 2),
|
||||
_gpuResults!.View.SubView(0, offsetCount),
|
||||
_gpuDirs.View.SubView(0, offsetCount),
|
||||
stationaryCount,
|
||||
movingCount
|
||||
);
|
||||
|
||||
_accelerator.Synchronize();
|
||||
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(results);
|
||||
var witnesses = new ContactWitness[offsetCount];
|
||||
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(witnesses);
|
||||
ResolveContacts(witnesses, offsets, results, default, directions);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public void InvalidateStationary() => _lastStationaryData = null;
|
||||
public void InvalidateStationary()
|
||||
{
|
||||
lock (_lock)
|
||||
_lastStationaryData = null;
|
||||
}
|
||||
|
||||
public void InvalidateMoving() => _lastMovingData = null;
|
||||
public void InvalidateMoving()
|
||||
{
|
||||
lock (_lock)
|
||||
_lastMovingData = null;
|
||||
}
|
||||
|
||||
private void ResolveContacts(
|
||||
ContactWitness[] witnesses,
|
||||
double[] offsets,
|
||||
double[] results,
|
||||
PushDirection direction,
|
||||
int[]? directions
|
||||
)
|
||||
{
|
||||
var moving = default(List<Line>);
|
||||
var stationary = default(List<Line>);
|
||||
var contacts = default(SlideContactClassifier);
|
||||
for (var i = 0; i < witnesses.Length; i++)
|
||||
{
|
||||
var witness = witnesses[i];
|
||||
results[i] = witness.Distance;
|
||||
if (witness.Distance == double.MaxValue)
|
||||
continue;
|
||||
|
||||
// GPU finds the nearest event. Prepare the complete boundaries only
|
||||
// once per batch, and share their material-side topology at each offset.
|
||||
if (contacts == null)
|
||||
{
|
||||
moving = ToLines(_lastMovingData!);
|
||||
stationary = ToLines(_lastStationaryData!);
|
||||
contacts = SlideContactClassifier.FromLines(
|
||||
moving, Vector.Zero, stationary, Vector.Zero).Prepare();
|
||||
}
|
||||
|
||||
var offset = new Vector(offsets[i * 2], offsets[i * 2 + 1]);
|
||||
var push = directions == null ? direction : (PushDirection)directions[i];
|
||||
var unit = SpatialQuery.DirectionToOffset(push, 1);
|
||||
var placed = contacts.At(offset, Vector.Zero);
|
||||
if (placed.Blocks(
|
||||
new Vector(witness.MovingX, witness.MovingY),
|
||||
new Vector(witness.StationaryX, witness.StationaryY), unit.X, unit.Y))
|
||||
continue;
|
||||
|
||||
// A departing or grazing event does not discard the obstacle: replay
|
||||
// all events through the shared resolver to find the next blocking one,
|
||||
// including another contact tied at the same distance.
|
||||
results[i] = SpatialQuery.DirectionalDistance(
|
||||
moving!, offset.X, offset.Y, stationary!, push, placed);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Line> ToLines(double[] segments)
|
||||
{
|
||||
var lines = new List<Line>(segments.Length / 4);
|
||||
for (var i = 0; i < segments.Length; i += 4)
|
||||
lines.Add(new Line(segments[i], segments[i + 1], segments[i + 2], segments[i + 3]));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private void EnsureStationary(double[] data, int count)
|
||||
{
|
||||
// Fast check: if same object or content is identical, skip upload
|
||||
if (
|
||||
_gpuStationaryPrep != null
|
||||
&& _lastStationaryData != null
|
||||
&& _lastStationaryData.Length == data.Length
|
||||
)
|
||||
{
|
||||
// Reference equality or content equality
|
||||
if (
|
||||
_lastStationaryData == data
|
||||
|| new ReadOnlySpan<double>(_lastStationaryData).SequenceEqual(
|
||||
new ReadOnlySpan<double>(data)
|
||||
)
|
||||
)
|
||||
{
|
||||
// Cache the active prefix by value: callers may reuse an array with a
|
||||
// different segment count or mutate its coordinates between batches.
|
||||
var active = data.AsSpan(0, count * 4);
|
||||
if (_gpuStationaryPrep != null && _lastStationaryData != null
|
||||
&& active.SequenceEqual(_lastStationaryData))
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_gpuStationaryRaw?.Dispose();
|
||||
_gpuStationaryPrep?.Dispose();
|
||||
|
||||
_gpuStationaryRaw = _accelerator.Allocate1D(data);
|
||||
var snapshot = active.ToArray();
|
||||
_gpuStationaryRaw = _accelerator.Allocate1D(snapshot);
|
||||
_gpuStationaryPrep = _accelerator.Allocate1D<double>(count * 10);
|
||||
|
||||
_prepareKernel(count, _gpuStationaryRaw.View, _gpuStationaryPrep.View, count);
|
||||
_accelerator.Synchronize();
|
||||
|
||||
_lastStationaryData = data; // store reference for next comparison
|
||||
_lastStationaryData = snapshot;
|
||||
}
|
||||
|
||||
private void EnsureMoving(double[] data, int count)
|
||||
{
|
||||
if (
|
||||
_gpuMovingPrep != null
|
||||
&& _lastMovingData != null
|
||||
&& _lastMovingData.Length == data.Length
|
||||
)
|
||||
{
|
||||
if (
|
||||
_lastMovingData == data
|
||||
|| new ReadOnlySpan<double>(_lastMovingData).SequenceEqual(
|
||||
new ReadOnlySpan<double>(data)
|
||||
)
|
||||
)
|
||||
{
|
||||
// Cache the active prefix by value: callers may reuse an array with a
|
||||
// different segment count or mutate its coordinates between batches.
|
||||
var active = data.AsSpan(0, count * 4);
|
||||
if (_gpuMovingPrep != null && _lastMovingData != null
|
||||
&& active.SequenceEqual(_lastMovingData))
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_gpuMovingRaw?.Dispose();
|
||||
_gpuMovingPrep?.Dispose();
|
||||
|
||||
_gpuMovingRaw = _accelerator.Allocate1D(data);
|
||||
var snapshot = active.ToArray();
|
||||
_gpuMovingRaw = _accelerator.Allocate1D(snapshot);
|
||||
_gpuMovingPrep = _accelerator.Allocate1D<double>(count * 10);
|
||||
|
||||
_prepareKernel(count, _gpuMovingRaw.View, _gpuMovingPrep.View, count);
|
||||
_accelerator.Synchronize();
|
||||
|
||||
_lastMovingData = data;
|
||||
_lastMovingData = snapshot;
|
||||
}
|
||||
|
||||
private void EnsureOffsetBuffers(int offsetCount)
|
||||
@@ -262,7 +317,7 @@ namespace OpenNest.Gpu
|
||||
_gpuDirs?.Dispose();
|
||||
|
||||
_gpuOffsets = _accelerator.Allocate1D<double>(newCapacity * 2);
|
||||
_gpuResults = _accelerator.Allocate1D<double>(newCapacity);
|
||||
_gpuResults = _accelerator.Allocate1D<ContactWitness>(newCapacity);
|
||||
_gpuDirs = _accelerator.Allocate1D<int>(newCapacity);
|
||||
|
||||
_offsetCapacity = newCapacity;
|
||||
@@ -293,8 +348,8 @@ namespace OpenNest.Gpu
|
||||
var dy = y2 - y1;
|
||||
|
||||
// invD is used for parameter 't'. We use a small epsilon for stability.
|
||||
prepared[index * 10 + 4] = (XMath.Abs(dx) < 1e-9) ? 0 : 1.0 / dx;
|
||||
prepared[index * 10 + 5] = (XMath.Abs(dy) < 1e-9) ? 0 : 1.0 / dy;
|
||||
prepared[index * 10 + 4] = (XMath.Abs(dx) < Tolerance.Epsilon) ? 0 : 1.0 / dx;
|
||||
prepared[index * 10 + 5] = (XMath.Abs(dy) < Tolerance.Epsilon) ? 0 : 1.0 / dy;
|
||||
|
||||
prepared[index * 10 + 6] = XMath.Min(x1, x2);
|
||||
prepared[index * 10 + 7] = XMath.Max(x1, x2);
|
||||
@@ -304,12 +359,48 @@ namespace OpenNest.Gpu
|
||||
|
||||
// ── Main Slide Kernels ───────────────────────────────────────
|
||||
|
||||
// Public because ILGPU's CPU backend emits kernel argument types in a separate assembly.
|
||||
public struct ContactWitness
|
||||
{
|
||||
public double Distance;
|
||||
public double MovingX;
|
||||
public double MovingY;
|
||||
public double StationaryX;
|
||||
public double StationaryY;
|
||||
}
|
||||
|
||||
private static void Consider(
|
||||
ref ContactWitness nearest,
|
||||
double distance,
|
||||
double vx,
|
||||
double vy,
|
||||
int rayDirection,
|
||||
bool vertexMoves
|
||||
)
|
||||
{
|
||||
var snapped = distance > Tolerance.Epsilon ? distance : 0;
|
||||
if (snapped >= nearest.Distance)
|
||||
return;
|
||||
|
||||
// Use the unsnapped hit for incidence; snapping a tiny gap to zero must
|
||||
// not move the witness off the other boundary.
|
||||
var dirX = rayDirection == 2 ? -1 : rayDirection == 3 ? 1 : 0;
|
||||
var dirY = rayDirection == 0 ? 1 : rayDirection == 1 ? -1 : 0;
|
||||
var hx = vx + distance * dirX;
|
||||
var hy = vy + distance * dirY;
|
||||
nearest.Distance = snapped;
|
||||
nearest.MovingX = vertexMoves ? vx : hx;
|
||||
nearest.MovingY = vertexMoves ? vy : hy;
|
||||
nearest.StationaryX = vertexMoves ? hx : vx;
|
||||
nearest.StationaryY = vertexMoves ? hy : vy;
|
||||
}
|
||||
|
||||
private static void SlideKernel(
|
||||
Index1D index,
|
||||
ArrayView1D<double, Stride1D.Dense> stationaryPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> movingPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> offsets,
|
||||
ArrayView1D<double, Stride1D.Dense> results,
|
||||
ArrayView1D<ContactWitness, Stride1D.Dense> results,
|
||||
int sCount,
|
||||
int mCount,
|
||||
int direction
|
||||
@@ -337,7 +428,7 @@ namespace OpenNest.Gpu
|
||||
ArrayView1D<double, Stride1D.Dense> stationaryPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> movingPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> offsets,
|
||||
ArrayView1D<double, Stride1D.Dense> results,
|
||||
ArrayView1D<ContactWitness, Stride1D.Dense> results,
|
||||
ArrayView1D<int, Stride1D.Dense> directions,
|
||||
int sCount,
|
||||
int mCount
|
||||
@@ -361,7 +452,7 @@ namespace OpenNest.Gpu
|
||||
);
|
||||
}
|
||||
|
||||
private static double ComputeSlideLean(
|
||||
private static ContactWitness ComputeSlideLean(
|
||||
ArrayView1D<double, Stride1D.Dense> sPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> mPrep,
|
||||
double dx,
|
||||
@@ -371,20 +462,20 @@ namespace OpenNest.Gpu
|
||||
int direction
|
||||
)
|
||||
{
|
||||
const double eps = 0.00001;
|
||||
var minDist = double.MaxValue;
|
||||
const double eps = Tolerance.Epsilon;
|
||||
var nearest = new ContactWitness { Distance = double.MaxValue };
|
||||
var horizontal = direction >= 2;
|
||||
var oppDir = direction ^ 1;
|
||||
|
||||
// ── Forward Pass: moving vertices vs stationary edges ─────
|
||||
for (int i = 0; i < mCount; i++)
|
||||
for (var i = 0; i < mCount; i++)
|
||||
{
|
||||
var m1x = mPrep[i * 10 + 0] + dx;
|
||||
var m1y = mPrep[i * 10 + 1] + dy;
|
||||
var m2x = mPrep[i * 10 + 2] + dx;
|
||||
var m2y = mPrep[i * 10 + 3] + dy;
|
||||
|
||||
for (int j = 0; j < sCount; j++)
|
||||
for (var j = 0; j < sCount; j++)
|
||||
{
|
||||
var sMin = horizontal ? sPrep[j * 10 + 8] : sPrep[j * 10 + 6];
|
||||
var sMax = horizontal ? sPrep[j * 10 + 9] : sPrep[j * 10 + 7];
|
||||
@@ -394,8 +485,8 @@ namespace OpenNest.Gpu
|
||||
if (mv1 >= sMin - eps && mv1 <= sMax + eps)
|
||||
{
|
||||
var d = RayEdgeLean(m1x, m1y, sPrep, j, direction, eps);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
Consider(ref nearest, d, m1x, m1y,
|
||||
direction, vertexMoves: true);
|
||||
}
|
||||
|
||||
// Test moving vertex 2 against stationary edge j
|
||||
@@ -403,21 +494,21 @@ namespace OpenNest.Gpu
|
||||
if (mv2 >= sMin - eps && mv2 <= sMax + eps)
|
||||
{
|
||||
var d = RayEdgeLean(m2x, m2y, sPrep, j, direction, eps);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
Consider(ref nearest, d, m2x, m2y,
|
||||
direction, vertexMoves: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reverse Pass: stationary vertices vs moving edges ─────
|
||||
for (int i = 0; i < sCount; i++)
|
||||
for (var i = 0; i < sCount; i++)
|
||||
{
|
||||
var s1x = sPrep[i * 10 + 0];
|
||||
var s1y = sPrep[i * 10 + 1];
|
||||
var s2x = sPrep[i * 10 + 2];
|
||||
var s2y = sPrep[i * 10 + 3];
|
||||
|
||||
for (int j = 0; j < mCount; j++)
|
||||
for (var j = 0; j < mCount; j++)
|
||||
{
|
||||
var mMin = horizontal ? (mPrep[j * 10 + 8] + dy) : (mPrep[j * 10 + 6] + dx);
|
||||
var mMax = horizontal ? (mPrep[j * 10 + 9] + dy) : (mPrep[j * 10 + 7] + dx);
|
||||
@@ -427,8 +518,8 @@ namespace OpenNest.Gpu
|
||||
if (sv1 >= mMin - eps && sv1 <= mMax + eps)
|
||||
{
|
||||
var d = RayEdgeLeanMoving(s1x, s1y, mPrep, j, dx, dy, oppDir, eps);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
Consider(ref nearest, d, s1x, s1y,
|
||||
oppDir, vertexMoves: false);
|
||||
}
|
||||
|
||||
// Test stationary vertex 2 against moving edge j
|
||||
@@ -436,13 +527,13 @@ namespace OpenNest.Gpu
|
||||
if (sv2 >= mMin - eps && sv2 <= mMax + eps)
|
||||
{
|
||||
var d = RayEdgeLeanMoving(s2x, s2y, mPrep, j, dx, dy, oppDir, eps);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
Consider(ref nearest, d, s2x, s2y,
|
||||
oppDir, vertexMoves: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
return nearest;
|
||||
}
|
||||
|
||||
private static double RayEdgeLean(
|
||||
@@ -472,9 +563,7 @@ namespace OpenNest.Gpu
|
||||
var ix = p1x + t * (p2x - p1x);
|
||||
var dist = (direction == 2) ? (vx - ix) : (ix - vx);
|
||||
|
||||
if (dist > eps)
|
||||
return dist;
|
||||
return (dist >= -eps) ? 0.0 : double.MaxValue;
|
||||
return dist >= -eps ? dist : double.MaxValue;
|
||||
}
|
||||
else // Vertical (Up=0, Down=1)
|
||||
{
|
||||
@@ -489,9 +578,7 @@ namespace OpenNest.Gpu
|
||||
var iy = p1y + t * (p2y - p1y);
|
||||
var dist = (direction == 1) ? (vy - iy) : (iy - vy);
|
||||
|
||||
if (dist > eps)
|
||||
return dist;
|
||||
return (dist >= -eps) ? 0.0 : double.MaxValue;
|
||||
return dist >= -eps ? dist : double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,9 +611,7 @@ namespace OpenNest.Gpu
|
||||
var ix = p1x + t * (p2x - p1x);
|
||||
var dist = (direction == 2) ? (vx - ix) : (ix - vx);
|
||||
|
||||
if (dist > eps)
|
||||
return dist;
|
||||
return (dist >= -eps) ? 0.0 : double.MaxValue;
|
||||
return dist >= -eps ? dist : double.MaxValue;
|
||||
}
|
||||
else // Vertical
|
||||
{
|
||||
@@ -541,9 +626,7 @@ namespace OpenNest.Gpu
|
||||
var iy = p1y + t * (p2y - p1y);
|
||||
var dist = (direction == 1) ? (vy - iy) : (iy - vy);
|
||||
|
||||
if (dist > eps)
|
||||
return dist;
|
||||
return (dist >= -eps) ? 0.0 : double.MaxValue;
|
||||
return dist >= -eps ? dist : double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
using System.Text.Json;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using CuttingParametersSerializer = OpenNest.IO.CuttingParametersSerializer;
|
||||
|
||||
namespace OpenNest.IO.Tests;
|
||||
|
||||
public class CuttingParametersSerializerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("None")]
|
||||
[InlineData("Line")]
|
||||
[InlineData("Arc")]
|
||||
[InlineData("LineArc")]
|
||||
[InlineData("CleanHole")]
|
||||
[InlineData("LineLine")]
|
||||
public void SettingsRoundTrip_PreservesEveryLeadInType(string type)
|
||||
{
|
||||
var leadIn = CreateLeadIn(type);
|
||||
var original = new CuttingParameters
|
||||
{
|
||||
ExternalLeadIn = leadIn,
|
||||
InternalLeadIn = leadIn,
|
||||
ArcCircleLeadIn = leadIn,
|
||||
TabConfig = new NormalTab { Size = 0.42, TabLeadIn = leadIn },
|
||||
};
|
||||
|
||||
var restored = RoundTrip(original);
|
||||
|
||||
AssertEquivalent(leadIn, restored.ExternalLeadIn);
|
||||
AssertEquivalent(leadIn, restored.InternalLeadIn);
|
||||
AssertEquivalent(leadIn, restored.ArcCircleLeadIn);
|
||||
AssertEquivalent(leadIn, restored.TabConfig.TabLeadIn);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("None")]
|
||||
[InlineData("Line")]
|
||||
[InlineData("Arc")]
|
||||
public void SettingsRoundTrip_PreservesEveryLeadOutType(string type)
|
||||
{
|
||||
var leadOut = CreateLeadOut(type);
|
||||
var original = new CuttingParameters
|
||||
{
|
||||
ExternalLeadOut = leadOut,
|
||||
InternalLeadOut = leadOut,
|
||||
ArcCircleLeadOut = leadOut,
|
||||
TabConfig = new NormalTab { Size = 0.42, TabLeadOut = leadOut },
|
||||
};
|
||||
|
||||
var restored = RoundTrip(original);
|
||||
|
||||
AssertEquivalent(leadOut, restored.ExternalLeadOut);
|
||||
AssertEquivalent(leadOut, restored.InternalLeadOut);
|
||||
AssertEquivalent(leadOut, restored.ArcCircleLeadOut);
|
||||
AssertEquivalent(leadOut, restored.TabConfig.TabLeadOut);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Normal")]
|
||||
[InlineData("Machine")]
|
||||
[InlineData("Breaker")]
|
||||
public void SettingsRoundTrip_PreservesEveryTabType(string type)
|
||||
{
|
||||
var original = new CuttingParameters
|
||||
{
|
||||
TabsEnabled = true,
|
||||
TabConfig = CreateTab(type),
|
||||
};
|
||||
|
||||
AssertEquivalent(original, RoundTrip(original));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SequenceMethod.RightSide)]
|
||||
[InlineData(SequenceMethod.LeastCode)]
|
||||
[InlineData(SequenceMethod.Advanced)]
|
||||
[InlineData(SequenceMethod.BottomSide)]
|
||||
[InlineData(SequenceMethod.EdgeStart)]
|
||||
[InlineData(SequenceMethod.LeftSide)]
|
||||
[InlineData(SequenceMethod.RightSideAlt)]
|
||||
public void SettingsRoundTrip_PreservesAllFieldsAndSequenceMethods(SequenceMethod method)
|
||||
{
|
||||
var original = CreateParameters();
|
||||
original.Assignment.Method = method;
|
||||
original.Sequencing.Method = method;
|
||||
|
||||
AssertEquivalent(original, RoundTrip(original));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_LegacySettings_PreservesValuesAndUsesMissingFieldDefaults()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"externalLeadIn": { "type": "LineLine", "length1": 0.2, "angle1": 31,
|
||||
"length2": 0.4, "angle2": 62 },
|
||||
"externalLeadOut": { "type": "Line", "length": 0.5, "approachAngle": 43 },
|
||||
"internalLeadIn": { "type": "Arc", "radius": 0.12 },
|
||||
"internalLeadOut": { "type": "Arc", "radius": 0.09, "gapSize": 0 },
|
||||
"arcCircleLeadIn": { "type": "CleanHole", "lineLength": 0.8,
|
||||
"arcRadius": 0.3, "kerf": 0.04 },
|
||||
"arcCircleLeadOut": { "type": "None" },
|
||||
"tabsEnabled": true,
|
||||
"tabWidth": 0.375,
|
||||
"pierceClearance": 0.0625
|
||||
}
|
||||
""";
|
||||
|
||||
var restored = CuttingParametersSerializer.Deserialize(json);
|
||||
|
||||
var lineLine = Assert.IsType<LineLineLeadIn>(restored.ExternalLeadIn);
|
||||
Assert.Equal(0.2, lineLine.Length1);
|
||||
Assert.Equal(31, lineLine.ApproachAngle1);
|
||||
Assert.Equal(0.4, lineLine.Length2);
|
||||
Assert.Equal(62, lineLine.ApproachAngle2);
|
||||
var lineOut = Assert.IsType<LineLeadOut>(restored.ExternalLeadOut);
|
||||
Assert.Equal(0.5, lineOut.Length);
|
||||
Assert.Equal(43, lineOut.ApproachAngle);
|
||||
Assert.Equal(0.12, Assert.IsType<ArcLeadIn>(restored.InternalLeadIn).Radius);
|
||||
Assert.Equal(0.09, Assert.IsType<ArcLeadOut>(restored.InternalLeadOut).Radius);
|
||||
var cleanHole = Assert.IsType<CleanHoleLeadIn>(restored.ArcCircleLeadIn);
|
||||
Assert.Equal(0.8, cleanHole.LineLength);
|
||||
Assert.Equal(0.3, cleanHole.ArcRadius);
|
||||
Assert.Equal(0.04, cleanHole.Kerf);
|
||||
Assert.IsType<NoLeadOut>(restored.ArcCircleLeadOut);
|
||||
Assert.True(restored.TabsEnabled);
|
||||
Assert.Equal(0.375, Assert.IsType<NormalTab>(restored.TabConfig).Size);
|
||||
Assert.Equal(0.0625, restored.PierceClearance);
|
||||
Assert.False(restored.RoundLeadInAngles);
|
||||
Assert.Equal(5, restored.LeadInAngleIncrement);
|
||||
Assert.Equal(0, restored.AutoTabMinSize);
|
||||
Assert.Equal(0, restored.AutoTabMaxSize);
|
||||
AssertEquivalent(new AssignmentParameters(), restored.Assignment);
|
||||
AssertEquivalent(new SequenceParameters(), restored.Sequencing);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void Deserialize_LegacyNonpositiveAngleIncrement_UsesFiveDegrees(double increment)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new { leadInAngleIncrement = increment });
|
||||
|
||||
Assert.Equal(5, CuttingParametersSerializer.Deserialize(json).LeadInAngleIncrement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_EmptyObject_PreservesLegacyDefaults()
|
||||
{
|
||||
var restored = CuttingParametersSerializer.Deserialize("{}");
|
||||
|
||||
Assert.IsType<NoLeadIn>(restored.ExternalLeadIn);
|
||||
Assert.IsType<NoLeadIn>(restored.InternalLeadIn);
|
||||
Assert.IsType<NoLeadIn>(restored.ArcCircleLeadIn);
|
||||
Assert.IsType<NoLeadOut>(restored.ExternalLeadOut);
|
||||
Assert.IsType<NoLeadOut>(restored.InternalLeadOut);
|
||||
Assert.IsType<NoLeadOut>(restored.ArcCircleLeadOut);
|
||||
Assert.Equal(0, Assert.IsType<NormalTab>(restored.TabConfig).Size);
|
||||
Assert.Equal(0, restored.PierceClearance);
|
||||
Assert.Equal(5, restored.LeadInAngleIncrement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_JsonNull_ReturnsDomainDefaults()
|
||||
{
|
||||
AssertEquivalent(new CuttingParameters(), CuttingParametersSerializer.Deserialize("null"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-json")]
|
||||
[InlineData("{\"externalLeadIn\":17}")]
|
||||
public void Deserialize_MalformedSettings_ThrowsJsonException(string json)
|
||||
{
|
||||
Assert.Throws<JsonException>(() => CuttingParametersSerializer.Deserialize(json));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_UnknownLeadTypes_UsesNoLead()
|
||||
{
|
||||
var restored = CuttingParametersSerializer.Deserialize("""
|
||||
{ "externalLeadIn": { "type": "FutureLead" },
|
||||
"externalLeadOut": { "type": "FutureLead" } }
|
||||
""");
|
||||
|
||||
Assert.IsType<NoLeadIn>(restored.ExternalLeadIn);
|
||||
Assert.IsType<NoLeadOut>(restored.ExternalLeadOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsRoundTrip_NullTab_PreservesLegacyWidthFallback()
|
||||
{
|
||||
var original = new CuttingParameters { TabConfig = null };
|
||||
var json = CuttingParametersSerializer.Serialize(original);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
|
||||
Assert.Equal(0.25, document.RootElement.GetProperty("tabWidth").GetDouble());
|
||||
Assert.DoesNotContain('\n', json);
|
||||
Assert.Equal(0.25, Assert.IsType<NormalTab>(
|
||||
CuttingParametersSerializer.Deserialize(json).TabConfig).Size);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Normal")]
|
||||
[InlineData("Machine")]
|
||||
[InlineData("Breaker")]
|
||||
public void DtoRoundTrip_UsesNestJsonOptionsAndPreservesAllFields(string tabType)
|
||||
{
|
||||
var original = CreateParameters();
|
||||
original.TabConfig = CreateTab(tabType);
|
||||
original.LeadInAngleIncrement = 0;
|
||||
var dto = CuttingParametersSerializer.ToDto(original);
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, NestFormat.JsonOptions);
|
||||
var parsed = JsonSerializer.Deserialize<CuttingParametersDto>(json, NestFormat.JsonOptions);
|
||||
var restored = CuttingParametersSerializer.FromDto(parsed);
|
||||
|
||||
AssertEquivalent(original, restored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DtoRoundTrip_NullParameters_RemainNull()
|
||||
{
|
||||
Assert.Null(CuttingParametersSerializer.ToDto(null));
|
||||
Assert.Null(CuttingParametersSerializer.FromDto(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DtoRoundTrip_DefaultParameters_PreservesNullTab()
|
||||
{
|
||||
var original = new CuttingParameters();
|
||||
var dto = CuttingParametersSerializer.ToDto(original);
|
||||
var json = JsonSerializer.Serialize(dto, NestFormat.JsonOptions);
|
||||
var parsed = JsonSerializer.Deserialize<CuttingParametersDto>(json, NestFormat.JsonOptions);
|
||||
|
||||
AssertEquivalent(original, CuttingParametersSerializer.FromDto(parsed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DtoRoundTrip_NullAssignmentAndSequencing_RemainNull()
|
||||
{
|
||||
var original = new CuttingParameters { Assignment = null, Sequencing = null };
|
||||
var dto = CuttingParametersSerializer.ToDto(original);
|
||||
var json = JsonSerializer.Serialize(dto, NestFormat.JsonOptions);
|
||||
var parsed = JsonSerializer.Deserialize<CuttingParametersDto>(json, NestFormat.JsonOptions);
|
||||
|
||||
AssertEquivalent(original, CuttingParametersSerializer.FromDto(parsed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DtoMapping_SourceSnapshotAndRestoredParameters_AreIndependent()
|
||||
{
|
||||
var original = CreateParameters();
|
||||
var dto = CuttingParametersSerializer.ToDto(original);
|
||||
var restored = CuttingParametersSerializer.FromDto(dto);
|
||||
AssertEquivalent(original, restored);
|
||||
|
||||
original.Assignment.Preference = "changed source";
|
||||
original.Sequencing.SmallCutoutWidth = 101;
|
||||
((LineLineLeadIn)original.ExternalLeadIn).Length1 = 102;
|
||||
((BreakerTab)original.TabConfig).BreakerDepth = 103;
|
||||
((LineArcLeadIn)original.TabConfig.TabLeadIn).ArcRadius = 104;
|
||||
((ArcLeadOut)original.TabConfig.TabLeadOut).Radius = 105;
|
||||
AssertEquivalent(CreateParameters(), CuttingParametersSerializer.FromDto(dto));
|
||||
|
||||
dto.Assignment.Preference = "changed snapshot";
|
||||
dto.Sequencing.SmallCutoutWidth = 201;
|
||||
dto.ExternalLeadIn.Length1 = 202;
|
||||
dto.TabConfig.BreakerDepth = 203;
|
||||
dto.TabConfig.TabLeadIn.ArcRadius = 204;
|
||||
dto.TabConfig.TabLeadOut.Radius = 205;
|
||||
AssertEquivalent(CreateParameters(), restored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_UnknownTabType_DoesNotInventACutStrategy()
|
||||
{
|
||||
var restored = CuttingParametersSerializer.Deserialize("""
|
||||
{ "tabConfig": { "type": "FutureTab" }, "tabWidth": 0.7 }
|
||||
""");
|
||||
|
||||
Assert.Null(restored.TabConfig);
|
||||
}
|
||||
|
||||
private static CuttingParameters RoundTrip(CuttingParameters original) =>
|
||||
CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(original));
|
||||
|
||||
private static CuttingParameters CreateParameters() => new()
|
||||
{
|
||||
Id = 47,
|
||||
MachineName = "Laser A",
|
||||
MaterialName = "Steel",
|
||||
Grade = "A36",
|
||||
Thickness = 0.1875,
|
||||
Kerf = 0.018,
|
||||
PartSpacing = 0.23,
|
||||
ExternalLeadIn = CreateLeadIn("LineLine"),
|
||||
ExternalLeadOut = CreateLeadOut("Line"),
|
||||
InternalLeadIn = CreateLeadIn("CleanHole"),
|
||||
InternalLeadOut = CreateLeadOut("Arc"),
|
||||
ArcCircleLeadIn = CreateLeadIn("LineArc"),
|
||||
ArcCircleLeadOut = CreateLeadOut("None"),
|
||||
PierceClearance = 0.17,
|
||||
RoundLeadInAngles = true,
|
||||
LeadInAngleIncrement = 13,
|
||||
AutoTabMinSize = 0.62,
|
||||
AutoTabMaxSize = 4.7,
|
||||
TabConfig = CreateTab("Breaker"),
|
||||
TabsEnabled = true,
|
||||
Assignment = new AssignmentParameters
|
||||
{
|
||||
Method = SequenceMethod.EdgeStart,
|
||||
Preference = "TAIL",
|
||||
MinGeometryLength = 0.37,
|
||||
},
|
||||
Sequencing = new SequenceParameters
|
||||
{
|
||||
Method = SequenceMethod.LeftSide,
|
||||
SmallCutoutWidth = 2.3,
|
||||
SmallCutoutHeight = 3.4,
|
||||
MediumCutoutWidth = 9.5,
|
||||
MediumCutoutHeight = 10.6,
|
||||
DistanceMediumSmall = 1.7,
|
||||
AlternateRowsColumns = false,
|
||||
AlternateCutoutsWithinRowColumn = false,
|
||||
MinDistanceBetweenRowsColumns = 0.8,
|
||||
},
|
||||
};
|
||||
|
||||
private static LeadIn CreateLeadIn(string type) => type switch
|
||||
{
|
||||
"Line" => new LineLeadIn { Length = 0.37, ApproachAngle = 47 },
|
||||
"Arc" => new ArcLeadIn { Radius = 0.53 },
|
||||
"LineArc" => new LineArcLeadIn
|
||||
{
|
||||
LineLength = 0.43,
|
||||
ArcRadius = 0.29,
|
||||
ApproachAngle = 118,
|
||||
},
|
||||
"CleanHole" => new CleanHoleLeadIn { LineLength = 0.38, ArcRadius = 0.16, Kerf = 0.021 },
|
||||
"LineLine" => new LineLineLeadIn
|
||||
{
|
||||
Length1 = 0.24,
|
||||
ApproachAngle1 = 72,
|
||||
Length2 = 0.48,
|
||||
ApproachAngle2 = 36,
|
||||
},
|
||||
"None" => new NoLeadIn(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
|
||||
private static LeadOut CreateLeadOut(string type) => type switch
|
||||
{
|
||||
"Line" => new LineLeadOut { Length = 0.21, ApproachAngle = 58 },
|
||||
"Arc" => new ArcLeadOut { Radius = 0.19 },
|
||||
"None" => new NoLeadOut(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
|
||||
private static Tab CreateTab(string type)
|
||||
{
|
||||
var tab = type switch
|
||||
{
|
||||
"Normal" => (Tab)new NormalTab
|
||||
{
|
||||
CutoutMinWidth = 0.31,
|
||||
CutoutMinHeight = 0.52,
|
||||
CutoutMaxWidth = 3.7,
|
||||
CutoutMaxHeight = 4.6,
|
||||
},
|
||||
"Machine" => new MachineTab { MachineTabId = 29 },
|
||||
"Breaker" => new BreakerTab
|
||||
{
|
||||
BreakerDepth = 0.013,
|
||||
BreakerLeadInLength = 0.09,
|
||||
BreakerAngle = 26,
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
tab.Size = 0.41;
|
||||
tab.TabLeadIn = CreateLeadIn("LineArc");
|
||||
tab.TabLeadOut = CreateLeadOut("Arc");
|
||||
return tab;
|
||||
}
|
||||
|
||||
// Compare concrete runtime properties, including nested subtype fields, rather than
|
||||
// using the serializer under test as the equality oracle. All model objects must be owned.
|
||||
private static void AssertEquivalent(object? expected, object? actual)
|
||||
{
|
||||
if (expected == null)
|
||||
{
|
||||
Assert.Null(actual);
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.NotNull(actual);
|
||||
var type = expected.GetType();
|
||||
Assert.Equal(type, actual.GetType());
|
||||
if (type.IsValueType || expected is string)
|
||||
{
|
||||
Assert.Equal(expected, actual);
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.NotSame(expected, actual);
|
||||
foreach (var property in type.GetProperties())
|
||||
AssertEquivalent(property.GetValue(expected), property.GetValue(actual));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
@@ -17,17 +18,26 @@ namespace OpenNest.IO.Bending
|
||||
|
||||
private static readonly Regex BendNoteRegex = new Regex(
|
||||
@"(?<direction>UP|DOWN|DN)\s+(?<angle>\d+(\.\d+)?)[^A-Z\d]*R\s*(?<radius>\d+(\.\d+)?)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase,
|
||||
TimeSpan.FromSeconds(1)
|
||||
);
|
||||
|
||||
private static readonly Regex MTextFormatRegex = new Regex(
|
||||
@"\\[fHCTQWASpOoLlKk][^;]*;|\\P|[{}]|%%[dDpPcC]",
|
||||
RegexOptions.Compiled
|
||||
RegexOptions.Compiled,
|
||||
TimeSpan.FromSeconds(1)
|
||||
);
|
||||
|
||||
private static readonly Regex UnicodeEscapeRegex = new Regex(
|
||||
@"\\U\+([0-9A-Fa-f]{4})",
|
||||
RegexOptions.Compiled
|
||||
RegexOptions.Compiled,
|
||||
TimeSpan.FromSeconds(1)
|
||||
);
|
||||
|
||||
private static readonly Regex WhitespaceRegex = new Regex(
|
||||
@"\s+",
|
||||
RegexOptions.None,
|
||||
TimeSpan.FromSeconds(1)
|
||||
);
|
||||
|
||||
public List<Bend> DetectBends(CadDocument document)
|
||||
@@ -238,7 +248,7 @@ namespace OpenNest.IO.Bending
|
||||
result = MTextFormatRegex.Replace(result, " ");
|
||||
|
||||
// Collapse multiple spaces
|
||||
return Regex.Replace(result.Trim(), @"\s+", " ");
|
||||
return WhitespaceRegex.Replace(result.Trim(), " ");
|
||||
}
|
||||
|
||||
private MText FindClosestBendNote(ACadSharp.Entities.Line bendLine, List<MText> notes)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
|
||||
namespace OpenNest.IO;
|
||||
|
||||
/// <summary>
|
||||
/// JSON-safe cutting parameters shared by nest files and desktop settings.
|
||||
/// Lead and tab discriminators avoid serializing abstract domain types.
|
||||
/// </summary>
|
||||
public class CuttingParametersDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string MachineName { get; set; }
|
||||
public string MaterialName { get; set; }
|
||||
public string Grade { get; set; }
|
||||
public double Thickness { get; set; }
|
||||
public double Kerf { get; set; }
|
||||
public double PartSpacing { get; set; }
|
||||
public LeadInDto ExternalLeadIn { get; set; }
|
||||
public LeadOutDto ExternalLeadOut { get; set; }
|
||||
public LeadInDto InternalLeadIn { get; set; }
|
||||
public LeadOutDto InternalLeadOut { get; set; }
|
||||
public LeadInDto ArcCircleLeadIn { get; set; }
|
||||
public LeadOutDto ArcCircleLeadOut { get; set; }
|
||||
public bool TabsEnabled { get; set; }
|
||||
|
||||
// Legacy settings only stored tabWidth. A missing TabConfig uses that width;
|
||||
// Type = "None" explicitly records a null tab in a nest parameter snapshot.
|
||||
public double TabWidth { get; set; }
|
||||
public TabDto TabConfig { get; set; }
|
||||
public double PierceClearance { get; set; }
|
||||
public bool RoundLeadInAngles { get; set; }
|
||||
public double LeadInAngleIncrement { get; set; }
|
||||
public double AutoTabMinSize { get; set; }
|
||||
public double AutoTabMaxSize { get; set; }
|
||||
public SequenceDto Sequencing { get; set; } = new();
|
||||
public AssignmentDto Assignment { get; set; } = new();
|
||||
|
||||
public class LeadInDto
|
||||
{
|
||||
public string Type { get; set; } = "None";
|
||||
public double Length { get; set; }
|
||||
public double ApproachAngle { get; set; }
|
||||
public double Radius { get; set; }
|
||||
public double LineLength { get; set; }
|
||||
public double ArcRadius { get; set; }
|
||||
public double Kerf { get; set; }
|
||||
public double Length1 { get; set; }
|
||||
public double Angle1 { get; set; }
|
||||
public double Length2 { get; set; }
|
||||
public double Angle2 { get; set; }
|
||||
}
|
||||
|
||||
public class LeadOutDto
|
||||
{
|
||||
public string Type { get; set; } = "None";
|
||||
public double Length { get; set; }
|
||||
public double ApproachAngle { get; set; }
|
||||
public double Radius { get; set; }
|
||||
public double GapSize { get; set; }
|
||||
}
|
||||
|
||||
public class TabDto
|
||||
{
|
||||
public string Type { get; set; } = "None";
|
||||
public double Size { get; set; }
|
||||
public LeadInDto TabLeadIn { get; set; }
|
||||
public LeadOutDto TabLeadOut { get; set; }
|
||||
public double CutoutMinWidth { get; set; }
|
||||
public double CutoutMinHeight { get; set; }
|
||||
public double CutoutMaxWidth { get; set; }
|
||||
public double CutoutMaxHeight { get; set; }
|
||||
public int MachineTabId { get; set; }
|
||||
public double BreakerDepth { get; set; }
|
||||
public double BreakerLeadInLength { get; set; }
|
||||
public double BreakerAngle { get; set; }
|
||||
}
|
||||
|
||||
public class SequenceDto
|
||||
{
|
||||
public SequenceMethod Method { get; set; } = SequenceMethod.Advanced;
|
||||
public double SmallCutoutWidth { get; set; } = 1.5;
|
||||
public double SmallCutoutHeight { get; set; } = 1.5;
|
||||
public double MediumCutoutWidth { get; set; } = 8.0;
|
||||
public double MediumCutoutHeight { get; set; } = 8.0;
|
||||
public double DistanceMediumSmall { get; set; }
|
||||
public bool AlternateRowsColumns { get; set; } = true;
|
||||
public bool AlternateCutoutsWithinRowColumn { get; set; } = true;
|
||||
public double MinDistanceBetweenRowsColumns { get; set; } = 0.25;
|
||||
}
|
||||
|
||||
public class AssignmentDto
|
||||
{
|
||||
public SequenceMethod Method { get; set; } = SequenceMethod.Advanced;
|
||||
public string Preference { get; set; } = "ILAT";
|
||||
public double MinGeometryLength { get; set; } = 0.01;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using static OpenNest.IO.CuttingParametersDto;
|
||||
|
||||
namespace OpenNest.IO;
|
||||
|
||||
public static class CuttingParametersSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
/// <summary>Writes desktop settings, retaining the legacy null-tab width fallback.</summary>
|
||||
public static string Serialize(CuttingParameters parameters)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parameters);
|
||||
var dto = ToDto(parameters);
|
||||
if (parameters.TabConfig == null)
|
||||
dto.TabConfig = null;
|
||||
return JsonSerializer.Serialize(dto, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>Reads desktop settings with their historical defaults and angle fallback.</summary>
|
||||
public static CuttingParameters Deserialize(string json)
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<CuttingParametersDto>(json, JsonOptions);
|
||||
var parameters = FromDto(dto) ?? new CuttingParameters();
|
||||
if (parameters.LeadInAngleIncrement <= 0)
|
||||
parameters.LeadInAngleIncrement = 5.0;
|
||||
return parameters;
|
||||
}
|
||||
|
||||
/// <summary>Captures an owned snapshot. Null stays null for absent plate parameters.</summary>
|
||||
public static CuttingParametersDto ToDto(CuttingParameters parameters)
|
||||
{
|
||||
if (parameters == null)
|
||||
return null;
|
||||
|
||||
return new CuttingParametersDto
|
||||
{
|
||||
Id = parameters.Id,
|
||||
MachineName = parameters.MachineName,
|
||||
MaterialName = parameters.MaterialName,
|
||||
Grade = parameters.Grade,
|
||||
Thickness = parameters.Thickness,
|
||||
Kerf = parameters.Kerf,
|
||||
PartSpacing = parameters.PartSpacing,
|
||||
ExternalLeadIn = ToLeadInDto(parameters.ExternalLeadIn),
|
||||
ExternalLeadOut = ToLeadOutDto(parameters.ExternalLeadOut),
|
||||
InternalLeadIn = ToLeadInDto(parameters.InternalLeadIn),
|
||||
InternalLeadOut = ToLeadOutDto(parameters.InternalLeadOut),
|
||||
ArcCircleLeadIn = ToLeadInDto(parameters.ArcCircleLeadIn),
|
||||
ArcCircleLeadOut = ToLeadOutDto(parameters.ArcCircleLeadOut),
|
||||
TabsEnabled = parameters.TabsEnabled,
|
||||
TabWidth = parameters.TabConfig?.Size ?? 0.25,
|
||||
TabConfig = ToTabDto(parameters.TabConfig),
|
||||
PierceClearance = parameters.PierceClearance,
|
||||
RoundLeadInAngles = parameters.RoundLeadInAngles,
|
||||
LeadInAngleIncrement = parameters.LeadInAngleIncrement,
|
||||
AutoTabMinSize = parameters.AutoTabMinSize,
|
||||
AutoTabMaxSize = parameters.AutoTabMaxSize,
|
||||
Sequencing = ToSequenceDto(parameters.Sequencing),
|
||||
Assignment = ToAssignmentDto(parameters.Assignment),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Restores an owned snapshot without the desktop settings' value normalization.</summary>
|
||||
public static CuttingParameters FromDto(CuttingParametersDto dto)
|
||||
{
|
||||
if (dto == null)
|
||||
return null;
|
||||
|
||||
return new CuttingParameters
|
||||
{
|
||||
Id = dto.Id,
|
||||
MachineName = dto.MachineName,
|
||||
MaterialName = dto.MaterialName,
|
||||
Grade = dto.Grade,
|
||||
Thickness = dto.Thickness,
|
||||
Kerf = dto.Kerf,
|
||||
PartSpacing = dto.PartSpacing,
|
||||
ExternalLeadIn = FromLeadInDto(dto.ExternalLeadIn),
|
||||
ExternalLeadOut = FromLeadOutDto(dto.ExternalLeadOut),
|
||||
InternalLeadIn = FromLeadInDto(dto.InternalLeadIn),
|
||||
InternalLeadOut = FromLeadOutDto(dto.InternalLeadOut),
|
||||
ArcCircleLeadIn = FromLeadInDto(dto.ArcCircleLeadIn),
|
||||
ArcCircleLeadOut = FromLeadOutDto(dto.ArcCircleLeadOut),
|
||||
TabsEnabled = dto.TabsEnabled,
|
||||
TabConfig = dto.TabConfig == null
|
||||
? new NormalTab { Size = dto.TabWidth }
|
||||
: FromTabDto(dto.TabConfig),
|
||||
PierceClearance = dto.PierceClearance,
|
||||
RoundLeadInAngles = dto.RoundLeadInAngles,
|
||||
LeadInAngleIncrement = dto.LeadInAngleIncrement,
|
||||
AutoTabMinSize = dto.AutoTabMinSize,
|
||||
AutoTabMaxSize = dto.AutoTabMaxSize,
|
||||
Sequencing = FromSequenceDto(dto.Sequencing),
|
||||
Assignment = FromAssignmentDto(dto.Assignment),
|
||||
};
|
||||
}
|
||||
|
||||
private static LeadInDto ToLeadInDto(LeadIn leadIn) => leadIn switch
|
||||
{
|
||||
LineLeadIn line => new LeadInDto
|
||||
{
|
||||
Type = "Line",
|
||||
Length = line.Length,
|
||||
ApproachAngle = line.ApproachAngle,
|
||||
},
|
||||
ArcLeadIn arc => new LeadInDto { Type = "Arc", Radius = arc.Radius },
|
||||
LineArcLeadIn lineArc => new LeadInDto
|
||||
{
|
||||
Type = "LineArc",
|
||||
LineLength = lineArc.LineLength,
|
||||
ArcRadius = lineArc.ArcRadius,
|
||||
ApproachAngle = lineArc.ApproachAngle,
|
||||
},
|
||||
CleanHoleLeadIn cleanHole => new LeadInDto
|
||||
{
|
||||
Type = "CleanHole",
|
||||
LineLength = cleanHole.LineLength,
|
||||
ArcRadius = cleanHole.ArcRadius,
|
||||
Kerf = cleanHole.Kerf,
|
||||
},
|
||||
LineLineLeadIn lineLine => new LeadInDto
|
||||
{
|
||||
Type = "LineLine",
|
||||
Length1 = lineLine.Length1,
|
||||
Angle1 = lineLine.ApproachAngle1,
|
||||
Length2 = lineLine.Length2,
|
||||
Angle2 = lineLine.ApproachAngle2,
|
||||
},
|
||||
_ => new LeadInDto { Type = "None" },
|
||||
};
|
||||
|
||||
private static LeadIn FromLeadInDto(LeadInDto dto) => dto?.Type switch
|
||||
{
|
||||
"Line" => new LineLeadIn { Length = dto.Length, ApproachAngle = dto.ApproachAngle },
|
||||
"Arc" => new ArcLeadIn { Radius = dto.Radius },
|
||||
"LineArc" => new LineArcLeadIn
|
||||
{
|
||||
LineLength = dto.LineLength,
|
||||
ArcRadius = dto.ArcRadius,
|
||||
ApproachAngle = dto.ApproachAngle,
|
||||
},
|
||||
"CleanHole" => new CleanHoleLeadIn
|
||||
{
|
||||
LineLength = dto.LineLength,
|
||||
ArcRadius = dto.ArcRadius,
|
||||
Kerf = dto.Kerf,
|
||||
},
|
||||
"LineLine" => new LineLineLeadIn
|
||||
{
|
||||
Length1 = dto.Length1,
|
||||
ApproachAngle1 = dto.Angle1,
|
||||
Length2 = dto.Length2,
|
||||
ApproachAngle2 = dto.Angle2,
|
||||
},
|
||||
_ => new NoLeadIn(),
|
||||
};
|
||||
|
||||
private static LeadOutDto ToLeadOutDto(LeadOut leadOut) => leadOut switch
|
||||
{
|
||||
LineLeadOut line => new LeadOutDto
|
||||
{
|
||||
Type = "Line",
|
||||
Length = line.Length,
|
||||
ApproachAngle = line.ApproachAngle,
|
||||
},
|
||||
ArcLeadOut arc => new LeadOutDto { Type = "Arc", Radius = arc.Radius },
|
||||
_ => new LeadOutDto { Type = "None" },
|
||||
};
|
||||
|
||||
private static LeadOut FromLeadOutDto(LeadOutDto dto) => dto?.Type switch
|
||||
{
|
||||
"Line" => new LineLeadOut { Length = dto.Length, ApproachAngle = dto.ApproachAngle },
|
||||
"Arc" => new ArcLeadOut { Radius = dto.Radius },
|
||||
_ => new NoLeadOut(),
|
||||
};
|
||||
|
||||
private static TabDto ToTabDto(Tab tab)
|
||||
{
|
||||
var dto = tab switch
|
||||
{
|
||||
NormalTab normal => new TabDto
|
||||
{
|
||||
Type = "Normal",
|
||||
CutoutMinWidth = normal.CutoutMinWidth,
|
||||
CutoutMinHeight = normal.CutoutMinHeight,
|
||||
CutoutMaxWidth = normal.CutoutMaxWidth,
|
||||
CutoutMaxHeight = normal.CutoutMaxHeight,
|
||||
},
|
||||
MachineTab machine => new TabDto { Type = "Machine", MachineTabId = machine.MachineTabId },
|
||||
BreakerTab breaker => new TabDto
|
||||
{
|
||||
Type = "Breaker",
|
||||
BreakerDepth = breaker.BreakerDepth,
|
||||
BreakerLeadInLength = breaker.BreakerLeadInLength,
|
||||
BreakerAngle = breaker.BreakerAngle,
|
||||
},
|
||||
_ => new TabDto { Type = "None" },
|
||||
};
|
||||
if (tab != null)
|
||||
{
|
||||
dto.Size = tab.Size;
|
||||
dto.TabLeadIn = tab.TabLeadIn == null ? null : ToLeadInDto(tab.TabLeadIn);
|
||||
dto.TabLeadOut = tab.TabLeadOut == null ? null : ToLeadOutDto(tab.TabLeadOut);
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
private static Tab FromTabDto(TabDto dto)
|
||||
{
|
||||
var tab = dto.Type switch
|
||||
{
|
||||
"Normal" => (Tab)new NormalTab
|
||||
{
|
||||
CutoutMinWidth = dto.CutoutMinWidth,
|
||||
CutoutMinHeight = dto.CutoutMinHeight,
|
||||
CutoutMaxWidth = dto.CutoutMaxWidth,
|
||||
CutoutMaxHeight = dto.CutoutMaxHeight,
|
||||
},
|
||||
"Machine" => new MachineTab { MachineTabId = dto.MachineTabId },
|
||||
"Breaker" => new BreakerTab
|
||||
{
|
||||
BreakerDepth = dto.BreakerDepth,
|
||||
BreakerLeadInLength = dto.BreakerLeadInLength,
|
||||
BreakerAngle = dto.BreakerAngle,
|
||||
},
|
||||
_ => null,
|
||||
};
|
||||
if (tab != null)
|
||||
{
|
||||
tab.Size = dto.Size;
|
||||
tab.TabLeadIn = dto.TabLeadIn == null ? null : FromLeadInDto(dto.TabLeadIn);
|
||||
tab.TabLeadOut = dto.TabLeadOut == null ? null : FromLeadOutDto(dto.TabLeadOut);
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
private static SequenceDto ToSequenceDto(SequenceParameters parameters) => parameters == null
|
||||
? null
|
||||
: new SequenceDto
|
||||
{
|
||||
Method = parameters.Method,
|
||||
SmallCutoutWidth = parameters.SmallCutoutWidth,
|
||||
SmallCutoutHeight = parameters.SmallCutoutHeight,
|
||||
MediumCutoutWidth = parameters.MediumCutoutWidth,
|
||||
MediumCutoutHeight = parameters.MediumCutoutHeight,
|
||||
DistanceMediumSmall = parameters.DistanceMediumSmall,
|
||||
AlternateRowsColumns = parameters.AlternateRowsColumns,
|
||||
AlternateCutoutsWithinRowColumn = parameters.AlternateCutoutsWithinRowColumn,
|
||||
MinDistanceBetweenRowsColumns = parameters.MinDistanceBetweenRowsColumns,
|
||||
};
|
||||
|
||||
private static SequenceParameters FromSequenceDto(SequenceDto dto) => dto == null
|
||||
? null
|
||||
: new SequenceParameters
|
||||
{
|
||||
Method = dto.Method,
|
||||
SmallCutoutWidth = dto.SmallCutoutWidth,
|
||||
SmallCutoutHeight = dto.SmallCutoutHeight,
|
||||
MediumCutoutWidth = dto.MediumCutoutWidth,
|
||||
MediumCutoutHeight = dto.MediumCutoutHeight,
|
||||
DistanceMediumSmall = dto.DistanceMediumSmall,
|
||||
AlternateRowsColumns = dto.AlternateRowsColumns,
|
||||
AlternateCutoutsWithinRowColumn = dto.AlternateCutoutsWithinRowColumn,
|
||||
MinDistanceBetweenRowsColumns = dto.MinDistanceBetweenRowsColumns,
|
||||
};
|
||||
|
||||
private static AssignmentDto ToAssignmentDto(AssignmentParameters parameters) => parameters == null
|
||||
? null
|
||||
: new AssignmentDto
|
||||
{
|
||||
Method = parameters.Method,
|
||||
Preference = parameters.Preference,
|
||||
MinGeometryLength = parameters.MinGeometryLength,
|
||||
};
|
||||
|
||||
private static AssignmentParameters FromAssignmentDto(AssignmentDto dto) => dto == null
|
||||
? null
|
||||
: new AssignmentParameters
|
||||
{
|
||||
Method = dto.Method,
|
||||
Preference = dto.Preference,
|
||||
MinGeometryLength = dto.MinGeometryLength,
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,8 @@ namespace OpenNest.IO
|
||||
public string DateLastModified { get; init; } = "";
|
||||
public string Notes { get; init; } = "";
|
||||
public string AssistGas { get; init; } = "";
|
||||
public string Status { get; init; } = "Quote";
|
||||
public string MadeBy { get; init; } = "";
|
||||
public double Thickness { get; init; }
|
||||
public MaterialDto Material { get; init; } = new();
|
||||
public PlateDefaultsDto PlateDefaults { get; init; } = new();
|
||||
@@ -66,6 +68,7 @@ namespace OpenNest.IO
|
||||
public double PartSpacing { get; init; }
|
||||
public SpacingDto EdgeSpacing { get; init; } = new();
|
||||
public double GrainAngle { get; init; }
|
||||
public CuttingParametersDto CuttingParameters { get; init; }
|
||||
public List<PartDto> Parts { get; init; } = new();
|
||||
public List<CutOffDto> CutOffs { get; init; } = new();
|
||||
}
|
||||
@@ -78,6 +81,8 @@ namespace OpenNest.IO
|
||||
public double Rotation { get; init; }
|
||||
public bool HasManualLeadIns { get; init; }
|
||||
public bool LeadInsLocked { get; init; }
|
||||
public string Program { get; init; }
|
||||
public string DrawingHash { get; init; }
|
||||
}
|
||||
|
||||
public record CutOffDto
|
||||
@@ -87,6 +92,12 @@ namespace OpenNest.IO
|
||||
public string Axis { get; init; } = "vertical";
|
||||
public double? StartLimit { get; init; }
|
||||
public double? EndLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Zero-based place in the plate's cut sequence (<c>Plate.Parts</c> order,
|
||||
/// cut-offs included). Null in older files, which load it at the end.
|
||||
/// </summary>
|
||||
public int? Sequence { get; init; }
|
||||
}
|
||||
|
||||
public record SizeDto
|
||||
|
||||
+74
-26
@@ -18,6 +18,11 @@ namespace OpenNest.IO
|
||||
{
|
||||
private readonly Stream stream;
|
||||
private readonly ZipArchive zipArchive;
|
||||
private readonly Dictionary<int, string> drawingHashes = new();
|
||||
private readonly List<string> warnings = new();
|
||||
|
||||
/// <summary>Part programs that could not be restored; other parts still load.</summary>
|
||||
public IReadOnlyList<string> Warnings => warnings.AsReadOnly();
|
||||
|
||||
public NestReader(string file)
|
||||
{
|
||||
@@ -63,33 +68,63 @@ namespace OpenNest.IO
|
||||
var programs = new Dictionary<int, Program>();
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
var entry = zipArchive.GetEntry($"programs/program-{i}");
|
||||
if (entry == null)
|
||||
var name = $"programs/program-{i}";
|
||||
if (zipArchive.GetEntry(name) == null)
|
||||
continue;
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
var memStream = new MemoryStream();
|
||||
entryStream.CopyTo(memStream);
|
||||
memStream.Position = 0;
|
||||
|
||||
var reader = new ProgramReader(memStream);
|
||||
programs[i] = reader.Read();
|
||||
|
||||
// Read sub-programs if present
|
||||
var subsEntry = zipArchive.GetEntry($"programs/program-{i}-subs");
|
||||
if (subsEntry != null)
|
||||
{
|
||||
using var subsStream = subsEntry.Open();
|
||||
ReadSubPrograms(programs[i], subsStream);
|
||||
}
|
||||
programs[i] = ReadProgram(name, out var hash);
|
||||
drawingHashes[i] = hash;
|
||||
}
|
||||
return programs;
|
||||
}
|
||||
|
||||
private Program ReadProgram(string name, out string hash)
|
||||
{
|
||||
var text = ReadEntry(name);
|
||||
var subs = zipArchive.GetEntry(name + "-subs") == null ? "" : ReadEntry(name + "-subs");
|
||||
hash = NestWriter.GetDrawingHash(text, subs);
|
||||
using var programStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(text));
|
||||
var program = new ProgramReader(programStream).Read();
|
||||
if (subs.Length > 0)
|
||||
{
|
||||
using var subsStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(subs));
|
||||
ReadSubPrograms(program, subsStream);
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
private void RestorePartProgram(Part part, PartDto dto, int plateId, int partIndex)
|
||||
{
|
||||
// Old files had only transient flags. They still load clean and silently.
|
||||
if (string.IsNullOrEmpty(dto.Program))
|
||||
return;
|
||||
|
||||
// A missing hash cannot establish that this program belongs to the current drawing.
|
||||
if (!drawingHashes.TryGetValue(dto.DrawingId, out var hash)
|
||||
|| !string.Equals(dto.DrawingHash, hash, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var program = ReadProgram(dto.Program, out _);
|
||||
foreach (var call in program.Codes.OfType<SubProgramCall>())
|
||||
if (call.Program == null || !call.Program.Codes.Any(c => c is Motion))
|
||||
throw new InvalidDataException($"Missing or empty hole sub-program {call.Id}.");
|
||||
|
||||
if (!part.RestoreLeadInProgram(program, dto.LeadInsLocked))
|
||||
throw new InvalidDataException("The saved part program has no motion.");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException || ex is InvalidDataException || ex is FormatException
|
||||
|| ex is OverflowException || ex is ArgumentException || ex is InvalidOperationException)
|
||||
{
|
||||
warnings.Add($"Plate {plateId}, part {partIndex} ('{part.BaseDrawing.Name}'): "
|
||||
+ $"could not restore '{dto.Program}'; loaded the clean drawing without lead-ins or tabs. {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadSubPrograms(Program parent, Stream stream)
|
||||
{
|
||||
using var reader = new StreamReader(stream);
|
||||
var currentId = -1;
|
||||
var currentId = (int?)null;
|
||||
var lines = new List<string>();
|
||||
|
||||
string line;
|
||||
@@ -100,18 +135,18 @@ namespace OpenNest.IO
|
||||
if (trimmed.StartsWith(":") && int.TryParse(trimmed.Substring(1), out var id))
|
||||
{
|
||||
// Flush previous sub-program
|
||||
if (currentId >= 0 && lines.Count > 0)
|
||||
parent.SubPrograms[currentId] = ParseSubProgram(lines);
|
||||
if (currentId.HasValue && lines.Count > 0)
|
||||
parent.SubPrograms[currentId.Value] = ParseSubProgram(lines);
|
||||
|
||||
currentId = id;
|
||||
lines.Clear();
|
||||
}
|
||||
else if (trimmed == "M99")
|
||||
{
|
||||
if (currentId >= 0 && lines.Count > 0)
|
||||
parent.SubPrograms[currentId] = ParseSubProgram(lines);
|
||||
if (currentId.HasValue && lines.Count > 0)
|
||||
parent.SubPrograms[currentId.Value] = ParseSubProgram(lines);
|
||||
|
||||
currentId = -1;
|
||||
currentId = null;
|
||||
lines.Clear();
|
||||
}
|
||||
else
|
||||
@@ -306,6 +341,11 @@ namespace OpenNest.IO
|
||||
nest.DateLastModified = DateTime.Parse(dto.DateLastModified);
|
||||
nest.Notes = dto.Notes;
|
||||
nest.AssistGas = dto.AssistGas ?? "";
|
||||
// Additive v2 fields: absent or unknown values keep the defaults.
|
||||
nest.Status = Enum.TryParse<NestStatus>(dto.Status, true, out var status)
|
||||
? status
|
||||
: NestStatus.Quote;
|
||||
nest.MadeBy = dto.MadeBy ?? "";
|
||||
|
||||
// Nest-level material and thickness (fall back to PlateDefaults for old files)
|
||||
var pd = dto.PlateDefaults;
|
||||
@@ -357,21 +397,26 @@ namespace OpenNest.IO
|
||||
p.EdgeSpacing.Top
|
||||
);
|
||||
plate.GrainAngle = p.GrainAngle;
|
||||
plate.CuttingParameters = CuttingParametersSerializer.FromDto(p.CuttingParameters);
|
||||
|
||||
foreach (var partDto in p.Parts)
|
||||
for (var partIndex = 0; partIndex < p.Parts.Count; partIndex++)
|
||||
{
|
||||
var partDto = p.Parts[partIndex];
|
||||
if (!drawingMap.TryGetValue(partDto.DrawingId, out var dwg))
|
||||
continue;
|
||||
|
||||
var part = new Part(dwg);
|
||||
part.Rotate(partDto.Rotation);
|
||||
part.Offset(new Vector(partDto.X, partDto.Y));
|
||||
RestorePartProgram(part, partDto, p.Id, partIndex);
|
||||
plate.Parts.Add(part);
|
||||
}
|
||||
|
||||
// Cut-offs
|
||||
if (p.CutOffs != null)
|
||||
{
|
||||
var sequence = new Dictionary<CutOff, int>();
|
||||
|
||||
foreach (var cutoffDto in p.CutOffs)
|
||||
{
|
||||
var axis =
|
||||
@@ -384,9 +429,12 @@ namespace OpenNest.IO
|
||||
EndLimit = cutoffDto.EndLimit,
|
||||
};
|
||||
plate.CutOffs.Add(cutoff);
|
||||
|
||||
if (cutoffDto.Sequence is int index)
|
||||
sequence[cutoff] = index;
|
||||
}
|
||||
|
||||
plate.RegenerateCutOffs(new CutOffSettings());
|
||||
plate.RegenerateCutOffs(new CutOffSettings(), sequence);
|
||||
}
|
||||
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
+75
-44
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using OpenNest.CNC;
|
||||
@@ -18,6 +20,8 @@ namespace OpenNest.IO
|
||||
|
||||
private readonly Nest nest;
|
||||
private Dictionary<int, Drawing> drawingDict;
|
||||
private readonly Dictionary<int, string> drawingHashes = new();
|
||||
private readonly Dictionary<string, Program> partPrograms = new();
|
||||
|
||||
public NestWriter(Nest nest)
|
||||
{
|
||||
@@ -38,8 +42,12 @@ namespace OpenNest.IO
|
||||
|
||||
using var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true);
|
||||
|
||||
WriteNestJson(zipArchive);
|
||||
drawingHashes.Clear();
|
||||
partPrograms.Clear();
|
||||
WritePrograms(zipArchive);
|
||||
WriteNestJson(zipArchive);
|
||||
foreach (var entry in partPrograms)
|
||||
WriteProgramEntry(zipArchive, entry.Key, entry.Value);
|
||||
WriteEntities(zipArchive);
|
||||
WriteBestFits(zipArchive);
|
||||
|
||||
@@ -48,6 +56,7 @@ namespace OpenNest.IO
|
||||
|
||||
private void SetDrawingIds()
|
||||
{
|
||||
drawingDict.Clear();
|
||||
var id = 1;
|
||||
foreach (var drawing in nest.Drawings)
|
||||
{
|
||||
@@ -79,6 +88,8 @@ namespace OpenNest.IO
|
||||
DateLastModified = nest.DateLastModified.ToString("o"),
|
||||
Notes = nest.Notes ?? "",
|
||||
AssistGas = nest.AssistGas ?? "",
|
||||
Status = nest.Status.ToString(),
|
||||
MadeBy = nest.MadeBy ?? "",
|
||||
Thickness = nest.Thickness,
|
||||
Material = new MaterialDto
|
||||
{
|
||||
@@ -207,6 +218,11 @@ namespace OpenNest.IO
|
||||
var match = drawingDict
|
||||
.Where(dwg => dwg.Value == part.BaseDrawing)
|
||||
.FirstOrDefault();
|
||||
var programName = part.HasManualLeadIns
|
||||
? $"parts/plate-{id}/part-{parts.Count}"
|
||||
: null;
|
||||
if (programName != null)
|
||||
partPrograms.Add(programName, part.Program);
|
||||
parts.Add(
|
||||
new PartDto
|
||||
{
|
||||
@@ -216,6 +232,8 @@ namespace OpenNest.IO
|
||||
Rotation = part.Rotation,
|
||||
HasManualLeadIns = part.HasManualLeadIns,
|
||||
LeadInsLocked = part.LeadInsLocked,
|
||||
Program = programName,
|
||||
DrawingHash = programName == null ? null : drawingHashes[match.Key],
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -223,6 +241,16 @@ namespace OpenNest.IO
|
||||
var cutoffs = new List<CutOffDto>();
|
||||
foreach (var cutoff in plate.CutOffs)
|
||||
{
|
||||
var sequence = -1;
|
||||
for (var j = 0; j < plate.Parts.Count; j++)
|
||||
{
|
||||
if (ReferenceEquals(plate.Parts[j].BaseDrawing, cutoff.Drawing))
|
||||
{
|
||||
sequence = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cutoffs.Add(
|
||||
new CutOffDto
|
||||
{
|
||||
@@ -231,6 +259,7 @@ namespace OpenNest.IO
|
||||
Axis = cutoff.Axis == CutOffAxis.Vertical ? "vertical" : "horizontal",
|
||||
StartLimit = cutoff.StartLimit,
|
||||
EndLimit = cutoff.EndLimit,
|
||||
Sequence = sequence >= 0 ? sequence : null,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -253,6 +282,7 @@ namespace OpenNest.IO
|
||||
Parts = parts,
|
||||
CutOffs = cutoffs,
|
||||
GrainAngle = plate.GrainAngle,
|
||||
CuttingParameters = CuttingParametersSerializer.ToDto(plate.CuttingParameters),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -331,43 +361,44 @@ namespace OpenNest.IO
|
||||
private void WritePrograms(ZipArchive zipArchive)
|
||||
{
|
||||
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
|
||||
{
|
||||
var name = $"programs/program-{kvp.Key}";
|
||||
var stream = new MemoryStream();
|
||||
WriteDrawing(stream, kvp.Value);
|
||||
drawingHashes[kvp.Key] = WriteProgramEntry(
|
||||
zipArchive, $"programs/program-{kvp.Key}", kvp.Value.Program);
|
||||
}
|
||||
|
||||
private static string WriteProgramEntry(ZipArchive zipArchive, string name, Program program)
|
||||
{
|
||||
var text = GetProgramText(program);
|
||||
var subs = GetSubProgramsText(program);
|
||||
WriteTextEntry(zipArchive, name, text);
|
||||
if (subs.Length > 0)
|
||||
WriteTextEntry(zipArchive, name + "-subs", subs);
|
||||
return GetDrawingHash(text, subs);
|
||||
}
|
||||
|
||||
private static void WriteTextEntry(ZipArchive zipArchive, string name, string text)
|
||||
{
|
||||
var entry = zipArchive.CreateEntry(name);
|
||||
using (var entryStream = entry.Open())
|
||||
using var stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream, new UTF8Encoding(false));
|
||||
writer.Write(text);
|
||||
}
|
||||
|
||||
// Hash the exact saved text, including holes: a change to either invalidates the part.
|
||||
internal static string GetDrawingHash(string text, string subs) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(
|
||||
text.Length.ToString(CultureInfo.InvariantCulture) + ":" + text + subs)));
|
||||
|
||||
/// <summary>Serializes the hole programs using the same text as the nest archive.</summary>
|
||||
public static string GetSubProgramsText(Program program)
|
||||
{
|
||||
stream.CopyTo(entryStream);
|
||||
}
|
||||
|
||||
// Write sub-programs if present
|
||||
if (kvp.Value.Program.SubPrograms.Count > 0)
|
||||
WriteSubPrograms(zipArchive, kvp.Key, kvp.Value.Program.SubPrograms);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteSubPrograms(
|
||||
ZipArchive zipArchive,
|
||||
int drawingId,
|
||||
Dictionary<int, Program> subPrograms
|
||||
)
|
||||
{
|
||||
var entry = zipArchive.CreateEntry($"programs/program-{drawingId}-subs");
|
||||
using var entryStream = entry.Open();
|
||||
using var writer = new StreamWriter(entryStream, Encoding.UTF8);
|
||||
|
||||
foreach (var kvp in subPrograms.OrderBy(k => k.Key))
|
||||
using var writer = new StringWriter(CultureInfo.InvariantCulture) { NewLine = "\n" };
|
||||
foreach (var kvp in program.SubPrograms.OrderBy(k => k.Key))
|
||||
{
|
||||
writer.WriteLine($":{kvp.Key}");
|
||||
writer.WriteLine(kvp.Value.Mode == Mode.Absolute ? "G90" : "G91");
|
||||
|
||||
foreach (var code in kvp.Value.Codes)
|
||||
writer.WriteLine(GetCodeString(code));
|
||||
|
||||
WriteProgram(writer, kvp.Value);
|
||||
writer.WriteLine("M99");
|
||||
}
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private void WriteEntities(ZipArchive zipArchive)
|
||||
@@ -391,11 +422,16 @@ namespace OpenNest.IO
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteDrawing(Stream stream, Drawing drawing)
|
||||
/// <summary>Serializes a program in its current local frame, without transforming it.</summary>
|
||||
public static string GetProgramText(Program program)
|
||||
{
|
||||
using var writer = new StringWriter(CultureInfo.InvariantCulture) { NewLine = "\n" };
|
||||
WriteProgram(writer, program);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static void WriteProgram(TextWriter writer, Program program)
|
||||
{
|
||||
var program = drawing.Program;
|
||||
var writer = new StreamWriter(stream);
|
||||
writer.AutoFlush = true;
|
||||
|
||||
// Emit variable definitions before G-code
|
||||
foreach (var v in program.Variables.Values)
|
||||
@@ -410,16 +446,11 @@ namespace OpenNest.IO
|
||||
|
||||
writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91");
|
||||
|
||||
for (var i = 0; i < drawing.Program.Length; ++i)
|
||||
{
|
||||
var code = drawing.Program[i];
|
||||
foreach (var code in program.Codes)
|
||||
writer.WriteLine(GetCodeString(code));
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
}
|
||||
|
||||
private string FormatCoord(
|
||||
private static string FormatCoord(
|
||||
double value,
|
||||
string axis,
|
||||
Dictionary<string, string> variableRefs
|
||||
@@ -430,7 +461,7 @@ namespace OpenNest.IO
|
||||
return System.Math.Round(value, OutputPrecision).ToString(CoordinateFormat);
|
||||
}
|
||||
|
||||
private string GetCodeString(ICode code)
|
||||
private static string GetCodeString(ICode code)
|
||||
{
|
||||
switch (code.Type)
|
||||
{
|
||||
@@ -534,7 +565,7 @@ namespace OpenNest.IO
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private string GetLayerString(LayerType layer)
|
||||
private static string GetLayerString(LayerType layer)
|
||||
{
|
||||
switch (layer)
|
||||
{
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Mcp;
|
||||
|
||||
NestingEngineRegistry.LoadPlugins(Path.Combine(AppContext.BaseDirectory, "Engines"));
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
// stdout carries the JSON-RPC stream; console logs must go to stderr or they corrupt it.
|
||||
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
|
||||
|
||||
using var toolGate = new SessionToolGate();
|
||||
builder.Services.AddSingleton<NestSession>();
|
||||
builder
|
||||
.Services.AddMcpServer()
|
||||
.WithRequestFilters(filters => filters.AddCallToolFilter(
|
||||
next => (context, token) => toolGate.RunAsync(ct => next(context, ct), token)))
|
||||
.WithStdioServerTransport()
|
||||
.WithToolsFromAssembly(typeof(Program).Assembly);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user