Compare commits
505 Commits
2ed02c2dae
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 2db8c49838 | |||
| 80e8693da3 | |||
| d7eb3ebd7a | |||
| 4404d3a5d0 | |||
| d27dee3db9 | |||
| 7081c7b4d0 | |||
| a6e813bc85 | |||
| 98453243fc | |||
| 64874857a1 | |||
| 5d3fcb2dc8 | |||
| ae9a63b5ce | |||
| 596328148d | |||
| 6cd48a623d | |||
| 42243c7df0 | |||
| 4b10d4801c | |||
| f0bdaa14e6 | |||
| 79ddce346b | |||
| 20777541c0 | |||
| 7c8168b002 | |||
| 203bd4eeea | |||
| 02d15dea9c | |||
| a88937b716 | |||
| 986a0412b1 | |||
| e7f2ee80e2 | |||
| 31063d954d | |||
| fc1fee54cd | |||
| 094b522644 | |||
| 45dea4ec2b | |||
| 743bb25f7b | |||
| a34811bb6d | |||
| 9b460f77e5 | |||
| 85bf779f21 | |||
| 641c1cd461 | |||
| 4a5ed1b9c0 | |||
| c40941ed35 | |||
| d6184fdc8f | |||
| d61ec1747a | |||
| 7b815c9579 | |||
| 5568789902 | |||
| fd93cc9db2 | |||
| 740fd79adc | |||
| e1b6752ede | |||
| 18d9bbadfa | |||
| e27def388f | |||
| 356b989424 | |||
| c6652f7707 | |||
| df008081d1 | |||
| 0a294934ae | |||
| f711a2e4d6 | |||
| a4df4027f1 | |||
| 278bbe54ba | |||
| ca5eb53bc1 | |||
| bbc02f6f3f | |||
| 12173204d1 | |||
| cbabf5e9d1 | |||
| 1aac03c9ef | |||
| f46bcd4e4b | |||
| f29f086080 | |||
| 19001ea5be | |||
| 269746b8a4 | |||
| 35218a7435 | |||
| bd973c5f79 | |||
| d042bd1844 | |||
| ebdd489fdc | |||
| 885dec5f0e | |||
| 6106df929e | |||
| 965b9c8c1a | |||
| 98e90cc176 | |||
| d9005cccc3 | |||
| f208569e72 | |||
| 1ffe904892 | |||
| 4cc8b8f9b7 | |||
| 1f159d5dcc | |||
| f626fbe063 | |||
| d5b5ab57e3 | |||
| 6916f5ecca | |||
| e1bcb7498f | |||
| a7f8972722 | |||
| 6d1a3f5e2c | |||
| 52eca5f5c2 | |||
| 3bce45be5f | |||
| 3f0a4c57b5 | |||
| ededc7b6b4 | |||
| 5f74afeda1 | |||
| 574a8f2c38 | |||
| dd2892a9fe | |||
| 7056f8816f | |||
| c2a470f79c | |||
| 39f8a79cfd | |||
| df18b72881 | |||
| cd8adc97d6 | |||
| ba7aa39941 | |||
| 5d93ddb2c4 | |||
| 15b2043048 | |||
| aa8b6f3d9e | |||
| 3686d074e6 | |||
| 8f1a3fb6b7 | |||
| 60ce297d6a | |||
| addd7acc3c | |||
| d91ffccfa3 | |||
| adb8ed12d7 | |||
| 4acd8b8bad | |||
| d7b095cf2d | |||
| 499e0425b5 | |||
| c2c3e23024 | |||
| 5afb311ac7 | |||
| 765a862440 | |||
| b970629a59 | |||
| 072915abf2 | |||
| aeeb2e4074 | |||
| a2f7219db3 | |||
| 7e4040ba08 | |||
| 0246073b31 | |||
| 4801895321 | |||
| 833abfe72e | |||
| 379000bbd8 | |||
| 5936272ce4 | |||
| da8e7e6fd3 | |||
| 53d24ddaf1 | |||
| 8efdc8720c | |||
| ca8a0942ab | |||
| 8c3659a439 | |||
| 95a0815484 | |||
| e9caa9b8eb | |||
| 95a0db1983 | |||
| a323dcc230 | |||
| 24cd18da88 | |||
| 5d26efb552 | |||
| 60c4545a17 | |||
| 4db51b8cdf | |||
| 1c561d880e | |||
| 17fc9c6cab | |||
| 4287c5fa46 | |||
| a735884ee9 | |||
| 22554b0fa3 | |||
| 48b4849a88 | |||
| f79df4d426 | |||
| ebb18d9b49 | |||
| 31a9e6dbad | |||
| a576f9fafa | |||
| 9453bb51ce | |||
| ad58332a5d | |||
| d4f60d5e8e | |||
| 3ea05257eb | |||
| 7e49ed620b | |||
| 57bd0447e9 | |||
| 07d6f08e8b | |||
| 2f19f47a85 | |||
| d58a446eac | |||
| 5fc7d1989a | |||
| 3f6bc2b2a1 | |||
| 7681a1bad0 | |||
| a548d5329a | |||
| 07012033c7 | |||
| 92b17b2963 | |||
| b6ee04f038 | |||
| 8ffdacd6c0 | |||
| ccd402c50f | |||
| b1e872577c | |||
| 9903478d3e | |||
| 93a8981d0a | |||
| 00e7866506 | |||
| 560105f952 | |||
| 266f8a83e6 | |||
| 0b7697e9c0 | |||
| 83124eb38d | |||
| 24beb8ada1 | |||
| ee83f17afe | |||
| 99546e7eef | |||
| 4586a53590 | |||
| 1a41eeb81d | |||
| f894ffd27c | |||
| 0ec22f2207 | |||
| 3f3d95a5e4 | |||
| 811d23510e | |||
| 0597a11a23 | |||
| 2ae1d513cf | |||
| 904d30d05d | |||
| e9678c73b2 | |||
| 4060430757 | |||
| de527cd668 | |||
| 9887cb1aa3 | |||
| cdf8e4e40e | |||
| 4f21fb91a1 | |||
| 7f96d632f3 | |||
| 38dcaf16d3 | |||
| 8c57e43221 | |||
| bc78ddc49c | |||
| c88cec2beb | |||
| b7c7cecd75 | |||
| 4d0d8c453b | |||
| 5f4288a786 | |||
| 707ddb80d9 | |||
| 71f28600d1 | |||
| d39b0ae540 | |||
| ee5c77c645 | |||
| 4615bcb40d | |||
| 7843de145b | |||
| 2d1f2217e5 | |||
| ae88c34361 | |||
| 708d895a04 | |||
| 884817c5f9 | |||
| cf1c5fe120 | |||
| a04586f7df | |||
| 069e966453 | |||
| d9d275b675 | |||
| 9411dd0fdd | |||
| facd07d7de |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -208,3 +208,8 @@ FakesAssemblies/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
|
||||
# Launch settings
|
||||
**/Properties/launchSettings.json
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"opennest": {
|
||||
"command": "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/OpenNest.Mcp.exe",
|
||||
"args": []
|
||||
"command": "cmd",
|
||||
"args": ["/c", "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/run.cmd"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
CLAUDE.md
28
CLAUDE.md
@@ -24,25 +24,28 @@ Eight projects form a layered architecture:
|
||||
Domain model, geometry, and CNC primitives organized into namespaces:
|
||||
|
||||
- **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
|
||||
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`). Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning.
|
||||
- **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`.
|
||||
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) and an optional `Variables` dictionary of `VariableDefinition` entries. Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. `VariableDefinition` stores a named variable's expression, resolved value, and flags (`Inline`, `Global`). `ProgramVariableManager` manages numbered machine variables for post-processor output.
|
||||
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction).
|
||||
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
|
||||
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding). 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.
|
||||
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
|
||||
- **CutOffs** (`namespace OpenNest`): `CutOff` (axis-aligned cut line with position, axis, optional start/end limits), `CutOffAxis` enum (`Horizontal`, `Vertical`), `CutOffSettings` (clearance, overtravel, min segment length, direction), `CutDirection` enum (`TowardOrigin`, `AwayFromOrigin`). Cut-offs generate CNC `Program` objects with trimmed line segments that avoid parts.
|
||||
- **Splitting** (`Splitting/`, `namespace OpenNest`): `DrawingSplitter` splits a Drawing into multiple pieces along split lines. `ISplitFeature` strategy pattern with implementations: `StraightSplit` (clean edge), `WeldGapTabSplit` (rectangular tab spacers on one side), `SpikeGrooveSplit` (interlocking spike/V-groove pairs). `AutoSplitCalculator` computes split lines for fit-to-plate and split-by-count modes. Supporting types: `SplitLine`, `SplitParameters`, `SplitFeatureResult`.
|
||||
- **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning.
|
||||
|
||||
### OpenNest.Engine (class library, depends on Core)
|
||||
Nesting algorithms with a pluggable engine architecture. `NestEngineBase` is the abstract base class; `DefaultNestEngine` (formerly `NestEngine`) provides the multi-phase fill strategy. `NestEngineRegistry` manages available engines (built-in + plugins from `Engines/` directory) and the globally active engine. `AutoNester` handles mixed-part NFP-based nesting with simulated annealing (not yet integrated into the registry).
|
||||
Nesting algorithms with a pluggable engine architecture. `NestEngineBase` is the abstract base class; `DefaultNestEngine` (formerly `NestEngine`) provides the multi-phase fill strategy. `NestEngineRegistry` manages available engines (built-in + plugins from `Engines/` directory) and the globally active engine.
|
||||
|
||||
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`.
|
||||
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases) → `VerticalRemnantEngine` (optimizes for right-side drop), `HorizontalRemnantEngine` (optimizes for top-side drop). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`.
|
||||
- **IFillComparer**: Interface enabling engine-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), `HorizontalRemnantComparer` (minimize Y-extent). Engines provide their comparer via `CreateComparer()` factory, grouped into `FillPolicy` on `FillContext`.
|
||||
- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency.
|
||||
- **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`.
|
||||
- **Strategies/** (`namespace OpenNest.Engine.Strategies`): Pluggable fill strategy layer — `IFillStrategy` interface, `FillContext`, `FillStrategyRegistry` (auto-discovers strategies via reflection, supports plugin DLLs), `FillHelpers`. Built-in strategies: `LinearFillStrategy`, `PairsFillStrategy`, `RectBestFitStrategy`, `ExtentsFillStrategy`.
|
||||
- **BestFit/** (`namespace OpenNest.Engine.BestFit`): NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups.
|
||||
- **RectanglePacking/** (`namespace OpenNest.RectanglePacking`): `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
|
||||
- **CirclePacking/** (`namespace OpenNest.CirclePacking`): Alternative packing for circular parts.
|
||||
- **Nfp/** (`namespace OpenNest.Engine.Nfp`): NFP-based nesting (not yet integrated) — `AutoNester` (mixed-part nesting with simulated annealing), `BottomLeftFill` (BLF placement), `NfpCache` (computed NFP caching), `SimulatedAnnealing` (optimizer), `INestOptimizer`/`NestResult`.
|
||||
- **Nfp/** (`namespace OpenNest.Engine.Nfp`): Internal NFP-based single-part placement utilities — `AutoNester` (NFP placement with simulated annealing), `BottomLeftFill` (BLF placement), `NfpCache` (computed NFP caching), `SimulatedAnnealing` (optimizer), `INestOptimizer`/`OptimizationResult`. Not exposed as a nest engine; used internally for individual part placement.
|
||||
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data).
|
||||
- `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints.
|
||||
- `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback.
|
||||
@@ -54,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).
|
||||
- `ProgramReader` — G-code text parser.
|
||||
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types.
|
||||
- `CadImporter` — shared "DXF → Drawing" service used by the UI, console, MCP, API, and training projects. Two-stage API: `Import(path, options)` loads raw entities, runs bend detection, and returns a mutable `CadImportResult`; `BuildDrawing(result, visible, bends, quantity, customer, editedProgram)` produces a fully-populated `Drawing` with `Source.Offset`, `SourceEntities`, `SuppressedEntityIds`, and bends. `ImportDrawing(path, options)` composes both stages for headless callers.
|
||||
- `CadImportOptions`, `CadImportResult` — inputs and intermediate state for `CadImporter`.
|
||||
|
||||
### OpenNest.Console (console app, depends on Core + Engine + IO)
|
||||
Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`).
|
||||
@@ -76,15 +81,15 @@ MCP server for Claude Code integration. Exposes nesting operations as MCP tools
|
||||
### OpenNest (WinForms WinExe, depends on Core + Engine + IO)
|
||||
The UI application with MDI interface.
|
||||
|
||||
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
|
||||
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), `SplitDrawingForm` (split oversized drawings into smaller pieces, launched from CadConverterForm), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
|
||||
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
|
||||
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`.
|
||||
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`, `ActionCutOff`.
|
||||
- **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime.
|
||||
|
||||
## File Format
|
||||
|
||||
Nest files (`.nest`, ZIP-based) use v2 JSON format:
|
||||
- `nest.json` — single JSON file containing all nest metadata: nest info (name, units, customer, dates, notes), plate defaults (size, thickness, quadrant, spacing, material, edge spacing), drawings array (id, name, color, quantity, priority, rotation constraints, material, source), and plates array (id, size, material, edge spacing, parts with drawingId/x/y/rotation)
|
||||
- `nest.json` — single JSON file containing all nest metadata: nest info (name, units, customer, dates, notes), plate defaults (size, thickness, quadrant, spacing, material, edge spacing), drawings array (id, name, color, quantity, priority, rotation constraints, material, source), and plates array (id, size, material, edge spacing, parts with drawingId/x/y/rotation, cutoffs with x/y/axis/startLimit/endLimit)
|
||||
- `programs/program-N` — G-code text for each drawing's cut program (N = drawing id)
|
||||
- `bestfits/bestfit-N` — JSON array of best-fit pair evaluation results per drawing, keyed by plate size/spacing (optional, only present if best-fit data was computed)
|
||||
|
||||
@@ -100,6 +105,8 @@ Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary metho
|
||||
|
||||
Always keep `README.md` and `CLAUDE.md` up to date when making changes that affect project structure, architecture, build instructions, dependencies, or key patterns. If you add a new project, change a namespace, modify the build process, or alter significant behavior, update both files as part of the same change.
|
||||
|
||||
**Do not commit** design specs, implementation plans, or other temporary planning documents (`docs/superpowers/` etc.) to the repository. These are working documents only — keep them local and untracked.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.
|
||||
@@ -110,3 +117,6 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
|
||||
- Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`.
|
||||
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
|
||||
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies.
|
||||
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).
|
||||
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
|
||||
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
|
||||
|
||||
@@ -5,8 +5,6 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
@@ -25,20 +23,26 @@ public static class NestRunner
|
||||
|
||||
// 1. Import DXFs → Drawings
|
||||
var drawings = new List<Drawing>();
|
||||
var importer = new DxfImporter();
|
||||
|
||||
foreach (var part in request.Parts)
|
||||
{
|
||||
if (!File.Exists(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}");
|
||||
|
||||
var pgm = ConvertGeometry.ToProgram(geometry);
|
||||
var name = Path.GetFileNameWithoutExtension(part.DxfPath);
|
||||
var drawing = new Drawing(name);
|
||||
drawing.Program = pgm;
|
||||
drawings.Add(drawing);
|
||||
}
|
||||
|
||||
@@ -58,6 +62,8 @@ public static class NestRunner
|
||||
|
||||
// 3. Multi-plate loop
|
||||
var nest = new Nest();
|
||||
nest.Thickness = request.Thickness;
|
||||
nest.Material = new Material(request.Material);
|
||||
var remaining = items.Select(item => item.Quantity).ToList();
|
||||
|
||||
while (remaining.Any(q => q > 0))
|
||||
@@ -66,9 +72,7 @@ public static class NestRunner
|
||||
|
||||
var plate = new Plate(request.SheetSize)
|
||||
{
|
||||
Thickness = request.Thickness,
|
||||
PartSpacing = request.Spacing,
|
||||
Material = new Material(request.Material)
|
||||
};
|
||||
|
||||
// Build items for this pass with remaining quantities
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using OpenNest;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using System;
|
||||
@@ -7,6 +6,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
|
||||
return NestConsole.Run(args);
|
||||
@@ -20,6 +20,12 @@ static class NestConsole
|
||||
if (options == null)
|
||||
return 0; // --help was requested
|
||||
|
||||
if (options.ListPosts)
|
||||
{
|
||||
ListPostProcessors(options);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (options.InputFiles.Count == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
@@ -35,7 +41,6 @@ static class NestConsole
|
||||
}
|
||||
}
|
||||
|
||||
using var log = SetUpLog(options);
|
||||
var nest = LoadOrCreateNest(options);
|
||||
|
||||
if (nest == null)
|
||||
@@ -62,12 +67,9 @@ static class NestConsole
|
||||
|
||||
var overlapCount = CheckOverlaps(plate, options);
|
||||
|
||||
// Flush and close the log before printing results.
|
||||
Trace.Flush();
|
||||
log?.Dispose();
|
||||
|
||||
PrintResults(success, plate, elapsed);
|
||||
Save(nest, options);
|
||||
PostProcess(nest, options);
|
||||
|
||||
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
|
||||
}
|
||||
@@ -105,9 +107,6 @@ static class NestConsole
|
||||
case "--no-save":
|
||||
o.NoSave = true;
|
||||
break;
|
||||
case "--no-log":
|
||||
o.NoLog = true;
|
||||
break;
|
||||
case "--keep-parts":
|
||||
o.KeepParts = true;
|
||||
break;
|
||||
@@ -120,6 +119,18 @@ static class NestConsole
|
||||
case "--engine" when i + 1 < args.Length:
|
||||
NestEngineRegistry.ActiveEngineName = args[++i];
|
||||
break;
|
||||
case "--post" when i + 1 < args.Length:
|
||||
o.PostName = args[++i];
|
||||
break;
|
||||
case "--post-output" when i + 1 < args.Length:
|
||||
o.PostOutput = args[++i];
|
||||
break;
|
||||
case "--posts-dir" when i + 1 < args.Length:
|
||||
o.PostsDir = args[++i];
|
||||
break;
|
||||
case "--list-posts":
|
||||
o.ListPosts = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
PrintUsage();
|
||||
@@ -134,21 +145,6 @@ static class NestConsole
|
||||
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)
|
||||
{
|
||||
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
||||
@@ -221,30 +217,15 @@ static class NestConsole
|
||||
|
||||
static Drawing ImportDxf(string path)
|
||||
{
|
||||
var importer = new DxfImporter();
|
||||
|
||||
if (!importer.GetGeometry(path, out var geometry))
|
||||
try
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
if (geometry.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: no geometry found in DXF file: {path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var pgm = ConvertGeometry.ToProgram(geometry);
|
||||
|
||||
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)
|
||||
@@ -258,10 +239,9 @@ static class NestConsole
|
||||
return;
|
||||
}
|
||||
|
||||
var templatePlate = new NestReader(options.TemplateFile).Read().PlateDefaults.CreateNew();
|
||||
plate.Thickness = templatePlate.Thickness;
|
||||
var templateNest = new NestReader(options.TemplateFile).Read();
|
||||
var templatePlate = templateNest.PlateDefaults.CreateNew();
|
||||
plate.Quadrant = templatePlate.Quadrant;
|
||||
plate.Material = templatePlate.Material;
|
||||
plate.EdgeSpacing = templatePlate.EdgeSpacing;
|
||||
plate.PartSpacing = templatePlate.PartSpacing;
|
||||
Console.WriteLine($"Template: {options.TemplateFile}");
|
||||
@@ -382,6 +362,100 @@ static class NestConsole
|
||||
Console.WriteLine($"Saved: {outputFile}");
|
||||
}
|
||||
|
||||
static string ResolvePostsDir(Options options)
|
||||
{
|
||||
if (options.PostsDir != null)
|
||||
return options.PostsDir;
|
||||
|
||||
var exePath = Assembly.GetEntryAssembly()?.Location
|
||||
?? typeof(NestConsole).Assembly.Location;
|
||||
return Path.Combine(Path.GetDirectoryName(exePath), "Posts");
|
||||
}
|
||||
|
||||
static List<IPostProcessor> LoadPostProcessors(string postsDir)
|
||||
{
|
||||
var processors = new List<IPostProcessor>();
|
||||
|
||||
if (!Directory.Exists(postsDir))
|
||||
return processors;
|
||||
|
||||
foreach (var file in Directory.GetFiles(postsDir, "*.dll"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.LoadFrom(file);
|
||||
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
if (!typeof(IPostProcessor).IsAssignableFrom(type) || type.IsInterface || type.IsAbstract)
|
||||
continue;
|
||||
|
||||
if (Activator.CreateInstance(type) is IPostProcessor processor)
|
||||
processors.Add(processor);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Warning: failed to load post processor from {Path.GetFileName(file)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return processors;
|
||||
}
|
||||
|
||||
static void ListPostProcessors(Options options)
|
||||
{
|
||||
var postsDir = ResolvePostsDir(options);
|
||||
var processors = LoadPostProcessors(postsDir);
|
||||
|
||||
if (processors.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"No post processors found in: {postsDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Post processors ({postsDir}):");
|
||||
|
||||
foreach (var p in processors)
|
||||
Console.WriteLine($" {p.Name,-30} {p.Description}");
|
||||
}
|
||||
|
||||
static void PostProcess(Nest nest, Options options)
|
||||
{
|
||||
if (options.PostName == null)
|
||||
return;
|
||||
|
||||
var postsDir = ResolvePostsDir(options);
|
||||
var processors = LoadPostProcessors(postsDir);
|
||||
var post = processors.FirstOrDefault(p =>
|
||||
p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (post == null)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: post processor '{options.PostName}' not found");
|
||||
|
||||
if (processors.Count > 0)
|
||||
Console.Error.WriteLine($"Available: {string.Join(", ", processors.Select(p => p.Name))}");
|
||||
else
|
||||
Console.Error.WriteLine($"No post processors found in: {postsDir}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var outputFile = options.PostOutput;
|
||||
|
||||
if (outputFile == null)
|
||||
{
|
||||
var firstInput = options.InputFiles[0];
|
||||
outputFile = Path.Combine(
|
||||
Path.GetDirectoryName(firstInput),
|
||||
$"{Path.GetFileNameWithoutExtension(firstInput)}.cnc");
|
||||
}
|
||||
|
||||
post.Post(nest, outputFile);
|
||||
Console.WriteLine($"Post: {post.Name} -> {outputFile}");
|
||||
}
|
||||
|
||||
static void PrintUsage()
|
||||
{
|
||||
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
|
||||
@@ -406,7 +480,10 @@ static class NestConsole
|
||||
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
||||
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
|
||||
Console.Error.WriteLine(" --no-save Skip saving output file");
|
||||
Console.Error.WriteLine(" --no-log Skip writing debug log file");
|
||||
Console.Error.WriteLine(" --post <name> Run a post processor after nesting");
|
||||
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)");
|
||||
Console.Error.WriteLine(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)");
|
||||
Console.Error.WriteLine(" --list-posts List available post processors and exit");
|
||||
Console.Error.WriteLine(" -h, --help Show this help");
|
||||
}
|
||||
|
||||
@@ -421,9 +498,12 @@ static class NestConsole
|
||||
public Size? PlateSize;
|
||||
public bool CheckOverlaps;
|
||||
public bool NoSave;
|
||||
public bool NoLog;
|
||||
public bool KeepParts;
|
||||
public bool AutoNest;
|
||||
public string TemplateFile;
|
||||
public string PostName;
|
||||
public string PostOutput;
|
||||
public string PostsDir;
|
||||
public bool ListPosts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,61 +125,36 @@ namespace OpenNest
|
||||
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)
|
||||
return;
|
||||
|
||||
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 first = list[0];
|
||||
var last = list[lastIndex];
|
||||
var start = horizontal ? list[0].BoundingBox.Center.X : list[0].BoundingBox.Center.Y;
|
||||
var end = horizontal ? list[lastIndex].BoundingBox.Center.X : list[lastIndex].BoundingBox.Center.Y;
|
||||
|
||||
var start = first.BoundingBox.Center.X;
|
||||
var end = last.BoundingBox.Center.X;
|
||||
var diff = end - start;
|
||||
var spacing = (end - start) / lastIndex;
|
||||
|
||||
var spacing = diff / lastIndex;
|
||||
|
||||
for (int i = 1; i < lastIndex; ++i)
|
||||
for (var i = 1; i < lastIndex; ++i)
|
||||
{
|
||||
var part = list[i];
|
||||
var newX = start + i * spacing;
|
||||
var curX = part.BoundingBox.Center.X;
|
||||
var cur = horizontal ? part.BoundingBox.Center.X : part.BoundingBox.Center.Y;
|
||||
var delta = start + i * spacing - cur;
|
||||
|
||||
part.Offset(newX - curX, 0);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
part.Offset(horizontal ? delta : 0, horizontal ? 0 : delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
97
OpenNest.Core/Bending/Bend.cs
Normal file
97
OpenNest.Core/Bending/Bend.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
|
||||
namespace OpenNest.Bending
|
||||
{
|
||||
public class Bend
|
||||
{
|
||||
public static readonly Layer EtchLayer = new Layer("ETCH")
|
||||
{
|
||||
Color = Color.Green,
|
||||
IsVisible = true
|
||||
};
|
||||
|
||||
private const double DefaultEtchLength = 1.0;
|
||||
private const string BendEtchTag = "BendEtch";
|
||||
|
||||
public Vector StartPoint { get; set; }
|
||||
public Vector EndPoint { get; set; }
|
||||
public BendDirection Direction { get; set; }
|
||||
public double? Angle { get; set; }
|
||||
public double? Radius { get; set; }
|
||||
public string NoteText { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public Entity SourceEntity { get; set; }
|
||||
|
||||
public double Length => StartPoint.DistanceTo(EndPoint);
|
||||
|
||||
public double AngleRadians => Angle.HasValue
|
||||
? OpenNest.Math.Angle.ToRadians(Angle.Value)
|
||||
: 0;
|
||||
|
||||
public Line ToLine() => new Line(StartPoint, EndPoint);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the angle of the bend line itself (not the bend angle).
|
||||
/// Used for grain direction comparison.
|
||||
/// </summary>
|
||||
public double LineAngle => StartPoint.AngleTo(EndPoint);
|
||||
|
||||
/// <summary>
|
||||
/// Generates etch mark entities for this bend (up bends only).
|
||||
/// Returns 1" dashes at each end of the bend line, or the full line if shorter than 3".
|
||||
/// </summary>
|
||||
public List<Line> GetEtchEntities(double etchLength = DefaultEtchLength)
|
||||
{
|
||||
var result = new List<Line>();
|
||||
if (Direction != BendDirection.Up)
|
||||
return result;
|
||||
|
||||
var length = Length;
|
||||
|
||||
if (length < etchLength * 3.0)
|
||||
{
|
||||
result.Add(CreateEtchLine(StartPoint, EndPoint));
|
||||
}
|
||||
else
|
||||
{
|
||||
var angle = StartPoint.AngleTo(EndPoint);
|
||||
var dx = System.Math.Cos(angle) * etchLength;
|
||||
var dy = System.Math.Sin(angle) * etchLength;
|
||||
|
||||
result.Add(CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy)));
|
||||
result.Add(CreateEtchLine(new Vector(EndPoint.X - dx, EndPoint.Y - dy), EndPoint));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes existing etch entities from the list and regenerates from the given bends.
|
||||
/// </summary>
|
||||
public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends)
|
||||
{
|
||||
entities.RemoveAll(e => e.Tag == BendEtchTag);
|
||||
if (bends == null) return;
|
||||
|
||||
foreach (var bend in bends)
|
||||
entities.AddRange(bend.GetEtchEntities());
|
||||
}
|
||||
|
||||
private static Line CreateEtchLine(Vector start, Vector end)
|
||||
{
|
||||
return new Line(start, end) { Layer = EtchLayer, Color = Color.Green, Tag = BendEtchTag };
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var dir = Direction.ToString();
|
||||
var angle = Angle?.ToString("0.##") ?? "?";
|
||||
var radius = Radius?.ToString("0.###") ?? "?";
|
||||
return $"{dir} {angle}° R{radius}";
|
||||
}
|
||||
}
|
||||
}
|
||||
9
OpenNest.Core/Bending/BendDirection.cs
Normal file
9
OpenNest.Core/Bending/BendDirection.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace OpenNest.Bending
|
||||
{
|
||||
public enum BendDirection
|
||||
{
|
||||
Unknown,
|
||||
Up,
|
||||
Down
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
@@ -65,7 +66,9 @@ namespace OpenNest.CNC
|
||||
{
|
||||
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.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
@@ -7,69 +9,345 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public CuttingParameters Parameters { get; set; }
|
||||
|
||||
private record ContourEntry(Shape Shape, Vector Point, Entity Entity);
|
||||
|
||||
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();
|
||||
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);
|
||||
|
||||
// Find closest point on perimeter from exit point
|
||||
var perimeterPoint = profile.Perimeter.ClosestPointTo(exitPoint, out var perimeterEntity);
|
||||
// Start from the bounding box corner opposite the origin (max X, max Y)
|
||||
var bbox = entities.GetBoundingBox();
|
||||
var startCorner = new Vector(bbox.Right, bbox.Top);
|
||||
|
||||
// Chain cutouts by nearest-neighbor from perimeter point, then reverse
|
||||
// so farthest cutouts are cut first, nearest-to-perimeter cut last
|
||||
var orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterPoint);
|
||||
// Initial pass: sequence cutouts from bbox corner
|
||||
var seedPoint = startCorner;
|
||||
var orderedCutouts = SequenceCutouts(profile.Cutouts, seedPoint);
|
||||
orderedCutouts.Reverse();
|
||||
|
||||
// Build output program: cutouts first (farthest to nearest), perimeter last
|
||||
var result = new Program();
|
||||
var currentPoint = exitPoint;
|
||||
var perimeterSeed = profile.Perimeter.ClosestPointTo(seedPoint, out _);
|
||||
var cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterSeed);
|
||||
|
||||
foreach (var cutout in orderedCutouts)
|
||||
Vector perimeterPt;
|
||||
Entity perimeterEntity;
|
||||
|
||||
if (!double.IsNaN(nextPartStart.X) && cutoutEntries.Count > 0)
|
||||
{
|
||||
var contourType = DetectContourType(cutout);
|
||||
var closestPt = cutout.ClosestPointTo(currentPoint, out var entity);
|
||||
var normal = ComputeNormal(closestPt, entity, contourType);
|
||||
var winding = DetermineWinding(cutout);
|
||||
// Iterate: each pass refines the perimeter lead-in which changes
|
||||
// the internal sequence which changes the last cutout position
|
||||
for (var iter = 0; iter < 3; iter++)
|
||||
{
|
||||
var lastCutoutPt = cutoutEntries[cutoutEntries.Count - 1].Point;
|
||||
perimeterSeed = FindPerimeterIntersection(profile.Perimeter, lastCutoutPt, nextPartStart, out _);
|
||||
|
||||
var leadIn = SelectLeadIn(contourType);
|
||||
var leadOut = SelectLeadOut(contourType);
|
||||
orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterSeed);
|
||||
orderedCutouts.Reverse();
|
||||
cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterSeed);
|
||||
}
|
||||
|
||||
result.Codes.AddRange(leadIn.Generate(closestPt, normal, winding));
|
||||
var reindexed = cutout.ReindexAt(closestPt, entity);
|
||||
result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt));
|
||||
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
|
||||
result.Codes.AddRange(leadOut.Generate(closestPt, normal, winding));
|
||||
|
||||
currentPoint = closestPt;
|
||||
var finalLastCutout = cutoutEntries[cutoutEntries.Count - 1].Point;
|
||||
perimeterPt = FindPerimeterIntersection(profile.Perimeter, finalLastCutout, nextPartStart, out perimeterEntity);
|
||||
}
|
||||
else
|
||||
{
|
||||
var perimeterRef = cutoutEntries.Count > 0 ? cutoutEntries[0].Point : approachPoint;
|
||||
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);
|
||||
lastCutPoint = perimeterPt;
|
||||
var normal = ComputeNormal(perimeterPt, perimeterEntity, ContourType.External);
|
||||
var winding = DetermineWinding(profile.Perimeter);
|
||||
|
||||
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 (!entry.Shape.IsClosed())
|
||||
EmitRawContour(result, entry.Shape);
|
||||
else
|
||||
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
|
||||
}
|
||||
|
||||
if (!profile.Perimeter.IsClosed())
|
||||
EmitRawContour(result, profile.Perimeter);
|
||||
else
|
||||
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
|
||||
|
||||
result.Mode = Mode.Incremental;
|
||||
|
||||
return new CuttingResult
|
||||
{
|
||||
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)
|
||||
{
|
||||
var remaining = new List<Shape>(cutouts);
|
||||
@@ -102,7 +380,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private ContourType DetectContourType(Shape cutout)
|
||||
public static ContourType DetectContourType(Shape cutout)
|
||||
{
|
||||
if (cutout.Entities.Count == 1 && cutout.Entities[0] is Circle)
|
||||
return ContourType.ArcCircle;
|
||||
@@ -110,23 +388,33 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
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;
|
||||
|
||||
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);
|
||||
normal = tangent + Math.Angle.HalfPI;
|
||||
if (winding == RotationType.CCW)
|
||||
normal += System.Math.PI;
|
||||
}
|
||||
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);
|
||||
if (arc.Rotation != winding)
|
||||
normal += System.Math.PI;
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
// Radial outward — always correct regardless of winding
|
||||
normal = point.AngleFrom(circle.Center);
|
||||
}
|
||||
else
|
||||
@@ -141,11 +429,61 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return Math.Angle.NormalizeRad(normal);
|
||||
}
|
||||
|
||||
private RotationType DetermineWinding(Shape shape)
|
||||
public static RotationType DetermineWinding(Shape shape)
|
||||
{
|
||||
// Use signed area: positive = CCW, negative = CW
|
||||
var area = shape.Area();
|
||||
return area >= 0 ? RotationType.CCW : RotationType.CW;
|
||||
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
|
||||
return circle.Rotation;
|
||||
|
||||
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)
|
||||
@@ -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>();
|
||||
|
||||
@@ -176,15 +578,15 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation));
|
||||
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer });
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -194,5 +596,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
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 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 bool TabsEnabled { get; set; }
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return new List<ICode>
|
||||
{
|
||||
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),
|
||||
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>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(arcStart),
|
||||
new ArcMove(contourStartPoint, arcCenter, winding)
|
||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,5 +45,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
arcStartX + LineLength * System.Math.Cos(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);
|
||||
|
||||
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>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(arcStart),
|
||||
new ArcMove(contourStartPoint, arcCenter, winding)
|
||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,5 +45,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
arcStartX + LineLength * System.Math.Cos(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>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(contourStartPoint)
|
||||
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin }
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
contourStartPoint.X + Length * System.Math.Cos(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 secondAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle1);
|
||||
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||
var midPoint = new Vector(
|
||||
contourStartPoint.X + Length2 * System.Math.Cos(secondAngle),
|
||||
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle));
|
||||
@@ -24,14 +24,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(midPoint),
|
||||
new LinearMove(contourStartPoint)
|
||||
new LinearMove(midPoint) { Layer = LayerType.Leadin },
|
||||
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin }
|
||||
};
|
||||
}
|
||||
|
||||
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 midY = contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle);
|
||||
|
||||
@@ -40,5 +40,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
midX + Length1 * System.Math.Cos(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>
|
||||
{
|
||||
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,
|
||||
RotationType winding = RotationType.CW)
|
||||
{
|
||||
var overcutAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
||||
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
|
||||
var endPoint = new Vector(
|
||||
contourEndPoint.X + Length * System.Math.Cos(overcutAngle),
|
||||
contourEndPoint.Y + Length * System.Math.Sin(overcutAngle));
|
||||
|
||||
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 string VariableRef { get; set; }
|
||||
|
||||
public CodeType Type
|
||||
{
|
||||
get { return CodeType.SetFeedrate; }
|
||||
@@ -24,7 +26,7 @@
|
||||
|
||||
public ICode Clone()
|
||||
{
|
||||
return new Feedrate(Value);
|
||||
return new Feedrate(Value) { VariableRef = VariableRef };
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
@@ -31,7 +32,9 @@ namespace OpenNest.CNC
|
||||
{
|
||||
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
|
||||
{
|
||||
@@ -12,6 +13,10 @@ namespace OpenNest.CNC
|
||||
|
||||
public int Feedrate { get; set; }
|
||||
|
||||
public bool Suppressed { get; set; }
|
||||
|
||||
public Dictionary<string, string> VariableRefs { get; set; }
|
||||
|
||||
protected Motion()
|
||||
{
|
||||
Feedrate = CNC.Feedrate.UseDefault;
|
||||
@@ -20,21 +25,25 @@ namespace OpenNest.CNC
|
||||
public virtual void Rotate(double angle)
|
||||
{
|
||||
EndPoint = EndPoint.Rotate(angle);
|
||||
VariableRefs = null;
|
||||
}
|
||||
|
||||
public virtual void Rotate(double angle, Vector origin)
|
||||
{
|
||||
EndPoint = EndPoint.Rotate(angle, origin);
|
||||
VariableRefs = null;
|
||||
}
|
||||
|
||||
public virtual void Offset(double x, double y)
|
||||
{
|
||||
EndPoint = new Vector(EndPoint.X + x, EndPoint.Y + y);
|
||||
VariableRefs = null;
|
||||
}
|
||||
|
||||
public virtual void Offset(Vector voffset)
|
||||
{
|
||||
EndPoint += voffset;
|
||||
VariableRefs = null;
|
||||
}
|
||||
|
||||
public abstract CodeType Type { get; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
@@ -9,6 +10,10 @@ namespace OpenNest.CNC
|
||||
{
|
||||
public List<ICode> Codes;
|
||||
|
||||
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Dictionary<int, Program> SubPrograms { get; } = new();
|
||||
|
||||
private Mode mode;
|
||||
|
||||
public Program(Mode mode = Mode.Absolute)
|
||||
@@ -51,37 +56,7 @@ namespace OpenNest.CNC
|
||||
mode = Mode.Absolute;
|
||||
}
|
||||
|
||||
public virtual void Rotate(double angle)
|
||||
{
|
||||
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 virtual void Rotate(double angle) => Rotate(angle, new Vector(0, 0));
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
@@ -114,6 +89,17 @@ namespace OpenNest.CNC
|
||||
{
|
||||
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)
|
||||
subpgm.Program.Rotate(angle, origin);
|
||||
}
|
||||
@@ -142,6 +128,12 @@ namespace OpenNest.CNC
|
||||
{
|
||||
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)
|
||||
continue;
|
||||
|
||||
@@ -164,6 +156,12 @@ namespace OpenNest.CNC
|
||||
{
|
||||
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)
|
||||
continue;
|
||||
|
||||
@@ -302,6 +300,10 @@ namespace OpenNest.CNC
|
||||
|
||||
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 minY = 0.0;
|
||||
double maxX = 0.0;
|
||||
@@ -317,7 +319,7 @@ namespace OpenNest.CNC
|
||||
{
|
||||
var line = (LinearMove)code;
|
||||
var pt = Mode == Mode.Absolute ?
|
||||
line.EndPoint :
|
||||
frameOrigin + line.EndPoint :
|
||||
line.EndPoint + pos;
|
||||
|
||||
if (pt.X > maxX)
|
||||
@@ -339,7 +341,7 @@ namespace OpenNest.CNC
|
||||
{
|
||||
var line = (RapidMove)code;
|
||||
var pt = Mode == Mode.Absolute
|
||||
? line.EndPoint
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
|
||||
if (pt.X > maxX)
|
||||
@@ -372,8 +374,8 @@ namespace OpenNest.CNC
|
||||
}
|
||||
else
|
||||
{
|
||||
endpt = arc.EndPoint;
|
||||
centerpt = arc.CenterPoint;
|
||||
endpt = frameOrigin + arc.EndPoint;
|
||||
centerpt = frameOrigin + arc.CenterPoint;
|
||||
}
|
||||
|
||||
double minX1;
|
||||
@@ -447,6 +449,12 @@ namespace OpenNest.CNC
|
||||
case CodeType.SubProgramCall:
|
||||
{
|
||||
var subpgm = (SubProgramCall)code;
|
||||
if (subpgm.Program == null)
|
||||
break;
|
||||
|
||||
// Sub-program frame origin in this program's frame
|
||||
// is frameOrigin + Offset, regardless of current pos.
|
||||
pos = frameOrigin + subpgm.Offset;
|
||||
var box = subpgm.Program.BoundingBox(ref pos);
|
||||
|
||||
if (box.Left < minX)
|
||||
@@ -484,6 +492,12 @@ namespace OpenNest.CNC
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
18
OpenNest.Core/CNC/ProgramVariable.cs
Normal file
18
OpenNest.Core/CNC/ProgramVariable.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public sealed class ProgramVariable
|
||||
{
|
||||
public int Number { get; }
|
||||
public string Name { get; }
|
||||
public string Expression { get; set; }
|
||||
|
||||
public ProgramVariable(int number, string name, string expression = null)
|
||||
{
|
||||
Number = number;
|
||||
Name = name;
|
||||
Expression = expression;
|
||||
}
|
||||
|
||||
public string Reference => $"#{Number}";
|
||||
}
|
||||
}
|
||||
43
OpenNest.Core/CNC/ProgramVariableManager.cs
Normal file
43
OpenNest.Core/CNC/ProgramVariableManager.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public sealed class ProgramVariableManager
|
||||
{
|
||||
private readonly Dictionary<int, ProgramVariable> _variables = new();
|
||||
|
||||
public ProgramVariable GetOrCreate(string name, int number, string expression = null)
|
||||
{
|
||||
if (_variables.TryGetValue(number, out var existing))
|
||||
return existing;
|
||||
|
||||
var variable = new ProgramVariable(number, name, expression);
|
||||
_variables[number] = variable;
|
||||
return variable;
|
||||
}
|
||||
|
||||
public List<string> EmitDeclarations()
|
||||
{
|
||||
return _variables.Values
|
||||
.Where(v => v.Expression != null)
|
||||
.OrderBy(v => v.Number)
|
||||
.Select(v => $"{v.Reference}={v.Expression} ({FormatComment(v.Name)})")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string FormatComment(string name)
|
||||
{
|
||||
// "LeadInFeedrate" -> "LEAD IN FEEDRATE"
|
||||
var sb = new StringBuilder();
|
||||
foreach (var c in name)
|
||||
{
|
||||
if (char.IsUpper(c) && sb.Length > 0)
|
||||
sb.Append(' ');
|
||||
sb.Append(char.ToUpper(c));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
80
OpenNest.Core/CNC/RapidEnumerator.cs
Normal file
80
OpenNest.Core/CNC/RapidEnumerator.cs
Normal file
@@ -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
|
||||
{
|
||||
@@ -26,7 +27,11 @@ namespace OpenNest.CNC
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using OpenNest.Math;
|
||||
using System.Text;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
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>
|
||||
/// Gets or sets the rotation of the program in degrees.
|
||||
/// </summary>
|
||||
@@ -78,12 +86,18 @@ namespace OpenNest.CNC
|
||||
/// <returns></returns>
|
||||
public ICode Clone()
|
||||
{
|
||||
return new SubProgramCall(program, Rotation);
|
||||
return new SubProgramCall(program, Rotation) { Id = Id, Offset = Offset };
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
OpenNest.Core/CNC/VariableDefinition.cs
Normal file
21
OpenNest.Core/CNC/VariableDefinition.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,8 @@ namespace OpenNest.Collections
|
||||
public bool Remove(T 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;
|
||||
}
|
||||
|
||||
|
||||
137
OpenNest.Core/Converters/ContourInfo.cs
Normal file
137
OpenNest.Core/Converters/ContourInfo.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ namespace OpenNest.Converters
|
||||
if (startpt != lastpt)
|
||||
pgm.MoveTo(startpt);
|
||||
|
||||
pgm.ArcTo(startpt, circle.Center, RotationType.CCW);
|
||||
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
|
||||
|
||||
lastpt = startpt;
|
||||
return lastpt;
|
||||
@@ -108,7 +108,10 @@ namespace OpenNest.Converters
|
||||
if (line.StartPoint != lastpt)
|
||||
pgm.MoveTo(line.StartPoint);
|
||||
|
||||
pgm.LineTo(line.EndPoint);
|
||||
var move = new LinearMove(line.EndPoint);
|
||||
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
||||
move.Layer = LayerType.Scribe;
|
||||
pgm.Codes.Add(move);
|
||||
|
||||
lastpt = line.EndPoint;
|
||||
return lastpt;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Converters
|
||||
@@ -9,7 +9,6 @@ namespace OpenNest.Converters
|
||||
/// Converts the program to absolute coordinates.
|
||||
/// Does NOT check program mode before converting.
|
||||
/// </summary>
|
||||
/// <param name="pgm"></param>
|
||||
public static void ToAbsolute(Program pgm)
|
||||
{
|
||||
var pos = new Vector(0, 0);
|
||||
@@ -17,21 +16,27 @@ namespace OpenNest.Converters
|
||||
for (int i = 0; i < pgm.Codes.Count; ++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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the program to intermental coordinates.
|
||||
/// Converts the program to incremental coordinates.
|
||||
/// Does NOT check program mode before converting.
|
||||
/// </summary>
|
||||
/// <param name="pgm"></param>
|
||||
public static void ToIncremental(Program pgm)
|
||||
{
|
||||
var pos = new Vector(0, 0);
|
||||
@@ -39,9 +44,16 @@ namespace OpenNest.Converters
|
||||
for (int i = 0; i < pgm.Codes.Count; ++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;
|
||||
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)
|
||||
{
|
||||
// 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;
|
||||
|
||||
for (int i = 0; i < program.Length; ++i)
|
||||
@@ -41,12 +44,15 @@ namespace OpenNest.Converters
|
||||
break;
|
||||
|
||||
case CodeType.SubProgramCall:
|
||||
var tmpmode = mode;
|
||||
var subpgm = (SubProgramCall)code;
|
||||
var geoProgram = new Shape();
|
||||
AddProgram(subpgm.Program, ref mode, ref curpos, ref geoProgram.Entities);
|
||||
geometry.Add(geoProgram);
|
||||
mode = tmpmode;
|
||||
var savedMode = mode;
|
||||
|
||||
// The sub-program's frame origin in this program's frame is
|
||||
// frameOrigin + Offset — independent of current tool position.
|
||||
curpos = new Vector(frameOrigin.X + subpgm.Offset.X, frameOrigin.Y + subpgm.Offset.Y);
|
||||
|
||||
AddProgram(subpgm.Program, ref mode, ref curpos, ref geometry);
|
||||
mode = savedMode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -59,9 +65,11 @@ namespace OpenNest.Converters
|
||||
if (mode == Mode.Incremental)
|
||||
pt += curpos;
|
||||
|
||||
var layer = ConvertLayer(linearMove.Layer);
|
||||
var line = new Line(curpos, pt)
|
||||
{
|
||||
Layer = ConvertLayer(linearMove.Layer)
|
||||
Layer = layer,
|
||||
Color = layer.Color
|
||||
};
|
||||
geometry.Add(line);
|
||||
curpos = pt;
|
||||
@@ -76,7 +84,8 @@ namespace OpenNest.Converters
|
||||
|
||||
var line = new Line(curpos, pt)
|
||||
{
|
||||
Layer = SpecialLayers.Rapid
|
||||
Layer = SpecialLayers.Rapid,
|
||||
Color = SpecialLayers.Rapid.Color
|
||||
};
|
||||
geometry.Add(line);
|
||||
curpos = pt;
|
||||
@@ -103,9 +112,9 @@ namespace OpenNest.Converters
|
||||
var layer = ConvertLayer(arcMove.Layer);
|
||||
|
||||
if (startAngle.IsEqualTo(endAngle))
|
||||
geometry.Add(new Circle(center, radius) { Layer = layer });
|
||||
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color, Rotation = arcMove.Rotation });
|
||||
else
|
||||
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer });
|
||||
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer, Color = layer.Color });
|
||||
|
||||
curpos = endpt;
|
||||
}
|
||||
|
||||
211
OpenNest.Core/CutOff.cs
Normal file
211
OpenNest.Core/CutOff.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum CutOffAxis
|
||||
{
|
||||
Horizontal,
|
||||
Vertical
|
||||
}
|
||||
|
||||
public class CutOff
|
||||
{
|
||||
public Vector Position { get; set; }
|
||||
public CutOffAxis Axis { get; set; }
|
||||
public double? StartLimit { get; set; }
|
||||
public double? EndLimit { get; set; }
|
||||
public Drawing Drawing { get; private set; }
|
||||
|
||||
public CutOff(Vector position, CutOffAxis axis)
|
||||
{
|
||||
Position = position;
|
||||
Axis = axis;
|
||||
Drawing = new Drawing(GetName()) { IsCutOff = true };
|
||||
}
|
||||
|
||||
public void Regenerate(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache = null)
|
||||
{
|
||||
var segments = ComputeSegments(plate, settings, cache);
|
||||
var program = BuildProgram(segments, settings);
|
||||
Drawing.Program = program;
|
||||
}
|
||||
|
||||
private string GetName()
|
||||
{
|
||||
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
|
||||
var coord = Axis == CutOffAxis.Vertical ? Position.X : Position.Y;
|
||||
return $"CutOff-{axisChar}-{coord:F2}";
|
||||
}
|
||||
|
||||
private List<(double Start, double End)> ComputeSegments(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache)
|
||||
{
|
||||
var bounds = plate.BoundingBox(includeParts: false);
|
||||
|
||||
double lineStart, lineEnd, cutPosition;
|
||||
|
||||
if (Axis == CutOffAxis.Vertical)
|
||||
{
|
||||
cutPosition = Position.X;
|
||||
lineStart = StartLimit ?? bounds.Y;
|
||||
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + settings.Overtravel);
|
||||
}
|
||||
else
|
||||
{
|
||||
cutPosition = Position.Y;
|
||||
lineStart = StartLimit ?? bounds.X;
|
||||
lineEnd = EndLimit ?? (bounds.X + bounds.Length + settings.Overtravel);
|
||||
}
|
||||
|
||||
var exclusions = new List<(double Start, double End)>();
|
||||
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
Entity perimeter = null;
|
||||
cache?.TryGetValue(part, out perimeter);
|
||||
var partExclusions = GetPartExclusions(part, perimeter, cutPosition, lineStart, lineEnd, settings.PartClearance);
|
||||
exclusions.AddRange(partExclusions);
|
||||
}
|
||||
|
||||
exclusions.Sort((a, b) => a.Start.CompareTo(b.Start));
|
||||
var merged = new List<(double Start, double End)>();
|
||||
foreach (var ex in exclusions)
|
||||
{
|
||||
if (merged.Count > 0 && ex.Start <= merged[^1].End)
|
||||
merged[^1] = (merged[^1].Start, System.Math.Max(merged[^1].End, ex.End));
|
||||
else
|
||||
merged.Add(ex);
|
||||
}
|
||||
|
||||
var segments = new List<(double Start, double End)>();
|
||||
var current = lineStart;
|
||||
|
||||
foreach (var ex in merged)
|
||||
{
|
||||
var clampedStart = System.Math.Max(ex.Start, lineStart);
|
||||
var clampedEnd = System.Math.Min(ex.End, lineEnd);
|
||||
|
||||
if (clampedStart > current)
|
||||
segments.Add((current, clampedStart));
|
||||
|
||||
current = System.Math.Max(current, clampedEnd);
|
||||
}
|
||||
|
||||
if (current < lineEnd)
|
||||
segments.Add((current, lineEnd));
|
||||
|
||||
segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList();
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
private static readonly List<(double Start, double End)> EmptyExclusions = new();
|
||||
|
||||
private List<(double Start, double End)> GetPartExclusions(
|
||||
Part part, Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
var (partMin, partMax) = AxisBounds(bb, clearance);
|
||||
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
|
||||
|
||||
if (cutPosition < partMin || cutPosition > partMax)
|
||||
return EmptyExclusions;
|
||||
|
||||
if (perimeter != null)
|
||||
{
|
||||
var perimeterExclusions = IntersectPerimeter(perimeter, cutPosition, lineStart, lineEnd, clearance);
|
||||
if (perimeterExclusions != null)
|
||||
return perimeterExclusions;
|
||||
}
|
||||
|
||||
return new List<(double Start, double End)> { (partStart, partEnd) };
|
||||
}
|
||||
|
||||
private List<(double Start, double End)> IntersectPerimeter(
|
||||
Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance)
|
||||
{
|
||||
var target = OffsetOutward(perimeter, clearance) ?? perimeter;
|
||||
var usedOffset = target != perimeter;
|
||||
var cutLine = new Line(MakePoint(cutPosition, lineStart), MakePoint(cutPosition, lineEnd));
|
||||
|
||||
if (!target.Intersects(cutLine, out var pts) || pts.Count < 2)
|
||||
return null;
|
||||
|
||||
var coords = pts
|
||||
.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
|
||||
.OrderBy(c => c)
|
||||
.ToList();
|
||||
|
||||
if (coords.Count % 2 != 0)
|
||||
return null;
|
||||
|
||||
var padding = usedOffset ? 0 : clearance;
|
||||
var result = new List<(double Start, double End)>();
|
||||
for (var i = 0; i < coords.Count; i += 2)
|
||||
result.Add((coords[i] - padding, coords[i + 1] + padding));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Entity OffsetOutward(Entity perimeter, double clearance)
|
||||
{
|
||||
if (clearance <= 0)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var offset = perimeter.OffsetEntity(clearance, OffsetSide.Left);
|
||||
offset?.UpdateBounds();
|
||||
return offset;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Vector MakePoint(double cutCoord, double lineCoord) =>
|
||||
Axis == CutOffAxis.Vertical
|
||||
? new Vector(cutCoord, lineCoord)
|
||||
: new Vector(lineCoord, cutCoord);
|
||||
|
||||
private (double Min, double Max) AxisBounds(Box bb, double clearance) =>
|
||||
Axis == CutOffAxis.Vertical
|
||||
? (bb.X - clearance, bb.X + bb.Length + clearance)
|
||||
: (bb.Y - clearance, bb.Y + bb.Width + clearance);
|
||||
|
||||
private (double Start, double End) CrossAxisBounds(Box bb, double clearance) =>
|
||||
Axis == CutOffAxis.Vertical
|
||||
? (bb.Y - clearance, bb.Y + bb.Width + clearance)
|
||||
: (bb.X - clearance, bb.X + bb.Length + clearance);
|
||||
|
||||
private Program BuildProgram(List<(double Start, double End)> segments, CutOffSettings settings)
|
||||
{
|
||||
var program = new Program();
|
||||
|
||||
if (segments.Count == 0)
|
||||
return program;
|
||||
|
||||
var toward = settings.CutDirection == CutDirection.TowardOrigin;
|
||||
segments = toward
|
||||
? segments.OrderByDescending(s => s.Start).ToList()
|
||||
: segments.OrderBy(s => s.Start).ToList();
|
||||
|
||||
var cutPos = Axis == CutOffAxis.Vertical ? Position.X : Position.Y;
|
||||
|
||||
foreach (var seg in segments)
|
||||
{
|
||||
var (from, to) = toward ? (seg.End, seg.Start) : (seg.Start, seg.End);
|
||||
program.Codes.Add(new RapidMove(MakePoint(cutPos, from)));
|
||||
program.Codes.Add(new LinearMove(MakePoint(cutPos, to)));
|
||||
}
|
||||
|
||||
return program;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
OpenNest.Core/CutOffSettings.cs
Normal file
16
OpenNest.Core/CutOffSettings.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum CutDirection
|
||||
{
|
||||
TowardOrigin,
|
||||
AwayFromOrigin
|
||||
}
|
||||
|
||||
public class CutOffSettings
|
||||
{
|
||||
public double PartClearance { get; set; } = 0.02;
|
||||
public double Overtravel { get; set; }
|
||||
public double MinSegmentLength { get; set; } = 0.05;
|
||||
public CutDirection CutDirection { get; set; } = CutDirection.AwayFromOrigin;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Bending;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -10,8 +13,32 @@ namespace OpenNest
|
||||
public class Drawing
|
||||
{
|
||||
private static int nextId;
|
||||
private static int nextColorIndex;
|
||||
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()
|
||||
: this(string.Empty, new Program())
|
||||
{
|
||||
@@ -56,10 +83,26 @@ namespace OpenNest
|
||||
|
||||
public Color Color { get; set; }
|
||||
|
||||
public bool IsCutOff { get; set; }
|
||||
|
||||
public NestConstraints Constraints { get; set; }
|
||||
|
||||
public SourceInfo Source { get; set; }
|
||||
|
||||
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 void UpdateArea()
|
||||
|
||||
@@ -155,6 +155,17 @@ namespace OpenNest.Geometry
|
||||
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>
|
||||
/// Splits the arc at the given point, returning two sub-arcs.
|
||||
/// Either half may be null if the split point coincides with an endpoint.
|
||||
@@ -256,6 +267,13 @@ namespace OpenNest.Geometry
|
||||
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>
|
||||
/// Reverses the rotation direction.
|
||||
/// </summary>
|
||||
@@ -409,8 +427,8 @@ namespace OpenNest.Geometry
|
||||
|
||||
boundingBox.X = minX;
|
||||
boundingBox.Y = minY;
|
||||
boundingBox.Width = maxX - minX;
|
||||
boundingBox.Length = maxY - minY;
|
||||
boundingBox.Length = maxX - minX;
|
||||
boundingBox.Width = maxY - minY;
|
||||
}
|
||||
|
||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||
|
||||
130
OpenNest.Core/Geometry/ArcFit.cs
Normal file
130
OpenNest.Core/Geometry/ArcFit.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
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 constrained to be tangent to the given directions at both
|
||||
/// the first and last points. The center lies at the intersection of the normals
|
||||
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives
|
||||
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent);
|
||||
/// deviation includes any endpoint gap at Pn.
|
||||
/// </summary>
|
||||
internal static (Vector center, double radius, double deviation) FitWithDualTangent(
|
||||
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 stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y);
|
||||
var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y);
|
||||
if (stLen < 1e-10 || etLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Normal to start tangent at P1 (perpendicular)
|
||||
var n1x = -startTangent.Y / stLen;
|
||||
var n1y = startTangent.X / stLen;
|
||||
|
||||
// Normal to end tangent at Pn
|
||||
var n2x = -endTangent.Y / etLen;
|
||||
var n2y = endTangent.X / etLen;
|
||||
|
||||
// Solve: P1 + t1*N1 = Pn + t2*N2
|
||||
var det = n1x * (-n2y) - (-n2x) * n1y;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var t1 = (dx * (-n2y) - (-n2x) * dy) / det;
|
||||
|
||||
var cx = p1.X + t1 * n1x;
|
||||
var cy = p1.Y + t1 * n1y;
|
||||
|
||||
// Use radius from P1 (guarantees exact start tangent and passes through P1)
|
||||
var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
||||
if (r1 < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Measure endpoint gap at Pn
|
||||
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y));
|
||||
var endpointDev = System.Math.Abs(r2 - r1);
|
||||
|
||||
var interiorDev = MaxRadialDeviation(points, cx, cy, r1);
|
||||
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
|
||||
}
|
||||
|
||||
/// <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 minY = boxes[0].Y;
|
||||
double maxX = boxes[0].X + boxes[0].Width;
|
||||
double maxY = boxes[0].Y + boxes[0].Length;
|
||||
double maxX = boxes[0].Right;
|
||||
double maxY = boxes[0].Top;
|
||||
|
||||
foreach (var box in boxes)
|
||||
{
|
||||
|
||||
@@ -14,15 +14,15 @@ namespace OpenNest.Geometry
|
||||
public Box(double x, double y, double w, double h)
|
||||
{
|
||||
Location = new Vector(x, y);
|
||||
Width = w;
|
||||
Length = h;
|
||||
Length = w;
|
||||
Width = h;
|
||||
}
|
||||
|
||||
public Vector Location;
|
||||
|
||||
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;
|
||||
@@ -74,6 +74,16 @@ namespace OpenNest.Geometry
|
||||
Location += voffset;
|
||||
}
|
||||
|
||||
public Box Translate(double x, double y)
|
||||
{
|
||||
return new Box(X + x, Y + y, Length, Width);
|
||||
}
|
||||
|
||||
public Box Translate(Vector offset)
|
||||
{
|
||||
return new Box(X + offset.X, Y + offset.Y, Length, Width);
|
||||
}
|
||||
|
||||
public double Left
|
||||
{
|
||||
get { return X; }
|
||||
@@ -81,12 +91,12 @@ namespace OpenNest.Geometry
|
||||
|
||||
public double Right
|
||||
{
|
||||
get { return X + Width; }
|
||||
get { return X + Length; }
|
||||
}
|
||||
|
||||
public double Top
|
||||
{
|
||||
get { return Y + Length; }
|
||||
get { return Y + Width; }
|
||||
}
|
||||
|
||||
public double Bottom
|
||||
@@ -197,7 +207,7 @@ namespace OpenNest.Geometry
|
||||
|
||||
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()
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
var x = large.Left;
|
||||
var y = small.Top;
|
||||
var w = large.Width;
|
||||
var w = large.Length;
|
||||
var h = large.Top - y;
|
||||
|
||||
return new Box(x, y, w, h);
|
||||
@@ -23,7 +23,7 @@
|
||||
var x = large.Left;
|
||||
var y = large.Bottom;
|
||||
var w = small.Left - x;
|
||||
var h = large.Length;
|
||||
var h = large.Width;
|
||||
|
||||
return new Box(x, y, w, h);
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
var x = large.Left;
|
||||
var y = large.Bottom;
|
||||
var w = large.Width;
|
||||
var w = large.Length;
|
||||
var h = small.Top - y;
|
||||
|
||||
return new Box(x, y, w, h);
|
||||
@@ -49,7 +49,7 @@
|
||||
var x = small.Right;
|
||||
var y = large.Bottom;
|
||||
var w = large.Right - x;
|
||||
var h = large.Length;
|
||||
var h = large.Width;
|
||||
|
||||
return new Box(x, y, w, h);
|
||||
}
|
||||
|
||||
@@ -137,7 +137,9 @@ namespace OpenNest.Geometry
|
||||
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
|
||||
{
|
||||
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
|
||||
? Radius / System.Math.Cos(stepAngle / 2.0)
|
||||
@@ -163,6 +165,13 @@ namespace OpenNest.Geometry
|
||||
get { return Circumference(); }
|
||||
}
|
||||
|
||||
public override Entity Clone()
|
||||
{
|
||||
var copy = new Circle(center, radius) { Rotation = Rotation };
|
||||
CopyBaseTo(copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverses the rotation direction.
|
||||
/// </summary>
|
||||
|
||||
330
OpenNest.Core/Geometry/Collision.cs
Normal file
330
OpenNest.Core/Geometry/Collision.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
OpenNest.Core/Geometry/CollisionResult.cs
Normal file
23
OpenNest.Core/Geometry/CollisionResult.cs
Normal file
@@ -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; }
|
||||
}
|
||||
}
|
||||
245
OpenNest.Core/Geometry/EllipseConverter.cs
Normal file
245
OpenNest.Core/Geometry/EllipseConverter.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public static class EllipseConverter
|
||||
{
|
||||
private const int MaxSubdivisionDepth = 12;
|
||||
private const int DeviationSamples = 20;
|
||||
|
||||
internal static Vector EvaluatePoint(double semiMajor, double semiMinor, double rotation, Vector center, double t)
|
||||
{
|
||||
var x = semiMajor * System.Math.Cos(t);
|
||||
var y = semiMinor * System.Math.Sin(t);
|
||||
|
||||
var cos = System.Math.Cos(rotation);
|
||||
var sin = System.Math.Sin(rotation);
|
||||
|
||||
return new Vector(
|
||||
center.X + x * cos - y * sin,
|
||||
center.Y + x * sin + y * cos);
|
||||
}
|
||||
|
||||
internal static Vector EvaluateTangent(double semiMajor, double semiMinor, double rotation, double t)
|
||||
{
|
||||
var tx = -semiMajor * System.Math.Sin(t);
|
||||
var ty = semiMinor * System.Math.Cos(t);
|
||||
|
||||
var cos = System.Math.Cos(rotation);
|
||||
var sin = System.Math.Sin(rotation);
|
||||
|
||||
return new Vector(
|
||||
tx * cos - ty * sin,
|
||||
tx * sin + ty * cos);
|
||||
}
|
||||
|
||||
internal static Vector EvaluateNormal(double semiMajor, double semiMinor, double rotation, double t)
|
||||
{
|
||||
// Inward normal: perpendicular to tangent, pointing toward center of curvature.
|
||||
// In local coords: N(t) = (-b*cos(t), -a*sin(t))
|
||||
var nx = -semiMinor * System.Math.Cos(t);
|
||||
var ny = -semiMajor * System.Math.Sin(t);
|
||||
|
||||
var cos = System.Math.Cos(rotation);
|
||||
var sin = System.Math.Sin(rotation);
|
||||
|
||||
return new Vector(
|
||||
nx * cos - ny * sin,
|
||||
nx * sin + ny * cos);
|
||||
}
|
||||
|
||||
internal static Vector IntersectNormals(Vector p1, Vector n1, Vector p2, Vector n2)
|
||||
{
|
||||
// Solve: p1 + s*n1 = p2 + t*n2
|
||||
var det = n1.X * (-n2.Y) - (-n2.X) * n1.Y;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return Vector.Invalid;
|
||||
|
||||
var dx = p2.X - p1.X;
|
||||
var dy = p2.Y - p1.Y;
|
||||
var s = (dx * (-n2.Y) - dy * (-n2.X)) / det;
|
||||
|
||||
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,
|
||||
double rotation, double startParam, double endParam, double tolerance = 0.001)
|
||||
{
|
||||
if (tolerance <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(tolerance), "Tolerance must be positive.");
|
||||
if (semiMajor <= 0 || semiMinor <= 0)
|
||||
throw new ArgumentOutOfRangeException("Semi-axis lengths must be positive.");
|
||||
|
||||
if (endParam <= startParam)
|
||||
endParam += Angle.TwoPI;
|
||||
|
||||
// True circle — emit a single arc (or two for full circle)
|
||||
if (System.Math.Abs(semiMajor - semiMinor) < Tolerance.Epsilon)
|
||||
return ConvertCircle(center, semiMajor, rotation, startParam, endParam);
|
||||
|
||||
var splits = GetInitialSplits(startParam, endParam);
|
||||
|
||||
var entities = new List<Entity>();
|
||||
for (var i = 0; i < splits.Count - 1; i++)
|
||||
FitSegment(center, semiMajor, semiMinor, rotation,
|
||||
splits[i], splits[i + 1], tolerance, entities, 0);
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static List<Entity> ConvertCircle(Vector center, double radius,
|
||||
double rotation, double startParam, double endParam)
|
||||
{
|
||||
var sweep = endParam - startParam;
|
||||
var isFull = System.Math.Abs(sweep - Angle.TwoPI) < 0.01;
|
||||
|
||||
if (isFull)
|
||||
{
|
||||
var startAngle1 = Angle.NormalizeRad(startParam + rotation);
|
||||
var midAngle = Angle.NormalizeRad(startParam + System.Math.PI + rotation);
|
||||
var endAngle2 = startAngle1;
|
||||
|
||||
return new List<Entity>
|
||||
{
|
||||
new Arc(center, radius, startAngle1, midAngle, false),
|
||||
new Arc(center, radius, midAngle, endAngle2, false)
|
||||
};
|
||||
}
|
||||
|
||||
var sa = Angle.NormalizeRad(startParam + rotation);
|
||||
var ea = Angle.NormalizeRad(endParam + rotation);
|
||||
return new List<Entity> { new Arc(center, radius, sa, ea, false) };
|
||||
}
|
||||
|
||||
private static List<double> GetInitialSplits(double startParam, double endParam)
|
||||
{
|
||||
var splits = new List<double> { startParam };
|
||||
|
||||
var firstQuadrant = System.Math.Ceiling(startParam / (System.Math.PI / 2)) * (System.Math.PI / 2);
|
||||
for (var q = firstQuadrant; q < endParam; q += System.Math.PI / 2)
|
||||
{
|
||||
if (q > startParam + 1e-10 && q < endParam - 1e-10)
|
||||
splits.Add(q);
|
||||
}
|
||||
|
||||
splits.Add(endParam);
|
||||
return splits;
|
||||
}
|
||||
|
||||
private static void FitSegment(Vector center, double semiMajor, double semiMinor,
|
||||
double rotation, double t0, double t1, double tolerance, List<Entity> results, int depth)
|
||||
{
|
||||
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t0);
|
||||
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t1);
|
||||
|
||||
if (p0.DistanceTo(p1) < 1e-10)
|
||||
return;
|
||||
|
||||
var n0 = EvaluateNormal(semiMajor, semiMinor, rotation, t0);
|
||||
var n1 = EvaluateNormal(semiMajor, semiMinor, rotation, t1);
|
||||
|
||||
var arcCenter = IntersectNormals(p0, n0, p1, n1);
|
||||
|
||||
if (!arcCenter.IsValid() || depth >= MaxSubdivisionDepth)
|
||||
{
|
||||
results.Add(new Line(p0, p1));
|
||||
return;
|
||||
}
|
||||
|
||||
var radius = p0.DistanceTo(arcCenter);
|
||||
var maxDev = MeasureDeviation(center, semiMajor, semiMinor, rotation,
|
||||
t0, t1, arcCenter, radius);
|
||||
|
||||
if (maxDev <= tolerance)
|
||||
{
|
||||
results.Add(CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1));
|
||||
}
|
||||
else
|
||||
{
|
||||
var tMid = (t0 + t1) / 2.0;
|
||||
FitSegment(center, semiMajor, semiMinor, rotation, t0, tMid, tolerance, results, depth + 1);
|
||||
FitSegment(center, semiMajor, semiMinor, rotation, tMid, t1, tolerance, results, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static double MeasureDeviation(Vector center, double semiMajor, double semiMinor,
|
||||
double rotation, double t0, double t1, Vector arcCenter, double radius)
|
||||
{
|
||||
var maxDev = 0.0;
|
||||
for (var i = 1; i <= DeviationSamples; i++)
|
||||
{
|
||||
var t = t0 + (t1 - t0) * i / DeviationSamples;
|
||||
var p = EvaluatePoint(semiMajor, semiMinor, rotation, center, t);
|
||||
var dist = p.DistanceTo(arcCenter);
|
||||
var dev = System.Math.Abs(dist - radius);
|
||||
if (dev > maxDev) maxDev = dev;
|
||||
}
|
||||
return maxDev;
|
||||
}
|
||||
|
||||
private static Arc CreateArc(Vector arcCenter, double radius,
|
||||
Vector ellipseCenter, double semiMajor, double semiMinor, double rotation,
|
||||
double t0, double t1)
|
||||
{
|
||||
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t0);
|
||||
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1);
|
||||
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 endAngle = System.Math.Atan2(p1.Y - arcCenter.Y, p1.X - arcCenter.X);
|
||||
|
||||
var points = new List<Vector> { p0, pMid, p1 };
|
||||
var isReversed = SumSignedAngles(arcCenter, points) < 0;
|
||||
|
||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0) endAngle += Angle.TwoPI;
|
||||
|
||||
return new Arc(arcCenter, radius, startAngle, endAngle, isReversed);
|
||||
}
|
||||
|
||||
private static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI) da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI) da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
|
||||
@@ -10,10 +11,16 @@ namespace OpenNest.Geometry
|
||||
|
||||
protected Entity()
|
||||
{
|
||||
Id = Guid.NewGuid();
|
||||
Layer = OpenNest.Geometry.Layer.Default;
|
||||
boundingBox = new Box();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this entity, stable across edit sessions.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Entity color (resolved from DXF ByLayer/ByBlock to actual color).
|
||||
/// </summary>
|
||||
@@ -29,6 +36,11 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
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>
|
||||
/// Smallest box that contains the entity.
|
||||
/// </summary>
|
||||
@@ -239,6 +251,23 @@ namespace OpenNest.Geometry
|
||||
/// <returns></returns>
|
||||
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>
|
||||
/// Type of entity.
|
||||
/// </summary>
|
||||
@@ -247,7 +276,15 @@ namespace OpenNest.Geometry
|
||||
|
||||
public static class EntityExtensions
|
||||
{
|
||||
public static BoundingRectangleResult FindBestRotation(this List<Entity> entities, double startAngle = 0, double endAngle = Angle.TwoPI)
|
||||
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)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
|
||||
@@ -286,17 +323,35 @@ namespace OpenNest.Geometry
|
||||
|
||||
case EntityType.Shape:
|
||||
var shape = (Shape)entity;
|
||||
var subResult = shape.Entities.FindBestRotation(startAngle, endAngle);
|
||||
return subResult;
|
||||
points.AddRange(shape.Entities.CollectPoints());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
public static BoundingRectangleResult FindBestRotation(this List<Entity> entities, double startAngle = 0, double endAngle = Angle.TwoPI)
|
||||
{
|
||||
// Check for Shape entity first (recursive case returns early)
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
if (entity.Type == EntityType.Shape)
|
||||
{
|
||||
var shape = (Shape)entity;
|
||||
var subResult = shape.Entities.FindBestRotation(startAngle, endAngle);
|
||||
return subResult;
|
||||
}
|
||||
}
|
||||
|
||||
var points = entities.CollectPoints();
|
||||
|
||||
if (points.Count == 0)
|
||||
return new BoundingRectangleResult(startAngle, 0, 0);
|
||||
|
||||
var hull = ConvexHull.Compute(points);
|
||||
|
||||
bool constrained = !startAngle.IsEqualTo(0) || !endAngle.IsEqualTo(Angle.TwoPI);
|
||||
var constrained = !startAngle.IsEqualTo(0) || !endAngle.IsEqualTo(Angle.TwoPI);
|
||||
|
||||
return constrained
|
||||
? RotatingCalipers.MinimumBoundingRectangle(hull, startAngle, endAngle)
|
||||
|
||||
@@ -7,65 +7,46 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
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));
|
||||
|
||||
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 (int i = 0; i < arcs.Count; ++i)
|
||||
for (var i = 0; i < items.Count; ++i)
|
||||
{
|
||||
var arc = arcs[i];
|
||||
|
||||
var coradialArcs = arcs.GetCoradialArs(arc, i);
|
||||
int index = 0;
|
||||
|
||||
while (index < coradialArcs.Count)
|
||||
{
|
||||
Arc arc2 = coradialArcs[index];
|
||||
Arc joinArc;
|
||||
|
||||
if (!TryJoinArcs(arc, arc2, out joinArc))
|
||||
{
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
coradialArcs.Remove(arc2);
|
||||
arcs.Remove(arc2);
|
||||
|
||||
arc = joinArc;
|
||||
index = 0;
|
||||
}
|
||||
|
||||
arcs[i] = arc;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Optimize(IList<Line> lines)
|
||||
{
|
||||
for (int i = 0; i < lines.Count; ++i)
|
||||
{
|
||||
var line = lines[i];
|
||||
|
||||
var collinearLines = lines.GetCollinearLines(line, i);
|
||||
var item = items[i];
|
||||
var candidates = findCandidates(items, item, i);
|
||||
var index = 0;
|
||||
|
||||
while (index < collinearLines.Count)
|
||||
while (index < candidates.Count)
|
||||
{
|
||||
Line line2 = collinearLines[index];
|
||||
Line joinLine;
|
||||
var candidate = candidates[index];
|
||||
|
||||
if (!TryJoinLines(line, line2, out joinLine))
|
||||
if (!tryJoin(item, candidate, out var joined))
|
||||
{
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
collinearLines.Remove(line2);
|
||||
lines.Remove(line2);
|
||||
candidates.Remove(candidate);
|
||||
items.Remove(candidate);
|
||||
|
||||
line = joinLine;
|
||||
item = joined;
|
||||
index = 0;
|
||||
}
|
||||
|
||||
lines[i] = line;
|
||||
items[i] = item;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +57,9 @@ namespace OpenNest.Geometry
|
||||
if (line1 == line2)
|
||||
return false;
|
||||
|
||||
if (line1.Layer?.Name != line2.Layer?.Name)
|
||||
return false;
|
||||
|
||||
if (!line1.IsCollinearTo(line2))
|
||||
return false;
|
||||
|
||||
@@ -113,9 +97,9 @@ namespace OpenNest.Geometry
|
||||
var b = b1 < b2 ? b1 : b2;
|
||||
|
||||
if (!line1.IsVertical() && line1.Slope() < 0)
|
||||
lineOut = new Line(new Vector(l, t), new Vector(r, b));
|
||||
lineOut = new Line(new Vector(l, t), new Vector(r, b)) { Layer = line1.Layer, Color = line1.Color };
|
||||
else
|
||||
lineOut = new Line(new Vector(l, b), new Vector(r, t));
|
||||
lineOut = new Line(new Vector(l, b), new Vector(r, t)) { Layer = line1.Layer, Color = line1.Color };
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -127,28 +111,44 @@ namespace OpenNest.Geometry
|
||||
if (arc1 == arc2)
|
||||
return false;
|
||||
|
||||
if (arc1.Layer?.Name != arc2.Layer?.Name)
|
||||
return false;
|
||||
|
||||
if (arc1.Center != arc2.Center)
|
||||
return false;
|
||||
|
||||
if (!arc1.Radius.IsEqualTo(arc2.Radius))
|
||||
return false;
|
||||
|
||||
if (arc1.StartAngle > arc1.EndAngle)
|
||||
arc1.StartAngle -= Angle.TwoPI;
|
||||
var start1 = arc1.StartAngle;
|
||||
var end1 = arc1.EndAngle;
|
||||
var start2 = arc2.StartAngle;
|
||||
var end2 = arc2.EndAngle;
|
||||
|
||||
if (arc2.StartAngle > arc2.EndAngle)
|
||||
arc2.StartAngle -= Angle.TwoPI;
|
||||
if (start1 > end1)
|
||||
start1 -= Angle.TwoPI;
|
||||
|
||||
if (arc1.EndAngle < arc2.StartAngle || arc1.StartAngle > arc2.EndAngle)
|
||||
if (start2 > end2)
|
||||
start2 -= Angle.TwoPI;
|
||||
|
||||
// Check that arcs are adjacent (endpoints touch), not overlapping
|
||||
var touch1 = end1.IsEqualTo(start2) || (end1 + Angle.TwoPI).IsEqualTo(start2);
|
||||
var touch2 = end2.IsEqualTo(start1) || (end2 + Angle.TwoPI).IsEqualTo(start1);
|
||||
if (!touch1 && !touch2)
|
||||
return false;
|
||||
|
||||
var startAngle = arc1.StartAngle < arc2.StartAngle ? arc1.StartAngle : arc2.StartAngle;
|
||||
var endAngle = arc1.EndAngle > arc2.EndAngle ? arc1.EndAngle : arc2.EndAngle;
|
||||
var startAngle = start1 < start2 ? start1 : start2;
|
||||
var endAngle = end1 > end2 ? end1 : end2;
|
||||
|
||||
// Don't merge if the result would be a full circle (start == end)
|
||||
var sweep = endAngle - startAngle;
|
||||
if (sweep >= Angle.TwoPI - Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0) endAngle += Angle.TwoPI;
|
||||
|
||||
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle);
|
||||
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle) { Layer = arc1.Layer, Color = arc1.Color };
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
648
OpenNest.Core/Geometry/GeometrySimplifier.cs
Normal file
648
OpenNest.Core/Geometry/GeometrySimplifier.cs
Normal file
@@ -0,0 +1,648 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry;
|
||||
|
||||
public class ArcCandidate
|
||||
{
|
||||
public int ShapeIndex { get; set; }
|
||||
public int StartIndex { get; set; }
|
||||
public int EndIndex { get; set; }
|
||||
public int LineCount => EndIndex - StartIndex + 1;
|
||||
public Arc FittedArc { get; set; }
|
||||
public double MaxDeviation { get; set; }
|
||||
public Box BoundingBox { get; set; }
|
||||
public bool IsSelected { get; set; } = true;
|
||||
/// <summary>First point of the original line segments this candidate covers.</summary>
|
||||
public Vector FirstPoint { get; set; }
|
||||
/// <summary>Last point of the original line segments this candidate covers.</summary>
|
||||
public Vector LastPoint { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A mirror axis defined by a point on the axis and a unit direction vector.
|
||||
/// </summary>
|
||||
public class MirrorAxisResult
|
||||
{
|
||||
public static readonly MirrorAxisResult None = new(Vector.Invalid, Vector.Invalid, 0);
|
||||
|
||||
public Vector Point { get; }
|
||||
public Vector Direction { get; }
|
||||
public double Score { get; }
|
||||
public bool IsValid => Point.IsValid();
|
||||
|
||||
public MirrorAxisResult(Vector point, Vector direction, double score)
|
||||
{
|
||||
Point = point;
|
||||
Direction = direction;
|
||||
Score = score;
|
||||
}
|
||||
|
||||
/// <summary>Reflects a point across this axis.</summary>
|
||||
public Vector Reflect(Vector p)
|
||||
{
|
||||
var dx = p.X - Point.X;
|
||||
var dy = p.Y - Point.Y;
|
||||
var dot = dx * Direction.X + dy * Direction.Y;
|
||||
return new Vector(
|
||||
p.X - 2 * (dx - dot * Direction.X),
|
||||
p.Y - 2 * (dy - dot * Direction.Y));
|
||||
}
|
||||
}
|
||||
|
||||
public class GeometrySimplifier
|
||||
{
|
||||
public double Tolerance { get; set; } = 0.004;
|
||||
public int MinLines { get; set; } = 3;
|
||||
|
||||
public List<ArcCandidate> Analyze(Shape shape)
|
||||
{
|
||||
var candidates = new List<ArcCandidate>();
|
||||
var entities = shape.Entities;
|
||||
var i = 0;
|
||||
|
||||
while (i < entities.Count)
|
||||
{
|
||||
if (entities[i] is not Line and not Arc)
|
||||
{
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var runStart = i;
|
||||
var layerName = entities[i].Layer?.Name;
|
||||
var lineCount = 0;
|
||||
while (i < entities.Count && (entities[i] is Line || entities[i] is Arc) && entities[i].Layer?.Name == layerName)
|
||||
{
|
||||
if (entities[i] is Line) lineCount++;
|
||||
i++;
|
||||
}
|
||||
var runEnd = i - 1;
|
||||
|
||||
if (lineCount >= MinLines)
|
||||
FindCandidatesInRun(entities, runStart, runEnd, candidates);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
public Shape Apply(Shape shape, List<ArcCandidate> candidates)
|
||||
{
|
||||
var selected = candidates
|
||||
.Where(c => c.IsSelected)
|
||||
.OrderBy(c => c.StartIndex)
|
||||
.ToList();
|
||||
|
||||
var newEntities = new List<Entity>();
|
||||
var i = 0;
|
||||
|
||||
foreach (var candidate in selected)
|
||||
{
|
||||
while (i < candidate.StartIndex)
|
||||
{
|
||||
newEntities.Add(shape.Entities[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
newEntities.Add(candidate.FittedArc);
|
||||
i = candidate.EndIndex + 1;
|
||||
}
|
||||
|
||||
while (i < shape.Entities.Count)
|
||||
{
|
||||
newEntities.Add(shape.Entities[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
var result = new Shape();
|
||||
result.Entities.AddRange(newEntities);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects the mirror axis of a shape by testing candidate axes through the
|
||||
/// centroid. Uses PCA to find principal directions, then also tests horizontal
|
||||
/// and vertical. Works for shapes rotated at any angle.
|
||||
/// </summary>
|
||||
public static MirrorAxisResult DetectMirrorAxis(Shape shape)
|
||||
{
|
||||
var midpoints = new List<Vector>();
|
||||
foreach (var e in shape.Entities)
|
||||
midpoints.Add(e.BoundingBox.Center);
|
||||
|
||||
if (midpoints.Count < 4) return MirrorAxisResult.None;
|
||||
|
||||
var centroid = new Vector(
|
||||
midpoints.Average(p => p.X),
|
||||
midpoints.Average(p => p.Y));
|
||||
var cx = centroid.X;
|
||||
var cy = centroid.Y;
|
||||
|
||||
// Covariance matrix for PCA
|
||||
var cxx = 0.0;
|
||||
var cxy = 0.0;
|
||||
var cyy = 0.0;
|
||||
foreach (var p in midpoints)
|
||||
{
|
||||
var dx = p.X - cx;
|
||||
var dy = p.Y - cy;
|
||||
cxx += dx * dx;
|
||||
cxy += dx * dy;
|
||||
cyy += dy * dy;
|
||||
}
|
||||
|
||||
// Eigenvectors of 2x2 symmetric matrix via analytic formula
|
||||
var trace = cxx + cyy;
|
||||
var det = cxx * cyy - cxy * cxy;
|
||||
var disc = System.Math.Sqrt(System.Math.Max(0, trace * trace / 4 - det));
|
||||
var lambda1 = trace / 2 + disc;
|
||||
var lambda2 = trace / 2 - disc;
|
||||
|
||||
var candidates = new List<Vector>();
|
||||
|
||||
// PCA eigenvectors (major and minor axes)
|
||||
if (System.Math.Abs(cxy) > 1e-10)
|
||||
{
|
||||
candidates.Add(Normalize(new Vector(lambda1 - cyy, cxy)));
|
||||
candidates.Add(Normalize(new Vector(lambda2 - cyy, cxy)));
|
||||
}
|
||||
else
|
||||
{
|
||||
candidates.Add(new Vector(1, 0));
|
||||
candidates.Add(new Vector(0, 1));
|
||||
}
|
||||
|
||||
// Also always test pure horizontal and vertical
|
||||
candidates.Add(new Vector(1, 0));
|
||||
candidates.Add(new Vector(0, 1));
|
||||
|
||||
// Score each candidate axis
|
||||
var bestResult = MirrorAxisResult.None;
|
||||
foreach (var dir in candidates)
|
||||
{
|
||||
var score = MirrorMatchScore(midpoints, centroid, dir);
|
||||
if (score > bestResult.Score)
|
||||
bestResult = new MirrorAxisResult(centroid, dir, score);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var matchTol = 0.1;
|
||||
var matched = 0;
|
||||
|
||||
for (var i = 0; i < points.Count; i++)
|
||||
{
|
||||
var p = points[i];
|
||||
var dist = PerpendicularDistance(p, axisPoint, axisDir);
|
||||
|
||||
// Points on the axis count as matched
|
||||
if (dist < matchTol)
|
||||
{
|
||||
matched++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reflect across axis and look for partner
|
||||
var reflected = new MirrorAxisResult(axisPoint, axisDir, 0).Reflect(p);
|
||||
|
||||
for (var j = 0; j < points.Count; j++)
|
||||
{
|
||||
if (i == j) continue;
|
||||
var d = reflected.DistanceTo(points[j]);
|
||||
if (d < matchTol)
|
||||
{
|
||||
matched++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (double)matched / points.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pairs candidates across a mirror axis and forces each pair to use
|
||||
/// the same arc (mirrored). The candidate with more lines or lower
|
||||
/// deviation is kept as the source.
|
||||
/// </summary>
|
||||
public void Symmetrize(List<ArcCandidate> candidates, MirrorAxisResult axis)
|
||||
{
|
||||
if (!axis.IsValid || candidates.Count < 2) return;
|
||||
|
||||
var paired = new HashSet<int>();
|
||||
|
||||
for (var i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
if (paired.Contains(i)) continue;
|
||||
|
||||
var ci = candidates[i];
|
||||
var ciCenter = ci.BoundingBox.Center;
|
||||
if (PerpendicularDistance(ciCenter, axis.Point, axis.Direction) < 0.1) continue; // on the axis
|
||||
|
||||
var mirrorCenter = axis.Reflect(ciCenter);
|
||||
|
||||
var bestJ = -1;
|
||||
var bestDist = double.MaxValue;
|
||||
for (var j = i + 1; j < candidates.Count; j++)
|
||||
{
|
||||
if (paired.Contains(j)) continue;
|
||||
var d = mirrorCenter.DistanceTo(candidates[j].BoundingBox.Center);
|
||||
if (d < bestDist)
|
||||
{
|
||||
bestDist = d;
|
||||
bestJ = j;
|
||||
}
|
||||
}
|
||||
|
||||
var matchTol = System.Math.Max(ci.BoundingBox.Width, ci.BoundingBox.Length) * 0.5;
|
||||
if (bestJ < 0 || bestDist > matchTol) continue;
|
||||
|
||||
paired.Add(i);
|
||||
paired.Add(bestJ);
|
||||
|
||||
var cj = candidates[bestJ];
|
||||
var sourceIdx = i;
|
||||
var targetIdx = bestJ;
|
||||
if (cj.LineCount > ci.LineCount || (cj.LineCount == ci.LineCount && cj.MaxDeviation < ci.MaxDeviation))
|
||||
{
|
||||
sourceIdx = bestJ;
|
||||
targetIdx = i;
|
||||
}
|
||||
|
||||
var source = candidates[sourceIdx];
|
||||
var target = candidates[targetIdx];
|
||||
var mirrored = MirrorArc(source.FittedArc, axis);
|
||||
|
||||
// Only apply the mirrored arc if its endpoints are close enough to the
|
||||
// target's actual boundary points. Otherwise the mirror introduces gaps.
|
||||
var mirroredStart = mirrored.StartPoint();
|
||||
var mirroredEnd = mirrored.EndPoint();
|
||||
var startDist = mirroredStart.DistanceTo(target.FirstPoint);
|
||||
var endDist = mirroredEnd.DistanceTo(target.LastPoint);
|
||||
|
||||
if (startDist <= Tolerance && endDist <= Tolerance)
|
||||
{
|
||||
target.FittedArc = mirrored;
|
||||
target.MaxDeviation = source.MaxDeviation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Arc MirrorArc(Arc arc, MirrorAxisResult axis)
|
||||
{
|
||||
var mirrorCenter = axis.Reflect(arc.Center);
|
||||
|
||||
// Reflect start and end points, then compute new angles
|
||||
var sp = arc.StartPoint();
|
||||
var ep = arc.EndPoint();
|
||||
var mirrorSp = axis.Reflect(sp);
|
||||
var mirrorEp = axis.Reflect(ep);
|
||||
|
||||
// Mirroring reverses winding — swap start/end to preserve arc direction
|
||||
var mirrorStart = NormalizeAngle(System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X));
|
||||
var mirrorEnd = NormalizeAngle(System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X));
|
||||
|
||||
var result = new Arc(mirrorCenter, arc.Radius, mirrorStart, mirrorEnd, arc.IsReversed);
|
||||
result.Layer = arc.Layer;
|
||||
result.Color = arc.Color;
|
||||
return result;
|
||||
}
|
||||
|
||||
private void FindCandidatesInRun(List<Entity> entities, int runStart, int runEnd, List<ArcCandidate> candidates)
|
||||
{
|
||||
var j = runStart;
|
||||
var chainedTangent = Vector.Invalid;
|
||||
|
||||
while (j <= runEnd - MinLines + 1)
|
||||
{
|
||||
var result = TryFitArcAt(entities, j, runEnd, chainedTangent);
|
||||
if (result == null)
|
||||
{
|
||||
j++;
|
||||
chainedTangent = Vector.Invalid;
|
||||
continue;
|
||||
}
|
||||
|
||||
chainedTangent = ComputeEndTangent(result.Center, result.Points);
|
||||
var arc = CreateArc(result.Center, result.Radius, result.Points, entities[j]);
|
||||
candidates.Add(new ArcCandidate
|
||||
{
|
||||
StartIndex = j,
|
||||
EndIndex = result.EndIndex,
|
||||
FittedArc = arc,
|
||||
MaxDeviation = result.Deviation,
|
||||
BoundingBox = result.Points.GetBoundingBox(),
|
||||
FirstPoint = arc.StartPoint(),
|
||||
LastPoint = arc.EndPoint(),
|
||||
});
|
||||
|
||||
j = result.EndIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private record ArcFitResult(Vector Center, double Radius, double Deviation, List<Vector> Points, int EndIndex);
|
||||
|
||||
private ArcFitResult TryFitArcAt(List<Entity> entities, int start, int runEnd, Vector chainedTangent)
|
||||
{
|
||||
var k = start + MinLines - 1;
|
||||
if (k > runEnd) return null;
|
||||
|
||||
var points = CollectPoints(entities, start, k);
|
||||
if (points.Count < 3) return null;
|
||||
|
||||
var startTangent = chainedTangent.IsValid()
|
||||
? chainedTangent
|
||||
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||
|
||||
var endTangent = GetExitDirection(entities[k]);
|
||||
var (center, radius, dev) = TryFit(points, startTangent, endTangent);
|
||||
if (!center.IsValid()) return null;
|
||||
|
||||
// Extend the arc as far as possible
|
||||
while (k + 1 <= runEnd)
|
||||
{
|
||||
var extPoints = CollectPoints(entities, start, k + 1);
|
||||
var extEndTangent = GetExitDirection(entities[k + 1]);
|
||||
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d);
|
||||
if (!nc.IsValid()) break;
|
||||
|
||||
k++;
|
||||
center = nc;
|
||||
radius = nr;
|
||||
dev = nd;
|
||||
points = extPoints;
|
||||
}
|
||||
|
||||
// Reject arcs that subtend a tiny angle — these are nearly-straight lines
|
||||
// that happen to fit a huge circle. Applied after extension so that many small
|
||||
// segments can accumulate enough sweep to qualify.
|
||||
var sweep = System.Math.Abs(SumSignedAngles(center, points));
|
||||
if (sweep < Angle.ToRadians(5))
|
||||
return null;
|
||||
|
||||
return new ArcFitResult(center, radius, dev, points, k);
|
||||
}
|
||||
|
||||
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent, Vector endTangent)
|
||||
{
|
||||
// Try dual-tangent fit first (matches direction at both endpoints)
|
||||
if (endTangent.IsValid())
|
||||
{
|
||||
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent);
|
||||
if (dc.IsValid() && dd <= Tolerance)
|
||||
{
|
||||
var isRev = SumSignedAngles(dc, points) < 0;
|
||||
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev);
|
||||
if (aDev <= Tolerance)
|
||||
return (dc, dr, System.Math.Max(dd, aDev));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to start-tangent-only, then mirror axis
|
||||
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
(center, radius, dev) = FitMirrorAxis(points);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the tangent direction at the last point of a fitted arc,
|
||||
/// used to chain tangent continuity to the next arc.
|
||||
/// </summary>
|
||||
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
|
||||
{
|
||||
var lastPt = points[^1];
|
||||
var rx = lastPt.X - center.X;
|
||||
var ry = lastPt.Y - center.Y;
|
||||
var sign = SumSignedAngles(center, points) >= 0 ? 1 : -1;
|
||||
return new Vector(-sign * ry, sign * rx);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits a circular arc using the mirror axis approach. The center is constrained
|
||||
/// to the perpendicular bisector of the chord (P1->Pn), guaranteeing the arc
|
||||
/// passes exactly through both endpoints. Golden section search optimizes position.
|
||||
/// </summary>
|
||||
private (Vector center, double radius, double deviation) FitMirrorAxis(List<Vector> points)
|
||||
{
|
||||
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 halfChord = chordLen / 2;
|
||||
var nx = -dy / chordLen;
|
||||
var ny = dx / chordLen;
|
||||
|
||||
var maxSagitta = 0.0;
|
||||
for (var i = 1; i < points.Count - 1; i++)
|
||||
{
|
||||
var proj = (points[i].X - mx) * nx + (points[i].Y - my) * ny;
|
||||
if (System.Math.Abs(proj) > System.Math.Abs(maxSagitta))
|
||||
maxSagitta = proj;
|
||||
}
|
||||
if (System.Math.Abs(maxSagitta) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var dInit = (maxSagitta * maxSagitta - halfChord * halfChord) / (2 * maxSagitta);
|
||||
var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord);
|
||||
|
||||
var dOpt = GoldenSectionMin(dInit - range, dInit + range,
|
||||
d => ArcFit.MaxRadialDeviation(points, mx + d * nx, my + d * ny,
|
||||
System.Math.Sqrt(halfChord * halfChord + d * d)));
|
||||
|
||||
var center = new Vector(mx + dOpt * nx, my + dOpt * ny);
|
||||
var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt);
|
||||
return (center, radius, ArcFit.MaxRadialDeviation(points, center.X, center.Y, radius));
|
||||
}
|
||||
|
||||
private static double GoldenSectionMin(double low, double high, Func<double, double> eval)
|
||||
{
|
||||
var phi = (System.Math.Sqrt(5) - 1) / 2;
|
||||
for (var iter = 0; iter < 30; iter++)
|
||||
{
|
||||
var d1 = high - phi * (high - low);
|
||||
var d2 = low + phi * (high - low);
|
||||
if (eval(d1) < eval(d2))
|
||||
high = d2;
|
||||
else
|
||||
low = d1;
|
||||
if (high - low < 1e-6)
|
||||
break;
|
||||
}
|
||||
return (low + high) / 2;
|
||||
}
|
||||
|
||||
private static List<Vector> CollectPoints(List<Entity> entities, int start, int end)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
|
||||
for (var i = start; i <= end; i++)
|
||||
{
|
||||
switch (entities[i])
|
||||
{
|
||||
case Line line:
|
||||
if (i == start)
|
||||
points.Add(line.StartPoint);
|
||||
points.Add(line.EndPoint);
|
||||
break;
|
||||
|
||||
case Arc arc:
|
||||
if (i == start)
|
||||
points.Add(arc.StartPoint());
|
||||
var segments = System.Math.Max(2, arc.SegmentsForTolerance(0.1));
|
||||
var arcPoints = arc.ToPoints(segments);
|
||||
for (var j = 1; j < arcPoints.Count; j++)
|
||||
points.Add(arcPoints[j]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private static Arc CreateArc(Vector center, double radius, List<Vector> points, Entity sourceEntity)
|
||||
{
|
||||
var firstPoint = points[0];
|
||||
var lastPoint = points[^1];
|
||||
|
||||
var startAngle = NormalizeAngle(System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X));
|
||||
var endAngle = NormalizeAngle(System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X));
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
|
||||
var arc = new Arc(center, radius, startAngle, endAngle, isReversed);
|
||||
arc.Layer = sourceEntity.Layer;
|
||||
arc.Color = sourceEntity.Color;
|
||||
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>
|
||||
/// Sums signed angular change traversing consecutive points around a center.
|
||||
/// Positive = CCW, negative = CW.
|
||||
/// </summary>
|
||||
private static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI) da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI) da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measures the maximum distance from sampled points along the fitted arc
|
||||
/// back to the original line segments. This catches cases where points lie
|
||||
/// on a large circle but the arc bulges far from the original straight geometry.
|
||||
/// </summary>
|
||||
private static double MaxArcToSegmentDeviation(List<Vector> points, Vector center, double radius, bool isReversed)
|
||||
{
|
||||
var startAngle = System.Math.Atan2(points[0].Y - center.Y, points[0].X - center.X);
|
||||
var endAngle = System.Math.Atan2(points[^1].Y - center.Y, points[^1].X - center.X);
|
||||
|
||||
var sweep = endAngle - startAngle;
|
||||
if (isReversed)
|
||||
{
|
||||
if (sweep > 0) sweep -= Angle.TwoPI;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (sweep < 0) sweep += Angle.TwoPI;
|
||||
}
|
||||
|
||||
var sampleCount = System.Math.Max(10, (int)(System.Math.Abs(sweep) * radius * 10));
|
||||
sampleCount = System.Math.Min(sampleCount, 100);
|
||||
|
||||
var maxDev = 0.0;
|
||||
for (var i = 1; i < sampleCount; i++)
|
||||
{
|
||||
var t = (double)i / sampleCount;
|
||||
var angle = startAngle + sweep * t;
|
||||
var px = center.X + radius * System.Math.Cos(angle);
|
||||
var py = center.Y + radius * System.Math.Sin(angle);
|
||||
var arcPt = new Vector(px, py);
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
for (var j = 0; j < points.Count - 1; j++)
|
||||
{
|
||||
var dist = DistanceToSegment(arcPt, points[j], points[j + 1]);
|
||||
if (dist < minDist) minDist = dist;
|
||||
}
|
||||
if (minDist > maxDev) maxDev = minDist;
|
||||
}
|
||||
return maxDev;
|
||||
}
|
||||
|
||||
private static double DistanceToSegment(Vector p, Vector a, Vector b)
|
||||
{
|
||||
var dx = b.X - a.X;
|
||||
var dy = b.Y - a.Y;
|
||||
var lenSq = dx * dx + dy * dy;
|
||||
if (lenSq < 1e-20)
|
||||
return System.Math.Sqrt((p.X - a.X) * (p.X - a.X) + (p.Y - a.Y) * (p.Y - a.Y));
|
||||
|
||||
var t = ((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lenSq;
|
||||
t = System.Math.Max(0, System.Math.Min(1, t));
|
||||
var projX = a.X + t * dx;
|
||||
var projY = a.Y + t * dy;
|
||||
return System.Math.Sqrt((p.X - projX) * (p.X - projX) + (p.Y - projY) * (p.Y - projY));
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ namespace OpenNest.Geometry
|
||||
result.Vertices.Add(new Vector(ifpRight, ifpTop));
|
||||
result.Vertices.Add(new Vector(ifpLeft, ifpTop));
|
||||
result.Close();
|
||||
result.UpdateBounds();
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -62,36 +63,20 @@ namespace OpenNest.Geometry
|
||||
/// Returns the polygon representing valid placement positions, or an empty
|
||||
/// polygon if no valid position exists.
|
||||
/// </summary>
|
||||
public static Polygon ComputeFeasibleRegion(Polygon ifp, Polygon[] nfps)
|
||||
public static Polygon ComputeFeasibleRegion(Polygon ifp, PathsD nfpPaths)
|
||||
{
|
||||
if (ifp.Vertices.Count < 3)
|
||||
return new Polygon();
|
||||
|
||||
if (nfps == null || nfps.Length == 0)
|
||||
if (nfpPaths == null || nfpPaths.Count == 0)
|
||||
return ifp;
|
||||
|
||||
var ifpPath = NoFitPolygon.ToClipperPath(ifp);
|
||||
var ifpPaths = new PathsD { ifpPath };
|
||||
|
||||
// Union all NFPs.
|
||||
var nfpPaths = new PathsD();
|
||||
|
||||
foreach (var nfp in nfps)
|
||||
{
|
||||
if (nfp.Vertices.Count >= 3)
|
||||
{
|
||||
var path = NoFitPolygon.ToClipperPath(nfp);
|
||||
nfpPaths.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (nfpPaths.Count == 0)
|
||||
return ifp;
|
||||
|
||||
var nfpUnion = Clipper.Union(nfpPaths, FillRule.NonZero);
|
||||
|
||||
// Subtract the NFP union from the IFP.
|
||||
var feasible = Clipper.Difference(ifpPaths, nfpUnion, FillRule.NonZero);
|
||||
// Subtract the NFPs from the IFP.
|
||||
// Clipper2 handles the implicit union of the clip paths.
|
||||
var feasible = Clipper.Difference(ifpPaths, nfpPaths, FillRule.NonZero);
|
||||
|
||||
if (feasible.Count == 0)
|
||||
return new Polygon();
|
||||
@@ -118,6 +103,25 @@ namespace OpenNest.Geometry
|
||||
return bestPath != null ? NoFitPolygon.FromClipperPath(bestPath) : new Polygon();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the feasible region for placing a part given already-placed parts.
|
||||
/// (Legacy overload for backward compatibility).
|
||||
/// </summary>
|
||||
public static Polygon ComputeFeasibleRegion(Polygon ifp, Polygon[] nfps)
|
||||
{
|
||||
if (nfps == null || nfps.Length == 0)
|
||||
return ifp;
|
||||
|
||||
var nfpPaths = new PathsD(nfps.Length);
|
||||
foreach (var nfp in nfps)
|
||||
{
|
||||
if (nfp.Vertices.Count >= 3)
|
||||
nfpPaths.Add(NoFitPolygon.ToClipperPath(nfp));
|
||||
}
|
||||
|
||||
return ComputeFeasibleRegion(ifp, nfpPaths);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the bottom-left-most point on a polygon boundary.
|
||||
/// "Bottom-left" means: minimize Y first, then minimize X.
|
||||
|
||||
@@ -219,6 +219,14 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
internal static bool Intersects(Line line1, Line line2, out Vector pt)
|
||||
{
|
||||
if (!IntersectsUnbounded(line1, line2, out pt))
|
||||
return false;
|
||||
|
||||
return line1.BoundingBox.Contains(pt) && line2.BoundingBox.Contains(pt);
|
||||
}
|
||||
|
||||
internal static bool IntersectsUnbounded(Line line1, Line line2, out Vector pt)
|
||||
{
|
||||
var a1 = line1.EndPoint.Y - line1.StartPoint.Y;
|
||||
var b1 = line1.StartPoint.X - line1.EndPoint.X;
|
||||
@@ -240,7 +248,7 @@ namespace OpenNest.Geometry
|
||||
var y = (a1 * c2 - a2 * c1) / d;
|
||||
|
||||
pt = new Vector(x, y);
|
||||
return line1.BoundingBox.Contains(pt) && line2.BoundingBox.Contains(pt);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool Intersects(Line line, Shape shape, out List<Vector> pts)
|
||||
@@ -249,9 +257,8 @@ namespace OpenNest.Geometry
|
||||
|
||||
foreach (var geo in shape.Entities)
|
||||
{
|
||||
List<Vector> pts3;
|
||||
geo.Intersects(line, out pts3);
|
||||
pts.AddRange(pts3);
|
||||
if (geo.Intersects(line, out var pts3))
|
||||
pts.AddRange(pts3);
|
||||
}
|
||||
|
||||
return pts.Count > 0;
|
||||
|
||||
@@ -257,6 +257,13 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
}
|
||||
|
||||
public override Entity Clone()
|
||||
{
|
||||
var copy = new Line(pt1, pt2);
|
||||
CopyBaseTo(copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reversed the line.
|
||||
/// </summary>
|
||||
@@ -370,23 +377,23 @@ namespace OpenNest.Geometry
|
||||
if (StartPoint.X < EndPoint.X)
|
||||
{
|
||||
boundingBox.X = StartPoint.X;
|
||||
boundingBox.Width = EndPoint.X - StartPoint.X;
|
||||
boundingBox.Length = EndPoint.X - StartPoint.X;
|
||||
}
|
||||
else
|
||||
{
|
||||
boundingBox.X = EndPoint.X;
|
||||
boundingBox.Width = StartPoint.X - EndPoint.X;
|
||||
boundingBox.Length = StartPoint.X - EndPoint.X;
|
||||
}
|
||||
|
||||
if (StartPoint.Y < EndPoint.Y)
|
||||
{
|
||||
boundingBox.Y = StartPoint.Y;
|
||||
boundingBox.Length = EndPoint.Y - StartPoint.Y;
|
||||
boundingBox.Width = EndPoint.Y - StartPoint.Y;
|
||||
}
|
||||
else
|
||||
{
|
||||
boundingBox.Y = EndPoint.Y;
|
||||
boundingBox.Length = StartPoint.Y - EndPoint.Y;
|
||||
boundingBox.Width = StartPoint.Y - EndPoint.Y;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
@@ -22,8 +23,20 @@ namespace OpenNest.Geometry
|
||||
return MinkowskiSum(stationary, reflected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optimized version of Compute for polygons known to be convex.
|
||||
/// Bypasses expensive triangulation and Clipper unions.
|
||||
/// </summary>
|
||||
public static Polygon ComputeConvex(Polygon stationary, Polygon orbiting)
|
||||
{
|
||||
var reflected = Reflect(orbiting);
|
||||
return ConvexMinkowskiSum(stationary, reflected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reflects a polygon through the origin (negates all vertex coordinates).
|
||||
/// Point reflection (negating both axes) is equivalent to 180° rotation,
|
||||
/// which preserves winding order. No reversal needed.
|
||||
/// </summary>
|
||||
private static Polygon Reflect(Polygon polygon)
|
||||
{
|
||||
@@ -32,8 +45,6 @@ namespace OpenNest.Geometry
|
||||
foreach (var v in polygon.Vertices)
|
||||
result.Vertices.Add(new Vector(-v.X, -v.Y));
|
||||
|
||||
// Reflecting reverses winding order — reverse to maintain CCW.
|
||||
result.Vertices.Reverse();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -78,19 +89,24 @@ namespace OpenNest.Geometry
|
||||
/// edge vectors sorted by angle. O(n+m) where n and m are vertex counts.
|
||||
/// Both polygons must have CCW winding.
|
||||
/// </summary>
|
||||
internal static Polygon ConvexMinkowskiSum(Polygon a, Polygon b)
|
||||
public static Polygon ConvexMinkowskiSum(Polygon a, Polygon b)
|
||||
{
|
||||
var edgesA = GetEdgeVectors(a);
|
||||
var edgesB = GetEdgeVectors(b);
|
||||
|
||||
// Find bottom-most (then left-most) vertex for each polygon as starting point.
|
||||
// Find indices of bottom-left vertices for both.
|
||||
var startA = FindBottomLeft(a);
|
||||
var startB = FindBottomLeft(b);
|
||||
|
||||
var result = new Polygon();
|
||||
|
||||
// The starting point of the Minkowski sum A + B is the sum of the
|
||||
// starting points of A and B. For NFP = A + (-B), this is
|
||||
// startA + startReflectedB.
|
||||
var current = new Vector(
|
||||
a.Vertices[startA].X + b.Vertices[startB].X,
|
||||
a.Vertices[startA].Y + b.Vertices[startB].Y);
|
||||
|
||||
result.Vertices.Add(current);
|
||||
|
||||
var ia = 0;
|
||||
@@ -98,7 +114,6 @@ namespace OpenNest.Geometry
|
||||
var na = edgesA.Count;
|
||||
var nb = edgesB.Count;
|
||||
|
||||
// Reorder edges to start from the bottom-left vertex.
|
||||
var orderedA = ReorderEdges(edgesA, startA);
|
||||
var orderedB = ReorderEdges(edgesB, startB);
|
||||
|
||||
@@ -117,7 +132,10 @@ namespace OpenNest.Geometry
|
||||
else
|
||||
{
|
||||
var angleA = System.Math.Atan2(orderedA[ia].Y, orderedA[ia].X);
|
||||
if (angleA < 0) angleA += Angle.TwoPI;
|
||||
|
||||
var angleB = System.Math.Atan2(orderedB[ib].Y, orderedB[ib].X);
|
||||
if (angleB < 0) angleB += Angle.TwoPI;
|
||||
|
||||
if (angleA < angleB)
|
||||
{
|
||||
@@ -129,7 +147,6 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
else
|
||||
{
|
||||
// Same angle — merge both edges.
|
||||
edge = new Vector(
|
||||
orderedA[ia].X + orderedB[ib].X,
|
||||
orderedA[ia].Y + orderedB[ib].Y);
|
||||
@@ -143,6 +160,7 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
result.Close();
|
||||
result.UpdateBounds();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -250,9 +268,9 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OpenNest Polygon to a Clipper2 PathD.
|
||||
/// Converts an OpenNest Polygon to a Clipper2 PathD, with an optional offset.
|
||||
/// </summary>
|
||||
internal static PathD ToClipperPath(Polygon polygon)
|
||||
public static PathD ToClipperPath(Polygon polygon, Vector offset = default)
|
||||
{
|
||||
var path = new PathD();
|
||||
var verts = polygon.Vertices;
|
||||
@@ -263,7 +281,7 @@ namespace OpenNest.Geometry
|
||||
n--;
|
||||
|
||||
for (var i = 0; i < n; i++)
|
||||
path.Add(new PointD(verts[i].X, verts[i].Y));
|
||||
path.Add(new PointD(verts[i].X + offset.X, verts[i].Y + offset.Y));
|
||||
|
||||
return path;
|
||||
}
|
||||
@@ -271,7 +289,7 @@ namespace OpenNest.Geometry
|
||||
/// <summary>
|
||||
/// Converts a Clipper2 PathD to an OpenNest Polygon.
|
||||
/// </summary>
|
||||
internal static Polygon FromClipperPath(PathD path)
|
||||
public static Polygon FromClipperPath(PathD path)
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
|
||||
@@ -279,6 +297,7 @@ namespace OpenNest.Geometry
|
||||
polygon.Vertices.Add(new Vector(pt.x, pt.y));
|
||||
|
||||
polygon.Close();
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,13 @@ namespace OpenNest.Geometry
|
||||
get { return Perimeter(); }
|
||||
}
|
||||
|
||||
public override Entity Clone()
|
||||
{
|
||||
var copy = new Polygon { Vertices = new List<Vector>(Vertices) };
|
||||
CopyBaseTo(copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverses the rotation direction of the polygon.
|
||||
/// </summary>
|
||||
@@ -311,18 +318,74 @@ namespace OpenNest.Geometry
|
||||
|
||||
boundingBox.X = minX;
|
||||
boundingBox.Y = minY;
|
||||
boundingBox.Width = maxX - minX;
|
||||
boundingBox.Length = maxY - minY;
|
||||
boundingBox.Length = maxX - minX;
|
||||
boundingBox.Width = maxY - minY;
|
||||
}
|
||||
|
||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
if (Vertices.Count < 3)
|
||||
return null;
|
||||
|
||||
var isClosed = IsClosed();
|
||||
var count = isClosed ? Vertices.Count - 1 : Vertices.Count;
|
||||
if (count < 3)
|
||||
return null;
|
||||
|
||||
var ccw = CalculateArea() > 0;
|
||||
var outward = ccw ? OffsetSide.Left : OffsetSide.Right;
|
||||
var sign = side == outward ? 1.0 : -1.0;
|
||||
var d = distance * sign;
|
||||
|
||||
var normals = new Vector[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var next = (i + 1) % count;
|
||||
var dx = Vertices[next].X - Vertices[i].X;
|
||||
var dy = Vertices[next].Y - Vertices[i].Y;
|
||||
var len = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (len < Tolerance.Epsilon)
|
||||
return null;
|
||||
normals[i] = new Vector(-dy / len * d, dx / len * d);
|
||||
}
|
||||
|
||||
var result = new Polygon();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var prev = (i - 1 + count) % count;
|
||||
|
||||
var a1 = new Vector(Vertices[prev].X + normals[prev].X, Vertices[prev].Y + normals[prev].Y);
|
||||
var a2 = new Vector(Vertices[i].X + normals[prev].X, Vertices[i].Y + normals[prev].Y);
|
||||
var b1 = new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y);
|
||||
var b2 = new Vector(Vertices[(i + 1) % count].X + normals[i].X, Vertices[(i + 1) % count].Y + normals[i].Y);
|
||||
|
||||
var edgeA = new Line(a1, a2);
|
||||
var edgeB = new Line(b1, b2);
|
||||
|
||||
if (edgeA.Intersects(edgeB, out var pt) && pt.IsValid())
|
||||
result.Vertices.Add(pt);
|
||||
else
|
||||
result.Vertices.Add(new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y));
|
||||
}
|
||||
|
||||
result.Close();
|
||||
result.RemoveSelfIntersections();
|
||||
result.UpdateBounds();
|
||||
return result;
|
||||
}
|
||||
|
||||
public override Entity OffsetEntity(double distance, Vector pt)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
var left = OffsetEntity(distance, OffsetSide.Left);
|
||||
var right = OffsetEntity(distance, OffsetSide.Right);
|
||||
|
||||
if (left == null) return right;
|
||||
if (right == null) return left;
|
||||
|
||||
var distLeft = left.ClosestPointTo(pt).DistanceTo(pt);
|
||||
var distRight = right.ClosestPointTo(pt).DistanceTo(pt);
|
||||
|
||||
return distLeft > distRight ? left : right;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -349,6 +349,15 @@ namespace OpenNest.Geometry
|
||||
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>
|
||||
/// Reverses the rotation direction of the shape.
|
||||
/// </summary>
|
||||
@@ -532,9 +541,29 @@ namespace OpenNest.Geometry
|
||||
Line line, Line offsetLine,
|
||||
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.Intersects(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;
|
||||
lastOffsetLine.EndPoint = intersection;
|
||||
@@ -558,6 +587,76 @@ namespace OpenNest.Geometry
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offsets the shape outward by the given distance.
|
||||
/// Normalizes to CW winding before offsetting Left (which is outward for CW),
|
||||
/// making the method independent of the original contour winding direction.
|
||||
/// </summary>
|
||||
public Shape OffsetOutward(double distance)
|
||||
{
|
||||
var poly = ToPolygon();
|
||||
|
||||
if (poly == null || poly.Vertices.Count < 3
|
||||
|| poly.RotationDirection() == RotationType.CW)
|
||||
return OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
|
||||
// Shape is CCW — reverse to CW so Left offset goes outward.
|
||||
var copy = new Shape();
|
||||
|
||||
for (var i = Entities.Count - 1; i >= 0; i--)
|
||||
{
|
||||
switch (Entities[i])
|
||||
{
|
||||
case Line l:
|
||||
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
|
||||
break;
|
||||
case Arc a:
|
||||
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
|
||||
break;
|
||||
case Circle c:
|
||||
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CW });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offsets the shape inward by the given distance.
|
||||
/// Normalizes to CCW winding before offsetting Left (which is inward for CCW),
|
||||
/// making the method independent of the original contour winding direction.
|
||||
/// </summary>
|
||||
public Shape OffsetInward(double distance)
|
||||
{
|
||||
var poly = ToPolygon();
|
||||
|
||||
if (poly == null || poly.Vertices.Count < 3
|
||||
|| poly.RotationDirection() == RotationType.CCW)
|
||||
return OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
|
||||
// Create a reversed copy to avoid mutating shared entity objects.
|
||||
var copy = new Shape();
|
||||
|
||||
for (var i = Entities.Count - 1; i >= 0; i--)
|
||||
{
|
||||
switch (Entities[i])
|
||||
{
|
||||
case Line l:
|
||||
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
|
||||
break;
|
||||
case Arc a:
|
||||
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
|
||||
break;
|
||||
case Circle c:
|
||||
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CCW });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the closest point on the shape to the given point.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -21,9 +22,12 @@ namespace OpenNest.Geometry
|
||||
Perimeter = shapes[0];
|
||||
Cutouts = new List<Shape>();
|
||||
|
||||
for (int i = 1; i < shapes.Count; i++)
|
||||
for (var i = 1; i < shapes.Count; i++)
|
||||
{
|
||||
if (shapes[i].Left < Perimeter.Left)
|
||||
var bb = shapes[i].BoundingBox;
|
||||
var perimBB = Perimeter.BoundingBox;
|
||||
|
||||
if (bb.Width * bb.Length > perimBB.Width * perimBB.Length)
|
||||
{
|
||||
Cutouts.Add(Perimeter);
|
||||
Perimeter = shapes[i];
|
||||
@@ -38,5 +42,53 @@ namespace OpenNest.Geometry
|
||||
public Shape Perimeter { get; set; }
|
||||
|
||||
public List<Shape> Cutouts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ensures CNC-standard winding: perimeter CW (kerf left = outward),
|
||||
/// cutouts CCW (kerf left = inward). Reverses contours in-place as needed.
|
||||
/// </summary>
|
||||
public void NormalizeWinding()
|
||||
{
|
||||
EnsureWinding(Perimeter, RotationType.CW);
|
||||
|
||||
foreach (var cutout in Cutouts)
|
||||
EnsureWinding(cutout, RotationType.CCW);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the entities in normalized winding order (perimeter first, then cutouts).
|
||||
/// </summary>
|
||||
public List<Entity> ToNormalizedEntities()
|
||||
{
|
||||
NormalizeWinding();
|
||||
var result = new List<Entity>(Perimeter.Entities);
|
||||
|
||||
foreach (var cutout in Cutouts)
|
||||
result.AddRange(cutout.Entities);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience method: builds a ShapeProfile from raw entities,
|
||||
/// normalizes winding, and returns the corrected entity list.
|
||||
/// </summary>
|
||||
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
|
||||
{
|
||||
var cloned = entities.CloneAll();
|
||||
var profile = new ShapeProfile(cloned);
|
||||
return profile.ToNormalizedEntities();
|
||||
}
|
||||
|
||||
private static void EnsureWinding(Shape shape, RotationType desired)
|
||||
{
|
||||
var poly = shape.ToPolygon();
|
||||
|
||||
if (poly != null && poly.Vertices.Count >= 3
|
||||
&& poly.RotationDirection() != desired)
|
||||
{
|
||||
shape.Reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||
private static double RayEdgeDistance(
|
||||
public static double RayEdgeDistance(
|
||||
double vx, double vy,
|
||||
double p1x, double p1y, double p2x, double p2y,
|
||||
double dirX, double dirY)
|
||||
@@ -104,6 +104,98 @@ namespace OpenNest.Geometry
|
||||
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>
|
||||
/// Computes the minimum translation distance along a push direction before
|
||||
/// any edge of movingLines contacts any edge of stationaryLines.
|
||||
@@ -111,57 +203,7 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, PushDirection direction)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// 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;
|
||||
return DirectionalDistance(movingLines, 0, 0, stationaryLines, direction);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -176,21 +218,10 @@ namespace OpenNest.Geometry
|
||||
var movingOffset = new Vector(movingDx, movingDy);
|
||||
|
||||
// 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 + movingOffset);
|
||||
movingVertices.Add(movingLines[i].pt2 + movingOffset);
|
||||
}
|
||||
var movingVertices = CollectVertices(movingLines, movingOffset);
|
||||
|
||||
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);
|
||||
|
||||
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();
|
||||
var stationaryEdges = ToEdgeArray(stationaryLines);
|
||||
SortEdgesForPruning(stationaryEdges, direction);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
@@ -200,21 +231,10 @@ namespace OpenNest.Geometry
|
||||
|
||||
// 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 stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
|
||||
|
||||
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();
|
||||
var movingEdges = ToEdgeArray(movingLines);
|
||||
SortEdgesForPruning(movingEdges, opposite);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
@@ -253,15 +273,11 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Extract unique vertices from moving edges.
|
||||
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);
|
||||
}
|
||||
SortEdgesForPruning(stationaryEdges, direction);
|
||||
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = CollectVertices(movingEdges, movingOffset);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
|
||||
@@ -270,12 +286,9 @@ namespace OpenNest.Geometry
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryVertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < stationaryEdges.Length; i++)
|
||||
{
|
||||
stationaryVertices.Add(stationaryEdges[i].start + stationaryOffset);
|
||||
stationaryVertices.Add(stationaryEdges[i].end + stationaryOffset);
|
||||
}
|
||||
SortEdgesForPruning(movingEdges, opposite);
|
||||
|
||||
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
@@ -293,49 +306,38 @@ namespace OpenNest.Geometry
|
||||
var minDist = double.MaxValue;
|
||||
var vx = vertex.X;
|
||||
var vy = vertex.Y;
|
||||
var horizontal = IsHorizontalDirection(direction);
|
||||
|
||||
// Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary.
|
||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
||||
// Pruning: edges are sorted by their perpendicular min-coordinate.
|
||||
// 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;
|
||||
var e2 = edges[i].end + edgeOffset;
|
||||
|
||||
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;
|
||||
perpValue = vy;
|
||||
edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y;
|
||||
edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y;
|
||||
}
|
||||
}
|
||||
else // Up/Down
|
||||
{
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
else
|
||||
{
|
||||
var e1 = edges[i].start + edgeOffset;
|
||||
var e2 = edges[i].end + edgeOffset;
|
||||
|
||||
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;
|
||||
perpValue = vx;
|
||||
edgeMin = e1.X < e2.X ? e1.X : e2.X;
|
||||
edgeMax = e1.X > e2.X ? e1.X : e2.X;
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -467,12 +469,7 @@ namespace OpenNest.Geometry
|
||||
var dirX = direction.X;
|
||||
var dirY = direction.Y;
|
||||
|
||||
var movingVertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < movingLines.Count; i++)
|
||||
{
|
||||
movingVertices.Add(movingLines[i].pt1);
|
||||
movingVertices.Add(movingLines[i].pt2);
|
||||
}
|
||||
var movingVertices = CollectVertices(movingLines, Vector.Zero);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
@@ -487,12 +484,7 @@ namespace OpenNest.Geometry
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var stationaryVertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < stationaryLines.Count; i++)
|
||||
{
|
||||
stationaryVertices.Add(stationaryLines[i].pt1);
|
||||
stationaryVertices.Add(stationaryLines[i].pt2);
|
||||
}
|
||||
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
@@ -507,6 +499,311 @@ namespace OpenNest.Geometry
|
||||
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)
|
||||
{
|
||||
var x = dx >= 0 ? box.Left : box.Right;
|
||||
@@ -523,177 +820,17 @@ namespace OpenNest.Geometry
|
||||
|
||||
#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)
|
||||
{
|
||||
var verticalBoxes = boxes.Where(b => !(b.Left > pt.X || b.Right < pt.X)).ToList();
|
||||
|
||||
#region Find Top/Bottom Limits
|
||||
|
||||
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
|
||||
if (!FindVerticalLimits(pt, bounds, verticalBoxes, out var top, out var btm))
|
||||
return Box.Empty;
|
||||
|
||||
var horizontalBoxes = boxes.Where(b => !(b.Bottom >= top || b.Top <= btm)).ToList();
|
||||
|
||||
#region Find Left/Right Limits
|
||||
|
||||
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
|
||||
if (!FindHorizontalLimits(pt, bounds, horizontalBoxes, out var lft, out var rgt))
|
||||
return Box.Empty;
|
||||
|
||||
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();
|
||||
|
||||
#region Find Left/Right Limits
|
||||
|
||||
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
|
||||
if (!FindHorizontalLimits(pt, bounds, horizontalBoxes, out var lft, out var rgt))
|
||||
return Box.Empty;
|
||||
|
||||
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;
|
||||
var btm = double.MinValue;
|
||||
return new Box(lft, btm, rgt - lft, top - btm);
|
||||
}
|
||||
|
||||
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 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 (bounds.Top > pt.Y) top = bounds.Top;
|
||||
else return false;
|
||||
}
|
||||
|
||||
if (btm == double.MinValue)
|
||||
{
|
||||
if (bounds.Bottom < pt.Y)
|
||||
btm = bounds.Bottom;
|
||||
else return Box.Empty;
|
||||
if (bounds.Bottom < pt.Y) btm = bounds.Bottom;
|
||||
else return false;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
197
OpenNest.Core/Geometry/SplineConverter.cs
Normal file
197
OpenNest.Core/Geometry/SplineConverter.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public static class SplineConverter
|
||||
{
|
||||
private const int MinPointsForArc = 3;
|
||||
|
||||
public static List<Entity> Convert(List<Vector> points, bool isClosed, double tolerance = 0.001)
|
||||
{
|
||||
if (points == null || points.Count < 2)
|
||||
return new List<Entity>();
|
||||
|
||||
var entities = new List<Entity>();
|
||||
var i = 0;
|
||||
var chainedTangent = Vector.Invalid;
|
||||
|
||||
while (i < points.Count - 1)
|
||||
{
|
||||
var result = TryFitArc(points, i, chainedTangent, tolerance);
|
||||
if (result != null)
|
||||
{
|
||||
entities.Add(result.Arc);
|
||||
chainedTangent = result.EndTangent;
|
||||
i = result.EndIndex;
|
||||
}
|
||||
else
|
||||
{
|
||||
entities.Add(new Line(points[i], points[i + 1]));
|
||||
chainedTangent = Vector.Invalid;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static ArcFitResult TryFitArc(List<Vector> points, int start,
|
||||
Vector chainedTangent, double tolerance)
|
||||
{
|
||||
var minEnd = start + MinPointsForArc - 1;
|
||||
if (minEnd >= points.Count)
|
||||
return null;
|
||||
|
||||
var hasTangent = chainedTangent.IsValid();
|
||||
|
||||
var subPoints = points.GetRange(start, MinPointsForArc);
|
||||
var (center, radius, dev) = hasTangent
|
||||
? FitWithStartTangent(subPoints, chainedTangent)
|
||||
: FitCircumscribed(subPoints);
|
||||
|
||||
if (!center.IsValid() || dev > tolerance)
|
||||
return null;
|
||||
|
||||
var endIdx = minEnd;
|
||||
while (endIdx + 1 < points.Count)
|
||||
{
|
||||
var extPoints = points.GetRange(start, endIdx + 1 - start + 1);
|
||||
var (nc, nr, nd) = hasTangent
|
||||
? FitWithStartTangent(extPoints, chainedTangent)
|
||||
: FitCircumscribed(extPoints);
|
||||
|
||||
if (!nc.IsValid() || nd > tolerance)
|
||||
break;
|
||||
|
||||
endIdx++;
|
||||
center = nc;
|
||||
radius = nr;
|
||||
dev = nd;
|
||||
}
|
||||
|
||||
var finalPoints = points.GetRange(start, endIdx - start + 1);
|
||||
var sweep = System.Math.Abs(SumSignedAngles(center, finalPoints));
|
||||
if (sweep < Angle.ToRadians(5))
|
||||
return null;
|
||||
|
||||
var arc = CreateArc(center, radius, finalPoints);
|
||||
var endTangent = ComputeEndTangent(center, finalPoints);
|
||||
|
||||
return new ArcFitResult(arc, endTangent, endIdx);
|
||||
}
|
||||
|
||||
private static (Vector center, double radius, double deviation) FitCircumscribed(
|
||||
List<Vector> points)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var p0 = points[0];
|
||||
var pMid = points[points.Count / 2];
|
||||
var pEnd = points[^1];
|
||||
|
||||
// Find circumcenter by intersecting perpendicular bisectors of two chords
|
||||
var (center, radius) = Circumcenter(p0, pMid, pEnd);
|
||||
if (!center.IsValid())
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
return (center, radius, MaxRadialDeviation(points, center.X, center.Y, radius));
|
||||
}
|
||||
|
||||
private static (Vector center, double radius) Circumcenter(Vector a, Vector b, Vector c)
|
||||
{
|
||||
// Perpendicular bisector of chord a-b
|
||||
var m1x = (a.X + b.X) / 2;
|
||||
var m1y = (a.Y + b.Y) / 2;
|
||||
var d1x = -(b.Y - a.Y);
|
||||
var d1y = b.X - a.X;
|
||||
|
||||
// Perpendicular bisector of chord b-c
|
||||
var m2x = (b.X + c.X) / 2;
|
||||
var m2y = (b.Y + c.Y) / 2;
|
||||
var d2x = -(c.Y - b.Y);
|
||||
var d2y = c.X - b.X;
|
||||
|
||||
var det = d1x * d2y - d1y * d2x;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0);
|
||||
|
||||
var t = ((m2x - m1x) * d2y - (m2y - m1y) * d2x) / det;
|
||||
|
||||
var cx = m1x + t * d1x;
|
||||
var cy = m1y + t * d1y;
|
||||
var radius = System.Math.Sqrt((cx - a.X) * (cx - a.X) + (cy - a.Y) * (cy - a.Y));
|
||||
|
||||
if (radius < 1e-10)
|
||||
return (Vector.Invalid, 0);
|
||||
|
||||
return (new Vector(cx, cy), radius);
|
||||
}
|
||||
|
||||
private static (Vector center, double radius, double deviation) FitWithStartTangent(
|
||||
List<Vector> points, Vector tangent) =>
|
||||
ArcFit.FitWithStartTangent(points, tangent);
|
||||
|
||||
private static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius) =>
|
||||
ArcFit.MaxRadialDeviation(points, cx, cy, radius);
|
||||
|
||||
private static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI) da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI) da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
|
||||
{
|
||||
var lastPt = points[^1];
|
||||
var totalAngle = SumSignedAngles(center, points);
|
||||
|
||||
var rx = lastPt.X - center.X;
|
||||
var ry = lastPt.Y - center.Y;
|
||||
|
||||
return totalAngle >= 0
|
||||
? new Vector(-ry, rx)
|
||||
: new Vector(ry, -rx);
|
||||
}
|
||||
|
||||
private static Arc CreateArc(Vector center, double radius, List<Vector> points)
|
||||
{
|
||||
var firstPoint = points[0];
|
||||
var lastPoint = points[^1];
|
||||
|
||||
var startAngle = 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 isReversed = SumSignedAngles(center, points) < 0;
|
||||
|
||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0) endAngle += Angle.TwoPI;
|
||||
|
||||
return new Arc(center, radius, startAngle, endAngle, isReversed);
|
||||
}
|
||||
|
||||
private sealed class ArcFitResult
|
||||
{
|
||||
public Arc Arc { get; }
|
||||
public Vector EndTangent { get; }
|
||||
public int EndIndex { get; }
|
||||
|
||||
public ArcFitResult(Arc arc, Vector endTangent, int endIndex)
|
||||
{
|
||||
Arc = arc;
|
||||
EndTangent = endTangent;
|
||||
EndIndex = endIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
OpenNest.Core/IConfigurablePostProcessor.cs
Normal file
9
OpenNest.Core/IConfigurablePostProcessor.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
public interface IConfigurablePostProcessor : IPostProcessor
|
||||
{
|
||||
object Config { get; }
|
||||
|
||||
void SaveConfig();
|
||||
}
|
||||
}
|
||||
9
OpenNest.Core/IMaterialProvidingPostProcessor.cs
Normal file
9
OpenNest.Core/IMaterialProvidingPostProcessor.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public interface IMaterialProvidingPostProcessor
|
||||
{
|
||||
IEnumerable<string> GetMaterialNames();
|
||||
}
|
||||
}
|
||||
7
OpenNest.Core/IPostProcessorNestAware.cs
Normal file
7
OpenNest.Core/IPostProcessorNestAware.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
public interface IPostProcessorNestAware
|
||||
{
|
||||
void PrepareForNest(Nest nest);
|
||||
}
|
||||
}
|
||||
158
OpenNest.Core/Math/ExpressionEvaluator.cs
Normal file
158
OpenNest.Core/Math/ExpressionEvaluator.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
@@ -21,6 +22,7 @@ namespace OpenNest
|
||||
Plates.ItemRemoved += Plates_PlateRemoved;
|
||||
Drawings = new DrawingCollection();
|
||||
PlateDefaults = new PlateSettings();
|
||||
Material = new Material();
|
||||
Customer = string.Empty;
|
||||
Notes = string.Empty;
|
||||
}
|
||||
@@ -36,6 +38,12 @@ namespace OpenNest
|
||||
|
||||
public string Notes { get; set; }
|
||||
|
||||
public string AssistGas { get; set; } = "";
|
||||
|
||||
public double Thickness { get; set; }
|
||||
|
||||
public Material Material { get; set; }
|
||||
|
||||
public Units Units { get; set; }
|
||||
|
||||
public DateTime DateCreated { get; set; }
|
||||
@@ -44,6 +52,10 @@ namespace OpenNest
|
||||
|
||||
public PlateSettings PlateDefaults { get; set; }
|
||||
|
||||
public List<PlateOption> PlateOptions { get; set; } = new();
|
||||
|
||||
public double SalvageRate { get; set; } = 0.5;
|
||||
|
||||
public Plate CreatePlate()
|
||||
{
|
||||
var plate = PlateDefaults.CreateNew();
|
||||
@@ -82,18 +94,6 @@ namespace OpenNest
|
||||
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
|
||||
{
|
||||
get { return plate.Size; }
|
||||
@@ -114,9 +114,7 @@ namespace OpenNest
|
||||
|
||||
public void SetFromExisting(Plate plate)
|
||||
{
|
||||
Thickness = plate.Thickness;
|
||||
Quadrant = plate.Quadrant;
|
||||
Material = plate.Material;
|
||||
Size = plate.Size;
|
||||
EdgeSpacing = plate.EdgeSpacing;
|
||||
PartSpacing = plate.PartSpacing;
|
||||
@@ -126,11 +124,9 @@ namespace OpenNest
|
||||
{
|
||||
return new Plate()
|
||||
{
|
||||
Thickness = Thickness,
|
||||
Size = Size,
|
||||
EdgeSpacing = EdgeSpacing,
|
||||
PartSpacing = PartSpacing,
|
||||
Material = Material,
|
||||
Quadrant = Quadrant,
|
||||
Quantity = 1
|
||||
};
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<RootNamespace>OpenNest</RootNamespace>
|
||||
<AssemblyName>OpenNest.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Clipper2" Version="2.0.0" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
@@ -21,6 +22,7 @@ namespace OpenNest
|
||||
{
|
||||
private Vector location;
|
||||
private bool ownsProgram;
|
||||
private double preLeadInRotation;
|
||||
|
||||
public readonly Drawing BaseDrawing;
|
||||
|
||||
@@ -55,12 +57,61 @@ namespace OpenNest
|
||||
|
||||
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>
|
||||
/// Gets the rotation of the part in radians.
|
||||
/// </summary>
|
||||
public double Rotation
|
||||
{
|
||||
get { return Program.Rotation; }
|
||||
get { return HasManualLeadIns ? preLeadInRotation : Program.Rotation; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -72,6 +123,7 @@ namespace OpenNest
|
||||
EnsureOwnedProgram();
|
||||
Program.Rotate(angle);
|
||||
location = Location.Rotate(angle);
|
||||
preLeadInRotation = Program.Rotation;
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
@@ -85,6 +137,7 @@ namespace OpenNest
|
||||
EnsureOwnedProgram();
|
||||
Program.Rotate(angle);
|
||||
location = Location.Rotate(angle, origin);
|
||||
preLeadInRotation = Program.Rotation;
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
@@ -142,7 +195,14 @@ namespace OpenNest
|
||||
{
|
||||
var rotation = Rotation;
|
||||
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>
|
||||
@@ -170,10 +230,18 @@ namespace OpenNest
|
||||
if (perimeter1 == null || perimeter2 == null)
|
||||
return false;
|
||||
|
||||
perimeter1.Offset(Location);
|
||||
perimeter2.Offset(part.Location);
|
||||
var polygon1 = perimeter1.ToPolygon();
|
||||
var polygon2 = perimeter2.ToPolygon();
|
||||
|
||||
return perimeter1.Intersects(perimeter2, out pts);
|
||||
if (polygon1 == null || polygon2 == null)
|
||||
return false;
|
||||
|
||||
polygon1.Offset(Location);
|
||||
polygon2.Offset(part.Location);
|
||||
|
||||
var result = Geometry.Collision.Check(polygon1, polygon2);
|
||||
pts = result.IntersectionPoints.ToList();
|
||||
return result.Overlaps;
|
||||
}
|
||||
|
||||
public double Left
|
||||
@@ -221,7 +289,7 @@ namespace OpenNest
|
||||
var part = new Part(BaseDrawing, Program,
|
||||
location + offset,
|
||||
new Box(BoundingBox.X + offset.X, BoundingBox.Y + offset.Y,
|
||||
BoundingBox.Width, BoundingBox.Length));
|
||||
BoundingBox.Length, BoundingBox.Width));
|
||||
|
||||
return part;
|
||||
}
|
||||
|
||||
@@ -39,25 +39,120 @@ namespace OpenNest
|
||||
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 shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
|
||||
var profile = new ShapeProfile(
|
||||
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
var lines = new List<Line>();
|
||||
var totalSpacing = spacing;
|
||||
|
||||
foreach (var shape in shapes)
|
||||
AddOffsetLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
|
||||
chordTolerance, part.Location);
|
||||
|
||||
if (!perimeterOnly)
|
||||
{
|
||||
// Add chord tolerance to compensate for inscribed polygon chords
|
||||
// being inside the actual offset arcs.
|
||||
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
|
||||
|
||||
if (offsetEntity == null)
|
||||
continue;
|
||||
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.RemoveSelfIntersections();
|
||||
polygon.Offset(part.Location);
|
||||
lines.AddRange(polygon.ToLines());
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
|
||||
chordTolerance, part.Location);
|
||||
}
|
||||
|
||||
return lines;
|
||||
@@ -66,21 +161,17 @@ namespace OpenNest
|
||||
public static List<Line> GetOffsetPartLines(Part part, double spacing, PushDirection facingDirection, double chordTolerance = 0.001)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
|
||||
var profile = new ShapeProfile(
|
||||
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
var lines = new List<Line>();
|
||||
var totalSpacing = spacing;
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
|
||||
AddOffsetDirectionalLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
|
||||
chordTolerance, part.Location, facingDirection);
|
||||
|
||||
if (offsetEntity == null)
|
||||
continue;
|
||||
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.RemoveSelfIntersections();
|
||||
polygon.Offset(part.Location);
|
||||
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
|
||||
}
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
AddOffsetDirectionalLines(lines, cutout.OffsetInward(totalSpacing),
|
||||
chordTolerance, part.Location, facingDirection);
|
||||
|
||||
return lines;
|
||||
}
|
||||
@@ -104,21 +195,17 @@ namespace OpenNest
|
||||
public static List<Line> GetOffsetPartLines(Part part, double spacing, Vector facingDirection, double chordTolerance = 0.001)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
|
||||
var profile = new ShapeProfile(
|
||||
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
var lines = new List<Line>();
|
||||
var totalSpacing = spacing;
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
|
||||
AddOffsetDirectionalLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
|
||||
chordTolerance, part.Location, facingDirection);
|
||||
|
||||
if (offsetEntity == null)
|
||||
continue;
|
||||
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.RemoveSelfIntersections();
|
||||
polygon.Offset(part.Location);
|
||||
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
|
||||
}
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
AddOffsetDirectionalLines(lines, cutout.OffsetInward(totalSpacing),
|
||||
chordTolerance, part.Location, facingDirection);
|
||||
|
||||
return lines;
|
||||
}
|
||||
@@ -189,5 +276,41 @@ namespace OpenNest
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static void AddOffsetLines(List<Line> lines, Shape offsetEntity,
|
||||
double chordTolerance, Vector location)
|
||||
{
|
||||
if (offsetEntity == null)
|
||||
return;
|
||||
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.RemoveSelfIntersections();
|
||||
polygon.Offset(location);
|
||||
lines.AddRange(polygon.ToLines());
|
||||
}
|
||||
|
||||
private static void AddOffsetDirectionalLines(List<Line> lines, Shape offsetEntity,
|
||||
double chordTolerance, Vector location, PushDirection facingDirection)
|
||||
{
|
||||
if (offsetEntity == null)
|
||||
return;
|
||||
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.RemoveSelfIntersections();
|
||||
polygon.Offset(location);
|
||||
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
|
||||
}
|
||||
|
||||
private static void AddOffsetDirectionalLines(List<Line> lines, Shape offsetEntity,
|
||||
double chordTolerance, Vector location, Vector facingDirection)
|
||||
{
|
||||
if (offsetEntity == null)
|
||||
return;
|
||||
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.RemoveSelfIntersections();
|
||||
polygon.Offset(location);
|
||||
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using OpenNest.Shapes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -43,28 +44,25 @@ namespace OpenNest
|
||||
{
|
||||
EdgeSpacing = new Spacing();
|
||||
Size = size;
|
||||
Material = new Material();
|
||||
Parts = new ObservableList<Part>();
|
||||
Parts.ItemAdded += Parts_PartAdded;
|
||||
Parts.ItemRemoved += Parts_PartRemoved;
|
||||
CutOffs = new ObservableList<CutOff>();
|
||||
Quadrant = 1;
|
||||
}
|
||||
|
||||
private void Parts_PartAdded(object sender, ItemAddedEventArgs<Part> e)
|
||||
{
|
||||
e.Item.BaseDrawing.Quantity.Nested += Quantity;
|
||||
if (!e.Item.BaseDrawing.IsCutOff)
|
||||
e.Item.BaseDrawing.Quantity.Nested += Quantity;
|
||||
}
|
||||
|
||||
private void Parts_PartRemoved(object sender, ItemRemovedEventArgs<Part> e)
|
||||
{
|
||||
e.Item.BaseDrawing.Quantity.Nested -= Quantity;
|
||||
if (!e.Item.BaseDrawing.IsCutOff)
|
||||
e.Item.BaseDrawing.Quantity.Nested -= Quantity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thickness of the plate.
|
||||
/// </summary>
|
||||
public double Thickness { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The spacing between parts.
|
||||
/// </summary>
|
||||
@@ -80,16 +78,104 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public Size Size { get; set; }
|
||||
|
||||
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Material the plate is made out of.
|
||||
/// Material grain direction in radians. 0 = horizontal.
|
||||
/// </summary>
|
||||
public Material Material { get; set; }
|
||||
public double GrainAngle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The parts that the plate contains.
|
||||
/// </summary>
|
||||
public ObservableList<Part> Parts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The cut-off lines defined on this plate.
|
||||
/// </summary>
|
||||
public ObservableList<CutOff> CutOffs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Regenerates all cut-off drawings and materializes them as parts.
|
||||
/// Existing cut-off parts are removed first, then each cut-off is
|
||||
/// regenerated and added back if it produces any geometry.
|
||||
/// </summary>
|
||||
public void RegenerateCutOffs(CutOffSettings settings)
|
||||
{
|
||||
// Remove existing cut-off parts
|
||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (Parts[i].BaseDrawing.IsCutOff)
|
||||
Parts.RemoveAt(i);
|
||||
}
|
||||
|
||||
var cache = BuildPerimeterCache(this);
|
||||
|
||||
// Regenerate and materialize each cut-off
|
||||
foreach (var cutoff in CutOffs)
|
||||
{
|
||||
cutoff.Regenerate(this, settings, cache);
|
||||
|
||||
if (cutoff.Drawing.Program.Codes.Count == 0)
|
||||
continue;
|
||||
|
||||
var part = new Part(cutoff.Drawing);
|
||||
Parts.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a dictionary mapping each non-cut-off part to its perimeter entity.
|
||||
/// Closed shapes use ShapeProfile; open contours fall back to ConvexHull.
|
||||
/// </summary>
|
||||
public static Dictionary<Part, Geometry.Entity> BuildPerimeterCache(Plate plate)
|
||||
{
|
||||
var cache = new Dictionary<Part, Geometry.Entity>();
|
||||
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
Geometry.Entity perimeter = null;
|
||||
try
|
||||
{
|
||||
var entities = Converters.ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
|
||||
if (entities.Count > 0)
|
||||
{
|
||||
var profile = new Geometry.ShapeProfile(entities);
|
||||
|
||||
if (profile.Perimeter.IsClosed())
|
||||
{
|
||||
perimeter = profile.Perimeter;
|
||||
perimeter.Offset(part.Location);
|
||||
}
|
||||
else
|
||||
{
|
||||
var points = entities.CollectPoints();
|
||||
if (points.Count >= 3)
|
||||
{
|
||||
var hull = Geometry.ConvexHull.Compute(points);
|
||||
hull.Offset(part.Location);
|
||||
perimeter = hull;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
perimeter = null;
|
||||
}
|
||||
|
||||
cache[part] = perimeter;
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The number of times to cut the plate.
|
||||
/// </summary>
|
||||
@@ -240,11 +326,20 @@ namespace OpenNest
|
||||
/// <param name="angle"></param>
|
||||
public void Rotate(double angle)
|
||||
{
|
||||
for (int i = 0; i < Parts.Count; ++i)
|
||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (Parts[i].BaseDrawing.IsCutOff)
|
||||
Parts.RemoveAt(i);
|
||||
}
|
||||
|
||||
for (var i = 0; i < Parts.Count; ++i)
|
||||
{
|
||||
var part = Parts[i];
|
||||
part.Rotate(angle);
|
||||
}
|
||||
|
||||
foreach (var cutoff in CutOffs)
|
||||
cutoff.Position = cutoff.Position.Rotate(angle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -254,11 +349,24 @@ namespace OpenNest
|
||||
/// <param name="origin"></param>
|
||||
public void Rotate(double angle, Vector origin)
|
||||
{
|
||||
for (int i = 0; i < Parts.Count; ++i)
|
||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (Parts[i].BaseDrawing.IsCutOff)
|
||||
Parts.RemoveAt(i);
|
||||
}
|
||||
|
||||
for (var i = 0; i < Parts.Count; ++i)
|
||||
{
|
||||
var part = Parts[i];
|
||||
part.Rotate(angle, origin);
|
||||
}
|
||||
|
||||
foreach (var cutoff in CutOffs)
|
||||
{
|
||||
var pos = cutoff.Position - origin;
|
||||
pos = pos.Rotate(angle);
|
||||
cutoff.Position = pos + origin;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -268,11 +376,22 @@ namespace OpenNest
|
||||
/// <param name="y"></param>
|
||||
public void Offset(double x, double y)
|
||||
{
|
||||
for (int i = 0; i < Parts.Count; ++i)
|
||||
// Remove cut-off parts before transforming
|
||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (Parts[i].BaseDrawing.IsCutOff)
|
||||
Parts.RemoveAt(i);
|
||||
}
|
||||
|
||||
for (var i = 0; i < Parts.Count; ++i)
|
||||
{
|
||||
var part = Parts[i];
|
||||
part.Offset(x, y);
|
||||
}
|
||||
|
||||
// Transform cut-off positions
|
||||
foreach (var cutoff in CutOffs)
|
||||
cutoff.Position = new Vector(cutoff.Position.X + x, cutoff.Position.Y + y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -281,11 +400,20 @@ namespace OpenNest
|
||||
/// <param name="voffset"></param>
|
||||
public void Offset(Vector voffset)
|
||||
{
|
||||
for (int i = 0; i < Parts.Count; ++i)
|
||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (Parts[i].BaseDrawing.IsCutOff)
|
||||
Parts.RemoveAt(i);
|
||||
}
|
||||
|
||||
for (var i = 0; i < Parts.Count; ++i)
|
||||
{
|
||||
var part = Parts[i];
|
||||
part.Offset(voffset);
|
||||
}
|
||||
|
||||
foreach (var cutoff in CutOffs)
|
||||
cutoff.Position = new Vector(cutoff.Position.X + voffset.X, cutoff.Position.Y + voffset.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -297,7 +425,7 @@ namespace OpenNest
|
||||
{
|
||||
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)
|
||||
{
|
||||
case 1:
|
||||
@@ -324,8 +452,8 @@ namespace OpenNest
|
||||
return new Box();
|
||||
}
|
||||
|
||||
plateBox.Width = Size.Length;
|
||||
plateBox.Length = Size.Width;
|
||||
plateBox.Width = Size.Width;
|
||||
plateBox.Length = Size.Length;
|
||||
|
||||
if (!includeParts)
|
||||
return plateBox;
|
||||
@@ -341,11 +469,11 @@ namespace OpenNest
|
||||
? partsBox.Bottom
|
||||
: plateBox.Bottom;
|
||||
|
||||
boundingBox.Width = partsBox.Right > plateBox.Right
|
||||
boundingBox.Length = partsBox.Right > plateBox.Right
|
||||
? partsBox.Right - boundingBox.X
|
||||
: plateBox.Right - boundingBox.X;
|
||||
|
||||
boundingBox.Length = partsBox.Top > plateBox.Top
|
||||
boundingBox.Width = partsBox.Top > plateBox.Top
|
||||
? partsBox.Top - boundingBox.Y
|
||||
: plateBox.Top - boundingBox.Y;
|
||||
|
||||
@@ -362,8 +490,8 @@ namespace OpenNest
|
||||
|
||||
box.X += EdgeSpacing.Left;
|
||||
box.Y += EdgeSpacing.Bottom;
|
||||
box.Width -= EdgeSpacing.Left + EdgeSpacing.Right;
|
||||
box.Length -= EdgeSpacing.Top + EdgeSpacing.Bottom;
|
||||
box.Length -= EdgeSpacing.Left + EdgeSpacing.Right;
|
||||
box.Width -= EdgeSpacing.Top + EdgeSpacing.Bottom;
|
||||
|
||||
return box;
|
||||
}
|
||||
@@ -421,6 +549,65 @@ namespace OpenNest
|
||||
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>
|
||||
/// Gets the area of the top surface of the plate.
|
||||
/// </summary>
|
||||
@@ -433,19 +620,17 @@ namespace OpenNest
|
||||
/// <summary>
|
||||
/// Gets the volume of the plate.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public double Volume()
|
||||
public double Volume(double thickness)
|
||||
{
|
||||
return Area() * Thickness;
|
||||
return Area() * thickness;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the weight of the plate.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public double Weight()
|
||||
public double Weight(double thickness, double density)
|
||||
{
|
||||
return Volume() * Material.Density;
|
||||
return Volume(thickness) * density;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -454,24 +639,37 @@ namespace OpenNest
|
||||
/// <returns>Returns a number between 0.0 and 1.0</returns>
|
||||
public double Utilization()
|
||||
{
|
||||
return Parts.Sum(part => part.BaseDrawing.Area) / Area();
|
||||
return Parts.Where(p => !p.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area) / Area();
|
||||
}
|
||||
|
||||
public bool HasOverlappingParts(out List<Vector> pts)
|
||||
{
|
||||
pts = new List<Vector>();
|
||||
var realParts = Parts.Where(p => !p.BaseDrawing.IsCutOff).ToList();
|
||||
|
||||
for (int i = 0; i < Parts.Count; i++)
|
||||
for (var i = 0; i < realParts.Count; i++)
|
||||
{
|
||||
var part1 = Parts[i];
|
||||
var part1 = realParts[i];
|
||||
var b1 = part1.BoundingBox;
|
||||
|
||||
for (int j = i + 1; j < Parts.Count; j++)
|
||||
for (var j = i + 1; j < realParts.Count; j++)
|
||||
{
|
||||
var part2 = Parts[j];
|
||||
var part2 = realParts[j];
|
||||
var b2 = part2.BoundingBox;
|
||||
|
||||
List<Vector> pts2;
|
||||
// Skip pairs whose bounding boxes don't meaningfully overlap.
|
||||
// Floating-point rounding can produce sub-epsilon overlaps for
|
||||
// parts that are merely edge-touching, so require the overlap
|
||||
// region to exceed Epsilon in both dimensions.
|
||||
var overlapX = System.Math.Min(b1.Right, b2.Right)
|
||||
- System.Math.Max(b1.Left, b2.Left);
|
||||
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
||||
- System.Math.Max(b1.Bottom, b2.Bottom);
|
||||
|
||||
if (part1.Intersects(part2, out pts2))
|
||||
if (overlapX <= Math.Tolerance.Epsilon || overlapY <= Math.Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
if (part1.Intersects(part2, out var pts2))
|
||||
pts.AddRange(pts2);
|
||||
}
|
||||
}
|
||||
|
||||
243
OpenNest.Core/PlateManager.cs
Normal file
243
OpenNest.Core/PlateManager.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
OpenNest.Core/PlateOptimizerResult.cs
Normal file
12
OpenNest.Core/PlateOptimizerResult.cs
Normal file
@@ -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; }
|
||||
}
|
||||
}
|
||||
11
OpenNest.Core/PlateOption.cs
Normal file
11
OpenNest.Core/PlateOption.cs
Normal file
@@ -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 override string GenerateName() => $"Circle {Dim(Diameter)} Dia";
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Diameter = 8;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var entities = new List<Entity>
|
||||
|
||||
@@ -8,6 +8,14 @@ namespace OpenNest.Shapes
|
||||
public double Base { 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()
|
||||
{
|
||||
var midX = Base / 2.0;
|
||||
|
||||
@@ -10,6 +10,16 @@ namespace OpenNest.Shapes
|
||||
public double LegWidth { 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()
|
||||
{
|
||||
var lw = LegWidth > 0 ? LegWidth : Width / 2.0;
|
||||
|
||||
@@ -3,28 +3,40 @@ using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Shapes
|
||||
{
|
||||
public class OctagonShape : ShapeDefinition
|
||||
public class NgonShape : ShapeDefinition
|
||||
{
|
||||
public int Sides { 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()
|
||||
{
|
||||
var n = Sides < 3 ? 3 : Sides;
|
||||
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];
|
||||
for (var i = 0; i < 8; i++)
|
||||
var vertices = new Vector[n];
|
||||
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(
|
||||
center + circumRadius * System.Math.Cos(angle),
|
||||
center + circumRadius * System.Math.Sin(angle));
|
||||
}
|
||||
|
||||
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]));
|
||||
}
|
||||
|
||||
@@ -3,22 +3,41 @@ using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Shapes
|
||||
{
|
||||
public class FlangeShape : ShapeDefinition
|
||||
public class PipeFlangeShape : ShapeDefinition
|
||||
{
|
||||
public double NominalPipeSize { get; set; }
|
||||
public double OD { get; set; }
|
||||
public double HoleDiameter { get; set; }
|
||||
public double HolePatternDiameter { 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()
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
|
||||
// Outer circle
|
||||
entities.Add(new Circle(0, 0, OD / 2.0));
|
||||
|
||||
// Bolt holes evenly spaced on the bolt circle
|
||||
var boltCircleRadius = HolePatternDiameter / 2.0;
|
||||
var holeRadius = HoleDiameter / 2.0;
|
||||
var angleStep = 2.0 * System.Math.PI / HoleCount;
|
||||
@@ -31,6 +50,12 @@ namespace OpenNest.Shapes
|
||||
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);
|
||||
}
|
||||
}
|
||||
78
OpenNest.Core/Shapes/PipeSizes.cs
Normal file
78
OpenNest.Core/Shapes/PipeSizes.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
255
OpenNest.Core/Shapes/PlateSizes.cs
Normal file
255
OpenNest.Core/Shapes/PlateSizes.cs
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,25 @@ namespace OpenNest.Shapes
|
||||
{
|
||||
public class RectangleShape : ShapeDefinition
|
||||
{
|
||||
public double Length { get; set; }
|
||||
public double Width { get; set; }
|
||||
public double Height { get; set; }
|
||||
|
||||
public override string GenerateName() => $"Rectangle {Dim(Length)}x{Dim(Width)}";
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Length = 12;
|
||||
Width = 6;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var entities = new List<Entity>
|
||||
{
|
||||
new Line(0, 0, Width, 0),
|
||||
new Line(Width, 0, Width, Height),
|
||||
new Line(Width, Height, 0, Height),
|
||||
new Line(0, Height, 0, 0)
|
||||
new Line(0, 0, Length, 0),
|
||||
new Line(Length, 0, Length, Width),
|
||||
new Line(Length, Width, 0, Width),
|
||||
new Line(0, Width, 0, 0)
|
||||
};
|
||||
|
||||
return CreateDrawing(entities);
|
||||
|
||||
@@ -8,6 +8,14 @@ namespace OpenNest.Shapes
|
||||
public double Width { 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()
|
||||
{
|
||||
var entities = new List<Entity>
|
||||
|
||||
@@ -8,6 +8,14 @@ namespace OpenNest.Shapes
|
||||
public double OuterDiameter { 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()
|
||||
{
|
||||
var entities = new List<Entity>
|
||||
|
||||
@@ -6,10 +6,19 @@ namespace OpenNest.Shapes
|
||||
{
|
||||
public class RoundedRectangleShape : ShapeDefinition
|
||||
{
|
||||
public double Length { get; set; }
|
||||
public double Width { get; set; }
|
||||
public double Height { 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()
|
||||
{
|
||||
var r = Radius;
|
||||
@@ -17,36 +26,36 @@ namespace OpenNest.Shapes
|
||||
|
||||
if (r <= 0)
|
||||
{
|
||||
entities.Add(new Line(0, 0, Width, 0));
|
||||
entities.Add(new Line(Width, 0, Width, Height));
|
||||
entities.Add(new Line(Width, Height, 0, Height));
|
||||
entities.Add(new Line(0, Height, 0, 0));
|
||||
entities.Add(new Line(0, 0, Length, 0));
|
||||
entities.Add(new Line(Length, 0, Length, Width));
|
||||
entities.Add(new Line(Length, Width, 0, Width));
|
||||
entities.Add(new Line(0, Width, 0, 0));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Bottom edge (left to right, above bottom-left arc to bottom-right arc)
|
||||
entities.Add(new Line(r, 0, Width - r, 0));
|
||||
entities.Add(new Line(r, 0, Length - r, 0));
|
||||
|
||||
// Bottom-right corner arc: center at (Width-r, r), from 270deg to 360deg
|
||||
entities.Add(new Arc(Width - r, r, r,
|
||||
// Bottom-right corner arc: center at (Length-r, r), from 270deg to 360deg
|
||||
entities.Add(new Arc(Length - r, r, r,
|
||||
Angle.ToRadians(270), Angle.ToRadians(360)));
|
||||
|
||||
// Right edge
|
||||
entities.Add(new Line(Width, r, Width, Height - r));
|
||||
entities.Add(new Line(Length, r, Length, Width - r));
|
||||
|
||||
// Top-right corner arc: center at (Width-r, Height-r), from 0deg to 90deg
|
||||
entities.Add(new Arc(Width - r, Height - r, r,
|
||||
// Top-right corner arc: center at (Length-r, Width-r), from 0deg to 90deg
|
||||
entities.Add(new Arc(Length - r, Width - r, r,
|
||||
Angle.ToRadians(0), Angle.ToRadians(90)));
|
||||
|
||||
// Top edge (right to left)
|
||||
entities.Add(new Line(Width - r, Height, r, Height));
|
||||
entities.Add(new Line(Length - r, Width, r, Width));
|
||||
|
||||
// Top-left corner arc: center at (r, Height-r), from 90deg to 180deg
|
||||
entities.Add(new Arc(r, Height - r, r,
|
||||
// Top-left corner arc: center at (r, Width-r), from 90deg to 180deg
|
||||
entities.Add(new Arc(r, Width - r, r,
|
||||
Angle.ToRadians(90), Angle.ToRadians(180)));
|
||||
|
||||
// Left edge
|
||||
entities.Add(new Line(0, Height - r, 0, r));
|
||||
entities.Add(new Line(0, Width - r, 0, r));
|
||||
|
||||
// Bottom-left corner arc: center at (r, r), from 180deg to 270deg
|
||||
entities.Add(new Arc(r, r, r,
|
||||
|
||||
@@ -26,12 +26,24 @@ namespace OpenNest.Shapes
|
||||
|
||||
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
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions);
|
||||
}
|
||||
|
||||
protected static string Dim(double value) => value.ToString("0.###");
|
||||
|
||||
protected Drawing CreateDrawing(List<Entity> entities)
|
||||
{
|
||||
var pgm = ConvertGeometry.ToProgram(entities);
|
||||
|
||||
@@ -10,6 +10,16 @@ namespace OpenNest.Shapes
|
||||
public double StemWidth { 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()
|
||||
{
|
||||
var sw = StemWidth > 0 ? StemWidth : Width / 3.0;
|
||||
|
||||
@@ -9,6 +9,15 @@ namespace OpenNest.Shapes
|
||||
public double BottomWidth { 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()
|
||||
{
|
||||
var offset = (BottomWidth - TopWidth) / 2.0;
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Drawing;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public static class SpecialLayers
|
||||
{
|
||||
public static readonly Layer Default = new Layer("0");
|
||||
public static readonly Layer Default = new Layer("0") { Color = Color.White };
|
||||
|
||||
public static readonly Layer Cut = new Layer("CUT");
|
||||
public static readonly Layer Cut = new Layer("CUT") { Color = Color.White };
|
||||
|
||||
public static readonly Layer Rapid = new Layer("RAPID");
|
||||
public static readonly Layer Rapid = new Layer("RAPID") { Color = Color.Gray };
|
||||
|
||||
public static readonly Layer Display = new Layer("DISPLAY");
|
||||
public static readonly Layer Display = new Layer("DISPLAY") { Color = Color.Cyan };
|
||||
|
||||
public static readonly Layer Leadin = new Layer("LEADIN");
|
||||
public static readonly Layer Leadin = new Layer("LEADIN") { Color = Color.Brown };
|
||||
|
||||
public static readonly Layer Leadout = new Layer("LEADOUT");
|
||||
public static readonly Layer Leadout = new Layer("LEADOUT") { Color = Color.Brown };
|
||||
|
||||
public static readonly Layer Scribe = new Layer("SCRIBE");
|
||||
public static readonly Layer Scribe = new Layer("SCRIBE") { Color = Color.Magenta };
|
||||
}
|
||||
}
|
||||
|
||||
51
OpenNest.Core/Splitting/AutoSplitCalculator.cs
Normal file
51
OpenNest.Core/Splitting/AutoSplitCalculator.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
public static class AutoSplitCalculator
|
||||
{
|
||||
public static List<SplitLine> FitToPlate(Box partBounds, double plateWidth, double plateHeight,
|
||||
double edgeSpacing, double featureOverhang)
|
||||
{
|
||||
var usableWidth = plateWidth - 2 * edgeSpacing - featureOverhang;
|
||||
var usableHeight = plateHeight - 2 * edgeSpacing - featureOverhang;
|
||||
|
||||
var lines = new List<SplitLine>();
|
||||
|
||||
var verticalSplits = usableWidth > 0 ? (int)System.Math.Ceiling(partBounds.Length / usableWidth) - 1 : 0;
|
||||
var horizontalSplits = usableHeight > 0 ? (int)System.Math.Ceiling(partBounds.Width / usableHeight) - 1 : 0;
|
||||
|
||||
if (verticalSplits < 0) verticalSplits = 0;
|
||||
if (horizontalSplits < 0) horizontalSplits = 0;
|
||||
|
||||
for (var i = 1; i <= verticalSplits; i++)
|
||||
lines.Add(new SplitLine(partBounds.X + usableWidth * i, CutOffAxis.Vertical));
|
||||
|
||||
for (var i = 1; i <= horizontalSplits; i++)
|
||||
lines.Add(new SplitLine(partBounds.Y + usableHeight * i, CutOffAxis.Horizontal));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
public static List<SplitLine> SplitByCount(Box partBounds, int horizontalPieces, int verticalPieces)
|
||||
{
|
||||
var lines = new List<SplitLine>();
|
||||
|
||||
if (verticalPieces > 1)
|
||||
{
|
||||
var spacing = partBounds.Length / verticalPieces;
|
||||
for (var i = 1; i < verticalPieces; i++)
|
||||
lines.Add(new SplitLine(partBounds.X + spacing * i, CutOffAxis.Vertical));
|
||||
}
|
||||
|
||||
if (horizontalPieces > 1)
|
||||
{
|
||||
var spacing = partBounds.Width / horizontalPieces;
|
||||
for (var i = 1; i < horizontalPieces; i++)
|
||||
lines.Add(new SplitLine(partBounds.Y + spacing * i, CutOffAxis.Horizontal));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
719
OpenNest.Core/Splitting/DrawingSplitter.cs
Normal file
719
OpenNest.Core/Splitting/DrawingSplitter.cs
Normal file
@@ -0,0 +1,719 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Splits a Drawing into multiple pieces along split lines with optional feature geometry.
|
||||
/// </summary>
|
||||
public static class DrawingSplitter
|
||||
{
|
||||
public static List<Drawing> Split(Drawing drawing, List<SplitLine> splitLines, SplitParameters parameters)
|
||||
{
|
||||
if (splitLines.Count == 0)
|
||||
return new List<Drawing> { drawing };
|
||||
|
||||
var profile = BuildProfile(drawing);
|
||||
DecomposeCircles(profile);
|
||||
|
||||
var perimeter = profile.Perimeter;
|
||||
var bounds = perimeter.BoundingBox;
|
||||
|
||||
var sortedLines = splitLines
|
||||
.Where(l => IsLineInsideBounds(l, bounds))
|
||||
.OrderBy(l => l.Position)
|
||||
.ToList();
|
||||
|
||||
if (sortedLines.Count == 0)
|
||||
return new List<Drawing> { drawing };
|
||||
|
||||
var regions = BuildClipRegions(sortedLines, bounds);
|
||||
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 pieceIndex = 1;
|
||||
|
||||
foreach (var region in regions)
|
||||
{
|
||||
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters, cutoutPolygons);
|
||||
if (pieceEntities.Count == 0)
|
||||
continue;
|
||||
|
||||
var cutoutEntities = CollectCutouts(profile.Cutouts, region, sortedLines);
|
||||
|
||||
var allEntities = new List<Entity>();
|
||||
allEntities.AddRange(pieceEntities);
|
||||
allEntities.AddRange(cutoutEntities);
|
||||
|
||||
// A single region may yield multiple physically-disjoint pieces when an
|
||||
// interior cutout spans across it. Group the region's entities into
|
||||
// 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;
|
||||
}
|
||||
|
||||
private static ShapeProfile BuildProfile(Drawing drawing)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
return new ShapeProfile(entities);
|
||||
}
|
||||
|
||||
private static List<Entity> CollectCutouts(List<Shape> cutouts, Box region, List<SplitLine> splitLines)
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
foreach (var cutout in cutouts)
|
||||
{
|
||||
if (IsCutoutInRegion(cutout, region))
|
||||
entities.AddRange(cutout.Entities);
|
||||
else if (DoesCutoutCrossSplitLine(cutout, splitLines))
|
||||
{
|
||||
var clipped = ClipCutoutToRegion(cutout, region, splitLines);
|
||||
if (clipped.Count > 0)
|
||||
entities.AddRange(clipped);
|
||||
}
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static Drawing BuildPieceDrawing(Drawing source, List<Entity> entities, int pieceIndex, Box region)
|
||||
{
|
||||
var pieceBounds = entities.Select(e => e.BoundingBox).ToList().GetBoundingBox();
|
||||
var offsetX = -pieceBounds.X;
|
||||
var offsetY = -pieceBounds.Y;
|
||||
|
||||
foreach (var e in entities)
|
||||
e.Offset(offsetX, offsetY);
|
||||
|
||||
var pgm = ConvertGeometry.ToProgram(entities);
|
||||
var piece = new Drawing($"{source.Name}-{pieceIndex}", pgm);
|
||||
piece.Color = source.Color;
|
||||
piece.Priority = source.Priority;
|
||||
piece.Material = source.Material;
|
||||
piece.Constraints = source.Constraints;
|
||||
piece.Customer = source.Customer;
|
||||
piece.Source = source.Source;
|
||||
piece.Quantity.Required = source.Quantity.Required;
|
||||
|
||||
if (source.Bends != null && source.Bends.Count > 0)
|
||||
{
|
||||
piece.Bends = new List<Bending.Bend>();
|
||||
foreach (var bend in source.Bends)
|
||||
{
|
||||
var clipped = ClipLineToBox(bend.StartPoint, bend.EndPoint, region);
|
||||
if (clipped == null)
|
||||
continue;
|
||||
|
||||
piece.Bends.Add(new Bending.Bend
|
||||
{
|
||||
StartPoint = new Vector(clipped.Value.Start.X + offsetX, clipped.Value.Start.Y + offsetY),
|
||||
EndPoint = new Vector(clipped.Value.End.X + offsetX, clipped.Value.End.Y + offsetY),
|
||||
Direction = bend.Direction,
|
||||
Angle = bend.Angle,
|
||||
Radius = bend.Radius,
|
||||
NoteText = bend.NoteText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return piece;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clips a line segment to an axis-aligned box using Liang-Barsky algorithm.
|
||||
/// Returns the clipped start/end or null if the line is entirely outside.
|
||||
/// </summary>
|
||||
private static (Vector Start, Vector End)? ClipLineToBox(Vector start, Vector end, Box box)
|
||||
{
|
||||
var dx = end.X - start.X;
|
||||
var dy = end.Y - start.Y;
|
||||
double t0 = 0, t1 = 1;
|
||||
|
||||
double[] p = { -dx, dx, -dy, dy };
|
||||
double[] q = { start.X - box.Left, box.Right - start.X, start.Y - box.Bottom, box.Top - start.Y };
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
if (System.Math.Abs(p[i]) < Math.Tolerance.Epsilon)
|
||||
{
|
||||
if (q[i] < -Math.Tolerance.Epsilon)
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var t = q[i] / p[i];
|
||||
if (p[i] < 0)
|
||||
t0 = System.Math.Max(t0, t);
|
||||
else
|
||||
t1 = System.Math.Min(t1, t);
|
||||
|
||||
if (t0 > t1)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var clippedStart = new Vector(start.X + t0 * dx, start.Y + t0 * dy);
|
||||
var clippedEnd = new Vector(start.X + t1 * dx, start.Y + t1 * dy);
|
||||
return (clippedStart, clippedEnd);
|
||||
}
|
||||
|
||||
private static void DecomposeCircles(ShapeProfile profile)
|
||||
{
|
||||
DecomposeCirclesInShape(profile.Perimeter);
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
DecomposeCirclesInShape(cutout);
|
||||
}
|
||||
|
||||
private static void DecomposeCirclesInShape(Shape shape)
|
||||
{
|
||||
for (var i = shape.Entities.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (shape.Entities[i] is Circle circle)
|
||||
{
|
||||
var arc1 = new Arc(circle.Center, circle.Radius, 0, System.Math.PI);
|
||||
var arc2 = new Arc(circle.Center, circle.Radius, System.Math.PI, System.Math.PI * 2);
|
||||
shape.Entities.RemoveAt(i);
|
||||
shape.Entities.Insert(i, arc2);
|
||||
shape.Entities.Insert(i, arc1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLineInsideBounds(SplitLine line, Box bounds)
|
||||
{
|
||||
return line.Axis == CutOffAxis.Vertical
|
||||
? line.Position > bounds.Left + OpenNest.Math.Tolerance.Epsilon
|
||||
&& line.Position < bounds.Right - OpenNest.Math.Tolerance.Epsilon
|
||||
: line.Position > bounds.Bottom + OpenNest.Math.Tolerance.Epsilon
|
||||
&& line.Position < bounds.Top - OpenNest.Math.Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
private static List<Box> BuildClipRegions(List<SplitLine> sortedLines, Box bounds)
|
||||
{
|
||||
var verticals = sortedLines.Where(l => l.Axis == CutOffAxis.Vertical).OrderBy(l => l.Position).ToList();
|
||||
var horizontals = sortedLines.Where(l => l.Axis == CutOffAxis.Horizontal).OrderBy(l => l.Position).ToList();
|
||||
|
||||
var xEdges = new List<double> { bounds.Left };
|
||||
xEdges.AddRange(verticals.Select(v => v.Position));
|
||||
xEdges.Add(bounds.Right);
|
||||
|
||||
var yEdges = new List<double> { bounds.Bottom };
|
||||
yEdges.AddRange(horizontals.Select(h => h.Position));
|
||||
yEdges.Add(bounds.Top);
|
||||
|
||||
var regions = new List<Box>();
|
||||
for (var yi = 0; yi < yEdges.Count - 1; yi++)
|
||||
for (var xi = 0; xi < xEdges.Count - 1; xi++)
|
||||
regions.Add(new Box(xEdges[xi], yEdges[yi], xEdges[xi + 1] - xEdges[xi], yEdges[yi + 1] - yEdges[yi]));
|
||||
|
||||
return regions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clip perimeter to a region by walking entities, splitting at split line crossings,
|
||||
/// and stitching in feature edges. No polygon clipping library needed.
|
||||
/// </summary>
|
||||
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
|
||||
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters,
|
||||
List<Polygon> cutoutPolygons)
|
||||
{
|
||||
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
||||
var entities = new List<Entity>();
|
||||
|
||||
foreach (var entity in perimeter.Entities)
|
||||
ProcessEntity(entity, region, entities);
|
||||
|
||||
if (entities.Count == 0)
|
||||
return new List<Entity>();
|
||||
|
||||
InsertFeatureEdges(entities, region, boundarySplitLines, feature, parameters, cutoutPolygons);
|
||||
// Winding is handled later in AssemblePieces, once connected components
|
||||
// are known. At this stage the piece may still be multiple disjoint loops.
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static void ProcessEntity(Entity entity, Box region, List<Entity> entities)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
var clipped = ClipLineToBox(line.StartPoint, line.EndPoint, region);
|
||||
if (clipped == null) return;
|
||||
if (clipped.Value.Start.DistanceTo(clipped.Value.End) < Math.Tolerance.Epsilon) return;
|
||||
entities.Add(new Line(clipped.Value.Start, clipped.Value.End));
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
foreach (var sub in ClipArcToRegion(arc, region))
|
||||
entities.Add(sub);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Returns split lines whose position matches a boundary edge of the region.
|
||||
/// </summary>
|
||||
private static List<SplitLine> GetBoundarySplitLines(Box region, List<SplitLine> splitLines)
|
||||
{
|
||||
var result = new List<SplitLine>();
|
||||
foreach (var sl in splitLines)
|
||||
{
|
||||
if (sl.Axis == CutOffAxis.Vertical)
|
||||
{
|
||||
if (System.Math.Abs(sl.Position - region.Left) < OpenNest.Math.Tolerance.Epsilon
|
||||
|| System.Math.Abs(sl.Position - region.Right) < OpenNest.Math.Tolerance.Epsilon)
|
||||
result.Add(sl);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (System.Math.Abs(sl.Position - region.Bottom) < OpenNest.Math.Tolerance.Epsilon
|
||||
|| System.Math.Abs(sl.Position - region.Top) < OpenNest.Math.Tolerance.Epsilon)
|
||||
result.Add(sl);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns -1 or +1 indicating which side of the split line the region center is on.
|
||||
/// </summary>
|
||||
private static int RegionSideOf(Box region, SplitLine sl)
|
||||
{
|
||||
return SplitLineIntersect.SideOf(region.Center, sl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the midpoint of an entity. For lines: average of endpoints.
|
||||
/// For arcs: point at the mid-angle.
|
||||
/// </summary>
|
||||
private static Vector MidPoint(Entity entity)
|
||||
{
|
||||
if (entity is Line line)
|
||||
return line.MidPoint;
|
||||
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
var midAngle = (arc.StartAngle + arc.EndAngle) / 2;
|
||||
return new Vector(
|
||||
arc.Center.X + arc.Radius * System.Math.Cos(midAngle),
|
||||
arc.Center.Y + arc.Radius * System.Math.Sin(midAngle));
|
||||
}
|
||||
|
||||
return new Vector(0, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
private static void InsertFeatureEdges(List<Entity> entities,
|
||||
Box region, List<SplitLine> boundarySplitLines,
|
||||
ISplitFeature feature, SplitParameters parameters,
|
||||
List<Polygon> cutoutPolygons)
|
||||
{
|
||||
foreach (var sl in boundarySplitLines)
|
||||
{
|
||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
||||
var extentStart = isVertical ? region.Bottom : region.Left;
|
||||
var extentEnd = isVertical ? region.Top : region.Right;
|
||||
|
||||
if (extentEnd - extentStart < Math.Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
|
||||
var isNegativeSide = RegionSideOf(region, sl) < 0;
|
||||
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
|
||||
|
||||
// Trim any line segments that cross a cutout — cut lines must never
|
||||
// travel through a hole.
|
||||
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)
|
||||
{
|
||||
if (TryIntersectSegments(line.StartPoint, line.EndPoint, edge.StartPoint, edge.EndPoint, out var t))
|
||||
{
|
||||
if (t > Math.Tolerance.Epsilon && t < 1.0 - Math.Tolerance.Epsilon)
|
||||
ts.Add(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ts.Sort();
|
||||
|
||||
var segments = new List<Line>();
|
||||
for (var i = 0; i < ts.Count - 1; i++)
|
||||
{
|
||||
var t0 = ts[i];
|
||||
var t1 = ts[i + 1];
|
||||
if (t1 - t0 < Math.Tolerance.Epsilon) continue;
|
||||
|
||||
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 segments;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
tOnA = 0;
|
||||
var rx = a1.X - a0.X;
|
||||
var ry = a1.Y - a0.Y;
|
||||
var sx = b1.X - b0.X;
|
||||
var sy = b1.Y - b0.Y;
|
||||
|
||||
var denom = rx * sy - ry * sx;
|
||||
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)
|
||||
{
|
||||
if (cutout.Entities.Count == 0) return false;
|
||||
var bb = cutout.BoundingBox;
|
||||
// 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)
|
||||
{
|
||||
var bb = cutout.BoundingBox;
|
||||
foreach (var sl in splitLines)
|
||||
{
|
||||
if (sl.Axis == CutOffAxis.Vertical && bb.Left < sl.Position && bb.Right > sl.Position)
|
||||
return true;
|
||||
if (sl.Axis == CutOffAxis.Horizontal && bb.Bottom < sl.Position && bb.Top > sl.Position)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clip a cutout shape to a region by walking entities and splitting at split-line
|
||||
/// crossings. Only returns the cutout-edge fragments that lie inside the region —
|
||||
/// 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>
|
||||
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
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++)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// For each outer, attach the holes that fall inside it.
|
||||
for (var i = 0; i < shapes.Count; i++)
|
||||
{
|
||||
if (isHole[i]) continue;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return pieces;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first vertex of a shape (start point of its first entity). Used as
|
||||
/// a representative for containment testing: if bbox pre-check says the whole
|
||||
/// 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]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True iff box <paramref name="inner"/> is entirely inside box
|
||||
/// <paramref name="outer"/> (tolerant comparison).
|
||||
/// </summary>
|
||||
private static bool BoxContainsBox(Box outer, Box inner)
|
||||
{
|
||||
var eps = Math.Tolerance.Epsilon;
|
||||
return inner.Left >= outer.Left - eps
|
||||
&& inner.Right <= outer.Right + eps
|
||||
&& inner.Bottom >= outer.Bottom - eps
|
||||
&& inner.Top <= outer.Top + eps;
|
||||
}
|
||||
|
||||
private static Vector GetStartPoint(Entity entity)
|
||||
{
|
||||
return entity switch
|
||||
{
|
||||
Line l => l.StartPoint,
|
||||
Arc a => a.StartPoint(),
|
||||
_ => new Vector(0, 0)
|
||||
};
|
||||
}
|
||||
|
||||
private static Vector GetEndPoint(Entity entity)
|
||||
{
|
||||
return entity switch
|
||||
{
|
||||
Line l => l.EndPoint,
|
||||
Arc a => a.EndPoint(),
|
||||
_ => new Vector(0, 0)
|
||||
};
|
||||
}
|
||||
|
||||
private static ISplitFeature GetFeature(SplitType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
SplitType.Straight => new StraightSplit(),
|
||||
SplitType.WeldGapTabs => new WeldGapTabSplit(),
|
||||
SplitType.SpikeGroove => new SpikeGrooveSplit(),
|
||||
_ => new StraightSplit()
|
||||
};
|
||||
}
|
||||
}
|
||||
22
OpenNest.Core/Splitting/ISplitFeature.cs
Normal file
22
OpenNest.Core/Splitting/ISplitFeature.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
public class SplitFeatureResult
|
||||
{
|
||||
public List<Entity> NegativeSideEdge { get; }
|
||||
public List<Entity> PositiveSideEdge { get; }
|
||||
|
||||
public SplitFeatureResult(List<Entity> negativeSideEdge, List<Entity> positiveSideEdge)
|
||||
{
|
||||
NegativeSideEdge = negativeSideEdge;
|
||||
PositiveSideEdge = positiveSideEdge;
|
||||
}
|
||||
}
|
||||
|
||||
public interface ISplitFeature
|
||||
{
|
||||
string Name { get; }
|
||||
SplitFeatureResult GenerateFeatures(SplitLine line, double extentStart, double extentEnd, SplitParameters parameters);
|
||||
}
|
||||
112
OpenNest.Core/Splitting/SpikeGrooveSplit.cs
Normal file
112
OpenNest.Core/Splitting/SpikeGrooveSplit.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Generates interlocking spike/V-groove pairs along the split edge.
|
||||
/// Spikes protrude from the positive side into the negative side.
|
||||
/// V-grooves on the negative side receive the spikes for self-alignment during welding.
|
||||
/// The weld gap (grooveDepth - spikeDepth) is the clearance at the tip when assembled.
|
||||
/// </summary>
|
||||
public class SpikeGrooveSplit : ISplitFeature
|
||||
{
|
||||
public string Name => "Spike / V-Groove";
|
||||
|
||||
public SplitFeatureResult GenerateFeatures(SplitLine line, double extentStart, double extentEnd, SplitParameters parameters)
|
||||
{
|
||||
var extent = extentEnd - extentStart;
|
||||
var pairCount = parameters.SpikePairCount;
|
||||
var spikeDepth = parameters.SpikeDepth;
|
||||
var grooveDepth = parameters.GrooveDepth;
|
||||
var angleRad = OpenNest.Math.Angle.ToRadians(parameters.SpikeAngle / 2);
|
||||
var spikeHalfWidth = spikeDepth * System.Math.Tan(angleRad);
|
||||
var grooveHalfWidth = grooveDepth * System.Math.Tan(angleRad);
|
||||
|
||||
var isVertical = line.Axis == CutOffAxis.Vertical;
|
||||
var pos = line.Position;
|
||||
|
||||
// Use custom positions if provided, otherwise place evenly with margin
|
||||
var pairPositions = new List<double>();
|
||||
if (line.FeaturePositions.Count > 0)
|
||||
{
|
||||
pairPositions.AddRange(line.FeaturePositions);
|
||||
}
|
||||
else if (pairCount == 1)
|
||||
{
|
||||
pairPositions.Add(extentStart + extent / 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
var margin = extent * 0.15;
|
||||
var usable = extent - 2 * margin;
|
||||
for (var i = 0; i < pairCount; i++)
|
||||
pairPositions.Add(extentStart + margin + usable * i / (pairCount - 1));
|
||||
}
|
||||
|
||||
var negEntities = BuildGrooveSide(pairPositions, grooveHalfWidth, grooveDepth, extentStart, extentEnd, pos, isVertical);
|
||||
var posEntities = BuildSpikeSide(pairPositions, spikeHalfWidth, spikeDepth, extentStart, extentEnd, pos, isVertical);
|
||||
|
||||
return new SplitFeatureResult(negEntities, posEntities);
|
||||
}
|
||||
|
||||
private static List<Entity> BuildGrooveSide(List<double> pairPositions, double halfWidth, double depth,
|
||||
double extentStart, double extentEnd, double pos, bool isVertical)
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
var cursor = extentStart;
|
||||
|
||||
foreach (var center in pairPositions)
|
||||
{
|
||||
var grooveStart = center - halfWidth;
|
||||
var grooveEnd = center + halfWidth;
|
||||
|
||||
if (grooveStart > cursor + OpenNest.Math.Tolerance.Epsilon)
|
||||
entities.Add(MakeLine(pos, cursor, pos, grooveStart, isVertical));
|
||||
|
||||
entities.Add(MakeLine(pos, grooveStart, pos - depth, center, isVertical));
|
||||
entities.Add(MakeLine(pos - depth, center, pos, grooveEnd, isVertical));
|
||||
|
||||
cursor = grooveEnd;
|
||||
}
|
||||
|
||||
if (extentEnd > cursor + OpenNest.Math.Tolerance.Epsilon)
|
||||
entities.Add(MakeLine(pos, cursor, pos, extentEnd, isVertical));
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static List<Entity> BuildSpikeSide(List<double> pairPositions, double halfWidth, double depth,
|
||||
double extentStart, double extentEnd, double pos, bool isVertical)
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
var cursor = extentEnd;
|
||||
|
||||
for (var i = pairPositions.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var center = pairPositions[i];
|
||||
var spikeEnd = center + halfWidth;
|
||||
var spikeStart = center - halfWidth;
|
||||
|
||||
if (cursor > spikeEnd + OpenNest.Math.Tolerance.Epsilon)
|
||||
entities.Add(MakeLine(pos, cursor, pos, spikeEnd, isVertical));
|
||||
|
||||
entities.Add(MakeLine(pos, spikeEnd, pos - depth, center, isVertical));
|
||||
entities.Add(MakeLine(pos - depth, center, pos, spikeStart, isVertical));
|
||||
|
||||
cursor = spikeStart;
|
||||
}
|
||||
|
||||
if (cursor > extentStart + OpenNest.Math.Tolerance.Epsilon)
|
||||
entities.Add(MakeLine(pos, cursor, pos, extentStart, isVertical));
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static Line MakeLine(double splitAxis1, double along1, double splitAxis2, double along2, bool isVertical)
|
||||
{
|
||||
return isVertical
|
||||
? new Line(new Vector(splitAxis1, along1), new Vector(splitAxis2, along2))
|
||||
: new Line(new Vector(along1, splitAxis1), new Vector(along2, splitAxis2));
|
||||
}
|
||||
}
|
||||
39
OpenNest.Core/Splitting/SplitLine.cs
Normal file
39
OpenNest.Core/Splitting/SplitLine.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a split line at a position along an axis.
|
||||
/// For Vertical, Position is the X coordinate. For Horizontal, Position is the Y coordinate.
|
||||
/// </summary>
|
||||
public class SplitLine
|
||||
{
|
||||
public double Position { get; }
|
||||
public CutOffAxis Axis { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom center positions for features (tabs/spikes) along the split line.
|
||||
/// Values are absolute coordinates on the perpendicular axis.
|
||||
/// When empty, feature generators use their default even spacing.
|
||||
/// </summary>
|
||||
public List<double> FeaturePositions { get; set; } = new();
|
||||
|
||||
public SplitLine(double position, CutOffAxis axis)
|
||||
{
|
||||
Position = position;
|
||||
Axis = axis;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a Line entity at the split position spanning the given extent range.
|
||||
/// For Vertical: line from (Position, extentStart) to (Position, extentEnd).
|
||||
/// For Horizontal: line from (extentStart, Position) to (extentEnd, Position).
|
||||
/// </summary>
|
||||
public Line ToLine(double extentStart, double extentEnd)
|
||||
{
|
||||
return Axis == CutOffAxis.Vertical
|
||||
? new Line(Position, extentStart, Position, extentEnd)
|
||||
: new Line(extentStart, Position, extentEnd, Position);
|
||||
}
|
||||
}
|
||||
81
OpenNest.Core/Splitting/SplitLineIntersect.cs
Normal file
81
OpenNest.Core/Splitting/SplitLineIntersect.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Static helpers for testing entity-splitline intersections.
|
||||
/// </summary>
|
||||
public static class SplitLineIntersect
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds the intersection point between an entity and a split line.
|
||||
/// Returns null if no intersection or the entity doesn't straddle the split line.
|
||||
/// </summary>
|
||||
public static Vector? FindIntersection(Entity entity, SplitLine sl)
|
||||
{
|
||||
if (!CrossesSplitLine(entity, sl))
|
||||
return null;
|
||||
|
||||
var bbox = entity.BoundingBox;
|
||||
var margin = 1.0;
|
||||
|
||||
// Create a line at the split position spanning the entity's bbox extent (with margin)
|
||||
Line splitLine;
|
||||
|
||||
if (sl.Axis == CutOffAxis.Vertical)
|
||||
splitLine = sl.ToLine(bbox.Bottom - margin, bbox.Top + margin);
|
||||
else
|
||||
splitLine = sl.ToLine(bbox.Left - margin, bbox.Right + margin);
|
||||
|
||||
switch (entity.Type)
|
||||
{
|
||||
case EntityType.Line:
|
||||
var line = (Line)entity;
|
||||
if (Intersect.Intersects(line, splitLine, out var pt))
|
||||
return pt;
|
||||
return null;
|
||||
|
||||
case EntityType.Arc:
|
||||
var arc = (Arc)entity;
|
||||
if (Intersect.Intersects(arc, splitLine, out var pts))
|
||||
return pts.Count > 0 ? pts[0] : null;
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the entity's bounding box straddles the split line,
|
||||
/// meaning it extends to both sides of the split position (not just touching).
|
||||
/// </summary>
|
||||
public static bool CrossesSplitLine(Entity entity, SplitLine sl)
|
||||
{
|
||||
var bbox = entity.BoundingBox;
|
||||
|
||||
if (sl.Axis == CutOffAxis.Vertical)
|
||||
return bbox.Left < sl.Position - Tolerance.Epsilon
|
||||
&& bbox.Right > sl.Position + Tolerance.Epsilon;
|
||||
else
|
||||
return bbox.Bottom < sl.Position - Tolerance.Epsilon
|
||||
&& bbox.Top > sl.Position + Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns -1 if the point is below/left of the split line,
|
||||
/// +1 if above/right, or 0 if on the line (within tolerance).
|
||||
/// </summary>
|
||||
public static int SideOf(Vector pt, SplitLine sl)
|
||||
{
|
||||
var value = sl.Axis == CutOffAxis.Vertical ? pt.X : pt.Y;
|
||||
var diff = value - sl.Position;
|
||||
|
||||
if (System.Math.Abs(diff) <= Tolerance.Epsilon)
|
||||
return 0;
|
||||
|
||||
return diff < 0 ? -1 : 1;
|
||||
}
|
||||
}
|
||||
35
OpenNest.Core/Splitting/SplitParameters.cs
Normal file
35
OpenNest.Core/Splitting/SplitParameters.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace OpenNest;
|
||||
|
||||
public enum SplitType
|
||||
{
|
||||
Straight,
|
||||
WeldGapTabs,
|
||||
SpikeGroove
|
||||
}
|
||||
|
||||
public class SplitParameters
|
||||
{
|
||||
public SplitType Type { get; set; } = SplitType.Straight;
|
||||
|
||||
// Tab parameters
|
||||
public double TabWidth { get; set; } = 1.0;
|
||||
public double TabHeight { get; set; } = 0.125;
|
||||
public int TabCount { get; set; } = 3;
|
||||
|
||||
// Spike/Groove parameters
|
||||
public double SpikeDepth { get; set; } = 0.5;
|
||||
public double GrooveDepth { get; set; } = 0.625;
|
||||
public double SpikeWeldGap { get; set; } = 0.125;
|
||||
public double SpikeAngle { get; set; } = 60.0; // degrees
|
||||
public int SpikePairCount { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Max protrusion from the split edge (for auto-fit plate size calculation).
|
||||
/// </summary>
|
||||
public double FeatureOverhang => Type switch
|
||||
{
|
||||
SplitType.WeldGapTabs => TabHeight,
|
||||
SplitType.SpikeGroove => System.Math.Max(SpikeDepth, GrooveDepth),
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
22
OpenNest.Core/Splitting/StraightSplit.cs
Normal file
22
OpenNest.Core/Splitting/StraightSplit.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
public class StraightSplit : ISplitFeature
|
||||
{
|
||||
public string Name => "Straight";
|
||||
|
||||
public SplitFeatureResult GenerateFeatures(SplitLine line, double extentStart, double extentEnd, SplitParameters parameters)
|
||||
{
|
||||
var (negEdge, posEdge) = line.Axis == CutOffAxis.Vertical
|
||||
? (new Line(new Vector(line.Position, extentStart), new Vector(line.Position, extentEnd)),
|
||||
new Line(new Vector(line.Position, extentEnd), new Vector(line.Position, extentStart)))
|
||||
: (new Line(new Vector(extentStart, line.Position), new Vector(extentEnd, line.Position)),
|
||||
new Line(new Vector(extentEnd, line.Position), new Vector(extentStart, line.Position)));
|
||||
|
||||
return new SplitFeatureResult(
|
||||
new List<Entity> { negEdge },
|
||||
new List<Entity> { posEdge });
|
||||
}
|
||||
}
|
||||
92
OpenNest.Core/Splitting/WeldGapTabSplit.cs
Normal file
92
OpenNest.Core/Splitting/WeldGapTabSplit.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Generates rectangular tabs on one side of the split edge (negative side).
|
||||
/// The positive side remains a straight line. Tabs act as weld-gap spacers.
|
||||
/// </summary>
|
||||
public class WeldGapTabSplit : ISplitFeature
|
||||
{
|
||||
public string Name => "Weld-Gap Tabs";
|
||||
|
||||
public SplitFeatureResult GenerateFeatures(SplitLine line, double extentStart, double extentEnd, SplitParameters parameters)
|
||||
{
|
||||
var extent = extentEnd - extentStart;
|
||||
var tabCount = parameters.TabCount;
|
||||
var tabWidth = parameters.TabWidth;
|
||||
var tabHeight = parameters.TabHeight;
|
||||
|
||||
// Use custom positions if provided, otherwise evenly space
|
||||
var tabCenters = new List<double>();
|
||||
if (line.FeaturePositions.Count > 0)
|
||||
{
|
||||
tabCenters.AddRange(line.FeaturePositions);
|
||||
}
|
||||
else
|
||||
{
|
||||
var spacing = extent / (tabCount + 1);
|
||||
for (var i = 0; i < tabCount; i++)
|
||||
tabCenters.Add(extentStart + spacing * (i + 1));
|
||||
}
|
||||
|
||||
var negEntities = new List<Entity>();
|
||||
var isVertical = line.Axis == CutOffAxis.Vertical;
|
||||
var pos = line.Position;
|
||||
|
||||
// Tabs protrude toward the negative side (lower coordinate on the split axis)
|
||||
var tabDir = -1.0;
|
||||
|
||||
var cursor = extentStart;
|
||||
|
||||
for (var i = 0; i < tabCenters.Count; i++)
|
||||
{
|
||||
var tabCenter = tabCenters[i];
|
||||
var tabStart = tabCenter - tabWidth / 2;
|
||||
var tabEnd = tabCenter + tabWidth / 2;
|
||||
|
||||
if (isVertical)
|
||||
{
|
||||
if (tabStart > cursor + OpenNest.Math.Tolerance.Epsilon)
|
||||
negEntities.Add(new Line(new Vector(pos, cursor), new Vector(pos, tabStart)));
|
||||
|
||||
negEntities.Add(new Line(new Vector(pos, tabStart), new Vector(pos + tabDir * tabHeight, tabStart)));
|
||||
negEntities.Add(new Line(new Vector(pos + tabDir * tabHeight, tabStart), new Vector(pos + tabDir * tabHeight, tabEnd)));
|
||||
negEntities.Add(new Line(new Vector(pos + tabDir * tabHeight, tabEnd), new Vector(pos, tabEnd)));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (tabStart > cursor + OpenNest.Math.Tolerance.Epsilon)
|
||||
negEntities.Add(new Line(new Vector(cursor, pos), new Vector(tabStart, pos)));
|
||||
|
||||
negEntities.Add(new Line(new Vector(tabStart, pos), new Vector(tabStart, pos + tabDir * tabHeight)));
|
||||
negEntities.Add(new Line(new Vector(tabStart, pos + tabDir * tabHeight), new Vector(tabEnd, pos + tabDir * tabHeight)));
|
||||
negEntities.Add(new Line(new Vector(tabEnd, pos + tabDir * tabHeight), new Vector(tabEnd, pos)));
|
||||
}
|
||||
|
||||
cursor = tabEnd;
|
||||
}
|
||||
|
||||
// Final segment from last tab to extent end
|
||||
if (isVertical)
|
||||
{
|
||||
if (extentEnd > cursor + OpenNest.Math.Tolerance.Epsilon)
|
||||
negEntities.Add(new Line(new Vector(pos, cursor), new Vector(pos, extentEnd)));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (extentEnd > cursor + OpenNest.Math.Tolerance.Epsilon)
|
||||
negEntities.Add(new Line(new Vector(cursor, pos), new Vector(extentEnd, pos)));
|
||||
}
|
||||
|
||||
// Positive side: plain straight line (reversed direction)
|
||||
var posEntities = new List<Entity>();
|
||||
if (isVertical)
|
||||
posEntities.Add(new Line(new Vector(pos, extentEnd), new Vector(pos, extentStart)));
|
||||
else
|
||||
posEntities.Add(new Line(new Vector(extentEnd, pos), new Vector(extentStart, pos)));
|
||||
|
||||
return new SplitFeatureResult(negEntities, posEntities);
|
||||
}
|
||||
}
|
||||
9
OpenNest.Data/CutOffConfig.cs
Normal file
9
OpenNest.Data/CutOffConfig.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public class CutOffConfig
|
||||
{
|
||||
public double PartClearance { get; set; } = 0.02;
|
||||
public double Overtravel { get; set; }
|
||||
public double MinSegmentLength { get; set; } = 0.05;
|
||||
public string Direction { get; set; } = "AwayFromOrigin";
|
||||
}
|
||||
174
OpenNest.Data/Defaults/CL-980.json
Normal file
174
OpenNest.Data/Defaults/CL-980.json
Normal file
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000980001",
|
||||
"schemaVersion": 1,
|
||||
"name": "CL-980",
|
||||
"type": "laser",
|
||||
"units": "inches",
|
||||
"materials": [
|
||||
{
|
||||
"name": "Mild Steel",
|
||||
"grade": "A36",
|
||||
"density": 0.2836,
|
||||
"thicknesses": [
|
||||
{
|
||||
"value": 0.060,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.075,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.105,
|
||||
"kerf": 0.010,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
|
||||
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.135,
|
||||
"kerf": 0.010,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
|
||||
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120", "60x144" ]
|
||||
},
|
||||
{
|
||||
"value": 0.1875,
|
||||
"kerf": 0.012,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
|
||||
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120", "60x144" ]
|
||||
},
|
||||
{
|
||||
"value": 0.250,
|
||||
"kerf": 0.012,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
|
||||
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120", "60x144" ]
|
||||
},
|
||||
{
|
||||
"value": 0.375,
|
||||
"kerf": 0.016,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.375, "angle": 90.0, "radius": 0.1875 },
|
||||
"leadOut": { "type": "Line", "length": 0.1875, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.625, "overtravel": 0.3125, "minSegmentLength": 1.25, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "60x120", "60x144", "72x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.500,
|
||||
"kerf": 0.020,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.5, "angle": 90.0, "radius": 0.25 },
|
||||
"leadOut": { "type": "Line", "length": 0.25, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.75, "overtravel": 0.375, "minSegmentLength": 1.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "60x120", "60x144", "72x120" ]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Stainless Steel",
|
||||
"grade": "304",
|
||||
"density": 0.289,
|
||||
"thicknesses": [
|
||||
{
|
||||
"value": 0.060,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.075,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.105,
|
||||
"kerf": 0.010,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
|
||||
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.250,
|
||||
"kerf": 0.014,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
|
||||
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Aluminum",
|
||||
"grade": "5052",
|
||||
"density": 0.097,
|
||||
"thicknesses": [
|
||||
{
|
||||
"value": 0.060,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.080,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.125,
|
||||
"kerf": 0.010,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
|
||||
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.250,
|
||||
"kerf": 0.014,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
|
||||
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
9
OpenNest.Data/IDataProvider.cs
Normal file
9
OpenNest.Data/IDataProvider.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public interface IDataProvider
|
||||
{
|
||||
IReadOnlyList<MachineSummary> GetMachines();
|
||||
MachineConfig? GetMachine(Guid id);
|
||||
void SaveMachine(MachineConfig machine);
|
||||
void DeleteMachine(Guid id);
|
||||
}
|
||||
9
OpenNest.Data/LeadConfig.cs
Normal file
9
OpenNest.Data/LeadConfig.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public class LeadConfig
|
||||
{
|
||||
public string Type { get; set; } = "Line";
|
||||
public double Length { get; set; }
|
||||
public double Angle { get; set; } = 90.0;
|
||||
public double Radius { get; set; }
|
||||
}
|
||||
112
OpenNest.Data/LocalJsonProvider.cs
Normal file
112
OpenNest.Data/LocalJsonProvider.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public class LocalJsonProvider : IDataProvider
|
||||
{
|
||||
private readonly string _directory;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
public LocalJsonProvider(string directory)
|
||||
{
|
||||
_directory = directory;
|
||||
Directory.CreateDirectory(_directory);
|
||||
}
|
||||
|
||||
public IReadOnlyList<MachineSummary> GetMachines()
|
||||
{
|
||||
var summaries = new List<MachineSummary>();
|
||||
foreach (var file in Directory.GetFiles(_directory, "*.json"))
|
||||
{
|
||||
var machine = ReadFile(file);
|
||||
if (machine is not null)
|
||||
summaries.Add(new MachineSummary(machine.Id, machine.Name));
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
public MachineConfig? GetMachine(Guid id)
|
||||
{
|
||||
var path = GetPath(id);
|
||||
return File.Exists(path) ? ReadFile(path) : null;
|
||||
}
|
||||
|
||||
public void SaveMachine(MachineConfig machine)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(machine, JsonOptions);
|
||||
var path = GetPath(machine.Id);
|
||||
WriteWithRetry(path, json);
|
||||
}
|
||||
|
||||
public void DeleteMachine(Guid id)
|
||||
{
|
||||
var path = GetPath(id);
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
private string GetPath(Guid id) => Path.Combine(_directory, $"{id}.json");
|
||||
|
||||
private static MachineConfig? ReadFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<MachineConfig>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void EnsureDefaults()
|
||||
{
|
||||
if (Directory.GetFiles(_directory, "*.json").Length > 0)
|
||||
return;
|
||||
|
||||
var assembly = typeof(LocalJsonProvider).Assembly;
|
||||
var resourceName = assembly.GetManifestResourceNames()
|
||||
.FirstOrDefault(n => n.EndsWith("CL-980.json"));
|
||||
|
||||
if (resourceName is null) return;
|
||||
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null) return;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
|
||||
var config = JsonSerializer.Deserialize<MachineConfig>(json, JsonOptions);
|
||||
if (config is null) return;
|
||||
|
||||
SaveMachine(config);
|
||||
}
|
||||
|
||||
private static void WriteWithRetry(string path, string json, int maxRetries = 3)
|
||||
{
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < maxRetries - 1)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user