mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 03:51:05 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,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,112 @@
|
||||
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.Engine.Tests
|
||||
- OpenNest.IO.Tests
|
||||
- OpenNest.Server.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 run here too, alongside the desktop.
|
||||
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,287 @@
|
||||
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 == 'release' && github.event.release.prerelease == false) ||
|
||||
(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:
|
||||
windows:
|
||||
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:
|
||||
run:
|
||||
shell: pwsh
|
||||
@@ -47,13 +49,30 @@ jobs:
|
||||
run: |
|
||||
dotnet build OpenNest.sln -c Release
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
||||
- name: Run all test projects on Windows
|
||||
- name: Install pinned Poppler for PDF report tests
|
||||
run: |
|
||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests')) {
|
||||
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||
$zip = Join-Path $env:RUNNER_TEMP 'poppler-26.09.0.zip'
|
||||
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.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." }
|
||||
}
|
||||
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.' }
|
||||
- name: Publish and verify Windows package
|
||||
run: ./scripts/Publish-Windows.ps1 -Version $env:RELEASE_VERSION
|
||||
@@ -85,6 +104,6 @@ jobs:
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: windows-test-results
|
||||
path: TestResults/*.trx
|
||||
path: TestResults/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
+9
-1
@@ -207,13 +207,21 @@ FakesAssemblies/
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# Claude Code
|
||||
# Local agent state and temporary planning/progress documents
|
||||
.claude/
|
||||
/.hermes/plans/
|
||||
/.hermes/progress/
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
/docs/*-plan.md
|
||||
/docs/*-progress.md
|
||||
|
||||
# Launch settings
|
||||
**/Properties/launchSettings.json
|
||||
|
||||
# Local test config (contains user-specific paths to proprietary test assets)
|
||||
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,69 @@
|
||||
# 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/`.
|
||||
- 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:
|
||||
|
||||
```bash
|
||||
```sh
|
||||
# Full solution: Windows
|
||||
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.Server.Tests/OpenNest.Server.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.
|
||||
After changing planning, contour preparation or geometry checks, run the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); no plate may rank below its baseline. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
|
||||
|
||||
`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`. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory.
|
||||
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection. Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
|
||||
|
||||
### 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 and the pre-post part-spacing review share Core's `SpacingOutline` for candidates; the review then confirms each one on finely flattened native contours, so it never warns where Auto Nest's check would pass. Do not loosen tolerances to hide failures.
|
||||
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
|
||||
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
|
||||
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
|
||||
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
|
||||
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
|
||||
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
|
||||
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
|
||||
|
||||
### 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.
|
||||
|
||||
## Architecture
|
||||
|
||||
Nine projects form a layered architecture:
|
||||
|
||||
### OpenNest.Core (class library)
|
||||
Domain model, geometry, and CNC primitives organized into namespaces:
|
||||
|
||||
- **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
|
||||
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) and an optional `Variables` dictionary of `VariableDefinition` entries. Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. `VariableDefinition` stores a named variable's expression, resolved value, and flags (`Inline`, `Global`). `ProgramVariableManager` manages numbered machine variables for post-processor output.
|
||||
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon` (convex NFP only), `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, `ClipperBridge` (Clipper2 region offsetting for CPU preparation only; see Key Patterns), and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction; deliberately hand-rolled as the reference for a future GPU kernel, with the port contract in its class summary).
|
||||
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
|
||||
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `ExpressionEvaluator` (arithmetic expression parser for G-code variable expressions with `$name` references). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
|
||||
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
|
||||
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
|
||||
- **CutOffs** (`namespace OpenNest`): `CutOff` (axis-aligned cut line with position, axis, optional start/end limits), `CutOffAxis` enum (`Horizontal`, `Vertical`), `CutOffSettings` (clearance, overtravel, min segment length, direction), `CutDirection` enum (`TowardOrigin`, `AwayFromOrigin`). Cut-offs generate CNC `Program` objects with trimmed line segments that avoid parts.
|
||||
- **Splitting** (`Splitting/`, `namespace OpenNest`): `DrawingSplitter` splits a Drawing into multiple pieces along split lines. `ISplitFeature` strategy pattern with implementations: `StraightSplit` (clean edge), `WeldGapTabSplit` (rectangular tab spacers on one side), `SpikeGrooveSplit` (interlocking spike/V-groove pairs). `AutoSplitCalculator` computes split lines for fit-to-plate and split-by-count modes. Supporting types: `SplitLine`, `SplitParameters`, `SplitFeatureResult`.
|
||||
- **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning.
|
||||
|
||||
### OpenNest.Engine (class library, depends on Core)
|
||||
Nesting algorithms use the jobs-only API. `INestingEngine.Solve(NestJob)` returns `NestJobResult`; `NestJobRunner` alone commits demand and finite/unlimited stock accounting; `IPlateNester` only proposes a one-sheet candidate; and `PlateNesterFactory` resolves a named built-in placement strategy.
|
||||
|
||||
- **Whole-job API (`Jobs/`)**: `NestJob` owns part requirements, physical stock, and options for one material/thickness/unit system. `PartGeometrySnapshot` contains owned flat rapid/line/arc geometry; results contain stock IDs and placement poses (radians), not mutable desktop models. `NestJobPlacementValidator` validates contours, rotation, usable work area, overlap, and spacing before accounting commits. The runner selects valid trial candidates greedily by priority vector, sheet area, envelope, and input order; an incomplete result reports why but does not prove geometric impossibility. `DrawingJobMapper` and `NestResultMaterializer` are the domain-boundary adapters.
|
||||
- **Placement boundary (`Jobs/Placement/`, `Jobs/Adapters/`)**: `DefaultPlateNester`, `StripPlateNester`, and `RemnantPlateNester` are built-ins with run-scoped private geometry. `PlateFillService` is the public single-plate proposal service for interactive fill/group/pack flows; it returns parts without mutating caller-owned plates. Job-path identity is reference-based rather than drawing name; `PlateOptimizer` retains name-based helpers and remains outside the runner path.
|
||||
- **Filler pipeline (`Jobs/Placement/Fillers/`)**: internal `DefaultPlateFiller`, `StripPlateFiller`, and policy-backed `RemnantPlateFiller` implement the standard single-plate geometry pipeline. `Default` runs the Linear, Pairs, RectBestFit, and Extents phases; remnant variants preserve their distinct comparer, direction, trim-axis, and angle-ordering policies.
|
||||
- **Engine registration**: `NestingEngineRegistry` holds whole-job `INestingEngine` implementations including the four fixed strategies and `StockLadder`. It loads plug-ins that implement `INestingEngine` and have a public parameterless constructor. Plug-ins for the removed single-plate inheritance API are not binary compatible.
|
||||
- **Plugin engines**: independent `INestingEngine` plugins are class libraries that reference `OpenNest.Engine` and are built outside `OpenNest.sln`. The desktop app and `OpenNest.Benchmark` load them from an `Engines/` folder next to their build output (e.g. `OpenNest.Benchmark/bin/<Config>/net8.0/Engines/`). Do not add engine projects to this repo.
|
||||
- **IFillComparer**: Interface enabling filler-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), and `HorizontalRemnantComparer` (minimize Y-extent) are grouped into `FillPolicy` on `FillContext`.
|
||||
- **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`.
|
||||
- **Strategies/** (`namespace OpenNest.Engine.Strategies`): Pluggable fill strategy layer — `IFillStrategy` interface, `FillContext`, `FillStrategyRegistry` (auto-discovers strategies via reflection, supports plugin DLLs), `FillHelpers`. Built-in strategies: `LinearFillStrategy`, `PairsFillStrategy`, `RectBestFitStrategy`, `ExtentsFillStrategy`.
|
||||
- **BestFit/** (`namespace OpenNest.Engine.BestFit`): NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups.
|
||||
- **RectanglePacking/** (`namespace OpenNest.Engine.RectanglePacking`): `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
|
||||
- **CirclePacking/** (`namespace OpenNest.Engine.CirclePacking`): Alternative packing for circular parts.
|
||||
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features; `Extract(drawing, includeBitmask: false)` skips the 32x32 training bitmap for inference while scalars stay identical; the default overload keeps it for training), `BruteForceRunner` (full angle sweep for training data).
|
||||
- `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints.
|
||||
- `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback.
|
||||
|
||||
### OpenNest.IO (class library, depends on Core)
|
||||
File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
|
||||
|
||||
- `DxfImporter`/`DxfExporter` — DXF file import/export via ACadSharp.
|
||||
- `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format).
|
||||
- `ProgramReader` — G-code text parser.
|
||||
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types.
|
||||
- `CadImporter` — shared "DXF → Drawing" service used by the UI, console, MCP, API, and training projects. Two-stage API: `Import(path, options)` loads raw entities, runs bend detection, and returns a mutable `CadImportResult`; `BuildDrawing(result, visible, bends, quantity, customer, editedProgram)` produces a fully-populated `Drawing` with `Source.Offset`, `SourceEntities`, `SuppressedEntityIds`, and bends. `ImportDrawing(path, options)` composes both stages for headless callers.
|
||||
- `CadImportOptions`, `CadImportResult` — inputs and intermediate state for `CadImporter`.
|
||||
- `Bending/BendRepair` — conservative opt-in repair configured by `CadImportOptions.BendRepair`. Requires explicit inches/mm source units and an endpoint movement limit above 0.001 and at most 3.175 physical mm. Only unambiguous paired ETCH/SCRIBE ticks may move along the existing bend axis; cut geometry and unrelated marks must remain unchanged. Opt-in imports preserve source marks without blanket etch regeneration and expose per-bend outcomes in `CadImportResult.BendRepairReports`.
|
||||
|
||||
### OpenNest.Console (console app, depends on Core + Engine + IO)
|
||||
Command-line interface for batch nesting (`net8.0`). Supports DXF import, plate configuration, linear fill, and multi-drawing auto-nesting through the active engine's `Nest()` (`--autonest`). `--repair-bends-mm <limit> --cad-units inches|mm` opts newly imported DXFs into conservative bend repair and prints per-bend reports; it does not rescale coordinates or repair saved nests.
|
||||
|
||||
### OpenNest.Gpu (class library, depends on Core + Engine)
|
||||
GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` implements `IPairEvaluator`, `GpuSlideComputer` implements `ISlideComputer`, and `PartBitmap` handles rasterization. `GpuEvaluatorFactory` provides factory methods.
|
||||
|
||||
### OpenNest.Training (console app, depends on Core + Engine)
|
||||
Training data collection for ML angle prediction. `TrainingDatabase` stores per-angle nesting results in SQLite via EF Core for offline model training.
|
||||
|
||||
### OpenNest.Benchmark (console app, depends on Core + Engine + IO)
|
||||
Compares registered `INestingEngine` implementations against each other on real `.nest` files. Each engine solves the whole job — it owns its own multi-plate/size strategy rather than being handed one already-sized plate at a time. Fully generic — it never hardcodes drawing geometry, just reads whatever drawings/quantities/plate settings each input file already has.
|
||||
|
||||
- `JobLoader` builds `BenchmarkJob`s from a `.nest` file or a folder of them via `NestReader`, using every drawing with `Quantity.Required > 0`. `--sheet-sizes` can sweep a fixed list of plate sizes instead of each file's own.
|
||||
- `DxfManifestLoader` builds a `BenchmarkJob` from a JSON manifest (`sheetSizes`, `spacing`, `edgeSpacing`, `quadrant`, `parts[] { dxf, quantity, allowRotation }`) instead of a `.nest`, importing each DXF with `CadImporter.ImportDrawing`. DXF paths resolve relative to the manifest; sheet sizes are required (manifest or `--sheet-sizes`, which overrides). `allowRotation: false` locks rotation the same way `NestRunner` does. `JobLoader.Load` routes `*.json` inputs to it, and folder scans pick up `*.nest` plus `*.manifest.json` (plain `*.json` is ignored so `--output` reports are never read as manifests). Invalid manifests throw rather than being skipped.
|
||||
- `BenchmarkJob.BuildNestJob(maxPlates)` converts the job into a `NestJob`: one `NestJobPart` per requested drawing (via `DrawingJobMapper.FromDrawing`) and one `NestPlateStock` per candidate sheet size (unlimited quantity — the engine decides how many of each size it uses).
|
||||
- `BenchmarkRunner` fans the (job × engine) pairs out with `Parallel.ForEach` (`NoBuffering`, `MaxDegreeOfParallelism` from `--parallel`, CLI default 3, `Run`'s own default 1) and writes results by index so report order stays job-then-engine. Each solve builds its own `NestJob` snapshot and materialized drawings, so solves share no mutable drawing state. Concurrent solves compete for cores, so `Time(ms)` is only clean at `--parallel 1`. It calls each engine's `INestingEngine.Solve(NestJob)` once per job, under a wall-clock timeout so a runaway or hanging engine can't stall the whole benchmark run, then materializes the result back into legacy `Plate`/`Part` objects via `NestResultMaterializer` for scoring.
|
||||
- `NestValidator` checks the returned layout: every part inside `Plate.WorkArea()`, every pair at least `Plate.PartSpacing` apart (checked geometrically: each part's perimeter inflated and cutouts shrunk by the spacing, tested against the other part's raw material with holes subtracted, so part-in-part inside a cutout is legal; an X-sorted bounding-box sweep prunes distant pairs), and no drawing over its requested quantity. `ValidateAgainstJob` also checks the raw `NestJobResult`: every sheet must match a stock entry the job offered (size, spacing, edge spacing, quadrant; finite quantity not overdrawn), and every placement rotation must satisfy its part's `RotationPolicy.Allows`. An invalid, throwing, or timed-out run places nothing for scoring.
|
||||
- Ranking (`Report.Compare`): valid > invalid, fully placed > not, then lower `JobResult.Cost`, then fewer plates. Cost = salvage-credited sheet area (`StockLadderNestingEngine.EstimateNetArea` per plate, recomputed from job geometry) + `BenchmarkJob.UnplacedPartPenalty` (largest candidate sheet area) per unplaced part, so dropping hard parts never improves the score. The summary sums cost and areas across jobs (area-weighted, not a mean of per-job percentages). Without `--sheet-sizes`, `.nest` jobs only offer their original sizes, and the CLI warns that this hints engines. Numeric CLI and manifest sheet sizes parse with the invariant culture (`JobLoader.TryParseSheetSize`).
|
||||
- `--engines Name1,Name2` filters to specific registered engines (default: all); `--csv <path>` writes a flat per-job CSV alongside the console report. `--progress` passes each solve a `JobProgressLog`, which writes `[job/engine]` lines for start, finish, every `PlateCommitted`, and `EvaluatingCandidate` throttled to one line per 2 s.
|
||||
- `tools/PepNestExport` (outside the solution; references `PepLib.Core` from the sibling `PepApi.Core` repo) converts a PepApi year of PEP nests into `.nest` files that keep PEP's placements as the benchmark `Baseline`. PEP loop quirks: sub-loop calls continue the incremental position; lead-in/out, `DESTRUCT CUT` and non-cut moves must not reach the program as rapids (a program's bounding box counts rapid endpoints); contours may be broken by uncut micro-joint tabs (a rapid of up to 0.25 across the tab, at the seam or mid-contour, e.g. a cutout cut as two halves), which the export bridges only where the pieces chain into a closed loop; and one drawing can be placed through several loops with different origins.
|
||||
|
||||
### OpenNest.Mcp (console app, depends on Core + Engine + IO)
|
||||
MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`.
|
||||
|
||||
- **Tools/InputTools**: `load_nest`, `import_dxf`, `create_drawing` (built-in shapes or G-code).
|
||||
- **Tools/SetupTools**: `create_plate`, `clear_plate`.
|
||||
- **Tools/NestingTools**: `fill_plate`, `fill_area`, `fill_remnants`, `pack_plate`.
|
||||
- **Tools/InspectionTools**: `get_plate_info`, `get_parts`, `check_overlaps`.
|
||||
- `NestSession` — in-memory state across tool calls (current Nest, standalone plates/drawings).
|
||||
|
||||
### OpenNest (WinForms WinExe, depends on Core + Engine + IO)
|
||||
The UI application with MDI interface.
|
||||
|
||||
- **Auto Nest engine routing**: when the selected engine is not a built-in fill strategy (`EngineSelection.IsFillStrategy` is false, i.e. StockLadder or an `Engines/` plug-in), `MainForm.RunJobEngineAsync` solves the whole job through `INestingEngine.Solve`. `JobEngineNest` builds the `NestJob` from the auto-nest items and either the plate options or the current plate, and it converts `NestJobProgress` for `NestProgressForm`: an engine's `LegacyProgress` passes through, and otherwise the stage and committed counts become the description. It then binds the result poses back onto the nest's own drawings. Whole-job engines throw on cancel, so the progress form hides Accept (`AllowAccept = false`) and Stop discards the run. Built-in strategies keep the existing per-plate fill path.
|
||||
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), `SplitDrawingForm` (split oversized drawings into smaller pieces, launched from CadConverterForm), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
|
||||
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
|
||||
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`, `ActionCutOff`.
|
||||
- **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime. 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).
|
||||
- [Nest file format](docs/nest-file-format.md)
|
||||
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
|
||||
- [Lead-in placement](docs/geometry/lead-in-placement.md) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
|
||||
- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material
|
||||
- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them
|
||||
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
|
||||
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
|
||||
- [Pre-post verification](docs/post-verification.md)
|
||||
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
|
||||
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
|
||||
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
|
||||
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`.
|
||||
@@ -16,6 +16,9 @@ public class NestRequest
|
||||
|
||||
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
|
||||
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 double Thickness { get; init; } = 0.06;
|
||||
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>
|
||||
public class NestRequestPlate
|
||||
{
|
||||
/// <summary>Positive per-sheet cost in common units; omit on every row for area scoring.</summary>
|
||||
public double? Cost { get; init; }
|
||||
|
||||
public string Id { get; init; }
|
||||
public Size Size { get; init; }
|
||||
|
||||
|
||||
@@ -5,26 +5,35 @@ using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
/// <summary>Stable fulfillment metadata for one requested part identity.</summary>
|
||||
public sealed record NestPartFulfillment(string PartId, int Requested, int Placed, int Unplaced);
|
||||
|
||||
/// <summary>Copies of one requested part identity the proposal leaves unplaced, and why.</summary>
|
||||
public sealed record NestUnplacedRequestPart(string PartId, int Count, NestUnplacedReason Reason);
|
||||
|
||||
/// <summary>Physical-sheet usage for one stock identity.</summary>
|
||||
public sealed record NestStockUsage(string StockId, int Used, int? Remaining);
|
||||
|
||||
/// <summary>Maps each materialized physical sheet to its source stock identity.</summary>
|
||||
/// <summary>Maps each materialized layout (Nest.Plates index) to its stock identity.
|
||||
/// Its Plate.Quantity gives the physical copies; schema 3 and older used one sheet per entry.</summary>
|
||||
public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
|
||||
|
||||
/// <summary>Independent geometry validation; null on old archives means not recorded.</summary>
|
||||
public enum NestValidationStatus { Valid, Invalid, Unrepresentable }
|
||||
|
||||
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>
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
/// <summary>Physical sheets: sum of Plate.Quantity, not materialized layout entries.</summary>
|
||||
public int SheetCount { get; init; }
|
||||
|
||||
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
|
||||
@@ -35,9 +44,16 @@ public class NestResponse
|
||||
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
|
||||
public NestJobStatus? Status { 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; } = [];
|
||||
/// <summary>Every unplaced copy by requested part and reason; empty when everything was
|
||||
/// placed. Archives before schema 5 did not record it and load empty.</summary>
|
||||
public IReadOnlyList<NestUnplacedRequestPart> Unplaced { get; init; } = [];
|
||||
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
|
||||
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||
public NestCostSummary Costs { get; init; }
|
||||
public Nest Nest { get; init; }
|
||||
public NestRequest Request { get; init; }
|
||||
|
||||
@@ -75,9 +91,13 @@ public class NestResponse
|
||||
ElapsedTicks = Elapsed.Ticks,
|
||||
Status = Status,
|
||||
StopReason = StopReason,
|
||||
ValidationStatus = ValidationStatus,
|
||||
Violations = Violations is null ? [] : new List<string>(Violations),
|
||||
Fulfillment = Fulfillment is null
|
||||
? []
|
||||
: new List<NestPartFulfillment>(Fulfillment),
|
||||
Unplaced = Unplaced is null ? [] : new List<NestUnplacedRequestPart>(Unplaced),
|
||||
Costs = Costs,
|
||||
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
|
||||
PlateStockMappings = PlateStockMappings is null
|
||||
? []
|
||||
@@ -89,7 +109,7 @@ public class NestResponse
|
||||
|
||||
var nestEntry = zip.CreateEntry("nest.nest");
|
||||
using var nestMs = new MemoryStream();
|
||||
new NestWriter(Nest).Write(nestMs);
|
||||
new NestWriter(Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(nestMs);
|
||||
nestMs.Position = 0;
|
||||
await using (var stream = nestEntry.Open())
|
||||
{
|
||||
@@ -146,7 +166,7 @@ public class NestResponse
|
||||
await stream.CopyToAsync(nestMs);
|
||||
}
|
||||
nestMs.Position = 0;
|
||||
nest = new NestReader(nestMs).Read();
|
||||
nest = new NestReader(nestMs) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
}
|
||||
|
||||
return new NestResponse
|
||||
@@ -158,9 +178,13 @@ public class NestResponse
|
||||
Elapsed = TimeSpan.FromTicks(archive.ElapsedTicks),
|
||||
Status = hasStatusMetadata ? archive.Status : null,
|
||||
StopReason = hasStatusMetadata ? archive.StopReason : null,
|
||||
ValidationStatus = archive.ValidationStatus,
|
||||
Violations = archive.Violations ?? [],
|
||||
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
|
||||
Unplaced = hasSchemaVersion && archive.SchemaVersion >= 5 ? archive.Unplaced ?? [] : [],
|
||||
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
|
||||
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
|
||||
Costs = archive.Costs,
|
||||
Nest = nest,
|
||||
Request = request,
|
||||
};
|
||||
@@ -168,6 +192,7 @@ public class NestResponse
|
||||
|
||||
private sealed class NestResponseArchiveDto
|
||||
{
|
||||
public NestCostSummary Costs { get; init; }
|
||||
public int SchemaVersion { get; init; }
|
||||
public int SheetCount { get; init; }
|
||||
public double Utilization { get; init; }
|
||||
@@ -175,7 +200,10 @@ public class NestResponse
|
||||
public long ElapsedTicks { get; init; }
|
||||
public NestJobStatus? Status { 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<NestUnplacedRequestPart> Unplaced { get; init; } = [];
|
||||
public List<NestStockUsage> StockUsage { get; init; } = [];
|
||||
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||
}
|
||||
|
||||
+80
-43
@@ -5,10 +5,9 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
@@ -29,10 +28,11 @@ public static class NestRunner
|
||||
if (requestParts.Count == 0)
|
||||
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
||||
|
||||
var stock = CreateStock(request);
|
||||
var sw = Stopwatch.StartNew();
|
||||
var parts = IdentifyParts(requestParts);
|
||||
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
var jobParts = new List<NestJobPart>(parts.Count);
|
||||
var items = new List<NestItem>(parts.Count);
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
@@ -68,23 +68,68 @@ public static class NestRunner
|
||||
importedByPath.Add(part.Request.DxfPath, drawing);
|
||||
}
|
||||
|
||||
ConfigureDrawingForRequirement(drawing, part.Request);
|
||||
jobParts.Add(DrawingJobMapper.FromDrawing(part.Id, drawing, part.Request.Quantity));
|
||||
// Each requirement keeps its own identity/constraints even when paths are shared.
|
||||
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(
|
||||
jobParts,
|
||||
CreateStock(request),
|
||||
new NestJobOptions(ResolvePlacementStrategy(request))
|
||||
);
|
||||
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
|
||||
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.
|
||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||
var nest = materialized.Nest;
|
||||
nest.Thickness = request.Thickness;
|
||||
nest.Material = new Material(request.Material);
|
||||
// API returns a detached proposal, not an acceptance/commit to a caller's nest.
|
||||
// Invalid but representable proposals retain every pose and carry explicit validation status.
|
||||
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
|
||||
if (request.Plates != null)
|
||||
nest.PlateOptions = stock.Where(s => s.Quantity != 0).Select(s => new PlateOption
|
||||
{ Width = s.Size.Width, Length = s.Size.Length, Cost = s.Cost ?? 0 }).ToList();
|
||||
foreach (var item in items)
|
||||
nest.Drawings.Add(item.Drawing);
|
||||
foreach (var proposed in result.Plates)
|
||||
{
|
||||
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 cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
|
||||
@@ -93,33 +138,21 @@ public static class NestRunner
|
||||
return Task.FromResult(
|
||||
new NestResponse
|
||||
{
|
||||
SheetCount = nest.Plates.Count,
|
||||
SheetCount = nest.Plates.Sum(p => p.Quantity),
|
||||
Utilization = CalculateUtilization(nest),
|
||||
CutTime = cutTime,
|
||||
Elapsed = sw.Elapsed,
|
||||
Status = result.Status,
|
||||
StopReason = result.StopReason,
|
||||
Fulfillment = result
|
||||
.Fulfillment.Select(value => new NestPartFulfillment(
|
||||
value.PartId,
|
||||
value.Requested,
|
||||
value.Placed,
|
||||
value.Unplaced
|
||||
))
|
||||
.ToArray(),
|
||||
StockUsage = result
|
||||
.StockUsage.Select(value => new NestStockUsage(
|
||||
value.StockId,
|
||||
value.Used,
|
||||
value.Remaining
|
||||
))
|
||||
.ToArray(),
|
||||
PlateStockMappings = result
|
||||
.Plates.Select(value => new NestPlateStockMapping(
|
||||
value.PlateIndex,
|
||||
value.StockId
|
||||
))
|
||||
.ToArray(),
|
||||
Status = complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
||||
StopReason = stopReason,
|
||||
ValidationStatus = !result.CanKeep ? NestValidationStatus.Unrepresentable
|
||||
: result.IsValid ? NestValidationStatus.Valid : NestValidationStatus.Invalid,
|
||||
Violations = result.Violations,
|
||||
Fulfillment = fulfillment,
|
||||
Unplaced = unplaced,
|
||||
StockUsage = usage,
|
||||
Costs = result.CanKeep && result.IsValid ? NestCostSummary.FromAccepted(result) : null,
|
||||
PlateStockMappings = result.Plates.Select((value, index) =>
|
||||
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
|
||||
Nest = nest,
|
||||
Request = request,
|
||||
}
|
||||
@@ -140,6 +173,8 @@ public static class NestRunner
|
||||
"Request parts must not contain null entries.",
|
||||
nameof(requestParts)
|
||||
);
|
||||
if (part.Quantity < 0)
|
||||
throw new ArgumentException("Part quantities must be nonnegative.", nameof(requestParts));
|
||||
var id = part.Id ?? $"part-{index}";
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
|
||||
@@ -181,11 +216,13 @@ public static class NestRunner
|
||||
plate.Quantity,
|
||||
plate.PartSpacing,
|
||||
plate.EdgeSpacing,
|
||||
plate.Quadrant
|
||||
plate.Quadrant,
|
||||
plate.Cost
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
NestJobValidator.ValidateStockCosts(stock);
|
||||
return stock;
|
||||
}
|
||||
|
||||
@@ -214,11 +251,11 @@ public static class NestRunner
|
||||
|
||||
private static double CalculateUtilization(Nest nest)
|
||||
{
|
||||
var sheetArea = nest.Plates.Sum(plate => plate.Area());
|
||||
var sheetArea = nest.Plates.Sum(plate => plate.Area() * plate.Quantity);
|
||||
if (sheetArea == 0)
|
||||
return 0;
|
||||
var placedArea = nest.Plates.Sum(plate =>
|
||||
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area)
|
||||
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area) * plate.Quantity
|
||||
);
|
||||
return placedArea / sheetArea;
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ namespace OpenNest.Benchmark
|
||||
parts,
|
||||
stock,
|
||||
new NestJobOptions(
|
||||
"Default",
|
||||
"Fill",
|
||||
maxPlates,
|
||||
salvageRate ?? SalvageRate,
|
||||
minimumSalvageDimension ?? 0
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
|
||||
@@ -290,7 +291,7 @@ namespace OpenNest.Benchmark
|
||||
// Manifest jobs have no source nest to copy from, so they keep the job's name.
|
||||
if (job.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var source = new OpenNest.IO.NestReader(job.SourceFile).Read();
|
||||
var source = new OpenNest.IO.NestReader(job.SourceFile) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
materialized.Nest.Name = source.Name;
|
||||
materialized.Nest.Units = source.Units;
|
||||
materialized.Nest.Material = source.Material;
|
||||
@@ -316,7 +317,7 @@ namespace OpenNest.Benchmark
|
||||
throw new InvalidOperationException(
|
||||
"Output must not overwrite the source nest."
|
||||
);
|
||||
new OpenNest.IO.NestWriter(materialized.Nest).Write(path);
|
||||
new OpenNest.IO.NestWriter(materialized.Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(path);
|
||||
var report = new
|
||||
{
|
||||
Source = job.SourceFile,
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
@@ -39,7 +40,7 @@ namespace OpenNest.Benchmark
|
||||
|
||||
try
|
||||
{
|
||||
nest = new NestReader(file).Read();
|
||||
nest = new NestReader(file) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+454
-129
@@ -5,11 +5,16 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using OpenNest;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
@@ -21,6 +26,33 @@ static class NestConsole
|
||||
{
|
||||
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);
|
||||
|
||||
if (options == null)
|
||||
@@ -48,14 +80,26 @@ static class NestConsole
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!options.AutoNest && (options.MaxSheets != 1 || options.Stock != null))
|
||||
{
|
||||
Console.Error.WriteLine("Error: --max-sheets and --stock require --autonest.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (options.MaxSheets < 1 || options.Stock is { Count: 0 })
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Error: --max-sheets needs a whole number >= 1 and --stock needs WxL[:cost] sizes separated by commas (for example 60x120,48x96:85)."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Validate --engine up front: autonest names a jobs engine, plain fill names a
|
||||
// single-plate placement strategy. Unknown names exit with the valid choices.
|
||||
if (options.AutoNest)
|
||||
{
|
||||
var isJobsEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
|
||||
e.Name.Equals(options.Engine, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
if (!isJobsEngine)
|
||||
// ResolveName also accepts renamed engines' old names (for example Opus55NestingEngine).
|
||||
if (NestingEngineRegistry.ResolveName(options.Engine) == null)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"Error: unknown engine '{options.Engine}'. Jobs engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
|
||||
@@ -65,19 +109,37 @@ static class NestConsole
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
PlateFillService.ResolveStrategy(options.Engine);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
var engine = EngineNameResolver.Classify(options.Engine);
|
||||
if (!engine.IsFillStrategy)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"Error: unknown engine '{options.Engine}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (jobs engines such as StockLadder require --autonest)"
|
||||
engine.IsJobEngine
|
||||
? $"Error: engine '{engine.Name}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
|
||||
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as StockLadder require --autonest)"
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.PlanCutting && (options.CuttingParamsFile != null || options.KeepPartOrder
|
||||
|| options.AcceptUnverifiedPlan || options.AllPlates))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Error: --cutting-params, --keep-part-order, --accept-unverified-plan and --all-plates require --plan-cutting."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null
|
||||
|| options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue
|
||||
|| options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size or --template."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (options.InputFiles.Count == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
@@ -93,36 +155,77 @@ static class NestConsole
|
||||
}
|
||||
}
|
||||
|
||||
// Read explicit settings before nesting, so a bad file fails without wasting the fill.
|
||||
CuttingParameters cuttingParameters = null;
|
||||
if (options.CuttingParamsFile != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
cuttingParameters = CuttingParametersSerializer.DeserializeExplicit(
|
||||
File.ReadAllText(options.CuttingParamsFile));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException
|
||||
or NotSupportedException)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: cannot read cutting settings '{options.CuttingParamsFile}': {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
var nest = LoadOrCreateNest(options);
|
||||
|
||||
if (nest == null)
|
||||
return 1;
|
||||
|
||||
var plate = nest.Plates[options.PlateIndex];
|
||||
IReadOnlyList<Plate> plates = new[] { plate };
|
||||
var overlapCount = 0;
|
||||
|
||||
ApplyTemplate(plate, options);
|
||||
ApplyOverrides(plate, options);
|
||||
if (options.NoFill)
|
||||
{
|
||||
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)
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -185,15 +288,29 @@ static class NestConsole
|
||||
case "--template" when i + 1 < args.Length:
|
||||
o.TemplateFile = args[++i];
|
||||
break;
|
||||
case "--allow-invalid":
|
||||
o.AllowInvalid = true;
|
||||
break;
|
||||
case "--autonest":
|
||||
o.AutoNest = true;
|
||||
break;
|
||||
case "--max-sheets" when i + 1 < args.Length:
|
||||
o.MaxSheets = int.TryParse(args[++i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var sheets)
|
||||
? sheets
|
||||
: 0;
|
||||
break;
|
||||
case "--stock" when i + 1 < args.Length:
|
||||
o.Stock = ParseStock(args[++i]);
|
||||
break;
|
||||
case "--engine" when i + 1 < args.Length:
|
||||
o.Engine = args[++i];
|
||||
break;
|
||||
case "--post" when i + 1 < args.Length:
|
||||
o.PostName = args[++i];
|
||||
break;
|
||||
case "--acknowledge-post-risks":
|
||||
o.AcknowledgePostRisks = true;
|
||||
break;
|
||||
case "--post-output" when i + 1 < args.Length:
|
||||
o.PostOutput = args[++i];
|
||||
break;
|
||||
@@ -203,6 +320,24 @@ static class NestConsole
|
||||
case "--list-posts":
|
||||
o.ListPosts = true;
|
||||
break;
|
||||
case "--no-fill":
|
||||
o.NoFill = true;
|
||||
break;
|
||||
case "--plan-cutting":
|
||||
o.PlanCutting = true;
|
||||
break;
|
||||
case "--cutting-params" when i + 1 < args.Length:
|
||||
o.CuttingParamsFile = args[++i];
|
||||
break;
|
||||
case "--keep-part-order":
|
||||
o.KeepPartOrder = true;
|
||||
break;
|
||||
case "--accept-unverified-plan":
|
||||
o.AcceptUnverifiedPlan = true;
|
||||
break;
|
||||
case "--all-plates":
|
||||
o.AllPlates = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
PrintUsage();
|
||||
@@ -217,23 +352,54 @@ static class NestConsole
|
||||
return o;
|
||||
}
|
||||
|
||||
/// <summary>Parses "WxL[:cost],..."; any malformed entry returns an empty list, which validation rejects.</summary>
|
||||
static List<PlateOption> ParseStock(string value)
|
||||
{
|
||||
var stock = new List<PlateOption>();
|
||||
foreach (var entry in value.Split(',', StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var parts = entry.Split(':', StringSplitOptions.TrimEntries);
|
||||
var cost = 0.0;
|
||||
if (
|
||||
parts.Length > 2
|
||||
|| !Size.TryParse(parts[0], out var size)
|
||||
|| !double.IsFinite(size.Width)
|
||||
|| !double.IsFinite(size.Length)
|
||||
|| size.Width <= 0
|
||||
|| size.Length <= 0
|
||||
|| (
|
||||
parts.Length == 2
|
||||
&& (
|
||||
!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out cost)
|
||||
|| !double.IsFinite(cost)
|
||||
|| cost <= 0
|
||||
)
|
||||
)
|
||||
)
|
||||
return new List<PlateOption>();
|
||||
|
||||
stock.Add(new PlateOption { Width = size.Width, Length = size.Length, Cost = cost });
|
||||
}
|
||||
|
||||
return stock;
|
||||
}
|
||||
|
||||
static bool IsCadFile(string path) =>
|
||||
path.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
static Nest LoadOrCreateNest(Options options)
|
||||
{
|
||||
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
||||
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
var dxfFiles = options
|
||||
.InputFiles.Where(f =>
|
||||
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||
|| f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
.ToList();
|
||||
var dxfFiles = options.InputFiles.Where(IsCadFile).ToList();
|
||||
|
||||
// If we have a nest file, load it and optionally add DXFs.
|
||||
if (nestFile != null)
|
||||
{
|
||||
var nest = new NestReader(nestFile).Read();
|
||||
var nest = new NestReader(nestFile) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
|
||||
if (nest.Plates.Count == 0)
|
||||
{
|
||||
@@ -278,9 +444,11 @@ static class NestConsole
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build the plate as the desktop does: Quantity = 1 so part counts and the
|
||||
// drawings' nested quantities include it.
|
||||
var newNest = new Nest { Name = "DXF Import" };
|
||||
var plate = new Plate { Size = options.PlateSize.Value };
|
||||
newNest.Plates.Add(plate);
|
||||
newNest.PlateDefaults.Size = options.PlateSize.Value;
|
||||
newNest.CreatePlate();
|
||||
|
||||
foreach (var dxf in dxfFiles)
|
||||
{
|
||||
@@ -337,7 +505,7 @@ static class NestConsole
|
||||
return;
|
||||
}
|
||||
|
||||
var templateNest = new NestReader(options.TemplateFile).Read();
|
||||
var templateNest = new NestReader(options.TemplateFile) { BestFitStore = BestFitCacheStore.Shared }.Read();
|
||||
var templatePlate = templateNest.PlateDefaults.CreateNew();
|
||||
plate.Quadrant = templatePlate.Quadrant;
|
||||
plate.EdgeSpacing = templatePlate.EdgeSpacing;
|
||||
@@ -394,7 +562,9 @@ static class NestConsole
|
||||
);
|
||||
var existingPartsMessage = options.KeepParts
|
||||
? $"Keeping {existingCount} existing parts"
|
||||
: $"Cleared {existingCount} existing parts";
|
||||
: options.AutoNest
|
||||
? $"Will replace {existingCount} existing parts only after acceptance"
|
||||
: $"Cleared {existingCount} existing parts";
|
||||
Console.WriteLine(
|
||||
$"""
|
||||
Drawing: {drawing.Name}
|
||||
@@ -404,15 +574,18 @@ static class NestConsole
|
||||
);
|
||||
}
|
||||
|
||||
static (bool success, long elapsedMs) Fill(
|
||||
static (bool success, long elapsedMs, bool accepted, IReadOnlyList<Plate> plates) Fill(
|
||||
Nest nest,
|
||||
Plate plate,
|
||||
Drawing drawing,
|
||||
Options options
|
||||
Options options,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
bool success;
|
||||
var accepted = true;
|
||||
IReadOnlyList<Plate> plates = new[] { plate };
|
||||
|
||||
if (options.AutoNest)
|
||||
{
|
||||
@@ -433,13 +606,17 @@ static class NestConsole
|
||||
$"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
|
||||
{
|
||||
// Single-plate fill: explicit placement strategy through the public service;
|
||||
// the process-global engine registry is never consulted.
|
||||
var strategy = ResolveFillStrategy(options.Engine);
|
||||
var strategy = EngineNameResolver.Classify(options.Engine).FillStrategy;
|
||||
var item = new NestItem { Drawing = drawing, Quantity = options.Quantity };
|
||||
var parts = PlateFillService.FillItem(
|
||||
strategy,
|
||||
@@ -447,8 +624,9 @@ static class NestConsole
|
||||
item,
|
||||
plate.WorkArea(),
|
||||
null,
|
||||
CancellationToken.None
|
||||
token
|
||||
);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (parts.Count > 0)
|
||||
plate.Parts.AddRange(parts);
|
||||
@@ -456,119 +634,216 @@ static class NestConsole
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
return (success, sw.ElapsedMilliseconds);
|
||||
return (success, sw.ElapsedMilliseconds, accepted, plates);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Solves the drawings as one whole job against this single plate using the named jobs
|
||||
/// engine, then commits the returned placements onto the plate. Placements are mapped back
|
||||
/// onto the caller's original drawings (same pose semantics as NestResultMaterializer), so
|
||||
/// the saved nest keeps its existing drawing identities.
|
||||
/// </summary>
|
||||
static bool AutoNestJob(Plate plate, List<NestItem> nestItems, string engineName)
|
||||
static (bool accepted, int committed, IReadOnlyList<Plate> plates) AutoNestJob(
|
||||
Nest nest, Plate plate, List<NestItem> nestItems, Options options, CancellationToken token)
|
||||
{
|
||||
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 drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < nestItems.Count; i++)
|
||||
var tooManySheets = result.Raw.Plates.Count > options.MaxSheets;
|
||||
if (!result.CanKeep || tooManySheets || (!result.IsValid && !options.AllowInvalid))
|
||||
{
|
||||
var partId = $"part-{i}";
|
||||
parts.Add(DrawingJobMapper.FromItem(partId, nestItems[i]));
|
||||
drawingsByPartId[partId] = nestItems[i].Drawing;
|
||||
Console.Error.WriteLine(tooManySheets
|
||||
? $"Error: result uses {result.Raw.Plates.Count} sheets, more than --max-sheets {options.MaxSheets}, even with --allow-invalid. Nothing saved."
|
||||
: !result.CanKeep
|
||||
? "Error: result cannot be represented faithfully, even with --allow-invalid. Nothing saved."
|
||||
: "Error: invalid result discarded. Use --allow-invalid to explicitly keep its violations. Nothing saved.");
|
||||
return rejected;
|
||||
}
|
||||
|
||||
// One physical sheet: this plate, this solve — the runner owns stock accounting.
|
||||
var stock = DrawingJobMapper.FromPlate("plate-0", plate, 1);
|
||||
var job = new NestJob(parts, [stock]);
|
||||
|
||||
var result = engine.Solve(job, null, CancellationToken.None);
|
||||
|
||||
var committed = 0;
|
||||
foreach (var plateResult in result.Plates)
|
||||
token.ThrowIfCancellationRequested();
|
||||
var committed = result.Plates.Sum(p => p.Parts.Count * p.Quantity);
|
||||
if (committed == 0)
|
||||
{
|
||||
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))
|
||||
continue;
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(pose.Rotation);
|
||||
part.Location = new Vector(pose.X, pose.Y);
|
||||
part.UpdateBounds();
|
||||
plate.Parts.Add(part);
|
||||
committed++;
|
||||
targetUsed = true;
|
||||
plate.Parts.Clear();
|
||||
return plate;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"Engine: {engineName} — committed {committed} placements");
|
||||
return committed > 0;
|
||||
var created = new Plate(plate.Size)
|
||||
{
|
||||
GrainAngle = plate.GrainAngle,
|
||||
CuttingParameters = plate.CuttingParameters,
|
||||
};
|
||||
nest.Plates.Insert(insertAt++, created);
|
||||
return created;
|
||||
}, options.AllowInvalid, token);
|
||||
|
||||
Console.WriteLine($"Engine: {options.Engine} — committed {committed} placements on {applied.Sum(p => p.Quantity)} sheet(s)");
|
||||
if (costs != null)
|
||||
Console.WriteLine($"Cost ({costs.Basis}): gross {costs.GrossTotal:F2}, salvage credit {costs.SalvageCredit:F2}, net {costs.NetScore:F2}");
|
||||
return (true, committed, applied);
|
||||
}
|
||||
|
||||
static string ResolveFillStrategy(string engineName)
|
||||
static int CheckOverlaps(Nest nest, IReadOnlyList<Plate> plates, Options options)
|
||||
{
|
||||
try
|
||||
{
|
||||
return PlateFillService.ResolveStrategy(engineName);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
var isJobEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
|
||||
e.Name.Equals(engineName, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
isJobEngine
|
||||
? $"Error: engine '{engineName}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
|
||||
: $"Error: unknown engine '{engineName}'. Engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
|
||||
);
|
||||
Environment.Exit(1);
|
||||
throw; // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
static int CheckOverlaps(Plate plate, Options options)
|
||||
{
|
||||
if (!options.CheckOverlaps || plate.Parts.Count == 0)
|
||||
if (!options.CheckOverlaps)
|
||||
return 0;
|
||||
|
||||
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
|
||||
Console.WriteLine(
|
||||
hasOverlaps
|
||||
? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
|
||||
: "Overlap check: PASS"
|
||||
);
|
||||
var total = 0;
|
||||
foreach (var plate in plates.Where(p => p.Parts.Count > 0))
|
||||
{
|
||||
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
|
||||
var label = plates.Count > 1 ? $"Plate {nest.Plates.IndexOf(plate)}: " : "";
|
||||
Console.WriteLine(
|
||||
hasOverlaps
|
||||
? $"{label}OVERLAPS DETECTED: {overlapPts.Count} intersection points"
|
||||
: $"{label}Overlap check: PASS"
|
||||
);
|
||||
total += overlapPts.Count;
|
||||
}
|
||||
|
||||
return overlapPts.Count;
|
||||
return total;
|
||||
}
|
||||
|
||||
static void PrintResults(bool success, Plate plate, long elapsedMs)
|
||||
static void PrintResults(bool success, Nest nest, IReadOnlyList<Plate> plates, long elapsedMs)
|
||||
{
|
||||
if (plates.Count > 1)
|
||||
{
|
||||
foreach (var plate in plates)
|
||||
Console.WriteLine(
|
||||
$"Plate {nest.Plates.IndexOf(plate)}: {plate.Size.Width:F1} x {plate.Size.Length:F1} x{plate.Quantity}, {plate.Parts.Count} parts, utilization {plate.Utilization():P1}"
|
||||
);
|
||||
}
|
||||
|
||||
var area = plates.Sum(p => p.Size.Width * p.Size.Length * p.Quantity);
|
||||
var utilization = area > 0
|
||||
? plates.Sum(p => p.Utilization() * p.Size.Width * p.Size.Length * p.Quantity) / area
|
||||
: 0;
|
||||
Console.WriteLine(
|
||||
$"""
|
||||
Result: {(success ? "success" : "failed")}
|
||||
Parts placed: {plate.Parts.Count}
|
||||
Utilization: {plate.Utilization():P1}
|
||||
Parts placed: {plates.Sum(p => p.Parts.Count * p.Quantity)}
|
||||
Utilization: {utilization:P1}
|
||||
Time: {elapsedMs}ms
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plans and applies cutting (part order, contour order, entries and lead-ins) on the
|
||||
/// occupied plates in scope, all or nothing. Settings are the explicit file or the plates'
|
||||
/// own saved settings; none are invented. Unverified plans apply only with per-invocation
|
||||
/// acceptance, which is never consent to post.
|
||||
/// </summary>
|
||||
static bool PlanCutting(Nest nest, IEnumerable<Plate> plates, CuttingParameters explicitParameters,
|
||||
Options options, CancellationToken token)
|
||||
{
|
||||
var scope = plates.Where(p => p.Parts.Count > 0).ToArray();
|
||||
if (scope.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine("Error: no occupied plates to plan cutting for. Nothing saved.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var parameters = explicitParameters ?? SavedCuttingParameters(scope);
|
||||
if (parameters == null)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Error: --plan-cutting needs confirmed cutting settings: pass --cutting-params <settings.json>, or plan plates that all carry the same saved settings. Nothing saved."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine("---");
|
||||
Console.WriteLine($"Cutting settings: {(explicitParameters != null ? options.CuttingParamsFile : "saved with the plates")}");
|
||||
var batch = CuttingPlanBatch.Capture(scope, parameters, options.KeepPartOrder,
|
||||
scope.Select(p => nest.Plates.IndexOf(p)).ToArray(), token);
|
||||
var proposal = batch.Plan(new PlanningProgress(scope.Length), token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (var line in proposal.Describe(UnitsHelper.GetShortString(nest.Units),
|
||||
"Pass --cutting-params settings that select"))
|
||||
Console.WriteLine(line);
|
||||
|
||||
if (!(proposal.CanApply || options.AcceptUnverifiedPlan && proposal.CanApplyWithWarnings))
|
||||
{
|
||||
Console.Error.WriteLine(proposal.RequiresWarningAcceptance
|
||||
? "Cutting plan not applied: it is unverified. Review the warnings above; to apply it anyway, explicitly use --accept-unverified-plan for this invocation. Nothing saved."
|
||||
: "Cutting plan not applied: every plate must be ready. Nothing saved.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var commit = proposal.Apply(options.AcceptUnverifiedPlan, token);
|
||||
if (commit.Status != CuttingCommitStatus.Applied)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: cutting plan not applied ({commit.Status}): {commit.Message} Nothing saved.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Cutting plan applied to {scope.Length} plate(s).");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The plates' saved settings when every plate carries the same ones; otherwise null.</summary>
|
||||
static CuttingParameters SavedCuttingParameters(IReadOnlyList<Plate> plates)
|
||||
{
|
||||
var first = plates[0].CuttingParameters;
|
||||
if (first == null)
|
||||
return null;
|
||||
var json = CuttingParametersSerializer.Serialize(first);
|
||||
return plates.All(p => p.CuttingParameters != null
|
||||
&& CuttingParametersSerializer.Serialize(p.CuttingParameters) == json) ? first : null;
|
||||
}
|
||||
|
||||
/// <summary>Reports synchronously, so phase lines print in order with the summary.</summary>
|
||||
sealed class PlanningProgress(int plateCount) : IProgress<CuttingPlanProgress>
|
||||
{
|
||||
public void Report(CuttingPlanProgress value) => Console.WriteLine(
|
||||
(plateCount > 1 ? $"Planning plate {value.PlateNumber}: " : "Planning: ") + value.Phase switch
|
||||
{
|
||||
CuttingPlanPhase.Reordering => "finding a part order...",
|
||||
CuttingPlanPhase.KeepingOrder => "keeping the current order...",
|
||||
CuttingPlanPhase.CheckingOverlap => "checking for overlaps...",
|
||||
_ => value.Phase + "...",
|
||||
});
|
||||
}
|
||||
|
||||
static void Save(Nest nest, Options options)
|
||||
{
|
||||
if (options.NoSave)
|
||||
return;
|
||||
|
||||
var firstInput = options.InputFiles[0];
|
||||
var outputFile =
|
||||
options.OutputFile
|
||||
?? Path.Combine(
|
||||
Path.GetDirectoryName(firstInput),
|
||||
$"{Path.GetFileNameWithoutExtension(firstInput)}-result{NestFormat.FileExtension}"
|
||||
);
|
||||
var outputFile = NestOutputPath(options);
|
||||
|
||||
new NestWriter(nest).Write(outputFile);
|
||||
new NestWriter(nest) { BestFitStore = BestFitCacheStore.Shared }.Write(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)
|
||||
{
|
||||
if (options.PostsDir != null)
|
||||
@@ -633,10 +908,13 @@ static class NestConsole
|
||||
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)
|
||||
return;
|
||||
{
|
||||
Save(nest, options);
|
||||
return true;
|
||||
}
|
||||
|
||||
var postsDir = ResolvePostsDir(options);
|
||||
var processors = LoadPostProcessors(postsDir);
|
||||
@@ -655,7 +933,15 @@ static class NestConsole
|
||||
else
|
||||
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;
|
||||
@@ -669,8 +955,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);
|
||||
Console.WriteLine($"Post: {post.Name} -> {outputFile}");
|
||||
foreach (var file in outputFiles)
|
||||
Console.WriteLine($"Post: {post.Name} -> {file}");
|
||||
return true;
|
||||
}
|
||||
|
||||
static void PrintUsage()
|
||||
@@ -697,14 +997,29 @@ static class NestConsole
|
||||
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
|
||||
--output <path> Output nest file path (default: <input>-result.nest)
|
||||
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
|
||||
--autonest Whole-job nesting via the jobs engine (--engine) instead of single-plate fill
|
||||
--engine <name> With --autonest: jobs engine (default: Default; also StockLadder, Strip, ...).
|
||||
Without --autonest: fill strategy (Default, Strip, Vertical Remnant, Horizontal Remnant)
|
||||
--autonest Validated whole-job nesting (one sheet unless --max-sheets); replaces only after acceptance
|
||||
--max-sheets <n> With --autonest: physical sheets the job may use (default: 1); extra
|
||||
layouts are inserted as new plates after the target plate
|
||||
--stock <WxL[:cost],...> With --autonest: sheet sizes to offer, each unlimited (default: the
|
||||
target plate's size); spacing, quadrant and clamps follow the target
|
||||
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
|
||||
--engine <name> With --autonest: jobs engine (default: Default; also Irregular, Rectangles, Fill, ...).
|
||||
Without --autonest: fill strategy (Fill (default), Strip, Vertical Remnant, Horizontal Remnant)
|
||||
--keep-parts Don't clear existing parts before filling
|
||||
--no-fill Keep the loaded .nest as it is (no fill); plan, save or post it
|
||||
--plan-cutting After filling (or loading with --no-fill), plan cutting (part order,
|
||||
contour order, entries, lead-ins); applied only when every plate is
|
||||
ready or --accept-unverified-plan is given (else exit 2)
|
||||
--cutting-params <path> Cutting settings JSON for --plan-cutting (default: the plates' own
|
||||
saved settings, which must agree; never invented)
|
||||
--keep-part-order With --plan-cutting: keep the current whole-part order
|
||||
--all-plates With --plan-cutting: plan every occupied plate, not just the target
|
||||
--accept-unverified-plan Explicitly apply an unverified cutting plan for this invocation
|
||||
--check-overlaps Run overlap detection after fill (exit code 1 if found)
|
||||
--no-save Skip saving output file
|
||||
--post <name> Run a post processor after nesting
|
||||
--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/)
|
||||
--list-posts List available post processors and exit
|
||||
-h, --help Show this help
|
||||
@@ -725,12 +1040,22 @@ static class NestConsole
|
||||
public bool NoSave;
|
||||
public bool KeepParts;
|
||||
public bool AutoNest;
|
||||
public bool AllowInvalid;
|
||||
public int MaxSheets = 1;
|
||||
public List<PlateOption> Stock;
|
||||
public string Engine = "Default";
|
||||
public string TemplateFile;
|
||||
public string PostName;
|
||||
public bool AcknowledgePostRisks;
|
||||
public string PostOutput;
|
||||
public string PostsDir;
|
||||
public bool ListPosts;
|
||||
public bool NoFill;
|
||||
public bool PlanCutting;
|
||||
public string CuttingParamsFile;
|
||||
public bool KeepPartOrder;
|
||||
public bool AcceptUnverifiedPlan;
|
||||
public bool AllPlates;
|
||||
public double? RepairBendsMillimeters;
|
||||
public BendRepairUnits CadUnits;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Existing cutting work on a set of plates: applied lead-ins and cut-offs.</summary>
|
||||
/// <param name="LeadInParts">Real parts carrying applied lead-ins (locked ones included).</param>
|
||||
/// <param name="LockedLeadInParts">Of those, parts whose lead-ins are locked.</param>
|
||||
/// <param name="CutOffs">Cut-off definitions.</param>
|
||||
/// <param name="PlateNumbers">Display numbers of the plates holding any of it, ascending.</param>
|
||||
public sealed record AppliedCuttingWorkSummary(int LeadInParts, int LockedLeadInParts, int CutOffs,
|
||||
IReadOnlyList<int> PlateNumbers)
|
||||
{
|
||||
public bool HasExistingWork => LeadInParts > 0 || CutOffs > 0;
|
||||
|
||||
/// <summary>Operator text for the cutting planner's warning banner; empty when nothing exists.</summary>
|
||||
public string Describe()
|
||||
{
|
||||
if (!HasExistingWork)
|
||||
return string.Empty;
|
||||
var found = new List<string>();
|
||||
if (LeadInParts > 0)
|
||||
found.Add($"lead-ins on {Count(LeadInParts, "part")}"
|
||||
+ (LockedLeadInParts > 0 ? $" ({LockedLeadInParts} locked)" : string.Empty));
|
||||
if (CutOffs > 0)
|
||||
found.Add(Count(CutOffs, "cut-off"));
|
||||
var plates = PlateNumbers.Count == 1 ? "plate " : "plates ";
|
||||
var kept = new List<string>();
|
||||
if (LockedLeadInParts > 0)
|
||||
kept.Add("locked lead-ins");
|
||||
if (CutOffs > 0)
|
||||
kept.Add("existing cut-offs");
|
||||
return $"ALREADY APPLIED: {string.Join(" and ", found)} on {plates}{string.Join(", ", PlateNumbers)}."
|
||||
+ (LeadInParts > LockedLeadInParts ? " Planning recalculates unlocked lead-ins." : string.Empty)
|
||||
+ (kept.Count > 0 ? $" Planning keeps {string.Join(" and ", kept)} unchanged." : string.Empty)
|
||||
+ " Clear them to recalculate everything from scratch.";
|
||||
}
|
||||
|
||||
private static string Count(int value, string noun) => $"{value} {noun}{(value == 1 ? string.Empty : "s")}";
|
||||
}
|
||||
|
||||
/// <summary>Finds and clears applied lead-ins and cut-offs so a plan can recalculate them.</summary>
|
||||
public static class AppliedCuttingWork
|
||||
{
|
||||
/// <summary>
|
||||
/// Counts applied lead-ins on real parts and cut-off definitions. Definitions in
|
||||
/// <paramref name="ignoredCutOffs"/> (for example ones staged by the caller) are not counted.
|
||||
/// </summary>
|
||||
public static AppliedCuttingWorkSummary Summarize(IReadOnlyList<Plate> plates,
|
||||
IReadOnlyList<int> plateNumbers = null, IEnumerable<CutOff> ignoredCutOffs = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plates);
|
||||
if (plateNumbers != null && plateNumbers.Count != plates.Count)
|
||||
throw new ArgumentException("Give one display number per plate.", nameof(plateNumbers));
|
||||
var ignored = new HashSet<CutOff>(ignoredCutOffs ?? [], ReferenceEqualityComparer.Instance);
|
||||
int leads = 0, locked = 0, cutOffs = 0;
|
||||
var numbers = new SortedSet<int>();
|
||||
for (var index = 0; index < plates.Count; index++)
|
||||
{
|
||||
var plate = plates[index] ?? throw new ArgumentException("Plates must not be null.", nameof(plates));
|
||||
var plateLeads = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray();
|
||||
var plateCutOffs = plate.CutOffs.Count(c => !ignored.Contains(c));
|
||||
leads += plateLeads.Length;
|
||||
locked += plateLeads.Count(p => p.LeadInsLocked);
|
||||
cutOffs += plateCutOffs;
|
||||
if (plateLeads.Length > 0 || plateCutOffs > 0)
|
||||
numbers.Add(plateNumbers?[index] ?? index + 1);
|
||||
}
|
||||
return new(leads, locked, cutOffs, numbers.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes every applied lead-in (locked ones too) from real parts and every cut-off with
|
||||
/// its cut-off parts, and forgets each plate's recorded cutting parameters. Part poses,
|
||||
/// order and quantities are unchanged. Returns what was cleared.
|
||||
/// </summary>
|
||||
public static AppliedCuttingWorkSummary Clear(IReadOnlyList<Plate> plates, IReadOnlyList<int> plateNumbers = null)
|
||||
{
|
||||
var cleared = Summarize(plates, plateNumbers);
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
foreach (var part in plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray())
|
||||
part.RemoveLeadIns();
|
||||
plate.CutOffs.Clear();
|
||||
for (var index = plate.Parts.Count - 1; index >= 0; index--)
|
||||
if (plate.Parts[index].BaseDrawing.IsCutOff)
|
||||
plate.Parts.RemoveAt(index);
|
||||
plate.CuttingParameters = null;
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
}
|
||||
@@ -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,554 @@
|
||||
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; }
|
||||
}
|
||||
|
||||
public enum AutomaticCutOffDiagnosticCode
|
||||
{
|
||||
ExistingCutOff,
|
||||
LimitedCutOffConflict,
|
||||
EmptyCut,
|
||||
SegmentedCut,
|
||||
ClampBlockedCut,
|
||||
NoSafeTailSeparator,
|
||||
TailBelowMinimum,
|
||||
ShiftedCut,
|
||||
}
|
||||
|
||||
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>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;
|
||||
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 occupied = 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;
|
||||
|
||||
occupied = System.Math.Max(occupied, ValidatePart(part, bounds, sign));
|
||||
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;
|
||||
occupied = System.Math.Min(occupied, length);
|
||||
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance)
|
||||
+ GeometryTolerance;
|
||||
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
|
||||
// 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.
|
||||
var keepsTail = hasTailCandidate && length - separator >= options.MinimumTailLength;
|
||||
var usedSpan = keepsTail ? separator : length;
|
||||
var candidateCount = System.Math.Ceiling(usedSpan / 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>();
|
||||
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
||||
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 && FullSpanLimits(c, bounds, settings))
|
||||
.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 (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;
|
||||
}
|
||||
// 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;
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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 = new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical)
|
||||
.MeasureSegments(plate, settings, cache);
|
||||
measurements.Add(distance, measured);
|
||||
}
|
||||
return measured;
|
||||
}
|
||||
|
||||
void AddCandidate(double x, bool isSeparator)
|
||||
{
|
||||
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
|
||||
Near(c.Position.X, x)).ToList();
|
||||
|
||||
if (matches.Any(c => !FullSpanLimits(c, bounds, settings)))
|
||||
{
|
||||
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
|
||||
? new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
|
||||
: 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, bounds))
|
||||
{
|
||||
WarnNoSeparator(x);
|
||||
return;
|
||||
}
|
||||
separated = true;
|
||||
separatorX = candidate.Position.X;
|
||||
}
|
||||
else if (usable && !IsFullSpanProgram(program, bounds))
|
||||
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));
|
||||
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(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
|
||||
Require(plate.Parts != null && plate.CutOffs != null,
|
||||
"Plate parts and cut-off collections are required.", nameof(plate));
|
||||
}
|
||||
|
||||
private static double ValidatePart(Part part, Box sheet, int sign)
|
||||
{
|
||||
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");
|
||||
Require(Inside(box, sheet), "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 roundoff = CutOff.GetBoundsRoundoff(part);
|
||||
foreach (var entity in ConvertProgram.ToGeometry(part.Program))
|
||||
{
|
||||
var entityBox = entity.BoundingBox;
|
||||
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
|
||||
if (!SpecialLayers.IsMaterial(entity.Layer))
|
||||
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), "Part geometry extends outside the physical sheet.", "plate");
|
||||
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
|
||||
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
|
||||
}
|
||||
Require(hasMaterial, "Real parts must contain material geometry.", "plate");
|
||||
return occupied;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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) ||
|
||||
!Near(from.EndPoint.X, to.EndPoint.X) ||
|
||||
System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y) <= GeometryTolerance)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsFullSpanProgram(Program program, Box bounds)
|
||||
{
|
||||
// No gaps, bridges, or filtered middle segments can separate the tail.
|
||||
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
|
||||
program.Codes[1] is not LinearMove to)
|
||||
return false;
|
||||
return System.Math.Min(from.EndPoint.Y, to.EndPoint.Y) <= bounds.Bottom + GeometryTolerance &&
|
||||
System.Math.Max(from.EndPoint.Y, to.EndPoint.Y) >= bounds.Top - GeometryTolerance;
|
||||
}
|
||||
|
||||
private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
|
||||
Nonnegative(box.Length) && Nonnegative(box.Width) &&
|
||||
double.IsFinite(box.Right) && double.IsFinite(box.Top);
|
||||
|
||||
private static bool Inside(Box box, Box sheet) =>
|
||||
box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance &&
|
||||
box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance;
|
||||
|
||||
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,51 @@
|
||||
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.</summary>
|
||||
public sealed record SavedPairSet(
|
||||
double PlateWidth,
|
||||
double PlateHeight,
|
||||
double Spacing,
|
||||
IReadOnlyList<SavedPairResult> Results
|
||||
);
|
||||
|
||||
/// <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"/>.</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(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,56 @@
|
||||
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>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
|
||||
{
|
||||
/// <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,125 @@
|
||||
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); 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));
|
||||
if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++;
|
||||
if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) 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,236 @@
|
||||
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;
|
||||
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
|
||||
{
|
||||
var admitted = AdmittedClosureSlack(material.Rings[r]);
|
||||
for (var e = 0; e < material.Rings[r].Length; e++)
|
||||
{
|
||||
Query(token, ref budget);
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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,212 @@
|
||||
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>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)
|
||||
{
|
||||
Expected = expected;
|
||||
Order = order == null ? null : Array.AsReadOnly(order.ToArray());
|
||||
Programs = Array.AsReadOnly((programs ?? []).ToArray());
|
||||
}
|
||||
|
||||
public PlateCuttingState Expected { get; }
|
||||
public IReadOnlyList<Part> Order { get; }
|
||||
public IReadOnlyList<PlannedPartProgram> Programs { 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 reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
|
||||
staged.Add(new(plate, order, programs, reordered || programs.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)>();
|
||||
try
|
||||
{
|
||||
foreach (var item in staged)
|
||||
{
|
||||
undo.Add((item.Plate, item.Plate.Parts.ToArray(),
|
||||
item.Programs.Select(p => (p.Part, p.Part.CaptureCuttingState())).ToArray()));
|
||||
item.Plate.Parts.SetOrder(item.Order);
|
||||
foreach (var (part, program, bounds, parameters) in item.Programs)
|
||||
{
|
||||
beforeInstall?.Invoke(item.Plate, part);
|
||||
part.InstallPlannedProgram(program, bounds, parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
for (var i = undo.Count - 1; i >= 0; i--)
|
||||
{
|
||||
foreach (var (part, state) in undo[i].States)
|
||||
part.RestoreCuttingState(state);
|
||||
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, bool Changed);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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;
|
||||
foreach (var code in current.Codes)
|
||||
{
|
||||
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.");
|
||||
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));
|
||||
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 ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab };
|
||||
|
||||
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,174 @@
|
||||
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)
|
||||
{
|
||||
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>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>();
|
||||
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.
|
||||
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab))
|
||||
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);
|
||||
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.
|
||||
for (var r = 0; r < rings.Count; r++)
|
||||
for (var s = r; s < rings.Count; s++)
|
||||
for (var i = 0; i < rings[r].Length; i++)
|
||||
for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Material validation exceeds the native query limit.");
|
||||
var a = rings[r][i];
|
||||
var b = rings[s][j];
|
||||
var contacts = a.Contacts(b, out var overlap);
|
||||
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
|
||||
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($"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("Material must have exactly one enclosing perimeter.");
|
||||
if (ringTabs[outer].Any(tab => tab))
|
||||
throw new ArgumentException("A perimeter cannot have tabs.");
|
||||
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);
|
||||
|
||||
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.");
|
||||
}
|
||||
rings.Add(chain.ToArray());
|
||||
ringTabs.Add(chainTabs.ToArray());
|
||||
chain.Clear();
|
||||
chainTabs.Clear();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
{
|
||||
return new LeadMaterialSnapshot(Array.Empty<PostVerificationGeometry.Curve[]>(), Array.Empty<bool[]>(), ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token)
|
||||
&& !Rings.Skip(1).Any(hole => Inside(point, hole, token));
|
||||
|
||||
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token)
|
||||
{
|
||||
var inside = false;
|
||||
foreach (var curve in ring)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (curve.CrossesRay(point))
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
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;
|
||||
foreach (var boundary in target.Rings.SelectMany(r => r))
|
||||
{
|
||||
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))
|
||||
{
|
||||
Charge(material.BoundaryCount);
|
||||
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, charged as if it ran.
|
||||
// Ray parity is still asked natively unless the lead lies beside the material's rows.
|
||||
bool FarInside(LeadMaterialSnapshot material)
|
||||
{
|
||||
Charge(material.BoundaryCount);
|
||||
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)
|
||||
{
|
||||
foreach (var boundary in material.Rings.SelectMany(r => r))
|
||||
{
|
||||
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 && 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,140 @@
|
||||
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),
|
||||
ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn),
|
||||
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
|
||||
InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
|
||||
ArcCircleLeadOut = CopyLeadOut(source.ArcCircleLeadOut),
|
||||
RoundLeadInAngles = source.RoundLeadInAngles,
|
||||
LeadInAngleIncrement = source.LeadInAngleIncrement,
|
||||
AutoTabMinSize = Dimension(source.AutoTabMinSize),
|
||||
AutoTabMaxSize = Dimension(source.AutoTabMaxSize),
|
||||
TabConfig = source.TabConfig == null
|
||||
? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
|
||||
: CopyTab(source.TabConfig),
|
||||
TabsEnabled = source.TabsEnabled,
|
||||
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) },
|
||||
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.");
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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)
|
||||
{
|
||||
Start = start;
|
||||
End = end;
|
||||
Rapid = rapid;
|
||||
Layer = layer;
|
||||
Curve = curve;
|
||||
}
|
||||
|
||||
public Vector? Start { get; }
|
||||
public Vector End { get; }
|
||||
public bool Rapid { get; }
|
||||
public LayerType Layer { get; }
|
||||
public double Length => Curve?.Length ?? 0;
|
||||
internal PostVerificationGeometry.Curve Curve { get; }
|
||||
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve);
|
||||
}
|
||||
@@ -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,208 @@
|
||||
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)).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))
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
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>A nominal native entry owned by one preparation, not an actual emitted pierce.</summary>
|
||||
public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector Point)
|
||||
{
|
||||
internal PreparedContours Owner { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// A perimeter pierce the planner placed in open scrap for the pierce zone (see
|
||||
/// <see cref="PreparedContours.ScrapPierceEntry"/>): the lead-in runs straight from here to
|
||||
/// <see cref="Point"/> instead of the configured lead-in. Null for every other choice.
|
||||
/// </summary>
|
||||
public Vector? Pierce { get; internal init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable owned clean contours and settings. Callers supply the clean, already-rotated
|
||||
/// program and keep it stable during Capture. This is not a topology or lead-path verdict.
|
||||
/// </summary>
|
||||
public sealed class PreparedContours
|
||||
{
|
||||
private readonly Shape[] shapes;
|
||||
private readonly CuttingParameters parameters;
|
||||
private readonly List<Entity> scribes;
|
||||
// Open break cuts in program order, each travel-ordered with its scrap on the left. They are
|
||||
// not contours: no choices, no material; every full or prefix emission cuts them first.
|
||||
private readonly List<Shape> breakCuts;
|
||||
// Closed cutouts with tab spans, in program order. Like break cuts they are not choices:
|
||||
// every full or prefix emission cuts their pieces after the break cuts.
|
||||
private readonly List<Shape> tabbedCutouts;
|
||||
// By contour ordinal; all null without pins. A pinned contour has exactly one entry and
|
||||
// its own owned settings (the pin's lead-in, lead-out and tab).
|
||||
private readonly ContourChoice[] pinned;
|
||||
private readonly CuttingParameters[] contourParameters;
|
||||
private readonly int[] pinNumbers;
|
||||
|
||||
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
|
||||
CuttingParameters parameters)
|
||||
{
|
||||
this.shapes = shapes;
|
||||
this.scribes = scribes;
|
||||
this.breakCuts = breakCuts;
|
||||
this.tabbedCutouts = tabbedCutouts;
|
||||
this.parameters = parameters;
|
||||
pinned = new ContourChoice[shapes.Length];
|
||||
pinNumbers = new int[shapes.Length];
|
||||
}
|
||||
|
||||
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
|
||||
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters)
|
||||
{
|
||||
contourParameters = new CuttingParameters[shapes.Length];
|
||||
var rejected = new List<CutPinResolution>();
|
||||
foreach (var pin in pins)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (pin == null)
|
||||
throw new ArgumentException("A pin resolution is missing.", nameof(pins));
|
||||
if (!pin.IsResolved)
|
||||
continue; // The caller reports why; the contour stays automatic.
|
||||
var contour = pin.ContourOrdinal;
|
||||
if (contour < 0 || contour >= Count || pin.EntityOrdinal < 0
|
||||
|| pin.EntityOrdinal >= shapes[contour].Entities.Count)
|
||||
throw new ArgumentException("A pin resolution does not belong to these contours.", nameof(pins));
|
||||
if (pinned[contour] != null)
|
||||
throw new ArgumentException("Two pins resolve to one contour.", nameof(pins));
|
||||
// The emitter tabs only the outside, so a tab elsewhere cannot be cut as pinned.
|
||||
if (pin.Pin.HasTab && contour != PerimeterOrdinal)
|
||||
{
|
||||
rejected.Add(pin);
|
||||
continue;
|
||||
}
|
||||
var choice = new ContourChoice(contour, pin.EntityOrdinal, pin.Point) { Owner = this };
|
||||
ValidateChoice(choice);
|
||||
pinned[contour] = choice;
|
||||
pinNumbers[contour] = pin.PinIndex + 1;
|
||||
contourParameters[contour] = PinParameters(parameters, pin.Pin);
|
||||
}
|
||||
RejectedPins = rejected.AsReadOnly();
|
||||
}
|
||||
|
||||
public int Count => shapes.Length;
|
||||
|
||||
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
|
||||
internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
|
||||
public int PerimeterOrdinal => Count - 1;
|
||||
|
||||
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary>
|
||||
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
|
||||
|
||||
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
|
||||
public bool IsPinned(int contourOrdinal) => GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
|
||||
|
||||
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
|
||||
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
|
||||
|
||||
/// <summary>The pinned contour's only entry, or null for an automatic contour.</summary>
|
||||
public ContourChoice PinnedEntry(int contourOrdinal) => IsPinned(contourOrdinal) ? pinned[contourOrdinal] : null;
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="Capture(Program, CuttingParameters, CancellationToken)"/>, then fixes every
|
||||
/// resolved pin's contour: that contour's only entry is the pin's point and it is emitted with
|
||||
/// the pin's own lead-in, lead-out and tab through the same emitter, so its tab gap is genuine
|
||||
/// and certifiable. <paramref name="pins"/> must come from <see cref="CutPinResolver"/> against
|
||||
/// contours of this same program; unresolved pins are skipped (the caller reports them), a pin
|
||||
/// with a tab on an internal contour is listed in <see cref="RejectedPins"/>, and every other
|
||||
/// contour keeps the automatic selection with <paramref name="parameters"/>.
|
||||
/// </summary>
|
||||
public static PreparedContours Capture(Program program, CuttingParameters parameters,
|
||||
IReadOnlyList<CutPinResolution> pins, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pins);
|
||||
var unpinned = Capture(program, parameters, token);
|
||||
return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned;
|
||||
}
|
||||
|
||||
// The pin's leads replace every contour type's leads, since the type the emitter detects is
|
||||
// not the caller's concern; angle rounding is off because a pinned entry never moves.
|
||||
private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin)
|
||||
{
|
||||
var owned = OwnedCuttingParameters.Copy(shared);
|
||||
owned.ExternalLeadIn = pin.LeadIn;
|
||||
owned.InternalLeadIn = pin.LeadIn;
|
||||
owned.ArcCircleLeadIn = pin.LeadIn;
|
||||
owned.ExternalLeadOut = pin.LeadOut;
|
||||
owned.InternalLeadOut = pin.LeadOut;
|
||||
owned.ArcCircleLeadOut = pin.LeadOut;
|
||||
owned.RoundLeadInAngles = false;
|
||||
owned.TabsEnabled = pin.HasTab;
|
||||
if (pin.HasTab)
|
||||
owned.TabConfig = pin.Tab;
|
||||
return owned;
|
||||
}
|
||||
|
||||
private CuttingParameters ParametersFor(int contourOrdinal) => contourParameters?[contourOrdinal] ?? parameters;
|
||||
|
||||
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var ownedParameters = OwnedCuttingParameters.Copy(parameters);
|
||||
ExecutionMotionReader.ReadSupported(program, Vector.Zero, null, token);
|
||||
// ToGeometry constructs fresh native entities. Normalize an owned motion graph
|
||||
// to incremental mode so absolute subprograms retain their frame offsets too.
|
||||
// Retain execution boundaries: ShapeBuilder can join duplicate closed contours.
|
||||
var geometryProgram = CopyForGeometry(program, token);
|
||||
var contours = new List<Shape>();
|
||||
var scribes = new List<Entity>();
|
||||
var breakCuts = new List<Shape>();
|
||||
var tabbedCutouts = new List<Shape>();
|
||||
Shape breakCut = null;
|
||||
var current = new Shape();
|
||||
foreach (var entity in geometryProgram.ToGeometry())
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (entity.GetType() != typeof(Line) && entity.GetType() != typeof(Arc) && entity.GetType() != typeof(Circle))
|
||||
throw new NotSupportedException("Unsupported native geometry.");
|
||||
if (entity.Layer == SpecialLayers.Break)
|
||||
{
|
||||
if (current.Entities.Count != 0)
|
||||
throw new ArgumentException("Preparation requires closed execution contours.");
|
||||
if (entity is Circle || entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
|
||||
throw new ArgumentException("Degenerate break cut entity.");
|
||||
if (breakCut == null || End(breakCut.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
breakCut = new Shape();
|
||||
breakCuts.Add(breakCut);
|
||||
}
|
||||
breakCut.Entities.Add(entity);
|
||||
continue;
|
||||
}
|
||||
breakCut = null;
|
||||
if (entity.Layer == SpecialLayers.Rapid || entity.Layer == SpecialLayers.Scribe)
|
||||
{
|
||||
if (current.Entities.Count != 0)
|
||||
throw new ArgumentException("Preparation requires closed execution contours.");
|
||||
if (entity.Layer == SpecialLayers.Scribe)
|
||||
scribes.Add(entity);
|
||||
continue;
|
||||
}
|
||||
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab)
|
||||
throw new ArgumentException("Preparation requires clean cut/display contours.");
|
||||
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
|
||||
throw new ArgumentException("Degenerate contour entity.");
|
||||
if (entity is Circle)
|
||||
{
|
||||
if (current.Entities.Count != 0 || entity.Layer == SpecialLayers.Tab)
|
||||
throw new ArgumentException("Circle interrupts an open contour.");
|
||||
current.Entities.Add(entity);
|
||||
FinishContour();
|
||||
continue;
|
||||
}
|
||||
if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("Discontinuous native contour.");
|
||||
current.Entities.Add(entity);
|
||||
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
|
||||
FinishContour();
|
||||
}
|
||||
if (current.Entities.Count != 0 || contours.Count == 0)
|
||||
throw new ArgumentException("Preparation requires nonempty closed contours.");
|
||||
var perimeter = 0;
|
||||
for (var i = 1; i < contours.Count; i++)
|
||||
{
|
||||
var box = contours[i].BoundingBox;
|
||||
var best = contours[perimeter].BoundingBox;
|
||||
if (box.Width * box.Length > best.Width * best.Length)
|
||||
perimeter = i;
|
||||
}
|
||||
var outer = contours[perimeter];
|
||||
contours.RemoveAt(perimeter);
|
||||
contours.Add(outer);
|
||||
foreach (var cut in breakCuts)
|
||||
cut.UpdateBounds();
|
||||
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters);
|
||||
|
||||
void FinishContour()
|
||||
{
|
||||
current.UpdateBounds();
|
||||
if (ContourCuttingStrategy.IsTabbedCutout(current))
|
||||
{
|
||||
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
|
||||
throw new ArgumentException("A tabbed cutout has nothing to cut.");
|
||||
tabbedCutouts.Add(current);
|
||||
}
|
||||
else
|
||||
contours.Add(current);
|
||||
current = new Shape();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The closest native entry to <paramref name="approach"/>; a pinned contour's pinned entry.</summary>
|
||||
public ContourChoice ClosestEntry(int contourOrdinal, Vector approach)
|
||||
{
|
||||
PostVerificationGeometry.Validate(approach);
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (pinned[contourOrdinal] is { } pin)
|
||||
return pin;
|
||||
var point = shape.ClosestPointTo(approach, out var entity);
|
||||
return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closest native point first, then source-order vertices/midpoints or eight circle
|
||||
/// angles (0 through 315 degrees). Geometrical duplicates keep their first entity.
|
||||
/// Emit before validating actual paths: circle rounding/clamping may move this point.
|
||||
/// A pinned contour has its pinned entry only.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ContourChoice> Entries(int contourOrdinal, Vector approach, int maxEntries = 16, CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (maxEntries <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxEntries));
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (pinned[contourOrdinal] is { } pin)
|
||||
return new[] { pin };
|
||||
var entries = new List<ContourChoice>();
|
||||
entries.Add(ClosestEntry(contourOrdinal, approach));
|
||||
for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var entity = shape.Entities[i];
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
for (var angle = 0; angle < 8 && entries.Count < maxEntries; angle++)
|
||||
Add(i, circle.Center + new Vector(System.Math.Cos(angle * System.Math.PI / 4),
|
||||
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius);
|
||||
}
|
||||
else
|
||||
{
|
||||
Add(i, Start(entity));
|
||||
Add(i, entity is Line line ? line.MidPoint : ((Arc)entity).MidPoint());
|
||||
Add(i, End(entity));
|
||||
}
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
return entries.AsReadOnly();
|
||||
|
||||
void Add(int entityOrdinal, Vector point)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon))
|
||||
entries.Add(Entry(contourOrdinal, entityOrdinal, point));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every contour with a native entity within <paramref name="tolerance"/> of
|
||||
/// <paramref name="point"/>, in ordinal order, each with its closest entity and the exact
|
||||
/// closest native point on it. A locating aid for drawing pins, not an entry choice.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<(int ContourOrdinal, int EntityOrdinal, Vector Point)> ContoursNear(Vector point, double tolerance)
|
||||
{
|
||||
PostVerificationGeometry.Validate(point);
|
||||
var found = new List<(int, int, Vector)>();
|
||||
for (var ordinal = 0; ordinal < Count; ordinal++)
|
||||
{
|
||||
var shape = shapes[ordinal];
|
||||
var closest = shape.ClosestPointTo(point, out var entity);
|
||||
if (closest.DistanceTo(point) <= tolerance)
|
||||
found.Add((ordinal, shape.Entities.IndexOf(entity), closest));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
|
||||
{
|
||||
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
|
||||
ValidateChoice(choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An automatic perimeter entry pierced at <paramref name="pierce"/> (local coordinates),
|
||||
/// entering at the closest native point of the perimeter along a straight lead-in. The
|
||||
/// planner's pierce-zone fallback chooses the pierce from the free scrap around the part;
|
||||
/// emission reads only this choice and the perimeter, and the lead validator checks the
|
||||
/// lead as usual. A pinned perimeter keeps its pin's entry.
|
||||
/// </summary>
|
||||
internal ContourChoice ScrapPierceEntry(Vector pierce)
|
||||
{
|
||||
PostVerificationGeometry.Validate(pierce);
|
||||
var shape = GetShape(PerimeterOrdinal);
|
||||
var point = shape.ClosestPointTo(pierce, out var entity);
|
||||
var choice = new ContourChoice(PerimeterOrdinal, shape.Entities.IndexOf(entity), point)
|
||||
{
|
||||
Owner = this,
|
||||
Pierce = pierce,
|
||||
};
|
||||
ValidateChoice(choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The uncapped preferred automatic start catalogue for one contour, in preference then
|
||||
/// contour-travel order: convex corners of the contour's own winding, then straight-edge
|
||||
/// midpoints, then tangent line/arc joints. Reflex and cusp vertices, collinear
|
||||
/// line/line splits, circles and interior points never appear; each geometric point is
|
||||
/// reported once, keeping the most preferred kind. A pure-arc contour can have no
|
||||
/// preferred point at all — <see cref="AutomaticEntryCandidatesWithFallbacks"/> supplies
|
||||
/// those. Manual entry through <see cref="Entry"/> / <see cref="ClosestEntry"/> is
|
||||
/// unaffected.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidates(int contourOrdinal, CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (pinned[contourOrdinal] is { } pin)
|
||||
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
||||
if (IsSingleCircle(shape))
|
||||
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue.");
|
||||
return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The complete uncapped automatic start catalogue: the preferred points of
|
||||
/// <see cref="AutomaticEntryCandidates"/> followed by tier-3 fallbacks — native arc
|
||||
/// midpoints, near-convex-corner points on straight edges, the eight compass points of a
|
||||
/// whole circle, and the exact target-facing closest point toward
|
||||
/// <paramref name="lookAhead"/> (pass the arrival point there when there is no next cut).
|
||||
/// A pure-circle contour therefore yields compass points instead of refusing. Every
|
||||
/// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the
|
||||
/// preferred tier; fallbacks never replace a preferred point at the same geometry. A
|
||||
/// pinned contour's catalogue is its pinned entry alone: nothing may relocate it.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
|
||||
int contourOrdinal,
|
||||
Vector? lookAhead = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var shape = GetShape(contourOrdinal);
|
||||
if (pinned[contourOrdinal] is { } pin)
|
||||
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
|
||||
var all = IsSingleCircle(shape)
|
||||
? new List<ContourEntryCandidate>()
|
||||
: PreferredCandidates(shape, contourOrdinal, token);
|
||||
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
|
||||
return MergeByGeometry(all);
|
||||
}
|
||||
|
||||
private static bool IsSingleCircle(Shape shape) =>
|
||||
shape.Entities.Count == 1 && shape.Entities[0] is Circle;
|
||||
|
||||
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
|
||||
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
|
||||
=> CataloguePoints(shape, token)
|
||||
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Tier-3 fallbacks for one contour, each already run through the reflex/cusp exclusion:
|
||||
/// native arc midpoints, the eight compass points of whole circles, near-convex-corner
|
||||
/// insets on straight edges, and the exact target-facing closest point toward
|
||||
/// <paramref name="lookAhead"/>. No ranking and no lead-safety verdict here.
|
||||
/// </summary>
|
||||
private List<ContourEntryCandidate> FallbackCandidates(
|
||||
Shape shape,
|
||||
int contourOrdinal,
|
||||
Vector? lookAhead,
|
||||
CancellationToken token)
|
||||
{
|
||||
var fallbacks = new List<ContourEntryCandidate>();
|
||||
var lead = ApplicableLeadInLength(contourOrdinal);
|
||||
var count = shape.Entities.Count;
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
switch (shape.Entities[i])
|
||||
{
|
||||
case Arc arc:
|
||||
// The native midpoint of an arc (exact native API, never tessellation).
|
||||
fallbacks.Add(Fallback(i, arc.MidPoint(), AutomaticEntryKind.ArcMidpoint));
|
||||
break;
|
||||
case Circle circle:
|
||||
// The legacy eight compass points, same native construction.
|
||||
for (var angle = 0; angle < 8; angle++)
|
||||
fallbacks.Add(Fallback(i, circle.Center + new Vector(
|
||||
System.Math.Cos(angle * System.Math.PI / 4),
|
||||
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius,
|
||||
AutomaticEntryKind.CircleCompass));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Near-convex-corner fallbacks: about twice the applicable lead-in length back from
|
||||
// each convex corner along each incident STRAIGHT edge, only when strictly inside
|
||||
// that edge. Short edges simply omit the point; it never extrapolates past an edge
|
||||
// endpoint and so never lands on the reflex/cusp vertex at the far end.
|
||||
if (lead > 0 && !(count == 1 && shape.Entities[0] is Circle))
|
||||
{
|
||||
foreach (var corner in CataloguePoints(shape, token)
|
||||
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
// Back INTO each incident edge from the corner: the edge ending at the
|
||||
// corner retreats against its own travel, the edge starting at the corner
|
||||
// advances along its own travel.
|
||||
AddInset(corner.EntityOrdinal, inward: false);
|
||||
AddInset((corner.EntityOrdinal + 1) % count, inward: true);
|
||||
}
|
||||
}
|
||||
|
||||
if (lookAhead != null)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
PostVerificationGeometry.Validate(lookAhead.Value);
|
||||
var facing = ClosestEntry(contourOrdinal, lookAhead.Value);
|
||||
// A raw closest point may land exactly on a reflex/cusp vertex; automatic
|
||||
// selection must never sneak a forbidden inside corner back in, so drop it.
|
||||
if (!IsForbiddenVertex(shape, facing.Point))
|
||||
fallbacks.Add(Fallback(facing.EntityOrdinal, facing.Point, AutomaticEntryKind.TargetFacing));
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
return fallbacks;
|
||||
|
||||
void AddInset(int entityOrdinal, bool inward)
|
||||
{
|
||||
// `inward` selects along the edge's own travel from its start; without it the
|
||||
// point retreats against travel. Both ways move BACK INTO the edge from the
|
||||
// corner, which sits at the edge's end (inward=false) or start (inward=true).
|
||||
if (shape.Entities[entityOrdinal] is not Line line)
|
||||
return;
|
||||
var direction = (line.EndPoint - line.StartPoint).Normalize();
|
||||
var offset = direction * (2 * lead);
|
||||
// Adjacent native endpoints may differ within contour-continuity tolerance.
|
||||
// Anchor to the selected edge itself, never the preceding edge's vertex.
|
||||
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
|
||||
// Strictly inside the edge by projection parameter (distance alone loses the
|
||||
// sign when a short edge is overshoot): never the corner, never the far
|
||||
// endpoint (where a reflex vertex might sit). Short edges omit the point.
|
||||
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
|
||||
if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon)
|
||||
return;
|
||||
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
|
||||
}
|
||||
|
||||
ContourEntryCandidate Fallback(int entityOrdinal, Vector point, AutomaticEntryKind kind)
|
||||
=> new(Entry(contourOrdinal, entityOrdinal, point), kind);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// At equal geometric points the most preferred kind wins, independent of which entity
|
||||
/// supplied it; the winner keeps its own ordinal and point. Result order: preference,
|
||||
/// then entity ordinal.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ContourEntryCandidate> MergeByGeometry(List<ContourEntryCandidate> candidates)
|
||||
{
|
||||
var byPoint = new Dictionary<(long, long), ContourEntryCandidate>();
|
||||
var order = new List<(long, long)>();
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var key = candidate.GeometryKey;
|
||||
if (!byPoint.TryGetValue(key, out var existing))
|
||||
{
|
||||
byPoint[key] = candidate;
|
||||
order.Add(key);
|
||||
}
|
||||
else if (candidate.Kind < existing.Kind)
|
||||
byPoint[key] = candidate;
|
||||
}
|
||||
return order
|
||||
.OrderBy(key => byPoint[key].Kind)
|
||||
.ThenBy(key => byPoint[key].Choice.EntityOrdinal)
|
||||
.Select(key => byPoint[key])
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="point"/> sits on a vertex of the contour that is reflex or
|
||||
/// cusp from either travel direction — a point automatic selection must never emit.
|
||||
/// </summary>
|
||||
private bool IsForbiddenVertex(Shape shape, Vector point)
|
||||
{
|
||||
for (var i = 0; i < shape.Entities.Count; i++)
|
||||
{
|
||||
if (shape.Entities[i] is Circle)
|
||||
continue; // A whole circle has no vertex.
|
||||
var vertex = End(shape.Entities[i]);
|
||||
if (vertex.DistanceTo(point) > PostVerificationGeometry.Epsilon)
|
||||
continue;
|
||||
if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, shape.Entities[i], out var corner)
|
||||
&& corner.Kind is ContourCuttingStrategy.CornerKind.Reflex or ContourCuttingStrategy.CornerKind.Cusp)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The applicable lead-in length for a contour following the emitter's own selection:
|
||||
/// the perimeter (last shape) is external, anything else internal; non-length lead-in
|
||||
/// styles contribute 0, which omits the near-corner fallback instead of approximating.
|
||||
/// </summary>
|
||||
private double ApplicableLeadInLength(int contourOrdinal)
|
||||
=> (contourOrdinal == PerimeterOrdinal
|
||||
? parameters.ExternalLeadIn
|
||||
: parameters.InternalLeadIn) switch
|
||||
{
|
||||
LineLeadIn line => line.Length,
|
||||
LineLineLeadIn lineLine => lineLine.Length1,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The preferred catalogue points for one contour: for each entity, its convex corner
|
||||
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
|
||||
/// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute
|
||||
/// nothing; a single whole circle yields no points at all.
|
||||
/// </summary>
|
||||
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token)
|
||||
{
|
||||
var found = new List<(int, Vector, AutomaticEntryKind)>();
|
||||
var count = shape.Entities.Count;
|
||||
if (count == 1 && shape.Entities[0] is Circle)
|
||||
{
|
||||
// A whole circle has no corners or joints; only the fallback tier applies.
|
||||
return found;
|
||||
}
|
||||
|
||||
if (count < 2)
|
||||
throw new ArgumentException("Contour has no vertex to classify.");
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var entity = shape.Entities[i];
|
||||
|
||||
// Vertex reached by travelling along entity i (its end point), reported under
|
||||
// entity i. The closed contour guarantees every vertex appears exactly once
|
||||
// this way; each is classified from the contour's own winding.
|
||||
var vertex = End(entity);
|
||||
if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner))
|
||||
{
|
||||
switch (corner.Kind)
|
||||
{
|
||||
case ContourCuttingStrategy.CornerKind.Convex:
|
||||
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
|
||||
break;
|
||||
case ContourCuttingStrategy.CornerKind.Smooth
|
||||
when entity is Line && Next(i) is Arc:
|
||||
// A line leaving into an arc: the tangent joint. The reverse travel
|
||||
// order classifies the same joint from the arc, matched below.
|
||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
||||
break;
|
||||
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
|
||||
// of a plain straight edge are not preferred automatic starts here.
|
||||
case ContourCuttingStrategy.CornerKind.Smooth
|
||||
when entity is Arc && Next(i) is Line:
|
||||
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (entity is Line line)
|
||||
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
|
||||
}
|
||||
return found;
|
||||
|
||||
Entity Next(int index) => shape.Entities[(index + 1) % count];
|
||||
}
|
||||
|
||||
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
|
||||
public Program Emit(IReadOnlyList<ContourChoice> choices)
|
||||
{
|
||||
Validate(choices, true);
|
||||
return EmitPrefix(choices);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic seam: emit exactly ONE owned contour — with its normal lead-in and
|
||||
/// lead-out and its ORIGINAL contour type (the perimeter keeps External even when no
|
||||
/// holes precede it) — so a candidate's emitted leads can be validated before the hole
|
||||
/// choices exist. The result is a throwaway probe, not a plan: it must never be
|
||||
/// installed on a Part or accepted as complete output. Normal <see cref="Emit"/> and
|
||||
/// <see cref="EmitPrefix"/> keep the perimeter-last rule untouched.
|
||||
/// </summary>
|
||||
internal Program EmitCandidateForValidation(ContourChoice choice)
|
||||
{
|
||||
if (choice == null || !ReferenceEquals(choice.Owner, this))
|
||||
throw new ArgumentException("Foreign contour choice.");
|
||||
ValidateChoice(choice);
|
||||
// Same owned clones as a real emission; the source shapes/settings are never used
|
||||
// directly, so the probe cannot drift the preparation or mutate it.
|
||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice,
|
||||
contourParameters);
|
||||
}
|
||||
|
||||
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
|
||||
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
|
||||
{
|
||||
Validate(choices, false);
|
||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
|
||||
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
|
||||
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList());
|
||||
}
|
||||
|
||||
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
|
||||
public int BreakCutCount => breakCuts.Count;
|
||||
|
||||
/// <summary>Tabbed cutouts this preparation cuts, as pieces, after its break cuts and before its contours.</summary>
|
||||
public int TabbedCutoutCount => tabbedCutouts.Count;
|
||||
|
||||
/// <summary>Open pieces the tabbed cutouts are cut in, each a lead-in and cut group of its own.</summary>
|
||||
public int TabbedPieceCount => tabbedCutouts.Sum(c => ContourCuttingStrategy.TabbedCutoutPieces(c).Count);
|
||||
|
||||
internal void ValidateCompleteChoices(IReadOnlyList<ContourChoice> choices) => Validate(choices, true);
|
||||
|
||||
// Build expected-emission metadata independently of the selected payload BEFORE
|
||||
// replay. Final verification only reads this immutable execution and clean geometry.
|
||||
internal SelectedContourProgram CaptureSelectedProgram(IReadOnlyList<ContourChoice> choices,
|
||||
Vector location, CancellationToken token)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
|
||||
// Only the outside is tabbed, with its own pin's tab when it is pinned.
|
||||
var perimeter = ParametersFor(PerimeterOrdinal);
|
||||
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
|
||||
}
|
||||
|
||||
private void Validate(IReadOnlyList<ContourChoice> choices, bool complete)
|
||||
{
|
||||
if (choices == null || (complete && choices.Count != Count) || choices.Count > Count)
|
||||
throw new ArgumentException("Every contour must be represented exactly once.");
|
||||
var visited = new HashSet<int>();
|
||||
foreach (var choice in choices)
|
||||
{
|
||||
ValidateChoice(choice);
|
||||
if (!visited.Add(choice.ContourOrdinal) || choice.ContourOrdinal == PerimeterOrdinal && visited.Count != Count)
|
||||
throw new ArgumentException("Duplicate contour or perimeter before its internal contours.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateChoice(ContourChoice choice)
|
||||
{
|
||||
if (choice == null || !ReferenceEquals(choice.Owner, this))
|
||||
throw new ArgumentException("Foreign contour choice.");
|
||||
PostVerificationGeometry.Validate(choice.Point);
|
||||
var shape = GetShape(choice.ContourOrdinal);
|
||||
if (choice.EntityOrdinal < 0 || choice.EntityOrdinal >= shape.Entities.Count)
|
||||
throw new ArgumentException("Foreign entity ordinal.");
|
||||
var distance = shape.Entities[choice.EntityOrdinal].ClosestPointTo(choice.Point).DistanceTo(choice.Point);
|
||||
if (!double.IsFinite(distance) || distance > PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("Entry is not on the selected native entity.");
|
||||
// A pinned contour is cut exactly as pinned: no other entry may be emitted for it.
|
||||
if (pinned[choice.ContourOrdinal] is { } pin && pin != choice)
|
||||
throw new ArgumentException("A pinned contour keeps its drawing pin's entry.");
|
||||
if (choice.Pierce is { } pierce)
|
||||
{
|
||||
PostVerificationGeometry.Validate(pierce);
|
||||
if (choice.ContourOrdinal != PerimeterOrdinal)
|
||||
throw new ArgumentException("Only a perimeter entry can be pierced in scrap.");
|
||||
if (pierce.DistanceTo(choice.Point) <= PostVerificationGeometry.Epsilon)
|
||||
throw new ArgumentException("A scrap pierce must lie off its contour.");
|
||||
}
|
||||
}
|
||||
|
||||
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
|
||||
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
|
||||
|
||||
/// <summary>
|
||||
/// Representative POINTS per contour ordinal (bounding-box centre) for hole routing
|
||||
/// only — the S10 <c>CuttingHoleOrder</c> proxy. The perimeter has no entry; it is the
|
||||
/// route's fixed endpoint, not a stop. These are ordering proxies, never cut points.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<Vector?> HoleCentres(CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var centres = new Vector?[Count];
|
||||
for (var contour = 0; contour < PerimeterOrdinal; contour++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
centres[contour] = GetShape(contour).BoundingBox.Center;
|
||||
}
|
||||
return centres;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>.
|
||||
/// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
|
||||
/// </summary>
|
||||
internal bool HoleContains(int contourOrdinal, Vector point)
|
||||
{
|
||||
var shape = GetShape(contourOrdinal);
|
||||
return contourOrdinal != PerimeterOrdinal && shape.BoundingBox.Contains(point)
|
||||
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
|
||||
}
|
||||
|
||||
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
|
||||
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
|
||||
|
||||
private static Program CopyForGeometry(Program source, CancellationToken token)
|
||||
{
|
||||
var copies = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
|
||||
return Copy(source);
|
||||
|
||||
Program Copy(Program current)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (copies.TryGetValue(current, out var existing))
|
||||
return existing;
|
||||
var copy = new Program(current.Mode);
|
||||
copies.Add(current, copy);
|
||||
foreach (var code in current.Codes)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var ownedCode = code.Clone();
|
||||
if (ownedCode is SubProgramCall call)
|
||||
call.BindProgram(Copy(((SubProgramCall)code).Program));
|
||||
copy.Codes.Add(ownedCode);
|
||||
}
|
||||
copy.Mode = Mode.Incremental;
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,420 @@
|
||||
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;
|
||||
|
||||
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);
|
||||
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.
|
||||
/// </summary>
|
||||
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed) Check(
|
||||
OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed,
|
||||
CancellationToken token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(execution);
|
||||
if (arrival is { } point)
|
||||
PostVerificationGeometry.Validate(point);
|
||||
var moves = execution.Motions.ToArray();
|
||||
moves[0] = moves[0].WithStart(arrival);
|
||||
var findings = new List<PostVerificationFinding>();
|
||||
var fixedFindings = new List<PostVerificationFinding>();
|
||||
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings);
|
||||
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var current = new CutGroup();
|
||||
var unfinished = new List<CutGroup>();
|
||||
// Part-local cuts read only by the open-cut proof below: closed contours, break cuts
|
||||
// and leads no contour owns. Each lead belongs to the cut it enters or leaves.
|
||||
var closed = new List<CutGroup>();
|
||||
var otherOpen = new List<CutGroup>();
|
||||
var loose = new CutGroup();
|
||||
var leadIns = new List<PostVerificationGeometry.Curve>();
|
||||
CutGroup previous = null;
|
||||
var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>();
|
||||
var hasLead = false;
|
||||
var contourNumber = 0;
|
||||
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 has no nonzero placed lead-in motion.")
|
||||
{ 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;
|
||||
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
|
||||
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
||||
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
||||
$"of part {obstacle.Part}.")
|
||||
{
|
||||
Location = (start + move.End) * 0.5,
|
||||
RapidStart = start,
|
||||
RapidEnd = move.End,
|
||||
ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token)
|
||||
}, owned);
|
||||
}
|
||||
}
|
||||
else if (move.Layer == LayerType.Leadin)
|
||||
{
|
||||
Finish();
|
||||
previous = null;
|
||||
hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon;
|
||||
if (move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||
leadIns.Add(move.Curve);
|
||||
}
|
||||
else if (move.Layer is LayerType.Leadout or LayerType.Scribe)
|
||||
{
|
||||
if (move.Layer == LayerType.Leadout && 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} has no nonzero placed lead-in motion.")
|
||||
{ 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();
|
||||
}
|
||||
}
|
||||
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));
|
||||
|
||||
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 && PostVerificationGeometry.Closed(current.Curves))
|
||||
{
|
||||
obstacles.Add(new(part, contourNumber, current.Curves.ToArray()));
|
||||
closed.Add(current);
|
||||
}
|
||||
else if (!cutoff)
|
||||
unfinished.Add(current);
|
||||
current = new CutGroup();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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 ArcMove { Layer: LayerType.Cut or LayerType.Display };
|
||||
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,258 @@
|
||||
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);
|
||||
Lead(p.ArcCircleLeadIn);
|
||||
Out(p.ArcCircleLeadOut);
|
||||
Number(p.PierceClearance);
|
||||
Number(p.PierceDiameter);
|
||||
Flag(p.RoundLeadInAngles);
|
||||
Number(p.LeadInAngleIncrement);
|
||||
Number(p.AutoTabMinSize);
|
||||
Number(p.AutoTabMaxSize);
|
||||
TabSettings(p.TabConfig);
|
||||
Flag(p.TabsEnabled);
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
@@ -19,6 +20,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
|
||||
{
|
||||
var entities = partProgram.ToGeometry();
|
||||
var breakCuts = TakeBreakCuts(entities);
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||
|
||||
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
|
||||
@@ -77,10 +79,13 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var result = new Program(Mode.Absolute);
|
||||
|
||||
EmitScribeContours(result, scribeEntities);
|
||||
EmitBreakCuts(result, breakCuts, BreakCutContours(profile));
|
||||
|
||||
foreach (var entry in cutoutEntries)
|
||||
{
|
||||
if (!entry.Shape.IsClosed())
|
||||
if (IsTabbedCutout(entry.Shape))
|
||||
EmitTabbedCutout(result, entry.Shape);
|
||||
else if (!entry.Shape.IsClosed())
|
||||
EmitRawContour(result, entry.Shape);
|
||||
else
|
||||
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
|
||||
@@ -110,6 +115,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
)
|
||||
{
|
||||
var entities = partProgram.ToGeometry();
|
||||
var breakCuts = TakeBreakCuts(entities);
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||
|
||||
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
|
||||
@@ -120,14 +126,20 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var result = new Program(Mode.Absolute);
|
||||
|
||||
EmitScribeContours(result, scribeEntities);
|
||||
EmitBreakCuts(result, breakCuts, BreakCutContours(profile));
|
||||
|
||||
// Find the target shape that contains the clicked entity
|
||||
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
|
||||
|
||||
// Emit cutouts — only the target gets lead-in/out (skip open contours)
|
||||
// Emit cutouts — only the target gets lead-in/out (skip open contours)
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
if (!cutout.IsClosed())
|
||||
// A tabbed cutout's pieces are always entered from its slug, like break cuts.
|
||||
if (IsTabbedCutout(cutout))
|
||||
{
|
||||
EmitTabbedCutout(result, cutout);
|
||||
}
|
||||
else if (!cutout.IsClosed())
|
||||
{
|
||||
EmitRawContour(result, cutout);
|
||||
}
|
||||
@@ -234,6 +246,64 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <param name="contourParameters">Per-contour settings by contour ordinal (a drawing pin's
|
||||
/// own lead-in, lead-out and tab); a null list or entry uses <see cref="Parameters"/>.</param>
|
||||
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
|
||||
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
|
||||
IReadOnlyList<Shape> tabbedCutouts = null)
|
||||
{
|
||||
var result = new Program(Mode.Absolute);
|
||||
EmitScribeContours(result, scribes);
|
||||
EmitBreakCuts(result, breakCuts, shapes);
|
||||
foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>())
|
||||
EmitTabbedCutout(result, cutout);
|
||||
EmitChosenContours(result, shapes, choices, contourParameters);
|
||||
result.Mode = Mode.Incremental;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Diagnostic seam for candidate lead validation (PreparedContours
|
||||
// .EmitCandidateForValidation): emit a single already-validated choice with its
|
||||
// ORIGINAL contour type — the last shape is External even when emitted alone, so a
|
||||
// perimeter never degrades to a hole because it was isolated. Output must not be
|
||||
// installed on a Part or accepted as a complete plan.
|
||||
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes,
|
||||
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null)
|
||||
{
|
||||
var result = new Program(Mode.Absolute);
|
||||
EmitScribeContours(result, scribes);
|
||||
EmitChosenContours(result, shapes, new[] { choice }, contourParameters);
|
||||
result.Mode = Mode.Incremental;
|
||||
return result;
|
||||
}
|
||||
|
||||
// One contour per choice in order; the perimeter (last shape) is always External.
|
||||
// Contour emission itself reads only the contour's own geometry and settings, never
|
||||
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
|
||||
private void EmitChosenContours(Program result, Shape[] shapes,
|
||||
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters)
|
||||
{
|
||||
var shared = Parameters;
|
||||
try
|
||||
{
|
||||
foreach (var choice in choices)
|
||||
{
|
||||
// A contour's own settings apply to its emission alone; scribes and every
|
||||
// other contour keep the shared settings.
|
||||
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
|
||||
var shape = shapes[choice.ContourOrdinal];
|
||||
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
|
||||
exactCirclePrograms: true, scrapPierce: choice.Pierce);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Parameters = shared;
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitRawContour(Program program, Shape shape)
|
||||
{
|
||||
var startPoint = GetShapeStartPoint(shape);
|
||||
@@ -300,12 +370,58 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return HashCode.Combine(r, a);
|
||||
}
|
||||
|
||||
// Prepared emission compares the actual resolved, generated motions, not a
|
||||
// rounded geometry hash. Labels are deterministic encounter-order identifiers;
|
||||
// legacy Apply/ApplySingle retain their existing cache and labels unchanged.
|
||||
private static int RegisterPreparedCircleProgram(Program owner, Program generated)
|
||||
{
|
||||
CuttingPlanning.ExecutionMotionReader.ReadSupported(generated, Vector.Zero, null);
|
||||
if (generated.Variables.Count != 0 || generated.SubPrograms.Count != 0)
|
||||
throw new NotSupportedException("Unsupported generated circle program metadata.");
|
||||
foreach (var code in generated.Codes)
|
||||
if (code is not (RapidMove or LinearMove or ArcMove) || ((Motion)code).VariableRefs != null)
|
||||
throw new NotSupportedException("Unsupported generated circle instruction.");
|
||||
foreach (var pair in owner.SubPrograms)
|
||||
if (SameGeneratedCircleProgram(pair.Value, generated))
|
||||
return pair.Key;
|
||||
var key = checked(owner.SubPrograms.Count + 1);
|
||||
owner.SubPrograms.Add(key, generated);
|
||||
return key;
|
||||
}
|
||||
|
||||
private static bool SameGeneratedCircleProgram(Program a, Program b)
|
||||
{
|
||||
if (a.Mode != b.Mode || !SameBits(a.Rotation, b.Rotation) || a.Codes.Count != b.Codes.Count)
|
||||
return false;
|
||||
for (var i = 0; i < a.Codes.Count; i++)
|
||||
{
|
||||
var left = (Motion)a.Codes[i];
|
||||
var right = (Motion)b.Codes[i];
|
||||
if (left.GetType() != right.GetType() || left.Suppressed != right.Suppressed
|
||||
|| left.UseExactStop != right.UseExactStop || left.Feedrate != right.Feedrate
|
||||
|| !SameVector(left.EndPoint, right.EndPoint))
|
||||
return false;
|
||||
if (left is LinearMove line && line.Layer != ((LinearMove)right).Layer)
|
||||
return false;
|
||||
if (left is ArcMove arc && (arc.Layer != ((ArcMove)right).Layer
|
||||
|| arc.Rotation != ((ArcMove)right).Rotation
|
||||
|| !SameVector(arc.CenterPoint, ((ArcMove)right).CenterPoint)))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y);
|
||||
private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
|
||||
|
||||
private void EmitContour(
|
||||
Program program,
|
||||
Shape shape,
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType? forceType = null
|
||||
ContourType? forceType = null,
|
||||
bool exactCirclePrograms = false,
|
||||
Vector? scrapPierce = null
|
||||
)
|
||||
{
|
||||
var contourType = forceType ?? DetectContourType(shape);
|
||||
@@ -330,7 +446,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
);
|
||||
}
|
||||
|
||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal);
|
||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
|
||||
Parameters.GetEffectivePierceClearance());
|
||||
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius());
|
||||
|
||||
// Build hole sub-program relative to (0,0)
|
||||
var holeCenter = circle.Center;
|
||||
@@ -351,7 +469,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
subPgm.Mode = Mode.Incremental;
|
||||
|
||||
// Deduplicate: check if an identical sub-program already exists
|
||||
var key = ComputeSubProgramKey(circle.Radius, normal);
|
||||
var key = exactCirclePrograms
|
||||
? RegisterPreparedCircleProgram(program, subPgm)
|
||||
: ComputeSubProgramKey(circle.Radius, normal);
|
||||
if (!program.SubPrograms.ContainsKey(key))
|
||||
program.SubPrograms[key] = subPgm;
|
||||
|
||||
@@ -367,19 +487,49 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return;
|
||||
}
|
||||
|
||||
program.Codes.AddRange(leadIn.Generate(point, normal, winding));
|
||||
double leadInNormal;
|
||||
// A planner-placed scrap pierce replaces the configured lead-in with a straight one.
|
||||
if (scrapPierce is { } pierce)
|
||||
leadIn = StraightLeadIn(point, pierce, out leadInNormal);
|
||||
else
|
||||
{
|
||||
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
||||
Parameters.GetEffectivePierceClearance(), out leadInNormal);
|
||||
if (contourType == ContourType.Internal)
|
||||
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius());
|
||||
}
|
||||
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
|
||||
|
||||
var reindexedShape = shape.ReindexAt(point, entity);
|
||||
|
||||
if (
|
||||
Parameters.TabsEnabled
|
||||
var tabbed = Parameters.TabsEnabled
|
||||
&& Parameters.TabConfig != null
|
||||
&& contourType == ContourType.External
|
||||
)
|
||||
&& contourType == ContourType.External;
|
||||
|
||||
// A tab leaves the contour short of the corner; a run-out through it would cut the tab.
|
||||
var leadOutPoint = point;
|
||||
var leadOutNormal = normal;
|
||||
if (tabbed)
|
||||
{
|
||||
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
||||
|
||||
// Leave from where the trimmed cut actually ends, on that entity's normal:
|
||||
// an arc generated at the nominal entry would not start on its own radius.
|
||||
if (reindexedShape.Entities.Count > 0 && reindexedShape.Entities[^1] is Line or Arc)
|
||||
{
|
||||
var last = reindexedShape.Entities[^1];
|
||||
leadOutPoint = EntityEndPoint(last);
|
||||
leadOutNormal = ComputeNormal(leadOutPoint, last, contourType, winding);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
leadOut = ResolveLeadOut(shape, point, entity, contourType, leadOut, winding,
|
||||
Parameters.GetEffectivePierceClearance(), out leadOutNormal);
|
||||
}
|
||||
|
||||
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||
program.Codes.AddRange(leadOut.Generate(point, normal, winding));
|
||||
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, winding));
|
||||
}
|
||||
|
||||
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
||||
@@ -436,6 +586,291 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return ContourType.Internal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses the inward angle bisector for straight lead-ins at cutout corners.
|
||||
/// Edge interiors and other lead-in styles keep the entity normal. Shared
|
||||
/// by program generation and the manual placement preview.
|
||||
/// </summary>
|
||||
public static double ComputeLeadInNormal(
|
||||
Shape shape,
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType contourType,
|
||||
LeadIn leadIn,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var normal = ComputeNormal(point, entity, contourType, winding);
|
||||
if (contourType != ContourType.Internal || leadIn is not LineLeadIn
|
||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||
return normal;
|
||||
|
||||
return BisectCorner(point, corner, contourType, winding) ?? normal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the lead-in to emit at <paramref name="point"/> and the normal to
|
||||
/// generate it with. At a corner of an outside perimeter, a straight
|
||||
/// (<see cref="LineLeadIn"/>) lead-in extends the edge cut first so the torch
|
||||
/// enters on that edge's line, provided the pierce keeps
|
||||
/// <paramref name="pierceClearance"/> from the contour; the approach angle is
|
||||
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a
|
||||
/// reflex corner it bisects the notch. The result does not depend on which of
|
||||
/// the two edges meeting at the corner was picked. Other styles and contour
|
||||
/// types keep <see cref="ComputeLeadInNormal"/>.
|
||||
/// </summary>
|
||||
public static LeadIn ResolveLeadIn(
|
||||
Shape shape,
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType contourType,
|
||||
LeadIn leadIn,
|
||||
RotationType winding,
|
||||
double pierceClearance,
|
||||
out double normal
|
||||
)
|
||||
{
|
||||
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
|
||||
if (contourType != ContourType.External || leadIn is not LineLeadIn line
|
||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||
return leadIn;
|
||||
|
||||
switch (ClassifyCorner(corner, winding))
|
||||
{
|
||||
case CornerKind.Convex:
|
||||
var pierce = point - corner.TangentOut * line.Length;
|
||||
if (IsClearStraightLead(shape, point, pierce, pierceClearance))
|
||||
{
|
||||
normal = Angle.NormalizeRad((-corner.TangentOut).Angle());
|
||||
return new LineLeadIn { Length = line.Length, ApproachAngle = 90 };
|
||||
}
|
||||
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||
return leadIn;
|
||||
case CornerKind.Smooth:
|
||||
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||
return leadIn;
|
||||
case CornerKind.Reflex:
|
||||
normal = BisectCorner(point, corner, contourType, winding) ?? normal;
|
||||
return leadIn;
|
||||
default:
|
||||
return leadIn;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lead-out counterpart of <see cref="ResolveLeadIn"/>. At a convex outside
|
||||
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner
|
||||
/// along the edge cut last, when its end keeps <paramref name="clearance"/> from
|
||||
/// the contour; otherwise it is perpendicular to that edge. At a reflex corner it
|
||||
/// bisects the notch. Other styles and contour types keep the entity normal.
|
||||
/// </summary>
|
||||
public static LeadOut ResolveLeadOut(
|
||||
Shape shape,
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType contourType,
|
||||
LeadOut leadOut,
|
||||
RotationType winding,
|
||||
double clearance,
|
||||
out double normal
|
||||
)
|
||||
{
|
||||
normal = ComputeNormal(point, entity, contourType, winding);
|
||||
if (contourType != ContourType.External || leadOut is not LineLeadOut line
|
||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||
return leadOut;
|
||||
|
||||
switch (ClassifyCorner(corner, winding))
|
||||
{
|
||||
case CornerKind.Convex:
|
||||
var end = point + corner.TangentIn * line.Length;
|
||||
if (IsClearStraightLead(shape, point, end, clearance))
|
||||
{
|
||||
normal = Angle.NormalizeRad(corner.TangentIn.Angle());
|
||||
return new LineLeadOut { Length = line.Length, ApproachAngle = 90 };
|
||||
}
|
||||
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||
return leadOut;
|
||||
case CornerKind.Smooth:
|
||||
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||
return leadOut;
|
||||
case CornerKind.Reflex:
|
||||
normal = BisectCorner(point, corner, contourType, winding) ?? normal;
|
||||
return leadOut;
|
||||
default:
|
||||
return leadOut;
|
||||
}
|
||||
}
|
||||
|
||||
internal enum CornerKind
|
||||
{
|
||||
Convex,
|
||||
Reflex,
|
||||
Smooth,
|
||||
Cusp,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A vertex classified for automatic start-point planning: what kind of turn the
|
||||
/// contour makes there, and the travel tangents of the two edges it joins. Read-only
|
||||
/// so callers cannot mutate the contour; winding is derived the same way
|
||||
/// <see cref="EmitContour"/> derives it, so the kind matches actual emission.
|
||||
/// </summary>
|
||||
internal readonly record struct AutomaticCorner(CornerKind Kind, Vector TangentIn, Vector TangentOut);
|
||||
|
||||
/// <summary>
|
||||
/// Classification query shared with entry planning: the turn at <paramref name="point"/>
|
||||
/// on a closed line/arc contour, using the same corner geometry and winding derivation
|
||||
/// as emission. False when <paramref name="point"/> is not a shared vertex of two
|
||||
/// chainable entities (an interior point, an open contour, a degenerate or non-finite
|
||||
/// corner). Lead generation itself is not involved.
|
||||
/// </summary>
|
||||
internal static bool TryClassifyAutomaticStartCorner(
|
||||
Shape shape,
|
||||
Vector point,
|
||||
Entity entity,
|
||||
out AutomaticCorner corner)
|
||||
{
|
||||
if (!TryGetCorner(shape, point, entity, out var raw))
|
||||
{
|
||||
corner = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
corner = new AutomaticCorner(
|
||||
ClassifyCorner(raw, DetermineWinding(shape)), raw.TangentIn, raw.TangentOut);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>A contour vertex: the entity cut into it and the one cut away from it.</summary>
|
||||
private readonly record struct ContourCorner(
|
||||
Entity Incoming,
|
||||
Entity Outgoing,
|
||||
Vector TangentIn,
|
||||
Vector TangentOut
|
||||
);
|
||||
|
||||
private static bool TryGetCorner(Shape shape, Vector point, Entity entity, out ContourCorner corner)
|
||||
{
|
||||
corner = default;
|
||||
if (entity is not (Line or Arc) || entity.Length <= Tolerance.Epsilon
|
||||
|| shape.Entities.Count < 2 || !shape.IsClosed())
|
||||
return false;
|
||||
|
||||
var index = shape.Entities.IndexOf(entity);
|
||||
if (index < 0)
|
||||
return false;
|
||||
|
||||
var atStart = point.DistanceTo(EntityStartPoint(entity)) <= Tolerance.Epsilon;
|
||||
if (!atStart && point.DistanceTo(EntityEndPoint(entity)) > Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
var adjacentIndex = atStart
|
||||
? (index + shape.Entities.Count - 1) % shape.Entities.Count
|
||||
: (index + 1) % shape.Entities.Count;
|
||||
var adjacent = shape.Entities[adjacentIndex];
|
||||
var adjacentPoint = atStart ? EntityEndPoint(adjacent) : EntityStartPoint(adjacent);
|
||||
|
||||
if (adjacent is not (Line or Arc) || adjacent.Length <= Tolerance.Epsilon
|
||||
|| point.DistanceTo(adjacentPoint) > Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
var incoming = atStart ? adjacent : entity;
|
||||
var outgoing = atStart ? entity : adjacent;
|
||||
var tangentIn = TravelTangent(incoming, point);
|
||||
var tangentOut = TravelTangent(outgoing, point);
|
||||
if (!IsFinite(tangentIn) || !IsFinite(tangentOut))
|
||||
return false;
|
||||
|
||||
corner = new ContourCorner(incoming, outgoing, tangentIn, tangentOut);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Unit direction of travel along a line or arc at a point on it.</summary>
|
||||
private static Vector TravelTangent(Entity entity, Vector point)
|
||||
{
|
||||
if (entity is Line line)
|
||||
return (line.EndPoint - line.StartPoint).Normalize();
|
||||
|
||||
var arc = (Arc)entity;
|
||||
var radial = (point - arc.Center).Normalize();
|
||||
return arc.IsReversed ? new Vector(radial.Y, -radial.X) : new Vector(-radial.Y, radial.X);
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector v) => double.IsFinite(v.X) && double.IsFinite(v.Y);
|
||||
|
||||
/// <summary>
|
||||
/// Convex corners point away from the part (interior angle under 180 degrees).
|
||||
/// A turn whose offset over the tangent is within chaining tolerance is smooth,
|
||||
/// not a corner.
|
||||
/// </summary>
|
||||
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding)
|
||||
{
|
||||
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X;
|
||||
var dot = corner.TangentIn.DotProduct(corner.TangentOut);
|
||||
var turn = winding == RotationType.CCW ? cross : -cross;
|
||||
|
||||
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
|
||||
return dot > 0 ? CornerKind.Smooth : CornerKind.Cusp;
|
||||
|
||||
return turn > 0 ? CornerKind.Convex : CornerKind.Reflex;
|
||||
}
|
||||
|
||||
private static double? BisectCorner(
|
||||
Vector point,
|
||||
ContourCorner corner,
|
||||
ContourType contourType,
|
||||
RotationType winding
|
||||
)
|
||||
{
|
||||
var normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||
var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||
// Sum unit normals rather than averaging angles (which fails at 0/2Ï€).
|
||||
// Winding makes this point into the scrap even at reflex corners.
|
||||
var x = System.Math.Cos(normal) + System.Math.Cos(adjacentNormal);
|
||||
var y = System.Math.Sin(normal) + System.Math.Sin(adjacentNormal);
|
||||
if (!double.IsFinite(x) || !double.IsFinite(y)
|
||||
|| x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
|
||||
return null; // Opposing normals at a cusp have no unique bisector.
|
||||
|
||||
return Angle.NormalizeRad(System.Math.Atan2(y, x));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A straight lead from <paramref name="end"/> to the corner stays in the scrap:
|
||||
/// its free end keeps <paramref name="clearance"/> from the contour and the lead
|
||||
/// crosses the contour nowhere but at the corner.
|
||||
/// </summary>
|
||||
private static bool IsClearStraightLead(Shape shape, Vector corner, Vector end, double clearance)
|
||||
{
|
||||
if (!IsFinite(end) || end.DistanceTo(corner) <= Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
var nearest = shape.ClosestPointTo(end, out _);
|
||||
if (nearest.DistanceTo(end) < System.Math.Max(clearance, 0) - Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
if (shape.Intersects(new Line(end, corner), out var crossings))
|
||||
{
|
||||
foreach (var crossing in crossings)
|
||||
{
|
||||
if (crossing.DistanceTo(corner) > Tolerance.ChainTolerance)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Vector EntityEndPoint(Entity entity)
|
||||
{
|
||||
if (entity is Line line)
|
||||
return line.EndPoint;
|
||||
if (entity is Arc arc)
|
||||
return arc.EndPoint();
|
||||
return Vector.Invalid;
|
||||
}
|
||||
|
||||
public static double ComputeNormal(
|
||||
Vector point,
|
||||
Entity entity,
|
||||
@@ -447,7 +882,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
if (entity is Line line)
|
||||
{
|
||||
// Perpendicular to line direction: tangent + π/2 = left side.
|
||||
// Perpendicular to line direction: tangent + π/2 = left side.
|
||||
// Left side = outward for CW winding; for CCW winding, outward
|
||||
// is on the right side, so flip.
|
||||
var tangent = line.EndPoint.AngleFrom(line.StartPoint);
|
||||
@@ -458,7 +893,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
// Radial direction from center to point.
|
||||
// Flip when the arc direction differs from the contour winding —
|
||||
// Flip when the arc direction differs from the contour winding —
|
||||
// that indicates a concave feature where radial points inward.
|
||||
normal = point.AngleFrom(arc.Center);
|
||||
if (arc.Rotation != winding)
|
||||
@@ -466,7 +901,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
// Radial outward — always correct regardless of winding
|
||||
// Radial outward — always correct regardless of winding
|
||||
normal = point.AngleFrom(circle.Center);
|
||||
}
|
||||
else
|
||||
@@ -494,18 +929,24 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return polygon.RotationDirection();
|
||||
}
|
||||
|
||||
private LeadIn ClampLeadInForCircle(
|
||||
/// <summary>
|
||||
/// Shortens a round hole's lead-in so its pierce keeps <paramref name="clearance"/> from
|
||||
/// the hole wall. A hole no wider than twice the clearance keeps the lead as it is.
|
||||
/// Shared by program generation and the manual placement preview.
|
||||
/// </summary>
|
||||
public static LeadIn ClampLeadInForCircle(
|
||||
LeadIn leadIn,
|
||||
Circle circle,
|
||||
Vector contourPoint,
|
||||
double normalAngle
|
||||
double normalAngle,
|
||||
double clearance
|
||||
)
|
||||
{
|
||||
if (leadIn is NoLeadIn || Parameters.PierceClearance <= 0)
|
||||
if (leadIn is NoLeadIn || clearance <= 0)
|
||||
return leadIn;
|
||||
|
||||
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle);
|
||||
var maxRadius = circle.Radius - Parameters.PierceClearance;
|
||||
var maxRadius = circle.Radius - clearance;
|
||||
if (maxRadius <= 0)
|
||||
return leadIn;
|
||||
|
||||
@@ -543,6 +984,85 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return leadIn.Scale(scale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a hole's lead-in pierce <paramref name="pierceRadius"/> clear of the hole wall.
|
||||
/// A lead-in whose pierce already keeps it, or that pierces on the contour itself, is
|
||||
/// returned as given. Otherwise the pierce moves onto the line from
|
||||
/// <paramref name="point"/> toward the hole's pole of inaccessibility (the centre of a
|
||||
/// round hole), as close to the contour as the zone allows, and the lead becomes a
|
||||
/// straight line from there, generated with <paramref name="normal"/>. A hole too small
|
||||
/// for the zone is pierced at the pole itself, its best clearance; the lead validator and
|
||||
/// pre-post review still report the shortfall. Reads only the hole's own geometry, so it
|
||||
/// knows nothing of parts nested inside the hole. Shared by program generation and the
|
||||
/// manual placement preview.
|
||||
/// </summary>
|
||||
public static LeadIn ResolvePierceZone(Shape hole, Vector point, LeadIn leadIn, double normal,
|
||||
double pierceRadius)
|
||||
{
|
||||
if (pierceRadius <= 0 || !double.IsFinite(pierceRadius) || leadIn is NoLeadIn
|
||||
|| !hole.IsClosed())
|
||||
return leadIn;
|
||||
|
||||
var circle = hole.Entities.Count == 1 ? hole.Entities[0] as Circle : null;
|
||||
var polygon = circle == null
|
||||
? hole.ToPolygonWithTolerance(System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance))
|
||||
: null;
|
||||
if (Clear(leadIn.GetPiercePoint(point, normal)))
|
||||
return leadIn;
|
||||
|
||||
var pole = circle?.Center ?? PolyLabel.Find(polygon, null,
|
||||
System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance), PoleSearchCells);
|
||||
if (!Inside(pole))
|
||||
return leadIn;
|
||||
|
||||
// Clearance grows monotonically toward the pole of a convex hole, so the bisection
|
||||
// finds the shortest clear lead; in other holes it still ends on a clear point.
|
||||
var pierce = pole;
|
||||
if (Clear(pole))
|
||||
{
|
||||
double low = 0, high = 1;
|
||||
for (var i = 0; i < 40; i++)
|
||||
{
|
||||
var middle = (low + high) / 2;
|
||||
if (Clear(point + (pole - point) * middle))
|
||||
high = middle;
|
||||
else
|
||||
low = middle;
|
||||
}
|
||||
pierce = point + (pole - point) * high;
|
||||
}
|
||||
|
||||
var length = pierce.DistanceTo(point);
|
||||
if (length <= Math.Tolerance.Epsilon)
|
||||
return leadIn;
|
||||
var direction = System.Math.Atan2(pierce.Y - point.Y, pierce.X - point.X);
|
||||
return new LineLeadIn
|
||||
{
|
||||
Length = length,
|
||||
ApproachAngle = Angle.ToDegrees(Angle.NormalizeRad(direction - normal + Angle.HalfPI)),
|
||||
};
|
||||
|
||||
bool Inside(Vector p) => circle != null
|
||||
? p.DistanceTo(circle.Center) < circle.Radius
|
||||
: polygon.ContainsPoint(p);
|
||||
|
||||
bool Clear(Vector p) => double.IsFinite(p.X) && double.IsFinite(p.Y) && Inside(p)
|
||||
&& hole.ClosestPointTo(p, out _).DistanceTo(p) >= pierceRadius;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A straight lead-in from <paramref name="pierce"/> to <paramref name="point"/>, with the
|
||||
/// normal to generate it with.
|
||||
/// </summary>
|
||||
private static LeadIn StraightLeadIn(Vector point, Vector pierce, out double normal)
|
||||
{
|
||||
normal = System.Math.Atan2(pierce.Y - point.Y, pierce.X - point.X);
|
||||
return new LineLeadIn { Length = pierce.DistanceTo(point), ApproachAngle = 90 };
|
||||
}
|
||||
|
||||
// Bounds the pole search on long, thin holes; the result is checked before use.
|
||||
private const int PoleSearchCells = 4096;
|
||||
|
||||
private LeadIn SelectLeadIn(ContourType contourType)
|
||||
{
|
||||
return contourType switch
|
||||
@@ -605,7 +1125,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
break;
|
||||
}
|
||||
|
||||
// No intersection — entity is entirely inside circle, remove it
|
||||
// No intersection — entity is entirely inside circle, remove it
|
||||
if (EntityStartPoint(entity).DistanceTo(center) <= tabSize + Tolerance.Epsilon)
|
||||
{
|
||||
entities.RemoveAt(entities.Count - 1);
|
||||
@@ -636,15 +1156,27 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
)
|
||||
{
|
||||
var moves = new List<ICode>();
|
||||
var position = startPoint;
|
||||
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
moves.Add(new LinearMove(line.EndPoint) { Layer = layer });
|
||||
position = line.EndPoint;
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
// A contour entered away from its seam crosses the seam's admitted closure gap
|
||||
// mid-cut. Starting the arc there would move its start off its own circle, so
|
||||
// bridge the gap with a straight move and cut the arc exactly as drawn. A lead-in
|
||||
// must join the contour itself, so an entry on the seam is left as it was.
|
||||
var arcStart = arc.StartPoint();
|
||||
var gap = position.DistanceTo(arcStart);
|
||||
if (moves.Count > 0 && gap > Diagnostics.PostVerificationGeometry.Epsilon
|
||||
&& CuttingPlanning.NominalContourClosure.IsClosed(position, arcStart))
|
||||
moves.Add(new LinearMove(arcStart) { Layer = layer });
|
||||
position = arc.EndPoint();
|
||||
moves.Add(
|
||||
new ArcMove(
|
||||
arc.EndPoint(),
|
||||
@@ -661,6 +1193,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
moves.Add(
|
||||
new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer }
|
||||
);
|
||||
position = startPoint;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -673,6 +1206,253 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return moves;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes break-cut entities from <paramref name="entities"/> (in program order) and
|
||||
/// returns them as travel-ordered open chains, one per uninterrupted run.
|
||||
/// </summary>
|
||||
internal static List<Shape> TakeBreakCuts(List<Entity> entities)
|
||||
{
|
||||
var cuts = new List<Shape>();
|
||||
Shape current = null;
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
if (entity.Layer != SpecialLayers.Break)
|
||||
{
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
if (current == null
|
||||
|| EntityEndPoint(current.Entities[^1]).DistanceTo(EntityStartPoint(entity)) > Tolerance.Epsilon)
|
||||
{
|
||||
current = new Shape();
|
||||
cuts.Add(current);
|
||||
}
|
||||
current.Entities.Add(entity);
|
||||
}
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Break);
|
||||
foreach (var cut in cuts)
|
||||
cut.UpdateBounds();
|
||||
return cuts;
|
||||
}
|
||||
|
||||
// A clean program's break cut travels with its scrap on the left. It is entered from the
|
||||
// scrap, with the internal lead-in (holes are pierced in scrap the same way), at whichever
|
||||
// end has more scrap before the part's contours: near the outline the scrap narrows to a
|
||||
// wedge. NoLeadIn pierces on the line. There is no lead-out: the cut ends at a tab, which a
|
||||
// run-out would cut into. Entering at the far end cuts it the other way round.
|
||||
private void EmitBreakCuts(Program program, IReadOnlyList<Shape> cuts, IReadOnlyList<Shape> contours)
|
||||
{
|
||||
if (cuts == null)
|
||||
return;
|
||||
var leadIn = Parameters?.InternalLeadIn ?? new NoLeadIn();
|
||||
foreach (var cut in cuts)
|
||||
{
|
||||
var (entry, scrap, fromEnd) = BreakCutEntry(cut, contours);
|
||||
var path = cut;
|
||||
if (fromEnd)
|
||||
{
|
||||
path = (Shape)cut.Clone();
|
||||
path.Reverse();
|
||||
}
|
||||
var normal = Angle.NormalizeRad(System.Math.Atan2(scrap.Y, scrap.X));
|
||||
program.Codes.AddRange(leadIn.Generate(entry, normal, fromEnd ? RotationType.CW : RotationType.CCW));
|
||||
program.Codes.AddRange(ConvertShapeToMoves(path, entry, LayerType.Break));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where a break cut (travelling with its scrap on the left) is entered: the end with more
|
||||
/// scrap before <paramref name="contours"/>, the unit direction into the scrap there, and
|
||||
/// whether that is the cut's far end.
|
||||
/// </summary>
|
||||
public static (Vector Entry, Vector ScrapDirection, bool FromEnd) BreakCutEntry(Shape cut,
|
||||
IReadOnlyList<Shape> contours)
|
||||
{
|
||||
var start = EntityStartPoint(cut.Entities[0]);
|
||||
var end = EntityEndPoint(cut.Entities[^1]);
|
||||
var startScrap = LeftOf(Tangent(cut.Entities[0], atEnd: false));
|
||||
var endScrap = LeftOf(Tangent(cut.Entities[^1], atEnd: true));
|
||||
return Room(end, endScrap, contours) > Room(start, startScrap, contours)
|
||||
? (end, endScrap, true)
|
||||
: (start, startScrap, false);
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="shape"/> has <see cref="SpecialLayers.Tab"/> spans: a tabbed cutout.</summary>
|
||||
internal static bool IsTabbedCutout(Shape shape) => shape.Entities.Exists(e => e.Layer == SpecialLayers.Tab);
|
||||
|
||||
/// <summary>
|
||||
/// The cut pieces of a closed tabbed cutout in its own travel order, starting after a tab:
|
||||
/// each maximal run of entities that are not on the tab layer.
|
||||
/// </summary>
|
||||
internal static List<Shape> TabbedCutoutPieces(Shape cutout)
|
||||
{
|
||||
var entities = cutout.Entities;
|
||||
var count = entities.Count;
|
||||
var first = -1;
|
||||
for (var i = 0; i < count && first < 0; i++)
|
||||
if (entities[i].Layer != SpecialLayers.Tab && entities[(i + count - 1) % count].Layer == SpecialLayers.Tab)
|
||||
first = i;
|
||||
var pieces = new List<Shape>();
|
||||
if (first < 0)
|
||||
return pieces;
|
||||
Shape current = null;
|
||||
for (var k = 0; k < count; k++)
|
||||
{
|
||||
var entity = entities[(first + k) % count];
|
||||
if (entity.Layer == SpecialLayers.Tab)
|
||||
{
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
if (current == null)
|
||||
{
|
||||
current = new Shape();
|
||||
pieces.Add(current);
|
||||
}
|
||||
current.Entities.Add(entity);
|
||||
}
|
||||
foreach (var piece in pieces)
|
||||
piece.UpdateBounds();
|
||||
return pieces;
|
||||
}
|
||||
|
||||
// A tabbed cutout is cut as open pieces between its tabs, in the cutout's own direction.
|
||||
// Each piece is entered from the slug with the internal lead-in (NoLeadIn pierces on the
|
||||
// contour) and has no lead-out, since it ends at a tab a run-out would cut into. A lead-in
|
||||
// that would reach back over the tab behind its piece is mirrored about the entry normal,
|
||||
// so it approaches from the piece's side instead. On an arc, a lead ending in an arc would
|
||||
// meet the cut tangentially (a contact verification cannot resolve), so it pierces straight
|
||||
// in from the same distance.
|
||||
private void EmitTabbedCutout(Program program, Shape cutout)
|
||||
{
|
||||
var winding = DetermineWinding(cutout);
|
||||
var leadIn = Parameters?.InternalLeadIn ?? new NoLeadIn();
|
||||
foreach (var piece in TabbedCutoutPieces(cutout))
|
||||
{
|
||||
var first = piece.Entities[0];
|
||||
var entry = EntityStartPoint(first);
|
||||
var normal = ComputeNormal(entry, first, ContourType.Internal, winding);
|
||||
var tangent = TravelTangent(first, entry);
|
||||
program.Codes.AddRange(TabbedLeadIn(leadIn, entry, normal, tangent, winding, first is Arc));
|
||||
program.Codes.AddRange(ConvertShapeToMoves(piece, entry));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ICode> TabbedLeadIn(LeadIn leadIn, Vector entry, double normal, Vector tangent,
|
||||
RotationType winding, bool onArc)
|
||||
{
|
||||
var codes = leadIn.Generate(entry, normal, winding);
|
||||
if (!(onArc && codes.Count > 0 && codes[^1] is ArcMove))
|
||||
{
|
||||
if (ReachBehind(codes, entry, tangent) <= Tolerance.Epsilon)
|
||||
return codes;
|
||||
var mirrored = codes.Select(code => Mirror(code, entry, tangent)).ToList();
|
||||
if (ReachBehind(mirrored, entry, tangent) <= Tolerance.Epsilon)
|
||||
return mirrored;
|
||||
}
|
||||
// Reaching both ways, or tangent to an arc: pierce straight in from the same distance.
|
||||
var pierce = leadIn.GetPiercePoint(entry, normal);
|
||||
return new LineLeadIn { Length = pierce.DistanceTo(entry), ApproachAngle = 90 }.Generate(entry, normal, winding);
|
||||
}
|
||||
|
||||
// How far the lead motions of `codes` (absolute, after their first rapid) reach behind
|
||||
// `entry`, against the unit travel `tangent`.
|
||||
private static double ReachBehind(List<ICode> codes, Vector entry, Vector tangent)
|
||||
{
|
||||
var behind = new Vector(-tangent.X, -tangent.Y);
|
||||
double Along(Vector p) => (p.X - entry.X) * behind.X + (p.Y - entry.Y) * behind.Y;
|
||||
var reach = double.NegativeInfinity;
|
||||
Vector? position = null;
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is not Motion motion)
|
||||
continue;
|
||||
var end = motion.EndPoint;
|
||||
if (position is { } start && code is not RapidMove)
|
||||
{
|
||||
reach = System.Math.Max(reach, System.Math.Max(Along(start), Along(end)));
|
||||
if (code is ArcMove arc)
|
||||
{
|
||||
var center = arc.CenterPoint;
|
||||
var radius = center.DistanceTo(end);
|
||||
var extreme = new Vector(center.X + behind.X * radius, center.Y + behind.Y * radius);
|
||||
if (OnArc(center, start, end, extreme, arc.Rotation))
|
||||
reach = System.Math.Max(reach, Along(extreme));
|
||||
}
|
||||
}
|
||||
position = end;
|
||||
}
|
||||
return reach;
|
||||
}
|
||||
|
||||
private static bool OnArc(Vector center, Vector start, Vector end, Vector point, RotationType rotation)
|
||||
{
|
||||
var a0 = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
|
||||
var a1 = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
|
||||
var ap = System.Math.Atan2(point.Y - center.Y, point.X - center.X);
|
||||
var sign = rotation == RotationType.CCW ? 1 : -1;
|
||||
var sweep = Angle.NormalizeRad(sign * (a1 - a0));
|
||||
if (sweep <= Tolerance.Epsilon)
|
||||
sweep = Angle.TwoPI;
|
||||
return Angle.NormalizeRad(sign * (ap - a0)) <= sweep;
|
||||
}
|
||||
|
||||
// `code` reflected across the line through `entry` along its normal (negating the
|
||||
// tangent component), with arc directions swapped.
|
||||
private static ICode Mirror(ICode code, Vector entry, Vector tangent)
|
||||
{
|
||||
Vector Reflect(Vector p)
|
||||
{
|
||||
var along = (p.X - entry.X) * tangent.X + (p.Y - entry.Y) * tangent.Y;
|
||||
return new Vector(p.X - 2 * along * tangent.X, p.Y - 2 * along * tangent.Y);
|
||||
}
|
||||
return code switch
|
||||
{
|
||||
RapidMove rapid => new RapidMove(Reflect(rapid.EndPoint)),
|
||||
LinearMove line => new LinearMove(Reflect(line.EndPoint)) { Layer = line.Layer },
|
||||
ArcMove arc => new ArcMove(Reflect(arc.EndPoint), Reflect(arc.CenterPoint),
|
||||
arc.Rotation == RotationType.CW ? RotationType.CCW : RotationType.CW)
|
||||
{ Layer = arc.Layer },
|
||||
_ => code,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<Shape> BreakCutContours(ShapeProfile profile) =>
|
||||
new List<Shape>(profile.Cutouts) { profile.Perimeter };
|
||||
|
||||
// Distance from a break cut end along its scrap direction to the nearest contour.
|
||||
private static double Room(Vector point, Vector direction, IReadOnlyList<Shape> contours)
|
||||
{
|
||||
const double reach = 1e6;
|
||||
var ray = new Line(point, point + direction * reach);
|
||||
var nearest = reach;
|
||||
foreach (var contour in contours ?? Array.Empty<Shape>())
|
||||
foreach (var entity in contour.Entities)
|
||||
if (entity.Intersects(ray, out var hits))
|
||||
foreach (var hit in hits)
|
||||
nearest = System.Math.Min(nearest, hit.DistanceTo(point));
|
||||
return nearest;
|
||||
}
|
||||
|
||||
private static Vector LeftOf(Vector tangent)
|
||||
{
|
||||
var length = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
|
||||
return length <= 0 ? new Vector(0, 1) : new Vector(-tangent.Y / length, tangent.X / length);
|
||||
}
|
||||
|
||||
private static Vector Tangent(Entity entity, bool atEnd)
|
||||
{
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
var angle = atEnd ? arc.EndAngle : arc.StartAngle;
|
||||
return arc.IsReversed
|
||||
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
|
||||
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
|
||||
}
|
||||
var line = (Line)entity;
|
||||
return line.EndPoint - line.StartPoint;
|
||||
}
|
||||
|
||||
private static Vector GetShapeStartPoint(Shape shape)
|
||||
{
|
||||
var first = shape.Entities[0];
|
||||
|
||||
@@ -24,6 +24,19 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
public double PierceClearance { get; set; } = 0.0625;
|
||||
|
||||
/// <summary>
|
||||
/// Diameter of the crater a pierce blows through this plate; 0 turns the pierce zone off.
|
||||
/// When set, every lead-in pierce must keep half of it from all part material.
|
||||
/// </summary>
|
||||
public double PierceDiameter { get; set; }
|
||||
|
||||
/// <summary>The pierce zone's radius, or 0 when <see cref="PierceDiameter"/> is off.</summary>
|
||||
public double GetPierceZoneRadius() =>
|
||||
PierceDiameter > 0 && double.IsFinite(PierceDiameter) ? PierceDiameter / 2 : 0;
|
||||
|
||||
/// <summary>The clearance a pierce keeps from its own contour: the larger setting wins.</summary>
|
||||
public double GetEffectivePierceClearance() => System.Math.Max(PierceClearance, GetPierceZoneRadius());
|
||||
|
||||
public bool RoundLeadInAngles { get; set; }
|
||||
public double LeadInAngleIncrement { get; set; } = 5.0;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace OpenNest.CNC
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public enum LayerType
|
||||
{
|
||||
@@ -7,5 +7,19 @@
|
||||
Cut,
|
||||
Leadin,
|
||||
Leadout,
|
||||
|
||||
/// <summary>
|
||||
/// An intentionally open cut through part material, left uncut at tabs (a breakoff's
|
||||
/// break line). It cuts like <see cref="Cut"/> but is not a material boundary; the scrap
|
||||
/// it may be pierced from lies to the left of travel.
|
||||
/// </summary>
|
||||
Break,
|
||||
|
||||
/// <summary>
|
||||
/// An uncut span of a material boundary (a tabbed cutout's tab). It bounds material like
|
||||
/// <see cref="Cut"/> so the cutout stays a closed hole, but it is never cut: emission
|
||||
/// leaves it out, cutting the rest of the contour as open pieces.
|
||||
/// </summary>
|
||||
Tab,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
-164
@@ -90,6 +90,9 @@ namespace OpenNest.CNC
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -269,37 +272,37 @@ namespace OpenNest.CNC
|
||||
switch (Mode)
|
||||
{
|
||||
case Mode.Absolute:
|
||||
{
|
||||
for (int i = Codes.Count; i >= 0; --i)
|
||||
{
|
||||
var code = Codes[i];
|
||||
var motion = code as Motion;
|
||||
for (int i = Codes.Count; i >= 0; --i)
|
||||
{
|
||||
var code = Codes[i];
|
||||
var motion = code as Motion;
|
||||
|
||||
if (motion == null)
|
||||
continue;
|
||||
if (motion == null)
|
||||
continue;
|
||||
|
||||
return motion.EndPoint;
|
||||
return motion.EndPoint;
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Mode.Incremental:
|
||||
{
|
||||
var pos = new Vector(0, 0);
|
||||
|
||||
for (int i = 0; i < Codes.Count; ++i)
|
||||
{
|
||||
var code = Codes[i];
|
||||
var motion = code as Motion;
|
||||
var pos = new Vector(0, 0);
|
||||
|
||||
if (motion == null)
|
||||
continue;
|
||||
for (int i = 0; i < Codes.Count; ++i)
|
||||
{
|
||||
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);
|
||||
@@ -334,164 +337,164 @@ namespace OpenNest.CNC
|
||||
switch (code.Type)
|
||||
{
|
||||
case CodeType.LinearMove:
|
||||
{
|
||||
var line = (LinearMove)code;
|
||||
var pt =
|
||||
Mode == Mode.Absolute
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
{
|
||||
var line = (LinearMove)code;
|
||||
var pt =
|
||||
Mode == Mode.Absolute
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
|
||||
if (pt.X > maxX)
|
||||
maxX = pt.X;
|
||||
if (pt.X < minX)
|
||||
minX = pt.X;
|
||||
if (pt.X > maxX)
|
||||
maxX = pt.X;
|
||||
if (pt.X < minX)
|
||||
minX = pt.X;
|
||||
|
||||
if (pt.Y > maxY)
|
||||
maxY = pt.Y;
|
||||
if (pt.Y < minY)
|
||||
minY = pt.Y;
|
||||
if (pt.Y > maxY)
|
||||
maxY = pt.Y;
|
||||
if (pt.Y < minY)
|
||||
minY = pt.Y;
|
||||
|
||||
pos = pt;
|
||||
pos = pt;
|
||||
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CodeType.RapidMove:
|
||||
{
|
||||
var line = (RapidMove)code;
|
||||
var pt =
|
||||
Mode == Mode.Absolute
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
{
|
||||
var line = (RapidMove)code;
|
||||
var pt =
|
||||
Mode == Mode.Absolute
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
|
||||
if (pt.X > maxX)
|
||||
maxX = pt.X;
|
||||
if (pt.X < minX)
|
||||
minX = pt.X;
|
||||
if (pt.X > maxX)
|
||||
maxX = pt.X;
|
||||
if (pt.X < minX)
|
||||
minX = pt.X;
|
||||
|
||||
if (pt.Y > maxY)
|
||||
maxY = pt.Y;
|
||||
if (pt.Y < minY)
|
||||
minY = pt.Y;
|
||||
if (pt.Y > maxY)
|
||||
maxY = pt.Y;
|
||||
if (pt.Y < minY)
|
||||
minY = pt.Y;
|
||||
|
||||
pos = pt;
|
||||
pos = pt;
|
||||
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
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;
|
||||
centerpt = arc.CenterPoint + pos;
|
||||
var arc = (ArcMove)code;
|
||||
var radius = arc.CenterPoint.DistanceTo(arc.EndPoint);
|
||||
|
||||
Vector endpt;
|
||||
Vector centerpt;
|
||||
|
||||
if (Mode == Mode.Incremental)
|
||||
{
|
||||
endpt = arc.EndPoint + pos;
|
||||
centerpt = arc.CenterPoint + pos;
|
||||
}
|
||||
else
|
||||
{
|
||||
endpt = frameOrigin + arc.EndPoint;
|
||||
centerpt = frameOrigin + arc.CenterPoint;
|
||||
}
|
||||
|
||||
double minX1;
|
||||
double minY1;
|
||||
double maxX1;
|
||||
double maxY1;
|
||||
|
||||
if (pos.X < endpt.X)
|
||||
{
|
||||
minX1 = pos.X;
|
||||
maxX1 = endpt.X;
|
||||
}
|
||||
else
|
||||
{
|
||||
minX1 = endpt.X;
|
||||
maxX1 = pos.X;
|
||||
}
|
||||
|
||||
if (pos.Y < endpt.Y)
|
||||
{
|
||||
minY1 = pos.Y;
|
||||
maxY1 = endpt.Y;
|
||||
}
|
||||
else
|
||||
{
|
||||
minY1 = endpt.Y;
|
||||
maxY1 = pos.Y;
|
||||
}
|
||||
|
||||
var startAngle = pos.AngleFrom(centerpt);
|
||||
var endAngle = endpt.AngleFrom(centerpt);
|
||||
|
||||
// switch the angle to counter clockwise.
|
||||
if (arc.Rotation == RotationType.CW)
|
||||
Generic.Swap(ref startAngle, ref endAngle);
|
||||
|
||||
startAngle = Angle.NormalizeRad(startAngle);
|
||||
endAngle = Angle.NormalizeRad(endAngle);
|
||||
|
||||
if (Angle.IsBetweenRad(Angle.HalfPI, startAngle, endAngle))
|
||||
maxY1 = centerpt.Y + radius;
|
||||
|
||||
if (Angle.IsBetweenRad(System.Math.PI, startAngle, endAngle))
|
||||
minX1 = centerpt.X - radius;
|
||||
|
||||
const double oneHalfPI = System.Math.PI * 1.5;
|
||||
|
||||
if (Angle.IsBetweenRad(oneHalfPI, startAngle, endAngle))
|
||||
minY1 = centerpt.Y - radius;
|
||||
|
||||
if (Angle.IsBetweenRad(Angle.TwoPI, startAngle, endAngle))
|
||||
maxX1 = centerpt.X + radius;
|
||||
|
||||
if (maxX1 > maxX)
|
||||
maxX = maxX1;
|
||||
|
||||
if (minX1 < minX)
|
||||
minX = minX1;
|
||||
|
||||
if (maxY1 > maxY)
|
||||
maxY = maxY1;
|
||||
|
||||
if (minY1 < minY)
|
||||
minY = minY1;
|
||||
|
||||
pos = endpt;
|
||||
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
endpt = frameOrigin + arc.EndPoint;
|
||||
centerpt = frameOrigin + arc.CenterPoint;
|
||||
}
|
||||
|
||||
double minX1;
|
||||
double minY1;
|
||||
double maxX1;
|
||||
double maxY1;
|
||||
|
||||
if (pos.X < endpt.X)
|
||||
{
|
||||
minX1 = pos.X;
|
||||
maxX1 = endpt.X;
|
||||
}
|
||||
else
|
||||
{
|
||||
minX1 = endpt.X;
|
||||
maxX1 = pos.X;
|
||||
}
|
||||
|
||||
if (pos.Y < endpt.Y)
|
||||
{
|
||||
minY1 = pos.Y;
|
||||
maxY1 = endpt.Y;
|
||||
}
|
||||
else
|
||||
{
|
||||
minY1 = endpt.Y;
|
||||
maxY1 = pos.Y;
|
||||
}
|
||||
|
||||
var startAngle = pos.AngleFrom(centerpt);
|
||||
var endAngle = endpt.AngleFrom(centerpt);
|
||||
|
||||
// switch the angle to counter clockwise.
|
||||
if (arc.Rotation == RotationType.CW)
|
||||
Generic.Swap(ref startAngle, ref endAngle);
|
||||
|
||||
startAngle = Angle.NormalizeRad(startAngle);
|
||||
endAngle = Angle.NormalizeRad(endAngle);
|
||||
|
||||
if (Angle.IsBetweenRad(Angle.HalfPI, startAngle, endAngle))
|
||||
maxY1 = centerpt.Y + radius;
|
||||
|
||||
if (Angle.IsBetweenRad(System.Math.PI, startAngle, endAngle))
|
||||
minX1 = centerpt.X - radius;
|
||||
|
||||
const double oneHalfPI = System.Math.PI * 1.5;
|
||||
|
||||
if (Angle.IsBetweenRad(oneHalfPI, startAngle, endAngle))
|
||||
minY1 = centerpt.Y - radius;
|
||||
|
||||
if (Angle.IsBetweenRad(Angle.TwoPI, startAngle, endAngle))
|
||||
maxX1 = centerpt.X + radius;
|
||||
|
||||
if (maxX1 > maxX)
|
||||
maxX = maxX1;
|
||||
|
||||
if (minX1 < minX)
|
||||
minX = minX1;
|
||||
|
||||
if (maxY1 > maxY)
|
||||
maxY = maxY1;
|
||||
|
||||
if (minY1 < minY)
|
||||
minY = minY1;
|
||||
|
||||
pos = endpt;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case CodeType.SubProgramCall:
|
||||
{
|
||||
var subpgm = (SubProgramCall)code;
|
||||
if (subpgm.Program == null)
|
||||
{
|
||||
var subpgm = (SubProgramCall)code;
|
||||
if (subpgm.Program == null)
|
||||
break;
|
||||
|
||||
// Sub-program frame origin in this program's frame
|
||||
// is frameOrigin + Offset, regardless of current pos.
|
||||
pos = frameOrigin + subpgm.Offset;
|
||||
if (!subpgm.Program.BoundingBox(ref pos, out var box))
|
||||
break;
|
||||
|
||||
if (box.Left < minX)
|
||||
minX = box.Left;
|
||||
|
||||
if (box.Right > maxX)
|
||||
maxX = box.Right;
|
||||
|
||||
if (box.Bottom < minY)
|
||||
minY = box.Bottom;
|
||||
|
||||
if (box.Top > maxY)
|
||||
maxY = box.Top;
|
||||
|
||||
break;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,8 +522,31 @@ namespace OpenNest.CNC
|
||||
foreach (var kvp in Variables)
|
||||
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)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,42 @@ namespace OpenNest.CNC
|
||||
{
|
||||
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)
|
||||
{
|
||||
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
|
||||
// pierce point. This also primes pos so the interior walk interprets
|
||||
// Incremental deltas from the correct absolute location (basePos), which
|
||||
// matters for raw pre-lead-in programs that are emitted Incremental.
|
||||
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
|
||||
{
|
||||
// Draw the approach to the first rapid destination, or to the program
|
||||
// origin when it starts cutting without a rapid. Only an initial rapid
|
||||
// (or call arrival) is already represented by this approach; a later
|
||||
// first rapid must remain visible. Walk starts at the program origin,
|
||||
// so an incremental initial rapid is not applied twice.
|
||||
var firstPierce = FirstPiercePoint(pgm, basePos);
|
||||
results.Add(new Segment(startPos, firstPierce));
|
||||
|
||||
var pos = firstPierce;
|
||||
Walk(pgm, basePos, ref pos, skipFirst: true, results);
|
||||
return results;
|
||||
var pos = basePos;
|
||||
Walk(pgm, basePos, ref pos, skipFirst: StartsWithRapidOrCall(pgm), results);
|
||||
// The last rapid ends at a pierce, not necessarily the final tool position.
|
||||
return pos;
|
||||
}
|
||||
|
||||
private static Vector FirstPiercePoint(Program pgm, Vector basePos)
|
||||
@@ -31,11 +53,23 @@ namespace OpenNest.CNC
|
||||
return FirstPiercePoint(call.Program, basePos + call.Offset);
|
||||
|
||||
if (pgm[i] is Motion motion)
|
||||
return motion.EndPoint + basePos;
|
||||
return motion is RapidMove ? motion.EndPoint + basePos : basePos;
|
||||
}
|
||||
return basePos;
|
||||
}
|
||||
|
||||
private static bool StartsWithRapidOrCall(Program pgm)
|
||||
{
|
||||
for (var i = 0; i < pgm.Length; i++)
|
||||
{
|
||||
if (pgm[i] is SubProgramCall { Program: not null })
|
||||
return true;
|
||||
if (pgm[i] is Motion motion)
|
||||
return motion is RapidMove;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void Walk(
|
||||
Program pgm,
|
||||
Vector basePos,
|
||||
@@ -61,7 +95,7 @@ namespace OpenNest.CNC
|
||||
results.Add(new Segment(pos, firstPierce));
|
||||
|
||||
var subPos = holeBase;
|
||||
Walk(program, holeBase, ref subPos, skipFirst: true, results);
|
||||
Walk(program, holeBase, ref subPos, skipFirst: StartsWithRapidOrCall(program), results);
|
||||
pos = subPos;
|
||||
}
|
||||
else if (code is Motion motion)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
@@ -79,12 +79,28 @@ namespace OpenNest.CNC
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <returns></returns>
|
||||
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()
|
||||
|
||||
@@ -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<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()
|
||||
{
|
||||
items = new List<T>();
|
||||
@@ -64,6 +67,68 @@ namespace OpenNest.Collections
|
||||
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)
|
||||
{
|
||||
return items.IndexOf(item);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
@@ -9,7 +9,24 @@ namespace OpenNest.Converters
|
||||
{
|
||||
public static Program ToProgram(IList<Entity> geometry)
|
||||
{
|
||||
var shapes = ShapeBuilder.GetShapes(geometry);
|
||||
// Break cuts keep their order and direction (their scrap side): they are emitted as
|
||||
// given, one program run per continuous chain, before the contours.
|
||||
var breakCuts = new List<Shape>();
|
||||
var contours = new List<Entity>();
|
||||
foreach (var entity in geometry)
|
||||
{
|
||||
if (entity.Layer != SpecialLayers.Break)
|
||||
{
|
||||
contours.Add(entity);
|
||||
continue;
|
||||
}
|
||||
var last = breakCuts.Count == 0 ? null : breakCuts[^1].Entities[^1];
|
||||
if (last == null || EndOf(last).DistanceTo(StartOf(entity)) > Tolerance.Epsilon)
|
||||
breakCuts.Add(new Shape());
|
||||
breakCuts[^1].Entities.Add(entity);
|
||||
}
|
||||
|
||||
var shapes = ShapeBuilder.GetShapes(contours);
|
||||
|
||||
if (shapes.Count == 0)
|
||||
return null;
|
||||
@@ -34,6 +51,9 @@ namespace OpenNest.Converters
|
||||
|
||||
var pgm = new Program();
|
||||
|
||||
foreach (var cut in breakCuts)
|
||||
pgm.Merge(ToProgram(cut));
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var subpgm = ToProgram(shape);
|
||||
@@ -46,6 +66,10 @@ namespace OpenNest.Converters
|
||||
return pgm;
|
||||
}
|
||||
|
||||
private static Vector StartOf(Entity entity) => entity is Arc arc ? arc.StartPoint() : ((Line)entity).StartPoint;
|
||||
|
||||
private static Vector EndOf(Entity entity) => entity is Arc arc ? arc.EndPoint() : ((Line)entity).EndPoint;
|
||||
|
||||
public static Program ToProgram(Shape shape)
|
||||
{
|
||||
var pgm = new Program();
|
||||
@@ -153,6 +177,12 @@ namespace OpenNest.Converters
|
||||
)
|
||||
return LayerType.Scribe;
|
||||
|
||||
if (string.Equals(name, SpecialLayers.Break.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.Break;
|
||||
|
||||
if (string.Equals(name, SpecialLayers.Tab.Name, System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.Tab;
|
||||
|
||||
return LayerType.Cut;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
@@ -207,6 +207,12 @@ namespace OpenNest.Converters
|
||||
case LayerType.Scribe:
|
||||
return SpecialLayers.Scribe;
|
||||
|
||||
case LayerType.Break:
|
||||
return SpecialLayers.Break;
|
||||
|
||||
case LayerType.Tab:
|
||||
return SpecialLayers.Tab;
|
||||
|
||||
default:
|
||||
return new Layer(layer.ToString());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Puts etch marks back on drawings that lost them. Nests converted from PEP kept etch marks
|
||||
/// only in each placed part's saved cutting program; the drawing itself had none, so removing
|
||||
/// lead-ins (which rebuilds the part from its drawing) silently removed the marks too.
|
||||
/// </summary>
|
||||
public static class DrawingMarkRecovery
|
||||
{
|
||||
private const double MatchTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// For each drawing without etch marks whose saved placed programs all carry the same marks,
|
||||
/// adds those marks to the drawing in its own frame and refreshes the drawing's clean placed
|
||||
/// parts. Saved programs are never changed. Drawings whose saved programs disagree, or that
|
||||
/// already have marks, are left alone. Returns the number of drawings that were given marks.
|
||||
/// </summary>
|
||||
public static int Apply(Nest nest)
|
||||
{
|
||||
if (nest == null)
|
||||
return 0;
|
||||
|
||||
var parts = nest.Plates.SelectMany(plate => plate.Parts).ToList();
|
||||
var recovered = 0;
|
||||
foreach (var group in parts.GroupBy(part => part.BaseDrawing, ReferenceEqualityComparer.Instance))
|
||||
{
|
||||
var drawing = (Drawing)group.Key;
|
||||
if (Apply(drawing, group.ToList()))
|
||||
recovered++;
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
/// <summary>Recovers marks for one drawing from its placed parts; true when marks were added.</summary>
|
||||
public static bool Apply(Drawing drawing, IReadOnlyList<Part> placedParts)
|
||||
{
|
||||
if (drawing?.Program == null || drawing.IsCutOff || placedParts == null)
|
||||
return false;
|
||||
// An incremental call's end position is not tracked by Program.EndPoint, so only
|
||||
// flat drawings (which is what conversion produces) can be extended safely.
|
||||
if (HasMarks(drawing.Program) || drawing.Program.Codes.Any(code => code is SubProgramCall))
|
||||
return false;
|
||||
|
||||
var saved = placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing) && part.HasManualLeadIns)
|
||||
.ToList();
|
||||
if (saved.Count == 0)
|
||||
return false;
|
||||
|
||||
var marks = MarksInDrawingFrame(saved[0]);
|
||||
if (marks.Count == 0 || saved.Skip(1).Any(part => !SameMarks(marks, MarksInDrawingFrame(part))))
|
||||
return false;
|
||||
|
||||
AppendMarks(drawing.Program, marks);
|
||||
drawing.UpdateArea();
|
||||
drawing.RecomputeCanonicalAngle();
|
||||
|
||||
foreach (var part in placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing)
|
||||
&& !part.HasManualLeadIns))
|
||||
part.Update();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasMarks(Program program) =>
|
||||
program.Codes.Any(code => code is LinearMove { Layer: LayerType.Scribe } or ArcMove { Layer: LayerType.Scribe }
|
||||
|| code is SubProgramCall { Program: not null } call && HasMarks(call.Program));
|
||||
|
||||
/// <summary>
|
||||
/// The part's marks as they would sit in its drawing: <see cref="Part.RemoveLeadIns"/>
|
||||
/// rebuilds the part as the drawing turned by <see cref="Part.Rotation"/>, so undo that turn.
|
||||
/// </summary>
|
||||
private static List<Entity> MarksInDrawingFrame(Part part)
|
||||
{
|
||||
var marks = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(entity => entity.Layer == SpecialLayers.Scribe)
|
||||
.ToList();
|
||||
if (!Tolerance.IsEqualTo(part.Rotation, 0))
|
||||
foreach (var mark in marks)
|
||||
mark.Rotate(-part.Rotation);
|
||||
return marks;
|
||||
}
|
||||
|
||||
private static bool SameMarks(List<Entity> expected, List<Entity> actual)
|
||||
{
|
||||
if (expected.Count != actual.Count)
|
||||
return false;
|
||||
var unmatched = actual.ToList();
|
||||
foreach (var mark in expected)
|
||||
{
|
||||
var index = unmatched.FindIndex(other => SameMark(mark, other));
|
||||
if (index < 0)
|
||||
return false;
|
||||
unmatched.RemoveAt(index);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Direction does not matter for an etch: both marks must lie on each other.
|
||||
private static bool SameMark(Entity a, Entity b) =>
|
||||
a.Type == b.Type && LiesOn(Samples(a), b) && LiesOn(Samples(b), a);
|
||||
|
||||
private static bool LiesOn(List<Vector> points, Entity entity) =>
|
||||
points.Count > 0 && points.All(point => entity.ClosestPointTo(point).DistanceTo(point) <= MatchTolerance);
|
||||
|
||||
private static List<Vector> Samples(Entity entity) => entity switch
|
||||
{
|
||||
Line line => new List<Vector>
|
||||
{
|
||||
line.StartPoint,
|
||||
line.EndPoint,
|
||||
new((line.StartPoint.X + line.EndPoint.X) / 2, (line.StartPoint.Y + line.EndPoint.Y) / 2),
|
||||
},
|
||||
Arc arc => new List<Vector> { arc.StartPoint(), arc.EndPoint(), arc.MidPoint() },
|
||||
Circle circle => new List<Vector>
|
||||
{
|
||||
new(circle.Center.X + circle.Radius, circle.Center.Y),
|
||||
new(circle.Center.X - circle.Radius, circle.Center.Y),
|
||||
new(circle.Center.X, circle.Center.Y + circle.Radius),
|
||||
new(circle.Center.X, circle.Center.Y - circle.Radius),
|
||||
},
|
||||
_ => new List<Vector>(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Appends the marks after the drawing's existing motion, so the existing codes and the
|
||||
/// drawing's first-rapid origin stay exactly as they were.
|
||||
/// </summary>
|
||||
private static void AppendMarks(Program program, List<Entity> marks)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.AddRange(marks);
|
||||
var markProgram = ConvertGeometry.ToProgram(shape);
|
||||
|
||||
// Start from the drawing's end point, with an explicit rapid onto the first mark.
|
||||
var start = EndOf(program);
|
||||
var first = markProgram.Codes.OfType<Motion>().First();
|
||||
var positioned = new Program(Mode.Absolute);
|
||||
positioned.Codes.Add(new RapidMove(start));
|
||||
if (first is not RapidMove)
|
||||
positioned.Codes.Add(new RapidMove(StartOf(marks[0])));
|
||||
positioned.Codes.AddRange(markProgram.Codes);
|
||||
|
||||
if (program.Mode == Mode.Incremental)
|
||||
positioned.Mode = Mode.Incremental;
|
||||
// Drop the move onto the drawing's end point; it is where the drawing already stops.
|
||||
program.Codes.AddRange(positioned.Codes.Skip(1));
|
||||
}
|
||||
|
||||
// Where a flat program leaves the head (callers exclude sub-program calls).
|
||||
private static Vector EndOf(Program program)
|
||||
{
|
||||
var position = Vector.Zero;
|
||||
foreach (var motion in program.Codes.OfType<Motion>())
|
||||
position = program.Mode == Mode.Incremental ? position + motion.EndPoint : motion.EndPoint;
|
||||
return position;
|
||||
}
|
||||
|
||||
private static Vector StartOf(Entity entity) => entity switch
|
||||
{
|
||||
Line line => line.StartPoint,
|
||||
Arc arc => arc.StartPoint(),
|
||||
Circle circle => new Vector(circle.Center.X + circle.Radius, circle.Center.Y),
|
||||
_ => Vector.Zero,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Converters
|
||||
{
|
||||
/// <summary>What a drawing does with a source entity.</summary>
|
||||
public enum EntityRole
|
||||
{
|
||||
/// <summary>Cut through the material.</summary>
|
||||
Cut,
|
||||
|
||||
/// <summary>Marked on the surface (a Scribe pass): never material.</summary>
|
||||
Etch,
|
||||
|
||||
/// <summary>
|
||||
/// Shown with the part for reference only. It is kept out of the program, so it is never
|
||||
/// cut, posted, nested against or verified.
|
||||
/// </summary>
|
||||
Display,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies per-entity role overrides. An entity without an override keeps the role its layer
|
||||
/// implies: mark layers (ETCH, SCRIBE, ENGRAVE) etch, everything else cuts.
|
||||
/// </summary>
|
||||
public static class EntityRoles
|
||||
{
|
||||
public static EntityRole Default(Entity entity) =>
|
||||
ScribeLayerRepair.IsMarkLayer(entity.Layer) ? EntityRole.Etch : EntityRole.Cut;
|
||||
|
||||
public static EntityRole Of(Entity entity, IReadOnlyDictionary<Guid, EntityRole> overrides) =>
|
||||
overrides != null && overrides.TryGetValue(entity.Id, out var role) ? role : Default(entity);
|
||||
|
||||
/// <summary>
|
||||
/// The entities a program is built from: display-only entities are left out and entities
|
||||
/// whose override differs from their layer's role are replaced by copies, with the same
|
||||
/// ID, on a layer the converters read as that role. Inputs are never mutated.
|
||||
/// </summary>
|
||||
public static List<Entity> ForProgram(IEnumerable<Entity> entities, IReadOnlyDictionary<Guid, EntityRole> overrides)
|
||||
{
|
||||
var result = new List<Entity>();
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
if (overrides == null || !overrides.TryGetValue(entity.Id, out var role) || role == Default(entity))
|
||||
{
|
||||
result.Add(entity);
|
||||
continue;
|
||||
}
|
||||
if (role == EntityRole.Display)
|
||||
continue;
|
||||
|
||||
var copy = entity.Clone();
|
||||
copy.Id = entity.Id;
|
||||
copy.Layer = new Layer(role == EntityRole.Etch ? SpecialLayers.Scribe.Name : SpecialLayers.Cut.Name)
|
||||
{
|
||||
Color = entity.Layer?.Color ?? SpecialLayers.Cut.Color,
|
||||
IsVisible = true,
|
||||
};
|
||||
result.Add(copy);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A drawing's unsuppressed display-only entities as copies in its program frame (source
|
||||
/// coordinates shifted by -<c>Source.Offset</c>, before any program rotation).
|
||||
/// </summary>
|
||||
public static List<Entity> DisplayGeometry(Drawing drawing)
|
||||
{
|
||||
var result = new List<Entity>();
|
||||
if (drawing?.SourceEntities == null || drawing.EntityRoles == null || drawing.EntityRoles.Count == 0)
|
||||
return result;
|
||||
|
||||
var offset = drawing.Source?.Offset ?? Vector.Zero;
|
||||
foreach (var entity in drawing.SourceEntities)
|
||||
{
|
||||
if (drawing.SuppressedEntityIds.Contains(entity.Id) || Of(entity, drawing.EntityRoles) != EntityRole.Display)
|
||||
continue;
|
||||
var copy = entity.Clone();
|
||||
copy.Id = entity.Id;
|
||||
copy.Offset(-offset.X, -offset.Y);
|
||||
result.Add(copy);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
-3
@@ -39,6 +39,72 @@ namespace OpenNest
|
||||
Drawing.Program = program;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
foreach (var code in part.Program.Codes)
|
||||
{
|
||||
switch (code)
|
||||
{
|
||||
case RapidMove rapid:
|
||||
if (cutting)
|
||||
return false; // A second pierce leaves an uncut gap.
|
||||
position = rapid.EndPoint + part.Location;
|
||||
break;
|
||||
case LinearMove move when position != null:
|
||||
var end = move.EndPoint + part.Location;
|
||||
if (!OnLine(position.Value) || !OnLine(end))
|
||||
return false;
|
||||
foreach (var value in new[] { Along(position.Value), Along(end) })
|
||||
{
|
||||
low = low == null ? value : System.Math.Min(low.Value, value);
|
||||
high = high == null ? value : System.Math.Max(high.Value, value);
|
||||
}
|
||||
position = end;
|
||||
cutting = true;
|
||||
break;
|
||||
case Motion:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return cutting && low <= alongMin + Math.Tolerance.Epsilon && high >= alongMax - Math.Tolerance.Epsilon;
|
||||
|
||||
bool OnLine(Vector point) =>
|
||||
System.Math.Abs((vertical ? point.X : point.Y) - line) <= Math.Tolerance.Epsilon;
|
||||
double Along(Vector point) => vertical ? point.Y : point.X;
|
||||
}
|
||||
|
||||
private string GetName()
|
||||
{
|
||||
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
|
||||
@@ -78,8 +144,21 @@ namespace OpenNest
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
Entity perimeter = null;
|
||||
cache?.TryGetValue(part, out perimeter);
|
||||
Entity perimeter;
|
||||
if (cache != null)
|
||||
{
|
||||
cache.TryGetValue(part, out perimeter);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No pre-built cache: this is a direct/uncached caller (e.g. an
|
||||
// external API consumer calling Regenerate without going through
|
||||
// Plate.RegenerateCutOffs or the UI handlers, which always pass a
|
||||
// cache). Build the same converted-material envelope on the fly so
|
||||
// this path gets the same safety as the cached one, instead of
|
||||
// silently trusting the part's bare BoundingBox.
|
||||
perimeter = Plate.BuildPartPerimeter(part);
|
||||
}
|
||||
var partExclusions = GetPartExclusions(
|
||||
part,
|
||||
perimeter,
|
||||
@@ -134,15 +213,38 @@ namespace OpenNest
|
||||
double clearance
|
||||
)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
var cached = part.BoundingBox;
|
||||
var roundoff = GetBoundsRoundoff(part);
|
||||
// Keep the farther of the cached and converted perimeter extents. The
|
||||
// perimeter's fine intersection/offset can miss a near-tangent sliver;
|
||||
// only a line in that narrow outer band needs the conservative box.
|
||||
var bb = cached;
|
||||
if (perimeter != null)
|
||||
{
|
||||
var converted = perimeter.BoundingBox;
|
||||
var left = System.Math.Min(cached.Left, converted.Left);
|
||||
var right = System.Math.Max(cached.Right, converted.Right);
|
||||
var bottom = System.Math.Min(cached.Bottom, converted.Bottom);
|
||||
var top = System.Math.Max(cached.Top, converted.Top);
|
||||
bb = new Box(left, bottom, right - left, top - bottom);
|
||||
}
|
||||
var (cachedMin, cachedMax) = AxisBounds(cached, clearance);
|
||||
var (partMin, partMax) = AxisBounds(bb, clearance);
|
||||
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
|
||||
// Match the planner's representational allowance in BOTH pruning and
|
||||
// fallback exclusions. Tolerating it only in validation could cut an edge.
|
||||
partMin -= roundoff;
|
||||
partMax += roundoff;
|
||||
partStart -= roundoff;
|
||||
partEnd += roundoff;
|
||||
|
||||
if (cutPosition < partMin || cutPosition > partMax)
|
||||
return EmptyExclusions;
|
||||
|
||||
if (perimeter != null)
|
||||
{
|
||||
if (cutPosition <= cachedMin + roundoff || cutPosition >= cachedMax - roundoff)
|
||||
return new List<(double Start, double End)> { (partStart, partEnd) };
|
||||
var perimeterExclusions = IntersectPerimeter(
|
||||
perimeter,
|
||||
cutPosition,
|
||||
@@ -157,6 +259,34 @@ namespace OpenNest
|
||||
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(
|
||||
Entity perimeter,
|
||||
double cutPosition,
|
||||
@@ -166,6 +296,9 @@ namespace OpenNest
|
||||
)
|
||||
{
|
||||
var offset = OffsetOutward(perimeter, clearance);
|
||||
if (offset != null && offset.All(target => target is Polygon))
|
||||
return SlicePolygons(offset.Cast<Polygon>(), cutPosition);
|
||||
|
||||
var usedOffset = offset != null;
|
||||
var targets = offset ?? new List<Entity> { perimeter };
|
||||
var cutLine = new Line(
|
||||
@@ -191,6 +324,15 @@ namespace OpenNest
|
||||
if (coords.Count % 2 != 0)
|
||||
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 result = new List<(double Start, double End)>();
|
||||
for (var i = 0; i < coords.Count; i += 2)
|
||||
@@ -199,6 +341,51 @@ namespace OpenNest
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exact cross-section of the clearance outline on the cut line. A vertex on the line
|
||||
/// counts as below it (half-open edges), so a line through a vertex counts one
|
||||
/// crossing when the outline passes through and zero or two when it only touches. Edges
|
||||
/// lying on the line are excluded whole. Unlike <see cref="Entity.Intersects"/>, which
|
||||
/// reports a shared vertex once per incident edge, this never needs the bounding-box
|
||||
/// fallback, so a lattice line through a vertex is trimmed like any other line.
|
||||
/// </summary>
|
||||
private List<(double Start, double End)> SlicePolygons(IEnumerable<Polygon> polygons, double cutPosition)
|
||||
{
|
||||
var crossings = new List<double>();
|
||||
var result = new List<(double Start, double End)>();
|
||||
foreach (var polygon in polygons)
|
||||
{
|
||||
var vertices = polygon.Vertices;
|
||||
if (vertices.Count < 3)
|
||||
return null;
|
||||
var closed = vertices[0].X == vertices[^1].X && vertices[0].Y == vertices[^1].Y;
|
||||
var edges = closed ? vertices.Count - 1 : vertices.Count;
|
||||
for (var i = 0; i < edges; i++)
|
||||
{
|
||||
var from = vertices[i];
|
||||
var to = vertices[(i + 1) % vertices.Count];
|
||||
var (acrossFrom, alongFrom) = Split(from);
|
||||
var (acrossTo, alongTo) = Split(to);
|
||||
if (acrossFrom == cutPosition && acrossTo == cutPosition)
|
||||
result.Add((System.Math.Min(alongFrom, alongTo), System.Math.Max(alongFrom, alongTo)));
|
||||
else if (acrossFrom > cutPosition != acrossTo > cutPosition)
|
||||
crossings.Add(acrossFrom == cutPosition ? alongFrom
|
||||
: acrossTo == cutPosition ? alongTo
|
||||
: alongFrom + (cutPosition - acrossFrom) * (alongTo - alongFrom) / (acrossTo - acrossFrom));
|
||||
}
|
||||
}
|
||||
|
||||
if (crossings.Count % 2 != 0)
|
||||
return null;
|
||||
crossings.Sort();
|
||||
for (var i = 0; i < crossings.Count; i += 2)
|
||||
result.Add((crossings[i], crossings[i + 1]));
|
||||
return result;
|
||||
|
||||
(double Across, double Along) Split(Vector point) =>
|
||||
Axis == CutOffAxis.Vertical ? (point.X, point.Y) : (point.Y, point.X);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grows the perimeter by the clearance as one Clipper region offset, so slots
|
||||
/// narrower than twice the clearance close up instead of leaving a gap the cut
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
namespace OpenNest;
|
||||
|
||||
public class CutParameters
|
||||
{
|
||||
@@ -11,6 +11,18 @@ public class CutParameters
|
||||
public string PostProcessor { get; set; }
|
||||
public Units Units { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Acceleration limits; null estimates every move at full speed. Applied by
|
||||
/// <see cref="CNC.CutSimulation"/> and the plate/nest overloads of <see cref="Timing.CalculateTime(Plate, CutParameters)"/>.
|
||||
/// </summary>
|
||||
public MotionProfile Motion { get; set; }
|
||||
|
||||
/// <summary>Feed for interior contours (holes); null or zero uses <see cref="Feedrate"/>.</summary>
|
||||
public double? HoleFeedrate { get; set; }
|
||||
|
||||
/// <summary>Leave out contours that only scribe/etch, as the CI Fiber post does by default.</summary>
|
||||
public bool SkipScribe { get; set; }
|
||||
|
||||
public static CutParameters Default =>
|
||||
new()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Cutting process a library drawing is prepared for, stored by name so new
|
||||
/// processes can be added without a migration.
|
||||
/// </summary>
|
||||
public enum CutType
|
||||
{
|
||||
Laser,
|
||||
Waterjet,
|
||||
Plasma,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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));
|
||||
var start = cut.Axis == CutOffAxis.Vertical
|
||||
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom)
|
||||
: new Vector(cut.StartLimit ?? sheet.Left, cut.Position.Y);
|
||||
var end = cut.Axis == CutOffAxis.Vertical
|
||||
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top)
|
||||
: new Vector(cut.EndLimit ?? sheet.Right, 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap ordered identity/pose check, not a geometry hash. In-place geometry editors must
|
||||
/// explicitly invalidate before mutating. Capture and match only on the model's UI thread.
|
||||
/// </summary>
|
||||
public sealed class OverlapGeometryStamp
|
||||
{
|
||||
private readonly Plate plate;
|
||||
private readonly Entry[] entries;
|
||||
private readonly (string Id, long X, long Y, long Length, long Width)[] zones;
|
||||
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End)[] cutoffs;
|
||||
private readonly (long Width, long Length, int Quadrant) sheet;
|
||||
|
||||
private OverlapGeometryStamp(Plate plate)
|
||||
{
|
||||
this.plate = plate;
|
||||
sheet = SheetState(plate);
|
||||
entries = new Entry[plate.Parts.Count];
|
||||
for (var i = 0; i < entries.Length; i++)
|
||||
entries[i] = new Entry(plate.Parts[i]);
|
||||
zones = new (string, long, long, long, long)[plate.ClampZones.Count];
|
||||
for (var i = 0; i < zones.Length; i++)
|
||||
zones[i] = ZoneState(plate.ClampZones[i]);
|
||||
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?)[plate.CutOffs.Count];
|
||||
for (var i = 0; i < cutoffs.Length; i++)
|
||||
cutoffs[i] = CutOffState(plate.CutOffs[i]);
|
||||
}
|
||||
|
||||
// Missing entries are malformed input the analysis itself reports; freshness must not
|
||||
// throw first (the desktop captures it before its error boundary).
|
||||
private static (string, long, long, long, long) ZoneState(ClampZone zone) =>
|
||||
zone?.Bounds is not { } box ? default
|
||||
: (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width));
|
||||
|
||||
private static (CutOff, CutOffAxis, long, long, long?, long?) CutOffState(CutOff cut) =>
|
||||
cut == null ? default
|
||||
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
|
||||
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
|
||||
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null);
|
||||
|
||||
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||
|
||||
private static (long, long, int) SheetState(Plate plate) =>
|
||||
(Bits(plate.Size.Width), Bits(plate.Size.Length), plate.Quadrant);
|
||||
|
||||
public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
|
||||
|
||||
internal bool SameZones(Plate current)
|
||||
{
|
||||
// Stored zones are sheet-relative: a size or quadrant change moves them in the
|
||||
// parts' frame without changing their bits.
|
||||
if (!ReferenceEquals(plate, current) || current.ClampZones.Count != zones.Length
|
||||
|| sheet != SheetState(current))
|
||||
return false;
|
||||
for (var i = 0; i < zones.Length; i++)
|
||||
if (zones[i] != ZoneState(current.ClampZones[i]))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool SameCutOffs(Plate current)
|
||||
{
|
||||
if (!ReferenceEquals(plate, current) || current.CutOffs.Count != cutoffs.Length
|
||||
|| sheet != SheetState(current))
|
||||
return false;
|
||||
for (var i = 0; i < cutoffs.Length; i++)
|
||||
if (cutoffs[i] != CutOffState(current.CutOffs[i]))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Matches(Plate current)
|
||||
{
|
||||
if (current.Parts.Count != entries.Length || !SameZones(current) || !SameCutOffs(current))
|
||||
return false;
|
||||
for (var i = 0; i < entries.Length; i++)
|
||||
if (!entries[i].Matches(current.Parts[i]))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conservative display reuse: only unchanged ordered slots on the same plate survive.
|
||||
/// Index-changing edits may hide extra pairs, but cannot attach old geometry to new parts.
|
||||
/// </summary>
|
||||
internal bool[] UnchangedSlots(Plate current)
|
||||
{
|
||||
var matches = new bool[entries.Length];
|
||||
if (!ReferenceEquals(plate, current))
|
||||
return matches;
|
||||
for (var i = 0; i < entries.Length && i < current.Parts.Count; i++)
|
||||
matches[i] = entries[i].Matches(current.Parts[i]);
|
||||
return matches;
|
||||
}
|
||||
|
||||
private readonly struct Entry
|
||||
{
|
||||
private readonly Part part;
|
||||
private readonly Drawing drawing;
|
||||
private readonly Program placedProgram;
|
||||
private readonly Program cleanProgram;
|
||||
private readonly long x, y, rotation;
|
||||
private readonly bool isCutOff;
|
||||
|
||||
public Entry(Part part)
|
||||
{
|
||||
this.part = part;
|
||||
drawing = part.BaseDrawing;
|
||||
placedProgram = part.Program;
|
||||
cleanProgram = drawing.Program;
|
||||
x = BitConverter.DoubleToInt64Bits(part.Location.X);
|
||||
y = BitConverter.DoubleToInt64Bits(part.Location.Y);
|
||||
rotation = BitConverter.DoubleToInt64Bits(part.Rotation);
|
||||
isCutOff = drawing.IsCutOff;
|
||||
}
|
||||
|
||||
public bool Matches(Part current) =>
|
||||
ReferenceEquals(part, current)
|
||||
&& ReferenceEquals(drawing, current.BaseDrawing)
|
||||
&& ReferenceEquals(placedProgram, current.Program)
|
||||
&& ReferenceEquals(cleanProgram, current.BaseDrawing.Program)
|
||||
&& x == BitConverter.DoubleToInt64Bits(current.Location.X)
|
||||
&& y == BitConverter.DoubleToInt64Bits(current.Location.Y)
|
||||
&& rotation == BitConverter.DoubleToInt64Bits(current.Rotation)
|
||||
&& isCutOff == current.BaseDrawing.IsCutOff;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Viewport-sized pages of cached overlap details, including continuation pages for a
|
||||
/// single long pair. The caller supplies its actual single-line font measurement.
|
||||
/// No names or numeric details are elided, and no geometry is queried here.
|
||||
/// </summary>
|
||||
public sealed class OverlapHoverPages
|
||||
{
|
||||
private readonly IReadOnlyList<string>[] pages;
|
||||
private readonly IReadOnlyList<string>[] navigation;
|
||||
|
||||
private OverlapHoverPages(IReadOnlyList<string>[] pages, IReadOnlyList<string>[] navigation,
|
||||
bool needsLargerViewport = false)
|
||||
{
|
||||
this.pages = pages;
|
||||
this.navigation = navigation;
|
||||
NeedsLargerViewport = needsLargerViewport;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> Lines => PageCount == 0 ? Array.Empty<string>() : pages[PageIndex];
|
||||
public IReadOnlyList<string> NavigationLines => PageCount == 0 ? Array.Empty<string>() : navigation[PageIndex];
|
||||
public int PageIndex { get; private set; }
|
||||
public int PageCount => pages.Length;
|
||||
public bool NeedsLargerViewport { get; }
|
||||
|
||||
public void MovePage(int delta) => PageIndex = (int)System.Math.Clamp((long)PageIndex + delta, 0,
|
||||
System.Math.Max(0, PageCount - 1));
|
||||
|
||||
public static OverlapHoverPages Create(string text, int pairCount, int maxRows,
|
||||
double maxWidth, Func<string, double> measure)
|
||||
{
|
||||
var tooSmall = new OverlapHoverPages([], [], true);
|
||||
if (maxRows < 1 || !double.IsFinite(maxWidth) || maxWidth <= 0)
|
||||
return tooSmall;
|
||||
var lines = Wrap(text, maxWidth, measure);
|
||||
if (lines == null)
|
||||
return tooSmall;
|
||||
if (lines.Count <= maxRows)
|
||||
return new OverlapHoverPages([lines.AsReadOnly()], [Array.Empty<string>()]);
|
||||
|
||||
// Reserve the real, wrapped hint as well as content. Increasing the reserved
|
||||
// rows can increase the page count/digit count, so converge before slicing.
|
||||
for (var hintRows = 2; hintRows < maxRows;)
|
||||
{
|
||||
var contentRows = maxRows - hintRows;
|
||||
var count = (lines.Count - 1) / contentRows + 1;
|
||||
var hints = new IReadOnlyList<string>[count];
|
||||
var requiredHintRows = hintRows;
|
||||
for (var page = 0; page < count; page++)
|
||||
{
|
||||
var hint = Wrap($"Page {page + 1}/{count} · {pairCount} pairs\nPgUp/PgDn", maxWidth, measure);
|
||||
if (hint == null)
|
||||
return tooSmall;
|
||||
hints[page] = hint.AsReadOnly();
|
||||
requiredHintRows = System.Math.Max(requiredHintRows, hint.Count);
|
||||
}
|
||||
if (requiredHintRows > hintRows)
|
||||
{
|
||||
hintRows = requiredHintRows;
|
||||
continue;
|
||||
}
|
||||
var pages = Enumerable.Range(0, count)
|
||||
.Select(page => (IReadOnlyList<string>)Array.AsReadOnly(lines.Skip(page * contentRows).Take(contentRows).ToArray()))
|
||||
.ToArray();
|
||||
return new OverlapHoverPages(pages, hints);
|
||||
}
|
||||
// There must be room for at least one complete content line AND navigation.
|
||||
return tooSmall;
|
||||
}
|
||||
|
||||
private static List<string> Wrap(string text, double width, Func<string, double> measure)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var paragraph in text.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
if (paragraph.Length == 0)
|
||||
{
|
||||
lines.Add("");
|
||||
continue;
|
||||
}
|
||||
var starts = StringInfo.ParseCombiningCharacters(paragraph).Append(paragraph.Length).ToArray();
|
||||
for (var first = 0; first < starts.Length - 1;)
|
||||
{
|
||||
var low = first;
|
||||
var high = starts.Length - 1;
|
||||
while (low < high)
|
||||
{
|
||||
var end = low + (high - low + 1) / 2;
|
||||
if (measure(paragraph[starts[first]..starts[end]]) <= width)
|
||||
low = end;
|
||||
else
|
||||
high = end - 1;
|
||||
}
|
||||
if (low == first)
|
||||
return null; // Even one grapheme cannot fit; do not silently clip it.
|
||||
var last = low;
|
||||
if (last < starts.Length - 1)
|
||||
{
|
||||
for (var end = last; end > first; end--)
|
||||
{
|
||||
if (char.IsWhiteSpace(paragraph[starts[end - 1]]))
|
||||
{
|
||||
last = end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.Add(paragraph[starts[first]..starts[last]]);
|
||||
first = last;
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// A marker point on one island of shared material. The area centroid of a concave,
|
||||
/// holed or split overlap can lie off the material, so markers use the island's pole of
|
||||
/// inaccessibility instead, with a guaranteed-interior fallback.
|
||||
/// </summary>
|
||||
internal static class OverlapMarker
|
||||
{
|
||||
// Distance evaluations per island; a long sliver otherwise refines along its whole ridge.
|
||||
internal const int MaxCells = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// The island's pole of inaccessibility (searched to a tenth of its area/perimeter, a
|
||||
/// lower bound on a thin island's half-width) when that bounded search lands inside;
|
||||
/// otherwise the midpoint of the widest interior run of a horizontal scan line; otherwise
|
||||
/// <paramref name="fallback"/>. Rings are closed, as the clipping returns them.
|
||||
/// </summary>
|
||||
internal static Vector Find(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, double area,
|
||||
Vector fallback, int maxCells = MaxCells)
|
||||
{
|
||||
var outer = ToPolygon(outline);
|
||||
var hollow = holes.Select(ToPolygon).ToList();
|
||||
var perimeter = outer.Perimeter() + hollow.Sum(hole => hole.Perimeter());
|
||||
var precision = 0.1 * area / perimeter;
|
||||
if (double.IsFinite(precision) && precision > 0)
|
||||
{
|
||||
var pole = PolyLabel.Find(outer, hollow, precision, maxCells);
|
||||
if (Inside(outline, holes, pole))
|
||||
return pole;
|
||||
}
|
||||
return TryScanLine(outline, holes, out var point) ? point : fallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Midpoint of the widest interior run along a horizontal line through the middle of the
|
||||
/// island, placed between vertex heights so crossings are unambiguous (as JTS's
|
||||
/// interior-point-of-area). Even-odd pairing of outline and hole crossings keeps it
|
||||
/// inside the material.
|
||||
/// </summary>
|
||||
private static bool TryScanLine(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, out Vector point)
|
||||
{
|
||||
point = default;
|
||||
var minY = outline.Min(vertex => vertex.Y);
|
||||
var maxY = outline.Max(vertex => vertex.Y);
|
||||
var centre = (minY + maxY) / 2;
|
||||
double below = minY, above = maxY;
|
||||
foreach (var vertex in outline.Concat(holes.SelectMany(hole => hole)))
|
||||
{
|
||||
if (vertex.Y <= centre && vertex.Y > below)
|
||||
below = vertex.Y;
|
||||
else if (vertex.Y > centre && vertex.Y < above)
|
||||
above = vertex.Y;
|
||||
}
|
||||
var y = (below + above) / 2;
|
||||
if (!(y > minY && y < maxY))
|
||||
return false;
|
||||
|
||||
var crossings = new List<double>();
|
||||
AddCrossings(outline, y, crossings);
|
||||
foreach (var hole in holes)
|
||||
AddCrossings(hole, y, crossings);
|
||||
crossings.Sort();
|
||||
var widest = 0.0;
|
||||
for (var i = 0; i + 1 < crossings.Count; i += 2)
|
||||
{
|
||||
var width = crossings[i + 1] - crossings[i];
|
||||
if (width > widest)
|
||||
{
|
||||
widest = width;
|
||||
point = new Vector((crossings[i] + crossings[i + 1]) / 2, y);
|
||||
}
|
||||
}
|
||||
return widest > 0;
|
||||
}
|
||||
|
||||
private static void AddCrossings(IReadOnlyList<Vector> ring, double y, List<double> crossings)
|
||||
{
|
||||
for (var i = 0; i + 1 < ring.Count; i++)
|
||||
{
|
||||
var a = ring[i];
|
||||
var b = ring[i + 1];
|
||||
if ((a.Y > y) != (b.Y > y))
|
||||
crossings.Add(a.X + (y - a.Y) * (b.X - a.X) / (b.Y - a.Y));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Inside(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, Vector point) =>
|
||||
double.IsFinite(point.X) && double.IsFinite(point.Y)
|
||||
&& InsideRing(outline, point) && !holes.Any(hole => InsideRing(hole, point));
|
||||
|
||||
// Crossing-number test over a closed ring (half-open edges, so a vertex counts once).
|
||||
private static bool InsideRing(IReadOnlyList<Vector> ring, Vector point)
|
||||
{
|
||||
var inside = false;
|
||||
for (var i = 0; i + 1 < ring.Count; i++)
|
||||
{
|
||||
var a = ring[i];
|
||||
var b = ring[i + 1];
|
||||
if ((a.Y > point.Y) != (b.Y > point.Y)
|
||||
&& point.X < a.X + (point.Y - a.Y) * (b.X - a.X) / (b.Y - a.Y))
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static Polygon ToPolygon(IReadOnlyList<Vector> ring)
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.AddRange(ring);
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>Request-local, validated single-outer material. Never exposed to report consumers.</summary>
|
||||
internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
||||
{
|
||||
internal static OverlapMaterial Read(List<Entity> entities, CancellationToken cancellationToken)
|
||||
{
|
||||
// ShapeBuilder can reverse entities while chaining. Own a fresh copy for each analysis.
|
||||
var shapes = ShapeBuilder.GetShapes(entities.Select(entity => entity.Clone()));
|
||||
if (shapes.Count == 0)
|
||||
throw new ArgumentException("Drawing has no closed material contour.");
|
||||
var polygons = new List<Polygon>();
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ValidateChain(shape);
|
||||
var polygon = shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance);
|
||||
// The analytic chain was validated above. Normalize its sampled seam (e.g.
|
||||
// sin(2*pi) is not exactly zero), rather than adding a spurious microscopic edge.
|
||||
if (polygon.IsClosed())
|
||||
polygon.Vertices[^1] = polygon.Vertices[0];
|
||||
ValidatePolygon(polygon, cancellationToken);
|
||||
polygons.Add(polygon);
|
||||
}
|
||||
// Sampling can hide a crossing or tangency between curves. Reject native
|
||||
// contour contact before asking the polygon approximation about containment.
|
||||
for (var i = 0; i < shapes.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
for (var j = 0; j < i; j++)
|
||||
{
|
||||
shapes[i].Intersects(shapes[j], out var intersections);
|
||||
if (intersections.Count > 0)
|
||||
throw new ArgumentException("Native material contours cross or touch.");
|
||||
}
|
||||
}
|
||||
var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList();
|
||||
var outer = ordered[0];
|
||||
var holes = ordered.Skip(1).ToList();
|
||||
for (var i = 0; i < holes.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (BoundariesTouch(outer, holes[i], cancellationToken)
|
||||
|| !Inside(outer, holes[i].Vertices[0]))
|
||||
throw new ArgumentException("Contours must have one outer with strictly internal holes.");
|
||||
for (var j = 0; j < i; j++)
|
||||
{
|
||||
if (BoundariesTouch(holes[i], holes[j], cancellationToken)
|
||||
|| Inside(holes[i], holes[j].Vertices[0])
|
||||
|| Inside(holes[j], holes[i].Vertices[0]))
|
||||
throw new ArgumentException("Intersecting holes and nested material islands are unsupported.");
|
||||
}
|
||||
}
|
||||
return new OverlapMaterial(outer, holes);
|
||||
}
|
||||
|
||||
internal OverlapMaterial Transform(double rotation, Vector offset) =>
|
||||
new(TransformPolygon(Outer, rotation, offset),
|
||||
Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList());
|
||||
|
||||
private static Polygon TransformPolygon(Polygon polygon, double rotation, Vector offset)
|
||||
{
|
||||
var transformed = new Polygon();
|
||||
transformed.Vertices.AddRange(polygon.Vertices.Select(point =>
|
||||
(rotation == 0 ? point : point.Rotate(rotation)) + offset));
|
||||
if (transformed.Vertices.Any(point => !IsFinite(point)))
|
||||
throw new ArithmeticException("Transformed contour has nonfinite coordinates.");
|
||||
transformed.UpdateBounds();
|
||||
if (!double.IsFinite(transformed.BoundingBox.Length)
|
||||
|| !double.IsFinite(transformed.BoundingBox.Width))
|
||||
throw new ArithmeticException("Transformed contour bounds overflowed.");
|
||||
var sourceArea = Area(polygon.Vertices);
|
||||
var transformedArea = Area(transformed.Vertices);
|
||||
if (!double.IsFinite(transformedArea) || transformedArea <= Tolerance.Epsilon
|
||||
|| System.Math.Abs(sourceArea - transformedArea)
|
||||
> System.Math.Max(Tolerance.Epsilon, sourceArea * 1e-8))
|
||||
throw new ArithmeticException("Coordinate precision cannot preserve the contour area at this pose.");
|
||||
for (var i = 0; i + 1 < transformed.Vertices.Count; i++)
|
||||
{
|
||||
var a = transformed.Vertices[i];
|
||||
var b = transformed.Vertices[i + 1];
|
||||
if (a.X == b.X && a.Y == b.Y)
|
||||
throw new ArithmeticException("Coordinate precision collapsed a contour edge at this pose.");
|
||||
}
|
||||
return transformed;
|
||||
}
|
||||
|
||||
internal static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||
|
||||
/// <summary>Translation-stable unsigned shoelace area; accepts an explicit closing vertex.</summary>
|
||||
internal static double Area(IReadOnlyList<Vector> vertices)
|
||||
{
|
||||
var twiceArea = 0.0;
|
||||
for (var i = 1; i + 1 < vertices.Count; i++)
|
||||
twiceArea += Cross(vertices[0], vertices[i], vertices[i + 1]);
|
||||
return System.Math.Abs(twiceArea) * 0.5;
|
||||
}
|
||||
|
||||
private static void ValidateChain(Shape shape)
|
||||
{
|
||||
if (!shape.IsClosed())
|
||||
throw new ArgumentException("Material contour is open.");
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
if (!double.IsFinite(entity.Length) || entity.Length <= 0)
|
||||
throw new ArgumentException("Material contour has a nonfinite or zero-length edge.");
|
||||
}
|
||||
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
|
||||
{
|
||||
if (!IsFinite(circle.Center) || !double.IsFinite(circle.Radius) || circle.Radius <= 0)
|
||||
throw new ArgumentException("Material circle is invalid.");
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < shape.Entities.Count; i++)
|
||||
{
|
||||
var end = Endpoints(shape.Entities[i]).End;
|
||||
var start = Endpoints(shape.Entities[(i + 1) % shape.Entities.Count]).Start;
|
||||
// Do not let ShapeBuilder's larger chain tolerance silently repair a broken cut.
|
||||
if (!IsFinite(start) || !IsFinite(end) || end.DistanceTo(start) > Tolerance.Epsilon)
|
||||
throw new ArgumentException("Material contour has a gap or invalid endpoint.");
|
||||
}
|
||||
}
|
||||
|
||||
private static (Vector Start, Vector End) Endpoints(Entity entity) => entity switch
|
||||
{
|
||||
Line line => (line.StartPoint, line.EndPoint),
|
||||
Arc arc => (arc.StartPoint(), arc.EndPoint()),
|
||||
_ => throw new ArgumentException("Unsupported material entity."),
|
||||
};
|
||||
|
||||
private static void ValidatePolygon(Polygon polygon, CancellationToken cancellationToken)
|
||||
{
|
||||
var vertices = polygon.Vertices;
|
||||
var area = Area(vertices);
|
||||
if (vertices.Count < 4 || vertices.Any(point => !IsFinite(point))
|
||||
|| !double.IsFinite(area) || area <= Tolerance.Epsilon)
|
||||
throw new ArgumentException("Material contour is degenerate or nonfinite.");
|
||||
var count = vertices.Count - 1;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var previous = vertices[(i + count - 1) % count];
|
||||
var current = vertices[i];
|
||||
var next = vertices[i + 1];
|
||||
if (current.X == next.X && current.Y == next.Y)
|
||||
throw new ArgumentException("Material polygon has a zero-length edge.");
|
||||
if (Cross(previous, current, next) == 0
|
||||
&& (previous.X - current.X) * (next.X - current.X)
|
||||
+ (previous.Y - current.Y) * (next.Y - current.Y) > 0)
|
||||
throw new ArgumentException("Material polygon has a retraced edge.");
|
||||
for (var j = i + 2; j < count; j++)
|
||||
{
|
||||
if (i == 0 && j == count - 1)
|
||||
continue;
|
||||
if (SegmentsTouch(vertices[i], vertices[i + 1], vertices[j], vertices[j + 1]))
|
||||
throw new ArgumentException("Material contour self-intersects or touches itself.");
|
||||
}
|
||||
}
|
||||
// Ear clipping can stop early on unusable geometry. Do not certify that as clear.
|
||||
var local = TransformPolygon(polygon, 0, vertices[0] * -1);
|
||||
var triangulatedArea = Collision.Triangulate(local).Sum(triangle => Area(triangle.Vertices));
|
||||
if (!double.IsFinite(triangulatedArea)
|
||||
|| System.Math.Abs(triangulatedArea - area) > System.Math.Max(Tolerance.Epsilon, area * 1e-9))
|
||||
throw new ArgumentException("Material contour could not be completely triangulated.");
|
||||
}
|
||||
|
||||
private static bool BoundariesTouch(Polygon a, Polygon b, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var i = 0; i + 1 < a.Vertices.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
for (var j = 0; j + 1 < b.Vertices.Count; j++)
|
||||
if (SegmentsTouch(a.Vertices[i], a.Vertices[i + 1], b.Vertices[j], b.Vertices[j + 1]))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool SegmentsTouch(Vector a, Vector b, Vector c, Vector d)
|
||||
{
|
||||
var ac = Cross(a, b, c);
|
||||
var ad = Cross(a, b, d);
|
||||
var ca = Cross(c, d, a);
|
||||
var cb = Cross(c, d, b);
|
||||
return ac == 0 && OnSegment(a, b, c) || ad == 0 && OnSegment(a, b, d)
|
||||
|| ca == 0 && OnSegment(c, d, a) || cb == 0 && OnSegment(c, d, b)
|
||||
|| (ac < 0 && ad > 0 || ac > 0 && ad < 0)
|
||||
&& (ca < 0 && cb > 0 || ca > 0 && cb < 0);
|
||||
}
|
||||
|
||||
private static bool OnSegment(Vector a, Vector b, Vector point) =>
|
||||
point.X >= System.Math.Min(a.X, b.X) && point.X <= System.Math.Max(a.X, b.X)
|
||||
&& point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y);
|
||||
|
||||
// Boundary contact is rejected before this winding-number test is used for topology.
|
||||
private static bool Inside(Polygon polygon, Vector point)
|
||||
{
|
||||
var winding = 0;
|
||||
for (var i = 0; i + 1 < polygon.Vertices.Count; i++)
|
||||
{
|
||||
var a = polygon.Vertices[i];
|
||||
var b = polygon.Vertices[i + 1];
|
||||
if (a.Y <= point.Y && b.Y > point.Y && Cross(a, b, point) > 0)
|
||||
winding++;
|
||||
else if (a.Y > point.Y && b.Y <= point.Y && Cross(a, b, point) < 0)
|
||||
winding--;
|
||||
}
|
||||
return winding != 0;
|
||||
}
|
||||
|
||||
private static double Cross(Vector a, Vector b, Vector point) =>
|
||||
(b.X - a.X) * (point.Y - a.Y) - (b.Y - a.Y) * (point.X - a.X);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Reuses each clean drawing program's converted entities and prepared (validated, chorded,
|
||||
/// triangulation-checked) material across overlap requests. Preparation dominates the cost of
|
||||
/// drawings with many holes, and it depends only on the drawing, not on where parts sit, so a
|
||||
/// recheck after moving parts only repeats the cheap pose transforms and pair clipping.
|
||||
/// Entries are keyed by <see cref="Program"/> reference and released with it. A changed code
|
||||
/// count or program rotation is detected, but that is not a geometry hash: call
|
||||
/// <see cref="Clear"/> before any in-place edit of a clean program or its hole subprograms.
|
||||
/// Capture on one thread at a time; prepared entries may be shared by concurrent analyses.
|
||||
/// </summary>
|
||||
public sealed class OverlapMaterialCache
|
||||
{
|
||||
private readonly ConditionalWeakTable<Program, OverlapSource> sources = new();
|
||||
|
||||
public void Clear() => sources.Clear();
|
||||
|
||||
internal OverlapSource Get(Program program, Func<Program, OverlapSource> create)
|
||||
{
|
||||
if (sources.TryGetValue(program, out var source) && source.Matches(program))
|
||||
return source;
|
||||
source = create(program);
|
||||
sources.AddOrUpdate(program, source);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Owned converted entities for one clean program, plus its lazily prepared material.</summary>
|
||||
internal sealed class OverlapSource
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly int codeCount;
|
||||
private readonly long rotation;
|
||||
private PreparedMaterial prepared;
|
||||
|
||||
internal OverlapSource(Program program, List<Entity> entities, string error)
|
||||
{
|
||||
codeCount = CodeCount(program);
|
||||
rotation = BitConverter.DoubleToInt64Bits(program.Rotation);
|
||||
Entities = entities;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
/// <summary>Never mutated; preparation clones before chaining.</summary>
|
||||
internal List<Entity> Entities { get; }
|
||||
internal string Error { get; }
|
||||
|
||||
internal bool Matches(Program program) =>
|
||||
CodeCount(program) == codeCount && BitConverter.DoubleToInt64Bits(program.Rotation) == rotation;
|
||||
|
||||
// Program.Codes is a writable field; a missing list is a refused source, not a crash.
|
||||
private static int CodeCount(Program program) => program.Codes?.Count ?? -1;
|
||||
|
||||
/// <summary>
|
||||
/// Prepares once and shares the result. A geometry failure is cached like a success;
|
||||
/// cancellation is not, so a superseded request cannot poison the next one.
|
||||
/// </summary>
|
||||
internal PreparedMaterial Prepare(CancellationToken cancellationToken)
|
||||
{
|
||||
var current = Volatile.Read(ref prepared);
|
||||
if (current != null)
|
||||
return current;
|
||||
lock (gate)
|
||||
{
|
||||
if (prepared != null)
|
||||
return prepared;
|
||||
PreparedMaterial result;
|
||||
try
|
||||
{
|
||||
result = new PreparedMaterial(OverlapMaterial.Read(Entities, cancellationToken), null);
|
||||
}
|
||||
catch (Exception exception) when (PlateOverlapAnalyzer.IsGeometryFailure(exception))
|
||||
{
|
||||
result = new PreparedMaterial(null, exception.Message);
|
||||
}
|
||||
Volatile.Write(ref prepared, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Validated local-frame material, only ever read (transformed into new polygons).</summary>
|
||||
internal sealed record PreparedMaterial(OverlapMaterial Material, string Error);
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary>
|
||||
public static class OverlapPairPresentation
|
||||
{
|
||||
/// <summary>True for part-vs-clamp findings carried on the report (negative clamp slot).</summary>
|
||||
public static bool IsClampFinding(PlateOverlapPair pair) => pair.PartBId < 0;
|
||||
|
||||
public static string Label(PlateOverlapPair pair) =>
|
||||
IsClampFinding(pair) ? $"{pair.PartAId + 1}/clamp" : $"{pair.PartAId + 1}/{pair.PartBId + 1}";
|
||||
|
||||
public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null)
|
||||
{
|
||||
var units = UnitsHelper.GetShortString(capturedUnits);
|
||||
var head = IsClampFinding(pair)
|
||||
? $"Clamp overlap: {pair.PartAName} / {pair.PartBName}"
|
||||
: $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}";
|
||||
return head + "\n"
|
||||
+ $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
|
||||
+ $"Marker: ({Number(pair.Marker.X, provider)}, {Number(pair.Marker.Y, provider)}) {units}";
|
||||
}
|
||||
|
||||
// Significant figures adapt to scale: a positive sliver must never read as zero.
|
||||
public static string Number(double value, IFormatProvider provider = null) =>
|
||||
value.ToString("G6", provider ?? CultureInfo.CurrentCulture);
|
||||
|
||||
public static double MarkerHalfSize(int deviceDpi) => 6.0 * deviceDpi / 96;
|
||||
public static double HitRadius(int deviceDpi) => 10.0 * deviceDpi / 96;
|
||||
|
||||
/// <summary>
|
||||
/// Projects cached markers into a screen-pixel coordinate space. The pointer must
|
||||
/// use that same space; view zoom never scales the DPI-adjusted hit radius.
|
||||
/// Coincident or nearby markers return every matching pair in stable ID order.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PlateOverlapPair> HitTest(IEnumerable<PlateOverlapPair> pairs,
|
||||
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi)
|
||||
{
|
||||
var radius = HitRadius(deviceDpi);
|
||||
return pairs.Where(pair =>
|
||||
{
|
||||
var center = worldToScreen(pair.Marker);
|
||||
var dx = pointer.X - center.X;
|
||||
var dy = pointer.Y - center.Y;
|
||||
return dx * dx + dy * dy <= radius * radius;
|
||||
}).OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
public enum OverlapDisplayMode { Off, Areas, Centroids, Both }
|
||||
public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Failed, Canceled, Stale }
|
||||
|
||||
/// <summary>UI-thread lifecycle policy, independent of workers, GDI and view transforms.</summary>
|
||||
public sealed class OverlapReportState
|
||||
{
|
||||
private OverlapGeometryStamp stamp;
|
||||
private OverlapGeometryStamp displayStamp;
|
||||
private OverlapGeometryStamp observedDisplayStamp;
|
||||
private int uncheckedPartCount;
|
||||
|
||||
public long Generation { get; private set; }
|
||||
public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked;
|
||||
public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas;
|
||||
public PlateOverlapReport Report { get; private set; }
|
||||
/// <summary>Known overlap pairs safe to draw, even while the full layout needs a recheck.</summary>
|
||||
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
|
||||
public IReadOnlyList<CutOffClampFinding> DisplayCutOffClampFindings { get; private set; } =
|
||||
Array.Empty<CutOffClampFinding>();
|
||||
public bool IsRunning => Status == OverlapCheckStatus.Checking;
|
||||
|
||||
public string Message => Status switch
|
||||
{
|
||||
OverlapCheckStatus.Checking => "Checking overlaps…",
|
||||
OverlapCheckStatus.Current => Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0
|
||||
? (Report.Pairs.Count == 0 ? "No material overlaps detected"
|
||||
: $"Overlaps: {Report.Pairs.Count} pairs")
|
||||
: $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps, "
|
||||
+ $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts",
|
||||
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
|
||||
+ (Report.ClampFindings.Count == 0 ? "" : $"{Report.ClampFindings.Count} clamp findings; ")
|
||||
+ (Report.CutOffClampFindings.Count == 0 ? ""
|
||||
: $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts; ")
|
||||
+ $"{uncheckedPartCount} parts could not be checked",
|
||||
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
|
||||
OverlapCheckStatus.Canceled => "Overlap check canceled",
|
||||
OverlapCheckStatus.Stale => "Overlap check out of date — run Check Overlaps again",
|
||||
_ => "Overlaps: not checked"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Starts a request. A manual check from Off shows Areas; an automatic recheck keeps
|
||||
/// the user's display choice, including Off.
|
||||
/// </summary>
|
||||
public long Begin(Plate plate, bool automatic = false)
|
||||
{
|
||||
RefreshDisplayPairs(plate);
|
||||
Clear(OverlapCheckStatus.Checking, preserveDisplay: true);
|
||||
stamp = OverlapGeometryStamp.Capture(plate);
|
||||
if (!automatic && DisplayMode == OverlapDisplayMode.Off)
|
||||
DisplayMode = OverlapDisplayMode.Areas;
|
||||
return Generation;
|
||||
}
|
||||
|
||||
public bool TryPublish(long generation, Plate plate, PlateOverlapReport report)
|
||||
{
|
||||
if (!CanComplete(generation, plate))
|
||||
return false;
|
||||
Report = report;
|
||||
DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs
|
||||
: report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly();
|
||||
DisplayCutOffClampFindings = report.CutOffClampFindings;
|
||||
displayStamp = stamp;
|
||||
observedDisplayStamp = stamp;
|
||||
uncheckedPartCount = CountUncheckedParts(report.Issues);
|
||||
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryFail(long generation, Plate plate)
|
||||
{
|
||||
if (!CanComplete(generation, plate))
|
||||
return false;
|
||||
Clear(OverlapCheckStatus.Failed);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CanComplete(long generation, Plate plate) =>
|
||||
generation == Generation && IsRunning && EnsureFresh(plate);
|
||||
|
||||
public bool EnsureFresh(Plate plate)
|
||||
{
|
||||
RefreshDisplayPairs(plate);
|
||||
if (stamp == null)
|
||||
return false;
|
||||
if (stamp.Matches(plate))
|
||||
return true;
|
||||
Invalidate(plate);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Layout edit: retain only pairs whose two ordered slots still match exactly.</summary>
|
||||
public void Invalidate(Plate plate)
|
||||
{
|
||||
RefreshDisplayPairs(plate);
|
||||
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
|
||||
Clear(OverlapCheckStatus.Stale, preserveDisplay: true);
|
||||
}
|
||||
|
||||
/// <summary>In-place geometry edits and teardown must forget every cached display pair.</summary>
|
||||
public void Invalidate()
|
||||
{
|
||||
ClearDisplayPairs();
|
||||
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
|
||||
Clear(OverlapCheckStatus.Stale);
|
||||
}
|
||||
|
||||
private void RefreshDisplayPairs(Plate plate)
|
||||
{
|
||||
if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true)
|
||||
return;
|
||||
var unchanged = displayStamp.UnchangedSlots(plate);
|
||||
var sameZones = displayStamp.SameZones(plate);
|
||||
if (!sameZones || !displayStamp.SameCutOffs(plate))
|
||||
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
|
||||
var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId]
|
||||
&& (pair.PartBId < 0 ? sameZones : unchanged[pair.PartBId])).ToList();
|
||||
if (retained.Count != DisplayPairs.Count)
|
||||
DisplayPairs = retained.AsReadOnly();
|
||||
if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0)
|
||||
ClearDisplayPairs();
|
||||
else
|
||||
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
|
||||
}
|
||||
|
||||
private void ClearDisplayPairs()
|
||||
{
|
||||
DisplayPairs = Array.Empty<PlateOverlapPair>();
|
||||
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
|
||||
displayStamp = null;
|
||||
observedDisplayStamp = null;
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
if (IsRunning)
|
||||
Clear(OverlapCheckStatus.Canceled);
|
||||
}
|
||||
|
||||
public void Reset() => Clear(OverlapCheckStatus.NotChecked);
|
||||
|
||||
private void Clear(OverlapCheckStatus status, bool preserveDisplay = false)
|
||||
{
|
||||
if (!preserveDisplay)
|
||||
ClearDisplayPairs();
|
||||
Generation++;
|
||||
Report = null;
|
||||
uncheckedPartCount = 0;
|
||||
stamp = null;
|
||||
Status = status;
|
||||
}
|
||||
|
||||
public static int CountUncheckedParts(IEnumerable<PlateOverlapIssue> issues)
|
||||
{
|
||||
var ids = new HashSet<int>();
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
ids.Add(issue.PartAId);
|
||||
if (issue.PartBId.HasValue)
|
||||
ids.Add(issue.PartBId.Value);
|
||||
}
|
||||
return ids.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Pairs of placed parts closer than the plate's part spacing. Candidates are found exactly as Auto
|
||||
/// Nest's layout check finds them: each part's clean nesting material
|
||||
/// (<see cref="NestingMaterial.ForNesting"/>, leads excluded) outlined by <see cref="SpacingOutline"/>,
|
||||
/// the leftmost of a pair inflated by <see cref="SpacingOutline.InflationFor"/>. That outline
|
||||
/// circumscribes arcs by up to its chord tolerance, more than the slack, and engines add a margin
|
||||
/// for it; interactive fills place native arcs exactly at the spacing. So each candidate is
|
||||
/// confirmed on the native contours flattened at <see cref="FineTolerance"/> and reported only when
|
||||
/// that gap is short of the spacing by more than the slack. Zero spacing enforces nothing, so
|
||||
/// contact stays valid.
|
||||
/// Pairs whose material already overlaps, and parts the overlap check could not read, are left to
|
||||
/// that check. The sheet edge and clamps are not spacing-checked here.
|
||||
/// </summary>
|
||||
internal static class PartSpacingReview
|
||||
{
|
||||
/// <summary>Chord tolerance of the confirming measurement. Circumscribed on both sides, it
|
||||
/// understates a gap by at most twice this, a tenth of <see cref="SpacingOutline.SpacingSlack"/>.</summary>
|
||||
internal const double FineTolerance = SpacingOutline.SpacingSlack / 20;
|
||||
|
||||
internal static void Add(Plate plate, int plateNumber, IReadOnlyList<Part> parts,
|
||||
IReadOnlyList<int> partNumbers, PlateOverlapReport overlap, List<PostVerificationFinding> findings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var spacing = plate.PartSpacing;
|
||||
if (!double.IsFinite(spacing) || spacing < 0)
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||
$"Part spacing check: plate part spacing {spacing} is not a finite, non-negative value."));
|
||||
return;
|
||||
}
|
||||
var inflateBy = SpacingOutline.InflationFor(spacing);
|
||||
if (inflateBy <= Tolerance.Epsilon || parts.Count < 2)
|
||||
return;
|
||||
|
||||
var unreadable = overlap.Issues.Where(issue => issue.PartBId == null)
|
||||
.Select(issue => issue.PartAId).ToHashSet();
|
||||
var overlapping = overlap.Pairs.Select(pair => Key(pair.PartAId, pair.PartBId)).ToHashSet();
|
||||
var profiles = new Dictionary<(Program, long), ShapeProfile>();
|
||||
var shapes = new ShapeProfile[parts.Count];
|
||||
var fine = new FineRings[parts.Count];
|
||||
var raw = new SpacingOutline[parts.Count];
|
||||
var inflated = new SpacingOutline[parts.Count];
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (unreadable.Contains(i))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
var profile = Profile(parts[i], profiles);
|
||||
if (profile == null)
|
||||
continue;
|
||||
shapes[i] = profile;
|
||||
raw[i] = SpacingOutline.Build(profile, parts[i].Location, 0);
|
||||
inflated[i] = SpacingOutline.Build(profile, parts[i].Location, inflateBy);
|
||||
if (raw[i] == null || inflated[i] == null)
|
||||
throw new ArgumentException("Part material has no closed perimeter.");
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
raw[i] = inflated[i] = null;
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumbers[i], null,
|
||||
"Part spacing check: " + exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
var order = Enumerable.Range(0, parts.Count)
|
||||
.Where(i => raw[i] != null)
|
||||
.OrderBy(i => raw[i].Perimeter.BoundingBox.Left)
|
||||
.ToList();
|
||||
for (var a = 0; a < order.Count; a++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var i = order[a];
|
||||
var reach = inflated[i].Perimeter.BoundingBox;
|
||||
for (var b = a + 1; b < order.Count; b++)
|
||||
{
|
||||
var j = order[b];
|
||||
var other = raw[j].Perimeter.BoundingBox;
|
||||
// Sorted by Left, so nothing further along can reach part i either.
|
||||
if (other.Left > reach.Right + Tolerance.Epsilon)
|
||||
break;
|
||||
if (!BoxesTouch(reach, other) || overlapping.Contains(Key(i, j))
|
||||
|| !inflated[i].Overlaps(raw[j]))
|
||||
continue;
|
||||
double gap;
|
||||
Vector marker;
|
||||
try
|
||||
{
|
||||
fine[i] ??= FineRings.Of(shapes[i], parts[i].Location);
|
||||
fine[j] ??= FineRings.Of(shapes[j], parts[j].Location);
|
||||
gap = Gap(fine[i], fine[j], spacing, out marker);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber,
|
||||
System.Math.Min(partNumbers[i], partNumbers[j]),
|
||||
System.Math.Max(partNumbers[i], partNumbers[j]),
|
||||
"Part spacing check: " + exception.Message));
|
||||
continue;
|
||||
}
|
||||
if (!(gap < spacing - SpacingOutline.SpacingSlack))
|
||||
continue;
|
||||
var (first, second) = partNumbers[i] < partNumbers[j] ? (i, j) : (j, i);
|
||||
findings.Add(new(PostVerificationKind.PartSpacing, plateNumber, partNumbers[first],
|
||||
partNumbers[second],
|
||||
$"'{Name(parts[first])}' and '{Name(parts[second])}' are {gap:F3} apart, closer than the "
|
||||
+ $"plate's part spacing ({spacing:F3}).")
|
||||
{ Location = marker });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clean drawing material, rotated to the part's pose; null when the part has none.
|
||||
/// Converted once per program and rotation.</summary>
|
||||
private static ShapeProfile Profile(Part part, Dictionary<(Program, long), ShapeProfile> profiles)
|
||||
{
|
||||
var program = part.BaseDrawing.Program;
|
||||
var rotation = part.Rotation - program.Rotation;
|
||||
if (!double.IsFinite(rotation))
|
||||
throw new ArgumentException("Part pose must be finite.");
|
||||
var key = (program, BitConverter.DoubleToInt64Bits(rotation));
|
||||
if (profiles.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
// Conversion creates fresh geometry, so rotating it never touches the live program.
|
||||
var entities = ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
|
||||
.Where(entity => entity.Layer != SpecialLayers.Leadin && entity.Layer != SpecialLayers.Leadout)
|
||||
.ForNesting();
|
||||
if (rotation != 0)
|
||||
foreach (var entity in entities)
|
||||
entity.Rotate(rotation);
|
||||
var profile = entities.Count == 0 ? null : new ShapeProfile(entities);
|
||||
if (profile != null && profile.Perimeter == null)
|
||||
throw new ArgumentException("Part material has no closed perimeter.");
|
||||
profiles[key] = profile;
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// <summary>Closest boundary approach between two parts, including a part inside the other's
|
||||
/// cutout; <paramref name="marker"/> is the middle of that gap. Only edges within
|
||||
/// <paramref name="reach"/> of the other part's bounds can be closer than it, so the rest are
|
||||
/// skipped. Infinity when no edges are that near.</summary>
|
||||
private static double Gap(FineRings a, FineRings b, double reach, out Vector marker)
|
||||
{
|
||||
var best = double.PositiveInfinity;
|
||||
var middle = a.Bounds.Center;
|
||||
var perimeterA = Near(a.Perimeter, b.Bounds, reach);
|
||||
var perimeterB = Near(b.Perimeter, a.Bounds, reach);
|
||||
Measure(perimeterA, perimeterB);
|
||||
foreach (var hole in a.Holes)
|
||||
Measure(Near(hole, b.Bounds, reach), perimeterB);
|
||||
foreach (var hole in b.Holes)
|
||||
Measure(perimeterA, Near(hole, a.Bounds, reach));
|
||||
marker = middle;
|
||||
return best;
|
||||
|
||||
void Measure(List<Line> ringA, List<Line> ringB)
|
||||
{
|
||||
if (ringA.Count == 0 || ringB.Count == 0)
|
||||
return;
|
||||
var distance = Clearance.ClosestPoints(ringA, ringB, out var onA, out var onB);
|
||||
if (distance < best)
|
||||
{
|
||||
best = distance;
|
||||
middle = (onA + onB) * 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Line> Near(List<Line> lines, Box other, double reach) =>
|
||||
lines.Where(line =>
|
||||
System.Math.Min(line.StartPoint.X, line.EndPoint.X) <= other.Right + reach
|
||||
&& System.Math.Max(line.StartPoint.X, line.EndPoint.X) >= other.Left - reach
|
||||
&& System.Math.Min(line.StartPoint.Y, line.EndPoint.Y) <= other.Top + reach
|
||||
&& System.Math.Max(line.StartPoint.Y, line.EndPoint.Y) >= other.Bottom - reach).ToList();
|
||||
|
||||
/// <summary>A part's native contours in world space, flattened at <see cref="FineTolerance"/>.
|
||||
/// Circumscribed, so the measured gap never exceeds the true one by more than rounding.</summary>
|
||||
private sealed class FineRings
|
||||
{
|
||||
public List<Line> Perimeter { get; private init; }
|
||||
public List<List<Line>> Holes { get; private init; }
|
||||
public Box Bounds { get; private init; }
|
||||
|
||||
public static FineRings Of(ShapeProfile profile, Vector location)
|
||||
{
|
||||
var perimeter = World(profile.Perimeter, location, out var bounds);
|
||||
return new FineRings
|
||||
{
|
||||
Perimeter = perimeter.ToLines(),
|
||||
Holes = profile.Cutouts.Select(cutout => World(cutout, location, out _).ToLines()).ToList(),
|
||||
Bounds = bounds,
|
||||
};
|
||||
}
|
||||
|
||||
private static Polygon World(Shape shape, Vector location, out Box bounds)
|
||||
{
|
||||
var polygon = ClipperBridge.Flatten(shape, FineTolerance, true);
|
||||
polygon.Offset(location);
|
||||
polygon.UpdateBounds();
|
||||
bounds = polygon.BoundingBox;
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
|
||||
private static (int, int) Key(int a, int b) => a < b ? (a, b) : (b, a);
|
||||
|
||||
private static string Name(Part part) => part.BaseDrawing.Name;
|
||||
|
||||
private static bool BoxesTouch(Box a, Box b) =>
|
||||
a.Left <= b.Right + Tolerance.Epsilon
|
||||
&& b.Left <= a.Right + Tolerance.Epsilon
|
||||
&& a.Bottom <= b.Top + Tolerance.Epsilon
|
||||
&& b.Bottom <= a.Top + Tolerance.Epsilon;
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only, hole-aware material overlap diagnostics, separate from the engine's boolean checks.
|
||||
/// Uses clean drawing outlines, not placed lead-in/tab toolpaths or spacing offsets.
|
||||
/// </summary>
|
||||
public static class PlateOverlapAnalyzer
|
||||
{
|
||||
public const double ChordTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// Capture a whole plate including active clamps and nominal cut-off definitions.
|
||||
/// Use this entry for the desktop overlap check so manual cut-off contacts
|
||||
/// cannot be mistaken for a clear plate merely because cut-offs are not material.
|
||||
/// Clamp zones are mapped from the sheet's lower-left frame into the parts' signed
|
||||
/// placement frame; a malformed zone throws <see cref="ArgumentException"/>.
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot CapturePlate(Plate plate, OverlapMaterialCache cache = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
var material = Capture(plate.Parts.ToArray(), ClampZoneFrame.ToPlacement(plate).ToArray(),
|
||||
cache ?? new OverlapMaterialCache(), cancellationToken);
|
||||
var cutoffs = ClampCutDiagnostics.Find(plate);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(),
|
||||
material.Clamps.ToList(), cutoffs.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures poses and converts each distinct clean source program to owned entities once.
|
||||
/// Inputs must not change during capture. Later analysis never reads live domain objects.
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Capture(parts, null, new OverlapMaterialCache(), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, additionally capturing
|
||||
/// the plate's clamp keep-out rectangles: Analyze then reports part-vs-clamp findings
|
||||
/// (C1) alongside part pairs. Null means no zones.
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||
IReadOnlyList<ClampZone> clampZones, CancellationToken cancellationToken = default) =>
|
||||
Capture(parts, clampZones, new OverlapMaterialCache(), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, but reuses converted
|
||||
/// and prepared drawing material from <paramref name="cache"/> across requests. Clear the
|
||||
/// cache before any in-place clean-program edit (see <see cref="OverlapMaterialCache"/>).
|
||||
/// </summary>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts, OverlapMaterialCache cache,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Capture(parts, null, cache, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="Capture(IReadOnlyList{Part}, IReadOnlyList{ClampZone}, CancellationToken)"/>
|
||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||
IReadOnlyList<ClampZone> clampZones, OverlapMaterialCache cache,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parts);
|
||||
ArgumentNullException.ThrowIfNull(cache);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var captured = new List<CapturedOverlapPart>();
|
||||
var issues = new List<PlateOverlapIssue>();
|
||||
for (var id = 0; id < parts.Count; id++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var part = parts[id];
|
||||
if (part?.BaseDrawing?.IsCutOff == true)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
if (part?.BaseDrawing?.Program == null)
|
||||
throw new ArgumentException("Part has no clean drawing program.");
|
||||
var program = part.BaseDrawing.Program;
|
||||
var rotation = part.Rotation - program.Rotation;
|
||||
var location = part.Location;
|
||||
if (!double.IsFinite(rotation) || !OverlapMaterial.IsFinite(location))
|
||||
throw new ArgumentException("Part pose must be finite.");
|
||||
var source = cache.Get(program, CaptureSource);
|
||||
if (source.Error != null)
|
||||
throw new ArgumentException(source.Error);
|
||||
captured.Add(new CapturedOverlapPart(id, part.BaseDrawing.Name,
|
||||
source, rotation, location));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(id, null, exception.Message));
|
||||
}
|
||||
}
|
||||
var clamps = new List<CapturedClamp>();
|
||||
if (clampZones != null)
|
||||
{
|
||||
for (var ordinal = 0; ordinal < clampZones.Count; ordinal++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var zone = clampZones[ordinal];
|
||||
// ClampZone validates finiteness and positive size at construction; the
|
||||
// snapshot still owns its own copy so later plate edits never move it.
|
||||
clamps.Add(new CapturedClamp(ordinal, zone.Id,
|
||||
new Box(zone.Bounds.X, zone.Bounds.Y, zone.Bounds.Length, zone.Bounds.Width)));
|
||||
}
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PlateOverlapSnapshot(captured, issues, clamps);
|
||||
}
|
||||
|
||||
private static OverlapSource CaptureSource(Program program)
|
||||
{
|
||||
try
|
||||
{
|
||||
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance), false);
|
||||
// Conversion creates fresh geometry, including expanded shared hole calls;
|
||||
// no cloning/rotation of a live program or subprogram is necessary. A baked-in
|
||||
// seam overcut is trimmed on an owned copy: it encloses no area.
|
||||
return new OverlapSource(program, ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
|
||||
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
|
||||
&& entity.Layer != SpecialLayers.Leadin
|
||||
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
return new OverlapSource(program, null, exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Convenience synchronous capture and analysis of a group of parts.</summary>
|
||||
public static PlateOverlapReport Analyze(IReadOnlyList<Part> parts,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Analyze(Capture(parts, cancellationToken), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns deterministic pair reports containing closed world-coordinate overlap islands.
|
||||
/// Cancellation throws and publishes no partial report. Check IsComplete before claiming clear.
|
||||
/// </summary>
|
||||
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Analyze(snapshot, null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Incremental recheck: identical to a full analysis of <paramref name="snapshot"/>, but a
|
||||
/// pair whose two parts are unchanged since <paramref name="previous"/> (same captured source
|
||||
/// and bit-identical pose, in the same relative order) reuses that report's result instead
|
||||
/// of being clipped again. After moving one part only its own neighbors are recomputed.
|
||||
/// Reuse needs sources shared through one <see cref="OverlapMaterialCache"/>; otherwise every
|
||||
/// pair is recomputed. Null <paramref name="previous"/> performs a full analysis.
|
||||
/// </summary>
|
||||
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot, PlateOverlapReport previous,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var reuse = PairReuse.Create(previous, snapshot);
|
||||
var issues = snapshot.Issues.ToList();
|
||||
var pairs = new List<PlateOverlapPair>();
|
||||
var prepared = new List<PreparedPart>();
|
||||
foreach (var part in snapshot.Parts)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
// Prepared material is shared by every part and request using this source.
|
||||
var source = part.Source.Prepare(cancellationToken);
|
||||
if (source.Error != null)
|
||||
throw new ArgumentException(source.Error);
|
||||
var material = source.Material.Transform(part.Rotation, part.Location);
|
||||
prepared.Add(new PreparedPart(part, material));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(part.Id, null, exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
var sorted = prepared.OrderBy(part => part.Material.Outer.BoundingBox.Left)
|
||||
.ThenBy(part => part.Input.Id).ToArray();
|
||||
for (var i = 0; i < sorted.Length; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var first = sorted[i];
|
||||
var bounds = first.Material.Outer.BoundingBox;
|
||||
for (var j = i + 1; j < sorted.Length; j++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var second = sorted[j];
|
||||
var otherBounds = second.Material.Outer.BoundingBox;
|
||||
if (otherBounds.Left >= bounds.Right)
|
||||
break;
|
||||
if (otherBounds.Bottom >= bounds.Top || bounds.Bottom >= otherBounds.Top)
|
||||
continue;
|
||||
var a = first.Input.Id < second.Input.Id ? first : second;
|
||||
var b = first.Input.Id < second.Input.Id ? second : first;
|
||||
if (reuse != null && reuse.TryReuse(a.Input, b.Input, pairs, issues))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
// Keep pair clipping arithmetic near the parts where possible, then
|
||||
// restore output to world space.
|
||||
var origin = a.Material.Outer.Vertices[0];
|
||||
var localA = a.Material.Transform(0, origin * -1);
|
||||
var localB = b.Material.Transform(0, origin * -1);
|
||||
if (!TryShare(localA, localB.Outer, localB.Holes, origin, cancellationToken,
|
||||
out var regions, out var centroid, out var marker))
|
||||
continue;
|
||||
pairs.Add(new PlateOverlapPair(a.Input.Id, b.Input.Id,
|
||||
a.Input.Name, b.Input.Name, regions, centroid, marker));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(a.Input.Id, b.Input.Id, exception.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken);
|
||||
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
|
||||
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
|
||||
.ThenBy(issue => issue.PartBId).ToList(), snapshot, clampFindings
|
||||
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList(),
|
||||
snapshot.CutOffClampFindings.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C1 part-vs-clamp findings: the prepared hole-aware material against each captured
|
||||
/// zone rectangle with the same exact Collision oracle as part pairs. Deterministic in
|
||||
/// (part input id, clamp ordinal) order; failures become part issues like any other
|
||||
/// pair geometry failure. Empty zone lists return without touching the material.
|
||||
/// </summary>
|
||||
private static List<PlateOverlapPair> FindClampOverlaps(List<PreparedPart> prepared,
|
||||
PlateOverlapSnapshot snapshot, List<PlateOverlapIssue> issues,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var findings = new List<PlateOverlapPair>();
|
||||
if (snapshot.Clamps.Count == 0)
|
||||
return findings;
|
||||
|
||||
foreach (var part in prepared)
|
||||
{
|
||||
var partBounds = part.Material.Outer.BoundingBox;
|
||||
foreach (var clamp in snapshot.Clamps)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var zone = clamp.Bounds;
|
||||
// The prepared material is inscribed at ChordTolerance, so real material can
|
||||
// reach that far past its bounds; a tiny clamp there must still be checked.
|
||||
var reach = KeepOutOverlap.NeedsToleranceFreeTest(zone) ? ChordTolerance : 0;
|
||||
if (partBounds.Right + reach <= zone.Left || zone.Right <= partBounds.Left - reach
|
||||
|| partBounds.Top + reach <= zone.Bottom || zone.Top <= partBounds.Bottom - reach)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
// A clamp below the polygon tolerance collapses in Collision.Check and would
|
||||
// read as clear (PM aa995123): judge it tolerance-free and report the whole
|
||||
// (negligibly small) clamp rectangle as the overlap fragment.
|
||||
if (KeepOutOverlap.NeedsToleranceFreeTest(zone))
|
||||
{
|
||||
// Re-read the native contours conservatively and finely enough for
|
||||
// this clamp: the shared 0.001 chords can hide material between an
|
||||
// arc and its chord.
|
||||
var profile = new ShapeProfile(part.Input.Source.Entities.Select(e => e.Clone()).ToList());
|
||||
if (profile.Perimeter == null)
|
||||
throw new ArgumentException("Drawing has no closed material contour.");
|
||||
if (KeepOutOverlap.ConservativePolygons(profile, zone)
|
||||
.Select(polygons => new OverlapMaterial(polygons.Perimeter, polygons.Holes)
|
||||
.Transform(part.Input.Rotation, part.Input.Location))
|
||||
.Any(fine => KeepOutOverlap.MaterialEnters(fine.Outer, fine.Holes, zone)))
|
||||
findings.Add(TinyClampFinding(part, clamp));
|
||||
continue;
|
||||
}
|
||||
var origin = part.Material.Outer.Vertices[0];
|
||||
var local = part.Material.Transform(0, origin * -1);
|
||||
var rect = RectanglePolygon(zone, origin * -1);
|
||||
if (!TryShare(local, rect, null, origin, cancellationToken,
|
||||
out var regions, out var centroid, out var marker))
|
||||
continue;
|
||||
findings.Add(new PlateOverlapPair(part.Input.Id,
|
||||
ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
|
||||
$"Clamp '{clamp.Id}'", regions, centroid, marker));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
issues.Add(new PlateOverlapIssue(part.Input.Id, null, exception.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared material of a rebased operand with another rebased outline/holes, as world
|
||||
/// regions (connected islands) plus the area centroid of them all and a marker point
|
||||
/// inside the largest island. Returns false when the
|
||||
/// total shared area does not exceed <see cref="OpenNest.Math.Tolerance.Epsilon"/>: contact and rounding
|
||||
/// dust are not overlap. Invalid moments throw, so the pair becomes incomplete rather
|
||||
/// than clear or marked at the origin.
|
||||
/// </summary>
|
||||
private static bool TryShare(OverlapMaterial localA, Polygon outerB, List<Polygon> holesB, Vector origin,
|
||||
CancellationToken cancellationToken, out List<PlateOverlapRegion> regions, out Vector centroid,
|
||||
out Vector marker)
|
||||
{
|
||||
regions = null;
|
||||
centroid = default;
|
||||
marker = default;
|
||||
var islands = OverlapClipping.Intersect(localA.Outer, localA.Holes, outerB, holesB);
|
||||
var area = islands.Sum(island => island.Moments.Area);
|
||||
if (!double.IsFinite(area))
|
||||
throw new ArithmeticException("Overlap area moments are invalid.");
|
||||
if (area <= OpenNest.Math.Tolerance.Epsilon)
|
||||
return false;
|
||||
regions = new List<PlateOverlapRegion>(islands.Count);
|
||||
foreach (var island in islands)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
regions.Add(new PlateOverlapRegion(island.Outline.Select(point => point + origin), island.Moments.Area,
|
||||
island.Holes.Select(hole => hole.Select(point => point + origin))));
|
||||
}
|
||||
if (!PolygonAreaMoments.TryCombine(islands.Select(island => island.Moments), out var combined))
|
||||
throw new ArithmeticException("Combined overlap area moments are invalid.");
|
||||
centroid = combined.Centroid + origin;
|
||||
if (!OverlapMaterial.IsFinite(centroid))
|
||||
throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
|
||||
// The area centroid can lie between islands or in a hole: mark the largest island.
|
||||
var largest = islands.Aggregate((best, island) => island.Moments.Area > best.Moments.Area ? island : best);
|
||||
marker = OverlapMarker.Find(largest.Outline, largest.Holes, largest.Moments.Area, largest.Moments.Centroid)
|
||||
+ origin;
|
||||
if (!OverlapMaterial.IsFinite(marker))
|
||||
throw new ArithmeticException("Overlap marker is not finite in world coordinates.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static PlateOverlapPair TinyClampFinding(PreparedPart part, CapturedClamp clamp)
|
||||
{
|
||||
var zone = clamp.Bounds;
|
||||
var corners = new[]
|
||||
{
|
||||
new Vector(zone.Left, zone.Bottom), new Vector(zone.Right, zone.Bottom),
|
||||
new Vector(zone.Right, zone.Top), new Vector(zone.Left, zone.Top),
|
||||
new Vector(zone.Left, zone.Bottom), // Regions repeat their closing vertex exactly.
|
||||
};
|
||||
var centre = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2);
|
||||
return new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
|
||||
$"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, centre,
|
||||
centre);
|
||||
}
|
||||
|
||||
private static Polygon RectanglePolygon(Box box, Vector offset)
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Bottom + offset.Y));
|
||||
polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Bottom + offset.Y));
|
||||
polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Top + offset.Y));
|
||||
polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Top + offset.Y));
|
||||
polygon.Close();
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps unchanged parts to their previous input positions and looks up previous pair results.
|
||||
/// A part is unchanged when its captured source object and exact pose bits match; identical
|
||||
/// duplicates are matched in input order, which is safe because their inputs are bit-identical.
|
||||
/// </summary>
|
||||
private sealed class PairReuse
|
||||
{
|
||||
private readonly Dictionary<int, int> previousIds;
|
||||
private readonly Dictionary<(int, int), PlateOverlapPair> pairs = new();
|
||||
private readonly Dictionary<(int, int), PlateOverlapIssue> issues = new();
|
||||
|
||||
private PairReuse(Dictionary<int, int> previousIds, PlateOverlapReport previous)
|
||||
{
|
||||
this.previousIds = previousIds;
|
||||
foreach (var pair in previous.Pairs)
|
||||
pairs[(pair.PartAId, pair.PartBId)] = pair;
|
||||
foreach (var issue in previous.Issues)
|
||||
if (issue.PartBId.HasValue)
|
||||
issues[(issue.PartAId, issue.PartBId.Value)] = issue;
|
||||
}
|
||||
|
||||
public static PairReuse Create(PlateOverlapReport previous, PlateOverlapSnapshot snapshot)
|
||||
{
|
||||
if (previous?.Snapshot == null)
|
||||
return null;
|
||||
var available = new Dictionary<PoseKey, Queue<int>>();
|
||||
foreach (var part in previous.Snapshot.Parts)
|
||||
{
|
||||
var key = PoseKey.Of(part);
|
||||
if (!available.TryGetValue(key, out var ids))
|
||||
available.Add(key, ids = new Queue<int>());
|
||||
ids.Enqueue(part.Id);
|
||||
}
|
||||
var previousIds = new Dictionary<int, int>();
|
||||
foreach (var part in snapshot.Parts)
|
||||
if (available.TryGetValue(PoseKey.Of(part), out var ids) && ids.Count > 0)
|
||||
previousIds.Add(part.Id, ids.Dequeue());
|
||||
return previousIds.Count < 2 ? null : new PairReuse(previousIds, previous);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every bounding-box candidate pair among prepared parts was evaluated by the previous
|
||||
/// analysis, and unchanged parts have identical bounds, so absence there means clear.
|
||||
/// The previous pair must have had the same operand order: clipping is order-sensitive.
|
||||
/// </summary>
|
||||
public bool TryReuse(CapturedOverlapPart a, CapturedOverlapPart b,
|
||||
List<PlateOverlapPair> pairOutput, List<PlateOverlapIssue> issueOutput)
|
||||
{
|
||||
if (!previousIds.TryGetValue(a.Id, out var oldA) || !previousIds.TryGetValue(b.Id, out var oldB)
|
||||
|| oldA >= oldB)
|
||||
return false;
|
||||
if (pairs.TryGetValue((oldA, oldB), out var pair))
|
||||
pairOutput.Add(pair.Renumber(a.Id, b.Id, a.Name, b.Name));
|
||||
else if (issues.TryGetValue((oldA, oldB), out var issue))
|
||||
issueOutput.Add(issue with { PartAId = a.Id, PartBId = b.Id });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct PoseKey(OverlapSource Source, long Rotation, long X, long Y)
|
||||
{
|
||||
public static PoseKey Of(CapturedOverlapPart part) => new(part.Source,
|
||||
BitConverter.DoubleToInt64Bits(part.Rotation),
|
||||
BitConverter.DoubleToInt64Bits(part.Location.X),
|
||||
BitConverter.DoubleToInt64Bits(part.Location.Y));
|
||||
}
|
||||
|
||||
internal static bool IsGeometryFailure(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
|
||||
// Exact built-in instruction types. Anything else, subclasses included, is refused before the
|
||||
// graph is copied or converted, so no unknown Clone or cast runs.
|
||||
private static readonly HashSet<Type> SupportedCodeTypes =
|
||||
[
|
||||
typeof(RapidMove), typeof(LinearMove), typeof(ArcMove), typeof(SubProgramCall),
|
||||
typeof(Comment), typeof(Feedrate), typeof(Kerf),
|
||||
];
|
||||
|
||||
private static void ValidateProgram(Program program, HashSet<Program> visiting, bool subprogram)
|
||||
{
|
||||
if (program == null || !visiting.Add(program) || visiting.Count > 64)
|
||||
throw new ArgumentException("Missing, recursive, or excessively nested subprogram.");
|
||||
if (program.Codes == null)
|
||||
throw new ArgumentException("Program has no instruction list.");
|
||||
// The converter adds a call's frame offset to incremental moves only, so an absolute
|
||||
// subprogram would be read at its frame origin. Converting it exactly needs a lossless
|
||||
// frame transform; until then it is refused rather than misread (OpenNest writes hole
|
||||
// subprograms in incremental mode).
|
||||
if (subprogram && program.Mode == Mode.Absolute)
|
||||
throw new NotSupportedException("Absolute-mode subprograms are not supported by the overlap check.");
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
if (code == null)
|
||||
throw new ArgumentException("Program contains a missing instruction.");
|
||||
if (!SupportedCodeTypes.Contains(code.GetType()))
|
||||
throw new NotSupportedException("Program contains an unsupported instruction.");
|
||||
if (code is Motion motion && !OverlapMaterial.IsFinite(motion.EndPoint)
|
||||
|| code is ArcMove arc && !OverlapMaterial.IsFinite(arc.CenterPoint))
|
||||
throw new ArgumentException("Program coordinates must be finite.");
|
||||
if (code is SubProgramCall call)
|
||||
{
|
||||
if (!OverlapMaterial.IsFinite(call.Offset) || !double.IsFinite(call.Rotation))
|
||||
throw new ArgumentException("Subprogram pose must be finite.");
|
||||
ValidateProgram(call.Program, visiting, true);
|
||||
}
|
||||
}
|
||||
visiting.Remove(program);
|
||||
}
|
||||
|
||||
private sealed record PreparedPart(CapturedOverlapPart Input, OverlapMaterial Material);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>An owned diagnostic result. An empty Pairs list is clear only if IsComplete is true.</summary>
|
||||
public sealed class PlateOverlapReport
|
||||
{
|
||||
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
|
||||
PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null,
|
||||
List<CutOffClampFinding> cutOffClampFindings = null)
|
||||
{
|
||||
Pairs = pairs.AsReadOnly();
|
||||
Issues = issues.AsReadOnly();
|
||||
Snapshot = snapshot;
|
||||
ClampFindings = (clampFindings ?? new List<PlateOverlapPair>()).AsReadOnly();
|
||||
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
|
||||
internal PlateOverlapSnapshot Snapshot { get; }
|
||||
|
||||
public IReadOnlyList<PlateOverlapPair> Pairs { get; }
|
||||
public IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Part-vs-clamp-zone findings (C1): the same owned-fragment shape as part pairs, with a
|
||||
/// negative <see cref="PlateOverlapPair.PartBId"/> slot encoding the zone ordinal. Findings
|
||||
/// are reported like other overlap findings; they do not change <see cref="IsComplete"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PlateOverlapPair> ClampFindings { get; }
|
||||
/// <summary>Nominal cut-off/clamp contacts. Lines have no material area or part pair.</summary>
|
||||
public IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
|
||||
|
||||
public bool IsComplete => Issues.Count == 0;
|
||||
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
|
||||
}
|
||||
|
||||
/// <summary>Shared material for two input positions, ordered by zero-based input index.</summary>
|
||||
public sealed class PlateOverlapPair
|
||||
{
|
||||
private readonly Box bounds;
|
||||
|
||||
private PlateOverlapPair(PlateOverlapPair source, int partAId, int partBId, string partAName,
|
||||
string partBName)
|
||||
{
|
||||
PartAId = partAId;
|
||||
PartBId = partBId;
|
||||
PartAName = partAName;
|
||||
PartBName = partBName;
|
||||
Regions = source.Regions;
|
||||
Area = source.Area;
|
||||
Centroid = source.Centroid;
|
||||
Marker = source.Marker;
|
||||
bounds = source.bounds;
|
||||
}
|
||||
|
||||
/// <summary>The same immutable geometry under new input positions and captured names.</summary>
|
||||
internal PlateOverlapPair Renumber(int partAId, int partBId, string partAName, string partBName) =>
|
||||
new(this, partAId, partBId, partAName, partBName);
|
||||
|
||||
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
|
||||
List<PlateOverlapRegion> regions, Vector centroid, Vector marker)
|
||||
{
|
||||
PartAId = partAId;
|
||||
PartBId = partBId;
|
||||
PartAName = partAName;
|
||||
PartBName = partBName;
|
||||
Regions = regions.AsReadOnly();
|
||||
Area = regions.Sum(region => region.Area);
|
||||
Centroid = centroid;
|
||||
Marker = marker;
|
||||
var points = regions.SelectMany(region => region.Vertices).ToArray();
|
||||
var left = points.Min(point => point.X);
|
||||
var bottom = points.Min(point => point.Y);
|
||||
bounds = new Box(left, bottom, points.Max(point => point.X) - left,
|
||||
points.Max(point => point.Y) - bottom);
|
||||
}
|
||||
|
||||
public int PartAId { get; }
|
||||
public int PartBId { get; }
|
||||
public string PartAName { get; }
|
||||
public string PartBName { get; }
|
||||
/// <summary>Connected islands of shared material; no mutable kernel polygons are exposed.</summary>
|
||||
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
|
||||
public double Area { get; }
|
||||
/// <summary>
|
||||
/// Finite world-coordinate area centroid of all shared material, after hole subtraction.
|
||||
/// This can lie outside disconnected or concave shared material. Returned by value.
|
||||
/// </summary>
|
||||
public Vector Centroid { get; }
|
||||
/// <summary>
|
||||
/// Finite world-coordinate point on the shared material, inside the pair's largest island:
|
||||
/// its pole of inaccessibility, or an interior scan-line point when a thin island defeats
|
||||
/// the bounded pole search. Markers, hit testing and inspection rings use this point.
|
||||
/// A tolerance-free tiny-clamp finding (a clamp narrower than the chord tolerance) keeps
|
||||
/// its conservative whole-clamp region, so its marker is the clamp's centre, within half
|
||||
/// the clamp's size of the material. Returned by value.
|
||||
/// </summary>
|
||||
public Vector Marker { get; }
|
||||
/// <summary>A fresh world-coordinate bounds copy.</summary>
|
||||
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One connected island of shared material in world coordinates, after hole subtraction:
|
||||
/// a counterclockwise outline and its clockwise holes.
|
||||
/// </summary>
|
||||
public sealed class PlateOverlapRegion
|
||||
{
|
||||
private static readonly IReadOnlyList<IReadOnlyList<Vector>> NoHoles = System.Array.AsReadOnly(
|
||||
System.Array.Empty<IReadOnlyList<Vector>>());
|
||||
|
||||
internal PlateOverlapRegion(IEnumerable<Vector> vertices, double area,
|
||||
IEnumerable<IEnumerable<Vector>> holes = null)
|
||||
{
|
||||
Vertices = System.Array.AsReadOnly(vertices.ToArray());
|
||||
Area = area;
|
||||
Holes = holes == null
|
||||
? NoHoles
|
||||
: System.Array.AsReadOnly(holes
|
||||
.Select(hole => (IReadOnlyList<Vector>)System.Array.AsReadOnly(hole.ToArray()))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Read-only counterclockwise outline with an exactly repeated closing vertex.</summary>
|
||||
public IReadOnlyList<Vector> Vertices { get; }
|
||||
|
||||
/// <summary>Read-only clockwise hole rings, each with an exactly repeated closing vertex.</summary>
|
||||
public IReadOnlyList<IReadOnlyList<Vector>> Holes { get; }
|
||||
|
||||
/// <summary>Positive net area: the outline's area minus its holes'.</summary>
|
||||
public double Area { get; }
|
||||
}
|
||||
|
||||
/// <summary>An input or pair that could not be checked. IDs are zero-based input positions.</summary>
|
||||
public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Owned clean geometry and poses. Capture while inputs are stable, then analyze on a worker.
|
||||
/// No live Part, Drawing, Program, or subprogram is retained.
|
||||
/// </summary>
|
||||
public sealed class PlateOverlapSnapshot
|
||||
{
|
||||
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues,
|
||||
List<CapturedClamp> clamps = null, List<CutOffClampFinding> cutOffClampFindings = null)
|
||||
{
|
||||
Parts = parts.AsReadOnly();
|
||||
Issues = issues.AsReadOnly();
|
||||
Clamps = (clamps ?? new List<CapturedClamp>()).AsReadOnly();
|
||||
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
|
||||
}
|
||||
|
||||
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
|
||||
internal IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||
|
||||
/// <summary>Owned clamp keep-out rectangles in plate coordinates, capture order.</summary>
|
||||
internal IReadOnlyList<CapturedClamp> Clamps { get; }
|
||||
internal IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
|
||||
}
|
||||
|
||||
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
|
||||
double Rotation, Vector Location);
|
||||
|
||||
/// <summary>A clamp keep-out rectangle owned by the snapshot; Ordinal is its capture index.</summary>
|
||||
internal sealed record CapturedClamp(int Ordinal, string Id, Box Bounds);
|
||||
|
||||
/// <summary>Deterministic negative PartBId for a part-vs-clamp finding's clamp slot.</summary>
|
||||
internal static class ClampFindingSlot
|
||||
{
|
||||
public static int IdFor(int ordinal) => -2 - ordinal;
|
||||
public static int OrdinalOf(int pairPartBId) => -2 - pairPartBId;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only pre-post diagnostics. The caller must keep the nest stable for the entire call.
|
||||
/// Placed programs are already rotated; only their placement translation is applied here.
|
||||
/// This intentionally does not invoke a post or promise machine collision avoidance.
|
||||
/// </summary>
|
||||
public static class PostVerificationAnalyzer
|
||||
{
|
||||
public static PostVerificationReport AnalyzeForPost(Nest nest, IPostProcessor postProcessor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var report = Analyze(nest, cancellationToken);
|
||||
if (postProcessor is IPostVerificationSupport { PreservesPlacedProgramOrder: true })
|
||||
return report;
|
||||
var findings = report.Findings.ToList();
|
||||
findings.Add(new(PostVerificationKind.Incomplete, 0, null, null,
|
||||
$"Post '{postProcessor?.Name ?? "unknown"}' does not declare that it preserves placed part/contour order " +
|
||||
"and pierce positions. Nest-level checks ran, but the final rapid sequence requires manual review."));
|
||||
return new PostVerificationReport(findings);
|
||||
}
|
||||
|
||||
public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default) =>
|
||||
Analyze(nest, Vector.Zero, cancellationToken);
|
||||
|
||||
public static PostVerificationReport Analyze(Nest nest, Vector startPoint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
PostVerificationGeometry.Validate(startPoint);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var findings = new List<PostVerificationFinding>();
|
||||
if (nest.Plates == null)
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, 1, null, null, "Nest has no plate collection."));
|
||||
return new PostVerificationReport(findings);
|
||||
}
|
||||
for (var plateIndex = 0; plateIndex < nest.Plates.Count; plateIndex++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var plate = nest.Plates[plateIndex];
|
||||
var plateNumber = plateIndex + 1;
|
||||
if (plate?.Parts == null)
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||
"Plate has no part collection."));
|
||||
continue;
|
||||
}
|
||||
var materialParts = new List<Part>();
|
||||
var indices = new List<int>();
|
||||
// Stored zones use the sheet's lower-left frame; motions and parts are signed.
|
||||
// Validate them before any early exit: zone bounds are publicly mutable.
|
||||
IReadOnlyList<ClampZone> clamps;
|
||||
try
|
||||
{
|
||||
clamps = ClampZoneFrame.ToPlacement(plate);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
clamps = null;
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||
"Clamp check: " + exception.Message));
|
||||
}
|
||||
// One clamp warning per cut-off part and clamp: the placed-program check below
|
||||
// reports only what the nominal definition check has not already reported.
|
||||
var cutOffClampContacts = new HashSet<(int PartNumber, string ClampId)>();
|
||||
if (clamps != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var contact in ClampCutDiagnostics.Find(plate))
|
||||
{
|
||||
var cut = plate.CutOffs[contact.CutOffIndex];
|
||||
var partIndex = -1;
|
||||
for (var partSlot = 0; partSlot < plate.Parts.Count; partSlot++)
|
||||
if (ReferenceEquals(plate.Parts[partSlot]?.BaseDrawing, cut.Drawing))
|
||||
{
|
||||
partIndex = partSlot;
|
||||
break;
|
||||
}
|
||||
if (partIndex >= 0)
|
||||
cutOffClampContacts.Add((partIndex + 1, contact.ClampId));
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber,
|
||||
partIndex < 0 ? null : partIndex + 1, null,
|
||||
$"Manual cut-off {contact.CutOffIndex + 1} crosses active clamp '{contact.ClampId}'. "
|
||||
+ "Review the clamp and cut before posting; this warning is not a repair.")
|
||||
{ Location = contact.Location, ClampId = contact.ClampId });
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||
"Clamp/cut-off check: " + exception.Message));
|
||||
}
|
||||
}
|
||||
var obstacles = new ReleasedContourState();
|
||||
Vector? position = startPoint;
|
||||
for (var index = 0; index < plate.Parts.Count; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var part = plate.Parts[index];
|
||||
var partNumber = index + 1;
|
||||
var cutoff = part?.BaseDrawing?.IsCutOff == true;
|
||||
var expectsCuts = cutoff;
|
||||
// Preflight even clean programs before calling the overlap converter: malformed
|
||||
// recursive graphs must never reach Program.Clone or unguarded conversion.
|
||||
if (!cutoff)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clean = ExecutionMotionReader.Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken).Motions;
|
||||
expectsCuts = clean.Any(move => !move.Rapid && move.Layer != LayerType.Scribe);
|
||||
if (!clean.Any(move => move.Layer == LayerType.Scribe)
|
||||
|| expectsCuts)
|
||||
{
|
||||
materialParts.Add(part);
|
||||
indices.Add(partNumber);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
Incomplete("Overlap check: " + exception.Message);
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
if (part == null || !double.IsFinite(part.Rotation))
|
||||
throw new ArgumentException("Missing part or invalid rotation.");
|
||||
var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, cancellationToken).Motions;
|
||||
if (expectsCuts && !moves.Any(move => !move.Rapid
|
||||
&& move.Layer is LayerType.Cut or LayerType.Display
|
||||
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
|
||||
Incomplete("Placed program has no cutting contour motions for this drawing.");
|
||||
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken,
|
||||
proveOpenCuts: true);
|
||||
if (clamps is { Count: > 0 })
|
||||
foreach (var move in moves)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!move.Rapid || move.Start is not { } from
|
||||
|| from.X == move.End.X && from.Y == move.End.Y)
|
||||
continue;
|
||||
foreach (var zone in clamps)
|
||||
if (ClampCutDiagnostics.Intersects(from, move.End, zone.Bounds, out var contact))
|
||||
findings.Add(new(PostVerificationKind.RapidCrossing, plateNumber,
|
||||
partNumber, null,
|
||||
$"Direct XY rapid crosses active clamp '{zone.Id}'. "
|
||||
+ "Head height is not modeled; review the posted route and clamp setup.")
|
||||
{
|
||||
Location = (from + move.End) * 0.5,
|
||||
RapidStart = from,
|
||||
RapidEnd = move.End,
|
||||
ContactPoints = new[] { contact },
|
||||
ClampId = zone.Id
|
||||
});
|
||||
}
|
||||
// The placed program is what posts: a definition edited without
|
||||
// regenerating it can still cut through a clamp its nominal line misses.
|
||||
if (cutoff && clamps is { Count: > 0 })
|
||||
foreach (var move in moves)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (move.Rapid || move.Start is not { } from)
|
||||
continue;
|
||||
foreach (var zone in clamps)
|
||||
if (!cutOffClampContacts.Contains((partNumber, zone.Id))
|
||||
&& CutTouches(move, from, zone.Bounds, out var contact))
|
||||
{
|
||||
cutOffClampContacts.Add((partNumber, zone.Id));
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber, partNumber, null,
|
||||
$"The placed cut-off program crosses active clamp '{zone.Id}'. "
|
||||
+ "Regenerate or move the cut-off before posting; this warning is not a repair.")
|
||||
{ Location = contact, ClampId = zone.Id });
|
||||
}
|
||||
}
|
||||
position = moves[^1].End;
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
Incomplete("Lead-in/rapid check: " + exception.Message);
|
||||
// Subsequent internal moves can still be checked, but the incoming segment
|
||||
// cannot be reconstructed after an invalid program.
|
||||
position = null;
|
||||
}
|
||||
|
||||
void Incomplete(string message) => findings.Add(new(PostVerificationKind.Incomplete,
|
||||
plateNumber, partNumber, null, message));
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
// Leads Plan Cutting accepted as given (locked and drawing-pinned) are checked again here.
|
||||
var fixedMotions = new Dictionary<int, Func<int, bool>>();
|
||||
FixedLeadReview.Add(plate, plateNumber, findings, clamps, cancellationToken, fixedMotions);
|
||||
// Parts nested in cutouts: inner-before-host order and leads across the pair.
|
||||
NestedPartReview.Add(plate, plateNumber, findings, fixedMotions, cancellationToken);
|
||||
// Lead-in pierces from any source keep the pierce diameter's zone clear of material.
|
||||
PierceZoneReview.Add(plate, plateNumber, findings, cancellationToken);
|
||||
var overlap = PlateOverlapAnalyzer.Analyze(
|
||||
PlateOverlapAnalyzer.Capture(materialParts, (clamps ?? Array.Empty<ClampZone>()).ToArray(),
|
||||
cancellationToken),
|
||||
cancellationToken);
|
||||
foreach (var pair in overlap.Pairs)
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
|
||||
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted).")
|
||||
{ Location = pair.Marker, Overlap = pair });
|
||||
foreach (var pair in overlap.ClampFindings)
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
|
||||
null, $"Clean drawing material overlaps {pair.PartBName} (holes subtracted).")
|
||||
{ Location = pair.Marker, Overlap = pair });
|
||||
foreach (var issue in overlap.Issues)
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
|
||||
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
|
||||
// Parts that do not overlap may still be closer than the plate's part spacing.
|
||||
PartSpacingReview.Add(plate, plateNumber, materialParts, indices, overlap, findings, cancellationToken);
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new PostVerificationReport(findings);
|
||||
}
|
||||
|
||||
// Lines are tested exactly; an arc conservatively by its whole supporting extent.
|
||||
internal static bool CutTouches(ExecutionMotion move, Vector from, Box zone, out Vector contact)
|
||||
{
|
||||
if (move.Curve == null || move.Curve.IsLine)
|
||||
return ClampCutDiagnostics.Intersects(from, move.End, zone, out contact);
|
||||
var extent = move.Curve.Extent;
|
||||
contact = new Vector(
|
||||
System.Math.Clamp(move.Curve.Midpoint.X, zone.Left, zone.Right),
|
||||
System.Math.Clamp(move.Curve.Midpoint.Y, zone.Bottom, zone.Top));
|
||||
return !(extent.MinX > zone.Right || extent.MaxX < zone.Left
|
||||
|| extent.MinY > zone.Top || extent.MaxY < zone.Bottom);
|
||||
}
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>Local native line/arc queries, without changing the engine's geometry semantics.</summary>
|
||||
internal static class PostVerificationGeometry
|
||||
{
|
||||
internal const double Epsilon = 1e-8;
|
||||
// Matches NominalContourClosure.Tolerance: the largest gap capture admits as a closed ring.
|
||||
// Ray parity snaps vertex Y comparisons within this band so an admitted seam cannot flip an
|
||||
// exterior point to interior (see Curve.CrossesRay).
|
||||
internal const double RaySeamTolerance = 1e-6;
|
||||
private const double TwoPi = 2 * System.Math.PI;
|
||||
|
||||
/// <summary>An axis-aligned extent used only to skip queries that cannot meet.</summary>
|
||||
internal readonly record struct Extent(double MinX, double MinY, double MaxX, double MaxY)
|
||||
{
|
||||
/// <summary>No extent: unions with anything give the other extent.</summary>
|
||||
internal static Extent None => new(double.PositiveInfinity, double.PositiveInfinity,
|
||||
double.NegativeInfinity, double.NegativeInfinity);
|
||||
|
||||
internal Extent Union(Extent other) => new(System.Math.Min(MinX, other.MinX),
|
||||
System.Math.Min(MinY, other.MinY), System.Math.Max(MaxX, other.MaxX), System.Math.Max(MaxY, other.MaxY));
|
||||
|
||||
/// <summary>
|
||||
/// The gap two extents need before their checks may be skipped: ten times the widest
|
||||
/// absolute band any native contact query allows beyond an extent (the 0.00001 bounding-box
|
||||
/// allowance of native intersections, the 0.0001 contact reach of tiny arcs).
|
||||
/// </summary>
|
||||
internal const double ClearMargin = 1e-3;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates up to which extents may be skipped at all. Within it rounding stays far below
|
||||
/// <see cref="ClearMargin"/>; beyond it, where rounding of large supports can exceed any fixed
|
||||
/// margin, every check runs.
|
||||
/// </summary>
|
||||
internal const double WellConditionedLimit = 1e6;
|
||||
|
||||
/// <summary>
|
||||
/// True only when both extents are finite, lie within <see cref="WellConditionedLimit"/> and
|
||||
/// are more than <see cref="ClearMargin"/> apart on some axis, so no native query can count
|
||||
/// anything in one as touching or entering the other. Everything else must be checked.
|
||||
/// </summary>
|
||||
internal bool IsClearOf(Extent other) => IsClearOf(other, ClearMargin);
|
||||
|
||||
/// <summary>
|
||||
/// The relative rounding allowance of native line/circle intersections. Their discriminant
|
||||
/// b^2 - 4ac cancels: its absolute error stays below about 30 units of roundoff times
|
||||
/// |d1|^2 (|d2|^2 + r^2) (d1 the line, d2 from the circle's centre to the line's start), so a
|
||||
/// spurious root can lie at most about 1e-7 times the span of both extents beyond the true
|
||||
/// distance between line and circle (about 2e-9 is seen in practice). A long lead beside a
|
||||
/// small circle needs the full check; a short lead beside a nearby part does not.
|
||||
/// </summary>
|
||||
internal const double SpanRounding = 1e-6;
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="IsClearOf(Extent)"/>, with a gap that also covers the rounding of native
|
||||
/// line/circle queries over the span of both extents (<see cref="SpanRounding"/>), plus
|
||||
/// <paramref name="reach"/>. Lead checks skip a material only on this test.
|
||||
/// </summary>
|
||||
internal bool IsClearOfQueriesFrom(Extent other, double reach = 0)
|
||||
{
|
||||
var union = Union(other);
|
||||
var span = System.Math.Max(union.MaxX - union.MinX, union.MaxY - union.MinY);
|
||||
return IsClearOf(other, ClearMargin + reach + SpanRounding * span);
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="y"/> lies more than <see cref="ClearMargin"/> outside the Y range.</summary>
|
||||
internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit
|
||||
&& (y > MaxY + ClearMargin || y < MinY - ClearMargin);
|
||||
|
||||
private bool IsClearOf(Extent other, double margin) => IsWellConditioned && other.IsWellConditioned
|
||||
&& (MaxX + margin < other.MinX || other.MaxX + margin < MinX
|
||||
|| MaxY + margin < other.MinY || other.MaxY + margin < MinY);
|
||||
|
||||
// Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds
|
||||
// (including the empty extent's) fail this test too.
|
||||
private bool IsWellConditioned =>
|
||||
System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)),
|
||||
System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))) <= WellConditionedLimit;
|
||||
}
|
||||
|
||||
internal static void Validate(Vector point)
|
||||
{
|
||||
if (!double.IsFinite(point.X) || !double.IsFinite(point.Y)
|
||||
|| System.Math.Abs(point.X) > 1e12 || System.Math.Abs(point.Y) > 1e12)
|
||||
throw new ArgumentException("Nonfinite or numerically unsupported program coordinates.");
|
||||
}
|
||||
|
||||
internal static bool Closed(IReadOnlyList<Curve> curves) => curves.Count > 0
|
||||
&& curves[0].Start.DistanceTo(curves[^1].End) <= Epsilon;
|
||||
|
||||
internal static bool Crosses(Vector start, Vector end, IReadOnlyList<Curve> curves,
|
||||
CancellationToken token)
|
||||
{
|
||||
var delta = end - start;
|
||||
var length = start.DistanceTo(end);
|
||||
if (length <= Epsilon)
|
||||
return false;
|
||||
var direction = delta * (1 / length);
|
||||
foreach (var curve in curves)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (curve.ContactAfterStart(start, direction, length))
|
||||
return true;
|
||||
}
|
||||
// If there are no contacts except possibly departure, all open-segment points
|
||||
// have the same inside/outside status. A midpoint catches travel entirely inside
|
||||
// and departure into the interior, without flagging start-only outward contact.
|
||||
var midpoint = start + delta * 0.5;
|
||||
var inside = false;
|
||||
foreach (var curve in curves)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (curve.CrossesRay(midpoint))
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<Vector> ContactPoints(Vector start, Vector end,
|
||||
IReadOnlyList<Curve> curves, CancellationToken token)
|
||||
{
|
||||
var length = start.DistanceTo(end);
|
||||
if (length <= Epsilon)
|
||||
return Array.Empty<Vector>();
|
||||
var direction = (end - start) * (1 / length);
|
||||
var distances = new List<double>();
|
||||
foreach (var curve in curves)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
curve.ContactAfterStart(start, direction, length, distances);
|
||||
}
|
||||
distances.Sort();
|
||||
var points = new List<Vector>();
|
||||
foreach (var distance in distances)
|
||||
{
|
||||
var point = start + direction * distance;
|
||||
if (points.Count == 0 || points[^1].DistanceTo(point) > Epsilon)
|
||||
points.Add(point);
|
||||
}
|
||||
return points.AsReadOnly();
|
||||
}
|
||||
|
||||
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
|
||||
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
||||
private static double Normalize(double angle)
|
||||
{
|
||||
angle %= TwoPi;
|
||||
return angle < 0 ? angle + TwoPi : angle;
|
||||
}
|
||||
|
||||
/// <summary>Exact identity of a <see cref="Curve"/>'s defining values (see <see cref="Curve.Key"/>).</summary>
|
||||
internal readonly record struct CurveKey(long StartX, long StartY, long EndX, long EndY,
|
||||
bool HasCenter, long CenterX, long CenterY, long Radius, long Sweep);
|
||||
|
||||
internal sealed class Curve
|
||||
{
|
||||
private Curve(Vector start, Vector end, Vector? center, double radius, double sweep)
|
||||
{
|
||||
Start = start;
|
||||
End = end;
|
||||
Center = center;
|
||||
Radius = radius;
|
||||
Sweep = sweep;
|
||||
nativeEntity = new Lazy<Entity>(ToEntity);
|
||||
}
|
||||
|
||||
// Never expose this mutable native wrapper; its geometry is read-only after creation.
|
||||
// Lazy preserves the old conversion point for unsupported curves and is safe for concurrent checks.
|
||||
private readonly Lazy<Entity> nativeEntity;
|
||||
|
||||
internal Vector Start { get; }
|
||||
internal Vector End { get; }
|
||||
private Vector? Center { get; }
|
||||
private double Radius { get; }
|
||||
private double Sweep { get; }
|
||||
internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End);
|
||||
internal bool IsLine => !Center.HasValue;
|
||||
|
||||
// Native entities are freshly allocated; the immutable curve never exposes state.
|
||||
internal Entity ToEntity() => Center is { } center
|
||||
? System.Math.Abs(Sweep) >= TwoPi
|
||||
? new Circle(center, Radius)
|
||||
: new Arc(center, Radius, Normalize(StartAngle), Normalize(StartAngle + Sweep), Sweep < 0)
|
||||
: new Line(Start, End);
|
||||
|
||||
/// <summary>
|
||||
/// A conservative axis-aligned extent of everything a native query may count as touching
|
||||
/// this curve: for an arc, its whole supporting circle widened by the contact band of
|
||||
/// <see cref="ContactAfterStart"/>; for a line, its endpoints. Nonfinite geometry yields NaN
|
||||
/// bounds, which no separation test can pass.
|
||||
/// </summary>
|
||||
internal Extent Extent => Center is { } center
|
||||
? new(center.X - ContactReach, center.Y - ContactReach, center.X + ContactReach, center.Y + ContactReach)
|
||||
: new(System.Math.Min(Start.X, End.X), System.Math.Min(Start.Y, End.Y),
|
||||
System.Math.Max(Start.X, End.X), System.Math.Max(Start.Y, End.Y));
|
||||
|
||||
internal Vector Midpoint => Center is { } center
|
||||
? new Vector(center.X + Radius * System.Math.Cos(StartAngle + Sweep / 2),
|
||||
center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2))
|
||||
: (Start + End) * 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// The exact bits of every value a native query reads, so two curves with equal keys
|
||||
/// give identical query results. Not a geometric tolerance: equal shapes built from
|
||||
/// different roundoff get different keys.
|
||||
/// </summary>
|
||||
internal CurveKey Key => new(Bits(Start.X), Bits(Start.Y), Bits(End.X), Bits(End.Y),
|
||||
Center.HasValue, Bits(Center?.X ?? 0), Bits(Center?.Y ?? 0), Bits(Radius), Bits(Sweep));
|
||||
|
||||
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||
|
||||
internal bool SameSupport(Curve other) => SameSupport(other, Epsilon);
|
||||
|
||||
/// <summary>
|
||||
/// Support agreement with an explicit positional allowance. Used only where an upstream
|
||||
/// stage has already admitted a larger nominal discrepancy — currently the inclusive
|
||||
/// <see cref="NominalContourClosure.Tolerance"/> joint that a replayed ring bridges. It is
|
||||
/// never a collision tolerance: safety queries keep the exact epsilon.
|
||||
/// </summary>
|
||||
internal bool SameSupport(Curve other, double positionTolerance)
|
||||
{
|
||||
if (Center is { } center)
|
||||
return other.Center is { } c && center.DistanceTo(c) <= positionTolerance
|
||||
&& System.Math.Abs(Radius - other.Radius) <= positionTolerance;
|
||||
if (other.Center.HasValue || Length <= positionTolerance || other.Length <= positionTolerance)
|
||||
return false;
|
||||
var direction = (End - Start) * (1 / Length);
|
||||
return System.Math.Abs(Cross(other.Start - Start, direction)) <= positionTolerance
|
||||
&& System.Math.Abs(Cross(other.End - Start, direction)) <= positionTolerance;
|
||||
}
|
||||
|
||||
// Directed native arc-length coordinates for contour replay, not collision queries.
|
||||
internal bool SameDirection(Curve other) => SameDirection(other, Epsilon);
|
||||
|
||||
/// <summary>Directed coverage check with an admitted positional allowance; direction sign stays exact.</summary>
|
||||
internal bool SameDirection(Curve other, double positionTolerance) => SameSupport(other, positionTolerance)
|
||||
&& (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep)
|
||||
: Dot(End - Start, other.End - other.Start) > 0);
|
||||
|
||||
internal double DistanceAlong(Vector point) => Center is { } center
|
||||
? point.DistanceTo(Start) <= Epsilon ? 0
|
||||
: Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)) * Radius
|
||||
: Dot(point - Start, (End - Start) * (1 / Length));
|
||||
|
||||
/// <summary>Unit direction of travel at the curve's start or end.</summary>
|
||||
internal Vector Tangent(bool atEnd)
|
||||
{
|
||||
if (Center is not { } center)
|
||||
return (End - Start) * (1 / Length);
|
||||
var radial = (atEnd ? End : Start) - center;
|
||||
var turn = System.Math.Sign(Sweep);
|
||||
return new Vector(-radial.Y * turn, radial.X * turn) * (1 / Radius);
|
||||
}
|
||||
|
||||
/// <summary>How far the curve reaches from <paramref name="origin"/> along unit <paramref name="direction"/>.</summary>
|
||||
internal double Reach(Vector origin, Vector direction)
|
||||
{
|
||||
var reach = System.Math.Max(Dot(Start - origin, direction), Dot(End - origin, direction));
|
||||
if (Center is { } center && OnArc(center + direction * Radius))
|
||||
reach = System.Math.Max(reach, Dot(center - origin, direction) + Radius);
|
||||
return reach;
|
||||
}
|
||||
|
||||
internal Vector PointAtLength(double distance)
|
||||
{
|
||||
if (distance <= 0) return Start;
|
||||
if (distance >= Length) return End;
|
||||
return Center is { } center
|
||||
? center + new Vector(System.Math.Cos(StartAngle + System.Math.Sign(Sweep) * distance / Radius),
|
||||
System.Math.Sin(StartAngle + System.Math.Sign(Sweep) * distance / Radius)) * Radius
|
||||
: Start + (End - Start) * (distance / Length);
|
||||
}
|
||||
|
||||
/// <summary>Exact native distance from <paramref name="point"/> to this finite curve.</summary>
|
||||
internal double DistanceTo(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point);
|
||||
|
||||
internal bool Contains(Vector point) => Contains(point, Epsilon);
|
||||
|
||||
/// <summary>On-support test with an admitted positional allowance (see <see cref="SameSupport(Curve, double)"/>).</summary>
|
||||
internal bool Contains(Vector point, double positionTolerance)
|
||||
=> nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= positionTolerance;
|
||||
|
||||
/// <summary>
|
||||
/// Filters supporting-circle contacts against the actual finite arc span. The
|
||||
/// native angular padding is larger and must not turn an arc extension into a
|
||||
/// self-intersection at an adjacent nominal joint. This is not a radial test.
|
||||
/// </summary>
|
||||
internal bool IsOutsideArcSpan(Vector point) => Center.HasValue && !OnArc(point);
|
||||
|
||||
internal bool IsFiniteArc => Center.HasValue && System.Math.Abs(Sweep) < TwoPi;
|
||||
|
||||
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
|
||||
{
|
||||
var entity = nativeEntity.Value;
|
||||
var candidate = other.nativeEntity.Value;
|
||||
// Native arc filters discard NaN supporting-circle intersections. Inspect
|
||||
// both unfiltered queries first: roundoff can differ by operand direction.
|
||||
// Coincident supports have separate overlap handling below.
|
||||
if (Center is { } center && other.Center is { } otherCenter && !SameSupport(other))
|
||||
{
|
||||
var support = new Circle(center, Radius);
|
||||
var otherSupport = new Circle(otherCenter, other.Radius);
|
||||
support.Intersects(otherSupport, out var forward);
|
||||
otherSupport.Intersects(support, out var reverse);
|
||||
foreach (var point in forward)
|
||||
Validate(point);
|
||||
foreach (var point in reverse)
|
||||
Validate(point);
|
||||
}
|
||||
List<Vector> points;
|
||||
bool intersects;
|
||||
switch (candidate)
|
||||
{
|
||||
case Line line: intersects = entity.Intersects(line, out points); break;
|
||||
case Arc arc: intersects = entity.Intersects(arc, out points); break;
|
||||
case Circle circle: intersects = entity.Intersects(circle, out points); break;
|
||||
default: throw new NotSupportedException("Unsupported native boundary.");
|
||||
}
|
||||
if (!intersects)
|
||||
points.Clear();
|
||||
// Coincident circles produce NaNs in the native discrete-contact query.
|
||||
// Their support overlap is handled separately, without inventing crossings.
|
||||
// Supports that agree only within Epsilon (arcs of one circle whose centers were
|
||||
// each derived from their own endpoints) put a witness up to that far off the other
|
||||
// support. Test those spans at the admitted seam tolerance instead: shared spans
|
||||
// still refuse, while arcs that only meet at a joint or lie apart are not overlap.
|
||||
var witness = Center is { } c && other.Center is { } oc
|
||||
&& (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius) ? RaySeamTolerance : Epsilon;
|
||||
overlap = SameSupport(other) && (InteriorWitness(Midpoint, other, witness)
|
||||
|| InteriorWitness(other.Midpoint, this, witness)
|
||||
|| InteriorWitness(Start, other, witness) || InteriorWitness(End, other, witness)
|
||||
|| InteriorWitness(other.Start, this, witness) || InteriorWitness(other.End, this, witness));
|
||||
if (SameSupport(other))
|
||||
points.Clear();
|
||||
foreach (var point in points)
|
||||
Validate(point);
|
||||
var nativeContactCount = points.Count;
|
||||
foreach (var point in new[] { Start, End, other.Start, other.End })
|
||||
if (Contains(point) && other.Contains(point)
|
||||
&& !points.Exists(p => p.DistanceTo(point) <= Epsilon))
|
||||
points.Add(point);
|
||||
// Existing exact line/ray contact semantics guard native queries which
|
||||
// suppress very short or nearly parallel intersections. Uncertainty refuses.
|
||||
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other))
|
||||
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
||||
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this))
|
||||
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
||||
return points;
|
||||
|
||||
static bool InteriorWitness(Vector point, Curve curve, double tolerance) => curve.Contains(point, tolerance)
|
||||
&& (curve.Start.DistanceTo(curve.End) <= Epsilon
|
||||
|| (point.DistanceTo(curve.Start) > tolerance && point.DistanceTo(curve.End) > tolerance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether exact ray semantics find <paramref name="curve"/> touching <paramref name="line"/>
|
||||
/// anywhere except at a line endpoint the curve contains, which is already a recorded contact.
|
||||
/// Rays from both ends together cover the whole line. A ray stops short of a contained
|
||||
/// endpoint (both stop half-way when both endpoints are contained): a line meeting a
|
||||
/// tangent arc at their shared vertex is otherwise rediscovered there as a contact the
|
||||
/// native query missed, because rounding can drop the tangent root of its quadratic.
|
||||
/// </summary>
|
||||
private static bool ExactLineContact(Curve line, Curve curve)
|
||||
{
|
||||
var direction = (line.End - line.Start) * (1 / line.Length);
|
||||
var startRecorded = curve.Contains(line.Start);
|
||||
var endRecorded = curve.Contains(line.End);
|
||||
var forward = endRecorded ? startRecorded ? line.Length / 2 : 0 : line.Length;
|
||||
var backward = startRecorded ? endRecorded ? line.Length / 2 : 0 : line.Length;
|
||||
return (forward > 0 && curve.ContactAfterStart(line.Start, direction, forward))
|
||||
|| (backward > 0 && curve.ContactAfterStart(line.End, direction * -1, backward));
|
||||
}
|
||||
|
||||
|
||||
internal static Curve Create(Vector start, Vector end, Vector? center, bool clockwise)
|
||||
{
|
||||
if (center is not { } c)
|
||||
return new Curve(start, end, null, 0, 0);
|
||||
Validate(c);
|
||||
var radius = start.DistanceTo(c);
|
||||
if (radius <= Epsilon || !double.IsFinite(radius)
|
||||
|| System.Math.Abs(radius - end.DistanceTo(c)) > Epsilon * System.Math.Max(1, radius))
|
||||
throw new ArgumentException("Arc has zero or inconsistent radius.");
|
||||
var a = System.Math.Atan2(start.Y - c.Y, start.X - c.X);
|
||||
var b = System.Math.Atan2(end.Y - c.Y, end.X - c.X);
|
||||
var sweep = start.DistanceTo(end) <= Epsilon ? TwoPi
|
||||
: Normalize(clockwise ? a - b : b - a);
|
||||
return new Curve(start, end, c, radius, clockwise ? -sweep : sweep);
|
||||
}
|
||||
|
||||
private double StartAngle => System.Math.Atan2(Start.Y - Center.Value.Y, Start.X - Center.Value.X);
|
||||
private double Travel(double angle) => Normalize(Sweep < 0 ? StartAngle - angle : angle - StartAngle);
|
||||
private bool OnArc(Vector point) => Travel(System.Math.Atan2(point.Y - Center.Value.Y,
|
||||
point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius
|
||||
|| point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon;
|
||||
|
||||
// How far past r^2 a squared distance from the centre still counts as touching the arc.
|
||||
// For small arcs this band reaches well beyond the radius (sqrt(1e-8) = 1e-4 as r -> 0).
|
||||
private double ContactSlack => Epsilon * System.Math.Max(1, Radius * 2);
|
||||
|
||||
// The largest distance from the centre that ContactAfterStart can count as contact.
|
||||
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack);
|
||||
|
||||
internal bool ContactAfterStart(Vector origin, Vector direction, double length,
|
||||
List<double> contacts = null)
|
||||
{
|
||||
if (Center is { } center)
|
||||
{
|
||||
// Intersect the actual circle, not an inscribed chord polygon: tangencies
|
||||
// and short arcs must not vanish between tessellation vertices.
|
||||
var relative = center - origin;
|
||||
var projection = Dot(relative, direction);
|
||||
var perpendicular = Cross(relative, direction);
|
||||
var square = Radius * Radius - perpendicular * perpendicular;
|
||||
if (square < -ContactSlack)
|
||||
return false;
|
||||
var offset = System.Math.Sqrt(System.Math.Max(0, square));
|
||||
return contacts == null
|
||||
? Hit(projection - offset) || Hit(projection + offset)
|
||||
: Hit(projection - offset) | Hit(projection + offset);
|
||||
|
||||
bool Hit(double distance) => distance > Epsilon && distance <= length + Epsilon
|
||||
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length))
|
||||
&& Record(distance);
|
||||
}
|
||||
var edge = End - Start;
|
||||
var relativeStart = Start - origin;
|
||||
var denominator = Cross(direction, edge);
|
||||
if (System.Math.Abs(denominator) <= 1e-12 * System.Math.Max(1, Length))
|
||||
{
|
||||
if (System.Math.Abs(Cross(relativeStart, direction)) > Epsilon)
|
||||
return false;
|
||||
var a = Dot(relativeStart, direction);
|
||||
var b = Dot(End - origin, direction);
|
||||
var low = System.Math.Max(0, System.Math.Min(a, b));
|
||||
var high = System.Math.Min(length, System.Math.Max(a, b));
|
||||
return high > Epsilon && low <= high + Epsilon && Record(low) && Record(high);
|
||||
}
|
||||
var distanceAlongRapid = Cross(relativeStart, edge) / denominator;
|
||||
var fractionAlongEdge = Cross(relativeStart, direction) / denominator;
|
||||
return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon
|
||||
&& fractionAlongEdge >= -Epsilon / System.Math.Max(Length, Epsilon)
|
||||
&& fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon)
|
||||
&& Record(distanceAlongRapid);
|
||||
|
||||
bool Record(double distance)
|
||||
{
|
||||
contacts?.Add(System.Math.Clamp(distance, 0, length));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool CrossesRay(Vector point)
|
||||
{
|
||||
// Ray parity needs both edges meeting at a shared vertex to agree which side of the ray
|
||||
// that vertex is on. Rings are only nominally closed: capture admits closure seams and
|
||||
// PEP-converted or rotated geometry carries sub-micro-inch vertex jogs, so an exact
|
||||
// comparison can split one vertex across the ray and report far-away exterior points as
|
||||
// interior — which made LeadPathValidator refuse leads that could touch nothing.
|
||||
// Snapping within the admitted seam tolerance keeps the decision consistent on both
|
||||
// edges; real features (notches, tabs, arc spans) are far larger than the tolerance and
|
||||
// still count. Containment of a point that itself lies within the tolerance of a
|
||||
// boundary edge stays ambiguous by design, exactly as it was before.
|
||||
var rayY = point.Y + RaySeamTolerance;
|
||||
if (Center is not { } center)
|
||||
return (Start.Y > rayY) != (End.Y > rayY)
|
||||
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
|
||||
|
||||
// Split arcs at vertical extrema, giving monotone-Y pieces. Apply the same
|
||||
// half-open endpoint rule as a polygon ray test, solving X on the native
|
||||
// circle. This handles full circles, reversed arcs and shared vertices.
|
||||
var breaks = new List<double> { 0, System.Math.Abs(Sweep) };
|
||||
foreach (var angle in new[] { System.Math.PI / 2, 3 * System.Math.PI / 2 })
|
||||
{
|
||||
var travel = Travel(angle);
|
||||
if (travel > 0 && travel < System.Math.Abs(Sweep))
|
||||
breaks.Add(travel);
|
||||
}
|
||||
breaks.Sort();
|
||||
var inside = false;
|
||||
for (var i = 1; i < breaks.Count; i++)
|
||||
{
|
||||
var a = StartAngle + System.Math.Sign(Sweep) * breaks[i - 1];
|
||||
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
|
||||
var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a);
|
||||
var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * System.Math.Sin(b);
|
||||
if ((ya > rayY) == (yb > rayY))
|
||||
continue;
|
||||
var dy = point.Y - center.Y;
|
||||
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
|
||||
var x = center.X + (System.Math.Cos((a + b) / 2) >= 0 ? dx : -dx);
|
||||
if (x > point.X)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
public enum PostVerificationKind
|
||||
{
|
||||
Overlap,
|
||||
MissingLeadIn,
|
||||
RapidCrossing,
|
||||
Incomplete,
|
||||
|
||||
/// <summary>
|
||||
/// A violation inside a fixed emission (a locked program, or a contour cut exactly as its
|
||||
/// drawing pins it): accepted as given, never repaired, always reported for review.
|
||||
/// </summary>
|
||||
FixedLead,
|
||||
|
||||
/// <summary>A part inside another part's cutout is cut after that host, whose cutout releases it.</summary>
|
||||
NestedCutOrder,
|
||||
|
||||
/// <summary>
|
||||
/// A lead of a nested part or of its host contacts or enters the other's material; for a host
|
||||
/// this is typically an enclosing-hole lead crossing the insert.
|
||||
/// </summary>
|
||||
NestedLead,
|
||||
|
||||
/// <summary>A lead-in pierce is closer to part material than half the pierce diameter.</summary>
|
||||
PierceZone,
|
||||
/// <summary>
|
||||
/// Two parts are closer than the plate's part spacing without sharing material, judged as
|
||||
/// Auto Nest's layout check judges them.
|
||||
/// </summary>
|
||||
PartSpacing
|
||||
}
|
||||
|
||||
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
|
||||
public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber,
|
||||
int? PartNumber, int? OtherPartNumber, string Message)
|
||||
{
|
||||
public Vector? Location { get; init; }
|
||||
public Vector? RapidStart { get; init; }
|
||||
public Vector? RapidEnd { get; init; }
|
||||
/// <summary>Rapid boundary contacts in travel order; collinear contact spans use their endpoints.</summary>
|
||||
public IReadOnlyList<Vector> ContactPoints { get; init; } = Array.Empty<Vector>();
|
||||
public PlateOverlapPair Overlap { get; init; }
|
||||
/// <summary>Active clamp contacted by a cut-off or direct XY rapid, if applicable.</summary>
|
||||
public string ClampId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
|
||||
public sealed class PostVerificationReport
|
||||
{
|
||||
internal PostVerificationReport(IEnumerable<PostVerificationFinding> findings)
|
||||
{
|
||||
Findings = Array.AsReadOnly(findings.ToArray());
|
||||
}
|
||||
|
||||
public IReadOnlyList<PostVerificationFinding> Findings { get; }
|
||||
public bool HasWarnings => Findings.Count != 0;
|
||||
public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged;
|
||||
|
||||
public string ToDisplayText() => ToDisplayText("Pre-post verification");
|
||||
|
||||
public string ToDisplayText(string title)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.AppendLine(title);
|
||||
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
|
||||
Summary(PostVerificationKind.Overlap, "Overlap");
|
||||
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
|
||||
Summary(PostVerificationKind.RapidCrossing, "Rapid crossings");
|
||||
// Only nests with fixed-lead findings gain the line, so other reports read as before.
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.FixedLead))
|
||||
Summary(PostVerificationKind.FixedLead, "Fixed leads");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.NestedCutOrder))
|
||||
Summary(PostVerificationKind.NestedCutOrder, "Nested part order");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.NestedLead))
|
||||
Summary(PostVerificationKind.NestedLead, "Nested part leads");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PierceZone))
|
||||
Summary(PostVerificationKind.PierceZone, "Pierce zones");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSpacing))
|
||||
Summary(PostVerificationKind.PartSpacing, "Part spacing");
|
||||
foreach (var finding in Findings)
|
||||
{
|
||||
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
|
||||
if (finding.PartNumber is { } part)
|
||||
text.Append($", part {part}");
|
||||
if (finding.OtherPartNumber is { } other)
|
||||
text.Append($", other part {other}");
|
||||
text.AppendLine($": {finding.Kind}: {finding.Message}");
|
||||
}
|
||||
text.AppendLine("These checks cover direct XY rapids in plate/program order; the post may " +
|
||||
"change order, routing or retracts. Inspect the posted machine program and machine setup " +
|
||||
"before cutting, and confirm actual contour gaps give adequate retention.");
|
||||
return text.ToString();
|
||||
|
||||
void Summary(PostVerificationKind kind, string label)
|
||||
{
|
||||
var count = Findings.Count(finding => finding.Kind == kind);
|
||||
text.AppendLine($"{label}: {count} warning(s)" +
|
||||
(incomplete ? "; verification incomplete — review before posting." : "."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
@@ -32,13 +32,18 @@ namespace OpenNest
|
||||
Color.FromArgb(215, 130, 130), // Light Coral
|
||||
};
|
||||
|
||||
public static Color GetNextColor()
|
||||
public static bool UseGoldenAngleColors { get; set; }
|
||||
|
||||
public static Color GetPartColor(int index)
|
||||
{
|
||||
var color = PartColors[nextColorIndex % PartColors.Length];
|
||||
nextColorIndex++;
|
||||
return color;
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||
return UseGoldenAngleColors
|
||||
? PartColorPalette.GoldenAngle(index)
|
||||
: PartColors[index % PartColors.Length];
|
||||
}
|
||||
|
||||
public static Color GetNextColor() => GetPartColor(unchecked(nextColorIndex++) & int.MaxValue);
|
||||
|
||||
public Drawing()
|
||||
: this(string.Empty, new Program()) { }
|
||||
|
||||
@@ -99,14 +104,53 @@ namespace OpenNest
|
||||
|
||||
public Color Color { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when someone chose <see cref="Color"/> on purpose. Otherwise the color comes
|
||||
/// from the active scheme's palette and is reassigned when the scheme changes or the
|
||||
/// nest is opened.
|
||||
/// </summary>
|
||||
public bool HasCustomColor { get; set; }
|
||||
|
||||
public bool IsCutOff { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cut this drawing's parts before every other part when automatic sequencing runs,
|
||||
/// e.g. a test coupon checked before the rest of the sheet is cut.
|
||||
/// </summary>
|
||||
public bool CutFirst { get; set; }
|
||||
|
||||
public NestConstraints Constraints { get; set; }
|
||||
|
||||
public SourceInfo Source { get; set; }
|
||||
|
||||
public List<Bend> Bends { get; set; } = new List<Bend>();
|
||||
|
||||
/// <summary>
|
||||
/// The library revision this drawing was copied from, or null when it did not come
|
||||
/// from a drawing library. Editing the drawing in a nest keeps the link as provenance.
|
||||
/// </summary>
|
||||
public DrawingLibraryLink Library { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contours this drawing says must be cut a fixed way, in the source frame. At most one
|
||||
/// pin per contour is used; <see cref="CNC.CuttingPlanning.CutPinResolver"/> maps them to
|
||||
/// a placement. Pins do not follow geometry into new drawings (split pieces).
|
||||
/// </summary>
|
||||
public List<CNC.CuttingStrategy.ContourCutPin> CutPins { get; set; } = new List<CNC.CuttingStrategy.ContourCutPin>();
|
||||
|
||||
/// <summary>
|
||||
/// Scrap pieces tabbed to the part, in the source frame. <see cref="Program"/> already has
|
||||
/// them applied; they are kept so an editor can change them from <see cref="SourceEntities"/>.
|
||||
/// </summary>
|
||||
public List<Breakoffs.Breakoff> Breakoffs { get; set; } = new List<Breakoffs.Breakoff>();
|
||||
|
||||
/// <summary>
|
||||
/// Holes whose slugs stay attached by tabs, in the source frame. <see cref="Program"/> already
|
||||
/// has them applied (cut pieces plus <see cref="CNC.LayerType.Tab"/> spans); they are kept so
|
||||
/// an editor can change them from <see cref="SourceEntities"/>.
|
||||
/// </summary>
|
||||
public List<TabbedCutouts.TabbedCutout> TabbedCutouts { get; set; } = new List<TabbedCutouts.TabbedCutout>();
|
||||
|
||||
/// <summary>
|
||||
/// Complete set of source entities with stable GUIDs.
|
||||
/// Null when the drawing was created from G-code or an older nest file.
|
||||
@@ -119,6 +163,14 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public HashSet<Guid> SuppressedEntityIds { get; set; } = new HashSet<Guid>();
|
||||
|
||||
/// <summary>
|
||||
/// Role overrides for entities in <see cref="SourceEntities"/>; entities not listed keep the
|
||||
/// role their layer implies (see <see cref="Converters.EntityRoles"/>). <see cref="Program"/> already has
|
||||
/// them applied; display-only entities are not in it and come from
|
||||
/// <see cref="Converters.EntityRoles.DisplayGeometry"/>.
|
||||
/// </summary>
|
||||
public Dictionary<Guid, EntityRole> EntityRoles { get; set; } = new Dictionary<Guid, EntityRole>();
|
||||
|
||||
public double Area { get; protected set; }
|
||||
|
||||
public void UpdateArea()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Where a nest drawing came from in a server drawing library: the exact revision that
|
||||
/// was copied in and a snapshot of its library details at that time. The nest owns its
|
||||
/// copy; the link never changes the library and the library never changes the copy.
|
||||
/// Immutable, so drawing copies may share one instance.
|
||||
/// </summary>
|
||||
public sealed class DrawingLibraryLink
|
||||
{
|
||||
public DrawingLibraryLink(string serverKey, Guid id, int revision, CutType cutType, double thickness,
|
||||
string materialName, string materialGrade, Units units, string notes, string packageSha256)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serverKey))
|
||||
throw new ArgumentException("A library link needs the server it came from.", nameof(serverKey));
|
||||
if (id == Guid.Empty)
|
||||
throw new ArgumentException("A library link needs a drawing id.", nameof(id));
|
||||
if (revision < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(revision), revision, "Revisions start at 1.");
|
||||
if (!Enum.IsDefined(cutType))
|
||||
throw new ArgumentOutOfRangeException(nameof(cutType), cutType, "Unsupported cut type.");
|
||||
if (!double.IsFinite(thickness) || thickness < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(thickness), thickness, "Thickness must be finite and not negative.");
|
||||
if (!Enum.IsDefined(units))
|
||||
throw new ArgumentOutOfRangeException(nameof(units), units, "Unsupported units.");
|
||||
|
||||
ServerKey = serverKey;
|
||||
Id = id;
|
||||
Revision = revision;
|
||||
CutType = cutType;
|
||||
Thickness = thickness;
|
||||
MaterialName = materialName ?? "";
|
||||
MaterialGrade = materialGrade ?? "";
|
||||
Units = units;
|
||||
Notes = notes ?? "";
|
||||
PackageSha256 = packageSha256 ?? "";
|
||||
}
|
||||
|
||||
/// <summary>Normalized base URL of the server holding the library (no trailing slash).</summary>
|
||||
public string ServerKey { get; }
|
||||
|
||||
public Guid Id { get; }
|
||||
|
||||
/// <summary>The library revision this drawing is a copy of.</summary>
|
||||
public int Revision { get; }
|
||||
|
||||
public CutType CutType { get; }
|
||||
|
||||
/// <summary>Library thickness in <see cref="Units"/>.</summary>
|
||||
public double Thickness { get; }
|
||||
|
||||
public string MaterialName { get; }
|
||||
|
||||
public string MaterialGrade { get; }
|
||||
|
||||
public Units Units { get; }
|
||||
|
||||
public string Notes { get; }
|
||||
|
||||
/// <summary>Lower-case hex SHA-256 of the library package for <see cref="Revision"/>.</summary>
|
||||
public string PackageSha256 { get; }
|
||||
|
||||
/// <summary>True when both links name the same revision of the same library drawing.</summary>
|
||||
public bool IsSameRevision(DrawingLibraryLink other) =>
|
||||
other != null && Id == other.Id && Revision == other.Revision
|
||||
&& string.Equals(ServerKey, other.ServerKey, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Captures drawing program text before an edit and rebuilds only the parts whose
|
||||
/// drawing program changed. Drawing keys use reference identity because names are editable.
|
||||
/// </summary>
|
||||
public sealed class DrawingProgramSnapshot
|
||||
{
|
||||
private readonly Dictionary<Drawing, Captured> programs;
|
||||
private readonly Func<Program, string> fingerprint;
|
||||
|
||||
private DrawingProgramSnapshot(
|
||||
Dictionary<Drawing, Captured> programs,
|
||||
Func<Program, string> fingerprint)
|
||||
{
|
||||
this.programs = programs;
|
||||
this.fingerprint = fingerprint;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture before handing drawings to an editor, including its load operation:
|
||||
/// programs can be edited in place. The callback must include both the main
|
||||
/// program text and its hole sub-programs, and must not mutate the program.
|
||||
/// </summary>
|
||||
public static DrawingProgramSnapshot Capture(
|
||||
IEnumerable<Drawing> drawings,
|
||||
Func<Program, string> fingerprint)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(drawings);
|
||||
ArgumentNullException.ThrowIfNull(fingerprint);
|
||||
var programs = new Dictionary<Drawing, Captured>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
foreach (var drawing in drawings)
|
||||
{
|
||||
if (!drawing.IsCutOff && !programs.ContainsKey(drawing))
|
||||
programs.Add(drawing, new Captured(fingerprint(drawing.Program), SourceOffset(drawing)));
|
||||
}
|
||||
|
||||
return new DrawingProgramSnapshot(programs, fingerprint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete the captured edit by rebuilding changed drawings' parts across all plates.
|
||||
/// Part.Update clears obsolete lead-ins, tabs and locks; the placement is anchored to the
|
||||
/// source frame, so a rebuild that re-origins the program (Source.Offset moved, e.g. a new
|
||||
/// first pierce) leaves the geometry where it was on the plate.
|
||||
/// Unchanged and uncaptured drawings' parts retain their program instances and state.
|
||||
/// Returns the rebuilt parts so a UI can invalidate just their graphics.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Part> UpdateChangedParts(IEnumerable<Plate> plates)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plates);
|
||||
var originShifts = new Dictionary<Drawing, Vector>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
foreach (var entry in programs)
|
||||
{
|
||||
if (!entry.Key.IsCutOff
|
||||
&& !string.Equals(entry.Value.Fingerprint, fingerprint(entry.Key.Program), StringComparison.Ordinal))
|
||||
originShifts.Add(entry.Key, SourceOffset(entry.Key) - entry.Value.SourceOffset);
|
||||
}
|
||||
|
||||
var updated = new List<Part>();
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (!originShifts.TryGetValue(part.BaseDrawing, out var shift))
|
||||
continue;
|
||||
|
||||
// A source point P sat at Location + R(P - old); keep it there with the new origin.
|
||||
var rotation = part.Rotation;
|
||||
part.Update();
|
||||
if (shift != Vector.Zero)
|
||||
part.Offset(shift.Rotate(rotation));
|
||||
updated.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
private static Vector SourceOffset(Drawing drawing) => drawing.Source?.Offset ?? Vector.Zero;
|
||||
|
||||
private readonly record struct Captured(string Fingerprint, Vector SourceOffset);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Tuning for <see cref="DrawingAligner"/>. Every default is a design choice
|
||||
/// under measurement, not a calibrated safe envelope: until calibration
|
||||
/// establishes one, every alignment pair still requires operator confirmation.
|
||||
/// Values are expressed in the declared units of the drawings being aligned.
|
||||
/// </summary>
|
||||
public sealed class AlignmentOptions
|
||||
{
|
||||
/// <summary>Chord-error tolerance used when flattening arcs for measurement.</summary>
|
||||
public double FlattenTolerance { get; set; } = 0.01;
|
||||
|
||||
/// <summary>
|
||||
/// Target arclength between resampled contour samples. Must resolve the
|
||||
/// intended residual gate: a residual below roughly half this spacing is
|
||||
/// not meaningful.
|
||||
/// </summary>
|
||||
public double SamplingSpacing { get; set; } = 0.5;
|
||||
|
||||
/// <summary>Upper bound on samples per contour ring.</summary>
|
||||
public int MaxSamplesPerRing { get; set; } = 4000;
|
||||
|
||||
/// <summary>Upper bound on total samples across all rings of one drawing.</summary>
|
||||
public int MaxTotalSamples { get; set; } = 20000;
|
||||
|
||||
/// <summary>Trim fraction: worst-distance correspondences excluded from each ICP fit.</summary>
|
||||
public double TrimFraction { get; set; } = 0.10;
|
||||
|
||||
/// <summary>Fit iterations per candidate start.</summary>
|
||||
public int MaxIterations { get; set; } = 40;
|
||||
|
||||
/// <summary>Pose change (radians) treated as converged.</summary>
|
||||
public double RotationEpsilon { get; set; } = 1e-7;
|
||||
|
||||
/// <summary>Translation change in units treated as converged.</summary>
|
||||
public double TranslationEpsilon { get; set; } = 1e-7;
|
||||
|
||||
/// <summary>
|
||||
/// Correspondences farther than this count as unmatched (changed geometry),
|
||||
/// not as fit error. Defaults to a generous envelope; the review gate
|
||||
/// reports coverage instead of silently clamping it.
|
||||
/// </summary>
|
||||
public double OutlierDistance { get; set; } = 10.0;
|
||||
|
||||
/// <summary>Minimum distinct samples required to attempt a fit at all.</summary>
|
||||
public int MinSamples { get; set; } = 8;
|
||||
}
|
||||
|
||||
/// <summary>Why an alignment is not trustworthy. Absence of all flags is not a
|
||||
/// calibrated safe envelope; it only means no review reason fired.</summary>
|
||||
[Flags]
|
||||
public enum AlignmentReasons
|
||||
{
|
||||
None = 0,
|
||||
|
||||
/// <summary>The fit stopped on an iteration/work limit or stagnated without converging.</summary>
|
||||
FailedConvergence = 1,
|
||||
|
||||
/// <summary>The revised geometry has too few usable samples, too little outer
|
||||
/// support, or too little of the target matched to it.</summary>
|
||||
InsufficientSupport = 2,
|
||||
|
||||
/// <summary>Significant changed/unmatched boundary spans in either direction.</summary>
|
||||
SignificantBoundaryChange = 4,
|
||||
|
||||
/// <summary>Two or more genuinely distinct poses fit near-equally (symmetry,
|
||||
/// repeated features). The reported transform is still valid geometry.</summary>
|
||||
UnresolvedAlternatives = 8,
|
||||
|
||||
/// <summary>Input geometry is invalid: nonfinite coordinates, degenerate or
|
||||
/// unclosed rings, unsupported topology, zero usable perimeter.</summary>
|
||||
InvalidGeometry = 16,
|
||||
|
||||
/// <summary>A reflected candidate fits as well as the best rigid one, or the
|
||||
/// input cannot exclude reflection. Reflection is never applied silently.</summary>
|
||||
ReflectionUncertain = 32,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured outcome of aligning one revised drawing to one old drawing.
|
||||
/// The transform maps NEW points into the OLD drawing-local frame:
|
||||
/// reflect about the declared local axis (only when <see cref="Reflection"/> is
|
||||
/// true, which the automatic aligner never sets), then rotate by
|
||||
/// <see cref="Rotation"/> (radians), then translate by <see cref="Translation"/>.
|
||||
/// Diagnostics are in the drawings' declared units. A diagnostic IoU is
|
||||
/// bounded to [0, 1] and is not a calibrated probability.
|
||||
/// </summary>
|
||||
public sealed class AlignmentResult
|
||||
{
|
||||
public bool Converged { get; init; }
|
||||
public double Rotation { get; init; }
|
||||
public Vector Translation { get; init; }
|
||||
public bool Reflection { get; init; }
|
||||
|
||||
/// <summary>Review reasons; combined across candidate selection. Empty does
|
||||
/// not license skipping the operator overlay — no calibrated envelope
|
||||
/// exists yet.</summary>
|
||||
public AlignmentReasons Reasons { get; init; }
|
||||
|
||||
/// <summary>Trimmed RMS of matched sample-to-segment residuals.</summary>
|
||||
public double ResidualRms { get; init; }
|
||||
public double ResidualP50 { get; init; }
|
||||
public double ResidualP90 { get; init; }
|
||||
|
||||
/// <summary>Fraction of NEW samples matching OLD within the outlier bound, and vice versa.</summary>
|
||||
public double NewToOldCoverage { get; init; }
|
||||
public double OldToNewCoverage { get; init; }
|
||||
|
||||
/// <summary>Number of distinct converged candidate poses clustered as equivalent.</summary>
|
||||
public int EquivalentCandidateCount { get; init; }
|
||||
|
||||
public int Iterations { get; init; }
|
||||
public int NewSampleCount { get; init; }
|
||||
public int OldSampleCount { get; init; }
|
||||
|
||||
/// <summary>Bounded diagnostic intersection-over-union of the material regions,
|
||||
/// or null when regions are unavailable or degenerate. Never a gate input.</summary>
|
||||
public double? DiagnosticIoU { get; init; }
|
||||
|
||||
public string FailureMessage { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -118,6 +119,27 @@ namespace OpenNest.Geometry
|
||||
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sums signed angular change traversing consecutive points around a center.
|
||||
/// Positive = CCW, negative = CW.
|
||||
/// </summary>
|
||||
public static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI)
|
||||
da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI)
|
||||
da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the maximum radial deviation of interior points from a circle.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Signed clearance between two closed polygons, plus the unit direction that
|
||||
/// increases it by moving the first polygon.
|
||||
/// </summary>
|
||||
public struct ClearanceResult
|
||||
{
|
||||
/// <summary>
|
||||
/// > 0: minimum boundary distance. 0: touching. < 0: penetration depth
|
||||
/// (the translation of <c>a</c> along <see cref="Direction"/> needed to end
|
||||
/// contact).
|
||||
/// </summary>
|
||||
public double Distance;
|
||||
|
||||
/// <summary>
|
||||
/// Unit direction for translating <c>a</c> away from <c>b</c>. For penetration
|
||||
/// this is the minimum-translation direction. Never zero-length; degenerate
|
||||
/// (coincident-centroid) penetration resolves to a deterministic axis.
|
||||
/// </summary>
|
||||
public Vector Direction;
|
||||
|
||||
public ClearanceResult(double distance, Vector direction)
|
||||
{
|
||||
Distance = distance;
|
||||
Direction = direction;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Omnidirectional clearance between two closed, lines-only polygons.
|
||||
/// Complements <see cref="SpatialQuery.DirectionalDistance"/> (movement along a
|
||||
/// fixed ray) with the all-directions minimum distance and separating direction,
|
||||
/// and <see cref="Collision"/> (boolean overlap) with depth and direction.
|
||||
/// <para>
|
||||
/// Reference quality, not hot-loop quality: separation is a brute-force
|
||||
/// segment-pair minimum with a bounding-box reject, penetration is a
|
||||
/// separating-axis sweep over both polygons' edge normals. The overlap verdict
|
||||
/// defers to <see cref="Collision.HasOverlap(Polygon, Polygon, List{Polygon}, List{Polygon})"/>
|
||||
/// so callers that validate with Collision never see a disagreeing kernel.
|
||||
/// Rings with holes are handled by the caller: pass every ring pair (a part's
|
||||
/// material boundary is its outer ring plus its hole rings).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class Clearance
|
||||
{
|
||||
public static ClearanceResult Between(Polygon a, Polygon b)
|
||||
{
|
||||
var linesA = a.ToLines();
|
||||
var linesB = b.ToLines();
|
||||
|
||||
if (linesA.Count == 0 || linesB.Count == 0)
|
||||
return new ClearanceResult(0, new Vector(1, 0));
|
||||
|
||||
if (Collision.HasOverlap(a, b))
|
||||
return Penetration(linesA, linesB);
|
||||
|
||||
return Separation(linesA, linesB);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-negative minimum boundary (edge-to-edge) distance between two rings
|
||||
/// and the direction that translates <paramref name="a"/> away from
|
||||
/// <paramref name="b"/> at the closest contact. Never tests overlap: a ring
|
||||
/// contained in another (a part inside a cutout ring) still reports its true
|
||||
/// gap. For signed material clearance use <see cref="Between"/>.
|
||||
/// </summary>
|
||||
public static ClearanceResult BoundaryDistance(Polygon a, Polygon b)
|
||||
{
|
||||
var linesA = a.ToLines();
|
||||
var linesB = b.ToLines();
|
||||
|
||||
if (linesA.Count == 0 || linesB.Count == 0)
|
||||
return new ClearanceResult(0, new Vector(1, 0));
|
||||
|
||||
return Separation(linesA, linesB);
|
||||
}
|
||||
|
||||
/// <summary>Distance from a closed boundary's segments to every segment
|
||||
/// of an intentional open cutting path. Unlike polygonizing that path,
|
||||
/// this retains segment interiors and disjoint subpaths.</summary>
|
||||
internal static ClearanceResult BoundaryDistance(List<Line> closed, List<Line> open)
|
||||
{
|
||||
if (closed.Count == 0 || open.Count == 0)
|
||||
return new ClearanceResult(0, new Vector(1, 0));
|
||||
return Separation(closed, open);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum boundary distance between two non-overlapping rings and the
|
||||
/// direction that translates <paramref name="linesA"/> away from
|
||||
/// <paramref name="linesB"/> at the closest contact.
|
||||
/// </summary>
|
||||
private static ClearanceResult Separation(List<Line> linesA, List<Line> linesB)
|
||||
{
|
||||
var minDist = ClosestPoints(linesA, linesB, out var pa, out var pb);
|
||||
|
||||
var dir = pa - pb;
|
||||
var len = Magnitude(dir);
|
||||
|
||||
if (len <= Tolerance.Epsilon)
|
||||
dir = CentroidAway(linesA, linesB);
|
||||
else
|
||||
dir = dir / len;
|
||||
|
||||
return new ClearanceResult(minDist, dir);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum boundary (edge-to-edge) distance between two rings and the point on each
|
||||
/// boundary where it occurs. Like <see cref="BoundaryDistance(Polygon, Polygon)"/>, never
|
||||
/// tests overlap. Infinity, with zero points, when either ring has no edges.
|
||||
/// </summary>
|
||||
public static double ClosestPoints(Polygon a, Polygon b, out Vector onA, out Vector onB)
|
||||
{
|
||||
var linesA = a.ToLines();
|
||||
var linesB = b.ToLines();
|
||||
|
||||
if (linesA.Count == 0 || linesB.Count == 0)
|
||||
{
|
||||
onA = onB = Vector.Zero;
|
||||
return double.PositiveInfinity;
|
||||
}
|
||||
|
||||
return ClosestPoints(linesA, linesB, out onA, out onB);
|
||||
}
|
||||
|
||||
internal static double ClosestPoints(List<Line> linesA, List<Line> linesB, out Vector pa, out Vector pb)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
pa = Vector.Zero;
|
||||
pb = Vector.Zero;
|
||||
|
||||
var boxes = new Box[linesB.Count];
|
||||
for (var i = 0; i < linesB.Count; i++)
|
||||
boxes[i] = SegmentBox(linesB[i]);
|
||||
|
||||
foreach (var la in linesA)
|
||||
{
|
||||
var boxA = SegmentBox(la);
|
||||
|
||||
for (var i = 0; i < linesB.Count; i++)
|
||||
{
|
||||
if (!BoxesWithin(boxA, boxes[i], minDist))
|
||||
continue;
|
||||
|
||||
var d = SegmentDistance(la, linesB[i], out var qa, out var qb);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
pa = qa;
|
||||
pb = qb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Penetration depth and minimum-translation direction along the separating-
|
||||
/// axis candidates of both rings. Per candidate axis the true translation
|
||||
/// depth is used (exit distance to the far side), so containment reports the
|
||||
/// depth that actually ends contact, not the interval-intersection length.
|
||||
/// Depth is reported as a negative clearance.
|
||||
/// </summary>
|
||||
private static ClearanceResult Penetration(List<Line> linesA, List<Line> linesB)
|
||||
{
|
||||
var ca = Centroid(linesA);
|
||||
var cb = Centroid(linesB);
|
||||
|
||||
var bestDepth = double.MaxValue;
|
||||
var bestDir = new Vector(1, 0);
|
||||
|
||||
var bestAxis = -1;
|
||||
|
||||
for (var axis = 0; axis < 2; axis++)
|
||||
{
|
||||
var lines = axis == 0 ? linesA : linesB;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var edge = line.pt2 - line.pt1;
|
||||
var n = new Vector(edge.Y, -edge.X);
|
||||
var len = Magnitude(n);
|
||||
if (len <= Tolerance.Epsilon)
|
||||
continue;
|
||||
n = n / len;
|
||||
|
||||
var (minA, maxA) = Project(linesA, n);
|
||||
var (minB, maxB) = Project(linesB, n);
|
||||
|
||||
if (maxA <= minB || maxB <= minA)
|
||||
continue; // separating axis found
|
||||
|
||||
// Depth pushing a away from b along ±n.
|
||||
var forward = maxB - minA; // move a in +n until minA >= maxB
|
||||
var backward = maxA - minB; // move a in -n until maxA <= minB
|
||||
|
||||
double depth;
|
||||
Vector dir;
|
||||
if (forward <= backward)
|
||||
{
|
||||
depth = forward;
|
||||
dir = n;
|
||||
}
|
||||
else
|
||||
{
|
||||
depth = backward;
|
||||
dir = -n;
|
||||
}
|
||||
|
||||
if (depth < bestDepth - Tolerance.Epsilon || bestAxis < 0)
|
||||
{
|
||||
bestDepth = depth;
|
||||
bestDir = dir;
|
||||
bestAxis = axis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestAxis < 0)
|
||||
{
|
||||
// No candidate axis (degenerate rings): deterministic fallback.
|
||||
var away = ca - cb;
|
||||
var len = Magnitude(away);
|
||||
bestDir = len > Tolerance.Epsilon ? away / len : new Vector(1, 0);
|
||||
bestDepth = 0;
|
||||
}
|
||||
|
||||
return new ClearanceResult(-bestDepth, bestDir);
|
||||
}
|
||||
|
||||
private static Vector CentroidAway(List<Line> linesA, List<Line> linesB)
|
||||
{
|
||||
var away = Centroid(linesA) - Centroid(linesB);
|
||||
var len = Magnitude(away);
|
||||
return len > Tolerance.Epsilon ? away / len : new Vector(1, 0);
|
||||
}
|
||||
|
||||
private static Vector Centroid(List<Line> lines)
|
||||
{
|
||||
var sum = Vector.Zero;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
sum += line.pt1;
|
||||
sum += line.pt2;
|
||||
}
|
||||
return sum / (2 * lines.Count);
|
||||
}
|
||||
|
||||
private static (double Min, double Max) Project(List<Line> lines, Vector n)
|
||||
{
|
||||
var min = double.MaxValue;
|
||||
var max = double.MinValue;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var d1 = line.pt1.DotProduct(n);
|
||||
var d2 = line.pt2.DotProduct(n);
|
||||
if (d1 < min)
|
||||
min = d1;
|
||||
if (d1 > max)
|
||||
max = d1;
|
||||
if (d2 < min)
|
||||
min = d2;
|
||||
if (d2 > max)
|
||||
max = d2;
|
||||
}
|
||||
|
||||
return (min, max);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum distance between two segments with the closest points.
|
||||
/// Non-parallel segments use the classic clamped closest-point solve;
|
||||
/// (near-)parallel segments fall back to the four endpoint-to-segment
|
||||
/// distances, which is where the minimum always lies.
|
||||
/// </summary>
|
||||
private static double SegmentDistance(Line a, Line b, out Vector pa, out Vector pb)
|
||||
{
|
||||
var p = a.pt1;
|
||||
var r = a.pt2 - a.pt1;
|
||||
var q = b.pt1;
|
||||
var s = b.pt2 - b.pt1;
|
||||
|
||||
var rxr = r.DotProduct(r);
|
||||
var sxs = s.DotProduct(s);
|
||||
var rxs = r.DotProduct(s);
|
||||
|
||||
const double eps = 1e-12;
|
||||
|
||||
var denom = rxr * sxs - rxs * rxs;
|
||||
if (denom > eps && rxr > eps && sxs > eps)
|
||||
{
|
||||
// Minimize |(p + r t) - (q + s u)|^2; setting both partials to
|
||||
// zero and solving (Cramer) with d0 = p - q:
|
||||
// t = ((r.s)(d0.s) - (d0.r)(s.s)) / (rr.ss - (r.s)^2)
|
||||
// u = ((r.r)(d0.s) - (r.s)(d0.r)) / (rr.ss - (r.s)^2)
|
||||
var d0 = p - q;
|
||||
var d0r = d0.DotProduct(r);
|
||||
var d0s = d0.DotProduct(s);
|
||||
|
||||
var t = Clamp((rxs * d0s - d0r * sxs) / denom, 0, 1);
|
||||
var u = Clamp((rxs * t + d0s) / sxs, 0, 1); // nearest u on b for clamped t
|
||||
t = Clamp((rxs * u - d0r) / rxr, 0, 1); // re-solve t for clamped u
|
||||
|
||||
pa = p + r * t;
|
||||
pb = q + s * u;
|
||||
return pa.DistanceTo(pb);
|
||||
}
|
||||
|
||||
// Degenerate or parallel: the minimum is attained at an endpoint.
|
||||
var bestPa = p;
|
||||
var bestPb = q;
|
||||
var best = double.MaxValue;
|
||||
|
||||
void Consider(Vector pt, Line seg, bool ptOnA)
|
||||
{
|
||||
var d = seg.pt2 - seg.pt1;
|
||||
var len2 = d.DotProduct(d);
|
||||
var u = len2 <= eps ? 0 : Clamp((pt - seg.pt1).DotProduct(d) / len2, 0, 1);
|
||||
var on = seg.pt1 + d * u;
|
||||
var dist = pt.DistanceTo(on);
|
||||
if (dist < best)
|
||||
{
|
||||
best = dist;
|
||||
bestPa = ptOnA ? pt : on;
|
||||
bestPb = ptOnA ? on : pt;
|
||||
}
|
||||
}
|
||||
|
||||
Consider(p, b, true);
|
||||
Consider(a.pt2, b, true);
|
||||
Consider(q, a, false);
|
||||
Consider(b.pt2, a, false);
|
||||
|
||||
pa = bestPa;
|
||||
pb = bestPb;
|
||||
return best;
|
||||
}
|
||||
|
||||
private static double Clamp(double v, double lo, double hi) =>
|
||||
v < lo ? lo : (v > hi ? hi : v);
|
||||
|
||||
private static double Magnitude(Vector v) => System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
|
||||
|
||||
private static Box SegmentBox(Line line)
|
||||
{
|
||||
return new Box(
|
||||
System.Math.Min(line.pt1.X, line.pt2.X),
|
||||
System.Math.Min(line.pt1.Y, line.pt2.Y),
|
||||
System.Math.Abs(line.pt2.X - line.pt1.X),
|
||||
System.Math.Abs(line.pt2.Y - line.pt1.Y)
|
||||
);
|
||||
}
|
||||
|
||||
private static bool BoxesWithin(Box a, Box b, double distance)
|
||||
{
|
||||
return !(
|
||||
a.Right + distance < b.Left
|
||||
|| b.Right + distance < a.Left
|
||||
|| a.Top + distance < b.Bottom
|
||||
|| b.Top + distance < a.Bottom
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ using OpenNest.Math;
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Polygon overlap test with hole subtraction. This is the reference implementation
|
||||
/// for a future GPU kernel, so it deliberately stays hand-rolled instead of using
|
||||
/// Clipper (which is CPU-only and allocation-heavy; see <see cref="ClipperBridge"/>
|
||||
/// for the CPU preparation that feeds it).
|
||||
/// Polygon overlap test with hole subtraction, used by engine and validation per-pair
|
||||
/// checks. It was written as the reference for a GPU kernel; that port is on hold in
|
||||
/// favour of Clipper2, starting with the material-overlap diagnostic. Engine callers
|
||||
/// stay on this kernel until a Clipper2 near-contact differential and the layout gate
|
||||
/// pass.
|
||||
/// <para>
|
||||
/// GPU-port contract. Per-polygon preparation, done once per drawing and rotation,
|
||||
/// Former GPU-port contract, kept for reference. Per-polygon preparation, done once
|
||||
/// per drawing and rotation,
|
||||
/// then cached and uploaded: the spacing offset (<see cref="ClipperBridge"/>),
|
||||
/// triangulation (<see cref="ConvexDecomposition.Triangulate"/>) of the outline and
|
||||
/// each hole, and the bounding box of every polygon and triangle. Per-pair work,
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// One world-space point on a cut contour together with the cut direction there.
|
||||
/// Produced by <see cref="ContourSampler"/> for rendering (cut-direction arrows)
|
||||
/// and for measurement (contour alignment); neither caller may mutate it.
|
||||
/// </summary>
|
||||
public readonly struct ContourSample
|
||||
{
|
||||
/// <summary>Point on the contour in world coordinates.</summary>
|
||||
public Vector Position { get; }
|
||||
|
||||
/// <summary>Unit vector pointing in the direction of travel along the contour.</summary>
|
||||
public Vector Direction { get; }
|
||||
|
||||
/// <summary>
|
||||
/// World-frame tangent angle in radians (atan2 of <see cref="Direction"/>).
|
||||
/// Screen-space conversion is the renderer's job.
|
||||
/// </summary>
|
||||
public double Tangent { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Arclength of this sample from the start of the contour walk it came from:
|
||||
/// cumulative distance along non-rapid, non-suppressed moves for a program
|
||||
/// walk, and from the ring's first vertex for a ring walk.
|
||||
/// </summary>
|
||||
public double At { get; }
|
||||
|
||||
public ContourSample(Vector position, Vector direction, double tangent, double at)
|
||||
{
|
||||
Position = position;
|
||||
Direction = direction;
|
||||
Tangent = tangent;
|
||||
At = at;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure contour sampling math shared by the cut-direction arrow renderer and by
|
||||
/// contour alignment. All positions and tangents are world-space; no screen
|
||||
/// conversion, no arrowheads, and no view state appear here.
|
||||
/// <para>
|
||||
/// Two scheduling policies live here deliberately:
|
||||
/// <see cref="LineMoves"/> and <see cref="ArcMoves"/> implement the arrow
|
||||
/// renderer's established display policy (skip moves shorter than half the
|
||||
/// spacing, place <c>max(1, trunc(len/spacing))</c> arrows strictly inside each
|
||||
/// move, resetting per move), while <see cref="RingMoves"/> is a contour-wide
|
||||
/// arclength scheduler for measurement: distance is carried across segment
|
||||
/// boundaries, no segment is omitted, and the closing vertex of a ring is never
|
||||
/// duplicated. Alignment must use the ring policy; display zoom must never
|
||||
/// change an alignment result because alignment spacing comes from the model,
|
||||
/// not the view.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ContourSampler
|
||||
{
|
||||
/// <summary>
|
||||
/// Samples one bounded line move using the arrow display policy: no samples
|
||||
/// when the move is shorter than half the spacing or degenerate; otherwise
|
||||
/// <c>max(1, (int)(length / spacing))</c> samples strictly between the
|
||||
/// endpoints at uniform spacing. Appends to <paramref name="output"/>.
|
||||
/// </summary>
|
||||
public static void LineMoves(
|
||||
Vector start,
|
||||
Vector end,
|
||||
double spacing,
|
||||
List<ContourSample> output
|
||||
)
|
||||
{
|
||||
var dx = end.X - start.X;
|
||||
var dy = end.Y - start.Y;
|
||||
var length = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (length < spacing * 0.5)
|
||||
return;
|
||||
|
||||
var dirX = dx / length;
|
||||
var dirY = dy / length;
|
||||
var tangent = System.Math.Atan2(dirY, dirX);
|
||||
|
||||
var count = System.Math.Max(1, (int)(length / spacing));
|
||||
var step = length / (count + 1);
|
||||
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
var t = step * i;
|
||||
var pt = new Vector(start.X + dirX * t, start.Y + dirY * t);
|
||||
output.Add(new ContourSample(pt, new Vector(dirX, dirY), tangent, t));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Samples one bounded arc move using the arrow display policy. The sweep is
|
||||
/// taken in the requested rotation direction and always in (0, 2*PI], so a
|
||||
/// full circle (equal endpoints) yields a full turn. No samples when the arc
|
||||
/// is shorter than half the spacing or the radius is degenerate. Tangents
|
||||
/// follow the direction of travel: +90 degrees from the radius for CCW,
|
||||
/// -90 degrees for CW. Appends to <paramref name="output"/>.
|
||||
/// </summary>
|
||||
public static void ArcMoves(
|
||||
Vector start,
|
||||
Vector end,
|
||||
Vector center,
|
||||
RotationType rotation,
|
||||
double spacing,
|
||||
List<ContourSample> output
|
||||
)
|
||||
{
|
||||
var radius = center.DistanceTo(start);
|
||||
if (radius < Tolerance.Epsilon)
|
||||
return;
|
||||
|
||||
var startAngle = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
|
||||
var endAngle = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
|
||||
|
||||
double sweep;
|
||||
if (rotation == RotationType.CCW)
|
||||
{
|
||||
sweep = endAngle - startAngle;
|
||||
if (sweep <= 0)
|
||||
sweep += 2 * System.Math.PI;
|
||||
}
|
||||
else
|
||||
{
|
||||
sweep = startAngle - endAngle;
|
||||
if (sweep <= 0)
|
||||
sweep += 2 * System.Math.PI;
|
||||
}
|
||||
|
||||
var arcLength = radius * System.Math.Abs(sweep);
|
||||
if (arcLength < spacing * 0.5)
|
||||
return;
|
||||
|
||||
var count = System.Math.Max(1, (int)(arcLength / spacing));
|
||||
var stepAngle = sweep / (count + 1);
|
||||
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
double angle;
|
||||
if (rotation == RotationType.CCW)
|
||||
angle = startAngle + stepAngle * i;
|
||||
else
|
||||
angle = startAngle - stepAngle * i;
|
||||
|
||||
var pt = new Vector(
|
||||
center.X + radius * System.Math.Cos(angle),
|
||||
center.Y + radius * System.Math.Sin(angle)
|
||||
);
|
||||
|
||||
double tangent;
|
||||
if (rotation == RotationType.CCW)
|
||||
tangent = angle + System.Math.PI / 2;
|
||||
else
|
||||
tangent = angle - System.Math.PI / 2;
|
||||
|
||||
var dir = new Vector(System.Math.Cos(tangent), System.Math.Sin(tangent));
|
||||
var at = radius * System.Math.Abs(stepAngle * i);
|
||||
output.Add(new ContourSample(pt, dir, tangent, at));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks a CNC program in world space with the same traversal policy the cut
|
||||
/// direction renderer has always used: absolute endpoints are relative to
|
||||
/// <paramref name="basePos"/>, incremental endpoints and arc centers are
|
||||
/// relative to the current position, suppressed moves and rapids advance the
|
||||
/// pen but produce no samples, and each sub-program call executes at
|
||||
/// <c>basePos + Offset</c> against a shared program (callers own the shared
|
||||
/// program; this method only reads it). Suppressed sub-program content is
|
||||
/// filtered inside the sub-program itself.
|
||||
/// </summary>
|
||||
/// <returns>The pen position after the program, so callers keep the
|
||||
/// reference semantics of the renderer's by-ref position.</returns>
|
||||
public static Vector ProgramMoves(
|
||||
Program pgm,
|
||||
Vector basePos,
|
||||
Vector pos,
|
||||
double spacing,
|
||||
List<ContourSample> output
|
||||
)
|
||||
{
|
||||
var at = 0.0;
|
||||
WalkProgram(pgm, basePos, ref pos, spacing, output, ref at);
|
||||
return pos;
|
||||
}
|
||||
|
||||
private static void WalkProgram(
|
||||
Program pgm,
|
||||
Vector basePos,
|
||||
ref Vector pos,
|
||||
double spacing,
|
||||
List<ContourSample> output,
|
||||
ref double at
|
||||
)
|
||||
{
|
||||
for (var i = 0; i < pgm.Length; ++i)
|
||||
{
|
||||
var code = pgm[i];
|
||||
|
||||
if (code.Type == CodeType.SubProgramCall)
|
||||
{
|
||||
var subpgm = (SubProgramCall)code;
|
||||
if (subpgm.Program != null)
|
||||
{
|
||||
var holeBase = basePos + subpgm.Offset;
|
||||
pos = holeBase;
|
||||
WalkProgram(
|
||||
subpgm.Program,
|
||||
holeBase,
|
||||
ref pos,
|
||||
spacing,
|
||||
output,
|
||||
ref at
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code is not Motion motion)
|
||||
continue;
|
||||
|
||||
var endpt =
|
||||
pgm.Mode == Mode.Incremental
|
||||
? motion.EndPoint + pos
|
||||
: motion.EndPoint + basePos;
|
||||
|
||||
if (code.Type == CodeType.LinearMove)
|
||||
{
|
||||
var line = (LinearMove)code;
|
||||
if (!line.Suppressed)
|
||||
{
|
||||
var before = output.Count;
|
||||
LineMoves(pos, endpt, spacing, output);
|
||||
Relocate(output, before, at);
|
||||
at += Distance(pos, endpt);
|
||||
}
|
||||
}
|
||||
else if (code.Type == CodeType.ArcMove)
|
||||
{
|
||||
var arc = (ArcMove)code;
|
||||
if (!arc.Suppressed)
|
||||
{
|
||||
var center =
|
||||
pgm.Mode == Mode.Incremental
|
||||
? arc.CenterPoint + pos
|
||||
: arc.CenterPoint + basePos;
|
||||
var before = output.Count;
|
||||
ArcMoves(pos, endpt, center, arc.Rotation, spacing, output);
|
||||
Relocate(output, before, at);
|
||||
at += ArcDistance(pos, endpt, center, arc.Rotation);
|
||||
}
|
||||
}
|
||||
|
||||
pos = endpt;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resamples a closed ring at near-uniform arclength for measurement. The
|
||||
/// distance counter is carried across segment boundaries, no segment is
|
||||
/// omitted, and the closing vertex is not duplicated: samples sit at
|
||||
/// arclength <c>i * step</c> for <c>i in [0, count)</c> where
|
||||
/// <c>step = perimeter / count</c> divides the perimeter exactly, so the
|
||||
/// sample set is invariant to where the ring's start vertex sits as long as
|
||||
/// the caller quantizes consistently. A duplicated explicit closing vertex
|
||||
/// is accepted and ignored.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The ring has fewer than three distinct vertices, nonfinite coordinates,
|
||||
/// or a zero perimeter.
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><paramref name="spacing"/> is not finite or not positive.</exception>
|
||||
public static void RingMoves(IList<Vector> ring, double spacing, List<ContourSample> output)
|
||||
{
|
||||
if (ring == null)
|
||||
throw new ArgumentNullException(nameof(ring));
|
||||
if (!(spacing > 0) || double.IsInfinity(spacing) || double.IsNaN(spacing))
|
||||
throw new ArgumentOutOfRangeException(nameof(spacing));
|
||||
|
||||
var n = ring.Count;
|
||||
if (n > 1 && ring[0] == ring[n - 1])
|
||||
n--; // ignore an explicit closing vertex; the ring closes implicitly
|
||||
if (n < 3)
|
||||
throw new ArgumentException("Ring needs at least 3 distinct vertices.", nameof(ring));
|
||||
|
||||
var perimeter = 0.0;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var a = ring[i];
|
||||
var b = ring[(i + 1) % n];
|
||||
if (double.IsNaN(a.X) || double.IsNaN(a.Y) || double.IsNaN(b.X) || double.IsNaN(b.Y))
|
||||
throw new ArgumentException("Ring contains nonfinite coordinates.", nameof(ring));
|
||||
perimeter += Distance(a, b);
|
||||
}
|
||||
if (!(perimeter > Tolerance.Epsilon))
|
||||
throw new ArgumentException("Ring has zero perimeter.", nameof(ring));
|
||||
|
||||
var count = System.Math.Max(1, (int)System.Math.Round(perimeter / spacing));
|
||||
var step = perimeter / count;
|
||||
|
||||
var seg = 0;
|
||||
var segStart = 0.0; // cumulative arclength at the start of segment seg
|
||||
for (var k = 0; k < count; k++)
|
||||
{
|
||||
var s = step * k;
|
||||
|
||||
// Carry the walk across segment boundaries; short segments advance
|
||||
// the arclength counter without ever being skipped.
|
||||
var a = ring[seg];
|
||||
var b = ring[(seg + 1) % n];
|
||||
var segLen = Distance(a, b);
|
||||
while (s > segStart + segLen && seg + 1 < n)
|
||||
{
|
||||
segStart += segLen;
|
||||
seg++;
|
||||
a = ring[seg];
|
||||
b = ring[(seg + 1) % n];
|
||||
segLen = Distance(a, b);
|
||||
}
|
||||
|
||||
var local = segLen > 0 ? (s - segStart) / segLen : 0.0;
|
||||
var dir = SegmentDirection(a, b);
|
||||
var pt = new Vector(a.X + (b.X - a.X) * local, a.Y + (b.Y - a.Y) * local);
|
||||
output.Add(new ContourSample(pt, dir, System.Math.Atan2(dir.Y, dir.X), s));
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector SegmentDirection(Vector a, Vector b)
|
||||
{
|
||||
var dx = b.X - a.X;
|
||||
var dy = b.Y - a.Y;
|
||||
var len = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
return len > 0 ? new Vector(dx / len, dy / len) : new Vector(1, 0);
|
||||
}
|
||||
|
||||
private static double Distance(Vector a, Vector b)
|
||||
{
|
||||
var dx = b.X - a.X;
|
||||
var dy = b.Y - a.Y;
|
||||
return System.Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
private static double ArcDistance(
|
||||
Vector start,
|
||||
Vector end,
|
||||
Vector center,
|
||||
RotationType rotation
|
||||
)
|
||||
{
|
||||
var radius = center.DistanceTo(start);
|
||||
if (radius < Tolerance.Epsilon)
|
||||
return 0.0;
|
||||
|
||||
// Same sweep convention as ArcMoves: always in (0, 2*PI], so a full
|
||||
// circle counts its whole circumference toward the walk's arclength.
|
||||
var startAngle = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
|
||||
var endAngle = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
|
||||
var sweep =
|
||||
rotation == RotationType.CCW ? endAngle - startAngle : startAngle - endAngle;
|
||||
if (sweep <= 0)
|
||||
sweep += 2 * System.Math.PI;
|
||||
return radius * sweep;
|
||||
}
|
||||
|
||||
private static void Relocate(List<ContourSample> output, int from, double baseAt)
|
||||
{
|
||||
if (baseAt == 0.0)
|
||||
return;
|
||||
for (var i = from; i < output.Count; i++)
|
||||
{
|
||||
var s = output[i];
|
||||
output[i] = new ContourSample(
|
||||
s.Position,
|
||||
s.Direction,
|
||||
s.Tangent,
|
||||
baseAt + s.At
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,14 +148,12 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
private static double SignedArea(List<Vector> verts)
|
||||
{
|
||||
// World-coordinate products can erase the sign of a small polygon's area
|
||||
// far from the origin, leaving CW outlines/holes untriangulated. Measure
|
||||
// relative to a vertex, just as the clipping kernel measures its fragments.
|
||||
var area = 0.0;
|
||||
|
||||
for (var i = 0; i < verts.Count; i++)
|
||||
{
|
||||
var j = (i + 1) % verts.Count;
|
||||
area += verts[i].X * verts[j].Y;
|
||||
area -= verts[j].X * verts[i].Y;
|
||||
}
|
||||
for (var i = 1; i + 1 < verts.Count; i++)
|
||||
area += Cross(verts[0], verts[i], verts[i + 1]);
|
||||
|
||||
return area * 0.5;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -108,8 +108,18 @@ namespace OpenNest.Geometry
|
||||
nameof(tolerance),
|
||||
"Tolerance must be positive."
|
||||
);
|
||||
if (semiMajor <= 0 || semiMinor <= 0)
|
||||
throw new ArgumentOutOfRangeException("Semi-axis lengths must be positive.");
|
||||
if (semiMajor <= 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(semiMajor),
|
||||
semiMajor,
|
||||
"Semi-major axis length must be positive."
|
||||
);
|
||||
if (semiMinor <= 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(semiMinor),
|
||||
semiMinor,
|
||||
"Semi-minor axis length must be positive."
|
||||
);
|
||||
|
||||
if (endParam <= startParam)
|
||||
endParam += Angle.TwoPI;
|
||||
@@ -320,7 +330,7 @@ namespace OpenNest.Geometry
|
||||
var endAngle = System.Math.Atan2(p1.Y - arcCenter.Y, p1.X - arcCenter.X);
|
||||
|
||||
var points = new List<Vector> { p0, pMid, p1 };
|
||||
var isReversed = SumSignedAngles(arcCenter, points) < 0;
|
||||
var isReversed = ArcFit.SumSignedAngles(arcCenter, points) < 0;
|
||||
|
||||
if (startAngle < 0)
|
||||
startAngle += Angle.TwoPI;
|
||||
@@ -329,22 +339,5 @@ namespace OpenNest.Geometry
|
||||
|
||||
return new Arc(arcCenter, radius, startAngle, endAngle, isReversed);
|
||||
}
|
||||
|
||||
private static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI)
|
||||
da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI)
|
||||
da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,32 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Threading;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public abstract class Entity : IBoundable
|
||||
{
|
||||
private static long idCounter;
|
||||
private static readonly Guid idSalt = Guid.NewGuid();
|
||||
|
||||
protected Box boundingBox;
|
||||
|
||||
protected Entity()
|
||||
{
|
||||
Id = Guid.NewGuid();
|
||||
// Retain a process-random prefix; allocate the final six bytes atomically.
|
||||
// IDs are opaque persistence keys, not unpredictable security tokens.
|
||||
var counter = Interlocked.Increment(ref idCounter);
|
||||
if ((ulong)counter > 0xFFFFFFFFFFFFUL)
|
||||
throw new InvalidOperationException("Entity identifier sequence exhausted.");
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
idSalt.TryWriteBytes(bytes);
|
||||
Span<byte> counterBytes = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteInt64LittleEndian(counterBytes, counter);
|
||||
counterBytes[..6].CopyTo(bytes[10..]);
|
||||
Id = new Guid(bytes);
|
||||
Layer = OpenNest.Geometry.Layer.Default;
|
||||
boundingBox = new Box();
|
||||
}
|
||||
|
||||
Loaded 100 of 966 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user