Compare commits
121
Commits
a68e252ac7
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
094c4c196b | ||
|
|
77b729e58c | ||
|
|
1d1c60daa7 | ||
|
|
1e8e532063 | ||
|
|
d70505b7c0 | ||
|
|
8188533d72 | ||
|
|
6863c8bdb1 | ||
|
|
4553f8afad | ||
|
|
4ec92c95ec | ||
|
|
b2c864a328 | ||
|
|
6efa6b1117 | ||
|
|
cec7396da6 | ||
|
|
b318950a54 | ||
|
|
1b862dc1a8 | ||
|
|
d1f3907505 | ||
|
|
4fb924b03a | ||
|
|
39db4dc10f | ||
|
|
23dd99fa2f | ||
|
|
695ccc0a3b | ||
|
|
98d0124172 | ||
|
|
4161e6d1c7 | ||
|
|
896ed2026a | ||
|
|
7f63c725e6 | ||
|
|
ec5f57171f | ||
|
|
dd1a958f5c | ||
|
|
ae2b0beb45 | ||
|
|
6ab45e6de7 | ||
|
|
029299ccf8 | ||
|
|
4cdb39870b | ||
|
|
95833236cf | ||
|
|
1b5e1b14a6 | ||
|
|
e3d10e95ae | ||
|
|
22d21924b8 | ||
|
|
87f7ddad1a | ||
|
|
23fcec4b6c | ||
|
|
d6d819bfed | ||
|
|
c45909e397 | ||
|
|
1c363504f5 | ||
|
|
dc9e83ef17 | ||
|
|
1a7ed3c759 | ||
|
|
b122298f57 | ||
|
|
2ecd94c705 | ||
|
|
26c17a386b | ||
|
|
4dd00c14c4 | ||
|
|
5064340eb3 | ||
|
|
062c7fa08f | ||
|
|
9af97c70b0 | ||
|
|
01789c5929 | ||
|
|
dceb5f7d18 | ||
|
|
10fe00d8ab | ||
|
|
9b9386b029 | ||
|
|
12f97474b7 | ||
|
|
a6bc9d8be6 | ||
|
|
7964c87eb9 | ||
|
|
45a59c6b02 | ||
|
|
1c8305e8a1 | ||
|
|
828500f984 | ||
|
|
8e4735d25d | ||
|
|
5bbb7b7461 | ||
|
|
57e9f625b6 | ||
|
|
e65f849c7a | ||
|
|
1be0904506 | ||
|
|
98c4929d14 | ||
|
|
014c071716 | ||
|
|
da1f8120f1 | ||
|
|
0512f3f84a | ||
|
|
b14cb9d10a | ||
|
|
a0417a6077 | ||
|
|
56f39556d1 | ||
|
|
86f6c9efa1 | ||
|
|
6f250b5730 | ||
|
|
c28bc0da21 | ||
|
|
88966d118c | ||
|
|
f36e124039 | ||
|
|
856dbfd8af | ||
|
|
eb8fbec1aa | ||
|
|
073ead9b79 | ||
|
|
bb671cb176 | ||
|
|
6bcea7919a | ||
|
|
ed908b9706 | ||
|
|
e69ec07830 | ||
|
|
eafa0fab01 | ||
|
|
9b9e510510 | ||
|
|
451841401a | ||
|
|
fd3375cde6 | ||
|
|
42bbde7433 | ||
|
|
64d38c452c | ||
|
|
630d514b0e | ||
|
|
a764a70e52 | ||
|
|
2a855139d2 | ||
|
|
f23f0fa566 | ||
|
|
6f38c11383 | ||
|
|
f34b3c4449 | ||
|
|
365825bc5a | ||
|
|
02fc0ea3db | ||
|
|
451876c02f | ||
|
|
5061b41a5d | ||
|
|
e7cbd99db6 | ||
|
|
27684c3782 | ||
|
|
54694f9b17 | ||
|
|
de1248589a | ||
|
|
a9ebd8bb55 | ||
|
|
aec0523062 | ||
|
|
8e6fa677fb | ||
|
|
589d341455 | ||
|
|
1a05391d94 | ||
|
|
f5d27652f4 | ||
|
|
ea4bd836cd | ||
|
|
9b69c67572 | ||
|
|
aa88eee484 | ||
|
|
e0e3b96bed | ||
|
|
424ff15ebc | ||
|
|
9888fe6083 | ||
|
|
a2dcfc7484 | ||
|
|
ae704478af | ||
|
|
ecca71e185 | ||
|
|
f0fe79f0f1 | ||
|
|
a9e0f8a1d4 | ||
|
|
20da5477b6 | ||
|
|
6a0fba0fec | ||
|
|
a085339ba9 |
@@ -0,0 +1,68 @@
|
||||
# Unified code style for OpenNest.
|
||||
# Canonical formatter: dotnet format.
|
||||
# Format only the files or directories being changed:
|
||||
# dotnet format OpenNest.sln --include path/to/changed-file.cs
|
||||
# dotnet format OpenNest.sln --include path/to/changed-file.cs --verify-no-changes
|
||||
# These settings are used by dotnet format and IDE auto-formatting
|
||||
# (VS / Rider / VS Code).
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{csproj,props,targets,xml,config,manifest}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{json,yml,yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{cs,csx}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
# dotnet format cannot re-wrap source to this limit, but IDEs can surface it
|
||||
# as a visual guide and analyzers can flag hard violations.
|
||||
max_line_length = 100
|
||||
|
||||
# --- Using directives (System first, outside the namespace) ---
|
||||
dotnet_sort_system_directives_first = true
|
||||
csharp_using_directive_placement = outside_namespace:warning
|
||||
|
||||
# --- Brace placement: Allman (opening brace on its own line) ---
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_new_line_before_else = true
|
||||
csharp_new_line_before_catch = true
|
||||
csharp_new_line_before_finally = true
|
||||
csharp_new_line_before_members_in_object_initializers = true
|
||||
csharp_new_line_before_members_in_anonymous_types = true
|
||||
csharp_new_line_between_query_expression_clauses = true
|
||||
|
||||
# --- Spacing ---
|
||||
csharp_space_after_keywords_in_control_flow_statements = true
|
||||
csharp_space_between_method_call_parameter_list_parentheses = false
|
||||
csharp_space_between_method_declaration_parameter_list_parentheses = false
|
||||
csharp_space_between_parentheses = false
|
||||
csharp_space_before_colon_in_inheritance_clause = true
|
||||
csharp_space_after_colon_in_inheritance_clause = true
|
||||
csharp_space_around_binary_operators = before_and_after
|
||||
csharp_space_after_cast = false
|
||||
csharp_space_after_comma = true
|
||||
csharp_space_before_comma = false
|
||||
|
||||
# --- Code style preferences ---
|
||||
# Project rule: always use var for locals (see CLAUDE.md).
|
||||
csharp_style_var_for_built_in_types = true:suggestion
|
||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||
csharp_style_var_elsewhere = true:suggestion
|
||||
csharp_prefer_braces = true:suggestion
|
||||
csharp_prefer_simple_using_statement = true:suggestion
|
||||
csharp_style_namespace_declarations = file_scoped:silent
|
||||
dotnet_style_prefer_auto_properties = true:suggestion
|
||||
dotnet_style_object_initializer = true:suggestion
|
||||
dotnet_style_collection_initializer = true:suggestion
|
||||
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
|
||||
@@ -0,0 +1,3 @@
|
||||
# Commits whose changes git blame should skip (whitespace-only sweeps).
|
||||
# Enable locally: git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||
aec052306234e3c4313c0ee8905e2557d3c3671b
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"opennest": {
|
||||
"command": "cmd",
|
||||
"args": ["/c", "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/run.cmd"]
|
||||
"command": "${USERPROFILE}/.claude/mcp/OpenNest.Mcp/OpenNest.Mcp.exe",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# AGENTS.md
|
||||
|
||||
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.
|
||||
|
||||
## Project Overview
|
||||
|
||||
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.
|
||||
|
||||
## Build
|
||||
|
||||
This is a .NET 8 solution using SDK-style `.csproj` files. The desktop app and Windows-dependent projects target `net8.0-windows`; the core libraries and `OpenNest.Console` target `net8.0`. Build the full solution on Windows with:
|
||||
|
||||
```bash
|
||||
dotnet build OpenNest.sln
|
||||
```
|
||||
|
||||
Cross-platform whole-job engine tests (net8.0, runs on Linux/macOS/Windows without the desktop project or DXF fixtures): `dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj`. The 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, Cincinnati, and GravographIS libraries target `net8.0`; post-processor 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.
|
||||
|
||||
`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.
|
||||
|
||||
Cross-platform CAD import tests: `dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj`. These synthetic-DXF and bend-repair tests target `net8.0`, require no external fixtures, and are included in the solution. Build the headless console independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
|
||||
|
||||
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `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).
|
||||
|
||||
### Fill performance verification
|
||||
|
||||
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.
|
||||
|
||||
## 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. 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`.
|
||||
- `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).
|
||||
@@ -1,126 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
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.
|
||||
|
||||
## Build
|
||||
|
||||
This is a .NET 8 solution using SDK-style `.csproj` files targeting `net8.0-windows`. Build with:
|
||||
|
||||
```bash
|
||||
dotnet build OpenNest.sln
|
||||
```
|
||||
|
||||
Cross-platform whole-job engine tests (net8.0, runs on Linux/macOS/Windows without the desktop project or DXF fixtures): `dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj`. The existing `OpenNest.Tests` suite targets `net8.0-windows` and requires a Windows runner; cross-compiling on Linux is not Windows runtime verification.
|
||||
|
||||
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training).
|
||||
|
||||
## Architecture
|
||||
|
||||
Eight projects form a layered architecture:
|
||||
|
||||
### 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`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction).
|
||||
- **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 provide both a legacy single-plate API and a whole-job API. The legacy path centers on `NestEngineBase`, `DefaultNestEngine` (formerly `NestEngine`), and the global `NestEngineRegistry`. New job callers use immutable, ID-based contracts in `Jobs/`: `INestingEngine.Solve(NestJob)` returns `NestJobResult`; `NestJobRunner` alone commits demand and finite/unlimited stock accounting; `IPlateNester` only proposes a one-sheet candidate; and `PlateNesterFactory` resolves a named strategy without reading or changing the process-global registry.
|
||||
|
||||
- **Whole-job API (`Jobs/`)**: `NestJob` owns part requirements, physical stock, and options for one material/thickness/unit system. `PartGeometrySnapshot` contains owned flat rapid/line/arc geometry; results contain stock IDs and placement poses (radians), not mutable desktop models. `NestJobPlacementValidator` validates contours, rotation, usable work area, overlap, and spacing before accounting commits. The runner selects valid trial candidates greedily by priority vector, sheet area, envelope, and input order; an incomplete result reports why but does not prove geometric impossibility. `DrawingJobMapper` and `NestResultMaterializer` are the domain-boundary adapters.
|
||||
- **Placement boundary (`Jobs/Placement/`, `Jobs/Adapters/`)**: `DefaultPlateNester` and `StripPlateNester` are migrated built-ins with run-scoped private geometry; `LegacyPlateNesterAdapter` remains for remnant strategies and legacy plugins/callers during rollout. Job-path identity is reference-based rather than drawing name; `PlateOptimizer` and NFP/`AutoNester` retain legacy name-based helpers and are deliberately outside the runner path.
|
||||
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases) → `VerticalRemnantEngine` (optimizes for right-side drop), `HorizontalRemnantEngine` (optimizes for top-side drop). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`. Existing desktop, CLI, and MCP callers remain on this compatibility path until separate migrations preserve their existing-plate, preview, and accept/cancel semantics.
|
||||
- **IFillComparer**: Interface enabling engine-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), `HorizontalRemnantComparer` (minimize Y-extent). Engines provide their comparer via `CreateComparer()` factory, grouped into `FillPolicy` on `FillContext`.
|
||||
- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency.
|
||||
- **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`.
|
||||
- **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.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.CirclePacking`): Alternative packing for circular parts.
|
||||
- **Nfp/** (`namespace OpenNest.Engine.Nfp`): Internal NFP-based single-part placement utilities — `AutoNester` (NFP placement with simulated annealing), `BottomLeftFill` (BLF placement), `NfpCache` (computed NFP caching), `SimulatedAnnealing` (optimizer), `INestOptimizer`/`OptimizationResult`. Not exposed as a nest engine; used internally for individual part placement.
|
||||
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `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`.
|
||||
|
||||
### OpenNest.Console (console app, depends on Core + Engine + IO)
|
||||
Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`).
|
||||
|
||||
### 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.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.
|
||||
|
||||
- **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.
|
||||
|
||||
## 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 `CLAUDE.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.
|
||||
|
||||
**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.Nfp` (NFP-based nesting, not yet integrated), `OpenNest.Engine.ML`, `OpenNest.Engine.RapidPlanning`, `OpenNest.Engine.Sequencing`.
|
||||
- `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`.
|
||||
- `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.
|
||||
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).
|
||||
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
|
||||
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
|
||||
@AGENTS.md
|
||||
|
||||
@@ -6,17 +6,20 @@ namespace OpenNest.Api;
|
||||
public class NestRequest
|
||||
{
|
||||
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Explicit available physical stock. Null keeps the legacy unlimited SheetSize fallback;
|
||||
/// an empty list deliberately means no stock is available.
|
||||
/// </summary>
|
||||
public IReadOnlyList<NestRequestPlate> Plates { get; init; }
|
||||
public Size SheetSize { get; init; } = new(60, 120);
|
||||
|
||||
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
|
||||
public string PlacementStrategy { get; init; } = "Default";
|
||||
public string Material { get; init; } = "Steel, A1011 HR";
|
||||
public double Thickness { get; init; } = 0.06;
|
||||
public double Spacing { get; init; } = 0.1;
|
||||
|
||||
/// <summary>Legacy compatibility setting; Auto maps to the Default whole-job strategy.</summary>
|
||||
public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
|
||||
public CutParameters Cutting { get; init; } = CutParameters.Default;
|
||||
|
||||
@@ -7,6 +7,7 @@ public class NestRequestPlate
|
||||
{
|
||||
public string Id { get; init; }
|
||||
public Size Size { get; init; }
|
||||
|
||||
/// <summary>Available physical sheets; null means unlimited.</summary>
|
||||
public int? Quantity { get; init; }
|
||||
public double PartSpacing { get; init; }
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
@@ -25,10 +26,12 @@ public class NestResponse
|
||||
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
public int SheetCount { get; init; }
|
||||
|
||||
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
|
||||
public double Utilization { get; init; }
|
||||
public TimeSpan CutTime { get; init; }
|
||||
public TimeSpan Elapsed { get; init; }
|
||||
|
||||
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
|
||||
public NestJobStatus? Status { get; init; }
|
||||
public NestJobStopReason? StopReason { get; init; }
|
||||
@@ -43,7 +46,7 @@ public class NestResponse
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
IncludeFields = true, // Required for OpenNest.Geometry.Size and Spacing public fields.
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public async Task SaveAsync(string path)
|
||||
@@ -61,19 +64,27 @@ public class NestResponse
|
||||
var responseEntry = zip.CreateEntry("response.json");
|
||||
await using (var stream = responseEntry.Open())
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, new NestResponseArchiveDto
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
SheetCount = SheetCount,
|
||||
Utilization = Utilization,
|
||||
CutTimeTicks = CutTime.Ticks,
|
||||
ElapsedTicks = Elapsed.Ticks,
|
||||
Status = Status,
|
||||
StopReason = StopReason,
|
||||
Fulfillment = Fulfillment is null ? [] : new List<NestPartFulfillment>(Fulfillment),
|
||||
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
|
||||
PlateStockMappings = PlateStockMappings is null ? [] : new List<NestPlateStockMapping>(PlateStockMappings)
|
||||
}, JsonOptions);
|
||||
await JsonSerializer.SerializeAsync(
|
||||
stream,
|
||||
new NestResponseArchiveDto
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
SheetCount = SheetCount,
|
||||
Utilization = Utilization,
|
||||
CutTimeTicks = CutTime.Ticks,
|
||||
ElapsedTicks = Elapsed.Ticks,
|
||||
Status = Status,
|
||||
StopReason = StopReason,
|
||||
Fulfillment = Fulfillment is null
|
||||
? []
|
||||
: new List<NestPartFulfillment>(Fulfillment),
|
||||
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
|
||||
PlateStockMappings = PlateStockMappings is null
|
||||
? []
|
||||
: new List<NestPlateStockMapping>(PlateStockMappings),
|
||||
},
|
||||
JsonOptions
|
||||
);
|
||||
}
|
||||
|
||||
var nestEntry = zip.CreateEntry("nest.nest");
|
||||
@@ -91,16 +102,19 @@ public class NestResponse
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
|
||||
using var zip = new ZipArchive(fs, ZipArchiveMode.Read);
|
||||
|
||||
var requestEntry = zip.GetEntry("request.json")
|
||||
var requestEntry =
|
||||
zip.GetEntry("request.json")
|
||||
?? throw new InvalidOperationException("Missing request.json in .nestquote file");
|
||||
NestRequest request;
|
||||
await using (var stream = requestEntry.Open())
|
||||
{
|
||||
request = await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions)
|
||||
request =
|
||||
await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions)
|
||||
?? throw new InvalidOperationException("Invalid request.json in .nestquote file");
|
||||
}
|
||||
|
||||
var responseEntry = zip.GetEntry("response.json")
|
||||
var responseEntry =
|
||||
zip.GetEntry("response.json")
|
||||
?? throw new InvalidOperationException("Missing response.json in .nestquote file");
|
||||
NestResponseArchiveDto archive;
|
||||
var hasSchemaVersion = false;
|
||||
@@ -110,16 +124,19 @@ public class NestResponse
|
||||
{
|
||||
var root = document.RootElement;
|
||||
hasSchemaVersion = root.TryGetProperty("schemaVersion", out _);
|
||||
hasStatusMetadata = root.TryGetProperty("status", out _) ||
|
||||
root.TryGetProperty("stopReason", out _) ||
|
||||
root.TryGetProperty("fulfillment", out _) ||
|
||||
root.TryGetProperty("stockUsage", out _) ||
|
||||
root.TryGetProperty("plateStockMappings", out _);
|
||||
archive = root.Deserialize<NestResponseArchiveDto>(JsonOptions)
|
||||
hasStatusMetadata =
|
||||
root.TryGetProperty("status", out _)
|
||||
|| root.TryGetProperty("stopReason", out _)
|
||||
|| root.TryGetProperty("fulfillment", out _)
|
||||
|| root.TryGetProperty("stockUsage", out _)
|
||||
|| root.TryGetProperty("plateStockMappings", out _);
|
||||
archive =
|
||||
root.Deserialize<NestResponseArchiveDto>(JsonOptions)
|
||||
?? throw new InvalidOperationException("Invalid response.json in .nestquote file");
|
||||
}
|
||||
|
||||
var nestEntry = zip.GetEntry("nest.nest")
|
||||
var nestEntry =
|
||||
zip.GetEntry("nest.nest")
|
||||
?? throw new InvalidOperationException("Missing nest.nest in .nestquote file");
|
||||
Nest nest;
|
||||
using (var nestMs = new MemoryStream())
|
||||
@@ -145,7 +162,7 @@ public class NestResponse
|
||||
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
|
||||
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
|
||||
Nest = nest,
|
||||
Request = request
|
||||
Request = request,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+107
-46
@@ -6,6 +6,9 @@ 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;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
@@ -16,10 +19,13 @@ public static class NestRunner
|
||||
public static Task<NestResponse> RunAsync(
|
||||
NestRequest request,
|
||||
IProgress<NestProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var requestParts = request.Parts ?? throw new ArgumentException("Request parts must not be null.", nameof(request));
|
||||
var requestParts =
|
||||
request.Parts
|
||||
?? throw new ArgumentException("Request parts must not be null.", nameof(request));
|
||||
if (requestParts.Count == 0)
|
||||
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
||||
|
||||
@@ -32,22 +38,32 @@ public static class NestRunner
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!File.Exists(part.Request.DxfPath))
|
||||
throw new FileNotFoundException($"DXF file not found: {part.Request.DxfPath}", part.Request.DxfPath);
|
||||
throw new FileNotFoundException(
|
||||
$"DXF file not found: {part.Request.DxfPath}",
|
||||
part.Request.DxfPath
|
||||
);
|
||||
|
||||
if (!importedByPath.TryGetValue(part.Request.DxfPath, out var drawing))
|
||||
{
|
||||
try
|
||||
{
|
||||
drawing = CadImporter.ImportDrawing(part.Request.DxfPath,
|
||||
new CadImportOptions { Quantity = part.Request.Quantity });
|
||||
drawing = CadImporter.ImportDrawing(
|
||||
part.Request.DxfPath,
|
||||
new CadImportOptions { Quantity = part.Request.Quantity }
|
||||
);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to import DXF: {part.Request.DxfPath}", exception);
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to import DXF: {part.Request.DxfPath}",
|
||||
exception
|
||||
);
|
||||
}
|
||||
|
||||
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
|
||||
throw new InvalidOperationException($"Failed to import DXF: {part.Request.DxfPath}");
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to import DXF: {part.Request.DxfPath}"
|
||||
);
|
||||
|
||||
importedByPath.Add(part.Request.DxfPath, drawing);
|
||||
}
|
||||
@@ -56,8 +72,11 @@ public static class NestRunner
|
||||
jobParts.Add(DrawingJobMapper.FromDrawing(part.Id, drawing, part.Request.Quantity));
|
||||
}
|
||||
|
||||
var job = new NestJob(jobParts, CreateStock(request),
|
||||
new NestJobOptions(ResolvePlacementStrategy(request)));
|
||||
var job = new NestJob(
|
||||
jobParts,
|
||||
CreateStock(request),
|
||||
new NestJobOptions(ResolvePlacementStrategy(request))
|
||||
);
|
||||
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
|
||||
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job, jobProgress, token);
|
||||
|
||||
@@ -71,35 +90,56 @@ public static class NestRunner
|
||||
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
|
||||
sw.Stop();
|
||||
|
||||
return Task.FromResult(new NestResponse
|
||||
{
|
||||
SheetCount = nest.Plates.Count,
|
||||
Utilization = CalculateUtilization(nest),
|
||||
CutTime = cutTime,
|
||||
Elapsed = sw.Elapsed,
|
||||
Status = result.Status,
|
||||
StopReason = result.StopReason,
|
||||
Fulfillment = result.Fulfillment
|
||||
.Select(value => new NestPartFulfillment(value.PartId, value.Requested, value.Placed, value.Unplaced))
|
||||
.ToArray(),
|
||||
StockUsage = result.StockUsage
|
||||
.Select(value => new NestStockUsage(value.StockId, value.Used, value.Remaining))
|
||||
.ToArray(),
|
||||
PlateStockMappings = result.Plates
|
||||
.Select(value => new NestPlateStockMapping(value.PlateIndex, value.StockId))
|
||||
.ToArray(),
|
||||
Nest = nest,
|
||||
Request = request
|
||||
});
|
||||
return Task.FromResult(
|
||||
new NestResponse
|
||||
{
|
||||
SheetCount = nest.Plates.Count,
|
||||
Utilization = CalculateUtilization(nest),
|
||||
CutTime = cutTime,
|
||||
Elapsed = sw.Elapsed,
|
||||
Status = result.Status,
|
||||
StopReason = result.StopReason,
|
||||
Fulfillment = result
|
||||
.Fulfillment.Select(value => new NestPartFulfillment(
|
||||
value.PartId,
|
||||
value.Requested,
|
||||
value.Placed,
|
||||
value.Unplaced
|
||||
))
|
||||
.ToArray(),
|
||||
StockUsage = result
|
||||
.StockUsage.Select(value => new NestStockUsage(
|
||||
value.StockId,
|
||||
value.Used,
|
||||
value.Remaining
|
||||
))
|
||||
.ToArray(),
|
||||
PlateStockMappings = result
|
||||
.Plates.Select(value => new NestPlateStockMapping(
|
||||
value.PlateIndex,
|
||||
value.StockId
|
||||
))
|
||||
.ToArray(),
|
||||
Nest = nest,
|
||||
Request = request,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IdentifiedRequestPart> IdentifyParts(IReadOnlyList<NestRequestPart> requestParts)
|
||||
private static IReadOnlyList<IdentifiedRequestPart> IdentifyParts(
|
||||
IReadOnlyList<NestRequestPart> requestParts
|
||||
)
|
||||
{
|
||||
var identified = new List<IdentifiedRequestPart>(requestParts.Count);
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var index = 0; index < requestParts.Count; index++)
|
||||
{
|
||||
var part = requestParts[index] ?? throw new ArgumentException("Request parts must not contain null entries.", nameof(requestParts));
|
||||
var part =
|
||||
requestParts[index]
|
||||
?? throw new ArgumentException(
|
||||
"Request parts must not contain null entries.",
|
||||
nameof(requestParts)
|
||||
);
|
||||
var id = part.Id ?? $"part-{index}";
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
|
||||
@@ -117,8 +157,12 @@ public static class NestRunner
|
||||
{
|
||||
return
|
||||
[
|
||||
new NestPlateStock(LegacyStockId, request.SheetSize, quantity: null,
|
||||
partSpacing: request.Spacing)
|
||||
new NestPlateStock(
|
||||
LegacyStockId,
|
||||
request.SheetSize,
|
||||
quantity: null,
|
||||
partSpacing: request.Spacing
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -126,9 +170,20 @@ public static class NestRunner
|
||||
foreach (var plate in request.Plates)
|
||||
{
|
||||
if (plate is null)
|
||||
throw new ArgumentException("Request plates must not contain null entries.", nameof(request));
|
||||
stock.Add(new NestPlateStock(plate.Id, plate.Size, plate.Quantity, plate.PartSpacing,
|
||||
plate.EdgeSpacing, plate.Quadrant));
|
||||
throw new ArgumentException(
|
||||
"Request plates must not contain null entries.",
|
||||
nameof(request)
|
||||
);
|
||||
stock.Add(
|
||||
new NestPlateStock(
|
||||
plate.Id,
|
||||
plate.Size,
|
||||
plate.Quantity,
|
||||
plate.PartSpacing,
|
||||
plate.EdgeSpacing,
|
||||
plate.Quadrant
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return stock;
|
||||
@@ -147,25 +202,31 @@ public static class NestRunner
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolvePlacementStrategy(NestRequest request) => request.PlacementStrategy ?? request.Strategy switch
|
||||
{
|
||||
NestStrategy.Auto => "Default",
|
||||
_ => throw new NotSupportedException($"Unknown legacy nesting strategy: {request.Strategy}.")
|
||||
};
|
||||
private static string ResolvePlacementStrategy(NestRequest request) =>
|
||||
request.PlacementStrategy
|
||||
?? request.Strategy switch
|
||||
{
|
||||
NestStrategy.Auto => "Default",
|
||||
_ => throw new NotSupportedException(
|
||||
$"Unknown legacy nesting strategy: {request.Strategy}."
|
||||
),
|
||||
};
|
||||
|
||||
private static double CalculateUtilization(Nest nest)
|
||||
{
|
||||
var sheetArea = nest.Plates.Sum(plate => plate.Area());
|
||||
if (sheetArea == 0) return 0;
|
||||
var placedArea = nest.Plates.Sum(plate => plate.Parts
|
||||
.Where(part => !part.BaseDrawing.IsCutOff)
|
||||
.Sum(part => part.BaseDrawing.Area));
|
||||
if (sheetArea == 0)
|
||||
return 0;
|
||||
var placedArea = nest.Plates.Sum(plate =>
|
||||
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area)
|
||||
);
|
||||
return placedArea / sheetArea;
|
||||
}
|
||||
|
||||
private sealed record IdentifiedRequestPart(string Id, NestRequestPart Request);
|
||||
|
||||
private sealed class JobProgressBridge(IProgress<NestProgress> progress) : IProgress<NestJobProgress>
|
||||
private sealed class JobProgressBridge(IProgress<NestProgress> progress)
|
||||
: IProgress<NestJobProgress>
|
||||
{
|
||||
public void Report(NestJobProgress value)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public enum NestStrategy { Auto }
|
||||
public enum NestStrategy
|
||||
{
|
||||
Auto,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>OpenNest.Api</RootNamespace>
|
||||
<AssemblyName>OpenNest.Api</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// One request to nest a specific drawing, with the quantity and rotation
|
||||
/// constraints pulled from its source .nest file.
|
||||
/// </summary>
|
||||
public class DrawingRequest
|
||||
{
|
||||
public Drawing Drawing { get; init; }
|
||||
public int Quantity { get; init; }
|
||||
public int Priority { get; init; }
|
||||
public double StepAngle { get; init; }
|
||||
public double RotationStart { get; init; }
|
||||
public double RotationEnd { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An immutable specification for one benchmark job: the full set of
|
||||
/// drawings/quantities that must be nested, and the pool of sheet sizes the
|
||||
/// engine may draw from while doing it. A single run may use several
|
||||
/// plates - possibly of different sizes - to place everything, the same
|
||||
/// way a real production job spreads across whatever plates it needs
|
||||
/// rather than being handed one fixed-size sheet.
|
||||
/// </summary>
|
||||
public class BenchmarkJob
|
||||
{
|
||||
public string SourceFile { get; init; }
|
||||
public List<Size> CandidateSizes { get; init; }
|
||||
public Spacing EdgeSpacing { get; init; }
|
||||
public double PartSpacing { get; init; }
|
||||
public int Quadrant { get; init; }
|
||||
|
||||
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
|
||||
public double SalvageRate { get; init; }
|
||||
|
||||
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
|
||||
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
|
||||
public List<DrawingRequest> Requests { get; init; }
|
||||
|
||||
public string Name => Path.GetFileNameWithoutExtension(SourceFile);
|
||||
|
||||
public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity);
|
||||
|
||||
/// <summary>Sheet area charged per unplaced part: the largest candidate
|
||||
/// sheet. Any single part that fits the stock at all fits on one such
|
||||
/// sheet, so placing a part is never scored worse than leaving it out.</summary>
|
||||
public double UnplacedPartPenalty =>
|
||||
CandidateSizes.Count == 0 ? 0 : CandidateSizes.Max(s => s.Width * s.Length);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the whole-job request this job represents: one NestJobPart per
|
||||
/// requested drawing, and one NestPlateStock per candidate sheet size
|
||||
/// (unlimited quantity - the engine under test decides how many of each
|
||||
/// size it actually uses, and how demand splits across plates). The
|
||||
/// engine owns its own multi-plate/size strategy; this harness no
|
||||
/// longer picks plate sizes on the engine's behalf.
|
||||
/// </summary>
|
||||
public NestJob BuildNestJob(
|
||||
int maxPlates,
|
||||
double? salvageRate = null,
|
||||
double? minimumSalvageDimension = null
|
||||
)
|
||||
{
|
||||
var parts = Requests.Select(r =>
|
||||
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
|
||||
);
|
||||
var stock = CandidateSizes.Select(size => new NestPlateStock(
|
||||
size.ToString(1),
|
||||
size,
|
||||
null,
|
||||
PartSpacing,
|
||||
EdgeSpacing,
|
||||
Quadrant
|
||||
));
|
||||
return new NestJob(
|
||||
parts,
|
||||
stock,
|
||||
new NestJobOptions(
|
||||
"Default",
|
||||
maxPlates,
|
||||
salvageRate ?? SalvageRate,
|
||||
minimumSalvageDimension ?? 0
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs every candidate engine against every job. Each engine is a full
|
||||
/// INestingEngine: it owns its own plate/size selection and multi-plate
|
||||
/// strategy for the whole job, rather than being handed one already-sized
|
||||
/// plate at a time by this harness. A per-run timeout guards against a
|
||||
/// runaway or hanging engine — cooperative cancellation, so it reliably
|
||||
/// stops engines built on NestJobRunner (all four built-ins) but can't
|
||||
/// forcibly interrupt an engine that never checks its token.
|
||||
/// </summary>
|
||||
public static class BenchmarkRunner
|
||||
{
|
||||
/// <summary>Physical-sheet cap passed to every job's NestJobOptions.MaxPlates.</summary>
|
||||
private const int MaxPlates = 40;
|
||||
|
||||
/// <summary>Wall-clock budget for one engine solving one job.</summary>
|
||||
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
public static List<JobResult> Run(
|
||||
List<BenchmarkJob> jobs,
|
||||
IReadOnlyList<NestingEngineInfo> engines,
|
||||
double? salvageRate = null,
|
||||
double? minimumSalvageDimension = null,
|
||||
string outputDirectory = null,
|
||||
int maxParallelism = 1,
|
||||
System.IO.TextWriter progressLog = null
|
||||
)
|
||||
{
|
||||
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
|
||||
.ToList();
|
||||
var results = new JobResult[pairs.Count];
|
||||
var options = new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = System.Math.Max(1, maxParallelism),
|
||||
};
|
||||
|
||||
var baselineResults = new JobResult[jobs.Count];
|
||||
Parallel.ForEach(
|
||||
Partitioner.Create(
|
||||
Enumerable.Range(0, jobs.Count),
|
||||
EnumerablePartitionerOptions.NoBuffering
|
||||
),
|
||||
options,
|
||||
i => baselineResults[i] = RunBaseline(jobs[i], salvageRate, minimumSalvageDimension)
|
||||
);
|
||||
|
||||
// NoBuffering hands out one pair at a time: solves run for seconds to minutes,
|
||||
// so chunked partitioning would leave workers idle behind a slow engine.
|
||||
Parallel.ForEach(
|
||||
Partitioner.Create(
|
||||
Enumerable.Range(0, pairs.Count),
|
||||
EnumerablePartitionerOptions.NoBuffering
|
||||
),
|
||||
options,
|
||||
i =>
|
||||
results[i] = RunOne(
|
||||
pairs[i].Job,
|
||||
pairs[i].Engine,
|
||||
salvageRate,
|
||||
minimumSalvageDimension,
|
||||
outputDirectory,
|
||||
progressLog
|
||||
)
|
||||
);
|
||||
|
||||
// Indexed writes keep the report in job-then-engine order whatever finishes first.
|
||||
var ordered = new List<JobResult>(
|
||||
results.Length + baselineResults.Count(result => result != null)
|
||||
);
|
||||
for (var jobIndex = 0; jobIndex < jobs.Count; jobIndex++)
|
||||
{
|
||||
if (baselineResults[jobIndex] != null)
|
||||
ordered.Add(baselineResults[jobIndex]);
|
||||
var firstResult = jobIndex * engines.Count;
|
||||
for (var engineIndex = 0; engineIndex < engines.Count; engineIndex++)
|
||||
ordered.Add(results[firstResult + engineIndex]);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static JobResult RunBaseline(
|
||||
BenchmarkJob job,
|
||||
double? salvageRate,
|
||||
double? minimumSalvageDimension
|
||||
)
|
||||
{
|
||||
if (job.BaselinePlateRuns == null)
|
||||
return null;
|
||||
var requested = job.TotalRequestedQuantity;
|
||||
try
|
||||
{
|
||||
var requirements = job.Requests.ToDictionary<
|
||||
DrawingRequest,
|
||||
Drawing,
|
||||
(string Name, int Quantity)
|
||||
>(
|
||||
request => request.Drawing,
|
||||
request => (request.Drawing.Name, request.Quantity),
|
||||
ReferenceEqualityComparer.Instance
|
||||
);
|
||||
var partIds = job.Requests.ToDictionary<DrawingRequest, Drawing, string>(
|
||||
request => request.Drawing,
|
||||
request => request.Drawing.Id.ToString(),
|
||||
ReferenceEqualityComparer.Instance
|
||||
);
|
||||
var validation = NestValidator.Validate(job.BaselinePlateRuns, requirements);
|
||||
var benchmarkJob = job.BuildNestJob(
|
||||
MaxPlates,
|
||||
salvageRate,
|
||||
minimumSalvageDimension
|
||||
);
|
||||
var instanceIndices = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var plateResults = job
|
||||
.BaselinePlateRuns.Select(
|
||||
(run, index) =>
|
||||
{
|
||||
var stock = new NestPlateStock(
|
||||
$"baseline-{index}",
|
||||
run.Plate.Size,
|
||||
1,
|
||||
run.Plate.PartSpacing,
|
||||
run.Plate.EdgeSpacing,
|
||||
run.Plate.Quadrant
|
||||
);
|
||||
var placements = run
|
||||
.Parts.Select(part =>
|
||||
{
|
||||
var partId = partIds[part.BaseDrawing];
|
||||
instanceIndices.TryGetValue(partId, out var instanceIndex);
|
||||
instanceIndices[partId] = instanceIndex + 1;
|
||||
return new NestJobPlacement(
|
||||
partId,
|
||||
instanceIndex,
|
||||
part.Location.X,
|
||||
part.Location.Y,
|
||||
part.Rotation
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
return new NestJobPlateResult(index, stock, placements);
|
||||
}
|
||||
)
|
||||
.ToList();
|
||||
var baselineJob = new NestJob(
|
||||
benchmarkJob.Parts,
|
||||
plateResults.Select(result => result.Stock),
|
||||
benchmarkJob.Options
|
||||
);
|
||||
var baselineJobResult = new NestJobResult(
|
||||
NestJobStatus.Complete,
|
||||
NestJobStopReason.Completed,
|
||||
plateResults,
|
||||
Array.Empty<PartFulfillment>(),
|
||||
Array.Empty<StockUsage>()
|
||||
);
|
||||
NestValidator.ValidateAgainstJob(
|
||||
baselineJob,
|
||||
baselineJobResult,
|
||||
job.Requests.ToDictionary(
|
||||
request => request.Drawing.Id.ToString(),
|
||||
request => request.Drawing.Name
|
||||
),
|
||||
validation
|
||||
);
|
||||
|
||||
var plateRuns = job.BaselinePlateRuns;
|
||||
var placedArea = validation.Valid
|
||||
? plateRuns.Sum(run => run.Parts.Sum(part => part.BaseDrawing.Area))
|
||||
: 0;
|
||||
var plateArea = plateRuns.Sum(run => run.Plate.Area());
|
||||
var netSheetArea = validation.Valid
|
||||
? plateResults.Sum(result =>
|
||||
NestJobCost.NetSheetArea(baselineJob, result)
|
||||
)
|
||||
: 0;
|
||||
var sizeBreakdown = plateRuns
|
||||
.GroupBy(run => run.Plate.Size.ToString(1))
|
||||
.OrderByDescending(group => group.Count())
|
||||
.ToDictionary(group => group.Key, group => group.Count());
|
||||
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = "Baseline",
|
||||
JobName = job.Name,
|
||||
Valid = validation.Valid,
|
||||
Violations = validation.Violations,
|
||||
PartsPlaced = plateRuns.Sum(run => run.Parts.Count),
|
||||
PartsRequested = requested,
|
||||
PlacedArea = placedArea,
|
||||
PlateArea = plateArea,
|
||||
NetSheetArea = netSheetArea,
|
||||
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||
PlatesUsed = plateRuns.Count,
|
||||
SizeBreakdown = sizeBreakdown,
|
||||
ElapsedMs = 0,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = "Baseline",
|
||||
JobName = job.Name,
|
||||
Valid = false,
|
||||
PartsRequested = requested,
|
||||
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||
Error = $"{ex.GetType().Name}: {ex.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static JobResult RunOne(
|
||||
BenchmarkJob job,
|
||||
NestingEngineInfo engineInfo,
|
||||
double? salvageRate,
|
||||
double? minimumSalvageDimension,
|
||||
string outputDirectory,
|
||||
System.IO.TextWriter progressLog
|
||||
)
|
||||
{
|
||||
var requested = job.TotalRequestedQuantity;
|
||||
var log = progressLog == null
|
||||
? null
|
||||
: new JobProgressLog(progressLog, $"{job.Name}/{engineInfo.Name}");
|
||||
log?.Started();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
|
||||
var engine = engineInfo.Factory();
|
||||
using var cts = new CancellationTokenSource(SolveTimeout);
|
||||
var jobResult = engine.Solve(nestJob, log, cts.Token);
|
||||
log?.Finished(jobResult, sw.ElapsedMilliseconds);
|
||||
|
||||
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
|
||||
var plateRuns = materialized
|
||||
.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList()))
|
||||
.ToList();
|
||||
|
||||
var requirements = job.Requests.ToDictionary<
|
||||
DrawingRequest,
|
||||
Drawing,
|
||||
(string Name, int Quantity)
|
||||
>(
|
||||
r => materialized.DrawingsByPartId[r.Drawing.Id.ToString()],
|
||||
r => (r.Drawing.Name, r.Quantity),
|
||||
ReferenceEqualityComparer.Instance
|
||||
);
|
||||
|
||||
var validation = NestValidator.Validate(plateRuns, requirements);
|
||||
NestValidator.ValidateAgainstJob(
|
||||
nestJob,
|
||||
jobResult,
|
||||
job.Requests.ToDictionary(r => r.Drawing.Id.ToString(), r => r.Drawing.Name),
|
||||
validation
|
||||
);
|
||||
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
|
||||
var placedArea = validation.Valid
|
||||
? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area))
|
||||
: 0;
|
||||
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
|
||||
// Salvage credit is recomputed from the job's own geometry, never taken from the engine.
|
||||
var netSheetArea = validation.Valid
|
||||
? jobResult.Plates.Sum(p =>
|
||||
NestJobCost.NetSheetArea(nestJob, p)
|
||||
)
|
||||
: 0;
|
||||
|
||||
var sizeBreakdown = plateRuns
|
||||
.GroupBy(pr => pr.Plate.Size.ToString(1))
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
if (validation.Valid && outputDirectory != null)
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(outputDirectory);
|
||||
// Keep names and job metadata for a useful inspectable output; never modify source.
|
||||
// Manifest jobs have no source nest to copy from, so they keep the job's name.
|
||||
if (job.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var source = new OpenNest.IO.NestReader(job.SourceFile).Read();
|
||||
materialized.Nest.Name = source.Name;
|
||||
materialized.Nest.Units = source.Units;
|
||||
materialized.Nest.Material = source.Material;
|
||||
materialized.Nest.Thickness = source.Thickness;
|
||||
}
|
||||
else
|
||||
{
|
||||
materialized.Nest.Name = job.Name;
|
||||
}
|
||||
materialized.Nest.SalvageRate = nestJob.Options.SalvageRate;
|
||||
foreach (var request in job.Requests)
|
||||
materialized.DrawingsByPartId[request.Drawing.Id.ToString()].Name = request
|
||||
.Drawing
|
||||
.Name;
|
||||
var path = System.IO.Path.Combine(
|
||||
outputDirectory,
|
||||
$"{job.Name}-{engineInfo.Name}.nest"
|
||||
);
|
||||
if (
|
||||
System.IO.Path.GetFullPath(path)
|
||||
== System.IO.Path.GetFullPath(job.SourceFile)
|
||||
)
|
||||
throw new InvalidOperationException(
|
||||
"Output must not overwrite the source nest."
|
||||
);
|
||||
new OpenNest.IO.NestWriter(materialized.Nest).Write(path);
|
||||
var report = new
|
||||
{
|
||||
Source = job.SourceFile,
|
||||
Engine = engineInfo.Name,
|
||||
jobResult.Status,
|
||||
jobResult.StopReason,
|
||||
Requested = requested,
|
||||
Placed = totalPlaced,
|
||||
SheetArea = plateArea,
|
||||
PlacedArea = placedArea,
|
||||
SalvageRate = nestJob.Options.SalvageRate,
|
||||
MinimumSalvageDimension = nestJob.Options.MinimumSalvageDimension,
|
||||
EstimatedNetArea = netSheetArea,
|
||||
Fulfillment = jobResult.Fulfillment,
|
||||
StockUsage = jobResult.StockUsage,
|
||||
Plates = jobResult.Plates,
|
||||
validation.Violations,
|
||||
};
|
||||
System.IO.File.WriteAllText(
|
||||
System.IO.Path.ChangeExtension(path, ".json"),
|
||||
System.Text.Json.JsonSerializer.Serialize(
|
||||
report,
|
||||
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }
|
||||
)
|
||||
);
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
JobName = job.Name,
|
||||
Valid = validation.Valid,
|
||||
Violations = validation.Violations,
|
||||
PartsPlaced = totalPlaced,
|
||||
PartsRequested = requested,
|
||||
PlacedArea = placedArea,
|
||||
PlateArea = plateArea,
|
||||
NetSheetArea = netSheetArea,
|
||||
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||
PlatesUsed = plateRuns.Count,
|
||||
SizeBreakdown = sizeBreakdown,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
sw.Stop();
|
||||
log?.Failed("timed out", sw.ElapsedMilliseconds);
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
JobName = job.Name,
|
||||
Valid = false,
|
||||
PartsRequested = requested,
|
||||
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)",
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
log?.Failed($"{ex.GetType().Name}: {ex.Message}", sw.ElapsedMilliseconds);
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
JobName = job.Name,
|
||||
Valid = false,
|
||||
PartsRequested = requested,
|
||||
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
Error = $"{ex.GetType().Name}: {ex.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a BenchmarkJob from a JSON manifest that lists DXF files and the
|
||||
/// quantity of each to nest. DXF paths resolve relative to the manifest.
|
||||
/// Sheet sizes come from the manifest or the caller's override; unlike a
|
||||
/// .nest file there is no plate to inherit them from, so a job with none is
|
||||
/// an error. Sheet sizes must use the same units as the DXFs.
|
||||
/// </summary>
|
||||
public static class DxfManifestLoader
|
||||
{
|
||||
/// <summary>Suffix a manifest needs to be picked up when scanning a folder.</summary>
|
||||
public const string FolderSuffix = ".manifest.json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
public static BenchmarkJob Load(
|
||||
string manifestPath,
|
||||
IReadOnlyList<Size> sheetSizeOverrides = null,
|
||||
double? partSpacingOverride = null
|
||||
)
|
||||
{
|
||||
var manifest = ReadManifest(manifestPath);
|
||||
var baseDir = Path.GetDirectoryName(Path.GetFullPath(manifestPath));
|
||||
|
||||
if (manifest.Parts == null || manifest.Parts.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest '{manifestPath}' has no parts. Add entries to \"parts\"."
|
||||
);
|
||||
|
||||
var sizes = ResolveSheetSizes(manifest, sheetSizeOverrides, manifestPath);
|
||||
var requests = manifest.Parts.Select(p => BuildRequest(p, baseDir)).ToList();
|
||||
|
||||
return new BenchmarkJob
|
||||
{
|
||||
SourceFile = manifestPath,
|
||||
CandidateSizes = sizes,
|
||||
EdgeSpacing = new Spacing(manifest.EdgeSpacing, manifest.EdgeSpacing),
|
||||
PartSpacing = partSpacingOverride ?? manifest.Spacing,
|
||||
Quadrant = manifest.Quadrant,
|
||||
Requests = requests,
|
||||
};
|
||||
}
|
||||
|
||||
private static Manifest ReadManifest(string manifestPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Manifest>(
|
||||
File.ReadAllText(manifestPath),
|
||||
JsonOptions
|
||||
) ?? throw new InvalidOperationException("The manifest is empty.");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest '{manifestPath}' is not valid JSON: {ex.Message}",
|
||||
ex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Size> ResolveSheetSizes(
|
||||
Manifest manifest,
|
||||
IReadOnlyList<Size> overrides,
|
||||
string manifestPath
|
||||
)
|
||||
{
|
||||
if (overrides != null && overrides.Count > 0)
|
||||
return overrides.ToList();
|
||||
|
||||
var sizes = new List<Size>();
|
||||
|
||||
foreach (var text in manifest.SheetSizes ?? new List<string>())
|
||||
{
|
||||
if (!JobLoader.TryParseSheetSize(text, out var size))
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest '{manifestPath}': could not parse sheet size '{text}' (expected e.g. \"48x96\")."
|
||||
);
|
||||
|
||||
sizes.Add(size);
|
||||
}
|
||||
|
||||
if (sizes.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest '{manifestPath}' has no sheet sizes. Set \"sheetSizes\" or pass --sheet-sizes."
|
||||
);
|
||||
|
||||
return sizes.Distinct().ToList();
|
||||
}
|
||||
|
||||
private static DrawingRequest BuildRequest(ManifestPart part, string baseDir)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(part.Dxf))
|
||||
throw new InvalidOperationException("A manifest part is missing \"dxf\".");
|
||||
|
||||
if (part.Quantity <= 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest part '{part.Dxf}': quantity must be greater than 0 (was {part.Quantity})."
|
||||
);
|
||||
|
||||
var dxfPath = Path.GetFullPath(Path.Combine(baseDir, part.Dxf));
|
||||
|
||||
if (!File.Exists(dxfPath))
|
||||
throw new FileNotFoundException($"DXF file not found: {dxfPath}", dxfPath);
|
||||
|
||||
Drawing drawing;
|
||||
|
||||
try
|
||||
{
|
||||
drawing = CadImporter.ImportDrawing(
|
||||
dxfPath,
|
||||
new CadImportOptions { Quantity = part.Quantity }
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}", ex);
|
||||
}
|
||||
|
||||
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
|
||||
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}");
|
||||
|
||||
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
|
||||
if (!part.AllowRotation)
|
||||
{
|
||||
drawing.Constraints ??= new NestConstraints();
|
||||
drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI;
|
||||
drawing.Constraints.StartAngle = 0;
|
||||
drawing.Constraints.EndAngle = 0;
|
||||
}
|
||||
|
||||
var constraints = drawing.Constraints;
|
||||
|
||||
return new DrawingRequest
|
||||
{
|
||||
Drawing = drawing,
|
||||
Quantity = part.Quantity,
|
||||
Priority = drawing.Priority,
|
||||
StepAngle = constraints?.StepAngle ?? 0,
|
||||
RotationStart = constraints?.StartAngle ?? 0,
|
||||
RotationEnd = constraints?.EndAngle ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
private class Manifest
|
||||
{
|
||||
public List<string> SheetSizes { get; set; }
|
||||
public double Spacing { get; set; }
|
||||
public double EdgeSpacing { get; set; }
|
||||
public int Quadrant { get; set; } = 1;
|
||||
public List<ManifestPart> Parts { get; set; }
|
||||
}
|
||||
|
||||
private class ManifestPart
|
||||
{
|
||||
public string Dxf { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
public bool AllowRotation { get; set; } = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds BenchmarkJobs from .nest files on disk. Fully generic: works on
|
||||
/// any valid .nest file, using whatever drawings/quantities/plate settings
|
||||
/// it contains. One job per file, carrying the full pool of candidate
|
||||
/// sheet sizes the engine may use across the whole nest - by default the
|
||||
/// distinct sizes already present in that file, or a fixed override list
|
||||
/// (e.g. a standard sheet-size lineup) applied to every file.
|
||||
/// </summary>
|
||||
public static class JobLoader
|
||||
{
|
||||
public static List<BenchmarkJob> Load(
|
||||
string inputPath,
|
||||
IReadOnlyList<Size> sheetSizeOverrides = null,
|
||||
double? partSpacingOverride = null
|
||||
)
|
||||
{
|
||||
var files = ResolveFiles(inputPath);
|
||||
var jobs = new List<BenchmarkJob>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Hand-written manifests fail loudly rather than being skipped like unreadable .nest files.
|
||||
jobs.Add(DxfManifestLoader.Load(file, sheetSizeOverrides, partSpacingOverride));
|
||||
continue;
|
||||
}
|
||||
|
||||
Nest nest;
|
||||
|
||||
try
|
||||
{
|
||||
nest = new NestReader(file).Read();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[JobLoader] Skipping '{file}': failed to read ({ex.Message})"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
var requests = BuildRequests(nest);
|
||||
|
||||
if (requests.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[JobLoader] Skipping '{file}': no drawings with quantity > 0"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
var template = ResolvePlateTemplate(nest);
|
||||
var sizes =
|
||||
sheetSizeOverrides != null && sheetSizeOverrides.Count > 0
|
||||
? sheetSizeOverrides.ToList()
|
||||
: ResolveSheetSizes(nest);
|
||||
|
||||
jobs.Add(
|
||||
new BenchmarkJob
|
||||
{
|
||||
SourceFile = file,
|
||||
CandidateSizes = sizes,
|
||||
EdgeSpacing = template.EdgeSpacing,
|
||||
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
||||
Quadrant = template.Quadrant,
|
||||
SalvageRate = nest.SalvageRate,
|
||||
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
|
||||
Requests = requests,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
/// <summary>Parses "WxL" with invariant-culture numbers, so "48.5x96" means the
|
||||
/// same thing on every machine (Size.Parse follows the current culture).</summary>
|
||||
public static bool TryParseSheetSize(string text, out Size size)
|
||||
{
|
||||
size = default;
|
||||
var dims = text?.Split('x', 'X');
|
||||
|
||||
if (dims == null || dims.Length != 2)
|
||||
return false;
|
||||
|
||||
var style = System.Globalization.NumberStyles.Float;
|
||||
var culture = System.Globalization.CultureInfo.InvariantCulture;
|
||||
|
||||
if (
|
||||
!double.TryParse(dims[0].Trim(), style, culture, out var width)
|
||||
|| !double.TryParse(dims[1].Trim(), style, culture, out var length)
|
||||
)
|
||||
return false;
|
||||
|
||||
size = new Size(width, length);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<string> ResolveFiles(string inputPath)
|
||||
{
|
||||
if (Directory.Exists(inputPath))
|
||||
{
|
||||
return Directory
|
||||
.EnumerateFiles(inputPath, "*", SearchOption.AllDirectories)
|
||||
.Where(f =>
|
||||
f.EndsWith(".nest", StringComparison.OrdinalIgnoreCase)
|
||||
|| f.EndsWith(
|
||||
DxfManifestLoader.FolderSuffix,
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
)
|
||||
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (File.Exists(inputPath))
|
||||
return new List<string> { inputPath };
|
||||
|
||||
throw new FileNotFoundException($"Benchmark input not found: {inputPath}");
|
||||
}
|
||||
|
||||
private static List<DrawingRequest> BuildRequests(Nest nest)
|
||||
{
|
||||
var requests = new List<DrawingRequest>();
|
||||
|
||||
foreach (var drawing in nest.Drawings)
|
||||
{
|
||||
var qty = drawing.Quantity.Required;
|
||||
|
||||
if (qty <= 0)
|
||||
continue;
|
||||
|
||||
var constraints = drawing.Constraints;
|
||||
|
||||
requests.Add(
|
||||
new DrawingRequest
|
||||
{
|
||||
Drawing = drawing,
|
||||
Quantity = qty,
|
||||
Priority = drawing.Priority,
|
||||
StepAngle = constraints?.StepAngle ?? 0,
|
||||
RotationStart = constraints?.StartAngle ?? 0,
|
||||
RotationEnd = constraints?.EndAngle ?? 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
private static List<(Plate Plate, List<Part> Parts)> BuildBaselinePlateRuns(
|
||||
Nest nest,
|
||||
double? partSpacingOverride
|
||||
)
|
||||
{
|
||||
var runs = new List<(Plate Plate, List<Part> Parts)>();
|
||||
foreach (var plate in nest.Plates ?? Enumerable.Empty<Plate>())
|
||||
{
|
||||
var parts = plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).ToList();
|
||||
if (parts.Count == 0)
|
||||
continue;
|
||||
var validationPlate = new Plate(new Size(plate.Size.Width, plate.Size.Length))
|
||||
{
|
||||
Quantity = 1,
|
||||
Quadrant = plate.Quadrant,
|
||||
PartSpacing = partSpacingOverride ?? plate.PartSpacing,
|
||||
EdgeSpacing = new Spacing(
|
||||
plate.EdgeSpacing.Left,
|
||||
plate.EdgeSpacing.Bottom,
|
||||
plate.EdgeSpacing.Right,
|
||||
plate.EdgeSpacing.Top
|
||||
),
|
||||
};
|
||||
for (var copy = 0; copy < plate.Quantity; copy++)
|
||||
runs.Add((validationPlate, parts));
|
||||
}
|
||||
return runs.Count > 0 ? runs : null;
|
||||
}
|
||||
|
||||
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(
|
||||
Nest nest
|
||||
)
|
||||
{
|
||||
var source = nest.Plates?.FirstOrDefault();
|
||||
|
||||
if (source != null)
|
||||
return (source.EdgeSpacing, source.PartSpacing, source.Quadrant);
|
||||
|
||||
var defaults = nest.PlateDefaults;
|
||||
return (defaults.EdgeSpacing, defaults.PartSpacing, defaults.Quadrant);
|
||||
}
|
||||
|
||||
private static List<Size> ResolveSheetSizes(Nest nest)
|
||||
{
|
||||
var sizes = (nest.Plates ?? Enumerable.Empty<Plate>())
|
||||
.Select(p => p.Size)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (sizes.Count == 0)
|
||||
sizes.Add(nest.PlateDefaults.Size);
|
||||
|
||||
return sizes;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes one solve's NestJobProgress as log lines prefixed with "[job/engine]". Every
|
||||
/// PlateCommitted is written; EvaluatingCandidate is throttled to one line per interval so a
|
||||
/// chatty engine cannot flood the console. One instance per solve; Report is thread-safe.
|
||||
/// </summary>
|
||||
public sealed class JobProgressLog : IProgress<NestJobProgress>
|
||||
{
|
||||
public static readonly TimeSpan DefaultInterval = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly TextWriter writer;
|
||||
private readonly string label;
|
||||
private readonly TimeSpan interval;
|
||||
private readonly Func<TimeSpan> clock;
|
||||
private readonly object sync = new();
|
||||
private TimeSpan? lastEvaluating;
|
||||
|
||||
public JobProgressLog(
|
||||
TextWriter writer,
|
||||
string label,
|
||||
TimeSpan? interval = null,
|
||||
Func<TimeSpan> clock = null
|
||||
)
|
||||
{
|
||||
this.writer = writer ?? throw new ArgumentNullException(nameof(writer));
|
||||
this.label = label;
|
||||
this.interval = interval ?? DefaultInterval;
|
||||
if (clock == null)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
clock = () => stopwatch.Elapsed;
|
||||
}
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public void Started() => Write("started");
|
||||
|
||||
public void Finished(NestJobResult result, long elapsedMs) =>
|
||||
Write(
|
||||
$"finished in {elapsedMs} ms: {result.Status} ({result.StopReason}), "
|
||||
+ $"{result.Plates.Count} plate(s)"
|
||||
);
|
||||
|
||||
public void Failed(string error, long elapsedMs) =>
|
||||
Write($"failed after {elapsedMs} ms: {error}");
|
||||
|
||||
public void Report(NestJobProgress value)
|
||||
{
|
||||
if (value == null)
|
||||
return;
|
||||
|
||||
if (value.Stage == NestJobStage.PlateCommitted)
|
||||
{
|
||||
Write(
|
||||
$"committed plate {value.CommittedPlates} on stock {value.StockId} "
|
||||
+ $"({value.CommittedParts} parts placed)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
lock (sync)
|
||||
{
|
||||
var now = clock();
|
||||
if (lastEvaluating.HasValue && now - lastEvaluating.Value < interval)
|
||||
return;
|
||||
lastEvaluating = now;
|
||||
}
|
||||
|
||||
var plate = value.PlateIndex >= 0 ? value.PlateIndex + 1 : value.CommittedPlates + 1;
|
||||
Write(
|
||||
$"evaluating plate {plate} on stock {value.StockId} "
|
||||
+ $"({value.CommittedPlates} plate(s), {value.CommittedParts} parts committed)"
|
||||
);
|
||||
}
|
||||
|
||||
private void Write(string message)
|
||||
{
|
||||
lock (sync)
|
||||
writer.WriteLine($"[{label}] {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Outcome of running one engine against one job. A job may span several
|
||||
/// plates (PlatesUsed, SizeBreakdown), since the engine may need more than
|
||||
/// one sheet - possibly of different sizes - to place everything asked of
|
||||
/// it. An invalid or crashed run places nothing as far as scoring is
|
||||
/// concerned: it earns no area and pays the unplaced penalty on every
|
||||
/// requested part.
|
||||
/// </summary>
|
||||
public class JobResult
|
||||
{
|
||||
public string EngineName { get; init; }
|
||||
public string JobName { get; init; }
|
||||
public bool Valid { get; init; }
|
||||
public List<string> Violations { get; init; } = new();
|
||||
public string Error { get; init; }
|
||||
public int PartsPlaced { get; init; }
|
||||
public int PartsRequested { get; init; }
|
||||
public double PlacedArea { get; init; }
|
||||
public double PlateArea { get; init; }
|
||||
|
||||
/// <summary>Sheet area consumed after crediting salvageable offcuts
|
||||
/// (NestJobCost.NetSheetArea summed over every plate).
|
||||
/// Equals PlateArea when salvage credit is disabled.</summary>
|
||||
public double NetSheetArea { get; init; }
|
||||
|
||||
/// <summary>Sheet area charged for each requested part that was not
|
||||
/// placed: the largest candidate sheet's area, so leaving a part out
|
||||
/// always costs at least as much as the extra sheet it would need.</summary>
|
||||
public double UnplacedPartPenalty { get; init; }
|
||||
|
||||
public int PlatesUsed { get; init; }
|
||||
public Dictionary<string, int> SizeBreakdown { get; init; } = new();
|
||||
public long ElapsedMs { get; init; }
|
||||
|
||||
public bool Crashed => Error != null;
|
||||
public bool FullyPlaced => Valid && PartsRequested > 0 && PartsPlaced >= PartsRequested;
|
||||
|
||||
/// <summary>Aggregate utilization across every plate the engine used:
|
||||
/// total placed drawing area over total plate area, matching
|
||||
/// Plate.Utilization()'s per-plate definition summed across the job.</summary>
|
||||
public double Utilization => Valid && PlateArea > 0 ? PlacedArea / PlateArea : 0;
|
||||
|
||||
/// <summary>Placed area over salvage-credited sheet area.</summary>
|
||||
public double NetUtilization =>
|
||||
Valid && NetSheetArea > 0 ? PlacedArea / NetSheetArea : 0;
|
||||
|
||||
public int PartsUnplaced =>
|
||||
Valid ? System.Math.Max(0, PartsRequested - PartsPlaced) : PartsRequested;
|
||||
|
||||
/// <summary>
|
||||
/// The ranking score, in sheet area (lower is better): net sheet area
|
||||
/// consumed plus the unplaced penalty. An engine cannot improve it by
|
||||
/// dropping awkward parts, and it sums honestly across jobs of
|
||||
/// different sizes. Invalid runs consume no sheet but pay the penalty
|
||||
/// on every requested part.
|
||||
/// </summary>
|
||||
public double Cost => (Valid ? NetSheetArea : 0) + PartsUnplaced * UnplacedPartPenalty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Benchmark;
|
||||
|
||||
/// <summary>Benchmark validation outcome.</summary>
|
||||
public class ValidationResult
|
||||
{
|
||||
public bool Valid => Violations.Count == 0;
|
||||
public List<string> Violations { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Compatibility wrapper over the shared layout validation contract.</summary>
|
||||
public static class NestValidator
|
||||
{
|
||||
/// <summary>Checks materialized plates using drawing-reference requirement identity.</summary>
|
||||
public static ValidationResult Validate(
|
||||
List<(Plate Plate, List<Part> Parts)> plateRuns,
|
||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
result.Violations.AddRange(NestLayoutCheck.Validate(plateRuns, requirements));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Appends offered-stock, finite-stock and rotation-policy violations.</summary>
|
||||
public static void ValidateAgainstJob(NestJob job, NestJobResult jobResult,
|
||||
IReadOnlyDictionary<string, string> displayNames, ValidationResult result) =>
|
||||
NestLayoutCheck.ValidateAgainstJob(job, jobResult, displayNames, result.Violations);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>OpenNest.Benchmark</RootNamespace>
|
||||
<AssemblyName>OpenNest.Benchmark</AssemblyName>
|
||||
<Nullable>disable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,349 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest;
|
||||
using OpenNest.Benchmark;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
return BenchmarkConsole.Run(args);
|
||||
|
||||
static class BenchmarkConsole
|
||||
{
|
||||
public static int Run(string[] args)
|
||||
{
|
||||
var options = ParseArgs(args);
|
||||
|
||||
if (options == null)
|
||||
return 0; // --help was requested
|
||||
|
||||
if (options.InputPath == null)
|
||||
{
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
List<BenchmarkJob> jobs;
|
||||
|
||||
try
|
||||
{
|
||||
jobs = JobLoader.Load(options.InputPath, options.SheetSizes, options.PartSpacing);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (jobs.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"No benchmark jobs found (no .nest files with any drawing quantity > 0, or *.manifest.json files)."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var enginesDir = Path.Combine(AppContext.BaseDirectory, "Engines");
|
||||
NestingEngineRegistry.LoadPlugins(enginesDir);
|
||||
|
||||
var engines = NestingEngineRegistry.AvailableEngines;
|
||||
|
||||
if (options.EngineNames.Count > 0)
|
||||
{
|
||||
engines = engines
|
||||
.Where(e =>
|
||||
options.EngineNames.Any(n =>
|
||||
n.Equals(e.Name, StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
)
|
||||
.ToList();
|
||||
|
||||
if (engines.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"None of the requested engines are registered. Available: "
|
||||
+ string.Join(
|
||||
", ",
|
||||
NestingEngineRegistry.AvailableEngines.Select(e => e.Name)
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"Loaded {jobs.Count} job(s) from '{options.InputPath}'");
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
var sizes = string.Join(", ", job.CandidateSizes.Select(s => s.ToString(1)));
|
||||
Console.WriteLine(
|
||||
$" {job.Name}: {job.Requests.Count} drawing(s), {job.TotalRequestedQuantity} part(s) requested, candidate sizes: {sizes}"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
options.SheetSizes.Count == 0
|
||||
&& jobs.Any(j => j.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
|
||||
)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Warning: no --sheet-sizes given, so each .nest job only offers the sheet sizes its "
|
||||
+ "original layout used - a hint toward that answer. Pass --sheet-sizes with the "
|
||||
+ "sizes you actually stock for an unbiased comparison."
|
||||
);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
|
||||
|
||||
var effectiveSalvageRates = jobs.Select(job => options.SalvageRate ?? job.SalvageRate);
|
||||
if (
|
||||
effectiveSalvageRates.Any(rate => rate > 0)
|
||||
&& (options.MinimumSalvageDimension ?? 0) <= 0
|
||||
)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Warning: salvage credit is disabled because --min-salvage-dimension was not set to a positive value."
|
||||
);
|
||||
}
|
||||
|
||||
var solves = jobs.Count * engines.Count;
|
||||
|
||||
if (options.Parallel > 1 && solves > 1)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"Running up to {options.Parallel} solves at a time; Time(ms) is measured under that "
|
||||
+ "concurrent load. Use --parallel 1 for strictly isolated timings."
|
||||
);
|
||||
}
|
||||
|
||||
var results = BenchmarkRunner.Run(
|
||||
jobs,
|
||||
engines,
|
||||
options.SalvageRate,
|
||||
options.MinimumSalvageDimension,
|
||||
options.OutputDirectory,
|
||||
options.Parallel,
|
||||
options.Progress ? Console.Out : null
|
||||
);
|
||||
|
||||
Report.PrintDetailed(results);
|
||||
Report.PrintSummary(results);
|
||||
|
||||
if (options.CsvPath != null)
|
||||
{
|
||||
Report.WriteCsv(options.CsvPath, results);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Wrote CSV report to {options.CsvPath}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Options ParseArgs(string[] args)
|
||||
{
|
||||
var o = new Options();
|
||||
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--sheet-sizes" when i + 1 < args.Length:
|
||||
o.SheetSizes = ParseSheetSizes(args[++i]);
|
||||
break;
|
||||
|
||||
case "--spacing" when i + 1 < args.Length:
|
||||
o.PartSpacing = double.Parse(
|
||||
args[++i],
|
||||
System.Globalization.CultureInfo.InvariantCulture
|
||||
);
|
||||
break;
|
||||
|
||||
case "--engines" when i + 1 < args.Length:
|
||||
o.EngineNames = args[++i]
|
||||
.Split(
|
||||
',',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
|
||||
)
|
||||
.ToList();
|
||||
break;
|
||||
|
||||
case "--csv" when i + 1 < args.Length:
|
||||
o.CsvPath = args[++i];
|
||||
break;
|
||||
|
||||
case "--salvage-rate" when i + 1 < args.Length:
|
||||
o.SalvageRate = double.Parse(
|
||||
args[++i],
|
||||
System.Globalization.CultureInfo.InvariantCulture
|
||||
);
|
||||
break;
|
||||
case "--min-salvage-dimension" when i + 1 < args.Length:
|
||||
o.MinimumSalvageDimension = double.Parse(
|
||||
args[++i],
|
||||
System.Globalization.CultureInfo.InvariantCulture
|
||||
);
|
||||
break;
|
||||
case "--output" when i + 1 < args.Length:
|
||||
o.OutputDirectory = args[++i];
|
||||
break;
|
||||
|
||||
case "--parallel" when i + 1 < args.Length:
|
||||
if (int.TryParse(args[++i], out var parallel) && parallel >= 1)
|
||||
o.Parallel = parallel;
|
||||
else
|
||||
Console.Error.WriteLine(
|
||||
$"Warning: --parallel needs a whole number >= 1, using {o.Parallel}"
|
||||
);
|
||||
break;
|
||||
|
||||
case "--progress":
|
||||
o.Progress = true;
|
||||
break;
|
||||
|
||||
case "--help":
|
||||
PrintUsage();
|
||||
return null;
|
||||
|
||||
default:
|
||||
if (!args[i].StartsWith("--"))
|
||||
o.InputPath = args[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return o;
|
||||
}
|
||||
|
||||
private static List<Size> ParseSheetSizes(string arg)
|
||||
{
|
||||
var sizes = new List<Size>();
|
||||
|
||||
foreach (
|
||||
var token in arg.Split(
|
||||
',',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
|
||||
)
|
||||
)
|
||||
{
|
||||
if (JobLoader.TryParseSheetSize(token, out var size))
|
||||
sizes.Add(size);
|
||||
else
|
||||
Console.Error.WriteLine($"Warning: could not parse sheet size '{token}', skipping");
|
||||
}
|
||||
|
||||
return sizes.Distinct().ToList();
|
||||
}
|
||||
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"OpenNest.Benchmark - compare registered whole-job nesting engines on a set of .nest files"
|
||||
);
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine(
|
||||
"For each .nest file, every drawing with quantity > 0 is nested (mixed together),"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
"once per registered INestingEngine. Each engine is handed the full job - every"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
"requested part and the whole pool of candidate sheet sizes - and owns its own"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
"multi-plate/size strategy: how many plates it uses, of which sizes, and how"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
"demand splits across them. Ranking: a run that places every requested part beats"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
"one that does not; then lower cost = sheet area consumed (minus salvage credit for a"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
"usable offcut) + the largest candidate sheet's area per unplaced part; then fewer"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
"plates. An invalid layout (out of bounds, overlapping, over-quantity, off-stock, or"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
"breaking a rotation constraint), a thrown exception, or a timeout places nothing."
|
||||
);
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Usage:");
|
||||
Console.Error.WriteLine(
|
||||
" OpenNest.Benchmark <file.nest | manifest.json | folder> [options]"
|
||||
);
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine(
|
||||
"A manifest.json builds a job straight from DXF files (paths relative to the manifest):"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" { \"sheetSizes\": [\"48x96\"], \"spacing\": 0.25, \"edgeSpacing\": 0.25, \"quadrant\": 1,"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" \"parts\": [ { \"dxf\": \"a.dxf\", \"quantity\": 12 }, { \"dxf\": \"b.dxf\", \"quantity\": 4, \"allowRotation\": false } ] }"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
"Sheet sizes must use the same units as the DXFs. A folder is scanned for *.nest and"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
"*.manifest.json files. --sheet-sizes and --spacing override the manifest."
|
||||
);
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Options:");
|
||||
Console.Error.WriteLine(
|
||||
" --sheet-sizes W1xL1,W2xL2,... Candidate sheet-size pool for the whole nest"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" (default: the distinct sizes already in each file,"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" which hints engines toward the original layout)"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --spacing <value> Override part spacing for every job"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --engines Name1,Name2,... Only benchmark these registered engines (default: all)"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --csv <path> Write a flat CSV of all results"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --salvage-rate <0..1> Fraction of eligible offcut area credited (default: saved .nest rate;"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" manifests 0; needs positive --min-salvage-dimension)"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --min-salvage-dimension <value> Both offcut dimensions must qualify; positive value enables credit (default 0)"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --output <directory> Save valid layouts as .nest plus detailed JSON reports"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --parallel <n> Solves to run at once (default 3; 1 = strictly sequential,"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" which gives the cleanest per-engine timings)"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --progress Log each solve's start, engine progress and finish"
|
||||
);
|
||||
Console.Error.WriteLine(" --help Show this message");
|
||||
}
|
||||
|
||||
private class Options
|
||||
{
|
||||
public string InputPath;
|
||||
public List<Size> SheetSizes = new();
|
||||
public double? PartSpacing;
|
||||
public List<string> EngineNames = new();
|
||||
public string CsvPath;
|
||||
public string OutputDirectory;
|
||||
public double? SalvageRate;
|
||||
public double? MinimumSalvageDimension;
|
||||
public int Parallel = 3;
|
||||
public bool Progress;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Console + CSV reporting for benchmark results. Ranking rule per job:
|
||||
/// valid beats invalid; placing every requested part beats not; then lower
|
||||
/// JobResult.Cost wins - salvage-credited sheet area consumed plus a
|
||||
/// largest-sheet penalty per unplaced part, so dropping awkward parts can
|
||||
/// never buy a better score; then fewer plates. Ties beyond that are a
|
||||
/// shared win. Across jobs, costs and areas are summed (not averaged), so
|
||||
/// a big job weighs more than a three-part one.
|
||||
/// </summary>
|
||||
public static class Report
|
||||
{
|
||||
private const double Epsilon = 1e-6;
|
||||
|
||||
public static void PrintDetailed(List<JobResult> results)
|
||||
{
|
||||
foreach (var jobGroup in results.GroupBy(r => r.JobName))
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"=== {jobGroup.Key} ===");
|
||||
|
||||
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
|
||||
var best = ranked.Count > 0 ? ranked[0] : null;
|
||||
|
||||
Console.WriteLine(
|
||||
$"{"Engine", -16} {"Result", -9} {"Parts", -10} {"Util%", -7} {"Net%", -7} {"Cost", -12} {"Plates", -18} {"Time(ms)", -9} Notes"
|
||||
);
|
||||
|
||||
foreach (var r in ranked)
|
||||
{
|
||||
var isWinner = best != null && Compare(r, best) == 0 && r.Valid;
|
||||
var marker = isWinner ? "*" : " ";
|
||||
var status =
|
||||
r.Crashed ? "CRASH"
|
||||
: r.Valid ? "ok"
|
||||
: "INVALID";
|
||||
var partsCol = $"{r.PartsPlaced}/{r.PartsRequested}";
|
||||
var utilCol = r.Valid ? $"{r.Utilization * 100:F1}" : "-";
|
||||
var netCol = r.Valid ? $"{r.NetUtilization * 100:F1}" : "-";
|
||||
var platesCol =
|
||||
r.PlatesUsed > 0 ? $"{r.PlatesUsed} ({SizeSummary(r.SizeBreakdown)})" : "-";
|
||||
var notes = r.Crashed ? r.Error : string.Join("; ", r.Violations.Take(2));
|
||||
|
||||
Console.WriteLine(
|
||||
$"{marker}{r.EngineName, -15} {status, -9} {partsCol, -10} {utilCol, -7} {netCol, -7} {r.Cost, -12:F1} {platesCol, -18} {r.ElapsedMs, -9} {notes}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void PrintSummary(List<JobResult> results)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=== Summary ===");
|
||||
|
||||
var byEngine = results
|
||||
.GroupBy(r => r.EngineName)
|
||||
.Select(g => new
|
||||
{
|
||||
Engine = g.Key,
|
||||
Jobs = g.Count(),
|
||||
Valid = g.Count(r => r.Valid),
|
||||
Crashed = g.Count(r => r.Crashed),
|
||||
FullyPlaced = g.Count(r => r.FullyPlaced),
|
||||
Unplaced = g.Sum(r => r.PartsUnplaced),
|
||||
PlacedArea = g.Where(r => r.Valid).Sum(r => r.PlacedArea),
|
||||
PlateArea = g.Where(r => r.Valid).Sum(r => r.PlateArea),
|
||||
NetSheetArea = g.Where(r => r.Valid).Sum(r => r.NetSheetArea),
|
||||
TotalCost = g.Sum(r => r.Cost),
|
||||
TotalPlates = g.Sum(r => r.PlatesUsed),
|
||||
TotalTimeMs = g.Sum(r => r.ElapsedMs),
|
||||
})
|
||||
.OrderBy(e => e.TotalCost)
|
||||
.ToList();
|
||||
|
||||
var wins = CountWins(results);
|
||||
|
||||
// Util% and Net% are area-weighted over valid runs (sum placed / sum
|
||||
// sheet), not a mean of per-job percentages. TotalCost sums across
|
||||
// jobs, so it is only meaningful when every job uses the same units.
|
||||
Console.WriteLine(
|
||||
$"{"Engine", -16} {"Jobs", -6} {"Valid", -7} {"Complete", -9} {"Unplaced", -9} {"Wins", -6} {"Util%", -7} {"Net%", -7} {"TotalCost", -14} {"Plates", -8} {"TotalTime(ms)", -14}"
|
||||
);
|
||||
|
||||
foreach (var e in byEngine)
|
||||
{
|
||||
var util = e.PlateArea > 0 ? e.PlacedArea / e.PlateArea * 100 : 0;
|
||||
var netUtil = e.NetSheetArea > 0 ? e.PlacedArea / e.NetSheetArea * 100 : 0;
|
||||
var winCount = wins.TryGetValue(e.Engine, out var w) ? w : 0;
|
||||
Console.WriteLine(
|
||||
$"{e.Engine, -16} {e.Jobs, -6} {e.Valid, -7} {e.FullyPlaced, -9} {e.Unplaced, -9} {winCount, -6} {util, -7:F1} {netUtil, -7:F1} {e.TotalCost, -14:F1} {e.TotalPlates, -8} {e.TotalTimeMs, -14}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static void WriteCsv(string path, List<JobResult> results)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(
|
||||
"Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes"
|
||||
);
|
||||
|
||||
foreach (var r in results)
|
||||
{
|
||||
var notes = r.Crashed ? r.Error : string.Join(" | ", r.Violations);
|
||||
sb.AppendLine(
|
||||
string.Join(
|
||||
",",
|
||||
Csv(r.JobName),
|
||||
Csv(r.EngineName),
|
||||
r.Valid,
|
||||
r.Crashed,
|
||||
r.FullyPlaced,
|
||||
r.PartsPlaced,
|
||||
r.PartsRequested,
|
||||
r.Utilization.ToString("F4", CultureInfo.InvariantCulture),
|
||||
r.NetUtilization.ToString("F4", CultureInfo.InvariantCulture),
|
||||
r.PlateArea.ToString("F2", CultureInfo.InvariantCulture),
|
||||
r.NetSheetArea.ToString("F2", CultureInfo.InvariantCulture),
|
||||
r.Cost.ToString("F2", CultureInfo.InvariantCulture),
|
||||
r.PlatesUsed,
|
||||
Csv(SizeSummary(r.SizeBreakdown)),
|
||||
r.ElapsedMs,
|
||||
Csv(notes)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
}
|
||||
|
||||
private static string SizeSummary(Dictionary<string, int> breakdown)
|
||||
{
|
||||
if (breakdown == null || breakdown.Count == 0)
|
||||
return "-";
|
||||
|
||||
return string.Join("; ", breakdown.Select(kv => $"{kv.Key}×{kv.Value}"));
|
||||
}
|
||||
|
||||
private static string Csv(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return string.Empty;
|
||||
|
||||
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> CountWins(List<JobResult> results)
|
||||
{
|
||||
var wins = new Dictionary<string, int>();
|
||||
|
||||
foreach (var jobGroup in results.GroupBy(r => r.JobName))
|
||||
{
|
||||
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
|
||||
|
||||
if (ranked.Count == 0 || !ranked[0].Valid)
|
||||
continue;
|
||||
|
||||
foreach (var r in ranked.TakeWhile(r => Compare(r, ranked[0]) == 0))
|
||||
wins[r.EngineName] = wins.GetValueOrDefault(r.EngineName) + 1;
|
||||
}
|
||||
|
||||
return wins;
|
||||
}
|
||||
|
||||
/// <summary>Lower sorts first (better). Valid beats invalid, complete beats
|
||||
/// incomplete, then lower cost (relative tolerance, since costs are areas
|
||||
/// in whatever units the job uses), then fewer plates.</summary>
|
||||
public static int Compare(JobResult a, JobResult b)
|
||||
{
|
||||
if (a.Valid != b.Valid)
|
||||
return a.Valid ? -1 : 1;
|
||||
|
||||
if (!a.Valid)
|
||||
return 0;
|
||||
|
||||
if (a.FullyPlaced != b.FullyPlaced)
|
||||
return a.FullyPlaced ? -1 : 1;
|
||||
|
||||
var costDiff = a.Cost - b.Cost;
|
||||
var scale = System.Math.Max(1, System.Math.Max(a.Cost, b.Cost));
|
||||
|
||||
if (System.Math.Abs(costDiff) > Epsilon * scale)
|
||||
return costDiff > 0 ? 1 : -1;
|
||||
|
||||
if (a.PlatesUsed != b.PlatesUsed)
|
||||
return a.PlatesUsed > b.PlatesUsed ? 1 : -1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>OpenNest.Console</RootNamespace>
|
||||
<AssemblyName>OpenNest.Console</AssemblyName>
|
||||
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
|
||||
|
||||
+308
-60
@@ -1,13 +1,19 @@
|
||||
using OpenNest;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using OpenNest;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.IO.Bending;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
|
||||
return NestConsole.Run(args);
|
||||
|
||||
@@ -20,12 +26,58 @@ static class NestConsole
|
||||
if (options == null)
|
||||
return 0; // --help was requested
|
||||
|
||||
if (
|
||||
options.RepairBendsMillimeters.HasValue
|
||||
&& (
|
||||
options.CadUnits == BendRepairUnits.Unspecified
|
||||
|| !double.IsFinite(options.RepairBendsMillimeters.Value)
|
||||
|| options.RepairBendsMillimeters <= 0.001
|
||||
|| options.RepairBendsMillimeters > 3.175
|
||||
)
|
||||
)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Error: --repair-bends-mm requires a limit > 0.001 and <= 3.175 mm and --cad-units inches|mm."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (options.ListPosts)
|
||||
{
|
||||
ListPostProcessors(options);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Validate --engine up front: autonest names a jobs engine, plain fill names a
|
||||
// single-plate placement strategy. Unknown names exit with the valid choices.
|
||||
if (options.AutoNest)
|
||||
{
|
||||
var isJobsEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
|
||||
e.Name.Equals(options.Engine, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
if (!isJobsEngine)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"Error: unknown engine '{options.Engine}'. Jobs engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
PlateFillService.ResolveStrategy(options.Engine);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"Error: unknown engine '{options.Engine}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (jobs engines such as StockLadder require --autonest)"
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.InputFiles.Count == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
@@ -82,6 +134,26 @@ static class NestConsole
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--repair-bends-mm":
|
||||
o.RepairBendsMillimeters =
|
||||
i + 1 < args.Length
|
||||
&& double.TryParse(
|
||||
args[++i],
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var limit
|
||||
)
|
||||
? limit
|
||||
: double.NaN;
|
||||
break;
|
||||
case "--cad-units" when i + 1 < args.Length:
|
||||
o.CadUnits = args[++i] switch
|
||||
{
|
||||
"inches" => BendRepairUnits.Inches,
|
||||
"mm" => BendRepairUnits.Millimeters,
|
||||
_ => BendRepairUnits.Unspecified,
|
||||
};
|
||||
break;
|
||||
case "--drawing" when i + 1 < args.Length:
|
||||
o.DrawingName = args[++i];
|
||||
break;
|
||||
@@ -117,7 +189,7 @@ static class NestConsole
|
||||
o.AutoNest = true;
|
||||
break;
|
||||
case "--engine" when i + 1 < args.Length:
|
||||
NestEngineRegistry.ActiveEngineName = args[++i];
|
||||
o.Engine = args[++i];
|
||||
break;
|
||||
case "--post" when i + 1 < args.Length:
|
||||
o.PostName = args[++i];
|
||||
@@ -149,10 +221,14 @@ static class NestConsole
|
||||
{
|
||||
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
||||
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
||||
var dxfFiles = options.InputFiles.Where(f =>
|
||||
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
|
||||
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
var dxfFiles = options
|
||||
.InputFiles.Where(f =>
|
||||
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||
|| f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
.ToList();
|
||||
|
||||
// If we have a nest file, load it and optionally add DXFs.
|
||||
if (nestFile != null)
|
||||
@@ -167,13 +243,15 @@ static class NestConsole
|
||||
|
||||
if (options.PlateIndex >= nest.Plates.Count)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: plate index {options.PlateIndex} out of range (0-{nest.Plates.Count - 1})");
|
||||
Console.Error.WriteLine(
|
||||
$"Error: plate index {options.PlateIndex} out of range (0-{nest.Plates.Count - 1})"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var dxf in dxfFiles)
|
||||
{
|
||||
var drawing = ImportDxf(dxf);
|
||||
var drawing = ImportDxf(dxf, options);
|
||||
|
||||
if (drawing == null)
|
||||
return null;
|
||||
@@ -194,7 +272,9 @@ static class NestConsole
|
||||
|
||||
if (!options.PlateSize.HasValue)
|
||||
{
|
||||
Console.Error.WriteLine("Error: --size WxL is required when importing DXF files without a nest");
|
||||
Console.Error.WriteLine(
|
||||
"Error: --size WxL is required when importing DXF files without a nest"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -204,7 +284,7 @@ static class NestConsole
|
||||
|
||||
foreach (var dxf in dxfFiles)
|
||||
{
|
||||
var drawing = ImportDxf(dxf);
|
||||
var drawing = ImportDxf(dxf, options);
|
||||
|
||||
if (drawing == null)
|
||||
return null;
|
||||
@@ -216,11 +296,28 @@ static class NestConsole
|
||||
return newNest;
|
||||
}
|
||||
|
||||
static Drawing ImportDxf(string path)
|
||||
static Drawing ImportDxf(string path, Options options)
|
||||
{
|
||||
try
|
||||
{
|
||||
return CadImporter.ImportDrawing(path);
|
||||
var result = CadImporter.Import(
|
||||
path,
|
||||
new CadImportOptions
|
||||
{
|
||||
BendRepair = options.RepairBendsMillimeters.HasValue
|
||||
? new BendRepairOptions
|
||||
{
|
||||
DrawingUnits = options.CadUnits,
|
||||
MaxEndpointMovementMillimeters = options.RepairBendsMillimeters.Value,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
);
|
||||
foreach (var report in result.BendRepairReports)
|
||||
Console.WriteLine(
|
||||
$"Bend repair {Path.GetFileName(path)} #{report.BendIndex + 1}: {report.Status}: {report.Reason} ({report.OriginalStart} -> {report.Start}; {report.OriginalEnd} -> {report.End})"
|
||||
);
|
||||
return CadImporter.BuildDrawing(result, result.Entities, result.Bends, 1, null, null);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
@@ -256,7 +353,8 @@ static class NestConsole
|
||||
// Only apply size override when it wasn't already used to create the plate.
|
||||
var hasDxfOnly = !options.InputFiles.Any(f =>
|
||||
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
|
||||
if (options.PlateSize.HasValue && !hasDxfOnly)
|
||||
plate.Size = options.PlateSize.Value;
|
||||
@@ -264,33 +362,51 @@ static class NestConsole
|
||||
|
||||
static Drawing ResolveDrawing(Nest nest, Options options)
|
||||
{
|
||||
var drawing = options.DrawingName != null
|
||||
? nest.Drawings.FirstOrDefault(d => d.Name == options.DrawingName)
|
||||
: nest.Drawings.FirstOrDefault();
|
||||
var drawing =
|
||||
options.DrawingName != null
|
||||
? nest.Drawings.FirstOrDefault(d => d.Name == options.DrawingName)
|
||||
: nest.Drawings.FirstOrDefault();
|
||||
|
||||
if (drawing != null)
|
||||
return drawing;
|
||||
|
||||
Console.Error.WriteLine(options.DrawingName != null
|
||||
? $"Error: drawing '{options.DrawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}"
|
||||
: "Error: nest file contains no drawings");
|
||||
Console.Error.WriteLine(
|
||||
options.DrawingName != null
|
||||
? $"Error: drawing '{options.DrawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}"
|
||||
: "Error: nest file contains no drawings"
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static void PrintHeader(Nest nest, Plate plate, Drawing drawing, int existingCount, Options options)
|
||||
static void PrintHeader(
|
||||
Nest nest,
|
||||
Plate plate,
|
||||
Drawing drawing,
|
||||
int existingCount,
|
||||
Options options
|
||||
)
|
||||
{
|
||||
Console.WriteLine($"Nest: {nest.Name}");
|
||||
var wa = plate.WorkArea();
|
||||
Console.WriteLine($"Plate: {options.PlateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}, edge=({plate.EdgeSpacing.Left},{plate.EdgeSpacing.Bottom},{plate.EdgeSpacing.Right},{plate.EdgeSpacing.Top}), workArea={wa.Width:F1}x{wa.Length:F1}");
|
||||
Console.WriteLine(
|
||||
$"Plate: {options.PlateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}, edge=({plate.EdgeSpacing.Left},{plate.EdgeSpacing.Bottom},{plate.EdgeSpacing.Right},{plate.EdgeSpacing.Top}), workArea={wa.Width:F1}x{wa.Length:F1}"
|
||||
);
|
||||
Console.WriteLine($"Drawing: {drawing.Name}");
|
||||
Console.WriteLine(options.KeepParts
|
||||
? $"Keeping {existingCount} existing parts"
|
||||
: $"Cleared {existingCount} existing parts");
|
||||
Console.WriteLine(
|
||||
options.KeepParts
|
||||
? $"Keeping {existingCount} existing parts"
|
||||
: $"Cleared {existingCount} existing parts"
|
||||
);
|
||||
Console.WriteLine("---");
|
||||
}
|
||||
|
||||
static (bool success, long elapsedMs) Fill(Nest nest, Plate plate, Drawing drawing, Options options)
|
||||
static (bool success, long elapsedMs) Fill(
|
||||
Nest nest,
|
||||
Plate plate,
|
||||
Drawing drawing,
|
||||
Options options
|
||||
)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
bool success;
|
||||
@@ -310,33 +426,113 @@ static class NestConsole
|
||||
nestItems.Add(new NestItem { Drawing = d, Quantity = qty });
|
||||
}
|
||||
|
||||
Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts");
|
||||
Console.WriteLine(
|
||||
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
|
||||
);
|
||||
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var nestParts = engine.Nest(nestItems, null, CancellationToken.None);
|
||||
plate.Parts.AddRange(nestParts);
|
||||
success = nestParts.Count > 0;
|
||||
success = AutoNestJob(plate, nestItems, options.Engine);
|
||||
}
|
||||
else
|
||||
{
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
// Single-plate fill: explicit placement strategy through the public service;
|
||||
// the process-global engine registry is never consulted.
|
||||
var strategy = ResolveFillStrategy(options.Engine);
|
||||
var item = new NestItem { Drawing = drawing, Quantity = options.Quantity };
|
||||
success = engine.Fill(item);
|
||||
var parts = PlateFillService.FillItem(
|
||||
strategy,
|
||||
plate,
|
||||
item,
|
||||
plate.WorkArea(),
|
||||
null,
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
if (parts.Count > 0)
|
||||
plate.Parts.AddRange(parts);
|
||||
success = parts.Count > 0;
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
return (success, sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var engine = NestingEngineRegistry.Create(engineName);
|
||||
|
||||
var parts = new List<NestJobPart>(nestItems.Count);
|
||||
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < nestItems.Count; i++)
|
||||
{
|
||||
var partId = $"part-{i}";
|
||||
parts.Add(DrawingJobMapper.FromItem(partId, nestItems[i]));
|
||||
drawingsByPartId[partId] = nestItems[i].Drawing;
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
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.WriteLine($"Engine: {engineName} — committed {committed} placements");
|
||||
return committed > 0;
|
||||
}
|
||||
|
||||
static string ResolveFillStrategy(string engineName)
|
||||
{
|
||||
try
|
||||
{
|
||||
return PlateFillService.ResolveStrategy(engineName);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
var isJobEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
|
||||
e.Name.Equals(engineName, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
isJobEngine
|
||||
? $"Error: engine '{engineName}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
|
||||
: $"Error: unknown engine '{engineName}'. Engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
|
||||
);
|
||||
Environment.Exit(1);
|
||||
throw; // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
static int CheckOverlaps(Plate plate, Options options)
|
||||
{
|
||||
if (!options.CheckOverlaps || plate.Parts.Count == 0)
|
||||
return 0;
|
||||
|
||||
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
|
||||
Console.WriteLine(hasOverlaps
|
||||
? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
|
||||
: "Overlap check: PASS");
|
||||
Console.WriteLine(
|
||||
hasOverlaps
|
||||
? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
|
||||
: "Overlap check: PASS"
|
||||
);
|
||||
|
||||
return overlapPts.Count;
|
||||
}
|
||||
@@ -355,9 +551,12 @@ static class NestConsole
|
||||
return;
|
||||
|
||||
var firstInput = options.InputFiles[0];
|
||||
var outputFile = options.OutputFile ?? Path.Combine(
|
||||
Path.GetDirectoryName(firstInput),
|
||||
$"{Path.GetFileNameWithoutExtension(firstInput)}-result{NestFormat.FileExtension}");
|
||||
var outputFile =
|
||||
options.OutputFile
|
||||
?? Path.Combine(
|
||||
Path.GetDirectoryName(firstInput),
|
||||
$"{Path.GetFileNameWithoutExtension(firstInput)}-result{NestFormat.FileExtension}"
|
||||
);
|
||||
|
||||
new NestWriter(nest).Write(outputFile);
|
||||
Console.WriteLine($"Saved: {outputFile}");
|
||||
@@ -368,8 +567,8 @@ static class NestConsole
|
||||
if (options.PostsDir != null)
|
||||
return options.PostsDir;
|
||||
|
||||
var exePath = Assembly.GetEntryAssembly()?.Location
|
||||
?? typeof(NestConsole).Assembly.Location;
|
||||
var exePath =
|
||||
Assembly.GetEntryAssembly()?.Location ?? typeof(NestConsole).Assembly.Location;
|
||||
return Path.Combine(Path.GetDirectoryName(exePath), "Posts");
|
||||
}
|
||||
|
||||
@@ -388,7 +587,11 @@ static class NestConsole
|
||||
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
if (!typeof(IPostProcessor).IsAssignableFrom(type) || type.IsInterface || type.IsAbstract)
|
||||
if (
|
||||
!typeof(IPostProcessor).IsAssignableFrom(type)
|
||||
|| type.IsInterface
|
||||
|| type.IsAbstract
|
||||
)
|
||||
continue;
|
||||
|
||||
if (Activator.CreateInstance(type) is IPostProcessor processor)
|
||||
@@ -397,7 +600,9 @@ static class NestConsole
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Warning: failed to load post processor from {Path.GetFileName(file)}: {ex.Message}");
|
||||
Console.Error.WriteLine(
|
||||
$"Warning: failed to load post processor from {Path.GetFileName(file)}: {ex.Message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +623,7 @@ static class NestConsole
|
||||
Console.WriteLine($"Post processors ({postsDir}):");
|
||||
|
||||
foreach (var p in processors)
|
||||
Console.WriteLine($" {p.Name,-30} {p.Description}");
|
||||
Console.WriteLine($" {p.Name, -30} {p.Description}");
|
||||
}
|
||||
|
||||
static void PostProcess(Nest nest, Options options)
|
||||
@@ -429,14 +634,17 @@ static class NestConsole
|
||||
var postsDir = ResolvePostsDir(options);
|
||||
var processors = LoadPostProcessors(postsDir);
|
||||
var post = processors.FirstOrDefault(p =>
|
||||
p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase));
|
||||
p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
|
||||
if (post == null)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: post processor '{options.PostName}' not found");
|
||||
|
||||
if (processors.Count > 0)
|
||||
Console.Error.WriteLine($"Available: {string.Join(", ", processors.Select(p => p.Name))}");
|
||||
Console.Error.WriteLine(
|
||||
$"Available: {string.Join(", ", processors.Select(p => p.Name))}"
|
||||
);
|
||||
else
|
||||
Console.Error.WriteLine($"No post processors found in: {postsDir}");
|
||||
|
||||
@@ -450,7 +658,8 @@ static class NestConsole
|
||||
var firstInput = options.InputFiles[0];
|
||||
outputFile = Path.Combine(
|
||||
Path.GetDirectoryName(firstInput),
|
||||
$"{Path.GetFileNameWithoutExtension(firstInput)}.cnc");
|
||||
$"{Path.GetFileNameWithoutExtension(firstInput)}.cnc"
|
||||
);
|
||||
}
|
||||
|
||||
post.Post(nest, outputFile);
|
||||
@@ -462,28 +671,64 @@ static class NestConsole
|
||||
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Arguments:");
|
||||
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf/.dwg drawing files");
|
||||
Console.Error.WriteLine(
|
||||
" input-files One or more .nest nest files or .dxf/.dwg drawing files"
|
||||
);
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Modes:");
|
||||
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
|
||||
Console.Error.WriteLine(" <part.dxf> --size WxL Import DXF, create plate, and fill");
|
||||
Console.Error.WriteLine(" <nest.nest> <part.dxf> Load nest and add imported DXF drawings");
|
||||
Console.Error.WriteLine(
|
||||
" <nest.nest> <part.dxf> Load nest and add imported DXF drawings"
|
||||
);
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Options:");
|
||||
Console.Error.WriteLine(" --drawing <name> Drawing name to fill with (default: first drawing)");
|
||||
Console.Error.WriteLine(
|
||||
" --repair-bends-mm <n> Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --cad-units inches|mm Explicit source coordinate units required for bend repair"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --drawing <name> Drawing name to fill with (default: first drawing)"
|
||||
);
|
||||
Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)");
|
||||
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)");
|
||||
Console.Error.WriteLine(
|
||||
" --quantity <n> Max parts to place (default: 0 = unlimited)"
|
||||
);
|
||||
Console.Error.WriteLine(" --spacing <value> Override part spacing");
|
||||
Console.Error.WriteLine(" --size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode");
|
||||
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.nest)");
|
||||
Console.Error.WriteLine(" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)");
|
||||
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
|
||||
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
||||
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
|
||||
Console.Error.WriteLine(
|
||||
" --size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --output <path> Output nest file path (default: <input>-result.nest)"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --autonest Whole-job nesting via the jobs engine (--engine) instead of single-plate fill"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --engine <name> With --autonest: jobs engine (default: Default; also StockLadder, Strip, ...)."
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" Without --autonest: fill strategy (Default, Strip, Vertical Remnant, Horizontal Remnant)"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --keep-parts Don't clear existing parts before filling"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --check-overlaps Run overlap detection after fill (exit code 1 if found)"
|
||||
);
|
||||
Console.Error.WriteLine(" --no-save Skip saving output file");
|
||||
Console.Error.WriteLine(" --post <name> Run a post processor after nesting");
|
||||
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)");
|
||||
Console.Error.WriteLine(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)");
|
||||
Console.Error.WriteLine(
|
||||
" --post-output <path> Output file for post processor (default: <input>.cnc)"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)"
|
||||
);
|
||||
Console.Error.WriteLine(" --list-posts List available post processors and exit");
|
||||
Console.Error.WriteLine(" -h, --help Show this help");
|
||||
}
|
||||
@@ -501,10 +746,13 @@ static class NestConsole
|
||||
public bool NoSave;
|
||||
public bool KeepParts;
|
||||
public bool AutoNest;
|
||||
public string Engine = "Default";
|
||||
public string TemplateFile;
|
||||
public string PostName;
|
||||
public string PostOutput;
|
||||
public string PostsDir;
|
||||
public bool ListPosts;
|
||||
public double? RepairBendsMillimeters;
|
||||
public BendRepairUnits CadUnits;
|
||||
}
|
||||
}
|
||||
|
||||
+35
-12
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
@@ -7,7 +7,10 @@ namespace OpenNest
|
||||
{
|
||||
public static void Vertically(Entity fixedEntity, Entity movableEntity)
|
||||
{
|
||||
movableEntity.Offset(fixedEntity.BoundingBox.Center.X - movableEntity.BoundingBox.Center.X, 0);
|
||||
movableEntity.Offset(
|
||||
fixedEntity.BoundingBox.Center.X - movableEntity.BoundingBox.Center.X,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
public static void Vertically(Entity fixedEntity, List<Entity> entities)
|
||||
@@ -17,7 +20,10 @@ namespace OpenNest
|
||||
|
||||
public static void Vertically(Part fixedPart, Part movablePart)
|
||||
{
|
||||
movablePart.Offset(fixedPart.BoundingBox.Center.X - movablePart.BoundingBox.Center.X, 0);
|
||||
movablePart.Offset(
|
||||
fixedPart.BoundingBox.Center.X - movablePart.BoundingBox.Center.X,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
public static void Vertically(Part fixedPart, List<Part> parts)
|
||||
@@ -27,7 +33,10 @@ namespace OpenNest
|
||||
|
||||
public static void Horizontally(Entity fixedEntity, Entity movableEntity)
|
||||
{
|
||||
movableEntity.Offset(0, fixedEntity.BoundingBox.Center.Y - movableEntity.BoundingBox.Center.Y);
|
||||
movableEntity.Offset(
|
||||
0,
|
||||
fixedEntity.BoundingBox.Center.Y - movableEntity.BoundingBox.Center.Y
|
||||
);
|
||||
}
|
||||
|
||||
public static void Horizontally(Entity fixedEntity, List<Entity> entities)
|
||||
@@ -37,7 +46,10 @@ namespace OpenNest
|
||||
|
||||
public static void Horizontally(Part fixedPart, Part movablePart)
|
||||
{
|
||||
movablePart.Offset(0, fixedPart.BoundingBox.Center.Y - movablePart.BoundingBox.Center.Y);
|
||||
movablePart.Offset(
|
||||
0,
|
||||
fixedPart.BoundingBox.Center.Y - movablePart.BoundingBox.Center.Y
|
||||
);
|
||||
}
|
||||
|
||||
public static void Horizontally(Part fixedPart, List<Part> parts)
|
||||
@@ -67,7 +79,10 @@ namespace OpenNest
|
||||
|
||||
public static void Right(Entity fixedEntity, Entity movableEntity)
|
||||
{
|
||||
movableEntity.Offset(fixedEntity.BoundingBox.Right - movableEntity.BoundingBox.Right, 0);
|
||||
movableEntity.Offset(
|
||||
fixedEntity.BoundingBox.Right - movableEntity.BoundingBox.Right,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
public static void Right(Entity fixedEntity, List<Entity> entities)
|
||||
@@ -107,7 +122,10 @@ namespace OpenNest
|
||||
|
||||
public static void Bottom(Entity fixedEntity, Entity movableEntity)
|
||||
{
|
||||
movableEntity.Offset(0, fixedEntity.BoundingBox.Bottom - movableEntity.BoundingBox.Bottom);
|
||||
movableEntity.Offset(
|
||||
0,
|
||||
fixedEntity.BoundingBox.Bottom - movableEntity.BoundingBox.Bottom
|
||||
);
|
||||
}
|
||||
|
||||
public static void Bottom(Entity fixedEntity, List<Entity> entities)
|
||||
@@ -137,14 +155,19 @@ namespace OpenNest
|
||||
return;
|
||||
|
||||
var list = new List<Part>(parts);
|
||||
list.Sort((p1, p2) => horizontal
|
||||
? p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X)
|
||||
: p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y));
|
||||
list.Sort(
|
||||
(p1, p2) =>
|
||||
horizontal
|
||||
? p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X)
|
||||
: p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y)
|
||||
);
|
||||
|
||||
var lastIndex = list.Count - 1;
|
||||
|
||||
var start = horizontal ? list[0].BoundingBox.Center.X : list[0].BoundingBox.Center.Y;
|
||||
var end = horizontal ? list[lastIndex].BoundingBox.Center.X : list[lastIndex].BoundingBox.Center.Y;
|
||||
var end = horizontal
|
||||
? list[lastIndex].BoundingBox.Center.X
|
||||
: list[lastIndex].BoundingBox.Center.Y;
|
||||
|
||||
var spacing = (end - start) / lastIndex;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
namespace OpenNest
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum AlignType
|
||||
{
|
||||
@@ -10,6 +9,6 @@ namespace OpenNest
|
||||
Horizontally,
|
||||
Vertically,
|
||||
EvenlySpaceHorizontally,
|
||||
EvenlySpaceVertically
|
||||
EvenlySpaceVertically,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Bending
|
||||
{
|
||||
@@ -10,7 +10,7 @@ namespace OpenNest.Bending
|
||||
public static readonly Layer EtchLayer = new Layer("ETCH")
|
||||
{
|
||||
Color = Color.Green,
|
||||
IsVisible = true
|
||||
IsVisible = true,
|
||||
};
|
||||
|
||||
private const double DefaultEtchLength = 1.0;
|
||||
@@ -32,9 +32,8 @@ namespace OpenNest.Bending
|
||||
|
||||
public double Length => StartPoint.DistanceTo(EndPoint);
|
||||
|
||||
public double AngleRadians => Angle.HasValue
|
||||
? OpenNest.Math.Angle.ToRadians(Angle.Value)
|
||||
: 0;
|
||||
public double AngleRadians =>
|
||||
Angle.HasValue ? OpenNest.Math.Angle.ToRadians(Angle.Value) : 0;
|
||||
|
||||
public Line ToLine() => new Line(StartPoint, EndPoint);
|
||||
|
||||
@@ -66,7 +65,9 @@ namespace OpenNest.Bending
|
||||
var dx = System.Math.Cos(angle) * etchLength;
|
||||
var dy = System.Math.Sin(angle) * etchLength;
|
||||
|
||||
result.Add(CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy)));
|
||||
result.Add(
|
||||
CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy))
|
||||
);
|
||||
result.Add(CreateEtchLine(new Vector(EndPoint.X - dx, EndPoint.Y - dy), EndPoint));
|
||||
}
|
||||
|
||||
@@ -79,7 +80,8 @@ namespace OpenNest.Bending
|
||||
public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends)
|
||||
{
|
||||
entities.RemoveAll(e => e.Tag == BendEtchTag);
|
||||
if (bends == null) return;
|
||||
if (bends == null)
|
||||
return;
|
||||
|
||||
foreach (var bend in bends)
|
||||
entities.AddRange(bend.GetEtchEntities());
|
||||
@@ -87,7 +89,12 @@ namespace OpenNest.Bending
|
||||
|
||||
private static Line CreateEtchLine(Vector start, Vector end)
|
||||
{
|
||||
return new Line(start, end) { Layer = EtchLayer, Color = Color.Green, Tag = BendEtchTag };
|
||||
return new Line(start, end)
|
||||
{
|
||||
Layer = EtchLayer,
|
||||
Color = Color.Green,
|
||||
Tag = BendEtchTag,
|
||||
};
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
|
||||
@@ -4,6 +4,6 @@ namespace OpenNest.Bending
|
||||
{
|
||||
Unknown,
|
||||
Up,
|
||||
Down
|
||||
Down,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,22 @@ namespace OpenNest.CNC
|
||||
{
|
||||
public class ArcMove : Motion
|
||||
{
|
||||
public ArcMove()
|
||||
{
|
||||
}
|
||||
public ArcMove() { }
|
||||
|
||||
public ArcMove(double x, double y, double i, double j, RotationType rotation = RotationType.CCW)
|
||||
: this(new Vector(x, y), new Vector(i, j), rotation)
|
||||
{
|
||||
}
|
||||
public ArcMove(
|
||||
double x,
|
||||
double y,
|
||||
double i,
|
||||
double j,
|
||||
RotationType rotation = RotationType.CCW
|
||||
)
|
||||
: this(new Vector(x, y), new Vector(i, j), rotation) { }
|
||||
|
||||
public ArcMove(Vector endPoint, Vector centerPoint, RotationType rotation = RotationType.CCW)
|
||||
public ArcMove(
|
||||
Vector endPoint,
|
||||
Vector centerPoint,
|
||||
RotationType rotation = RotationType.CCW
|
||||
)
|
||||
{
|
||||
EndPoint = endPoint;
|
||||
CenterPoint = centerPoint;
|
||||
@@ -68,7 +74,8 @@ namespace OpenNest.CNC
|
||||
{
|
||||
Layer = Layer,
|
||||
Suppressed = Suppressed,
|
||||
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
|
||||
VariableRefs =
|
||||
VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,9 +92,9 @@ namespace OpenNest.CNC
|
||||
var i = CenterPoint.X.ToString(dp);
|
||||
var j = CenterPoint.Y.ToString(dp);
|
||||
|
||||
return Rotation == RotationType.CW ?
|
||||
string.Format("G02 X{0} Y{1} I{2} J{3}", x, y, i, j) :
|
||||
string.Format("G03 X{0} Y{1} I{2} J{3}", x, y, i, j);
|
||||
return Rotation == RotationType.CW
|
||||
? string.Format("G02 X{0} Y{1} I{2} J{3}", x, y, i, j)
|
||||
: string.Format("G03 X{0} Y{1} I{2} J{3}", x, y, i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
namespace OpenNest.CNC
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public enum CodeType
|
||||
{
|
||||
@@ -9,6 +8,6 @@ namespace OpenNest.CNC
|
||||
RapidMove,
|
||||
SetFeedrate,
|
||||
SetKerf,
|
||||
SubProgramCall
|
||||
SubProgramCall,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
{
|
||||
public class Comment : ICode
|
||||
{
|
||||
public Comment()
|
||||
{
|
||||
}
|
||||
public Comment() { }
|
||||
|
||||
public Comment(string value)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -48,7 +48,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
for (var iter = 0; iter < 3; iter++)
|
||||
{
|
||||
var lastCutoutPt = cutoutEntries[cutoutEntries.Count - 1].Point;
|
||||
perimeterSeed = FindPerimeterIntersection(profile.Perimeter, lastCutoutPt, nextPartStart, out _);
|
||||
perimeterSeed = FindPerimeterIntersection(
|
||||
profile.Perimeter,
|
||||
lastCutoutPt,
|
||||
nextPartStart,
|
||||
out _
|
||||
);
|
||||
|
||||
orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterSeed);
|
||||
orderedCutouts.Reverse();
|
||||
@@ -56,7 +61,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
}
|
||||
|
||||
var finalLastCutout = cutoutEntries[cutoutEntries.Count - 1].Point;
|
||||
perimeterPt = FindPerimeterIntersection(profile.Perimeter, finalLastCutout, nextPartStart, out perimeterEntity);
|
||||
perimeterPt = FindPerimeterIntersection(
|
||||
profile.Perimeter,
|
||||
finalLastCutout,
|
||||
nextPartStart,
|
||||
out perimeterEntity
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -79,18 +89,25 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
if (!profile.Perimeter.IsClosed())
|
||||
EmitRawContour(result, profile.Perimeter);
|
||||
else
|
||||
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
|
||||
EmitContour(
|
||||
result,
|
||||
profile.Perimeter,
|
||||
perimeterPt,
|
||||
perimeterEntity,
|
||||
ContourType.External
|
||||
);
|
||||
|
||||
result.Mode = Mode.Incremental;
|
||||
|
||||
return new CuttingResult
|
||||
{
|
||||
Program = result,
|
||||
LastCutPoint = perimeterPt
|
||||
};
|
||||
return new CuttingResult { Program = result, LastCutPoint = perimeterPt };
|
||||
}
|
||||
|
||||
public CuttingResult ApplySingle(Program partProgram, Vector point, Entity entity, ContourType contourType)
|
||||
public CuttingResult ApplySingle(
|
||||
Program partProgram,
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType contourType
|
||||
)
|
||||
{
|
||||
var entities = partProgram.ToGeometry();
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||
@@ -141,14 +158,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
result.Mode = Mode.Incremental;
|
||||
|
||||
return new CuttingResult
|
||||
{
|
||||
Program = result,
|
||||
LastCutPoint = point
|
||||
};
|
||||
return new CuttingResult { Program = result, LastCutPoint = point };
|
||||
}
|
||||
|
||||
private static (Shape Shape, Entity Entity) FindTargetShape(ShapeProfile profile, Vector point, Entity clickedEntity)
|
||||
private static (Shape Shape, Entity Entity) FindTargetShape(
|
||||
ShapeProfile profile,
|
||||
Vector point,
|
||||
Entity clickedEntity
|
||||
)
|
||||
{
|
||||
var matched = FindMatchingEntity(profile.Perimeter, clickedEntity);
|
||||
if (matched != null)
|
||||
@@ -190,20 +207,26 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
if (shapeEntity is Line sLine && clickedEntity is Line cLine)
|
||||
{
|
||||
if (sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon
|
||||
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon)
|
||||
if (
|
||||
sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon
|
||||
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon
|
||||
)
|
||||
return shapeEntity;
|
||||
}
|
||||
else if (shapeEntity is Arc sArc && clickedEntity is Arc cArc)
|
||||
{
|
||||
if (System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon
|
||||
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon)
|
||||
if (
|
||||
System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon
|
||||
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon
|
||||
)
|
||||
return shapeEntity;
|
||||
}
|
||||
else if (shapeEntity is Circle sCircle && clickedEntity is Circle cCircle)
|
||||
{
|
||||
if (System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon
|
||||
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon)
|
||||
if (
|
||||
System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon
|
||||
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon
|
||||
)
|
||||
return shapeEntity;
|
||||
}
|
||||
}
|
||||
@@ -218,7 +241,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
program.Codes.AddRange(ConvertShapeToMoves(shape, startPoint));
|
||||
}
|
||||
|
||||
private static List<ContourEntry> ResolveLeadInPoints(List<Shape> cutouts, Vector startPoint)
|
||||
private static List<ContourEntry> ResolveLeadInPoints(
|
||||
List<Shape> cutouts,
|
||||
Vector startPoint
|
||||
)
|
||||
{
|
||||
var entries = new ContourEntry[cutouts.Count];
|
||||
var currentPoint = startPoint;
|
||||
@@ -235,7 +261,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return new List<ContourEntry>(entries);
|
||||
}
|
||||
|
||||
private static Vector FindPerimeterIntersection(Shape perimeter, Vector lastCutout, Vector nextPartStart, out Entity entity)
|
||||
private static Vector FindPerimeterIntersection(
|
||||
Shape perimeter,
|
||||
Vector lastCutout,
|
||||
Vector nextPartStart,
|
||||
out Entity entity
|
||||
)
|
||||
{
|
||||
var ray = new Line(lastCutout, nextPartStart);
|
||||
|
||||
@@ -269,7 +300,13 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return HashCode.Combine(r, a);
|
||||
}
|
||||
|
||||
private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null)
|
||||
private void EmitContour(
|
||||
Program program,
|
||||
Shape shape,
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType? forceType = null
|
||||
)
|
||||
{
|
||||
var contourType = forceType ?? DetectContourType(shape);
|
||||
var winding = DetermineWinding(shape);
|
||||
@@ -289,7 +326,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var outwardAngle = normal - System.Math.PI;
|
||||
point = new Vector(
|
||||
circle.Center.X + circle.Radius * System.Math.Cos(outwardAngle),
|
||||
circle.Center.Y + circle.Radius * System.Math.Sin(outwardAngle));
|
||||
circle.Center.Y + circle.Radius * System.Math.Sin(outwardAngle)
|
||||
);
|
||||
}
|
||||
|
||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal);
|
||||
@@ -297,7 +335,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
// Build hole sub-program relative to (0,0)
|
||||
var holeCenter = circle.Center;
|
||||
var relativePoint = new Vector(point.X - holeCenter.X, point.Y - holeCenter.Y);
|
||||
var relativeCircle = new Circle(new Vector(0, 0), circle.Radius) { Rotation = circle.Rotation };
|
||||
var relativeCircle = new Circle(new Vector(0, 0), circle.Radius)
|
||||
{
|
||||
Rotation = circle.Rotation,
|
||||
};
|
||||
var relativeShape = new Shape();
|
||||
relativeShape.Entities.Add(relativeCircle);
|
||||
|
||||
@@ -314,12 +355,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
if (!program.SubPrograms.ContainsKey(key))
|
||||
program.SubPrograms[key] = subPgm;
|
||||
|
||||
program.Codes.Add(new SubProgramCall
|
||||
{
|
||||
Id = key,
|
||||
Program = program.SubPrograms[key],
|
||||
Offset = holeCenter
|
||||
});
|
||||
program.Codes.Add(
|
||||
new SubProgramCall
|
||||
{
|
||||
Id = key,
|
||||
Program = program.SubPrograms[key],
|
||||
Offset = holeCenter,
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -328,7 +371,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
var reindexedShape = shape.ReindexAt(point, entity);
|
||||
|
||||
if (Parameters.TabsEnabled && Parameters.TabConfig != null && contourType == ContourType.External)
|
||||
if (
|
||||
Parameters.TabsEnabled
|
||||
&& Parameters.TabConfig != null
|
||||
&& contourType == ContourType.External
|
||||
)
|
||||
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
||||
|
||||
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||
@@ -337,7 +384,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
||||
{
|
||||
if (scribeEntities.Count == 0) return;
|
||||
if (scribeEntities.Count == 0)
|
||||
return;
|
||||
|
||||
var shapes = ShapeBuilder.GetShapes(scribeEntities);
|
||||
foreach (var shape in shapes)
|
||||
@@ -388,8 +436,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return ContourType.Internal;
|
||||
}
|
||||
|
||||
public static double ComputeNormal(Vector point, Entity entity, ContourType contourType,
|
||||
RotationType winding = RotationType.CW)
|
||||
public static double ComputeNormal(
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType contourType,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
double normal;
|
||||
|
||||
@@ -442,7 +494,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return polygon.RotationDirection();
|
||||
}
|
||||
|
||||
private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle)
|
||||
private LeadIn ClampLeadInForCircle(
|
||||
LeadIn leadIn,
|
||||
Circle circle,
|
||||
Vector contourPoint,
|
||||
double normalAngle
|
||||
)
|
||||
{
|
||||
if (leadIn is NoLeadIn || Parameters.PierceClearance <= 0)
|
||||
return leadIn;
|
||||
@@ -492,7 +549,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn,
|
||||
ContourType.Internal => Parameters.InternalLeadIn,
|
||||
_ => Parameters.ExternalLeadIn
|
||||
_ => Parameters.ExternalLeadIn,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -502,7 +559,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut,
|
||||
ContourType.Internal => Parameters.InternalLeadOut,
|
||||
_ => Parameters.ExternalLeadOut
|
||||
_ => Parameters.ExternalLeadOut,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -565,12 +622,18 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
private static Vector EntityStartPoint(Entity entity)
|
||||
{
|
||||
if (entity is Line line) return line.StartPoint;
|
||||
if (entity is Arc arc) return arc.StartPoint();
|
||||
if (entity is Line line)
|
||||
return line.StartPoint;
|
||||
if (entity is Arc arc)
|
||||
return arc.StartPoint();
|
||||
return Vector.Zero;
|
||||
}
|
||||
|
||||
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint, LayerType layer = LayerType.Display)
|
||||
private List<ICode> ConvertShapeToMoves(
|
||||
Shape shape,
|
||||
Vector startPoint,
|
||||
LayerType layer = LayerType.Display
|
||||
)
|
||||
{
|
||||
var moves = new List<ICode>();
|
||||
|
||||
@@ -582,15 +645,28 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer });
|
||||
moves.Add(
|
||||
new ArcMove(
|
||||
arc.EndPoint(),
|
||||
arc.Center,
|
||||
arc.IsReversed ? RotationType.CW : RotationType.CCW
|
||||
)
|
||||
{
|
||||
Layer = layer,
|
||||
}
|
||||
);
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer });
|
||||
moves.Add(
|
||||
new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer }
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new System.InvalidOperationException($"Unsupported entity type: {entity.Type}");
|
||||
throw new System.InvalidOperationException(
|
||||
$"Unsupported entity type: {entity.Type}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,9 +676,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
private static Vector GetShapeStartPoint(Shape shape)
|
||||
{
|
||||
var first = shape.Entities[0];
|
||||
if (first is Line line) return line.StartPoint;
|
||||
if (first is Arc arc) return arc.StartPoint();
|
||||
if (first is Circle circle) return new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
|
||||
if (first is Line line)
|
||||
return line.StartPoint;
|
||||
if (first is Arc arc)
|
||||
return arc.StartPoint();
|
||||
if (first is Circle circle)
|
||||
return new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
|
||||
return Vector.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
External,
|
||||
Internal,
|
||||
ArcCircle
|
||||
ArcCircle,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
|
||||
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
|
||||
|
||||
public LeadIn InternalLeadIn { get; set; } = new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
|
||||
public LeadIn InternalLeadIn { get; set; } =
|
||||
new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
|
||||
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
|
||||
|
||||
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -7,19 +7,23 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public double Radius { get; set; }
|
||||
|
||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
|
||||
var arcCenter = new Vector(
|
||||
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
|
||||
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle));
|
||||
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,10 +34,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
return new Vector(
|
||||
arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
|
||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle));
|
||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle)
|
||||
);
|
||||
}
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new ArcLeadIn { Radius = Radius * factor };
|
||||
public override LeadIn Scale(double factor) => new ArcLeadIn { Radius = Radius * factor };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public double ArcRadius { get; set; }
|
||||
public double Kerf { get; set; }
|
||||
|
||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
|
||||
@@ -22,13 +25,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
|
||||
var arcStart = new Vector(
|
||||
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle));
|
||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,10 +47,16 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
return new Vector(
|
||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
||||
arcStartY + LineLength * System.Math.Sin(lineAngle));
|
||||
arcStartY + LineLength * System.Math.Sin(lineAngle)
|
||||
);
|
||||
}
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new CleanHoleLeadIn { LineLength = LineLength * factor, ArcRadius = ArcRadius * factor, Kerf = Kerf };
|
||||
new CleanHoleLeadIn
|
||||
{
|
||||
LineLength = LineLength * factor,
|
||||
ArcRadius = ArcRadius * factor,
|
||||
Kerf = Kerf,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public abstract class LeadIn
|
||||
{
|
||||
public abstract List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW);
|
||||
public abstract List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
);
|
||||
|
||||
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public double ApproachAngle { get; set; } = 135.0;
|
||||
public double ArcRadius { get; set; }
|
||||
|
||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
|
||||
@@ -22,13 +25,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
||||
var arcStart = new Vector(
|
||||
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle));
|
||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,10 +47,16 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
return new Vector(
|
||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
||||
arcStartY + LineLength * System.Math.Sin(lineAngle));
|
||||
arcStartY + LineLength * System.Math.Sin(lineAngle)
|
||||
);
|
||||
}
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new LineArcLeadIn { LineLength = LineLength * factor, ArcRadius = ArcRadius * factor, ApproachAngle = ApproachAngle };
|
||||
new LineArcLeadIn
|
||||
{
|
||||
LineLength = LineLength * factor,
|
||||
ArcRadius = ArcRadius * factor,
|
||||
ApproachAngle = ApproachAngle,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -9,15 +9,18 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public double Length { get; set; }
|
||||
public double ApproachAngle { get; set; } = 90.0;
|
||||
|
||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin }
|
||||
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +29,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
|
||||
return new Vector(
|
||||
contourStartPoint.X + Length * System.Math.Cos(approachAngle),
|
||||
contourStartPoint.Y + Length * System.Math.Sin(approachAngle));
|
||||
contourStartPoint.Y + Length * System.Math.Sin(approachAngle)
|
||||
);
|
||||
}
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -11,21 +11,25 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public double Length2 { get; set; }
|
||||
public double ApproachAngle2 { get; set; } = 90.0;
|
||||
|
||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
|
||||
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||
var midPoint = new Vector(
|
||||
contourStartPoint.X + Length2 * System.Math.Cos(secondAngle),
|
||||
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle));
|
||||
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(midPoint) { Layer = LayerType.Leadin },
|
||||
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin }
|
||||
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,10 +42,17 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2);
|
||||
return new Vector(
|
||||
midX + Length1 * System.Math.Cos(firstAngle),
|
||||
midY + Length1 * System.Math.Sin(firstAngle));
|
||||
midY + Length1 * System.Math.Sin(firstAngle)
|
||||
);
|
||||
}
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new LineLineLeadIn { Length1 = Length1 * factor, ApproachAngle1 = ApproachAngle1, Length2 = Length2 * factor, ApproachAngle2 = ApproachAngle2 };
|
||||
new LineLineLeadIn
|
||||
{
|
||||
Length1 = Length1 * factor,
|
||||
ApproachAngle1 = ApproachAngle1,
|
||||
Length2 = Length2 * factor,
|
||||
ApproachAngle2 = ApproachAngle2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public class NoLeadIn : LeadIn
|
||||
{
|
||||
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(contourStartPoint)
|
||||
};
|
||||
return new List<ICode> { new RapidMove(contourStartPoint) };
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -7,8 +7,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public double Radius { get; set; }
|
||||
|
||||
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var arcCenterX = contourEndPoint.X + Radius * System.Math.Cos(contourNormalAngle);
|
||||
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
|
||||
@@ -16,11 +19,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
var endPoint = new Vector(
|
||||
arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2),
|
||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2));
|
||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new ArcMove(endPoint, arcCenter, winding) { Layer = LayerType.Leadout }
|
||||
new ArcMove(endPoint, arcCenter, winding) { Layer = LayerType.Leadout },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public abstract class LeadOut
|
||||
{
|
||||
public abstract List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW);
|
||||
public abstract List<ICode> Generate(
|
||||
Vector contourEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -9,18 +9,19 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public double Length { get; set; }
|
||||
public double ApproachAngle { get; set; } = 90.0;
|
||||
|
||||
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
|
||||
var endPoint = new Vector(
|
||||
contourEndPoint.X + Length * System.Math.Cos(overcutAngle),
|
||||
contourEndPoint.Y + Length * System.Math.Sin(overcutAngle));
|
||||
contourEndPoint.Y + Length * System.Math.Sin(overcutAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new LinearMove(endPoint) { Layer = LayerType.Leadout }
|
||||
};
|
||||
return new List<ICode> { new LinearMove(endPoint) { Layer = LayerType.Leadout } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public class NoLeadOut : LeadOut
|
||||
{
|
||||
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
public override List<ICode> Generate(
|
||||
Vector contourEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
return new List<ICode>();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
BottomSide = 4,
|
||||
EdgeStart = 5,
|
||||
LeftSide = 7,
|
||||
RightSideAlt = 8
|
||||
RightSideAlt = 8,
|
||||
}
|
||||
|
||||
public class SequenceParameters
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public double BreakerAngle { get; set; }
|
||||
|
||||
public override List<ICode> Generate(
|
||||
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
Vector tabStartPoint,
|
||||
Vector tabEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var codes = new List<ICode>();
|
||||
|
||||
@@ -21,7 +24,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var scoreAngle = contourNormalAngle + System.Math.PI;
|
||||
var scoreEnd = new Vector(
|
||||
tabStartPoint.X + BreakerDepth * System.Math.Cos(scoreAngle),
|
||||
tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle));
|
||||
tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle)
|
||||
);
|
||||
codes.Add(new LinearMove(scoreEnd));
|
||||
codes.Add(new RapidMove(tabEndPoint));
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -8,13 +8,13 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public int MachineTabId { get; set; }
|
||||
|
||||
public override List<ICode> Generate(
|
||||
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
Vector tabStartPoint,
|
||||
Vector tabEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(tabEndPoint)
|
||||
};
|
||||
return new List<ICode> { new RapidMove(tabEndPoint) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -11,8 +11,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public double CutoutMaxHeight { get; set; }
|
||||
|
||||
public override List<ICode> Generate(
|
||||
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
Vector tabStartPoint,
|
||||
Vector tabEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var codes = new List<ICode>();
|
||||
|
||||
@@ -29,8 +32,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
public bool AppliesToCutout(double cutoutWidth, double cutoutHeight)
|
||||
{
|
||||
return cutoutWidth >= CutoutMinWidth && cutoutWidth <= CutoutMaxWidth
|
||||
&& cutoutHeight >= CutoutMinHeight && cutoutHeight <= CutoutMaxHeight;
|
||||
return cutoutWidth >= CutoutMinWidth
|
||||
&& cutoutWidth <= CutoutMaxWidth
|
||||
&& cutoutHeight >= CutoutMinHeight
|
||||
&& cutoutHeight <= CutoutMaxHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -10,7 +10,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public LeadOut TabLeadOut { get; set; }
|
||||
|
||||
public abstract List<ICode> Generate(
|
||||
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW);
|
||||
Vector tabStartPoint,
|
||||
Vector tabEndPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
|
||||
public const int UseMax = -2;
|
||||
|
||||
public Feedrate()
|
||||
{
|
||||
}
|
||||
public Feedrate() { }
|
||||
|
||||
public Feedrate(double value)
|
||||
{
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
|
||||
namespace OpenNest.CNC
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public enum KerfType
|
||||
{
|
||||
None,
|
||||
Left,
|
||||
Right
|
||||
Right,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
namespace OpenNest.CNC
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public enum LayerType
|
||||
{
|
||||
@@ -7,6 +6,6 @@ namespace OpenNest.CNC
|
||||
Scribe,
|
||||
Cut,
|
||||
Leadin,
|
||||
Leadout
|
||||
Leadout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,10 @@ namespace OpenNest.CNC
|
||||
public class LinearMove : Motion
|
||||
{
|
||||
public LinearMove()
|
||||
: this(new Vector())
|
||||
{
|
||||
}
|
||||
: this(new Vector()) { }
|
||||
|
||||
public LinearMove(double x, double y)
|
||||
: this(new Vector(x, y))
|
||||
{
|
||||
}
|
||||
: this(new Vector(x, y)) { }
|
||||
|
||||
public LinearMove(Vector endPoint)
|
||||
{
|
||||
@@ -34,7 +30,8 @@ namespace OpenNest.CNC
|
||||
{
|
||||
Layer = Layer,
|
||||
Suppressed = Suppressed,
|
||||
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
|
||||
VariableRefs =
|
||||
VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
|
||||
namespace OpenNest.CNC
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public enum Mode
|
||||
{
|
||||
Absolute,
|
||||
Incremental
|
||||
Incremental,
|
||||
}
|
||||
}
|
||||
|
||||
+206
-182
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
@@ -10,7 +10,8 @@ namespace OpenNest.CNC
|
||||
{
|
||||
public List<ICode> Codes;
|
||||
|
||||
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, VariableDefinition> Variables { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Dictionary<int, Program> SubPrograms { get; } = new();
|
||||
|
||||
@@ -66,9 +67,17 @@ namespace OpenNest.CNC
|
||||
{
|
||||
if (code is Motion m)
|
||||
{
|
||||
var cmd = m is RapidMove ? "G00" : (m is ArcMove am ? (am.Rotation == RotationType.CW ? "G02" : "G03") : "G01");
|
||||
var cmd =
|
||||
m is RapidMove
|
||||
? "G00"
|
||||
: (
|
||||
m is ArcMove am
|
||||
? (am.Rotation == RotationType.CW ? "G02" : "G03")
|
||||
: "G01"
|
||||
);
|
||||
sb.Append($"{cmd}X{m.EndPoint.X:F4}Y{m.EndPoint.Y:F4}");
|
||||
if (m is ArcMove arc) sb.Append($"I{arc.CenterPoint.X:F4}J{arc.CenterPoint.Y:F4}");
|
||||
if (m is ArcMove arc)
|
||||
sb.Append($"I{arc.CenterPoint.X:F4}J{arc.CenterPoint.Y:F4}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
@@ -97,7 +106,8 @@ namespace OpenNest.CNC
|
||||
var dy = subpgm.Offset.Y - origin.Y;
|
||||
subpgm.Offset = new Geometry.Vector(
|
||||
origin.X + dx * cos - dy * sin,
|
||||
origin.Y + dx * sin + dy * cos);
|
||||
origin.Y + dx * sin + dy * cos
|
||||
);
|
||||
}
|
||||
|
||||
if (subpgm.Program != null)
|
||||
@@ -130,8 +140,7 @@ namespace OpenNest.CNC
|
||||
|
||||
if (code is SubProgramCall subpgm)
|
||||
{
|
||||
subpgm.Offset = new Geometry.Vector(
|
||||
subpgm.Offset.X + x, subpgm.Offset.Y + y);
|
||||
subpgm.Offset = new Geometry.Vector(subpgm.Offset.X + x, subpgm.Offset.Y + y);
|
||||
}
|
||||
|
||||
if (code is Motion == false)
|
||||
@@ -159,7 +168,9 @@ namespace OpenNest.CNC
|
||||
if (code is SubProgramCall subpgm)
|
||||
{
|
||||
subpgm.Offset = new Geometry.Vector(
|
||||
subpgm.Offset.X + voffset.X, subpgm.Offset.Y + voffset.Y);
|
||||
subpgm.Offset.X + voffset.X,
|
||||
subpgm.Offset.Y + voffset.Y
|
||||
);
|
||||
}
|
||||
|
||||
if (code is Motion == false)
|
||||
@@ -258,56 +269,63 @@ namespace OpenNest.CNC
|
||||
switch (Mode)
|
||||
{
|
||||
case Mode.Absolute:
|
||||
{
|
||||
for (int i = Codes.Count; i >= 0; --i)
|
||||
{
|
||||
for (int i = Codes.Count; i >= 0; --i)
|
||||
{
|
||||
var code = Codes[i];
|
||||
var motion = code as Motion;
|
||||
var code = Codes[i];
|
||||
var motion = code as Motion;
|
||||
|
||||
if (motion == null) continue;
|
||||
if (motion == null)
|
||||
continue;
|
||||
|
||||
return motion.EndPoint;
|
||||
}
|
||||
break;
|
||||
return motion.EndPoint;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Mode.Incremental:
|
||||
{
|
||||
var pos = new Vector(0, 0);
|
||||
|
||||
for (int i = 0; i < Codes.Count; ++i)
|
||||
{
|
||||
var pos = new Vector(0, 0);
|
||||
var code = Codes[i];
|
||||
var motion = code as Motion;
|
||||
|
||||
for (int i = 0; i < Codes.Count; ++i)
|
||||
{
|
||||
var code = Codes[i];
|
||||
var motion = code as Motion;
|
||||
if (motion == null)
|
||||
continue;
|
||||
|
||||
if (motion == null) continue;
|
||||
|
||||
pos += motion.EndPoint;
|
||||
}
|
||||
|
||||
return pos;
|
||||
pos += motion.EndPoint;
|
||||
}
|
||||
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
|
||||
return new Vector(0, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounding box of the geometry the program visits. The tool's starting position is not
|
||||
/// part of the geometry, so the origin only contributes when the program reaches it.
|
||||
/// An empty program returns a zero-size box at the origin.
|
||||
/// </summary>
|
||||
public Box BoundingBox()
|
||||
{
|
||||
var origin = new Vector(0, 0);
|
||||
return BoundingBox(ref origin);
|
||||
return BoundingBox(ref origin, out var box) ? box : new Box(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
private Box BoundingBox(ref Vector pos)
|
||||
private bool BoundingBox(ref Vector pos, out Box result)
|
||||
{
|
||||
// Capture the frame origin at entry. Sub-program Offsets and
|
||||
// absolute-mode endpoints are relative to this fixed origin.
|
||||
var frameOrigin = pos;
|
||||
|
||||
double minX = 0.0;
|
||||
double minY = 0.0;
|
||||
double maxX = 0.0;
|
||||
double maxY = 0.0;
|
||||
var minX = double.PositiveInfinity;
|
||||
var minY = double.PositiveInfinity;
|
||||
var maxX = double.NegativeInfinity;
|
||||
var maxY = double.NegativeInfinity;
|
||||
|
||||
for (int i = 0; i < Codes.Count; ++i)
|
||||
{
|
||||
@@ -316,174 +334,180 @@ namespace OpenNest.CNC
|
||||
switch (code.Type)
|
||||
{
|
||||
case CodeType.LinearMove:
|
||||
{
|
||||
var line = (LinearMove)code;
|
||||
var pt = Mode == Mode.Absolute ?
|
||||
frameOrigin + line.EndPoint :
|
||||
line.EndPoint + pos;
|
||||
|
||||
if (pt.X > maxX)
|
||||
maxX = pt.X;
|
||||
else if (pt.X < minX)
|
||||
minX = pt.X;
|
||||
|
||||
if (pt.Y > maxY)
|
||||
maxY = pt.Y;
|
||||
else if (pt.Y < minY)
|
||||
minY = pt.Y;
|
||||
|
||||
pos = pt;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case CodeType.RapidMove:
|
||||
{
|
||||
var line = (RapidMove)code;
|
||||
var pt = Mode == Mode.Absolute
|
||||
{
|
||||
var line = (LinearMove)code;
|
||||
var pt =
|
||||
Mode == Mode.Absolute
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
|
||||
if (pt.X > maxX)
|
||||
maxX = pt.X;
|
||||
else if (pt.X < minX)
|
||||
minX = pt.X;
|
||||
if (pt.X > maxX)
|
||||
maxX = pt.X;
|
||||
if (pt.X < minX)
|
||||
minX = pt.X;
|
||||
|
||||
if (pt.Y > maxY)
|
||||
maxY = pt.Y;
|
||||
else if (pt.Y < minY)
|
||||
minY = pt.Y;
|
||||
if (pt.Y > maxY)
|
||||
maxY = pt.Y;
|
||||
if (pt.Y < minY)
|
||||
minY = pt.Y;
|
||||
|
||||
pos = pt;
|
||||
pos = pt;
|
||||
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CodeType.RapidMove:
|
||||
{
|
||||
var line = (RapidMove)code;
|
||||
var pt =
|
||||
Mode == Mode.Absolute
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
|
||||
if (pt.X > maxX)
|
||||
maxX = pt.X;
|
||||
if (pt.X < minX)
|
||||
minX = pt.X;
|
||||
|
||||
if (pt.Y > maxY)
|
||||
maxY = pt.Y;
|
||||
if (pt.Y < minY)
|
||||
minY = pt.Y;
|
||||
|
||||
pos = pt;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case CodeType.ArcMove:
|
||||
{
|
||||
var arc = (ArcMove)code;
|
||||
var radius = arc.CenterPoint.DistanceTo(arc.EndPoint);
|
||||
|
||||
Vector endpt;
|
||||
Vector centerpt;
|
||||
|
||||
if (Mode == Mode.Incremental)
|
||||
{
|
||||
var arc = (ArcMove)code;
|
||||
var radius = arc.CenterPoint.DistanceTo(arc.EndPoint);
|
||||
|
||||
Vector endpt;
|
||||
Vector centerpt;
|
||||
|
||||
if (Mode == Mode.Incremental)
|
||||
{
|
||||
endpt = arc.EndPoint + pos;
|
||||
centerpt = arc.CenterPoint + pos;
|
||||
}
|
||||
else
|
||||
{
|
||||
endpt = frameOrigin + arc.EndPoint;
|
||||
centerpt = frameOrigin + arc.CenterPoint;
|
||||
}
|
||||
|
||||
double minX1;
|
||||
double minY1;
|
||||
double maxX1;
|
||||
double maxY1;
|
||||
|
||||
if (pos.X < endpt.X)
|
||||
{
|
||||
minX1 = pos.X;
|
||||
maxX1 = endpt.X;
|
||||
}
|
||||
else
|
||||
{
|
||||
minX1 = endpt.X;
|
||||
maxX1 = pos.X;
|
||||
}
|
||||
|
||||
if (pos.Y < endpt.Y)
|
||||
{
|
||||
minY1 = pos.Y;
|
||||
maxY1 = endpt.Y;
|
||||
}
|
||||
else
|
||||
{
|
||||
minY1 = endpt.Y;
|
||||
maxY1 = pos.Y;
|
||||
}
|
||||
|
||||
var startAngle = pos.AngleFrom(centerpt);
|
||||
var endAngle = endpt.AngleFrom(centerpt);
|
||||
|
||||
// switch the angle to counter clockwise.
|
||||
if (arc.Rotation == RotationType.CW)
|
||||
Generic.Swap(ref startAngle, ref endAngle);
|
||||
|
||||
startAngle = Angle.NormalizeRad(startAngle);
|
||||
endAngle = Angle.NormalizeRad(endAngle);
|
||||
|
||||
if (Angle.IsBetweenRad(Angle.HalfPI, startAngle, endAngle))
|
||||
maxY1 = centerpt.Y + radius;
|
||||
|
||||
if (Angle.IsBetweenRad(System.Math.PI, startAngle, endAngle))
|
||||
minX1 = centerpt.X - radius;
|
||||
|
||||
const double oneHalfPI = System.Math.PI * 1.5;
|
||||
|
||||
if (Angle.IsBetweenRad(oneHalfPI, startAngle, endAngle))
|
||||
minY1 = centerpt.Y - radius;
|
||||
|
||||
if (Angle.IsBetweenRad(Angle.TwoPI, startAngle, endAngle))
|
||||
maxX1 = centerpt.X + radius;
|
||||
|
||||
if (maxX1 > maxX)
|
||||
maxX = maxX1;
|
||||
|
||||
if (minX1 < minX)
|
||||
minX = minX1;
|
||||
|
||||
if (maxY1 > maxY)
|
||||
maxY = maxY1;
|
||||
|
||||
if (minY1 < minY)
|
||||
minY = minY1;
|
||||
|
||||
pos = endpt;
|
||||
|
||||
break;
|
||||
endpt = arc.EndPoint + pos;
|
||||
centerpt = arc.CenterPoint + pos;
|
||||
}
|
||||
else
|
||||
{
|
||||
endpt = frameOrigin + arc.EndPoint;
|
||||
centerpt = frameOrigin + arc.CenterPoint;
|
||||
}
|
||||
|
||||
double minX1;
|
||||
double minY1;
|
||||
double maxX1;
|
||||
double maxY1;
|
||||
|
||||
if (pos.X < endpt.X)
|
||||
{
|
||||
minX1 = pos.X;
|
||||
maxX1 = endpt.X;
|
||||
}
|
||||
else
|
||||
{
|
||||
minX1 = endpt.X;
|
||||
maxX1 = pos.X;
|
||||
}
|
||||
|
||||
if (pos.Y < endpt.Y)
|
||||
{
|
||||
minY1 = pos.Y;
|
||||
maxY1 = endpt.Y;
|
||||
}
|
||||
else
|
||||
{
|
||||
minY1 = endpt.Y;
|
||||
maxY1 = pos.Y;
|
||||
}
|
||||
|
||||
var startAngle = pos.AngleFrom(centerpt);
|
||||
var endAngle = endpt.AngleFrom(centerpt);
|
||||
|
||||
// switch the angle to counter clockwise.
|
||||
if (arc.Rotation == RotationType.CW)
|
||||
Generic.Swap(ref startAngle, ref endAngle);
|
||||
|
||||
startAngle = Angle.NormalizeRad(startAngle);
|
||||
endAngle = Angle.NormalizeRad(endAngle);
|
||||
|
||||
if (Angle.IsBetweenRad(Angle.HalfPI, startAngle, endAngle))
|
||||
maxY1 = centerpt.Y + radius;
|
||||
|
||||
if (Angle.IsBetweenRad(System.Math.PI, startAngle, endAngle))
|
||||
minX1 = centerpt.X - radius;
|
||||
|
||||
const double oneHalfPI = System.Math.PI * 1.5;
|
||||
|
||||
if (Angle.IsBetweenRad(oneHalfPI, startAngle, endAngle))
|
||||
minY1 = centerpt.Y - radius;
|
||||
|
||||
if (Angle.IsBetweenRad(Angle.TwoPI, startAngle, endAngle))
|
||||
maxX1 = centerpt.X + radius;
|
||||
|
||||
if (maxX1 > maxX)
|
||||
maxX = maxX1;
|
||||
|
||||
if (minX1 < minX)
|
||||
minX = minX1;
|
||||
|
||||
if (maxY1 > maxY)
|
||||
maxY = maxY1;
|
||||
|
||||
if (minY1 < minY)
|
||||
minY = minY1;
|
||||
|
||||
pos = endpt;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case CodeType.SubProgramCall:
|
||||
{
|
||||
var subpgm = (SubProgramCall)code;
|
||||
if (subpgm.Program == null)
|
||||
break;
|
||||
|
||||
// Sub-program frame origin in this program's frame
|
||||
// is frameOrigin + Offset, regardless of current pos.
|
||||
pos = frameOrigin + subpgm.Offset;
|
||||
var box = subpgm.Program.BoundingBox(ref pos);
|
||||
|
||||
if (box.Left < minX)
|
||||
minX = box.Left;
|
||||
|
||||
if (box.Right > maxX)
|
||||
maxX = box.Right;
|
||||
|
||||
if (box.Bottom < minY)
|
||||
minY = box.Bottom;
|
||||
|
||||
if (box.Top > maxY)
|
||||
maxY = box.Top;
|
||||
|
||||
{
|
||||
var subpgm = (SubProgramCall)code;
|
||||
if (subpgm.Program == null)
|
||||
break;
|
||||
}
|
||||
|
||||
// Sub-program frame origin in this program's frame
|
||||
// is frameOrigin + Offset, regardless of current pos.
|
||||
pos = frameOrigin + subpgm.Offset;
|
||||
if (!subpgm.Program.BoundingBox(ref pos, out var box))
|
||||
break;
|
||||
|
||||
if (box.Left < minX)
|
||||
minX = box.Left;
|
||||
|
||||
if (box.Right > maxX)
|
||||
maxX = box.Right;
|
||||
|
||||
if (box.Bottom < minY)
|
||||
minY = box.Bottom;
|
||||
|
||||
if (box.Top > maxY)
|
||||
maxY = box.Top;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Box(minX, minY, maxX - minX, maxY - minY);
|
||||
if (minX > maxX || minY > maxY)
|
||||
{
|
||||
result = new Box(0, 0, 0, 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
result = new Box(minX, minY, maxX - minX, maxY - minY);
|
||||
return true;
|
||||
}
|
||||
|
||||
public object Clone()
|
||||
{
|
||||
var pgm = new Program()
|
||||
{
|
||||
mode = this.mode,
|
||||
Rotation = this.Rotation
|
||||
};
|
||||
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
|
||||
|
||||
var codes = new ICode[Length];
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ namespace OpenNest.CNC
|
||||
|
||||
public List<string> EmitDeclarations()
|
||||
{
|
||||
return _variables.Values
|
||||
.Where(v => v.Expression != null)
|
||||
return _variables
|
||||
.Values.Where(v => v.Expression != null)
|
||||
.OrderBy(v => v.Number)
|
||||
.Select(v => $"{v.Reference}={v.Expression} ({FormatComment(v.Name)})")
|
||||
.ToList();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
@@ -36,7 +36,13 @@ namespace OpenNest.CNC
|
||||
return basePos;
|
||||
}
|
||||
|
||||
private static void Walk(Program pgm, Vector basePos, ref Vector pos, bool skipFirst, List<Segment> results)
|
||||
private static void Walk(
|
||||
Program pgm,
|
||||
Vector basePos,
|
||||
ref Vector pos,
|
||||
bool skipFirst,
|
||||
List<Segment> results
|
||||
)
|
||||
{
|
||||
var skipped = !skipFirst;
|
||||
|
||||
@@ -60,9 +66,10 @@ namespace OpenNest.CNC
|
||||
}
|
||||
else if (code is Motion motion)
|
||||
{
|
||||
var endpt = pgm.Mode == Mode.Incremental
|
||||
? motion.EndPoint + pos
|
||||
: motion.EndPoint + basePos;
|
||||
var endpt =
|
||||
pgm.Mode == Mode.Incremental
|
||||
? motion.EndPoint + pos
|
||||
: motion.EndPoint + basePos;
|
||||
|
||||
if (code.Type == CodeType.RapidMove)
|
||||
{
|
||||
|
||||
@@ -30,7 +30,8 @@ namespace OpenNest.CNC
|
||||
return new RapidMove(EndPoint)
|
||||
{
|
||||
Suppressed = Suppressed,
|
||||
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
|
||||
VariableRefs =
|
||||
VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,7 @@ namespace OpenNest.CNC
|
||||
private double rotation;
|
||||
private Program program;
|
||||
|
||||
public SubProgramCall()
|
||||
{
|
||||
}
|
||||
public SubProgramCall() { }
|
||||
|
||||
public SubProgramCall(Program program, double rotation)
|
||||
{
|
||||
|
||||
@@ -8,8 +8,13 @@ namespace OpenNest.CNC
|
||||
public bool Inline { get; }
|
||||
public bool Global { get; }
|
||||
|
||||
public VariableDefinition(string name, string expression, double value,
|
||||
bool inline = false, bool global = false)
|
||||
public VariableDefinition(
|
||||
string name,
|
||||
string expression,
|
||||
double value,
|
||||
bool inline = false,
|
||||
bool global = false
|
||||
)
|
||||
{
|
||||
Name = name;
|
||||
Expression = expression;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Linq;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
@@ -14,6 +14,12 @@ namespace OpenNest
|
||||
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
|
||||
public const double SnapToZero = 0.001;
|
||||
|
||||
/// <summary>Centroid offsets below this fraction of the MBR extent count as symmetric.</summary>
|
||||
private const double SymmetryTolerance = 1e-6;
|
||||
|
||||
/// <summary>Angular margin (radians) keeping axis-aligned centroid offsets off the edge of the preferred quadrant.</summary>
|
||||
private const double PreferenceMargin = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which
|
||||
/// computes the MBR itself) and by PartClassifier (which already has one). Single formula
|
||||
@@ -44,8 +50,9 @@ namespace OpenNest
|
||||
if (drawing?.Program == null)
|
||||
return 0.0;
|
||||
|
||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
var entities = ConvertProgram
|
||||
.ToGeometry(drawing.Program)
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer));
|
||||
|
||||
var shapes = ShapeBuilder.GetShapes(entities);
|
||||
if (shapes.Count == 0)
|
||||
@@ -72,7 +79,89 @@ namespace OpenNest
|
||||
return 0.0;
|
||||
|
||||
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
|
||||
return FromMbr(mbr);
|
||||
var angle = FromMbr(mbr);
|
||||
if (mbr.Area <= OpenNest.Math.Tolerance.Epsilon)
|
||||
return angle;
|
||||
|
||||
var quarterTurns = PreferredQuarterTurns(polygon, hull, angle);
|
||||
if (quarterTurns == 0)
|
||||
return angle;
|
||||
|
||||
return NormalizeSigned(angle + quarterTurns * System.Math.PI / 2.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The MBR only fixes the frame modulo 90°, leaving four equivalent orientations. Nest
|
||||
/// results are not 90°-symmetric, so pick one deterministically: the quarter-turn count
|
||||
/// that puts the perimeter's centroid toward the lower-left of its MBR. Shapes with no
|
||||
/// centroid offset (rectangles, circles) are symmetric and keep the MBR orientation.
|
||||
/// </summary>
|
||||
private static int PreferredQuarterTurns(Polygon polygon, Polygon hull, double angle)
|
||||
{
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
foreach (var vertex in hull.Vertices)
|
||||
{
|
||||
var rotated = vertex.Rotate(angle);
|
||||
minX = System.Math.Min(minX, rotated.X);
|
||||
minY = System.Math.Min(minY, rotated.Y);
|
||||
maxX = System.Math.Max(maxX, rotated.X);
|
||||
maxY = System.Math.Max(maxY, rotated.Y);
|
||||
}
|
||||
|
||||
var centroid = Centroid(polygon).Rotate(angle);
|
||||
var dx = centroid.X - (minX + maxX) / 2.0;
|
||||
var dy = centroid.Y - (minY + maxY) / 2.0;
|
||||
|
||||
var extent = System.Math.Max(maxX - minX, maxY - minY);
|
||||
if (System.Math.Sqrt(dx * dx + dy * dy) <= SymmetryTolerance * extent)
|
||||
return 0;
|
||||
|
||||
// Choose k so the offset direction lands in [PI - margin, 3PI/2 - margin). The margin
|
||||
// keeps offsets lying exactly on an axis (mirror-symmetric parts) away from the
|
||||
// interval edge so floating-point noise cannot flip the choice.
|
||||
var halfPi = System.Math.PI / 2.0;
|
||||
var direction = System.Math.Atan2(dy, dx);
|
||||
for (var turns = 0; turns < 4; turns++)
|
||||
{
|
||||
var relative = direction + turns * halfPi - (System.Math.PI - PreferenceMargin);
|
||||
relative -= 2.0 * System.Math.PI * System.Math.Floor(relative / (2.0 * System.Math.PI));
|
||||
if (relative < halfPi)
|
||||
return turns;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Vector Centroid(Polygon polygon)
|
||||
{
|
||||
var vertices = polygon.Vertices;
|
||||
var doubleArea = 0.0;
|
||||
var cx = 0.0;
|
||||
var cy = 0.0;
|
||||
for (var i = 0; i < vertices.Count; i++)
|
||||
{
|
||||
var p = vertices[i];
|
||||
var q = vertices[(i + 1) % vertices.Count];
|
||||
var cross = p.X * q.Y - q.X * p.Y;
|
||||
doubleArea += cross;
|
||||
cx += (p.X + q.X) * cross;
|
||||
cy += (p.Y + q.Y) * cross;
|
||||
}
|
||||
|
||||
if (System.Math.Abs(doubleArea) <= OpenNest.Math.Tolerance.Epsilon)
|
||||
return new Vector(vertices.Average(v => v.X), vertices.Average(v => v.Y));
|
||||
|
||||
return new Vector(cx / (3.0 * doubleArea), cy / (3.0 * doubleArea));
|
||||
}
|
||||
|
||||
private static double NormalizeSigned(double angle)
|
||||
{
|
||||
var twoPi = 2.0 * System.Math.PI;
|
||||
angle -= twoPi * System.Math.Floor((angle + System.Math.PI) / twoPi);
|
||||
return angle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,5 @@
|
||||
|
||||
namespace OpenNest.Collections
|
||||
{
|
||||
public class DrawingCollection : HashSet<Drawing>
|
||||
{
|
||||
}
|
||||
public class DrawingCollection : HashSet<Drawing> { }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Converters
|
||||
{
|
||||
@@ -10,7 +10,7 @@ namespace OpenNest.Converters
|
||||
Perimeter,
|
||||
Hole,
|
||||
Etch,
|
||||
Open
|
||||
Open,
|
||||
}
|
||||
|
||||
public sealed class ContourInfo
|
||||
@@ -91,7 +91,8 @@ namespace OpenNest.Converters
|
||||
// Non-perimeter shapes first (matches CNC cut order: holes before perimeter)
|
||||
for (var i = 0; i < shapes.Count; i++)
|
||||
{
|
||||
if (i == perimeterIndex) continue;
|
||||
if (i == perimeterIndex)
|
||||
continue;
|
||||
var shape = shapes[i];
|
||||
var type = ClassifyShape(shape);
|
||||
|
||||
@@ -116,7 +117,13 @@ namespace OpenNest.Converters
|
||||
}
|
||||
|
||||
// Perimeter last
|
||||
result.Add(new ContourInfo(shapes[perimeterIndex], ContourClassification.Perimeter, "Perimeter"));
|
||||
result.Add(
|
||||
new ContourInfo(
|
||||
shapes[perimeterIndex],
|
||||
ContourClassification.Perimeter,
|
||||
"Perimeter"
|
||||
)
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -124,8 +131,12 @@ namespace OpenNest.Converters
|
||||
private static ContourClassification ClassifyShape(Shape shape)
|
||||
{
|
||||
// Check etch layer — all entities must be on ETCH layer
|
||||
if (shape.Entities.Count > 0 &&
|
||||
shape.Entities.All(e => string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)))
|
||||
if (
|
||||
shape.Entities.Count > 0
|
||||
&& shape.Entities.All(e =>
|
||||
string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
)
|
||||
return ContourClassification.Etch;
|
||||
|
||||
if (shape.IsClosed())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using OpenNest.CNC;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Converters
|
||||
{
|
||||
@@ -87,14 +87,24 @@ namespace OpenNest.Converters
|
||||
|
||||
lastpt = endpt;
|
||||
|
||||
var layer = ClassifyLayer(arc);
|
||||
var sweep = System.Math.Abs(arc.SweepAngle());
|
||||
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
||||
{
|
||||
pgm.LineTo(endpt);
|
||||
pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
|
||||
}
|
||||
else
|
||||
{
|
||||
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
|
||||
pgm.Codes.Add(
|
||||
new ArcMove(
|
||||
endpt,
|
||||
arc.Center,
|
||||
arc.IsReversed ? RotationType.CW : RotationType.CCW
|
||||
)
|
||||
{
|
||||
Layer = layer,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return lastpt;
|
||||
@@ -107,7 +117,12 @@ namespace OpenNest.Converters
|
||||
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||
pgm.MoveTo(startpt);
|
||||
|
||||
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
|
||||
pgm.Codes.Add(
|
||||
new ArcMove(startpt, circle.Center, circle.Rotation)
|
||||
{
|
||||
Layer = ClassifyLayer(circle),
|
||||
}
|
||||
);
|
||||
|
||||
lastpt = startpt;
|
||||
return lastpt;
|
||||
@@ -118,13 +133,27 @@ namespace OpenNest.Converters
|
||||
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||
pgm.MoveTo(line.StartPoint);
|
||||
|
||||
var move = new LinearMove(line.EndPoint);
|
||||
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
||||
move.Layer = LayerType.Scribe;
|
||||
pgm.Codes.Add(move);
|
||||
pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
|
||||
|
||||
lastpt = line.EndPoint;
|
||||
return lastpt;
|
||||
}
|
||||
|
||||
// Engrave/etch/scribe geometry maps to Scribe so the post processor can treat it as a
|
||||
// separate tool pass; everything else keeps the move's default Cut layer. SCRIBE is the
|
||||
// name marks carry once saved (SpecialLayers.Scribe), so drawings rebuilt from stored
|
||||
// entities must map it too or their marks silently become cut moves.
|
||||
private static LayerType ClassifyLayer(Entity geo)
|
||||
{
|
||||
var name = geo.Layer?.Name;
|
||||
if (
|
||||
string.Equals(name, "ENGRAVE", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(name, "ETCH", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(name, SpecialLayers.Scribe.Name, System.StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
return LayerType.Scribe;
|
||||
|
||||
return LayerType.Cut;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using OpenNest.CNC;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Converters
|
||||
{
|
||||
@@ -18,7 +18,12 @@ namespace OpenNest.Converters
|
||||
return geometry;
|
||||
}
|
||||
|
||||
private static void AddProgram(Program program, ref Mode mode, ref Vector curpos, ref List<Entity> geometry)
|
||||
private static void AddProgram(
|
||||
Program program,
|
||||
ref Mode mode,
|
||||
ref Vector curpos,
|
||||
ref List<Entity> geometry
|
||||
)
|
||||
{
|
||||
// Capture the frame origin at entry. Sub-program Offsets are relative
|
||||
// to this fixed origin, not to the current tool position.
|
||||
@@ -49,7 +54,10 @@ namespace OpenNest.Converters
|
||||
|
||||
// The sub-program's frame origin in this program's frame is
|
||||
// frameOrigin + Offset — independent of current tool position.
|
||||
curpos = new Vector(frameOrigin.X + subpgm.Offset.X, frameOrigin.Y + subpgm.Offset.Y);
|
||||
curpos = new Vector(
|
||||
frameOrigin.X + subpgm.Offset.X,
|
||||
frameOrigin.Y + subpgm.Offset.Y
|
||||
);
|
||||
|
||||
AddProgram(subpgm.Program, ref mode, ref curpos, ref geometry);
|
||||
mode = savedMode;
|
||||
@@ -58,7 +66,12 @@ namespace OpenNest.Converters
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddLinearMove(LinearMove linearMove, ref Mode mode, ref Vector curpos, ref List<Entity> geometry)
|
||||
private static void AddLinearMove(
|
||||
LinearMove linearMove,
|
||||
ref Mode mode,
|
||||
ref Vector curpos,
|
||||
ref List<Entity> geometry
|
||||
)
|
||||
{
|
||||
var pt = linearMove.EndPoint;
|
||||
|
||||
@@ -66,16 +79,17 @@ namespace OpenNest.Converters
|
||||
pt += curpos;
|
||||
|
||||
var layer = ConvertLayer(linearMove.Layer);
|
||||
var line = new Line(curpos, pt)
|
||||
{
|
||||
Layer = layer,
|
||||
Color = layer.Color
|
||||
};
|
||||
var line = new Line(curpos, pt) { Layer = layer, Color = layer.Color };
|
||||
geometry.Add(line);
|
||||
curpos = pt;
|
||||
}
|
||||
|
||||
private static void AddRapidMove(RapidMove rapidMove, ref Mode mode, ref Vector curpos, ref List<Entity> geometry)
|
||||
private static void AddRapidMove(
|
||||
RapidMove rapidMove,
|
||||
ref Mode mode,
|
||||
ref Vector curpos,
|
||||
ref List<Entity> geometry
|
||||
)
|
||||
{
|
||||
var pt = rapidMove.EndPoint;
|
||||
|
||||
@@ -85,13 +99,18 @@ namespace OpenNest.Converters
|
||||
var line = new Line(curpos, pt)
|
||||
{
|
||||
Layer = SpecialLayers.Rapid,
|
||||
Color = SpecialLayers.Rapid.Color
|
||||
Color = SpecialLayers.Rapid.Color,
|
||||
};
|
||||
geometry.Add(line);
|
||||
curpos = pt;
|
||||
}
|
||||
|
||||
private static void AddArcMove(ArcMove arcMove, ref Mode mode, ref Vector curpos, ref List<Entity> geometry)
|
||||
private static void AddArcMove(
|
||||
ArcMove arcMove,
|
||||
ref Mode mode,
|
||||
ref Vector curpos,
|
||||
ref List<Entity> geometry
|
||||
)
|
||||
{
|
||||
var center = arcMove.CenterPoint;
|
||||
var endpt = arcMove.EndPoint;
|
||||
@@ -102,6 +121,8 @@ namespace OpenNest.Converters
|
||||
center += curpos;
|
||||
}
|
||||
|
||||
center = FitCenterToEndpoints(center, curpos, endpt);
|
||||
|
||||
var startAngle = center.AngleTo(curpos);
|
||||
var endAngle = center.AngleTo(endpt);
|
||||
|
||||
@@ -112,13 +133,61 @@ namespace OpenNest.Converters
|
||||
var layer = ConvertLayer(arcMove.Layer);
|
||||
|
||||
if (startAngle.IsEqualTo(endAngle))
|
||||
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color, Rotation = arcMove.Rotation });
|
||||
geometry.Add(
|
||||
new Circle(center, radius)
|
||||
{
|
||||
Layer = layer,
|
||||
Color = layer.Color,
|
||||
Rotation = arcMove.Rotation,
|
||||
}
|
||||
);
|
||||
else
|
||||
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer, Color = layer.Color });
|
||||
geometry.Add(
|
||||
new Arc(
|
||||
center,
|
||||
radius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
arcMove.Rotation == RotationType.CW
|
||||
)
|
||||
{
|
||||
Layer = layer,
|
||||
Color = layer.Color,
|
||||
}
|
||||
);
|
||||
|
||||
curpos = endpt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Programs can carry arc centers that are not quite equidistant from the
|
||||
/// start and end points (e.g. I0.03 on a 0.0598 chord). Building the arc from
|
||||
/// the end radius alone then leaves its start point off the previous move's
|
||||
/// end, which breaks contour chaining. Project the center onto the chord's
|
||||
/// perpendicular bisector so the arc passes through both endpoints exactly.
|
||||
/// </summary>
|
||||
private static Vector FitCenterToEndpoints(Vector center, Vector start, Vector end)
|
||||
{
|
||||
var startRadius = center.DistanceTo(start);
|
||||
var endRadius = center.DistanceTo(end);
|
||||
|
||||
if (startRadius.IsEqualTo(endRadius))
|
||||
return center;
|
||||
|
||||
var chord = end - start;
|
||||
var chordLengthSq = chord.X * chord.X + chord.Y * chord.Y;
|
||||
|
||||
// Full circle (start == end): no chord to fit against.
|
||||
if (chordLengthSq < Tolerance.Epsilon * Tolerance.Epsilon)
|
||||
return center;
|
||||
|
||||
var mid = new Vector((start.X + end.X) * 0.5, (start.Y + end.Y) * 0.5);
|
||||
var normal = new Vector(-chord.Y, chord.X);
|
||||
var t = ((center.X - mid.X) * normal.X + (center.Y - mid.Y) * normal.Y) / chordLengthSq;
|
||||
|
||||
return new Vector(mid.X + normal.X * t, mid.Y + normal.Y * t);
|
||||
}
|
||||
|
||||
private static Layer ConvertLayer(LayerType layer)
|
||||
{
|
||||
switch (layer)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Converters
|
||||
{
|
||||
/// <summary>
|
||||
/// Restores the Scribe layer on program moves that were saved as cuts. Programs built from
|
||||
/// stored entities before ConvertGeometry recognized the SCRIBE layer name turned etch marks
|
||||
/// into Cut moves, which nesting then treated as open cut geometry. The drawing's source
|
||||
/// entities still carry the mark layer, so matching moves are reclassified from them.
|
||||
/// </summary>
|
||||
public static class ScribeLayerRepair
|
||||
{
|
||||
private const double MatchTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// Reclassifies Cut moves in <paramref name="program"/> that lie on a mark entity in
|
||||
/// <paramref name="sourceEntities"/>. Program coordinates are source coordinates shifted
|
||||
/// by -<paramref name="sourceOffset"/>. Returns the number of moves reclassified.
|
||||
/// </summary>
|
||||
public static int Apply(Program program, IEnumerable<Entity> sourceEntities, Vector sourceOffset)
|
||||
{
|
||||
if (program == null || sourceEntities == null)
|
||||
return 0;
|
||||
|
||||
var marks = sourceEntities.Where(e => IsMarkLayer(e.Layer)).ToList();
|
||||
if (marks.Count == 0 || program.Codes.Any(c => c is SubProgramCall))
|
||||
return 0;
|
||||
|
||||
// ToGeometry emits exactly one entity per rapid/linear/arc move of a flat program.
|
||||
var motions = program.Codes.Where(c => c is RapidMove or LinearMove or ArcMove).ToList();
|
||||
var geometry = ConvertProgram.ToGeometry(program);
|
||||
if (geometry.Count != motions.Count)
|
||||
return 0;
|
||||
|
||||
var repaired = 0;
|
||||
for (var i = 0; i < motions.Count; i++)
|
||||
{
|
||||
var source = Translate(geometry[i], sourceOffset);
|
||||
switch (motions[i])
|
||||
{
|
||||
case LinearMove line when line.Layer == LayerType.Cut && IsOnMark(source, marks):
|
||||
line.Layer = LayerType.Scribe;
|
||||
repaired++;
|
||||
break;
|
||||
case ArcMove arc when arc.Layer == LayerType.Cut && IsOnMark(source, marks):
|
||||
arc.Layer = LayerType.Scribe;
|
||||
repaired++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return repaired;
|
||||
}
|
||||
|
||||
public static bool IsMarkLayer(Layer layer) =>
|
||||
layer != null
|
||||
&& (
|
||||
layer == SpecialLayers.Scribe
|
||||
|| string.Equals(layer.Name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(layer.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(layer.Name, "ENGRAVE", StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
|
||||
private static List<Vector> Translate(Entity entity, Vector offset)
|
||||
{
|
||||
var points = entity switch
|
||||
{
|
||||
Line l => new List<Vector>
|
||||
{
|
||||
l.StartPoint,
|
||||
l.EndPoint,
|
||||
new Vector((l.StartPoint.X + l.EndPoint.X) / 2, (l.StartPoint.Y + l.EndPoint.Y) / 2),
|
||||
},
|
||||
Arc a => new List<Vector> { a.StartPoint(), a.EndPoint(), a.MidPoint() },
|
||||
Circle c => new List<Vector>
|
||||
{
|
||||
new Vector(c.Center.X + c.Radius, c.Center.Y),
|
||||
new Vector(c.Center.X - c.Radius, c.Center.Y),
|
||||
new Vector(c.Center.X, c.Center.Y + c.Radius),
|
||||
},
|
||||
_ => new List<Vector>(),
|
||||
};
|
||||
return points.ConvertAll(p => new Vector(p.X + offset.X, p.Y + offset.Y));
|
||||
}
|
||||
|
||||
// A move is a mark when every sample point lies on one single mark entity.
|
||||
private static bool IsOnMark(List<Vector> points, List<Entity> marks) =>
|
||||
points.Count > 0
|
||||
&& marks.Any(m => points.All(p => m.ClosestPointTo(p).DistanceTo(p) <= MatchTolerance));
|
||||
}
|
||||
}
|
||||
+92
-26
@@ -1,18 +1,20 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum CutOffAxis
|
||||
{
|
||||
Horizontal,
|
||||
Vertical
|
||||
Vertical,
|
||||
}
|
||||
|
||||
public class CutOff
|
||||
{
|
||||
private const double OffsetTolerance = 0.001;
|
||||
|
||||
public Vector Position { get; set; }
|
||||
public CutOffAxis Axis { get; set; }
|
||||
public double? StartLimit { get; set; }
|
||||
@@ -26,7 +28,11 @@ namespace OpenNest
|
||||
Drawing = new Drawing(GetName()) { IsCutOff = true };
|
||||
}
|
||||
|
||||
public void Regenerate(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache = null)
|
||||
public void Regenerate(
|
||||
Plate plate,
|
||||
CutOffSettings settings,
|
||||
Dictionary<Part, Entity> cache = null
|
||||
)
|
||||
{
|
||||
var segments = ComputeSegments(plate, settings, cache);
|
||||
var program = BuildProgram(segments, settings);
|
||||
@@ -40,11 +46,17 @@ namespace OpenNest
|
||||
return $"CutOff-{axisChar}-{coord:F2}";
|
||||
}
|
||||
|
||||
private List<(double Start, double End)> ComputeSegments(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache)
|
||||
private List<(double Start, double End)> ComputeSegments(
|
||||
Plate plate,
|
||||
CutOffSettings settings,
|
||||
Dictionary<Part, Entity> cache
|
||||
)
|
||||
{
|
||||
var bounds = plate.BoundingBox(includeParts: false);
|
||||
|
||||
double lineStart, lineEnd, cutPosition;
|
||||
double lineStart,
|
||||
lineEnd,
|
||||
cutPosition;
|
||||
|
||||
if (Axis == CutOffAxis.Vertical)
|
||||
{
|
||||
@@ -68,7 +80,14 @@ namespace OpenNest
|
||||
|
||||
Entity perimeter = null;
|
||||
cache?.TryGetValue(part, out perimeter);
|
||||
var partExclusions = GetPartExclusions(part, perimeter, cutPosition, lineStart, lineEnd, settings.PartClearance);
|
||||
var partExclusions = GetPartExclusions(
|
||||
part,
|
||||
perimeter,
|
||||
cutPosition,
|
||||
lineStart,
|
||||
lineEnd,
|
||||
settings.PartClearance
|
||||
);
|
||||
exclusions.AddRange(partExclusions);
|
||||
}
|
||||
|
||||
@@ -107,7 +126,13 @@ namespace OpenNest
|
||||
private static readonly List<(double Start, double End)> EmptyExclusions = new();
|
||||
|
||||
private List<(double Start, double End)> GetPartExclusions(
|
||||
Part part, Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance)
|
||||
Part part,
|
||||
Entity perimeter,
|
||||
double cutPosition,
|
||||
double lineStart,
|
||||
double lineEnd,
|
||||
double clearance
|
||||
)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
var (partMin, partMax) = AxisBounds(bb, clearance);
|
||||
@@ -118,7 +143,13 @@ namespace OpenNest
|
||||
|
||||
if (perimeter != null)
|
||||
{
|
||||
var perimeterExclusions = IntersectPerimeter(perimeter, cutPosition, lineStart, lineEnd, clearance);
|
||||
var perimeterExclusions = IntersectPerimeter(
|
||||
perimeter,
|
||||
cutPosition,
|
||||
lineStart,
|
||||
lineEnd,
|
||||
clearance
|
||||
);
|
||||
if (perimeterExclusions != null)
|
||||
return perimeterExclusions;
|
||||
}
|
||||
@@ -127,17 +158,33 @@ namespace OpenNest
|
||||
}
|
||||
|
||||
private List<(double Start, double End)> IntersectPerimeter(
|
||||
Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance)
|
||||
Entity perimeter,
|
||||
double cutPosition,
|
||||
double lineStart,
|
||||
double lineEnd,
|
||||
double clearance
|
||||
)
|
||||
{
|
||||
var target = OffsetOutward(perimeter, clearance) ?? perimeter;
|
||||
var usedOffset = target != perimeter;
|
||||
var cutLine = new Line(MakePoint(cutPosition, lineStart), MakePoint(cutPosition, lineEnd));
|
||||
var offset = OffsetOutward(perimeter, clearance);
|
||||
var usedOffset = offset != null;
|
||||
var targets = offset ?? new List<Entity> { perimeter };
|
||||
var cutLine = new Line(
|
||||
MakePoint(cutPosition, lineStart),
|
||||
MakePoint(cutPosition, lineEnd)
|
||||
);
|
||||
|
||||
if (!target.Intersects(cutLine, out var pts) || pts.Count < 2)
|
||||
var pts = new List<Vector>();
|
||||
|
||||
foreach (var target in targets)
|
||||
{
|
||||
if (target.Intersects(cutLine, out var targetPts))
|
||||
pts.AddRange(targetPts);
|
||||
}
|
||||
|
||||
if (pts.Count < 2)
|
||||
return null;
|
||||
|
||||
var coords = pts
|
||||
.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
|
||||
var coords = pts.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
|
||||
.OrderBy(c => c)
|
||||
.ToList();
|
||||
|
||||
@@ -152,21 +199,37 @@ namespace OpenNest
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Entity OffsetOutward(Entity perimeter, double clearance)
|
||||
/// <summary>
|
||||
/// Grows the perimeter by the clearance as one Clipper region offset, so slots
|
||||
/// narrower than twice the clearance close up instead of leaving a gap the cut
|
||||
/// could run into. Holes appear only where the perimeter curls back on itself.
|
||||
/// </summary>
|
||||
private static List<Entity> OffsetOutward(Entity perimeter, double clearance)
|
||||
{
|
||||
if (clearance <= 0)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var offset = perimeter.OffsetEntity(clearance, OffsetSide.Left);
|
||||
offset?.UpdateBounds();
|
||||
return offset;
|
||||
}
|
||||
catch
|
||||
var offset = perimeter switch
|
||||
{
|
||||
Shape shape => ClipperBridge.OffsetPerimeter(
|
||||
shape,
|
||||
clearance,
|
||||
OffsetTolerance,
|
||||
circumscribe: true
|
||||
),
|
||||
Polygon polygon => ClipperBridge.OffsetPerimeter(
|
||||
polygon,
|
||||
clearance,
|
||||
OffsetTolerance,
|
||||
circumscribe: true
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (offset == null || offset.Outers.Count == 0)
|
||||
return null;
|
||||
}
|
||||
|
||||
return offset.Outers.Concat(offset.Holes).Cast<Entity>().ToList();
|
||||
}
|
||||
|
||||
private Vector MakePoint(double cutCoord, double lineCoord) =>
|
||||
@@ -184,7 +247,10 @@ namespace OpenNest
|
||||
? (bb.Y - clearance, bb.Y + bb.Width + clearance)
|
||||
: (bb.X - clearance, bb.X + bb.Length + clearance);
|
||||
|
||||
private Program BuildProgram(List<(double Start, double End)> segments, CutOffSettings settings)
|
||||
private Program BuildProgram(
|
||||
List<(double Start, double End)> segments,
|
||||
CutOffSettings settings
|
||||
)
|
||||
{
|
||||
var program = new Program();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace OpenNest
|
||||
public enum CutDirection
|
||||
{
|
||||
TowardOrigin,
|
||||
AwayFromOrigin
|
||||
AwayFromOrigin,
|
||||
}
|
||||
|
||||
public class CutOffSettings
|
||||
|
||||
@@ -11,11 +11,12 @@ public class CutParameters
|
||||
public string PostProcessor { get; set; }
|
||||
public Units Units { get; set; }
|
||||
|
||||
public static CutParameters Default => new()
|
||||
{
|
||||
Feedrate = 100,
|
||||
RapidTravelRate = 300,
|
||||
PierceTime = TimeSpan.FromSeconds(0.5),
|
||||
Units = OpenNest.Units.Inches
|
||||
};
|
||||
public static CutParameters Default =>
|
||||
new()
|
||||
{
|
||||
Feedrate = 100,
|
||||
RapidTravelRate = 300,
|
||||
PierceTime = TimeSpan.FromSeconds(0.5),
|
||||
Units = OpenNest.Units.Inches,
|
||||
};
|
||||
}
|
||||
|
||||
+22
-24
@@ -1,12 +1,12 @@
|
||||
using OpenNest.Bending;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Bending;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
@@ -18,18 +18,18 @@ namespace OpenNest
|
||||
|
||||
public static Color[] PartColors = new Color[]
|
||||
{
|
||||
Color.FromArgb(205, 92, 92), // Indian Red
|
||||
Color.FromArgb(148, 103, 189), // Medium Purple
|
||||
Color.FromArgb(75, 180, 175), // Teal
|
||||
Color.FromArgb(210, 190, 75), // Goldenrod
|
||||
Color.FromArgb(190, 85, 175), // Orchid
|
||||
Color.FromArgb(185, 115, 85), // Sienna
|
||||
Color.FromArgb(120, 100, 190), // Slate Blue
|
||||
Color.FromArgb(200, 100, 140), // Rose
|
||||
Color.FromArgb(80, 175, 155), // Sea Green
|
||||
Color.FromArgb(195, 160, 85), // Dark Khaki
|
||||
Color.FromArgb(175, 95, 160), // Plum
|
||||
Color.FromArgb(215, 130, 130), // Light Coral
|
||||
Color.FromArgb(205, 92, 92), // Indian Red
|
||||
Color.FromArgb(148, 103, 189), // Medium Purple
|
||||
Color.FromArgb(75, 180, 175), // Teal
|
||||
Color.FromArgb(210, 190, 75), // Goldenrod
|
||||
Color.FromArgb(190, 85, 175), // Orchid
|
||||
Color.FromArgb(185, 115, 85), // Sienna
|
||||
Color.FromArgb(120, 100, 190), // Slate Blue
|
||||
Color.FromArgb(200, 100, 140), // Rose
|
||||
Color.FromArgb(80, 175, 155), // Sea Green
|
||||
Color.FromArgb(195, 160, 85), // Dark Khaki
|
||||
Color.FromArgb(175, 95, 160), // Plum
|
||||
Color.FromArgb(215, 130, 130), // Light Coral
|
||||
};
|
||||
|
||||
public static Color GetNextColor()
|
||||
@@ -40,14 +40,10 @@ namespace OpenNest
|
||||
}
|
||||
|
||||
public Drawing()
|
||||
: this(string.Empty, new Program())
|
||||
{
|
||||
}
|
||||
: this(string.Empty, new Program()) { }
|
||||
|
||||
public Drawing(string name)
|
||||
: this(name, new Program())
|
||||
{
|
||||
}
|
||||
: this(name, new Program()) { }
|
||||
|
||||
public Drawing(string name, Program pgm)
|
||||
{
|
||||
@@ -127,7 +123,9 @@ namespace OpenNest
|
||||
|
||||
public void UpdateArea()
|
||||
{
|
||||
var geometry = ConvertProgram.ToGeometry(Program).Where(entity => entity.Layer != SpecialLayers.Rapid);
|
||||
var geometry = ConvertProgram
|
||||
.ToGeometry(Program)
|
||||
.Where(entity => SpecialLayers.IsMaterial(entity.Layer));
|
||||
var shapes = ShapeBuilder.GetShapes(geometry);
|
||||
|
||||
if (shapes.Count == 0)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -12,16 +12,18 @@ namespace OpenNest.Geometry
|
||||
private Vector center;
|
||||
private bool reversed;
|
||||
|
||||
public Arc()
|
||||
{
|
||||
}
|
||||
public Arc() { }
|
||||
|
||||
public Arc(double x, double y, double r, double a1, double a2, bool reversed = false)
|
||||
: this(new Vector(x, y), r, a1, a2, reversed)
|
||||
{
|
||||
}
|
||||
: this(new Vector(x, y), r, a1, a2, reversed) { }
|
||||
|
||||
public Arc(Vector center, double radius, double startAngle, double endAngle, bool reversed = false)
|
||||
public Arc(
|
||||
Vector center,
|
||||
double radius,
|
||||
double startAngle,
|
||||
double endAngle,
|
||||
bool reversed = false
|
||||
)
|
||||
{
|
||||
this.center = center;
|
||||
this.radius = radius;
|
||||
@@ -93,8 +95,7 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsFullCircle() =>
|
||||
SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
|
||||
public bool IsFullCircle() => SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
|
||||
|
||||
/// <summary>
|
||||
/// Angle in radians between start and end angles.
|
||||
@@ -130,10 +131,7 @@ namespace OpenNest.Geometry
|
||||
public RotationType Rotation
|
||||
{
|
||||
get { return IsReversed ? RotationType.CW : RotationType.CCW; }
|
||||
set
|
||||
{
|
||||
IsReversed = (value == RotationType.CW);
|
||||
}
|
||||
set { IsReversed = (value == RotationType.CW); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -144,7 +142,8 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
return new Vector(
|
||||
Center.X + Radius * System.Math.Cos(StartAngle),
|
||||
Center.Y + Radius * System.Math.Sin(StartAngle));
|
||||
Center.Y + Radius * System.Math.Sin(StartAngle)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -155,7 +154,8 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
return new Vector(
|
||||
Center.X + Radius * System.Math.Cos(EndAngle),
|
||||
Center.Y + Radius * System.Math.Sin(EndAngle));
|
||||
Center.Y + Radius * System.Math.Sin(EndAngle)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -166,7 +166,8 @@ namespace OpenNest.Geometry
|
||||
var midAngle = StartAngle + (IsReversed ? -SweepAngle() / 2 : SweepAngle() / 2);
|
||||
return new Vector(
|
||||
Center.X + Radius * System.Math.Cos(midAngle),
|
||||
Center.Y + Radius * System.Math.Sin(midAngle));
|
||||
Center.Y + Radius * System.Math.Sin(midAngle)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -231,7 +232,10 @@ namespace OpenNest.Geometry
|
||||
return 1;
|
||||
|
||||
var maxAngle = 2.0 * System.Math.Acos(1.0 - tolerance / Radius);
|
||||
return System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Abs(SweepAngle()) / maxAngle));
|
||||
return System.Math.Max(
|
||||
1,
|
||||
(int)System.Math.Ceiling(System.Math.Abs(SweepAngle()) / maxAngle)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -242,21 +246,23 @@ namespace OpenNest.Geometry
|
||||
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
var stepAngle = reversed
|
||||
? -SweepAngle() / segments
|
||||
: SweepAngle() / segments;
|
||||
var stepAngle = reversed ? -SweepAngle() / segments : SweepAngle() / segments;
|
||||
|
||||
var r = circumscribe && segments > 0
|
||||
? Radius / System.Math.Cos(System.Math.Abs(stepAngle) / 2.0)
|
||||
: Radius;
|
||||
var r =
|
||||
circumscribe && segments > 0
|
||||
? Radius / System.Math.Cos(System.Math.Abs(stepAngle) / 2.0)
|
||||
: Radius;
|
||||
|
||||
for (int i = 0; i <= segments; ++i)
|
||||
{
|
||||
var angle = stepAngle * i + StartAngle;
|
||||
|
||||
points.Add(new Vector(
|
||||
System.Math.Cos(angle) * r + Center.X,
|
||||
System.Math.Sin(angle) * r + Center.Y));
|
||||
points.Add(
|
||||
new Vector(
|
||||
System.Math.Cos(angle) * r + Center.X,
|
||||
System.Math.Sin(angle) * r + Center.Y
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return points;
|
||||
@@ -437,19 +443,23 @@ namespace OpenNest.Geometry
|
||||
boundingBox.Width = maxY - minY;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offsets the arc to the given side of its travel direction. The center lies to
|
||||
/// the left of a CCW arc and to the right of a CW (reversed) one, so the arc grows
|
||||
/// on the other side and shrinks toward its center. Returns null when it shrinks
|
||||
/// to nothing.
|
||||
/// </summary>
|
||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||
{
|
||||
if (side == OffsetSide.Left && reversed)
|
||||
{
|
||||
return new Arc(center, radius + distance, startAngle, endAngle, reversed);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (distance >= radius)
|
||||
return null;
|
||||
var grows = (side == OffsetSide.Left) == reversed;
|
||||
|
||||
return new Arc(center, radius - distance, startAngle, endAngle, reversed);
|
||||
}
|
||||
if (grows)
|
||||
return new Arc(center, radius + distance, startAngle, endAngle, reversed);
|
||||
|
||||
if (distance >= radius)
|
||||
return null;
|
||||
|
||||
return new Arc(center, radius - distance, startAngle, endAngle, reversed);
|
||||
}
|
||||
|
||||
public override Entity OffsetEntity(double distance, Vector pt)
|
||||
@@ -470,7 +480,8 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
return new Vector(
|
||||
System.Math.Cos(angle) * Radius + Center.X,
|
||||
System.Math.Sin(angle) * Radius + Center.Y);
|
||||
System.Math.Sin(angle) * Radius + Center.Y
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -500,7 +511,8 @@ namespace OpenNest.Geometry
|
||||
/// <returns></returns>
|
||||
public override bool Intersects(Arc arc, out List<Vector> pts)
|
||||
{
|
||||
return Intersect.Intersects(this, arc, out pts); ;
|
||||
return Intersect.Intersects(this, arc, out pts);
|
||||
;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -14,7 +14,9 @@ namespace OpenNest.Geometry
|
||||
/// the arc passes through both endpoints and departs P1 in the given direction.
|
||||
/// </summary>
|
||||
internal static (Vector center, double radius, double deviation) FitWithStartTangent(
|
||||
List<Vector> points, Vector tangent)
|
||||
List<Vector> points,
|
||||
Vector tangent
|
||||
)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
@@ -57,14 +59,22 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits a circular arc constrained to be tangent to the given directions at both
|
||||
/// the first and last points. The center lies at the intersection of the normals
|
||||
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives
|
||||
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent);
|
||||
/// deviation includes any endpoint gap at Pn.
|
||||
/// Fits a circular arc that passes exactly through both the first and last points
|
||||
/// while matching the given endpoint tangents as closely as possible. For any
|
||||
/// circle through two points, the tangents at those points make equal mirrored
|
||||
/// angles with the chord, so the achievable inscribed angle is the average of the
|
||||
/// two requested ones — when the requested tangents are consistent with a single
|
||||
/// circular arc, both are matched exactly.
|
||||
/// </summary>
|
||||
internal static (Vector center, double radius, double deviation) FitWithDualTangent(
|
||||
List<Vector> points, Vector startTangent, Vector endTangent)
|
||||
internal static (
|
||||
Vector center,
|
||||
double radius,
|
||||
double deviation
|
||||
) FitThroughEndpointsWithTangents(
|
||||
List<Vector> points,
|
||||
Vector startTangent,
|
||||
Vector endTangent
|
||||
)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
@@ -72,48 +82,51 @@ namespace OpenNest.Geometry
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
|
||||
var stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y);
|
||||
var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y);
|
||||
if (stLen < 1e-10 || etLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Normal to start tangent at P1 (perpendicular)
|
||||
var n1x = -startTangent.Y / stLen;
|
||||
var n1y = startTangent.X / stLen;
|
||||
|
||||
// Normal to end tangent at Pn
|
||||
var n2x = -endTangent.Y / etLen;
|
||||
var n2y = endTangent.X / etLen;
|
||||
|
||||
// Solve: P1 + t1*N1 = Pn + t2*N2
|
||||
var det = n1x * (-n2y) - (-n2x) * n1y;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var t1 = (dx * (-n2y) - (-n2x) * dy) / det;
|
||||
|
||||
var cx = p1.X + t1 * n1x;
|
||||
var cy = p1.Y + t1 * n1y;
|
||||
|
||||
// Use radius from P1 (guarantees exact start tangent and passes through P1)
|
||||
var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
||||
if (r1 < 1e-10)
|
||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (chordLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Measure endpoint gap at Pn
|
||||
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y));
|
||||
var endpointDev = System.Math.Abs(r2 - r1);
|
||||
var ux = dx / chordLen;
|
||||
var uy = dy / chordLen;
|
||||
|
||||
var interiorDev = MaxRadialDeviation(points, cx, cy, r1);
|
||||
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
|
||||
// Inscribed angle between chord and tangent at each endpoint (mirrored at Pn)
|
||||
var theta1 = SignedAngle(ux, uy, startTangent);
|
||||
var theta2 = -SignedAngle(ux, uy, endTangent);
|
||||
var theta = (theta1 + theta2) / 2;
|
||||
|
||||
// Nearly straight or degenerate (sweep would exceed ~356 degrees)
|
||||
if (System.Math.Abs(theta) < 1e-3 || System.Math.Abs(theta) > System.Math.PI * 0.99)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var halfChord = chordLen / 2;
|
||||
var radius = halfChord / System.Math.Abs(System.Math.Sin(theta));
|
||||
var d = -halfChord / System.Math.Tan(theta);
|
||||
|
||||
var cx = (p1.X + pn.X) / 2 + d * -uy;
|
||||
var cy = (p1.Y + pn.Y) / 2 + d * ux;
|
||||
|
||||
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
||||
}
|
||||
|
||||
private static double SignedAngle(double ux, double uy, Vector to)
|
||||
{
|
||||
var len = System.Math.Sqrt(to.X * to.X + to.Y * to.Y);
|
||||
if (len < 1e-10)
|
||||
return 0;
|
||||
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the maximum radial deviation of interior points from a circle.
|
||||
/// </summary>
|
||||
internal static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius)
|
||||
internal static double MaxRadialDeviation(
|
||||
List<Vector> points,
|
||||
double cx,
|
||||
double cy,
|
||||
double radius
|
||||
)
|
||||
{
|
||||
var maxDev = 0.0;
|
||||
for (var i = 1; i < points.Count - 1; i++)
|
||||
@@ -122,7 +135,8 @@ namespace OpenNest.Geometry
|
||||
var py = points[i].Y - cy;
|
||||
var dist = System.Math.Sqrt(px * px + py * py);
|
||||
var dev = System.Math.Abs(dist - radius);
|
||||
if (dev > maxDev) maxDev = dev;
|
||||
if (dev > maxDev)
|
||||
maxDev = dev;
|
||||
}
|
||||
return maxDev;
|
||||
}
|
||||
|
||||
@@ -17,10 +17,14 @@ namespace OpenNest.Geometry
|
||||
|
||||
foreach (var box in boxes)
|
||||
{
|
||||
if (box.Left < minX) minX = box.Left;
|
||||
if (box.Right > maxX) maxX = box.Right;
|
||||
if (box.Bottom < minY) minY = box.Bottom;
|
||||
if (box.Top > maxY) maxY = box.Top;
|
||||
if (box.Left < minX)
|
||||
minX = box.Left;
|
||||
if (box.Right > maxX)
|
||||
maxX = box.Right;
|
||||
if (box.Bottom < minY)
|
||||
minY = box.Bottom;
|
||||
if (box.Top > maxY)
|
||||
maxY = box.Top;
|
||||
}
|
||||
|
||||
return new Box(minX, minY, maxX - minX, maxY - minY);
|
||||
@@ -41,11 +45,15 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
var vertex = pts[i];
|
||||
|
||||
if (vertex.X < minX) minX = vertex.X;
|
||||
else if (vertex.X > maxX) maxX = vertex.X;
|
||||
if (vertex.X < minX)
|
||||
minX = vertex.X;
|
||||
else if (vertex.X > maxX)
|
||||
maxX = vertex.X;
|
||||
|
||||
if (vertex.Y < minY) minY = vertex.Y;
|
||||
else if (vertex.Y > maxY) maxY = vertex.Y;
|
||||
if (vertex.Y < minY)
|
||||
minY = vertex.Y;
|
||||
else if (vertex.Y > maxY)
|
||||
maxY = vertex.Y;
|
||||
}
|
||||
|
||||
return new Box(minX, minY, maxX - minX, maxY - minY);
|
||||
@@ -65,10 +73,14 @@ namespace OpenNest.Geometry
|
||||
|
||||
foreach (var box in items)
|
||||
{
|
||||
if (box.Left < left) left = box.Left;
|
||||
if (box.Right > right) right = box.Right;
|
||||
if (box.Bottom < bottom) bottom = box.Bottom;
|
||||
if (box.Top > top) top = box.Top;
|
||||
if (box.Left < left)
|
||||
left = box.Left;
|
||||
if (box.Right > right)
|
||||
right = box.Right;
|
||||
if (box.Bottom < bottom)
|
||||
bottom = box.Bottom;
|
||||
if (box.Top > top)
|
||||
top = box.Top;
|
||||
}
|
||||
|
||||
return new Box(left, bottom, right - left, top - bottom);
|
||||
|
||||
@@ -8,9 +8,7 @@ namespace OpenNest.Geometry
|
||||
public static readonly Box Empty = new Box();
|
||||
|
||||
public Box()
|
||||
: this(0, 0, 0, 0)
|
||||
{
|
||||
}
|
||||
: this(0, 0, 0, 0) { }
|
||||
|
||||
public Box(double x, double y, double w, double h)
|
||||
{
|
||||
@@ -117,10 +115,14 @@ namespace OpenNest.Geometry
|
||||
|
||||
public bool Intersects(Box box)
|
||||
{
|
||||
if (Left >= box.Right) return false;
|
||||
if (Right <= box.Left) return false;
|
||||
if (Top <= box.Bottom) return false;
|
||||
if (Bottom >= box.Top) return false;
|
||||
if (Left >= box.Right)
|
||||
return false;
|
||||
if (Right <= box.Left)
|
||||
return false;
|
||||
if (Top <= box.Bottom)
|
||||
return false;
|
||||
if (Bottom >= box.Top)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -146,18 +148,24 @@ namespace OpenNest.Geometry
|
||||
|
||||
public bool Contains(Box box)
|
||||
{
|
||||
if (box.Top > Top) return false;
|
||||
if (box.Left < Left) return false;
|
||||
if (box.Right > Right) return false;
|
||||
if (box.Bottom < Bottom) return false;
|
||||
if (box.Top > Top)
|
||||
return false;
|
||||
if (box.Left < Left)
|
||||
return false;
|
||||
if (box.Right > Right)
|
||||
return false;
|
||||
if (box.Bottom < Bottom)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Contains(Vector pt)
|
||||
{
|
||||
return pt.X >= Left - Tolerance.Epsilon && pt.X <= Right + Tolerance.Epsilon
|
||||
&& pt.Y >= Bottom - Tolerance.Epsilon && pt.Y <= Top + Tolerance.Epsilon;
|
||||
return pt.X >= Left - Tolerance.Epsilon
|
||||
&& pt.X <= Right + Tolerance.Epsilon
|
||||
&& pt.Y >= Bottom - Tolerance.Epsilon
|
||||
&& pt.Y <= Top + Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
public bool IsHorizontalTo(Box box)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -8,14 +8,10 @@ namespace OpenNest.Geometry
|
||||
private Vector center;
|
||||
private double radius;
|
||||
|
||||
public Circle()
|
||||
{
|
||||
}
|
||||
public Circle() { }
|
||||
|
||||
public Circle(double x, double y, double radius)
|
||||
: this(new Vector(x, y), radius)
|
||||
{
|
||||
}
|
||||
: this(new Vector(x, y), radius) { }
|
||||
|
||||
public Circle(Vector center, double radius)
|
||||
{
|
||||
@@ -137,21 +133,22 @@ namespace OpenNest.Geometry
|
||||
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
var stepAngle = Rotation == RotationType.CW
|
||||
? -Angle.TwoPI / segments
|
||||
: Angle.TwoPI / segments;
|
||||
var stepAngle =
|
||||
Rotation == RotationType.CW ? -Angle.TwoPI / segments : Angle.TwoPI / segments;
|
||||
|
||||
var r = circumscribe && segments > 0
|
||||
? Radius / System.Math.Cos(stepAngle / 2.0)
|
||||
: Radius;
|
||||
var r =
|
||||
circumscribe && segments > 0 ? Radius / System.Math.Cos(stepAngle / 2.0) : Radius;
|
||||
|
||||
for (int i = 0; i <= segments; ++i)
|
||||
{
|
||||
var angle = stepAngle * i;
|
||||
|
||||
points.Add(new Vector(
|
||||
System.Math.Cos(angle) * r + Center.X,
|
||||
System.Math.Sin(angle) * r + Center.Y));
|
||||
points.Add(
|
||||
new Vector(
|
||||
System.Math.Cos(angle) * r + Center.X,
|
||||
System.Math.Sin(angle) * r + Center.Y
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return points;
|
||||
@@ -276,17 +273,18 @@ namespace OpenNest.Geometry
|
||||
|
||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||
{
|
||||
if (side == OffsetSide.Left && Rotation == RotationType.CCW)
|
||||
// The center lies to the left of a CCW circle and to the right of a CW one.
|
||||
var shrinks = (side == OffsetSide.Left) == (Rotation == RotationType.CCW);
|
||||
|
||||
if (shrinks)
|
||||
{
|
||||
return Radius <= distance ? null : new Circle(center, Radius - distance)
|
||||
{
|
||||
Layer = Layer,
|
||||
Rotation = Rotation
|
||||
};
|
||||
return Radius <= distance
|
||||
? null
|
||||
: new Circle(center, Radius - distance) { Layer = Layer, Rotation = Rotation };
|
||||
}
|
||||
else
|
||||
{
|
||||
return new Circle(center, Radius + distance) { Layer = Layer };
|
||||
return new Circle(center, Radius + distance) { Layer = Layer, Rotation = Rotation };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,11 +292,9 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
if (ContainsPoint(pt))
|
||||
{
|
||||
return Radius <= distance ? null : new Circle(center, Radius - distance)
|
||||
{
|
||||
Layer = Layer,
|
||||
Rotation = Rotation
|
||||
};
|
||||
return Radius <= distance
|
||||
? null
|
||||
: new Circle(center, Radius - distance) { Layer = Layer, Rotation = Rotation };
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -317,7 +313,8 @@ namespace OpenNest.Geometry
|
||||
|
||||
return new Vector(
|
||||
System.Math.Cos(angle) * Radius + Center.X,
|
||||
System.Math.Sin(angle) * Radius + Center.Y);
|
||||
System.Math.Sin(angle) * Radius + Center.Y
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -350,7 +347,9 @@ namespace OpenNest.Geometry
|
||||
public override bool Intersects(Circle circle)
|
||||
{
|
||||
var dist = Center.DistanceTo(circle.Center);
|
||||
return (dist < (Radius + circle.Radius) && dist > System.Math.Abs(Radius - circle.Radius));
|
||||
return (
|
||||
dist < (Radius + circle.Radius) && dist > System.Math.Abs(Radius - circle.Radius)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
using System.Collections.Generic;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Region offsetting through Clipper2, for CPU-side preparation only: work done
|
||||
/// once per drawing, rotation or spacing whose output is cached and fed to hot
|
||||
/// loops. Per-pair tests (<see cref="Collision"/>) stay hand-rolled so they can
|
||||
/// be ported to a GPU kernel.
|
||||
/// </summary>
|
||||
public static class ClipperBridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Decimal places Clipper keeps (1e-4 in either inches or mm).
|
||||
/// </summary>
|
||||
public const int Precision = 4;
|
||||
|
||||
private const double MiterLimit = 2.0;
|
||||
|
||||
private const double ConservativeJoinFactor = 0.25;
|
||||
|
||||
private const double ValidationJoinFactor = 0.1;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a polygon to a Clipper path, dropping the closing vertex and
|
||||
/// orienting it positive (CCW) or negative (CW).
|
||||
/// </summary>
|
||||
public static PathD ToPath(Polygon polygon, bool positive)
|
||||
{
|
||||
var path = ToPath(polygon, new Vector());
|
||||
|
||||
if (path.Count >= 3 && Clipper.IsPositive(path) != positive)
|
||||
path.Reverse();
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a polygon to a Clipper path with an optional offset, dropping the
|
||||
/// closing vertex and keeping the polygon's own winding.
|
||||
/// </summary>
|
||||
public static PathD ToPath(Polygon polygon, Vector offset)
|
||||
{
|
||||
var verts = polygon.Vertices;
|
||||
var n = verts.Count;
|
||||
|
||||
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
|
||||
n--;
|
||||
|
||||
var path = new PathD(n);
|
||||
|
||||
for (var i = 0; i < n; i++)
|
||||
path.Add(new PointD(verts[i].X + offset.X, verts[i].Y + offset.Y));
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Clipper path to a closed polygon with updated bounds.
|
||||
/// </summary>
|
||||
public static Polygon ToPolygon(PathD path)
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
|
||||
foreach (var pt in path)
|
||||
polygon.Vertices.Add(new Vector(pt.x, pt.y));
|
||||
|
||||
polygon.Close();
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flattens a profile into a Clipper region: perimeter positive, cutouts negative.
|
||||
/// </summary>
|
||||
public static PathsD ToRegion(ShapeProfile profile, double tolerance, bool circumscribe)
|
||||
{
|
||||
var region = new PathsD(profile.Cutouts.Count + 1);
|
||||
AddShape(region, profile.Perimeter, tolerance, circumscribe, positive: true);
|
||||
|
||||
// A cutout is flattened the opposite way: circumscribing it would shrink the
|
||||
// material around it, so inscribe instead to keep the region conservative.
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
AddShape(region, cutout, tolerance, !circumscribe, positive: false);
|
||||
|
||||
return region;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offsets a part region outward by <paramref name="distance"/>: the perimeter
|
||||
/// grows and the cutouts shrink. Features narrower than twice the distance
|
||||
/// collapse, and cutouts that close up disappear. Joins are round, with chords
|
||||
/// no more than <paramref name="tolerance"/> from the true arc.
|
||||
/// </summary>
|
||||
/// <param name="circumscribe">
|
||||
/// When true, the result never under-estimates the offset: perimeter arcs are
|
||||
/// flattened outside the true curve, cutout arcs inside it, and the inflation is
|
||||
/// padded by the round-join chord error and Clipper's rounding.
|
||||
/// </param>
|
||||
public static OffsetRegion Offset(
|
||||
ShapeProfile profile,
|
||||
double distance,
|
||||
double tolerance,
|
||||
bool circumscribe = false
|
||||
)
|
||||
{
|
||||
var region = ToRegion(profile, tolerance, circumscribe);
|
||||
return Offset(region, distance, tolerance, circumscribe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offsets a single closed shape outward, ignoring any cutouts. A perimeter that
|
||||
/// curls back on itself (a C shape with a narrow mouth) can gain holes.
|
||||
/// </summary>
|
||||
public static OffsetRegion OffsetPerimeter(
|
||||
Shape perimeter,
|
||||
double distance,
|
||||
double tolerance,
|
||||
bool circumscribe = false
|
||||
)
|
||||
{
|
||||
var polygon = Flatten(perimeter, tolerance, circumscribe);
|
||||
return OffsetPerimeter(polygon, distance, tolerance, circumscribe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offsets a closed polygon outward, whatever its winding.
|
||||
/// </summary>
|
||||
public static OffsetRegion OffsetPerimeter(
|
||||
Polygon perimeter,
|
||||
double distance,
|
||||
double tolerance,
|
||||
bool circumscribe = false
|
||||
)
|
||||
{
|
||||
var region = new PathsD(1);
|
||||
AddPolygon(region, perimeter, positive: true);
|
||||
return Offset(region, distance, tolerance, circumscribe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offsets an already-flattened region (outers positive, holes negative).
|
||||
/// A distance of zero only unions the region, with no conservative padding.
|
||||
/// </summary>
|
||||
public static OffsetRegion Offset(
|
||||
PathsD region,
|
||||
double distance,
|
||||
double tolerance,
|
||||
bool circumscribe = false
|
||||
)
|
||||
{
|
||||
// Round joins put their vertices on the true arc, so each chord sits inside
|
||||
// it by up to the join tolerance. In conservative mode, joins use a finer
|
||||
// tolerance and the inflation is padded by it (plus Clipper's rounding).
|
||||
var delta = distance;
|
||||
var joinTolerance = tolerance;
|
||||
|
||||
if (circumscribe && distance > 0)
|
||||
{
|
||||
joinTolerance = tolerance * ConservativeJoinFactor;
|
||||
delta += joinTolerance + 0.5 * System.Math.Pow(10, -Precision);
|
||||
}
|
||||
|
||||
return Inflate(region, delta, joinTolerance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offset for checking a finished layout against its spacing. Arcs are flattened
|
||||
/// as in conservative mode (perimeter arcs circumscribed, cutout arcs inscribed),
|
||||
/// but round joins use a tenth of the tolerance and nothing is padded, so a layout
|
||||
/// exactly at the spacing passes. The only under-estimate is the join chord error
|
||||
/// at convex corners, at most a tenth of <paramref name="tolerance"/>.
|
||||
/// </summary>
|
||||
public static OffsetRegion OffsetForValidation(
|
||||
ShapeProfile profile,
|
||||
double distance,
|
||||
double tolerance
|
||||
)
|
||||
{
|
||||
var region = ToRegion(profile, tolerance, circumscribe: true);
|
||||
return Inflate(region, distance, tolerance * ValidationJoinFactor);
|
||||
}
|
||||
|
||||
private static OffsetRegion Inflate(PathsD region, double delta, double joinTolerance)
|
||||
{
|
||||
var inflated =
|
||||
delta <= 0
|
||||
? Union(region)
|
||||
: Clipper.InflatePaths(
|
||||
region,
|
||||
delta,
|
||||
JoinType.Round,
|
||||
EndType.Polygon,
|
||||
MiterLimit,
|
||||
Precision,
|
||||
joinTolerance
|
||||
);
|
||||
|
||||
var result = new OffsetRegion(new List<Polygon>(), new List<Polygon>());
|
||||
|
||||
foreach (var path in inflated)
|
||||
{
|
||||
if (path.Count < 3)
|
||||
continue;
|
||||
|
||||
if (Clipper.IsPositive(path))
|
||||
result.Outers.Add(ToPolygon(path));
|
||||
else
|
||||
result.Holes.Add(ToPolygon(path));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Miter-offsets a closed polygon by <paramref name="delta"/> (positive grows it,
|
||||
/// negative shrinks it). Returns the largest resulting polygon (CCW), or null
|
||||
/// when the polygon collapses.
|
||||
/// </summary>
|
||||
public static Polygon OffsetMiter(Polygon polygon, double delta)
|
||||
{
|
||||
var path = ToPath(polygon, positive: true);
|
||||
|
||||
if (path.Count < 3)
|
||||
return null;
|
||||
|
||||
var inflated = Clipper.InflatePaths(
|
||||
new PathsD { path },
|
||||
delta,
|
||||
JoinType.Miter,
|
||||
EndType.Polygon,
|
||||
MiterLimit,
|
||||
Precision
|
||||
);
|
||||
|
||||
PathD largest = null;
|
||||
var largestArea = 0.0;
|
||||
|
||||
foreach (var candidate in inflated)
|
||||
{
|
||||
var area = Clipper.Area(candidate);
|
||||
|
||||
if (area > largestArea)
|
||||
{
|
||||
largest = candidate;
|
||||
largestArea = area;
|
||||
}
|
||||
}
|
||||
|
||||
return largest == null ? null : ToPolygon(largest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flattens a closed shape to a polygon whose chords stay within
|
||||
/// <paramref name="tolerance"/> of every arc. Inscribed, the vertices lie on the
|
||||
/// arcs. Circumscribed, arc endpoints stay on the arc and the interior vertices sit
|
||||
/// on tangent intersections, so the polygon never falls inside the curve and never
|
||||
/// pokes past the straight edges an arc meets.
|
||||
/// </summary>
|
||||
public static Polygon Flatten(Shape shape, double tolerance, bool circumscribe)
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Line line:
|
||||
polygon.Vertices.Add(line.StartPoint);
|
||||
polygon.Vertices.Add(line.EndPoint);
|
||||
break;
|
||||
|
||||
case Arc arc:
|
||||
AddArc(polygon.Vertices, arc, tolerance, circumscribe);
|
||||
break;
|
||||
|
||||
case Circle circle:
|
||||
AddCircle(polygon.Vertices, circle, tolerance, circumscribe);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
polygon.Close();
|
||||
polygon.Cleanup();
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
|
||||
private static void AddArc(List<Vector> points, Arc arc, double tolerance, bool circumscribe)
|
||||
{
|
||||
if (!circumscribe)
|
||||
{
|
||||
points.AddRange(arc.ToPoints(arc.SegmentsForTolerance(tolerance)));
|
||||
return;
|
||||
}
|
||||
|
||||
var sweep = arc.SweepAngle();
|
||||
var segments = CircumscribedSegments(arc.Radius, sweep, tolerance);
|
||||
var step = (arc.IsReversed ? -sweep : sweep) / segments;
|
||||
var r = arc.Radius / System.Math.Cos(System.Math.Abs(step) / 2);
|
||||
|
||||
points.Add(arc.StartPoint());
|
||||
|
||||
for (var i = 0; i < segments; i++)
|
||||
{
|
||||
var angle = arc.StartAngle + step * (i + 0.5);
|
||||
points.Add(
|
||||
new Vector(
|
||||
arc.Center.X + r * System.Math.Cos(angle),
|
||||
arc.Center.Y + r * System.Math.Sin(angle)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
points.Add(arc.EndPoint());
|
||||
}
|
||||
|
||||
private static void AddCircle(
|
||||
List<Vector> points,
|
||||
Circle circle,
|
||||
double tolerance,
|
||||
bool circumscribe
|
||||
)
|
||||
{
|
||||
if (!circumscribe)
|
||||
{
|
||||
points.AddRange(circle.ToPoints(circle.SegmentsForTolerance(tolerance)));
|
||||
return;
|
||||
}
|
||||
|
||||
var segments = CircumscribedSegments(circle.Radius, Angle.TwoPI, tolerance);
|
||||
var step = Angle.TwoPI / segments;
|
||||
var r = circle.Radius / System.Math.Cos(step / 2);
|
||||
|
||||
for (var i = 0; i < segments; i++)
|
||||
{
|
||||
points.Add(
|
||||
new Vector(
|
||||
circle.Center.X + r * System.Math.Cos(step * i),
|
||||
circle.Center.Y + r * System.Math.Sin(step * i)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Segments for a circumscribed arc: a tangent-intersection vertex sits
|
||||
/// radius / cos(step / 2) from the center, so keep that within the tolerance, and
|
||||
/// keep each step at 90 degrees or less so the tangents meet close to the arc.
|
||||
/// </summary>
|
||||
private static int CircumscribedSegments(double radius, double sweep, double tolerance)
|
||||
{
|
||||
var maxHalfStep = System.Math.Acos(radius / (radius + tolerance));
|
||||
var segments = (int)System.Math.Ceiling(System.Math.Abs(sweep) / (2 * maxHalfStep));
|
||||
var quarters = (int)System.Math.Ceiling(System.Math.Abs(sweep) / Angle.HalfPI);
|
||||
|
||||
return System.Math.Max(1, System.Math.Max(segments, quarters));
|
||||
}
|
||||
|
||||
private static PathsD Union(PathsD region)
|
||||
{
|
||||
var clipper = new ClipperD(Precision);
|
||||
clipper.AddSubject(region);
|
||||
|
||||
var solution = new PathsD();
|
||||
clipper.Execute(ClipType.Union, FillRule.NonZero, solution);
|
||||
return solution;
|
||||
}
|
||||
|
||||
private static void AddShape(
|
||||
PathsD region,
|
||||
Shape shape,
|
||||
double tolerance,
|
||||
bool circumscribe,
|
||||
bool positive
|
||||
)
|
||||
{
|
||||
AddPolygon(region, Flatten(shape, tolerance, circumscribe), positive);
|
||||
}
|
||||
|
||||
private static void AddPolygon(PathsD region, Polygon polygon, bool positive)
|
||||
{
|
||||
if (polygon.Vertices.Count < 3)
|
||||
return;
|
||||
|
||||
var path = ToPath(polygon, positive);
|
||||
|
||||
if (path.Count >= 3)
|
||||
region.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="ClipperBridge.Offset(ShapeProfile, double, double, bool)"/>:
|
||||
/// outer boundaries (CCW) and holes (CW), as closed polygons.
|
||||
/// </summary>
|
||||
public sealed record OffsetRegion(List<Polygon> Outers, List<Polygon> Holes)
|
||||
{
|
||||
/// <summary>
|
||||
/// The outer boundary with the largest area, or null when the region is empty.
|
||||
/// </summary>
|
||||
public Polygon LargestOuter()
|
||||
{
|
||||
Polygon best = null;
|
||||
var bestArea = 0.0;
|
||||
|
||||
foreach (var outer in Outers)
|
||||
{
|
||||
var area = outer.Area();
|
||||
|
||||
if (best == null || area > bestArea)
|
||||
{
|
||||
best = outer;
|
||||
bestArea = area;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
-114
@@ -1,12 +1,32 @@
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Polygon overlap test with hole subtraction. This is the reference implementation
|
||||
/// for a future GPU kernel, so it deliberately stays hand-rolled instead of using
|
||||
/// Clipper (which is CPU-only and allocation-heavy; see <see cref="ClipperBridge"/>
|
||||
/// for the CPU preparation that feeds it).
|
||||
/// <para>
|
||||
/// GPU-port contract. Per-polygon preparation, done once per drawing and rotation,
|
||||
/// then cached and uploaded: the spacing offset (<see cref="ClipperBridge"/>),
|
||||
/// triangulation (<see cref="ConvexDecomposition.Triangulate"/>) of the outline and
|
||||
/// each hole, and the bounding box of every polygon and triangle. Per-pair work,
|
||||
/// kernel-shaped (fixed-size, loop-only, no recursion): the bounding-box rejects,
|
||||
/// Sutherland-Hodgman clipping of convex triangle pairs (<c>ClipConvex</c>), and
|
||||
/// subtraction of hole triangles from the clipped regions (<c>SubtractTriangles</c>).
|
||||
/// Inputs are closed, lines-only polygons; winding is normalized by triangulation.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class Collision
|
||||
{
|
||||
public static CollisionResult Check(Polygon a, Polygon b,
|
||||
List<Polygon> holesA = null, List<Polygon> holesB = null)
|
||||
public static CollisionResult Check(
|
||||
Polygon a,
|
||||
Polygon b,
|
||||
List<Polygon> holesA = null,
|
||||
List<Polygon> holesB = null
|
||||
)
|
||||
{
|
||||
// Step 1: Bounding box pre-filter
|
||||
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
|
||||
@@ -46,8 +66,12 @@ namespace OpenNest.Geometry
|
||||
return new CollisionResult(true, regions, intersectionPoints);
|
||||
}
|
||||
|
||||
public static bool HasOverlap(Polygon a, Polygon b,
|
||||
List<Polygon> holesA = null, List<Polygon> holesB = null)
|
||||
public static bool HasOverlap(
|
||||
Polygon a,
|
||||
Polygon b,
|
||||
List<Polygon> holesA = null,
|
||||
List<Polygon> holesB = null
|
||||
)
|
||||
{
|
||||
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
|
||||
return false;
|
||||
@@ -57,8 +81,10 @@ namespace OpenNest.Geometry
|
||||
return Check(a, b, holesA, holesB).Overlaps;
|
||||
}
|
||||
|
||||
public static List<CollisionResult> CheckAll(List<Polygon> polygons,
|
||||
List<List<Polygon>> holes = null)
|
||||
public static List<CollisionResult> CheckAll(
|
||||
List<Polygon> polygons,
|
||||
List<List<Polygon>> holes = null
|
||||
)
|
||||
{
|
||||
var results = new List<CollisionResult>();
|
||||
|
||||
@@ -78,8 +104,7 @@ namespace OpenNest.Geometry
|
||||
return results;
|
||||
}
|
||||
|
||||
public static bool HasAnyOverlap(List<Polygon> polygons,
|
||||
List<List<Polygon>> holes = null)
|
||||
public static bool HasAnyOverlap(List<Polygon> polygons, List<List<Polygon>> holes = null)
|
||||
{
|
||||
for (var i = 0; i < polygons.Count; i++)
|
||||
{
|
||||
@@ -98,10 +123,8 @@ namespace OpenNest.Geometry
|
||||
|
||||
private static bool BoundingBoxesOverlap(Box a, Box b)
|
||||
{
|
||||
var overlapX = System.Math.Min(a.Right, b.Right)
|
||||
- System.Math.Max(a.Left, b.Left);
|
||||
var overlapY = System.Math.Min(a.Top, b.Top)
|
||||
- System.Math.Max(a.Bottom, b.Bottom);
|
||||
var overlapX = System.Math.Min(a.Right, b.Right) - System.Math.Max(a.Left, b.Left);
|
||||
var overlapY = System.Math.Min(a.Top, b.Top) - System.Math.Max(a.Bottom, b.Bottom);
|
||||
|
||||
return overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon;
|
||||
}
|
||||
@@ -161,67 +184,14 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
private static Polygon ClipConvex(Polygon subject, Polygon clip)
|
||||
{
|
||||
var output = new List<Vector>(subject.Vertices);
|
||||
|
||||
// Remove closing vertex if present
|
||||
if (output.Count > 1 && output[0].X == output[output.Count - 1].X
|
||||
&& output[0].Y == output[output.Count - 1].Y)
|
||||
output.RemoveAt(output.Count - 1);
|
||||
|
||||
var clipVerts = new List<Vector>(clip.Vertices);
|
||||
if (clipVerts.Count > 1 && clipVerts[0].X == clipVerts[clipVerts.Count - 1].X
|
||||
&& clipVerts[0].Y == clipVerts[clipVerts.Count - 1].Y)
|
||||
clipVerts.RemoveAt(clipVerts.Count - 1);
|
||||
|
||||
for (var i = 0; i < clipVerts.Count; i++)
|
||||
var output = OpenVertices(subject);
|
||||
var clipVerts = OpenVertices(clip);
|
||||
for (var i = 0; i < clipVerts.Count && output.Count >= 3; i++)
|
||||
{
|
||||
if (output.Count == 0)
|
||||
return null;
|
||||
|
||||
var edgeStart = clipVerts[i];
|
||||
var edgeEnd = clipVerts[(i + 1) % clipVerts.Count];
|
||||
var input = output;
|
||||
output = new List<Vector>();
|
||||
|
||||
for (var j = 0; j < input.Count; j++)
|
||||
{
|
||||
var current = input[j];
|
||||
var next = input[(j + 1) % input.Count];
|
||||
var currentInside = Cross(edgeStart, edgeEnd, current) >= -Tolerance.Epsilon;
|
||||
var nextInside = Cross(edgeStart, edgeEnd, next) >= -Tolerance.Epsilon;
|
||||
|
||||
if (currentInside)
|
||||
{
|
||||
output.Add(current);
|
||||
if (!nextInside)
|
||||
{
|
||||
var ix = LineIntersection(edgeStart, edgeEnd, current, next);
|
||||
if (ix.IsValid())
|
||||
output.Add(ix);
|
||||
}
|
||||
}
|
||||
else if (nextInside)
|
||||
{
|
||||
var ix = LineIntersection(edgeStart, edgeEnd, current, next);
|
||||
if (ix.IsValid())
|
||||
output.Add(ix);
|
||||
}
|
||||
}
|
||||
output = ClipHalfSpace(output, clipVerts[i], clipVerts[(i + 1) % clipVerts.Count], true);
|
||||
}
|
||||
|
||||
if (output.Count < 3)
|
||||
return null;
|
||||
|
||||
var result = new Polygon();
|
||||
result.Vertices.AddRange(output);
|
||||
result.Close();
|
||||
result.UpdateBounds();
|
||||
|
||||
// Reject degenerate slivers
|
||||
if (result.Area() < Tolerance.Epsilon)
|
||||
return null;
|
||||
|
||||
return result;
|
||||
return PositiveAreaPolygon(output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -231,36 +201,23 @@ namespace OpenNest.Geometry
|
||||
private static double Cross(Vector edgeStart, Vector edgeEnd, Vector point)
|
||||
{
|
||||
return (edgeEnd.X - edgeStart.X) * (point.Y - edgeStart.Y)
|
||||
- (edgeEnd.Y - edgeStart.Y) * (point.X - edgeStart.X);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Intersection of lines (a1->a2) and (b1->b2). Returns Vector.Invalid if parallel.
|
||||
/// </summary>
|
||||
private static Vector LineIntersection(Vector a1, Vector a2, Vector b1, Vector b2)
|
||||
{
|
||||
var d1x = a2.X - a1.X;
|
||||
var d1y = a2.Y - a1.Y;
|
||||
var d2x = b2.X - b1.X;
|
||||
var d2y = b2.Y - b1.Y;
|
||||
var cross = d1x * d2y - d1y * d2x;
|
||||
|
||||
if (System.Math.Abs(cross) < Tolerance.Epsilon)
|
||||
return Vector.Invalid;
|
||||
|
||||
var t = ((b1.X - a1.X) * d2y - (b1.Y - a1.Y) * d2x) / cross;
|
||||
return new Vector(a1.X + t * d1x, a1.Y + t * d1y);
|
||||
- (edgeEnd.Y - edgeStart.Y) * (point.X - edgeStart.X);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subtracts holes from overlap regions.
|
||||
/// </summary>
|
||||
private static List<Polygon> SubtractHoles(List<Polygon> regions,
|
||||
List<Polygon> holesA, List<Polygon> holesB)
|
||||
private static List<Polygon> SubtractHoles(
|
||||
List<Polygon> regions,
|
||||
List<Polygon> holesA,
|
||||
List<Polygon> holesB
|
||||
)
|
||||
{
|
||||
var allHoles = new List<Polygon>();
|
||||
if (holesA != null) allHoles.AddRange(holesA);
|
||||
if (holesB != null) allHoles.AddRange(holesB);
|
||||
if (holesA != null)
|
||||
allHoles.AddRange(holesA);
|
||||
if (holesB != null)
|
||||
allHoles.AddRange(holesB);
|
||||
|
||||
if (allHoles.Count == 0)
|
||||
return regions;
|
||||
@@ -286,8 +243,9 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subtracts hole triangles from a region. Conservative: partial overlaps
|
||||
/// keep the full piece triangle (acceptable for visual shading).
|
||||
/// Subtracts hole triangles from a convex region. At each edge, emit the outside
|
||||
/// portion and carry only the inside remainder to the next edge. The emitted
|
||||
/// pieces are disjoint and convex, so no repeated triangulation is needed.
|
||||
/// </summary>
|
||||
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
|
||||
{
|
||||
@@ -295,36 +253,117 @@ namespace OpenNest.Geometry
|
||||
|
||||
foreach (var holeTri in holeTris)
|
||||
{
|
||||
if (!BoundingBoxesOverlap(region.BoundingBox, holeTri.BoundingBox))
|
||||
continue;
|
||||
|
||||
var next = new List<Polygon>();
|
||||
|
||||
foreach (var piece in current)
|
||||
{
|
||||
var pieceTris = TriangulateWithBounds(piece);
|
||||
|
||||
foreach (var pieceTri in pieceTris)
|
||||
// Subtraction must also remove thin fragments created by clipping.
|
||||
// The pair-level length tolerance would skip some of these even
|
||||
// when their area is large enough to count as an overlap.
|
||||
var a = piece.BoundingBox;
|
||||
var b = holeTri.BoundingBox;
|
||||
if (a.Right <= b.Left || b.Right <= a.Left || a.Top <= b.Bottom || b.Top <= a.Bottom)
|
||||
{
|
||||
var inside = ClipConvex(pieceTri, holeTri);
|
||||
if (inside == null)
|
||||
{
|
||||
// No overlap with hole - keep
|
||||
next.Add(pieceTri);
|
||||
}
|
||||
else if (inside.Area() < pieceTri.Area() - Tolerance.Epsilon)
|
||||
{
|
||||
// Partial overlap - keep the piece (conservative)
|
||||
next.Add(pieceTri);
|
||||
}
|
||||
// else: fully inside hole - discard
|
||||
next.Add(piece);
|
||||
continue;
|
||||
}
|
||||
|
||||
var remainder = OpenVertices(piece);
|
||||
var holeVerts = OpenVertices(holeTri);
|
||||
for (var i = 0; i < holeVerts.Count && remainder.Count >= 3; i++)
|
||||
{
|
||||
var start = holeVerts[i];
|
||||
var end = holeVerts[(i + 1) % holeVerts.Count];
|
||||
var outside = PositiveAreaPolygon(ClipHalfSpace(remainder, start, end, false));
|
||||
if (outside != null)
|
||||
next.Add(outside);
|
||||
remainder = ClipHalfSpace(remainder, start, end, true);
|
||||
}
|
||||
}
|
||||
|
||||
current = next;
|
||||
if (current.Count == 0)
|
||||
break;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clips an open vertex list against one half-space. Classification and
|
||||
/// interpolation use the same signed cross products: intersections always
|
||||
/// lie on the input segment. An epsilon-shifted inside test combined with
|
||||
/// intersections on the unshifted line can extrapolate and create material.
|
||||
/// Apply the area tolerance only to the resulting polygons, not to edge signs.
|
||||
/// </summary>
|
||||
private static List<Vector> ClipHalfSpace(
|
||||
List<Vector> vertices,
|
||||
Vector edgeStart,
|
||||
Vector edgeEnd,
|
||||
bool inside
|
||||
)
|
||||
{
|
||||
var kept = new List<Vector>();
|
||||
for (var i = 0; i < vertices.Count; i++)
|
||||
{
|
||||
var current = vertices[i];
|
||||
var next = vertices[(i + 1) % vertices.Count];
|
||||
var currentDistance = Cross(edgeStart, edgeEnd, current);
|
||||
var nextDistance = Cross(edgeStart, edgeEnd, next);
|
||||
if (inside ? currentDistance >= 0 : currentDistance <= 0)
|
||||
AddDistinct(kept, current);
|
||||
|
||||
// Only strict opposite signs cross the line. Boundary endpoints
|
||||
// are already kept, and near-parallel crossings need no cutoff.
|
||||
if ((currentDistance < 0 && nextDistance > 0) || (currentDistance > 0 && nextDistance < 0))
|
||||
{
|
||||
var t = currentDistance / (currentDistance - nextDistance);
|
||||
AddDistinct(kept, new Vector(
|
||||
current.X + t * (next.X - current.X),
|
||||
current.Y + t * (next.Y - current.Y)));
|
||||
}
|
||||
}
|
||||
if (kept.Count > 1 && SamePoint(kept[0], kept[kept.Count - 1]))
|
||||
kept.RemoveAt(kept.Count - 1);
|
||||
return kept;
|
||||
}
|
||||
|
||||
private static bool SamePoint(Vector a, Vector b) => a.X == b.X && a.Y == b.Y;
|
||||
|
||||
private static void AddDistinct(List<Vector> vertices, Vector point)
|
||||
{
|
||||
if (vertices.Count == 0 || !SamePoint(vertices[vertices.Count - 1], point))
|
||||
vertices.Add(point);
|
||||
}
|
||||
|
||||
private static List<Vector> OpenVertices(Polygon polygon)
|
||||
{
|
||||
var vertices = new List<Vector>(polygon.Vertices);
|
||||
if (vertices.Count > 1 && SamePoint(vertices[0], vertices[vertices.Count - 1]))
|
||||
vertices.RemoveAt(vertices.Count - 1);
|
||||
return vertices;
|
||||
}
|
||||
|
||||
private static Polygon PositiveAreaPolygon(List<Vector> vertices)
|
||||
{
|
||||
if (vertices.Count < 3)
|
||||
return null;
|
||||
|
||||
// Measure relative to a vertex to avoid cancellation of world-coordinate
|
||||
// products when a small clipped fragment is far from the origin.
|
||||
var twiceArea = 0.0;
|
||||
for (var i = 1; i + 1 < vertices.Count; i++)
|
||||
twiceArea += Cross(vertices[0], vertices[i], vertices[i + 1]);
|
||||
if (System.Math.Abs(twiceArea) <= 2 * Tolerance.Epsilon)
|
||||
return null;
|
||||
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.AddRange(vertices);
|
||||
// Polygon.Close uses fuzzy Vector equality; clipping needs an exact
|
||||
// closing vertex even when the last edge is shorter than Epsilon.
|
||||
polygon.Vertices.Add(vertices[0]);
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,17 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
public class CollisionResult
|
||||
{
|
||||
public static readonly CollisionResult None = new(false, new List<Polygon>(), new List<Vector>());
|
||||
public static readonly CollisionResult None = new(
|
||||
false,
|
||||
new List<Polygon>(),
|
||||
new List<Vector>()
|
||||
);
|
||||
|
||||
public CollisionResult(bool overlaps, List<Polygon> overlapRegions, List<Vector> intersectionPoints)
|
||||
public CollisionResult(
|
||||
bool overlaps,
|
||||
List<Polygon> overlapRegions,
|
||||
List<Vector> intersectionPoints
|
||||
)
|
||||
{
|
||||
Overlaps = overlaps;
|
||||
OverlapRegions = overlapRegions;
|
||||
|
||||
@@ -19,8 +19,11 @@ namespace OpenNest.Geometry
|
||||
var verts = new List<Vector>(polygon.Vertices);
|
||||
|
||||
// Remove closing vertex if polygon is closed.
|
||||
if (verts.Count > 1 && verts[0].X == verts[verts.Count - 1].X
|
||||
&& verts[0].Y == verts[verts.Count - 1].Y)
|
||||
if (
|
||||
verts.Count > 1
|
||||
&& verts[0].X == verts[verts.Count - 1].X
|
||||
&& verts[0].Y == verts[verts.Count - 1].Y
|
||||
)
|
||||
verts.RemoveAt(verts.Count - 1);
|
||||
|
||||
if (verts.Count < 3)
|
||||
@@ -84,8 +87,14 @@ namespace OpenNest.Geometry
|
||||
/// Tests whether the vertex at curr forms an ear (a convex vertex whose
|
||||
/// triangle contains no other polygon vertices).
|
||||
/// </summary>
|
||||
private static bool IsEar(Vector prev, Vector curr, Vector next,
|
||||
List<Vector> verts, List<int> indices, int n)
|
||||
private static bool IsEar(
|
||||
Vector prev,
|
||||
Vector curr,
|
||||
Vector next,
|
||||
List<Vector> verts,
|
||||
List<int> indices,
|
||||
int n
|
||||
)
|
||||
{
|
||||
// Must be convex (CCW turn).
|
||||
if (Cross(prev, curr, next) <= 0)
|
||||
|
||||
@@ -20,7 +20,10 @@ namespace OpenNest.Geometry
|
||||
|
||||
foreach (var p in sorted)
|
||||
{
|
||||
while (lower.Count >= 2 && Cross(lower[lower.Count - 2], lower[lower.Count - 1], p) <= 0)
|
||||
while (
|
||||
lower.Count >= 2
|
||||
&& Cross(lower[lower.Count - 2], lower[lower.Count - 1], p) <= 0
|
||||
)
|
||||
lower.RemoveAt(lower.Count - 1);
|
||||
|
||||
lower.Add(p);
|
||||
@@ -32,7 +35,10 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
var p = sorted[i];
|
||||
|
||||
while (upper.Count >= 2 && Cross(upper[upper.Count - 2], upper[upper.Count - 1], p) <= 0)
|
||||
while (
|
||||
upper.Count >= 2
|
||||
&& Cross(upper[upper.Count - 2], upper[upper.Count - 1], p) <= 0
|
||||
)
|
||||
upper.RemoveAt(upper.Count - 1);
|
||||
|
||||
upper.Add(p);
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Immutable flat-array polygon with a uniform edge grid, used as an outer-shell
|
||||
/// clearance prefilter. Two closed polygons share positive area only when an edge pair
|
||||
/// crosses/touches or one polygon's vertex lies strictly inside the other; neither
|
||||
/// happening certifies the two closed regions (hence any materials inside them) are
|
||||
/// clear. <see cref="Relate"/> returns Clear only in that certified case and Unknown
|
||||
/// for uncertain contacts, so it can only ever skip the exact <see cref="Collision"/>
|
||||
/// gate when the exact gate would also find no overlap - the exact gate triangulates
|
||||
/// both polygons per call and dominates runtime on finely flattened arc geometry.
|
||||
/// <para>
|
||||
/// A <see cref="EdgeGridPolygonTemplate"/> holds the shared geometry; <see cref="Translated"/>
|
||||
/// produces a placement in world coordinates in O(1) - translation leaves the grid and
|
||||
/// all cell indices unchanged, only the predicate coordinates shift.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class EdgeGridPolygon
|
||||
{
|
||||
/// <summary>Vertex-on-segment / collinearity tolerance for conservative touches.</summary>
|
||||
private const double TouchEps = 1e-9;
|
||||
|
||||
private readonly EdgeGridPolygonTemplate _template;
|
||||
|
||||
/// <summary>Translation applied to the shared template geometry.</summary>
|
||||
private readonly double Dx;
|
||||
|
||||
private readonly double Dy;
|
||||
|
||||
private EdgeGridPolygon(EdgeGridPolygonTemplate template, double dx, double dy)
|
||||
{
|
||||
_template = template;
|
||||
Dx = dx;
|
||||
Dy = dy;
|
||||
}
|
||||
|
||||
private double MinX => _template.MinX + Dx;
|
||||
private double MinY => _template.MinY + Dy;
|
||||
private double MaxX => _template.MaxX + Dx;
|
||||
private double MaxY => _template.MaxY + Dy;
|
||||
|
||||
/// <summary>
|
||||
/// Builds from a closed <see cref="Polygon"/> (last vertex may repeat the first).
|
||||
/// Returns null when the polygon has no usable ring - callers treat that as
|
||||
/// "no information" and fall through to the exact gate.
|
||||
/// </summary>
|
||||
public static EdgeGridPolygon? From(Polygon polygon)
|
||||
{
|
||||
var template = EdgeGridPolygonTemplate.Build(polygon);
|
||||
return template == null ? null : new EdgeGridPolygon(template, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>Returns a placement sharing immutable geometry, with an added translation.</summary>
|
||||
public EdgeGridPolygon Translated(double dx, double dy) => new(_template, Dx + dx, Dy + dy);
|
||||
|
||||
private double X(int i) => _template.X[i] + Dx;
|
||||
private double Y(int i) => _template.Y[i] + Dy;
|
||||
|
||||
/// <summary>
|
||||
/// Certifies disjoint filled perimeters. Any crossing, containment or uncertain
|
||||
/// boundary contact returns Unknown and must defer to the exact collision test.
|
||||
/// Holes need not be supplied: removing material cannot invalidate Clear.
|
||||
/// </summary>
|
||||
public static ShellRelation Relate(EdgeGridPolygon a, EdgeGridPolygon b)
|
||||
{
|
||||
if (
|
||||
a.MaxX <= b.MinX
|
||||
|| b.MaxX <= a.MinX
|
||||
|| a.MaxY <= b.MinY
|
||||
|| b.MaxY <= a.MinY
|
||||
)
|
||||
return ShellRelation.Clear; // disjoint bounding boxes
|
||||
|
||||
// One walk per direction reports the strongest edge relation: a transversal
|
||||
// crossing shares a positive-area wedge (overlap); a mere touch shares zero
|
||||
// area but may hide a crossing in near-degenerate coordinates (unknown).
|
||||
var edge = EdgeRelation(a, b);
|
||||
if (edge < 2)
|
||||
{
|
||||
var back = EdgeRelation(b, a);
|
||||
if (back > edge)
|
||||
edge = back;
|
||||
}
|
||||
if (edge == 2)
|
||||
return ShellRelation.Unknown;
|
||||
|
||||
// Only fully disjoint boundaries can certify clearance. Point touches and
|
||||
// collinear/near-degenerate contacts always defer to the reference test.
|
||||
switch (edge)
|
||||
{
|
||||
case 0:
|
||||
if (ContainsPointStrictly(a, b.X(0), b.Y(0)))
|
||||
return ShellRelation.Unknown;
|
||||
if (ContainsPointStrictly(b, a.X(0), a.Y(0)))
|
||||
return ShellRelation.Unknown;
|
||||
return ShellRelation.Clear;
|
||||
default:
|
||||
return ShellRelation.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies whether any edge of <paramref name="q"/> crosses or touches the boundary of
|
||||
/// <paramref name="p"/>. Walks p's grid using each query edge's own bbox cells.
|
||||
/// p's grid lives in p's LOCAL frame (the template's own coordinates), so the
|
||||
/// query edge is converted by subtracting p's translation first.
|
||||
/// </summary>
|
||||
private static int EdgeRelation(EdgeGridPolygon p, EdgeGridPolygon q)
|
||||
{
|
||||
var t = p._template;
|
||||
var n = t.Count;
|
||||
Span<int> seen = n <= 1024 ? stackalloc int[n] : new int[n];
|
||||
seen.Clear();
|
||||
var head = t.Head;
|
||||
var nodeEdge = t.NodeEdge;
|
||||
var nodeNext = t.NodeNext;
|
||||
var no = q._template.Count;
|
||||
var strongest = 0;
|
||||
|
||||
for (var e = 0; e < no; e++)
|
||||
{
|
||||
// Stamp per QUERY edge: a grid edge may need testing against every query
|
||||
// edge; the dedupe only collapses cells an individual query edge crosses
|
||||
// more than once.
|
||||
var stamp = e + 1;
|
||||
var i2 = (e + 1) % no;
|
||||
var p0x = q.X(e) - p.Dx;
|
||||
var p0y = q.Y(e) - p.Dy;
|
||||
var p1x = q.X(i2) - p.Dx;
|
||||
var p1y = q.Y(i2) - p.Dy;
|
||||
|
||||
var c0 = ColLow(t, p0x, p1x);
|
||||
if (c0 > ColHigh(t, p0x, p1x))
|
||||
continue;
|
||||
var c1 = ColHigh(t, p0x, p1x);
|
||||
var r0 = RowLow(t, p0y, p1y);
|
||||
if (r0 > RowHigh(t, p0y, p1y))
|
||||
continue;
|
||||
var r1 = RowHigh(t, p0y, p1y);
|
||||
|
||||
for (var r = r0; r <= r1; r++)
|
||||
for (var c = c0; c <= c1; c++)
|
||||
for (var nIdx = head[r * t.Cols + c]; nIdx >= 0; nIdx = nodeNext[nIdx])
|
||||
{
|
||||
var ea = nodeEdge[nIdx];
|
||||
if (seen[ea] == stamp)
|
||||
continue;
|
||||
seen[ea] = stamp;
|
||||
var a2 = (ea + 1) % n;
|
||||
var relation = SegmentRelation(
|
||||
t.X[ea], t.Y[ea], t.X[a2], t.Y[a2], p0x, p0y, p1x, p1y
|
||||
);
|
||||
if (relation == 2)
|
||||
return 2; // transversal crossing
|
||||
if (relation > strongest)
|
||||
strongest = relation;
|
||||
}
|
||||
}
|
||||
return strongest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Segment-pair relation: 2 = transversal crossing (strict sign flips on both
|
||||
/// orientations - the regions share a positive-area wedge); 1 = a clean endpoint
|
||||
/// touch (zero shared area by itself; callers decide via interior-vertex tests);
|
||||
/// 3 = collinear or near-degenerate contact (a shared boundary segment can hide
|
||||
/// either a same-side positive overlap or an opposite-side tangency, so it must
|
||||
/// defer to the exact gate); 0 = disjoint.
|
||||
/// </summary>
|
||||
private static int SegmentRelation(
|
||||
double ax, double ay, double bx, double by, double cx, double cy, double dx, double dy
|
||||
)
|
||||
{
|
||||
var rx = bx - ax;
|
||||
var ry = by - ay;
|
||||
var sx = dx - cx;
|
||||
var sy = dy - cy;
|
||||
var d1 = rx * (cy - ay) - ry * (cx - ax);
|
||||
var d2 = rx * (dy - ay) - ry * (dx - ax);
|
||||
var d3 = sx * (ay - cy) - sy * (ax - cx);
|
||||
var d4 = sx * (by - cy) - sy * (bx - cx);
|
||||
|
||||
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)))
|
||||
return 2; // proper crossing
|
||||
|
||||
// A near-zero orientation means the configuration is collinear or too close to
|
||||
// classify; only exact-zero orientations get the clean point-touch verdict.
|
||||
var scale = System.Math.Max(
|
||||
1e-30,
|
||||
System.Math.Max(System.Math.Abs(rx) + System.Math.Abs(ry), System.Math.Abs(sx) + System.Math.Abs(sy))
|
||||
);
|
||||
var eps = TouchEps * scale;
|
||||
var nearDegenerate =
|
||||
(System.Math.Abs(d1) <= eps && d1 != 0)
|
||||
|| (System.Math.Abs(d2) <= eps && d2 != 0)
|
||||
|| (System.Math.Abs(d3) <= eps && d3 != 0)
|
||||
|| (System.Math.Abs(d4) <= eps && d4 != 0);
|
||||
var exactDegenerate = d1 == 0 || d2 == 0 || d3 == 0 || d4 == 0;
|
||||
|
||||
var touch =
|
||||
(d1 == 0 && PointOnSegment(cx, cy, ax, ay, bx, by))
|
||||
|| (d2 == 0 && PointOnSegment(dx, dy, ax, ay, bx, by))
|
||||
|| (d3 == 0 && PointOnSegment(ax, ay, cx, cy, dx, dy))
|
||||
|| (d4 == 0 && PointOnSegment(bx, by, cx, cy, dx, dy));
|
||||
|
||||
if (nearDegenerate)
|
||||
return 3;
|
||||
if (exactDegenerate)
|
||||
// Collinear: contact along a segment (or too close to tell) must defer to
|
||||
// the exact gate; collinear but disjoint edges simply do not touch.
|
||||
return touch ? 3 : 0;
|
||||
if (touch)
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool PointOnSegment(
|
||||
double px, double py, double ax, double ay, double bx, double by
|
||||
) =>
|
||||
System.Math.Min(ax, bx) - TouchEps <= px
|
||||
&& px <= System.Math.Max(ax, bx) + TouchEps
|
||||
&& System.Math.Min(ay, by) - TouchEps <= py
|
||||
&& py <= System.Math.Max(ay, by) + TouchEps;
|
||||
|
||||
/// <summary>Strict ray-cast containment (boundary touches are excluded upstream).</summary>
|
||||
private static bool ContainsPointStrictly(EdgeGridPolygon poly, double px, double py)
|
||||
{
|
||||
var t = poly._template;
|
||||
var inside = false;
|
||||
var n = t.Count;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var j = (i + 1) % n;
|
||||
var yi = poly.Y(i);
|
||||
var yj = poly.Y(j);
|
||||
if ((yi > py) != (yj > py))
|
||||
{
|
||||
var xAt = poly.X(i) + (py - yi) / (yj - yi) * (poly.X(j) - poly.X(i));
|
||||
if (px < xAt)
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static int ColLow(EdgeGridPolygonTemplate t, double a, double b) =>
|
||||
System.Math.Clamp((int)System.Math.Floor((System.Math.Min(a, b) - t.MinX) / t.CellSize), 0, t.Cols);
|
||||
|
||||
private static int ColHigh(EdgeGridPolygonTemplate t, double a, double b) =>
|
||||
System.Math.Clamp((int)System.Math.Floor((System.Math.Max(a, b) - t.MinX) / t.CellSize), -1, t.Cols - 1);
|
||||
|
||||
private static int RowLow(EdgeGridPolygonTemplate t, double a, double b) =>
|
||||
System.Math.Clamp((int)System.Math.Floor((System.Math.Min(a, b) - t.MinY) / t.CellSize), 0, t.Rows);
|
||||
|
||||
private static int RowHigh(EdgeGridPolygonTemplate t, double a, double b) =>
|
||||
System.Math.Clamp((int)System.Math.Floor((System.Math.Max(a, b) - t.MinY) / t.CellSize), -1, t.Rows - 1);
|
||||
|
||||
/// <summary>
|
||||
/// Shared, immutable grid geometry for <see cref="EdgeGridPolygon"/>; the grid is defined
|
||||
/// relative to the shape's own local coordinates, so translated instances reuse it.
|
||||
/// Per-query deduplication scratch is local, so placements may be queried concurrently.
|
||||
/// </summary>
|
||||
private sealed class EdgeGridPolygonTemplate
|
||||
{
|
||||
internal readonly double[] X;
|
||||
internal readonly double[] Y;
|
||||
internal readonly int Count;
|
||||
internal readonly double MinX;
|
||||
internal readonly double MinY;
|
||||
internal readonly double MaxX;
|
||||
internal readonly double MaxY;
|
||||
|
||||
internal readonly double CellSize;
|
||||
|
||||
internal readonly int Cols;
|
||||
internal readonly int Rows;
|
||||
internal readonly int[] Head;
|
||||
|
||||
/// <summary>
|
||||
/// Grid nodes as parallel (edge, next) arrays: an edge spanning several cells gets
|
||||
/// one node PER cell - a single next-per-edge chain would corrupt the other cells'
|
||||
/// chains and silently drop edges from the walk.
|
||||
/// </summary>
|
||||
internal readonly int[] NodeEdge;
|
||||
|
||||
internal readonly int[] NodeNext;
|
||||
|
||||
|
||||
private EdgeGridPolygonTemplate(
|
||||
double[] x,
|
||||
double[] y,
|
||||
int count,
|
||||
double minX,
|
||||
double minY,
|
||||
double maxX,
|
||||
double maxY
|
||||
)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
Count = count;
|
||||
MinX = minX;
|
||||
MinY = minY;
|
||||
MaxX = maxX;
|
||||
MaxY = maxY;
|
||||
|
||||
var extentX = System.Math.Max(maxX - minX, 1e-9);
|
||||
var extentY = System.Math.Max(maxY - minY, 1e-9);
|
||||
CellSize = System.Math.Max(System.Math.Max(extentX, extentY) / 16.0, 1e-9);
|
||||
Cols = System.Math.Clamp((int)System.Math.Ceiling(extentX / CellSize) + 1, 1, 48);
|
||||
Rows = System.Math.Clamp((int)System.Math.Ceiling(extentY / CellSize) + 1, 1, 48);
|
||||
Head = new int[Cols * Rows];
|
||||
Array.Fill(Head, -1);
|
||||
|
||||
// Pass 1: count nodes; pass 2: fill (edge, next) node arrays.
|
||||
var cellsPerEdge = new int[count];
|
||||
var total = 0;
|
||||
for (var e = 0; e < count; e++)
|
||||
{
|
||||
var i2 = (e + 1) % count;
|
||||
var c0 = ClampCol(System.Math.Min(x[e], x[i2]) - minX);
|
||||
var c1 = ClampCol(System.Math.Max(x[e], x[i2]) - minX);
|
||||
var r0 = ClampRow(System.Math.Min(y[e], y[i2]) - minY);
|
||||
var r1 = ClampRow(System.Math.Max(y[e], y[i2]) - minY);
|
||||
cellsPerEdge[e] = (c1 - c0 + 1) * (r1 - r0 + 1);
|
||||
total += cellsPerEdge[e];
|
||||
}
|
||||
NodeEdge = new int[total];
|
||||
NodeNext = new int[total];
|
||||
var node = 0;
|
||||
for (var e = 0; e < count; e++)
|
||||
{
|
||||
var i2 = (e + 1) % count;
|
||||
var c0 = ClampCol(System.Math.Min(x[e], x[i2]) - minX);
|
||||
var c1 = ClampCol(System.Math.Max(x[e], x[i2]) - minX);
|
||||
var r0 = ClampRow(System.Math.Min(y[e], y[i2]) - minY);
|
||||
var r1 = ClampRow(System.Math.Max(y[e], y[i2]) - minY);
|
||||
for (var r = r0; r <= r1; r++)
|
||||
for (var c = c0; c <= c1; c++)
|
||||
{
|
||||
var cell = r * Cols + c;
|
||||
NodeEdge[node] = e;
|
||||
NodeNext[node] = Head[cell];
|
||||
Head[cell] = node;
|
||||
node++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int ClampCol(double dx) =>
|
||||
System.Math.Clamp((int)System.Math.Floor(dx / CellSize), 0, Cols - 1);
|
||||
|
||||
private int ClampRow(double dy) =>
|
||||
System.Math.Clamp((int)System.Math.Floor(dy / CellSize), 0, Rows - 1);
|
||||
|
||||
internal static EdgeGridPolygonTemplate? Build(Polygon polygon)
|
||||
{
|
||||
var vertices = polygon.Vertices;
|
||||
var n = vertices.Count;
|
||||
if (n >= 2 && vertices[0].X == vertices[n - 1].X && vertices[0].Y == vertices[n - 1].Y)
|
||||
n--;
|
||||
if (n < 3)
|
||||
return null;
|
||||
var xs = new double[n];
|
||||
var ys = new double[n];
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var vx = vertices[i].X;
|
||||
var vy = vertices[i].Y;
|
||||
xs[i] = vx;
|
||||
ys[i] = vy;
|
||||
if (vx < minX)
|
||||
minX = vx;
|
||||
if (vx > maxX)
|
||||
maxX = vx;
|
||||
if (vy < minY)
|
||||
minY = vy;
|
||||
if (vy > maxY)
|
||||
maxY = vy;
|
||||
}
|
||||
return new EdgeGridPolygonTemplate(xs, ys, n, minX, minY, maxX, maxY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Conservative result of an outer-perimeter prefilter.</summary>
|
||||
public enum ShellRelation
|
||||
{
|
||||
/// <summary>Filled perimeters, and therefore their material, are disjoint.</summary>
|
||||
Clear,
|
||||
/// <summary>Run an exact collision test; the prefilter cannot certify clearance.</summary>
|
||||
Unknown,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -9,7 +9,13 @@ namespace OpenNest.Geometry
|
||||
private const int MaxSubdivisionDepth = 12;
|
||||
private const int DeviationSamples = 20;
|
||||
|
||||
internal static Vector EvaluatePoint(double semiMajor, double semiMinor, double rotation, Vector center, double t)
|
||||
internal static Vector EvaluatePoint(
|
||||
double semiMajor,
|
||||
double semiMinor,
|
||||
double rotation,
|
||||
Vector center,
|
||||
double t
|
||||
)
|
||||
{
|
||||
var x = semiMajor * System.Math.Cos(t);
|
||||
var y = semiMinor * System.Math.Sin(t);
|
||||
@@ -17,12 +23,15 @@ namespace OpenNest.Geometry
|
||||
var cos = System.Math.Cos(rotation);
|
||||
var sin = System.Math.Sin(rotation);
|
||||
|
||||
return new Vector(
|
||||
center.X + x * cos - y * sin,
|
||||
center.Y + x * sin + y * cos);
|
||||
return new Vector(center.X + x * cos - y * sin, center.Y + x * sin + y * cos);
|
||||
}
|
||||
|
||||
internal static Vector EvaluateTangent(double semiMajor, double semiMinor, double rotation, double t)
|
||||
internal static Vector EvaluateTangent(
|
||||
double semiMajor,
|
||||
double semiMinor,
|
||||
double rotation,
|
||||
double t
|
||||
)
|
||||
{
|
||||
var tx = -semiMajor * System.Math.Sin(t);
|
||||
var ty = semiMinor * System.Math.Cos(t);
|
||||
@@ -30,12 +39,15 @@ namespace OpenNest.Geometry
|
||||
var cos = System.Math.Cos(rotation);
|
||||
var sin = System.Math.Sin(rotation);
|
||||
|
||||
return new Vector(
|
||||
tx * cos - ty * sin,
|
||||
tx * sin + ty * cos);
|
||||
return new Vector(tx * cos - ty * sin, tx * sin + ty * cos);
|
||||
}
|
||||
|
||||
internal static Vector EvaluateNormal(double semiMajor, double semiMinor, double rotation, double t)
|
||||
internal static Vector EvaluateNormal(
|
||||
double semiMajor,
|
||||
double semiMinor,
|
||||
double rotation,
|
||||
double t
|
||||
)
|
||||
{
|
||||
// Inward normal: perpendicular to tangent, pointing toward center of curvature.
|
||||
// In local coords: N(t) = (-b*cos(t), -a*sin(t))
|
||||
@@ -45,9 +57,7 @@ namespace OpenNest.Geometry
|
||||
var cos = System.Math.Cos(rotation);
|
||||
var sin = System.Math.Sin(rotation);
|
||||
|
||||
return new Vector(
|
||||
nx * cos - ny * sin,
|
||||
nx * sin + ny * cos);
|
||||
return new Vector(nx * cos - ny * sin, nx * sin + ny * cos);
|
||||
}
|
||||
|
||||
internal static Vector IntersectNormals(Vector p1, Vector n1, Vector p2, Vector n2)
|
||||
@@ -83,11 +93,21 @@ namespace OpenNest.Geometry
|
||||
return new Vector(ux + c.X, uy + c.Y);
|
||||
}
|
||||
|
||||
public static List<Entity> Convert(Vector center, double semiMajor, double semiMinor,
|
||||
double rotation, double startParam, double endParam, double tolerance = 0.001)
|
||||
public static List<Entity> Convert(
|
||||
Vector center,
|
||||
double semiMajor,
|
||||
double semiMinor,
|
||||
double rotation,
|
||||
double startParam,
|
||||
double endParam,
|
||||
double tolerance = 0.001
|
||||
)
|
||||
{
|
||||
if (tolerance <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(tolerance), "Tolerance must be positive.");
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(tolerance),
|
||||
"Tolerance must be positive."
|
||||
);
|
||||
if (semiMajor <= 0 || semiMinor <= 0)
|
||||
throw new ArgumentOutOfRangeException("Semi-axis lengths must be positive.");
|
||||
|
||||
@@ -102,14 +122,28 @@ namespace OpenNest.Geometry
|
||||
|
||||
var entities = new List<Entity>();
|
||||
for (var i = 0; i < splits.Count - 1; i++)
|
||||
FitSegment(center, semiMajor, semiMinor, rotation,
|
||||
splits[i], splits[i + 1], tolerance, entities, 0);
|
||||
FitSegment(
|
||||
center,
|
||||
semiMajor,
|
||||
semiMinor,
|
||||
rotation,
|
||||
splits[i],
|
||||
splits[i + 1],
|
||||
tolerance,
|
||||
entities,
|
||||
0
|
||||
);
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static List<Entity> ConvertCircle(Vector center, double radius,
|
||||
double rotation, double startParam, double endParam)
|
||||
private static List<Entity> ConvertCircle(
|
||||
Vector center,
|
||||
double radius,
|
||||
double rotation,
|
||||
double startParam,
|
||||
double endParam
|
||||
)
|
||||
{
|
||||
var sweep = endParam - startParam;
|
||||
var isFull = System.Math.Abs(sweep - Angle.TwoPI) < 0.01;
|
||||
@@ -123,7 +157,7 @@ namespace OpenNest.Geometry
|
||||
return new List<Entity>
|
||||
{
|
||||
new Arc(center, radius, startAngle1, midAngle, false),
|
||||
new Arc(center, radius, midAngle, endAngle2, false)
|
||||
new Arc(center, radius, midAngle, endAngle2, false),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,7 +170,8 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
var splits = new List<double> { startParam };
|
||||
|
||||
var firstQuadrant = System.Math.Ceiling(startParam / (System.Math.PI / 2)) * (System.Math.PI / 2);
|
||||
var firstQuadrant =
|
||||
System.Math.Ceiling(startParam / (System.Math.PI / 2)) * (System.Math.PI / 2);
|
||||
for (var q = firstQuadrant; q < endParam; q += System.Math.PI / 2)
|
||||
{
|
||||
if (q > startParam + 1e-10 && q < endParam - 1e-10)
|
||||
@@ -147,8 +182,17 @@ namespace OpenNest.Geometry
|
||||
return splits;
|
||||
}
|
||||
|
||||
private static void FitSegment(Vector center, double semiMajor, double semiMinor,
|
||||
double rotation, double t0, double t1, double tolerance, List<Entity> results, int depth)
|
||||
private static void FitSegment(
|
||||
Vector center,
|
||||
double semiMajor,
|
||||
double semiMinor,
|
||||
double rotation,
|
||||
double t0,
|
||||
double t1,
|
||||
double tolerance,
|
||||
List<Entity> results,
|
||||
int depth
|
||||
)
|
||||
{
|
||||
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t0);
|
||||
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t1);
|
||||
@@ -168,12 +212,29 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
var radius = p0.DistanceTo(arcCenter);
|
||||
var maxDev = MeasureDeviation(center, semiMajor, semiMinor, rotation,
|
||||
t0, t1, arcCenter, radius);
|
||||
var maxDev = MeasureDeviation(
|
||||
center,
|
||||
semiMajor,
|
||||
semiMinor,
|
||||
rotation,
|
||||
t0,
|
||||
t1,
|
||||
arcCenter,
|
||||
radius
|
||||
);
|
||||
|
||||
if (maxDev <= tolerance)
|
||||
{
|
||||
var arc = CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1);
|
||||
var arc = CreateArc(
|
||||
arcCenter,
|
||||
radius,
|
||||
center,
|
||||
semiMajor,
|
||||
semiMinor,
|
||||
rotation,
|
||||
t0,
|
||||
t1
|
||||
);
|
||||
if (arc.SweepAngle() < Tolerance.Epsilon)
|
||||
results.Add(new Line(p0, p1));
|
||||
else
|
||||
@@ -182,13 +243,41 @@ namespace OpenNest.Geometry
|
||||
else
|
||||
{
|
||||
var tMid = (t0 + t1) / 2.0;
|
||||
FitSegment(center, semiMajor, semiMinor, rotation, t0, tMid, tolerance, results, depth + 1);
|
||||
FitSegment(center, semiMajor, semiMinor, rotation, tMid, t1, tolerance, results, depth + 1);
|
||||
FitSegment(
|
||||
center,
|
||||
semiMajor,
|
||||
semiMinor,
|
||||
rotation,
|
||||
t0,
|
||||
tMid,
|
||||
tolerance,
|
||||
results,
|
||||
depth + 1
|
||||
);
|
||||
FitSegment(
|
||||
center,
|
||||
semiMajor,
|
||||
semiMinor,
|
||||
rotation,
|
||||
tMid,
|
||||
t1,
|
||||
tolerance,
|
||||
results,
|
||||
depth + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static double MeasureDeviation(Vector center, double semiMajor, double semiMinor,
|
||||
double rotation, double t0, double t1, Vector arcCenter, double radius)
|
||||
private static double MeasureDeviation(
|
||||
Vector center,
|
||||
double semiMajor,
|
||||
double semiMinor,
|
||||
double rotation,
|
||||
double t0,
|
||||
double t1,
|
||||
Vector arcCenter,
|
||||
double radius
|
||||
)
|
||||
{
|
||||
var maxDev = 0.0;
|
||||
for (var i = 1; i <= DeviationSamples; i++)
|
||||
@@ -197,14 +286,22 @@ namespace OpenNest.Geometry
|
||||
var p = EvaluatePoint(semiMajor, semiMinor, rotation, center, t);
|
||||
var dist = p.DistanceTo(arcCenter);
|
||||
var dev = System.Math.Abs(dist - radius);
|
||||
if (dev > maxDev) maxDev = dev;
|
||||
if (dev > maxDev)
|
||||
maxDev = dev;
|
||||
}
|
||||
return maxDev;
|
||||
}
|
||||
|
||||
private static Arc CreateArc(Vector arcCenter, double radius,
|
||||
Vector ellipseCenter, double semiMajor, double semiMinor, double rotation,
|
||||
double t0, double t1)
|
||||
private static Arc CreateArc(
|
||||
Vector arcCenter,
|
||||
double radius,
|
||||
Vector ellipseCenter,
|
||||
double semiMajor,
|
||||
double semiMinor,
|
||||
double rotation,
|
||||
double t0,
|
||||
double t1
|
||||
)
|
||||
{
|
||||
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t0);
|
||||
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1);
|
||||
@@ -225,8 +322,10 @@ namespace OpenNest.Geometry
|
||||
var points = new List<Vector> { p0, pMid, p1 };
|
||||
var isReversed = SumSignedAngles(arcCenter, points) < 0;
|
||||
|
||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0) endAngle += Angle.TwoPI;
|
||||
if (startAngle < 0)
|
||||
startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0)
|
||||
endAngle += Angle.TwoPI;
|
||||
|
||||
return new Arc(arcCenter, radius, startAngle, endAngle, isReversed);
|
||||
}
|
||||
@@ -239,8 +338,10 @@ namespace OpenNest.Geometry
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI) da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI) da += Angle.TwoPI;
|
||||
while (da > System.Math.PI)
|
||||
da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI)
|
||||
da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -331,7 +331,11 @@ namespace OpenNest.Geometry
|
||||
return points;
|
||||
}
|
||||
|
||||
public static BoundingRectangleResult FindBestRotation(this List<Entity> entities, double startAngle = 0, double endAngle = Angle.TwoPI)
|
||||
public static BoundingRectangleResult FindBestRotation(
|
||||
this List<Entity> entities,
|
||||
double startAngle = 0,
|
||||
double endAngle = Angle.TwoPI
|
||||
)
|
||||
{
|
||||
// Check for Shape entity first (recursive case returns early)
|
||||
foreach (var entity in entities)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public enum EntityType
|
||||
{
|
||||
@@ -7,6 +6,6 @@ namespace OpenNest.Geometry
|
||||
Circle,
|
||||
Line,
|
||||
Shape,
|
||||
Polygon
|
||||
Polygon,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public static class GeometryOptimizer
|
||||
{
|
||||
public static void Optimize(IList<Arc> arcs) =>
|
||||
MergePass(arcs,
|
||||
MergePass(
|
||||
arcs,
|
||||
(list, item, i) => list.GetCoradialArs(item, i),
|
||||
(Arc a, Arc b, out Arc joined) => TryJoinArcs(a, b, out joined));
|
||||
(Arc a, Arc b, out Arc joined) => TryJoinArcs(a, b, out joined)
|
||||
);
|
||||
|
||||
public static void Optimize(IList<Line> lines) =>
|
||||
MergePass(lines,
|
||||
MergePass(
|
||||
lines,
|
||||
(list, item, i) => list.GetCollinearLines(item, i),
|
||||
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined));
|
||||
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined)
|
||||
);
|
||||
|
||||
public static void Deduplicate(IList<Circle> circles)
|
||||
{
|
||||
@@ -23,8 +27,10 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
for (var j = i - 1; j >= 0; j--)
|
||||
{
|
||||
if (circles[i].Center.DistanceTo(circles[j].Center) <= Tolerance.Epsilon
|
||||
&& circles[i].Radius.IsEqualTo(circles[j].Radius))
|
||||
if (
|
||||
circles[i].Center.DistanceTo(circles[j].Center) <= Tolerance.Epsilon
|
||||
&& circles[i].Radius.IsEqualTo(circles[j].Radius)
|
||||
)
|
||||
{
|
||||
circles.RemoveAt(i);
|
||||
break;
|
||||
@@ -39,9 +45,11 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
for (var j = arcs.Count - 1; j >= 0; j--)
|
||||
{
|
||||
if (arcs[j].Center.DistanceTo(circles[i].Center) <= Tolerance.Epsilon
|
||||
if (
|
||||
arcs[j].Center.DistanceTo(circles[i].Center) <= Tolerance.Epsilon
|
||||
&& arcs[j].Radius.IsEqualTo(circles[i].Radius)
|
||||
&& arcs[j].IsFullCircle())
|
||||
&& arcs[j].IsFullCircle()
|
||||
)
|
||||
{
|
||||
arcs.RemoveAt(j);
|
||||
}
|
||||
@@ -51,9 +59,12 @@ namespace OpenNest.Geometry
|
||||
|
||||
private delegate bool TryJoin<T>(T a, T b, out T joined);
|
||||
|
||||
private static void MergePass<T>(IList<T> items,
|
||||
private static void MergePass<T>(
|
||||
IList<T> items,
|
||||
Func<IList<T>, T, int, List<T>> findCandidates,
|
||||
TryJoin<T> tryJoin) where T : class
|
||||
TryJoin<T> tryJoin
|
||||
)
|
||||
where T : class
|
||||
{
|
||||
for (var i = 0; i < items.Count; ++i)
|
||||
{
|
||||
@@ -117,10 +128,14 @@ namespace OpenNest.Geometry
|
||||
|
||||
if (!onPoint)
|
||||
{
|
||||
if (t1 < b2 - Tolerance.Epsilon) return false;
|
||||
if (b1 > t2 + Tolerance.Epsilon) return false;
|
||||
if (l1 > r2 + Tolerance.Epsilon) return false;
|
||||
if (r1 < l2 - Tolerance.Epsilon) return false;
|
||||
if (t1 < b2 - Tolerance.Epsilon)
|
||||
return false;
|
||||
if (b1 > t2 + Tolerance.Epsilon)
|
||||
return false;
|
||||
if (l1 > r2 + Tolerance.Epsilon)
|
||||
return false;
|
||||
if (r1 < l2 - Tolerance.Epsilon)
|
||||
return false;
|
||||
}
|
||||
|
||||
var l = l1 < l2 ? l1 : l2;
|
||||
@@ -129,9 +144,17 @@ namespace OpenNest.Geometry
|
||||
var b = b1 < b2 ? b1 : b2;
|
||||
|
||||
if (!line1.IsVertical() && line1.Slope() < 0)
|
||||
lineOut = new Line(new Vector(l, t), new Vector(r, b)) { Layer = line1.Layer, Color = line1.Color };
|
||||
lineOut = new Line(new Vector(l, t), new Vector(r, b))
|
||||
{
|
||||
Layer = line1.Layer,
|
||||
Color = line1.Color,
|
||||
};
|
||||
else
|
||||
lineOut = new Line(new Vector(l, b), new Vector(r, t)) { Layer = line1.Layer, Color = line1.Color };
|
||||
lineOut = new Line(new Vector(l, b), new Vector(r, t))
|
||||
{
|
||||
Layer = line1.Layer,
|
||||
Color = line1.Color,
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -177,33 +200,47 @@ namespace OpenNest.Geometry
|
||||
if (sweep >= Angle.TwoPI - Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0) endAngle += Angle.TwoPI;
|
||||
if (startAngle < 0)
|
||||
startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0)
|
||||
endAngle += Angle.TwoPI;
|
||||
|
||||
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle) { Layer = arc1.Layer, Color = arc1.Color };
|
||||
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle)
|
||||
{
|
||||
Layer = arc1.Layer,
|
||||
Color = arc1.Color,
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<Line> GetCollinearLines(this IList<Line> lines, Line line, int startIndex)
|
||||
private static List<Line> GetCollinearLines(
|
||||
this IList<Line> lines,
|
||||
Line line,
|
||||
int startIndex
|
||||
)
|
||||
{
|
||||
var collinearLines = new List<Line>();
|
||||
|
||||
Parallel.For(startIndex, lines.Count, index =>
|
||||
{
|
||||
var compareLine = lines[index];
|
||||
|
||||
if (Object.ReferenceEquals(line, compareLine))
|
||||
return;
|
||||
|
||||
if (!line.IsCollinearTo(compareLine))
|
||||
return;
|
||||
|
||||
lock (collinearLines)
|
||||
Parallel.For(
|
||||
startIndex,
|
||||
lines.Count,
|
||||
index =>
|
||||
{
|
||||
collinearLines.Add(compareLine);
|
||||
var compareLine = lines[index];
|
||||
|
||||
if (Object.ReferenceEquals(line, compareLine))
|
||||
return;
|
||||
|
||||
if (!line.IsCollinearTo(compareLine))
|
||||
return;
|
||||
|
||||
lock (collinearLines)
|
||||
{
|
||||
collinearLines.Add(compareLine);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
return collinearLines;
|
||||
}
|
||||
@@ -212,21 +249,25 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
var coradialArcs = new List<Arc>();
|
||||
|
||||
Parallel.For(startIndex, arcs.Count, index =>
|
||||
{
|
||||
var compareArc = arcs[index];
|
||||
|
||||
if (Object.ReferenceEquals(arc, compareArc))
|
||||
return;
|
||||
|
||||
if (!arc.IsCoradialTo(compareArc))
|
||||
return;
|
||||
|
||||
lock (coradialArcs)
|
||||
Parallel.For(
|
||||
startIndex,
|
||||
arcs.Count,
|
||||
index =>
|
||||
{
|
||||
coradialArcs.Add(compareArc);
|
||||
var compareArc = arcs[index];
|
||||
|
||||
if (Object.ReferenceEquals(arc, compareArc))
|
||||
return;
|
||||
|
||||
if (!arc.IsCoradialTo(compareArc))
|
||||
return;
|
||||
|
||||
lock (coradialArcs)
|
||||
{
|
||||
coradialArcs.Add(compareArc);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
return coradialArcs;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@ public class ArcCandidate
|
||||
public double MaxDeviation { get; set; }
|
||||
public Box BoundingBox { get; set; }
|
||||
public bool IsSelected { get; set; } = true;
|
||||
|
||||
/// <summary>First point of the original line segments this candidate covers.</summary>
|
||||
public Vector FirstPoint { get; set; }
|
||||
|
||||
/// <summary>Last point of the original line segments this candidate covers.</summary>
|
||||
public Vector LastPoint { get; set; }
|
||||
}
|
||||
@@ -46,9 +48,7 @@ public class MirrorAxisResult
|
||||
var dx = p.X - Point.X;
|
||||
var dy = p.Y - Point.Y;
|
||||
var dot = dx * Direction.X + dy * Direction.Y;
|
||||
return new Vector(
|
||||
p.X - 2 * (dx - dot * Direction.X),
|
||||
p.Y - 2 * (dy - dot * Direction.Y));
|
||||
return new Vector(p.X - 2 * (dx - dot * Direction.X), p.Y - 2 * (dy - dot * Direction.Y));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +74,14 @@ public class GeometrySimplifier
|
||||
var runStart = i;
|
||||
var layerName = entities[i].Layer?.Name;
|
||||
var lineCount = 0;
|
||||
while (i < entities.Count && (entities[i] is Line || entities[i] is Arc) && entities[i].Layer?.Name == layerName)
|
||||
while (
|
||||
i < entities.Count
|
||||
&& (entities[i] is Line || entities[i] is Arc)
|
||||
&& entities[i].Layer?.Name == layerName
|
||||
)
|
||||
{
|
||||
if (entities[i] is Line) lineCount++;
|
||||
if (entities[i] is Line)
|
||||
lineCount++;
|
||||
i++;
|
||||
}
|
||||
var runEnd = i - 1;
|
||||
@@ -90,10 +95,7 @@ public class GeometrySimplifier
|
||||
|
||||
public Shape Apply(Shape shape, List<ArcCandidate> candidates)
|
||||
{
|
||||
var selected = candidates
|
||||
.Where(c => c.IsSelected)
|
||||
.OrderBy(c => c.StartIndex)
|
||||
.ToList();
|
||||
var selected = candidates.Where(c => c.IsSelected).OrderBy(c => c.StartIndex).ToList();
|
||||
|
||||
var newEntities = new List<Entity>();
|
||||
var i = 0;
|
||||
@@ -132,11 +134,10 @@ public class GeometrySimplifier
|
||||
foreach (var e in shape.Entities)
|
||||
midpoints.Add(e.BoundingBox.Center);
|
||||
|
||||
if (midpoints.Count < 4) return MirrorAxisResult.None;
|
||||
if (midpoints.Count < 4)
|
||||
return MirrorAxisResult.None;
|
||||
|
||||
var centroid = new Vector(
|
||||
midpoints.Average(p => p.X),
|
||||
midpoints.Average(p => p.Y));
|
||||
var centroid = new Vector(midpoints.Average(p => p.X), midpoints.Average(p => p.Y));
|
||||
var cx = centroid.X;
|
||||
var cy = centroid.Y;
|
||||
|
||||
@@ -190,8 +191,7 @@ public class GeometrySimplifier
|
||||
return bestResult.Score >= 0.8 ? bestResult : MirrorAxisResult.None;
|
||||
}
|
||||
|
||||
private static double NormalizeAngle(double angle) =>
|
||||
angle < 0 ? angle + Angle.TwoPI : angle;
|
||||
private static double NormalizeAngle(double angle) => angle < 0 ? angle + Angle.TwoPI : angle;
|
||||
|
||||
private static Vector Normalize(Vector v)
|
||||
{
|
||||
@@ -231,7 +231,8 @@ public class GeometrySimplifier
|
||||
|
||||
for (var j = 0; j < points.Count; j++)
|
||||
{
|
||||
if (i == j) continue;
|
||||
if (i == j)
|
||||
continue;
|
||||
var d = reflected.DistanceTo(points[j]);
|
||||
if (d < matchTol)
|
||||
{
|
||||
@@ -251,17 +252,20 @@ public class GeometrySimplifier
|
||||
/// </summary>
|
||||
public void Symmetrize(List<ArcCandidate> candidates, MirrorAxisResult axis)
|
||||
{
|
||||
if (!axis.IsValid || candidates.Count < 2) return;
|
||||
if (!axis.IsValid || candidates.Count < 2)
|
||||
return;
|
||||
|
||||
var paired = new HashSet<int>();
|
||||
|
||||
for (var i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
if (paired.Contains(i)) continue;
|
||||
if (paired.Contains(i))
|
||||
continue;
|
||||
|
||||
var ci = candidates[i];
|
||||
var ciCenter = ci.BoundingBox.Center;
|
||||
if (PerpendicularDistance(ciCenter, axis.Point, axis.Direction) < 0.1) continue; // on the axis
|
||||
if (PerpendicularDistance(ciCenter, axis.Point, axis.Direction) < 0.1)
|
||||
continue; // on the axis
|
||||
|
||||
var mirrorCenter = axis.Reflect(ciCenter);
|
||||
|
||||
@@ -269,7 +273,8 @@ public class GeometrySimplifier
|
||||
var bestDist = double.MaxValue;
|
||||
for (var j = i + 1; j < candidates.Count; j++)
|
||||
{
|
||||
if (paired.Contains(j)) continue;
|
||||
if (paired.Contains(j))
|
||||
continue;
|
||||
var d = mirrorCenter.DistanceTo(candidates[j].BoundingBox.Center);
|
||||
if (d < bestDist)
|
||||
{
|
||||
@@ -279,7 +284,8 @@ public class GeometrySimplifier
|
||||
}
|
||||
|
||||
var matchTol = System.Math.Max(ci.BoundingBox.Width, ci.BoundingBox.Length) * 0.5;
|
||||
if (bestJ < 0 || bestDist > matchTol) continue;
|
||||
if (bestJ < 0 || bestDist > matchTol)
|
||||
continue;
|
||||
|
||||
paired.Add(i);
|
||||
paired.Add(bestJ);
|
||||
@@ -287,7 +293,10 @@ public class GeometrySimplifier
|
||||
var cj = candidates[bestJ];
|
||||
var sourceIdx = i;
|
||||
var targetIdx = bestJ;
|
||||
if (cj.LineCount > ci.LineCount || (cj.LineCount == ci.LineCount && cj.MaxDeviation < ci.MaxDeviation))
|
||||
if (
|
||||
cj.LineCount > ci.LineCount
|
||||
|| (cj.LineCount == ci.LineCount && cj.MaxDeviation < ci.MaxDeviation)
|
||||
)
|
||||
{
|
||||
sourceIdx = bestJ;
|
||||
targetIdx = i;
|
||||
@@ -323,8 +332,12 @@ public class GeometrySimplifier
|
||||
var mirrorEp = axis.Reflect(ep);
|
||||
|
||||
// Mirroring reverses winding — swap start/end to preserve arc direction
|
||||
var mirrorStart = NormalizeAngle(System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X));
|
||||
var mirrorEnd = NormalizeAngle(System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X));
|
||||
var mirrorStart = NormalizeAngle(
|
||||
System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X)
|
||||
);
|
||||
var mirrorEnd = NormalizeAngle(
|
||||
System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X)
|
||||
);
|
||||
|
||||
var result = new Arc(mirrorCenter, arc.Radius, mirrorStart, mirrorEnd, arc.IsReversed);
|
||||
result.Layer = arc.Layer;
|
||||
@@ -332,7 +345,12 @@ public class GeometrySimplifier
|
||||
return result;
|
||||
}
|
||||
|
||||
private void FindCandidatesInRun(List<Entity> entities, int runStart, int runEnd, List<ArcCandidate> candidates)
|
||||
private void FindCandidatesInRun(
|
||||
List<Entity> entities,
|
||||
int runStart,
|
||||
int runEnd,
|
||||
List<ArcCandidate> candidates
|
||||
)
|
||||
{
|
||||
var j = runStart;
|
||||
var chainedTangent = Vector.Invalid;
|
||||
@@ -349,46 +367,63 @@ public class GeometrySimplifier
|
||||
|
||||
chainedTangent = ComputeEndTangent(result.Center, result.Points);
|
||||
var arc = CreateArc(result.Center, result.Radius, result.Points, entities[j]);
|
||||
candidates.Add(new ArcCandidate
|
||||
{
|
||||
StartIndex = j,
|
||||
EndIndex = result.EndIndex,
|
||||
FittedArc = arc,
|
||||
MaxDeviation = result.Deviation,
|
||||
BoundingBox = result.Points.GetBoundingBox(),
|
||||
FirstPoint = arc.StartPoint(),
|
||||
LastPoint = arc.EndPoint(),
|
||||
});
|
||||
candidates.Add(
|
||||
new ArcCandidate
|
||||
{
|
||||
StartIndex = j,
|
||||
EndIndex = result.EndIndex,
|
||||
FittedArc = arc,
|
||||
MaxDeviation = result.Deviation,
|
||||
BoundingBox = result.Points.GetBoundingBox(),
|
||||
FirstPoint = arc.StartPoint(),
|
||||
LastPoint = arc.EndPoint(),
|
||||
}
|
||||
);
|
||||
|
||||
j = result.EndIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private record ArcFitResult(Vector Center, double Radius, double Deviation, List<Vector> Points, int EndIndex);
|
||||
private record ArcFitResult(
|
||||
Vector Center,
|
||||
double Radius,
|
||||
double Deviation,
|
||||
List<Vector> Points,
|
||||
int EndIndex
|
||||
);
|
||||
|
||||
private ArcFitResult TryFitArcAt(List<Entity> entities, int start, int runEnd, Vector chainedTangent)
|
||||
private ArcFitResult TryFitArcAt(
|
||||
List<Entity> entities,
|
||||
int start,
|
||||
int runEnd,
|
||||
Vector chainedTangent
|
||||
)
|
||||
{
|
||||
var k = start + MinLines - 1;
|
||||
if (k > runEnd) return null;
|
||||
if (k > runEnd)
|
||||
return null;
|
||||
|
||||
var points = CollectPoints(entities, start, k);
|
||||
if (points.Count < 3) return null;
|
||||
if (points.Count < 3)
|
||||
return null;
|
||||
|
||||
var startTangent = chainedTangent.IsValid()
|
||||
? chainedTangent
|
||||
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||
|
||||
var endTangent = GetExitDirection(entities[k]);
|
||||
var startTangent = EstimateStartTangent(entities, start, points, chainedTangent);
|
||||
var endTangent = EstimateEndTangent(entities, k, points);
|
||||
var (center, radius, dev) = TryFit(points, startTangent, endTangent);
|
||||
if (!center.IsValid()) return null;
|
||||
if (!center.IsValid())
|
||||
return null;
|
||||
|
||||
// Extend the arc as far as possible
|
||||
while (k + 1 <= runEnd)
|
||||
{
|
||||
var extPoints = CollectPoints(entities, start, k + 1);
|
||||
var extEndTangent = GetExitDirection(entities[k + 1]);
|
||||
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d);
|
||||
if (!nc.IsValid()) break;
|
||||
if (extPoints.Count < 3)
|
||||
break;
|
||||
|
||||
var extEndTangent = EstimateEndTangent(entities, k + 1, extPoints);
|
||||
var (nc, nr, nd) = TryFit(extPoints, startTangent, extEndTangent);
|
||||
if (!nc.IsValid())
|
||||
break;
|
||||
|
||||
k++;
|
||||
center = nc;
|
||||
@@ -407,37 +442,228 @@ public class GeometrySimplifier
|
||||
return new ArcFitResult(center, radius, dev, points, k);
|
||||
}
|
||||
|
||||
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent, Vector endTangent)
|
||||
private (Vector center, double radius, double deviation) TryFit(
|
||||
List<Vector> points,
|
||||
TangentEstimate start,
|
||||
TangentEstimate end
|
||||
)
|
||||
{
|
||||
// Try dual-tangent fit first (matches direction at both endpoints)
|
||||
if (endTangent.IsValid())
|
||||
foreach (var (center, radius, dev) in FitAttempts(points, start, end))
|
||||
{
|
||||
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent);
|
||||
if (dc.IsValid() && dd <= Tolerance)
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
continue;
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
continue;
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
}
|
||||
|
||||
return (Vector.Invalid, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Yields fit attempts in preference order. A trusted tangent (chained from the
|
||||
/// previous arc, an adjacent original arc, or a long straight edge) is enforced
|
||||
/// exactly on its side; otherwise the tangency error is balanced between both
|
||||
/// endpoints. The unconstrained mirror-axis fit is the last resort. Every attempt
|
||||
/// passes exactly through both endpoints, so no gaps are introduced.
|
||||
/// </summary>
|
||||
private IEnumerable<(Vector center, double radius, double deviation)> FitAttempts(
|
||||
List<Vector> points,
|
||||
TangentEstimate start,
|
||||
TangentEstimate end
|
||||
)
|
||||
{
|
||||
if (start.Trusted && !end.Trusted)
|
||||
{
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(
|
||||
points,
|
||||
start.Direction,
|
||||
end.Direction
|
||||
);
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
}
|
||||
else if (end.Trusted && !start.Trusted)
|
||||
{
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(
|
||||
points,
|
||||
start.Direction,
|
||||
end.Direction
|
||||
);
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(
|
||||
points,
|
||||
start.Direction,
|
||||
end.Direction
|
||||
);
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
}
|
||||
|
||||
yield return FitMirrorAxis(points);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits an arc through both endpoints with an exact tangent at the last point,
|
||||
/// by running the start-tangent fit on the reversed point sequence.
|
||||
/// </summary>
|
||||
private static (Vector center, double radius, double deviation) FitWithEndTangent(
|
||||
List<Vector> points,
|
||||
Vector endTangent
|
||||
)
|
||||
{
|
||||
var reversed = new List<Vector>(points);
|
||||
reversed.Reverse();
|
||||
return ArcFit.FitWithStartTangent(reversed, new Vector(-endTangent.X, -endTangent.Y));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An estimated tangent direction at a fit endpoint. Trusted estimates come from
|
||||
/// exact geometry (a chained arc, an adjacent original arc, or a long straight
|
||||
/// edge) and are enforced exactly; untrusted ones are derived from the polyline
|
||||
/// vertices and only guide the fit.
|
||||
/// </summary>
|
||||
private readonly record struct TangentEstimate(Vector Direction, bool Trusted);
|
||||
|
||||
/// <summary>Segment-length ratio above which a neighboring line counts as a true
|
||||
/// straight edge (rather than another chord of the tessellated curve).</summary>
|
||||
private const double NeighborEdgeFactor = 3.0;
|
||||
|
||||
private static TangentEstimate EstimateStartTangent(
|
||||
List<Entity> entities,
|
||||
int start,
|
||||
List<Vector> points,
|
||||
Vector chainedTangent
|
||||
)
|
||||
{
|
||||
if (chainedTangent.IsValid())
|
||||
return new TangentEstimate(chainedTangent, true);
|
||||
|
||||
if (entities[start] is Arc startArc)
|
||||
return new TangentEstimate(GetEntryDirection(startArc), true);
|
||||
|
||||
var firstChordLen = points[0].DistanceTo(points[1]);
|
||||
if (start > 0)
|
||||
{
|
||||
var prev = entities[start - 1];
|
||||
var prevEnd = prev switch
|
||||
{
|
||||
var isRev = SumSignedAngles(dc, points) < 0;
|
||||
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev);
|
||||
if (aDev <= Tolerance)
|
||||
return (dc, dr, System.Math.Max(dd, aDev));
|
||||
Line l => l.EndPoint,
|
||||
Arc a => a.EndPoint(),
|
||||
_ => Vector.Invalid,
|
||||
};
|
||||
if (prevEnd.IsValid() && prevEnd.DistanceTo(points[0]) < 1e-6)
|
||||
{
|
||||
if (prev is Arc)
|
||||
return new TangentEstimate(GetExitDirection(prev), true);
|
||||
if (
|
||||
prev is Line prevLine
|
||||
&& prevLine.StartPoint.DistanceTo(prevLine.EndPoint)
|
||||
>= NeighborEdgeFactor * firstChordLen
|
||||
)
|
||||
return new TangentEstimate(GetExitDirection(prevLine), true);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to start-tangent-only, then mirror axis
|
||||
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
(center, radius, dev) = FitMirrorAxis(points);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
var chord = new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||
if (points.Count >= 3)
|
||||
return new TangentEstimate(
|
||||
EstimateVertexTangent(points[0], points[1], points[2], chord),
|
||||
false
|
||||
);
|
||||
return new TangentEstimate(chord, false);
|
||||
}
|
||||
|
||||
private static TangentEstimate EstimateEndTangent(
|
||||
List<Entity> entities,
|
||||
int k,
|
||||
List<Vector> points
|
||||
)
|
||||
{
|
||||
if (entities[k] is Arc endArc)
|
||||
return new TangentEstimate(GetExitDirection(endArc), true);
|
||||
|
||||
var lastChordLen = points[^1].DistanceTo(points[^2]);
|
||||
if (k + 1 < entities.Count)
|
||||
{
|
||||
var next = entities[k + 1];
|
||||
var nextStart = next switch
|
||||
{
|
||||
Line l => l.StartPoint,
|
||||
Arc a => a.StartPoint(),
|
||||
_ => Vector.Invalid,
|
||||
};
|
||||
if (nextStart.IsValid() && nextStart.DistanceTo(points[^1]) < 1e-6)
|
||||
{
|
||||
if (next is Arc nextArc)
|
||||
return new TangentEstimate(GetEntryDirection(nextArc), true);
|
||||
if (
|
||||
next is Line nextLine
|
||||
&& nextLine.StartPoint.DistanceTo(nextLine.EndPoint)
|
||||
>= NeighborEdgeFactor * lastChordLen
|
||||
)
|
||||
return new TangentEstimate(GetExitDirection(nextLine), true);
|
||||
}
|
||||
}
|
||||
|
||||
var chord = new Vector(points[^1].X - points[^2].X, points[^1].Y - points[^2].Y);
|
||||
if (points.Count >= 3)
|
||||
return new TangentEstimate(
|
||||
EstimateVertexTangent(points[^1], points[^2], points[^3], chord),
|
||||
false
|
||||
);
|
||||
return new TangentEstimate(chord, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimates the curve tangent at a polyline vertex from the circle through it and
|
||||
/// its two nearest neighbors. A raw chord direction is off from the true tangent by
|
||||
/// half the chord's subtended angle; the circumcircle estimate removes that bias.
|
||||
/// Falls back to the travel direction when the three points are collinear.
|
||||
/// </summary>
|
||||
private static Vector EstimateVertexTangent(Vector at, Vector b, Vector c, Vector travel)
|
||||
{
|
||||
var d = 2 * (at.X * (b.Y - c.Y) + b.X * (c.Y - at.Y) + c.X * (at.Y - b.Y));
|
||||
if (System.Math.Abs(d) < 1e-14)
|
||||
return travel;
|
||||
|
||||
var sqA = at.X * at.X + at.Y * at.Y;
|
||||
var sqB = b.X * b.X + b.Y * b.Y;
|
||||
var sqC = c.X * c.X + c.Y * c.Y;
|
||||
var cx = (sqA * (b.Y - c.Y) + sqB * (c.Y - at.Y) + sqC * (at.Y - b.Y)) / d;
|
||||
var cy = (sqA * (c.X - b.X) + sqB * (at.X - c.X) + sqC * (b.X - at.X)) / d;
|
||||
|
||||
var tangent = new Vector(-(at.Y - cy), at.X - cx);
|
||||
if (tangent.X * travel.X + tangent.Y * travel.Y < 0)
|
||||
tangent = new Vector(-tangent.X, -tangent.Y);
|
||||
return tangent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the entry direction (tangent at start point) of an entity.
|
||||
/// </summary>
|
||||
private static Vector GetEntryDirection(Entity entity) =>
|
||||
entity switch
|
||||
{
|
||||
Line line => new Vector(
|
||||
line.EndPoint.X - line.StartPoint.X,
|
||||
line.EndPoint.Y - line.StartPoint.Y
|
||||
),
|
||||
Arc arc => arc.IsReversed
|
||||
? new Vector(System.Math.Sin(arc.StartAngle), -System.Math.Cos(arc.StartAngle))
|
||||
: new Vector(-System.Math.Sin(arc.StartAngle), System.Math.Cos(arc.StartAngle)),
|
||||
_ => Vector.Invalid,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes the tangent direction at the last point of a fitted arc,
|
||||
/// used to chain tangent continuity to the next arc.
|
||||
@@ -488,9 +714,17 @@ public class GeometrySimplifier
|
||||
var dInit = (maxSagitta * maxSagitta - halfChord * halfChord) / (2 * maxSagitta);
|
||||
var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord);
|
||||
|
||||
var dOpt = GoldenSectionMin(dInit - range, dInit + range,
|
||||
d => ArcFit.MaxRadialDeviation(points, mx + d * nx, my + d * ny,
|
||||
System.Math.Sqrt(halfChord * halfChord + d * d)));
|
||||
var dOpt = GoldenSectionMin(
|
||||
dInit - range,
|
||||
dInit + range,
|
||||
d =>
|
||||
ArcFit.MaxRadialDeviation(
|
||||
points,
|
||||
mx + d * nx,
|
||||
my + d * ny,
|
||||
System.Math.Sqrt(halfChord * halfChord + d * d)
|
||||
)
|
||||
);
|
||||
|
||||
var center = new Vector(mx + dOpt * nx, my + dOpt * ny);
|
||||
var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt);
|
||||
@@ -542,13 +776,22 @@ public class GeometrySimplifier
|
||||
return points;
|
||||
}
|
||||
|
||||
private static Arc CreateArc(Vector center, double radius, List<Vector> points, Entity sourceEntity)
|
||||
private static Arc CreateArc(
|
||||
Vector center,
|
||||
double radius,
|
||||
List<Vector> points,
|
||||
Entity sourceEntity
|
||||
)
|
||||
{
|
||||
var firstPoint = points[0];
|
||||
var lastPoint = points[^1];
|
||||
|
||||
var startAngle = NormalizeAngle(System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X));
|
||||
var endAngle = NormalizeAngle(System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X));
|
||||
var startAngle = NormalizeAngle(
|
||||
System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X)
|
||||
);
|
||||
var endAngle = NormalizeAngle(
|
||||
System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X)
|
||||
);
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
|
||||
var arc = new Arc(center, radius, startAngle, endAngle, isReversed);
|
||||
@@ -560,14 +803,18 @@ public class GeometrySimplifier
|
||||
/// <summary>
|
||||
/// Returns the exit direction (tangent at endpoint) of an entity.
|
||||
/// </summary>
|
||||
private static Vector GetExitDirection(Entity entity) => entity switch
|
||||
{
|
||||
Line line => new Vector(line.EndPoint.X - line.StartPoint.X, line.EndPoint.Y - line.StartPoint.Y),
|
||||
Arc arc => arc.IsReversed
|
||||
? new Vector(System.Math.Sin(arc.EndAngle), -System.Math.Cos(arc.EndAngle))
|
||||
: new Vector(-System.Math.Sin(arc.EndAngle), System.Math.Cos(arc.EndAngle)),
|
||||
_ => Vector.Invalid,
|
||||
};
|
||||
private static Vector GetExitDirection(Entity entity) =>
|
||||
entity switch
|
||||
{
|
||||
Line line => new Vector(
|
||||
line.EndPoint.X - line.StartPoint.X,
|
||||
line.EndPoint.Y - line.StartPoint.Y
|
||||
),
|
||||
Arc arc => arc.IsReversed
|
||||
? new Vector(System.Math.Sin(arc.EndAngle), -System.Math.Cos(arc.EndAngle))
|
||||
: new Vector(-System.Math.Sin(arc.EndAngle), System.Math.Cos(arc.EndAngle)),
|
||||
_ => Vector.Invalid,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Sums signed angular change traversing consecutive points around a center.
|
||||
@@ -581,8 +828,10 @@ public class GeometrySimplifier
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI) da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI) da += Angle.TwoPI;
|
||||
while (da > System.Math.PI)
|
||||
da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI)
|
||||
da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
@@ -593,7 +842,12 @@ public class GeometrySimplifier
|
||||
/// back to the original line segments. This catches cases where points lie
|
||||
/// on a large circle but the arc bulges far from the original straight geometry.
|
||||
/// </summary>
|
||||
private static double MaxArcToSegmentDeviation(List<Vector> points, Vector center, double radius, bool isReversed)
|
||||
private static double MaxArcToSegmentDeviation(
|
||||
List<Vector> points,
|
||||
Vector center,
|
||||
double radius,
|
||||
bool isReversed
|
||||
)
|
||||
{
|
||||
var startAngle = System.Math.Atan2(points[0].Y - center.Y, points[0].X - center.X);
|
||||
var endAngle = System.Math.Atan2(points[^1].Y - center.Y, points[^1].X - center.X);
|
||||
@@ -601,11 +855,13 @@ public class GeometrySimplifier
|
||||
var sweep = endAngle - startAngle;
|
||||
if (isReversed)
|
||||
{
|
||||
if (sweep > 0) sweep -= Angle.TwoPI;
|
||||
if (sweep > 0)
|
||||
sweep -= Angle.TwoPI;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (sweep < 0) sweep += Angle.TwoPI;
|
||||
if (sweep < 0)
|
||||
sweep += Angle.TwoPI;
|
||||
}
|
||||
|
||||
var sampleCount = System.Math.Max(10, (int)(System.Math.Abs(sweep) * radius * 10));
|
||||
@@ -624,9 +880,11 @@ public class GeometrySimplifier
|
||||
for (var j = 0; j < points.Count - 1; j++)
|
||||
{
|
||||
var dist = DistanceToSegment(arcPt, points[j], points[j + 1]);
|
||||
if (dist < minDist) minDist = dist;
|
||||
if (dist < minDist)
|
||||
minDist = dist;
|
||||
}
|
||||
if (minDist > maxDev) maxDev = minDist;
|
||||
if (minDist > maxDev)
|
||||
maxDev = minDist;
|
||||
}
|
||||
return maxDev;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public interface IBoundable
|
||||
{
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
using Clipper2Lib;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the Inner-Fit Polygon (IFP) — the feasible region where a part's
|
||||
/// reference point can be placed so the part stays entirely within the plate boundary.
|
||||
/// For a rectangular plate, the IFP is the plate shrunk by the part's bounding dimensions.
|
||||
/// </summary>
|
||||
public static class InnerFitPolygon
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the IFP for placing a part polygon inside a rectangular work area.
|
||||
/// The result is a polygon representing all valid reference point positions.
|
||||
/// </summary>
|
||||
public static Polygon Compute(Box workArea, Polygon partPolygon)
|
||||
{
|
||||
// Get the part's bounding box relative to its reference point (origin).
|
||||
var verts = partPolygon.Vertices;
|
||||
|
||||
if (verts.Count < 3)
|
||||
return new Polygon();
|
||||
|
||||
var minX = verts[0].X;
|
||||
var maxX = verts[0].X;
|
||||
var minY = verts[0].Y;
|
||||
var maxY = verts[0].Y;
|
||||
|
||||
for (var i = 1; i < verts.Count; i++)
|
||||
{
|
||||
if (verts[i].X < minX) minX = verts[i].X;
|
||||
if (verts[i].X > maxX) maxX = verts[i].X;
|
||||
if (verts[i].Y < minY) minY = verts[i].Y;
|
||||
if (verts[i].Y > maxY) maxY = verts[i].Y;
|
||||
}
|
||||
|
||||
// The IFP is the work area shrunk inward by the part's extent in each direction.
|
||||
// The reference point can range from (workArea.Left - minX) to (workArea.Right - maxX)
|
||||
// and (workArea.Bottom - minY) to (workArea.Top - maxY).
|
||||
var ifpLeft = workArea.X - minX;
|
||||
var ifpRight = workArea.Right - maxX;
|
||||
var ifpBottom = workArea.Y - minY;
|
||||
var ifpTop = workArea.Top - maxY;
|
||||
|
||||
// If the part doesn't fit, return an empty polygon.
|
||||
if (ifpRight < ifpLeft || ifpTop < ifpBottom)
|
||||
return new Polygon();
|
||||
|
||||
var result = new Polygon();
|
||||
result.Vertices.Add(new Vector(ifpLeft, ifpBottom));
|
||||
result.Vertices.Add(new Vector(ifpRight, ifpBottom));
|
||||
result.Vertices.Add(new Vector(ifpRight, ifpTop));
|
||||
result.Vertices.Add(new Vector(ifpLeft, ifpTop));
|
||||
result.Close();
|
||||
result.UpdateBounds();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the feasible region for placing a part given already-placed parts.
|
||||
/// FeasibleRegion = IFP(plate, part) - union(NFP(placed_i, part))
|
||||
/// Returns the polygon representing valid placement positions, or an empty
|
||||
/// polygon if no valid position exists.
|
||||
/// </summary>
|
||||
public static Polygon ComputeFeasibleRegion(Polygon ifp, PathsD nfpPaths)
|
||||
{
|
||||
if (ifp.Vertices.Count < 3)
|
||||
return new Polygon();
|
||||
|
||||
if (nfpPaths == null || nfpPaths.Count == 0)
|
||||
return ifp;
|
||||
|
||||
var ifpPath = NoFitPolygon.ToClipperPath(ifp);
|
||||
var ifpPaths = new PathsD { ifpPath };
|
||||
|
||||
// Subtract the NFPs from the IFP.
|
||||
// Clipper2 handles the implicit union of the clip paths.
|
||||
var feasible = Clipper.Difference(ifpPaths, nfpPaths, FillRule.NonZero);
|
||||
|
||||
if (feasible.Count == 0)
|
||||
return new Polygon();
|
||||
|
||||
// Find the polygon with the bottom-left-most point.
|
||||
// This ensures we pick the correct region for placement.
|
||||
PathD bestPath = null;
|
||||
var bestY = double.MaxValue;
|
||||
var bestX = double.MaxValue;
|
||||
|
||||
foreach (var path in feasible)
|
||||
{
|
||||
foreach (var pt in path)
|
||||
{
|
||||
if (pt.y < bestY || (pt.y == bestY && pt.x < bestX))
|
||||
{
|
||||
bestY = pt.y;
|
||||
bestX = pt.x;
|
||||
bestPath = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestPath != null ? NoFitPolygon.FromClipperPath(bestPath) : new Polygon();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the feasible region for placing a part given already-placed parts.
|
||||
/// (Legacy overload for backward compatibility).
|
||||
/// </summary>
|
||||
public static Polygon ComputeFeasibleRegion(Polygon ifp, Polygon[] nfps)
|
||||
{
|
||||
if (nfps == null || nfps.Length == 0)
|
||||
return ifp;
|
||||
|
||||
var nfpPaths = new PathsD(nfps.Length);
|
||||
foreach (var nfp in nfps)
|
||||
{
|
||||
if (nfp.Vertices.Count >= 3)
|
||||
nfpPaths.Add(NoFitPolygon.ToClipperPath(nfp));
|
||||
}
|
||||
|
||||
return ComputeFeasibleRegion(ifp, nfpPaths);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the bottom-left-most point on a polygon boundary.
|
||||
/// "Bottom-left" means: minimize Y first, then minimize X.
|
||||
/// Returns Vector.Invalid if the polygon has no vertices.
|
||||
/// </summary>
|
||||
public static Vector FindBottomLeftPoint(Polygon polygon)
|
||||
{
|
||||
if (polygon.Vertices.Count == 0)
|
||||
return Vector.Invalid;
|
||||
|
||||
var best = polygon.Vertices[0];
|
||||
|
||||
for (var i = 1; i < polygon.Vertices.Count; i++)
|
||||
{
|
||||
var v = polygon.Vertices[i];
|
||||
|
||||
if (v.Y < best.Y || (v.Y == best.Y && v.X < best.X))
|
||||
best = v;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -18,8 +18,19 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
pts = pts.Where(pt =>
|
||||
Angle.IsBetweenRad(arc1.Center.AngleTo(pt), arc1.StartAngle, arc1.EndAngle, arc1.IsReversed) &&
|
||||
Angle.IsBetweenRad(arc2.Center.AngleTo(pt), arc2.StartAngle, arc2.EndAngle, arc2.IsReversed))
|
||||
Angle.IsBetweenRad(
|
||||
arc1.Center.AngleTo(pt),
|
||||
arc1.StartAngle,
|
||||
arc1.EndAngle,
|
||||
arc1.IsReversed
|
||||
)
|
||||
&& Angle.IsBetweenRad(
|
||||
arc2.Center.AngleTo(pt),
|
||||
arc2.StartAngle,
|
||||
arc2.EndAngle,
|
||||
arc2.IsReversed
|
||||
)
|
||||
)
|
||||
.ToList();
|
||||
|
||||
return pts.Count > 0;
|
||||
@@ -35,11 +46,15 @@ namespace OpenNest.Geometry
|
||||
return false;
|
||||
}
|
||||
|
||||
pts = pts.Where(pt => Angle.IsBetweenRad(
|
||||
arc.Center.AngleTo(pt),
|
||||
arc.StartAngle,
|
||||
arc.EndAngle,
|
||||
arc.IsReversed)).ToList();
|
||||
pts = pts.Where(pt =>
|
||||
Angle.IsBetweenRad(
|
||||
arc.Center.AngleTo(pt),
|
||||
arc.StartAngle,
|
||||
arc.EndAngle,
|
||||
arc.IsReversed
|
||||
)
|
||||
)
|
||||
.ToList();
|
||||
|
||||
return pts.Count > 0;
|
||||
}
|
||||
@@ -54,11 +69,15 @@ namespace OpenNest.Geometry
|
||||
return false;
|
||||
}
|
||||
|
||||
pts = pts.Where(pt => Angle.IsBetweenRad(
|
||||
arc.Center.AngleTo(pt),
|
||||
arc.StartAngle,
|
||||
arc.EndAngle,
|
||||
arc.IsReversed)).ToList();
|
||||
pts = pts.Where(pt =>
|
||||
Angle.IsBetweenRad(
|
||||
arc.Center.AngleTo(pt),
|
||||
arc.StartAngle,
|
||||
arc.EndAngle,
|
||||
arc.IsReversed
|
||||
)
|
||||
)
|
||||
.ToList();
|
||||
|
||||
return pts.Count > 0;
|
||||
}
|
||||
@@ -74,11 +93,15 @@ namespace OpenNest.Geometry
|
||||
pts2.AddRange(pts3);
|
||||
}
|
||||
|
||||
pts = pts2.Where(pt => Angle.IsBetweenRad(
|
||||
arc.Center.AngleTo(pt),
|
||||
arc.StartAngle,
|
||||
arc.EndAngle,
|
||||
arc.IsReversed)).ToList();
|
||||
pts = pts2.Where(pt =>
|
||||
Angle.IsBetweenRad(
|
||||
arc.Center.AngleTo(pt),
|
||||
arc.StartAngle,
|
||||
arc.EndAngle,
|
||||
arc.IsReversed
|
||||
)
|
||||
)
|
||||
.ToList();
|
||||
|
||||
return pts.Count > 0;
|
||||
}
|
||||
@@ -95,11 +118,15 @@ namespace OpenNest.Geometry
|
||||
pts2.AddRange(pts3);
|
||||
}
|
||||
|
||||
pts = pts2.Where(pt => Angle.IsBetweenRad(
|
||||
arc.Center.AngleTo(pt),
|
||||
arc.StartAngle,
|
||||
arc.EndAngle,
|
||||
arc.IsReversed)).ToList();
|
||||
pts = pts2.Where(pt =>
|
||||
Angle.IsBetweenRad(
|
||||
arc.Center.AngleTo(pt),
|
||||
arc.StartAngle,
|
||||
arc.EndAngle,
|
||||
arc.IsReversed
|
||||
)
|
||||
)
|
||||
.ToList();
|
||||
|
||||
return pts.Count > 0;
|
||||
}
|
||||
@@ -123,20 +150,22 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
var d = circle2.Center - circle1.Center;
|
||||
var a = (circle1.Radius * circle1.Radius - circle2.Radius * circle2.Radius + distance * distance) / (2.0 * distance);
|
||||
var a =
|
||||
(
|
||||
circle1.Radius * circle1.Radius
|
||||
- circle2.Radius * circle2.Radius
|
||||
+ distance * distance
|
||||
) / (2.0 * distance);
|
||||
var h = System.Math.Sqrt(circle1.Radius * circle1.Radius - a * a);
|
||||
|
||||
var pt = new Vector(
|
||||
circle1.Center.X + (a * d.X) / distance,
|
||||
circle1.Center.Y + (a * d.Y) / distance);
|
||||
circle1.Center.Y + (a * d.Y) / distance
|
||||
);
|
||||
|
||||
var i1 = new Vector(
|
||||
pt.X + (h * d.Y) / distance,
|
||||
pt.Y - (h * d.X) / distance);
|
||||
var i1 = new Vector(pt.X + (h * d.Y) / distance, pt.Y - (h * d.X) / distance);
|
||||
|
||||
var i2 = new Vector(
|
||||
pt.X - (h * d.Y) / distance,
|
||||
pt.Y + (h * d.X) / distance);
|
||||
var i2 = new Vector(pt.X - (h * d.Y) / distance, pt.Y + (h * d.X) / distance);
|
||||
|
||||
pts = i1 != i2 ? new List<Vector> { i1, i2 } : new List<Vector> { i1 };
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace OpenNest.Geometry
|
||||
public static readonly Layer Default = new Layer("0")
|
||||
{
|
||||
Color = Color.White,
|
||||
IsVisible = true
|
||||
IsVisible = true,
|
||||
};
|
||||
|
||||
public Layer(string name)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -9,14 +9,10 @@ namespace OpenNest.Geometry
|
||||
internal Vector pt1;
|
||||
internal Vector pt2;
|
||||
|
||||
public Line()
|
||||
{
|
||||
}
|
||||
public Line() { }
|
||||
|
||||
public Line(double x1, double y1, double x2, double y2)
|
||||
: this(new Vector(x1, y1), new Vector(x2, y2))
|
||||
{
|
||||
}
|
||||
: this(new Vector(x1, y1), new Vector(x2, y2)) { }
|
||||
|
||||
public Line(Vector startPoint, Vector endPoint)
|
||||
{
|
||||
@@ -83,9 +79,7 @@ namespace OpenNest.Geometry
|
||||
return EndPoint;
|
||||
else
|
||||
{
|
||||
return new Vector(
|
||||
StartPoint.X + param * diff2.X,
|
||||
StartPoint.Y + param * diff2.Y);
|
||||
return new Vector(StartPoint.X + param * diff2.X, StartPoint.Y + param * diff2.Y);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +366,7 @@ namespace OpenNest.Geometry
|
||||
/// <summary>
|
||||
/// Updates the bounding box.
|
||||
/// </summary>
|
||||
public override sealed void UpdateBounds()
|
||||
public sealed override void UpdateBounds()
|
||||
{
|
||||
if (StartPoint.X < EndPoint.X)
|
||||
{
|
||||
@@ -404,11 +398,9 @@ namespace OpenNest.Geometry
|
||||
var x = System.Math.Cos(angle) * distance;
|
||||
var y = System.Math.Sin(angle) * distance;
|
||||
|
||||
var pt = new Vector(x, y);
|
||||
var pt = side == OffsetSide.Left ? new Vector(x, y) : new Vector(-x, -y);
|
||||
|
||||
return side == OffsetSide.Left
|
||||
? new Line(StartPoint + pt, EndPoint + pt)
|
||||
: new Line(EndPoint + pt, StartPoint + pt);
|
||||
return new Line(StartPoint + pt, EndPoint + pt);
|
||||
}
|
||||
|
||||
public override Entity OffsetEntity(double distance, Vector pt)
|
||||
@@ -429,13 +421,13 @@ namespace OpenNest.Geometry
|
||||
/// <returns>A tuple of (first, second) sub-lines.</returns>
|
||||
public (Line first, Line second) SplitAt(Vector point)
|
||||
{
|
||||
var first = point.DistanceTo(StartPoint) < Tolerance.Epsilon
|
||||
? null
|
||||
: new Line(StartPoint, point);
|
||||
var first =
|
||||
point.DistanceTo(StartPoint) < Tolerance.Epsilon
|
||||
? null
|
||||
: new Line(StartPoint, point);
|
||||
|
||||
var second = point.DistanceTo(EndPoint) < Tolerance.Epsilon
|
||||
? null
|
||||
: new Line(point, EndPoint);
|
||||
var second =
|
||||
point.DistanceTo(EndPoint) < Tolerance.Epsilon ? null : new Line(point, EndPoint);
|
||||
|
||||
return (first, second);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -11,21 +11,71 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
public static class NoFitPolygon
|
||||
{
|
||||
private const double ClipperScale = 1000.0;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the NFP between a stationary polygon A and an orbiting polygon B.
|
||||
/// NFP(A, B) = Minkowski sum of A and -B (B reflected through its reference point).
|
||||
/// Computes forbidden translations of moving around stationary. Interior means
|
||||
/// overlap and boundary means touch, subject to Clipper rounding at precision.
|
||||
/// Inputs are simple filled perimeters, with either winding and optional closing
|
||||
/// vertices. Cutouts are not supported: use Collision for hole-aware decisions.
|
||||
/// The moving reference point is the origin, not its first vertex. Cache this
|
||||
/// CPU preparation result. Rings with fewer than three vertices produce no region.
|
||||
/// </summary>
|
||||
public static Polygon Compute(Polygon stationary, Polygon orbiting)
|
||||
public static PathsD Compute(PathD stationary, PathD moving, int precision = ClipperBridge.Precision)
|
||||
{
|
||||
var reflected = Reflect(orbiting);
|
||||
return MinkowskiSum(stationary, reflected);
|
||||
var a = Normalize(stationary);
|
||||
var b = Normalize(moving);
|
||||
if (a.Count < 3 || b.Count < 3)
|
||||
return new PathsD();
|
||||
|
||||
if (IsConvex(a) && IsConvex(b))
|
||||
return new PathsD { ClipperBridge.ToPath(ComputeConvex(
|
||||
ClipperBridge.ToPolygon(a), ClipperBridge.ToPolygon(b)), true) };
|
||||
|
||||
var negB = new PathD(b.Count);
|
||||
foreach (var point in b)
|
||||
negB.Add(new PointD(-point.x, -point.y));
|
||||
// The boundary sweep alone misses both kinds of containment.
|
||||
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));
|
||||
return Clipper.Union(sweep, new PathsD(), FillRule.NonZero, precision);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optimized version of Compute for polygons known to be convex.
|
||||
/// Bypasses expensive triangulation and Clipper unions.
|
||||
/// Computes forbidden origin translations for two filled, lines-only perimeters.
|
||||
/// Cutouts are not supported; use Collision for hole-aware decisions.
|
||||
/// </summary>
|
||||
public static PathsD Compute(Polygon stationary, Polygon moving) =>
|
||||
Compute(ClipperBridge.ToPath(stationary, true), ClipperBridge.ToPath(moving, true));
|
||||
|
||||
private static PathD Normalize(PathD source)
|
||||
{
|
||||
var path = new PathD();
|
||||
foreach (var point in source)
|
||||
if (path.Count == 0 || path[path.Count - 1].x != point.x || path[path.Count - 1].y != point.y)
|
||||
path.Add(point);
|
||||
if (path.Count > 1 && path[0].x == path[path.Count - 1].x && path[0].y == path[path.Count - 1].y)
|
||||
path.RemoveAt(path.Count - 1);
|
||||
if (!Clipper.IsPositive(path))
|
||||
path.Reverse();
|
||||
return path;
|
||||
}
|
||||
|
||||
private static bool IsConvex(PathD path)
|
||||
{
|
||||
for (var i = 0; i < path.Count; i++)
|
||||
{
|
||||
var a = path[i];
|
||||
var b = path[(i + 1) % path.Count];
|
||||
var c = path[(i + 2) % path.Count];
|
||||
if ((b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x) < 0)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the NFP between a convex stationary polygon A and a convex orbiting
|
||||
/// polygon B: the Minkowski sum of A and -B (B reflected through its reference point).
|
||||
/// </summary>
|
||||
public static Polygon ComputeConvex(Polygon stationary, Polygon orbiting)
|
||||
{
|
||||
@@ -48,42 +98,6 @@ namespace OpenNest.Geometry
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the Minkowski sum of two polygons using convex decomposition.
|
||||
/// For convex polygons, uses the direct O(n+m) merge-sort of edge vectors.
|
||||
/// For concave polygons, decomposes into triangles, computes pairwise
|
||||
/// convex Minkowski sums, and unions the results with Clipper2.
|
||||
/// </summary>
|
||||
private static Polygon MinkowskiSum(Polygon a, Polygon b)
|
||||
{
|
||||
var trisA = ConvexDecomposition.Triangulate(a);
|
||||
var trisB = ConvexDecomposition.Triangulate(b);
|
||||
|
||||
if (trisA.Count == 0 || trisB.Count == 0)
|
||||
return new Polygon();
|
||||
|
||||
var partialSums = new List<Polygon>();
|
||||
|
||||
foreach (var ta in trisA)
|
||||
{
|
||||
foreach (var tb in trisB)
|
||||
{
|
||||
var sum = ConvexMinkowskiSum(ta, tb);
|
||||
|
||||
if (sum.Vertices.Count >= 3)
|
||||
partialSums.Add(sum);
|
||||
}
|
||||
}
|
||||
|
||||
if (partialSums.Count == 0)
|
||||
return new Polygon();
|
||||
|
||||
if (partialSums.Count == 1)
|
||||
return partialSums[0];
|
||||
|
||||
return UnionPolygons(partialSums);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the Minkowski sum of two convex polygons by merging their
|
||||
/// edge vectors sorted by angle. O(n+m) where n and m are vertex counts.
|
||||
@@ -99,14 +113,15 @@ namespace OpenNest.Geometry
|
||||
var startB = FindBottomLeft(b);
|
||||
|
||||
var result = new Polygon();
|
||||
|
||||
// The starting point of the Minkowski sum A + B is the sum of the
|
||||
// starting points of A and B. For NFP = A + (-B), this is
|
||||
|
||||
// The starting point of the Minkowski sum A + B is the sum of the
|
||||
// starting points of A and B. For NFP = A + (-B), this is
|
||||
// startA + startReflectedB.
|
||||
var current = new Vector(
|
||||
a.Vertices[startA].X + b.Vertices[startB].X,
|
||||
a.Vertices[startA].Y + b.Vertices[startB].Y);
|
||||
|
||||
a.Vertices[startA].Y + b.Vertices[startB].Y
|
||||
);
|
||||
|
||||
result.Vertices.Add(current);
|
||||
|
||||
var ia = 0;
|
||||
@@ -132,10 +147,12 @@ namespace OpenNest.Geometry
|
||||
else
|
||||
{
|
||||
var angleA = System.Math.Atan2(orderedA[ia].Y, orderedA[ia].X);
|
||||
if (angleA < 0) angleA += Angle.TwoPI;
|
||||
if (angleA < 0)
|
||||
angleA += Angle.TwoPI;
|
||||
|
||||
var angleB = System.Math.Atan2(orderedB[ib].Y, orderedB[ib].X);
|
||||
if (angleB < 0) angleB += Angle.TwoPI;
|
||||
if (angleB < 0)
|
||||
angleB += Angle.TwoPI;
|
||||
|
||||
if (angleA < angleB)
|
||||
{
|
||||
@@ -149,7 +166,8 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
edge = new Vector(
|
||||
orderedA[ia].X + orderedB[ib].X,
|
||||
orderedA[ia].Y + orderedB[ib].Y);
|
||||
orderedA[ia].Y + orderedB[ib].Y
|
||||
);
|
||||
ia++;
|
||||
ib++;
|
||||
}
|
||||
@@ -203,8 +221,10 @@ namespace OpenNest.Geometry
|
||||
|
||||
for (var i = 1; i < n; i++)
|
||||
{
|
||||
if (verts[i].Y < verts[best].Y ||
|
||||
(verts[i].Y == verts[best].Y && verts[i].X < verts[best].X))
|
||||
if (
|
||||
verts[i].Y < verts[best].Y
|
||||
|| (verts[i].Y == verts[best].Y && verts[i].X < verts[best].X)
|
||||
)
|
||||
best = i;
|
||||
}
|
||||
|
||||
@@ -224,81 +244,5 @@ namespace OpenNest.Geometry
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unions multiple polygons using Clipper2.
|
||||
/// Returns the outer boundary of the union as a single polygon.
|
||||
/// </summary>
|
||||
internal static Polygon UnionPolygons(List<Polygon> polygons)
|
||||
{
|
||||
var paths = new PathsD();
|
||||
|
||||
foreach (var poly in polygons)
|
||||
{
|
||||
var path = ToClipperPath(poly);
|
||||
|
||||
if (path.Count >= 3)
|
||||
paths.Add(path);
|
||||
}
|
||||
|
||||
if (paths.Count == 0)
|
||||
return new Polygon();
|
||||
|
||||
var result = Clipper.Union(paths, FillRule.NonZero);
|
||||
|
||||
if (result.Count == 0)
|
||||
return new Polygon();
|
||||
|
||||
// Find the largest polygon (by area) as the outer boundary.
|
||||
var largest = result[0];
|
||||
var largestArea = System.Math.Abs(Clipper.Area(largest));
|
||||
|
||||
for (var i = 1; i < result.Count; i++)
|
||||
{
|
||||
var area = System.Math.Abs(Clipper.Area(result[i]));
|
||||
|
||||
if (area > largestArea)
|
||||
{
|
||||
largest = result[i];
|
||||
largestArea = area;
|
||||
}
|
||||
}
|
||||
|
||||
return FromClipperPath(largest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OpenNest Polygon to a Clipper2 PathD, with an optional offset.
|
||||
/// </summary>
|
||||
public static PathD ToClipperPath(Polygon polygon, Vector offset = default)
|
||||
{
|
||||
var path = new PathD();
|
||||
var verts = polygon.Vertices;
|
||||
var n = verts.Count;
|
||||
|
||||
// Skip closing vertex if present.
|
||||
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
|
||||
n--;
|
||||
|
||||
for (var i = 0; i < n; i++)
|
||||
path.Add(new PointD(verts[i].X + offset.X, verts[i].Y + offset.Y));
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Clipper2 PathD to an OpenNest Polygon.
|
||||
/// </summary>
|
||||
public static Polygon FromClipperPath(PathD path)
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
|
||||
foreach (var pt in path)
|
||||
polygon.Vertices.Add(new Vector(pt.x, pt.y));
|
||||
|
||||
polygon.Close();
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
public static class PolyLabel
|
||||
{
|
||||
public static Vector Find(Polygon outer, IList<Polygon> holes = null, double precision = 0.5)
|
||||
public static Vector Find(
|
||||
Polygon outer,
|
||||
IList<Polygon> holes = null,
|
||||
double precision = 0.5
|
||||
)
|
||||
{
|
||||
if (outer.Vertices.Count < 3)
|
||||
return outer.Vertices.Count > 0
|
||||
? outer.Vertices[0]
|
||||
: new Vector();
|
||||
return outer.Vertices.Count > 0 ? outer.Vertices[0] : new Vector();
|
||||
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
@@ -19,10 +21,14 @@ namespace OpenNest.Geometry
|
||||
for (var i = 0; i < outer.Vertices.Count; i++)
|
||||
{
|
||||
var v = outer.Vertices[i];
|
||||
if (v.X < minX) minX = v.X;
|
||||
if (v.Y < minY) minY = v.Y;
|
||||
if (v.X > maxX) maxX = v.X;
|
||||
if (v.Y > maxY) maxY = v.Y;
|
||||
if (v.X < minX)
|
||||
minX = v.X;
|
||||
if (v.Y < minY)
|
||||
minY = v.Y;
|
||||
if (v.X > maxX)
|
||||
maxX = v.X;
|
||||
if (v.Y > maxY)
|
||||
maxY = v.Y;
|
||||
}
|
||||
|
||||
var width = maxX - minX;
|
||||
@@ -37,8 +43,8 @@ namespace OpenNest.Geometry
|
||||
var queue = new List<Cell>();
|
||||
|
||||
for (var x = minX; x < maxX; x += cellSize)
|
||||
for (var y = minY; y < maxY; y += cellSize)
|
||||
queue.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes));
|
||||
for (var y = minY; y < maxY; y += cellSize)
|
||||
queue.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes));
|
||||
|
||||
queue.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist));
|
||||
|
||||
@@ -194,7 +200,12 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
}
|
||||
|
||||
private static double PointToAllEdgesDist(double x, double y, Polygon outer, IList<Polygon> holes)
|
||||
private static double PointToAllEdgesDist(
|
||||
double x,
|
||||
double y,
|
||||
Polygon outer,
|
||||
IList<Polygon> holes
|
||||
)
|
||||
{
|
||||
var minDist = PointToPolygonDist(x, y, outer);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -107,7 +107,9 @@ namespace OpenNest.Geometry
|
||||
public RotationType RotationDirection()
|
||||
{
|
||||
if (Vertices.Count < 3)
|
||||
throw new Exception("Not enough points to determine direction. Must have at least 3 points.");
|
||||
throw new Exception(
|
||||
"Not enough points to determine direction. Must have at least 3 points."
|
||||
);
|
||||
|
||||
return CalculateArea() > 0 ? RotationType.CCW : RotationType.CW;
|
||||
}
|
||||
@@ -309,11 +311,15 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
var vertex = Vertices[i];
|
||||
|
||||
if (vertex.X < minX) minX = vertex.X;
|
||||
else if (vertex.X > maxX) maxX = vertex.X;
|
||||
if (vertex.X < minX)
|
||||
minX = vertex.X;
|
||||
else if (vertex.X > maxX)
|
||||
maxX = vertex.X;
|
||||
|
||||
if (vertex.Y < minY) minY = vertex.Y;
|
||||
else if (vertex.Y > maxY) maxY = vertex.Y;
|
||||
if (vertex.Y < minY)
|
||||
minY = vertex.Y;
|
||||
else if (vertex.Y > maxY)
|
||||
maxY = vertex.Y;
|
||||
}
|
||||
|
||||
boundingBox.X = minX;
|
||||
@@ -322,55 +328,29 @@ namespace OpenNest.Geometry
|
||||
boundingBox.Width = maxY - minY;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Miter-offsets the closed polygon to the given side, keeping its winding.
|
||||
/// Corners sharper than the miter limit are squared off, and features that
|
||||
/// collapse under the offset are dropped. When the offset splits the polygon,
|
||||
/// the largest piece is returned.
|
||||
/// </summary>
|
||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||
{
|
||||
if (Vertices.Count < 3)
|
||||
return null;
|
||||
|
||||
var isClosed = IsClosed();
|
||||
var count = isClosed ? Vertices.Count - 1 : Vertices.Count;
|
||||
if (count < 3)
|
||||
return null;
|
||||
|
||||
var ccw = CalculateArea() > 0;
|
||||
var outward = ccw ? OffsetSide.Left : OffsetSide.Right;
|
||||
var sign = side == outward ? 1.0 : -1.0;
|
||||
var d = distance * sign;
|
||||
var delta = side == outward ? distance : -distance;
|
||||
|
||||
var normals = new Vector[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var next = (i + 1) % count;
|
||||
var dx = Vertices[next].X - Vertices[i].X;
|
||||
var dy = Vertices[next].Y - Vertices[i].Y;
|
||||
var len = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (len < Tolerance.Epsilon)
|
||||
return null;
|
||||
normals[i] = new Vector(-dy / len * d, dx / len * d);
|
||||
}
|
||||
var result = ClipperBridge.OffsetMiter(this, delta);
|
||||
|
||||
var result = new Polygon();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var prev = (i - 1 + count) % count;
|
||||
if (result == null)
|
||||
return null;
|
||||
|
||||
var a1 = new Vector(Vertices[prev].X + normals[prev].X, Vertices[prev].Y + normals[prev].Y);
|
||||
var a2 = new Vector(Vertices[i].X + normals[prev].X, Vertices[i].Y + normals[prev].Y);
|
||||
var b1 = new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y);
|
||||
var b2 = new Vector(Vertices[(i + 1) % count].X + normals[i].X, Vertices[(i + 1) % count].Y + normals[i].Y);
|
||||
if (!ccw)
|
||||
result.Reverse();
|
||||
|
||||
var edgeA = new Line(a1, a2);
|
||||
var edgeB = new Line(b1, b2);
|
||||
|
||||
if (edgeA.Intersects(edgeB, out var pt) && pt.IsValid())
|
||||
result.Vertices.Add(pt);
|
||||
else
|
||||
result.Vertices.Add(new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y));
|
||||
}
|
||||
|
||||
result.Close();
|
||||
result.RemoveSelfIntersections();
|
||||
result.UpdateBounds();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -379,8 +359,10 @@ namespace OpenNest.Geometry
|
||||
var left = OffsetEntity(distance, OffsetSide.Left);
|
||||
var right = OffsetEntity(distance, OffsetSide.Right);
|
||||
|
||||
if (left == null) return right;
|
||||
if (right == null) return left;
|
||||
if (left == null)
|
||||
return right;
|
||||
if (right == null)
|
||||
return left;
|
||||
|
||||
var distLeft = left.ClosestPointTo(pt).DistanceTo(pt);
|
||||
var distRight = right.ClosestPointTo(pt).DistanceTo(pt);
|
||||
@@ -537,133 +519,6 @@ namespace OpenNest.Geometry
|
||||
get { return EntityType.Polygon; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes self-intersecting loops from the polygon by finding non-adjacent
|
||||
/// edge crossings and keeping the larger contour at each crossing.
|
||||
/// </summary>
|
||||
public void RemoveSelfIntersections()
|
||||
{
|
||||
if (!IsClosed() || Vertices.Count < 5)
|
||||
return;
|
||||
|
||||
while (FindCrossing(out var edgeI, out var edgeJ, out var pt))
|
||||
{
|
||||
Vertices = SplitAtCrossing(edgeI, edgeJ, pt);
|
||||
}
|
||||
}
|
||||
|
||||
private bool FindCrossing(out int edgeI, out int edgeJ, out Vector pt)
|
||||
{
|
||||
var n = Vertices.Count - 1;
|
||||
|
||||
// Pre-calculate edge bounding boxes to speed up intersection checks.
|
||||
var edgeBounds = new (double minX, double maxX, double minY, double maxY)[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var v1 = Vertices[i];
|
||||
var v2 = Vertices[i + 1];
|
||||
edgeBounds[i] = (
|
||||
System.Math.Min(v1.X, v2.X) - Tolerance.Epsilon,
|
||||
System.Math.Max(v1.X, v2.X) + Tolerance.Epsilon,
|
||||
System.Math.Min(v1.Y, v2.Y) - Tolerance.Epsilon,
|
||||
System.Math.Max(v1.Y, v2.Y) + Tolerance.Epsilon
|
||||
);
|
||||
}
|
||||
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var bi = edgeBounds[i];
|
||||
for (var j = i + 2; j < n; j++)
|
||||
{
|
||||
if (i == 0 && j == n - 1)
|
||||
continue;
|
||||
|
||||
var bj = edgeBounds[j];
|
||||
|
||||
// Prune with bounding box check.
|
||||
if (bi.maxX < bj.minX || bj.maxX < bi.minX ||
|
||||
bi.maxY < bj.minY || bj.maxY < bi.minY)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SegmentsIntersect(Vertices[i], Vertices[i + 1], Vertices[j], Vertices[j + 1], out pt))
|
||||
{
|
||||
edgeI = i;
|
||||
edgeJ = j;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
edgeI = edgeJ = -1;
|
||||
pt = Vector.Zero;
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<Vector> SplitAtCrossing(int edgeI, int edgeJ, Vector pt)
|
||||
{
|
||||
var n = Vertices.Count - 1;
|
||||
|
||||
var loopA = Vertices.GetRange(0, edgeI + 1);
|
||||
loopA.Add(pt);
|
||||
loopA.AddRange(Vertices.GetRange(edgeJ + 1, n - edgeJ - 1));
|
||||
loopA.Add(loopA[0]);
|
||||
|
||||
var loopB = new List<Vector> { pt };
|
||||
loopB.AddRange(Vertices.GetRange(edgeI + 1, edgeJ - edgeI));
|
||||
loopB.Add(pt);
|
||||
|
||||
var areaA = System.Math.Abs(CalculateArea(loopA));
|
||||
var areaB = System.Math.Abs(CalculateArea(loopB));
|
||||
|
||||
return areaA >= areaB ? loopA : loopB;
|
||||
}
|
||||
|
||||
private static bool SegmentsIntersect(Vector a1, Vector a2, Vector b1, Vector b2, out Vector pt)
|
||||
{
|
||||
var da = a2 - a1;
|
||||
var db = b2 - b1;
|
||||
var cross = da.X * db.Y - da.Y * db.X;
|
||||
|
||||
if (cross.IsEqualTo(0.0))
|
||||
{
|
||||
pt = Vector.Zero;
|
||||
return false;
|
||||
}
|
||||
|
||||
var dc = b1 - a1;
|
||||
var t = (dc.X * db.Y - dc.Y * db.X) / cross;
|
||||
var u = (dc.X * da.Y - dc.Y * da.X) / cross;
|
||||
|
||||
if (t > Tolerance.Epsilon && t < 1.0 - Tolerance.Epsilon &&
|
||||
u > Tolerance.Epsilon && u < 1.0 - Tolerance.Epsilon)
|
||||
{
|
||||
pt = new Vector(a1.X + t * da.X, a1.Y + t * da.Y);
|
||||
return true;
|
||||
}
|
||||
|
||||
pt = Vector.Zero;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double CalculateArea(List<Vector> vertices)
|
||||
{
|
||||
double xsum = 0;
|
||||
double ysum = 0;
|
||||
|
||||
for (int i = 0; i < vertices.Count - 1; i++)
|
||||
{
|
||||
var current = vertices[i];
|
||||
var next = vertices[i + 1];
|
||||
|
||||
xsum += current.X * next.Y;
|
||||
ysum += current.Y * next.X;
|
||||
}
|
||||
|
||||
return (xsum - ysum) * 0.5;
|
||||
}
|
||||
|
||||
internal void Cleanup()
|
||||
{
|
||||
for (int i = Vertices.Count - 1; i > 0; i--)
|
||||
@@ -701,8 +556,10 @@ namespace OpenNest.Geometry
|
||||
var vi = Vertices[i];
|
||||
var vj = Vertices[j];
|
||||
|
||||
if ((vi.Y > pt.Y) != (vj.Y > pt.Y) &&
|
||||
pt.X < (vj.X - vi.X) * (pt.Y - vi.Y) / (vj.Y - vi.Y) + vi.X)
|
||||
if (
|
||||
(vi.Y > pt.Y) != (vj.Y > pt.Y)
|
||||
&& pt.X < (vj.X - vi.X) * (pt.Y - vi.Y) / (vj.Y - vi.Y) + vi.X
|
||||
)
|
||||
{
|
||||
inside = !inside;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -71,18 +71,24 @@ namespace OpenNest.Geometry
|
||||
var vy = ux;
|
||||
|
||||
// Project all hull vertices onto edge direction (u) and perpendicular (v)
|
||||
double minU = double.MaxValue, maxU = double.MinValue;
|
||||
double minV = double.MaxValue, maxV = double.MinValue;
|
||||
double minU = double.MaxValue,
|
||||
maxU = double.MinValue;
|
||||
double minV = double.MaxValue,
|
||||
maxV = double.MinValue;
|
||||
|
||||
for (int j = 0; j < n; j++)
|
||||
{
|
||||
var projU = vertices[j].X * ux + vertices[j].Y * uy;
|
||||
var projV = vertices[j].X * vx + vertices[j].Y * vy;
|
||||
|
||||
if (projU < minU) minU = projU;
|
||||
if (projU > maxU) maxU = projU;
|
||||
if (projV < minV) minV = projV;
|
||||
if (projV > maxV) maxV = projV;
|
||||
if (projU < minU)
|
||||
minU = projU;
|
||||
if (projU > maxU)
|
||||
maxU = projU;
|
||||
if (projV < minV)
|
||||
minV = projV;
|
||||
if (projV > maxV)
|
||||
maxV = projV;
|
||||
}
|
||||
|
||||
var width = maxU - minU;
|
||||
@@ -99,7 +105,11 @@ namespace OpenNest.Geometry
|
||||
return best ?? new BoundingRectangleResult(0, 0, 0);
|
||||
}
|
||||
|
||||
public static BoundingRectangleResult MinimumBoundingRectangle(Polygon hull, double startAngle, double endAngle)
|
||||
public static BoundingRectangleResult MinimumBoundingRectangle(
|
||||
Polygon hull,
|
||||
double startAngle,
|
||||
double endAngle
|
||||
)
|
||||
{
|
||||
var vertices = hull.Vertices;
|
||||
int n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count;
|
||||
@@ -153,23 +163,33 @@ namespace OpenNest.Geometry
|
||||
return best ?? new BoundingRectangleResult(startAngle, 0, 0);
|
||||
}
|
||||
|
||||
private static BoundingRectangleResult EvaluateAtAngle(IList<Vector> vertices, int n, double angle)
|
||||
private static BoundingRectangleResult EvaluateAtAngle(
|
||||
IList<Vector> vertices,
|
||||
int n,
|
||||
double angle
|
||||
)
|
||||
{
|
||||
var cos = System.Math.Cos(angle);
|
||||
var sin = System.Math.Sin(angle);
|
||||
|
||||
double minU = double.MaxValue, maxU = double.MinValue;
|
||||
double minV = double.MaxValue, maxV = double.MinValue;
|
||||
double minU = double.MaxValue,
|
||||
maxU = double.MinValue;
|
||||
double minV = double.MaxValue,
|
||||
maxV = double.MinValue;
|
||||
|
||||
for (int j = 0; j < n; j++)
|
||||
{
|
||||
var projU = vertices[j].X * cos + vertices[j].Y * sin;
|
||||
var projV = -vertices[j].X * sin + vertices[j].Y * cos;
|
||||
|
||||
if (projU < minU) minU = projU;
|
||||
if (projU > maxU) maxU = projU;
|
||||
if (projV < minV) minV = projV;
|
||||
if (projV > maxV) maxV = projV;
|
||||
if (projU < minU)
|
||||
minU = projU;
|
||||
if (projU > maxU)
|
||||
maxU = projU;
|
||||
if (projV < minV)
|
||||
minV = projV;
|
||||
if (projV > maxV)
|
||||
maxV = projV;
|
||||
}
|
||||
|
||||
var width = maxU - minU;
|
||||
|
||||
+250
-78
@@ -282,11 +282,7 @@ namespace OpenNest.Geometry
|
||||
|
||||
case EntityType.Line:
|
||||
var line = (Line)entity;
|
||||
polygon.Vertices.AddRange(new[]
|
||||
{
|
||||
line.StartPoint,
|
||||
line.EndPoint
|
||||
});
|
||||
polygon.Vertices.AddRange(new[] { line.StartPoint, line.EndPoint });
|
||||
break;
|
||||
|
||||
case EntityType.Circle:
|
||||
@@ -302,6 +298,7 @@ namespace OpenNest.Geometry
|
||||
|
||||
polygon.Close();
|
||||
polygon.Cleanup();
|
||||
polygon.UpdateBounds();
|
||||
|
||||
return polygon;
|
||||
}
|
||||
@@ -320,21 +317,21 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
case EntityType.Arc:
|
||||
var arc = (Arc)entity;
|
||||
polygon.Vertices.AddRange(arc.ToPoints(arc.SegmentsForTolerance(tolerance), circumscribe));
|
||||
polygon.Vertices.AddRange(
|
||||
arc.ToPoints(arc.SegmentsForTolerance(tolerance), circumscribe)
|
||||
);
|
||||
break;
|
||||
|
||||
case EntityType.Line:
|
||||
var line = (Line)entity;
|
||||
polygon.Vertices.AddRange(new[]
|
||||
{
|
||||
line.StartPoint,
|
||||
line.EndPoint
|
||||
});
|
||||
polygon.Vertices.AddRange(new[] { line.StartPoint, line.EndPoint });
|
||||
break;
|
||||
|
||||
case EntityType.Circle:
|
||||
var circle = (Circle)entity;
|
||||
polygon.Vertices.AddRange(circle.ToPoints(circle.SegmentsForTolerance(tolerance), circumscribe));
|
||||
polygon.Vertices.AddRange(
|
||||
circle.ToPoints(circle.SegmentsForTolerance(tolerance), circumscribe)
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -345,6 +342,7 @@ namespace OpenNest.Geometry
|
||||
|
||||
polygon.Close();
|
||||
polygon.Cleanup();
|
||||
polygon.UpdateBounds();
|
||||
|
||||
return polygon;
|
||||
}
|
||||
@@ -462,84 +460,227 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
public override void UpdateBounds()
|
||||
{
|
||||
boundingBox = Entities.Select(geo => geo.BoundingBox)
|
||||
.ToList()
|
||||
.GetBoundingBox();
|
||||
boundingBox = Entities.Select(geo => geo.BoundingBox).ToList().GetBoundingBox();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offsets each perimeter entity to the given side and joins the pieces into a
|
||||
/// closed chain: line-line corners get a round join (convex) or a miter (concave),
|
||||
/// other convex corners get a round join, and any remaining gap (a concave corner
|
||||
/// involving an arc, or an entity that collapsed under the offset) is bridged
|
||||
/// with a line. Cutouts are offset the same way.
|
||||
/// <para>
|
||||
/// Where a feature is narrower than twice the distance, the result keeps zero-area
|
||||
/// spikes and inverted loops. They lie inside the true offset envelope, so they are
|
||||
/// harmless to directional-distance queries, which only need a closed boundary
|
||||
/// that never falls inside the envelope. Use <see cref="ClipperBridge"/> when a
|
||||
/// clean region is needed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||
{
|
||||
var offsetShape = new Shape();
|
||||
var definedShape = new ShapeProfile(this);
|
||||
|
||||
Entity firstEntity = null;
|
||||
Entity firstOffsetEntity = null;
|
||||
Entity lastEntity = null;
|
||||
Entity lastOffsetEntity = null;
|
||||
var pieces = new List<OffsetPiece>();
|
||||
var collapsed = false;
|
||||
|
||||
foreach (var entity in definedShape.Perimeter.Entities)
|
||||
{
|
||||
var offsetEntity = entity.OffsetEntity(distance, side);
|
||||
|
||||
if (offsetEntity == null)
|
||||
{
|
||||
collapsed = true;
|
||||
continue;
|
||||
|
||||
if (firstEntity == null)
|
||||
{
|
||||
firstEntity = entity;
|
||||
firstOffsetEntity = offsetEntity;
|
||||
}
|
||||
|
||||
switch (entity.Type)
|
||||
{
|
||||
case EntityType.Line:
|
||||
{
|
||||
var line = (Line)entity;
|
||||
var offsetLine = (Line)offsetEntity;
|
||||
|
||||
if (lastOffsetEntity != null && lastOffsetEntity.Type == EntityType.Line)
|
||||
{
|
||||
JoinOffsetLines(
|
||||
(Line)lastEntity, (Line)lastOffsetEntity,
|
||||
line, offsetLine,
|
||||
distance, side, offsetShape);
|
||||
}
|
||||
|
||||
offsetShape.Entities.Add(offsetLine);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
offsetShape.Entities.Add(offsetEntity);
|
||||
break;
|
||||
}
|
||||
|
||||
lastOffsetEntity = offsetEntity;
|
||||
lastEntity = entity;
|
||||
pieces.Add(new OffsetPiece(entity, offsetEntity, collapsed));
|
||||
collapsed = false;
|
||||
}
|
||||
|
||||
// Close the shape: join last offset entity back to first
|
||||
if (lastOffsetEntity != null && firstOffsetEntity != null
|
||||
&& lastOffsetEntity != firstOffsetEntity
|
||||
&& lastOffsetEntity.Type == EntityType.Line
|
||||
&& firstOffsetEntity.Type == EntityType.Line)
|
||||
// Entities that collapsed at the end of the loop sit before the first piece.
|
||||
if (collapsed && pieces.Count > 0)
|
||||
pieces[0] = pieces[0] with { CollapsedBefore = true };
|
||||
|
||||
for (var i = 0; i < pieces.Count; i++)
|
||||
{
|
||||
JoinOffsetLines(
|
||||
(Line)lastEntity, (Line)lastOffsetEntity,
|
||||
(Line)firstEntity, (Line)firstOffsetEntity,
|
||||
distance, side, offsetShape);
|
||||
offsetShape.Entities.Add(pieces[i].Offset);
|
||||
|
||||
if (pieces.Count > 1)
|
||||
{
|
||||
JoinOffsetPieces(
|
||||
pieces[i],
|
||||
pieces[(i + 1) % pieces.Count],
|
||||
distance,
|
||||
side,
|
||||
offsetShape
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var cutout in definedShape.Cutouts)
|
||||
offsetShape.Entities.AddRange(((Shape)cutout.OffsetEntity(distance, side)).Entities);
|
||||
offsetShape.Entities.AddRange(
|
||||
((Shape)cutout.OffsetEntity(distance, side)).Entities
|
||||
);
|
||||
|
||||
return offsetShape;
|
||||
}
|
||||
|
||||
private readonly record struct OffsetPiece(
|
||||
Entity Source,
|
||||
Entity Offset,
|
||||
bool CollapsedBefore
|
||||
);
|
||||
|
||||
private static void JoinOffsetPieces(
|
||||
OffsetPiece last,
|
||||
OffsetPiece next,
|
||||
double distance,
|
||||
OffsetSide side,
|
||||
Shape offsetShape
|
||||
)
|
||||
{
|
||||
// Lines meeting across a collapsed fillet are concave, so a miter trims both at
|
||||
// their intersection. Parallel ones (a round-bottomed slot) fall through to
|
||||
// the bridge below.
|
||||
if (
|
||||
next.CollapsedBefore
|
||||
&& last.Offset is Line lastOffsetLine
|
||||
&& next.Offset is Line nextOffsetLine
|
||||
&& Intersect.IntersectsUnbounded(nextOffsetLine, lastOffsetLine, out var miter)
|
||||
)
|
||||
{
|
||||
lastOffsetLine.EndPoint = miter;
|
||||
nextOffsetLine.StartPoint = miter;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!next.CollapsedBefore && last.Source is Line lastLine && next.Source is Line nextLine)
|
||||
{
|
||||
JoinOffsetLines(
|
||||
lastLine,
|
||||
(Line)last.Offset,
|
||||
nextLine,
|
||||
(Line)next.Offset,
|
||||
distance,
|
||||
side,
|
||||
offsetShape
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!TryGetEnds(last.Offset, out _, out var gapStart)
|
||||
|| !TryGetEnds(next.Offset, out var gapEnd, out _)
|
||||
)
|
||||
return;
|
||||
|
||||
if (gapStart.DistanceTo(gapEnd) <= OpenNest.Math.Tolerance.Epsilon)
|
||||
return;
|
||||
|
||||
if (
|
||||
!next.CollapsedBefore
|
||||
&& IsConvexCorner(last.Source, next.Source, side, out var corner)
|
||||
)
|
||||
{
|
||||
offsetShape.Entities.Add(
|
||||
new Arc(
|
||||
corner,
|
||||
distance,
|
||||
corner.AngleTo(gapStart),
|
||||
corner.AngleTo(gapEnd),
|
||||
side == OffsetSide.Left
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Concave corner or collapsed entity: the neighbors' offsets overlap, so a
|
||||
// straight bridge stays inside the offset envelope and closes the chain.
|
||||
offsetShape.Entities.Add(new Line(gapStart, gapEnd));
|
||||
}
|
||||
|
||||
private static bool IsConvexCorner(
|
||||
Entity last,
|
||||
Entity next,
|
||||
OffsetSide side,
|
||||
out Vector corner
|
||||
)
|
||||
{
|
||||
corner = default;
|
||||
|
||||
if (
|
||||
!TryGetEnds(last, out _, out corner)
|
||||
|| !TryGetTangents(last, out _, out var d1)
|
||||
|| !TryGetTangents(next, out var d2, out _)
|
||||
)
|
||||
return false;
|
||||
|
||||
var cross = d1.X * d2.Y - d1.Y * d2.X;
|
||||
|
||||
return (side == OffsetSide.Left && cross < -OpenNest.Math.Tolerance.Epsilon)
|
||||
|| (side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon);
|
||||
}
|
||||
|
||||
private static bool TryGetEnds(Entity entity, out Vector start, out Vector end)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Line line:
|
||||
start = line.StartPoint;
|
||||
end = line.EndPoint;
|
||||
return true;
|
||||
|
||||
case Arc arc:
|
||||
start = arc.StartPoint();
|
||||
end = arc.EndPoint();
|
||||
return true;
|
||||
|
||||
default:
|
||||
start = end = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direction of travel at the start and end of a line or arc.
|
||||
/// </summary>
|
||||
private static bool TryGetTangents(Entity entity, out Vector start, out Vector end)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Line line:
|
||||
start = end = line.EndPoint - line.StartPoint;
|
||||
return true;
|
||||
|
||||
case Arc arc:
|
||||
start = ArcTangent(arc, arc.StartAngle);
|
||||
end = ArcTangent(arc, arc.EndAngle);
|
||||
return true;
|
||||
|
||||
default:
|
||||
start = end = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector ArcTangent(Arc arc, double angle)
|
||||
{
|
||||
var sin = System.Math.Sin(angle);
|
||||
var cos = System.Math.Cos(angle);
|
||||
return arc.IsReversed ? new Vector(sin, -cos) : new Vector(-sin, cos);
|
||||
}
|
||||
|
||||
private static void JoinOffsetLines(
|
||||
Line lastLine, Line lastOffsetLine,
|
||||
Line line, Line offsetLine,
|
||||
double distance, OffsetSide side, Shape offsetShape)
|
||||
Line lastLine,
|
||||
Line lastOffsetLine,
|
||||
Line line,
|
||||
Line offsetLine,
|
||||
double distance,
|
||||
OffsetSide side,
|
||||
Shape offsetShape
|
||||
)
|
||||
{
|
||||
// Determine if this is a convex corner using the cross product of
|
||||
// the original line directions. Convex corners need an arc; concave
|
||||
@@ -548,8 +689,9 @@ namespace OpenNest.Geometry
|
||||
var d2 = line.EndPoint - line.StartPoint;
|
||||
var cross = d1.X * d2.Y - d1.Y * d2.X;
|
||||
|
||||
var isConvex = (side == OffsetSide.Left && cross < -OpenNest.Math.Tolerance.Epsilon) ||
|
||||
(side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon);
|
||||
var isConvex =
|
||||
(side == OffsetSide.Left && cross < -OpenNest.Math.Tolerance.Epsilon)
|
||||
|| (side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon);
|
||||
|
||||
if (isConvex)
|
||||
{
|
||||
@@ -559,11 +701,13 @@ namespace OpenNest.Geometry
|
||||
line.StartPoint.AngleTo(lastOffsetLine.EndPoint),
|
||||
line.StartPoint.AngleTo(offsetLine.StartPoint),
|
||||
side == OffsetSide.Left
|
||||
);
|
||||
);
|
||||
|
||||
offsetShape.Entities.Add(arc);
|
||||
}
|
||||
else if (Intersect.IntersectsUnbounded(offsetLine, lastOffsetLine, out var intersection))
|
||||
else if (
|
||||
Intersect.IntersectsUnbounded(offsetLine, lastOffsetLine, out var intersection)
|
||||
)
|
||||
{
|
||||
offsetLine.StartPoint = intersection;
|
||||
lastOffsetLine.EndPoint = intersection;
|
||||
@@ -576,7 +720,7 @@ namespace OpenNest.Geometry
|
||||
line.StartPoint.AngleTo(lastOffsetLine.EndPoint),
|
||||
line.StartPoint.AngleTo(offsetLine.StartPoint),
|
||||
side == OffsetSide.Left
|
||||
);
|
||||
);
|
||||
|
||||
offsetShape.Entities.Add(arc);
|
||||
}
|
||||
@@ -592,12 +736,15 @@ namespace OpenNest.Geometry
|
||||
/// Normalizes to CW winding before offsetting Left (which is outward for CW),
|
||||
/// making the method independent of the original contour winding direction.
|
||||
/// </summary>
|
||||
public Shape OffsetOutward(double distance)
|
||||
internal Shape OffsetOutward(double distance)
|
||||
{
|
||||
var poly = ToPolygon();
|
||||
|
||||
if (poly == null || poly.Vertices.Count < 3
|
||||
|| poly.RotationDirection() == RotationType.CW)
|
||||
if (
|
||||
poly == null
|
||||
|| poly.Vertices.Count < 3
|
||||
|| poly.RotationDirection() == RotationType.CW
|
||||
)
|
||||
return OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
|
||||
// Shape is CCW — reverse to CW so Left offset goes outward.
|
||||
@@ -611,10 +758,21 @@ namespace OpenNest.Geometry
|
||||
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
|
||||
break;
|
||||
case Arc a:
|
||||
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
|
||||
copy.Entities.Add(
|
||||
new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed)
|
||||
{
|
||||
Layer = a.Layer,
|
||||
}
|
||||
);
|
||||
break;
|
||||
case Circle c:
|
||||
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CW });
|
||||
copy.Entities.Add(
|
||||
new Circle(c.Center, c.Radius)
|
||||
{
|
||||
Layer = c.Layer,
|
||||
Rotation = RotationType.CW,
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -627,12 +785,15 @@ namespace OpenNest.Geometry
|
||||
/// Normalizes to CCW winding before offsetting Left (which is inward for CCW),
|
||||
/// making the method independent of the original contour winding direction.
|
||||
/// </summary>
|
||||
public Shape OffsetInward(double distance)
|
||||
internal Shape OffsetInward(double distance)
|
||||
{
|
||||
var poly = ToPolygon();
|
||||
|
||||
if (poly == null || poly.Vertices.Count < 3
|
||||
|| poly.RotationDirection() == RotationType.CCW)
|
||||
if (
|
||||
poly == null
|
||||
|| poly.Vertices.Count < 3
|
||||
|| poly.RotationDirection() == RotationType.CCW
|
||||
)
|
||||
return OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
|
||||
// Create a reversed copy to avoid mutating shared entity objects.
|
||||
@@ -646,10 +807,21 @@ namespace OpenNest.Geometry
|
||||
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
|
||||
break;
|
||||
case Arc a:
|
||||
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
|
||||
copy.Entities.Add(
|
||||
new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed)
|
||||
{
|
||||
Layer = a.Layer,
|
||||
}
|
||||
);
|
||||
break;
|
||||
case Circle c:
|
||||
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CCW });
|
||||
copy.Entities.Add(
|
||||
new Circle(c.Center, c.Radius)
|
||||
{
|
||||
Layer = c.Layer,
|
||||
Rotation = RotationType.CCW,
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public static class ShapeBuilder
|
||||
{
|
||||
public static List<Shape> GetShapes(IEnumerable<Entity> entities, double? weldTolerance = null)
|
||||
public static List<Shape> GetShapes(
|
||||
IEnumerable<Entity> entities,
|
||||
double? weldTolerance = null
|
||||
)
|
||||
{
|
||||
var lines = new List<Line>();
|
||||
var arcs = new List<Arc>();
|
||||
@@ -141,7 +144,11 @@ namespace OpenNest.Geometry
|
||||
|
||||
private static void AddToGroup(
|
||||
List<List<(Entity entity, bool isStart, Vector point)>> groups,
|
||||
Entity entity, bool isStart, Vector point, double tolerance)
|
||||
Entity entity,
|
||||
bool isStart,
|
||||
Vector point,
|
||||
double tolerance
|
||||
)
|
||||
{
|
||||
foreach (var group in groups)
|
||||
{
|
||||
|
||||
@@ -84,8 +84,7 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
var poly = shape.ToPolygon();
|
||||
|
||||
if (poly != null && poly.Vertices.Count >= 3
|
||||
&& poly.RotationDirection() != desired)
|
||||
if (poly != null && poly.Vertices.Count >= 3 && poly.RotationDirection() != desired)
|
||||
{
|
||||
shape.Reverse();
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ namespace OpenNest.Geometry
|
||||
|
||||
public override string ToString() => $"{Width} x {Length}";
|
||||
|
||||
public string ToString(int decimalPlaces) => $"{System.Math.Round(Width, decimalPlaces)} x {System.Math.Round(Length, decimalPlaces)}";
|
||||
public string ToString(int decimalPlaces) =>
|
||||
$"{System.Math.Round(Width, decimalPlaces)} x {System.Math.Round(Length, decimalPlaces)}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -13,57 +13,72 @@ namespace OpenNest.Geometry
|
||||
private static double RayEdgeDistance(Vector vertex, Line edge, PushDirection direction)
|
||||
{
|
||||
return RayEdgeDistance(
|
||||
vertex.X, vertex.Y,
|
||||
edge.pt1.X, edge.pt1.Y, edge.pt2.X, edge.pt2.Y,
|
||||
direction);
|
||||
vertex.X,
|
||||
vertex.Y,
|
||||
edge.pt1.X,
|
||||
edge.pt1.Y,
|
||||
edge.pt2.X,
|
||||
edge.pt2.Y,
|
||||
direction
|
||||
);
|
||||
}
|
||||
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining
|
||||
)]
|
||||
private static double RayEdgeDistance(
|
||||
double vx, double vy,
|
||||
double p1x, double p1y, double p2x, double p2y,
|
||||
PushDirection direction)
|
||||
double vx,
|
||||
double vy,
|
||||
double p1x,
|
||||
double p1y,
|
||||
double p2x,
|
||||
double p2y,
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case PushDirection.Left:
|
||||
case PushDirection.Right:
|
||||
{
|
||||
var dy = p2y - p1y;
|
||||
if (System.Math.Abs(dy) < Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
var t = (vy - p1y) / dy;
|
||||
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
var ix = p1x + t * (p2x - p1x);
|
||||
var dist = direction == PushDirection.Left ? vx - ix : ix - vx;
|
||||
|
||||
if (dist > Tolerance.Epsilon) return dist;
|
||||
if (dist >= -Tolerance.Epsilon) return 0;
|
||||
{
|
||||
var dy = p2y - p1y;
|
||||
if (System.Math.Abs(dy) < Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
var t = (vy - p1y) / dy;
|
||||
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
var ix = p1x + t * (p2x - p1x);
|
||||
var dist = direction == PushDirection.Left ? vx - ix : ix - vx;
|
||||
|
||||
if (dist > Tolerance.Epsilon)
|
||||
return dist;
|
||||
if (dist >= -Tolerance.Epsilon)
|
||||
return 0;
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
case PushDirection.Down:
|
||||
case PushDirection.Up:
|
||||
{
|
||||
var dx = p2x - p1x;
|
||||
if (System.Math.Abs(dx) < Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
var t = (vx - p1x) / dx;
|
||||
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
var iy = p1y + t * (p2y - p1y);
|
||||
var dist = direction == PushDirection.Down ? vy - iy : iy - vy;
|
||||
|
||||
if (dist > Tolerance.Epsilon) return dist;
|
||||
if (dist >= -Tolerance.Epsilon) return 0;
|
||||
{
|
||||
var dx = p2x - p1x;
|
||||
if (System.Math.Abs(dx) < Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
var t = (vx - p1x) / dx;
|
||||
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
var iy = p1y + t * (p2y - p1y);
|
||||
var dist = direction == PushDirection.Down ? vy - iy : iy - vy;
|
||||
|
||||
if (dist > Tolerance.Epsilon)
|
||||
return dist;
|
||||
if (dist >= -Tolerance.Epsilon)
|
||||
return 0;
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
default:
|
||||
return double.MaxValue;
|
||||
@@ -75,11 +90,18 @@ namespace OpenNest.Geometry
|
||||
/// Returns double.MaxValue if the ray does not hit the segment.
|
||||
/// </summary>
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining
|
||||
)]
|
||||
public static double RayEdgeDistance(
|
||||
double vx, double vy,
|
||||
double p1x, double p1y, double p2x, double p2y,
|
||||
double dirX, double dirY)
|
||||
double vx,
|
||||
double vy,
|
||||
double p1x,
|
||||
double p1y,
|
||||
double p2x,
|
||||
double p2y,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
{
|
||||
var ex = p2x - p1x;
|
||||
var ey = p2y - p1y;
|
||||
@@ -99,8 +121,10 @@ namespace OpenNest.Geometry
|
||||
if (s < -Tolerance.Epsilon || s > 1.0 + Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
if (t > Tolerance.Epsilon) return t;
|
||||
if (t >= -Tolerance.Epsilon) return 0;
|
||||
if (t > Tolerance.Epsilon)
|
||||
return t;
|
||||
if (t >= -Tolerance.Epsilon)
|
||||
return 0;
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
@@ -109,12 +133,19 @@ namespace OpenNest.Geometry
|
||||
/// Returns false if no real intersection exists.
|
||||
/// </summary>
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining
|
||||
)]
|
||||
private static bool SolveRayCircle(
|
||||
double vx, double vy,
|
||||
double cx, double cy, double r,
|
||||
double dirX, double dirY,
|
||||
out double t1, out double t2)
|
||||
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;
|
||||
@@ -143,12 +174,20 @@ namespace OpenNest.Geometry
|
||||
/// angular span. Returns double.MaxValue if no hit.
|
||||
/// </summary>
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining
|
||||
)]
|
||||
public static double RayArcDistance(
|
||||
double vx, double vy,
|
||||
double cx, double cy, double r,
|
||||
double startAngle, double endAngle, bool reversed,
|
||||
double dirX, double dirY)
|
||||
double vx,
|
||||
double vy,
|
||||
double cx,
|
||||
double cy,
|
||||
double r,
|
||||
double startAngle,
|
||||
double endAngle,
|
||||
bool reversed,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
{
|
||||
if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
|
||||
return double.MaxValue;
|
||||
@@ -157,16 +196,18 @@ namespace OpenNest.Geometry
|
||||
|
||||
if (t1 > -Tolerance.Epsilon)
|
||||
{
|
||||
var hitAngle = Angle.NormalizeRad(System.Math.Atan2(
|
||||
vy + t1 * dirY - cy, vx + t1 * dirX - cx));
|
||||
var hitAngle = Angle.NormalizeRad(
|
||||
System.Math.Atan2(vy + t1 * dirY - cy, vx + t1 * dirX - cx)
|
||||
);
|
||||
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
|
||||
best = t1 > Tolerance.Epsilon ? t1 : 0;
|
||||
}
|
||||
|
||||
if (t2 > -Tolerance.Epsilon && t2 < best)
|
||||
{
|
||||
var hitAngle = Angle.NormalizeRad(System.Math.Atan2(
|
||||
vy + t2 * dirY - cy, vx + t2 * dirX - cx));
|
||||
var hitAngle = Angle.NormalizeRad(
|
||||
System.Math.Atan2(vy + t2 * dirY - cy, vx + t2 * dirX - cx)
|
||||
);
|
||||
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
|
||||
best = t2 > Tolerance.Epsilon ? t2 : 0;
|
||||
}
|
||||
@@ -179,19 +220,29 @@ namespace OpenNest.Geometry
|
||||
/// Returns double.MaxValue if no hit.
|
||||
/// </summary>
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining
|
||||
)]
|
||||
public static double RayCircleDistance(
|
||||
double vx, double vy,
|
||||
double cx, double cy, double r,
|
||||
double dirX, double dirY)
|
||||
double vx,
|
||||
double vy,
|
||||
double cx,
|
||||
double cy,
|
||||
double r,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
{
|
||||
if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
|
||||
return double.MaxValue;
|
||||
|
||||
if (t1 > Tolerance.Epsilon) return t1;
|
||||
if (t1 >= -Tolerance.Epsilon) return 0;
|
||||
if (t2 > Tolerance.Epsilon) return t2;
|
||||
if (t2 >= -Tolerance.Epsilon) return 0;
|
||||
if (t1 > Tolerance.Epsilon)
|
||||
return t1;
|
||||
if (t1 >= -Tolerance.Epsilon)
|
||||
return 0;
|
||||
if (t2 > Tolerance.Epsilon)
|
||||
return t2;
|
||||
if (t2 >= -Tolerance.Epsilon)
|
||||
return 0;
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
@@ -201,7 +252,11 @@ namespace OpenNest.Geometry
|
||||
/// any edge of movingLines contacts any edge of stationaryLines.
|
||||
/// Returns double.MaxValue if no collision path exists.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, PushDirection direction)
|
||||
public static double DirectionalDistance(
|
||||
List<Line> movingLines,
|
||||
List<Line> stationaryLines,
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
return DirectionalDistance(movingLines, 0, 0, stationaryLines, direction);
|
||||
}
|
||||
@@ -211,8 +266,12 @@ namespace OpenNest.Geometry
|
||||
/// by (movingDx, movingDy) without creating new Line objects.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Line> movingLines, double movingDx, double movingDy,
|
||||
List<Line> stationaryLines, PushDirection direction)
|
||||
List<Line> movingLines,
|
||||
double movingDx,
|
||||
double movingDy,
|
||||
List<Line> stationaryLines,
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var movingOffset = new Vector(movingDx, movingDy);
|
||||
@@ -226,7 +285,8 @@ namespace OpenNest.Geometry
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
@@ -239,7 +299,8 @@ namespace OpenNest.Geometry
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
|
||||
if (d < minDist) minDist = d;
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
@@ -267,9 +328,12 @@ namespace OpenNest.Geometry
|
||||
/// to avoid all intermediate object allocations.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
(Vector start, Vector end)[] movingEdges, Vector movingOffset,
|
||||
(Vector start, Vector end)[] stationaryEdges, Vector stationaryOffset,
|
||||
PushDirection direction)
|
||||
(Vector start, Vector end)[] movingEdges,
|
||||
Vector movingOffset,
|
||||
(Vector start, Vector end)[] stationaryEdges,
|
||||
Vector stationaryOffset,
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
@@ -281,7 +345,8 @@ namespace OpenNest.Geometry
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
@@ -293,15 +358,19 @@ namespace OpenNest.Geometry
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
|
||||
if (d < minDist) minDist = d;
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
public static double OneWayDistance(
|
||||
Vector vertex, (Vector start, Vector end)[] edges, Vector edgeOffset,
|
||||
PushDirection direction)
|
||||
Vector vertex,
|
||||
(Vector start, Vector end)[] edges,
|
||||
Vector edgeOffset,
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var vx = vertex.X;
|
||||
@@ -315,7 +384,9 @@ namespace OpenNest.Geometry
|
||||
var e1 = edges[i].start + edgeOffset;
|
||||
var e2 = edges[i].end + edgeOffset;
|
||||
|
||||
double perpValue, edgeMin, edgeMax;
|
||||
double perpValue,
|
||||
edgeMin,
|
||||
edgeMax;
|
||||
if (horizontal)
|
||||
{
|
||||
perpValue = vy;
|
||||
@@ -337,7 +408,8 @@ namespace OpenNest.Geometry
|
||||
continue;
|
||||
|
||||
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
@@ -347,11 +419,16 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case PushDirection.Left: return PushDirection.Right;
|
||||
case PushDirection.Right: return PushDirection.Left;
|
||||
case PushDirection.Up: return PushDirection.Down;
|
||||
case PushDirection.Down: return PushDirection.Up;
|
||||
default: return direction;
|
||||
case PushDirection.Left:
|
||||
return PushDirection.Right;
|
||||
case PushDirection.Right:
|
||||
return PushDirection.Left;
|
||||
case PushDirection.Up:
|
||||
return PushDirection.Down;
|
||||
case PushDirection.Down:
|
||||
return PushDirection.Up;
|
||||
default:
|
||||
return direction;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,11 +441,16 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case PushDirection.Left: return box.Left - boundary.Left;
|
||||
case PushDirection.Right: return boundary.Right - box.Right;
|
||||
case PushDirection.Up: return boundary.Top - box.Top;
|
||||
case PushDirection.Down: return box.Bottom - boundary.Bottom;
|
||||
default: return double.MaxValue;
|
||||
case PushDirection.Left:
|
||||
return box.Left - boundary.Left;
|
||||
case PushDirection.Right:
|
||||
return boundary.Right - box.Right;
|
||||
case PushDirection.Up:
|
||||
return boundary.Top - box.Top;
|
||||
case PushDirection.Down:
|
||||
return box.Bottom - boundary.Bottom;
|
||||
default:
|
||||
return double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,11 +458,16 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case PushDirection.Left: return new Vector(-distance, 0);
|
||||
case PushDirection.Right: return new Vector(distance, 0);
|
||||
case PushDirection.Up: return new Vector(0, distance);
|
||||
case PushDirection.Down: return new Vector(0, -distance);
|
||||
default: return new Vector();
|
||||
case PushDirection.Left:
|
||||
return new Vector(-distance, 0);
|
||||
case PushDirection.Right:
|
||||
return new Vector(distance, 0);
|
||||
case PushDirection.Up:
|
||||
return new Vector(0, distance);
|
||||
case PushDirection.Down:
|
||||
return new Vector(0, -distance);
|
||||
default:
|
||||
return new Vector();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,11 +475,16 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case PushDirection.Left: return from.Left - to.Right;
|
||||
case PushDirection.Right: return to.Left - from.Right;
|
||||
case PushDirection.Up: return to.Bottom - from.Top;
|
||||
case PushDirection.Down: return from.Bottom - to.Top;
|
||||
default: return double.MaxValue;
|
||||
case PushDirection.Left:
|
||||
return from.Left - to.Right;
|
||||
case PushDirection.Right:
|
||||
return to.Left - from.Right;
|
||||
case PushDirection.Up:
|
||||
return to.Bottom - from.Top;
|
||||
case PushDirection.Down:
|
||||
return from.Bottom - to.Top;
|
||||
default:
|
||||
return double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,23 +501,27 @@ namespace OpenNest.Geometry
|
||||
if (direction.X < -Tolerance.Epsilon)
|
||||
{
|
||||
var d = (box.Left - boundary.Left) / -direction.X;
|
||||
if (d < dist) dist = d;
|
||||
if (d < dist)
|
||||
dist = d;
|
||||
}
|
||||
else if (direction.X > Tolerance.Epsilon)
|
||||
{
|
||||
var d = (boundary.Right - box.Right) / direction.X;
|
||||
if (d < dist) dist = d;
|
||||
if (d < dist)
|
||||
dist = d;
|
||||
}
|
||||
|
||||
if (direction.Y < -Tolerance.Epsilon)
|
||||
{
|
||||
var d = (box.Bottom - boundary.Bottom) / -direction.Y;
|
||||
if (d < dist) dist = d;
|
||||
if (d < dist)
|
||||
dist = d;
|
||||
}
|
||||
else if (direction.Y > Tolerance.Epsilon)
|
||||
{
|
||||
var d = (boundary.Top - box.Top) / direction.Y;
|
||||
if (d < dist) dist = d;
|
||||
if (d < dist)
|
||||
dist = d;
|
||||
}
|
||||
|
||||
return dist < 0 ? 0 : dist;
|
||||
@@ -463,7 +559,11 @@ namespace OpenNest.Geometry
|
||||
/// Computes the minimum translation distance along an arbitrary unit direction
|
||||
/// before any edge of movingLines contacts any edge of stationaryLines.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, Vector direction)
|
||||
public static double DirectionalDistance(
|
||||
List<Line> movingLines,
|
||||
List<Line> stationaryLines,
|
||||
Vector direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var dirX = direction.X;
|
||||
@@ -476,8 +576,18 @@ namespace OpenNest.Geometry
|
||||
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);
|
||||
if (d < minDist) minDist = d;
|
||||
var d = RayEdgeDistance(
|
||||
mv.X,
|
||||
mv.Y,
|
||||
e.pt1.X,
|
||||
e.pt1.Y,
|
||||
e.pt2.X,
|
||||
e.pt2.Y,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,8 +601,18 @@ namespace OpenNest.Geometry
|
||||
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);
|
||||
if (d < minDist) minDist = d;
|
||||
var d = RayEdgeDistance(
|
||||
sv.X,
|
||||
sv.Y,
|
||||
e.pt1.X,
|
||||
e.pt1.Y,
|
||||
e.pt2.X,
|
||||
e.pt2.Y,
|
||||
oppX,
|
||||
oppY
|
||||
);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,9 +625,16 @@ namespace OpenNest.Geometry
|
||||
/// stationaryEntities. Delegates to the Vector-based overload.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Entity> movingEntities, List<Entity> stationaryEntities, PushDirection direction)
|
||||
List<Entity> movingEntities,
|
||||
List<Entity> stationaryEntities,
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
return DirectionalDistance(movingEntities, stationaryEntities, DirectionToOffset(direction, 1.0));
|
||||
return DirectionalDistance(
|
||||
movingEntities,
|
||||
stationaryEntities,
|
||||
DirectionToOffset(direction, 1.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -517,7 +644,10 @@ namespace OpenNest.Geometry
|
||||
/// without tessellation.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Entity> movingEntities, List<Entity> stationaryEntities, Vector direction)
|
||||
List<Entity> movingEntities,
|
||||
List<Entity> stationaryEntities,
|
||||
Vector direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var dirX = direction.X;
|
||||
@@ -536,7 +666,8 @@ namespace OpenNest.Geometry
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0) return 0;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -557,7 +688,8 @@ namespace OpenNest.Geometry
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0) return 0;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -566,10 +698,24 @@ namespace OpenNest.Geometry
|
||||
// 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(movingEntities, stationaryEntities, dirX, dirY, minDist);
|
||||
if (minDist <= 0) return 0;
|
||||
minDist = ArcToLineClosestDistance(stationaryEntities, movingEntities, oppX, oppY, minDist);
|
||||
if (minDist <= 0) return 0;
|
||||
minDist = ArcToLineClosestDistance(
|
||||
movingEntities,
|
||||
stationaryEntities,
|
||||
dirX,
|
||||
dirY,
|
||||
minDist
|
||||
);
|
||||
if (minDist <= 0)
|
||||
return 0;
|
||||
minDist = ArcToLineClosestDistance(
|
||||
stationaryEntities,
|
||||
movingEntities,
|
||||
oppX,
|
||||
oppY,
|
||||
minDist
|
||||
);
|
||||
if (minDist <= 0)
|
||||
return 0;
|
||||
|
||||
// Phase 4: Curve-to-curve direct distance.
|
||||
// The vertex-to-entity approach misses the closest contact between two
|
||||
@@ -605,20 +751,35 @@ namespace OpenNest.Geometry
|
||||
if (me is Arc mArc)
|
||||
{
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx));
|
||||
if (!Angle.IsBetweenRad(angle, mArc.StartAngle, mArc.EndAngle, mArc.IsReversed))
|
||||
if (
|
||||
!Angle.IsBetweenRad(
|
||||
angle,
|
||||
mArc.StartAngle,
|
||||
mArc.EndAngle,
|
||||
mArc.IsReversed
|
||||
)
|
||||
)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (se is Arc sArc)
|
||||
{
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx));
|
||||
if (!Angle.IsBetweenRad(angle, sArc.StartAngle, sArc.EndAngle, sArc.IsReversed))
|
||||
if (
|
||||
!Angle.IsBetweenRad(
|
||||
angle,
|
||||
sArc.StartAngle,
|
||||
sArc.EndAngle,
|
||||
sArc.IsReversed
|
||||
)
|
||||
)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
minDist = d;
|
||||
if (d <= 0) return 0;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,8 +787,12 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
private static double ArcToLineClosestDistance(
|
||||
List<Entity> arcEntities, List<Entity> lineEntities,
|
||||
double dirX, double dirY, double minDist)
|
||||
List<Entity> arcEntities,
|
||||
List<Entity> lineEntities,
|
||||
double dirX,
|
||||
double dirY,
|
||||
double minDist
|
||||
)
|
||||
{
|
||||
for (var i = 0; i < arcEntities.Count; i++)
|
||||
{
|
||||
@@ -662,15 +827,30 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
var theta = k == 0 ? theta1 : theta2;
|
||||
|
||||
if (!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||
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; }
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -678,28 +858,54 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
private static double RayEntityDistance(
|
||||
double vx, double vy, Entity entity, double dirX, double dirY)
|
||||
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);
|
||||
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);
|
||||
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 RayCircleDistance(
|
||||
vx,
|
||||
vy,
|
||||
circle.Center.X,
|
||||
circle.Center.Y,
|
||||
circle.Radius,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
return double.MaxValue;
|
||||
@@ -759,7 +965,10 @@ namespace OpenNest.Geometry
|
||||
return CollectVertices(ToEdgeArray(lines), offset);
|
||||
}
|
||||
|
||||
private static HashSet<Vector> CollectVertices((Vector start, Vector end)[] edges, Vector offset)
|
||||
private static HashSet<Vector> CollectVertices(
|
||||
(Vector start, Vector end)[] edges,
|
||||
Vector offset
|
||||
)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
@@ -778,26 +987,48 @@ namespace OpenNest.Geometry
|
||||
return edges;
|
||||
}
|
||||
|
||||
private static void SortEdgesForPruning((Vector start, Vector end)[] edges, PushDirection direction)
|
||||
private static void SortEdgesForPruning(
|
||||
(Vector start, Vector end)[] edges,
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
||||
System.Array.Sort(edges, (a, b) =>
|
||||
System.Math.Min(a.start.Y, a.end.Y).CompareTo(System.Math.Min(b.start.Y, b.end.Y)));
|
||||
System.Array.Sort(
|
||||
edges,
|
||||
(a, b) =>
|
||||
System
|
||||
.Math.Min(a.start.Y, a.end.Y)
|
||||
.CompareTo(System.Math.Min(b.start.Y, b.end.Y))
|
||||
);
|
||||
else
|
||||
System.Array.Sort(edges, (a, b) =>
|
||||
System.Math.Min(a.start.X, a.end.X).CompareTo(System.Math.Min(b.start.X, b.end.X)));
|
||||
System.Array.Sort(
|
||||
edges,
|
||||
(a, b) =>
|
||||
System
|
||||
.Math.Min(a.start.X, a.end.X)
|
||||
.CompareTo(System.Math.Min(b.start.X, b.end.X))
|
||||
);
|
||||
}
|
||||
|
||||
private static bool TryGetCurveParams(Entity entity, out double cx, out double cy, out double r)
|
||||
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;
|
||||
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;
|
||||
cx = arc.Center.X;
|
||||
cy = arc.Center.Y;
|
||||
r = arc.Radius;
|
||||
return true;
|
||||
}
|
||||
cx = cy = r = 0;
|
||||
@@ -850,7 +1081,13 @@ namespace OpenNest.Geometry
|
||||
return new Box(lft, btm, rgt - lft, top - btm);
|
||||
}
|
||||
|
||||
private static bool FindVerticalLimits(Vector pt, Box bounds, List<Box> boxes, out double top, out double btm)
|
||||
private static bool FindVerticalLimits(
|
||||
Vector pt,
|
||||
Box bounds,
|
||||
List<Box> boxes,
|
||||
out double top,
|
||||
out double btm
|
||||
)
|
||||
{
|
||||
top = double.MaxValue;
|
||||
btm = double.MinValue;
|
||||
@@ -868,20 +1105,30 @@ namespace OpenNest.Geometry
|
||||
|
||||
if (top == double.MaxValue)
|
||||
{
|
||||
if (bounds.Top > pt.Y) top = bounds.Top;
|
||||
else return false;
|
||||
if (bounds.Top > pt.Y)
|
||||
top = bounds.Top;
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
if (btm == double.MinValue)
|
||||
{
|
||||
if (bounds.Bottom < pt.Y) btm = bounds.Bottom;
|
||||
else return false;
|
||||
if (bounds.Bottom < pt.Y)
|
||||
btm = bounds.Bottom;
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool FindHorizontalLimits(Vector pt, Box bounds, List<Box> boxes, out double lft, out double rgt)
|
||||
private static bool FindHorizontalLimits(
|
||||
Vector pt,
|
||||
Box bounds,
|
||||
List<Box> boxes,
|
||||
out double lft,
|
||||
out double rgt
|
||||
)
|
||||
{
|
||||
lft = double.MinValue;
|
||||
rgt = double.MaxValue;
|
||||
@@ -899,14 +1146,18 @@ namespace OpenNest.Geometry
|
||||
|
||||
if (rgt == double.MaxValue)
|
||||
{
|
||||
if (bounds.Right > pt.X) rgt = bounds.Right;
|
||||
else return false;
|
||||
if (bounds.Right > pt.X)
|
||||
rgt = bounds.Right;
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lft == double.MinValue)
|
||||
{
|
||||
if (bounds.Left < pt.X) lft = bounds.Left;
|
||||
else return false;
|
||||
if (bounds.Left < pt.X)
|
||||
lft = bounds.Left;
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user