mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-29 04:51:05 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41460481d6 | ||
|
|
a9396ba065 | ||
|
|
101d7b5a50 | ||
|
|
a0d063bb94 | ||
|
|
c21b5b006e | ||
|
|
55ab8ec4a5 | ||
|
|
662d3daf8c | ||
|
|
360c7136dd | ||
|
|
501d90b825 | ||
|
|
fa7a1f0fce | ||
|
|
672ed09074 | ||
|
|
e5dc67346d | ||
|
|
f22e839850 | ||
|
|
b8e8cc387d | ||
|
|
c6b6678a2f | ||
|
|
4179b53c97 | ||
|
|
c8419761e0 | ||
|
|
16bd92567c | ||
|
|
cd51f7cfda | ||
|
|
6f4e872c03 | ||
|
|
588ae5ed09 | ||
|
|
277ca36f42 | ||
|
|
8dded7f177 | ||
|
|
b65a9485c7 | ||
|
|
63915d6fb6 | ||
|
|
379e3fd433 | ||
|
|
f1468e6f57 | ||
|
|
0be9e2201a | ||
|
|
fcb61f3b3e | ||
|
|
87e0a70878 | ||
|
|
8776eb4734 | ||
|
|
fd2e9c2f56 | ||
|
|
3618b335cb | ||
|
|
7d9c64487d | ||
|
|
cc066b457e | ||
|
|
d2f814f1f9 | ||
|
|
69d8187adc | ||
|
|
4670577110 | ||
|
|
d505a50934 | ||
|
|
bc15c362e1 | ||
|
|
9b4c1f51d0 | ||
|
|
eba15791e7 | ||
|
|
7fe4de4b6d | ||
|
|
f4317bc52f | ||
|
|
207f126b9e | ||
|
|
0f1e5fb568 | ||
|
|
0ede9443de | ||
|
|
58b8467c39 | ||
|
|
eedff85904 | ||
|
|
19be5a5ece | ||
|
|
242a0d2a1b | ||
|
|
f26fc0d596 | ||
|
|
ac886d05b8 | ||
|
|
3e1df45c7f | ||
|
|
cc16590881 | ||
|
|
8ad32e4864 | ||
|
|
7198f7a4f7 | ||
|
|
ee842c822f | ||
|
|
512b861739 | ||
|
|
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 | ||
|
|
eff148c24e | ||
|
|
648ffbca65 | ||
|
|
4d94273a00 | ||
|
|
028adee046 | ||
|
|
3ddfff242d | ||
|
|
5fc563bbca | ||
|
|
785a861ab9 | ||
|
|
986ba3dee1 | ||
|
|
a73a85ebc0 | ||
|
|
8628c69098 | ||
|
|
c1c4b18f17 | ||
|
|
7e887921ea | ||
|
|
f50705c491 | ||
|
|
81f3c006ab | ||
|
|
b83518feac | ||
|
|
1826e6d5b5 | ||
|
|
fc300705a8 | ||
|
|
1c923976a8 | ||
|
|
f4d5cfe98e | ||
|
|
e7e4012974 | ||
|
|
9196d9157f | ||
|
|
7468483a10 | ||
|
|
432eb54633 | ||
|
|
64459f7bc8 | ||
|
|
7622d63556 | ||
|
|
414e9b4e75 | ||
|
|
76f73f7a87 | ||
|
|
6b7d0d7f90 | ||
|
|
47140cc59e | ||
|
|
fa3c5e348e | ||
|
|
d4544ea358 | ||
|
|
acd1f21293 | ||
|
|
2399cc3936 | ||
|
|
bd3f529ccd | ||
|
|
a791b74945 | ||
|
|
4e60cebf68 | ||
|
|
63f2a4b555 | ||
|
|
ed35b94e7f | ||
|
|
e75f68dbb6 | ||
|
|
b8f319d3e8 | ||
|
|
91aef95b1b | ||
|
|
9110cd62e0 | ||
|
|
8e8b1de25f | ||
|
|
1add37d3e0 | ||
|
|
71f41e23e4 | ||
|
|
b591528aa2 | ||
|
|
12206ed8fd | ||
|
|
bb43118451 | ||
|
|
89356371db | ||
|
|
5af44b251c | ||
|
|
0a1d021bf7 | ||
|
|
6b4078aeb2 | ||
|
|
18a61717fe | ||
|
|
bec4db21a8 | ||
|
|
bc061488ee | ||
|
|
ea1270e1e7 | ||
|
|
8c21c62ec3 | ||
|
|
1c0320d17e | ||
|
|
4e6419fd2c | ||
|
|
c21f687987 | ||
|
|
c1c8d5fc36 | ||
|
|
2419a9e2e3 | ||
|
|
b8f3d3bf0a | ||
|
|
bfe2e518e6 | ||
|
|
48367da820 | ||
|
|
65d320d2e5 | ||
|
|
1b422ef99a | ||
|
|
faf3c9bcc4 | ||
|
|
8590f21c9c | ||
|
|
1769b8907d | ||
|
|
187234f459 | ||
|
|
a0ac7e5756 | ||
|
|
9a4f73b924 | ||
|
|
248972c6e7 | ||
|
|
124d70afe7 | ||
|
|
81d3253580 | ||
|
|
0ac72fad70 | ||
|
|
b83aeb0e41 | ||
|
|
f4d1d45c81 | ||
|
|
ca153a1c20 | ||
|
|
03b3ed47b2 | ||
|
|
1b5a6bf6da | ||
|
|
3fc32e56e1 | ||
|
|
3a9bd08f39 | ||
|
|
4a55d6664c | ||
|
|
3a8419e6e9 | ||
|
|
f145826309 | ||
|
|
da0c54f280 | ||
|
|
9920aba6eb | ||
|
|
0446127e11 | ||
|
|
eda4a846f0 | ||
|
|
32db086384 | ||
|
|
5f67a51bf1 | ||
|
|
20f8b046de | ||
|
|
bb104a07bb | ||
|
|
ea36c47a30 | ||
|
|
b8afcc6fdd | ||
|
|
1d8c5fe274 | ||
|
|
e43a7c99d0 | ||
|
|
b9b475ab77 | ||
|
|
d37b38622f | ||
|
|
4bbd8454b5 | ||
|
|
f3bca26fb9 | ||
|
|
9c530ca7d2 | ||
|
|
ebc54f0653 | ||
|
|
7127884584 | ||
|
|
6f7652ee83 | ||
|
|
fef93cbfa5 | ||
|
|
cf615dcef8 | ||
|
|
e8d974a2db | ||
|
|
9f7580a486 | ||
|
|
fb7ef6fd0d | ||
|
|
e00aa113a0 | ||
|
|
d7204e8d07 | ||
|
|
1597ee9069 | ||
|
|
d1109aaa43 | ||
|
|
085edeea3b | ||
|
|
70d886e4cf | ||
|
|
018e4e3dd4 | ||
|
|
e71fcea73d | ||
|
|
6addad7b2e | ||
|
|
7451596047 | ||
|
|
15cca0efd5 | ||
|
|
4842b80ceb | ||
|
|
418f75916b | ||
|
|
a15c54fbaf | ||
|
|
67fb80e660 | ||
|
|
5f9e8814a7 | ||
|
|
3ba8f4e3b2 | ||
|
|
2a5ebf1e8a | ||
|
|
90587803be | ||
|
|
b7dc7dee13 | ||
|
|
39a15aee14 | ||
|
|
23d258b886 | ||
|
|
60d3915af2 | ||
|
|
c989ba6fd0 | ||
|
|
761cee7c1f | ||
|
|
c49c387af5 | ||
|
|
9e15d5dceb | ||
|
|
eace08f351 | ||
|
|
96267e760e | ||
|
|
646d4c3975 | ||
|
|
1fadf6c4d3 | ||
|
|
f53bead0a8 | ||
|
|
1ec79ae594 | ||
|
|
d428357c8b | ||
|
|
142c77a27f | ||
|
|
b39509a6a3 | ||
|
|
6d1e0cb896 | ||
|
|
5d8874a9e7 | ||
|
|
b17fe1a318 | ||
|
|
7999e3cdde | ||
|
|
14a7ce43a7 | ||
|
|
bd5db59e42 | ||
|
|
25755a8850 | ||
|
|
c7b4ba4457 | ||
|
|
d8b763da13 | ||
|
|
c01a348243 | ||
|
|
e886bcfa35 | ||
|
|
4fa0f636ce | ||
|
|
94e23e4b7a | ||
|
|
591d08c627 | ||
|
|
6b6928213d | ||
|
|
a26e663f01 | ||
|
|
694d4b28cc | ||
|
|
d14071692b | ||
|
|
a368ef0115 | ||
|
|
62fd42346e | ||
|
|
56ab3a5f30 | ||
|
|
7a52058b0f | ||
|
|
7ce53008f1 | ||
|
|
4b6e131cec | ||
|
|
2e700df8ed | ||
|
|
c6f8a06dc7 | ||
|
|
96bcf6ded1 | ||
|
|
e9c312b9fb | ||
|
|
a854e34a3d | ||
|
|
3b9f5ca5fa | ||
|
|
0209a24008 | ||
|
|
8450ceee40 | ||
|
|
c55e375cf3 | ||
|
|
5e9eaf8a4c | ||
|
|
dbcf7dea65 | ||
|
|
98870e78a7 | ||
|
|
c9bb32470c | ||
|
|
e84d7f0e72 | ||
|
|
6426a98f82 | ||
|
|
23ffc14f3f | ||
|
|
b2deed6c5a | ||
|
|
1cc95c8115 | ||
|
|
038886a757 | ||
|
|
8664656658 | ||
|
|
d7fd2f8468 | ||
|
|
5bf3c5f9aa | ||
|
|
984d31a391 | ||
|
|
4f46fa15ea | ||
|
|
38324628b0 | ||
|
|
132bb07bc9 | ||
|
|
50884daca1 | ||
|
|
fc53c7732e | ||
|
|
ab1f3a28d1 | ||
|
|
43ebd61fdf | ||
|
|
bf608fa271 | ||
|
|
fa5e5a8e29 | ||
|
|
388816b015 | ||
|
|
3bdefb1d1c | ||
|
|
7f648e6400 | ||
|
|
b688ce896f | ||
|
|
1eb509740e | ||
|
|
ad36101b7f | ||
|
|
00ede7c5d0 | ||
|
|
314ca2f2a6 | ||
|
|
64045be968 | ||
|
|
437d659c9d | ||
|
|
10ed3d3a30 | ||
|
|
ef2f9aa714 | ||
|
|
b15c4cf6c6 | ||
|
|
e4d64a88eb | ||
|
|
74d2b6aa68 | ||
|
|
64d0e50df3 | ||
|
|
7d179c696a | ||
|
|
662bc133b1 | ||
|
|
b3a4390169 | ||
|
|
b8bded1b09 | ||
|
|
ac901f7ee9 | ||
|
|
43253cd9ab | ||
|
|
7aeb636ca2 | ||
|
|
c914d2bbd9 | ||
|
|
481c3e5128 | ||
|
|
29953ea601 | ||
|
|
99c31748ba | ||
|
|
2b8a3ce7db | ||
|
|
36de55f10f | ||
|
|
55fe0ef228 | ||
|
|
8720580004 | ||
|
|
c3dd346b7a | ||
|
|
4afab63046 | ||
|
|
2cc06d1dc7 | ||
|
|
45d2dfced4 | ||
|
|
a5fddec0e9 | ||
|
|
a8204bb51f | ||
|
|
e4d07121c8 | ||
|
|
76044f6bd9 | ||
|
|
1581260e31 | ||
|
|
19c3a89c32 | ||
|
|
4dbdc2678d | ||
|
|
fb32d508f8 | ||
|
|
1f637ca43c | ||
|
|
648b0eaca5 | ||
|
|
b254a0bd3e | ||
|
|
74302761ce | ||
|
|
4da48eed68 | ||
|
|
6803d7519f | ||
|
|
86dbd8819b | ||
|
|
20395eed9b | ||
|
|
a886735040 | ||
|
|
75d41f3bb7 | ||
|
|
26252b333a | ||
|
|
c219ec4e66 | ||
|
|
32586149c6 | ||
|
|
9ec28add80 | ||
|
|
35aa64fe4e | ||
|
|
c33337cea2 | ||
|
|
b0997f614b | ||
|
|
6d7c98ef4a | ||
|
|
f44c1d465f | ||
|
|
bdb78a689d | ||
|
|
3f89f8f5dd | ||
|
|
3cab90747a | ||
|
|
fff3bef4e4 | ||
|
|
2b78fb3a75 | ||
|
|
a04460c57b | ||
|
|
27948ba8cf |
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"vpk": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"commands": [
|
||||||
|
"vpk"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
**
|
||||||
|
!OpenNest.Server/
|
||||||
|
!OpenNest.Server/**
|
||||||
|
!OpenNest.Data/
|
||||||
|
!OpenNest.Data/**
|
||||||
|
!OpenNest.Core/
|
||||||
|
!OpenNest.Core/**
|
||||||
|
**/bin/**
|
||||||
|
**/obj/**
|
||||||
|
**/data/**
|
||||||
|
**/*.db
|
||||||
|
**/*.db-*
|
||||||
|
**/.env*
|
||||||
|
**/*.env
|
||||||
|
**/*.env.*
|
||||||
|
**/*.user
|
||||||
|
**/Properties/launchSettings.json
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
name: Cross-platform tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unit-tests:
|
||||||
|
name: Unit tests (${{ matrix.project }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
max-parallel: 4
|
||||||
|
matrix:
|
||||||
|
project:
|
||||||
|
- OpenNest.Tests
|
||||||
|
- OpenNest.Desktop.Tests
|
||||||
|
- OpenNest.Engine.Tests
|
||||||
|
- OpenNest.IO.Tests
|
||||||
|
- OpenNest.Server.Tests
|
||||||
|
- OpenNest.FrontEnd.Tests
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
- name: Run ${{ matrix.project }}
|
||||||
|
run: dotnet test "${{ matrix.project }}/${{ matrix.project }}.csproj"
|
||||||
|
|
||||||
|
synthetic-nests:
|
||||||
|
name: Six synthetic Irregular nests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
- name: Test fail-closed final CI check
|
||||||
|
run: python3 scripts/test_ci_results.py -v
|
||||||
|
- name: Check six synthetic Irregular nests
|
||||||
|
timeout-minutes: 6
|
||||||
|
run: |
|
||||||
|
python3 scripts/test_check_synthetic_nests.py -v
|
||||||
|
python3 scripts/check-synthetic-nests.py --parallel 2
|
||||||
|
|
||||||
|
# Preserve the existing check name for required-status consumers.
|
||||||
|
tests:
|
||||||
|
name: tests
|
||||||
|
needs: [unit-tests, synthetic-nests]
|
||||||
|
if: ${{ always() }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Require every test job to succeed
|
||||||
|
env:
|
||||||
|
UNIT_TEST_RESULT: ${{ needs.unit-tests.result }}
|
||||||
|
SYNTHETIC_RESULT: ${{ needs.synthetic-nests.result }}
|
||||||
|
run: bash scripts/check-ci-results.sh "$UNIT_TEST_RESULT" "$SYNTHETIC_RESULT"
|
||||||
|
|
||||||
|
windows-desktop:
|
||||||
|
# WinForms tests need a Windows desktop runtime; the Linux job can only
|
||||||
|
# cross-compile them. FrontEnd.Tests also run here, beside the desktop build.
|
||||||
|
runs-on: windows-2022
|
||||||
|
timeout-minutes: 20
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
|
||||||
|
DOTNET_NOLOGO: '1'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
- name: Run Windows desktop and front-end tests
|
||||||
|
# A test silent for 5 minutes is killed and named in the log, with a
|
||||||
|
# mini dump in TestResults, instead of holding the runner until the
|
||||||
|
# job timeout.
|
||||||
|
run: |
|
||||||
|
$failed = @()
|
||||||
|
foreach ($project in @('OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||||
|
dotnet test "$project/$project.csproj" -c Release --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||||
|
if ($LASTEXITCODE -ne 0) { $failed += $project }
|
||||||
|
}
|
||||||
|
if ($failed.Count -gt 0) { throw "Failed: $($failed -join ', ')" }
|
||||||
|
- name: Test acceptance evidence checker
|
||||||
|
run: python scripts/test_windows_acceptance.py -v
|
||||||
|
- name: Verify named Windows acceptance coverage
|
||||||
|
if: always()
|
||||||
|
run: python scripts/windows_acceptance.py --results TestResults
|
||||||
|
- name: Upload test results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: windows-desktop-test-results
|
||||||
|
path: TestResults/
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 14
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
name: Development build
|
||||||
|
|
||||||
|
# Every master push whose cross-platform tests pass becomes the rolling
|
||||||
|
# `dev-build` pre-release: X.Y.Z-dev.N, where X.Y.Z is the minor after the
|
||||||
|
# newest release tag and N the commit count. Installed copies take it only
|
||||||
|
# with Tools > Options > Include development builds.
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ['Cross-platform tests']
|
||||||
|
types: [completed]
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: dev-build
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
package:
|
||||||
|
if: >-
|
||||||
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
github.event.workflow_run.event == 'push' &&
|
||||||
|
github.event.workflow_run.head_repository.full_name == github.repository
|
||||||
|
runs-on: windows-2022
|
||||||
|
timeout-minutes: 30
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
|
||||||
|
DOTNET_NOLOGO: '1'
|
||||||
|
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
- name: Resolve development version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
$releases = git tag --list 'v*' | Where-Object { $_ -match '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' } |
|
||||||
|
ForEach-Object { [version]$_.Substring(1) } | Sort-Object
|
||||||
|
if (-not $releases) { throw 'No vX.Y.Z release tag found.' }
|
||||||
|
$latest = @($releases)[-1]
|
||||||
|
$count = (git rev-list --count $env:SOURCE_SHA).Trim()
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Cannot count commits.' }
|
||||||
|
$version = "$($latest.Major).$($latest.Minor + 1).0-dev.$count"
|
||||||
|
Write-Host "Development version $version (after v$latest)"
|
||||||
|
"version=$version" >> $env:GITHUB_OUTPUT
|
||||||
|
- name: Publish and verify Windows package
|
||||||
|
run: ./scripts/Publish-Windows.ps1 -Version ${{ steps.version.outputs.version }}
|
||||||
|
- name: Upload development build
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: OpenNest-dev-build
|
||||||
|
path: |
|
||||||
|
artifacts/*.zip
|
||||||
|
artifacts/*.sha256
|
||||||
|
artifacts/velopack/OpenNest-win-Setup.exe
|
||||||
|
artifacts/velopack/OpenNest-win-Portable.zip
|
||||||
|
artifacts/velopack/*-full.nupkg
|
||||||
|
artifacts/velopack/releases.win.json
|
||||||
|
artifacts/velopack/RELEASES
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
compression-level: 0
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: package
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: read
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
VERSION: ${{ needs.package.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- name: Skip if master has moved on
|
||||||
|
id: current
|
||||||
|
run: |
|
||||||
|
head=$(gh api "repos/$GH_REPO/commits/master" --jq .sha)
|
||||||
|
if [ "$head" != "$SOURCE_SHA" ]; then
|
||||||
|
echo "master is at $head; its own run publishes the newer build."
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- name: Download development build
|
||||||
|
if: steps.current.outputs.skip != 'true'
|
||||||
|
run: gh run download "$GITHUB_RUN_ID" -n OpenNest-dev-build -D dev
|
||||||
|
- name: Verify checksums
|
||||||
|
if: steps.current.outputs.skip != 'true'
|
||||||
|
working-directory: dev
|
||||||
|
run: |
|
||||||
|
sha256sum -c ./*.zip.sha256
|
||||||
|
(cd velopack && sha256sum -c "../OpenNest.v$VERSION.velopack.sha256")
|
||||||
|
- name: Replace the dev-build pre-release
|
||||||
|
if: steps.current.outputs.skip != 'true'
|
||||||
|
working-directory: dev
|
||||||
|
run: |
|
||||||
|
if gh release view dev-build >/dev/null 2>&1; then
|
||||||
|
gh release delete dev-build --cleanup-tag --yes
|
||||||
|
fi
|
||||||
|
cat > notes.md <<EOF
|
||||||
|
Automatic development build of master at $SOURCE_SHA.
|
||||||
|
|
||||||
|
It may contain unfinished features. Installed copies receive it only with
|
||||||
|
**Tools > Options > Include development builds**; full releases are unaffected.
|
||||||
|
EOF
|
||||||
|
gh release create dev-build ./*.zip ./*.sha256 velopack/* \
|
||||||
|
--target "$SOURCE_SHA" --prerelease --latest=false \
|
||||||
|
--title "OpenNest $VERSION (development build, ${SOURCE_SHA:0:8})" --notes-file notes.md
|
||||||
|
- name: Read back the pre-release
|
||||||
|
if: steps.current.outputs.skip != 'true'
|
||||||
|
run: |
|
||||||
|
gh release view dev-build --json isPrerelease,isDraft,tagName,assets \
|
||||||
|
--jq '"prerelease=\(.isPrerelease) draft=\(.isDraft) assets=\(.assets|length)"' | tee readback.txt
|
||||||
|
grep -qx 'prerelease=true draft=false assets=8' readback.txt
|
||||||
|
[ "$(gh api "repos/$GH_REPO/git/ref/tags/dev-build" --jq .object.sha)" = "$SOURCE_SHA" ]
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
name: Server image
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'OpenNest.Server/**'
|
||||||
|
- 'OpenNest.Data/**'
|
||||||
|
- 'OpenNest.Core/**'
|
||||||
|
- 'OpenNest.Server.Tests/**'
|
||||||
|
- 'scripts/Server.Tests/**'
|
||||||
|
- 'scripts/Test-ServerContainer.sh'
|
||||||
|
- 'scripts/test_server_container_cleanup.py'
|
||||||
|
- 'scripts/server_image_release.py'
|
||||||
|
- 'scripts/test_server_image_release.py'
|
||||||
|
- '.dockerignore'
|
||||||
|
- 'compose.server.yaml'
|
||||||
|
- '.github/workflows/server-image.yml'
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- 'OpenNest.Server/**'
|
||||||
|
- 'OpenNest.Data/**'
|
||||||
|
- 'OpenNest.Core/**'
|
||||||
|
- 'OpenNest.Server.Tests/**'
|
||||||
|
- 'scripts/Server.Tests/**'
|
||||||
|
- 'scripts/Test-ServerContainer.sh'
|
||||||
|
- 'scripts/test_server_container_cleanup.py'
|
||||||
|
- 'scripts/server_image_release.py'
|
||||||
|
- 'scripts/test_server_image_release.py'
|
||||||
|
- '.dockerignore'
|
||||||
|
- 'compose.server.yaml'
|
||||||
|
- '.github/workflows/server-image.yml'
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Approved existing vX.Y.Z tag to publish (dispatch from master only)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# No tag-push trigger. Windows tag builds produce candidates, not publications.
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
if: github.event_name == 'pull_request' || github.event_name == 'push'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
VERSION: '0.0.0'
|
||||||
|
SOURCE_SHA: ${{ github.sha }}
|
||||||
|
LOCAL_IMAGE: opennest-server:ci
|
||||||
|
steps:
|
||||||
|
- name: Isolate logs, safe metadata and ephemeral auth
|
||||||
|
run: |
|
||||||
|
printf 'METADATA_DIR=%s/server-image-metadata\nRESULTS=%s/server-image-results\nDOCKER_CONFIG=%s/server-image-auth\n' \
|
||||||
|
"$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||||
|
env:
|
||||||
|
DOTNET_INSTALL_DIR: ${{ runner.temp }}/dotnet8
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
- name: Test fail-closed release and cleanup helpers
|
||||||
|
run: |
|
||||||
|
python3 -m unittest discover -s scripts -p test_server_image_release.py -v
|
||||||
|
python3 -m unittest discover -s scripts -p test_server_container_cleanup.py -v
|
||||||
|
- name: Build and test Server
|
||||||
|
run: |
|
||||||
|
dotnet build OpenNest.Server/OpenNest.Server.csproj -c Release
|
||||||
|
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release --logger 'trx;LogFileName=server.trx' --results-directory "$RESULTS/tests"
|
||||||
|
- name: Resolve base image digests
|
||||||
|
id: bases
|
||||||
|
run: python3 scripts/server_image_release.py bases
|
||||||
|
- name: Build exact local linux/amd64 image (never cached)
|
||||||
|
id: build
|
||||||
|
env:
|
||||||
|
SDK_IMAGE: ${{ steps.bases.outputs.sdk_image }}
|
||||||
|
RUNTIME_IMAGE: ${{ steps.bases.outputs.runtime_image }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "$RESULTS"
|
||||||
|
docker build --pull --no-cache --platform linux/amd64 --provenance=false --sbom=false -f OpenNest.Server/Dockerfile \
|
||||||
|
--build-arg VERSION="$VERSION" --build-arg SOURCE_REVISION="$SOURCE_SHA" \
|
||||||
|
--build-arg SDK_IMAGE="$SDK_IMAGE" --build-arg RUNTIME_IMAGE="$RUNTIME_IMAGE" \
|
||||||
|
-t "$LOCAL_IMAGE" . 2>&1 | tee "$RESULTS/build.log"
|
||||||
|
python3 scripts/server_image_release.py local
|
||||||
|
- name: Freeze the inspected image identity for smoke and publication
|
||||||
|
env:
|
||||||
|
IMAGE_ID: ${{ steps.build.outputs.image_id }}
|
||||||
|
run: printf 'LOCAL_IMAGE=%s\n' "$IMAGE_ID" >> "$GITHUB_ENV"
|
||||||
|
- name: Real-client persistence, backup/restore and runtime smoke
|
||||||
|
run: scripts/Test-ServerContainer.sh --image "$LOCAL_IMAGE" --results "$RESULTS/smoke"
|
||||||
|
- name: Retain logs and safe provenance only
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: server-image-validation-${{ github.run_id }}
|
||||||
|
path: |
|
||||||
|
${{ runner.temp }}/server-image-results/**/*.log
|
||||||
|
${{ runner.temp }}/server-image-results/tests/*.trx
|
||||||
|
${{ runner.temp }}/server-image-metadata/bases.json
|
||||||
|
${{ runner.temp }}/server-image-metadata/local.json
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
publish:
|
||||||
|
if: >-
|
||||||
|
github.repository == 'ajisaacs/OpenNest' &&
|
||||||
|
github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 40
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
concurrency:
|
||||||
|
group: opennest-server-ghcr-publish
|
||||||
|
cancel-in-progress: false
|
||||||
|
env:
|
||||||
|
LOCAL_IMAGE: opennest-server:release
|
||||||
|
outputs:
|
||||||
|
source_sha: ${{ steps.source.outputs.source_sha }}
|
||||||
|
version: ${{ steps.source.outputs.version }}
|
||||||
|
manifest_digest: ${{ steps.readback.outputs.manifest_digest }}
|
||||||
|
config_digest: ${{ steps.readback.outputs.config_digest }}
|
||||||
|
steps:
|
||||||
|
- name: Isolate logs, safe metadata and ephemeral auth
|
||||||
|
run: |
|
||||||
|
printf 'METADATA_DIR=%s/server-image-metadata\nRESULTS=%s/server-image-results\nDOCKER_CONFIG=%s/server-image-auth\n' \
|
||||||
|
"$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
ref: master
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Resolve approved tag, release event commit and master ancestry
|
||||||
|
id: source
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
||||||
|
run: python3 scripts/server_image_release.py source
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
ref: ${{ steps.source.outputs.source_sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Use immutable source/version for every remaining gate
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.source.outputs.version }}
|
||||||
|
SOURCE_SHA: ${{ steps.source.outputs.source_sha }}
|
||||||
|
run: |
|
||||||
|
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||||
|
printf 'VERSION=%s\nSOURCE_SHA=%s\n' "$VERSION" "$SOURCE_SHA" >> "$GITHUB_ENV"
|
||||||
|
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||||
|
env:
|
||||||
|
DOTNET_INSTALL_DIR: ${{ runner.temp }}/dotnet8
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
- name: Test fail-closed release and cleanup helpers
|
||||||
|
run: |
|
||||||
|
python3 -m unittest discover -s scripts -p test_server_image_release.py -v
|
||||||
|
python3 -m unittest discover -s scripts -p test_server_container_cleanup.py -v
|
||||||
|
- name: Build and test exact Server source
|
||||||
|
run: |
|
||||||
|
dotnet build OpenNest.Server/OpenNest.Server.csproj -c Release
|
||||||
|
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release --logger 'trx;LogFileName=server.trx' --results-directory "$RESULTS/tests"
|
||||||
|
- name: Resolve base image digests
|
||||||
|
id: bases
|
||||||
|
run: python3 scripts/server_image_release.py bases
|
||||||
|
- name: Build exact local linux/amd64 image (never cached)
|
||||||
|
id: build
|
||||||
|
env:
|
||||||
|
SDK_IMAGE: ${{ steps.bases.outputs.sdk_image }}
|
||||||
|
RUNTIME_IMAGE: ${{ steps.bases.outputs.runtime_image }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "$RESULTS"
|
||||||
|
docker build --pull --no-cache --platform linux/amd64 --provenance=false --sbom=false -f OpenNest.Server/Dockerfile \
|
||||||
|
--build-arg VERSION="$VERSION" --build-arg SOURCE_REVISION="$SOURCE_SHA" \
|
||||||
|
--build-arg SDK_IMAGE="$SDK_IMAGE" --build-arg RUNTIME_IMAGE="$RUNTIME_IMAGE" \
|
||||||
|
-t "$LOCAL_IMAGE" . 2>&1 | tee "$RESULTS/build.log"
|
||||||
|
python3 scripts/server_image_release.py local
|
||||||
|
- name: Freeze the inspected image identity for smoke and publication
|
||||||
|
env:
|
||||||
|
IMAGE_ID: ${{ steps.build.outputs.image_id }}
|
||||||
|
run: printf 'LOCAL_IMAGE=%s\n' "$IMAGE_ID" >> "$GITHUB_ENV"
|
||||||
|
- name: Smoke the same local image before any registry write
|
||||||
|
run: scripts/Test-ServerContainer.sh --image "$LOCAL_IMAGE" --results "$RESULTS/smoke"
|
||||||
|
- name: Refuse unknown visibility, wrong association, and both existing tags
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: python3 scripts/server_image_release.py preflight
|
||||||
|
- name: Login and publish only the two immutable tags (no rebuild)
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
mkdir -m 700 -p "$DOCKER_CONFIG"
|
||||||
|
printf '%s' "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
|
||||||
|
image=ghcr.io/ajisaacs/opennest-server
|
||||||
|
docker tag "$LOCAL_IMAGE" "$image:$VERSION"
|
||||||
|
docker tag "$LOCAL_IMAGE" "$image:sha-$SOURCE_SHA"
|
||||||
|
docker push "$image:$VERSION"
|
||||||
|
docker push "$image:sha-$SOURCE_SHA"
|
||||||
|
- name: Exact registry readback of both tags and smoked image config
|
||||||
|
id: readback
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: python3 scripts/server_image_release.py readback
|
||||||
|
- name: Remove ephemeral Docker credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf -- "$DOCKER_CONFIG"
|
||||||
|
- name: Retain logs and safe provenance only (not a verification verdict)
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: server-image-publication-${{ github.run_id }}
|
||||||
|
path: |
|
||||||
|
${{ runner.temp }}/server-image-results/**/*.log
|
||||||
|
${{ runner.temp }}/server-image-results/tests/*.trx
|
||||||
|
${{ runner.temp }}/server-image-metadata/source.json
|
||||||
|
${{ runner.temp }}/server-image-metadata/bases.json
|
||||||
|
${{ runner.temp }}/server-image-metadata/local.json
|
||||||
|
${{ runner.temp }}/server-image-metadata/preflight.json
|
||||||
|
${{ runner.temp }}/server-image-metadata/registry.json
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
verify-published:
|
||||||
|
needs: publish
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 25
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: read
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.publish.outputs.version }}
|
||||||
|
SOURCE_SHA: ${{ needs.publish.outputs.source_sha }}
|
||||||
|
MANIFEST_DIGEST: ${{ needs.publish.outputs.manifest_digest }}
|
||||||
|
CONFIG_DIGEST: ${{ needs.publish.outputs.config_digest }}
|
||||||
|
steps:
|
||||||
|
- name: Isolate logs, safe metadata and ephemeral auth
|
||||||
|
run: |
|
||||||
|
printf 'METADATA_DIR=%s/server-image-metadata\nRESULTS=%s/server-image-results\nDOCKER_CONFIG=%s/server-image-auth\n' \
|
||||||
|
"$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.publish.outputs.source_sha }}
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||||
|
env:
|
||||||
|
DOTNET_INSTALL_DIR: ${{ runner.temp }}/dotnet8
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
- name: Pull returned digest on a clean runner
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
python3 -c 'import os,sys; sys.path.insert(0,"scripts"); from server_image_release import sha256,commit,version; sha256(os.environ["MANIFEST_DIGEST"]); sha256(os.environ["CONFIG_DIGEST"]); commit(os.environ["SOURCE_SHA"]); version("v"+os.environ["VERSION"])'
|
||||||
|
mkdir -m 700 -p "$DOCKER_CONFIG"
|
||||||
|
printf '%s' "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
|
||||||
|
image="ghcr.io/ajisaacs/opennest-server@$MANIFEST_DIGEST"
|
||||||
|
docker pull --platform linux/amd64 "$image"
|
||||||
|
printf 'LOCAL_IMAGE=%s\n' "$image" >> "$GITHUB_ENV"
|
||||||
|
- name: Remove ephemeral Docker credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf -- "$DOCKER_CONFIG"
|
||||||
|
- name: Verify pulled digest, config, platform and provenance
|
||||||
|
run: python3 scripts/server_image_release.py pulled
|
||||||
|
- name: Real-client persistence smoke of pulled digest
|
||||||
|
run: scripts/Test-ServerContainer.sh --image "$LOCAL_IMAGE" --results "$RESULTS/smoke"
|
||||||
|
- name: Mark verified only after pulled-image smoke passes
|
||||||
|
run: printf 'Verified private server image `%s` from `%s`.\n' "$LOCAL_IMAGE" "$SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
- name: Retain logs and safe pulled-image provenance only
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: server-image-pulled-verification-${{ github.run_id }}
|
||||||
|
path: |
|
||||||
|
${{ runner.temp }}/server-image-results/**/*.log
|
||||||
|
${{ runner.temp }}/server-image-metadata/pulled.json
|
||||||
|
retention-days: 14
|
||||||
@@ -17,7 +17,9 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
windows:
|
windows:
|
||||||
runs-on: windows-2022
|
runs-on: windows-2022
|
||||||
timeout-minutes: 30
|
# The suites have roughly doubled since the v0.3.0 run (7.5 min); the hang
|
||||||
|
# guard below names a stuck test long before this cap.
|
||||||
|
timeout-minutes: 45
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -47,13 +49,30 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
dotnet build OpenNest.sln -c Release
|
dotnet build OpenNest.sln -c Release
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
||||||
- name: Run all test projects on Windows
|
- name: Install pinned Poppler for PDF report tests
|
||||||
run: |
|
run: |
|
||||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests')) {
|
$zip = Join-Path $env:RUNNER_TEMP 'poppler-26.09.0.zip'
|
||||||
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
Invoke-WebRequest 'https://github.com/oschwartz10612/poppler-windows/releases/download/v26.09.0-0/Release-26.09.0-0.zip' -OutFile $zip
|
||||||
|
$actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
if ($actual -ne '7a6f256a0ddf7536182246a5733331bf4677cbcc34f4663774947ad34556c8d0') { throw 'Poppler archive hash mismatch.' }
|
||||||
|
$destination = Join-Path $env:RUNNER_TEMP 'poppler-release-tests'
|
||||||
|
Expand-Archive $zip -DestinationPath $destination
|
||||||
|
$bin = Join-Path $destination 'poppler-26.09.0/Library/bin'
|
||||||
|
$tool = Join-Path $bin 'pdftotext.exe'
|
||||||
|
if (-not (Test-Path $tool)) { throw 'Pinned pdftotext.exe is missing.' }
|
||||||
|
& $tool -v
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Pinned pdftotext.exe cannot start.' }
|
||||||
|
$bin >> $env:GITHUB_PATH
|
||||||
|
- name: Run all test projects on Windows
|
||||||
|
# A test silent for 5 minutes is killed and named in the log, with a
|
||||||
|
# mini dump in TestResults, instead of holding the runner until the
|
||||||
|
# job timeout.
|
||||||
|
run: |
|
||||||
|
foreach ($project in @('OpenNest.Tests', 'OpenNest.Desktop.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||||
|
dotnet test "$project/$project.csproj" -c Release --no-build --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||||
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
||||||
}
|
}
|
||||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug --logger 'trx;LogFileName=OpenNest.Tests.Debug.trx' --results-directory TestResults
|
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug --blame-hang-timeout 5m --blame-hang-dump-type mini --logger 'trx;LogFileName=OpenNest.Tests.Debug.trx' --results-directory TestResults
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'Debug tests failed.' }
|
if ($LASTEXITCODE -ne 0) { throw 'Debug tests failed.' }
|
||||||
- name: Publish and verify Windows package
|
- name: Publish and verify Windows package
|
||||||
run: ./scripts/Publish-Windows.ps1 -Version $env:RELEASE_VERSION
|
run: ./scripts/Publish-Windows.ps1 -Version $env:RELEASE_VERSION
|
||||||
@@ -77,6 +96,11 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
artifacts/*.zip
|
artifacts/*.zip
|
||||||
artifacts/*.sha256
|
artifacts/*.sha256
|
||||||
|
artifacts/velopack/OpenNest-win-Setup.exe
|
||||||
|
artifacts/velopack/OpenNest-win-Portable.zip
|
||||||
|
artifacts/velopack/*-full.nupkg
|
||||||
|
artifacts/velopack/releases.win.json
|
||||||
|
artifacts/velopack/RELEASES
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
compression-level: 0
|
compression-level: 0
|
||||||
@@ -85,6 +109,6 @@ jobs:
|
|||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
with:
|
with:
|
||||||
name: windows-test-results
|
name: windows-test-results
|
||||||
path: TestResults/*.trx
|
path: TestResults/
|
||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
+9
-1
@@ -207,13 +207,21 @@ FakesAssemblies/
|
|||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|
||||||
# Claude Code
|
# Local agent state and temporary planning/progress documents
|
||||||
.claude/
|
.claude/
|
||||||
|
/.hermes/plans/
|
||||||
|
/.hermes/progress/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
/docs/*-plan.md
|
||||||
|
/docs/*-progress.md
|
||||||
|
|
||||||
# Launch settings
|
# Launch settings
|
||||||
**/Properties/launchSettings.json
|
**/Properties/launchSettings.json
|
||||||
|
|
||||||
# Local test config (contains user-specific paths to proprietary test assets)
|
# Local test config (contains user-specific paths to proprietary test assets)
|
||||||
OpenNest.Tests/test-config.json
|
OpenNest.Tests/test-config.json
|
||||||
|
|
||||||
|
# Vendor programming manuals: keep reference copies outside source control.
|
||||||
|
CINCINNATI LASER PROGRAMMING MANUAL.pdf
|
||||||
|
TF5200_programming_manual_en.pdf
|
||||||
@@ -1,157 +1,81 @@
|
|||||||
# AGENTS.md
|
# OpenNest agent instructions
|
||||||
|
|
||||||
This file contains shared repository instructions for coding agents working on OpenNest. It is the single source of truth; `CLAUDE.md` imports it for Claude Code compatibility.
|
Shared instructions; keep `CLAUDE.md` as the thin `@AGENTS.md` import.
|
||||||
|
OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform libraries.
|
||||||
|
|
||||||
## Project Overview
|
## Working rules
|
||||||
|
|
||||||
OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts on material plates to minimize waste. It imports DXF drawings, places parts onto plates using NFP-based (No Fit Polygon) and rectangle-packing algorithms, and can export nest layouts as DXF or post-process them to G-code for CNC cutting machines.
|
- Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search.
|
||||||
|
- Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`.
|
||||||
|
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`. 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.
|
||||||
|
|
||||||
## Build
|
## Build and test
|
||||||
|
|
||||||
This is a .NET 8 solution using SDK-style `.csproj` files. The desktop app and Windows-dependent projects target `net8.0-windows`; the core libraries and `OpenNest.Console` target `net8.0`. Build the full solution on Windows with:
|
```sh
|
||||||
|
# Full solution: Windows
|
||||||
```bash
|
|
||||||
dotnet build OpenNest.sln
|
dotnet build OpenNest.sln
|
||||||
|
|
||||||
|
# Cross-platform suites: run independently on Linux/macOS/Windows
|
||||||
|
# Use Release for routine runs; use Debug explicitly for DEBUG-only work counters.
|
||||||
|
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release
|
||||||
|
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj -c Release
|
||||||
|
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj -c Release
|
||||||
|
dotnet test OpenNest.Desktop.Tests/OpenNest.Desktop.Tests.csproj -c Release
|
||||||
|
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release
|
||||||
|
dotnet test OpenNest.FrontEnd.Tests/OpenNest.FrontEnd.Tests.csproj -c Release
|
||||||
|
|
||||||
|
# Windows runtime tests
|
||||||
|
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
Cross-platform whole-job engine tests (net8.0, runs on Linux/macOS/Windows without the desktop project or DXF fixtures): `dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj`. The main `OpenNest.Tests` suite also targets `net8.0`: run `dotnet test OpenNest.Tests/OpenNest.Tests.csproj` independently on Linux/macOS/Windows. It must not reference the WinForms `OpenNest` project. The API, Data, and post-processor libraries target `net8.0`. Post-processor projects live under `Posts/` (`Posts/OpenNest.Posts.<Name>/`, referencing `..\..\OpenNest.Core`); their build deployment still targets the desktop app's `net8.0-windows/Posts` directory. Optional CHR-font fixtures are configured through `OpenNest.Tests/test-config.json` and skip when absent.
|
Only Plan Cutting changes need the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); never run it for nesting engines, fills, UI, posts or file formats. Limit it to the areas the change touches with `OPENNEST_PROBLEM_NESTS_CHECKS` (table in that doc); 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`.
|
||||||
|
|
||||||
`OpenNest.WinForms.Tests` contains the desktop-assembly-dependent `CadBendNoteTests` (`CadText`) and `CuttingParametersSerializerTests` (`CuttingParametersSerializer`). It targets `net8.0-windows`, references `OpenNest`, and requires a Windows runner: `dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj`. Keep future desktop-dependent tests here rather than in `OpenNest.Tests`. Linux cross-compilation uses `dotnet build OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj -p:EnableWindowsTargeting=true`; cross-compilation is not Windows runtime verification.
|
Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror.
|
||||||
|
|
||||||
Cross-platform CAD import tests: `dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj`. These synthetic-DXF and bend-repair tests target `net8.0`, require no external fixtures, and are included in the solution. Build the headless console independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
|
## Project map and boundaries
|
||||||
|
|
||||||
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `Clipper2` 2.0.0 (region offsetting, in OpenNest.Core), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training).
|
- `OpenNest.Core`: domain (`Nest -> Plate -> Part -> Drawing -> CNC.Program`), geometry, cutting strategies and diagnostics. Angles are radians; use `Tolerance.Epsilon` for geometry comparisons. `OpenNest.Math` shadows `System.Math`, so qualify the latter.
|
||||||
|
- `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based.
|
||||||
|
- Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution.
|
||||||
|
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence; 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). WinForms `Properties.Settings` persist to `%APPDATA%\OpenNest\user-settings.json` through `AppDataSettingsProvider`, never inside the Velopack install folder. 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.Desktop` (net8.0): toolkit-neutral desktop logic shared by the WinForms app and `OpenNest.Avalonia`, the cross-platform desktop in progress. It must not reference WinForms/WindowsDesktop, `System.Drawing.Common` or Avalonia (`OpenNest.Desktop.Tests` guards this, including transitive packages); put toolkit-specific rendering, dialogs and settings behind adapters in the front ends.
|
||||||
|
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection and the Python sketch-search prototype (`sketch-search/README.md`). Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
|
||||||
|
|
||||||
### Windows release packaging
|
## Geometry and ownership safeguards
|
||||||
|
|
||||||
The GitHub `Windows release build` workflow runs on `release/vX.Y.Z` branches, `vX.Y.Z` tags, or manual dispatch. It builds with .NET 8 on `windows-2022`, runs all four test projects in Release plus the main Debug suite, and executes `scripts/Publish-Windows.ps1`. The script creates a self-contained win-x64 desktop ZIP with all three shipped posts plus pinned Gpt6Astra/Opus55/Qwen38FlashNext plug-ins from `scripts/external-engines.json`, runs their tests against this host, validates package contents/version and registry discovery (including a missing-DLL failure case), launches the extracted app, and writes a SHA-256 checksum. It refuses an existing output directory. Workflow artifacts are release candidates, not automatically published releases; follow [the release procedure](docs/releasing.md). Keep Gitea authoritative for Git refs.
|
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
|
||||||
|
- 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.
|
||||||
|
- `Plate.Outline`/`NestPlateStock.Outline` (`SheetOutline`) make a sheet irregular; `Size` stays its bounding box. Only Irregular fills it (`PackBoundary` eroded by the largest edge spacing); every other engine and fill strategy must place nothing on it, validation must reject parts outside it or over its obstacles, and automatic cut-offs refuse it. See [irregular stock](docs/irregular-stock.md).
|
||||||
|
- 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.
|
||||||
|
- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. Every post must raise before a marked rapid or refuse the nest.
|
||||||
|
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
|
||||||
|
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
|
||||||
|
|
||||||
### Fill performance verification
|
Read the relevant contract before changing its behavior:
|
||||||
|
|
||||||
See [fill verification](docs/performance/fill-verification.md) for opt-in measurements, targeted tests, Debug counter isolation, and predictor initialization rules. Keep training bitmaps by default; the angle builder checks predictor availability before scalar-only extraction.
|
- [Nest file format](docs/nest-file-format.md)
|
||||||
|
- [Irregular stock](docs/irregular-stock.md): outlined sheets, what honors the outline and what refuses it
|
||||||
## Architecture
|
- [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) (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
|
||||||
Nine projects form a layered architecture:
|
- [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
|
||||||
### OpenNest.Core (class library)
|
- [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
|
||||||
Domain model, geometry, and CNC primitives organized into namespaces:
|
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
|
||||||
|
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
|
||||||
- **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
|
- [Pre-post verification](docs/post-verification.md)
|
||||||
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) and an optional `Variables` dictionary of `VariableDefinition` entries. Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. `VariableDefinition` stores a named variable's expression, resolved value, and flags (`Inline`, `Global`). `ProgramVariableManager` manages numbered machine variables for post-processor output.
|
- [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
|
||||||
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon` (convex NFP only), `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, `ClipperBridge` (Clipper2 region offsetting for CPU preparation only; see Key Patterns), and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction; deliberately hand-rolled as the reference for a future GPU kernel, with the port contract in its class summary).
|
- [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
|
||||||
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
|
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
|
||||||
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `ExpressionEvaluator` (arithmetic expression parser for G-code variable expressions with `$name` references). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
|
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
|
||||||
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
|
- [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.
|
||||||
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
|
- [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, opt-in rolling `dev-build` pre-release from green master pushes, the nest server's `/updates` mirror first in Database mode (GitHub fallback), `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
|
||||||
- **CutOffs** (`namespace OpenNest`): `CutOff` (axis-aligned cut line with position, axis, optional start/end limits), `CutOffAxis` enum (`Horizontal`, `Vertical`), `CutOffSettings` (clearance, overtravel, min segment length, direction), `CutDirection` enum (`TowardOrigin`, `AwayFromOrigin`). Cut-offs generate CNC `Program` objects with trimmed line segments that avoid parts.
|
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
|
||||||
- **Splitting** (`Splitting/`, `namespace OpenNest`): `DrawingSplitter` splits a Drawing into multiple pieces along split lines. `ISplitFeature` strategy pattern with implementations: `StraightSplit` (clean edge), `WeldGapTabSplit` (rectangular tab spacers on one side), `SpikeGrooveSplit` (interlocking spike/V-groove pairs). `AutoSplitCalculator` computes split lines for fit-to-plate and split-by-count modes. Supporting types: `SplitLine`, `SplitParameters`, `SplitFeatureResult`.
|
- [Desktop help](docs/desktop-help.md): WebView2 help window, F1 topic IDs and the pinned help release both packaging scripts install.
|
||||||
- **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning.
|
|
||||||
|
|
||||||
### OpenNest.Engine (class library, depends on Core)
|
|
||||||
Nesting algorithms use the jobs-only API. `INestingEngine.Solve(NestJob)` returns `NestJobResult`; `NestJobRunner` alone commits demand and finite/unlimited stock accounting; `IPlateNester` only proposes a one-sheet candidate; and `PlateNesterFactory` resolves a named built-in placement strategy.
|
|
||||||
|
|
||||||
- **Whole-job API (`Jobs/`)**: `NestJob` owns part requirements, physical stock, and options for one material/thickness/unit system. `PartGeometrySnapshot` contains owned flat rapid/line/arc geometry; results contain stock IDs and placement poses (radians), not mutable desktop models. `NestJobPlacementValidator` validates contours, rotation, usable work area, overlap, and spacing before accounting commits. The runner selects valid trial candidates greedily by priority vector, sheet area, envelope, and input order; an incomplete result reports why but does not prove geometric impossibility. `DrawingJobMapper` and `NestResultMaterializer` are the domain-boundary adapters.
|
|
||||||
- **Placement boundary (`Jobs/Placement/`, `Jobs/Adapters/`)**: `DefaultPlateNester`, `StripPlateNester`, and `RemnantPlateNester` are built-ins with run-scoped private geometry. `PlateFillService` is the public single-plate proposal service for interactive fill/group/pack flows; it returns parts without mutating caller-owned plates. Job-path identity is reference-based rather than drawing name; `PlateOptimizer` retains name-based helpers and remains outside the runner path.
|
|
||||||
- **Filler pipeline (`Jobs/Placement/Fillers/`)**: internal `DefaultPlateFiller`, `StripPlateFiller`, and policy-backed `RemnantPlateFiller` implement the standard single-plate geometry pipeline. `Default` runs the Linear, Pairs, RectBestFit, and Extents phases; remnant variants preserve their distinct comparer, direction, trim-axis, and angle-ordering policies.
|
|
||||||
- **Engine registration**: `NestingEngineRegistry` holds whole-job `INestingEngine` implementations including the four fixed strategies and `StockLadder`. It loads plug-ins that implement `INestingEngine` and have a public parameterless constructor. Plug-ins for the removed single-plate inheritance API are not binary compatible.
|
|
||||||
- **Plugin engines**: independent `INestingEngine` plugins are class libraries that reference `OpenNest.Engine` and are built outside `OpenNest.sln`. The desktop app and `OpenNest.Benchmark` load them from an `Engines/` folder next to their build output (e.g. `OpenNest.Benchmark/bin/<Config>/net8.0/Engines/`). Do not add engine projects to this repo.
|
|
||||||
- **IFillComparer**: Interface enabling filler-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), and `HorizontalRemnantComparer` (minimize Y-extent) are grouped into `FillPolicy` on `FillContext`.
|
|
||||||
- **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`.
|
|
||||||
- **Strategies/** (`namespace OpenNest.Engine.Strategies`): Pluggable fill strategy layer — `IFillStrategy` interface, `FillContext`, `FillStrategyRegistry` (auto-discovers strategies via reflection, supports plugin DLLs), `FillHelpers`. Built-in strategies: `LinearFillStrategy`, `PairsFillStrategy`, `RectBestFitStrategy`, `ExtentsFillStrategy`.
|
|
||||||
- **BestFit/** (`namespace OpenNest.Engine.BestFit`): NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups.
|
|
||||||
- **RectanglePacking/** (`namespace OpenNest.Engine.RectanglePacking`): `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
|
|
||||||
- **CirclePacking/** (`namespace OpenNest.Engine.CirclePacking`): Alternative packing for circular parts.
|
|
||||||
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features; `Extract(drawing, includeBitmask: false)` skips the 32x32 training bitmap for inference while scalars stay identical; the default overload keeps it for training), `BruteForceRunner` (full angle sweep for training data).
|
|
||||||
- `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints.
|
|
||||||
- `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback.
|
|
||||||
|
|
||||||
### OpenNest.IO (class library, depends on Core)
|
|
||||||
File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
|
|
||||||
|
|
||||||
- `DxfImporter`/`DxfExporter` — DXF file import/export via ACadSharp.
|
|
||||||
- `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format).
|
|
||||||
- `ProgramReader` — G-code text parser.
|
|
||||||
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types.
|
|
||||||
- `CadImporter` — shared "DXF → Drawing" service used by the UI, console, MCP, API, and training projects. Two-stage API: `Import(path, options)` loads raw entities, runs bend detection, and returns a mutable `CadImportResult`; `BuildDrawing(result, visible, bends, quantity, customer, editedProgram)` produces a fully-populated `Drawing` with `Source.Offset`, `SourceEntities`, `SuppressedEntityIds`, and bends. `ImportDrawing(path, options)` composes both stages for headless callers.
|
|
||||||
- `CadImportOptions`, `CadImportResult` — inputs and intermediate state for `CadImporter`.
|
|
||||||
- `Bending/BendRepair` — conservative opt-in repair configured by `CadImportOptions.BendRepair`. Requires explicit inches/mm source units and an endpoint movement limit above 0.001 and at most 3.175 physical mm. Only unambiguous paired ETCH/SCRIBE ticks may move along the existing bend axis; cut geometry and unrelated marks must remain unchanged. Opt-in imports preserve source marks without blanket etch regeneration and expose per-bend outcomes in `CadImportResult.BendRepairReports`.
|
|
||||||
|
|
||||||
### OpenNest.Console (console app, depends on Core + Engine + IO)
|
|
||||||
Command-line interface for batch nesting (`net8.0`). Supports DXF import, plate configuration, linear fill, and multi-drawing auto-nesting through the active engine's `Nest()` (`--autonest`). `--repair-bends-mm <limit> --cad-units inches|mm` opts newly imported DXFs into conservative bend repair and prints per-bend reports; it does not rescale coordinates or repair saved nests.
|
|
||||||
|
|
||||||
### OpenNest.Gpu (class library, depends on Core + Engine)
|
|
||||||
GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` implements `IPairEvaluator`, `GpuSlideComputer` implements `ISlideComputer`, and `PartBitmap` handles rasterization. `GpuEvaluatorFactory` provides factory methods.
|
|
||||||
|
|
||||||
### OpenNest.Training (console app, depends on Core + Engine)
|
|
||||||
Training data collection for ML angle prediction. `TrainingDatabase` stores per-angle nesting results in SQLite via EF Core for offline model training.
|
|
||||||
|
|
||||||
### OpenNest.Benchmark (console app, depends on Core + Engine + IO)
|
|
||||||
Compares registered `INestingEngine` implementations against each other on real `.nest` files. Each engine solves the whole job — it owns its own multi-plate/size strategy rather than being handed one already-sized plate at a time. Fully generic — it never hardcodes drawing geometry, just reads whatever drawings/quantities/plate settings each input file already has.
|
|
||||||
|
|
||||||
- `JobLoader` builds `BenchmarkJob`s from a `.nest` file or a folder of them via `NestReader`, using every drawing with `Quantity.Required > 0`. `--sheet-sizes` can sweep a fixed list of plate sizes instead of each file's own.
|
|
||||||
- `DxfManifestLoader` builds a `BenchmarkJob` from a JSON manifest (`sheetSizes`, `spacing`, `edgeSpacing`, `quadrant`, `parts[] { dxf, quantity, allowRotation }`) instead of a `.nest`, importing each DXF with `CadImporter.ImportDrawing`. DXF paths resolve relative to the manifest; sheet sizes are required (manifest or `--sheet-sizes`, which overrides). `allowRotation: false` locks rotation the same way `NestRunner` does. `JobLoader.Load` routes `*.json` inputs to it, and folder scans pick up `*.nest` plus `*.manifest.json` (plain `*.json` is ignored so `--output` reports are never read as manifests). Invalid manifests throw rather than being skipped.
|
|
||||||
- `BenchmarkJob.BuildNestJob(maxPlates)` converts the job into a `NestJob`: one `NestJobPart` per requested drawing (via `DrawingJobMapper.FromDrawing`) and one `NestPlateStock` per candidate sheet size (unlimited quantity — the engine decides how many of each size it uses).
|
|
||||||
- `BenchmarkRunner` fans the (job × engine) pairs out with `Parallel.ForEach` (`NoBuffering`, `MaxDegreeOfParallelism` from `--parallel`, CLI default 3, `Run`'s own default 1) and writes results by index so report order stays job-then-engine. Each solve builds its own `NestJob` snapshot and materialized drawings, so solves share no mutable drawing state. Concurrent solves compete for cores, so `Time(ms)` is only clean at `--parallel 1`. It calls each engine's `INestingEngine.Solve(NestJob)` once per job, under a wall-clock timeout so a runaway or hanging engine can't stall the whole benchmark run, then materializes the result back into legacy `Plate`/`Part` objects via `NestResultMaterializer` for scoring.
|
|
||||||
- `NestValidator` checks the returned layout: every part inside `Plate.WorkArea()`, every pair at least `Plate.PartSpacing` apart (checked geometrically: each part's perimeter inflated and cutouts shrunk by the spacing, tested against the other part's raw material with holes subtracted, so part-in-part inside a cutout is legal; an X-sorted bounding-box sweep prunes distant pairs), and no drawing over its requested quantity. `ValidateAgainstJob` also checks the raw `NestJobResult`: every sheet must match a stock entry the job offered (size, spacing, edge spacing, quadrant; finite quantity not overdrawn), and every placement rotation must satisfy its part's `RotationPolicy.Allows`. An invalid, throwing, or timed-out run places nothing for scoring.
|
|
||||||
- Ranking (`Report.Compare`): valid > invalid, fully placed > not, then lower `JobResult.Cost`, then fewer plates. Cost = salvage-credited sheet area (`StockLadderNestingEngine.EstimateNetArea` per plate, recomputed from job geometry) + `BenchmarkJob.UnplacedPartPenalty` (largest candidate sheet area) per unplaced part, so dropping hard parts never improves the score. The summary sums cost and areas across jobs (area-weighted, not a mean of per-job percentages). Without `--sheet-sizes`, `.nest` jobs only offer their original sizes, and the CLI warns that this hints engines. Numeric CLI and manifest sheet sizes parse with the invariant culture (`JobLoader.TryParseSheetSize`).
|
|
||||||
- `--engines Name1,Name2` filters to specific registered engines (default: all); `--csv <path>` writes a flat per-job CSV alongside the console report. `--progress` passes each solve a `JobProgressLog`, which writes `[job/engine]` lines for start, finish, every `PlateCommitted`, and `EvaluatingCandidate` throttled to one line per 2 s.
|
|
||||||
- `tools/PepNestExport` (outside the solution; references `PepLib.Core` from the sibling `PepApi.Core` repo) converts a PepApi year of PEP nests into `.nest` files that keep PEP's placements as the benchmark `Baseline`. PEP loop quirks: sub-loop calls continue the incremental position; lead-in/out, `DESTRUCT CUT` and non-cut moves must not reach the program as rapids (a program's bounding box counts rapid endpoints); contours may be broken by uncut micro-joint tabs (a rapid of up to 0.25 across the tab, at the seam or mid-contour, e.g. a cutout cut as two halves), which the export bridges only where the pieces chain into a closed loop; and one drawing can be placed through several loops with different origins.
|
|
||||||
|
|
||||||
### OpenNest.Mcp (console app, depends on Core + Engine + IO)
|
|
||||||
MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`.
|
|
||||||
|
|
||||||
- **Tools/InputTools**: `load_nest`, `import_dxf`, `create_drawing` (built-in shapes or G-code).
|
|
||||||
- **Tools/SetupTools**: `create_plate`, `clear_plate`.
|
|
||||||
- **Tools/NestingTools**: `fill_plate`, `fill_area`, `fill_remnants`, `pack_plate`.
|
|
||||||
- **Tools/InspectionTools**: `get_plate_info`, `get_parts`, `check_overlaps`.
|
|
||||||
- `NestSession` — in-memory state across tool calls (current Nest, standalone plates/drawings).
|
|
||||||
|
|
||||||
### OpenNest (WinForms WinExe, depends on Core + Engine + IO)
|
|
||||||
The UI application with MDI interface.
|
|
||||||
|
|
||||||
- **Auto Nest engine routing**: when the selected engine is not a built-in fill strategy (`EngineSelection.IsFillStrategy` is false, i.e. StockLadder or an `Engines/` plug-in), `MainForm.RunJobEngineAsync` solves the whole job through `INestingEngine.Solve`. `JobEngineNest` builds the `NestJob` from the auto-nest items and either the plate options or the current plate, and it converts `NestJobProgress` for `NestProgressForm`: an engine's `LegacyProgress` passes through, and otherwise the stage and committed counts become the description. It then binds the result poses back onto the nest's own drawings. Whole-job engines throw on cancel, so the progress form hides Accept (`AllowAccept = false`) and Stop discards the run. Built-in strategies keep the existing per-plate fill path.
|
|
||||||
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), `SplitDrawingForm` (split oversized drawings into smaller pieces, launched from CadConverterForm), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
|
|
||||||
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
|
|
||||||
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`, `ActionCutOff`.
|
|
||||||
- **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime. Plugin sources live in the repository's `Posts/` folder (the solution's `PostProcessors` folder).
|
|
||||||
|
|
||||||
## File Format
|
|
||||||
|
|
||||||
Nest files (`.nest`, ZIP-based) use v2 JSON format:
|
|
||||||
- `nest.json` — single JSON file containing all nest metadata: nest info (name, units, customer, dates, notes), plate defaults (size, thickness, quadrant, spacing, material, edge spacing), drawings array (id, name, color, quantity, priority, rotation constraints, material, source), and plates array (id, size, material, edge spacing, parts with drawingId/x/y/rotation, cutoffs with x/y/axis/startLimit/endLimit)
|
|
||||||
- `programs/program-N` — G-code text for each drawing's cut program (N = drawing id)
|
|
||||||
- `bestfits/bestfit-N` — JSON array of best-fit pair evaluation results per drawing, keyed by plate size/spacing (optional, only present if best-fit data was computed)
|
|
||||||
|
|
||||||
## Tool Preferences
|
|
||||||
|
|
||||||
Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary method for exploring and analyzing this codebase. It is faster and more efficient than file-based searches. Use it for finding symbols, references, diagnostics, type hierarchies, and code navigation. Only fall back to Glob/Grep when Roslyn Bridge cannot fulfill the query.
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
- Always use `var` instead of explicit types (e.g., `var parts = new List<Part>();` not `List<Part> parts = new List<Part>();`).
|
|
||||||
|
|
||||||
## Documentation Maintenance
|
|
||||||
|
|
||||||
Always keep `README.md` and `AGENTS.md` up to date when making changes that affect project structure, architecture, build instructions, dependencies, or key patterns. If you add a new project, change a namespace, modify the build process, or alter significant behavior, update both files as part of the same change. Keep `CLAUDE.md` as a thin `@AGENTS.md` import rather than duplicating shared instructions.
|
|
||||||
|
|
||||||
**Do not commit** design specs, implementation plans, or other temporary planning documents (`docs/superpowers/` etc.) to the repository. These are working documents only — keep them local and untracked.
|
|
||||||
|
|
||||||
## Key Patterns
|
|
||||||
|
|
||||||
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.
|
|
||||||
- OpenNest.Engine uses sub-namespaces: `OpenNest.Engine.Fill` (fill algorithms), `OpenNest.Engine.Strategies` (pluggable strategy layer), `OpenNest.Engine.BestFit`, `OpenNest.Engine.Jobs` (whole-job API, with `.Placement` and `.Adapters`), `OpenNest.Engine.ML`, `OpenNest.Engine.RapidPlanning`, `OpenNest.Engine.Sequencing`, `OpenNest.Engine.RectanglePacking`, `OpenNest.Engine.CirclePacking`. All Engine types live in namespaces matching their directory under `OpenNest.Engine/` (project files use `namespace X;` file-scoped or block style); consumers reference them via explicit `using OpenNest.Engine[.Sub];` directives.
|
|
||||||
- `ObservableList<T>` provides ItemAdded/ItemRemoved/ItemChanged events used for automatic quantity tracking between plates and drawings.
|
|
||||||
- Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion).
|
|
||||||
- `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations.
|
|
||||||
- Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`.
|
|
||||||
- **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed with endpoints kept on the arc, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `NestValidator` uses `OffsetForValidation` instead: the same flattening with fine joins and no padding, inflated by the spacing less `NestTolerances.SpacingSlack` (0.0005), so a layout exactly at the spacing passes even after rotation and coordinate rounding leave it ~1e-4 short. `NestJobPlacementValidator` applies the same slack to its edge-distance check. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. `FillLinear` prepares each distinct `Program` (reference identity) once per public `Fill`/`FillRow` call and translates clones; never share that cache across calls or threads. Both `HasOverlappingParts` loops use one `PartOverlapChecker` per call (same keying; it also caches each part's triangles; parts and programs must not change while it is in use). Clipper is allowed only for cached CPU preparation, never in per-pair hot loops.
|
|
||||||
- **Marks are not material**: scribe/etch moves are marked on the surface, never cut through, so they are left out of nesting. `SpecialLayers.IsMaterial(layer)` (excludes `Rapid` and `Scribe`) is the filter for every consumer that builds part material from a program: drawing area, canonical angle, part collision, `PartGeometry`, plate perimeters, best-fit/pair evaluation, rotation analysis, the GPU evaluators, and both validators (`NestJobPlacementValidator`, benchmark `NestValidator`). Cutting time, on-screen display, splitting, and post-processors still see marks. Older `.nest` files (e.g. `tools/PepNestExport` output) saved etch as cut moves while their source entities kept the `SCRIBE` layer; `NestReader` runs `ScribeLayerRepair` on load to move matching program moves back to `Scribe`.
|
|
||||||
- **Native curve contact**: CPU best-fit and shared directional queries use `SpatialQuery.CurveTangencyDistance` for sum- and difference-radius tangency, checking both forward roots and both arc spans. It supplements vertex/line phases, not a complete collision/clearance validator. See [pair-spacing checks](docs/geometry/pair-spacing.md) for the regression and remaining limits.
|
|
||||||
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
|
|
||||||
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. After its null/empty guards, `DefaultFillComparer` decides unequal counts without scoring; equal counts still use scores, and exact ties retain the current layout. `FillHelpers.FillPattern` computes eager scores only when no custom comparer is supplied; custom comparers remain authoritative and may perform their own scoring.
|
|
||||||
- **Extents column pitch**: for finite valid geometry, finite pair height, and finite nonnegative spacing, `FillExtents.BuildColumn` uses `pair.Bbox.Width + partSpacing` directly. The old vertical slide calculation clamps to the same pitch, so it need not prepare boundaries or temporary test clones. Negative/nonfinite spacing or nonfinite pair height retains the legacy calculation: public/interactive callers do not all validate spacing. Do not remove `BuildPair` boundary preparation or the adjusted-column overlap fallback, or turn this shortcut into a geometry/validation policy change.
|
|
||||||
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).
|
|
||||||
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
|
|
||||||
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). MCP, API, and Training projects use `ImportDrawing` for headless conversion. The console uses `Import` followed by `BuildDrawing` so it can report bend-repair outcomes. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
|
|
||||||
- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` and the saved `SCRIBE` layer (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList<GravographPass>, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth).
|
|
||||||
@@ -16,6 +16,9 @@ public class NestRequest
|
|||||||
|
|
||||||
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
|
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
|
||||||
public string PlacementStrategy { get; init; } = "Default";
|
public string PlacementStrategy { get; init; } = "Default";
|
||||||
|
|
||||||
|
/// <summary>Registered whole-job engine. Null uses PlacementStrategy (or legacy Strategy).</summary>
|
||||||
|
public string Engine { get; init; }
|
||||||
public string Material { get; init; } = "Steel, A1011 HR";
|
public string Material { get; init; } = "Steel, A1011 HR";
|
||||||
public double Thickness { get; init; } = 0.06;
|
public double Thickness { get; init; } = 0.06;
|
||||||
public double Spacing { get; init; } = 0.1;
|
public double Spacing { get; init; } = 0.1;
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ namespace OpenNest.Api;
|
|||||||
/// <summary>One explicit physical-stock type for a whole nesting job.</summary>
|
/// <summary>One explicit physical-stock type for a whole nesting job.</summary>
|
||||||
public class NestRequestPlate
|
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 string Id { get; init; }
|
||||||
public Size Size { get; init; }
|
public Size Size { get; init; }
|
||||||
|
|
||||||
|
|||||||
@@ -5,26 +5,35 @@ using System.IO.Compression;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using OpenNest.IO;
|
using OpenNest.Engine.BestFit;
|
||||||
using OpenNest.Engine.Jobs;
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
namespace OpenNest.Api;
|
namespace OpenNest.Api;
|
||||||
|
|
||||||
/// <summary>Stable fulfillment metadata for one requested part identity.</summary>
|
/// <summary>Stable fulfillment metadata for one requested part identity.</summary>
|
||||||
public sealed record NestPartFulfillment(string PartId, int Requested, int Placed, int Unplaced);
|
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>
|
/// <summary>Physical-sheet usage for one stock identity.</summary>
|
||||||
public sealed record NestStockUsage(string StockId, int Used, int? Remaining);
|
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);
|
public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
|
||||||
|
|
||||||
|
/// <summary>Independent geometry validation; null on old archives means not recorded.</summary>
|
||||||
|
public enum NestValidationStatus { Valid, Invalid, Unrepresentable }
|
||||||
|
|
||||||
public class NestResponse
|
public class NestResponse
|
||||||
{
|
{
|
||||||
public const int CurrentSchemaVersion = 2;
|
public const int CurrentSchemaVersion = 5;
|
||||||
|
|
||||||
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
|
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
|
||||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||||
|
/// <summary>Physical sheets: sum of Plate.Quantity, not materialized layout entries.</summary>
|
||||||
public int SheetCount { get; init; }
|
public int SheetCount { get; init; }
|
||||||
|
|
||||||
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
|
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
|
||||||
@@ -35,9 +44,16 @@ public class NestResponse
|
|||||||
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
|
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
|
||||||
public NestJobStatus? Status { get; init; }
|
public NestJobStatus? Status { get; init; }
|
||||||
public NestJobStopReason? StopReason { get; init; }
|
public NestJobStopReason? StopReason { get; init; }
|
||||||
|
/// <summary>Fulfillment status is not geometry acceptance. Review this status before using Nest.</summary>
|
||||||
|
public NestValidationStatus? ValidationStatus { get; init; }
|
||||||
|
public IReadOnlyList<string> Violations { get; init; } = [];
|
||||||
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
|
public IReadOnlyList<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<NestStockUsage> StockUsage { get; init; } = [];
|
||||||
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||||
|
public NestCostSummary Costs { get; init; }
|
||||||
public Nest Nest { get; init; }
|
public Nest Nest { get; init; }
|
||||||
public NestRequest Request { get; init; }
|
public NestRequest Request { get; init; }
|
||||||
|
|
||||||
@@ -75,9 +91,13 @@ public class NestResponse
|
|||||||
ElapsedTicks = Elapsed.Ticks,
|
ElapsedTicks = Elapsed.Ticks,
|
||||||
Status = Status,
|
Status = Status,
|
||||||
StopReason = StopReason,
|
StopReason = StopReason,
|
||||||
|
ValidationStatus = ValidationStatus,
|
||||||
|
Violations = Violations is null ? [] : new List<string>(Violations),
|
||||||
Fulfillment = Fulfillment is null
|
Fulfillment = Fulfillment is null
|
||||||
? []
|
? []
|
||||||
: new List<NestPartFulfillment>(Fulfillment),
|
: new List<NestPartFulfillment>(Fulfillment),
|
||||||
|
Unplaced = Unplaced is null ? [] : new List<NestUnplacedRequestPart>(Unplaced),
|
||||||
|
Costs = Costs,
|
||||||
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
|
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
|
||||||
PlateStockMappings = PlateStockMappings is null
|
PlateStockMappings = PlateStockMappings is null
|
||||||
? []
|
? []
|
||||||
@@ -89,7 +109,7 @@ public class NestResponse
|
|||||||
|
|
||||||
var nestEntry = zip.CreateEntry("nest.nest");
|
var nestEntry = zip.CreateEntry("nest.nest");
|
||||||
using var nestMs = new MemoryStream();
|
using var nestMs = new MemoryStream();
|
||||||
new NestWriter(Nest).Write(nestMs);
|
new NestWriter(Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(nestMs);
|
||||||
nestMs.Position = 0;
|
nestMs.Position = 0;
|
||||||
await using (var stream = nestEntry.Open())
|
await using (var stream = nestEntry.Open())
|
||||||
{
|
{
|
||||||
@@ -146,7 +166,7 @@ public class NestResponse
|
|||||||
await stream.CopyToAsync(nestMs);
|
await stream.CopyToAsync(nestMs);
|
||||||
}
|
}
|
||||||
nestMs.Position = 0;
|
nestMs.Position = 0;
|
||||||
nest = new NestReader(nestMs).Read();
|
nest = new NestReader(nestMs) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NestResponse
|
return new NestResponse
|
||||||
@@ -158,9 +178,13 @@ public class NestResponse
|
|||||||
Elapsed = TimeSpan.FromTicks(archive.ElapsedTicks),
|
Elapsed = TimeSpan.FromTicks(archive.ElapsedTicks),
|
||||||
Status = hasStatusMetadata ? archive.Status : null,
|
Status = hasStatusMetadata ? archive.Status : null,
|
||||||
StopReason = hasStatusMetadata ? archive.StopReason : null,
|
StopReason = hasStatusMetadata ? archive.StopReason : null,
|
||||||
|
ValidationStatus = archive.ValidationStatus,
|
||||||
|
Violations = archive.Violations ?? [],
|
||||||
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
|
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
|
||||||
|
Unplaced = hasSchemaVersion && archive.SchemaVersion >= 5 ? archive.Unplaced ?? [] : [],
|
||||||
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
|
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
|
||||||
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
|
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
|
||||||
|
Costs = archive.Costs,
|
||||||
Nest = nest,
|
Nest = nest,
|
||||||
Request = request,
|
Request = request,
|
||||||
};
|
};
|
||||||
@@ -168,6 +192,7 @@ public class NestResponse
|
|||||||
|
|
||||||
private sealed class NestResponseArchiveDto
|
private sealed class NestResponseArchiveDto
|
||||||
{
|
{
|
||||||
|
public NestCostSummary Costs { get; init; }
|
||||||
public int SchemaVersion { get; init; }
|
public int SchemaVersion { get; init; }
|
||||||
public int SheetCount { get; init; }
|
public int SheetCount { get; init; }
|
||||||
public double Utilization { get; init; }
|
public double Utilization { get; init; }
|
||||||
@@ -175,7 +200,10 @@ public class NestResponse
|
|||||||
public long ElapsedTicks { get; init; }
|
public long ElapsedTicks { get; init; }
|
||||||
public NestJobStatus? Status { get; init; }
|
public NestJobStatus? Status { get; init; }
|
||||||
public NestJobStopReason? StopReason { get; init; }
|
public NestJobStopReason? StopReason { get; init; }
|
||||||
|
public NestValidationStatus? ValidationStatus { get; init; }
|
||||||
|
public List<string> Violations { get; init; } = [];
|
||||||
public List<NestPartFulfillment> Fulfillment { get; init; } = [];
|
public List<NestPartFulfillment> Fulfillment { get; init; } = [];
|
||||||
|
public List<NestUnplacedRequestPart> Unplaced { get; init; } = [];
|
||||||
public List<NestStockUsage> StockUsage { get; init; } = [];
|
public List<NestStockUsage> StockUsage { get; init; } = [];
|
||||||
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-43
@@ -5,10 +5,9 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using OpenNest.IO;
|
|
||||||
using OpenNest.Engine;
|
using OpenNest.Engine;
|
||||||
using OpenNest.Engine.Jobs;
|
using OpenNest.Engine.Jobs;
|
||||||
using OpenNest.Engine.Jobs.Adapters;
|
using OpenNest.IO;
|
||||||
|
|
||||||
namespace OpenNest.Api;
|
namespace OpenNest.Api;
|
||||||
|
|
||||||
@@ -29,10 +28,11 @@ public static class NestRunner
|
|||||||
if (requestParts.Count == 0)
|
if (requestParts.Count == 0)
|
||||||
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
||||||
|
|
||||||
|
var stock = CreateStock(request);
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
var parts = IdentifyParts(requestParts);
|
var parts = IdentifyParts(requestParts);
|
||||||
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||||
var jobParts = new List<NestJobPart>(parts.Count);
|
var items = new List<NestItem>(parts.Count);
|
||||||
|
|
||||||
foreach (var part in parts)
|
foreach (var part in parts)
|
||||||
{
|
{
|
||||||
@@ -68,23 +68,68 @@ public static class NestRunner
|
|||||||
importedByPath.Add(part.Request.DxfPath, drawing);
|
importedByPath.Add(part.Request.DxfPath, drawing);
|
||||||
}
|
}
|
||||||
|
|
||||||
ConfigureDrawingForRequirement(drawing, part.Request);
|
// Each requirement keeps its own identity/constraints even when paths are shared.
|
||||||
jobParts.Add(DrawingJobMapper.FromDrawing(part.Id, drawing, part.Request.Quantity));
|
var requirementDrawing = new Drawing(part.Id, drawing.Program);
|
||||||
|
ConfigureDrawingForRequirement(requirementDrawing, part.Request);
|
||||||
|
requirementDrawing.Quantity.Required = part.Request.Quantity;
|
||||||
|
items.Add(new NestItem
|
||||||
|
{
|
||||||
|
Drawing = requirementDrawing,
|
||||||
|
Quantity = part.Request.Quantity,
|
||||||
|
Priority = part.Request.Priority,
|
||||||
|
StepAngle = requirementDrawing.Constraints.StepAngle,
|
||||||
|
RotationStart = requirementDrawing.Constraints.StartAngle,
|
||||||
|
RotationEnd = requirementDrawing.Constraints.EndAngle,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var job = new NestJob(
|
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
|
||||||
jobParts,
|
|
||||||
CreateStock(request),
|
|
||||||
new NestJobOptions(ResolvePlacementStrategy(request))
|
|
||||||
);
|
|
||||||
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
|
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
|
||||||
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job, jobProgress, token);
|
var result = NestPipeline.Run(new NestPipelineRequest(
|
||||||
|
engineName, items, stock, new NestJobOptions(engineName)), jobProgress, token);
|
||||||
|
|
||||||
// This is the sole translation from immutable result poses to mutable legacy output objects.
|
// API returns a detached proposal, not an acceptance/commit to a caller's nest.
|
||||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
// Invalid but representable proposals retain every pose and carry explicit validation status.
|
||||||
var nest = materialized.Nest;
|
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
|
||||||
nest.Thickness = request.Thickness;
|
if (request.Plates != null)
|
||||||
nest.Material = new Material(request.Material);
|
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)
|
||||||
|
{
|
||||||
|
var plate = new Plate(proposed.Stock.Size)
|
||||||
|
{
|
||||||
|
PartSpacing = proposed.Stock.PartSpacing,
|
||||||
|
EdgeSpacing = proposed.Stock.EdgeSpacing,
|
||||||
|
Quadrant = proposed.Stock.Quadrant,
|
||||||
|
Quantity = proposed.Quantity,
|
||||||
|
};
|
||||||
|
plate.Parts.AddRange(proposed.Parts);
|
||||||
|
nest.Plates.Add(plate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline IDs are internal part-i values. Exposed counts/IDs come from the
|
||||||
|
// returned, bound placements and the original request, never plug-in summaries.
|
||||||
|
var counts = result.Plates.SelectMany(p => p.Parts.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.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);
|
||||||
|
var stopReason = complete ? NestJobStopReason.Completed
|
||||||
|
: usage.All(u => u.Remaining == 0) ? NestJobStopReason.StockExhausted
|
||||||
|
: NestJobStopReason.NoPlacementFound;
|
||||||
|
|
||||||
var timingInfo = Timing.GetTimingInfo(nest);
|
var timingInfo = Timing.GetTimingInfo(nest);
|
||||||
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
|
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
|
||||||
@@ -93,33 +138,21 @@ public static class NestRunner
|
|||||||
return Task.FromResult(
|
return Task.FromResult(
|
||||||
new NestResponse
|
new NestResponse
|
||||||
{
|
{
|
||||||
SheetCount = nest.Plates.Count,
|
SheetCount = nest.Plates.Sum(p => p.Quantity),
|
||||||
Utilization = CalculateUtilization(nest),
|
Utilization = CalculateUtilization(nest),
|
||||||
CutTime = cutTime,
|
CutTime = cutTime,
|
||||||
Elapsed = sw.Elapsed,
|
Elapsed = sw.Elapsed,
|
||||||
Status = result.Status,
|
Status = complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
||||||
StopReason = result.StopReason,
|
StopReason = stopReason,
|
||||||
Fulfillment = result
|
ValidationStatus = !result.CanKeep ? NestValidationStatus.Unrepresentable
|
||||||
.Fulfillment.Select(value => new NestPartFulfillment(
|
: result.IsValid ? NestValidationStatus.Valid : NestValidationStatus.Invalid,
|
||||||
value.PartId,
|
Violations = result.Violations,
|
||||||
value.Requested,
|
Fulfillment = fulfillment,
|
||||||
value.Placed,
|
Unplaced = unplaced,
|
||||||
value.Unplaced
|
StockUsage = usage,
|
||||||
))
|
Costs = result.CanKeep && result.IsValid ? NestCostSummary.FromAccepted(result) : null,
|
||||||
.ToArray(),
|
PlateStockMappings = result.Plates.Select((value, index) =>
|
||||||
StockUsage = result
|
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
|
||||||
.StockUsage.Select(value => new NestStockUsage(
|
|
||||||
value.StockId,
|
|
||||||
value.Used,
|
|
||||||
value.Remaining
|
|
||||||
))
|
|
||||||
.ToArray(),
|
|
||||||
PlateStockMappings = result
|
|
||||||
.Plates.Select(value => new NestPlateStockMapping(
|
|
||||||
value.PlateIndex,
|
|
||||||
value.StockId
|
|
||||||
))
|
|
||||||
.ToArray(),
|
|
||||||
Nest = nest,
|
Nest = nest,
|
||||||
Request = request,
|
Request = request,
|
||||||
}
|
}
|
||||||
@@ -140,6 +173,8 @@ public static class NestRunner
|
|||||||
"Request parts must not contain null entries.",
|
"Request parts must not contain null entries.",
|
||||||
nameof(requestParts)
|
nameof(requestParts)
|
||||||
);
|
);
|
||||||
|
if (part.Quantity < 0)
|
||||||
|
throw new ArgumentException("Part quantities must be nonnegative.", nameof(requestParts));
|
||||||
var id = part.Id ?? $"part-{index}";
|
var id = part.Id ?? $"part-{index}";
|
||||||
if (string.IsNullOrWhiteSpace(id))
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
|
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
|
||||||
@@ -181,11 +216,13 @@ public static class NestRunner
|
|||||||
plate.Quantity,
|
plate.Quantity,
|
||||||
plate.PartSpacing,
|
plate.PartSpacing,
|
||||||
plate.EdgeSpacing,
|
plate.EdgeSpacing,
|
||||||
plate.Quadrant
|
plate.Quadrant,
|
||||||
|
plate.Cost
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NestJobValidator.ValidateStockCosts(stock);
|
||||||
return stock;
|
return stock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,11 +251,11 @@ public static class NestRunner
|
|||||||
|
|
||||||
private static double CalculateUtilization(Nest nest)
|
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)
|
if (sheetArea == 0)
|
||||||
return 0;
|
return 0;
|
||||||
var placedArea = nest.Plates.Sum(plate =>
|
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;
|
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,21 @@
|
|||||||
|
<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" />
|
||||||
|
<ProjectReference Include="..\OpenNest.Desktop\OpenNest.Desktop.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.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -37,6 +38,12 @@ namespace OpenNest.Benchmark
|
|||||||
public double PartSpacing { get; init; }
|
public double PartSpacing { get; init; }
|
||||||
public int Quadrant { 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>
|
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
|
||||||
public double SalvageRate { get; init; }
|
public double SalvageRate { get; init; }
|
||||||
|
|
||||||
@@ -60,7 +67,8 @@ namespace OpenNest.Benchmark
|
|||||||
/// (unlimited quantity - the engine under test decides how many of each
|
/// (unlimited quantity - the engine under test decides how many of each
|
||||||
/// size it actually uses, and how demand splits across plates). The
|
/// size it actually uses, and how demand splits across plates). The
|
||||||
/// engine owns its own multi-plate/size strategy; this harness no
|
/// 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>
|
/// </summary>
|
||||||
public NestJob BuildNestJob(
|
public NestJob BuildNestJob(
|
||||||
int maxPlates,
|
int maxPlates,
|
||||||
@@ -71,24 +79,40 @@ namespace OpenNest.Benchmark
|
|||||||
var parts = Requests.Select(r =>
|
var parts = Requests.Select(r =>
|
||||||
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
|
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
|
||||||
);
|
);
|
||||||
var stock = CandidateSizes.Select(size => new NestPlateStock(
|
// No options would make the builder offer the template itself; an empty pool stays empty.
|
||||||
size.ToString(1),
|
var stock = CandidateSizes.Count == 0
|
||||||
size,
|
? Array.Empty<NestPlateStock>()
|
||||||
null,
|
: NestStockBuilder.FromTemplate(
|
||||||
PartSpacing,
|
StockTemplate(),
|
||||||
EdgeSpacing,
|
CandidateSizes.Select(size => new PlateOption { Width = size.Width, Length = size.Length }).ToList()
|
||||||
Quadrant
|
);
|
||||||
));
|
|
||||||
return new NestJob(
|
return new NestJob(
|
||||||
parts,
|
parts,
|
||||||
stock,
|
stock,
|
||||||
new NestJobOptions(
|
new NestJobOptions(
|
||||||
"Default",
|
"Fill",
|
||||||
maxPlates,
|
maxPlates,
|
||||||
salvageRate ?? SalvageRate,
|
salvageRate ?? SalvageRate,
|
||||||
minimumSalvageDimension ?? 0
|
minimumSalvageDimension ?? 0
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using OpenNest.Engine.BestFit;
|
||||||
using OpenNest.Engine.Jobs;
|
using OpenNest.Engine.Jobs;
|
||||||
using OpenNest.Engine.Jobs.Adapters;
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
|
||||||
@@ -125,14 +126,7 @@ namespace OpenNest.Benchmark
|
|||||||
.BaselinePlateRuns.Select(
|
.BaselinePlateRuns.Select(
|
||||||
(run, index) =>
|
(run, index) =>
|
||||||
{
|
{
|
||||||
var stock = new NestPlateStock(
|
var stock = DrawingJobMapper.FromPlate($"baseline-{index}", run.Plate, 1);
|
||||||
$"baseline-{index}",
|
|
||||||
run.Plate.Size,
|
|
||||||
1,
|
|
||||||
run.Plate.PartSpacing,
|
|
||||||
run.Plate.EdgeSpacing,
|
|
||||||
run.Plate.Quadrant
|
|
||||||
);
|
|
||||||
var placements = run
|
var placements = run
|
||||||
.Parts.Select(part =>
|
.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.
|
// Manifest jobs have no source nest to copy from, so they keep the job's name.
|
||||||
if (job.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
|
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.Name = source.Name;
|
||||||
materialized.Nest.Units = source.Units;
|
materialized.Nest.Units = source.Units;
|
||||||
materialized.Nest.Material = source.Material;
|
materialized.Nest.Material = source.Material;
|
||||||
@@ -316,7 +310,7 @@ namespace OpenNest.Benchmark
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Output must not overwrite the source nest."
|
"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
|
var report = new
|
||||||
{
|
{
|
||||||
Source = job.SourceFile,
|
Source = job.SourceFile,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using OpenNest.Engine.BestFit;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ namespace OpenNest.Benchmark
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
nest = new NestReader(file).Read();
|
nest = new NestReader(file) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -73,6 +74,8 @@ namespace OpenNest.Benchmark
|
|||||||
EdgeSpacing = template.EdgeSpacing,
|
EdgeSpacing = template.EdgeSpacing,
|
||||||
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
||||||
Quadrant = template.Quadrant,
|
Quadrant = template.Quadrant,
|
||||||
|
ClampRail = template.ClampRail,
|
||||||
|
ClampZones = template.ClampZones,
|
||||||
SalvageRate = nest.SalvageRate,
|
SalvageRate = nest.SalvageRate,
|
||||||
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
|
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
|
||||||
Requests = requests,
|
Requests = requests,
|
||||||
@@ -181,23 +184,45 @@ namespace OpenNest.Benchmark
|
|||||||
plate.EdgeSpacing.Top
|
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++)
|
for (var copy = 0; copy < plate.Quantity; copy++)
|
||||||
runs.Add((validationPlate, parts));
|
runs.Add((validationPlate, parts));
|
||||||
}
|
}
|
||||||
return runs.Count > 0 ? runs : null;
|
return runs.Count > 0 ? runs : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(
|
private static (
|
||||||
Nest nest
|
Spacing EdgeSpacing,
|
||||||
)
|
double PartSpacing,
|
||||||
|
int Quadrant,
|
||||||
|
ClampRail ClampRail,
|
||||||
|
IReadOnlyList<ClampZone> ClampZones
|
||||||
|
) ResolvePlateTemplate(Nest nest)
|
||||||
{
|
{
|
||||||
var source = nest.Plates?.FirstOrDefault();
|
var source = nest.Plates?.FirstOrDefault();
|
||||||
|
|
||||||
if (source != null)
|
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;
|
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)
|
private static List<Size> ResolveSheetSizes(Nest nest)
|
||||||
|
|||||||
+487
-129
@@ -5,11 +5,16 @@ using System.Globalization;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using OpenNest;
|
using OpenNest;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
using OpenNest.Engine;
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.BestFit;
|
||||||
|
using OpenNest.Engine.CuttingPlanning;
|
||||||
using OpenNest.Engine.Jobs;
|
using OpenNest.Engine.Jobs;
|
||||||
using OpenNest.Engine.Jobs.Adapters;
|
|
||||||
using OpenNest.Engine.Jobs.Placement;
|
using OpenNest.Engine.Jobs.Placement;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
@@ -21,6 +26,33 @@ static class NestConsole
|
|||||||
{
|
{
|
||||||
public static int Run(string[] args)
|
public static int Run(string[] args)
|
||||||
{
|
{
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
ConsoleCancelEventHandler cancel = (_, e) => { e.Cancel = true; cancellation.Cancel(); };
|
||||||
|
Console.CancelKeyPress += cancel;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return RunCore(args, cancellation.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Nesting cancelled; nothing saved.");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.CancelKeyPress -= cancel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int RunCore(string[] args, CancellationToken token)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
NestingEngineRegistry.LoadPlugins(Path.Combine(AppContext.BaseDirectory, "Engines"));
|
||||||
var options = ParseArgs(args);
|
var options = ParseArgs(args);
|
||||||
|
|
||||||
if (options == null)
|
if (options == null)
|
||||||
@@ -48,14 +80,26 @@ static class NestConsole
|
|||||||
return 0;
|
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
|
// Validate --engine up front: autonest names a jobs engine, plain fill names a
|
||||||
// single-plate placement strategy. Unknown names exit with the valid choices.
|
// single-plate placement strategy. Unknown names exit with the valid choices.
|
||||||
if (options.AutoNest)
|
if (options.AutoNest)
|
||||||
{
|
{
|
||||||
var isJobsEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
|
// ResolveName also accepts renamed engines' old names (for example Opus55NestingEngine).
|
||||||
e.Name.Equals(options.Engine, StringComparison.OrdinalIgnoreCase)
|
if (NestingEngineRegistry.ResolveName(options.Engine) == null)
|
||||||
);
|
|
||||||
if (!isJobsEngine)
|
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"Error: unknown engine '{options.Engine}'. Jobs engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
|
$"Error: unknown engine '{options.Engine}'. Jobs engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
|
||||||
@@ -65,19 +109,49 @@ static class NestConsole
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
try
|
var engine = EngineNameResolver.Classify(options.Engine);
|
||||||
{
|
if (!engine.IsFillStrategy)
|
||||||
PlateFillService.ResolveStrategy(options.Engine);
|
|
||||||
}
|
|
||||||
catch (NotSupportedException)
|
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
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;
|
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.OutlineFile != null || 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, --outline or --template."
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.OutlineFile != null && !options.AutoNest)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Error: --outline needs --autonest; single-plate fill strategies fill rectangles only.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.OutlineFile != null && !File.Exists(options.OutlineFile))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Error: Outline file not found: {options.OutlineFile}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.InputFiles.Count == 0)
|
if (options.InputFiles.Count == 0)
|
||||||
{
|
{
|
||||||
PrintUsage();
|
PrintUsage();
|
||||||
@@ -93,36 +167,83 @@ 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);
|
var nest = LoadOrCreateNest(options);
|
||||||
|
|
||||||
if (nest == null)
|
if (nest == null)
|
||||||
return 1;
|
return 1;
|
||||||
|
|
||||||
var plate = nest.Plates[options.PlateIndex];
|
var plate = nest.Plates[options.PlateIndex];
|
||||||
|
IReadOnlyList<Plate> plates = new[] { plate };
|
||||||
|
var overlapCount = 0;
|
||||||
|
|
||||||
ApplyTemplate(plate, options);
|
if (options.NoFill)
|
||||||
ApplyOverrides(plate, options);
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
var drawing = ResolveDrawing(nest, options);
|
ApplyTemplate(plate, options);
|
||||||
|
ApplyOverrides(plate, options);
|
||||||
|
|
||||||
if (drawing == null)
|
if (!options.AutoNest && PlateFillService.Refusal(plate) is { } refusal)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Error: {refusal} Use --autonest.");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return 1;
|
||||||
|
|
||||||
var existingCount = plate.Parts.Count;
|
|
||||||
|
|
||||||
if (!options.KeepParts)
|
|
||||||
plate.Parts.Clear();
|
|
||||||
|
|
||||||
PrintHeader(nest, plate, drawing, existingCount, options);
|
|
||||||
|
|
||||||
var (success, elapsed) = Fill(nest, plate, drawing, options);
|
|
||||||
|
|
||||||
var overlapCount = CheckOverlaps(plate, options);
|
|
||||||
|
|
||||||
PrintResults(success, plate, elapsed);
|
|
||||||
Save(nest, options);
|
|
||||||
PostProcess(nest, options);
|
|
||||||
|
|
||||||
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
|
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +294,9 @@ static class NestConsole
|
|||||||
if (Size.TryParse(args[++i], out var sz))
|
if (Size.TryParse(args[++i], out var sz))
|
||||||
o.PlateSize = sz;
|
o.PlateSize = sz;
|
||||||
break;
|
break;
|
||||||
|
case "--outline" when i + 1 < args.Length:
|
||||||
|
o.OutlineFile = args[++i];
|
||||||
|
break;
|
||||||
case "--check-overlaps":
|
case "--check-overlaps":
|
||||||
o.CheckOverlaps = true;
|
o.CheckOverlaps = true;
|
||||||
break;
|
break;
|
||||||
@@ -185,15 +309,29 @@ static class NestConsole
|
|||||||
case "--template" when i + 1 < args.Length:
|
case "--template" when i + 1 < args.Length:
|
||||||
o.TemplateFile = args[++i];
|
o.TemplateFile = args[++i];
|
||||||
break;
|
break;
|
||||||
|
case "--allow-invalid":
|
||||||
|
o.AllowInvalid = true;
|
||||||
|
break;
|
||||||
case "--autonest":
|
case "--autonest":
|
||||||
o.AutoNest = true;
|
o.AutoNest = true;
|
||||||
break;
|
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:
|
case "--engine" when i + 1 < args.Length:
|
||||||
o.Engine = args[++i];
|
o.Engine = args[++i];
|
||||||
break;
|
break;
|
||||||
case "--post" when i + 1 < args.Length:
|
case "--post" when i + 1 < args.Length:
|
||||||
o.PostName = args[++i];
|
o.PostName = args[++i];
|
||||||
break;
|
break;
|
||||||
|
case "--acknowledge-post-risks":
|
||||||
|
o.AcknowledgePostRisks = true;
|
||||||
|
break;
|
||||||
case "--post-output" when i + 1 < args.Length:
|
case "--post-output" when i + 1 < args.Length:
|
||||||
o.PostOutput = args[++i];
|
o.PostOutput = args[++i];
|
||||||
break;
|
break;
|
||||||
@@ -203,6 +341,24 @@ static class NestConsole
|
|||||||
case "--list-posts":
|
case "--list-posts":
|
||||||
o.ListPosts = true;
|
o.ListPosts = true;
|
||||||
break;
|
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 "--help":
|
||||||
case "-h":
|
case "-h":
|
||||||
PrintUsage();
|
PrintUsage();
|
||||||
@@ -217,23 +373,54 @@ static class NestConsole
|
|||||||
return o;
|
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)
|
static Nest LoadOrCreateNest(Options options)
|
||||||
{
|
{
|
||||||
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
||||||
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
||||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
|
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
|
||||||
);
|
);
|
||||||
var dxfFiles = options
|
var dxfFiles = options.InputFiles.Where(IsCadFile).ToList();
|
||||||
.InputFiles.Where(f =>
|
|
||||||
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
|
|
||||||
)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// If we have a nest file, load it and optionally add DXFs.
|
// If we have a nest file, load it and optionally add DXFs.
|
||||||
if (nestFile != null)
|
if (nestFile != null)
|
||||||
{
|
{
|
||||||
var nest = new NestReader(nestFile).Read();
|
var nest = new NestReader(nestFile) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||||
|
|
||||||
if (nest.Plates.Count == 0)
|
if (nest.Plates.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -278,9 +465,11 @@ static class NestConsole
|
|||||||
return null;
|
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 newNest = new Nest { Name = "DXF Import" };
|
||||||
var plate = new Plate { Size = options.PlateSize.Value };
|
newNest.PlateDefaults.Size = options.PlateSize.Value;
|
||||||
newNest.Plates.Add(plate);
|
newNest.CreatePlate();
|
||||||
|
|
||||||
foreach (var dxf in dxfFiles)
|
foreach (var dxf in dxfFiles)
|
||||||
{
|
{
|
||||||
@@ -337,7 +526,7 @@ static class NestConsole
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var templateNest = new NestReader(options.TemplateFile).Read();
|
var templateNest = new NestReader(options.TemplateFile) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||||
var templatePlate = templateNest.PlateDefaults.CreateNew();
|
var templatePlate = templateNest.PlateDefaults.CreateNew();
|
||||||
plate.Quadrant = templatePlate.Quadrant;
|
plate.Quadrant = templatePlate.Quadrant;
|
||||||
plate.EdgeSpacing = templatePlate.EdgeSpacing;
|
plate.EdgeSpacing = templatePlate.EdgeSpacing;
|
||||||
@@ -358,6 +547,13 @@ static class NestConsole
|
|||||||
|
|
||||||
if (options.PlateSize.HasValue && !hasDxfOnly)
|
if (options.PlateSize.HasValue && !hasDxfOnly)
|
||||||
plate.Size = options.PlateSize.Value;
|
plate.Size = options.PlateSize.Value;
|
||||||
|
|
||||||
|
if (options.OutlineFile != null)
|
||||||
|
{
|
||||||
|
// The outline is the sheet: its bounds replace any size given.
|
||||||
|
plate.Outline = SheetOutline.FromGeometry(CadImporter.Import(options.OutlineFile).Entities);
|
||||||
|
Console.WriteLine($"Outline: {options.OutlineFile} ({plate.Outline})");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Drawing ResolveDrawing(Nest nest, Options options)
|
static Drawing ResolveDrawing(Nest nest, Options options)
|
||||||
@@ -394,7 +590,9 @@ static class NestConsole
|
|||||||
);
|
);
|
||||||
var existingPartsMessage = options.KeepParts
|
var existingPartsMessage = options.KeepParts
|
||||||
? $"Keeping {existingCount} existing parts"
|
? $"Keeping {existingCount} existing parts"
|
||||||
: $"Cleared {existingCount} existing parts";
|
: options.AutoNest
|
||||||
|
? $"Will replace {existingCount} existing parts only after acceptance"
|
||||||
|
: $"Cleared {existingCount} existing parts";
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"""
|
$"""
|
||||||
Drawing: {drawing.Name}
|
Drawing: {drawing.Name}
|
||||||
@@ -404,15 +602,18 @@ static class NestConsole
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static (bool success, long elapsedMs) Fill(
|
static (bool success, long elapsedMs, bool accepted, IReadOnlyList<Plate> plates) Fill(
|
||||||
Nest nest,
|
Nest nest,
|
||||||
Plate plate,
|
Plate plate,
|
||||||
Drawing drawing,
|
Drawing drawing,
|
||||||
Options options
|
Options options,
|
||||||
|
CancellationToken token
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
bool success;
|
bool success;
|
||||||
|
var accepted = true;
|
||||||
|
IReadOnlyList<Plate> plates = new[] { plate };
|
||||||
|
|
||||||
if (options.AutoNest)
|
if (options.AutoNest)
|
||||||
{
|
{
|
||||||
@@ -433,13 +634,17 @@ static class NestConsole
|
|||||||
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
|
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
|
||||||
);
|
);
|
||||||
|
|
||||||
success = AutoNestJob(plate, nestItems, options.Engine);
|
var outcome = AutoNestJob(nest, plate, nestItems, options, token);
|
||||||
|
accepted = outcome.accepted;
|
||||||
|
success = outcome.committed > 0;
|
||||||
|
if (accepted)
|
||||||
|
plates = outcome.plates;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Single-plate fill: explicit placement strategy through the public service;
|
// Single-plate fill: explicit placement strategy through the public service;
|
||||||
// the process-global engine registry is never consulted.
|
// 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 item = new NestItem { Drawing = drawing, Quantity = options.Quantity };
|
||||||
var parts = PlateFillService.FillItem(
|
var parts = PlateFillService.FillItem(
|
||||||
strategy,
|
strategy,
|
||||||
@@ -447,8 +652,9 @@ static class NestConsole
|
|||||||
item,
|
item,
|
||||||
plate.WorkArea(),
|
plate.WorkArea(),
|
||||||
null,
|
null,
|
||||||
CancellationToken.None
|
token
|
||||||
);
|
);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
if (parts.Count > 0)
|
if (parts.Count > 0)
|
||||||
plate.Parts.AddRange(parts);
|
plate.Parts.AddRange(parts);
|
||||||
@@ -456,119 +662,217 @@ static class NestConsole
|
|||||||
}
|
}
|
||||||
|
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
return (success, sw.ElapsedMilliseconds);
|
return (success, sw.ElapsedMilliseconds, accepted, plates);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
static (bool accepted, int committed, IReadOnlyList<Plate> plates) AutoNestJob(
|
||||||
/// Solves the drawings as one whole job against this single plate using the named jobs
|
Nest nest, Plate plate, List<NestItem> nestItems, Options options, CancellationToken token)
|
||||||
/// engine, then commits the returned placements onto the plate. Placements are mapped back
|
|
||||||
/// onto the caller's original drawings (same pose semantics as NestResultMaterializer), so
|
|
||||||
/// the saved nest keeps its existing drawing identities.
|
|
||||||
/// </summary>
|
|
||||||
static bool AutoNestJob(Plate plate, List<NestItem> nestItems, string engineName)
|
|
||||||
{
|
{
|
||||||
var engine = NestingEngineRegistry.Create(engineName);
|
var 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(
|
||||||
|
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()}");
|
||||||
|
|
||||||
var parts = new List<NestJobPart>(nestItems.Count);
|
var tooManySheets = result.Raw.Plates.Count > options.MaxSheets;
|
||||||
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
if (!result.CanKeep || tooManySheets || (!result.IsValid && !options.AllowInvalid))
|
||||||
for (var i = 0; i < nestItems.Count; i++)
|
|
||||||
{
|
{
|
||||||
var partId = $"part-{i}";
|
Console.Error.WriteLine(tooManySheets
|
||||||
parts.Add(DrawingJobMapper.FromItem(partId, nestItems[i]));
|
? $"Error: result uses {result.Raw.Plates.Count} sheets, more than --max-sheets {options.MaxSheets}, even with --allow-invalid. Nothing saved."
|
||||||
drawingsByPartId[partId] = nestItems[i].Drawing;
|
: !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 rejected;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One physical sheet: this plate, this solve — the runner owns stock accounting.
|
token.ThrowIfCancellationRequested();
|
||||||
var stock = DrawingJobMapper.FromPlate("plate-0", plate, 1);
|
var committed = result.Plates.Sum(p => p.Parts.Count * p.Quantity);
|
||||||
var job = new NestJob(parts, [stock]);
|
if (committed == 0)
|
||||||
|
|
||||||
var result = engine.Solve(job, null, CancellationToken.None);
|
|
||||||
|
|
||||||
var committed = 0;
|
|
||||||
foreach (var plateResult in result.Plates)
|
|
||||||
{
|
{
|
||||||
foreach (var pose in plateResult.Placements)
|
Console.Error.WriteLine("Error: no placements returned. Existing layout and output left unchanged.");
|
||||||
|
return rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
if (!drawingsByPartId.TryGetValue(pose.PartId, out var drawing))
|
targetUsed = true;
|
||||||
continue;
|
plate.Parts.Clear();
|
||||||
var part = new Part(drawing);
|
return plate;
|
||||||
part.Rotate(pose.Rotation);
|
|
||||||
part.Location = new Vector(pose.X, pose.Y);
|
|
||||||
part.UpdateBounds();
|
|
||||||
plate.Parts.Add(part);
|
|
||||||
committed++;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine($"Engine: {engineName} — committed {committed} placements");
|
var created = new Plate(plate.Size)
|
||||||
return committed > 0;
|
{
|
||||||
|
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
|
if (!options.CheckOverlaps)
|
||||||
{
|
|
||||||
return PlateFillService.ResolveStrategy(engineName);
|
|
||||||
}
|
|
||||||
catch (NotSupportedException)
|
|
||||||
{
|
|
||||||
var isJobEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
|
|
||||||
e.Name.Equals(engineName, StringComparison.OrdinalIgnoreCase)
|
|
||||||
);
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
isJobEngine
|
|
||||||
? $"Error: engine '{engineName}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
|
|
||||||
: $"Error: unknown engine '{engineName}'. Engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
|
|
||||||
);
|
|
||||||
Environment.Exit(1);
|
|
||||||
throw; // unreachable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static int CheckOverlaps(Plate plate, Options options)
|
|
||||||
{
|
|
||||||
if (!options.CheckOverlaps || plate.Parts.Count == 0)
|
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
|
var total = 0;
|
||||||
Console.WriteLine(
|
foreach (var plate in plates.Where(p => p.Parts.Count > 0))
|
||||||
hasOverlaps
|
{
|
||||||
? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
|
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
|
||||||
: "Overlap check: PASS"
|
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(
|
Console.WriteLine(
|
||||||
$"""
|
$"""
|
||||||
Result: {(success ? "success" : "failed")}
|
Result: {(success ? "success" : "failed")}
|
||||||
Parts placed: {plate.Parts.Count}
|
Parts placed: {plates.Sum(p => p.Parts.Count * p.Quantity)}
|
||||||
Utilization: {plate.Utilization():P1}
|
Utilization: {utilization:P1}
|
||||||
Time: {elapsedMs}ms
|
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)
|
static void Save(Nest nest, Options options)
|
||||||
{
|
{
|
||||||
if (options.NoSave)
|
if (options.NoSave)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var firstInput = options.InputFiles[0];
|
var outputFile = NestOutputPath(options);
|
||||||
var outputFile =
|
|
||||||
options.OutputFile
|
|
||||||
?? Path.Combine(
|
|
||||||
Path.GetDirectoryName(firstInput),
|
|
||||||
$"{Path.GetFileNameWithoutExtension(firstInput)}-result{NestFormat.FileExtension}"
|
|
||||||
);
|
|
||||||
|
|
||||||
new NestWriter(nest).Write(outputFile);
|
new NestWriter(nest) { BestFitStore = BestFitCacheStore.Shared }.Write(outputFile);
|
||||||
Console.WriteLine($"Saved: {outputFile}");
|
Console.WriteLine($"Saved: {outputFile}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static string NestOutputPath(Options options) => options.OutputFile
|
||||||
|
?? Path.Combine(Path.GetDirectoryName(options.InputFiles[0]),
|
||||||
|
$"{Path.GetFileNameWithoutExtension(options.InputFiles[0])}-result{NestFormat.FileExtension}");
|
||||||
|
|
||||||
static string ResolvePostsDir(Options options)
|
static string ResolvePostsDir(Options options)
|
||||||
{
|
{
|
||||||
if (options.PostsDir != null)
|
if (options.PostsDir != null)
|
||||||
@@ -633,10 +937,13 @@ static class NestConsole
|
|||||||
Console.WriteLine($" {p.Name,-30} {p.Description}");
|
Console.WriteLine($" {p.Name,-30} {p.Description}");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void PostProcess(Nest nest, Options options)
|
static bool SaveAndPost(Nest nest, Options options)
|
||||||
{
|
{
|
||||||
if (options.PostName == null)
|
if (options.PostName == null)
|
||||||
return;
|
{
|
||||||
|
Save(nest, options);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
var postsDir = ResolvePostsDir(options);
|
var postsDir = ResolvePostsDir(options);
|
||||||
var processors = LoadPostProcessors(postsDir);
|
var processors = LoadPostProcessors(postsDir);
|
||||||
@@ -655,7 +962,15 @@ static class NestConsole
|
|||||||
else
|
else
|
||||||
Console.Error.WriteLine($"No post processors found in: {postsDir}");
|
Console.Error.WriteLine($"No post processors found in: {postsDir}");
|
||||||
|
|
||||||
return;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var verification = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
|
||||||
|
Console.WriteLine(verification.ToDisplayText());
|
||||||
|
if (!verification.CanPost(options.AcknowledgePostRisks))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Posting blocked: review these warnings. To accept the risks, including possible head crashes and machine or material damage, explicitly use --acknowledge-post-risks for this invocation.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var outputFile = options.PostOutput;
|
var outputFile = options.PostOutput;
|
||||||
@@ -669,8 +984,22 @@ static class NestConsole
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var outputFiles = post is IMultiFilePostProcessor multiFile
|
||||||
|
? multiFile.GetOutputFiles(nest, outputFile)
|
||||||
|
: new[] { outputFile };
|
||||||
|
|
||||||
|
if (!options.NoSave && outputFiles.Any(file => string.Equals(
|
||||||
|
Path.GetFullPath(file), Path.GetFullPath(NestOutputPath(options)), StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Error: nest save and CNC output paths must be different. No output was written.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Save(nest, options);
|
||||||
post.Post(nest, outputFile);
|
post.Post(nest, outputFile);
|
||||||
Console.WriteLine($"Post: {post.Name} -> {outputFile}");
|
foreach (var file in outputFiles)
|
||||||
|
Console.WriteLine($"Post: {post.Name} -> {file}");
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void PrintUsage()
|
static void PrintUsage()
|
||||||
@@ -695,16 +1024,34 @@ static class NestConsole
|
|||||||
--quantity <n> Max parts to place (default: 0 = unlimited)
|
--quantity <n> Max parts to place (default: 0 = unlimited)
|
||||||
--spacing <value> Override part spacing
|
--spacing <value> Override part spacing
|
||||||
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
|
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
|
||||||
|
--outline <dxf> Make the plate an irregular sheet: the file's largest closed contour is
|
||||||
|
the sheet, other closed contours are obstacles; sets the size. Only
|
||||||
|
--autonest with the Irregular (or Default) engine nests on it
|
||||||
--output <path> Output nest file path (default: <input>-result.nest)
|
--output <path> Output nest file path (default: <input>-result.nest)
|
||||||
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
|
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
|
||||||
--autonest Whole-job nesting via the jobs engine (--engine) instead of single-plate fill
|
--autonest Validated whole-job nesting (one sheet unless --max-sheets); replaces only after acceptance
|
||||||
--engine <name> With --autonest: jobs engine (default: Default; also StockLadder, Strip, ...).
|
--max-sheets <n> With --autonest: physical sheets the job may use (default: 1); extra
|
||||||
Without --autonest: fill strategy (Default, Strip, Vertical Remnant, Horizontal Remnant)
|
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
|
--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)
|
--check-overlaps Run overlap detection after fill (exit code 1 if found)
|
||||||
--no-save Skip saving output file
|
--no-save Skip saving output file
|
||||||
--post <name> Run a post processor after nesting
|
--post <name> Run a post processor after nesting
|
||||||
--post-output <path> Output file for post processor (default: <input>.cnc)
|
--post-output <path> Output file for post processor (default: <input>.cnc)
|
||||||
|
--acknowledge-post-risks Explicitly accept displayed verification risks for this invocation
|
||||||
--posts-dir <path> Directory containing post processor DLLs (default: Posts/)
|
--posts-dir <path> Directory containing post processor DLLs (default: Posts/)
|
||||||
--list-posts List available post processors and exit
|
--list-posts List available post processors and exit
|
||||||
-h, --help Show this help
|
-h, --help Show this help
|
||||||
@@ -721,16 +1068,27 @@ static class NestConsole
|
|||||||
public int Quantity;
|
public int Quantity;
|
||||||
public double? Spacing;
|
public double? Spacing;
|
||||||
public Size? PlateSize;
|
public Size? PlateSize;
|
||||||
|
public string OutlineFile;
|
||||||
public bool CheckOverlaps;
|
public bool CheckOverlaps;
|
||||||
public bool NoSave;
|
public bool NoSave;
|
||||||
public bool KeepParts;
|
public bool KeepParts;
|
||||||
public bool AutoNest;
|
public bool AutoNest;
|
||||||
|
public bool AllowInvalid;
|
||||||
|
public int MaxSheets = 1;
|
||||||
|
public List<PlateOption> Stock;
|
||||||
public string Engine = "Default";
|
public string Engine = "Default";
|
||||||
public string TemplateFile;
|
public string TemplateFile;
|
||||||
public string PostName;
|
public string PostName;
|
||||||
|
public bool AcknowledgePostRisks;
|
||||||
public string PostOutput;
|
public string PostOutput;
|
||||||
public string PostsDir;
|
public string PostsDir;
|
||||||
public bool ListPosts;
|
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 double? RepairBendsMillimeters;
|
||||||
public BendRepairUnits CadUnits;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plans and applies the same automatic-cutoff settings to a stable, ordered set of plates.
|
||||||
|
/// Callers must prevent concurrent edits for the duration of either operation.
|
||||||
|
/// </summary>
|
||||||
|
public static class AutomaticCutOffBatch
|
||||||
|
{
|
||||||
|
/// <summary>Detached plans in plate order. Invalid input identifies the one-based plate number.</summary>
|
||||||
|
public static IReadOnlyList<AutomaticCutOffPlan> Create(IReadOnlyList<Plate> plates,
|
||||||
|
AutomaticCutOffOptions options, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plates);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(settings);
|
||||||
|
if (plates.Distinct(ReferenceEqualityComparer.Instance).Count() != plates.Count)
|
||||||
|
throw new ArgumentException("Each plate must occur only once.", nameof(plates));
|
||||||
|
|
||||||
|
var plans = new List<AutomaticCutOffPlan>(plates.Count);
|
||||||
|
for (var index = 0; index < plates.Count; index++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
plans.Add(AutomaticCutOffPlanner.Create(plates[index], options, settings));
|
||||||
|
}
|
||||||
|
catch (ArgumentException error)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Plate {index + 1}: {error.Message}", nameof(plates), error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plans.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replans all plates before changing any. A blocking plan returns without applying any
|
||||||
|
/// definitions; empty/unchanged plates are untouched. On failure, restores cutoff state
|
||||||
|
/// on every touched plate, reporting explicitly if any restoration also fails.
|
||||||
|
/// Returned plans describe the fresh proposal, not preview parts to accept into a plate.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<AutomaticCutOffPlan> Apply(IReadOnlyList<Plate> plates,
|
||||||
|
AutomaticCutOffOptions options, CutOffSettings settings) => 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 new AutomaticCutOffStaging(plans, []);
|
||||||
|
|
||||||
|
var restoreActions = new List<(int PlateNumber, Action Restore)>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var index = 0; index < plates.Count; index++)
|
||||||
|
{
|
||||||
|
var plan = plans[index];
|
||||||
|
if (plan.Definitions.Count == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var plate = plates[index];
|
||||||
|
// Register recovery before the first observable mutation, including AddRange.
|
||||||
|
restoreActions.Add((index + 1, CaptureRestore(plate, plan)));
|
||||||
|
plate.CutOffs.AddRange(plan.Definitions);
|
||||||
|
plate.RegenerateCutOffs(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception applyError)
|
||||||
|
{
|
||||||
|
var rollbackErrors = RestoreAll(restoreActions);
|
||||||
|
if (rollbackErrors.Count > 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: "
|
||||||
|
+ string.Join("; ", rollbackErrors.Select(e => e.Message))
|
||||||
|
+ "\nThe nest may be incomplete; review it before saving or cutting.",
|
||||||
|
new AggregateException(new[] { applyError }.Concat(rollbackErrors)));
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError);
|
||||||
|
}
|
||||||
|
return 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)
|
||||||
|
{
|
||||||
|
// Regeneration replaces drawing programs and removes/reinserts cutoff parts.
|
||||||
|
// Save only that state; real parts, poses, programs and quantities stay untouched.
|
||||||
|
var programs = plate.CutOffs.Select(c => (CutOff: c, Program: c.Drawing.Program)).ToArray();
|
||||||
|
var parts = plate.Parts.Select((part, index) => (Part: part, Index: index))
|
||||||
|
.Where(p => p.Part.BaseDrawing.IsCutOff).ToArray();
|
||||||
|
return () =>
|
||||||
|
{
|
||||||
|
foreach (var definition in plan.Definitions)
|
||||||
|
plate.CutOffs.Remove(definition);
|
||||||
|
for (var index = plate.Parts.Count - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
if (plate.Parts[index].BaseDrawing.IsCutOff)
|
||||||
|
plate.Parts.RemoveAt(index);
|
||||||
|
}
|
||||||
|
foreach (var saved in programs)
|
||||||
|
saved.CutOff.Drawing.Program = saved.Program;
|
||||||
|
foreach (var saved in parts)
|
||||||
|
plate.Parts.Insert(saved.Index, saved.Part);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest;
|
||||||
|
|
||||||
|
public sealed class AutomaticCutOffOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Nominal distance between vertical cut lines in model units. Must be finite and
|
||||||
|
/// greater than AutomaticCutOffPlanner.MinimumSpacing; Create also bounds candidate count.
|
||||||
|
/// </summary>
|
||||||
|
public double Spacing { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum retained-tail length along X in model units. Zero permits any positive tail.
|
||||||
|
/// The desktop default is 12 inches (304.8 mm).
|
||||||
|
/// </summary>
|
||||||
|
public double MinimumTailLength { get; set; }
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
{
|
||||||
|
ExistingCutOff,
|
||||||
|
LimitedCutOffConflict,
|
||||||
|
EmptyCut,
|
||||||
|
SegmentedCut,
|
||||||
|
ClampBlockedCut,
|
||||||
|
NoSafeTailSeparator,
|
||||||
|
TailBelowMinimum,
|
||||||
|
ShiftedCut,
|
||||||
|
HorizontalRemnant,
|
||||||
|
NoSafeHorizontalSeparator,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode Code, string Message, bool IsBlocking = false, double? X = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detached proposal. Collections are read-only; contained definitions/preview parts belong
|
||||||
|
/// to the caller. Never accept a blocked plan or a preview made for an older layout/settings.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AutomaticCutOffPlan
|
||||||
|
{
|
||||||
|
/// <summary>Only new, usable definitions; existing equivalent definitions are not returned.</summary>
|
||||||
|
public IReadOnlyList<CutOff> Definitions { get; internal set; } = Array.Empty<CutOff>();
|
||||||
|
|
||||||
|
/// <summary>Detached display parts, in the same order as Definitions. Not for acceptance.</summary>
|
||||||
|
public IReadOnlyList<Part> PreviewParts { get; internal set; } = Array.Empty<Part>();
|
||||||
|
|
||||||
|
public IReadOnlyList<AutomaticCutOffDiagnostic> Diagnostics { get; internal set; } =
|
||||||
|
Array.Empty<AutomaticCutOffDiagnostic>();
|
||||||
|
|
||||||
|
/// <summary>Furthest real-part X distance from the origin, excluding cut-off parts.</summary>
|
||||||
|
public double OccupiedSpan { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Distance to the verified separator, or full sheet length when no separated tail can
|
||||||
|
/// be claimed. Zero on empty sheets. This is not a disconnected-scrap-size guarantee.
|
||||||
|
/// </summary>
|
||||||
|
public double UsedSpan { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>Length beyond a verified separator, otherwise zero (also on empty sheets).</summary>
|
||||||
|
public double TailLength { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>Signed X coordinate of a verified new or equivalent existing separator.</summary>
|
||||||
|
public double? TailSeparatorX { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
|
||||||
|
public bool HasSeparatedTail => TailSeparatorX.HasValue;
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pure proposals for vertical scrap cuts, measured in the plate's model units.</summary>
|
||||||
|
public static class AutomaticCutOffPlanner
|
||||||
|
{
|
||||||
|
public const double GeometryTolerance = Tolerance.Epsilon;
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// Invalid inputs throw ArgumentException. Blocking diagnostics return no definitions.
|
||||||
|
/// Accept by adding Definitions to Plate.CutOffs and calling RegenerateCutOffs with the
|
||||||
|
/// same settings, only while the layout is unchanged. Do not add PreviewParts to the plate.
|
||||||
|
/// Nominal spacing is not a guarantee of fully disconnected, hopper-sized scrap.
|
||||||
|
/// </summary>
|
||||||
|
public static AutomaticCutOffPlan Create(Plate plate, AutomaticCutOffOptions options,
|
||||||
|
CutOffSettings settings)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plate);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(settings);
|
||||||
|
ValidateInputs(plate, options, settings);
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
Require(part?.BaseDrawing != null, "Every part must have a drawing.", nameof(plate));
|
||||||
|
if (part.BaseDrawing.IsCutOff)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var (partX, partY) = ValidatePart(part, bounds, sign, ySign);
|
||||||
|
occupied = System.Math.Max(occupied, partX);
|
||||||
|
occupiedY = System.Math.Max(occupiedY, partY);
|
||||||
|
hasParts = true;
|
||||||
|
}
|
||||||
|
if (!hasParts)
|
||||||
|
return new AutomaticCutOffPlan();
|
||||||
|
|
||||||
|
// Use distances from the coordinate origin, not the sheet's lower-left corner.
|
||||||
|
var length = plate.Size.Length;
|
||||||
|
var width = plate.Size.Width;
|
||||||
|
occupied = System.Math.Min(occupied, length);
|
||||||
|
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 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));
|
||||||
|
|
||||||
|
// Validate and bound the candidate count before preparing geometry or allocating lines.
|
||||||
|
ValidateExisting(plate, bounds, settings);
|
||||||
|
var cache = Plate.BuildPerimeterCache(plate);
|
||||||
|
var definitions = new List<CutOff>();
|
||||||
|
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>();
|
||||||
|
|
||||||
|
if (shifted)
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 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
|
||||||
|
{
|
||||||
|
Definitions = definitions.AsReadOnly(),
|
||||||
|
PreviewParts = definitions.Select(c => new Part(c.Drawing)).ToList().AsReadOnly(),
|
||||||
|
Diagnostics = diagnostics.AsReadOnly(),
|
||||||
|
OccupiedSpan = occupied,
|
||||||
|
UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length,
|
||||||
|
TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0,
|
||||||
|
TailSeparatorX = separatorX,
|
||||||
|
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 => !Spans(c)))
|
||||||
|
{
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
|
||||||
|
"A same-line manual cut has different limits. Manual review is required; no cuts may be applied.",
|
||||||
|
true, x));
|
||||||
|
if (isSeparator)
|
||||||
|
WarnNoSeparator(x);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
? NewVertical(x)
|
||||||
|
: new CutOff(existing.Position, existing.Axis)
|
||||||
|
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
|
||||||
|
candidate.Regenerate(plate, settings, cache);
|
||||||
|
var program = candidate.Drawing.Program;
|
||||||
|
var usable = HasUsableSegments(program);
|
||||||
|
if (existing != null)
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.ExistingCutOff,
|
||||||
|
"An equivalent full-span cut-off already exists; no duplicate was added.", X: x));
|
||||||
|
if (!usable)
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.EmptyCut,
|
||||||
|
"The line has no usable cut segments after part clearance and minimum-length filtering.",
|
||||||
|
X: x));
|
||||||
|
|
||||||
|
if (isSeparator)
|
||||||
|
{
|
||||||
|
if (!usable || !IsFullSpanProgram(program, true, spanLow, spanHigh))
|
||||||
|
{
|
||||||
|
WarnNoSeparator(x);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
separated = true;
|
||||||
|
separatorX = candidate.Position.X;
|
||||||
|
}
|
||||||
|
else if (usable && !IsFullSpanProgram(program, true, spanLow, spanHigh))
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.SegmentedCut,
|
||||||
|
"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. Review the tail manually.",
|
||||||
|
X: x));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateInputs(Plate plate, AutomaticCutOffOptions options, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
|
||||||
|
$"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options));
|
||||||
|
Require(!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 &&
|
||||||
|
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
|
||||||
|
"Sheet length and width must be positive and finite.", nameof(plate));
|
||||||
|
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate));
|
||||||
|
// Strips are planned across a rectangle's known span; an outlined sheet gets manual cut-offs only.
|
||||||
|
Require(plate.Outline == null, "Automatic cut-offs need a rectangular sheet; this plate has an outline.", 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 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");
|
||||||
|
ValidateProgram(part.Program, new HashSet<Program>());
|
||||||
|
var box = part.Program.BoundingBox();
|
||||||
|
box.Offset(part.Location);
|
||||||
|
Require(ValidBox(box) && box.Length > 0 && box.Width > 0,
|
||||||
|
"Real parts must have finite, nonempty geometry.", "plate");
|
||||||
|
var cached = part.BoundingBox;
|
||||||
|
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
|
||||||
|
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
|
||||||
|
"Part geometry has stale bounds; update it before planning.", "plate");
|
||||||
|
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);
|
||||||
|
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
|
||||||
|
// (including refitted arc centers) are still rejected, even below epsilon.
|
||||||
|
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
|
||||||
|
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
|
||||||
|
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
|
||||||
|
Require(Inside(entityBox, sheet,
|
||||||
|
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, occupiedY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateProgram(Program program, HashSet<Program> path)
|
||||||
|
{
|
||||||
|
Require(program?.Codes != null && path.Count < 64 && path.Add(program),
|
||||||
|
"Part program is missing, recursive, or nested too deeply.", "plate");
|
||||||
|
foreach (var code in program.Codes)
|
||||||
|
{
|
||||||
|
Require(code != null, "Part program contains a missing instruction.", "plate");
|
||||||
|
if (code is Motion motion)
|
||||||
|
Require(Finite(motion.EndPoint), "Part motion coordinates must be finite.", "plate");
|
||||||
|
if (code is ArcMove arc)
|
||||||
|
Require(Finite(arc.CenterPoint) && Enum.IsDefined(arc.Rotation),
|
||||||
|
"Part arc geometry must be finite with a valid direction.", "plate");
|
||||||
|
if (code is SubProgramCall call)
|
||||||
|
{
|
||||||
|
Require(Finite(call.Offset) && double.IsFinite(call.Rotation),
|
||||||
|
"Part sub-program pose must be finite.", "plate");
|
||||||
|
ValidateProgram(call.Program, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.Remove(program);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateExisting(Plate plate, Box bounds, CutOffSettings settings)
|
||||||
|
{
|
||||||
|
foreach (var cut in plate.CutOffs)
|
||||||
|
{
|
||||||
|
Require(cut != null && Enum.IsDefined(cut.Axis) && Finite(cut.Position) &&
|
||||||
|
(!cut.StartLimit.HasValue || double.IsFinite(cut.StartLimit.Value)) &&
|
||||||
|
(!cut.EndLimit.HasValue || double.IsFinite(cut.EndLimit.Value)),
|
||||||
|
"Existing cut-off definitions must be finite with a valid axis.", nameof(plate));
|
||||||
|
var start = cut.StartLimit ?? (cut.Axis == CutOffAxis.Vertical ? bounds.Bottom : bounds.Left);
|
||||||
|
var end = cut.EndLimit ?? ((cut.Axis == CutOffAxis.Vertical ? bounds.Top : bounds.Right)
|
||||||
|
+ settings.Overtravel);
|
||||||
|
Require(double.IsFinite(end) && start < end,
|
||||||
|
"Existing cut-off limits must be finite and ordered.", nameof(plate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
||||||
|
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
|
||||||
|
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
|
||||||
|
|
||||||
|
private static bool HasUsableSegments(Program program, bool vertical = true)
|
||||||
|
{
|
||||||
|
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
|
||||||
|
return false;
|
||||||
|
for (var i = 0; i < program.Codes.Count; i += 2)
|
||||||
|
{
|
||||||
|
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
|
||||||
|
!Finite(from.EndPoint) || !Finite(to.EndPoint) ||
|
||||||
|
(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 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;
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
private static bool Near(double a, double b)
|
||||||
|
{
|
||||||
|
// An exact tolerance-sized offset can round just above epsilon after subtraction.
|
||||||
|
// Allow two representational steps, not a geometry-scale relative tolerance.
|
||||||
|
var magnitude = System.Math.Max(System.Math.Abs(a), System.Math.Abs(b));
|
||||||
|
var step = System.Math.BitIncrement(magnitude) - magnitude;
|
||||||
|
return System.Math.Abs(a - b) <= GeometryTolerance + (double.IsFinite(step) ? 2 * step : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Require(bool valid, string message, string parameter)
|
||||||
|
{
|
||||||
|
if (!valid)
|
||||||
|
throw new ArgumentException(message, parameter);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>Automatic start preference, ordered: a lower value is more preferred.</summary>
|
||||||
|
internal enum AutomaticEntryKind
|
||||||
|
{
|
||||||
|
/// <summary>A convex turn of the contour's own travel, classified exactly as emission classifies it.</summary>
|
||||||
|
ConvexCorner = 0,
|
||||||
|
|
||||||
|
/// <summary>The midpoint of a straight edge.</summary>
|
||||||
|
StraightMidpoint = 1,
|
||||||
|
|
||||||
|
/// <summary>A tangent line/arc joint. A collinear line/line split is not a joint and never appears.</summary>
|
||||||
|
TangentJoint = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tier 3 (fallback): a point on a straight edge meeting a convex corner, back from the
|
||||||
|
/// corner by about twice the applicable lead-in length, strictly inside the edge.
|
||||||
|
/// </summary>
|
||||||
|
NearCorner = 3,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tier 3 (fallback): the exact native closest point facing the caller's look-ahead
|
||||||
|
/// position (pass the arrival there when there is no next cut). Never a reflex/cusp vertex.
|
||||||
|
/// </summary>
|
||||||
|
TargetFacing = 4,
|
||||||
|
|
||||||
|
/// <summary>Tier 3 (fallback): the native midpoint of an arc entity.</summary>
|
||||||
|
ArcMidpoint = 5,
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// One automatic start candidate: the owned contour choice plus its preference kind and a
|
||||||
|
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
|
||||||
|
/// owner; nothing here exposes or mutates the underlying shape. <paramref name="CornerReach"/>
|
||||||
|
/// is how far a corner fillet's joint lies from the sharp corner it rounds (0 otherwise), so
|
||||||
|
/// ranking can count it on both of that corner's sides.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind, double CornerReach = 0)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
|
||||||
|
/// equal points rank together regardless of the entity that produced them.
|
||||||
|
/// </summary>
|
||||||
|
internal (long X, long Y) GeometryKey => (Quantize(Choice.Point.X), Quantize(Choice.Point.Y));
|
||||||
|
|
||||||
|
private static long Quantize(double value) =>
|
||||||
|
(long)System.Math.Round(value / PostVerificationGeometry.Epsilon);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure deterministic ordering of the automatic entry catalogue toward the next cut:
|
||||||
|
/// circles rank by outgoing distance; other contours use facing sides, tier and travel,
|
||||||
|
/// then a stable geometric key. It adds no
|
||||||
|
/// candidates, mutates nothing, runs no lead checks and applies no cap — feasibility
|
||||||
|
/// filtering and the bounded selection belong to S07/S08, the wiring to S09.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ContourEntryRanking
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Orders <paramref name="candidates"/> for one contour in local coordinates. With a
|
||||||
|
/// <paramref name="target"/> (the next cut's look-ahead point): the number of matched
|
||||||
|
/// facing sides of the candidate bounding rectangle descending (a corner on both facing
|
||||||
|
/// sides is ideal), then rank tier ascending, then arrival->entry + entry->target
|
||||||
|
/// ascending, then the stable geometric key. With no target (the last part): tier first,
|
||||||
|
/// then distance to <paramref name="arrival"/> — never toward the plate origin. The
|
||||||
|
/// input list is returned untouched as a new list; entity order is never meaningful.
|
||||||
|
/// </summary>
|
||||||
|
internal static IReadOnlyList<ContourEntryCandidate> RankTowardNextCut(
|
||||||
|
this IReadOnlyList<ContourEntryCandidate> candidates,
|
||||||
|
Vector? target = null,
|
||||||
|
Vector? arrival = null)
|
||||||
|
{
|
||||||
|
if (target.HasValue) PostVerificationGeometry.Validate(target.Value);
|
||||||
|
if (arrival.HasValue) PostVerificationGeometry.Validate(arrival.Value);
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
return new List<ContourEntryCandidate>();
|
||||||
|
|
||||||
|
// Whole circles have no corners. A diagonal compass point is equally near two
|
||||||
|
// bounding-box sides, but must not gain the two-side bonus of a real corner.
|
||||||
|
// Rank outgoing travel first so arrival cannot pull the start away from the next
|
||||||
|
// cut. Retain every compass/polar alternative for feasibility and rapid checks;
|
||||||
|
// configured angle rounding remains the emitter's responsibility.
|
||||||
|
if (target is { } next && candidates.Any(c => c.Kind == AutomaticEntryKind.CircleCompass)
|
||||||
|
&& candidates.All(c => c.Kind is AutomaticEntryKind.CircleCompass or AutomaticEntryKind.TargetFacing))
|
||||||
|
return candidates.OrderBy(c => c.Choice.Point.DistanceTo(next))
|
||||||
|
.ThenBy(c => arrival is { } from ? c.Choice.Point.DistanceTo(from) : 0.0)
|
||||||
|
.ThenBy(c => c.GeometryKey.X)
|
||||||
|
.ThenBy(c => c.GeometryKey.Y)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Candidate bounding rectangle in the contour's local coordinates; every candidate
|
||||||
|
// lies on the contour, so distances to the four side lines order side proximity.
|
||||||
|
var minX = double.PositiveInfinity;
|
||||||
|
var minY = double.PositiveInfinity;
|
||||||
|
var maxX = double.NegativeInfinity;
|
||||||
|
var maxY = double.NegativeInfinity;
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
var p = candidate.Choice.Point;
|
||||||
|
if (p.X < minX) minX = p.X;
|
||||||
|
if (p.X > maxX) maxX = p.X;
|
||||||
|
if (p.Y < minY) minY = p.Y;
|
||||||
|
if (p.Y > maxY) maxY = p.Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facing sides from the target relative to the centre, matching the source plan:
|
||||||
|
// horizontal right when the target is right of centre else left; vertical likewise.
|
||||||
|
var centreX = minX + (maxX - minX) * 0.5;
|
||||||
|
var centreY = minY + (maxY - minY) * 0.5;
|
||||||
|
var facingRight = target != null && target.Value.X > centreX;
|
||||||
|
var facingTop = target != null && target.Value.Y > centreY;
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival)))
|
||||||
|
.OrderByDescending(x => x.Score.Facing)
|
||||||
|
.ThenBy(x => x.Score.Tier)
|
||||||
|
.ThenBy(x => x.Score.Travel)
|
||||||
|
.ThenBy(x => x.Candidate.GeometryKey.X)
|
||||||
|
.ThenBy(x => x.Candidate.GeometryKey.Y)
|
||||||
|
.Select(x => x.Candidate)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ranking tier — coarser than the preference kind: outside corners first, then
|
||||||
|
/// straight midpoints and tangent joints as peers, then every fallback kind.
|
||||||
|
/// </summary>
|
||||||
|
internal static int RankTier(this AutomaticEntryKind kind) => kind switch
|
||||||
|
{
|
||||||
|
AutomaticEntryKind.ConvexCorner => 0,
|
||||||
|
AutomaticEntryKind.StraightMidpoint or AutomaticEntryKind.TangentJoint => 1,
|
||||||
|
_ => 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static (int Facing, int Tier, double Travel) Score(
|
||||||
|
ContourEntryCandidate candidate,
|
||||||
|
double minX,
|
||||||
|
double minY,
|
||||||
|
double maxX,
|
||||||
|
double maxY,
|
||||||
|
bool facingRight,
|
||||||
|
bool facingTop,
|
||||||
|
Vector? target,
|
||||||
|
Vector? arrival)
|
||||||
|
{
|
||||||
|
var p = candidate.Choice.Point;
|
||||||
|
var facing = 0;
|
||||||
|
if (target != null)
|
||||||
|
{
|
||||||
|
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
|
||||||
|
// sides within tolerance, a fillet's joint within its reach of the corner it
|
||||||
|
// rounds); count how many of them are facing sides.
|
||||||
|
var left = p.X - minX;
|
||||||
|
var right = maxX - p.X;
|
||||||
|
var bottom = p.Y - minY;
|
||||||
|
var top = maxY - p.Y;
|
||||||
|
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
|
||||||
|
var near = min + PostVerificationGeometry.Epsilon + candidate.CornerReach;
|
||||||
|
if (facingRight && right <= near) facing++;
|
||||||
|
if (!facingRight && left <= near) facing++;
|
||||||
|
if (facingTop && top <= near) facing++;
|
||||||
|
if (!facingTop && bottom <= near) facing++;
|
||||||
|
}
|
||||||
|
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
|
||||||
|
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
|
||||||
|
return (facing, candidate.Kind.RankTier(), travel);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Curve = OpenNest.Diagnostics.PostVerificationGeometry.Curve;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>Independently constructed expected geometry, frozen before final replay.
|
||||||
|
/// It is not a search verdict or the payload returned to the caller.</summary>
|
||||||
|
internal sealed class SelectedContourProgram
|
||||||
|
{
|
||||||
|
private readonly PreparedContours owner;
|
||||||
|
private readonly ContourChoice[] choices;
|
||||||
|
internal OwnedExecution Expected { get; }
|
||||||
|
internal double TabSize { get; }
|
||||||
|
|
||||||
|
internal SelectedContourProgram(PreparedContours owner, IReadOnlyList<ContourChoice> choices,
|
||||||
|
OwnedExecution expected, double tabSize)
|
||||||
|
{
|
||||||
|
this.owner = owner;
|
||||||
|
this.choices = choices.ToArray();
|
||||||
|
Expected = expected;
|
||||||
|
TabSize = tabSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool Matches(PreparedContours prepared, IReadOnlyList<ContourChoice> selected)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(owner, prepared) || !choices.SequenceEqual(selected)) return false;
|
||||||
|
prepared.ValidateCompleteChoices(selected);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Directed native boundary accounting and selected-emission correspondence.
|
||||||
|
/// No tessellation, topology inference, live source reads or final emission.</summary>
|
||||||
|
internal static class ContourProgramVerifier
|
||||||
|
{
|
||||||
|
private const double Epsilon = PostVerificationGeometry.Epsilon;
|
||||||
|
|
||||||
|
internal static bool Verify(OwnedExecution actual, LeadMaterialSnapshot material,
|
||||||
|
SelectedContourProgram selected, CancellationToken token)
|
||||||
|
{
|
||||||
|
var budget = 1000000;
|
||||||
|
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();
|
||||||
|
if (run.Curves.Count == 0) return false;
|
||||||
|
var first = run.Curves[0];
|
||||||
|
var ringIndex = -1;
|
||||||
|
var entityIndex = -1;
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
ringIndex = r;
|
||||||
|
entityIndex = e;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
// 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) > 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, tolerance, token, ref budget)) return false;
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var left = Runs(actual);
|
||||||
|
var right = Runs(expected);
|
||||||
|
if (left.Count != right.Count) return false;
|
||||||
|
for (var i = 0; i < left.Count; i++)
|
||||||
|
{
|
||||||
|
Query(token, ref budget);
|
||||||
|
var a = left[i];
|
||||||
|
var b = right[i];
|
||||||
|
if (a.Kind != b.Kind || a.Rapid != b.Rapid) return false;
|
||||||
|
if (a.Rapid)
|
||||||
|
{
|
||||||
|
// Arrival is supplied anew by replay, not captured expected geometry.
|
||||||
|
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, 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, double tolerance, CancellationToken token, ref int budget)
|
||||||
|
{
|
||||||
|
foreach (var curve in actual)
|
||||||
|
{
|
||||||
|
var consumed = 0.0;
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
if (!cyclic) return false;
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
|
var nominal = expected[index];
|
||||||
|
var remaining = nominal.Length - offset;
|
||||||
|
if (remaining <= 0)
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
offset = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var length = System.Math.Min(remaining, curve.Length - consumed);
|
||||||
|
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 - tolerance)
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cyclic || index == expected.Count || index == expected.Count - 1
|
||||||
|
&& expected[index].Length - offset <= tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Run> Runs(OwnedExecution execution)
|
||||||
|
{
|
||||||
|
var runs = new List<Run>();
|
||||||
|
foreach (var motion in execution.Motions)
|
||||||
|
{
|
||||||
|
var kind = motion.Layer is LayerType.Cut or LayerType.Display ? LayerType.Cut : motion.Layer;
|
||||||
|
if (motion.Rapid)
|
||||||
|
{
|
||||||
|
runs.Add(new Run(true, kind, motion.End));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (motion.Length <= Epsilon) continue; // Zero travel contributes no coverage.
|
||||||
|
if (runs.Count == 0 || runs[^1].Rapid || runs[^1].Kind != kind)
|
||||||
|
runs.Add(new Run(false, kind, motion.End));
|
||||||
|
runs[^1].Curves.Add(motion.Curve);
|
||||||
|
}
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Query(CancellationToken token, ref int budget)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (--budget < 0) throw new NotSupportedException("Replay contour accounting exceeds the native query limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Run(bool Rapid, LayerType Kind, Vector End)
|
||||||
|
{
|
||||||
|
internal List<Curve> Curves { get; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>An owned planned program for one unlocked part; ownership passes to the part on Apply.</summary>
|
||||||
|
internal sealed class PlannedPartProgram
|
||||||
|
{
|
||||||
|
public PlannedPartProgram(Part part, Program program, CuttingParameters parameters)
|
||||||
|
{
|
||||||
|
Part = part;
|
||||||
|
Program = program;
|
||||||
|
Parameters = parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Part Part { get; }
|
||||||
|
public Program Program { get; }
|
||||||
|
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<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
|
||||||
|
{
|
||||||
|
Applied,
|
||||||
|
Stale,
|
||||||
|
InvalidInput,
|
||||||
|
Cancelled,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applied means every plate in scope holds its new state. RefreshErrors are observer failures
|
||||||
|
/// raised after that consistent state was published; they are not a rollback. Every other
|
||||||
|
/// status leaves every plate exactly as it was.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CuttingCommitResult
|
||||||
|
{
|
||||||
|
internal CuttingCommitResult(CuttingCommitStatus status, string message = null, Plate plate = null,
|
||||||
|
Exception error = null, IEnumerable<Exception> refreshErrors = null)
|
||||||
|
{
|
||||||
|
Status = status;
|
||||||
|
Message = message;
|
||||||
|
Plate = plate;
|
||||||
|
Error = error;
|
||||||
|
RefreshErrors = Array.AsReadOnly((refreshErrors ?? []).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public CuttingCommitStatus Status { get; }
|
||||||
|
public string Message { get; }
|
||||||
|
public Plate Plate { get; }
|
||||||
|
public Exception Error { get; }
|
||||||
|
public IReadOnlyList<Exception> RefreshErrors { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Installs verified cutting plans for a whole scope at once. Nothing is searched, emitted,
|
||||||
|
/// rotated or regenerated here: inputs are validated and checked for freshness, bounds are
|
||||||
|
/// staged, then order and programs are installed synchronously and published once per plate.
|
||||||
|
/// Internal: it checks root program references only, so payloads must be owned copies of
|
||||||
|
/// independently replayed proposals. CuttingPlanService.Apply is the public entry point.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CuttingPlanCommit
|
||||||
|
{
|
||||||
|
internal static CuttingCommitResult Apply(IEnumerable<PlateCuttingPlan> plans, CancellationToken token = default) =>
|
||||||
|
Apply(plans, token, null);
|
||||||
|
|
||||||
|
// beforeInstall is a test seam that runs inside the install boundary, before each program.
|
||||||
|
internal static CuttingCommitResult Apply(IEnumerable<PlateCuttingPlan> plans, CancellationToken token,
|
||||||
|
Action<Plate, Part> beforeInstall)
|
||||||
|
{
|
||||||
|
if (token.IsCancellationRequested)
|
||||||
|
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
||||||
|
var scope = plans?.ToArray();
|
||||||
|
if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null))
|
||||||
|
return Invalid(null, "A nonempty set of captured plate plans is required.");
|
||||||
|
var plates = new HashSet<Plate>(ReferenceEqualityComparer.Instance);
|
||||||
|
foreach (var plan in scope)
|
||||||
|
if (!plates.Add(plan.Expected.Plate))
|
||||||
|
return Invalid(plan.Expected.Plate, "A plate appears more than once in the commit scope.");
|
||||||
|
|
||||||
|
// Freshness for the whole scope before any validation that reads live geometry.
|
||||||
|
foreach (var plan in scope)
|
||||||
|
{
|
||||||
|
string difference;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
difference = plan.Expected.Difference(token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
|
||||||
|
}
|
||||||
|
if (difference != null)
|
||||||
|
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
|
||||||
|
}
|
||||||
|
|
||||||
|
var staged = new List<Staged>(scope.Length);
|
||||||
|
var targets = new HashSet<Part>(ReferenceEqualityComparer.Instance);
|
||||||
|
var members = new HashSet<Part>(ReferenceEqualityComparer.Instance);
|
||||||
|
var installed = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||||
|
var live = new HashSet<Program>(scope.SelectMany(p => p.Expected.Order).Select(p => p.Program),
|
||||||
|
ReferenceEqualityComparer.Instance);
|
||||||
|
foreach (var plan in scope)
|
||||||
|
{
|
||||||
|
var plate = plan.Expected.Plate;
|
||||||
|
Part[] order;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
order = plate.Parts.ValidateReorder(plan.Order);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return Invalid(plate, "The planned order is not exactly the plate's current parts.");
|
||||||
|
}
|
||||||
|
// A part on two plates would let one plate's install change the other's verified program.
|
||||||
|
if (order.Any(part => !members.Add(part)))
|
||||||
|
return Invalid(plate, "A part appears on more than one plate in the commit scope.");
|
||||||
|
var onPlate = new HashSet<Part>(order, ReferenceEqualityComparer.Instance);
|
||||||
|
var programs = new List<(Part, Program, Box, CuttingParameters)>();
|
||||||
|
foreach (var planned in plan.Programs)
|
||||||
|
{
|
||||||
|
if (planned?.Part == null || !onPlate.Contains(planned.Part) || !targets.Add(planned.Part))
|
||||||
|
return Invalid(plate, "Planned programs must target distinct parts of their own plate.");
|
||||||
|
if (planned.Part.LeadInsLocked)
|
||||||
|
return Invalid(plate, "A locked part's program is retained exactly and cannot be replaced.");
|
||||||
|
if (planned.Program == null || planned.Parameters == null || live.Contains(planned.Program)
|
||||||
|
|| !installed.Add(planned.Program))
|
||||||
|
return Invalid(plate, "Planned programs and settings must be present, owned and unshared.");
|
||||||
|
if (!planned.Program.Codes.Any(code => code is Motion
|
||||||
|
|| code is SubProgramCall call && call.Program?.Codes.Any(sub => sub is Motion) == true))
|
||||||
|
return Invalid(plate, "A planned program has no motion.");
|
||||||
|
Box bounds;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bounds = planned.Program.BoundingBox();
|
||||||
|
bounds.Offset(planned.Part.Location);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
|
||||||
|
or NotSupportedException or ArithmeticException)
|
||||||
|
{
|
||||||
|
return Invalid(plate, "A planned program's bounds cannot be computed.");
|
||||||
|
}
|
||||||
|
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, 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,
|
||||||
|
(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).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)
|
||||||
|
{
|
||||||
|
for (var i = undo.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
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.",
|
||||||
|
undo.Count == 0 ? null : undo[^1].Plate, ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish only after the whole scope is consistent.
|
||||||
|
var errors = new List<Exception>();
|
||||||
|
foreach (var item in staged.Where(s => s.Changed))
|
||||||
|
item.Plate.Parts.RaiseItemsReordered(errors);
|
||||||
|
return new(CuttingCommitStatus.Applied, errors.Count == 0 ? null
|
||||||
|
: "Applied; one or more views failed to refresh.", refreshErrors: errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CuttingCommitResult Invalid(Plate plate, string message) =>
|
||||||
|
new(CuttingCommitStatus.InvalidInput, message, plate);
|
||||||
|
|
||||||
|
private sealed record Staged(Plate Plate, Part[] Order,
|
||||||
|
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs,
|
||||||
|
List<(Part Segment, Program Program, CutOff CutOff)> CutOffs, bool Changed);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>Guarded read of stable placed programs; never clones or mutates their graphs.</summary>
|
||||||
|
public static class ExecutionMotionReader
|
||||||
|
{
|
||||||
|
public static OwnedExecution Read(Program program, Vector origin, Vector? previous,
|
||||||
|
CancellationToken token)
|
||||||
|
=> ReadCore(program, origin, previous, token, false);
|
||||||
|
|
||||||
|
/// <summary>Read only exact built-in runtime types and defined program modes.
|
||||||
|
/// Validate the original graph before cloning: Clone can erase unsupported subclasses.
|
||||||
|
/// Inputs must remain stable during this bounded, cancellation-aware read.</summary>
|
||||||
|
public static OwnedExecution ReadSupported(Program program, Vector origin, Vector? previous,
|
||||||
|
CancellationToken token = default)
|
||||||
|
=> ReadCore(program, origin, previous, token, true);
|
||||||
|
|
||||||
|
private static OwnedExecution ReadCore(Program program, Vector origin, Vector? previous,
|
||||||
|
CancellationToken token, bool requireSupportedTypes)
|
||||||
|
{
|
||||||
|
var moves = new List<ExecutionMotion>();
|
||||||
|
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||||
|
var budget = 1000000;
|
||||||
|
Walk(program, origin, previous);
|
||||||
|
return new OwnedExecution(moves);
|
||||||
|
|
||||||
|
Vector Walk(Program current, Vector frame, Vector? arrival)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
PostVerificationGeometry.Validate(frame);
|
||||||
|
if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64)
|
||||||
|
throw new ArgumentException("Missing, recursive or excessively nested program.");
|
||||||
|
if (requireSupportedTypes && (current.GetType() != typeof(Program) || !Enum.IsDefined(current.Mode)))
|
||||||
|
throw new NotSupportedException("Unsupported program runtime type or mode.");
|
||||||
|
var pos = frame;
|
||||||
|
var first = true;
|
||||||
|
var countBefore = moves.Count;
|
||||||
|
var raised = HeadRaise.RaisedMask(current.Codes);
|
||||||
|
for (var codeIndex = 0; codeIndex < current.Codes.Count; codeIndex++)
|
||||||
|
{
|
||||||
|
var code = current.Codes[codeIndex];
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (--budget < 0)
|
||||||
|
throw new ArgumentException("Program expansion exceeds the verification limit.");
|
||||||
|
if (code == null)
|
||||||
|
throw new ArgumentException("Program contains a missing instruction.");
|
||||||
|
// 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();
|
||||||
|
if (type != typeof(RapidMove) && type != typeof(LinearMove) && type != typeof(ArcMove)
|
||||||
|
&& type != typeof(SubProgramCall) && type != typeof(Comment) && type != typeof(Feedrate) && type != typeof(Kerf))
|
||||||
|
throw new NotSupportedException("Unsupported instruction runtime type.");
|
||||||
|
}
|
||||||
|
if (code is SubProgramCall call)
|
||||||
|
{
|
||||||
|
if (!double.IsFinite(call.Rotation))
|
||||||
|
throw new ArgumentException("Subprogram rotation is not finite.");
|
||||||
|
// Call rotation is baked into the shared program by its setter. Do not
|
||||||
|
// rotate again; offsets are frame-relative even in incremental mode.
|
||||||
|
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
|
||||||
|
first = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code is not Motion motion)
|
||||||
|
{
|
||||||
|
if (code is not (Comment or Feedrate or Kerf))
|
||||||
|
throw new NotSupportedException("Unsupported program instruction.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Posts disagree about incremental position after suppressed instructions.
|
||||||
|
// Never silently certify a trajectory whose semantics are ambiguous.
|
||||||
|
if (motion.Suppressed)
|
||||||
|
throw new NotSupportedException("Suppressed motion requires post-specific verification.");
|
||||||
|
if (motion is not (RapidMove or LinearMove or ArcMove))
|
||||||
|
throw new NotSupportedException("Unsupported motion.");
|
||||||
|
var reference = current.Mode == Mode.Incremental ? pos : frame;
|
||||||
|
var end = reference + motion.EndPoint;
|
||||||
|
PostVerificationGeometry.Validate(end);
|
||||||
|
var rapid = motion is RapidMove;
|
||||||
|
if (first && !rapid)
|
||||||
|
moves.Add(new(arrival, frame, true, LayerType.Display, null));
|
||||||
|
var start = first && rapid ? arrival : pos;
|
||||||
|
var layer = motion switch
|
||||||
|
{
|
||||||
|
LinearMove line => line.Layer,
|
||||||
|
ArcMove arc => arc.Layer,
|
||||||
|
_ => LayerType.Display
|
||||||
|
};
|
||||||
|
if (!Enum.IsDefined(layer))
|
||||||
|
throw new NotSupportedException("Unsupported motion layer.");
|
||||||
|
if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation))
|
||||||
|
throw new NotSupportedException("Unsupported arc direction.");
|
||||||
|
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
|
||||||
|
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
|
||||||
|
motion is ArcMove { Rotation: RotationType.CW });
|
||||||
|
moves.Add(new(start, end, rapid, layer, curve, raised[codeIndex],
|
||||||
|
ReferenceEquals(current, program) ? codeIndex : null));
|
||||||
|
pos = end;
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
visiting.Remove(current);
|
||||||
|
if (moves.Count == countBefore)
|
||||||
|
throw new ArgumentException("Program has no motions.");
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
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, 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,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
// 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)
|
||||||
|
throw new ArgumentException("Material contains a degenerate motion.");
|
||||||
|
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
|
||||||
|
throw new ArgumentException("Material contour is discontinuous.");
|
||||||
|
chain.Add(curve);
|
||||||
|
chainTabs.Add(motion.Layer == LayerType.Tab);
|
||||||
|
chainDisplay.Add(motion.Layer == LayerType.DisplayPerimeter);
|
||||||
|
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
||||||
|
Finish();
|
||||||
|
}
|
||||||
|
Finish();
|
||||||
|
if (rings.Count == 0)
|
||||||
|
throw new ArgumentException("Material has no closed contour.");
|
||||||
|
var budget = 1000000;
|
||||||
|
// Certify simple rings and mutually disjoint boundaries before containment.
|
||||||
|
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++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token)))
|
||||||
|
{
|
||||||
|
if (outer >= 0)
|
||||||
|
throw new ArgumentException("Material perimeter is ambiguous.");
|
||||||
|
outer = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (outer < 0)
|
||||||
|
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(),
|
||||||
|
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
|
||||||
|
|
||||||
|
void Finish()
|
||||||
|
{
|
||||||
|
if (chain.Count == 0)
|
||||||
|
return;
|
||||||
|
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[]>(), Array.Empty<bool[]>(), ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 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)
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
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) =>
|
||||||
|
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
|
||||||
|
{
|
||||||
|
var budget = 1000000;
|
||||||
|
for (var i = 0; i < execution.Motions.Count; i++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
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."), null);
|
||||||
|
Vector? allowed = null;
|
||||||
|
var groupEdge = i;
|
||||||
|
var step = move.Layer == LayerType.Leadin ? 1 : -1;
|
||||||
|
while (groupEdge + step >= 0 && groupEdge + step < execution.Motions.Count
|
||||||
|
&& execution.Motions[groupEdge + step].Layer == move.Layer)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (--budget < 0)
|
||||||
|
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||||
|
groupEdge += step;
|
||||||
|
}
|
||||||
|
var adjacentIndex = groupEdge + step;
|
||||||
|
var genuineJoint = false;
|
||||||
|
var breakLead = false;
|
||||||
|
if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count)
|
||||||
|
{
|
||||||
|
var adjacent = execution.Motions[adjacentIndex];
|
||||||
|
// 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;
|
||||||
|
// 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)
|
||||||
|
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||||
|
if (joint.DistanceTo(contourJoint) <= PostVerificationGeometry.Epsilon
|
||||||
|
&& boundary.SameSupport(adjacent.Curve)
|
||||||
|
&& boundary.Contains(adjacent.Curve.Start) && boundary.Contains(adjacent.End)
|
||||||
|
&& boundary.Contains(adjacent.Curve.Midpoint)
|
||||||
|
&& adjacent.Length <= boundary.Length + PostVerificationGeometry.Epsilon)
|
||||||
|
{
|
||||||
|
genuineJoint = true;
|
||||||
|
if (groupEdge == i)
|
||||||
|
allowed = joint;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!genuineJoint)
|
||||||
|
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."), 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, 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 (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."), other);
|
||||||
|
}
|
||||||
|
return PierceZone();
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using System;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>Copies only supported, exact setting types; never slices custom subclasses.</summary>
|
||||||
|
internal static class OwnedCuttingParameters
|
||||||
|
{
|
||||||
|
internal static CuttingParameters Copy(CuttingParameters source)
|
||||||
|
{
|
||||||
|
Exact<CuttingParameters>(source);
|
||||||
|
Exact<SequenceParameters>(source.Sequencing);
|
||||||
|
Exact<AssignmentParameters>(source.Assignment);
|
||||||
|
var sequence = source.Sequencing;
|
||||||
|
var assignment = source.Assignment;
|
||||||
|
if (!Enum.IsDefined(sequence.Method) || !Enum.IsDefined(assignment.Method))
|
||||||
|
throw new ArgumentException("Unsupported sequence method.");
|
||||||
|
if (assignment.Preference == null)
|
||||||
|
throw new ArgumentException("Missing assignment preference.");
|
||||||
|
if (!double.IsFinite(source.LeadInAngleIncrement) || source.LeadInAngleIncrement <= 0
|
||||||
|
|| source.LeadInAngleIncrement > 360)
|
||||||
|
throw new ArgumentException("Angle increment must be in (0, 360] degrees.");
|
||||||
|
return new CuttingParameters
|
||||||
|
{
|
||||||
|
Id = source.Id,
|
||||||
|
MachineName = source.MachineName,
|
||||||
|
MaterialName = source.MaterialName,
|
||||||
|
Grade = source.Grade,
|
||||||
|
Thickness = Dimension(source.Thickness),
|
||||||
|
Kerf = Dimension(source.Kerf),
|
||||||
|
PartSpacing = Dimension(source.PartSpacing),
|
||||||
|
PierceClearance = Dimension(source.PierceClearance),
|
||||||
|
PierceDiameter = Dimension(source.PierceDiameter),
|
||||||
|
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
|
||||||
|
InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
|
||||||
|
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
|
||||||
|
InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
|
||||||
|
RoundLeadInAngles = source.RoundLeadInAngles,
|
||||||
|
LeadInAngleIncrement = source.LeadInAngleIncrement,
|
||||||
|
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,
|
||||||
|
SmallCutoutWidth = Dimension(sequence.SmallCutoutWidth),
|
||||||
|
SmallCutoutHeight = Dimension(sequence.SmallCutoutHeight),
|
||||||
|
MediumCutoutWidth = Dimension(sequence.MediumCutoutWidth),
|
||||||
|
MediumCutoutHeight = Dimension(sequence.MediumCutoutHeight),
|
||||||
|
DistanceMediumSmall = Dimension(sequence.DistanceMediumSmall),
|
||||||
|
AlternateRowsColumns = sequence.AlternateRowsColumns,
|
||||||
|
AlternateCutoutsWithinRowColumn = sequence.AlternateCutoutsWithinRowColumn,
|
||||||
|
MinDistanceBetweenRowsColumns = Dimension(sequence.MinDistanceBetweenRowsColumns)
|
||||||
|
},
|
||||||
|
Assignment = new AssignmentParameters
|
||||||
|
{
|
||||||
|
Method = assignment.Method,
|
||||||
|
Preference = assignment.Preference,
|
||||||
|
MinGeometryLength = Dimension(assignment.MinGeometryLength)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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), 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
|
||||||
|
{
|
||||||
|
Length1 = Dimension(n.Length1),
|
||||||
|
Length2 = Dimension(n.Length2),
|
||||||
|
ApproachAngle1 = Angle(n.ApproachAngle1),
|
||||||
|
ApproachAngle2 = Angle(n.ApproachAngle2)
|
||||||
|
},
|
||||||
|
CleanHoleLeadIn n when n.GetType() == typeof(CleanHoleLeadIn) => new CleanHoleLeadIn
|
||||||
|
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), Kerf = Dimension(n.Kerf) },
|
||||||
|
null => throw new ArgumentException("Missing lead-in settings."),
|
||||||
|
_ => throw new NotSupportedException("Unsupported lead-in runtime type.")
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
|
||||||
|
ArcLeadOut n when n.GetType() == typeof(ArcLeadOut) => new ArcLeadOut { Radius = Dimension(n.Radius) },
|
||||||
|
null => throw new ArgumentException("Missing lead-out settings."),
|
||||||
|
_ => throw new NotSupportedException("Unsupported lead-out runtime type.")
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static Tab CopyTab(Tab source)
|
||||||
|
{
|
||||||
|
Tab copy = source switch
|
||||||
|
{
|
||||||
|
NormalTab n when n.GetType() == typeof(NormalTab) => new NormalTab
|
||||||
|
{
|
||||||
|
CutoutMinWidth = Dimension(n.CutoutMinWidth),
|
||||||
|
CutoutMinHeight = Dimension(n.CutoutMinHeight),
|
||||||
|
CutoutMaxWidth = Dimension(n.CutoutMaxWidth),
|
||||||
|
CutoutMaxHeight = Dimension(n.CutoutMaxHeight)
|
||||||
|
},
|
||||||
|
BreakerTab n when n.GetType() == typeof(BreakerTab) => new BreakerTab
|
||||||
|
{
|
||||||
|
BreakerDepth = Dimension(n.BreakerDepth),
|
||||||
|
BreakerLeadInLength = Dimension(n.BreakerLeadInLength),
|
||||||
|
BreakerAngle = Angle(n.BreakerAngle)
|
||||||
|
},
|
||||||
|
MachineTab n when n.GetType() == typeof(MachineTab) => new MachineTab { MachineTabId = n.MachineTabId },
|
||||||
|
_ => throw new NotSupportedException("Unsupported tab runtime type.")
|
||||||
|
};
|
||||||
|
copy.Size = Dimension(source.Size);
|
||||||
|
copy.TabLeadIn = source.TabLeadIn == null ? null : CopyLeadIn(source.TabLeadIn);
|
||||||
|
copy.TabLeadOut = source.TabLeadOut == null ? null : CopyLeadOut(source.TabLeadOut);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Exact<T>(T value) where T : class
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
throw new ArgumentException("Missing cutting settings.");
|
||||||
|
if (value.GetType() != typeof(T))
|
||||||
|
throw new NotSupportedException("Unsupported settings runtime type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Dimension(double value) => double.IsFinite(value) && value >= 0
|
||||||
|
? value : throw new ArgumentException("Dimensions must be finite and nonnegative.");
|
||||||
|
|
||||||
|
// 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,68 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>Owned value motions; no references to live programs, instructions or settings.</summary>
|
||||||
|
public sealed class OwnedExecution
|
||||||
|
{
|
||||||
|
internal OwnedExecution(IEnumerable<ExecutionMotion> motions)
|
||||||
|
{
|
||||||
|
Motions = Array.AsReadOnly(motions.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ExecutionMotion> Motions { get; }
|
||||||
|
public Vector DeparturePoint => Motions[^1].End;
|
||||||
|
public bool HasCuttingContour => Motions.Any(move => !move.Rapid
|
||||||
|
&& move.Layer is LayerType.Cut or LayerType.Display
|
||||||
|
&& move.Length > PostVerificationGeometry.Epsilon);
|
||||||
|
|
||||||
|
public double RapidDistanceFrom(Vector arrival)
|
||||||
|
{
|
||||||
|
PostVerificationGeometry.Validate(arrival);
|
||||||
|
var total = 0.0;
|
||||||
|
for (var index = 0; index < Motions.Count; index++)
|
||||||
|
{
|
||||||
|
var move = Motions[index];
|
||||||
|
if (move.Rapid && (index == 0 ? arrival : move.Start) is { } start)
|
||||||
|
total += start.DistanceTo(move.End);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An immutable motion value; native line/arc geometry stays private to Core.</summary>
|
||||||
|
public sealed class ExecutionMotion
|
||||||
|
{
|
||||||
|
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
|
||||||
|
PostVerificationGeometry.Curve curve, bool headRaised = false, int? code = null)
|
||||||
|
{
|
||||||
|
Start = start;
|
||||||
|
End = end;
|
||||||
|
Rapid = rapid;
|
||||||
|
Layer = layer;
|
||||||
|
Curve = curve;
|
||||||
|
HeadRaised = headRaised;
|
||||||
|
Code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vector? Start { get; }
|
||||||
|
public Vector End { get; }
|
||||||
|
public bool Rapid { get; }
|
||||||
|
public LayerType Layer { get; }
|
||||||
|
|
||||||
|
/// <summary>A rapid made with the head raised over parts already cut (<see cref="HeadRaise"/>).</summary>
|
||||||
|
public bool HeadRaised { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The index of the code this motion runs in the read program's own codes; null for a motion
|
||||||
|
/// inside a sub-program and for the rapid a program that opens with a cut is given.
|
||||||
|
/// </summary>
|
||||||
|
public int? Code { get; }
|
||||||
|
public double Length => Curve?.Length ?? 0;
|
||||||
|
internal PostVerificationGeometry.Curve Curve { get; }
|
||||||
|
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve, HeadRaised, Code);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>Lossless owned copies at the cutting-plan boundary, not a general Clone change.</summary>
|
||||||
|
internal static class OwnedProgramCopy
|
||||||
|
{
|
||||||
|
private const int Limit = 1000000;
|
||||||
|
private const int MaxDepth = 64;
|
||||||
|
|
||||||
|
internal static Program Copy(Program original, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
Validate(original, token);
|
||||||
|
// Built-in Clone preserves mode/rotation and binds calls without rotating setters.
|
||||||
|
// Its per-parent maps can duplicate diamonds; restore one global owned graph below.
|
||||||
|
var cloned = (Program)original.Clone();
|
||||||
|
var programs = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
|
||||||
|
var codes = new Dictionary<ICode, ICode>(ReferenceEqualityComparer.Instance);
|
||||||
|
var bindings = new Dictionary<Dictionary<string, string>, Dictionary<string, string>>(ReferenceEqualityComparer.Instance);
|
||||||
|
return Restore(original, cloned);
|
||||||
|
|
||||||
|
Program Restore(Program source, Program copy)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (programs.TryGetValue(source, out var existing)) return existing;
|
||||||
|
programs.Add(source, copy);
|
||||||
|
for (var i = 0; i < source.Codes.Count; i++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var authored = source.Codes[i];
|
||||||
|
if (codes.TryGetValue(authored, out var owned))
|
||||||
|
{
|
||||||
|
copy.Codes[i] = owned;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
owned = copy.Codes[i];
|
||||||
|
codes.Add(authored, owned);
|
||||||
|
if (authored is Motion motion)
|
||||||
|
{
|
||||||
|
var target = (Motion)owned;
|
||||||
|
// All other built-in fields are retained by their Clone implementations.
|
||||||
|
target.UseExactStop = motion.UseExactStop;
|
||||||
|
target.Feedrate = motion.Feedrate;
|
||||||
|
if (motion.VariableRefs != null)
|
||||||
|
{
|
||||||
|
if (!bindings.TryGetValue(motion.VariableRefs, out var refs))
|
||||||
|
{
|
||||||
|
refs = new Dictionary<string, string>(motion.VariableRefs, motion.VariableRefs.Comparer);
|
||||||
|
bindings.Add(motion.VariableRefs, refs);
|
||||||
|
}
|
||||||
|
target.VariableRefs = refs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (authored is SubProgramCall call)
|
||||||
|
{
|
||||||
|
var target = (SubProgramCall)owned;
|
||||||
|
target.BindProgram(Restore(call.Program, target.Program));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var (id, child) in source.SubPrograms)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
copy.SubPrograms[id] = Restore(child, copy.SubPrograms[id]);
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlike the execution reader, inactive registered graphs can be motionless or
|
||||||
|
// contain suppressed motions. They still must be supported, acyclic and bounded.
|
||||||
|
internal static void Validate(Program program, CancellationToken token)
|
||||||
|
{
|
||||||
|
var active = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||||
|
var done = new Dictionary<Program, (int Cost, int Depth)>(ReferenceEqualityComparer.Instance);
|
||||||
|
var budget = Limit;
|
||||||
|
Visit(program);
|
||||||
|
(int Cost, int Depth) Visit(Program current)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (current == null || active.Contains(current) || active.Count >= MaxDepth)
|
||||||
|
throw new ArgumentException("Missing, recursive or excessively nested clone graph.");
|
||||||
|
if (done.TryGetValue(current, out var previous))
|
||||||
|
{
|
||||||
|
if (active.Count + previous.Depth > MaxDepth)
|
||||||
|
throw new ArgumentException("Excessively nested clone graph.");
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
if (current.GetType() != typeof(Program) || !Enum.IsDefined(current.Mode))
|
||||||
|
throw new NotSupportedException("Unsupported program runtime type or mode.");
|
||||||
|
if (current.Codes == null)
|
||||||
|
throw new ArgumentException("Missing clone graph instructions.");
|
||||||
|
active.Add(current);
|
||||||
|
var children = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||||
|
var cost = 1;
|
||||||
|
var depth = 1;
|
||||||
|
foreach (var code in current.Codes)
|
||||||
|
{
|
||||||
|
Step();
|
||||||
|
cost++;
|
||||||
|
if (code == null)
|
||||||
|
throw new ArgumentException("Missing clone graph instruction.");
|
||||||
|
var type = code.GetType();
|
||||||
|
if (type != typeof(RapidMove) && type != typeof(LinearMove) && type != typeof(ArcMove)
|
||||||
|
&& type != typeof(SubProgramCall) && type != typeof(Comment) && type != typeof(Feedrate) && type != typeof(Kerf))
|
||||||
|
throw new NotSupportedException("Unsupported instruction runtime type.");
|
||||||
|
if (code is SubProgramCall call) Child(call.Program);
|
||||||
|
}
|
||||||
|
foreach (var child in current.SubPrograms.Values)
|
||||||
|
{
|
||||||
|
Step();
|
||||||
|
Child(child);
|
||||||
|
}
|
||||||
|
if (cost > Limit)
|
||||||
|
throw new ArgumentException("Clone graph exceeds the verification limit.");
|
||||||
|
active.Remove(current);
|
||||||
|
var result = (cost, depth);
|
||||||
|
done.Add(current, result);
|
||||||
|
return result;
|
||||||
|
|
||||||
|
void Child(Program child)
|
||||||
|
{
|
||||||
|
var summary = Visit(child);
|
||||||
|
// Program.Clone shares children only within one parent; bound its real
|
||||||
|
// expanded work as well as the distinct original graph before calling it.
|
||||||
|
if (!children.Add(child)) return;
|
||||||
|
if (summary.Cost > Limit - cost)
|
||||||
|
throw new ArgumentException("Clone expansion exceeds the verification limit.");
|
||||||
|
cost += summary.Cost;
|
||||||
|
depth = System.Math.Max(depth, summary.Depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Step()
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (--budget < 0)
|
||||||
|
throw new ArgumentException("Clone graph exceeds the verification limit.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>Every Part field a cutting commit can change, held by reference for exact rollback.</summary>
|
||||||
|
internal sealed record PartCuttingState(Program Program, bool OwnsProgram, double PreLeadInRotation,
|
||||||
|
bool HasManualLeadIns, bool LeadInsLocked, CuttingParameters CuttingParameters, Vector Location,
|
||||||
|
Box BoundingBox);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Collections;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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, 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
|
||||||
|
{
|
||||||
|
private readonly ObservableList<Part> partList;
|
||||||
|
private readonly PartRecord[] parts;
|
||||||
|
private readonly int quantity;
|
||||||
|
private readonly Size size;
|
||||||
|
private readonly int quadrant;
|
||||||
|
private readonly ObservableList<CutOff> cutOffList;
|
||||||
|
private readonly CutOffRecord[] cutOffs;
|
||||||
|
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, DrawingRecord> drawings, Dictionary<CuttingParameters, string> settings)
|
||||||
|
{
|
||||||
|
this.settings = settings;
|
||||||
|
plateSettings = plate.CuttingParameters;
|
||||||
|
Plate = plate;
|
||||||
|
partList = plate.Parts;
|
||||||
|
this.parts = parts;
|
||||||
|
quantity = plate.Quantity;
|
||||||
|
size = plate.Size;
|
||||||
|
quadrant = plate.Quadrant;
|
||||||
|
cutOffList = plate.CutOffs;
|
||||||
|
this.cutOffs = cutOffs;
|
||||||
|
this.drawings = drawings;
|
||||||
|
Order = Array.AsReadOnly(parts.Select(p => p.Part).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Plate Plate { get; }
|
||||||
|
|
||||||
|
/// <summary>Captured part order (reference identity).</summary>
|
||||||
|
public IReadOnlyList<Part> Order { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures on the caller thread. Unsupported or malformed programs throw
|
||||||
|
/// <see cref="ArgumentException"/> or <see cref="NotSupportedException"/>; so do part or
|
||||||
|
/// plate settings that are not exact built-in settings types, whose state cannot be
|
||||||
|
/// captured exactly.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateCuttingState Capture(Plate plate, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
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, DrawingRecord>(ReferenceEqualityComparer.Instance);
|
||||||
|
var settings = new Dictionary<CuttingParameters, string>(ReferenceEqualityComparer.Instance);
|
||||||
|
Fingerprint(plate.CuttingParameters);
|
||||||
|
var records = new List<PartRecord>(plate.Parts.Count);
|
||||||
|
foreach (var part in plate.Parts)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (part?.BaseDrawing == null || part.Program == null)
|
||||||
|
throw new ArgumentException("Plate contains a missing part, drawing or program.");
|
||||||
|
var drawing = part.BaseDrawing;
|
||||||
|
if (!drawings.ContainsKey(drawing))
|
||||||
|
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),
|
||||||
|
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,
|
||||||
|
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
|
||||||
|
return new(plate, records.ToArray(), cutOffs, drawings, settings);
|
||||||
|
|
||||||
|
void Fingerprint(CuttingParameters parameters)
|
||||||
|
{
|
||||||
|
if (parameters != null && !settings.ContainsKey(parameters))
|
||||||
|
settings.Add(parameters, StateFingerprint.Of(parameters));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the live plate still has exactly the captured state.</summary>
|
||||||
|
public bool IsCurrent(CancellationToken token = default) => Difference(token) == null;
|
||||||
|
|
||||||
|
/// <summary>Null when current; otherwise the first observed difference.</summary>
|
||||||
|
public string Difference(CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var checkedSettings = new Dictionary<CuttingParameters, bool>(ReferenceEqualityComparer.Instance);
|
||||||
|
var plate = Plate;
|
||||||
|
if (!ReferenceEquals(plate.Parts, partList) || !ReferenceEquals(plate.CutOffs, cutOffList))
|
||||||
|
return "The plate's part or cutoff list was replaced.";
|
||||||
|
if (plate.Quantity != quantity || !Bits(plate.Size.Width, size.Width)
|
||||||
|
|| !Bits(plate.Size.Length, size.Length) || plate.Quadrant != quadrant)
|
||||||
|
return "Plate quantity, size or quadrant changed.";
|
||||||
|
if (!ReferenceEquals(plate.CuttingParameters, plateSettings) || !SameSettings(plateSettings))
|
||||||
|
return "Plate cutting settings changed.";
|
||||||
|
if (plate.Parts.Count != parts.Length)
|
||||||
|
return "Parts were added or removed.";
|
||||||
|
for (var i = 0; i < parts.Length; i++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var record = parts[i];
|
||||||
|
var part = plate.Parts[i];
|
||||||
|
if (!ReferenceEquals(part, record.Part))
|
||||||
|
return $"Part order changed at position {i + 1}.";
|
||||||
|
var live = part.CaptureCuttingState();
|
||||||
|
var was = record.State;
|
||||||
|
if (!ReferenceEquals(live.Program, was.Program) || live.OwnsProgram != was.OwnsProgram
|
||||||
|
|| !ReferenceEquals(live.CuttingParameters, was.CuttingParameters)
|
||||||
|
|| !ReferenceEquals(live.BoundingBox, was.BoundingBox))
|
||||||
|
return $"Part {i + 1} program, settings or bounds were replaced.";
|
||||||
|
if (!Bits(live.Location.X, was.Location.X) || !Bits(live.Location.Y, was.Location.Y)
|
||||||
|
|| !Bits(live.PreLeadInRotation, was.PreLeadInRotation)
|
||||||
|
|| live.HasManualLeadIns != was.HasManualLeadIns || live.LeadInsLocked != was.LeadInsLocked)
|
||||||
|
return $"Part {i + 1} pose, lead-in or lock state changed.";
|
||||||
|
if (!BoxValues(live.BoundingBox).SequenceEqual(record.Bounds))
|
||||||
|
return $"Part {i + 1} bounds changed.";
|
||||||
|
if (!ProgramContent.Equal(part.Program, record.Program, token))
|
||||||
|
return $"Part {i + 1} program was edited in place.";
|
||||||
|
if (!SameSettings(live.CuttingParameters))
|
||||||
|
return $"Part {i + 1} cutting settings were edited in place.";
|
||||||
|
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 != 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.";
|
||||||
|
for (var i = 0; i < cutOffs.Length; i++)
|
||||||
|
{
|
||||||
|
var record = cutOffs[i];
|
||||||
|
var cutOff = plate.CutOffs[i];
|
||||||
|
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.HeadLockOvercut, record.HeadLockOvercut) || cutOff.FarEdgeFirst != record.FarEdgeFirst)
|
||||||
|
return $"Cutoff {i + 1} definition changed.";
|
||||||
|
}
|
||||||
|
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
|
||||||
|
// all compared exactly above.
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// References were compared already; this catches in-place edits of the same object.
|
||||||
|
// Each distinct settings object is fingerprinted at most once per check. A nested
|
||||||
|
// settings object replaced since capture by an unsupported type cannot equal the
|
||||||
|
// captured built-in state: Stale, not an exception.
|
||||||
|
bool SameSettings(CuttingParameters parameters)
|
||||||
|
{
|
||||||
|
if (parameters == null)
|
||||||
|
return true;
|
||||||
|
if (checkedSettings.TryGetValue(parameters, out var known))
|
||||||
|
return known;
|
||||||
|
bool current;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
current = settings.TryGetValue(parameters, out var captured)
|
||||||
|
&& captured == StateFingerprint.Of(parameters);
|
||||||
|
}
|
||||||
|
catch (NotSupportedException)
|
||||||
|
{
|
||||||
|
current = false;
|
||||||
|
}
|
||||||
|
checkedSettings[parameters] = current;
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long[] BoxValues(Box box) => box == null ? [] :
|
||||||
|
[BitConverter.DoubleToInt64Bits(box.X), BitConverter.DoubleToInt64Bits(box.Y),
|
||||||
|
BitConverter.DoubleToInt64Bits(box.Width), BitConverter.DoubleToInt64Bits(box.Length)];
|
||||||
|
|
||||||
|
private static bool Bits(double a, double b) =>
|
||||||
|
BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 HeadLockOvercut, bool FarEdgeFirst);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,122 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exact structural equality of two program graphs: instruction runtime types, scalar bits,
|
||||||
|
/// motion metadata, variables and the same instruction/sub-program sharing shape. This is
|
||||||
|
/// a freshness check, not geometric equivalence; unsupported instruction types never match.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ProgramContent
|
||||||
|
{
|
||||||
|
internal static bool Equal(Program left, Program right, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var programs = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
|
||||||
|
var programOwners = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||||
|
var codes = new Dictionary<ICode, ICode>(ReferenceEqualityComparer.Instance);
|
||||||
|
var codeOwners = new HashSet<ICode>(ReferenceEqualityComparer.Instance);
|
||||||
|
return SameProgram(left, right);
|
||||||
|
|
||||||
|
bool SameProgram(Program a, Program b)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (a == null || b == null)
|
||||||
|
return a == null && b == null;
|
||||||
|
// Pair graphs one-to-one so shared and distinct sub-programs cannot be confused.
|
||||||
|
if (programs.TryGetValue(a, out var paired))
|
||||||
|
return ReferenceEquals(paired, b);
|
||||||
|
if (!programOwners.Add(b))
|
||||||
|
return false;
|
||||||
|
programs.Add(a, b);
|
||||||
|
// Codes is a writable field: a missing list is a difference, never an exception.
|
||||||
|
if (a.Codes == null || b.Codes == null)
|
||||||
|
return a.Codes == null && b.Codes == null && a.GetType() == b.GetType();
|
||||||
|
if (a.GetType() != b.GetType() || a.Mode != b.Mode || !Bits(a.Rotation, b.Rotation)
|
||||||
|
|| a.Codes.Count != b.Codes.Count || a.SubPrograms.Count != b.SubPrograms.Count
|
||||||
|
|| !SameVariables(a.Variables, b.Variables))
|
||||||
|
return false;
|
||||||
|
for (var i = 0; i < a.Codes.Count; i++)
|
||||||
|
if (!SameCode(a.Codes[i], b.Codes[i]))
|
||||||
|
return false;
|
||||||
|
foreach (var (id, child) in a.SubPrograms)
|
||||||
|
if (!b.SubPrograms.TryGetValue(id, out var other) || !SameProgram(child, other))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SameCode(ICode a, ICode b)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (a == null || b == null)
|
||||||
|
return a == null && b == null;
|
||||||
|
if (codes.TryGetValue(a, out var paired))
|
||||||
|
return ReferenceEquals(paired, b);
|
||||||
|
if (!codeOwners.Add(b))
|
||||||
|
return false;
|
||||||
|
codes.Add(a, b);
|
||||||
|
if (a.GetType() != b.GetType())
|
||||||
|
return false;
|
||||||
|
return (a, b) switch
|
||||||
|
{
|
||||||
|
(RapidMove x, RapidMove y) => SameMotion(x, y),
|
||||||
|
(LinearMove x, LinearMove y) => SameMotion(x, y) && x.Layer == y.Layer,
|
||||||
|
(ArcMove x, ArcMove y) => SameMotion(x, y) && x.Layer == y.Layer
|
||||||
|
&& x.Rotation == y.Rotation && Bits(x.CenterPoint, y.CenterPoint),
|
||||||
|
(SubProgramCall x, SubProgramCall y) => x.Id == y.Id && Bits(x.Offset, y.Offset)
|
||||||
|
&& Bits(x.Rotation, y.Rotation) && SameProgram(x.Program, y.Program),
|
||||||
|
(Comment x, Comment y) => string.Equals(x.Value, y.Value, StringComparison.Ordinal),
|
||||||
|
(Feedrate x, Feedrate y) => Bits(x.Value, y.Value)
|
||||||
|
&& string.Equals(x.VariableRef, y.VariableRef, StringComparison.Ordinal),
|
||||||
|
(Kerf x, Kerf y) => x.Value == y.Value,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SameMotion(Motion a, Motion b) => Bits(a.EndPoint, b.EndPoint)
|
||||||
|
&& a.UseExactStop == b.UseExactStop && a.Feedrate == b.Feedrate && a.Suppressed == b.Suppressed
|
||||||
|
&& SameRefs(a.VariableRefs, b.VariableRefs);
|
||||||
|
|
||||||
|
private static bool SameRefs(Dictionary<string, string> a, Dictionary<string, string> b)
|
||||||
|
{
|
||||||
|
if (a == null || b == null)
|
||||||
|
return a == null && b == null;
|
||||||
|
if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer) || !SameSpelling(a.Keys, b.Keys))
|
||||||
|
return false;
|
||||||
|
foreach (var (key, value) in a)
|
||||||
|
if (!b.TryGetValue(key, out var other) || !string.Equals(value, other, StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A case-insensitive dictionary finds a respelled key; authored spelling must still match.
|
||||||
|
private static bool SameSpelling(IEnumerable<string> a, IEnumerable<string> b) =>
|
||||||
|
new HashSet<string>(a, StringComparer.Ordinal).SetEquals(b);
|
||||||
|
|
||||||
|
private static bool SameVariables(Dictionary<string, VariableDefinition> a, Dictionary<string, VariableDefinition> b)
|
||||||
|
{
|
||||||
|
if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer) || !SameSpelling(a.Keys, b.Keys))
|
||||||
|
return false;
|
||||||
|
foreach (var (key, value) in a)
|
||||||
|
{
|
||||||
|
if (!b.TryGetValue(key, out var other))
|
||||||
|
return false;
|
||||||
|
if (ReferenceEquals(value, other))
|
||||||
|
continue;
|
||||||
|
if (value == null || other == null
|
||||||
|
|| !string.Equals(value.Name, other.Name, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(value.Expression, other.Expression, StringComparison.Ordinal)
|
||||||
|
|| !Bits(value.Value, other.Value) || value.Inline != other.Inline || value.Global != other.Global)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Bits(Vector a, Vector b) => Bits(a.X, b.X) && Bits(a.Y, b.Y);
|
||||||
|
|
||||||
|
private static bool Bits(double a, double b) =>
|
||||||
|
BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>Direct XY completed-contour checker. This is not physical machine safety.</summary>
|
||||||
|
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(reportMissingLeadIns)
|
||||||
|
{
|
||||||
|
partContours = partContours,
|
||||||
|
partClosed = partClosed,
|
||||||
|
};
|
||||||
|
copy.obstacles.AddRange(obstacles);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Consumes one whole owned program; part numbers are caller identity keys.</summary>
|
||||||
|
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
|
||||||
|
int partNumber, CancellationToken token = default) => Check(execution, arrival, partNumber, false, token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// As the analyzer does for cutoffs: rapids are checked, but cutoff cuts need no lead-in and,
|
||||||
|
/// being open, never become obstacles.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
|
||||||
|
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. Crossings made by a rapid with the
|
||||||
|
/// head raised are returned apart as well, as Raised: they are the raise's reason, not a fault.
|
||||||
|
/// </summary>
|
||||||
|
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed,
|
||||||
|
IReadOnlyList<PostVerificationFinding> Raised) Check(
|
||||||
|
OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(execution);
|
||||||
|
if (arrival is { } point)
|
||||||
|
PostVerificationGeometry.Validate(point);
|
||||||
|
var moves = execution.Motions.ToArray();
|
||||||
|
moves[0] = moves[0].WithStart(arrival);
|
||||||
|
var findings = new List<PostVerificationFinding>();
|
||||||
|
var fixedFindings = new List<PostVerificationFinding>();
|
||||||
|
var raisedFindings = new List<PostVerificationFinding>();
|
||||||
|
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings,
|
||||||
|
raisedFindings: raisedFindings);
|
||||||
|
return (findings.AsReadOnly(), fixedFindings.AsReadOnly(), raisedFindings.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,
|
||||||
|
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
|
||||||
|
bool proveOpenCuts = false, int contoursBefore = 0, List<PostVerificationFinding> raisedFindings = null)
|
||||||
|
{
|
||||||
|
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 = 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),
|
||||||
|
System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X),
|
||||||
|
System.Math.Max(start.Y, move.End.Y));
|
||||||
|
foreach (var obstacle in obstacles)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
// A rapid well clear of a contour's extent can neither cross nor touch it.
|
||||||
|
if (reach.IsClearOf(obstacle.Extent))
|
||||||
|
continue;
|
||||||
|
// 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))
|
||||||
|
continue;
|
||||||
|
var crossing = new PostVerificationFinding(PostVerificationKind.RapidCrossing, plate, part,
|
||||||
|
obstacle.Part, (move.HeadRaised ? "Head-raised" : "Direct") + " XY rapid crosses or touches "
|
||||||
|
+ $"completed untabbed contour {obstacle.Contour} of part {obstacle.Part}.")
|
||||||
|
{
|
||||||
|
Location = (start + move.End) * 0.5,
|
||||||
|
RapidStart = start,
|
||||||
|
RapidEnd = move.End,
|
||||||
|
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
|
||||||
|
};
|
||||||
|
// The head clears a cut part on a raised rapid; the crossing is the raise's reason.
|
||||||
|
if (move.HeadRaised)
|
||||||
|
raisedFindings?.Add(crossing);
|
||||||
|
else
|
||||||
|
Add(crossing, 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 && 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)
|
||||||
|
{
|
||||||
|
// 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 (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 resumed = unfinished.FindIndex(chain =>
|
||||||
|
chain.Curves[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
|
||||||
|
if (resumed >= 0)
|
||||||
|
{
|
||||||
|
current = unfinished[resumed];
|
||||||
|
unfinished.RemoveAt(resumed);
|
||||||
|
}
|
||||||
|
current.Leads.AddRange(leadIns);
|
||||||
|
leadIns.Clear();
|
||||||
|
}
|
||||||
|
current.Curves.Add(move.Curve);
|
||||||
|
current.Fixed |= owned;
|
||||||
|
previous = current;
|
||||||
|
// A completed contour becomes an obstacle immediately, not at part end.
|
||||||
|
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();
|
||||||
|
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."),
|
||||||
|
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 (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 && (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(current);
|
||||||
|
current = new CutGroup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exact text record of cutting settings for detecting in-place edits after capture. It is a
|
||||||
|
/// freshness fingerprint, not a serializer.
|
||||||
|
/// Only the exact built-in settings types are supported — the same set
|
||||||
|
/// <see cref="OwnedCuttingParameters"/> copies: <see cref="CuttingParameters"/>,
|
||||||
|
/// <see cref="SequenceParameters"/>, <see cref="AssignmentParameters"/> and the built-in
|
||||||
|
/// lead-in, lead-out and tab types. Every member is written explicitly by type, so nothing is
|
||||||
|
/// discovered reflectively and no code of another type ever runs: each object's runtime type
|
||||||
|
/// is checked before any of its members is read, and any other runtime type (a subclass
|
||||||
|
/// included) throws <see cref="NotSupportedException"/>. Doubles are written by bit pattern
|
||||||
|
/// and every number is formatted invariantly, so the text is exact and culture-free.
|
||||||
|
/// </summary>
|
||||||
|
internal static class StateFingerprint
|
||||||
|
{
|
||||||
|
/// <summary>Fingerprint of <paramref name="settings"/>; null renders as a marker.</summary>
|
||||||
|
/// <exception cref="NotSupportedException">A settings object is not an exact built-in type.</exception>
|
||||||
|
internal static string Of(CuttingParameters settings)
|
||||||
|
{
|
||||||
|
var writer = new Writer();
|
||||||
|
writer.Settings(settings);
|
||||||
|
return writer.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
Int(p.Id);
|
||||||
|
Text(p.MachineName);
|
||||||
|
Text(p.MaterialName);
|
||||||
|
Text(p.Grade);
|
||||||
|
Number(p.Thickness);
|
||||||
|
Number(p.Kerf);
|
||||||
|
Number(p.PartSpacing);
|
||||||
|
Lead(p.ExternalLeadIn);
|
||||||
|
Out(p.ExternalLeadOut);
|
||||||
|
Lead(p.InternalLeadIn);
|
||||||
|
Out(p.InternalLeadOut);
|
||||||
|
Number(p.PierceClearance);
|
||||||
|
Number(p.PierceDiameter);
|
||||||
|
Flag(p.RoundLeadInAngles);
|
||||||
|
Number(p.LeadInAngleIncrement);
|
||||||
|
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('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Sequence(SequenceParameters s)
|
||||||
|
{
|
||||||
|
if (!Begin(s, typeof(SequenceParameters)))
|
||||||
|
return;
|
||||||
|
Int((int)s.Method);
|
||||||
|
Number(s.SmallCutoutWidth);
|
||||||
|
Number(s.SmallCutoutHeight);
|
||||||
|
Number(s.MediumCutoutWidth);
|
||||||
|
Number(s.MediumCutoutHeight);
|
||||||
|
Number(s.DistanceMediumSmall);
|
||||||
|
Flag(s.AlternateRowsColumns);
|
||||||
|
Flag(s.AlternateCutoutsWithinRowColumn);
|
||||||
|
Number(s.MinDistanceBetweenRowsColumns);
|
||||||
|
text.Append('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Assignment(AssignmentParameters a)
|
||||||
|
{
|
||||||
|
if (!Begin(a, typeof(AssignmentParameters)))
|
||||||
|
return;
|
||||||
|
Int((int)a.Method);
|
||||||
|
Text(a.Preference);
|
||||||
|
Number(a.MinGeometryLength);
|
||||||
|
text.Append('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Lead(LeadIn lead)
|
||||||
|
{
|
||||||
|
if (lead == null)
|
||||||
|
{
|
||||||
|
text.Append("null;");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (lead)
|
||||||
|
{
|
||||||
|
case NoLeadIn when Begin(lead, typeof(NoLeadIn)):
|
||||||
|
break;
|
||||||
|
case LineLeadIn l when Begin(lead, typeof(LineLeadIn)):
|
||||||
|
Number(l.Length);
|
||||||
|
Number(l.ApproachAngle);
|
||||||
|
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);
|
||||||
|
Number(l.ApproachAngle);
|
||||||
|
Number(l.ArcRadius);
|
||||||
|
break;
|
||||||
|
case LineLineLeadIn l when Begin(lead, typeof(LineLineLeadIn)):
|
||||||
|
Number(l.Length1);
|
||||||
|
Number(l.ApproachAngle1);
|
||||||
|
Number(l.Length2);
|
||||||
|
Number(l.ApproachAngle2);
|
||||||
|
break;
|
||||||
|
case CleanHoleLeadIn l when Begin(lead, typeof(CleanHoleLeadIn)):
|
||||||
|
Number(l.LineLength);
|
||||||
|
Number(l.ArcRadius);
|
||||||
|
Number(l.Kerf);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw Unsupported(lead);
|
||||||
|
}
|
||||||
|
text.Append('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Out(LeadOut lead)
|
||||||
|
{
|
||||||
|
if (lead == null)
|
||||||
|
{
|
||||||
|
text.Append("null;");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (lead)
|
||||||
|
{
|
||||||
|
case NoLeadOut when Begin(lead, typeof(NoLeadOut)):
|
||||||
|
break;
|
||||||
|
case LineLeadOut l when Begin(lead, typeof(LineLeadOut)):
|
||||||
|
Number(l.Length);
|
||||||
|
Number(l.ApproachAngle);
|
||||||
|
break;
|
||||||
|
case ArcLeadOut l when Begin(lead, typeof(ArcLeadOut)):
|
||||||
|
Number(l.Radius);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw Unsupported(lead);
|
||||||
|
}
|
||||||
|
text.Append('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TabSettings(Tab tab)
|
||||||
|
{
|
||||||
|
if (tab == null)
|
||||||
|
{
|
||||||
|
text.Append("null;");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (tab)
|
||||||
|
{
|
||||||
|
case NormalTab t when Begin(tab, typeof(NormalTab)):
|
||||||
|
Number(t.CutoutMinWidth);
|
||||||
|
Number(t.CutoutMinHeight);
|
||||||
|
Number(t.CutoutMaxWidth);
|
||||||
|
Number(t.CutoutMaxHeight);
|
||||||
|
break;
|
||||||
|
case BreakerTab t when Begin(tab, typeof(BreakerTab)):
|
||||||
|
Number(t.BreakerDepth);
|
||||||
|
Number(t.BreakerLeadInLength);
|
||||||
|
Number(t.BreakerAngle);
|
||||||
|
break;
|
||||||
|
case MachineTab t when Begin(tab, typeof(MachineTab)):
|
||||||
|
Int(t.MachineTabId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw Unsupported(tab);
|
||||||
|
}
|
||||||
|
Number(tab.Size);
|
||||||
|
Lead(tab.TabLeadIn);
|
||||||
|
Out(tab.TabLeadOut);
|
||||||
|
text.Append('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
private bool Begin(object value, Type exact)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
{
|
||||||
|
text.Append("null;");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value.GetType() != exact)
|
||||||
|
throw Unsupported(value);
|
||||||
|
text.Append(exact.Name).Append('{');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Number(double value) =>
|
||||||
|
text.Append(BitConverter.DoubleToInt64Bits(value).ToString(CultureInfo.InvariantCulture)).Append(';');
|
||||||
|
|
||||||
|
private void Int(int value) => text.Append(value.ToString(CultureInfo.InvariantCulture)).Append(';');
|
||||||
|
|
||||||
|
private void Flag(bool value) => text.Append(value ? "1;" : "0;");
|
||||||
|
|
||||||
|
// Length-prefixed so a delimiter inside the text cannot shift fields; null differs from "".
|
||||||
|
private void Text(string value)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
text.Append("~;");
|
||||||
|
else
|
||||||
|
text.Append(value.Length.ToString(CultureInfo.InvariantCulture)).Append(':').Append(value).Append(';');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NotSupportedException Unsupported(object value) =>
|
||||||
|
new($"Cutting settings of type {value.GetType().FullName} cannot be captured exactly; " +
|
||||||
|
"only the built-in settings types are supported.");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -15,24 +15,81 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
|
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
|
||||||
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
|
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; } =
|
public LeadIn InternalLeadIn { get; set; } =
|
||||||
new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
|
new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
|
||||||
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
|
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;
|
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 bool RoundLeadInAngles { get; set; }
|
||||||
public double LeadInAngleIncrement { get; set; } = 5.0;
|
public double LeadInAngleIncrement { get; set; } = 5.0;
|
||||||
|
|
||||||
public double AutoTabMinSize { get; set; }
|
|
||||||
public double AutoTabMaxSize { get; set; }
|
|
||||||
|
|
||||||
public Tab TabConfig { get; set; }
|
public Tab TabConfig { get; set; }
|
||||||
public bool TabsEnabled { get; set; }
|
public bool TabsEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <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 SequenceParameters Sequencing { get; set; } = new SequenceParameters();
|
||||||
public AssignmentParameters Assignment { get; set; } = new AssignmentParameters();
|
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 System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
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 class ArcLeadIn : LeadIn
|
||||||
{
|
{
|
||||||
|
public const double DefaultSweepAngle = 180.0;
|
||||||
|
|
||||||
public double Radius { get; set; }
|
public double Radius { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Degrees of arc, in (0, 180].</summary>
|
||||||
|
public double SweepAngle { get; set; } = DefaultSweepAngle;
|
||||||
|
|
||||||
public override List<ICode> Generate(
|
public override List<ICode> Generate(
|
||||||
Vector contourStartPoint,
|
Vector contourStartPoint,
|
||||||
double contourNormalAngle,
|
double contourNormalAngle,
|
||||||
RotationType winding = RotationType.CW
|
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>
|
return new List<ICode>
|
||||||
{
|
{
|
||||||
new RapidMove(piercePoint),
|
new RapidMove(GetPiercePoint(contourStartPoint, contourNormalAngle, winding)),
|
||||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
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);
|
// The contour point sits opposite the normal from the center; the pierce is the
|
||||||
var arcCenterY = contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
|
// 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(
|
return new Vector(
|
||||||
arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
|
center.X + Radius * System.Math.Cos(angle),
|
||||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle)
|
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 System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
public class CleanHoleLeadIn : LeadIn
|
public class CleanHoleLeadIn : LeadIn
|
||||||
{
|
{
|
||||||
|
private const double ApproachAngle = 135.0;
|
||||||
|
|
||||||
public double LineLength { get; set; }
|
public double LineLength { get; set; }
|
||||||
public double ArcRadius { get; set; }
|
public double ArcRadius { get; set; }
|
||||||
public double Kerf { get; set; }
|
public double Kerf { get; set; }
|
||||||
@@ -14,42 +15,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
Vector contourStartPoint,
|
Vector contourStartPoint,
|
||||||
double contourNormalAngle,
|
double contourNormalAngle,
|
||||||
RotationType winding = RotationType.CW
|
RotationType winding = RotationType.CW
|
||||||
)
|
) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
|
||||||
{
|
|
||||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
|
||||||
|
|
||||||
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||||
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
RotationType winding = RotationType.CW) =>
|
||||||
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
|
||||||
|
|
||||||
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 LeadIn Scale(double factor) =>
|
public override LeadIn Scale(double factor) =>
|
||||||
new CleanHoleLeadIn
|
new CleanHoleLeadIn
|
||||||
@@ -58,5 +28,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
ArcRadius = ArcRadius * factor,
|
ArcRadius = ArcRadius * factor,
|
||||||
Kerf = Kerf,
|
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
|
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 class LeadIn
|
||||||
{
|
{
|
||||||
public abstract List<ICode> Generate(
|
public abstract List<ICode> Generate(
|
||||||
@@ -11,7 +16,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
RotationType winding = RotationType.CW
|
RotationType winding = RotationType.CW
|
||||||
);
|
);
|
||||||
|
|
||||||
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
|
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||||
|
RotationType winding = RotationType.CW);
|
||||||
|
|
||||||
public virtual LeadIn Scale(double factor) => this;
|
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 System.Collections.Generic;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
{
|
{
|
||||||
@@ -14,42 +13,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
Vector contourStartPoint,
|
Vector contourStartPoint,
|
||||||
double contourNormalAngle,
|
double contourNormalAngle,
|
||||||
RotationType winding = RotationType.CW
|
RotationType winding = RotationType.CW
|
||||||
)
|
) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
|
||||||
{
|
|
||||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
|
||||||
|
|
||||||
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
|
||||||
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
RotationType winding = RotationType.CW) =>
|
||||||
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
|
||||||
|
|
||||||
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 LeadIn Scale(double factor) =>
|
public override LeadIn Scale(double factor) =>
|
||||||
new LineArcLeadIn
|
new LineArcLeadIn
|
||||||
@@ -58,5 +26,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
ArcRadius = ArcRadius * factor,
|
ArcRadius = ArcRadius * factor,
|
||||||
ApproachAngle = ApproachAngle,
|
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);
|
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
|
||||||
return new Vector(
|
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 secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||||
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
|
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return new List<ICode> { new RapidMove(contourStartPoint) };
|
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;
|
return contourStartPoint;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ using OpenNest.Geometry;
|
|||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
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 class ArcLeadOut : LeadOut
|
||||||
{
|
{
|
||||||
public double Radius { get; set; }
|
public double Radius { get; set; }
|
||||||
@@ -17,9 +21,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
|
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
|
||||||
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
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(
|
var endPoint = new Vector(
|
||||||
arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2),
|
arcCenterX + Radius * System.Math.Cos(endAngle),
|
||||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)
|
arcCenterY + Radius * System.Math.Sin(endAngle)
|
||||||
);
|
);
|
||||||
|
|
||||||
return new List<ICode>
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A rapid the head must make raised because it passes over parts already cut, which may have
|
||||||
|
/// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing: a
|
||||||
|
/// <see cref="Marker"/> comment immediately before the <see cref="RapidMove"/>. Rapid checks accept
|
||||||
|
/// a crossing on a marked rapid. Posts write their machine's head raise before every marked rapid,
|
||||||
|
/// and refuse to post a marked program when they have none, since the rapid is unsafe head down.
|
||||||
|
/// See docs/cutting-planner.md.
|
||||||
|
/// </summary>
|
||||||
|
public static class HeadRaise
|
||||||
|
{
|
||||||
|
/// <summary>The comment immediately before a rapid made with the head raised.</summary>
|
||||||
|
public const string Marker = "HEAD RAISE";
|
||||||
|
|
||||||
|
public static bool IsMarker(ICode code) => code is Comment { Value: Marker };
|
||||||
|
|
||||||
|
/// <summary>Whether <paramref name="program"/>'s own codes hold a marker.</summary>
|
||||||
|
public static bool IsMarked(Program program)
|
||||||
|
{
|
||||||
|
if (program?.Codes == null)
|
||||||
|
return false;
|
||||||
|
foreach (var code in program.Codes)
|
||||||
|
if (IsMarker(code))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For each of <paramref name="codes"/>, whether it is a rapid made with the head raised. A
|
||||||
|
/// marker must be followed directly by a rapid; anything else is a malformed program
|
||||||
|
/// (<see cref="ArgumentException"/>), since a post could raise before the wrong move.
|
||||||
|
/// </summary>
|
||||||
|
public static bool[] RaisedMask(IReadOnlyList<ICode> codes)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(codes);
|
||||||
|
var mask = new bool[codes.Count];
|
||||||
|
for (var i = 0; i < codes.Count; i++)
|
||||||
|
{
|
||||||
|
if (!IsMarker(codes[i]))
|
||||||
|
continue;
|
||||||
|
if (i + 1 >= codes.Count || codes[i + 1] is not RapidMove)
|
||||||
|
throw new ArgumentException("A head-raise marker is not followed by a rapid.");
|
||||||
|
mask[i + 1] = true;
|
||||||
|
}
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A copy of <paramref name="program"/> with a marker before each of its own codes at
|
||||||
|
/// <paramref name="rapids"/>, which must be rapids not marked already.
|
||||||
|
/// </summary>
|
||||||
|
public static Program Mark(Program program, IReadOnlyCollection<int> rapids)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(program);
|
||||||
|
ArgumentNullException.ThrowIfNull(rapids);
|
||||||
|
var marked = RaisedMask(program.Codes);
|
||||||
|
var copy = (Program)program.Clone();
|
||||||
|
for (var i = copy.Codes.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (!rapids.Contains(i))
|
||||||
|
continue;
|
||||||
|
if (copy.Codes[i] is not RapidMove || marked[i])
|
||||||
|
throw new ArgumentException("Only an unmarked rapid can be marked for a head raise.");
|
||||||
|
copy.Codes.Insert(i, new Comment(Marker));
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refuses a nest a post cannot write, before any output is created: a marked rapid needs
|
||||||
|
/// the post's head-raise code, and its markers must be well formed.
|
||||||
|
/// </summary>
|
||||||
|
public static void EnsurePostable(Nest nest, bool hasCode, 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 (!hasCode)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{name} has rapids that must raise the head over parts already cut, but the {postName} "
|
||||||
|
+ "post has no head-raise code. Enter it in the post's configuration before posting.");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RaisedMask(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
|
public enum LayerType
|
||||||
{
|
{
|
||||||
@@ -7,5 +7,28 @@
|
|||||||
Cut,
|
Cut,
|
||||||
Leadin,
|
Leadin,
|
||||||
Leadout,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+205
-164
@@ -90,6 +90,9 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
SetModeAbs();
|
SetModeAbs();
|
||||||
|
|
||||||
|
// Several calls can share one sub-program (identical holes); rotate each once.
|
||||||
|
var rotatedSubPrograms = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
for (int i = 0; i < Codes.Count; ++i)
|
for (int i = 0; i < Codes.Count; ++i)
|
||||||
{
|
{
|
||||||
var code = Codes[i];
|
var code = Codes[i];
|
||||||
@@ -110,7 +113,7 @@ namespace OpenNest.CNC
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subpgm.Program != null)
|
if (subpgm.Program != null && rotatedSubPrograms.Add(subpgm.Program))
|
||||||
subpgm.Program.Rotate(angle, origin);
|
subpgm.Program.Rotate(angle, origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,37 +272,37 @@ namespace OpenNest.CNC
|
|||||||
switch (Mode)
|
switch (Mode)
|
||||||
{
|
{
|
||||||
case Mode.Absolute:
|
case Mode.Absolute:
|
||||||
{
|
|
||||||
for (int i = Codes.Count; i >= 0; --i)
|
|
||||||
{
|
{
|
||||||
var code = Codes[i];
|
for (int i = Codes.Count; i >= 0; --i)
|
||||||
var motion = code as Motion;
|
{
|
||||||
|
var code = Codes[i];
|
||||||
|
var motion = code as Motion;
|
||||||
|
|
||||||
if (motion == null)
|
if (motion == null)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
return motion.EndPoint;
|
return motion.EndPoint;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case Mode.Incremental:
|
case Mode.Incremental:
|
||||||
{
|
|
||||||
var pos = new Vector(0, 0);
|
|
||||||
|
|
||||||
for (int i = 0; i < Codes.Count; ++i)
|
|
||||||
{
|
{
|
||||||
var code = Codes[i];
|
var pos = new Vector(0, 0);
|
||||||
var motion = code as Motion;
|
|
||||||
|
|
||||||
if (motion == null)
|
for (int i = 0; i < Codes.Count; ++i)
|
||||||
continue;
|
{
|
||||||
|
var code = Codes[i];
|
||||||
|
var motion = code as Motion;
|
||||||
|
|
||||||
pos += motion.EndPoint;
|
if (motion == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
pos += motion.EndPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Vector(0, 0);
|
return new Vector(0, 0);
|
||||||
@@ -334,164 +337,164 @@ namespace OpenNest.CNC
|
|||||||
switch (code.Type)
|
switch (code.Type)
|
||||||
{
|
{
|
||||||
case CodeType.LinearMove:
|
case CodeType.LinearMove:
|
||||||
{
|
{
|
||||||
var line = (LinearMove)code;
|
var line = (LinearMove)code;
|
||||||
var pt =
|
var pt =
|
||||||
Mode == Mode.Absolute
|
Mode == Mode.Absolute
|
||||||
? frameOrigin + line.EndPoint
|
? frameOrigin + line.EndPoint
|
||||||
: line.EndPoint + pos;
|
: line.EndPoint + pos;
|
||||||
|
|
||||||
if (pt.X > maxX)
|
if (pt.X > maxX)
|
||||||
maxX = pt.X;
|
maxX = pt.X;
|
||||||
if (pt.X < minX)
|
if (pt.X < minX)
|
||||||
minX = pt.X;
|
minX = pt.X;
|
||||||
|
|
||||||
if (pt.Y > maxY)
|
if (pt.Y > maxY)
|
||||||
maxY = pt.Y;
|
maxY = pt.Y;
|
||||||
if (pt.Y < minY)
|
if (pt.Y < minY)
|
||||||
minY = pt.Y;
|
minY = pt.Y;
|
||||||
|
|
||||||
pos = pt;
|
pos = pt;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case CodeType.RapidMove:
|
case CodeType.RapidMove:
|
||||||
{
|
{
|
||||||
var line = (RapidMove)code;
|
var line = (RapidMove)code;
|
||||||
var pt =
|
var pt =
|
||||||
Mode == Mode.Absolute
|
Mode == Mode.Absolute
|
||||||
? frameOrigin + line.EndPoint
|
? frameOrigin + line.EndPoint
|
||||||
: line.EndPoint + pos;
|
: line.EndPoint + pos;
|
||||||
|
|
||||||
if (pt.X > maxX)
|
if (pt.X > maxX)
|
||||||
maxX = pt.X;
|
maxX = pt.X;
|
||||||
if (pt.X < minX)
|
if (pt.X < minX)
|
||||||
minX = pt.X;
|
minX = pt.X;
|
||||||
|
|
||||||
if (pt.Y > maxY)
|
if (pt.Y > maxY)
|
||||||
maxY = pt.Y;
|
maxY = pt.Y;
|
||||||
if (pt.Y < minY)
|
if (pt.Y < minY)
|
||||||
minY = pt.Y;
|
minY = pt.Y;
|
||||||
|
|
||||||
pos = pt;
|
pos = pt;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case CodeType.ArcMove:
|
case CodeType.ArcMove:
|
||||||
{
|
|
||||||
var arc = (ArcMove)code;
|
|
||||||
var radius = arc.CenterPoint.DistanceTo(arc.EndPoint);
|
|
||||||
|
|
||||||
Vector endpt;
|
|
||||||
Vector centerpt;
|
|
||||||
|
|
||||||
if (Mode == Mode.Incremental)
|
|
||||||
{
|
{
|
||||||
endpt = arc.EndPoint + pos;
|
var arc = (ArcMove)code;
|
||||||
centerpt = arc.CenterPoint + pos;
|
var radius = arc.CenterPoint.DistanceTo(arc.EndPoint);
|
||||||
|
|
||||||
|
Vector endpt;
|
||||||
|
Vector centerpt;
|
||||||
|
|
||||||
|
if (Mode == Mode.Incremental)
|
||||||
|
{
|
||||||
|
endpt = arc.EndPoint + pos;
|
||||||
|
centerpt = arc.CenterPoint + pos;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
endpt = frameOrigin + arc.EndPoint;
|
||||||
|
centerpt = frameOrigin + arc.CenterPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
double minX1;
|
||||||
|
double minY1;
|
||||||
|
double maxX1;
|
||||||
|
double maxY1;
|
||||||
|
|
||||||
|
if (pos.X < endpt.X)
|
||||||
|
{
|
||||||
|
minX1 = pos.X;
|
||||||
|
maxX1 = endpt.X;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
minX1 = endpt.X;
|
||||||
|
maxX1 = pos.X;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos.Y < endpt.Y)
|
||||||
|
{
|
||||||
|
minY1 = pos.Y;
|
||||||
|
maxY1 = endpt.Y;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
minY1 = endpt.Y;
|
||||||
|
maxY1 = pos.Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startAngle = pos.AngleFrom(centerpt);
|
||||||
|
var endAngle = endpt.AngleFrom(centerpt);
|
||||||
|
|
||||||
|
// switch the angle to counter clockwise.
|
||||||
|
if (arc.Rotation == RotationType.CW)
|
||||||
|
Generic.Swap(ref startAngle, ref endAngle);
|
||||||
|
|
||||||
|
startAngle = Angle.NormalizeRad(startAngle);
|
||||||
|
endAngle = Angle.NormalizeRad(endAngle);
|
||||||
|
|
||||||
|
if (Angle.IsBetweenRad(Angle.HalfPI, startAngle, endAngle))
|
||||||
|
maxY1 = centerpt.Y + radius;
|
||||||
|
|
||||||
|
if (Angle.IsBetweenRad(System.Math.PI, startAngle, endAngle))
|
||||||
|
minX1 = centerpt.X - radius;
|
||||||
|
|
||||||
|
const double oneHalfPI = System.Math.PI * 1.5;
|
||||||
|
|
||||||
|
if (Angle.IsBetweenRad(oneHalfPI, startAngle, endAngle))
|
||||||
|
minY1 = centerpt.Y - radius;
|
||||||
|
|
||||||
|
if (Angle.IsBetweenRad(Angle.TwoPI, startAngle, endAngle))
|
||||||
|
maxX1 = centerpt.X + radius;
|
||||||
|
|
||||||
|
if (maxX1 > maxX)
|
||||||
|
maxX = maxX1;
|
||||||
|
|
||||||
|
if (minX1 < minX)
|
||||||
|
minX = minX1;
|
||||||
|
|
||||||
|
if (maxY1 > maxY)
|
||||||
|
maxY = maxY1;
|
||||||
|
|
||||||
|
if (minY1 < minY)
|
||||||
|
minY = minY1;
|
||||||
|
|
||||||
|
pos = endpt;
|
||||||
|
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
endpt = frameOrigin + arc.EndPoint;
|
|
||||||
centerpt = frameOrigin + arc.CenterPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
double minX1;
|
|
||||||
double minY1;
|
|
||||||
double maxX1;
|
|
||||||
double maxY1;
|
|
||||||
|
|
||||||
if (pos.X < endpt.X)
|
|
||||||
{
|
|
||||||
minX1 = pos.X;
|
|
||||||
maxX1 = endpt.X;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
minX1 = endpt.X;
|
|
||||||
maxX1 = pos.X;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pos.Y < endpt.Y)
|
|
||||||
{
|
|
||||||
minY1 = pos.Y;
|
|
||||||
maxY1 = endpt.Y;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
minY1 = endpt.Y;
|
|
||||||
maxY1 = pos.Y;
|
|
||||||
}
|
|
||||||
|
|
||||||
var startAngle = pos.AngleFrom(centerpt);
|
|
||||||
var endAngle = endpt.AngleFrom(centerpt);
|
|
||||||
|
|
||||||
// switch the angle to counter clockwise.
|
|
||||||
if (arc.Rotation == RotationType.CW)
|
|
||||||
Generic.Swap(ref startAngle, ref endAngle);
|
|
||||||
|
|
||||||
startAngle = Angle.NormalizeRad(startAngle);
|
|
||||||
endAngle = Angle.NormalizeRad(endAngle);
|
|
||||||
|
|
||||||
if (Angle.IsBetweenRad(Angle.HalfPI, startAngle, endAngle))
|
|
||||||
maxY1 = centerpt.Y + radius;
|
|
||||||
|
|
||||||
if (Angle.IsBetweenRad(System.Math.PI, startAngle, endAngle))
|
|
||||||
minX1 = centerpt.X - radius;
|
|
||||||
|
|
||||||
const double oneHalfPI = System.Math.PI * 1.5;
|
|
||||||
|
|
||||||
if (Angle.IsBetweenRad(oneHalfPI, startAngle, endAngle))
|
|
||||||
minY1 = centerpt.Y - radius;
|
|
||||||
|
|
||||||
if (Angle.IsBetweenRad(Angle.TwoPI, startAngle, endAngle))
|
|
||||||
maxX1 = centerpt.X + radius;
|
|
||||||
|
|
||||||
if (maxX1 > maxX)
|
|
||||||
maxX = maxX1;
|
|
||||||
|
|
||||||
if (minX1 < minX)
|
|
||||||
minX = minX1;
|
|
||||||
|
|
||||||
if (maxY1 > maxY)
|
|
||||||
maxY = maxY1;
|
|
||||||
|
|
||||||
if (minY1 < minY)
|
|
||||||
minY = minY1;
|
|
||||||
|
|
||||||
pos = endpt;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case CodeType.SubProgramCall:
|
case CodeType.SubProgramCall:
|
||||||
{
|
{
|
||||||
var subpgm = (SubProgramCall)code;
|
var subpgm = (SubProgramCall)code;
|
||||||
if (subpgm.Program == null)
|
if (subpgm.Program == null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Sub-program frame origin in this program's frame
|
||||||
|
// is frameOrigin + Offset, regardless of current pos.
|
||||||
|
pos = frameOrigin + subpgm.Offset;
|
||||||
|
if (!subpgm.Program.BoundingBox(ref pos, out var box))
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (box.Left < minX)
|
||||||
|
minX = box.Left;
|
||||||
|
|
||||||
|
if (box.Right > maxX)
|
||||||
|
maxX = box.Right;
|
||||||
|
|
||||||
|
if (box.Bottom < minY)
|
||||||
|
minY = box.Bottom;
|
||||||
|
|
||||||
|
if (box.Top > maxY)
|
||||||
|
maxY = box.Top;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
// Sub-program frame origin in this program's frame
|
|
||||||
// is frameOrigin + Offset, regardless of current pos.
|
|
||||||
pos = frameOrigin + subpgm.Offset;
|
|
||||||
if (!subpgm.Program.BoundingBox(ref pos, out var box))
|
|
||||||
break;
|
|
||||||
|
|
||||||
if (box.Left < minX)
|
|
||||||
minX = box.Left;
|
|
||||||
|
|
||||||
if (box.Right > maxX)
|
|
||||||
maxX = box.Right;
|
|
||||||
|
|
||||||
if (box.Bottom < minY)
|
|
||||||
minY = box.Bottom;
|
|
||||||
|
|
||||||
if (box.Top > maxY)
|
|
||||||
maxY = box.Top;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,6 +508,21 @@ namespace OpenNest.CNC
|
|||||||
return true;
|
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()
|
public object Clone()
|
||||||
{
|
{
|
||||||
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
|
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
|
||||||
@@ -519,8 +537,31 @@ namespace OpenNest.CNC
|
|||||||
foreach (var kvp in Variables)
|
foreach (var kvp in Variables)
|
||||||
pgm.Variables[kvp.Key] = kvp.Value;
|
pgm.Variables[kvp.Key] = kvp.Value;
|
||||||
|
|
||||||
|
// The copy owns its sub-programs: rotating it must never turn the source's holes.
|
||||||
|
// Calls that shared one sub-program keep sharing one copy.
|
||||||
|
Dictionary<Program, Program> subCopies = null;
|
||||||
|
|
||||||
|
Program CopyOf(Program sub)
|
||||||
|
{
|
||||||
|
subCopies ??= new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
|
if (!subCopies.TryGetValue(sub, out var copy))
|
||||||
|
{
|
||||||
|
copy = (Program)sub.Clone();
|
||||||
|
subCopies[sub] = copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var kvp in SubPrograms)
|
foreach (var kvp in SubPrograms)
|
||||||
pgm.SubPrograms[kvp.Key] = (Program)kvp.Value.Clone();
|
pgm.SubPrograms[kvp.Key] = CopyOf(kvp.Value);
|
||||||
|
|
||||||
|
foreach (var code in codes)
|
||||||
|
{
|
||||||
|
if (code is SubProgramCall call && call.Program != null)
|
||||||
|
call.BindProgram(CopyOf(call.Program));
|
||||||
|
}
|
||||||
|
|
||||||
return pgm;
|
return pgm;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A character range in generated G-code assigned by one highlighting rule.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Index">Zero-based UTF-16 index of the first colored character.</param>
|
||||||
|
/// <param name="Length">Number of UTF-16 characters to color.</param>
|
||||||
|
/// <param name="RuleIndex">
|
||||||
|
/// Zero-based rule index: 0 comments, 1 motion modes (G90/G91), 2 rapid moves (G00),
|
||||||
|
/// 3 linear moves (G01), 4 arcs (G02/G03).
|
||||||
|
/// </param>
|
||||||
|
public readonly record struct HighlightSpan(int Index, int Length, int RuleIndex);
|
||||||
|
|
||||||
|
/// <summary>Computes cosmetic G-code highlight spans without changing the generated text.</summary>
|
||||||
|
public static class ProgramHighlighting
|
||||||
|
{
|
||||||
|
private static readonly Regex[] Rules =
|
||||||
|
{
|
||||||
|
new(@"^;.*$", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||||
|
new(@"^G9[01]\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||||
|
new(@"^G00\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||||
|
new(@"^G01\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||||
|
new(@"^G0[23]\b", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materializes all matches in rule/application order (later rules overwrite earlier ones).
|
||||||
|
/// A timeout propagates before any result is published; no partial span list escapes.
|
||||||
|
/// The timeout is per regex match, not a deadline for the complete operation.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="RegexMatchTimeoutException">A rule exceeded its match budget.</exception>
|
||||||
|
public static IReadOnlyList<HighlightSpan> ComputeSpans(string text)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(text);
|
||||||
|
var spans = new List<HighlightSpan>();
|
||||||
|
for (var ruleIndex = 0; ruleIndex < Rules.Length; ruleIndex++)
|
||||||
|
{
|
||||||
|
// MatchCollection is lazy: enumerate every rule before returning any spans.
|
||||||
|
foreach (Match match in Rules[ruleIndex].Matches(text))
|
||||||
|
spans.Add(new HighlightSpan(match.Index, match.Length, ruleIndex));
|
||||||
|
}
|
||||||
|
return spans.AsReadOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,20 +7,44 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
public readonly record struct Segment(Vector From, Vector To);
|
public readonly record struct Segment(Vector From, Vector To);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates plate rapids in cutting order, advancing through all cutting
|
||||||
|
/// motions before connecting to the next part (including scrap cutoffs).
|
||||||
|
/// </summary>
|
||||||
|
public static List<Segment> Enumerate(IEnumerable<Part> parts)
|
||||||
|
{
|
||||||
|
var results = new List<Segment>();
|
||||||
|
var pos = Vector.Zero;
|
||||||
|
|
||||||
|
foreach (var part in parts)
|
||||||
|
pos = AppendProgram(part.Program, part.Location, pos, results);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
public static List<Segment> Enumerate(Program pgm, Vector basePos, Vector startPos)
|
public static List<Segment> Enumerate(Program pgm, Vector basePos, Vector startPos)
|
||||||
{
|
{
|
||||||
var results = new List<Segment>();
|
var results = new List<Segment>();
|
||||||
|
AppendProgram(pgm, basePos, startPos, results);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
// Draw the rapid from the previous tool position to the program's first
|
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
|
||||||
// pierce point. This also primes pos so the interior walk interprets
|
{
|
||||||
// Incremental deltas from the correct absolute location (basePos), which
|
// The head never travels to a display perimeter's tail.
|
||||||
// matters for raw pre-lead-in programs that are emitted Incremental.
|
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);
|
var firstPierce = FirstPiercePoint(pgm, basePos);
|
||||||
results.Add(new Segment(startPos, firstPierce));
|
results.Add(new Segment(startPos, firstPierce));
|
||||||
|
|
||||||
var pos = firstPierce;
|
var pos = basePos;
|
||||||
Walk(pgm, basePos, ref pos, skipFirst: true, results);
|
Walk(pgm, basePos, ref pos, skipFirst: StartsWithRapidOrCall(pgm), results);
|
||||||
return results;
|
// The last rapid ends at a pierce, not necessarily the final tool position.
|
||||||
|
return pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector FirstPiercePoint(Program pgm, Vector basePos)
|
private static Vector FirstPiercePoint(Program pgm, Vector basePos)
|
||||||
@@ -31,11 +55,23 @@ namespace OpenNest.CNC
|
|||||||
return FirstPiercePoint(call.Program, basePos + call.Offset);
|
return FirstPiercePoint(call.Program, basePos + call.Offset);
|
||||||
|
|
||||||
if (pgm[i] is Motion motion)
|
if (pgm[i] is Motion motion)
|
||||||
return motion.EndPoint + basePos;
|
return motion is RapidMove ? motion.EndPoint + basePos : basePos;
|
||||||
}
|
}
|
||||||
return 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(
|
private static void Walk(
|
||||||
Program pgm,
|
Program pgm,
|
||||||
Vector basePos,
|
Vector basePos,
|
||||||
@@ -61,7 +97,7 @@ namespace OpenNest.CNC
|
|||||||
results.Add(new Segment(pos, firstPierce));
|
results.Add(new Segment(pos, firstPierce));
|
||||||
|
|
||||||
var subPos = holeBase;
|
var subPos = holeBase;
|
||||||
Walk(program, holeBase, ref subPos, skipFirst: true, results);
|
Walk(program, holeBase, ref subPos, skipFirst: StartsWithRapidOrCall(program), results);
|
||||||
pos = subPos;
|
pos = subPos;
|
||||||
}
|
}
|
||||||
else if (code is Motion motion)
|
else if (code is Motion motion)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
|
||||||
@@ -79,12 +79,28 @@ namespace OpenNest.CNC
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a shallow copy.
|
/// Gets a shallow copy that references the same program. Copies the fields
|
||||||
|
/// directly: going through the setters would re-align (rotate) the shared program.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public ICode Clone()
|
public ICode Clone()
|
||||||
{
|
{
|
||||||
return new SubProgramCall(program, Rotation) { Id = Id, Offset = Offset };
|
return new SubProgramCall
|
||||||
|
{
|
||||||
|
program = program,
|
||||||
|
rotation = rotation,
|
||||||
|
Id = Id,
|
||||||
|
Offset = Offset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Points the call at <paramref name="copy"/>, a copy of its current program, without
|
||||||
|
/// re-aligning its rotation: the copy already has the geometry the call executes.
|
||||||
|
/// </summary>
|
||||||
|
internal void BindProgram(Program copy)
|
||||||
|
{
|
||||||
|
program = copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ namespace OpenNest.Collections
|
|||||||
public event EventHandler<ItemRemovedEventArgs<T>> ItemRemoved;
|
public event EventHandler<ItemRemovedEventArgs<T>> ItemRemoved;
|
||||||
public event EventHandler<ItemChangedEventArgs<T>> ItemChanged;
|
public event EventHandler<ItemChangedEventArgs<T>> ItemChanged;
|
||||||
|
|
||||||
|
/// <summary>Raised once after <see cref="Reorder"/> (or a cutting commit) installs a new order.</summary>
|
||||||
|
public event EventHandler ItemsReordered;
|
||||||
|
|
||||||
public ObservableList()
|
public ObservableList()
|
||||||
{
|
{
|
||||||
items = new List<T>();
|
items = new List<T>();
|
||||||
@@ -64,6 +67,68 @@ namespace OpenNest.Collections
|
|||||||
RemoveAt(i);
|
RemoveAt(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes only the order of the current items. <paramref name="order"/> must hold exactly
|
||||||
|
/// the current non-null items: the same references with the same multiplicity. No
|
||||||
|
/// ItemAdded/ItemRemoved is raised, so quantity accounting is untouched; ItemsReordered is
|
||||||
|
/// raised once after the new order is installed. Invalid input throws before any change.
|
||||||
|
/// </summary>
|
||||||
|
public void Reorder(IEnumerable<T> order)
|
||||||
|
{
|
||||||
|
SetOrder(ValidateReorder(order));
|
||||||
|
ItemsReordered?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal T[] ValidateReorder(IEnumerable<T> order)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(order);
|
||||||
|
var proposed = order.ToArray();
|
||||||
|
if (proposed.Length != items.Count)
|
||||||
|
throw new ArgumentException("A reorder must contain exactly the current items.", nameof(order));
|
||||||
|
var comparer = typeof(T).IsValueType
|
||||||
|
? EqualityComparer<T>.Default
|
||||||
|
: (IEqualityComparer<T>)(object)ReferenceEqualityComparer.Instance;
|
||||||
|
var counts = new Dictionary<T, int>(comparer);
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (item == null)
|
||||||
|
throw new InvalidOperationException("A list holding null items cannot be reordered.");
|
||||||
|
counts[item] = counts.TryGetValue(item, out var count) ? count + 1 : 1;
|
||||||
|
}
|
||||||
|
foreach (var item in proposed)
|
||||||
|
{
|
||||||
|
if (item == null || !counts.TryGetValue(item, out var count) || count == 0)
|
||||||
|
throw new ArgumentException("A reorder must contain exactly the current items.", nameof(order));
|
||||||
|
counts[item] = count - 1;
|
||||||
|
}
|
||||||
|
return proposed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validated order only; raises nothing so a commit can publish after a whole scope installs.
|
||||||
|
internal void SetOrder(T[] order)
|
||||||
|
{
|
||||||
|
items.Clear();
|
||||||
|
items.AddRange(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invokes every observer even if one throws, so a refresh failure cannot starve the rest.
|
||||||
|
internal void RaiseItemsReordered(ICollection<Exception> errors)
|
||||||
|
{
|
||||||
|
if (ItemsReordered == null)
|
||||||
|
return;
|
||||||
|
foreach (var handler in ItemsReordered.GetInvocationList())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
((EventHandler)handler)(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errors.Add(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public int IndexOf(T item)
|
public int IndexOf(T item)
|
||||||
{
|
{
|
||||||
return items.IndexOf(item);
|
return items.IndexOf(item);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
@@ -9,7 +9,24 @@ namespace OpenNest.Converters
|
|||||||
{
|
{
|
||||||
public static Program ToProgram(IList<Entity> geometry)
|
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)
|
if (shapes.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
@@ -34,6 +51,9 @@ namespace OpenNest.Converters
|
|||||||
|
|
||||||
var pgm = new Program();
|
var pgm = new Program();
|
||||||
|
|
||||||
|
foreach (var cut in breakCuts)
|
||||||
|
pgm.Merge(ToProgram(cut));
|
||||||
|
|
||||||
foreach (var shape in shapes)
|
foreach (var shape in shapes)
|
||||||
{
|
{
|
||||||
var subpgm = ToProgram(shape);
|
var subpgm = ToProgram(shape);
|
||||||
@@ -46,6 +66,10 @@ namespace OpenNest.Converters
|
|||||||
return pgm;
|
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)
|
public static Program ToProgram(Shape shape)
|
||||||
{
|
{
|
||||||
var pgm = new Program();
|
var pgm = new Program();
|
||||||
@@ -153,6 +177,15 @@ namespace OpenNest.Converters
|
|||||||
)
|
)
|
||||||
return LayerType.Scribe;
|
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;
|
return LayerType.Cut;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
@@ -207,6 +207,15 @@ namespace OpenNest.Converters
|
|||||||
case LayerType.Scribe:
|
case LayerType.Scribe:
|
||||||
return SpecialLayers.Scribe;
|
return SpecialLayers.Scribe;
|
||||||
|
|
||||||
|
case LayerType.Break:
|
||||||
|
return SpecialLayers.Break;
|
||||||
|
|
||||||
|
case LayerType.Tab:
|
||||||
|
return SpecialLayers.Tab;
|
||||||
|
|
||||||
|
case LayerType.DisplayPerimeter:
|
||||||
|
return SpecialLayers.DisplayPerimeter;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return new Layer(layer.ToString());
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+418
-18
@@ -21,6 +21,23 @@ namespace OpenNest
|
|||||||
public double? EndLimit { get; set; }
|
public double? EndLimit { get; set; }
|
||||||
public Drawing Drawing { get; private 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)
|
public CutOff(Vector position, CutOffAxis axis)
|
||||||
{
|
{
|
||||||
Position = position;
|
Position = position;
|
||||||
@@ -35,10 +52,201 @@ namespace OpenNest
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var segments = ComputeSegments(plate, settings, cache);
|
var segments = ComputeSegments(plate, settings, cache);
|
||||||
var program = BuildProgram(segments, settings);
|
var program = BuildProgram(segments, settings, LockedFarEdge(plate));
|
||||||
Drawing.Program = program;
|
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()
|
private string GetName()
|
||||||
{
|
{
|
||||||
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
|
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
|
||||||
@@ -53,6 +261,9 @@ namespace OpenNest
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var bounds = plate.BoundingBox(includeParts: false);
|
var bounds = plate.BoundingBox(includeParts: false);
|
||||||
|
// A head-locked overcut replaces the overtravel at the high edge when that is the far edge.
|
||||||
|
var lockedFarEdge = LockedFarEdge(plate);
|
||||||
|
var overtravel = lockedFarEdge is { High: true } ? 0 : settings.Overtravel;
|
||||||
|
|
||||||
double lineStart,
|
double lineStart,
|
||||||
lineEnd,
|
lineEnd,
|
||||||
@@ -62,15 +273,22 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
cutPosition = Position.X;
|
cutPosition = Position.X;
|
||||||
lineStart = StartLimit ?? bounds.Y;
|
lineStart = StartLimit ?? bounds.Y;
|
||||||
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + settings.Overtravel);
|
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + overtravel);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
cutPosition = Position.Y;
|
cutPosition = Position.Y;
|
||||||
lineStart = StartLimit ?? bounds.X;
|
lineStart = StartLimit ?? bounds.X;
|
||||||
lineEnd = EndLimit ?? (bounds.X + bounds.Length + settings.Overtravel);
|
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An outlined sheet is cut only where the line crosses material: each crossing is a
|
||||||
|
// span of its own, started at the material's edge and overtravelled past the far
|
||||||
|
// one, within the user's limits.
|
||||||
|
var spans = plate.Outline == null
|
||||||
|
? new List<(double Start, double End)> { (lineStart, lineEnd) }
|
||||||
|
: MaterialSpans(plate, cutPosition, lineStart, lineEnd, settings.Overtravel, lockedFarEdge);
|
||||||
|
|
||||||
var exclusions = new List<(double Start, double End)>();
|
var exclusions = new List<(double Start, double End)>();
|
||||||
|
|
||||||
foreach (var part in plate.Parts)
|
foreach (var part in plate.Parts)
|
||||||
@@ -78,8 +296,21 @@ namespace OpenNest
|
|||||||
if (part.BaseDrawing.IsCutOff)
|
if (part.BaseDrawing.IsCutOff)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
Entity perimeter = null;
|
Entity perimeter;
|
||||||
cache?.TryGetValue(part, out 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(
|
var partExclusions = GetPartExclusions(
|
||||||
part,
|
part,
|
||||||
perimeter,
|
perimeter,
|
||||||
@@ -102,27 +333,71 @@ namespace OpenNest
|
|||||||
}
|
}
|
||||||
|
|
||||||
var segments = new List<(double Start, double End)>();
|
var segments = new List<(double Start, double End)>();
|
||||||
var current = lineStart;
|
foreach (var (spanStart, spanEnd) in spans)
|
||||||
|
|
||||||
foreach (var ex in merged)
|
|
||||||
{
|
{
|
||||||
var clampedStart = System.Math.Max(ex.Start, lineStart);
|
var current = spanStart;
|
||||||
var clampedEnd = System.Math.Min(ex.End, lineEnd);
|
|
||||||
|
|
||||||
if (clampedStart > current)
|
foreach (var ex in merged)
|
||||||
segments.Add((current, clampedStart));
|
{
|
||||||
|
var clampedStart = System.Math.Max(ex.Start, spanStart);
|
||||||
|
var clampedEnd = System.Math.Min(ex.End, spanEnd);
|
||||||
|
|
||||||
current = System.Math.Max(current, clampedEnd);
|
if (clampedStart > current)
|
||||||
|
segments.Add((current, clampedStart));
|
||||||
|
|
||||||
|
current = System.Math.Max(current, clampedEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current < spanEnd)
|
||||||
|
segments.Add((current, spanEnd));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current < lineEnd)
|
|
||||||
segments.Add((current, lineEnd));
|
|
||||||
|
|
||||||
segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList();
|
segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList();
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the line at <paramref name="cutPosition"/> crosses the sheet's outlined material,
|
||||||
|
/// in ascending order along the axis and clipped to the line's limits. Each span starts at
|
||||||
|
/// the material edge and runs <paramref name="overtravel"/> past the far one unless a limit
|
||||||
|
/// or a head-locked far edge ends it there.
|
||||||
|
/// </summary>
|
||||||
|
private List<(double Start, double End)> MaterialSpans(Plate plate, double cutPosition,
|
||||||
|
double lineStart, double lineEnd, double overtravel, (double Edge, bool High)? lockedFarEdge)
|
||||||
|
{
|
||||||
|
var vertical = Axis == CutOffAxis.Vertical;
|
||||||
|
var crossings = new List<double>();
|
||||||
|
foreach (var path in plate.Outline.Material(plate.Outline.PlacementOrigin(plate.Quadrant)))
|
||||||
|
{
|
||||||
|
for (var i = 0; i < path.Count; i++)
|
||||||
|
{
|
||||||
|
var a = path[i];
|
||||||
|
var b = path[(i + 1) % path.Count];
|
||||||
|
var (aAcross, aAlong) = vertical ? (a.x, a.y) : (a.y, a.x);
|
||||||
|
var (bAcross, bAlong) = vertical ? (b.x, b.y) : (b.y, b.x);
|
||||||
|
// Half-open on the across axis, so a line through a vertex counts one edge.
|
||||||
|
if (aAcross <= cutPosition == bAcross <= cutPosition)
|
||||||
|
continue;
|
||||||
|
crossings.Add(aAlong + (bAlong - aAlong) * (cutPosition - aAcross) / (bAcross - aAcross));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crossings.Sort();
|
||||||
|
|
||||||
|
var spans = new List<(double Start, double End)>();
|
||||||
|
for (var i = 0; i + 1 < crossings.Count; i += 2)
|
||||||
|
{
|
||||||
|
var start = System.Math.Max(crossings[i], lineStart);
|
||||||
|
var end = crossings[i + 1];
|
||||||
|
var limited = EndLimit != null && end >= EndLimit.Value
|
||||||
|
|| lockedFarEdge is var (edge, high) && high && end == edge;
|
||||||
|
end = System.Math.Min(limited ? end : end + overtravel, lineEnd);
|
||||||
|
if (end > start)
|
||||||
|
spans.Add((start, end));
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly List<(double Start, double End)> EmptyExclusions = new();
|
private static readonly List<(double Start, double End)> EmptyExclusions = new();
|
||||||
|
|
||||||
private List<(double Start, double End)> GetPartExclusions(
|
private List<(double Start, double End)> GetPartExclusions(
|
||||||
@@ -134,15 +409,38 @@ namespace OpenNest
|
|||||||
double clearance
|
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 (partMin, partMax) = AxisBounds(bb, clearance);
|
||||||
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
|
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
|
||||||
|
// Match the planner's representational allowance in BOTH pruning and
|
||||||
|
// fallback exclusions. Tolerating it only in validation could cut an edge.
|
||||||
|
partMin -= roundoff;
|
||||||
|
partMax += roundoff;
|
||||||
|
partStart -= roundoff;
|
||||||
|
partEnd += roundoff;
|
||||||
|
|
||||||
if (cutPosition < partMin || cutPosition > partMax)
|
if (cutPosition < partMin || cutPosition > partMax)
|
||||||
return EmptyExclusions;
|
return EmptyExclusions;
|
||||||
|
|
||||||
if (perimeter != null)
|
if (perimeter != null)
|
||||||
{
|
{
|
||||||
|
if (cutPosition <= cachedMin + roundoff || cutPosition >= cachedMax - roundoff)
|
||||||
|
return new List<(double Start, double End)> { (partStart, partEnd) };
|
||||||
var perimeterExclusions = IntersectPerimeter(
|
var perimeterExclusions = IntersectPerimeter(
|
||||||
perimeter,
|
perimeter,
|
||||||
cutPosition,
|
cutPosition,
|
||||||
@@ -157,6 +455,34 @@ namespace OpenNest
|
|||||||
return new List<(double Start, double End)> { (partStart, partEnd) };
|
return new List<(double Start, double End)> { (partStart, partEnd) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bounds reconstructed as (local minimum + placement) + size can differ
|
||||||
|
/// from translated material endpoints by a few floating-point steps. This
|
||||||
|
/// is not a geometry tolerance: cap it well below epsilon so inconsistent
|
||||||
|
/// arcs still require repair. Do not change shared Part bounds or programs.
|
||||||
|
/// </summary>
|
||||||
|
internal static double GetBoundsRoundoff(Part part)
|
||||||
|
{
|
||||||
|
var bb = part.BoundingBox;
|
||||||
|
var magnitude = System.Math.Max(System.Math.Abs(bb.Left), System.Math.Abs(bb.Right));
|
||||||
|
magnitude = System.Math.Max(magnitude,
|
||||||
|
System.Math.Max(System.Math.Abs(bb.Bottom), System.Math.Abs(bb.Top)));
|
||||||
|
magnitude = System.Math.Max(magnitude, System.Math.Max(bb.Length, bb.Width));
|
||||||
|
magnitude = System.Math.Max(magnitude,
|
||||||
|
System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y)));
|
||||||
|
var step = System.Math.BitIncrement(magnitude) - magnitude;
|
||||||
|
// 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(
|
private List<(double Start, double End)> IntersectPerimeter(
|
||||||
Entity perimeter,
|
Entity perimeter,
|
||||||
double cutPosition,
|
double cutPosition,
|
||||||
@@ -166,6 +492,9 @@ namespace OpenNest
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var offset = OffsetOutward(perimeter, clearance);
|
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 usedOffset = offset != null;
|
||||||
var targets = offset ?? new List<Entity> { perimeter };
|
var targets = offset ?? new List<Entity> { perimeter };
|
||||||
var cutLine = new Line(
|
var cutLine = new Line(
|
||||||
@@ -191,6 +520,15 @@ namespace OpenNest
|
|||||||
if (coords.Count % 2 != 0)
|
if (coords.Count % 2 != 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
// Intersects reports both incident edges at a shared vertex. Such hits may be
|
||||||
|
// crossings or tangencies, so neither pairing nor deduplicating them preserves
|
||||||
|
// inside/outside parity. Fall back to the clearance-expanded part bounds.
|
||||||
|
for (var i = 1; i < coords.Count; i++)
|
||||||
|
{
|
||||||
|
if (coords[i] - coords[i - 1] <= Math.Tolerance.Epsilon)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var padding = usedOffset ? 0 : clearance;
|
var padding = usedOffset ? 0 : clearance;
|
||||||
var result = new List<(double Start, double End)>();
|
var result = new List<(double Start, double End)>();
|
||||||
for (var i = 0; i < coords.Count; i += 2)
|
for (var i = 0; i < coords.Count; i += 2)
|
||||||
@@ -199,6 +537,51 @@ namespace OpenNest
|
|||||||
return result;
|
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>
|
/// <summary>
|
||||||
/// Grows the perimeter by the clearance as one Clipper region offset, so slots
|
/// 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
|
/// narrower than twice the clearance close up instead of leaving a gap the cut
|
||||||
@@ -249,7 +632,8 @@ namespace OpenNest
|
|||||||
|
|
||||||
private Program BuildProgram(
|
private Program BuildProgram(
|
||||||
List<(double Start, double End)> segments,
|
List<(double Start, double End)> segments,
|
||||||
CutOffSettings settings
|
CutOffSettings settings,
|
||||||
|
(double Edge, bool High)? farEdge
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var program = new Program();
|
var program = new Program();
|
||||||
@@ -266,6 +650,22 @@ namespace OpenNest
|
|||||||
|
|
||||||
foreach (var seg in segments)
|
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);
|
var (from, to) = toward ? (seg.End, seg.Start) : (seg.Start, seg.End);
|
||||||
program.Codes.Add(new RapidMove(MakePoint(cutPos, from)));
|
program.Codes.Add(new RapidMove(MakePoint(cutPos, from)));
|
||||||
program.Codes.Add(new LinearMove(MakePoint(cutPos, to)));
|
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 Overtravel { get; set; }
|
||||||
public double MinSegmentLength { get; set; } = 0.05;
|
public double MinSegmentLength { get; set; } = 0.05;
|
||||||
public CutDirection CutDirection { get; set; } = CutDirection.AwayFromOrigin;
|
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;
|
using System;
|
||||||
|
|
||||||
namespace OpenNest.Api;
|
namespace OpenNest;
|
||||||
|
|
||||||
public class CutParameters
|
public class CutParameters
|
||||||
{
|
{
|
||||||
@@ -11,6 +11,18 @@ public class CutParameters
|
|||||||
public string PostProcessor { get; set; }
|
public string PostProcessor { get; set; }
|
||||||
public Units Units { 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 =>
|
public static CutParameters Default =>
|
||||||
new()
|
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,80 @@
|
|||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
public enum OverlapAutoCheckStep { None, Wait, Check }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UI-thread debounce policy for automatic overlap rechecks; the host owns the timer.
|
||||||
|
/// Restart the quiet-period timer whenever <see cref="Observe"/> returns true, and call
|
||||||
|
/// <see cref="Elapsed"/> when it fires. A check starts only after the ordered layout stamp
|
||||||
|
/// has stayed unchanged for a whole quiet period with no interaction in progress.
|
||||||
|
/// A canceled or failed request is not retried until the layout differs from the one it
|
||||||
|
/// started from, so cancellation is respected and a failing layout cannot loop.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OverlapAutoCheckScheduler
|
||||||
|
{
|
||||||
|
private OverlapGeometryStamp pending;
|
||||||
|
private OverlapGeometryStamp lastRequest;
|
||||||
|
|
||||||
|
public bool IsWaiting => pending != null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Call after the report state's freshness check. True means (re)start the quiet-period
|
||||||
|
/// timer; false means leave it as it is.
|
||||||
|
/// </summary>
|
||||||
|
public bool Observe(Plate plate, OverlapReportState state)
|
||||||
|
{
|
||||||
|
if (plate == null || !NeedsCheck(plate, state))
|
||||||
|
{
|
||||||
|
pending = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (pending != null && pending.Matches(plate))
|
||||||
|
return false;
|
||||||
|
pending = OverlapGeometryStamp.Capture(plate);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Call when the quiet period ends. <see cref="OverlapAutoCheckStep.Wait"/> means restart the
|
||||||
|
/// timer (the layout moved or an interaction is still running); only
|
||||||
|
/// <see cref="OverlapAutoCheckStep.Check"/> starts a request.
|
||||||
|
/// </summary>
|
||||||
|
public OverlapAutoCheckStep Elapsed(Plate plate, OverlapReportState state, bool interactionActive)
|
||||||
|
{
|
||||||
|
if (pending == null)
|
||||||
|
return OverlapAutoCheckStep.None;
|
||||||
|
if (plate == null || !NeedsCheck(plate, state))
|
||||||
|
{
|
||||||
|
pending = null;
|
||||||
|
return OverlapAutoCheckStep.None;
|
||||||
|
}
|
||||||
|
if (interactionActive || !pending.Matches(plate))
|
||||||
|
{
|
||||||
|
pending = OverlapGeometryStamp.Capture(plate);
|
||||||
|
return OverlapAutoCheckStep.Wait;
|
||||||
|
}
|
||||||
|
pending = null;
|
||||||
|
return OverlapAutoCheckStep.Check;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Record every request start, manual or automatic.</summary>
|
||||||
|
public void Started(Plate plate)
|
||||||
|
{
|
||||||
|
pending = null;
|
||||||
|
lastRequest = OverlapGeometryStamp.Capture(plate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forget pending and previous requests (plate switch, handle loss, disable).</summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
pending = null;
|
||||||
|
lastRequest = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool NeedsCheck(Plate plate, OverlapReportState state) => state.Status switch
|
||||||
|
{
|
||||||
|
OverlapCheckStatus.NotChecked or OverlapCheckStatus.Stale => true,
|
||||||
|
OverlapCheckStatus.Canceled or OverlapCheckStatus.Failed => lastRequest?.Matches(plate) != true,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 1335 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user