mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 13:51:06 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19be5a5ece | ||
|
|
242a0d2a1b | ||
|
|
f26fc0d596 | ||
|
|
ac886d05b8 | ||
|
|
3e1df45c7f | ||
|
|
3e767ca3b6 | ||
|
|
e18304066d | ||
|
|
dcb0a5c519 | ||
|
|
1592d4bfd9 | ||
|
|
857a560dce | ||
|
|
69a83b3ad9 | ||
|
|
a3a75d589d | ||
|
|
a3b7c715d5 | ||
|
|
16afb41c26 | ||
|
|
2e72469d6c | ||
|
|
3dd1acd3cf | ||
|
|
9a62a5102d | ||
|
|
223bfea47c | ||
|
|
d1270f5208 | ||
|
|
fe31a3f8ec | ||
|
|
a88c056f4b | ||
|
|
81ec6cc738 | ||
|
|
11b04cc272 | ||
|
|
6a9b81326e | ||
|
|
37cdc52f0d | ||
|
|
d3d7bc0299 | ||
|
|
3884d058ec | ||
|
|
27610c3b1d | ||
|
|
1b99a7c866 | ||
|
|
6e40c2e6ed | ||
|
|
0ba5539fab | ||
|
|
2e17dd7bc4 | ||
|
|
86b0370a8f | ||
|
|
e5e2c268b8 | ||
|
|
7f1c5a3854 | ||
|
|
96d8a6874f | ||
|
|
fecfcc337e | ||
|
|
b0697f92b3 | ||
|
|
98f8e98230 | ||
|
|
5611d9a663 | ||
|
|
5761eefd37 | ||
|
|
9f335ea111 | ||
|
|
0c7ad5aefb | ||
|
|
73a7c8d6a2 | ||
|
|
479883e7ac | ||
|
|
07499fe1cd | ||
|
|
1b1f86085d | ||
|
|
36c8de825e | ||
|
|
ccd361dfa9 | ||
|
|
7c4276a457 | ||
|
|
14ba2e653b | ||
|
|
18612e698d | ||
|
|
90e2fd2e6e | ||
|
|
9f3c22dc4f | ||
|
|
84e6adbcf2 | ||
|
|
b35490addf | ||
|
|
1fbe57a841 | ||
|
|
1bb58117af | ||
|
|
70e8666335 | ||
|
|
2d8d877482 | ||
|
|
610b9dafcb | ||
|
|
079dfaf9ea | ||
|
|
c27188c722 | ||
|
|
548522775f | ||
|
|
be01124a0f | ||
|
|
f847b216f2 | ||
|
|
23f093fcc1 | ||
|
|
15750949dd | ||
|
|
ea1decc097 | ||
|
|
42f1762c56 | ||
|
|
455c543501 | ||
|
|
4b982e8eeb | ||
|
|
0d369c9a51 | ||
|
|
cbf3b728aa | ||
|
|
fa70713bfc | ||
|
|
ba55d5bff0 | ||
|
|
f600c5e58b | ||
|
|
27796851f3 | ||
|
|
d4269c1f06 | ||
|
|
8cd9b2cbe3 | ||
|
|
b341848e07 | ||
|
|
33f60d3dcb | ||
|
|
ad3443f60d | ||
|
|
ebecd42433 | ||
|
|
3f5d402489 | ||
|
|
a6056a6065 | ||
|
|
dfa6ccb954 | ||
|
|
87672543cd | ||
|
|
a9198e5c25 | ||
|
|
6a556b75ab | ||
|
|
b376d217dd | ||
|
|
49bb7389f7 | ||
|
|
ee208b2766 | ||
|
|
0d1dade063 | ||
|
|
48863d51c8 | ||
|
|
cd1961e26b | ||
|
|
414479fc4e | ||
|
|
bc21382c5d | ||
|
|
ba2102c793 | ||
|
|
4b312a552d | ||
|
|
a3caff6e40 | ||
|
|
6b63d41030 | ||
|
|
9c78b4ebec | ||
|
|
993555dba6 | ||
|
|
13352b8acd | ||
|
|
7ce5e73a82 | ||
|
|
a00e2c3233 | ||
|
|
0785bb84c9 | ||
|
|
45592a811c | ||
|
|
39f28c7fa9 | ||
|
|
07e7d7edcd | ||
|
|
bbadd6aa75 | ||
|
|
c188014a30 | ||
|
|
069efc2368 | ||
|
|
ed92c37845 | ||
|
|
00cb639c8e | ||
|
|
9e4361fd11 | ||
|
|
3c3bab0a9b | ||
|
|
6201065034 | ||
|
|
684207039a | ||
|
|
8f568482bc | ||
|
|
73e54fbd67 | ||
|
|
b45e9df593 | ||
|
|
6e3705eb89 | ||
|
|
c325a1745a | ||
|
|
dc4fc1d593 | ||
|
|
d4903a648c | ||
|
|
9d69c89b21 | ||
|
|
939d7bfff2 | ||
|
|
8fd1fabc54 | ||
|
|
54b15cff19 | ||
|
|
f657292801 | ||
|
|
8f27eac2ca | ||
|
|
ff46de69df | ||
|
|
1fc36e9e54 | ||
|
|
099a020424 | ||
|
|
c58ae6f9e1 | ||
|
|
63771b0029 | ||
|
|
09d58ce064 | ||
|
|
64eca9775f | ||
|
|
d1c77b8ec7 | ||
|
|
35359906fe | ||
|
|
5ecdc98581 | ||
|
|
3ca7d6185f | ||
|
|
f6781d27c3 | ||
|
|
74203483c5 | ||
|
|
4b604d3874 | ||
|
|
28db6ae807 | ||
|
|
19809c61d0 | ||
|
|
382abf702b | ||
|
|
2963764215 | ||
|
|
64e24fc6c8 | ||
|
|
0eb6d5886a | ||
|
|
9ab6fbbeaf | ||
|
|
8c7dc41ce8 | ||
|
|
c3881c5534 | ||
|
|
7bf753ce17 | ||
|
|
11b6032459 | ||
|
|
0ca4e64189 | ||
|
|
f901840ed0 | ||
|
|
059a873d32 | ||
|
|
0922e41401 | ||
|
|
ed9c1f9df6 | ||
|
|
50f966913d | ||
|
|
96f02c665c | ||
|
|
aeb90d8a86 | ||
|
|
f51e5aefdd | ||
|
|
a7c0747e11 | ||
|
|
b65876c031 | ||
|
|
cc731ff3d6 | ||
|
|
1ccba9b910 | ||
|
|
af6975b9f1 | ||
|
|
44666b289d | ||
|
|
be057bf337 | ||
|
|
28ee04a480 | ||
|
|
1b323e2380 | ||
|
|
2d74d09023 | ||
|
|
cc2b3d1b81 | ||
|
|
63233db108 | ||
|
|
abfc1a714a | ||
|
|
efa4844049 | ||
|
|
bf240ab6df | ||
|
|
3d4ac5ddf7 | ||
|
|
1ab39c3cd0 | ||
|
|
56eaf4829a | ||
|
|
a10e204268 | ||
|
|
67daeafc98 | ||
|
|
391a27b2f9 | ||
|
|
cf32d1cab5 | ||
|
|
04a6e0eb50 | ||
|
|
a0713289fa | ||
|
|
94898225e8 | ||
|
|
40e4e559dd | ||
|
|
b9c5240a43 | ||
|
|
13d75854c3 | ||
|
|
61ad85768d | ||
|
|
274651b810 | ||
|
|
1ed66bbf20 | ||
|
|
b9092a5142 | ||
|
|
98a046a112 | ||
|
|
b97f67af41 | ||
|
|
5620c9b842 | ||
|
|
be8d1443e3 | ||
|
|
3d1270a860 | ||
|
|
add94dff6c | ||
|
|
e82f26b976 | ||
|
|
c7df3f5e23 | ||
|
|
3bc71a7e87 | ||
|
|
d35178c3b5 | ||
|
|
a1458a355b | ||
|
|
baa5f8481a | ||
|
|
9bf7760c1d | ||
|
|
50cb34305c | ||
|
|
2b3f8e7b52 | ||
|
|
5dc13a03ae | ||
|
|
93cf8007bc | ||
|
|
6676f9f6f1 | ||
|
|
4676c97c2c | ||
|
|
5dff83cfb8 | ||
|
|
3a7073d38c | ||
|
|
00acaeaaf1 | ||
|
|
cd2bea054b | ||
|
|
a2fb945669 | ||
|
|
b11225135e | ||
|
|
d384e63068 | ||
|
|
c997b2e588 | ||
|
|
0baa737162 | ||
|
|
e6c87df372 | ||
|
|
13ab8e02f8 | ||
|
|
ee8be3d79e | ||
|
|
fb505ef0ff | ||
|
|
d317a90f1e | ||
|
|
f26c75ada5 | ||
|
|
f009fab31d | ||
|
|
d548ba4e6f | ||
|
|
87b6d7ee90 | ||
|
|
9d68945e75 | ||
|
|
c875edcdd1 | ||
|
|
66afea5a51 | ||
|
|
d1aa056b9b | ||
|
|
bbe0f17a64 | ||
|
|
78ca08efe1 | ||
|
|
d3cb23f117 | ||
|
|
095b4d6e72 | ||
|
|
530456a91b | ||
|
|
dd2c00aed4 | ||
|
|
f01b2cec67 | ||
|
|
0e4fd5f90f | ||
|
|
278252fe79 | ||
|
|
b115b9dcb0 | ||
|
|
9f2e3d5291 | ||
|
|
05aac952ba | ||
|
|
859d10b31d | ||
|
|
de845a5818 | ||
|
|
a430da89d0 | ||
|
|
f3a6af7f09 | ||
|
|
2174416dcc | ||
|
|
6e2e27d6fc | ||
|
|
d87302a189 | ||
|
|
27a03b9caf | ||
|
|
0186ccfbff | ||
|
|
ba39c3dc55 | ||
|
|
6cdda38d35 | ||
|
|
3672697cb2 | ||
|
|
af65cb178b | ||
|
|
4176e9d89a | ||
|
|
766584633e | ||
|
|
8f73704bef | ||
|
|
5a374aa3f4 | ||
|
|
42d99f296c | ||
|
|
7a1a554804 | ||
|
|
3541249441 | ||
|
|
93d118585f | ||
|
|
9b46db4891 | ||
|
|
3cae257fb1 | ||
|
|
e6b1e0308f | ||
|
|
d87d113ceb | ||
|
|
686bb4d749 | ||
|
|
9d6c10c563 | ||
|
|
6f555345e3 | ||
|
|
6fd14c5452 | ||
|
|
ca09c33c38 | ||
|
|
adc12e175d | ||
|
|
ac161f93c4 | ||
|
|
bc363cc904 | ||
|
|
4a1587d6bd | ||
|
|
6ac56c4a3f | ||
|
|
0cef0ae297 | ||
|
|
b324baa69a | ||
|
|
170b517a28 | ||
|
|
9360f59077 | ||
|
|
2f91f1b036 | ||
|
|
6c691cc23b | ||
|
|
9816326429 | ||
|
|
a18f8c9b56 | ||
|
|
8d17cf8c59 | ||
|
|
7949d41b6d | ||
|
|
d56f67a99a | ||
|
|
36ba3c2d6b | ||
|
|
d9b8f054cc | ||
|
|
734346eeec | ||
|
|
0f06748f28 | ||
|
|
1bd0b258af | ||
|
|
2aecafb356 | ||
|
|
f93b0d6273 | ||
|
|
a1bd44d0fa | ||
|
|
175782d8f8 | ||
|
|
4dbb4527e2 | ||
|
|
15654c0690 | ||
|
|
8b63c042b5 | ||
|
|
0c1eb3ad65 | ||
|
|
3c577d4ac7 | ||
|
|
76133193df | ||
|
|
0ee5b9d93e | ||
|
|
7a5c06bc99 | ||
|
|
844f360732 | ||
|
|
2d5bf6f42c | ||
|
|
6e66a1efe7 | ||
|
|
063362a674 | ||
|
|
4e0699de7a | ||
|
|
9f627c1a9d | ||
|
|
7600d75f41 | ||
|
|
90a6d9e4d2 | ||
|
|
19adfbcfbd | ||
|
|
2ea201140b | ||
|
|
6bf0658d68 | ||
|
|
4e95a09706 | ||
|
|
37ddf1b7b2 | ||
|
|
75195e8ba4 | ||
|
|
274a1a1dea | ||
|
|
91b693be90 | ||
|
|
648c0f1efc | ||
|
|
017e06de2d | ||
|
|
21e1aee342 | ||
|
|
571776716f | ||
|
|
405a867a77 | ||
|
|
c3fa8e79e0 | ||
|
|
658c1c9ee3 | ||
|
|
697b6492c7 | ||
|
|
f05533f69c | ||
|
|
7d254ce18d | ||
|
|
b305b56876 | ||
|
|
f4dd8d2a85 | ||
|
|
9f0d4f37ce | ||
|
|
d028d82f0a | ||
|
|
ee3bc3aabe | ||
|
|
12f2ce5deb | ||
|
|
1672b62717 | ||
|
|
83b606c486 | ||
|
|
80d01839bd | ||
|
|
dc0aab06fc | ||
|
|
f04d9f298b | ||
|
|
8663463945 | ||
|
|
1c05f65571 | ||
|
|
fb92b75c06 | ||
|
|
a62f9a359e | ||
|
|
f99ba97b28 | ||
|
|
04c9f748f9 | ||
|
|
1db0ef04f0 | ||
|
|
855d686a6a | ||
|
|
b7779904f6 | ||
|
|
f9e3c361b4 | ||
|
|
8834abc854 | ||
|
|
720c3d9b7a | ||
|
|
e8c0eea0b0 | ||
|
|
bf8ca19ce5 | ||
|
|
21d38beb53 | ||
|
|
18be8a2c81 | ||
|
|
efc2b73709 | ||
|
|
1d745d20ba | ||
|
|
29f48789dd | ||
|
|
5180ce1506 | ||
|
|
74f32f0516 | ||
|
|
6424512e8c | ||
|
|
27f8c9799b | ||
|
|
775ece5d21 | ||
|
|
cad1083287 | ||
|
|
4183af4142 | ||
|
|
73be301ebf | ||
|
|
fdc6f074ee | ||
|
|
dc4b07ddf5 | ||
|
|
6c76617905 | ||
|
|
43e08965b9 | ||
|
|
ddd1e422be | ||
|
|
b9a387b517 | ||
|
|
9f2ece37ed | ||
|
|
81cccfc610 | ||
|
|
c107ab13f7 | ||
|
|
57450ecea8 | ||
|
|
b245290664 | ||
|
|
6d5d963654 | ||
|
|
178954e2df | ||
|
|
f63fee7392 | ||
|
|
5857e7fc69 | ||
|
|
15055aef4d | ||
|
|
c57e0ded83 | ||
|
|
f2cc471a33 | ||
|
|
07cba5719f | ||
|
|
9df9a802cd | ||
|
|
1bcead4b3a | ||
|
|
d9273c889f | ||
|
|
4226c0f902 | ||
|
|
6908e00969 | ||
|
|
e38980e54b | ||
|
|
a1914e147a | ||
|
|
64ebda2f6f | ||
|
|
360f58c101 | ||
|
|
6fc1a0ebdc | ||
|
|
6b995ac40a | ||
|
|
7ed40d4bb8 | ||
|
|
04d538b0a4 | ||
|
|
65754957cc | ||
|
|
53db543d6b | ||
|
|
2b208c9b6a | ||
|
|
b46310e01b | ||
|
|
f19a825c4e | ||
|
|
af50da7609 | ||
|
|
3bf63b74f9 | ||
|
|
d24e660286 | ||
|
|
9b5885e5a2 | ||
|
|
8f0c0692fd | ||
|
|
3f0a2703fd | ||
|
|
83ae1a48f0 | ||
|
|
92d8964339 | ||
|
|
df2f1b796d | ||
|
|
3e9e336072 | ||
|
|
c18db3f896 | ||
|
|
9a293bf221 | ||
|
|
bde332fb10 | ||
|
|
d7187f65c7 | ||
|
|
3014ab3480 | ||
|
|
104b0aef99 | ||
|
|
74544c689a | ||
|
|
3e58a9e93f | ||
|
|
87849e5326 | ||
|
|
83c77269a1 | ||
|
|
ec058d270c | ||
|
|
bafb311735 | ||
|
|
37700b3ef9 | ||
|
|
5125a5f2f3 | ||
|
|
fe7083f4c4 | ||
|
|
1e37c661ff | ||
|
|
76b49677c4 | ||
|
|
aea51b5e1d | ||
|
|
5c9c7df038 | ||
|
|
7069f155f5 | ||
|
|
99f64840b2 | ||
|
|
5674764447 | ||
|
|
81e3e58f13 | ||
|
|
cc85a9e068 | ||
|
|
040776807a | ||
|
|
fbca54637b | ||
|
|
c374e02614 | ||
|
|
de9a087027 | ||
|
|
dc87911b2f | ||
|
|
d001abcf69 | ||
|
|
c16092c293 | ||
|
|
c04562f56d | ||
|
|
b66af8658b | ||
|
|
311178c15a | ||
|
|
b36db41769 | ||
|
|
e5c82e2c92 | ||
|
|
522de2761c | ||
|
|
49017c4a91 | ||
|
|
c1d5a827bc | ||
|
|
937448242f | ||
|
|
f74da248b3 | ||
|
|
c67577d463 | ||
|
|
565dc90762 | ||
|
|
18d63ffd61 | ||
|
|
e3bcc77145 | ||
|
|
df94824054 | ||
|
|
9bd5d0dfa7 | ||
|
|
d6ae706098 | ||
|
|
f9b2e1282a | ||
|
|
943b63937d | ||
|
|
6ed7ab7392 | ||
|
|
4738a0390e | ||
|
|
322dc3eda6 | ||
|
|
ddc4710dfd | ||
|
|
97a468ee2c | ||
|
|
09dd19ca66 | ||
|
|
fae37dd1d0 | ||
|
|
94ed8a0f8f | ||
|
|
028832debc | ||
|
|
51ad700bec | ||
|
|
0216a9e9e2 | ||
|
|
a7c8e8541e | ||
|
|
5f1eeb00eb | ||
|
|
914c197181 | ||
|
|
5598804942 | ||
|
|
d216bcd406 | ||
|
|
11471e3c71 | ||
|
|
ed7600dc90 | ||
|
|
a47b414392 | ||
|
|
3d512c794c | ||
|
|
08acb092a7 | ||
|
|
dc142cc88d | ||
|
|
6799e08356 | ||
|
|
ca6966906f | ||
|
|
c28ccd7568 | ||
|
|
90ae3b6a84 | ||
|
|
72776886cb | ||
|
|
d59c03f5ab | ||
|
|
bb108e9341 | ||
|
|
80c647bf07 | ||
|
|
24be90ace9 | ||
|
|
4feada094c | ||
|
|
4e846007a2 | ||
|
|
8ebb48694f | ||
|
|
afa50c5a12 | ||
|
|
ef6ca80e6d | ||
|
|
0c99455489 | ||
|
|
c34ac2bc0e | ||
|
|
076b794dda | ||
|
|
5dbf887b68 | ||
|
|
17e504fb70 | ||
|
|
69304d3851 | ||
|
|
1c6d02c480 | ||
|
|
d328c5f691 | ||
|
|
e897258a85 | ||
|
|
1b9d8a36d8 | ||
|
|
b750475228 | ||
|
|
138c86526a | ||
|
|
07e682a654 | ||
|
|
0b29c683b9 | ||
|
|
71bd964344 | ||
|
|
1d50c6ce60 | ||
|
|
962ae8c825 | ||
|
|
d3e325f36c | ||
|
|
575412a464 | ||
|
|
60fb009329 | ||
|
|
41737a4f4c | ||
|
|
f1d1df3f17 | ||
|
|
81404fc673 | ||
|
|
a5b6c8a00e | ||
|
|
2a6e5504cd | ||
|
|
3d5c73a353 | ||
|
|
8b94e49711 | ||
|
|
2623517219 |
No files matched your search
@@ -114,8 +114,7 @@ jobs:
|
||||
publish:
|
||||
if: >-
|
||||
github.repository == 'ajisaacs/OpenNest' &&
|
||||
((github.event_name == 'release' && github.event.release.prerelease == false) ||
|
||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'))
|
||||
github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
permissions:
|
||||
|
||||
@@ -7,7 +7,7 @@ OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform librari
|
||||
|
||||
- Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search.
|
||||
- Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`.
|
||||
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`.
|
||||
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`. User guides and their screenshots live in the separate `ajisaacs/opennest-help` repo: update or recapture them when a UI change alters a documented screen, and never rename a `HelpTopics` ID.
|
||||
- Never commit design specs, implementation plans, progress notes or temporary benchmark reports. Keep working records under local, ignored `.hermes/plans/` or `.hermes/progress/`; retain reusable verification procedures in `docs/`.
|
||||
- Keep vendor manuals/full-text extracts out of source control unless redistribution is authorized. Write project-specific behavior summaries with citations, separating controller rules, machine macros and unconfirmed behavior.
|
||||
|
||||
@@ -28,7 +28,7 @@ dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release
|
||||
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
|
||||
```
|
||||
|
||||
Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
|
||||
After changing planning, contour preparation or geometry checks, run the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); no plate may rank below its baseline. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
|
||||
|
||||
Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror.
|
||||
|
||||
@@ -37,18 +37,20 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
|
||||
- `OpenNest.Core`: domain (`Nest -> Plate -> Part -> Drawing -> CNC.Program`), geometry, cutting strategies and diagnostics. Angles are radians; use `Tolerance.Epsilon` for geometry comparisons. `OpenNest.Math` shadows `System.Math`, so qualify the latter.
|
||||
- `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based.
|
||||
- Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution.
|
||||
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
|
||||
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json`. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory.
|
||||
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence; depends on Core only (an IO.Tests guard enforces it). Best fits persist only when callers pass `BestFitCacheStore.Shared` as `NestReader`/`NestWriter.BestFitStore`. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
|
||||
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
|
||||
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection. Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
|
||||
|
||||
## Geometry and ownership safeguards
|
||||
|
||||
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
|
||||
- Clipper is for cached CPU region preparation, never per-pair hot loops. Preserve the hand-written `Collision` kernel's GPU-port contract. Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding. Do not loosen tolerances to hide failures.
|
||||
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check, the runner's candidate validation and the pre-post part-spacing review all find spacing candidates with Core's `SpacingOutline` and confirm them on `NativeContours` (the outline circumscribes arcs by more than the slack), so they agree; `NestLayoutCheck.Clears` skips the confirmation and stays stricter for engine certification. Do not loosen tolerances to hide failures.
|
||||
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
|
||||
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
|
||||
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
|
||||
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
|
||||
- A cutoff starts only at the origin-side edge: stock can be oversize, so the far edge is unknown. Only a head-locked overcut (`CutOff.HeadLockOvercut`, `HeadHeightLock` markers) may start its far-edge segment there; posts refuse it without lock codes.
|
||||
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
|
||||
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
|
||||
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
|
||||
|
||||
@@ -56,9 +58,18 @@ Read the relevant contract before changing its behavior:
|
||||
|
||||
- [Nest file format](docs/nest-file-format.md)
|
||||
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
|
||||
- [Lead-in placement](docs/geometry/lead-in-placement.md)
|
||||
- [Lead-in placement](docs/geometry/lead-in-placement.md) (including the planner's eroded pierce for cutouts too small for their lead-in) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
|
||||
- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material
|
||||
- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them
|
||||
- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, and Plan Cutting's tab placement for parts the grid cannot hold level
|
||||
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
|
||||
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
|
||||
- [Pre-post verification](docs/post-verification.md)
|
||||
- [Machine cutting defaults](docs/machine-cutting-defaults.md): the nest's machine supplies leads, kerf and cut-off settings for plates without their own parameters
|
||||
- [Shared settings](docs/shared-settings.md): in Database mode machines, post settings, clamps and nest defaults come from the server once it holds them; offline uses the last copy read, read-only
|
||||
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
|
||||
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
|
||||
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
|
||||
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`.
|
||||
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
|
||||
- [Desktop help](docs/desktop-help.md): WebView2 help window, F1 topic IDs and the pinned help release both packaging scripts install.
|
||||
@@ -5,6 +5,9 @@ namespace OpenNest.Api;
|
||||
/// <summary>One explicit physical-stock type for a whole nesting job.</summary>
|
||||
public class NestRequestPlate
|
||||
{
|
||||
/// <summary>Positive per-sheet cost in common units; omit on every row for area scoring.</summary>
|
||||
public double? Cost { get; init; }
|
||||
|
||||
public string Id { get; init; }
|
||||
public Size Size { get; init; }
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.IO;
|
||||
|
||||
@@ -13,10 +14,14 @@ namespace OpenNest.Api;
|
||||
/// <summary>Stable fulfillment metadata for one requested part identity.</summary>
|
||||
public sealed record NestPartFulfillment(string PartId, int Requested, int Placed, int Unplaced);
|
||||
|
||||
/// <summary>Copies of one requested part identity the proposal leaves unplaced, and why.</summary>
|
||||
public sealed record NestUnplacedRequestPart(string PartId, int Count, NestUnplacedReason Reason);
|
||||
|
||||
/// <summary>Physical-sheet usage for one stock identity.</summary>
|
||||
public sealed record NestStockUsage(string StockId, int Used, int? Remaining);
|
||||
|
||||
/// <summary>Maps each materialized physical sheet to its source stock identity.</summary>
|
||||
/// <summary>Maps each materialized layout (Nest.Plates index) to its stock identity.
|
||||
/// Its Plate.Quantity gives the physical copies; schema 3 and older used one sheet per entry.</summary>
|
||||
public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
|
||||
|
||||
/// <summary>Independent geometry validation; null on old archives means not recorded.</summary>
|
||||
@@ -24,10 +29,11 @@ public enum NestValidationStatus { Valid, Invalid, Unrepresentable }
|
||||
|
||||
public class NestResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 3;
|
||||
public const int CurrentSchemaVersion = 5;
|
||||
|
||||
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
/// <summary>Physical sheets: sum of Plate.Quantity, not materialized layout entries.</summary>
|
||||
public int SheetCount { get; init; }
|
||||
|
||||
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
|
||||
@@ -42,8 +48,12 @@ public class NestResponse
|
||||
public NestValidationStatus? ValidationStatus { get; init; }
|
||||
public IReadOnlyList<string> Violations { get; init; } = [];
|
||||
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
|
||||
/// <summary>Every unplaced copy by requested part and reason; empty when everything was
|
||||
/// placed. Archives before schema 5 did not record it and load empty.</summary>
|
||||
public IReadOnlyList<NestUnplacedRequestPart> Unplaced { get; init; } = [];
|
||||
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
|
||||
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||
public NestCostSummary Costs { get; init; }
|
||||
public Nest Nest { get; init; }
|
||||
public NestRequest Request { get; init; }
|
||||
|
||||
@@ -86,6 +96,8 @@ public class NestResponse
|
||||
Fulfillment = Fulfillment is null
|
||||
? []
|
||||
: new List<NestPartFulfillment>(Fulfillment),
|
||||
Unplaced = Unplaced is null ? [] : new List<NestUnplacedRequestPart>(Unplaced),
|
||||
Costs = Costs,
|
||||
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
|
||||
PlateStockMappings = PlateStockMappings is null
|
||||
? []
|
||||
@@ -97,7 +109,7 @@ public class NestResponse
|
||||
|
||||
var nestEntry = zip.CreateEntry("nest.nest");
|
||||
using var nestMs = new MemoryStream();
|
||||
new NestWriter(Nest).Write(nestMs);
|
||||
new NestWriter(Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(nestMs);
|
||||
nestMs.Position = 0;
|
||||
await using (var stream = nestEntry.Open())
|
||||
{
|
||||
@@ -154,7 +166,7 @@ public class NestResponse
|
||||
await stream.CopyToAsync(nestMs);
|
||||
}
|
||||
nestMs.Position = 0;
|
||||
nest = new NestReader(nestMs).Read();
|
||||
nest = new NestReader(nestMs) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
}
|
||||
|
||||
return new NestResponse
|
||||
@@ -169,8 +181,10 @@ public class NestResponse
|
||||
ValidationStatus = archive.ValidationStatus,
|
||||
Violations = archive.Violations ?? [],
|
||||
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
|
||||
Unplaced = hasSchemaVersion && archive.SchemaVersion >= 5 ? archive.Unplaced ?? [] : [],
|
||||
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
|
||||
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
|
||||
Costs = archive.Costs,
|
||||
Nest = nest,
|
||||
Request = request,
|
||||
};
|
||||
@@ -178,6 +192,7 @@ public class NestResponse
|
||||
|
||||
private sealed class NestResponseArchiveDto
|
||||
{
|
||||
public NestCostSummary Costs { get; init; }
|
||||
public int SchemaVersion { get; init; }
|
||||
public int SheetCount { get; init; }
|
||||
public double Utilization { get; init; }
|
||||
@@ -188,6 +203,7 @@ public class NestResponse
|
||||
public NestValidationStatus? ValidationStatus { get; init; }
|
||||
public List<string> Violations { get; init; } = [];
|
||||
public List<NestPartFulfillment> Fulfillment { get; init; } = [];
|
||||
public List<NestUnplacedRequestPart> Unplaced { get; init; } = [];
|
||||
public List<NestStockUsage> StockUsage { get; init; } = [];
|
||||
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public static class NestRunner
|
||||
if (requestParts.Count == 0)
|
||||
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
||||
|
||||
var stock = CreateStock(request);
|
||||
var sw = Stopwatch.StartNew();
|
||||
var parts = IdentifyParts(requestParts);
|
||||
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
@@ -82,7 +83,6 @@ public static class NestRunner
|
||||
});
|
||||
}
|
||||
|
||||
var stock = CreateStock(request);
|
||||
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
|
||||
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
|
||||
var result = NestPipeline.Run(new NestPipelineRequest(
|
||||
@@ -91,6 +91,9 @@ public static class NestRunner
|
||||
// API returns a detached proposal, not an acceptance/commit to a caller's nest.
|
||||
// Invalid but representable proposals retain every pose and carry explicit validation status.
|
||||
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
|
||||
if (request.Plates != null)
|
||||
nest.PlateOptions = stock.Where(s => s.Quantity != 0).Select(s => new PlateOption
|
||||
{ Width = s.Size.Width, Length = s.Size.Length, Cost = s.Cost ?? 0 }).ToList();
|
||||
foreach (var item in items)
|
||||
nest.Drawings.Add(item.Drawing);
|
||||
foreach (var proposed in result.Plates)
|
||||
@@ -100,7 +103,7 @@ public static class NestRunner
|
||||
PartSpacing = proposed.Stock.PartSpacing,
|
||||
EdgeSpacing = proposed.Stock.EdgeSpacing,
|
||||
Quadrant = proposed.Stock.Quadrant,
|
||||
Quantity = 1,
|
||||
Quantity = proposed.Quantity,
|
||||
};
|
||||
plate.Parts.AddRange(proposed.Parts);
|
||||
nest.Plates.Add(plate);
|
||||
@@ -108,17 +111,19 @@ public static class NestRunner
|
||||
|
||||
// Pipeline IDs are internal part-i values. Exposed counts/IDs come from the
|
||||
// returned, bound placements and the original request, never plug-in summaries.
|
||||
var counts = result.Plates.SelectMany(p => p.Parts)
|
||||
.GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Count());
|
||||
var counts = result.Plates.SelectMany(p => p.Parts.Select(part => (part.BaseDrawing, p.Quantity)))
|
||||
.GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Sum(p => p.Quantity));
|
||||
var fulfillment = parts.Select((part, i) =>
|
||||
{
|
||||
var placed = counts.GetValueOrDefault(items[i].Drawing);
|
||||
return new NestPartFulfillment(part.Id, part.Request.Quantity, placed,
|
||||
System.Math.Max(0, part.Request.Quantity - placed));
|
||||
}).ToArray();
|
||||
var unplaced = result.Unplaced.Select(u => new NestUnplacedRequestPart(
|
||||
parts[items.FindIndex(item => ReferenceEquals(item.Drawing, u.Drawing))].Id, u.Count, u.Reason)).ToArray();
|
||||
var usage = stock.Select(s =>
|
||||
{
|
||||
var used = result.Plates.Count(p => p.Stock.Id == s.Id);
|
||||
var used = result.Plates.Where(p => p.Stock.Id == s.Id).Sum(p => p.Quantity);
|
||||
return new NestStockUsage(s.Id, used, s.Quantity.HasValue ? System.Math.Max(0, s.Quantity.Value - used) : null);
|
||||
}).ToArray();
|
||||
var complete = fulfillment.All(f => f.Placed == f.Requested);
|
||||
@@ -133,7 +138,7 @@ public static class NestRunner
|
||||
return Task.FromResult(
|
||||
new NestResponse
|
||||
{
|
||||
SheetCount = nest.Plates.Count,
|
||||
SheetCount = nest.Plates.Sum(p => p.Quantity),
|
||||
Utilization = CalculateUtilization(nest),
|
||||
CutTime = cutTime,
|
||||
Elapsed = sw.Elapsed,
|
||||
@@ -143,7 +148,9 @@ public static class NestRunner
|
||||
: result.IsValid ? NestValidationStatus.Valid : NestValidationStatus.Invalid,
|
||||
Violations = result.Violations,
|
||||
Fulfillment = fulfillment,
|
||||
Unplaced = unplaced,
|
||||
StockUsage = usage,
|
||||
Costs = result.CanKeep && result.IsValid ? NestCostSummary.FromAccepted(result) : null,
|
||||
PlateStockMappings = result.Plates.Select((value, index) =>
|
||||
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
|
||||
Nest = nest,
|
||||
@@ -209,11 +216,13 @@ public static class NestRunner
|
||||
plate.Quantity,
|
||||
plate.PartSpacing,
|
||||
plate.EdgeSpacing,
|
||||
plate.Quadrant
|
||||
plate.Quadrant,
|
||||
plate.Cost
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
NestJobValidator.ValidateStockCosts(stock);
|
||||
return stock;
|
||||
}
|
||||
|
||||
@@ -242,11 +251,11 @@ public static class NestRunner
|
||||
|
||||
private static double CalculateUtilization(Nest nest)
|
||||
{
|
||||
var sheetArea = nest.Plates.Sum(plate => plate.Area());
|
||||
var sheetArea = nest.Plates.Sum(plate => plate.Area() * plate.Quantity);
|
||||
if (sheetArea == 0)
|
||||
return 0;
|
||||
var placedArea = nest.Plates.Sum(plate =>
|
||||
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area)
|
||||
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area) * plate.Quantity
|
||||
);
|
||||
return placedArea / sheetArea;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="OpenNest.Avalonia.App"
|
||||
RequestedThemeVariant="Light">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -0,0 +1,30 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace OpenNest.Avalonia
|
||||
{
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var window = new MainWindow();
|
||||
desktop.MainWindow = window;
|
||||
var args = desktop.Args ?? System.Array.Empty<string>();
|
||||
if (args.Length > 0)
|
||||
window.OpenNest(args[0]);
|
||||
// --snapshot <png> [plate]: render the window to an image and exit, for checking the drawing.
|
||||
var snapshot = System.Array.IndexOf(args, "--snapshot");
|
||||
if (snapshot >= 0 && snapshot + 1 < args.Length)
|
||||
window.SaveSnapshotAndClose(args[snapshot + 1],
|
||||
snapshot + 2 < args.Length && int.TryParse(args[snapshot + 2], out var plate) ? plate : 1);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Rendering.SceneGraph;
|
||||
using Avalonia.Skia;
|
||||
using OpenNest.Avalonia.Rendering;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using SkiaSharp;
|
||||
using Control = Avalonia.Controls.Control;
|
||||
using Vector = OpenNest.Geometry.Vector;
|
||||
|
||||
namespace OpenNest.Avalonia.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// Draws one plate with Skia: pans with a left or middle drag, zooms at the cursor with the
|
||||
/// wheel, fits on a double click, and highlights the part under the cursor.
|
||||
/// </summary>
|
||||
public sealed class PlateCanvas : Control
|
||||
{
|
||||
private static readonly SKColor BackgroundColor = new(169, 169, 169);
|
||||
private static readonly SKColor PlateFillColor = new(245, 245, 245);
|
||||
private static readonly SKColor PlateOutlineColor = new(128, 128, 128);
|
||||
private static readonly SKColor EdgeSpacingColor = new(180, 180, 180);
|
||||
private static readonly SKColor LeadColor = new(255, 0, 0);
|
||||
private static readonly SKColor EtchColor = new(0, 150, 60);
|
||||
private static readonly SKColor SelectedColor = new(150, 200, 255, 90 * 255 / 100);
|
||||
|
||||
// Paths are built once per program and only read afterwards, so the render thread may draw
|
||||
// them while the UI thread builds others. Keyed by reference: a rotation replaces the program.
|
||||
private readonly Dictionary<CNC.Program, ProgramPaths> paths = new();
|
||||
|
||||
private Plate plate;
|
||||
private double scale = 1;
|
||||
private Point origin;
|
||||
private Point? dragStart;
|
||||
private bool fitPending = true;
|
||||
|
||||
public Plate Plate
|
||||
{
|
||||
get => plate;
|
||||
set
|
||||
{
|
||||
plate = value;
|
||||
HoveredPart = null;
|
||||
SelectedPart = null;
|
||||
fitPending = true;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
public Part HoveredPart { get; private set; }
|
||||
|
||||
public Part SelectedPart { get; private set; }
|
||||
|
||||
/// <summary>The cursor in plate coordinates, or null when it is outside the view.</summary>
|
||||
public Vector? CursorWorld { get; private set; }
|
||||
|
||||
public event EventHandler ViewChanged;
|
||||
|
||||
public PlateCanvas()
|
||||
{
|
||||
ClipToBounds = true;
|
||||
Focusable = true;
|
||||
}
|
||||
|
||||
public void ClearCache() => paths.Clear();
|
||||
|
||||
public void ZoomToFit()
|
||||
{
|
||||
if (!Fit())
|
||||
return;
|
||||
InvalidateVisual();
|
||||
ViewChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Fits the view to the plate and its parts without requesting a redraw.</summary>
|
||||
private bool Fit()
|
||||
{
|
||||
if (plate == null || Bounds.Width <= 0 || Bounds.Height <= 0)
|
||||
return false;
|
||||
var extents = PlateRect(plate);
|
||||
foreach (var part in plate.Parts)
|
||||
extents = Union(extents, part.BoundingBox);
|
||||
const double margin = 20;
|
||||
var sx = (Bounds.Width - 2 * margin) / System.Math.Max(extents.Length, 1e-6);
|
||||
var sy = (Bounds.Height - 2 * margin) / System.Math.Max(extents.Width, 1e-6);
|
||||
scale = System.Math.Min(sx, sy);
|
||||
origin = new Point(
|
||||
Bounds.Width / 2 - (extents.Left + extents.Length / 2) * scale,
|
||||
Bounds.Height / 2 + (extents.Bottom + extents.Width / 2) * scale);
|
||||
fitPending = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public double Zoom => scale;
|
||||
|
||||
private Vector ToWorld(Point p) => new((p.X - origin.X) / scale, (origin.Y - p.Y) / scale);
|
||||
|
||||
private SKMatrix WorldMatrix =>
|
||||
new((float)scale, 0, (float)origin.X, 0, (float)-scale, (float)origin.Y, 0, 0, 1);
|
||||
|
||||
private static Box PlateRect(Plate plate)
|
||||
{
|
||||
var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0;
|
||||
var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0;
|
||||
return new Box(left, bottom, plate.Size.Length, plate.Size.Width);
|
||||
}
|
||||
|
||||
private static Box Union(Box a, Box b)
|
||||
{
|
||||
var left = System.Math.Min(a.Left, b.Left);
|
||||
var bottom = System.Math.Min(a.Bottom, b.Bottom);
|
||||
return new Box(left, bottom,
|
||||
System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
|
||||
}
|
||||
|
||||
private ProgramPaths PathsFor(Part part)
|
||||
{
|
||||
if (!paths.TryGetValue(part.Program, out var result))
|
||||
paths[part.Program] = result = ProgramPaths.Build(part.Program);
|
||||
return result;
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
// A redraw may not be requested during a render, so tell listeners afterwards.
|
||||
if (fitPending && Fit())
|
||||
global::Avalonia.Threading.Dispatcher.UIThread.Post(() => ViewChanged?.Invoke(this, EventArgs.Empty));
|
||||
|
||||
var items = new List<PartItem>();
|
||||
if (plate != null)
|
||||
{
|
||||
for (var i = 0; i < plate.Parts.Count; i++)
|
||||
{
|
||||
var part = plate.Parts[i];
|
||||
items.Add(new PartItem(PathsFor(part), part.Location, part.BoundingBox,
|
||||
ToSkColor(part.BaseDrawing.Color), (i + 1).ToString(),
|
||||
ReferenceEquals(part, SelectedPart), ReferenceEquals(part, HoveredPart),
|
||||
part.BaseDrawing.IsCutOff, part.HasManualLeadIns));
|
||||
}
|
||||
}
|
||||
|
||||
context.Custom(new PlateDrawOperation(new Rect(Bounds.Size), plate == null ? null : PlateRect(plate),
|
||||
plate?.EdgeSpacing, WorldMatrix, items));
|
||||
}
|
||||
|
||||
private static SKColor ToSkColor(System.Drawing.Color color) =>
|
||||
color.IsEmpty ? new SKColor(130, 204, 130) : new SKColor(color.R, color.G, color.B, color.A);
|
||||
|
||||
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
|
||||
{
|
||||
var at = e.GetPosition(this);
|
||||
var factor = System.Math.Pow(1.2, e.Delta.Y);
|
||||
// Keep the world point under the cursor fixed.
|
||||
origin = new Point(at.X - (at.X - origin.X) * factor, at.Y - (at.Y - origin.Y) * factor);
|
||||
scale *= factor;
|
||||
InvalidateVisual();
|
||||
ViewChanged?.Invoke(this, EventArgs.Empty);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||
{
|
||||
Focus();
|
||||
var point = e.GetCurrentPoint(this);
|
||||
if (e.ClickCount == 2)
|
||||
{
|
||||
ZoomToFit();
|
||||
return;
|
||||
}
|
||||
if (point.Properties.IsLeftButtonPressed)
|
||||
{
|
||||
SelectedPart = HitTest(ToWorld(point.Position));
|
||||
InvalidateVisual();
|
||||
ViewChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
if (point.Properties.IsLeftButtonPressed || point.Properties.IsMiddleButtonPressed)
|
||||
{
|
||||
dragStart = point.Position;
|
||||
e.Pointer.Capture(this);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
var at = e.GetPosition(this);
|
||||
if (dragStart is { } start)
|
||||
{
|
||||
origin += at - start;
|
||||
dragStart = at;
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
CursorWorld = ToWorld(at);
|
||||
var hovered = dragStart == null ? HitTest(CursorWorld.Value) : HoveredPart;
|
||||
if (!ReferenceEquals(hovered, HoveredPart))
|
||||
{
|
||||
HoveredPart = hovered;
|
||||
InvalidateVisual();
|
||||
}
|
||||
ViewChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||
{
|
||||
dragStart = null;
|
||||
e.Pointer.Capture(null);
|
||||
}
|
||||
|
||||
protected override void OnPointerExited(PointerEventArgs e)
|
||||
{
|
||||
CursorWorld = null;
|
||||
if (HoveredPart != null)
|
||||
{
|
||||
HoveredPart = null;
|
||||
InvalidateVisual();
|
||||
}
|
||||
ViewChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.F)
|
||||
{
|
||||
ZoomToFit();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnSizeChanged(global::Avalonia.Controls.SizeChangedEventArgs e)
|
||||
{
|
||||
base.OnSizeChanged(e);
|
||||
if (fitPending)
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
/// <summary>The topmost part whose material contains <paramref name="world"/>.</summary>
|
||||
private Part HitTest(Vector world)
|
||||
{
|
||||
if (plate == null)
|
||||
return null;
|
||||
for (var i = plate.Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var part = plate.Parts[i];
|
||||
var box = part.BoundingBox;
|
||||
if (world.X < box.Left || world.X > box.Right || world.Y < box.Bottom || world.Y > box.Top)
|
||||
continue;
|
||||
var local = world - part.Location;
|
||||
if (PathsFor(part).Fill.Contains((float)local.X, (float)local.Y))
|
||||
return part;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record PartItem(ProgramPaths Paths, Vector Location, Box Bounds, SKColor Color,
|
||||
string Label, bool Selected, bool Hovered, bool IsCutOff, bool ShowLeads);
|
||||
|
||||
private sealed class PlateDrawOperation : ICustomDrawOperation
|
||||
{
|
||||
private readonly Box plateRect;
|
||||
private readonly Spacing? edgeSpacing;
|
||||
private readonly SKMatrix matrix;
|
||||
private readonly List<PartItem> items;
|
||||
|
||||
public PlateDrawOperation(Rect bounds, Box plateRect, Spacing? edgeSpacing, SKMatrix matrix,
|
||||
List<PartItem> items)
|
||||
{
|
||||
Bounds = bounds;
|
||||
this.plateRect = plateRect;
|
||||
this.edgeSpacing = edgeSpacing;
|
||||
this.matrix = matrix;
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
public Rect Bounds { get; }
|
||||
|
||||
public bool HitTest(Point p) => Bounds.Contains(p);
|
||||
|
||||
public bool Equals(ICustomDrawOperation other) => false;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public void Render(ImmediateDrawingContext context)
|
||||
{
|
||||
var lease = context.TryGetFeature<ISkiaSharpApiLeaseFeature>();
|
||||
if (lease == null)
|
||||
return;
|
||||
using var api = lease.Lease();
|
||||
var canvas = api.SkCanvas;
|
||||
canvas.Save();
|
||||
canvas.ClipRect(new SKRect(0, 0, (float)Bounds.Width, (float)Bounds.Height));
|
||||
canvas.Clear(BackgroundColor);
|
||||
|
||||
if (plateRect != null)
|
||||
Draw(canvas);
|
||||
|
||||
canvas.Restore();
|
||||
}
|
||||
|
||||
private void Draw(SKCanvas canvas)
|
||||
{
|
||||
// Hairline strokes (width 0) stay one pixel wide at any zoom.
|
||||
using var fill = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
using var stroke = new SKPaint { Style = SKPaintStyle.Stroke, StrokeWidth = 0, IsAntialias = true };
|
||||
|
||||
canvas.Save();
|
||||
var world = matrix;
|
||||
canvas.Concat(ref world);
|
||||
|
||||
var plate = new SKRect((float)plateRect.Left, (float)plateRect.Bottom,
|
||||
(float)plateRect.Right, (float)plateRect.Top);
|
||||
fill.Color = PlateFillColor;
|
||||
canvas.DrawRect(plate, fill);
|
||||
if (edgeSpacing is { } spacing)
|
||||
{
|
||||
stroke.Color = EdgeSpacingColor;
|
||||
canvas.DrawRect(new SKRect(plate.Left + (float)spacing.Left,
|
||||
plate.Top + (float)spacing.Bottom, plate.Right - (float)spacing.Right,
|
||||
plate.Bottom - (float)spacing.Top), stroke);
|
||||
}
|
||||
stroke.Color = PlateOutlineColor;
|
||||
canvas.DrawRect(plate, stroke);
|
||||
|
||||
var view = canvas.LocalClipBounds;
|
||||
foreach (var item in items)
|
||||
{
|
||||
var b = item.Bounds;
|
||||
if (b.Right < view.Left || b.Left > view.Right || b.Top < view.Top || b.Bottom > view.Bottom)
|
||||
continue;
|
||||
|
||||
canvas.Save();
|
||||
canvas.Translate((float)item.Location.X, (float)item.Location.Y);
|
||||
|
||||
fill.Color = item.Selected ? SelectedColor
|
||||
: item.Hovered ? Lighten(item.Color) : item.Color;
|
||||
canvas.DrawPath(item.Paths.Fill, fill);
|
||||
|
||||
stroke.Color = EtchColor;
|
||||
canvas.DrawPath(item.Paths.Etch, stroke);
|
||||
stroke.Color = item.Selected ? new SKColor(30, 90, 200) : Darken(item.Color);
|
||||
canvas.DrawPath(item.Paths.Cut, stroke);
|
||||
// As on the desktop, only manually placed lead-ins are shown.
|
||||
if (item.ShowLeads)
|
||||
{
|
||||
stroke.Color = LeadColor;
|
||||
canvas.DrawPath(item.Paths.Lead, stroke);
|
||||
}
|
||||
|
||||
canvas.Restore();
|
||||
}
|
||||
|
||||
canvas.Restore();
|
||||
DrawLabels(canvas);
|
||||
}
|
||||
|
||||
// Ids are drawn in screen space and only where the part is big enough on screen to hold one.
|
||||
private void DrawLabels(SKCanvas canvas)
|
||||
{
|
||||
using var text = new SKPaint { Color = SKColors.Black, IsAntialias = true, TextSize = 12 };
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.IsCutOff)
|
||||
continue;
|
||||
var center = matrix.MapPoint((float)item.Bounds.Center.X, (float)item.Bounds.Center.Y);
|
||||
var width = item.Bounds.Length * matrix.ScaleX;
|
||||
var height = item.Bounds.Width * -matrix.ScaleY;
|
||||
var measured = text.MeasureText(item.Label);
|
||||
if (measured + 4 > width || text.TextSize + 4 > height)
|
||||
continue;
|
||||
canvas.DrawText(item.Label, center.X - measured / 2, center.Y + text.TextSize / 3, text);
|
||||
}
|
||||
}
|
||||
|
||||
private static SKColor Darken(SKColor c) =>
|
||||
new((byte)(c.Red * 0.55), (byte)(c.Green * 0.55), (byte)(c.Blue * 0.55), 255);
|
||||
|
||||
private static SKColor Lighten(SKColor c) =>
|
||||
new((byte)(c.Red + (255 - c.Red) / 2), (byte)(c.Green + (255 - c.Green) / 2),
|
||||
(byte)(c.Blue + (255 - c.Blue) / 2), c.Alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:c="using:OpenNest.Avalonia.Controls"
|
||||
xmlns:local="using:OpenNest.Avalonia"
|
||||
x:Class="OpenNest.Avalonia.MainWindow"
|
||||
Title="OpenNest (Avalonia prototype)"
|
||||
Width="1400" Height="860">
|
||||
<DockPanel>
|
||||
<Menu DockPanel.Dock="Top">
|
||||
<MenuItem Header="_File">
|
||||
<MenuItem Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
|
||||
<Separator />
|
||||
<MenuItem Header="E_xit" Click="OnExit" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_View">
|
||||
<MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" />
|
||||
<MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" />
|
||||
<MenuItem Header="_Next Plate" InputGesture="PageDown" Click="OnNextPlate" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<Button Content="Open" Click="OnOpen" />
|
||||
<Button Content="Fit" Click="OnFit" />
|
||||
<Button Content="Previous" Click="OnPreviousPlate" />
|
||||
<Button Content="Next" Click="OnNextPlate" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border DockPanel.Dock="Bottom" Padding="6,3" Background="#F0F0F0">
|
||||
<TextBlock x:Name="Status" Text="Open a .nest file to begin." />
|
||||
</Border>
|
||||
|
||||
<Grid ColumnDefinitions="260,4,*,4,320">
|
||||
<DockPanel Grid.Column="0">
|
||||
<TextBlock DockPanel.Dock="Top" Margin="6,6,6,2" FontWeight="SemiBold" Text="Plates" />
|
||||
<ListBox x:Name="PlateList" SelectionChanged="OnPlateSelected" />
|
||||
</DockPanel>
|
||||
<GridSplitter Grid.Column="1" ResizeDirection="Columns" />
|
||||
<c:PlateCanvas Grid.Column="2" x:Name="Canvas" />
|
||||
<GridSplitter Grid.Column="3" ResizeDirection="Columns" />
|
||||
<DockPanel Grid.Column="4">
|
||||
<TextBlock DockPanel.Dock="Top" Margin="6,6,6,2" FontWeight="SemiBold" Text="Drawings on plate" />
|
||||
<DataGrid x:Name="DrawingGrid" x:DataType="local:DrawingRow" IsReadOnly="True"
|
||||
AutoGenerateColumns="False" GridLinesVisibility="Horizontal" CanUserSortColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Drawing" Binding="{Binding Name}" Width="*" />
|
||||
<DataGridTextColumn Header="Qty" Binding="{Binding Count}" />
|
||||
<DataGridTextColumn Header="Size" Binding="{Binding Size}" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Avalonia
|
||||
{
|
||||
public sealed record DrawingRow(string Name, int Count, string Size);
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private Nest nest;
|
||||
private string fileName;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
Canvas.ViewChanged += (_, _) => UpdateStatus();
|
||||
KeyBindings.Add(new KeyBinding
|
||||
{
|
||||
Gesture = new KeyGesture(Key.O, KeyModifiers.Control),
|
||||
Command = new Command(() => OnOpen(null, null)),
|
||||
});
|
||||
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageUp), Command = new Command(() => StepPlate(-1)) });
|
||||
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageDown), Command = new Command(() => StepPlate(1)) });
|
||||
}
|
||||
|
||||
public void OpenNest(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
nest = new NestReader(path).Read();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Status.Text = $"Could not open {Path.GetFileName(path)}: {ex.Message}";
|
||||
return;
|
||||
}
|
||||
|
||||
fileName = Path.GetFileName(path);
|
||||
Title = $"{fileName} - OpenNest (Avalonia prototype)";
|
||||
Canvas.ClearCache();
|
||||
PlateList.ItemsSource = nest.Plates
|
||||
.Select((plate, i) => $"Plate {i + 1} {plate.Size.Length:0.###} x {plate.Size.Width:0.###} ({plate.Parts.Count} parts)")
|
||||
.ToList();
|
||||
PlateList.SelectedIndex = nest.Plates.Count > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
public void SaveSnapshotAndClose(string path, int plateNumber)
|
||||
{
|
||||
Opened += async (_, _) =>
|
||||
{
|
||||
if (nest?.Plates.Count > 0)
|
||||
PlateList.SelectedIndex = System.Math.Clamp(plateNumber - 1, 0, nest.Plates.Count - 1);
|
||||
await System.Threading.Tasks.Task.Delay(500);
|
||||
var size = new global::Avalonia.PixelSize((int)Bounds.Width, (int)Bounds.Height);
|
||||
using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
|
||||
bitmap.Render(this);
|
||||
bitmap.Save(path);
|
||||
Close();
|
||||
};
|
||||
}
|
||||
|
||||
private async void OnOpen(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Open Nest",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = new[] { new FilePickerFileType("Nest files") { Patterns = new[] { "*.nest" } } },
|
||||
});
|
||||
var path = files.FirstOrDefault()?.TryGetLocalPath();
|
||||
if (path != null)
|
||||
OpenNest(path);
|
||||
}
|
||||
|
||||
private void OnExit(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private void OnFit(object sender, RoutedEventArgs e) => Canvas.ZoomToFit();
|
||||
|
||||
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
|
||||
|
||||
private void OnNextPlate(object sender, RoutedEventArgs e) => StepPlate(1);
|
||||
|
||||
private void StepPlate(int step)
|
||||
{
|
||||
if (nest == null || nest.Plates.Count == 0)
|
||||
return;
|
||||
PlateList.SelectedIndex = System.Math.Clamp(PlateList.SelectedIndex + step, 0, nest.Plates.Count - 1);
|
||||
}
|
||||
|
||||
private void OnPlateSelected(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
var index = PlateList.SelectedIndex;
|
||||
var plate = nest != null && index >= 0 ? nest.Plates[index] : null;
|
||||
Canvas.Plate = plate;
|
||||
DrawingGrid.ItemsSource = plate?.Parts
|
||||
.Where(part => !part.BaseDrawing.IsCutOff)
|
||||
.GroupBy(part => part.BaseDrawing)
|
||||
.Select(group => new DrawingRow(group.Key.Name, group.Count(), SizeOf(group.First())))
|
||||
.OrderByDescending(row => row.Count)
|
||||
.ToList();
|
||||
UpdateStatus();
|
||||
}
|
||||
|
||||
private static string SizeOf(Part part) =>
|
||||
$"{part.BoundingBox.Length:0.###} x {part.BoundingBox.Width:0.###}";
|
||||
|
||||
private void UpdateStatus()
|
||||
{
|
||||
if (Canvas.Plate == null)
|
||||
return;
|
||||
var text = $"{fileName} Plate {PlateList.SelectedIndex + 1} of {nest.Plates.Count} Zoom {Canvas.Zoom:0.##} px/unit";
|
||||
if (Canvas.CursorWorld is { } at)
|
||||
text += $" X {at.X:0.000} Y {at.Y:0.000}";
|
||||
var part = Canvas.HoveredPart ?? Canvas.SelectedPart;
|
||||
if (part != null)
|
||||
text += $" {(ReferenceEquals(part, Canvas.SelectedPart) ? "Selected" : "Over")}: {part.BaseDrawing.Name}";
|
||||
Status.Text = text;
|
||||
}
|
||||
|
||||
private sealed class Command : System.Windows.Input.ICommand
|
||||
{
|
||||
private readonly Action action;
|
||||
|
||||
public Command(Action action) => this.action = action;
|
||||
|
||||
public event EventHandler CanExecuteChanged { add { } remove { } }
|
||||
|
||||
public bool CanExecute(object parameter) => true;
|
||||
|
||||
public void Execute(object parameter) => action();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>OpenNest.Avalonia</RootNamespace>
|
||||
<AssemblyName>OpenNest.Avalonia</AssemblyName>
|
||||
<Nullable>disable</Nullable>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
|
||||
namespace OpenNest.Avalonia
|
||||
{
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args) =>
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace OpenNest.Avalonia.Rendering
|
||||
{
|
||||
/// <summary>
|
||||
/// A program's display geometry as Skia paths in the program frame, before placement.
|
||||
/// A Skia port of the desktop's GraphicsHelper.GetDisplayPaths, without tab bridging.
|
||||
/// </summary>
|
||||
internal sealed class ProgramPaths : IDisposable
|
||||
{
|
||||
public SKPath Cut { get; } = new() { FillType = SKPathFillType.EvenOdd };
|
||||
|
||||
public SKPath Lead { get; } = new();
|
||||
|
||||
public SKPath Etch { get; } = new();
|
||||
|
||||
/// <summary>The material outline to fill: cuts and tabs, without open break cuts.</summary>
|
||||
public SKPath Fill { get; } = new() { FillType = SKPathFillType.EvenOdd };
|
||||
|
||||
public SKRect Bounds { get; private set; }
|
||||
|
||||
public static ProgramPaths Build(CNC.Program program)
|
||||
{
|
||||
var paths = new ProgramPaths();
|
||||
var position = Vector.Zero;
|
||||
paths.AddProgram(program, ref position);
|
||||
var bounds = paths.Cut.Bounds;
|
||||
if (!paths.Etch.IsEmpty)
|
||||
bounds = paths.Cut.IsEmpty ? paths.Etch.Bounds : SKRect.Union(bounds, paths.Etch.Bounds);
|
||||
paths.Bounds = bounds;
|
||||
return paths;
|
||||
}
|
||||
|
||||
private SKPath TargetFor(LayerType layer) => layer switch
|
||||
{
|
||||
LayerType.Leadin or LayerType.Leadout => Lead,
|
||||
LayerType.Scribe => Etch,
|
||||
LayerType.Display or LayerType.Tab => null,
|
||||
_ => Cut,
|
||||
};
|
||||
|
||||
private void AddProgram(CNC.Program program, ref Vector position)
|
||||
{
|
||||
// Sub-program offsets are relative to the frame origin at entry, not the tool position.
|
||||
var frameOrigin = position;
|
||||
var mode = program.Mode;
|
||||
SKPath open = null;
|
||||
|
||||
for (var i = 0; i < program.Length; ++i)
|
||||
{
|
||||
var code = program[i];
|
||||
switch (code)
|
||||
{
|
||||
case LinearMove line:
|
||||
{
|
||||
var end = mode == Mode.Incremental ? line.EndPoint + position : line.EndPoint;
|
||||
var target = line.Suppressed ? null : TargetFor(line.Layer);
|
||||
if (target != null)
|
||||
{
|
||||
Continue(ref open, target, position);
|
||||
target.LineTo((float)end.X, (float)end.Y);
|
||||
}
|
||||
if (!line.Suppressed && line.Layer != LayerType.Break)
|
||||
AddFillLine(line.Layer, position, end);
|
||||
position = end;
|
||||
break;
|
||||
}
|
||||
|
||||
case ArcMove arc:
|
||||
{
|
||||
var end = arc.EndPoint;
|
||||
var center = arc.CenterPoint;
|
||||
if (mode == Mode.Incremental)
|
||||
{
|
||||
end += position;
|
||||
center += position;
|
||||
}
|
||||
var target = arc.Suppressed ? null : TargetFor(arc.Layer);
|
||||
if (target != null)
|
||||
{
|
||||
Continue(ref open, target, position);
|
||||
AddArc(target, position, end, center, arc.Rotation);
|
||||
}
|
||||
if (!arc.Suppressed && arc.Layer != LayerType.Break)
|
||||
AddFillArc(arc.Layer, position, end, center, arc.Rotation);
|
||||
position = end;
|
||||
break;
|
||||
}
|
||||
|
||||
case RapidMove rapid:
|
||||
{
|
||||
var end = mode == Mode.Incremental ? rapid.EndPoint + position : rapid.EndPoint;
|
||||
if (end.DistanceTo(position) > 0.001)
|
||||
open = null;
|
||||
position = end;
|
||||
break;
|
||||
}
|
||||
|
||||
case SubProgramCall call when call.Program != null:
|
||||
open = null;
|
||||
position = new Vector(frameOrigin.X + call.Offset.X, frameOrigin.Y + call.Offset.Y);
|
||||
AddProgram(call.Program, ref position);
|
||||
open = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Starts a new contour unless the move continues the one already open on the same path.
|
||||
private SKPath openFill;
|
||||
|
||||
private static void Continue(ref SKPath open, SKPath target, Vector from)
|
||||
{
|
||||
if (!ReferenceEquals(open, target) || target.LastPoint != new SKPoint((float)from.X, (float)from.Y))
|
||||
target.MoveTo((float)from.X, (float)from.Y);
|
||||
open = target;
|
||||
}
|
||||
|
||||
private bool IsMaterial(LayerType layer) =>
|
||||
layer is not (LayerType.Scribe or LayerType.Leadin or LayerType.Leadout or LayerType.Display);
|
||||
|
||||
private void AddFillLine(LayerType layer, Vector from, Vector to)
|
||||
{
|
||||
if (!IsMaterial(layer))
|
||||
return;
|
||||
Continue(ref openFill, Fill, from);
|
||||
Fill.LineTo((float)to.X, (float)to.Y);
|
||||
}
|
||||
|
||||
private void AddFillArc(LayerType layer, Vector from, Vector to, Vector center, RotationType rotation)
|
||||
{
|
||||
if (!IsMaterial(layer))
|
||||
return;
|
||||
Continue(ref openFill, Fill, from);
|
||||
AddArc(Fill, from, to, center, rotation);
|
||||
}
|
||||
|
||||
private static void AddArc(SKPath path, Vector from, Vector to, Vector center, RotationType rotation)
|
||||
{
|
||||
var startAngle = Angle.NormalizeDeg(Angle.ToDegrees(System.Math.Atan2(from.Y - center.Y, from.X - center.X)));
|
||||
var endAngle = Angle.NormalizeDeg(Angle.ToDegrees(System.Math.Atan2(to.Y - center.Y, to.X - center.X)));
|
||||
var radius = (float)center.DistanceTo(to);
|
||||
var oval = new SKRect((float)center.X - radius, (float)center.Y - radius,
|
||||
(float)center.X + radius, (float)center.Y + radius);
|
||||
|
||||
if (startAngle.IsEqualTo(endAngle))
|
||||
{
|
||||
// A full circle: two half sweeps keep the contour starting at the current point.
|
||||
var sweep = rotation == RotationType.CCW ? 180f : -180f;
|
||||
path.ArcTo(oval, (float)startAngle, sweep, false);
|
||||
path.ArcTo(oval, (float)startAngle + sweep, sweep, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rotation == RotationType.CCW && endAngle < startAngle)
|
||||
endAngle += 360.0;
|
||||
else if (rotation == RotationType.CW && startAngle < endAngle)
|
||||
startAngle += 360.0;
|
||||
|
||||
path.ArcTo(oval, (float)startAngle, (float)(endAngle - startAngle), false);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cut.Dispose();
|
||||
Lead.Dispose();
|
||||
Etch.Dispose();
|
||||
Fill.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@@ -37,6 +38,12 @@ namespace OpenNest.Benchmark
|
||||
public double PartSpacing { get; init; }
|
||||
public int Quadrant { get; init; }
|
||||
|
||||
/// <summary>Source plate's machine clamp rail, resolved for each candidate size as Auto Nest does; null when none.</summary>
|
||||
public ClampRail ClampRail { get; init; }
|
||||
|
||||
/// <summary>Source plate's explicit clamp zones when it has no rail, offered unchanged on every size.</summary>
|
||||
public IReadOnlyList<ClampZone> ClampZones { get; init; } = Array.Empty<ClampZone>();
|
||||
|
||||
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
|
||||
public double SalvageRate { get; init; }
|
||||
|
||||
@@ -60,7 +67,8 @@ namespace OpenNest.Benchmark
|
||||
/// (unlimited quantity - the engine under test decides how many of each
|
||||
/// size it actually uses, and how demand splits across plates). The
|
||||
/// engine owns its own multi-plate/size strategy; this harness no
|
||||
/// longer picks plate sizes on the engine's behalf.
|
||||
/// longer picks plate sizes on the engine's behalf. Stock comes from
|
||||
/// the same template builder as desktop Auto Nest, so clamps match it.
|
||||
/// </summary>
|
||||
public NestJob BuildNestJob(
|
||||
int maxPlates,
|
||||
@@ -71,14 +79,13 @@ namespace OpenNest.Benchmark
|
||||
var parts = Requests.Select(r =>
|
||||
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
|
||||
);
|
||||
var stock = CandidateSizes.Select(size => new NestPlateStock(
|
||||
size.ToString(1),
|
||||
size,
|
||||
null,
|
||||
PartSpacing,
|
||||
EdgeSpacing,
|
||||
Quadrant
|
||||
));
|
||||
// No options would make the builder offer the template itself; an empty pool stays empty.
|
||||
var stock = CandidateSizes.Count == 0
|
||||
? Array.Empty<NestPlateStock>()
|
||||
: NestStockBuilder.FromTemplate(
|
||||
StockTemplate(),
|
||||
CandidateSizes.Select(size => new PlateOption { Width = size.Width, Length = size.Length }).ToList()
|
||||
);
|
||||
return new NestJob(
|
||||
parts,
|
||||
stock,
|
||||
@@ -90,5 +97,22 @@ namespace OpenNest.Benchmark
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>A plate carrying this job's spacing, quadrant and clamps, sized to the largest candidate.</summary>
|
||||
private Plate StockTemplate()
|
||||
{
|
||||
var template = new Plate(CandidateSizes.MaxBy(size => size.Width * size.Length))
|
||||
{
|
||||
PartSpacing = PartSpacing,
|
||||
EdgeSpacing = EdgeSpacing,
|
||||
Quadrant = Quadrant,
|
||||
};
|
||||
if (ClampRail != null)
|
||||
template.SetClampRail(ClampRail);
|
||||
else
|
||||
foreach (var zone in ClampZones)
|
||||
template.ClampZones.Add(zone);
|
||||
return template;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
|
||||
@@ -125,14 +126,7 @@ namespace OpenNest.Benchmark
|
||||
.BaselinePlateRuns.Select(
|
||||
(run, index) =>
|
||||
{
|
||||
var stock = new NestPlateStock(
|
||||
$"baseline-{index}",
|
||||
run.Plate.Size,
|
||||
1,
|
||||
run.Plate.PartSpacing,
|
||||
run.Plate.EdgeSpacing,
|
||||
run.Plate.Quadrant
|
||||
);
|
||||
var stock = DrawingJobMapper.FromPlate($"baseline-{index}", run.Plate, 1);
|
||||
var placements = run
|
||||
.Parts.Select(part =>
|
||||
{
|
||||
@@ -290,7 +284,7 @@ namespace OpenNest.Benchmark
|
||||
// Manifest jobs have no source nest to copy from, so they keep the job's name.
|
||||
if (job.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var source = new OpenNest.IO.NestReader(job.SourceFile).Read();
|
||||
var source = new OpenNest.IO.NestReader(job.SourceFile) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
materialized.Nest.Name = source.Name;
|
||||
materialized.Nest.Units = source.Units;
|
||||
materialized.Nest.Material = source.Material;
|
||||
@@ -316,7 +310,7 @@ namespace OpenNest.Benchmark
|
||||
throw new InvalidOperationException(
|
||||
"Output must not overwrite the source nest."
|
||||
);
|
||||
new OpenNest.IO.NestWriter(materialized.Nest).Write(path);
|
||||
new OpenNest.IO.NestWriter(materialized.Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(path);
|
||||
var report = new
|
||||
{
|
||||
Source = job.SourceFile,
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
@@ -39,7 +40,7 @@ namespace OpenNest.Benchmark
|
||||
|
||||
try
|
||||
{
|
||||
nest = new NestReader(file).Read();
|
||||
nest = new NestReader(file) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -73,6 +74,8 @@ namespace OpenNest.Benchmark
|
||||
EdgeSpacing = template.EdgeSpacing,
|
||||
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
||||
Quadrant = template.Quadrant,
|
||||
ClampRail = template.ClampRail,
|
||||
ClampZones = template.ClampZones,
|
||||
SalvageRate = nest.SalvageRate,
|
||||
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
|
||||
Requests = requests,
|
||||
@@ -181,23 +184,45 @@ namespace OpenNest.Benchmark
|
||||
plate.EdgeSpacing.Top
|
||||
),
|
||||
};
|
||||
// Clamps are part of the sheet the layout was made on.
|
||||
if (plate.ClampRail != null)
|
||||
validationPlate.SetClampRail(plate.ClampRail, plate.RemovedClamps);
|
||||
else
|
||||
foreach (var zone in plate.ClampZones)
|
||||
validationPlate.ClampZones.Add(zone);
|
||||
for (var copy = 0; copy < plate.Quantity; copy++)
|
||||
runs.Add((validationPlate, parts));
|
||||
}
|
||||
return runs.Count > 0 ? runs : null;
|
||||
}
|
||||
|
||||
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(
|
||||
Nest nest
|
||||
)
|
||||
private static (
|
||||
Spacing EdgeSpacing,
|
||||
double PartSpacing,
|
||||
int Quadrant,
|
||||
ClampRail ClampRail,
|
||||
IReadOnlyList<ClampZone> ClampZones
|
||||
) ResolvePlateTemplate(Nest nest)
|
||||
{
|
||||
var source = nest.Plates?.FirstOrDefault();
|
||||
|
||||
if (source != null)
|
||||
return (source.EdgeSpacing, source.PartSpacing, source.Quadrant);
|
||||
return (
|
||||
source.EdgeSpacing,
|
||||
source.PartSpacing,
|
||||
source.Quadrant,
|
||||
source.ClampRail,
|
||||
source.ClampRail == null ? source.ClampZones.ToList() : Array.Empty<ClampZone>()
|
||||
);
|
||||
|
||||
var defaults = nest.PlateDefaults;
|
||||
return (defaults.EdgeSpacing, defaults.PartSpacing, defaults.Quadrant);
|
||||
return (
|
||||
defaults.EdgeSpacing,
|
||||
defaults.PartSpacing,
|
||||
defaults.Quadrant,
|
||||
defaults.ClampRail,
|
||||
Array.Empty<ClampZone>()
|
||||
);
|
||||
}
|
||||
|
||||
private static List<Size> ResolveSheetSizes(Nest nest)
|
||||
|
||||
+358
-99
@@ -5,10 +5,15 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using OpenNest;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
@@ -75,6 +80,20 @@ static class NestConsole
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!options.AutoNest && (options.MaxSheets != 1 || options.Stock != null))
|
||||
{
|
||||
Console.Error.WriteLine("Error: --max-sheets and --stock require --autonest.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (options.MaxSheets < 1 || options.Stock is { Count: 0 })
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Error: --max-sheets needs a whole number >= 1 and --stock needs WxL[:cost] sizes separated by commas (for example 60x120,48x96:85)."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Validate --engine up front: autonest names a jobs engine, plain fill names a
|
||||
// single-plate placement strategy. Unknown names exit with the valid choices.
|
||||
if (options.AutoNest)
|
||||
@@ -90,19 +109,37 @@ static class NestConsole
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
PlateFillService.ResolveStrategy(options.Engine);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
var engine = EngineNameResolver.Classify(options.Engine);
|
||||
if (!engine.IsFillStrategy)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"Error: unknown engine '{options.Engine}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (jobs engines such as StockLadder require --autonest)"
|
||||
engine.IsJobEngine
|
||||
? $"Error: engine '{engine.Name}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
|
||||
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as Irregular require --autonest)"
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.PlanCutting && (options.CuttingParamsFile != null || options.KeepPartOrder
|
||||
|| options.AcceptUnverifiedPlan || options.AllPlates))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Error: --cutting-params, --keep-part-order, --accept-unverified-plan and --all-plates require --plan-cutting."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null
|
||||
|| options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue
|
||||
|| options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size or --template."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (options.InputFiles.Count == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
@@ -118,42 +155,74 @@ static class NestConsole
|
||||
}
|
||||
}
|
||||
|
||||
// Read explicit settings before nesting, so a bad file fails without wasting the fill.
|
||||
CuttingParameters cuttingParameters = null;
|
||||
if (options.CuttingParamsFile != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
cuttingParameters = CuttingParametersSerializer.DeserializeExplicit(
|
||||
File.ReadAllText(options.CuttingParamsFile));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException
|
||||
or NotSupportedException)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: cannot read cutting settings '{options.CuttingParamsFile}': {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
var nest = LoadOrCreateNest(options);
|
||||
|
||||
if (nest == null)
|
||||
return 1;
|
||||
|
||||
var plate = nest.Plates[options.PlateIndex];
|
||||
IReadOnlyList<Plate> plates = new[] { plate };
|
||||
var overlapCount = 0;
|
||||
|
||||
if (options.AutoNest && options.KeepParts && plate.Parts.Count > 0)
|
||||
if (options.NoFill)
|
||||
{
|
||||
Console.Error.WriteLine("Error: --autonest --keep-parts cannot use an occupied plate. Use plain fill for existing obstacles.");
|
||||
return 2;
|
||||
Console.WriteLine($"Nest: {nest.Name}");
|
||||
overlapCount = CheckOverlaps(nest, plates, options);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (options.AutoNest && options.KeepParts && plate.Parts.Count > 0)
|
||||
{
|
||||
Console.Error.WriteLine("Error: --autonest --keep-parts cannot use an occupied plate. Use plain fill for existing obstacles.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
ApplyTemplate(plate, options);
|
||||
ApplyOverrides(plate, options);
|
||||
|
||||
var drawing = ResolveDrawing(nest, options);
|
||||
|
||||
if (drawing == null)
|
||||
return 1;
|
||||
|
||||
var existingCount = plate.Parts.Count;
|
||||
|
||||
if (!options.AutoNest && !options.KeepParts)
|
||||
plate.Parts.Clear();
|
||||
|
||||
PrintHeader(nest, plate, drawing, existingCount, options);
|
||||
|
||||
var (success, elapsed, accepted, filled) = Fill(nest, plate, drawing, options, token);
|
||||
if (!accepted)
|
||||
return 2; // No save or post may run after a rejected proposal.
|
||||
|
||||
plates = filled;
|
||||
overlapCount = CheckOverlaps(nest, plates, options);
|
||||
|
||||
PrintResults(success, nest, plates, elapsed);
|
||||
}
|
||||
|
||||
ApplyTemplate(plate, options);
|
||||
ApplyOverrides(plate, options);
|
||||
|
||||
var drawing = ResolveDrawing(nest, options);
|
||||
|
||||
if (drawing == null)
|
||||
return 1;
|
||||
|
||||
var existingCount = plate.Parts.Count;
|
||||
|
||||
if (!options.AutoNest && !options.KeepParts)
|
||||
plate.Parts.Clear();
|
||||
|
||||
PrintHeader(nest, plate, drawing, existingCount, options);
|
||||
|
||||
var (success, elapsed, accepted) = Fill(nest, plate, drawing, options, token);
|
||||
if (!accepted)
|
||||
return 2; // No save or post may run after a rejected proposal.
|
||||
|
||||
var overlapCount = CheckOverlaps(plate, options);
|
||||
|
||||
PrintResults(success, plate, elapsed);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (options.PlanCutting && !PlanCutting(nest, options.AllPlates ? nest.Plates : plates,
|
||||
cuttingParameters, options, token))
|
||||
return 2; // Like a rejected nest: nothing is saved or posted.
|
||||
if (!SaveAndPost(nest, options))
|
||||
return 1;
|
||||
|
||||
@@ -225,6 +294,14 @@ static class NestConsole
|
||||
case "--autonest":
|
||||
o.AutoNest = true;
|
||||
break;
|
||||
case "--max-sheets" when i + 1 < args.Length:
|
||||
o.MaxSheets = int.TryParse(args[++i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var sheets)
|
||||
? sheets
|
||||
: 0;
|
||||
break;
|
||||
case "--stock" when i + 1 < args.Length:
|
||||
o.Stock = ParseStock(args[++i]);
|
||||
break;
|
||||
case "--engine" when i + 1 < args.Length:
|
||||
o.Engine = args[++i];
|
||||
break;
|
||||
@@ -243,6 +320,24 @@ static class NestConsole
|
||||
case "--list-posts":
|
||||
o.ListPosts = true;
|
||||
break;
|
||||
case "--no-fill":
|
||||
o.NoFill = true;
|
||||
break;
|
||||
case "--plan-cutting":
|
||||
o.PlanCutting = true;
|
||||
break;
|
||||
case "--cutting-params" when i + 1 < args.Length:
|
||||
o.CuttingParamsFile = args[++i];
|
||||
break;
|
||||
case "--keep-part-order":
|
||||
o.KeepPartOrder = true;
|
||||
break;
|
||||
case "--accept-unverified-plan":
|
||||
o.AcceptUnverifiedPlan = true;
|
||||
break;
|
||||
case "--all-plates":
|
||||
o.AllPlates = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
PrintUsage();
|
||||
@@ -257,23 +352,54 @@ static class NestConsole
|
||||
return o;
|
||||
}
|
||||
|
||||
/// <summary>Parses "WxL[:cost],..."; any malformed entry returns an empty list, which validation rejects.</summary>
|
||||
static List<PlateOption> ParseStock(string value)
|
||||
{
|
||||
var stock = new List<PlateOption>();
|
||||
foreach (var entry in value.Split(',', StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var parts = entry.Split(':', StringSplitOptions.TrimEntries);
|
||||
var cost = 0.0;
|
||||
if (
|
||||
parts.Length > 2
|
||||
|| !Size.TryParse(parts[0], out var size)
|
||||
|| !double.IsFinite(size.Width)
|
||||
|| !double.IsFinite(size.Length)
|
||||
|| size.Width <= 0
|
||||
|| size.Length <= 0
|
||||
|| (
|
||||
parts.Length == 2
|
||||
&& (
|
||||
!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out cost)
|
||||
|| !double.IsFinite(cost)
|
||||
|| cost <= 0
|
||||
)
|
||||
)
|
||||
)
|
||||
return new List<PlateOption>();
|
||||
|
||||
stock.Add(new PlateOption { Width = size.Width, Length = size.Length, Cost = cost });
|
||||
}
|
||||
|
||||
return stock;
|
||||
}
|
||||
|
||||
static bool IsCadFile(string path) =>
|
||||
path.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
static Nest LoadOrCreateNest(Options options)
|
||||
{
|
||||
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
||||
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
var dxfFiles = options
|
||||
.InputFiles.Where(f =>
|
||||
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||
|| f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
.ToList();
|
||||
var dxfFiles = options.InputFiles.Where(IsCadFile).ToList();
|
||||
|
||||
// If we have a nest file, load it and optionally add DXFs.
|
||||
if (nestFile != null)
|
||||
{
|
||||
var nest = new NestReader(nestFile).Read();
|
||||
var nest = new NestReader(nestFile) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
|
||||
if (nest.Plates.Count == 0)
|
||||
{
|
||||
@@ -318,9 +444,11 @@ static class NestConsole
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build the plate as the desktop does: Quantity = 1 so part counts and the
|
||||
// drawings' nested quantities include it.
|
||||
var newNest = new Nest { Name = "DXF Import" };
|
||||
var plate = new Plate { Size = options.PlateSize.Value };
|
||||
newNest.Plates.Add(plate);
|
||||
newNest.PlateDefaults.Size = options.PlateSize.Value;
|
||||
newNest.CreatePlate();
|
||||
|
||||
foreach (var dxf in dxfFiles)
|
||||
{
|
||||
@@ -377,7 +505,7 @@ static class NestConsole
|
||||
return;
|
||||
}
|
||||
|
||||
var templateNest = new NestReader(options.TemplateFile).Read();
|
||||
var templateNest = new NestReader(options.TemplateFile) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
var templatePlate = templateNest.PlateDefaults.CreateNew();
|
||||
plate.Quadrant = templatePlate.Quadrant;
|
||||
plate.EdgeSpacing = templatePlate.EdgeSpacing;
|
||||
@@ -446,7 +574,7 @@ static class NestConsole
|
||||
);
|
||||
}
|
||||
|
||||
static (bool success, long elapsedMs, bool accepted) Fill(
|
||||
static (bool success, long elapsedMs, bool accepted, IReadOnlyList<Plate> plates) Fill(
|
||||
Nest nest,
|
||||
Plate plate,
|
||||
Drawing drawing,
|
||||
@@ -457,6 +585,7 @@ static class NestConsole
|
||||
var sw = Stopwatch.StartNew();
|
||||
bool success;
|
||||
var accepted = true;
|
||||
IReadOnlyList<Plate> plates = new[] { plate };
|
||||
|
||||
if (options.AutoNest)
|
||||
{
|
||||
@@ -477,15 +606,17 @@ static class NestConsole
|
||||
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
|
||||
);
|
||||
|
||||
var outcome = AutoNestJob(plate, nestItems, options.Engine, options.AllowInvalid, token);
|
||||
var outcome = AutoNestJob(nest, plate, nestItems, options, token);
|
||||
accepted = outcome.accepted;
|
||||
success = outcome.committed > 0;
|
||||
if (accepted)
|
||||
plates = outcome.plates;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single-plate fill: explicit placement strategy through the public service;
|
||||
// the process-global engine registry is never consulted.
|
||||
var strategy = ResolveFillStrategy(options.Engine);
|
||||
var strategy = EngineNameResolver.Classify(options.Engine).FillStrategy;
|
||||
var item = new NestItem { Drawing = drawing, Quantity = options.Quantity };
|
||||
var parts = PlateFillService.FillItem(
|
||||
strategy,
|
||||
@@ -503,95 +634,202 @@ static class NestConsole
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
return (success, sw.ElapsedMilliseconds, accepted);
|
||||
return (success, sw.ElapsedMilliseconds, accepted, plates);
|
||||
}
|
||||
|
||||
static (bool accepted, int committed) AutoNestJob(
|
||||
Plate plate, List<NestItem> nestItems, string engineName, bool allowInvalid, CancellationToken token)
|
||||
static (bool accepted, int committed, IReadOnlyList<Plate> plates) AutoNestJob(
|
||||
Nest nest, Plate plate, List<NestItem> nestItems, Options options, CancellationToken token)
|
||||
{
|
||||
var rejected = (false, 0, (IReadOnlyList<Plate>)Array.Empty<Plate>());
|
||||
// One sheet of the target's size keeps the original single-sheet stock exactly;
|
||||
// otherwise the target is the template for every offered size, unlimited per size
|
||||
// and capped by --max-sheets physical sheets in total.
|
||||
var singleSheet = options.MaxSheets == 1 && options.Stock == null;
|
||||
var stock = singleSheet
|
||||
? NestStockBuilder.SinglePlate(plate)
|
||||
: NestStockBuilder.FromTemplate(plate, options.Stock);
|
||||
var result = NestPipeline.Run(new NestPipelineRequest(
|
||||
engineName, nestItems, NestStockBuilder.SinglePlate(plate)), token: token);
|
||||
options.Engine, nestItems, stock,
|
||||
singleSheet ? null : new NestJobOptions(maxPlates: options.MaxSheets)), token: token);
|
||||
foreach (var violation in result.Violations)
|
||||
Console.Error.WriteLine($"Violation: {violation}");
|
||||
foreach (var unplaced in result.Unplaced)
|
||||
Console.Error.WriteLine($"Unplaced: {unplaced.Describe()}");
|
||||
|
||||
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
|
||||
var tooManySheets = result.Raw.Plates.Count > options.MaxSheets;
|
||||
if (!result.CanKeep || tooManySheets || (!result.IsValid && !options.AllowInvalid))
|
||||
{
|
||||
Console.Error.WriteLine(result.Plates.Count > 1
|
||||
? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved."
|
||||
Console.Error.WriteLine(tooManySheets
|
||||
? $"Error: result uses {result.Raw.Plates.Count} sheets, more than --max-sheets {options.MaxSheets}, even with --allow-invalid. Nothing saved."
|
||||
: !result.CanKeep
|
||||
? "Error: result cannot be represented faithfully, even with --allow-invalid. Nothing saved."
|
||||
: "Error: invalid result discarded. Use --allow-invalid to explicitly keep its violations. Nothing saved.");
|
||||
return (false, 0);
|
||||
return rejected;
|
||||
}
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
var proposed = result.Plates.SingleOrDefault();
|
||||
var committed = proposed?.Parts.Count ?? 0;
|
||||
var committed = result.Plates.Sum(p => p.Parts.Count * p.Quantity);
|
||||
if (committed == 0)
|
||||
{
|
||||
Console.Error.WriteLine("Error: no placements returned. Existing layout and output left unchanged.");
|
||||
return (false, 0);
|
||||
return rejected;
|
||||
}
|
||||
|
||||
plate.Parts.Clear();
|
||||
plate.Size = proposed.Stock.Size;
|
||||
plate.PartSpacing = proposed.Stock.PartSpacing;
|
||||
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
|
||||
plate.Quadrant = proposed.Stock.Quadrant;
|
||||
plate.Quantity = 1;
|
||||
plate.Parts.AddRange(proposed.Parts);
|
||||
Console.WriteLine($"Engine: {engineName} — committed {committed} placements");
|
||||
return (true, committed);
|
||||
var costs = result.IsValid && options.Stock != null ? NestCostSummary.FromAccepted(result) : null;
|
||||
|
||||
// The first layout replaces the target's parts; further layouts become new sheets
|
||||
// right after it, carrying the target's cutting settings. Other plates are unchanged.
|
||||
var insertAt = nest.Plates.IndexOf(plate) + 1;
|
||||
var targetUsed = false;
|
||||
var applied = NestPipelineCommit.ApplyToPlates(result, () =>
|
||||
{
|
||||
if (!targetUsed)
|
||||
{
|
||||
targetUsed = true;
|
||||
plate.Parts.Clear();
|
||||
return plate;
|
||||
}
|
||||
|
||||
var created = new Plate(plate.Size)
|
||||
{
|
||||
GrainAngle = plate.GrainAngle,
|
||||
CuttingParameters = plate.CuttingParameters,
|
||||
};
|
||||
nest.Plates.Insert(insertAt++, created);
|
||||
return created;
|
||||
}, options.AllowInvalid, token);
|
||||
|
||||
Console.WriteLine($"Engine: {options.Engine} — committed {committed} placements on {applied.Sum(p => p.Quantity)} sheet(s)");
|
||||
if (costs != null)
|
||||
Console.WriteLine($"Cost ({costs.Basis}): gross {costs.GrossTotal:F2}, salvage credit {costs.SalvageCredit:F2}, net {costs.NetScore:F2}");
|
||||
return (true, committed, applied);
|
||||
}
|
||||
|
||||
static string ResolveFillStrategy(string engineName)
|
||||
static int CheckOverlaps(Nest nest, IReadOnlyList<Plate> plates, Options options)
|
||||
{
|
||||
try
|
||||
{
|
||||
return PlateFillService.ResolveStrategy(engineName);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
var isJobEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
|
||||
e.Name.Equals(engineName, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
isJobEngine
|
||||
? $"Error: engine '{engineName}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
|
||||
: $"Error: unknown engine '{engineName}'. Engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
|
||||
);
|
||||
Environment.Exit(1);
|
||||
throw; // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
static int CheckOverlaps(Plate plate, Options options)
|
||||
{
|
||||
if (!options.CheckOverlaps || plate.Parts.Count == 0)
|
||||
if (!options.CheckOverlaps)
|
||||
return 0;
|
||||
|
||||
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
|
||||
Console.WriteLine(
|
||||
hasOverlaps
|
||||
? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
|
||||
: "Overlap check: PASS"
|
||||
);
|
||||
var total = 0;
|
||||
foreach (var plate in plates.Where(p => p.Parts.Count > 0))
|
||||
{
|
||||
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
|
||||
var label = plates.Count > 1 ? $"Plate {nest.Plates.IndexOf(plate)}: " : "";
|
||||
Console.WriteLine(
|
||||
hasOverlaps
|
||||
? $"{label}OVERLAPS DETECTED: {overlapPts.Count} intersection points"
|
||||
: $"{label}Overlap check: PASS"
|
||||
);
|
||||
total += overlapPts.Count;
|
||||
}
|
||||
|
||||
return overlapPts.Count;
|
||||
return total;
|
||||
}
|
||||
|
||||
static void PrintResults(bool success, Plate plate, long elapsedMs)
|
||||
static void PrintResults(bool success, Nest nest, IReadOnlyList<Plate> plates, long elapsedMs)
|
||||
{
|
||||
if (plates.Count > 1)
|
||||
{
|
||||
foreach (var plate in plates)
|
||||
Console.WriteLine(
|
||||
$"Plate {nest.Plates.IndexOf(plate)}: {plate.Size.Width:F1} x {plate.Size.Length:F1} x{plate.Quantity}, {plate.Parts.Count} parts, utilization {plate.Utilization():P1}"
|
||||
);
|
||||
}
|
||||
|
||||
var area = plates.Sum(p => p.Size.Width * p.Size.Length * p.Quantity);
|
||||
var utilization = area > 0
|
||||
? plates.Sum(p => p.Utilization() * p.Size.Width * p.Size.Length * p.Quantity) / area
|
||||
: 0;
|
||||
Console.WriteLine(
|
||||
$"""
|
||||
Result: {(success ? "success" : "failed")}
|
||||
Parts placed: {plate.Parts.Count}
|
||||
Utilization: {plate.Utilization():P1}
|
||||
Parts placed: {plates.Sum(p => p.Parts.Count * p.Quantity)}
|
||||
Utilization: {utilization:P1}
|
||||
Time: {elapsedMs}ms
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plans and applies cutting (part order, contour order, entries and lead-ins) on the
|
||||
/// occupied plates in scope, all or nothing. Settings are the explicit file or the plates'
|
||||
/// own saved settings; none are invented. Unverified plans apply only with per-invocation
|
||||
/// acceptance, which is never consent to post.
|
||||
/// </summary>
|
||||
static bool PlanCutting(Nest nest, IEnumerable<Plate> plates, CuttingParameters explicitParameters,
|
||||
Options options, CancellationToken token)
|
||||
{
|
||||
var scope = plates.Where(p => p.Parts.Count > 0).ToArray();
|
||||
if (scope.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine("Error: no occupied plates to plan cutting for. Nothing saved.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var parameters = explicitParameters ?? SavedCuttingParameters(scope);
|
||||
if (parameters == null)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Error: --plan-cutting needs confirmed cutting settings: pass --cutting-params <settings.json>, or plan plates that all carry the same saved settings. Nothing saved."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine("---");
|
||||
Console.WriteLine($"Cutting settings: {(explicitParameters != null ? options.CuttingParamsFile : "saved with the plates")}");
|
||||
var batch = CuttingPlanBatch.Capture(scope, parameters, options.KeepPartOrder,
|
||||
scope.Select(p => nest.Plates.IndexOf(p)).ToArray(), token);
|
||||
var proposal = batch.Plan(new PlanningProgress(scope.Length), token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (var line in proposal.Describe(UnitsHelper.GetShortString(nest.Units),
|
||||
"Pass --cutting-params settings that select"))
|
||||
Console.WriteLine(line);
|
||||
|
||||
if (!(proposal.CanApply || options.AcceptUnverifiedPlan && proposal.CanApplyWithWarnings))
|
||||
{
|
||||
Console.Error.WriteLine(proposal.RequiresWarningAcceptance
|
||||
? "Cutting plan not applied: it is unverified. Review the warnings above; to apply it anyway, explicitly use --accept-unverified-plan for this invocation. Nothing saved."
|
||||
: "Cutting plan not applied: every plate must be ready. Nothing saved.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var commit = proposal.Apply(options.AcceptUnverifiedPlan, token);
|
||||
if (commit.Status != CuttingCommitStatus.Applied)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: cutting plan not applied ({commit.Status}): {commit.Message} Nothing saved.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Cutting plan applied to {scope.Length} plate(s).");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The plates' saved settings when every plate carries the same ones; otherwise null.</summary>
|
||||
static CuttingParameters SavedCuttingParameters(IReadOnlyList<Plate> plates)
|
||||
{
|
||||
var first = plates[0].CuttingParameters;
|
||||
if (first == null)
|
||||
return null;
|
||||
var json = CuttingParametersSerializer.Serialize(first);
|
||||
return plates.All(p => p.CuttingParameters != null
|
||||
&& CuttingParametersSerializer.Serialize(p.CuttingParameters) == json) ? first : null;
|
||||
}
|
||||
|
||||
/// <summary>Reports synchronously, so phase lines print in order with the summary.</summary>
|
||||
sealed class PlanningProgress(int plateCount) : IProgress<CuttingPlanProgress>
|
||||
{
|
||||
public void Report(CuttingPlanProgress value) => Console.WriteLine(
|
||||
(plateCount > 1 ? $"Planning plate {value.PlateNumber}: " : "Planning: ") + value.Phase switch
|
||||
{
|
||||
CuttingPlanPhase.Reordering => "finding a part order...",
|
||||
CuttingPlanPhase.KeepingOrder => "keeping the current order...",
|
||||
CuttingPlanPhase.SafestRoute => "finding the safest route...",
|
||||
CuttingPlanPhase.CheckingOverlap => "checking for overlaps...",
|
||||
_ => value.Phase + "...",
|
||||
});
|
||||
}
|
||||
|
||||
static void Save(Nest nest, Options options)
|
||||
{
|
||||
if (options.NoSave)
|
||||
@@ -599,7 +837,7 @@ static class NestConsole
|
||||
|
||||
var outputFile = NestOutputPath(options);
|
||||
|
||||
new NestWriter(nest).Write(outputFile);
|
||||
new NestWriter(nest) { BestFitStore = BestFitCacheStore.Shared }.Write(outputFile);
|
||||
Console.WriteLine($"Saved: {outputFile}");
|
||||
}
|
||||
|
||||
@@ -760,11 +998,24 @@ static class NestConsole
|
||||
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
|
||||
--output <path> Output nest file path (default: <input>-result.nest)
|
||||
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
|
||||
--autonest Validated whole-job nesting onto one sheet; replaces only after acceptance
|
||||
--autonest Validated whole-job nesting (one sheet unless --max-sheets); replaces only after acceptance
|
||||
--max-sheets <n> With --autonest: physical sheets the job may use (default: 1); extra
|
||||
layouts are inserted as new plates after the target plate
|
||||
--stock <WxL[:cost],...> With --autonest: sheet sizes to offer, each unlimited (default: the
|
||||
target plate's size); spacing, quadrant and clamps follow the target
|
||||
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
|
||||
--engine <name> With --autonest: jobs engine (default: Default; also Irregular, Rectangles, Fill, ...).
|
||||
Without --autonest: fill strategy (Fill (default), Strip, Vertical Remnant, Horizontal Remnant)
|
||||
--keep-parts Don't clear existing parts before filling
|
||||
--no-fill Keep the loaded .nest as it is (no fill); plan, save or post it
|
||||
--plan-cutting After filling (or loading with --no-fill), plan cutting (part order,
|
||||
contour order, entries, lead-ins); applied only when every plate is
|
||||
ready or --accept-unverified-plan is given (else exit 2)
|
||||
--cutting-params <path> Cutting settings JSON for --plan-cutting (default: the plates' own
|
||||
saved settings, which must agree; never invented)
|
||||
--keep-part-order With --plan-cutting: keep the current whole-part order
|
||||
--all-plates With --plan-cutting: plan every occupied plate, not just the target
|
||||
--accept-unverified-plan Explicitly apply an unverified cutting plan for this invocation
|
||||
--check-overlaps Run overlap detection after fill (exit code 1 if found)
|
||||
--no-save Skip saving output file
|
||||
--post <name> Run a post processor after nesting
|
||||
@@ -791,6 +1042,8 @@ static class NestConsole
|
||||
public bool KeepParts;
|
||||
public bool AutoNest;
|
||||
public bool AllowInvalid;
|
||||
public int MaxSheets = 1;
|
||||
public List<PlateOption> Stock;
|
||||
public string Engine = "Default";
|
||||
public string TemplateFile;
|
||||
public string PostName;
|
||||
@@ -798,6 +1051,12 @@ static class NestConsole
|
||||
public string PostOutput;
|
||||
public string PostsDir;
|
||||
public bool ListPosts;
|
||||
public bool NoFill;
|
||||
public bool PlanCutting;
|
||||
public string CuttingParamsFile;
|
||||
public bool KeepPartOrder;
|
||||
public bool AcceptUnverifiedPlan;
|
||||
public bool AllPlates;
|
||||
public double? RepairBendsMillimeters;
|
||||
public BendRepairUnits CadUnits;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Existing cutting work on a set of plates: applied lead-ins and cut-offs.</summary>
|
||||
/// <param name="LeadInParts">Real parts carrying applied lead-ins (locked ones included).</param>
|
||||
/// <param name="LockedLeadInParts">Of those, parts whose lead-ins are locked.</param>
|
||||
/// <param name="CutOffs">Cut-off definitions.</param>
|
||||
/// <param name="PlateNumbers">Display numbers of the plates holding any of it, ascending.</param>
|
||||
public sealed record AppliedCuttingWorkSummary(int LeadInParts, int LockedLeadInParts, int CutOffs,
|
||||
IReadOnlyList<int> PlateNumbers)
|
||||
{
|
||||
public bool HasExistingWork => LeadInParts > 0 || CutOffs > 0;
|
||||
|
||||
/// <summary>Operator text for the cutting planner's warning banner; empty when nothing exists.</summary>
|
||||
public string Describe()
|
||||
{
|
||||
if (!HasExistingWork)
|
||||
return string.Empty;
|
||||
var found = new List<string>();
|
||||
if (LeadInParts > 0)
|
||||
found.Add($"lead-ins on {Count(LeadInParts, "part")}"
|
||||
+ (LockedLeadInParts > 0 ? $" ({LockedLeadInParts} locked)" : string.Empty));
|
||||
if (CutOffs > 0)
|
||||
found.Add(Count(CutOffs, "cut-off"));
|
||||
var plates = PlateNumbers.Count == 1 ? "plate " : "plates ";
|
||||
var kept = new List<string>();
|
||||
if (LockedLeadInParts > 0)
|
||||
kept.Add("locked lead-ins");
|
||||
if (CutOffs > 0)
|
||||
kept.Add("existing cut-offs");
|
||||
return $"ALREADY APPLIED: {string.Join(" and ", found)} on {plates}{string.Join(", ", PlateNumbers)}."
|
||||
+ (LeadInParts > LockedLeadInParts ? " Planning recalculates unlocked lead-ins." : string.Empty)
|
||||
+ (kept.Count > 0 ? $" Planning keeps {string.Join(" and ", kept)} unchanged." : string.Empty)
|
||||
+ " Clear them to recalculate everything from scratch.";
|
||||
}
|
||||
|
||||
private static string Count(int value, string noun) => $"{value} {noun}{(value == 1 ? string.Empty : "s")}";
|
||||
}
|
||||
|
||||
/// <summary>Finds and clears applied lead-ins and cut-offs so a plan can recalculate them.</summary>
|
||||
public static class AppliedCuttingWork
|
||||
{
|
||||
/// <summary>
|
||||
/// Counts applied lead-ins on real parts and cut-off definitions. Definitions in
|
||||
/// <paramref name="ignoredCutOffs"/> (for example ones staged by the caller) are not counted.
|
||||
/// </summary>
|
||||
public static AppliedCuttingWorkSummary Summarize(IReadOnlyList<Plate> plates,
|
||||
IReadOnlyList<int> plateNumbers = null, IEnumerable<CutOff> ignoredCutOffs = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plates);
|
||||
if (plateNumbers != null && plateNumbers.Count != plates.Count)
|
||||
throw new ArgumentException("Give one display number per plate.", nameof(plateNumbers));
|
||||
var ignored = new HashSet<CutOff>(ignoredCutOffs ?? [], ReferenceEqualityComparer.Instance);
|
||||
int leads = 0, locked = 0, cutOffs = 0;
|
||||
var numbers = new SortedSet<int>();
|
||||
for (var index = 0; index < plates.Count; index++)
|
||||
{
|
||||
var plate = plates[index] ?? throw new ArgumentException("Plates must not be null.", nameof(plates));
|
||||
var plateLeads = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray();
|
||||
var plateCutOffs = plate.CutOffs.Count(c => !ignored.Contains(c));
|
||||
leads += plateLeads.Length;
|
||||
locked += plateLeads.Count(p => p.LeadInsLocked);
|
||||
cutOffs += plateCutOffs;
|
||||
if (plateLeads.Length > 0 || plateCutOffs > 0)
|
||||
numbers.Add(plateNumbers?[index] ?? index + 1);
|
||||
}
|
||||
return new(leads, locked, cutOffs, numbers.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes every applied lead-in (locked ones too) from real parts and every cut-off with
|
||||
/// its cut-off parts, and forgets each plate's recorded cutting parameters. Part poses,
|
||||
/// order and quantities are unchanged. Returns what was cleared.
|
||||
/// </summary>
|
||||
public static AppliedCuttingWorkSummary Clear(IReadOnlyList<Plate> plates, IReadOnlyList<int> plateNumbers = null)
|
||||
{
|
||||
var cleared = Summarize(plates, plateNumbers);
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
foreach (var part in plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray())
|
||||
part.RemoveLeadIns();
|
||||
plate.CutOffs.Clear();
|
||||
for (var index = plate.Parts.Count - 1; index >= 0; index--)
|
||||
if (plate.Parts[index].BaseDrawing.IsCutOff)
|
||||
plate.Parts.RemoveAt(index);
|
||||
plate.CuttingParameters = null;
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
}
|
||||
@@ -42,11 +42,19 @@ public static class AutomaticCutOffBatch
|
||||
/// Returned plans describe the fresh proposal, not preview parts to accept into a plate.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<AutomaticCutOffPlan> Apply(IReadOnlyList<Plate> plates,
|
||||
AutomaticCutOffOptions options, CutOffSettings settings) => Stage(plates, options, settings).Plans;
|
||||
|
||||
/// <summary>
|
||||
/// Applies like <see cref="Apply"/>, but keeps the recovery state so the caller can take the
|
||||
/// new cut-offs back off again (<see cref="AutomaticCutOffStaging.Revert"/>) when the work they
|
||||
/// were added for is abandoned. A blocked batch changes nothing and has nothing to revert.
|
||||
/// </summary>
|
||||
public static AutomaticCutOffStaging Stage(IReadOnlyList<Plate> plates,
|
||||
AutomaticCutOffOptions options, CutOffSettings settings)
|
||||
{
|
||||
var plans = Create(plates, options, settings);
|
||||
if (plans.Any(plan => plan.HasBlockingDiagnostics))
|
||||
return plans;
|
||||
return new AutomaticCutOffStaging(plans, []);
|
||||
|
||||
var restoreActions = new List<(int PlateNumber, Action Restore)>();
|
||||
try
|
||||
@@ -66,21 +74,7 @@ public static class AutomaticCutOffBatch
|
||||
}
|
||||
catch (Exception applyError)
|
||||
{
|
||||
var rollbackErrors = new List<Exception>();
|
||||
for (var index = restoreActions.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var saved = restoreActions[index];
|
||||
try
|
||||
{
|
||||
saved.Restore();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
// A broken observer on one plate must not prevent recovery of the others.
|
||||
rollbackErrors.Add(new InvalidOperationException(
|
||||
$"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError));
|
||||
}
|
||||
}
|
||||
var rollbackErrors = RestoreAll(restoreActions);
|
||||
if (rollbackErrors.Count > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: "
|
||||
@@ -91,7 +85,27 @@ public static class AutomaticCutOffBatch
|
||||
throw new InvalidOperationException(
|
||||
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError);
|
||||
}
|
||||
return plans;
|
||||
return new AutomaticCutOffStaging(plans, restoreActions);
|
||||
}
|
||||
|
||||
internal static List<Exception> RestoreAll(IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions)
|
||||
{
|
||||
var errors = new List<Exception>();
|
||||
for (var index = restoreActions.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var saved = restoreActions[index];
|
||||
try
|
||||
{
|
||||
saved.Restore();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
// A broken observer on one plate must not prevent recovery of the others.
|
||||
errors.Add(new InvalidOperationException(
|
||||
$"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError));
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static Action CaptureRestore(Plate plate, AutomaticCutOffPlan plan)
|
||||
@@ -117,3 +131,52 @@ public static class AutomaticCutOffBatch
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cut-offs added by <see cref="AutomaticCutOffBatch.Stage"/> that are still provisional: either
|
||||
/// <see cref="Keep"/> them once the work they belong to is committed, or <see cref="Revert"/> them.
|
||||
/// Owner-thread only, like the plates it changed.
|
||||
/// </summary>
|
||||
public sealed class AutomaticCutOffStaging
|
||||
{
|
||||
private IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions;
|
||||
|
||||
internal AutomaticCutOffStaging(IReadOnlyList<AutomaticCutOffPlan> plans,
|
||||
IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions)
|
||||
{
|
||||
Plans = plans;
|
||||
this.restoreActions = restoreActions;
|
||||
Definitions = plans.SelectMany(plan => plan.HasBlockingDiagnostics ? [] : plan.Definitions).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>The fresh plans, one per plate in the order given.</summary>
|
||||
public IReadOnlyList<AutomaticCutOffPlan> Plans { get; }
|
||||
|
||||
/// <summary>True when any plate blocked the batch; then nothing was added.</summary>
|
||||
public bool IsBlocked => Plans.Any(plan => plan.HasBlockingDiagnostics);
|
||||
|
||||
/// <summary>The definitions this staging added (empty when blocked or nothing was needed).</summary>
|
||||
public IReadOnlyList<CutOff> Definitions { get; }
|
||||
|
||||
/// <summary>True while the added cut-offs can still be reverted.</summary>
|
||||
public bool CanRevert => restoreActions.Count > 0;
|
||||
|
||||
/// <summary>Makes the added cut-offs permanent; a later <see cref="Revert"/> does nothing.</summary>
|
||||
public void Keep() => restoreActions = [];
|
||||
|
||||
/// <summary>
|
||||
/// Removes the added cut-offs and restores each touched plate's previous cut-off programs and
|
||||
/// sequence. Real parts are not touched. Runs once; throws if any plate could not be restored.
|
||||
/// </summary>
|
||||
public void Revert()
|
||||
{
|
||||
var actions = restoreActions;
|
||||
restoreActions = [];
|
||||
var errors = AutomaticCutOffBatch.RestoreAll(actions);
|
||||
if (errors.Count > 0)
|
||||
throw new InvalidOperationException(
|
||||
"Removing the added cut-offs failed: " + string.Join("; ", errors.Select(e => e.Message))
|
||||
+ "\nThe nest may be incomplete; review it before saving or cutting.",
|
||||
new AggregateException(errors));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,20 @@ public sealed class AutomaticCutOffOptions
|
||||
/// The desktop default is 12 inches (304.8 mm).
|
||||
/// </summary>
|
||||
public double MinimumTailLength { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum strip length in model units. Null or equal to Spacing preserves the fixed grid.
|
||||
/// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing.
|
||||
/// </summary>
|
||||
public double? MinimumStripLength { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to propose a full-length horizontal separator that keeps the unused band beyond the
|
||||
/// parts along Y (away from the origin) when that band is a larger piece than the X tail.
|
||||
/// The band must be at least MinimumTailLength wide. False preserves vertical-only proposals.
|
||||
/// An existing full-length horizontal cut-off clear of the parts is honored either way.
|
||||
/// </summary>
|
||||
public bool KeepHorizontalRemnant { get; set; }
|
||||
}
|
||||
|
||||
public enum AutomaticCutOffDiagnosticCode
|
||||
@@ -29,8 +43,12 @@ public enum AutomaticCutOffDiagnosticCode
|
||||
LimitedCutOffConflict,
|
||||
EmptyCut,
|
||||
SegmentedCut,
|
||||
ClampBlockedCut,
|
||||
NoSafeTailSeparator,
|
||||
TailBelowMinimum,
|
||||
ShiftedCut,
|
||||
HorizontalRemnant,
|
||||
NoSafeHorizontalSeparator,
|
||||
}
|
||||
|
||||
public sealed record AutomaticCutOffDiagnostic(
|
||||
@@ -69,6 +87,21 @@ public sealed class AutomaticCutOffPlan
|
||||
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
|
||||
public bool HasSeparatedTail => TailSeparatorX.HasValue;
|
||||
|
||||
/// <summary>
|
||||
/// Signed Y coordinate of the new or existing full-length horizontal separator the vertical
|
||||
/// lines stop at, keeping the band beyond it; null when the lines cross the full sheet width.
|
||||
/// </summary>
|
||||
public double? HorizontalSeparatorY { get; internal set; }
|
||||
|
||||
/// <summary>Width along Y of the band kept beyond HorizontalSeparatorY, otherwise zero.</summary>
|
||||
public double HorizontalRemnantWidth { get; internal set; }
|
||||
|
||||
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
|
||||
public double StripCutLength { get; internal set; }
|
||||
|
||||
/// <summary>Length of the nominal new strip lines in the same gaps. Equal to StripCutLength on the fixed-grid path.</summary>
|
||||
public double FixedGridCutLength { get; internal set; }
|
||||
|
||||
public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking);
|
||||
}
|
||||
|
||||
@@ -76,8 +109,18 @@ public sealed class AutomaticCutOffPlan
|
||||
public static class AutomaticCutOffPlanner
|
||||
{
|
||||
public const double GeometryTolerance = Tolerance.Epsilon;
|
||||
|
||||
/// <summary>
|
||||
/// How far a display perimeter (a supplied blank's never-cut outline) may extend past the
|
||||
/// physical sheet, in model units. Imported blanks equal their sheet but carry the source CAM's
|
||||
/// rounding (PEP: a 0.000006 outline skew plus placements rounded to 0.001), a few hundred-
|
||||
/// thousandths of an inch. Nothing cuts that outline, so it is accepted; anything cut, or a
|
||||
/// larger overhang that signals a real placement error, is still refused.
|
||||
/// </summary>
|
||||
public const double DisplayPerimeterSheetAllowance = 1e-4;
|
||||
public const double MinimumSpacing = 2 * GeometryTolerance;
|
||||
public const int MaximumCandidateCount = 10000;
|
||||
public const int MaximumShiftCandidateCount = 20000;
|
||||
|
||||
/// <summary>
|
||||
/// Plans without changing the plate, its parts, or existing cut-off definitions/programs.
|
||||
@@ -93,12 +136,16 @@ public static class AutomaticCutOffPlanner
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
ValidateInputs(plate, options, settings);
|
||||
// Stored zones use the sheet's lower-left frame; cut positions are signed.
|
||||
var clamps = ClampZoneFrame.ToPlacement(plate);
|
||||
var bounds = plate.BoundingBox(false);
|
||||
Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel),
|
||||
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
|
||||
|
||||
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
|
||||
var ySign = plate.Quadrant is 3 or 4 ? -1 : 1;
|
||||
var occupied = 0.0;
|
||||
var occupiedY = 0.0;
|
||||
var hasParts = false;
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
@@ -106,7 +153,9 @@ public static class AutomaticCutOffPlanner
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
occupied = System.Math.Max(occupied, ValidatePart(part, bounds, sign));
|
||||
var (partX, partY) = ValidatePart(part, bounds, sign, ySign);
|
||||
occupied = System.Math.Max(occupied, partX);
|
||||
occupiedY = System.Math.Max(occupiedY, partY);
|
||||
hasParts = true;
|
||||
}
|
||||
if (!hasParts)
|
||||
@@ -114,12 +163,62 @@ public static class AutomaticCutOffPlanner
|
||||
|
||||
// Use distances from the coordinate origin, not the sheet's lower-left corner.
|
||||
var length = plate.Size.Length;
|
||||
var width = plate.Size.Width;
|
||||
occupied = System.Math.Min(occupied, length);
|
||||
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance)
|
||||
+ GeometryTolerance;
|
||||
occupiedY = System.Math.Min(occupiedY, width);
|
||||
var margin = System.Math.Max(plate.PartSpacing, settings.PartClearance) + GeometryTolerance;
|
||||
var separator = occupied + margin;
|
||||
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
|
||||
var usedSpan = hasTailCandidate ? separator : length;
|
||||
var candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
|
||||
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
||||
|
||||
// A band beyond the parts along Y is kept by a full-length horizontal separator; the
|
||||
// vertical lines then stop at it instead of slicing the band. A user's line clear of the
|
||||
// parts is honored; a new one is proposed only when its band beats the X tail.
|
||||
var horizontalDistance = plate.CutOffs
|
||||
.Where(c => c?.Axis == CutOffAxis.Horizontal && Finite(c.Position) && FullLengthLimits(c, bounds, settings))
|
||||
.Select(c => ySign * c.Position.Y)
|
||||
.Where(distance => distance > occupiedY + GeometryTolerance && distance < width - GeometryTolerance
|
||||
&& width - distance >= options.MinimumTailLength)
|
||||
.Select(distance => (double?)distance)
|
||||
.Min();
|
||||
var horizontalExists = horizontalDistance.HasValue;
|
||||
if (!horizontalExists && options.KeepHorizontalRemnant)
|
||||
{
|
||||
var proposed = occupiedY + margin;
|
||||
var tailArea = hasTailCandidate && length - separator >= options.MinimumTailLength
|
||||
? (length - separator) * width : 0;
|
||||
if (double.IsFinite(proposed) && proposed < width - GeometryTolerance
|
||||
&& width - proposed >= options.MinimumTailLength && (width - proposed) * length > tailArea)
|
||||
horizontalDistance = proposed;
|
||||
}
|
||||
if (horizontalDistance.HasValue && !horizontalExists)
|
||||
{
|
||||
var y = ySign * horizontalDistance.Value;
|
||||
var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y));
|
||||
var clamp = clamps.FirstOrDefault(zone =>
|
||||
y >= zone.Bounds.Bottom - plate.EdgeSpacing.Bottom && y <= zone.Bounds.Top + plate.EdgeSpacing.Bottom);
|
||||
if (limited || clamp != null)
|
||||
{
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.NoSafeHorizontalSeparator,
|
||||
(clamp != null ? $"A horizontal separator at Y = {y:G8} would cross clamp '{clamp.Id}'"
|
||||
: $"A same-line manual cut at Y = {y:G8} has different limits")
|
||||
+ "; vertical lines cross the full sheet width instead."));
|
||||
horizontalDistance = null;
|
||||
}
|
||||
}
|
||||
|
||||
// A tail too short to keep stays attached to the skeleton, so the strip grid must
|
||||
// continue to the sheet end; stopping at the skipped separator would leave a final
|
||||
// strip longer than the spacing. Beside a kept horizontal band the tail is a corner
|
||||
// piece, which must also be wide enough to keep.
|
||||
bool KeepsTail(double? band) => hasTailCandidate && length - separator >= options.MinimumTailLength
|
||||
&& (band == null || band.Value >= options.MinimumTailLength);
|
||||
var keepsTail = KeepsTail(horizontalDistance);
|
||||
var usedSpan = keepsTail ? separator : length;
|
||||
// Bound the count for either outcome: the horizontal separator is verified only below.
|
||||
var candidateCount = System.Math.Ceiling(System.Math.Max(usedSpan, KeepsTail(null) ? separator : length)
|
||||
/ options.Spacing);
|
||||
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
|
||||
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
|
||||
|
||||
@@ -127,36 +226,126 @@ public static class AutomaticCutOffPlanner
|
||||
ValidateExisting(plate, bounds, settings);
|
||||
var cache = Plate.BuildPerimeterCache(plate);
|
||||
var definitions = new List<CutOff>();
|
||||
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
||||
CutOff horizontalSeparator = null;
|
||||
if (horizontalDistance.HasValue && !horizontalExists)
|
||||
{
|
||||
horizontalSeparator = new CutOff(new Vector(0, ySign * horizontalDistance.Value), CutOffAxis.Horizontal);
|
||||
horizontalSeparator.Regenerate(plate, settings, cache);
|
||||
if (!HasUsableSegments(horizontalSeparator.Drawing.Program, false) ||
|
||||
!IsFullSpanProgram(horizontalSeparator.Drawing.Program, false, bounds.Left, bounds.Right))
|
||||
{
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.NoSafeHorizontalSeparator,
|
||||
"No full-length horizontal separator survives the current settings; "
|
||||
+ "vertical lines cross the full sheet width instead."));
|
||||
horizontalSeparator = null;
|
||||
horizontalDistance = null;
|
||||
keepsTail = KeepsTail(null);
|
||||
usedSpan = keepsTail ? separator : length;
|
||||
candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
|
||||
}
|
||||
}
|
||||
// Vertical lines run from the origin-side edge up to a kept band's separator.
|
||||
var horizontalY = horizontalDistance.HasValue ? ySign * horizontalDistance.Value : (double?)null;
|
||||
var startLimit = ySign < 0 ? horizontalY : null;
|
||||
var endLimit = ySign > 0 ? horizontalY : null;
|
||||
var spanLow = startLimit ?? bounds.Bottom;
|
||||
var spanHigh = endLimit ?? bounds.Top;
|
||||
var separated = false;
|
||||
var separatorX = (double?)null;
|
||||
var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing;
|
||||
var stripCutLength = 0.0;
|
||||
var fixedGridCutLength = 0.0;
|
||||
var measurements = new Dictionary<double, (double Length, int SegmentCount)>();
|
||||
var candidates = new HashSet<double>();
|
||||
|
||||
// Multiplication by an integer avoids drift from repeated floating-point addition.
|
||||
for (var index = 1; index <= (int)candidateCount; index++)
|
||||
if (shifted)
|
||||
{
|
||||
var distance = index * options.Spacing;
|
||||
if (distance >= usedSpan - GeometryTolerance)
|
||||
break;
|
||||
AddCandidate(sign * distance, false);
|
||||
// Full-span definitions fix the strip boundaries, even when their emitted
|
||||
// programs are segmented. Partial cuts remain obstacles, not boundaries.
|
||||
var boundaries = plate.CutOffs
|
||||
.Where(c => c.Axis == CutOffAxis.Vertical && Spans(c))
|
||||
.Select(c => sign * c.Position.X)
|
||||
.Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance)
|
||||
.Distinct().OrderBy(distance => distance).Append(usedSpan);
|
||||
var gaps = new List<(double Start, double End, int Count)>();
|
||||
var start = 0.0;
|
||||
var nominalCount = 0;
|
||||
foreach (var end in boundaries)
|
||||
{
|
||||
var count = 0;
|
||||
for (var index = 1; index <= (int)candidateCount; index++)
|
||||
{
|
||||
if (start + index * options.Spacing >= end - GeometryTolerance)
|
||||
break;
|
||||
count++;
|
||||
}
|
||||
gaps.Add((start, end, count));
|
||||
nominalCount += count;
|
||||
start = end;
|
||||
}
|
||||
// The origin-based lattice alone fits within 20,000 positions, but its
|
||||
// off-lattice nominal lines need reserved slots too. Budget both before
|
||||
// constructing any row so a valid, dense grid is never refused halfway.
|
||||
Require(nominalCount < MaximumShiftCandidateCount,
|
||||
"Too many nominal strip lines for the shift search.", nameof(options));
|
||||
var step = System.Math.Max(options.Spacing / 700,
|
||||
usedSpan / (MaximumShiftCandidateCount - nominalCount));
|
||||
foreach (var gap in gaps)
|
||||
AddShiftedGap(gap.Start, gap.End, gap.Count, step);
|
||||
}
|
||||
if (hasTailCandidate)
|
||||
else
|
||||
{
|
||||
if (length - separator >= options.MinimumTailLength)
|
||||
AddCandidate(sign * separator, true);
|
||||
else
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
|
||||
"The proposed tail is shorter than the minimum tail length; the final separator was skipped. "
|
||||
+ "Other automatic lines and existing cut-offs are unchanged. No retained tail is claimed.",
|
||||
X: sign * separator));
|
||||
// Keep the legacy loop and certification unchanged when shifting is disabled.
|
||||
for (var index = 1; index <= (int)candidateCount; index++)
|
||||
{
|
||||
var distance = index * options.Spacing;
|
||||
if (distance >= usedSpan - GeometryTolerance)
|
||||
break;
|
||||
AddCandidate(sign * distance, false);
|
||||
}
|
||||
}
|
||||
if (keepsTail)
|
||||
AddCandidate(sign * separator, true);
|
||||
else if (hasTailCandidate)
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
|
||||
"The proposed tail is shorter than the minimum tail length; the final separator was skipped "
|
||||
+ "and strip lines continue to the sheet end. Existing cut-offs are unchanged. No retained tail is claimed.",
|
||||
X: sign * separator));
|
||||
|
||||
if (horizontalSeparator != null)
|
||||
definitions.Add(horizontalSeparator);
|
||||
if (horizontalY.HasValue)
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.HorizontalRemnant,
|
||||
$"Vertical lines stop at the {(horizontalExists ? "existing" : "new")} horizontal separator at "
|
||||
+ $"Y = {horizontalY.Value:G8}, keeping a {width - horizontalDistance.Value:G8}-wide band "
|
||||
+ "along the sheet length."));
|
||||
|
||||
if (diagnostics.Any(d => d.IsBlocking))
|
||||
{
|
||||
// A partial proposal must not accidentally be accepted around a manual conflict.
|
||||
definitions.Clear();
|
||||
stripCutLength = 0;
|
||||
separated = false;
|
||||
separatorX = null;
|
||||
horizontalY = null;
|
||||
}
|
||||
// The legacy placement loop stays untouched. Its detached programs are already
|
||||
// regenerated, so reporting their actual new strip cutting needs no new search.
|
||||
if (!shifted)
|
||||
fixedGridCutLength = stripCutLength;
|
||||
|
||||
// Lines are planned and measured without the head-locked overcut: it only extends the
|
||||
// segment reaching the far edge past the sheet, where there is nothing to separate.
|
||||
if (settings.NewCutOffHeadLockOvercut > 0)
|
||||
{
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
definition.HeadLockOvercut = settings.NewCutOffHeadLockOvercut;
|
||||
definition.Regenerate(plate, settings, cache);
|
||||
}
|
||||
}
|
||||
|
||||
return new AutomaticCutOffPlan
|
||||
@@ -168,13 +357,88 @@ public static class AutomaticCutOffPlanner
|
||||
UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length,
|
||||
TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0,
|
||||
TailSeparatorX = separatorX,
|
||||
StripCutLength = stripCutLength,
|
||||
FixedGridCutLength = fixedGridCutLength,
|
||||
HorizontalSeparatorY = horizontalY,
|
||||
HorizontalRemnantWidth = horizontalY.HasValue ? width - horizontalDistance.Value : 0,
|
||||
};
|
||||
|
||||
CutOff NewVertical(double x) => new(new Vector(x, 0), CutOffAxis.Vertical)
|
||||
{
|
||||
StartLimit = startLimit,
|
||||
EndLimit = endLimit,
|
||||
};
|
||||
|
||||
// A full-span line, or one with the limits this plan gives its own vertical lines.
|
||||
bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) ||
|
||||
horizontalY.HasValue &&
|
||||
Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) &&
|
||||
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel));
|
||||
|
||||
void AddShiftedGap(double start, double end, int count, double step)
|
||||
{
|
||||
var nominal = new List<double>(count);
|
||||
for (var index = 1; index <= count; index++)
|
||||
{
|
||||
var distance = start + index * options.Spacing;
|
||||
nominal.Add(distance);
|
||||
fixedGridCutLength += Measure(distance).Length;
|
||||
}
|
||||
if (count == 0)
|
||||
return;
|
||||
var positions = AutomaticCutOffStripSearch.FindPositions(start, end, count,
|
||||
options.Spacing, options.MinimumStripLength.Value, step, Allowed, Measure) ?? nominal.ToArray();
|
||||
for (var index = 0; index < positions.Length; index++)
|
||||
{
|
||||
var distance = positions[index];
|
||||
AddCandidate(sign * distance, false);
|
||||
if (!Near(distance, nominal[index]))
|
||||
{
|
||||
var difference = Measure(nominal[index]).Length - Measure(distance).Length;
|
||||
var comparison = difference > 0 ? $"{difference:G8} model units less" :
|
||||
difference < 0 ? $"{-difference:G8} model units more" : "the same length";
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(AutomaticCutOffDiagnosticCode.ShiftedCut,
|
||||
$"Moved from {nominal[index]:G8} to {distance:G8} model units; "
|
||||
+ $"this line cuts {comparison}.", X: sign * distance));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Allowed(double distance)
|
||||
{
|
||||
RegisterCandidate(distance);
|
||||
// Use the certification path's exact Near predicate, including its ULP
|
||||
// allowance, so a selected line cannot rediscover a limited-cut conflict.
|
||||
var x = sign * distance;
|
||||
return !plate.CutOffs.Any(c => c.Axis == CutOffAxis.Vertical && Near(c.Position.X, x)) &&
|
||||
!clamps.Any(zone => CrossesClamp(zone, x));
|
||||
}
|
||||
|
||||
void RegisterCandidate(double distance)
|
||||
{
|
||||
if (candidates.Add(distance))
|
||||
Require(candidates.Count <= MaximumShiftCandidateCount,
|
||||
$"Shift search would generate more than {MaximumShiftCandidateCount} distinct positions. "
|
||||
+ "Increase spacing or the minimum strip length, or use the fixed grid.", nameof(options));
|
||||
}
|
||||
|
||||
(double Length, int SegmentCount) Measure(double distance)
|
||||
{
|
||||
RegisterCandidate(distance);
|
||||
if (!measurements.TryGetValue(distance, out var measured))
|
||||
{
|
||||
measured = NewVertical(sign * distance).MeasureSegments(plate, settings, cache);
|
||||
measurements.Add(distance, measured);
|
||||
}
|
||||
return measured;
|
||||
}
|
||||
|
||||
void AddCandidate(double x, bool isSeparator)
|
||||
{
|
||||
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
|
||||
Near(c.Position.X, x)).ToList();
|
||||
if (matches.Any(c => !FullSpanLimits(c, bounds, settings)))
|
||||
|
||||
if (matches.Any(c => !Spans(c)))
|
||||
{
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
|
||||
@@ -185,11 +449,29 @@ public static class AutomaticCutOffPlanner
|
||||
return;
|
||||
}
|
||||
|
||||
// A full-width cut crosses every active front-edge clamp at its X. Test the
|
||||
// proposed X and every equivalent existing definition, because a tolerance-close
|
||||
// existing line is regenerated and certified at its own X. Keep the whole
|
||||
// footprint when it hangs off the sheet. A same-line manual limit conflict above
|
||||
// still blocks the whole plan, clamp or not.
|
||||
var blocked = clamps.FirstOrDefault(zone =>
|
||||
matches.Select(c => c.Position.X).Prepend(x).Any(position => CrossesClamp(zone, position)));
|
||||
if (blocked != null)
|
||||
{
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.ClampBlockedCut,
|
||||
$"The proposed cut crosses clamp '{blocked.Id}' (including bottom-edge clearance); it was skipped.",
|
||||
X: x));
|
||||
if (isSeparator)
|
||||
WarnNoSeparator(x);
|
||||
return;
|
||||
}
|
||||
|
||||
// Even a duplicate must be regenerated detached with CURRENT settings. Its live
|
||||
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
|
||||
var existing = matches.FirstOrDefault();
|
||||
var candidate = existing == null
|
||||
? new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
|
||||
? NewVertical(x)
|
||||
: new CutOff(existing.Position, existing.Axis)
|
||||
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
|
||||
candidate.Regenerate(plate, settings, cache);
|
||||
@@ -202,12 +484,12 @@ public static class AutomaticCutOffPlanner
|
||||
if (!usable)
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.EmptyCut,
|
||||
"The line has no usable cut segments after part clearance and minimum-length filtering; it is not a partition.",
|
||||
"The line has no usable cut segments after part clearance and minimum-length filtering.",
|
||||
X: x));
|
||||
|
||||
if (isSeparator)
|
||||
{
|
||||
if (!usable || !IsFullSpanProgram(program, bounds))
|
||||
if (!usable || !IsFullSpanProgram(program, true, spanLow, spanHigh))
|
||||
{
|
||||
WarnNoSeparator(x);
|
||||
return;
|
||||
@@ -215,19 +497,36 @@ public static class AutomaticCutOffPlanner
|
||||
separated = true;
|
||||
separatorX = candidate.Position.X;
|
||||
}
|
||||
else if (usable && !IsFullSpanProgram(program, bounds))
|
||||
else if (usable && !IsFullSpanProgram(program, true, spanLow, spanHigh))
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.SegmentedCut,
|
||||
"Part clearance or segment filtering interrupts this line; nominal spacing does not guarantee disconnected scrap.",
|
||||
"Part clearance or segment filtering interrupts this line; review scrap separation before relying on it.",
|
||||
X: x));
|
||||
|
||||
if (usable && existing == null)
|
||||
{
|
||||
definitions.Add(candidate);
|
||||
if (!isSeparator)
|
||||
{
|
||||
if (shifted)
|
||||
stripCutLength += Measure(System.Math.Abs(x)).Length;
|
||||
else
|
||||
for (var index = 0; index < program.Codes.Count; index += 2)
|
||||
stripCutLength += System.Math.Abs(
|
||||
((LinearMove)program.Codes[index + 1]).EndPoint.Y -
|
||||
((RapidMove)program.Codes[index]).EndPoint.Y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Share the exact placement-frame predicate with candidate exclusion and final
|
||||
// certification. Infeasible gaps still use nominal lines through AddCandidate.
|
||||
bool CrossesClamp(ClampZone zone, double x) =>
|
||||
x >= zone.Bounds.Left - plate.EdgeSpacing.Bottom && x <= zone.Bounds.Right + plate.EdgeSpacing.Bottom;
|
||||
|
||||
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
|
||||
"No safe full-width tail separator survives the current settings. No separated tail is claimed; review manually.",
|
||||
"No safe full-width tail separator survives the current settings. Review the tail manually.",
|
||||
X: x));
|
||||
}
|
||||
|
||||
@@ -235,6 +534,10 @@ public static class AutomaticCutOffPlanner
|
||||
{
|
||||
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
|
||||
$"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options));
|
||||
Require(!options.MinimumStripLength.HasValue ||
|
||||
(double.IsFinite(options.MinimumStripLength.Value) && options.MinimumStripLength.Value > MinimumSpacing &&
|
||||
options.MinimumStripLength.Value <= options.Spacing),
|
||||
$"Minimum strip length must be finite, greater than {MinimumSpacing} and no greater than spacing.", nameof(options));
|
||||
Require(Nonnegative(options.MinimumTailLength),
|
||||
"Minimum tail length must be finite and nonnegative.", nameof(options));
|
||||
Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 &&
|
||||
@@ -242,14 +545,19 @@ public static class AutomaticCutOffPlanner
|
||||
"Sheet length and width must be positive and finite.", nameof(plate));
|
||||
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate));
|
||||
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
|
||||
Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
|
||||
Nonnegative(plate.EdgeSpacing.Bottom)),
|
||||
"Clamp zones and bottom-edge clearance must be finite and valid.", nameof(plate));
|
||||
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
|
||||
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
|
||||
Require(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
|
||||
"A head-locked overcut must be finite and positive.", nameof(settings));
|
||||
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
|
||||
Require(plate.Parts != null && plate.CutOffs != null,
|
||||
"Plate parts and cut-off collections are required.", nameof(plate));
|
||||
}
|
||||
|
||||
private static double ValidatePart(Part part, Box sheet, int sign)
|
||||
private static (double X, double Y) ValidatePart(Part part, Box sheet, int sign, int ySign)
|
||||
{
|
||||
Require(Finite(part.Location) && double.IsFinite(part.Rotation),
|
||||
"Part pose must be finite.", "plate");
|
||||
@@ -262,19 +570,32 @@ public static class AutomaticCutOffPlanner
|
||||
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
|
||||
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
|
||||
"Part geometry has stale bounds; update it before planning.", "plate");
|
||||
Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate");
|
||||
var hasDisplayPerimeter = DisplayPerimeterTail.HasDisplayPerimeterMotion(part.Program);
|
||||
Require(Inside(box, sheet, hasDisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
|
||||
"Part geometry extends outside the physical sheet.", "plate");
|
||||
|
||||
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
|
||||
// Checking converted entities catches overflowing incremental moves and curve bounds.
|
||||
var hasMaterial = false;
|
||||
var occupied = sign > 0 ? box.Right : -box.Left;
|
||||
var occupiedY = ySign > 0 ? box.Top : -box.Bottom;
|
||||
var roundoff = CutOff.GetBoundsRoundoff(part);
|
||||
foreach (var entity in ConvertProgram.ToGeometry(part.Program))
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var outline = entities.Where(e => e.Layer == SpecialLayers.DisplayPerimeter).ToList();
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var entityBox = entity.BoundingBox;
|
||||
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
|
||||
if (!SpecialLayers.IsMaterial(entity.Layer))
|
||||
{
|
||||
// Travel and marks (a scribe is lasered) keep the ordinary tolerance. Only a rapid
|
||||
// ending on the outline (its first corner, or an applied program's display tail)
|
||||
// shares the outline's allowance.
|
||||
Require(Inside(entityBox.Translate(part.Location), sheet)
|
||||
|| hasDisplayPerimeter && IsRapidOntoOutline(entity, outline, part.Location, sheet),
|
||||
"Part geometry extends outside the physical sheet.", "plate");
|
||||
continue;
|
||||
}
|
||||
entityBox = entityBox.Translate(part.Location);
|
||||
// Permit only bounded floating-point roundoff, with the same conservative
|
||||
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions
|
||||
@@ -282,12 +603,15 @@ public static class AutomaticCutOffPlanner
|
||||
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
|
||||
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
|
||||
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
|
||||
Require(Inside(entityBox, sheet), "Part geometry extends outside the physical sheet.", "plate");
|
||||
Require(Inside(entityBox, sheet,
|
||||
entity.Layer == SpecialLayers.DisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
|
||||
"Part geometry extends outside the physical sheet.", "plate");
|
||||
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
|
||||
occupiedY = System.Math.Max(occupiedY, ySign > 0 ? entityBox.Top : -entityBox.Bottom);
|
||||
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
|
||||
}
|
||||
Require(hasMaterial, "Real parts must contain material geometry.", "plate");
|
||||
return occupied;
|
||||
return (occupied, occupiedY);
|
||||
}
|
||||
|
||||
private static void ValidateProgram(Program program, HashSet<Program> path)
|
||||
@@ -332,7 +656,7 @@ public static class AutomaticCutOffPlanner
|
||||
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
|
||||
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
|
||||
|
||||
private static bool HasUsableSegments(Program program)
|
||||
private static bool HasUsableSegments(Program program, bool vertical = true)
|
||||
{
|
||||
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
|
||||
return false;
|
||||
@@ -340,30 +664,49 @@ public static class AutomaticCutOffPlanner
|
||||
{
|
||||
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
|
||||
!Finite(from.EndPoint) || !Finite(to.EndPoint) ||
|
||||
!Near(from.EndPoint.X, to.EndPoint.X) ||
|
||||
System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y) <= GeometryTolerance)
|
||||
(vertical ? !Near(from.EndPoint.X, to.EndPoint.X) : !Near(from.EndPoint.Y, to.EndPoint.Y)) ||
|
||||
(vertical ? System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y)
|
||||
: System.Math.Abs(from.EndPoint.X - to.EndPoint.X)) <= GeometryTolerance)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsFullSpanProgram(Program program, Box bounds)
|
||||
private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
||||
Near(cut.StartLimit ?? bounds.Left, bounds.Left) &&
|
||||
Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel);
|
||||
|
||||
private static bool IsFullSpanProgram(Program program, bool vertical, double low, double high)
|
||||
{
|
||||
// No gaps, bridges, or filtered middle segments can separate the tail.
|
||||
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
|
||||
program.Codes[1] is not LinearMove to)
|
||||
return false;
|
||||
return System.Math.Min(from.EndPoint.Y, to.EndPoint.Y) <= bounds.Bottom + GeometryTolerance &&
|
||||
System.Math.Max(from.EndPoint.Y, to.EndPoint.Y) >= bounds.Top - GeometryTolerance;
|
||||
var start = vertical ? from.EndPoint.Y : from.EndPoint.X;
|
||||
var end = vertical ? to.EndPoint.Y : to.EndPoint.X;
|
||||
return System.Math.Min(start, end) <= low + GeometryTolerance &&
|
||||
System.Math.Max(start, end) >= high - GeometryTolerance;
|
||||
}
|
||||
|
||||
private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
|
||||
Nonnegative(box.Length) && Nonnegative(box.Width) &&
|
||||
double.IsFinite(box.Right) && double.IsFinite(box.Top);
|
||||
|
||||
private static bool Inside(Box box, Box sheet) =>
|
||||
box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance &&
|
||||
box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance;
|
||||
// A rapid that leaves the sheet only at an end lying on the display perimeter, within its allowance.
|
||||
private static bool IsRapidOntoOutline(Entity entity, List<Entity> outline, Vector location, Box sheet)
|
||||
{
|
||||
if (entity.Layer != SpecialLayers.Rapid || entity is not Line rapid
|
||||
|| !Inside(rapid.BoundingBox.Translate(location), sheet, DisplayPerimeterSheetAllowance))
|
||||
return false;
|
||||
bool Allowed(Vector point) =>
|
||||
Inside(new Box(point.X + location.X, point.Y + location.Y, 0, 0), sheet)
|
||||
|| outline.Any(e => e.ClosestPointTo(point).DistanceTo(point) <= GeometryTolerance);
|
||||
return Allowed(rapid.StartPoint) && Allowed(rapid.EndPoint);
|
||||
}
|
||||
|
||||
private static bool Inside(Box box, Box sheet, double tolerance = GeometryTolerance) =>
|
||||
box.Left >= sheet.Left - tolerance && box.Right <= sheet.Right + tolerance &&
|
||||
box.Bottom >= sheet.Bottom - tolerance && box.Top <= sheet.Top + tolerance;
|
||||
|
||||
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Exact layered search over sorted positions; each transition uses a sliding minimum.</summary>
|
||||
internal static class AutomaticCutOffStripSearch
|
||||
{
|
||||
internal static double[] FindPositions(double start, double end, int count, double spacing,
|
||||
double minimum, double step, Func<double, bool> allowed,
|
||||
Func<double, (double Length, int SegmentCount)> measure)
|
||||
{
|
||||
var rows = new List<double[]>(count);
|
||||
var nominal = new double[count];
|
||||
for (var line = 1; line <= count; line++)
|
||||
{
|
||||
var today = start + line * spacing;
|
||||
nominal[line - 1] = today;
|
||||
var low = System.Math.Max(start + line * minimum, end - (count - line + 1) * spacing);
|
||||
var high = System.Math.Min(today, end - AutomaticCutOffPlanner.GeometryTolerance);
|
||||
var positions = new List<double>();
|
||||
// Integer indices keep candidates on the origin-based lattice without cumulative drift.
|
||||
var first = (long)System.Math.Ceiling(low / step);
|
||||
var last = (long)System.Math.Floor(high / step);
|
||||
for (var index = first; index <= last; index++)
|
||||
{
|
||||
var position = index * step;
|
||||
if (position >= low && position <= high && allowed(position))
|
||||
positions.Add(position);
|
||||
}
|
||||
if (allowed(today))
|
||||
positions.Add(today);
|
||||
positions.Sort();
|
||||
for (var index = positions.Count - 1; index > 0; index--)
|
||||
if (positions[index] == positions[index - 1])
|
||||
positions.RemoveAt(index);
|
||||
if (positions.Count == 0)
|
||||
return null;
|
||||
rows.Add(positions.ToArray());
|
||||
}
|
||||
return Solve(start, rows, nominal, spacing, minimum, measure);
|
||||
}
|
||||
|
||||
// Kept independent of geometry so tests can compare against exhaustive, non-greedy oracles.
|
||||
internal static double[] Solve(double start, IReadOnlyList<double[]> rows,
|
||||
IReadOnlyList<double> nominal, double spacing, double minimum,
|
||||
Func<double, (double Length, int SegmentCount)> measure)
|
||||
{
|
||||
var previous = new List<State> { new() { Position = start } };
|
||||
for (var line = 0; line < rows.Count; line++)
|
||||
{
|
||||
var current = new List<State>(rows[line].Length);
|
||||
var deque = new int[previous.Count];
|
||||
var head = 0;
|
||||
var tail = 0;
|
||||
var added = 0;
|
||||
foreach (var position in rows[line])
|
||||
{
|
||||
var upper = position - minimum + AutomaticCutOffPlanner.GeometryTolerance;
|
||||
var lower = position - spacing - AutomaticCutOffPlanner.GeometryTolerance;
|
||||
while (added < previous.Count && previous[added].Position <= upper)
|
||||
{
|
||||
while (tail > head && Compare(previous[added], previous[deque[tail - 1]]) < 0)
|
||||
tail--;
|
||||
deque[tail++] = added++;
|
||||
}
|
||||
while (head < tail && previous[deque[head]].Position < lower)
|
||||
head++;
|
||||
if (head == tail)
|
||||
continue;
|
||||
var parent = previous[deque[head]];
|
||||
var cost = measure(position);
|
||||
current.Add(new State
|
||||
{
|
||||
Position = position,
|
||||
Length = parent.Length + RoundedMicroUnits(cost.Length),
|
||||
Pierces = parent.Pierces + cost.SegmentCount,
|
||||
Deviation = parent.Deviation + System.Math.Abs(nominal[line] - position),
|
||||
Parent = parent,
|
||||
});
|
||||
}
|
||||
if (current.Count == 0)
|
||||
return null;
|
||||
AssignLexicographicRanks(current, previous.Count);
|
||||
previous = current;
|
||||
}
|
||||
State best = null;
|
||||
foreach (var state in previous)
|
||||
if (best == null || Compare(state, best) < 0)
|
||||
best = state;
|
||||
var result = new double[rows.Count];
|
||||
for (var line = result.Length - 1; line >= 0; line--)
|
||||
{
|
||||
result[line] = best.Position;
|
||||
best = best.Parent;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static BigInteger RoundedMicroUnits(double length)
|
||||
{
|
||||
if (!double.IsFinite(length) || length < 0)
|
||||
throw new ArgumentException("Cut-off segment length must be finite and nonnegative.", nameof(length));
|
||||
var rounded = System.Math.Round(length, 6);
|
||||
// A shortest round-trip decimal may spell a different integer near 1e16
|
||||
// (for example 26833294515438512d prints as 26833294515438510).
|
||||
// An already-integral double is exact; never round it through text.
|
||||
if (rounded == System.Math.Truncate(rounded))
|
||||
return new BigInteger(rounded) * 1_000_000;
|
||||
// Compare sums of the specified decimal-rounded model units, not binary
|
||||
// approximations such as 0.1 + 0.2 versus 0.3. The round-trip string
|
||||
// retains micro-unit digits for fractional scores.
|
||||
var value = decimal.Parse(rounded.ToString("R", CultureInfo.InvariantCulture),
|
||||
NumberStyles.Float, CultureInfo.InvariantCulture);
|
||||
return new BigInteger(decimal.Round(value * 1_000_000m, 0));
|
||||
}
|
||||
|
||||
private static int Compare(State left, State right)
|
||||
{
|
||||
var comparison = left.Length.CompareTo(right.Length);
|
||||
if (comparison == 0)
|
||||
comparison = left.Pierces.CompareTo(right.Pierces);
|
||||
if (comparison == 0)
|
||||
comparison = left.Deviation.CompareTo(right.Deviation);
|
||||
return comparison != 0 ? comparison : left.LexicographicRank.CompareTo(right.LexicographicRank);
|
||||
}
|
||||
|
||||
private static void AssignLexicographicRanks(List<State> states, int parentCount)
|
||||
{
|
||||
// Stable counting sort by prefix rank. Positions are already sorted, so within a
|
||||
// prefix their order is lexicographic. Ranking and transitions are linear per row.
|
||||
var offsets = new int[parentCount];
|
||||
foreach (var state in states)
|
||||
offsets[state.Parent.LexicographicRank]++;
|
||||
var total = 0;
|
||||
for (var index = 0; index < offsets.Length; index++)
|
||||
{
|
||||
var count = offsets[index];
|
||||
offsets[index] = total;
|
||||
total += count;
|
||||
}
|
||||
foreach (var state in states)
|
||||
state.LexicographicRank = offsets[state.Parent.LexicographicRank]++;
|
||||
}
|
||||
|
||||
private sealed class State
|
||||
{
|
||||
public double Position;
|
||||
public BigInteger Length;
|
||||
public int Pierces;
|
||||
public double Deviation;
|
||||
public State Parent;
|
||||
public int LexicographicRank;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.BestFit
|
||||
{
|
||||
/// <summary>One saved best-fit pair result, as persisted in a .nest archive.</summary>
|
||||
public sealed record SavedPairResult(
|
||||
double Part1Rotation,
|
||||
double Part2Rotation,
|
||||
double Part2OffsetX,
|
||||
double Part2OffsetY,
|
||||
int StrategyType,
|
||||
int TestNumber,
|
||||
double CandidateSpacing,
|
||||
double RotatedArea,
|
||||
double BoundingWidth,
|
||||
double BoundingHeight,
|
||||
double OptimalRotation,
|
||||
bool Keep,
|
||||
string Reason,
|
||||
double TrueArea,
|
||||
IReadOnlyList<double> HullAngles
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// The saved best-fit results for one drawing at one plate size and spacing.
|
||||
/// <paramref name="CanonicalAngle"/> is the drawing's canonical angle (radians) when the
|
||||
/// set was saved: the frame its pair geometry is in. Null means the frame is unknown.
|
||||
/// </summary>
|
||||
public sealed record SavedPairSet(
|
||||
double PlateWidth,
|
||||
double PlateHeight,
|
||||
double Spacing,
|
||||
IReadOnlyList<SavedPairResult> Results,
|
||||
double? CanonicalAngle = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Persists best-fit pair caches across .nest round trips. Implementations own their
|
||||
/// cache; readers and writers never assume one exists, and a file without saved sets is
|
||||
/// always valid because the data can be recomputed.
|
||||
/// </summary>
|
||||
public interface IBestFitStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets to save for <paramref name="drawing"/>, limited to the given
|
||||
/// (plate width, plate length, part spacing) keys.
|
||||
/// </summary>
|
||||
IReadOnlyList<SavedPairSet> Export(
|
||||
Drawing drawing,
|
||||
IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Restores saved sets for <paramref name="drawing"/>. Sets saved in a frame other
|
||||
/// than the drawing's current canonical frame, or in an unknown one, are stale.
|
||||
/// </summary>
|
||||
void Import(Drawing drawing, IReadOnlyList<SavedPairSet> sets);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Breakoffs
|
||||
{
|
||||
/// <summary>
|
||||
/// Scrap drawn on a part (for example a forming backgauge tab) that stays attached by tabs and
|
||||
/// is snapped off later. It is identified by its joining line: a chain of source entities whose
|
||||
/// ends touch the outline around part and scrap and which splits that region in two. By
|
||||
/// default the smaller side is the scrap. Building the drawing cuts the joining line as open
|
||||
/// break cuts between tabs, entered from the scrap side. All values are in the source frame.
|
||||
/// </summary>
|
||||
public sealed class Breakoff
|
||||
{
|
||||
public const double DefaultTabWidth = 0.0625;
|
||||
public const int DefaultTabCount = 4;
|
||||
public const int MinTabCount = 2;
|
||||
|
||||
/// <summary>Source entity IDs of the joining line between part and scrap.</summary>
|
||||
public List<Guid> EdgeIds { get; set; } = new();
|
||||
|
||||
/// <summary>True when the larger side is the scrap instead of the smaller one.</summary>
|
||||
public bool Flip { get; set; }
|
||||
|
||||
/// <summary>Uncut length of each tab along the joining line.</summary>
|
||||
public double TabWidth { get; set; } = DefaultTabWidth;
|
||||
|
||||
/// <summary>
|
||||
/// Tab centers as distances along the joining line from its start, ascending; the first
|
||||
/// and last tab sit at the ends. Empty means <see cref="DefaultTabCount"/> evenly spaced.
|
||||
/// </summary>
|
||||
public List<double> TabCenters { get; set; } = new();
|
||||
|
||||
public Breakoff Clone() =>
|
||||
new()
|
||||
{
|
||||
EdgeIds = new List<Guid>(EdgeIds),
|
||||
Flip = Flip,
|
||||
TabWidth = TabWidth,
|
||||
TabCenters = new List<double>(TabCenters),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Breakoffs
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves and applies <see cref="Breakoff"/>s: the joining line becomes open cuts on the
|
||||
/// break layer between tabs, each travelling with the scrap on its left so the cutting
|
||||
/// strategies pierce and lead in from the scrap.
|
||||
/// </summary>
|
||||
public static class BreakoffBuilder
|
||||
{
|
||||
/// <summary>How close the joining line's ends must be to the outline.</summary>
|
||||
public const double JoinTolerance = 0.001;
|
||||
|
||||
private const double ArcTolerance = 0.0005;
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="entities"/> with every valid breakoff applied: its joining line replaced
|
||||
/// by break cuts and its outline's joints made exact. Inputs are not modified; generated
|
||||
/// entities are new. Breakoffs that cannot be resolved are skipped and described in
|
||||
/// <paramref name="errors"/>.
|
||||
/// </summary>
|
||||
public static List<Entity> Apply(IReadOnlyList<Entity> entities, IReadOnlyList<Breakoff> breakoffs,
|
||||
out List<string> errors)
|
||||
{
|
||||
errors = new List<string>();
|
||||
var working = entities.ToList();
|
||||
if (breakoffs == null || breakoffs.Count == 0)
|
||||
return working;
|
||||
|
||||
var allEdges = breakoffs.SelectMany(b => b.EdgeIds).ToHashSet();
|
||||
for (var i = 0; i < breakoffs.Count; i++)
|
||||
{
|
||||
var layout = Layout(working, breakoffs[i], allEdges);
|
||||
if (!layout.IsValid)
|
||||
{
|
||||
errors.Add($"Breakoff {i + 1}: {layout.Error}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var replaced = new HashSet<Entity>(layout.ReplacedEntities, ReferenceEqualityComparer.Instance);
|
||||
working.RemoveAll(replaced.Contains);
|
||||
working.AddRange(layout.Outline.Select(e => e.Clone()));
|
||||
working.AddRange(layout.Cuts.SelectMany(c => c).Select(e => e.Clone()));
|
||||
}
|
||||
return working;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <paramref name="breakoff"/> against <paramref name="entities"/> (the entities that
|
||||
/// will be cut). <paramref name="otherEdgeIds"/> are joining lines of other breakoffs on the
|
||||
/// same drawing, which are ignored while finding the outline.
|
||||
/// </summary>
|
||||
public static BreakoffLayout Layout(IReadOnlyList<Entity> entities, Breakoff breakoff,
|
||||
IEnumerable<Guid> otherEdgeIds = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entities);
|
||||
ArgumentNullException.ThrowIfNull(breakoff);
|
||||
if (!(breakoff.TabWidth > 0) || !double.IsFinite(breakoff.TabWidth))
|
||||
return Fail("The tab width must be greater than zero.");
|
||||
|
||||
var edgeIds = breakoff.EdgeIds.ToHashSet();
|
||||
var edgeSources = entities.Where(e => edgeIds.Contains(e.Id)).ToList();
|
||||
if (edgeIds.Count == 0 || edgeSources.Count != edgeIds.Count)
|
||||
return Fail("Its joining line is hidden or missing.");
|
||||
|
||||
var line = BreakoffPath.ChainOpen(edgeSources, JoinTolerance);
|
||||
if (line == null)
|
||||
return Fail("Its joining line must be one open chain.");
|
||||
|
||||
// Measure tabs from a fixed end so positions survive re-chaining.
|
||||
var a = BreakoffPath.Start(line[0]);
|
||||
var b = BreakoffPath.End(line[^1]);
|
||||
if (b.X < a.X - JoinTolerance || (System.Math.Abs(b.X - a.X) <= JoinTolerance && b.Y < a.Y))
|
||||
{
|
||||
line = BreakoffPath.Reversed(line);
|
||||
(a, b) = (b, a);
|
||||
}
|
||||
|
||||
var excluded = new HashSet<Guid>(otherEdgeIds ?? Enumerable.Empty<Guid>());
|
||||
excluded.UnionWith(edgeIds);
|
||||
var sources = new Dictionary<Entity, Entity>(ReferenceEqualityComparer.Instance);
|
||||
var candidates = new List<Entity>();
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
if (excluded.Contains(entity.Id) || IsMark(entity) || entity is not (Line or Arc))
|
||||
continue;
|
||||
var copy = BreakoffPath.Copy(entity);
|
||||
sources[copy] = entity;
|
||||
candidates.Add(copy);
|
||||
}
|
||||
|
||||
Shape outline = null;
|
||||
var outlineArea = 0.0;
|
||||
foreach (var shape in ShapeBuilder.GetShapes(WithoutDanglingEnds(candidates)))
|
||||
{
|
||||
if (shape.Entities.Count < 2 || !shape.IsClosed()
|
||||
|| shape.ClosestPointTo(a).DistanceTo(a) > JoinTolerance
|
||||
|| shape.ClosestPointTo(b).DistanceTo(b) > JoinTolerance)
|
||||
continue;
|
||||
var area = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(shape.Entities, ArcTolerance)));
|
||||
if (outline == null || area > outlineArea)
|
||||
{
|
||||
outline = shape;
|
||||
outlineArea = area;
|
||||
}
|
||||
}
|
||||
if (outline == null)
|
||||
return Fail("Both ends of the joining line must touch a closed outline.");
|
||||
|
||||
var loop = CloseExactly(outline.Entities);
|
||||
var total = BreakoffPath.Length(loop);
|
||||
var atA = BreakoffPath.DistanceOf(loop, a);
|
||||
var rotated = BreakoffPath.Sub(loop, atA, total).Concat(BreakoffPath.Sub(loop, 0, atA)).ToList();
|
||||
var atB = BreakoffPath.DistanceOf(rotated, b);
|
||||
if (atB <= JoinTolerance || atB >= total - JoinTolerance)
|
||||
return Fail("The joining line must touch the outline at two different points.");
|
||||
var toB = BreakoffPath.Sub(rotated, 0, atB);
|
||||
var backToA = BreakoffPath.Sub(rotated, atB, total);
|
||||
|
||||
// The two sides: the outline from A to B closed back along the line, and the line
|
||||
// closed back along the rest of the outline.
|
||||
var sideTo = toB.Concat(BreakoffPath.Reversed(line)).ToList();
|
||||
var sideBack = line.Concat(backToA).ToList();
|
||||
var sideToArea = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(sideTo, ArcTolerance)));
|
||||
var sideBackArea = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(sideBack, ArcTolerance)));
|
||||
if (System.Math.Abs(sideToArea + sideBackArea - outlineArea) > 0.01 * outlineArea)
|
||||
return Fail("The joining line must run inside the outline.");
|
||||
|
||||
var scrap = (sideToArea <= sideBackArea) != breakoff.Flip ? sideTo : sideBack;
|
||||
var scrapPoints = BreakoffPath.ToPoints(scrap, ArcTolerance);
|
||||
|
||||
var length = BreakoffPath.Length(line);
|
||||
var tabWidth = breakoff.TabWidth;
|
||||
var centers = breakoff.TabCenters.Count == 0
|
||||
? EvenTabCenters(length, Breakoff.DefaultTabCount, tabWidth)
|
||||
: breakoff.TabCenters.OrderBy(c => c).ToList();
|
||||
if (centers.Count < Breakoff.MinTabCount || length <= tabWidth * centers.Count)
|
||||
return Fail("The joining line is too short for its tabs.");
|
||||
if (centers[0] < tabWidth / 2 - JoinTolerance || centers[^1] > length - tabWidth / 2 + JoinTolerance)
|
||||
return Fail("A tab runs off the joining line; space the tabs evenly again.");
|
||||
for (var i = 1; i < centers.Count; i++)
|
||||
if (centers[i] - centers[i - 1] <= tabWidth + JoinTolerance)
|
||||
return Fail("Two tabs overlap; space the tabs evenly again.");
|
||||
|
||||
var side = SideOf(line, length, scrapPoints);
|
||||
if (side == 0)
|
||||
return Fail("Could not tell which side of the joining line is scrap.");
|
||||
|
||||
var cuts = new List<IReadOnlyList<Entity>>();
|
||||
for (var i = 0; i + 1 < centers.Count; i++)
|
||||
{
|
||||
var cut = BreakoffPath.Sub(line, centers[i] + tabWidth / 2, centers[i + 1] - tabWidth / 2);
|
||||
if (side < 0)
|
||||
cut = BreakoffPath.Reversed(cut);
|
||||
foreach (var e in cut)
|
||||
{
|
||||
e.Id = Guid.NewGuid();
|
||||
e.Layer = SpecialLayers.Break;
|
||||
}
|
||||
cuts.Add(cut);
|
||||
}
|
||||
|
||||
var replaced = outline.Entities.Select(e => sources[e]).ToList();
|
||||
replaced.AddRange(edgeSources);
|
||||
return new BreakoffLayout
|
||||
{
|
||||
BreakLine = line,
|
||||
Length = length,
|
||||
Outline = loop,
|
||||
Scrap = scrap,
|
||||
Cuts = cuts,
|
||||
TabCenters = centers,
|
||||
TabWidth = tabWidth,
|
||||
ReplacedEntities = replaced,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary><paramref name="count"/> tab centers spaced evenly with the first and last at the ends.</summary>
|
||||
public static List<double> EvenTabCenters(double length, int count, double tabWidth)
|
||||
{
|
||||
count = System.Math.Max(count, Breakoff.MinTabCount);
|
||||
var half = tabWidth / 2;
|
||||
var span = length - tabWidth;
|
||||
return Enumerable.Range(0, count).Select(i => half + span * i / (count - 1)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The chain of visible lines and arcs through <paramref name="picked"/>, grown until it
|
||||
/// reaches a point where it does not continue as a single path (such as where the joining
|
||||
/// line meets the outline). Empty when <paramref name="picked"/> cannot be a joining line.
|
||||
/// </summary>
|
||||
public static List<Entity> FindEdgeChain(IReadOnlyList<Entity> entities, Entity picked)
|
||||
{
|
||||
var pool = entities.Where(e => e is Line or Arc && e.IsVisible && e.Layer?.IsVisible != false && !IsMark(e))
|
||||
.ToList();
|
||||
if (!pool.Contains(picked))
|
||||
return new List<Entity>();
|
||||
|
||||
var chain = new List<Entity> { picked };
|
||||
Grow(BreakoffPath.End(picked));
|
||||
Grow(BreakoffPath.Start(picked));
|
||||
return chain;
|
||||
|
||||
void Grow(Vector tip)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var touching = pool.Where(e => !chain.Contains(e)
|
||||
&& (BreakoffPath.Start(e).DistanceTo(tip) <= JoinTolerance
|
||||
|| BreakoffPath.End(e).DistanceTo(tip) <= JoinTolerance)).ToList();
|
||||
if (touching.Count != 1)
|
||||
return;
|
||||
var next = touching[0];
|
||||
chain.Add(next);
|
||||
tip = BreakoffPath.Start(next).DistanceTo(tip) <= JoinTolerance
|
||||
? BreakoffPath.End(next)
|
||||
: BreakoffPath.Start(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits each visible line or arc of <paramref name="entities"/> where another one ends on
|
||||
/// its interior (a T-junction), so a joining line can end exactly where it meets other
|
||||
/// geometry. The first piece keeps the entity's ID. Returns the IDs of every split entity's
|
||||
/// pieces, keyed by its original ID.
|
||||
/// </summary>
|
||||
public static Dictionary<Guid, List<Guid>> SplitAtJunctions(List<Entity> entities)
|
||||
{
|
||||
var pool = entities.Where(e => e is Line or Arc && e.IsVisible && e.Layer?.IsVisible != false && !IsMark(e))
|
||||
.ToList();
|
||||
var ends = pool.SelectMany(e => new[] { BreakoffPath.Start(e), BreakoffPath.End(e) }).ToList();
|
||||
var split = new Dictionary<Guid, List<Guid>>();
|
||||
foreach (var host in pool)
|
||||
{
|
||||
var box = host.BoundingBox;
|
||||
var cuts = new List<double>();
|
||||
foreach (var point in ends)
|
||||
{
|
||||
if (point.X < box.Left - JoinTolerance || point.X > box.Right + JoinTolerance
|
||||
|| point.Y < box.Bottom - JoinTolerance || point.Y > box.Top + JoinTolerance)
|
||||
continue;
|
||||
if (point.DistanceTo(BreakoffPath.Start(host)) <= JoinTolerance
|
||||
|| point.DistanceTo(BreakoffPath.End(host)) <= JoinTolerance
|
||||
|| host.ClosestPointTo(point).DistanceTo(point) > JoinTolerance)
|
||||
continue;
|
||||
var at = BreakoffPath.DistanceOf(new[] { host }, point);
|
||||
if (cuts.All(c => System.Math.Abs(c - at) > JoinTolerance))
|
||||
cuts.Add(at);
|
||||
}
|
||||
if (cuts.Count == 0)
|
||||
continue;
|
||||
|
||||
cuts.Sort();
|
||||
var bounds = new List<double> { 0 };
|
||||
bounds.AddRange(cuts);
|
||||
bounds.Add(host.Length);
|
||||
var pieces = new List<Entity>();
|
||||
for (var i = 0; i + 1 < bounds.Count; i++)
|
||||
pieces.AddRange(BreakoffPath.Sub(new[] { host }, bounds[i], bounds[i + 1]));
|
||||
pieces[0].Id = host.Id;
|
||||
foreach (var piece in pieces)
|
||||
piece.IsVisible = host.IsVisible;
|
||||
var index = entities.IndexOf(host);
|
||||
entities.RemoveAt(index);
|
||||
entities.InsertRange(index, pieces);
|
||||
split[host.Id] = pieces.Select(p => p.Id).ToList();
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
private static BreakoffLayout Fail(string error) => new(error);
|
||||
|
||||
// A stray open line ending on the outline (an unmarked etch line, say) would otherwise be
|
||||
// chained into it and keep the loop from closing. Nothing with a free end can be part of a
|
||||
// closed outline, so such entities are dropped, repeatedly, before chaining.
|
||||
private static List<Entity> WithoutDanglingEnds(List<Entity> entities)
|
||||
{
|
||||
// Cluster endpoints that meet within the chain tolerance, using a grid of that size.
|
||||
const double cell = Tolerance.ChainTolerance;
|
||||
var grid = new Dictionary<(long, long), List<(Vector Point, int Cluster)>>();
|
||||
var clusterOf = new int[entities.Count, 2];
|
||||
var sizes = new List<int>();
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
for (var side = 0; side < 2; side++)
|
||||
{
|
||||
var point = side == 0 ? BreakoffPath.Start(entities[i]) : BreakoffPath.End(entities[i]);
|
||||
var gx = (long)System.Math.Floor(point.X / cell);
|
||||
var gy = (long)System.Math.Floor(point.Y / cell);
|
||||
var cluster = -1;
|
||||
for (var dx = -1; dx <= 1 && cluster < 0; dx++)
|
||||
for (var dy = -1; dy <= 1 && cluster < 0; dy++)
|
||||
if (grid.TryGetValue((gx + dx, gy + dy), out var near))
|
||||
foreach (var (other, id) in near)
|
||||
if (other.DistanceTo(point) <= cell)
|
||||
{
|
||||
cluster = id;
|
||||
break;
|
||||
}
|
||||
if (cluster < 0)
|
||||
{
|
||||
cluster = sizes.Count;
|
||||
sizes.Add(0);
|
||||
}
|
||||
if (!grid.TryGetValue((gx, gy), out var bucket))
|
||||
grid[(gx, gy)] = bucket = new List<(Vector, int)>();
|
||||
bucket.Add((point, cluster));
|
||||
sizes[cluster]++;
|
||||
clusterOf[i, side] = cluster;
|
||||
}
|
||||
|
||||
// Peel entities with a free end until none is left; closed loops never peel.
|
||||
var members = new List<int>[sizes.Count];
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
for (var side = 0; side < 2; side++)
|
||||
(members[clusterOf[i, side]] ??= new List<int>()).Add(i);
|
||||
var removed = new bool[entities.Count];
|
||||
var queue = new Queue<int>(Enumerable.Range(0, entities.Count)
|
||||
.Where(i => sizes[clusterOf[i, 0]] < 2 || sizes[clusterOf[i, 1]] < 2));
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var i = queue.Dequeue();
|
||||
if (removed[i])
|
||||
continue;
|
||||
removed[i] = true;
|
||||
for (var side = 0; side < 2; side++)
|
||||
{
|
||||
var cluster = clusterOf[i, side];
|
||||
if (--sizes[cluster] == 1)
|
||||
foreach (var other in members[cluster])
|
||||
if (!removed[other])
|
||||
queue.Enqueue(other);
|
||||
}
|
||||
}
|
||||
return entities.Where((_, i) => !removed[i]).ToList();
|
||||
}
|
||||
|
||||
internal static bool IsMark(Entity e) =>
|
||||
string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(e.Layer?.Name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// The planner closes contours far tighter than the import chain tolerance, and the outline
|
||||
// is re-chained from any entity when the program is built, so every joint must be exact.
|
||||
internal static List<Entity> CloseExactly(IReadOnlyList<Entity> loop)
|
||||
{
|
||||
var result = new List<Entity>();
|
||||
for (var i = 0; i < loop.Count; i++)
|
||||
{
|
||||
var current = loop[i];
|
||||
var next = loop[(i + 1) % loop.Count];
|
||||
result.Add(current);
|
||||
var end = BreakoffPath.End(current);
|
||||
var start = BreakoffPath.Start(next);
|
||||
if (end.DistanceTo(start) == 0)
|
||||
continue;
|
||||
if (next is Line nextLine)
|
||||
nextLine.StartPoint = end;
|
||||
else if (current is Line currentLine)
|
||||
currentLine.EndPoint = start;
|
||||
else if (end.DistanceTo(start) > 1e-8)
|
||||
result.Add(new Line(end, start) { Layer = current.Layer, Color = current.Color, LineTypeName = current.LineTypeName });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Vector Normal(IReadOnlyList<Entity> path, double length, double distance)
|
||||
{
|
||||
const double nudge = 1e-6;
|
||||
var before = BreakoffPath.TangentAt(path, System.Math.Max(distance - nudge, 0));
|
||||
var after = BreakoffPath.TangentAt(path, System.Math.Min(distance + nudge, length));
|
||||
var t = new Vector(before.X + after.X, before.Y + after.Y);
|
||||
var size = System.Math.Sqrt(t.X * t.X + t.Y * t.Y);
|
||||
t = size <= Tolerance.Epsilon ? after : new Vector(t.X / size, t.Y / size);
|
||||
return new Vector(-t.Y, t.X);
|
||||
}
|
||||
|
||||
// +1 when the scrap lies left of the line's travel, -1 when right, 0 when unclear.
|
||||
private static int SideOf(IReadOnlyList<Entity> line, double length, IReadOnlyList<Vector> scrap)
|
||||
{
|
||||
var probe = System.Math.Min(0.01, length * 0.01);
|
||||
var votes = 0;
|
||||
foreach (var fraction in new[] { 0.25, 0.5, 0.75 })
|
||||
{
|
||||
var d = length * fraction;
|
||||
var p = BreakoffPath.PointAt(line, d);
|
||||
var n = Normal(line, length, d);
|
||||
var left = BreakoffPath.Contains(scrap, p + n * probe);
|
||||
var right = BreakoffPath.Contains(scrap, p - n * probe);
|
||||
if (left != right)
|
||||
votes += left ? 1 : -1;
|
||||
}
|
||||
return System.Math.Sign(votes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Breakoffs
|
||||
{
|
||||
/// <summary>
|
||||
/// A <see cref="Breakoff"/> resolved against a drawing's entities. When <see cref="Error"/> is
|
||||
/// set nothing else is meaningful and the breakoff is not applied.
|
||||
/// </summary>
|
||||
public sealed class BreakoffLayout
|
||||
{
|
||||
internal BreakoffLayout(string error) => Error = error;
|
||||
|
||||
internal BreakoffLayout() { }
|
||||
|
||||
public string Error { get; }
|
||||
|
||||
public bool IsValid => Error == null;
|
||||
|
||||
/// <summary>The joining line in travel order from its start.</summary>
|
||||
public IReadOnlyList<Entity> BreakLine { get; internal set; }
|
||||
|
||||
public double Length { get; internal set; }
|
||||
|
||||
/// <summary>The closed outline around part and scrap, with its joints made exact.</summary>
|
||||
public IReadOnlyList<Entity> Outline { get; internal set; }
|
||||
|
||||
/// <summary>The scrap side's closed boundary (part of the outline plus the joining line).</summary>
|
||||
public IReadOnlyList<Entity> Scrap { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// One open cut per section of the joining line between tabs, on the break layer and
|
||||
/// travel-ordered with the scrap to its left, so each is entered from the scrap.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyList<Entity>> Cuts { get; internal set; }
|
||||
|
||||
/// <summary>Tab centers used, as distances along <see cref="BreakLine"/>.</summary>
|
||||
public IReadOnlyList<double> TabCenters { get; internal set; }
|
||||
|
||||
public double TabWidth { get; internal set; }
|
||||
|
||||
/// <summary>Entities the outline and cuts replace: the original outline and joining line.</summary>
|
||||
internal IReadOnlyList<Entity> ReplacedEntities { get; set; }
|
||||
|
||||
public Vector PointAt(double distance) => BreakoffPath.PointAt(BreakLine, distance);
|
||||
|
||||
/// <summary>The distance along the joining line closest to <paramref name="point"/>.</summary>
|
||||
public double DistanceOf(Vector point) => BreakoffPath.DistanceOf(BreakLine, point);
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab stays on the
|
||||
/// joining line and keeps a gap from its neighbours.
|
||||
/// </summary>
|
||||
public double ClampTabCenter(int index, double center)
|
||||
{
|
||||
var half = TabWidth / 2;
|
||||
var min = index == 0 ? half : TabCenters[index - 1] + TabWidth + MinGap;
|
||||
var max = index == TabCenters.Count - 1 ? Length - half : TabCenters[index + 1] - TabWidth - MinGap;
|
||||
return max < min ? TabCenters[index] : System.Math.Clamp(center, min, max);
|
||||
}
|
||||
|
||||
// Wider than the builder's overlap tolerance, so a clamped drag always resolves.
|
||||
internal double MinGap => System.Math.Max(TabWidth / 2, 2 * BreakoffBuilder.JoinTolerance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Breakoffs
|
||||
{
|
||||
/// <summary>
|
||||
/// Distance-based operations on a chain of lines and arcs, each oriented in travel order
|
||||
/// (one's end is the next one's start). Splits and copies keep the source entity's style.
|
||||
/// </summary>
|
||||
internal static class BreakoffPath
|
||||
{
|
||||
public static Vector Start(Entity e) => e is Line l ? l.StartPoint : ((Arc)e).StartPoint();
|
||||
|
||||
public static Vector End(Entity e) => e is Line l ? l.EndPoint : ((Arc)e).EndPoint();
|
||||
|
||||
public static double Length(IReadOnlyList<Entity> path) => path.Sum(e => e.Length);
|
||||
|
||||
public static Vector PointAt(IReadOnlyList<Entity> path, double distance) =>
|
||||
Locate(path, distance, out var entity, out var local) ? PointOn(entity, local) : End(path[^1]);
|
||||
|
||||
public static Vector TangentAt(IReadOnlyList<Entity> path, double distance)
|
||||
{
|
||||
Locate(path, distance, out var entity, out var local);
|
||||
return TangentOn(entity, local);
|
||||
}
|
||||
|
||||
/// <summary>The distance along the path of the path point closest to <paramref name="point"/>.</summary>
|
||||
public static double DistanceOf(IReadOnlyList<Entity> path, Vector point)
|
||||
{
|
||||
var best = double.MaxValue;
|
||||
var bestDistance = 0.0;
|
||||
var walked = 0.0;
|
||||
foreach (var e in path)
|
||||
{
|
||||
var closest = e.ClosestPointTo(point);
|
||||
var gap = closest.DistanceTo(point);
|
||||
if (gap < best)
|
||||
{
|
||||
best = gap;
|
||||
bestDistance = walked + LocalDistance(e, closest);
|
||||
}
|
||||
walked += e.Length;
|
||||
}
|
||||
return bestDistance;
|
||||
}
|
||||
|
||||
/// <summary>Copies of the part of <paramref name="path"/> from <paramref name="from"/> to <paramref name="to"/>.</summary>
|
||||
public static List<Entity> Sub(IReadOnlyList<Entity> path, double from, double to)
|
||||
{
|
||||
var result = new List<Entity>();
|
||||
var walked = 0.0;
|
||||
foreach (var e in path)
|
||||
{
|
||||
var length = e.Length;
|
||||
var a = System.Math.Max(from - walked, 0);
|
||||
var b = System.Math.Min(to - walked, length);
|
||||
walked += length;
|
||||
if (b - a <= Tolerance.Epsilon)
|
||||
continue;
|
||||
result.Add(Piece(e, a, b));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Copies of <paramref name="path"/> in reverse travel order.</summary>
|
||||
public static List<Entity> Reversed(IEnumerable<Entity> path)
|
||||
{
|
||||
var result = path.Select(Copy).ToList();
|
||||
result.Reverse();
|
||||
foreach (var e in result)
|
||||
e.Reverse();
|
||||
return result;
|
||||
}
|
||||
|
||||
public static Entity Copy(Entity e)
|
||||
{
|
||||
var copy = e.Clone();
|
||||
copy.Id = e.Id;
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>Points along the path, arcs subdivided so no chord deviates more than <paramref name="tolerance"/>.</summary>
|
||||
public static List<Vector> ToPoints(IEnumerable<Entity> path, double tolerance)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
foreach (var e in path)
|
||||
{
|
||||
if (points.Count == 0)
|
||||
points.Add(Start(e));
|
||||
if (e is Arc arc)
|
||||
points.AddRange(arc.ToPoints(arc.SegmentsForTolerance(tolerance)).Skip(1));
|
||||
else
|
||||
points.Add(End(e));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/// <summary>Signed area (counterclockwise positive) of a closed point loop.</summary>
|
||||
public static double SignedArea(IReadOnlyList<Vector> loop)
|
||||
{
|
||||
var sum = 0.0;
|
||||
for (var i = 0; i < loop.Count; i++)
|
||||
{
|
||||
var a = loop[i];
|
||||
var b = loop[(i + 1) % loop.Count];
|
||||
sum += a.X * b.Y - b.X * a.Y;
|
||||
}
|
||||
return sum / 2;
|
||||
}
|
||||
|
||||
public static bool Contains(IReadOnlyList<Vector> loop, Vector point)
|
||||
{
|
||||
var inside = false;
|
||||
for (int i = 0, j = loop.Count - 1; i < loop.Count; j = i++)
|
||||
{
|
||||
var a = loop[i];
|
||||
var b = loop[j];
|
||||
if ((a.Y > point.Y) != (b.Y > point.Y)
|
||||
&& point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static bool Locate(IReadOnlyList<Entity> path, double distance, out Entity entity, out double local)
|
||||
{
|
||||
var walked = 0.0;
|
||||
foreach (var e in path)
|
||||
{
|
||||
var length = e.Length;
|
||||
if (distance <= walked + length)
|
||||
{
|
||||
entity = e;
|
||||
local = System.Math.Clamp(distance - walked, 0, length);
|
||||
return true;
|
||||
}
|
||||
walked += length;
|
||||
}
|
||||
entity = path[^1];
|
||||
local = entity.Length;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Vector PointOn(Entity e, double local)
|
||||
{
|
||||
if (e is Line line)
|
||||
{
|
||||
var t = line.Length <= 0 ? 0 : local / line.Length;
|
||||
return line.StartPoint + (line.EndPoint - line.StartPoint) * t;
|
||||
}
|
||||
var arc = (Arc)e;
|
||||
var angle = ArcAngleAt(arc, local);
|
||||
return new Vector(arc.Center.X + arc.Radius * System.Math.Cos(angle),
|
||||
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
|
||||
}
|
||||
|
||||
private static Vector TangentOn(Entity e, double local)
|
||||
{
|
||||
if (e is Line line)
|
||||
{
|
||||
var d = line.EndPoint - line.StartPoint;
|
||||
var length = line.Length;
|
||||
return length <= 0 ? new Vector(1, 0) : new Vector(d.X / length, d.Y / length);
|
||||
}
|
||||
var arc = (Arc)e;
|
||||
var angle = ArcAngleAt(arc, local);
|
||||
return arc.IsReversed
|
||||
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
|
||||
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
|
||||
}
|
||||
|
||||
private static double ArcAngleAt(Arc arc, double local) =>
|
||||
arc.StartAngle + (arc.IsReversed ? -1 : 1) * local / arc.Radius;
|
||||
|
||||
private static double LocalDistance(Entity e, Vector point)
|
||||
{
|
||||
if (e is Line line)
|
||||
return line.StartPoint.DistanceTo(point);
|
||||
var arc = (Arc)e;
|
||||
var swept = Angle.NormalizeRad(arc.IsReversed
|
||||
? arc.StartAngle - arc.Center.AngleTo(point)
|
||||
: arc.Center.AngleTo(point) - arc.StartAngle);
|
||||
var distance = swept * arc.Radius;
|
||||
// A point a hair before the start normalizes to a full turn; it belongs at an end.
|
||||
if (distance > arc.Length)
|
||||
return point.DistanceTo(arc.StartPoint()) <= point.DistanceTo(arc.EndPoint()) ? 0 : arc.Length;
|
||||
return distance;
|
||||
}
|
||||
|
||||
private static Entity Piece(Entity e, double a, double b)
|
||||
{
|
||||
Entity piece;
|
||||
if (e is Line line)
|
||||
{
|
||||
piece = new Line(PointOn(line, a), PointOn(line, b));
|
||||
}
|
||||
else
|
||||
{
|
||||
var arc = (Arc)e;
|
||||
piece = new Arc(arc.Center, arc.Radius, Angle.NormalizeRad(ArcAngleAt(arc, a)),
|
||||
Angle.NormalizeRad(ArcAngleAt(arc, b)), arc.IsReversed);
|
||||
}
|
||||
piece.Layer = e.Layer;
|
||||
piece.Color = e.Color;
|
||||
piece.LineTypeName = e.LineTypeName;
|
||||
return piece;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orders <paramref name="edges"/> into one open chain, copied and oriented in travel
|
||||
/// order, or returns null when they do not form exactly one open chain.
|
||||
/// </summary>
|
||||
public static List<Entity> ChainOpen(IReadOnlyList<Entity> edges, double tolerance)
|
||||
{
|
||||
if (edges.Count == 0 || edges.Any(e => e is not (Line or Arc)))
|
||||
return null;
|
||||
|
||||
int Degree(Vector p) => edges.Count(e => Start(e).DistanceTo(p) <= tolerance)
|
||||
+ edges.Count(e => End(e).DistanceTo(p) <= tolerance);
|
||||
|
||||
var first = edges.FirstOrDefault(e => Degree(Start(e)) == 1 || Degree(End(e)) == 1);
|
||||
if (first == null)
|
||||
return null;
|
||||
|
||||
var remaining = edges.ToList();
|
||||
var chain = new List<Entity>();
|
||||
var current = Copy(first);
|
||||
if (Degree(Start(first)) != 1)
|
||||
current.Reverse();
|
||||
remaining.Remove(first);
|
||||
chain.Add(current);
|
||||
|
||||
while (remaining.Count > 0)
|
||||
{
|
||||
var tip = End(chain[^1]);
|
||||
var next = remaining.FirstOrDefault(e => Start(e).DistanceTo(tip) <= tolerance
|
||||
|| End(e).DistanceTo(tip) <= tolerance);
|
||||
if (next == null)
|
||||
return null;
|
||||
var copy = Copy(next);
|
||||
if (Start(next).DistanceTo(tip) > tolerance)
|
||||
copy.Reverse();
|
||||
remaining.Remove(next);
|
||||
chain.Add(copy);
|
||||
}
|
||||
|
||||
return Start(chain[0]).DistanceTo(End(chain[^1])) <= tolerance ? null : chain;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
/// <summary>
|
||||
/// Turns a plate's recorded moves into a timed timeline: numbers the contours, drops
|
||||
/// etch-only contours the post would skip, sorts contours into holes and outsides, and
|
||||
/// times each move at its feed, with acceleration when a motion profile is set.
|
||||
/// </summary>
|
||||
internal static class ContourTiming
|
||||
{
|
||||
private const double ClosedTolerance = 1e-4;
|
||||
|
||||
public static List<CutSimulationStep> Finish(List<CutSimulationStep> steps, Plate plate, CutParameters parameters)
|
||||
{
|
||||
NumberContours(steps);
|
||||
|
||||
if (parameters.SkipScribe && RemoveScribeContours(ref steps))
|
||||
NumberContours(steps);
|
||||
|
||||
ClassifyHoles(steps, plate);
|
||||
|
||||
var holeFeed = parameters.HoleFeedrate is > 0 ? parameters.HoleFeedrate.Value : parameters.Feedrate;
|
||||
foreach (var step in steps)
|
||||
{
|
||||
step.NominalSpeed = step.Kind switch
|
||||
{
|
||||
CutSimulationStepKind.Rapid => Speed(parameters.RapidTravelRate, parameters.Units),
|
||||
CutSimulationStepKind.Cut when step.IsHole => Speed(holeFeed, parameters.Units),
|
||||
CutSimulationStepKind.Pierce => 0,
|
||||
_ => Speed(parameters.Feedrate, parameters.Units),
|
||||
};
|
||||
step.Duration = step.Kind == CutSimulationStepKind.Pierce
|
||||
? parameters.PierceTime
|
||||
: step.NominalSpeed > 0 && step.Length > 0
|
||||
? TimeSpan.FromSeconds(step.Length / step.NominalSpeed)
|
||||
: TimeSpan.Zero;
|
||||
}
|
||||
|
||||
if (parameters.Motion != null)
|
||||
MotionPlanner.Plan(steps, parameters.Motion);
|
||||
|
||||
var clock = TimeSpan.Zero;
|
||||
foreach (var step in steps)
|
||||
{
|
||||
step.StartTime = clock;
|
||||
clock += step.Duration;
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
// Inch rates are per minute, millimeter rates per second, as Timing.CalculateTime reads them.
|
||||
private static double Speed(double rate, Units units) =>
|
||||
rate <= 0 ? 0 : units == Units.Millimeters ? rate : rate / 60;
|
||||
|
||||
/// <summary>
|
||||
/// A contour starts where the torch goes down after travel: at a pierce, at a scribe
|
||||
/// after travel, on a new part, or where cutting switches to scribing or back.
|
||||
/// </summary>
|
||||
private static void NumberContours(List<CutSimulationStep> steps)
|
||||
{
|
||||
var contour = -1;
|
||||
CutSimulationStep previous = null;
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (step.Kind == CutSimulationStepKind.Rapid)
|
||||
{
|
||||
step.ContourIndex = -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
var starts = previous == null
|
||||
|| previous.Kind == CutSimulationStepKind.Rapid
|
||||
|| step.Kind == CutSimulationStepKind.Pierce
|
||||
|| previous.PartIndex != step.PartIndex
|
||||
|| previous.Kind != CutSimulationStepKind.Pierce && previous.Kind != step.Kind;
|
||||
if (starts)
|
||||
contour++;
|
||||
step.ContourIndex = contour;
|
||||
}
|
||||
previous = step;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Leaves out contours that only scribe, as the CI Fiber post does by default, along with
|
||||
/// the travel to them; travel is then rejoined around the gap. True when any were removed.
|
||||
/// </summary>
|
||||
private static bool RemoveScribeContours(ref List<CutSimulationStep> steps)
|
||||
{
|
||||
var scribeOnly = steps
|
||||
.Where(s => s.ContourIndex >= 0)
|
||||
.GroupBy(s => s.ContourIndex)
|
||||
.Where(g => g.All(s => s.Kind == CutSimulationStepKind.Scribe))
|
||||
.Select(g => g.Key)
|
||||
.ToHashSet();
|
||||
if (scribeOnly.Count == 0)
|
||||
return false;
|
||||
|
||||
var kept = new List<CutSimulationStep>();
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (!scribeOnly.Contains(step.ContourIndex))
|
||||
{
|
||||
kept.Add(step);
|
||||
continue;
|
||||
}
|
||||
// Drop the travel that led to the skipped contour.
|
||||
while (kept.Count > 0 && kept[^1].Kind == CutSimulationStepKind.Rapid)
|
||||
kept.RemoveAt(kept.Count - 1);
|
||||
}
|
||||
|
||||
var joined = new List<CutSimulationStep>(kept.Count);
|
||||
var at = Vector.Zero;
|
||||
foreach (var step in kept)
|
||||
{
|
||||
if (step.Kind == CutSimulationStepKind.Rapid)
|
||||
{
|
||||
if (at.DistanceTo(step.End) > Math.Tolerance.Epsilon)
|
||||
joined.Add(Travel(at, step.End, step.PartIndex));
|
||||
at = step.End;
|
||||
continue;
|
||||
}
|
||||
if (at.DistanceTo(step.Start) > Math.Tolerance.Epsilon)
|
||||
joined.Add(Travel(at, step.Start, step.PartIndex));
|
||||
joined.Add(step);
|
||||
at = step.End;
|
||||
}
|
||||
|
||||
steps = joined;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static CutSimulationStep Travel(Vector from, Vector to, int partIndex) =>
|
||||
new()
|
||||
{
|
||||
Kind = CutSimulationStepKind.Rapid,
|
||||
PartIndex = partIndex,
|
||||
Start = from,
|
||||
End = to,
|
||||
Length = from.DistanceTo(to),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Matches the CI Fiber post's interior/exterior choice (L2/L4): a cut-off, or a closed
|
||||
/// contour wound clockwise, is exterior; everything else is a hole. Lead moves are
|
||||
/// left out of the winding, as the post samples from the lead-in's end.
|
||||
/// </summary>
|
||||
private static void ClassifyHoles(List<CutSimulationStep> steps, Plate plate)
|
||||
{
|
||||
foreach (var contour in steps.Where(s => s.ContourIndex >= 0).GroupBy(s => s.ContourIndex))
|
||||
{
|
||||
var moves = contour.Where(s => s.Kind == CutSimulationStepKind.Cut).ToList();
|
||||
if (moves.Count == 0)
|
||||
continue;
|
||||
|
||||
var partIndex = moves[0].PartIndex;
|
||||
var isCutOff = partIndex < plate.Parts.Count && plate.Parts[partIndex].BaseDrawing?.IsCutOff == true;
|
||||
var isHole = !isCutOff && !IsClosedClockwise(moves);
|
||||
foreach (var step in contour)
|
||||
step.IsHole = isHole && step.Kind != CutSimulationStepKind.Scribe;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsClosedClockwise(List<CutSimulationStep> moves)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
foreach (var move in moves.Where(m => m.Layer is not (LayerType.Leadin or LayerType.Leadout)))
|
||||
{
|
||||
if (points.Count == 0)
|
||||
points.Add(move.Start);
|
||||
if (move.IsArc)
|
||||
{
|
||||
var samples = System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Abs(move.Sweep) / (System.Math.PI / 2)));
|
||||
for (var i = 1; i < samples; i++)
|
||||
points.Add(move.PointAt((double)i / samples));
|
||||
}
|
||||
points.Add(move.End);
|
||||
}
|
||||
|
||||
if (points.Count < 3 || points[0].DistanceTo(points[^1]) > ClosedTolerance)
|
||||
return false;
|
||||
|
||||
var area = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
area += points[i].X * points[i + 1].Y - points[i + 1].X * points[i].Y;
|
||||
return area < 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public enum CutSimulationStepKind
|
||||
{
|
||||
/// <summary>Head-up travel: a rapid, a gap between programs, or an uncut (display/tab) move.</summary>
|
||||
Rapid,
|
||||
|
||||
/// <summary>The dwell before a cut that starts after travel.</summary>
|
||||
Pierce,
|
||||
|
||||
/// <summary>Torch-on motion through material (cut, lead-in/out, break).</summary>
|
||||
Cut,
|
||||
|
||||
/// <summary>Marking motion that does not cut through.</summary>
|
||||
Scribe,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One timed tool motion in plate coordinates. Arcs carry a signed sweep
|
||||
/// (positive counterclockwise); lines and pierces leave it zero.
|
||||
/// </summary>
|
||||
public sealed class CutSimulationStep
|
||||
{
|
||||
public CutSimulationStepKind Kind { get; init; }
|
||||
public int PartIndex { get; init; }
|
||||
public Vector Start { get; init; }
|
||||
public Vector End { get; init; }
|
||||
public bool IsArc { get; init; }
|
||||
public Vector Center { get; init; }
|
||||
public double Radius { get; init; }
|
||||
public double StartAngle { get; init; }
|
||||
public double Sweep { get; init; }
|
||||
public double Length { get; init; }
|
||||
|
||||
/// <summary>The program layer of a cutting or scribing move.</summary>
|
||||
public LayerType Layer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The contour this move or pierce belongs to, numbered in cutting order; -1 for travel.
|
||||
/// </summary>
|
||||
public int ContourIndex { get; internal set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// True when the move is part of an interior contour, timed at the hole feed. Follows the
|
||||
/// CI Fiber post's rule: only a closed clockwise contour (or a cut-off) is exterior.
|
||||
/// </summary>
|
||||
public bool IsHole { get; internal set; }
|
||||
|
||||
/// <summary>Programmed speed in units per second, before acceleration.</summary>
|
||||
public double NominalSpeed { get; internal set; }
|
||||
|
||||
public TimeSpan StartTime { get; internal set; }
|
||||
public TimeSpan Duration { get; internal set; }
|
||||
public TimeSpan EndTime => StartTime + Duration;
|
||||
|
||||
/// <summary>
|
||||
/// Planned speed profile in units per second: accelerate from <see cref="EntrySpeed"/>
|
||||
/// to <see cref="CruiseSpeed"/>, then decelerate to <see cref="ExitSpeed"/>. A zero
|
||||
/// <see cref="Acceleration"/> moves at constant speed for the whole step.
|
||||
/// </summary>
|
||||
public double EntrySpeed { get; internal set; }
|
||||
public double CruiseSpeed { get; internal set; }
|
||||
public double ExitSpeed { get; internal set; }
|
||||
public double Acceleration { get; internal set; }
|
||||
|
||||
/// <summary>Fraction (0..1) of this step's distance covered <paramref name="elapsed"/> into it.</summary>
|
||||
public double ProgressAt(TimeSpan elapsed)
|
||||
{
|
||||
if (Duration <= TimeSpan.Zero)
|
||||
return elapsed >= TimeSpan.Zero ? 1 : 0;
|
||||
if (Acceleration <= 0 || Length <= 0)
|
||||
return System.Math.Clamp(elapsed / Duration, 0, 1);
|
||||
return System.Math.Clamp(DistanceAt(elapsed.TotalSeconds) / Length, 0, 1);
|
||||
}
|
||||
|
||||
/// <summary>Head speed in units per second <paramref name="elapsed"/> into this step.</summary>
|
||||
public double SpeedAt(TimeSpan elapsed)
|
||||
{
|
||||
if (Kind == CutSimulationStepKind.Pierce || Duration <= TimeSpan.Zero)
|
||||
return 0;
|
||||
if (Acceleration <= 0)
|
||||
return Length / Duration.TotalSeconds;
|
||||
|
||||
var t = System.Math.Clamp(elapsed.TotalSeconds, 0, Duration.TotalSeconds);
|
||||
var (accelEnd, cruiseEnd) = PhaseEnds();
|
||||
if (t < accelEnd)
|
||||
return EntrySpeed + Acceleration * t;
|
||||
if (t < cruiseEnd)
|
||||
return CruiseSpeed;
|
||||
return System.Math.Max(0, CruiseSpeed - Acceleration * (t - cruiseEnd));
|
||||
}
|
||||
|
||||
private (double AccelEnd, double CruiseEnd) PhaseEnds()
|
||||
{
|
||||
var accelTime = (CruiseSpeed - EntrySpeed) / Acceleration;
|
||||
var accelDistance = (CruiseSpeed * CruiseSpeed - EntrySpeed * EntrySpeed) / (2 * Acceleration);
|
||||
var decelDistance = (CruiseSpeed * CruiseSpeed - ExitSpeed * ExitSpeed) / (2 * Acceleration);
|
||||
var cruise = CruiseSpeed > 0 ? System.Math.Max(0, Length - accelDistance - decelDistance) / CruiseSpeed : 0;
|
||||
return (accelTime, accelTime + cruise);
|
||||
}
|
||||
|
||||
private double DistanceAt(double t)
|
||||
{
|
||||
var (accelEnd, cruiseEnd) = PhaseEnds();
|
||||
var accelDistance = EntrySpeed * accelEnd + Acceleration * accelEnd * accelEnd / 2;
|
||||
if (t < accelEnd)
|
||||
return EntrySpeed * t + Acceleration * t * t / 2;
|
||||
if (t < cruiseEnd)
|
||||
return accelDistance + CruiseSpeed * (t - accelEnd);
|
||||
var decel = t - cruiseEnd;
|
||||
return accelDistance + CruiseSpeed * (cruiseEnd - accelEnd) + CruiseSpeed * decel - Acceleration * decel * decel / 2;
|
||||
}
|
||||
|
||||
/// <summary>Unit direction of travel at the start (0) or end (1) of the step.</summary>
|
||||
internal Vector TangentAt(double fraction)
|
||||
{
|
||||
if (IsArc)
|
||||
{
|
||||
var angle = StartAngle + Sweep * fraction;
|
||||
var sign = System.Math.Sign(Sweep);
|
||||
return new Vector(-System.Math.Sin(angle) * sign, System.Math.Cos(angle) * sign);
|
||||
}
|
||||
return Length > 0 ? new Vector((End.X - Start.X) / Length, (End.Y - Start.Y) / Length) : Vector.Zero;
|
||||
}
|
||||
|
||||
/// <summary>The tool position after <paramref name="fraction"/> (0..1) of this step.</summary>
|
||||
public Vector PointAt(double fraction)
|
||||
{
|
||||
fraction = System.Math.Clamp(fraction, 0, 1);
|
||||
|
||||
if (IsArc)
|
||||
{
|
||||
var angle = StartAngle + Sweep * fraction;
|
||||
return new Vector(
|
||||
Center.X + Radius * System.Math.Cos(angle),
|
||||
Center.Y + Radius * System.Math.Sin(angle));
|
||||
}
|
||||
|
||||
return new Vector(
|
||||
Start.X + (End.X - Start.X) * fraction,
|
||||
Start.Y + (End.Y - Start.Y) * fraction);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A replayable timeline of a plate's tool motion in cutting order. Cut and display-layer
|
||||
/// moves cut (as the posts emit them), at the hole or outside feed. Suppressed moves and,
|
||||
/// with <see cref="CutParameters.SkipScribe"/>, etch-only contours are left out. Adds
|
||||
/// <see cref="CutParameters.Motion"/> acceleration when set (see <see cref="MotionPlanner"/>).
|
||||
/// It does not model jerk, controller feed tables or clamp removal. See docs/cut-time-estimates.md.
|
||||
/// </summary>
|
||||
public sealed class CutSimulation
|
||||
{
|
||||
private readonly List<CutSimulationStep> steps;
|
||||
private readonly List<TimeSpan> contourStarts = new();
|
||||
private readonly List<TimeSpan> partStarts = new();
|
||||
|
||||
private CutSimulation(List<CutSimulationStep> steps, int partCount)
|
||||
{
|
||||
this.steps = steps;
|
||||
PartCount = partCount;
|
||||
TotalTime = steps.Count == 0 ? TimeSpan.Zero : steps[^1].EndTime;
|
||||
|
||||
// A contour (a cutout or a part's perimeter) begins where the torch goes down after travel.
|
||||
var lastPart = -1;
|
||||
var lastContour = -1;
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (step.ContourIndex < 0 || step.ContourIndex == lastContour)
|
||||
continue;
|
||||
lastContour = step.ContourIndex;
|
||||
|
||||
contourStarts.Add(step.StartTime);
|
||||
if (step.PartIndex != lastPart)
|
||||
{
|
||||
partStarts.Add(step.StartTime);
|
||||
lastPart = step.PartIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<CutSimulationStep> Steps => steps;
|
||||
public int PartCount { get; }
|
||||
public TimeSpan TotalTime { get; }
|
||||
|
||||
/// <summary>When each contour's first cut (or its pierce) starts, in cutting order.</summary>
|
||||
public IReadOnlyList<TimeSpan> ContourStarts => contourStarts;
|
||||
|
||||
/// <summary>When each part's first contour starts, in cutting order.</summary>
|
||||
public IReadOnlyList<TimeSpan> PartStarts => partStarts;
|
||||
|
||||
public TimeSpan NextContour(TimeSpan time) => Next(contourStarts, time);
|
||||
public TimeSpan PreviousContour(TimeSpan time) => Previous(contourStarts, time);
|
||||
public TimeSpan NextPart(TimeSpan time) => Next(partStarts, time);
|
||||
public TimeSpan PreviousPart(TimeSpan time) => Previous(partStarts, time);
|
||||
|
||||
// The first start after the playhead, or the end once none is left.
|
||||
private TimeSpan Next(List<TimeSpan> starts, TimeSpan time)
|
||||
{
|
||||
foreach (var start in starts)
|
||||
{
|
||||
if (start > time)
|
||||
return start;
|
||||
}
|
||||
return TotalTime;
|
||||
}
|
||||
|
||||
// Like a track's previous button: back to the start of the current item, or the one
|
||||
// before when the playhead is already there.
|
||||
private static TimeSpan Previous(List<TimeSpan> starts, TimeSpan time)
|
||||
{
|
||||
for (var i = starts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (starts[i] < time)
|
||||
return starts[i];
|
||||
}
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
public static CutSimulation Build(Plate plate, CutParameters parameters)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
ArgumentNullException.ThrowIfNull(parameters);
|
||||
|
||||
var builder = new Builder();
|
||||
|
||||
for (var i = 0; i < plate.Parts.Count; i++)
|
||||
{
|
||||
var part = plate.Parts[i];
|
||||
if (part.Program == null)
|
||||
continue;
|
||||
|
||||
builder.PartIndex = i;
|
||||
var mode = Mode.Absolute;
|
||||
var pos = part.Location;
|
||||
builder.AddProgram(DisplayPerimeterTail.WithoutTail(part.Program), part.Location, ref mode, ref pos);
|
||||
}
|
||||
|
||||
var steps = ContourTiming.Finish(builder.Steps, plate, parameters);
|
||||
return new CutSimulation(steps, plate.Parts.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Index of the step running at <paramref name="time"/>; <see cref="Steps"/>.Count
|
||||
/// once the timeline has finished, -1 when it is empty.
|
||||
/// </summary>
|
||||
public int IndexAt(TimeSpan time)
|
||||
{
|
||||
if (steps.Count == 0)
|
||||
return -1;
|
||||
if (time >= TotalTime)
|
||||
return steps.Count;
|
||||
|
||||
int lo = 0, hi = steps.Count - 1;
|
||||
while (lo < hi)
|
||||
{
|
||||
var mid = (lo + hi + 1) / 2;
|
||||
if (steps[mid].StartTime <= time)
|
||||
lo = mid;
|
||||
else
|
||||
hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fraction (0..1) of step <paramref name="index"/> completed at <paramref name="time"/>:
|
||||
/// distance for motion, elapsed time for a pierce.
|
||||
/// </summary>
|
||||
public double FractionAt(int index, TimeSpan time)
|
||||
{
|
||||
var step = steps[index];
|
||||
if (step.Kind == CutSimulationStepKind.Pierce)
|
||||
return step.Duration <= TimeSpan.Zero
|
||||
? time >= step.StartTime ? 1 : 0
|
||||
: System.Math.Clamp((time - step.StartTime) / step.Duration, 0, 1);
|
||||
return step.ProgressAt(time - step.StartTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When step <paramref name="index"/> is <paramref name="fraction"/> complete: the inverse
|
||||
/// of <see cref="FractionAt"/>, so a retimed timeline can find the same tool position.
|
||||
/// </summary>
|
||||
public TimeSpan TimeAt(int index, double fraction)
|
||||
{
|
||||
var step = steps[index];
|
||||
if (step.Duration <= TimeSpan.Zero || fraction <= 0)
|
||||
return step.StartTime;
|
||||
if (fraction >= 1)
|
||||
return step.EndTime;
|
||||
|
||||
// Progress never decreases through a step, so bisect its duration.
|
||||
long lo = 0, hi = step.Duration.Ticks;
|
||||
while (lo < hi)
|
||||
{
|
||||
var mid = lo + (hi - lo) / 2;
|
||||
if (step.ProgressAt(TimeSpan.FromTicks(mid)) < fraction)
|
||||
lo = mid + 1;
|
||||
else
|
||||
hi = mid;
|
||||
}
|
||||
return step.StartTime + TimeSpan.FromTicks(lo);
|
||||
}
|
||||
|
||||
public Vector ToolPositionAt(TimeSpan time)
|
||||
{
|
||||
var index = IndexAt(time);
|
||||
if (index < 0)
|
||||
return Vector.Zero;
|
||||
if (index >= steps.Count)
|
||||
return steps[^1].End;
|
||||
return steps[index].PointAt(FractionAt(index, time));
|
||||
}
|
||||
|
||||
private sealed class Builder
|
||||
{
|
||||
private Vector tool = Vector.Zero;
|
||||
private bool torchOn;
|
||||
|
||||
|
||||
public List<CutSimulationStep> Steps { get; } = new();
|
||||
public int PartIndex { get; set; }
|
||||
|
||||
// Sub-program offsets are relative to the calling frame's origin, as ConvertProgram reads them.
|
||||
public void AddProgram(Program program, Vector frameOrigin, ref Mode mode, ref Vector pos)
|
||||
{
|
||||
mode = program.Mode;
|
||||
|
||||
for (var i = 0; i < program.Length; i++)
|
||||
{
|
||||
switch (program[i])
|
||||
{
|
||||
case SubProgramCall { Program: { } sub } call:
|
||||
var savedMode = mode;
|
||||
var subOrigin = frameOrigin + call.Offset;
|
||||
pos = subOrigin;
|
||||
AddProgram(sub, subOrigin, ref mode, ref pos);
|
||||
mode = savedMode;
|
||||
break;
|
||||
|
||||
case RapidMove rapid:
|
||||
var rapidEnd = Resolve(rapid.EndPoint, mode, pos, frameOrigin);
|
||||
AddTravel(tool, rapidEnd);
|
||||
torchOn = false; // A rapid in place still ends the cut.
|
||||
pos = rapidEnd;
|
||||
break;
|
||||
|
||||
case Motion { Suppressed: true } suppressed:
|
||||
// Posts leave suppressed moves out; the next move starts from here.
|
||||
pos = Resolve(suppressed.EndPoint, mode, pos, frameOrigin);
|
||||
break;
|
||||
|
||||
case LinearMove linear:
|
||||
var lineEnd = Resolve(linear.EndPoint, mode, pos, frameOrigin);
|
||||
AddMotion(linear.Layer, pos, lineEnd, isArc: false, default, default);
|
||||
pos = lineEnd;
|
||||
break;
|
||||
|
||||
case ArcMove arc:
|
||||
var arcEnd = Resolve(arc.EndPoint, mode, pos, frameOrigin);
|
||||
var center = Resolve(arc.CenterPoint, mode, pos, frameOrigin);
|
||||
AddMotion(arc.Layer, pos, arcEnd, isArc: true, center, arc.Rotation);
|
||||
pos = arcEnd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector Resolve(Vector point, Mode mode, Vector pos, Vector frameOrigin) =>
|
||||
mode == Mode.Incremental ? point + pos : point + frameOrigin;
|
||||
|
||||
private static CutSimulationStepKind? KindOf(LayerType layer) =>
|
||||
layer switch
|
||||
{
|
||||
// Cutting strategies emit contour moves on Display; the posts cut them.
|
||||
LayerType.Cut or LayerType.Display or LayerType.Leadin or LayerType.Leadout or LayerType.Break
|
||||
=> CutSimulationStepKind.Cut,
|
||||
LayerType.Scribe => CutSimulationStepKind.Scribe,
|
||||
_ => null, // Tab spans are never cut.
|
||||
};
|
||||
|
||||
private void AddMotion(LayerType layer, Vector start, Vector end, bool isArc, Vector center, RotationType rotation)
|
||||
{
|
||||
if (KindOf(layer) is not { } kind)
|
||||
{
|
||||
AddTravel(tool, end);
|
||||
return;
|
||||
}
|
||||
|
||||
// A program that begins cutting away from the tool reaches its start head-up.
|
||||
AddTravel(tool, start);
|
||||
|
||||
if (!torchOn && kind == CutSimulationStepKind.Cut)
|
||||
{
|
||||
Add(new CutSimulationStep
|
||||
{
|
||||
Kind = CutSimulationStepKind.Pierce,
|
||||
PartIndex = PartIndex,
|
||||
Start = start,
|
||||
End = start,
|
||||
});
|
||||
}
|
||||
|
||||
torchOn = kind == CutSimulationStepKind.Cut;
|
||||
|
||||
if (isArc)
|
||||
AddArc(kind, layer, start, end, center, rotation);
|
||||
else
|
||||
Add(new CutSimulationStep
|
||||
{
|
||||
Kind = kind,
|
||||
Layer = layer,
|
||||
PartIndex = PartIndex,
|
||||
Start = start,
|
||||
End = end,
|
||||
Length = start.DistanceTo(end),
|
||||
});
|
||||
}
|
||||
|
||||
private void AddArc(CutSimulationStepKind kind, LayerType layer, Vector start, Vector end, Vector center, RotationType rotation)
|
||||
{
|
||||
var radius = center.DistanceTo(start);
|
||||
var startAngle = center.AngleTo(start);
|
||||
var endAngle = center.AngleTo(end);
|
||||
var ccw = rotation == RotationType.CCW;
|
||||
|
||||
// Coincident ends are a full circle, as ConvertProgram reads them.
|
||||
var sweep = ccw
|
||||
? Angle.NormalizeRad(endAngle - startAngle)
|
||||
: -Angle.NormalizeRad(startAngle - endAngle);
|
||||
if (System.Math.Abs(sweep) < Tolerance.Epsilon)
|
||||
sweep = ccw ? Angle.TwoPI : -Angle.TwoPI;
|
||||
|
||||
var length = System.Math.Abs(sweep) * radius;
|
||||
Add(new CutSimulationStep
|
||||
{
|
||||
Kind = kind,
|
||||
Layer = layer,
|
||||
PartIndex = PartIndex,
|
||||
Start = start,
|
||||
End = end,
|
||||
IsArc = true,
|
||||
Center = center,
|
||||
Radius = radius,
|
||||
StartAngle = startAngle,
|
||||
Sweep = sweep,
|
||||
Length = length,
|
||||
});
|
||||
}
|
||||
|
||||
private void AddTravel(Vector start, Vector end)
|
||||
{
|
||||
var distance = start.DistanceTo(end);
|
||||
if (distance <= Tolerance.Epsilon)
|
||||
{
|
||||
tool = end;
|
||||
return;
|
||||
}
|
||||
|
||||
torchOn = false;
|
||||
|
||||
Add(new CutSimulationStep
|
||||
{
|
||||
Kind = CutSimulationStepKind.Rapid,
|
||||
PartIndex = PartIndex,
|
||||
Start = start,
|
||||
End = end,
|
||||
Length = distance,
|
||||
});
|
||||
}
|
||||
|
||||
private void Add(CutSimulationStep step)
|
||||
{
|
||||
tool = step.End;
|
||||
Steps.Add(step);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Which motions of an execution cut a break line: its lead-in and its break moves.</summary>
|
||||
internal static class BreakCutMotions
|
||||
{
|
||||
/// <summary>True for every <see cref="LayerType.Break"/> motion and the lead-in run directly before one.</summary>
|
||||
internal static bool[] Mask(IReadOnlyList<ExecutionMotion> motions)
|
||||
{
|
||||
var mask = new bool[motions.Count];
|
||||
for (var i = 0; i < motions.Count; i++)
|
||||
{
|
||||
if (motions[i].Rapid || motions[i].Layer != LayerType.Break)
|
||||
continue;
|
||||
mask[i] = true;
|
||||
for (var lead = i - 1; lead >= 0 && !motions[lead].Rapid && motions[lead].Layer == LayerType.Leadin; lead--)
|
||||
mask[lead] = true;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,38 @@ internal enum AutomaticEntryKind
|
||||
|
||||
/// <summary>Tier 3 (fallback): one of the eight compass points of a whole circle.</summary>
|
||||
CircleCompass = 6,
|
||||
|
||||
/// <summary>A drawing pin's own entry: the one and only candidate of a pinned contour.</summary>
|
||||
Pinned = 7,
|
||||
|
||||
/// <summary>
|
||||
/// The perimeter point where a tab best keeps the part level on its support bed; only a
|
||||
/// perimeter tabbed for support has it (see <see cref="PreparedContours.WithSupportTab"/>).
|
||||
/// </summary>
|
||||
SupportTab = 8,
|
||||
|
||||
/// <summary>
|
||||
/// A cutout entry pierced where eroding the cutout leaves room (see
|
||||
/// <see cref="PreparedContours.ErodedPierceEntries"/>): only offered once no configured
|
||||
/// lead-in of the cutout fits.
|
||||
/// </summary>
|
||||
ErodedPierce = 9,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which automatic entries a perimeter tabbed for support offers (see
|
||||
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalogue.
|
||||
/// </summary>
|
||||
internal enum SupportEntries
|
||||
{
|
||||
/// <summary>Entries whose tab steadies the part, clear of inside corners, plus its best tab point.</summary>
|
||||
Steadying,
|
||||
|
||||
/// <summary>Every entry clear of inside corners: the fallback when no steadying entry fits.</summary>
|
||||
ClearOfInsideCorners,
|
||||
|
||||
/// <summary>Every entry: the last fallback, when nothing clear of inside corners fits.</summary>
|
||||
Any,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -47,6 +47,7 @@ internal static class ContourProgramVerifier
|
||||
if (material == null || !material.IsComplete) return false;
|
||||
if (selected != null && !SameEmission(actual, selected.Expected, token, ref budget)) return false;
|
||||
var visited = new HashSet<int>();
|
||||
var piecesCut = new Dictionary<int, HashSet<int>>();
|
||||
foreach (var run in Runs(actual).Where(r => !r.Rapid && r.Kind == LayerType.Cut))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
@@ -54,41 +55,87 @@ internal static class ContourProgramVerifier
|
||||
var first = run.Curves[0];
|
||||
var ringIndex = -1;
|
||||
var entityIndex = -1;
|
||||
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
|
||||
for (var e = 0; e < material.Rings[r].Length; e++)
|
||||
// The admitted slack is far below any extent margin, so only boundaries near the run's
|
||||
// start can contain it; they come in ring order, as the full scan met them.
|
||||
foreach (var (r, e) in material.PositionsNear(new(first.Start.X, first.Start.Y, first.Start.X, first.Start.Y)))
|
||||
{
|
||||
Query(token, ref budget);
|
||||
var admitted = AdmittedClosureSlack(material.Rings[r]);
|
||||
var curve = material.Rings[r][e];
|
||||
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
|
||||
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
||||
{
|
||||
Query(token, ref budget);
|
||||
var curve = material.Rings[r][e];
|
||||
if (curve.SameDirection(first) && curve.Contains(first.Start)
|
||||
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
||||
{
|
||||
ringIndex = r;
|
||||
entityIndex = e;
|
||||
break;
|
||||
}
|
||||
ringIndex = r;
|
||||
entityIndex = e;
|
||||
break;
|
||||
}
|
||||
if (ringIndex < 0 || !visited.Add(ringIndex)) return false;
|
||||
}
|
||||
if (ringIndex < 0) return false;
|
||||
// A display perimeter is never cut: any run along it is not this plan.
|
||||
if (ringIndex == 0 && material.HasDisplayPerimeter) return false;
|
||||
var tabs = material.Tabs[ringIndex];
|
||||
if (tabs.Any(tab => tab))
|
||||
{
|
||||
// A tabbed cutout: each run cuts exactly one span between tabs, whole and once.
|
||||
if (!CutsTabbedSpan(run.Curves, material.Rings[ringIndex], tabs, entityIndex, token, ref budget)
|
||||
|| !(piecesCut.TryGetValue(ringIndex, out var cut) ? cut : piecesCut[ringIndex] = new()).Add(entityIndex))
|
||||
return false;
|
||||
if (piecesCut[ringIndex].Count == SpanStarts(tabs).Count)
|
||||
visited.Add(ringIndex);
|
||||
continue;
|
||||
}
|
||||
if (!visited.Add(ringIndex)) return false;
|
||||
var ring = material.Rings[ringIndex];
|
||||
// Capture admits a nominal ring that closes within NominalContourClosure.Tolerance, and
|
||||
// preparation agrees at that inclusive boundary. A rotated tool path bridges that one
|
||||
// admitted joint, so this ring's positional and length agreement carries exactly that
|
||||
// admitted slack, whether bridging it shortens or lengthens the cut.
|
||||
var tolerance = AdmittedClosureSlack(ring);
|
||||
var nominalLength = ring.Sum(c => c.Length);
|
||||
var cutLength = run.Curves.Sum(c => c.Length);
|
||||
var missing = nominalLength - cutLength;
|
||||
if (missing < -Epsilon) return false; // Never a second circuit/retrace.
|
||||
if (missing > Epsilon)
|
||||
// Bridging the admitted joint may add up to its gap; a second circuit/retrace never passes.
|
||||
if (missing < -tolerance) return false;
|
||||
if (missing > tolerance)
|
||||
{
|
||||
// Fixed legacy payloads have no certified selected tab/entry metadata.
|
||||
// A merely open contour or a caller's current tab switch proves nothing.
|
||||
if (selected == null || ringIndex != 0 || selected.TabSize <= 0
|
||||
|| System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > Epsilon)
|
||||
|| System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > tolerance)
|
||||
return false;
|
||||
// SameEmission has already bound this exact terminal gap to the owned
|
||||
// settings' independently emitted, rounded/clamped selected geometry.
|
||||
}
|
||||
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
|
||||
true, token, ref budget)) return false;
|
||||
true, tolerance, token, ref budget)) return false;
|
||||
}
|
||||
return visited.Count == material.Rings.Count;
|
||||
return visited.Count == material.Rings.Count - (material.HasDisplayPerimeter ? 1 : 0);
|
||||
}
|
||||
|
||||
// Indexes of the curves that start a span between tabs: uncut-to-cut transitions.
|
||||
private static List<int> SpanStarts(bool[] tabs) => Enumerable.Range(0, tabs.Length)
|
||||
.Where(i => !tabs[i] && tabs[(i + tabs.Length - 1) % tabs.Length]).ToList();
|
||||
|
||||
// A run that starts exactly where a span between tabs starts and covers that span exactly,
|
||||
// stopping at the next tab.
|
||||
private static bool CutsTabbedSpan(IReadOnlyList<Curve> run, IReadOnlyList<Curve> ring, bool[] tabs,
|
||||
int entityIndex, CancellationToken token, ref int budget)
|
||||
{
|
||||
var tolerance = AdmittedClosureSlack(ring);
|
||||
if (!SpanStarts(tabs).Contains(entityIndex) || ring[entityIndex].DistanceAlong(run[0].Start) > tolerance)
|
||||
return false;
|
||||
var span = new List<Curve>();
|
||||
for (var e = entityIndex; !tabs[e % tabs.Length]; e++)
|
||||
span.Add(ring[e % tabs.Length]);
|
||||
return System.Math.Abs(run.Sum(c => c.Length) - span.Sum(c => c.Length)) <= tolerance
|
||||
&& Follows(run, span, 0, 0, false, tolerance, token, ref budget);
|
||||
}
|
||||
|
||||
// Zero for an exactly closed ring: a ring may lawfully close within the nominal closure
|
||||
// tolerance, and only that admitted endpoint gap relaxes replay's positional agreement.
|
||||
private static double AdmittedClosureSlack(IReadOnlyList<Curve> ring) => ring.Count == 0 ? Epsilon
|
||||
: Epsilon + System.Math.Max(0.0, ring[^1].End.DistanceTo(ring[0].Start));
|
||||
|
||||
private static bool SameEmission(OwnedExecution actual, OwnedExecution expected,
|
||||
CancellationToken token, ref int budget)
|
||||
{
|
||||
@@ -107,20 +154,24 @@ internal static class ContourProgramVerifier
|
||||
if (a.End.DistanceTo(b.End) > Epsilon) return false;
|
||||
}
|
||||
else if (System.Math.Abs(a.Curves.Sum(c => c.Length) - b.Curves.Sum(c => c.Length)) > Epsilon
|
||||
|| !Follows(a.Curves, b.Curves, 0, 0, false, token, ref budget)) return false;
|
||||
|| !Follows(a.Curves, b.Curves, 0, 0, false, Epsilon, token, ref budget)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Consume directed native arc-length from both streams. Subdivision, merged
|
||||
// collinear moves and a cyclic ring's reindexing do not change coverage.
|
||||
// `tolerance` is the admitted positional slack for this pair of streams (the exact epsilon
|
||||
// unless the nominal ring closes only within the nominal closure tolerance).
|
||||
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
|
||||
int index, double offset, bool cyclic, CancellationToken token, ref int budget)
|
||||
int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget)
|
||||
{
|
||||
foreach (var curve in actual)
|
||||
{
|
||||
var consumed = 0.0;
|
||||
while (consumed < curve.Length - Epsilon)
|
||||
// Like the nominal side below, a remainder within the slack is consumed: a path that
|
||||
// bridges the admitted joint leaves exactly that much on the edge it extends.
|
||||
while (consumed < curve.Length - tolerance)
|
||||
{
|
||||
Query(token, ref budget);
|
||||
if (index >= expected.Count)
|
||||
@@ -137,13 +188,13 @@ internal static class ContourProgramVerifier
|
||||
continue;
|
||||
}
|
||||
var length = System.Math.Min(remaining, curve.Length - consumed);
|
||||
if (!nominal.SameDirection(curve)
|
||||
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > Epsilon
|
||||
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > Epsilon)
|
||||
if (!nominal.SameDirection(curve, tolerance)
|
||||
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > tolerance
|
||||
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > tolerance)
|
||||
return false;
|
||||
consumed += length;
|
||||
offset += length;
|
||||
if (offset >= nominal.Length - Epsilon)
|
||||
if (offset >= nominal.Length - tolerance)
|
||||
{
|
||||
index++;
|
||||
offset = 0;
|
||||
@@ -151,7 +202,7 @@ internal static class ContourProgramVerifier
|
||||
}
|
||||
}
|
||||
return cyclic || index == expected.Count || index == expected.Count - 1
|
||||
&& expected[index].Length - offset <= Epsilon;
|
||||
&& expected[index].Length - offset <= tolerance;
|
||||
}
|
||||
|
||||
private static List<Run> Runs(OwnedExecution execution)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
public enum CutPinStatus
|
||||
{
|
||||
/// <summary>The pin lies on exactly one contour, which no other pin claims.</summary>
|
||||
Resolved,
|
||||
|
||||
/// <summary>The pin no longer lies on any contour of the drawing.</summary>
|
||||
NotOnContour,
|
||||
|
||||
/// <summary>The pin lies on more than one contour, so which one it pins is unknown.</summary>
|
||||
OnSeveralContours,
|
||||
|
||||
/// <summary>Another pin lies on the same contour; neither is used.</summary>
|
||||
ContourPinnedTwice,
|
||||
|
||||
/// <summary>The drawing's program cannot be prepared, or is not in the frame its source offset describes.</summary>
|
||||
UnsupportedGeometry,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where one drawing pin lands on a placed contour. <see cref="Point"/> is in the placed
|
||||
/// part's local frame (add the part's location for plate coordinates): the exact native point
|
||||
/// when resolved, otherwise where the pin maps to (<see cref="Vector.Invalid"/> when the drawing
|
||||
/// frame is unknown). Ordinals match <see cref="PreparedContours"/> captured from the same
|
||||
/// rotated clean program.
|
||||
/// </summary>
|
||||
public sealed record CutPinResolution(int PinIndex, ContourCutPin Pin, CutPinStatus Status, int ContourOrdinal,
|
||||
int EntityOrdinal, Vector Point)
|
||||
{
|
||||
public bool IsResolved => Status == CutPinStatus.Resolved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a drawing's source-frame pins onto its program contours at a placement rotation. A
|
||||
/// pin never resolves by guessing: it must lie within the contour closure tolerance of exactly
|
||||
/// one contour that no other pin claims, or it is reported unresolved with the reason.
|
||||
/// </summary>
|
||||
public static class CutPinResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the drawing's pins for a part rotated by <paramref name="rotation"/> radians
|
||||
/// about the drawing program's origin, as <see cref="Part.Rotate(double)"/> does.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<CutPinResolution> Resolve(Drawing drawing, double rotation, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(drawing);
|
||||
if (!double.IsFinite(rotation))
|
||||
throw new ArgumentOutOfRangeException(nameof(rotation), rotation, "Rotation must be finite.");
|
||||
var pins = drawing.CutPins ?? new List<ContourCutPin>();
|
||||
if (pins.Count == 0)
|
||||
return Array.Empty<CutPinResolution>();
|
||||
if (drawing.Program == null || !IsSourceFrame(drawing))
|
||||
return Unsupported(pins);
|
||||
|
||||
var program = (Program)drawing.Program.Clone();
|
||||
if (!Math.Tolerance.IsEqualTo(rotation, 0))
|
||||
program.Rotate(rotation);
|
||||
|
||||
PreparedContours contours;
|
||||
try
|
||||
{
|
||||
contours = PreparedContours.Capture(program, new CuttingParameters(), token);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException)
|
||||
{
|
||||
return Unsupported(pins);
|
||||
}
|
||||
|
||||
return Resolve(drawing, rotation, contours, token);
|
||||
}
|
||||
|
||||
/// <summary>Resolves the pins of a placed part at its current rotation.</summary>
|
||||
public static IReadOnlyList<CutPinResolution> Resolve(Part part, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(part);
|
||||
return Resolve(part.BaseDrawing, part.Rotation, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves against contours the caller already captured. <paramref name="contours"/> must
|
||||
/// come from the drawing's clean program rotated by <paramref name="rotation"/>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<CutPinResolution> Resolve(Drawing drawing, double rotation, PreparedContours contours,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(drawing);
|
||||
ArgumentNullException.ThrowIfNull(contours);
|
||||
if (!double.IsFinite(rotation))
|
||||
throw new ArgumentOutOfRangeException(nameof(rotation), rotation, "Rotation must be finite.");
|
||||
var pins = drawing.CutPins ?? new List<ContourCutPin>();
|
||||
if (pins.Count == 0)
|
||||
return Array.Empty<CutPinResolution>();
|
||||
if (!IsSourceFrame(drawing))
|
||||
return Unsupported(pins);
|
||||
|
||||
var offset = drawing.Source?.Offset ?? Vector.Zero;
|
||||
var results = new CutPinResolution[pins.Count];
|
||||
for (var i = 0; i < pins.Count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var pin = pins[i] ?? throw new ArgumentException($"Drawing pin {i + 1} is missing.", nameof(drawing));
|
||||
var local = (pin.Point - offset).Rotate(rotation);
|
||||
var near = contours.ContoursNear(local, NominalContourClosure.Tolerance);
|
||||
results[i] = near.Count switch
|
||||
{
|
||||
0 => new(i, pin, CutPinStatus.NotOnContour, -1, -1, local),
|
||||
1 => new(i, pin, CutPinStatus.Resolved, near[0].ContourOrdinal, near[0].EntityOrdinal, near[0].Point),
|
||||
_ => new(i, pin, CutPinStatus.OnSeveralContours, -1, -1, local),
|
||||
};
|
||||
}
|
||||
|
||||
// At most one pin per contour. Choosing between two would be a guess, so neither is used.
|
||||
foreach (var claimed in results.Where(r => r.IsResolved).GroupBy(r => r.ContourOrdinal).Where(g => g.Count() > 1))
|
||||
foreach (var result in claimed)
|
||||
results[result.PinIndex] = result with { Status = CutPinStatus.ContourPinnedTwice };
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Pins are stored relative to the source entities; the program is that frame moved by
|
||||
// -Source.Offset and never rotated in place. A rotated program (a canonical copy) is not.
|
||||
private static bool IsSourceFrame(Drawing drawing) =>
|
||||
drawing.Program != null && Math.Tolerance.IsEqualTo(drawing.Program.Rotation, 0);
|
||||
|
||||
private static CutPinResolution[] Unsupported(List<ContourCutPin> pins) =>
|
||||
pins.Select((pin, i) => new CutPinResolution(i, pin, CutPinStatus.UnsupportedGeometry, -1, -1, Vector.Invalid))
|
||||
.ToArray();
|
||||
}
|
||||
@@ -22,20 +22,39 @@ internal sealed class PlannedPartProgram
|
||||
public CuttingParameters Parameters { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An owned cutoff segment program cut the other way at the far sheet edge
|
||||
/// (<see cref="CutOff.ReverseAtFarEdge"/>); Apply installs it and records the direction on the
|
||||
/// segment's cutoff definition, so regeneration keeps it.
|
||||
/// </summary>
|
||||
internal sealed class PlannedCutOffProgram
|
||||
{
|
||||
public PlannedCutOffProgram(Part segment, Program program)
|
||||
{
|
||||
Segment = segment;
|
||||
Program = program;
|
||||
}
|
||||
|
||||
public Part Segment { get; }
|
||||
public Program Program { get; }
|
||||
}
|
||||
|
||||
/// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
|
||||
internal sealed class PlateCuttingPlan
|
||||
{
|
||||
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
|
||||
IEnumerable<PlannedPartProgram> programs = null)
|
||||
IEnumerable<PlannedPartProgram> programs = null, IEnumerable<PlannedCutOffProgram> cutOffs = null)
|
||||
{
|
||||
Expected = expected;
|
||||
Order = order == null ? null : Array.AsReadOnly(order.ToArray());
|
||||
Programs = Array.AsReadOnly((programs ?? []).ToArray());
|
||||
CutOffs = Array.AsReadOnly((cutOffs ?? []).ToArray());
|
||||
}
|
||||
|
||||
public PlateCuttingState Expected { get; }
|
||||
public IReadOnlyList<Part> Order { get; }
|
||||
public IReadOnlyList<PlannedPartProgram> Programs { get; }
|
||||
public IReadOnlyList<PlannedCutOffProgram> CutOffs { get; }
|
||||
}
|
||||
|
||||
public enum CuttingCommitStatus
|
||||
@@ -161,27 +180,49 @@ internal static class CuttingPlanCommit
|
||||
}
|
||||
programs.Add((planned.Part, planned.Program, bounds, planned.Parameters));
|
||||
}
|
||||
var cutOffs = new List<(Part, Program, CutOff)>();
|
||||
foreach (var planned in plan.CutOffs)
|
||||
{
|
||||
if (planned?.Segment == null || !onPlate.Contains(planned.Segment) || !targets.Add(planned.Segment))
|
||||
return Invalid(plate, "Planned programs must target distinct parts of their own plate.");
|
||||
var definition = plate.CutOffs.FirstOrDefault(c => ReferenceEquals(c?.Drawing, planned.Segment.BaseDrawing));
|
||||
// Only the placed far-edge segment, cut exactly the other way, may be installed.
|
||||
if (definition == null || planned.Program == null || live.Contains(planned.Program)
|
||||
|| !installed.Add(planned.Program)
|
||||
|| CutOff.ReverseAtFarEdge(planned.Segment.Program) is not { } expected
|
||||
|| !ProgramContent.Equal(expected, planned.Program, token))
|
||||
return Invalid(plate, "A cutoff segment can only be installed cut the other way at the far edge.");
|
||||
cutOffs.Add((planned.Segment, planned.Program, definition));
|
||||
}
|
||||
var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
|
||||
staged.Add(new(plate, order, programs, reordered || programs.Count != 0));
|
||||
staged.Add(new(plate, order, programs, cutOffs, reordered || programs.Count != 0 || cutOffs.Count != 0));
|
||||
}
|
||||
|
||||
// Last cancellation point. The boundary below has no await, search or geometry work.
|
||||
if (token.IsCancellationRequested)
|
||||
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
||||
|
||||
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States)>();
|
||||
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States,
|
||||
(CutOff CutOff, bool FarEdgeFirst, Program Program)[] CutOffs)>();
|
||||
try
|
||||
{
|
||||
foreach (var item in staged)
|
||||
{
|
||||
undo.Add((item.Plate, item.Plate.Parts.ToArray(),
|
||||
item.Programs.Select(p => (p.Part, p.Part.CaptureCuttingState())).ToArray()));
|
||||
item.Programs.Select(p => p.Part).Concat(item.CutOffs.Select(c => c.Segment))
|
||||
.Select(part => (part, part.CaptureCuttingState())).ToArray(),
|
||||
item.CutOffs.Select(c => (c.CutOff, c.CutOff.FarEdgeFirst, c.CutOff.Drawing.Program)).ToArray()));
|
||||
item.Plate.Parts.SetOrder(item.Order);
|
||||
foreach (var (part, program, bounds, parameters) in item.Programs)
|
||||
{
|
||||
beforeInstall?.Invoke(item.Plate, part);
|
||||
part.InstallPlannedProgram(program, bounds, parameters);
|
||||
}
|
||||
foreach (var (segment, program, cutOff) in item.CutOffs)
|
||||
{
|
||||
beforeInstall?.Invoke(item.Plate, segment);
|
||||
cutOff.InstallFarEdgeSegment(segment, program);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -190,6 +231,11 @@ internal static class CuttingPlanCommit
|
||||
{
|
||||
foreach (var (part, state) in undo[i].States)
|
||||
part.RestoreCuttingState(state);
|
||||
foreach (var (cutOff, farEdgeFirst, program) in undo[i].CutOffs)
|
||||
{
|
||||
cutOff.FarEdgeFirst = farEdgeFirst;
|
||||
cutOff.Drawing.Program = program;
|
||||
}
|
||||
undo[i].Plate.Parts.SetOrder(undo[i].Order);
|
||||
}
|
||||
return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
|
||||
@@ -208,5 +254,6 @@ internal static class CuttingPlanCommit
|
||||
new(CuttingCommitStatus.InvalidInput, message, plate);
|
||||
|
||||
private sealed record Staged(Plate Plate, Part[] Order,
|
||||
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs, bool Changed);
|
||||
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs,
|
||||
List<(Part Segment, Program Program, CutOff CutOff)> CutOffs, bool Changed);
|
||||
}
|
||||
@@ -48,6 +48,15 @@ public static class ExecutionMotionReader
|
||||
throw new ArgumentException("Program expansion exceeds the verification limit.");
|
||||
if (code == null)
|
||||
throw new ArgumentException("Program contains a missing instruction.");
|
||||
// A display perimeter's tail is never executed: an applied program ends at its marker.
|
||||
// Only a well-formed tail at the end of the part's own program may end the read.
|
||||
if (code is Comment { Value: DisplayPerimeterTail.Marker })
|
||||
{
|
||||
if (!ReferenceEquals(current, program))
|
||||
throw new ArgumentException("A display perimeter tail can only end the part's own program, not a sub-program.");
|
||||
DisplayPerimeterTail.IndexOf(current);
|
||||
break;
|
||||
}
|
||||
if (requireSupportedTypes)
|
||||
{
|
||||
var type = code.GetType();
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-post review of fixed emissions. Plan Cutting accepts a locked program's leads and a
|
||||
/// drawing-pinned contour's leads as given and warns (D1a), so posting checks them again against
|
||||
/// every part's clean material on the plate and the active clamps. It also reports a part with
|
||||
/// active drawing pins whose program does not cut its pinned contours as pinned. Reports only;
|
||||
/// nothing is repaired and an incomplete check is never a clear result.
|
||||
/// </summary>
|
||||
internal static class FixedLeadReview
|
||||
{
|
||||
/// <param name="fixedMotions">Receives, per reviewed part index, which of its placed program's
|
||||
/// motions this review owns, so other lead checks do not report them twice.</param>
|
||||
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
|
||||
IReadOnlyList<ClampZone> clamps, CancellationToken token,
|
||||
IDictionary<int, Func<int, bool>> fixedMotions = null)
|
||||
{
|
||||
var parts = plate.Parts;
|
||||
var reviewed = Enumerable.Range(0, parts.Count)
|
||||
.Where(i => parts[i] is { BaseDrawing.IsCutOff: false } part
|
||||
&& (part.LeadInsLocked || part.HasActiveDrawingPins)).ToArray();
|
||||
if (reviewed.Length == 0)
|
||||
return;
|
||||
var materials = new LeadMaterialSnapshot[parts.Count];
|
||||
var uncaptured = new List<int>();
|
||||
for (var index = 0; index < parts.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var part = parts[index];
|
||||
if (part?.BaseDrawing?.Program == null || part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
// Scribe-only drawings are marks, not material.
|
||||
if (!ExecutionMotionReader.Read(part.BaseDrawing.Program, Vector.Zero, null, token).Motions
|
||||
.Any(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display))
|
||||
continue;
|
||||
materials[index] = LeadMaterialSnapshot.Capture(Clean(part), part.Location, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
// The overlap and rapid checks already report unreadable programs.
|
||||
}
|
||||
if (materials[index] is not { IsComplete: true })
|
||||
uncaptured.Add(index);
|
||||
}
|
||||
var others = materials.Select((material, index) => (Material: material, Index: index))
|
||||
.Where(m => m.Material is { IsComplete: true }).ToArray();
|
||||
// Leads near material that could not be captured are not checked: say so once per plate.
|
||||
foreach (var index in uncaptured)
|
||||
findings.Add(Incomplete(index, (materials[index]?.Reason ?? "the clean material could not be captured.")
|
||||
+ " Fixed leads near this part are not checked."));
|
||||
foreach (var index in reviewed)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var part = parts[index];
|
||||
OwnedExecution execution;
|
||||
try
|
||||
{
|
||||
execution = ExecutionMotionReader.Read(part.Program, part.Location, null, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
continue; // The rapid check reports the unreadable program.
|
||||
}
|
||||
// Which motions are fixed: a locked program's every motion; otherwise the motions
|
||||
// that cut each active pin's contour exactly as pinned. Anything else was planned.
|
||||
int?[] pins = null;
|
||||
if (part.LeadInsLocked)
|
||||
{
|
||||
if (part.HasActiveDrawingPins)
|
||||
NotApplied(index, "the part's locked lead-ins override them. Use Plate > Remove Lead-ins, "
|
||||
+ "then Plan Cutting, to apply the pins.");
|
||||
}
|
||||
else
|
||||
{
|
||||
pins = PinnedMotions(plate, part, execution, reason => NotApplied(index, reason), token);
|
||||
if (pins == null || pins.All(p => p == null))
|
||||
continue;
|
||||
}
|
||||
bool IsFixed(int motion) => pins == null || pins[motion] != null;
|
||||
if (fixedMotions != null)
|
||||
fixedMotions[index] = IsFixed;
|
||||
if (materials[index] is { IsComplete: true } own)
|
||||
{
|
||||
var failures = new List<FixedLeadFailure>();
|
||||
var result = LeadPathValidator.Check(execution, own, others.Select(o => o.Material).ToArray(),
|
||||
IsFixed, failures, token);
|
||||
if (!result.IsComplete)
|
||||
findings.Add(Incomplete(index, result.Reason));
|
||||
foreach (var failure in failures)
|
||||
{
|
||||
var obstacle = failure.Obstacle is int other ? others[other].Index + 1 : (int?)null;
|
||||
var what = !failure.Result.IsComplete ? $"could not be checked ({failure.Result.Reason})"
|
||||
: obstacle != null ? $"contacts or enters the material of part {obstacle}"
|
||||
: "contacts or enters its own part's material";
|
||||
Lead(index, execution, failure.Motion, pins?[failure.Motion], what, obstacle, null, null);
|
||||
}
|
||||
}
|
||||
for (var motion = 0; motion < execution.Motions.Count && clamps is { Count: > 0 }; motion++)
|
||||
{
|
||||
var move = execution.Motions[motion];
|
||||
if (!IsFixed(motion) || move.Rapid || move.Start is not { } from
|
||||
|| move.Layer is not (LayerType.Leadin or LayerType.Leadout))
|
||||
continue;
|
||||
foreach (var zone in clamps)
|
||||
if (PostVerificationAnalyzer.CutTouches(move, from, zone.Bounds, out var contact))
|
||||
Lead(index, execution, motion, pins?[motion], $"touches active clamp '{zone.Id}'", null,
|
||||
zone.Id, contact);
|
||||
}
|
||||
}
|
||||
|
||||
void Lead(int index, OwnedExecution execution, int motion, int? pin, string what, int? obstacle,
|
||||
string clampId, Vector? contact)
|
||||
{
|
||||
var lead = pin is int number ? $"Drawing pin {number} lead" : "Locked lead";
|
||||
var move = execution.Motions[motion];
|
||||
findings.Add(new(PostVerificationKind.FixedLead, plateNumber, index + 1, obstacle,
|
||||
$"{lead} on cutting contour {ContourAt(execution, motion)} {what}. "
|
||||
+ "Planning kept it as given; review it before posting.")
|
||||
{ Location = contact ?? move.Curve?.Midpoint ?? move.End, ClampId = clampId });
|
||||
}
|
||||
|
||||
void NotApplied(int index, string reason) => findings.Add(new(PostVerificationKind.FixedLead,
|
||||
plateNumber, index + 1, null, "Drawing pins not applied: " + reason));
|
||||
|
||||
PostVerificationFinding Incomplete(int index, string reason) =>
|
||||
new(PostVerificationKind.Incomplete, plateNumber, index + 1, null, "Fixed lead check: " + reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For each motion of an unlocked part's placed program, the one-based pin whose contour it
|
||||
/// cuts exactly as pinned, found by re-emitting each pinned contour with the part's settings
|
||||
/// and matching that emission motion for motion; null where none. Each pin that cannot be
|
||||
/// found, or does not resolve, is reported through <paramref name="notApplied"/>.
|
||||
/// </summary>
|
||||
private static int?[] PinnedMotions(Plate plate, Part part, OwnedExecution execution,
|
||||
Action<string> notApplied, CancellationToken token)
|
||||
{
|
||||
var parameters = part.CuttingParameters ?? plate.CuttingParameters ?? new CuttingParameters();
|
||||
PreparedContours pinned;
|
||||
IReadOnlyList<CutPinResolution> resolutions;
|
||||
try
|
||||
{
|
||||
var clean = Clean(part);
|
||||
var plain = PreparedContours.Capture(clean, parameters, token);
|
||||
resolutions = CutPinResolver.Resolve(part.BaseDrawing, part.Rotation, plain, token);
|
||||
pinned = PreparedContours.Capture(clean, parameters, resolutions, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
notApplied($"the drawing pins could not be checked ({exception.Message}).");
|
||||
return null;
|
||||
}
|
||||
foreach (var resolution in resolutions.Where(r => !r.IsResolved))
|
||||
notApplied($"drawing pin {resolution.PinIndex + 1} no longer matches the geometry.");
|
||||
foreach (var rejected in pinned.RejectedPins)
|
||||
notApplied($"drawing pin {rejected.PinIndex + 1} puts a tab on an internal contour.");
|
||||
var owned = new int?[execution.Motions.Count];
|
||||
for (var contour = 0; contour < pinned.Count; contour++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (pinned.PinnedEntry(contour) is not { } entry)
|
||||
continue;
|
||||
var number = pinned.PinNumberOf(contour);
|
||||
var expected = ExecutionMotionReader.Read(pinned.EmitCandidateForValidation(entry), part.Location, null, token)
|
||||
.Motions.Where(m => !m.Rapid && m.Layer != LayerType.Scribe).ToArray();
|
||||
var at = Find(execution.Motions, expected);
|
||||
if (at < 0)
|
||||
{
|
||||
notApplied($"drawing pin {number} is not cut as pinned; plan cutting to apply it.");
|
||||
continue;
|
||||
}
|
||||
for (var offset = 0; offset < expected.Length; offset++)
|
||||
owned[at + offset] = number;
|
||||
}
|
||||
return owned;
|
||||
}
|
||||
|
||||
// The first place the expected emission occurs motion for motion in the placed program.
|
||||
private static int Find(IReadOnlyList<ExecutionMotion> actual, ExecutionMotion[] expected)
|
||||
{
|
||||
const double epsilon = PostVerificationGeometry.Epsilon;
|
||||
for (var start = 0; expected.Length != 0 && start + expected.Length <= actual.Count; start++)
|
||||
{
|
||||
var same = true;
|
||||
for (var offset = 0; offset < expected.Length && same; offset++)
|
||||
{
|
||||
var a = actual[start + offset];
|
||||
var e = expected[offset];
|
||||
same = !a.Rapid && Kind(a.Layer) == Kind(e.Layer) && a.End.DistanceTo(e.End) <= epsilon
|
||||
&& System.Math.Abs(a.Length - e.Length) <= epsilon
|
||||
&& (a.Start is not { } from || e.Start is not { } to || from.DistanceTo(to) <= epsilon);
|
||||
}
|
||||
if (same)
|
||||
return start;
|
||||
}
|
||||
return -1;
|
||||
|
||||
static LayerType Kind(LayerType layer) => layer == LayerType.Display ? LayerType.Cut : layer;
|
||||
}
|
||||
|
||||
// The same clean, rotation-baked, drift-closed, seam-trimmed program that planning captures.
|
||||
internal static Program Clean(Part part)
|
||||
{
|
||||
var clean = (Program)part.BaseDrawing.Program.Clone();
|
||||
clean.Rotate(part.Rotation - part.BaseDrawing.Program.Rotation);
|
||||
IncrementalClosureDrift.Close(clean);
|
||||
SeamOvercut.Trim(clean);
|
||||
return clean;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one-based cutting contour a lead motion belongs to: a lead-in leads into the next cut
|
||||
/// run, a lead-out leaves the last one. Scribes are not cutting contours.
|
||||
/// </summary>
|
||||
internal static int ContourAt(OwnedExecution execution, int motion)
|
||||
{
|
||||
var count = 0;
|
||||
var cutting = false;
|
||||
for (var index = 0; index < motion; index++)
|
||||
{
|
||||
var move = execution.Motions[index];
|
||||
var cut = !move.Rapid && move.Layer is LayerType.Cut or LayerType.Display
|
||||
&& move.Length > PostVerificationGeometry.Epsilon;
|
||||
if (cut && !cutting)
|
||||
count++;
|
||||
cutting = cut;
|
||||
}
|
||||
return execution.Motions[motion].Layer == LayerType.Leadout ? System.Math.Max(count, 1) : count + 1;
|
||||
}
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Rounding drift at an incremental contour's seam. Each incremental move is written at the source
|
||||
/// CAM's precision, so a contour's end is a sum of rounded moves and can miss its start by more than
|
||||
/// <see cref="NominalContourClosure.Tolerance"/> (converted PEP perimeters of a hundred polyline
|
||||
/// moves written at 1e-6 miss by 1.4e-6). An absolute program has no such sum. A contour that misses
|
||||
/// by no more than its moves could accumulate at that precision, and never more than
|
||||
/// <see cref="Tolerance.Epsilon"/>, ends its last cut on its start; the next motion absorbs the
|
||||
/// correction, so nothing after the contour moves. Gaps inside the closure tolerance are closed too:
|
||||
/// material capture and preparation sum the moves differently, so a gap at that boundary can pass
|
||||
/// one and fail the other. Contours closed within the native epsilon are left alone.
|
||||
/// </summary>
|
||||
internal static class IncrementalClosureDrift
|
||||
{
|
||||
/// <summary>Closes drifted contours of an owned program in place; returns how many were closed.</summary>
|
||||
internal static int Close(Program program)
|
||||
{
|
||||
if (program?.Codes == null || program.Mode != Mode.Incremental)
|
||||
return 0;
|
||||
var closed = 0;
|
||||
var run = new List<Motion>();
|
||||
var start = new Vector();
|
||||
var position = new Vector();
|
||||
for (var index = 0; index <= program.Codes.Count; index++)
|
||||
{
|
||||
var code = index < program.Codes.Count ? program.Codes[index] : null;
|
||||
if (code is Motion motion && IsContour(motion))
|
||||
{
|
||||
if (run.Count == 0)
|
||||
start = position;
|
||||
run.Add(motion);
|
||||
position += motion.EndPoint;
|
||||
continue;
|
||||
}
|
||||
if (run.Count > 0 && TryClose(program, run, start, position, index))
|
||||
closed++;
|
||||
run.Clear();
|
||||
if (code is Motion other)
|
||||
position += other.EndPoint;
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
// The chains material capture closes: cut, display and tab moves between rapids and other layers.
|
||||
private static bool IsContour(Motion motion) => !motion.Suppressed
|
||||
&& motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter }
|
||||
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter };
|
||||
|
||||
private static bool TryClose(Program program, List<Motion> run, Vector start, Vector end, int next)
|
||||
{
|
||||
var gap = start.DistanceTo(end);
|
||||
// Each rounded move is off by at most half a quantum per axis.
|
||||
var drift = System.Math.Min(Tolerance.Epsilon,
|
||||
run.Count * NominalContourClosure.Tolerance * System.Math.Sqrt(0.5));
|
||||
if (run.Count < 2 || !(gap > PostVerificationGeometry.Epsilon) || !(gap <= drift))
|
||||
return false;
|
||||
var last = run[^1];
|
||||
if (last.VariableRefs is { Count: > 0 })
|
||||
return false;
|
||||
// The first motion after the contour starts where the contour used to end. A subprogram call
|
||||
// first would run from the corrected end instead, so that contour is left alone.
|
||||
Motion absorber = null;
|
||||
for (; next < program.Codes.Count && absorber == null; next++)
|
||||
{
|
||||
if (program.Codes[next] is SubProgramCall)
|
||||
return false;
|
||||
absorber = program.Codes[next] as Motion;
|
||||
}
|
||||
if (absorber != null && (absorber is ArcMove || absorber.VariableRefs is { Count: > 0 }))
|
||||
return false;
|
||||
|
||||
var correction = start - end;
|
||||
var endPoint = last.EndPoint + correction;
|
||||
if (last is ArcMove arc)
|
||||
{
|
||||
// Keep the arc's centre (relative to its start) equidistant from its moved end.
|
||||
var chord = endPoint.DistanceTo(new Vector());
|
||||
if (chord <= Tolerance.Epsilon)
|
||||
return false;
|
||||
var normal = new Vector(-endPoint.Y, endPoint.X) * (1 / chord);
|
||||
var middle = endPoint * 0.5;
|
||||
var offset = arc.CenterPoint - middle;
|
||||
arc.CenterPoint = middle + normal * (offset.X * normal.X + offset.Y * normal.Y);
|
||||
}
|
||||
last.EndPoint = endPoint;
|
||||
if (absorber != null)
|
||||
absorber.EndPoint -= correction;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Diagnostics;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Remembers whether a lead curve stays clear of another part's material, so a search that
|
||||
/// re-checks the same emitted leads (every prefix of a part repeats its earlier contours' leads,
|
||||
/// and backtracking revisits them) runs each native query set once. A hit returns exactly what
|
||||
/// <see cref="LeadPathValidator.MaterialContact"/> returned for that curve and material and charges
|
||||
/// the same native query budget. Curves match by exact bits and materials by reference; the
|
||||
/// materials must not change while the memo is in use. Scans that threw are not remembered and
|
||||
/// run again. One instance belongs to one single-threaded search; replay must not use one.
|
||||
/// </summary>
|
||||
internal sealed class LeadContactMemo
|
||||
{
|
||||
private readonly Dictionary<(PostVerificationGeometry.CurveKey Curve, LeadMaterialSnapshot Material), Scan> scans = new();
|
||||
|
||||
/// <summary>Remembered native queries answered without running (Debug cost tests).</summary>
|
||||
internal long Hits { get; private set; }
|
||||
|
||||
internal bool TryGet(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, out Scan scan)
|
||||
{
|
||||
if (!scans.TryGetValue((curve.Key, material), out scan))
|
||||
return false;
|
||||
Hits++;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void Add(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, Scan scan) =>
|
||||
scans[(curve.Key, material)] = scan;
|
||||
|
||||
/// <summary>A completed scan: whether it found contact and how many boundary queries it charged.</summary>
|
||||
internal readonly record struct Scan(bool Contact, int Queries);
|
||||
}
|
||||
@@ -4,22 +4,50 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Owned nominal material: one simple perimeter minus disjoint simple holes.</summary>
|
||||
public sealed class LeadMaterialSnapshot
|
||||
{
|
||||
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, string reason)
|
||||
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs,
|
||||
string reason, bool displayPerimeter = false)
|
||||
{
|
||||
HasDisplayPerimeter = displayPerimeter;
|
||||
Rings = rings;
|
||||
Tabs = tabs;
|
||||
Reason = reason;
|
||||
var extent = PostVerificationGeometry.Extent.None;
|
||||
foreach (var ring in rings)
|
||||
foreach (var curve in ring)
|
||||
extent = extent.Union(curve.Extent);
|
||||
Extent = extent;
|
||||
BoundaryCount = rings.Sum(ring => ring.Length);
|
||||
}
|
||||
|
||||
public bool IsComplete => Reason == null;
|
||||
public string Reason { get; }
|
||||
internal IReadOnlyList<PostVerificationGeometry.Curve[]> Rings { get; }
|
||||
|
||||
/// <summary>A conservative extent of every boundary's native contact reach; material lies within it.</summary>
|
||||
internal PostVerificationGeometry.Extent Extent { get; }
|
||||
|
||||
/// <summary>The boundary curves of all rings: the native queries one full scan charges.</summary>
|
||||
internal int BoundaryCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Per ring curve, whether it is a tabbed cutout's tab: material boundary that the
|
||||
/// program never cuts. Aligned with <see cref="Rings"/>.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<bool[]> Tabs { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the perimeter (<see cref="Rings"/>[0]) is a display perimeter: a supplied blank's
|
||||
/// edge that bounds material but is never cut. Every other ring is a hole the program cuts.
|
||||
/// </summary>
|
||||
internal bool HasDisplayPerimeter { get; }
|
||||
|
||||
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
|
||||
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
|
||||
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
|
||||
@@ -29,16 +57,23 @@ public sealed class LeadMaterialSnapshot
|
||||
{
|
||||
var execution = ExecutionMotionReader.ReadSupported(cleanProgram, location, null, token);
|
||||
var rings = new List<PostVerificationGeometry.Curve[]>();
|
||||
var ringTabs = new List<bool[]>();
|
||||
var chain = new List<PostVerificationGeometry.Curve>();
|
||||
var chainTabs = new List<bool>();
|
||||
var ringDisplay = new List<bool>();
|
||||
var chainDisplay = new List<bool>();
|
||||
foreach (var motion in execution.Motions)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (motion.Rapid || motion.Layer is LayerType.Scribe or LayerType.Leadin or LayerType.Leadout)
|
||||
// Break cuts run through material that stays attached at tabs; they bound nothing.
|
||||
if (motion.Rapid || motion.Layer is LayerType.Scribe or LayerType.Leadin or LayerType.Leadout or LayerType.Break)
|
||||
{
|
||||
Finish();
|
||||
continue;
|
||||
}
|
||||
if (motion.Layer is not (LayerType.Cut or LayerType.Display))
|
||||
// A tabbed cutout's tabs are never cut but still close its hole.
|
||||
// A display perimeter is never cut but still bounds the blank's material.
|
||||
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter))
|
||||
throw new ArgumentException("Material capture contains an unsupported layer.");
|
||||
var curve = motion.Curve;
|
||||
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
|
||||
@@ -46,7 +81,9 @@ public sealed class LeadMaterialSnapshot
|
||||
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("Material contour is discontinuous.");
|
||||
chain.Add(curve);
|
||||
if (PostVerificationGeometry.Closed(chain))
|
||||
chainTabs.Add(motion.Layer == LayerType.Tab);
|
||||
chainDisplay.Add(motion.Layer == LayerType.DisplayPerimeter);
|
||||
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
||||
Finish();
|
||||
}
|
||||
Finish();
|
||||
@@ -54,25 +91,36 @@ public sealed class LeadMaterialSnapshot
|
||||
throw new ArgumentException("Material has no closed contour.");
|
||||
var budget = 1000000;
|
||||
// Certify simple rings and mutually disjoint boundaries before containment.
|
||||
for (var r = 0; r < rings.Count; r++)
|
||||
for (var s = r; s < rings.Count; s++)
|
||||
for (var i = 0; i < rings[r].Length; i++)
|
||||
for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Material validation exceeds the native query limit.");
|
||||
var a = rings[r][i];
|
||||
var b = rings[s][j];
|
||||
var contacts = a.Contacts(b, out var overlap);
|
||||
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
|
||||
if (overlap || contacts.Any(p => !adjacent
|
||||
|| !(p.DistanceTo(a.End) <= PostVerificationGeometry.Epsilon
|
||||
&& p.DistanceTo(b.Start) <= PostVerificationGeometry.Epsilon)
|
||||
&& !(p.DistanceTo(a.Start) <= PostVerificationGeometry.Epsilon
|
||||
&& p.DistanceTo(b.End) <= PostVerificationGeometry.Epsilon)))
|
||||
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
||||
}
|
||||
foreach (var (r, i, s, j) in ContactCandidates(rings, token))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Material validation exceeds the native query limit.");
|
||||
var a = rings[r][i];
|
||||
var b = rings[s][j];
|
||||
var contacts = a.Contacts(b, out var overlap);
|
||||
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
|
||||
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
|
||||
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|
||||
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
|
||||
// Native arc queries pad angles by 1e-5 radians, admitting
|
||||
// intersections on an extension (distance grows with radius).
|
||||
// At a shared arc joint require both actual finite spans;
|
||||
// overlap and genuine second intersections still refuse.
|
||||
if (arcJoint)
|
||||
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
|
||||
// Native contacts at rounded arc/line joints can drift from the
|
||||
// authored endpoint. Use the shared geometry tolerance only to
|
||||
// recognize that joint; overlaps and other contacts still refuse.
|
||||
if (overlap || contacts.Any(p => !adjacent
|
||||
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
|
||||
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
|
||||
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
|
||||
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
|
||||
throw new ArgumentException(r != s && (ringDisplay[r] || ringDisplay[s])
|
||||
? "A display perimeter must enclose every other contour."
|
||||
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
||||
}
|
||||
var outer = -1;
|
||||
for (var i = 0; i < rings.Count; i++)
|
||||
{
|
||||
@@ -85,42 +133,226 @@ public sealed class LeadMaterialSnapshot
|
||||
}
|
||||
}
|
||||
if (outer < 0)
|
||||
throw new ArgumentException("Material must have exactly one enclosing perimeter.");
|
||||
throw new ArgumentException(ringDisplay.Contains(true)
|
||||
? "A display perimeter must enclose every other contour."
|
||||
: "Material must have exactly one enclosing perimeter.");
|
||||
if (ringTabs[outer].Any(tab => tab))
|
||||
throw new ArgumentException("A perimeter cannot have tabs.");
|
||||
if (ringDisplay.Where((_, i) => i != outer).Any(display => display))
|
||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||
if (ringDisplay[outer] && rings.Count == 1)
|
||||
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
||||
var holes = rings.Where((_, i) => i != outer).ToArray();
|
||||
for (var i = 0; i < holes.Length; i++)
|
||||
for (var j = i + 1; j < holes.Length; j++)
|
||||
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
|
||||
throw new ArgumentException("Nested cutouts are not supported material.");
|
||||
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(), null);
|
||||
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
|
||||
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
|
||||
|
||||
void Finish()
|
||||
{
|
||||
if (chain.Count == 0)
|
||||
return;
|
||||
if (!PostVerificationGeometry.Closed(chain))
|
||||
throw new ArgumentException("Nominal material contour is open; tab gaps cannot be filled implicitly.");
|
||||
if (!NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
||||
{
|
||||
var gap = chain[0].Start.DistanceTo(chain[^1].End);
|
||||
throw new ArgumentException(FormattableString.Invariant(
|
||||
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
|
||||
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
|
||||
}
|
||||
if (chainDisplay.Contains(true) && chainDisplay.Contains(false))
|
||||
throw new ArgumentException("A display perimeter must be one whole closed contour.");
|
||||
rings.Add(chain.ToArray());
|
||||
ringTabs.Add(chainTabs.ToArray());
|
||||
ringDisplay.Add(chainDisplay.Count > 0 && chainDisplay[0]);
|
||||
chain.Clear();
|
||||
chainTabs.Clear();
|
||||
chainDisplay.Clear();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
{
|
||||
return new LeadMaterialSnapshot(Array.Empty<PostVerificationGeometry.Curve[]>(), ex.Message);
|
||||
return new LeadMaterialSnapshot(Array.Empty<PostVerificationGeometry.Curve[]>(), Array.Empty<bool[]>(), ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token)
|
||||
&& !Rings.Skip(1).Any(hole => Inside(point, hole, token));
|
||||
|
||||
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token)
|
||||
/// <summary>
|
||||
/// The boundary pairs certification queries natively, in the order of the full pairwise scan
|
||||
/// (ring, other ring, curve, other curve). A pair whose extents are clear by the lead checks'
|
||||
/// margin (<see cref="PostVerificationGeometry.Extent.IsClearOfQueriesFrom"/>) can report no
|
||||
/// native contact, so it is left out; a sweep over X finds the rest without visiting every pair.
|
||||
/// Material that is not well conditioned keeps every pair.
|
||||
/// </summary>
|
||||
private static List<(int R, int I, int S, int J)> ContactCandidates(
|
||||
IReadOnlyList<PostVerificationGeometry.Curve[]> rings, CancellationToken token)
|
||||
{
|
||||
var inside = false;
|
||||
foreach (var curve in ring)
|
||||
var curves = new List<(int Ring, int Index, PostVerificationGeometry.Extent Extent)>();
|
||||
var whole = PostVerificationGeometry.Extent.None;
|
||||
for (var r = 0; r < rings.Count; r++)
|
||||
for (var i = 0; i < rings[r].Length; i++)
|
||||
{
|
||||
curves.Add((r, i, rings[r][i].Extent));
|
||||
whole = whole.Union(rings[r][i].Extent);
|
||||
}
|
||||
var pairs = new List<(int R, int I, int S, int J)>();
|
||||
if (!whole.IsWellConditioned)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (curve.CrossesRay(point))
|
||||
inside = !inside;
|
||||
for (var a = 0; a < curves.Count; a++)
|
||||
for (var b = a + 1; b < curves.Count; b++)
|
||||
pairs.Add((curves[a].Ring, curves[a].Index, curves[b].Ring, curves[b].Index));
|
||||
}
|
||||
return inside;
|
||||
else
|
||||
{
|
||||
// No pair spans more than the whole material, so this bounds every pair's margin.
|
||||
var reach = PostVerificationGeometry.Extent.ClearMargin + PostVerificationGeometry.Extent.SpanRounding
|
||||
* System.Math.Max(whole.MaxX - whole.MinX, whole.MaxY - whole.MinY);
|
||||
var byMinX = curves.OrderBy(c => c.Extent.MinX).ToArray();
|
||||
for (var a = 0; a < byMinX.Length; a++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var first = byMinX[a];
|
||||
for (var b = a + 1; b < byMinX.Length && byMinX[b].Extent.MinX <= first.Extent.MaxX + reach; b++)
|
||||
{
|
||||
var second = byMinX[b];
|
||||
if (first.Extent.IsClearOfQueriesFrom(second.Extent))
|
||||
continue;
|
||||
pairs.Add((first.Ring, first.Index).CompareTo((second.Ring, second.Index)) < 0
|
||||
? (first.Ring, first.Index, second.Ring, second.Index)
|
||||
: (second.Ring, second.Index, first.Ring, first.Index));
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs.Sort((x, y) => (x.R, x.S, x.I, x.J).CompareTo((y.R, y.S, y.I, y.J)));
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/// <summary>Material with fewer boundaries is scanned whole, exactly as before the index.</summary>
|
||||
private const int IndexedBoundaries = 64;
|
||||
|
||||
private BoundaryIndex index;
|
||||
|
||||
/// <summary>
|
||||
/// The boundaries a native query from <paramref name="query"/> can report a contact with, in
|
||||
/// ring order: every boundary but those whose extents are clear of it by the lead checks'
|
||||
/// margin plus <paramref name="reach"/> (<see cref="PostVerificationGeometry.Extent.IsClearOfQueriesFrom"/>).
|
||||
/// Small material returns every boundary.
|
||||
/// </summary>
|
||||
internal IEnumerable<PostVerificationGeometry.Curve> BoundariesNear(PostVerificationGeometry.Extent query,
|
||||
double reach = 0) => PositionsNear(query, reach).Select(p => Rings[p.Ring][p.Index]);
|
||||
|
||||
/// <summary>As <see cref="BoundariesNear"/>, each boundary as its ring and its index in that ring.</summary>
|
||||
internal IEnumerable<(int Ring, int Index)> PositionsNear(PostVerificationGeometry.Extent query, double reach = 0)
|
||||
{
|
||||
if (BoundaryCount < IndexedBoundaries)
|
||||
return Rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i)));
|
||||
return LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).Near(query, reach);
|
||||
}
|
||||
|
||||
internal bool ContainsMaterial(Vector point, CancellationToken token)
|
||||
{
|
||||
if (BoundaryCount < IndexedBoundaries)
|
||||
return Inside(point, Rings[0], token) && !Rings.Skip(1).Any(hole => Inside(point, hole, token));
|
||||
var holes = LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).HolesOnRow(point.Y);
|
||||
return Inside(point, Rings[0], token) && !holes.Any(hole => Inside(point, Rings[hole], token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A uniform grid of boundary extents over the material, for material with many holes. Read
|
||||
/// only once built, so concurrent searches may share it.
|
||||
/// </summary>
|
||||
private sealed class BoundaryIndex
|
||||
{
|
||||
private readonly (int Ring, int Index)[] positions;
|
||||
private readonly PostVerificationGeometry.Extent[] extents;
|
||||
private readonly List<int>[] cells;
|
||||
private readonly List<int>[] bands;
|
||||
private readonly PostVerificationGeometry.Extent[] ringExtents;
|
||||
private readonly int ringCount;
|
||||
private readonly PostVerificationGeometry.Extent whole;
|
||||
private readonly int columns;
|
||||
private readonly int rows;
|
||||
private readonly double width;
|
||||
private readonly double height;
|
||||
|
||||
internal BoundaryIndex(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, PostVerificationGeometry.Extent whole)
|
||||
{
|
||||
positions = rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i))).ToArray();
|
||||
extents = rings.SelectMany(r => r).Select(b => b.Extent).ToArray();
|
||||
ringCount = rings.Count;
|
||||
ringExtents = new PostVerificationGeometry.Extent[rings.Count];
|
||||
this.whole = whole;
|
||||
var side = System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Sqrt(positions.Length)));
|
||||
columns = side;
|
||||
rows = side;
|
||||
width = System.Math.Max(whole.MaxX - whole.MinX, double.Epsilon) / columns;
|
||||
height = System.Math.Max(whole.MaxY - whole.MinY, double.Epsilon) / rows;
|
||||
cells = new List<int>[columns * rows];
|
||||
bands = new List<int>[rows];
|
||||
if (!whole.IsWellConditioned)
|
||||
return;
|
||||
for (var ring = 1; ring < rings.Count; ring++)
|
||||
{
|
||||
var extent = rings[ring].Aggregate(PostVerificationGeometry.Extent.None, (e, curve) => e.Union(curve.Extent));
|
||||
ringExtents[ring] = extent;
|
||||
for (var band = Row(extent.MinY - PostVerificationGeometry.Extent.ClearMargin);
|
||||
band <= Row(extent.MaxY + PostVerificationGeometry.Extent.ClearMargin); band++)
|
||||
(bands[band] ??= new List<int>()).Add(ring);
|
||||
}
|
||||
for (var b = 0; b < positions.Length; b++)
|
||||
{
|
||||
var (x0, y0, x1, y1) = Cells(extents[b], 0);
|
||||
for (var y = y0; y <= y1; y++)
|
||||
for (var x = x0; x <= x1; x++)
|
||||
(cells[y * columns + x] ??= new List<int>()).Add(b);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The holes, in ring order, whose curves a ray along row <paramref name="y"/> can cross:
|
||||
/// every hole but those whose extents are clear of the row (see
|
||||
/// <see cref="PostVerificationGeometry.Extent.IsClearOfRow"/>).
|
||||
/// </summary>
|
||||
internal IEnumerable<int> HolesOnRow(double y)
|
||||
{
|
||||
if (!whole.IsWellConditioned || !(System.Math.Abs(y) <= PostVerificationGeometry.Extent.WellConditionedLimit))
|
||||
return Enumerable.Range(1, ringCount - 1);
|
||||
return (bands[Row(y)] ?? []).Where(ring => !ringExtents[ring].IsClearOfRow(y));
|
||||
}
|
||||
|
||||
internal IEnumerable<(int Ring, int Index)> Near(PostVerificationGeometry.Extent query, double reach)
|
||||
{
|
||||
// The margin covers every pair's span, and each skip is then decided for the pair alone.
|
||||
// Material or a query that is not well conditioned (whose cells were never built, for
|
||||
// the material) keeps every boundary.
|
||||
var union = whole.Union(query);
|
||||
var margin = PostVerificationGeometry.Extent.ClearMargin + reach
|
||||
+ PostVerificationGeometry.Extent.SpanRounding * System.Math.Max(union.MaxX - union.MinX, union.MaxY - union.MinY);
|
||||
if (!union.IsWellConditioned || !double.IsFinite(margin))
|
||||
return positions;
|
||||
var (x0, y0, x1, y1) = Cells(query, margin);
|
||||
var found = new List<int>();
|
||||
for (var y = y0; y <= y1; y++)
|
||||
for (var x = x0; x <= x1; x++)
|
||||
if (cells[y * columns + x] is { } cell)
|
||||
found.AddRange(cell);
|
||||
found.Sort();
|
||||
var near = new List<(int Ring, int Index)>();
|
||||
for (var i = 0; i < found.Count; i++)
|
||||
if ((i == 0 || found[i] != found[i - 1]) && !query.IsClearOfQueriesFrom(extents[found[i]], reach))
|
||||
near.Add(positions[found[i]]);
|
||||
return near;
|
||||
}
|
||||
|
||||
// The cells an extent widened by margin covers, clamped to the grid.
|
||||
private (int X0, int Y0, int X1, int Y1) Cells(PostVerificationGeometry.Extent extent, double margin) => (
|
||||
Column(extent.MinX - margin), Row(extent.MinY - margin), Column(extent.MaxX + margin), Row(extent.MaxY + margin));
|
||||
|
||||
private int Column(double x) => (int)System.Math.Clamp(System.Math.Floor((x - whole.MinX) / width), 0, columns - 1);
|
||||
|
||||
private int Row(double y) => (int)System.Math.Clamp(System.Math.Floor((y - whole.MinY) / height), 0, rows - 1);
|
||||
}
|
||||
|
||||
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token) =>
|
||||
PostVerificationGeometry.Inside(point, ring, token);
|
||||
}
|
||||
@@ -10,15 +10,52 @@ namespace OpenNest.CNC.CuttingPlanning;
|
||||
/// <summary>A complete unsafe result is distinct from an incomplete/unsupported check.</summary>
|
||||
public sealed record LeadPathValidationResult(bool IsComplete, bool IsClear, string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// One lead motion of a fixed emission that failed validation. <see cref="Obstacle"/> indexes the
|
||||
/// other materials passed to the check, or is null when the lead meets its own part's material
|
||||
/// or could not be checked.
|
||||
/// </summary>
|
||||
internal sealed record FixedLeadFailure(int Motion, LeadPathValidationResult Result, int? Obstacle);
|
||||
|
||||
/// <summary>Certifies actual emitted native lead paths against owned nominal material.</summary>
|
||||
public static class LeadPathValidator
|
||||
{
|
||||
public static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
|
||||
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, CancellationToken token = default)
|
||||
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, CancellationToken token = default) =>
|
||||
Check(execution, target, otherMaterials, null, null, token);
|
||||
|
||||
/// <summary>
|
||||
/// As the public check, and every lead-in pierce must also keep <paramref name="pierceRadius"/>
|
||||
/// from all of the materials (see <see cref="CuttingStrategy.CuttingParameters.PierceDiameter"/>).
|
||||
/// Zero checks paths only.
|
||||
/// </summary>
|
||||
public static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
|
||||
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, double pierceRadius, CancellationToken token = default) =>
|
||||
Check(execution, target, otherMaterials, null, null, token, pierceRadius: pierceRadius);
|
||||
|
||||
/// <summary>As the public check, remembering other-material results in <paramref name="memo"/>.</summary>
|
||||
internal static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
|
||||
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, LeadContactMemo memo, CancellationToken token,
|
||||
double pierceRadius = 0) =>
|
||||
Check(execution, target, otherMaterials, null, null, token, memo, pierceRadius);
|
||||
|
||||
/// <summary>
|
||||
/// As the public check, but a lead motion that <paramref name="isFixed"/> owns (by motion
|
||||
/// index) is part of a fixed emission: its failure is added to <paramref name="fixedFailures"/>
|
||||
/// and checking continues. Any other failure is returned exactly as the public check returns it.
|
||||
/// A positive <paramref name="pierceRadius"/> also requires each lead-in pierce to keep that
|
||||
/// distance from every material; a contour pierced directly, without a lead-in, is not checked.
|
||||
/// </summary>
|
||||
internal static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
|
||||
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, Func<int, bool> isFixed,
|
||||
List<FixedLeadFailure> fixedFailures, CancellationToken token, LeadContactMemo memo = null,
|
||||
double pierceRadius = 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (execution == null || target == null || otherMaterials == null)
|
||||
return new(false, false, "Missing execution or material snapshots.");
|
||||
if (!double.IsFinite(pierceRadius) || pierceRadius < 0)
|
||||
return new(false, false, "Pierce zone radius must be finite and nonnegative.");
|
||||
if (!target.IsComplete || otherMaterials.Any(m => m == null || !m.IsComplete))
|
||||
return new(false, false, "Material snapshot is incomplete.");
|
||||
try
|
||||
@@ -30,11 +67,25 @@ public static class LeadPathValidator
|
||||
var move = execution.Motions[i];
|
||||
if (move.Layer is not (LayerType.Leadin or LayerType.Leadout))
|
||||
continue;
|
||||
var (failure, obstacle) = CheckMotion(i);
|
||||
if (failure == null)
|
||||
continue;
|
||||
if (isFixed?.Invoke(i) != true || fixedFailures == null)
|
||||
return failure;
|
||||
fixedFailures.Add(new(i, failure, obstacle));
|
||||
}
|
||||
// No lead motion is valid for direct contour piercing. The caller still
|
||||
// checks rapid travel and contour accounting for the complete program.
|
||||
return new(true, true, null);
|
||||
|
||||
(LeadPathValidationResult Failure, int? Obstacle) CheckMotion(int i)
|
||||
{
|
||||
var move = execution.Motions[i];
|
||||
if (move.Rapid || move.Start is not { } start || move.Curve == null
|
||||
|| !double.IsFinite(move.Length) || move.Length <= PostVerificationGeometry.Epsilon
|
||||
|| start.DistanceTo(move.Curve.Start) > PostVerificationGeometry.Epsilon
|
||||
|| move.End.DistanceTo(move.Curve.End) > PostVerificationGeometry.Epsilon)
|
||||
return new(false, false, "Lead motion is missing, degenerate or inconsistent.");
|
||||
return (new(false, false, "Lead motion is missing, degenerate or inconsistent."), null);
|
||||
Vector? allowed = null;
|
||||
var groupEdge = i;
|
||||
var step = move.Layer == LayerType.Leadin ? 1 : -1;
|
||||
@@ -48,16 +99,34 @@ public static class LeadPathValidator
|
||||
}
|
||||
var adjacentIndex = groupEdge + step;
|
||||
var genuineJoint = false;
|
||||
var breakLead = false;
|
||||
if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count)
|
||||
{
|
||||
var adjacent = execution.Motions[adjacentIndex];
|
||||
if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display
|
||||
// A break cut lies inside its part's material, so its lead joins no boundary:
|
||||
// the joint is the cut's own start (or end for a lead-out).
|
||||
if (!adjacent.Rapid && adjacent.Layer == LayerType.Break
|
||||
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
var edge = execution.Motions[groupEdge];
|
||||
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
|
||||
var cutJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
|
||||
if (joint.DistanceTo(cutJoint) <= PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
genuineJoint = true;
|
||||
breakLead = true;
|
||||
if (groupEdge == i)
|
||||
allowed = joint;
|
||||
}
|
||||
}
|
||||
else if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display
|
||||
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
var edge = execution.Motions[groupEdge];
|
||||
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
|
||||
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
|
||||
foreach (var boundary in target.Rings.SelectMany(r => r))
|
||||
// A boundary clear of the contour's first motion contains none of its points.
|
||||
foreach (var boundary in target.BoundariesNear(adjacent.Curve.Extent))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
@@ -77,48 +146,161 @@ public static class LeadPathValidator
|
||||
}
|
||||
}
|
||||
if (!genuineJoint)
|
||||
return new(true, false, "Lead chain has no genuine adjacent target contour entry or exit.");
|
||||
return (new(true, false, "Lead chain has no genuine adjacent target contour entry or exit."), null);
|
||||
if (allowed is { } jointPoint
|
||||
&& (move.Layer == LayerType.Leadin ? start : move.End).DistanceTo(jointPoint) <= PostVerificationGeometry.Epsilon)
|
||||
return new(true, false, "A positive-length lead returns to its contour joint; contact is not endpoint-only.");
|
||||
var failure = CheckMaterial(target, allowed);
|
||||
return (new(true, false, "A positive-length lead returns to its contour joint; contact is not endpoint-only."), null);
|
||||
// A break cut's lead runs through its own breakoff scrap: entering the target's
|
||||
// material is expected there, but touching any of its boundaries is not.
|
||||
var failure = breakLead
|
||||
? MaterialContact(move.Curve, target, null, ref budget, token, allowInterior: true)
|
||||
: CheckMaterial(target, allowed);
|
||||
if (failure != null)
|
||||
return new(true, false, $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure}).");
|
||||
foreach (var material in otherMaterials)
|
||||
return (new(true, false, breakLead
|
||||
? $"Break cut lead motion {i} contacts its part's boundary ({failure}). Try a shorter lead-in or move the tabs."
|
||||
: $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."), null);
|
||||
var reach = move.Curve.Extent;
|
||||
for (var other = 0; other < otherMaterials.Count; other++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var material = otherMaterials[other];
|
||||
if (ReferenceEquals(material, target))
|
||||
continue;
|
||||
if (CheckMaterial(material, null) != null)
|
||||
return new(true, false, "Lead contacts or enters another placed material. "
|
||||
if (reach.IsClearOfQueriesFrom(material.Extent) ? FarInside(material) : OtherContact(material))
|
||||
return (new(true, false, "Lead contacts or enters another placed material. "
|
||||
+ "Try spacing the parts farther apart or reducing the lead-in/lead-out length, then replan. "
|
||||
+ "For locked parts, edit the leads or unlock the part before replanning.");
|
||||
+ "For locked parts, edit the leads or unlock the part before replanning."), other);
|
||||
}
|
||||
return PierceZone();
|
||||
|
||||
string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint)
|
||||
// A lead-in reached by a rapid (or starting the program) starts at its pierce. A break
|
||||
// cut's pierce lies in its own breakoff scrap, inside the part's material, so only
|
||||
// others are checked.
|
||||
(LeadPathValidationResult, int?) PierceZone()
|
||||
{
|
||||
foreach (var boundary in material.Rings.SelectMany(r => r))
|
||||
if (pierceRadius <= 0 || move.Layer != LayerType.Leadin || i > 0 && !execution.Motions[i - 1].Rapid)
|
||||
return (null, null);
|
||||
if (!breakLead && Clearance(target) is { } own)
|
||||
return (Short(own, "its own part's material"), null);
|
||||
for (var other = 0; other < otherMaterials.Count; other++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||
var contacts = move.Curve.Contacts(boundary, out var overlap);
|
||||
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|
||||
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
|
||||
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
|
||||
var material = otherMaterials[other];
|
||||
if (ReferenceEquals(material, target))
|
||||
continue;
|
||||
// Every boundary of material this far away is farther than the zone.
|
||||
if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y)
|
||||
.IsClearOfQueriesFrom(material.Extent, pierceRadius))
|
||||
continue;
|
||||
if (Clearance(material) is { } distance)
|
||||
return (Short(distance, "another placed part"), other);
|
||||
}
|
||||
// With all other boundary contacts excluded, the connected open path
|
||||
// has constant material membership. Use the native arc midpoint, not
|
||||
// the chord midpoint or endpoints (which can both lie in scrap).
|
||||
return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null;
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
// The pierce's distance to the material when it is inside the zone, else null.
|
||||
double? Clearance(LeadMaterialSnapshot material)
|
||||
{
|
||||
var distance = MaterialDistance(start, material, ref budget, token);
|
||||
return IsInsideZone(distance, pierceRadius) ? distance : null;
|
||||
}
|
||||
|
||||
LeadPathValidationResult Short(double distance, string what) =>
|
||||
new(true, false, PierceZoneMessage(start, distance, what, pierceRadius)
|
||||
+ " Try a longer lead-in, more part spacing or a smaller pierce diameter, then replan.");
|
||||
|
||||
string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint) =>
|
||||
MaterialContact(move.Curve, material, permittedJoint, ref budget, token);
|
||||
|
||||
// Exactly the unmemoized check: a remembered scan charges the queries it ran
|
||||
// and throws where running it again would exhaust the budget.
|
||||
bool OtherContact(LeadMaterialSnapshot material)
|
||||
{
|
||||
if (memo == null)
|
||||
return CheckMaterial(material, null) != null;
|
||||
if (memo.TryGet(move.Curve, material, out var scan))
|
||||
{
|
||||
Charge(scan.Queries);
|
||||
return scan.Contact;
|
||||
}
|
||||
var before = budget;
|
||||
var contact = CheckMaterial(material, null) != null;
|
||||
memo.Add(move.Curve, material, new(contact, before - budget));
|
||||
return contact;
|
||||
}
|
||||
|
||||
// Material too far from the lead for any native boundary query to report a contact
|
||||
// (see IsClearOfQueriesFrom): the result the full scan gives. Only queries that run
|
||||
// are charged, so many far parts or holes do not exhaust the budget. Ray parity is
|
||||
// still asked natively unless the lead lies beside the material's rows.
|
||||
bool FarInside(LeadMaterialSnapshot material)
|
||||
{
|
||||
var midpoint = move.Curve.Midpoint;
|
||||
return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token);
|
||||
}
|
||||
|
||||
void Charge(int queries)
|
||||
{
|
||||
if (budget - queries < 0)
|
||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||
budget -= queries;
|
||||
}
|
||||
}
|
||||
// Missing leads are the ReleasedContourState check's responsibility.
|
||||
return new(true, true, null);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
{
|
||||
return new(false, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The exact native distance from <paramref name="point"/> to the nearest boundary of complete
|
||||
/// material. Exceeding the budget throws.
|
||||
/// </summary>
|
||||
internal static double MaterialDistance(Vector point, LeadMaterialSnapshot material, ref int budget,
|
||||
CancellationToken token)
|
||||
{
|
||||
var nearest = double.PositiveInfinity;
|
||||
foreach (var boundary in material.Rings.SelectMany(r => r))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||
nearest = System.Math.Min(nearest, boundary.DistanceTo(point));
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/// <summary>True when material <paramref name="distance"/> away lies inside a pierce zone.</summary>
|
||||
internal static bool IsInsideZone(double distance, double pierceRadius) =>
|
||||
pierceRadius > 0 && distance < pierceRadius - PostVerificationGeometry.Epsilon;
|
||||
|
||||
internal static string PierceZoneMessage(Vector pierce, double distance, string what, double pierceRadius) =>
|
||||
FormattableString.Invariant(
|
||||
$"The pierce at ({pierce.X:0.####}, {pierce.Y:0.####}) is {distance:0.####} from {what}; ")
|
||||
+ FormattableString.Invariant($"the {pierceRadius * 2:0.####} pierce diameter needs {pierceRadius:0.####}.");
|
||||
|
||||
/// <summary>
|
||||
/// How a native lead curve contacts or enters complete material, or null when it stays clear.
|
||||
/// A contact at <paramref name="permittedJoint"/> is allowed. Exceeding the budget throws.
|
||||
/// </summary>
|
||||
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
|
||||
Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false)
|
||||
{
|
||||
// Boundaries no native query from the curve can reach report no contact; only the others run.
|
||||
foreach (var boundary in material.BoundariesNear(curve.Extent))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||
var contacts = curve.Contacts(boundary, out var overlap);
|
||||
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|
||||
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
|
||||
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
|
||||
}
|
||||
// With all other boundary contacts excluded, the connected open path
|
||||
// has constant material membership. Use the native arc midpoint, not
|
||||
// the chord midpoint or endpoints (which can both lie in scrap).
|
||||
return !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Whether one part's clean material lies strictly inside a cutout of another's. The cutting
|
||||
/// planner's prerequisites and the pre-post review share this proof, so a pair the planner orders
|
||||
/// inner-before-host is the pair posting checks. Bounds only select candidates; containment is
|
||||
/// proven on native material, and any boundary contact or incomplete material is ambiguous.
|
||||
/// </summary>
|
||||
internal static class NestedPartContainment
|
||||
{
|
||||
private const int ContactBudget = 1000000;
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="inner"/>'s perimeter lies strictly inside one cutout of
|
||||
/// <paramref name="host"/>, false when it lies in none; otherwise null, with
|
||||
/// <paramref name="ambiguity"/> saying why containment cannot be established.
|
||||
/// </summary>
|
||||
internal static bool? InsideCutout(LeadMaterialSnapshot inner, LeadMaterialSnapshot host,
|
||||
CancellationToken token, out string ambiguity)
|
||||
{
|
||||
ambiguity = null;
|
||||
if (!inner.IsComplete || !host.IsComplete)
|
||||
{
|
||||
ambiguity = inner.Reason ?? host.Reason;
|
||||
return null;
|
||||
}
|
||||
var budget = ContactBudget;
|
||||
foreach (var curve in inner.Rings[0])
|
||||
foreach (var ring in host.Rings)
|
||||
foreach (var boundary in ring)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
{
|
||||
ambiguity = "the containment check exceeds the native query limit";
|
||||
return null;
|
||||
}
|
||||
if (curve.Contacts(boundary, out var overlap).Count != 0 || overlap)
|
||||
{
|
||||
ambiguity = "their boundaries touch or cross";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
var probe = inner.Rings[0][0].Start;
|
||||
return host.Rings.Skip(1).Any(hole => LeadMaterialSnapshot.Inside(probe, hole, token));
|
||||
}
|
||||
|
||||
/// <summary>Material extent of a clean program at a location: rapids and scribe marks excluded.</summary>
|
||||
internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) =>
|
||||
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions
|
||||
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter && m.Curve != null)
|
||||
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
|
||||
|
||||
/// <summary>Whether a host's bounds admit an inner part's bounds as a containment candidate.</summary>
|
||||
internal static bool Within(Box inner, Box outer) =>
|
||||
inner.Left >= outer.Left - Tolerance.Epsilon && inner.Right <= outer.Right + Tolerance.Epsilon
|
||||
&& inner.Bottom >= outer.Bottom - Tolerance.Epsilon && inner.Top <= outer.Top + Tolerance.Epsilon;
|
||||
|
||||
/// <summary>The union of two boxes.</summary>
|
||||
internal static Box Union(Box a, Box b)
|
||||
{
|
||||
var left = System.Math.Min(a.Left, b.Left);
|
||||
var bottom = System.Math.Min(a.Bottom, b.Bottom);
|
||||
return new(left, bottom, System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-post review of parts nested in other parts' cutouts. Cutting a host's enclosing hole
|
||||
/// releases the slug the nested part is cut from, so the nested part must be cut first; and the
|
||||
/// host's hole leads must not cross the nested part, nor the nested part's leads enter the host.
|
||||
/// Containment is proven exactly as the cutting planner proves it; an ambiguous pair is incomplete,
|
||||
/// never clear. Motions a fixed-lead review already owns are not reported twice. Reports only.
|
||||
/// </summary>
|
||||
internal static class NestedPartReview
|
||||
{
|
||||
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
|
||||
IReadOnlyDictionary<int, Func<int, bool>> fixedMotions, CancellationToken token)
|
||||
{
|
||||
var parts = plate.Parts;
|
||||
var clean = new Program[parts.Count];
|
||||
var cleanBounds = new Box[parts.Count];
|
||||
var hostBounds = new Box[parts.Count];
|
||||
for (var index = 0; index < parts.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var part = parts[index];
|
||||
if (part?.BaseDrawing?.Program == null || part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
// Scribe-only drawings are marks, not material.
|
||||
if (!ExecutionMotionReader.Read(part.BaseDrawing.Program, Vector.Zero, null, token).Motions
|
||||
.Any(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null))
|
||||
continue;
|
||||
var program = FixedLeadReview.Clean(part);
|
||||
cleanBounds[index] = NestedPartContainment.CleanBounds(program, part.Location, token);
|
||||
hostBounds[index] = NestedPartContainment.Union(part.BoundingBox, cleanBounds[index]);
|
||||
clean[index] = program;
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
// The overlap and rapid checks already report unreadable programs.
|
||||
}
|
||||
}
|
||||
var materials = new LeadMaterialSnapshot[parts.Count];
|
||||
var executions = new OwnedExecution[parts.Count];
|
||||
for (var inner = 0; inner < parts.Count; inner++)
|
||||
for (var host = 0; host < parts.Count; host++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (inner == host || clean[inner] == null || clean[host] == null
|
||||
|| !NestedPartContainment.Within(cleanBounds[inner], hostBounds[host]))
|
||||
continue;
|
||||
var inside = NestedPartContainment.InsideCutout(Material(inner), Material(host), token,
|
||||
out var ambiguity);
|
||||
if (inside == null)
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, inner + 1, host + 1,
|
||||
$"Containment of part {inner + 1} relative to part {host + 1} cannot be established: "
|
||||
+ $"{ambiguity}. Their cut order and leads are not checked."));
|
||||
continue;
|
||||
}
|
||||
if (inside == false)
|
||||
continue;
|
||||
if (inner > host)
|
||||
{
|
||||
var bounds = cleanBounds[inner];
|
||||
findings.Add(new(PostVerificationKind.NestedCutOrder, plateNumber, inner + 1, host + 1,
|
||||
$"Part {inner + 1} lies inside a cutout of part {host + 1} but is cut after it, so cutting "
|
||||
+ "the cutout releases its slug first. Plan Cutting cuts nested parts first; replan or "
|
||||
+ "reorder before posting.")
|
||||
{ Location = new Vector((bounds.Left + bounds.Right) / 2, (bounds.Bottom + bounds.Top) / 2) });
|
||||
}
|
||||
Leads(host, inner, $"part {inner + 1}, which is nested in this part's cutout");
|
||||
Leads(inner, host, $"part {host + 1}, whose cutout this part is nested in");
|
||||
}
|
||||
|
||||
LeadMaterialSnapshot Material(int index) =>
|
||||
materials[index] ??= LeadMaterialSnapshot.Capture(clean[index], parts[index].Location, token);
|
||||
|
||||
void Leads(int from, int against, string what)
|
||||
{
|
||||
OwnedExecution execution;
|
||||
try
|
||||
{
|
||||
execution = executions[from] ??= ExecutionMotionReader.Read(parts[from].Program,
|
||||
parts[from].Location, null, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
return; // The rapid check reports the unreadable program.
|
||||
}
|
||||
var owned = fixedMotions.TryGetValue(from, out var isFixed) ? isFixed : null;
|
||||
var budget = 1000000;
|
||||
for (var motion = 0; motion < execution.Motions.Count; motion++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var move = execution.Motions[motion];
|
||||
if (move.Rapid || move.Layer is not (LayerType.Leadin or LayerType.Leadout)
|
||||
|| owned?.Invoke(motion) == true
|
||||
|| move.Curve == null || !(move.Length > PostVerificationGeometry.Epsilon))
|
||||
continue;
|
||||
string contact;
|
||||
try
|
||||
{
|
||||
contact = LeadPathValidator.MaterialContact(move.Curve, materials[against], null, ref budget, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, from + 1, against + 1,
|
||||
"Nested part lead check: " + exception.Message));
|
||||
return;
|
||||
}
|
||||
if (contact != null)
|
||||
findings.Add(new(PostVerificationKind.NestedLead, plateNumber, from + 1, against + 1,
|
||||
$"Lead on cutting contour {FixedLeadReview.ContourAt(execution, motion)} contacts or enters "
|
||||
+ $"the material of {what}. Replan cutting or edit the lead before posting.")
|
||||
{ Location = move.Curve.Midpoint });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Closure of source contours during planner material capture and preparation.</summary>
|
||||
internal static class NominalContourClosure
|
||||
{
|
||||
internal const double Tolerance = 0.000001;
|
||||
|
||||
internal static bool IsClosed(Vector start, Vector end)
|
||||
{
|
||||
// Subtracting translated/rotated coordinates can put an exact boundary gap
|
||||
// a few floating-point ulps above the limit. Bound that allowance to 1e-12.
|
||||
var scale = System.Math.Max(1, System.Math.Max(
|
||||
System.Math.Max(System.Math.Abs(start.X), System.Math.Abs(start.Y)),
|
||||
System.Math.Max(System.Math.Abs(end.X), System.Math.Abs(end.Y))));
|
||||
var roundoff = System.Math.Min(1e-12, 4 * 2.2204460492503131e-16 * scale);
|
||||
return start.DistanceTo(end) <= Tolerance + roundoff;
|
||||
}
|
||||
}
|
||||
@@ -30,20 +30,21 @@ internal static class OwnedCuttingParameters
|
||||
Kerf = Dimension(source.Kerf),
|
||||
PartSpacing = Dimension(source.PartSpacing),
|
||||
PierceClearance = Dimension(source.PierceClearance),
|
||||
PierceDiameter = Dimension(source.PierceDiameter),
|
||||
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
|
||||
InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
|
||||
ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn),
|
||||
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
|
||||
InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
|
||||
ArcCircleLeadOut = CopyLeadOut(source.ArcCircleLeadOut),
|
||||
RoundLeadInAngles = source.RoundLeadInAngles,
|
||||
LeadInAngleIncrement = source.LeadInAngleIncrement,
|
||||
AutoTabMinSize = Dimension(source.AutoTabMinSize),
|
||||
AutoTabMaxSize = Dimension(source.AutoTabMaxSize),
|
||||
TabConfig = source.TabConfig == null
|
||||
? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
|
||||
: CopyTab(source.TabConfig),
|
||||
TabsEnabled = source.TabsEnabled,
|
||||
SupportTabs = source.SupportTabs,
|
||||
NoBedTabMinWidth = Dimension(source.NoBedTabMinWidth),
|
||||
NoBedTabMaxWidth = Dimension(source.NoBedTabMaxWidth),
|
||||
TabHeldMaxLength = Dimension(source.TabHeldMaxLength),
|
||||
Sequencing = new SequenceParameters
|
||||
{
|
||||
Method = sequence.Method,
|
||||
@@ -65,12 +66,13 @@ internal static class OwnedCuttingParameters
|
||||
};
|
||||
}
|
||||
|
||||
private static LeadIn CopyLeadIn(LeadIn source) => source switch
|
||||
internal static LeadIn CopyLeadIn(LeadIn source) => source switch
|
||||
{
|
||||
NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
|
||||
LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
|
||||
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
|
||||
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn { Radius = Dimension(n.Radius) },
|
||||
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn
|
||||
{ Radius = Dimension(n.Radius), SweepAngle = Sweep(n.SweepAngle) },
|
||||
LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn
|
||||
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) },
|
||||
LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn
|
||||
@@ -86,7 +88,7 @@ internal static class OwnedCuttingParameters
|
||||
_ => throw new NotSupportedException("Unsupported lead-in runtime type.")
|
||||
};
|
||||
|
||||
private static LeadOut CopyLeadOut(LeadOut source) => source switch
|
||||
internal static LeadOut CopyLeadOut(LeadOut source) => source switch
|
||||
{
|
||||
NoLeadOut n when n.GetType() == typeof(NoLeadOut) => new NoLeadOut(),
|
||||
LineLeadOut n when n.GetType() == typeof(LineLeadOut) => new LineLeadOut
|
||||
@@ -96,7 +98,7 @@ internal static class OwnedCuttingParameters
|
||||
_ => throw new NotSupportedException("Unsupported lead-out runtime type.")
|
||||
};
|
||||
|
||||
private static Tab CopyTab(Tab source)
|
||||
internal static Tab CopyTab(Tab source)
|
||||
{
|
||||
Tab copy = source switch
|
||||
{
|
||||
@@ -136,4 +138,7 @@ internal static class OwnedCuttingParameters
|
||||
// Approach angles are degrees, not lengths. Signed and periodic angles are valid.
|
||||
private static double Angle(double value) => double.IsFinite(value)
|
||||
? value : throw new ArgumentException("Angles must be finite.");
|
||||
|
||||
private static double Sweep(double value) => double.IsFinite(value) && value > 0 && value <= 180
|
||||
? value : throw new ArgumentException("Arc sweep must be in (0, 180] degrees.");
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-post review of pierce zones. When a part's cutting parameters set a
|
||||
/// <see cref="CuttingParameters.PierceDiameter"/>, every lead-in pierce of its placed program must
|
||||
/// keep half of it from every part's clean material on the plate, however the leads were made
|
||||
/// (planned, assigned, placed by hand or locked). A contour pierced directly, without a lead-in,
|
||||
/// is not checked. Reports only; an incomplete check is never a clear result.
|
||||
/// </summary>
|
||||
internal static class PierceZoneReview
|
||||
{
|
||||
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
|
||||
CancellationToken token)
|
||||
{
|
||||
var parts = plate.Parts;
|
||||
var radii = parts.Select(part => part is { BaseDrawing.IsCutOff: false }
|
||||
? (part.CuttingParameters ?? plate.CuttingParameters)?.GetPierceZoneRadius() ?? 0 : 0).ToArray();
|
||||
if (radii.All(radius => radius <= 0))
|
||||
return;
|
||||
var materials = new LeadMaterialSnapshot[parts.Count];
|
||||
var uncaptured = new List<int>();
|
||||
for (var index = 0; index < parts.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var part = parts[index];
|
||||
if (part?.BaseDrawing?.Program == null || part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
// Scribe-only drawings are marks, not material.
|
||||
if (!ExecutionMotionReader.Read(part.BaseDrawing.Program, Vector.Zero, null, token).Motions
|
||||
.Any(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display))
|
||||
continue;
|
||||
materials[index] = LeadMaterialSnapshot.Capture(FixedLeadReview.Clean(part), part.Location, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
// The overlap and rapid checks already report unreadable programs.
|
||||
}
|
||||
if (materials[index] is not { IsComplete: true })
|
||||
uncaptured.Add(index);
|
||||
}
|
||||
// Pierces near material that could not be captured are not checked: say so once per part.
|
||||
foreach (var index in uncaptured)
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, index + 1, null,
|
||||
"Pierce zone check: " + (materials[index]?.Reason ?? "the clean material could not be captured.")
|
||||
+ " Pierces near this part are not checked."));
|
||||
for (var index = 0; index < parts.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (radii[index] <= 0)
|
||||
continue;
|
||||
OwnedExecution execution;
|
||||
try
|
||||
{
|
||||
execution = ExecutionMotionReader.Read(parts[index].Program, parts[index].Location, null, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
continue; // The rapid check reports the unreadable program.
|
||||
}
|
||||
try
|
||||
{
|
||||
Review(index, execution, radii[index]);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, index + 1, null,
|
||||
"Pierce zone check: " + exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
void Review(int index, OwnedExecution execution, double radius)
|
||||
{
|
||||
var budget = 1000000;
|
||||
var motions = execution.Motions;
|
||||
for (var motion = 0; motion < motions.Count; motion++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var move = motions[motion];
|
||||
// A lead-in reached by a rapid (or starting the program) starts at its pierce.
|
||||
if (move.Rapid || move.Layer != LayerType.Leadin || move.Start is not { } pierce
|
||||
|| motion > 0 && !motions[motion - 1].Rapid)
|
||||
continue;
|
||||
var end = motion;
|
||||
while (end + 1 < motions.Count && !motions[end + 1].Rapid && motions[end + 1].Layer == LayerType.Leadin)
|
||||
end++;
|
||||
// A break cut's pierce sits in its breakoff scrap, inside its own part's material.
|
||||
var breakLead = end + 1 < motions.Count && motions[end + 1].Layer == LayerType.Break;
|
||||
for (var other = 0; other < parts.Count; other++)
|
||||
{
|
||||
if (materials[other] is not { IsComplete: true } material || other == index && breakLead)
|
||||
continue;
|
||||
var distance = LeadPathValidator.MaterialDistance(pierce, material, ref budget, token);
|
||||
if (!LeadPathValidator.IsInsideZone(distance, radius))
|
||||
continue;
|
||||
var what = other == index ? "its own part's material" : $"the material of part {other + 1}";
|
||||
findings.Add(new(PostVerificationKind.PierceZone, plateNumber, index + 1,
|
||||
other == index ? null : other + 1,
|
||||
$"Lead-in on cutting contour {FixedLeadReview.ContourAt(execution, motion)}: "
|
||||
+ LeadPathValidator.PierceZoneMessage(pierce, distance, what, radius)
|
||||
+ " Replan cutting or edit the lead before posting.")
|
||||
{ Location = pierce });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ namespace OpenNest.CNC.CuttingPlanning;
|
||||
/// Exact caller-thread record of everything a cutting proposal for one plate depends on:
|
||||
/// part list instance and order, plate quantity/size/quadrant/settings, cutoff definitions, and
|
||||
/// each part's complete cutting state with owned copies of its placed and drawing programs,
|
||||
/// drawing cutoff classification and exact settings content.
|
||||
/// drawing cutoff classification, drawing pins and pins-off, and exact settings content.
|
||||
/// A commit compares it with the live plate and refuses when anything differs.
|
||||
/// </summary>
|
||||
public sealed class PlateCuttingState
|
||||
@@ -24,12 +24,12 @@ public sealed class PlateCuttingState
|
||||
private readonly int quadrant;
|
||||
private readonly ObservableList<CutOff> cutOffList;
|
||||
private readonly CutOffRecord[] cutOffs;
|
||||
private readonly Dictionary<Drawing, (Program Program, Program Copy, bool IsCutOff)> drawings;
|
||||
private readonly Dictionary<Drawing, DrawingRecord> drawings;
|
||||
private readonly Dictionary<CuttingParameters, string> settings;
|
||||
private readonly CuttingParameters plateSettings;
|
||||
|
||||
private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs,
|
||||
Dictionary<Drawing, (Program, Program, bool)> drawings, Dictionary<CuttingParameters, string> settings)
|
||||
Dictionary<Drawing, DrawingRecord> drawings, Dictionary<CuttingParameters, string> settings)
|
||||
{
|
||||
this.settings = settings;
|
||||
plateSettings = plate.CuttingParameters;
|
||||
@@ -61,7 +61,7 @@ public sealed class PlateCuttingState
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
if (plate.Parts == null || plate.CutOffs == null)
|
||||
throw new ArgumentException("Plate part and cutoff lists are required.");
|
||||
var drawings = new Dictionary<Drawing, (Program, Program, bool)>(ReferenceEqualityComparer.Instance);
|
||||
var drawings = new Dictionary<Drawing, DrawingRecord>(ReferenceEqualityComparer.Instance);
|
||||
var settings = new Dictionary<CuttingParameters, string>(ReferenceEqualityComparer.Instance);
|
||||
Fingerprint(plate.CuttingParameters);
|
||||
var records = new List<PartRecord>(plate.Parts.Count);
|
||||
@@ -72,15 +72,18 @@ public sealed class PlateCuttingState
|
||||
throw new ArgumentException("Plate contains a missing part, drawing or program.");
|
||||
var drawing = part.BaseDrawing;
|
||||
if (!drawings.ContainsKey(drawing))
|
||||
drawings.Add(drawing, (drawing.Program, drawing.Program == null ? null
|
||||
: OwnedProgramCopy.Copy(drawing.Program, token), drawing.IsCutOff));
|
||||
drawings.Add(drawing, new(drawing.Program, drawing.Program == null ? null
|
||||
: OwnedProgramCopy.Copy(drawing.Program, token), drawing.IsCutOff,
|
||||
drawing.CutPins, StateFingerprint.Of(drawing.CutPins)));
|
||||
var state = part.CaptureCuttingState();
|
||||
Fingerprint(state.CuttingParameters);
|
||||
records.Add(new(part, state, OwnedProgramCopy.Copy(part.Program, token), BoxValues(state.BoundingBox)));
|
||||
records.Add(new(part, state, OwnedProgramCopy.Copy(part.Program, token), BoxValues(state.BoundingBox),
|
||||
part.DrawingPinsOff));
|
||||
}
|
||||
var cutOffs = plate.CutOffs.Select(c => c == null
|
||||
? throw new ArgumentException("Plate contains a missing cutoff definition.")
|
||||
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit)).ToArray();
|
||||
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit,
|
||||
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
|
||||
return new(plate, records.ToArray(), cutOffs, drawings, settings);
|
||||
|
||||
void Fingerprint(CuttingParameters parameters)
|
||||
@@ -130,12 +133,19 @@ public sealed class PlateCuttingState
|
||||
return $"Part {i + 1} program was edited in place.";
|
||||
if (!SameSettings(live.CuttingParameters))
|
||||
return $"Part {i + 1} cutting settings were edited in place.";
|
||||
var (drawingProgram, drawingCopy, isCutOff) = drawings[part.BaseDrawing];
|
||||
if (!ReferenceEquals(part.BaseDrawing.Program, drawingProgram)
|
||||
|| !ProgramContent.Equal(drawingProgram, drawingCopy, token))
|
||||
var drawing = drawings[part.BaseDrawing];
|
||||
if (!ReferenceEquals(part.BaseDrawing.Program, drawing.Program)
|
||||
|| !ProgramContent.Equal(drawing.Program, drawing.Copy, token))
|
||||
return $"Part {i + 1} drawing program changed.";
|
||||
if (part.BaseDrawing.IsCutOff != isCutOff)
|
||||
if (part.BaseDrawing.IsCutOff != drawing.IsCutOff)
|
||||
return $"Part {i + 1} cutoff classification changed.";
|
||||
// The drawing's pins decide how its pinned contours are cut, and pins-off whether
|
||||
// they apply to this placement: either change makes the proposal stale.
|
||||
if (part.DrawingPinsOff != record.DrawingPinsOff)
|
||||
return $"Part {i + 1} drawing pins were switched on or off.";
|
||||
if (!ReferenceEquals(part.BaseDrawing.CutPins, drawing.Pins)
|
||||
|| StateFingerprint.Of(drawing.Pins) != drawing.PinsFingerprint)
|
||||
return $"Part {i + 1} drawing pins changed.";
|
||||
}
|
||||
if (plate.CutOffs.Count != cutOffs.Length)
|
||||
return "Cutoffs were added or removed.";
|
||||
@@ -146,7 +156,8 @@ public sealed class PlateCuttingState
|
||||
if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
|
||||
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|
||||
|| !Bits(cutOff.Position.Y, record.Position.Y)
|
||||
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit))
|
||||
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit)
|
||||
|| !Bits(cutOff.HeadLockOvercut, record.HeadLockOvercut) || cutOff.FarEdgeFirst != record.FarEdgeFirst)
|
||||
return $"Cutoff {i + 1} definition changed.";
|
||||
}
|
||||
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
|
||||
@@ -188,8 +199,12 @@ public sealed class PlateCuttingState
|
||||
private static bool Bits(double? a, double? b) => a.HasValue == b.HasValue
|
||||
&& (!a.HasValue || Bits(a.Value, b.Value));
|
||||
|
||||
private sealed record PartRecord(Part Part, PartCuttingState State, Program Program, long[] Bounds);
|
||||
private sealed record PartRecord(Part Part, PartCuttingState State, Program Program, long[] Bounds,
|
||||
bool DrawingPinsOff);
|
||||
|
||||
private sealed record DrawingRecord(Program Program, Program Copy, bool IsCutOff,
|
||||
List<ContourCutPin> Pins, string PinsFingerprint);
|
||||
|
||||
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
|
||||
double? StartLimit, double? EndLimit);
|
||||
double? StartLimit, double? EndLimit, double HeadLockOvercut, bool FarEdgeFirst);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Threading;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Support;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
@@ -12,6 +13,15 @@ namespace OpenNest.CNC.CuttingPlanning;
|
||||
public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector Point)
|
||||
{
|
||||
internal PreparedContours Owner { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// A pierce the planner placed off the contour: a perimeter's in open scrap for the pierce
|
||||
/// zone (see <see cref="PreparedContours.ScrapPierceEntry"/>), or a cutout's where eroding it
|
||||
/// leaves room (see <see cref="PreparedContours.ErodedPierceEntries"/>). The lead-in runs
|
||||
/// straight from here to <see cref="Point"/> instead of the configured lead-in; a cutout so
|
||||
/// pierced has no lead-out. Null for every other choice.
|
||||
/// </summary>
|
||||
public Vector? Pierce { get; internal init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -23,16 +33,200 @@ public sealed class PreparedContours
|
||||
private readonly Shape[] shapes;
|
||||
private readonly CuttingParameters parameters;
|
||||
private readonly List<Entity> scribes;
|
||||
// Open break cuts in program order, each travel-ordered with its scrap on the left. They are
|
||||
// not contours: no choices, no material; every full or prefix emission cuts them first.
|
||||
private readonly List<Shape> breakCuts;
|
||||
// Closed cutouts with tab spans, in program order. Like break cuts they are not choices:
|
||||
// every full or prefix emission cuts their pieces after the break cuts.
|
||||
private readonly List<Shape> tabbedCutouts;
|
||||
// By contour ordinal; all null without pins. A pinned contour has exactly one entry and
|
||||
// its own owned settings (the pin's lead-in, lead-out and tab).
|
||||
private readonly ContourChoice[] pinned;
|
||||
private readonly CuttingParameters[] contourParameters;
|
||||
private readonly int[] pinNumbers;
|
||||
// Set when the support bed cannot hold the part level: the perimeter is tabbed and its
|
||||
// automatic entries keep to points where the tab steadies the part. World = local + origin.
|
||||
private readonly PartSupport support;
|
||||
private readonly Vector supportOrigin;
|
||||
// The outside when it is a display perimeter: shown and material, never cut. It is not one of
|
||||
// the contours, so every contour is a hole; a complete emission only appends it as the
|
||||
// never-executed DisplayPerimeterTail. Null for an ordinary cut perimeter.
|
||||
private readonly Shape displayOutline;
|
||||
private bool hasCutPerimeter => displayOutline == null;
|
||||
|
||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, CuttingParameters parameters)
|
||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
||||
CuttingParameters parameters)
|
||||
: this(shapes, scribes, breakCuts, tabbedCutouts, parameters, displayOutline: null)
|
||||
{
|
||||
this.shapes = shapes;
|
||||
this.scribes = scribes;
|
||||
this.parameters = parameters;
|
||||
}
|
||||
|
||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
||||
CuttingParameters parameters, Shape displayOutline)
|
||||
{
|
||||
this.displayOutline = displayOutline;
|
||||
this.shapes = shapes;
|
||||
this.scribes = scribes;
|
||||
this.breakCuts = breakCuts;
|
||||
this.tabbedCutouts = tabbedCutouts;
|
||||
this.parameters = parameters;
|
||||
pinned = new ContourChoice[shapes.Length];
|
||||
pinNumbers = new int[shapes.Length];
|
||||
}
|
||||
|
||||
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
|
||||
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters,
|
||||
unpinned.displayOutline)
|
||||
{
|
||||
contourParameters = new CuttingParameters[shapes.Length];
|
||||
var rejected = new List<CutPinResolution>();
|
||||
foreach (var pin in pins)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (pin == null)
|
||||
throw new ArgumentException("A pin resolution is missing.", nameof(pins));
|
||||
if (!pin.IsResolved)
|
||||
continue; // The caller reports why; the contour stays automatic.
|
||||
var contour = pin.ContourOrdinal;
|
||||
if (contour < 0 || contour >= Count || pin.EntityOrdinal < 0
|
||||
|| pin.EntityOrdinal >= shapes[contour].Entities.Count)
|
||||
throw new ArgumentException("A pin resolution does not belong to these contours.", nameof(pins));
|
||||
if (pinned[contour] != null)
|
||||
throw new ArgumentException("Two pins resolve to one contour.", nameof(pins));
|
||||
// The emitter tabs only the outside, so a tab elsewhere cannot be cut as pinned.
|
||||
if (pin.Pin.HasTab && contour != PerimeterOrdinal)
|
||||
{
|
||||
rejected.Add(pin);
|
||||
continue;
|
||||
}
|
||||
var choice = new ContourChoice(contour, pin.EntityOrdinal,
|
||||
SnapToVertex(shapes[contour], pin.EntityOrdinal, pin.Point))
|
||||
{ Owner = this };
|
||||
ValidateChoice(choice);
|
||||
pinned[contour] = choice;
|
||||
pinNumbers[contour] = pin.PinIndex + 1;
|
||||
contourParameters[contour] = PinParameters(parameters, pin.Pin);
|
||||
}
|
||||
RejectedPins = rejected.AsReadOnly();
|
||||
}
|
||||
|
||||
// A copy that tabs the perimeter for support; every pin keeps its entry, now owned here, and
|
||||
// a pinned perimeter keeps its pin's settings: the analysis is only kept for review.
|
||||
private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab)
|
||||
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
|
||||
source.displayOutline)
|
||||
{
|
||||
for (var contour = 0; contour < Count; contour++)
|
||||
{
|
||||
pinned[contour] = source.pinned[contour] is { } pin ? pin with { Owner = this } : null;
|
||||
pinNumbers[contour] = source.pinNumbers[contour];
|
||||
}
|
||||
contourParameters = source.contourParameters == null
|
||||
? new CuttingParameters[Count]
|
||||
: (CuttingParameters[])source.contourParameters.Clone();
|
||||
if (pinned[PerimeterOrdinal] == null)
|
||||
{
|
||||
var perimeter = OwnedCuttingParameters.Copy(source.ParametersFor(PerimeterOrdinal));
|
||||
perimeter.TabsEnabled = true;
|
||||
perimeter.TabConfig = OwnedCuttingParameters.CopyTab(tab);
|
||||
contourParameters[PerimeterOrdinal] = perimeter;
|
||||
}
|
||||
RejectedPins = source.RejectedPins;
|
||||
this.support = support;
|
||||
supportOrigin = location;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A copy whose perimeter is tabbed with <paramref name="tab"/> where it steadies the part
|
||||
/// on its support bed: <paramref name="support"/> analyzed this part's material placed at
|
||||
/// <paramref name="location"/>, and the perimeter's automatic entries keep to points it
|
||||
/// accepts (<see cref="PartSupport.AcceptsTabAt"/>) plus its best tab point. A pinned
|
||||
/// perimeter keeps its pin's entry, leads and tab (the drawing wins); the copy only keeps
|
||||
/// the analysis, so replay can report a pin that leaves the part unsteady.
|
||||
/// </summary>
|
||||
public PreparedContours WithSupportTab(PartSupport support, Vector location, Tab tab)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(support);
|
||||
ArgumentNullException.ThrowIfNull(tab);
|
||||
PostVerificationGeometry.Validate(location);
|
||||
if (!support.IsComplete || support.BestTabPoint == null)
|
||||
throw new ArgumentException("Support tabs need a complete analysis with a tab point.", nameof(support));
|
||||
if (!hasCutPerimeter)
|
||||
throw new ArgumentException("A display perimeter is never cut, so it cannot hold a support tab.", nameof(support));
|
||||
return new PreparedContours(this, support, location, tab);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The analysis of a part its support bed cannot hold level (see <see cref="WithSupportTab"/>),
|
||||
/// or null; <see cref="SupportOrigin"/> places its local contours in its world frame.
|
||||
/// </summary>
|
||||
public PartSupport Support => support;
|
||||
|
||||
/// <summary>The part's location: world = local + this, for <see cref="Support"/>.</summary>
|
||||
public Vector SupportOrigin => supportOrigin;
|
||||
|
||||
/// <summary>Whether the emitted perimeter is tabbed: by the plate settings, its pin or support.</summary>
|
||||
internal bool PerimeterTabbed => hasCutPerimeter && ParametersFor(PerimeterOrdinal).TabsEnabled;
|
||||
|
||||
public int Count => shapes.Length;
|
||||
public int PerimeterOrdinal => Count - 1;
|
||||
|
||||
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
|
||||
internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
|
||||
/// <summary>
|
||||
/// The cut perimeter, emitted after every hole; <see cref="Count"/> (no contour) when the outside
|
||||
/// is a display perimeter, so every contour below it is a hole.
|
||||
/// </summary>
|
||||
public int PerimeterOrdinal => hasCutPerimeter ? Count - 1 : Count;
|
||||
|
||||
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
|
||||
public bool HasCutPerimeter => hasCutPerimeter;
|
||||
|
||||
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary>
|
||||
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
|
||||
|
||||
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
|
||||
public bool IsPinned(int contourOrdinal) => !(contourOrdinal == PerimeterOrdinal && !hasCutPerimeter)
|
||||
&& GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
|
||||
|
||||
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
|
||||
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
|
||||
|
||||
/// <summary>The pinned contour's only entry, or null for an automatic contour.</summary>
|
||||
public ContourChoice PinnedEntry(int contourOrdinal) => IsPinned(contourOrdinal) ? pinned[contourOrdinal] : null;
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="Capture(Program, CuttingParameters, CancellationToken)"/>, then fixes every
|
||||
/// resolved pin's contour: that contour's only entry is the pin's point and it is emitted with
|
||||
/// the pin's own lead-in, lead-out and tab through the same emitter, so its tab gap is genuine
|
||||
/// and certifiable. <paramref name="pins"/> must come from <see cref="CutPinResolver"/> against
|
||||
/// contours of this same program; unresolved pins are skipped (the caller reports them), a pin
|
||||
/// with a tab on an internal contour is listed in <see cref="RejectedPins"/>, and every other
|
||||
/// contour keeps the automatic selection with <paramref name="parameters"/>.
|
||||
/// </summary>
|
||||
public static PreparedContours Capture(Program program, CuttingParameters parameters,
|
||||
IReadOnlyList<CutPinResolution> pins, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pins);
|
||||
var unpinned = Capture(program, parameters, token);
|
||||
return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned;
|
||||
}
|
||||
|
||||
// The pin's leads replace both lead sets, since the type the emitter detects is
|
||||
// not the caller's concern; angle rounding is off because a pinned entry never moves.
|
||||
private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin)
|
||||
{
|
||||
var owned = OwnedCuttingParameters.Copy(shared);
|
||||
owned.ExternalLeadIn = pin.LeadIn;
|
||||
owned.InternalLeadIn = pin.LeadIn;
|
||||
owned.ExternalLeadOut = pin.LeadOut;
|
||||
owned.InternalLeadOut = pin.LeadOut;
|
||||
owned.RoundLeadInAngles = false;
|
||||
owned.TabsEnabled = pin.HasTab;
|
||||
if (pin.HasTab)
|
||||
owned.TabConfig = pin.Tab;
|
||||
return owned;
|
||||
}
|
||||
|
||||
private CuttingParameters ParametersFor(int contourOrdinal) => contourParameters?[contourOrdinal] ?? parameters;
|
||||
|
||||
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default)
|
||||
{
|
||||
@@ -45,12 +239,31 @@ public sealed class PreparedContours
|
||||
var geometryProgram = CopyForGeometry(program, token);
|
||||
var contours = new List<Shape>();
|
||||
var scribes = new List<Entity>();
|
||||
var breakCuts = new List<Shape>();
|
||||
var tabbedCutouts = new List<Shape>();
|
||||
Shape displayPerimeter = null;
|
||||
Shape breakCut = null;
|
||||
var current = new Shape();
|
||||
foreach (var entity in geometryProgram.ToGeometry())
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (entity.GetType() != typeof(Line) && entity.GetType() != typeof(Arc) && entity.GetType() != typeof(Circle))
|
||||
throw new NotSupportedException("Unsupported native geometry.");
|
||||
if (entity.Layer == SpecialLayers.Break)
|
||||
{
|
||||
if (current.Entities.Count != 0)
|
||||
throw new ArgumentException("Preparation requires closed execution contours.");
|
||||
if (entity is Circle || entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
|
||||
throw new ArgumentException("Degenerate break cut entity.");
|
||||
if (breakCut == null || End(breakCut.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
breakCut = new Shape();
|
||||
breakCuts.Add(breakCut);
|
||||
}
|
||||
breakCut.Entities.Add(entity);
|
||||
continue;
|
||||
}
|
||||
breakCut = null;
|
||||
if (entity.Layer == SpecialLayers.Rapid || entity.Layer == SpecialLayers.Scribe)
|
||||
{
|
||||
if (current.Entities.Count != 0)
|
||||
@@ -59,13 +272,14 @@ public sealed class PreparedContours
|
||||
scribes.Add(entity);
|
||||
continue;
|
||||
}
|
||||
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display)
|
||||
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab
|
||||
&& entity.Layer != SpecialLayers.DisplayPerimeter)
|
||||
throw new ArgumentException("Preparation requires clean cut/display contours.");
|
||||
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
|
||||
throw new ArgumentException("Degenerate contour entity.");
|
||||
if (entity is Circle)
|
||||
{
|
||||
if (current.Entities.Count != 0)
|
||||
if (current.Entities.Count != 0 || entity.Layer == SpecialLayers.Tab)
|
||||
throw new ArgumentException("Circle interrupts an open contour.");
|
||||
current.Entities.Add(entity);
|
||||
FinishContour();
|
||||
@@ -74,9 +288,31 @@ public sealed class PreparedContours
|
||||
if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("Discontinuous native contour.");
|
||||
current.Entities.Add(entity);
|
||||
if (Start(current.Entities[0]).DistanceTo(End(entity)) <= PostVerificationGeometry.Epsilon)
|
||||
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
|
||||
FinishContour();
|
||||
}
|
||||
if (displayPerimeter != null)
|
||||
{
|
||||
if (current.Entities.Count != 0)
|
||||
throw new ArgumentException("Preparation requires nonempty closed contours.");
|
||||
if (contours.Count == 0 && tabbedCutouts.Count == 0 && breakCuts.Count == 0)
|
||||
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
||||
// Every cut must lie on the blank: a non-rectangular blank's box is not enough.
|
||||
// Each cut is sampled along its arcs and must not cross or touch the outline anywhere.
|
||||
var outline = displayPerimeter.ToPolygon();
|
||||
foreach (var shape in contours.Concat(tabbedCutouts).Concat(breakCuts))
|
||||
if (shape.ToPolygon().Vertices.Any(vertex => !outline.ContainsPoint(vertex))
|
||||
|| displayPerimeter.Intersects(shape))
|
||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||
// The search plans a part through its closed contours; tabbed cutouts and break cuts
|
||||
// alone leave it nothing to choose.
|
||||
if (contours.Count == 0)
|
||||
throw new ArgumentException("A display perimeter needs at least one closed cutout without tabs.");
|
||||
foreach (var cut in breakCuts)
|
||||
cut.UpdateBounds();
|
||||
// Never a contour: nothing can choose, emit or lead it. Every contour is a hole.
|
||||
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, displayPerimeter);
|
||||
}
|
||||
if (current.Entities.Count != 0 || contours.Count == 0)
|
||||
throw new ArgumentException("Preparation requires nonempty closed contours.");
|
||||
var perimeter = 0;
|
||||
@@ -90,20 +326,40 @@ public sealed class PreparedContours
|
||||
var outer = contours[perimeter];
|
||||
contours.RemoveAt(perimeter);
|
||||
contours.Add(outer);
|
||||
return new(contours.ToArray(), scribes, ownedParameters);
|
||||
foreach (var cut in breakCuts)
|
||||
cut.UpdateBounds();
|
||||
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters);
|
||||
|
||||
void FinishContour()
|
||||
{
|
||||
current.UpdateBounds();
|
||||
contours.Add(current);
|
||||
if (current.Entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
||||
{
|
||||
if (!current.Entities.TrueForAll(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
||||
throw new ArgumentException("A display perimeter must be one whole closed contour.");
|
||||
if (displayPerimeter != null)
|
||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||
displayPerimeter = current;
|
||||
}
|
||||
else if (ContourCuttingStrategy.IsTabbedCutout(current))
|
||||
{
|
||||
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
|
||||
throw new ArgumentException("A tabbed cutout has nothing to cut.");
|
||||
tabbedCutouts.Add(current);
|
||||
}
|
||||
else
|
||||
contours.Add(current);
|
||||
current = new Shape();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The closest native entry to <paramref name="approach"/>; a pinned contour's pinned entry.</summary>
|
||||
public ContourChoice ClosestEntry(int contourOrdinal, Vector approach)
|
||||
{
|
||||
PostVerificationGeometry.Validate(approach);
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (pinned[contourOrdinal] is { } pin)
|
||||
return pin;
|
||||
var point = shape.ClosestPointTo(approach, out var entity);
|
||||
return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
|
||||
}
|
||||
@@ -112,6 +368,7 @@ public sealed class PreparedContours
|
||||
/// Closest native point first, then source-order vertices/midpoints or eight circle
|
||||
/// angles (0 through 315 degrees). Geometrical duplicates keep their first entity.
|
||||
/// Emit before validating actual paths: circle rounding/clamping may move this point.
|
||||
/// A pinned contour has its pinned entry only.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ContourChoice> Entries(int contourOrdinal, Vector approach, int maxEntries = 16, CancellationToken token = default)
|
||||
{
|
||||
@@ -119,6 +376,8 @@ public sealed class PreparedContours
|
||||
if (maxEntries <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxEntries));
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (pinned[contourOrdinal] is { } pin)
|
||||
return new[] { pin };
|
||||
var entries = new List<ContourChoice>();
|
||||
entries.Add(ClosestEntry(contourOrdinal, approach));
|
||||
for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++)
|
||||
@@ -144,18 +403,112 @@ public sealed class PreparedContours
|
||||
void Add(int entityOrdinal, Vector point)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon))
|
||||
entries.Add(Entry(contourOrdinal, entityOrdinal, point));
|
||||
if (entries.Count >= maxEntries)
|
||||
return;
|
||||
// Compare the entry as emitted: snapping can bring two raw points together.
|
||||
var entry = Entry(contourOrdinal, entityOrdinal, point);
|
||||
if (!entries.Any(e => e.Point.DistanceTo(entry.Point) <= PostVerificationGeometry.Epsilon))
|
||||
entries.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every contour with a native entity within <paramref name="tolerance"/> of
|
||||
/// <paramref name="point"/>, in ordinal order, each with its closest entity and the exact
|
||||
/// closest native point on it. A locating aid for drawing pins, not an entry choice.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<(int ContourOrdinal, int EntityOrdinal, Vector Point)> ContoursNear(Vector point, double tolerance)
|
||||
{
|
||||
PostVerificationGeometry.Validate(point);
|
||||
var found = new List<(int, int, Vector)>();
|
||||
for (var ordinal = 0; ordinal < Count; ordinal++)
|
||||
{
|
||||
var shape = shapes[ordinal];
|
||||
var closest = shape.ClosestPointTo(point, out var entity);
|
||||
if (closest.DistanceTo(point) <= tolerance)
|
||||
found.Add((ordinal, shape.Entities.IndexOf(entity), closest));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
|
||||
{
|
||||
point = SnapToVertex(GetShape(contourOrdinal), entityOrdinal, point);
|
||||
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
|
||||
ValidateChoice(choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An automatic perimeter entry pierced at <paramref name="pierce"/> (local coordinates),
|
||||
/// entering at the closest native point of the perimeter along a straight lead-in. The
|
||||
/// planner's pierce-zone fallback chooses the pierce from the free scrap around the part;
|
||||
/// emission reads only this choice and the perimeter, and the lead validator checks the
|
||||
/// lead as usual. A pinned perimeter keeps its pin's entry.
|
||||
/// </summary>
|
||||
internal ContourChoice ScrapPierceEntry(Vector pierce) => PiercedEntry(PerimeterOrdinal, pierce);
|
||||
|
||||
/// <summary>
|
||||
/// Entries of cutout <paramref name="contourOrdinal"/> pierced where eroding it leaves room
|
||||
/// (see <see cref="ErodedPierce"/>): the planner's fallback when no configured lead-in fits the
|
||||
/// cutout. For each of the first <paramref name="max"/> <paramref name="targets"/> (its own
|
||||
/// entries, usually ranked) the pierce is the eroded point nearest it, and the lead-in runs
|
||||
/// straight from there to the target itself, then, as an alternative, to the cutout's native
|
||||
/// point closest to the pierce. Neither has a lead-out. Nothing for the perimeter or a pinned
|
||||
/// cutout; the lead validator still checks each one.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<ContourChoice> ErodedPierceEntries(int contourOrdinal, IReadOnlyList<ContourChoice> targets,
|
||||
int max, CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
|
||||
return Array.Empty<ContourChoice>();
|
||||
targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray();
|
||||
var found = ErodedPierce.Find(shape, ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
|
||||
targets.Select(t => t.Point).ToArray(), max);
|
||||
var entries = new List<ContourChoice>();
|
||||
for (var i = 0; i < found.Pierces.Count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var pierce = found.Pierces[i];
|
||||
Add(() => targets[i] with { Pierce = pierce });
|
||||
Add(() => PiercedEntry(contourOrdinal, pierce));
|
||||
}
|
||||
return entries;
|
||||
|
||||
void Add(Func<ContourChoice> entry)
|
||||
{
|
||||
try
|
||||
{
|
||||
var choice = entry();
|
||||
ValidateChoice(choice);
|
||||
if (!entries.Any(e => e.Point == choice.Point && e.Pierce == choice.Pierce))
|
||||
entries.Add(choice);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// A pierce on the wall itself has no lead; the others still stand.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A contour entry at the closest native point to a pierce off it, along a straight lead-in.
|
||||
private ContourChoice PiercedEntry(int contourOrdinal, Vector pierce)
|
||||
{
|
||||
PostVerificationGeometry.Validate(pierce);
|
||||
var shape = GetShape(contourOrdinal);
|
||||
var point = shape.ClosestPointTo(pierce, out var entity);
|
||||
point = SnapToVertex(shape, shape.Entities.IndexOf(entity), point);
|
||||
var choice = new ContourChoice(contourOrdinal, shape.Entities.IndexOf(entity), point)
|
||||
{
|
||||
Owner = this,
|
||||
Pierce = pierce,
|
||||
};
|
||||
ValidateChoice(choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The uncapped preferred automatic start catalogue for one contour, in preference then
|
||||
/// contour-travel order: convex corners of the contour's own winding, then straight-edge
|
||||
@@ -170,9 +523,11 @@ public sealed class PreparedContours
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (pinned[contourOrdinal] is { } pin)
|
||||
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
||||
if (IsSingleCircle(shape))
|
||||
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue.");
|
||||
return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token));
|
||||
return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -183,20 +538,62 @@ public sealed class PreparedContours
|
||||
/// <paramref name="lookAhead"/> (pass the arrival point there when there is no next cut).
|
||||
/// A pure-circle contour therefore yields compass points instead of refusing. Every
|
||||
/// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the
|
||||
/// preferred tier; fallbacks never replace a preferred point at the same geometry.
|
||||
/// preferred tier; fallbacks never replace a preferred point at the same geometry. A
|
||||
/// pinned contour's catalogue is its pinned entry alone: nothing may relocate it.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
||||
int contourOrdinal,
|
||||
Vector? lookAhead = null,
|
||||
CancellationToken token = default)
|
||||
CancellationToken token = default,
|
||||
SupportEntries supportEntries = SupportEntries.Steadying)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (pinned[contourOrdinal] is { } pin)
|
||||
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
||||
var all = IsSingleCircle(shape)
|
||||
? new List<ContourEntryCandidate>()
|
||||
: PreferredCandidates(shape, contourOrdinal, token);
|
||||
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
|
||||
return MergeByGeometry(all);
|
||||
return SupportFiltered(contourOrdinal, all, supportEntries);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
|
||||
/// its automatic catalogue then keeps to steadying points unless asked for a fallback.
|
||||
/// </summary>
|
||||
internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal);
|
||||
|
||||
/// <summary>
|
||||
/// The support margin with this perimeter's tab at <paramref name="choice"/> (see
|
||||
/// <see cref="PartSupport.MarginWith"/>); negative infinity when it is not tabbed for support.
|
||||
/// </summary>
|
||||
internal double SupportMarginAt(ContourChoice choice) =>
|
||||
support?.MarginWith(choice.Point + supportOrigin) ?? double.NegativeInfinity;
|
||||
|
||||
/// <summary>Whether a support tab at <paramref name="choice"/> holds the part level (see <see cref="PartSupport.HoldsLevelWithTabAt"/>).</summary>
|
||||
internal bool HoldsLevelAt(ContourChoice choice) =>
|
||||
support != null && support.HoldsLevelWithTabAt(choice.Point + supportOrigin);
|
||||
|
||||
/// <summary>Whether a support tab at <paramref name="choice"/> keeps the inside-corner clearance.</summary>
|
||||
internal bool IsClearOfInsideCorners(ContourChoice choice) =>
|
||||
support == null || support.IsClearOfInsideCorners(choice.Point + supportOrigin);
|
||||
|
||||
// A perimeter tabbed for support keeps the candidates whose tab steadies the part, and gains
|
||||
// the outline's best tab point. The search's fallbacks, when none of them has a lead-in that
|
||||
// fits, keep every candidate clear of inside corners and then every candidate, still tabbed.
|
||||
private IReadOnlyList<ContourEntryCandidate> SupportFiltered(int contourOrdinal,
|
||||
List<ContourEntryCandidate> candidates, SupportEntries entries)
|
||||
{
|
||||
if (support == null || contourOrdinal != PerimeterOrdinal || entries == SupportEntries.Any)
|
||||
return MergeByGeometry(candidates);
|
||||
if (entries == SupportEntries.ClearOfInsideCorners)
|
||||
return MergeByGeometry(candidates.Where(c => IsClearOfInsideCorners(c.Choice)).ToList());
|
||||
var kept = candidates.Where(c => support.AcceptsTabAt(c.Choice.Point + supportOrigin)).ToList();
|
||||
var best = ClosestEntry(PerimeterOrdinal, support.BestTabPoint!.Value - supportOrigin);
|
||||
if (!IsForbiddenVertex(GetShape(PerimeterOrdinal), best.Point))
|
||||
kept.Add(new ContourEntryCandidate(best, AutomaticEntryKind.SupportTab));
|
||||
return MergeByGeometry(kept);
|
||||
}
|
||||
|
||||
private static bool IsSingleCircle(Shape shape) =>
|
||||
@@ -254,12 +651,11 @@ public sealed class PreparedContours
|
||||
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var cornerPoint = End(shape.Entities[corner.EntityOrdinal]);
|
||||
// Back INTO each incident edge from the corner: the edge ending at the
|
||||
// corner retreats against its own travel, the edge starting at the corner
|
||||
// advances along its own travel.
|
||||
AddInset(corner.EntityOrdinal, cornerPoint, inward: false);
|
||||
AddInset((corner.EntityOrdinal + 1) % count, cornerPoint, inward: true);
|
||||
AddInset(corner.EntityOrdinal, inward: false);
|
||||
AddInset((corner.EntityOrdinal + 1) % count, inward: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +672,7 @@ public sealed class PreparedContours
|
||||
token.ThrowIfCancellationRequested();
|
||||
return fallbacks;
|
||||
|
||||
void AddInset(int entityOrdinal, Vector corner, bool inward)
|
||||
void AddInset(int entityOrdinal, bool inward)
|
||||
{
|
||||
// `inward` selects along the edge's own travel from its start; without it the
|
||||
// point retreats against travel. Both ways move BACK INTO the edge from the
|
||||
@@ -285,12 +681,15 @@ public sealed class PreparedContours
|
||||
return;
|
||||
var direction = (line.EndPoint - line.StartPoint).Normalize();
|
||||
var offset = direction * (2 * lead);
|
||||
var point = inward ? corner + offset : corner - offset;
|
||||
// Adjacent native endpoints may differ within contour-continuity tolerance.
|
||||
// Anchor to the selected edge itself, never the preceding edge's vertex.
|
||||
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
|
||||
// Strictly inside the edge by projection parameter (distance alone loses the
|
||||
// sign when a short edge is overshoot): never the corner, never the far
|
||||
// endpoint (where a reflex vertex might sit). Short edges omit the point.
|
||||
// endpoint (where a reflex vertex might sit). Short edges omit the point. The
|
||||
// margin is the vertex snap distance, so Entry cannot snap it onto either end.
|
||||
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
|
||||
if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon)
|
||||
if (t <= OpenNest.Math.Tolerance.Epsilon || t >= line.Length - OpenNest.Math.Tolerance.Epsilon)
|
||||
return;
|
||||
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
|
||||
}
|
||||
@@ -422,7 +821,7 @@ public sealed class PreparedContours
|
||||
public Program Emit(IReadOnlyList<ContourChoice> choices)
|
||||
{
|
||||
Validate(choices, true);
|
||||
return EmitPrefix(choices);
|
||||
return EmitOwned(choices, complete: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -439,19 +838,38 @@ public sealed class PreparedContours
|
||||
throw new ArgumentException("Foreign contour choice.");
|
||||
ValidateChoice(choice);
|
||||
// Same owned clones as a real emission; the source shapes/settings are never used
|
||||
// directly, so the probe cannot drift the preparation or mutate it.
|
||||
// directly, so the probe cannot drift the preparation or mutate it. Isolated emission
|
||||
// reads only the chosen contour and the contour count, so only that contour is cloned.
|
||||
var owned = new Shape[shapes.Length];
|
||||
owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
|
||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice);
|
||||
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal);
|
||||
}
|
||||
|
||||
// Each prefix is a standalone owned program, including the same scribes once.
|
||||
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
|
||||
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
|
||||
{
|
||||
Validate(choices, false);
|
||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices);
|
||||
return EmitOwned(choices, complete: false);
|
||||
}
|
||||
|
||||
// Only a complete emission carries a display perimeter's tail: prefixes are search probes.
|
||||
private Program EmitOwned(IReadOnlyList<ContourChoice> choices, bool complete) =>
|
||||
new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
|
||||
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
|
||||
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal,
|
||||
complete ? (Shape)displayOutline?.Clone() : null);
|
||||
|
||||
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
|
||||
public int BreakCutCount => breakCuts.Count;
|
||||
|
||||
/// <summary>Tabbed cutouts this preparation cuts, as pieces, after its break cuts and before its contours.</summary>
|
||||
public int TabbedCutoutCount => tabbedCutouts.Count;
|
||||
|
||||
/// <summary>Open pieces the tabbed cutouts are cut in, each a lead-in and cut group of its own.</summary>
|
||||
public int TabbedPieceCount => tabbedCutouts.Sum(c => ContourCuttingStrategy.TabbedCutoutPieces(c).Count);
|
||||
|
||||
internal void ValidateCompleteChoices(IReadOnlyList<ContourChoice> choices) => Validate(choices, true);
|
||||
|
||||
// Build expected-emission metadata independently of the selected payload BEFORE
|
||||
@@ -461,7 +879,11 @@ public sealed class PreparedContours
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
|
||||
return new(this, choices, expected, parameters.TabsEnabled ? parameters.TabConfig.Size : 0);
|
||||
// Only the outside is tabbed, with its own pin's tab when it is pinned.
|
||||
if (!hasCutPerimeter)
|
||||
return new(this, choices, expected, 0);
|
||||
var perimeter = ParametersFor(PerimeterOrdinal);
|
||||
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
|
||||
}
|
||||
|
||||
private void Validate(IReadOnlyList<ContourChoice> choices, bool complete)
|
||||
@@ -488,6 +910,15 @@ public sealed class PreparedContours
|
||||
var distance = shape.Entities[choice.EntityOrdinal].ClosestPointTo(choice.Point).DistanceTo(choice.Point);
|
||||
if (!double.IsFinite(distance) || distance > PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("Entry is not on the selected native entity.");
|
||||
// A pinned contour is cut exactly as pinned: no other entry may be emitted for it.
|
||||
if (pinned[choice.ContourOrdinal] is { } pin && pin != choice)
|
||||
throw new ArgumentException("A pinned contour keeps its drawing pin's entry.");
|
||||
if (choice.Pierce is { } pierce)
|
||||
{
|
||||
PostVerificationGeometry.Validate(pierce);
|
||||
if (pierce.DistanceTo(choice.Point) <= PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("A planner-placed pierce must lie off its contour.");
|
||||
}
|
||||
}
|
||||
|
||||
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
|
||||
@@ -510,6 +941,47 @@ public sealed class PreparedContours
|
||||
return centres;
|
||||
}
|
||||
|
||||
/// <summary>The bounding box of contour <paramref name="contourOrdinal"/>, local. A routing hint only.</summary>
|
||||
internal Box ContourBounds(int contourOrdinal) => GetShape(contourOrdinal).BoundingBox;
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>.
|
||||
/// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
|
||||
/// </summary>
|
||||
internal bool HoleContains(int contourOrdinal, Vector point)
|
||||
{
|
||||
var shape = GetShape(contourOrdinal);
|
||||
return contourOrdinal != PerimeterOrdinal && shape.BoundingBox.Contains(point)
|
||||
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emission splits a contour at its entry with <see cref="Arc.SplitAt"/>/<see cref="Line.SplitAt"/>,
|
||||
/// which treat a point within <see cref="OpenNest.Math.Tolerance.Epsilon"/> of an endpoint as that endpoint and
|
||||
/// start the contour there. An entry that close to a vertex is that vertex, so the lead-in ends
|
||||
/// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
|
||||
/// stored centre sits beside the chord's midpoint) is never cut.
|
||||
/// </summary>
|
||||
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
|
||||
{
|
||||
if (entityOrdinal < 0 || entityOrdinal >= shape.Entities.Count
|
||||
|| shape.Entities[entityOrdinal] is not (Line or Arc))
|
||||
return point;
|
||||
var entity = shape.Entities[entityOrdinal];
|
||||
// Within the verifier's epsilon the entry already cuts the contour whole: leave it, so
|
||||
// programs emitted before this snap (saved pinned entries) re-emit unchanged.
|
||||
bool Snaps(Vector vertex)
|
||||
{
|
||||
var distance = point.DistanceTo(vertex);
|
||||
return distance > PostVerificationGeometry.Epsilon && distance < OpenNest.Math.Tolerance.Epsilon;
|
||||
}
|
||||
var start = Start(entity);
|
||||
if (point.DistanceTo(start) < OpenNest.Math.Tolerance.Epsilon)
|
||||
return Snaps(start) ? start : point;
|
||||
var end = End(entity);
|
||||
return Snaps(end) ? end : point;
|
||||
}
|
||||
|
||||
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
|
||||
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
|
||||
|
||||
|
||||
@@ -11,10 +11,29 @@ namespace OpenNest.CNC.CuttingPlanning;
|
||||
public sealed class ReleasedContourState
|
||||
{
|
||||
private readonly List<Obstacle> obstacles = new();
|
||||
private readonly bool reportMissingLeadIns;
|
||||
// How the last analysed part ended: the contours it numbered, and whether nothing it left
|
||||
// (open fragments, deferred findings, a reported tab, a dangling lead-in) can change how
|
||||
// motions appended to it are judged. See Continue.
|
||||
private int partContours;
|
||||
private bool partClosed;
|
||||
|
||||
public ReleasedContourState() : this(true) { }
|
||||
|
||||
/// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning
|
||||
/// may pierce directly on a contour and checks travel without requiring a lead.</param>
|
||||
public ReleasedContourState(bool reportMissingLeadIns)
|
||||
{
|
||||
this.reportMissingLeadIns = reportMissingLeadIns;
|
||||
}
|
||||
|
||||
public ReleasedContourState Copy()
|
||||
{
|
||||
var copy = new ReleasedContourState();
|
||||
var copy = new ReleasedContourState(reportMissingLeadIns)
|
||||
{
|
||||
partContours = partContours,
|
||||
partClosed = partClosed,
|
||||
};
|
||||
copy.obstacles.AddRange(obstacles);
|
||||
return copy;
|
||||
}
|
||||
@@ -28,7 +47,18 @@ public sealed class ReleasedContourState
|
||||
/// being open, never become obstacles.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
|
||||
int partNumber, bool cutoff, CancellationToken token = default)
|
||||
int partNumber, bool cutoff, CancellationToken token = default) =>
|
||||
Check(execution, arrival, partNumber, cutoff, null, token).Strict;
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="Check(OwnedExecution, Vector?, int, bool, CancellationToken)"/>, but a
|
||||
/// finding whose offending motion <paramref name="isFixed"/> owns (by motion index) is
|
||||
/// returned apart: a fixed emission is accepted as given and only reported. The checker
|
||||
/// state advances exactly as it would without ownership.
|
||||
/// </summary>
|
||||
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed) Check(
|
||||
OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed,
|
||||
CancellationToken token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(execution);
|
||||
if (arrival is { } point)
|
||||
@@ -36,24 +66,88 @@ public sealed class ReleasedContourState
|
||||
var moves = execution.Motions.ToArray();
|
||||
moves[0] = moves[0].WithStart(arrival);
|
||||
var findings = new List<PostVerificationFinding>();
|
||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token);
|
||||
return findings.AsReadOnly();
|
||||
var fixedFindings = new List<PostVerificationFinding>();
|
||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings);
|
||||
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks <paramref name="execution"/> as the continuation of the part the last check ended
|
||||
/// in, as if that part's whole execution had been checked with these motions appended: the
|
||||
/// findings are those the appended motions would add, and the state advances the same way.
|
||||
/// It needs that part to have ended with every cut closed and nothing deferred, and the
|
||||
/// continuation to open with a rapid and end the same way; otherwise the result is null and
|
||||
/// this state must be discarded, and the caller checks the whole execution instead. Fixed
|
||||
/// emissions and cutoffs are not continued.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<PostVerificationFinding> Continue(OwnedExecution execution, Vector arrival,
|
||||
int partNumber, CancellationToken token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(execution);
|
||||
PostVerificationGeometry.Validate(arrival);
|
||||
if (!partClosed || execution.Motions.Count == 0 || !execution.Motions[0].Rapid)
|
||||
return null;
|
||||
var moves = execution.Motions.ToArray();
|
||||
moves[0] = moves[0].WithStart(arrival);
|
||||
var findings = new List<PostVerificationFinding>();
|
||||
AnalyzeMoves(moves, false, findings, 1, partNumber, token, contoursBefore: partContours);
|
||||
return partClosed ? findings.AsReadOnly() : null;
|
||||
}
|
||||
|
||||
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
|
||||
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token)
|
||||
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
|
||||
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
|
||||
bool proveOpenCuts = false, int contoursBefore = 0)
|
||||
{
|
||||
var contour = new List<PostVerificationGeometry.Curve>();
|
||||
var unfinished = new List<PostVerificationGeometry.Curve[]>();
|
||||
var current = new CutGroup();
|
||||
var unfinished = new List<CutGroup>();
|
||||
// Part-local cuts read only by the open-cut proof below: closed contours, break cuts
|
||||
// and leads no contour owns. Each lead belongs to the cut it enters or leaves.
|
||||
var closed = new List<CutGroup>();
|
||||
var otherOpen = new List<CutGroup>();
|
||||
var loose = new CutGroup();
|
||||
var leadIns = new List<PostVerificationGeometry.Curve>();
|
||||
CutGroup previous = null;
|
||||
var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>();
|
||||
var hasLead = false;
|
||||
var contourNumber = 0;
|
||||
foreach (var move in moves)
|
||||
var contourNumber = contoursBefore;
|
||||
var inBreakCut = false;
|
||||
var reportedTab = false;
|
||||
for (var index = 0; index < moves.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var move = moves[index];
|
||||
var owned = isFixed?.Invoke(index) == true;
|
||||
if (!move.Rapid && move.Layer == LayerType.Break)
|
||||
{
|
||||
// A break cut is intentionally open and retained by its uncut tabs: it is never a
|
||||
// rapid obstacle and never an unexplained fragment, but it is still pierced.
|
||||
if (!inBreakCut)
|
||||
{
|
||||
Finish();
|
||||
if (reportMissingLeadIns && !cutoff && !hasLead)
|
||||
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||
"A break cut is missing lead-in geometry.")
|
||||
{ Location = move.Curve.Start }, owned);
|
||||
inBreakCut = true;
|
||||
hasLead = false;
|
||||
previous = new CutGroup();
|
||||
previous.Leads.AddRange(leadIns);
|
||||
leadIns.Clear();
|
||||
otherOpen.Add(previous);
|
||||
}
|
||||
if (move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||
previous.Curves.Add(move.Curve);
|
||||
continue;
|
||||
}
|
||||
inBreakCut = false;
|
||||
if (move.Rapid)
|
||||
{
|
||||
Finish();
|
||||
hasLead = false;
|
||||
loose.Leads.AddRange(leadIns);
|
||||
leadIns.Clear();
|
||||
previous = null;
|
||||
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
|
||||
continue;
|
||||
var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X),
|
||||
@@ -65,75 +159,337 @@ public sealed class ReleasedContourState
|
||||
// A rapid well clear of a contour's extent can neither cross nor touch it.
|
||||
if (reach.IsClearOf(obstacle.Extent))
|
||||
continue;
|
||||
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
|
||||
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
||||
// A rapid leaving an overcut departs from its retraced tail, which lies on the first
|
||||
// cut only within the seam tolerance its arcs were matched at. Contact within that
|
||||
// much of the departure is the departure itself.
|
||||
var from = start;
|
||||
if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
|
||||
&& start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
|
||||
from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
|
||||
if (PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
|
||||
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
||||
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
||||
$"of part {obstacle.Part}."));
|
||||
$"of part {obstacle.Part}.")
|
||||
{
|
||||
Location = (start + move.End) * 0.5,
|
||||
RapidStart = start,
|
||||
RapidEnd = move.End,
|
||||
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
|
||||
}, owned);
|
||||
}
|
||||
}
|
||||
else if (move.Layer == LayerType.Leadin)
|
||||
{
|
||||
Finish();
|
||||
previous = null;
|
||||
hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon;
|
||||
if (move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||
leadIns.Add(move.Curve);
|
||||
}
|
||||
else if (move.Layer is LayerType.Leadout or LayerType.Scribe)
|
||||
{
|
||||
if (move.Layer == LayerType.Leadout && contour.Count > 0
|
||||
&& !PostVerificationGeometry.Closed(contour)
|
||||
&& move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
|
||||
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
|
||||
"Rapid safety for that contour requires manual review."));
|
||||
if (move.Layer == LayerType.Leadout && move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
// Withheld until the part ends, where the open-cut proof may show it cuts no gap.
|
||||
if (current.Curves.Count > 0 && !PostVerificationGeometry.Closed(current.Curves))
|
||||
Defer(new(PostVerificationKind.Incomplete, plate, part, null,
|
||||
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
|
||||
"Rapid safety for that contour requires manual review."), owned);
|
||||
(previous ?? loose).Leads.Add(move.Curve);
|
||||
}
|
||||
Finish();
|
||||
hasLead = false;
|
||||
if (move.Layer == LayerType.Scribe)
|
||||
previous = null;
|
||||
}
|
||||
else if (move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
if (contour.Count == 0)
|
||||
// A clean program's tab spans close their cutout but are not meant to be cut.
|
||||
if (move.Layer == LayerType.Tab && !reportedTab)
|
||||
{
|
||||
reportedTab = true;
|
||||
Add(new(PostVerificationKind.Incomplete, plate, part, null,
|
||||
"A tabbed cutout still has its tabs as motions, so it may be cut free. " +
|
||||
"Assign lead-ins or plan cutting to cut it in pieces around its tabs.")
|
||||
{ Location = move.Curve.Start }, owned);
|
||||
}
|
||||
if (current.Curves.Count == 0)
|
||||
{
|
||||
contourNumber++;
|
||||
if (!cutoff && !hasLead)
|
||||
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
|
||||
if (reportMissingLeadIns && !cutoff && !hasLead)
|
||||
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||
$"Cutting contour {contourNumber} is missing lead-in geometry.")
|
||||
{ Location = move.Curve.Start }, owned);
|
||||
hasLead = false;
|
||||
// A rapid can pause/reposition without leaving any material gap.
|
||||
// Retain already-cut fragments, but do not turn them into obstacles
|
||||
// until an actually continuous chain closes.
|
||||
var previous = unfinished.FindIndex(chain =>
|
||||
chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
|
||||
if (previous >= 0)
|
||||
var resumed = unfinished.FindIndex(chain =>
|
||||
chain.Curves[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
|
||||
if (resumed >= 0)
|
||||
{
|
||||
contour.AddRange(unfinished[previous]);
|
||||
unfinished.RemoveAt(previous);
|
||||
current = unfinished[resumed];
|
||||
unfinished.RemoveAt(resumed);
|
||||
}
|
||||
current.Leads.AddRange(leadIns);
|
||||
leadIns.Clear();
|
||||
}
|
||||
contour.Add(move.Curve);
|
||||
current.Curves.Add(move.Curve);
|
||||
current.Fixed |= owned;
|
||||
previous = current;
|
||||
// A completed contour becomes an obstacle immediately, not at part end.
|
||||
if (PostVerificationGeometry.Closed(contour))
|
||||
if (PostVerificationGeometry.Closed(current.Curves))
|
||||
Finish();
|
||||
else if (OvercutClosure(current.Curves) is { } closing)
|
||||
{
|
||||
current.OvercutEnd = current.Curves[^1].End;
|
||||
current.Curves[^1] = closing;
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
Finish();
|
||||
if (unfinished.Count > 1)
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
|
||||
loose.Leads.AddRange(leadIns);
|
||||
// Open cuts that provably enclose nothing retain all their material, however many
|
||||
// pieces a tabbed cutout was cut in. Otherwise they need manual review. Planning proves
|
||||
// them too (its own tabbed cutouts are cut in pieces without lead-outs), but only
|
||||
// pre-post review lets the proof clear a lead-out: planning keeps refusing to certify
|
||||
// its own tabs followed by a lead-out.
|
||||
var retained = !cutoff && (unfinished.Count > 1 || deferred.Count > 0)
|
||||
&& unfinished.All(LeadsClearOpenEnds)
|
||||
&& OpenCutsEncloseNothing(closed, unfinished.Concat(otherOpen).Append(loose).ToList(), token);
|
||||
for (var i = deferred.Count - 1; i >= 0 && !(retained && proveOpenCuts); i--)
|
||||
deferred[i].Target.Insert(deferred[i].Index, deferred[i].Finding);
|
||||
// Only one open fragment per part can be the planner's own certified tab, so a
|
||||
// further fragment that a fixed emission cut makes this uncertainty that emission's.
|
||||
if (unfinished.Count > 1 && !retained)
|
||||
Add(new(PostVerificationKind.Incomplete, plate, part, null,
|
||||
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
|
||||
"they cannot be assumed to be retained by tabs. Review rapid travel manually."));
|
||||
"they cannot be assumed to be retained by tabs. Review rapid travel manually."),
|
||||
unfinished.Any(chain => chain.Fixed));
|
||||
partContours = contourNumber;
|
||||
partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab
|
||||
&& !inBreakCut && loose.Leads.Count == 0;
|
||||
|
||||
void Add(PostVerificationFinding finding, bool fixedEmission)
|
||||
{
|
||||
if (fixedEmission && fixedFindings != null)
|
||||
fixedFindings.Add(finding);
|
||||
else
|
||||
findings.Add(finding);
|
||||
}
|
||||
|
||||
// Records the finding's place among the others, in case it is reported after all.
|
||||
void Defer(PostVerificationFinding finding, bool fixedEmission)
|
||||
{
|
||||
var target = fixedEmission && fixedFindings != null ? fixedFindings : findings;
|
||||
deferred.Add((target, target.Count, finding));
|
||||
}
|
||||
|
||||
void Finish()
|
||||
{
|
||||
if (contour.Count == 0)
|
||||
if (current.Curves.Count == 0)
|
||||
return;
|
||||
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
|
||||
// no flag or tab configuration is used as evidence of retention.
|
||||
if (!cutoff && PostVerificationGeometry.Closed(contour))
|
||||
obstacles.Add(new(part, contourNumber, contour.ToArray()));
|
||||
if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
|
||||
{
|
||||
obstacles.Add(new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd));
|
||||
closed.Add(current);
|
||||
}
|
||||
else if (!cutoff)
|
||||
unfinished.Add(contour.ToArray());
|
||||
contour.Clear();
|
||||
unfinished.Add(current);
|
||||
current = new CutGroup();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves)
|
||||
/// <summary>
|
||||
/// The closing piece of a contour cut on past its own start along its first cut, as a source
|
||||
/// CAM's seam overcut is (see <see cref="SeamOvercut"/> for the clean-contour form): the last
|
||||
/// cut passes back through the start and its tail retraces the first cut. The tail encloses
|
||||
/// nothing, so the contour is closed by the last cut up to its start. Null for any other shape.
|
||||
/// Arcs of one circle may carry centers derived from their own rounded endpoints, so the seam
|
||||
/// is matched at <see cref="PostVerificationGeometry.RaySeamTolerance"/>, the closure seam
|
||||
/// ray parity already admits; the obstacle keeps that seam, never the retraced tail.
|
||||
/// </summary>
|
||||
private static PostVerificationGeometry.Curve OvercutClosure(IReadOnlyList<PostVerificationGeometry.Curve> curves)
|
||||
{
|
||||
if (curves.Count < 2)
|
||||
return null;
|
||||
const double tolerance = PostVerificationGeometry.RaySeamTolerance;
|
||||
var first = curves[0];
|
||||
var last = curves[^1];
|
||||
if (!last.SameDirection(first, tolerance) || !last.Contains(first.Start, tolerance)
|
||||
|| !first.Contains(last.End, tolerance))
|
||||
return null;
|
||||
var seam = last.ClosestPoint(first.Start);
|
||||
var along = last.DistanceAlong(seam);
|
||||
var tail = last.Length - along;
|
||||
return along > tolerance && tail > tolerance && tail <= first.Length + tolerance ? last.Until(seam) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether one part's open cuts, with their leads, can release no material. A closed
|
||||
/// contour with its own leads already encloses whatever it releases and counts as one node.
|
||||
/// Open cuts may touch any cut only at their own ends, and must form no loop through those
|
||||
/// nodes or each other: a forest of open paths separates no region of the sheet. Kerf and
|
||||
/// tab strength are not modelled, exactly as for a single open contour.
|
||||
/// </summary>
|
||||
private const double JointTolerance = 1e-5;
|
||||
|
||||
/// <summary>
|
||||
/// Whether every lead at an open end of <paramref name="group"/> stays out of the uncut gap
|
||||
/// beyond it: a lead-out never reaches ahead of the end it leaves, and a lead-in never
|
||||
/// behind the start it enters. A lead that curls into the gap can cut the tab with its kerf
|
||||
/// even where its centerline does not touch the next cut.
|
||||
/// </summary>
|
||||
private static bool LeadsClearOpenEnds(CutGroup group)
|
||||
{
|
||||
var start = group.Curves[0].Start;
|
||||
var end = group.Curves[^1].End;
|
||||
var behindStart = group.Curves[0].Tangent(false) * -1;
|
||||
var aheadOfEnd = group.Curves[^1].Tangent(true);
|
||||
foreach (var lead in group.Leads)
|
||||
{
|
||||
if (Touches(lead, start) && lead.Reach(start, behindStart) > PostVerificationGeometry.Epsilon)
|
||||
return false;
|
||||
if (Touches(lead, end) && lead.Reach(end, aheadOfEnd) > PostVerificationGeometry.Epsilon)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
static bool Touches(PostVerificationGeometry.Curve lead, Vector point) =>
|
||||
lead.Start.DistanceTo(point) <= PostVerificationGeometry.Epsilon
|
||||
|| lead.End.DistanceTo(point) <= PostVerificationGeometry.Epsilon;
|
||||
}
|
||||
|
||||
private static bool OpenCutsEncloseNothing(IReadOnlyList<CutGroup> closed, IReadOnlyList<CutGroup> open,
|
||||
CancellationToken token)
|
||||
{
|
||||
var edges = open.SelectMany(group => group.Curves.Concat(group.Leads)).ToList();
|
||||
var nodes = new DisjointSet(closed.Count + 2 * edges.Count);
|
||||
int Node(int edge, bool end) => closed.Count + 2 * edge + (end ? 1 : 0);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < closed.Count; i++)
|
||||
for (var j = 0; j < i; j++)
|
||||
if (Touch(closed[i], closed[j], token))
|
||||
nodes.Union(i, j);
|
||||
for (var a = 0; a < edges.Count; a++)
|
||||
{
|
||||
var edge = edges[a];
|
||||
if (edge.Start.DistanceTo(edge.End) <= PostVerificationGeometry.Epsilon)
|
||||
return false;
|
||||
for (var g = 0; g < closed.Count; g++)
|
||||
{
|
||||
foreach (var other in closed[g].Curves.Concat(closed[g].Leads))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (edge.Extent.IsClearOf(other.Extent))
|
||||
continue;
|
||||
foreach (var point in Contacts(edge, other))
|
||||
{
|
||||
if (EndAt(edge, point, other) is not { } end)
|
||||
return false;
|
||||
nodes.Union(Node(a, end), g);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var b = a + 1; b < edges.Count; b++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var other = edges[b];
|
||||
if (edge.Extent.IsClearOf(other.Extent))
|
||||
continue;
|
||||
foreach (var point in Contacts(edge, other))
|
||||
{
|
||||
if (EndAt(edge, point, other) is not { } end || EndAt(other, point, edge) is not { } otherEnd)
|
||||
return false;
|
||||
nodes.Union(Node(a, end), Node(b, otherEnd));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is ArgumentException or NotSupportedException
|
||||
or ArithmeticException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var forest = new DisjointSet(closed.Count + 2 * edges.Count);
|
||||
for (var a = 0; a < edges.Count; a++)
|
||||
{
|
||||
var start = nodes.Find(Node(a, false));
|
||||
var end = nodes.Find(Node(a, true));
|
||||
if (forest.Find(start) == forest.Find(end))
|
||||
return false;
|
||||
forest.Union(start, end);
|
||||
}
|
||||
return true;
|
||||
|
||||
static IReadOnlyList<Vector> Contacts(PostVerificationGeometry.Curve a, PostVerificationGeometry.Curve b)
|
||||
{
|
||||
var points = a.Contacts(b, out var overlap);
|
||||
if (overlap)
|
||||
throw new ArgumentException("Cuts share a span.");
|
||||
return points;
|
||||
}
|
||||
|
||||
// Native contacts at a near-tangent joint (a lead or line meeting an arc) can land
|
||||
// slightly off the vertex the two cuts share; that is still their shared end.
|
||||
static bool? EndAt(PostVerificationGeometry.Curve curve, Vector point, PostVerificationGeometry.Curve other)
|
||||
{
|
||||
foreach (var end in new[] { false, true })
|
||||
{
|
||||
var vertex = end ? curve.End : curve.Start;
|
||||
if (point.DistanceTo(vertex) <= PostVerificationGeometry.Epsilon
|
||||
|| point.DistanceTo(vertex) <= JointTolerance
|
||||
&& (vertex.DistanceTo(other.Start) <= PostVerificationGeometry.Epsilon
|
||||
|| vertex.DistanceTo(other.End) <= PostVerificationGeometry.Epsilon))
|
||||
return end;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool Touch(CutGroup a, CutGroup b, CancellationToken token)
|
||||
{
|
||||
foreach (var curve in a.Curves.Concat(a.Leads))
|
||||
foreach (var other in b.Curves.Concat(b.Leads))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!curve.Extent.IsClearOf(other.Extent) && Contacts(curve, other).Count > 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One contour's cut motions with the leads entering and leaving it.</summary>
|
||||
private sealed class CutGroup
|
||||
{
|
||||
internal List<PostVerificationGeometry.Curve> Curves { get; } = new();
|
||||
internal List<PostVerificationGeometry.Curve> Leads { get; } = new();
|
||||
internal bool Fixed { get; set; }
|
||||
// Where the cut really ended when OvercutClosure closed it: the retraced tail's end, on the
|
||||
// first cut. Its last curve now ends at the seam instead.
|
||||
internal Vector? OvercutEnd { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DisjointSet(int count)
|
||||
{
|
||||
private readonly int[] parents = Enumerable.Range(0, count).ToArray();
|
||||
|
||||
internal int Find(int node)
|
||||
{
|
||||
while (parents[node] != node)
|
||||
node = parents[node] = parents[parents[node]];
|
||||
return node;
|
||||
}
|
||||
|
||||
internal void Union(int a, int b) => parents[Find(a)] = Find(b);
|
||||
}
|
||||
|
||||
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves,
|
||||
Vector? OvercutEnd)
|
||||
{
|
||||
internal PostVerificationGeometry.Extent Extent { get; } =
|
||||
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// A seam overcut baked into a clean contour: the last cut runs on past the contour's own start
|
||||
/// along its first cut, then a straight jog returns to the start (converted nests carry a source
|
||||
/// CAM's 0.03 overcut this way). The retraced tail encloses no area, so trimming it leaves the
|
||||
/// material unchanged; left in, the contour touches itself at its seam and nothing can certify it.
|
||||
/// Only that exact shape is recognized. Any other self-contact is still refused by its consumer.
|
||||
/// </summary>
|
||||
internal static class SeamOvercut
|
||||
{
|
||||
/// <summary>
|
||||
/// The same program when it has no seam overcut, otherwise an owned copy without them.
|
||||
/// The source program is never edited.
|
||||
/// </summary>
|
||||
internal static Program Trimmed(Program program)
|
||||
{
|
||||
var edits = Find(program);
|
||||
if (edits.Count == 0)
|
||||
return program;
|
||||
var copy = (Program)program.Clone();
|
||||
Apply(copy, edits);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>Trims seam overcuts from an owned program in place; returns how many were removed.</summary>
|
||||
internal static int Trim(Program program)
|
||||
{
|
||||
var edits = Find(program);
|
||||
Apply(program, edits);
|
||||
return edits.Count;
|
||||
}
|
||||
|
||||
private static void Apply(Program program, List<Edit> edits)
|
||||
{
|
||||
// Last first, so removing a return jog does not shift an earlier edit's indices.
|
||||
for (var i = edits.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var edit = edits[i];
|
||||
var last = (Motion)program.Codes[edit.LastCut];
|
||||
last.EndPoint = edit.EndPoint;
|
||||
if (last is ArcMove arc)
|
||||
arc.CenterPoint = edit.CenterPoint;
|
||||
program.Codes.RemoveAt(edit.ReturnJog);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Edit> Find(Program program)
|
||||
{
|
||||
var edits = new List<Edit>();
|
||||
if (program?.Codes == null)
|
||||
return edits;
|
||||
var incremental = program.Mode == Mode.Incremental;
|
||||
var run = new List<Segment>();
|
||||
// Null while the position is unknown: after a subprogram call in an absolute program.
|
||||
Vector? position = new Vector();
|
||||
for (var index = 0; index < program.Codes.Count; index++)
|
||||
{
|
||||
var code = program.Codes[index];
|
||||
if (code is not Motion motion)
|
||||
{
|
||||
Finish();
|
||||
if (code is SubProgramCall && !incremental)
|
||||
position = null;
|
||||
continue;
|
||||
}
|
||||
// Incremental coordinates only need a consistent origin within one contour.
|
||||
var reference = incremental ? position ?? new Vector() : new Vector();
|
||||
var end = reference + motion.EndPoint;
|
||||
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter }
|
||||
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter };
|
||||
if (!cut || position == null)
|
||||
{
|
||||
Finish();
|
||||
position = end;
|
||||
continue;
|
||||
}
|
||||
run.Add(new(index, motion, position.Value, end,
|
||||
motion is ArcMove arc ? reference + arc.CenterPoint : null, reference));
|
||||
position = end;
|
||||
}
|
||||
Finish();
|
||||
return edits;
|
||||
|
||||
void Finish()
|
||||
{
|
||||
if (TryTrim(run, out var edit))
|
||||
edits.Add(edit);
|
||||
run.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryTrim(List<Segment> run, out Edit edit)
|
||||
{
|
||||
edit = default;
|
||||
if (run.Count < 3)
|
||||
return false;
|
||||
var first = run[0];
|
||||
var last = run[^2];
|
||||
var jog = run[^1];
|
||||
var seam = first.Start;
|
||||
var overcut = last.End;
|
||||
if (jog.Motion is not LinearMove || !NominalContourClosure.IsClosed(seam, jog.End)
|
||||
|| HasVariables(last.Motion) || HasVariables(jog.Motion)
|
||||
|| !IsFinite(seam) || !IsFinite(overcut) || !IsFinite(last.Start)
|
||||
|| overcut.DistanceTo(seam) <= Tolerance.Epsilon || last.Start.DistanceTo(seam) <= Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
if (first.Center == null && last.Center == null)
|
||||
{
|
||||
var length = first.Start.DistanceTo(first.End);
|
||||
if (length <= Tolerance.Epsilon)
|
||||
return false;
|
||||
var direction = (first.End - first.Start) * (1 / length);
|
||||
// The last cut lies on the first cut's line, passes the seam and stops inside the first cut.
|
||||
var along = Dot(overcut - seam, direction);
|
||||
var before = Dot(seam - last.Start, direction);
|
||||
if (System.Math.Abs(Cross(overcut - seam, direction)) > Tolerance.Epsilon
|
||||
|| System.Math.Abs(Cross(last.Start - seam, direction)) > Tolerance.Epsilon
|
||||
|| along <= Tolerance.Epsilon || along >= length - Tolerance.Epsilon || before <= Tolerance.Epsilon)
|
||||
return false;
|
||||
edit = new(last.Index, jog.Index, seam - last.Reference, new Vector());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (first.Center is not { } firstCenter || last.Center is not { } lastCenter
|
||||
|| !IsFinite(firstCenter) || !IsFinite(lastCenter)
|
||||
|| ((ArcMove)first.Motion).Rotation != ((ArcMove)last.Motion).Rotation)
|
||||
return false;
|
||||
var radius = seam.DistanceTo(firstCenter);
|
||||
if (radius <= Tolerance.Epsilon || firstCenter.DistanceTo(lastCenter) > Tolerance.Epsilon
|
||||
|| System.Math.Abs(overcut.DistanceTo(lastCenter) - radius) > Tolerance.Epsilon
|
||||
|| System.Math.Abs(overcut.DistanceTo(firstCenter) - radius) > Tolerance.Epsilon
|
||||
|| System.Math.Abs(seam.DistanceTo(lastCenter) - radius) > Tolerance.Epsilon)
|
||||
return false;
|
||||
var clockwise = ((ArcMove)first.Motion).Rotation == RotationType.CW;
|
||||
var margin = Tolerance.Epsilon / radius;
|
||||
// The overcut end lies inside the first arc's span, and the seam inside the last arc's.
|
||||
var firstSweep = first.Start.DistanceTo(first.End) <= Tolerance.Epsilon
|
||||
? 2 * System.Math.PI : Travel(firstCenter, seam, first.End, clockwise);
|
||||
var passed = Travel(firstCenter, seam, overcut, clockwise);
|
||||
var lastSweep = Travel(lastCenter, last.Start, overcut, clockwise);
|
||||
var toSeam = Travel(lastCenter, last.Start, seam, clockwise);
|
||||
if (passed <= margin || passed >= System.Math.PI || passed >= firstSweep - margin
|
||||
|| toSeam <= margin || toSeam >= lastSweep - margin
|
||||
|| System.Math.Abs(lastSweep - toSeam - passed) > margin)
|
||||
return false;
|
||||
|
||||
// Keep the shortened arc's center equidistant from both of its ends.
|
||||
var chord = seam - last.Start;
|
||||
var normal = new Vector(-chord.Y, chord.X) * (1 / last.Start.DistanceTo(seam));
|
||||
var middle = (last.Start + seam) * 0.5;
|
||||
var center = middle + normal * Dot(lastCenter - middle, normal);
|
||||
edit = new(last.Index, jog.Index, seam - last.Reference, center - last.Reference);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The angle travelled around <paramref name="center"/> from one point to another, in [0, 2pi).</summary>
|
||||
private static double Travel(Vector center, Vector from, Vector to, bool clockwise)
|
||||
{
|
||||
var start = System.Math.Atan2(from.Y - center.Y, from.X - center.X);
|
||||
var end = System.Math.Atan2(to.Y - center.Y, to.X - center.X);
|
||||
var travel = (clockwise ? start - end : end - start) % (2 * System.Math.PI);
|
||||
return travel < 0 ? travel + 2 * System.Math.PI : travel;
|
||||
}
|
||||
|
||||
private static bool HasVariables(Motion motion) => motion.VariableRefs is { Count: > 0 };
|
||||
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
|
||||
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
||||
|
||||
/// <summary>One cut of a contour in the walk's coordinates; <see cref="Reference"/> maps them back to its code.</summary>
|
||||
private readonly record struct Segment(int Index, Motion Motion, Vector Start, Vector End, Vector? Center, Vector Reference);
|
||||
|
||||
/// <summary>The last cut's new code-relative end and center, and the return jog to remove.</summary>
|
||||
private readonly record struct Edit(int LastCut, int ReturnJog, Vector EndPoint, Vector CenterPoint);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
@@ -23,11 +24,51 @@ internal static class StateFingerprint
|
||||
/// <exception cref="NotSupportedException">A settings object is not an exact built-in type.</exception>
|
||||
internal static string Of(CuttingParameters settings)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
Settings(settings);
|
||||
return text.ToString();
|
||||
var writer = new Writer();
|
||||
writer.Settings(settings);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
void Settings(CuttingParameters p)
|
||||
/// <summary>
|
||||
/// Fingerprint of a drawing's cut pins in order: each pin's point, lead-in, lead-out and
|
||||
/// tab. A null list and an empty list differ.
|
||||
/// </summary>
|
||||
internal static string Of(IReadOnlyList<ContourCutPin> pins)
|
||||
{
|
||||
var writer = new Writer();
|
||||
writer.Pins(pins);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private sealed class Writer
|
||||
{
|
||||
private readonly StringBuilder text = new();
|
||||
|
||||
public override string ToString() => text.ToString();
|
||||
|
||||
internal void Pins(IReadOnlyList<ContourCutPin> pins)
|
||||
{
|
||||
if (pins == null)
|
||||
{
|
||||
text.Append("null;");
|
||||
return;
|
||||
}
|
||||
text.Append("Pins{");
|
||||
foreach (var pin in pins)
|
||||
{
|
||||
if (!Begin(pin, typeof(ContourCutPin)))
|
||||
continue;
|
||||
Number(pin.Point.X);
|
||||
Number(pin.Point.Y);
|
||||
Lead(pin.LeadIn);
|
||||
Out(pin.LeadOut);
|
||||
TabSettings(pin.Tab);
|
||||
text.Append('}');
|
||||
}
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
internal void Settings(CuttingParameters p)
|
||||
{
|
||||
if (!Begin(p, typeof(CuttingParameters)))
|
||||
return;
|
||||
@@ -42,21 +83,22 @@ internal static class StateFingerprint
|
||||
Out(p.ExternalLeadOut);
|
||||
Lead(p.InternalLeadIn);
|
||||
Out(p.InternalLeadOut);
|
||||
Lead(p.ArcCircleLeadIn);
|
||||
Out(p.ArcCircleLeadOut);
|
||||
Number(p.PierceClearance);
|
||||
Number(p.PierceDiameter);
|
||||
Flag(p.RoundLeadInAngles);
|
||||
Number(p.LeadInAngleIncrement);
|
||||
Number(p.AutoTabMinSize);
|
||||
Number(p.AutoTabMaxSize);
|
||||
TabSettings(p.TabConfig);
|
||||
Flag(p.TabsEnabled);
|
||||
Flag(p.SupportTabs);
|
||||
Number(p.NoBedTabMinWidth);
|
||||
Number(p.NoBedTabMaxWidth);
|
||||
Number(p.TabHeldMaxLength);
|
||||
Sequence(p.Sequencing);
|
||||
Assignment(p.Assignment);
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void Sequence(SequenceParameters s)
|
||||
private void Sequence(SequenceParameters s)
|
||||
{
|
||||
if (!Begin(s, typeof(SequenceParameters)))
|
||||
return;
|
||||
@@ -72,7 +114,7 @@ internal static class StateFingerprint
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void Assignment(AssignmentParameters a)
|
||||
private void Assignment(AssignmentParameters a)
|
||||
{
|
||||
if (!Begin(a, typeof(AssignmentParameters)))
|
||||
return;
|
||||
@@ -82,7 +124,7 @@ internal static class StateFingerprint
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void Lead(LeadIn lead)
|
||||
private void Lead(LeadIn lead)
|
||||
{
|
||||
if (lead == null)
|
||||
{
|
||||
@@ -99,6 +141,7 @@ internal static class StateFingerprint
|
||||
break;
|
||||
case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)):
|
||||
Number(l.Radius);
|
||||
Number(l.SweepAngle);
|
||||
break;
|
||||
case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)):
|
||||
Number(l.LineLength);
|
||||
@@ -122,7 +165,7 @@ internal static class StateFingerprint
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void Out(LeadOut lead)
|
||||
private void Out(LeadOut lead)
|
||||
{
|
||||
if (lead == null)
|
||||
{
|
||||
@@ -146,7 +189,7 @@ internal static class StateFingerprint
|
||||
text.Append('}');
|
||||
}
|
||||
|
||||
void TabSettings(Tab tab)
|
||||
private void TabSettings(Tab tab)
|
||||
{
|
||||
if (tab == null)
|
||||
{
|
||||
@@ -180,7 +223,7 @@ internal static class StateFingerprint
|
||||
|
||||
// Writes the exact type tag and returns true; null writes a marker and returns false.
|
||||
// A runtime type other than the expected exact type throws before any member is read.
|
||||
bool Begin(object value, Type exact)
|
||||
private bool Begin(object value, Type exact)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
@@ -193,15 +236,15 @@ internal static class StateFingerprint
|
||||
return true;
|
||||
}
|
||||
|
||||
void Number(double value) =>
|
||||
private void Number(double value) =>
|
||||
text.Append(BitConverter.DoubleToInt64Bits(value).ToString(CultureInfo.InvariantCulture)).Append(';');
|
||||
|
||||
void Int(int value) => text.Append(value.ToString(CultureInfo.InvariantCulture)).Append(';');
|
||||
private void Int(int value) => text.Append(value.ToString(CultureInfo.InvariantCulture)).Append(';');
|
||||
|
||||
void Flag(bool value) => text.Append(value ? "1;" : "0;");
|
||||
private void Flag(bool value) => text.Append(value ? "1;" : "0;");
|
||||
|
||||
// Length-prefixed so a delimiter inside the text cannot shift fields; null differs from "".
|
||||
void Text(string value)
|
||||
private void Text(string value)
|
||||
{
|
||||
if (value == null)
|
||||
text.Append("~;");
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// How one contour of a drawing must be cut: where it is entered and the lead-in,
|
||||
/// lead-out and optional tab used there. <see cref="Point"/> is in the drawing's source
|
||||
/// frame (the frame of <see cref="Drawing.SourceEntities"/>), which does not move when the
|
||||
/// program is re-origined; <see cref="CutPinResolver"/> maps it to a placed contour.
|
||||
/// Immutable: only the exact built-in lead and tab types are accepted, they are copied
|
||||
/// in, and every property returns a fresh copy.
|
||||
/// </summary>
|
||||
public sealed class ContourCutPin
|
||||
{
|
||||
private readonly LeadIn leadIn;
|
||||
private readonly LeadOut leadOut;
|
||||
private readonly Tab tab;
|
||||
|
||||
/// <exception cref="ArgumentException">A missing or invalid lead, a nonfinite point, or a tab with a lead-out.</exception>
|
||||
/// <exception cref="NotSupportedException">A lead or tab that is not an exact built-in type.</exception>
|
||||
public ContourCutPin(Vector point, LeadIn leadIn, LeadOut leadOut, Tab tab = null)
|
||||
{
|
||||
PostVerificationGeometry.Validate(point);
|
||||
var ownedLeadIn = OwnedCuttingParameters.CopyLeadIn(leadIn);
|
||||
var ownedLeadOut = OwnedCuttingParameters.CopyLeadOut(leadOut);
|
||||
var ownedTab = tab == null ? null : OwnedCuttingParameters.CopyTab(tab);
|
||||
// A tab leaves the contour open at its end; a lead-out after an open contour is
|
||||
// refused by the planner, so the pin cannot ask for both.
|
||||
if (ownedTab != null && ownedLeadOut.GetType() != typeof(NoLeadOut))
|
||||
throw new ArgumentException("A pin with a tab cannot have a lead-out.", nameof(leadOut));
|
||||
|
||||
Point = point;
|
||||
this.leadIn = ownedLeadIn;
|
||||
this.leadOut = ownedLeadOut;
|
||||
this.tab = ownedTab;
|
||||
}
|
||||
|
||||
/// <summary>The contour entry in the drawing's source frame.</summary>
|
||||
public Vector Point { get; }
|
||||
|
||||
/// <summary>A copy of the pinned lead-in.</summary>
|
||||
public LeadIn LeadIn => OwnedCuttingParameters.CopyLeadIn(leadIn);
|
||||
|
||||
/// <summary>A copy of the pinned lead-out; always <see cref="NoLeadOut"/> when <see cref="HasTab"/>.</summary>
|
||||
public LeadOut LeadOut => OwnedCuttingParameters.CopyLeadOut(leadOut);
|
||||
|
||||
/// <summary>A copy of the pinned tab, or null for a closed contour.</summary>
|
||||
public Tab Tab => tab == null ? null : OwnedCuttingParameters.CopyTab(tab);
|
||||
|
||||
public bool HasTab => tab != null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
@@ -19,6 +20,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
|
||||
{
|
||||
var entities = partProgram.ToGeometry();
|
||||
RefuseDisplayPerimeter(entities);
|
||||
var breakCuts = TakeBreakCuts(entities);
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||
|
||||
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
|
||||
@@ -77,10 +80,13 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var result = new Program(Mode.Absolute);
|
||||
|
||||
EmitScribeContours(result, scribeEntities);
|
||||
EmitBreakCuts(result, breakCuts, BreakCutContours(profile));
|
||||
|
||||
foreach (var entry in cutoutEntries)
|
||||
{
|
||||
if (!entry.Shape.IsClosed())
|
||||
if (IsTabbedCutout(entry.Shape))
|
||||
EmitTabbedCutout(result, entry.Shape);
|
||||
else if (!entry.Shape.IsClosed())
|
||||
EmitRawContour(result, entry.Shape);
|
||||
else
|
||||
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
|
||||
@@ -102,6 +108,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return new CuttingResult { Program = result, LastCutPoint = perimeterPt };
|
||||
}
|
||||
|
||||
// These tools would lead the outline or mistake the largest cutout for it (an External lead
|
||||
// pierced on the blank). Only the cutting planner knows every contour of such a part is a hole.
|
||||
private static void RefuseDisplayPerimeter(List<Entity> entities)
|
||||
{
|
||||
if (entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
|
||||
throw new InvalidOperationException(DisplayPerimeterTail.LeadInRefusal);
|
||||
}
|
||||
|
||||
public CuttingResult ApplySingle(
|
||||
Program partProgram,
|
||||
Vector point,
|
||||
@@ -110,6 +124,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
)
|
||||
{
|
||||
var entities = partProgram.ToGeometry();
|
||||
RefuseDisplayPerimeter(entities);
|
||||
var breakCuts = TakeBreakCuts(entities);
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||
|
||||
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
|
||||
@@ -120,14 +136,20 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var result = new Program(Mode.Absolute);
|
||||
|
||||
EmitScribeContours(result, scribeEntities);
|
||||
EmitBreakCuts(result, breakCuts, BreakCutContours(profile));
|
||||
|
||||
// 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)
|
||||
// Emit cutouts — only the target gets lead-in/out (skip open contours)
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
if (!cutout.IsClosed())
|
||||
// A tabbed cutout's pieces are always entered from its slug, like break cuts.
|
||||
if (IsTabbedCutout(cutout))
|
||||
{
|
||||
EmitTabbedCutout(result, cutout);
|
||||
}
|
||||
else if (!cutout.IsClosed())
|
||||
{
|
||||
EmitRawContour(result, cutout);
|
||||
}
|
||||
@@ -234,43 +256,70 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <param name="contourParameters">Per-contour settings by contour ordinal (a drawing pin's
|
||||
/// own lead-in, lead-out and tab); a null list or entry uses <see cref="Parameters"/>.</param>
|
||||
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices)
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
|
||||
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
|
||||
IReadOnlyList<Shape> tabbedCutouts = null, int? perimeterOrdinal = null, Shape displayTail = null)
|
||||
{
|
||||
var result = new Program(Mode.Absolute);
|
||||
EmitScribeContours(result, scribes);
|
||||
EmitChosenContours(result, shapes, choices);
|
||||
EmitBreakCuts(result, breakCuts, shapes);
|
||||
foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>())
|
||||
EmitTabbedCutout(result, cutout);
|
||||
EmitChosenContours(result, shapes, choices, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
|
||||
if (displayTail != null)
|
||||
{
|
||||
// Display only, after every cut: executed reads and posts stop at its marker.
|
||||
var start = GetShapeStartPoint(displayTail);
|
||||
DisplayPerimeterTail.Append(result, ConvertShapeToMoves(displayTail, start, LayerType.DisplayPerimeter), start);
|
||||
}
|
||||
result.Mode = Mode.Incremental;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Diagnostic seam for candidate lead validation (PreparedContours
|
||||
// .EmitCandidateForValidation): emit a single already-validated choice with its
|
||||
// ORIGINAL contour type — the last shape is External even when emitted alone, so a
|
||||
// ORIGINAL contour type — the last shape is External even when emitted alone, so a
|
||||
// perimeter never degrades to a hole because it was isolated. Output must not be
|
||||
// installed on a Part or accepted as a complete plan.
|
||||
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes,
|
||||
CuttingPlanning.ContourChoice choice)
|
||||
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null,
|
||||
int? perimeterOrdinal = null)
|
||||
{
|
||||
var result = new Program(Mode.Absolute);
|
||||
EmitScribeContours(result, scribes);
|
||||
EmitChosenContours(result, shapes, new[] { choice });
|
||||
EmitChosenContours(result, shapes, new[] { choice }, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
|
||||
result.Mode = Mode.Incremental;
|
||||
return result;
|
||||
}
|
||||
|
||||
// One contour per choice in order; the perimeter (last shape) is always External.
|
||||
// One contour per choice in order; the perimeter is always External. Without a cut perimeter
|
||||
// (a display perimeter outside) perimeterOrdinal is past the last shape: every contour is a hole.
|
||||
// Contour emission itself reads only the contour's own geometry and settings, never
|
||||
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
|
||||
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
|
||||
private void EmitChosenContours(Program result, Shape[] shapes,
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices)
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters,
|
||||
int perimeterOrdinal)
|
||||
{
|
||||
foreach (var choice in choices)
|
||||
var shared = Parameters;
|
||||
try
|
||||
{
|
||||
var shape = shapes[choice.ContourOrdinal];
|
||||
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
|
||||
exactCirclePrograms: true);
|
||||
foreach (var choice in choices)
|
||||
{
|
||||
// A contour's own settings apply to its emission alone; scribes and every
|
||||
// other contour keep the shared settings.
|
||||
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
|
||||
var shape = shapes[choice.ContourOrdinal];
|
||||
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
|
||||
exactCirclePrograms: true, scrapPierce: choice.Pierce);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Parameters = shared;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +439,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType? forceType = null,
|
||||
bool exactCirclePrograms = false
|
||||
bool exactCirclePrograms = false,
|
||||
Vector? scrapPierce = null
|
||||
)
|
||||
{
|
||||
var contourType = forceType ?? DetectContourType(shape);
|
||||
@@ -399,6 +449,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
var leadIn = SelectLeadIn(contourType);
|
||||
var leadOut = SelectLeadOut(contourType);
|
||||
// A cutout the planner pierced by erosion is too small for its configured leads.
|
||||
if (scrapPierce != null && contourType != ContourType.External)
|
||||
leadOut = new NoLeadOut();
|
||||
|
||||
if (contourType == ContourType.ArcCircle && entity is Circle circle)
|
||||
{
|
||||
@@ -415,7 +468,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
);
|
||||
}
|
||||
|
||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal);
|
||||
if (scrapPierce is { } holePierce)
|
||||
leadIn = StraightLeadIn(point, holePierce, out normal);
|
||||
else
|
||||
{
|
||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
|
||||
Parameters.GetEffectivePierceClearance());
|
||||
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius(), winding);
|
||||
}
|
||||
|
||||
// Build hole sub-program relative to (0,0)
|
||||
var holeCenter = circle.Center;
|
||||
@@ -454,9 +514,19 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return;
|
||||
}
|
||||
|
||||
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
||||
Parameters.PierceClearance, out var leadInNormal);
|
||||
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
|
||||
var turn = LeadTurn(contourType, winding);
|
||||
double leadInNormal;
|
||||
// A planner-placed scrap pierce replaces the configured lead-in with a straight one.
|
||||
if (scrapPierce is { } pierce)
|
||||
leadIn = StraightLeadIn(point, pierce, out leadInNormal);
|
||||
else
|
||||
{
|
||||
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
||||
Parameters.GetEffectivePierceClearance(), out leadInNormal);
|
||||
if (contourType == ContourType.Internal)
|
||||
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius(), turn);
|
||||
}
|
||||
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
|
||||
|
||||
var reindexedShape = shape.ReindexAt(point, entity);
|
||||
|
||||
@@ -483,13 +553,24 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
else
|
||||
{
|
||||
leadOut = ResolveLeadOut(shape, point, entity, contourType, leadOut, winding,
|
||||
Parameters.PierceClearance, out leadOutNormal);
|
||||
Parameters.GetEffectivePierceClearance(), out leadOutNormal);
|
||||
}
|
||||
|
||||
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, winding));
|
||||
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, turn));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The direction a lead arc turns to meet a contour of <paramref name="winding"/>
|
||||
/// tangentially, in the cut direction. Normals point away from the material, so the arc
|
||||
/// center lies outside an outside perimeter (turning against its winding) and inside a
|
||||
/// cutout (turning with it).
|
||||
/// </summary>
|
||||
public static RotationType LeadTurn(ContourType contourType, RotationType winding) =>
|
||||
contourType == ContourType.External
|
||||
? winding == RotationType.CW ? RotationType.CCW : RotationType.CW
|
||||
: winding;
|
||||
|
||||
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
||||
{
|
||||
if (scribeEntities.Count == 0)
|
||||
@@ -574,8 +655,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
/// <paramref name="pierceClearance"/> from the contour; the approach angle is
|
||||
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a
|
||||
/// reflex corner it bisects the notch. The result does not depend on which of
|
||||
/// the two edges meeting at the corner was picked. Other styles and contour
|
||||
/// types keep <see cref="ComputeLeadInNormal"/>.
|
||||
/// the two edges meeting at the corner was picked. A curved lead-in at a convex
|
||||
/// perimeter corner uses the normal of the edge cut first, so its arc meets that
|
||||
/// edge tangentially. Other styles and contour types keep
|
||||
/// <see cref="ComputeLeadInNormal"/>.
|
||||
/// </summary>
|
||||
public static LeadIn ResolveLeadIn(
|
||||
Shape shape,
|
||||
@@ -589,8 +672,19 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
)
|
||||
{
|
||||
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
|
||||
if (contourType != ContourType.External || leadIn is not LineLeadIn line
|
||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||
if (contourType != ContourType.External || !TryGetCorner(shape, point, entity, out var corner))
|
||||
return leadIn;
|
||||
|
||||
if (leadIn is ArcLeadIn or LineArcLeadIn or CleanHoleLeadIn)
|
||||
{
|
||||
// The arc meets the edge cut first tangentially; its center sits off that edge,
|
||||
// which at a convex corner keeps the whole arc clear of the part.
|
||||
if (ClassifyCorner(corner, winding) is CornerKind.Convex or CornerKind.Smooth)
|
||||
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||
return leadIn;
|
||||
}
|
||||
|
||||
if (leadIn is not LineLeadIn line)
|
||||
return leadIn;
|
||||
|
||||
switch (ClassifyCorner(corner, winding))
|
||||
@@ -620,7 +714,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner
|
||||
/// along the edge cut last, when its end keeps <paramref name="clearance"/> from
|
||||
/// the contour; otherwise it is perpendicular to that edge. At a reflex corner it
|
||||
/// bisects the notch. Other styles and contour types keep the entity normal.
|
||||
/// bisects the notch. An <see cref="ArcLeadOut"/> at a convex corner uses the normal of
|
||||
/// the edge cut last. Other styles and contour types keep the entity normal.
|
||||
/// </summary>
|
||||
public static LeadOut ResolveLeadOut(
|
||||
Shape shape,
|
||||
@@ -634,8 +729,18 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
)
|
||||
{
|
||||
normal = ComputeNormal(point, entity, contourType, winding);
|
||||
if (contourType != ContourType.External || leadOut is not LineLeadOut line
|
||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||
if (contourType != ContourType.External || !TryGetCorner(shape, point, entity, out var corner))
|
||||
return leadOut;
|
||||
|
||||
if (leadOut is ArcLeadOut)
|
||||
{
|
||||
// The arc leaves the edge cut last tangentially.
|
||||
if (ClassifyCorner(corner, winding) is CornerKind.Convex or CornerKind.Smooth)
|
||||
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||
return leadOut;
|
||||
}
|
||||
|
||||
if (leadOut is not LineLeadOut line)
|
||||
return leadOut;
|
||||
|
||||
switch (ClassifyCorner(corner, winding))
|
||||
@@ -783,7 +888,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
var normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||
var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||
// Sum unit normals rather than averaging angles (which fails at 0/2π).
|
||||
// Sum unit normals rather than averaging angles (which fails at 0/2Ï€).
|
||||
// Winding makes this point into the scrap even at reflex corners.
|
||||
var x = System.Math.Cos(normal) + System.Math.Cos(adjacentNormal);
|
||||
var y = System.Math.Sin(normal) + System.Math.Sin(adjacentNormal);
|
||||
@@ -840,7 +945,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
if (entity is Line line)
|
||||
{
|
||||
// Perpendicular to line direction: tangent + π/2 = left side.
|
||||
// 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);
|
||||
@@ -851,7 +956,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
// Radial direction from center to point.
|
||||
// Flip when the arc direction differs from the contour winding —
|
||||
// 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)
|
||||
@@ -859,7 +964,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
// Radial outward — always correct regardless of winding
|
||||
// Radial outward — always correct regardless of winding
|
||||
normal = point.AngleFrom(circle.Center);
|
||||
}
|
||||
else
|
||||
@@ -887,18 +992,24 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return polygon.RotationDirection();
|
||||
}
|
||||
|
||||
private LeadIn ClampLeadInForCircle(
|
||||
/// <summary>
|
||||
/// Shortens a round hole's lead-in so its pierce keeps <paramref name="clearance"/> from
|
||||
/// the hole wall. A hole no wider than twice the clearance keeps the lead as it is.
|
||||
/// Shared by program generation and the manual placement preview.
|
||||
/// </summary>
|
||||
public static LeadIn ClampLeadInForCircle(
|
||||
LeadIn leadIn,
|
||||
Circle circle,
|
||||
Vector contourPoint,
|
||||
double normalAngle
|
||||
double normalAngle,
|
||||
double clearance
|
||||
)
|
||||
{
|
||||
if (leadIn is NoLeadIn || Parameters.PierceClearance <= 0)
|
||||
if (leadIn is NoLeadIn || clearance <= 0)
|
||||
return leadIn;
|
||||
|
||||
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle);
|
||||
var maxRadius = circle.Radius - Parameters.PierceClearance;
|
||||
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle, circle.Rotation);
|
||||
var maxRadius = circle.Radius - clearance;
|
||||
if (maxRadius <= 0)
|
||||
return leadIn;
|
||||
|
||||
@@ -936,12 +1047,91 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return leadIn.Scale(scale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a hole's lead-in pierce <paramref name="pierceRadius"/> clear of the hole wall.
|
||||
/// A lead-in whose pierce already keeps it, or that pierces on the contour itself, is
|
||||
/// returned as given. Otherwise the pierce moves onto the line from
|
||||
/// <paramref name="point"/> toward the hole's pole of inaccessibility (the centre of a
|
||||
/// round hole), as close to the contour as the zone allows, and the lead becomes a
|
||||
/// straight line from there, generated with <paramref name="normal"/>. A hole too small
|
||||
/// for the zone is pierced at the pole itself, its best clearance; the lead validator and
|
||||
/// pre-post review still report the shortfall. Reads only the hole's own geometry, so it
|
||||
/// knows nothing of parts nested inside the hole. Shared by program generation and the
|
||||
/// manual placement preview. <paramref name="turn"/> is the hole's
|
||||
/// <see cref="LeadTurn"/>, which places a curved lead's pierce.
|
||||
/// </summary>
|
||||
public static LeadIn ResolvePierceZone(Shape hole, Vector point, LeadIn leadIn, double normal,
|
||||
double pierceRadius, RotationType turn)
|
||||
{
|
||||
if (pierceRadius <= 0 || !double.IsFinite(pierceRadius) || leadIn is NoLeadIn
|
||||
|| !hole.IsClosed())
|
||||
return leadIn;
|
||||
|
||||
var circle = hole.Entities.Count == 1 ? hole.Entities[0] as Circle : null;
|
||||
var polygon = circle == null
|
||||
? hole.ToPolygonWithTolerance(System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance))
|
||||
: null;
|
||||
if (Clear(leadIn.GetPiercePoint(point, normal, turn)))
|
||||
return leadIn;
|
||||
|
||||
var pole = circle?.Center ?? PolyLabel.Find(polygon, null,
|
||||
System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance), PoleSearchCells);
|
||||
if (!Inside(pole))
|
||||
return leadIn;
|
||||
|
||||
// Clearance grows monotonically toward the pole of a convex hole, so the bisection
|
||||
// finds the shortest clear lead; in other holes it still ends on a clear point.
|
||||
var pierce = pole;
|
||||
if (Clear(pole))
|
||||
{
|
||||
double low = 0, high = 1;
|
||||
for (var i = 0; i < 40; i++)
|
||||
{
|
||||
var middle = (low + high) / 2;
|
||||
if (Clear(point + (pole - point) * middle))
|
||||
high = middle;
|
||||
else
|
||||
low = middle;
|
||||
}
|
||||
pierce = point + (pole - point) * high;
|
||||
}
|
||||
|
||||
var length = pierce.DistanceTo(point);
|
||||
if (length <= Math.Tolerance.Epsilon)
|
||||
return leadIn;
|
||||
var direction = System.Math.Atan2(pierce.Y - point.Y, pierce.X - point.X);
|
||||
return new LineLeadIn
|
||||
{
|
||||
Length = length,
|
||||
ApproachAngle = Angle.ToDegrees(Angle.NormalizeRad(direction - normal + Angle.HalfPI)),
|
||||
};
|
||||
|
||||
bool Inside(Vector p) => circle != null
|
||||
? p.DistanceTo(circle.Center) < circle.Radius
|
||||
: polygon.ContainsPoint(p);
|
||||
|
||||
bool Clear(Vector p) => double.IsFinite(p.X) && double.IsFinite(p.Y) && Inside(p)
|
||||
&& hole.ClosestPointTo(p, out _).DistanceTo(p) >= pierceRadius;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A straight lead-in from <paramref name="pierce"/> to <paramref name="point"/>, with the
|
||||
/// normal to generate it with.
|
||||
/// </summary>
|
||||
private static LeadIn StraightLeadIn(Vector point, Vector pierce, out double normal)
|
||||
{
|
||||
normal = System.Math.Atan2(pierce.Y - point.Y, pierce.X - point.X);
|
||||
return new LineLeadIn { Length = pierce.DistanceTo(point), ApproachAngle = 90 };
|
||||
}
|
||||
|
||||
// Bounds the pole search on long, thin holes; the result is checked before use.
|
||||
private const int PoleSearchCells = 4096;
|
||||
|
||||
private LeadIn SelectLeadIn(ContourType contourType)
|
||||
{
|
||||
return contourType switch
|
||||
{
|
||||
ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn,
|
||||
ContourType.Internal => Parameters.InternalLeadIn,
|
||||
ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadIn,
|
||||
_ => Parameters.ExternalLeadIn,
|
||||
};
|
||||
}
|
||||
@@ -950,8 +1140,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
return contourType switch
|
||||
{
|
||||
ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut,
|
||||
ContourType.Internal => Parameters.InternalLeadOut,
|
||||
ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadOut,
|
||||
_ => Parameters.ExternalLeadOut,
|
||||
};
|
||||
}
|
||||
@@ -998,7 +1187,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
break;
|
||||
}
|
||||
|
||||
// No intersection — entity is entirely inside circle, remove it
|
||||
// No intersection — entity is entirely inside circle, remove it
|
||||
if (EntityStartPoint(entity).DistanceTo(center) <= tabSize + Tolerance.Epsilon)
|
||||
{
|
||||
entities.RemoveAt(entities.Count - 1);
|
||||
@@ -1029,15 +1218,27 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
)
|
||||
{
|
||||
var moves = new List<ICode>();
|
||||
var position = startPoint;
|
||||
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
moves.Add(new LinearMove(line.EndPoint) { Layer = layer });
|
||||
position = line.EndPoint;
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
// A contour entered away from its seam crosses the seam's admitted closure gap
|
||||
// mid-cut. Starting the arc there would move its start off its own circle, so
|
||||
// bridge the gap with a straight move and cut the arc exactly as drawn. A lead-in
|
||||
// must join the contour itself, so an entry on the seam is left as it was.
|
||||
var arcStart = arc.StartPoint();
|
||||
var gap = position.DistanceTo(arcStart);
|
||||
if (moves.Count > 0 && gap > Diagnostics.PostVerificationGeometry.Epsilon
|
||||
&& CuttingPlanning.NominalContourClosure.IsClosed(position, arcStart))
|
||||
moves.Add(new LinearMove(arcStart) { Layer = layer });
|
||||
position = arc.EndPoint();
|
||||
moves.Add(
|
||||
new ArcMove(
|
||||
arc.EndPoint(),
|
||||
@@ -1054,6 +1255,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
moves.Add(
|
||||
new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer }
|
||||
);
|
||||
position = startPoint;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1066,6 +1268,253 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return moves;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes break-cut entities from <paramref name="entities"/> (in program order) and
|
||||
/// returns them as travel-ordered open chains, one per uninterrupted run.
|
||||
/// </summary>
|
||||
internal static List<Shape> TakeBreakCuts(List<Entity> entities)
|
||||
{
|
||||
var cuts = new List<Shape>();
|
||||
Shape current = null;
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
if (entity.Layer != SpecialLayers.Break)
|
||||
{
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
if (current == null
|
||||
|| EntityEndPoint(current.Entities[^1]).DistanceTo(EntityStartPoint(entity)) > Tolerance.Epsilon)
|
||||
{
|
||||
current = new Shape();
|
||||
cuts.Add(current);
|
||||
}
|
||||
current.Entities.Add(entity);
|
||||
}
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Break);
|
||||
foreach (var cut in cuts)
|
||||
cut.UpdateBounds();
|
||||
return cuts;
|
||||
}
|
||||
|
||||
// A clean program's break cut travels with its scrap on the left. It is entered from the
|
||||
// scrap, with the internal lead-in (holes are pierced in scrap the same way), at whichever
|
||||
// end has more scrap before the part's contours: near the outline the scrap narrows to a
|
||||
// wedge. NoLeadIn pierces on the line. There is no lead-out: the cut ends at a tab, which a
|
||||
// run-out would cut into. Entering at the far end cuts it the other way round.
|
||||
private void EmitBreakCuts(Program program, IReadOnlyList<Shape> cuts, IReadOnlyList<Shape> contours)
|
||||
{
|
||||
if (cuts == null)
|
||||
return;
|
||||
var leadIn = Parameters?.InternalLeadIn ?? new NoLeadIn();
|
||||
foreach (var cut in cuts)
|
||||
{
|
||||
var (entry, scrap, fromEnd) = BreakCutEntry(cut, contours);
|
||||
var path = cut;
|
||||
if (fromEnd)
|
||||
{
|
||||
path = (Shape)cut.Clone();
|
||||
path.Reverse();
|
||||
}
|
||||
var normal = Angle.NormalizeRad(System.Math.Atan2(scrap.Y, scrap.X));
|
||||
program.Codes.AddRange(leadIn.Generate(entry, normal, fromEnd ? RotationType.CW : RotationType.CCW));
|
||||
program.Codes.AddRange(ConvertShapeToMoves(path, entry, LayerType.Break));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where a break cut (travelling with its scrap on the left) is entered: the end with more
|
||||
/// scrap before <paramref name="contours"/>, the unit direction into the scrap there, and
|
||||
/// whether that is the cut's far end.
|
||||
/// </summary>
|
||||
public static (Vector Entry, Vector ScrapDirection, bool FromEnd) BreakCutEntry(Shape cut,
|
||||
IReadOnlyList<Shape> contours)
|
||||
{
|
||||
var start = EntityStartPoint(cut.Entities[0]);
|
||||
var end = EntityEndPoint(cut.Entities[^1]);
|
||||
var startScrap = LeftOf(Tangent(cut.Entities[0], atEnd: false));
|
||||
var endScrap = LeftOf(Tangent(cut.Entities[^1], atEnd: true));
|
||||
return Room(end, endScrap, contours) > Room(start, startScrap, contours)
|
||||
? (end, endScrap, true)
|
||||
: (start, startScrap, false);
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="shape"/> has <see cref="SpecialLayers.Tab"/> spans: a tabbed cutout.</summary>
|
||||
internal static bool IsTabbedCutout(Shape shape) => shape.Entities.Exists(e => e.Layer == SpecialLayers.Tab);
|
||||
|
||||
/// <summary>
|
||||
/// The cut pieces of a closed tabbed cutout in its own travel order, starting after a tab:
|
||||
/// each maximal run of entities that are not on the tab layer.
|
||||
/// </summary>
|
||||
internal static List<Shape> TabbedCutoutPieces(Shape cutout)
|
||||
{
|
||||
var entities = cutout.Entities;
|
||||
var count = entities.Count;
|
||||
var first = -1;
|
||||
for (var i = 0; i < count && first < 0; i++)
|
||||
if (entities[i].Layer != SpecialLayers.Tab && entities[(i + count - 1) % count].Layer == SpecialLayers.Tab)
|
||||
first = i;
|
||||
var pieces = new List<Shape>();
|
||||
if (first < 0)
|
||||
return pieces;
|
||||
Shape current = null;
|
||||
for (var k = 0; k < count; k++)
|
||||
{
|
||||
var entity = entities[(first + k) % count];
|
||||
if (entity.Layer == SpecialLayers.Tab)
|
||||
{
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
if (current == null)
|
||||
{
|
||||
current = new Shape();
|
||||
pieces.Add(current);
|
||||
}
|
||||
current.Entities.Add(entity);
|
||||
}
|
||||
foreach (var piece in pieces)
|
||||
piece.UpdateBounds();
|
||||
return pieces;
|
||||
}
|
||||
|
||||
// A tabbed cutout is cut as open pieces between its tabs, in the cutout's own direction.
|
||||
// Each piece is entered from the slug with the internal lead-in (NoLeadIn pierces on the
|
||||
// contour) and has no lead-out, since it ends at a tab a run-out would cut into. A lead-in
|
||||
// that would reach back over the tab behind its piece is mirrored about the entry normal,
|
||||
// so it approaches from the piece's side instead. On an arc, a lead ending in an arc would
|
||||
// meet the cut tangentially (a contact verification cannot resolve), so it pierces straight
|
||||
// in from the same distance.
|
||||
private void EmitTabbedCutout(Program program, Shape cutout)
|
||||
{
|
||||
var winding = DetermineWinding(cutout);
|
||||
var leadIn = Parameters?.InternalLeadIn ?? new NoLeadIn();
|
||||
foreach (var piece in TabbedCutoutPieces(cutout))
|
||||
{
|
||||
var first = piece.Entities[0];
|
||||
var entry = EntityStartPoint(first);
|
||||
var normal = ComputeNormal(entry, first, ContourType.Internal, winding);
|
||||
var tangent = TravelTangent(first, entry);
|
||||
program.Codes.AddRange(TabbedLeadIn(leadIn, entry, normal, tangent, winding, first is Arc));
|
||||
program.Codes.AddRange(ConvertShapeToMoves(piece, entry));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ICode> TabbedLeadIn(LeadIn leadIn, Vector entry, double normal, Vector tangent,
|
||||
RotationType winding, bool onArc)
|
||||
{
|
||||
var codes = leadIn.Generate(entry, normal, winding);
|
||||
if (!(onArc && codes.Count > 0 && codes[^1] is ArcMove))
|
||||
{
|
||||
if (ReachBehind(codes, entry, tangent) <= Tolerance.Epsilon)
|
||||
return codes;
|
||||
var mirrored = codes.Select(code => Mirror(code, entry, tangent)).ToList();
|
||||
if (ReachBehind(mirrored, entry, tangent) <= Tolerance.Epsilon)
|
||||
return mirrored;
|
||||
}
|
||||
// Reaching both ways, or tangent to an arc: pierce straight in from the same distance.
|
||||
var pierce = leadIn.GetPiercePoint(entry, normal, winding);
|
||||
return new LineLeadIn { Length = pierce.DistanceTo(entry), ApproachAngle = 90 }.Generate(entry, normal, winding);
|
||||
}
|
||||
|
||||
// How far the lead motions of `codes` (absolute, after their first rapid) reach behind
|
||||
// `entry`, against the unit travel `tangent`.
|
||||
private static double ReachBehind(List<ICode> codes, Vector entry, Vector tangent)
|
||||
{
|
||||
var behind = new Vector(-tangent.X, -tangent.Y);
|
||||
double Along(Vector p) => (p.X - entry.X) * behind.X + (p.Y - entry.Y) * behind.Y;
|
||||
var reach = double.NegativeInfinity;
|
||||
Vector? position = null;
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is not Motion motion)
|
||||
continue;
|
||||
var end = motion.EndPoint;
|
||||
if (position is { } start && code is not RapidMove)
|
||||
{
|
||||
reach = System.Math.Max(reach, System.Math.Max(Along(start), Along(end)));
|
||||
if (code is ArcMove arc)
|
||||
{
|
||||
var center = arc.CenterPoint;
|
||||
var radius = center.DistanceTo(end);
|
||||
var extreme = new Vector(center.X + behind.X * radius, center.Y + behind.Y * radius);
|
||||
if (OnArc(center, start, end, extreme, arc.Rotation))
|
||||
reach = System.Math.Max(reach, Along(extreme));
|
||||
}
|
||||
}
|
||||
position = end;
|
||||
}
|
||||
return reach;
|
||||
}
|
||||
|
||||
private static bool OnArc(Vector center, Vector start, Vector end, Vector point, RotationType rotation)
|
||||
{
|
||||
var a0 = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
|
||||
var a1 = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
|
||||
var ap = System.Math.Atan2(point.Y - center.Y, point.X - center.X);
|
||||
var sign = rotation == RotationType.CCW ? 1 : -1;
|
||||
var sweep = Angle.NormalizeRad(sign * (a1 - a0));
|
||||
if (sweep <= Tolerance.Epsilon)
|
||||
sweep = Angle.TwoPI;
|
||||
return Angle.NormalizeRad(sign * (ap - a0)) <= sweep;
|
||||
}
|
||||
|
||||
// `code` reflected across the line through `entry` along its normal (negating the
|
||||
// tangent component), with arc directions swapped.
|
||||
private static ICode Mirror(ICode code, Vector entry, Vector tangent)
|
||||
{
|
||||
Vector Reflect(Vector p)
|
||||
{
|
||||
var along = (p.X - entry.X) * tangent.X + (p.Y - entry.Y) * tangent.Y;
|
||||
return new Vector(p.X - 2 * along * tangent.X, p.Y - 2 * along * tangent.Y);
|
||||
}
|
||||
return code switch
|
||||
{
|
||||
RapidMove rapid => new RapidMove(Reflect(rapid.EndPoint)),
|
||||
LinearMove line => new LinearMove(Reflect(line.EndPoint)) { Layer = line.Layer },
|
||||
ArcMove arc => new ArcMove(Reflect(arc.EndPoint), Reflect(arc.CenterPoint),
|
||||
arc.Rotation == RotationType.CW ? RotationType.CCW : RotationType.CW)
|
||||
{ Layer = arc.Layer },
|
||||
_ => code,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<Shape> BreakCutContours(ShapeProfile profile) =>
|
||||
new List<Shape>(profile.Cutouts) { profile.Perimeter };
|
||||
|
||||
// Distance from a break cut end along its scrap direction to the nearest contour.
|
||||
private static double Room(Vector point, Vector direction, IReadOnlyList<Shape> contours)
|
||||
{
|
||||
const double reach = 1e6;
|
||||
var ray = new Line(point, point + direction * reach);
|
||||
var nearest = reach;
|
||||
foreach (var contour in contours ?? Array.Empty<Shape>())
|
||||
foreach (var entity in contour.Entities)
|
||||
if (entity.Intersects(ray, out var hits))
|
||||
foreach (var hit in hits)
|
||||
nearest = System.Math.Min(nearest, hit.DistanceTo(point));
|
||||
return nearest;
|
||||
}
|
||||
|
||||
private static Vector LeftOf(Vector tangent)
|
||||
{
|
||||
var length = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
|
||||
return length <= 0 ? new Vector(0, 1) : new Vector(-tangent.Y / length, tangent.X / length);
|
||||
}
|
||||
|
||||
private static Vector Tangent(Entity entity, bool atEnd)
|
||||
{
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
var angle = atEnd ? arc.EndAngle : arc.StartAngle;
|
||||
return arc.IsReversed
|
||||
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
|
||||
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
|
||||
}
|
||||
var line = (Line)entity;
|
||||
return line.EndPoint - line.StartPoint;
|
||||
}
|
||||
|
||||
private static Vector GetShapeStartPoint(Shape shape)
|
||||
{
|
||||
var first = shape.Entities[0];
|
||||
|
||||
@@ -15,24 +15,81 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
|
||||
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
|
||||
|
||||
/// <summary>
|
||||
/// The lead-in of every cutout, full circles included; circles still get their own
|
||||
/// geometry handling (radius clamp, angle rounding) when the lead is emitted.
|
||||
/// </summary>
|
||||
public LeadIn InternalLeadIn { get; set; } =
|
||||
new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
|
||||
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
|
||||
|
||||
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
|
||||
public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut();
|
||||
|
||||
public double PierceClearance { get; set; } = 0.0625;
|
||||
|
||||
/// <summary>
|
||||
/// Diameter of the crater a pierce blows through this plate; 0 turns the pierce zone off.
|
||||
/// When set, every lead-in pierce must keep half of it from all part material.
|
||||
/// </summary>
|
||||
public double PierceDiameter { get; set; }
|
||||
|
||||
/// <summary>The pierce zone's radius, or 0 when <see cref="PierceDiameter"/> is off.</summary>
|
||||
public double GetPierceZoneRadius() =>
|
||||
PierceDiameter > 0 && double.IsFinite(PierceDiameter) ? PierceDiameter / 2 : 0;
|
||||
|
||||
/// <summary>The clearance a pierce keeps from its own contour: the larger setting wins.</summary>
|
||||
public double GetEffectivePierceClearance() => System.Math.Max(PierceClearance, GetPierceZoneRadius());
|
||||
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Tabs each part its plate's support bed cannot hold level once it is cut free, at the
|
||||
/// perimeter point that best keeps its center of gravity over the bed's contacts (see
|
||||
/// docs/support-tabs.md). Needs <see cref="TabConfig"/> for the tab size.
|
||||
/// </summary>
|
||||
public bool SupportTabs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// With <see cref="SupportTabs"/> on a plate that has no support bed, the narrowest part
|
||||
/// width that is tabbed instead (see <see cref="TabsWithoutBedAt"/>).
|
||||
/// </summary>
|
||||
public double NoBedTabMinWidth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// With <see cref="SupportTabs"/> on a plate that has no support bed, the widest part
|
||||
/// width that is tabbed instead; 0 turns the rule off, so nothing is tabbed without a bed.
|
||||
/// </summary>
|
||||
public double NoBedTabMaxWidth { get; set; }
|
||||
|
||||
/// <summary>Whether <see cref="NoBedTabMinWidth"/> to <see cref="NoBedTabMaxWidth"/> is a usable range.</summary>
|
||||
public bool HasNoBedTabRange() =>
|
||||
NoBedTabMaxWidth > 0 && double.IsFinite(NoBedTabMaxWidth)
|
||||
&& NoBedTabMinWidth >= 0 && NoBedTabMinWidth <= NoBedTabMaxWidth;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a part <paramref name="width"/> wide (the short side of its minimum-area
|
||||
/// bounding rectangle) is tabbed when its plate has no support bed.
|
||||
/// </summary>
|
||||
public bool TabsWithoutBedAt(double width) =>
|
||||
HasNoBedTabRange()
|
||||
&& width >= NoBedTabMinWidth - Math.Tolerance.Epsilon
|
||||
&& width <= NoBedTabMaxWidth + Math.Tolerance.Epsilon;
|
||||
|
||||
/// <summary>
|
||||
/// With <see cref="SupportTabs"/> on a plate with a support bed, the longest part (the
|
||||
/// long side of its outline's minimum-area bounding rectangle) its tab alone holds: such a
|
||||
/// part the bed cannot hold level is tabbed, and any tab clear of inside corners steadies
|
||||
/// it. 0 turns the rule off.
|
||||
/// </summary>
|
||||
public double TabHeldMaxLength { get; set; }
|
||||
|
||||
/// <summary>Whether a part <paramref name="length"/> long is held by its tab alone (see <see cref="TabHeldMaxLength"/>).</summary>
|
||||
public bool TabHoldsAt(double length) =>
|
||||
TabHeldMaxLength > 0 && double.IsFinite(TabHeldMaxLength)
|
||||
&& length <= TabHeldMaxLength + Math.Tolerance.Epsilon;
|
||||
|
||||
public SequenceParameters Sequencing { get; set; } = new SequenceParameters();
|
||||
public AssignmentParameters Assignment { get; set; } = new AssignmentParameters();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Pierces found by eroding a cutout: the part of it that keeps a clearance from its wall
|
||||
/// (see <see cref="ErodedPierce.Find"/>).
|
||||
/// </summary>
|
||||
/// <param name="Pierces">The pierce for each target in turn, up to the maximum asked for.</param>
|
||||
/// <param name="Clearance">The least any pierce keeps from the wall, at the erosion level that found it.</param>
|
||||
public sealed record ErodedPierces(IReadOnlyList<Vector> Pierces, double Clearance)
|
||||
{
|
||||
public static ErodedPierces None { get; } = new(System.Array.Empty<Vector>(), 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pierce search for cutouts too small for their configured lead-in (text, slots, small
|
||||
/// holes): erode the cutout inward, as a laser kerf offset would, and pierce in what is left.
|
||||
/// The erosion goes as deep as the pierce clearance when the cutout has room for it, else
|
||||
/// almost to its pole of inaccessibility. Each target, an entry the planner ranked, gets the
|
||||
/// nearest point of the deepest erosion that still reaches the wall beside it, stepping down to
|
||||
/// half that depth: a letter stroke is pierced on its own centre line next to the entry, and
|
||||
/// only a target with no stroke beside it falls back to the deepest place. The lead then runs
|
||||
/// straight from the pierce to the wall.
|
||||
/// </summary>
|
||||
public static class ErodedPierce
|
||||
{
|
||||
/// <summary>
|
||||
/// The share of a cutout's inscribed radius the erosion keeps when the full clearance does
|
||||
/// not fit: deep enough to stay near the centre line, with room for every place the
|
||||
/// cutout is about as wide as its widest.
|
||||
/// </summary>
|
||||
public const double DepthFraction = 0.9;
|
||||
|
||||
private const int PoleSearchCells = 4096;
|
||||
|
||||
// Shallower erosion levels a target may fall back to, each a tenth of the deepest less.
|
||||
private const int LevelCount = 6;
|
||||
private const double LevelStep = 0.1;
|
||||
|
||||
/// <summary>
|
||||
/// A pierce in the closed cutout <paramref name="hole"/> for each of the first
|
||||
/// <paramref name="max"/> <paramref name="targets"/>: the eroded point nearest it. A cutout
|
||||
/// too small to erode is pierced at its pole for every target. Nothing for an open or
|
||||
/// degenerate contour.
|
||||
/// </summary>
|
||||
public static ErodedPierces Find(Shape hole, double clearance, IReadOnlyList<Vector> targets, int max)
|
||||
{
|
||||
if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0
|
||||
|| !double.IsFinite(clearance))
|
||||
return ErodedPierces.None;
|
||||
|
||||
// The inscribed radius, from the true wall: the deepest any erosion can go.
|
||||
var coarse = ClipperBridge.Flatten(hole, Tolerance(hole.BoundingBox.Width, hole.BoundingBox.Length), false);
|
||||
if (coarse.Vertices.Count < 3)
|
||||
return ErodedPierces.None;
|
||||
var pole = PolyLabel.Find(coarse, null, Tolerance(coarse.BoundingBox.Width, coarse.BoundingBox.Length) / 10,
|
||||
PoleSearchCells);
|
||||
if (!double.IsFinite(pole.X) || !double.IsFinite(pole.Y) || !coarse.ContainsPoint(pole))
|
||||
return ErodedPierces.None;
|
||||
var inscribed = hole.ClosestPointTo(pole, out _).DistanceTo(pole);
|
||||
if (!(inscribed > 0))
|
||||
return ErodedPierces.None;
|
||||
|
||||
// Inscribed flattening can stand off a concave arc, and round joins cut their arcs,
|
||||
// each by the tolerance; Clipper rounds to its precision. Erode past both.
|
||||
var depth = System.Math.Min(System.Math.Max(clearance, 0), inscribed * DepthFraction);
|
||||
var tolerance = System.Math.Max(depth / 100, System.Math.Pow(10, -ClipperBridge.Precision));
|
||||
var margin = 2 * tolerance + System.Math.Pow(10, -ClipperBridge.Precision);
|
||||
if (depth + margin >= inscribed - tolerance)
|
||||
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
|
||||
|
||||
var polygon = ClipperBridge.Flatten(hole, tolerance, circumscribe: false);
|
||||
var path = new PathsD { ClipperBridge.ToPath(polygon, positive: true) };
|
||||
var levels = new List<PathD>[LevelCount];
|
||||
List<PathD> Level(int level) => levels[level] ??= Clipper.InflatePaths(path,
|
||||
-(Depth(level) + margin), JoinType.Round, EndType.Polygon, 2.0, ClipperBridge.Precision, tolerance)
|
||||
.Where(p => p.Count >= 3).ToList();
|
||||
double Depth(int level) => depth * (1 - level * LevelStep);
|
||||
if (Level(0).Count == 0)
|
||||
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
|
||||
|
||||
// Each target gets the deepest level that reaches the wall beside it: a stroke
|
||||
// narrower than the cutout's widest place is still pierced on its own centre line, near
|
||||
// the entry, rather than only at the widest place.
|
||||
var pierces = new List<Vector>();
|
||||
var shallowest = depth;
|
||||
foreach (var target in targets)
|
||||
{
|
||||
var pierce = Nearest(Level(0), target);
|
||||
var reached = 0;
|
||||
for (var level = 0; level < LevelCount; level++)
|
||||
{
|
||||
var region = Level(level);
|
||||
if (region.Count == 0)
|
||||
continue;
|
||||
var nearest = Nearest(region, target);
|
||||
if (nearest.DistanceTo(target) <= 2 * (Depth(level) + margin))
|
||||
{
|
||||
pierce = nearest;
|
||||
reached = level;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pierces.Add(pierce);
|
||||
shallowest = System.Math.Min(shallowest, Depth(reached));
|
||||
if (pierces.Count == max)
|
||||
break;
|
||||
}
|
||||
return new(pierces, shallowest);
|
||||
}
|
||||
|
||||
// A hundredth of the smaller side: fine enough to find a thin cutout's pole.
|
||||
private static double Tolerance(double width, double length) =>
|
||||
System.Math.Max(System.Math.Min(width, length) / 100, System.Math.Pow(10, -ClipperBridge.Precision));
|
||||
|
||||
// The closest point of the region's boundary; targets lie on the wall, outside it.
|
||||
private static Vector Nearest(List<PathD> paths, Vector target)
|
||||
{
|
||||
var best = default(Vector);
|
||||
var bestDistance = double.PositiveInfinity;
|
||||
foreach (var path in paths)
|
||||
for (var i = 0; i < path.Count; i++)
|
||||
{
|
||||
var a = new Vector(path[i].x, path[i].y);
|
||||
var b = new Vector(path[(i + 1) % path.Count].x, path[(i + 1) % path.Count].y);
|
||||
var point = new Line(a, b).ClosestPointTo(target);
|
||||
var distance = point.DistanceTo(target);
|
||||
if (distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
best = point;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,62 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// A single arc that ends tangent to the cut. Its center lies <see cref="Radius"/> along the
|
||||
/// contour normal and it turns <see cref="SweepAngle"/> degrees in the lead's turn direction:
|
||||
/// 90 is a quarter circle, 180 (the default) a half circle starting two radii out.
|
||||
/// </summary>
|
||||
public class ArcLeadIn : LeadIn
|
||||
{
|
||||
public const double DefaultSweepAngle = 180.0;
|
||||
|
||||
public double Radius { get; set; }
|
||||
|
||||
/// <summary>Degrees of arc, in (0, 180].</summary>
|
||||
public double SweepAngle { get; set; } = DefaultSweepAngle;
|
||||
|
||||
public override List<ICode> Generate(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
|
||||
var arcCenter = new Vector(
|
||||
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
|
||||
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||
new RapidMove(GetPiercePoint(contourStartPoint, contourNormalAngle, winding)),
|
||||
new ArcMove(contourStartPoint, ArcCenter(contourStartPoint, contourNormalAngle), winding)
|
||||
{
|
||||
Layer = LayerType.Leadin,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
public override Vector GetPiercePoint(
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var arcCenterX = contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle);
|
||||
var arcCenterY = contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
|
||||
|
||||
// The contour point sits opposite the normal from the center; the pierce is the
|
||||
// sweep back from there, against the turn. A half circle pierces on the normal.
|
||||
var shortfall = Angle.ToRadians(DefaultSweepAngle - SweepAngle);
|
||||
var angle = contourNormalAngle + (winding == RotationType.CCW ? shortfall : -shortfall);
|
||||
var center = ArcCenter(contourStartPoint, contourNormalAngle);
|
||||
return new Vector(
|
||||
arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
|
||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle)
|
||||
center.X + Radius * System.Math.Cos(angle),
|
||||
center.Y + Radius * System.Math.Sin(angle)
|
||||
);
|
||||
}
|
||||
|
||||
public override LeadIn Scale(double factor) => new ArcLeadIn { Radius = Radius * factor };
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new ArcLeadIn { Radius = Radius * factor, SweepAngle = SweepAngle };
|
||||
|
||||
private Vector ArcCenter(Vector contourStartPoint, double contourNormalAngle) => new Vector(
|
||||
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
|
||||
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public class CleanHoleLeadIn : LeadIn
|
||||
{
|
||||
private const double ApproachAngle = 135.0;
|
||||
|
||||
public double LineLength { get; set; }
|
||||
public double ArcRadius { get; set; }
|
||||
public double Kerf { get; set; }
|
||||
@@ -14,42 +15,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
|
||||
|
||||
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
||||
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
||||
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
||||
|
||||
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
|
||||
var arcStart = new Vector(
|
||||
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||
};
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
{
|
||||
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
||||
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
||||
|
||||
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
|
||||
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
|
||||
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
|
||||
|
||||
return new Vector(
|
||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
||||
arcStartY + LineLength * System.Math.Sin(lineAngle)
|
||||
);
|
||||
}
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW) =>
|
||||
Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new CleanHoleLeadIn
|
||||
@@ -58,5 +28,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
ArcRadius = ArcRadius * factor,
|
||||
Kerf = Kerf,
|
||||
};
|
||||
|
||||
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle, RotationType winding) =>
|
||||
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength, winding);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@ using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// A lead into a contour point. <c>winding</c> is the direction a lead arc turns to meet the
|
||||
/// cut tangentially: the cut's own direction around the arc center, which lies along the
|
||||
/// normal (see <see cref="ContourCuttingStrategy.LeadTurn"/>). Straight styles ignore it.
|
||||
/// </summary>
|
||||
public abstract class LeadIn
|
||||
{
|
||||
public abstract List<ICode> Generate(
|
||||
@@ -11,7 +16,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
RotationType winding = RotationType.CW
|
||||
);
|
||||
|
||||
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
|
||||
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW);
|
||||
|
||||
public virtual LeadIn Scale(double factor) => this;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// A straight line into an arc that ends on the contour start, shared by
|
||||
/// <see cref="LineArcLeadIn"/> and <see cref="CleanHoleLeadIn"/>. The arc's center lies
|
||||
/// the arc radius along the contour normal; the line meets the arc at the approach angle
|
||||
/// (degrees from the normal, on the side the arc turns from) and starts at the pierce point.
|
||||
/// </summary>
|
||||
internal readonly struct LineArcApproach
|
||||
{
|
||||
private LineArcApproach(Vector arcCenter, Vector arcStart, Vector piercePoint)
|
||||
{
|
||||
ArcCenter = arcCenter;
|
||||
ArcStart = arcStart;
|
||||
PiercePoint = piercePoint;
|
||||
}
|
||||
|
||||
public Vector ArcCenter { get; }
|
||||
|
||||
public Vector ArcStart { get; }
|
||||
|
||||
public Vector PiercePoint { get; }
|
||||
|
||||
public static LineArcApproach Create(Vector contourStartPoint, double contourNormalAngle,
|
||||
double arcRadius, double approachAngle, double lineLength, RotationType winding)
|
||||
{
|
||||
var arcCenterX = contourStartPoint.X + arcRadius * System.Math.Cos(contourNormalAngle);
|
||||
var arcCenterY = contourStartPoint.Y + arcRadius * System.Math.Sin(contourNormalAngle);
|
||||
|
||||
// The approach angle is measured back from the normal against the turn, so the arc
|
||||
// runs the short way round to the contour point whichever way it turns.
|
||||
var approach = Angle.ToRadians(approachAngle);
|
||||
var lineAngle = contourNormalAngle + (winding == RotationType.CCW ? approach : -approach);
|
||||
var arcStartX = arcCenterX + arcRadius * System.Math.Cos(lineAngle);
|
||||
var arcStartY = arcCenterY + arcRadius * System.Math.Sin(lineAngle);
|
||||
|
||||
return new LineArcApproach(
|
||||
new Vector(arcCenterX, arcCenterY),
|
||||
new Vector(arcStartX, arcStartY),
|
||||
new Vector(
|
||||
arcStartX + lineLength * System.Math.Cos(lineAngle),
|
||||
arcStartY + lineLength * System.Math.Sin(lineAngle)));
|
||||
}
|
||||
|
||||
/// <summary>Rapid to the pierce point, then the line and arc on the lead-in layer.</summary>
|
||||
public List<ICode> ToCodes(Vector contourStartPoint, RotationType winding) =>
|
||||
new List<ICode>
|
||||
{
|
||||
new RapidMove(PiercePoint),
|
||||
new LinearMove(ArcStart) { Layer = LayerType.Leadin },
|
||||
new ArcMove(contourStartPoint, ArcCenter, winding) { Layer = LayerType.Leadin },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -14,42 +13,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
|
||||
|
||||
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
||||
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
||||
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
||||
|
||||
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
||||
var arcStart = new Vector(
|
||||
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||
};
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
{
|
||||
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
||||
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
||||
|
||||
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
||||
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
|
||||
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
|
||||
|
||||
return new Vector(
|
||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
||||
arcStartY + LineLength * System.Math.Sin(lineAngle)
|
||||
);
|
||||
}
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW) =>
|
||||
Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new LineArcLeadIn
|
||||
@@ -58,5 +26,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
ArcRadius = ArcRadius * factor,
|
||||
ApproachAngle = ApproachAngle,
|
||||
};
|
||||
|
||||
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle, RotationType winding) =>
|
||||
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength, winding);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
};
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
{
|
||||
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
|
||||
return new Vector(
|
||||
|
||||
@@ -33,7 +33,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
};
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
{
|
||||
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
|
||||
|
||||
@@ -14,7 +14,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return new List<ICode> { new RapidMove(contourStartPoint) };
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
{
|
||||
return contourStartPoint;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// A quarter circle leaving the cut tangentially, turning <c>winding</c> about a center
|
||||
/// <see cref="Radius"/> along the contour normal.
|
||||
/// </summary>
|
||||
public class ArcLeadOut : LeadOut
|
||||
{
|
||||
public double Radius { get; set; }
|
||||
@@ -17,9 +21,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
|
||||
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
||||
|
||||
// The contour point sits opposite the normal from the center; turn a quarter on.
|
||||
var endAngle = contourNormalAngle
|
||||
+ (winding == RotationType.CCW ? -System.Math.PI / 2 : System.Math.PI / 2);
|
||||
var endPoint = new Vector(
|
||||
arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2),
|
||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)
|
||||
arcCenterX + Radius * System.Math.Cos(endAngle),
|
||||
arcCenterY + Radius * System.Math.Sin(endAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
/// <summary>
|
||||
/// The never-cut outline of a supplied blank, kept at the end of an applied cutting program.
|
||||
/// An applied program for a blank with a <see cref="LayerType.DisplayPerimeter"/> outline cuts
|
||||
/// only its cutouts; the outline follows them after <see cref="Marker"/> so the part keeps the
|
||||
/// blank's extent for bounds, overlap and cut-offs. Nothing that executes or posts a program may
|
||||
/// run the tail: executed reads stop at the marker and posts take <see cref="ForPosting"/>.
|
||||
/// </summary>
|
||||
public static class DisplayPerimeterTail
|
||||
{
|
||||
/// <summary>The comment that starts the tail; every code after it is display only.</summary>
|
||||
public const string Marker = "DISPLAY PERIMETER NEVER CUT";
|
||||
|
||||
/// <summary>Why the manual and legacy lead-in tools leave a display-perimeter part alone.</summary>
|
||||
public const string LeadInRefusal =
|
||||
"This part's outline is a display perimeter: a supplied blank that is never cut. "
|
||||
+ "Lead-ins for its cutouts come from Plan Cutting, which keeps every pierce inside a cutout.";
|
||||
|
||||
/// <summary>
|
||||
/// The index of the tail's marker in <paramref name="program"/>'s codes, or -1. A marker that is
|
||||
/// repeated, sits in a sub-program, or is followed by anything but one rapid and moves on the
|
||||
/// display perimeter layer is a malformed program (<see cref="ArgumentException"/>): cuts after
|
||||
/// it must never be dropped silently.
|
||||
/// </summary>
|
||||
public static int IndexOf(Program program)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(program);
|
||||
var marker = -1;
|
||||
for (var i = 0; i < program.Codes.Count; i++)
|
||||
{
|
||||
if (program.Codes[i] is Comment { Value: Marker })
|
||||
{
|
||||
if (marker >= 0)
|
||||
throw new ArgumentException("A program has more than one display perimeter tail.");
|
||||
marker = i;
|
||||
}
|
||||
else if (program.Codes[i] is SubProgramCall call
|
||||
&& HasMarker(call.Program, new HashSet<Program>(ReferenceEqualityComparer.Instance)))
|
||||
throw new ArgumentException("A display perimeter tail can only end the part's own program, not a sub-program.");
|
||||
}
|
||||
if (marker < 0)
|
||||
return -1;
|
||||
var tail = program.Codes.Count - marker - 1;
|
||||
if (tail < 2 || program.Codes[marker + 1] is not RapidMove
|
||||
|| !program.Codes.Skip(marker + 2).All(code =>
|
||||
code is LinearMove { Layer: LayerType.DisplayPerimeter } or ArcMove { Layer: LayerType.DisplayPerimeter }))
|
||||
throw new ArgumentException(
|
||||
"A display perimeter tail must be one rapid followed only by the outline on the display perimeter layer.");
|
||||
return marker;
|
||||
}
|
||||
|
||||
private static bool HasMarker(Program program, HashSet<Program> visited)
|
||||
{
|
||||
if (program?.Codes == null || !visited.Add(program))
|
||||
return false;
|
||||
foreach (var code in program.Codes)
|
||||
if (code is Comment { Value: Marker } || code is SubProgramCall call && HasMarker(call.Program, visited))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool HasTail(Program program) => IndexOf(program) >= 0;
|
||||
|
||||
/// <summary>
|
||||
/// Appends the outline after the marker: a rapid to its start, then its entities on the
|
||||
/// display perimeter layer. <paramref name="program"/> must still be in absolute mode.
|
||||
/// </summary>
|
||||
internal static void Append(Program program, IReadOnlyList<ICode> outline, Vector start)
|
||||
{
|
||||
if (program.Mode != Mode.Absolute)
|
||||
throw new InvalidOperationException("Append the display tail before switching to incremental mode.");
|
||||
program.Codes.Add(new Comment(Marker));
|
||||
program.Codes.Add(new RapidMove(start));
|
||||
program.Codes.AddRange(outline);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The executed program for estimates and drawing: <paramref name="program"/> itself when it
|
||||
/// has no well-formed tail, otherwise a shallow copy of the codes before the marker. Posts
|
||||
/// and verification use the strict <see cref="ForPosting"/> and executed reads instead.
|
||||
/// </summary>
|
||||
public static Program WithoutTail(Program program)
|
||||
{
|
||||
try
|
||||
{
|
||||
var marker = IndexOf(program);
|
||||
return marker < 0 ? program : program.HeadCopy(marker);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return program;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="program"/> or any sub-program it calls moves on the display perimeter layer.</summary>
|
||||
public static bool HasDisplayPerimeterMotion(Program program) =>
|
||||
HasDisplayPerimeterMotion(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
|
||||
|
||||
private static bool HasDisplayPerimeterMotion(Program program, HashSet<Program> visited)
|
||||
{
|
||||
if (program?.Codes == null || !visited.Add(program))
|
||||
return false;
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
if (code is LinearMove { Layer: LayerType.DisplayPerimeter } or ArcMove { Layer: LayerType.DisplayPerimeter })
|
||||
return true;
|
||||
if (code is SubProgramCall call && HasDisplayPerimeterMotion(call.Program, visited))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The program a post may write for a part: its tail removed. A display perimeter anywhere
|
||||
/// else means the part was never planned, and posting it would cut the blank's outline.
|
||||
/// </summary>
|
||||
public static Program ForPosting(Program program, string partName)
|
||||
{
|
||||
int marker;
|
||||
try
|
||||
{
|
||||
marker = IndexOf(program);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"{partName} cannot be posted: {ex.Message}", ex);
|
||||
}
|
||||
var executed = marker < 0 ? program : program.HeadCopy(marker);
|
||||
if (HasDisplayPerimeterMotion(executed))
|
||||
throw new InvalidOperationException(
|
||||
$"{partName} has a display perimeter (a supplied blank's outline that is never cut) but no "
|
||||
+ "cutting plan. Plan cutting first (Plate > Plan Cutting..., or --plan-cutting) so only its cutouts are cut.");
|
||||
return executed;
|
||||
}
|
||||
|
||||
/// <summary>The name posts give a part in a refusal.</summary>
|
||||
public static string PartName(Part part) => $"Part '{part.BaseDrawing?.Name}'";
|
||||
|
||||
/// <summary>
|
||||
/// Refuses a nest a post cannot write, before any output is created: every part's program
|
||||
/// must pass <see cref="ForPosting"/>, so a refusal never leaves an empty or partial file.
|
||||
/// </summary>
|
||||
public static void EnsurePostable(Nest nest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
foreach (var plate in nest.Plates)
|
||||
foreach (var part in plate.Parts)
|
||||
if (part?.Program != null && part.BaseDrawing?.IsCutOff != true)
|
||||
ForPosting(part.Program, PartName(part));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
/// <summary>
|
||||
/// The head-height lock around a cut-off's overcut past the nominal far sheet edge. Stock can
|
||||
/// arrive wider than nominal, so a cut-off overcuts past the far edge to cut the oversize off;
|
||||
/// past the real edge there is no material to sense, so the head height is locked first. A
|
||||
/// cut-off program marks the locked stretch with a <see cref="Lock"/> comment and ends it with an
|
||||
/// <see cref="Unlock"/> comment; a rapid between them keeps the head locked and is not a head
|
||||
/// raise. Posts write each marker as their machine's lock or unlock code and refuse to post a
|
||||
/// marked program when no code is configured, because the overcut is unsafe without the lock.
|
||||
/// See docs/automatic-scrap-cutoffs.md.
|
||||
/// </summary>
|
||||
public static class HeadHeightLock
|
||||
{
|
||||
/// <summary>The comment before the first motion made with the head height locked.</summary>
|
||||
public const string Lock = "HEAD HEIGHT LOCK";
|
||||
|
||||
/// <summary>The comment after the last motion made with the head height locked.</summary>
|
||||
public const string Unlock = "HEAD HEIGHT UNLOCK";
|
||||
|
||||
public static bool IsLock(ICode code) => code is Comment { Value: Lock };
|
||||
|
||||
public static bool IsUnlock(ICode code) => code is Comment { Value: Unlock };
|
||||
|
||||
/// <summary>Whether <paramref name="program"/>'s own codes hold a lock marker.</summary>
|
||||
public static bool IsMarked(Program program)
|
||||
{
|
||||
if (program?.Codes == null)
|
||||
return false;
|
||||
foreach (var code in program.Codes)
|
||||
if (IsLock(code))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For each of <paramref name="codes"/>, whether the head height is locked while it runs: the
|
||||
/// codes after a lock marker up to its unlock marker. Markers must alternate, starting with a
|
||||
/// lock and ending unlocked; anything else is a malformed program (<see cref="ArgumentException"/>),
|
||||
/// since a post that lost track of the lock could cut off the sheet unlocked or never unlock.
|
||||
/// </summary>
|
||||
public static bool[] LockedMask(IReadOnlyList<ICode> codes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(codes);
|
||||
var mask = new bool[codes.Count];
|
||||
var locked = false;
|
||||
for (var i = 0; i < codes.Count; i++)
|
||||
{
|
||||
if (IsLock(codes[i]))
|
||||
{
|
||||
if (locked)
|
||||
throw new ArgumentException("A head-height lock is set twice without an unlock.");
|
||||
locked = true;
|
||||
}
|
||||
else if (IsUnlock(codes[i]))
|
||||
{
|
||||
if (!locked)
|
||||
throw new ArgumentException("A head-height unlock has no lock before it.");
|
||||
locked = false;
|
||||
}
|
||||
else
|
||||
mask[i] = locked;
|
||||
}
|
||||
if (locked)
|
||||
throw new ArgumentException("A head-height lock is never released.");
|
||||
return mask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses a nest a post cannot write, before any output is created: a cut-off with a locked
|
||||
/// overcut needs the post's lock and unlock codes, and its markers must be well formed.
|
||||
/// </summary>
|
||||
public static void EnsurePostable(Nest nest, bool hasCodes, string postName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
foreach (var plate in nest.Plates)
|
||||
{
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (part?.Program == null || !IsMarked(part.Program))
|
||||
continue;
|
||||
var name = DisplayPerimeterTail.PartName(part);
|
||||
if (!hasCodes)
|
||||
throw new InvalidOperationException(
|
||||
$"{name} overcuts past the sheet edge with the head height locked, but the {postName} post "
|
||||
+ "has no head-height lock and unlock codes. Enter them in the post's configuration, "
|
||||
+ "or delete the cut-off and add it again with the head-height lock off.");
|
||||
try
|
||||
{
|
||||
LockedMask(part.Program.Codes);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace OpenNest.CNC
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public enum LayerType
|
||||
{
|
||||
@@ -7,5 +7,28 @@
|
||||
Cut,
|
||||
Leadin,
|
||||
Leadout,
|
||||
|
||||
/// <summary>
|
||||
/// An intentionally open cut through part material, left uncut at tabs (a breakoff's
|
||||
/// break line). It cuts like <see cref="Cut"/> but is not a material boundary; the scrap
|
||||
/// it may be pierced from lies to the left of travel.
|
||||
/// </summary>
|
||||
Break,
|
||||
|
||||
/// <summary>
|
||||
/// An uncut span of a material boundary (a tabbed cutout's tab). It bounds material like
|
||||
/// <see cref="Cut"/> so the cutout stays a closed hole, but it is never cut: emission
|
||||
/// leaves it out, cutting the rest of the contour as open pieces.
|
||||
/// </summary>
|
||||
Tab,
|
||||
|
||||
/// <summary>
|
||||
/// The outside edge of a supplied blank that is only shown, never cut (for example a
|
||||
/// purchased door whose instrument cutouts are the only cuts). It bounds material like
|
||||
/// <see cref="Cut"/>, so nesting, containment, lead clearance and cut-offs all see the
|
||||
/// blank, but it has no pierce, lead or motion in any emitted program. It must be one whole
|
||||
/// closed contour enclosing every other contour.
|
||||
/// </summary>
|
||||
DisplayPerimeter,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
/// <summary>
|
||||
/// Re-times <see cref="CutSimulationStep"/>s with trapezoidal acceleration, in the style of
|
||||
/// Grbl's lookahead planner. Each cutting run (moves between a pierce and the next travel)
|
||||
/// starts and ends at rest. Corners slow to a junction-deviation speed, arcs are capped at
|
||||
/// their centripetal limit, and a backward then forward pass keeps every change of speed
|
||||
/// within the acceleration. Travel and pierces are planned on their own, from rest to rest.
|
||||
/// </summary>
|
||||
public static class MotionPlanner
|
||||
{
|
||||
private const double Straight = 0.999999;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces each step's <see cref="CutSimulationStep.Duration"/> with its accelerated time,
|
||||
/// from its <see cref="CutSimulationStep.NominalSpeed"/>. Pierces keep their dwell.
|
||||
/// </summary>
|
||||
public static void Plan(List<CutSimulationStep> steps, MotionProfile motion)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(steps);
|
||||
ArgumentNullException.ThrowIfNull(motion);
|
||||
|
||||
var start = 0;
|
||||
while (start < steps.Count)
|
||||
{
|
||||
var end = start + 1;
|
||||
if (IsCutting(steps[start]))
|
||||
{
|
||||
while (end < steps.Count && IsCutting(steps[end]) && steps[end].Kind == steps[start].Kind)
|
||||
end++;
|
||||
PlanRun(steps, start, end, motion.CutAcceleration, motion.CornerDeviation);
|
||||
}
|
||||
else if (steps[start].Kind == CutSimulationStepKind.Rapid)
|
||||
{
|
||||
PlanRun(steps, start, end, motion.RapidAcceleration, 0);
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCutting(CutSimulationStep step) =>
|
||||
step.Kind is CutSimulationStepKind.Cut or CutSimulationStepKind.Scribe;
|
||||
|
||||
private static void PlanRun(List<CutSimulationStep> steps, int start, int end, double acceleration, double deviation)
|
||||
{
|
||||
if (acceleration <= 0)
|
||||
return; // Keep the constant-speed timing.
|
||||
|
||||
var count = end - start;
|
||||
var maxSpeed = new double[count];
|
||||
var entry = new double[count + 1]; // entry[count] is the run's exit: at rest.
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var step = steps[start + i];
|
||||
var nominal = step.NominalSpeed;
|
||||
maxSpeed[i] = step.IsArc && step.Radius > 0
|
||||
? System.Math.Min(nominal, System.Math.Sqrt(acceleration * step.Radius))
|
||||
: nominal;
|
||||
entry[i] = i == 0 ? 0 : JunctionSpeed(steps[start + i - 1], step, acceleration, deviation, System.Math.Min(maxSpeed[i - 1], maxSpeed[i]));
|
||||
}
|
||||
|
||||
// Backward: every step must be able to slow to the next one's entry.
|
||||
for (var i = count - 1; i >= 0; i--)
|
||||
entry[i] = System.Math.Min(entry[i], Reachable(entry[i + 1], acceleration, steps[start + i].Length));
|
||||
|
||||
// Forward: and to reach that entry from its own.
|
||||
for (var i = 0; i < count; i++)
|
||||
entry[i + 1] = System.Math.Min(entry[i + 1], Reachable(entry[i], acceleration, steps[start + i].Length));
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
Profile(steps[start + i], entry[i], entry[i + 1], maxSpeed[i], acceleration);
|
||||
}
|
||||
|
||||
private static double Reachable(double speed, double acceleration, double length) =>
|
||||
System.Math.Sqrt(speed * speed + 2 * acceleration * length);
|
||||
|
||||
/// <summary>
|
||||
/// The fastest the head may pass from <paramref name="previous"/> into <paramref name="next"/>:
|
||||
/// v² = a·δ·sin(θ/2) / (1 − sin(θ/2)), θ being the angle the path turns through.
|
||||
/// </summary>
|
||||
internal static double JunctionSpeed(CutSimulationStep previous, CutSimulationStep next, double acceleration, double deviation, double cap)
|
||||
{
|
||||
var exit = previous.TangentAt(1);
|
||||
var enter = next.TangentAt(0);
|
||||
var cosTheta = -(exit.X * enter.X + exit.Y * enter.Y);
|
||||
|
||||
if (cosTheta < -Straight)
|
||||
return cap;
|
||||
if (cosTheta > Straight || deviation <= 0)
|
||||
return 0;
|
||||
|
||||
var sinHalf = System.Math.Sqrt(0.5 * (1 - cosTheta));
|
||||
var speed = System.Math.Sqrt(acceleration * deviation * sinHalf / (1 - sinHalf));
|
||||
return System.Math.Min(cap, speed);
|
||||
}
|
||||
|
||||
private static void Profile(CutSimulationStep step, double entry, double exit, double maxSpeed, double acceleration)
|
||||
{
|
||||
var length = step.Length;
|
||||
if (length <= 0 || maxSpeed <= 0)
|
||||
return; // Nothing to accelerate: keep the constant-speed timing.
|
||||
|
||||
var cruise = maxSpeed;
|
||||
var accelDistance = (cruise * cruise - entry * entry) / (2 * acceleration);
|
||||
var decelDistance = (cruise * cruise - exit * exit) / (2 * acceleration);
|
||||
if (accelDistance + decelDistance > length)
|
||||
{
|
||||
// Too short to reach full speed: a triangle peaking where the two ramps meet.
|
||||
cruise = System.Math.Sqrt((2 * acceleration * length + entry * entry + exit * exit) / 2);
|
||||
cruise = System.Math.Max(cruise, System.Math.Max(entry, exit));
|
||||
accelDistance = (cruise * cruise - entry * entry) / (2 * acceleration);
|
||||
decelDistance = (cruise * cruise - exit * exit) / (2 * acceleration);
|
||||
}
|
||||
|
||||
var cruiseDistance = System.Math.Max(0, length - accelDistance - decelDistance);
|
||||
var seconds = (cruise - entry) / acceleration + (cruise - exit) / acceleration
|
||||
+ (cruise > 0 ? cruiseDistance / cruise : 0);
|
||||
|
||||
step.EntrySpeed = entry;
|
||||
step.CruiseSpeed = cruise;
|
||||
step.ExitSpeed = exit;
|
||||
step.Acceleration = acceleration;
|
||||
step.Duration = TimeSpan.FromSeconds(seconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,6 +508,21 @@ namespace OpenNest.CNC
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A shallow copy holding only the first <paramref name="count"/> codes: the same
|
||||
/// instruction, variable and sub-program instances, mode and rotation.
|
||||
/// </summary>
|
||||
internal Program HeadCopy(int count)
|
||||
{
|
||||
var head = new Program() { mode = this.mode, Rotation = this.Rotation };
|
||||
head.Codes.AddRange(Codes.GetRange(0, count));
|
||||
foreach (var kvp in Variables)
|
||||
head.Variables[kvp.Key] = kvp.Value;
|
||||
foreach (var kvp in SubPrograms)
|
||||
head.SubPrograms[kvp.Key] = kvp.Value;
|
||||
return head;
|
||||
}
|
||||
|
||||
public object Clone()
|
||||
{
|
||||
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
|
||||
|
||||
@@ -31,16 +31,18 @@ namespace OpenNest.CNC
|
||||
|
||||
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
|
||||
{
|
||||
// Draw the rapid from the previous tool position to the program's first
|
||||
// pierce point. The walk then starts at the program origin (basePos), not
|
||||
// the pierce: the skipped first rapid still advances pos, so starting at
|
||||
// the pierce would apply a nonzero Incremental first delta twice (as in
|
||||
// lead-in programs) and shift every later rapid by it.
|
||||
// The head never travels to a display perimeter's tail.
|
||||
pgm = DisplayPerimeterTail.WithoutTail(pgm);
|
||||
// Draw the approach to the first rapid destination, or to the program
|
||||
// origin when it starts cutting without a rapid. Only an initial rapid
|
||||
// (or call arrival) is already represented by this approach; a later
|
||||
// first rapid must remain visible. Walk starts at the program origin,
|
||||
// so an incremental initial rapid is not applied twice.
|
||||
var firstPierce = FirstPiercePoint(pgm, basePos);
|
||||
results.Add(new Segment(startPos, firstPierce));
|
||||
|
||||
var pos = basePos;
|
||||
Walk(pgm, basePos, ref pos, skipFirst: true, results);
|
||||
Walk(pgm, basePos, ref pos, skipFirst: StartsWithRapidOrCall(pgm), results);
|
||||
// The last rapid ends at a pierce, not necessarily the final tool position.
|
||||
return pos;
|
||||
}
|
||||
@@ -53,11 +55,23 @@ namespace OpenNest.CNC
|
||||
return FirstPiercePoint(call.Program, basePos + call.Offset);
|
||||
|
||||
if (pgm[i] is Motion motion)
|
||||
return motion.EndPoint + basePos;
|
||||
return motion is RapidMove ? motion.EndPoint + basePos : basePos;
|
||||
}
|
||||
return basePos;
|
||||
}
|
||||
|
||||
private static bool StartsWithRapidOrCall(Program pgm)
|
||||
{
|
||||
for (var i = 0; i < pgm.Length; i++)
|
||||
{
|
||||
if (pgm[i] is SubProgramCall { Program: not null })
|
||||
return true;
|
||||
if (pgm[i] is Motion motion)
|
||||
return motion is RapidMove;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void Walk(
|
||||
Program pgm,
|
||||
Vector basePos,
|
||||
@@ -83,7 +97,7 @@ namespace OpenNest.CNC
|
||||
results.Add(new Segment(pos, firstPierce));
|
||||
|
||||
var subPos = holeBase;
|
||||
Walk(program, holeBase, ref subPos, skipFirst: true, results);
|
||||
Walk(program, holeBase, ref subPos, skipFirst: StartsWithRapidOrCall(program), results);
|
||||
pos = subPos;
|
||||
}
|
||||
else if (code is Motion motion)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// The sheet edge a <see cref="ClampRail"/> clamp sits on, named relative to the plate
|
||||
/// origin so one rail resolves onto every sheet size and quadrant.
|
||||
/// </summary>
|
||||
public enum ClampEdge
|
||||
{
|
||||
/// <summary>The edge along X through the origin (operator side).</summary>
|
||||
OriginSide = 0,
|
||||
|
||||
/// <summary>The edge along X across the sheet's width from the origin.</summary>
|
||||
FarSide = 1,
|
||||
|
||||
/// <summary>The edge along Y through the origin.</summary>
|
||||
OriginEnd = 2,
|
||||
|
||||
/// <summary>The edge along Y across the sheet's length from the origin.</summary>
|
||||
FarEnd = 3,
|
||||
}
|
||||
|
||||
public static class ClampEdges
|
||||
{
|
||||
/// <summary>True for the two edges along X, whose clamp positions are X distances.</summary>
|
||||
public static bool RunsAlongX(this ClampEdge edge) =>
|
||||
edge is ClampEdge.OriginSide or ClampEdge.FarSide;
|
||||
|
||||
public static bool IsDefined(ClampEdge edge) =>
|
||||
edge is ClampEdge.OriginSide or ClampEdge.FarSide or ClampEdge.OriginEnd or ClampEdge.FarEnd;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One clamp of a <see cref="ClampRail"/>: its edge and its center measured from the plate
|
||||
/// origin along that edge (X for the sides, Y for the ends). Orders by edge, then center.
|
||||
/// </summary>
|
||||
public readonly record struct RailClamp(ClampEdge Edge, double Center) : IComparable<RailClamp>
|
||||
{
|
||||
public int CompareTo(RailClamp other)
|
||||
{
|
||||
var edge = Edge.CompareTo(other.Edge);
|
||||
return edge != 0 ? edge : Center.CompareTo(other.Center);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// The machine's fixed clamp rail: identical rectangular clamps, each on one sheet edge
|
||||
/// (<see cref="ClampEdge"/>) with its center measured from the plate origin along that
|
||||
/// edge. Plates always start at the origin, so the same rail resolves onto every sheet
|
||||
/// size without re-scaling. A clamp whose footprint lies wholly beyond a sheet is skipped;
|
||||
/// one that still reaches onto it is kept whole: the zone is an avoidance area, so its
|
||||
/// full footprint applies even where it hangs past the sheet.
|
||||
/// </summary>
|
||||
public sealed class ClampRail : IEquatable<ClampRail>
|
||||
{
|
||||
/// <summary>
|
||||
/// Planner default minutes to remove one clamp and put it back after cutting (D9),
|
||||
/// used when a caller does not supply its own value.
|
||||
/// </summary>
|
||||
public const double DefaultRemovalMinutes = 5.0;
|
||||
|
||||
/// <summary>A rail whose clamps all sit on the origin-side edge.</summary>
|
||||
public ClampRail(
|
||||
double width,
|
||||
double depth,
|
||||
IEnumerable<double> centers,
|
||||
double removalMinutes = DefaultRemovalMinutes
|
||||
)
|
||||
: this(
|
||||
width,
|
||||
depth,
|
||||
(centers ?? throw new ArgumentNullException(nameof(centers)))
|
||||
.Select(c => new RailClamp(ClampEdge.OriginSide, c)),
|
||||
removalMinutes
|
||||
)
|
||||
{ }
|
||||
|
||||
public ClampRail(
|
||||
double width,
|
||||
double depth,
|
||||
IEnumerable<RailClamp> clamps,
|
||||
double removalMinutes = DefaultRemovalMinutes
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(clamps);
|
||||
if (!double.IsFinite(width) || width <= 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(width),
|
||||
"Clamp width must be finite and positive."
|
||||
);
|
||||
if (!double.IsFinite(depth) || depth <= 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(depth),
|
||||
"Clamp depth must be finite and positive."
|
||||
);
|
||||
if (!double.IsFinite(removalMinutes) || removalMinutes < 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(removalMinutes),
|
||||
"Clamp removal time must be finite and zero or more."
|
||||
);
|
||||
|
||||
var sorted = new SortedSet<RailClamp>();
|
||||
foreach (var clamp in clamps)
|
||||
{
|
||||
if (!double.IsFinite(clamp.Center) || clamp.Center < 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
"centers",
|
||||
"Clamp centers must be finite and measured from the origin (zero or more)."
|
||||
);
|
||||
if (!ClampEdges.IsDefined(clamp.Edge))
|
||||
throw new ArgumentOutOfRangeException(nameof(clamps), "Unknown clamp edge.");
|
||||
sorted.Add(clamp);
|
||||
}
|
||||
|
||||
Width = width;
|
||||
Depth = depth;
|
||||
RemovalMinutes = removalMinutes;
|
||||
Clamps = sorted.ToArray();
|
||||
Centers = Clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Clamp extent along its edge.</summary>
|
||||
public double Width { get; }
|
||||
|
||||
/// <summary>Clamp intrusion into the sheet from its edge.</summary>
|
||||
public double Depth { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Minutes to remove one clamp and put it back after cutting (D9). Added to a sheet's
|
||||
/// cut-time estimate once per clamp <see cref="Plate.RemovedClamps"/> lists there.
|
||||
/// </summary>
|
||||
public double RemovalMinutes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Distinct clamps ordered by edge, then center. A clamp's position in this list
|
||||
/// gives its <c>clamp-N</c> id, so a rail with only origin-side clamps keeps the ids
|
||||
/// it had before clamps could sit on other edges.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RailClamp> Clamps { get; }
|
||||
|
||||
/// <summary>Centers of the origin-side clamps, ascending; see <see cref="Clamps"/> for all.</summary>
|
||||
public IReadOnlyList<double> Centers { get; }
|
||||
|
||||
/// <summary>A copy with the same size and removal time and these clamps.</summary>
|
||||
public ClampRail WithClamps(IEnumerable<RailClamp> clamps) =>
|
||||
new(Width, Depth, clamps, RemovalMinutes);
|
||||
|
||||
/// <summary>The <c>clamp-N</c> id of the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary>
|
||||
public static string IdOf(int index) => $"clamp-{index + 1}";
|
||||
|
||||
/// <summary>
|
||||
/// The footprint <paramref name="clamp"/> would have on a sheet, in plate coordinates
|
||||
/// (lower-left corner origin), or null when it lies wholly beyond the sheet. The clamp
|
||||
/// need not belong to this rail; it takes this rail's width and depth.
|
||||
/// </summary>
|
||||
public Box Footprint(RailClamp clamp, Size size, int quadrant) =>
|
||||
Footprint(clamp, Width, Depth, size, quadrant);
|
||||
|
||||
/// <summary>
|
||||
/// The footprint of a <paramref name="width"/> by <paramref name="depth"/> clamp at
|
||||
/// <paramref name="clamp"/>; see <see cref="Footprint(RailClamp, Size, int)"/>.
|
||||
/// </summary>
|
||||
public static Box Footprint(RailClamp clamp, double width, double depth, Size size, int quadrant)
|
||||
{
|
||||
var length = size.Length;
|
||||
var sheetWidth = size.Width;
|
||||
if (!(length > 0) || !(sheetWidth > 0) || !double.IsFinite(length) || !double.IsFinite(sheetWidth))
|
||||
return null;
|
||||
|
||||
var extendsLeft = quadrant is 2 or 3;
|
||||
var extendsDown = quadrant is 3 or 4;
|
||||
var half = width / 2;
|
||||
// Distances from the origin along the edge.
|
||||
var near = clamp.Center - half;
|
||||
var far = clamp.Center + half;
|
||||
|
||||
if (clamp.Edge.RunsAlongX())
|
||||
{
|
||||
if (near >= length)
|
||||
return null; // wholly beyond this sheet
|
||||
// The origin-side edge is y = 0 of the sheet, or its top when it extends to -Y.
|
||||
var atTop = (clamp.Edge == ClampEdge.FarSide) != extendsDown;
|
||||
// Plate coordinates are measured from the lower-left corner; a sheet extending
|
||||
// to -X from the origin has its origin edge at x = length.
|
||||
var x = extendsLeft ? length - far : near;
|
||||
return new Box(x, atTop ? sheetWidth - depth : 0, width, depth);
|
||||
}
|
||||
|
||||
if (near >= sheetWidth)
|
||||
return null;
|
||||
var atRight = (clamp.Edge == ClampEdge.FarEnd) != extendsLeft;
|
||||
var y = extendsDown ? sheetWidth - far : near;
|
||||
return new Box(atRight ? length - depth : 0, y, depth, width);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The zones this rail places on a sheet, in plate coordinates (the frame of
|
||||
/// <see cref="Plate.ClampZones"/>: origin at the sheet's lower-left corner). The
|
||||
/// quadrant locates the plate origin, and with it the origin-side edge, on the sheet.
|
||||
/// Zones are whole clamp footprints, never clipped, so one reaching past a sheet end
|
||||
/// (or deeper than a narrow sheet) extends beyond the sheet. Ids are <c>clamp-N</c> by
|
||||
/// rail position, so one physical clamp keeps its id on every sheet even when others
|
||||
/// are skipped.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ClampZone> ResolveFor(Size size, int quadrant)
|
||||
{
|
||||
var zones = new List<ClampZone>(Clamps.Count);
|
||||
for (var i = 0; i < Clamps.Count; i++)
|
||||
{
|
||||
var box = Footprint(Clamps[i], size, quadrant);
|
||||
if (box != null)
|
||||
zones.Add(new ClampZone(IdOf(i), box));
|
||||
}
|
||||
|
||||
return zones;
|
||||
}
|
||||
|
||||
/// <summary>True when the plate's zones are exactly (ids and bits) this rail's for its sheet.</summary>
|
||||
public bool Matches(Plate plate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
return SameZones(plate.ClampZones, ResolveFor(plate.Size, plate.Quadrant));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the plate's zones are exactly this rail's resolution for its sheet minus
|
||||
/// the clamps named in <paramref name="removedClamps"/> (D8 last-resort removal, C13).
|
||||
/// A null or empty set is the same check as <see cref="Matches(Plate)"/>.
|
||||
/// </summary>
|
||||
public bool Matches(Plate plate, IReadOnlyCollection<string> removedClamps)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
if (removedClamps == null || removedClamps.Count == 0)
|
||||
return Matches(plate);
|
||||
var expected = ResolveFor(plate.Size, plate.Quadrant)
|
||||
.Where(z => !removedClamps.Contains(z.Id))
|
||||
.ToList();
|
||||
return SameZones(plate.ClampZones, expected);
|
||||
}
|
||||
|
||||
internal static bool SameZones(IList<ClampZone> current, IReadOnlyList<ClampZone> expected)
|
||||
{
|
||||
if (current.Count != expected.Count)
|
||||
return false;
|
||||
for (var i = 0; i < current.Count; i++)
|
||||
{
|
||||
var a = current[i];
|
||||
var b = expected[i];
|
||||
if (
|
||||
!string.Equals(a.Id, b.Id, StringComparison.Ordinal)
|
||||
|| a.Bounds.X != b.Bounds.X
|
||||
|| a.Bounds.Y != b.Bounds.Y
|
||||
|| a.Bounds.Length != b.Bounds.Length
|
||||
|| a.Bounds.Width != b.Bounds.Width
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Equals(ClampRail other) =>
|
||||
other is not null
|
||||
&& Width.Equals(other.Width)
|
||||
&& Depth.Equals(other.Depth)
|
||||
&& RemovalMinutes.Equals(other.RemovalMinutes)
|
||||
&& Clamps.SequenceEqual(other.Clamps);
|
||||
|
||||
public override bool Equals(object obj) => Equals(obj as ClampRail);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Clamps.Count);
|
||||
foreach (var clamp in Clamps)
|
||||
hash = HashCode.Combine(hash, clamp);
|
||||
return hash;
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
$"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Clamps.Select(Describe))}]";
|
||||
|
||||
private static string Describe(RailClamp clamp) =>
|
||||
clamp.Edge == ClampEdge.OriginSide
|
||||
? clamp.Center.ToString("R")
|
||||
: $"{clamp.Edge} {clamp.Center:R}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// A fixed rectangular keep-out region in plate coordinates (the sheet's own frame:
|
||||
/// origin at the plate corner, X along the edge, Y depth from it). Machine edge clamps
|
||||
/// are the first users; a future "don't cut here" tag is another entry of this shape.
|
||||
/// Zones are exclusion and display only - never material, quantity, utilization,
|
||||
/// cut sequence or CNC output. Rectangles only: axis-aligned by construction.
|
||||
/// </summary>
|
||||
public sealed class ClampZone
|
||||
{
|
||||
public ClampZone(string id, Box bounds)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentNullException.ThrowIfNull(bounds);
|
||||
if (
|
||||
!double.IsFinite(bounds.X)
|
||||
|| !double.IsFinite(bounds.Y)
|
||||
|| !double.IsFinite(bounds.Length)
|
||||
|| !double.IsFinite(bounds.Width)
|
||||
)
|
||||
throw new ArgumentException(
|
||||
$"Clamp zone '{id}' bounds must have finite coordinates and size.",
|
||||
nameof(bounds)
|
||||
);
|
||||
if (bounds.Length <= 0 || bounds.Width <= 0)
|
||||
throw new ArgumentException(
|
||||
$"Clamp zone '{id}' must have a positive length (X) and width (Y).",
|
||||
nameof(bounds)
|
||||
);
|
||||
|
||||
Id = id;
|
||||
// Detached copy: the zone owns its rectangle, callers may keep mutating theirs.
|
||||
Bounds = new Box(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
||||
}
|
||||
|
||||
/// <summary>Stable identifier used by persistence and diagnostics.</summary>
|
||||
public string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The rectangle in plate coordinates: Length is the extent along X, Width the
|
||||
/// extent along Y (depth from the y = 0 operator-side edge for machine clamps).
|
||||
/// </summary>
|
||||
public Box Bounds { get; }
|
||||
|
||||
public override string ToString() =>
|
||||
$"ClampZone '{Id}' [{Bounds.Left:R}, {Bounds.Bottom:R}] x {Bounds.Length:R} x {Bounds.Width:R}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a plate's <see cref="Plate.ClampZones"/>, stored in the sheet's lower-left frame
|
||||
/// (<see cref="ClampRail.ResolveFor"/>), into the signed placement frame of its parts,
|
||||
/// cut-offs and motions. The translation matches the sheet itself and the engine stock:
|
||||
/// -Length on X in quadrants 2 and 3, -Width on Y in quadrants 3 and 4. Whole footprints are
|
||||
/// kept, never clipped. Every plate uses the sheet frame, including plates the engine builds
|
||||
/// from a <c>NestPlateStock</c>, so this is the only translation any consumer applies.
|
||||
/// </summary>
|
||||
public static class ClampZoneFrame
|
||||
{
|
||||
/// <summary>
|
||||
/// Detached placement-frame copies in the plate's zone order; empty when there are none.
|
||||
/// Throws <see cref="ArgumentException"/> for a missing or malformed zone: zone bounds stay
|
||||
/// publicly mutable, so construction-time validation is not proof of current geometry.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ClampZone> ToPlacement(Plate plate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
var zones = plate.ClampZones;
|
||||
if (zones == null || zones.Count == 0)
|
||||
return Array.Empty<ClampZone>();
|
||||
if (plate.Quadrant is < 1 or > 4)
|
||||
throw new ArgumentException("Quadrant must be 1 through 4.", nameof(plate));
|
||||
var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0;
|
||||
var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0;
|
||||
if (!double.IsFinite(left) || !double.IsFinite(bottom))
|
||||
throw new ArgumentException("Sheet size must be finite.", nameof(plate));
|
||||
|
||||
var mapped = new ClampZone[zones.Count];
|
||||
for (var i = 0; i < mapped.Length; i++)
|
||||
{
|
||||
var zone = zones[i];
|
||||
if (zone?.Bounds == null)
|
||||
throw new ArgumentException($"Clamp zone {i + 1} is missing.", nameof(plate));
|
||||
var box = zone.Bounds;
|
||||
var x = box.X + left;
|
||||
var y = box.Y + bottom;
|
||||
if (!(box.Length > 0) || !(box.Width > 0) || !double.IsFinite(box.Length)
|
||||
|| !double.IsFinite(box.Width) || !double.IsFinite(box.X) || !double.IsFinite(box.Y)
|
||||
|| !double.IsFinite(x) || !double.IsFinite(y)
|
||||
|| !double.IsFinite(x + box.Length) || !double.IsFinite(y + box.Width))
|
||||
throw new ArgumentException(
|
||||
$"Clamp zone '{zone.Id}' must have finite bounds and a positive size.", nameof(plate));
|
||||
mapped[i] = new ClampZone(zone.Id, new Box(x, y, box.Length, box.Width));
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
@@ -9,7 +9,24 @@ namespace OpenNest.Converters
|
||||
{
|
||||
public static Program ToProgram(IList<Entity> geometry)
|
||||
{
|
||||
var shapes = ShapeBuilder.GetShapes(geometry);
|
||||
// Break cuts keep their order and direction (their scrap side): they are emitted as
|
||||
// given, one program run per continuous chain, before the contours.
|
||||
var breakCuts = new List<Shape>();
|
||||
var contours = new List<Entity>();
|
||||
foreach (var entity in geometry)
|
||||
{
|
||||
if (entity.Layer != SpecialLayers.Break)
|
||||
{
|
||||
contours.Add(entity);
|
||||
continue;
|
||||
}
|
||||
var last = breakCuts.Count == 0 ? null : breakCuts[^1].Entities[^1];
|
||||
if (last == null || EndOf(last).DistanceTo(StartOf(entity)) > Tolerance.Epsilon)
|
||||
breakCuts.Add(new Shape());
|
||||
breakCuts[^1].Entities.Add(entity);
|
||||
}
|
||||
|
||||
var shapes = ShapeBuilder.GetShapes(contours);
|
||||
|
||||
if (shapes.Count == 0)
|
||||
return null;
|
||||
@@ -34,6 +51,9 @@ namespace OpenNest.Converters
|
||||
|
||||
var pgm = new Program();
|
||||
|
||||
foreach (var cut in breakCuts)
|
||||
pgm.Merge(ToProgram(cut));
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var subpgm = ToProgram(shape);
|
||||
@@ -46,6 +66,10 @@ namespace OpenNest.Converters
|
||||
return pgm;
|
||||
}
|
||||
|
||||
private static Vector StartOf(Entity entity) => entity is Arc arc ? arc.StartPoint() : ((Line)entity).StartPoint;
|
||||
|
||||
private static Vector EndOf(Entity entity) => entity is Arc arc ? arc.EndPoint() : ((Line)entity).EndPoint;
|
||||
|
||||
public static Program ToProgram(Shape shape)
|
||||
{
|
||||
var pgm = new Program();
|
||||
@@ -153,6 +177,15 @@ namespace OpenNest.Converters
|
||||
)
|
||||
return LayerType.Scribe;
|
||||
|
||||
if (string.Equals(name, SpecialLayers.Break.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.Break;
|
||||
|
||||
if (string.Equals(name, SpecialLayers.Tab.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.Tab;
|
||||
|
||||
if (string.Equals(name, SpecialLayers.DisplayPerimeter.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.DisplayPerimeter;
|
||||
|
||||
return LayerType.Cut;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
@@ -207,6 +207,15 @@ namespace OpenNest.Converters
|
||||
case LayerType.Scribe:
|
||||
return SpecialLayers.Scribe;
|
||||
|
||||
case LayerType.Break:
|
||||
return SpecialLayers.Break;
|
||||
|
||||
case LayerType.Tab:
|
||||
return SpecialLayers.Tab;
|
||||
|
||||
case LayerType.DisplayPerimeter:
|
||||
return SpecialLayers.DisplayPerimeter;
|
||||
|
||||
default:
|
||||
return new Layer(layer.ToString());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Puts etch marks back on drawings that lost them. Nests converted from PEP kept etch marks
|
||||
/// only in each placed part's saved cutting program; the drawing itself had none, so removing
|
||||
/// lead-ins (which rebuilds the part from its drawing) silently removed the marks too.
|
||||
/// </summary>
|
||||
public static class DrawingMarkRecovery
|
||||
{
|
||||
private const double MatchTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// For each drawing without etch marks whose saved placed programs all carry the same marks,
|
||||
/// adds those marks to the drawing in its own frame and refreshes the drawing's clean placed
|
||||
/// parts. Saved programs are never changed. Drawings whose saved programs disagree, or that
|
||||
/// already have marks, are left alone. Returns the number of drawings that were given marks.
|
||||
/// </summary>
|
||||
public static int Apply(Nest nest)
|
||||
{
|
||||
if (nest == null)
|
||||
return 0;
|
||||
|
||||
var parts = nest.Plates.SelectMany(plate => plate.Parts).ToList();
|
||||
var recovered = 0;
|
||||
foreach (var group in parts.GroupBy(part => part.BaseDrawing, ReferenceEqualityComparer.Instance))
|
||||
{
|
||||
var drawing = (Drawing)group.Key;
|
||||
if (Apply(drawing, group.ToList()))
|
||||
recovered++;
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
/// <summary>Recovers marks for one drawing from its placed parts; true when marks were added.</summary>
|
||||
public static bool Apply(Drawing drawing, IReadOnlyList<Part> placedParts)
|
||||
{
|
||||
if (drawing?.Program == null || drawing.IsCutOff || placedParts == null)
|
||||
return false;
|
||||
// An incremental call's end position is not tracked by Program.EndPoint, so only
|
||||
// flat drawings (which is what conversion produces) can be extended safely.
|
||||
if (HasMarks(drawing.Program) || drawing.Program.Codes.Any(code => code is SubProgramCall))
|
||||
return false;
|
||||
|
||||
var saved = placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing) && part.HasManualLeadIns)
|
||||
.ToList();
|
||||
if (saved.Count == 0)
|
||||
return false;
|
||||
|
||||
var marks = MarksInDrawingFrame(saved[0]);
|
||||
if (marks.Count == 0 || saved.Skip(1).Any(part => !SameMarks(marks, MarksInDrawingFrame(part))))
|
||||
return false;
|
||||
|
||||
AppendMarks(drawing.Program, marks);
|
||||
drawing.UpdateArea();
|
||||
drawing.RecomputeCanonicalAngle();
|
||||
|
||||
foreach (var part in placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing)
|
||||
&& !part.HasManualLeadIns))
|
||||
part.Update();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasMarks(Program program) =>
|
||||
program.Codes.Any(code => code is LinearMove { Layer: LayerType.Scribe } or ArcMove { Layer: LayerType.Scribe }
|
||||
|| code is SubProgramCall { Program: not null } call && HasMarks(call.Program));
|
||||
|
||||
/// <summary>
|
||||
/// The part's marks as they would sit in its drawing: <see cref="Part.RemoveLeadIns"/>
|
||||
/// rebuilds the part as the drawing turned by <see cref="Part.Rotation"/>, so undo that turn.
|
||||
/// </summary>
|
||||
private static List<Entity> MarksInDrawingFrame(Part part)
|
||||
{
|
||||
var marks = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(entity => entity.Layer == SpecialLayers.Scribe)
|
||||
.ToList();
|
||||
if (!Tolerance.IsEqualTo(part.Rotation, 0))
|
||||
foreach (var mark in marks)
|
||||
mark.Rotate(-part.Rotation);
|
||||
return marks;
|
||||
}
|
||||
|
||||
private static bool SameMarks(List<Entity> expected, List<Entity> actual)
|
||||
{
|
||||
if (expected.Count != actual.Count)
|
||||
return false;
|
||||
var unmatched = actual.ToList();
|
||||
foreach (var mark in expected)
|
||||
{
|
||||
var index = unmatched.FindIndex(other => SameMark(mark, other));
|
||||
if (index < 0)
|
||||
return false;
|
||||
unmatched.RemoveAt(index);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Direction does not matter for an etch: both marks must lie on each other.
|
||||
private static bool SameMark(Entity a, Entity b) =>
|
||||
a.Type == b.Type && LiesOn(Samples(a), b) && LiesOn(Samples(b), a);
|
||||
|
||||
private static bool LiesOn(List<Vector> points, Entity entity) =>
|
||||
points.Count > 0 && points.All(point => entity.ClosestPointTo(point).DistanceTo(point) <= MatchTolerance);
|
||||
|
||||
private static List<Vector> Samples(Entity entity) => entity switch
|
||||
{
|
||||
Line line => new List<Vector>
|
||||
{
|
||||
line.StartPoint,
|
||||
line.EndPoint,
|
||||
new((line.StartPoint.X + line.EndPoint.X) / 2, (line.StartPoint.Y + line.EndPoint.Y) / 2),
|
||||
},
|
||||
Arc arc => new List<Vector> { arc.StartPoint(), arc.EndPoint(), arc.MidPoint() },
|
||||
Circle circle => new List<Vector>
|
||||
{
|
||||
new(circle.Center.X + circle.Radius, circle.Center.Y),
|
||||
new(circle.Center.X - circle.Radius, circle.Center.Y),
|
||||
new(circle.Center.X, circle.Center.Y + circle.Radius),
|
||||
new(circle.Center.X, circle.Center.Y - circle.Radius),
|
||||
},
|
||||
_ => new List<Vector>(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Appends the marks after the drawing's existing motion, so the existing codes and the
|
||||
/// drawing's first-rapid origin stay exactly as they were.
|
||||
/// </summary>
|
||||
private static void AppendMarks(Program program, List<Entity> marks)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.AddRange(marks);
|
||||
var markProgram = ConvertGeometry.ToProgram(shape);
|
||||
|
||||
// Start from the drawing's end point, with an explicit rapid onto the first mark.
|
||||
var start = EndOf(program);
|
||||
var first = markProgram.Codes.OfType<Motion>().First();
|
||||
var positioned = new Program(Mode.Absolute);
|
||||
positioned.Codes.Add(new RapidMove(start));
|
||||
if (first is not RapidMove)
|
||||
positioned.Codes.Add(new RapidMove(StartOf(marks[0])));
|
||||
positioned.Codes.AddRange(markProgram.Codes);
|
||||
|
||||
if (program.Mode == Mode.Incremental)
|
||||
positioned.Mode = Mode.Incremental;
|
||||
// Drop the move onto the drawing's end point; it is where the drawing already stops.
|
||||
program.Codes.AddRange(positioned.Codes.Skip(1));
|
||||
}
|
||||
|
||||
// Where a flat program leaves the head (callers exclude sub-program calls).
|
||||
private static Vector EndOf(Program program)
|
||||
{
|
||||
var position = Vector.Zero;
|
||||
foreach (var motion in program.Codes.OfType<Motion>())
|
||||
position = program.Mode == Mode.Incremental ? position + motion.EndPoint : motion.EndPoint;
|
||||
return position;
|
||||
}
|
||||
|
||||
private static Vector StartOf(Entity entity) => entity switch
|
||||
{
|
||||
Line line => line.StartPoint,
|
||||
Arc arc => arc.StartPoint(),
|
||||
Circle circle => new Vector(circle.Center.X + circle.Radius, circle.Center.Y),
|
||||
_ => Vector.Zero,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Converters
|
||||
{
|
||||
/// <summary>What a drawing does with a source entity.</summary>
|
||||
public enum EntityRole
|
||||
{
|
||||
/// <summary>Cut through the material.</summary>
|
||||
Cut,
|
||||
|
||||
/// <summary>Marked on the surface (a Scribe pass): never material.</summary>
|
||||
Etch,
|
||||
|
||||
/// <summary>
|
||||
/// Shown with the part for reference only. It is kept out of the program, so it is never
|
||||
/// cut, posted, nested against or verified.
|
||||
/// </summary>
|
||||
Display,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies per-entity role overrides. An entity without an override keeps the role its layer
|
||||
/// implies: mark layers (ETCH, SCRIBE, ENGRAVE) etch, everything else cuts.
|
||||
/// </summary>
|
||||
public static class EntityRoles
|
||||
{
|
||||
public static EntityRole Default(Entity entity) =>
|
||||
ScribeLayerRepair.IsMarkLayer(entity.Layer) ? EntityRole.Etch : EntityRole.Cut;
|
||||
|
||||
public static EntityRole Of(Entity entity, IReadOnlyDictionary<Guid, EntityRole> overrides) =>
|
||||
overrides != null && overrides.TryGetValue(entity.Id, out var role) ? role : Default(entity);
|
||||
|
||||
/// <summary>
|
||||
/// The entities a program is built from: display-only entities are left out and entities
|
||||
/// whose override differs from their layer's role are replaced by copies, with the same
|
||||
/// ID, on a layer the converters read as that role. Inputs are never mutated.
|
||||
/// </summary>
|
||||
public static List<Entity> ForProgram(IEnumerable<Entity> entities, IReadOnlyDictionary<Guid, EntityRole> overrides)
|
||||
{
|
||||
var result = new List<Entity>();
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
if (overrides == null || !overrides.TryGetValue(entity.Id, out var role) || role == Default(entity))
|
||||
{
|
||||
result.Add(entity);
|
||||
continue;
|
||||
}
|
||||
if (role == EntityRole.Display)
|
||||
continue;
|
||||
|
||||
var copy = entity.Clone();
|
||||
copy.Id = entity.Id;
|
||||
copy.Layer = new Layer(role == EntityRole.Etch ? SpecialLayers.Scribe.Name : SpecialLayers.Cut.Name)
|
||||
{
|
||||
Color = entity.Layer?.Color ?? SpecialLayers.Cut.Color,
|
||||
IsVisible = true,
|
||||
};
|
||||
result.Add(copy);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A drawing's unsuppressed display-only entities as copies in its program frame (source
|
||||
/// coordinates shifted by -<c>Source.Offset</c>, before any program rotation).
|
||||
/// </summary>
|
||||
public static List<Entity> DisplayGeometry(Drawing drawing)
|
||||
{
|
||||
var result = new List<Entity>();
|
||||
if (drawing?.SourceEntities == null || drawing.EntityRoles == null || drawing.EntityRoles.Count == 0)
|
||||
return result;
|
||||
|
||||
var offset = drawing.Source?.Offset ?? Vector.Zero;
|
||||
foreach (var entity in drawing.SourceEntities)
|
||||
{
|
||||
if (drawing.SuppressedEntityIds.Contains(entity.Id) || Of(entity, drawing.EntityRoles) != EntityRole.Display)
|
||||
continue;
|
||||
var copy = entity.Clone();
|
||||
copy.Id = entity.Id;
|
||||
copy.Offset(-offset.X, -offset.Y);
|
||||
result.Add(copy);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
+321
-8
@@ -21,6 +21,23 @@ namespace OpenNest
|
||||
public double? EndLimit { get; set; }
|
||||
public Drawing Drawing { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// How far past the nominal far sheet edge (the edge away from the plate's origin) the
|
||||
/// segment reaching that edge cuts with the head height locked; zero for none. Stock can
|
||||
/// arrive wider than nominal, so only the origin-side edge is certain: the overcut cuts the
|
||||
/// oversize off. It replaces <see cref="CutOffSettings.Overtravel"/> at the far edge and
|
||||
/// applies only when the line runs to that edge (no limit on that side).
|
||||
/// </summary>
|
||||
public double HeadLockOvercut { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the segment reaching the far edge is cut from it toward the origin: it starts at
|
||||
/// the nominal far edge, overcuts outward with the head locked, rapids back with the head
|
||||
/// still locked, unlocks and cuts toward the origin. Ignored without a head-locked overcut,
|
||||
/// since nothing else may start a cut at an edge whose position is unknown.
|
||||
/// </summary>
|
||||
public bool FarEdgeFirst { get; set; }
|
||||
|
||||
public CutOff(Vector position, CutOffAxis axis)
|
||||
{
|
||||
Position = position;
|
||||
@@ -35,10 +52,201 @@ namespace OpenNest
|
||||
)
|
||||
{
|
||||
var segments = ComputeSegments(plate, settings, cache);
|
||||
var program = BuildProgram(segments, settings);
|
||||
var program = BuildProgram(segments, settings, LockedFarEdge(plate));
|
||||
Drawing.Program = program;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The regenerated program split into its cutting segments, one rapid-and-cut program
|
||||
/// each in program order, so the plate can sequence every segment on its own. A rapid with
|
||||
/// the head height locked returns along the overcut and stays in its segment.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Program> SegmentPrograms()
|
||||
{
|
||||
var program = Drawing.Program;
|
||||
var result = new List<Program>();
|
||||
if (program == null)
|
||||
return result;
|
||||
Program current = null;
|
||||
var locked = false;
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
if (code is RapidMove && !locked || current == null)
|
||||
{
|
||||
current = new Program(program.Mode);
|
||||
result.Add(current);
|
||||
}
|
||||
if (HeadHeightLock.IsLock(code))
|
||||
locked = true;
|
||||
else if (HeadHeightLock.IsUnlock(code))
|
||||
locked = false;
|
||||
current.Codes.Add(code);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the far edge, along this axis on <paramref name="plate"/>, is the high
|
||||
/// coordinate: the sheet runs from the origin toward positive coordinates on that axis.
|
||||
/// </summary>
|
||||
internal static bool FarEdgeIsHigh(CutOffAxis axis, int quadrant) =>
|
||||
axis == CutOffAxis.Vertical ? quadrant is 1 or 2 : quadrant is 1 or 4;
|
||||
|
||||
/// <summary>
|
||||
/// The nominal far edge this cut-off overcuts with the head locked, along its axis, or null
|
||||
/// when it has no head-locked overcut or a limit stops it short of that edge.
|
||||
/// </summary>
|
||||
private (double Edge, bool High)? LockedFarEdge(Plate plate)
|
||||
{
|
||||
if (!(HeadLockOvercut > 0) || !double.IsFinite(HeadLockOvercut))
|
||||
return null;
|
||||
var bounds = plate.BoundingBox(includeParts: false);
|
||||
var high = FarEdgeIsHigh(Axis, plate.Quadrant);
|
||||
if ((high ? EndLimit : StartLimit) != null)
|
||||
return null;
|
||||
var vertical = Axis == CutOffAxis.Vertical;
|
||||
return (high ? (vertical ? bounds.Top : bounds.Right) : (vertical ? bounds.Bottom : bounds.Left), high);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same segment cut the other way at the far edge: a head-locked segment cut toward the
|
||||
/// far edge becomes one started there, and back; null when <paramref name="segment"/> is not
|
||||
/// one of the two forms <see cref="Regenerate"/> builds for the segment reaching that edge.
|
||||
/// </summary>
|
||||
public static Program ReverseAtFarEdge(Program segment)
|
||||
{
|
||||
if (segment?.Codes == null || segment.Mode != Mode.Absolute)
|
||||
return null;
|
||||
var codes = segment.Codes;
|
||||
var result = new Program(segment.Mode);
|
||||
// Toward the far edge: rapid near, cut to the edge, lock, overcut, unlock.
|
||||
if (codes.Count == 5 && codes[0] is RapidMove { Suppressed: false } near
|
||||
&& codes[1] is LinearMove { Suppressed: false, Layer: LayerType.Cut } toEdge
|
||||
&& HeadHeightLock.IsLock(codes[2])
|
||||
&& codes[3] is LinearMove { Suppressed: false, Layer: LayerType.Cut } overcut
|
||||
&& HeadHeightLock.IsUnlock(codes[4]))
|
||||
{
|
||||
result.Codes.Add(new RapidMove(toEdge.EndPoint));
|
||||
result.Codes.Add(new Comment(HeadHeightLock.Lock));
|
||||
result.Codes.Add(new LinearMove(overcut.EndPoint));
|
||||
result.Codes.Add(new RapidMove(toEdge.EndPoint));
|
||||
result.Codes.Add(new Comment(HeadHeightLock.Unlock));
|
||||
result.Codes.Add(new LinearMove(near.EndPoint));
|
||||
return result;
|
||||
}
|
||||
// From the far edge: rapid to the edge, lock, overcut, rapid back, unlock, cut away.
|
||||
if (codes.Count == 6 && codes[0] is RapidMove { Suppressed: false } edge
|
||||
&& HeadHeightLock.IsLock(codes[1])
|
||||
&& codes[2] is LinearMove { Suppressed: false, Layer: LayerType.Cut } outward
|
||||
&& codes[3] is RapidMove { Suppressed: false } back && back.EndPoint == edge.EndPoint
|
||||
&& HeadHeightLock.IsUnlock(codes[4])
|
||||
&& codes[5] is LinearMove { Suppressed: false, Layer: LayerType.Cut } away)
|
||||
{
|
||||
result.Codes.Add(new RapidMove(away.EndPoint));
|
||||
result.Codes.Add(new LinearMove(edge.EndPoint));
|
||||
result.Codes.Add(new Comment(HeadHeightLock.Lock));
|
||||
result.Codes.Add(new LinearMove(outward.EndPoint));
|
||||
result.Codes.Add(new Comment(HeadHeightLock.Unlock));
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs Plan Cutting's choice for the far-edge segment: <paramref name="segment"/> (one
|
||||
/// of this cut-off's placed parts) takes <paramref name="program"/>, the same segment cut the
|
||||
/// other way, and <see cref="FarEdgeFirst"/> and the whole-line program follow it, so
|
||||
/// regeneration keeps the direction. The caller has checked that the program is that flip.
|
||||
/// </summary>
|
||||
internal void InstallFarEdgeSegment(Part segment, Program program)
|
||||
{
|
||||
var codes = new List<ICode>();
|
||||
foreach (var part in SegmentPrograms())
|
||||
codes.AddRange((ReverseAtFarEdge(part) != null ? program : part).Codes);
|
||||
var whole = new Program(Drawing.Program?.Mode ?? Mode.Absolute);
|
||||
whole.Codes.AddRange(codes);
|
||||
segment.ReplaceCutOffProgram(program);
|
||||
FarEdgeFirst = StartsAtFarEdge(program);
|
||||
Drawing.Program = whole;
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="segment"/> is the far-edge segment cut from the far edge.</summary>
|
||||
public static bool StartsAtFarEdge(Program segment) =>
|
||||
segment?.Codes is { Count: 6 } codes && codes[0] is RapidMove && HeadHeightLock.IsLock(codes[1]);
|
||||
|
||||
/// <summary>Measures the same trimmed, filtered segments as regeneration without building a program.</summary>
|
||||
internal (double Length, int SegmentCount) MeasureSegments(
|
||||
Plate plate,
|
||||
CutOffSettings settings,
|
||||
Dictionary<Part, Entity> cache = null
|
||||
)
|
||||
{
|
||||
var segments = ComputeSegments(plate, settings, cache);
|
||||
return (segments.Sum(s => s.End - s.Start), segments.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="part"/>, the placed part of <paramref name="definition"/>, cuts
|
||||
/// one continuous straight line on the definition's axis across the whole sheet, so it
|
||||
/// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a
|
||||
/// line on the sheet edge, a stale or unrecognised program) does not separate the sheet.
|
||||
/// </summary>
|
||||
internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet)
|
||||
{
|
||||
if (part?.Program == null || definition == null || sheet == null
|
||||
|| part.Program.Mode != Mode.Absolute)
|
||||
return false;
|
||||
var vertical = definition.Axis == CutOffAxis.Vertical;
|
||||
var line = vertical ? definition.Position.X : definition.Position.Y;
|
||||
var acrossMin = vertical ? sheet.Left : sheet.Bottom;
|
||||
var acrossMax = vertical ? sheet.Right : sheet.Top;
|
||||
var alongMin = vertical ? sheet.Bottom : sheet.Left;
|
||||
var alongMax = vertical ? sheet.Top : sheet.Right;
|
||||
if (!(line > acrossMin + Math.Tolerance.Epsilon && line < acrossMax - Math.Tolerance.Epsilon))
|
||||
return false;
|
||||
|
||||
Vector? position = null;
|
||||
double? low = null, high = null;
|
||||
var cutting = false;
|
||||
var locked = false;
|
||||
foreach (var code in part.Program.Codes)
|
||||
{
|
||||
if (HeadHeightLock.IsLock(code))
|
||||
locked = true;
|
||||
else if (HeadHeightLock.IsUnlock(code))
|
||||
locked = false;
|
||||
switch (code)
|
||||
{
|
||||
case RapidMove rapid:
|
||||
// A second pierce leaves an uncut gap; a head-locked rapid returns along
|
||||
// the overcut to where the cut toward the origin begins.
|
||||
if (cutting && !locked)
|
||||
return false;
|
||||
position = rapid.EndPoint + part.Location;
|
||||
break;
|
||||
case LinearMove move when position != null:
|
||||
var end = move.EndPoint + part.Location;
|
||||
if (!OnLine(position.Value) || !OnLine(end))
|
||||
return false;
|
||||
foreach (var value in new[] { Along(position.Value), Along(end) })
|
||||
{
|
||||
low = low == null ? value : System.Math.Min(low.Value, value);
|
||||
high = high == null ? value : System.Math.Max(high.Value, value);
|
||||
}
|
||||
position = end;
|
||||
cutting = true;
|
||||
break;
|
||||
case Motion:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return cutting && low <= alongMin + Math.Tolerance.Epsilon && high >= alongMax - Math.Tolerance.Epsilon;
|
||||
|
||||
bool OnLine(Vector point) =>
|
||||
System.Math.Abs((vertical ? point.X : point.Y) - line) <= Math.Tolerance.Epsilon;
|
||||
double Along(Vector point) => vertical ? point.Y : point.X;
|
||||
}
|
||||
|
||||
private string GetName()
|
||||
{
|
||||
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
|
||||
@@ -53,6 +261,8 @@ namespace OpenNest
|
||||
)
|
||||
{
|
||||
var bounds = plate.BoundingBox(includeParts: false);
|
||||
// A head-locked overcut replaces the overtravel at the high edge when that is the far edge.
|
||||
var overtravel = LockedFarEdge(plate) is { High: true } ? 0 : settings.Overtravel;
|
||||
|
||||
double lineStart,
|
||||
lineEnd,
|
||||
@@ -62,13 +272,13 @@ namespace OpenNest
|
||||
{
|
||||
cutPosition = Position.X;
|
||||
lineStart = StartLimit ?? bounds.Y;
|
||||
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + settings.Overtravel);
|
||||
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + overtravel);
|
||||
}
|
||||
else
|
||||
{
|
||||
cutPosition = Position.Y;
|
||||
lineStart = StartLimit ?? bounds.X;
|
||||
lineEnd = EndLimit ?? (bounds.X + bounds.Length + settings.Overtravel);
|
||||
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
|
||||
}
|
||||
|
||||
var exclusions = new List<(double Start, double End)>();
|
||||
@@ -78,8 +288,21 @@ namespace OpenNest
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
Entity perimeter = null;
|
||||
cache?.TryGetValue(part, out perimeter);
|
||||
Entity perimeter;
|
||||
if (cache != null)
|
||||
{
|
||||
cache.TryGetValue(part, out perimeter);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No pre-built cache: this is a direct/uncached caller (e.g. an
|
||||
// external API consumer calling Regenerate without going through
|
||||
// Plate.RegenerateCutOffs or the UI handlers, which always pass a
|
||||
// cache). Build the same converted-material envelope on the fly so
|
||||
// this path gets the same safety as the cached one, instead of
|
||||
// silently trusting the part's bare BoundingBox.
|
||||
perimeter = Plate.BuildPartPerimeter(part);
|
||||
}
|
||||
var partExclusions = GetPartExclusions(
|
||||
part,
|
||||
perimeter,
|
||||
@@ -134,8 +357,22 @@ namespace OpenNest
|
||||
double clearance
|
||||
)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
var cached = part.BoundingBox;
|
||||
var roundoff = GetBoundsRoundoff(part);
|
||||
// Keep the farther of the cached and converted perimeter extents. The
|
||||
// perimeter's fine intersection/offset can miss a near-tangent sliver;
|
||||
// only a line in that narrow outer band needs the conservative box.
|
||||
var bb = cached;
|
||||
if (perimeter != null)
|
||||
{
|
||||
var converted = perimeter.BoundingBox;
|
||||
var left = System.Math.Min(cached.Left, converted.Left);
|
||||
var right = System.Math.Max(cached.Right, converted.Right);
|
||||
var bottom = System.Math.Min(cached.Bottom, converted.Bottom);
|
||||
var top = System.Math.Max(cached.Top, converted.Top);
|
||||
bb = new Box(left, bottom, right - left, top - bottom);
|
||||
}
|
||||
var (cachedMin, cachedMax) = AxisBounds(cached, clearance);
|
||||
var (partMin, partMax) = AxisBounds(bb, clearance);
|
||||
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
|
||||
// Match the planner's representational allowance in BOTH pruning and
|
||||
@@ -150,6 +387,8 @@ namespace OpenNest
|
||||
|
||||
if (perimeter != null)
|
||||
{
|
||||
if (cutPosition <= cachedMin + roundoff || cutPosition >= cachedMax - roundoff)
|
||||
return new List<(double Start, double End)> { (partStart, partEnd) };
|
||||
var perimeterExclusions = IntersectPerimeter(
|
||||
perimeter,
|
||||
cutPosition,
|
||||
@@ -180,7 +419,16 @@ namespace OpenNest
|
||||
magnitude = System.Math.Max(magnitude,
|
||||
System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y)));
|
||||
var step = System.Math.BitIncrement(magnitude) - magnitude;
|
||||
return double.IsFinite(step) ? System.Math.Min(8 * step, Math.Tolerance.Epsilon / 4) : 0;
|
||||
// Imported, nearly tangent arcs can amplify endpoint rounding when
|
||||
// ConvertProgram fits their centers to a chord. Keep that allowance
|
||||
// tiny and apply it to both pruning and fallback exclusions above;
|
||||
// larger refits still require an explicit geometry repair. The allowance is
|
||||
// absolute: rotated small arcs on ordinary sheets refit by ~5e-11, which a
|
||||
// magnitude-scaled step count would reject.
|
||||
var arcFitRoundoff = Math.Tolerance.Epsilon / 100000;
|
||||
return double.IsFinite(step)
|
||||
? System.Math.Min(System.Math.Max(8 * step, arcFitRoundoff), Math.Tolerance.Epsilon / 4)
|
||||
: 0;
|
||||
}
|
||||
|
||||
private List<(double Start, double End)> IntersectPerimeter(
|
||||
@@ -192,6 +440,9 @@ namespace OpenNest
|
||||
)
|
||||
{
|
||||
var offset = OffsetOutward(perimeter, clearance);
|
||||
if (offset != null && offset.All(target => target is Polygon))
|
||||
return SlicePolygons(offset.Cast<Polygon>(), cutPosition);
|
||||
|
||||
var usedOffset = offset != null;
|
||||
var targets = offset ?? new List<Entity> { perimeter };
|
||||
var cutLine = new Line(
|
||||
@@ -234,6 +485,51 @@ namespace OpenNest
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exact cross-section of the clearance outline on the cut line. A vertex on the line
|
||||
/// counts as below it (half-open edges), so a line through a vertex counts one
|
||||
/// crossing when the outline passes through and zero or two when it only touches. Edges
|
||||
/// lying on the line are excluded whole. Unlike <see cref="Entity.Intersects"/>, which
|
||||
/// reports a shared vertex once per incident edge, this never needs the bounding-box
|
||||
/// fallback, so a lattice line through a vertex is trimmed like any other line.
|
||||
/// </summary>
|
||||
private List<(double Start, double End)> SlicePolygons(IEnumerable<Polygon> polygons, double cutPosition)
|
||||
{
|
||||
var crossings = new List<double>();
|
||||
var result = new List<(double Start, double End)>();
|
||||
foreach (var polygon in polygons)
|
||||
{
|
||||
var vertices = polygon.Vertices;
|
||||
if (vertices.Count < 3)
|
||||
return null;
|
||||
var closed = vertices[0].X == vertices[^1].X && vertices[0].Y == vertices[^1].Y;
|
||||
var edges = closed ? vertices.Count - 1 : vertices.Count;
|
||||
for (var i = 0; i < edges; i++)
|
||||
{
|
||||
var from = vertices[i];
|
||||
var to = vertices[(i + 1) % vertices.Count];
|
||||
var (acrossFrom, alongFrom) = Split(from);
|
||||
var (acrossTo, alongTo) = Split(to);
|
||||
if (acrossFrom == cutPosition && acrossTo == cutPosition)
|
||||
result.Add((System.Math.Min(alongFrom, alongTo), System.Math.Max(alongFrom, alongTo)));
|
||||
else if (acrossFrom > cutPosition != acrossTo > cutPosition)
|
||||
crossings.Add(acrossFrom == cutPosition ? alongFrom
|
||||
: acrossTo == cutPosition ? alongTo
|
||||
: alongFrom + (cutPosition - acrossFrom) * (alongTo - alongFrom) / (acrossTo - acrossFrom));
|
||||
}
|
||||
}
|
||||
|
||||
if (crossings.Count % 2 != 0)
|
||||
return null;
|
||||
crossings.Sort();
|
||||
for (var i = 0; i < crossings.Count; i += 2)
|
||||
result.Add((crossings[i], crossings[i + 1]));
|
||||
return result;
|
||||
|
||||
(double Across, double Along) Split(Vector point) =>
|
||||
Axis == CutOffAxis.Vertical ? (point.X, point.Y) : (point.Y, point.X);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grows the perimeter by the clearance as one Clipper region offset, so slots
|
||||
/// narrower than twice the clearance close up instead of leaving a gap the cut
|
||||
@@ -284,7 +580,8 @@ namespace OpenNest
|
||||
|
||||
private Program BuildProgram(
|
||||
List<(double Start, double End)> segments,
|
||||
CutOffSettings settings
|
||||
CutOffSettings settings,
|
||||
(double Edge, bool High)? farEdge
|
||||
)
|
||||
{
|
||||
var program = new Program();
|
||||
@@ -301,6 +598,22 @@ namespace OpenNest
|
||||
|
||||
foreach (var seg in segments)
|
||||
{
|
||||
if (farEdge is var (edge, high) && (high ? seg.End : seg.Start) == edge)
|
||||
{
|
||||
// The segment reaching the far edge overcuts it with the head height locked,
|
||||
// whichever way the rest of the line runs.
|
||||
var near = MakePoint(cutPos, high ? seg.Start : seg.End);
|
||||
var atEdge = MakePoint(cutPos, edge);
|
||||
var beyond = MakePoint(cutPos, high ? edge + HeadLockOvercut : edge - HeadLockOvercut);
|
||||
var towardEdge = new Program();
|
||||
towardEdge.Codes.Add(new RapidMove(near));
|
||||
towardEdge.Codes.Add(new LinearMove(atEdge));
|
||||
towardEdge.Codes.Add(new Comment(HeadHeightLock.Lock));
|
||||
towardEdge.Codes.Add(new LinearMove(beyond));
|
||||
towardEdge.Codes.Add(new Comment(HeadHeightLock.Unlock));
|
||||
program.Codes.AddRange((FarEdgeFirst ? ReverseAtFarEdge(towardEdge) : towardEdge).Codes);
|
||||
continue;
|
||||
}
|
||||
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)));
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// One placed cutting segment of a cut-off: the part that cuts it, where its cut runs along
|
||||
/// the cut-off's axis, and the span of the nominal line it stands for, from the end of the
|
||||
/// segment before it (or the line's start) to the start of the one after it (or the line's
|
||||
/// end). The parts the nominal line passes through lie in the gaps between segments, so each
|
||||
/// is inside the span of the segment on either side of it; a segment is cut before exactly
|
||||
/// those parts.
|
||||
/// </summary>
|
||||
public sealed record CutOffSegment(Part Part, double Start, double End, double SpanStart, double SpanEnd);
|
||||
|
||||
/// <summary>
|
||||
/// A cut-off's trimmed segments are materialized as one part each, so every segment has its
|
||||
/// own place in the cut sequence. This finds those parts and the span each stands for.
|
||||
/// </summary>
|
||||
public static class CutOffSegments
|
||||
{
|
||||
/// <summary>The definition's placed segment parts in ascending order along its axis.</summary>
|
||||
public static IReadOnlyList<CutOffSegment> Of(Plate plate, CutOff definition) =>
|
||||
Of(definition, plate.Parts, plate.BoundingBox(includeParts: false));
|
||||
|
||||
/// <summary>
|
||||
/// The definition's segment parts among <paramref name="parts"/> (those placed from its
|
||||
/// drawing) in ascending order along its axis, with spans measured against the nominal
|
||||
/// line's limits on <paramref name="sheet"/>. A part whose cut cannot be measured stands
|
||||
/// for the whole line.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<CutOffSegment> Of(CutOff definition, IEnumerable<Part> parts, Box sheet)
|
||||
{
|
||||
var vertical = definition.Axis == CutOffAxis.Vertical;
|
||||
var lineStart = definition.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left);
|
||||
var lineEnd = definition.EndLimit ?? (vertical ? sheet.Top : sheet.Right);
|
||||
var low = System.Math.Min(lineStart, lineEnd);
|
||||
var high = System.Math.Max(lineStart, lineEnd);
|
||||
var measured = new List<(Part Part, double Start, double End)>();
|
||||
var whole = new List<CutOffSegment>();
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (!ReferenceEquals(part.BaseDrawing, definition.Drawing))
|
||||
continue;
|
||||
if (Extent(part, vertical) is { } extent)
|
||||
measured.Add((part, extent.Start, extent.End));
|
||||
else
|
||||
whole.Add(new CutOffSegment(part, low, high, low, high));
|
||||
}
|
||||
measured.Sort((a, b) => a.Start != b.Start ? a.Start.CompareTo(b.Start) : a.End.CompareTo(b.End));
|
||||
var result = new List<CutOffSegment>(measured.Count + whole.Count);
|
||||
for (var i = 0; i < measured.Count; i++)
|
||||
{
|
||||
var (part, start, end) = measured[i];
|
||||
var spanStart = i == 0 ? System.Math.Min(low, start) : measured[i - 1].End;
|
||||
var spanEnd = i == measured.Count - 1 ? System.Math.Max(high, end) : measured[i + 1].Start;
|
||||
result.Add(new CutOffSegment(part, start, end, spanStart, spanEnd));
|
||||
}
|
||||
result.AddRange(whole);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The extent along the cut-off's axis of the part's cutting moves, or null when the
|
||||
/// part cuts nothing or its program is not the absolute rapid-and-cut form cut-offs use.
|
||||
/// </summary>
|
||||
public static (double Start, double End)? Extent(Part part, bool vertical)
|
||||
{
|
||||
if (part?.Program == null || part.Program.Mode != Mode.Absolute)
|
||||
return null;
|
||||
double? min = null, max = null;
|
||||
Vector? position = null;
|
||||
foreach (var code in part.Program.Codes)
|
||||
{
|
||||
switch (code)
|
||||
{
|
||||
case RapidMove rapid:
|
||||
position = rapid.EndPoint + part.Location;
|
||||
break;
|
||||
case Motion motion when position != null:
|
||||
var end = motion.EndPoint + part.Location;
|
||||
foreach (var point in new[] { position.Value, end })
|
||||
{
|
||||
var along = vertical ? point.Y : point.X;
|
||||
min = min == null ? along : System.Math.Min(min.Value, along);
|
||||
max = max == null ? along : System.Math.Max(max.Value, along);
|
||||
}
|
||||
position = end;
|
||||
break;
|
||||
case Motion:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return min == null ? null : (min.Value, max.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,18 @@ namespace OpenNest
|
||||
public double Overtravel { get; set; }
|
||||
public double MinSegmentLength { get; set; } = 0.05;
|
||||
public CutDirection CutDirection { get; set; } = CutDirection.AwayFromOrigin;
|
||||
|
||||
/// <summary>
|
||||
/// Whether new cut-offs lock the head height and overcut past the nominal far sheet edge
|
||||
/// by <see cref="HeadLockOvercut"/> (see <see cref="CutOff.HeadLockOvercut"/>). Off until
|
||||
/// the machine's post has head-height lock codes.
|
||||
/// </summary>
|
||||
public bool LockHeadHeight { get; set; }
|
||||
|
||||
/// <summary>How far past the nominal far edge a head-locked cut-off cuts, in model units.</summary>
|
||||
public double HeadLockOvercut { get; set; } = 1.5;
|
||||
|
||||
/// <summary>The head-locked overcut a new cut-off gets from these settings; zero when off.</summary>
|
||||
public double NewCutOffHeadLockOvercut => LockHeadHeight ? HeadLockOvercut : 0;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
namespace OpenNest;
|
||||
|
||||
public class CutParameters
|
||||
{
|
||||
@@ -11,6 +11,18 @@ public class CutParameters
|
||||
public string PostProcessor { get; set; }
|
||||
public Units Units { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Acceleration limits; null estimates every move at full speed. Applied by
|
||||
/// <see cref="CNC.CutSimulation"/> and the plate/nest overloads of <see cref="Timing.CalculateTime(Plate, CutParameters)"/>.
|
||||
/// </summary>
|
||||
public MotionProfile Motion { get; set; }
|
||||
|
||||
/// <summary>Feed for interior contours (holes); null or zero uses <see cref="Feedrate"/>.</summary>
|
||||
public double? HoleFeedrate { get; set; }
|
||||
|
||||
/// <summary>Leave out contours that only scribe/etch, as the CI Fiber post does by default.</summary>
|
||||
public bool SkipScribe { get; set; }
|
||||
|
||||
public static CutParameters Default =>
|
||||
new()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Cutting process a library drawing is prepared for, stored by name so new
|
||||
/// processes can be added without a migration.
|
||||
/// </summary>
|
||||
public enum CutType
|
||||
{
|
||||
Laser,
|
||||
Waterjet,
|
||||
Plasma,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>A nominal manual cut-off line meeting an active, physical clamp footprint.</summary>
|
||||
public sealed record CutOffClampFinding(int CutOffIndex, string ClampId, Vector Location);
|
||||
|
||||
/// <summary>
|
||||
/// Read-only axis-aligned clamp contact checks. Nominal cut-off definitions are checked
|
||||
/// even if their current regenerated program is stale or trimmed by nearby parts.
|
||||
/// A removed clamp is absent from Plate.ClampZones and therefore cannot be reported.
|
||||
/// Zones are compared in the plate's signed placement frame (<see cref="ClampZoneFrame"/>).
|
||||
/// </summary>
|
||||
public static class ClampCutDiagnostics
|
||||
{
|
||||
public static IReadOnlyList<CutOffClampFinding> Find(Plate plate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
var findings = new List<CutOffClampFinding>();
|
||||
if (plate.ClampZones.Count == 0 || plate.CutOffs.Count == 0)
|
||||
return findings.AsReadOnly();
|
||||
var zones = ClampZoneFrame.ToPlacement(plate);
|
||||
var sheet = plate.BoundingBox(false);
|
||||
for (var index = 0; index < plate.CutOffs.Count; index++)
|
||||
{
|
||||
var cut = plate.CutOffs[index];
|
||||
if (cut == null || !Enum.IsDefined(cut.Axis))
|
||||
throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate));
|
||||
// A head-locked overcut runs on past the far edge, where an off-sheet clamp can be.
|
||||
var overcut = cut.HeadLockOvercut > 0 && double.IsFinite(cut.HeadLockOvercut) ? cut.HeadLockOvercut : 0;
|
||||
var farHigh = CutOff.FarEdgeIsHigh(cut.Axis, plate.Quadrant);
|
||||
var low = farHigh ? 0 : overcut;
|
||||
var high = farHigh ? overcut : 0;
|
||||
var start = cut.Axis == CutOffAxis.Vertical
|
||||
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom - low)
|
||||
: new Vector(cut.StartLimit ?? sheet.Left - low, cut.Position.Y);
|
||||
var end = cut.Axis == CutOffAxis.Vertical
|
||||
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top + high)
|
||||
: new Vector(cut.EndLimit ?? sheet.Right + high, cut.Position.Y);
|
||||
foreach (var zone in zones)
|
||||
if (Intersects(start, end, zone.Bounds, out var location))
|
||||
findings.Add(new CutOffClampFinding(index, zone.Id, location));
|
||||
}
|
||||
return findings.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>True for contact with the whole rectangle, including a boundary touch.</summary>
|
||||
public static bool Intersects(Vector from, Vector to, Box box, out Vector location)
|
||||
{
|
||||
location = default;
|
||||
if (box == null || !Finite(from) || !Finite(to) || !Finite(box.Location)
|
||||
|| !double.IsFinite(box.Right) || !double.IsFinite(box.Top)
|
||||
|| box.Length <= 0 || box.Width <= 0)
|
||||
throw new ArgumentException("Clamp contact geometry must be finite and nonempty.");
|
||||
var dx = to.X - from.X;
|
||||
var dy = to.Y - from.Y;
|
||||
if (!double.IsFinite(dx) || !double.IsFinite(dy)
|
||||
|| !double.IsFinite(from.X - box.Left) || !double.IsFinite(box.Right - from.X)
|
||||
|| !double.IsFinite(from.Y - box.Bottom) || !double.IsFinite(box.Top - from.Y))
|
||||
throw new ArgumentException("Clamp contact motion is too large.");
|
||||
var enter = 0.0;
|
||||
var exit = 1.0;
|
||||
if (!Clip(-dx, from.X - box.Left, ref enter, ref exit)
|
||||
|| !Clip(dx, box.Right - from.X, ref enter, ref exit)
|
||||
|| !Clip(-dy, from.Y - box.Bottom, ref enter, ref exit)
|
||||
|| !Clip(dy, box.Top - from.Y, ref enter, ref exit))
|
||||
return false;
|
||||
var midpoint = (enter + exit) * 0.5;
|
||||
location = new Vector(from.X + dx * midpoint, from.Y + dy * midpoint);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool Clip(double p, double q, ref double enter, ref double exit)
|
||||
{
|
||||
if (p == 0)
|
||||
return q >= 0;
|
||||
var ratio = q / p;
|
||||
if (p < 0)
|
||||
enter = System.Math.Max(enter, ratio);
|
||||
else
|
||||
exit = System.Math.Min(exit, ratio);
|
||||
return enter <= exit;
|
||||
}
|
||||
|
||||
private static bool Finite(Vector value) => double.IsFinite(value.X) && double.IsFinite(value.Y);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Shared material of two hole-aware outlines as connected islands, through Clipper2.
|
||||
/// Callers pass part-local coordinates (rebased near the origin) and add the origin back.
|
||||
/// <para>
|
||||
/// Coordinates are rounded to a 1e-8 model-unit grid, far below the 0.001 chord tolerance
|
||||
/// and the <see cref="OpenNest.Math.Tolerance.Epsilon"/> area threshold. Each operand is
|
||||
/// its outline minus the union of its holes (outline positive, holes negative,
|
||||
/// <see cref="FillRule.Positive"/>).
|
||||
/// Nonfinite or out-of-range coordinates throw <see cref="ArithmeticException"/>, which the
|
||||
/// analyzer turns into an incomplete-check issue rather than a clear result.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class OverlapClipping
|
||||
{
|
||||
private const double Scale = 1e8;
|
||||
|
||||
// Scaled coordinates stay several orders of magnitude inside Clipper2's int64 range.
|
||||
private const double MaxCoordinate = 1e9;
|
||||
|
||||
/// <summary>One connected island: a counterclockwise outline and clockwise holes, both closed.</summary>
|
||||
internal sealed record Island(List<Vector> Outline, List<List<Vector>> Holes, PolygonAreaMoments Moments);
|
||||
|
||||
internal static List<Island> Intersect(Polygon outerA, IReadOnlyList<Polygon> holesA,
|
||||
Polygon outerB, IReadOnlyList<Polygon> holesB)
|
||||
{
|
||||
var clipper = new Clipper64();
|
||||
clipper.AddSubject(Region(outerA, holesA));
|
||||
clipper.AddClip(Region(outerB, holesB));
|
||||
var tree = new PolyTree64();
|
||||
if (!clipper.Execute(ClipType.Intersection, FillRule.Positive, tree))
|
||||
throw new InvalidOperationException("Clipper2 could not intersect the overlap material.");
|
||||
var islands = new List<Island>();
|
||||
Collect(tree, islands);
|
||||
return islands;
|
||||
}
|
||||
|
||||
private static void Collect(PolyPath64 node, List<Island> islands)
|
||||
{
|
||||
for (var i = 0; i < node.Count; i++)
|
||||
{
|
||||
var outer = (PolyPath64)node[i];
|
||||
var outline = ToVectors(outer.Polygon, positive: true);
|
||||
var holes = new List<List<Vector>>();
|
||||
for (var h = 0; h < outer.Count; h++)
|
||||
{
|
||||
var hole = (PolyPath64)outer[h];
|
||||
holes.Add(ToVectors(hole.Polygon, positive: false));
|
||||
// An island can sit inside a hole formed by both operands' holes together.
|
||||
Collect(hole, islands);
|
||||
}
|
||||
if (TryMoments(outline, holes, out var moments))
|
||||
islands.Add(new Island(outline, holes, moments));
|
||||
else if (Clipper.Area(outer.Polygon) / (Scale * Scale) >= OpenNest.Math.Tolerance.Epsilon)
|
||||
throw new ArithmeticException("Overlap island area moments are invalid.");
|
||||
// Otherwise a degenerate dust island below the reporting threshold is skipped.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Net area and centroid of an outline minus its holes. Fails for degenerate,
|
||||
/// nonfinite or nonpositive results.
|
||||
/// </summary>
|
||||
private static bool TryMoments(List<Vector> outline, List<List<Vector>> holes, out PolygonAreaMoments moments)
|
||||
{
|
||||
moments = default;
|
||||
if (!PolygonAreaMoments.TryCompute(outline, out var outer))
|
||||
return false;
|
||||
var removed = new List<PolygonAreaMoments>(holes.Count);
|
||||
foreach (var hole in holes)
|
||||
{
|
||||
if (!PolygonAreaMoments.TryCompute(hole, out var hollow))
|
||||
return false;
|
||||
removed.Add(hollow);
|
||||
}
|
||||
return PolygonAreaMoments.TrySubtract(outer, removed, out moments);
|
||||
}
|
||||
|
||||
private static Paths64 Region(Polygon outer, IReadOnlyList<Polygon> holes)
|
||||
{
|
||||
var region = new Paths64 { ToPath(outer, positive: true) };
|
||||
if (holes != null)
|
||||
foreach (var hole in holes)
|
||||
region.Add(ToPath(hole, positive: false));
|
||||
return region;
|
||||
}
|
||||
|
||||
private static Path64 ToPath(Polygon polygon, bool positive)
|
||||
{
|
||||
var vertices = polygon.Vertices;
|
||||
var count = vertices.Count;
|
||||
if (count > 1 && vertices[count - 1].X == vertices[0].X && vertices[count - 1].Y == vertices[0].Y)
|
||||
count--;
|
||||
var path = new Path64(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var point = vertices[i];
|
||||
if (!double.IsFinite(point.X) || !double.IsFinite(point.Y)
|
||||
|| System.Math.Abs(point.X) > MaxCoordinate || System.Math.Abs(point.Y) > MaxCoordinate)
|
||||
throw new ArithmeticException("Overlap material is outside the exact clipping range.");
|
||||
path.Add(new Point64(System.Math.Round(point.X * Scale), System.Math.Round(point.Y * Scale)));
|
||||
}
|
||||
if (Clipper.IsPositive(path) != positive)
|
||||
path.Reverse();
|
||||
return path;
|
||||
}
|
||||
|
||||
private static List<Vector> ToVectors(Path64 path, bool positive)
|
||||
{
|
||||
var points = new List<Vector>(path.Count + 1);
|
||||
foreach (var point in path)
|
||||
points.Add(new Vector(point.X / Scale, point.Y / Scale));
|
||||
if (Clipper.IsPositive(path) != positive)
|
||||
points.Reverse();
|
||||
points.Add(points[0]); // Regions repeat their closing vertex exactly.
|
||||
return points;
|
||||
}
|
||||
}
|
||||
@@ -11,20 +11,72 @@ public sealed class OverlapGeometryStamp
|
||||
{
|
||||
private readonly Plate plate;
|
||||
private readonly Entry[] entries;
|
||||
private readonly (string Id, long X, long Y, long Length, long Width)[] zones;
|
||||
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End, long Overcut)[] cutoffs;
|
||||
private readonly (long Width, long Length, int Quadrant) sheet;
|
||||
|
||||
private OverlapGeometryStamp(Plate plate)
|
||||
{
|
||||
this.plate = plate;
|
||||
sheet = SheetState(plate);
|
||||
entries = new Entry[plate.Parts.Count];
|
||||
for (var i = 0; i < entries.Length; i++)
|
||||
entries[i] = new Entry(plate.Parts[i]);
|
||||
zones = new (string, long, long, long, long)[plate.ClampZones.Count];
|
||||
for (var i = 0; i < zones.Length; i++)
|
||||
zones[i] = ZoneState(plate.ClampZones[i]);
|
||||
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?, long)[plate.CutOffs.Count];
|
||||
for (var i = 0; i < cutoffs.Length; i++)
|
||||
cutoffs[i] = CutOffState(plate.CutOffs[i]);
|
||||
}
|
||||
|
||||
// Missing entries are malformed input the analysis itself reports; freshness must not
|
||||
// throw first (the desktop captures it before its error boundary).
|
||||
private static (string, long, long, long, long) ZoneState(ClampZone zone) =>
|
||||
zone?.Bounds is not { } box ? default
|
||||
: (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width));
|
||||
|
||||
private static (CutOff, CutOffAxis, long, long, long?, long?, long) CutOffState(CutOff cut) =>
|
||||
cut == null ? default
|
||||
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
|
||||
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
|
||||
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null,
|
||||
Bits(cut.HeadLockOvercut));
|
||||
|
||||
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||
|
||||
private static (long, long, int) SheetState(Plate plate) =>
|
||||
(Bits(plate.Size.Width), Bits(plate.Size.Length), plate.Quadrant);
|
||||
|
||||
public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
|
||||
|
||||
internal bool SameZones(Plate current)
|
||||
{
|
||||
// Stored zones are sheet-relative: a size or quadrant change moves them in the
|
||||
// parts' frame without changing their bits.
|
||||
if (!ReferenceEquals(plate, current) || current.ClampZones.Count != zones.Length
|
||||
|| sheet != SheetState(current))
|
||||
return false;
|
||||
for (var i = 0; i < zones.Length; i++)
|
||||
if (zones[i] != ZoneState(current.ClampZones[i]))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool SameCutOffs(Plate current)
|
||||
{
|
||||
if (!ReferenceEquals(plate, current) || current.CutOffs.Count != cutoffs.Length
|
||||
|| sheet != SheetState(current))
|
||||
return false;
|
||||
for (var i = 0; i < cutoffs.Length; i++)
|
||||
if (cutoffs[i] != CutOffState(current.CutOffs[i]))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Matches(Plate current)
|
||||
{
|
||||
if (!ReferenceEquals(plate, current) || current.Parts.Count != entries.Length)
|
||||
if (current.Parts.Count != entries.Length || !SameZones(current) || !SameCutOffs(current))
|
||||
return false;
|
||||
for (var i = 0; i < entries.Length; i++)
|
||||
if (!entries[i].Matches(current.Parts[i]))
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// A marker point on one island of shared material. The area centroid of a concave,
|
||||
/// holed or split overlap can lie off the material, so markers use the island's pole of
|
||||
/// inaccessibility instead, with a guaranteed-interior fallback.
|
||||
/// </summary>
|
||||
internal static class OverlapMarker
|
||||
{
|
||||
// Distance evaluations per island; a long sliver otherwise refines along its whole ridge.
|
||||
internal const int MaxCells = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// The island's pole of inaccessibility (searched to a tenth of its area/perimeter, a
|
||||
/// lower bound on a thin island's half-width) when that bounded search lands inside;
|
||||
/// otherwise the midpoint of the widest interior run of a horizontal scan line; otherwise
|
||||
/// <paramref name="fallback"/>. Rings are closed, as the clipping returns them.
|
||||
/// </summary>
|
||||
internal static Vector Find(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, double area,
|
||||
Vector fallback, int maxCells = MaxCells)
|
||||
{
|
||||
var outer = ToPolygon(outline);
|
||||
var hollow = holes.Select(ToPolygon).ToList();
|
||||
var perimeter = outer.Perimeter() + hollow.Sum(hole => hole.Perimeter());
|
||||
var precision = 0.1 * area / perimeter;
|
||||
if (double.IsFinite(precision) && precision > 0)
|
||||
{
|
||||
var pole = PolyLabel.Find(outer, hollow, precision, maxCells);
|
||||
if (Inside(outline, holes, pole))
|
||||
return pole;
|
||||
}
|
||||
return TryScanLine(outline, holes, out var point) ? point : fallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Midpoint of the widest interior run along a horizontal line through the middle of the
|
||||
/// island, placed between vertex heights so crossings are unambiguous (as JTS's
|
||||
/// interior-point-of-area). Even-odd pairing of outline and hole crossings keeps it
|
||||
/// inside the material.
|
||||
/// </summary>
|
||||
private static bool TryScanLine(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, out Vector point)
|
||||
{
|
||||
point = default;
|
||||
var minY = outline.Min(vertex => vertex.Y);
|
||||
var maxY = outline.Max(vertex => vertex.Y);
|
||||
var centre = (minY + maxY) / 2;
|
||||
double below = minY, above = maxY;
|
||||
foreach (var vertex in outline.Concat(holes.SelectMany(hole => hole)))
|
||||
{
|
||||
if (vertex.Y <= centre && vertex.Y > below)
|
||||
below = vertex.Y;
|
||||
else if (vertex.Y > centre && vertex.Y < above)
|
||||
above = vertex.Y;
|
||||
}
|
||||
var y = (below + above) / 2;
|
||||
if (!(y > minY && y < maxY))
|
||||
return false;
|
||||
|
||||
var crossings = new List<double>();
|
||||
AddCrossings(outline, y, crossings);
|
||||
foreach (var hole in holes)
|
||||
AddCrossings(hole, y, crossings);
|
||||
crossings.Sort();
|
||||
var widest = 0.0;
|
||||
for (var i = 0; i + 1 < crossings.Count; i += 2)
|
||||
{
|
||||
var width = crossings[i + 1] - crossings[i];
|
||||
if (width > widest)
|
||||
{
|
||||
widest = width;
|
||||
point = new Vector((crossings[i] + crossings[i + 1]) / 2, y);
|
||||
}
|
||||
}
|
||||
return widest > 0;
|
||||
}
|
||||
|
||||
private static void AddCrossings(IReadOnlyList<Vector> ring, double y, List<double> crossings)
|
||||
{
|
||||
for (var i = 0; i + 1 < ring.Count; i++)
|
||||
{
|
||||
var a = ring[i];
|
||||
var b = ring[i + 1];
|
||||
if ((a.Y > y) != (b.Y > y))
|
||||
crossings.Add(a.X + (y - a.Y) * (b.X - a.X) / (b.Y - a.Y));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Inside(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, Vector point) =>
|
||||
double.IsFinite(point.X) && double.IsFinite(point.Y)
|
||||
&& InsideRing(outline, point) && !holes.Any(hole => InsideRing(hole, point));
|
||||
|
||||
// Crossing-number test over a closed ring (half-open edges, so a vertex counts once).
|
||||
private static bool InsideRing(IReadOnlyList<Vector> ring, Vector point)
|
||||
{
|
||||
var inside = false;
|
||||
for (var i = 0; i + 1 < ring.Count; i++)
|
||||
{
|
||||
var a = ring[i];
|
||||
var b = ring[i + 1];
|
||||
if ((a.Y > point.Y) != (b.Y > point.Y)
|
||||
&& point.X < a.X + (point.Y - a.Y) * (b.X - a.X) / (b.Y - a.Y))
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static Polygon ToPolygon(IReadOnlyList<Vector> ring)
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.AddRange(ring);
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,21 @@ namespace OpenNest.Diagnostics;
|
||||
/// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary>
|
||||
public static class OverlapPairPresentation
|
||||
{
|
||||
public static string Label(PlateOverlapPair pair) => $"{pair.PartAId + 1}/{pair.PartBId + 1}";
|
||||
/// <summary>True for part-vs-clamp findings carried on the report (negative clamp slot).</summary>
|
||||
public static bool IsClampFinding(PlateOverlapPair pair) => pair.PartBId < 0;
|
||||
|
||||
public static string Label(PlateOverlapPair pair) =>
|
||||
IsClampFinding(pair) ? $"{pair.PartAId + 1}/clamp" : $"{pair.PartAId + 1}/{pair.PartBId + 1}";
|
||||
|
||||
public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null)
|
||||
{
|
||||
var units = UnitsHelper.GetShortString(capturedUnits);
|
||||
return $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}\n"
|
||||
var head = IsClampFinding(pair)
|
||||
? $"Clamp overlap: {pair.PartAName} / {pair.PartBName}"
|
||||
: $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}";
|
||||
return head + "\n"
|
||||
+ $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
|
||||
+ $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}";
|
||||
+ $"Marker: ({Number(pair.Marker.X, provider)}, {Number(pair.Marker.Y, provider)}) {units}";
|
||||
}
|
||||
|
||||
// Significant figures adapt to scale: a positive sliver must never read as zero.
|
||||
@@ -27,7 +34,7 @@ public static class OverlapPairPresentation
|
||||
public static double HitRadius(int deviceDpi) => 10.0 * deviceDpi / 96;
|
||||
|
||||
/// <summary>
|
||||
/// Projects cached centroids into a screen-pixel coordinate space. The pointer must
|
||||
/// Projects cached markers into a screen-pixel coordinate space. The pointer must
|
||||
/// use that same space; view zoom never scales the DPI-adjusted hit radius.
|
||||
/// Coincident or nearby markers return every matching pair in stable ID order.
|
||||
/// </summary>
|
||||
@@ -37,7 +44,7 @@ public static class OverlapPairPresentation
|
||||
var radius = HitRadius(deviceDpi);
|
||||
return pairs.Where(pair =>
|
||||
{
|
||||
var center = worldToScreen(pair.Centroid);
|
||||
var center = worldToScreen(pair.Marker);
|
||||
var dx = pointer.X - center.X;
|
||||
var dy = pointer.Y - center.Y;
|
||||
return dx * dx + dy * dy <= radius * radius;
|
||||
|
||||
@@ -21,14 +21,22 @@ public sealed class OverlapReportState
|
||||
public PlateOverlapReport Report { get; private set; }
|
||||
/// <summary>Known overlap pairs safe to draw, even while the full layout needs a recheck.</summary>
|
||||
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
|
||||
public IReadOnlyList<CutOffClampFinding> DisplayCutOffClampFindings { get; private set; } =
|
||||
Array.Empty<CutOffClampFinding>();
|
||||
public bool IsRunning => Status == OverlapCheckStatus.Checking;
|
||||
|
||||
public string Message => Status switch
|
||||
{
|
||||
OverlapCheckStatus.Checking => "Checking overlaps…",
|
||||
OverlapCheckStatus.Current => Report.Pairs.Count == 0
|
||||
? "No material overlaps detected" : $"Overlaps: {Report.Pairs.Count} pairs",
|
||||
OverlapCheckStatus.Current => Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0
|
||||
? (Report.Pairs.Count == 0 ? "No material overlaps detected"
|
||||
: $"Overlaps: {Report.Pairs.Count} pairs")
|
||||
: $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps, "
|
||||
+ $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts",
|
||||
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
|
||||
+ (Report.ClampFindings.Count == 0 ? "" : $"{Report.ClampFindings.Count} clamp findings; ")
|
||||
+ (Report.CutOffClampFindings.Count == 0 ? ""
|
||||
: $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts; ")
|
||||
+ $"{uncheckedPartCount} parts could not be checked",
|
||||
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
|
||||
OverlapCheckStatus.Canceled => "Overlap check canceled",
|
||||
@@ -55,7 +63,9 @@ public sealed class OverlapReportState
|
||||
if (!CanComplete(generation, plate))
|
||||
return false;
|
||||
Report = report;
|
||||
DisplayPairs = report.Pairs;
|
||||
DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs
|
||||
: report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly();
|
||||
DisplayCutOffClampFindings = report.CutOffClampFindings;
|
||||
displayStamp = stamp;
|
||||
observedDisplayStamp = stamp;
|
||||
uncheckedPartCount = CountUncheckedParts(report.Issues);
|
||||
@@ -106,10 +116,14 @@ public sealed class OverlapReportState
|
||||
if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true)
|
||||
return;
|
||||
var unchanged = displayStamp.UnchangedSlots(plate);
|
||||
var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList();
|
||||
var sameZones = displayStamp.SameZones(plate);
|
||||
if (!sameZones || !displayStamp.SameCutOffs(plate))
|
||||
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
|
||||
var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId]
|
||||
&& (pair.PartBId < 0 ? sameZones : unchanged[pair.PartBId])).ToList();
|
||||
if (retained.Count != DisplayPairs.Count)
|
||||
DisplayPairs = retained.AsReadOnly();
|
||||
if (DisplayPairs.Count == 0)
|
||||
if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0)
|
||||
ClearDisplayPairs();
|
||||
else
|
||||
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
|
||||
@@ -118,6 +132,7 @@ public sealed class OverlapReportState
|
||||
private void ClearDisplayPairs()
|
||||
{
|
||||
DisplayPairs = Array.Empty<PlateOverlapPair>();
|
||||
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
|
||||
displayStamp = null;
|
||||
observedDisplayStamp = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Pairs of placed parts closer than the plate's part spacing. Candidates are found exactly as Auto
|
||||
/// Nest's layout check finds them: each part's clean nesting material
|
||||
/// (<see cref="NestingMaterial.ForNesting"/>, leads excluded) outlined by <see cref="SpacingOutline"/>,
|
||||
/// the leftmost of a pair inflated by <see cref="SpacingOutline.InflationFor"/>. That outline
|
||||
/// circumscribes arcs by up to its chord tolerance, more than the slack, and interactive fills
|
||||
/// place native arcs exactly at the spacing. So each candidate is confirmed on
|
||||
/// <see cref="NativeContours"/>, as the layout check confirms it, and reported only when that gap
|
||||
/// is short of the spacing by more than the slack. Zero spacing enforces nothing, so contact stays
|
||||
/// valid.
|
||||
/// Pairs whose material already overlaps, and parts the overlap check could not read, are left to
|
||||
/// that check. The sheet edge and clamps are not spacing-checked here.
|
||||
/// </summary>
|
||||
internal static class PartSpacingReview
|
||||
{
|
||||
internal static void Add(Plate plate, int plateNumber, IReadOnlyList<Part> parts,
|
||||
IReadOnlyList<int> partNumbers, PlateOverlapReport overlap, List<PostVerificationFinding> findings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var spacing = plate.PartSpacing;
|
||||
if (!double.IsFinite(spacing) || spacing < 0)
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||
$"Part spacing check: plate part spacing {spacing} is not a finite, non-negative value."));
|
||||
return;
|
||||
}
|
||||
var inflateBy = SpacingOutline.InflationFor(spacing);
|
||||
if (inflateBy <= Tolerance.Epsilon || parts.Count < 2)
|
||||
return;
|
||||
|
||||
var unreadable = overlap.Issues.Where(issue => issue.PartBId == null)
|
||||
.Select(issue => issue.PartAId).ToHashSet();
|
||||
var overlapping = overlap.Pairs.Select(pair => Key(pair.PartAId, pair.PartBId)).ToHashSet();
|
||||
var profiles = new Dictionary<(Program, long), ShapeProfile>();
|
||||
var shapes = new ShapeProfile[parts.Count];
|
||||
var fine = new NativeContours[parts.Count];
|
||||
var raw = new SpacingOutline[parts.Count];
|
||||
var inflated = new SpacingOutline[parts.Count];
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (unreadable.Contains(i))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
var profile = Profile(parts[i], profiles);
|
||||
if (profile == null)
|
||||
continue;
|
||||
shapes[i] = profile;
|
||||
raw[i] = SpacingOutline.Build(profile, parts[i].Location, 0);
|
||||
inflated[i] = SpacingOutline.Build(profile, parts[i].Location, inflateBy);
|
||||
if (raw[i] == null || inflated[i] == null)
|
||||
throw new ArgumentException("Part material has no closed perimeter.");
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
raw[i] = inflated[i] = null;
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumbers[i], null,
|
||||
"Part spacing check: " + exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
var order = Enumerable.Range(0, parts.Count)
|
||||
.Where(i => raw[i] != null)
|
||||
.OrderBy(i => raw[i].Perimeter.BoundingBox.Left)
|
||||
.ToList();
|
||||
for (var a = 0; a < order.Count; a++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var i = order[a];
|
||||
var reach = inflated[i].Perimeter.BoundingBox;
|
||||
for (var b = a + 1; b < order.Count; b++)
|
||||
{
|
||||
var j = order[b];
|
||||
var other = raw[j].Perimeter.BoundingBox;
|
||||
// Sorted by Left, so nothing further along can reach part i either.
|
||||
if (other.Left > reach.Right + Tolerance.Epsilon)
|
||||
break;
|
||||
if (!BoxesTouch(reach, other) || overlapping.Contains(Key(i, j))
|
||||
|| !inflated[i].Overlaps(raw[j]))
|
||||
continue;
|
||||
double gap;
|
||||
Vector marker;
|
||||
try
|
||||
{
|
||||
fine[i] ??= NativeContours.Of(shapes[i], parts[i].Location);
|
||||
fine[j] ??= NativeContours.Of(shapes[j], parts[j].Location);
|
||||
gap = fine[i].GapTo(fine[j], spacing, out marker);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber,
|
||||
System.Math.Min(partNumbers[i], partNumbers[j]),
|
||||
System.Math.Max(partNumbers[i], partNumbers[j]),
|
||||
"Part spacing check: " + exception.Message));
|
||||
continue;
|
||||
}
|
||||
if (!(gap < spacing - SpacingOutline.SpacingSlack))
|
||||
continue;
|
||||
var (first, second) = partNumbers[i] < partNumbers[j] ? (i, j) : (j, i);
|
||||
findings.Add(new(PostVerificationKind.PartSpacing, plateNumber, partNumbers[first],
|
||||
partNumbers[second],
|
||||
$"'{Name(parts[first])}' and '{Name(parts[second])}' are {gap:F3} apart, closer than the "
|
||||
+ $"plate's part spacing ({spacing:F3}).")
|
||||
{ Location = marker });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clean drawing material, rotated to the part's pose; null when the part has none.
|
||||
/// Converted once per program and rotation.</summary>
|
||||
private static ShapeProfile Profile(Part part, Dictionary<(Program, long), ShapeProfile> profiles)
|
||||
{
|
||||
var program = part.BaseDrawing.Program;
|
||||
var rotation = part.Rotation - program.Rotation;
|
||||
if (!double.IsFinite(rotation))
|
||||
throw new ArgumentException("Part pose must be finite.");
|
||||
var key = (program, BitConverter.DoubleToInt64Bits(rotation));
|
||||
if (profiles.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
// Conversion creates fresh geometry, so rotating it never touches the live program.
|
||||
var entities = ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
|
||||
.Where(entity => entity.Layer != SpecialLayers.Leadin && entity.Layer != SpecialLayers.Leadout)
|
||||
.ForNesting();
|
||||
if (rotation != 0)
|
||||
foreach (var entity in entities)
|
||||
entity.Rotate(rotation);
|
||||
var profile = entities.Count == 0 ? null : new ShapeProfile(entities);
|
||||
if (profile != null && profile.Perimeter == null)
|
||||
throw new ArgumentException("Part material has no closed perimeter.");
|
||||
profiles[key] = profile;
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static (int, int) Key(int a, int b) => a < b ? (a, b) : (b, a);
|
||||
|
||||
private static string Name(Part part) => part.BaseDrawing.Name;
|
||||
|
||||
private static bool BoxesTouch(Box a, Box b) =>
|
||||
a.Left <= b.Right + Tolerance.Epsilon
|
||||
&& b.Left <= a.Right + Tolerance.Epsilon
|
||||
&& a.Bottom <= b.Top + Tolerance.Epsilon
|
||||
&& b.Bottom <= a.Top + Tolerance.Epsilon;
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Support;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-post review of part support (docs/support-tabs.md). Every part whose cutting parameters
|
||||
/// ask for support tabs and that needs one (<see cref="SupportTabSettings.Analyze"/>: its plate's
|
||||
/// support bed cannot hold it level or, without a bed, its width is in the no-bed range) must be
|
||||
/// cut with a tab that keeps it level, however its program was made (planned, placed by hand or
|
||||
/// locked); without a bed any tab will do. The tab is the gap the outside cut leaves between its end and its start. Reports
|
||||
/// only; a part whose material or program cannot be read is reported as not checked.
|
||||
/// </summary>
|
||||
internal static class PartSupportReview
|
||||
{
|
||||
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
|
||||
CancellationToken token)
|
||||
{
|
||||
var bed = plate.SupportBed;
|
||||
for (var index = 0; index < plate.Parts.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var part = plate.Parts[index];
|
||||
var parameters = part?.CuttingParameters ?? plate.CuttingParameters;
|
||||
if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff || parameters?.SupportTabs != true
|
||||
|| (bed == null && !parameters.HasNoBedTabRange()))
|
||||
continue;
|
||||
var partNumber = index + 1;
|
||||
try
|
||||
{
|
||||
var support = SupportTabSettings.Analyze(parameters, bed, plate.Quadrant, SupportMaterial.FromPart(part));
|
||||
if (support == null)
|
||||
continue;
|
||||
if (!support.IsComplete)
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumber, null,
|
||||
"Support check: " + support.Reason));
|
||||
continue;
|
||||
}
|
||||
if (support.IsSupported)
|
||||
continue;
|
||||
var tab = OutsideTab(ExecutionMotionReader.Read(part.Program, part.Location, null, token));
|
||||
if (tab is { } point && support.IsSteadiedByTabAt(point))
|
||||
continue;
|
||||
findings.Add(new(PostVerificationKind.PartSupport, plateNumber, partNumber, null,
|
||||
Message(support, tab))
|
||||
{ Location = support.CenterOfGravity });
|
||||
}
|
||||
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException
|
||||
or NotSupportedException or ArithmeticException)
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumber, null,
|
||||
"Support check: " + exception.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The middle of the gap the outside cut leaves open, or null when it closes: the outside cut
|
||||
/// is the chain of cutting motions with the largest extent.
|
||||
/// </summary>
|
||||
internal static Vector? OutsideTab(OwnedExecution execution)
|
||||
{
|
||||
Vector? start = null, end = null, bestStart = null, bestEnd = null;
|
||||
var minX = double.PositiveInfinity; var minY = double.PositiveInfinity;
|
||||
var maxX = double.NegativeInfinity; var maxY = double.NegativeInfinity;
|
||||
var bestArea = -1.0;
|
||||
foreach (var motion in execution.Motions.Append(null))
|
||||
{
|
||||
if (motion is { Rapid: false, Layer: LayerType.Cut or LayerType.Display })
|
||||
{
|
||||
start ??= motion.Start ?? motion.End;
|
||||
end = motion.End;
|
||||
foreach (var p in new[] { motion.Start ?? motion.End, motion.End, motion.Curve?.Midpoint ?? motion.End })
|
||||
{
|
||||
minX = System.Math.Min(minX, p.X); minY = System.Math.Min(minY, p.Y);
|
||||
maxX = System.Math.Max(maxX, p.X); maxY = System.Math.Max(maxY, p.Y);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Anything else ends a chain: a rapid, a lead, a scribe or a break cut.
|
||||
if (start != null && (maxX - minX) * (maxY - minY) > bestArea)
|
||||
{
|
||||
bestArea = (maxX - minX) * (maxY - minY);
|
||||
bestStart = start;
|
||||
bestEnd = end;
|
||||
}
|
||||
start = end = null;
|
||||
minX = minY = double.PositiveInfinity;
|
||||
maxX = maxY = double.NegativeInfinity;
|
||||
}
|
||||
if (bestStart is not { } first || bestEnd is not { } last
|
||||
|| first.DistanceTo(last) <= PostVerificationGeometry.Epsilon)
|
||||
return null;
|
||||
return (first + last) * 0.5;
|
||||
}
|
||||
|
||||
private static string Message(PartSupport support, Vector? tab)
|
||||
{
|
||||
// Without a bed, or for a part its tab alone holds, any tab steadies the part, so the only
|
||||
// failure is no tab at all.
|
||||
if (!support.HasBed || support.IsHeldByTab)
|
||||
return (support.HasBed
|
||||
? FormattableString.Invariant(
|
||||
$"The part is {support.Length:0.####} long, short enough for its tab alone to hold, ")
|
||||
: FormattableString.Invariant(
|
||||
$"The part is {support.Width:0.####} wide, in the width range tabbed without a support bed, "))
|
||||
+ "and it has no tab; it may tip once cut free."
|
||||
+ (support.BestTabPoint is { } suggested
|
||||
? FormattableString.Invariant($" Tab it near ({suggested.X:0.####}, {suggested.Y:0.####}).")
|
||||
: "");
|
||||
var held = support.Margin == double.NegativeInfinity
|
||||
? "rests on no support contact"
|
||||
: support.Margin < 0
|
||||
? FormattableString.Invariant($"has its center of gravity {-support.Margin:0.####} outside its support contacts")
|
||||
: FormattableString.Invariant($"has its center of gravity only {support.Margin:0.####} inside its support contacts");
|
||||
var tabText = tab is { } point
|
||||
? FormattableString.Invariant($"its tab at ({point.X:0.####}, {point.Y:0.####}) does not steady it")
|
||||
: "it has no tab";
|
||||
var best = support.BestTabPoint is { } bestPoint && support.TabCanSupport
|
||||
? FormattableString.Invariant($" A tab near ({bestPoint.X:0.####}, {bestPoint.Y:0.####}) would.")
|
||||
: " No single tab can steady it.";
|
||||
return FormattableString.Invariant($"The part {held} (needs {support.RequiredMargin:0.####}) and {tabText}; ")
|
||||
+ "it may tip once cut free." + best;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
@@ -16,13 +17,41 @@ public static class PlateOverlapAnalyzer
|
||||
{
|
||||
public const double ChordTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// Capture a whole plate including active clamps and nominal cut-off definitions.
|
||||
/// Use this entry for the desktop overlap check so manual cut-off contacts
|
||||
/// cannot be mistaken for a clear plate merely because cut-offs are not material.
|
||||
/// Clamp zones are mapped from the sheet's lower-left frame into the parts' signed
|
||||
/// placement frame; a malformed zone throws <see cref="ArgumentException"/>.
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot CapturePlate(Plate plate, OverlapMaterialCache cache = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
var material = Capture(plate.Parts.ToArray(), ClampZoneFrame.ToPlacement(plate).ToArray(),
|
||||
cache ?? new OverlapMaterialCache(), cancellationToken);
|
||||
var cutoffs = ClampCutDiagnostics.Find(plate);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(),
|
||||
material.Clamps.ToList(), cutoffs.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures poses and converts each distinct clean source program to owned entities once.
|
||||
/// Inputs must not change during capture. Later analysis never reads live domain objects.
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Capture(parts, new OverlapMaterialCache(), cancellationToken);
|
||||
Capture(parts, null, new OverlapMaterialCache(), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, additionally capturing
|
||||
/// the plate's clamp keep-out rectangles: Analyze then reports part-vs-clamp findings
|
||||
/// (C1) alongside part pairs. Null means no zones.
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||
IReadOnlyList<ClampZone> clampZones, CancellationToken cancellationToken = default) =>
|
||||
Capture(parts, clampZones, new OverlapMaterialCache(), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, but reuses converted
|
||||
@@ -30,6 +59,12 @@ public static class PlateOverlapAnalyzer
|
||||
/// cache before any in-place clean-program edit (see <see cref="OverlapMaterialCache"/>).
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts, OverlapMaterialCache cache,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Capture(parts, null, cache, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="Capture(IReadOnlyList{Part}, IReadOnlyList{ClampZone}, CancellationToken)"/>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||
IReadOnlyList<ClampZone> clampZones, OverlapMaterialCache cache,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parts);
|
||||
@@ -63,8 +98,21 @@ public static class PlateOverlapAnalyzer
|
||||
issues.Add(new PlateOverlapIssue(id, null, exception.Message));
|
||||
}
|
||||
}
|
||||
var clamps = new List<CapturedClamp>();
|
||||
if (clampZones != null)
|
||||
{
|
||||
for (var ordinal = 0; ordinal < clampZones.Count; ordinal++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var zone = clampZones[ordinal];
|
||||
// ClampZone validates finiteness and positive size at construction; the
|
||||
// snapshot still owns its own copy so later plate edits never move it.
|
||||
clamps.Add(new CapturedClamp(ordinal, zone.Id,
|
||||
new Box(zone.Bounds.X, zone.Bounds.Y, zone.Bounds.Length, zone.Bounds.Width)));
|
||||
}
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PlateOverlapSnapshot(captured, issues);
|
||||
return new PlateOverlapSnapshot(captured, issues, clamps);
|
||||
}
|
||||
|
||||
private static OverlapSource CaptureSource(Program program)
|
||||
@@ -73,8 +121,9 @@ public static class PlateOverlapAnalyzer
|
||||
{
|
||||
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance), false);
|
||||
// Conversion creates fresh geometry, including expanded shared hole calls;
|
||||
// no cloning/rotation of a live program or subprogram is necessary.
|
||||
return new OverlapSource(program, ConvertProgram.ToGeometry(program)
|
||||
// no cloning/rotation of a live program or subprogram is necessary. A baked-in
|
||||
// seam overcut is trimmed on an owned copy: it encloses no area.
|
||||
return new OverlapSource(program, ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
|
||||
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
|
||||
&& entity.Layer != SpecialLayers.Leadin
|
||||
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
|
||||
@@ -91,7 +140,7 @@ public static class PlateOverlapAnalyzer
|
||||
Analyze(Capture(parts, cancellationToken), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns deterministic pair reports containing closed world-coordinate overlap fragments.
|
||||
/// Returns deterministic pair reports containing closed world-coordinate overlap islands.
|
||||
/// Cancellation throws and publishes no partial report. Check IsComplete before claiming clear.
|
||||
/// </summary>
|
||||
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot,
|
||||
@@ -156,34 +205,15 @@ public static class PlateOverlapAnalyzer
|
||||
try
|
||||
{
|
||||
// Keep pair clipping arithmetic near the parts where possible, then
|
||||
// restore output to world space. Triangulation itself also uses stable
|
||||
// local-origin winding so tiny holes in a huge part remain correct.
|
||||
// restore output to world space.
|
||||
var origin = a.Material.Outer.Vertices[0];
|
||||
var localA = a.Material.Transform(0, origin * -1);
|
||||
var localB = b.Material.Transform(0, origin * -1);
|
||||
var result = Collision.Check(localA.Outer, localB.Outer, localA.Holes, localB.Holes);
|
||||
if (!result.Overlaps)
|
||||
if (!TryShare(localA, localB.Outer, localB.Holes, origin, cancellationToken,
|
||||
out var regions, out var centroid, out var marker))
|
||||
continue;
|
||||
// Evaluate every hole-subtracted fragment before restoring world space.
|
||||
// A failed moment must make this pair incomplete, never an origin marker.
|
||||
var moments = new List<PolygonAreaMoments>();
|
||||
var regions = new List<PlateOverlapRegion>();
|
||||
foreach (var region in result.OverlapRegions)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment))
|
||||
throw new ArithmeticException("Overlap fragment area moments are invalid.");
|
||||
moments.Add(fragment);
|
||||
regions.Add(new PlateOverlapRegion(region.Vertices.Select(point => point + origin),
|
||||
fragment.Area));
|
||||
}
|
||||
if (!PolygonAreaMoments.TryCombine(moments, out var combined))
|
||||
throw new ArithmeticException("Combined overlap area moments are invalid.");
|
||||
var centroid = combined.Centroid + origin;
|
||||
if (!OverlapMaterial.IsFinite(centroid))
|
||||
throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
|
||||
pairs.Add(new PlateOverlapPair(a.Input.Id, b.Input.Id,
|
||||
a.Input.Name, b.Input.Name, regions, centroid));
|
||||
a.Input.Name, b.Input.Name, regions, centroid, marker));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
@@ -192,9 +222,148 @@ public static class PlateOverlapAnalyzer
|
||||
}
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken);
|
||||
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
|
||||
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
|
||||
.ThenBy(issue => issue.PartBId).ToList(), snapshot);
|
||||
.ThenBy(issue => issue.PartBId).ToList(), snapshot, clampFindings
|
||||
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList(),
|
||||
snapshot.CutOffClampFindings.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C1 part-vs-clamp findings: the prepared hole-aware material against each captured
|
||||
/// zone rectangle with the same exact Collision oracle as part pairs. Deterministic in
|
||||
/// (part input id, clamp ordinal) order; failures become part issues like any other
|
||||
/// pair geometry failure. Empty zone lists return without touching the material.
|
||||
/// </summary>
|
||||
private static List<PlateOverlapPair> FindClampOverlaps(List<PreparedPart> prepared,
|
||||
PlateOverlapSnapshot snapshot, List<PlateOverlapIssue> issues,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var findings = new List<PlateOverlapPair>();
|
||||
if (snapshot.Clamps.Count == 0)
|
||||
return findings;
|
||||
|
||||
foreach (var part in prepared)
|
||||
{
|
||||
var partBounds = part.Material.Outer.BoundingBox;
|
||||
foreach (var clamp in snapshot.Clamps)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var zone = clamp.Bounds;
|
||||
// The prepared material is inscribed at ChordTolerance, so real material can
|
||||
// reach that far past its bounds; a tiny clamp there must still be checked.
|
||||
var reach = KeepOutOverlap.NeedsToleranceFreeTest(zone) ? ChordTolerance : 0;
|
||||
if (partBounds.Right + reach <= zone.Left || zone.Right <= partBounds.Left - reach
|
||||
|| partBounds.Top + reach <= zone.Bottom || zone.Top <= partBounds.Bottom - reach)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
// A clamp below the polygon tolerance collapses in Collision.Check and would
|
||||
// read as clear (PM aa995123): judge it tolerance-free and report the whole
|
||||
// (negligibly small) clamp rectangle as the overlap fragment.
|
||||
if (KeepOutOverlap.NeedsToleranceFreeTest(zone))
|
||||
{
|
||||
// Re-read the native contours conservatively and finely enough for
|
||||
// this clamp: the shared 0.001 chords can hide material between an
|
||||
// arc and its chord.
|
||||
var profile = new ShapeProfile(part.Input.Source.Entities.Select(e => e.Clone()).ToList());
|
||||
if (profile.Perimeter == null)
|
||||
throw new ArgumentException("Drawing has no closed material contour.");
|
||||
if (KeepOutOverlap.ConservativePolygons(profile, zone)
|
||||
.Select(polygons => new OverlapMaterial(polygons.Perimeter, polygons.Holes)
|
||||
.Transform(part.Input.Rotation, part.Input.Location))
|
||||
.Any(fine => KeepOutOverlap.MaterialEnters(fine.Outer, fine.Holes, zone)))
|
||||
findings.Add(TinyClampFinding(part, clamp));
|
||||
continue;
|
||||
}
|
||||
var origin = part.Material.Outer.Vertices[0];
|
||||
var local = part.Material.Transform(0, origin * -1);
|
||||
var rect = RectanglePolygon(zone, origin * -1);
|
||||
if (!TryShare(local, rect, null, origin, cancellationToken,
|
||||
out var regions, out var centroid, out var marker))
|
||||
continue;
|
||||
findings.Add(new PlateOverlapPair(part.Input.Id,
|
||||
ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
|
||||
$"Clamp '{clamp.Id}'", regions, centroid, marker));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(part.Input.Id, null, exception.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared material of a rebased operand with another rebased outline/holes, as world
|
||||
/// regions (connected islands) plus the area centroid of them all and a marker point
|
||||
/// inside the largest island. Returns false when the
|
||||
/// total shared area does not exceed <see cref="OpenNest.Math.Tolerance.Epsilon"/>: contact and rounding
|
||||
/// dust are not overlap. Invalid moments throw, so the pair becomes incomplete rather
|
||||
/// than clear or marked at the origin.
|
||||
/// </summary>
|
||||
private static bool TryShare(OverlapMaterial localA, Polygon outerB, List<Polygon> holesB, Vector origin,
|
||||
CancellationToken cancellationToken, out List<PlateOverlapRegion> regions, out Vector centroid,
|
||||
out Vector marker)
|
||||
{
|
||||
regions = null;
|
||||
centroid = default;
|
||||
marker = default;
|
||||
var islands = OverlapClipping.Intersect(localA.Outer, localA.Holes, outerB, holesB);
|
||||
var area = islands.Sum(island => island.Moments.Area);
|
||||
if (!double.IsFinite(area))
|
||||
throw new ArithmeticException("Overlap area moments are invalid.");
|
||||
if (area <= OpenNest.Math.Tolerance.Epsilon)
|
||||
return false;
|
||||
regions = new List<PlateOverlapRegion>(islands.Count);
|
||||
foreach (var island in islands)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
regions.Add(new PlateOverlapRegion(island.Outline.Select(point => point + origin), island.Moments.Area,
|
||||
island.Holes.Select(hole => hole.Select(point => point + origin))));
|
||||
}
|
||||
if (!PolygonAreaMoments.TryCombine(islands.Select(island => island.Moments), out var combined))
|
||||
throw new ArithmeticException("Combined overlap area moments are invalid.");
|
||||
centroid = combined.Centroid + origin;
|
||||
if (!OverlapMaterial.IsFinite(centroid))
|
||||
throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
|
||||
// The area centroid can lie between islands or in a hole: mark the largest island.
|
||||
var largest = islands.Aggregate((best, island) => island.Moments.Area > best.Moments.Area ? island : best);
|
||||
marker = OverlapMarker.Find(largest.Outline, largest.Holes, largest.Moments.Area, largest.Moments.Centroid)
|
||||
+ origin;
|
||||
if (!OverlapMaterial.IsFinite(marker))
|
||||
throw new ArithmeticException("Overlap marker is not finite in world coordinates.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static PlateOverlapPair TinyClampFinding(PreparedPart part, CapturedClamp clamp)
|
||||
{
|
||||
var zone = clamp.Bounds;
|
||||
var corners = new[]
|
||||
{
|
||||
new Vector(zone.Left, zone.Bottom), new Vector(zone.Right, zone.Bottom),
|
||||
new Vector(zone.Right, zone.Top), new Vector(zone.Left, zone.Top),
|
||||
new Vector(zone.Left, zone.Bottom), // Regions repeat their closing vertex exactly.
|
||||
};
|
||||
var centre = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2);
|
||||
return new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
|
||||
$"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, centre,
|
||||
centre);
|
||||
}
|
||||
|
||||
private static Polygon RectanglePolygon(Box box, Vector offset)
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Bottom + offset.Y));
|
||||
polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Bottom + offset.Y));
|
||||
polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Top + offset.Y));
|
||||
polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Top + offset.Y));
|
||||
polygon.Close();
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -8,11 +8,14 @@ namespace OpenNest.Diagnostics;
|
||||
public sealed class PlateOverlapReport
|
||||
{
|
||||
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
|
||||
PlateOverlapSnapshot snapshot)
|
||||
PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null,
|
||||
List<CutOffClampFinding> cutOffClampFindings = null)
|
||||
{
|
||||
Pairs = pairs.AsReadOnly();
|
||||
Issues = issues.AsReadOnly();
|
||||
Snapshot = snapshot;
|
||||
ClampFindings = (clampFindings ?? new List<PlateOverlapPair>()).AsReadOnly();
|
||||
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
|
||||
@@ -20,6 +23,16 @@ public sealed class PlateOverlapReport
|
||||
|
||||
public IReadOnlyList<PlateOverlapPair> Pairs { get; }
|
||||
public IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Part-vs-clamp-zone findings (C1): the same owned-fragment shape as part pairs, with a
|
||||
/// negative <see cref="PlateOverlapPair.PartBId"/> slot encoding the zone ordinal. Findings
|
||||
/// are reported like other overlap findings; they do not change <see cref="IsComplete"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PlateOverlapPair> ClampFindings { get; }
|
||||
/// <summary>Nominal cut-off/clamp contacts. Lines have no material area or part pair.</summary>
|
||||
public IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
|
||||
|
||||
public bool IsComplete => Issues.Count == 0;
|
||||
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
|
||||
}
|
||||
@@ -39,6 +52,7 @@ public sealed class PlateOverlapPair
|
||||
Regions = source.Regions;
|
||||
Area = source.Area;
|
||||
Centroid = source.Centroid;
|
||||
Marker = source.Marker;
|
||||
bounds = source.bounds;
|
||||
}
|
||||
|
||||
@@ -47,7 +61,7 @@ public sealed class PlateOverlapPair
|
||||
new(this, partAId, partBId, partAName, partBName);
|
||||
|
||||
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
|
||||
List<PlateOverlapRegion> regions, Vector centroid)
|
||||
List<PlateOverlapRegion> regions, Vector centroid, Vector marker)
|
||||
{
|
||||
PartAId = partAId;
|
||||
PartBId = partBId;
|
||||
@@ -56,6 +70,7 @@ public sealed class PlateOverlapPair
|
||||
Regions = regions.AsReadOnly();
|
||||
Area = regions.Sum(region => region.Area);
|
||||
Centroid = centroid;
|
||||
Marker = marker;
|
||||
var points = regions.SelectMany(region => region.Vertices).ToArray();
|
||||
var left = points.Min(point => point.X);
|
||||
var bottom = points.Min(point => point.Y);
|
||||
@@ -67,7 +82,7 @@ public sealed class PlateOverlapPair
|
||||
public int PartBId { get; }
|
||||
public string PartAName { get; }
|
||||
public string PartBName { get; }
|
||||
/// <summary>Convex fragments, not connected islands; no mutable kernel polygons are exposed.</summary>
|
||||
/// <summary>Connected islands of shared material; no mutable kernel polygons are exposed.</summary>
|
||||
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
|
||||
public double Area { get; }
|
||||
/// <summary>
|
||||
@@ -75,21 +90,47 @@ public sealed class PlateOverlapPair
|
||||
/// This can lie outside disconnected or concave shared material. Returned by value.
|
||||
/// </summary>
|
||||
public Vector Centroid { get; }
|
||||
/// <summary>
|
||||
/// Finite world-coordinate point on the shared material, inside the pair's largest island:
|
||||
/// its pole of inaccessibility, or an interior scan-line point when a thin island defeats
|
||||
/// the bounded pole search. Markers, hit testing and inspection rings use this point.
|
||||
/// A tolerance-free tiny-clamp finding (a clamp narrower than the chord tolerance) keeps
|
||||
/// its conservative whole-clamp region, so its marker is the clamp's centre, within half
|
||||
/// the clamp's size of the material. Returned by value.
|
||||
/// </summary>
|
||||
public Vector Marker { get; }
|
||||
/// <summary>A fresh world-coordinate bounds copy.</summary>
|
||||
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
||||
}
|
||||
|
||||
/// <summary>A positive-area, hole-subtracted convex polygon in world coordinates.</summary>
|
||||
/// <summary>
|
||||
/// One connected island of shared material in world coordinates, after hole subtraction:
|
||||
/// a counterclockwise outline and its clockwise holes.
|
||||
/// </summary>
|
||||
public sealed class PlateOverlapRegion
|
||||
{
|
||||
internal PlateOverlapRegion(IEnumerable<Vector> vertices, double area)
|
||||
private static readonly IReadOnlyList<IReadOnlyList<Vector>> NoHoles = System.Array.AsReadOnly(
|
||||
System.Array.Empty<IReadOnlyList<Vector>>());
|
||||
|
||||
internal PlateOverlapRegion(IEnumerable<Vector> vertices, double area,
|
||||
IEnumerable<IEnumerable<Vector>> holes = null)
|
||||
{
|
||||
Vertices = System.Array.AsReadOnly(vertices.ToArray());
|
||||
Area = area;
|
||||
Holes = holes == null
|
||||
? NoHoles
|
||||
: System.Array.AsReadOnly(holes
|
||||
.Select(hole => (IReadOnlyList<Vector>)System.Array.AsReadOnly(hole.ToArray()))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Read-only vertices with an exactly repeated closing vertex.</summary>
|
||||
/// <summary>Read-only counterclockwise outline with an exactly repeated closing vertex.</summary>
|
||||
public IReadOnlyList<Vector> Vertices { get; }
|
||||
|
||||
/// <summary>Read-only clockwise hole rings, each with an exactly repeated closing vertex.</summary>
|
||||
public IReadOnlyList<IReadOnlyList<Vector>> Holes { get; }
|
||||
|
||||
/// <summary>Positive net area: the outline's area minus its holes'.</summary>
|
||||
public double Area { get; }
|
||||
}
|
||||
|
||||
@@ -102,15 +143,32 @@ public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message
|
||||
/// </summary>
|
||||
public sealed class PlateOverlapSnapshot
|
||||
{
|
||||
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues)
|
||||
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues,
|
||||
List<CapturedClamp> clamps = null, List<CutOffClampFinding> cutOffClampFindings = null)
|
||||
{
|
||||
Parts = parts.AsReadOnly();
|
||||
Issues = issues.AsReadOnly();
|
||||
Clamps = (clamps ?? new List<CapturedClamp>()).AsReadOnly();
|
||||
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
|
||||
}
|
||||
|
||||
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
|
||||
internal IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||
|
||||
/// <summary>Owned clamp keep-out rectangles in plate coordinates, capture order.</summary>
|
||||
internal IReadOnlyList<CapturedClamp> Clamps { get; }
|
||||
internal IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
|
||||
}
|
||||
|
||||
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
|
||||
double Rotation, Vector Location);
|
||||
|
||||
/// <summary>A clamp keep-out rectangle owned by the snapshot; Ordinal is its capture index.</summary>
|
||||
internal sealed record CapturedClamp(int Ordinal, string Id, Box Bounds);
|
||||
|
||||
/// <summary>Deterministic negative PartBId for a part-vs-clamp finding's clamp slot.</summary>
|
||||
internal static class ClampFindingSlot
|
||||
{
|
||||
public static int IdFor(int ordinal) => -2 - ordinal;
|
||||
public static int OrdinalOf(int pairPartBId) => -2 - pairPartBId;
|
||||
}
|
||||
@@ -56,6 +56,51 @@ public static class PostVerificationAnalyzer
|
||||
}
|
||||
var materialParts = new List<Part>();
|
||||
var indices = new List<int>();
|
||||
// Stored zones use the sheet's lower-left frame; motions and parts are signed.
|
||||
// Validate them before any early exit: zone bounds are publicly mutable.
|
||||
IReadOnlyList<ClampZone> clamps;
|
||||
try
|
||||
{
|
||||
clamps = ClampZoneFrame.ToPlacement(plate);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
clamps = null;
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||
"Clamp check: " + exception.Message));
|
||||
}
|
||||
// One clamp warning per cut-off part and clamp: the placed-program check below
|
||||
// reports only what the nominal definition check has not already reported.
|
||||
var cutOffClampContacts = new HashSet<(int PartNumber, string ClampId)>();
|
||||
if (clamps != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var contact in ClampCutDiagnostics.Find(plate))
|
||||
{
|
||||
var cut = plate.CutOffs[contact.CutOffIndex];
|
||||
var partIndex = -1;
|
||||
for (var partSlot = 0; partSlot < plate.Parts.Count; partSlot++)
|
||||
if (ReferenceEquals(plate.Parts[partSlot]?.BaseDrawing, cut.Drawing))
|
||||
{
|
||||
partIndex = partSlot;
|
||||
break;
|
||||
}
|
||||
if (partIndex >= 0)
|
||||
cutOffClampContacts.Add((partIndex + 1, contact.ClampId));
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber,
|
||||
partIndex < 0 ? null : partIndex + 1, null,
|
||||
$"Manual cut-off {contact.CutOffIndex + 1} crosses active clamp '{contact.ClampId}'. "
|
||||
+ "Review the clamp and cut before posting; this warning is not a repair.")
|
||||
{ Location = contact.Location, ClampId = contact.ClampId });
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||
"Clamp/cut-off check: " + exception.Message));
|
||||
}
|
||||
}
|
||||
var obstacles = new ReleasedContourState();
|
||||
Vector? position = startPoint;
|
||||
for (var index = 0; index < plate.Parts.Count; index++)
|
||||
@@ -90,11 +135,57 @@ public static class PostVerificationAnalyzer
|
||||
if (part == null || !double.IsFinite(part.Rotation))
|
||||
throw new ArgumentException("Missing part or invalid rotation.");
|
||||
var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, cancellationToken).Motions;
|
||||
// Executed reads stop at an applied program's display tail, so any display
|
||||
// perimeter left is an unplanned blank whose outline a post would refuse.
|
||||
if (moves.Any(move => move.Layer == LayerType.DisplayPerimeter))
|
||||
Incomplete("This part has a display perimeter (a supplied blank's outline that is never cut) "
|
||||
+ "but no cutting plan; posts refuse it. Plan cutting first (Plate > Plan Cutting..., or --plan-cutting) so only its cutouts are cut.");
|
||||
if (expectsCuts && !moves.Any(move => !move.Rapid
|
||||
&& move.Layer is LayerType.Cut or LayerType.Display
|
||||
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
|
||||
Incomplete("Placed program has no cutting contour motions for this drawing.");
|
||||
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken);
|
||||
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken,
|
||||
proveOpenCuts: true);
|
||||
if (clamps is { Count: > 0 })
|
||||
foreach (var move in moves)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!move.Rapid || move.Start is not { } from
|
||||
|| from.X == move.End.X && from.Y == move.End.Y)
|
||||
continue;
|
||||
foreach (var zone in clamps)
|
||||
if (ClampCutDiagnostics.Intersects(from, move.End, zone.Bounds, out var contact))
|
||||
findings.Add(new(PostVerificationKind.RapidCrossing, plateNumber,
|
||||
partNumber, null,
|
||||
$"Direct XY rapid crosses active clamp '{zone.Id}'. "
|
||||
+ "Head height is not modeled; review the posted route and clamp setup.")
|
||||
{
|
||||
Location = (from + move.End) * 0.5,
|
||||
RapidStart = from,
|
||||
RapidEnd = move.End,
|
||||
ContactPoints = new[] { contact },
|
||||
ClampId = zone.Id
|
||||
});
|
||||
}
|
||||
// The placed program is what posts: a definition edited without
|
||||
// regenerating it can still cut through a clamp its nominal line misses.
|
||||
if (cutoff && clamps is { Count: > 0 })
|
||||
foreach (var move in moves)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (move.Rapid || move.Start is not { } from)
|
||||
continue;
|
||||
foreach (var zone in clamps)
|
||||
if (!cutOffClampContacts.Contains((partNumber, zone.Id))
|
||||
&& CutTouches(move, from, zone.Bounds, out var contact))
|
||||
{
|
||||
cutOffClampContacts.Add((partNumber, zone.Id));
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber, partNumber, null,
|
||||
$"The placed cut-off program crosses active clamp '{zone.Id}'. "
|
||||
+ "Regenerate or move the cut-off before posting; this warning is not a repair.")
|
||||
{ Location = contact, ClampId = zone.Id });
|
||||
}
|
||||
}
|
||||
position = moves[^1].End;
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
@@ -109,19 +200,50 @@ public static class PostVerificationAnalyzer
|
||||
plateNumber, partNumber, null, message));
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
// Leads Plan Cutting accepted as given (locked and drawing-pinned) are checked again here.
|
||||
var fixedMotions = new Dictionary<int, Func<int, bool>>();
|
||||
FixedLeadReview.Add(plate, plateNumber, findings, clamps, cancellationToken, fixedMotions);
|
||||
// Parts nested in cutouts: inner-before-host order and leads across the pair.
|
||||
NestedPartReview.Add(plate, plateNumber, findings, fixedMotions, cancellationToken);
|
||||
// Lead-in pierces from any source keep the pierce diameter's zone clear of material.
|
||||
PierceZoneReview.Add(plate, plateNumber, findings, cancellationToken);
|
||||
var overlap = PlateOverlapAnalyzer.Analyze(
|
||||
PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken);
|
||||
PlateOverlapAnalyzer.Capture(materialParts, (clamps ?? Array.Empty<ClampZone>()).ToArray(),
|
||||
cancellationToken),
|
||||
cancellationToken);
|
||||
foreach (var pair in overlap.Pairs)
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
|
||||
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted)."));
|
||||
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted).")
|
||||
{ Location = pair.Marker, Overlap = pair });
|
||||
foreach (var pair in overlap.ClampFindings)
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
|
||||
null, $"Clean drawing material overlaps {pair.PartBName} (holes subtracted).")
|
||||
{ Location = pair.Marker, Overlap = pair });
|
||||
foreach (var issue in overlap.Issues)
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
|
||||
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
|
||||
// Parts that do not overlap may still be closer than the plate's part spacing.
|
||||
PartSpacingReview.Add(plate, plateNumber, materialParts, indices, overlap, findings, cancellationToken);
|
||||
// Parts the support bed cannot hold level need a tab that keeps them level.
|
||||
PartSupportReview.Add(plate, plateNumber, findings, cancellationToken);
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PostVerificationReport(findings);
|
||||
}
|
||||
|
||||
// Lines are tested exactly; an arc conservatively by its whole supporting extent.
|
||||
internal static bool CutTouches(ExecutionMotion move, Vector from, Box zone, out Vector contact)
|
||||
{
|
||||
if (move.Curve == null || move.Curve.IsLine)
|
||||
return ClampCutDiagnostics.Intersects(from, move.End, zone, out contact);
|
||||
var extent = move.Curve.Extent;
|
||||
contact = new Vector(
|
||||
System.Math.Clamp(move.Curve.Midpoint.X, zone.Left, zone.Right),
|
||||
System.Math.Clamp(move.Curve.Midpoint.Y, zone.Bottom, zone.Top));
|
||||
return !(extent.MinX > zone.Right || extent.MaxX < zone.Left
|
||||
|| extent.MinY > zone.Top || extent.MaxY < zone.Bottom);
|
||||
}
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ namespace OpenNest.Diagnostics;
|
||||
internal static class PostVerificationGeometry
|
||||
{
|
||||
internal const double Epsilon = 1e-8;
|
||||
// Matches NominalContourClosure.Tolerance: the largest gap capture admits as a closed ring.
|
||||
// Ray parity snaps vertex Y comparisons within this band so an admitted seam cannot flip an
|
||||
// exterior point to interior (see Curve.CrossesRay).
|
||||
internal const double RaySeamTolerance = 1e-6;
|
||||
private const double TwoPi = 2 * System.Math.PI;
|
||||
|
||||
/// <summary>An axis-aligned extent used only to skip queries that cannot meet.</summary>
|
||||
@@ -40,13 +44,41 @@ internal static class PostVerificationGeometry
|
||||
/// are more than <see cref="ClearMargin"/> apart on some axis, so no native query can count
|
||||
/// anything in one as touching or entering the other. Everything else must be checked.
|
||||
/// </summary>
|
||||
internal bool IsClearOf(Extent other) => IsWellConditioned && other.IsWellConditioned
|
||||
&& (MaxX + ClearMargin < other.MinX || other.MaxX + ClearMargin < MinX
|
||||
|| MaxY + ClearMargin < other.MinY || other.MaxY + ClearMargin < MinY);
|
||||
internal bool IsClearOf(Extent other) => IsClearOf(other, ClearMargin);
|
||||
|
||||
/// <summary>
|
||||
/// The relative rounding allowance of native line/circle intersections. Their discriminant
|
||||
/// b^2 - 4ac cancels: its absolute error stays below about 30 units of roundoff times
|
||||
/// |d1|^2 (|d2|^2 + r^2) (d1 the line, d2 from the circle's centre to the line's start), so a
|
||||
/// spurious root can lie at most about 1e-7 times the span of both extents beyond the true
|
||||
/// distance between line and circle (about 2e-9 is seen in practice). A long lead beside a
|
||||
/// small circle needs the full check; a short lead beside a nearby part does not.
|
||||
/// </summary>
|
||||
internal const double SpanRounding = 1e-6;
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="IsClearOf(Extent)"/>, with a gap that also covers the rounding of native
|
||||
/// line/circle queries over the span of both extents (<see cref="SpanRounding"/>), plus
|
||||
/// <paramref name="reach"/>. Lead checks skip a material only on this test.
|
||||
/// </summary>
|
||||
internal bool IsClearOfQueriesFrom(Extent other, double reach = 0)
|
||||
{
|
||||
var union = Union(other);
|
||||
var span = System.Math.Max(union.MaxX - union.MinX, union.MaxY - union.MinY);
|
||||
return IsClearOf(other, ClearMargin + reach + SpanRounding * span);
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="y"/> lies more than <see cref="ClearMargin"/> outside the Y range.</summary>
|
||||
internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit
|
||||
&& (y > MaxY + ClearMargin || y < MinY - ClearMargin);
|
||||
|
||||
private bool IsClearOf(Extent other, double margin) => IsWellConditioned && other.IsWellConditioned
|
||||
&& (MaxX + margin < other.MinX || other.MaxX + margin < MinX
|
||||
|| MaxY + margin < other.MinY || other.MaxY + margin < MinY);
|
||||
|
||||
// Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds
|
||||
// (including the empty extent's) fail this test too.
|
||||
private bool IsWellConditioned =>
|
||||
internal bool IsWellConditioned =>
|
||||
System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)),
|
||||
System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))) <= WellConditionedLimit;
|
||||
}
|
||||
@@ -78,17 +110,80 @@ internal static class PostVerificationGeometry
|
||||
// If there are no contacts except possibly departure, all open-segment points
|
||||
// have the same inside/outside status. A midpoint catches travel entirely inside
|
||||
// and departure into the interior, without flagging start-only outward contact.
|
||||
var midpoint = start + delta * 0.5;
|
||||
return Inside(start + delta * 0.5, curves, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ray parity of <paramref name="point"/> against closed <paramref name="curves"/>, cast at
|
||||
/// <see cref="RayY"/> so no vertex of them lies on the ray.
|
||||
/// </summary>
|
||||
internal static bool Inside(Vector point, IReadOnlyList<Curve> curves, CancellationToken token)
|
||||
{
|
||||
var rayY = RayY(point, curves, token);
|
||||
var inside = false;
|
||||
foreach (var curve in curves)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (curve.CrossesRay(midpoint))
|
||||
if (curve.CrossesRay(point, rayY))
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The height a parity ray from <paramref name="point"/> runs at: just above it, by about
|
||||
/// <see cref="RaySeamTolerance"/>, and clear of every vertex height of <paramref name="curves"/>.
|
||||
/// The two copies of a seam vertex can differ in the last bits; a ray running between them
|
||||
/// counts one edge and not the other, so a point that happens to sit a tolerance below another
|
||||
/// contour's vertex (text baselines a micro-inch apart) would read as inside it.
|
||||
/// </summary>
|
||||
internal static double RayY(Vector point, IReadOnlyList<Curve> curves, CancellationToken token)
|
||||
{
|
||||
const int steps = 8;
|
||||
const double band = RaySeamTolerance / (4 * steps);
|
||||
for (var step = 0; step < steps; step++)
|
||||
{
|
||||
var rayY = point.Y + RaySeamTolerance * (1 + (double)step / steps);
|
||||
var clear = true;
|
||||
foreach (var curve in curves)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (curve.HasVertexNear(rayY, band))
|
||||
{
|
||||
clear = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (clear)
|
||||
return rayY;
|
||||
}
|
||||
return point.Y + RaySeamTolerance;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<Vector> ContactPoints(Vector start, Vector end,
|
||||
IReadOnlyList<Curve> curves, CancellationToken token)
|
||||
{
|
||||
var length = start.DistanceTo(end);
|
||||
if (length <= Epsilon)
|
||||
return Array.Empty<Vector>();
|
||||
var direction = (end - start) * (1 / length);
|
||||
var distances = new List<double>();
|
||||
foreach (var curve in curves)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
curve.ContactAfterStart(start, direction, length, distances);
|
||||
}
|
||||
distances.Sort();
|
||||
var points = new List<Vector>();
|
||||
foreach (var distance in distances)
|
||||
{
|
||||
var point = start + direction * distance;
|
||||
if (points.Count == 0 || points[^1].DistanceTo(point) > Epsilon)
|
||||
points.Add(point);
|
||||
}
|
||||
return points.AsReadOnly();
|
||||
}
|
||||
|
||||
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
|
||||
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
||||
private static double Normalize(double angle)
|
||||
@@ -97,6 +192,10 @@ internal static class PostVerificationGeometry
|
||||
return angle < 0 ? angle + TwoPi : angle;
|
||||
}
|
||||
|
||||
/// <summary>Exact identity of a <see cref="Curve"/>'s defining values (see <see cref="Curve.Key"/>).</summary>
|
||||
internal readonly record struct CurveKey(long StartX, long StartY, long EndX, long EndY,
|
||||
bool HasCenter, long CenterX, long CenterY, long Radius, long Sweep);
|
||||
|
||||
internal sealed class Curve
|
||||
{
|
||||
private Curve(Vector start, Vector end, Vector? center, double radius, double sweep)
|
||||
@@ -106,14 +205,20 @@ internal static class PostVerificationGeometry
|
||||
Center = center;
|
||||
Radius = radius;
|
||||
Sweep = sweep;
|
||||
nativeEntity = new Lazy<Entity>(ToEntity);
|
||||
}
|
||||
|
||||
// Never expose this mutable native wrapper; its geometry is read-only after creation.
|
||||
// Lazy preserves the old conversion point for unsupported curves and is safe for concurrent checks.
|
||||
private readonly Lazy<Entity> nativeEntity;
|
||||
|
||||
internal Vector Start { get; }
|
||||
internal Vector End { get; }
|
||||
private Vector? Center { get; }
|
||||
private double Radius { get; }
|
||||
private double Sweep { get; }
|
||||
internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End);
|
||||
internal bool IsLine => !Center.HasValue;
|
||||
|
||||
// Native entities are freshly allocated; the immutable curve never exposes state.
|
||||
internal Entity ToEntity() => Center is { } center
|
||||
@@ -138,20 +243,41 @@ internal static class PostVerificationGeometry
|
||||
center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2))
|
||||
: (Start + End) * 0.5;
|
||||
|
||||
internal bool SameSupport(Curve other)
|
||||
/// <summary>
|
||||
/// The exact bits of every value a native query reads, so two curves with equal keys
|
||||
/// give identical query results. Not a geometric tolerance: equal shapes built from
|
||||
/// different roundoff get different keys.
|
||||
/// </summary>
|
||||
internal CurveKey Key => new(Bits(Start.X), Bits(Start.Y), Bits(End.X), Bits(End.Y),
|
||||
Center.HasValue, Bits(Center?.X ?? 0), Bits(Center?.Y ?? 0), Bits(Radius), Bits(Sweep));
|
||||
|
||||
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||
|
||||
internal bool SameSupport(Curve other) => SameSupport(other, Epsilon);
|
||||
|
||||
/// <summary>
|
||||
/// Support agreement with an explicit positional allowance. Used only where an upstream
|
||||
/// stage has already admitted a larger nominal discrepancy — currently the inclusive
|
||||
/// <see cref="NominalContourClosure.Tolerance"/> joint that a replayed ring bridges. It is
|
||||
/// never a collision tolerance: safety queries keep the exact epsilon.
|
||||
/// </summary>
|
||||
internal bool SameSupport(Curve other, double positionTolerance)
|
||||
{
|
||||
if (Center is { } center)
|
||||
return other.Center is { } c && center.DistanceTo(c) <= Epsilon
|
||||
&& System.Math.Abs(Radius - other.Radius) <= Epsilon;
|
||||
if (other.Center.HasValue || Length <= Epsilon || other.Length <= Epsilon)
|
||||
return other.Center is { } c && center.DistanceTo(c) <= positionTolerance
|
||||
&& System.Math.Abs(Radius - other.Radius) <= positionTolerance;
|
||||
if (other.Center.HasValue || Length <= positionTolerance || other.Length <= positionTolerance)
|
||||
return false;
|
||||
var direction = (End - Start) * (1 / Length);
|
||||
return System.Math.Abs(Cross(other.Start - Start, direction)) <= Epsilon
|
||||
&& System.Math.Abs(Cross(other.End - Start, direction)) <= Epsilon;
|
||||
return System.Math.Abs(Cross(other.Start - Start, direction)) <= positionTolerance
|
||||
&& System.Math.Abs(Cross(other.End - Start, direction)) <= positionTolerance;
|
||||
}
|
||||
|
||||
// Directed native arc-length coordinates for contour replay, not collision queries.
|
||||
internal bool SameDirection(Curve other) => SameSupport(other)
|
||||
internal bool SameDirection(Curve other) => SameDirection(other, Epsilon);
|
||||
|
||||
/// <summary>Directed coverage check with an admitted positional allowance; direction sign stays exact.</summary>
|
||||
internal bool SameDirection(Curve other, double positionTolerance) => SameSupport(other, positionTolerance)
|
||||
&& (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep)
|
||||
: Dot(End - Start, other.End - other.Start) > 0);
|
||||
|
||||
@@ -160,6 +286,25 @@ internal static class PostVerificationGeometry
|
||||
: Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)) * Radius
|
||||
: Dot(point - Start, (End - Start) * (1 / Length));
|
||||
|
||||
/// <summary>Unit direction of travel at the curve's start or end.</summary>
|
||||
internal Vector Tangent(bool atEnd)
|
||||
{
|
||||
if (Center is not { } center)
|
||||
return (End - Start) * (1 / Length);
|
||||
var radial = (atEnd ? End : Start) - center;
|
||||
var turn = System.Math.Sign(Sweep);
|
||||
return new Vector(-radial.Y * turn, radial.X * turn) * (1 / Radius);
|
||||
}
|
||||
|
||||
/// <summary>How far the curve reaches from <paramref name="origin"/> along unit <paramref name="direction"/>.</summary>
|
||||
internal double Reach(Vector origin, Vector direction)
|
||||
{
|
||||
var reach = System.Math.Max(Dot(Start - origin, direction), Dot(End - origin, direction));
|
||||
if (Center is { } center && OnArc(center + direction * Radius))
|
||||
reach = System.Math.Max(reach, Dot(center - origin, direction) + Radius);
|
||||
return reach;
|
||||
}
|
||||
|
||||
internal Vector PointAtLength(double distance)
|
||||
{
|
||||
if (distance <= 0) return Start;
|
||||
@@ -170,12 +315,40 @@ internal static class PostVerificationGeometry
|
||||
: Start + (End - Start) * (distance / Length);
|
||||
}
|
||||
|
||||
internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon;
|
||||
/// <summary>Exact native distance from <paramref name="point"/> to this finite curve.</summary>
|
||||
internal double DistanceTo(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point);
|
||||
|
||||
/// <summary>The native point of this finite curve closest to <paramref name="point"/>.</summary>
|
||||
internal Vector ClosestPoint(Vector point) => nativeEntity.Value.ClosestPointTo(point);
|
||||
|
||||
/// <summary>
|
||||
/// This curve from its start up to <paramref name="point"/>, which must be one of its own
|
||||
/// interior points (see <see cref="ClosestPoint"/>); an arc keeps its center and radius.
|
||||
/// </summary>
|
||||
internal Curve Until(Vector point) => Center is { } center
|
||||
? new Curve(Start, point, center, Radius,
|
||||
System.Math.Sign(Sweep) * Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)))
|
||||
: new Curve(Start, point, null, 0, 0);
|
||||
|
||||
internal bool Contains(Vector point) => Contains(point, Epsilon);
|
||||
|
||||
/// <summary>On-support test with an admitted positional allowance (see <see cref="SameSupport(Curve, double)"/>).</summary>
|
||||
internal bool Contains(Vector point, double positionTolerance)
|
||||
=> nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= positionTolerance;
|
||||
|
||||
/// <summary>
|
||||
/// Filters supporting-circle contacts against the actual finite arc span. The
|
||||
/// native angular padding is larger and must not turn an arc extension into a
|
||||
/// self-intersection at an adjacent nominal joint. This is not a radial test.
|
||||
/// </summary>
|
||||
internal bool IsOutsideArcSpan(Vector point) => Center.HasValue && !OnArc(point);
|
||||
|
||||
internal bool IsFiniteArc => Center.HasValue && System.Math.Abs(Sweep) < TwoPi;
|
||||
|
||||
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
|
||||
{
|
||||
var entity = ToEntity();
|
||||
var candidate = other.ToEntity();
|
||||
var entity = nativeEntity.Value;
|
||||
var candidate = other.nativeEntity.Value;
|
||||
// Native arc filters discard NaN supporting-circle intersections. Inspect
|
||||
// both unfiltered queries first: roundoff can differ by operand direction.
|
||||
// Coincident supports have separate overlap handling below.
|
||||
@@ -203,18 +376,21 @@ internal static class PostVerificationGeometry
|
||||
points.Clear();
|
||||
// Coincident circles produce NaNs in the native discrete-contact query.
|
||||
// Their support overlap is handled separately, without inventing crossings.
|
||||
overlap = SameSupport(other) && (InteriorWitness(Midpoint, other)
|
||||
|| InteriorWitness(other.Midpoint, this)
|
||||
|| InteriorWitness(Start, other) || InteriorWitness(End, other)
|
||||
|| InteriorWitness(other.Start, this) || InteriorWitness(other.End, this));
|
||||
// Supports that agree only within Epsilon (arcs of one circle whose centers were
|
||||
// each derived from their own endpoints) put a witness up to that far off the other
|
||||
// support. Test those spans at the admitted seam tolerance instead: shared spans
|
||||
// still refuse, while arcs that only meet at a joint or lie apart are not overlap.
|
||||
var witness = Center is { } c && other.Center is { } oc
|
||||
&& (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius) ? RaySeamTolerance : Epsilon;
|
||||
overlap = SameSupport(other) && (InteriorWitness(Midpoint, other, witness)
|
||||
|| InteriorWitness(other.Midpoint, this, witness)
|
||||
|| InteriorWitness(Start, other, witness) || InteriorWitness(End, other, witness)
|
||||
|| InteriorWitness(other.Start, this, witness) || InteriorWitness(other.End, this, witness));
|
||||
if (SameSupport(other))
|
||||
points.Clear();
|
||||
foreach (var point in points)
|
||||
Validate(point);
|
||||
var nativeContactCount = points.Count;
|
||||
if (Center is { } c && other.Center is { } oc && SameSupport(other)
|
||||
&& (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius))
|
||||
overlap = true; // Nearly coincident supports are uncertain, never clear.
|
||||
foreach (var point in new[] { Start, End, other.Start, other.End })
|
||||
if (Contains(point) && other.Contains(point)
|
||||
&& !points.Exists(p => p.DistanceTo(point) <= Epsilon))
|
||||
@@ -227,9 +403,9 @@ internal static class PostVerificationGeometry
|
||||
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
||||
return points;
|
||||
|
||||
static bool InteriorWitness(Vector point, Curve curve) => curve.Contains(point)
|
||||
static bool InteriorWitness(Vector point, Curve curve, double tolerance) => curve.Contains(point, tolerance)
|
||||
&& (curve.Start.DistanceTo(curve.End) <= Epsilon
|
||||
|| (point.DistanceTo(curve.Start) > Epsilon && point.DistanceTo(curve.End) > Epsilon));
|
||||
|| (point.DistanceTo(curve.Start) > tolerance && point.DistanceTo(curve.End) > tolerance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -281,7 +457,8 @@ internal static class PostVerificationGeometry
|
||||
// The largest distance from the centre that ContactAfterStart can count as contact.
|
||||
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack);
|
||||
|
||||
internal bool ContactAfterStart(Vector origin, Vector direction, double length)
|
||||
internal bool ContactAfterStart(Vector origin, Vector direction, double length,
|
||||
List<double> contacts = null)
|
||||
{
|
||||
if (Center is { } center)
|
||||
{
|
||||
@@ -294,10 +471,13 @@ internal static class PostVerificationGeometry
|
||||
if (square < -ContactSlack)
|
||||
return false;
|
||||
var offset = System.Math.Sqrt(System.Math.Max(0, square));
|
||||
return Hit(projection - offset) || Hit(projection + offset);
|
||||
return contacts == null
|
||||
? Hit(projection - offset) || Hit(projection + offset)
|
||||
: Hit(projection - offset) | Hit(projection + offset);
|
||||
|
||||
bool Hit(double distance) => distance > Epsilon && distance <= length + Epsilon
|
||||
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length));
|
||||
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length))
|
||||
&& Record(distance);
|
||||
}
|
||||
var edge = End - Start;
|
||||
var relativeStart = Start - origin;
|
||||
@@ -310,19 +490,48 @@ internal static class PostVerificationGeometry
|
||||
var b = Dot(End - origin, direction);
|
||||
var low = System.Math.Max(0, System.Math.Min(a, b));
|
||||
var high = System.Math.Min(length, System.Math.Max(a, b));
|
||||
return high > Epsilon && low <= high + Epsilon;
|
||||
return high > Epsilon && low <= high + Epsilon && Record(low) && Record(high);
|
||||
}
|
||||
var distanceAlongRapid = Cross(relativeStart, edge) / denominator;
|
||||
var fractionAlongEdge = Cross(relativeStart, direction) / denominator;
|
||||
return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon
|
||||
&& fractionAlongEdge >= -Epsilon / System.Math.Max(Length, Epsilon)
|
||||
&& fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon);
|
||||
&& fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon)
|
||||
&& Record(distanceAlongRapid);
|
||||
|
||||
bool Record(double distance)
|
||||
{
|
||||
contacts?.Add(System.Math.Clamp(distance, 0, length));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool CrossesRay(Vector point)
|
||||
/// <summary>
|
||||
/// Whether an endpoint, or the top or bottom of an arc's circle, lies within
|
||||
/// <paramref name="band"/> of height <paramref name="y"/>.
|
||||
/// </summary>
|
||||
internal bool HasVertexNear(double y, double band) =>
|
||||
System.Math.Abs(Start.Y - y) <= band || System.Math.Abs(End.Y - y) <= band
|
||||
|| Center is { } center && (System.Math.Abs(center.Y + Radius - y) <= band
|
||||
|| System.Math.Abs(center.Y - Radius - y) <= band);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a ray running in +X from <paramref name="point"/>, at height
|
||||
/// <paramref name="rayY"/> (see <see cref="RayY"/>), crosses this curve.
|
||||
/// </summary>
|
||||
internal bool CrossesRay(Vector point, double rayY)
|
||||
{
|
||||
// Ray parity needs both edges meeting at a shared vertex to agree which side of the ray
|
||||
// that vertex is on. Rings are only nominally closed: capture admits closure seams and
|
||||
// PEP-converted or rotated geometry carries sub-micro-inch vertex jogs, so an exact
|
||||
// comparison can split one vertex across the ray and report far-away exterior points as
|
||||
// interior — which made LeadPathValidator refuse leads that could touch nothing.
|
||||
// Snapping within the admitted seam tolerance keeps the decision consistent on both
|
||||
// edges; real features (notches, tabs, arc spans) are far larger than the tolerance and
|
||||
// still count. Containment of a point that itself lies within the tolerance of a
|
||||
// boundary edge stays ambiguous by design, exactly as it was before.
|
||||
if (Center is not { } center)
|
||||
return (Start.Y > point.Y) != (End.Y > point.Y)
|
||||
return (Start.Y > rayY) != (End.Y > rayY)
|
||||
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
|
||||
|
||||
// Split arcs at vertical extrema, giving monotone-Y pieces. Apply the same
|
||||
@@ -343,7 +552,7 @@ internal static class PostVerificationGeometry
|
||||
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
|
||||
var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a);
|
||||
var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * System.Math.Sin(b);
|
||||
if ((ya > point.Y) == (yb > point.Y))
|
||||
if ((ya > rayY) == (yb > rayY))
|
||||
continue;
|
||||
var dy = point.Y - center.Y;
|
||||
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
@@ -10,12 +11,51 @@ public enum PostVerificationKind
|
||||
Overlap,
|
||||
MissingLeadIn,
|
||||
RapidCrossing,
|
||||
Incomplete
|
||||
Incomplete,
|
||||
|
||||
/// <summary>
|
||||
/// A violation inside a fixed emission (a locked program, or a contour cut exactly as its
|
||||
/// drawing pins it): accepted as given, never repaired, always reported for review.
|
||||
/// </summary>
|
||||
FixedLead,
|
||||
|
||||
/// <summary>A part inside another part's cutout is cut after that host, whose cutout releases it.</summary>
|
||||
NestedCutOrder,
|
||||
|
||||
/// <summary>
|
||||
/// A lead of a nested part or of its host contacts or enters the other's material; for a host
|
||||
/// this is typically an enclosing-hole lead crossing the insert.
|
||||
/// </summary>
|
||||
NestedLead,
|
||||
|
||||
/// <summary>A lead-in pierce is closer to part material than half the pierce diameter.</summary>
|
||||
PierceZone,
|
||||
/// <summary>
|
||||
/// Two parts are closer than the plate's part spacing without sharing material, judged as
|
||||
/// Auto Nest's layout check judges them.
|
||||
/// </summary>
|
||||
PartSpacing,
|
||||
|
||||
/// <summary>
|
||||
/// A part its plate's support bed cannot hold level has no tab that keeps it level, so it may
|
||||
/// tip once cut free (see docs/support-tabs.md).
|
||||
/// </summary>
|
||||
PartSupport
|
||||
}
|
||||
|
||||
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
|
||||
public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber,
|
||||
int? PartNumber, int? OtherPartNumber, string Message);
|
||||
int? PartNumber, int? OtherPartNumber, string Message)
|
||||
{
|
||||
public Vector? Location { get; init; }
|
||||
public Vector? RapidStart { get; init; }
|
||||
public Vector? RapidEnd { get; init; }
|
||||
/// <summary>Rapid boundary contacts in travel order; collinear contact spans use their endpoints.</summary>
|
||||
public IReadOnlyList<Vector> ContactPoints { get; init; } = Array.Empty<Vector>();
|
||||
public PlateOverlapPair Overlap { get; init; }
|
||||
/// <summary>Active clamp contacted by a cut-off or direct XY rapid, if applicable.</summary>
|
||||
public string ClampId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
|
||||
public sealed class PostVerificationReport
|
||||
@@ -29,14 +69,29 @@ public sealed class PostVerificationReport
|
||||
public bool HasWarnings => Findings.Count != 0;
|
||||
public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged;
|
||||
|
||||
public string ToDisplayText()
|
||||
public string ToDisplayText() => ToDisplayText("Pre-post verification");
|
||||
|
||||
public string ToDisplayText(string title)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.AppendLine("Pre-post verification");
|
||||
text.AppendLine(title);
|
||||
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
|
||||
Summary(PostVerificationKind.Overlap, "Overlap");
|
||||
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
|
||||
Summary(PostVerificationKind.RapidCrossing, "Rapid crossings");
|
||||
// Only nests with fixed-lead findings gain the line, so other reports read as before.
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.FixedLead))
|
||||
Summary(PostVerificationKind.FixedLead, "Fixed leads");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.NestedCutOrder))
|
||||
Summary(PostVerificationKind.NestedCutOrder, "Nested part order");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.NestedLead))
|
||||
Summary(PostVerificationKind.NestedLead, "Nested part leads");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PierceZone))
|
||||
Summary(PostVerificationKind.PierceZone, "Pierce zones");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSpacing))
|
||||
Summary(PostVerificationKind.PartSpacing, "Part spacing");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSupport))
|
||||
Summary(PostVerificationKind.PartSupport, "Part support");
|
||||
foreach (var finding in Findings)
|
||||
{
|
||||
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
|
||||
@@ -46,16 +101,16 @@ public sealed class PostVerificationReport
|
||||
text.Append($", other part {other}");
|
||||
text.AppendLine($": {finding.Kind}: {finding.Message}");
|
||||
}
|
||||
text.AppendLine("This is not a physical safety certification. The check uses direct XY rapids " +
|
||||
"in plate/program order; the post may change order, routing or retracts. Inspect the posted " +
|
||||
"machine program and machine setup. Actual contour gaps are not proof of adequate retention.");
|
||||
text.AppendLine("These checks cover direct XY rapids in plate/program order; the post may " +
|
||||
"change order, routing or retracts. Inspect the posted machine program and machine setup " +
|
||||
"before cutting, and confirm actual contour gaps give adequate retention.");
|
||||
return text.ToString();
|
||||
|
||||
void Summary(PostVerificationKind kind, string label)
|
||||
{
|
||||
var count = Findings.Count(finding => finding.Kind == kind);
|
||||
text.AppendLine($"{label}: {count} warning(s)" +
|
||||
(incomplete ? "; verification incomplete — do not treat as clear." : "."));
|
||||
(incomplete ? "; verification incomplete — review before posting." : "."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,14 +104,53 @@ namespace OpenNest
|
||||
|
||||
public Color Color { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when someone chose <see cref="Color"/> on purpose. Otherwise the color comes
|
||||
/// from the active scheme's palette and is reassigned when the scheme changes or the
|
||||
/// nest is opened.
|
||||
/// </summary>
|
||||
public bool HasCustomColor { get; set; }
|
||||
|
||||
public bool IsCutOff { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cut this drawing's parts before every other part when automatic sequencing runs,
|
||||
/// e.g. a test coupon checked before the rest of the sheet is cut.
|
||||
/// </summary>
|
||||
public bool CutFirst { get; set; }
|
||||
|
||||
public NestConstraints Constraints { get; set; }
|
||||
|
||||
public SourceInfo Source { get; set; }
|
||||
|
||||
public List<Bend> Bends { get; set; } = new List<Bend>();
|
||||
|
||||
/// <summary>
|
||||
/// The library revision this drawing was copied from, or null when it did not come
|
||||
/// from a drawing library. Editing the drawing in a nest keeps the link as provenance.
|
||||
/// </summary>
|
||||
public DrawingLibraryLink Library { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contours this drawing says must be cut a fixed way, in the source frame. At most one
|
||||
/// pin per contour is used; <see cref="CNC.CuttingPlanning.CutPinResolver"/> maps them to
|
||||
/// a placement. Pins do not follow geometry into new drawings (split pieces).
|
||||
/// </summary>
|
||||
public List<CNC.CuttingStrategy.ContourCutPin> CutPins { get; set; } = new List<CNC.CuttingStrategy.ContourCutPin>();
|
||||
|
||||
/// <summary>
|
||||
/// Scrap pieces tabbed to the part, in the source frame. <see cref="Program"/> already has
|
||||
/// them applied; they are kept so an editor can change them from <see cref="SourceEntities"/>.
|
||||
/// </summary>
|
||||
public List<Breakoffs.Breakoff> Breakoffs { get; set; } = new List<Breakoffs.Breakoff>();
|
||||
|
||||
/// <summary>
|
||||
/// Holes whose slugs stay attached by tabs, in the source frame. <see cref="Program"/> already
|
||||
/// has them applied (cut pieces plus <see cref="CNC.LayerType.Tab"/> spans); they are kept so
|
||||
/// an editor can change them from <see cref="SourceEntities"/>.
|
||||
/// </summary>
|
||||
public List<TabbedCutouts.TabbedCutout> TabbedCutouts { get; set; } = new List<TabbedCutouts.TabbedCutout>();
|
||||
|
||||
/// <summary>
|
||||
/// Complete set of source entities with stable GUIDs.
|
||||
/// Null when the drawing was created from G-code or an older nest file.
|
||||
@@ -124,6 +163,14 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public HashSet<Guid> SuppressedEntityIds { get; set; } = new HashSet<Guid>();
|
||||
|
||||
/// <summary>
|
||||
/// Role overrides for entities in <see cref="SourceEntities"/>; entities not listed keep the
|
||||
/// role their layer implies (see <see cref="Converters.EntityRoles"/>). <see cref="Program"/> already has
|
||||
/// them applied; display-only entities are not in it and come from
|
||||
/// <see cref="Converters.EntityRoles.DisplayGeometry"/>.
|
||||
/// </summary>
|
||||
public Dictionary<Guid, EntityRole> EntityRoles { get; set; } = new Dictionary<Guid, EntityRole>();
|
||||
|
||||
public double Area { get; protected set; }
|
||||
|
||||
public void UpdateArea()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Where a nest drawing came from in a server drawing library: the exact revision that
|
||||
/// was copied in and a snapshot of its library details at that time. The nest owns its
|
||||
/// copy; the link never changes the library and the library never changes the copy.
|
||||
/// Immutable, so drawing copies may share one instance.
|
||||
/// </summary>
|
||||
public sealed class DrawingLibraryLink
|
||||
{
|
||||
public DrawingLibraryLink(string serverKey, Guid id, int revision, CutType cutType, double thickness,
|
||||
string materialName, string materialGrade, Units units, string notes, string packageSha256)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serverKey))
|
||||
throw new ArgumentException("A library link needs the server it came from.", nameof(serverKey));
|
||||
if (id == Guid.Empty)
|
||||
throw new ArgumentException("A library link needs a drawing id.", nameof(id));
|
||||
if (revision < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(revision), revision, "Revisions start at 1.");
|
||||
if (!Enum.IsDefined(cutType))
|
||||
throw new ArgumentOutOfRangeException(nameof(cutType), cutType, "Unsupported cut type.");
|
||||
if (!double.IsFinite(thickness) || thickness < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(thickness), thickness, "Thickness must be finite and not negative.");
|
||||
if (!Enum.IsDefined(units))
|
||||
throw new ArgumentOutOfRangeException(nameof(units), units, "Unsupported units.");
|
||||
|
||||
ServerKey = serverKey;
|
||||
Id = id;
|
||||
Revision = revision;
|
||||
CutType = cutType;
|
||||
Thickness = thickness;
|
||||
MaterialName = materialName ?? "";
|
||||
MaterialGrade = materialGrade ?? "";
|
||||
Units = units;
|
||||
Notes = notes ?? "";
|
||||
PackageSha256 = packageSha256 ?? "";
|
||||
}
|
||||
|
||||
/// <summary>Normalized base URL of the server holding the library (no trailing slash).</summary>
|
||||
public string ServerKey { get; }
|
||||
|
||||
public Guid Id { get; }
|
||||
|
||||
/// <summary>The library revision this drawing is a copy of.</summary>
|
||||
public int Revision { get; }
|
||||
|
||||
public CutType CutType { get; }
|
||||
|
||||
/// <summary>Library thickness in <see cref="Units"/>.</summary>
|
||||
public double Thickness { get; }
|
||||
|
||||
public string MaterialName { get; }
|
||||
|
||||
public string MaterialGrade { get; }
|
||||
|
||||
public Units Units { get; }
|
||||
|
||||
public string Notes { get; }
|
||||
|
||||
/// <summary>Lower-case hex SHA-256 of the library package for <see cref="Revision"/>.</summary>
|
||||
public string PackageSha256 { get; }
|
||||
|
||||
/// <summary>True when both links name the same revision of the same library drawing.</summary>
|
||||
public bool IsSameRevision(DrawingLibraryLink other) =>
|
||||
other != null && Id == other.Id && Revision == other.Revision
|
||||
&& string.Equals(ServerKey, other.ServerKey, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
@@ -10,11 +11,11 @@ namespace OpenNest;
|
||||
/// </summary>
|
||||
public sealed class DrawingProgramSnapshot
|
||||
{
|
||||
private readonly Dictionary<Drawing, string> programs;
|
||||
private readonly Dictionary<Drawing, Captured> programs;
|
||||
private readonly Func<Program, string> fingerprint;
|
||||
|
||||
private DrawingProgramSnapshot(
|
||||
Dictionary<Drawing, string> programs,
|
||||
Dictionary<Drawing, Captured> programs,
|
||||
Func<Program, string> fingerprint)
|
||||
{
|
||||
this.programs = programs;
|
||||
@@ -32,12 +33,12 @@ public sealed class DrawingProgramSnapshot
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(drawings);
|
||||
ArgumentNullException.ThrowIfNull(fingerprint);
|
||||
var programs = new Dictionary<Drawing, string>(ReferenceEqualityComparer.Instance);
|
||||
var programs = new Dictionary<Drawing, Captured>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
foreach (var drawing in drawings)
|
||||
{
|
||||
if (!drawing.IsCutOff && !programs.ContainsKey(drawing))
|
||||
programs.Add(drawing, fingerprint(drawing.Program));
|
||||
programs.Add(drawing, new Captured(fingerprint(drawing.Program), SourceOffset(drawing)));
|
||||
}
|
||||
|
||||
return new DrawingProgramSnapshot(programs, fingerprint);
|
||||
@@ -45,20 +46,22 @@ public sealed class DrawingProgramSnapshot
|
||||
|
||||
/// <summary>
|
||||
/// Complete the captured edit by rebuilding changed drawings' parts across all plates.
|
||||
/// Part.Update preserves the placement and clears obsolete lead-ins, tabs and locks.
|
||||
/// Part.Update clears obsolete lead-ins, tabs and locks; the placement is anchored to the
|
||||
/// source frame, so a rebuild that re-origins the program (Source.Offset moved, e.g. a new
|
||||
/// first pierce) leaves the geometry where it was on the plate.
|
||||
/// Unchanged and uncaptured drawings' parts retain their program instances and state.
|
||||
/// Returns the rebuilt parts so a UI can invalidate just their graphics.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Part> UpdateChangedParts(IEnumerable<Plate> plates)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plates);
|
||||
var changed = new HashSet<Drawing>(ReferenceEqualityComparer.Instance);
|
||||
var originShifts = new Dictionary<Drawing, Vector>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
foreach (var entry in programs)
|
||||
{
|
||||
if (!entry.Key.IsCutOff
|
||||
&& !string.Equals(entry.Value, fingerprint(entry.Key.Program), StringComparison.Ordinal))
|
||||
changed.Add(entry.Key);
|
||||
&& !string.Equals(entry.Value.Fingerprint, fingerprint(entry.Key.Program), StringComparison.Ordinal))
|
||||
originShifts.Add(entry.Key, SourceOffset(entry.Key) - entry.Value.SourceOffset);
|
||||
}
|
||||
|
||||
var updated = new List<Part>();
|
||||
@@ -66,14 +69,22 @@ public sealed class DrawingProgramSnapshot
|
||||
{
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (!changed.Contains(part.BaseDrawing))
|
||||
if (!originShifts.TryGetValue(part.BaseDrawing, out var shift))
|
||||
continue;
|
||||
|
||||
// A source point P sat at Location + R(P - old); keep it there with the new origin.
|
||||
var rotation = part.Rotation;
|
||||
part.Update();
|
||||
if (shift != Vector.Zero)
|
||||
part.Offset(shift.Rotate(rotation));
|
||||
updated.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
private static Vector SourceOffset(Drawing drawing) => drawing.Source?.Offset ?? Vector.Zero;
|
||||
|
||||
private readonly record struct Captured(string Fingerprint, Vector SourceOffset);
|
||||
}
|
||||
@@ -80,16 +80,60 @@ namespace OpenNest.Geometry
|
||||
return Separation(linesA, linesB);
|
||||
}
|
||||
|
||||
/// <summary>Distance from a closed boundary's segments to every segment
|
||||
/// of an intentional open cutting path. Unlike polygonizing that path,
|
||||
/// this retains segment interiors and disjoint subpaths.</summary>
|
||||
internal static ClearanceResult BoundaryDistance(List<Line> closed, List<Line> open)
|
||||
{
|
||||
if (closed.Count == 0 || open.Count == 0)
|
||||
return new ClearanceResult(0, new Vector(1, 0));
|
||||
return Separation(closed, open);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum boundary distance between two non-overlapping rings and the
|
||||
/// direction that translates <paramref name="linesA"/> away from
|
||||
/// <paramref name="linesB"/> at the closest contact.
|
||||
/// </summary>
|
||||
private static ClearanceResult Separation(List<Line> linesA, List<Line> linesB)
|
||||
{
|
||||
var minDist = ClosestPoints(linesA, linesB, out var pa, out var pb);
|
||||
|
||||
var dir = pa - pb;
|
||||
var len = Magnitude(dir);
|
||||
|
||||
if (len <= Tolerance.Epsilon)
|
||||
dir = CentroidAway(linesA, linesB);
|
||||
else
|
||||
dir = dir / len;
|
||||
|
||||
return new ClearanceResult(minDist, dir);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum boundary (edge-to-edge) distance between two rings and the point on each
|
||||
/// boundary where it occurs. Like <see cref="BoundaryDistance(Polygon, Polygon)"/>, never
|
||||
/// tests overlap. Infinity, with zero points, when either ring has no edges.
|
||||
/// </summary>
|
||||
public static double ClosestPoints(Polygon a, Polygon b, out Vector onA, out Vector onB)
|
||||
{
|
||||
var linesA = a.ToLines();
|
||||
var linesB = b.ToLines();
|
||||
|
||||
if (linesA.Count == 0 || linesB.Count == 0)
|
||||
{
|
||||
onA = onB = Vector.Zero;
|
||||
return double.PositiveInfinity;
|
||||
}
|
||||
|
||||
return ClosestPoints(linesA, linesB, out onA, out onB);
|
||||
}
|
||||
|
||||
internal static double ClosestPoints(List<Line> linesA, List<Line> linesB, out Vector pa, out Vector pb)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var pa = Vector.Zero;
|
||||
var pb = Vector.Zero;
|
||||
pa = Vector.Zero;
|
||||
pb = Vector.Zero;
|
||||
|
||||
var boxes = new Box[linesB.Count];
|
||||
for (var i = 0; i < linesB.Count; i++)
|
||||
@@ -114,15 +158,7 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
}
|
||||
|
||||
var dir = pa - pb;
|
||||
var len = Magnitude(dir);
|
||||
|
||||
if (len <= Tolerance.Epsilon)
|
||||
dir = CentroidAway(linesA, linesB);
|
||||
else
|
||||
dir = dir / len;
|
||||
|
||||
return new ClearanceResult(minDist, dir);
|
||||
return minDist;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,12 +5,14 @@ using OpenNest.Math;
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Polygon overlap test with hole subtraction. This is the reference implementation
|
||||
/// for a future GPU kernel, so it deliberately stays hand-rolled instead of using
|
||||
/// Clipper (which is CPU-only and allocation-heavy; see <see cref="ClipperBridge"/>
|
||||
/// for the CPU preparation that feeds it).
|
||||
/// Polygon overlap test with hole subtraction, used by engine and validation per-pair
|
||||
/// checks. It was written as the reference for a GPU kernel; that port is on hold in
|
||||
/// favour of Clipper2, starting with the material-overlap diagnostic. Engine callers
|
||||
/// stay on this kernel until a Clipper2 near-contact differential and the layout gate
|
||||
/// pass.
|
||||
/// <para>
|
||||
/// GPU-port contract. Per-polygon preparation, done once per drawing and rotation,
|
||||
/// Former GPU-port contract, kept for reference. Per-polygon preparation, done once
|
||||
/// per drawing and rotation,
|
||||
/// then cached and uploaded: the spacing offset (<see cref="ClipperBridge"/>),
|
||||
/// triangulation (<see cref="ConvexDecomposition.Triangulate"/>) of the outline and
|
||||
/// each hole, and the bounding box of every polygon and triangle. Per-pair work,
|
||||
|
||||
@@ -953,7 +953,7 @@ namespace OpenNest.Geometry
|
||||
OldSampleCount = old.TotalSamples,
|
||||
DiagnosticIoU = iou,
|
||||
FailureMessage = (reasons & (AlignmentReasons.FailedConvergence | AlignmentReasons.InsufficientSupport)) != 0
|
||||
? "Automatic alignment is not trustworthy for this pair; use the manual overlay."
|
||||
? "Use the manual overlay to align this pair."
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
Loaded 100 of 1040 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user