Compare commits
328
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a085339ba9 | ||
|
|
e493d83899 | ||
|
|
987a5e25bc | ||
|
|
86582d28c3 | ||
|
|
f064368008 | ||
|
|
9148797897 | ||
|
|
da77cc9270 | ||
|
|
27f0685058 | ||
|
|
53988acefc | ||
|
|
a8d90be2ea | ||
|
|
c25b6bc23a | ||
|
|
1c994718fb | ||
|
|
9d58e6fba8 | ||
|
|
2bae5340f0 | ||
|
|
0b322817d7 | ||
|
|
e41f335c63 | ||
|
|
0ab33af5d3 | ||
|
|
e04c9381f3 | ||
|
|
ceb9cc0b44 | ||
|
|
4cecaba83a | ||
|
|
4053f1f989 | ||
|
|
ca67b1bd29 | ||
|
|
199095ee43 | ||
|
|
eb493d501a | ||
|
|
6c98732117 | ||
|
|
a2e9fd4d14 | ||
|
|
d228b6b812 | ||
|
|
c634aecd4b | ||
|
|
14b7c1cf32 | ||
|
|
402af91af5 | ||
|
|
9a6b656e3c | ||
|
|
d2f9597b0c | ||
|
|
c40dcf0e25 | ||
|
|
28653e3a9f | ||
|
|
7c3246c6e7 | ||
|
|
bd48f57ce0 | ||
|
|
a6ec21accc | ||
|
|
320cf40f41 | ||
|
|
3beca10429 | ||
|
|
8bea5dac6c | ||
|
|
12f8bbf8f5 | ||
|
|
d15790b948 | ||
|
|
d80f76e386 | ||
|
|
07bce8699a | ||
|
|
9b84508ff4 | ||
|
|
6fdf0ad3c5 | ||
|
|
4f7bfcc3ad | ||
|
|
3c53d6fecd | ||
|
|
e239967a7b | ||
|
|
9d57d3875a | ||
|
|
0e299d7f6f | ||
|
|
c6f544c5d7 | ||
|
|
9563094c2b | ||
|
|
a3ae61d993 | ||
|
|
838a247ef9 | ||
|
|
a5e5e78c4e | ||
|
|
c386e462b2 | ||
|
|
2c0457d503 | ||
|
|
b03b3eb4d9 | ||
|
|
29c2872819 | ||
|
|
3e96c62f33 | ||
|
|
6880dee489 | ||
|
|
0e45c13515 | ||
|
|
54def611fa | ||
|
|
b1d094104a | ||
|
|
9d66b78a11 | ||
|
|
eddbbca7ef | ||
|
|
4e7b5304a0 | ||
|
|
06485053fc | ||
|
|
92a57d33df | ||
|
|
6adc5b0967 | ||
|
|
d215d02844 | ||
|
|
57863e16e9 | ||
|
|
091e750e1b | ||
|
|
87b965f895 | ||
|
|
08f60690a7 | ||
|
|
a4609c816c | ||
|
|
5a4272696e | ||
|
|
2cf03be360 | ||
|
|
041e184d93 | ||
|
|
26df3174ea | ||
|
|
0f5aace126 | ||
|
|
399f8dda6e | ||
|
|
d921558b9c | ||
|
|
bf3e3e1f42 | ||
|
|
e120ece014 | ||
|
|
264e8264be | ||
|
|
24babe353e | ||
|
|
e63be93051 | ||
|
|
ba3c3cbea3 | ||
|
|
572fa06a21 | ||
|
|
a6c2235647 | ||
|
|
5c918a0978 | ||
|
|
92461deb98 | ||
|
|
bc859aa28c | ||
|
|
09eac96a03 | ||
|
|
df65414a9d | ||
|
|
4aed231611 | ||
|
|
c641b3b68e | ||
|
|
f3b27c32c3 | ||
|
|
c270d8ea76 | ||
|
|
de6877ac48 | ||
|
|
3481764416 | ||
|
|
640814fdf6 | ||
|
|
6a30828fad | ||
|
|
786b6e2e88 | ||
|
|
ba89967448 | ||
|
|
b566d984b0 | ||
|
|
c1e6092e83 | ||
|
|
df86d4367b | ||
|
|
40026ab4dc | ||
|
|
b18a82df7a | ||
|
|
f090a2e299 | ||
|
|
55192a4888 | ||
|
|
7c28a35ad8 | ||
|
|
b2a723ca60 | ||
|
|
3dca25c601 | ||
|
|
ebc1a5f980 | ||
|
|
b729f92cd6 | ||
|
|
5d6e018b81 | ||
|
|
5163b02f89 | ||
|
|
a59911b38a | ||
|
|
810e37cacf | ||
|
|
8dfa45c446 | ||
|
|
b223f69572 | ||
|
|
98c574c2ad | ||
|
|
30f1008fa9 | ||
|
|
41c20eaf75 | ||
|
|
3a97253473 | ||
|
|
3eab3c5946 | ||
|
|
0e05ad04ea | ||
|
|
5ac985dc0f | ||
|
|
865754611c | ||
|
|
9db326ee5d | ||
|
|
25faba430c | ||
|
|
089df67627 | ||
|
|
11884e712d | ||
|
|
6bed736cf0 | ||
|
|
c20a079874 | ||
|
|
804a7fd9c1 | ||
|
|
3c4d00baa4 | ||
|
|
959ab15491 | ||
|
|
cca70db547 | ||
|
|
62d9dce0b1 | ||
|
|
1f88453d4c | ||
|
|
0697bebbc2 | ||
|
|
beadb14acc | ||
|
|
09f1140f54 | ||
|
|
7c918a2378 | ||
|
|
feb08a5f60 | ||
|
|
f1fd211ba5 | ||
|
|
fd3c2462df | ||
|
|
a4773748a1 | ||
|
|
af57153269 | ||
|
|
35e89600d0 | ||
|
|
89a4e6b981 | ||
|
|
ebad3577dd | ||
|
|
a8dc275da4 | ||
|
|
d84becdaee | ||
|
|
9cba3a6cd7 | ||
|
|
e93523d7a2 | ||
|
|
3bdbf21881 | ||
|
|
a8e42fb4b5 | ||
|
|
ea3c6afbdd | ||
|
|
ba88ac253a | ||
|
|
250fdefaea | ||
|
|
e92208b8c0 | ||
|
|
297ebee45b | ||
|
|
1eba3e7cde | ||
|
|
d65f3460a9 | ||
|
|
ede06b1bf6 | ||
|
|
51eea6d1e6 | ||
|
|
3d23ad8073 | ||
|
|
107fd86066 | ||
|
|
d12f0cee3e | ||
|
|
d93b69c524 | ||
|
|
a65598615e | ||
|
|
ed082a6799 | ||
|
|
c9b17619ef | ||
|
|
f78cc78a65 | ||
|
|
37130e8a28 | ||
|
|
6f19fe1822 | ||
|
|
81c167320d | ||
|
|
981188f65e | ||
|
|
ffd060bf61 | ||
|
|
a360452da3 | ||
|
|
b3e9e5e28b | ||
|
|
7380a43349 | ||
|
|
59e00cd707 | ||
|
|
44cb6e4a2b | ||
|
|
5949c3ca1f | ||
|
|
ef15421915 | ||
|
|
943c262ad2 | ||
|
|
301831e096 | ||
|
|
fce287e649 | ||
|
|
7e86313d7c | ||
|
|
c5943e22eb | ||
|
|
e50a7c82cf | ||
|
|
7a893ef50f | ||
|
|
925a1c7751 | ||
|
|
036b48e273 | ||
|
|
bd9b0369cf | ||
|
|
93391c4b8f | ||
|
|
ebab795f86 | ||
|
|
9f9111975d | ||
|
|
25ee193ae6 | ||
|
|
5bcad9667b | ||
|
|
64945220b9 | ||
|
|
ec0baad585 | ||
|
|
f26edb824d | ||
|
|
aae593a73e | ||
|
|
36d8f7fb11 | ||
|
|
52ad5b4575 | ||
|
|
7416f8ae3f | ||
|
|
46e3104dfc | ||
|
|
27afa04e4a | ||
|
|
95b9613e2d | ||
|
|
3bc9301e22 | ||
|
|
1040db414f | ||
|
|
287023d802 | ||
|
|
3a24e76dbd | ||
|
|
a6e2845261 | ||
|
|
97d897e885 | ||
|
|
9db7abcd37 | ||
|
|
3e340e67e0 | ||
|
|
7a6c407edd | ||
|
|
9f76659d5d | ||
|
|
a8341e9e99 | ||
|
|
fb067187b4 | ||
|
|
5c66fb3b72 | ||
|
|
5bd4c89999 | ||
|
|
dd93c230dd | ||
|
|
d6ffd8efc9 | ||
|
|
68c3a904e8 | ||
|
|
d57e2ca54b | ||
|
|
904eeb38c2 | ||
|
|
e1bb723169 | ||
|
|
aa156fff57 | ||
|
|
d3a439181c | ||
|
|
bb70ae26d3 | ||
|
|
35dc954017 | ||
|
|
0cae9e88e7 | ||
|
|
5d824a1aff | ||
|
|
8a293bcc9d | ||
|
|
24b89689c5 | ||
|
|
3da5d1c70c | ||
|
|
d3ec4eb3e2 | ||
|
|
cb446e1057 | ||
|
|
f3ca021fad | ||
|
|
ffe32fc38c | ||
|
|
27bbe99e7e | ||
|
|
5a9a06a6a0 | ||
|
|
c1f1c829dc | ||
|
|
e8fe01aea2 | ||
|
|
7b7d2cd8d1 | ||
|
|
6ca0e9da92 | ||
|
|
bcaa4a03ee | ||
|
|
54c6f1bc89 | ||
|
|
429e4b63e1 | ||
|
|
159b54a1ec | ||
|
|
568539d5b1 | ||
|
|
d7fa4bef43 | ||
|
|
7c58cfa749 | ||
|
|
525cbc6f12 | ||
|
|
134771aa23 | ||
|
|
59a66173e1 | ||
|
|
a2b7be44f8 | ||
|
|
e94a556f23 | ||
|
|
428dbdb03c | ||
|
|
e860ca3f4a | ||
|
|
a399c89f58 | ||
|
|
d16ef36d34 | ||
|
|
5307c5c85a | ||
|
|
21321740d6 | ||
|
|
7f8c708d3f | ||
|
|
ab4f806820 | ||
|
|
c9b5ee1918 | ||
|
|
f34dce95da | ||
|
|
a2a19938d3 | ||
|
|
c064c7647a | ||
|
|
8a712b9755 | ||
|
|
82de512f44 | ||
|
|
f903cbe18a | ||
|
|
3d4204db7b | ||
|
|
722f758e94 | ||
|
|
9b2322abe9 | ||
|
|
b15375cca5 | ||
|
|
e3b388464d | ||
|
|
ab09f835d3 | ||
|
|
f8b0fb573b | ||
|
|
6ce501da11 | ||
|
|
05037bc928 | ||
|
|
f83df3a55a | ||
|
|
84ad39414a | ||
|
|
fdb4a2373a | ||
|
|
3a0267c041 | ||
|
|
036f723876 | ||
|
|
21a5d3b026 | ||
|
|
0607c6c7c5 | ||
|
|
c8cfeb3c6b | ||
|
|
d4f424f274 | ||
|
|
028b1fabfc | ||
|
|
a7c2fcffe6 | ||
|
|
b834813889 | ||
|
|
4fa6100722 | ||
|
|
8f2fbee02c | ||
|
|
230a11d32e | ||
|
|
953429dae9 | ||
|
|
1c2b569ff4 | ||
|
|
048b10a1e9 | ||
|
|
3022982f6d | ||
|
|
cc85493a0c | ||
|
|
3da287cdc0 | ||
|
|
d1a701a7f7 | ||
|
|
17f786c9e8 | ||
|
|
b7a8e2662c | ||
|
|
912a47c5e8 | ||
|
|
a85213a524 | ||
|
|
fb696aaf58 | ||
|
|
d854a1f5d2 | ||
|
|
abc707f1d9 | ||
|
|
61b917c398 | ||
|
|
e3e51611d5 | ||
|
|
8104bd3626 | ||
|
|
ae262b8a77 | ||
|
|
afbbc9ed79 | ||
|
|
6071e6fa14 | ||
|
|
afdd386456 |
@@ -209,6 +209,10 @@ FakesAssemblies/
|
|||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
docs/superpowers/
|
||||||
|
|
||||||
# Launch settings
|
# Launch settings
|
||||||
**/Properties/launchSettings.json
|
**/Properties/launchSettings.json
|
||||||
|
|
||||||
|
# Local test config (contains user-specific paths to proprietary test assets)
|
||||||
|
OpenNest.Tests/test-config.json
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ Eight projects form a layered architecture:
|
|||||||
Domain model, geometry, and CNC primitives organized into namespaces:
|
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`.
|
- **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`). Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning.
|
- **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`, and `RotatingCalipers`.
|
- **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).
|
- **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). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
|
- **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.
|
- **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`.
|
- **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.
|
- **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.
|
||||||
@@ -57,6 +57,8 @@ File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
|
|||||||
- `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format).
|
- `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format).
|
||||||
- `ProgramReader` — G-code text parser.
|
- `ProgramReader` — G-code text parser.
|
||||||
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types.
|
- `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)
|
### OpenNest.Console (console app, depends on Core + Engine + IO)
|
||||||
Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`).
|
Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`).
|
||||||
@@ -116,3 +118,6 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
|
|||||||
- `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.
|
- `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.
|
- `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).
|
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).
|
||||||
|
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
|
||||||
|
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
|
||||||
|
- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList<GravographPass>, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth).
|
||||||
|
|||||||
+15
-12
@@ -5,8 +5,6 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using OpenNest.Converters;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
|
|
||||||
namespace OpenNest.Api;
|
namespace OpenNest.Api;
|
||||||
@@ -25,21 +23,26 @@ public static class NestRunner
|
|||||||
|
|
||||||
// 1. Import DXFs → Drawings
|
// 1. Import DXFs → Drawings
|
||||||
var drawings = new List<Drawing>();
|
var drawings = new List<Drawing>();
|
||||||
var importer = new DxfImporter();
|
|
||||||
|
|
||||||
foreach (var part in request.Parts)
|
foreach (var part in request.Parts)
|
||||||
{
|
{
|
||||||
if (!File.Exists(part.DxfPath))
|
if (!File.Exists(part.DxfPath))
|
||||||
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
|
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
|
||||||
|
|
||||||
if (!importer.GetGeometry(part.DxfPath, out var geometry) || geometry.Count == 0)
|
Drawing drawing;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
drawing = CadImporter.ImportDrawing(part.DxfPath,
|
||||||
|
new CadImportOptions { Quantity = part.Quantity });
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to import DXF: {part.DxfPath}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
|
||||||
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
|
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
|
||||||
|
|
||||||
var normalized = ShapeProfile.NormalizeEntities(geometry);
|
|
||||||
var pgm = ConvertGeometry.ToProgram(normalized);
|
|
||||||
var name = Path.GetFileNameWithoutExtension(part.DxfPath);
|
|
||||||
var drawing = new Drawing(name);
|
|
||||||
drawing.Program = pgm;
|
|
||||||
drawings.Add(drawing);
|
drawings.Add(drawing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +62,8 @@ public static class NestRunner
|
|||||||
|
|
||||||
// 3. Multi-plate loop
|
// 3. Multi-plate loop
|
||||||
var nest = new Nest();
|
var nest = new Nest();
|
||||||
|
nest.Thickness = request.Thickness;
|
||||||
|
nest.Material = new Material(request.Material);
|
||||||
var remaining = items.Select(item => item.Quantity).ToList();
|
var remaining = items.Select(item => item.Quantity).ToList();
|
||||||
|
|
||||||
while (remaining.Any(q => q > 0))
|
while (remaining.Any(q => q > 0))
|
||||||
@@ -67,9 +72,7 @@ public static class NestRunner
|
|||||||
|
|
||||||
var plate = new Plate(request.SheetSize)
|
var plate = new Plate(request.SheetSize)
|
||||||
{
|
{
|
||||||
Thickness = request.Thickness,
|
|
||||||
PartSpacing = request.Spacing,
|
PartSpacing = request.Spacing,
|
||||||
Material = new Material(request.Material)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build items for this pass with remaining quantities
|
// Build items for this pass with remaining quantities
|
||||||
|
|||||||
+12
-54
@@ -1,5 +1,4 @@
|
|||||||
using OpenNest;
|
using OpenNest;
|
||||||
using OpenNest.Converters;
|
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
using System;
|
using System;
|
||||||
@@ -42,7 +41,6 @@ static class NestConsole
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
using var log = SetUpLog(options);
|
|
||||||
var nest = LoadOrCreateNest(options);
|
var nest = LoadOrCreateNest(options);
|
||||||
|
|
||||||
if (nest == null)
|
if (nest == null)
|
||||||
@@ -69,10 +67,6 @@ static class NestConsole
|
|||||||
|
|
||||||
var overlapCount = CheckOverlaps(plate, options);
|
var overlapCount = CheckOverlaps(plate, options);
|
||||||
|
|
||||||
// Flush and close the log before printing results.
|
|
||||||
Trace.Flush();
|
|
||||||
log?.Dispose();
|
|
||||||
|
|
||||||
PrintResults(success, plate, elapsed);
|
PrintResults(success, plate, elapsed);
|
||||||
Save(nest, options);
|
Save(nest, options);
|
||||||
PostProcess(nest, options);
|
PostProcess(nest, options);
|
||||||
@@ -113,9 +107,6 @@ static class NestConsole
|
|||||||
case "--no-save":
|
case "--no-save":
|
||||||
o.NoSave = true;
|
o.NoSave = true;
|
||||||
break;
|
break;
|
||||||
case "--no-log":
|
|
||||||
o.NoLog = true;
|
|
||||||
break;
|
|
||||||
case "--keep-parts":
|
case "--keep-parts":
|
||||||
o.KeepParts = true;
|
o.KeepParts = true;
|
||||||
break;
|
break;
|
||||||
@@ -154,28 +145,14 @@ static class NestConsole
|
|||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
|
|
||||||
static StreamWriter SetUpLog(Options options)
|
|
||||||
{
|
|
||||||
if (options.NoLog)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var baseDir = Path.GetDirectoryName(options.InputFiles[0]);
|
|
||||||
var logDir = Path.Combine(baseDir, "test-harness-logs");
|
|
||||||
Directory.CreateDirectory(logDir);
|
|
||||||
var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log");
|
|
||||||
var writer = new StreamWriter(logFile) { AutoFlush = true };
|
|
||||||
Trace.Listeners.Add(new TextWriterTraceListener(writer));
|
|
||||||
Console.WriteLine($"Debug log: {logFile}");
|
|
||||||
return writer;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Nest LoadOrCreateNest(Options options)
|
static Nest LoadOrCreateNest(Options options)
|
||||||
{
|
{
|
||||||
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
||||||
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
||||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
||||||
var dxfFiles = options.InputFiles.Where(f =>
|
var dxfFiles = options.InputFiles.Where(f =>
|
||||||
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToList();
|
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
// If we have a nest file, load it and optionally add DXFs.
|
// If we have a nest file, load it and optionally add DXFs.
|
||||||
if (nestFile != null)
|
if (nestFile != null)
|
||||||
@@ -211,7 +188,7 @@ static class NestConsole
|
|||||||
// DXF-only mode: create a fresh nest.
|
// DXF-only mode: create a fresh nest.
|
||||||
if (dxfFiles.Count == 0)
|
if (dxfFiles.Count == 0)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("Error: no nest (.nest) or DXF (.dxf) files specified");
|
Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg) files specified");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,31 +218,15 @@ static class NestConsole
|
|||||||
|
|
||||||
static Drawing ImportDxf(string path)
|
static Drawing ImportDxf(string path)
|
||||||
{
|
{
|
||||||
var importer = new DxfImporter();
|
try
|
||||||
|
|
||||||
if (!importer.GetGeometry(path, out var geometry))
|
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"Error: failed to read DXF file: {path}");
|
return CadImporter.ImportDrawing(path);
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Error: failed to import DXF '{path}': {ex.Message}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (geometry.Count == 0)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"Error: no geometry found in DXF file: {path}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var normalized = ShapeProfile.NormalizeEntities(geometry);
|
|
||||||
var pgm = ConvertGeometry.ToProgram(normalized);
|
|
||||||
|
|
||||||
if (pgm == null)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"Error: failed to convert geometry: {path}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var name = Path.GetFileNameWithoutExtension(path);
|
|
||||||
return new Drawing(name, pgm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void ApplyTemplate(Plate plate, Options options)
|
static void ApplyTemplate(Plate plate, Options options)
|
||||||
@@ -279,10 +240,9 @@ static class NestConsole
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var templatePlate = new NestReader(options.TemplateFile).Read().PlateDefaults.CreateNew();
|
var templateNest = new NestReader(options.TemplateFile).Read();
|
||||||
plate.Thickness = templatePlate.Thickness;
|
var templatePlate = templateNest.PlateDefaults.CreateNew();
|
||||||
plate.Quadrant = templatePlate.Quadrant;
|
plate.Quadrant = templatePlate.Quadrant;
|
||||||
plate.Material = templatePlate.Material;
|
|
||||||
plate.EdgeSpacing = templatePlate.EdgeSpacing;
|
plate.EdgeSpacing = templatePlate.EdgeSpacing;
|
||||||
plate.PartSpacing = templatePlate.PartSpacing;
|
plate.PartSpacing = templatePlate.PartSpacing;
|
||||||
Console.WriteLine($"Template: {options.TemplateFile}");
|
Console.WriteLine($"Template: {options.TemplateFile}");
|
||||||
@@ -502,7 +462,7 @@ static class NestConsole
|
|||||||
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
|
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
|
||||||
Console.Error.WriteLine();
|
Console.Error.WriteLine();
|
||||||
Console.Error.WriteLine("Arguments:");
|
Console.Error.WriteLine("Arguments:");
|
||||||
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf drawing files");
|
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf/.dwg drawing files");
|
||||||
Console.Error.WriteLine();
|
Console.Error.WriteLine();
|
||||||
Console.Error.WriteLine("Modes:");
|
Console.Error.WriteLine("Modes:");
|
||||||
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
|
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
|
||||||
@@ -521,7 +481,6 @@ static class NestConsole
|
|||||||
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
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(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
|
||||||
Console.Error.WriteLine(" --no-save Skip saving output file");
|
Console.Error.WriteLine(" --no-save Skip saving output file");
|
||||||
Console.Error.WriteLine(" --no-log Skip writing debug log file");
|
|
||||||
Console.Error.WriteLine(" --post <name> Run a post processor after nesting");
|
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(" --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(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)");
|
||||||
@@ -540,7 +499,6 @@ static class NestConsole
|
|||||||
public Size? PlateSize;
|
public Size? PlateSize;
|
||||||
public bool CheckOverlaps;
|
public bool CheckOverlaps;
|
||||||
public bool NoSave;
|
public bool NoSave;
|
||||||
public bool NoLog;
|
|
||||||
public bool KeepParts;
|
public bool KeepParts;
|
||||||
public bool AutoNest;
|
public bool AutoNest;
|
||||||
public string TemplateFile;
|
public string TemplateFile;
|
||||||
|
|||||||
+17
-42
@@ -125,61 +125,36 @@ namespace OpenNest
|
|||||||
parts.ForEach(part => Bottom(fixedPart, part));
|
parts.ForEach(part => Bottom(fixedPart, part));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void EvenlyDistributeHorizontally(List<Part> parts)
|
public static void EvenlyDistributeHorizontally(List<Part> parts) =>
|
||||||
|
EvenlyDistribute(parts, horizontal: true);
|
||||||
|
|
||||||
|
public static void EvenlyDistributeVertically(List<Part> parts) =>
|
||||||
|
EvenlyDistribute(parts, horizontal: false);
|
||||||
|
|
||||||
|
private static void EvenlyDistribute(List<Part> parts, bool horizontal)
|
||||||
{
|
{
|
||||||
if (parts.Count < 3)
|
if (parts.Count < 3)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var list = new List<Part>(parts);
|
var list = new List<Part>(parts);
|
||||||
list.Sort((p1, p2) => p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X));
|
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 lastIndex = list.Count - 1;
|
||||||
|
|
||||||
var first = list[0];
|
var start = horizontal ? list[0].BoundingBox.Center.X : list[0].BoundingBox.Center.Y;
|
||||||
var last = list[lastIndex];
|
var end = horizontal ? list[lastIndex].BoundingBox.Center.X : list[lastIndex].BoundingBox.Center.Y;
|
||||||
|
|
||||||
var start = first.BoundingBox.Center.X;
|
var spacing = (end - start) / lastIndex;
|
||||||
var end = last.BoundingBox.Center.X;
|
|
||||||
var diff = end - start;
|
|
||||||
|
|
||||||
var spacing = diff / lastIndex;
|
for (var i = 1; i < lastIndex; ++i)
|
||||||
|
|
||||||
for (int i = 1; i < lastIndex; ++i)
|
|
||||||
{
|
{
|
||||||
var part = list[i];
|
var part = list[i];
|
||||||
var newX = start + i * spacing;
|
var cur = horizontal ? part.BoundingBox.Center.X : part.BoundingBox.Center.Y;
|
||||||
var curX = part.BoundingBox.Center.X;
|
var delta = start + i * spacing - cur;
|
||||||
|
|
||||||
part.Offset(newX - curX, 0);
|
part.Offset(horizontal ? delta : 0, horizontal ? 0 : delta);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void EvenlyDistributeVertically(List<Part> parts)
|
|
||||||
{
|
|
||||||
if (parts.Count < 3)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var list = new List<Part>(parts);
|
|
||||||
list.Sort((p1, p2) => p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y));
|
|
||||||
|
|
||||||
var lastIndex = list.Count - 1;
|
|
||||||
|
|
||||||
var first = list[0];
|
|
||||||
var last = list[lastIndex];
|
|
||||||
|
|
||||||
var start = first.BoundingBox.Center.Y;
|
|
||||||
var end = last.BoundingBox.Center.Y;
|
|
||||||
var diff = end - start;
|
|
||||||
|
|
||||||
var spacing = diff / lastIndex;
|
|
||||||
|
|
||||||
for (int i = 1; i < lastIndex; ++i)
|
|
||||||
{
|
|
||||||
var part = list[i];
|
|
||||||
var newX = start + i * spacing;
|
|
||||||
var curX = part.BoundingBox.Center.Y;
|
|
||||||
|
|
||||||
part.Offset(0, newX - curX);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ namespace OpenNest.Bending
|
|||||||
};
|
};
|
||||||
|
|
||||||
private const double DefaultEtchLength = 1.0;
|
private const double DefaultEtchLength = 1.0;
|
||||||
|
private const string BendEtchTag = "BendEtch";
|
||||||
|
|
||||||
public Vector StartPoint { get; set; }
|
public Vector StartPoint { get; set; }
|
||||||
public Vector EndPoint { get; set; }
|
public Vector EndPoint { get; set; }
|
||||||
@@ -73,7 +74,7 @@ namespace OpenNest.Bending
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends)
|
public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends)
|
||||||
{
|
{
|
||||||
entities.RemoveAll(e => e.Layer == EtchLayer);
|
entities.RemoveAll(e => e.Tag == BendEtchTag);
|
||||||
if (bends == null) return;
|
if (bends == null) return;
|
||||||
|
|
||||||
foreach (var bend in bends)
|
foreach (var bend in bends)
|
||||||
@@ -82,7 +83,7 @@ namespace OpenNest.Bending
|
|||||||
|
|
||||||
private static Line CreateEtchLine(Vector start, Vector end)
|
private static Line CreateEtchLine(Vector start, Vector end)
|
||||||
{
|
{
|
||||||
return new Line(start, end) { Layer = EtchLayer, Color = Color.Green };
|
return new Line(start, end) { Layer = EtchLayer, Color = Color.Green, Tag = BendEtchTag };
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using OpenNest.Geometry;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC
|
namespace OpenNest.CNC
|
||||||
{
|
{
|
||||||
@@ -65,7 +66,9 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
return new ArcMove(EndPoint, CenterPoint, Rotation)
|
return new ArcMove(EndPoint, CenterPoint, Rotation)
|
||||||
{
|
{
|
||||||
Layer = Layer
|
Layer = Layer,
|
||||||
|
Suppressed = Suppressed,
|
||||||
|
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
@@ -7,69 +9,345 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
public CuttingParameters Parameters { get; set; }
|
public CuttingParameters Parameters { get; set; }
|
||||||
|
|
||||||
|
private record ContourEntry(Shape Shape, Vector Point, Entity Entity);
|
||||||
|
|
||||||
public CuttingResult Apply(Program partProgram, Vector approachPoint)
|
public CuttingResult Apply(Program partProgram, Vector approachPoint)
|
||||||
{
|
{
|
||||||
var exitPoint = approachPoint;
|
return Apply(partProgram, approachPoint, Vector.Invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
|
||||||
|
{
|
||||||
var entities = partProgram.ToGeometry();
|
var entities = partProgram.ToGeometry();
|
||||||
|
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||||
|
|
||||||
|
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
|
||||||
|
entities.RemoveAll(e => e.Layer == SpecialLayers.Scribe);
|
||||||
|
|
||||||
var profile = new ShapeProfile(entities);
|
var profile = new ShapeProfile(entities);
|
||||||
|
|
||||||
// Find closest point on perimeter from exit point
|
// Start from the bounding box corner opposite the origin (max X, max Y)
|
||||||
var perimeterPoint = profile.Perimeter.ClosestPointTo(exitPoint, out var perimeterEntity);
|
var bbox = entities.GetBoundingBox();
|
||||||
|
var startCorner = new Vector(bbox.Right, bbox.Top);
|
||||||
|
|
||||||
// Chain cutouts by nearest-neighbor from perimeter point, then reverse
|
// Initial pass: sequence cutouts from bbox corner
|
||||||
// so farthest cutouts are cut first, nearest-to-perimeter cut last
|
var seedPoint = startCorner;
|
||||||
var orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterPoint);
|
var orderedCutouts = SequenceCutouts(profile.Cutouts, seedPoint);
|
||||||
orderedCutouts.Reverse();
|
orderedCutouts.Reverse();
|
||||||
|
|
||||||
// Build output program: cutouts first (farthest to nearest), perimeter last
|
var perimeterSeed = profile.Perimeter.ClosestPointTo(seedPoint, out _);
|
||||||
var result = new Program();
|
var cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterSeed);
|
||||||
var currentPoint = exitPoint;
|
|
||||||
|
|
||||||
foreach (var cutout in orderedCutouts)
|
Vector perimeterPt;
|
||||||
|
Entity perimeterEntity;
|
||||||
|
|
||||||
|
if (!double.IsNaN(nextPartStart.X) && cutoutEntries.Count > 0)
|
||||||
{
|
{
|
||||||
var contourType = DetectContourType(cutout);
|
// Iterate: each pass refines the perimeter lead-in which changes
|
||||||
var closestPt = cutout.ClosestPointTo(currentPoint, out var entity);
|
// the internal sequence which changes the last cutout position
|
||||||
var normal = ComputeNormal(closestPt, entity, contourType);
|
for (var iter = 0; iter < 3; iter++)
|
||||||
var winding = DetermineWinding(cutout);
|
{
|
||||||
|
var lastCutoutPt = cutoutEntries[cutoutEntries.Count - 1].Point;
|
||||||
|
perimeterSeed = FindPerimeterIntersection(profile.Perimeter, lastCutoutPt, nextPartStart, out _);
|
||||||
|
|
||||||
var leadIn = SelectLeadIn(contourType);
|
orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterSeed);
|
||||||
var leadOut = SelectLeadOut(contourType);
|
orderedCutouts.Reverse();
|
||||||
|
cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterSeed);
|
||||||
|
}
|
||||||
|
|
||||||
result.Codes.AddRange(leadIn.Generate(closestPt, normal, winding));
|
var finalLastCutout = cutoutEntries[cutoutEntries.Count - 1].Point;
|
||||||
var reindexed = cutout.ReindexAt(closestPt, entity);
|
perimeterPt = FindPerimeterIntersection(profile.Perimeter, finalLastCutout, nextPartStart, out perimeterEntity);
|
||||||
result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt));
|
}
|
||||||
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
|
else
|
||||||
result.Codes.AddRange(leadOut.Generate(closestPt, normal, winding));
|
{
|
||||||
|
var perimeterRef = cutoutEntries.Count > 0 ? cutoutEntries[0].Point : approachPoint;
|
||||||
currentPoint = closestPt;
|
perimeterPt = profile.Perimeter.ClosestPointTo(perimeterRef, out perimeterEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastCutPoint = exitPoint;
|
var result = new Program(Mode.Absolute);
|
||||||
|
|
||||||
// Perimeter last
|
EmitScribeContours(result, scribeEntities);
|
||||||
|
|
||||||
|
foreach (var entry in cutoutEntries)
|
||||||
{
|
{
|
||||||
var perimeterPt = profile.Perimeter.ClosestPointTo(currentPoint, out perimeterEntity);
|
if (!entry.Shape.IsClosed())
|
||||||
lastCutPoint = perimeterPt;
|
EmitRawContour(result, entry.Shape);
|
||||||
var normal = ComputeNormal(perimeterPt, perimeterEntity, ContourType.External);
|
else
|
||||||
var winding = DetermineWinding(profile.Perimeter);
|
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
|
||||||
|
|
||||||
var leadIn = SelectLeadIn(ContourType.External);
|
|
||||||
var leadOut = SelectLeadOut(ContourType.External);
|
|
||||||
|
|
||||||
result.Codes.AddRange(leadIn.Generate(perimeterPt, normal, winding));
|
|
||||||
var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity);
|
|
||||||
result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt));
|
|
||||||
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
|
|
||||||
result.Codes.AddRange(leadOut.Generate(perimeterPt, normal, winding));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!profile.Perimeter.IsClosed())
|
||||||
|
EmitRawContour(result, profile.Perimeter);
|
||||||
|
else
|
||||||
|
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
|
||||||
|
|
||||||
|
result.Mode = Mode.Incremental;
|
||||||
|
|
||||||
return new CuttingResult
|
return new CuttingResult
|
||||||
{
|
{
|
||||||
Program = result,
|
Program = result,
|
||||||
LastCutPoint = lastCutPoint
|
LastCutPoint = perimeterPt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CuttingResult ApplySingle(Program partProgram, Vector point, Entity entity, ContourType contourType)
|
||||||
|
{
|
||||||
|
var entities = partProgram.ToGeometry();
|
||||||
|
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||||
|
|
||||||
|
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
|
||||||
|
entities.RemoveAll(e => e.Layer == SpecialLayers.Scribe);
|
||||||
|
|
||||||
|
var profile = new ShapeProfile(entities);
|
||||||
|
|
||||||
|
var result = new Program(Mode.Absolute);
|
||||||
|
|
||||||
|
EmitScribeContours(result, scribeEntities);
|
||||||
|
|
||||||
|
// Find the target shape that contains the clicked entity
|
||||||
|
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
|
||||||
|
|
||||||
|
// Emit cutouts — only the target gets lead-in/out (skip open contours)
|
||||||
|
foreach (var cutout in profile.Cutouts)
|
||||||
|
{
|
||||||
|
if (!cutout.IsClosed())
|
||||||
|
{
|
||||||
|
EmitRawContour(result, cutout);
|
||||||
|
}
|
||||||
|
else if (cutout == targetShape)
|
||||||
|
{
|
||||||
|
var ct = DetectContourType(cutout);
|
||||||
|
EmitContour(result, cutout, point, matchedEntity, ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
EmitRawContour(result, cutout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit perimeter
|
||||||
|
if (!profile.Perimeter.IsClosed())
|
||||||
|
{
|
||||||
|
EmitRawContour(result, profile.Perimeter);
|
||||||
|
}
|
||||||
|
else if (profile.Perimeter == targetShape)
|
||||||
|
{
|
||||||
|
EmitContour(result, profile.Perimeter, point, matchedEntity, ContourType.External);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
EmitRawContour(result, profile.Perimeter);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Mode = Mode.Incremental;
|
||||||
|
|
||||||
|
return new CuttingResult
|
||||||
|
{
|
||||||
|
Program = result,
|
||||||
|
LastCutPoint = point
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Shape Shape, Entity Entity) FindTargetShape(ShapeProfile profile, Vector point, Entity clickedEntity)
|
||||||
|
{
|
||||||
|
var matched = FindMatchingEntity(profile.Perimeter, clickedEntity);
|
||||||
|
if (matched != null)
|
||||||
|
return (profile.Perimeter, matched);
|
||||||
|
|
||||||
|
foreach (var cutout in profile.Cutouts)
|
||||||
|
{
|
||||||
|
matched = FindMatchingEntity(cutout, clickedEntity);
|
||||||
|
if (matched != null)
|
||||||
|
return (cutout, matched);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: closest shape, use closest point to find entity
|
||||||
|
var best = profile.Perimeter;
|
||||||
|
var bestPt = profile.Perimeter.ClosestPointTo(point, out var bestEntity);
|
||||||
|
var bestDist = bestPt.DistanceTo(point);
|
||||||
|
|
||||||
|
foreach (var cutout in profile.Cutouts)
|
||||||
|
{
|
||||||
|
var pt = cutout.ClosestPointTo(point, out var cutoutEntity);
|
||||||
|
var dist = pt.DistanceTo(point);
|
||||||
|
if (dist < bestDist)
|
||||||
|
{
|
||||||
|
best = cutout;
|
||||||
|
bestEntity = cutoutEntity;
|
||||||
|
bestDist = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (best, bestEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entity FindMatchingEntity(Shape shape, Entity clickedEntity)
|
||||||
|
{
|
||||||
|
foreach (var shapeEntity in shape.Entities)
|
||||||
|
{
|
||||||
|
if (shapeEntity.GetType() != clickedEntity.GetType())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
return shapeEntity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitRawContour(Program program, Shape shape)
|
||||||
|
{
|
||||||
|
var startPoint = GetShapeStartPoint(shape);
|
||||||
|
program.Codes.Add(new RapidMove(startPoint));
|
||||||
|
program.Codes.AddRange(ConvertShapeToMoves(shape, startPoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ContourEntry> ResolveLeadInPoints(List<Shape> cutouts, Vector startPoint)
|
||||||
|
{
|
||||||
|
var entries = new ContourEntry[cutouts.Count];
|
||||||
|
var currentPoint = startPoint;
|
||||||
|
|
||||||
|
// Walk backward through cutting order (from perimeter outward)
|
||||||
|
// so each cutout's lead-in point faces the next cutout to be cut
|
||||||
|
for (var i = cutouts.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var closestPt = cutouts[i].ClosestPointTo(currentPoint, out var entity);
|
||||||
|
entries[i] = new ContourEntry(cutouts[i], closestPt, entity);
|
||||||
|
currentPoint = closestPt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new List<ContourEntry>(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector FindPerimeterIntersection(Shape perimeter, Vector lastCutout, Vector nextPartStart, out Entity entity)
|
||||||
|
{
|
||||||
|
var ray = new Line(lastCutout, nextPartStart);
|
||||||
|
|
||||||
|
if (perimeter.Intersects(ray, out var pts) && pts.Count > 0)
|
||||||
|
{
|
||||||
|
// Pick the intersection closest to the last cutout
|
||||||
|
var best = pts[0];
|
||||||
|
var bestDist = best.DistanceTo(lastCutout);
|
||||||
|
|
||||||
|
for (var i = 1; i < pts.Count; i++)
|
||||||
|
{
|
||||||
|
var dist = pts[i].DistanceTo(lastCutout);
|
||||||
|
if (dist < bestDist)
|
||||||
|
{
|
||||||
|
best = pts[i];
|
||||||
|
bestDist = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return perimeter.ClosestPointTo(best, out entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: closest point on perimeter to the last cutout
|
||||||
|
return perimeter.ClosestPointTo(lastCutout, out entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ComputeSubProgramKey(double radius, double normalAngle)
|
||||||
|
{
|
||||||
|
var r = System.Math.Round(radius, 6);
|
||||||
|
var a = System.Math.Round(normalAngle, 6);
|
||||||
|
return HashCode.Combine(r, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null)
|
||||||
|
{
|
||||||
|
var contourType = forceType ?? DetectContourType(shape);
|
||||||
|
var winding = DetermineWinding(shape);
|
||||||
|
var normal = ComputeNormal(point, entity, contourType, winding);
|
||||||
|
|
||||||
|
var leadIn = SelectLeadIn(contourType);
|
||||||
|
var leadOut = SelectLeadOut(contourType);
|
||||||
|
|
||||||
|
if (contourType == ContourType.ArcCircle && entity is Circle circle)
|
||||||
|
{
|
||||||
|
if (Parameters.RoundLeadInAngles && Parameters.LeadInAngleIncrement > 0)
|
||||||
|
{
|
||||||
|
var increment = Angle.ToRadians(Parameters.LeadInAngleIncrement);
|
||||||
|
normal = System.Math.Round(normal / increment) * increment;
|
||||||
|
normal = Angle.NormalizeRad(normal);
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal);
|
||||||
|
|
||||||
|
// 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 relativeShape = new Shape();
|
||||||
|
relativeShape.Entities.Add(relativeCircle);
|
||||||
|
|
||||||
|
var subPgm = new Program(Mode.Absolute);
|
||||||
|
subPgm.Codes.AddRange(leadIn.Generate(relativePoint, normal, winding));
|
||||||
|
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
|
||||||
|
|
||||||
|
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
|
||||||
|
subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding));
|
||||||
|
subPgm.Mode = Mode.Incremental;
|
||||||
|
|
||||||
|
// Deduplicate: check if an identical sub-program already exists
|
||||||
|
var key = ComputeSubProgramKey(circle.Radius, normal);
|
||||||
|
if (!program.SubPrograms.ContainsKey(key))
|
||||||
|
program.SubPrograms[key] = subPgm;
|
||||||
|
|
||||||
|
program.Codes.Add(new SubProgramCall
|
||||||
|
{
|
||||||
|
Id = key,
|
||||||
|
Program = program.SubPrograms[key],
|
||||||
|
Offset = holeCenter
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
program.Codes.AddRange(leadIn.Generate(point, normal, winding));
|
||||||
|
|
||||||
|
var reindexedShape = shape.ReindexAt(point, entity);
|
||||||
|
|
||||||
|
if (Parameters.TabsEnabled && Parameters.TabConfig != null && contourType == ContourType.External)
|
||||||
|
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
||||||
|
|
||||||
|
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||||
|
program.Codes.AddRange(leadOut.Generate(point, normal, winding));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
||||||
|
{
|
||||||
|
if (scribeEntities.Count == 0) return;
|
||||||
|
|
||||||
|
var shapes = ShapeBuilder.GetShapes(scribeEntities);
|
||||||
|
foreach (var shape in shapes)
|
||||||
|
{
|
||||||
|
var startPt = GetShapeStartPoint(shape);
|
||||||
|
program.Codes.Add(new RapidMove(startPt));
|
||||||
|
program.Codes.AddRange(ConvertShapeToMoves(shape, startPt, LayerType.Scribe));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private List<Shape> SequenceCutouts(List<Shape> cutouts, Vector startPoint)
|
private List<Shape> SequenceCutouts(List<Shape> cutouts, Vector startPoint)
|
||||||
{
|
{
|
||||||
var remaining = new List<Shape>(cutouts);
|
var remaining = new List<Shape>(cutouts);
|
||||||
@@ -102,7 +380,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return ordered;
|
return ordered;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ContourType DetectContourType(Shape cutout)
|
public static ContourType DetectContourType(Shape cutout)
|
||||||
{
|
{
|
||||||
if (cutout.Entities.Count == 1 && cutout.Entities[0] is Circle)
|
if (cutout.Entities.Count == 1 && cutout.Entities[0] is Circle)
|
||||||
return ContourType.ArcCircle;
|
return ContourType.ArcCircle;
|
||||||
@@ -110,23 +388,33 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return ContourType.Internal;
|
return ContourType.Internal;
|
||||||
}
|
}
|
||||||
|
|
||||||
private double ComputeNormal(Vector point, Entity entity, ContourType contourType)
|
public static double ComputeNormal(Vector point, Entity entity, ContourType contourType,
|
||||||
|
RotationType winding = RotationType.CW)
|
||||||
{
|
{
|
||||||
double normal;
|
double normal;
|
||||||
|
|
||||||
if (entity is Line line)
|
if (entity is Line line)
|
||||||
{
|
{
|
||||||
// Perpendicular to line direction
|
// Perpendicular to line direction: tangent + π/2 = left side.
|
||||||
|
// Left side = outward for CW winding; for CCW winding, outward
|
||||||
|
// is on the right side, so flip.
|
||||||
var tangent = line.EndPoint.AngleFrom(line.StartPoint);
|
var tangent = line.EndPoint.AngleFrom(line.StartPoint);
|
||||||
normal = tangent + Math.Angle.HalfPI;
|
normal = tangent + Math.Angle.HalfPI;
|
||||||
|
if (winding == RotationType.CCW)
|
||||||
|
normal += System.Math.PI;
|
||||||
}
|
}
|
||||||
else if (entity is Arc arc)
|
else if (entity is Arc arc)
|
||||||
{
|
{
|
||||||
// Radial direction from center to point
|
// Radial direction from center to point.
|
||||||
|
// Flip when the arc direction differs from the contour winding —
|
||||||
|
// that indicates a concave feature where radial points inward.
|
||||||
normal = point.AngleFrom(arc.Center);
|
normal = point.AngleFrom(arc.Center);
|
||||||
|
if (arc.Rotation != winding)
|
||||||
|
normal += System.Math.PI;
|
||||||
}
|
}
|
||||||
else if (entity is Circle circle)
|
else if (entity is Circle circle)
|
||||||
{
|
{
|
||||||
|
// Radial outward — always correct regardless of winding
|
||||||
normal = point.AngleFrom(circle.Center);
|
normal = point.AngleFrom(circle.Center);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -141,11 +429,61 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return Math.Angle.NormalizeRad(normal);
|
return Math.Angle.NormalizeRad(normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private RotationType DetermineWinding(Shape shape)
|
public static RotationType DetermineWinding(Shape shape)
|
||||||
{
|
{
|
||||||
// Use signed area: positive = CCW, negative = CW
|
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
|
||||||
var area = shape.Area();
|
return circle.Rotation;
|
||||||
return area >= 0 ? RotationType.CCW : RotationType.CW;
|
|
||||||
|
var polygon = shape.ToPolygon();
|
||||||
|
|
||||||
|
if (polygon.Vertices.Count < 3)
|
||||||
|
return RotationType.CCW;
|
||||||
|
|
||||||
|
return polygon.RotationDirection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle)
|
||||||
|
{
|
||||||
|
if (leadIn is NoLeadIn || Parameters.PierceClearance <= 0)
|
||||||
|
return leadIn;
|
||||||
|
|
||||||
|
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle);
|
||||||
|
var maxRadius = circle.Radius - Parameters.PierceClearance;
|
||||||
|
if (maxRadius <= 0)
|
||||||
|
return leadIn;
|
||||||
|
|
||||||
|
var distFromCenter = piercePoint.DistanceTo(circle.Center);
|
||||||
|
if (distFromCenter <= maxRadius)
|
||||||
|
return leadIn;
|
||||||
|
|
||||||
|
// Compute max distance from contourPoint toward piercePoint that stays
|
||||||
|
// inside a circle of radius maxRadius centered at circle.Center.
|
||||||
|
// Solve: |contourPoint + t*d - center|^2 = maxRadius^2
|
||||||
|
var currentDist = contourPoint.DistanceTo(piercePoint);
|
||||||
|
if (currentDist < Math.Tolerance.Epsilon)
|
||||||
|
return leadIn;
|
||||||
|
|
||||||
|
var dx = (piercePoint.X - contourPoint.X) / currentDist;
|
||||||
|
var dy = (piercePoint.Y - contourPoint.Y) / currentDist;
|
||||||
|
var vx = contourPoint.X - circle.Center.X;
|
||||||
|
var vy = contourPoint.Y - circle.Center.Y;
|
||||||
|
|
||||||
|
var b = 2.0 * (vx * dx + vy * dy);
|
||||||
|
var c = vx * vx + vy * vy - maxRadius * maxRadius;
|
||||||
|
var discriminant = b * b - 4.0 * c;
|
||||||
|
|
||||||
|
if (discriminant < 0)
|
||||||
|
return leadIn;
|
||||||
|
|
||||||
|
var t = (-b + System.Math.Sqrt(discriminant)) / 2.0;
|
||||||
|
if (t <= 0)
|
||||||
|
return leadIn;
|
||||||
|
|
||||||
|
var scale = t / currentDist;
|
||||||
|
if (scale >= 1.0)
|
||||||
|
return leadIn;
|
||||||
|
|
||||||
|
return leadIn.Scale(scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
private LeadIn SelectLeadIn(ContourType contourType)
|
private LeadIn SelectLeadIn(ContourType contourType)
|
||||||
@@ -168,7 +506,71 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint)
|
private static Shape TrimShapeForTab(Shape shape, Vector center, double tabSize)
|
||||||
|
{
|
||||||
|
var tabCircle = new Circle(center, tabSize);
|
||||||
|
var entities = new List<Entity>(shape.Entities);
|
||||||
|
|
||||||
|
// Trim end: walk backward removing entities inside the tab circle
|
||||||
|
while (entities.Count > 0)
|
||||||
|
{
|
||||||
|
var entity = entities[entities.Count - 1];
|
||||||
|
if (entity.Intersects(tabCircle, out var pts) && pts.Count > 0)
|
||||||
|
{
|
||||||
|
// Find intersection furthest from center (furthest along path from end)
|
||||||
|
var best = pts[0];
|
||||||
|
var bestDist = best.DistanceTo(center);
|
||||||
|
for (var j = 1; j < pts.Count; j++)
|
||||||
|
{
|
||||||
|
var dist = pts[j].DistanceTo(center);
|
||||||
|
if (dist > bestDist)
|
||||||
|
{
|
||||||
|
best = pts[j];
|
||||||
|
bestDist = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity is Line line)
|
||||||
|
{
|
||||||
|
var (first, _) = line.SplitAt(best);
|
||||||
|
entities.RemoveAt(entities.Count - 1);
|
||||||
|
if (first != null)
|
||||||
|
entities.Add(first);
|
||||||
|
}
|
||||||
|
else if (entity is Arc arc)
|
||||||
|
{
|
||||||
|
var (first, _) = arc.SplitAt(best);
|
||||||
|
entities.RemoveAt(entities.Count - 1);
|
||||||
|
if (first != null)
|
||||||
|
entities.Add(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No intersection — entity is entirely inside circle, remove it
|
||||||
|
if (EntityStartPoint(entity).DistanceTo(center) <= tabSize + Tolerance.Epsilon)
|
||||||
|
{
|
||||||
|
entities.RemoveAt(entities.Count - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new Shape();
|
||||||
|
result.Entities.AddRange(entities);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector EntityStartPoint(Entity entity)
|
||||||
|
{
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
var moves = new List<ICode>();
|
var moves = new List<ICode>();
|
||||||
|
|
||||||
@@ -176,15 +578,15 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
if (entity is Line line)
|
if (entity is Line line)
|
||||||
{
|
{
|
||||||
moves.Add(new LinearMove(line.EndPoint));
|
moves.Add(new LinearMove(line.EndPoint) { Layer = layer });
|
||||||
}
|
}
|
||||||
else if (entity is Arc arc)
|
else if (entity is Arc arc)
|
||||||
{
|
{
|
||||||
moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW));
|
moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer });
|
||||||
}
|
}
|
||||||
else if (entity is Circle circle)
|
else if (entity is Circle circle)
|
||||||
{
|
{
|
||||||
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation));
|
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -194,5 +596,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
return moves;
|
return moves;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
return Vector.Zero;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
|
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
|
||||||
public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut();
|
public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut();
|
||||||
|
|
||||||
|
public double PierceClearance { get; set; } = 0.0625;
|
||||||
|
|
||||||
|
public bool RoundLeadInAngles { get; set; }
|
||||||
|
public double LeadInAngleIncrement { get; set; } = 5.0;
|
||||||
|
|
||||||
|
public double AutoTabMinSize { get; set; }
|
||||||
|
public double AutoTabMaxSize { get; set; }
|
||||||
|
|
||||||
public Tab TabConfig { get; set; }
|
public Tab TabConfig { get; set; }
|
||||||
public bool TabsEnabled { get; set; }
|
public bool TabsEnabled { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new RapidMove(piercePoint),
|
new RapidMove(piercePoint),
|
||||||
new ArcMove(contourStartPoint, arcCenter, winding)
|
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,5 +32,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new RapidMove(piercePoint),
|
new RapidMove(piercePoint),
|
||||||
new LinearMove(arcStart),
|
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||||
new ArcMove(contourStartPoint, arcCenter, winding)
|
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,5 +45,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
RotationType winding = RotationType.CW);
|
RotationType winding = RotationType.CW);
|
||||||
|
|
||||||
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
|
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
|
||||||
|
|
||||||
|
public virtual LeadIn Scale(double factor) => this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new RapidMove(piercePoint),
|
new RapidMove(piercePoint),
|
||||||
new LinearMove(arcStart),
|
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||||
new ArcMove(contourStartPoint, arcCenter, winding)
|
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,5 +45,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,16 +17,19 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new RapidMove(piercePoint),
|
new RapidMove(piercePoint),
|
||||||
new LinearMove(contourStartPoint)
|
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||||
{
|
{
|
||||||
var approachAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
|
||||||
return new Vector(
|
return new Vector(
|
||||||
contourStartPoint.X + Length * System.Math.Cos(approachAngle),
|
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) =>
|
||||||
|
new LineLeadIn { Length = Length * factor, ApproachAngle = ApproachAngle };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
{
|
{
|
||||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||||
|
|
||||||
var secondAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle1);
|
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||||
var midPoint = new Vector(
|
var midPoint = new Vector(
|
||||||
contourStartPoint.X + Length2 * System.Math.Cos(secondAngle),
|
contourStartPoint.X + Length2 * System.Math.Cos(secondAngle),
|
||||||
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle));
|
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle));
|
||||||
@@ -24,14 +24,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new RapidMove(piercePoint),
|
new RapidMove(piercePoint),
|
||||||
new LinearMove(midPoint),
|
new LinearMove(midPoint) { Layer = LayerType.Leadin },
|
||||||
new LinearMove(contourStartPoint)
|
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||||
{
|
{
|
||||||
var secondAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle1);
|
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||||
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
|
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
|
||||||
var midY = contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle);
|
var midY = contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle);
|
||||||
|
|
||||||
@@ -40,5 +40,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
midX + Length1 * System.Math.Cos(firstAngle),
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
return new List<ICode>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new ArcMove(endPoint, arcCenter, winding)
|
new ArcMove(endPoint, arcCenter, winding) { Layer = LayerType.Leadout }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
||||||
RotationType winding = RotationType.CW)
|
RotationType winding = RotationType.CW)
|
||||||
{
|
{
|
||||||
var overcutAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
|
||||||
var endPoint = new Vector(
|
var endPoint = new Vector(
|
||||||
contourEndPoint.X + Length * System.Math.Cos(overcutAngle),
|
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>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new LinearMove(endPoint)
|
new LinearMove(endPoint) { Layer = LayerType.Leadout }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
using OpenNest.Geometry;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
|
||||||
{
|
|
||||||
public class MicrotabLeadOut : LeadOut
|
|
||||||
{
|
|
||||||
public double GapSize { get; set; } = 0.03;
|
|
||||||
|
|
||||||
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
|
||||||
RotationType winding = RotationType.CW)
|
|
||||||
{
|
|
||||||
return new List<ICode>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
|
|
||||||
public double Value { get; set; }
|
public double Value { get; set; }
|
||||||
|
|
||||||
|
public string VariableRef { get; set; }
|
||||||
|
|
||||||
public CodeType Type
|
public CodeType Type
|
||||||
{
|
{
|
||||||
get { return CodeType.SetFeedrate; }
|
get { return CodeType.SetFeedrate; }
|
||||||
@@ -24,7 +26,7 @@
|
|||||||
|
|
||||||
public ICode Clone()
|
public ICode Clone()
|
||||||
{
|
{
|
||||||
return new Feedrate(Value);
|
return new Feedrate(Value) { VariableRef = VariableRef };
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using OpenNest.Geometry;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC
|
namespace OpenNest.CNC
|
||||||
{
|
{
|
||||||
@@ -31,7 +32,9 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
return new LinearMove(EndPoint)
|
return new LinearMove(EndPoint)
|
||||||
{
|
{
|
||||||
Layer = Layer
|
Layer = Layer,
|
||||||
|
Suppressed = Suppressed,
|
||||||
|
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using OpenNest.Geometry;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC
|
namespace OpenNest.CNC
|
||||||
{
|
{
|
||||||
@@ -12,6 +13,10 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
public int Feedrate { get; set; }
|
public int Feedrate { get; set; }
|
||||||
|
|
||||||
|
public bool Suppressed { get; set; }
|
||||||
|
|
||||||
|
public Dictionary<string, string> VariableRefs { get; set; }
|
||||||
|
|
||||||
protected Motion()
|
protected Motion()
|
||||||
{
|
{
|
||||||
Feedrate = CNC.Feedrate.UseDefault;
|
Feedrate = CNC.Feedrate.UseDefault;
|
||||||
@@ -20,21 +25,25 @@ namespace OpenNest.CNC
|
|||||||
public virtual void Rotate(double angle)
|
public virtual void Rotate(double angle)
|
||||||
{
|
{
|
||||||
EndPoint = EndPoint.Rotate(angle);
|
EndPoint = EndPoint.Rotate(angle);
|
||||||
|
VariableRefs = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual void Rotate(double angle, Vector origin)
|
public virtual void Rotate(double angle, Vector origin)
|
||||||
{
|
{
|
||||||
EndPoint = EndPoint.Rotate(angle, origin);
|
EndPoint = EndPoint.Rotate(angle, origin);
|
||||||
|
VariableRefs = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual void Offset(double x, double y)
|
public virtual void Offset(double x, double y)
|
||||||
{
|
{
|
||||||
EndPoint = new Vector(EndPoint.X + x, EndPoint.Y + y);
|
EndPoint = new Vector(EndPoint.X + x, EndPoint.Y + y);
|
||||||
|
VariableRefs = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual void Offset(Vector voffset)
|
public virtual void Offset(Vector voffset)
|
||||||
{
|
{
|
||||||
EndPoint += voffset;
|
EndPoint += voffset;
|
||||||
|
VariableRefs = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract CodeType Type { get; }
|
public abstract CodeType Type { get; }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace OpenNest.CNC
|
namespace OpenNest.CNC
|
||||||
@@ -9,6 +10,10 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
public List<ICode> Codes;
|
public List<ICode> Codes;
|
||||||
|
|
||||||
|
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public Dictionary<int, Program> SubPrograms { get; } = new();
|
||||||
|
|
||||||
private Mode mode;
|
private Mode mode;
|
||||||
|
|
||||||
public Program(Mode mode = Mode.Absolute)
|
public Program(Mode mode = Mode.Absolute)
|
||||||
@@ -51,37 +56,7 @@ namespace OpenNest.CNC
|
|||||||
mode = Mode.Absolute;
|
mode = Mode.Absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual void Rotate(double angle)
|
public virtual void Rotate(double angle) => Rotate(angle, new Vector(0, 0));
|
||||||
{
|
|
||||||
var mode = Mode;
|
|
||||||
|
|
||||||
SetModeAbs();
|
|
||||||
|
|
||||||
for (int i = 0; i < Codes.Count; ++i)
|
|
||||||
{
|
|
||||||
var code = Codes[i];
|
|
||||||
|
|
||||||
if (code.Type == CodeType.SubProgramCall)
|
|
||||||
{
|
|
||||||
var subpgm = (SubProgramCall)code;
|
|
||||||
|
|
||||||
if (subpgm.Program != null)
|
|
||||||
subpgm.Program.Rotate(angle);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code is Motion == false)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var code2 = (Motion)code;
|
|
||||||
|
|
||||||
code2.Rotate(angle);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode == Mode.Incremental)
|
|
||||||
SetModeInc();
|
|
||||||
|
|
||||||
Rotation = Angle.NormalizeRad(Rotation + angle);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
{
|
{
|
||||||
@@ -114,6 +89,17 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
var subpgm = (SubProgramCall)code;
|
var subpgm = (SubProgramCall)code;
|
||||||
|
|
||||||
|
if (subpgm.Offset.X != 0 || subpgm.Offset.Y != 0)
|
||||||
|
{
|
||||||
|
var cos = System.Math.Cos(angle);
|
||||||
|
var sin = System.Math.Sin(angle);
|
||||||
|
var dx = subpgm.Offset.X - origin.X;
|
||||||
|
var dy = subpgm.Offset.Y - origin.Y;
|
||||||
|
subpgm.Offset = new Geometry.Vector(
|
||||||
|
origin.X + dx * cos - dy * sin,
|
||||||
|
origin.Y + dx * sin + dy * cos);
|
||||||
|
}
|
||||||
|
|
||||||
if (subpgm.Program != null)
|
if (subpgm.Program != null)
|
||||||
subpgm.Program.Rotate(angle, origin);
|
subpgm.Program.Rotate(angle, origin);
|
||||||
}
|
}
|
||||||
@@ -142,6 +128,12 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
var code = Codes[i];
|
var code = Codes[i];
|
||||||
|
|
||||||
|
if (code is SubProgramCall subpgm)
|
||||||
|
{
|
||||||
|
subpgm.Offset = new Geometry.Vector(
|
||||||
|
subpgm.Offset.X + x, subpgm.Offset.Y + y);
|
||||||
|
}
|
||||||
|
|
||||||
if (code is Motion == false)
|
if (code is Motion == false)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -164,6 +156,12 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
var code = Codes[i];
|
var code = Codes[i];
|
||||||
|
|
||||||
|
if (code is SubProgramCall subpgm)
|
||||||
|
{
|
||||||
|
subpgm.Offset = new Geometry.Vector(
|
||||||
|
subpgm.Offset.X + voffset.X, subpgm.Offset.Y + voffset.Y);
|
||||||
|
}
|
||||||
|
|
||||||
if (code is Motion == false)
|
if (code is Motion == false)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -302,6 +300,10 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
private Box BoundingBox(ref Vector pos)
|
private Box BoundingBox(ref Vector pos)
|
||||||
{
|
{
|
||||||
|
// 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 minX = 0.0;
|
||||||
double minY = 0.0;
|
double minY = 0.0;
|
||||||
double maxX = 0.0;
|
double maxX = 0.0;
|
||||||
@@ -317,7 +319,7 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
var line = (LinearMove)code;
|
var line = (LinearMove)code;
|
||||||
var pt = Mode == Mode.Absolute ?
|
var pt = Mode == Mode.Absolute ?
|
||||||
line.EndPoint :
|
frameOrigin + line.EndPoint :
|
||||||
line.EndPoint + pos;
|
line.EndPoint + pos;
|
||||||
|
|
||||||
if (pt.X > maxX)
|
if (pt.X > maxX)
|
||||||
@@ -339,7 +341,7 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
var line = (RapidMove)code;
|
var line = (RapidMove)code;
|
||||||
var pt = Mode == Mode.Absolute
|
var pt = Mode == Mode.Absolute
|
||||||
? line.EndPoint
|
? frameOrigin + line.EndPoint
|
||||||
: line.EndPoint + pos;
|
: line.EndPoint + pos;
|
||||||
|
|
||||||
if (pt.X > maxX)
|
if (pt.X > maxX)
|
||||||
@@ -372,8 +374,8 @@ namespace OpenNest.CNC
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
endpt = arc.EndPoint;
|
endpt = frameOrigin + arc.EndPoint;
|
||||||
centerpt = arc.CenterPoint;
|
centerpt = frameOrigin + arc.CenterPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
double minX1;
|
double minX1;
|
||||||
@@ -447,6 +449,12 @@ namespace OpenNest.CNC
|
|||||||
case CodeType.SubProgramCall:
|
case CodeType.SubProgramCall:
|
||||||
{
|
{
|
||||||
var subpgm = (SubProgramCall)code;
|
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);
|
var box = subpgm.Program.BoundingBox(ref pos);
|
||||||
|
|
||||||
if (box.Left < minX)
|
if (box.Left < minX)
|
||||||
@@ -484,6 +492,12 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
pgm.Codes.AddRange(codes);
|
pgm.Codes.AddRange(codes);
|
||||||
|
|
||||||
|
foreach (var kvp in Variables)
|
||||||
|
pgm.Variables[kvp.Key] = kvp.Value;
|
||||||
|
|
||||||
|
foreach (var kvp in SubPrograms)
|
||||||
|
pgm.SubPrograms[kvp.Key] = (Program)kvp.Value.Clone();
|
||||||
|
|
||||||
return pgm;
|
return pgm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using OpenNest.Geometry;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC
|
||||||
|
{
|
||||||
|
public static class RapidEnumerator
|
||||||
|
{
|
||||||
|
public readonly record struct Segment(Vector From, Vector To);
|
||||||
|
|
||||||
|
public static List<Segment> Enumerate(Program pgm, Vector basePos, Vector startPos)
|
||||||
|
{
|
||||||
|
var results = new List<Segment>();
|
||||||
|
|
||||||
|
// Draw the rapid from the previous tool position to the program's first
|
||||||
|
// pierce point. This also primes pos so the interior walk interprets
|
||||||
|
// Incremental deltas from the correct absolute location (basePos), which
|
||||||
|
// matters for raw pre-lead-in programs that are emitted Incremental.
|
||||||
|
var firstPierce = FirstPiercePoint(pgm, basePos);
|
||||||
|
results.Add(new Segment(startPos, firstPierce));
|
||||||
|
|
||||||
|
var pos = firstPierce;
|
||||||
|
Walk(pgm, basePos, ref pos, skipFirst: true, results);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector FirstPiercePoint(Program pgm, Vector basePos)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < pgm.Length; i++)
|
||||||
|
{
|
||||||
|
if (pgm[i] is SubProgramCall call && call.Program != null)
|
||||||
|
return FirstPiercePoint(call.Program, basePos + call.Offset);
|
||||||
|
|
||||||
|
if (pgm[i] is Motion motion)
|
||||||
|
return motion.EndPoint + basePos;
|
||||||
|
}
|
||||||
|
return basePos;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Walk(Program pgm, Vector basePos, ref Vector pos, bool skipFirst, List<Segment> results)
|
||||||
|
{
|
||||||
|
var skipped = !skipFirst;
|
||||||
|
|
||||||
|
for (var i = 0; i < pgm.Length; ++i)
|
||||||
|
{
|
||||||
|
var code = pgm[i];
|
||||||
|
|
||||||
|
if (code is SubProgramCall { Program: { } program } call)
|
||||||
|
{
|
||||||
|
var holeBase = basePos + call.Offset;
|
||||||
|
var firstPierce = FirstPiercePoint(program, holeBase);
|
||||||
|
|
||||||
|
if (!skipped)
|
||||||
|
skipped = true;
|
||||||
|
else
|
||||||
|
results.Add(new Segment(pos, firstPierce));
|
||||||
|
|
||||||
|
var subPos = holeBase;
|
||||||
|
Walk(program, holeBase, ref subPos, skipFirst: true, results);
|
||||||
|
pos = subPos;
|
||||||
|
}
|
||||||
|
else if (code is Motion motion)
|
||||||
|
{
|
||||||
|
var endpt = pgm.Mode == Mode.Incremental
|
||||||
|
? motion.EndPoint + pos
|
||||||
|
: motion.EndPoint + basePos;
|
||||||
|
|
||||||
|
if (code.Type == CodeType.RapidMove)
|
||||||
|
{
|
||||||
|
if (!skipped)
|
||||||
|
skipped = true;
|
||||||
|
else
|
||||||
|
results.Add(new Segment(pos, endpt));
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = endpt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using OpenNest.Geometry;
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.CNC
|
namespace OpenNest.CNC
|
||||||
{
|
{
|
||||||
@@ -26,7 +27,11 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
public override ICode Clone()
|
public override ICode Clone()
|
||||||
{
|
{
|
||||||
return new RapidMove(EndPoint);
|
return new RapidMove(EndPoint)
|
||||||
|
{
|
||||||
|
Suppressed = Suppressed,
|
||||||
|
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using OpenNest.Math;
|
using System.Text;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.CNC
|
namespace OpenNest.CNC
|
||||||
{
|
{
|
||||||
@@ -35,6 +37,12 @@ namespace OpenNest.CNC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the offset (position) at which the sub-program is executed.
|
||||||
|
/// For hole sub-programs, this is the hole center.
|
||||||
|
/// </summary>
|
||||||
|
public Vector Offset { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the rotation of the program in degrees.
|
/// Gets or sets the rotation of the program in degrees.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -78,12 +86,18 @@ namespace OpenNest.CNC
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public ICode Clone()
|
public ICode Clone()
|
||||||
{
|
{
|
||||||
return new SubProgramCall(program, Rotation);
|
return new SubProgramCall(program, Rotation) { Id = Id, Offset = Offset };
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
{
|
{
|
||||||
return string.Format("G65 P{0} R{1}", Id, Rotation);
|
var sb = new StringBuilder();
|
||||||
|
sb.Append($"G65 P{Id}");
|
||||||
|
if (Offset.X != 0 || Offset.Y != 0)
|
||||||
|
sb.Append($" X{Offset.X} Y{Offset.Y}");
|
||||||
|
if (Rotation != 0)
|
||||||
|
sb.Append($" R{Rotation}");
|
||||||
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace OpenNest.CNC
|
||||||
|
{
|
||||||
|
public sealed class VariableDefinition
|
||||||
|
{
|
||||||
|
public string Name { get; }
|
||||||
|
public string Expression { get; }
|
||||||
|
public double Value { get; }
|
||||||
|
public bool Inline { get; }
|
||||||
|
public bool Global { get; }
|
||||||
|
|
||||||
|
public VariableDefinition(string name, string expression, double value,
|
||||||
|
bool inline = false, bool global = false)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Expression = expression;
|
||||||
|
Value = value;
|
||||||
|
Inline = inline;
|
||||||
|
Global = global;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the rotation that maps a drawing to its canonical (MBR-axis-aligned) frame.
|
||||||
|
/// Lives in OpenNest.Core so Drawing.Program setter can invoke it directly without
|
||||||
|
/// a circular dependency on OpenNest.Engine.
|
||||||
|
/// </summary>
|
||||||
|
public static class CanonicalAngle
|
||||||
|
{
|
||||||
|
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
|
||||||
|
public const double SnapToZero = 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
|
||||||
|
/// across both callers.
|
||||||
|
/// </summary>
|
||||||
|
public static double FromMbr(BoundingRectangleResult mbr)
|
||||||
|
{
|
||||||
|
if (mbr.Area <= OpenNest.Math.Tolerance.Epsilon)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
// The MBR edge angle can represent any of four equivalent orientations
|
||||||
|
// (edge-i, edge-i + π/2, edge-i + π, edge-i - π/2) depending on which hull
|
||||||
|
// edge the algorithm happened to pick. Normalize -mbr.Angle to the
|
||||||
|
// representative in [-π/4, π/4] so snap-to-zero works for inputs near
|
||||||
|
// ANY of the equivalent orientations.
|
||||||
|
var angle = -mbr.Angle;
|
||||||
|
const double halfPi = System.Math.PI / 2.0;
|
||||||
|
angle -= halfPi * System.Math.Round(angle / halfPi);
|
||||||
|
|
||||||
|
if (System.Math.Abs(angle) < SnapToZero)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
return angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double Compute(Drawing drawing)
|
||||||
|
{
|
||||||
|
if (drawing?.Program == null)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||||
|
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||||
|
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
|
if (shapes.Count == 0)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
var perimeter = shapes[0];
|
||||||
|
var perimeterArea = perimeter.Area();
|
||||||
|
for (var i = 1; i < shapes.Count; i++)
|
||||||
|
{
|
||||||
|
var area = shapes[i].Area();
|
||||||
|
if (area > perimeterArea)
|
||||||
|
{
|
||||||
|
perimeter = shapes[i];
|
||||||
|
perimeterArea = area;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var polygon = perimeter.ToPolygonWithTolerance(0.1);
|
||||||
|
if (polygon == null || polygon.Vertices.Count < 3)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
var hull = ConvexHull.Compute(polygon.Vertices);
|
||||||
|
if (hull.Vertices.Count < 3)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
|
||||||
|
return FromMbr(mbr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,8 @@ namespace OpenNest.Collections
|
|||||||
public bool Remove(T item)
|
public bool Remove(T item)
|
||||||
{
|
{
|
||||||
var success = items.Remove(item);
|
var success = items.Remove(item);
|
||||||
ItemRemoved?.Invoke(this, new ItemRemovedEventArgs<T>(item, success));
|
if (success)
|
||||||
|
ItemRemoved?.Invoke(this, new ItemRemovedEventArgs<T>(item, success));
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using OpenNest.Geometry;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest.Converters
|
||||||
|
{
|
||||||
|
public enum ContourClassification
|
||||||
|
{
|
||||||
|
Perimeter,
|
||||||
|
Hole,
|
||||||
|
Etch,
|
||||||
|
Open
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ContourInfo
|
||||||
|
{
|
||||||
|
public Shape Shape { get; }
|
||||||
|
public ContourClassification Type { get; private set; }
|
||||||
|
public string Label { get; private set; }
|
||||||
|
|
||||||
|
private ContourInfo(Shape shape, ContourClassification type, string label)
|
||||||
|
{
|
||||||
|
Shape = shape;
|
||||||
|
Type = type;
|
||||||
|
Label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DirectionLabel
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (Type == ContourClassification.Open || Type == ContourClassification.Etch)
|
||||||
|
return "Open";
|
||||||
|
var poly = Shape.ToPolygon();
|
||||||
|
if (poly == null || poly.Vertices.Count < 3)
|
||||||
|
return "?";
|
||||||
|
return poly.RotationDirection() == RotationType.CW ? "CW" : "CCW";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DimensionLabel
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (Shape.Entities.Count == 1 && Shape.Entities[0] is Circle c)
|
||||||
|
return $"Circle R{c.Radius:0.#}";
|
||||||
|
Shape.UpdateBounds();
|
||||||
|
var box = Shape.BoundingBox;
|
||||||
|
return $"{box.Width:0.#} x {box.Length:0.#}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reverse()
|
||||||
|
{
|
||||||
|
Shape.Reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetLabel(string label)
|
||||||
|
{
|
||||||
|
Label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ContourInfo> Classify(List<Shape> shapes)
|
||||||
|
{
|
||||||
|
if (shapes.Count == 0)
|
||||||
|
return new List<ContourInfo>();
|
||||||
|
|
||||||
|
// Ensure bounding boxes are up to date before comparing
|
||||||
|
foreach (var s in shapes)
|
||||||
|
s.UpdateBounds();
|
||||||
|
|
||||||
|
// Find perimeter — largest bounding box area
|
||||||
|
var perimeterIndex = 0;
|
||||||
|
var maxArea = shapes[0].BoundingBox.Area();
|
||||||
|
for (var i = 1; i < shapes.Count; i++)
|
||||||
|
{
|
||||||
|
var area = shapes[i].BoundingBox.Area();
|
||||||
|
if (area > maxArea)
|
||||||
|
{
|
||||||
|
maxArea = area;
|
||||||
|
perimeterIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<ContourInfo>();
|
||||||
|
var holeCount = 0;
|
||||||
|
var etchCount = 0;
|
||||||
|
var openCount = 0;
|
||||||
|
|
||||||
|
// Non-perimeter shapes first (matches CNC cut order: holes before perimeter)
|
||||||
|
for (var i = 0; i < shapes.Count; i++)
|
||||||
|
{
|
||||||
|
if (i == perimeterIndex) continue;
|
||||||
|
var shape = shapes[i];
|
||||||
|
var type = ClassifyShape(shape);
|
||||||
|
|
||||||
|
string label;
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case ContourClassification.Hole:
|
||||||
|
holeCount++;
|
||||||
|
label = $"Hole {holeCount}";
|
||||||
|
break;
|
||||||
|
case ContourClassification.Etch:
|
||||||
|
etchCount++;
|
||||||
|
label = etchCount == 1 ? "Etch" : $"Etch {etchCount}";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
openCount++;
|
||||||
|
label = openCount == 1 ? "Open" : $"Open {openCount}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(new ContourInfo(shape, type, label));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perimeter last
|
||||||
|
result.Add(new ContourInfo(shapes[perimeterIndex], ContourClassification.Perimeter, "Perimeter"));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)))
|
||||||
|
return ContourClassification.Etch;
|
||||||
|
|
||||||
|
if (shape.IsClosed())
|
||||||
|
return ContourClassification.Hole;
|
||||||
|
|
||||||
|
return ContourClassification.Open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace OpenNest.Converters
|
namespace OpenNest.Converters
|
||||||
@@ -81,12 +82,22 @@ namespace OpenNest.Converters
|
|||||||
var startpt = arc.StartPoint();
|
var startpt = arc.StartPoint();
|
||||||
var endpt = arc.EndPoint();
|
var endpt = arc.EndPoint();
|
||||||
|
|
||||||
if (startpt != lastpt)
|
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(startpt);
|
pgm.MoveTo(startpt);
|
||||||
|
|
||||||
lastpt = endpt;
|
lastpt = endpt;
|
||||||
|
|
||||||
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
|
var layer = ClassifyLayer(arc);
|
||||||
|
var sweep = System.Math.Abs(arc.SweepAngle());
|
||||||
|
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
||||||
|
{
|
||||||
|
pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pgm.Codes.Add(new ArcMove(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer });
|
||||||
|
}
|
||||||
|
|
||||||
return lastpt;
|
return lastpt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,10 +105,10 @@ namespace OpenNest.Converters
|
|||||||
{
|
{
|
||||||
var startpt = new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
|
var startpt = new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
|
||||||
|
|
||||||
if (startpt != lastpt)
|
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(startpt);
|
pgm.MoveTo(startpt);
|
||||||
|
|
||||||
pgm.ArcTo(startpt, circle.Center, RotationType.CCW);
|
pgm.Codes.Add(new ArcMove(startpt, circle.Center, circle.Rotation) { Layer = ClassifyLayer(circle) });
|
||||||
|
|
||||||
lastpt = startpt;
|
lastpt = startpt;
|
||||||
return lastpt;
|
return lastpt;
|
||||||
@@ -105,16 +116,25 @@ namespace OpenNest.Converters
|
|||||||
|
|
||||||
private static Vector AddLine(Program pgm, Vector lastpt, Line line)
|
private static Vector AddLine(Program pgm, Vector lastpt, Line line)
|
||||||
{
|
{
|
||||||
if (line.StartPoint != lastpt)
|
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(line.StartPoint);
|
pgm.MoveTo(line.StartPoint);
|
||||||
|
|
||||||
var move = new LinearMove(line.EndPoint);
|
pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
|
||||||
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
|
||||||
move.Layer = LayerType.Scribe;
|
|
||||||
pgm.Codes.Add(move);
|
|
||||||
|
|
||||||
lastpt = line.EndPoint;
|
lastpt = line.EndPoint;
|
||||||
return lastpt;
|
return lastpt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Engrave/etch geometry maps to Scribe so the post processor can treat it as a
|
||||||
|
// separate tool pass; everything else keeps the move's default Cut layer.
|
||||||
|
private static LayerType ClassifyLayer(Entity geo)
|
||||||
|
{
|
||||||
|
var name = geo.Layer?.Name;
|
||||||
|
if (string.Equals(name, "ENGRAVE", System.StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
||||||
|
return LayerType.Scribe;
|
||||||
|
|
||||||
|
return LayerType.Cut;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Converters
|
namespace OpenNest.Converters
|
||||||
@@ -9,7 +9,6 @@ namespace OpenNest.Converters
|
|||||||
/// Converts the program to absolute coordinates.
|
/// Converts the program to absolute coordinates.
|
||||||
/// Does NOT check program mode before converting.
|
/// Does NOT check program mode before converting.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pgm"></param>
|
|
||||||
public static void ToAbsolute(Program pgm)
|
public static void ToAbsolute(Program pgm)
|
||||||
{
|
{
|
||||||
var pos = new Vector(0, 0);
|
var pos = new Vector(0, 0);
|
||||||
@@ -17,21 +16,27 @@ namespace OpenNest.Converters
|
|||||||
for (int i = 0; i < pgm.Codes.Count; ++i)
|
for (int i = 0; i < pgm.Codes.Count; ++i)
|
||||||
{
|
{
|
||||||
var code = pgm.Codes[i];
|
var code = pgm.Codes[i];
|
||||||
var motion = code as Motion;
|
|
||||||
|
|
||||||
if (motion != null)
|
if (code is SubProgramCall subCall && subCall.Program != null)
|
||||||
{
|
{
|
||||||
motion.Offset(pos);
|
// Sub-program is placed at Offset in this program's frame.
|
||||||
|
// After it runs, the tool is at Offset + (sub's end in its own frame).
|
||||||
|
pos = ComputeEndPosition(subCall.Program, subCall.Offset);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code is Motion motion)
|
||||||
|
{
|
||||||
|
motion.Offset(pos.X, pos.Y);
|
||||||
pos = motion.EndPoint;
|
pos = motion.EndPoint;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts the program to intermental coordinates.
|
/// Converts the program to incremental coordinates.
|
||||||
/// Does NOT check program mode before converting.
|
/// Does NOT check program mode before converting.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pgm"></param>
|
|
||||||
public static void ToIncremental(Program pgm)
|
public static void ToIncremental(Program pgm)
|
||||||
{
|
{
|
||||||
var pos = new Vector(0, 0);
|
var pos = new Vector(0, 0);
|
||||||
@@ -39,9 +44,16 @@ namespace OpenNest.Converters
|
|||||||
for (int i = 0; i < pgm.Codes.Count; ++i)
|
for (int i = 0; i < pgm.Codes.Count; ++i)
|
||||||
{
|
{
|
||||||
var code = pgm.Codes[i];
|
var code = pgm.Codes[i];
|
||||||
var motion = code as Motion;
|
|
||||||
|
|
||||||
if (motion != null)
|
if (code is SubProgramCall subCall && subCall.Program != null)
|
||||||
|
{
|
||||||
|
// Sub-program is placed at Offset in this program's frame,
|
||||||
|
// regardless of where the tool was before the call.
|
||||||
|
pos = ComputeEndPosition(subCall.Program, subCall.Offset);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code is Motion motion)
|
||||||
{
|
{
|
||||||
var pos2 = motion.EndPoint;
|
var pos2 = motion.EndPoint;
|
||||||
motion.Offset(-pos.X, -pos.Y);
|
motion.Offset(-pos.X, -pos.Y);
|
||||||
@@ -49,5 +61,37 @@ namespace OpenNest.Converters
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the tool position after executing <paramref name="pgm"/>,
|
||||||
|
/// given that the program's frame origin is at <paramref name="startPos"/>
|
||||||
|
/// in the caller's frame. Walks nested sub-program calls recursively.
|
||||||
|
/// </summary>
|
||||||
|
private static Vector ComputeEndPosition(Program pgm, Vector startPos)
|
||||||
|
{
|
||||||
|
var pos = startPos;
|
||||||
|
|
||||||
|
for (int i = 0; i < pgm.Codes.Count; ++i)
|
||||||
|
{
|
||||||
|
var code = pgm.Codes[i];
|
||||||
|
|
||||||
|
if (code is SubProgramCall subCall && subCall.Program != null)
|
||||||
|
{
|
||||||
|
// Nested sub's frame origin in the caller's frame is startPos + Offset.
|
||||||
|
pos = ComputeEndPosition(subCall.Program, startPos + subCall.Offset);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code is Motion motion)
|
||||||
|
{
|
||||||
|
if (pgm.Mode == Mode.Incremental)
|
||||||
|
pos = pos + motion.EndPoint;
|
||||||
|
else
|
||||||
|
pos = startPos + motion.EndPoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ namespace OpenNest.Converters
|
|||||||
|
|
||||||
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.
|
||||||
|
var frameOrigin = curpos;
|
||||||
mode = program.Mode;
|
mode = program.Mode;
|
||||||
|
|
||||||
for (int i = 0; i < program.Length; ++i)
|
for (int i = 0; i < program.Length; ++i)
|
||||||
@@ -41,12 +44,15 @@ namespace OpenNest.Converters
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case CodeType.SubProgramCall:
|
case CodeType.SubProgramCall:
|
||||||
var tmpmode = mode;
|
|
||||||
var subpgm = (SubProgramCall)code;
|
var subpgm = (SubProgramCall)code;
|
||||||
var geoProgram = new Shape();
|
var savedMode = mode;
|
||||||
AddProgram(subpgm.Program, ref mode, ref curpos, ref geoProgram.Entities);
|
|
||||||
geometry.Add(geoProgram);
|
// The sub-program's frame origin in this program's frame is
|
||||||
mode = tmpmode;
|
// frameOrigin + Offset — independent of current tool position.
|
||||||
|
curpos = new Vector(frameOrigin.X + subpgm.Offset.X, frameOrigin.Y + subpgm.Offset.Y);
|
||||||
|
|
||||||
|
AddProgram(subpgm.Program, ref mode, ref curpos, ref geometry);
|
||||||
|
mode = savedMode;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +112,7 @@ namespace OpenNest.Converters
|
|||||||
var layer = ConvertLayer(arcMove.Layer);
|
var layer = ConvertLayer(arcMove.Layer);
|
||||||
|
|
||||||
if (startAngle.IsEqualTo(endAngle))
|
if (startAngle.IsEqualTo(endAngle))
|
||||||
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color });
|
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color, Rotation = arcMove.Rotation });
|
||||||
else
|
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 });
|
||||||
|
|
||||||
|
|||||||
@@ -50,13 +50,13 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
cutPosition = Position.X;
|
cutPosition = Position.X;
|
||||||
lineStart = StartLimit ?? bounds.Y;
|
lineStart = StartLimit ?? bounds.Y;
|
||||||
lineEnd = EndLimit ?? (bounds.Y + bounds.Length + settings.Overtravel);
|
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + settings.Overtravel);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
cutPosition = Position.Y;
|
cutPosition = Position.Y;
|
||||||
lineStart = StartLimit ?? bounds.X;
|
lineStart = StartLimit ?? bounds.X;
|
||||||
lineEnd = EndLimit ?? (bounds.X + bounds.Width + settings.Overtravel);
|
lineEnd = EndLimit ?? (bounds.X + bounds.Length + settings.Overtravel);
|
||||||
}
|
}
|
||||||
|
|
||||||
var exclusions = new List<(double Start, double End)>();
|
var exclusions = new List<(double Start, double End)>();
|
||||||
@@ -176,13 +176,13 @@ namespace OpenNest
|
|||||||
|
|
||||||
private (double Min, double Max) AxisBounds(Box bb, double clearance) =>
|
private (double Min, double Max) AxisBounds(Box bb, double clearance) =>
|
||||||
Axis == CutOffAxis.Vertical
|
Axis == CutOffAxis.Vertical
|
||||||
? (bb.X - clearance, bb.X + bb.Width + clearance)
|
? (bb.X - clearance, bb.X + bb.Length + clearance)
|
||||||
: (bb.Y - clearance, bb.Y + bb.Length + clearance);
|
: (bb.Y - clearance, bb.Y + bb.Width + clearance);
|
||||||
|
|
||||||
private (double Start, double End) CrossAxisBounds(Box bb, double clearance) =>
|
private (double Start, double End) CrossAxisBounds(Box bb, double clearance) =>
|
||||||
Axis == CutOffAxis.Vertical
|
Axis == CutOffAxis.Vertical
|
||||||
? (bb.Y - clearance, bb.Y + bb.Length + clearance)
|
? (bb.Y - clearance, bb.Y + bb.Width + clearance)
|
||||||
: (bb.X - clearance, bb.X + 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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -12,8 +13,32 @@ namespace OpenNest
|
|||||||
public class Drawing
|
public class Drawing
|
||||||
{
|
{
|
||||||
private static int nextId;
|
private static int nextId;
|
||||||
|
private static int nextColorIndex;
|
||||||
private Program program;
|
private Program program;
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Color GetNextColor()
|
||||||
|
{
|
||||||
|
var color = PartColors[nextColorIndex % PartColors.Length];
|
||||||
|
nextColorIndex++;
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
public Drawing()
|
public Drawing()
|
||||||
: this(string.Empty, new Program())
|
: this(string.Empty, new Program())
|
||||||
{
|
{
|
||||||
@@ -29,9 +54,9 @@ namespace OpenNest
|
|||||||
Id = Interlocked.Increment(ref nextId);
|
Id = Interlocked.Increment(ref nextId);
|
||||||
Name = name;
|
Name = name;
|
||||||
Material = new Material();
|
Material = new Material();
|
||||||
Program = pgm;
|
|
||||||
Constraints = new NestConstraints();
|
Constraints = new NestConstraints();
|
||||||
Source = new SourceInfo();
|
Source = new SourceInfo();
|
||||||
|
Program = pgm;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int Id { get; }
|
public int Id { get; }
|
||||||
@@ -53,9 +78,29 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
program = value;
|
program = value;
|
||||||
UpdateArea();
|
UpdateArea();
|
||||||
|
RecomputeCanonicalAngle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recomputes and stores the canonical angle from the current Program.
|
||||||
|
/// Callers that mutate Program in place (rather than reassigning it) must invoke this explicitly.
|
||||||
|
/// Cut-off drawings are left with Angle=0.
|
||||||
|
/// </summary>
|
||||||
|
public void RecomputeCanonicalAngle()
|
||||||
|
{
|
||||||
|
if (Source == null)
|
||||||
|
Source = new SourceInfo();
|
||||||
|
|
||||||
|
if (program == null || IsCutOff)
|
||||||
|
{
|
||||||
|
Source.Angle = 0.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Source.Angle = CanonicalAngle.Compute(this);
|
||||||
|
}
|
||||||
|
|
||||||
public Color Color { get; set; }
|
public Color Color { get; set; }
|
||||||
|
|
||||||
public bool IsCutOff { get; set; }
|
public bool IsCutOff { get; set; }
|
||||||
@@ -66,6 +111,18 @@ namespace OpenNest
|
|||||||
|
|
||||||
public List<Bend> Bends { get; set; } = new List<Bend>();
|
public List<Bend> Bends { get; set; } = new List<Bend>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Complete set of source entities with stable GUIDs.
|
||||||
|
/// Null when the drawing was created from G-code or an older nest file.
|
||||||
|
/// </summary>
|
||||||
|
public List<Entity> SourceEntities { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IDs of entities in <see cref="SourceEntities"/> that are suppressed (hidden).
|
||||||
|
/// Suppressed entities are excluded from the active Program but preserved for re-enabling.
|
||||||
|
/// </summary>
|
||||||
|
public HashSet<Guid> SuppressedEntityIds { get; set; } = new HashSet<Guid>();
|
||||||
|
|
||||||
public double Area { get; protected set; }
|
public double Area { get; protected set; }
|
||||||
|
|
||||||
public void UpdateArea()
|
public void UpdateArea()
|
||||||
@@ -126,5 +183,15 @@ namespace OpenNest
|
|||||||
/// Offset distances to the original location.
|
/// Offset distances to the original location.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Vector Offset { get; set; }
|
public Vector Offset { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rotation (radians) that maps the source program geometry to its canonical
|
||||||
|
/// (MBR-axis-aligned) frame. Populated automatically by the <see cref="Drawing.Program"/>
|
||||||
|
/// setter via <see cref="CanonicalAngle.Compute"/>. A value of 0 means the drawing is
|
||||||
|
/// already canonical or <see cref="Drawing.IsCutOff"/> is true. Callers that mutate
|
||||||
|
/// <see cref="Drawing.Program"/> in place must invoke
|
||||||
|
/// <see cref="Drawing.RecomputeCanonicalAngle"/> to refresh.
|
||||||
|
/// </summary>
|
||||||
|
public double Angle { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsFullCircle() =>
|
||||||
|
SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Angle in radians between start and end angles.
|
/// Angle in radians between start and end angles.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -155,6 +158,17 @@ namespace OpenNest.Geometry
|
|||||||
Center.Y + Radius * System.Math.Sin(EndAngle));
|
Center.Y + Radius * System.Math.Sin(EndAngle));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mid point of the arc (point at the angle midway between start and end).
|
||||||
|
/// </summary>
|
||||||
|
public Vector MidPoint()
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Splits the arc at the given point, returning two sub-arcs.
|
/// Splits the arc at the given point, returning two sub-arcs.
|
||||||
/// Either half may be null if the split point coincides with an endpoint.
|
/// Either half may be null if the split point coincides with an endpoint.
|
||||||
@@ -256,6 +270,13 @@ namespace OpenNest.Geometry
|
|||||||
get { return Diameter * System.Math.PI * SweepAngle() / Angle.TwoPI; }
|
get { return Diameter * System.Math.PI * SweepAngle() / Angle.TwoPI; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Arc(center, radius, startAngle, endAngle, reversed);
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reverses the rotation direction.
|
/// Reverses the rotation direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -386,31 +407,34 @@ namespace OpenNest.Geometry
|
|||||||
maxY = startpt.Y;
|
maxY = startpt.Y;
|
||||||
}
|
}
|
||||||
|
|
||||||
var angle1 = StartAngle;
|
var sweep = SweepAngle();
|
||||||
var angle2 = EndAngle;
|
if (sweep > Tolerance.Epsilon)
|
||||||
|
{
|
||||||
|
var angle1 = StartAngle;
|
||||||
|
var angle2 = EndAngle;
|
||||||
|
|
||||||
// switch the angle to counter clockwise.
|
if (IsReversed)
|
||||||
if (IsReversed)
|
Generic.Swap(ref angle1, ref angle2);
|
||||||
Generic.Swap(ref angle1, ref angle2);
|
|
||||||
|
|
||||||
if (Angle.IsBetweenRad(Angle.HalfPI, angle1, angle2))
|
if (Angle.IsBetweenRad(Angle.HalfPI, angle1, angle2))
|
||||||
maxY = Center.Y + Radius;
|
maxY = Center.Y + Radius;
|
||||||
|
|
||||||
if (Angle.IsBetweenRad(System.Math.PI, angle1, angle2))
|
if (Angle.IsBetweenRad(System.Math.PI, angle1, angle2))
|
||||||
minX = Center.X - Radius;
|
minX = Center.X - Radius;
|
||||||
|
|
||||||
const double oneHalfPI = System.Math.PI * 1.5;
|
const double oneHalfPI = System.Math.PI * 1.5;
|
||||||
|
|
||||||
if (Angle.IsBetweenRad(oneHalfPI, angle1, angle2))
|
if (Angle.IsBetweenRad(oneHalfPI, angle1, angle2))
|
||||||
minY = Center.Y - Radius;
|
minY = Center.Y - Radius;
|
||||||
|
|
||||||
if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2))
|
if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2))
|
||||||
maxX = Center.X + Radius;
|
maxX = Center.X + Radius;
|
||||||
|
}
|
||||||
|
|
||||||
boundingBox.X = minX;
|
boundingBox.X = minX;
|
||||||
boundingBox.Y = minY;
|
boundingBox.Y = minY;
|
||||||
boundingBox.Width = maxX - minX;
|
boundingBox.Length = maxX - minX;
|
||||||
boundingBox.Length = maxY - minY;
|
boundingBox.Width = maxY - minY;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.Geometry
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Shared arc-fitting utilities used by SplineConverter and GeometrySimplifier.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ArcFit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Fits a circular arc constrained to be tangent to the given direction at the
|
||||||
|
/// first point. The center lies at the intersection of the normal at P1 (perpendicular
|
||||||
|
/// to the tangent) and the perpendicular bisector of the chord P1->Pn, guaranteeing
|
||||||
|
/// 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)
|
||||||
|
{
|
||||||
|
if (points.Count < 3)
|
||||||
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
|
|
||||||
|
var p1 = points[0];
|
||||||
|
var pn = points[^1];
|
||||||
|
|
||||||
|
var mx = (p1.X + pn.X) / 2;
|
||||||
|
var my = (p1.Y + pn.Y) / 2;
|
||||||
|
var dx = pn.X - p1.X;
|
||||||
|
var dy = pn.Y - p1.Y;
|
||||||
|
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||||
|
if (chordLen < 1e-10)
|
||||||
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
|
|
||||||
|
var bx = -dy / chordLen;
|
||||||
|
var by = dx / chordLen;
|
||||||
|
|
||||||
|
var tLen = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
|
||||||
|
if (tLen < 1e-10)
|
||||||
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
|
|
||||||
|
var nx = -tangent.Y / tLen;
|
||||||
|
var ny = tangent.X / tLen;
|
||||||
|
|
||||||
|
var det = nx * by - ny * bx;
|
||||||
|
if (System.Math.Abs(det) < 1e-10)
|
||||||
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
|
|
||||||
|
var s = ((mx - p1.X) * by - (my - p1.Y) * bx) / det;
|
||||||
|
|
||||||
|
var cx = p1.X + s * nx;
|
||||||
|
var cy = p1.Y + s * ny;
|
||||||
|
var radius = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
||||||
|
|
||||||
|
if (radius < 1e-10)
|
||||||
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
|
|
||||||
|
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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) FitThroughEndpointsWithTangents(
|
||||||
|
List<Vector> points, Vector startTangent, Vector endTangent)
|
||||||
|
{
|
||||||
|
if (points.Count < 3)
|
||||||
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
|
|
||||||
|
var p1 = points[0];
|
||||||
|
var pn = points[^1];
|
||||||
|
|
||||||
|
var dx = pn.X - p1.X;
|
||||||
|
var dy = pn.Y - p1.Y;
|
||||||
|
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||||
|
if (chordLen < 1e-10)
|
||||||
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
|
|
||||||
|
var ux = dx / chordLen;
|
||||||
|
var uy = dy / chordLen;
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
var maxDev = 0.0;
|
||||||
|
for (var i = 1; i < points.Count - 1; i++)
|
||||||
|
{
|
||||||
|
var px = points[i].X - cx;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return maxDev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,8 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
double minX = boxes[0].X;
|
double minX = boxes[0].X;
|
||||||
double minY = boxes[0].Y;
|
double minY = boxes[0].Y;
|
||||||
double maxX = boxes[0].X + boxes[0].Width;
|
double maxX = boxes[0].Right;
|
||||||
double maxY = boxes[0].Y + boxes[0].Length;
|
double maxY = boxes[0].Top;
|
||||||
|
|
||||||
foreach (var box in boxes)
|
foreach (var box in boxes)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
using OpenNest.Math;
|
using System;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.Geometry
|
namespace OpenNest.Geometry
|
||||||
{
|
{
|
||||||
public class Box
|
public class Box : IComparable<Box>
|
||||||
{
|
{
|
||||||
public static readonly Box Empty = new Box();
|
public static readonly Box Empty = new Box();
|
||||||
|
|
||||||
@@ -14,15 +15,15 @@ namespace OpenNest.Geometry
|
|||||||
public Box(double x, double y, double w, double h)
|
public Box(double x, double y, double w, double h)
|
||||||
{
|
{
|
||||||
Location = new Vector(x, y);
|
Location = new Vector(x, y);
|
||||||
Width = w;
|
Length = w;
|
||||||
Length = h;
|
Width = h;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Vector Location;
|
public Vector Location;
|
||||||
|
|
||||||
public Vector Center
|
public Vector Center
|
||||||
{
|
{
|
||||||
get { return new Vector(X + Width * 0.5, Y + Length * 0.5); }
|
get { return new Vector(X + Length * 0.5, Y + Width * 0.5); }
|
||||||
}
|
}
|
||||||
|
|
||||||
public Size Size;
|
public Size Size;
|
||||||
@@ -76,12 +77,12 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
public Box Translate(double x, double y)
|
public Box Translate(double x, double y)
|
||||||
{
|
{
|
||||||
return new Box(X + x, Y + y, Width, Length);
|
return new Box(X + x, Y + y, Length, Width);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Box Translate(Vector offset)
|
public Box Translate(Vector offset)
|
||||||
{
|
{
|
||||||
return new Box(X + offset.X, Y + offset.Y, Width, Length);
|
return new Box(X + offset.X, Y + offset.Y, Length, Width);
|
||||||
}
|
}
|
||||||
|
|
||||||
public double Left
|
public double Left
|
||||||
@@ -91,12 +92,12 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
public double Right
|
public double Right
|
||||||
{
|
{
|
||||||
get { return X + Width; }
|
get { return X + Length; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public double Top
|
public double Top
|
||||||
{
|
{
|
||||||
get { return Y + Length; }
|
get { return Y + Width; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public double Bottom
|
public double Bottom
|
||||||
@@ -207,12 +208,26 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
public Box Offset(double d)
|
public Box Offset(double d)
|
||||||
{
|
{
|
||||||
return new Box(X - d, Y - d, Width + d * 2, Length + d * 2);
|
return new Box(X - d, Y - d, Length + d * 2, Width + d * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
{
|
{
|
||||||
return string.Format("[Box: X={0}, Y={1}, Width={2}, Length={3}]", X, Y, Width, Length);
|
return string.Format("[Box: X={0}, Y={1}, Width={2}, Length={3}]", X, Y, Width, Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int CompareTo(Box other)
|
||||||
|
{
|
||||||
|
var cmp = Width.CompareTo(other.Width);
|
||||||
|
return cmp != 0 ? cmp : Length.CompareTo(other.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator >(Box a, Box b) => a.CompareTo(b) > 0;
|
||||||
|
|
||||||
|
public static bool operator <(Box a, Box b) => a.CompareTo(b) < 0;
|
||||||
|
|
||||||
|
public static bool operator >=(Box a, Box b) => a.CompareTo(b) >= 0;
|
||||||
|
|
||||||
|
public static bool operator <=(Box a, Box b) => a.CompareTo(b) <= 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
var x = large.Left;
|
var x = large.Left;
|
||||||
var y = small.Top;
|
var y = small.Top;
|
||||||
var w = large.Width;
|
var w = large.Length;
|
||||||
var h = large.Top - y;
|
var h = large.Top - y;
|
||||||
|
|
||||||
return new Box(x, y, w, h);
|
return new Box(x, y, w, h);
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
var x = large.Left;
|
var x = large.Left;
|
||||||
var y = large.Bottom;
|
var y = large.Bottom;
|
||||||
var w = small.Left - x;
|
var w = small.Left - x;
|
||||||
var h = large.Length;
|
var h = large.Width;
|
||||||
|
|
||||||
return new Box(x, y, w, h);
|
return new Box(x, y, w, h);
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
var x = large.Left;
|
var x = large.Left;
|
||||||
var y = large.Bottom;
|
var y = large.Bottom;
|
||||||
var w = large.Width;
|
var w = large.Length;
|
||||||
var h = small.Top - y;
|
var h = small.Top - y;
|
||||||
|
|
||||||
return new Box(x, y, w, h);
|
return new Box(x, y, w, h);
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
var x = small.Right;
|
var x = small.Right;
|
||||||
var y = large.Bottom;
|
var y = large.Bottom;
|
||||||
var w = large.Right - x;
|
var w = large.Right - x;
|
||||||
var h = large.Length;
|
var h = large.Width;
|
||||||
|
|
||||||
return new Box(x, y, w, h);
|
return new Box(x, y, w, h);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,9 @@ namespace OpenNest.Geometry
|
|||||||
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
|
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
|
||||||
{
|
{
|
||||||
var points = new List<Vector>();
|
var points = new List<Vector>();
|
||||||
var stepAngle = Angle.TwoPI / segments;
|
var stepAngle = Rotation == RotationType.CW
|
||||||
|
? -Angle.TwoPI / segments
|
||||||
|
: Angle.TwoPI / segments;
|
||||||
|
|
||||||
var r = circumscribe && segments > 0
|
var r = circumscribe && segments > 0
|
||||||
? Radius / System.Math.Cos(stepAngle / 2.0)
|
? Radius / System.Math.Cos(stepAngle / 2.0)
|
||||||
@@ -163,6 +165,13 @@ namespace OpenNest.Geometry
|
|||||||
get { return Circumference(); }
|
get { return Circumference(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Circle(center, radius) { Rotation = Rotation };
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reverses the rotation direction.
|
/// Reverses the rotation direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
using OpenNest.Math;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.Geometry
|
||||||
|
{
|
||||||
|
public static class Collision
|
||||||
|
{
|
||||||
|
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))
|
||||||
|
return CollisionResult.None;
|
||||||
|
|
||||||
|
// Step 2: Quick intersection test for crossing points
|
||||||
|
var intersectionPoints = FindCrossingPoints(a, b);
|
||||||
|
|
||||||
|
// Step 3: Convex decomposition
|
||||||
|
var trisA = TriangulateWithBounds(a);
|
||||||
|
var trisB = TriangulateWithBounds(b);
|
||||||
|
|
||||||
|
// Step 4: Clip all triangle pairs
|
||||||
|
var regions = new List<Polygon>();
|
||||||
|
|
||||||
|
foreach (var triA in trisA)
|
||||||
|
{
|
||||||
|
foreach (var triB in trisB)
|
||||||
|
{
|
||||||
|
if (!BoundingBoxesOverlap(triA.BoundingBox, triB.BoundingBox))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var clipped = ClipConvex(triA, triB);
|
||||||
|
if (clipped != null)
|
||||||
|
regions.Add(clipped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Hole subtraction
|
||||||
|
if (regions.Count > 0)
|
||||||
|
regions = SubtractHoles(regions, holesA, holesB);
|
||||||
|
|
||||||
|
if (regions.Count == 0)
|
||||||
|
return new CollisionResult(false, regions, intersectionPoints);
|
||||||
|
|
||||||
|
// Step 6: Build result
|
||||||
|
return new CollisionResult(true, regions, intersectionPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasOverlap(Polygon a, Polygon b,
|
||||||
|
List<Polygon> holesA = null, List<Polygon> holesB = null)
|
||||||
|
{
|
||||||
|
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Full check is needed: crossing points alone miss containment cases
|
||||||
|
// (one polygon entirely inside another has zero edge crossings).
|
||||||
|
return Check(a, b, holesA, holesB).Overlaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<CollisionResult> CheckAll(List<Polygon> polygons,
|
||||||
|
List<List<Polygon>> holes = null)
|
||||||
|
{
|
||||||
|
var results = new List<CollisionResult>();
|
||||||
|
|
||||||
|
for (var i = 0; i < polygons.Count; i++)
|
||||||
|
{
|
||||||
|
for (var j = i + 1; j < polygons.Count; j++)
|
||||||
|
{
|
||||||
|
var holesA = holes != null && i < holes.Count ? holes[i] : null;
|
||||||
|
var holesB = holes != null && j < holes.Count ? holes[j] : null;
|
||||||
|
var result = Check(polygons[i], polygons[j], holesA, holesB);
|
||||||
|
|
||||||
|
if (result.Overlaps)
|
||||||
|
results.Add(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasAnyOverlap(List<Polygon> polygons,
|
||||||
|
List<List<Polygon>> holes = null)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < polygons.Count; i++)
|
||||||
|
{
|
||||||
|
for (var j = i + 1; j < polygons.Count; j++)
|
||||||
|
{
|
||||||
|
var holesA = holes != null && i < holes.Count ? holes[i] : null;
|
||||||
|
var holesB = holes != null && j < holes.Count ? holes[j] : null;
|
||||||
|
|
||||||
|
if (HasOverlap(polygons[i], polygons[j], holesA, holesB))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Vector> FindCrossingPoints(Polygon a, Polygon b)
|
||||||
|
{
|
||||||
|
if (!Intersect.Intersects(a, b, out var rawPts))
|
||||||
|
return new List<Vector>();
|
||||||
|
|
||||||
|
// Filter boundary contacts (vertex touches)
|
||||||
|
var vertsA = CollectVertices(a);
|
||||||
|
var vertsB = CollectVertices(b);
|
||||||
|
var filtered = new List<Vector>();
|
||||||
|
|
||||||
|
foreach (var pt in rawPts)
|
||||||
|
{
|
||||||
|
if (IsNearAnyVertex(pt, vertsA) || IsNearAnyVertex(pt, vertsB))
|
||||||
|
continue;
|
||||||
|
filtered.Add(pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Vector> CollectVertices(Polygon polygon)
|
||||||
|
{
|
||||||
|
var verts = new List<Vector>(polygon.Vertices.Count);
|
||||||
|
foreach (var v in polygon.Vertices)
|
||||||
|
verts.Add(v);
|
||||||
|
return verts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNearAnyVertex(Vector pt, List<Vector> vertices)
|
||||||
|
{
|
||||||
|
foreach (var v in vertices)
|
||||||
|
{
|
||||||
|
if (pt.X.IsEqualTo(v.X) && pt.Y.IsEqualTo(v.Y))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Triangulates a polygon and ensures each triangle has its bounding box updated.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Polygon> TriangulateWithBounds(Polygon polygon)
|
||||||
|
{
|
||||||
|
var tris = ConvexDecomposition.Triangulate(polygon);
|
||||||
|
foreach (var tri in tris)
|
||||||
|
tri.UpdateBounds();
|
||||||
|
return tris;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sutherland-Hodgman polygon clipping. Clips subject against each edge
|
||||||
|
/// of clip. Both must be convex. Returns null if no overlap.
|
||||||
|
/// </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++)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cross product of vectors (edgeStart->edgeEnd) and (edgeStart->point).
|
||||||
|
/// Positive = point is left of edge (inside for CCW polygon).
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subtracts holes from overlap regions.
|
||||||
|
/// </summary>
|
||||||
|
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 (allHoles.Count == 0)
|
||||||
|
return regions;
|
||||||
|
|
||||||
|
foreach (var hole in allHoles)
|
||||||
|
{
|
||||||
|
var holeTris = TriangulateWithBounds(hole);
|
||||||
|
var surviving = new List<Polygon>();
|
||||||
|
|
||||||
|
foreach (var region in regions)
|
||||||
|
{
|
||||||
|
var pieces = SubtractTriangles(region, holeTris);
|
||||||
|
surviving.AddRange(pieces);
|
||||||
|
}
|
||||||
|
|
||||||
|
regions = surviving;
|
||||||
|
|
||||||
|
if (regions.Count == 0)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subtracts hole triangles from a region. Conservative: partial overlaps
|
||||||
|
/// keep the full piece triangle (acceptable for visual shading).
|
||||||
|
/// </summary>
|
||||||
|
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
|
||||||
|
{
|
||||||
|
var current = new List<Polygon> { region };
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest.Geometry
|
||||||
|
{
|
||||||
|
public class CollisionResult
|
||||||
|
{
|
||||||
|
public static readonly CollisionResult None = new(false, new List<Polygon>(), new List<Vector>());
|
||||||
|
|
||||||
|
public CollisionResult(bool overlaps, List<Polygon> overlapRegions, List<Vector> intersectionPoints)
|
||||||
|
{
|
||||||
|
Overlaps = overlaps;
|
||||||
|
OverlapRegions = overlapRegions;
|
||||||
|
IntersectionPoints = intersectionPoints;
|
||||||
|
OverlapArea = overlapRegions.Sum(r => r.Area());
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Overlaps { get; }
|
||||||
|
public IReadOnlyList<Polygon> OverlapRegions { get; }
|
||||||
|
public IReadOnlyList<Vector> IntersectionPoints { get; }
|
||||||
|
public double OverlapArea { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,25 @@ namespace OpenNest.Geometry
|
|||||||
return new Vector(p1.X + s * n1.X, p1.Y + s * n1.Y);
|
return new Vector(p1.X + s * n1.X, p1.Y + s * n1.Y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static Vector Circumcenter(Vector a, Vector b, Vector c)
|
||||||
|
{
|
||||||
|
var ax = a.X - c.X;
|
||||||
|
var ay = a.Y - c.Y;
|
||||||
|
var bx = b.X - c.X;
|
||||||
|
var by = b.Y - c.Y;
|
||||||
|
var D = 2.0 * (ax * by - ay * bx);
|
||||||
|
|
||||||
|
if (System.Math.Abs(D) < 1e-10)
|
||||||
|
return Vector.Invalid;
|
||||||
|
|
||||||
|
var a2 = ax * ax + ay * ay;
|
||||||
|
var b2 = bx * bx + by * by;
|
||||||
|
var ux = (by * a2 - ay * b2) / D;
|
||||||
|
var uy = (ax * b2 - bx * a2) / D;
|
||||||
|
|
||||||
|
return new Vector(ux + c.X, uy + c.Y);
|
||||||
|
}
|
||||||
|
|
||||||
public static List<Entity> Convert(Vector center, double semiMajor, double semiMinor,
|
public static List<Entity> Convert(Vector center, double semiMajor, double semiMinor,
|
||||||
double rotation, double startParam, double endParam, double tolerance = 0.001)
|
double rotation, double startParam, double endParam, double tolerance = 0.001)
|
||||||
{
|
{
|
||||||
@@ -154,7 +173,11 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
if (maxDev <= tolerance)
|
if (maxDev <= tolerance)
|
||||||
{
|
{
|
||||||
results.Add(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
|
||||||
|
results.Add(arc);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -185,11 +208,20 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t0);
|
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t0);
|
||||||
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1);
|
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1);
|
||||||
|
var pMid = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, (t0 + t1) / 2);
|
||||||
|
|
||||||
|
// Use circumcircle of (p0, pMid, p1) so the arc passes through both
|
||||||
|
// endpoints exactly, eliminating gaps between adjacent arcs.
|
||||||
|
var cc = Circumcenter(p0, pMid, p1);
|
||||||
|
if (cc.IsValid())
|
||||||
|
{
|
||||||
|
arcCenter = cc;
|
||||||
|
radius = p0.DistanceTo(cc);
|
||||||
|
}
|
||||||
|
|
||||||
var startAngle = System.Math.Atan2(p0.Y - arcCenter.Y, p0.X - arcCenter.X);
|
var startAngle = System.Math.Atan2(p0.Y - arcCenter.Y, p0.X - arcCenter.X);
|
||||||
var endAngle = System.Math.Atan2(p1.Y - arcCenter.Y, p1.X - arcCenter.X);
|
var endAngle = System.Math.Atan2(p1.Y - arcCenter.Y, p1.X - arcCenter.X);
|
||||||
|
|
||||||
var pMid = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, (t0 + t1) / 2);
|
|
||||||
var points = new List<Vector> { p0, pMid, p1 };
|
var points = new List<Vector> { p0, pMid, p1 };
|
||||||
var isReversed = SumSignedAngles(arcCenter, points) < 0;
|
var isReversed = SumSignedAngles(arcCenter, points) < 0;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
|
|
||||||
@@ -10,10 +11,16 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
protected Entity()
|
protected Entity()
|
||||||
{
|
{
|
||||||
|
Id = Guid.NewGuid();
|
||||||
Layer = OpenNest.Geometry.Layer.Default;
|
Layer = OpenNest.Geometry.Layer.Default;
|
||||||
boundingBox = new Box();
|
boundingBox = new Box();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier for this entity, stable across edit sessions.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entity color (resolved from DXF ByLayer/ByBlock to actual color).
|
/// Entity color (resolved from DXF ByLayer/ByBlock to actual color).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -29,6 +36,11 @@ namespace OpenNest.Geometry
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsVisible { get; set; } = true;
|
public bool IsVisible { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional tag for identifying generated entities (e.g. bend etch marks).
|
||||||
|
/// </summary>
|
||||||
|
public string Tag { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Smallest box that contains the entity.
|
/// Smallest box that contains the entity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -239,6 +251,23 @@ namespace OpenNest.Geometry
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public abstract bool Intersects(Shape shape, out List<Vector> pts);
|
public abstract bool Intersects(Shape shape, out List<Vector> pts);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a deep copy of the entity with a new Id.
|
||||||
|
/// </summary>
|
||||||
|
public abstract Entity Clone();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies common Entity properties from this instance to the target.
|
||||||
|
/// </summary>
|
||||||
|
protected void CopyBaseTo(Entity target)
|
||||||
|
{
|
||||||
|
target.Color = Color;
|
||||||
|
target.Layer = Layer;
|
||||||
|
target.LineTypeName = LineTypeName;
|
||||||
|
target.IsVisible = IsVisible;
|
||||||
|
target.Tag = Tag;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Type of entity.
|
/// Type of entity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -247,6 +276,14 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
public static class EntityExtensions
|
public static class EntityExtensions
|
||||||
{
|
{
|
||||||
|
public static List<Entity> CloneAll(this IEnumerable<Entity> entities)
|
||||||
|
{
|
||||||
|
var result = new List<Entity>();
|
||||||
|
foreach (var e in entities)
|
||||||
|
result.Add(e.Clone());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public static List<Vector> CollectPoints(this IEnumerable<Entity> entities)
|
public static List<Vector> CollectPoints(this IEnumerable<Entity> entities)
|
||||||
{
|
{
|
||||||
var points = new List<Vector>();
|
var points = new List<Vector>();
|
||||||
|
|||||||
@@ -7,65 +7,78 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
public static class GeometryOptimizer
|
public static class GeometryOptimizer
|
||||||
{
|
{
|
||||||
public static void Optimize(IList<Arc> arcs)
|
public static void Optimize(IList<Arc> arcs) =>
|
||||||
|
MergePass(arcs,
|
||||||
|
(list, item, i) => list.GetCoradialArs(item, i),
|
||||||
|
(Arc a, Arc b, out Arc joined) => TryJoinArcs(a, b, out joined));
|
||||||
|
|
||||||
|
public static void Optimize(IList<Line> lines) =>
|
||||||
|
MergePass(lines,
|
||||||
|
(list, item, i) => list.GetCollinearLines(item, i),
|
||||||
|
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined));
|
||||||
|
|
||||||
|
public static void Deduplicate(IList<Circle> circles)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < arcs.Count; ++i)
|
for (var i = circles.Count - 1; i >= 1; i--)
|
||||||
{
|
{
|
||||||
var arc = arcs[i];
|
for (var j = i - 1; j >= 0; j--)
|
||||||
|
|
||||||
var coradialArcs = arcs.GetCoradialArs(arc, i);
|
|
||||||
int index = 0;
|
|
||||||
|
|
||||||
while (index < coradialArcs.Count)
|
|
||||||
{
|
{
|
||||||
Arc arc2 = coradialArcs[index];
|
if (circles[i].Center.DistanceTo(circles[j].Center) <= Tolerance.Epsilon
|
||||||
Arc joinArc;
|
&& circles[i].Radius.IsEqualTo(circles[j].Radius))
|
||||||
|
|
||||||
if (!TryJoinArcs(arc, arc2, out joinArc))
|
|
||||||
{
|
{
|
||||||
index++;
|
circles.RemoveAt(i);
|
||||||
continue;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
coradialArcs.Remove(arc2);
|
|
||||||
arcs.Remove(arc2);
|
|
||||||
|
|
||||||
arc = joinArc;
|
|
||||||
index = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
arcs[i] = arc;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Optimize(IList<Line> lines)
|
public static void Deduplicate(IList<Circle> circles, IList<Arc> arcs)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < lines.Count; ++i)
|
for (var i = circles.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
var line = lines[i];
|
for (var j = arcs.Count - 1; j >= 0; j--)
|
||||||
|
{
|
||||||
|
if (arcs[j].Center.DistanceTo(circles[i].Center) <= Tolerance.Epsilon
|
||||||
|
&& arcs[j].Radius.IsEqualTo(circles[i].Radius)
|
||||||
|
&& arcs[j].IsFullCircle())
|
||||||
|
{
|
||||||
|
arcs.RemoveAt(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var collinearLines = lines.GetCollinearLines(line, i);
|
private delegate bool TryJoin<T>(T a, T b, out T joined);
|
||||||
|
|
||||||
|
private static void MergePass<T>(IList<T> items,
|
||||||
|
Func<IList<T>, T, int, List<T>> findCandidates,
|
||||||
|
TryJoin<T> tryJoin) where T : class
|
||||||
|
{
|
||||||
|
for (var i = 0; i < items.Count; ++i)
|
||||||
|
{
|
||||||
|
var item = items[i];
|
||||||
|
var candidates = findCandidates(items, item, i);
|
||||||
var index = 0;
|
var index = 0;
|
||||||
|
|
||||||
while (index < collinearLines.Count)
|
while (index < candidates.Count)
|
||||||
{
|
{
|
||||||
Line line2 = collinearLines[index];
|
var candidate = candidates[index];
|
||||||
Line joinLine;
|
|
||||||
|
|
||||||
if (!TryJoinLines(line, line2, out joinLine))
|
if (!tryJoin(item, candidate, out var joined))
|
||||||
{
|
{
|
||||||
index++;
|
index++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
collinearLines.Remove(line2);
|
candidates.Remove(candidate);
|
||||||
lines.Remove(line2);
|
items.Remove(candidate);
|
||||||
|
|
||||||
line = joinLine;
|
item = joined;
|
||||||
index = 0;
|
index = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
lines[i] = line;
|
items[i] = item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,13 +134,11 @@ public class GeometrySimplifier
|
|||||||
|
|
||||||
if (midpoints.Count < 4) return MirrorAxisResult.None;
|
if (midpoints.Count < 4) return MirrorAxisResult.None;
|
||||||
|
|
||||||
// Centroid
|
var centroid = new Vector(
|
||||||
var cx = 0.0;
|
midpoints.Average(p => p.X),
|
||||||
var cy = 0.0;
|
midpoints.Average(p => p.Y));
|
||||||
foreach (var p in midpoints) { cx += p.X; cy += p.Y; }
|
var cx = centroid.X;
|
||||||
cx /= midpoints.Count;
|
var cy = centroid.Y;
|
||||||
cy /= midpoints.Count;
|
|
||||||
var centroid = new Vector(cx, cy);
|
|
||||||
|
|
||||||
// Covariance matrix for PCA
|
// Covariance matrix for PCA
|
||||||
var cxx = 0.0;
|
var cxx = 0.0;
|
||||||
@@ -192,12 +190,25 @@ public class GeometrySimplifier
|
|||||||
return bestResult.Score >= 0.8 ? bestResult : MirrorAxisResult.None;
|
return bestResult.Score >= 0.8 ? bestResult : MirrorAxisResult.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static double NormalizeAngle(double angle) =>
|
||||||
|
angle < 0 ? angle + Angle.TwoPI : angle;
|
||||||
|
|
||||||
private static Vector Normalize(Vector v)
|
private static Vector Normalize(Vector v)
|
||||||
{
|
{
|
||||||
var len = System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
|
var len = System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
|
||||||
return len < 1e-10 ? new Vector(1, 0) : new Vector(v.X / len, v.Y / len);
|
return len < 1e-10 ? new Vector(1, 0) : new Vector(v.X / len, v.Y / len);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static double PerpendicularDistance(Vector point, Vector axisPoint, Vector axisDir)
|
||||||
|
{
|
||||||
|
var dx = point.X - axisPoint.X;
|
||||||
|
var dy = point.Y - axisPoint.Y;
|
||||||
|
var dot = dx * axisDir.X + dy * axisDir.Y;
|
||||||
|
var px = dx - dot * axisDir.X;
|
||||||
|
var py = dy - dot * axisDir.Y;
|
||||||
|
return System.Math.Sqrt(px * px + py * py);
|
||||||
|
}
|
||||||
|
|
||||||
private static double MirrorMatchScore(List<Vector> points, Vector axisPoint, Vector axisDir)
|
private static double MirrorMatchScore(List<Vector> points, Vector axisPoint, Vector axisDir)
|
||||||
{
|
{
|
||||||
var matchTol = 0.1;
|
var matchTol = 0.1;
|
||||||
@@ -206,14 +217,7 @@ public class GeometrySimplifier
|
|||||||
for (var i = 0; i < points.Count; i++)
|
for (var i = 0; i < points.Count; i++)
|
||||||
{
|
{
|
||||||
var p = points[i];
|
var p = points[i];
|
||||||
|
var dist = PerpendicularDistance(p, axisPoint, axisDir);
|
||||||
// Distance from point to axis
|
|
||||||
var dx = p.X - axisPoint.X;
|
|
||||||
var dy = p.Y - axisPoint.Y;
|
|
||||||
var dot = dx * axisDir.X + dy * axisDir.Y;
|
|
||||||
var perpX = dx - dot * axisDir.X;
|
|
||||||
var perpY = dy - dot * axisDir.Y;
|
|
||||||
var dist = System.Math.Sqrt(perpX * perpX + perpY * perpY);
|
|
||||||
|
|
||||||
// Points on the axis count as matched
|
// Points on the axis count as matched
|
||||||
if (dist < matchTol)
|
if (dist < matchTol)
|
||||||
@@ -223,14 +227,12 @@ public class GeometrySimplifier
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reflect across axis and look for partner
|
// Reflect across axis and look for partner
|
||||||
var mx = p.X - 2 * perpX;
|
var reflected = new MirrorAxisResult(axisPoint, axisDir, 0).Reflect(p);
|
||||||
var my = p.Y - 2 * perpY;
|
|
||||||
|
|
||||||
for (var j = 0; j < points.Count; j++)
|
for (var j = 0; j < points.Count; j++)
|
||||||
{
|
{
|
||||||
if (i == j) continue;
|
if (i == j) continue;
|
||||||
var d = System.Math.Sqrt((points[j].X - mx) * (points[j].X - mx) +
|
var d = reflected.DistanceTo(points[j]);
|
||||||
(points[j].Y - my) * (points[j].Y - my));
|
|
||||||
if (d < matchTol)
|
if (d < matchTol)
|
||||||
{
|
{
|
||||||
matched++;
|
matched++;
|
||||||
@@ -259,14 +261,7 @@ public class GeometrySimplifier
|
|||||||
|
|
||||||
var ci = candidates[i];
|
var ci = candidates[i];
|
||||||
var ciCenter = ci.BoundingBox.Center;
|
var ciCenter = ci.BoundingBox.Center;
|
||||||
|
if (PerpendicularDistance(ciCenter, axis.Point, axis.Direction) < 0.1) continue; // on the axis
|
||||||
// Distance from candidate center to axis
|
|
||||||
var dx = ciCenter.X - axis.Point.X;
|
|
||||||
var dy = ciCenter.Y - axis.Point.Y;
|
|
||||||
var dot = dx * axis.Direction.X + dy * axis.Direction.Y;
|
|
||||||
var perpDist = System.Math.Sqrt((dx - dot * axis.Direction.X) * (dx - dot * axis.Direction.X) +
|
|
||||||
(dy - dot * axis.Direction.Y) * (dy - dot * axis.Direction.Y));
|
|
||||||
if (perpDist < 0.1) continue; // on the axis
|
|
||||||
|
|
||||||
var mirrorCenter = axis.Reflect(ciCenter);
|
var mirrorCenter = axis.Reflect(ciCenter);
|
||||||
|
|
||||||
@@ -328,12 +323,8 @@ public class GeometrySimplifier
|
|||||||
var mirrorEp = axis.Reflect(ep);
|
var mirrorEp = axis.Reflect(ep);
|
||||||
|
|
||||||
// Mirroring reverses winding — swap start/end to preserve arc direction
|
// Mirroring reverses winding — swap start/end to preserve arc direction
|
||||||
var mirrorStart = System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X);
|
var mirrorStart = NormalizeAngle(System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X));
|
||||||
var mirrorEnd = System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X);
|
var mirrorEnd = NormalizeAngle(System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X));
|
||||||
|
|
||||||
// Normalize to [0, 2pi)
|
|
||||||
if (mirrorStart < 0) mirrorStart += Angle.TwoPI;
|
|
||||||
if (mirrorEnd < 0) mirrorEnd += Angle.TwoPI;
|
|
||||||
|
|
||||||
var result = new Arc(mirrorCenter, arc.Radius, mirrorStart, mirrorEnd, arc.IsReversed);
|
var result = new Arc(mirrorCenter, arc.Radius, mirrorStart, mirrorEnd, arc.IsReversed);
|
||||||
result.Layer = arc.Layer;
|
result.Layer = arc.Layer;
|
||||||
@@ -357,15 +348,16 @@ public class GeometrySimplifier
|
|||||||
}
|
}
|
||||||
|
|
||||||
chainedTangent = ComputeEndTangent(result.Center, result.Points);
|
chainedTangent = ComputeEndTangent(result.Center, result.Points);
|
||||||
|
var arc = CreateArc(result.Center, result.Radius, result.Points, entities[j]);
|
||||||
candidates.Add(new ArcCandidate
|
candidates.Add(new ArcCandidate
|
||||||
{
|
{
|
||||||
StartIndex = j,
|
StartIndex = j,
|
||||||
EndIndex = result.EndIndex,
|
EndIndex = result.EndIndex,
|
||||||
FittedArc = CreateArc(result.Center, result.Radius, result.Points, entities[j]),
|
FittedArc = arc,
|
||||||
MaxDeviation = result.Deviation,
|
MaxDeviation = result.Deviation,
|
||||||
BoundingBox = result.Points.GetBoundingBox(),
|
BoundingBox = result.Points.GetBoundingBox(),
|
||||||
FirstPoint = result.Points[0],
|
FirstPoint = arc.StartPoint(),
|
||||||
LastPoint = result.Points[^1],
|
LastPoint = arc.EndPoint(),
|
||||||
});
|
});
|
||||||
|
|
||||||
j = result.EndIndex + 1;
|
j = result.EndIndex + 1;
|
||||||
@@ -382,18 +374,19 @@ public class GeometrySimplifier
|
|||||||
var points = CollectPoints(entities, start, k);
|
var points = CollectPoints(entities, start, k);
|
||||||
if (points.Count < 3) return null;
|
if (points.Count < 3) return null;
|
||||||
|
|
||||||
var startTangent = chainedTangent.IsValid()
|
var startTangent = EstimateStartTangent(entities, start, points, chainedTangent);
|
||||||
? chainedTangent
|
var endTangent = EstimateEndTangent(entities, k, points);
|
||||||
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
var (center, radius, dev) = TryFit(points, startTangent, endTangent);
|
||||||
|
|
||||||
var (center, radius, dev) = TryFit(points, startTangent);
|
|
||||||
if (!center.IsValid()) return null;
|
if (!center.IsValid()) return null;
|
||||||
|
|
||||||
// Extend the arc as far as possible
|
// Extend the arc as far as possible
|
||||||
while (k + 1 <= runEnd)
|
while (k + 1 <= runEnd)
|
||||||
{
|
{
|
||||||
var extPoints = CollectPoints(entities, start, k + 1);
|
var extPoints = CollectPoints(entities, start, k + 1);
|
||||||
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent) : (Vector.Invalid, 0, 0d);
|
if (extPoints.Count < 3) break;
|
||||||
|
|
||||||
|
var extEndTangent = EstimateEndTangent(entities, k + 1, extPoints);
|
||||||
|
var (nc, nr, nd) = TryFit(extPoints, startTangent, extEndTangent);
|
||||||
if (!nc.IsValid()) break;
|
if (!nc.IsValid()) break;
|
||||||
|
|
||||||
k++;
|
k++;
|
||||||
@@ -413,72 +406,172 @@ public class GeometrySimplifier
|
|||||||
return new ArcFitResult(center, radius, dev, points, k);
|
return new ArcFitResult(center, radius, dev, points, k);
|
||||||
}
|
}
|
||||||
|
|
||||||
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent)
|
private (Vector center, double radius, double deviation) TryFit(
|
||||||
|
List<Vector> points, TangentEstimate start, TangentEstimate end)
|
||||||
{
|
{
|
||||||
var (center, radius, dev) = FitWithStartTangent(points, startTangent);
|
foreach (var (center, radius, dev) in FitAttempts(points, start, end))
|
||||||
if (!center.IsValid() || dev > Tolerance)
|
{
|
||||||
(center, radius, dev) = FitMirrorAxis(points);
|
if (!center.IsValid() || dev > Tolerance)
|
||||||
if (!center.IsValid() || dev > Tolerance)
|
continue;
|
||||||
return (Vector.Invalid, 0, 0);
|
|
||||||
|
|
||||||
// Check that the arc doesn't bulge away from the original line segments
|
// Check that the arc doesn't bulge away from the original line segments
|
||||||
var isReversed = SumSignedAngles(center, points) < 0;
|
var isReversed = SumSignedAngles(center, points) < 0;
|
||||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||||
if (arcDev > Tolerance)
|
if (arcDev > Tolerance)
|
||||||
return (Vector.Invalid, 0, 0);
|
continue;
|
||||||
|
|
||||||
return (center, radius, System.Math.Max(dev, arcDev));
|
return (center, radius, System.Math.Max(dev, arcDev));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Vector.Invalid, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fits a circular arc constrained to be tangent to the given direction at the
|
/// Yields fit attempts in preference order. A trusted tangent (chained from the
|
||||||
/// first point. The center lies at the intersection of the normal at P1 (perpendicular
|
/// previous arc, an adjacent original arc, or a long straight edge) is enforced
|
||||||
/// to the tangent) and the perpendicular bisector of the chord P1->Pn, guaranteeing
|
/// exactly on its side; otherwise the tangency error is balanced between both
|
||||||
/// the arc passes through both endpoints and departs P1 in the given direction.
|
/// endpoints. The unconstrained mirror-axis fit is the last resort. Every attempt
|
||||||
|
/// passes exactly through both endpoints, so no gaps are introduced.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static (Vector center, double radius, double deviation) FitWithStartTangent(
|
private IEnumerable<(Vector center, double radius, double deviation)> FitAttempts(
|
||||||
List<Vector> points, Vector tangent)
|
List<Vector> points, TangentEstimate start, TangentEstimate end)
|
||||||
{
|
{
|
||||||
if (points.Count < 3)
|
if (start.Trusted && !end.Trusted)
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
var p1 = points[0];
|
yield return FitMirrorAxis(points);
|
||||||
var pn = points[^1];
|
|
||||||
|
|
||||||
var mx = (p1.X + pn.X) / 2;
|
|
||||||
var my = (p1.Y + pn.Y) / 2;
|
|
||||||
var dx = pn.X - p1.X;
|
|
||||||
var dy = pn.Y - p1.Y;
|
|
||||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
|
||||||
if (chordLen < 1e-10)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
|
||||||
|
|
||||||
var bx = -dy / chordLen;
|
|
||||||
var by = dx / chordLen;
|
|
||||||
|
|
||||||
var tLen = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
|
|
||||||
if (tLen < 1e-10)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
|
||||||
|
|
||||||
var nx = -tangent.Y / tLen;
|
|
||||||
var ny = tangent.X / tLen;
|
|
||||||
|
|
||||||
var det = nx * by - ny * bx;
|
|
||||||
if (System.Math.Abs(det) < 1e-10)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
|
||||||
|
|
||||||
var t = ((mx - p1.X) * by - (my - p1.Y) * bx) / det;
|
|
||||||
|
|
||||||
var cx = p1.X + t * nx;
|
|
||||||
var cy = p1.Y + t * ny;
|
|
||||||
var radius = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
|
||||||
|
|
||||||
if (radius < 1e-10)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
|
||||||
|
|
||||||
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fits an arc through both endpoints with an exact tangent at the last point,
|
||||||
|
/// by running the start-tangent fit on the reversed point sequence.
|
||||||
|
/// </summary>
|
||||||
|
private static (Vector center, double radius, double deviation) FitWithEndTangent(
|
||||||
|
List<Vector> points, Vector endTangent)
|
||||||
|
{
|
||||||
|
var reversed = new List<Vector>(points);
|
||||||
|
reversed.Reverse();
|
||||||
|
return ArcFit.FitWithStartTangent(reversed, new Vector(-endTangent.X, -endTangent.Y));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An estimated tangent direction at a fit endpoint. Trusted estimates come from
|
||||||
|
/// exact geometry (a chained arc, an adjacent original arc, or a long straight
|
||||||
|
/// edge) and are enforced exactly; untrusted ones are derived from the polyline
|
||||||
|
/// vertices and only guide the fit.
|
||||||
|
/// </summary>
|
||||||
|
private readonly record struct TangentEstimate(Vector Direction, bool Trusted);
|
||||||
|
|
||||||
|
/// <summary>Segment-length ratio above which a neighboring line counts as a true
|
||||||
|
/// straight edge (rather than another chord of the tessellated curve).</summary>
|
||||||
|
private const double NeighborEdgeFactor = 3.0;
|
||||||
|
|
||||||
|
private static TangentEstimate EstimateStartTangent(
|
||||||
|
List<Entity> entities, int start, List<Vector> points, Vector chainedTangent)
|
||||||
|
{
|
||||||
|
if (chainedTangent.IsValid())
|
||||||
|
return new TangentEstimate(chainedTangent, true);
|
||||||
|
|
||||||
|
if (entities[start] is Arc startArc)
|
||||||
|
return new TangentEstimate(GetEntryDirection(startArc), true);
|
||||||
|
|
||||||
|
var firstChordLen = points[0].DistanceTo(points[1]);
|
||||||
|
if (start > 0)
|
||||||
|
{
|
||||||
|
var prev = entities[start - 1];
|
||||||
|
var prevEnd = prev switch { Line l => l.EndPoint, Arc a => a.EndPoint(), _ => Vector.Invalid };
|
||||||
|
if (prevEnd.IsValid() && prevEnd.DistanceTo(points[0]) < 1e-6)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
/// <summary>
|
||||||
/// Computes the tangent direction at the last point of a fitted arc,
|
/// Computes the tangent direction at the last point of a fitted arc,
|
||||||
/// used to chain tangent continuity to the next arc.
|
/// used to chain tangent continuity to the next arc.
|
||||||
@@ -486,15 +579,10 @@ public class GeometrySimplifier
|
|||||||
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
|
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
|
||||||
{
|
{
|
||||||
var lastPt = points[^1];
|
var lastPt = points[^1];
|
||||||
var totalAngle = SumSignedAngles(center, points);
|
|
||||||
|
|
||||||
var rx = lastPt.X - center.X;
|
var rx = lastPt.X - center.X;
|
||||||
var ry = lastPt.Y - center.Y;
|
var ry = lastPt.Y - center.Y;
|
||||||
|
var sign = SumSignedAngles(center, points) >= 0 ? 1 : -1;
|
||||||
if (totalAngle >= 0)
|
return new Vector(-sign * ry, sign * rx);
|
||||||
return new Vector(-ry, rx);
|
|
||||||
else
|
|
||||||
return new Vector(ry, -rx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -535,12 +623,12 @@ public class GeometrySimplifier
|
|||||||
var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord);
|
var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord);
|
||||||
|
|
||||||
var dOpt = GoldenSectionMin(dInit - range, dInit + range,
|
var dOpt = GoldenSectionMin(dInit - range, dInit + range,
|
||||||
d => MaxRadialDeviation(points, mx + d * nx, my + d * ny,
|
d => ArcFit.MaxRadialDeviation(points, mx + d * nx, my + d * ny,
|
||||||
System.Math.Sqrt(halfChord * halfChord + d * d)));
|
System.Math.Sqrt(halfChord * halfChord + d * d)));
|
||||||
|
|
||||||
var center = new Vector(mx + dOpt * nx, my + dOpt * ny);
|
var center = new Vector(mx + dOpt * nx, my + dOpt * ny);
|
||||||
var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt);
|
var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt);
|
||||||
return (center, radius, MaxRadialDeviation(points, center.X, center.Y, radius));
|
return (center, radius, ArcFit.MaxRadialDeviation(points, center.X, center.Y, radius));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double GoldenSectionMin(double low, double high, Func<double, double> eval)
|
private static double GoldenSectionMin(double low, double high, Func<double, double> eval)
|
||||||
@@ -593,20 +681,28 @@ public class GeometrySimplifier
|
|||||||
var firstPoint = points[0];
|
var firstPoint = points[0];
|
||||||
var lastPoint = points[^1];
|
var lastPoint = points[^1];
|
||||||
|
|
||||||
var startAngle = System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X);
|
var startAngle = NormalizeAngle(System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X));
|
||||||
var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X);
|
var endAngle = NormalizeAngle(System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X));
|
||||||
var isReversed = SumSignedAngles(center, points) < 0;
|
var isReversed = SumSignedAngles(center, points) < 0;
|
||||||
|
|
||||||
// Normalize to [0, 2pi)
|
|
||||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
|
||||||
if (endAngle < 0) endAngle += Angle.TwoPI;
|
|
||||||
|
|
||||||
var arc = new Arc(center, radius, startAngle, endAngle, isReversed);
|
var arc = new Arc(center, radius, startAngle, endAngle, isReversed);
|
||||||
arc.Layer = sourceEntity.Layer;
|
arc.Layer = sourceEntity.Layer;
|
||||||
arc.Color = sourceEntity.Color;
|
arc.Color = sourceEntity.Color;
|
||||||
return arc;
|
return arc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sums signed angular change traversing consecutive points around a center.
|
/// Sums signed angular change traversing consecutive points around a center.
|
||||||
/// Positive = CCW, negative = CW.
|
/// Positive = CCW, negative = CW.
|
||||||
@@ -626,23 +722,6 @@ public class GeometrySimplifier
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Max deviation of intermediate points (excluding endpoints) from a circle.
|
|
||||||
/// </summary>
|
|
||||||
private 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++)
|
|
||||||
{
|
|
||||||
var px = points[i].X - cx;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
return maxDev;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Measures the maximum distance from sampled points along the fitted arc
|
/// Measures the maximum distance from sampled points along the fitted arc
|
||||||
/// back to the original line segments. This catches cases where points lie
|
/// back to the original line segments. This catches cases where points lie
|
||||||
|
|||||||
@@ -257,6 +257,13 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Line(pt1, pt2);
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reversed the line.
|
/// Reversed the line.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -370,23 +377,23 @@ namespace OpenNest.Geometry
|
|||||||
if (StartPoint.X < EndPoint.X)
|
if (StartPoint.X < EndPoint.X)
|
||||||
{
|
{
|
||||||
boundingBox.X = StartPoint.X;
|
boundingBox.X = StartPoint.X;
|
||||||
boundingBox.Width = EndPoint.X - StartPoint.X;
|
boundingBox.Length = EndPoint.X - StartPoint.X;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
boundingBox.X = EndPoint.X;
|
boundingBox.X = EndPoint.X;
|
||||||
boundingBox.Width = StartPoint.X - EndPoint.X;
|
boundingBox.Length = StartPoint.X - EndPoint.X;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (StartPoint.Y < EndPoint.Y)
|
if (StartPoint.Y < EndPoint.Y)
|
||||||
{
|
{
|
||||||
boundingBox.Y = StartPoint.Y;
|
boundingBox.Y = StartPoint.Y;
|
||||||
boundingBox.Length = EndPoint.Y - StartPoint.Y;
|
boundingBox.Width = EndPoint.Y - StartPoint.Y;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
boundingBox.Y = EndPoint.Y;
|
boundingBox.Y = EndPoint.Y;
|
||||||
boundingBox.Length = StartPoint.Y - EndPoint.Y;
|
boundingBox.Width = StartPoint.Y - EndPoint.Y;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,13 @@ namespace OpenNest.Geometry
|
|||||||
get { return Perimeter(); }
|
get { return Perimeter(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Polygon { Vertices = new List<Vector>(Vertices) };
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reverses the rotation direction of the polygon.
|
/// Reverses the rotation direction of the polygon.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -311,8 +318,8 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
boundingBox.X = minX;
|
boundingBox.X = minX;
|
||||||
boundingBox.Y = minY;
|
boundingBox.Y = minY;
|
||||||
boundingBox.Width = maxX - minX;
|
boundingBox.Length = maxX - minX;
|
||||||
boundingBox.Length = maxY - minY;
|
boundingBox.Width = maxY - minY;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||||
|
|||||||
@@ -349,6 +349,15 @@ namespace OpenNest.Geometry
|
|||||||
return polygon;
|
return polygon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Shape();
|
||||||
|
foreach (var e in Entities)
|
||||||
|
copy.Entities.Add(e.Clone());
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reverses the rotation direction of the shape.
|
/// Reverses the rotation direction of the shape.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -532,9 +541,29 @@ namespace OpenNest.Geometry
|
|||||||
Line line, Line offsetLine,
|
Line line, Line offsetLine,
|
||||||
double distance, OffsetSide side, Shape offsetShape)
|
double distance, OffsetSide side, Shape offsetShape)
|
||||||
{
|
{
|
||||||
Vector intersection;
|
// Determine if this is a convex corner using the cross product of
|
||||||
|
// the original line directions. Convex corners need an arc; concave
|
||||||
|
// corners use the line intersection (miter join).
|
||||||
|
var d1 = lastLine.EndPoint - lastLine.StartPoint;
|
||||||
|
var d2 = line.EndPoint - line.StartPoint;
|
||||||
|
var cross = d1.X * d2.Y - d1.Y * d2.X;
|
||||||
|
|
||||||
if (Intersect.IntersectsUnbounded(offsetLine, lastOffsetLine, out intersection))
|
var isConvex = (side == OffsetSide.Left && cross < -OpenNest.Math.Tolerance.Epsilon) ||
|
||||||
|
(side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon);
|
||||||
|
|
||||||
|
if (isConvex)
|
||||||
|
{
|
||||||
|
var arc = new Arc(
|
||||||
|
line.StartPoint,
|
||||||
|
distance,
|
||||||
|
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))
|
||||||
{
|
{
|
||||||
offsetLine.StartPoint = intersection;
|
offsetLine.StartPoint = intersection;
|
||||||
lastOffsetLine.EndPoint = intersection;
|
lastOffsetLine.EndPoint = intersection;
|
||||||
@@ -559,43 +588,38 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Offsets the shape outward by the given distance, detecting winding direction
|
/// Offsets the shape outward by the given distance.
|
||||||
/// to choose the correct offset side. Falls back to the opposite side if the
|
/// Normalizes to CW winding before offsetting Left (which is outward for CW),
|
||||||
/// bounding box shrinks (indicating the offset went inward).
|
/// making the method independent of the original contour winding direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Shape OffsetOutward(double distance)
|
public Shape OffsetOutward(double distance)
|
||||||
{
|
{
|
||||||
var poly = ToPolygon();
|
var poly = ToPolygon();
|
||||||
var side = poly.Vertices.Count >= 3 && poly.RotationDirection() == RotationType.CW
|
|
||||||
? OffsetSide.Left
|
|
||||||
: OffsetSide.Right;
|
|
||||||
|
|
||||||
var result = OffsetEntity(distance, side) as Shape;
|
if (poly == null || poly.Vertices.Count < 3
|
||||||
|
|| poly.RotationDirection() == RotationType.CW)
|
||||||
|
return OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||||
|
|
||||||
if (result == null)
|
// Shape is CCW — reverse to CW so Left offset goes outward.
|
||||||
return null;
|
var copy = new Shape();
|
||||||
|
|
||||||
UpdateBounds();
|
for (var i = Entities.Count - 1; i >= 0; i--)
|
||||||
var originalBB = BoundingBox;
|
|
||||||
result.UpdateBounds();
|
|
||||||
var offsetBB = result.BoundingBox;
|
|
||||||
|
|
||||||
if (offsetBB.Width < originalBB.Width || offsetBB.Length < originalBB.Length)
|
|
||||||
{
|
{
|
||||||
Trace.TraceWarning(
|
switch (Entities[i])
|
||||||
"Shape.OffsetOutward: offset shrank bounding box " +
|
{
|
||||||
$"(original={originalBB.Width:F3}x{originalBB.Length:F3}, " +
|
case Line l:
|
||||||
$"offset={offsetBB.Width:F3}x{offsetBB.Length:F3}). " +
|
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
|
||||||
"Retrying with opposite side.");
|
break;
|
||||||
|
case Arc a:
|
||||||
var opposite = side == OffsetSide.Left ? OffsetSide.Right : OffsetSide.Left;
|
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
|
||||||
var retry = OffsetEntity(distance, opposite) as Shape;
|
break;
|
||||||
|
case Circle c:
|
||||||
if (retry != null)
|
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CW });
|
||||||
result = retry;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -625,7 +649,7 @@ namespace OpenNest.Geometry
|
|||||||
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;
|
break;
|
||||||
case Circle c:
|
case Circle c:
|
||||||
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer });
|
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CCW });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace OpenNest.Geometry
|
namespace OpenNest.Geometry
|
||||||
{
|
{
|
||||||
public static class ShapeBuilder
|
public static class ShapeBuilder
|
||||||
{
|
{
|
||||||
public static List<Shape> GetShapes(IEnumerable<Entity> entities)
|
public static List<Shape> GetShapes(IEnumerable<Entity> entities, double? weldTolerance = null)
|
||||||
{
|
{
|
||||||
var lines = new List<Line>();
|
var lines = new List<Line>();
|
||||||
var arcs = new List<Arc>();
|
var arcs = new List<Arc>();
|
||||||
@@ -57,6 +58,9 @@ namespace OpenNest.Geometry
|
|||||||
entityList.AddRange(lines);
|
entityList.AddRange(lines);
|
||||||
entityList.AddRange(arcs);
|
entityList.AddRange(arcs);
|
||||||
|
|
||||||
|
if (weldTolerance.HasValue)
|
||||||
|
WeldEndpoints(entityList, weldTolerance.Value);
|
||||||
|
|
||||||
while (entityList.Count > 0)
|
while (entityList.Count > 0)
|
||||||
{
|
{
|
||||||
var next = entityList[0];
|
var next = entityList[0];
|
||||||
@@ -107,6 +111,93 @@ namespace OpenNest.Geometry
|
|||||||
return shapes;
|
return shapes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void WeldEndpoints(List<Entity> entities, double tolerance)
|
||||||
|
{
|
||||||
|
var endpointGroups = new List<List<(Entity entity, bool isStart, Vector point)>>();
|
||||||
|
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
var (start, end) = GetEndpoints(entity);
|
||||||
|
if (!start.IsValid() || !end.IsValid())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
AddToGroup(endpointGroups, entity, true, start, tolerance);
|
||||||
|
AddToGroup(endpointGroups, entity, false, end, tolerance);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var group in endpointGroups)
|
||||||
|
{
|
||||||
|
if (group.Count <= 1)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var avgX = group.Average(g => g.point.X);
|
||||||
|
var avgY = group.Average(g => g.point.Y);
|
||||||
|
var weldedPoint = new Vector(avgX, avgY);
|
||||||
|
|
||||||
|
foreach (var (entity, isStart, _) in group)
|
||||||
|
ApplyWeld(entity, isStart, weldedPoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddToGroup(
|
||||||
|
List<List<(Entity entity, bool isStart, Vector point)>> groups,
|
||||||
|
Entity entity, bool isStart, Vector point, double tolerance)
|
||||||
|
{
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
if (group[0].point.DistanceTo(point) <= tolerance)
|
||||||
|
{
|
||||||
|
group.Add((entity, isStart, point));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.Add(new List<(Entity, bool, Vector)> { (entity, isStart, point) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Vector start, Vector end) GetEndpoints(Entity entity)
|
||||||
|
{
|
||||||
|
switch (entity.Type)
|
||||||
|
{
|
||||||
|
case EntityType.Arc:
|
||||||
|
var arc = (Arc)entity;
|
||||||
|
return (arc.StartPoint(), arc.EndPoint());
|
||||||
|
|
||||||
|
case EntityType.Line:
|
||||||
|
var line = (Line)entity;
|
||||||
|
return (line.StartPoint, line.EndPoint);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (Vector.Invalid, Vector.Invalid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyWeld(Entity entity, bool isStart, Vector weldedPoint)
|
||||||
|
{
|
||||||
|
switch (entity.Type)
|
||||||
|
{
|
||||||
|
case EntityType.Line:
|
||||||
|
var line = (Line)entity;
|
||||||
|
if (isStart)
|
||||||
|
line.StartPoint = weldedPoint;
|
||||||
|
else
|
||||||
|
line.EndPoint = weldedPoint;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EntityType.Arc:
|
||||||
|
var arc = (Arc)entity;
|
||||||
|
var deltaX = weldedPoint.X - arc.Center.X;
|
||||||
|
var deltaY = weldedPoint.Y - arc.Center.Y;
|
||||||
|
var angle = System.Math.Atan2(deltaY, deltaX);
|
||||||
|
|
||||||
|
if (isStart)
|
||||||
|
arc.StartAngle = angle;
|
||||||
|
else
|
||||||
|
arc.EndAngle = angle;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
|
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
|
||||||
{
|
{
|
||||||
var tol = Tolerance.ChainTolerance;
|
var tol = Tolerance.ChainTolerance;
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ namespace OpenNest.Geometry
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
|
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
|
||||||
{
|
{
|
||||||
var profile = new ShapeProfile(entities.ToList());
|
var cloned = entities.CloneAll();
|
||||||
|
var profile = new ShapeProfile(cloned);
|
||||||
return profile.ToNormalizedEntities();
|
return profile.ToNormalizedEntities();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,98 @@ namespace OpenNest.Geometry
|
|||||||
return double.MaxValue;
|
return double.MaxValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Solves ray-circle intersection, returning the two parametric t values.
|
||||||
|
/// Returns false if no real intersection exists.
|
||||||
|
/// </summary>
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var ox = vx - cx;
|
||||||
|
var oy = vy - cy;
|
||||||
|
|
||||||
|
var a = dirX * dirX + dirY * dirY;
|
||||||
|
var b = 2.0 * (ox * dirX + oy * dirY);
|
||||||
|
var c = ox * ox + oy * oy - r * r;
|
||||||
|
|
||||||
|
var discriminant = b * b - 4.0 * a * c;
|
||||||
|
if (discriminant < 0)
|
||||||
|
{
|
||||||
|
t1 = t2 = double.MaxValue;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sqrtD = System.Math.Sqrt(discriminant);
|
||||||
|
var inv2a = 1.0 / (2.0 * a);
|
||||||
|
t1 = (-b - sqrtD) * inv2a;
|
||||||
|
t2 = (-b + sqrtD) * inv2a;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the distance from a point along a direction to an arc.
|
||||||
|
/// Solves ray-circle intersection, then constrains hits to the arc's
|
||||||
|
/// angular span. Returns double.MaxValue if no hit.
|
||||||
|
/// </summary>
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
|
||||||
|
return double.MaxValue;
|
||||||
|
|
||||||
|
var best = double.MaxValue;
|
||||||
|
|
||||||
|
if (t1 > -Tolerance.Epsilon)
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
|
||||||
|
best = t2 > Tolerance.Epsilon ? t2 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the distance from a point along a direction to a full circle.
|
||||||
|
/// Returns double.MaxValue if no hit.
|
||||||
|
/// </summary>
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(
|
||||||
|
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static double RayCircleDistance(
|
||||||
|
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;
|
||||||
|
|
||||||
|
return double.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Computes the minimum translation distance along a push direction before
|
/// Computes the minimum translation distance along a push direction before
|
||||||
/// any edge of movingLines contacts any edge of stationaryLines.
|
/// any edge of movingLines contacts any edge of stationaryLines.
|
||||||
@@ -111,57 +203,7 @@ namespace OpenNest.Geometry
|
|||||||
/// </summary>
|
/// </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)
|
||||||
{
|
{
|
||||||
var minDist = double.MaxValue;
|
return DirectionalDistance(movingLines, 0, 0, stationaryLines, direction);
|
||||||
|
|
||||||
// Case 1: Each moving vertex -> each stationary edge
|
|
||||||
var movingVertices = new HashSet<Vector>();
|
|
||||||
for (int i = 0; i < movingLines.Count; i++)
|
|
||||||
{
|
|
||||||
movingVertices.Add(movingLines[i].pt1);
|
|
||||||
movingVertices.Add(movingLines[i].pt2);
|
|
||||||
}
|
|
||||||
|
|
||||||
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
|
|
||||||
for (int i = 0; i < stationaryLines.Count; i++)
|
|
||||||
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
|
|
||||||
|
|
||||||
// Sort edges for pruning if not already sorted (usually they aren't here)
|
|
||||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
|
||||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
|
||||||
else
|
|
||||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
|
||||||
|
|
||||||
foreach (var mv in movingVertices)
|
|
||||||
{
|
|
||||||
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
|
|
||||||
if (d < minDist) minDist = d;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
|
||||||
var opposite = OppositeDirection(direction);
|
|
||||||
var stationaryVertices = new HashSet<Vector>();
|
|
||||||
for (int i = 0; i < stationaryLines.Count; i++)
|
|
||||||
{
|
|
||||||
stationaryVertices.Add(stationaryLines[i].pt1);
|
|
||||||
stationaryVertices.Add(stationaryLines[i].pt2);
|
|
||||||
}
|
|
||||||
|
|
||||||
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
|
|
||||||
for (int i = 0; i < movingLines.Count; i++)
|
|
||||||
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
|
|
||||||
|
|
||||||
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
|
|
||||||
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
|
||||||
else
|
|
||||||
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
|
||||||
|
|
||||||
foreach (var sv in stationaryVertices)
|
|
||||||
{
|
|
||||||
var d = OneWayDistance(sv, movingEdges, Vector.Zero, opposite);
|
|
||||||
if (d < minDist) minDist = d;
|
|
||||||
}
|
|
||||||
|
|
||||||
return minDist;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -176,21 +218,10 @@ namespace OpenNest.Geometry
|
|||||||
var movingOffset = new Vector(movingDx, movingDy);
|
var movingOffset = new Vector(movingDx, movingDy);
|
||||||
|
|
||||||
// Case 1: Each moving vertex -> each stationary edge
|
// Case 1: Each moving vertex -> each stationary edge
|
||||||
var movingVertices = new HashSet<Vector>();
|
var movingVertices = CollectVertices(movingLines, movingOffset);
|
||||||
for (int i = 0; i < movingLines.Count; i++)
|
|
||||||
{
|
|
||||||
movingVertices.Add(movingLines[i].pt1 + movingOffset);
|
|
||||||
movingVertices.Add(movingLines[i].pt2 + movingOffset);
|
|
||||||
}
|
|
||||||
|
|
||||||
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
|
var stationaryEdges = ToEdgeArray(stationaryLines);
|
||||||
for (int i = 0; i < stationaryLines.Count; i++)
|
SortEdgesForPruning(stationaryEdges, direction);
|
||||||
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
|
|
||||||
|
|
||||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
|
||||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
|
||||||
else
|
|
||||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
|
||||||
|
|
||||||
foreach (var mv in movingVertices)
|
foreach (var mv in movingVertices)
|
||||||
{
|
{
|
||||||
@@ -200,21 +231,10 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||||
var opposite = OppositeDirection(direction);
|
var opposite = OppositeDirection(direction);
|
||||||
var stationaryVertices = new HashSet<Vector>();
|
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
|
||||||
for (int i = 0; i < stationaryLines.Count; i++)
|
|
||||||
{
|
|
||||||
stationaryVertices.Add(stationaryLines[i].pt1);
|
|
||||||
stationaryVertices.Add(stationaryLines[i].pt2);
|
|
||||||
}
|
|
||||||
|
|
||||||
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
|
var movingEdges = ToEdgeArray(movingLines);
|
||||||
for (int i = 0; i < movingLines.Count; i++)
|
SortEdgesForPruning(movingEdges, opposite);
|
||||||
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
|
|
||||||
|
|
||||||
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
|
|
||||||
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
|
||||||
else
|
|
||||||
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
|
||||||
|
|
||||||
foreach (var sv in stationaryVertices)
|
foreach (var sv in stationaryVertices)
|
||||||
{
|
{
|
||||||
@@ -253,15 +273,11 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
var minDist = double.MaxValue;
|
var minDist = double.MaxValue;
|
||||||
|
|
||||||
// Extract unique vertices from moving edges.
|
SortEdgesForPruning(stationaryEdges, direction);
|
||||||
var movingVertices = new HashSet<Vector>();
|
|
||||||
for (var i = 0; i < movingEdges.Length; i++)
|
|
||||||
{
|
|
||||||
movingVertices.Add(movingEdges[i].start + movingOffset);
|
|
||||||
movingVertices.Add(movingEdges[i].end + movingOffset);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 1: Each moving vertex -> each stationary edge
|
// Case 1: Each moving vertex -> each stationary edge
|
||||||
|
var movingVertices = CollectVertices(movingEdges, movingOffset);
|
||||||
|
|
||||||
foreach (var mv in movingVertices)
|
foreach (var mv in movingVertices)
|
||||||
{
|
{
|
||||||
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
|
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
|
||||||
@@ -270,12 +286,9 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||||
var opposite = OppositeDirection(direction);
|
var opposite = OppositeDirection(direction);
|
||||||
var stationaryVertices = new HashSet<Vector>();
|
SortEdgesForPruning(movingEdges, opposite);
|
||||||
for (var i = 0; i < stationaryEdges.Length; i++)
|
|
||||||
{
|
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset);
|
||||||
stationaryVertices.Add(stationaryEdges[i].start + stationaryOffset);
|
|
||||||
stationaryVertices.Add(stationaryEdges[i].end + stationaryOffset);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var sv in stationaryVertices)
|
foreach (var sv in stationaryVertices)
|
||||||
{
|
{
|
||||||
@@ -293,49 +306,38 @@ namespace OpenNest.Geometry
|
|||||||
var minDist = double.MaxValue;
|
var minDist = double.MaxValue;
|
||||||
var vx = vertex.X;
|
var vx = vertex.X;
|
||||||
var vy = vertex.Y;
|
var vy = vertex.Y;
|
||||||
|
var horizontal = IsHorizontalDirection(direction);
|
||||||
|
|
||||||
// Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary.
|
// Pruning: edges are sorted by their perpendicular min-coordinate.
|
||||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
// For horizontal push, prune by Y range; for vertical push, prune by X range.
|
||||||
|
for (var i = 0; i < edges.Length; i++)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < edges.Length; i++)
|
var e1 = edges[i].start + edgeOffset;
|
||||||
|
var e2 = edges[i].end + edgeOffset;
|
||||||
|
|
||||||
|
double perpValue, edgeMin, edgeMax;
|
||||||
|
if (horizontal)
|
||||||
{
|
{
|
||||||
var e1 = edges[i].start + edgeOffset;
|
perpValue = vy;
|
||||||
var e2 = edges[i].end + edgeOffset;
|
edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y;
|
||||||
|
edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y;
|
||||||
var minY = e1.Y < e2.Y ? e1.Y : e2.Y;
|
|
||||||
var maxY = e1.Y > e2.Y ? e1.Y : e2.Y;
|
|
||||||
|
|
||||||
// Since edges are sorted by minY, if vy < minY, then vy < all subsequent minY.
|
|
||||||
if (vy < minY - Tolerance.Epsilon)
|
|
||||||
break;
|
|
||||||
|
|
||||||
if (vy > maxY + Tolerance.Epsilon)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
|
|
||||||
if (d < minDist) minDist = d;
|
|
||||||
}
|
}
|
||||||
}
|
else
|
||||||
else // Up/Down
|
|
||||||
{
|
|
||||||
for (var i = 0; i < edges.Length; i++)
|
|
||||||
{
|
{
|
||||||
var e1 = edges[i].start + edgeOffset;
|
perpValue = vx;
|
||||||
var e2 = edges[i].end + edgeOffset;
|
edgeMin = e1.X < e2.X ? e1.X : e2.X;
|
||||||
|
edgeMax = e1.X > e2.X ? e1.X : e2.X;
|
||||||
var minX = e1.X < e2.X ? e1.X : e2.X;
|
|
||||||
var maxX = e1.X > e2.X ? e1.X : e2.X;
|
|
||||||
|
|
||||||
// Since edges are sorted by minX, if vx < minX, then vx < all subsequent minX.
|
|
||||||
if (vx < minX - Tolerance.Epsilon)
|
|
||||||
break;
|
|
||||||
|
|
||||||
if (vx > maxX + Tolerance.Epsilon)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
|
|
||||||
if (d < minDist) minDist = d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Since edges are sorted by edgeMin, if perpValue < edgeMin, all subsequent edges are also past.
|
||||||
|
if (perpValue < edgeMin - Tolerance.Epsilon)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (perpValue > edgeMax + Tolerance.Epsilon)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
|
||||||
|
if (d < minDist) minDist = d;
|
||||||
}
|
}
|
||||||
|
|
||||||
return minDist;
|
return minDist;
|
||||||
@@ -467,12 +469,7 @@ namespace OpenNest.Geometry
|
|||||||
var dirX = direction.X;
|
var dirX = direction.X;
|
||||||
var dirY = direction.Y;
|
var dirY = direction.Y;
|
||||||
|
|
||||||
var movingVertices = new HashSet<Vector>();
|
var movingVertices = CollectVertices(movingLines, Vector.Zero);
|
||||||
for (var i = 0; i < movingLines.Count; i++)
|
|
||||||
{
|
|
||||||
movingVertices.Add(movingLines[i].pt1);
|
|
||||||
movingVertices.Add(movingLines[i].pt2);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var mv in movingVertices)
|
foreach (var mv in movingVertices)
|
||||||
{
|
{
|
||||||
@@ -487,12 +484,7 @@ namespace OpenNest.Geometry
|
|||||||
var oppX = -dirX;
|
var oppX = -dirX;
|
||||||
var oppY = -dirY;
|
var oppY = -dirY;
|
||||||
|
|
||||||
var stationaryVertices = new HashSet<Vector>();
|
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
|
||||||
for (var i = 0; i < stationaryLines.Count; i++)
|
|
||||||
{
|
|
||||||
stationaryVertices.Add(stationaryLines[i].pt1);
|
|
||||||
stationaryVertices.Add(stationaryLines[i].pt2);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var sv in stationaryVertices)
|
foreach (var sv in stationaryVertices)
|
||||||
{
|
{
|
||||||
@@ -507,6 +499,311 @@ namespace OpenNest.Geometry
|
|||||||
return minDist;
|
return minDist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the minimum translation distance along a push direction
|
||||||
|
/// before any vertex/edge of movingEntities contacts any vertex/edge of
|
||||||
|
/// stationaryEntities. Delegates to the Vector-based overload.
|
||||||
|
/// </summary>
|
||||||
|
public static double DirectionalDistance(
|
||||||
|
List<Entity> movingEntities, List<Entity> stationaryEntities, PushDirection direction)
|
||||||
|
{
|
||||||
|
return DirectionalDistance(movingEntities, stationaryEntities, DirectionToOffset(direction, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the minimum translation distance along an arbitrary unit direction
|
||||||
|
/// before any vertex/edge of movingEntities contacts any vertex/edge of
|
||||||
|
/// stationaryEntities. Works with native Line, Arc, and Circle entities
|
||||||
|
/// without tessellation.
|
||||||
|
/// </summary>
|
||||||
|
public static double DirectionalDistance(
|
||||||
|
List<Entity> movingEntities, List<Entity> stationaryEntities, Vector direction)
|
||||||
|
{
|
||||||
|
var minDist = double.MaxValue;
|
||||||
|
var dirX = direction.X;
|
||||||
|
var dirY = direction.Y;
|
||||||
|
|
||||||
|
var movingVertices = ExtractEntityVertices(movingEntities);
|
||||||
|
|
||||||
|
for (var v = 0; v < movingVertices.Length; v++)
|
||||||
|
{
|
||||||
|
var vx = movingVertices[v].X;
|
||||||
|
var vy = movingVertices[v].Y;
|
||||||
|
|
||||||
|
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||||
|
{
|
||||||
|
var d = RayEntityDistance(vx, vy, stationaryEntities[j], dirX, dirY);
|
||||||
|
if (d < minDist)
|
||||||
|
{
|
||||||
|
minDist = d;
|
||||||
|
if (d <= 0) return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var oppX = -dirX;
|
||||||
|
var oppY = -dirY;
|
||||||
|
|
||||||
|
var stationaryVertices = ExtractEntityVertices(stationaryEntities);
|
||||||
|
|
||||||
|
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||||
|
{
|
||||||
|
var vx = stationaryVertices[v].X;
|
||||||
|
var vy = stationaryVertices[v].Y;
|
||||||
|
|
||||||
|
for (var j = 0; j < movingEntities.Count; j++)
|
||||||
|
{
|
||||||
|
var d = RayEntityDistance(vx, vy, movingEntities[j], oppX, oppY);
|
||||||
|
if (d < minDist)
|
||||||
|
{
|
||||||
|
minDist = d;
|
||||||
|
if (d <= 0) return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Arc-to-line closest-point check.
|
||||||
|
// Phases 1-2 sample arc endpoints and cardinal extremes, but the actual
|
||||||
|
// closest point on a small corner arc to a straight edge may lie between
|
||||||
|
// those samples. Use ClosestPointTo to find it and fire a ray from there.
|
||||||
|
minDist = ArcToLineClosestDistance(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
|
||||||
|
// curved entities (circles/arcs) because only a few cardinal vertices are
|
||||||
|
// sampled. The true closest contact along the push direction is found by
|
||||||
|
// treating it as a ray from one center to an expanded circle at the other
|
||||||
|
// center (radius = r1 + r2).
|
||||||
|
for (var i = 0; i < movingEntities.Count; i++)
|
||||||
|
{
|
||||||
|
var me = movingEntities[i];
|
||||||
|
if (!TryGetCurveParams(me, out var mcx, out var mcy, out var mr))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||||
|
{
|
||||||
|
var se = stationaryEntities[j];
|
||||||
|
if (!TryGetCurveParams(se, out var scx, out var scy, out var sr))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var d = RayCircleDistance(mcx, mcy, scx, scy, mr + sr, dirX, dirY);
|
||||||
|
|
||||||
|
if (d >= minDist)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// For arcs, verify the contact point falls within both arcs' angular ranges.
|
||||||
|
if (me is Arc || se is Arc)
|
||||||
|
{
|
||||||
|
var mx = mcx + d * dirX;
|
||||||
|
var my = mcy + d * dirY;
|
||||||
|
var toCx = scx - mx;
|
||||||
|
var toCy = scy - my;
|
||||||
|
|
||||||
|
if (me is Arc mArc)
|
||||||
|
{
|
||||||
|
var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx));
|
||||||
|
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))
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
minDist = d;
|
||||||
|
if (d <= 0) return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return minDist;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ArcToLineClosestDistance(
|
||||||
|
List<Entity> arcEntities, List<Entity> lineEntities,
|
||||||
|
double dirX, double dirY, double minDist)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < arcEntities.Count; i++)
|
||||||
|
{
|
||||||
|
if (arcEntities[i] is not Arc arc)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var cx = arc.Center.X;
|
||||||
|
var cy = arc.Center.Y;
|
||||||
|
var r = arc.Radius;
|
||||||
|
|
||||||
|
for (var j = 0; j < lineEntities.Count; j++)
|
||||||
|
{
|
||||||
|
if (lineEntities[j] is not Line line)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var p1x = line.pt1.X;
|
||||||
|
var p1y = line.pt1.Y;
|
||||||
|
var ex = line.pt2.X - p1x;
|
||||||
|
var ey = line.pt2.Y - p1y;
|
||||||
|
|
||||||
|
var det = ex * dirY - ey * dirX;
|
||||||
|
if (System.Math.Abs(det) < Tolerance.Epsilon)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// The directional distance from an arc point at angle θ to the
|
||||||
|
// line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det.
|
||||||
|
// dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π.
|
||||||
|
var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey));
|
||||||
|
var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI);
|
||||||
|
|
||||||
|
for (var k = 0; k < 2; k++)
|
||||||
|
{
|
||||||
|
var theta = k == 0 ? theta1 : theta2;
|
||||||
|
|
||||||
|
if (!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var qx = cx + r * System.Math.Cos(theta);
|
||||||
|
var qy = cy + r * System.Math.Sin(theta);
|
||||||
|
|
||||||
|
var d = RayEdgeDistance(qx, qy, p1x, p1y, line.pt2.X, line.pt2.Y,
|
||||||
|
dirX, dirY);
|
||||||
|
if (d < minDist) { minDist = d; if (d <= 0) return 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return minDist;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double RayEntityDistance(
|
||||||
|
double vx, double vy, Entity entity, double dirX, double dirY)
|
||||||
|
{
|
||||||
|
if (entity is Line line)
|
||||||
|
{
|
||||||
|
return RayEdgeDistance(vx, vy,
|
||||||
|
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
|
||||||
|
dirX, dirY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity is Arc arc)
|
||||||
|
{
|
||||||
|
return RayArcDistance(vx, vy,
|
||||||
|
arc.Center.X, arc.Center.Y, arc.Radius,
|
||||||
|
arc.StartAngle, arc.EndAngle, arc.IsReversed,
|
||||||
|
dirX, dirY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity is Circle circle)
|
||||||
|
{
|
||||||
|
return RayCircleDistance(vx, vy,
|
||||||
|
circle.Center.X, circle.Center.Y, circle.Radius,
|
||||||
|
dirX, dirY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return double.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector[] ExtractEntityVertices(List<Entity> entities)
|
||||||
|
{
|
||||||
|
var vertices = new HashSet<Vector>();
|
||||||
|
|
||||||
|
for (var i = 0; i < entities.Count; i++)
|
||||||
|
{
|
||||||
|
var entity = entities[i];
|
||||||
|
|
||||||
|
if (entity is Line line)
|
||||||
|
{
|
||||||
|
vertices.Add(line.pt1);
|
||||||
|
vertices.Add(line.pt2);
|
||||||
|
}
|
||||||
|
else if (entity is Arc arc)
|
||||||
|
{
|
||||||
|
vertices.Add(arc.StartPoint());
|
||||||
|
vertices.Add(arc.EndPoint());
|
||||||
|
AddArcExtremeVertices(vertices, arc);
|
||||||
|
}
|
||||||
|
else if (entity is Circle circle)
|
||||||
|
{
|
||||||
|
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
|
||||||
|
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
|
||||||
|
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
|
||||||
|
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return vertices.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddArcExtremeVertices(HashSet<Vector> points, Arc arc)
|
||||||
|
{
|
||||||
|
var a1 = arc.StartAngle;
|
||||||
|
var a2 = arc.EndAngle;
|
||||||
|
|
||||||
|
if (arc.IsReversed)
|
||||||
|
Generic.Swap(ref a1, ref a2);
|
||||||
|
|
||||||
|
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
|
||||||
|
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
|
||||||
|
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
|
||||||
|
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
|
||||||
|
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
|
||||||
|
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
|
||||||
|
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
|
||||||
|
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<Vector> CollectVertices(List<Line> lines, Vector offset)
|
||||||
|
{
|
||||||
|
return CollectVertices(ToEdgeArray(lines), 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++)
|
||||||
|
{
|
||||||
|
vertices.Add(edges[i].start + offset);
|
||||||
|
vertices.Add(edges[i].end + offset);
|
||||||
|
}
|
||||||
|
return vertices;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Vector start, Vector end)[] ToEdgeArray(List<Line> lines)
|
||||||
|
{
|
||||||
|
var edges = new (Vector start, Vector end)[lines.Count];
|
||||||
|
for (var i = 0; i < lines.Count; i++)
|
||||||
|
edges[i] = (lines[i].pt1, lines[i].pt2);
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)));
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetCurveParams(Entity entity, out double cx, out double cy, out double r)
|
||||||
|
{
|
||||||
|
if (entity is Circle circle)
|
||||||
|
{
|
||||||
|
cx = circle.Center.X; cy = circle.Center.Y; r = circle.Radius;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (entity is Arc arc)
|
||||||
|
{
|
||||||
|
cx = arc.Center.X; cy = arc.Center.Y; r = arc.Radius;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
cx = cy = r = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static double BoxProjectionMin(Box box, double dx, double dy)
|
private static double BoxProjectionMin(Box box, double dx, double dy)
|
||||||
{
|
{
|
||||||
var x = dx >= 0 ? box.Left : box.Right;
|
var x = dx >= 0 ? box.Left : box.Right;
|
||||||
@@ -523,177 +820,17 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
public static double ClosestDistanceLeft(Box box, List<Box> boxes)
|
|
||||||
{
|
|
||||||
var closestDistance = double.MaxValue;
|
|
||||||
|
|
||||||
for (int i = 0; i < boxes.Count; i++)
|
|
||||||
{
|
|
||||||
var compareBox = boxes[i];
|
|
||||||
|
|
||||||
RelativePosition pos;
|
|
||||||
|
|
||||||
if (!box.IsHorizontalTo(compareBox, out pos))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (pos != RelativePosition.Right)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var distance = box.Left - compareBox.Right;
|
|
||||||
|
|
||||||
if (distance < closestDistance)
|
|
||||||
closestDistance = distance;
|
|
||||||
}
|
|
||||||
|
|
||||||
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static double ClosestDistanceRight(Box box, List<Box> boxes)
|
|
||||||
{
|
|
||||||
var closestDistance = double.MaxValue;
|
|
||||||
|
|
||||||
for (int i = 0; i < boxes.Count; i++)
|
|
||||||
{
|
|
||||||
var compareBox = boxes[i];
|
|
||||||
|
|
||||||
RelativePosition pos;
|
|
||||||
|
|
||||||
if (!box.IsHorizontalTo(compareBox, out pos))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (pos != RelativePosition.Left)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var distance = compareBox.Left - box.Right;
|
|
||||||
|
|
||||||
if (distance < closestDistance)
|
|
||||||
closestDistance = distance;
|
|
||||||
}
|
|
||||||
|
|
||||||
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static double ClosestDistanceUp(Box box, List<Box> boxes)
|
|
||||||
{
|
|
||||||
var closestDistance = double.MaxValue;
|
|
||||||
|
|
||||||
for (int i = 0; i < boxes.Count; i++)
|
|
||||||
{
|
|
||||||
var compareBox = boxes[i];
|
|
||||||
|
|
||||||
RelativePosition pos;
|
|
||||||
|
|
||||||
if (!box.IsVerticalTo(compareBox, out pos))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (pos != RelativePosition.Bottom)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var distance = compareBox.Bottom - box.Top;
|
|
||||||
|
|
||||||
if (distance < closestDistance)
|
|
||||||
closestDistance = distance;
|
|
||||||
}
|
|
||||||
|
|
||||||
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static double ClosestDistanceDown(Box box, List<Box> boxes)
|
|
||||||
{
|
|
||||||
var closestDistance = double.MaxValue;
|
|
||||||
|
|
||||||
for (int i = 0; i < boxes.Count; i++)
|
|
||||||
{
|
|
||||||
var compareBox = boxes[i];
|
|
||||||
|
|
||||||
RelativePosition pos;
|
|
||||||
|
|
||||||
if (!box.IsVerticalTo(compareBox, out pos))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (pos != RelativePosition.Top)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var distance = box.Bottom - compareBox.Top;
|
|
||||||
|
|
||||||
if (distance < closestDistance)
|
|
||||||
closestDistance = distance;
|
|
||||||
}
|
|
||||||
|
|
||||||
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Box GetLargestBoxVertically(Vector pt, Box bounds, IEnumerable<Box> boxes)
|
public static Box GetLargestBoxVertically(Vector pt, Box bounds, IEnumerable<Box> boxes)
|
||||||
{
|
{
|
||||||
var verticalBoxes = boxes.Where(b => !(b.Left > pt.X || b.Right < pt.X)).ToList();
|
var verticalBoxes = boxes.Where(b => !(b.Left > pt.X || b.Right < pt.X)).ToList();
|
||||||
|
|
||||||
#region Find Top/Bottom Limits
|
if (!FindVerticalLimits(pt, bounds, verticalBoxes, out var top, out var btm))
|
||||||
|
return Box.Empty;
|
||||||
var top = double.MaxValue;
|
|
||||||
var btm = double.MinValue;
|
|
||||||
|
|
||||||
foreach (var box in verticalBoxes)
|
|
||||||
{
|
|
||||||
var boxBtm = box.Bottom;
|
|
||||||
var boxTop = box.Top;
|
|
||||||
|
|
||||||
if (boxBtm > pt.Y && boxBtm < top)
|
|
||||||
top = boxBtm;
|
|
||||||
|
|
||||||
else if (box.Top < pt.Y && boxTop > btm)
|
|
||||||
btm = boxTop;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (top == double.MaxValue)
|
|
||||||
{
|
|
||||||
if (bounds.Top > pt.Y)
|
|
||||||
top = bounds.Top;
|
|
||||||
else return Box.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (btm == double.MinValue)
|
|
||||||
{
|
|
||||||
if (bounds.Bottom < pt.Y)
|
|
||||||
btm = bounds.Bottom;
|
|
||||||
else return Box.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
var horizontalBoxes = boxes.Where(b => !(b.Bottom >= top || b.Top <= btm)).ToList();
|
var horizontalBoxes = boxes.Where(b => !(b.Bottom >= top || b.Top <= btm)).ToList();
|
||||||
|
|
||||||
#region Find Left/Right Limits
|
if (!FindHorizontalLimits(pt, bounds, horizontalBoxes, out var lft, out var rgt))
|
||||||
|
return Box.Empty;
|
||||||
var lft = double.MinValue;
|
|
||||||
var rgt = double.MaxValue;
|
|
||||||
|
|
||||||
foreach (var box in horizontalBoxes)
|
|
||||||
{
|
|
||||||
var boxLft = box.Left;
|
|
||||||
var boxRgt = box.Right;
|
|
||||||
|
|
||||||
if (boxLft > pt.X && boxLft < rgt)
|
|
||||||
rgt = boxLft;
|
|
||||||
|
|
||||||
else if (boxRgt < pt.X && boxRgt > lft)
|
|
||||||
lft = boxRgt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rgt == double.MaxValue)
|
|
||||||
{
|
|
||||||
if (bounds.Right > pt.X)
|
|
||||||
rgt = bounds.Right;
|
|
||||||
else return Box.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lft == double.MinValue)
|
|
||||||
{
|
|
||||||
if (bounds.Left < pt.X)
|
|
||||||
lft = bounds.Left;
|
|
||||||
else return Box.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
return new Box(lft, btm, rgt - lft, top - btm);
|
return new Box(lft, btm, rgt - lft, top - btm);
|
||||||
}
|
}
|
||||||
@@ -702,75 +839,77 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
var horizontalBoxes = boxes.Where(b => !(b.Bottom > pt.Y || b.Top < pt.Y)).ToList();
|
var horizontalBoxes = boxes.Where(b => !(b.Bottom > pt.Y || b.Top < pt.Y)).ToList();
|
||||||
|
|
||||||
#region Find Left/Right Limits
|
if (!FindHorizontalLimits(pt, bounds, horizontalBoxes, out var lft, out var rgt))
|
||||||
|
return Box.Empty;
|
||||||
var lft = double.MinValue;
|
|
||||||
var rgt = double.MaxValue;
|
|
||||||
|
|
||||||
foreach (var box in horizontalBoxes)
|
|
||||||
{
|
|
||||||
var boxLft = box.Left;
|
|
||||||
var boxRgt = box.Right;
|
|
||||||
|
|
||||||
if (boxLft > pt.X && boxLft < rgt)
|
|
||||||
rgt = boxLft;
|
|
||||||
|
|
||||||
else if (boxRgt < pt.X && boxRgt > lft)
|
|
||||||
lft = boxRgt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rgt == double.MaxValue)
|
|
||||||
{
|
|
||||||
if (bounds.Right > pt.X)
|
|
||||||
rgt = bounds.Right;
|
|
||||||
else return Box.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lft == double.MinValue)
|
|
||||||
{
|
|
||||||
if (bounds.Left < pt.X)
|
|
||||||
lft = bounds.Left;
|
|
||||||
else return Box.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
var verticalBoxes = boxes.Where(b => !(b.Left >= rgt || b.Right <= lft)).ToList();
|
var verticalBoxes = boxes.Where(b => !(b.Left >= rgt || b.Right <= lft)).ToList();
|
||||||
|
|
||||||
#region Find Top/Bottom Limits
|
if (!FindVerticalLimits(pt, bounds, verticalBoxes, out var top, out var btm))
|
||||||
|
return Box.Empty;
|
||||||
|
|
||||||
var top = double.MaxValue;
|
return new Box(lft, btm, rgt - lft, top - btm);
|
||||||
var btm = double.MinValue;
|
}
|
||||||
|
|
||||||
foreach (var box in verticalBoxes)
|
private static bool FindVerticalLimits(Vector pt, Box bounds, List<Box> boxes, out double top, out double btm)
|
||||||
|
{
|
||||||
|
top = double.MaxValue;
|
||||||
|
btm = double.MinValue;
|
||||||
|
|
||||||
|
foreach (var box in boxes)
|
||||||
{
|
{
|
||||||
var boxBtm = box.Bottom;
|
var boxBtm = box.Bottom;
|
||||||
var boxTop = box.Top;
|
var boxTop = box.Top;
|
||||||
|
|
||||||
if (boxBtm > pt.Y && boxBtm < top)
|
if (boxBtm > pt.Y && boxBtm < top)
|
||||||
top = boxBtm;
|
top = boxBtm;
|
||||||
|
|
||||||
else if (box.Top < pt.Y && boxTop > btm)
|
else if (box.Top < pt.Y && boxTop > btm)
|
||||||
btm = boxTop;
|
btm = boxTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (top == double.MaxValue)
|
if (top == double.MaxValue)
|
||||||
{
|
{
|
||||||
if (bounds.Top > pt.Y)
|
if (bounds.Top > pt.Y) top = bounds.Top;
|
||||||
top = bounds.Top;
|
else return false;
|
||||||
else return Box.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (btm == double.MinValue)
|
if (btm == double.MinValue)
|
||||||
{
|
{
|
||||||
if (bounds.Bottom < pt.Y)
|
if (bounds.Bottom < pt.Y) btm = bounds.Bottom;
|
||||||
btm = bounds.Bottom;
|
else return false;
|
||||||
else return Box.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return new Box(lft, btm, rgt - lft, top - btm);
|
private static bool FindHorizontalLimits(Vector pt, Box bounds, List<Box> boxes, out double lft, out double rgt)
|
||||||
|
{
|
||||||
|
lft = double.MinValue;
|
||||||
|
rgt = double.MaxValue;
|
||||||
|
|
||||||
|
foreach (var box in boxes)
|
||||||
|
{
|
||||||
|
var boxLft = box.Left;
|
||||||
|
var boxRgt = box.Right;
|
||||||
|
|
||||||
|
if (boxLft > pt.X && boxLft < rgt)
|
||||||
|
rgt = boxLft;
|
||||||
|
else if (boxRgt < pt.X && boxRgt > lft)
|
||||||
|
lft = boxRgt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rgt == double.MaxValue)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,61 +131,11 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static (Vector center, double radius, double deviation) FitWithStartTangent(
|
private static (Vector center, double radius, double deviation) FitWithStartTangent(
|
||||||
List<Vector> points, Vector tangent)
|
List<Vector> points, Vector tangent) =>
|
||||||
{
|
ArcFit.FitWithStartTangent(points, tangent);
|
||||||
if (points.Count < 3)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
|
||||||
|
|
||||||
var p1 = points[0];
|
private static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius) =>
|
||||||
var pn = points[^1];
|
ArcFit.MaxRadialDeviation(points, cx, cy, radius);
|
||||||
|
|
||||||
var mx = (p1.X + pn.X) / 2;
|
|
||||||
var my = (p1.Y + pn.Y) / 2;
|
|
||||||
var dx = pn.X - p1.X;
|
|
||||||
var dy = pn.Y - p1.Y;
|
|
||||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
|
||||||
if (chordLen < 1e-10)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
|
||||||
|
|
||||||
var bx = -dy / chordLen;
|
|
||||||
var by = dx / chordLen;
|
|
||||||
|
|
||||||
var tLen = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
|
|
||||||
if (tLen < 1e-10)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
|
||||||
|
|
||||||
var nx = -tangent.Y / tLen;
|
|
||||||
var ny = tangent.X / tLen;
|
|
||||||
|
|
||||||
var det = nx * by - ny * bx;
|
|
||||||
if (System.Math.Abs(det) < 1e-10)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
|
||||||
|
|
||||||
var s = ((mx - p1.X) * by - (my - p1.Y) * bx) / det;
|
|
||||||
|
|
||||||
var cx = p1.X + s * nx;
|
|
||||||
var cy = p1.Y + s * ny;
|
|
||||||
var radius = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
|
||||||
|
|
||||||
if (radius < 1e-10)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
|
||||||
|
|
||||||
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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++)
|
|
||||||
{
|
|
||||||
var px = points[i].X - cx;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
return maxDev;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double SumSignedAngles(Vector center, List<Vector> points)
|
private static double SumSignedAngles(Vector center, List<Vector> points)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public interface IConfigurablePostProcessor : IPostProcessor
|
||||||
|
{
|
||||||
|
object Config { get; }
|
||||||
|
|
||||||
|
void SaveConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public interface IMaterialProvidingPostProcessor
|
||||||
|
{
|
||||||
|
IEnumerable<string> GetMaterialNames();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public interface IPostProcessorNestAware
|
||||||
|
{
|
||||||
|
void PrepareForNest(Nest nest);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace OpenNest.Math
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Recursive descent parser for simple arithmetic expressions supporting
|
||||||
|
/// +, -, *, /, parentheses, unary minus/plus, and $variable references.
|
||||||
|
/// </summary>
|
||||||
|
public static class ExpressionEvaluator
|
||||||
|
{
|
||||||
|
public static double Evaluate(string expression, IReadOnlyDictionary<string, double> variables)
|
||||||
|
{
|
||||||
|
var parser = new Parser(expression, variables);
|
||||||
|
var result = parser.ParseExpression();
|
||||||
|
parser.SkipWhitespace();
|
||||||
|
if (!parser.IsEnd)
|
||||||
|
throw new FormatException($"Unexpected character at position {parser.Position}: '{parser.Current}'");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ref struct Parser
|
||||||
|
{
|
||||||
|
private readonly ReadOnlySpan<char> _input;
|
||||||
|
private readonly IReadOnlyDictionary<string, double> _variables;
|
||||||
|
private int _pos;
|
||||||
|
|
||||||
|
public Parser(string input, IReadOnlyDictionary<string, double> variables)
|
||||||
|
{
|
||||||
|
_input = input.AsSpan();
|
||||||
|
_variables = variables;
|
||||||
|
_pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Position => _pos;
|
||||||
|
public bool IsEnd => _pos >= _input.Length;
|
||||||
|
public char Current => _input[_pos];
|
||||||
|
|
||||||
|
public void SkipWhitespace()
|
||||||
|
{
|
||||||
|
while (_pos < _input.Length && _input[_pos] == ' ')
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expression = Term (('+' | '-') Term)*
|
||||||
|
public double ParseExpression()
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
var left = ParseTerm();
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
if (IsEnd) break;
|
||||||
|
|
||||||
|
var op = Current;
|
||||||
|
if (op != '+' && op != '-') break;
|
||||||
|
|
||||||
|
_pos++;
|
||||||
|
SkipWhitespace();
|
||||||
|
var right = ParseTerm();
|
||||||
|
|
||||||
|
left = op == '+' ? left + right : left - right;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Term = Unary (('*' | '/') Unary)*
|
||||||
|
private double ParseTerm()
|
||||||
|
{
|
||||||
|
var left = ParseUnary();
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
if (IsEnd) break;
|
||||||
|
|
||||||
|
var op = Current;
|
||||||
|
if (op != '*' && op != '/') break;
|
||||||
|
|
||||||
|
_pos++;
|
||||||
|
SkipWhitespace();
|
||||||
|
var right = ParseUnary();
|
||||||
|
|
||||||
|
left = op == '*' ? left * right : left / right;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unary = ('-' | '+')? Primary
|
||||||
|
private double ParseUnary()
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
if (!IsEnd && Current == '-')
|
||||||
|
{
|
||||||
|
_pos++;
|
||||||
|
return -ParsePrimary();
|
||||||
|
}
|
||||||
|
if (!IsEnd && Current == '+')
|
||||||
|
{
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
return ParsePrimary();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary = '(' Expression ')' | '$' Identifier | Number
|
||||||
|
private double ParsePrimary()
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
|
||||||
|
if (IsEnd)
|
||||||
|
throw new FormatException("Unexpected end of expression.");
|
||||||
|
|
||||||
|
if (Current == '(')
|
||||||
|
{
|
||||||
|
_pos++; // consume '('
|
||||||
|
var value = ParseExpression();
|
||||||
|
SkipWhitespace();
|
||||||
|
if (IsEnd || Current != ')')
|
||||||
|
throw new FormatException("Expected closing parenthesis.");
|
||||||
|
_pos++; // consume ')'
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Current == '$')
|
||||||
|
{
|
||||||
|
_pos++; // consume '$'
|
||||||
|
var start = _pos;
|
||||||
|
while (_pos < _input.Length && (char.IsLetterOrDigit(_input[_pos]) || _input[_pos] == '_'))
|
||||||
|
_pos++;
|
||||||
|
if (_pos == start)
|
||||||
|
throw new FormatException("Expected variable name after '$'.");
|
||||||
|
var name = _input.Slice(start, _pos - start).ToString();
|
||||||
|
if (!_variables.TryGetValue(name, out var varValue))
|
||||||
|
throw new KeyNotFoundException($"Undefined variable: ${name}");
|
||||||
|
return varValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number
|
||||||
|
var numStart = _pos;
|
||||||
|
while (_pos < _input.Length && (char.IsDigit(_input[_pos]) || _input[_pos] == '.'))
|
||||||
|
_pos++;
|
||||||
|
|
||||||
|
if (_pos == numStart)
|
||||||
|
throw new FormatException($"Unexpected character '{Current}' at position {_pos}.");
|
||||||
|
|
||||||
|
var numSpan = _input.Slice(numStart, _pos - numStart).ToString();
|
||||||
|
if (!double.TryParse(numSpan, NumberStyles.Float, CultureInfo.InvariantCulture, out var number))
|
||||||
|
throw new FormatException($"Invalid number: '{numSpan}'");
|
||||||
|
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ using System.Linq;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace OpenNest.IO.Bom
|
namespace OpenNest.Math
|
||||||
{
|
{
|
||||||
public static class Fraction
|
public static class Fraction
|
||||||
{
|
{
|
||||||
+10
-16
@@ -1,6 +1,7 @@
|
|||||||
using OpenNest.Collections;
|
using OpenNest.Collections;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace OpenNest
|
namespace OpenNest
|
||||||
{
|
{
|
||||||
@@ -21,6 +22,7 @@ namespace OpenNest
|
|||||||
Plates.ItemRemoved += Plates_PlateRemoved;
|
Plates.ItemRemoved += Plates_PlateRemoved;
|
||||||
Drawings = new DrawingCollection();
|
Drawings = new DrawingCollection();
|
||||||
PlateDefaults = new PlateSettings();
|
PlateDefaults = new PlateSettings();
|
||||||
|
Material = new Material();
|
||||||
Customer = string.Empty;
|
Customer = string.Empty;
|
||||||
Notes = string.Empty;
|
Notes = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -38,6 +40,10 @@ namespace OpenNest
|
|||||||
|
|
||||||
public string AssistGas { get; set; } = "";
|
public string AssistGas { get; set; } = "";
|
||||||
|
|
||||||
|
public double Thickness { get; set; }
|
||||||
|
|
||||||
|
public Material Material { get; set; }
|
||||||
|
|
||||||
public Units Units { get; set; }
|
public Units Units { get; set; }
|
||||||
|
|
||||||
public DateTime DateCreated { get; set; }
|
public DateTime DateCreated { get; set; }
|
||||||
@@ -46,6 +52,10 @@ namespace OpenNest
|
|||||||
|
|
||||||
public PlateSettings PlateDefaults { get; set; }
|
public PlateSettings PlateDefaults { get; set; }
|
||||||
|
|
||||||
|
public List<PlateOption> PlateOptions { get; set; } = new();
|
||||||
|
|
||||||
|
public double SalvageRate { get; set; } = 0.5;
|
||||||
|
|
||||||
public Plate CreatePlate()
|
public Plate CreatePlate()
|
||||||
{
|
{
|
||||||
var plate = PlateDefaults.CreateNew();
|
var plate = PlateDefaults.CreateNew();
|
||||||
@@ -84,18 +94,6 @@ namespace OpenNest
|
|||||||
set { plate.Quadrant = value; }
|
set { plate.Quadrant = value; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public double Thickness
|
|
||||||
{
|
|
||||||
get { return plate.Thickness; }
|
|
||||||
set { plate.Thickness = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public Material Material
|
|
||||||
{
|
|
||||||
get { return plate.Material; }
|
|
||||||
set { plate.Material = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public Size Size
|
public Size Size
|
||||||
{
|
{
|
||||||
get { return plate.Size; }
|
get { return plate.Size; }
|
||||||
@@ -116,9 +114,7 @@ namespace OpenNest
|
|||||||
|
|
||||||
public void SetFromExisting(Plate plate)
|
public void SetFromExisting(Plate plate)
|
||||||
{
|
{
|
||||||
Thickness = plate.Thickness;
|
|
||||||
Quadrant = plate.Quadrant;
|
Quadrant = plate.Quadrant;
|
||||||
Material = plate.Material;
|
|
||||||
Size = plate.Size;
|
Size = plate.Size;
|
||||||
EdgeSpacing = plate.EdgeSpacing;
|
EdgeSpacing = plate.EdgeSpacing;
|
||||||
PartSpacing = plate.PartSpacing;
|
PartSpacing = plate.PartSpacing;
|
||||||
@@ -128,11 +124,9 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
return new Plate()
|
return new Plate()
|
||||||
{
|
{
|
||||||
Thickness = Thickness,
|
|
||||||
Size = Size,
|
Size = Size,
|
||||||
EdgeSpacing = EdgeSpacing,
|
EdgeSpacing = EdgeSpacing,
|
||||||
PartSpacing = PartSpacing,
|
PartSpacing = PartSpacing,
|
||||||
Material = Material,
|
|
||||||
Quadrant = Quadrant,
|
Quadrant = Quadrant,
|
||||||
Quantity = 1
|
Quantity = 1
|
||||||
};
|
};
|
||||||
|
|||||||
+70
-49
@@ -22,6 +22,7 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
private Vector location;
|
private Vector location;
|
||||||
private bool ownsProgram;
|
private bool ownsProgram;
|
||||||
|
private double preLeadInRotation;
|
||||||
|
|
||||||
public readonly Drawing BaseDrawing;
|
public readonly Drawing BaseDrawing;
|
||||||
|
|
||||||
@@ -56,12 +57,61 @@ namespace OpenNest
|
|||||||
|
|
||||||
public bool HasManualLeadIns { get; set; }
|
public bool HasManualLeadIns { get; set; }
|
||||||
|
|
||||||
|
public bool LeadInsLocked { get; set; }
|
||||||
|
|
||||||
|
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
|
||||||
|
|
||||||
|
public void ApplyLeadIns(CNC.CuttingStrategy.CuttingParameters parameters, Vector approachPoint)
|
||||||
|
{
|
||||||
|
ApplyLeadIns(parameters, approachPoint, Geometry.Vector.Invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyLeadIns(CNC.CuttingStrategy.CuttingParameters parameters, Vector approachPoint, Vector nextPartStart)
|
||||||
|
{
|
||||||
|
preLeadInRotation = Rotation;
|
||||||
|
var strategy = new CNC.CuttingStrategy.ContourCuttingStrategy { Parameters = parameters };
|
||||||
|
var result = strategy.Apply(Program, approachPoint, nextPartStart);
|
||||||
|
Program = result.Program;
|
||||||
|
CuttingParameters = parameters;
|
||||||
|
HasManualLeadIns = true;
|
||||||
|
UpdateBounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplySingleLeadIn(CNC.CuttingStrategy.CuttingParameters parameters,
|
||||||
|
Geometry.Vector point, Geometry.Entity entity, CNC.CuttingStrategy.ContourType contourType)
|
||||||
|
{
|
||||||
|
preLeadInRotation = Rotation;
|
||||||
|
var strategy = new CNC.CuttingStrategy.ContourCuttingStrategy { Parameters = parameters };
|
||||||
|
var result = strategy.ApplySingle(Program, point, entity, contourType);
|
||||||
|
Program = result.Program;
|
||||||
|
CuttingParameters = parameters;
|
||||||
|
HasManualLeadIns = true;
|
||||||
|
UpdateBounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveLeadIns()
|
||||||
|
{
|
||||||
|
var rotation = preLeadInRotation;
|
||||||
|
var location = Location;
|
||||||
|
Program = BaseDrawing.Program.Clone() as Program;
|
||||||
|
ownsProgram = true;
|
||||||
|
|
||||||
|
if (!Math.Tolerance.IsEqualTo(rotation, 0))
|
||||||
|
Program.Rotate(rotation);
|
||||||
|
|
||||||
|
Location = location;
|
||||||
|
HasManualLeadIns = false;
|
||||||
|
LeadInsLocked = false;
|
||||||
|
CuttingParameters = null;
|
||||||
|
UpdateBounds();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the rotation of the part in radians.
|
/// Gets the rotation of the part in radians.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double Rotation
|
public double Rotation
|
||||||
{
|
{
|
||||||
get { return Program.Rotation; }
|
get { return HasManualLeadIns ? preLeadInRotation : Program.Rotation; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -73,6 +123,7 @@ namespace OpenNest
|
|||||||
EnsureOwnedProgram();
|
EnsureOwnedProgram();
|
||||||
Program.Rotate(angle);
|
Program.Rotate(angle);
|
||||||
location = Location.Rotate(angle);
|
location = Location.Rotate(angle);
|
||||||
|
preLeadInRotation = Program.Rotation;
|
||||||
UpdateBounds();
|
UpdateBounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +137,7 @@ namespace OpenNest
|
|||||||
EnsureOwnedProgram();
|
EnsureOwnedProgram();
|
||||||
Program.Rotate(angle);
|
Program.Rotate(angle);
|
||||||
location = Location.Rotate(angle, origin);
|
location = Location.Rotate(angle, origin);
|
||||||
|
preLeadInRotation = Program.Rotation;
|
||||||
UpdateBounds();
|
UpdateBounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +195,14 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
var rotation = Rotation;
|
var rotation = Rotation;
|
||||||
Program = BaseDrawing.Program.Clone() as Program;
|
Program = BaseDrawing.Program.Clone() as Program;
|
||||||
Program.Rotate(Program.Rotation - rotation);
|
|
||||||
|
if (!Math.Tolerance.IsEqualTo(rotation, 0))
|
||||||
|
Program.Rotate(rotation);
|
||||||
|
|
||||||
|
HasManualLeadIns = false;
|
||||||
|
LeadInsLocked = false;
|
||||||
|
CuttingParameters = null;
|
||||||
|
UpdateBounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -171,56 +230,18 @@ namespace OpenNest
|
|||||||
if (perimeter1 == null || perimeter2 == null)
|
if (perimeter1 == null || perimeter2 == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
perimeter1.Offset(Location);
|
var polygon1 = perimeter1.ToPolygon();
|
||||||
perimeter2.Offset(part.Location);
|
var polygon2 = perimeter2.ToPolygon();
|
||||||
|
|
||||||
if (!perimeter1.Intersects(perimeter2, out var rawPts))
|
if (polygon1 == null || polygon2 == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Exclude intersection points that coincide with vertices of BOTH
|
polygon1.Offset(Location);
|
||||||
// perimeters — these are touch points (shared corners/endpoints),
|
polygon2.Offset(part.Location);
|
||||||
// not actual crossings where one shape enters the other's interior.
|
|
||||||
var verts1 = CollectVertices(perimeter1);
|
|
||||||
var verts2 = CollectVertices(perimeter2);
|
|
||||||
|
|
||||||
foreach (var pt in rawPts)
|
var result = Geometry.Collision.Check(polygon1, polygon2);
|
||||||
{
|
pts = result.IntersectionPoints.ToList();
|
||||||
if (IsNearAnyVertex(pt, verts1) && IsNearAnyVertex(pt, verts2))
|
return result.Overlaps;
|
||||||
continue;
|
|
||||||
pts.Add(pt);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pts.Count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Vector> CollectVertices(Geometry.Shape shape)
|
|
||||||
{
|
|
||||||
var verts = new List<Vector>();
|
|
||||||
foreach (var entity in shape.Entities)
|
|
||||||
{
|
|
||||||
switch (entity)
|
|
||||||
{
|
|
||||||
case Geometry.Line line:
|
|
||||||
verts.Add(line.StartPoint);
|
|
||||||
verts.Add(line.EndPoint);
|
|
||||||
break;
|
|
||||||
case Geometry.Arc arc:
|
|
||||||
verts.Add(arc.StartPoint());
|
|
||||||
verts.Add(arc.EndPoint());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return verts;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsNearAnyVertex(Vector pt, List<Vector> vertices)
|
|
||||||
{
|
|
||||||
foreach (var v in vertices)
|
|
||||||
{
|
|
||||||
if (pt.X.IsEqualTo(v.X) && pt.Y.IsEqualTo(v.Y))
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public double Left
|
public double Left
|
||||||
@@ -268,7 +289,7 @@ namespace OpenNest
|
|||||||
var part = new Part(BaseDrawing, Program,
|
var part = new Part(BaseDrawing, Program,
|
||||||
location + offset,
|
location + offset,
|
||||||
new Box(BoundingBox.X + offset.X, BoundingBox.Y + offset.Y,
|
new Box(BoundingBox.X + offset.X, BoundingBox.Y + offset.Y,
|
||||||
BoundingBox.Width, BoundingBox.Length));
|
BoundingBox.Length, BoundingBox.Width));
|
||||||
|
|
||||||
return part;
|
return part;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,20 +39,121 @@ namespace OpenNest
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001)
|
/// <summary>
|
||||||
|
/// Returns the perimeter entities (Line, Arc, Circle) with spacing offset applied,
|
||||||
|
/// without tessellation. Much faster than GetOffsetPartLines for parts with many arcs.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
|
||||||
|
{
|
||||||
|
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||||
|
var profile = new ShapeProfile(
|
||||||
|
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||||
|
|
||||||
|
var offsetShape = profile.Perimeter.OffsetOutward(spacing);
|
||||||
|
if (offsetShape == null)
|
||||||
|
return new List<Entity>();
|
||||||
|
|
||||||
|
// Offset the shape's entities to the part's location.
|
||||||
|
// OffsetOutward creates a new Shape, so mutating is safe.
|
||||||
|
foreach (var entity in offsetShape.Entities)
|
||||||
|
entity.Offset(part.Location);
|
||||||
|
|
||||||
|
return offsetShape.Entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all entities (perimeter + cutouts) with spacing offset applied,
|
||||||
|
/// without tessellation. Perimeter is offset outward, cutouts inward.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Entity> GetOffsetPartEntities(Part part, double spacing)
|
||||||
|
{
|
||||||
|
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||||
|
var profile = new ShapeProfile(
|
||||||
|
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||||
|
var entities = new List<Entity>();
|
||||||
|
|
||||||
|
var perimeter = profile.Perimeter.OffsetOutward(spacing);
|
||||||
|
if (perimeter != null)
|
||||||
|
{
|
||||||
|
foreach (var entity in perimeter.Entities)
|
||||||
|
entity.Offset(part.Location);
|
||||||
|
entities.AddRange(perimeter.Entities);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var cutout in profile.Cutouts)
|
||||||
|
{
|
||||||
|
var inset = cutout.OffsetInward(spacing);
|
||||||
|
if (inset == null) continue;
|
||||||
|
foreach (var entity in inset.Entities)
|
||||||
|
entity.Offset(part.Location);
|
||||||
|
entities.AddRange(inset.Entities);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns perimeter entities at the part's world location, without tessellation
|
||||||
|
/// or spacing offset.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Entity> GetPerimeterEntities(Part part)
|
||||||
|
{
|
||||||
|
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||||
|
var profile = new ShapeProfile(
|
||||||
|
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||||
|
|
||||||
|
return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all entities (perimeter + cutouts) at the part's world location,
|
||||||
|
/// without tessellation or spacing offset.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Entity> GetPartEntities(Part part)
|
||||||
|
{
|
||||||
|
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||||
|
var profile = new ShapeProfile(
|
||||||
|
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||||
|
var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
|
||||||
|
|
||||||
|
foreach (var cutout in profile.Cutouts)
|
||||||
|
entities.AddRange(CopyEntitiesAtLocation(cutout.Entities, part.Location));
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Entity> CopyEntitiesAtLocation(List<Entity> source, Vector location)
|
||||||
|
{
|
||||||
|
var result = new List<Entity>(source.Count);
|
||||||
|
|
||||||
|
foreach (var entity in source)
|
||||||
|
{
|
||||||
|
var copy = entity.Clone();
|
||||||
|
copy.Offset(location);
|
||||||
|
result.Add(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001,
|
||||||
|
bool perimeterOnly = false)
|
||||||
{
|
{
|
||||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||||
var profile = new ShapeProfile(
|
var profile = new ShapeProfile(
|
||||||
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||||
var lines = new List<Line>();
|
var lines = new List<Line>();
|
||||||
var totalSpacing = spacing + chordTolerance;
|
var totalSpacing = spacing;
|
||||||
|
|
||||||
AddOffsetLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
|
AddOffsetLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
|
||||||
chordTolerance, part.Location);
|
chordTolerance, part.Location);
|
||||||
|
|
||||||
foreach (var cutout in profile.Cutouts)
|
if (!perimeterOnly)
|
||||||
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
|
{
|
||||||
chordTolerance, part.Location);
|
foreach (var cutout in profile.Cutouts)
|
||||||
|
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
|
||||||
|
chordTolerance, part.Location);
|
||||||
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
@@ -63,7 +164,7 @@ namespace OpenNest
|
|||||||
var profile = new ShapeProfile(
|
var profile = new ShapeProfile(
|
||||||
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||||
var lines = new List<Line>();
|
var lines = new List<Line>();
|
||||||
var totalSpacing = spacing + chordTolerance;
|
var totalSpacing = spacing;
|
||||||
|
|
||||||
AddOffsetDirectionalLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
|
AddOffsetDirectionalLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
|
||||||
chordTolerance, part.Location, facingDirection);
|
chordTolerance, part.Location, facingDirection);
|
||||||
@@ -97,7 +198,7 @@ namespace OpenNest
|
|||||||
var profile = new ShapeProfile(
|
var profile = new ShapeProfile(
|
||||||
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||||
var lines = new List<Line>();
|
var lines = new List<Line>();
|
||||||
var totalSpacing = spacing + chordTolerance;
|
var totalSpacing = spacing;
|
||||||
|
|
||||||
AddOffsetDirectionalLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
|
AddOffsetDirectionalLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
|
||||||
chordTolerance, part.Location, facingDirection);
|
chordTolerance, part.Location, facingDirection);
|
||||||
|
|||||||
+72
-23
@@ -1,6 +1,7 @@
|
|||||||
using OpenNest.Collections;
|
using OpenNest.Collections;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
using OpenNest.Shapes;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -43,7 +44,6 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
EdgeSpacing = new Spacing();
|
EdgeSpacing = new Spacing();
|
||||||
Size = size;
|
Size = size;
|
||||||
Material = new Material();
|
|
||||||
Parts = new ObservableList<Part>();
|
Parts = new ObservableList<Part>();
|
||||||
Parts.ItemAdded += Parts_PartAdded;
|
Parts.ItemAdded += Parts_PartAdded;
|
||||||
Parts.ItemRemoved += Parts_PartRemoved;
|
Parts.ItemRemoved += Parts_PartRemoved;
|
||||||
@@ -63,11 +63,6 @@ namespace OpenNest
|
|||||||
e.Item.BaseDrawing.Quantity.Nested -= Quantity;
|
e.Item.BaseDrawing.Quantity.Nested -= Quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Thickness of the plate.
|
|
||||||
/// </summary>
|
|
||||||
public double Thickness { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The spacing between parts.
|
/// The spacing between parts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -83,10 +78,7 @@ namespace OpenNest
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Size Size { get; set; }
|
public Size Size { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
|
||||||
/// Material the plate is made out of.
|
|
||||||
/// </summary>
|
|
||||||
public Material Material { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Material grain direction in radians. 0 = horizontal.
|
/// Material grain direction in radians. 0 = horizontal.
|
||||||
@@ -433,7 +425,7 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
var plateBox = new Box();
|
var plateBox = new Box();
|
||||||
|
|
||||||
// Convention: Size.Length = X axis (horizontal), Size.Width = Y axis (vertical)
|
// Width = Y axis (vertical), Length = X axis (horizontal)
|
||||||
switch (Quadrant)
|
switch (Quadrant)
|
||||||
{
|
{
|
||||||
case 1:
|
case 1:
|
||||||
@@ -460,8 +452,8 @@ namespace OpenNest
|
|||||||
return new Box();
|
return new Box();
|
||||||
}
|
}
|
||||||
|
|
||||||
plateBox.Width = Size.Length;
|
plateBox.Width = Size.Width;
|
||||||
plateBox.Length = Size.Width;
|
plateBox.Length = Size.Length;
|
||||||
|
|
||||||
if (!includeParts)
|
if (!includeParts)
|
||||||
return plateBox;
|
return plateBox;
|
||||||
@@ -477,11 +469,11 @@ namespace OpenNest
|
|||||||
? partsBox.Bottom
|
? partsBox.Bottom
|
||||||
: plateBox.Bottom;
|
: plateBox.Bottom;
|
||||||
|
|
||||||
boundingBox.Width = partsBox.Right > plateBox.Right
|
boundingBox.Length = partsBox.Right > plateBox.Right
|
||||||
? partsBox.Right - boundingBox.X
|
? partsBox.Right - boundingBox.X
|
||||||
: plateBox.Right - boundingBox.X;
|
: plateBox.Right - boundingBox.X;
|
||||||
|
|
||||||
boundingBox.Length = partsBox.Top > plateBox.Top
|
boundingBox.Width = partsBox.Top > plateBox.Top
|
||||||
? partsBox.Top - boundingBox.Y
|
? partsBox.Top - boundingBox.Y
|
||||||
: plateBox.Top - boundingBox.Y;
|
: plateBox.Top - boundingBox.Y;
|
||||||
|
|
||||||
@@ -498,8 +490,8 @@ namespace OpenNest
|
|||||||
|
|
||||||
box.X += EdgeSpacing.Left;
|
box.X += EdgeSpacing.Left;
|
||||||
box.Y += EdgeSpacing.Bottom;
|
box.Y += EdgeSpacing.Bottom;
|
||||||
box.Width -= EdgeSpacing.Left + EdgeSpacing.Right;
|
box.Length -= EdgeSpacing.Left + EdgeSpacing.Right;
|
||||||
box.Length -= EdgeSpacing.Top + EdgeSpacing.Bottom;
|
box.Width -= EdgeSpacing.Top + EdgeSpacing.Bottom;
|
||||||
|
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
@@ -557,6 +549,65 @@ namespace OpenNest
|
|||||||
Rounding.RoundUpToNearest(xExtent, roundingFactor));
|
Rounding.RoundUpToNearest(xExtent, roundingFactor));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sizes the plate using the <see cref="PlateSizes"/> catalog: small
|
||||||
|
/// layouts snap to an increment, larger ones round up to the next
|
||||||
|
/// standard mill sheet. The plate's long-axis orientation (X vs Y)
|
||||||
|
/// is preserved. Does nothing if the plate has no parts.
|
||||||
|
/// </summary>
|
||||||
|
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
|
||||||
|
{
|
||||||
|
if (Parts.Count == 0)
|
||||||
|
return default;
|
||||||
|
|
||||||
|
var bounds = Parts.GetBoundingBox();
|
||||||
|
|
||||||
|
// Quadrant-aware extents relative to the plate origin, matching AutoSize.
|
||||||
|
double xExtent;
|
||||||
|
double yExtent;
|
||||||
|
|
||||||
|
switch (Quadrant)
|
||||||
|
{
|
||||||
|
case 1:
|
||||||
|
xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right;
|
||||||
|
yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left;
|
||||||
|
yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left;
|
||||||
|
yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right;
|
||||||
|
yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlateSizes.Recommend takes (short, long); canonicalize then map
|
||||||
|
// the result back so the plate's long axis stays aligned with the
|
||||||
|
// parts' long axis.
|
||||||
|
var shortDim = System.Math.Min(xExtent, yExtent);
|
||||||
|
var longDim = System.Math.Max(xExtent, yExtent);
|
||||||
|
var result = PlateSizes.Recommend(shortDim, longDim, options);
|
||||||
|
|
||||||
|
// Plate convention: Length = X axis, Width = Y axis.
|
||||||
|
if (xExtent >= yExtent)
|
||||||
|
Size = new Size(result.Width, result.Length); // X is the long axis
|
||||||
|
else
|
||||||
|
Size = new Size(result.Length, result.Width); // Y is the long axis
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the area of the top surface of the plate.
|
/// Gets the area of the top surface of the plate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -569,19 +620,17 @@ namespace OpenNest
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the volume of the plate.
|
/// Gets the volume of the plate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
public double Volume(double thickness)
|
||||||
public double Volume()
|
|
||||||
{
|
{
|
||||||
return Area() * Thickness;
|
return Area() * thickness;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the weight of the plate.
|
/// Gets the weight of the plate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
public double Weight(double thickness, double density)
|
||||||
public double Weight()
|
|
||||||
{
|
{
|
||||||
return Volume() * Material.Density;
|
return Volume(thickness) * density;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
using OpenNest.Collections;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public class PlateChangedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public Plate Plate { get; }
|
||||||
|
public int Index { get; }
|
||||||
|
|
||||||
|
public PlateChangedEventArgs(Plate plate, int index)
|
||||||
|
{
|
||||||
|
Plate = plate;
|
||||||
|
Index = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlateManager : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Nest nest;
|
||||||
|
private bool disposed;
|
||||||
|
private bool suppressNavigation;
|
||||||
|
private bool batching;
|
||||||
|
private Plate subscribedLast;
|
||||||
|
private Plate subscribedSecondToLast;
|
||||||
|
|
||||||
|
public event EventHandler<PlateChangedEventArgs> CurrentPlateChanged;
|
||||||
|
public event EventHandler PlateListChanged;
|
||||||
|
|
||||||
|
public PlateManager(Nest nest)
|
||||||
|
{
|
||||||
|
this.nest = nest;
|
||||||
|
nest.Plates.ItemAdded += OnPlateAdded;
|
||||||
|
nest.Plates.ItemRemoved += OnPlateRemoved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CurrentIndex { get; private set; }
|
||||||
|
|
||||||
|
public Plate CurrentPlate => nest.Plates.Count > 0 ? nest.Plates[CurrentIndex] : null;
|
||||||
|
|
||||||
|
public int Count => nest.Plates.Count;
|
||||||
|
|
||||||
|
public bool IsFirst => Count == 0 || CurrentIndex <= 0;
|
||||||
|
|
||||||
|
public bool IsLast => CurrentIndex + 1 >= Count;
|
||||||
|
|
||||||
|
public bool CanRemoveCurrent => Count > 1 && CurrentPlate != null && CurrentPlate.Parts.Count > 0;
|
||||||
|
|
||||||
|
public void LoadFirst()
|
||||||
|
{
|
||||||
|
if (Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CurrentIndex = 0;
|
||||||
|
FireCurrentPlateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LoadLast()
|
||||||
|
{
|
||||||
|
if (Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CurrentIndex = Count - 1;
|
||||||
|
FireCurrentPlateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool LoadNext()
|
||||||
|
{
|
||||||
|
if (CurrentIndex + 1 >= Count)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
CurrentIndex++;
|
||||||
|
FireCurrentPlateChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool LoadPrevious()
|
||||||
|
{
|
||||||
|
if (Count == 0 || CurrentIndex - 1 < 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
CurrentIndex--;
|
||||||
|
FireCurrentPlateChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LoadAt(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= Count)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CurrentIndex = index;
|
||||||
|
FireCurrentPlateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnsureSentinel()
|
||||||
|
{
|
||||||
|
suppressNavigation = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Count == 0 || nest.Plates[^1].Parts.Count > 0)
|
||||||
|
nest.CreatePlate();
|
||||||
|
|
||||||
|
while (Count > 1
|
||||||
|
&& nest.Plates[^1].Parts.Count == 0
|
||||||
|
&& nest.Plates[^2].Parts.Count == 0)
|
||||||
|
{
|
||||||
|
nest.Plates.RemoveAt(Count - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
suppressNavigation = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
SubscribeToTailPlates();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BeginBatch()
|
||||||
|
{
|
||||||
|
batching = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EndBatch()
|
||||||
|
{
|
||||||
|
batching = false;
|
||||||
|
EnsureSentinel();
|
||||||
|
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
FireCurrentPlateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Plate GetOrCreateEmpty()
|
||||||
|
{
|
||||||
|
for (var i = Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (nest.Plates[i].Parts.Count == 0)
|
||||||
|
return nest.Plates[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return nest.CreatePlate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveCurrent()
|
||||||
|
{
|
||||||
|
if (Count < 2)
|
||||||
|
return;
|
||||||
|
|
||||||
|
nest.Plates.RemoveAt(CurrentIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SubscribeToTailPlates()
|
||||||
|
{
|
||||||
|
UnsubscribeFromTailPlates();
|
||||||
|
|
||||||
|
if (Count > 0)
|
||||||
|
{
|
||||||
|
subscribedLast = nest.Plates[^1];
|
||||||
|
subscribedLast.PartAdded += OnTailPartAdded;
|
||||||
|
subscribedLast.PartRemoved += OnTailPartRemoved;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Count > 1)
|
||||||
|
{
|
||||||
|
subscribedSecondToLast = nest.Plates[^2];
|
||||||
|
subscribedSecondToLast.PartAdded += OnTailPartAdded;
|
||||||
|
subscribedSecondToLast.PartRemoved += OnTailPartRemoved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnsubscribeFromTailPlates()
|
||||||
|
{
|
||||||
|
if (subscribedLast != null)
|
||||||
|
{
|
||||||
|
subscribedLast.PartAdded -= OnTailPartAdded;
|
||||||
|
subscribedLast.PartRemoved -= OnTailPartRemoved;
|
||||||
|
subscribedLast = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscribedSecondToLast != null)
|
||||||
|
{
|
||||||
|
subscribedSecondToLast.PartAdded -= OnTailPartAdded;
|
||||||
|
subscribedSecondToLast.PartRemoved -= OnTailPartRemoved;
|
||||||
|
subscribedSecondToLast = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTailPartAdded(object sender, ItemAddedEventArgs<Part> e)
|
||||||
|
{
|
||||||
|
if (!batching)
|
||||||
|
EnsureSentinel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTailPartRemoved(object sender, ItemRemovedEventArgs<Part> e)
|
||||||
|
{
|
||||||
|
if (!batching)
|
||||||
|
EnsureSentinel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlateAdded(object sender, ItemAddedEventArgs<Plate> e)
|
||||||
|
{
|
||||||
|
if (!suppressNavigation && !batching)
|
||||||
|
EnsureSentinel();
|
||||||
|
|
||||||
|
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
if (!suppressNavigation)
|
||||||
|
{
|
||||||
|
CurrentIndex = Count - 1;
|
||||||
|
FireCurrentPlateChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlateRemoved(object sender, ItemRemovedEventArgs<Plate> e)
|
||||||
|
{
|
||||||
|
if (CurrentIndex >= Count && Count > 0)
|
||||||
|
CurrentIndex = Count - 1;
|
||||||
|
|
||||||
|
if (!suppressNavigation && !batching)
|
||||||
|
EnsureSentinel();
|
||||||
|
|
||||||
|
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
if (!suppressNavigation)
|
||||||
|
FireCurrentPlateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FireCurrentPlateChanged()
|
||||||
|
{
|
||||||
|
CurrentPlateChanged?.Invoke(this, new PlateChangedEventArgs(CurrentPlate, CurrentIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
disposed = true;
|
||||||
|
UnsubscribeFromTailPlates();
|
||||||
|
nest.Plates.ItemAdded -= OnPlateAdded;
|
||||||
|
nest.Plates.ItemRemoved -= OnPlateRemoved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public class PlateOptimizerResult
|
||||||
|
{
|
||||||
|
public List<Part> Parts { get; set; } = new();
|
||||||
|
public PlateOption ChosenSize { get; set; }
|
||||||
|
public double NetCost { get; set; }
|
||||||
|
public double Utilization { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public class PlateOption
|
||||||
|
{
|
||||||
|
public double Width { get; set; }
|
||||||
|
public double Length { get; set; }
|
||||||
|
public double Cost { get; set; }
|
||||||
|
|
||||||
|
public double Area => Width * Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,13 @@ namespace OpenNest.Shapes
|
|||||||
{
|
{
|
||||||
public double Diameter { get; set; }
|
public double Diameter { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"Circle {Dim(Diameter)} Dia";
|
||||||
|
|
||||||
|
public override void SetPreviewDefaults()
|
||||||
|
{
|
||||||
|
Diameter = 8;
|
||||||
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
var entities = new List<Entity>
|
var entities = new List<Entity>
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ namespace OpenNest.Shapes
|
|||||||
public double Base { get; set; }
|
public double Base { get; set; }
|
||||||
public double Height { get; set; }
|
public double Height { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"Isosceles Triangle {Dim(Base)}x{Dim(Height)}";
|
||||||
|
|
||||||
|
public override void SetPreviewDefaults()
|
||||||
|
{
|
||||||
|
Base = 8;
|
||||||
|
Height = 10;
|
||||||
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
var midX = Base / 2.0;
|
var midX = Base / 2.0;
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ namespace OpenNest.Shapes
|
|||||||
public double LegWidth { get; set; }
|
public double LegWidth { get; set; }
|
||||||
public double LegHeight { get; set; }
|
public double LegHeight { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"L {Dim(Width)}x{Dim(Height)}";
|
||||||
|
|
||||||
|
public override void SetPreviewDefaults()
|
||||||
|
{
|
||||||
|
Width = 8;
|
||||||
|
Height = 10;
|
||||||
|
LegWidth = 3;
|
||||||
|
LegHeight = 3;
|
||||||
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
var lw = LegWidth > 0 ? LegWidth : Width / 2.0;
|
var lw = LegWidth > 0 ? LegWidth : Width / 2.0;
|
||||||
|
|||||||
@@ -3,28 +3,40 @@ using System.Collections.Generic;
|
|||||||
|
|
||||||
namespace OpenNest.Shapes
|
namespace OpenNest.Shapes
|
||||||
{
|
{
|
||||||
public class OctagonShape : ShapeDefinition
|
public class NgonShape : ShapeDefinition
|
||||||
{
|
{
|
||||||
|
public int Sides { get; set; }
|
||||||
public double Width { get; set; }
|
public double Width { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"{Sides}-Sided Polygon {Dim(Width)}";
|
||||||
|
|
||||||
|
public override void SetPreviewDefaults()
|
||||||
|
{
|
||||||
|
Sides = 8;
|
||||||
|
Width = 8;
|
||||||
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
|
var n = Sides < 3 ? 3 : Sides;
|
||||||
var center = Width / 2.0;
|
var center = Width / 2.0;
|
||||||
var circumRadius = Width / (2.0 * System.Math.Cos(System.Math.PI / 8.0));
|
var circumRadius = Width / (2.0 * System.Math.Cos(System.Math.PI / n));
|
||||||
|
var step = 2.0 * System.Math.PI / n;
|
||||||
|
var start = System.Math.PI / n;
|
||||||
|
|
||||||
var vertices = new Vector[8];
|
var vertices = new Vector[n];
|
||||||
for (var i = 0; i < 8; i++)
|
for (var i = 0; i < n; i++)
|
||||||
{
|
{
|
||||||
var angle = System.Math.PI / 8.0 + i * System.Math.PI / 4.0;
|
var angle = start + i * step;
|
||||||
vertices[i] = new Vector(
|
vertices[i] = new Vector(
|
||||||
center + circumRadius * System.Math.Cos(angle),
|
center + circumRadius * System.Math.Cos(angle),
|
||||||
center + circumRadius * System.Math.Sin(angle));
|
center + circumRadius * System.Math.Sin(angle));
|
||||||
}
|
}
|
||||||
|
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
for (var i = 0; i < 8; i++)
|
for (var i = 0; i < n; i++)
|
||||||
{
|
{
|
||||||
var next = (i + 1) % 8;
|
var next = (i + 1) % n;
|
||||||
entities.Add(new Line(vertices[i], vertices[next]));
|
entities.Add(new Line(vertices[i], vertices[next]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,22 +3,41 @@ using System.Collections.Generic;
|
|||||||
|
|
||||||
namespace OpenNest.Shapes
|
namespace OpenNest.Shapes
|
||||||
{
|
{
|
||||||
public class FlangeShape : ShapeDefinition
|
public class PipeFlangeShape : ShapeDefinition
|
||||||
{
|
{
|
||||||
public double NominalPipeSize { get; set; }
|
|
||||||
public double OD { get; set; }
|
public double OD { get; set; }
|
||||||
public double HoleDiameter { get; set; }
|
public double HoleDiameter { get; set; }
|
||||||
public double HolePatternDiameter { get; set; }
|
public double HolePatternDiameter { get; set; }
|
||||||
public int HoleCount { get; set; }
|
public int HoleCount { get; set; }
|
||||||
|
public string PipeSize { get; set; }
|
||||||
|
public double PipeClearance { get; set; }
|
||||||
|
public bool Blind { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName()
|
||||||
|
{
|
||||||
|
var name = $"Pipe Flange {Dim(OD)} OD";
|
||||||
|
if (!string.IsNullOrEmpty(PipeSize))
|
||||||
|
name += $" {PipeSize} Pipe";
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SetPreviewDefaults()
|
||||||
|
{
|
||||||
|
OD = 7.5;
|
||||||
|
HoleDiameter = 0.875;
|
||||||
|
HolePatternDiameter = 5.5;
|
||||||
|
HoleCount = 8;
|
||||||
|
PipeSize = "2";
|
||||||
|
PipeClearance = 0.0625;
|
||||||
|
Blind = false;
|
||||||
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
|
|
||||||
// Outer circle
|
|
||||||
entities.Add(new Circle(0, 0, OD / 2.0));
|
entities.Add(new Circle(0, 0, OD / 2.0));
|
||||||
|
|
||||||
// Bolt holes evenly spaced on the bolt circle
|
|
||||||
var boltCircleRadius = HolePatternDiameter / 2.0;
|
var boltCircleRadius = HolePatternDiameter / 2.0;
|
||||||
var holeRadius = HoleDiameter / 2.0;
|
var holeRadius = HoleDiameter / 2.0;
|
||||||
var angleStep = 2.0 * System.Math.PI / HoleCount;
|
var angleStep = 2.0 * System.Math.PI / HoleCount;
|
||||||
@@ -31,6 +50,12 @@ namespace OpenNest.Shapes
|
|||||||
entities.Add(new Circle(cx, cy, holeRadius));
|
entities.Add(new Circle(cx, cy, holeRadius));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Blind && !string.IsNullOrEmpty(PipeSize) && PipeSizes.TryGetOD(PipeSize, out var pipeOD))
|
||||||
|
{
|
||||||
|
var boreDiameter = pipeOD + PipeClearance;
|
||||||
|
entities.Add(new Circle(0, 0, boreDiameter / 2.0));
|
||||||
|
}
|
||||||
|
|
||||||
return CreateDrawing(entities);
|
return CreateDrawing(entities);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.Shapes
|
||||||
|
{
|
||||||
|
public static class PipeSizes
|
||||||
|
{
|
||||||
|
public readonly record struct Entry(string Label, double OuterDiameter);
|
||||||
|
|
||||||
|
public static IReadOnlyList<Entry> All { get; } = new[]
|
||||||
|
{
|
||||||
|
new Entry("1/8", 0.405),
|
||||||
|
new Entry("1/4", 0.540),
|
||||||
|
new Entry("3/8", 0.675),
|
||||||
|
new Entry("1/2", 0.840),
|
||||||
|
new Entry("3/4", 1.050),
|
||||||
|
new Entry("1", 1.315),
|
||||||
|
new Entry("1 1/4", 1.660),
|
||||||
|
new Entry("1 1/2", 1.900),
|
||||||
|
new Entry("2", 2.375),
|
||||||
|
new Entry("2 1/2", 2.875),
|
||||||
|
new Entry("3", 3.500),
|
||||||
|
new Entry("3 1/2", 4.000),
|
||||||
|
new Entry("4", 4.500),
|
||||||
|
new Entry("4 1/2", 5.000),
|
||||||
|
new Entry("5", 5.563),
|
||||||
|
new Entry("6", 6.625),
|
||||||
|
new Entry("7", 7.625),
|
||||||
|
new Entry("8", 8.625),
|
||||||
|
new Entry("9", 9.625),
|
||||||
|
new Entry("10", 10.750),
|
||||||
|
new Entry("11", 11.750),
|
||||||
|
new Entry("12", 12.750),
|
||||||
|
new Entry("14", 14.000),
|
||||||
|
new Entry("16", 16.000),
|
||||||
|
new Entry("18", 18.000),
|
||||||
|
new Entry("20", 20.000),
|
||||||
|
new Entry("24", 24.000),
|
||||||
|
new Entry("26", 26.000),
|
||||||
|
new Entry("28", 28.000),
|
||||||
|
new Entry("30", 30.000),
|
||||||
|
new Entry("32", 32.000),
|
||||||
|
new Entry("34", 34.000),
|
||||||
|
new Entry("36", 36.000),
|
||||||
|
new Entry("42", 42.000),
|
||||||
|
new Entry("48", 48.000),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool TryGetOD(string label, out double outerDiameter)
|
||||||
|
{
|
||||||
|
foreach (var entry in All)
|
||||||
|
{
|
||||||
|
if (entry.Label == label)
|
||||||
|
{
|
||||||
|
outerDiameter = entry.OuterDiameter;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outerDiameter = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all pipe sizes whose outer diameter is less than or equal to <paramref name="maxOD"/>.
|
||||||
|
/// The bound is inclusive.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Entry> GetFittingSizes(double maxOD)
|
||||||
|
{
|
||||||
|
foreach (var entry in All)
|
||||||
|
{
|
||||||
|
if (entry.OuterDiameter <= maxOD)
|
||||||
|
{
|
||||||
|
yield return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Shapes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Catalog of standard mill sheet sizes (inches) with helpers for matching
|
||||||
|
/// a bounding box to a recommended plate size. Uses the project-wide
|
||||||
|
/// (Width, Length) convention where Width is the short dimension and
|
||||||
|
/// Length is the long dimension.
|
||||||
|
/// </summary>
|
||||||
|
public static class PlateSizes
|
||||||
|
{
|
||||||
|
public readonly record struct Entry(string Label, double Width, double Length)
|
||||||
|
{
|
||||||
|
public double Area => Width * Length;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if a part of the given dimensions fits within this entry
|
||||||
|
/// in either orientation.
|
||||||
|
/// </summary>
|
||||||
|
public bool Fits(double width, double length) =>
|
||||||
|
(width <= Width && length <= Length) || (width <= Length && length <= Width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Standard mill sheet sizes (inches), sorted by area ascending.
|
||||||
|
/// Canonical orientation: Width <= Length.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<Entry> All { get; } = new[]
|
||||||
|
{
|
||||||
|
new Entry("48x96", 48, 96), // 4608
|
||||||
|
new Entry("48x120", 48, 120), // 5760
|
||||||
|
new Entry("48x144", 48, 144), // 6912
|
||||||
|
new Entry("60x120", 60, 120), // 7200
|
||||||
|
new Entry("60x144", 60, 144), // 8640
|
||||||
|
new Entry("72x120", 72, 120), // 8640
|
||||||
|
new Entry("72x144", 72, 144), // 10368
|
||||||
|
new Entry("96x240", 96, 240), // 23040
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a standard size by label. Case-insensitive.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGet(string label, out Entry entry)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(label))
|
||||||
|
{
|
||||||
|
foreach (var candidate in All)
|
||||||
|
{
|
||||||
|
if (string.Equals(candidate.Label, label, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
entry = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recommends a plate size for the given bounding box. The box's
|
||||||
|
/// spatial axes are normalized to (short, long) so neither the bbox
|
||||||
|
/// orientation nor Box's internal Length/Width naming matters.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateSizeResult Recommend(Box bbox, PlateSizeOptions options = null)
|
||||||
|
{
|
||||||
|
var a = bbox.Width;
|
||||||
|
var b = bbox.Length;
|
||||||
|
return Recommend(System.Math.Min(a, b), System.Math.Max(a, b), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recommends a plate size for the envelope of the given boxes.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateSizeResult Recommend(IEnumerable<Box> boxes, PlateSizeOptions options = null)
|
||||||
|
{
|
||||||
|
if (boxes == null)
|
||||||
|
throw new ArgumentNullException(nameof(boxes));
|
||||||
|
|
||||||
|
var hasAny = false;
|
||||||
|
var minX = double.PositiveInfinity;
|
||||||
|
var minY = double.PositiveInfinity;
|
||||||
|
var maxX = double.NegativeInfinity;
|
||||||
|
var maxY = double.NegativeInfinity;
|
||||||
|
|
||||||
|
foreach (var box in boxes)
|
||||||
|
{
|
||||||
|
hasAny = true;
|
||||||
|
if (box.Left < minX) minX = box.Left;
|
||||||
|
if (box.Bottom < minY) minY = box.Bottom;
|
||||||
|
if (box.Right > maxX) maxX = box.Right;
|
||||||
|
if (box.Top > maxY) maxY = box.Top;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAny)
|
||||||
|
throw new ArgumentException("At least one box is required.", nameof(boxes));
|
||||||
|
|
||||||
|
var b = maxX - minX;
|
||||||
|
var a = maxY - minY;
|
||||||
|
return Recommend(System.Math.Min(a, b), System.Math.Max(a, b), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recommends a plate size for a (width, length) pair.
|
||||||
|
/// Inputs are treated as orientation-independent.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateSizeResult Recommend(double width, double length, PlateSizeOptions options = null)
|
||||||
|
{
|
||||||
|
options ??= new PlateSizeOptions();
|
||||||
|
|
||||||
|
var w = width + 2 * options.Margin;
|
||||||
|
var l = length + 2 * options.Margin;
|
||||||
|
|
||||||
|
// Canonicalize (short, long) — Fits handles rotation anyway, but
|
||||||
|
// normalizing lets the below-min comparison use the narrower
|
||||||
|
// MinSheet dimensions consistently.
|
||||||
|
if (w > l)
|
||||||
|
(w, l) = (l, w);
|
||||||
|
|
||||||
|
// Below full-sheet threshold: snap each dimension up to the nearest increment.
|
||||||
|
if (w <= options.MinSheetWidth && l <= options.MinSheetLength)
|
||||||
|
return SnapResult(w, l, options.SnapIncrement);
|
||||||
|
|
||||||
|
var catalog = BuildCatalog(options.AllowedSizes);
|
||||||
|
|
||||||
|
var best = PickBest(catalog, w, l, options.Selection);
|
||||||
|
if (best.HasValue)
|
||||||
|
return new PlateSizeResult(best.Value.Width, best.Value.Length, best.Value.Label);
|
||||||
|
|
||||||
|
// Nothing in the catalog fits - fall back to snap-up (ad-hoc oversize sheet).
|
||||||
|
return SnapResult(w, l, options.SnapIncrement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlateSizeResult SnapResult(double width, double length, double increment)
|
||||||
|
{
|
||||||
|
if (increment <= 0)
|
||||||
|
return new PlateSizeResult(width, length, null);
|
||||||
|
|
||||||
|
return new PlateSizeResult(SnapUp(width, increment), SnapUp(length, increment), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double SnapUp(double value, double increment)
|
||||||
|
{
|
||||||
|
var steps = System.Math.Ceiling(value / increment);
|
||||||
|
return steps * increment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<Entry> BuildCatalog(IReadOnlyList<string> allowedSizes)
|
||||||
|
{
|
||||||
|
if (allowedSizes == null || allowedSizes.Count == 0)
|
||||||
|
return All;
|
||||||
|
|
||||||
|
var result = new List<Entry>(allowedSizes.Count);
|
||||||
|
foreach (var label in allowedSizes)
|
||||||
|
{
|
||||||
|
if (TryParseEntry(label, out var entry))
|
||||||
|
result.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseEntry(string label, out Entry entry)
|
||||||
|
{
|
||||||
|
if (TryGet(label, out entry))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Accept ad-hoc "WxL" strings (e.g. "50x100", "50 x 100").
|
||||||
|
if (!string.IsNullOrWhiteSpace(label))
|
||||||
|
{
|
||||||
|
var parts = label.Split(new[] { 'x', 'X' }, 2);
|
||||||
|
if (parts.Length == 2
|
||||||
|
&& double.TryParse(parts[0].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var a)
|
||||||
|
&& double.TryParse(parts[1].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var b)
|
||||||
|
&& a > 0 && b > 0)
|
||||||
|
{
|
||||||
|
var width = System.Math.Min(a, b);
|
||||||
|
var length = System.Math.Max(a, b);
|
||||||
|
entry = new Entry(label.Trim(), width, length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entry? PickBest(IReadOnlyList<Entry> catalog, double width, double length, PlateSizeSelection selection)
|
||||||
|
{
|
||||||
|
var fitting = catalog.Where(e => e.Fits(width, length));
|
||||||
|
|
||||||
|
fitting = selection switch
|
||||||
|
{
|
||||||
|
PlateSizeSelection.NarrowestFirst => fitting.OrderBy(e => e.Width).ThenBy(e => e.Area),
|
||||||
|
_ => fitting.OrderBy(e => e.Area).ThenBy(e => e.Width),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var candidate in fitting)
|
||||||
|
return candidate;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct PlateSizeResult(double Width, double Length, string MatchedLabel)
|
||||||
|
{
|
||||||
|
public bool IsStandard => MatchedLabel != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlateSizeOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// If the margin-adjusted bounding box fits within MinSheetWidth x MinSheetLength
|
||||||
|
/// the result is snapped to <see cref="SnapIncrement"/> instead of routed to a
|
||||||
|
/// standard sheet. Default 48" x 48".
|
||||||
|
/// </summary>
|
||||||
|
public double MinSheetWidth { get; set; } = 48;
|
||||||
|
public double MinSheetLength { get; set; } = 48;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Increment used for below-threshold rounding and oversize fallback. Default 1".
|
||||||
|
/// </summary>
|
||||||
|
public double SnapIncrement { get; set; } = 1.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extra clearance added to each side of the bounding box before matching.
|
||||||
|
/// </summary>
|
||||||
|
public double Margin { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional whitelist. When non-empty, only these sizes are considered.
|
||||||
|
/// Entries may be standard catalog labels (e.g. "48x96") or arbitrary
|
||||||
|
/// "WxL" strings (e.g. "50x100").
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> AllowedSizes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tiebreaker when multiple sheets can contain the bounding box.
|
||||||
|
/// </summary>
|
||||||
|
public PlateSizeSelection Selection { get; set; } = PlateSizeSelection.SmallestArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PlateSizeSelection
|
||||||
|
{
|
||||||
|
/// <summary>Pick the cheapest sheet that contains the bbox (smallest area).</summary>
|
||||||
|
SmallestArea,
|
||||||
|
/// <summary>Prefer narrower-width sheets (e.g. 48-wide before 60-wide).</summary>
|
||||||
|
NarrowestFirst,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,14 @@ namespace OpenNest.Shapes
|
|||||||
public double Length { get; set; }
|
public double Length { get; set; }
|
||||||
public double Width { get; set; }
|
public double Width { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"Rectangle {Dim(Length)}x{Dim(Width)}";
|
||||||
|
|
||||||
|
public override void SetPreviewDefaults()
|
||||||
|
{
|
||||||
|
Length = 12;
|
||||||
|
Width = 6;
|
||||||
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
var entities = new List<Entity>
|
var entities = new List<Entity>
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ namespace OpenNest.Shapes
|
|||||||
public double Width { get; set; }
|
public double Width { get; set; }
|
||||||
public double Height { get; set; }
|
public double Height { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"Right Triangle {Dim(Width)}x{Dim(Height)}";
|
||||||
|
|
||||||
|
public override void SetPreviewDefaults()
|
||||||
|
{
|
||||||
|
Width = 8;
|
||||||
|
Height = 6;
|
||||||
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
var entities = new List<Entity>
|
var entities = new List<Entity>
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ namespace OpenNest.Shapes
|
|||||||
public double OuterDiameter { get; set; }
|
public double OuterDiameter { get; set; }
|
||||||
public double InnerDiameter { get; set; }
|
public double InnerDiameter { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"Ring {Dim(OuterDiameter)}x{Dim(InnerDiameter)}";
|
||||||
|
|
||||||
|
public override void SetPreviewDefaults()
|
||||||
|
{
|
||||||
|
OuterDiameter = 10;
|
||||||
|
InnerDiameter = 6;
|
||||||
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
var entities = new List<Entity>
|
var entities = new List<Entity>
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ namespace OpenNest.Shapes
|
|||||||
public double Width { get; set; }
|
public double Width { get; set; }
|
||||||
public double Radius { get; set; }
|
public double Radius { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"Rounded Rectangle {Dim(Length)}x{Dim(Width)} R{Dim(Radius)}";
|
||||||
|
|
||||||
|
public override void SetPreviewDefaults()
|
||||||
|
{
|
||||||
|
Length = 12;
|
||||||
|
Width = 6;
|
||||||
|
Radius = 1;
|
||||||
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
var r = Radius;
|
var r = Radius;
|
||||||
|
|||||||
@@ -26,12 +26,24 @@ namespace OpenNest.Shapes
|
|||||||
|
|
||||||
public abstract Drawing GetDrawing();
|
public abstract Drawing GetDrawing();
|
||||||
|
|
||||||
|
public virtual string GenerateName()
|
||||||
|
{
|
||||||
|
var typeName = GetType().Name;
|
||||||
|
return typeName.EndsWith("Shape")
|
||||||
|
? typeName.Substring(0, typeName.Length - 5)
|
||||||
|
: typeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual void SetPreviewDefaults() { }
|
||||||
|
|
||||||
public static List<T> LoadFromJson<T>(string path) where T : ShapeDefinition
|
public static List<T> LoadFromJson<T>(string path) where T : ShapeDefinition
|
||||||
{
|
{
|
||||||
var json = File.ReadAllText(path);
|
var json = File.ReadAllText(path);
|
||||||
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions);
|
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static string Dim(double value) => value.ToString("0.###");
|
||||||
|
|
||||||
protected Drawing CreateDrawing(List<Entity> entities)
|
protected Drawing CreateDrawing(List<Entity> entities)
|
||||||
{
|
{
|
||||||
var pgm = ConvertGeometry.ToProgram(entities);
|
var pgm = ConvertGeometry.ToProgram(entities);
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ namespace OpenNest.Shapes
|
|||||||
public double StemWidth { get; set; }
|
public double StemWidth { get; set; }
|
||||||
public double BarHeight { get; set; }
|
public double BarHeight { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"T {Dim(Width)}x{Dim(Height)}";
|
||||||
|
|
||||||
|
public override void SetPreviewDefaults()
|
||||||
|
{
|
||||||
|
Width = 10;
|
||||||
|
Height = 8;
|
||||||
|
StemWidth = 3;
|
||||||
|
BarHeight = 3;
|
||||||
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
var sw = StemWidth > 0 ? StemWidth : Width / 3.0;
|
var sw = StemWidth > 0 ? StemWidth : Width / 3.0;
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ namespace OpenNest.Shapes
|
|||||||
public double BottomWidth { get; set; }
|
public double BottomWidth { get; set; }
|
||||||
public double Height { get; set; }
|
public double Height { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"Trapezoid {Dim(TopWidth)}x{Dim(BottomWidth)}x{Dim(Height)}";
|
||||||
|
|
||||||
|
public override void SetPreviewDefaults()
|
||||||
|
{
|
||||||
|
TopWidth = 6;
|
||||||
|
BottomWidth = 10;
|
||||||
|
Height = 6;
|
||||||
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
var offset = (BottomWidth - TopWidth) / 2.0;
|
var offset = (BottomWidth - TopWidth) / 2.0;
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ namespace OpenNest
|
|||||||
|
|
||||||
public static readonly Layer Display = new Layer("DISPLAY") { Color = Color.Cyan };
|
public static readonly Layer Display = new Layer("DISPLAY") { Color = Color.Cyan };
|
||||||
|
|
||||||
public static readonly Layer Leadin = new Layer("LEADIN") { Color = Color.Yellow };
|
public static readonly Layer Leadin = new Layer("LEADIN") { Color = Color.Brown };
|
||||||
|
|
||||||
public static readonly Layer Leadout = new Layer("LEADOUT") { Color = Color.Yellow };
|
public static readonly Layer Leadout = new Layer("LEADOUT") { Color = Color.Brown };
|
||||||
|
|
||||||
public static readonly Layer Scribe = new Layer("SCRIBE") { Color = Color.Magenta };
|
public static readonly Layer Scribe = new Layer("SCRIBE") { Color = Color.Magenta };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ public static class AutoSplitCalculator
|
|||||||
|
|
||||||
var lines = new List<SplitLine>();
|
var lines = new List<SplitLine>();
|
||||||
|
|
||||||
var verticalSplits = usableWidth > 0 ? (int)System.Math.Ceiling(partBounds.Width / usableWidth) - 1 : 0;
|
var verticalSplits = usableWidth > 0 ? (int)System.Math.Ceiling(partBounds.Length / usableWidth) - 1 : 0;
|
||||||
var horizontalSplits = usableHeight > 0 ? (int)System.Math.Ceiling(partBounds.Length / usableHeight) - 1 : 0;
|
var horizontalSplits = usableHeight > 0 ? (int)System.Math.Ceiling(partBounds.Width / usableHeight) - 1 : 0;
|
||||||
|
|
||||||
if (verticalSplits < 0) verticalSplits = 0;
|
if (verticalSplits < 0) verticalSplits = 0;
|
||||||
if (horizontalSplits < 0) horizontalSplits = 0;
|
if (horizontalSplits < 0) horizontalSplits = 0;
|
||||||
@@ -34,14 +34,14 @@ public static class AutoSplitCalculator
|
|||||||
|
|
||||||
if (verticalPieces > 1)
|
if (verticalPieces > 1)
|
||||||
{
|
{
|
||||||
var spacing = partBounds.Width / verticalPieces;
|
var spacing = partBounds.Length / verticalPieces;
|
||||||
for (var i = 1; i < verticalPieces; i++)
|
for (var i = 1; i < verticalPieces; i++)
|
||||||
lines.Add(new SplitLine(partBounds.X + spacing * i, CutOffAxis.Vertical));
|
lines.Add(new SplitLine(partBounds.X + spacing * i, CutOffAxis.Vertical));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (horizontalPieces > 1)
|
if (horizontalPieces > 1)
|
||||||
{
|
{
|
||||||
var spacing = partBounds.Length / horizontalPieces;
|
var spacing = partBounds.Width / horizontalPieces;
|
||||||
for (var i = 1; i < horizontalPieces; i++)
|
for (var i = 1; i < horizontalPieces; i++)
|
||||||
lines.Add(new SplitLine(partBounds.Y + spacing * i, CutOffAxis.Horizontal));
|
lines.Add(new SplitLine(partBounds.Y + spacing * i, CutOffAxis.Horizontal));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,20 @@ public static class DrawingSplitter
|
|||||||
var regions = BuildClipRegions(sortedLines, bounds);
|
var regions = BuildClipRegions(sortedLines, bounds);
|
||||||
var feature = GetFeature(parameters.Type);
|
var feature = GetFeature(parameters.Type);
|
||||||
|
|
||||||
|
// Polygonize cutouts once. Used for trimming feature edges (so cut lines
|
||||||
|
// don't travel through a cutout interior) and for hole/containment tests
|
||||||
|
// in the final component-assembly pass.
|
||||||
|
var cutoutPolygons = profile.Cutouts
|
||||||
|
.Select(c => c.ToPolygon())
|
||||||
|
.Where(p => p != null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var results = new List<Drawing>();
|
var results = new List<Drawing>();
|
||||||
var pieceIndex = 1;
|
var pieceIndex = 1;
|
||||||
|
|
||||||
foreach (var region in regions)
|
foreach (var region in regions)
|
||||||
{
|
{
|
||||||
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters);
|
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters, cutoutPolygons);
|
||||||
if (pieceEntities.Count == 0)
|
if (pieceEntities.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -47,9 +55,16 @@ public static class DrawingSplitter
|
|||||||
allEntities.AddRange(pieceEntities);
|
allEntities.AddRange(pieceEntities);
|
||||||
allEntities.AddRange(cutoutEntities);
|
allEntities.AddRange(cutoutEntities);
|
||||||
|
|
||||||
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region);
|
// A single region may yield multiple physically-disjoint pieces when an
|
||||||
results.Add(piece);
|
// interior cutout spans across it. Group the region's entities into
|
||||||
pieceIndex++;
|
// connected closed loops, nest holes by containment, and emit one
|
||||||
|
// Drawing per outer loop (with its contained holes).
|
||||||
|
foreach (var pieceOfRegion in AssemblePieces(allEntities))
|
||||||
|
{
|
||||||
|
var piece = BuildPieceDrawing(drawing, pieceOfRegion, pieceIndex, region);
|
||||||
|
results.Add(piece);
|
||||||
|
pieceIndex++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
@@ -218,100 +233,108 @@ public static class DrawingSplitter
|
|||||||
/// and stitching in feature edges. No polygon clipping library needed.
|
/// and stitching in feature edges. No polygon clipping library needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
|
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
|
||||||
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters)
|
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters,
|
||||||
|
List<Polygon> cutoutPolygons)
|
||||||
{
|
{
|
||||||
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
|
|
||||||
|
|
||||||
foreach (var entity in perimeter.Entities)
|
foreach (var entity in perimeter.Entities)
|
||||||
{
|
ProcessEntity(entity, region, entities);
|
||||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entities.Count == 0)
|
if (entities.Count == 0)
|
||||||
return new List<Entity>();
|
return new List<Entity>();
|
||||||
|
|
||||||
InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters);
|
InsertFeatureEdges(entities, region, boundarySplitLines, feature, parameters, cutoutPolygons);
|
||||||
EnsurePerimeterWinding(entities);
|
// Winding is handled later in AssemblePieces, once connected components
|
||||||
|
// are known. At this stage the piece may still be multiple disjoint loops.
|
||||||
return entities;
|
return entities;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ProcessEntity(Entity entity, Box region,
|
private static void ProcessEntity(Entity entity, Box region, List<Entity> entities)
|
||||||
List<SplitLine> boundarySplitLines, List<Entity> entities,
|
|
||||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
|
|
||||||
{
|
|
||||||
// Find the first boundary split line this entity crosses
|
|
||||||
SplitLine crossedLine = null;
|
|
||||||
Vector? intersectionPt = null;
|
|
||||||
|
|
||||||
foreach (var sl in boundarySplitLines)
|
|
||||||
{
|
|
||||||
if (SplitLineIntersect.CrossesSplitLine(entity, sl))
|
|
||||||
{
|
|
||||||
var pt = SplitLineIntersect.FindIntersection(entity, sl);
|
|
||||||
if (pt != null)
|
|
||||||
{
|
|
||||||
crossedLine = sl;
|
|
||||||
intersectionPt = pt;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (crossedLine != null)
|
|
||||||
{
|
|
||||||
// Entity crosses a split line — split it and keep the half inside the region
|
|
||||||
var regionSide = RegionSideOf(region, crossedLine);
|
|
||||||
var startPt = GetStartPoint(entity);
|
|
||||||
var startSide = SplitLineIntersect.SideOf(startPt, crossedLine);
|
|
||||||
var startInRegion = startSide == regionSide || startSide == 0;
|
|
||||||
|
|
||||||
SplitEntityAtPoint(entity, intersectionPt.Value, startInRegion, crossedLine, entities, splitPoints);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Entity doesn't cross any boundary split line — check if it's inside the region
|
|
||||||
var mid = MidPoint(entity);
|
|
||||||
if (region.Contains(mid))
|
|
||||||
entities.Add(entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SplitEntityAtPoint(Entity entity, Vector point, bool startInRegion,
|
|
||||||
SplitLine crossedLine, List<Entity> entities,
|
|
||||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
|
|
||||||
{
|
{
|
||||||
if (entity is Line line)
|
if (entity is Line line)
|
||||||
{
|
{
|
||||||
var (first, second) = line.SplitAt(point);
|
var clipped = ClipLineToBox(line.StartPoint, line.EndPoint, region);
|
||||||
if (startInRegion)
|
if (clipped == null) return;
|
||||||
{
|
if (clipped.Value.Start.DistanceTo(clipped.Value.End) < Math.Tolerance.Epsilon) return;
|
||||||
if (first != null) entities.Add(first);
|
entities.Add(new Line(clipped.Value.Start, clipped.Value.End));
|
||||||
splitPoints.Add((point, crossedLine, true));
|
return;
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
splitPoints.Add((point, crossedLine, false));
|
|
||||||
if (second != null) entities.Add(second);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (entity is Arc arc)
|
|
||||||
|
if (entity is Arc arc)
|
||||||
{
|
{
|
||||||
var (first, second) = arc.SplitAt(point);
|
foreach (var sub in ClipArcToRegion(arc, region))
|
||||||
if (startInRegion)
|
entities.Add(sub);
|
||||||
{
|
return;
|
||||||
if (first != null) entities.Add(first);
|
|
||||||
splitPoints.Add((point, crossedLine, true));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
splitPoints.Add((point, crossedLine, false));
|
|
||||||
if (second != null) entities.Add(second);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clips an arc against the four edges of a region box. Returns the sub-arcs
|
||||||
|
/// whose midpoints lie inside the region. Uses line-arc intersection to find
|
||||||
|
/// split points, then iteratively bisects the arc at each crossing.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Arc> ClipArcToRegion(Arc arc, Box region)
|
||||||
|
{
|
||||||
|
var edges = new[]
|
||||||
|
{
|
||||||
|
new Line(new Vector(region.Left, region.Bottom), new Vector(region.Right, region.Bottom)),
|
||||||
|
new Line(new Vector(region.Right, region.Bottom), new Vector(region.Right, region.Top)),
|
||||||
|
new Line(new Vector(region.Right, region.Top), new Vector(region.Left, region.Top)),
|
||||||
|
new Line(new Vector(region.Left, region.Top), new Vector(region.Left, region.Bottom))
|
||||||
|
};
|
||||||
|
|
||||||
|
var arcs = new List<Arc> { arc };
|
||||||
|
|
||||||
|
foreach (var edge in edges)
|
||||||
|
{
|
||||||
|
var next = new List<Arc>();
|
||||||
|
foreach (var a in arcs)
|
||||||
|
{
|
||||||
|
if (!Intersect.Intersects(a, edge, out var pts) || pts.Count == 0)
|
||||||
|
{
|
||||||
|
next.Add(a);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the arc at each intersection that actually lies on one of
|
||||||
|
// the working sub-arcs. Prior splits may make some original hits
|
||||||
|
// moot for the sub-arc that now holds them.
|
||||||
|
var working = new List<Arc> { a };
|
||||||
|
foreach (var pt in pts)
|
||||||
|
{
|
||||||
|
var replaced = new List<Arc>();
|
||||||
|
foreach (var w in working)
|
||||||
|
{
|
||||||
|
var onArc = OpenNest.Math.Angle.IsBetweenRad(
|
||||||
|
w.Center.AngleTo(pt), w.StartAngle, w.EndAngle, w.IsReversed);
|
||||||
|
if (!onArc)
|
||||||
|
{
|
||||||
|
replaced.Add(w);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (first, second) = w.SplitAt(pt);
|
||||||
|
if (first != null && first.SweepAngle() > Math.Tolerance.Epsilon) replaced.Add(first);
|
||||||
|
if (second != null && second.SweepAngle() > Math.Tolerance.Epsilon) replaced.Add(second);
|
||||||
|
}
|
||||||
|
working = replaced;
|
||||||
|
}
|
||||||
|
next.AddRange(working);
|
||||||
|
}
|
||||||
|
arcs = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<Arc>();
|
||||||
|
foreach (var a in arcs)
|
||||||
|
{
|
||||||
|
if (region.Contains(a.MidPoint()))
|
||||||
|
result.Add(a);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns split lines whose position matches a boundary edge of the region.
|
/// Returns split lines whose position matches a boundary edge of the region.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -365,104 +388,157 @@ public static class DrawingSplitter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Groups split points by split line, pairs exits with entries, and generates feature edges.
|
/// For each boundary split line of the region, generates a feature edge that
|
||||||
|
/// spans the full region boundary along that split line and trims it against
|
||||||
|
/// interior cutouts. This produces one (or zero) feature edge per contiguous
|
||||||
|
/// material interval on the boundary, handling corner regions (one perimeter
|
||||||
|
/// crossing), spanning cutouts (two holes puncturing the line), and
|
||||||
|
/// normal mid-part splits uniformly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void InsertFeatureEdges(List<Entity> entities,
|
private static void InsertFeatureEdges(List<Entity> entities,
|
||||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints,
|
|
||||||
Box region, List<SplitLine> boundarySplitLines,
|
Box region, List<SplitLine> boundarySplitLines,
|
||||||
ISplitFeature feature, SplitParameters parameters)
|
ISplitFeature feature, SplitParameters parameters,
|
||||||
|
List<Polygon> cutoutPolygons)
|
||||||
{
|
{
|
||||||
// Group split points by their split line
|
foreach (var sl in boundarySplitLines)
|
||||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
|
||||||
foreach (var sp in splitPoints)
|
|
||||||
{
|
{
|
||||||
if (!groups.ContainsKey(sp.Line))
|
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
||||||
groups[sp.Line] = new List<(Vector, bool)>();
|
var extentStart = isVertical ? region.Bottom : region.Left;
|
||||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
var extentEnd = isVertical ? region.Top : region.Right;
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var kvp in groups)
|
if (extentEnd - extentStart < Math.Tolerance.Epsilon)
|
||||||
{
|
|
||||||
var sl = kvp.Key;
|
|
||||||
var points = kvp.Value;
|
|
||||||
|
|
||||||
// Pair each exit with the next entry
|
|
||||||
var exits = points.Where(p => p.IsExit).Select(p => p.Point).ToList();
|
|
||||||
var entries = points.Where(p => !p.IsExit).Select(p => p.Point).ToList();
|
|
||||||
|
|
||||||
if (exits.Count == 0 || entries.Count == 0)
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// For each exit, find the matching entry to form the feature edge span
|
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
|
||||||
// Sort exits and entries by their position along the split line
|
var isNegativeSide = RegionSideOf(region, sl) < 0;
|
||||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
|
||||||
exits = exits.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
|
||||||
entries = entries.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
|
||||||
|
|
||||||
// Pair them up: each exit with the next entry (or vice versa)
|
// Trim any line segments that cross a cutout — cut lines must never
|
||||||
var pairCount = System.Math.Min(exits.Count, entries.Count);
|
// travel through a hole.
|
||||||
for (var i = 0; i < pairCount; i++)
|
featureEdge = TrimFeatureEdgeAgainstCutouts(featureEdge, cutoutPolygons);
|
||||||
|
|
||||||
|
entities.AddRange(featureEdge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subtracts any portions of line entities in <paramref name="featureEdge"/> that
|
||||||
|
/// lie inside any of the supplied cutout polygons. Non-line entities (arcs) are
|
||||||
|
/// passed through unchanged; a tighter fix for arcs in feature edges (weld-gap
|
||||||
|
/// tabs, spike-groove) can be added later if a test demands it.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Entity> TrimFeatureEdgeAgainstCutouts(List<Entity> featureEdge, List<Polygon> cutoutPolygons)
|
||||||
|
{
|
||||||
|
if (cutoutPolygons.Count == 0 || featureEdge.Count == 0)
|
||||||
|
return featureEdge;
|
||||||
|
|
||||||
|
var result = new List<Entity>();
|
||||||
|
foreach (var entity in featureEdge)
|
||||||
|
{
|
||||||
|
if (entity is Line line)
|
||||||
|
result.AddRange(SubtractCutoutsFromLine(line, cutoutPolygons));
|
||||||
|
else
|
||||||
|
result.Add(entity);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the sub-segments of <paramref name="line"/> that lie outside every
|
||||||
|
/// cutout polygon. Handles the common axis-aligned feature-edge case exactly.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Line> SubtractCutoutsFromLine(Line line, List<Polygon> cutoutPolygons)
|
||||||
|
{
|
||||||
|
// Collect parameter values t in [0,1] where the line crosses any cutout edge.
|
||||||
|
var ts = new List<double> { 0.0, 1.0 };
|
||||||
|
foreach (var poly in cutoutPolygons)
|
||||||
|
{
|
||||||
|
var polyLines = poly.ToLines();
|
||||||
|
foreach (var edge in polyLines)
|
||||||
{
|
{
|
||||||
var exitPt = exits[i];
|
if (TryIntersectSegments(line.StartPoint, line.EndPoint, edge.StartPoint, edge.EndPoint, out var t))
|
||||||
var entryPt = entries[i];
|
{
|
||||||
|
if (t > Math.Tolerance.Epsilon && t < 1.0 - Math.Tolerance.Epsilon)
|
||||||
var extentStart = isVertical
|
ts.Add(t);
|
||||||
? System.Math.Min(exitPt.Y, entryPt.Y)
|
}
|
||||||
: System.Math.Min(exitPt.X, entryPt.X);
|
|
||||||
var extentEnd = isVertical
|
|
||||||
? System.Math.Max(exitPt.Y, entryPt.Y)
|
|
||||||
: System.Math.Max(exitPt.X, entryPt.X);
|
|
||||||
|
|
||||||
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
|
|
||||||
|
|
||||||
var isNegativeSide = RegionSideOf(region, sl) < 0;
|
|
||||||
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
|
|
||||||
|
|
||||||
if (featureEdge.Count > 0)
|
|
||||||
featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis);
|
|
||||||
|
|
||||||
entities.AddRange(featureEdge);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis)
|
ts.Sort();
|
||||||
{
|
|
||||||
var featureStart = GetStartPoint(featureEdge[0]);
|
|
||||||
var featureEnd = GetEndPoint(featureEdge[^1]);
|
|
||||||
var isVertical = axis == CutOffAxis.Vertical;
|
|
||||||
|
|
||||||
var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X;
|
var segments = new List<Line>();
|
||||||
var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
|
for (var i = 0; i < ts.Count - 1; i++)
|
||||||
|
|
||||||
if (edgeGoesForward != featureGoesForward)
|
|
||||||
{
|
{
|
||||||
featureEdge = new List<Entity>(featureEdge);
|
var t0 = ts[i];
|
||||||
featureEdge.Reverse();
|
var t1 = ts[i + 1];
|
||||||
foreach (var e in featureEdge)
|
if (t1 - t0 < Math.Tolerance.Epsilon) continue;
|
||||||
e.Reverse();
|
|
||||||
|
var tMid = (t0 + t1) * 0.5;
|
||||||
|
var mid = new Vector(
|
||||||
|
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * tMid,
|
||||||
|
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * tMid);
|
||||||
|
|
||||||
|
var insideCutout = false;
|
||||||
|
foreach (var poly in cutoutPolygons)
|
||||||
|
{
|
||||||
|
if (poly.ContainsPoint(mid))
|
||||||
|
{
|
||||||
|
insideCutout = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (insideCutout) continue;
|
||||||
|
|
||||||
|
var p0 = new Vector(
|
||||||
|
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t0,
|
||||||
|
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t0);
|
||||||
|
var p1 = new Vector(
|
||||||
|
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t1,
|
||||||
|
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t1);
|
||||||
|
|
||||||
|
segments.Add(new Line(p0, p1));
|
||||||
}
|
}
|
||||||
|
|
||||||
return featureEdge;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void EnsurePerimeterWinding(List<Entity> entities)
|
/// <summary>
|
||||||
|
/// Segment-segment intersection. On hit, returns the parameter t along segment AB
|
||||||
|
/// (0 = a0, 1 = a1) via <paramref name="tOnA"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryIntersectSegments(Vector a0, Vector a1, Vector b0, Vector b1, out double tOnA)
|
||||||
{
|
{
|
||||||
var shape = new Shape();
|
tOnA = 0;
|
||||||
shape.Entities.AddRange(entities);
|
var rx = a1.X - a0.X;
|
||||||
var poly = shape.ToPolygon();
|
var ry = a1.Y - a0.Y;
|
||||||
if (poly != null && poly.RotationDirection() != RotationType.CW)
|
var sx = b1.X - b0.X;
|
||||||
shape.Reverse();
|
var sy = b1.Y - b0.Y;
|
||||||
|
|
||||||
entities.Clear();
|
var denom = rx * sy - ry * sx;
|
||||||
entities.AddRange(shape.Entities);
|
if (System.Math.Abs(denom) < Math.Tolerance.Epsilon)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var dx = b0.X - a0.X;
|
||||||
|
var dy = b0.Y - a0.Y;
|
||||||
|
var t = (dx * sy - dy * sx) / denom;
|
||||||
|
var u = (dx * ry - dy * rx) / denom;
|
||||||
|
|
||||||
|
if (t < -Math.Tolerance.Epsilon || t > 1 + Math.Tolerance.Epsilon) return false;
|
||||||
|
if (u < -Math.Tolerance.Epsilon || u > 1 + Math.Tolerance.Epsilon) return false;
|
||||||
|
|
||||||
|
tOnA = t;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsCutoutInRegion(Shape cutout, Box region)
|
private static bool IsCutoutInRegion(Shape cutout, Box region)
|
||||||
{
|
{
|
||||||
if (cutout.Entities.Count == 0) return false;
|
if (cutout.Entities.Count == 0) return false;
|
||||||
var pt = GetStartPoint(cutout.Entities[0]);
|
var bb = cutout.BoundingBox;
|
||||||
return region.Contains(pt);
|
// Fully contained iff the cutout's bounding box fits inside the region.
|
||||||
|
return bb.Left >= region.Left - Math.Tolerance.Epsilon
|
||||||
|
&& bb.Right <= region.Right + Math.Tolerance.Epsilon
|
||||||
|
&& bb.Bottom >= region.Bottom - Math.Tolerance.Epsilon
|
||||||
|
&& bb.Top <= region.Top + Math.Tolerance.Epsilon;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool DoesCutoutCrossSplitLine(Shape cutout, List<SplitLine> splitLines)
|
private static bool DoesCutoutCrossSplitLine(Shape cutout, List<SplitLine> splitLines)
|
||||||
@@ -479,57 +555,135 @@ public static class DrawingSplitter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clip a cutout shape to a region by walking entities, splitting at split line
|
/// Clip a cutout shape to a region by walking entities and splitting at split-line
|
||||||
/// intersections, keeping portions inside the region, and closing gaps with
|
/// crossings. Only returns the cutout-edge fragments that lie inside the region —
|
||||||
/// straight lines. No polygon clipping library needed.
|
/// it deliberately does NOT emit synthetic closing lines at the region boundary.
|
||||||
|
///
|
||||||
|
/// Rationale: a closing line on the region boundary would overlap the split-line
|
||||||
|
/// feature edge and reintroduce a cut through the cutout interior. The feature
|
||||||
|
/// edge (trimmed against cutouts in <see cref="InsertFeatureEdges"/>) and these
|
||||||
|
/// cutout fragments are stitched together later by <see cref="AssemblePieces"/>
|
||||||
|
/// using endpoint connectivity, which produces the correct closed loops — one
|
||||||
|
/// loop per physically-connected strip of material.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
|
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
|
||||||
{
|
{
|
||||||
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
|
|
||||||
|
|
||||||
foreach (var entity in cutout.Entities)
|
foreach (var entity in cutout.Entities)
|
||||||
|
ProcessEntity(entity, region, entities);
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Groups a region's entities into closed components and nests holes inside
|
||||||
|
/// outer loops by point-in-polygon containment. Returns one entity list per
|
||||||
|
/// output <see cref="Drawing"/> — outer loop first, then its contained holes.
|
||||||
|
/// Each outer loop is normalized to CW winding and each hole to CCW.
|
||||||
|
/// </summary>
|
||||||
|
private static List<List<Entity>> AssemblePieces(List<Entity> entities)
|
||||||
|
{
|
||||||
|
var pieces = new List<List<Entity>>();
|
||||||
|
if (entities.Count == 0) return pieces;
|
||||||
|
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
|
if (shapes.Count == 0) return pieces;
|
||||||
|
|
||||||
|
// Polygonize every shape once so we can run containment tests.
|
||||||
|
var polygons = new List<Polygon>(shapes.Count);
|
||||||
|
foreach (var s in shapes)
|
||||||
|
polygons.Add(s.ToPolygon());
|
||||||
|
|
||||||
|
// Classify each shape as outer or hole using nesting by containment.
|
||||||
|
// Shape A is contained in shape B iff A's bounding box is strictly inside
|
||||||
|
// B's bounding box AND a representative vertex of A lies inside B's polygon.
|
||||||
|
// The bbox pre-check avoids the ambiguity of bbox-center tests when two
|
||||||
|
// shapes share a center (e.g., an outer half and a centered cutout).
|
||||||
|
var isHole = new bool[shapes.Count];
|
||||||
|
for (var i = 0; i < shapes.Count; i++)
|
||||||
{
|
{
|
||||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
var bbA = shapes[i].BoundingBox;
|
||||||
|
var repA = FirstVertexOf(shapes[i]);
|
||||||
|
|
||||||
|
for (var j = 0; j < shapes.Count; j++)
|
||||||
|
{
|
||||||
|
if (i == j) continue;
|
||||||
|
if (polygons[j] == null) continue;
|
||||||
|
if (polygons[j].Vertices.Count < 3) continue;
|
||||||
|
|
||||||
|
var bbB = shapes[j].BoundingBox;
|
||||||
|
if (!BoxContainsBox(bbB, bbA)) continue;
|
||||||
|
if (!polygons[j].ContainsPoint(repA)) continue;
|
||||||
|
|
||||||
|
isHole[i] = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entities.Count == 0)
|
// For each outer, attach the holes that fall inside it.
|
||||||
return new List<Entity>();
|
for (var i = 0; i < shapes.Count; i++)
|
||||||
|
|
||||||
// Close gaps with straight lines (connect exit→entry pairs)
|
|
||||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
|
||||||
foreach (var sp in splitPoints)
|
|
||||||
{
|
{
|
||||||
if (!groups.ContainsKey(sp.Line))
|
if (isHole[i]) continue;
|
||||||
groups[sp.Line] = new List<(Vector, bool)>();
|
|
||||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
var outer = shapes[i];
|
||||||
|
var outerPoly = polygons[i];
|
||||||
|
|
||||||
|
// Enforce perimeter winding = CW.
|
||||||
|
if (outerPoly != null && outerPoly.Vertices.Count >= 3
|
||||||
|
&& outerPoly.RotationDirection() != RotationType.CW)
|
||||||
|
outer.Reverse();
|
||||||
|
|
||||||
|
var piece = new List<Entity>();
|
||||||
|
piece.AddRange(outer.Entities);
|
||||||
|
|
||||||
|
for (var j = 0; j < shapes.Count; j++)
|
||||||
|
{
|
||||||
|
if (!isHole[j]) continue;
|
||||||
|
if (polygons[i] == null || polygons[i].Vertices.Count < 3) continue;
|
||||||
|
|
||||||
|
var bbJ = shapes[j].BoundingBox;
|
||||||
|
if (!BoxContainsBox(shapes[i].BoundingBox, bbJ)) continue;
|
||||||
|
|
||||||
|
var rep = FirstVertexOf(shapes[j]);
|
||||||
|
if (!polygons[i].ContainsPoint(rep)) continue;
|
||||||
|
|
||||||
|
var hole = shapes[j];
|
||||||
|
var holePoly = polygons[j];
|
||||||
|
if (holePoly != null && holePoly.Vertices.Count >= 3
|
||||||
|
&& holePoly.RotationDirection() != RotationType.CCW)
|
||||||
|
hole.Reverse();
|
||||||
|
|
||||||
|
piece.AddRange(hole.Entities);
|
||||||
|
}
|
||||||
|
|
||||||
|
pieces.Add(piece);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var kvp in groups)
|
return pieces;
|
||||||
{
|
}
|
||||||
var sl = kvp.Key;
|
|
||||||
var points = kvp.Value;
|
|
||||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
|
||||||
|
|
||||||
var exits = points.Where(p => p.IsExit).Select(p => p.Point)
|
/// <summary>
|
||||||
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
/// Returns the first vertex of a shape (start point of its first entity). Used as
|
||||||
var entries = points.Where(p => !p.IsExit).Select(p => p.Point)
|
/// a representative for containment testing: if bbox pre-check says the whole
|
||||||
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
/// shape is inside another, testing one vertex is sufficient to confirm.
|
||||||
|
/// </summary>
|
||||||
|
private static Vector FirstVertexOf(Shape shape)
|
||||||
|
{
|
||||||
|
if (shape.Entities.Count == 0)
|
||||||
|
return new Vector(0, 0);
|
||||||
|
return GetStartPoint(shape.Entities[0]);
|
||||||
|
}
|
||||||
|
|
||||||
var pairCount = System.Math.Min(exits.Count, entries.Count);
|
/// <summary>
|
||||||
for (var i = 0; i < pairCount; i++)
|
/// True iff box <paramref name="inner"/> is entirely inside box
|
||||||
entities.Add(new Line(exits[i], entries[i]));
|
/// <paramref name="outer"/> (tolerant comparison).
|
||||||
}
|
/// </summary>
|
||||||
|
private static bool BoxContainsBox(Box outer, Box inner)
|
||||||
// Ensure CCW winding for cutouts
|
{
|
||||||
var shape = new Shape();
|
var eps = Math.Tolerance.Epsilon;
|
||||||
shape.Entities.AddRange(entities);
|
return inner.Left >= outer.Left - eps
|
||||||
var poly = shape.ToPolygon();
|
&& inner.Right <= outer.Right + eps
|
||||||
if (poly != null && poly.RotationDirection() != RotationType.CCW)
|
&& inner.Bottom >= outer.Bottom - eps
|
||||||
shape.Reverse();
|
&& inner.Top <= outer.Top + eps;
|
||||||
|
|
||||||
return shape.Entities;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector GetStartPoint(Entity entity)
|
private static Vector GetStartPoint(Entity entity)
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ namespace OpenNest.Engine.BestFit
|
|||||||
if (_cache.TryGetValue(key, out var cached))
|
if (_cache.TryGetValue(key, out var cached))
|
||||||
return cached;
|
return cached;
|
||||||
|
|
||||||
|
// Operate on the canonical frame so cached pair positions are orientation-invariant.
|
||||||
|
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||||
|
|
||||||
IPairEvaluator evaluator = null;
|
IPairEvaluator evaluator = null;
|
||||||
ISlideComputer slideComputer = null;
|
ISlideComputer slideComputer = null;
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
{
|
{
|
||||||
if (CreateEvaluator != null)
|
if (CreateEvaluator != null)
|
||||||
{
|
{
|
||||||
try { evaluator = CreateEvaluator(drawing, spacing); }
|
try { evaluator = CreateEvaluator(canonical, spacing); }
|
||||||
catch { /* fall back to default evaluator */ }
|
catch { /* fall back to default evaluator */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
}
|
}
|
||||||
|
|
||||||
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
|
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
|
||||||
var results = finder.FindBestFits(drawing, spacing, StepSize);
|
var results = finder.FindBestFits(canonical, spacing, StepSize);
|
||||||
|
|
||||||
_cache.TryAdd(key, results);
|
_cache.TryAdd(key, results);
|
||||||
return results;
|
return results;
|
||||||
@@ -86,9 +89,12 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Operate on the canonical frame so cached pair positions are orientation-invariant.
|
||||||
|
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||||
|
|
||||||
if (CreateEvaluator != null)
|
if (CreateEvaluator != null)
|
||||||
{
|
{
|
||||||
try { evaluator = CreateEvaluator(drawing, spacing); }
|
try { evaluator = CreateEvaluator(canonical, spacing); }
|
||||||
catch { /* fall back to default evaluator */ }
|
catch { /* fall back to default evaluator */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +106,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
// Compute candidates and evaluate once with the largest plate.
|
// Compute candidates and evaluate once with the largest plate.
|
||||||
var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer);
|
var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer);
|
||||||
var baseResults = finder.FindBestFits(drawing, spacing, StepSize);
|
var baseResults = finder.FindBestFits(canonical, spacing, StepSize);
|
||||||
|
|
||||||
// Cache a filtered copy for each plate size.
|
// Cache a filtered copy for each plate size.
|
||||||
foreach (var size in needed)
|
foreach (var size in needed)
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ namespace OpenNest.Engine.BestFit
|
|||||||
if (!result.Keep)
|
if (!result.Keep)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight))
|
if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight) ||
|
||||||
|
result.LongestSide > System.Math.Max(MaxPlateWidth, MaxPlateHeight))
|
||||||
{
|
{
|
||||||
result.Keep = false;
|
result.Keep = false;
|
||||||
result.Reason = "Exceeds plate dimensions";
|
result.Reason = "Exceeds plate dimensions";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using OpenNest.Geometry;
|
|||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@@ -49,6 +50,8 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
var allCandidates = candidateBags.SelectMany(c => c).ToList();
|
var allCandidates = candidateBags.SelectMany(c => c).ToList();
|
||||||
|
|
||||||
|
Debug.WriteLine($"[BestFitFinder] {strategies.Count} strategies, {allCandidates.Count} candidates");
|
||||||
|
|
||||||
var results = _evaluator.EvaluateAll(allCandidates);
|
var results = _evaluator.EvaluateAll(allCandidates);
|
||||||
|
|
||||||
_filter.Apply(results);
|
_filter.Apply(results);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace OpenNest.Engine.BestFit
|
namespace OpenNest.Engine.BestFit
|
||||||
{
|
{
|
||||||
@@ -54,6 +57,68 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
return new List<Part> { part1, part2 };
|
return new List<Part> { part1, part2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Part> BuildCanonicalParts()
|
||||||
|
{
|
||||||
|
return NormalizeToCutOrigin(BuildParts(Candidate.Drawing));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Part> BuildSourceParts(Drawing drawing)
|
||||||
|
{
|
||||||
|
var parts = BuildCanonicalParts();
|
||||||
|
var sourceAngle = drawing?.Source?.Angle ?? 0.0;
|
||||||
|
|
||||||
|
for (var i = 0; i < parts.Count; i++)
|
||||||
|
{
|
||||||
|
var p = parts[i];
|
||||||
|
var rebound = Part.CreateAtOrigin(drawing, p.Rotation);
|
||||||
|
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
|
||||||
|
rebound.Offset(delta);
|
||||||
|
rebound.UpdateBounds();
|
||||||
|
parts[i] = rebound;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NormalizeToCutOrigin(CanonicalFrame.FromCanonical(parts, sourceAngle));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Box GetCutBounds(List<Part> parts)
|
||||||
|
{
|
||||||
|
return GetCutBoundingBox(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Part> NormalizeToCutOrigin(List<Part> parts)
|
||||||
|
{
|
||||||
|
if (parts == null || parts.Count == 0)
|
||||||
|
return parts;
|
||||||
|
|
||||||
|
var bounds = GetCutBoundingBox(parts);
|
||||||
|
var offset = new Vector(-bounds.Left, -bounds.Bottom);
|
||||||
|
|
||||||
|
foreach (var part in parts)
|
||||||
|
part.Offset(offset);
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Box GetCutBoundingBox(List<Part> parts)
|
||||||
|
{
|
||||||
|
var entities = new List<IBoundable>();
|
||||||
|
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
var partEntities = ConvertProgram.ToGeometry(part.Program)
|
||||||
|
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var entity in partEntities)
|
||||||
|
{
|
||||||
|
entity.Offset(part.Location);
|
||||||
|
entities.Add(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities.GetBoundingBox();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum BestFitSortField
|
public enum BestFitSortField
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
@@ -17,7 +18,6 @@ namespace OpenNest.Engine.BestFit
|
|||||||
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines);
|
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines);
|
||||||
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
|
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
|
||||||
|
|
||||||
// Pre-filter vertices per unique direction (typically 4 cardinal directions).
|
|
||||||
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
|
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
|
||||||
|
|
||||||
foreach (var offset in offsets)
|
foreach (var offset in offsets)
|
||||||
@@ -43,7 +43,6 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
var minDist = double.MaxValue;
|
var minDist = double.MaxValue;
|
||||||
|
|
||||||
// Case 1: Leading moving vertices → stationary edges
|
|
||||||
for (var v = 0; v < leadingMoving.Length; v++)
|
for (var v = 0; v < leadingMoving.Length; v++)
|
||||||
{
|
{
|
||||||
var vx = leadingMoving[v].X + offset.Dx;
|
var vx = leadingMoving[v].X + offset.Dx;
|
||||||
@@ -66,7 +65,6 @@ namespace OpenNest.Engine.BestFit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 2: Facing stationary vertices → moving edges (opposite direction)
|
|
||||||
for (var v = 0; v < facingStationary.Length; v++)
|
for (var v = 0; v < facingStationary.Length; v++)
|
||||||
{
|
{
|
||||||
var svx = facingStationary[v].X;
|
var svx = facingStationary[v].X;
|
||||||
@@ -95,6 +93,253 @@ namespace OpenNest.Engine.BestFit
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public double[] ComputeDistances(
|
||||||
|
List<Entity> stationaryEntities,
|
||||||
|
List<Entity> movingEntities,
|
||||||
|
SlideOffset[] offsets)
|
||||||
|
{
|
||||||
|
var count = offsets.Length;
|
||||||
|
var results = new double[count];
|
||||||
|
|
||||||
|
var allMovingVerts = ExtractVerticesFromEntities(movingEntities);
|
||||||
|
var allStationaryVerts = ExtractVerticesFromEntities(stationaryEntities);
|
||||||
|
|
||||||
|
var movingCurves = ExtractCurveParams(movingEntities);
|
||||||
|
var stationaryCurves = ExtractCurveParams(stationaryEntities);
|
||||||
|
|
||||||
|
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
|
||||||
|
|
||||||
|
foreach (var offset in offsets)
|
||||||
|
{
|
||||||
|
var key = (offset.DirX, offset.DirY);
|
||||||
|
if (vertexCache.ContainsKey(key))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var leading = FilterVerticesByProjection(allMovingVerts, offset.DirX, offset.DirY, keepHigh: true);
|
||||||
|
var facing = FilterVerticesByProjection(allStationaryVerts, offset.DirX, offset.DirY, keepHigh: false);
|
||||||
|
vertexCache[key] = (leading, facing);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.Threading.Tasks.Parallel.For(0, count, i =>
|
||||||
|
{
|
||||||
|
var offset = offsets[i];
|
||||||
|
var dirX = offset.DirX;
|
||||||
|
var dirY = offset.DirY;
|
||||||
|
var oppX = -dirX;
|
||||||
|
var oppY = -dirY;
|
||||||
|
|
||||||
|
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
|
||||||
|
|
||||||
|
var minDist = double.MaxValue;
|
||||||
|
|
||||||
|
// Case 1: Leading moving vertices → stationary entities
|
||||||
|
for (var v = 0; v < leadingMoving.Length; v++)
|
||||||
|
{
|
||||||
|
var vx = leadingMoving[v].X + offset.Dx;
|
||||||
|
var vy = leadingMoving[v].Y + offset.Dy;
|
||||||
|
|
||||||
|
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||||
|
{
|
||||||
|
var d = RayEntityDistance(vx, vy, stationaryEntities[j], 0, 0, dirX, dirY);
|
||||||
|
|
||||||
|
if (d < minDist)
|
||||||
|
{
|
||||||
|
minDist = d;
|
||||||
|
if (d <= 0) { results[i] = 0; return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: Facing stationary vertices → moving entities (opposite direction)
|
||||||
|
for (var v = 0; v < facingStationary.Length; v++)
|
||||||
|
{
|
||||||
|
var svx = facingStationary[v].X;
|
||||||
|
var svy = facingStationary[v].Y;
|
||||||
|
|
||||||
|
for (var j = 0; j < movingEntities.Count; j++)
|
||||||
|
{
|
||||||
|
var d = RayEntityDistance(svx, svy, movingEntities[j], offset.Dx, offset.Dy, oppX, oppY);
|
||||||
|
|
||||||
|
if (d < minDist)
|
||||||
|
{
|
||||||
|
minDist = d;
|
||||||
|
if (d <= 0) { results[i] = 0; return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Curve-to-curve direct distance.
|
||||||
|
// Vertex sampling misses the true contact between two curved entities
|
||||||
|
// when the approach angle doesn't align with a sampled vertex.
|
||||||
|
for (var m = 0; m < movingCurves.Length; m++)
|
||||||
|
{
|
||||||
|
var mc = movingCurves[m];
|
||||||
|
var mcx = mc.Cx + offset.Dx;
|
||||||
|
var mcy = mc.Cy + offset.Dy;
|
||||||
|
|
||||||
|
for (var s = 0; s < stationaryCurves.Length; s++)
|
||||||
|
{
|
||||||
|
var sc = stationaryCurves[s];
|
||||||
|
var d = SpatialQuery.RayCircleDistance(
|
||||||
|
mcx, mcy, sc.Cx, sc.Cy, mc.Radius + sc.Radius, dirX, dirY);
|
||||||
|
|
||||||
|
if (d >= minDist || d == double.MaxValue)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (mc.Entity is Arc || sc.Entity is Arc)
|
||||||
|
{
|
||||||
|
var mx = mcx + d * dirX;
|
||||||
|
var my = mcy + d * dirY;
|
||||||
|
var toCx = sc.Cx - mx;
|
||||||
|
var toCy = sc.Cy - my;
|
||||||
|
|
||||||
|
if (mc.Entity is Arc mArc)
|
||||||
|
{
|
||||||
|
var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx));
|
||||||
|
if (!Angle.IsBetweenRad(angle, mArc.StartAngle, mArc.EndAngle, mArc.IsReversed))
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sc.Entity is Arc sArc)
|
||||||
|
{
|
||||||
|
var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx));
|
||||||
|
if (!Angle.IsBetweenRad(angle, sArc.StartAngle, sArc.EndAngle, sArc.IsReversed))
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
minDist = d;
|
||||||
|
if (d <= 0) { results[i] = 0; return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results[i] = minDist;
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly struct CurveParams
|
||||||
|
{
|
||||||
|
public readonly Entity Entity;
|
||||||
|
public readonly double Cx, Cy, Radius;
|
||||||
|
|
||||||
|
public CurveParams(Entity entity, double cx, double cy, double radius)
|
||||||
|
{
|
||||||
|
Entity = entity;
|
||||||
|
Cx = cx;
|
||||||
|
Cy = cy;
|
||||||
|
Radius = radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CurveParams[] ExtractCurveParams(List<Entity> entities)
|
||||||
|
{
|
||||||
|
var curves = new List<CurveParams>();
|
||||||
|
for (var i = 0; i < entities.Count; i++)
|
||||||
|
{
|
||||||
|
if (entities[i] is Circle circle)
|
||||||
|
curves.Add(new CurveParams(circle, circle.Center.X, circle.Center.Y, circle.Radius));
|
||||||
|
else if (entities[i] is Arc arc)
|
||||||
|
curves.Add(new CurveParams(arc, arc.Center.X, arc.Center.Y, arc.Radius));
|
||||||
|
}
|
||||||
|
return curves.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double RayEntityDistance(
|
||||||
|
double vx, double vy, Entity entity,
|
||||||
|
double entityOffsetX, double entityOffsetY,
|
||||||
|
double dirX, double dirY)
|
||||||
|
{
|
||||||
|
if (entity is Line line)
|
||||||
|
{
|
||||||
|
return SpatialQuery.RayEdgeDistance(
|
||||||
|
vx, vy,
|
||||||
|
line.StartPoint.X + entityOffsetX, line.StartPoint.Y + entityOffsetY,
|
||||||
|
line.EndPoint.X + entityOffsetX, line.EndPoint.Y + entityOffsetY,
|
||||||
|
dirX, dirY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity is Arc arc)
|
||||||
|
{
|
||||||
|
return SpatialQuery.RayArcDistance(
|
||||||
|
vx, vy,
|
||||||
|
arc.Center.X + entityOffsetX, arc.Center.Y + entityOffsetY,
|
||||||
|
arc.Radius,
|
||||||
|
arc.StartAngle, arc.EndAngle, arc.IsReversed,
|
||||||
|
dirX, dirY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity is Circle circle)
|
||||||
|
{
|
||||||
|
return SpatialQuery.RayCircleDistance(
|
||||||
|
vx, vy,
|
||||||
|
circle.Center.X + entityOffsetX, circle.Center.Y + entityOffsetY,
|
||||||
|
circle.Radius,
|
||||||
|
dirX, dirY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return double.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector[] ExtractVerticesFromEntities(List<Entity> entities)
|
||||||
|
{
|
||||||
|
var vertices = new HashSet<Vector>();
|
||||||
|
|
||||||
|
for (var i = 0; i < entities.Count; i++)
|
||||||
|
{
|
||||||
|
var entity = entities[i];
|
||||||
|
|
||||||
|
if (entity is Line line)
|
||||||
|
{
|
||||||
|
vertices.Add(line.StartPoint);
|
||||||
|
vertices.Add(line.EndPoint);
|
||||||
|
}
|
||||||
|
else if (entity is Arc arc)
|
||||||
|
{
|
||||||
|
vertices.Add(arc.StartPoint());
|
||||||
|
vertices.Add(arc.EndPoint());
|
||||||
|
AddArcExtremes(vertices, arc);
|
||||||
|
}
|
||||||
|
else if (entity is Circle circle)
|
||||||
|
{
|
||||||
|
// Four cardinal points
|
||||||
|
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
|
||||||
|
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
|
||||||
|
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
|
||||||
|
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return vertices.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddArcExtremes(HashSet<Vector> points, Arc arc)
|
||||||
|
{
|
||||||
|
var a1 = arc.StartAngle;
|
||||||
|
var a2 = arc.EndAngle;
|
||||||
|
var reversed = arc.IsReversed;
|
||||||
|
|
||||||
|
if (reversed)
|
||||||
|
Generic.Swap(ref a1, ref a2);
|
||||||
|
|
||||||
|
// Right (0°)
|
||||||
|
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
|
||||||
|
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
|
||||||
|
|
||||||
|
// Top (90°)
|
||||||
|
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
|
||||||
|
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
|
||||||
|
|
||||||
|
// Left (180°)
|
||||||
|
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
|
||||||
|
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
|
||||||
|
|
||||||
|
// Bottom (270°)
|
||||||
|
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
|
||||||
|
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
|
||||||
|
}
|
||||||
|
|
||||||
private static Vector[] ExtractUniqueVertices(List<Line> lines)
|
private static Vector[] ExtractUniqueVertices(List<Line> lines)
|
||||||
{
|
{
|
||||||
var vertices = new HashSet<Vector>();
|
var vertices = new HashSet<Vector>();
|
||||||
@@ -106,11 +351,6 @@ namespace OpenNest.Engine.BestFit
|
|||||||
return vertices.ToArray();
|
return vertices.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Filters vertices by their projection onto the push direction.
|
|
||||||
/// keepHigh=true returns the leading half (front face, closest to target).
|
|
||||||
/// keepHigh=false returns the facing half (side facing the approaching part).
|
|
||||||
/// </summary>
|
|
||||||
private static Vector[] FilterVerticesByProjection(
|
private static Vector[] FilterVerticesByProjection(
|
||||||
Vector[] vertices, double dirX, double dirY, bool keepHigh)
|
Vector[] vertices, double dirX, double dirY, bool keepHigh)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ namespace OpenNest.Engine.BestFit
|
|||||||
flatOffsets, count, directions);
|
flatOffsets, count, directions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public double[] ComputeDistances(
|
||||||
|
List<Entity> stationaryEntities,
|
||||||
|
List<Entity> movingEntities,
|
||||||
|
SlideOffset[] offsets)
|
||||||
|
{
|
||||||
|
// GPU path doesn't support native entities yet — fall back to CPU.
|
||||||
|
var cpu = new CpuDistanceComputer();
|
||||||
|
return cpu.ComputeDistances(stationaryEntities, movingEntities, offsets);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a unit direction vector to a PushDirection int for the GPU interface.
|
/// Maps a unit direction vector to a PushDirection int for the GPU interface.
|
||||||
/// Left=0, Down=1, Right=2, Up=3.
|
/// Left=0, Down=1, Right=2, Up=3.
|
||||||
|
|||||||
@@ -9,5 +9,10 @@ namespace OpenNest.Engine.BestFit
|
|||||||
List<Line> stationaryLines,
|
List<Line> stationaryLines,
|
||||||
List<Line> movingTemplateLines,
|
List<Line> movingTemplateLines,
|
||||||
SlideOffset[] offsets);
|
SlideOffset[] offsets);
|
||||||
|
|
||||||
|
double[] ComputeDistances(
|
||||||
|
List<Entity> stationaryEntities,
|
||||||
|
List<Entity> movingEntities,
|
||||||
|
SlideOffset[] offsets);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
|
||||||
|
|
||||||
namespace OpenNest.Engine.BestFit
|
namespace OpenNest.Engine.BestFit
|
||||||
{
|
{
|
||||||
public class NfpSlideStrategy : IBestFitStrategy
|
public class NfpSlideStrategy : IBestFitStrategy
|
||||||
{
|
{
|
||||||
private static readonly string LogPath = Path.Combine(
|
|
||||||
System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop),
|
|
||||||
"nfp-slide-debug.log");
|
|
||||||
|
|
||||||
private static readonly object LogLock = new object();
|
|
||||||
|
|
||||||
private readonly double _part2Rotation;
|
private readonly double _part2Rotation;
|
||||||
private readonly Polygon _stationaryPerimeter;
|
private readonly Polygon _stationaryPerimeter;
|
||||||
private readonly Polygon _stationaryHull;
|
private readonly Polygon _stationaryHull;
|
||||||
@@ -46,12 +38,6 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
var hull = ConvexHull.Compute(result.Polygon.Vertices);
|
var hull = ConvexHull.Compute(result.Polygon.Vertices);
|
||||||
|
|
||||||
Log($"=== Create: drawing={drawing.Name}, rotation={Angle.ToDegrees(part2Rotation):F1}deg ===");
|
|
||||||
Log($" Perimeter: {result.Polygon.Vertices.Count} verts, bounds={FormatBounds(result.Polygon)}");
|
|
||||||
Log($" Hull: {hull.Vertices.Count} verts, bounds={FormatBounds(hull)}");
|
|
||||||
Log($" Correction: ({result.Correction.X:F4}, {result.Correction.Y:F4})");
|
|
||||||
Log($" ProgramBBox: {drawing.Program.BoundingBox()}");
|
|
||||||
|
|
||||||
return new NfpSlideStrategy(part2Rotation, type, description,
|
return new NfpSlideStrategy(part2Rotation, type, description,
|
||||||
result.Polygon, hull, result.Correction);
|
result.Polygon, hull, result.Correction);
|
||||||
}
|
}
|
||||||
@@ -63,40 +49,17 @@ namespace OpenNest.Engine.BestFit
|
|||||||
if (stepSize <= 0)
|
if (stepSize <= 0)
|
||||||
return candidates;
|
return candidates;
|
||||||
|
|
||||||
Log($"--- GenerateCandidates: drawing={drawing.Name}, part2Rot={Angle.ToDegrees(_part2Rotation):F1}deg, spacing={spacing}, stepSize={stepSize} ---");
|
|
||||||
|
|
||||||
// Orbiting polygon: same shape rotated to Part2's angle.
|
|
||||||
var orbitingPerimeter = PolygonHelper.RotatePolygon(_stationaryPerimeter, _part2Rotation, reNormalize: true);
|
var orbitingPerimeter = PolygonHelper.RotatePolygon(_stationaryPerimeter, _part2Rotation, reNormalize: true);
|
||||||
var orbitingPoly = ConvexHull.Compute(orbitingPerimeter.Vertices);
|
var orbitingPoly = ConvexHull.Compute(orbitingPerimeter.Vertices);
|
||||||
|
|
||||||
Log($" Stationary hull: {_stationaryHull.Vertices.Count} verts, bounds={FormatBounds(_stationaryHull)}");
|
|
||||||
Log($" Orbiting perimeter (rotated): {orbitingPerimeter.Vertices.Count} verts, bounds={FormatBounds(orbitingPerimeter)}");
|
|
||||||
Log($" Orbiting hull: {orbitingPoly.Vertices.Count} verts, bounds={FormatBounds(orbitingPoly)}");
|
|
||||||
|
|
||||||
var nfp = NoFitPolygon.ComputeConvex(_stationaryHull, orbitingPoly);
|
var nfp = NoFitPolygon.ComputeConvex(_stationaryHull, orbitingPoly);
|
||||||
|
|
||||||
if (nfp == null || nfp.Vertices.Count < 3)
|
if (nfp == null || nfp.Vertices.Count < 3)
|
||||||
{
|
|
||||||
Log($" NFP failed or degenerate (verts={nfp?.Vertices.Count ?? 0})");
|
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
|
||||||
|
|
||||||
var verts = nfp.Vertices;
|
var verts = nfp.Vertices;
|
||||||
var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count;
|
var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count;
|
||||||
|
|
||||||
Log($" NFP: {verts.Count} verts (closed={nfp.IsClosed()}, walking {vertCount}), bounds={FormatBounds(nfp)}");
|
|
||||||
Log($" Correction: ({_correction.X:F4}, {_correction.Y:F4})");
|
|
||||||
|
|
||||||
// Log NFP vertices
|
|
||||||
for (var v = 0; v < vertCount; v++)
|
|
||||||
Log($" NFP vert[{v}]: ({verts[v].X:F4}, {verts[v].Y:F4}) -> corrected: ({verts[v].X - _correction.X:F4}, {verts[v].Y - _correction.Y:F4})");
|
|
||||||
|
|
||||||
// Compare with what RotationSlideStrategy would produce
|
|
||||||
var part1 = Part.CreateAtOrigin(drawing);
|
|
||||||
var part2 = Part.CreateAtOrigin(drawing, _part2Rotation);
|
|
||||||
Log($" Part1 (rot=0): loc=({part1.Location.X:F4}, {part1.Location.Y:F4}), bbox={part1.BoundingBox}");
|
|
||||||
Log($" Part2 (rot={Angle.ToDegrees(_part2Rotation):F1}): loc=({part2.Location.X:F4}, {part2.Location.Y:F4}), bbox={part2.BoundingBox}");
|
|
||||||
|
|
||||||
var testNumber = 0;
|
var testNumber = 0;
|
||||||
|
|
||||||
for (var i = 0; i < vertCount; i++)
|
for (var i = 0; i < vertCount; i++)
|
||||||
@@ -125,20 +88,6 @@ namespace OpenNest.Engine.BestFit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log overlap check for vertex candidates (first few)
|
|
||||||
var checkCount = System.Math.Min(vertCount, 8);
|
|
||||||
for (var c = 0; c < checkCount; c++)
|
|
||||||
{
|
|
||||||
var cand = candidates[c];
|
|
||||||
var p2 = Part.CreateAtOrigin(drawing, cand.Part2Rotation);
|
|
||||||
p2.Location = cand.Part2Offset;
|
|
||||||
var overlaps = part1.Intersects(p2, out _);
|
|
||||||
Log($" Candidate[{c}]: offset=({cand.Part2Offset.X:F4}, {cand.Part2Offset.Y:F4}), overlaps={overlaps}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Log($" Total candidates: {candidates.Count}");
|
|
||||||
Log("");
|
|
||||||
|
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,20 +109,5 @@ namespace OpenNest.Engine.BestFit
|
|||||||
Spacing = spacing
|
Spacing = spacing
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatBounds(Polygon polygon)
|
|
||||||
{
|
|
||||||
polygon.UpdateBounds();
|
|
||||||
var bb = polygon.BoundingBox;
|
|
||||||
return $"[({bb.Left:F4}, {bb.Bottom:F4})-({bb.Right:F4}, {bb.Top:F4}), {bb.Width:F2}x{bb.Length:F2}]";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Log(string message)
|
|
||||||
{
|
|
||||||
lock (LogLock)
|
|
||||||
{
|
|
||||||
File.AppendAllText(LogPath, message + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,18 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
|
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
|
||||||
{
|
{
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
return new List<BestFitResult>();
|
||||||
|
|
||||||
|
// Build a perimeter-only drawing once — all candidates share the same drawing.
|
||||||
|
// This avoids cloning the full program (with all cutouts) for every candidate.
|
||||||
|
var perimeterDrawing = CreatePerimeterDrawing(candidates[0].Drawing);
|
||||||
|
|
||||||
var resultBag = new ConcurrentBag<BestFitResult>();
|
var resultBag = new ConcurrentBag<BestFitResult>();
|
||||||
|
|
||||||
Parallel.ForEach(candidates, c =>
|
Parallel.ForEach(candidates, c =>
|
||||||
{
|
{
|
||||||
resultBag.Add(Evaluate(c));
|
resultBag.Add(Evaluate(c, perimeterDrawing));
|
||||||
});
|
});
|
||||||
|
|
||||||
return resultBag.ToList();
|
return resultBag.ToList();
|
||||||
@@ -27,18 +34,24 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
public BestFitResult Evaluate(PairCandidate candidate)
|
public BestFitResult Evaluate(PairCandidate candidate)
|
||||||
{
|
{
|
||||||
var drawing = candidate.Drawing;
|
var perimeterDrawing = CreatePerimeterDrawing(candidate.Drawing);
|
||||||
|
return Evaluate(candidate, perimeterDrawing);
|
||||||
|
}
|
||||||
|
|
||||||
var part1 = Part.CreateAtOrigin(drawing);
|
private BestFitResult Evaluate(PairCandidate candidate, Drawing perimeterDrawing)
|
||||||
|
{
|
||||||
|
var part1 = Part.CreateAtOrigin(perimeterDrawing);
|
||||||
|
|
||||||
var part2 = Part.CreateAtOrigin(drawing, candidate.Part2Rotation);
|
var part2 = Part.CreateAtOrigin(perimeterDrawing, candidate.Part2Rotation);
|
||||||
part2.Location = candidate.Part2Offset;
|
part2.Location = candidate.Part2Offset;
|
||||||
part2.UpdateBounds();
|
part2.UpdateBounds();
|
||||||
|
|
||||||
// Check overlap via shape intersection
|
// Overlap check — perimeter vs perimeter
|
||||||
var overlaps = CheckOverlap(part1, part2);
|
var shape1 = GetPerimeterShape(part1);
|
||||||
|
var shape2 = GetPerimeterShape(part2);
|
||||||
|
var overlaps = shape1 != null && shape2 != null && shape1.Intersects(shape2, out _);
|
||||||
|
|
||||||
// Collect all polygon vertices for convex hull / optimal rotation
|
// Convex hull vertices from perimeter polygons only
|
||||||
var allPoints = GetPartVertices(part1);
|
var allPoints = GetPartVertices(part1);
|
||||||
allPoints.AddRange(GetPartVertices(part2));
|
allPoints.AddRange(GetPartVertices(part2));
|
||||||
|
|
||||||
@@ -66,7 +79,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
hullAngles = new List<double> { 0 };
|
hullAngles = new List<double> { 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
var trueArea = drawing.Area * 2;
|
var trueArea = candidate.Drawing.Area * 2;
|
||||||
|
|
||||||
// Normalize to landscape (width >= height) for consistent display.
|
// Normalize to landscape (width >= height) for consistent display.
|
||||||
if (bestHeight > bestWidth)
|
if (bestHeight > bestWidth)
|
||||||
@@ -91,38 +104,29 @@ namespace OpenNest.Engine.BestFit
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CheckOverlap(Part part1, Part part2)
|
private static Drawing CreatePerimeterDrawing(Drawing source)
|
||||||
{
|
{
|
||||||
var shapes1 = GetPartShapes(part1);
|
var entities = ConvertProgram.ToGeometry(source.Program)
|
||||||
var shapes2 = GetPartShapes(part2);
|
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||||
|
var profile = new ShapeProfile(entities);
|
||||||
for (var i = 0; i < shapes1.Count; i++)
|
var program = ConvertGeometry.ToProgram(profile.Perimeter);
|
||||||
{
|
return new Drawing(source.Name, program);
|
||||||
for (var j = 0; j < shapes2.Count; j++)
|
|
||||||
{
|
|
||||||
List<Vector> pts;
|
|
||||||
|
|
||||||
if (shapes1[i].Intersects(shapes2[j], out pts))
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Shape> GetPartShapes(Part part)
|
private static Shape GetPerimeterShape(Part part)
|
||||||
{
|
{
|
||||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||||
var shapes = ShapeBuilder.GetShapes(entities);
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
shapes.ForEach(s => s.Offset(part.Location));
|
if (shapes.Count == 0) return null;
|
||||||
return shapes;
|
shapes[0].Offset(part.Location);
|
||||||
|
return shapes[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Vector> GetPartVertices(Part part)
|
private static List<Vector> GetPartVertices(Part part)
|
||||||
{
|
{
|
||||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||||
var shapes = ShapeBuilder.GetShapes(entities);
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
var points = new List<Vector>();
|
var points = new List<Vector>();
|
||||||
|
|
||||||
@@ -130,9 +134,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
{
|
{
|
||||||
var polygon = shape.ToPolygonWithTolerance(ChordTolerance);
|
var polygon = shape.ToPolygonWithTolerance(ChordTolerance);
|
||||||
polygon.Offset(part.Location);
|
polygon.Offset(part.Location);
|
||||||
|
points.AddRange(polygon.Vertices);
|
||||||
foreach (var vertex in polygon.Vertices)
|
|
||||||
points.Add(vertex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return points;
|
return points;
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ namespace OpenNest.Engine.BestFit
|
|||||||
var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation);
|
var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation);
|
||||||
|
|
||||||
var halfSpacing = spacing / 2;
|
var halfSpacing = spacing / 2;
|
||||||
var part1Lines = PartGeometry.GetOffsetPartLines(part1, halfSpacing);
|
var part1Entities = PartGeometry.GetOffsetPerimeterEntities(part1, halfSpacing);
|
||||||
var part2TemplateLines = PartGeometry.GetOffsetPartLines(part2Template, halfSpacing);
|
var part2Entities = PartGeometry.GetOffsetPerimeterEntities(part2Template, halfSpacing);
|
||||||
|
|
||||||
var bbox1 = part1.BoundingBox;
|
var bbox1 = part1.BoundingBox;
|
||||||
var bbox2 = part2Template.BoundingBox;
|
var bbox2 = part2Template.BoundingBox;
|
||||||
@@ -48,7 +48,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
return candidates;
|
return candidates;
|
||||||
|
|
||||||
var distances = _distanceComputer.ComputeDistances(
|
var distances = _distanceComputer.ComputeDistances(
|
||||||
part1Lines, part2TemplateLines, offsets);
|
part1Entities, part2Entities, offsets);
|
||||||
|
|
||||||
var testNumber = 0;
|
var testNumber = 0;
|
||||||
|
|
||||||
@@ -89,15 +89,20 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
if (isHorizontalPush)
|
if (isHorizontalPush)
|
||||||
{
|
{
|
||||||
perpMin = -(bbox2.Length + spacing);
|
// Perpendicular sweep along Y → Width; push extent along X → Length
|
||||||
perpMax = bbox1.Length + bbox2.Length + spacing;
|
// Trim to offsets where the parts overlap by at least 50%.
|
||||||
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
|
var halfOverlap = bbox2.Width * 0.5;
|
||||||
|
perpMin = -(halfOverlap - spacing);
|
||||||
|
perpMax = bbox1.Width + halfOverlap + spacing;
|
||||||
|
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
perpMin = -(bbox2.Width + spacing);
|
// Perpendicular sweep along X → Length; push extent along Y → Width
|
||||||
perpMax = bbox1.Width + bbox2.Width + spacing;
|
var halfOverlap = bbox2.Length * 0.5;
|
||||||
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
|
perpMin = -(halfOverlap - spacing);
|
||||||
|
perpMax = bbox1.Length + halfOverlap + spacing;
|
||||||
|
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
|
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Produces transient canonical (MBR-axis-aligned) copies of drawings for engine consumption
|
||||||
|
/// and un-rotates placed parts back to the drawing's original frame.
|
||||||
|
/// </summary>
|
||||||
|
public static class CanonicalFrame
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new Drawing whose Program geometry is rotated to the canonical frame.
|
||||||
|
/// The source drawing is not mutated.
|
||||||
|
/// </summary>
|
||||||
|
public static Drawing AsCanonicalCopy(Drawing drawing)
|
||||||
|
{
|
||||||
|
if (drawing == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var angle = drawing.Source?.Angle ?? 0.0;
|
||||||
|
|
||||||
|
// Clone program (never mutate the source).
|
||||||
|
var pgm = (drawing.Program.Clone() as OpenNest.CNC.Program)
|
||||||
|
?? new OpenNest.CNC.Program();
|
||||||
|
|
||||||
|
if (!Tolerance.IsEqualTo(angle, 0))
|
||||||
|
pgm.Rotate(angle, pgm.BoundingBox().Center);
|
||||||
|
|
||||||
|
var copy = new Drawing(drawing.Name ?? string.Empty, pgm)
|
||||||
|
{
|
||||||
|
Color = drawing.Color,
|
||||||
|
Constraints = drawing.Constraints,
|
||||||
|
Material = drawing.Material,
|
||||||
|
Priority = drawing.Priority,
|
||||||
|
Customer = drawing.Customer,
|
||||||
|
IsCutOff = drawing.IsCutOff,
|
||||||
|
Source = new SourceInfo
|
||||||
|
{
|
||||||
|
Path = drawing.Source?.Path,
|
||||||
|
Offset = drawing.Source?.Offset ?? new Vector(0, 0),
|
||||||
|
Angle = 0.0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes the source drawing's canonical angle onto each placed part so the
|
||||||
|
/// returned list is in the drawing's original (visible) frame.
|
||||||
|
///
|
||||||
|
/// Derivation: let sourceAngle = S (rotation mapping source -> canonical).
|
||||||
|
/// Canonical part at rotation R shows visible orientation R.
|
||||||
|
/// Source part at rotation R' shows visible orientation R' + (-S), because the
|
||||||
|
/// source geometry is already rotated by -S relative to canonical.
|
||||||
|
/// Setting equal gives R' = R + S, so we ADD sourceAngle to each placed part.
|
||||||
|
///
|
||||||
|
/// Rotation is performed around the part's Location so its placement position is preserved;
|
||||||
|
/// only the orientation composes.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Part> FromCanonical(List<Part> placed, double sourceAngle)
|
||||||
|
{
|
||||||
|
if (placed == null || placed.Count == 0)
|
||||||
|
return placed;
|
||||||
|
if (Tolerance.IsEqualTo(sourceAngle, 0))
|
||||||
|
return placed;
|
||||||
|
|
||||||
|
foreach (var p in placed)
|
||||||
|
p.Rotate(sourceAngle, p.Location);
|
||||||
|
|
||||||
|
return placed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,11 @@ namespace OpenNest.CirclePacking
|
|||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Bin FillHorizontal(Item item)
|
private Bin FillHorizontal(Item item) => FillAxis(item, horizontal: true);
|
||||||
|
|
||||||
|
private Bin FillVertical(Item item) => FillAxis(item, horizontal: false);
|
||||||
|
|
||||||
|
private Bin FillAxis(Item item, bool horizontal)
|
||||||
{
|
{
|
||||||
var bin = Bin.Clone() as Bin;
|
var bin = Bin.Clone() as Bin;
|
||||||
|
|
||||||
@@ -35,65 +39,36 @@ namespace OpenNest.CirclePacking
|
|||||||
bin.Right - item.BoundingBox.Right + Tolerance.Epsilon,
|
bin.Right - item.BoundingBox.Right + Tolerance.Epsilon,
|
||||||
bin.Top - item.BoundingBox.Top + Tolerance.Epsilon);
|
bin.Top - item.BoundingBox.Top + Tolerance.Epsilon);
|
||||||
|
|
||||||
var count = System.Math.Floor((bin.Width + Tolerance.Epsilon) / item.Diameter);
|
var primarySize = horizontal ? bin.Width : bin.Length;
|
||||||
|
var count = System.Math.Floor((primarySize + Tolerance.Epsilon) / item.Diameter);
|
||||||
|
|
||||||
if (count == 0)
|
if (count == 0)
|
||||||
return bin;
|
return bin;
|
||||||
|
|
||||||
var xoffset = (bin.Width - item.Diameter) / (count - 1);
|
var primaryOffset = (primarySize - item.Diameter) / (count - 1);
|
||||||
var yoffset = Trigonometry.Height(xoffset * 0.5, item.Diameter);
|
var secondaryOffset = horizontal
|
||||||
|
? Trigonometry.Height(primaryOffset * 0.5, item.Diameter)
|
||||||
|
: Trigonometry.Base(primaryOffset * 0.5, item.Diameter);
|
||||||
|
|
||||||
int row = 0;
|
var outerStart = horizontal ? bin.Y : bin.X;
|
||||||
|
var outerMax = horizontal ? max.Y : max.X;
|
||||||
|
var innerStart = horizontal ? bin.X : bin.Y;
|
||||||
|
var innerMax = horizontal ? max.X : max.Y;
|
||||||
|
|
||||||
for (var y = bin.Y; y <= max.Y; y += yoffset)
|
var stripe = 0;
|
||||||
|
|
||||||
|
for (var outer = outerStart; outer <= outerMax; outer += secondaryOffset)
|
||||||
{
|
{
|
||||||
var x = row.IsOdd() ? bin.X + xoffset * 0.5 : bin.X;
|
var inner = stripe.IsOdd() ? innerStart + primaryOffset * 0.5 : innerStart;
|
||||||
|
|
||||||
for (; x <= max.X; x += xoffset)
|
for (; inner <= innerMax; inner += primaryOffset)
|
||||||
{
|
{
|
||||||
var addedItem = item.Clone() as Item;
|
var addedItem = item.Clone() as Item;
|
||||||
addedItem.Center = new Vector(x, y);
|
addedItem.Center = horizontal ? new Vector(inner, outer) : new Vector(outer, inner);
|
||||||
|
|
||||||
bin.Items.Add(addedItem);
|
bin.Items.Add(addedItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
row++;
|
stripe++;
|
||||||
}
|
|
||||||
|
|
||||||
return bin;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Bin FillVertical(Item item)
|
|
||||||
{
|
|
||||||
var bin = Bin.Clone() as Bin;
|
|
||||||
|
|
||||||
var max = new Vector(
|
|
||||||
Bin.Right - item.BoundingBox.Right + Tolerance.Epsilon,
|
|
||||||
Bin.Top - item.BoundingBox.Top + Tolerance.Epsilon);
|
|
||||||
|
|
||||||
var count = System.Math.Floor((bin.Length + Tolerance.Epsilon) / item.Diameter);
|
|
||||||
|
|
||||||
if (count == 0)
|
|
||||||
return bin;
|
|
||||||
|
|
||||||
var yoffset = (bin.Length - item.Diameter) / (count - 1);
|
|
||||||
var xoffset = Trigonometry.Base(yoffset * 0.5, item.Diameter);
|
|
||||||
|
|
||||||
int column = 0;
|
|
||||||
|
|
||||||
for (var x = bin.X; x <= max.X; x += xoffset)
|
|
||||||
{
|
|
||||||
var y = column.IsOdd() ? bin.Y + yoffset * 0.5 : bin.Y;
|
|
||||||
|
|
||||||
for (; y <= max.Y; y += yoffset)
|
|
||||||
{
|
|
||||||
var addedItem = item.Clone() as Item;
|
|
||||||
addedItem.Center = new Vector(x, y);
|
|
||||||
|
|
||||||
bin.Items.Add(addedItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
column++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return bin;
|
return bin;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.BestFit;
|
||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
using OpenNest.Engine.Strategies;
|
using OpenNest.Engine.Strategies;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
using OpenNest.RectanglePacking;
|
using OpenNest.RectanglePacking;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
@@ -26,9 +29,9 @@ namespace OpenNest
|
|||||||
set => angleBuilder.ForceFullSweep = value;
|
set => angleBuilder.ForceFullSweep = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
|
public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
|
||||||
{
|
{
|
||||||
return angleBuilder.Build(item, bestRotation, workArea);
|
return angleBuilder.Build(item, classification, workArea);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void RecordProductiveAngles(List<AngleResult> angleResults)
|
protected override void RecordProductiveAngles(List<AngleResult> angleResults)
|
||||||
@@ -44,27 +47,66 @@ namespace OpenNest
|
|||||||
PhaseResults.Clear();
|
PhaseResults.Clear();
|
||||||
AngleResults.Clear();
|
AngleResults.Clear();
|
||||||
|
|
||||||
var context = new FillContext
|
// Replace the item's Drawing with a canonical copy for the duration of this fill.
|
||||||
|
// All internal methods see canonical geometry; this wrapper un-canonicalizes the final result.
|
||||||
|
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
|
||||||
|
var originalDrawing = item.Drawing;
|
||||||
|
var canonicalItem = new NestItem
|
||||||
{
|
{
|
||||||
Item = item,
|
Drawing = CanonicalFrame.AsCanonicalCopy(item.Drawing),
|
||||||
WorkArea = workArea,
|
Quantity = item.Quantity,
|
||||||
Plate = Plate,
|
Priority = item.Priority,
|
||||||
PlateNumber = PlateNumber,
|
RotationStart = item.RotationStart,
|
||||||
Token = token,
|
RotationEnd = item.RotationEnd,
|
||||||
Progress = progress,
|
StepAngle = item.StepAngle,
|
||||||
Policy = BuildPolicy(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
RunPipeline(context);
|
// Fast path for qty 1-2.
|
||||||
|
if (canonicalItem.Quantity > 0 && canonicalItem.Quantity <= 2)
|
||||||
|
{
|
||||||
|
var fast = TryFillSmallQuantity(canonicalItem, workArea);
|
||||||
|
if (fast != null && fast.Count >= canonicalItem.Quantity)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={canonicalItem.Quantity}");
|
||||||
|
WinnerPhase = NestPhase.Pairs;
|
||||||
|
fast = RebindAndUnCanonicalize(fast, originalDrawing, sourceAngle);
|
||||||
|
ReportProgress(progress, new ProgressReport
|
||||||
|
{
|
||||||
|
Phase = WinnerPhase,
|
||||||
|
PlateNumber = PlateNumber,
|
||||||
|
Parts = fast,
|
||||||
|
WorkArea = workArea,
|
||||||
|
Description = $"Fast path: {fast.Count} parts",
|
||||||
|
IsOverallBest = true,
|
||||||
|
});
|
||||||
|
return fast;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PhaseResults already synced during RunPipeline.
|
var effectiveWorkArea = workArea;
|
||||||
AngleResults.AddRange(context.AngleResults);
|
if (canonicalItem.Quantity > 0)
|
||||||
WinnerPhase = context.WinnerPhase;
|
{
|
||||||
|
effectiveWorkArea = ShrinkWorkArea(canonicalItem, workArea, Plate.PartSpacing);
|
||||||
|
if (effectiveWorkArea != workArea)
|
||||||
|
Debug.WriteLine($"[Fill] Low-qty shrink: {canonicalItem.Quantity} requested, " +
|
||||||
|
$"from {workArea.Width:F1}x{workArea.Length:F1} " +
|
||||||
|
$"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}");
|
||||||
|
}
|
||||||
|
|
||||||
var best = context.CurrentBest ?? new List<Part>();
|
var best = RunFillPipeline(canonicalItem, effectiveWorkArea, progress, token);
|
||||||
|
|
||||||
if (item.Quantity > 0 && best.Count > item.Quantity)
|
if (canonicalItem.Quantity > 0 && best.Count < canonicalItem.Quantity && effectiveWorkArea != workArea)
|
||||||
best = ShrinkFiller.TrimToCount(best, item.Quantity, TrimAxis);
|
{
|
||||||
|
Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {canonicalItem.Quantity}, retrying full area");
|
||||||
|
PhaseResults.Clear();
|
||||||
|
AngleResults.Clear();
|
||||||
|
best = RunFillPipeline(canonicalItem, workArea, progress, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canonicalItem.Quantity > 0 && best.Count > canonicalItem.Quantity)
|
||||||
|
best = ShrinkFiller.TrimToCount(best, canonicalItem.Quantity, TrimAxis);
|
||||||
|
|
||||||
|
best = RebindAndUnCanonicalize(best, originalDrawing, sourceAngle);
|
||||||
|
|
||||||
ReportProgress(progress, new ProgressReport
|
ReportProgress(progress, new ProgressReport
|
||||||
{
|
{
|
||||||
@@ -79,6 +121,211 @@ namespace OpenNest
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single exit point for canonical -> source frame conversion. Rebinds every Part to the
|
||||||
|
/// original Drawing (so consumers see the user's drawing identity, not the transient canonical copy)
|
||||||
|
/// and composes sourceAngle onto each Part's rotation via CanonicalFrame.FromCanonical.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Part> RebindAndUnCanonicalize(List<Part> parts, Drawing original, double sourceAngle)
|
||||||
|
{
|
||||||
|
if (parts == null || parts.Count == 0)
|
||||||
|
return parts;
|
||||||
|
|
||||||
|
for (var i = 0; i < parts.Count; i++)
|
||||||
|
{
|
||||||
|
var p = parts[i];
|
||||||
|
// Rebind to `original` while preserving world pose. CreateAtOrigin rotates
|
||||||
|
// at the origin (keeping bbox at world (0,0)) then we offset to match p's bbox.
|
||||||
|
var rebound = Part.CreateAtOrigin(original, p.Rotation);
|
||||||
|
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
|
||||||
|
rebound.Offset(delta);
|
||||||
|
rebound.UpdateBounds();
|
||||||
|
parts[i] = rebound;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CanonicalFrame.FromCanonical(parts, sourceAngle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fast path for qty 1-2: place a single part or a best-fit pair
|
||||||
|
/// without running the full strategy pipeline.
|
||||||
|
/// </summary>
|
||||||
|
private List<Part> TryFillSmallQuantity(NestItem item, Box workArea)
|
||||||
|
{
|
||||||
|
if (item.Quantity == 1)
|
||||||
|
return TryPlaceSingle(item.Drawing, workArea);
|
||||||
|
|
||||||
|
if (item.Quantity == 2)
|
||||||
|
return TryPlaceBestFitPair(item.Drawing, workArea);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Part> TryPlaceSingle(Drawing drawing, Box workArea)
|
||||||
|
{
|
||||||
|
var part = Part.CreateAtOrigin(drawing);
|
||||||
|
if (part.BoundingBox.Width > workArea.Width + Tolerance.Epsilon ||
|
||||||
|
part.BoundingBox.Length > workArea.Length + Tolerance.Epsilon)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
part.Offset(workArea.Location - part.BoundingBox.Location);
|
||||||
|
return new List<Part> { part };
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Part> TryPlaceBestFitPair(Drawing drawing, Box workArea)
|
||||||
|
{
|
||||||
|
var bestFits = BestFitCache.GetOrCompute(
|
||||||
|
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
|
||||||
|
|
||||||
|
// Build pair candidates with a canonical drawing so their geometry matches
|
||||||
|
// the coordinate frame of the cached fit results.
|
||||||
|
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||||
|
|
||||||
|
List<Part> bestPlacement = null;
|
||||||
|
|
||||||
|
foreach (var fit in bestFits)
|
||||||
|
{
|
||||||
|
if (!fit.Keep)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Skip pairs that can't possibly fit the work area in either orientation.
|
||||||
|
if (fit.ShortestSide > System.Math.Min(workArea.Width, workArea.Length) + Tolerance.Epsilon)
|
||||||
|
continue;
|
||||||
|
if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var landscape = fit.BuildParts(canonicalDrawing);
|
||||||
|
var portrait = RotatePair90(landscape);
|
||||||
|
|
||||||
|
var lFits = TryOffsetToWorkArea(landscape, workArea);
|
||||||
|
var pFits = TryOffsetToWorkArea(portrait, workArea);
|
||||||
|
|
||||||
|
// Pick the better orientation for this pair.
|
||||||
|
List<Part> candidate = null;
|
||||||
|
if (lFits && pFits)
|
||||||
|
candidate = IsBetterFill(portrait, landscape, workArea) ? portrait : landscape;
|
||||||
|
else if (lFits)
|
||||||
|
candidate = landscape;
|
||||||
|
else if (pFits)
|
||||||
|
candidate = portrait;
|
||||||
|
|
||||||
|
if (candidate == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (bestPlacement == null || IsBetterFill(candidate, bestPlacement, workArea))
|
||||||
|
bestPlacement = candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parts are returned in canonical frame, bound to the canonical drawing.
|
||||||
|
// The outer Fill wrapper (Task 7) rebinds to `drawing` and composes sourceAngle onto rotation.
|
||||||
|
return bestPlacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Part> RotatePair90(List<Part> parts)
|
||||||
|
{
|
||||||
|
var rotated = new List<Part>(parts.Count);
|
||||||
|
foreach (var p in parts)
|
||||||
|
rotated.Add((Part)p.Clone());
|
||||||
|
|
||||||
|
var bbox = ((IEnumerable<IBoundable>)rotated).GetBoundingBox();
|
||||||
|
var center = bbox.Center;
|
||||||
|
|
||||||
|
foreach (var p in rotated)
|
||||||
|
p.Rotate(-Angle.HalfPI, center);
|
||||||
|
|
||||||
|
var newBbox = ((IEnumerable<IBoundable>)rotated).GetBoundingBox();
|
||||||
|
var offset = new Vector(-newBbox.Left, -newBbox.Bottom);
|
||||||
|
foreach (var p in rotated)
|
||||||
|
{
|
||||||
|
p.Offset(offset);
|
||||||
|
p.UpdateBounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rotated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryOffsetToWorkArea(List<Part> parts, Box workArea)
|
||||||
|
{
|
||||||
|
var bbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
||||||
|
if (bbox.Width > workArea.Width + Tolerance.Epsilon ||
|
||||||
|
bbox.Length > workArea.Length + Tolerance.Epsilon)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var offset = workArea.Location - bbox.Location;
|
||||||
|
foreach (var p in parts)
|
||||||
|
{
|
||||||
|
p.Offset(offset);
|
||||||
|
p.UpdateBounds();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shrinks the work area in both dimensions proportionally when the
|
||||||
|
/// requested quantity is much less than the plate capacity.
|
||||||
|
/// </summary>
|
||||||
|
private static Box ShrinkWorkArea(NestItem item, Box workArea, double spacing)
|
||||||
|
{
|
||||||
|
var bbox = item.Drawing.Program.BoundingBox();
|
||||||
|
if (bbox.Width <= 0 || bbox.Length <= 0)
|
||||||
|
return workArea;
|
||||||
|
|
||||||
|
var bin = new Bin { Size = new Size(workArea.Width, workArea.Length) };
|
||||||
|
var packItem = new Item { Size = new Size(bbox.Width + spacing, bbox.Length + spacing) };
|
||||||
|
var packer = new FillBestFit(bin);
|
||||||
|
packer.Fill(packItem);
|
||||||
|
var fullCount = bin.Items.Count;
|
||||||
|
|
||||||
|
if (fullCount <= 0 || fullCount <= item.Quantity)
|
||||||
|
return workArea;
|
||||||
|
|
||||||
|
// Scale both dimensions by sqrt(ratio) so the area shrinks
|
||||||
|
// proportionally. 2x margin gives strategies room to optimize.
|
||||||
|
var ratio = (double)item.Quantity / fullCount;
|
||||||
|
var scale = System.Math.Sqrt(ratio) * 2.0;
|
||||||
|
|
||||||
|
var newWidth = workArea.Width * scale;
|
||||||
|
var newLength = workArea.Length * scale;
|
||||||
|
|
||||||
|
// Ensure at least one part fits.
|
||||||
|
var minWidth = bbox.Width + spacing * 2;
|
||||||
|
var minLength = bbox.Length + spacing * 2;
|
||||||
|
newWidth = System.Math.Max(newWidth, minWidth);
|
||||||
|
newLength = System.Math.Max(newLength, minLength);
|
||||||
|
|
||||||
|
// Clamp to original dimensions.
|
||||||
|
newWidth = System.Math.Min(newWidth, workArea.Width);
|
||||||
|
newLength = System.Math.Min(newLength, workArea.Length);
|
||||||
|
|
||||||
|
if (newWidth >= workArea.Width && newLength >= workArea.Length)
|
||||||
|
return workArea;
|
||||||
|
|
||||||
|
return new Box(workArea.X, workArea.Y, newLength, newWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Part> RunFillPipeline(NestItem item, Box workArea,
|
||||||
|
IProgress<NestProgress> progress, CancellationToken token)
|
||||||
|
{
|
||||||
|
var context = new FillContext
|
||||||
|
{
|
||||||
|
Item = item,
|
||||||
|
WorkArea = workArea,
|
||||||
|
Plate = Plate,
|
||||||
|
PlateNumber = PlateNumber,
|
||||||
|
Token = token,
|
||||||
|
Progress = progress,
|
||||||
|
Policy = BuildPolicy(),
|
||||||
|
MaxQuantity = item.Quantity,
|
||||||
|
};
|
||||||
|
|
||||||
|
RunPipeline(context);
|
||||||
|
|
||||||
|
AngleResults.AddRange(context.AngleResults);
|
||||||
|
WinnerPhase = context.WinnerPhase;
|
||||||
|
|
||||||
|
return context.CurrentBest ?? new List<Part>();
|
||||||
|
}
|
||||||
|
|
||||||
public override List<Part> Fill(List<Part> groupParts, Box workArea,
|
public override List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||||
IProgress<NestProgress> progress, CancellationToken token)
|
IProgress<NestProgress> progress, CancellationToken token)
|
||||||
{
|
{
|
||||||
@@ -94,7 +341,7 @@ namespace OpenNest
|
|||||||
|
|
||||||
// Multi-part group: linear pattern fill only.
|
// Multi-part group: linear pattern fill only.
|
||||||
PhaseResults.Clear();
|
PhaseResults.Clear();
|
||||||
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
var engine = new FillLinear(workArea, Plate.PartSpacing) { Label = "GroupPattern" };
|
||||||
var angles = RotationAnalysis.FindHullEdgeAngles(groupParts);
|
var angles = RotationAnalysis.FindHullEdgeAngles(groupParts);
|
||||||
var best = FillHelpers.FillPattern(engine, groupParts, angles, workArea, Comparer);
|
var best = FillHelpers.FillPattern(engine, groupParts, angles, workArea, Comparer);
|
||||||
PhaseResults.Add(new PhaseResult(NestPhase.Linear, best?.Count ?? 0, 0));
|
PhaseResults.Add(new PhaseResult(NestPhase.Linear, best?.Count ?? 0, 0));
|
||||||
@@ -132,10 +379,12 @@ namespace OpenNest
|
|||||||
|
|
||||||
protected virtual void RunPipeline(FillContext context)
|
protected virtual void RunPipeline(FillContext context)
|
||||||
{
|
{
|
||||||
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
|
var classification = PartClassifier.Classify(context.Item.Drawing);
|
||||||
context.SharedState["BestRotation"] = bestRotation;
|
context.PartType = classification.Type;
|
||||||
|
context.SharedState["BestRotation"] = classification.PrimaryAngle;
|
||||||
|
context.SharedState["Classification"] = classification;
|
||||||
|
|
||||||
var angles = BuildAngles(context.Item, bestRotation, context.WorkArea);
|
var angles = BuildAngles(context.Item, classification, context.WorkArea);
|
||||||
context.SharedState["AngleCandidates"] = angles;
|
context.SharedState["AngleCandidates"] = angles;
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -143,6 +392,7 @@ namespace OpenNest
|
|||||||
foreach (var strategy in FillStrategyRegistry.Strategies)
|
foreach (var strategy in FillStrategyRegistry.Strategies)
|
||||||
{
|
{
|
||||||
context.Token.ThrowIfCancellationRequested();
|
context.Token.ThrowIfCancellationRequested();
|
||||||
|
context.ActivePhase = strategy.Phase;
|
||||||
|
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
var result = strategy.Fill(context);
|
var result = strategy.Fill(context);
|
||||||
@@ -156,14 +406,18 @@ namespace OpenNest
|
|||||||
// during progress reporting.
|
// during progress reporting.
|
||||||
PhaseResults.Add(phaseResult);
|
PhaseResults.Add(phaseResult);
|
||||||
|
|
||||||
if (context.Policy.Comparer.IsBetter(result, context.CurrentBest, context.WorkArea))
|
// FillContext.ReportProgress updates CurrentBest during the
|
||||||
|
// strategy's angle sweep. This catches strategies that return a
|
||||||
|
// result without reporting it (e.g. RectBestFit).
|
||||||
|
var improved = context.Policy.Comparer.IsBetter(result, context.CurrentBest, context.WorkArea);
|
||||||
|
if (improved)
|
||||||
{
|
{
|
||||||
context.CurrentBest = result;
|
context.CurrentBest = result;
|
||||||
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
|
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
|
||||||
context.WinnerPhase = strategy.Phase;
|
context.WinnerPhase = strategy.Phase;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.CurrentBest != null && context.CurrentBest.Count > 0)
|
if (improved && context.CurrentBest != null && context.CurrentBest.Count > 0)
|
||||||
{
|
{
|
||||||
ReportProgress(context.Progress, new ProgressReport
|
ReportProgress(context.Progress, new ProgressReport
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,31 +7,68 @@ using System.Linq;
|
|||||||
|
|
||||||
namespace OpenNest.Engine.Fill
|
namespace OpenNest.Engine.Fill
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Builds candidate rotation angles for single-item fill. Encapsulates the
|
|
||||||
/// full pipeline: base angles, narrow-area sweep, ML prediction, and
|
|
||||||
/// known-good pruning across fills.
|
|
||||||
/// </summary>
|
|
||||||
public class AngleCandidateBuilder
|
public class AngleCandidateBuilder
|
||||||
{
|
{
|
||||||
private readonly HashSet<double> knownGoodAngles = new();
|
private readonly HashSet<double> knownGoodAngles = new();
|
||||||
|
|
||||||
public bool ForceFullSweep { get; set; }
|
public bool ForceFullSweep { get; set; }
|
||||||
|
|
||||||
public List<double> Build(NestItem item, double bestRotation, Box workArea)
|
public List<double> Build(NestItem item, ClassificationResult classification, Box workArea)
|
||||||
{
|
{
|
||||||
var baseAngles = new[] { bestRotation, bestRotation + Angle.HalfPI };
|
// User constraints always take precedence over classification.
|
||||||
|
if (HasExplicitConstraints(item))
|
||||||
|
return BuildFromConstraints(item);
|
||||||
|
|
||||||
|
switch (classification.Type)
|
||||||
|
{
|
||||||
|
case PartType.Circle:
|
||||||
|
return new List<double> { 0 };
|
||||||
|
|
||||||
|
case PartType.Rectangle:
|
||||||
|
return new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return BuildIrregularAngles(item, classification.PrimaryAngle, workArea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasExplicitConstraints(NestItem item)
|
||||||
|
{
|
||||||
|
// Default NestConstraints: Start=0, End=0. Both zero = no constraints.
|
||||||
|
return !(item.RotationStart.IsEqualTo(0) && item.RotationEnd.IsEqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<double> BuildFromConstraints(NestItem item)
|
||||||
|
{
|
||||||
|
var angles = new List<double>();
|
||||||
|
var step = item.StepAngle > Tolerance.Epsilon ? item.StepAngle : Angle.ToRadians(5);
|
||||||
|
|
||||||
|
for (var a = item.RotationStart; a <= item.RotationEnd + Tolerance.Epsilon; a += step)
|
||||||
|
{
|
||||||
|
if (!ContainsAngle(angles, a))
|
||||||
|
angles.Add(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (angles.Count == 0)
|
||||||
|
angles.Add(item.RotationStart);
|
||||||
|
|
||||||
|
return angles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<double> BuildIrregularAngles(NestItem item, double primaryAngle, Box workArea)
|
||||||
|
{
|
||||||
|
var baseAngles = new[] { primaryAngle, primaryAngle + Angle.HalfPI };
|
||||||
|
|
||||||
if (knownGoodAngles.Count > 0 && !ForceFullSweep)
|
if (knownGoodAngles.Count > 0 && !ForceFullSweep)
|
||||||
return BuildPrunedList(baseAngles);
|
return BuildPrunedList(baseAngles);
|
||||||
|
|
||||||
var angles = new List<double>(baseAngles);
|
var angles = new List<double>(baseAngles);
|
||||||
|
|
||||||
if (ForceFullSweep)
|
// Full 5-degree sweep for irregular parts.
|
||||||
AddSweepAngles(angles);
|
AddSweepAngles(angles);
|
||||||
|
|
||||||
if (!ForceFullSweep && angles.Count > 2)
|
// ML prediction complements the sweep when available.
|
||||||
angles = ApplyMlPrediction(item, workArea, baseAngles, angles);
|
angles = ApplyMlPrediction(item, workArea, baseAngles, angles);
|
||||||
|
|
||||||
return angles;
|
return angles;
|
||||||
}
|
}
|
||||||
@@ -64,7 +101,14 @@ namespace OpenNest.Engine.Fill
|
|||||||
mlAngles.Add(b);
|
mlAngles.Add(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug.WriteLine($"[AngleCandidateBuilder] ML: {fallback.Count} angles -> {mlAngles.Count} predicted");
|
// Merge ML angles into the existing sweep so both contribute.
|
||||||
|
foreach (var a in fallback)
|
||||||
|
{
|
||||||
|
if (!ContainsAngle(mlAngles, a))
|
||||||
|
mlAngles.Add(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.WriteLine($"[AngleCandidateBuilder] ML: {fallback.Count} sweep + {predicted.Count} predicted = {mlAngles.Count} total");
|
||||||
return mlAngles;
|
return mlAngles;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,10 +130,6 @@ namespace OpenNest.Engine.Fill
|
|||||||
return angles.Any(existing => existing.IsEqualTo(angle));
|
return angles.Any(existing => existing.IsEqualTo(angle));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Records angles that produced results. These are used to prune
|
|
||||||
/// subsequent Build() calls.
|
|
||||||
/// </summary>
|
|
||||||
public void RecordProductive(List<AngleResult> angleResults)
|
public void RecordProductive(List<AngleResult> angleResults)
|
||||||
{
|
{
|
||||||
foreach (var ar in angleResults)
|
foreach (var ar in angleResults)
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
namespace OpenNest
|
namespace OpenNest
|
||||||
{
|
{
|
||||||
|
internal record CombinationResult(bool Found, int Count1, int Count2);
|
||||||
|
|
||||||
internal static class BestCombination
|
internal static class BestCombination
|
||||||
{
|
{
|
||||||
public static bool FindFrom2(double length1, double length2, double overallLength, out int count1, out int count2)
|
public static CombinationResult FindFrom2(double length1, double length2, double overallLength)
|
||||||
{
|
{
|
||||||
overallLength += Tolerance.Epsilon;
|
overallLength += Tolerance.Epsilon;
|
||||||
count1 = 0;
|
var count1 = 0;
|
||||||
count2 = 0;
|
var count2 = 0;
|
||||||
|
|
||||||
var maxCount1 = (int)System.Math.Floor(overallLength / length1);
|
var maxCount1 = (int)System.Math.Floor(overallLength / length1);
|
||||||
var bestRemnant = overallLength + 1;
|
var bestRemnant = overallLength + 1;
|
||||||
@@ -30,7 +32,7 @@ namespace OpenNest
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return count1 > 0 || count2 > 0;
|
return new CombinationResult(count1 > 0 || count2 > 0, count1, count2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.Engine.Fill
|
namespace OpenNest.Engine.Fill
|
||||||
{
|
{
|
||||||
@@ -11,12 +12,10 @@ namespace OpenNest.Engine.Fill
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Compactor
|
public static class Compactor
|
||||||
{
|
{
|
||||||
private const double ChordTolerance = 0.001;
|
|
||||||
|
|
||||||
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
|
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
|
||||||
{
|
{
|
||||||
var obstacleParts = plate.Parts
|
var obstacleParts = plate.Parts
|
||||||
.Where(p => !movingParts.Contains(p))
|
.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
||||||
@@ -28,7 +27,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
public static double Push(List<Part> movingParts, Plate plate, double angle)
|
public static double Push(List<Part> movingParts, Plate plate, double angle)
|
||||||
{
|
{
|
||||||
var obstacleParts = plate.Parts
|
var obstacleParts = plate.Parts
|
||||||
.Where(p => !movingParts.Contains(p))
|
.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
|
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
|
||||||
@@ -44,7 +43,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
var opposite = -direction;
|
var opposite = -direction;
|
||||||
|
|
||||||
var obstacleBoxes = new Box[obstacleParts.Count];
|
var obstacleBoxes = new Box[obstacleParts.Count];
|
||||||
var obstacleLines = new List<Line>[obstacleParts.Count];
|
var obstacleEntities = new List<Entity>[obstacleParts.Count];
|
||||||
|
|
||||||
for (var i = 0; i < obstacleParts.Count; i++)
|
for (var i = 0; i < obstacleParts.Count; i++)
|
||||||
obstacleBoxes[i] = obstacleParts[i].BoundingBox;
|
obstacleBoxes[i] = obstacleParts[i].BoundingBox;
|
||||||
@@ -61,7 +60,19 @@ namespace OpenNest.Engine.Fill
|
|||||||
distance = edgeDist;
|
distance = edgeDist;
|
||||||
|
|
||||||
var movingBox = moving.BoundingBox;
|
var movingBox = moving.BoundingBox;
|
||||||
List<Line> movingLines = null;
|
List<Entity> movingEntities = null;
|
||||||
|
|
||||||
|
// Check if any obstacle is inside the moving part — only then
|
||||||
|
// do we need cutout entities on the moving part.
|
||||||
|
var needCutouts = false;
|
||||||
|
for (var i = 0; i < obstacleBoxes.Length; i++)
|
||||||
|
{
|
||||||
|
if (movingBox.Contains(obstacleBoxes[i]))
|
||||||
|
{
|
||||||
|
needCutouts = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = 0; i < obstacleBoxes.Length; i++)
|
for (var i = 0; i < obstacleBoxes.Length; i++)
|
||||||
{
|
{
|
||||||
@@ -76,15 +87,26 @@ namespace OpenNest.Engine.Fill
|
|||||||
if (!SpatialQuery.PerpendicularOverlap(movingBox, obstacleBoxes[i], direction))
|
if (!SpatialQuery.PerpendicularOverlap(movingBox, obstacleBoxes[i], direction))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
movingLines ??= halfSpacing > 0
|
movingEntities ??= halfSpacing > 0
|
||||||
? PartGeometry.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance)
|
? (needCutouts
|
||||||
: PartGeometry.GetPartLines(moving, direction, ChordTolerance);
|
? PartGeometry.GetOffsetPartEntities(moving, halfSpacing)
|
||||||
|
: PartGeometry.GetOffsetPerimeterEntities(moving, halfSpacing))
|
||||||
|
: (needCutouts
|
||||||
|
? PartGeometry.GetPartEntities(moving)
|
||||||
|
: PartGeometry.GetPerimeterEntities(moving));
|
||||||
|
|
||||||
obstacleLines[i] ??= halfSpacing > 0
|
obstacleEntities[i] ??= halfSpacing > 0
|
||||||
? PartGeometry.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance)
|
? PartGeometry.GetOffsetPerimeterEntities(obstacleParts[i], halfSpacing)
|
||||||
: PartGeometry.GetPartLines(obstacleParts[i], opposite, ChordTolerance);
|
: PartGeometry.GetPerimeterEntities(obstacleParts[i]);
|
||||||
|
|
||||||
|
var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction);
|
||||||
|
if (d <= Tolerance.Epsilon
|
||||||
|
&& partSpacing <= Tolerance.Epsilon
|
||||||
|
&& CanNudgeWithoutOverlap(moving, obstacleParts[i], direction))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var d = SpatialQuery.DirectionalDistance(movingLines, obstacleLines[i], direction);
|
|
||||||
if (d < distance)
|
if (d < distance)
|
||||||
distance = d;
|
distance = d;
|
||||||
}
|
}
|
||||||
@@ -101,6 +123,31 @@ namespace OpenNest.Engine.Fill
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IntersectsAny(Part candidate, List<Part> parts)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < parts.Count; i++)
|
||||||
|
{
|
||||||
|
if (candidate.Intersects(parts[i], out _))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanNudgeWithoutOverlap(Part moving, Part obstacle, Vector direction)
|
||||||
|
{
|
||||||
|
var nudge = direction * (Tolerance.Epsilon * 10);
|
||||||
|
|
||||||
|
moving.Offset(nudge);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return !moving.Intersects(obstacle, out _);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
moving.Offset(-nudge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static double Push(List<Part> movingParts, List<Part> obstacleParts,
|
public static double Push(List<Part> movingParts, List<Part> obstacleParts,
|
||||||
Box workArea, double partSpacing, PushDirection direction)
|
Box workArea, double partSpacing, PushDirection direction)
|
||||||
{
|
{
|
||||||
@@ -116,7 +163,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
public static double PushBoundingBox(List<Part> movingParts, Plate plate, PushDirection direction)
|
public static double PushBoundingBox(List<Part> movingParts, Plate plate, PushDirection direction)
|
||||||
{
|
{
|
||||||
var obstacleParts = plate.Parts
|
var obstacleParts = plate.Parts
|
||||||
.Where(p => !movingParts.Contains(p))
|
.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return PushBoundingBox(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
return PushBoundingBox(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
||||||
@@ -157,7 +204,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
|
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
|
||||||
var d = gap - partSpacing - 2 * ChordTolerance;
|
var d = gap - partSpacing - 0.002;
|
||||||
if (d < 0) d = 0;
|
if (d < 0) d = 0;
|
||||||
if (d < distance)
|
if (d < distance)
|
||||||
distance = d;
|
distance = d;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
using OpenNest.Engine.Strategies;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
|
||||||
namespace OpenNest.Engine.Fill
|
namespace OpenNest.Engine.Fill
|
||||||
@@ -24,27 +24,18 @@ namespace OpenNest.Engine.Fill
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<Part> Fill(Drawing drawing, double rotationAngle = 0,
|
public List<Part> Fill(Drawing drawing, double rotationAngle = 0,
|
||||||
int plateNumber = 0,
|
|
||||||
CancellationToken token = default,
|
CancellationToken token = default,
|
||||||
IProgress<NestProgress> progress = null,
|
Action<List<Part>, string> reportProgress = null)
|
||||||
List<Engine.BestFit.BestFitResult> bestFits = null)
|
|
||||||
{
|
{
|
||||||
var pair = BuildPair(drawing, rotationAngle);
|
var pair = BuildPair(drawing, rotationAngle);
|
||||||
if (pair == null)
|
if (pair == null)
|
||||||
return new List<Part>();
|
return new List<Part>();
|
||||||
|
|
||||||
var column = BuildColumn(pair.Value.part1, pair.Value.part2, pair.Value.pairBbox);
|
var column = BuildColumn(pair.Value);
|
||||||
if (column.Count == 0)
|
if (column.Count == 0)
|
||||||
return new List<Part>();
|
return new List<Part>();
|
||||||
|
|
||||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
reportProgress?.Invoke(column, $"Extents: initial column {column.Count} parts");
|
||||||
{
|
|
||||||
Phase = NestPhase.Extents,
|
|
||||||
PlateNumber = plateNumber,
|
|
||||||
Parts = column,
|
|
||||||
WorkArea = workArea,
|
|
||||||
Description = $"Extents: initial column {column.Count} parts",
|
|
||||||
});
|
|
||||||
|
|
||||||
var adjusted = AdjustColumn(pair.Value, column, token);
|
var adjusted = AdjustColumn(pair.Value, column, token);
|
||||||
|
|
||||||
@@ -56,32 +47,18 @@ namespace OpenNest.Engine.Fill
|
|||||||
adjusted = column;
|
adjusted = column;
|
||||||
}
|
}
|
||||||
|
|
||||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
reportProgress?.Invoke(adjusted, $"Extents: column {adjusted.Count} parts");
|
||||||
{
|
|
||||||
Phase = NestPhase.Extents,
|
|
||||||
PlateNumber = plateNumber,
|
|
||||||
Parts = adjusted,
|
|
||||||
WorkArea = workArea,
|
|
||||||
Description = $"Extents: column {adjusted.Count} parts",
|
|
||||||
});
|
|
||||||
|
|
||||||
var result = RepeatColumns(adjusted, token);
|
var result = RepeatColumns(adjusted, token);
|
||||||
|
|
||||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
reportProgress?.Invoke(result, $"Extents: {result.Count} parts total");
|
||||||
{
|
|
||||||
Phase = NestPhase.Extents,
|
|
||||||
PlateNumber = plateNumber,
|
|
||||||
Parts = result,
|
|
||||||
WorkArea = workArea,
|
|
||||||
Description = $"Extents: {result.Count} parts total",
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Step 1: Pair Construction ---
|
// --- Step 1: Pair Construction ---
|
||||||
|
|
||||||
private (Part part1, Part part2, Box pairBbox)? BuildPair(Drawing drawing, double rotationAngle)
|
private PartPair? BuildPair(Drawing drawing, double rotationAngle)
|
||||||
{
|
{
|
||||||
var part1 = Part.CreateAtOrigin(drawing, rotationAngle);
|
var part1 = Part.CreateAtOrigin(drawing, rotationAngle);
|
||||||
var part2 = Part.CreateAtOrigin(drawing, rotationAngle + System.Math.PI);
|
var part2 = Part.CreateAtOrigin(drawing, rotationAngle + System.Math.PI);
|
||||||
@@ -96,7 +73,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
var boundary2 = new PartBoundary(part2, halfSpacing);
|
var boundary2 = new PartBoundary(part2, halfSpacing);
|
||||||
|
|
||||||
// Position part2 to the right of part1 at bounding box width distance.
|
// Position part2 to the right of part1 at bounding box width distance.
|
||||||
var startOffset = part1.BoundingBox.Width + part2.BoundingBox.Width + partSpacing;
|
var startOffset = part1.BoundingBox.Length + part2.BoundingBox.Length + partSpacing;
|
||||||
part2.Offset(startOffset, 0);
|
part2.Offset(startOffset, 0);
|
||||||
part2.UpdateBounds();
|
part2.UpdateBounds();
|
||||||
|
|
||||||
@@ -111,46 +88,40 @@ namespace OpenNest.Engine.Fill
|
|||||||
part2.UpdateBounds();
|
part2.UpdateBounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-anchor pair to work area origin.
|
var pair = AnchorToWorkArea(part1, part2);
|
||||||
var pairBbox = ((IEnumerable<IBoundable>)new IBoundable[] { part1, part2 }).GetBoundingBox();
|
if (pair == null)
|
||||||
var anchor = new Vector(workArea.X - pairBbox.Left, workArea.Y - pairBbox.Bottom);
|
|
||||||
part1.Offset(anchor);
|
|
||||||
part2.Offset(anchor);
|
|
||||||
part1.UpdateBounds();
|
|
||||||
part2.UpdateBounds();
|
|
||||||
|
|
||||||
pairBbox = ((IEnumerable<IBoundable>)new IBoundable[] { part1, part2 }).GetBoundingBox();
|
|
||||||
|
|
||||||
// Verify pair fits in work area.
|
|
||||||
if (pairBbox.Width > workArea.Width + Tolerance.Epsilon ||
|
|
||||||
pairBbox.Length > workArea.Length + Tolerance.Epsilon)
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return (part1, part2, pairBbox);
|
// Verify pair fits in work area.
|
||||||
|
if (pair.Value.Bbox.Width > workArea.Width + Tolerance.Epsilon ||
|
||||||
|
pair.Value.Bbox.Length > workArea.Length + Tolerance.Epsilon)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return pair;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Step 2: Build Column (tile vertically) ---
|
// --- Step 2: Build Column (tile vertically) ---
|
||||||
|
|
||||||
private List<Part> BuildColumn(Part part1, Part part2, Box pairBbox)
|
private List<Part> BuildColumn(PartPair pair)
|
||||||
{
|
{
|
||||||
var column = new List<Part> { (Part)part1.Clone(), (Part)part2.Clone() };
|
var column = new List<Part> { (Part)pair.Part1.Clone(), (Part)pair.Part2.Clone() };
|
||||||
|
|
||||||
// Find geometry-aware copy distance for the pair vertically.
|
// Find geometry-aware copy distance for the pair vertically.
|
||||||
var boundary1 = new PartBoundary(part1, halfSpacing);
|
var boundary1 = new PartBoundary(pair.Part1, halfSpacing);
|
||||||
var boundary2 = new PartBoundary(part2, halfSpacing);
|
var boundary2 = new PartBoundary(pair.Part2, halfSpacing);
|
||||||
|
|
||||||
// Compute vertical copy distance using bounding boxes as starting point,
|
// Compute vertical copy distance using bounding boxes as starting point,
|
||||||
// then slide down to find true geometry distance.
|
// then slide down to find true geometry distance.
|
||||||
var pairHeight = pairBbox.Length;
|
var pairHeight = pair.Bbox.Width;
|
||||||
var testOffset = new Vector(0, pairHeight);
|
var testOffset = new Vector(0, pairHeight);
|
||||||
|
|
||||||
// Create test parts for slide distance measurement.
|
// Create test parts for slide distance measurement.
|
||||||
var testPart1 = part1.CloneAtOffset(testOffset);
|
var testPart1 = pair.Part1.CloneAtOffset(testOffset);
|
||||||
var testPart2 = part2.CloneAtOffset(testOffset);
|
var testPart2 = pair.Part2.CloneAtOffset(testOffset);
|
||||||
|
|
||||||
// Find minimum distance from test pair sliding down toward original pair.
|
// Find minimum distance from test pair sliding down toward original pair.
|
||||||
var copyDistance = FindVerticalCopyDistance(
|
var copyDistance = FindVerticalCopyDistance(
|
||||||
part1, part2, testPart1, testPart2,
|
pair.Part1, pair.Part2, testPart1, testPart2,
|
||||||
boundary1, boundary2, pairHeight);
|
boundary1, boundary2, pairHeight);
|
||||||
|
|
||||||
if (copyDistance <= 0)
|
if (copyDistance <= 0)
|
||||||
@@ -159,13 +130,13 @@ namespace OpenNest.Engine.Fill
|
|||||||
var count = 1;
|
var count = 1;
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
var nextBottom = pairBbox.Bottom + copyDistance * count;
|
var nextBottom = pair.Bbox.Bottom + copyDistance * count;
|
||||||
if (nextBottom + pairHeight > workArea.Top + Tolerance.Epsilon)
|
if (nextBottom + pairHeight > workArea.Top + Tolerance.Epsilon)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
var offset = new Vector(0, copyDistance * count);
|
var offset = new Vector(0, copyDistance * count);
|
||||||
column.Add(part1.CloneAtOffset(offset));
|
column.Add(pair.Part1.CloneAtOffset(offset));
|
||||||
column.Add(part2.CloneAtOffset(offset));
|
column.Add(pair.Part2.CloneAtOffset(offset));
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,23 +150,20 @@ namespace OpenNest.Engine.Fill
|
|||||||
double pairHeight)
|
double pairHeight)
|
||||||
{
|
{
|
||||||
// Check all 4 combinations: test parts sliding down toward original parts.
|
// Check all 4 combinations: test parts sliding down toward original parts.
|
||||||
|
var slidePairs = new[]
|
||||||
|
{
|
||||||
|
(moving: boundary1, movingLoc: testPart1.Location, stationary: boundary1, stationaryLoc: origPart1.Location),
|
||||||
|
(moving: boundary1, movingLoc: testPart1.Location, stationary: boundary2, stationaryLoc: origPart2.Location),
|
||||||
|
(moving: boundary2, movingLoc: testPart2.Location, stationary: boundary1, stationaryLoc: origPart1.Location),
|
||||||
|
(moving: boundary2, movingLoc: testPart2.Location, stationary: boundary2, stationaryLoc: origPart2.Location),
|
||||||
|
};
|
||||||
|
|
||||||
var minSlide = double.MaxValue;
|
var minSlide = double.MaxValue;
|
||||||
|
foreach (var (moving, movingLoc, stationary, stationaryLoc) in slidePairs)
|
||||||
// Test1 -> Orig1
|
{
|
||||||
var d = SlideDistance(boundary1, testPart1.Location, boundary1, origPart1.Location, PushDirection.Down);
|
var d = SlideDistance(moving, movingLoc, stationary, stationaryLoc, PushDirection.Down);
|
||||||
if (d < minSlide) minSlide = d;
|
if (d < minSlide) minSlide = d;
|
||||||
|
}
|
||||||
// Test1 -> Orig2
|
|
||||||
d = SlideDistance(boundary1, testPart1.Location, boundary2, origPart2.Location, PushDirection.Down);
|
|
||||||
if (d < minSlide) minSlide = d;
|
|
||||||
|
|
||||||
// Test2 -> Orig1
|
|
||||||
d = SlideDistance(boundary2, testPart2.Location, boundary1, origPart1.Location, PushDirection.Down);
|
|
||||||
if (d < minSlide) minSlide = d;
|
|
||||||
|
|
||||||
// Test2 -> Orig2
|
|
||||||
d = SlideDistance(boundary2, testPart2.Location, boundary2, origPart2.Location, PushDirection.Down);
|
|
||||||
if (d < minSlide) minSlide = d;
|
|
||||||
|
|
||||||
if (minSlide >= double.MaxValue || minSlide < 0)
|
if (minSlide >= double.MaxValue || minSlide < 0)
|
||||||
return pairHeight + partSpacing;
|
return pairHeight + partSpacing;
|
||||||
@@ -225,12 +193,9 @@ namespace OpenNest.Engine.Fill
|
|||||||
|
|
||||||
// --- Step 3: Iterative Adjustment ---
|
// --- Step 3: Iterative Adjustment ---
|
||||||
|
|
||||||
private List<Part> AdjustColumn(
|
private List<Part> AdjustColumn(PartPair pair, List<Part> column, CancellationToken token)
|
||||||
(Part part1, Part part2, Box pairBbox) pair,
|
|
||||||
List<Part> column,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
{
|
||||||
var originalPairWidth = pair.pairBbox.Width;
|
var originalPairWidth = pair.Bbox.Length;
|
||||||
|
|
||||||
for (var iteration = 0; iteration < MaxIterations; iteration++)
|
for (var iteration = 0; iteration < MaxIterations; iteration++)
|
||||||
{
|
{
|
||||||
@@ -261,7 +226,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
if (adjusted == null)
|
if (adjusted == null)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
var newColumn = BuildColumn(adjusted.Value.part1, adjusted.Value.part2, adjusted.Value.pairBbox);
|
var newColumn = BuildColumn(adjusted.Value);
|
||||||
if (newColumn.Count == 0)
|
if (newColumn.Count == 0)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -272,9 +237,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
return column;
|
return column;
|
||||||
}
|
}
|
||||||
|
|
||||||
private (Part part1, Part part2, Box pairBbox)? TryAdjustPair(
|
private PartPair? TryAdjustPair(PartPair pair, double adjustment, double originalPairWidth)
|
||||||
(Part part1, Part part2, Box pairBbox) pair,
|
|
||||||
double adjustment, double originalPairWidth)
|
|
||||||
{
|
{
|
||||||
// Try shifting part2 up first.
|
// Try shifting part2 up first.
|
||||||
var result = TryShiftDirection(pair, adjustment, originalPairWidth);
|
var result = TryShiftDirection(pair, adjustment, originalPairWidth);
|
||||||
@@ -286,13 +249,11 @@ namespace OpenNest.Engine.Fill
|
|||||||
return TryShiftDirection(pair, -adjustment, originalPairWidth);
|
return TryShiftDirection(pair, -adjustment, originalPairWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
private (Part part1, Part part2, Box pairBbox)? TryShiftDirection(
|
private PartPair? TryShiftDirection(PartPair pair, double verticalShift, double originalPairWidth)
|
||||||
(Part part1, Part part2, Box pairBbox) pair,
|
|
||||||
double verticalShift, double originalPairWidth)
|
|
||||||
{
|
{
|
||||||
// Clone parts so we don't mutate the originals.
|
// Clone parts so we don't mutate the originals.
|
||||||
var p1 = (Part)pair.part1.Clone();
|
var p1 = (Part)pair.Part1.Clone();
|
||||||
var p2 = (Part)pair.part2.Clone();
|
var p2 = (Part)pair.Part2.Clone();
|
||||||
|
|
||||||
// Separate: shift part2 right so bounding boxes don't touch.
|
// Separate: shift part2 right so bounding boxes don't touch.
|
||||||
p2.Offset(partSpacing, 0);
|
p2.Offset(partSpacing, 0);
|
||||||
@@ -308,20 +269,12 @@ namespace OpenNest.Engine.Fill
|
|||||||
Compactor.Push(moving, obstacles, workArea, partSpacing, PushDirection.Left);
|
Compactor.Push(moving, obstacles, workArea, partSpacing, PushDirection.Left);
|
||||||
|
|
||||||
// Check if the pair got wider.
|
// Check if the pair got wider.
|
||||||
var newBbox = ((IEnumerable<IBoundable>)new IBoundable[] { p1, p2 }).GetBoundingBox();
|
var newBbox = PairBbox(p1, p2);
|
||||||
|
|
||||||
if (newBbox.Width > originalPairWidth + Tolerance.Epsilon)
|
if (newBbox.Length > originalPairWidth + Tolerance.Epsilon)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
// Re-anchor to work area origin.
|
return AnchorToWorkArea(p1, p2);
|
||||||
var anchor = new Vector(workArea.X - newBbox.Left, workArea.Y - newBbox.Bottom);
|
|
||||||
p1.Offset(anchor);
|
|
||||||
p2.Offset(anchor);
|
|
||||||
p1.UpdateBounds();
|
|
||||||
p2.UpdateBounds();
|
|
||||||
|
|
||||||
newBbox = ((IEnumerable<IBoundable>)new IBoundable[] { p1, p2 }).GetBoundingBox();
|
|
||||||
return (p1, p2, newBbox);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Step 4: Horizontal Repetition ---
|
// --- Step 4: Horizontal Repetition ---
|
||||||
@@ -331,110 +284,35 @@ namespace OpenNest.Engine.Fill
|
|||||||
if (column.Count == 0)
|
if (column.Count == 0)
|
||||||
return column;
|
return column;
|
||||||
|
|
||||||
var columnBbox = ((IEnumerable<IBoundable>)column).GetBoundingBox();
|
var pattern = new Pattern();
|
||||||
var columnWidth = columnBbox.Width;
|
pattern.Parts.AddRange(column);
|
||||||
|
pattern.UpdateBounds();
|
||||||
|
|
||||||
// Create a test column shifted right by columnWidth + spacing.
|
var linear = new FillLinear(workArea, partSpacing);
|
||||||
var testOffset = columnWidth + partSpacing;
|
return linear.Fill(pattern, NestDirection.Horizontal);
|
||||||
var testColumn = new List<Part>(column.Count);
|
|
||||||
foreach (var part in column)
|
|
||||||
testColumn.Add(part.CloneAtOffset(new Vector(testOffset, 0)));
|
|
||||||
|
|
||||||
// Compact the test column left against the original column.
|
|
||||||
var distanceMoved = Compactor.Push(testColumn, column, workArea, partSpacing, PushDirection.Left);
|
|
||||||
|
|
||||||
// Derive the true copy distance from where the test column ended up.
|
|
||||||
var testBbox = ((IEnumerable<IBoundable>)testColumn).GetBoundingBox();
|
|
||||||
var copyDistance = testBbox.Left - columnBbox.Left;
|
|
||||||
|
|
||||||
if (copyDistance <= Tolerance.Epsilon)
|
|
||||||
copyDistance = columnWidth + partSpacing;
|
|
||||||
|
|
||||||
// Safety: if the compacted test column overlaps the original column,
|
|
||||||
// fall back to bbox-based spacing.
|
|
||||||
var probe = new List<Part>(column);
|
|
||||||
probe.AddRange(testColumn.Where(IsWithinWorkArea));
|
|
||||||
if (HasOverlappingParts(probe))
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"[FillExtents] Compacted column overlaps, falling back to bbox spacing");
|
|
||||||
copyDistance = columnWidth + partSpacing;
|
|
||||||
|
|
||||||
// Rebuild test column at safe distance.
|
|
||||||
testColumn.Clear();
|
|
||||||
foreach (var part in column)
|
|
||||||
testColumn.Add(part.CloneAtOffset(new Vector(copyDistance, 0)));
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug.WriteLine($"[FillExtents] Column copy distance: {copyDistance:F2} (bbox width: {columnWidth:F2}, spacing: {partSpacing:F2})");
|
|
||||||
|
|
||||||
// Build all columns.
|
|
||||||
var result = new List<Part>(column);
|
|
||||||
|
|
||||||
// Add the test column we already computed as column 2.
|
|
||||||
foreach (var part in testColumn)
|
|
||||||
{
|
|
||||||
if (IsWithinWorkArea(part))
|
|
||||||
result.Add(part);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tile additional columns at the copy distance.
|
|
||||||
var colIndex = 2;
|
|
||||||
while (!token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
var offset = new Vector(copyDistance * colIndex, 0);
|
|
||||||
var anyFit = false;
|
|
||||||
|
|
||||||
foreach (var part in column)
|
|
||||||
{
|
|
||||||
var clone = part.CloneAtOffset(offset);
|
|
||||||
if (IsWithinWorkArea(clone))
|
|
||||||
{
|
|
||||||
result.Add(clone);
|
|
||||||
anyFit = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!anyFit)
|
|
||||||
break;
|
|
||||||
|
|
||||||
colIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsWithinWorkArea(Part part)
|
// --- Helpers ---
|
||||||
|
|
||||||
|
private PartPair? AnchorToWorkArea(Part part1, Part part2)
|
||||||
{
|
{
|
||||||
return part.BoundingBox.Right <= workArea.Right + Tolerance.Epsilon &&
|
var bbox = PairBbox(part1, part2);
|
||||||
part.BoundingBox.Top <= workArea.Top + Tolerance.Epsilon &&
|
var anchor = new Vector(workArea.X - bbox.Left, workArea.Y - bbox.Bottom);
|
||||||
part.BoundingBox.Left >= workArea.Left - Tolerance.Epsilon &&
|
part1.Offset(anchor);
|
||||||
part.BoundingBox.Bottom >= workArea.Bottom - Tolerance.Epsilon;
|
part2.Offset(anchor);
|
||||||
|
part1.UpdateBounds();
|
||||||
|
part2.UpdateBounds();
|
||||||
|
|
||||||
|
bbox = PairBbox(part1, part2);
|
||||||
|
return new PartPair(part1, part2, bbox);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool HasOverlappingParts(List<Part> parts)
|
private static Box PairBbox(Part part1, Part part2) =>
|
||||||
{
|
((IEnumerable<IBoundable>)new IBoundable[] { part1, part2 }).GetBoundingBox();
|
||||||
for (var i = 0; i < parts.Count; i++)
|
|
||||||
{
|
|
||||||
var b1 = parts[i].BoundingBox;
|
|
||||||
|
|
||||||
for (var j = i + 1; j < parts.Count; j++)
|
private static bool HasOverlappingParts(List<Part> parts) =>
|
||||||
{
|
FillHelpers.HasOverlappingParts(parts);
|
||||||
var b2 = parts[j].BoundingBox;
|
|
||||||
|
|
||||||
var overlapX = System.Math.Min(b1.Right, b2.Right)
|
private readonly record struct PartPair(Part Part1, Part Part2, Box Bbox);
|
||||||
- System.Math.Max(b1.Left, b2.Left);
|
|
||||||
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
|
||||||
- System.Math.Max(b1.Bottom, b2.Bottom);
|
|
||||||
|
|
||||||
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (parts[i].Intersects(parts[j], out _))
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-137
@@ -1,6 +1,7 @@
|
|||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace OpenNest.Engine.Fill
|
namespace OpenNest.Engine.Fill
|
||||||
@@ -10,7 +11,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
public FillLinear(Box workArea, double partSpacing)
|
public FillLinear(Box workArea, double partSpacing)
|
||||||
{
|
{
|
||||||
PartSpacing = partSpacing;
|
PartSpacing = partSpacing;
|
||||||
WorkArea = new Box(workArea.X, workArea.Y, workArea.Width, workArea.Length);
|
WorkArea = new Box(workArea.X, workArea.Y, workArea.Length, workArea.Width);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Box WorkArea { get; }
|
public Box WorkArea { get; }
|
||||||
@@ -19,6 +20,11 @@ namespace OpenNest.Engine.Fill
|
|||||||
|
|
||||||
public double HalfSpacing => PartSpacing / 2;
|
public double HalfSpacing => PartSpacing / 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Diagnostic label set by callers to identify the engine/context in overlap logs.
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; }
|
||||||
|
|
||||||
private static Vector MakeOffset(NestDirection direction, double distance)
|
private static Vector MakeOffset(NestDirection direction, double distance)
|
||||||
{
|
{
|
||||||
return direction == NestDirection.Horizontal
|
return direction == NestDirection.Horizontal
|
||||||
@@ -35,7 +41,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
|
|
||||||
private static double GetDimension(Box box, NestDirection direction)
|
private static double GetDimension(Box box, NestDirection direction)
|
||||||
{
|
{
|
||||||
return direction == NestDirection.Horizontal ? box.Width : box.Length;
|
return direction == NestDirection.Horizontal ? box.Length : box.Width;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double GetStart(Box box, NestDirection direction)
|
private static double GetStart(Box box, NestDirection direction)
|
||||||
@@ -55,91 +61,91 @@ namespace OpenNest.Engine.Fill
|
|||||||
: NestDirection.Horizontal;
|
: NestDirection.Horizontal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Computes the slide distance for the push algorithm, returning the
|
|
||||||
/// geometry-aware copy distance along the given axis.
|
|
||||||
/// </summary>
|
|
||||||
private double ComputeCopyDistance(double bboxDim, double slideDistance)
|
|
||||||
{
|
|
||||||
if (slideDistance >= double.MaxValue || slideDistance < 0)
|
|
||||||
return bboxDim + PartSpacing;
|
|
||||||
|
|
||||||
// The geometry-aware slide can produce a copy distance smaller than
|
|
||||||
// the part itself when inflated corner/arc vertices interact spuriously.
|
|
||||||
// Clamp to bboxDim + PartSpacing to prevent bounding box overlap.
|
|
||||||
return System.Math.Max(bboxDim - slideDistance, bboxDim + PartSpacing);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds the geometry-aware copy distance between two identical parts along an axis.
|
/// Finds the geometry-aware copy distance between two identical parts along an axis.
|
||||||
/// Both parts are inflated by half-spacing for symmetric spacing.
|
/// Uses native Line/Arc entities (inflated by half-spacing) so curves are handled
|
||||||
|
/// exactly without polygon sampling error.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private double FindCopyDistance(Part partA, NestDirection direction, PartBoundary boundary)
|
private double FindCopyDistance(Part partA, NestDirection direction)
|
||||||
{
|
{
|
||||||
var bboxDim = GetDimension(partA.BoundingBox, direction);
|
var bboxDim = GetDimension(partA.BoundingBox, direction);
|
||||||
var pushDir = GetPushDirection(direction);
|
var pushDir = GetPushDirection(direction);
|
||||||
|
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
|
||||||
|
var offset = MakeOffset(direction, startOffset);
|
||||||
|
|
||||||
var locationBOffset = MakeOffset(direction, bboxDim);
|
var stationaryEntities = PartGeometry.GetOffsetPerimeterEntities(partA, HalfSpacing);
|
||||||
|
var movingEntities = PartGeometry.GetOffsetPerimeterEntities(
|
||||||
|
partA.CloneAtOffset(offset), HalfSpacing);
|
||||||
|
|
||||||
// Use the most efficient array-based overload to avoid all allocations.
|
|
||||||
var slideDistance = SpatialQuery.DirectionalDistance(
|
var slideDistance = SpatialQuery.DirectionalDistance(
|
||||||
boundary.GetEdges(pushDir), partA.Location + locationBOffset,
|
movingEntities, stationaryEntities, pushDir);
|
||||||
boundary.GetEdges(SpatialQuery.OppositeDirection(pushDir)), partA.Location,
|
|
||||||
pushDir);
|
|
||||||
|
|
||||||
return ComputeCopyDistance(bboxDim, slideDistance);
|
if (slideDistance >= double.MaxValue || slideDistance < 0)
|
||||||
|
return bboxDim + PartSpacing;
|
||||||
|
|
||||||
|
return startOffset - slideDistance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds the geometry-aware copy distance between two identical patterns along an axis.
|
/// Finds the geometry-aware copy distance between two identical patterns along an axis.
|
||||||
/// Checks every pair of parts across adjacent patterns so that multi-part
|
/// Checks every pair of parts across adjacent pattern copies so multi-part patterns
|
||||||
/// patterns (e.g. interlocking pairs) maintain spacing between ALL parts.
|
/// (e.g. interlocking pairs) maintain spacing between ALL parts. Uses native entity
|
||||||
/// Both sides are inflated by half-spacing for symmetric spacing.
|
/// geometry inflated by half-spacing — same primitive the Compactor uses — so arcs
|
||||||
|
/// are exact and no bbox clamp is needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private double FindPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary[] boundaries)
|
private double FindPatternCopyDistance(Pattern patternA, NestDirection direction)
|
||||||
{
|
{
|
||||||
if (patternA.Parts.Count <= 1)
|
if (patternA.Parts.Count == 1)
|
||||||
return FindSinglePartPatternCopyDistance(patternA, direction, boundaries[0]);
|
return FindCopyDistance(patternA.Parts[0], direction);
|
||||||
|
|
||||||
var bboxDim = GetDimension(patternA.BoundingBox, direction);
|
var bboxDim = GetDimension(patternA.BoundingBox, direction);
|
||||||
var pushDir = GetPushDirection(direction);
|
var pushDir = GetPushDirection(direction);
|
||||||
var opposite = SpatialQuery.OppositeDirection(pushDir);
|
var opposite = SpatialQuery.OppositeDirection(pushDir);
|
||||||
|
var dirVec = SpatialQuery.DirectionToOffset(pushDir, 1.0);
|
||||||
|
|
||||||
// bboxDim already spans max(upper) - min(lower) across all parts,
|
// bboxDim already spans max(upper) - min(lower) across all parts,
|
||||||
// so the start offset just needs to push beyond that plus spacing.
|
// so the start offset just needs to push beyond that plus spacing.
|
||||||
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
|
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
|
||||||
var offset = MakeOffset(direction, startOffset);
|
var offset = MakeOffset(direction, startOffset);
|
||||||
|
|
||||||
var maxCopyDistance = FindMaxPairDistance(
|
var parts = patternA.Parts;
|
||||||
patternA.Parts, boundaries, offset, pushDir, opposite, startOffset);
|
var stationaryBoxes = new Box[parts.Count];
|
||||||
|
var movingBoxes = new Box[parts.Count];
|
||||||
|
var stationaryEntities = new List<Entity>[parts.Count];
|
||||||
|
var movingEntities = new List<Entity>[parts.Count];
|
||||||
|
|
||||||
if (maxCopyDistance < Tolerance.Epsilon)
|
for (var i = 0; i < parts.Count; i++)
|
||||||
return bboxDim + PartSpacing;
|
{
|
||||||
|
stationaryBoxes[i] = parts[i].BoundingBox;
|
||||||
|
movingBoxes[i] = stationaryBoxes[i].Translate(offset);
|
||||||
|
}
|
||||||
|
|
||||||
return maxCopyDistance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests every pair of parts across adjacent pattern copies and returns the
|
|
||||||
/// maximum copy distance found. Returns 0 if no valid slide was found.
|
|
||||||
/// </summary>
|
|
||||||
private static double FindMaxPairDistance(
|
|
||||||
List<Part> parts, PartBoundary[] boundaries, Vector offset,
|
|
||||||
PushDirection pushDir, PushDirection opposite, double startOffset)
|
|
||||||
{
|
|
||||||
var maxCopyDistance = 0.0;
|
var maxCopyDistance = 0.0;
|
||||||
|
|
||||||
for (var j = 0; j < parts.Count; j++)
|
for (var j = 0; j < parts.Count; j++)
|
||||||
{
|
{
|
||||||
var movingEdges = boundaries[j].GetEdges(pushDir);
|
var movingBox = movingBoxes[j];
|
||||||
var locationB = parts[j].Location + offset;
|
|
||||||
|
|
||||||
for (var i = 0; i < parts.Count; i++)
|
for (var i = 0; i < parts.Count; i++)
|
||||||
{
|
{
|
||||||
|
var stationaryBox = stationaryBoxes[i];
|
||||||
|
|
||||||
|
// Skip if stationary is already ahead of moving in the push direction
|
||||||
|
// (sliding forward would take them further apart).
|
||||||
|
if (SpatialQuery.DirectionalGap(movingBox, stationaryBox, opposite) > 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Skip if bboxes can't overlap along the axis perpendicular to the push.
|
||||||
|
if (!SpatialQuery.PerpendicularOverlap(movingBox, stationaryBox, dirVec))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
stationaryEntities[i] ??= PartGeometry.GetOffsetPerimeterEntities(
|
||||||
|
parts[i], HalfSpacing);
|
||||||
|
movingEntities[j] ??= PartGeometry.GetOffsetPerimeterEntities(
|
||||||
|
parts[j].CloneAtOffset(offset), HalfSpacing);
|
||||||
|
|
||||||
var slideDistance = SpatialQuery.DirectionalDistance(
|
var slideDistance = SpatialQuery.DirectionalDistance(
|
||||||
movingEdges, locationB,
|
movingEntities[j], stationaryEntities[i], pushDir);
|
||||||
boundaries[i].GetEdges(opposite), parts[i].Location,
|
|
||||||
pushDir);
|
|
||||||
|
|
||||||
if (slideDistance >= double.MaxValue || slideDistance < 0)
|
if (slideDistance >= double.MaxValue || slideDistance < 0)
|
||||||
continue;
|
continue;
|
||||||
@@ -154,86 +160,15 @@ namespace OpenNest.Engine.Fill
|
|||||||
return maxCopyDistance;
|
return maxCopyDistance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fast path for single-part patterns — no cross-part conflicts possible.
|
|
||||||
/// </summary>
|
|
||||||
private double FindSinglePartPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary boundary)
|
|
||||||
{
|
|
||||||
var template = patternA.Parts[0];
|
|
||||||
return FindCopyDistance(template, direction, boundary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets offset boundary lines for all parts in a pattern using a shared boundary.
|
|
||||||
/// </summary>
|
|
||||||
private static List<Line> GetPatternLines(Pattern pattern, PartBoundary boundary, PushDirection direction)
|
|
||||||
{
|
|
||||||
var lines = new List<Line>();
|
|
||||||
|
|
||||||
foreach (var part in pattern.Parts)
|
|
||||||
lines.AddRange(boundary.GetLines(part.Location, direction));
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets boundary lines for all parts in a pattern, with an additional
|
|
||||||
/// location offset applied. Avoids cloning the pattern.
|
|
||||||
/// </summary>
|
|
||||||
private static List<Line> GetOffsetPatternLines(Pattern pattern, Vector offset, PartBoundary boundary, PushDirection direction)
|
|
||||||
{
|
|
||||||
var lines = new List<Line>();
|
|
||||||
|
|
||||||
foreach (var part in pattern.Parts)
|
|
||||||
lines.AddRange(boundary.GetLines(part.Location + offset, direction));
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates boundaries for all parts in a pattern. Parts that share the same
|
|
||||||
/// program geometry (same drawing and rotation) reuse the same boundary instance.
|
|
||||||
/// </summary>
|
|
||||||
private PartBoundary[] CreateBoundaries(Pattern pattern)
|
|
||||||
{
|
|
||||||
var boundaries = new PartBoundary[pattern.Parts.Count];
|
|
||||||
var cache = new List<(Drawing drawing, double rotation, PartBoundary boundary)>();
|
|
||||||
|
|
||||||
for (var i = 0; i < pattern.Parts.Count; i++)
|
|
||||||
{
|
|
||||||
var part = pattern.Parts[i];
|
|
||||||
PartBoundary found = null;
|
|
||||||
|
|
||||||
foreach (var entry in cache)
|
|
||||||
{
|
|
||||||
if (entry.drawing == part.BaseDrawing && entry.rotation.IsEqualTo(part.Rotation))
|
|
||||||
{
|
|
||||||
found = entry.boundary;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (found == null)
|
|
||||||
{
|
|
||||||
found = new PartBoundary(part, HalfSpacing);
|
|
||||||
cache.Add((part.BaseDrawing, part.Rotation, found));
|
|
||||||
}
|
|
||||||
|
|
||||||
boundaries[i] = found;
|
|
||||||
}
|
|
||||||
|
|
||||||
return boundaries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tiles a pattern along the given axis, returning the cloned parts
|
/// Tiles a pattern along the given axis, returning the cloned parts
|
||||||
/// (does not include the original pattern's parts). For multi-part
|
/// (does not include the original pattern's parts). For multi-part
|
||||||
/// patterns, also adds individual parts from the next incomplete copy
|
/// patterns, also adds individual parts from the next incomplete copy
|
||||||
/// that still fit within the work area.
|
/// that still fit within the work area.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private List<Part> TilePattern(Pattern basePattern, NestDirection direction, PartBoundary[] boundaries)
|
private List<Part> TilePattern(Pattern basePattern, NestDirection direction)
|
||||||
{
|
{
|
||||||
var copyDistance = FindPatternCopyDistance(basePattern, direction, boundaries);
|
var copyDistance = FindPatternCopyDistance(basePattern, direction);
|
||||||
|
|
||||||
if (copyDistance <= 0)
|
if (copyDistance <= 0)
|
||||||
return new List<Part>();
|
return new List<Part>();
|
||||||
@@ -323,7 +258,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool HasOverlappingParts(List<Part> parts)
|
private static bool HasOverlappingParts(List<Part> parts, out int overlapA, out int overlapB)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < parts.Count; i++)
|
for (var i = 0; i < parts.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -338,11 +273,20 @@ namespace OpenNest.Engine.Fill
|
|||||||
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
||||||
- System.Math.Max(b1.Bottom, b2.Bottom);
|
- System.Math.Max(b1.Bottom, b2.Bottom);
|
||||||
|
|
||||||
if (overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon)
|
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (parts[i].Intersects(parts[j], out _))
|
||||||
|
{
|
||||||
|
overlapA = i;
|
||||||
|
overlapB = j;
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
overlapA = -1;
|
||||||
|
overlapB = -1;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,16 +322,14 @@ namespace OpenNest.Engine.Fill
|
|||||||
private List<Part> FillGrid(Pattern pattern, NestDirection direction)
|
private List<Part> FillGrid(Pattern pattern, NestDirection direction)
|
||||||
{
|
{
|
||||||
var perpAxis = PerpendicularAxis(direction);
|
var perpAxis = PerpendicularAxis(direction);
|
||||||
var boundaries = CreateBoundaries(pattern);
|
|
||||||
|
|
||||||
// Step 1: Tile along primary axis
|
// Step 1: Tile along primary axis
|
||||||
var row = new List<Part>(pattern.Parts);
|
var row = new List<Part>(pattern.Parts);
|
||||||
row.AddRange(TilePattern(pattern, direction, boundaries));
|
row.AddRange(TilePattern(pattern, direction));
|
||||||
|
|
||||||
// Safety: if geometry-aware spacing produced overlapping parts,
|
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a1, out var b1))
|
||||||
// fall back to bbox-based spacing for this axis.
|
|
||||||
if (pattern.Parts.Count > 1 && HasOverlappingParts(row))
|
|
||||||
{
|
{
|
||||||
|
LogOverlap("Step1-Primary", direction, pattern, row, a1, b1);
|
||||||
row = new List<Part>(pattern.Parts);
|
row = new List<Part>(pattern.Parts);
|
||||||
row.AddRange(TilePatternBbox(pattern, direction));
|
row.AddRange(TilePatternBbox(pattern, direction));
|
||||||
}
|
}
|
||||||
@@ -395,10 +337,11 @@ namespace OpenNest.Engine.Fill
|
|||||||
// If primary tiling didn't produce copies, just tile along perpendicular
|
// If primary tiling didn't produce copies, just tile along perpendicular
|
||||||
if (row.Count <= pattern.Parts.Count)
|
if (row.Count <= pattern.Parts.Count)
|
||||||
{
|
{
|
||||||
row.AddRange(TilePattern(pattern, perpAxis, boundaries));
|
row.AddRange(TilePattern(pattern, perpAxis));
|
||||||
|
|
||||||
if (pattern.Parts.Count > 1 && HasOverlappingParts(row))
|
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2))
|
||||||
{
|
{
|
||||||
|
LogOverlap("Step1-PerpOnly", perpAxis, pattern, row, a2, b2);
|
||||||
row = new List<Part>(pattern.Parts);
|
row = new List<Part>(pattern.Parts);
|
||||||
row.AddRange(TilePatternBbox(pattern, perpAxis));
|
row.AddRange(TilePatternBbox(pattern, perpAxis));
|
||||||
}
|
}
|
||||||
@@ -411,13 +354,48 @@ namespace OpenNest.Engine.Fill
|
|||||||
rowPattern.Parts.AddRange(row);
|
rowPattern.Parts.AddRange(row);
|
||||||
rowPattern.UpdateBounds();
|
rowPattern.UpdateBounds();
|
||||||
|
|
||||||
var rowBoundaries = CreateBoundaries(rowPattern);
|
|
||||||
var gridResult = new List<Part>(rowPattern.Parts);
|
var gridResult = new List<Part>(rowPattern.Parts);
|
||||||
gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries));
|
gridResult.AddRange(TilePattern(rowPattern, perpAxis));
|
||||||
|
|
||||||
|
if (HasOverlappingParts(gridResult, out var a3, out var b3))
|
||||||
|
{
|
||||||
|
LogOverlap("Step2-Perp", perpAxis, rowPattern, gridResult, a3, b3);
|
||||||
|
gridResult = new List<Part>(rowPattern.Parts);
|
||||||
|
gridResult.AddRange(TilePatternBbox(rowPattern, perpAxis));
|
||||||
|
}
|
||||||
|
|
||||||
return gridResult;
|
return gridResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LogOverlap(string step, NestDirection tilingDir,
|
||||||
|
Pattern pattern, List<Part> parts, int idxA, int idxB)
|
||||||
|
{
|
||||||
|
var pa = parts[idxA];
|
||||||
|
var pb = parts[idxB];
|
||||||
|
var ba = pa.BoundingBox;
|
||||||
|
var bb = pb.BoundingBox;
|
||||||
|
|
||||||
|
Debug.WriteLine($"[FillLinear] OVERLAP FALLBACK ({Label ?? "unknown"})");
|
||||||
|
Debug.WriteLine($" Step: {step}, TilingDir: {tilingDir}");
|
||||||
|
Debug.WriteLine($" WorkArea: ({WorkArea.X:F4},{WorkArea.Y:F4}) {WorkArea.Width:F4}x{WorkArea.Length:F4}, Spacing: {PartSpacing}");
|
||||||
|
Debug.WriteLine($" Pattern: {pattern.Parts.Count} parts, bbox {pattern.BoundingBox.Width:F4}x{pattern.BoundingBox.Length:F4}");
|
||||||
|
Debug.WriteLine($" Total parts after tiling: {parts.Count}");
|
||||||
|
Debug.WriteLine($" Overlapping pair [{idxA}] vs [{idxB}]:");
|
||||||
|
Debug.WriteLine($" [{idxA}]: drawing={pa.BaseDrawing?.Name ?? "?"} rot={Angle.ToDegrees(pa.Rotation):F2}° " +
|
||||||
|
$"loc=({pa.Location.X:F4},{pa.Location.Y:F4}) bbox=({ba.Left:F4},{ba.Bottom:F4})-({ba.Right:F4},{ba.Top:F4})");
|
||||||
|
Debug.WriteLine($" [{idxB}]: drawing={pb.BaseDrawing?.Name ?? "?"} rot={Angle.ToDegrees(pb.Rotation):F2}° " +
|
||||||
|
$"loc=({pb.Location.X:F4},{pb.Location.Y:F4}) bbox=({bb.Left:F4},{bb.Bottom:F4})-({bb.Right:F4},{bb.Top:F4})");
|
||||||
|
|
||||||
|
// Log all pattern seed parts for reproduction
|
||||||
|
Debug.WriteLine($" Pattern seed parts:");
|
||||||
|
for (var i = 0; i < pattern.Parts.Count; i++)
|
||||||
|
{
|
||||||
|
var p = pattern.Parts[i];
|
||||||
|
Debug.WriteLine($" [{i}]: drawing={p.BaseDrawing?.Name ?? "?"} rot={Angle.ToDegrees(p.Rotation):F2}° " +
|
||||||
|
$"loc=({p.Location.X:F4},{p.Location.Y:F4}) bbox={p.BoundingBox.Width:F4}x{p.BoundingBox.Length:F4}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fills a single row of identical parts along one axis using geometry-aware spacing.
|
/// Fills a single row of identical parts along one axis using geometry-aware spacing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -429,9 +407,8 @@ namespace OpenNest.Engine.Fill
|
|||||||
return seed;
|
return seed;
|
||||||
|
|
||||||
var template = seed.Parts[0];
|
var template = seed.Parts[0];
|
||||||
var boundary = new PartBoundary(template, HalfSpacing);
|
|
||||||
|
|
||||||
var copyDistance = FindCopyDistance(template, direction, boundary);
|
var copyDistance = FindCopyDistance(template, direction);
|
||||||
|
|
||||||
if (copyDistance <= 0)
|
if (copyDistance <= 0)
|
||||||
return seed;
|
return seed;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user