mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-10 18:55:14 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d01515911 | |||
| 8ffb97d541 | |||
| 8f13e948a9 | |||
| 59a85a1646 | |||
| 4890cebd36 | |||
| 27d5a264f3 | |||
| 3b82c40ff7 | |||
| a05b47339f | |||
| b53fa7a569 | |||
| 4579e1ad50 | |||
| bc63b3670b | |||
| dfb240a982 | |||
| a22d42ad98 | |||
| 7c03f57fd9 | |||
| 91a08fc63a | |||
| 193a13d7fc | |||
| 2e8e68c64d | |||
| 11d408d8c2 | |||
| 1b4547f2b3 | |||
| ae4f93a7cf | |||
| 59b78db068 | |||
| 9210343c9d | |||
| cce1c9dcf5 | |||
| 11bb98683f | |||
| 55297e5b30 |
Vendored
+14
@@ -230,6 +230,20 @@ jobs:
|
||||
- run: rustup target add wasm32-unknown-unknown
|
||||
- run: cargo check -p solidcore --target wasm32-unknown-unknown
|
||||
|
||||
# Each package builds on the Rust release its `rust-version` declares (scripts/ci/
|
||||
# check-rust-version.sh). Downstream users build on older pinned toolchains than
|
||||
# rust-toolchain.toml's, and nothing else here runs one.
|
||||
rust-version:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.full == 'true'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> bin"
|
||||
- run: scripts/ci/check-rust-version.sh
|
||||
|
||||
# Same packaging check the release workflow runs before publishing (no secrets).
|
||||
publish-dry-run:
|
||||
needs: changes
|
||||
|
||||
Vendored
+2
@@ -1,3 +1,5 @@
|
||||
> **solidcore is developed inside [SolidCraft](https://github.com/tangency-labs/solidcraft), at [`solidcore/`](https://github.com/tangency-labs/solidcraft/tree/main/solidcore).** The [tangency-labs/solidcore](https://github.com/tangency-labs/solidcore) repository is an automatic, read-only mirror of that folder. Open issues and pull requests in [tangency-labs/solidcraft](https://github.com/tangency-labs/solidcraft): kernel-only work against the `kernel` branch.
|
||||
|
||||
<p align="center"><img src="assets/logo.svg" width="140" alt="solidcore logo: an isometric solid with one filleted edge and a NURBS curve on its top face"></p>
|
||||
|
||||
# solidcore
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{"format":1,"unit":"blend.feature_executive"}
|
||||
{"case":"add_by_faces_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2774,380658],[2585,341911],[6017,756869],[2124,294143],[7047,692538],[7860,1181034],[13215,1649521],[2767,78418]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176984],[2441,349823],[4137,677755],[748,14692]],"n":804},"feature2_graph":{"blocks":[[1305,197906],[1748,275169],[1932,107176]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115412070844,687.1779118214433,52744.46080298995]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155029.88762338806,155061.88762338806,20154954.18077134],[116389.18300344986,116429.18300344986,12866754.328732701],[106102.57307303276,106122.57307303276,13684737.362150839],[45934.73239171605,45934.73239171605,3587621.6467926963]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6389177.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155065.5,155109.5,18928751.0],[105021.5,105109.5,12645808.0],[135.0,135.0,1804.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15852924.877656039],[60998.52675559926,60998.52675559926,4015371.2724186946]],"n":423},"record":{"blocks":[[326.0,346.0,12744.0]],"n":124}}}
|
||||
{"case":"all_faces_defillet","deferred":null,"error":null,"ints":{"base_graph":[1,0,0,0,0,0,0,0,0,0,1],"feature0_graph":{"blocks":[[1743,230242],[2881,397902],[3189,537461],[5001,812093],[4768,323418]],"n":1165}},"topology":null,"values":{"base_geometry":[],"base_record":[0.0,0.0],"feature0_geometry":{"blocks":[[1.3599999999999958e+22,1.3599999999999958e+22,9.316000000000001e+23],[14.000069999999997,14.000069999999997,140.00077]],"n":276},"feature0_record":{"blocks":[[154466.63959673684,154466.63959673684,18841428.541271232],[129333.9587499411,129333.9587499411,14745007.961884614],[90266.21715728752,90266.21715728752,11236079.691673888],[9.0,9.0,68.0]],"n":779},"record":[1.0,0.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,0.0,9.0,1.0,0.0]}}
|
||||
{"case":"all_faces_filleted_box","deferred":null,"error":null,"ints":{"base_graph":[1,0,0,0,0,0,0,0,0,0,1],"feature0_graph":{"blocks":[[1903,220645],[3189,516856],[3449,269618],[5567,663498],[7339,1115480],[11638,1409058]],"n":1528}},"topology":null,"values":{"base_geometry":[],"base_record":[0.0,0.0],"feature0_geometry":{"blocks":[[1.91999999999999e+22,1.91999999999999e+22,1.8528000000000002e+24],[452.0003399999995,452.0003399999995,26406.02375]],"n":378},"feature0_record":{"blocks":[[155397.5,155397.5,18957409.781469982],[154927.4900204164,154927.4900204164,20151093.307439756],[103966.76733889646,103996.76733889646,12124882.750080192],[75978.24264068715,76068.24264068715,7458816.37737983]],"n":988},"record":[1.0,0.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,0.0,10.0,1.0,0.0]}}
|
||||
{"case":"attributes_add_by_faces_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2774,380658],[2585,341911],[6017,756869],[2124,294143],[7047,692538],[7860,1181034],[13215,1649521],[2767,78418]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176984],[2441,349823],[4137,677755],[748,14692]],"n":804},"feature2_graph":{"blocks":[[1305,197906],[1748,275169],[1932,107176]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115412070844,687.1779118214433,52744.46080298995]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155029.88762338806,155061.88762338806,20154954.18077134],[116389.18300344986,116429.18300344986,12866754.328732701],[106102.57307303276,106122.57307303276,13684737.362150839],[45934.73239171605,45934.73239171605,3587621.6467926963]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6389177.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155065.5,155109.5,18928751.0],[105021.5,105109.5,12645808.0],[135.0,135.0,1804.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15852924.877656039],[60998.52675559926,60998.52675559926,4015371.2724186946]],"n":423},"record":{"blocks":[[337.0,357.0,14391.0]],"n":164}}}
|
||||
{"case":"attributes_inner_loop_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2548,339314],[2808,388653],[5366,597446],[3316,572452],[5984,574809],[9450,1382528],[11491,1234945]],"n":1760},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261}},"topology":null,"values":{"base_geometry":{"blocks":[[2.0799999999999883e+22,2.0799999999999883e+22,2.1736000000000002e+24],[363.8030081955735,560.3693788099324,25313.152184450642]],"n":418},"base_record":{"blocks":[[155416.25547015207,155492.8218407664,18950600.3615193],[155118.1152126431,155118.1152126431,20152219.349649835],[116983.14328273362,117023.14328273362,13745846.636862068],[91170.82416150477,91234.82416150477,11435584.550245512],[30337.0,30353.0,1019935.0]],"n":1118},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6385487.88502191]],"n":197},"record":{"blocks":[[81.0,89.0,1463.0]],"n":58}}}
|
||||
{"case":"attributes_rebuild_fillets_defillet","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[1906,252991],[3366,516788],[2314,400069],[4443,571496],[8162,998322]],"n":1264},"feature0_graph":{"blocks":[[853,122476],[847,63880]],"n":377}},"topology":null,"values":{"base_geometry":{"blocks":[[1.439999999999995e+22,1.439999999999995e+22,1.044e+24],[21.200139999999998,21.200139999999998,379.20287]],"n":296},"base_record":{"blocks":[[154399.14159265358,154399.14159265358,18832865.4645943],[128406.54159265356,128406.54159265356,14075364.427283542],[105272.0,105272.0,13472688.57781746],[15078.741592653581,15082.741592653596,542971.1221134894]],"n":844},"feature0_geometry":{"blocks":[[4e+21,4e+21,8.199999999999998e+22]],"n":82},"feature0_record":{"blocks":[[115424.47391131187,115428.47391131187,10922544.14210909]],"n":245},"record":{"blocks":[[79.0,89.0,1508.0]],"n":70}}}
|
||||
{"case":"attributes_restore_defillet","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[1508,183428],[2419,270746],[3057,403507],[6277,856170],[551,3370]],"n":1035},"feature0_graph":{"blocks":[[278,24878]],"n":146}},"topology":null,"values":{"base_geometry":{"blocks":[[1.1999999999999975e+22,1.1999999999999975e+22,7.260000000000001e+23]],"n":252},"base_record":{"blocks":[[154397.14159265358,154397.14159265358,18832863.4645943],[130360.84159265357,130360.84159265357,15657691.00865363],[60190.0,60190.0,6048065.0]],"n":714},"feature0_geometry":{"blocks":[[1.6e+21,1.6e+21,1.36e+22]],"n":38},"feature0_record":{"blocks":[[57216.03231865831,57216.03231865831,2227522.302144891]],"n":115},"record":{"blocks":[[59.0,67.0,823.0]],"n":55}}}
|
||||
@@ -54,6 +56,9 @@
|
||||
{"case":"fillet_group_torii_fillet_rb11","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2573,312590],[2235,302121],[7287,1092477],[3374,249678],[9425,1441704],[5510,902128],[13186,1872602],[16615,2004017],[1666,20910]],"n":2071}},"topology":null,"values":{"base_geometry":{"blocks":[[2.5599999999999832e+22,2.5599999999999832e+22,3.2896e+24],[8e+20,8e+20,3.6e+21],[1.0000200000000001,1.0000200000000001,3.00005]],"n":516},"base_record":{"blocks":[[155035.83440579096,155661.89765365858,18948680.82052572],[155281.77152757163,155574.77152757163,20161266.305777643],[141134.20495401855,141407.41630458267,17778418.33330449],[105017.50209477526,105586.50209477526,11821412.01749248],[91174.28875598182,91629.28875598182,10910463.960739516],[15238.813143693185,15288.813143693185,109863.88984156726]],"n":1327},"record":[0.0,0.0,1.0,0.0,0.0]}}
|
||||
{"case":"fillet_group_torii_fillet_rb23","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2573,312590],[2235,302121],[7287,1092477],[3374,249678],[9425,1441704],[5510,902128],[13186,1872602],[16615,2004017],[1666,20910]],"n":2071}},"topology":null,"values":{"base_geometry":{"blocks":[[2.5599999999999832e+22,2.5599999999999832e+22,3.2896e+24],[8e+20,8e+20,3.6e+21],[1.0000200000000001,1.0000200000000001,3.00005]],"n":516},"base_record":{"blocks":[[155035.83440579096,155661.89765365858,18948680.82052572],[155281.77152757163,155574.77152757163,20161266.305777643],[141134.20495401855,141407.41630458267,17778418.33330449],[105017.50209477526,105586.50209477526,11821412.01749248],[91174.28875598182,91629.28875598182,10910463.960739516],[15238.813143693185,15288.813143693185,109863.88984156726]],"n":1327},"record":[0.0,0.0,1.0,0.0,0.0]}}
|
||||
{"case":"mitred_rounds_box_top_r3","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2083,253864],[3024,468943],[3863,321220],[5564,804198],[5754,904563],[11792,1526882],[1838,41095]],"n":1577},"feature0_graph":{"blocks":[[1806,224222],[3712,613264],[2805,507349],[4462,572148],[9492,1266469],[1232,21604]],"n":1313}},"topology":null,"values":{"base_geometry":{"blocks":[[1.91999999999999e+22,1.91999999999999e+22,1.8528000000000002e+24],[782.0829429411759,782.082942941176,29519.122368823526]],"n":383},"base_record":{"blocks":[[156507.5,156507.5,19111185.0],[156364.54117647058,156364.54117647058,20322396.93382353],[105433.0411764706,105433.0411764706,12327102.061764706],[94630.0,95718.0,11327548.0],[210.0,210.0,660.0]],"n":1029},"feature0_geometry":{"blocks":[[1.5999999999999933e+22,1.5999999999999933e+22,1.288e+24],[37.98568732985196,37.98568732985196,788.0151850985815]],"n":313},"feature0_record":{"blocks":[[156703.22494602168,156703.22494602168,19162418.692349844],[143258.58235294116,143258.58235294116,17060525.736758336],[93725.45584412271,94813.45584412271,10865237.012552246],[31834.0,31834.0,749544.5]],"n":851},"record":{"blocks":[[180.0,190.0,3080.0]],"n":52}}}
|
||||
{"case":"multi_feature_adv_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2764,379783],[2576,339959],[6104,754984],[2151,298312],[7054,677922],[7816,1172299],[13400,1684853],[2574,69936]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176978],[2441,349693],[4111,670924],[774,14683]],"n":804},"feature2_graph":{"blocks":[[1305,198056],[1736,271264],[1944,108295]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[638.8841584798115,775.4505290941705,56271.317757521465]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155094.1600378454,155126.1600378454,20164389.693007357],[116325.18331626526,116365.18331626526,12865628.669316046],[106093.77870914956,106113.77870914956,13675114.593415532],[45943.52615059925,45943.52615059925,3582894.5606996478]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6385487.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105063.5,105159.5,12655567.5],[75.0,75.0,967.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.34995257392,142729.34995257392,15852336.370148472],[60998.52615059925,60998.52615059925,4010652.7699044417]],"n":423},"record":{"blocks":[[326.0,346.0,13094.0]],"n":124}}}
|
||||
{"case":"multi_feature_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2771,380151],[2581,340435],[6027,751066],[2174,302712],[6982,679564],[7880,1185829],[13383,1678399],[2591,69948]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176978],[2441,349693],[4111,670924],[774,14683]],"n":804},"feature2_graph":{"blocks":[[1305,197879],[1748,275250],[1932,107087]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115312070845,687.1779218214433,52984.82530328035]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155029.88762338806,155061.88762338806,20148234.18077134],[116389.18300344986,116429.18300344986,12864740.769140378],[106102.57306303276,106122.57308303277,13677357.359610839],[45934.73234671234,45934.73240671234,3583931.6460126126]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6385487.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105063.5,105159.5,12655567.5],[75.0,75.0,967.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15851718.332201496],[60998.526700595554,60998.52678059555,4011681.271218581]],"n":423},"record":{"blocks":[[326.0,346.0,13090.0]],"n":124}}}
|
||||
{"case":"multi_rebuild_0_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2771,380151],[2581,340435],[6027,751042],[2174,302712],[6995,682597],[7865,1184614],[13385,1678999],[2591,70004]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176993],[2441,349514],[4107,669726],[778,14797]],"n":804},"feature2_graph":{"blocks":[[1305,197879],[1748,275250],[1932,107087]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115312070845,667.1779218214434,52652.187164461]],"n":442},"base_record":{"blocks":[[155451.53865545918,155483.53865545918,18959902.682866313],[155023.60443808086,155068.17080869523,20150495.966324273],[116389.18300344986,116429.18300344986,12864740.769140378],[106102.57306303276,106103.90832107945,13677333.40532395],[45934.73234671234,45934.73240671234,3583931.6460126126]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87795.71681469283,87822.18479396813,6386245.248830656]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105057.83309524418,105149.83309524418,12654314.892937822],[76.66690475583123,76.66690475583123,1007.5613490585596]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15851718.332201496],[60998.526700595554,60998.52678059555,4011681.271218581]],"n":423},"record":{"blocks":[[322.0,342.0,12969.0]],"n":127}}}
|
||||
{"case":"multi_rebuild_1_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2077,266206],[3633,579612],[2138,226476],[4989,506469],[8927,1280475],[3025,147354]],"n":1369},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176993],[2441,349514],[4107,669726],[778,14797]],"n":804},"feature2_graph":{"blocks":[[293,26021]],"n":149}},"topology":null,"values":{"base_geometry":{"blocks":[[1.5999999999999933e+22,1.5999999999999933e+22,1.288e+24],[110.8481596440759,207.41453025843512,3679.688704523623]],"n":343},"base_record":{"blocks":[[155448.14519604263,155480.14519604263,18960494.546111137],[155020.50867205963,155065.07504267397,20143946.545116752],[91052.57229961093,91093.23991863422,11082444.561509266],[45484.33380951166,45485.00142853499,2282412.032856306]],"n":899},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87795.71681469283,87822.18479396813,6386245.248830656]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105057.83309524418,105149.83309524418,12654314.892937822],[76.66690475583123,76.66690475583123,1007.5613490585596]],"n":536},"feature2_geometry":{"blocks":[[1.6e+21,1.6e+21,1.36e+22]],"n":49},"feature2_record":{"blocks":[[71831.53239638661,71831.53239638661,3671408.0937480605]],"n":139},"record":{"blocks":[[287.0,307.0,9792.0]],"n":127}}}
|
||||
{"case":"multi_rebuild_2_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2207,279844],[3288,509422],[3158,278508],[4947,640592],[6593,1014126],[9416,988308]],"n":1500},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176993],[2441,349514],[4107,669726],[778,14797]],"n":804}},"topology":null,"values":{"base_geometry":{"blocks":[[1.7599999999999916e+22,1.7599999999999916e+22,1.5576000000000002e+24],[152.8067495591884,269.37312017354753,7235.722609831468]],"n":364},"base_record":{"blocks":[[155260.6164362172,155305.18280683155,18929403.35444251],[155162.03743188508,155194.03743188508,20182286.063773222],[91107.57229961093,91148.23991863422,11083020.561509266],[60725.11195932037,60725.779578343696,4299053.8873962285]],"n":940},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87795.71681469283,87822.18479396813,6386245.248830656]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105057.83309524418,105149.83309524418,12654314.892937822],[76.66690475583123,76.66690475583123,1007.5613490585596]],"n":536},"record":{"blocks":[[271.0,287.0,7844.0]],"n":102}}}
|
||||
{"case":"no_ops_defillet","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[1728,230155],[2865,397674],[3250,542715],[4992,815381],[4729,327785]],"n":1165},"feature0_none":[1]},"topology":null,"values":{"base_geometry":{"blocks":[[1.3599999999999958e+22,1.3599999999999958e+22,9.316000000000001e+23],[11.000069999999997,11.000069999999997,92.00077000000002]],"n":276},"base_record":{"blocks":[[154398.14159265358,154398.14159265358,18832864.4645943],[129390.13959673682,129390.13959673682,14750795.369363222],[90273.51715728751,90273.51715728751,11239145.200591717],[14.017157287525382,14.017157287525382,85.60294372515229]],"n":779},"record":{"blocks":[[48.0,64.0,438.0]],"n":43}}}
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
{"case":"gen0039","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1745.6446517672211,1759.0009086963332,53080.473545951456]],"n":61}}}
|
||||
{"case":"gen0040","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1293.1780750967052,1331.5983705234573,43731.711951791374]],"n":54}}}
|
||||
{"case":"gen0041","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[-1.512782707950123,1082.7428297938636,-509.6925401146473]],"n":89}}}
|
||||
{"case":"gen0042","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[247.59502854734214,267.6372546180361,10403.49931860245]],"n":67}}}
|
||||
{"case":"gen0042","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[247.59500994892622,267.637273216452,10403.498983830963]],"n":67}}}
|
||||
{"case":"gen0043","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[["inf","inf","inf"]],"n":58}}}
|
||||
{"case":"gen0044","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[2363.331389460768,2381.342638010389,23505.946664766696]],"n":54}}}
|
||||
{"case":"gen0045","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[46563.40296431203,46630.561956428515,823550.7565376083]],"n":131}}}
|
||||
@@ -909,7 +909,7 @@
|
||||
{"case":"gen0852","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[160.1181334263939,588.6357976688842,7315.822570183931]],"n":78}}}
|
||||
{"case":"gen0853","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[483.08853328132193,583.7951633580492,21580.922615116793]],"n":68}}}
|
||||
{"case":"gen0854","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1904.368161806397,1986.2846515331662,41598.7870653989]],"n":65}}}
|
||||
{"case":"gen0855","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[-156.02409151847633,410.5124223109191,-4682.4589894044575]],"n":68}}}
|
||||
{"case":"gen0855","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[-156.02408784125566,410.5124186336983,-4682.458967341133]],"n":68}}}
|
||||
{"case":"gen0856","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1840.2670017776718,2073.924694260032,52771.46827067364]],"n":81}}}
|
||||
{"case":"gen0857","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[344.30209778448113,387.76064706770353,11668.607945433409]],"n":56}}}
|
||||
{"case":"gen0858","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[80.44331673962174,1051.7564644076338,4225.38697740385]],"n":89}}}
|
||||
@@ -1655,7 +1655,7 @@
|
||||
{"case":"gen1598","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1107.122912136224,2062.064170269247,18820.15307682447]],"n":100}}}
|
||||
{"case":"gen1599","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1649.8413176227855,1688.9076570088273,82824.75732259153]],"n":80}}}
|
||||
{"case":"gen1600","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1328.8306468841884,1537.8237072141824,64621.894244369585]],"n":89}}}
|
||||
{"case":"gen1601","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[245.6209888918087,260.6012897777472,8049.2557268355995]],"n":55}}}
|
||||
{"case":"gen1601","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[245.62098559210304,260.60129307745285,8049.255707037365]],"n":55}}}
|
||||
{"case":"gen1602","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1565.0240348651373,1591.1848258051928,97009.86974045317]],"n":97}}}
|
||||
{"case":"gen1603","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1295.903574378681,1475.9072442360741,96443.64845655844]],"n":103}}}
|
||||
{"case":"gen1604","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[44185.678798756344,46310.99065267807,787573.7979235228]],"n":172}}}
|
||||
|
||||
+5
-5
@@ -26,7 +26,7 @@
|
||||
{"case":"edge_negate","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"extent":[-3.0,-1.0]}}
|
||||
{"case":"edge_periodic_degenerate_wrap","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[3.0,1.0,0.0,0.0,1.0,0.0]}}
|
||||
{"case":"edge_periodic_huge_value","deferred":null,"error":null,"ints":{"flags":[0,0,1,1]},"topology":null,"values":{"values":[1e+20,1e+20,-1e+20,-1e+20,1.0,-1e+20]}}
|
||||
{"case":"edge_periodic_near_top","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[89.99999000000003,0.0,0.0,90.0,0.0,0.0]}}
|
||||
{"case":"edge_periodic_near_top","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[89.99999000000003,-9.999999974752427e-6,9.999999974752427e-6,90.00000999999997,-9.999999974752427e-6,9.999999974752427e-6]}}
|
||||
{"case":"edge_periodic_negative_period","deferred":null,"error":null,"ints":{"flags":[0,0,1,0]},"topology":null,"values":{"values":[20.0,-160.0,-200.0,-610.0,90.0,-610.0]}}
|
||||
{"case":"edge_periodic_zero_period","deferred":null,"error":null,"ints":{"flags":[0,0,0,0]},"topology":null,"values":{"values":[1.0,3.0,3.0,2.0,1.0,2.0]}}
|
||||
{"case":"edge_same_scaled_zero","deferred":null,"error":null,"ints":{"flags":[0,0,0,0,0,0,1]},"topology":null,"values":{"intersect":[5e-13,1000000.0],"union":[0.0,1000000.0000005]}}
|
||||
@@ -921,7 +921,7 @@
|
||||
{"case":"gen0867","deferred":null,"error":null,"ints":{},"topology":null,"values":{"extents":[-4032.5445657100436,0.0,-4032.5445657100436,-4032.5445657100436,1e+20,-1e+20]}}
|
||||
{"case":"gen0868","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-4.547682406915637,-4.547682406915637,2.000177801164682e-12,3.547682406915637,-4.547682406915637,2.000177801164682e-12]}}
|
||||
{"case":"gen0869","deferred":null,"error":null,"ints":{"disjoint":[0]},"topology":null,"values":{}}
|
||||
{"case":"gen0870","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-3.999999999999,-4.0,0.0,1.000088900582341e-12,-4.0,0.0]}}
|
||||
{"case":"gen0870","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-3.999999999999,-3.999999999999,1.000088900582341e-12,0.0,-3.999999999999,0.0]}}
|
||||
{"case":"gen0871","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-999999.999901,-2.999901,999997.000099,0.00009900000000007125,-2.999901,0.00009900000000007125]}}
|
||||
{"case":"gen0872","deferred":null,"error":null,"ints":{"status_count":[0,0]},"topology":null,"values":{"intervals":[]}}
|
||||
{"case":"gen0873","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-420.0475459692425,-5864.660909553929,0.0,-5444.613363584686,-5864.660909553929,-5444.613363584686]}}
|
||||
@@ -1554,7 +1554,7 @@
|
||||
{"case":"gen1500","deferred":null,"error":null,"ints":{"flags":[0,0,0,0]},"topology":null,"values":{"values":[1e-6,1e-6,1e+20,1e+20,-1e+20,1e+20]}}
|
||||
{"case":"gen1501","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-0.9587557303154033,-0.9587557303154033,4.041244269684597,0.3265002390765175,-0.9587557303154033,0.3265002390765175]}}
|
||||
{"case":"gen1502","deferred":null,"error":null,"ints":{"counts":[0],"flag":[0]},"topology":null,"values":{"clamped":[-3.6531332205612728]}}
|
||||
{"case":"gen1503","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.8431568102231495,-0.8412598042009409,0.0,3.841259804200941,-0.8412598042009409,0.0]}}
|
||||
{"case":"gen1503","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.8431568102231495,-0.8412598042009449,3.9968028886505635e-15,3.841259804200945,-0.8412598042009449,3.9968028886505635e-15]}}
|
||||
{"case":"gen1504","deferred":null,"error":null,"ints":{"flags":[1,1,0,0,0,1,1,1,1,1,0,0,0]},"topology":null,"values":{"values":[-5.0,8.696559494945177,1.8482797474725885,13.696559494945177,8.696559494945177,0.0,0.0,-5.0,0.0,-5.0,0.3650551805981122]}}
|
||||
{"case":"gen1505","deferred":null,"error":null,"ints":{"flags":[0,0,0,0,0,0,0]},"topology":null,"values":{"intersect":[2.5565017038286015,3.0],"union":[-7.213387952269873e-10,3.165025619210411]}}
|
||||
{"case":"gen1506","deferred":null,"error":null,"ints":{"flags":[1,1,0,1]},"topology":null,"values":{"values":[5605.960734602222,5605.960734602222,0.0,0.0,5605.960734602222,0.0]}}
|
||||
@@ -2114,7 +2114,7 @@
|
||||
{"case":"gen2060","deferred":null,"error":null,"ints":{"flags":[0,1,0,0,0,1,1,1,0,4,0,0,1001]},"topology":null,"values":{"values":[3564.9311334680096,3564.9311344680095,3564.93113396801,9.99999883788405e-7,3564.9311344680095,3564.9311344680095,3564.9311344680095,3564.93469839883,0.00010000000020227162,3564.9311334680096,101.0000118234325]}}
|
||||
{"case":"gen2061","deferred":null,"error":null,"ints":{"counts":[],"flag":[0]},"topology":null,"values":{"clamped":[]}}
|
||||
{"case":"gen2062","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"extent":[2.0,360.0]}}
|
||||
{"case":"gen2063","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-0.0001752855794858732,-0.0009064160095653894,0.0,0.0009064156015241429,-0.0009064160095653894,0.0]}}
|
||||
{"case":"gen2063","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-0.0001752855794858732,-0.0009064160095650919,2.975050761300224e-16,0.0009064156015238454,-0.0009064160095650919,2.975050761300224e-16]}}
|
||||
{"case":"gen2064","deferred":null,"error":null,"ints":{"flags":[1001,1,1,0,0,0,0]},"topology":null,"values":{"intersect":[1e+20,-1e+20],"union":[0.465547398032645,1000000.0]}}
|
||||
{"case":"gen2065","deferred":null,"error":null,"ints":{"flag":[1]},"topology":null,"values":{"clamped":[7.390963598478955,-5.0,-0.8157526895288466,-3.0,2.9999,0.0,-1e+20,-3.3306754088323505,7.774316697598074,4.0,-4.903134706608148,0.0,-1.5550380498551388,-4.0]}}
|
||||
{"case":"gen2066","deferred":null,"error":null,"ints":{"flags":[1,1,0,1]},"topology":null,"values":{"values":[-3482.7204020819313,-3482.7204020819313,9.99999883788405e-7,3478.7204020819313,-3482.7204020819313,9.99999883788405e-7]}}
|
||||
@@ -2318,7 +2318,7 @@
|
||||
{"case":"gen2264","deferred":null,"error":null,"ints":{"status":[1007]},"topology":null,"values":{"extent":[1e+20,-1e+20]}}
|
||||
{"case":"gen2265","deferred":null,"error":null,"ints":{"flags":[1001,1,1,0,0,0,0]},"topology":null,"values":{"intersect":[1e+20,-1e+20],"union":[-1234567.0,1234567.0]}}
|
||||
{"case":"gen2266","deferred":null,"error":null,"ints":{"flags":[0,0,0,0]},"topology":null,"values":{"values":[3.5,-0.5005336876101969,0.5,4.500533687610197,-0.0005336876101968466,0.5]}}
|
||||
{"case":"gen2267","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.005902837378700454,0.000983806229783426,0.0,2.0009838062297836,0.000983806229783426,0.0]}}
|
||||
{"case":"gen2267","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.005902837378700454,0.000983806229783113,3.131175874138137e-16,2.000983806229783,0.000983806229783113,3.131175874138137e-16]}}
|
||||
{"case":"gen2268","deferred":null,"error":null,"ints":{},"topology":null,"values":{"approx":[-0.0,0.7741788387164605],"approx_plain":[-0.0,0.7741788387164605],"expanded":[-0.0,0.7741788387164605]}}
|
||||
{"case":"gen2269","deferred":null,"error":null,"ints":{"flags":[1,1,1,1]},"topology":null,"values":{"values":[4.700315805143418,302.8236045754655,0.0,1.9895196601282805e-12,302.8236045754655,0.0]}}
|
||||
{"case":"gen2270","deferred":null,"error":null,"ints":{"counts":[],"flag":[0]},"topology":null,"values":{"clamped":[]}}
|
||||
|
||||
+1
-1
@@ -3029,7 +3029,7 @@
|
||||
{"case":"my_test_extent2d_classify_point_tol_v","deferred":null,"error":null,"ints":{"classify":[1,0],"contains":[1,1],"left_eval":[1,1],"on_boundary":[0],"on_boundary_bits":[0,0]},"topology":null,"values":{"binormal":[0.0,0.0],"clamp":[1.1,2.1],"outside_dist":[0.0]}}
|
||||
{"case":"my_test_extent2d_clip_line","deferred":null,"error":null,"ints":{},"topology":null,"values":{"t":[1.0]}}
|
||||
{"case":"my_test_extent2d_contains_periodic","deferred":null,"error":null,"ints":{"contained":[0],"contained_again":[0]},"topology":null,"values":{"periodic_point":[4.9,3.0]}}
|
||||
{"case":"my_test_extent2d_contains_periodic_tol","deferred":null,"error":null,"ints":{"contained":[1],"contained_again":[1]},"topology":null,"values":{"periodic_point":[1.0,3.0]}}
|
||||
{"case":"my_test_extent2d_contains_periodic_tol","deferred":null,"error":null,"ints":{"contained":[1],"contained_again":[1]},"topology":null,"values":{"periodic_point":[0.9000000000000004,3.0]}}
|
||||
{"case":"my_test_extent2d_contains_point","deferred":null,"error":null,"ints":{"classify":[4,1],"contains":[0,0],"left_eval":[1,1],"on_boundary":[0],"on_boundary_bits":[0,0]},"topology":null,"values":{"binormal":[0.0,0.0],"clamp":[1.0,3.0],"outside_dist":[0.09999999999999998]}}
|
||||
{"case":"my_test_extent2d_contains_point_tol","deferred":null,"error":null,"ints":{"classify":[0,1],"contains":[1,1],"left_eval":[1,1],"on_boundary":[1],"on_boundary_bits":[1,1]},"topology":null,"values":{"binormal":[1.0,0.0],"clamp":[1.0,3.0],"outside_dist":[0.0]}}
|
||||
{"case":"my_test_extent2d_e1_from_points","deferred":null,"error":null,"ints":{},"topology":null,"values":{"extent":[1.0,2.0,3.0,4.0]}}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
{
|
||||
"blend.feature_executive": {
|
||||
"add_by_faces_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
|
||||
"attributes_add_by_faces_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
|
||||
"multi_feature_adv_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
|
||||
"multi_feature_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
|
||||
"multi_rebuild_0_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
|
||||
"single_face_fillet1019_6": "tangency-labs/solidcore#3093: success with a body that fails check_brep level 2 (11 lines, the first Bad Repeated CPts)"
|
||||
},
|
||||
"blend.fillet_executive_p3": {
|
||||
|
||||
+1
-1
@@ -350,7 +350,7 @@
|
||||
{"case":"adv_sec_b8_e2_m0_f0.0_c2_t0.0_0.0","deferred":null,"error":"rejected","ints":{},"topology":null,"values":{}}
|
||||
{"case":"adv_sec_b8_e2_m0_f0.0_c2_t0.5_0.0","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
|
||||
{"case":"adv_sec_b8_e2_m0_f0.5_c1_t0.5_0.5","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
|
||||
{"case":"adv_sec_b8_e2_m0_f1e-09_c2_t0.0_0.0","deferred":null,"error":"rejected","ints":{},"topology":null,"values":{}}
|
||||
{"case":"adv_sec_b8_e2_m0_f1e-09_c2_t0.0_0.0","deferred":null,"error":null,"ints":{"sector":[1,1,5]},"topology":null,"values":{}}
|
||||
{"case":"adv_sec_b8_e2_m1_f0.5_c1_t0.0_0.0","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
|
||||
{"case":"adv_sec_b8_e2_m1_f0.5_c1_t1.0_0.0","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
|
||||
{"case":"adv_sec_b8_e2_m2_f0.5_c2_t0.25_0.75","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
|
||||
|
||||
+6
-6
@@ -11,6 +11,7 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::mass::enclosure::VolumeSign;
|
||||
use solidcore::topology::brep::validation;
|
||||
use solidcore::topology::Brep;
|
||||
|
||||
@@ -25,8 +26,8 @@ pub struct Soundness {
|
||||
pub check: Result<Vec<String>, String>,
|
||||
/// Whether the body is a manifold solid.
|
||||
pub manifold: Option<bool>,
|
||||
/// Its volume, when it is a manifold solid.
|
||||
pub volume: Option<f64>,
|
||||
/// Its volume's sign, when it is a manifold solid.
|
||||
pub volume: Option<VolumeSign>,
|
||||
/// Its face, edge and vertex counts.
|
||||
pub counts: [usize; 3],
|
||||
}
|
||||
@@ -35,7 +36,7 @@ impl Soundness {
|
||||
/// Whether the check is clean and a manifold body has a positive volume.
|
||||
pub fn sound(&self) -> bool {
|
||||
self.check.as_ref().is_ok_and(Vec::is_empty)
|
||||
&& (self.manifold != Some(true) || self.volume.is_some_and(|v| v > 0.0))
|
||||
&& (self.manifold != Some(true) || matches!(self.volume, Some(VolumeSign::Positive(_))))
|
||||
}
|
||||
|
||||
/// One line: `sound`, the check's line count, manifold, volume and counts.
|
||||
@@ -164,7 +165,7 @@ pub fn probe(runner: &dyn crate::UnitDiff) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{measure, start, take};
|
||||
use super::{measure, start, take, VolumeSign};
|
||||
use solidcore::kernel::Kernel;
|
||||
|
||||
fn cube() -> solidcore::topology::Brep {
|
||||
@@ -198,8 +199,7 @@ mod tests {
|
||||
assert!(s.sound(), "{}", s.line());
|
||||
assert_eq!(s.manifold, Some(true));
|
||||
assert!(
|
||||
s.volume
|
||||
.is_some_and(|v| (v - 1000.0).abs() <= 1e-6 * 1000.0),
|
||||
matches!(s.volume, Some(VolumeSign::Positive(e)) if e.lo <= 1000.0 && 1000.0 <= e.hi),
|
||||
"{}",
|
||||
s.line()
|
||||
);
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
version.workspace = true
|
||||
publish = false
|
||||
rust-version.workspace = true
|
||||
# solidcore-record needs Rust 1.88 (edition 2024 let chains).
|
||||
rust-version = "1.88"
|
||||
repository = "https://github.com/tangency-labs/solidcore"
|
||||
keywords = ["cad", "brep", "nurbs", "geometry"]
|
||||
categories = ["data-structures", "science"]
|
||||
|
||||
+151
-7
@@ -21,6 +21,8 @@
|
||||
#![allow(clippy::field_reassign_with_default)]
|
||||
#![allow(clippy::neg_cmp_op_on_partial_ord)] // NaN-refusing tests
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solidcore::curve::curve::Curve as KernelCurve;
|
||||
use solidcore::curve::from_parameters::{
|
||||
self as curves, approx_tolerance, zone_tolerance, MAX_TOLERANCE,
|
||||
@@ -93,6 +95,86 @@ pub struct Order {
|
||||
pub vertices: Vec<VertexId>,
|
||||
}
|
||||
|
||||
/// A kernel edge solids touch along, as [`to_record`] writes it: one record edge per pair of
|
||||
/// faces it separates (SolidCraft's `history::Piece`). An edge with uses on three or more faces
|
||||
/// has one [`Piece`] per record edge [`to_record`] copied it into, each with the faces of that
|
||||
/// copy's own uses (two, for the sector pairs the radial order gives); none is merged or split
|
||||
/// here.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Piece {
|
||||
/// The kernel edge.
|
||||
pub kernel: EdgeId,
|
||||
/// One of its record edges.
|
||||
pub edge: u32,
|
||||
/// The record faces that record edge bounds, ascending.
|
||||
pub faces: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Order {
|
||||
/// Each kernel face's record face (the inverse of [`Order::faces`]).
|
||||
#[must_use]
|
||||
pub fn record_faces(&self) -> BTreeMap<FaceId, u32> {
|
||||
inverse(&self.faces)
|
||||
}
|
||||
|
||||
/// Each kernel edge's first record edge (the inverse of [`Order::edges`]; a kernel edge
|
||||
/// solids touch along has more, its [`Order::pieces`]).
|
||||
#[must_use]
|
||||
pub fn record_edges(&self) -> BTreeMap<EdgeId, u32> {
|
||||
inverse(&self.edges)
|
||||
}
|
||||
|
||||
/// Each kernel vertex's first record vertex (the inverse of [`Order::vertices`]).
|
||||
#[must_use]
|
||||
pub fn record_vertices(&self) -> BTreeMap<VertexId, u32> {
|
||||
inverse(&self.vertices)
|
||||
}
|
||||
|
||||
/// Every record edge of each kernel edge that has more than one, in record order, with the
|
||||
/// record faces it bounds (`record` is the record this order came with): the faces of that
|
||||
/// record edge's uses, sorted and without repeats.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Unsupported::Invalid`] for a record whose edges are not this order's.
|
||||
pub fn pieces(&self, record: &r::Brep) -> R<Vec<Piece>> {
|
||||
if record.edges.len() != self.edges.len() {
|
||||
return Err(Unsupported::Invalid("a record not of this order"));
|
||||
}
|
||||
let mut count: BTreeMap<EdgeId, usize> = BTreeMap::new();
|
||||
for &e in &self.edges {
|
||||
*count.entry(e).or_default() += 1;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (i, (&kernel, e)) in self.edges.iter().zip(&record.edges).enumerate() {
|
||||
if count.get(&kernel).copied().unwrap_or(0) < 2 {
|
||||
continue;
|
||||
}
|
||||
let mut faces: Vec<u32> = e.uses.iter().map(|u| u.face).collect();
|
||||
faces.sort_unstable();
|
||||
faces.dedup();
|
||||
out.push(Piece {
|
||||
kernel,
|
||||
edge: u32::try_from(i).map_err(|_| Unsupported::Invalid("index"))?,
|
||||
faces,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Each entity's first index in `list`. An index past `u32` is left out: unreachable, since
|
||||
/// every list is an [`Order`]'s, indexed by a record's `u32` indices.
|
||||
fn inverse<T: Copy + Ord>(list: &[T]) -> BTreeMap<T, u32> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (i, &x) in list.iter().enumerate() {
|
||||
if let Ok(i) = u32::try_from(i) {
|
||||
out.entry(x).or_insert(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn int(n: usize) -> R<u64> {
|
||||
u64::try_from(n).map_err(|_| Unsupported::Invalid("count"))
|
||||
}
|
||||
@@ -231,8 +313,8 @@ fn build(
|
||||
}
|
||||
|
||||
/// The record as the kernel's B-rep data: edgeuses face by face, loop by loop, in loop order;
|
||||
/// edges and vertices in the record's order. Each edge and vertex keeps its own tolerance (at
|
||||
/// least the body's); the body and every face take the body's. The record has no face
|
||||
/// edges and vertices in the record's order. Each edge and vertex keeps its own tolerance; the
|
||||
/// body's and each face's are the least of theirs ([`own_tolerances`]). The record has no face
|
||||
/// tolerance: a face's own crosses as its edges' (`to_record` puts it into them).
|
||||
fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
|
||||
let zone = zone_tolerance(rec.tolerance);
|
||||
@@ -318,7 +400,7 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
|
||||
// Edges first (their kernel intervals are the trims' parameter), in the record's order.
|
||||
let mut intervals = Vec::new();
|
||||
for e in &rec.edges {
|
||||
let tolerance = e.tolerance.max(rec.tolerance);
|
||||
let tolerance = e.tolerance;
|
||||
if !(tolerance <= MAX_TOLERANCE) {
|
||||
return Err(Unsupported::NotYet(
|
||||
"an edge tolerance over the kernel's limit",
|
||||
@@ -475,9 +557,18 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
|
||||
let interval = intervals
|
||||
.get(u.edge as usize)
|
||||
.ok_or(Unsupported::Invalid("edge"))?;
|
||||
let source_interval = rec
|
||||
.edges
|
||||
.get(u.edge as usize)
|
||||
.ok_or(Unsupported::Invalid("edge"))?
|
||||
.interval;
|
||||
ud.uv_curve = int(d.uv_curves.len())?;
|
||||
let trim =
|
||||
curves::uv_trim(&crate::curve2(&u.pcurve), interval, made.scale)?;
|
||||
let trim = curves::uv_trim_on_edge(
|
||||
&crate::curve2(&u.pcurve),
|
||||
source_interval,
|
||||
interval,
|
||||
made.scale,
|
||||
)?;
|
||||
d.uv_curves.push(Keyed {
|
||||
key: key(),
|
||||
object: trim,
|
||||
@@ -500,9 +591,9 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
|
||||
}
|
||||
}
|
||||
|
||||
// Vertices, each with its own tolerance, never below the body's.
|
||||
// Vertices, each with its own tolerance.
|
||||
for v in &rec.vertices {
|
||||
let tolerance = v.tolerance.max(rec.tolerance);
|
||||
let tolerance = v.tolerance;
|
||||
if !(tolerance <= MAX_TOLERANCE) {
|
||||
return Err(Unsupported::NotYet(
|
||||
"a vertex tolerance over the kernel's limit",
|
||||
@@ -514,9 +605,62 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
|
||||
vd.point = Vec3::new(v.point[0], v.point[1], v.point[2]);
|
||||
d.vertices.push(vd);
|
||||
}
|
||||
own_tolerances(&mut d, rec, zone)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// The body's and each face's tolerance from the edges' and vertices' own, as SolidCraft's
|
||||
/// flat import took them (`own_tolerances`, after the capi's): the kernel's build raises every
|
||||
/// entity to the body's, so the body's is the least of the record's and its edges' and
|
||||
/// vertices', and a face's the least of its loops' edges' and vertices', at most the record's.
|
||||
/// Each entity keeps its own across the crossing, none raised to the body's.
|
||||
///
|
||||
/// This keeps invariant 5 of `docs/validity-and-tolerances.md` §10 (every local tolerance at
|
||||
/// least the body's) by the other route from raising each entity to the body's (8252ae3e93,
|
||||
/// "never below the body's"): the body comes down to the least of them instead, so no entity
|
||||
/// is below it and none is changed. A face's is at least the body's (both are minima, the
|
||||
/// body's over more) and at most its loops' edges' and vertices'.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Unsupported::Invalid`] when the least zone is not positive and finite: a tolerance so
|
||||
/// small that its zone (half of it) underflows to zero passes the record's validation.
|
||||
fn own_tolerances(d: &mut BrepData, rec: &r::Brep, zone: f64) -> R<()> {
|
||||
let edge_zone = |e: u32| -> R<f64> { Ok(at(&d.edges, e)?.zone_tol_3d) };
|
||||
let vertex_zone = |v: u32| -> R<f64> { Ok(at(&d.vertices, v)?.zone_tol_3d) };
|
||||
let mut faces = Vec::with_capacity(rec.faces.len());
|
||||
for f in &rec.faces {
|
||||
let mut t = f64::INFINITY;
|
||||
for l in &f.loops {
|
||||
match l {
|
||||
r::Loop::Edges { edges } => {
|
||||
for oe in edges {
|
||||
t = t.min(edge_zone(oe.edge)?);
|
||||
}
|
||||
}
|
||||
r::Loop::Vertex { vertex } => t = t.min(vertex_zone(*vertex)?),
|
||||
}
|
||||
}
|
||||
faces.push(if t.is_finite() { t.min(zone) } else { zone });
|
||||
}
|
||||
let least = d
|
||||
.edges
|
||||
.iter()
|
||||
.map(|e| e.zone_tol_3d)
|
||||
.chain(d.vertices.iter().map(|v| v.zone_tol_3d))
|
||||
.fold(zone, f64::min);
|
||||
if !(least.is_finite() && least > 0.0) {
|
||||
return Err(Unsupported::Invalid(
|
||||
"a tolerance whose zone (half of it) is zero or not finite",
|
||||
));
|
||||
}
|
||||
d.zone_tol_3d = least;
|
||||
for (fd, t) in d.faces.iter_mut().zip(faces) {
|
||||
fd.zone_tol_3d = t;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The curves of a ball patch face's loops, for [`face_surface`] to certify on its sphere:
|
||||
/// each edge's NURBS form, or its curve; empty for any other face, whose label is not
|
||||
/// checked against its loops.
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
//! B-rep data, with no arrays between. What is done here is index mapping and field copies:
|
||||
//!
|
||||
//! - **Geometry:** each edge's curve and each face's surface as the kernel writes its record
|
||||
//! ([`record_of`], [`record_face`]): a line by its two end points, a conic by its own frame and
|
||||
//! ([`record_of`], [`record_face`]): a line as the kernel holds it, a conic by its own frame and
|
||||
//! its NURBS form, proved on each other; an analytic face by its label and its NURBS form,
|
||||
//! proved (`lies_on`); a plane by its placement, its parameters its kernel ones times its
|
||||
//! scales. Nothing is sampled. A curve or surface with no NURBS form (an offset) is refused.
|
||||
|
||||
+76
-4
@@ -226,6 +226,29 @@ fn a_cube_record_builds_the_kernels_cube() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pcurve_support_may_extend_past_its_edge_interval() {
|
||||
let mut record = cube(1.0);
|
||||
let Curve::Line { start, end } = &record.edges[0].curve else {
|
||||
panic!("cube line")
|
||||
};
|
||||
let (start, end) = (*start, *end);
|
||||
record.edges[0].curve = Curve::Spline {
|
||||
knots: vec![0.0, 0.0, 1.0, 1.0],
|
||||
points: vec![start, end],
|
||||
};
|
||||
record.edges[0].interval = [0.0, f64::from_bits(1.0_f64.to_bits() - 2)];
|
||||
record.validate().unwrap();
|
||||
let original = record.faces.clone();
|
||||
let mut k = kernel();
|
||||
let (mut body, _) = to_kernel(&mut k, &record, true).unwrap();
|
||||
let (saved, _) = to_record(&mut k, &mut body).unwrap();
|
||||
assert_eq!(
|
||||
saved.faces.iter().map(|f| &f.loops).collect::<Vec<_>>(),
|
||||
original.iter().map(|f| &f.loops).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_the_conversion_cannot_carry_are_refused() {
|
||||
let mut k = kernel();
|
||||
@@ -556,8 +579,8 @@ fn lumps_and_solids_touching_along_an_edge_go_round() {
|
||||
}
|
||||
|
||||
/// An analytic body's record: its curved faces are labels on their NURBS forms, its circles
|
||||
/// labels on theirs, its lines their two end points over [0, 1]; each pcurve on its edge's
|
||||
/// interval.
|
||||
/// labels on theirs, its lines as the kernel holds them (its point and unit vector, over the
|
||||
/// height); each pcurve on its edge's interval.
|
||||
#[test]
|
||||
fn analytic_records_carry_labels_on_their_forms() {
|
||||
let mut k = kernel();
|
||||
@@ -576,8 +599,9 @@ fn analytic_records_carry_labels_on_their_forms() {
|
||||
for e in &r.edges {
|
||||
match &e.curve {
|
||||
Curve::Line { start, end } => {
|
||||
assert_eq!(e.interval, [0.0, 1.0]);
|
||||
assert_eq!((start[2], end[2]), (0.0, 5.0));
|
||||
// as the kernel holds the seam: its point and unit vector, over its height
|
||||
assert_eq!(e.interval, [0.0, 5.0]);
|
||||
assert_eq!((start[2], end[2]), (0.0, 1.0));
|
||||
assert!(e.nurbs.is_none());
|
||||
}
|
||||
Curve::Circle { radius, .. } => {
|
||||
@@ -1146,3 +1170,51 @@ fn a_ball_patch_far_from_the_origin_reads_as_one() {
|
||||
assert!(read_ball(&mut k, &off, i).is_none(), "face {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A record whose body tolerance is above its edges' and vertices' builds with the body's
|
||||
/// lowered to the least of them (the flat import's `own_tolerances`): valid at Level 2, and
|
||||
/// every edge and vertex at least the body's (invariant 5 of `docs/validity-and-tolerances.md`
|
||||
/// §10), each at its own (the zone, half the record's), none raised.
|
||||
#[test]
|
||||
fn a_body_tolerance_above_its_entities_comes_down_to_theirs() {
|
||||
let mut k = kernel();
|
||||
let mut r = cube(10.0);
|
||||
r.tolerance = 1e-4;
|
||||
r.edges[3].tolerance = 4e-6;
|
||||
r.vertices[5].tolerance = 2e-6;
|
||||
r.validate().unwrap();
|
||||
let (mut b, _) = to_kernel(&mut k, &r, true).unwrap();
|
||||
valid(&mut k, &mut b);
|
||||
// the least is the other edges' 1e-6, as a zone: half of it
|
||||
assert_eq!(b.tolerance(), 5e-7);
|
||||
for &e in b.edge_list() {
|
||||
assert!(b.edge(e).unwrap().tolerance >= b.tolerance(), "edge {e:?}");
|
||||
}
|
||||
for &v in b.vertex_list() {
|
||||
assert!(
|
||||
b.vertex(v).unwrap().tolerance >= b.tolerance(),
|
||||
"vertex {v:?}"
|
||||
);
|
||||
}
|
||||
let mut own: Vec<f64> = b
|
||||
.edge_list()
|
||||
.iter()
|
||||
.map(|&e| b.edge(e).unwrap().tolerance)
|
||||
.collect();
|
||||
own.sort_by(f64::total_cmp);
|
||||
own.dedup();
|
||||
assert_eq!(own, vec![5e-7, 2e-6], "edges keep their own zones");
|
||||
}
|
||||
|
||||
/// A tolerance so small that its zone (half of it) underflows to zero passes the record's
|
||||
/// validation and is refused by the conversion, not dropped to the body's.
|
||||
#[test]
|
||||
fn a_tolerance_whose_zone_underflows_is_refused() {
|
||||
let mut k = kernel();
|
||||
let mut r = cube(1.0);
|
||||
r.vertices[0].tolerance = f64::from_bits(1);
|
||||
assert!(matches!(
|
||||
to_kernel(&mut k, &r, true),
|
||||
Err(Unsupported::Invalid(_))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
//! Parity with SolidCraft's flat-array hop (#3137): every operand record of the rig corpus
|
||||
//! (`crates/solidcore/tests/kernel_cases/`) is what SolidCraft's flat export wrote (record →
|
||||
//! arrays → kernel → arrays → record), or, for a body SolidCraft made or read itself (not
|
||||
//! `trusted`), what it handed the kernel. Each goes to the kernel ([`to_kernel`]) and back
|
||||
//! ([`to_record`]); the record that comes back is compared with the one that went in, field by
|
||||
//! field, bit for bit. A difference is classed by where it is (indices dropped) and, for a
|
||||
//! kind, by what it became; a number within [`ROUNDING`] of its size (relative, no floor) is
|
||||
//! counted as rounding, with its count and the largest.
|
||||
//!
|
||||
//! On the whole corpus (1,333 bodies, at #3199 with kernel case 11991 (#3231) in): 1,259 come
|
||||
//! back bit for bit, 36 within rounding (the largest 4.9e-16 relative), and 38 differ, each in exactly the classes
|
||||
//! [`pinned`] lists for it.
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing,
|
||||
clippy::print_stderr
|
||||
)] // test
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Read as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcore::kernel::{Kernel, KernelOptions, UpstreamFixes};
|
||||
use solidcore_convert::brep::{to_kernel, to_kernel_body, to_record, Unsupported};
|
||||
use solidcore_record::brep::Brep as Record;
|
||||
|
||||
fn corpus() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../solidcore/tests/kernel_cases")
|
||||
}
|
||||
|
||||
fn entries(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.collect();
|
||||
paths.sort();
|
||||
for p in paths {
|
||||
if p.is_dir() {
|
||||
entries(&p, out);
|
||||
} else if p.extension().is_some_and(|x| x == "json")
|
||||
&& !p.to_string_lossy().ends_with(".bodies.json")
|
||||
&& p.parent() != Some(corpus().as_path())
|
||||
{
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Class {
|
||||
bodies: usize,
|
||||
places: usize,
|
||||
examples: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Tally {
|
||||
bodies: usize,
|
||||
identical: usize,
|
||||
rounding_only: usize,
|
||||
rounding_places: usize,
|
||||
rounding_largest: f64,
|
||||
refused_in: Vec<String>,
|
||||
/// Sheets with a face used on its reverse side (every difference follows from that).
|
||||
sheet_side: Vec<String>,
|
||||
refused_out: Vec<String>,
|
||||
classes: BTreeMap<String, Class>,
|
||||
/// Each differing body's classes (sheet-side bodies' too).
|
||||
per_body: BTreeMap<String, BTreeSet<String>>,
|
||||
/// Bodies whose `.tolerance` moved other than down to the least of the record's.
|
||||
tolerance_wrong: Vec<String>,
|
||||
/// Sheet-side bodies whose geometry differs as multisets (not only renumbered).
|
||||
sheet_geometry: Vec<String>,
|
||||
/// (body, face, the record's kind, the kind that came back, trusted)
|
||||
relabelled: Vec<(String, usize, String, String, bool)>,
|
||||
}
|
||||
|
||||
/// The relative size of a difference counted as rounding, not a class: a few ulps.
|
||||
const ROUNDING: f64 = 1e-15;
|
||||
|
||||
fn normal(path: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut in_index = false;
|
||||
for c in path.chars() {
|
||||
match c {
|
||||
'[' => {
|
||||
in_index = true;
|
||||
out.push_str("[*]");
|
||||
}
|
||||
']' => in_index = false,
|
||||
_ if in_index => {}
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every place `a` and `b` differ: (class, place); numbers within [`ROUNDING`] of their size
|
||||
/// (relative, no floor) counted apart, with the largest relative difference.
|
||||
fn walk(
|
||||
at: &str,
|
||||
a: &Value,
|
||||
b: &Value,
|
||||
diffs: &mut Vec<(String, String)>,
|
||||
rounding: &mut (usize, f64),
|
||||
) {
|
||||
if a == b {
|
||||
return;
|
||||
}
|
||||
if let (Some(x), Some(y)) = (a.as_f64(), b.as_f64()) {
|
||||
let rel = (x - y).abs() / x.abs().max(y.abs());
|
||||
if rel <= ROUNDING {
|
||||
rounding.0 += 1;
|
||||
rounding.1 = rounding.1.max(rel);
|
||||
return;
|
||||
}
|
||||
diffs.push((normal(at), format!("{at}: {x} -> {y}")));
|
||||
return;
|
||||
}
|
||||
match (a, b) {
|
||||
(Value::Object(x), Value::Object(y)) => {
|
||||
for (k, v) in x {
|
||||
walk(
|
||||
&format!("{at}.{k}"),
|
||||
v,
|
||||
y.get(k).unwrap_or(&Value::Null),
|
||||
diffs,
|
||||
rounding,
|
||||
);
|
||||
}
|
||||
for (k, w) in y {
|
||||
if !x.contains_key(k) {
|
||||
walk(&format!("{at}.{k}"), &Value::Null, w, diffs, rounding);
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(x), Value::Array(y)) if x.len() == y.len() => {
|
||||
for (i, (v, w)) in x.iter().zip(y).enumerate() {
|
||||
walk(&format!("{at}[{i}]"), v, w, diffs, rounding);
|
||||
}
|
||||
}
|
||||
(Value::Array(x), Value::Array(y)) => diffs.push((
|
||||
format!("{}: length", normal(at)),
|
||||
format!("{at}: {} -> {}", x.len(), y.len()),
|
||||
)),
|
||||
(Value::String(x), Value::String(y)) => {
|
||||
diffs.push((format!("{}: {x} -> {y}", normal(at)), at.to_owned()));
|
||||
}
|
||||
_ => {
|
||||
let short = |v: &Value| {
|
||||
let s = v.to_string();
|
||||
s.chars().take(60).collect::<String>()
|
||||
};
|
||||
diffs.push((normal(at), format!("{at}: {} -> {}", short(a), short(b))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A record's geometry as multisets, its numbering dropped: the vertex points, each edge's
|
||||
/// curve (a line's two ends unordered), each face's surface and NURBS form.
|
||||
fn geometry(r: &Value) -> [Vec<String>; 3] {
|
||||
let list = |key: &str, f: &dyn Fn(&Value) -> String| -> Vec<String> {
|
||||
let mut v: Vec<String> = r[key]
|
||||
.as_array()
|
||||
.map(|a| a.iter().map(f).collect())
|
||||
.unwrap_or_default();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let curve = |e: &Value| {
|
||||
let c = &e["curve"];
|
||||
if c["kind"] == "line" {
|
||||
let mut ends = [c["start"].to_string(), c["end"].to_string()];
|
||||
ends.sort();
|
||||
format!("line {ends:?}")
|
||||
} else {
|
||||
c.to_string()
|
||||
}
|
||||
};
|
||||
[
|
||||
list("vertices", &|v| v["point"].to_string()),
|
||||
list("edges", &curve),
|
||||
list("faces", &|f| format!("{} {}", f["surface"], f["nurbs"])),
|
||||
]
|
||||
}
|
||||
|
||||
fn kind(face: &Value) -> String {
|
||||
face["surface"]["kind"].as_str().unwrap_or("?").to_owned()
|
||||
}
|
||||
|
||||
fn one(name: &str, record: &Record, trusted: bool, fixes: &UpstreamFixes, t: &mut Tally) {
|
||||
t.bodies += 1;
|
||||
// a sheet face used on its reverse side: the kernel keeps both uses of a sheet's face and
|
||||
// the record comes back with the `same` one, as SolidCraft's flat export wrote it
|
||||
// (a sheet's shell is listed by one region; a closed shell by the two it separates)
|
||||
let listed = |i: usize| {
|
||||
record
|
||||
.regions
|
||||
.iter()
|
||||
.filter(|r| r.shells.iter().any(|&s| s as usize == i))
|
||||
.count()
|
||||
};
|
||||
let sheet_side = record.shells.iter().enumerate().any(|(i, s)| match s {
|
||||
solidcore_record::brep::Shell::Faces { faces, .. } => {
|
||||
listed(i) == 1 && faces.iter().any(|f| !f.forward)
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
let mut k = Kernel::new(&KernelOptions::default());
|
||||
k.fixes = *fixes;
|
||||
let built = match to_kernel(&mut k, record, trusted) {
|
||||
Ok((b, _)) => Ok(b),
|
||||
Err(Unsupported::NotYet(w)) if w.contains("healer") => {
|
||||
to_kernel_body(&mut k, record, trusted)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let mut b = match built {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
t.refused_in.push(format!("{name}: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let back = match to_record(&mut k, &mut b) {
|
||||
Ok((r, _)) => r,
|
||||
Err(e) => {
|
||||
t.refused_out.push(format!("{name}: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (a, z) = (
|
||||
serde_json::to_value(record).unwrap(),
|
||||
serde_json::to_value(&back).unwrap(),
|
||||
);
|
||||
// `PARITY_DUMP=<dir>` and `PARITY_ONLY=<part of a name>`: both records written there
|
||||
if let (Ok(dir), Ok(only)) = (std::env::var("PARITY_DUMP"), std::env::var("PARITY_ONLY")) {
|
||||
if name.contains(&only) {
|
||||
let stem = name.replace(['/', ':'], "_");
|
||||
std::fs::write(format!("{dir}/{stem}.in.json"), a.to_string()).unwrap();
|
||||
std::fs::write(format!("{dir}/{stem}.out.json"), z.to_string()).unwrap();
|
||||
}
|
||||
}
|
||||
let mut diffs = Vec::new();
|
||||
let mut rounding = (0, 0.0_f64);
|
||||
walk("", &a, &z, &mut diffs, &mut rounding);
|
||||
t.rounding_places += rounding.0;
|
||||
t.rounding_largest = t.rounding_largest.max(rounding.1);
|
||||
if let (Some(fa), Some(fz)) = (a["faces"].as_array(), z["faces"].as_array()) {
|
||||
for (i, (x, y)) in fa.iter().zip(fz).enumerate() {
|
||||
if kind(x) != kind(y) {
|
||||
t.relabelled
|
||||
.push((name.to_owned(), i, kind(x), kind(y), trusted));
|
||||
}
|
||||
}
|
||||
}
|
||||
if diffs.is_empty() {
|
||||
if rounding.0 == 0 {
|
||||
t.identical += 1;
|
||||
} else {
|
||||
t.rounding_only += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let classes: BTreeSet<String> = diffs.iter().map(|(c, _)| c.clone()).collect();
|
||||
if classes.contains(".tolerance") {
|
||||
// lowered, to the least of the record's own (the flat import's `own_tolerances`)
|
||||
let least = record
|
||||
.edges
|
||||
.iter()
|
||||
.map(|e| e.tolerance)
|
||||
.chain(record.vertices.iter().map(|v| v.tolerance))
|
||||
.fold(record.tolerance, f64::min);
|
||||
if !(back.tolerance < record.tolerance && back.tolerance == least) {
|
||||
t.tolerance_wrong.push(format!(
|
||||
"{name}: {} -> {} (least {least})",
|
||||
record.tolerance, back.tolerance
|
||||
));
|
||||
}
|
||||
}
|
||||
t.per_body.insert(name.to_owned(), classes);
|
||||
if sheet_side && geometry(&a) != geometry(&z) {
|
||||
t.sheet_geometry.push(name.to_owned());
|
||||
}
|
||||
if sheet_side {
|
||||
t.sheet_side.push(name.to_owned());
|
||||
return;
|
||||
}
|
||||
let mut seen = BTreeMap::new();
|
||||
for (class, place) in diffs {
|
||||
seen.entry(class).or_insert((0, place)).0 += 1;
|
||||
}
|
||||
for (class, (n, place)) in seen {
|
||||
let tag = if trusted { "" } else { "(untrusted) " };
|
||||
let c = t.classes.entry(format!("{tag}{class}")).or_default();
|
||||
c.bodies += 1;
|
||||
c.places += n;
|
||||
if c.examples.len() < 3 {
|
||||
c.examples.push(format!("{name} {place}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fixes() -> UpstreamFixes {
|
||||
let fixes_json: Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(corpus().join("fixes.json")).unwrap())
|
||||
.unwrap();
|
||||
let mut fixes = UpstreamFixes::default();
|
||||
for flag in fixes_json.as_array().unwrap() {
|
||||
*fixes.flag_mut(flag.as_str().unwrap()).unwrap() = true;
|
||||
}
|
||||
fixes
|
||||
}
|
||||
|
||||
const LABEL: [&str; 6] = [
|
||||
".faces[*].nurbs",
|
||||
".faces[*].surface.frame",
|
||||
".faces[*].surface.homogeneous",
|
||||
".faces[*].surface.u_knots",
|
||||
".faces[*].surface.v_knots",
|
||||
"",
|
||||
];
|
||||
|
||||
/// Every body that does not come back bit for bit (or within [`ROUNDING`]), with exactly the
|
||||
/// classes it differs in: a body that newly differs, one that no longer does, and one whose
|
||||
/// classes change all fail (#3187 review). Each group says why its difference is intended.
|
||||
fn pinned() -> BTreeMap<String, BTreeSet<String>> {
|
||||
let set = |classes: &[&str]| -> BTreeSet<String> {
|
||||
classes
|
||||
.iter()
|
||||
.filter(|c| !c.is_empty())
|
||||
.map(|c| (*c).to_owned())
|
||||
.collect()
|
||||
};
|
||||
let label = |kind: &str, radii: &[&str]| {
|
||||
let mut c = set(&LABEL);
|
||||
c.insert(format!(".faces[*].surface.kind: {kind}"));
|
||||
c.extend(radii.iter().map(|r| format!(".faces[*].surface.{r}")));
|
||||
c
|
||||
};
|
||||
let groups: Vec<(BTreeSet<String>, &[&str])> = vec![
|
||||
// a record whose body tolerance is above its least entity's: the import lowers it to
|
||||
// that least, as the flat import's `own_tolerances` does (checked: only lowered, and
|
||||
// only to the least of the record's own)
|
||||
(
|
||||
set(&[".tolerance"]),
|
||||
&[
|
||||
"continuity/cov-patch-g1-caps-an-opened-tube-end.json:b3",
|
||||
"continuity/cov-patch-g2-caps-an-opened-tube-end.json:b3",
|
||||
"continuity/g1-patch-g2-at-a-corner-whose-rounds-clash.json:b13",
|
||||
"continuity/g1-patch-g3-at-a-corner-whose-rounds-clash.json:b13",
|
||||
"continuity/g2-patch-g2-on-a-hole-in-one-sheet.json:b11",
|
||||
"continuity/g3-patch-g3-on-a-hole-in-one-sheet.json:b11",
|
||||
"fillets/9-fillet-corner-r5-curvature-default-setback.json:b8",
|
||||
"fillets/cov-fillet-g2-pocket-corner.json:b18",
|
||||
"fillets/every-box-corner-three-edges-r3-curvature-setback-3-5.json:b8",
|
||||
"fillets/report-fillet-curvature-three-edges-at-bar-corner-solver-residues.json:b18",
|
||||
"fillets/report-fillet-curvature-three-edges-at-bar-corner.json:b18",
|
||||
"fillets/report-fillet-g2-corner-of-bar-running-into-cylinder.json:b18",
|
||||
"ladder/07-fillets/015-corner-g2-patch.json:b8",
|
||||
"ladder/07-fillets/019-concave-corner-g2-patch.json:b18",
|
||||
"surfaces/cov-blend-step-between-offset-sheets-g1.json:b1",
|
||||
"surfaces/cov-blend-step-between-offset-sheets-g2.json:b1",
|
||||
"surfaces/cov-blend-step-between-offset-sheets-g3.json:b1",
|
||||
"surfaces/cov-patch-g0-fills-a-240-deg-window-in-a-cylinder.json:b13",
|
||||
"surfaces/cov-patch-g0-fills-a-saddle-window-in-a-torus.json:b13",
|
||||
"surfaces/cov-patch-g1-fills-a-240-deg-window-in-a-cylinder.json:b13",
|
||||
"surfaces/cov-patch-g1-fills-a-saddle-window-in-a-torus.json:b13",
|
||||
"surfaces/cov-patch-open-box-rim-and-stitch-closes-it.json:b3",
|
||||
],
|
||||
),
|
||||
// a plane through three points: the kernel's plane holds no record of which kind it
|
||||
// came from, and the flat export never writes this kind
|
||||
(
|
||||
set(&[
|
||||
".faces[*].surface.kind: plane_through -> plane",
|
||||
".faces[*].surface.u_axis",
|
||||
".faces[*].surface.u_point",
|
||||
".faces[*].surface.v_axis",
|
||||
".faces[*].surface.v_point",
|
||||
]),
|
||||
&[
|
||||
"surfaces/cov-ruled-box-edge-along-the-top-normal.json:b2",
|
||||
"surfaces/cov-ruled-closed-top-chain-tangent-mitred-frame.json:b2",
|
||||
"surfaces/cov-ruled-cylinder-rim-tangent-to-the-top-is-a-flat-ring.json:b2",
|
||||
],
|
||||
),
|
||||
// a label its form is not proved on goes as the form alone (#2540): cylinders whose
|
||||
// frames are off their forms past the rounding `evaluates_as` derives (margins 1.23 to
|
||||
// 2.03, tests/issue_3185_label_margins.rs)
|
||||
(
|
||||
label("cylinder -> rational_spline", &["radius"]),
|
||||
&[
|
||||
"booleans/3456-deep-cut-through-drilled-taper-leaves-base.json:b94",
|
||||
"crashes/2243-step-export-flanged-enclosure.json:b43",
|
||||
"fillets/2903-original-rim-chamfer.json:b0",
|
||||
"fillets/2903-original-rim-round.json:b0",
|
||||
],
|
||||
),
|
||||
// a torus label whose frame is off its form's own first sweep row past the rounding
|
||||
// `evaluates_as` derives (margin 1.69, tests/issue_3185_label_margins.rs)
|
||||
(
|
||||
label(
|
||||
"torus -> rational_spline",
|
||||
&["major_radius", "minor_radius"],
|
||||
),
|
||||
&["sweeps/4280-circular-pattern-of-sweep-cut-groove-on-hub.json:b46"],
|
||||
),
|
||||
// an extruded circle the flat hop left a spline, proved a cylinder
|
||||
(
|
||||
label("rational_spline -> cylinder", &["radius"]),
|
||||
&[
|
||||
"tessellation/1615-crossed-lofted-tubes.json:b3",
|
||||
"tessellation/1615-crossed-lofted-tubes.json:b6",
|
||||
],
|
||||
),
|
||||
// a line end the export wrote as 3.1e-16 (a cos 90 deg residue) where the kernel's
|
||||
// line, point plus its vector, holds 0: an absolute 3e-16 on a coordinate of size 0
|
||||
(
|
||||
set(&[".edges[*].curve.end[*]"]),
|
||||
&["assemblies/cov-contact-check-touching-faces-do-not-overlap.json:b725"],
|
||||
),
|
||||
];
|
||||
let mut out = BTreeMap::new();
|
||||
for (classes, bodies) in groups {
|
||||
for b in bodies {
|
||||
out.insert((*b).to_owned(), classes.clone());
|
||||
}
|
||||
}
|
||||
// a sheet face used on its reverse side: the kernel keeps both uses of a sheet's face and
|
||||
// the record comes back with the `same` one, as the flat export's rule says; every
|
||||
// difference is the renumbering that follows (checked: the same vertex points, edge curves
|
||||
// and face surfaces, as multisets), and two of them also lower `.tolerance`
|
||||
let renumbered = [
|
||||
".edges[*].uses[*].position",
|
||||
".faces[*].loops[*].edges[*].edge",
|
||||
".shells[*].faces[*].forward",
|
||||
".vertices[*].point[*]",
|
||||
];
|
||||
let match_sheet = [
|
||||
".edges[*].curve.knots: length",
|
||||
".edges[*].curve.points: length",
|
||||
".edges[*].uses[*].upper",
|
||||
];
|
||||
let reverse = [
|
||||
".edges[*].curve.end[*]",
|
||||
".edges[*].curve.start[*]",
|
||||
".edges[*].vertices[*]",
|
||||
];
|
||||
let with = |extra: &[&[&str]]| {
|
||||
let mut c = set(&renumbered);
|
||||
for e in extra {
|
||||
c.extend(set(e));
|
||||
}
|
||||
c
|
||||
};
|
||||
for (b, classes) in [
|
||||
(
|
||||
"surfaces/cov-match-coplanar-sheet-onto-the-near-edge.json:b1",
|
||||
with(&[&match_sheet]),
|
||||
),
|
||||
(
|
||||
"surfaces/cov-match-raised-sheet-down-onto-the-edge-g1.json:b1",
|
||||
with(&[&match_sheet, &[".tolerance"]]),
|
||||
),
|
||||
(
|
||||
"surfaces/cov-match-raised-sheet-down-onto-the-edge-g3.json:b1",
|
||||
with(&[&match_sheet, &[".tolerance"]]),
|
||||
),
|
||||
(
|
||||
"surfaces/cov-reverse-normal-l-sheet-swaps-the-thicken-side.json:b2",
|
||||
with(&[&reverse, &[".edges[*].uses: length"]]),
|
||||
),
|
||||
(
|
||||
"surfaces/cov-reverse-normal-then-thicken-goes-down.json:b1",
|
||||
with(&[&reverse]),
|
||||
),
|
||||
] {
|
||||
out.insert(b.to_owned(), classes);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every `stride`th entry; the report is written to `$PARITY_OUT` when set. No body is refused
|
||||
/// either way, and every difference is a named one.
|
||||
fn parity(stride: usize) {
|
||||
let mut files = Vec::new();
|
||||
entries(&corpus(), &mut files);
|
||||
let fixes = fixes();
|
||||
let mut t = Tally::default();
|
||||
let with_bodies = files.iter().filter_map(|f| {
|
||||
let e: Value = serde_json::from_str(&std::fs::read_to_string(f).unwrap()).unwrap();
|
||||
let bodies = e["bodies"].as_object()?.clone();
|
||||
(!bodies.is_empty()).then_some((f.clone(), bodies))
|
||||
});
|
||||
for (f, bodies) in with_bodies.step_by(stride) {
|
||||
let gz = f.with_extension("bodies.json.gz");
|
||||
let mut text = String::new();
|
||||
flate2::read::GzDecoder::new(std::fs::File::open(&gz).unwrap())
|
||||
.read_to_string(&mut text)
|
||||
.unwrap();
|
||||
let records: Value = serde_json::from_str(&text).unwrap();
|
||||
let short = f.strip_prefix(corpus()).unwrap_or(&f).display().to_string();
|
||||
for (id, how) in bodies {
|
||||
let record: Record = serde_json::from_value(records[&id].clone()).unwrap();
|
||||
one(
|
||||
&format!("{short}:{id}"),
|
||||
&record,
|
||||
how["trusted"].as_bool().unwrap_or(true),
|
||||
&fixes,
|
||||
&mut t,
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut report = String::new();
|
||||
let _ = writeln!(
|
||||
report,
|
||||
"bodies {} identical {} rounding-only {} (places {}, largest {:e}) differing {} refused in {} refused out {}",
|
||||
t.bodies,
|
||||
t.identical,
|
||||
t.rounding_only,
|
||||
t.rounding_places,
|
||||
t.rounding_largest,
|
||||
t.bodies - t.identical - t.rounding_only - t.refused_in.len() - t.refused_out.len(),
|
||||
t.refused_in.len(),
|
||||
t.refused_out.len()
|
||||
);
|
||||
for (class, c) in &t.classes {
|
||||
let _ = writeln!(
|
||||
report,
|
||||
"CLASS {class} | bodies {} places {}",
|
||||
c.bodies, c.places
|
||||
);
|
||||
for e in &c.examples {
|
||||
let _ = writeln!(report, " {e}");
|
||||
}
|
||||
}
|
||||
for n in &t.sheet_side {
|
||||
let _ = writeln!(report, "SHEET-SIDE {n}");
|
||||
}
|
||||
for (n, classes) in &t.per_body {
|
||||
let list: Vec<&str> = classes.iter().map(String::as_str).collect();
|
||||
let _ = writeln!(report, "BODY {n} | {}", list.join(" ; "));
|
||||
}
|
||||
for w in &t.tolerance_wrong {
|
||||
let _ = writeln!(report, "TOLERANCE-WRONG {w}");
|
||||
}
|
||||
for (n, f, a, b, trusted) in &t.relabelled {
|
||||
let _ = writeln!(report, "RELABEL {n} face {f}: {a} -> {b} trusted {trusted}");
|
||||
}
|
||||
for r in &t.refused_in {
|
||||
let _ = writeln!(report, "REFUSED-IN {r}");
|
||||
}
|
||||
for r in &t.refused_out {
|
||||
let _ = writeln!(report, "REFUSED-OUT {r}");
|
||||
}
|
||||
if let Ok(p) = std::env::var("PARITY_OUT") {
|
||||
std::fs::write(p, &report).unwrap();
|
||||
}
|
||||
eprintln!("{}", report.lines().next().unwrap_or(""));
|
||||
assert!(t.bodies > 0);
|
||||
assert!(t.refused_in.is_empty(), "{:#?}", t.refused_in);
|
||||
assert!(t.refused_out.is_empty(), "{:#?}", t.refused_out);
|
||||
assert!(t.tolerance_wrong.is_empty(), "{:#?}", t.tolerance_wrong);
|
||||
assert!(t.sheet_geometry.is_empty(), "{:#?}", t.sheet_geometry);
|
||||
let pinned = pinned();
|
||||
let seen: BTreeMap<&String, &BTreeSet<String>> = t.per_body.iter().collect();
|
||||
for (b, classes) in &seen {
|
||||
assert_eq!(pinned.get(*b), Some(*classes), "{b}: classes not as pinned");
|
||||
}
|
||||
if stride == 1 {
|
||||
let missing: Vec<&String> = pinned.keys().filter(|b| !seen.contains_key(b)).collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"pinned bodies that no longer differ: {missing:#?}"
|
||||
);
|
||||
// The corpus grows as cases are added, so the totals aren't pinned: every body that
|
||||
// differs is pinned above, and the rest must be identical or within rounding.
|
||||
assert_eq!(
|
||||
(t.bodies - t.identical - t.rounding_only, t.rounding_only),
|
||||
(pinned.len(), 36),
|
||||
"bodies that differ, bodies within rounding"
|
||||
);
|
||||
// the largest rounding difference, relative (#3137: 4.885e-16)
|
||||
assert!(t.rounding_largest <= 4.9e-16, "{:e}", t.rounding_largest);
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole corpus (`PARITY_STRIDE` takes every nth entry; the run takes seconds in release).
|
||||
#[test]
|
||||
fn every_corpus_body_comes_back_as_the_flat_export_wrote_it() {
|
||||
parity(
|
||||
std::env::var("PARITY_STRIDE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! (#3185) An analytic label is kept on its NURBS form when the kernel's analytic surface,
|
||||
//! carrying the label's numbers, evaluates as the form within the rounding
|
||||
//! `AnalyticSupport::build` derives term by term (`surface::implicit`'s `evaluates_as`):
|
||||
//! [`AnalyticSupport::parametrisation_margin`] is the largest ratio of distance to bound over
|
||||
//! the form's Greville points, at most 1 where the label stands.
|
||||
//!
|
||||
//! - The corpus labels #3137's parity run refused under the old constant `gamma(16 (p + q +
|
||||
//! 2)) size`, each pinned to its margin as measured under the derived bound: the three tori
|
||||
//! of b6 and b85 (0.02 to 0.50) stand; b46's torus (1.69) and the four cylinders (b94 1.23,
|
||||
//! b43 1.40, the 2903 rims 1.52 and 2.03) do not: their frames are off their forms past
|
||||
//! the rounding at their size. Since #3199 the kernel's recognised sweep frame is the form's
|
||||
//! own (its end meridians), so a label is measured against the form, not against a fit
|
||||
//! that shared the label's error.
|
||||
//! - A torus the kernel makes at the groove's sizes stands on its own form, and the same label
|
||||
//! turned about its axis by four times what the bound allows is refused.
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing,
|
||||
clippy::print_stderr,
|
||||
clippy::disallowed_methods
|
||||
)] // test (the turn's cos and sin)
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Read as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcore::blend::fillet_section::station::as_b_spline;
|
||||
use solidcore::geom::axis2_placement::Axis2Placement;
|
||||
use solidcore::geom::extent2d::Extent2d;
|
||||
use solidcore::geom::vector3d::{Point3, Vec3};
|
||||
use solidcore::kernel::{Kernel, KernelOptions, UpstreamFixes};
|
||||
use solidcore::surface::from_parameters::from_parameters;
|
||||
use solidcore::surface::implicit::AnalyticSupport;
|
||||
use solidcore::surface::surface::{Surface, SurfaceKind};
|
||||
use solidcore::surface::torus::{Torus, TorusOptions};
|
||||
use solidcore_record::brep::Brep as Record;
|
||||
|
||||
fn corpus() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../solidcore/tests/kernel_cases")
|
||||
}
|
||||
|
||||
/// The corpus's kernel, its fixes on (as the parity run and SolidCraft run it).
|
||||
fn kernel() -> Kernel {
|
||||
let v: Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(corpus().join("fixes.json")).unwrap())
|
||||
.unwrap();
|
||||
let mut fixes = UpstreamFixes::default();
|
||||
for flag in v.as_array().unwrap() {
|
||||
*fixes.flag_mut(flag.as_str().unwrap()).unwrap() = true;
|
||||
}
|
||||
let mut k = Kernel::new(&KernelOptions::default());
|
||||
k.fixes = fixes;
|
||||
k
|
||||
}
|
||||
|
||||
fn body(case: &str, id: &str) -> Record {
|
||||
let gz = corpus().join(format!("{case}.bodies.json.gz"));
|
||||
let mut text = String::new();
|
||||
flate2::read::GzDecoder::new(std::fs::File::open(&gz).unwrap())
|
||||
.read_to_string(&mut text)
|
||||
.unwrap();
|
||||
let records: Value = serde_json::from_str(&text).unwrap();
|
||||
serde_json::from_value(records[id].clone()).unwrap()
|
||||
}
|
||||
|
||||
fn placement(f: &solidcore::eval::Frame) -> Axis2Placement {
|
||||
let v = |a: [f64; 3]| Vec3::new(a[0], a[1], a[2]);
|
||||
Axis2Placement::from_stored(
|
||||
Point3::new(f.origin[0], f.origin[1], f.origin[2]),
|
||||
v(f.x_axis),
|
||||
v(f.y_axis),
|
||||
)
|
||||
}
|
||||
|
||||
/// A corpus face's label as the kernel's support, and its NURBS form.
|
||||
fn label_and_form(
|
||||
k: &Kernel,
|
||||
case: &str,
|
||||
id: &str,
|
||||
face: usize,
|
||||
) -> (
|
||||
AnalyticSupport,
|
||||
solidcore::nurbs::b_spline_surface::BSplineSurface,
|
||||
) {
|
||||
let r = body(case, id);
|
||||
let f = &r.faces[face];
|
||||
let nurbs = solidcore_convert::surface(f.nurbs.as_ref().unwrap()).unwrap();
|
||||
let (s, _) = from_parameters(k, &nurbs, f.domain).unwrap();
|
||||
let SurfaceKind::BSpline(form) = s.kind() else {
|
||||
panic!("{case} {id} {face}: the form is not a B-spline")
|
||||
};
|
||||
let support = match solidcore_convert::surface(&f.surface).unwrap() {
|
||||
solidcore::eval::Surface::Torus {
|
||||
frame,
|
||||
major_radius,
|
||||
minor_radius,
|
||||
..
|
||||
} => AnalyticSupport::Torus {
|
||||
placement: placement(&frame),
|
||||
major_radius,
|
||||
minor_radius,
|
||||
},
|
||||
solidcore::eval::Surface::Cylinder { frame, radius } => AnalyticSupport::Cylinder {
|
||||
placement: placement(&frame),
|
||||
radius,
|
||||
},
|
||||
other => panic!("{case} {id} {face}: {other:?}"),
|
||||
};
|
||||
(support, form.copy())
|
||||
}
|
||||
|
||||
/// (case, body, face, the margin measured at #3185, whether the label stands)
|
||||
const CASES: &[(&str, &str, usize, f64, bool)] = &[
|
||||
(
|
||||
"sweeps/3897-sweep-ring-along-a-short-arc-joined-to-a-collar",
|
||||
"b6",
|
||||
1,
|
||||
0.0201,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"sweeps/4280-circular-pattern-of-sweep-cut-groove-on-hub",
|
||||
"b46",
|
||||
1,
|
||||
1.6943,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"sweeps/4280-circular-pattern-of-sweep-cut-groove-on-hub",
|
||||
"b85",
|
||||
15,
|
||||
0.4995,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"sweeps/4280-circular-pattern-of-sweep-cut-groove-on-hub",
|
||||
"b85",
|
||||
21,
|
||||
0.4245,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"booleans/3456-deep-cut-through-drilled-taper-leaves-base",
|
||||
"b94",
|
||||
3,
|
||||
1.2339,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"crashes/2243-step-export-flanged-enclosure",
|
||||
"b43",
|
||||
1,
|
||||
1.4008,
|
||||
false,
|
||||
),
|
||||
("fillets/2903-original-rim-chamfer", "b0", 1, 1.5243, false),
|
||||
("fillets/2903-original-rim-chamfer", "b0", 3, 2.0275, false),
|
||||
("fillets/2903-original-rim-round", "b0", 1, 1.5243, false),
|
||||
("fillets/2903-original-rim-round", "b0", 3, 2.0275, false),
|
||||
];
|
||||
|
||||
/// Each corpus label's margin, pinned to 1 % of what #3185 measured, on its side of 1, and
|
||||
/// `build` agreeing with it.
|
||||
#[test]
|
||||
fn the_corpus_labels_stand_or_fall_by_the_derived_bound() {
|
||||
let k = kernel();
|
||||
let mut report = String::new();
|
||||
let mut wrong = Vec::new();
|
||||
for &(case, id, face, pinned, stands) in CASES {
|
||||
let (support, form) = label_and_form(&k, case, id, face);
|
||||
let m = support.parametrisation_margin(&k, &form).unwrap().unwrap();
|
||||
let _ = writeln!(report, "{case} {id} face {face}: margin {m:.4}");
|
||||
let built = support.build(&k, form).is_ok();
|
||||
if (m <= 1.0) != stands || built != stands || (m - pinned).abs() > 0.01 * pinned {
|
||||
wrong.push(format!(
|
||||
"{case} {id} face {face}: margin {m} (pinned {pinned}, stands {stands}), build {built}"
|
||||
));
|
||||
}
|
||||
}
|
||||
eprintln!("{report}");
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// A torus at the groove's sizes (#3185's b46: centre (19.375, -33.558, 37.5), R 33.657, r 2,
|
||||
/// a 43.6 degree sweep of the whole tube), made by the kernel: its own label stands on its
|
||||
/// form; turned about its axis by four times the turn the bound allows (the margin is linear
|
||||
/// in a small turn: a turn of `t` reads `m(t)`, so `4 t / m(t)` reads 4), it is refused.
|
||||
#[test]
|
||||
fn a_torus_label_turned_past_its_bound_is_refused() {
|
||||
let k = kernel();
|
||||
let o = Point3::new(19.374_999_999_999_975, -33.558_484_396_647_01, 37.5);
|
||||
let x = Vec3::new(
|
||||
-0.427_099_277_807_218_7,
|
||||
0.739_757_649_038_078_7,
|
||||
-0.519_946_946_895_745_4,
|
||||
);
|
||||
let y = Vec3::new(
|
||||
0.259_973_473_447_872_36,
|
||||
-0.450_287_264_631_874_2,
|
||||
-0.854_198_555_614_438_5,
|
||||
);
|
||||
let domain = Extent2d::from_bounds(0.0, -180.0, 43.602_818_972_703_645, 180.0);
|
||||
let made = Torus::new(
|
||||
&k,
|
||||
o,
|
||||
x,
|
||||
y,
|
||||
domain,
|
||||
33.657_280_044_590_65,
|
||||
2.0,
|
||||
TorusOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let s = Surface::from(made);
|
||||
let form = as_b_spline(&k, &s).unwrap().unwrap().into_owned();
|
||||
let own = AnalyticSupport::of(&s).unwrap();
|
||||
let m0 = own.parametrisation_margin(&k, &form).unwrap().unwrap();
|
||||
assert!(m0 <= 1.0, "own label: {m0}");
|
||||
assert!(own.build(&k, form.copy()).is_ok());
|
||||
let turned = |t: f64| {
|
||||
let AnalyticSupport::Torus {
|
||||
placement,
|
||||
major_radius,
|
||||
minor_radius,
|
||||
} = own
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
let (c, sn) = (t.cos(), t.sin());
|
||||
let (px, py) = (placement.x_axis(), placement.y_axis());
|
||||
AnalyticSupport::Torus {
|
||||
placement: Axis2Placement::from_stored(
|
||||
placement.origin(),
|
||||
px * c + py * sn,
|
||||
py * c - px * sn,
|
||||
),
|
||||
major_radius,
|
||||
minor_radius,
|
||||
}
|
||||
};
|
||||
let probe = 1e-11;
|
||||
let m1 = turned(probe)
|
||||
.parametrisation_margin(&k, &form)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(m1 > 1.0, "a turn of {probe} rad reads {m1}");
|
||||
let four = 4.0 * probe / m1;
|
||||
let m4 = turned(four)
|
||||
.parametrisation_margin(&k, &form)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
eprintln!("own {m0:.4}; turn {probe:e}: {m1:.3}; turn {four:e}: {m4:.3}");
|
||||
assert!(m4 > 1.0, "a turn of {four:e} rad reads {m4}");
|
||||
assert!(turned(four).build(&k, form).is_err());
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! [`Order`]'s inverse and its pieces (#3137): what SolidCraft's `history::Order` gave, read off
|
||||
//! the conversion's order. A body whose solids touch along an edge comes back with one record
|
||||
//! edge per pair of faces that edge separates: each is a piece of that kernel edge.
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing
|
||||
)] // test
|
||||
|
||||
use solidcore::boolean::api_brep::boolean_union;
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::math::Point3;
|
||||
use solidcore::primitive::api_primitives::create_box;
|
||||
use solidcore_convert::brep::to_record;
|
||||
|
||||
/// Two unit boxes touching along one edge (the line x = y = 1): one body, its shared edge one
|
||||
/// kernel edge with four uses, two record edges.
|
||||
#[test]
|
||||
fn an_edge_solids_touch_along_is_two_pieces() {
|
||||
let mut k = Kernel::default();
|
||||
let a = create_box(&mut k, Point3::ZERO, 1.0, 1.0, 1.0).unwrap();
|
||||
let c = create_box(&mut k, Point3::new(1.0, 1.0, 0.0), 1.0, 1.0, 1.0).unwrap();
|
||||
let mut b = boolean_union(&mut k, a, c).unwrap();
|
||||
let (record, order) = to_record(&mut k, &mut b).unwrap();
|
||||
let pieces = order.pieces(&record).unwrap();
|
||||
assert_eq!(pieces.len(), 2, "{pieces:?}");
|
||||
assert_eq!(pieces[0].kernel, pieces[1].kernel);
|
||||
assert_ne!(pieces[0].edge, pieces[1].edge);
|
||||
for p in &pieces {
|
||||
assert_eq!(p.faces.len(), 2, "{p:?}");
|
||||
assert_eq!(order.edges[p.edge as usize], p.kernel);
|
||||
// the faces are those of that record edge's own uses
|
||||
let mut uses: Vec<u32> = record.edges[p.edge as usize]
|
||||
.uses
|
||||
.iter()
|
||||
.map(|u| u.face)
|
||||
.collect();
|
||||
uses.sort_unstable();
|
||||
uses.dedup();
|
||||
assert_eq!(p.faces, uses, "{p:?}");
|
||||
}
|
||||
// the two pieces bound four different faces, two of each box
|
||||
let mut all: Vec<u32> = pieces.iter().flat_map(|p| p.faces.clone()).collect();
|
||||
all.sort_unstable();
|
||||
all.dedup();
|
||||
assert_eq!(all.len(), 4);
|
||||
// the inverses name each entity's first record index
|
||||
for (i, f) in order.faces.iter().enumerate() {
|
||||
assert_eq!(order.record_faces()[f] as usize, i);
|
||||
}
|
||||
let first = order.record_edges()[&pieces[0].kernel];
|
||||
assert_eq!(first, pieces[0].edge.min(pieces[1].edge));
|
||||
for (i, v) in order.vertices.iter().enumerate() {
|
||||
assert!(order.record_vertices()[v] as usize <= i);
|
||||
}
|
||||
}
|
||||
|
||||
/// A body with no touching edge has no pieces.
|
||||
#[test]
|
||||
fn a_box_has_no_pieces() {
|
||||
let mut k = Kernel::default();
|
||||
let mut b = create_box(&mut k, Point3::ZERO, 1.0, 1.0, 1.0).unwrap();
|
||||
let (record, order) = to_record(&mut k, &mut b).unwrap();
|
||||
assert!(order.pieces(&record).unwrap().is_empty());
|
||||
// one record edge per kernel edge here; in general there are at most as many kernel edges
|
||||
// as record edges (a touching edge is copied)
|
||||
assert!(order.record_edges().len() <= record.edges.len());
|
||||
assert_eq!(order.record_edges().len(), record.edges.len());
|
||||
}
|
||||
+2
-1
@@ -5,7 +5,8 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
version.workspace = true
|
||||
publish = false
|
||||
rust-version.workspace = true
|
||||
# solidcore-record needs Rust 1.88 (edition 2024 let chains).
|
||||
rust-version = "1.88"
|
||||
repository = "https://github.com/tangency-labs/solidcore"
|
||||
keywords = ["cad", "brep", "nurbs", "modelling"]
|
||||
categories = ["science"]
|
||||
|
||||
+5
-7
@@ -824,13 +824,11 @@ fn overflow_first(
|
||||
}
|
||||
// a round mitred to one that goes first goes with it, through every mitre
|
||||
let place = |e: &EdgeId| edges.iter().position(|p| p == e);
|
||||
let pairs: Vec<(usize, usize)> = mitred
|
||||
// two by two (an odd last one is no pair); the chunk size 2 is not 0: cannot panic
|
||||
.chunks_exact(2)
|
||||
.filter_map(|pair| match pair {
|
||||
[a, b] => Some((place(a)?, place(b)?)),
|
||||
_ => None,
|
||||
})
|
||||
// two by two (an odd last one is no pair)
|
||||
let (mitred_pairs, _) = mitred.as_chunks::<2>();
|
||||
let pairs: Vec<(usize, usize)> = mitred_pairs
|
||||
.iter()
|
||||
.filter_map(|[a, b]| Some((place(a)?, place(b)?)))
|
||||
.collect();
|
||||
let mut grew = true;
|
||||
while grew {
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
//! Contact between bodies: whether two bodies meet (share material or touch within a
|
||||
//! tolerance) and the distance between their boundaries, each decided by the kernel's
|
||||
//! certified bracket ([`solidcore::query::contact`]). Ops passes the tolerance on as the
|
||||
//! caller gives it and reads the kernel's verdict; it computes no distance of its own.
|
||||
|
||||
use solidcore::geom::certified::Verdict;
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::query::contact::{self as c, Contact, Prepared};
|
||||
use solidcore::topology::brep::p3::brep_copy;
|
||||
use solidcore::topology::Brep;
|
||||
use solidcore::Point3;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::session::{run_on, Answer};
|
||||
use crate::{Error, Result};
|
||||
|
||||
impl Answer for Contact {}
|
||||
impl Answer for Vec<Contact> {}
|
||||
|
||||
/// The bracket of the distance between two bodies' boundaries, at most the width asked.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Gap {
|
||||
/// No point of either boundary is nearer the other than this.
|
||||
pub lower: f64,
|
||||
/// The distance of the witness points, rounded up.
|
||||
pub upper: f64,
|
||||
/// A point on each body `upper` apart, when one is known (the first on this body).
|
||||
pub witness: Option<[[f64; 3]; 2]>,
|
||||
}
|
||||
|
||||
fn array(p: Point3) -> [f64; 3] {
|
||||
[p.x, p.y, p.z]
|
||||
}
|
||||
|
||||
/// A copy of `body`'s kernel body, the body released at once: the contact test takes its
|
||||
/// operands mutably (for its classifications' caches) and never edits the caller's, and a
|
||||
/// body tested against itself is locked only once at a time.
|
||||
fn copy_of(k: &mut Kernel, body: &Body) -> solidcore::Result<Brep> {
|
||||
let b = body.lock();
|
||||
brep_copy(k, &b)
|
||||
}
|
||||
|
||||
/// The verdict as an answer: `Yes` meets, `No` does not, `Undecided` refused with the
|
||||
/// bracket, which names the tolerance that would decide.
|
||||
fn decided(contact: &Contact, other: usize) -> Result<bool> {
|
||||
match contact.verdict {
|
||||
Verdict::Yes => Ok(true),
|
||||
Verdict::No => Ok(false),
|
||||
Verdict::Undecided => Err(Error::ContactUndecided {
|
||||
other,
|
||||
lower: contact.lower,
|
||||
upper: contact.upper,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn poisoned(bodies: &[&Body]) -> Result<()> {
|
||||
if bodies.iter().any(|b| b.is_poisoned()) {
|
||||
return Err(Error::Internal(
|
||||
"contact: a body was left unfinished by an earlier panic".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Body {
|
||||
/// Whether this body and `other` meet at `tol` (the caller's: ops invents none): share
|
||||
/// material, or have boundaries within `tol` of each other
|
||||
/// ([`solidcore::query::contact::meets`]). Neither body changes; `other` may be this body.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::ContactUndecided`] when the kernel's bracket does not decide it (`other` 0):
|
||||
/// the bracket, never read as either answer; [`Error::Status`] `SM_ERR_INVALID_INPUT` for a
|
||||
/// tolerance not finite and positive or a body without faces; those of the session.
|
||||
pub fn meets(&self, other: &Body, tol: f64) -> Result<bool> {
|
||||
poisoned(&[self, other])?;
|
||||
let contact = run_on("contact::meets", &[("a", self), ("b", other)], |k| {
|
||||
let mut a = copy_of(k, self)?;
|
||||
let mut b = copy_of(k, other)?;
|
||||
c::meets(k, &mut a, &mut b, tol)
|
||||
})?;
|
||||
decided(&contact, 0)
|
||||
}
|
||||
|
||||
/// [`Body::meets`] against each of `others`, each with its own tolerance, this body
|
||||
/// prepared once (its faces' pieces about the centre of its box,
|
||||
/// [`Prepared::about`]).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::ContactUndecided`] for the first of `others` the kernel's bracket does not
|
||||
/// decide, by its index; those of [`Body::meets`].
|
||||
pub fn meets_each(&self, others: &[(&Body, f64)]) -> Result<Vec<bool>> {
|
||||
let mut bodies: Vec<&Body> = vec![self];
|
||||
bodies.extend(others.iter().map(|(b, _)| *b));
|
||||
poisoned(&bodies)?;
|
||||
let inputs: Vec<(&'static str, &Body)> = bodies.iter().map(|b| ("brep", *b)).collect();
|
||||
let contacts = run_on("contact::meets_each", &inputs, |k| {
|
||||
let mut a = copy_of(k, self)?;
|
||||
let prepared = Prepared::about(k, &a)?;
|
||||
let mut contacts = Vec::new();
|
||||
for (other, tol) in others {
|
||||
let mut b = copy_of(k, other)?;
|
||||
contacts.push(c::meets_prepared(k, &mut a, &prepared, &mut b, *tol)?);
|
||||
}
|
||||
Ok(contacts)
|
||||
})?;
|
||||
contacts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, contact)| decided(contact, i))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The distance between this body's boundary and `other`'s, bracketed to at most `width`
|
||||
/// (relative to its upper end, absolute below 1;
|
||||
/// [`solidcore::query::contact::boundary_distance`]).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::ContactUndecided`] (`other` 0) with the narrowest bracket reached, when the
|
||||
/// kernel's work runs out before `width`; [`Error::Status`] `SM_ERR_INVALID_INPUT` for a
|
||||
/// width not finite and positive or a body without faces; those of the session.
|
||||
pub fn boundary_distance(&self, other: &Body, width: f64) -> Result<Gap> {
|
||||
poisoned(&[self, other])?;
|
||||
let contact = run_on(
|
||||
"contact::boundary_distance",
|
||||
&[("a", self), ("b", other)],
|
||||
|k| {
|
||||
let mut a = copy_of(k, self)?;
|
||||
let mut b = copy_of(k, other)?;
|
||||
c::boundary_distance(k, &mut a, &mut b, width)
|
||||
},
|
||||
)?;
|
||||
decided(&contact, 0)?;
|
||||
Ok(Gap {
|
||||
lower: contact.lower,
|
||||
upper: contact.upper,
|
||||
witness: contact.witness.map(|[p, q]| [array(p), array(q)]),
|
||||
})
|
||||
}
|
||||
}
|
||||
+21
@@ -134,6 +134,19 @@ pub enum Error {
|
||||
/// The kernel found its own state inconsistent part way through a call, or was called
|
||||
/// before the preparation it needs.
|
||||
Exception,
|
||||
/// A contact test the kernel's certified bracket of the boundary distance does not decide
|
||||
/// (`solidcore::query::contact`): the tolerance lies inside `[lower, upper]` (or the
|
||||
/// boundaries are apart but one body's enclosure of the other is not decided), or a
|
||||
/// distance's bracket is still wider than asked. Never read as either answer. `other` is
|
||||
/// the index of the body tested among the call's others.
|
||||
ContactUndecided {
|
||||
/// The other body's index among the call's (0 for one).
|
||||
other: usize,
|
||||
/// No point of either boundary is nearer the other than this.
|
||||
lower: f64,
|
||||
/// A pair of points, one on each boundary, is this far apart.
|
||||
upper: f64,
|
||||
},
|
||||
/// The application cancelled the call.
|
||||
Cancelled,
|
||||
/// Ops was called from inside a kernel call or from the failure callback.
|
||||
@@ -325,6 +338,14 @@ impl std::fmt::Display for Error {
|
||||
),
|
||||
Error::TooFar { largest } => write!(f, "too far to extend (largest {largest:?})"),
|
||||
Error::Exception => write!(f, "solidcore raised an internal exception"),
|
||||
Error::ContactUndecided {
|
||||
other,
|
||||
lower,
|
||||
upper,
|
||||
} => write!(
|
||||
f,
|
||||
"contact with body {other} is undecided: the boundary distance is in [{lower}, {upper}]"
|
||||
),
|
||||
Error::Cancelled => write!(f, "cancelled"),
|
||||
Error::Reentered => write!(
|
||||
f,
|
||||
|
||||
+9
-2
@@ -19,8 +19,8 @@
|
||||
//! - [`provenance`]: the hook an application's construction history rides on.
|
||||
//!
|
||||
//! The families: [`measure`] (mass properties, area, box, point class, gap samples, the picks'
|
||||
//! distances and normals), [`boolean`], [`stitch`], [`prism`] (planar region booleans and
|
||||
//! extrusion, prism sections).
|
||||
//! distances and normals), [`contact`] (whether bodies meet, their boundary distance),
|
||||
//! [`boolean`], [`stitch`], [`prism`] (planar region booleans and extrusion, prism sections).
|
||||
//!
|
||||
//! Operations change the body they are given, as the kernel does. An application that keeps
|
||||
//! its input copies it first ([`Body::copy`]).
|
||||
@@ -32,6 +32,7 @@ pub mod blend;
|
||||
pub mod body;
|
||||
pub mod boolean;
|
||||
pub mod check;
|
||||
pub mod contact;
|
||||
pub mod error;
|
||||
pub mod extend;
|
||||
pub mod file;
|
||||
@@ -42,8 +43,10 @@ pub mod prism;
|
||||
pub mod provenance;
|
||||
pub mod session;
|
||||
pub mod split;
|
||||
pub mod step_assembly;
|
||||
pub mod stitch;
|
||||
pub mod sweep;
|
||||
pub mod tangent;
|
||||
pub mod untrim;
|
||||
|
||||
pub use blend::{
|
||||
@@ -54,6 +57,7 @@ pub use blend::{
|
||||
pub use body::{make_box, make_cone, make_cylinder, make_sphere, make_torus, Body};
|
||||
pub use boolean::BooleanOp;
|
||||
pub use check::{CheckLevel, CheckReport};
|
||||
pub use contact::Gap;
|
||||
pub use error::{Error, FitKind, Result, RoomReason, Unblendable};
|
||||
pub use measure::{
|
||||
point_distance, Distance, EdgePoint, FaceCentroid, GapSample, MassPropertiesEstimate, Normal,
|
||||
@@ -61,7 +65,10 @@ pub use measure::{
|
||||
};
|
||||
pub use offset::{draft, offset_faces, offset_sheet, shell, thicken};
|
||||
pub use order::Order;
|
||||
pub use step_assembly::{placed_bodies, PlacedBody};
|
||||
|
||||
#[cfg(test)]
|
||||
mod noisy_round_tests;
|
||||
#[cfg(test)]
|
||||
mod test_corpus;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
//! #3066: a round on supports read from a record with rounding in their frames keeps its exact
|
||||
//! definition. The class is decided within the body's tolerance; the round lies on a torus of
|
||||
//! its own, its net certified there at bound 0, and that torus's distance from the class's
|
||||
//! torus from the supports is held to the body's tolerance, so the round answers
|
||||
//! `procedural_blend()`.
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing,
|
||||
clippy::disallowed_methods
|
||||
)] // test
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use solidcore::surface::surface::SurfaceKind;
|
||||
use solidcore::topology::face::p2 as face;
|
||||
use solidcore_record::brep::Brep as Record;
|
||||
|
||||
use crate::blend::{blend, Blend, Corners};
|
||||
use crate::boolean::boolean;
|
||||
use crate::{make_box, make_cylinder, session, Body, BooleanOp, CheckLevel};
|
||||
|
||||
fn fixes() {
|
||||
let fixes: Vec<String> = serde_json::from_str(include_str!(
|
||||
"../../solidcore/tests/kernel_cases/fixes.json"
|
||||
))
|
||||
.unwrap();
|
||||
session::set_fixes(fixes.iter().map(String::as_str)).unwrap();
|
||||
}
|
||||
|
||||
/// The faces of `b` that are no plane, as (with a definition, without one).
|
||||
fn definitions(b: &Body) -> (usize, usize) {
|
||||
let brep = b.lock();
|
||||
let (mut with, mut without) = (0, 0);
|
||||
for &f in brep.face_list() {
|
||||
let s = face::surface(&brep, f).unwrap().unwrap();
|
||||
if matches!(s.kind(), SurfaceKind::Plane(_)) {
|
||||
continue;
|
||||
}
|
||||
if s.procedural_blend().is_some() {
|
||||
with += 1;
|
||||
} else {
|
||||
without += 1;
|
||||
}
|
||||
}
|
||||
(with, without)
|
||||
}
|
||||
|
||||
/// The edge of `b` through `p`.
|
||||
fn edge_through(b: &Body, p: [f64; 3]) -> usize {
|
||||
let k = solidcore::Kernel::default();
|
||||
let brep = b.lock();
|
||||
let q = solidcore::Vec3::new(p[0], p[1], p[2]);
|
||||
brep.edge_list()
|
||||
.iter()
|
||||
.position(|&e| {
|
||||
solidcore::query::api_queries::edge_closest_point(&k, &brep, e, q)
|
||||
.unwrap()
|
||||
.closest
|
||||
.distance
|
||||
< 1e-6
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A boss R 8 standing 10 out of a 40 x 40 x 10 block, its axis turned 1e-10 off square to the
|
||||
/// block's top, its root rounded 2: a circle-class round on supports only that close to the
|
||||
/// class (the error the 1e-10 turn makes over the part is far inside the body's tolerance, and
|
||||
/// far outside the rounding the net is certified to). The round is concave: the corner between
|
||||
/// the wall and the top, less the quarter disc, turned about the axis. By Pappus, it adds
|
||||
/// 2 pi (R + x) r^2 (1 - pi / 4), x = r (10 - 3 pi) / (12 - 3 pi) the corner's centroid from the
|
||||
/// wall.
|
||||
#[test]
|
||||
fn a_round_on_a_boss_tilted_off_the_class_keeps_its_definition() {
|
||||
fixes();
|
||||
let block = make_box([0.0; 3], [40.0, 40.0, 10.0]).unwrap();
|
||||
// the boss from z = 5 to 20, its axis turned 1e-10 about x off square to the block's top
|
||||
let boss = make_cylinder([0.0; 3], 8.0, 15.0).unwrap();
|
||||
let (c, s) = (1e-10_f64.cos(), 1e-10_f64.sin());
|
||||
boss.transform(&[
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, c, s, 0.0],
|
||||
[0.0, -s, c, 0.0],
|
||||
[20.0, 20.0, 5.0, 1.0],
|
||||
])
|
||||
.unwrap();
|
||||
let b = boolean(block, boss, BooleanOp::Union).unwrap().unwrap();
|
||||
let e = edge_through(&b, [28.0, 20.0, 10.0]);
|
||||
let (r, big_r) = (2.0, 8.0);
|
||||
blend(&b, Blend::Round, &[e], &[r], &[], Corners::Filled).unwrap();
|
||||
let report = b.check(CheckLevel::All).unwrap();
|
||||
assert!(report.is_valid(), "{}", report.text);
|
||||
// the boss's wall and the round: the round keeps its definition
|
||||
assert_eq!(definitions(&b), (1, 1));
|
||||
let x = r * (10.0 - 3.0 * PI) / (12.0 - 3.0 * PI);
|
||||
let want =
|
||||
16000.0 + PI * big_r * big_r * 10.0 + 2.0 * PI * (big_r + x) * r * r * (1.0 - PI / 4.0);
|
||||
let got = b.volume(1e-10).unwrap();
|
||||
assert!((got - want).abs() <= 1e-9 * want, "{got} {want}");
|
||||
}
|
||||
|
||||
/// SolidCraft's raked wedge (`tests/data/raked_wedge_noisy_cylinders.json`): its end outline's
|
||||
/// rounds keep their definitions, the two circle-class rounds on the read-back cylinders among
|
||||
/// them; the operand's own faces, read from the record, have none.
|
||||
#[test]
|
||||
fn the_raked_wedges_rounds_keep_their_definitions() {
|
||||
fixes();
|
||||
let record: Record = serde_json::from_str(include_str!(
|
||||
"../tests/data/raked_wedge_noisy_cylinders.json"
|
||||
))
|
||||
.unwrap();
|
||||
let (b, _) = Body::from_record(&record, true).unwrap();
|
||||
let (_, before) = definitions(&b);
|
||||
let top = 60.0 - 60.0 * 10f64.to_radians().tan();
|
||||
let mut seeds = Vec::new();
|
||||
for x in [-69.0, 69.0] {
|
||||
for p in [
|
||||
[x, -41.0, 22.0],
|
||||
[x, 8.0, 41.0],
|
||||
[x, (38.0 + top) / 2.0, 60.0],
|
||||
] {
|
||||
seeds.push(edge_through(&b, p));
|
||||
}
|
||||
}
|
||||
let chain = crate::blend::tangent_chain(&b, &seeds, 0.5_f64.to_radians()).unwrap();
|
||||
blend(&b, Blend::Round, &chain, &[0.854], &[], Corners::Filled).unwrap();
|
||||
let report = b.check(CheckLevel::All).unwrap();
|
||||
assert!(report.is_valid(), "{}", report.text);
|
||||
let (with, without) = definitions(&b);
|
||||
// as many of the new rounds keep their definitions as on the same wedge with exact
|
||||
// cylinders, and the rest are as bare there
|
||||
let (exact_with, exact_bare) = exact_wedge_rounds();
|
||||
assert_eq!((with, without - before), (exact_with, exact_bare));
|
||||
assert_eq!(with + without - before, chain.len());
|
||||
}
|
||||
|
||||
/// The same wedge built here, its cylinders exact, its end outline rounded as SolidCraft's: of the
|
||||
/// rounds that last call makes, how many keep a definition and how many don't.
|
||||
fn exact_wedge_rounds() -> (usize, usize) {
|
||||
use crate::sweep::{extrude, Curve, DraftCorner};
|
||||
let top = 60.0 - 60.0 * 10f64.to_radians().tan();
|
||||
let profile = [
|
||||
[-60.0, 0.0],
|
||||
[60.0, 0.0],
|
||||
[top, 60.0],
|
||||
[38.0, 60.0],
|
||||
[-22.0, 22.0],
|
||||
[-60.0, 22.0],
|
||||
];
|
||||
let at = |p: [f64; 2]| [-69.0, p[0], p[1]];
|
||||
let curves: Vec<Curve> = (0..6)
|
||||
.map(|i| Curve::Line {
|
||||
start: at(profile[i]),
|
||||
end: at(profile[(i + 1) % 6]),
|
||||
})
|
||||
.collect();
|
||||
let b = extrude(
|
||||
&curves,
|
||||
[1.0, 0.0, 0.0],
|
||||
138.0,
|
||||
0.0,
|
||||
DraftCorner::Curvature,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let round = |pts: &[[f64; 3]], r: f64, chain: bool| {
|
||||
let seeds: Vec<usize> = pts.iter().map(|&p| edge_through(&b, p)).collect();
|
||||
let edges = if chain {
|
||||
crate::blend::tangent_chain(&b, &seeds, 0.5_f64.to_radians()).unwrap()
|
||||
} else {
|
||||
seeds
|
||||
};
|
||||
blend(&b, Blend::Round, &edges, &[r], &[], Corners::Filled).unwrap();
|
||||
};
|
||||
round(&[[0.0, -60.0, 22.0]], 1.2, true);
|
||||
round(&[[0.0, -22.0, 22.0]], 1.5, true);
|
||||
round(&[[0.0, 38.0, 60.0]], 1.5, true);
|
||||
let back = (60.0 + top) / 2.0;
|
||||
let four = [
|
||||
[-69.0, -60.0, 12.0],
|
||||
[69.0, -60.0, 12.0],
|
||||
[-69.0, back, 30.0],
|
||||
[69.0, back, 30.0],
|
||||
];
|
||||
round(&four, 4.0, false);
|
||||
let (with_before, bare_before) = definitions(&b);
|
||||
let mut ends = Vec::new();
|
||||
for x in [-69.0, 69.0] {
|
||||
ends.extend([
|
||||
[x, -41.0, 22.0],
|
||||
[x, 8.0, 41.0],
|
||||
[x, (38.0 + top) / 2.0, 60.0],
|
||||
]);
|
||||
}
|
||||
round(&ends, 0.854, true);
|
||||
let (with, bare) = definitions(&b);
|
||||
(with - with_before, bare - bare_before)
|
||||
}
|
||||
+467
@@ -0,0 +1,467 @@
|
||||
//! Compose a STEP product structure into placed body instances.
|
||||
//!
|
||||
//! The record reader keeps the placements exactly as the file writes them: assembly
|
||||
//! occurrences, `MAPPED_ITEM` targets, representation maps and transformation operators.
|
||||
//! This module turns those raw placements into kernel matrices, walks the product tree and
|
||||
//! returns every body with the transform that places it in the file's world frame.
|
||||
//!
|
||||
//! It computes no geometry of its own: frame and operator matrices, their products and
|
||||
//! inverses come from `solidcore::geom::step_matrix`. Body construction is the caller's:
|
||||
//! each returned [`PlacedBody::entity`] is a STEP entity id and every occurrence of a shared
|
||||
//! body is emitted separately with its own transform.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use solidcore::geom::step_matrix::{
|
||||
concat, frame_matrix, inverse, operator_matrix, Matrix4, IDENTITY,
|
||||
};
|
||||
use solidcore_record::step::geom::Placement;
|
||||
use solidcore_record::step::product::{
|
||||
Frame, ItemKind, Link, Occurrence, ProductStructure, Transformation, MAX_ASSEMBLY_DEPTH,
|
||||
MAX_VISITS,
|
||||
};
|
||||
|
||||
/// One body as it appears in the expanded product structure.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PlacedBody {
|
||||
/// The STEP entity id of the body (`MANIFOLD_SOLID_BREP`, …).
|
||||
pub entity: u64,
|
||||
/// The body's placement in the file's world frame, in millimetres.
|
||||
pub transform: Matrix4,
|
||||
}
|
||||
|
||||
struct Walk<'a> {
|
||||
structure: &'a ProductStructure,
|
||||
/// Representations reachable from each definition's own shapes through plain
|
||||
/// representation relationships.
|
||||
closure: Vec<Vec<usize>>,
|
||||
/// Child occurrences of each definition, in file order.
|
||||
children: Vec<Vec<usize>>,
|
||||
/// The definitions on the current path, to refuse a cycle.
|
||||
path: Vec<usize>,
|
||||
visits: usize,
|
||||
out: Vec<PlacedBody>,
|
||||
}
|
||||
|
||||
/// Expand the product structure and return every body with its world placement.
|
||||
///
|
||||
/// A root definition is placed by the identity. Each occurrence multiplies the child's
|
||||
/// placement by the occurrence transformation, and each mapped item places the mapped
|
||||
/// representation by `target · origin⁻¹`. Refuses cycles and past the record's depth or
|
||||
/// instance bounds.
|
||||
pub fn placed_bodies(structure: &ProductStructure) -> Result<Vec<PlacedBody>, String> {
|
||||
let mut children = vec![Vec::new(); structure.definitions.len()];
|
||||
for (index, occurrence) in structure.occurrences.iter().enumerate() {
|
||||
if let Some(list) = children.get_mut(occurrence.parent) {
|
||||
list.push(index);
|
||||
}
|
||||
}
|
||||
let closure = (0..structure.definitions.len())
|
||||
.map(|definition| representation_closure(structure, definition))
|
||||
.collect();
|
||||
let mut walk = Walk {
|
||||
structure,
|
||||
closure,
|
||||
children,
|
||||
path: Vec::new(),
|
||||
visits: 0,
|
||||
out: Vec::new(),
|
||||
};
|
||||
for &root in &structure.roots {
|
||||
walk.definition(root, IDENTITY, 0)?;
|
||||
}
|
||||
Ok(walk.out)
|
||||
}
|
||||
|
||||
fn representation_closure(structure: &ProductStructure, definition: usize) -> Vec<usize> {
|
||||
let Some(definition) = structure.definitions.get(definition) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
let mut stack = definition.shapes.clone();
|
||||
while let Some(representation) = stack.pop() {
|
||||
if !seen.insert(representation) {
|
||||
continue;
|
||||
}
|
||||
out.push(representation);
|
||||
for link in &structure.links {
|
||||
if link.transformation.is_some() {
|
||||
continue;
|
||||
}
|
||||
if link.rep_1 == representation {
|
||||
stack.push(link.rep_2);
|
||||
} else if link.rep_2 == representation {
|
||||
stack.push(link.rep_1);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Walk<'_> {
|
||||
fn definition(
|
||||
&mut self,
|
||||
definition: usize,
|
||||
world: Matrix4,
|
||||
depth: usize,
|
||||
) -> Result<(), String> {
|
||||
if depth > MAX_ASSEMBLY_DEPTH {
|
||||
return Err("assembly nested too deeply".into());
|
||||
}
|
||||
if self.path.contains(&definition) {
|
||||
return Err("assembly cycle".into());
|
||||
}
|
||||
self.visits += 1;
|
||||
if self.visits > MAX_VISITS {
|
||||
return Err(format!("more than {MAX_VISITS} assembly instances"));
|
||||
}
|
||||
self.path.push(definition);
|
||||
let reps = self.closure.get(definition).cloned().unwrap_or_default();
|
||||
let mut walked = BTreeSet::new();
|
||||
for representation in reps {
|
||||
self.representation(representation, world, &mut walked, depth)?;
|
||||
}
|
||||
let child_definitions: Vec<usize> =
|
||||
self.children.get(definition).cloned().unwrap_or_default();
|
||||
for occurrence_index in child_definitions {
|
||||
let Some(occurrence) = self.structure.occurrences.get(occurrence_index) else {
|
||||
continue;
|
||||
};
|
||||
let child_reps = self
|
||||
.closure
|
||||
.get(occurrence.child)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let local = occurrence_matrix(self.structure, occurrence, &child_reps)?;
|
||||
self.definition(occurrence.child, concat(&world, &local), depth + 1)?;
|
||||
}
|
||||
self.path.pop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn representation(
|
||||
&mut self,
|
||||
representation: usize,
|
||||
world: Matrix4,
|
||||
walked: &mut BTreeSet<usize>,
|
||||
depth: usize,
|
||||
) -> Result<(), String> {
|
||||
if depth > MAX_ASSEMBLY_DEPTH {
|
||||
return Err("mapped items nested too deeply".into());
|
||||
}
|
||||
if !walked.insert(representation) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(rep) = self.structure.representations.get(representation) else {
|
||||
return Err(format!("missing representation #{representation}"));
|
||||
};
|
||||
for item in &rep.items {
|
||||
match &item.kind {
|
||||
ItemKind::Body(_) => self.out.push(PlacedBody {
|
||||
entity: item.entity,
|
||||
transform: world,
|
||||
}),
|
||||
ItemKind::Mapped { map, target } => {
|
||||
let Some(map) = self.structure.maps.get(*map) else {
|
||||
return Err(format!("missing representation map #{}", item.entity));
|
||||
};
|
||||
let target = frame(target)?;
|
||||
let origin = frame(&map.origin)?;
|
||||
let origin_inverse = inverse(&origin)
|
||||
.ok_or_else(|| format!("singular map origin #{}", map.entity))?;
|
||||
let local = concat(&target, &origin_inverse);
|
||||
let placed = concat(&world, &local);
|
||||
let mapped = map.representation;
|
||||
let mut mapped_walked = BTreeSet::new();
|
||||
self.representation(mapped, placed, &mut mapped_walked, depth + 1)?;
|
||||
for link in &self.structure.links {
|
||||
if link.transformation.is_some() {
|
||||
continue;
|
||||
}
|
||||
let other = if link.rep_1 == mapped {
|
||||
Some(link.rep_2)
|
||||
} else if link.rep_2 == mapped {
|
||||
Some(link.rep_1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(other) = other {
|
||||
self.representation(other, placed, &mut mapped_walked, depth + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
ItemKind::Other(_) => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn occurrence_matrix(
|
||||
structure: &ProductStructure,
|
||||
occurrence: &Occurrence,
|
||||
child_reps: &[usize],
|
||||
) -> Result<Matrix4, String> {
|
||||
let Some(&link_index) = occurrence.placements.first() else {
|
||||
return Ok(IDENTITY);
|
||||
};
|
||||
let Some(link) = structure.links.get(link_index) else {
|
||||
return Err(format!("missing placement link #{link_index}"));
|
||||
};
|
||||
link_matrix(link, child_reps)
|
||||
}
|
||||
|
||||
fn link_matrix(link: &Link, child_reps: &[usize]) -> Result<Matrix4, String> {
|
||||
let Some(transformation) = &link.transformation else {
|
||||
return Ok(IDENTITY);
|
||||
};
|
||||
match transformation {
|
||||
Transformation::Frame(frame_value) => frame(frame_value),
|
||||
Transformation::Items { item_1, item_2, .. } => {
|
||||
let first = frame(item_1)?;
|
||||
let second = frame(item_2)?;
|
||||
let child_is_first =
|
||||
child_reps.contains(&link.rep_1) || !child_reps.contains(&link.rep_2);
|
||||
// Row-vector convention: a child in rep_1 is placed by
|
||||
// `item_1⁻¹ · item_2`; a child in rep_2 by `item_2⁻¹ · item_1`.
|
||||
let (from, to) = if child_is_first {
|
||||
(&first, &second)
|
||||
} else {
|
||||
(&second, &first)
|
||||
};
|
||||
let inverse_from = inverse(from)
|
||||
.ok_or_else(|| format!("singular occurrence placement #{}", link.entity))?;
|
||||
Ok(concat(&inverse_from, to))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(value: &Frame) -> Result<Matrix4, String> {
|
||||
match value {
|
||||
Frame::Placement(Placement {
|
||||
origin,
|
||||
axis,
|
||||
ref_direction,
|
||||
}) => frame_matrix(*origin, *axis, *ref_direction)
|
||||
.ok_or_else(|| "degenerate placement".to_string()),
|
||||
Frame::Operator(operator) => operator_matrix(
|
||||
operator.axis1,
|
||||
operator.axis2,
|
||||
operator.axis3,
|
||||
operator.origin,
|
||||
operator.scale,
|
||||
operator.scale2,
|
||||
operator.scale3,
|
||||
)
|
||||
.ok_or_else(|| "degenerate transformation operator".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solidcore_record::step::geom::Placement;
|
||||
use solidcore_record::step::product::{
|
||||
BodyKind, Definition, Item, OccurrenceKind, Representation,
|
||||
};
|
||||
use solidcore_record::step::units::{Context, Units};
|
||||
|
||||
fn translation(x: f64, y: f64, z: f64) -> Frame {
|
||||
Frame::Placement(Placement {
|
||||
origin: [x, y, z],
|
||||
axis: None,
|
||||
ref_direction: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn body(entity: u64) -> Item {
|
||||
Item {
|
||||
entity,
|
||||
name: String::new(),
|
||||
kind: ItemKind::Body(BodyKind::ManifoldSolidBrep),
|
||||
}
|
||||
}
|
||||
|
||||
fn representation(entity: u64, items: Vec<Item>) -> Representation {
|
||||
Representation {
|
||||
entity,
|
||||
kind: "SHAPE_REPRESENTATION".into(),
|
||||
name: String::new(),
|
||||
items,
|
||||
context: None,
|
||||
units: Context {
|
||||
units: Units { len: 1.0, ang: 1.0 },
|
||||
uncertainty: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn definition(entity: u64, shape: usize) -> Definition {
|
||||
Definition {
|
||||
entity,
|
||||
id: format!("def{entity}"),
|
||||
description: String::new(),
|
||||
formation: 0,
|
||||
product: 0,
|
||||
shapes: vec![shape],
|
||||
}
|
||||
}
|
||||
|
||||
fn structure() -> ProductStructure {
|
||||
ProductStructure {
|
||||
products: Vec::new(),
|
||||
definitions: vec![definition(1, 0), definition(2, 1)],
|
||||
representations: vec![
|
||||
representation(10, Vec::new()),
|
||||
representation(20, vec![body(200)]),
|
||||
],
|
||||
maps: Vec::new(),
|
||||
links: Vec::new(),
|
||||
occurrences: Vec::new(),
|
||||
roots: vec![0],
|
||||
styles: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_child_body_is_transformed_by_its_occurrence() {
|
||||
let mut structure = structure();
|
||||
structure.representations[0] = representation(10, Vec::new());
|
||||
structure.occurrences.push(Occurrence {
|
||||
entity: 30,
|
||||
kind: OccurrenceKind::NextAssemblyUsage,
|
||||
id: "occ".into(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
parent: 0,
|
||||
child: 1,
|
||||
reference_designator: None,
|
||||
placements: vec![0],
|
||||
});
|
||||
structure.links.push(Link {
|
||||
entity: 40,
|
||||
rep_1: 1,
|
||||
rep_2: 0,
|
||||
transformation: Some(Transformation::Items {
|
||||
entity: 41,
|
||||
item_1: translation(0.0, 0.0, 0.0),
|
||||
item_2: translation(2.0, 3.0, 0.0),
|
||||
}),
|
||||
});
|
||||
let placed = placed_bodies(&structure).unwrap();
|
||||
assert_eq!(placed.len(), 1);
|
||||
assert_eq!(placed[0].entity, 200);
|
||||
assert_eq!(
|
||||
placed[0].transform,
|
||||
[
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[2.0, 3.0, 0.0, 1.0],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_items_place_the_map_origin_onto_their_target() {
|
||||
let mut structure = structure();
|
||||
structure.representations[0] = representation(10, Vec::new());
|
||||
structure.representations[0].items.push(Item {
|
||||
entity: 100,
|
||||
name: String::new(),
|
||||
kind: ItemKind::Mapped {
|
||||
map: 0,
|
||||
target: translation(5.0, 0.0, 0.0),
|
||||
},
|
||||
});
|
||||
structure
|
||||
.maps
|
||||
.push(solidcore_record::step::product::RepresentationMap {
|
||||
entity: 50,
|
||||
origin: translation(1.0, 0.0, 0.0),
|
||||
representation: 1,
|
||||
});
|
||||
let placed = placed_bodies(&structure).unwrap();
|
||||
assert_eq!(placed.len(), 1);
|
||||
assert_eq!(placed[0].transform[3], [4.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_occurrence_placements_compose() {
|
||||
let mut structure = structure();
|
||||
structure.definitions.push(definition(3, 2));
|
||||
structure
|
||||
.representations
|
||||
.push(representation(30, vec![body(300)]));
|
||||
structure.occurrences.extend([
|
||||
Occurrence {
|
||||
entity: 31,
|
||||
kind: OccurrenceKind::NextAssemblyUsage,
|
||||
id: "a".into(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
parent: 0,
|
||||
child: 1,
|
||||
reference_designator: None,
|
||||
placements: vec![0],
|
||||
},
|
||||
Occurrence {
|
||||
entity: 32,
|
||||
kind: OccurrenceKind::NextAssemblyUsage,
|
||||
id: "b".into(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
parent: 1,
|
||||
child: 2,
|
||||
reference_designator: None,
|
||||
placements: vec![1],
|
||||
},
|
||||
]);
|
||||
structure.links.extend([
|
||||
Link {
|
||||
entity: 40,
|
||||
rep_1: 1,
|
||||
rep_2: 0,
|
||||
transformation: Some(Transformation::Items {
|
||||
entity: 41,
|
||||
item_1: translation(0.0, 0.0, 0.0),
|
||||
item_2: translation(1.0, 0.0, 0.0),
|
||||
}),
|
||||
},
|
||||
Link {
|
||||
entity: 42,
|
||||
rep_1: 2,
|
||||
rep_2: 1,
|
||||
transformation: Some(Transformation::Items {
|
||||
entity: 43,
|
||||
item_1: translation(0.0, 0.0, 0.0),
|
||||
item_2: translation(0.0, 2.0, 0.0),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
let placed = placed_bodies(&structure).unwrap();
|
||||
let grandchild = placed
|
||||
.iter()
|
||||
.find(|body| body.entity == 300)
|
||||
.expect("grandchild body");
|
||||
assert_eq!(grandchild.transform[3], [1.0, 2.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cycle_is_refused() {
|
||||
let mut structure = structure();
|
||||
structure.occurrences.push(Occurrence {
|
||||
entity: 30,
|
||||
kind: OccurrenceKind::NextAssemblyUsage,
|
||||
id: "occ".into(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
parent: 0,
|
||||
child: 0,
|
||||
reference_designator: None,
|
||||
placements: Vec::new(),
|
||||
});
|
||||
assert!(placed_bodies(&structure).is_err());
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
//! Tangent chains on record order: the edges a pick runs on into tangentially, and the faces
|
||||
//! joined to picked faces across smooth edges. The walks are here; every measure they stop
|
||||
//! on is the kernel's: whether an edge's faces meet tangentially all along it
|
||||
//! ([`solidcore::query::tangent_edge`]), how far one edge turns from running straight on into
|
||||
//! another at a vertex and whether an edge is convex there ([`solidcore::query::edge_turn`]).
|
||||
//!
|
||||
//! The caller's `angle` (radians) is the one tolerance: edges meeting at less than it continue
|
||||
//! one another, and faces meeting at less than it all along an edge make it smooth. Nothing
|
||||
//! here picks one (SolidCraft passes its shaded view's half a degree).
|
||||
//!
|
||||
//! - [`tangent_chain_ordered`]: the picked edges, then every edge that continues one of them
|
||||
//! within `angle` at a shared vertex, and so on. An edge does not join when it is not
|
||||
//! between two faces (a one-face edge), when it is smooth (its faces meet tangentially all
|
||||
//! along it, a seam among them), or when it turns over (convex where the edge it continues
|
||||
//! is concave, or the reverse).
|
||||
//! - [`tangent_face_chain_ordered`]: the picked faces, then every face across a smooth edge
|
||||
//! from one of them, and so on.
|
||||
//!
|
||||
//! An edge whose smoothness the kernel leaves undecided (a blend's net within its margin of
|
||||
//! the angle, [`solidcore::query::tangent_edge::Tangency::Undecided`]) joins nothing and is
|
||||
//! named in the answer.
|
||||
|
||||
use solidcore::query::edge_turn::{convex_at, turn_at};
|
||||
use solidcore::query::tangent_edge::{tangent_all_along, Tangency};
|
||||
use solidcore::topology::edge;
|
||||
use solidcore::{Brep, EdgeId, FaceId, Kernel};
|
||||
|
||||
use crate::session::with_body;
|
||||
use crate::{Body, Error, Order, Result};
|
||||
|
||||
/// What [`tangent_chain_ordered`] and [`tangent_face_chain_ordered`] answer, in record
|
||||
/// indices.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Chain {
|
||||
/// The picks (each once, in order), then what joined them, in the order it joined.
|
||||
pub members: Vec<usize>,
|
||||
/// Edges the walk met whose smoothness the kernel left undecided: they joined nothing.
|
||||
pub undecided: Vec<usize>,
|
||||
}
|
||||
|
||||
/// A kernel edge's smoothness, decided once.
|
||||
struct Smooth {
|
||||
known: Vec<(EdgeId, Option<Tangency>)>,
|
||||
}
|
||||
|
||||
impl Smooth {
|
||||
fn of(
|
||||
&mut self,
|
||||
k: &Kernel,
|
||||
b: &mut Brep,
|
||||
e: EdgeId,
|
||||
angle: f64,
|
||||
) -> solidcore::Result<Option<Tangency>> {
|
||||
if let Some((_, t)) = self.known.iter().find(|(f, _)| *f == e) {
|
||||
return Ok(*t);
|
||||
}
|
||||
let t = tangent_all_along(k, b, e, angle)?;
|
||||
self.known.push((e, t));
|
||||
Ok(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// An angle a chain takes: finite, strictly between 0 and a right angle.
|
||||
fn angle_ok(angle: f64) -> bool {
|
||||
angle > 0.0 && angle < std::f64::consts::FRAC_PI_2
|
||||
}
|
||||
|
||||
fn index_of<T: PartialEq + Copy>(list: &[T], x: T) -> Option<usize> {
|
||||
list.iter().position(|&y| y == x)
|
||||
}
|
||||
|
||||
/// The tangent chain of `body`'s edges at record indices `edges` (through `order`, its
|
||||
/// record's [`Order`]), as the module documentation describes, at the caller's `angle`;
|
||||
/// `passed_over` names edges a pick passes over, which never join.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Argument`] for no edges, an angle not finite and strictly between 0 and a right
|
||||
/// angle, or an index past the order or naming an edge the body does not have; the kernel's
|
||||
/// refusals (a face with no NURBS form, an edge use with no pcurve, an edge too costly to
|
||||
/// decide).
|
||||
pub fn tangent_chain_ordered(
|
||||
body: &Body,
|
||||
order: &Order,
|
||||
edges: &[usize],
|
||||
passed_over: &[usize],
|
||||
angle: f64,
|
||||
) -> Result<Chain> {
|
||||
if edges.is_empty() || !angle_ok(angle) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let (copy, mapped) = body.copy_with_order(order)?;
|
||||
with_body("tangent_chain_ordered", ©, |k, b| {
|
||||
let list = mapped.edges.clone();
|
||||
let mut chain: Vec<EdgeId> = Vec::new();
|
||||
for &i in edges {
|
||||
match list.get(i) {
|
||||
Some(&e) if b.edge_list().contains(&e) => {
|
||||
if !chain.contains(&e) {
|
||||
chain.push(e);
|
||||
}
|
||||
}
|
||||
_ => return Ok(Err(Error::Argument)),
|
||||
}
|
||||
}
|
||||
let skip: Vec<EdgeId> = passed_over
|
||||
.iter()
|
||||
.filter_map(|&i| list.get(i).copied())
|
||||
.collect();
|
||||
let mut smooth = Smooth { known: Vec::new() };
|
||||
let mut undecided: Vec<EdgeId> = Vec::new();
|
||||
let mut next = 0;
|
||||
while let Some(&e) = chain.get(next) {
|
||||
next += 1;
|
||||
let ends = [edge::start_vertex(b, e)?, edge::end_vertex(b, e)?];
|
||||
for v in ends.into_iter().flatten() {
|
||||
for &f in &list {
|
||||
if chain.contains(&f) || skip.contains(&f) {
|
||||
continue;
|
||||
}
|
||||
let meets = [edge::start_vertex(b, f)?, edge::end_vertex(b, f)?];
|
||||
if !meets.contains(&Some(v)) {
|
||||
continue;
|
||||
}
|
||||
match smooth.of(k, b, f, angle)? {
|
||||
None | Some(Tangency::Tangent) => continue,
|
||||
Some(Tangency::Undecided { .. }) => {
|
||||
if !undecided.contains(&f) {
|
||||
undecided.push(f);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some(Tangency::NotTangent) => {}
|
||||
}
|
||||
let turns_over = matches!(
|
||||
(convex_at(k, b, e, v)?, convex_at(k, b, f, v)?),
|
||||
(Some(a), Some(c)) if a != c
|
||||
);
|
||||
if turns_over {
|
||||
continue;
|
||||
}
|
||||
if turn_at(k, b, e, f, v)?.is_some_and(|turn| turn < angle) {
|
||||
chain.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let indices = |of: &[EdgeId]| -> Option<Vec<usize>> {
|
||||
of.iter().map(|&e| index_of(&list, e)).collect()
|
||||
};
|
||||
Ok(match (indices(&chain), indices(&undecided)) {
|
||||
(Some(members), Some(undecided)) => Ok(Chain { members, undecided }),
|
||||
_ => Err(Error::Exception),
|
||||
})
|
||||
})?
|
||||
}
|
||||
|
||||
/// The faces of `body` at record indices `faces` (through `order`), then every face across a
|
||||
/// smooth edge from one of them, and so on (module documentation), at the caller's `angle`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Argument`] for no faces, an angle as for [`tangent_chain_ordered`], or an index
|
||||
/// past the order or naming a face the body does not have; the kernel's refusals as for
|
||||
/// [`tangent_chain_ordered`].
|
||||
pub fn tangent_face_chain_ordered(
|
||||
body: &Body,
|
||||
order: &Order,
|
||||
faces: &[usize],
|
||||
angle: f64,
|
||||
) -> Result<Chain> {
|
||||
if faces.is_empty() || !angle_ok(angle) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let (copy, mapped) = body.copy_with_order(order)?;
|
||||
with_body("tangent_face_chain_ordered", ©, |k, b| {
|
||||
let face_list = mapped.faces.clone();
|
||||
let edge_list = mapped.edges.clone();
|
||||
let mut chain: Vec<FaceId> = Vec::new();
|
||||
for &i in faces {
|
||||
match face_list.get(i) {
|
||||
Some(&f) if b.face_list().contains(&f) => {
|
||||
if !chain.contains(&f) {
|
||||
chain.push(f);
|
||||
}
|
||||
}
|
||||
_ => return Ok(Err(Error::Argument)),
|
||||
}
|
||||
}
|
||||
let mut smooth = Smooth { known: Vec::new() };
|
||||
let mut undecided: Vec<EdgeId> = Vec::new();
|
||||
let mut next = 0;
|
||||
while let Some(&f) = chain.get(next) {
|
||||
next += 1;
|
||||
for &e in &edge_list {
|
||||
let across = solidcore::query::edge::edge_faces(b, e)?;
|
||||
if !across.contains(&f) {
|
||||
continue;
|
||||
}
|
||||
match smooth.of(k, b, e, angle)? {
|
||||
Some(Tangency::Tangent) => {}
|
||||
Some(Tangency::Undecided { .. }) => {
|
||||
if !undecided.contains(&e) {
|
||||
undecided.push(e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
None | Some(Tangency::NotTangent) => continue,
|
||||
}
|
||||
for g in across {
|
||||
if !chain.contains(&g) {
|
||||
chain.push(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let members: Option<Vec<usize>> = chain.iter().map(|&f| index_of(&face_list, f)).collect();
|
||||
let undecided: Option<Vec<usize>> =
|
||||
undecided.iter().map(|&e| index_of(&edge_list, e)).collect();
|
||||
Ok(match (members, undecided) {
|
||||
(Some(members), Some(undecided)) => Ok(Chain { members, undecided }),
|
||||
_ => Err(Error::Exception),
|
||||
})
|
||||
})?
|
||||
}
|
||||
+71
-3
@@ -1,8 +1,12 @@
|
||||
//! The boolean family on boxes whose answers are known.
|
||||
#![allow(clippy::unwrap_used)]
|
||||
//! The boolean family on bodies whose answers are known.
|
||||
#![allow(clippy::unwrap_used, clippy::indexing_slicing)] // test
|
||||
|
||||
use std::io::Read as _;
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcore_ops::boolean::{boolean, imprint, union_all};
|
||||
use solidcore_ops::{make_box, session, Body, BooleanOp, Error};
|
||||
use solidcore_ops::{make_box, session, Body, BooleanOp, CheckLevel, Error};
|
||||
use solidcore_record::brep::Brep as Record;
|
||||
|
||||
fn all_fixes() {
|
||||
session::set_thread_fixes(solidcore::kernel::UpstreamFixes::NAMES.iter().copied()).unwrap();
|
||||
@@ -76,3 +80,67 @@ fn an_imprint_cuts_the_faces_the_tool_crosses() {
|
||||
assert_eq!(r.counts().unwrap(), [6, 12, 8]);
|
||||
assert!(near(volume(&r), 1.0), "{}", volume(&r));
|
||||
}
|
||||
|
||||
/// The kernel rig's case `booleans/<case>`: its entry, and its operand bodies by name, read
|
||||
/// under the corpus's fix set.
|
||||
fn corpus_case(case: &str) -> (Value, impl Fn(&str) -> Body) {
|
||||
let corpus = format!(
|
||||
"{}/../solidcore/tests/kernel_cases",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
);
|
||||
let fixes: Vec<String> =
|
||||
serde_json::from_str(&std::fs::read_to_string(format!("{corpus}/fixes.json")).unwrap())
|
||||
.unwrap();
|
||||
session::set_fixes(fixes.iter().map(String::as_str)).unwrap();
|
||||
let entry: Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(format!("{corpus}/booleans/{case}.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let gz = std::fs::File::open(format!("{corpus}/booleans/{case}.bodies.json.gz")).unwrap();
|
||||
let mut text = String::new();
|
||||
flate2::read::GzDecoder::new(gz)
|
||||
.read_to_string(&mut text)
|
||||
.unwrap();
|
||||
let records: Value = serde_json::from_str(&text).unwrap();
|
||||
let trusted = entry["bodies"].clone();
|
||||
let body = move |name: &str| {
|
||||
let record: Record = serde_json::from_value(records[name].clone()).unwrap();
|
||||
Body::from_record(&record, trusted[name]["trusted"].as_bool().unwrap())
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
(entry, body)
|
||||
}
|
||||
|
||||
/// A rod cut by two one-turn screw sweeps of one channel profile, one of each hand, from its
|
||||
/// top face: the profile's radial walls meet the rod's cylinder, so each sweep's rail there is
|
||||
/// a helix on it, and the two rails cross. The crossing is found more than once, a few 1e-12
|
||||
/// apart in the rail's parameter; the new edge along the second rail took the interval its
|
||||
/// UV curve's trim had snapped onto a knot of its own, 2.4e-12 before the 3D curve's end
|
||||
/// (`SmEdge` test 2). Cut in turn and cut by the two sweeps' union, the result is one valid
|
||||
/// solid of the case's closed form (its `expected.method`).
|
||||
#[test]
|
||||
fn a_rod_cut_by_two_counter_wound_channel_sweeps_is_valid() {
|
||||
let (entry, body) = corpus_case("13462-rod-cut-by-two-counter-wound-channel-coils");
|
||||
let want = entry["expected"]["volume_mm3"].as_f64().unwrap();
|
||||
let rel = entry["expected"]["tolerance"]["rel"].as_f64().unwrap();
|
||||
// b6 the rod, b4 and b5 the right- and left-handed sweeps, b14 the rod cut by b4 as the
|
||||
// case's run read it back from its record
|
||||
let in_turn = boolean(body("b14"), body("b5"), BooleanOp::Subtract)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let both = union_all(vec![body("b4"), body("b5")]).unwrap();
|
||||
let at_once = boolean(body("b6"), both, BooleanOp::Subtract)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
for (how, r) in [("in turn", &in_turn), ("by the union", &at_once)] {
|
||||
let report = r.check(CheckLevel::All).unwrap();
|
||||
assert!(report.is_valid(), "cut {how}: {}", report.text);
|
||||
assert_eq!(r.solid_count().unwrap(), 1, "cut {how}");
|
||||
let v = volume(r);
|
||||
assert!(
|
||||
(v - want).abs() <= rel * want,
|
||||
"cut {how}: volume {v}, closed form {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
//! Contact between bodies on closed forms: meets, meets against several, the boundary
|
||||
//! distance, and the refusals.
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use solidcore_ops::session;
|
||||
use solidcore_ops::{make_box, make_sphere, Error};
|
||||
|
||||
fn all_fixes() {
|
||||
session::set_thread_fixes(solidcore::kernel::UpstreamFixes::NAMES.iter().copied()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_sharing_a_face_meet() {
|
||||
all_fixes();
|
||||
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
let b = make_box([1.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
assert!(a.meets(&b, 1e-6).unwrap());
|
||||
assert!(b.meets(&a, 1e-6).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_one_apart_do_not_meet_and_their_distance_is_one() {
|
||||
all_fixes();
|
||||
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
let b = make_box([2.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
assert!(!a.meets(&b, 1e-3).unwrap());
|
||||
let g = a.boundary_distance(&b, 1e-9).unwrap();
|
||||
assert!(g.lower <= 1.0 && 1.0 <= g.upper, "{g:?}");
|
||||
assert!(g.upper - g.lower <= 1e-9 * g.upper.max(1.0), "{g:?}");
|
||||
let [p, q] = g.witness.unwrap();
|
||||
assert!(
|
||||
(p[0] - 1.0).abs() <= 1e-12 && (q[0] - 2.0).abs() <= 1e-12,
|
||||
"{g:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_meets_itself_and_one_it_encloses() {
|
||||
all_fixes();
|
||||
let a = make_box([0.0, 0.0, 0.0], [3.0, 3.0, 3.0]).unwrap();
|
||||
let inner = make_box([1.0, 1.0, 1.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
assert!(a.meets(&a, 1e-6).unwrap());
|
||||
assert!(a.meets(&inner, 1e-6).unwrap());
|
||||
assert!(inner.meets(&a, 1e-6).unwrap());
|
||||
// neither body changes
|
||||
assert_eq!(a.counts().unwrap(), [6, 12, 8]);
|
||||
assert_eq!(inner.counts().unwrap(), [6, 12, 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meets_each_answers_each_with_its_own_tolerance() {
|
||||
all_fixes();
|
||||
let a = make_sphere([0.0, 0.0, 0.0], 1.0).unwrap();
|
||||
let touching = make_box([1.0, -1.0, -1.0], [1.0, 2.0, 2.0]).unwrap();
|
||||
let near = make_sphere([2.5, 0.0, 0.0], 1.0).unwrap(); // 0.5 apart
|
||||
let far = make_box([5.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
let got = a
|
||||
.meets_each(&[(&touching, 1e-6), (&near, 0.1), (&near, 0.6), (&far, 1.0)])
|
||||
.unwrap();
|
||||
assert_eq!(got, vec![true, false, true, false]);
|
||||
}
|
||||
|
||||
/// The refusal is the kernel's own bracket: a tolerance equal to a gap the kernel brackets
|
||||
/// to its rounding is inside the bracket, and neither answer is given.
|
||||
#[test]
|
||||
fn a_tolerance_inside_the_bracket_is_refused_with_it() {
|
||||
all_fixes();
|
||||
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
let b = make_box([2.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
let far = make_box([5.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
match a.meets(&b, 1.0) {
|
||||
Err(Error::ContactUndecided {
|
||||
other: 0,
|
||||
lower,
|
||||
upper,
|
||||
}) => assert!(lower <= 1.0 && 1.0 <= upper, "[{lower}, {upper}]"),
|
||||
r => panic!("{r:?}"),
|
||||
}
|
||||
match a.meets_each(&[(&far, 1e-3), (&b, 1.0)]) {
|
||||
Err(Error::ContactUndecided { other: 1, .. }) => {}
|
||||
r => panic!("{r:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tolerance_or_width_not_finite_and_positive_is_refused() {
|
||||
all_fixes();
|
||||
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
let b = make_box([2.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
let invalid = Error::Status(solidcore::error::SM_ERR_INVALID_INPUT);
|
||||
for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
assert_eq!(a.meets(&b, bad).unwrap_err(), invalid, "{bad}");
|
||||
assert_eq!(a.boundary_distance(&b, bad).unwrap_err(), invalid, "{bad}");
|
||||
}
|
||||
}
|
||||
+90
-23
@@ -32,7 +32,9 @@
|
||||
//! - `KERNEL_CASES_TIMEOUT_S`: a case's time limit, 180 s by default; one past it is a hang, and
|
||||
//! the cases after it are not run (it holds the process kernel);
|
||||
//! - `KERNEL_CASES_REPORT`: a file to write the per-area table and the lists to;
|
||||
//! - `KERNEL_CASES_REPORT_ONLY`: set, the test reports without failing.
|
||||
//! - `KERNEL_CASES_REPORT_ONLY`: set, the test reports without failing;
|
||||
//! - `KERNEL_CASES_SMB`: a directory to write each run case's bodies to as `<case>.smb`, for
|
||||
//! its render (`solidcore-diag`): the final bodies, or the inputs of the refusal that ends it.
|
||||
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
@@ -231,31 +233,38 @@ fn f64s<const N: usize>(v: &Value) -> Option<[f64; N]> {
|
||||
(a.len() == N).then_some(out)
|
||||
}
|
||||
|
||||
/// A pick's record indices, optionally bounded by its record order's length.
|
||||
/// A pick's record indices, optionally bounded by its record order's length. Every pick's shape
|
||||
/// and range is checked first, so bad data fails wherever it sits; only then is a traced miss
|
||||
/// reported as unsupported (#2993).
|
||||
fn picked(op: &str, selection: &Value, length: Option<usize>) -> Result<Vec<usize>, Stop> {
|
||||
let bad = |what: &str| Stop::Fail(format!("{op}: parameter {what}"));
|
||||
selection["picked"]
|
||||
let picks = selection["picked"]
|
||||
.as_array()
|
||||
.ok_or_else(|| bad("picked"))?
|
||||
.iter()
|
||||
.map(|pick| {
|
||||
if let Some(miss) = pick.get("miss") {
|
||||
let why = miss.as_str().ok_or_else(|| bad("miss"))?;
|
||||
if pick.get("index").is_some() {
|
||||
return Err(bad("pick must hit or miss"));
|
||||
}
|
||||
return Err(Stop::Unsupported(format!("{op}: pick missed: {why}")));
|
||||
.ok_or_else(|| bad("picked"))?;
|
||||
let mut indices = Vec::with_capacity(picks.len());
|
||||
let mut missed = None;
|
||||
for pick in picks {
|
||||
if let Some(miss) = pick.get("miss") {
|
||||
let why = miss.as_str().ok_or_else(|| bad("miss"))?;
|
||||
if pick.get("index").is_some() {
|
||||
return Err(bad("pick must hit or miss"));
|
||||
}
|
||||
let index = pick["index"]
|
||||
.as_u64()
|
||||
.and_then(|i| usize::try_from(i).ok())
|
||||
.ok_or_else(|| bad("index"))?;
|
||||
if length.is_some_and(|length| index >= length) {
|
||||
return Err(bad("pick outside record order"));
|
||||
}
|
||||
Ok(index)
|
||||
})
|
||||
.collect()
|
||||
missed.get_or_insert(why);
|
||||
continue;
|
||||
}
|
||||
let index = pick["index"]
|
||||
.as_u64()
|
||||
.and_then(|i| usize::try_from(i).ok())
|
||||
.ok_or_else(|| bad("index"))?;
|
||||
if length.is_some_and(|length| index >= length) {
|
||||
return Err(bad("pick outside record order"));
|
||||
}
|
||||
indices.push(index);
|
||||
}
|
||||
match missed {
|
||||
Some(why) => Err(Stop::Unsupported(format!("{op}: pick missed: {why}"))),
|
||||
None => Ok(indices),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses typed record picks without reordering them: misses are unsupported; bad data fails.
|
||||
@@ -1197,6 +1206,21 @@ fn grade_seams(result: &[Slot], expected: &Value) -> Result<(), Stop> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// With `KERNEL_CASES_SMB` set, writes `bodies` there as `<case>.smb`, `<case>-1.smb`, ...
|
||||
fn write_smb(e: &Value, bodies: &[Slot]) {
|
||||
let Some(dir) = std::env::var_os("KERNEL_CASES_SMB") else {
|
||||
return;
|
||||
};
|
||||
let case = e["case"].as_str().unwrap();
|
||||
for (i, s) in bodies.iter().enumerate() {
|
||||
let name = match i {
|
||||
0 => format!("{case}.smb"),
|
||||
_ => format!("{case}-{i}.smb"),
|
||||
};
|
||||
std::fs::write(Path::new(&dir).join(name), s.body.to_smb(true).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a case's calls and grades the result against `rig` (or, for a hand-written case
|
||||
/// without one, `expected`): `Ok(deviation)` for a match.
|
||||
fn run_case(entry: &Path, e: &Value) -> Result<Option<f64>, Stop> {
|
||||
@@ -1298,7 +1322,10 @@ fn run_case_with_records(
|
||||
// A handled refusal is an attempt, not the case's outcome. Keep checking the
|
||||
// later operations and final geometry; an unmarked refusal still ends the case.
|
||||
(Err(_), Some(_)) if continued => {}
|
||||
(Err(_), Some(_)) => return Ok(None),
|
||||
(Err(_), Some(_)) => {
|
||||
write_smb(e, &ins);
|
||||
return Ok(None);
|
||||
}
|
||||
(Err(x), None) => return Err(Stop::Fail(format!("call {i} ({op}) refused: {x}"))),
|
||||
(Ok(_), Some(want)) => {
|
||||
return Err(Stop::Fail(format!(
|
||||
@@ -1321,6 +1348,7 @@ fn run_case_with_records(
|
||||
.ok_or_else(|| Stop::Fail(format!("no final body {n}")))?,
|
||||
);
|
||||
}
|
||||
write_smb(e, &result);
|
||||
let got = measure(&result).map_err(Stop::Fail)?;
|
||||
if let Some(seams) = e["expected"].get("seams") {
|
||||
grade_seams(&result, seams)?;
|
||||
@@ -1422,7 +1450,16 @@ fn seam_gaps_are_limited_independently_of_stored_tolerances() {
|
||||
|
||||
let body = solidcore_ops::make_box([0.0; 3], [10.0; 3]).unwrap();
|
||||
let (mut record, _) = body.to_record().unwrap();
|
||||
// every stored tolerance inflated, not the body's alone: the import keeps each edge's and
|
||||
// vertex's own and takes the body's as the least of them (#3187, as SolidCraft's flat
|
||||
// import does), so a body tolerance alone no longer reaches the edges the grade reads
|
||||
record.tolerance = 0.01;
|
||||
for edge in &mut record.edges {
|
||||
edge.tolerance = 0.01;
|
||||
}
|
||||
for vertex in &mut record.vertices {
|
||||
vertex.tolerance = 0.01;
|
||||
}
|
||||
for face in &mut record.faces {
|
||||
match &mut face.surface {
|
||||
Surface::Plane { origin, .. } => {
|
||||
@@ -3907,6 +3944,36 @@ fn record_picks_preserve_order_and_duplicate_parameter_alignment() {
|
||||
));
|
||||
}
|
||||
|
||||
/// Bad pick data fails wherever it sits in the list; a traced miss is unsupported only when
|
||||
/// every other pick is well formed (#2993).
|
||||
#[test]
|
||||
fn a_traced_miss_is_unsupported_only_among_well_formed_picks() {
|
||||
let list = |picked: Value| serde_json::json!({"kind": "edge", "picked": picked});
|
||||
for bad in [
|
||||
serde_json::json!([{"miss": "none"}, {"index": 999}]),
|
||||
serde_json::json!([{"index": 999}, {"miss": "none"}]),
|
||||
serde_json::json!([{"miss": "none"}, {}]),
|
||||
serde_json::json!([{"miss": "none"}, {"miss": "none", "index": 0}]),
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
record_picks("blend", &list(bad.clone()), "edge", 2),
|
||||
Err(Stop::Fail(_))
|
||||
),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
record_picks(
|
||||
"blend",
|
||||
&list(serde_json::json!([{"index": 0}, {"miss": "none"}])),
|
||||
"edge",
|
||||
2
|
||||
),
|
||||
Err(Stop::Unsupported(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// Captured split followed by merging its redundant faces, with the original grading.
|
||||
#[test]
|
||||
fn split_body_replays_face_plane() {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! `tangent::tangent_chain_ordered` and `tangent_face_chain_ordered` (#3141), on record order.
|
||||
//!
|
||||
//! A stadium prism: two straight walls and two half-cylinders, 4 high. Its top outline is
|
||||
//! four edges each running on tangentially into the next, so a pick of one top edge chains
|
||||
//! all four and nothing else (the walls meet each other smoothly, so the vertical edges
|
||||
//! between them are smooth, and the bottom outline turns square from the top's). Its walls
|
||||
//! meet across smooth edges, so a pick of one wall chains the four walls and not the caps. A
|
||||
//! box's edges and faces all meet square: a pick chains itself alone.
|
||||
//!
|
||||
//! Ported from SolidCraft (`crates/kernel/tests/chamfer_curved_holder.rs`,
|
||||
//! `one_chamfer_pick_follows_the_tangent_chain_at_any_part_size`, its #2672): a plate of side
|
||||
//! 140, 145 and 250, 10 thick, with a 50 x 10 stadium boss 10 tall on top. One pick on the
|
||||
//! boss's top straight edge chains the boss's whole top rim, its four edges at z = 20, at
|
||||
//! every plate size (SolidCraft's walk once stopped at the straight edge past a 200 mm part).
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing
|
||||
)] // test helpers
|
||||
|
||||
use solidcore_ops::boolean::{boolean, BooleanOp};
|
||||
use solidcore_ops::sweep::{extrude, Curve, DraftCorner};
|
||||
use solidcore_ops::tangent::{tangent_chain_ordered, tangent_face_chain_ordered};
|
||||
use solidcore_ops::{make_box, Body};
|
||||
use solidcore_record::brep::{Brep as Record, Surface};
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
|
||||
const H: f64 = 4.0;
|
||||
/// SolidCraft's smooth-edge angle, the caller's: half a degree.
|
||||
const ANGLE: f64 = 0.5 * PI / 180.0;
|
||||
|
||||
fn stadium() -> Body {
|
||||
let profile = [
|
||||
Curve::Line {
|
||||
start: [0.0, 0.0, 0.0],
|
||||
end: [20.0, 0.0, 0.0],
|
||||
},
|
||||
Curve::Arc {
|
||||
center: [20.0, 5.0, 0.0],
|
||||
axis: [0.0, 0.0, 1.0],
|
||||
reference: [1.0, 0.0, 0.0],
|
||||
radius: 5.0,
|
||||
start: -FRAC_PI_2,
|
||||
end: FRAC_PI_2,
|
||||
},
|
||||
Curve::Line {
|
||||
start: [20.0, 10.0, 0.0],
|
||||
end: [0.0, 10.0, 0.0],
|
||||
},
|
||||
Curve::Arc {
|
||||
center: [0.0, 5.0, 0.0],
|
||||
axis: [0.0, 0.0, 1.0],
|
||||
reference: [1.0, 0.0, 0.0],
|
||||
radius: 5.0,
|
||||
start: FRAC_PI_2,
|
||||
end: 1.5 * PI,
|
||||
},
|
||||
];
|
||||
extrude(
|
||||
&profile,
|
||||
[0.0, 0.0, 1.0],
|
||||
H,
|
||||
0.0,
|
||||
DraftCorner::default(),
|
||||
true,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The record index of the edge between the vertices at `a` and `b`.
|
||||
fn edge(r: &Record, a: [f64; 3], b: [f64; 3]) -> usize {
|
||||
let near = |i: u32, p: [f64; 3]| {
|
||||
let q = r.vertices[i as usize].point;
|
||||
(0..3).all(|j| (q[j] - p[j]).abs() < 1e-9)
|
||||
};
|
||||
r.edges
|
||||
.iter()
|
||||
.position(|e| {
|
||||
let [s, t] = e.vertices;
|
||||
(near(s, a) && near(t, b)) || (near(s, b) && near(t, a))
|
||||
})
|
||||
.expect("an edge between the points")
|
||||
}
|
||||
|
||||
/// Whether record edge `i` lies in the top, both its ends at z = H.
|
||||
fn on_top(r: &Record, i: usize) -> bool {
|
||||
r.edges[i]
|
||||
.vertices
|
||||
.iter()
|
||||
.all(|&v| (r.vertices[v as usize].point[2] - H).abs() < 1e-9)
|
||||
}
|
||||
|
||||
/// The record index of the planar face whose normal is along `n` (up to sign).
|
||||
fn plane_along(r: &Record, n: [f64; 3]) -> Vec<usize> {
|
||||
let cross = |a: [f64; 3], b: [f64; 3]| {
|
||||
[
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
]
|
||||
};
|
||||
let along = |m: [f64; 3]| {
|
||||
let c = cross(m, n);
|
||||
c.iter().all(|x| x.abs() < 1e-9) && m.iter().any(|x| x.abs() > 1e-9)
|
||||
};
|
||||
(0..r.faces.len())
|
||||
.filter(|&i| match r.faces[i].surface {
|
||||
Surface::Plane { u_axis, v_axis, .. } => along(cross(u_axis, v_axis)),
|
||||
_ => false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stadium_s_top_outline_is_one_chain() {
|
||||
let b = stadium();
|
||||
let (r, order) = b.to_record().unwrap();
|
||||
let front = edge(&r, [0.0, 0.0, H], [20.0, 0.0, H]);
|
||||
let chain = tangent_chain_ordered(&b, &order, &[front], &[], ANGLE).unwrap();
|
||||
assert!(chain.undecided.is_empty(), "{chain:?}");
|
||||
assert_eq!(chain.members.len(), 4, "{chain:?}");
|
||||
assert_eq!(chain.members[0], front);
|
||||
assert!(chain.members.iter().all(|&e| on_top(&r, e)), "{chain:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_passed_over_edge_stops_the_chain() {
|
||||
let b = stadium();
|
||||
let (r, order) = b.to_record().unwrap();
|
||||
let front = edge(&r, [0.0, 0.0, H], [20.0, 0.0, H]);
|
||||
let back = edge(&r, [20.0, 10.0, H], [0.0, 10.0, H]);
|
||||
let chain = tangent_chain_ordered(&b, &order, &[front], &[back], ANGLE).unwrap();
|
||||
assert_eq!(chain.members.len(), 3, "{chain:?}");
|
||||
assert!(!chain.members.contains(&back));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_edge_chains_alone() {
|
||||
let b = make_box([0.0; 3], [10.0, 6.0, H]).unwrap();
|
||||
let (r, order) = b.to_record().unwrap();
|
||||
let front = edge(&r, [0.0, 0.0, H], [10.0, 0.0, H]);
|
||||
let chain = tangent_chain_ordered(&b, &order, &[front], &[], ANGLE).unwrap();
|
||||
assert_eq!(chain.members, vec![front]);
|
||||
assert!(chain.undecided.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stadium_s_walls_are_one_face_chain() {
|
||||
let b = stadium();
|
||||
let (r, order) = b.to_record().unwrap();
|
||||
let walls = plane_along(&r, [0.0, 1.0, 0.0]);
|
||||
assert_eq!(walls.len(), 2);
|
||||
let chain = tangent_face_chain_ordered(&b, &order, &walls[..1], ANGLE).unwrap();
|
||||
assert!(chain.undecided.is_empty(), "{chain:?}");
|
||||
assert_eq!(chain.members.len(), 4, "{chain:?}");
|
||||
for &f in &chain.members {
|
||||
let caps = plane_along(&r, [0.0, 0.0, 1.0]);
|
||||
assert!(!caps.contains(&f), "a cap joined: {chain:?}");
|
||||
}
|
||||
assert!(walls.iter().all(|w| chain.members.contains(w)), "{chain:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_face_chains_alone() {
|
||||
let b = make_box([0.0; 3], [10.0, 6.0, H]).unwrap();
|
||||
let (r, order) = b.to_record().unwrap();
|
||||
let top = plane_along(&r, [0.0, 0.0, 1.0]);
|
||||
let chain = tangent_face_chain_ordered(&b, &order, &top[..1], ANGLE).unwrap();
|
||||
assert_eq!(chain.members, vec![top[0]]);
|
||||
}
|
||||
|
||||
/// The plate of side `plate` with the stadium boss on top.
|
||||
fn plate_with_stadium(plate: f64) -> Body {
|
||||
let base = make_box([-plate / 2.0, -plate / 2.0, 0.0], [plate, plate, 10.0]).unwrap();
|
||||
let z = 10.0;
|
||||
let profile = [
|
||||
Curve::Line {
|
||||
start: [-20.0, -5.0, z],
|
||||
end: [20.0, -5.0, z],
|
||||
},
|
||||
Curve::Arc {
|
||||
center: [20.0, 0.0, z],
|
||||
axis: [0.0, 0.0, 1.0],
|
||||
reference: [1.0, 0.0, 0.0],
|
||||
radius: 5.0,
|
||||
start: -FRAC_PI_2,
|
||||
end: FRAC_PI_2,
|
||||
},
|
||||
Curve::Line {
|
||||
start: [20.0, 5.0, z],
|
||||
end: [-20.0, 5.0, z],
|
||||
},
|
||||
Curve::Arc {
|
||||
center: [-20.0, 0.0, z],
|
||||
axis: [0.0, 0.0, 1.0],
|
||||
reference: [1.0, 0.0, 0.0],
|
||||
radius: 5.0,
|
||||
start: FRAC_PI_2,
|
||||
end: 1.5 * PI,
|
||||
},
|
||||
];
|
||||
let boss = extrude(
|
||||
&profile,
|
||||
[0.0, 0.0, 1.0],
|
||||
10.0,
|
||||
0.0,
|
||||
DraftCorner::default(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
boolean(base, boss, BooleanOp::Union).unwrap().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_pick_follows_the_boss_rim_at_any_part_size() {
|
||||
for plate in [140.0, 145.0, 250.0] {
|
||||
let b = plate_with_stadium(plate);
|
||||
let (r, order) = b.to_record().unwrap();
|
||||
let back = edge(&r, [20.0, 5.0, 20.0], [-20.0, 5.0, 20.0]);
|
||||
let chain = tangent_chain_ordered(&b, &order, &[back], &[], ANGLE).unwrap();
|
||||
assert!(chain.undecided.is_empty(), "plate {plate}: {chain:?}");
|
||||
assert_eq!(chain.members.len(), 4, "plate {plate}: {chain:?}");
|
||||
let at_top = |i: usize| {
|
||||
r.edges[i]
|
||||
.vertices
|
||||
.iter()
|
||||
.all(|&v| (r.vertices[v as usize].point[2] - 20.0).abs() < 1e-9)
|
||||
};
|
||||
assert!(
|
||||
chain.members.iter().all(|&e| at_top(e)),
|
||||
"plate {plate}: {chain:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
//! and structural validation ([`brep`]), its history and metadata records, the native body
|
||||
//! file ([`native`]), and the plain data of vectors ([`Vec2`], [`Vec3`]), sketch planes
|
||||
//! ([`Plane`]) and planar profiles ([`Seg2`], [`Loop2`], [`Region2`]), and STEP's Part 21
|
||||
//! exchange structure as parsed from a file ([`step`]).
|
||||
//! exchange structure as parsed from a file with the units of its representation contexts and
|
||||
//! its Part 42 curves and surfaces ([`step`]).
|
||||
//!
|
||||
//! The record computes no geometric quantity: no square roots, trigonometry, dot or cross
|
||||
//! products, normalising or solves (clippy `disallowed-methods`, this crate's `clippy.toml`). It
|
||||
|
||||
+7
-1
@@ -1,3 +1,9 @@
|
||||
//! STEP (ISO 10303) as data: the exchange structure a file is read into ([`p21`]). Building
|
||||
//! STEP (ISO 10303) as data: the exchange structure a file is read into ([`p21`]), the units
|
||||
//! of its representation contexts ([`units`]), its Part 42 curves and surfaces ([`geom`])
|
||||
//! as the entity reader ([`read`]) maps them, and its product structure ([`product`]). Building
|
||||
//! bodies from it is `solidcore-ops`' job (docs/layers.md §2, "Importers are solidcore's").
|
||||
pub mod geom;
|
||||
pub mod p21;
|
||||
pub mod product;
|
||||
pub mod read;
|
||||
pub mod units;
|
||||
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
//! STEP geometry (ISO 10303-42) as plain data: the curves and surfaces a file defines, exactly
|
||||
//! as it defines them, with lengths in millimetres and angles in radians. Nothing is
|
||||
//! approximated, bounded, normalised or turned into another kind on the way in: a circle stays a
|
||||
//! circle with its placement, an unbounded cylinder stays unbounded, a B-spline keeps its own
|
||||
//! knots and form, and a direction keeps the ratios the file writes. Every curve and surface keeps
|
||||
//! STEP's own parameterisation, so a 2D curve in a surface's parameter space means the same thing
|
||||
//! here as in the file.
|
||||
//!
|
||||
//! Entities the file shares (one surface under many edges' pcurves) are shared here too, through
|
||||
//! `Arc`, so reading is linear in the file however often an entity is referenced.
|
||||
//!
|
||||
//! The record computes no geometric quantity (docs/layers.md §1, §4): a direction is not made a
|
||||
//! unit vector, a placement's reference direction is not made orthogonal to its axis (nor chosen
|
||||
//! when the file omits it), its y axis is not formed, a vector's length is not taken, and nothing
|
||||
//! is evaluated. Those are the kernel's, on its own types, when it builds from this data (#2578,
|
||||
//! slice 4). The formulas on the variants below say what the data means, in ISO 10303-42's
|
||||
//! terms; they are not computed here.
|
||||
//!
|
||||
//! Origin: SolidCraft's `crates/step/src/geom.rs` at `a5b71ca2` (MIT OR Apache-2.0).
|
||||
//!
|
||||
//! Deviations from SolidCraft: [`Placement`] keeps `axis` and `ref_direction` as the file gives
|
||||
//! them (each optional, neither normalised; was: both unit and orthogonal, a missing reference
|
||||
//! direction chosen perpendicular to the axis), and [`Axis1::direction`] likewise. A `VECTOR` is
|
||||
//! a [`Vector`] of the written orientation, magnitude and unit factors (was: a unit direction
|
||||
//! and the vector's length), in [`Curve::Line`] and [`Surface::Extrusion`]. Removed, to come back
|
||||
//! as kernel primitives in slice 4: `Placement::y`, `Placement::at` and the vector helpers `add`,
|
||||
//! `sub`, `scale`, `dot`, `cross`, `norm`. Doc comments on every public item and field.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A point or vector: `[x, y, z]` (z = 0 in a 2D parameter space).
|
||||
pub type P3 = [f64; 3];
|
||||
|
||||
/// `AXIS2_PLACEMENT_3D`, or `AXIS2_PLACEMENT_2D` (no `axis`): an origin and the directions of
|
||||
/// its local Z and X axes, as the file writes them.
|
||||
///
|
||||
/// ISO 10303-42 makes the local axes from these: Z is `axis` (+Z when absent), X is
|
||||
/// `ref_direction` made orthogonal to Z (a chosen perpendicular when absent), Y is Z × X. That
|
||||
/// construction, with its checks (a zero or parallel reference direction), is the kernel's.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Placement {
|
||||
/// The location, in millimetres (or the parameter space's units).
|
||||
pub origin: P3,
|
||||
/// Local Z as the file gives it (not normalised); `None` when the file omits it and for an
|
||||
/// `AXIS2_PLACEMENT_2D`, which ISO 10303-42 defines as +Z.
|
||||
pub axis: Option<P3>,
|
||||
/// Local X as the file gives it (not normalised, not made orthogonal to `axis`); `None` when
|
||||
/// the file omits it.
|
||||
pub ref_direction: Option<P3>,
|
||||
}
|
||||
|
||||
/// `AXIS1_PLACEMENT`: an origin and a direction, as the file writes them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Axis1 {
|
||||
/// The location, in millimetres.
|
||||
pub origin: P3,
|
||||
/// The direction as the file gives it (not normalised); `None` when the file omits it, which
|
||||
/// ISO 10303-42 defines as +Z.
|
||||
pub direction: Option<P3>,
|
||||
}
|
||||
|
||||
/// `VECTOR`: an orientation and a magnitude, as the file writes them, and the unit factors of
|
||||
/// the space they are in.
|
||||
///
|
||||
/// In millimetres and radians, component `i` of the vector is
|
||||
/// `scale[i] · magnitude · orientation[i] / |orientation|`. The record does not form it; the
|
||||
/// kernel does (slice 4).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Vector {
|
||||
/// The direction ratios as written (not normalised; never all zero).
|
||||
pub orientation: P3,
|
||||
/// The magnitude as written, in file units (positive).
|
||||
pub magnitude: f64,
|
||||
/// The unit factor of each component: the length unit in model space, the parameter's unit
|
||||
/// (length, angle, or 1) in a surface's parameter space.
|
||||
pub scale: P3,
|
||||
}
|
||||
|
||||
/// A STEP LOGICAL.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Logical {
|
||||
/// `.T.`
|
||||
True,
|
||||
/// `.F.`
|
||||
False,
|
||||
/// `.U.`
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// `b_spline_curve_form`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CurveForm {
|
||||
/// `.POLYLINE_FORM.`
|
||||
PolylineForm,
|
||||
/// `.CIRCULAR_ARC.`
|
||||
CircularArc,
|
||||
/// `.ELLIPTIC_ARC.`
|
||||
EllipticArc,
|
||||
/// `.PARABOLIC_ARC.`
|
||||
ParabolicArc,
|
||||
/// `.HYPERBOLIC_ARC.`
|
||||
HyperbolicArc,
|
||||
/// `.UNSPECIFIED.`, or anything else.
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
/// `b_spline_surface_form`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SurfaceForm {
|
||||
/// `.PLANE_SURF.`
|
||||
PlaneSurf,
|
||||
/// `.CYLINDRICAL_SURF.`
|
||||
CylindricalSurf,
|
||||
/// `.CONICAL_SURF.`
|
||||
ConicalSurf,
|
||||
/// `.SPHERICAL_SURF.`
|
||||
SphericalSurf,
|
||||
/// `.TOROIDAL_SURF.`
|
||||
ToroidalSurf,
|
||||
/// `.SURF_OF_REVOLUTION.`
|
||||
SurfOfRevolution,
|
||||
/// `.RULED_SURF.`
|
||||
RuledSurf,
|
||||
/// `.GENERALISED_CONE.`
|
||||
GeneralisedCone,
|
||||
/// `.QUADRIC_SURF.`
|
||||
QuadricSurf,
|
||||
/// `.SURF_OF_LINEAR_EXTRUSION.`
|
||||
SurfOfLinearExtrusion,
|
||||
/// `.UNSPECIFIED.`, or anything else.
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
/// `knot_type`. A B-spline given without knots (`BEZIER_CURVE`, `UNIFORM_CURVE`,
|
||||
/// `QUASI_UNIFORM_CURVE` and their surfaces) has them laid out on reading, and keeps the
|
||||
/// matching type here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum KnotSpec {
|
||||
/// `.UNIFORM_KNOTS.`
|
||||
UniformKnots,
|
||||
/// `.QUASI_UNIFORM_KNOTS.`
|
||||
QuasiUniformKnots,
|
||||
/// `.PIECEWISE_BEZIER_KNOTS.`
|
||||
PiecewiseBezierKnots,
|
||||
/// `.UNSPECIFIED.`, or anything else.
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
/// A B-spline curve: distinct knots with their multiplicities, as STEP writes them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BSplineCurve {
|
||||
/// The degree (1 to `read::MAX_DEGREE`).
|
||||
pub degree: usize,
|
||||
/// The control points.
|
||||
pub points: Vec<P3>,
|
||||
/// Present for a rational curve: one positive weight per point.
|
||||
pub weights: Option<Vec<f64>>,
|
||||
/// The distinct knots, increasing.
|
||||
pub knots: Vec<f64>,
|
||||
/// Each knot's multiplicity (1 to degree + 1); they add up to points + degree + 1.
|
||||
pub multiplicities: Vec<usize>,
|
||||
/// The curve form.
|
||||
pub form: CurveForm,
|
||||
/// `closed_curve`.
|
||||
pub closed: Logical,
|
||||
/// `self_intersect`.
|
||||
pub self_intersect: Logical,
|
||||
/// The knot type.
|
||||
pub knot_spec: KnotSpec,
|
||||
}
|
||||
|
||||
/// A B-spline surface; `points[i][j]` runs along u with `i`, along v with `j`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BSplineSurface {
|
||||
/// The degree in u.
|
||||
pub u_degree: usize,
|
||||
/// The degree in v.
|
||||
pub v_degree: usize,
|
||||
/// The control net, rows along u, every row the same length.
|
||||
pub points: Vec<Vec<P3>>,
|
||||
/// Present for a rational surface: one positive weight per control point.
|
||||
pub weights: Option<Vec<Vec<f64>>>,
|
||||
/// The distinct u knots, increasing.
|
||||
pub u_knots: Vec<f64>,
|
||||
/// Each u knot's multiplicity.
|
||||
pub u_multiplicities: Vec<usize>,
|
||||
/// The distinct v knots, increasing.
|
||||
pub v_knots: Vec<f64>,
|
||||
/// Each v knot's multiplicity.
|
||||
pub v_multiplicities: Vec<usize>,
|
||||
/// The surface form.
|
||||
pub form: SurfaceForm,
|
||||
/// `u_closed`.
|
||||
pub u_closed: Logical,
|
||||
/// `v_closed`.
|
||||
pub v_closed: Logical,
|
||||
/// `self_intersect`.
|
||||
pub self_intersect: Logical,
|
||||
/// The knot type.
|
||||
pub knot_spec: KnotSpec,
|
||||
}
|
||||
|
||||
/// One end of a `TRIMMED_CURVE`: a point on the basis curve or its parameter (a trim lists one
|
||||
/// or both).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Trim {
|
||||
/// A `CARTESIAN_POINT`.
|
||||
Point(P3),
|
||||
/// A `PARAMETER_VALUE` (radians on a circle or an ellipse).
|
||||
Parameter(f64),
|
||||
}
|
||||
|
||||
/// `trimming_preference`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TrimPreference {
|
||||
/// `.CARTESIAN.`
|
||||
Cartesian,
|
||||
/// `.PARAMETER.`
|
||||
Parameter,
|
||||
/// `.UNSPECIFIED.`, or anything else.
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
/// `preferred_surface_curve_representation`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PreferredRep {
|
||||
/// `.CURVE_3D.`, or anything else.
|
||||
Curve3d,
|
||||
/// `.PCURVE_S1.`
|
||||
PcurveS1,
|
||||
/// `.PCURVE_S2.`
|
||||
PcurveS2,
|
||||
}
|
||||
|
||||
/// Which `SURFACE_CURVE` subtype.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SurfaceCurveKind {
|
||||
/// `SURFACE_CURVE`.
|
||||
Surface,
|
||||
/// `SEAM_CURVE`.
|
||||
Seam,
|
||||
/// `INTERSECTION_CURVE`.
|
||||
Intersection,
|
||||
/// `BOUNDED_SURFACE_CURVE`.
|
||||
BoundedSurface,
|
||||
}
|
||||
|
||||
/// `PCURVE`: a 2D curve in the parameter space of `surface`. Its coordinates are the surface's
|
||||
/// own parameters (lengths in mm, angles in radians).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Pcurve {
|
||||
/// The surface.
|
||||
pub surface: Arc<Surface>,
|
||||
/// A 2D curve: every point and placement has z = 0 and no placement has an axis.
|
||||
pub curve: Arc<Curve>,
|
||||
}
|
||||
|
||||
/// An item of a `SURFACE_CURVE`'s `associated_geometry`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Associated {
|
||||
/// A `PCURVE`.
|
||||
Pcurve(Pcurve),
|
||||
/// A surface.
|
||||
Surface(Arc<Surface>),
|
||||
}
|
||||
|
||||
/// A STEP curve.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Curve {
|
||||
/// `LINE`: `origin + t·v`, with `v` the [`Vector`].
|
||||
Line {
|
||||
/// `pnt`.
|
||||
origin: P3,
|
||||
/// `dir`.
|
||||
vector: Vector,
|
||||
},
|
||||
/// `CIRCLE`. Parameter: the angle from the placement's X axis.
|
||||
Circle {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// The radius (positive).
|
||||
radius: f64,
|
||||
},
|
||||
/// `ELLIPSE`: `origin + a·cos t·X + b·sin t·Y`.
|
||||
Ellipse {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `a`, along X (positive).
|
||||
semi_axis_1: f64,
|
||||
/// `b`, along Y (positive).
|
||||
semi_axis_2: f64,
|
||||
},
|
||||
/// `HYPERBOLA`: `origin + a·cosh t·X + b·sinh t·Y`.
|
||||
Hyperbola {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `a` (positive).
|
||||
semi_axis: f64,
|
||||
/// `b` (positive).
|
||||
semi_imag_axis: f64,
|
||||
},
|
||||
/// `PARABOLA`: `origin + f·t²·X + 2f·t·Y`.
|
||||
Parabola {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `f` (not zero).
|
||||
focal_dist: f64,
|
||||
},
|
||||
/// `B_SPLINE_CURVE` and its subtypes.
|
||||
BSpline(Box<BSplineCurve>),
|
||||
/// `POLYLINE`: segment `i` (from 0) is the parameters `[i, i + 1]`.
|
||||
Polyline(Vec<P3>),
|
||||
/// `TRIMMED_CURVE`.
|
||||
Trimmed {
|
||||
/// `basis_curve`.
|
||||
basis: Arc<Curve>,
|
||||
/// `trim_1` (one or two items).
|
||||
trim_1: Vec<Trim>,
|
||||
/// `trim_2` (one or two items).
|
||||
trim_2: Vec<Trim>,
|
||||
/// `sense_agreement`.
|
||||
sense: bool,
|
||||
/// `master_representation`.
|
||||
preference: TrimPreference,
|
||||
},
|
||||
/// `SURFACE_CURVE`, `SEAM_CURVE`, `INTERSECTION_CURVE`, `BOUNDED_SURFACE_CURVE`.
|
||||
SurfaceCurve {
|
||||
/// Which subtype.
|
||||
kind: SurfaceCurveKind,
|
||||
/// `curve_3d`.
|
||||
curve_3d: Arc<Curve>,
|
||||
/// `associated_geometry` (at most two items).
|
||||
associated: Vec<Associated>,
|
||||
/// `master_representation`.
|
||||
master: PreferredRep,
|
||||
},
|
||||
/// `OFFSET_CURVE_3D`.
|
||||
OffsetCurve3d {
|
||||
/// `basis_curve`.
|
||||
basis: Arc<Curve>,
|
||||
/// `distance`, in millimetres.
|
||||
distance: f64,
|
||||
/// `self_intersect`.
|
||||
self_intersect: Logical,
|
||||
/// `ref_direction` as the file gives it (not normalised).
|
||||
ref_direction: P3,
|
||||
},
|
||||
}
|
||||
|
||||
/// A STEP surface.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Surface {
|
||||
/// `PLANE`: `origin + u·X + v·Y`.
|
||||
Plane {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
},
|
||||
/// `CYLINDRICAL_SURFACE`: `origin + r·(cos u·X + sin u·Y) + v·Z`.
|
||||
Cylinder {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `r` (positive).
|
||||
radius: f64,
|
||||
},
|
||||
/// `CONICAL_SURFACE`: `origin + (r + v·tan a)·(cos u·X + sin u·Y) + v·Z`.
|
||||
Cone {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `r` (not negative).
|
||||
radius: f64,
|
||||
/// `a`, in radians, its size in (0, π/2).
|
||||
semi_angle: f64,
|
||||
},
|
||||
/// `SPHERICAL_SURFACE`: `origin + r·cos v·(cos u·X + sin u·Y) + r·sin v·Z`.
|
||||
Sphere {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `r` (positive).
|
||||
radius: f64,
|
||||
},
|
||||
/// `TOROIDAL_SURFACE`: `origin + (R + r·cos v)·(cos u·X + sin u·Y) + r·sin v·Z`.
|
||||
Torus {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `R` (may be negative: some writers use that for a spindle torus).
|
||||
major_radius: f64,
|
||||
/// `r` (positive).
|
||||
minor_radius: f64,
|
||||
},
|
||||
/// `DEGENERATE_TOROIDAL_SURFACE`: a torus whose minor radius reaches the axis;
|
||||
/// `select_outer` keeps the outer part.
|
||||
DegenerateTorus {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `R`.
|
||||
major_radius: f64,
|
||||
/// `r` (positive).
|
||||
minor_radius: f64,
|
||||
/// `select_outer`.
|
||||
select_outer: bool,
|
||||
},
|
||||
/// `B_SPLINE_SURFACE` and its subtypes.
|
||||
BSpline(Box<BSplineSurface>),
|
||||
/// `SURFACE_OF_REVOLUTION`: the profile at `v` turned by the angle `u` about the axis.
|
||||
Revolution {
|
||||
/// `swept_curve`.
|
||||
profile: Arc<Curve>,
|
||||
/// `axis_position`.
|
||||
axis: Axis1,
|
||||
},
|
||||
/// `SURFACE_OF_LINEAR_EXTRUSION`: `profile(u) + v·w`, with `w` the [`Vector`].
|
||||
Extrusion {
|
||||
/// `swept_curve`.
|
||||
profile: Arc<Curve>,
|
||||
/// `extrusion_axis`.
|
||||
vector: Vector,
|
||||
},
|
||||
/// `OFFSET_SURFACE`.
|
||||
Offset {
|
||||
/// `basis_surface`.
|
||||
basis: Arc<Surface>,
|
||||
/// `distance`, in millimetres.
|
||||
distance: f64,
|
||||
/// `self_intersect`.
|
||||
self_intersect: Logical,
|
||||
},
|
||||
/// `RECTANGULAR_TRIMMED_SURFACE`, its bounds in the basis surface's parameter units.
|
||||
RectangularTrimmed {
|
||||
/// `basis_surface`.
|
||||
basis: Arc<Surface>,
|
||||
/// `u1`.
|
||||
u1: f64,
|
||||
/// `u2`.
|
||||
u2: f64,
|
||||
/// `v1`.
|
||||
v1: f64,
|
||||
/// `v2`.
|
||||
v2: f64,
|
||||
/// `usense`.
|
||||
u_sense: bool,
|
||||
/// `vsense`.
|
||||
v_sense: bool,
|
||||
},
|
||||
}
|
||||
+1088
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,713 @@
|
||||
//! The product structure, one case at a time: a part, an assembly with a shared sub-assembly,
|
||||
//! mapped items, colours, names with escapes, and what is refused. Lengths are the file's
|
||||
//! numbers times the context's unit, written as the same products, so every comparison is
|
||||
//! exact.
|
||||
//!
|
||||
//! Origin: the data cases of SolidCraft's `crates/kernel/src/step_in/tests.rs` at `a5b71ca2`
|
||||
//! (`hand_written_part_in_inches_with_colour`, `two_instance_assembly`,
|
||||
//! `assembly_cycles_are_bounded`, `exponential_assemblies_are_bounded`), checking the data the
|
||||
//! import builds from instead of the bodies it builds.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use super::{
|
||||
BodyKind, Colour, Definition, Frame, Instance, Item, ItemKind, Link, MAX_ASSEMBLY_DEPTH,
|
||||
MAX_VISITS, Operator, Product, ProductStructure, Representation, RepresentationMap, Side,
|
||||
StyledItem, SurfaceStyle, Transformation, read,
|
||||
};
|
||||
use crate::step::geom::Placement;
|
||||
use crate::step::p21::{self, Exchange};
|
||||
use crate::step::units::{Context, Units};
|
||||
|
||||
fn file(data: &str) -> Exchange {
|
||||
let t = format!(
|
||||
"ISO-10303-21;\nHEADER;FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));ENDSEC;\nDATA;\n{data}\nENDSEC;\nEND-ISO-10303-21;\n"
|
||||
);
|
||||
match p21::parse(&t) {
|
||||
Ok(ex) if ex.duplicates.is_empty() => ex,
|
||||
Ok(ex) => panic!("ids defined twice: {:?}\n{t}", ex.duplicates),
|
||||
Err(e) => panic!("{e}\n{t}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn structure(data: &str) -> ProductStructure {
|
||||
read(&file(data)).unwrap_or_else(|e| panic!("{e}"))
|
||||
}
|
||||
|
||||
fn refused(data: &str) -> String {
|
||||
match read(&file(data)) {
|
||||
Ok(s) => panic!("read: {s:?}"),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// The application contexts and a body: #8, #9, the solid #101 'Cube' (its shell #100 empty:
|
||||
/// the product structure does not read topology).
|
||||
const BASE: &str = "#8=PRODUCT_DEFINITION_CONTEXT('part definition',#9,'design');\
|
||||
#9=APPLICATION_CONTEXT('');#100=CLOSED_SHELL('',());#101=MANIFOLD_SOLID_BREP('Cube',#100);\n";
|
||||
|
||||
/// A geometric context `#id` in the length unit `unit` (#id+2), radians (#id+3) and an
|
||||
/// uncertainty of 1e-6 in that unit (#id+1).
|
||||
fn context(id: u64, unit: &str) -> String {
|
||||
format!(
|
||||
"#{id}=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{},#{}))REPRESENTATION_CONTEXT('',''));\n#{}=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#{},'','');\n#{}={unit};\n#{}=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\n",
|
||||
id + 1,
|
||||
id + 2,
|
||||
id + 3,
|
||||
id + 1,
|
||||
id + 2,
|
||||
id + 2,
|
||||
id + 3
|
||||
)
|
||||
}
|
||||
|
||||
const MM: &str = "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))";
|
||||
const CM: &str = "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))";
|
||||
/// Millimetres per centimetre as `units` forms it (prefix times metre).
|
||||
const CM_MM: f64 = 1e-2 * 1000.0;
|
||||
|
||||
/// A product `name` and its definition `#pd`: `PRODUCT` #pd+1, `PRODUCT_CONTEXT` #pd+2, formation
|
||||
/// #pd+3, `PRODUCT_DEFINITION_SHAPE` #pd+4.
|
||||
fn product(pd: u64, name: &str) -> String {
|
||||
format!(
|
||||
"#{}=PRODUCT('{name}','{name}','',(#{}));\n#{}=PRODUCT_CONTEXT('',#9,'mechanical');\n#{}=PRODUCT_DEFINITION_FORMATION('','',#{});\n#{pd}=PRODUCT_DEFINITION('design','',#{},#8);\n#{}=PRODUCT_DEFINITION_SHAPE('','',#{pd});\n",
|
||||
pd + 1,
|
||||
pd + 2,
|
||||
pd + 2,
|
||||
pd + 3,
|
||||
pd + 1,
|
||||
pd + 3,
|
||||
pd + 4
|
||||
)
|
||||
}
|
||||
|
||||
fn mm(len: f64) -> Context {
|
||||
Context {
|
||||
units: Units { len, ang: 1.0 },
|
||||
uncertainty: Some(1.0E-6 * len),
|
||||
}
|
||||
}
|
||||
|
||||
fn product_of(entity: u64, name: &str) -> Product {
|
||||
Product {
|
||||
entity,
|
||||
id: name.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn placement(origin: [f64; 3], axis: Option<[f64; 3]>, ref_direction: Option<[f64; 3]>) -> Frame {
|
||||
Frame::Placement(Placement {
|
||||
origin,
|
||||
axis,
|
||||
ref_direction,
|
||||
})
|
||||
}
|
||||
|
||||
/// A part in inches with a colour: SolidCraft's `hand_written_part_in_inches_with_colour`. The
|
||||
/// definition's shape is an empty representation linked to the one with the solid.
|
||||
#[test]
|
||||
fn a_part() {
|
||||
let mut t = String::from(BASE);
|
||||
t += &context(
|
||||
20,
|
||||
"(CONVERSION_BASED_UNIT('INCH',#30)LENGTH_UNIT()NAMED_UNIT(*));\n#30=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#31);\n#31=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))",
|
||||
);
|
||||
t += &product(50, "Widget");
|
||||
t += "#60=ADVANCED_BREP_SHAPE_REPRESENTATION('brep',(#101,#65),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#62);\n#62=SHAPE_REPRESENTATION('',(),#20);\n#63=SHAPE_REPRESENTATION_RELATIONSHIP('','',#62,#60);\n";
|
||||
t += "#65=AXIS2_PLACEMENT_3D('',#66,$,$);\n#66=CARTESIAN_POINT('',(0.,0.,0.));\n";
|
||||
t += "#70=STYLED_ITEM('',(#71),#101);\n#71=PRESENTATION_STYLE_ASSIGNMENT((#72));\n#72=SURFACE_STYLE_USAGE(.BOTH.,#73);\n#73=SURFACE_SIDE_STYLE('',(#74));\n#74=SURFACE_STYLE_FILL_AREA(#75);\n#75=FILL_AREA_STYLE('',(#76));\n#76=FILL_AREA_STYLE_COLOUR('',#77);\n#77=COLOUR_RGB('',1.,0.5,0.);\n";
|
||||
let inch = Context {
|
||||
units: Units {
|
||||
len: 25.4 * 1.0,
|
||||
ang: 1.0,
|
||||
},
|
||||
uncertainty: Some(1.0E-6 * (25.4 * 1.0)),
|
||||
};
|
||||
assert_eq!(
|
||||
structure(&t),
|
||||
ProductStructure {
|
||||
products: vec![product_of(51, "Widget")],
|
||||
definitions: vec![Definition {
|
||||
entity: 50,
|
||||
id: "design".into(),
|
||||
description: String::new(),
|
||||
formation: 53,
|
||||
product: 0,
|
||||
shapes: vec![1],
|
||||
}],
|
||||
representations: vec![
|
||||
Representation {
|
||||
entity: 60,
|
||||
kind: "ADVANCED_BREP_SHAPE_REPRESENTATION".into(),
|
||||
name: "brep".into(),
|
||||
items: vec![
|
||||
Item {
|
||||
entity: 101,
|
||||
name: "Cube".into(),
|
||||
kind: ItemKind::Body(BodyKind::ManifoldSolidBrep),
|
||||
},
|
||||
Item {
|
||||
entity: 65,
|
||||
name: String::new(),
|
||||
kind: ItemKind::Other("AXIS2_PLACEMENT_3D".into()),
|
||||
},
|
||||
],
|
||||
context: Some(20),
|
||||
units: inch,
|
||||
},
|
||||
Representation {
|
||||
entity: 62,
|
||||
kind: "SHAPE_REPRESENTATION".into(),
|
||||
name: String::new(),
|
||||
items: Vec::new(),
|
||||
context: Some(20),
|
||||
units: inch,
|
||||
},
|
||||
],
|
||||
maps: Vec::new(),
|
||||
links: vec![Link {
|
||||
entity: 63,
|
||||
rep_1: 1,
|
||||
rep_2: 0,
|
||||
transformation: None,
|
||||
}],
|
||||
occurrences: Vec::new(),
|
||||
roots: vec![0],
|
||||
styles: vec![StyledItem {
|
||||
entity: 70,
|
||||
name: String::new(),
|
||||
item: 101,
|
||||
overrides: None,
|
||||
surface: vec![SurfaceStyle {
|
||||
side: Side::Both,
|
||||
fill: Some(Colour::Rgb {
|
||||
name: String::new(),
|
||||
rgb: [1.0, 0.5, 0.0],
|
||||
}),
|
||||
rendering: None,
|
||||
transparency: None,
|
||||
}],
|
||||
curve: Vec::new(),
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A solid outside any product: its representation is still read, and nothing else.
|
||||
#[test]
|
||||
fn a_loose_solid() {
|
||||
let mut t = String::from(BASE);
|
||||
t += &context(20, MM);
|
||||
t += "#60=MANIFOLD_SURFACE_SHAPE_REPRESENTATION('',(#101),#20);\n";
|
||||
let s = structure(&t);
|
||||
assert_eq!(
|
||||
(s.products.len(), s.definitions.len(), s.roots.len()),
|
||||
(0, 0, 0)
|
||||
);
|
||||
assert_eq!(s.representations.len(), 1);
|
||||
assert_eq!(s.representations[0].entity, 60);
|
||||
assert_eq!(s.representations[0].units, mm(1e-3 * 1000.0));
|
||||
}
|
||||
|
||||
/// The occurrence `#n` of `child` in `parent` placed by an `ITEM_DEFINED_TRANSFORMATION` of
|
||||
/// `item_1` (in `rep_1`) onto `item_2` (in `rep_2`): NAUO #n, its PDS #n+1, the transformation
|
||||
/// #n+2, the relationship #n+3, the CDSR #n+4.
|
||||
fn occurrence(
|
||||
n: u64,
|
||||
name: &str,
|
||||
parent: u64,
|
||||
child: u64,
|
||||
reps: (u64, u64),
|
||||
items: (u64, u64),
|
||||
) -> String {
|
||||
let mut t = String::new();
|
||||
let _ = write!(
|
||||
t,
|
||||
"#{n}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('{n}','{name}','',#{parent},#{child},$);\n\
|
||||
#{}=PRODUCT_DEFINITION_SHAPE('','',#{n});\n\
|
||||
#{}=ITEM_DEFINED_TRANSFORMATION('','',#{},#{});\n\
|
||||
#{}=(REPRESENTATION_RELATIONSHIP('','',#{},#{})REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{})SHAPE_REPRESENTATION_RELATIONSHIP());\n\
|
||||
#{}=CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{},#{});\n",
|
||||
n + 1,
|
||||
n + 2,
|
||||
items.0,
|
||||
items.1,
|
||||
n + 3,
|
||||
reps.0,
|
||||
reps.1,
|
||||
n + 2,
|
||||
n + 4,
|
||||
n + 3,
|
||||
n + 1
|
||||
);
|
||||
t
|
||||
}
|
||||
|
||||
/// Top uses Sub twice and Part once; Sub uses Part once. Sub's placements are in centimetres,
|
||||
/// Top's and Part's in millimetres. Four occurrences, six instances; the transformation is the
|
||||
/// two placements as written, each in its representation's unit.
|
||||
#[test]
|
||||
fn an_assembly_with_a_shared_sub_assembly() {
|
||||
let mut t = String::from(BASE);
|
||||
t += &context(20, CM);
|
||||
t += &context(30, MM);
|
||||
t += &product(50, "Top");
|
||||
t += &product(70, "Sub");
|
||||
t += &product(90, "Part");
|
||||
// Top (mm): its own frame #161 and the places of its three components #163, #165, #167.
|
||||
t += "#160=SHAPE_REPRESENTATION('Top',(#161,#163,#165,#167),#30);\n#162=SHAPE_DEFINITION_REPRESENTATION(#54,#160);\n";
|
||||
t += "#161=AXIS2_PLACEMENT_3D('',#1610,$,$);\n#1610=CARTESIAN_POINT('',(0.,0.,0.));\n";
|
||||
t += "#163=AXIS2_PLACEMENT_3D('',#1630,#1631,#1632);\n#1630=CARTESIAN_POINT('',(100.,0.,0.));\n#1631=DIRECTION('',(0.,0.,1.));\n#1632=DIRECTION('',(0.,2.,0.));\n";
|
||||
t += "#165=AXIS2_PLACEMENT_3D('',#1650,$,$);\n#1650=CARTESIAN_POINT('',(0.,100.,0.));\n";
|
||||
t += "#167=AXIS2_PLACEMENT_3D('',#1670,$,$);\n#1670=CARTESIAN_POINT('',(0.,0.,100.));\n";
|
||||
// Sub (cm): its frame #171 and the place of its part #173.
|
||||
t += "#170=SHAPE_REPRESENTATION('Sub',(#171,#173),#20);\n#172=SHAPE_DEFINITION_REPRESENTATION(#74,#170);\n";
|
||||
t += "#171=AXIS2_PLACEMENT_3D('',#1710,$,$);\n#1710=CARTESIAN_POINT('',(1.,2.,3.));\n";
|
||||
t += "#173=AXIS2_PLACEMENT_3D('',#1730,$,$);\n#1730=CARTESIAN_POINT('',(5.,0.,0.));\n";
|
||||
// Part (mm): the solid and its frame.
|
||||
t += "#180=ADVANCED_BREP_SHAPE_REPRESENTATION('Part',(#101,#181),#30);\n#182=SHAPE_DEFINITION_REPRESENTATION(#94,#180);\n";
|
||||
t += "#181=AXIS2_PLACEMENT_3D('',#1810,$,$);\n#1810=CARTESIAN_POINT('',(0.,0.,0.));\n";
|
||||
t += &occurrence(600, "Sub:1", 50, 70, (170, 160), (171, 163));
|
||||
t += &occurrence(610, "Sub:2", 50, 70, (170, 160), (171, 165));
|
||||
t += &occurrence(620, "Part:1", 70, 90, (180, 170), (181, 173));
|
||||
t += &occurrence(630, "Part:2", 50, 90, (180, 160), (181, 167));
|
||||
let s = structure(&t);
|
||||
assert_eq!(
|
||||
s.products,
|
||||
[
|
||||
product_of(51, "Top"),
|
||||
product_of(71, "Sub"),
|
||||
product_of(91, "Part")
|
||||
]
|
||||
);
|
||||
// Representations in id order: #160 Top, #170 Sub, #180 Part.
|
||||
let shapes: Vec<_> = s
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|d| (d.entity, d.product, d.shapes.clone()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shapes,
|
||||
[(50, 0, vec![0]), (70, 1, vec![1]), (90, 2, vec![2])]
|
||||
);
|
||||
assert_eq!(s.roots, [0]);
|
||||
let occ: Vec<_> = s
|
||||
.occurrences
|
||||
.iter()
|
||||
.map(|o| {
|
||||
(
|
||||
o.entity,
|
||||
o.id.as_str(),
|
||||
o.name.as_str(),
|
||||
o.parent,
|
||||
o.child,
|
||||
o.placements.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
occ,
|
||||
[
|
||||
(600, "600", "Sub:1", 0, 1, vec![0]),
|
||||
(610, "610", "Sub:2", 0, 1, vec![1]),
|
||||
(620, "620", "Part:1", 1, 2, vec![2]),
|
||||
(630, "630", "Part:2", 0, 2, vec![3]),
|
||||
]
|
||||
);
|
||||
assert_eq!(s.occurrences[0].reference_designator, None);
|
||||
// Sub:1: Sub's frame (cm: 1, 2, 3 → 10, 20, 30 mm) onto Top's place for it, as written.
|
||||
assert_eq!(
|
||||
s.links[0],
|
||||
Link {
|
||||
entity: 603,
|
||||
rep_1: 1,
|
||||
rep_2: 0,
|
||||
transformation: Some(Transformation::Items {
|
||||
entity: 602,
|
||||
item_1: placement([1.0 * CM_MM, 2.0 * CM_MM, 3.0 * CM_MM], None, None),
|
||||
item_2: placement(
|
||||
[100.0, 0.0, 0.0],
|
||||
Some([0.0, 0.0, 1.0]),
|
||||
Some([0.0, 2.0, 0.0])
|
||||
),
|
||||
}),
|
||||
}
|
||||
);
|
||||
// Part:1: Part's frame (mm) onto Sub's place for it (cm: 5 → 50 mm).
|
||||
assert_eq!(
|
||||
s.links[2].transformation,
|
||||
Some(Transformation::Items {
|
||||
entity: 622,
|
||||
item_1: placement([0.0; 3], None, None),
|
||||
item_2: placement([5.0 * CM_MM, 0.0, 0.0], None, None),
|
||||
})
|
||||
);
|
||||
// Expanded: Top; Sub:1 with its Part:1; Sub:2 with its Part:1; Part:2.
|
||||
let inst = s.instances().unwrap();
|
||||
assert_eq!(
|
||||
inst,
|
||||
[
|
||||
Instance {
|
||||
definition: 0,
|
||||
occurrence: None,
|
||||
parent: None
|
||||
},
|
||||
Instance {
|
||||
definition: 1,
|
||||
occurrence: Some(0),
|
||||
parent: Some(0)
|
||||
},
|
||||
Instance {
|
||||
definition: 2,
|
||||
occurrence: Some(2),
|
||||
parent: Some(1)
|
||||
},
|
||||
Instance {
|
||||
definition: 1,
|
||||
occurrence: Some(1),
|
||||
parent: Some(0)
|
||||
},
|
||||
Instance {
|
||||
definition: 2,
|
||||
occurrence: Some(2),
|
||||
parent: Some(3)
|
||||
},
|
||||
Instance {
|
||||
definition: 2,
|
||||
occurrence: Some(3),
|
||||
parent: Some(0)
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// SolidCraft's `exponential_assemblies_are_bounded`: P0 uses P1 twice, P1 uses P2 twice, … 30
|
||||
/// levels. Reading is linear (31 definitions, 60 occurrences); expanding stops at
|
||||
/// [`MAX_VISITS`]. Three levels expand to 1 + 2 + 4 + 8 instances.
|
||||
#[test]
|
||||
fn a_sub_assembly_shared_at_every_level_is_read_once() {
|
||||
let levels = |n: u64| {
|
||||
let mut t = String::from(BASE);
|
||||
for l in 0..=n {
|
||||
t += &product(1000 + l * 10, &format!("P{l}"));
|
||||
if l < n {
|
||||
for k in 0..2 {
|
||||
let _ = writeln!(
|
||||
t,
|
||||
"#{}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('','','',#{},#{},$);",
|
||||
2000 + l * 10 + k,
|
||||
1000 + l * 10,
|
||||
1000 + (l + 1) * 10
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
t
|
||||
};
|
||||
let s = structure(&levels(30));
|
||||
assert_eq!(
|
||||
(s.definitions.len(), s.occurrences.len(), s.roots.clone()),
|
||||
(31, 60, vec![0])
|
||||
);
|
||||
assert_eq!(
|
||||
s.instances(),
|
||||
Err(format!("more than {MAX_VISITS} assembly instances"))
|
||||
);
|
||||
assert_eq!(structure(&levels(3)).instances().map(|i| i.len()), Ok(15));
|
||||
}
|
||||
|
||||
/// A representation placed twice by `MAPPED_ITEM`s: once by a placement, once by a
|
||||
/// non-uniform operator. The map's origin is in the mapped representation's unit (inches), each
|
||||
/// target in the unit of the representation that lists it (millimetres).
|
||||
#[test]
|
||||
fn mapped_items() {
|
||||
let mut t = String::from(BASE);
|
||||
t += &context(20, MM);
|
||||
t += &context(
|
||||
30,
|
||||
"(CONVERSION_BASED_UNIT('INCH',#40)LENGTH_UNIT()NAMED_UNIT(*));\n#40=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#41);\n#41=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))",
|
||||
);
|
||||
t += &product(50, "Holder");
|
||||
t += "#60=SHAPE_REPRESENTATION('',(#110,#115),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n";
|
||||
t += "#110=MAPPED_ITEM('first',#111,#112);\n#111=REPRESENTATION_MAP(#113,#120);\n";
|
||||
t += "#112=AXIS2_PLACEMENT_3D('',#1120,#1121,$);\n#1120=CARTESIAN_POINT('',(5.,0.,0.));\n#1121=DIRECTION('',(0.,0.,-1.));\n";
|
||||
t += "#113=AXIS2_PLACEMENT_3D('',#1130,$,$);\n#1130=CARTESIAN_POINT('',(1.,0.,0.));\n";
|
||||
t += "#115=MAPPED_ITEM('second',#111,#116);\n";
|
||||
t += "#116=CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM('','',#1161,$,#1160,2.,$,$,3.);\n#1160=CARTESIAN_POINT('',(0.,7.,0.));\n#1161=DIRECTION('',(0.,1.,0.));\n";
|
||||
t += "#120=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101,#113),#30);\n";
|
||||
let s = structure(&t);
|
||||
assert_eq!(
|
||||
s.representations[0].items,
|
||||
[
|
||||
Item {
|
||||
entity: 110,
|
||||
name: "first".into(),
|
||||
kind: ItemKind::Mapped {
|
||||
map: 0,
|
||||
target: placement([5.0, 0.0, 0.0], Some([0.0, 0.0, -1.0]), None),
|
||||
},
|
||||
},
|
||||
Item {
|
||||
entity: 115,
|
||||
name: "second".into(),
|
||||
kind: ItemKind::Mapped {
|
||||
map: 0,
|
||||
target: Frame::Operator(Operator {
|
||||
axis1: Some([0.0, 1.0, 0.0]),
|
||||
axis2: None,
|
||||
axis3: None,
|
||||
origin: [0.0, 7.0, 0.0],
|
||||
scale: Some(2.0),
|
||||
scale2: None,
|
||||
scale3: Some(3.0),
|
||||
}),
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
s.maps,
|
||||
[RepresentationMap {
|
||||
entity: 111,
|
||||
origin: placement([1.0 * (25.4 * 1.0), 0.0, 0.0], None, None),
|
||||
representation: 1,
|
||||
}]
|
||||
);
|
||||
assert_eq!(s.representations[1].entity, 120);
|
||||
assert_eq!(
|
||||
s.representations[1].items[0].kind,
|
||||
ItemKind::Body(BodyKind::ManifoldSolidBrep)
|
||||
);
|
||||
}
|
||||
|
||||
/// Colours on a body and on a face: a fill colour, a rendering colour with a transparency, a
|
||||
/// pre-defined colour, one ISO 10303-46 does not name, a curve style's colour, and an override.
|
||||
#[test]
|
||||
fn colours_of_bodies_and_faces() {
|
||||
let mut t = String::from(BASE);
|
||||
t += "#102=ADVANCED_FACE('',(),#103,.T.);\n#103=PLANE('',$);\n";
|
||||
// The body: fill colour and the curve colour of its edges.
|
||||
t += "#70=STYLED_ITEM('body',(#71,#78),#101);\n#71=PRESENTATION_STYLE_ASSIGNMENT((#72));\n#72=SURFACE_STYLE_USAGE(.POSITIVE.,#73);\n#73=SURFACE_SIDE_STYLE('',(#74));\n#74=SURFACE_STYLE_FILL_AREA(#75);\n#75=FILL_AREA_STYLE('',(#76));\n#76=FILL_AREA_STYLE_COLOUR('',#77);\n#77=COLOUR_RGB('steel',0.2,0.25,1.5);\n";
|
||||
t += "#78=PRESENTATION_STYLE_ASSIGNMENT((#79));\n#79=CURVE_STYLE('',#80,POSITIVE_LENGTH_MEASURE(0.1),#81);\n#80=DRAUGHTING_PRE_DEFINED_CURVE_FONT('continuous');\n#81=DRAUGHTING_PRE_DEFINED_COLOUR('black');\n";
|
||||
// The face, over the body's style: rendered red, a quarter transparent, filled 'mauve'.
|
||||
t += "#90=OVER_RIDING_STYLED_ITEM('face',(#91),#102,#70);\n#91=PRESENTATION_STYLE_ASSIGNMENT((#92));\n#92=SURFACE_STYLE_USAGE(.BOTH.,#93);\n#93=SURFACE_SIDE_STYLE('',(#94,#97));\n";
|
||||
t += "#94=SURFACE_STYLE_RENDERING_WITH_PROPERTIES(.NORMAL_SHADING.,#95,(#96));\n#95=DRAUGHTING_PRE_DEFINED_COLOUR('red');\n#96=SURFACE_STYLE_TRANSPARENT(0.25);\n";
|
||||
t += "#97=SURFACE_STYLE_FILL_AREA(#98);\n#98=FILL_AREA_STYLE('',(#99));\n#99=FILL_AREA_STYLE_COLOUR('',#89);\n#89=DRAUGHTING_PRE_DEFINED_COLOUR('mauve');\n";
|
||||
let s = structure(&t);
|
||||
assert_eq!(
|
||||
s.styles,
|
||||
[
|
||||
StyledItem {
|
||||
entity: 70,
|
||||
name: "body".into(),
|
||||
item: 101,
|
||||
overrides: None,
|
||||
surface: vec![SurfaceStyle {
|
||||
side: Side::Positive,
|
||||
fill: Some(Colour::Rgb {
|
||||
name: "steel".into(),
|
||||
rgb: [0.2, 0.25, 1.5],
|
||||
}),
|
||||
rendering: None,
|
||||
transparency: None,
|
||||
}],
|
||||
curve: vec![Colour::PreDefined {
|
||||
name: "black".into(),
|
||||
rgb: Some([0.0, 0.0, 0.0]),
|
||||
}],
|
||||
},
|
||||
StyledItem {
|
||||
entity: 90,
|
||||
name: "face".into(),
|
||||
item: 102,
|
||||
overrides: Some(70),
|
||||
surface: vec![SurfaceStyle {
|
||||
side: Side::Both,
|
||||
fill: Some(Colour::PreDefined {
|
||||
name: "mauve".into(),
|
||||
rgb: None,
|
||||
}),
|
||||
rendering: Some(Colour::PreDefined {
|
||||
name: "red".into(),
|
||||
rgb: Some([1.0, 0.0, 0.0]),
|
||||
}),
|
||||
transparency: Some(0.25),
|
||||
}],
|
||||
curve: Vec::new(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// ISO 10303-46's eight pre-defined colours.
|
||||
#[test]
|
||||
fn pre_defined_colours() {
|
||||
let names = [
|
||||
"red", "green", "blue", "yellow", "magenta", "cyan", "black", "white",
|
||||
];
|
||||
let rgb: Vec<_> = names.iter().map(|n| super::pre_defined_rgb(n)).collect();
|
||||
assert_eq!(
|
||||
rgb,
|
||||
[
|
||||
Some([1.0, 0.0, 0.0]),
|
||||
Some([0.0, 1.0, 0.0]),
|
||||
Some([0.0, 0.0, 1.0]),
|
||||
Some([1.0, 1.0, 0.0]),
|
||||
Some([1.0, 0.0, 1.0]),
|
||||
Some([0.0, 1.0, 1.0]),
|
||||
Some([0.0, 0.0, 0.0]),
|
||||
Some([1.0, 1.0, 1.0]),
|
||||
]
|
||||
);
|
||||
assert_eq!(super::pre_defined_rgb("Red"), None);
|
||||
}
|
||||
|
||||
/// Names keep their escapes decoded, and are not trimmed.
|
||||
#[test]
|
||||
fn names_with_escapes() {
|
||||
let mut t = String::from(BASE);
|
||||
t += "#51=PRODUCT('P-\\X2\\00E9\\X0\\',' B\\X\\E4r ''7'' ','\\X2\\5706\\X0\\',(#52));\n#52=PRODUCT_CONTEXT('',#9,'mechanical');\n#53=PRODUCT_DEFINITION_FORMATION('','',#51);\n#50=PRODUCT_DEFINITION('d\\\\1',$,#53,#8);\n";
|
||||
t += &product(70, "Child");
|
||||
t += "#600=NEXT_ASSEMBLY_USAGE_OCCURRENCE('NAUO1','Gr\\X\\FC\\X\\DFe:1','',#50,#70,'R\\X2\\00E9\\X0\\f');\n";
|
||||
let s = structure(&t);
|
||||
assert_eq!(
|
||||
s.products[0],
|
||||
Product {
|
||||
entity: 51,
|
||||
id: "P-\u{e9}".into(),
|
||||
name: " B\u{e4}r '7' ".into(),
|
||||
description: "\u{5706}".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
s.definitions[0].id.as_str(),
|
||||
s.definitions[0].description.as_str()
|
||||
),
|
||||
("d\\1", "")
|
||||
);
|
||||
let o = &s.occurrences[0];
|
||||
assert_eq!(
|
||||
(
|
||||
o.id.as_str(),
|
||||
o.name.as_str(),
|
||||
o.reference_designator.as_deref()
|
||||
),
|
||||
("NAUO1", "Gr\u{fc}\u{df}e:1", Some("R\u{e9}f"))
|
||||
);
|
||||
}
|
||||
|
||||
/// A chain of `n` + 1 definitions, each the only component of the one before.
|
||||
fn chain(n: u64) -> String {
|
||||
let mut t = String::from(BASE);
|
||||
for l in 0..=n {
|
||||
t += &product(1000 + l * 10, &format!("P{l}"));
|
||||
if l < n {
|
||||
let _ = writeln!(
|
||||
t,
|
||||
"#{}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('','','',#{},#{},$);",
|
||||
5000 + l,
|
||||
1000 + l * 10,
|
||||
1000 + (l + 1) * 10
|
||||
);
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_is_bounded() {
|
||||
let depth = MAX_ASSEMBLY_DEPTH as u64;
|
||||
let s = structure(&chain(depth));
|
||||
assert_eq!(s.instances().map(|i| i.len()), Ok(MAX_ASSEMBLY_DEPTH + 1));
|
||||
assert_eq!(refused(&chain(depth + 1)), "assembly nested too deeply");
|
||||
}
|
||||
|
||||
/// What is refused, with its message.
|
||||
#[test]
|
||||
fn refusals_say_why() {
|
||||
let two = || {
|
||||
let mut t = String::from(BASE);
|
||||
t += &product(50, "A");
|
||||
t += &product(70, "B");
|
||||
t
|
||||
};
|
||||
// A contains B contains A (SolidCraft's `assembly_cycles_are_bounded`).
|
||||
let cycle = two()
|
||||
+ "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#70,$);\n#91=NEXT_ASSEMBLY_USAGE_OCCURRENCE('2','','',#70,#50,$);\n";
|
||||
assert_eq!(refused(&cycle), "assembly cycle");
|
||||
let itself = two() + "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#50,$);\n";
|
||||
assert_eq!(refused(&itself), "assembly cycle");
|
||||
let missing_child = two() + "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#999,$);\n";
|
||||
assert_eq!(
|
||||
refused(&missing_child),
|
||||
"occurrence #90: #999: expected a PRODUCT_DEFINITION"
|
||||
);
|
||||
let not_a_definition = two() + "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#51,$);\n";
|
||||
assert_eq!(
|
||||
refused(¬_a_definition),
|
||||
"occurrence #90: #51: expected a PRODUCT_DEFINITION"
|
||||
);
|
||||
let missing_product = String::from(BASE)
|
||||
+ "#53=PRODUCT_DEFINITION_FORMATION('','',#51);\n#50=PRODUCT_DEFINITION('design','',#53,#8);\n";
|
||||
assert_eq!(refused(&missing_product), "#51: expected a PRODUCT");
|
||||
let missing_rep = two() + "#61=SHAPE_DEFINITION_REPRESENTATION(#54,#999);\n";
|
||||
assert_eq!(refused(&missing_rep), "missing entity #999");
|
||||
let not_a_rep = two() + "#61=SHAPE_DEFINITION_REPRESENTATION(#54,#101);\n";
|
||||
assert_eq!(
|
||||
refused(¬_a_rep),
|
||||
"#101: expected a representation, found MANIFOLD_SOLID_BREP"
|
||||
);
|
||||
let missing_item = two()
|
||||
+ "#60=SHAPE_REPRESENTATION('',(#101,#999),$);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n";
|
||||
assert_eq!(refused(&missing_item), "missing entity #999");
|
||||
let mapped = |rest: &str| {
|
||||
two()
|
||||
+ "#60=SHAPE_REPRESENTATION('',(#110),$);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n#110=MAPPED_ITEM('',#111,#112);\n#112=AXIS2_PLACEMENT_3D('',#113,$,$);\n#113=CARTESIAN_POINT('',(0.,0.,0.));\n"
|
||||
+ rest
|
||||
};
|
||||
assert_eq!(
|
||||
refused(&mapped("#111=REPRESENTATION_MAP(#112,#60);\n")),
|
||||
"mapped item cycle"
|
||||
);
|
||||
assert_eq!(
|
||||
refused(&mapped("#111=SHAPE_REPRESENTATION('',(),$);\n")),
|
||||
"#110: mapping source #111 is a SHAPE_REPRESENTATION, not a REPRESENTATION_MAP"
|
||||
);
|
||||
assert_eq!(
|
||||
refused(&mapped("#111=REPRESENTATION_MAP(#112,#999);\n")),
|
||||
"missing entity #999"
|
||||
);
|
||||
assert_eq!(
|
||||
refused(&mapped(
|
||||
"#111=REPRESENTATION_MAP(#112,#120);\n#120=SHAPE_REPRESENTATION('',(#101),$);\n"
|
||||
)
|
||||
.replace(
|
||||
"#110=MAPPED_ITEM('',#111,#112);",
|
||||
"#110=MAPPED_ITEM('',#111,#114);#114=CARTESIAN_TRANSFORMATION_OPERATOR_3D('','',$,$,#113,0.,$);"
|
||||
)),
|
||||
"mapped item #110: operator scale: expected a number other than zero"
|
||||
);
|
||||
assert_eq!(
|
||||
refused(&mapped(
|
||||
"#111=REPRESENTATION_MAP(#113,#120);\n#120=SHAPE_REPRESENTATION('',(#101),$);\n"
|
||||
)),
|
||||
"representation map #111: #113: expected a placement, found CARTESIAN_POINT"
|
||||
);
|
||||
let styled = |rest: &str| two() + rest;
|
||||
assert_eq!(
|
||||
refused(&styled("#170=STYLED_ITEM('',(),#999);\n")),
|
||||
"styled item #170: missing entity #999"
|
||||
);
|
||||
assert_eq!(
|
||||
refused(&styled(
|
||||
"#170=STYLED_ITEM('',(#171),#101);\n#171=PRESENTATION_STYLE_ASSIGNMENT((#172));\n#172=SURFACE_STYLE_USAGE(.BOTH.,#173);\n#173=SURFACE_SIDE_STYLE('',(#174));\n#174=SURFACE_STYLE_RENDERING(.NORMAL_SHADING.,#175);\n#175=COLOUR_RGB('',1.,$,0.);\n"
|
||||
)),
|
||||
"styled item #170: green: expected a number"
|
||||
);
|
||||
let placed = two()
|
||||
+ "#60=SHAPE_REPRESENTATION('',(),$);\n#80=SHAPE_REPRESENTATION('',(),$);\n#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#70,$);\n#91=PRODUCT_DEFINITION_SHAPE('','',#90);\n#93=(REPRESENTATION_RELATIONSHIP('','',#60,#80)REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#92)SHAPE_REPRESENTATION_RELATIONSHIP());\n#94=CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#93,#91);\n";
|
||||
assert_eq!(refused(&placed), "relationship #93: missing entity #92");
|
||||
}
|
||||
+1084
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+466
@@ -0,0 +1,466 @@
|
||||
//! The units of a representation context (ISO 10303-41 measures and units, ISO 10303-43
|
||||
//! representation contexts): `GLOBAL_UNIT_ASSIGNED_CONTEXT`'s length and plane angle units and
|
||||
//! `GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT`'s distance uncertainty, read from a parsed
|
||||
//! [`Exchange`]. Missing or malformed units read as millimetres and radians; nothing here fails.
|
||||
//!
|
||||
//! A unit is a factor: millimetres per file length unit, radians per file plane angle unit. An
|
||||
//! SI unit is its prefix times its base (`METRE` = 1000 mm, `RADIAN` = 1 rad); a
|
||||
//! `CONVERSION_BASED_UNIT` is the value of its measure times the factor of the measure's own unit
|
||||
//! (an inch is `LENGTH_MEASURE(25.4)` of millimetres, a degree `PLANE_ANGLE_MEASURE(0.01745…)`
|
||||
//! of radians). Only multiplications: the factor of a degree is the number the file writes, not
|
||||
//! a π computed here (the record computes no geometric quantity, docs/layers.md §4).
|
||||
//!
|
||||
//! Origin: SolidCraft's `crates/step/src/units.rs` and the `Units` type of
|
||||
//! `crates/step/src/read.rs` at `a5b71ca2` (MIT OR Apache-2.0), carried over with their
|
||||
//! behaviour unchanged.
|
||||
//!
|
||||
//! Deviations from SolidCraft: [`Units`] lives here (it was in `read.rs`, which is not carried
|
||||
//! over yet). The nesting bound of a `CONVERSION_BASED_UNIT` chain is the named
|
||||
//! `MAX_CONVERSION_DEPTH` (was the literal 4). Otherwise only formatting and doc comments on every
|
||||
//! public item.
|
||||
|
||||
use super::p21::{Exchange, Param};
|
||||
|
||||
/// Most `CONVERSION_BASED_UNIT` links followed from one unit (a unit defined from a unit defined
|
||||
/// from …); a deeper chain, or a cycle, reads as no unit.
|
||||
const MAX_CONVERSION_DEPTH: usize = 4;
|
||||
|
||||
/// The units of a representation context.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Units {
|
||||
/// Millimetres per file length unit.
|
||||
pub len: f64,
|
||||
/// Radians per file plane angle unit.
|
||||
pub ang: f64,
|
||||
}
|
||||
|
||||
impl Default for Units {
|
||||
/// Millimetres and radians.
|
||||
fn default() -> Self {
|
||||
Units { len: 1.0, ang: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a representation context says about its numbers.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Context {
|
||||
/// The length and plane angle units (millimetres and radians where the context names none
|
||||
/// that can be read).
|
||||
pub units: Units,
|
||||
/// The context's distance uncertainty in millimetres (the last `UNCERTAINTY_MEASURE_WITH_UNIT`
|
||||
/// it lists), if any.
|
||||
pub uncertainty: Option<f64>,
|
||||
}
|
||||
|
||||
/// The factor of an SI prefix (`$` or an unknown prefix: 1).
|
||||
fn si_prefix(p: Option<&Param>) -> f64 {
|
||||
match p.and_then(Param::as_enum) {
|
||||
Some("EXA") => 1e18,
|
||||
Some("PETA") => 1e15,
|
||||
Some("TERA") => 1e12,
|
||||
Some("GIGA") => 1e9,
|
||||
Some("MEGA") => 1e6,
|
||||
Some("KILO") => 1e3,
|
||||
Some("HECTO") => 1e2,
|
||||
Some("DECA") => 1e1,
|
||||
Some("DECI") => 1e-1,
|
||||
Some("CENTI") => 1e-2,
|
||||
Some("MILLI") => 1e-3,
|
||||
Some("MICRO") => 1e-6,
|
||||
Some("NANO") => 1e-9,
|
||||
Some("PICO") => 1e-12,
|
||||
Some("FEMTO") => 1e-15,
|
||||
Some("ATTO") => 1e-18,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a unit measures.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UnitKind {
|
||||
/// A `LENGTH_UNIT`.
|
||||
Length,
|
||||
/// A `PLANE_ANGLE_UNIT`.
|
||||
Angle,
|
||||
/// Any other unit (solid angle, mass, …).
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A unit's kind and size: millimetres for lengths, radians for plane angles (1 for anything
|
||||
/// else, or a unit that cannot be read). `None` when `#id` is not an entity, or is a
|
||||
/// `CONVERSION_BASED_UNIT` whose measure cannot be read or is not finite and positive.
|
||||
pub fn unit_factor(ex: &Exchange, id: u64) -> Option<(UnitKind, f64)> {
|
||||
factor(ex, id, 0)
|
||||
}
|
||||
|
||||
fn factor(ex: &Exchange, id: u64, depth: usize) -> Option<(UnitKind, f64)> {
|
||||
if depth > MAX_CONVERSION_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let e = ex.get(id)?;
|
||||
let kind = if e.has("LENGTH_UNIT") {
|
||||
UnitKind::Length
|
||||
} else if e.has("PLANE_ANGLE_UNIT") {
|
||||
UnitKind::Angle
|
||||
} else {
|
||||
UnitKind::Other
|
||||
};
|
||||
if let Some(si) = e.record("SI_UNIT") {
|
||||
let base = match si.get(1).and_then(Param::as_enum) {
|
||||
Some("METRE") => 1000.0,
|
||||
Some("RADIAN") => 1.0,
|
||||
_ => return Some((kind, 1.0)),
|
||||
};
|
||||
return Some((kind, si_prefix(si.first()) * base));
|
||||
}
|
||||
if let Some(cb) = e.record("CONVERSION_BASED_UNIT") {
|
||||
let m = ex.get(cb.get(1)?.as_ref_id()?)?;
|
||||
let mp = m.params();
|
||||
let v = mp.first()?.as_f64()?;
|
||||
let (_, f) = factor(ex, mp.get(1)?.as_ref_id()?, depth + 1)?;
|
||||
let x = v * f;
|
||||
return (x.is_finite() && x > 0.0).then_some((kind, x));
|
||||
}
|
||||
Some((kind, 1.0))
|
||||
}
|
||||
|
||||
/// The units and uncertainty of the representation context `#id` (the defaults when `#id` is
|
||||
/// not an entity or lists no readable unit).
|
||||
///
|
||||
/// Each unit `GLOBAL_UNIT_ASSIGNED_CONTEXT` lists that reads as a finite, positive length or
|
||||
/// plane angle factor sets that unit; a later one wins. Each `UNCERTAINTY_MEASURE_WITH_UNIT` that
|
||||
/// `GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT` lists sets the uncertainty to its value times the factor
|
||||
/// of its unit (the context's length unit when that cannot be read), or clears it when the
|
||||
/// product is not finite and positive; the last one wins.
|
||||
pub fn context(ex: &Exchange, id: u64) -> Context {
|
||||
let mut c = Context::default();
|
||||
let Some(e) = ex.get(id) else { return c };
|
||||
if let Some(units) = e
|
||||
.record("GLOBAL_UNIT_ASSIGNED_CONTEXT")
|
||||
.and_then(<[Param]>::first)
|
||||
.and_then(Param::as_list)
|
||||
{
|
||||
for uid in units.iter().filter_map(Param::as_ref_id) {
|
||||
match unit_factor(ex, uid) {
|
||||
Some((UnitKind::Length, f)) if f.is_finite() && f > 0.0 => c.units.len = f,
|
||||
Some((UnitKind::Angle, f)) if f.is_finite() && f > 0.0 => c.units.ang = f,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(unc) = e
|
||||
.record("GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT")
|
||||
.and_then(<[Param]>::first)
|
||||
.and_then(Param::as_list)
|
||||
{
|
||||
for uid in unc.iter().filter_map(Param::as_ref_id) {
|
||||
if let Some(m) = ex.get(uid)
|
||||
&& let Some(v) = m.params().first().and_then(Param::as_f64)
|
||||
{
|
||||
let f = m
|
||||
.params()
|
||||
.get(1)
|
||||
.and_then(Param::as_ref_id)
|
||||
.and_then(|x| unit_factor(ex, x))
|
||||
.map(|x| x.1);
|
||||
c.uncertainty =
|
||||
Some(v * f.unwrap_or(c.units.len)).filter(|t| t.is_finite() && *t > 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::step::p21;
|
||||
use std::f64::consts::PI;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// Millimetres, radians and steradians as SolidCraft's writer and its topology tests
|
||||
/// (`crates/step/src/topology_tests.rs`, `file`) write them.
|
||||
const MM: &str = "#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));\
|
||||
#2=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\
|
||||
#3=(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT());";
|
||||
|
||||
fn parse(data: &str) -> Exchange {
|
||||
let t = format!(
|
||||
"ISO-10303-21;\nHEADER;FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));ENDSEC;\nDATA;\n{data}\nENDSEC;\nEND-ISO-10303-21;\n"
|
||||
);
|
||||
match p21::parse(&t) {
|
||||
Ok(ex) => ex,
|
||||
Err(e) => panic!("{e}\n{t}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A context `#100` with the units `units` and the uncertainties `unc` (entity ids).
|
||||
fn ctx(data: &str, units: &str, unc: &str) -> Context {
|
||||
let c = format!(
|
||||
"#100=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT(({unc}))\
|
||||
GLOBAL_UNIT_ASSIGNED_CONTEXT(({units}))REPRESENTATION_CONTEXT('',''));"
|
||||
);
|
||||
context(&parse(&format!("{data}{c}")), 100)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solidcraft_file_context() {
|
||||
// SolidCraft's topology test file: mm, rad, sr and an uncertainty of 1e-7 mm.
|
||||
let c = ctx(
|
||||
&format!(
|
||||
"{MM}#4=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#1,'distance_accuracy_value','');"
|
||||
),
|
||||
"#1,#2,#3",
|
||||
"#4",
|
||||
);
|
||||
assert_eq!(c.units, Units { len: 1.0, ang: 1.0 });
|
||||
// 1e-7 times MILLI (1e-3) times METRE (1000 mm).
|
||||
assert_eq!(c.uncertainty, Some(1e-7 * (1e-3 * 1000.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn si_prefixes() {
|
||||
for (prefix, mm) in [
|
||||
(".MILLI.", 1e-3 * 1000.0),
|
||||
("$", 1000.0),
|
||||
(".MICRO.", 1e-6 * 1000.0),
|
||||
(".CENTI.", 1e-2 * 1000.0),
|
||||
(".KILO.", 1e3 * 1000.0),
|
||||
] {
|
||||
let c = ctx(
|
||||
&format!("#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT({prefix},.METRE.));"),
|
||||
"#1",
|
||||
"",
|
||||
);
|
||||
assert_eq!(c.units.len, mm, "{prefix}");
|
||||
assert_eq!(c.units.ang, 1.0, "{prefix}");
|
||||
assert_eq!(c.uncertainty, None, "{prefix}");
|
||||
}
|
||||
// The products the table defines: a millimetre is exactly 1, a metre 1000.
|
||||
assert_eq!(1e-3 * 1000.0, 1.0);
|
||||
let ex = parse("#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MICRO.,.METRE.));");
|
||||
assert_eq!(unit_factor(&ex, 1), Some((UnitKind::Length, 1e-6 * 1000.0)));
|
||||
// A milliradian.
|
||||
let ex = parse("#1=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT(.MILLI.,.RADIAN.));");
|
||||
assert_eq!(unit_factor(&ex, 1), Some((UnitKind::Angle, 1e-3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inches_and_degrees() {
|
||||
// An inch as 25.4 mm, a degree as π/180 rad (written as the shortest decimal of
|
||||
// PI / 180, so it reads as exactly that double).
|
||||
let data = format!(
|
||||
"{MM}#10=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#1);\
|
||||
#11=(CONVERSION_BASED_UNIT('INCH',#10)LENGTH_UNIT()NAMED_UNIT(#12));\
|
||||
#12=DIMENSIONAL_EXPONENTS(1.,0.,0.,0.,0.,0.,0.);\
|
||||
#13=PLANE_ANGLE_MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE(0.017453292519943295),#2);\
|
||||
#14=(CONVERSION_BASED_UNIT('DEGREE',#13)NAMED_UNIT(#15)PLANE_ANGLE_UNIT());\
|
||||
#15=DIMENSIONAL_EXPONENTS(0.,0.,0.,0.,0.,0.,0.);"
|
||||
);
|
||||
let c = ctx(&data, "#11,#14,#3", "");
|
||||
assert_eq!(
|
||||
c.units,
|
||||
Units {
|
||||
len: 25.4,
|
||||
ang: PI / 180.0
|
||||
}
|
||||
);
|
||||
// An inch defined from metres: 0.0254 times 1000 mm.
|
||||
let c = ctx(
|
||||
"#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.));\
|
||||
#10=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.0254),#1);\
|
||||
#11=(CONVERSION_BASED_UNIT('INCH',#10)LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
"#11",
|
||||
"",
|
||||
);
|
||||
assert_eq!(c.units.len, 0.0254 * 1000.0);
|
||||
// A foot defined from the inch: a chain of two conversions, 12 times 25.4 mm.
|
||||
let c = ctx(
|
||||
&format!(
|
||||
"{data}#20=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(12.),#11);\
|
||||
#21=(CONVERSION_BASED_UNIT('FOOT',#20)LENGTH_UNIT()NAMED_UNIT(*));"
|
||||
),
|
||||
"#21",
|
||||
"",
|
||||
);
|
||||
assert_eq!(c.units.len, 12.0 * 25.4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_broken_units_read_as_mm_and_rad() {
|
||||
let mm_rad = Units { len: 1.0, ang: 1.0 };
|
||||
// No such context, or a context without units.
|
||||
assert_eq!(context(&parse(MM), 100), Context::default());
|
||||
assert_eq!(context(&parse(MM), 1), Context::default());
|
||||
assert_eq!(Context::default().units, mm_rad);
|
||||
for (data, units) in [
|
||||
// Dangling references.
|
||||
("", "#7,#8"),
|
||||
// An SI unit with an unknown base: factor 1.
|
||||
(
|
||||
"#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.FURLONG.));",
|
||||
"#1",
|
||||
),
|
||||
// A conversion whose measure is missing, unreadable, zero, negative or infinite.
|
||||
(
|
||||
"#1=(CONVERSION_BASED_UNIT('INCH',#9)LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
"#1",
|
||||
),
|
||||
(
|
||||
"#2=(NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.)LENGTH_UNIT());\
|
||||
#3=LENGTH_MEASURE_WITH_UNIT($,#2);\
|
||||
#1=(CONVERSION_BASED_UNIT('INCH',#3)LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
"#1",
|
||||
),
|
||||
(
|
||||
"#2=(NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.)LENGTH_UNIT());\
|
||||
#3=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.),#2);\
|
||||
#1=(CONVERSION_BASED_UNIT('INCH',#3)LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
"#1",
|
||||
),
|
||||
(
|
||||
"#2=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\
|
||||
#3=PLANE_ANGLE_MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE(-1.),#2);\
|
||||
#1=(CONVERSION_BASED_UNIT('DEGREE',#3)PLANE_ANGLE_UNIT()NAMED_UNIT(*));",
|
||||
"#1",
|
||||
),
|
||||
(
|
||||
"#2=(NAMED_UNIT(*)SI_UNIT(.EXA.,.METRE.)LENGTH_UNIT());\
|
||||
#3=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E300),#2);\
|
||||
#1=(CONVERSION_BASED_UNIT('BIG',#3)LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
"#1",
|
||||
),
|
||||
// A conversion that refers to itself: the depth bound ends it.
|
||||
(
|
||||
"#3=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(2.),#1);\
|
||||
#1=(CONVERSION_BASED_UNIT('LOOP',#3)LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
"#1",
|
||||
),
|
||||
// Units that are neither lengths nor plane angles.
|
||||
(MM, "#3"),
|
||||
// An empty list.
|
||||
(MM, ""),
|
||||
] {
|
||||
let c = ctx(data, units, "");
|
||||
assert_eq!(c.units, mm_rad, "{data} / {units}");
|
||||
}
|
||||
// A unit list that is not a list.
|
||||
let ex = parse(&format!(
|
||||
"{MM}#100=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNIT_ASSIGNED_CONTEXT(#1)REPRESENTATION_CONTEXT('',''));"
|
||||
));
|
||||
assert_eq!(context(&ex, 100), Context::default());
|
||||
// A broken length unit leaves the readable angle unit in place.
|
||||
let c = ctx(
|
||||
"#2=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT(.MILLI.,.RADIAN.));\
|
||||
#1=(CONVERSION_BASED_UNIT('INCH',#9)LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
"#1,#2",
|
||||
"",
|
||||
);
|
||||
assert_eq!(
|
||||
c.units,
|
||||
Units {
|
||||
len: 1.0,
|
||||
ang: 1e-3
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_depth_is_bounded() {
|
||||
// #1 is millimetres; #2..=#7 each 2 of the one before, so #(1+k) is 2^k mm and
|
||||
// MAX_CONVERSION_DEPTH links are followed from the unit asked for.
|
||||
let mut data = String::from("#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));");
|
||||
for k in 2..=7 {
|
||||
write!(
|
||||
data,
|
||||
"#{m}=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(2.),#{p});\
|
||||
#{k}=(CONVERSION_BASED_UNIT('U',#{m})LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
m = 100 + k,
|
||||
p = k - 1
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let ex = parse(&data);
|
||||
assert_eq!(unit_factor(&ex, 5), Some((UnitKind::Length, 16.0)));
|
||||
assert_eq!(unit_factor(&ex, 6), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncertainty_in_file_units() {
|
||||
let inch = format!(
|
||||
"{MM}#10=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#1);\
|
||||
#11=(CONVERSION_BASED_UNIT('INCH',#10)LENGTH_UNIT()NAMED_UNIT(*));"
|
||||
);
|
||||
// 1e-4 inch is 1e-4 * 25.4 mm.
|
||||
let c = ctx(
|
||||
&format!("{inch}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#11,'d','');"),
|
||||
"#11,#2",
|
||||
"#20",
|
||||
);
|
||||
assert_eq!(c.units.len, 25.4);
|
||||
assert_eq!(c.uncertainty, Some(1e-4 * 25.4));
|
||||
// A measure in metres in an inch context: its own unit, 1e-6 * 1000 mm.
|
||||
let c = ctx(
|
||||
&format!(
|
||||
"{inch}#12=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.));\
|
||||
#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-06),#12,'d','');"
|
||||
),
|
||||
"#11",
|
||||
"#20",
|
||||
);
|
||||
assert_eq!(c.uncertainty, Some(1e-6 * 1000.0));
|
||||
// A measure whose unit cannot be read is in the context's length unit.
|
||||
let c = ctx(
|
||||
&format!("{inch}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),$,'d','');"),
|
||||
"#11",
|
||||
"#20",
|
||||
);
|
||||
assert_eq!(c.uncertainty, Some(1e-4 * 25.4));
|
||||
// The last one listed wins.
|
||||
let c = ctx(
|
||||
&format!(
|
||||
"{MM}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#1,'d','');\
|
||||
#21=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(2.E-04),#1,'d','');"
|
||||
),
|
||||
"#1",
|
||||
"#20,#21",
|
||||
);
|
||||
assert_eq!(c.uncertainty, Some(2e-4 * (1e-3 * 1000.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncertainty_zero_negative_or_infinite_is_none() {
|
||||
for v in ["0.", "-1.E-04", "1.E300"] {
|
||||
// In exametres (1e21 mm): 0, negative, and 1e300 * 1e21 mm, which is infinite.
|
||||
let c = ctx(
|
||||
&format!(
|
||||
"{MM}#12=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.EXA.,.METRE.));\
|
||||
#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({v}),#12,'d','');"
|
||||
),
|
||||
"#1",
|
||||
"#20",
|
||||
);
|
||||
assert_eq!(c.uncertainty, None, "{v}");
|
||||
}
|
||||
// A bad one after a good one clears it (the last one wins).
|
||||
let c = ctx(
|
||||
&format!(
|
||||
"{MM}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#1,'d','');\
|
||||
#21=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.),#1,'d','');"
|
||||
),
|
||||
"#1",
|
||||
"#20,#21",
|
||||
);
|
||||
assert_eq!(c.uncertainty, None);
|
||||
// One without a readable value, or a dangling reference, is skipped.
|
||||
let c = ctx(
|
||||
&format!(
|
||||
"{MM}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#1,'d','');\
|
||||
#21=UNCERTAINTY_MEASURE_WITH_UNIT($,#1,'d','');"
|
||||
),
|
||||
"#1",
|
||||
"#20,#21,#99",
|
||||
);
|
||||
assert_eq!(c.uncertainty, Some(1e-4 * (1e-3 * 1000.0)));
|
||||
}
|
||||
}
|
||||
+644
-1
@@ -1,4 +1,5 @@
|
||||
//! The Part 21 parser over a STEP corpus: every file parses.
|
||||
//! The Part 21 parser over a STEP corpus: every file parses, the units of its representation
|
||||
//! contexts, and its Part 42 curves and surfaces.
|
||||
//!
|
||||
//! Ignored by default; the corpus is not in the repository. Run it with the directory of
|
||||
//! `.stp` / `.step` files (read in place, recursively) in `SOLIDCORE_STEP_CORPUS`:
|
||||
@@ -13,6 +14,31 @@
|
||||
//! caller holding the file's bytes would. The test fails when any file does not parse. The
|
||||
//! STEP files committed to the repository are parsed by `repository_files_parse`, which always
|
||||
//! runs.
|
||||
//!
|
||||
//! `corpus_units` (ignored too, same variable and command with `corpus_units` as the filter)
|
||||
//! reads every geometric representation context with assigned units in each file and prints
|
||||
//! one line per file and a table of how often each length and plane angle unit occurs, and
|
||||
//! how many contexts state an uncertainty. It fails when a file does not parse. Files with no
|
||||
//! context whose length unit can be read are listed and counted, not failed: a file written
|
||||
//! with Part 21 short names (`SUNT`, `LNGUNT`, `GMRPCN`, …) or one without geometry has none.
|
||||
//!
|
||||
//! `corpus_geometry` (ignored too, same variable, `corpus_geometry` as the filter) reads every
|
||||
//! curve and surface entity of each file with `step::read::Reader`, in the units of the file's
|
||||
//! first geometric context: curves in model space with `curve`, the curves of a parameter space
|
||||
//! through their `PCURVE` with `pcurve`, surfaces with `surface`. It prints one line per file
|
||||
//! and, per group and entity kind, how many were read and how many refused, with the reasons.
|
||||
//! It also lists the entities where a check the record no longer makes (a direction shorter than
|
||||
//! 1e-12, unit factors equal within 1e-12) would have decided otherwise. It fails when a file
|
||||
//! does not parse; refusals are counted, not failed.
|
||||
//!
|
||||
//! `corpus_product` (ignored too, same variable, `corpus_product` as the filter) reads the
|
||||
//! product structure of each file with `step::product::read` and expands its assembly tree. It
|
||||
//! prints one line per file and the files with assemblies, and checks the structure against the
|
||||
//! file's own entity counts: products, definitions, occurrences, body items (every solid the
|
||||
//! file has is an item of a representation the structure holds), relationships with a
|
||||
//! transformation and styled items must all be there; a mapped item it does not hold must be
|
||||
//! listed only by representations it does not reach (counted by the kind that lists them). It
|
||||
//! fails when a file does not parse, is refused, or a count differs.
|
||||
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
@@ -21,9 +47,11 @@
|
||||
clippy::indexing_slicing
|
||||
)] // test
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use solidcore_record::step::p21;
|
||||
use solidcore_record::step::units::{self, UnitKind};
|
||||
|
||||
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
@@ -109,6 +137,116 @@ fn corpus_parses() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A length factor's name (millimetres per unit).
|
||||
fn length_name(f: f64) -> String {
|
||||
match f {
|
||||
1.0 => "mm".into(),
|
||||
25.4 => "inch".into(),
|
||||
1000.0 => "m".into(),
|
||||
10.0 => "cm".into(),
|
||||
304.8 => "foot".into(),
|
||||
_ => format!("{f} mm"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A plane angle factor's name (radians per unit). A degree is the number the file writes:
|
||||
/// `degree` when it is π/180 to 12 digits or more, else the written factor, so a file that
|
||||
/// rounds π/180 shorter (`0.0174532925`) shows in the table as what it is.
|
||||
fn angle_name(f: f64) -> String {
|
||||
if f == 1.0 {
|
||||
"rad".into()
|
||||
} else if (f - std::f64::consts::PI / 180.0).abs() <= 1e-14 {
|
||||
"degree".into()
|
||||
} else {
|
||||
format!("{f} rad")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs a STEP corpus in SOLIDCORE_STEP_CORPUS"]
|
||||
fn corpus_units() {
|
||||
let Some(dir) = std::env::var_os("SOLIDCORE_STEP_CORPUS") else {
|
||||
eprintln!("SOLIDCORE_STEP_CORPUS is not set");
|
||||
return;
|
||||
};
|
||||
let root = Path::new(&dir);
|
||||
let mut paths = Vec::new();
|
||||
files(root, &mut paths);
|
||||
assert!(!paths.is_empty(), "no STEP files under {dir:?}");
|
||||
let mut failures = Vec::new();
|
||||
let mut unread = Vec::new();
|
||||
let mut lengths: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut angles: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let (mut contexts, mut with_uncertainty, mut files_read) = (0usize, 0usize, 0usize);
|
||||
for path in &paths {
|
||||
let name = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.display()
|
||||
.to_string();
|
||||
let ex = match std::fs::read(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|b| p21::parse(&String::from_utf8_lossy(&b)).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(ex) => ex,
|
||||
Err(e) => {
|
||||
println!("{name}: ERROR {e}");
|
||||
failures.push(format!("{name}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut here = Vec::new();
|
||||
let mut readable = false;
|
||||
for (&id, e) in &ex.entities {
|
||||
if !(e.has("GEOMETRIC_REPRESENTATION_CONTEXT") && e.has("GLOBAL_UNIT_ASSIGNED_CONTEXT"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Whether the context names a length unit that reads as a factor, rather than
|
||||
// falling back to millimetres.
|
||||
readable |= e
|
||||
.record("GLOBAL_UNIT_ASSIGNED_CONTEXT")
|
||||
.and_then(<[p21::Param]>::first)
|
||||
.and_then(p21::Param::as_list)
|
||||
.is_some_and(|l| {
|
||||
l.iter().filter_map(p21::Param::as_ref_id).any(|u| {
|
||||
matches!(units::unit_factor(&ex, u),
|
||||
Some((UnitKind::Length, f)) if f.is_finite() && f > 0.0)
|
||||
})
|
||||
});
|
||||
let c = units::context(&ex, id);
|
||||
contexts += 1;
|
||||
with_uncertainty += usize::from(c.uncertainty.is_some());
|
||||
let (l, a) = (length_name(c.units.len), angle_name(c.units.ang));
|
||||
*lengths.entry(l.clone()).or_default() += 1;
|
||||
*angles.entry(a.clone()).or_default() += 1;
|
||||
here.push(format!("#{id} {l}/{a} uncertainty {:?}", c.uncertainty));
|
||||
}
|
||||
println!("{name}: {} contexts: {}", here.len(), here.join(", "));
|
||||
if readable {
|
||||
files_read += 1;
|
||||
} else {
|
||||
println!("{name}: no context with a readable length unit");
|
||||
unread.push(name);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{files_read} of {} files have a context with a readable length unit; {contexts} contexts, {with_uncertainty} with an uncertainty",
|
||||
paths.len()
|
||||
);
|
||||
println!("length units (contexts): {lengths:?}");
|
||||
println!("plane angle units (contexts): {angles:?}");
|
||||
println!(
|
||||
"no context with a readable length unit: {} files {unread:?}",
|
||||
unread.len()
|
||||
);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"files that did not parse:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// The STEP files in the repository parse to every instance they hold, each id once: a parser
|
||||
/// that dropped or merged instances would change the counts (the instance lines in the files).
|
||||
#[test]
|
||||
@@ -130,3 +268,508 @@ fn repository_files_parse() {
|
||||
assert!(ex.duplicates.is_empty(), "{path}: {:?}", ex.duplicates);
|
||||
}
|
||||
}
|
||||
|
||||
/// Curve entity names (simple instances) the corpus count looks at: every one the reader maps,
|
||||
/// and the other ISO 10303-42 curves a file may hold, which it refuses.
|
||||
const CURVES: &[&str] = &[
|
||||
"B_SPLINE_CURVE",
|
||||
"B_SPLINE_CURVE_WITH_KNOTS",
|
||||
"BEZIER_CURVE",
|
||||
"BOUNDED_SURFACE_CURVE",
|
||||
"CIRCLE",
|
||||
"CLOTHOID",
|
||||
"COMPOSITE_CURVE",
|
||||
"COMPOSITE_CURVE_ON_SURFACE",
|
||||
"CURVE_REPLICA",
|
||||
"ELLIPSE",
|
||||
"HYPERBOLA",
|
||||
"INTERSECTION_CURVE",
|
||||
"LINE",
|
||||
"OFFSET_CURVE_2D",
|
||||
"OFFSET_CURVE_3D",
|
||||
"PARABOLA",
|
||||
"POLYLINE",
|
||||
"QUASI_UNIFORM_CURVE",
|
||||
"SEAM_CURVE",
|
||||
"SURFACE_CURVE",
|
||||
"TRIMMED_CURVE",
|
||||
"UNIFORM_CURVE",
|
||||
];
|
||||
|
||||
/// Surface entity names, likewise.
|
||||
const SURFACES: &[&str] = &[
|
||||
"B_SPLINE_SURFACE",
|
||||
"B_SPLINE_SURFACE_WITH_KNOTS",
|
||||
"BEZIER_SURFACE",
|
||||
"CONICAL_SURFACE",
|
||||
"CURVE_BOUNDED_SURFACE",
|
||||
"CYLINDRICAL_SURFACE",
|
||||
"DEGENERATE_TOROIDAL_SURFACE",
|
||||
"OFFSET_SURFACE",
|
||||
"ORIENTED_SURFACE",
|
||||
"PLANE",
|
||||
"QUASI_UNIFORM_SURFACE",
|
||||
"RECTANGULAR_COMPOSITE_SURFACE",
|
||||
"RECTANGULAR_TRIMMED_SURFACE",
|
||||
"SPHERICAL_SURFACE",
|
||||
"SURFACE_OF_LINEAR_EXTRUSION",
|
||||
"SURFACE_OF_REVOLUTION",
|
||||
"SURFACE_REPLICA",
|
||||
"TOROIDAL_SURFACE",
|
||||
"UNIFORM_SURFACE",
|
||||
];
|
||||
|
||||
/// Whether `e` is a curve or a surface for the count, and its kind: the entity name, or for a
|
||||
/// complex instance `(B_SPLINE_CURVE …)` with `rational` when it is.
|
||||
fn geometry_kind(e: &p21::Entity) -> Option<(bool, String)> {
|
||||
if e.is_complex() {
|
||||
let rational = if e.has("RATIONAL_B_SPLINE_CURVE") || e.has("RATIONAL_B_SPLINE_SURFACE") {
|
||||
" rational"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let is_curve =
|
||||
e.has("B_SPLINE_CURVE") || e.records.iter().any(|r| CURVES.contains(&r.name.as_str()));
|
||||
let is_surface = e.has("B_SPLINE_SURFACE")
|
||||
|| e.records
|
||||
.iter()
|
||||
.any(|r| SURFACES.contains(&r.name.as_str()));
|
||||
let names: Vec<&str> = e
|
||||
.records
|
||||
.iter()
|
||||
.map(|r| r.name.as_str())
|
||||
.filter(|n| CURVES.contains(n) || SURFACES.contains(n))
|
||||
.collect();
|
||||
let kind = format!("({}){rational}", names.join(" "));
|
||||
return match (is_curve, is_surface) {
|
||||
(true, false) => Some((true, kind)),
|
||||
(false, true) => Some((false, kind)),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let n = e.name();
|
||||
if CURVES.contains(&n) {
|
||||
Some((true, n.to_string()))
|
||||
} else if SURFACES.contains(&n) {
|
||||
Some((false, n.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// An error message with its entity ids replaced by `#n`, so equal reasons count together.
|
||||
fn reason(e: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut chars = e.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
out.push(c);
|
||||
if c == '#' && chars.peek().is_some_and(char::is_ascii_digit) {
|
||||
out.push('n');
|
||||
while chars.peek().is_some_and(char::is_ascii_digit) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The curves of a file that lie in a parameter space: the items of its
|
||||
/// `DEFINITIONAL_REPRESENTATION`s and the curves they refer to (a 2D trimmed curve's basis …).
|
||||
fn curves_2d(ex: &p21::Exchange) -> std::collections::BTreeSet<u64> {
|
||||
fn refs(p: &p21::Param, out: &mut Vec<u64>) {
|
||||
match p {
|
||||
p21::Param::Ref(r) => out.push(*r),
|
||||
p21::Param::List(l) | p21::Param::Typed(_, l) => l.iter().for_each(|x| refs(x, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut stack = Vec::new();
|
||||
for e in ex.entities.values() {
|
||||
if e.name() == "DEFINITIONAL_REPRESENTATION"
|
||||
&& let Some(items) = e.params().get(1)
|
||||
{
|
||||
refs(items, &mut stack);
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
while let Some(id) = stack.pop() {
|
||||
let Some(e) = ex.get(id) else { continue };
|
||||
if !matches!(geometry_kind(e), Some((true, _))) || !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
for r in &e.records {
|
||||
r.params.iter().for_each(|p| refs(p, &mut stack));
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// Read or refused, by reason.
|
||||
#[derive(Default)]
|
||||
struct Tally {
|
||||
read: usize,
|
||||
refused: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
fn add<T>(&mut self, r: &Result<T, String>) {
|
||||
match r {
|
||||
Ok(_) => self.read += 1,
|
||||
Err(e) => *self.refused.entry(reason(e)).or_default() += 1,
|
||||
}
|
||||
}
|
||||
fn refused(&self) -> usize {
|
||||
self.refused.values().sum()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs a STEP corpus in SOLIDCORE_STEP_CORPUS"]
|
||||
fn corpus_geometry() {
|
||||
use solidcore_record::step::read::Reader;
|
||||
let Some(dir) = std::env::var_os("SOLIDCORE_STEP_CORPUS") else {
|
||||
eprintln!("SOLIDCORE_STEP_CORPUS is not set");
|
||||
return;
|
||||
};
|
||||
let root = Path::new(&dir);
|
||||
let mut paths = Vec::new();
|
||||
files(root, &mut paths);
|
||||
assert!(!paths.is_empty(), "no STEP files under {dir:?}");
|
||||
let mut failures = Vec::new();
|
||||
// (group, kind) -> tally, groups "curve", "surface", "pcurve" (by its 2D curve's kind).
|
||||
let mut kinds: BTreeMap<(&str, String), Tally> = BTreeMap::new();
|
||||
let mut mixed_units = Vec::new();
|
||||
let mut removed = Vec::new();
|
||||
for path in &paths {
|
||||
let name = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.display()
|
||||
.to_string();
|
||||
let ex = match std::fs::read(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|b| p21::parse(&String::from_utf8_lossy(&b)).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(ex) => ex,
|
||||
Err(e) => {
|
||||
println!("{name}: ERROR {e}");
|
||||
failures.push(format!("{name}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// The units of the file's first geometric context (the topology reader, which comes
|
||||
// later, reads each representation in its own context).
|
||||
let contexts: Vec<units::Units> = ex
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|(_, e)| {
|
||||
e.has("GEOMETRIC_REPRESENTATION_CONTEXT") && e.has("GLOBAL_UNIT_ASSIGNED_CONTEXT")
|
||||
})
|
||||
.map(|(&id, _)| units::context(&ex, id).units)
|
||||
.collect();
|
||||
let u = contexts.first().copied().unwrap_or_default();
|
||||
if contexts.iter().any(|c| *c != u) {
|
||||
mixed_units.push(name.clone());
|
||||
}
|
||||
// Where a removed check would have decided otherwise: SolidCraft refused a direction
|
||||
// whose length is at most 1e-12 (here: only one that is exactly zero), and took the two
|
||||
// unit factors of a conic's parameter space as equal within 1e-12 relative (here: only
|
||||
// when equal).
|
||||
for (&id, e) in &ex.entities {
|
||||
if e.name() == "DIRECTION"
|
||||
&& let Some(r) = e.params().get(1).and_then(p21::Param::as_list)
|
||||
{
|
||||
let sq: f64 = r.iter().filter_map(p21::Param::as_f64).map(|x| x * x).sum();
|
||||
if sq > 0.0 && sq <= 1e-24 {
|
||||
removed.push(format!("{name}: #{id} a direction of length at most 1e-12"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if u.len != u.ang && (u.len - u.ang).abs() <= 1e-12 * u.len.abs().max(u.ang.abs()) {
|
||||
removed.push(format!(
|
||||
"{name}: length and angle factors equal within 1e-12"
|
||||
));
|
||||
}
|
||||
let flat = curves_2d(&ex);
|
||||
let mut reader = Reader::new(&ex, u);
|
||||
let mut here = (Tally::default(), Tally::default(), Tally::default());
|
||||
for (&id, e) in &ex.entities {
|
||||
if e.name() == "PCURVE" {
|
||||
let kind = e
|
||||
.params()
|
||||
.get(2)
|
||||
.and_then(p21::Param::as_ref_id)
|
||||
.and_then(|r| ex.get(r))
|
||||
.and_then(|r| r.params().get(1)?.as_list()?.first()?.as_ref_id())
|
||||
.and_then(|c| ex.get(c))
|
||||
.and_then(geometry_kind)
|
||||
.map_or_else(|| "?".to_string(), |k| k.1);
|
||||
let r = reader.pcurve(id);
|
||||
here.2.add(&r);
|
||||
kinds.entry(("pcurve", kind)).or_default().add(&r);
|
||||
continue;
|
||||
}
|
||||
let Some((is_curve, kind)) = geometry_kind(e) else {
|
||||
continue;
|
||||
};
|
||||
if is_curve {
|
||||
if flat.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let r = reader.curve(id);
|
||||
here.0.add(&r);
|
||||
kinds.entry(("curve", kind)).or_default().add(&r);
|
||||
} else {
|
||||
let r = reader.surface(id);
|
||||
here.1.add(&r);
|
||||
kinds.entry(("surface", kind)).or_default().add(&r);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{name}: curves {} read, {} refused; surfaces {} read, {} refused; pcurves {} read, {} refused",
|
||||
here.0.read,
|
||||
here.0.refused(),
|
||||
here.1.read,
|
||||
here.1.refused(),
|
||||
here.2.read,
|
||||
here.2.refused()
|
||||
);
|
||||
}
|
||||
println!("by kind (group, kind: read / refused, with reasons):");
|
||||
let mut totals: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
|
||||
for ((group, kind), t) in &kinds {
|
||||
println!(" {group} {kind}: {} / {}", t.read, t.refused());
|
||||
for (why, n) in &t.refused {
|
||||
println!(" {n} x {why}");
|
||||
}
|
||||
let g = totals.entry(group).or_default();
|
||||
g.0 += t.read;
|
||||
g.1 += t.refused();
|
||||
}
|
||||
println!("totals (read / refused): {totals:?}");
|
||||
println!(
|
||||
"files whose contexts have different units (read in the first): {} {mixed_units:?}",
|
||||
mixed_units.len()
|
||||
);
|
||||
println!(
|
||||
"entities a removed check would have decided otherwise: {} {removed:?}",
|
||||
removed.len()
|
||||
);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"files that did not parse:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// The entity types counted by `corpus_product` against what the product structure holds.
|
||||
const BODY_KINDS: [&str; 4] = [
|
||||
"MANIFOLD_SOLID_BREP",
|
||||
"BREP_WITH_VOIDS",
|
||||
"FACETED_BREP",
|
||||
"SHELL_BASED_SURFACE_MODEL",
|
||||
];
|
||||
const OCCURRENCE_KINDS: [&str; 3] = [
|
||||
"NEXT_ASSEMBLY_USAGE_OCCURRENCE",
|
||||
"ASSEMBLY_COMPONENT_USAGE",
|
||||
"SPECIFIED_HIGHER_USAGE_OCCURRENCE",
|
||||
];
|
||||
const STYLED_KINDS: [&str; 3] = [
|
||||
"STYLED_ITEM",
|
||||
"OVER_RIDING_STYLED_ITEM",
|
||||
"CONTEXT_DEPENDENT_OVER_RIDING_STYLED_ITEM",
|
||||
];
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs a STEP corpus in SOLIDCORE_STEP_CORPUS"]
|
||||
fn corpus_product() {
|
||||
use solidcore_record::step::product::{self, ItemKind};
|
||||
let Some(dir) = std::env::var_os("SOLIDCORE_STEP_CORPUS") else {
|
||||
eprintln!("SOLIDCORE_STEP_CORPUS is not set");
|
||||
return;
|
||||
};
|
||||
let root = Path::new(&dir);
|
||||
let mut paths = Vec::new();
|
||||
files(root, &mut paths);
|
||||
assert!(!paths.is_empty(), "no STEP files under {dir:?}");
|
||||
let mut failures = Vec::new();
|
||||
let mut assemblies = Vec::new();
|
||||
// Totals: name -> (held, census).
|
||||
let mut totals: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
|
||||
let mut coloured_by_kind: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut instances_total = 0usize;
|
||||
let mut unreached_mapped: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for path in &paths {
|
||||
let name = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.display()
|
||||
.to_string();
|
||||
let ex = match std::fs::read(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|b| p21::parse(&String::from_utf8_lossy(&b)).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(ex) => ex,
|
||||
Err(e) => {
|
||||
println!("{name}: ERROR {e}");
|
||||
failures.push(format!("{name}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let census = |names: &[&str]| {
|
||||
ex.entities
|
||||
.values()
|
||||
.filter(|e| names.contains(&e.name()))
|
||||
.count()
|
||||
};
|
||||
let s = match product::read(&ex) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
println!("{name}: REFUSED {e}");
|
||||
failures.push(format!("{name}: refused: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut bodies = std::collections::BTreeSet::new();
|
||||
let mut mapped = std::collections::BTreeSet::new();
|
||||
for r in &s.representations {
|
||||
for i in &r.items {
|
||||
match i.kind {
|
||||
ItemKind::Body(_) => {
|
||||
bodies.insert(i.entity);
|
||||
}
|
||||
ItemKind::Mapped { .. } => {
|
||||
mapped.insert(i.entity);
|
||||
}
|
||||
ItemKind::Other(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let placed = s
|
||||
.links
|
||||
.iter()
|
||||
.filter(|l| l.transformation.is_some())
|
||||
.count();
|
||||
let coloured: Vec<_> = s
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|st| {
|
||||
!st.curve.is_empty()
|
||||
|| st
|
||||
.surface
|
||||
.iter()
|
||||
.any(|f| f.fill.is_some() || f.rendering.is_some())
|
||||
})
|
||||
.collect();
|
||||
for st in &coloured {
|
||||
let kind = ex.get(st.item).map_or("?", p21::Entity::name);
|
||||
*coloured_by_kind.entry(kind.to_string()).or_default() += 1;
|
||||
}
|
||||
let instances = match s.instances() {
|
||||
Ok(i) => i.len(),
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: instances: {e}"));
|
||||
0
|
||||
}
|
||||
};
|
||||
instances_total += instances;
|
||||
let rows = [
|
||||
("products", s.products.len(), census(&["PRODUCT"])),
|
||||
(
|
||||
"definitions",
|
||||
s.definitions.len(),
|
||||
census(&[
|
||||
"PRODUCT_DEFINITION",
|
||||
"PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS",
|
||||
]),
|
||||
),
|
||||
(
|
||||
"occurrences",
|
||||
s.occurrences.len(),
|
||||
census(&OCCURRENCE_KINDS),
|
||||
),
|
||||
("body items", bodies.len(), census(&BODY_KINDS)),
|
||||
(
|
||||
"placed links",
|
||||
placed,
|
||||
ex.entities
|
||||
.values()
|
||||
.filter(|e| e.has("REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION"))
|
||||
.count(),
|
||||
),
|
||||
("styled items", s.styles.len(), census(&STYLED_KINDS)),
|
||||
];
|
||||
// A mapped item the structure does not hold is listed only by representations it does
|
||||
// not reach (the draughting models of PMI views): checked, and counted apart.
|
||||
let reached: std::collections::BTreeSet<u64> =
|
||||
s.representations.iter().map(|r| r.entity).collect();
|
||||
let mut listers: BTreeMap<u64, Vec<(u64, &str)>> = BTreeMap::new();
|
||||
for (&id, e) in &ex.entities {
|
||||
for r in &e.records {
|
||||
for p in &r.params {
|
||||
for m in p
|
||||
.as_list()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(p21::Param::as_ref_id)
|
||||
{
|
||||
if ex.get(m).is_some_and(|m| m.name() == "MAPPED_ITEM") {
|
||||
listers.entry(m).or_default().push((id, e.name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let all_mapped = census(&["MAPPED_ITEM"]);
|
||||
for (&id, e) in &ex.entities {
|
||||
if e.name() != "MAPPED_ITEM" || mapped.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let by = listers.get(&id).cloned().unwrap_or_default();
|
||||
if by.iter().any(|(l, _)| reached.contains(l)) {
|
||||
failures.push(format!(
|
||||
"{name}: mapped item #{id} of a reached representation not held"
|
||||
));
|
||||
}
|
||||
for (_, kind) in by {
|
||||
*unreached_mapped.entry(kind.to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let t = totals.entry("mapped items").or_default();
|
||||
t.0 += mapped.len();
|
||||
t.1 += all_mapped;
|
||||
let mut line = format!("{name}: mapped items {}/{all_mapped};", mapped.len());
|
||||
for (what, held, n) in rows {
|
||||
let _ = std::fmt::Write::write_fmt(&mut line, format_args!(" {what} {held}/{n};"));
|
||||
let t = totals.entry(what).or_default();
|
||||
t.0 += held;
|
||||
t.1 += n;
|
||||
if held != n {
|
||||
failures.push(format!("{name}: {what}: {held} held, {n} in the file"));
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{line} coloured items {}; roots {}; instances {instances}",
|
||||
coloured.len(),
|
||||
s.roots.len()
|
||||
);
|
||||
if !s.occurrences.is_empty() {
|
||||
assemblies.push(format!(
|
||||
"{name}: {} definitions, {} occurrences, {} roots, {instances} instances, {} mapped items",
|
||||
s.definitions.len(),
|
||||
s.occurrences.len(),
|
||||
s.roots.len(),
|
||||
mapped.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
println!("totals (held / in the files): {totals:?}");
|
||||
println!("coloured items by kind: {coloured_by_kind:?}");
|
||||
println!("mapped items not held, by the kind of entity listing them: {unreached_mapped:?}");
|
||||
println!("instances: {instances_total}");
|
||||
println!("files with assemblies: {}", assemblies.len());
|
||||
for a in &assemblies {
|
||||
println!(" {a}");
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
+14
-4
@@ -2298,16 +2298,26 @@ fn plan_face(
|
||||
}
|
||||
// Whatever the layout, no triangle leaves the surface by more than the tolerance.
|
||||
if !planar {
|
||||
if sag::refine(s, tol, reach, &fixed, &mut all, &mut triangles, MAX_REFINE)
|
||||
.map_err(|e| sag_stop(e, all.len()))?
|
||||
// The kernel's classifier is asked once per node over the three passes.
|
||||
let mut poles = sag::Poles::new(s, reach);
|
||||
if sag::refine(
|
||||
s,
|
||||
tol,
|
||||
&mut poles,
|
||||
&fixed,
|
||||
&mut all,
|
||||
&mut triangles,
|
||||
MAX_REFINE,
|
||||
)
|
||||
.map_err(|e| sag_stop(e, all.len()))?
|
||||
{
|
||||
caps.push(Cap::Refine);
|
||||
}
|
||||
sag::flip(s, tol, reach, &fixed, &all, &mut triangles)
|
||||
sag::flip(s, tol, &mut poles, &fixed, &all, &mut triangles)
|
||||
.map_err(|e| sag_stop(e, all.len()))?;
|
||||
// A face a bound limited is named with the sag its triangles reach.
|
||||
if !caps.is_empty() {
|
||||
let (sag, unmeasured) = sag::worst(s, reach, &fixed, &all, &triangles)
|
||||
let (sag, unmeasured) = sag::worst(s, &mut poles, &fixed, &all, &triangles)
|
||||
.map_err(|e| sag_stop(e, all.len()))?;
|
||||
stats.over_tolerance.push(OverTolerance {
|
||||
face: fi as usize,
|
||||
|
||||
+15
-2
@@ -379,7 +379,14 @@ fn the_flips_name_a_middle_the_surface_does_not_evaluate_at() {
|
||||
let uv = vec![[0.0, 0.0], [0.75, -0.5], [0.75, 0.5], [0.0, 1.0]];
|
||||
let fixed: Vec<Vec3> = uv.iter().map(|q| point(&s, *q)).collect();
|
||||
let mut tris = vec![[0, 1, 2], [0, 2, 3]];
|
||||
match super::sag::flip(&s, 0.01, 1e-9, &fixed, &uv, &mut tris) {
|
||||
match super::sag::flip(
|
||||
&s,
|
||||
0.01,
|
||||
&mut super::sag::Poles::new(&s, 1e-9),
|
||||
&fixed,
|
||||
&uv,
|
||||
&mut tris,
|
||||
) {
|
||||
Err(super::sag::Stop::NoValue(NoValue(q))) => assert_eq!(q[0], 0.5, "{q:?}"),
|
||||
other => panic!("{other:?}, flipped to {tris:?}"),
|
||||
}
|
||||
@@ -544,7 +551,13 @@ fn a_step_inwards_the_surface_has_no_point_at_refuses_the_face() {
|
||||
fn the_worst_sag_is_measured_where_the_surface_evaluates() {
|
||||
let s = cylinder_without(0.5);
|
||||
let uv = [[0.0, 0.0], [0.75, -0.5], [0.75, 0.5]];
|
||||
let got = super::sag::worst(&s, 1e-9, &[], &uv, &[[0, 1, 2]]);
|
||||
let got = super::sag::worst(
|
||||
&s,
|
||||
&mut super::sag::Poles::new(&s, 1e-9),
|
||||
&[],
|
||||
&uv,
|
||||
&[[0, 1, 2]],
|
||||
);
|
||||
// Measured at its three sides' middles; its middle, unmeasured, is counted, so the face is
|
||||
// never taken as within its tolerance over it.
|
||||
assert!(
|
||||
|
||||
+197
-81
@@ -18,8 +18,9 @@ use super::{NoValue, Unclassified};
|
||||
|
||||
/// Split the triangles `tris` of a face (indices into `uv`; the first `fixed.len()` points sit
|
||||
/// at `fixed`, the rest on the surface at their parameters) until none of them lies farther
|
||||
/// than `tol` from the surface `s`, adding at most `max_points` points to `uv`. `reach`: how near
|
||||
/// a pole a point is taken as one (see [`solidcore::eval::uv::degenerate_within`]).
|
||||
/// than `tol` from the surface `s`, adding at most `max_points` points to `uv`. `poles`: the
|
||||
/// classifier of the face's nodes, shared by [`refine`], [`flip`] and [`worst`] (how near a pole a
|
||||
/// point is taken as one: see [`solidcore::eval::uv::degenerate_within`]).
|
||||
///
|
||||
/// A point the surface does not evaluate at (a node, or a middle the check needs) is the
|
||||
/// evaluator refusing the face: the error names it, and the caller leaves the face out whole.
|
||||
@@ -50,7 +51,7 @@ impl From<Unclassified> for Stop {
|
||||
pub(crate) fn refine(
|
||||
s: &EvalSurface,
|
||||
tol: f64,
|
||||
reach: f64,
|
||||
poles: &mut Poles,
|
||||
fixed: &[Vec3],
|
||||
uv: &mut Vec<[f64; 2]>,
|
||||
tris: &mut Vec<[u32; 3]>,
|
||||
@@ -64,7 +65,6 @@ pub(crate) fn refine(
|
||||
.map(|d| kernel::record_vec3(d.0))
|
||||
.ok_or(NoValue(q))
|
||||
};
|
||||
let mean = |qs: &[[f64; 2]]| mean(s, reach, qs);
|
||||
// How far the flat point `c` lies from the surface, measured along the normal at the surface
|
||||
// point of the same parameters: those parameters need not be the nearest (a straight line in
|
||||
// the parameters is no geodesic near a pole), and only the departure across the surface
|
||||
@@ -103,10 +103,7 @@ pub(crate) fn refine(
|
||||
continue;
|
||||
};
|
||||
let p = |i: u32| pos.get(i as usize).copied();
|
||||
let q = |i: u32| uv.get(i as usize).copied();
|
||||
let (Some(pa), Some(pb), Some(pc), Some(qa), Some(qb), Some(qc)) =
|
||||
(p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2]))
|
||||
else {
|
||||
let (Some(pa), Some(pb), Some(pc)) = (p(t[0]), p(t[1]), p(t[2])) else {
|
||||
continue;
|
||||
};
|
||||
// Sides inside the face (two triangles on them), long enough to split, with their sag.
|
||||
@@ -117,7 +114,7 @@ pub(crate) fn refine(
|
||||
if sides.get(&e).is_none_or(|ts| ts.len() != 2) {
|
||||
continue;
|
||||
}
|
||||
let (Some(xa), Some(xb), Some(ua), Some(ub)) = (p(a), p(b), q(a), q(b)) else {
|
||||
let (Some(xa), Some(xb)) = (p(a), p(b)) else {
|
||||
continue;
|
||||
};
|
||||
let len = xa.dist(xb);
|
||||
@@ -127,7 +124,7 @@ pub(crate) fn refine(
|
||||
let sag = if let Some(&sag) = sag_of.get(&e) {
|
||||
sag
|
||||
} else {
|
||||
let sag = off(mean(&[ua, ub])?, (xa + xb) * 0.5)?;
|
||||
let sag = off(poles.mean(uv, &[a, b])?, (xa + xb) * 0.5)?;
|
||||
sag_of.insert(e, sag);
|
||||
sag
|
||||
};
|
||||
@@ -138,7 +135,7 @@ pub(crate) fn refine(
|
||||
// inside the face divides by four. A triangle whose middle is off splits its side inside
|
||||
// the face that sags most, while that side still contributes; what is left comes from
|
||||
// its boundary sides, which the edges' own sampling keeps within the tolerance.
|
||||
let centroid = mean(&[qa, qb, qc])?;
|
||||
let centroid = poles.mean(uv, &t)?;
|
||||
let worst = inner.iter().max_by(|x, y| x.0.total_cmp(&y.0)).copied();
|
||||
let split = match worst {
|
||||
Some((sag, _, e)) if sag > tol => e,
|
||||
@@ -174,14 +171,11 @@ pub(crate) fn refine(
|
||||
if from != ti {
|
||||
queue.push(ti);
|
||||
}
|
||||
let (Some(ua), Some(ub)) = (q(split.0), q(split.1)) else {
|
||||
continue;
|
||||
};
|
||||
// A split is wanted and there is no room for its point.
|
||||
if uv.len() >= limit {
|
||||
return Ok(true);
|
||||
}
|
||||
let m = mean(&[ua, ub])?;
|
||||
let m = poles.mean(uv, &[split.0, split.1])?;
|
||||
let pm = at(m)?;
|
||||
// Proven: `plan_face` checks its nodes and the MAX_REFINE more it lets this add are at most
|
||||
// MAX_FACE_NODES, so the index fits a u32.
|
||||
@@ -194,29 +188,82 @@ pub(crate) fn refine(
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// The mean of parameter points, as the surface point between them: at a pole the parameter
|
||||
/// along the degenerate direction is free (the pole copy carries a neighbour's), so there the
|
||||
/// mean takes it from the other points alone.
|
||||
/// A point whose poles the kernel's classifier cannot tell is the error.
|
||||
fn mean(s: &EvalSurface, reach: f64, qs: &[[f64; 2]]) -> Result<[f64; 2], Unclassified> {
|
||||
let mut out = [0.0; 2];
|
||||
// Proven: `d` < 2 enumerates `out` and indexes the `[f64; 2]` parameters `qs`.
|
||||
#[allow(clippy::indexing_slicing)]
|
||||
for (d, slot) in out.iter_mut().enumerate() {
|
||||
let mut free: Vec<f64> = Vec::new();
|
||||
for q in qs {
|
||||
if !super::pole_at(s, *q, d, reach)? {
|
||||
free.push(q[d]);
|
||||
/// Which parameter directions the surface is collapsed along at each node of a face's
|
||||
/// triangulation, as the kernel's classifier says (within `reach`): a property of the node's
|
||||
/// parameters, so it is asked once per node however many triangles and rounds look at it.
|
||||
pub(crate) struct Poles<'a> {
|
||||
s: &'a EvalSurface,
|
||||
reach: f64,
|
||||
/// Per node index; `None` until asked.
|
||||
flags: Vec<Option<[bool; 2]>>,
|
||||
/// Calls of the kernel's classifier made, for the tests.
|
||||
#[cfg(test)]
|
||||
asked: usize,
|
||||
}
|
||||
|
||||
impl<'a> Poles<'a> {
|
||||
pub(crate) fn new(s: &'a EvalSurface, reach: f64) -> Self {
|
||||
Poles {
|
||||
s,
|
||||
reach,
|
||||
flags: Vec::new(),
|
||||
#[cfg(test)]
|
||||
asked: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether each direction is collapsed at node `i`, whose parameters are `q`.
|
||||
fn at(&mut self, i: u32, q: [f64; 2]) -> Result<[bool; 2], Unclassified> {
|
||||
let i = i as usize;
|
||||
if let Some(Some(f)) = self.flags.get(i) {
|
||||
return Ok(*f);
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.asked += 2;
|
||||
}
|
||||
let f = [
|
||||
super::pole_at(self.s, q, 0, self.reach)?,
|
||||
super::pole_at(self.s, q, 1, self.reach)?,
|
||||
];
|
||||
if self.flags.len() <= i {
|
||||
self.flags.resize(i + 1, None);
|
||||
}
|
||||
if let Some(slot) = self.flags.get_mut(i) {
|
||||
*slot = Some(f);
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// The mean of nodes' parameters, as the surface point between them: at a pole the
|
||||
/// parameter along the degenerate direction is free (the pole copy carries a neighbour's),
|
||||
/// so there the mean takes it from the other nodes alone. A node whose poles the kernel's
|
||||
/// classifier cannot tell is the error.
|
||||
fn mean(&mut self, uv: &[[f64; 2]], nodes: &[u32]) -> Result<[f64; 2], Unclassified> {
|
||||
let mut qs: Vec<([f64; 2], [bool; 2])> = Vec::new();
|
||||
for &i in nodes {
|
||||
if let Some(&q) = uv.get(i as usize) {
|
||||
qs.push((q, self.at(i, q)?));
|
||||
}
|
||||
}
|
||||
let pick: Vec<f64> = if free.is_empty() {
|
||||
qs.iter().map(|q| q[d]).collect()
|
||||
} else {
|
||||
free
|
||||
};
|
||||
*slot = pick.iter().sum::<f64>() / pick.len().max(1) as f64;
|
||||
let mut out = [0.0; 2];
|
||||
// Proven: `d` < 2 enumerates `out` and indexes the `[f64; 2]` parameters and flags.
|
||||
#[allow(clippy::indexing_slicing)]
|
||||
for (d, slot) in out.iter_mut().enumerate() {
|
||||
let free: Vec<f64> = qs
|
||||
.iter()
|
||||
.filter(|(_, f)| !f[d])
|
||||
.map(|(q, _)| q[d])
|
||||
.collect();
|
||||
let pick: Vec<f64> = if free.is_empty() {
|
||||
qs.iter().map(|(q, _)| q[d]).collect()
|
||||
} else {
|
||||
free
|
||||
};
|
||||
*slot = pick.iter().sum::<f64>() / pick.len().max(1) as f64;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The farthest the triangles `tris` (as for [`refine`]) lie from the surface, measured as
|
||||
@@ -229,7 +276,7 @@ fn mean(s: &EvalSurface, reach: f64, qs: &[[f64; 2]]) -> Result<[f64; 2], Unclas
|
||||
/// classifier cannot tell.
|
||||
pub(crate) fn worst(
|
||||
s: &EvalSurface,
|
||||
reach: f64,
|
||||
poles: &mut Poles,
|
||||
fixed: &[Vec3],
|
||||
uv: &[[f64; 2]],
|
||||
tris: &[[u32; 3]],
|
||||
@@ -245,8 +292,8 @@ pub(crate) fn worst(
|
||||
})
|
||||
.collect();
|
||||
// How far the flat point `c` lies from the surface at the mean of `qs`, as `refine`'s `off`.
|
||||
let off = |qs: &[[f64; 2]], c: Vec3| -> Result<Option<f64>, Unclassified> {
|
||||
let m = mean(s, reach, qs)?;
|
||||
let mut off = |nodes: &[u32], c: Vec3| -> Result<Option<f64>, Unclassified> {
|
||||
let m = poles.mean(uv, nodes)?;
|
||||
let Some((x, su, sv)) = s.derivs(m[0], m[1]).map(kernel::record_triple) else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -259,7 +306,7 @@ pub(crate) fn worst(
|
||||
let (mut worst, mut unmeasured) = (0.0f64, 0usize);
|
||||
for (k, t) in tris.iter().enumerate() {
|
||||
let corner = |i: u32| Some((*uv.get(i as usize)?, *pos.get(i as usize)?));
|
||||
let (Some((qa, pa)), Some((qb, pb)), Some((qc, pc))) =
|
||||
let (Some((_, pa)), Some((_, pb)), Some((_, pc))) =
|
||||
(corner(t[0]), corner(t[1]), corner(t[2]))
|
||||
else {
|
||||
return Err(Stop::Corner(k));
|
||||
@@ -268,16 +315,16 @@ pub(crate) fn worst(
|
||||
// corner they need).
|
||||
let mut measured: Vec<Option<f64>> = Vec::new();
|
||||
measured.push(match (pa, pb, pc) {
|
||||
(Some(a), Some(b), Some(c)) => off(&[qa, qb, qc], (a + b + c) * (1.0 / 3.0))?,
|
||||
(Some(a), Some(b), Some(c)) => off(t, (a + b + c) * (1.0 / 3.0))?,
|
||||
_ => None,
|
||||
});
|
||||
for ((qx, px), (qy, py)) in [
|
||||
((qa, pa), (qb, pb)),
|
||||
((qb, pb), (qc, pc)),
|
||||
((qc, pc), (qa, pa)),
|
||||
for ((nx, px), (ny, py)) in [
|
||||
((t[0], pa), (t[1], pb)),
|
||||
((t[1], pb), (t[2], pc)),
|
||||
((t[2], pc), (t[0], pa)),
|
||||
] {
|
||||
measured.push(match (px, py) {
|
||||
(Some(x), Some(y)) => off(&[qx, qy], (x + y) * 0.5)?,
|
||||
(Some(x), Some(y)) => off(&[nx, ny], (x + y) * 0.5)?,
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
@@ -388,7 +435,7 @@ const MIN_FLIP_ANGLE: f64 = 1e-3;
|
||||
pub(crate) fn flip(
|
||||
s: &EvalSurface,
|
||||
tol: f64,
|
||||
reach: f64,
|
||||
poles: &mut Poles,
|
||||
fixed: &[Vec3],
|
||||
uv: &[[f64; 2]],
|
||||
tris: &mut [[u32; 3]],
|
||||
@@ -396,7 +443,6 @@ pub(crate) fn flip(
|
||||
if !(tol.is_finite() && tol > 0.0) || tris.len() < 2 {
|
||||
return Ok(());
|
||||
}
|
||||
let mean = |qs: &[[f64; 2]]| mean(s, reach, qs);
|
||||
let derivs = |q: [f64; 2]| {
|
||||
s.derivs(q[0], q[1])
|
||||
.map(kernel::record_triple)
|
||||
@@ -441,13 +487,11 @@ pub(crate) fn flip(
|
||||
angle(a, b, c).min(angle(b, c, a)).min(angle(c, a, b))
|
||||
};
|
||||
// How far a facet's normal turns from the surface normal at its middle (radians, 0 to pi/2).
|
||||
let deviation = |t: [u32; 3]| -> Result<f64, Stop> {
|
||||
let (Some(a), Some(b), Some(c), Some(qa), Some(qb), Some(qc)) =
|
||||
(p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2]))
|
||||
else {
|
||||
let deviation = |poles: &mut Poles, t: [u32; 3]| -> Result<f64, Stop> {
|
||||
let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) else {
|
||||
return Ok(0.0);
|
||||
};
|
||||
let m = mean(&[qa, qb, qc])?;
|
||||
let m = poles.mean(uv, &t)?;
|
||||
let facet = (b - a).cross(c - a).normalized();
|
||||
let (_, su, sv) = derivs(m)?;
|
||||
Ok(match (facet, su.cross(sv).normalized()) {
|
||||
@@ -458,20 +502,16 @@ pub(crate) fn flip(
|
||||
};
|
||||
// Whether the facet's normal lies on the side of the surface normal that its (u, v)
|
||||
// orientation `sign` says.
|
||||
let upright = |t: [u32; 3], sign: f64| -> Result<bool, Stop> {
|
||||
let (Some(a), Some(b), Some(c), Some(qa), Some(qb), Some(qc)) =
|
||||
(p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2]))
|
||||
else {
|
||||
let upright = |poles: &mut Poles, t: [u32; 3], sign: f64| -> Result<bool, Stop> {
|
||||
let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let (_, su, sv) = derivs(mean(&[qa, qb, qc])?)?;
|
||||
let (_, su, sv) = derivs(poles.mean(uv, &t)?)?;
|
||||
Ok((b - a).cross(c - a).dot(su.cross(sv)) * sign > 0.0)
|
||||
};
|
||||
let centroid_off = |t: [u32; 3]| -> Result<f64, Stop> {
|
||||
match (p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2])) {
|
||||
(Some(a), Some(b), Some(c), Some(qa), Some(qb), Some(qc)) => {
|
||||
Ok(off(mean(&[qa, qb, qc])?, (a + b + c) * (1.0 / 3.0))?)
|
||||
}
|
||||
let centroid_off = |poles: &mut Poles, t: [u32; 3]| -> Result<f64, Stop> {
|
||||
match (p(t[0]), p(t[1]), p(t[2])) {
|
||||
(Some(a), Some(b), Some(c)) => Ok(off(poles.mean(uv, &t)?, (a + b + c) * (1.0 / 3.0))?),
|
||||
_ => Ok(f64::INFINITY),
|
||||
}
|
||||
};
|
||||
@@ -523,20 +563,20 @@ pub(crate) fn flip(
|
||||
// The pair the other way must lie closer along the surface (its facets turn less from
|
||||
// the surface normal), by more than a degree: a pair within a degree of the surface is
|
||||
// left as the layout made it.
|
||||
let mut cached = |ti: u32, t: [u32; 3]| -> Result<f64, Stop> {
|
||||
let mut cached = |poles: &mut Poles, ti: u32, t: [u32; 3]| -> Result<f64, Stop> {
|
||||
if let Some(d) = dev_of.get(ti as usize).copied().flatten() {
|
||||
Ok(d)
|
||||
} else {
|
||||
let d = deviation(t)?;
|
||||
let d = deviation(poles, t)?;
|
||||
if let Some(slot) = dev_of.get_mut(ti as usize) {
|
||||
*slot = Some(d);
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
};
|
||||
let worse = cached(t1, ta)?.max(cached(t2, tb)?);
|
||||
let worse = cached(poles, t1, ta)?.max(cached(poles, t2, tb)?);
|
||||
let degree = 1f64.to_radians();
|
||||
if worse < degree || deviation(n1)?.max(deviation(n2)?) >= worse - degree {
|
||||
if worse < degree || deviation(poles, n1)?.max(deviation(poles, n2)?) >= worse - degree {
|
||||
continue;
|
||||
}
|
||||
// ... and have area in 3D (three corners on one line of a cone have none, however they
|
||||
@@ -544,21 +584,19 @@ pub(crate) fn flip(
|
||||
if min_angle(n1).min(min_angle(n2)) < MIN_FLIP_ANGLE {
|
||||
continue;
|
||||
}
|
||||
if !(upright(n1, sign)? && upright(n2, sign)?) {
|
||||
if !(upright(poles, n1, sign)? && upright(poles, n2, sign)?) {
|
||||
continue;
|
||||
}
|
||||
let (Some(pa), Some(pb), Some(pc), Some(pd), Some(qa), Some(qb), Some(qc), Some(qd)) =
|
||||
(p(a), p(b), p(c), p(d), q(a), q(b), q(c), q(d))
|
||||
else {
|
||||
let (Some(pa), Some(pb), Some(pc), Some(pd)) = (p(a), p(b), p(c), p(d)) else {
|
||||
continue;
|
||||
};
|
||||
let allowed = tol
|
||||
.max(off(mean(&[qa, qb])?, (pa + pb) * 0.5)?)
|
||||
.max(centroid_off(ta)?)
|
||||
.max(centroid_off(tb)?);
|
||||
if off(mean(&[qc, qd])?, (pc + pd) * 0.5)? > allowed
|
||||
|| centroid_off(n1)? > allowed
|
||||
|| centroid_off(n2)? > allowed
|
||||
.max(off(poles.mean(uv, &[a, b])?, (pa + pb) * 0.5)?)
|
||||
.max(centroid_off(poles, ta)?)
|
||||
.max(centroid_off(poles, tb)?);
|
||||
if off(poles.mean(uv, &[c, d])?, (pc + pd) * 0.5)? > allowed
|
||||
|| centroid_off(poles, n1)? > allowed
|
||||
|| centroid_off(poles, n2)? > allowed
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -607,7 +645,15 @@ mod tests {
|
||||
assert_eq!(fixed.len(), 4);
|
||||
let mut tris = vec![[0, 1, 2], [0, 2, 3]];
|
||||
assert_eq!(
|
||||
refine(&s, 0.01, 1e-9, &fixed, &mut uv, &mut tris, 1000),
|
||||
refine(
|
||||
&s,
|
||||
0.01,
|
||||
&mut Poles::new(&s, 1e-9),
|
||||
&fixed,
|
||||
&mut uv,
|
||||
&mut tris,
|
||||
1000
|
||||
),
|
||||
Err(Stop::NoValue(NoValue([0.5, 0.5])))
|
||||
);
|
||||
assert_eq!(tris, vec![[0, 1, 2], [0, 2, 3]]);
|
||||
@@ -628,11 +674,19 @@ mod tests {
|
||||
assert_eq!(fixed.len(), 4);
|
||||
let mut tris = vec![[0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4]];
|
||||
assert_eq!(
|
||||
refine(&s, 0.01, 1e-9, &fixed, &mut uv, &mut tris, 1000),
|
||||
refine(
|
||||
&s,
|
||||
0.01,
|
||||
&mut Poles::new(&s, 1e-9),
|
||||
&fixed,
|
||||
&mut uv,
|
||||
&mut tris,
|
||||
1000
|
||||
),
|
||||
Err(Stop::NoValue(NoValue([0.5, 0.5])))
|
||||
);
|
||||
assert_eq!(
|
||||
flip(&s, 0.01, 1e-9, &fixed, &uv, &mut tris),
|
||||
flip(&s, 0.01, &mut Poles::new(&s, 1e-9), &fixed, &uv, &mut tris),
|
||||
Err(Stop::NoValue(NoValue([0.5, 0.5])))
|
||||
);
|
||||
}
|
||||
@@ -661,7 +715,7 @@ mod tests {
|
||||
};
|
||||
let uv = [[0.5, -0.1], [0.5, 0.1], [0.6, 0.0]];
|
||||
let side = 3.0 * (1.0 - 0.1f64.cos());
|
||||
let got = worst(&s, 0.0, &[], &uv, &[[0, 1, 2]]);
|
||||
let got = worst(&s, &mut Poles::new(&s, 0.0), &[], &uv, &[[0, 1, 2]]);
|
||||
assert!(
|
||||
got.as_ref()
|
||||
.is_ok_and(|(w, unmeasured)| (w - side).abs() < 1e-9 * side && *unmeasured == 0),
|
||||
@@ -669,8 +723,70 @@ mod tests {
|
||||
);
|
||||
// A corner past the nodes is the triangle's index.
|
||||
assert_eq!(
|
||||
worst(&s, 0.0, &[], &uv, &[[0, 1, 2], [0, 1, 3]]),
|
||||
worst(
|
||||
&s,
|
||||
&mut Poles::new(&s, 0.0),
|
||||
&[],
|
||||
&uv,
|
||||
&[[0, 1, 2], [0, 1, 3]]
|
||||
),
|
||||
Err(Stop::Corner(1))
|
||||
);
|
||||
}
|
||||
|
||||
/// Refining, flipping and measuring a coarse patch of a cylinder (every sag over the
|
||||
/// tolerance, so the nodes are looked at by many triangles, sides and rounds) asks the
|
||||
/// kernel's classifier once per node and direction, not once per look.
|
||||
#[test]
|
||||
fn the_classifier_is_asked_once_per_node() {
|
||||
use solidcore::eval::EvalCurve;
|
||||
let s = EvalSurface::Revolved {
|
||||
profile: EvalCurve::new(
|
||||
1,
|
||||
vec![0.0, 0.0, 1.0, 1.0],
|
||||
vec![[3.0, 0.0, 0.0, 1.0], [3.0, 0.0, 4.0, 1.0]],
|
||||
),
|
||||
origin: solidcore::Vec3::ZERO,
|
||||
axis: solidcore::Vec3::Z,
|
||||
transform: [
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
],
|
||||
swap: false,
|
||||
};
|
||||
let n = 6u32;
|
||||
let mut uv: Vec<[f64; 2]> = Vec::new();
|
||||
for i in 0..=n {
|
||||
for j in 0..=n {
|
||||
uv.push([
|
||||
f64::from(i) / f64::from(n),
|
||||
3.0 * f64::from(j) / f64::from(n),
|
||||
]);
|
||||
}
|
||||
}
|
||||
let mut tris: Vec<[u32; 3]> = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let a = i * (n + 1) + j;
|
||||
let (b, c, d) = (a + 1, a + n + 1, a + n + 2);
|
||||
tris.push([a, c, d]);
|
||||
tris.push([a, d, b]);
|
||||
}
|
||||
}
|
||||
let mut poles = Poles::new(&s, 1e-9);
|
||||
let start = uv.len();
|
||||
// Refinement is capped (it may or may not run out of room): the count holds either way.
|
||||
refine(&s, 5e-2, &mut poles, &[], &mut uv, &mut tris, 300).unwrap();
|
||||
flip(&s, 5e-2, &mut poles, &[], &uv, &mut tris).unwrap();
|
||||
worst(&s, &mut poles, &[], &uv, &tris).unwrap();
|
||||
assert!(uv.len() > 2 * start, "{} nodes: not refined", uv.len());
|
||||
assert!(poles.asked > 0);
|
||||
assert!(
|
||||
poles.asked <= 2 * uv.len(),
|
||||
"{} calls for {} nodes",
|
||||
poles.asked,
|
||||
uv.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-2
@@ -61,12 +61,28 @@ pub(crate) fn crossing(
|
||||
if n < 3 {
|
||||
continue;
|
||||
}
|
||||
// Each node is on a pole or not whichever two chords it ends: asked once.
|
||||
let mut on_pole: Vec<Option<bool>> = vec![None; n];
|
||||
let mut node_pole = |k: usize, q: [f64; 2]| -> Result<bool, super::Unclassified> {
|
||||
if let Some(p) = on_pole.get(k).copied().flatten() {
|
||||
return Ok(p);
|
||||
}
|
||||
let p = pole(q)?;
|
||||
if let Some(slot) = on_pole.get_mut(k) {
|
||||
*slot = Some(p);
|
||||
}
|
||||
Ok(p)
|
||||
};
|
||||
for k in 0..n {
|
||||
// Proven: n = l.uv.len() >= 3, checked above
|
||||
let (Some(&a), Some(&b)) = (l.uv.get(k), l.uv.get((k + 1) % n)) else {
|
||||
let next = (k + 1) % n;
|
||||
let (Some(&a), Some(&b)) = (l.uv.get(k), l.uv.get(next)) else {
|
||||
continue;
|
||||
};
|
||||
if !(a.iter().chain(&b).all(|x| x.is_finite())) || pole(a)? || pole(b)? {
|
||||
if !(a.iter().chain(&b).all(|x| x.is_finite()))
|
||||
|| node_pole(k, a)?
|
||||
|| node_pole(next, b)?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// The loop's closing chord of a loop that winds round the surface ends at the copy of
|
||||
|
||||
+27
-2
@@ -1345,7 +1345,16 @@ fn sag_refinement_across_an_inflection_ends_without_slivers() {
|
||||
let fixed: Vec<Vec3> = Vec::new();
|
||||
let tol = 0.5;
|
||||
let start = uv.len();
|
||||
sag::refine(&s, tol, 1e-9, &fixed, &mut uv, &mut tris, 20_000).unwrap();
|
||||
sag::refine(
|
||||
&s,
|
||||
tol,
|
||||
&mut sag::Poles::new(&s, 1e-9),
|
||||
&fixed,
|
||||
&mut uv,
|
||||
&mut tris,
|
||||
20_000,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
uv.len() - start < 1_000,
|
||||
"{} points added: refinement ran to its budget",
|
||||
@@ -1400,7 +1409,7 @@ fn triangles_standing_across_a_curved_sheet_are_flipped_along_it() {
|
||||
.sum()
|
||||
};
|
||||
let before = area(&tris);
|
||||
sag::flip(&s, 0.1, 1e-9, &[], &uv, &mut tris).unwrap();
|
||||
sag::flip(&s, 0.1, &mut sag::Poles::new(&s, 1e-9), &[], &uv, &mut tris).unwrap();
|
||||
let after = area(&tris);
|
||||
let sides: Vec<(u32, u32)> = tris
|
||||
.iter()
|
||||
@@ -2138,3 +2147,19 @@ fn an_edge_stopped_at_its_depth_bound_is_named_within_its_tolerance() {
|
||||
assert!(capped.within && capped.sag <= capped.tol, "{capped:?}");
|
||||
assert!(capped.excess > 1.0, "{capped:?}");
|
||||
}
|
||||
|
||||
/// Refining a spline face moves no triangle count: the memo of the pole classifier only drops the
|
||||
/// repeated question (the coil took 1,089,434 / 1,091,452 calls at display / export before it).
|
||||
#[test]
|
||||
fn the_spline_coil_keeps_its_triangles() {
|
||||
let name = "smlib_coil_lower_half";
|
||||
let (b, size) = (fixture(name), size_of(name));
|
||||
for (t, want) in [
|
||||
(MeshTolerance::display_of(size), 30_776),
|
||||
(MeshTolerance::export_of(size), 30_808),
|
||||
] {
|
||||
let (p, _) = plan(&b, &t, size).unwrap();
|
||||
let (m, _) = p.evaluate();
|
||||
assert_eq!(m.triangles.len(), want);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
//! | `FilletBrep::calc_join_section` | `SmFilletVertex::CalcJoinSection` (543d25a) |
|
||||
//! | `FilletBrep::calc_rail_int_end_surface` | `SmFilletVertex::CalcRailIntEndSurface` (`SmBlendCornerEnd.cpp`, 3e26f76) |
|
||||
//! | [`mixed`] | the Mixed corner (`ClassifyMixed`, `MakeMixedTopology`, `CalcMixedVertGeom`, `CalcMixedEdgeGeom`, `CreateMixedPatches`; 6fd0411, 6e79de5, e1a2153) |
|
||||
//! | [`on_face`] | none: a Setback's spring curve kept inside its trimmed face (solidcore#2343 (d)) |
|
||||
//! | [`setback`] | the Setback (`ClassifySetBack`, `MakeSetBackTopology`, `BuildSetBackFill`, `CreateSetBackPatches`; 61547af, 1506e2f, f1d81f4) |
|
||||
//! | [`swept`] | the Swept turn (`Classify`'s Swept part, `BuildSweptSurface`, `CreateSweptPatch`) |
|
||||
//! | [`BlendChamferCorner`], [`chamfer`] | not the fork's: chamfers meeting at a vertex, closed by their planes (WR-716, #2345) |
|
||||
@@ -69,6 +70,7 @@ pub mod ball;
|
||||
pub mod chamfer;
|
||||
pub mod end;
|
||||
pub mod mixed;
|
||||
pub mod on_face;
|
||||
pub mod setback;
|
||||
pub mod swept;
|
||||
|
||||
|
||||
@@ -1423,7 +1423,8 @@ pub fn calc_mixed_edge_geom(
|
||||
Point3::new(to.0, to.1, 0.0),
|
||||
)?;
|
||||
let side_i = *nth(if turn { &which_t } else { &which }, kk, OP_EDGE)?;
|
||||
*nth_mut(of2m(&mut side_curves, f), side_i, OP_EDGE)? = Some(iso.clone());
|
||||
*nth_mut(of2m(&mut side_curves, f).as_mut_slice(), side_i, OP_EDGE)? =
|
||||
Some(iso.clone());
|
||||
of2m(&mut state.curves_3d, f).push(Curve::from(iso));
|
||||
of2m(&mut state.uv_curves, f).push(Curve::from(uv));
|
||||
of2m(&mut state.orients, f).push(*nth(&orients, kk, OP_EDGE)?);
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Whether a Setback's spring curve stays inside its trimmed face: it comes within no gap of
|
||||
//! any of the face's other edges (solidcore#2343 (d), the owner's ruling B there).
|
||||
//!
|
||||
//! Upstream: none. The fork's ruling on smlib#85 part C asked that the spring curve's
|
||||
//! parameter-plane control triangle `(P^, X^, Q^)` lie inside the trimmed face; that is the
|
||||
//! convex-hull bound for the curve itself, sound but loose (it refuses setbacks whose spring
|
||||
//! curve stays on the face). Ruling B on solidcore#2343 checks the curve.
|
||||
//!
|
||||
//! | Rust | Upstream |
|
||||
//! |-------------------|----------|
|
||||
//! | [`Clearance`] | none |
|
||||
//! | [`bezier_pieces`] | none |
|
||||
//! | [`clear_of`] | none |
|
||||
//!
|
||||
//! Decided on control points, never at sample points (`docs/definitions-and-certificates.md`
|
||||
//! §1): a rational Bézier piece with positive weights lies in the convex hull of its
|
||||
//! control points (Farin, *Curves and Surfaces for CAGD*). Two pieces whose hulls are
|
||||
//! proved farther apart than the gap and both pieces' rounding (their boxes', or their gap
|
||||
//! along the line between their centroids: `apart`) come no nearer than the gap. Otherwise the wider of the two is halved (de Casteljau on its
|
||||
//! homogeneous points) and the halves are tried in turn. A piece halved [`DEPTH`] times is
|
||||
//! halved no further, and a pair still undecided then, or once [`PAIRS`] pairs have been
|
||||
//! tried, is [`Clearance::Near`]: an undecided spring curve is not taken as clear. The
|
||||
//! rounding is Higham's `gamma` of the operations each point has been through, as
|
||||
//! `blend_setback::containment` bounds its patches.
|
||||
//!
|
||||
//! Deviations: no recursion: the pairs are kept on an explicit stack, each piece halved at
|
||||
//! most [`DEPTH`] times, at most [`PAIRS`] pairs per curve and piece list.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::curve::curve::{Curve, CurveKind};
|
||||
use crate::error::Result;
|
||||
use crate::geom::vector3d::sqrt;
|
||||
use crate::kernel::Kernel;
|
||||
use crate::math::Vec3;
|
||||
use crate::nurbs::b_spline_curve::BSplineCurve;
|
||||
use crate::nurbs::math::{gamma, NOW, NOZ};
|
||||
|
||||
const OP: &str = "blend::blend_corner::on_face::clear_of";
|
||||
|
||||
/// A homogeneous point `(x w, y w, z w, w)`.
|
||||
pub type Hom = [f64; 4];
|
||||
|
||||
/// How many times a piece is halved at most: a piece of a part's size, tens of millimetres,
|
||||
/// is then under 1e-10 long, the rounding of the part's coordinates (§4.4 of the
|
||||
/// definitions doc).
|
||||
pub const DEPTH: usize = 40;
|
||||
|
||||
/// How many pairs of pieces [`clear_of`] tries at most for one curve against one list of
|
||||
/// pieces. Near a closest approach a few pairs stay at each halving, so a decided case needs
|
||||
/// a few hundred.
|
||||
pub const PAIRS: usize = 4096;
|
||||
|
||||
/// Whether one curve comes within a gap of another ([`clear_of`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Clearance {
|
||||
/// Proved to stay farther than the gap from the other everywhere.
|
||||
Clear,
|
||||
/// Within the gap somewhere, or not shown not to be within [`DEPTH`] and [`PAIRS`].
|
||||
Near,
|
||||
}
|
||||
|
||||
/// The rational Bézier pieces of `curve` as homogeneous points, one piece per knot span: of
|
||||
/// the B-spline a line, a conic or a B-spline curve is built on (the whole of it, which holds
|
||||
/// an edge it is trimmed to; a line's or an ellipse's made on a copy where it holds none
|
||||
/// yet). `None` for a curve of another kind, a planar (2D) one, one whose B-spline is not
|
||||
/// made, or one whose weights are not all positive and finite: such a curve has no hull to
|
||||
/// bound it by.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Those of the B-spline's decomposition into its Bézier pieces; a cancellation while an
|
||||
/// ellipse's is made.
|
||||
pub fn bezier_pieces(k: &Kernel, curve: &Curve) -> Result<Option<Vec<Vec<Hom>>>> {
|
||||
let base: Cow<'_, BSplineCurve> = match curve.kind() {
|
||||
CurveKind::BSpline(c) => Cow::Borrowed(c),
|
||||
CurveKind::Line(c) => c.base_with_nurb()?,
|
||||
CurveKind::Ellipse(c) if c.base().nurb.is_none() => {
|
||||
let mut copy = c.clone();
|
||||
match copy.make_nurb(k) {
|
||||
Err(e) if e.is_unported_or_cancelled() => return Err(e),
|
||||
Err(_) => return Ok(None),
|
||||
Ok(()) => Cow::Owned(copy.base().clone()),
|
||||
}
|
||||
}
|
||||
CurveKind::Ellipse(c) => Cow::Borrowed(c.base()),
|
||||
CurveKind::Hyperbola(c) => Cow::Borrowed(&c.base),
|
||||
CurveKind::Parabola(c) => Cow::Borrowed(c.base()),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let Some(nurb) = base.nurb.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut pieces = Vec::new();
|
||||
for bez in nurb.decompose_bez()? {
|
||||
let mut piece = Vec::new();
|
||||
for cp in bez.cpts() {
|
||||
if cp.z == NOZ {
|
||||
return Ok(None);
|
||||
}
|
||||
let h = if cp.w == NOW {
|
||||
[cp.x, cp.y, cp.z, 1.0]
|
||||
} else {
|
||||
[cp.x, cp.y, cp.z, cp.w]
|
||||
};
|
||||
if !h.iter().all(|c| c.is_finite()) || h[3] <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
piece.push(h);
|
||||
}
|
||||
pieces.push(piece);
|
||||
}
|
||||
Ok(Some(pieces))
|
||||
}
|
||||
|
||||
/// A piece on the stack: its homogeneous points, how many more times it may be halved, and
|
||||
/// the rounding bound of its Euclidean points (that of the piece it was halved from).
|
||||
#[derive(Clone)]
|
||||
struct Piece {
|
||||
points: Vec<Hom>,
|
||||
depth: usize,
|
||||
rounding: f64,
|
||||
}
|
||||
|
||||
impl Piece {
|
||||
/// A piece as given: its Euclidean points are within `rounding` of the exact ones after
|
||||
/// [`DEPTH`] halvings of `degree` averaging steps each and the division by the weight:
|
||||
/// `3 gamma(ops + 2)` of its largest homogeneous coordinate over its least weight (shrunk
|
||||
/// by the weights' own rounding), `ops = 8 + DEPTH degree` (the 8 for the knot insertions
|
||||
/// that cut a B-spline into its pieces and the division). `None` where a weight is not
|
||||
/// positive or a coordinate not finite.
|
||||
fn new(points: &[Hom]) -> Option<Self> {
|
||||
let degree = points.len().checked_sub(1)?;
|
||||
let (mut most, mut w_min) = (0.0f64, f64::INFINITY);
|
||||
for h in points {
|
||||
if !h.iter().all(|c| c.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
most = most.max(h[0].abs()).max(h[1].abs()).max(h[2].abs());
|
||||
w_min = w_min.min(h[3]);
|
||||
}
|
||||
let ops = 8 + DEPTH * degree;
|
||||
let shrink = 1.0 - gamma(ops);
|
||||
if !(w_min > 0.0 && shrink > 0.0) {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
points: points.to_vec(),
|
||||
depth: DEPTH,
|
||||
rounding: 3.0 * gamma(ops + 2) * most / (w_min * shrink),
|
||||
})
|
||||
}
|
||||
|
||||
/// Its Euclidean points; `None` where a weight is not positive.
|
||||
fn euclidean(&self) -> Option<Vec<Vec3>> {
|
||||
let mut out = Vec::new();
|
||||
for h in &self.points {
|
||||
if h[3].is_nan() || h[3] <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
out.push(Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Its two halves at the middle of its parameter (de Casteljau), each halvable once less.
|
||||
fn halves(&self) -> (Self, Self) {
|
||||
let d = self.points.len().saturating_sub(1);
|
||||
let mut work = self.points.clone();
|
||||
let mut left = Vec::new();
|
||||
let mut right = Vec::new();
|
||||
for k in 0..=d {
|
||||
if let Some(&h) = work.first() {
|
||||
left.push(h);
|
||||
}
|
||||
if let Some(&h) = work.get(d - k) {
|
||||
right.push(h);
|
||||
}
|
||||
for i in 0..d - k {
|
||||
if let (Some(&a), Some(&b)) = (work.get(i), work.get(i + 1)) {
|
||||
if let Some(w) = work.get_mut(i) {
|
||||
*w = [
|
||||
0.5 * (a[0] + b[0]),
|
||||
0.5 * (a[1] + b[1]),
|
||||
0.5 * (a[2] + b[2]),
|
||||
0.5 * (a[3] + b[3]),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
right.reverse();
|
||||
let depth = self.depth.saturating_sub(1);
|
||||
(
|
||||
Self {
|
||||
points: left,
|
||||
depth,
|
||||
rounding: self.rounding,
|
||||
},
|
||||
Self {
|
||||
points: right,
|
||||
depth,
|
||||
rounding: self.rounding,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The widest extent of `points` along an axis.
|
||||
fn width(points: &[Vec3]) -> f64 {
|
||||
let (lo, hi) = extent(points);
|
||||
(hi.x - lo.x).max(hi.y - lo.y).max(hi.z - lo.z)
|
||||
}
|
||||
|
||||
/// The box of `points`, its least and greatest corners.
|
||||
fn extent(points: &[Vec3]) -> (Vec3, Vec3) {
|
||||
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
|
||||
let mut hi = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for p in points {
|
||||
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
|
||||
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
/// A lower bound on the distance between the convex hulls of `a` and `b`, less its own
|
||||
/// rounding: the larger of the distance between their boxes and their gap along the line
|
||||
/// between their centroids (any unit direction `u` separates them by `min u . b - max u . a`;
|
||||
/// that one closes on the hulls' distance as near-parallel pieces shrink, where the boxes'
|
||||
/// does not). The boxes' gaps are rounded by `2 gamma(1)` of the largest coordinate `s`, their
|
||||
/// sum of squares by `gamma(6)`; each projection by `gamma(3) sqrt 3 s |u|`, and the quotient
|
||||
/// by `|u|` by `gamma(4)`.
|
||||
fn apart(a: &[Vec3], b: &[Vec3]) -> f64 {
|
||||
let ((alo, ahi), (blo, bhi)) = (extent(a), extent(b));
|
||||
let size = [alo, ahi, blo, bhi].iter().fold(0.0f64, |m, c| {
|
||||
m.max(c.x.abs()).max(c.y.abs()).max(c.z.abs())
|
||||
});
|
||||
let gap = |l: f64, h: f64| l - h;
|
||||
let mut squares = 0.0;
|
||||
for d in [
|
||||
gap(blo.x, ahi.x).max(gap(alo.x, bhi.x)),
|
||||
gap(blo.y, ahi.y).max(gap(alo.y, bhi.y)),
|
||||
gap(blo.z, ahi.z).max(gap(alo.z, bhi.z)),
|
||||
] {
|
||||
let d = (d - 2.0 * gamma(1) * size).max(0.0);
|
||||
squares += d * d;
|
||||
}
|
||||
let boxes = sqrt(squares * (1.0 - gamma(6))) * (1.0 - gamma(2));
|
||||
let centroid = |p: &[Vec3]| {
|
||||
let (sum, count) = p
|
||||
.iter()
|
||||
.fold((Vec3::ZERO, 0.0), |(s, c), &q| (s + q, c + 1.0));
|
||||
sum * (1.0 / count)
|
||||
};
|
||||
let w = centroid(b) - centroid(a);
|
||||
// the line between the centroids, and its parts square to each piece's chord (for
|
||||
// near-parallel pieces side by side, whatever their shift along each other)
|
||||
let square_to = |p: &[Vec3]| match (p.first(), p.last()) {
|
||||
(Some(&first), Some(&last)) => {
|
||||
let t = last - first;
|
||||
let tt = t.dot(t);
|
||||
if tt > 0.0 {
|
||||
w - t * (w.dot(t) / tt)
|
||||
} else {
|
||||
w
|
||||
}
|
||||
}
|
||||
_ => w,
|
||||
};
|
||||
let mut best = boxes;
|
||||
for u in [w, square_to(a), square_to(b)] {
|
||||
let length = u.length();
|
||||
if length.is_nan() || length <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let reach_a = a.iter().fold(f64::NEG_INFINITY, |m, p| m.max(u.dot(*p)));
|
||||
let reach_b = b.iter().fold(f64::INFINITY, |m, p| m.min(u.dot(*p)));
|
||||
let along = (reach_b - reach_a) / length * (1.0 - gamma(4))
|
||||
- 2.0 * gamma(3) * sqrt(3.0) * size * (1.0 + gamma(4));
|
||||
best = best.max(along);
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Whether the rational Bézier curve `curve` (its homogeneous Bernstein coefficients) comes
|
||||
/// within `gap` of any of `pieces` (each such a curve too): [`Clearance::Clear`] only where
|
||||
/// that is proved on the pieces' control points (module docs).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The kernel's cancellation.
|
||||
pub fn clear_of(k: &Kernel, curve: &[Hom], pieces: &[Vec<Hom>], gap: f64) -> Result<Clearance> {
|
||||
let Some(a) = Piece::new(curve) else {
|
||||
return Ok(Clearance::Near);
|
||||
};
|
||||
let mut stack = Vec::new();
|
||||
for p in pieces {
|
||||
let Some(b) = Piece::new(p) else {
|
||||
return Ok(Clearance::Near);
|
||||
};
|
||||
stack.push((a.clone(), b));
|
||||
}
|
||||
let mut tried = 0;
|
||||
while let Some((a, b)) = stack.pop() {
|
||||
k.check_cancelled(OP)?;
|
||||
tried += 1;
|
||||
if tried > PAIRS {
|
||||
return Ok(Clearance::Near);
|
||||
}
|
||||
let (Some(pa), Some(pb)) = (a.euclidean(), b.euclidean()) else {
|
||||
return Ok(Clearance::Near);
|
||||
};
|
||||
if apart(&pa, &pb) > gap + a.rounding + b.rounding {
|
||||
continue;
|
||||
}
|
||||
let (wa, wb) = (width(&pa), width(&pb));
|
||||
// the wider is halved, where it may be; neither: undecided
|
||||
let halve_a = a.depth > 0 && (wa >= wb || b.depth == 0);
|
||||
if halve_a {
|
||||
let (a0, a1) = a.halves();
|
||||
stack.push((a1, b.clone()));
|
||||
stack.push((a0, b));
|
||||
} else if b.depth > 0 {
|
||||
let (b0, b1) = b.halves();
|
||||
stack.push((a.clone(), b1));
|
||||
stack.push((a, b0));
|
||||
} else {
|
||||
return Ok(Clearance::Near);
|
||||
}
|
||||
}
|
||||
Ok(Clearance::Clear)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A quarter circle of radius `r` about `(cx, cy)` in z = 0, from angle 0 to 90 degrees:
|
||||
/// the rational quadratic with middle weight `cos 45`.
|
||||
fn quarter(cx: f64, cy: f64, r: f64) -> Vec<Hom> {
|
||||
let w = 0.5f64.sqrt();
|
||||
vec![
|
||||
[cx + r, cy, 0.0, 1.0],
|
||||
[(cx + r) * w, (cy + r) * w, 0.0, w],
|
||||
[cx, cy + r, 0.0, 1.0],
|
||||
]
|
||||
}
|
||||
|
||||
/// A segment from `a` to `b`, degree 1.
|
||||
fn segment(a: [f64; 3], b: [f64; 3]) -> Vec<Hom> {
|
||||
vec![[a[0], a[1], a[2], 1.0], [b[0], b[1], b[2], 1.0]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_near_a_circle_is_clear_by_more_than_the_gap_and_near_within_it() {
|
||||
let k = Kernel::default();
|
||||
let arc = vec![quarter(0.0, 0.0, 10.0)];
|
||||
// the line x + y = c outside the circle is closest to the arc at 45 degrees, c / sqrt 2
|
||||
// - 10 away, and runs along it within twice that for a hundredth of a turn
|
||||
let c = 2.0f64.sqrt() * (10.0 + 1e-3);
|
||||
let chord = segment([c, 0.0, 0.0], [0.0, c, 0.0]);
|
||||
assert_eq!(
|
||||
clear_of(&k, &chord, &arc, 0.9e-3).unwrap(),
|
||||
Clearance::Clear
|
||||
);
|
||||
assert_eq!(clear_of(&k, &chord, &arc, 1.1e-3).unwrap(), Clearance::Near);
|
||||
// inside it, crossing it twice: near at any gap
|
||||
let c = 2.0f64.sqrt() * (10.0 - 1e-3);
|
||||
let chord = segment([c, 0.0, 0.0], [0.0, c, 0.0]);
|
||||
assert_eq!(clear_of(&k, &chord, &arc, 0.0).unwrap(), Clearance::Near);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_far_apart_are_clear_at_once_and_a_bad_weight_is_near() {
|
||||
let k = Kernel::default();
|
||||
let far = vec![quarter(100.0, 0.0, 1.0)];
|
||||
let chord = segment([0.0, 0.0, 0.0], [1.0, 1.0, 0.0]);
|
||||
assert_eq!(clear_of(&k, &chord, &far, 1e-6).unwrap(), Clearance::Clear);
|
||||
let mut bad = quarter(100.0, 0.0, 1.0);
|
||||
bad[1][3] = 0.0;
|
||||
assert_eq!(clear_of(&k, &chord, &[bad], 1e-6).unwrap(), Clearance::Near);
|
||||
}
|
||||
}
|
||||
+333
-2
@@ -44,6 +44,7 @@
|
||||
//! | `curved_least` (private) | `sm_SbCurvedLeast` (37c9e1d) |
|
||||
//! | `gather_ends` (private) | `BuildSetBackFill`'s `gather` |
|
||||
//! | `grow_curved` (private) | `BuildSetBackFill`'s growth beside curved faces (37c9e1d) |
|
||||
//! | `springs_on_faces`, `springs_refusal` (private) | none (solidcore#2343 (d)) |
|
||||
//!
|
||||
//! Deviations: the Setback's own members are kept in [`SetbackCorner`] on the corner
|
||||
//! ([`FilletCorner::setback`]) from its creation (`SetFillOrder`) until its patches are
|
||||
@@ -60,19 +61,26 @@
|
||||
//! the vertex there, from the edge's direction at the setback, not the end of its rails
|
||||
//! nearer the vertex as the fork's `gather` takes it: a round on an arc, extended past a far
|
||||
//! corner that does not blend, comes back round nearer the vertex than a long setback's end
|
||||
//! section (solidcore#2835).
|
||||
//! section (solidcore#2835). Beside a curved face each spring curve is checked to stay inside
|
||||
//! its trimmed face before the fill is made, and again at setbacks the growth finds; a
|
||||
//! setback that takes one off its face is refused, naming the largest that keeps every one
|
||||
//! on its face, or none where none down to the rails' crossing does or the growth's
|
||||
//! setbacks take one off (solidcore#2343 (d); the fork's ruling checked the curve's parameter-plane
|
||||
//! control triangle instead, and built no check).
|
||||
//!
|
||||
//! [`FilletCorner::setback`]: crate::blend::fillet_corner::p3::FilletCorner::setback
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::blend::blend_corner::ball::{solver_of_edgeuse_or_mate, BallSolvers};
|
||||
use crate::blend::blend_corner::on_face::{self, Clearance};
|
||||
use crate::blend::blend_corner::swept::fmt_g;
|
||||
use crate::blend::blend_corner::{BlendCorner, BlendCornerEvent};
|
||||
use crate::blend::blend_refusal::BlendRefusal;
|
||||
use crate::blend::blend_setback::{
|
||||
bisect_thousandths, certified_scale, containment, distances, fill_hole_counted,
|
||||
fill_hole_probe, Edge, Face, FaceKind, Fill, FillStats, Probe, Round,
|
||||
fill_hole_probe, spring_curve, Edge, Face, FaceKind, Fill, FillStats, Probe, Round,
|
||||
SpringCurve,
|
||||
};
|
||||
use crate::blend::fillet_corner::p1::{face_by_two_edgeuses, test_g1_continuity};
|
||||
use crate::blend::fillet_corner::p3::{CornerPatchEnv, FilletCorner, FilletCornerKind};
|
||||
@@ -92,6 +100,7 @@ use crate::error::{EntityRef, Result, SolidError};
|
||||
use crate::geom::extent1d::Extent1d;
|
||||
use crate::geom::extent2d::Extent2d;
|
||||
use crate::geom::global_solver::SolverOperation;
|
||||
use crate::geom::vector3d::sqrt;
|
||||
use crate::intersect::point_class::PointClassType;
|
||||
use crate::kernel::Kernel;
|
||||
use crate::math::{Point2, Point3, Vec3, BIG_DOUBLE, EFF_ZERO, EFF_ZERO_SQRT};
|
||||
@@ -105,6 +114,7 @@ use crate::surface::surface::Surface;
|
||||
use crate::topology::brep::p7::{make_face_with_curves, FaceCurvesInput};
|
||||
use crate::topology::edgeuse::p1 as eu1;
|
||||
use crate::topology::face::p2 as face2;
|
||||
use crate::topology::face::p4 as face4;
|
||||
use crate::topology::{edge, vertex, Brep, EdgeId, EdgeuseId, FaceId, VertexId};
|
||||
|
||||
const OP_CLASSIFY: &str = "blend::blend_corner::setback::classify_setback";
|
||||
@@ -129,6 +139,10 @@ pub struct SetbackCorner {
|
||||
/// curved face the length is the edge's arc length, as the setbacks are measured along
|
||||
/// it; between planes (straight edges) the distance between its ends.
|
||||
pub room: Vec<f64>,
|
||||
/// Not the fork (solidcore#2343 (d)): each blended edge's least setback, where the
|
||||
/// rails of the rounds beside it cross (`sm_SbSetBacks`'s `rvLeast`); a setback below it
|
||||
/// is too short.
|
||||
pub least: Vec<f64>,
|
||||
/// `m_pFill`: the fill, from the edge geometry until the patches take it.
|
||||
pub fill: Option<Fill>,
|
||||
/// `m_bSetBackOpen` (the fork's ef57570, smlib#125): an open G1 corner, its spring
|
||||
@@ -149,6 +163,7 @@ impl Default for SetbackCorner {
|
||||
convex: true,
|
||||
overflows: false,
|
||||
room: Vec::new(),
|
||||
least: Vec::new(),
|
||||
fill: None,
|
||||
open: false,
|
||||
definition: None,
|
||||
@@ -1234,6 +1249,7 @@ pub fn make_setback_topology(
|
||||
}
|
||||
sb.overflows = !fits;
|
||||
sb.room.clone_from(&room);
|
||||
sb.least.clone_from(&least);
|
||||
// a G1 fill is taken only certified convex: the least scale of the setbacks at which it
|
||||
// is (CertifiedScale). The rule's grow to it, each held at its room less the rule's
|
||||
// quarter radius (the others growing on); refused, naming them, where none within the
|
||||
@@ -1783,6 +1799,27 @@ fn grow_curved<S: SetbackSolvers>(
|
||||
let Some(ends) = growth.place(pseudo, corner, solvers, hi)? else {
|
||||
return Err(err());
|
||||
};
|
||||
// the setbacks the fill needs keep every spring curve on its face, or none is taken
|
||||
// (#2343 (d)): the growth finds no smaller one certified, and a spring curve off its face
|
||||
// at these is taken to stay off it at every larger one (as the growth takes
|
||||
// certification, checked on the cases, not proved)
|
||||
let springs = springs_on_faces(
|
||||
growth.k,
|
||||
growth.target,
|
||||
pseudo,
|
||||
corner,
|
||||
growth.planes,
|
||||
growth.tol,
|
||||
)?;
|
||||
if springs == Springs::Leave {
|
||||
return Ok(Err(SetbackRefusal::no_room(
|
||||
format!(
|
||||
"Fillet Error: the setback corner's fill stays no more concave than its faces only from {} times its setbacks, where a spring curve is not shown to stay on its face: no setback builds",
|
||||
fmt_g(hi)
|
||||
),
|
||||
Some(0.0),
|
||||
)));
|
||||
}
|
||||
if let Some(a) = asked {
|
||||
let room = &corner.setback.as_ref().ok_or_else(err)?.room;
|
||||
let fits_room = room.iter().all(|&r| hi * a.setback + a.ease < r);
|
||||
@@ -1805,6 +1842,292 @@ fn grow_curved<S: SetbackSolvers>(
|
||||
Ok(Ok(ends))
|
||||
}
|
||||
|
||||
/// Where a Setback's spring curves stand on its faces ([`springs_on_faces`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Springs {
|
||||
/// Each proved to keep clear of every other edge of its face.
|
||||
Clear,
|
||||
/// One comes within its gap of another edge of its face, or is not shown not to.
|
||||
Leave,
|
||||
/// One is not made: [`spring_curve`] refuses the rails' ends (the fill, which makes it
|
||||
/// from the same ends, says why).
|
||||
Unmade,
|
||||
}
|
||||
|
||||
/// The derivative along `v` of `face`'s unit normal field ([`Face::distance`]'s normal, out
|
||||
/// of the material, at any point) at `x`: zero on a plane; a sphere's `side (v - (v . u) u) /
|
||||
/// |x - c|` (u the unit direction from its centre); a cylinder's or cone's `side cos v_t /
|
||||
/// rho`, `v_t` the part of `v` along the circle about the axis through `x`, rho that circle's
|
||||
/// radius and `cos` that of the cone's half angle (1 on a cylinder). `None` on a quadric's
|
||||
/// centre or axis.
|
||||
fn normal_turn(face: &Face, x: Point3, v: Vec3) -> Option<Vec3> {
|
||||
let u = x - face.point;
|
||||
match face.kind {
|
||||
FaceKind::Plane => Some(Vec3::ZERO),
|
||||
FaceKind::Sphere => {
|
||||
let len = u.length();
|
||||
let d = u.unitize().ok()?;
|
||||
Some((v - d * v.dot(d)) * (face.side / len))
|
||||
}
|
||||
FaceKind::Cylinder | FaceKind::Cone => {
|
||||
let r = u - face.axis * u.dot(face.axis);
|
||||
let rho = r.length();
|
||||
let d = r.unitize().ok()?;
|
||||
let along = v - d * v.dot(d) - face.axis * v.dot(face.axis);
|
||||
let cos = 1.0 / sqrt(1.0 + face.slope * face.slope);
|
||||
Some(along * (face.side * cos / rho))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit direction of the rail on `face` of the round of radius `r` along `edge_id`, at
|
||||
/// its end `p` on `face` whose other end is `q` on `other` (the round's two contact points in
|
||||
/// one section), turned toward the corner's vertex (`sign` `+1` at a convex corner, `-1` at a
|
||||
/// concave one). In closed form, not from the rail as traced, which runs only from the
|
||||
/// setback the corner was made at outward: the ball's centre is `c = p - sign r n(p)` and
|
||||
/// runs along `n(p) x m(q)` (n, m the faces' normals, constant along their normal lines), the
|
||||
/// rail is `c + sign r n(c)`, so its direction is that of the centre's plus `sign r` times the
|
||||
/// normal's turn along it ([`normal_turn`]). Toward the vertex: the side the edge runs on to
|
||||
/// the vertex at its setback (as `gather_ends` takes the corner's side of an end section).
|
||||
/// `None` where the faces' normals are parallel there, or a normal's turn is not defined.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rail_toward_vertex(
|
||||
k: &Kernel,
|
||||
target: &Brep,
|
||||
corner: &FilletCorner,
|
||||
edge_id: EdgeId,
|
||||
face: &Face,
|
||||
p: Point3,
|
||||
other: &Face,
|
||||
q: Point3,
|
||||
r: f64,
|
||||
sign: f64,
|
||||
) -> Result<Option<Vec3>> {
|
||||
let nul = || SolidError::null_pointer(OP_FILL);
|
||||
let vtx = corner.vertex.ok_or_else(nul)?;
|
||||
let (_, n) = face.distance(p);
|
||||
let (_, m) = other.distance(q);
|
||||
let centre = p - n * (sign * r);
|
||||
let along = n.cross(m);
|
||||
let Some(turn) = normal_turn(face, centre, along) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Ok(t) = (along + turn * (sign * r)).unitize() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let setback = corner.set_back_dist_along(target, edge_id)?;
|
||||
let (_, edge_dir) = set_back_frame(k, target, edge_id, Some(vtx), setback, OP_FILL)?;
|
||||
let from_start = edge::start_vertex(target, edge_id)? == Some(vtx);
|
||||
let side = t.dot(if from_start { -edge_dir } else { edge_dir });
|
||||
if !side.is_finite() || side.abs() <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(if side > 0.0 { t } else { -t }))
|
||||
}
|
||||
|
||||
/// The corner's spring curves, one per face, at the setbacks its vertices are placed at, as
|
||||
/// the fill makes them ([`spring_curve`]): on face `F_i`, from `S_i`, the end of the blend
|
||||
/// after `F_i`, leaving its rail toward the vertex, to `M_i`, the end of the blend before,
|
||||
/// tangent to both rails ([`rail_toward_vertex`], each with the blend's other end, on its
|
||||
/// other face, the mate of its end on `F_i`). [`Springs::Leave`] where a rail's direction is
|
||||
/// not found (not shown to stay on its face); [`Springs::Unmade`] where [`spring_curve`]
|
||||
/// refuses the rails' ends.
|
||||
fn corner_springs(
|
||||
k: &Kernel,
|
||||
target: &Brep,
|
||||
pseudo: &FilletBrep,
|
||||
corner: &FilletCorner,
|
||||
planes: &CornerPlanes,
|
||||
) -> Result<core::result::Result<Vec<SpringCurve>, Springs>> {
|
||||
let nul = || SolidError::null_pointer(OP_FILL);
|
||||
let all = corner.all_eus.as_ref().ok_or_else(nul)?;
|
||||
let sign = if corner.setback.as_ref().ok_or_else(nul)?.convex {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
};
|
||||
let n = planes.faces.len();
|
||||
let point = |i: usize| -> Result<Point3> {
|
||||
pseudo.vertex_point(*corner.vertices.get(i).ok_or_else(nul)?)
|
||||
};
|
||||
let mut springs = Vec::new();
|
||||
for (f, plane) in planes.planes.iter().enumerate() {
|
||||
// (no face: no corner, refused by the checked remainders)
|
||||
let before = (f + n - 1).checked_rem(n).ok_or_else(nul)?;
|
||||
let after = (f + 1).checked_rem(n).ok_or_else(nul)?;
|
||||
let mut ends = [(Point3::ZERO, None); 2];
|
||||
// S_f with its mate M_(f+1) on F_(f+1); M_f with its mate S_(f-1) on F_(f-1)
|
||||
for (slot, (blend, at, mate, other)) in ends.iter_mut().zip([
|
||||
(f, 3 * f + 2, 3 * after, after),
|
||||
(before, 3 * f, 3 * before + 2, before),
|
||||
]) {
|
||||
let e = eu1::edge(target, *all.get(blend).ok_or_else(nul)?)?.ok_or_else(nul)?;
|
||||
let p = point(at)?;
|
||||
let tangent = rail_toward_vertex(
|
||||
k,
|
||||
target,
|
||||
corner,
|
||||
e,
|
||||
plane,
|
||||
p,
|
||||
planes.planes.get(other).ok_or_else(nul)?,
|
||||
point(mate)?,
|
||||
planes.edges.get(blend).ok_or_else(nul)?.radius,
|
||||
sign,
|
||||
)?;
|
||||
*slot = (p, tangent);
|
||||
}
|
||||
let [(p, Some(tp)), (q, Some(tq))] = ends else {
|
||||
return Ok(Err(Springs::Leave));
|
||||
};
|
||||
let Ok(spring) = spring_curve(plane, p, tp, q, -tq) else {
|
||||
return Ok(Err(Springs::Unmade));
|
||||
};
|
||||
springs.push(spring);
|
||||
}
|
||||
Ok(Ok(springs))
|
||||
}
|
||||
|
||||
/// solidcore#2343 (d), the owner's ruling B there: whether the corner's spring curves
|
||||
/// ([`corner_springs`]) stay inside their trimmed faces. Each starts inside its face, so it
|
||||
/// stays inside where it keeps clear of every edge of the face but the two blended edges at
|
||||
/// the vertex ([`on_face::clear_of`]: by more than the corner's tolerance and the edge's). The
|
||||
/// two blended edges are left out whole: between them the spring curve lies past the rails of
|
||||
/// their rounds, which run inside the face along them. An edge without a curve, or whose
|
||||
/// curve is not built on a B-spline, gives no hull to bound it by: the spring curve is not
|
||||
/// shown clear of it.
|
||||
fn springs_on_faces(
|
||||
k: &Kernel,
|
||||
target: &Brep,
|
||||
pseudo: &FilletBrep,
|
||||
corner: &FilletCorner,
|
||||
planes: &CornerPlanes,
|
||||
tol: f64,
|
||||
) -> Result<Springs> {
|
||||
let nul = || SolidError::null_pointer(OP_FILL);
|
||||
let vtx = corner.vertex.ok_or_else(nul)?;
|
||||
let springs = match corner_springs(k, target, pseudo, corner, planes)? {
|
||||
Ok(springs) => springs,
|
||||
Err(verdict) => return Ok(verdict),
|
||||
};
|
||||
for (&face, spring) in planes.faces.iter().zip(&springs) {
|
||||
let mut seen = Vec::new();
|
||||
for e in face4::edges(target, face, None, false, None)? {
|
||||
if seen.contains(&e) {
|
||||
continue;
|
||||
}
|
||||
seen.push(e);
|
||||
if edge::start_vertex(target, e)? == Some(vtx)
|
||||
|| edge::end_vertex(target, e)? == Some(vtx)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(curve) = edge::curve(target, e)? else {
|
||||
return Ok(Springs::Leave);
|
||||
};
|
||||
let Some(pieces) = on_face::bezier_pieces(k, curve)? else {
|
||||
return Ok(Springs::Leave);
|
||||
};
|
||||
let gap = tol + edge::tolerance(target, e)?;
|
||||
for half in [&spring.from_p.curve, &spring.from_q.curve] {
|
||||
if on_face::clear_of(k, half, &pieces, gap)? == Clearance::Near {
|
||||
return Ok(Springs::Leave);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Springs::Clear)
|
||||
}
|
||||
|
||||
/// What a setback that takes a spring curve off its face does, for [`setback_too_long`].
|
||||
const SPRING_LEAVES: &str =
|
||||
"takes a setback corner's spring curve off its face (it comes within the tolerances of another edge of the face, or is not shown not to)";
|
||||
|
||||
/// solidcore#2343 (d): where a spring curve leaves its face at the corner's setbacks
|
||||
/// ([`springs_on_faces`]), the refusal naming the largest common scale of them on the grid of
|
||||
/// thousandths at which every spring curve stays inside its face. It is found by the growth's
|
||||
/// bisection ([`bisect_thousandths`], each trial placing the setback vertices again as the
|
||||
/// growth does), between 1 and the least scale at which every setback reaches its edge's
|
||||
/// least, where the rails cross; the search assumes a spring curve on its face at a scale
|
||||
/// stays on it at every smaller one (the curve shrinks toward the vertex with its setbacks),
|
||||
/// as the growth assumes certification monotone: checked on the cases, not proved. One asked
|
||||
/// for is refused as [`BlendRefusal::SetbackTooLong`] naming that scale times it; the
|
||||
/// rule's, naming those setbacks. None at or above the least: no setback builds. A trial
|
||||
/// whose vertices are not placed counts as off its face, so the scale named is at worst
|
||||
/// smaller than need be, never one off its face; whether the fill certifies there is the
|
||||
/// fill's to say when that setback is asked. `None` where every spring curve stays on its
|
||||
/// face, or one is not made (the fill says why).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn springs_refusal<S: SetbackSolvers>(
|
||||
k: &Kernel,
|
||||
target: &Brep,
|
||||
pseudo: &mut FilletBrep,
|
||||
corner: &mut FilletCorner,
|
||||
solvers: &mut S,
|
||||
planes: &CornerPlanes,
|
||||
fills: &[SetBackFill],
|
||||
tol: f64,
|
||||
) -> Result<Option<SetbackRefusal>> {
|
||||
let err = || SolidError::sm_err(OP_FILL);
|
||||
let at_base = springs_on_faces(k, target, pseudo, corner, planes, tol)?;
|
||||
if at_base != Springs::Leave {
|
||||
return Ok(None);
|
||||
}
|
||||
let vtx = corner.vertex.ok_or_else(err)?;
|
||||
let asked = set_back_fill_of(fills, vtx);
|
||||
let sb = corner.setback.as_ref().ok_or_else(err)?;
|
||||
let base = sb.setbacks.clone();
|
||||
if sb.least.len() != base.len() {
|
||||
return Err(err());
|
||||
}
|
||||
let mut least_scale: f64 = 0.0;
|
||||
for (&l, &s) in sb.least.iter().zip(&base) {
|
||||
least_scale = smax(least_scale, l / s);
|
||||
}
|
||||
let growth = Growth {
|
||||
k,
|
||||
target,
|
||||
planes,
|
||||
tol,
|
||||
most: vec![BIG_DOUBLE; base.len()],
|
||||
base,
|
||||
};
|
||||
let mut leaves = |i: i32| -> Result<bool> {
|
||||
Ok(
|
||||
match growth.place(pseudo, corner, solvers, f64::from(i) / 1000.0)? {
|
||||
Some(_) => {
|
||||
springs_on_faces(k, target, pseudo, corner, planes, tol)? != Springs::Clear
|
||||
}
|
||||
None => true,
|
||||
},
|
||||
)
|
||||
};
|
||||
let lo = (1..1000).find(|&i| f64::from(i) >= 1000.0 * least_scale);
|
||||
let fit = match lo {
|
||||
Some(lo) if !leaves(lo)? => Some(bisect_thousandths(lo, 1000, &mut leaves)? - 1),
|
||||
_ => None,
|
||||
};
|
||||
let Some(fit) = fit.map(|i| f64::from(i) / 1000.0) else {
|
||||
return Ok(Some(SetbackRefusal::no_room(
|
||||
"Fillet Error: no setback at or above where the rails cross keeps the setback corner's spring curves on their faces",
|
||||
Some(0.0),
|
||||
)));
|
||||
};
|
||||
if let Some(a) = asked {
|
||||
return Ok(Some(setback_too_long(
|
||||
a,
|
||||
vtx,
|
||||
fit * a.setback,
|
||||
SPRING_LEAVES,
|
||||
)));
|
||||
}
|
||||
let list: Vec<String> = growth.base.iter().map(|&s| fmt_g(fit * s)).collect();
|
||||
Ok(Some(SetbackRefusal::not_built(format!(
|
||||
"Fillet Error: the rule's setbacks take a setback corner's spring curve off its face (it comes within the tolerances of another edge of the face, or is not shown not to): setbacks of {} keep every spring curve on its face",
|
||||
list.join(", ")
|
||||
))))
|
||||
}
|
||||
|
||||
/// A setback asked at `vtx` that, with its ease, does not fit (`why`): refused, naming
|
||||
/// `largest`, below which one fits ([`BlendRefusal::SetbackTooLong`]).
|
||||
fn setback_too_long(a: SetBackFill, vtx: VertexId, largest: f64, why: &str) -> SetbackRefusal {
|
||||
@@ -1907,6 +2230,14 @@ pub fn build_setback_fill<S: SetbackSolvers>(
|
||||
if open && order == 1 {
|
||||
return open_setback_springs(pseudo, corner, &planes.planes, &planes.edges);
|
||||
}
|
||||
// beside a curved face, the spring curves stay inside their trimmed faces (#2343 (d))
|
||||
if curved {
|
||||
if let Some(refusal) =
|
||||
springs_refusal(k, target, pseudo, corner, solvers, &planes, fills, tol)?
|
||||
{
|
||||
return Ok(Err(refusal));
|
||||
}
|
||||
}
|
||||
let mut stats = FillStats::default();
|
||||
let first = fill_hole_counted(
|
||||
k,
|
||||
|
||||
@@ -1229,10 +1229,15 @@ pub fn check_nx1_degeneracy_status(
|
||||
| SolidError::Cancelled { .. }
|
||||
| SolidError::NotFound { .. }),
|
||||
) => Err(e),
|
||||
Err(
|
||||
e @ (SolidError::InvalidInput { reason, .. }
|
||||
| SolidError::PortInvariant { reason, .. }),
|
||||
) if reason == NULL_DEREF => Err(e),
|
||||
Err(e)
|
||||
if matches!(
|
||||
&e,
|
||||
SolidError::InvalidInput { reason, .. }
|
||||
| SolidError::PortInvariant { reason, .. } if *reason == NULL_DEREF
|
||||
) =>
|
||||
{
|
||||
Err(e)
|
||||
}
|
||||
Err(e) => Ok(Err(e)),
|
||||
}
|
||||
};
|
||||
|
||||
+49
-58
@@ -2521,46 +2521,11 @@ fn keep_definition(
|
||||
let Some(surface) = pseudo.geom_mut(g)?.fillet_surface_mut(None)? else {
|
||||
return Err(SolidError::null_pointer(OP));
|
||||
};
|
||||
keep_round_if_proved(k, surface, round, inputs.tol)?;
|
||||
round.recertify(k, surface)?;
|
||||
surface.set_exact_round(Some(round));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `round` kept on `net` where it is proved ([`ExactRound::recertify`]), returning whether it
|
||||
/// was. The class is read within `tol`, the body's tolerance, and the certificate to rounding.
|
||||
/// Supports only that close to the class (a cylinder's axis 1e-13 off square to a plane, as
|
||||
/// read from a record) make a net that is the round but not provably on the class's surface:
|
||||
/// no bound of 0, so it keeps no definition. A net further off the
|
||||
/// class than `tol` ([`ExactRound::miss`]) is built wrongly, and stays an error.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The certificate's refusal of a net more than `tol` off the class, or of supports that
|
||||
/// give the class no surface; those of the certificate and of [`ExactRound::miss`].
|
||||
pub fn keep_round_if_proved(
|
||||
k: &Kernel,
|
||||
net: &mut Surface,
|
||||
round: ExactRound,
|
||||
tol: f64,
|
||||
) -> Result<bool> {
|
||||
match round.recertify(k, net) {
|
||||
Ok(_) => {
|
||||
net.set_exact_round(Some(round));
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
let off = matches!(
|
||||
&e,
|
||||
SolidError::InvalidInput { operation, .. }
|
||||
if *operation == crate::surface::implicit::OP_EXACT
|
||||
);
|
||||
match off.then(|| round.miss(k, net)).transpose()?.flatten() {
|
||||
Some(miss) if miss <= tol => Ok(false),
|
||||
_ => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`calc_exact_fillet_geom`]'s build by the class (`CalcExactFilletGeom`), without the
|
||||
/// definition kept.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -2719,7 +2684,8 @@ fn exact_fillet_geom_of_class(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A box's edge rounded 2: the round's net, without its definition, and the definition.
|
||||
/// A cylinder R 10, 20 high, its top rim rounded 2 (a round of the circle class): the
|
||||
/// round's net, without its definition, and the definition.
|
||||
fn kept_round() -> (Kernel, Surface, ExactRound) {
|
||||
use crate::blend::fillet_executive::session::FilletSession;
|
||||
use crate::kernel::UpstreamFixes;
|
||||
@@ -2729,24 +2695,51 @@ mod tests {
|
||||
fixes: UpstreamFixes::all(),
|
||||
..Kernel::default()
|
||||
};
|
||||
let mut b = crate::primitive::api_primitives::create_box(
|
||||
let mut b = crate::primitive::api_primitives::create_cylinder(
|
||||
&mut k,
|
||||
Point3::new(0.0, 0.0, 0.0),
|
||||
30.0,
|
||||
20.0,
|
||||
10.0,
|
||||
20.0,
|
||||
)
|
||||
.unwrap();
|
||||
let edge = b.edge_list()[0];
|
||||
// the top rim: the edge through (10, 0, 20)
|
||||
let rim = *b
|
||||
.edge_list()
|
||||
.iter()
|
||||
.min_by(|&&x, &&y| {
|
||||
let d = |e| {
|
||||
crate::query::api_queries::edge_closest_point(
|
||||
&k,
|
||||
&b,
|
||||
e,
|
||||
Point3::new(10.0, 0.0, 20.0),
|
||||
)
|
||||
.unwrap()
|
||||
.closest
|
||||
.distance
|
||||
+ crate::query::api_queries::edge_closest_point(
|
||||
&k,
|
||||
&b,
|
||||
e,
|
||||
Point3::new(-10.0, 0.0, 20.0),
|
||||
)
|
||||
.unwrap()
|
||||
.closest
|
||||
.distance
|
||||
};
|
||||
d(x).total_cmp(&d(y))
|
||||
})
|
||||
.unwrap();
|
||||
{
|
||||
let mut s = FilletSession::new(&mut k, &mut b).unwrap();
|
||||
s.prepare_constant_radius_edges(&mut k, &[edge], 2.0, 1, 1, 1.0)
|
||||
s.prepare_constant_radius_edges(&mut k, &[rim], 2.0, 1, 1, 1.0)
|
||||
.unwrap();
|
||||
s.do_fillet(&mut k).unwrap();
|
||||
}
|
||||
for &f in b.face_list() {
|
||||
let s = face::surface(&b, f).unwrap().unwrap();
|
||||
if let Some(BlendRef::Exact { round, .. }) = s.procedural_blend() {
|
||||
assert_eq!(round.class, ExactClass::Circle);
|
||||
let round = round.clone();
|
||||
let mut net = s.clone();
|
||||
net.set_exact_round(None);
|
||||
@@ -2769,23 +2762,21 @@ mod tests {
|
||||
net
|
||||
}
|
||||
|
||||
/// The class is read within 2e-5 here. A net on it keeps its definition; one off it by
|
||||
/// less keeps none (the round of supports that close to the class); one further off is
|
||||
/// built wrongly, and stays an error.
|
||||
/// A circle-class round's definition is the torus its net is on, a stated distance from
|
||||
/// the supports' class torus within the tolerance the class was read at (2e-5 here): the
|
||||
/// net as built is at 0, the net moved by (1, 1, 1) 1e-7 is at sqrt 3 1e-7, and the net
|
||||
/// moved by (1, 1, 1) 1e-3 is refused.
|
||||
#[test]
|
||||
fn a_net_off_its_class_keeps_no_definition_within_the_tolerance_only() {
|
||||
let tol = 2e-5;
|
||||
fn a_circle_rounds_net_is_certified_on_its_own_torus_near_the_class() {
|
||||
let (k, net, round) = kept_round();
|
||||
let mut on = net.clone();
|
||||
assert!(keep_round_if_proved(&k, &mut on, round.clone(), tol).unwrap());
|
||||
assert!(on.procedural_blend().is_some());
|
||||
|
||||
let mut near = moved(&k, &net, 1e-7);
|
||||
assert!(!keep_round_if_proved(&k, &mut near, round.clone(), tol).unwrap());
|
||||
assert!(near.procedural_blend().is_none());
|
||||
|
||||
let mut far = moved(&k, &net, 1e-3);
|
||||
let refused = keep_round_if_proved(&k, &mut far, round, tol);
|
||||
assert_eq!(round.recertify(&k, &net).unwrap(), 0.0);
|
||||
assert!(round.supports_distance(&k, &net).unwrap() < 1e-12);
|
||||
let near = moved(&k, &net, 1e-7);
|
||||
assert_eq!(round.recertify(&k, &near).unwrap(), 0.0);
|
||||
let d = round.supports_distance(&k, &near).unwrap();
|
||||
assert!((d - 3.0_f64.sqrt() * 1e-7).abs() < 1e-12, "{d}");
|
||||
let far = moved(&k, &net, 1e-3);
|
||||
let refused = round.recertify(&k, &far);
|
||||
assert!(
|
||||
matches!(refused, Err(SolidError::InvalidInput { .. })),
|
||||
"{refused:?}"
|
||||
|
||||
@@ -1304,7 +1304,7 @@ fn rational_spine_parts(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::blend::fillet_exact::{classify_supports, keep_round_if_proved, ClassInputs};
|
||||
use crate::blend::fillet_exact::{classify_supports, ClassInputs};
|
||||
use crate::curve::curve::CurveKind;
|
||||
use crate::geom::axis2_placement::Axis2Placement;
|
||||
use crate::surface::cone::Cone;
|
||||
@@ -1779,10 +1779,10 @@ mod tests {
|
||||
inputs,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
keep_round_if_proved(&k, &mut net, round, inputs.tol).unwrap(),
|
||||
"{offsets:?}, {contact:?}"
|
||||
);
|
||||
if let Err(e) = round.recertify(&k, &net) {
|
||||
panic!("{offsets:?}, {contact:?}: {e}");
|
||||
}
|
||||
net.set_exact_round(Some(round));
|
||||
assert_eq!(
|
||||
net.procedural_blend().unwrap().exact().unwrap().bound(),
|
||||
0.0
|
||||
|
||||
@@ -308,6 +308,61 @@ pub fn nurbs_partials_over_piece(
|
||||
va: f64,
|
||||
vb: f64,
|
||||
lk: usize,
|
||||
) -> Result<Partials<Ivl>> {
|
||||
partials_over_piece(cn, ku, kv, ua, ub, va, vb, lk, None)
|
||||
}
|
||||
|
||||
/// [`nurbs_partials_over_piece`] for a box inside the piece's closed knot span (not the
|
||||
/// fork's): there the weight is a convex combination of the piece's control weights, so it
|
||||
/// lies between their least and greatest, and its enclosure is cut to that range before
|
||||
/// the quotient. Where the Taylor enclosure of the weight alone holds 0 (a rational piece of
|
||||
/// high degree over a large box) this proves it positive. A box past the span is enclosed
|
||||
/// as [`nurbs_partials_over_piece`] encloses it.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// As [`nurbs_partials_over_piece`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn nurbs_partials_within_span(
|
||||
cn: &Nurbs,
|
||||
ku: usize,
|
||||
kv: usize,
|
||||
ua: f64,
|
||||
ub: f64,
|
||||
va: f64,
|
||||
vb: f64,
|
||||
lk: usize,
|
||||
) -> Result<Partials<Ivl>> {
|
||||
let inside = |u: &[f64], k: usize, a: f64, b: f64| {
|
||||
knot(u, k as isize) <= a && b <= knot(u, k as isize + 1)
|
||||
};
|
||||
let hull = if inside(&cn.u, ku, ua, ub) && inside(&cn.v, kv, va, vb) {
|
||||
let mut h: Option<Ivl> = None;
|
||||
for i in (ku + 1).saturating_sub(cn.p + 1)..=ku {
|
||||
for j in (kv + 1).saturating_sub(cn.q + 1)..=kv {
|
||||
let w = Ivl::from(pw_at(&cn.pw, 4 * (i * cn.nv + j) + 3));
|
||||
h = Some(h.map_or(w, |x| x.hull(w)));
|
||||
}
|
||||
}
|
||||
h
|
||||
} else {
|
||||
None
|
||||
};
|
||||
partials_over_piece(cn, ku, kv, ua, ub, va, vb, lk, hull)
|
||||
}
|
||||
|
||||
/// [`nurbs_partials_over_piece`], the weight's enclosure cut to `weights` where given.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn partials_over_piece(
|
||||
cn: &Nurbs,
|
||||
ku: usize,
|
||||
kv: usize,
|
||||
ua: f64,
|
||||
ub: f64,
|
||||
va: f64,
|
||||
vb: f64,
|
||||
lk: usize,
|
||||
weights: Option<Ivl>,
|
||||
) -> Result<Partials<Ivl>> {
|
||||
let um = 0.5 * (ua + ub);
|
||||
let vm = 0.5 * (va + vb);
|
||||
@@ -377,6 +432,10 @@ pub fn nurbs_partials_over_piece(
|
||||
}
|
||||
}
|
||||
let w00 = at2(&wv, 0, 0, IZ);
|
||||
let w00 = match weights {
|
||||
Some(h) => Ivl::new(w00.lo.max(h.lo), w00.hi.min(h.hi)),
|
||||
None => w00,
|
||||
};
|
||||
let positive = w00.lo > 0.0;
|
||||
if !positive {
|
||||
// "SmGnSectionFSG: a face's weights are not positive over a box"
|
||||
@@ -2160,6 +2219,23 @@ mod tests {
|
||||
/// A box across a knot line: the piecewise enclosure holds every partial of the face on
|
||||
/// both sides (the third u-partial jumps at the knot), which one piece continued over the
|
||||
/// box does not; a knot of too low a continuity, and a box past the knots, are refused.
|
||||
#[test]
|
||||
fn a_box_past_its_span_keeps_the_unclipped_weight() {
|
||||
// the control weights bound the weight only inside the span: a box across the knot at
|
||||
// u = 1 is enclosed as `nurbs_partials_over_piece` encloses it
|
||||
let mut n = cubic_with_knot(1);
|
||||
// weights from 1 to 4.5: the weight continued past the span leaves their range
|
||||
for (i, w) in n.pw.chunks_mut(4).enumerate() {
|
||||
let k = 1.0 + 0.5 * i as f64;
|
||||
for c in w.iter_mut() {
|
||||
*c *= k;
|
||||
}
|
||||
}
|
||||
let a = nurbs_partials_within_span(&n, 3, 1, 0.9, 1.1, 0.2, 0.3, 3).unwrap();
|
||||
let b = nurbs_partials_over_piece(&n, 3, 1, 0.9, 1.1, 0.2, 0.3, 3).unwrap();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_across_a_knot_line_is_enclosed_piece_by_piece() {
|
||||
let n = cubic_with_knot(1);
|
||||
|
||||
+8
-1
@@ -81,7 +81,14 @@
|
||||
//! - **Length.** Spans of degree above 1 use the ported `SmIntegrator` (unit
|
||||
//! `mass.integrator`; with `Kernel::fixes.integrator_propagates_failure`, WR-94,
|
||||
//! failed span integrals propagate instead of contributing zero). `Length` needs the
|
||||
//! kernel (the cache tessellation reads its tolerances).
|
||||
//! kernel (the cache tessellation reads its tolerances). Not upstream: the accuracy
|
||||
//! is held over the whole curve, not per span. Upstream integrates each span to
|
||||
//! `dDesiredAccuracy / span count` relative to that span's integral so far, so each of
|
||||
//! a span's steps may err by nearly that and the steps together by up to their number
|
||||
//! times it; here each step is held to half the accuracy relative to its share of the
|
||||
//! estimated length plus its own increment (`OdeOptions::rate`), shares that add up to
|
||||
//! at most twice the length ([`Curve::length`];
|
||||
//! `plan/findings/curve.curve_p5--curve-length-global-error-moves-cases-2026-11-08.md`).
|
||||
//! - **Out-params.** Flags with outputs are `Option`s (`IsLine`: point and vector;
|
||||
//! `IsArc`: frame, radius, start and end angle; the guesses); `IsLinear`'s
|
||||
//! optional outputs are the fields of [`Linearity`] (`want_deviation` stands for a
|
||||
|
||||
@@ -2330,6 +2330,40 @@ mod tests {
|
||||
assert_eq!(c.base().form(), BSplineCurveForm::EllipticArc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_1518_short_arc_inverts_each_point_to_itself() {
|
||||
// The 70.9232 x 16 ellipse of #1518 on an arc 7e-5 degrees long, shorter than
|
||||
// the angular tolerance (1e-4 degrees); atan2 puts its points a period up.
|
||||
let k = kernel();
|
||||
let (d0, d1) = (-10.0, -10.0 + 7.0e-5);
|
||||
let e = Ellipse::new(
|
||||
&k,
|
||||
Vec3::ZERO,
|
||||
Vec3::X,
|
||||
Vec3::Y,
|
||||
Extent1d::from_min_max(d0, d1),
|
||||
70.9232,
|
||||
16.0,
|
||||
&EllipseOptions::default(),
|
||||
)
|
||||
.expect("ellipse");
|
||||
let fractions = [0.25, 0.5, 0.75, 1.0];
|
||||
let points: Vec<(f64, Point3)> = fractions
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let a = d0 + f * (d1 - d0);
|
||||
(a, e.evaluate_step_point(a).expect("step point"))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// each point inverts to its own angle, not to the arc's start (the pinned C++
|
||||
// returns -10 for all four)
|
||||
for &(a, p) in &points {
|
||||
let t = e.step_inversion(&k, p, false, 0.0).0;
|
||||
assert!((t - a).abs() <= 1e-9 * a.abs(), "{a}: {t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_and_nurb_parameters_agree() {
|
||||
let k = kernel();
|
||||
|
||||
+71
-19
@@ -190,19 +190,33 @@ pub fn conic_on_form(k: &Kernel, conic: &Record, form: &Record) -> Result<Curve>
|
||||
Ok(Curve::from(e))
|
||||
}
|
||||
|
||||
/// A pcurve's numbers as the kernel's UV trim of an edge over `interval` (the edge's interval
|
||||
/// in the kernel's parameter): the record's pcurve runs along the edge over the edge's own
|
||||
/// interval in the record, and the kernel's trim runs along the edge over the edge's kernel
|
||||
/// interval, so its knots are mapped affinely from the pcurve's domain onto `interval` (copied
|
||||
/// as they are where the two are the same). Its points are scaled by `scale` in u and v: a
|
||||
/// plane face's kernel parameters are the record's times its axes' lengths (`[1, 1]`
|
||||
/// elsewhere, copied).
|
||||
/// Map a pcurve's entire natural support onto `interval` and scale its UV
|
||||
/// coordinates. This is for callers that intend to remap the whole support.
|
||||
/// A B-rep edge may occupy only part of that support: its importer must use
|
||||
/// [`uv_trim_on_edge`] with the record edge's actual interval instead.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `InvalidInput` for a spline whose knots do not fit its points or a pcurve or interval of no
|
||||
/// length; those of the constructors.
|
||||
pub fn uv_trim(c: &Record2, interval: &Extent1d, scale: [f64; 2]) -> Result<Curve> {
|
||||
let source = c
|
||||
.interval()
|
||||
.ok_or_else(|| SolidError::invalid_input(OP_TRIM, "invalid pcurve interval"))?;
|
||||
uv_trim_on_edge(c, source, interval, scale)
|
||||
}
|
||||
|
||||
/// Rebuild a pcurve while mapping the record edge parameter to the kernel edge
|
||||
/// parameter. The pcurve's own support may extend beyond either edge endpoint.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses invalid edge intervals or malformed UV spline data.
|
||||
pub fn uv_trim_on_edge(
|
||||
c: &Record2,
|
||||
source: [f64; 2],
|
||||
interval: &Extent1d,
|
||||
scale: [f64; 2],
|
||||
) -> Result<Curve> {
|
||||
let bad = |reason: &'static str| SolidError::invalid_input(OP_TRIM, reason);
|
||||
let [su, sv] = scale;
|
||||
let same = su == 1.0 && sv == 1.0;
|
||||
@@ -238,14 +252,35 @@ pub fn uv_trim(c: &Record2, interval: &Extent1d, scale: [f64; 2]) -> Result<Curv
|
||||
return Err(bad("too few knots"));
|
||||
};
|
||||
let (lo, hi) = (interval.min(), interval.max());
|
||||
if !(s_hi > s_lo) {
|
||||
let [from_lo, from_hi] = source;
|
||||
if !(s_lo.is_finite()
|
||||
&& s_hi.is_finite()
|
||||
&& s_hi > s_lo
|
||||
&& from_lo.is_finite()
|
||||
&& from_hi.is_finite()
|
||||
&& from_hi > from_lo
|
||||
&& lo.is_finite()
|
||||
&& hi.is_finite()
|
||||
&& hi > lo)
|
||||
{
|
||||
return Err(bad("the pcurve's domain has no length"));
|
||||
}
|
||||
let mapped: Vec<f64> = if (s_lo, s_hi) == (lo, hi) {
|
||||
let mapped: Vec<f64> = if (from_lo, from_hi) == (lo, hi) {
|
||||
knots.to_vec()
|
||||
} else {
|
||||
let by = (hi - lo) / (s_hi - s_lo);
|
||||
knots.iter().map(|s| lo + (s - s_lo) * by).collect()
|
||||
let by = (hi - lo) / (from_hi - from_lo);
|
||||
knots
|
||||
.iter()
|
||||
.map(|s| {
|
||||
if *s == from_lo {
|
||||
lo
|
||||
} else if *s == from_hi {
|
||||
hi
|
||||
} else {
|
||||
lo + (s - from_lo) * by
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
Ok(Curve::from(spline(&mapped, &rows, 2, OP_TRIM)?))
|
||||
}
|
||||
@@ -273,9 +308,11 @@ pub struct RecordCurve {
|
||||
/// The record of the kernel's edge curve `c` over `interval` (its kernel parameter), the
|
||||
/// inverse of [`from_parameters`] and [`conic_on_form`]:
|
||||
///
|
||||
/// - **Line:** the line through the curve's points at the interval's ends, over `[0, 1]`. A
|
||||
/// kernel line is affine in its parameter (its point plus the parameter times its vector),
|
||||
/// so this is the same line, the record's parameter an affine image of the kernel's.
|
||||
/// - **Line:** the line as the kernel holds it, its point and its point plus its vector, over
|
||||
/// the STEP parameters of the interval's ends (`ConvertTFromNURBSToSTEP`); where those run
|
||||
/// down, the vector and the parameters are negated so the interval increases. This is what
|
||||
/// SolidCraft's flat export wrote (`FlatFromBrep`'s line origin and direction, its range in
|
||||
/// STEP parameters), so a line comes back as it went in, not reparameterised onto `[0, 1]`.
|
||||
/// - **Circle, ellipse:** the label on the conic's own frame (copied, not rebuilt by a cross
|
||||
/// product), its y axis reversed where the conic's NURBS runs against its angle (its own
|
||||
/// `inside_out`, no sampling), with that NURBS as its form, over the kernel's interval.
|
||||
@@ -298,15 +335,30 @@ pub fn record_of(k: &Kernel, c: &Curve, interval: &Extent1d) -> Result<RecordCur
|
||||
return Err(bad("the interval is not finite and increasing"));
|
||||
}
|
||||
match c.kind() {
|
||||
CurveKind::Line(_) => {
|
||||
let (a, b) = (c.evaluate_point(k, lo)?, c.evaluate_point(k, hi)?);
|
||||
CurveKind::Line(l) => {
|
||||
let can = l.canonical();
|
||||
let (mut s0, mut s1) = (
|
||||
c.convert_t_from_nurbs_to_step(k, lo)?,
|
||||
c.convert_t_from_nurbs_to_step(k, hi)?,
|
||||
);
|
||||
let mut v = can.vector;
|
||||
// a line whose parameter runs against its vector over the edge: the vector
|
||||
// reversed, the parameters negated, so the record's interval increases
|
||||
if s0 > s1 {
|
||||
(s0, s1) = (-s0, -s1);
|
||||
v = -v;
|
||||
}
|
||||
if !(s0.is_finite() && s1.is_finite() && s1 > s0) {
|
||||
return Err(bad("a line's parameters over the edge are not increasing"));
|
||||
}
|
||||
let p = can.point;
|
||||
return Ok(RecordCurve {
|
||||
curve: Record::Line {
|
||||
start: [a.x, a.y, a.z],
|
||||
end: [b.x, b.y, b.z],
|
||||
start: [p.x, p.y, p.z],
|
||||
end: [p.x + v.x, p.y + v.y, p.z + v.z],
|
||||
},
|
||||
nurbs: None,
|
||||
interval: [0.0, 1.0],
|
||||
interval: [s0, s1],
|
||||
});
|
||||
}
|
||||
CurveKind::Ellipse(e) => {
|
||||
|
||||
@@ -232,10 +232,11 @@ fn a_trim_runs_over_its_edges_kernel_interval() {
|
||||
assert_eq!((p.x, p.y), (1.0, 2.0));
|
||||
}
|
||||
|
||||
/// A kernel line goes to the record by its points at its interval's ends, over [0, 1]: the
|
||||
/// same line, its parameter an affine image of the kernel's, and back again unchanged.
|
||||
/// A kernel line goes to the record as the kernel holds it, its point and its point plus its
|
||||
/// vector, over the STEP parameters of its interval (#3137: as SolidCraft's flat export wrote
|
||||
/// it), and back again unchanged.
|
||||
#[test]
|
||||
fn a_line_goes_back_by_its_two_end_points() {
|
||||
fn a_line_goes_back_as_the_kernel_holds_it() {
|
||||
let k = k();
|
||||
let line = Curve::from(Line::create_canonical(
|
||||
Point3::new(1.0, 2.0, 3.0),
|
||||
@@ -245,11 +246,44 @@ fn a_line_goes_back_by_its_two_end_points() {
|
||||
assert_eq!(
|
||||
r.curve,
|
||||
Record::Line {
|
||||
start: [2.0, 2.0, 3.0],
|
||||
end: [5.0, 2.0, 3.0],
|
||||
start: [1.0, 2.0, 3.0],
|
||||
end: [3.0, 2.0, 3.0],
|
||||
}
|
||||
);
|
||||
assert_eq!((&r.nurbs, r.interval), (&None, [0.0, 1.0]));
|
||||
assert_eq!((&r.nurbs, r.interval), (&None, [0.5, 2.0]));
|
||||
let back = from_parameters(&k, &r.curve, r.interval).unwrap();
|
||||
assert_eq!(record_of(&k, &back.curve, &back.interval).unwrap(), r);
|
||||
}
|
||||
|
||||
/// A line whose STEP parameter runs against its NURBS parameter (`inside_out`: `s = -t`) goes to
|
||||
/// the record with its vector reversed and its interval negated, so the interval increases; the
|
||||
/// record's point at each end of it is the kernel's at that end, bit for bit, and the round
|
||||
/// trip is the identity.
|
||||
#[test]
|
||||
fn a_reversed_line_goes_back_with_its_vector_turned() {
|
||||
let k = k();
|
||||
let line = Curve::from(Line::infinite(
|
||||
Point3::new(1.0, 2.0, 3.0),
|
||||
Vec3::new(2.0, 0.0, 0.0),
|
||||
3,
|
||||
true,
|
||||
));
|
||||
let (lo, hi) = (0.5, 2.0);
|
||||
let r = record_of(&k, &line, &Extent1d::from_raw(lo, hi)).unwrap();
|
||||
let Record::Line { start, end } = r.curve else {
|
||||
panic!("not a line: {:?}", r.curve)
|
||||
};
|
||||
assert!(r.interval[0] < r.interval[1], "{:?}", r.interval);
|
||||
let on = |s: f64| {
|
||||
Point3::new(
|
||||
start[0] + s * (end[0] - start[0]),
|
||||
start[1] + s * (end[1] - start[1]),
|
||||
start[2] + s * (end[2] - start[2]),
|
||||
)
|
||||
};
|
||||
// the record's interval runs the kernel's ends in order
|
||||
assert_eq!(on(r.interval[0]), at(&k, &line, lo));
|
||||
assert_eq!(on(r.interval[1]), at(&k, &line, hi));
|
||||
let back = from_parameters(&k, &r.curve, r.interval).unwrap();
|
||||
assert_eq!(record_of(&k, &back.curve, &back.interval).unwrap(), r);
|
||||
}
|
||||
|
||||
+8
-1
@@ -172,8 +172,11 @@ pub enum Unsoundness {
|
||||
GeometryUnchecked,
|
||||
/// The input was a manifold solid and the body is not.
|
||||
NotManifoldSolid,
|
||||
/// The body is a manifold solid whose volume is not positive.
|
||||
/// The body is a manifold solid whose volume's enclosure lies below 0.
|
||||
VolumeNotPositive,
|
||||
/// The body is a manifold solid whose volume's enclosure still holds 0 after its
|
||||
/// budget: its sign is not proved.
|
||||
VolumeUndecided,
|
||||
}
|
||||
|
||||
/// How loops of an offset face touch without crossing ([`OffsetFailure::WallsTouch`]).
|
||||
@@ -449,6 +452,10 @@ impl core::fmt::Display for OffsetFailure {
|
||||
"the local operation's body is a manifold solid whose volume is not \
|
||||
positive: refused, the input unchanged",
|
||||
),
|
||||
Unsoundness::VolumeUndecided => f.write_str(
|
||||
"the local operation's body is a manifold solid whose volume's sign \
|
||||
could not be proved: refused, the input unchanged",
|
||||
),
|
||||
},
|
||||
Self::ExactCurveUncut => f.write_str(
|
||||
"the extended faces beside an edge meet on an exact curve that could not be \
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ pub mod polar_box;
|
||||
pub mod polynomial;
|
||||
pub mod pseudo_box;
|
||||
pub mod solution_array;
|
||||
pub mod step_matrix;
|
||||
pub mod tol;
|
||||
pub mod tools;
|
||||
pub mod transform;
|
||||
|
||||
+42
-6
@@ -72,6 +72,9 @@
|
||||
//! prefix and zero-fills, unbounded dimensions are not written, counts are
|
||||
//! incremented); arrays shorter than the dimension (undefined behaviour upstream)
|
||||
//! are rejected with [`SolidError::InvalidInput`].
|
||||
//! - `ContainsPeriodicValue` moves a value within the tolerance of the top of the period
|
||||
//! above the start down by the period, and keeps it where that lies in the interval,
|
||||
//! instead of setting it to the start (WR-509, #1518; tangency-labs/smlib#46).
|
||||
//! - `SM_ASSERT*` debug checks and the error-log side effect of `SE(...)` are not
|
||||
//! ported (they do not change results). The destructor's "undefined" sentinel has
|
||||
//! no Rust counterpart; [`Extent1d::is_defined`] still checks for it.
|
||||
@@ -844,12 +847,16 @@ impl Extent1d {
|
||||
|
||||
contained = map > self.min - zero && map < self.max + zero;
|
||||
|
||||
if !contained
|
||||
&& are_same_to_tol(map, ivl.max(), zero)
|
||||
&& are_same_to_tol(map - period, self.min, zero)
|
||||
{
|
||||
map = self.min;
|
||||
contained = true;
|
||||
let mut kept_down = false;
|
||||
if !contained && are_same_to_tol(map, ivl.max(), zero) {
|
||||
// a period down, kept where it lies in the interval; the C++ sets it to the
|
||||
// start instead (WR-509, #1518; tangency-labs/smlib#46)
|
||||
let down = map - period;
|
||||
if down > self.min - zero && down < self.max + zero {
|
||||
map = down;
|
||||
contained = true;
|
||||
kept_down = true;
|
||||
}
|
||||
}
|
||||
|
||||
let periodic_value =
|
||||
@@ -861,6 +868,12 @@ impl Extent1d {
|
||||
map
|
||||
};
|
||||
let mut dist_to_min = map - self.min;
|
||||
if kept_down {
|
||||
// it can lie up to the tolerance below the start: its distance from it, not a
|
||||
// signed offset (assert_valid takes min2 of the two). Only this new case: other
|
||||
// inputs keep the signed value upstream gives them.
|
||||
dist_to_min = dist_to_min.abs();
|
||||
}
|
||||
if dist_to_min > period / 2.0 {
|
||||
dist_to_min = period - dist_to_min;
|
||||
}
|
||||
@@ -1150,6 +1163,29 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_1518_value_past_start_moves_down_a_period() {
|
||||
// An interval shorter than the tolerance (1e-4 degrees), and a value 3e-5 past
|
||||
// its start given a period up: the C++ returns the start (-10).
|
||||
let q = Extent1d::from_min_max(-10.0, -10.0 + 7.0e-5);
|
||||
let v = 350.0 + 3.0e-5;
|
||||
let c = q.contains_periodic_value(v, 360.0, -1.0);
|
||||
assert!(c.contained);
|
||||
assert_eq!(c.periodic_value, v - 360.0);
|
||||
assert!((c.dist_to_min - 3.0e-5).abs() < 1e-12);
|
||||
assert_eq!(q.clamp_periodic_value(v, 360.0, -1.0), v - 360.0);
|
||||
|
||||
// a value within the tolerance below the start keeps its place a period down
|
||||
// (the C++: the start, 0)
|
||||
let r = Extent1d::from_min_max(0.0, 90.0);
|
||||
let w = 360.0 - 5.0e-5;
|
||||
let c = r.contains_periodic_value(w, 360.0, -1.0);
|
||||
assert!(c.contained);
|
||||
assert_eq!(c.periodic_value, w - 360.0);
|
||||
// its distance to the start is a distance, not the signed offset -5e-5
|
||||
assert!((c.dist_to_min - 5.0e-5).abs() < 1e-12, "{}", c.dist_to_min);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_vector() {
|
||||
let mut nd = ExtentNd::new(3);
|
||||
|
||||
+6
-4
@@ -1004,10 +1004,12 @@ mod tests {
|
||||
);
|
||||
assert!(!e1.is_point_on_boundary(p(1.1, 2.1), EFF_ZERO).0);
|
||||
|
||||
assert_eq!(
|
||||
e1.contains_periodic_point(p(8.9, 3.0), 4.0, 0.0, 0.2),
|
||||
(true, p(1.0, 3.0))
|
||||
);
|
||||
// u = 8.9 is 0.9 a period down, within the tolerance of [1, 3]: it keeps 0.9
|
||||
// (WR-509, #1518; upstream's MyTest expects the start, 1.0)
|
||||
let (inside, q) = e1.contains_periodic_point(p(8.9, 3.0), 4.0, 0.0, 0.2);
|
||||
assert!(inside);
|
||||
assert!((q.x - 0.9).abs() <= 1e-12, "{}", q.x);
|
||||
assert_eq!(q.y, 3.0);
|
||||
assert!(!e1.contains_periodic_point(p(8.9, 3.0), 4.0, 0.0, 0.0).0);
|
||||
|
||||
assert_eq!(e1.evaluate(0.5, 0.5), p(2.0, 3.0));
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
//! Affine matrices for STEP placements and transformations.
|
||||
//!
|
||||
//! STEP keeps placements as frames and transformation operators as directions and
|
||||
//! scales. Building their matrices and composing or inverting them is geometric
|
||||
//! work, so it lives here rather than in `solidcore-record` or `solidcore-ops`.
|
||||
//!
|
||||
//! The matrix convention is the kernel's: rows 0–2 are the images of X, Y and Z,
|
||||
//! row 3 is the translation (`p' = p·A + t`).
|
||||
|
||||
use crate::geom::vector3d::Vec3;
|
||||
|
||||
/// A 4×4 affine matrix in the kernel's row-major convention.
|
||||
pub type Matrix4 = [[f64; 4]; 4];
|
||||
|
||||
/// The identity matrix.
|
||||
pub const IDENTITY: Matrix4 = [
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
];
|
||||
|
||||
/// `a · b` in the row-vector convention.
|
||||
#[must_use]
|
||||
#[allow(clippy::indexing_slicing)] // 4×4 loops over fixed-size arrays
|
||||
pub fn concat(a: &Matrix4, b: &Matrix4) -> Matrix4 {
|
||||
let mut out = [[0.0; 4]; 4];
|
||||
for (row, out_row) in out.iter_mut().enumerate() {
|
||||
for (col, cell) in out_row.iter_mut().enumerate() {
|
||||
*cell = (0..4).map(|k| a[row][k] * b[k][col]).sum();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The inverse of an affine matrix, or `None` when its linear part is singular or
|
||||
/// the entries are not finite.
|
||||
#[must_use]
|
||||
pub fn inverse(m: &Matrix4) -> Option<Matrix4> {
|
||||
let (a, b, c) = (
|
||||
Vec3::new(m[0][0], m[0][1], m[0][2]),
|
||||
Vec3::new(m[1][0], m[1][1], m[1][2]),
|
||||
Vec3::new(m[2][0], m[2][1], m[2][2]),
|
||||
);
|
||||
let det = a.dot(b.cross(c));
|
||||
if !det.is_finite() || det == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let x = b.cross(c);
|
||||
let y = c.cross(a);
|
||||
let z = a.cross(b);
|
||||
// The inverse's columns are the cofactor cross products; its translation is
|
||||
// `-(t · A⁻¹)` in the row-vector convention.
|
||||
let linear = [
|
||||
[x.x / det, y.x / det, z.x / det],
|
||||
[x.y / det, y.y / det, z.y / det],
|
||||
[x.z / det, y.z / det, z.z / det],
|
||||
];
|
||||
let t = Vec3::new(m[3][0], m[3][1], m[3][2]);
|
||||
let translation = Vec3::new(
|
||||
-(t.x * linear[0][0] + t.y * linear[1][0] + t.z * linear[2][0]),
|
||||
-(t.x * linear[0][1] + t.y * linear[1][1] + t.z * linear[2][1]),
|
||||
-(t.x * linear[0][2] + t.y * linear[1][2] + t.z * linear[2][2]),
|
||||
);
|
||||
let out = [
|
||||
[linear[0][0], linear[0][1], linear[0][2], 0.0],
|
||||
[linear[1][0], linear[1][1], linear[1][2], 0.0],
|
||||
[linear[2][0], linear[2][1], linear[2][2], 0.0],
|
||||
[translation.x, translation.y, translation.z, 1.0],
|
||||
];
|
||||
out.iter().flatten().all(|v| v.is_finite()).then_some(out)
|
||||
}
|
||||
|
||||
fn unit(value: [f64; 3]) -> Option<Vec3> {
|
||||
let v = Vec3::from(value);
|
||||
v.unitize()
|
||||
.ok()
|
||||
.filter(|v| v.x.is_finite() && v.y.is_finite() && v.z.is_finite())
|
||||
}
|
||||
|
||||
/// The matrix of an `AXIS2_PLACEMENT`: origin, Z axis and X reference direction.
|
||||
///
|
||||
/// An omitted axis is +Z; an omitted reference direction chooses any X perpendicular
|
||||
/// to Z. The reference direction is projected onto the plane normal to Z.
|
||||
#[must_use]
|
||||
pub fn frame_matrix(
|
||||
origin: [f64; 3],
|
||||
axis: Option<[f64; 3]>,
|
||||
ref_direction: Option<[f64; 3]>,
|
||||
) -> Option<Matrix4> {
|
||||
let z = unit(axis.unwrap_or([0.0, 0.0, 1.0]))?;
|
||||
let x = if let Some(raw) = ref_direction {
|
||||
let v = Vec3::from(raw);
|
||||
let projected = v - z * v.dot(z);
|
||||
unit([projected.x, projected.y, projected.z])?
|
||||
} else {
|
||||
let candidate = if z.x.abs() < 0.9 {
|
||||
Vec3::new(1.0, 0.0, 0.0)
|
||||
} else {
|
||||
Vec3::new(0.0, 1.0, 0.0)
|
||||
};
|
||||
let v = candidate - z * candidate.dot(z);
|
||||
unit([v.x, v.y, v.z])?
|
||||
};
|
||||
let y = z.cross(x);
|
||||
Some([
|
||||
[x.x, x.y, x.z, 0.0],
|
||||
[y.x, y.y, y.z, 0.0],
|
||||
[z.x, z.y, z.z, 0.0],
|
||||
[origin[0], origin[1], origin[2], 1.0],
|
||||
])
|
||||
}
|
||||
|
||||
/// The matrix of a `CARTESIAN_TRANSFORMATION_OPERATOR_3D`.
|
||||
///
|
||||
/// An omitted direction is the matching global axis; an omitted scale is 1 (or
|
||||
/// `scale` for the second and third scale).
|
||||
#[must_use]
|
||||
pub fn operator_matrix(
|
||||
axis1: Option<[f64; 3]>,
|
||||
axis2: Option<[f64; 3]>,
|
||||
axis3: Option<[f64; 3]>,
|
||||
origin: [f64; 3],
|
||||
scale: Option<f64>,
|
||||
scale2: Option<f64>,
|
||||
scale3: Option<f64>,
|
||||
) -> Option<Matrix4> {
|
||||
let x = unit(axis1.unwrap_or([1.0, 0.0, 0.0]))?;
|
||||
let y = unit(axis2.unwrap_or([0.0, 1.0, 0.0]))?;
|
||||
let z = unit(axis3.unwrap_or([0.0, 0.0, 1.0]))?;
|
||||
let sx = scale.unwrap_or(1.0);
|
||||
let sy = scale2.or(scale).unwrap_or(1.0);
|
||||
let sz = scale3.or(scale).unwrap_or(1.0);
|
||||
if ![sx, sy, sz].iter().all(|v| v.is_finite() && *v != 0.0) {
|
||||
return None;
|
||||
}
|
||||
Some([
|
||||
[x.x * sx, x.y * sx, x.z * sx, 0.0],
|
||||
[y.x * sy, y.y * sy, y.z * sy, 0.0],
|
||||
[z.x * sz, z.y * sz, z.z * sz, 0.0],
|
||||
[origin[0], origin[1], origin[2], 1.0],
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_identity_inverse_is_the_identity() {
|
||||
assert_eq!(inverse(&IDENTITY), Some(IDENTITY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_translation_inverse_moves_back() {
|
||||
let mut translation = IDENTITY;
|
||||
translation[3] = [2.0, -3.0, 0.5, 1.0];
|
||||
let inverse = inverse(&translation).expect("translation is regular");
|
||||
assert_eq!(concat(&translation, &inverse), IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_omitted_frame_axis_is_z_and_omitted_x_is_chosen() {
|
||||
let frame = frame_matrix([1.0, 2.0, 3.0], None, None).expect("default frame");
|
||||
assert_eq!(frame[2], [0.0, 0.0, 1.0, 0.0]);
|
||||
assert_eq!(frame[3], [1.0, 2.0, 3.0, 1.0]);
|
||||
assert!(
|
||||
(frame[0][0] * frame[0][0] + frame[0][1] * frame[0][1] + frame[0][2] * frame[0][2]
|
||||
- 1.0)
|
||||
.abs()
|
||||
< 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_operator_applies_its_scales() {
|
||||
let matrix = operator_matrix(None, None, None, [0.0; 3], Some(2.0), Some(3.0), None)
|
||||
.expect("operator");
|
||||
assert_eq!(matrix[0], [2.0, 0.0, 0.0, 0.0]);
|
||||
assert_eq!(matrix[1], [0.0, 3.0, 0.0, 0.0]);
|
||||
assert_eq!(matrix[2], [0.0, 0.0, 2.0, 0.0]);
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -2374,10 +2374,14 @@ impl<O: LoopObject> FaceProps<O> {
|
||||
// (the status is only reported upstream; a handle type that cannot name the
|
||||
// loop bisector's objects is a limitation of the caller, not a status)
|
||||
match classify_result {
|
||||
Err(
|
||||
e @ (SolidError::InvalidInput { reason, .. }
|
||||
| SolidError::PortInvariant { reason, .. }),
|
||||
) if reason == crate::query::face::p3::NO_CURVE_HANDLES => {
|
||||
Err(e)
|
||||
if matches!(
|
||||
&e,
|
||||
SolidError::InvalidInput { reason, .. }
|
||||
| SolidError::PortInvariant { reason, .. }
|
||||
if *reason == crate::query::face::p3::NO_CURVE_HANDLES
|
||||
) =>
|
||||
{
|
||||
return Err(e);
|
||||
}
|
||||
r => Ok(ignored(r)?),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! unification and the API entry points) and [`p2`] (`DoStitching`, `StitchIntoSolid`,
|
||||
//! `StitchIntoShell`, `SplitEdgesWithVertices`, the one-list `StitchVertexPairs`).
|
||||
|
||||
mod bounds;
|
||||
pub mod p1;
|
||||
pub mod p2;
|
||||
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
//! Outward-rounded bounds for the geometry a stitch closure identifies.
|
||||
//!
|
||||
//! No upstream counterpart. The curve-pair bound is a bound on a parameter
|
||||
//! correspondence, hence on both directed Hausdorff distances. B-splines are
|
||||
//! enclosed on every knot span by homogeneous interval de Boor evaluation; no
|
||||
//! rounded control-polygon conversion or sampled distance enters the proof.
|
||||
//!
|
||||
//! Deviations: no recursion. Work is bounded by the stored knot count and the
|
||||
//! kernel's NURBS degree limit. Curves without an implemented exact enclosure,
|
||||
//! non-positive weights, extrapolation and non-finite bounds are refused.
|
||||
|
||||
use crate::blend::fillet_section::interval::Ivl;
|
||||
use crate::curve::curve::{Curve, CurveKind};
|
||||
use crate::curve::line::Line;
|
||||
use crate::geom::extent1d::Extent1d;
|
||||
use crate::math::Point3;
|
||||
use crate::nurbs::crv_basic::Curve as Nurbs;
|
||||
use crate::nurbs::knots::MAX_DEGREE;
|
||||
use crate::nurbs::math::{CPoint, NOW, NOZ};
|
||||
use crate::{Result, SolidError};
|
||||
|
||||
const OP: &str = "heal::stitch::bounds";
|
||||
type Box3 = [Ivl; 3];
|
||||
type Box4 = [Ivl; 4];
|
||||
|
||||
fn invalid(reason: &'static str) -> SolidError {
|
||||
SolidError::invalid_input(OP, reason)
|
||||
}
|
||||
|
||||
fn unsupported() -> SolidError {
|
||||
SolidError::Unsupported {
|
||||
operation: OP,
|
||||
reason: "stitch gap enclosure is implemented for lines and positive-weight B-splines only",
|
||||
}
|
||||
}
|
||||
|
||||
fn at<T: Copy>(values: &[T], index: usize) -> Result<T> {
|
||||
values
|
||||
.get(index)
|
||||
.copied()
|
||||
.ok_or_else(|| invalid("invalid spline storage"))
|
||||
}
|
||||
|
||||
fn finite(interval: Ivl) -> bool {
|
||||
interval.lo.is_finite() && interval.hi.is_finite() && interval.lo <= interval.hi
|
||||
}
|
||||
|
||||
fn norm(v: Box3) -> Result<f64> {
|
||||
if !v.into_iter().all(finite) {
|
||||
return Err(invalid("gap enclosure is not finite"));
|
||||
}
|
||||
let scale = v.into_iter().map(Ivl::mag).fold(0.0_f64, f64::max);
|
||||
if scale == 0.0 {
|
||||
return Ok(0.0);
|
||||
}
|
||||
// Scale before squaring: small distances must not underflow to a zero bound.
|
||||
let mut sum = Ivl::from(0.0);
|
||||
for coordinate in v {
|
||||
// Direct endpoint division avoids overflowing 1/scale for subnormals.
|
||||
let scaled = Ivl::checked(coordinate.lo / scale, coordinate.hi / scale);
|
||||
sum = sum + scaled * scaled;
|
||||
}
|
||||
let length = sum.sqrt() * Ivl::from(scale);
|
||||
if !finite(length) {
|
||||
return Err(invalid("gap enclosure overflows"));
|
||||
}
|
||||
Ok(length.hi.max(0.0))
|
||||
}
|
||||
|
||||
fn point_box(p: Point3) -> Result<Box3> {
|
||||
let coordinates = [p.x, p.y, p.z];
|
||||
if !coordinates.into_iter().all(f64::is_finite) {
|
||||
return Err(invalid("non-finite point"));
|
||||
}
|
||||
Ok(coordinates.map(Ivl::from))
|
||||
}
|
||||
|
||||
fn difference(a: Box3, b: Box3) -> Box3 {
|
||||
core::array::from_fn(|i| {
|
||||
let x = a.get(i).copied().unwrap_or(Ivl::whole());
|
||||
let y = b.get(i).copied().unwrap_or(Ivl::whole());
|
||||
if x.lo == x.hi && x == y {
|
||||
Ivl::from(0.0)
|
||||
} else {
|
||||
x - y
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Upper bound on the exact Euclidean distance between the stored points.
|
||||
pub(super) fn points(a: Point3, b: Point3) -> Result<f64> {
|
||||
norm(difference(point_box(a)?, point_box(b)?))
|
||||
}
|
||||
|
||||
/// Upper bound on the positive increase between two stored tolerances.
|
||||
pub(super) fn growth(before: f64, after: f64) -> Result<f64> {
|
||||
if ![before, after]
|
||||
.into_iter()
|
||||
.all(|v| v.is_finite() && v >= 0.0)
|
||||
{
|
||||
return Err(invalid("non-finite or negative tolerance"));
|
||||
}
|
||||
if after <= before {
|
||||
return Ok(0.0);
|
||||
}
|
||||
let increase = Ivl::from(after) - Ivl::from(before);
|
||||
if !finite(increase) {
|
||||
return Err(invalid("tolerance growth overflows"));
|
||||
}
|
||||
Ok(increase.hi)
|
||||
}
|
||||
|
||||
fn checked_range(range: [f64; 2]) -> Result<Ivl> {
|
||||
if !range.into_iter().all(f64::is_finite) {
|
||||
return Err(invalid("non-finite curve interval"));
|
||||
}
|
||||
Ok(Ivl::new(range[0].min(range[1]), range[0].max(range[1])))
|
||||
}
|
||||
|
||||
fn homogeneous(c: CPoint) -> Result<Box4> {
|
||||
let coordinates = [
|
||||
c.x,
|
||||
c.y,
|
||||
if c.z == NOZ { 0.0 } else { c.z },
|
||||
if c.w == NOW { 1.0 } else { c.w },
|
||||
];
|
||||
if !coordinates.into_iter().all(f64::is_finite) || coordinates.last().is_none_or(|w| *w <= 0.0)
|
||||
{
|
||||
return Err(invalid("non-finite spline data or non-positive weight"));
|
||||
}
|
||||
Ok(coordinates.map(Ivl::from))
|
||||
}
|
||||
|
||||
fn validate(n: &Nurbs, range: Ivl) -> Result<()> {
|
||||
let p = n.degree();
|
||||
let count = n.cpts().len();
|
||||
if p > MAX_DEGREE + 1 || count <= p || count.checked_add(p + 1) != Some(n.knots().len()) {
|
||||
return Err(invalid("invalid spline degree or storage"));
|
||||
}
|
||||
if !n.knots().iter().all(|k| k.is_finite())
|
||||
|| n.knots().windows(2).any(|w| matches!(w, [a, b] if a > b))
|
||||
{
|
||||
return Err(invalid("invalid knot vector"));
|
||||
}
|
||||
if at(n.knots(), p)? >= at(n.knots(), count)?
|
||||
|| range.lo < at(n.knots(), p)?
|
||||
|| range.hi > at(n.knots(), count)?
|
||||
{
|
||||
return Err(invalid("curve gap enclosure does not extrapolate"));
|
||||
}
|
||||
let first = at(n.cpts(), 0)?;
|
||||
for &c in n.cpts() {
|
||||
if (c.w == NOW) != (first.w == NOW) || (c.z == NOZ) != (first.z == NOZ) {
|
||||
return Err(invalid("mixed homogeneous coordinate storage"));
|
||||
}
|
||||
homogeneous(c)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The exact rational polynomial on one nonempty knot span, evaluated in intervals.
|
||||
fn piece(n: &Nurbs, span: usize, t: Ivl) -> Result<Box3> {
|
||||
let p = n.degree();
|
||||
let first = span
|
||||
.checked_sub(p)
|
||||
.ok_or_else(|| invalid("invalid spline span"))?;
|
||||
let mut d = Vec::new();
|
||||
let mut min_weight = f64::INFINITY;
|
||||
for j in 0..=p {
|
||||
let h = homogeneous(at(n.cpts(), first + j)?)?;
|
||||
min_weight = min_weight.min(at(&h, 3)?.lo);
|
||||
d.push(h);
|
||||
}
|
||||
for r in 1..=p {
|
||||
for j in (r..=p).rev() {
|
||||
let i = first + j;
|
||||
let denominator =
|
||||
Ivl::from(at(n.knots(), i + p + 1 - r)?) - Ivl::from(at(n.knots(), i)?);
|
||||
if denominator.lo <= 0.0 {
|
||||
return Err(invalid("unproved knot denominator"));
|
||||
}
|
||||
let alpha = (t - Ivl::from(at(n.knots(), i)?)) / denominator;
|
||||
// On the selected span, the exact alpha is in [0, 1]. Intersecting
|
||||
// with that proved range removes rounding spill, not geometric error.
|
||||
let alpha = Ivl::new(alpha.lo.max(0.0), alpha.hi.min(1.0));
|
||||
if !finite(alpha) {
|
||||
return Err(invalid("invalid spline parameter enclosure"));
|
||||
}
|
||||
let left = at(&d, j - 1)?;
|
||||
let right = at(&d, j)?;
|
||||
let value = core::array::from_fn(|axis| {
|
||||
let a = left.get(axis).copied().unwrap_or(Ivl::whole());
|
||||
let b = right.get(axis).copied().unwrap_or(Ivl::whole());
|
||||
(Ivl::from(1.0) - alpha) * a + alpha * b
|
||||
});
|
||||
*d.get_mut(j)
|
||||
.ok_or_else(|| invalid("invalid spline workspace"))? = value;
|
||||
}
|
||||
}
|
||||
let h = at(&d, p)?;
|
||||
// Positive basis functions sum to one, so W is at least the least active weight.
|
||||
let w = at(&h, 3)?;
|
||||
let w = Ivl::new(w.lo.max(min_weight), w.hi);
|
||||
if !finite(w) || w.lo <= 0.0 {
|
||||
return Err(invalid("unproved spline denominator"));
|
||||
}
|
||||
let result = [at(&h, 0)? / w, at(&h, 1)? / w, at(&h, 2)? / w];
|
||||
if !result.into_iter().all(finite) {
|
||||
return Err(invalid("curve enclosure overflows"));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn spline_box(n: &Nurbs, range: Ivl) -> Result<Box3> {
|
||||
validate(n, range)?;
|
||||
let mut result: Option<Box3> = None;
|
||||
for (span, pair) in n.knots().windows(2).enumerate() {
|
||||
let [a, b] = pair else {
|
||||
continue;
|
||||
};
|
||||
if span < n.degree() || span >= n.cpts().len() || a >= b || *b < range.lo || *a > range.hi {
|
||||
continue;
|
||||
}
|
||||
let t = Ivl::new(range.lo.max(*a), range.hi.min(*b));
|
||||
let part = piece(n, span, t)?;
|
||||
result = Some(match result {
|
||||
None => part,
|
||||
Some(old) => core::array::from_fn(|i| {
|
||||
old.get(i)
|
||||
.copied()
|
||||
.unwrap_or(Ivl::whole())
|
||||
.hull(part.get(i).copied().unwrap_or(Ivl::whole()))
|
||||
}),
|
||||
});
|
||||
}
|
||||
result.ok_or_else(|| invalid("no knot span covers the curve interval"))
|
||||
}
|
||||
|
||||
fn line_box(line: &Line, t: Ivl) -> Result<Box3> {
|
||||
let natural = line.natural_interval();
|
||||
let step = line.step_interval();
|
||||
if !finite(t)
|
||||
|| !finite(checked_range([natural.min(), natural.max()])?)
|
||||
|| !finite(checked_range([step.min(), step.max()])?)
|
||||
{
|
||||
return Err(invalid("non-finite line parameterization"));
|
||||
}
|
||||
if t.lo < natural.min() || t.hi > natural.max() {
|
||||
return Err(invalid("curve gap enclosure does not extrapolate"));
|
||||
}
|
||||
let u = if line.is_bounded() {
|
||||
let length = Ivl::from(natural.max()) - Ivl::from(natural.min());
|
||||
if length.lo <= 0.0 || crate::geom::tol::is_zero(natural.length()) {
|
||||
return Err(invalid("degenerate line parameterization"));
|
||||
}
|
||||
let (start, direction) = if line.inside_out() {
|
||||
(natural.max(), -1.0)
|
||||
} else {
|
||||
(natural.min(), 1.0)
|
||||
};
|
||||
Ivl::from(step.min())
|
||||
+ (t - Ivl::from(start)) / length
|
||||
* Ivl::from(direction)
|
||||
* (Ivl::from(step.max()) - Ivl::from(step.min()))
|
||||
} else if line.inside_out() {
|
||||
Ivl::from(0.0) - t
|
||||
} else {
|
||||
t
|
||||
};
|
||||
let p = point_box(line.line_point())?;
|
||||
let v = point_box(line.line_vector())?;
|
||||
if !line.line_scale().is_finite() {
|
||||
return Err(invalid("non-finite line scale"));
|
||||
}
|
||||
let result = core::array::from_fn(|i| {
|
||||
p.get(i).copied().unwrap_or(Ivl::whole())
|
||||
+ u * v.get(i).copied().unwrap_or(Ivl::whole()) * Ivl::from(line.line_scale())
|
||||
});
|
||||
if !result.into_iter().all(finite) {
|
||||
return Err(invalid("line enclosure overflows"));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn curve_box(curve: &Curve, range: Ivl) -> Result<Box3> {
|
||||
match curve.kind() {
|
||||
CurveKind::Line(l) => line_box(l, range),
|
||||
CurveKind::BSpline(b) => spline_box(
|
||||
b.gw_nurb().ok_or_else(|| invalid("missing spline data"))?,
|
||||
range,
|
||||
),
|
||||
_ => Err(unsupported()),
|
||||
}
|
||||
}
|
||||
|
||||
/// An upper bound for point-to-curve distance using an exact point of the curve.
|
||||
pub(super) fn point_at_curve(point: Point3, curve: &Curve, parameter: f64) -> Result<f64> {
|
||||
norm(difference(
|
||||
point_box(point)?,
|
||||
curve_box(curve, checked_range([parameter, parameter])?)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Bounds the whole curve's distance from a point (the geometry a squeeze removes).
|
||||
pub(super) fn curve_to_point(curve: &Curve, interval: &Extent1d, point: Point3) -> Result<f64> {
|
||||
norm(difference(
|
||||
curve_box(curve, checked_range([interval.min(), interval.max()])?)?,
|
||||
point_box(point)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Bounds both directed curve gaps under the stated endpoint correspondence.
|
||||
pub(super) fn curves(first: &Curve, a: [f64; 2], second: &Curve, b: [f64; 2]) -> Result<f64> {
|
||||
let ar = checked_range(a)?;
|
||||
let br = checked_range(b)?;
|
||||
// With a common positive rational basis the difference is a convex combination
|
||||
// of the dehomogenized control-point differences. This also proves identity
|
||||
// exactly, without subtracting independently widened position boxes.
|
||||
if a == b {
|
||||
if let (CurveKind::BSpline(x), CurveKind::BSpline(y)) = (first.kind(), second.kind()) {
|
||||
let x = x.gw_nurb().ok_or_else(|| invalid("missing spline data"))?;
|
||||
let y = y.gw_nurb().ok_or_else(|| invalid("missing spline data"))?;
|
||||
validate(x, ar)?;
|
||||
validate(y, br)?;
|
||||
if x.degree() == y.degree()
|
||||
&& x.knots() == y.knots()
|
||||
&& x.cpts().iter().zip(y.cpts()).all(|(p, q)| p.w == q.w)
|
||||
{
|
||||
let mut upper = 0.0_f64;
|
||||
for (&p, &q) in x.cpts().iter().zip(y.cpts()) {
|
||||
let p = homogeneous(p)?;
|
||||
let q = homogeneous(q)?;
|
||||
let delta = difference(
|
||||
[at(&p, 0)?, at(&p, 1)?, at(&p, 2)?],
|
||||
[at(&q, 0)?, at(&q, 1)?, at(&q, 2)?],
|
||||
);
|
||||
let w = at(&p, 3)?;
|
||||
upper = upper.max(norm(delta.map(|d| {
|
||||
if d == Ivl::from(0.0) {
|
||||
d
|
||||
} else {
|
||||
d / w
|
||||
}
|
||||
}))?);
|
||||
}
|
||||
return Ok(upper);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Affine curves attain the maximum norm of their difference at an endpoint.
|
||||
// Otherwise enclose every parameter cell, never only its sample stations.
|
||||
let cells = if matches!(
|
||||
(first.kind(), second.kind()),
|
||||
(CurveKind::Line(_), CurveKind::Line(_))
|
||||
) {
|
||||
1_u32
|
||||
} else {
|
||||
64_u32
|
||||
};
|
||||
let mut upper = 0.0_f64;
|
||||
for i in 0..cells {
|
||||
let s = Ivl::new(
|
||||
f64::from(i) / f64::from(cells),
|
||||
f64::from(i + 1) / f64::from(cells),
|
||||
);
|
||||
let mapped =
|
||||
|range: [f64; 2]| Ivl::from(range[0]) * (Ivl::from(1.0) - s) + Ivl::from(range[1]) * s;
|
||||
if cells == 1 {
|
||||
for j in [0, 1] {
|
||||
let t = at(&a, j)?;
|
||||
let u = at(&b, j)?;
|
||||
upper = upper.max(norm(difference(
|
||||
curve_box(first, Ivl::from(t))?,
|
||||
curve_box(second, Ivl::from(u))?,
|
||||
))?);
|
||||
}
|
||||
} else {
|
||||
// Clip only the rounding spill of the affine parameter map.
|
||||
let x = mapped(a);
|
||||
let y = mapped(b);
|
||||
upper = upper.max(norm(difference(
|
||||
curve_box(first, Ivl::new(x.lo.max(ar.lo), x.hi.min(ar.hi)))?,
|
||||
curve_box(second, Ivl::new(y.lo.max(br.lo), y.hi.min(br.hi)))?,
|
||||
))?);
|
||||
}
|
||||
}
|
||||
Ok(upper)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::curve::hermite_curve::HermiteCurve;
|
||||
use crate::nurbs::b_spline_curve::BSplineCurve;
|
||||
use crate::{Kernel, Vec3};
|
||||
|
||||
/// Retain the raw data: an ordinary clone/owned constructor tunes the knots.
|
||||
fn spline(points: Vec<CPoint>, degree: usize, knots: Vec<f64>) -> Curve {
|
||||
BSplineCurve::from_nurb(Nurbs::new(points, degree, knots).unwrap(), 3, true).into()
|
||||
}
|
||||
|
||||
fn cp(x: f64, y: f64, w: f64) -> CPoint {
|
||||
CPoint { x, y, z: 0.0, w }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_curve_bound_catches_the_interior_bump_five_samples_miss() {
|
||||
// A degree-one hat on [1/16, 3/16], peaking at 1/8. Every old sample
|
||||
// at 0, 1/4, 1/2, 3/4, 1 is exactly on the flat curve.
|
||||
let knots = vec![0.0, 0.0, 0.0625, 0.125, 0.1875, 1.0, 1.0];
|
||||
let flat = spline(
|
||||
vec![
|
||||
cp(0.0, 0.0, NOW),
|
||||
cp(0.0625, 0.0, NOW),
|
||||
cp(0.125, 0.0, NOW),
|
||||
cp(0.1875, 0.0, NOW),
|
||||
cp(1.0, 0.0, NOW),
|
||||
],
|
||||
1,
|
||||
knots.clone(),
|
||||
);
|
||||
let bump = spline(
|
||||
vec![
|
||||
cp(0.0, 0.0, NOW),
|
||||
cp(0.0625, 0.0, NOW),
|
||||
cp(0.125, 1.0, NOW),
|
||||
cp(0.1875, 0.0, NOW),
|
||||
cp(1.0, 0.0, NOW),
|
||||
],
|
||||
1,
|
||||
knots,
|
||||
);
|
||||
let k = Kernel::default();
|
||||
for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
assert_eq!(
|
||||
flat.evaluate_point(&k, t).unwrap(),
|
||||
bump.evaluate_point(&k, t).unwrap()
|
||||
);
|
||||
}
|
||||
assert_eq!(bump.evaluate_point(&k, 0.125).unwrap().y, 1.0);
|
||||
let bound = curves(&flat, [0.0, 1.0], &bump, [0.0, 1.0]).unwrap();
|
||||
assert!((1.0..1.0 + 1e-12).contains(&bound), "{bound}");
|
||||
let squeezed =
|
||||
curve_to_point(&bump, &Extent1d::from_min_max(0.0, 1.0), Vec3::ZERO).unwrap();
|
||||
assert!(squeezed >= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_rational_weights_are_enclosed_without_a_polynomial_substitute() {
|
||||
// Y(t) = 4t(1-t)/(1+2t(1-t)), maximum 2/3 at 1/2.
|
||||
let rational = spline(
|
||||
vec![cp(0.0, 0.0, 1.0), cp(1.0, 2.0, 2.0), cp(1.0, 0.0, 1.0)],
|
||||
2,
|
||||
vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
);
|
||||
let line: Curve = Line::from_points(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0), 3).into();
|
||||
let bound = curves(&rational, [0.0, 1.0], &line, [0.0, 1.0]).unwrap();
|
||||
assert!((2.0 / 3.0..1.0).contains(&bound), "{bound}");
|
||||
let at_mid = point_at_curve(Vec3::new(0.5, 0.0, 0.0), &rational, 0.5).unwrap();
|
||||
assert!((2.0 / 3.0..2.0 / 3.0 + 1e-12).contains(&at_mid), "{at_mid}");
|
||||
assert_eq!(
|
||||
curves(&rational, [0.0, 1.0], &rational, [0.0, 1.0]).unwrap(),
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_and_subdomain_correspondences_cover_every_parameter() {
|
||||
let forward = spline(
|
||||
vec![cp(0.0, 0.0, NOW), cp(1.0, 0.0, NOW)],
|
||||
1,
|
||||
vec![0.0, 0.0, 1.0, 1.0],
|
||||
);
|
||||
let reverse = spline(
|
||||
vec![cp(1.0, 0.0, NOW), cp(0.0, 0.0, NOW)],
|
||||
1,
|
||||
vec![0.0, 0.0, 1.0, 1.0],
|
||||
);
|
||||
let bound = curves(&forward, [0.25, 0.75], &reverse, [0.75, 0.25]).unwrap();
|
||||
assert!(bound.is_finite() && bound < 0.1, "{bound}");
|
||||
let endpoint_bound = point_at_curve(Vec3::ZERO, &forward, 0.75).unwrap();
|
||||
assert!((0.75..0.75 + 1e-12).contains(&endpoint_bound));
|
||||
assert!(curves(&forward, [-0.1, 0.75], &reverse, [0.75, 0.25]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_and_growth_bounds_do_not_underflow_or_accept_nonfinite_results() {
|
||||
assert_eq!(points(Vec3::ZERO, Vec3::ZERO).unwrap(), 0.0);
|
||||
for tiny in [1e-200, f64::from_bits(1)] {
|
||||
let bound = points(Vec3::ZERO, Vec3::new(tiny, 0.0, 0.0)).unwrap();
|
||||
assert!(bound.is_finite() && bound >= tiny && bound > 0.0, "{bound}");
|
||||
}
|
||||
assert!(points(Vec3::ZERO, Vec3::new(1e300, 0.0, 0.0)).unwrap() >= 1e300);
|
||||
assert!(points(
|
||||
Vec3::new(-f64::MAX, 0.0, 0.0),
|
||||
Vec3::new(f64::MAX, 0.0, 0.0)
|
||||
)
|
||||
.is_err());
|
||||
assert!(points(Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)).is_err());
|
||||
assert_eq!(growth(1.0, 1.0).unwrap(), 0.0);
|
||||
assert_eq!(growth(2.0, 1.0).unwrap(), 0.0);
|
||||
assert!(growth(1.0, 2.0).unwrap() >= 1.0);
|
||||
assert!(growth(0.0, f64::from_bits(1)).unwrap() > 0.0);
|
||||
assert!(growth(-1.0, 0.0).is_err());
|
||||
assert!(growth(0.0, f64::INFINITY).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_weights_knots_storage_and_unsupported_kinds_fail_explicitly() {
|
||||
for weight in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
let c = spline(
|
||||
vec![cp(0.0, 0.0, weight), cp(1.0, 0.0, 1.0)],
|
||||
1,
|
||||
vec![0.0, 0.0, 1.0, 1.0],
|
||||
);
|
||||
assert!(point_at_curve(Vec3::ZERO, &c, 0.5).is_err());
|
||||
}
|
||||
for (degree, knots) in [
|
||||
(1, vec![0.0, 1.0]),
|
||||
(1, vec![0.0; 4]),
|
||||
(1, vec![0.0, 1.0, 0.0, 1.0]),
|
||||
(usize::MAX, vec![0.0, 0.0, 1.0, 1.0]),
|
||||
] {
|
||||
let c = spline(vec![cp(0.0, 0.0, NOW), cp(1.0, 0.0, NOW)], degree, knots);
|
||||
assert!(point_at_curve(Vec3::ZERO, &c, 0.5).is_err());
|
||||
}
|
||||
let mixed = spline(
|
||||
vec![cp(0.0, 0.0, NOW), cp(1.0, 0.0, 1.0)],
|
||||
1,
|
||||
vec![0.0, 0.0, 1.0, 1.0],
|
||||
);
|
||||
assert!(point_at_curve(Vec3::ZERO, &mixed, 0.5).is_err());
|
||||
let hermite: Curve = HermiteCurve::new(
|
||||
Vec3::ZERO,
|
||||
Vec3::new(1.0, 0.0, 0.0),
|
||||
Vec3::new(1.0, 0.0, 0.0),
|
||||
Vec3::new(1.0, 0.0, 0.0),
|
||||
3,
|
||||
)
|
||||
.into();
|
||||
assert!(matches!(
|
||||
point_at_curve(Vec3::ZERO, &hermite, 0.5),
|
||||
Err(SolidError::Unsupported { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,8 @@ use crate::topology::{
|
||||
};
|
||||
use crate::{Result, SolidError};
|
||||
|
||||
use super::p2::{ClosedGap, ClosedGapKind};
|
||||
|
||||
const OP: &str = "heal::stitch::p1";
|
||||
/// The operation of the `SM_API` entry points' `SM_ERR_INVALID_INPUT` (null arguments).
|
||||
const API_OP: &str = "heal::stitch::p1::api";
|
||||
@@ -932,6 +934,47 @@ pub struct Stitch<'c> {
|
||||
/// Remove sliver faces smaller than tolerance first (`m_bRemoveLaminarSlivers`,
|
||||
/// default `FALSE`).
|
||||
pub remove_laminar_slivers: bool,
|
||||
/// When `Some`, the stitch records what it closed: the entities, the gap it
|
||||
/// enclosed, and the tolerance growth its editing applied (not upstream,
|
||||
/// solidcore#2975). Nothing else changes.
|
||||
pub report_gaps: Option<Vec<ClosedGap>>,
|
||||
}
|
||||
|
||||
impl Stitch<'_> {
|
||||
/// The collected gap records, taking them out (`Ok(vec![])` when the caller did
|
||||
/// not ask for them).
|
||||
pub(crate) fn take_gaps(&mut self) -> Vec<ClosedGap> {
|
||||
self.report_gaps.take().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Records one closed gap when the caller asked for the report. The growth is
|
||||
/// an outward-rounded bound on how much the surviving entity's tolerance rose
|
||||
/// over its own tolerance before the closure, including inherited tolerance.
|
||||
pub(crate) fn record_gap(
|
||||
&mut self,
|
||||
kind: ClosedGapKind,
|
||||
gap: f64,
|
||||
before: f64,
|
||||
after: f64,
|
||||
) -> Result<()> {
|
||||
if let Some(gaps) = self.report_gaps.as_mut() {
|
||||
let growth = super::bounds::growth(before, after)?;
|
||||
if !gap.is_finite() || gap < 0.0 {
|
||||
return Err(SolidError::invalid_input(
|
||||
"heal::stitch::bounds",
|
||||
"non-finite or negative gap bound",
|
||||
));
|
||||
}
|
||||
gaps.push(ClosedGap {
|
||||
kind,
|
||||
gap,
|
||||
growth,
|
||||
tolerance_before: before,
|
||||
tolerance_after: after,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl core::fmt::Debug for Stitch<'_> {
|
||||
@@ -1151,6 +1194,7 @@ impl<'c> Stitch<'c> {
|
||||
split_edges_with_vertices: true,
|
||||
making_manifold_solid: true,
|
||||
remove_laminar_slivers: false,
|
||||
report_gaps: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+186
@@ -351,6 +351,62 @@ pub struct VertexList {
|
||||
pub min_unstitched_vert_gap: f64,
|
||||
}
|
||||
|
||||
/// What the stitcher closed, as the entities it glued (solidcore#2975).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ClosedGapKind {
|
||||
/// A vertex pair glued into the kept vertex.
|
||||
Vertex {
|
||||
/// The vertex the glue kept.
|
||||
kept: VertexId,
|
||||
/// The vertex the glue deleted.
|
||||
deleted: VertexId,
|
||||
},
|
||||
/// An edge pair glued into the kept edge.
|
||||
Edge {
|
||||
/// The edge the glue kept.
|
||||
kept: EdgeId,
|
||||
/// The edge the glue deleted.
|
||||
deleted: EdgeId,
|
||||
},
|
||||
/// A vertex pair one edge was squeezed into (the kept vertex stays).
|
||||
Squeezed {
|
||||
/// The vertex the squeeze kept.
|
||||
kept: VertexId,
|
||||
/// The vertex the squeeze closed over.
|
||||
deleted: VertexId,
|
||||
},
|
||||
/// A vertex dropped onto an edge and split there, with the vertex its split created.
|
||||
Split {
|
||||
/// The edge the vertex was dropped onto and split.
|
||||
edge: EdgeId,
|
||||
/// The vertex the split created.
|
||||
created: VertexId,
|
||||
},
|
||||
}
|
||||
|
||||
/// A closure's certified geometry bound and tolerance accounting (solidcore#2975).
|
||||
/// The bound includes floating-point rounding. For edges it bounds both directed
|
||||
/// Hausdorff distances via an endpoint-to-endpoint parameter correspondence; it
|
||||
/// need not be the tightest Hausdorff bound. Unsupported enclosures return an
|
||||
/// error from the reporting operation, never a sampled estimate as a certificate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ClosedGap {
|
||||
/// What the stitcher closed.
|
||||
pub kind: ClosedGapKind,
|
||||
/// Certified upper bound on the gap, in model units. A squeeze bounds the
|
||||
/// whole removed curve's distance to the kept point; a split bounds the source
|
||||
/// point's distance to the original curve at the split parameter.
|
||||
pub gap: f64,
|
||||
/// Upper bound on `max(tolerance_after - tolerance_before, 0)`, rounded up.
|
||||
/// Exactly zero when the surviving entity's stored tolerance did not grow.
|
||||
pub growth: f64,
|
||||
/// The surviving entity's own stored tolerance before this closure. For a
|
||||
/// split this is the new vertex's tolerance before its explicit adjustment.
|
||||
pub tolerance_before: f64,
|
||||
/// The surviving entity's stored tolerance after this closure.
|
||||
pub tolerance_after: f64,
|
||||
}
|
||||
|
||||
/// The result of [`Stitch::split_edges_with_vertices`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SplitEdges {
|
||||
@@ -479,9 +535,36 @@ impl Stitch<'_> {
|
||||
}
|
||||
|
||||
let surv_v = surv_v.ok_or_else(|| deref("edge without vertex"))?;
|
||||
let deleted_report = del_v.unwrap_or(surv_v);
|
||||
// Bound all the removed geometry, not its sampled length or
|
||||
// merely the distance between its endpoints.
|
||||
let squeeze_report = if self.report_gaps.is_some() {
|
||||
Some((
|
||||
vertex::tolerance(b, surv_v)?,
|
||||
super::bounds::curve_to_point(
|
||||
curve_of(b, e)?,
|
||||
&edge::interval(b, e)?,
|
||||
vertex::point(b, surv_v)?,
|
||||
)?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// remove and delete edge from topology graph by squeezing
|
||||
// [vertex-edge-vertex] into [vertex]
|
||||
self.edit_squeeze_edge(k, b, Some(e), surv_v)?;
|
||||
if let Some((pre, gap)) = squeeze_report {
|
||||
let after = vertex::tolerance(b, surv_v)?;
|
||||
self.record_gap(
|
||||
ClosedGapKind::Squeezed {
|
||||
kept: surv_v,
|
||||
deleted: deleted_report,
|
||||
},
|
||||
gap,
|
||||
pre,
|
||||
after,
|
||||
)?;
|
||||
}
|
||||
|
||||
if candidate_vertices.is_some() {
|
||||
add_unique(&mut cand_vertices, surv_v); // This becomes a candidate.
|
||||
@@ -570,7 +653,42 @@ impl Stitch<'_> {
|
||||
// candidates
|
||||
let new_tol =
|
||||
smos_max(vertex::tolerance(b, v)?, vertex::tolerance(b, new_v)?);
|
||||
let split_report = if self.report_gaps.is_some() {
|
||||
let range = edge::interval(b, e)?;
|
||||
let parameter = if edge::start_vertex(b, e)? == Some(new_v) {
|
||||
range.min()
|
||||
} else if edge::end_vertex(b, e)? == Some(new_v) {
|
||||
range.max()
|
||||
} else {
|
||||
return Err(SolidError::invalid_input(
|
||||
"heal::stitch::bounds",
|
||||
"split vertex is not on either resulting edge end",
|
||||
));
|
||||
};
|
||||
Some((
|
||||
vertex::tolerance(b, new_v)?,
|
||||
super::bounds::point_at_curve(
|
||||
vertex::point(b, v)?,
|
||||
curve_of(b, e)?,
|
||||
parameter,
|
||||
)?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
vertex::set_tolerance(b, new_v, zone_tol3d(k, new_tol), true, true)?;
|
||||
if let Some((pre, bound)) = split_report {
|
||||
let after = vertex::tolerance(b, new_v)?;
|
||||
self.record_gap(
|
||||
ClosedGapKind::Split {
|
||||
edge: e,
|
||||
created: new_v,
|
||||
},
|
||||
bound,
|
||||
pre,
|
||||
after,
|
||||
)?;
|
||||
}
|
||||
more_work_needed = true;
|
||||
|
||||
cand_vertices.push(new_v);
|
||||
@@ -734,6 +852,43 @@ impl Stitch<'_> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// (not upstream, #2975) capture the closure record's operands
|
||||
// before nothing else deflects them, and read the surviving
|
||||
// entity's tolerance after.
|
||||
let kept = if choice.keep_first { v } else { cv };
|
||||
let deleted = if choice.keep_first { cv } else { v };
|
||||
let pair_report = if self.report_gaps.is_some() && (do_glue || do_squeeze) {
|
||||
Some((
|
||||
vertex::tolerance(b, kept)?,
|
||||
if do_squeeze {
|
||||
if let Some(e) = squeeze_edge {
|
||||
super::bounds::curve_to_point(
|
||||
curve_of(b, e)?,
|
||||
&edge::interval(b, e)?,
|
||||
vertex::point(b, kept)?,
|
||||
)?
|
||||
} else {
|
||||
super::bounds::points(
|
||||
vertex::point(b, kept)?,
|
||||
vertex::point(b, deleted)?,
|
||||
)?
|
||||
}
|
||||
} else {
|
||||
super::bounds::points(
|
||||
vertex::point(b, kept)?,
|
||||
vertex::point(b, deleted)?,
|
||||
)?
|
||||
},
|
||||
if do_glue {
|
||||
ClosedGapKind::Vertex { kept, deleted }
|
||||
} else {
|
||||
ClosedGapKind::Squeezed { kept, deleted }
|
||||
},
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// squeeze or glue vertices together (make pV the retained vertex)
|
||||
if choice.keep_first {
|
||||
if do_squeeze {
|
||||
@@ -749,6 +904,10 @@ impl Stitch<'_> {
|
||||
}
|
||||
v = cv;
|
||||
}
|
||||
if let Some((pre, bound, kind)) = pair_report {
|
||||
let after = vertex::tolerance(b, v)?;
|
||||
self.record_gap(kind, bound, pre, after)?;
|
||||
}
|
||||
|
||||
// The surviving vertex (which is pV) should be a candidate.
|
||||
if candidate_vertices.is_some() {
|
||||
@@ -1057,6 +1216,21 @@ impl Stitch<'_> {
|
||||
for f in edge::faces(b, delete, None)? {
|
||||
add_unique(modified_faces, f);
|
||||
}
|
||||
// (not upstream, #2975) capture the closure's operands before the
|
||||
// glue, and the kept edge's tolerance after.
|
||||
let edge_report = if self.report_gaps.is_some() {
|
||||
Some((
|
||||
edge::tolerance(b, keep)?,
|
||||
super::bounds::curves(
|
||||
curve_of(b, e)?,
|
||||
[e_ivl.min(), e_ivl.max()],
|
||||
curve_of(b, test_e)?,
|
||||
[t_start, t_end],
|
||||
)?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match self.edit_glue_edges(
|
||||
k,
|
||||
b,
|
||||
@@ -1076,6 +1250,18 @@ impl Stitch<'_> {
|
||||
}
|
||||
set_none(&mut v_edges, kk)?;
|
||||
done_with_vertex = false;
|
||||
if let Some((pre, bound)) = edge_report {
|
||||
let after = edge::tolerance(b, e)?;
|
||||
self.record_gap(
|
||||
ClosedGapKind::Edge {
|
||||
kept: e,
|
||||
deleted: delete,
|
||||
},
|
||||
bound,
|
||||
pre,
|
||||
after,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Err(err) if err.is_unported_or_cancelled() => return Err(err),
|
||||
Err(_) if !self.ignore_problems => return Err(sm_err()),
|
||||
|
||||
+1
@@ -5,4 +5,5 @@
|
||||
//! `SmVertexProps`, `SmObjProps` in `source/SMLib`, and the area/volume queries of
|
||||
//! `SM_API/SmApiQueries`.
|
||||
|
||||
pub mod enclosure;
|
||||
pub mod integrator;
|
||||
|
||||
+1790
File diff suppressed because it is too large
Load Diff
+71
-4
@@ -1354,10 +1354,8 @@ mod tests {
|
||||
assert!(error.abs() <= 1e-6, "{error}");
|
||||
}
|
||||
|
||||
/// The final step's last node is the interval's end itself: `-0.1 + (0.3 - -0.1)`
|
||||
/// rounds to `0.30000000000000004`, past the end, where an integrand whose end is a
|
||||
/// knot (a curve's span, a face's iso-u piece) would read the span beyond it. Every
|
||||
/// node lies within `[x1, x2]`, and the step that ends the integration ends at `x2`.
|
||||
/// A rate that is NaN, infinite or negative is refused before the integrand is
|
||||
/// evaluated, and nothing is written.
|
||||
#[test]
|
||||
fn a_rate_that_is_not_a_finite_nonnegative_number_is_refused() {
|
||||
for rate in [
|
||||
@@ -1424,6 +1422,75 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `rate` on closed forms, as the curve length uses it: a range cut into `n` equal
|
||||
/// pieces, each integrated to half the accuracy with the range's mean as its rate.
|
||||
/// Each step is held to half the accuracy relative to its share of the range plus
|
||||
/// its own increment, shares that add up to twice the integral, so the pieces' sum is
|
||||
/// within the accuracy of the whole integral however many pieces and steps there are.
|
||||
#[test]
|
||||
fn a_rate_holds_a_range_cut_into_pieces_to_the_accuracy_of_the_whole() {
|
||||
use core::f64::consts::PI;
|
||||
type Case = (fn(f64) -> f64, f64, f64, f64);
|
||||
let cases: [Case; 3] = [
|
||||
// e^t on [0, 4]: e^4 - 1
|
||||
(f64::exp, 0.0, 4.0, 4.0_f64.exp() - 1.0),
|
||||
// Runge's 1 / (1 + 25 t^2) on [-1, 1]: 2/5 atan 5
|
||||
(
|
||||
|t| 1.0 / (1.0 + 25.0 * t * t),
|
||||
-1.0,
|
||||
1.0,
|
||||
0.4 * 5.0_f64.atan(),
|
||||
),
|
||||
// 2 + sin 20t on [0, pi]: 2 pi (the sine's ten periods cancel)
|
||||
(|t| 2.0 + (20.0 * t).sin(), 0.0, PI, 2.0 * PI),
|
||||
];
|
||||
for (g, a, b, exact) in cases {
|
||||
let rate = exact / (b - a);
|
||||
for accuracy in [1e-4, 1e-6, 1e-8, 1e-10] {
|
||||
for n in [1_u32, 8, 64] {
|
||||
let piece = (b - a) / f64::from(n);
|
||||
let mut sum = 0.0;
|
||||
for j in 0..n {
|
||||
let x1 = a + f64::from(j) * piece;
|
||||
let x2 = if j + 1 == n { b } else { x1 + piece };
|
||||
let mut y = [0.0];
|
||||
OdeIntegrator::default()
|
||||
.integrate(
|
||||
&mut y,
|
||||
x1,
|
||||
x2,
|
||||
OdeOptions {
|
||||
accuracy: accuracy / 2.0,
|
||||
initial_step: (x2 - x1) / 10.0,
|
||||
minimum_step: (x2 - x1) / 1e8,
|
||||
scale: None,
|
||||
quadrature: true,
|
||||
rounding: None,
|
||||
rate: Some(rate),
|
||||
},
|
||||
|t, _, dy| {
|
||||
dy[0] = g(t);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
sum += y[0];
|
||||
}
|
||||
let error = (sum - exact).abs() / exact;
|
||||
assert!(
|
||||
error <= accuracy,
|
||||
"[{a}, {b}] in {n} pieces at {accuracy:e}: {sum} against {exact}, \
|
||||
relative error {error:e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The final step's last node is the interval's end itself: `-0.1 + (0.3 - -0.1)`
|
||||
/// rounds to `0.30000000000000004`, past the end, where an integrand whose end is a
|
||||
/// knot (a curve's span, a face's iso-u piece) would read the span beyond it. Every
|
||||
/// node lies within `[x1, x2]`, and the step that ends the integration ends at `x2`.
|
||||
#[test]
|
||||
fn the_final_step_ends_at_the_interval_end_exactly() {
|
||||
let (x1, x2) = (-0.1_f64, 0.3_f64);
|
||||
|
||||
+3
@@ -6,8 +6,10 @@
|
||||
|
||||
pub mod api_queries;
|
||||
pub mod brep;
|
||||
pub mod contact;
|
||||
pub mod distance_bound;
|
||||
pub mod edge;
|
||||
pub mod edge_turn;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod exact;
|
||||
pub mod face;
|
||||
@@ -16,6 +18,7 @@ pub mod r#loop;
|
||||
pub mod normal;
|
||||
pub mod poly;
|
||||
pub mod poly_properties;
|
||||
pub mod tangent_edge;
|
||||
pub mod tess;
|
||||
|
||||
pub use face_surface::face_surface_intersect;
|
||||
|
||||
+732
@@ -0,0 +1,732 @@
|
||||
//! Not upstream: whether two bodies meet (share material or touch within a tolerance),
|
||||
//! decided only where it is proved: [`meets`] and its distance, [`boundary_distance`].
|
||||
//!
|
||||
//! | Rust | Upstream |
|
||||
//! |------|----------|
|
||||
//! | [`meets`], [`boundary_distance`], [`Prepared`] | none (`SmApiBrepRelationship`, ported as [`crate::query::api_queries::brep_relationship`], is a Newton solve: its gap is a point pair, never a lower bound, and its containment counts samples) |
|
||||
//!
|
||||
//! **The bracket.** The distance between the two bodies' boundaries is enclosed in `[lower,
|
||||
//! upper]`:
|
||||
//!
|
||||
//! - `lower`: the least, over pairs of pieces of the two bodies' faces, of the distance
|
||||
//! between their hulls' boxes. The pieces are those of
|
||||
//! [`crate::query::distance_bound`] (each face's NURBS form over its UV box, its Bézier
|
||||
//! patches about one common point, every coefficient's error carried and each box widened
|
||||
//! by it), so no point of either boundary is nearer the other than `lower`. Pairs wait on a
|
||||
//! heap, nearest first; the nearest is split (its larger piece, in four), so `lower`
|
||||
//! rises. A piece whose UV rectangle is proved off its face (no trim curve's box meets it,
|
||||
//! and its centre classifies outside the face) is dropped: a face's hole is no material.
|
||||
//! - `upper` and its witness: a pair of points, one on each body, each on its face's surface
|
||||
//! at parameters the face classifies decisively inside (`face_classify_uv`, the face
|
||||
//! itself: neither on its boundary nor undecided), their distance rounded up. The
|
||||
//! candidates are the centres of the pairs taken from the heap, each moved to the other
|
||||
//! surface's closest point and back ([`crate::query::api_queries::surface_closest_point`],
|
||||
//! a few times), so two surfaces that cross reach a distance of their rounding.
|
||||
//!
|
||||
//! **Meets.** At a tolerance `tol`: `Yes` when `upper <= tol` (with the witness); when
|
||||
//! `lower > tol`, the boundaries do not touch, and the bodies meet only if one encloses the
|
||||
//! other: a point of each boundary is classified in the other body, `Yes` when one is
|
||||
//! inside its material, `No` when both are outside. A classification on the other's boundary
|
||||
//! or not decided is no answer, and neither is a bracket that still straddles `tol` when
|
||||
//! the pairs reach [`MAX_PAIRS`]: [`Verdict::Undecided`], with the bracket, which names
|
||||
//! the size that would decide.
|
||||
//!
|
||||
//! Deviations: none (no upstream counterpart). No recursion: the pairs wait on a heap.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BinaryHeap;
|
||||
|
||||
use crate::geom::certified::{gamma, Verdict};
|
||||
use crate::geom::extent1d::Extent1d;
|
||||
use crate::geom::tol::{brep_zone_tol_3d, SmallTopoType};
|
||||
use crate::geom::vector2d::Point2;
|
||||
use crate::intersect::point_class::PointClassType;
|
||||
use crate::kernel::Kernel;
|
||||
use crate::mass::integrator::properties::region_material;
|
||||
use crate::math::Point3;
|
||||
use crate::query::api_queries::{
|
||||
edge_closest_point, face_bounding_box, face_classify_uv, surface_closest_point,
|
||||
};
|
||||
use crate::query::brep::BrepClassifier;
|
||||
use crate::query::distance_bound::{pieces_about, Piece};
|
||||
use crate::surface::surface::Surface;
|
||||
use crate::topology::{edgeuse::p1 as eu1, loopuse, Brep, FaceId};
|
||||
use crate::{EntityRef, Result, SolidError};
|
||||
|
||||
const OP: &str = "query::contact::meets";
|
||||
|
||||
/// The most piece pairs made for one answer.
|
||||
pub const MAX_PAIRS: usize = 1 << 16;
|
||||
|
||||
/// The times a witness is moved to the other surface's closest point and back.
|
||||
const PROJECTIONS: usize = 6;
|
||||
|
||||
/// How two bodies stand: the verdict at the tolerance asked, and the boundary distance's
|
||||
/// bracket.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Contact {
|
||||
/// `Yes` (they share material or their boundaries come within the tolerance), `No`, or
|
||||
/// `Undecided` (the bracket straddles the tolerance at the work's limit, or a
|
||||
/// classification was not decided).
|
||||
pub verdict: Verdict,
|
||||
/// A certified lower bound of the distance between the boundaries.
|
||||
pub lower: f64,
|
||||
/// An upper bound (the witness's distance, rounded up); infinite without a witness.
|
||||
pub upper: f64,
|
||||
/// Two points, one on each boundary, `upper` apart.
|
||||
pub witness: Option<[Point3; 2]>,
|
||||
}
|
||||
|
||||
/// A face's pieces and what decides whether a piece is on it.
|
||||
struct FacePieces {
|
||||
face: FaceId,
|
||||
surface: Surface,
|
||||
/// Its form's certified distance from its definition, lost from every bound.
|
||||
slack: f64,
|
||||
pieces: Vec<Piece>,
|
||||
/// The boxes of its own UV trim curves `[u0, u1, v0, v1]`; `None` when a trim curve or
|
||||
/// its box is missing (then no piece is dropped).
|
||||
trims: Option<Vec<[f64; 4]>>,
|
||||
}
|
||||
|
||||
/// A body made ready for contact tests: its faces' pieces about a common point.
|
||||
pub struct Prepared {
|
||||
origin: Point3,
|
||||
faces: Vec<FacePieces>,
|
||||
}
|
||||
|
||||
/// A pair of pieces, one of each body, with its lower bound.
|
||||
struct Pair {
|
||||
lower: f64,
|
||||
a: (usize, Piece),
|
||||
b: (usize, Piece),
|
||||
}
|
||||
|
||||
impl PartialEq for Pair {
|
||||
fn eq(&self, o: &Self) -> bool {
|
||||
self.lower.total_cmp(&o.lower) == Ordering::Equal
|
||||
}
|
||||
}
|
||||
impl Eq for Pair {}
|
||||
impl PartialOrd for Pair {
|
||||
fn partial_cmp(&self, o: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(o))
|
||||
}
|
||||
}
|
||||
impl Ord for Pair {
|
||||
// the heap pops the smallest bound first
|
||||
fn cmp(&self, o: &Self) -> Ordering {
|
||||
o.lower.total_cmp(&self.lower)
|
||||
}
|
||||
}
|
||||
|
||||
/// The box of body `b`'s faces, `(lo, hi)`.
|
||||
fn body_box(k: &Kernel, b: &Brep) -> Result<(Point3, Point3)> {
|
||||
let mut lo = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
|
||||
let mut hi = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for &f in b.face_list() {
|
||||
let e = face_bounding_box(k, b, f, false)?;
|
||||
let (l, h) = (e.min(), e.max());
|
||||
lo = Point3::new(lo.x.min(l.x), lo.y.min(l.y), lo.z.min(l.z));
|
||||
hi = Point3::new(hi.x.max(h.x), hi.y.max(h.y), hi.z.max(h.z));
|
||||
}
|
||||
if !(lo.x <= hi.x && lo.y <= hi.y && lo.z <= hi.z) {
|
||||
return Err(SolidError::invalid_input(OP, "a body without faces"));
|
||||
}
|
||||
Ok((lo, hi))
|
||||
}
|
||||
|
||||
/// The common point two bodies' pieces are made about: the centre of their boxes.
|
||||
fn common_origin(k: &Kernel, a: &Brep, b: &Brep) -> Result<Point3> {
|
||||
let ((al, ah), (bl, bh)) = (body_box(k, a)?, body_box(k, b)?);
|
||||
let mid = |x: f64, y: f64, z: f64, w: f64| 0.5 * (x.min(z) + y.max(w));
|
||||
Ok(Point3::new(
|
||||
mid(al.x, ah.x, bl.x, bh.x),
|
||||
mid(al.y, ah.y, bl.y, bh.y),
|
||||
mid(al.z, ah.z, bl.z, bh.z),
|
||||
))
|
||||
}
|
||||
|
||||
/// The parts each Bézier segment of a trim curve is cut into for its boxes: a rectangle
|
||||
/// that no part's box meets is proved clear of the curve.
|
||||
const TRIM_PARTS: usize = 16;
|
||||
|
||||
/// The boxes, `[u0, u1, v0, v1]`, of a UV trim curve's NURBS form: each Bézier segment cut in
|
||||
/// [`TRIM_PARTS`] by de Casteljau on its homogeneous points, each part in the box of its
|
||||
/// Euclidean points (its hull: positive weights), widened by the subdivision's rounding;
|
||||
/// `None` for a form whose weights are not positive.
|
||||
fn nurb_boxes(c: &crate::nurbs::crv_basic::Curve) -> Result<Option<Vec<[f64; 4]>>> {
|
||||
use crate::nurbs::math::NOW;
|
||||
let mut out = Vec::new();
|
||||
for seg in c.decompose_bez()? {
|
||||
let pts: Vec<[f64; 3]> = seg
|
||||
.cpts()
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let w = if q.w == NOW { 1.0 } else { q.w };
|
||||
[q.x, q.y, w]
|
||||
})
|
||||
.collect();
|
||||
if pts
|
||||
.iter()
|
||||
.any(|q| !(q[2] > 0.0 && q.iter().all(|v| v.is_finite())))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let size = pts.iter().flatten().fold(0.0_f64, |m, v| m.max(v.abs()));
|
||||
let wmin = pts.iter().fold(f64::INFINITY, |m, q| m.min(q[2]));
|
||||
// the right part from t: de Casteljau at t on the homogeneous points
|
||||
let cut = |p: &[[f64; 3]], t: f64| -> (Vec<[f64; 3]>, Vec<[f64; 3]>) {
|
||||
let mut level = p.to_vec();
|
||||
let (mut left, mut right) = (Vec::new(), Vec::new());
|
||||
while let (Some(&f), Some(&l)) = (level.first(), level.last()) {
|
||||
left.push(f);
|
||||
right.push(l);
|
||||
level = level
|
||||
.windows(2)
|
||||
.filter_map(|w| match w {
|
||||
[a, b] => Some([
|
||||
(1.0 - t) * a[0] + t * b[0],
|
||||
(1.0 - t) * a[1] + t * b[1],
|
||||
(1.0 - t) * a[2] + t * b[2],
|
||||
]),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
right.reverse();
|
||||
(left, right)
|
||||
};
|
||||
let degree = pts.len().saturating_sub(1);
|
||||
// TRIM_PARTS levels of convex combinations at most: their rounding, of the size
|
||||
let err = gamma(3 * degree * TRIM_PARTS + 3) * size;
|
||||
let mut rest = pts;
|
||||
for i in 0..TRIM_PARTS {
|
||||
let t = 1.0 / (TRIM_PARTS - i) as f64;
|
||||
let (part, next) = if i + 1 == TRIM_PARTS {
|
||||
(rest.clone(), Vec::new())
|
||||
} else {
|
||||
cut(&rest, t)
|
||||
};
|
||||
rest = next;
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
|
||||
for q in &part {
|
||||
for (k, v) in [q[0] / q[2], q[1] / q[2]].into_iter().enumerate() {
|
||||
if let (Some(l), Some(h)) = (lo.get_mut(k), hi.get_mut(k)) {
|
||||
*l = l.min(v);
|
||||
*h = h.max(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
// a quotient of coefficients within err: within err (1 + |x|) / (w - err)
|
||||
let widen = err * (1.0 + size / wmin) / (wmin - err).max(f64::MIN_POSITIVE)
|
||||
+ gamma(2) * size / wmin;
|
||||
out.push([lo[0] - widen, hi[0] + widen, lo[1] - widen, hi[1] + widen]);
|
||||
}
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// The boxes of face `f`'s own UV trim curves (each its NURBS form's parts,
|
||||
/// [`nurb_boxes`], or the curve's whole box where it has none); `None` where a trim curve or
|
||||
/// its box is missing.
|
||||
fn trim_boxes(k: &Kernel, b: &Brep, f: FaceId) -> Result<Option<Vec<[f64; 4]>>> {
|
||||
use crate::curve::curve::CurveKind;
|
||||
let Some(fu) = b.face(f)?.faceuse else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for lu in eu1::faceuse_loopuses(b, fu, false, None)? {
|
||||
for eu in loopuse::edgeuses(b, lu, None, None)? {
|
||||
// its own, or its mate's on the face's other side (the same face's parameters)
|
||||
let Some(c) = eu1::uv_trim_curve(b, eu)?.0 else {
|
||||
return Ok(None);
|
||||
};
|
||||
let nurb = match c.kind() {
|
||||
CurveKind::BSpline(x) => x.gw_nurb(),
|
||||
CurveKind::Ellipse(x) => x.base().gw_nurb(),
|
||||
CurveKind::Hyperbola(x) => x.base().gw_nurb(),
|
||||
CurveKind::Parabola(x) => x.base().gw_nurb(),
|
||||
CurveKind::Line(x) => x.base().gw_nurb(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(n) = nurb {
|
||||
if let Some(boxes) = nurb_boxes(n)? {
|
||||
out.extend(boxes);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let interval: Extent1d = c.natural_interval();
|
||||
let boxes = c.calculate_bounding_box(
|
||||
k,
|
||||
&interval,
|
||||
crate::nurbs::b_spline_curve::BoundingBoxRequest {
|
||||
normal: true,
|
||||
..Default::default()
|
||||
},
|
||||
true,
|
||||
)?;
|
||||
let Some(e) = boxes.normal else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (l, h) = (e.min(), e.max());
|
||||
// the box's own rounding, a few ulps of its size
|
||||
let r = 16.0 * f64::EPSILON * (l.x.abs() + h.x.abs() + l.y.abs() + h.y.abs());
|
||||
out.push([l.x - r, h.x + r, l.y - r, h.y + r]);
|
||||
}
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
/// Body `b`'s faces' pieces about `origin`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `InvalidInput` for a body without faces or a face without a surface; those of the
|
||||
/// pieces ([`crate::query::distance_bound`]) and the trim curves' boxes.
|
||||
pub fn new(k: &Kernel, b: &Brep, origin: Point3) -> Result<Self> {
|
||||
let mut faces = Vec::new();
|
||||
for &f in b.face_list() {
|
||||
let face = b.face(f)?;
|
||||
let surface = face
|
||||
.surface
|
||||
.as_ref()
|
||||
.ok_or_else(|| SolidError::invalid_input(OP, "a face without a surface"))?
|
||||
.clone();
|
||||
let (pieces, slack) = pieces_about(k, &surface, Some(&face.uv_domain), origin)?;
|
||||
let trims = trim_boxes(k, b, f)?;
|
||||
faces.push(FacePieces {
|
||||
face: f,
|
||||
surface,
|
||||
slack,
|
||||
pieces,
|
||||
trims,
|
||||
});
|
||||
}
|
||||
if faces.is_empty() {
|
||||
return Err(SolidError::invalid_input(OP, "a body without faces"));
|
||||
}
|
||||
Ok(Self { origin, faces })
|
||||
}
|
||||
|
||||
/// Body `b`'s faces' pieces about the centre of its own box: the preparation of one
|
||||
/// body for tests against several ([`meets_prepared`]).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Those of [`Prepared::new`] and of the faces' boxes.
|
||||
pub fn about(k: &Kernel, b: &Brep) -> Result<Self> {
|
||||
Self::new(k, b, common_origin(k, b, b)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// The certified lower bound of the distance between two pieces about the same point: the
|
||||
/// larger of the distance of their widened boxes and, along a unit direction `axis` (the
|
||||
/// witness's, when there is one), the gap between their hulls' extents
|
||||
/// ([`Piece::extent_along`]), each rounded down. The axis steers which bound is tight, never
|
||||
/// whether either holds: any direction separates the hulls by at most their distance.
|
||||
fn pair_lower(a: &Piece, b: &Piece, axis: Option<[f64; 3]>) -> Result<f64> {
|
||||
let by_box = box_lower(a, b)?;
|
||||
let Some(u) = axis else {
|
||||
return Ok(by_box);
|
||||
};
|
||||
let len = (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt() * (1.0 + gamma(4));
|
||||
if !(len > 0.0 && len.is_finite()) {
|
||||
return Ok(by_box);
|
||||
}
|
||||
let ((a0, a1), (b0, b1)) = (a.extent_along(u)?, b.extent_along(u)?);
|
||||
let gap = (b0 - a1).max(a0 - b1).max(0.0);
|
||||
// (the difference rounded down by gamma_1 of its terms, then over the length)
|
||||
let gap = (gap - gamma(1) * (a0.abs() + a1.abs() + b0.abs() + b1.abs())).max(0.0);
|
||||
let by_axis = gap / len * (1.0 - gamma(2));
|
||||
Ok(by_box.max(by_axis))
|
||||
}
|
||||
|
||||
/// The distance of two pieces' widened boxes, rounded down.
|
||||
fn box_lower(a: &Piece, b: &Piece) -> Result<f64> {
|
||||
let ((al, ah), (bl, bh)) = (a.widened_box()?, b.widened_box()?);
|
||||
let mut sum = 0.0_f64;
|
||||
for i in 0..3 {
|
||||
let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) =
|
||||
(al.get(i), ah.get(i), bl.get(i), bh.get(i))
|
||||
else {
|
||||
return Err(SolidError::sm_err(OP));
|
||||
};
|
||||
let gap = (y0 - x1).max(x0 - y1).max(0.0);
|
||||
// (the difference rounded up by at most gamma_1 of its terms)
|
||||
let gap = (gap - gamma(1) * (x0.abs() + x1.abs() + y0.abs() + y1.abs())).max(0.0);
|
||||
sum += gap * gap;
|
||||
}
|
||||
Ok(sum.sqrt() * (1.0 - gamma(5)))
|
||||
}
|
||||
|
||||
/// Where a piece stands against its face: `Some(true)` proved on it and decisively inside
|
||||
/// at its centre, `Some(false)` proved off it (no trim curve's box meets its rectangle and
|
||||
/// its centre is outside), `None` not decided.
|
||||
fn piece_on_face(k: &Kernel, b: &mut Brep, fp: &FacePieces, p: &Piece) -> Result<Option<bool>> {
|
||||
let [u0, u1, v0, v1] = p.uv;
|
||||
let crosses = match &fp.trims {
|
||||
None => true,
|
||||
Some(boxes) => boxes
|
||||
.iter()
|
||||
.any(|t| !(t[1] < u0 || t[0] > u1 || t[3] < v0 || t[2] > v1)),
|
||||
};
|
||||
let centre = Point2::new(0.5 * (u0 + u1), 0.5 * (v0 + v1));
|
||||
let class = match face_classify_uv(k, b, fp.face, centre) {
|
||||
Ok(c) => c,
|
||||
// a classification that fails decides nothing
|
||||
Err(e) if e.is_cancelled() => return Err(e),
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
Ok(match class {
|
||||
PointClassType::Unknown if !crosses => Some(false),
|
||||
PointClassType::Face => Some(true),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A point of body `b` for face `fp` at `uv`: the surface's point when the face classifies
|
||||
/// `uv` decisively inside it; when it classifies it on the face's boundary (within its
|
||||
/// tolerance, either side), the nearest point of the face's edges to the surface's point,
|
||||
/// which is on the body whichever side `uv` was; `None` outside or not decided.
|
||||
fn on_face(k: &Kernel, b: &mut Brep, fp: &FacePieces, uv: Point2) -> Result<Option<Point3>> {
|
||||
match face_classify_uv(k, b, fp.face, uv) {
|
||||
Ok(PointClassType::Face) => Ok(Some(fp.surface.evaluate_point(k, uv)?)),
|
||||
Ok(PointClassType::Edge | PointClassType::Vertex) => {
|
||||
let x = fp.surface.evaluate_point(k, uv)?;
|
||||
nearest_edge_point(k, b, fp.face, x)
|
||||
}
|
||||
Err(e) if e.is_cancelled() => Err(e),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The nearest point to `x` of face `f`'s edges (each solved on its edge, so on the body);
|
||||
/// `None` when no edge's solve succeeds.
|
||||
fn nearest_edge_point(k: &Kernel, b: &Brep, f: FaceId, x: Point3) -> Result<Option<Point3>> {
|
||||
let Some(fu) = b.face(f)?.faceuse else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut best: Option<(f64, Point3)> = None;
|
||||
for lu in eu1::faceuse_loopuses(b, fu, false, None)? {
|
||||
for eu in loopuse::edgeuses(b, lu, None, None)? {
|
||||
let Some(e) = b.edgeuse(eu)?.edge else {
|
||||
continue;
|
||||
};
|
||||
let Ok(c) = edge_closest_point(k, b, e, x) else {
|
||||
continue;
|
||||
};
|
||||
let d = c.closest.point.distance(x);
|
||||
if best.is_none_or(|(m, _)| d < m) {
|
||||
best = Some((d, c.closest.point));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(best.map(|(_, p)| p))
|
||||
}
|
||||
|
||||
/// The distance of two points rounded up: `|p - q|` within `gamma_5` of the terms' size.
|
||||
fn distance_up(p: Point3, q: Point3) -> f64 {
|
||||
let size = p.x.abs() + p.y.abs() + p.z.abs() + q.x.abs() + q.y.abs() + q.z.abs();
|
||||
p.distance(q) * (1.0 + gamma(5)) + gamma(5) * size
|
||||
}
|
||||
|
||||
/// The closest point of `fp`'s surface to `x`, kept when its face classifies it decisively
|
||||
/// inside; `None` for a solve that fails or a point off the face.
|
||||
fn closest_on(
|
||||
k: &Kernel,
|
||||
b: &mut Brep,
|
||||
fp: &FacePieces,
|
||||
x: Point3,
|
||||
) -> Result<Option<(Point3, Point2)>> {
|
||||
let Ok(c) = surface_closest_point(k, &fp.surface, x) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(&uv) = c.uvs.first() else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(on_face(k, b, fp, uv)?.map(|p| (p, uv)))
|
||||
}
|
||||
|
||||
/// The bracket between `a` and `b` (as [`Prepared`]), refined until `stop` says done or the
|
||||
/// pairs reach [`MAX_PAIRS`]: `stop(lower, upper)`.
|
||||
fn bracket(
|
||||
k: &Kernel,
|
||||
a: &mut Brep,
|
||||
pa: &Prepared,
|
||||
b: &mut Brep,
|
||||
pb: &Prepared,
|
||||
seed: f64,
|
||||
stop: &dyn Fn(f64, f64) -> bool,
|
||||
) -> Result<(f64, f64, Option<[Point3; 2]>, bool)> {
|
||||
let mut heap = BinaryHeap::new();
|
||||
let mut made = 0_usize;
|
||||
for (ia, fa) in pa.faces.iter().enumerate() {
|
||||
for (ib, fb) in pb.faces.iter().enumerate() {
|
||||
for x in &fa.pieces {
|
||||
for y in &fb.pieces {
|
||||
let lower = (pair_lower(x, y, None)? - fa.slack - fb.slack).max(0.0);
|
||||
heap.push(Pair {
|
||||
lower,
|
||||
a: (ia, x.clone()),
|
||||
b: (ib, y.clone()),
|
||||
});
|
||||
made += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (mut upper, mut witness) = (f64::INFINITY, None);
|
||||
// witnesses first from every face pair whose bound is within `seed`, nearest first (the
|
||||
// pairs tie closely where two bodies lie along each other; each is visited once here)
|
||||
let mut first: Vec<&Pair> = heap.iter().filter(|p| p.lower <= seed).collect();
|
||||
first.sort_by(|x, y| x.lower.total_cmp(&y.lower));
|
||||
for pair in first {
|
||||
let (Some(fa), Some(fb)) = (pa.faces.get(pair.a.0), pb.faces.get(pair.b.0)) else {
|
||||
return Err(SolidError::sm_err(OP));
|
||||
};
|
||||
if pair.lower < upper {
|
||||
witness_from(
|
||||
k,
|
||||
(&mut *a, fa, &pair.a.1),
|
||||
(&mut *b, fb, &pair.b.1),
|
||||
&mut upper,
|
||||
&mut witness,
|
||||
)?;
|
||||
}
|
||||
if stop(0.0, upper) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
loop {
|
||||
k.check_cancelled(OP)?;
|
||||
let Some(pair) = heap.pop() else {
|
||||
// every piece proved off its face: no boundary is there to bound
|
||||
return Ok((f64::INFINITY, upper, witness, true));
|
||||
};
|
||||
let lower = pair.lower.min(upper);
|
||||
if stop(lower, upper) {
|
||||
return Ok((lower, upper, witness, true));
|
||||
}
|
||||
if made >= MAX_PAIRS {
|
||||
return Ok((lower, upper, witness, false));
|
||||
}
|
||||
let (ia, x) = &pair.a;
|
||||
let (ib, y) = &pair.b;
|
||||
let (Some(fa), Some(fb)) = (pa.faces.get(*ia), pb.faces.get(*ib)) else {
|
||||
return Err(SolidError::sm_err(OP));
|
||||
};
|
||||
// a witness from the pair's centres (none can be nearer than the pair's bound, so a
|
||||
// pair past the witness so far is skipped)
|
||||
if pair.lower < upper {
|
||||
witness_from(
|
||||
k,
|
||||
(&mut *a, fa, x),
|
||||
(&mut *b, fb, y),
|
||||
&mut upper,
|
||||
&mut witness,
|
||||
)?;
|
||||
}
|
||||
// split the larger piece
|
||||
let size = |p: &Piece| -> Result<f64> {
|
||||
let (l, h) = p.widened_box()?;
|
||||
Ok((0..3)
|
||||
.map(|i| h.get(i).copied().unwrap_or(0.0) - l.get(i).copied().unwrap_or(0.0))
|
||||
.fold(0.0_f64, f64::max))
|
||||
};
|
||||
let split_a = size(x)? >= size(y)?;
|
||||
let (from, keep) = if split_a { (x, y) } else { (y, x) };
|
||||
// the witness's direction, for the hulls' extents along it
|
||||
let axis = witness.map(|[p, q]| [q.x - p.x, q.y - p.y, q.z - p.z]);
|
||||
for q in from.quarters()? {
|
||||
let (fp, brep) = if split_a {
|
||||
(fa, &mut *a)
|
||||
} else {
|
||||
(fb, &mut *b)
|
||||
};
|
||||
if piece_on_face(k, brep, fp, &q)? == Some(false) {
|
||||
continue;
|
||||
}
|
||||
let (qa, qb) = if split_a { (&q, keep) } else { (keep, &q) };
|
||||
let lower = (pair_lower(qa, qb, axis)? - fa.slack - fb.slack).max(0.0);
|
||||
heap.push(Pair {
|
||||
lower,
|
||||
a: (*ia, qa.clone()),
|
||||
b: (*ib, qb.clone()),
|
||||
});
|
||||
made += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A witness from a pair's centres, each moved to the other's surface's closest point and
|
||||
/// back ([`PROJECTIONS`] times), kept where both points are decisively on their faces and
|
||||
/// nearer than `upper`.
|
||||
fn witness_from(
|
||||
k: &Kernel,
|
||||
(a, fa, x): (&mut Brep, &FacePieces, &Piece),
|
||||
(b, fb, y): (&mut Brep, &FacePieces, &Piece),
|
||||
upper: &mut f64,
|
||||
witness: &mut Option<[Point3; 2]>,
|
||||
) -> Result<()> {
|
||||
let (_, ua) = x.centre();
|
||||
let (_, ub) = y.centre();
|
||||
let mut pa_pt = on_face(k, a, fa, Point2::new(ua[0], ua[1]))?;
|
||||
let mut pb_pt = on_face(k, b, fb, Point2::new(ub[0], ub[1]))?;
|
||||
for _ in 0..PROJECTIONS {
|
||||
if let Some(p) = pa_pt {
|
||||
if let Some((q, _)) = closest_on(k, b, fb, p)? {
|
||||
pb_pt = Some(q);
|
||||
}
|
||||
}
|
||||
if let Some(q) = pb_pt {
|
||||
if let Some((p, _)) = closest_on(k, a, fa, q)? {
|
||||
pa_pt = Some(p);
|
||||
}
|
||||
}
|
||||
if let (Some(p), Some(q)) = (pa_pt, pb_pt) {
|
||||
let d = distance_up(p, q) + fa.slack + fb.slack;
|
||||
if d < *upper {
|
||||
*upper = d;
|
||||
*witness = Some([p, q]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A point of `inner`'s boundary classified in `outer`: `Some(true)` inside its material,
|
||||
/// `Some(false)` outside it, `None` when none of `inner`'s points tried (its pieces'
|
||||
/// centres, where their faces hold them: [`on_face`]) is proved more than twice the
|
||||
/// classification's tolerance from `outer`'s boundary
|
||||
/// ([`crate::query::distance_bound::face_distance_lower_bound`] over `outer`'s faces), or
|
||||
/// its classification is not decided.
|
||||
fn point_in(
|
||||
k: &Kernel,
|
||||
inner: &mut Brep,
|
||||
p_inner: &Prepared,
|
||||
outer: &Brep,
|
||||
) -> Result<Option<bool>> {
|
||||
use crate::query::distance_bound::face_distance_lower_bound;
|
||||
let tol = brep_zone_tol_3d(k, outer, SmallTopoType::NormalSize);
|
||||
let clear = 2.0 * tol;
|
||||
for fp in &p_inner.faces {
|
||||
for piece in &fp.pieces {
|
||||
let (_, uv) = piece.centre();
|
||||
let Some(p) = on_face(k, inner, fp, Point2::new(uv[0], uv[1]))? else {
|
||||
continue;
|
||||
};
|
||||
let mut far = true;
|
||||
for &g in outer.face_list() {
|
||||
if face_distance_lower_bound(k, outer, g, p, clear)? <= clear {
|
||||
far = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !far {
|
||||
continue;
|
||||
}
|
||||
let mut classifier = BrepClassifier::of_brep(outer);
|
||||
let pc = match classifier.point_3d_classify(k, p, tol, true) {
|
||||
Ok(pc) => pc,
|
||||
Err(e) if e.is_cancelled() => return Err(e),
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
return Ok(match (pc.point_class(), pc.object()) {
|
||||
(PointClassType::Region, Some(EntityRef::Region(r))) => {
|
||||
Some(region_material(classifier.brep(), r)?)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` meet at `tol` (module docs), `a` prepared once for several `b`s.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `InvalidInput` for a tolerance not finite and positive, or a body without faces; those
|
||||
/// of the pieces, the classifications' cancellation and the closest-point solves' setup.
|
||||
pub fn meets_prepared(
|
||||
k: &Kernel,
|
||||
a: &mut Brep,
|
||||
pa: &Prepared,
|
||||
b: &mut Brep,
|
||||
tol: f64,
|
||||
) -> Result<Contact> {
|
||||
if !(tol.is_finite() && tol > 0.0) {
|
||||
return Err(SolidError::invalid_input(
|
||||
OP,
|
||||
"the tolerance is finite and positive",
|
||||
));
|
||||
}
|
||||
let pb = Prepared::new(k, b, pa.origin)?;
|
||||
let stop = |lower: f64, upper: f64| upper <= tol || lower > tol;
|
||||
let (lower, upper, witness, done) = bracket(k, a, pa, b, &pb, tol, &stop)?;
|
||||
let verdict = if upper <= tol {
|
||||
Verdict::Yes
|
||||
} else if !done {
|
||||
Verdict::Undecided
|
||||
} else {
|
||||
// the boundaries are more than tol apart: they meet only if one encloses the other
|
||||
match (point_in(k, b, &pb, a)?, point_in(k, a, pa, b)?) {
|
||||
(Some(true), _) | (_, Some(true)) => Verdict::Yes,
|
||||
(Some(false), Some(false)) => Verdict::No,
|
||||
_ => Verdict::Undecided,
|
||||
}
|
||||
};
|
||||
Ok(Contact {
|
||||
verdict,
|
||||
lower,
|
||||
upper,
|
||||
witness,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`meets_prepared`] for one pair: `a` prepared about the centre of both bodies' boxes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Those of [`meets_prepared`].
|
||||
pub fn meets(k: &Kernel, a: &mut Brep, b: &mut Brep, tol: f64) -> Result<Contact> {
|
||||
let origin = common_origin(k, a, b)?;
|
||||
let pa = Prepared::new(k, a, origin)?;
|
||||
meets_prepared(k, a, &pa, b, tol)
|
||||
}
|
||||
|
||||
/// The bracket of the distance between `a`'s and `b`'s boundaries, refined until it is at
|
||||
/// most `width` wide (relative to the upper end, or absolute below 1) or the pairs reach
|
||||
/// [`MAX_PAIRS`]; the verdict is `Yes` when it is that narrow, `Undecided` when the work
|
||||
/// ran out first. The bracket always holds the distance; it can stay wider than `width`
|
||||
/// where a nearest point lies on a face's trim, since a piece straddling the trim carries
|
||||
/// surface off the face and is cut finer only along the trim.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `InvalidInput` for a width not finite and positive; those of [`meets`].
|
||||
pub fn boundary_distance(k: &Kernel, a: &mut Brep, b: &mut Brep, width: f64) -> Result<Contact> {
|
||||
if !(width.is_finite() && width > 0.0) {
|
||||
return Err(SolidError::invalid_input(
|
||||
OP,
|
||||
"the width is finite and positive",
|
||||
));
|
||||
}
|
||||
let origin = common_origin(k, a, b)?;
|
||||
let pa = Prepared::new(k, a, origin)?;
|
||||
let pb = Prepared::new(k, b, origin)?;
|
||||
let stop = |lower: f64, upper: f64| upper - lower <= width * upper.max(1.0);
|
||||
let (lower, upper, witness, done) = bracket(k, a, &pa, b, &pb, f64::INFINITY, &stop)?;
|
||||
Ok(Contact {
|
||||
verdict: if done {
|
||||
Verdict::Yes
|
||||
} else {
|
||||
Verdict::Undecided
|
||||
},
|
||||
lower,
|
||||
upper,
|
||||
witness,
|
||||
})
|
||||
}
|
||||
+181
-72
@@ -44,15 +44,18 @@ pub const MAX_PATCHES: usize = 4096;
|
||||
/// A piece of a face's surface: its homogeneous coordinates about the point, as Bernstein
|
||||
/// polynomials on the unit square, and the absolute error each coefficient may carry.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Piece {
|
||||
pub(crate) struct Piece {
|
||||
/// `X W - p W`, `Y W - p W`, `Z W - p W`, `W`.
|
||||
h: [Bernstein2; 4],
|
||||
pub(crate) h: [Bernstein2; 4],
|
||||
/// The bound on every coefficient's error.
|
||||
err: f64,
|
||||
pub(crate) err: f64,
|
||||
/// The certified lower bound of the piece's distance from the point.
|
||||
lower: f64,
|
||||
pub(crate) lower: f64,
|
||||
/// Its subdivision depth.
|
||||
depth: usize,
|
||||
pub(crate) depth: usize,
|
||||
/// Its rectangle in the surface's NURB parameters, `[u0, u1, v0, v1]` (to the rounding
|
||||
/// of the splits that made it).
|
||||
pub(crate) uv: [f64; 4],
|
||||
}
|
||||
|
||||
impl PartialEq for Piece {
|
||||
@@ -74,21 +77,62 @@ impl Ord for Piece {
|
||||
}
|
||||
|
||||
impl Piece {
|
||||
/// The piece with its bound, the coefficients `h` within `err` each.
|
||||
fn new(h: [Bernstein2; 4], err: f64, depth: usize) -> Result<Self> {
|
||||
/// The piece with its bound, the coefficients `h` within `err` each, over `uv`.
|
||||
fn new(h: [Bernstein2; 4], err: f64, depth: usize, uv: [f64; 4]) -> Result<Self> {
|
||||
let lower = hull_bound(&h, err)?;
|
||||
Ok(Self {
|
||||
h,
|
||||
err,
|
||||
lower,
|
||||
depth,
|
||||
uv,
|
||||
})
|
||||
}
|
||||
|
||||
/// The box of its Euclidean control points about the point, each coordinate widened
|
||||
/// by the error a quotient of coefficients within `err` can carry: every point of the
|
||||
/// piece lies in it (the hull, with positive weights).
|
||||
pub(crate) fn widened_box(&self) -> Result<([f64; 3], [f64; 3])> {
|
||||
let (lo, hi, widen) = euclid_box(&self.h, self.err)?;
|
||||
Ok((lo.map(|l| l - widen), hi.map(|u| u + widen)))
|
||||
}
|
||||
|
||||
/// Its centre's Euclidean point about the point, and the centre's parameters.
|
||||
pub(crate) fn centre(&self) -> ([f64; 3], [f64; 2]) {
|
||||
let [x, y, z, w] = &self.h;
|
||||
let wc = w.eval(0.5, 0.5);
|
||||
let [u0, u1, v0, v1] = self.uv;
|
||||
(
|
||||
[
|
||||
x.eval(0.5, 0.5) / wc,
|
||||
y.eval(0.5, 0.5) / wc,
|
||||
z.eval(0.5, 0.5) / wc,
|
||||
],
|
||||
[0.5 * (u0 + u1), 0.5 * (v0 + v1)],
|
||||
)
|
||||
}
|
||||
|
||||
/// The certified range of `x . u` over the piece, for a direction `u`: the least and
|
||||
/// largest of its Euclidean control points' (its hull), each moved out by the widening
|
||||
/// along `u` and the products' rounding.
|
||||
pub(crate) fn extent_along(&self, u: [f64; 3]) -> Result<(f64, f64)> {
|
||||
let (lo, hi, widen) = euclid_box(&self.h, self.err)?;
|
||||
let size = lo.iter().chain(&hi).fold(0.0_f64, |m, v| m.max(v.abs())) + widen;
|
||||
let norm1 = u[0].abs() + u[1].abs() + u[2].abs();
|
||||
let (mut least, mut most) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for e in euclid_points(&self.h) {
|
||||
let d = e[0] * u[0] + e[1] * u[1] + e[2] * u[2];
|
||||
least = least.min(d);
|
||||
most = most.max(d);
|
||||
}
|
||||
let slack = widen * norm1 + gamma(3) * size * norm1;
|
||||
Ok((least - slack, most + slack))
|
||||
}
|
||||
|
||||
/// Its four quarters: one de Casteljau level each way, which adds the rounding of a
|
||||
/// convex combination per level (`gamma` of twice the degree plus two) of the largest
|
||||
/// coefficient.
|
||||
fn quarters(&self) -> Result<[Self; 4]> {
|
||||
pub(crate) fn quarters(&self) -> Result<[Self; 4]> {
|
||||
let largest = self
|
||||
.h
|
||||
.iter()
|
||||
@@ -108,14 +152,23 @@ impl Piece {
|
||||
split(&self.h[2]),
|
||||
split(&self.h[3]),
|
||||
];
|
||||
let [u0, u1, v0, v1] = self.uv;
|
||||
let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
|
||||
let rects = [
|
||||
[u0, um, v0, vm],
|
||||
[u0, um, vm, v1],
|
||||
[um, u1, v0, vm],
|
||||
[um, u1, vm, v1],
|
||||
];
|
||||
let mut out = Vec::new();
|
||||
for i in 0..4 {
|
||||
for (i, uv) in rects.into_iter().enumerate() {
|
||||
let pick =
|
||||
|a: &[Bernstein2; 4]| a.get(i).cloned().ok_or_else(|| SolidError::sm_err(OP));
|
||||
out.push(Self::new(
|
||||
[pick(&x)?, pick(&y)?, pick(&z)?, pick(&w)?],
|
||||
err,
|
||||
self.depth + 1,
|
||||
uv,
|
||||
)?);
|
||||
}
|
||||
out.try_into().map_err(|_| SolidError::sm_err(OP))
|
||||
@@ -130,42 +183,15 @@ impl Piece {
|
||||
/// least the least P . u, and |X| >= X . u / |u|), which, unlike the box, tightens with the
|
||||
/// square of the piece's size on a piece tilted to the axes.
|
||||
fn hull_bound(h: &[Bernstein2; 4], err: f64) -> Result<f64> {
|
||||
let [x, y, z, w] = h;
|
||||
let (xs, ys, zs, ws) = (
|
||||
x.coefficients(),
|
||||
y.coefficients(),
|
||||
z.coefficients(),
|
||||
w.coefficients(),
|
||||
);
|
||||
let w_min = ws.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
if !above(w_min - err, 0.0) {
|
||||
return Err(SolidError::Unsupported {
|
||||
operation: OP,
|
||||
reason: "a patch's weights are not proved positive",
|
||||
});
|
||||
}
|
||||
let mut points = Vec::new();
|
||||
let mut lo = [f64::INFINITY; 3];
|
||||
let mut hi = [f64::NEG_INFINITY; 3];
|
||||
let mut size = 0.0_f64;
|
||||
let (lo, hi, widen) = euclid_box(h, err)?;
|
||||
let points = euclid_points(h);
|
||||
let size = lo.iter().chain(&hi).fold(0.0_f64, |m, v| m.max(v.abs()));
|
||||
let mut sum_points = [0.0_f64; 3];
|
||||
for (((&a, &b), &c), &wi) in xs.iter().zip(ys).zip(zs).zip(ws) {
|
||||
let e = [a / wi, b / wi, c / wi];
|
||||
for (k, v) in e.into_iter().enumerate() {
|
||||
size = size.max(v.abs());
|
||||
if let (Some(l), Some(u), Some(t)) =
|
||||
(lo.get_mut(k), hi.get_mut(k), sum_points.get_mut(k))
|
||||
{
|
||||
*l = l.min(v);
|
||||
*u = u.max(v);
|
||||
*t += v;
|
||||
}
|
||||
for e in &points {
|
||||
for (t, v) in sum_points.iter_mut().zip(e) {
|
||||
*t += v;
|
||||
}
|
||||
points.push(e);
|
||||
}
|
||||
// |(v + dv) / (w + dw) - v / w| <= err (1 + |v / w|) / (w - err), and the quotient's
|
||||
// own rounding
|
||||
let widen = err * (1.0 + size) / (w_min - err) + gamma(2) * size;
|
||||
let mut sum = 0.0_f64;
|
||||
for (l, u) in lo.into_iter().zip(hi) {
|
||||
let out = (l - widen).max(-(u + widen)).max(0.0);
|
||||
@@ -202,6 +228,61 @@ fn hull_bound(h: &[Bernstein2; 4], err: f64) -> Result<f64> {
|
||||
Ok(bound.max(0.0))
|
||||
}
|
||||
|
||||
/// The Euclidean control points of the homogeneous coefficients `h`.
|
||||
fn euclid_points(h: &[Bernstein2; 4]) -> Vec<[f64; 3]> {
|
||||
let [x, y, z, w] = h;
|
||||
x.coefficients()
|
||||
.iter()
|
||||
.zip(y.coefficients())
|
||||
.zip(z.coefficients())
|
||||
.zip(w.coefficients())
|
||||
.map(|(((&a, &b), &c), &wi)| [a / wi, b / wi, c / wi])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The box of the Euclidean control points of `h`, and how far each coordinate is to be
|
||||
/// widened for coefficients within `err` (and the quotients' own rounding): `(lo, hi,
|
||||
/// widen)`.
|
||||
fn euclid_box(h: &[Bernstein2; 4], err: f64) -> Result<([f64; 3], [f64; 3], f64)> {
|
||||
let [x, y, z, w] = h;
|
||||
let (xs, ys, zs, ws) = (
|
||||
x.coefficients(),
|
||||
y.coefficients(),
|
||||
z.coefficients(),
|
||||
w.coefficients(),
|
||||
);
|
||||
let w_min = ws.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
if !above(w_min - err, 0.0) {
|
||||
return Err(SolidError::Unsupported {
|
||||
operation: OP,
|
||||
reason: "a patch's weights are not proved positive",
|
||||
});
|
||||
}
|
||||
let mut lo = [f64::INFINITY; 3];
|
||||
let mut hi = [f64::NEG_INFINITY; 3];
|
||||
let mut size = 0.0_f64;
|
||||
for (((&a, &b), &c), &wi) in xs.iter().zip(ys).zip(zs).zip(ws) {
|
||||
let e = [a / wi, b / wi, c / wi];
|
||||
for (k, v) in e.into_iter().enumerate() {
|
||||
size = size.max(v.abs());
|
||||
if let (Some(l), Some(u)) = (lo.get_mut(k), hi.get_mut(k)) {
|
||||
*l = l.min(v);
|
||||
*u = u.max(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
// |(v + dv) / (w + dw) - v / w| <= err (1 + |v / w|) / (w - err), and the quotient's
|
||||
// own rounding
|
||||
let widen = err * (1.0 + size) / (w_min - err) + gamma(2) * size;
|
||||
if !(widen.is_finite() && lo.iter().chain(&hi).all(|v| v.is_finite())) {
|
||||
return Err(SolidError::Unsupported {
|
||||
operation: OP,
|
||||
reason: "a patch's box is not finite",
|
||||
});
|
||||
}
|
||||
Ok((lo, hi, widen))
|
||||
}
|
||||
|
||||
/// A certified lower bound of the distance from `p` to face `f` of `b`: no point of the face
|
||||
/// is nearer to `p`. The face is bounded by its surface over the face's UV box (its trims
|
||||
/// lie inside it): [`surface_distance_lower_bound`] on them.
|
||||
@@ -320,6 +401,51 @@ pub fn surface_distance_lower_bound(
|
||||
"the bound asked for is finite and not negative",
|
||||
));
|
||||
}
|
||||
let (pieces, slack) = pieces_about(k, surf, uv_box, p)?;
|
||||
let want_net = want + slack;
|
||||
let mut made = pieces.len();
|
||||
let mut heap: BinaryHeap<Piece> = pieces.into_iter().collect();
|
||||
let off = |lower: f64| (lower - slack).max(0.0);
|
||||
loop {
|
||||
// (the box is inside the patches' range, so some patch overlaps it)
|
||||
let nearest = heap.pop().ok_or_else(|| SolidError::sm_err(OP))?;
|
||||
if nearest.lower >= want_net || made >= MAX_PATCHES {
|
||||
return Ok(off(nearest.lower));
|
||||
}
|
||||
// the piece's centre is a point of the net over the box: nearer than the target, no
|
||||
// bound reaches it
|
||||
let (centre, _) = nearest.centre();
|
||||
if (centre[0] * centre[0] + centre[1] * centre[1] + centre[2] * centre[2]).sqrt() < want_net
|
||||
{
|
||||
return Ok(off(nearest.lower));
|
||||
}
|
||||
for q in nearest.quarters()? {
|
||||
heap.push(q);
|
||||
}
|
||||
made += 4;
|
||||
}
|
||||
}
|
||||
|
||||
/// The pieces of `surf` over `uv_box` (as [`surface_distance_lower_bound`] takes it) about
|
||||
/// `p`: its NURBS form's Bézier patches restricted to the box, translated by `-p`, each
|
||||
/// with its coefficients' error (the form's against its definition, the extraction's and
|
||||
/// the translation's, module docs); and the slack every distance from them is to lose (a
|
||||
/// net's certified bound from its definition, 0 for a surface that evaluates on its net).
|
||||
/// The one construction of the face pieces that [`surface_distance_lower_bound`] and the
|
||||
/// contact test between bodies ([`crate::query::contact`]) bound distances with.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// As [`surface_distance_lower_bound`]'s, but for `want`.
|
||||
pub(crate) fn pieces_about(
|
||||
k: &Kernel,
|
||||
surf: &Surface,
|
||||
uv_box: Option<&Extent2d>,
|
||||
p: Point3,
|
||||
) -> Result<(Vec<Piece>, f64)> {
|
||||
if !(p.x.is_finite() && p.y.is_finite() && p.z.is_finite()) {
|
||||
return Err(SolidError::invalid_input(OP, "point not finite"));
|
||||
}
|
||||
if !measurable(k, surf)? {
|
||||
return Err(SolidError::Unsupported {
|
||||
operation: OP,
|
||||
@@ -424,9 +550,7 @@ pub fn surface_distance_lower_bound(
|
||||
));
|
||||
}
|
||||
}
|
||||
let want_net = want + slack;
|
||||
let mut heap = BinaryHeap::new();
|
||||
let mut made = 0_usize;
|
||||
let mut pieces = Vec::new();
|
||||
for patch in patches.iter().flatten() {
|
||||
let (pu, pv) = patch.degrees();
|
||||
let (uk, vk) = (patch.u_knots(), patch.v_knots());
|
||||
@@ -496,33 +620,15 @@ pub fn surface_distance_lower_bound(
|
||||
} else {
|
||||
h
|
||||
};
|
||||
heap.push(Piece::new(h, err, 0)?);
|
||||
made += 1;
|
||||
}
|
||||
let off = |lower: f64| (lower - slack).max(0.0);
|
||||
loop {
|
||||
// (the box is inside the patches' range, so some patch overlaps it)
|
||||
let nearest = heap.pop().ok_or_else(|| SolidError::sm_err(OP))?;
|
||||
if nearest.lower >= want_net || made >= MAX_PATCHES {
|
||||
return Ok(off(nearest.lower));
|
||||
}
|
||||
// the piece's centre is a point of the net over the box: nearer than the target, no
|
||||
// bound reaches it
|
||||
let [x, y, z, w] = &nearest.h;
|
||||
let wc = w.eval(0.5, 0.5);
|
||||
let centre = (
|
||||
x.eval(0.5, 0.5) / wc,
|
||||
y.eval(0.5, 0.5) / wc,
|
||||
z.eval(0.5, 0.5) / wc,
|
||||
);
|
||||
if (centre.0 * centre.0 + centre.1 * centre.1 + centre.2 * centre.2).sqrt() < want_net {
|
||||
return Ok(off(nearest.lower));
|
||||
}
|
||||
for q in nearest.quarters()? {
|
||||
heap.push(q);
|
||||
}
|
||||
made += 4;
|
||||
let uv = [
|
||||
u0 + a0 * (u1 - u0),
|
||||
u0 + a1 * (u1 - u0),
|
||||
v0 + b0 * (v1 - v0),
|
||||
v0 + b1 * (v1 - v0),
|
||||
];
|
||||
pieces.push(Piece::new(h, err, 0, uv)?);
|
||||
}
|
||||
Ok((pieces, slack))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -890,7 +996,10 @@ mod tests {
|
||||
.unwrap()
|
||||
});
|
||||
let exact = h.each_ref().map(exact_quarters);
|
||||
let quarters = Piece::new(h, 0.0, 0).unwrap().quarters().unwrap();
|
||||
let quarters = Piece::new(h, 0.0, 0, [0.0, 1.0, 0.0, 1.0])
|
||||
.unwrap()
|
||||
.quarters()
|
||||
.unwrap();
|
||||
for (q, quarter) in quarters.iter().enumerate() {
|
||||
for (axis, component) in quarter.h.iter().enumerate() {
|
||||
for (rounded, reference) in
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
//! At a vertex where edges meet: the direction an edge leaves it in, how far one edge turns
|
||||
//! from running straight on into another there, and whether an edge is convex there. The
|
||||
//! measures a tangent-chain walk takes at each vertex.
|
||||
//!
|
||||
//! Upstream: none (not in SMLib).
|
||||
//!
|
||||
//! | Rust | Upstream |
|
||||
//! |---------------|----------|
|
||||
//! | [`leaving`] | (none) |
|
||||
//! | [`turn_at`] | (none) |
|
||||
//! | [`convex_at`] | (none) |
|
||||
//!
|
||||
//! Deviations: no upstream counterpart.
|
||||
|
||||
use crate::error::{Result, SolidError};
|
||||
use crate::kernel::Kernel;
|
||||
use crate::math::Vec3;
|
||||
use crate::topology::edgeuse::p1 as eu1;
|
||||
use crate::topology::edgeuse::p3::{evaluate_binormal, BinormalOptions};
|
||||
use crate::topology::{edge, Brep, EdgeId, VertexId};
|
||||
|
||||
const OP: &str = "query::edge_turn";
|
||||
|
||||
fn invalid(reason: &'static str) -> SolidError {
|
||||
SolidError::invalid_input(OP, reason)
|
||||
}
|
||||
|
||||
/// Edge `e`'s parameter at its end `v`, and whether `v` is its start; `None` for a closed
|
||||
/// edge (both ends `v`).
|
||||
fn end_at(b: &Brep, e: EdgeId, v: VertexId) -> Result<Option<(f64, bool)>> {
|
||||
let start = edge::start_vertex(b, e)?;
|
||||
let end = edge::end_vertex(b, e)?;
|
||||
if start == end {
|
||||
return Ok(None);
|
||||
}
|
||||
let ivl = edge::interval(b, e)?;
|
||||
if start == Some(v) {
|
||||
Ok(Some((ivl.min(), true)))
|
||||
} else if end == Some(v) {
|
||||
Ok(Some((ivl.max(), false)))
|
||||
} else {
|
||||
Err(invalid("a vertex that is not an end of the edge"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit direction edge `e` leaves its end `v` in: its curve's tangent there, turned to
|
||||
/// point away from `v`. `None` for a closed edge, an edge without curve, or a tangent of no
|
||||
/// length.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`SolidError::InvalidInput`] for a vertex that is not an end of `e`; those of the
|
||||
/// evaluation.
|
||||
pub fn leaving(k: &Kernel, b: &Brep, e: EdgeId, v: VertexId) -> Result<Option<Vec3>> {
|
||||
let Some((t, at_start)) = end_at(b, e, v)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(curve) = edge::curve(b, e)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// from inside the edge: from the right at its start, from the left at its end
|
||||
let d = curve.evaluate(k, t, 1, !at_start, true)?;
|
||||
let Some(&tangent) = d.get(1) else {
|
||||
return Err(invalid("a curve's evaluation without its tangent"));
|
||||
};
|
||||
let Ok(unit) = tangent.unitize() else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(if at_start { unit } else { unit * -1.0 }))
|
||||
}
|
||||
|
||||
/// How far (radians, 0 to pi) edge `f` turns at their shared end `v` from running straight on
|
||||
/// from edge `e`: the angle between the direction `e` arrives at `v` in and the one `f` leaves
|
||||
/// it in. 0 where `f` continues `e` tangentially. `None` where either direction is undefined
|
||||
/// ([`leaving`]).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Those of [`leaving`].
|
||||
pub fn turn_at(k: &Kernel, b: &Brep, e: EdgeId, f: EdgeId, v: VertexId) -> Result<Option<f64>> {
|
||||
let (Some(arrives), Some(leaves)) = (leaving(k, b, e, v)?, leaving(k, b, f, v)?) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let arrives = arrives * -1.0;
|
||||
Ok(Some(
|
||||
arrives.cross(leaves).length().atan2(arrives.dot(leaves)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether edge `e` is convex at its end `v` (its faces meet at less than 180 degrees inside
|
||||
/// the material there): the direction from the edge into its second face, square to it,
|
||||
/// points along its first face's inward normal (the face use normals of the edge use on the
|
||||
/// material's side, its face use's region not void, and of its radial partner, which point
|
||||
/// into the material). `None` for
|
||||
/// an edge not between two face uses, a closed edge, an edge with no edge use on a material
|
||||
/// region's side, or faces whose normal and binormal there are square to each other (tangent
|
||||
/// faces: no convexity).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`SolidError::InvalidInput`] for a vertex that is not an end of `e`; those of the
|
||||
/// evaluations.
|
||||
pub fn convex_at(k: &Kernel, b: &mut Brep, e: EdgeId, v: VertexId) -> Result<Option<bool>> {
|
||||
if !edge::is_manifold(b, e)? || eu1::edge_is_lamina(b, e)? {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some((t, _)) = end_at(b, e, v)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// the edge use on the material's side: its face use and its radial partner's bound the
|
||||
// material, and their normals point into it (the primary edge use may be on the void's,
|
||||
// whose normals point out)
|
||||
let mut eu0 = None;
|
||||
for eu in edge::edgeuses(b, e, None, None)? {
|
||||
let Some(fu) = eu1::faceuse(b, eu)? else {
|
||||
continue;
|
||||
};
|
||||
if let Some(r) = eu1::faceuse_region(b, fu)? {
|
||||
if b.region(r)?.is_void() == 0 {
|
||||
eu0 = Some(eu);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(eu0) = eu0 else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(eu_r) = eu1::radial(b, eu0)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let options = BinormalOptions::default();
|
||||
let first = evaluate_binormal(k, b, eu0, t, false, &options)?;
|
||||
let second = evaluate_binormal(k, b, eu_r, t, false, &options)?;
|
||||
let side = second.binormal.dot(first.face_normal);
|
||||
if side == 0.0 || !side.is_finite() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(side > 0.0))
|
||||
}
|
||||
+1044
File diff suppressed because it is too large
Load Diff
+1
-6
@@ -1508,12 +1508,7 @@ impl Cone {
|
||||
hv: usize,
|
||||
flags: EvaluateFlags,
|
||||
) -> Result<Vec<Vec3>> {
|
||||
let rows = self.evaluate_rows(k, uv, hu, hv, flags)?;
|
||||
let mut d = Vec::with_capacity((hu + 1) * (hv + 1));
|
||||
for row in rows {
|
||||
d.extend(row);
|
||||
}
|
||||
Ok(d)
|
||||
Ok(self.evaluate_rows(k, uv, hu, hv, flags)?.concat())
|
||||
}
|
||||
|
||||
/// [`Self::evaluate`] as `D[i][j]` rows: exactly `hu + 1` rows of `hv + 1` entries
|
||||
|
||||
+122
-7
@@ -597,6 +597,13 @@ fn joint_removed(out: &Net, side: BoxSide, joint: f64) -> Result<Net> {
|
||||
/// curve: every homogeneous control point in a plane normal to the axis (a parallel) or
|
||||
/// through it (a meridian, a tube), within its rounding; a ruling is straight by its
|
||||
/// degree. Refused as not exact otherwise.
|
||||
///
|
||||
/// The limit, `8 gamma_32` times the point's own size (`|c|` plus the frame's size times
|
||||
/// `|w|`) plus the plane's height times `|w|`, has the shape of a rounding bound but its
|
||||
/// factor is tuned, not derived: `8` and `32` count no operations of `extract_iso_crv`, the
|
||||
/// dot product or the height. It is held to that size by a test (refused at twice it,
|
||||
/// accepted at half), and measured well inside it (largest off / limit 0.0123 over the
|
||||
/// offset and extension goldens); deriving it is open.
|
||||
fn certify_iso(
|
||||
s: &BSplineSurface,
|
||||
side: BoxSide,
|
||||
@@ -614,12 +621,6 @@ fn certify_iso(
|
||||
(middle.x, Direction::V)
|
||||
};
|
||||
let curve = extract_iso_crv(net, t, dir)?;
|
||||
// the iso is interpolated between the net's rows, so its points are known to the rounding
|
||||
// at the size of those rows, not at their own: a short arc's middle iso between rulings
|
||||
// extended 500 mm each way
|
||||
let rows = net.cpts().iter().flatten().fold(0.0_f64, |m, p| {
|
||||
m.max(p.x.abs()).max(p.y.abs()).max(p.z.abs())
|
||||
});
|
||||
let points: Vec<(Vec3, f64, f64)> = curve
|
||||
.cpts()
|
||||
.iter()
|
||||
@@ -627,7 +628,7 @@ fn certify_iso(
|
||||
let w = if p.w == NOW { 1.0 } else { p.w };
|
||||
let h = Vec3::new(p.x, p.y, p.z);
|
||||
// X W from the origin, and the size of its terms
|
||||
let mag = rows + f.size * w.abs();
|
||||
let mag = p.x.abs().max(p.y.abs()).max(p.z.abs()) + f.size * w.abs();
|
||||
(h - f.origin * w, w, mag)
|
||||
})
|
||||
.collect();
|
||||
@@ -921,3 +922,117 @@ fn slot_mut(cp: &mut [Vec<CPoint>], is_u: bool, a: usize, r: usize) -> Result<&m
|
||||
.and_then(|x| x.get_mut(j))
|
||||
.ok_or_else(|| SolidError::sm_err(OP))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing
|
||||
)]
|
||||
|
||||
use super::*;
|
||||
use crate::surface::cone::p1::Cone;
|
||||
|
||||
/// The limit `certify_iso` holds a parallel's homogeneous control point `c` to: the
|
||||
/// rounding at the point's own size and the frame's, `8 gamma_32 (|c| + size |w| +
|
||||
/// |height| |w|)` (written out here so that a change to the limit's size fails).
|
||||
fn limit(c: &CPoint, size: f64, height: f64) -> f64 {
|
||||
let w = if c.w == NOW { 1.0 } else { c.w };
|
||||
let mag = c.x.abs().max(c.y.abs()).max(c.z.abs()) + size * w.abs();
|
||||
8.0 * gamma(32) * (mag + height.abs() * w.abs())
|
||||
}
|
||||
|
||||
/// A cylinder of radius 1 about z, 60 degrees round, 1000 long: rows far larger than
|
||||
/// the turn's own points, so a limit taken at the rows' size would be about 1000 times
|
||||
/// the one at the iso's.
|
||||
fn long_cylinder() -> BSplineSurface {
|
||||
let k = Kernel::default();
|
||||
let s = Surface::from(Cone::new_cylinder_bounded(
|
||||
Vec3::new(0.0, 0.0, 0.0),
|
||||
Vec3::new(1.0, 0.0, 0.0),
|
||||
Vec3::new(0.0, 1.0, 0.0),
|
||||
1.0,
|
||||
0.0,
|
||||
60.0,
|
||||
1000.0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
));
|
||||
crate::blend::fillet_section::station::as_b_spline(&k, &s)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// The net with every control point of index `i` along the iso across `side` moved
|
||||
/// `delta` along the axis (its homogeneous `delta w`): the iso's point `i` moves by
|
||||
/// `delta`, the rest stay.
|
||||
fn moved(form: &BSplineSurface, side: BoxSide, i: usize, delta: f64) -> BSplineSurface {
|
||||
let net = form.gw_nurb().unwrap();
|
||||
let mut cpts = net.cpts();
|
||||
for (a, row) in cpts.iter_mut().enumerate() {
|
||||
for (b, c) in row.iter_mut().enumerate() {
|
||||
let along = if side.is_u() { a } else { b };
|
||||
if along == i {
|
||||
let w = if c.w == NOW { 1.0 } else { c.w };
|
||||
c.z += delta * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (p, q) = net.degrees();
|
||||
BSplineSurface::from_gw_nurb(
|
||||
&Net::new(cpts, p, q, net.u_knots().to_vec(), net.v_knots().to_vec()).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
/// `certify_iso` refuses a parallel whose control point is off its plane by twice the
|
||||
/// limit at the point's own size and passes one off by half of it: the limit's size is
|
||||
/// pinned, and a limit taken at the net's rows (1000 here) would pass both.
|
||||
#[test]
|
||||
fn the_turn_certificate_holds_the_iso_to_its_own_size() {
|
||||
let form = long_cylinder();
|
||||
let frame = Frame {
|
||||
origin: Vec3::new(0.0, 0.0, 0.0),
|
||||
axis: Vec3::new(0.0, 0.0, 1.0),
|
||||
apex: None,
|
||||
size: 1.0,
|
||||
quarter: 0.0,
|
||||
tube_left: 0.0,
|
||||
};
|
||||
let dom = knot_box(&form).unwrap();
|
||||
let mut tested = 0;
|
||||
for side in BoxSide::ALL {
|
||||
let middle = side.point(&dom, 0.5);
|
||||
let (t, dir) = if side.is_u() {
|
||||
(middle.y, Direction::U)
|
||||
} else {
|
||||
(middle.x, Direction::V)
|
||||
};
|
||||
let iso = extract_iso_crv(form.gw_nurb().unwrap(), t, dir).unwrap();
|
||||
let pts = iso.cpts();
|
||||
// the parallel: every point at one height (the other side's iso is a ruling)
|
||||
let z = |c: &CPoint| c.z / if c.w == NOW { 1.0 } else { c.w };
|
||||
if pts.len() < 3 || pts.iter().any(|c| (z(c) - z(&pts[0])).abs() > 1e-9) {
|
||||
continue;
|
||||
}
|
||||
assert!(certify_iso(&form, side, middle, &frame, Iso::Parallel).is_ok());
|
||||
let lim = limit(&pts[1], frame.size, z(&pts[0]));
|
||||
let w1 = if pts[1].w == NOW { 1.0 } else { pts[1].w };
|
||||
let over = moved(&form, side, 1, 2.0 * lim / w1);
|
||||
assert!(
|
||||
certify_iso(&over, side, middle, &frame, Iso::Parallel).is_err(),
|
||||
"{side:?}: twice the limit passes"
|
||||
);
|
||||
let under = moved(&form, side, 1, 0.5 * lim / w1);
|
||||
assert!(
|
||||
certify_iso(&under, side, middle, &frame, Iso::Parallel).is_ok(),
|
||||
"{side:?}: half the limit is refused"
|
||||
);
|
||||
tested += 1;
|
||||
}
|
||||
assert!(tested > 0, "no side's iso is the parallel");
|
||||
}
|
||||
}
|
||||
|
||||
+334
-108
@@ -11,7 +11,6 @@
|
||||
use crate::error::{Result, SolidError};
|
||||
use crate::geom::axis2_placement::Axis2Placement;
|
||||
use crate::geom::extent2d::Extent2d;
|
||||
use crate::geom::vector2d::Point2;
|
||||
use crate::kernel::Kernel;
|
||||
use crate::math::{Vec2, Vec3, PI};
|
||||
use crate::nurbs::b_spline_curve::BSplineCurve;
|
||||
@@ -69,42 +68,6 @@ pub enum Implicit {
|
||||
},
|
||||
}
|
||||
|
||||
impl Implicit {
|
||||
/// How far `x` is from this surface.
|
||||
#[must_use]
|
||||
pub fn distance(&self, x: Vec3) -> f64 {
|
||||
// the axial and radial parts of `x` about an axis through `o`
|
||||
let about = |o: Vec3, axis: Vec3| {
|
||||
let v = x - o;
|
||||
let h = v.dot(axis);
|
||||
(h, (v - axis * h).length())
|
||||
};
|
||||
match *self {
|
||||
Self::Plane { origin, normal } => (x - origin).dot(normal).abs(),
|
||||
Self::Cone {
|
||||
origin,
|
||||
axis,
|
||||
radius,
|
||||
tan_angle,
|
||||
} => {
|
||||
let (h, rho) = about(origin, axis);
|
||||
(rho - (radius + tan_angle * h)).abs() / (1.0 + tan_angle * tan_angle).sqrt()
|
||||
}
|
||||
Self::Sphere { centre, radius } => ((x - centre).length() - radius).abs(),
|
||||
Self::Torus {
|
||||
centre,
|
||||
axis,
|
||||
major,
|
||||
minor,
|
||||
} => {
|
||||
let (h, rho) = about(centre, axis);
|
||||
let (a, b) = (rho - major, h);
|
||||
((a * a + b * b).sqrt() - minor).abs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A polynomial in Bernstein form over a patch, degrees `(p, q)`, `c[i (q + 1) + j]`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct BPoly {
|
||||
@@ -343,7 +306,7 @@ fn bezier_on<'a>(
|
||||
f.c.iter().all(within)
|
||||
}
|
||||
|
||||
pub(crate) const OP_EXACT: &str = "surface::implicit::check_exact_net";
|
||||
const OP_EXACT: &str = "surface::implicit::check_exact_net";
|
||||
|
||||
/// A quadric `x.A.x + 2 b.x + c = 0` as `(A, b, c)`.
|
||||
pub type QuadricEquation = ([[f64; 3]; 3], [f64; 3], f64);
|
||||
@@ -439,46 +402,6 @@ pub fn check_exact_net(k: &Kernel, net: &Surface, check: &ExactNetCheck) -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The number of intervals [`net_miss`] samples a net at, each way.
|
||||
const MISS_SAMPLES: u32 = 16;
|
||||
|
||||
/// How far `net` strays from the class's `surface`, sampled over its domain. A guard, not a
|
||||
/// certificate: [`check_exact_net`] is the proof, and a net that passes only this keeps no
|
||||
/// definition. It tells a net that the class's rounding leaves off the surface from one
|
||||
/// built wrongly.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Those of the evaluations.
|
||||
pub fn net_miss(k: &Kernel, net: &Surface, surface: &ClassSurface) -> Result<f64> {
|
||||
let dom = net.natural_uv_domain_with_nurb(k)?;
|
||||
let (lo, hi) = (dom.min(), dom.max());
|
||||
let mut worst = 0.0_f64;
|
||||
for i in 0..=MISS_SAMPLES {
|
||||
for j in 0..=MISS_SAMPLES {
|
||||
let (fu, fv) = (
|
||||
f64::from(i) / f64::from(MISS_SAMPLES),
|
||||
f64::from(j) / f64::from(MISS_SAMPLES),
|
||||
);
|
||||
let uv = Point2::new(lo.x + (hi.x - lo.x) * fu, lo.y + (hi.y - lo.y) * fv);
|
||||
let x = net.evaluate_point(k, uv)?;
|
||||
let miss = match surface {
|
||||
ClassSurface::Implicit(a) => a.distance(x),
|
||||
ClassSurface::Canal {
|
||||
spine, map, radius, ..
|
||||
} => {
|
||||
let w = if map.along_v { uv.y } else { uv.x };
|
||||
let ivl = spine.natural_interval();
|
||||
let s = (map.a + map.b * w).max(ivl.min()).min(ivl.max());
|
||||
((x - spine.evaluate_point(s)?).length() - *radius).abs()
|
||||
}
|
||||
};
|
||||
worst = worst.max(miss);
|
||||
}
|
||||
}
|
||||
Ok(worst)
|
||||
}
|
||||
|
||||
/// A Bezier segment of a rational curve, Bernstein polynomials of degree `(p, 0)`: the
|
||||
/// homogeneous `(Y - O V)` components and the weight `V`.
|
||||
type CurveSegment = ([BPoly; 3], BPoly);
|
||||
@@ -1200,12 +1123,37 @@ impl AnalyticSupport {
|
||||
// a surface of revolution: the one the kernel recognises in the form, which must be
|
||||
// this one, take the form as its NURB and evaluate as the form at the form's own
|
||||
// parameters
|
||||
let not_form = || {
|
||||
SolidError::invalid_input(
|
||||
OP_BUILD,
|
||||
"a support's analytic surface is not its NURBS form's parametrisation",
|
||||
)
|
||||
};
|
||||
let surface = self.on_form(k, &form)?;
|
||||
if !evaluates_as(k, &surface, &form)? {
|
||||
return Err(not_form());
|
||||
}
|
||||
Ok(surface)
|
||||
}
|
||||
|
||||
/// The largest ratio, over the form's Greville points, of this support's analytic
|
||||
/// surface's distance from `form` to the rounding [`evaluates_as`] derives there (#3185):
|
||||
/// at most 1 where [`Self::build`]'s parametrisation test passes. The surface is the one
|
||||
/// `build` makes (the kernel's recognition of `form`, carrying this support's numbers);
|
||||
/// `lies_on` and the placement's axes are not checked here. `None` for a plane, or a
|
||||
/// surface the bound does not cover.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `InvalidInput` where the kernel recognises no surface of this kind in `form`; those of
|
||||
/// the recognisers, the conversions and the evaluations.
|
||||
pub fn parametrisation_margin(&self, k: &Kernel, form: &BSplineSurface) -> Result<Option<f64>> {
|
||||
if matches!(self, Self::Plane { .. }) {
|
||||
return Ok(None);
|
||||
}
|
||||
let surface = self.on_form(k, form)?;
|
||||
evaluation_margin(k, &surface, form)
|
||||
}
|
||||
|
||||
/// The surface of revolution of this support's kind the kernel recognises in `form`,
|
||||
/// carrying this support's own numbers and `form` as its NURB ([`Self::build`] after its
|
||||
/// checks of the point set, before the parametrisation's).
|
||||
fn on_form(&self, k: &Kernel, form: &BSplineSurface) -> Result<Surface> {
|
||||
let form = form.copy();
|
||||
let mut surface = recognise(k, self.kind(), &form)?.ok_or_else(not_form)?;
|
||||
// the recognised surface must be of this support's kind; the numbers recognition
|
||||
// recomputed are not compared with the support's, because it recomputes them from
|
||||
@@ -1255,13 +1203,18 @@ impl AnalyticSupport {
|
||||
}
|
||||
_ => return Err(not_form()),
|
||||
}
|
||||
if !evaluates_as(k, &surface, &form)? {
|
||||
return Err(not_form());
|
||||
}
|
||||
Ok(surface)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`AnalyticSupport::build`]'s refusal of a surface that is not its form's parametrisation.
|
||||
fn not_form() -> SolidError {
|
||||
SolidError::invalid_input(
|
||||
OP_BUILD,
|
||||
"a support's analytic surface is not its NURBS form's parametrisation",
|
||||
)
|
||||
}
|
||||
|
||||
/// A cylinder's or cone's recognised parametrisation carried by the support's own
|
||||
/// `placement`: its heights are measured along the axis from the placement's origin, so the
|
||||
/// recognised heights move by how far along the axis the recognised origin lies from it.
|
||||
@@ -1336,41 +1289,314 @@ fn recognise(k: &Kernel, kind: SupportKind, form: &BSplineSurface) -> Result<Opt
|
||||
}
|
||||
|
||||
/// Whether `surface` evaluates as `form` at the form's Greville parameters (each control
|
||||
/// point's), within the rounding of the two evaluations: `gamma_K` times the form's size,
|
||||
/// `K = 16 (p + q + 2)` for the rational evaluation of degrees `(p, q)` and the analytic
|
||||
/// one's conversion of the parameters and its trigonometry, a few ulps per operation.
|
||||
/// point's): at each, the analytic surface's point and the form's agree within the bound
|
||||
/// [`Rounding::at`] derives for that point, term by term below. The analytic side is the
|
||||
/// **STEP** evaluation of the converted parameters ([`Surface::convert_uv_from_nurbs_to_step`]
|
||||
/// and [`Surface::evaluate_step_point`]), which a cone, sphere, torus and surface of
|
||||
/// revolution compute from their placement, radii, semi-angle and generator.
|
||||
/// [`Surface::evaluate_point`] would not do: a sphere, a torus and a surface of revolution
|
||||
/// take it from their NURBS base, and that base is the `form` the caller has just installed,
|
||||
/// so it would compare the form with itself and read nothing of what the caller imposed
|
||||
/// (#2588).
|
||||
///
|
||||
/// The analytic side is the **STEP** evaluation of the converted parameters
|
||||
/// ([`Surface::convert_uv_from_nurbs_to_step`] and [`Surface::evaluate_step_point`]),
|
||||
/// which a cone, sphere, torus and surface of revolution compute from their placement,
|
||||
/// radii, semi-angle and generator. [`Surface::evaluate_point`] would not do: a sphere, a
|
||||
/// torus and a surface of revolution take it from their NURBS base, and that base is the
|
||||
/// `form` the caller has just installed, so it would compare the form with itself and read
|
||||
/// nothing of what the caller imposed (#2588; the cone already converted and evaluated
|
||||
/// analytically, which is why only the other kinds went unchecked).
|
||||
/// What the bound forgives and what it tests (#3185): it forgives the rounding of both
|
||||
/// evaluations and of the two parameter conversions, nothing else. It does not forgive the
|
||||
/// label's frame turned about the axis against the converter's, its axis, origin or radii,
|
||||
/// a form whose parameter is not the analytic surface's (a reparametrised arc), nor the
|
||||
/// form's distance from the analytic surface: those are what the comparison proves.
|
||||
///
|
||||
/// `u` is the unit roundoff, `gamma_n = n u / (1 - n u)`. Stated assumption: the libm
|
||||
/// `sin`, `cos`, `tan` and `atan2` are within one ulp (relative `2 u`).
|
||||
///
|
||||
/// 1. **The form** (`Surface::eval`, Cox-de Boor then two blends): each basis value is a
|
||||
/// nonnegative combination over `p` levels of at most 6 operations (two knot differences,
|
||||
/// a sum, a division, a product and a sum), so relative `gamma_6p`; the blends sum
|
||||
/// `q + 1` then `p + 1` products. With positive weights the homogeneous point's error is
|
||||
/// at most `gamma_(7 (p + q) + 4) W size` and the weight's `gamma_(7 (p + q) + 4) W`, so
|
||||
/// the Euclidean point's, after the division, `E_form = (2 gamma_(7 (p + q) + 4) + u)
|
||||
/// size` (`gamma_(7 (p + q) + 4) size` when polynomial), `size` the net's largest point.
|
||||
/// 2. **The STEP point**, `O + m (cos u X + sin u Y) + h Z`, `m` and `h` the generator's
|
||||
/// radial and axial parts (the torus `R + r cos v`, `r sin v`; the sphere `r cos v`,
|
||||
/// `r sin v`; the cone `r0 + v tan a`, `v`). An angle `x` in degrees goes to radians in
|
||||
/// two operations of the rounded `PI`: `gamma_3 |x|`; with one ulp of the cosine and sine,
|
||||
/// each is off by `dc(x) = 2 u + gamma_3 |x|`. Per coordinate (unit axes) the error is at
|
||||
/// most `gamma_3 (|O| + M + H) + M (gamma_2 + 2 dc(u)) + dm + dh`, `M`, `H` the sizes of
|
||||
/// `m`, `h` and `dm`, `dh` their errors: the torus `gamma_3 (R + r) + 2 r dc(v)`, the
|
||||
/// sphere `gamma_1 r + 2 r dc(v)`, the cone `gamma_2 (|r0| + |v t|) + |v| dt` (`dt =
|
||||
/// (1 + t^2) gamma_3 |a| + 2 u |t|` for the tangent of the half angle `a`); a general
|
||||
/// surface of revolution rotates its generator's point `G` (a B-spline: item 1's bound of
|
||||
/// its own net, `E_gen`) about the axis, `gamma_8 (|O| + 3 D) + 2 D dc(u) + E_gen`, `D`
|
||||
/// the net's reach from `O`. `E_step` is `sqrt 3` times the coordinate bound.
|
||||
/// 3. **The generator's parameter**: a circle (torus, sphere) inverts its NURBS point by
|
||||
/// `atan2` about its centre `c`: at its radius `r` the error is that point's (`E_form`)
|
||||
/// plus `gamma_4 (size + |c|)` and `gamma_4 r |v|` of the angle's own rounding; a line
|
||||
/// (cone) maps it affinely in 6 operations, `gamma_6 2 (|v0| + |v1|)` along the axis and
|
||||
/// its slope, times `1 + |t|`; a B-spline generator's parameter is its NURBS's (none).
|
||||
/// 4. **The sweep's parameter**: the polar converter evaluates its circle's NURBS at the
|
||||
/// parameter (item 1's bound of that NURBS's own net, `E_polar`) and inverts the point by
|
||||
/// `atan2` about the circle's centre `c` in its frame: at the circle's radius `rc` the
|
||||
/// angle is off by `(E_polar + gamma_4 (rc + 2 |c|)) / rc`, plus `gamma_4 |s|` of the
|
||||
/// angle's own rounding and the degrees' period (`gamma_1 2 pi`), times the STEP point's
|
||||
/// distance from that axis (the lever). The converter's ends are its domain's, which
|
||||
/// recognition takes from the form's own end meridians (#3199), so they are the inverted
|
||||
/// ends to the same rounding.
|
||||
///
|
||||
/// A surface of another kind, or a general surface of revolution whose generator is not a
|
||||
/// B-spline, has no bound here and is not proved.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Those of the parameter conversion and the evaluations and of the form's knots.
|
||||
fn evaluates_as(k: &Kernel, surface: &Surface, form: &BSplineSurface) -> Result<bool> {
|
||||
Ok(evaluation_margin(k, surface, form)?.is_some_and(|m| m <= 1.0))
|
||||
}
|
||||
|
||||
/// [`evaluates_as`]'s largest ratio of distance to bound over the Greville points (infinite
|
||||
/// for a distance that is not a number); `None` for a surface the bound does not cover.
|
||||
fn evaluation_margin(k: &Kernel, surface: &Surface, form: &BSplineSurface) -> Result<Option<f64>> {
|
||||
let (net, p, q) = euclidean_net(form)?;
|
||||
let size = net.iter().flatten().fold(0.0_f64, |a, x| a.max(x.length()));
|
||||
let tol = gamma(16 * (p + q + 2)) * size;
|
||||
let Some(r) = Rounding::of(surface, form.is_rational(), p, q, size, &net)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut worst = 0.0_f64;
|
||||
for u in greville(form, true)? {
|
||||
for v in greville(form, false)? {
|
||||
let uv = Vec2::new(u, v);
|
||||
let step_uv = surface.convert_uv_from_nurbs_to_step(k, uv)?;
|
||||
let off =
|
||||
(surface.evaluate_step_point(k, step_uv)? - form.evaluate_point(uv)?).length();
|
||||
if !matches!(
|
||||
off.partial_cmp(&tol),
|
||||
Some(core::cmp::Ordering::Less | core::cmp::Ordering::Equal)
|
||||
) {
|
||||
return Ok(false);
|
||||
}
|
||||
let s = surface.evaluate_step_point(k, step_uv)?;
|
||||
let f = form.evaluate_point(uv)?;
|
||||
let ratio = (s - f).length() / r.at(step_uv, s, f);
|
||||
worst = if ratio.is_nan() {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
worst.max(ratio)
|
||||
};
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
Ok(Some(worst))
|
||||
}
|
||||
|
||||
/// A surface of revolution's generator, for [`Rounding`]'s item 2 and 3.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Generator {
|
||||
/// `R + r cos v`, `r sin v` about the circle of centre `c`.
|
||||
Torus { major: f64, minor: f64, c: f64 },
|
||||
/// `r cos v`, `r sin v` about the circle of centre `c`.
|
||||
Sphere { radius: f64, c: f64 },
|
||||
/// `r0 + v t`, `v` (the half angle `a` in radians), its STEP `v` over `[v0, v1]`.
|
||||
Cone {
|
||||
r0: f64,
|
||||
t: f64,
|
||||
a: f64,
|
||||
v0: f64,
|
||||
v1: f64,
|
||||
},
|
||||
/// A B-spline generator, its own evaluation's bound.
|
||||
Spline { e_gen: f64 },
|
||||
}
|
||||
|
||||
/// The pieces of [`evaluates_as`]'s bound that do not depend on the point.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Rounding {
|
||||
e_form: f64,
|
||||
origin: Vec3,
|
||||
/// The net's reach from the origin.
|
||||
reach: f64,
|
||||
generator: Generator,
|
||||
/// The sweep converter's circle: centre, axes.
|
||||
polar: (Vec3, Vec3, Vec3, f64, f64),
|
||||
}
|
||||
|
||||
/// `|v|`'s largest coordinate.
|
||||
fn max_coord(v: Vec3) -> f64 {
|
||||
v.x.abs().max(v.y.abs()).max(v.z.abs())
|
||||
}
|
||||
|
||||
/// A rational (or polynomial) evaluation's bound of degrees summing to `n` over points of
|
||||
/// size `size` ([`evaluates_as`] item 1).
|
||||
fn eval_bound(rational: bool, n: usize, size: f64) -> f64 {
|
||||
let g = gamma(7 * n + 4);
|
||||
if rational {
|
||||
(2.0 * g + crate::nurbs::math::UNIT) * size
|
||||
} else {
|
||||
g * size
|
||||
}
|
||||
}
|
||||
|
||||
impl Rounding {
|
||||
/// The bound's data for `surface` (a cone, sphere, torus or surface of revolution with a
|
||||
/// B-spline generator) on its form of degrees `(p, q)`, net `net`; `None` for any other.
|
||||
fn of(
|
||||
surface: &Surface,
|
||||
rational: bool,
|
||||
p: usize,
|
||||
q: usize,
|
||||
size: f64,
|
||||
net: &[Vec<Vec3>],
|
||||
) -> Result<Option<Self>> {
|
||||
use crate::curve::curve::CurveKind;
|
||||
let e_form = eval_bound(rational, p + q, size);
|
||||
let circle_centre = |rev: &crate::surface::surf_of_revolution::SurfOfRevolution| match rev
|
||||
.gen_curve
|
||||
.as_ref()
|
||||
.map(crate::curve::curve::Curve::kind)
|
||||
{
|
||||
Some(CurveKind::Ellipse(e)) => Some(e.position.origin().length()),
|
||||
_ => None,
|
||||
};
|
||||
let (rev, generator) = match surface.kind() {
|
||||
SurfaceKind::Torus(t) => {
|
||||
let Some(c) = circle_centre(&t.base) else {
|
||||
return Ok(None);
|
||||
};
|
||||
(
|
||||
&t.base,
|
||||
Generator::Torus {
|
||||
major: t.major_radius,
|
||||
minor: t.minor_radius,
|
||||
c,
|
||||
},
|
||||
)
|
||||
}
|
||||
SurfaceKind::Sphere(sp) => {
|
||||
let Some(c) = circle_centre(&sp.base) else {
|
||||
return Ok(None);
|
||||
};
|
||||
(
|
||||
&sp.base,
|
||||
Generator::Sphere {
|
||||
radius: sp.radius,
|
||||
c,
|
||||
},
|
||||
)
|
||||
}
|
||||
SurfaceKind::Cone(c) => {
|
||||
let a = c.semi_angle_deg * PI / 180.0;
|
||||
let d = c.rev.anal_uv_domain;
|
||||
(
|
||||
&c.rev,
|
||||
Generator::Cone {
|
||||
r0: c.base_radius,
|
||||
t: c.tan_semi_angle(),
|
||||
a,
|
||||
v0: d.v_min(),
|
||||
v1: d.v_max(),
|
||||
},
|
||||
)
|
||||
}
|
||||
SurfaceKind::SurfOfRevolution(rev) => {
|
||||
let Some(CurveKind::BSpline(g)) =
|
||||
rev.gen_curve.as_ref().map(crate::curve::curve::Curve::kind)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let pts = g.control_points()?;
|
||||
let gsize = pts.iter().fold(0.0_f64, |m, c| {
|
||||
let w = if c.w == NOW { 1.0 } else { c.w };
|
||||
m.max((Vec3::new(c.x, c.y, c.z) * (1.0 / w)).length())
|
||||
});
|
||||
let rational = pts.iter().any(|c| c.w != NOW);
|
||||
(
|
||||
&**rev,
|
||||
Generator::Spline {
|
||||
e_gen: eval_bound(rational, g.degree()?, gsize),
|
||||
},
|
||||
)
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let Some(pc) = rev.polar_curve() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let e_polar = {
|
||||
let pts = pc.base().control_points()?;
|
||||
let psize = pts.iter().fold(0.0_f64, |m, c| {
|
||||
let w = if c.w == NOW { 1.0 } else { c.w };
|
||||
m.max((Vec3::new(c.x, c.y, c.z) * (1.0 / w)).length())
|
||||
});
|
||||
eval_bound(pts.iter().any(|c| c.w != NOW), pc.base().degree()?, psize)
|
||||
};
|
||||
let origin = rev.position.origin();
|
||||
let reach = net
|
||||
.iter()
|
||||
.flatten()
|
||||
.fold(0.0_f64, |m, x| m.max((*x - origin).length()));
|
||||
Ok(Some(Self {
|
||||
e_form,
|
||||
origin,
|
||||
reach,
|
||||
generator,
|
||||
polar: (
|
||||
pc.position.origin(),
|
||||
pc.position.x_axis(),
|
||||
pc.position.y_axis(),
|
||||
pc.radius_x,
|
||||
e_polar,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The bound at the STEP parameters `step` (degrees, and the generator's), the STEP
|
||||
/// point `s` and the form's point `f` ([`evaluates_as`] items 1 to 4).
|
||||
fn at(&self, step: Vec2, s: Vec3, f: Vec3) -> f64 {
|
||||
let u = crate::nurbs::math::UNIT;
|
||||
let rad = |deg: f64| deg * PI / 180.0;
|
||||
let dc = |deg: f64| 2.0 * u + gamma(3) * rad(deg).abs();
|
||||
let o = max_coord(self.origin);
|
||||
let size_f = f.length();
|
||||
// item 2 (per coordinate) and item 3 (as a length)
|
||||
let (coord, generator) = match self.generator {
|
||||
Generator::Torus { major, minor, c } => {
|
||||
let (m, h) = (major + minor, minor);
|
||||
let dm = gamma(3) * (major + minor) + 2.0 * minor * dc(step.y);
|
||||
let dh = gamma(1) * minor + 2.0 * minor * dc(step.y);
|
||||
(
|
||||
gamma(3) * (o + m + h) + m * (gamma(2) + 2.0 * dc(step.x)) + dm + dh,
|
||||
self.e_form + gamma(4) * (size_f + c) + gamma(4) * minor * rad(step.y).abs(),
|
||||
)
|
||||
}
|
||||
Generator::Sphere { radius, c } => {
|
||||
let dm = gamma(1) * radius + 2.0 * radius * dc(step.y);
|
||||
(
|
||||
gamma(3) * (o + 2.0 * radius)
|
||||
+ radius * (gamma(2) + 2.0 * dc(step.x))
|
||||
+ 2.0 * dm,
|
||||
self.e_form + gamma(4) * (size_f + c) + gamma(4) * radius * rad(step.y).abs(),
|
||||
)
|
||||
}
|
||||
Generator::Cone { r0, t, a, v0, v1 } => {
|
||||
let v = step.y;
|
||||
let m = r0.abs() + (v * t).abs();
|
||||
let dt = (1.0 + t * t) * gamma(3) * a.abs() + 2.0 * u * t.abs();
|
||||
let dm = gamma(2) * m + v.abs() * dt;
|
||||
(
|
||||
gamma(3) * (o + m + v.abs()) + m * (gamma(2) + 2.0 * dc(step.x)) + dm,
|
||||
gamma(6) * 2.0 * (v0.abs() + v1.abs()) * (1.0 + t.abs()),
|
||||
)
|
||||
}
|
||||
Generator::Spline { e_gen } => (
|
||||
gamma(8) * (o + 3.0 * self.reach) + 2.0 * self.reach * dc(step.x) + e_gen,
|
||||
0.0,
|
||||
),
|
||||
};
|
||||
let e_step = 3.0_f64.sqrt() * coord;
|
||||
// item 4: the polar converter's inversion of its own circle's point
|
||||
let (c, x, y, rc, e_polar) = self.polar;
|
||||
let z = x.cross(y);
|
||||
let along = |v: Vec3| v - z * v.dot(z);
|
||||
let lever = along(s - c).length();
|
||||
let sweep = if rc > 0.0 {
|
||||
lever
|
||||
* ((e_polar + gamma(4) * (rc + 2.0 * c.length())) / rc
|
||||
+ gamma(4) * rad(step.x).abs()
|
||||
+ gamma(1) * 2.0 * PI)
|
||||
} else {
|
||||
// a circle of no radius has no angle: any, at the STEP point's lever
|
||||
lever * PI
|
||||
};
|
||||
self.e_form + e_step + generator + sweep
|
||||
}
|
||||
}
|
||||
|
||||
const OP_DERIVE: &str = "surface::implicit::AnalyticSupport::derive";
|
||||
|
||||
@@ -39,11 +39,62 @@ use crate::geom::vector3d::{Point3, Vec3};
|
||||
use crate::kernel::Kernel;
|
||||
use crate::math::EFF_ZERO;
|
||||
use crate::nurbs::b_spline_curve::BSplineCurve;
|
||||
use crate::surface::implicit::{check_exact_net, net_miss, ClassSurface, ExactNetCheck, LeaderMap};
|
||||
use crate::surface::implicit::{
|
||||
check_exact_net, lies_on, AnalyticSupport, ClassSurface, ExactNetCheck, Implicit, LeaderMap,
|
||||
};
|
||||
use crate::surface::procedural_blend::chamfer::ChamferDefinition;
|
||||
use crate::surface::surface::Surface;
|
||||
|
||||
const OP_TRANSFORM: &str = "surface::procedural_blend::exact::ExactRound::transform";
|
||||
const OP_CIRCLE: &str = "surface::procedural_blend::exact::ExactRound::supports_distance";
|
||||
|
||||
/// The torus a round of the circle class is: a surface of revolution's, about its
|
||||
/// placement's axis, of the circle its generating arc is on (the centre of a rational quadratic
|
||||
/// arc from its first span's control points: the midpoint of the chord pushed away from the
|
||||
/// middle point by `a^2 / h`, `a` the half chord, `h` the middle point's distance from the
|
||||
/// chord's midpoint); or a torus label's own. `None` for any other surface.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Those of reading the arc's control polygon.
|
||||
fn own_torus(net: &Surface) -> Result<Option<Implicit>> {
|
||||
use crate::curve::curve::{Curve, CurveKind};
|
||||
use crate::surface::surface::SurfaceKind;
|
||||
match net.kind() {
|
||||
SurfaceKind::Torus(_) => Ok(AnalyticSupport::of(net)
|
||||
.as_ref()
|
||||
.map(AnalyticSupport::implicit)),
|
||||
SurfaceKind::SurfOfRevolution(rev) => {
|
||||
let Some(CurveKind::BSpline(arc)) = rev.gen_curve().map(Curve::kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (points, _) = arc.control_polygon()?;
|
||||
let [p0, p1, p2, ..] = points.as_slice() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mid = (*p0 + *p2) * 0.5;
|
||||
let (a, h) = ((*p0 - mid).length(), (mid - *p1).length());
|
||||
if h.partial_cmp(&0.0) != Some(core::cmp::Ordering::Greater) {
|
||||
return Ok(None);
|
||||
}
|
||||
let centre = mid + (mid - *p1) * (a * a / (h * h));
|
||||
let position = rev.position();
|
||||
let (o, axis) = (
|
||||
position.origin(),
|
||||
position.x_axis().cross(position.y_axis()),
|
||||
);
|
||||
let along = (centre - o).dot(axis);
|
||||
let across = (centre - o) - axis * along;
|
||||
Ok(Some(Implicit::Torus {
|
||||
centre: o + axis * along,
|
||||
axis,
|
||||
major: across.length(),
|
||||
minor: (*p0 - centre).length(),
|
||||
}))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The exact class of a round whose net is its definition.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -263,7 +314,8 @@ impl ExactRound {
|
||||
|
||||
/// Re-certifies `net` against this definition: the supports classified again are of
|
||||
/// this class, and the net lies on the class's surface derived from them
|
||||
/// ([`check_exact_net`]). Returns the distance proved, 0.
|
||||
/// ([`check_exact_net`]). A round of the circle class is certified on its own torus
|
||||
/// instead ([`ExactRound::supports_distance`]). Returns the distance proved, 0.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -271,6 +323,10 @@ impl ExactRound {
|
||||
/// surface from them, a net off it; those of reading the supports' quadrics and the
|
||||
/// net's NURBS form.
|
||||
pub fn recertify(&self, k: &Kernel, net: &Surface) -> Result<f64> {
|
||||
if self.class == ExactClass::Circle {
|
||||
self.supports_distance(k, net)?;
|
||||
return Ok(0.0);
|
||||
}
|
||||
let [a, b] = &self.supports;
|
||||
let blend = classify_supports(k, [a, b], &self.inputs)?;
|
||||
// the chord (#2486): a line spine between planes, of sizes that give its offsets
|
||||
@@ -302,19 +358,72 @@ impl ExactRound {
|
||||
Ok(0.0)
|
||||
}
|
||||
|
||||
/// How far `net` strays from the surface of the class its supports give
|
||||
/// ([`net_miss`]); `None` where they give it none.
|
||||
/// A round of the circle class: the torus `net` is ([`own_torus`]: its revolution's
|
||||
/// axis and generating circle, or its torus label), proved to hold its form to rounding
|
||||
/// ([`lies_on`]),
|
||||
/// and how far that torus is from the class's torus derived from the supports
|
||||
/// ([`class_surface`]): returned, refused past the tolerance the class was read within.
|
||||
///
|
||||
/// The class is read within the body's tolerance. Supports only that close to it (a plane
|
||||
/// 1e-13 off square to a cylinder's axis, read from a record) have no torus touching both
|
||||
/// exactly, so the round the solver builds, tangent to the supports as they are, is on a
|
||||
/// torus of its own. Its definition is that torus, at bound 0, a stated distance from the
|
||||
/// supports' class.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Those of reading the supports' quadrics and of the evaluations.
|
||||
pub fn miss(&self, k: &Kernel, net: &Surface) -> Result<Option<f64>> {
|
||||
/// `InvalidInput` for supports of another class or with no torus, a net of no torus, a
|
||||
/// net off its torus, or a torus further from the class's than the tolerance; those of
|
||||
/// reading the supports' quadrics, the net's NURBS form and [`lies_on`].
|
||||
pub fn supports_distance(&self, k: &Kernel, net: &Surface) -> Result<f64> {
|
||||
let refused = |why| SolidError::invalid_input(OP_CIRCLE, why);
|
||||
let [a, b] = &self.supports;
|
||||
let blend = classify_supports(k, [a, b], &self.inputs)?;
|
||||
match class_surface(&blend, self.leader.as_ref(), &self.map) {
|
||||
Some(surface) => net_miss(k, net, &surface).map(Some),
|
||||
None => Ok(None),
|
||||
if ExactClass::of_spine(blend.spine) != Some(ExactClass::Circle) {
|
||||
return Err(refused(
|
||||
"the supports classified again are not of the class the definition names",
|
||||
));
|
||||
}
|
||||
let Some(ClassSurface::Implicit(Implicit::Torus {
|
||||
centre,
|
||||
axis,
|
||||
major,
|
||||
minor,
|
||||
})) = class_surface(&blend, self.leader.as_ref(), &self.map)
|
||||
else {
|
||||
return Err(refused("the supports give the class no torus"));
|
||||
};
|
||||
let form = crate::blend::fillet_section::station::as_b_spline(k, net)?
|
||||
.ok_or_else(|| refused("the net has no NURBS form to check"))?;
|
||||
let own = own_torus(net)?.ok_or_else(|| refused("the net is no torus"))?;
|
||||
if !lies_on(&form, &own)? {
|
||||
return Err(refused("the net is off its own torus"));
|
||||
}
|
||||
let Implicit::Torus {
|
||||
centre: c,
|
||||
axis: d,
|
||||
major: big_r,
|
||||
minor: r,
|
||||
} = own
|
||||
else {
|
||||
return Err(refused("the net's own surface is no torus"));
|
||||
};
|
||||
// every point of the net's torus moves to the class's torus by at most: the turn of
|
||||
// its axis (an angle, over the torus's reach from its centre), the move of its
|
||||
// centre, and the change of its radii
|
||||
let turn = d.cross(axis).length().atan2(d.dot(axis).abs());
|
||||
let distance =
|
||||
turn * (big_r + r) + (c - centre).length() + (big_r - major).abs() + (r - minor).abs();
|
||||
let within = matches!(
|
||||
distance.partial_cmp(&self.inputs.tol),
|
||||
Some(core::cmp::Ordering::Less | core::cmp::Ordering::Equal)
|
||||
);
|
||||
if !within {
|
||||
return Err(refused(
|
||||
"the net's torus is further from the class's than the tolerance",
|
||||
));
|
||||
}
|
||||
Ok(distance)
|
||||
}
|
||||
|
||||
/// Moves the definition with its net (`Transform`): the supports, the leader and the
|
||||
|
||||
+72
-7
@@ -110,6 +110,12 @@
|
||||
//! WR-493, the SolidCraft fork's tangency-labs/smlib@7ea6a96): `IsNurbSurfaceSphere`
|
||||
//! tests the half meridian within `180 + 360 * SM_EFF_ZERO` degrees and the sphere it
|
||||
//! finds keeps the tested NURB (`SetFromGwNurb`). Off by default (upstream).
|
||||
//! - **Far spheres** (#3131, by default): `IsNurbSurfaceSphere` and
|
||||
//! `RebuildSTEPFromNURBParameters` add to their fixed half-meridian tolerances, and
|
||||
//! `IsNurbSurfaceSphere` to its generator's STEP interval test, the angles' rounding at
|
||||
//! the points' size ([`meridian_rounding`]): upstream's fixed degrees refused a sphere of
|
||||
//! radius 2.5 at 1e5 (180 + 6.7e-10 degrees). Near the origin the default half-meridian
|
||||
//! test widens from `1e-12` to about `1e-12 + 8e-12` degrees for radius 2.5.
|
||||
//! - **Parent calls.** `Transform` finishes in `SmSurfOfRevolution::Transform` (status
|
||||
//! ignored), its polar-converter rebuild given the sphere's iso circles as upstream's
|
||||
//! virtual call does; `ReadFromDB` reads the parent record with
|
||||
@@ -133,12 +139,15 @@ use crate::geom::tol::{are_same, are_same_to_tol, is_zero};
|
||||
use crate::geom::vector2d::Point2;
|
||||
use crate::geom::vector3d::{asin, sqrt, Point3, Vec3};
|
||||
use crate::kernel::Kernel;
|
||||
use crate::math::{max2, min2, EFF_ZERO, EFF_ZERO_PARAM, EFF_ZERO_SQRT, PI, UNDEF_DOUBLE};
|
||||
use crate::math::{
|
||||
max2, min2, rad_to_deg, EFF_ZERO, EFF_ZERO_PARAM, EFF_ZERO_SQRT, PI, UNDEF_DOUBLE,
|
||||
};
|
||||
use crate::nurbs::b_spline_curve::BSplineCurve;
|
||||
use crate::nurbs::b_spline_surface::p2::EditNotify;
|
||||
use crate::nurbs::b_spline_surface::{
|
||||
BSplineSurface, BoundingBoxRequest, IsoCurveOptions, LocationType, SurfParamType,
|
||||
};
|
||||
use crate::nurbs::math::gamma;
|
||||
use crate::nurbs::nurbs_crv::to_euclid;
|
||||
use crate::nurbs::nurbs_srf::copy_nurb_surface;
|
||||
use crate::surface::cone::Cone;
|
||||
@@ -2029,7 +2038,11 @@ impl Sphere {
|
||||
/// (WR-493) the half turn is tested within `180 + 360 * SM_EFF_ZERO` degrees and the
|
||||
/// sphere then keeps a copy of `test`'s NURB, its analytic data rebuilt from it
|
||||
/// (`SetFromGwNurb`); by default `180 + SM_EFF_ZERO` and the NURB made from the
|
||||
/// fitted circles, as upstream.
|
||||
/// fitted circles, as upstream. Not upstream: in both modes the half-meridian test, and
|
||||
/// the test of the generator's STEP interval against the sphere's (within `SM_EFF_ZERO`
|
||||
/// upstream), also allow the angles' rounding at the points' size,
|
||||
/// [`meridian_rounding`] of the corner points' size and the circle's radius: `4
|
||||
/// gamma_K (1 + |corners| + 2 r) / r` radians (#3131).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -2109,14 +2122,18 @@ impl Sphere {
|
||||
} else {
|
||||
EFF_ZERO
|
||||
};
|
||||
// not upstream: the angles' rounding at the points' size (`meridian_rounding`)
|
||||
let corners = min_pnt.max_dimension().max(max_pnt.max_dimension());
|
||||
let (p, q) = (test.degree(SurfParamType::U), test.degree(SurfParamType::V));
|
||||
let rounding = |radius: Option<f64>| meridian_rounding(p, q, corners, radius);
|
||||
let is_sphere_with_swap_uv = iso_u.parallel_planes
|
||||
&& !iso_u.same_plane // [ 080625 ]
|
||||
&& iso_v.coaxial_centers
|
||||
&& angles_v.length() <= 180.0 + half_meridian_tol;
|
||||
&& angles_v.length() <= 180.0 + half_meridian_tol + rounding(iso_v.radius);
|
||||
let is_sphere_no_swap_uv = iso_v.parallel_planes
|
||||
&& !iso_v.same_plane // [ 080625 ]
|
||||
&& iso_u.coaxial_centers
|
||||
&& angles_u.length() <= 180.0 + half_meridian_tol;
|
||||
&& angles_u.length() <= 180.0 + half_meridian_tol + rounding(iso_u.radius);
|
||||
|
||||
// quit when not a properly oriented sphere
|
||||
if !is_sphere_with_swap_uv && !is_sphere_no_swap_uv {
|
||||
@@ -2224,9 +2241,10 @@ impl Sphere {
|
||||
// If pCircle's STEP interval does not match the v-interval of sAnalUVDomain,
|
||||
// then the Sphere constructor will complain and the resulting bad surface can
|
||||
// lead to crashes. [B414]
|
||||
// (not upstream: its angles to the same rounding as the half meridian's)
|
||||
if !circle
|
||||
.step_interval()
|
||||
.are_equal(&anal_uv_domain.v_interval(), EFF_ZERO)
|
||||
.are_equal(&anal_uv_domain.v_interval(), EFF_ZERO + rounding(Some(rad)))
|
||||
{
|
||||
// upstream: WARN("Bad result in IsNurbSurfaceSphere(): GenCurve STEP
|
||||
// interval does not match sAnalUVDomain VInterval")
|
||||
@@ -2333,16 +2351,23 @@ impl Sphere {
|
||||
|
||||
// see if we have a U or a V oriented sphere
|
||||
// Tol: compensate for using degrees. [B279]
|
||||
// (not upstream: and the angles' rounding at the points' size, `meridian_rounding`)
|
||||
let corners = nurb_min_point
|
||||
.max_dimension()
|
||||
.max(nurb_max_point.max_dimension());
|
||||
let (p, q) = (srf.degree(SurfParamType::U), srf.degree(SurfParamType::V));
|
||||
let angles_u = iso_u.angles.unwrap_or(Extent1d::new());
|
||||
let angles_v = iso_v.angles.unwrap_or(Extent1d::new());
|
||||
let is_sphere_with_swap_uv = iso_u.parallel_planes
|
||||
&& !iso_v.same_plane // [ 080625 ]
|
||||
&& iso_v.coaxial_centers
|
||||
&& angles_v.length() <= 180.0 + 360.0 * EFF_ZERO;
|
||||
&& angles_v.length()
|
||||
<= 180.0 + 360.0 * EFF_ZERO + meridian_rounding(p, q, corners, iso_v.radius);
|
||||
let is_sphere_no_swap_uv = iso_v.parallel_planes
|
||||
&& !iso_v.same_plane // [ 080625 ]
|
||||
&& iso_u.coaxial_centers
|
||||
&& angles_u.length() <= 180.0 + 360.0 * EFF_ZERO;
|
||||
&& angles_u.length()
|
||||
<= 180.0 + 360.0 * EFF_ZERO + meridian_rounding(p, q, corners, iso_u.radius);
|
||||
|
||||
// quit when not a properly oriented sphere
|
||||
if !is_sphere_with_swap_uv && !is_sphere_no_swap_uv {
|
||||
@@ -2985,6 +3010,34 @@ fn convert_uv_from_nurbs_to_step(
|
||||
Ok(Point2::new(step_x, polar_param))
|
||||
}
|
||||
|
||||
/// Not upstream: the rounding, in degrees, of the length of the half meridian (or of a
|
||||
/// circle's STEP interval) that [`Sphere::is_nurb_surface_sphere`] and
|
||||
/// [`Sphere::rebuild_step_from_nurb_parameters`] read off a form of degrees `(p, q)` whose
|
||||
/// corner points are at most `corners` from the origin, its circle of radius `radius`; 0
|
||||
/// without a radius.
|
||||
///
|
||||
/// The angles are read off points of the form: an evaluation is within `gamma_K |P|` of
|
||||
/// the exact point (`K = 16 (p + q + 2)`, a few ulps for each of the rational evaluation's
|
||||
/// operations), `|P| <= 1 + corners + 2 r` (a point of the sphere is at most its
|
||||
/// diameter from a corner). The circle's centre is fitted to such points; its error is taken
|
||||
/// to be the same again, which is measured, not derived (the fit's conditioning is not
|
||||
/// bounded here: `far_sphere_recognised.rs` measures the excess over this allowance on
|
||||
/// spheres of radius 1e-3 to 1e3, at 0 to 1e7 from the origin, turned off the axes: at most
|
||||
/// 0.0055 of this allowance). An
|
||||
/// angle at radius `r` is then known to `2 gamma_K |P| / r` radians at each end, and the half
|
||||
/// turn's length to twice that: `4 gamma_K |P| / r`. As a position, `r` times that is
|
||||
/// `4 gamma_K |P|`, below the iso-circle tests' `100 SM_EFF_ZERO (1 + |corners|)` for every
|
||||
/// size, so it admits no form those tests refuse.
|
||||
pub(crate) fn meridian_rounding(p: usize, q: usize, corners: f64, radius: Option<f64>) -> f64 {
|
||||
match radius {
|
||||
Some(r) if r > 0.0 => {
|
||||
let size = 1.0 + corners + 2.0 * r;
|
||||
rad_to_deg(4.0 * gamma(16 * (p + q + 2)) * size / r)
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -3001,6 +3054,18 @@ mod tests {
|
||||
Sphere::create_canonical(k, &frame(), radius).expect("sphere")
|
||||
}
|
||||
|
||||
/// `meridian_rounding` for radius 2.5 against numbers recomputed independently of it:
|
||||
/// 1.8e-11 degrees with corners at 12.5 (the origin) and 9.77e-8 degrees with corners at
|
||||
/// 100012.5 (1e5 out), so a doubled or halved allowance fails here (#3131).
|
||||
#[test]
|
||||
fn the_meridian_allowance_is_its_closed_form() {
|
||||
for (corners, degrees) in [(12.5, 1.8e-11), (100_012.5, 9.77e-8)] {
|
||||
let got = meridian_rounding(2, 2, corners, Some(2.5));
|
||||
assert!((got / degrees - 1.0).abs() < 0.02, "{corners}: {got:e}");
|
||||
}
|
||||
assert_eq!(meridian_rounding(2, 2, 1.0, None), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_with_domain_keeps_the_shape() {
|
||||
let k = Kernel::default();
|
||||
|
||||
+99
-6
@@ -1061,7 +1061,6 @@ impl Torus {
|
||||
let ref_frame = ref_iso.reference_frame.ok_or_else(missing)?;
|
||||
let other_frame = other_iso.reference_frame.ok_or_else(missing)?;
|
||||
let min_rad = other_iso.radius.ok_or_else(missing)?;
|
||||
let sweep_ivl = ref_iso.angles.ok_or_else(missing)?;
|
||||
let common_point = other_iso.common_line_point.ok_or_else(missing)?;
|
||||
let common_vec = other_iso.common_line_vec.ok_or_else(missing)?;
|
||||
|
||||
@@ -1069,6 +1068,17 @@ impl Torus {
|
||||
let torus_origin =
|
||||
common_point + common_vec.dot(other_frame.origin() - common_point) * common_vec;
|
||||
|
||||
// not upstream (#3199): the x axis and the sweep interval from the form's own first
|
||||
// sweep row's ends about the axis (`sweep_frame`), not from `is_arc`'s fit of it
|
||||
let Some((torus_x_axis, torus_y_axis, sweep_ivl)) = sweep_frame(
|
||||
torus_origin,
|
||||
ref_frame.z_axis(),
|
||||
fitted_sides(&ref_frame, ref_iso.angles.ok_or_else(missing)?),
|
||||
meridians(test, swap_uv)?,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// get torus orientation and size
|
||||
// not upstream (WR-501, #1470, opt-in; tangency-labs/smlib@6136a04): the major
|
||||
// radius runs from the torus origin, not from the first sweep circle's centre,
|
||||
@@ -1079,9 +1089,7 @@ impl Torus {
|
||||
other_frame.origin() - ref_frame.origin()
|
||||
};
|
||||
let maj_rad = major_vec.length();
|
||||
let torus_x_axis = ref_frame.x_axis(); // the frame where all circles are parallel
|
||||
let torus_y_axis = ref_frame.y_axis();
|
||||
let torus_z_axis = ref_frame.z_axis();
|
||||
let torus_z_axis = ref_frame.z_axis(); // the frame where all circles are parallel
|
||||
|
||||
// torus must have positive radii
|
||||
if min_rad <= EFF_ZERO || maj_rad <= EFF_ZERO {
|
||||
@@ -1294,7 +1302,6 @@ impl Torus {
|
||||
let mut ref_frame = ref_iso.reference_frame.ok_or_else(missing)?;
|
||||
let other_frame = other_iso.reference_frame.ok_or_else(missing)?;
|
||||
let min_rad = other_iso.radius.ok_or_else(missing)?;
|
||||
let sweep_ivl = ref_iso.angles.ok_or_else(missing)?;
|
||||
let common_point = other_iso.common_line_point.ok_or_else(missing)?;
|
||||
let common_vec = other_iso.common_line_vec.ok_or_else(missing)?;
|
||||
|
||||
@@ -1302,8 +1309,18 @@ impl Torus {
|
||||
let torus_origin =
|
||||
common_point + common_vec.dot(other_frame.origin() - common_point) * common_vec;
|
||||
|
||||
// not upstream (#3199): the x axis and the sweep interval from the form's own first
|
||||
// sweep row's ends about the axis (`sweep_frame`), not from `is_arc`'s fit of it
|
||||
let (x_axis, y_axis, sweep_ivl) = sweep_frame(
|
||||
torus_origin,
|
||||
ref_frame.z_axis(),
|
||||
fitted_sides(&ref_frame, ref_iso.angles.ok_or_else(missing)?),
|
||||
meridians(srf, swap_uv)?,
|
||||
)
|
||||
.ok_or_else(|| SolidError::sm_err(OP_REBUILD))?;
|
||||
|
||||
// Install the origin into pRefFrame. // [B328]
|
||||
let _ = ref_frame.set_canonical(torus_origin, ref_frame.x_axis(), ref_frame.y_axis());
|
||||
let _ = ref_frame.set_canonical(torus_origin, x_axis, y_axis);
|
||||
|
||||
// get torus orientation and size
|
||||
let major_vec = other_frame.origin() - ref_frame.origin();
|
||||
@@ -1587,6 +1604,82 @@ impl Revolved for Torus {
|
||||
|
||||
/// `SmTorus::CreateIsoParametricCurve` of the surface of revolution `rev` of a torus
|
||||
/// of `shape` (also called from the base class's rebuilds, where it is virtual).
|
||||
/// The control points, Euclidean, of the form's first and last meridians: the columns of its
|
||||
/// net at the sweep's two ends (the sweep is along u, or along v with `swap_uv`). A clamped
|
||||
/// end's column is that boundary curve's own control polygon, and a meridian lies in a plane
|
||||
/// through the axis, so every one of its control points has the meridian's angle about the
|
||||
/// axis.
|
||||
fn meridians(srf: &BSplineSurface, swap_uv: bool) -> Result<[Vec<Point3>; 2]> {
|
||||
let nurb = srf
|
||||
.gw_nurb()
|
||||
.ok_or_else(|| SolidError::null_pointer(OP_NURB_TORUS))?;
|
||||
let (n, m) = (nurb.n(), nurb.m());
|
||||
let column = |end: bool| -> Result<Vec<Point3>> {
|
||||
if swap_uv {
|
||||
let j = if end { m } else { 0 };
|
||||
(0..=n).map(|i| Ok(nurb.cpt(i, j)?.to_euclid())).collect()
|
||||
} else {
|
||||
let i = if end { n } else { 0 };
|
||||
(0..=m).map(|j| Ok(nurb.cpt(i, j)?.to_euclid())).collect()
|
||||
}
|
||||
};
|
||||
Ok([column(false)?, column(true)?])
|
||||
}
|
||||
|
||||
/// The directions, about the axis, of the fitted first sweep row's ends (`is_arc`'s frame and
|
||||
/// angles): [`sweep_frame`] reads only their sides from them.
|
||||
fn fitted_sides(frame: &Axis2Placement, angles: Extent1d) -> [Vec3; 2] {
|
||||
let at = |deg: f64| {
|
||||
let a = deg * PI / 180.0;
|
||||
a.cos() * frame.x_axis() + a.sin() * frame.y_axis()
|
||||
};
|
||||
[at(angles.min()), at(angles.max())]
|
||||
}
|
||||
|
||||
/// Not upstream (#3199): a recognised torus's x axis, y axis and sweep interval (degrees) from
|
||||
/// its form's first and last meridians (`meridians`: their control points, exact data), about
|
||||
/// the axis through `origin` along the unit `z`. A meridian lies in a plane through the axis,
|
||||
/// so its control points share its angle on the side the tube's centre is on, and the
|
||||
/// opposite angle past the axis (a spindle torus's tube crosses it); `sides` (the fitted
|
||||
/// row's end directions, [`fitted_sides`]) says which side that is, and nothing else. Each
|
||||
/// meridian's angle is read at its control point farthest from the axis on that side: x is
|
||||
/// the first meridian's direction square to the axis (so the interval starts at 0), y `z x
|
||||
/// x`, and the interval's end the last meridian's angle from x about z (`atan2` in that
|
||||
/// frame), a closed sweep (within `SM_EFF_ZERO_SQRT` of 0) at 360, as `is_arc` maps its end.
|
||||
/// `is_arc` fitted the first sweep row's circle through three samples, so on a short arc its
|
||||
/// centre, and with it the x axis and the end angle, was off the axis by its conditioning
|
||||
/// (1e-14 rad on #3185's grooves); the converter centres the sweep circle on the axis, so its
|
||||
/// ends and its inside now agree to rounding. `None` when a meridian has no control point off
|
||||
/// the axis on its side.
|
||||
fn sweep_frame(
|
||||
origin: Point3,
|
||||
z: Vec3,
|
||||
sides: [Vec3; 2],
|
||||
[first, last]: [Vec<Point3>; 2],
|
||||
) -> Option<(Vec3, Vec3, Extent1d)> {
|
||||
let radial = |p: &Point3| {
|
||||
let s = *p - origin;
|
||||
s - s.dot(z) * z
|
||||
};
|
||||
let farthest = |ps: &[Point3], side: Vec3| {
|
||||
ps.iter()
|
||||
.map(radial)
|
||||
.filter(|r| r.dot(side) > 0.0)
|
||||
.max_by(|a, b| a.length().total_cmp(&b.length()))
|
||||
};
|
||||
let [first_side, last_side] = sides;
|
||||
let x = farthest(&first, first_side)?.unitize().ok()?;
|
||||
let y = z.cross(x);
|
||||
let e = farthest(&last, last_side)?;
|
||||
let mut end_deg = e.dot(y).atan2(e.dot(x)) * 180.0 / PI;
|
||||
if end_deg.abs() < EFF_ZERO_SQRT {
|
||||
end_deg = 360.0;
|
||||
} else if end_deg < 0.0 {
|
||||
end_deg += 360.0;
|
||||
}
|
||||
Some((x, y, Extent1d::from_min_max(0.0, end_deg)))
|
||||
}
|
||||
|
||||
fn iso_curve(
|
||||
rev: &SurfOfRevolution,
|
||||
shape: Shape,
|
||||
|
||||
+9
-4
@@ -1623,10 +1623,15 @@ pub(crate) fn ignore_read<T>(r: Result<T>) -> Result<Option<T>> {
|
||||
| SolidError::Cancelled { .. }
|
||||
| SolidError::Unsupported { .. }),
|
||||
) => Err(e),
|
||||
Err(
|
||||
e
|
||||
@ (SolidError::InvalidInput { reason, .. } | SolidError::PortInvariant { reason, .. }),
|
||||
) if reason == NESTING_LIMIT_REASON => Err(e),
|
||||
Err(e)
|
||||
if matches!(
|
||||
&e,
|
||||
SolidError::InvalidInput { reason, .. } | SolidError::PortInvariant { reason, .. }
|
||||
if *reason == NESTING_LIMIT_REASON
|
||||
) =>
|
||||
{
|
||||
Err(e)
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ pub mod directed;
|
||||
pub mod extrude;
|
||||
pub mod rotational_sweep_geometry;
|
||||
pub mod screw;
|
||||
pub mod section_blend;
|
||||
pub mod segments;
|
||||
pub mod topology_sweep;
|
||||
pub mod trans_sweep_geometry;
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
//! A multi-section blend: one B-spline surface through ordered cross-sections that leaves the
|
||||
//! first and the last of them with the continuity their frames ask for.
|
||||
//!
|
||||
//! Upstream: none (not in SMLib). SolidCraft's `loft_blend` (`crates/kernel/src/loft_blend.rs`
|
||||
//! at 52afe21) and the Hermite rows of its `bridge::section`, moved into the kernel so no
|
||||
//! application builds the surface.
|
||||
//!
|
||||
//! | Rust | Upstream |
|
||||
//! |--------------------|----------|
|
||||
//! | [`section_blend`] | (none; SolidCraft's `loft_blend`) |
|
||||
//! | [`SectionFrame`] | (none; SolidCraft's `bridge::Frame`) |
|
||||
//! | [`EndOrder`] | (none; SolidCraft's `Continuity`) |
|
||||
//! | `end_rows` (crate) | (none; the rows of SolidCraft's `bridge::section`) |
|
||||
//! | `third_derivative` (crate) | (none; SolidCraft's `bridge::third_derivative`) |
|
||||
//!
|
||||
//! **The surface.** The sections run along u, each sampled at the same stations along v. Section
|
||||
//! `i` sits at `u = i / (n - 1)` and the surface passes through every station of it: each
|
||||
//! control row is interpolated through its stations along v (degree `min(3, stations - 1)`, the
|
||||
//! kernel's global interpolation at uniform parameters with averaged knots). Along u the knots are
|
||||
//! clamped and repeated `degree` times at every interior section, so every span is one Bezier.
|
||||
//! A span's end rows are the Hermite rows (`end_rows`) of its two sections: position, then
|
||||
//! tangent, curvature and change of curvature as the section holds them, written as the Bezier's
|
||||
//! leading control rows at the span's parameter interval. The u degree is the largest
|
||||
//! `c + c' - 1` over the spans (`c`, `c'` the conditions at its two ends); a span with fewer is
|
||||
//! degree-elevated exactly (by its own row elevation: the rows are vectors of points before any
|
||||
//! surface exists, so the kernel's surface and single-curve elevations do not fit them).
|
||||
//!
|
||||
//! **What each section holds (the definition, SolidCraft's).**
|
||||
//! - The first and the last section hold what their [`EndOrder`] asks (G0 to G3) from the
|
||||
//! caller's frames: the tangent `leave * speed`, the curvature `bend * speed^2` and the change
|
||||
//! of curvature from `bend_change` (`third_derivative`: only its part square to the tangent
|
||||
//! holds the condition, the rest a reparametrisation). The speed is the station's chord to the
|
||||
//! neighbouring section `c`, over 3, times `1 + crown`, over the span's interval `1 / (n - 1)`:
|
||||
//! `d_u = leave * c / 3 * (1 + crown) * (n - 1)` for `n` sections, so the Bezier's first control
|
||||
//! offset is `c (1 + crown) / (3 m)` at span degree `m`. The last section's frames point back
|
||||
//! into the blend, so its odd derivatives turn round.
|
||||
//! - An interior section holds its position and the run's derivatives from finite differences of
|
||||
//! the neighbouring sections' stations: the central first and second differences, and a third
|
||||
//! (central, or one-sided at the second and the last-but-one section) where four sections
|
||||
//! reach. So the joint is C3 in u with four or more sections and C2 with three, at every
|
||||
//! station.
|
||||
//!
|
||||
//! This is SolidCraft's definition of the blend, not an approximation of another surface: the
|
||||
//! finite differences are the conditions the surface is built to, not estimates of a curve the
|
||||
//! sections lie on, and no error against anything is claimed. What is exact: the surface passes
|
||||
//! through every station; at every station of an end it holds the frames' conditions to rounding;
|
||||
//! at every station of an interior section the spans on either side agree in their u derivatives
|
||||
//! up to the order held. Between stations the end and joint curves are the v interpolants of the
|
||||
//! stations' rows (the interpolation is linear and commutes with evaluation in u), not conditions
|
||||
//! of their own.
|
||||
//!
|
||||
//! **Refused**, naming why: fewer than 2 or more than [`MAX_SECTIONS`] sections; sections of
|
||||
//! different station counts, or a count outside [`MIN_STATIONS`] ..= [`MAX_STATIONS`]; a frame
|
||||
//! that is not finite, or a `leave` of no length at an end that uses it; a crown outside
|
||||
//! `-0.9 ..= 3`; two neighbouring sections that meet at a station (no room for a blend there).
|
||||
//!
|
||||
//! **The two floors.** Two tests guard divisions, and both refuse; neither adjusts an input or a
|
||||
//! result to get past them. A tangent's length (an end's `leave`, and the first derivative in
|
||||
//! `third_derivative`) is divided by: a length that is not finite and positive is refused,
|
||||
//! never replaced by a default direction. Two neighbouring sections meeting at a station
|
||||
//! (distance not positive) leave the span there no room: a speed of zero, and no tangent to
|
||||
//! divide by at the next section; the blend is refused, never nudged apart. Both are exact tests,
|
||||
//! not SolidCraft's `1e-12` and `1e-9` floors: a chord of `1e-12` is not refused, its speed is
|
||||
//! about `1e-12` and the end rows collapse onto the section, a valid but degenerate surface.
|
||||
//!
|
||||
//! Deviations: no upstream counterpart. SolidCraft's `1e-12` floor on a tangent's length and its
|
||||
//! `1e-9` floor on the distance between neighbouring sections are exact tests here (a length that
|
||||
//! is not positive): what the floors protect is the division, and an exact test protects it
|
||||
//! without a scale-dependent constant. Its one-sided third difference at the last-but-one section
|
||||
//! has its sign turned (`f(i-2) - 3 f(i-1) + 3 f(i) - f(i+1)`, the negative of the change of
|
||||
//! curvature it estimates); here it is the backward difference, exact on a cubic like the other
|
||||
//! two. Its batched row solves are one solve per row. No
|
||||
//! recursion.
|
||||
|
||||
use crate::error::{Result, SolidError};
|
||||
use crate::math::{Point3, Vec3};
|
||||
use crate::nurbs::b_spline_curve::KnotType;
|
||||
use crate::nurbs::b_spline_surface::{BSplineSurface, BSplineSurfaceForm, CanonicalSurfaceData};
|
||||
use crate::nurbs::crv_fit::p1::{interp_given_params, interp_knot_vector};
|
||||
use crate::nurbs::knots::KnotVector;
|
||||
use crate::nurbs::math::NOW;
|
||||
|
||||
const OP: &str = "sweep::section_blend";
|
||||
|
||||
/// Most cross-sections one blend runs through.
|
||||
pub const MAX_SECTIONS: usize = 50;
|
||||
/// Fewest stations a cross-section is sampled at (a straight line across it).
|
||||
pub const MIN_STATIONS: usize = 2;
|
||||
/// Most stations a cross-section is sampled at.
|
||||
pub const MAX_STATIONS: usize = 257;
|
||||
|
||||
/// One station of a cross-section: its point and, at the first and last section, how the blend
|
||||
/// leaves it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SectionFrame {
|
||||
/// The point.
|
||||
pub p: Point3,
|
||||
/// The direction the blend leaves in, towards the next section (at the last section, back
|
||||
/// into the blend, towards the section before). Its length is ignored.
|
||||
pub leave: Vec3,
|
||||
/// The curvature vector the blend takes along `leave` (zero for a straight start).
|
||||
pub bend: Vec3,
|
||||
/// The arc-length derivative of `bend` along `leave`.
|
||||
pub bend_change: Vec3,
|
||||
}
|
||||
|
||||
/// The continuity a blend holds at its first or last section.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EndOrder {
|
||||
/// Position.
|
||||
G0,
|
||||
/// Position and tangent direction.
|
||||
G1,
|
||||
/// And curvature.
|
||||
G2,
|
||||
/// And change of curvature.
|
||||
G3,
|
||||
}
|
||||
|
||||
impl EndOrder {
|
||||
/// The conditions held: 1 to 4 (position, tangent, curvature, change of curvature).
|
||||
#[must_use]
|
||||
pub const fn conditions(self) -> usize {
|
||||
match self {
|
||||
Self::G0 => 1,
|
||||
Self::G1 => 2,
|
||||
Self::G2 => 3,
|
||||
Self::G3 => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(reason: &'static str) -> SolidError {
|
||||
SolidError::invalid_input(OP, reason)
|
||||
}
|
||||
|
||||
/// The derivatives a section holds along the run at each station: `d[k]` is the `(k + 1)`-th,
|
||||
/// as many as its conditions less one.
|
||||
struct Held {
|
||||
p: Vec<Point3>,
|
||||
d: [Vec<Vec3>; 3],
|
||||
}
|
||||
|
||||
/// The Bezier control rows at one end of a span of degree `m` over the parameter interval
|
||||
/// `delta`, from the end's position `p` and its derivatives `d` (the first `conditions - 1` of
|
||||
/// them used), as the span leaves the end (`backward` false: rows `0, 1, ..`) or arrives at it
|
||||
/// (`backward` true: rows `m, m - 1, ..`, the odd derivatives read the other way): the leading
|
||||
/// rows of a Bezier whose derivatives at that end are `d`. Row `k` comes back at index `k` from
|
||||
/// the end.
|
||||
///
|
||||
/// # Errors
|
||||
/// `InvalidInput` for more conditions than the degree carries or than `d` gives.
|
||||
pub(crate) fn end_rows(
|
||||
p: Point3,
|
||||
d: &[Vec3],
|
||||
conditions: usize,
|
||||
m: usize,
|
||||
delta: f64,
|
||||
backward: bool,
|
||||
) -> Result<Vec<Point3>> {
|
||||
if conditions == 0 || conditions > 4 || conditions > m + 1 || d.len() + 1 < conditions {
|
||||
return Err(invalid(
|
||||
"a span's end carries more conditions than its degree or its data",
|
||||
));
|
||||
}
|
||||
#[allow(clippy::cast_precision_loss)] // a degree, at most 32
|
||||
let k = m as f64;
|
||||
let sign = if backward { -1.0 } else { 1.0 };
|
||||
let one = delta / k;
|
||||
let two = if m >= 2 {
|
||||
delta * delta / (k * (k - 1.0))
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let three = if m >= 3 {
|
||||
delta * delta * delta / (k * (k - 1.0) * (k - 2.0))
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mut rows = vec![p];
|
||||
if conditions >= 2 {
|
||||
let d1 = *d.first().ok_or_else(|| invalid("a missing tangent"))?;
|
||||
let q1 = p + d1 * (sign * one);
|
||||
rows.push(q1);
|
||||
if conditions >= 3 {
|
||||
let d2 = *d.get(1).ok_or_else(|| invalid("a missing curvature"))?;
|
||||
let q2 = d2 * two + q1 * 2.0 - p;
|
||||
rows.push(q2);
|
||||
if conditions >= 4 {
|
||||
let d3 = *d
|
||||
.get(2)
|
||||
.ok_or_else(|| invalid("a missing change of curvature"))?;
|
||||
rows.push(d3 * (sign * three) + q2 * 3.0 - q1 * 3.0 + p);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The third derivative a curve needs at an end so that the arc-length derivative of its
|
||||
/// curvature vector is `target`, from its first two derivatives `d1`, `d2` there: only its part
|
||||
/// square to the tangent holds the condition, the rest is a reparametrisation and is left zero
|
||||
/// (SolidCraft's `bridge::third_derivative`).
|
||||
///
|
||||
/// # Errors
|
||||
/// `InvalidInput` for a tangent of no length or a result that is not finite.
|
||||
pub(crate) fn third_derivative(d1: Vec3, d2: Vec3, target: Vec3) -> Result<Vec3> {
|
||||
let sigma = d1.length();
|
||||
// the floor on the tangent's length, divided by below: refused, never adjusted
|
||||
if !(sigma.is_finite() && sigma > 0.0) {
|
||||
return Err(invalid(
|
||||
"no room for the change of curvature: the tangent has no length",
|
||||
));
|
||||
}
|
||||
let t = d1 * (1.0 / sigma);
|
||||
let tp = d2 * (1.0 / sigma) - d1 * (d1.dot(d2) / (sigma * sigma * sigma));
|
||||
let nn = d2 - t * d2.dot(t);
|
||||
let r = tp * (-d2.dot(t)) - t * d2.dot(tp);
|
||||
let x = (target * sigma.powi(5) - r * (sigma * sigma) + nn * (2.0 * d1.dot(d2)))
|
||||
* (1.0 / (sigma * sigma));
|
||||
if !(x.x.is_finite() && x.y.is_finite() && x.z.is_finite()) {
|
||||
return Err(invalid("the change of curvature is not finite"));
|
||||
}
|
||||
Ok(x - t * x.dot(t))
|
||||
}
|
||||
|
||||
fn finite(v: Vec3) -> bool {
|
||||
v.x.is_finite() && v.y.is_finite() && v.z.is_finite()
|
||||
}
|
||||
|
||||
/// One exact degree elevation of a Bezier net of rows: the same curve in one row more.
|
||||
fn elevate(rows: &[Vec<Point3>]) -> Vec<Vec<Point3>> {
|
||||
let degree = rows.len().saturating_sub(1);
|
||||
#[allow(clippy::cast_precision_loss)] // a degree, at most 32
|
||||
let up = degree as f64 + 1.0;
|
||||
let mut out: Vec<Vec<Point3>> = Vec::new();
|
||||
if let Some(first) = rows.first() {
|
||||
out.push(first.clone());
|
||||
}
|
||||
for i in 1..=degree {
|
||||
if let (Some(before), Some(here)) = (rows.get(i - 1), rows.get(i)) {
|
||||
#[allow(clippy::cast_precision_loss)] // a row index, at most 32
|
||||
let a = i as f64 / up;
|
||||
out.push(
|
||||
before
|
||||
.iter()
|
||||
.zip(here)
|
||||
.map(|(x, y)| *x * a + *y * (1.0 - a))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(last) = rows.last() {
|
||||
out.push(last.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The blend surface through `sections` (each a cross-section of the same stations, in order),
|
||||
/// holding `orders[0]` at the first section and `orders[1]` at the last, `crown` scaling how fast
|
||||
/// it leaves each (`-0.9 ..= 3`, 0 the plain blend). See the module's notes for the definition.
|
||||
///
|
||||
/// # Errors
|
||||
/// `InvalidInput` for the refusals the module names; those of the interpolation and the surface's
|
||||
/// construction.
|
||||
#[allow(clippy::too_many_lines)] // one construction, step by step
|
||||
pub fn section_blend(
|
||||
sections: &[Vec<SectionFrame>],
|
||||
orders: [EndOrder; 2],
|
||||
crown: [f64; 2],
|
||||
) -> Result<BSplineSurface> {
|
||||
let run = sections.len();
|
||||
if !(2..=MAX_SECTIONS).contains(&run) {
|
||||
return Err(invalid("a blend runs through 2 to 50 cross-sections"));
|
||||
}
|
||||
let samples = sections.first().map_or(0, Vec::len);
|
||||
if !(MIN_STATIONS..=MAX_STATIONS).contains(&samples) {
|
||||
return Err(invalid("a blend's cross-sections take 2 to 257 stations"));
|
||||
}
|
||||
if crown
|
||||
.iter()
|
||||
.any(|c| !(c.is_finite() && (-0.9..=3.0).contains(c)))
|
||||
{
|
||||
return Err(invalid("a blend's crown is between -0.9 and 3"));
|
||||
}
|
||||
for section in sections {
|
||||
if section.len() != samples {
|
||||
return Err(invalid(
|
||||
"every cross-section of a blend has the same stations",
|
||||
));
|
||||
}
|
||||
if section
|
||||
.iter()
|
||||
.any(|f| !(finite(f.p) && finite(f.leave) && finite(f.bend) && finite(f.bend_change)))
|
||||
{
|
||||
return Err(invalid("a blend's frame is not finite"));
|
||||
}
|
||||
}
|
||||
let point = |i: usize, s: usize| -> Result<Point3> {
|
||||
sections
|
||||
.get(i)
|
||||
.and_then(|sec| sec.get(s))
|
||||
.map(|f| f.p)
|
||||
.ok_or_else(|| invalid("a cross-section is missing a station"))
|
||||
};
|
||||
let frame = |i: usize, s: usize| -> Result<SectionFrame> {
|
||||
sections
|
||||
.get(i)
|
||||
.and_then(|sec| sec.get(s))
|
||||
.copied()
|
||||
.ok_or_else(|| invalid("a cross-section is missing a station"))
|
||||
};
|
||||
for i in 0..run - 1 {
|
||||
for s in 0..samples {
|
||||
// the floor on coincident sections (a zero speed, no tangent to divide by next):
|
||||
// refused, never nudged apart
|
||||
if point(i, s)?.distance(point(i + 1, s)?) <= 0.0 {
|
||||
return Err(invalid(
|
||||
"two neighbouring cross-sections meet at a station: there is no room for a blend",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// the conditions at each section: the ends' orders; an interior one four where a fourth
|
||||
// section reaches for the third difference, else three
|
||||
let mut conditions = vec![3usize; run];
|
||||
if let Some(c) = conditions.first_mut() {
|
||||
*c = orders[0].conditions();
|
||||
}
|
||||
if let Some(c) = conditions.last_mut() {
|
||||
*c = orders[1].conditions();
|
||||
}
|
||||
for (i, c) in conditions.iter_mut().enumerate().take(run - 1).skip(1) {
|
||||
if i >= 2 || i + 2 < run {
|
||||
*c = 4;
|
||||
}
|
||||
}
|
||||
let degree = conditions
|
||||
.windows(2)
|
||||
.map(|w| match w {
|
||||
[a, b] => a + b - 1,
|
||||
_ => 0,
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
#[allow(clippy::cast_precision_loss)] // at most 50 sections
|
||||
let step = 1.0 / (run - 1) as f64;
|
||||
|
||||
// what each section holds along the run
|
||||
let mut held: Vec<Held> = Vec::new();
|
||||
for (i, &c) in conditions.iter().enumerate() {
|
||||
let p: Vec<Point3> = (0..samples).map(|s| point(i, s)).collect::<Result<_>>()?;
|
||||
let mut d: [Vec<Vec3>; 3] = [Vec::new(), Vec::new(), Vec::new()];
|
||||
let first = i == 0;
|
||||
let last = i + 1 == run;
|
||||
for s in 0..samples {
|
||||
let (d1, d2, d3) = if first || last {
|
||||
let f = frame(i, s)?;
|
||||
let (other, crown_here, sign) = if first {
|
||||
(point(1, s)?, crown[0], 1.0)
|
||||
} else {
|
||||
(point(i - 1, s)?, crown[1], -1.0)
|
||||
};
|
||||
let length = f.leave.length();
|
||||
// the floor on the tangent's length, divided by below: refused, never defaulted
|
||||
if c >= 2 && !(length.is_finite() && length > 0.0) {
|
||||
return Err(invalid("a blend's leave direction has no length"));
|
||||
}
|
||||
let leave = if length > 0.0 {
|
||||
f.leave * (1.0 / length)
|
||||
} else {
|
||||
f.leave
|
||||
};
|
||||
// a third of the station's chord to its neighbour, the crown, over the interval
|
||||
let speed = f.p.distance(other) / 3.0 * (1.0 + crown_here) / step;
|
||||
let d1 = leave * (sign * speed);
|
||||
let d2 = f.bend * (speed * speed);
|
||||
let d3 = if c >= 4 {
|
||||
// the last section's frames point back into the blend: read it backwards
|
||||
if first {
|
||||
third_derivative(d1, d2, f.bend_change)?
|
||||
} else {
|
||||
third_derivative(-d1, d2, f.bend_change)? * -1.0
|
||||
}
|
||||
} else {
|
||||
Vec3::ZERO
|
||||
};
|
||||
(d1, d2, d3)
|
||||
} else {
|
||||
let before = point(i - 1, s)?;
|
||||
let here = point(i, s)?;
|
||||
let after = point(i + 1, s)?;
|
||||
let d1 = (after - before) * (1.0 / (2.0 * step));
|
||||
let d2 = (after - here * 2.0 + before) * (1.0 / (step * step));
|
||||
let d3 = if c >= 4 {
|
||||
let cube = step * step * step;
|
||||
match (i.checked_sub(2), i + 2 < run) {
|
||||
(Some(k), true) => {
|
||||
(point(i + 2, s)? - point(i + 1, s)? * 2.0 + point(i - 1, s)? * 2.0
|
||||
- point(k, s)?)
|
||||
* (1.0 / (2.0 * cube))
|
||||
}
|
||||
(None, true) => {
|
||||
(point(i + 2, s)? - point(i + 1, s)? * 3.0 + here * 3.0 - before)
|
||||
* (1.0 / cube)
|
||||
}
|
||||
// (the backward stencil f(i+1) - 3 f(i) + 3 f(i-1) - f(i-2): SolidCraft's
|
||||
// has its sign turned, see Deviations)
|
||||
(Some(k), false) => {
|
||||
(after - here * 3.0 + before * 3.0 - point(k, s)?) * (1.0 / cube)
|
||||
}
|
||||
(None, false) => {
|
||||
return Err(invalid(
|
||||
"no fourth section for an interior change of curvature",
|
||||
))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec3::ZERO
|
||||
};
|
||||
(d1, d2, d3)
|
||||
};
|
||||
if !(finite(d1) && finite(d2) && finite(d3)) {
|
||||
return Err(invalid(
|
||||
"a section's derivatives along the run are not finite",
|
||||
));
|
||||
}
|
||||
let [a, b, e] = &mut d;
|
||||
a.push(d1);
|
||||
b.push(d2);
|
||||
e.push(d3);
|
||||
}
|
||||
held.push(Held { p, d });
|
||||
}
|
||||
|
||||
// the spans' control rows, each elevated to the run's degree; a span's first row is the one
|
||||
// before's last
|
||||
let mut rows: Vec<Vec<Point3>> = Vec::new();
|
||||
for j in 0..run - 1 {
|
||||
let (Some(left), Some(right)) = (held.get(j), held.get(j + 1)) else {
|
||||
return Err(invalid("a missing section"));
|
||||
};
|
||||
let (Some(&kl), Some(&kr)) = (conditions.get(j), conditions.get(j + 1)) else {
|
||||
return Err(invalid("a missing section"));
|
||||
};
|
||||
let m = kl + kr - 1;
|
||||
let mut span = vec![vec![Point3::ZERO; samples]; m + 1];
|
||||
for s in 0..samples {
|
||||
let take = |h: &Held| -> Result<(Point3, Vec<Vec3>)> {
|
||||
let p = *h.p.get(s).ok_or_else(|| invalid("a missing station"))?;
|
||||
let d =
|
||||
h.d.iter()
|
||||
.map(|v| {
|
||||
v.get(s)
|
||||
.copied()
|
||||
.ok_or_else(|| invalid("a missing station"))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok((p, d))
|
||||
};
|
||||
let (pl, dl) = take(left)?;
|
||||
let (pr, dr) = take(right)?;
|
||||
for (k, q) in end_rows(pl, &dl, kl, m, step, false)?
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if let Some(slot) = span.get_mut(k).and_then(|r| r.get_mut(s)) {
|
||||
*slot = q;
|
||||
}
|
||||
}
|
||||
for (k, q) in end_rows(pr, &dr, kr, m, step, true)?
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if let Some(slot) = span.get_mut(m - k).and_then(|r| r.get_mut(s)) {
|
||||
*slot = q;
|
||||
}
|
||||
}
|
||||
}
|
||||
while span.len() < degree + 1 {
|
||||
span = elevate(&span);
|
||||
}
|
||||
rows.extend(span.into_iter().skip(usize::from(j > 0)));
|
||||
}
|
||||
|
||||
// each row interpolated through its stations along v
|
||||
#[allow(clippy::cast_precision_loss)] // at most 257 stations
|
||||
let params: Vec<f64> = (0..samples)
|
||||
.map(|s| s as f64 / (samples - 1) as f64)
|
||||
.collect();
|
||||
let degree_v = 3.min(samples - 1);
|
||||
let knots_v = interp_knot_vector(¶ms, degree_v)?;
|
||||
let kv = KnotVector::from_slice(&knots_v)?;
|
||||
let count_u = rows.len();
|
||||
let mut net = vec![Point3::ZERO; count_u * samples];
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let curve = interp_given_params(row, ¶ms, &kv, degree_v)?;
|
||||
for (s, c) in curve.cpts().iter().take(samples).enumerate() {
|
||||
// (exact tests: NOW is the stored "no weight" mark of a polynomial row, assigned,
|
||||
// never computed, and 1.0 an assigned unit weight; any other weight is a weighted
|
||||
// point)
|
||||
let q = if c.w == NOW || c.w == 1.0 {
|
||||
Point3::new(c.x, c.y, c.z)
|
||||
} else {
|
||||
Point3::new(c.x / c.w, c.y / c.w, c.z / c.w)
|
||||
};
|
||||
if let Some(slot) = net.get_mut(i * samples + s) {
|
||||
*slot = q;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the run's knots: clamped, every interior section `degree` times
|
||||
let mut u_knots = vec![0.0];
|
||||
let mut u_mults = vec![degree + 1];
|
||||
for i in 1..run - 1 {
|
||||
#[allow(clippy::cast_precision_loss)] // at most 50 sections
|
||||
u_knots.push(i as f64 * step);
|
||||
u_mults.push(degree);
|
||||
}
|
||||
u_knots.push(1.0);
|
||||
u_mults.push(degree + 1);
|
||||
let (v_knots, v_mults) = unique(&knots_v);
|
||||
BSplineSurface::create_canonical(&CanonicalSurfaceData {
|
||||
u_degree: degree,
|
||||
v_degree: degree_v,
|
||||
control_points: &net,
|
||||
form: BSplineSurfaceForm::Unspecified,
|
||||
u_multiplicities: &u_mults,
|
||||
v_multiplicities: &v_mults,
|
||||
u_knots: &u_knots,
|
||||
v_knots: &v_knots,
|
||||
knot_type: KnotType::Unspecified,
|
||||
weights: None,
|
||||
uv_domain: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A knot vector as its distinct knots and their multiplicities.
|
||||
fn unique(knots: &[f64]) -> (Vec<f64>, Vec<usize>) {
|
||||
let mut values: Vec<f64> = Vec::new();
|
||||
let mut mults: Vec<usize> = Vec::new();
|
||||
for &u in knots {
|
||||
match (values.last(), mults.last_mut()) {
|
||||
(Some(&last), Some(m)) if last == u => *m += 1,
|
||||
_ => {
|
||||
values.push(u);
|
||||
mults.push(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
(values, mults)
|
||||
}
|
||||
+18
-12
@@ -64,28 +64,33 @@ pub use p8::{
|
||||
};
|
||||
pub use p9::{make_vertex_loop, orient_sheet_surfaces};
|
||||
|
||||
/// The relative accuracy [`is_outward_solid`] measures the volume's sign at: the sign of a
|
||||
/// solid's volume is its whole volume away from zero.
|
||||
const SIGN_ACCURACY: f64 = 1e-3;
|
||||
|
||||
/// Whether `b` is a valid closed solid facing out (not upstream): one closed manifold solid
|
||||
/// ([`is_manifold_solid`]), passed by the kernel's validity check (its pointer check and
|
||||
/// `AssertValid`, [`validation::check_brep`] at `Level0`), and its integrated volume positive.
|
||||
/// `AssertValid`, [`validation::check_brep`] at `Level0`), and its volume proved positive by
|
||||
/// its certified enclosure ([`crate::mass::enclosure::volume_sign`]; an undecided sign is
|
||||
/// not).
|
||||
///
|
||||
/// The two guard the faceuse orientation together. A body with every face flipped
|
||||
/// (`FlipFaceOrientation`, which leaves a face invalid) is still manifold and keeps a
|
||||
/// positive volume (the integrator reads the loops and surfaces, not the uses' orientation):
|
||||
/// the pointer check is what refuses it. A valid body turned inside out (its loops and
|
||||
/// surfaces reversed with its uses) has a negative volume. The sign's margin is the whole
|
||||
/// volume, so it is measured at a relative accuracy of `1e-3`.
|
||||
/// surfaces reversed with its uses) has a negative volume.
|
||||
///
|
||||
/// A sign the enclosure leaves undecided (its interval still holds 0 when refinement ends:
|
||||
/// a gap between faces as large as the volume, or the cell budget spent) is "not a solid" on
|
||||
/// purpose: a solid is what is proved one, so ops' `finish` refuses the body as `NotSolid`.
|
||||
/// A body the enclosure cannot read at all is an error, not `false`: a face with no NURBS form
|
||||
/// (an offset surface, as the offset of a round keeps; a curve-bounded or in-volume surface),
|
||||
/// a face without trim loops, or weights not proved positive.
|
||||
///
|
||||
/// A region the kernel is unsure of is [`crate::SolidError::UnsureRegion`], not "not a solid":
|
||||
/// the volume's sign would read it as void.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Those of the manifold test, the validity check and the volume;
|
||||
/// [`crate::SolidError::UnsureRegion`].
|
||||
/// Those of the manifold test, the validity check and the volume
|
||||
/// ([`crate::mass::enclosure::volume_sign`]: [`crate::SolidError::Unsupported`] for a body
|
||||
/// it cannot read); [`crate::SolidError::UnsureRegion`].
|
||||
pub fn is_outward_solid(
|
||||
k: &mut crate::kernel::Kernel,
|
||||
b: &mut crate::topology::Brep,
|
||||
@@ -123,9 +128,10 @@ fn is_valid_and_outward(
|
||||
return Ok(false);
|
||||
}
|
||||
crate::mass::integrator::properties::no_unsure_region(b)?;
|
||||
let volume = crate::mass::integrator::properties::brep_volume_default(k, b, SIGN_ACCURACY)?;
|
||||
// (false for a NaN too)
|
||||
Ok(volume > 0.0)
|
||||
Ok(matches!(
|
||||
crate::mass::enclosure::volume_sign(k, b)?,
|
||||
crate::mass::enclosure::VolumeSign::Positive(_)
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -34,6 +34,7 @@ use crate::error::{EntityRef, Failure, Result, SolidError};
|
||||
use crate::geom::attribute::ObjectKey;
|
||||
use crate::geom::axis2_placement::Axis2Placement;
|
||||
use crate::kernel::Kernel;
|
||||
use crate::mass::enclosure::VolumeSign;
|
||||
use crate::math::Vec3;
|
||||
use crate::nurbs::b_spline_surface::p1::SurfParamType;
|
||||
use crate::nurbs::knots::Side;
|
||||
@@ -311,8 +312,12 @@ fn local_operation_on(
|
||||
/// - the body's pointer check failed, so its geometry was not checked (`AssertValid` is
|
||||
/// skipped then), even where the input's pointer check failed alike;
|
||||
/// - the input was a manifold solid and the body is not;
|
||||
/// - the body is a manifold solid whose volume is not positive (the input's volume is
|
||||
/// not measured).
|
||||
/// - the body is a manifold solid whose volume is not proved positive: its certified
|
||||
/// enclosure lies below 0, or still holds 0 after its budget
|
||||
/// ([`validation::volume_sign`]; the input's volume is not measured). A face whose net
|
||||
/// is certified within ε > 0 of its definition (a Gⁿ or law round, a sweep, a Mixed
|
||||
/// corner) is read on its net, so the gate decides on the body as stored; widening the
|
||||
/// enclosure by ε needs bounds on the definition's `S_u` and `S_v` (#3170).
|
||||
///
|
||||
/// The owners the check tests the geometry against are the identities the body's naming
|
||||
/// scheme names its entities by ([`validation::standing`]), as `move_faces` sets them.
|
||||
@@ -357,10 +362,12 @@ fn refuse_unsound(k: &mut Kernel, b: &Brep, input: &validation::Standing) -> Res
|
||||
Unsoundness::GeometryUnchecked
|
||||
} else if let Some(lost) = validation::lost_manifold(found.manifold, || Ok(input.manifold))? {
|
||||
lost
|
||||
} else if found.manifold
|
||||
&& validation::volume(k, &mut copy)?.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater)
|
||||
{
|
||||
Unsoundness::VolumeNotPositive
|
||||
} else if found.manifold {
|
||||
match validation::volume_sign(k, &mut copy)? {
|
||||
VolumeSign::Positive(_) => return Ok(()),
|
||||
VolumeSign::Negative(_) => Unsoundness::VolumeNotPositive,
|
||||
VolumeSign::Undecided(_) => Unsoundness::VolumeUndecided,
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -2085,6 +2092,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A body whose volume's sign is not proved is refused as such: the quarter shell R 10,
|
||||
/// t 1e-6 passes the check, but its faces' boundaries lie apart by about its thickness,
|
||||
/// so the strips between them hold 0 in its enclosure whatever its cells become.
|
||||
#[test]
|
||||
fn the_gate_refuses_a_body_whose_sign_it_cannot_prove() {
|
||||
let mut k = Kernel::default();
|
||||
let b = crate::primitive::api_primitives::create_cylindrical_box(
|
||||
&mut k,
|
||||
Point3::ZERO,
|
||||
10.0,
|
||||
10.0,
|
||||
10.0 + 1e-6,
|
||||
0.0,
|
||||
90.0,
|
||||
)
|
||||
.unwrap();
|
||||
let input =
|
||||
validation::standing(&mut k, &mut b.clone_standing_for_self().unwrap()).unwrap();
|
||||
let e = refuse_unsound(&mut k, &b, &input).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
e.failure().map(|f| &f.reason),
|
||||
Some(crate::error::Reason::Offset(OffsetFailure::Unsound {
|
||||
what: crate::error::offset::Unsoundness::VolumeUndecided
|
||||
}))
|
||||
),
|
||||
"{e}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A body whose pointer check fails is not checked further (`AssertValid` is skipped):
|
||||
/// the gate refuses it even where its input failed the pointer check alike, rather
|
||||
/// than finding nothing beyond the input's failures.
|
||||
|
||||
+56
-1
@@ -95,7 +95,7 @@ use crate::geom::extent1d::Extent1d;
|
||||
use crate::geom::extent2d::Extent2d;
|
||||
use crate::geom::object::{NotifyOperation, ObjectRef};
|
||||
use crate::heal::stitch::p1::{DefaultStitchCallback, Stitch, StitchCallback};
|
||||
use crate::heal::stitch::p2::MinGapRequest;
|
||||
use crate::heal::stitch::p2::{ClosedGap, MinGapRequest};
|
||||
use crate::kernel::Kernel;
|
||||
use crate::math::{Point2, Vec3, EFF_ZERO};
|
||||
use crate::nurbs::b_spline_curve_n_lib::TrimMode;
|
||||
@@ -1900,6 +1900,61 @@ pub(crate) fn stitch_faces_with_callback<'a, C: StitchCallback>(
|
||||
)
|
||||
}
|
||||
|
||||
/// [`stitch_faces_with_callback`] that additionally returns each gap the stitch
|
||||
/// closed, with the tolerance growth its closure applied (not upstream,
|
||||
/// solidcore#2975). The caller supplies the rollback boundary; certificate errors
|
||||
/// propagate regardless of the legacy edit-status flags.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Those of [`stitch_faces`] and geometry enclosure failures (unsupported curve
|
||||
/// kinds, invalid data or non-finite bounds).
|
||||
pub(crate) fn stitch_faces_with_gap_report(
|
||||
k: &mut Kernel,
|
||||
b: &mut Brep,
|
||||
xsect_tol_3d: f64,
|
||||
faces: Option<&[Option<FaceId>]>,
|
||||
allow_spine_edges: bool,
|
||||
identity: Option<crate::query::brep::BrepIdentity<'_>>,
|
||||
) -> Result<(StitchOutcome, Vec<ClosedGap>)> {
|
||||
// lock brep bounding box, until goes out of scope
|
||||
let mut lockit = LockBrepBox::new(k, b)?;
|
||||
let b = lockit.brep();
|
||||
|
||||
// construct Stitch manager with the default keep/delete logic for glue pairs,
|
||||
// recording each closure
|
||||
let mut callback = DefaultStitchCallback::new();
|
||||
let mut stitch = Stitch::new(&mut callback, xsect_tol_3d, true, true);
|
||||
stitch.query_identity = identity;
|
||||
stitch.fast_edge_compare = true;
|
||||
stitch.ignore_problems = true;
|
||||
stitch.squeeze_small_edges = true;
|
||||
stitch.making_manifold_solid = !allow_spine_edges;
|
||||
stitch.remove_laminar_slivers = true;
|
||||
stitch.report_gaps = Some(Vec::new());
|
||||
|
||||
let mut lists = None;
|
||||
if let Some(faces) = faces {
|
||||
let mut vertices = Vec::new();
|
||||
let mut edges = Vec::new();
|
||||
for f in faces.iter().flatten() {
|
||||
vertices.extend(face_vertices(b, *f)?);
|
||||
edges.extend(face_edges(b, *f)?);
|
||||
}
|
||||
lists = Some((vertices, edges));
|
||||
}
|
||||
// pass the stitch call along (SER: its status is returned as is)
|
||||
let outcome = do_stitching(
|
||||
k,
|
||||
b,
|
||||
&mut stitch,
|
||||
lists.as_ref().map(|(v, _)| v.as_slice()),
|
||||
lists.as_ref().map(|(_, e)| e.as_slice()),
|
||||
)?;
|
||||
let gaps = stitch.take_gaps();
|
||||
Ok((outcome, gaps))
|
||||
}
|
||||
|
||||
// ---- ShrinkGeometry ------------------------------------------------------------------------
|
||||
|
||||
/// `SmBrep::ShrinkGeometry`: shrinks each face's UV domain to its trim curves (snapped
|
||||
|
||||
@@ -78,6 +78,7 @@ use crate::geom::extent1d::Extent1d;
|
||||
use crate::geom::extent3d::Extent3d;
|
||||
use crate::geom::object::{NotifyOperation, ObjectRef};
|
||||
use crate::geom::tol::ZONE_TOL_3D;
|
||||
use crate::heal::stitch::p2::ClosedGap;
|
||||
use crate::kernel::Kernel;
|
||||
use crate::math::{max2, Point3, Vec3};
|
||||
use crate::nurbs::b_spline_curve::BSplineCurve;
|
||||
@@ -2662,6 +2663,103 @@ fn stitch_faces(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`stitch_faces`] that also returns what the stitch closed: each gap, the
|
||||
/// entities, and the tolerance growth its closure applied (`solidcore#2975`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Those of [`stitch_faces`].
|
||||
fn stitch_faces_reported(
|
||||
k: &mut Kernel,
|
||||
b: &mut Brep,
|
||||
tolerance: f64,
|
||||
allow_spine: bool,
|
||||
lamina: &mut usize,
|
||||
gaps: &mut Vec<ClosedGap>,
|
||||
) -> Result<()> {
|
||||
let (out, pass_gaps) = crate::topology::brep::p4::stitch_faces_with_gap_report(
|
||||
k,
|
||||
b,
|
||||
tolerance,
|
||||
None,
|
||||
allow_spine,
|
||||
None,
|
||||
)?;
|
||||
gaps.extend(pass_gaps);
|
||||
*lamina = usize::try_from(out.lamina_edges)
|
||||
.map_err(|_| SolidError::invalid_input(OP, "lamina edge count"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`stitch_and_orient`] that additionally reports what the stitch closed
|
||||
/// (`solidcore#2975`): for every gap it closed, the entities it glued, the gap it
|
||||
/// enclosed, and the tolerance growth its closure applied. Geometry bounds are
|
||||
/// independent of the stitcher's sampled acceptance test and do not feed back
|
||||
/// into the construction. A caller decides whether the returned bounds suffice.
|
||||
/// On any error the B-rep is restored, including when certification fails after
|
||||
/// earlier closures. Errors are never suppressed by legacy status flags.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// As [`stitch_and_orient`], plus [`SolidError::Unsupported`] for curve kinds
|
||||
/// without a proved enclosure and [`SolidError::InvalidInput`] for invalid data
|
||||
/// or a non-finite enclosure. No partial report or partial edit is returned.
|
||||
pub fn stitch_and_orient_reported(
|
||||
k: &mut Kernel,
|
||||
b: &mut Brep,
|
||||
options: &StitchAndOrientOptions,
|
||||
) -> Result<Vec<ClosedGap>> {
|
||||
k.check_cancelled(OP)?;
|
||||
b.with_rollback(
|
||||
|b| stitch_and_orient_reported_inner(k, b, options),
|
||||
Result::is_ok,
|
||||
)
|
||||
}
|
||||
|
||||
fn stitch_and_orient_reported_inner(
|
||||
k: &mut Kernel,
|
||||
b: &mut Brep,
|
||||
options: &StitchAndOrientOptions,
|
||||
) -> Result<Vec<ClosedGap>> {
|
||||
let mut lamina = 0;
|
||||
let mut gaps = Vec::new();
|
||||
let mut tolerance = initial_stitch_tolerance(b.tolerance(), options.stitch_tolerance);
|
||||
stitch_faces_reported(
|
||||
k,
|
||||
b,
|
||||
tolerance,
|
||||
options.allow_spine_edges,
|
||||
&mut lamina,
|
||||
&mut gaps,
|
||||
)?;
|
||||
if lamina > 1 {
|
||||
tolerance = if options.stitch_tolerance <= 0.0 {
|
||||
tolerance * 20.0
|
||||
} else {
|
||||
tolerance * 10.0
|
||||
};
|
||||
stitch_faces_reported(
|
||||
k,
|
||||
b,
|
||||
tolerance,
|
||||
options.allow_spine_edges,
|
||||
&mut lamina,
|
||||
&mut gaps,
|
||||
)?;
|
||||
}
|
||||
plain_edit(k, b, |k, b, env| {
|
||||
orient_trimmed_surfaces(
|
||||
k,
|
||||
b,
|
||||
Some(env),
|
||||
true,
|
||||
options.just_unify.unwrap_or(lamina > 0),
|
||||
options.faces_to_swap.as_deref(),
|
||||
)
|
||||
})?;
|
||||
Ok(gaps)
|
||||
}
|
||||
|
||||
/// `SmBrep::OrientTrimmedSurfaces(bNormalsOutward, bJustUnifyNormals, pFacesToSwapUV)`
|
||||
/// ([`crate::topology::brep::p3::orient_trimmed_surfaces`]), notifying as
|
||||
/// [`with_p3_notifications`].
|
||||
|
||||
@@ -34,7 +34,7 @@ pub use coincidence::{contains_coincident_topology, CoincidenceResult};
|
||||
pub(crate) use pointers::validate_geometry;
|
||||
pub use pointers::validate_pointers;
|
||||
pub(crate) use soundness::lost_manifold;
|
||||
pub use soundness::{soundness, standing, volume, Soundness, Standing};
|
||||
pub use soundness::{soundness, standing, volume_sign, Soundness, Standing};
|
||||
|
||||
use crate::error::{EntityRef, Result, SolidError};
|
||||
use crate::geom::assert_array::{AssertArray, AssertReport, ObjectKey, ReportSpec};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! (not upstream) A body's soundness: its check at `SM_LEVEL_2`, and for a closed body,
|
||||
//! whether it is a manifold solid of positive volume. The local operation measures its
|
||||
//! input's [`standing`] (the check with the body's own owner naming, and the manifold
|
||||
//! test) and its result's, and the result's [`volume`] only to test it is positive
|
||||
//! test) and its result's, and the result's [`volume_sign`] only to test it is positive
|
||||
//! ([`crate::topology::brep::local_operation::local_operation`]); the goldens soundness
|
||||
//! probe (`goldens::soundness`) measures result bodies with [`soundness`] (the geometry's
|
||||
//! recorded owners).
|
||||
@@ -15,10 +15,7 @@ use crate::kernel::Kernel;
|
||||
use crate::topology::Brep;
|
||||
|
||||
use super::assert_valid::{check_brep_with_keys, BrepCheck, CheckLevel};
|
||||
|
||||
/// The relative accuracy the volume is integrated at: its sign is a verdict on the body,
|
||||
/// which a closed solid's whole volume holds away from zero.
|
||||
const VOLUME_ACCURACY: f64 = 1e-6;
|
||||
use crate::mass::enclosure::VolumeSign;
|
||||
|
||||
/// What [`soundness`] measured of one body.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -28,16 +25,17 @@ pub struct Soundness {
|
||||
pub check: BrepCheck,
|
||||
/// Whether the body is a manifold solid ([`crate::topology::brep::is_manifold_solid`]).
|
||||
pub manifold: bool,
|
||||
/// Its volume, when it is a manifold solid.
|
||||
pub volume: Option<f64>,
|
||||
/// Its volume's sign, when it is a manifold solid ([`volume_sign`]).
|
||||
pub volume: Option<VolumeSign>,
|
||||
}
|
||||
|
||||
impl Soundness {
|
||||
/// Whether the check is clean and, when the body is a manifold solid, its volume is
|
||||
/// positive (a NaN volume is not).
|
||||
/// proved positive (an undecided sign is not).
|
||||
#[must_use]
|
||||
pub fn sound(&self) -> bool {
|
||||
self.check.is_valid() && (!self.manifold || self.volume.is_some_and(|v| v > 0.0))
|
||||
self.check.is_valid()
|
||||
&& (!self.manifold || matches!(self.volume, Some(VolumeSign::Positive(_))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +97,11 @@ pub fn soundness(k: &mut Kernel, b: &mut Brep) -> Result<Soundness> {
|
||||
};
|
||||
let check = check_brep_with_keys(k, b, CheckLevel::Level2, &keys)?;
|
||||
let manifold = crate::topology::brep::p2::is_manifold_solid(b)?;
|
||||
let volume = if manifold { Some(volume(k, b)?) } else { None };
|
||||
let volume = if manifold {
|
||||
Some(volume_sign(k, b)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Soundness {
|
||||
check,
|
||||
manifold,
|
||||
@@ -111,7 +113,7 @@ pub fn soundness(k: &mut Kernel, b: &mut Brep) -> Result<Soundness> {
|
||||
/// geometry's owner the identity `b`'s naming scheme names the entity by
|
||||
/// ([`Brep::owner_identity`], as the operations that bind geometry set it), so a curve or
|
||||
/// surface owned under another entity's identity fails the owner tests; then the manifold
|
||||
/// test. Its volume is not measured ([`volume`]).
|
||||
/// test. Its volume is not measured ([`volume_sign`]).
|
||||
///
|
||||
/// As for [`soundness`], the check edits what it measures: measure a private copy.
|
||||
///
|
||||
@@ -151,11 +153,15 @@ pub(crate) fn lost_manifold(
|
||||
Ok(Some(crate::error::offset::Unsoundness::NotManifoldSolid))
|
||||
}
|
||||
|
||||
/// The volume of the manifold solid `b`, integrated at [`VOLUME_ACCURACY`].
|
||||
/// The sign of the manifold solid `b`'s volume, from its certified enclosure
|
||||
/// ([`crate::mass::enclosure::volume_sign`]): positive or negative where the enclosure
|
||||
/// proves it, undecided where it still holds 0 after its budget. A face whose net is
|
||||
/// certified within ε > 0 of its definition is read on its net: the sign is the stored
|
||||
/// body's (#3170 widens it by ε).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Those of [`crate::mass::integrator::properties::brep_volume_default`].
|
||||
pub fn volume(k: &mut Kernel, b: &mut Brep) -> Result<f64> {
|
||||
crate::mass::integrator::properties::brep_volume_default(k, b, VOLUME_ACCURACY)
|
||||
/// Those of [`crate::mass::enclosure::volume_sign`].
|
||||
pub fn volume_sign(k: &Kernel, b: &mut Brep) -> Result<VolumeSign> {
|
||||
crate::mass::enclosure::volume_sign(k, b)
|
||||
}
|
||||
|
||||
+45
-12
@@ -107,6 +107,10 @@
|
||||
//! `Kernel::fixes.imprint_extends_edge_to_proto_vertex`, [`ProtoTopologyManager::imprint_edge_definition`]
|
||||
//! first fits each end of the definition's interval to its proto vertex (`imprint_ends`: the SMLib
|
||||
//! fork's thread-groove fixes, tangency-labs/smlib b43876f, a8caccd).
|
||||
//! - **Not upstream: one trim interval** (WR-767, tangency-labs/solidcore#3214).
|
||||
//! [`ProtoTopologyManager::create_proto_edge`] trims the 3D curve and its uv curves to the
|
||||
//! one interval none of their trims snaps (`exact_pieces::common_trim_interval`); upstream
|
||||
//! trims them in turn with one interval, and the edge can end past its 3D curve.
|
||||
//! - `UpdateEdges` trims a definition at the split point; its alternative of splitting
|
||||
//! the proto edge (`SmProtoEdge::Split`) sits behind `static constexpr SmBoolean
|
||||
//! cbiDoSplit = FALSE` and is not ported.
|
||||
@@ -140,6 +144,7 @@ use crate::math::{max2, max3, max4, min2, Point2, Point3, Vec3, EFF_ZERO};
|
||||
use crate::nurbs::b_spline_curve_n_lib::TrimMode;
|
||||
use crate::nurbs::b_spline_surface_n_lib::p2::OrientType;
|
||||
use crate::query::face::p2::proto_vertex_containment_radius;
|
||||
use crate::surface::surface::exact_pieces::{common_trim_interval, trim_unsnapped};
|
||||
use crate::topology::brep::p5::{make_edge_in_face_in, EdgeInFace};
|
||||
use crate::topology::VertexId;
|
||||
|
||||
@@ -2211,26 +2216,54 @@ impl<O: ClassObject> ProtoTopologyManager<O> {
|
||||
let p3d_curve = cc1.curve();
|
||||
let _ = cc2.curve() == p3d_curve;
|
||||
|
||||
// Create a copy of the input curve and trim it to this CurveInterval.
|
||||
// Create a copy of the input curve and of its uv curves (copy statuses ignored), and
|
||||
// trim them to this CurveInterval.
|
||||
let mut copy = curve_of(brep, p3d_curve)?.copy(k)?;
|
||||
let mut domain = ci1.interval;
|
||||
copy.trim(k, &mut domain, TrimMode::Notify)?;
|
||||
|
||||
// See about uv curves (copy statuses ignored; the trims' passed on with
|
||||
// `ssi_proto_topology_statuses_pass_on`, fork bcc6f01).
|
||||
let pass = k.fixes.ssi_proto_topology_statuses_pass_on;
|
||||
let mut uv1 = None;
|
||||
if let Some(c) = cc1.uv_curve1() {
|
||||
uv1 = ignore_soft(curve_of(brep, Some(c))?.copy(k))?;
|
||||
if let Some(u) = uv1.as_mut() {
|
||||
ignore_soft(passed_on(u.trim(k, &mut domain, TrimMode::Notify), pass)?)?;
|
||||
}
|
||||
}
|
||||
let mut uv2 = None;
|
||||
if let Some(c) = cc2.uv_curve1() {
|
||||
uv2 = ignore_soft(curve_of(brep, Some(c))?.copy(k))?;
|
||||
if let Some(u) = uv2.as_mut() {
|
||||
ignore_soft(passed_on(u.trim(k, &mut domain, TrimMode::Notify), pass)?)?;
|
||||
}
|
||||
|
||||
// The uv curves' trim statuses are ignored, and passed on with
|
||||
// `ssi_proto_topology_statuses_pass_on` (fork bcc6f01). Not upstream (WR-767): the
|
||||
// 3D curve and the uv curves whose trims accept the interval are trimmed to the one
|
||||
// interval none of their trims snaps ([`common_trim_interval`]). Upstream trims them
|
||||
// in turn with one interval, each trim snapping it onto a knot of its own curve, and
|
||||
// the edge takes the interval the last trim left: a uv curve's knot a few ulps from
|
||||
// the 3D curve's trim parameter leaves the edge's interval outside its 3D curve's
|
||||
// (`SmEdge` test 2). A uv curve whose trim refuses the interval is trimmed alone, as
|
||||
// upstream.
|
||||
let pass = k.fixes.ssi_proto_topology_statuses_pass_on;
|
||||
let mut joins = [false; 2];
|
||||
for (u, join) in [&uv1, &uv2].into_iter().zip(&mut joins) {
|
||||
if let Some(u) = u {
|
||||
let mut probe = ci1.interval;
|
||||
let trimmed = u.copy(k)?.trim(k, &mut probe, TrimMode::IntervalOnly);
|
||||
*join = ignore_soft(passed_on(trimmed, pass)?)?.is_some();
|
||||
}
|
||||
}
|
||||
let joined: Vec<&Curve> = std::iter::once(©)
|
||||
.chain(
|
||||
[&uv1, &uv2]
|
||||
.into_iter()
|
||||
.zip(joins)
|
||||
.filter_map(|(u, join)| u.as_ref().filter(|_| join)),
|
||||
)
|
||||
.collect();
|
||||
let domain = common_trim_interval(k, &joined, ci1.interval, OP_CREATE_PE)?;
|
||||
trim_unsnapped(k, &mut copy, domain, OP_CREATE_PE)?;
|
||||
for (u, join) in [&mut uv1, &mut uv2].into_iter().zip(joins) {
|
||||
if let Some(u) = u.as_mut() {
|
||||
let trimmed = if join {
|
||||
trim_unsnapped(k, u, domain, OP_CREATE_PE)
|
||||
} else {
|
||||
u.trim(k, &mut domain.clone(), TrimMode::Notify)
|
||||
};
|
||||
ignore_soft(passed_on(trimmed, pass)?)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! #3144: whether two bodies meet, decided by a certified bracket of their boundary distance
|
||||
//! (`query::contact`), against closed forms: boxes touching on a face, apart by 1e-9,
|
||||
//! overlapping in a face, an edge resting on a face, interpenetrating, one inside the other,
|
||||
//! far apart; spheres apart and crossing; a cylinder crossing a box's face and one against a
|
||||
//! side along a line; a peg through a hole that it clears.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::panic, clippy::float_cmp)] // test
|
||||
|
||||
use solidcore::boolean::api_brep::boolean_difference;
|
||||
use solidcore::geom::certified::Verdict;
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::primitive::api_primitives::{create_box, create_cylinder, create_sphere};
|
||||
use solidcore::query::contact::{boundary_distance, meets};
|
||||
use solidcore::topology::brep::mirror::transform_matrix;
|
||||
use solidcore::{Brep, Point3};
|
||||
|
||||
fn k() -> Kernel {
|
||||
Kernel {
|
||||
fixes: solidcore::kernel::UpstreamFixes::all(),
|
||||
..Kernel::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn cube(k: &mut Kernel, at: [f64; 3], size: f64) -> Brep {
|
||||
create_box(k, Point3::new(at[0], at[1], at[2]), size, size, size).unwrap()
|
||||
}
|
||||
|
||||
/// `b` turned by `angle` about the x axis, then moved by `by`.
|
||||
fn turned_about_x(k: &mut Kernel, b: &mut Brep, angle: f64, by: [f64; 3]) {
|
||||
let (c, s) = (angle.cos(), angle.sin());
|
||||
// row vectors (`p' = p A + t`): A's rows are the images of x, y and z, t the fourth row
|
||||
let m = [
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, c, s, 0.0],
|
||||
[0.0, -s, c, 0.0],
|
||||
[by[0], by[1], by[2], 1.0],
|
||||
];
|
||||
transform_matrix(k, b, &m, true).unwrap();
|
||||
}
|
||||
|
||||
/// Asserts the verdict at `tol`, the bracket shown on failure.
|
||||
fn verdict_is(k: &Kernel, a: &mut Brep, b: &mut Brep, tol: f64, want: Verdict) {
|
||||
let c = meets(k, a, b, tol).unwrap();
|
||||
assert!(c.lower <= c.upper, "{c:?}");
|
||||
assert_eq!(c.verdict, want, "at {tol}: {c:?}");
|
||||
}
|
||||
|
||||
/// The bracket of the boundary distance, asserted to hold `exact`.
|
||||
fn holds(k: &Kernel, a: &mut Brep, b: &mut Brep, exact: f64) {
|
||||
let c = boundary_distance(k, a, b, 1e-6).unwrap();
|
||||
assert_eq!(c.verdict, Verdict::Yes, "{c:?}");
|
||||
assert!(c.lower <= exact && exact <= c.upper, "{exact} not in {c:?}");
|
||||
assert!(c.upper - c.lower <= 1e-6 * c.upper.max(1.0), "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_touching_on_a_face_meet_at_any_tolerance() {
|
||||
let mut k = k();
|
||||
let mut a = cube(&mut k, [0.0; 3], 1.0);
|
||||
let mut b = cube(&mut k, [1.0, 0.0, 0.0], 1.0);
|
||||
let c = meets(&k, &mut a, &mut b, 1e-12).unwrap();
|
||||
assert_eq!(c.verdict, Verdict::Yes, "{c:?}");
|
||||
let [p, q] = c.witness.unwrap();
|
||||
assert!(
|
||||
p.distance(q) <= 1e-12 && (p.x - 1.0).abs() <= 1e-12,
|
||||
"{c:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_apart_by_a_nanometre_meet_only_at_a_tolerance_past_it() {
|
||||
let mut k = k();
|
||||
// the gap as the doubles have it
|
||||
let gap = (1.0 + 1e-9) - 1.0;
|
||||
let mut a = cube(&mut k, [0.0; 3], 1.0);
|
||||
let mut b = cube(&mut k, [1.0 + 1e-9, 0.0, 0.0], 1.0);
|
||||
verdict_is(&k, &mut a, &mut b, 1e-8, Verdict::Yes);
|
||||
verdict_is(&k, &mut a, &mut b, 1e-10, Verdict::No);
|
||||
holds(&k, &mut a, &mut b, gap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_overlapping_in_a_face_meet() {
|
||||
let mut k = k();
|
||||
let mut a = cube(&mut k, [0.0; 3], 1.0);
|
||||
let mut b = cube(&mut k, [1.0, 0.5, 0.5], 1.0);
|
||||
verdict_is(&k, &mut a, &mut b, 1e-12, Verdict::Yes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edge_resting_on_a_face_meets_it() {
|
||||
let mut k = k();
|
||||
let mut a = cube(&mut k, [0.0; 3], 1.0);
|
||||
// a cube centred on the x axis, turned 45 degrees about it (either way: it is square), is
|
||||
// a diamond whose lowest edge is at z = -sqrt(2)/2; moved onto a's top face
|
||||
let mut b = cube(&mut k, [0.0, -0.5, -0.5], 1.0);
|
||||
let half = std::f64::consts::FRAC_1_SQRT_2;
|
||||
turned_about_x(
|
||||
&mut k,
|
||||
&mut b,
|
||||
std::f64::consts::FRAC_PI_4,
|
||||
[0.0, 0.5, 1.0 + half],
|
||||
);
|
||||
let c = meets(&k, &mut a, &mut b, 1e-12).unwrap();
|
||||
assert_eq!(c.verdict, Verdict::Yes, "{c:?}");
|
||||
let [p, _] = c.witness.unwrap();
|
||||
assert!((p.z - 1.0).abs() <= 1e-9, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpenetrating_boxes_meet() {
|
||||
let mut k = k();
|
||||
let mut a = cube(&mut k, [0.0; 3], 1.0);
|
||||
let mut b = cube(&mut k, [0.5; 3], 1.0);
|
||||
verdict_is(&k, &mut a, &mut b, 1e-12, Verdict::Yes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_inside_another_meets_it_and_their_walls_are_one_apart() {
|
||||
let mut k = k();
|
||||
let mut a = cube(&mut k, [0.0; 3], 3.0);
|
||||
let mut b = cube(&mut k, [1.0; 3], 1.0);
|
||||
verdict_is(&k, &mut a, &mut b, 1e-3, Verdict::Yes);
|
||||
verdict_is(&k, &mut b, &mut a, 1e-3, Verdict::Yes);
|
||||
holds(&k, &mut a, &mut b, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_far_apart_do_not_meet_and_their_gap_is_bracketed() {
|
||||
let mut k = k();
|
||||
let mut a = cube(&mut k, [0.0; 3], 1.0);
|
||||
let mut b = cube(&mut k, [5.0, 0.0, 0.0], 1.0);
|
||||
verdict_is(&k, &mut a, &mut b, 0.1, Verdict::No);
|
||||
holds(&k, &mut a, &mut b, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spheres_apart_are_their_centres_gap_less_their_radii() {
|
||||
let mut k = k();
|
||||
let mut a = create_sphere(&mut k, Point3::new(0.0, 0.0, 0.0), 1.0).unwrap();
|
||||
let mut b = create_sphere(&mut k, Point3::new(3.0, 0.0, 0.0), 1.0).unwrap();
|
||||
verdict_is(&k, &mut a, &mut b, 0.5, Verdict::No);
|
||||
verdict_is(&k, &mut a, &mut b, 2.0, Verdict::Yes);
|
||||
holds(&k, &mut a, &mut b, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_spheres_meet_where_their_surfaces_cross() {
|
||||
let mut k = k();
|
||||
let mut a = create_sphere(&mut k, Point3::new(0.0, 0.0, 0.0), 1.0).unwrap();
|
||||
let mut b = create_sphere(&mut k, Point3::new(1.5, 0.0, 0.0), 1.0).unwrap();
|
||||
let c = meets(&k, &mut a, &mut b, 1e-9).unwrap();
|
||||
assert_eq!(c.verdict, Verdict::Yes, "{c:?}");
|
||||
// the witness is on the circle where they cross: x = 0.75, |p| = 1
|
||||
let [p, _] = c.witness.unwrap();
|
||||
assert!((p.x - 0.75).abs() <= 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cylinder_crossing_a_box_face_meets_the_box() {
|
||||
let mut k = k();
|
||||
let mut a = cube(&mut k, [0.0; 3], 1.0);
|
||||
let mut b = create_cylinder(&mut k, Point3::new(0.5, 0.5, 0.5), 0.3, 1.0).unwrap();
|
||||
verdict_is(&k, &mut a, &mut b, 1e-9, Verdict::Yes);
|
||||
}
|
||||
|
||||
/// A cylinder touching a box side along a line meets it; moved 0.25 off, its bracket holds
|
||||
/// 0.25 and the verdicts either side of it are exact.
|
||||
///
|
||||
/// The bracket is not 1e-6 wide here: trimmed rim pieces stall `lower` at 0.25 - 0.0013 (the
|
||||
/// end caps' pieces at the rim straddle the circular trim, and each hull carries a sliver of
|
||||
/// the cap's plane outside the disk toward the box; their subdivision runs out of pairs
|
||||
/// first). The bracket contains the truth; verdicts outside [0.2487, 0.25] are exact. The
|
||||
/// width asserted is what the pair budget certifies, not a target.
|
||||
#[test]
|
||||
fn a_cylinder_against_a_box_side_meets_it_along_a_line() {
|
||||
let mut k = k();
|
||||
let mut a = cube(&mut k, [0.0; 3], 1.0);
|
||||
let mut b = create_cylinder(&mut k, Point3::new(1.5, 0.5, 0.0), 0.5, 1.0).unwrap();
|
||||
verdict_is(&k, &mut a, &mut b, 1e-9, Verdict::Yes);
|
||||
let mut far = create_cylinder(&mut k, Point3::new(1.75, 0.5, 0.0), 0.5, 1.0).unwrap();
|
||||
verdict_is(&k, &mut a, &mut far, 0.24, Verdict::No);
|
||||
verdict_is(&k, &mut a, &mut far, 0.26, Verdict::Yes);
|
||||
let c = boundary_distance(&k, &mut a, &mut far, 1e-6).unwrap();
|
||||
assert!(c.lower <= 0.25 && 0.25 <= c.upper, "{c:?}");
|
||||
assert!(c.lower >= 0.2487 && c.upper - 0.25 <= 1e-12, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peg_through_a_hole_it_clears_does_not_meet_the_plate() {
|
||||
let mut k = k();
|
||||
// a 4 x 4 x 1 plate with a hole of radius 1 through its middle, and a peg of radius 0.75
|
||||
// down the hole's axis: the peg clears the hole's wall by 0.25
|
||||
let plate = create_box(&mut k, Point3::new(0.0, 0.0, 0.0), 4.0, 4.0, 1.0).unwrap();
|
||||
let hole = create_cylinder(&mut k, Point3::new(2.0, 2.0, -1.0), 1.0, 3.0).unwrap();
|
||||
let mut plate = boolean_difference(&mut k, plate, hole).unwrap();
|
||||
let mut peg = create_cylinder(&mut k, Point3::new(2.0, 2.0, -2.0), 0.75, 5.0).unwrap();
|
||||
verdict_is(&k, &mut plate, &mut peg, 0.1, Verdict::No);
|
||||
holds(&k, &mut plate, &mut peg, 0.25);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! A cylinder wall of a short arc far from the origin, extended 1500 mm along its rulings and
|
||||
//! round its axis. The iso that certifies the turn is interpolated between rulings 3 m long.
|
||||
//! At 500 mm the certificate took its rounding at the iso's own size and refused; at 1500 mm a
|
||||
//! weight ratio per row, differing by the continuation's rounding, took the iso out of its
|
||||
//! plane by 1.8e-10. Every point of the result stays on the cylinder, radius 80 about its axis.
|
||||
//! A cylinder wall of a short arc far from the origin, extended 500 and 1500 mm along its
|
||||
//! rulings and round its axis. The iso that certifies the turn is interpolated between rulings
|
||||
//! up to 3 m long: a weight ratio per row, differing by the continuation's rounding, took it out
|
||||
//! of its plane by 1.8e-10, and the certificate refused the extension as not exact. Every point
|
||||
//! of the result stays on the cylinder, radius 80 about its axis.
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
@@ -16,9 +16,11 @@ use solidcore::kernel::UpstreamFixes;
|
||||
use solidcore::math::{Point2, Point3, Vec3};
|
||||
use solidcore::primitive::api_curves as curves;
|
||||
use solidcore::primitive::api_primitives::create_box;
|
||||
use solidcore::surface::cone::p1::Cone;
|
||||
use solidcore::surface::extend::Continuity;
|
||||
use solidcore::surface::implicit::AnalyticSupport;
|
||||
use solidcore::surface::side::BoxSide;
|
||||
use solidcore::surface::surface::Surface;
|
||||
use solidcore::sweep::api_brep::create_linear_sweep;
|
||||
use solidcore::topology::brep::p1 as bp1;
|
||||
use solidcore::{Brep, Kernel};
|
||||
@@ -108,3 +110,46 @@ fn a_short_arc_extends_along_long_rulings_on_its_cylinder() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A cylinder of radius 1, 60 degrees round and 1 high at the origin, extended along its
|
||||
/// rulings and round its axis by up to 1e5: the frame's size stays the source's (about 1)
|
||||
/// while the rows grow to the extension, so this is where a limit at the iso's own size
|
||||
/// could fall under its rounding. Every point stays on the cylinder.
|
||||
#[test]
|
||||
fn a_small_arc_extends_far_beyond_its_size_on_its_cylinder() {
|
||||
let k = Kernel::default();
|
||||
let wall = Surface::from(Cone::new_cylinder_bounded(
|
||||
Vec3::new(0.0, 0.0, 0.0),
|
||||
Vec3::new(1.0, 0.0, 0.0),
|
||||
Vec3::new(0.0, 1.0, 0.0),
|
||||
1.0,
|
||||
0.0,
|
||||
60.0,
|
||||
1.0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
));
|
||||
for d in [1e2, 1e3, 1e4, 1e5] {
|
||||
let grown = wall
|
||||
.extended_sides_keeping_parameters(&k, &BoxSide::ALL, Continuity::Ruled, d)
|
||||
.unwrap_or_else(|e| panic!("{d}: {e}"))
|
||||
.surface;
|
||||
let dom = grown.natural_uv_domain_with_nurb(&k).unwrap();
|
||||
let (u, v) = (dom.u_interval(), dom.v_interval());
|
||||
for i in 0..=8 {
|
||||
for j in 0..=8 {
|
||||
let uv = Point2::new(
|
||||
u.min() + u.length() * f64::from(i) / 8.0,
|
||||
v.min() + v.length() * f64::from(j) / 8.0,
|
||||
);
|
||||
let x = grown.evaluate_point(&k, uv).unwrap();
|
||||
let r = x.x.hypot(x.y);
|
||||
assert!(
|
||||
(r - 1.0).abs() < 1e-9 * (1.0 + x.z.abs()),
|
||||
"{d} {i} {j} {r}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! A sphere's NURBS form is recognised as its sphere far from the origin, at any radius and
|
||||
//! orientation, and the half-meridian test allows the angles' rounding at the points' size,
|
||||
//! no more (#3131). A sphere of radius 2.5 at 1e5 reads 180 + 6.7e-10 degrees, past the fixed
|
||||
//! half-meridian tolerance, and was not recognised.
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing
|
||||
)] // test helpers
|
||||
|
||||
use solidcore::blend::fillet_section::station::as_b_spline;
|
||||
use solidcore::curve::curve::Curve;
|
||||
use solidcore::geom::axis2_placement::Axis2Placement;
|
||||
use solidcore::geom::extent2d::Extent2d;
|
||||
use solidcore::math::{Point3, Vec3};
|
||||
use solidcore::nurbs::b_spline_curve::{BSplineCurve, NurbCircleParam};
|
||||
use solidcore::nurbs::b_spline_surface::BSplineSurface;
|
||||
use solidcore::nurbs::math::gamma;
|
||||
use solidcore::nurbs::srf_basic::Surface as Net;
|
||||
use solidcore::primitive::api_primitives::create_sphere;
|
||||
use solidcore::surface::implicit::AnalyticSupport;
|
||||
use solidcore::surface::sphere::Sphere;
|
||||
use solidcore::surface::surf_of_revolution::SurfOfRevolution;
|
||||
use solidcore::surface::surface::Surface;
|
||||
use solidcore::topology::brep::p1 as bp1;
|
||||
use solidcore::Kernel;
|
||||
|
||||
/// The allowance the recogniser adds, as a position: `4 gamma_K (1 + |corners| + 2 r)`,
|
||||
/// `K = 16 (2 + 2 + 2)` for a form of degrees (2, 2) (written out so that a change to the
|
||||
/// allowance fails). In degrees it is this over `r`, turned to degrees.
|
||||
fn allowance_position(form: &BSplineSurface, r: f64) -> f64 {
|
||||
let d = form.natural_uv_domain();
|
||||
let corners = form
|
||||
.evaluate_point(d.min())
|
||||
.unwrap()
|
||||
.max_dimension()
|
||||
.max(form.evaluate_point(d.max()).unwrap().max_dimension());
|
||||
4.0 * gamma(16 * 6) * (1.0 + corners + 2.0 * r)
|
||||
}
|
||||
|
||||
/// The NURBS form of a sphere of radius `r` at `c`, turned by `turn` degrees about the axis
|
||||
/// (1, 1, 1) when it is not 0, and its label.
|
||||
fn form_at(k: &mut Kernel, c: Point3, r: f64, turn: f64) -> (BSplineSurface, AnalyticSupport) {
|
||||
let b = create_sphere(k, Point3::new(0.0, 0.0, 0.0), r).unwrap();
|
||||
let f = bp1::faces(&b, None).unwrap()[0];
|
||||
let mut s = b
|
||||
.face(f)
|
||||
.unwrap()
|
||||
.surface
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.copy(k)
|
||||
.unwrap();
|
||||
// turned about (1, 1, 1) by `turn`, then moved to `c`
|
||||
let a = turn.to_radians();
|
||||
let n = Vec3::new(1.0, 1.0, 1.0) * (1.0 / 3.0_f64.sqrt());
|
||||
let rot = |v: Vec3| v * a.cos() + n.cross(v) * a.sin() + n * (n.dot(v) * (1.0 - a.cos()));
|
||||
let mut place = Axis2Placement::default();
|
||||
place
|
||||
.set_canonical(
|
||||
c,
|
||||
rot(Vec3::new(1.0, 0.0, 0.0)),
|
||||
rot(Vec3::new(0.0, 1.0, 0.0)),
|
||||
)
|
||||
.unwrap();
|
||||
s.transform(k, &place, None).unwrap();
|
||||
let form = as_b_spline(k, &s).unwrap().unwrap().into_owned();
|
||||
(form, AnalyticSupport::of(&s).unwrap())
|
||||
}
|
||||
|
||||
/// Recognised at radii 1e-3 to 1e3, 0 to 1e7 from the origin (at most 1e9 radii out), on the
|
||||
/// axes and turned off them, with its radius and centre within the allowance as a position.
|
||||
#[test]
|
||||
fn a_far_sphere_is_recognised_on_its_own_numbers() {
|
||||
let mut k = Kernel::default();
|
||||
for r in [1e-3, 1.0, 2.5, 1e3] {
|
||||
for off in [0.0, 1e3, 1e5, 1e6, 1e7] {
|
||||
// a sphere of radius 1e-3 at 1e7, ten orders apart: upstream's iso-circle test
|
||||
// fails with a status there (1001), before any angle is read; unchanged here
|
||||
if off > 1e9 * r {
|
||||
continue;
|
||||
}
|
||||
for turn in [0.0, 37.0] {
|
||||
let c = Point3::new(off, off, off + 10.0);
|
||||
let (form, _) = form_at(&mut k, c, r, turn);
|
||||
let found = Sphere::is_nurb_surface_sphere(&k, &form, 1.0)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("not recognised: r {r} at {off}, turned {turn}"));
|
||||
let AnalyticSupport::Sphere { placement, radius } =
|
||||
AnalyticSupport::of(&Surface::from(found)).unwrap()
|
||||
else {
|
||||
panic!("not a sphere");
|
||||
};
|
||||
let tol = allowance_position(&form, r);
|
||||
assert!((radius - r).abs() <= tol, "r {r} at {off}: radius {radius}");
|
||||
assert!(
|
||||
(placement.origin() - c).length() <= tol,
|
||||
"r {r} at {off}: centre {:e} past {tol:e}",
|
||||
(placement.origin() - c).length()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A half meridian of `180 + extra` degrees, radius `r`, at `c`: a circle arc from -90 to
|
||||
/// `90 + extra` degrees in the plane y = c.y, turned a quarter turn about the vertical.
|
||||
fn long_meridian(k: &Kernel, c: Point3, r: f64, extra: f64) -> BSplineSurface {
|
||||
let mut frame = Axis2Placement::default();
|
||||
frame
|
||||
.set_canonical(c, Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0))
|
||||
.unwrap();
|
||||
let generator = BSplineCurve::create_circle_segment(
|
||||
3,
|
||||
&frame,
|
||||
r,
|
||||
-90.0,
|
||||
90.0 + extra,
|
||||
NurbCircleParam::Quadratic,
|
||||
)
|
||||
.unwrap();
|
||||
let mut sor = SurfOfRevolution::new(
|
||||
k,
|
||||
Curve::from(generator),
|
||||
c,
|
||||
Vec3::new(1.0, 0.0, 0.0),
|
||||
Vec3::new(0.0, 1.0, 0.0),
|
||||
Extent2d::from_bounds(0.0, -90.0, 90.0, 90.0 + extra),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
sor.make_nurb(k).unwrap();
|
||||
as_b_spline(k, &Surface::from(sor))
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// The half meridian is allowed `1e-12` degrees (upstream) plus the angles' rounding at the
|
||||
/// points' size. A meridian `d` past 180 degrees also folds the generator's STEP interval
|
||||
/// against the sphere's latitudes by `2 d` (a point past the pole reads latitude `90 - d`),
|
||||
/// held to the same allowance. So a meridian 0.4 of the allowance past is recognised (both
|
||||
/// tests pass; halve the allowance and the fold, 0.8 of it, is refused) and one twice the
|
||||
/// allowance past is refused, at the origin and at 1e5. A doubled allowance is caught by
|
||||
/// `sphere.rs`'s own unit test of `meridian_rounding` against its closed form.
|
||||
#[test]
|
||||
fn the_half_meridian_is_allowed_its_rounding_and_no_more() {
|
||||
let k = Kernel::default();
|
||||
let r = 2.5;
|
||||
for off in [0.0, 1e5] {
|
||||
let c = Point3::new(off, off, off + 10.0);
|
||||
let exact = long_meridian(&k, c, r, 0.0);
|
||||
let allowed = 1e-12 + (allowance_position(&exact, r) / r).to_degrees();
|
||||
let over = long_meridian(&k, c, r, 2.0 * allowed);
|
||||
assert!(
|
||||
Sphere::is_nurb_surface_sphere(&k, &over, 1.0)
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"at {off}: 180 + {:e} degrees recognised",
|
||||
2.0 * allowed
|
||||
);
|
||||
let under = long_meridian(&k, c, r, 0.4 * allowed);
|
||||
assert!(
|
||||
Sphere::is_nurb_surface_sphere(&k, &under, 1.0)
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"at {off}: 180 + {:e} degrees refused",
|
||||
0.4 * allowed
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A form or a label 1e-3 off the sphere is refused at every offset to 1e6: the label by
|
||||
/// `lies_on` (`AnalyticSupport::build`), the form by the iso-circle tests. Neither reaches
|
||||
/// the angle allowance; they show the allowance did not open what those tests close. At 1e7
|
||||
/// the iso-circle tests' own tolerance, `100 SM_EFF_ZERO (1 + |corners|)` (about 2e-3),
|
||||
/// admits a 1e-3 move: upstream's, unchanged here.
|
||||
#[test]
|
||||
fn a_form_or_label_1e3_off_is_refused_to_1e6() {
|
||||
let mut k = Kernel::default();
|
||||
for off in [0.0, 1e3, 1e4, 1e5, 1e6] {
|
||||
let c = Point3::new(off, off, off + 10.0);
|
||||
let (form, support) = form_at(&mut k, c, 2.5, 0.0);
|
||||
let AnalyticSupport::Sphere { placement, radius } = support else {
|
||||
panic!("a sphere");
|
||||
};
|
||||
let off_label = AnalyticSupport::Sphere {
|
||||
placement,
|
||||
radius: radius + 1e-3,
|
||||
};
|
||||
assert!(off_label.build(&k, form.copy()).is_err(), "label at {off}");
|
||||
let net = form.gw_nurb().unwrap();
|
||||
let mut cpts = net.cpts();
|
||||
let (i, j) = (cpts.len() / 2, cpts[0].len() / 2);
|
||||
let w = cpts[i][j].w;
|
||||
cpts[i][j].x += 1e-3
|
||||
* if w == solidcore::nurbs::math::NOW {
|
||||
1.0
|
||||
} else {
|
||||
w
|
||||
};
|
||||
let (p, q) = net.degrees();
|
||||
let moved = Net::new(cpts, p, q, net.u_knots().to_vec(), net.v_knots().to_vec()).unwrap();
|
||||
let moved = BSplineSurface::from_gw_nurb(&moved);
|
||||
assert!(
|
||||
Sphere::is_nurb_surface_sphere(&k, &moved, 1.0)
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"form at {off}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1363,3 +1363,82 @@ fn d_prism_asked_past_the_rails_names_the_setback_that_fits() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The least distance from the prism's axis of the top face's spring curve at the corner of
|
||||
/// [`d_prism_eight_case`] when every setback is `s`, by its closed form: the top is a plane,
|
||||
/// so the spring curve is the parabola (P, X, Q) itself, P on the chord's rail x = 7, `s`
|
||||
/// along the chord from V, Q on the arc's rail rho = 8, `s` along the arc from V, and X where
|
||||
/// the rails' tangents there cross. The least over 20000 even steps of the parabola's
|
||||
/// parameter (each a few millionths long).
|
||||
fn top_spring_least_rho(s: f64) -> f64 {
|
||||
let p = (7.0, 6.0 - s);
|
||||
let psi = 6.0f64.atan2(8.0) + s / 10.0;
|
||||
let q = (8.0 * psi.cos(), 8.0 * psi.sin());
|
||||
let along = (7.0 - q.0) / psi.sin();
|
||||
let x = (7.0, q.1 - along * psi.cos());
|
||||
(0..=20000)
|
||||
.map(|i| {
|
||||
let t = f64::from(i) / 20000.0;
|
||||
let (a, b, c) = ((1.0 - t) * (1.0 - t), 2.0 * t * (1.0 - t), t * t);
|
||||
(a * p.0 + b * x.0 + c * q.0).hypot(a * p.1 + b * x.1 + c * q.1)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// #2343 (d), "a thin flat strip beside a cylinder": the bored D prism (bore R 6.9) asked 8.5
|
||||
/// at V. By the closed form above its top face's spring curve dips into the bore from asked
|
||||
/// 7.8068; main built asked 7.8 (the spring curve 6e-4 clear of the bore) and refused 8 to
|
||||
/// 9.5 late, "a round kept round a feature does not close on the body", naming nothing. The
|
||||
/// corner checks what it takes out of the top: refused as `SetbackTooLong` at V, naming the
|
||||
/// largest setback on 8.5's grid of thousandths whose spring curve stays on the face, 0.918 x
|
||||
/// 8.5 = 7.803 (3.3e-4 clear of the bore; the next, 7.8115, 4.1e-4 into it). Asked that, the
|
||||
/// corner builds, graded as the corners above.
|
||||
#[test]
|
||||
fn bored_d_prism_spring_curve_leaving_the_top_names_the_setback_that_keeps_it() {
|
||||
let (bore, asked, step) = (6.9, 8.5, 8.5 / 1000.0);
|
||||
let (mut k, mut b, edges, case) = d_prism_eight_case(Some(bore), 14);
|
||||
let v = vertex_at(&b, case.v);
|
||||
let r = round_asked(
|
||||
&mut k,
|
||||
&mut b,
|
||||
&edges,
|
||||
&[1.0, 1.5, 2.0],
|
||||
case.v,
|
||||
Some(asked),
|
||||
);
|
||||
assert_eq!(r.event, Some(BlendCornerEvent::Setback), "{}", r.reason);
|
||||
assert!(!r.ok, "asked {asked}: built");
|
||||
let named = match r.refusal {
|
||||
Some(BlendRefusal::SetbackTooLong { vertex, largest }) => {
|
||||
assert_eq!(vertex, v);
|
||||
largest
|
||||
}
|
||||
other => panic!("{other:?}, {}", r.reason),
|
||||
};
|
||||
// on the grid, its spring curve clear of the bore, the next one's not
|
||||
let grid = (named / step).round();
|
||||
assert!((grid * step - named).abs() <= 1e-12, "{named} off the grid");
|
||||
assert!(
|
||||
top_spring_least_rho(named) > bore,
|
||||
"{named}: {}",
|
||||
top_spring_least_rho(named)
|
||||
);
|
||||
assert!(
|
||||
top_spring_least_rho(named + step) < bore,
|
||||
"{}: {}",
|
||||
named + step,
|
||||
top_spring_least_rho(named + step)
|
||||
);
|
||||
let (mut k, mut b, edges, case) = d_prism_eight_case(Some(bore), 14);
|
||||
let r = round_asked(
|
||||
&mut k,
|
||||
&mut b,
|
||||
&edges,
|
||||
&[1.0, 1.5, 2.0],
|
||||
case.v,
|
||||
Some(named),
|
||||
);
|
||||
assert!(r.ok, "asked {named}: {}", r.reason);
|
||||
graded(&mut k, &mut b, &case, 3);
|
||||
kept_definitions(&k, &b, &case, 3);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
//! Regression tests for tangency-labs/solidcore#2972: a body's volume sign is read from a
|
||||
//! certified enclosure `[lo, hi]` of its volume, not from a quadrature estimate.
|
||||
//!
|
||||
//! - A thin quarter shell (radius 1e4, thickness 1e-3, length 10) is a valid solid whose
|
||||
//! volume, 157.08, lies below the estimate's error at the sign test's accuracy (the
|
||||
//! estimate at 1e-3 reads -180.05): its enclosure holds the closed form and is positive,
|
||||
//! and `is_outward_solid` calls it a solid.
|
||||
//! - A box with one edge rounded (radius 3) is enclosed to a millionth of its volume,
|
||||
//! around the closed form `24000 - 40 * 9 * (1 - pi / 4)`, on the default kernel and with
|
||||
//! the rounds' definitions on.
|
||||
//! - A box, and the same box with its material outside (every face's material side
|
||||
//! swapped), decide positive and negative with enclosures holding +120 and -120; a
|
||||
//! sphere (seam and poles) holds `4/3 pi r^3`.
|
||||
//! - The same box with one side's surface moved out by 1e-3 and its trims kept: the faces
|
||||
//! no longer meet, and the closed surface the enclosure bounds (the faces and the strips
|
||||
//! across their gaps) holds 120 + 30e-3, which the faces alone do not.
|
||||
//! - Two unit cubes touching along an edge (their union: four face sides at that edge,
|
||||
//! paired by sense) are enclosed around 2.
|
||||
//! - A G2 round of the box edge (its net rational of degree 7 across, whose weights an
|
||||
//! interval proves positive only over small boxes) decides, around the round's volume:
|
||||
//! as a bare net, and as a procedural blend whose net is certified within a bound above 0
|
||||
//! of its definition, read on its net (#3170 widens by that bound).
|
||||
//! - The quarter shell R 10, t 1e-6 passes the check, but its faces' boundaries lie apart
|
||||
//! by about its thickness: the sign is undecided, early (the strips between them hold 0
|
||||
//! whatever the cells become), and `is_outward_solid` reads that as "not a solid". A box
|
||||
//! with an offset-surface face cannot be read at all: an error, not a sign.
|
||||
//! - `nurb_fillet.smb` (a sphere with a pocket) decides positive: its sphere face's loop
|
||||
//! closes along the pole, from trim ends a unit in the last place off the net's last
|
||||
//! knot, a segment whose 3D length is of the order of rounding.
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
|
||||
use solidcore::blend::fillet_executive::api::fillet_edges;
|
||||
use solidcore::blend::fillet_section::section::GN_THUMBWEIGHT_EQUAL_AREA;
|
||||
use solidcore::boolean::api_brep::boolean_union;
|
||||
use solidcore::geom::axis2_placement::Axis2Placement;
|
||||
use solidcore::kernel::{KernelOptions, UpstreamFixes};
|
||||
use solidcore::mass::enclosure::{
|
||||
enclose_volume, volume_sign, VolumeEnclosure, VolumeSign, CELL_BUDGET,
|
||||
};
|
||||
use solidcore::math::{Point3, Vec3, PI};
|
||||
use solidcore::primitive::api_primitives::{create_box, create_cylindrical_box, create_sphere};
|
||||
use solidcore::query::api_queries::edge_closest_point;
|
||||
use solidcore::surface::offset_surface::OffsetSurface;
|
||||
use solidcore::surface::surface::Surface;
|
||||
use solidcore::topology::brep::is_outward_solid;
|
||||
use solidcore::topology::brep::p1::{faces, regions};
|
||||
use solidcore::topology::brep::validation::{check_brep, CheckLevel};
|
||||
use solidcore::SolidError;
|
||||
use solidcore::{Brep, Kernel};
|
||||
|
||||
/// The quarter shell between radii `r` and `r + t` about Z, length 10.
|
||||
fn quarter_shell(k: &mut Kernel, r: f64, t: f64) -> Brep {
|
||||
create_cylindrical_box(k, Point3::new(0.0, 0.0, 0.0), 10.0, r, r + t, 0.0, 90.0).unwrap()
|
||||
}
|
||||
|
||||
fn shell_volume(r: f64, t: f64) -> f64 {
|
||||
PI / 4.0 * ((r + t) * (r + t) - r * r) * 10.0
|
||||
}
|
||||
|
||||
/// The box 40 x 30 x 20 with its edge through (20, 30, 20) rounded at radius 3.
|
||||
fn rounded_box(k: &mut Kernel) -> Brep {
|
||||
rounded_box_by(k, 1, 1, 1.0)
|
||||
}
|
||||
|
||||
/// [`rounded_box`] with `fillet_edges`' section kind, order and thumbweight.
|
||||
fn rounded_box_by(k: &mut Kernel, kind: u32, order: u32, weight: f64) -> Brep {
|
||||
let mut b = create_box(k, Point3::new(0.0, 0.0, 0.0), 40.0, 30.0, 20.0).unwrap();
|
||||
let q = Point3::new(20.0, 30.0, 20.0);
|
||||
let e = b
|
||||
.edge_list()
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&e| edge_closest_point(k, &b, e, q).is_ok_and(|c| c.closest.distance < 1e-9))
|
||||
.unwrap();
|
||||
fillet_edges(k, &mut b, &[e], 3.0, kind, order, weight).unwrap();
|
||||
b
|
||||
}
|
||||
|
||||
/// Every fix on but the G2 one, as SolidCraft runs: the round is an exact round, a
|
||||
/// definition with bound 0.
|
||||
fn definitions_kernel() -> Kernel {
|
||||
let mut k = Kernel::new(&KernelOptions::default());
|
||||
let mut f = UpstreamFixes::default();
|
||||
for &name in UpstreamFixes::NAMES {
|
||||
if name != "geometric_g2_continuity" {
|
||||
*f.flag_mut(name).unwrap() = true;
|
||||
}
|
||||
}
|
||||
k.fixes = f;
|
||||
k
|
||||
}
|
||||
|
||||
fn holds(e: &VolumeEnclosure, v: f64) -> bool {
|
||||
e.lo <= v && v <= e.hi
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thin_shell_below_the_estimates_error_decides_positive() {
|
||||
for (r, t) in [(1e4, 1e-3), (10.0, 1e-4)] {
|
||||
let mut k = Kernel::default();
|
||||
let mut b = quarter_shell(&mut k, r, t);
|
||||
let v = shell_volume(r, t);
|
||||
match volume_sign(&k, &mut b).unwrap() {
|
||||
VolumeSign::Positive(e) => assert!(holds(&e, v), "r {r} t {t}: {e:?} misses {v}"),
|
||||
other => panic!("r {r} t {t}: {other:?}, volume {v}"),
|
||||
}
|
||||
// the solid test reads the same sign: on the estimate (-180.05 at r 1e4) it was false
|
||||
let mut b = quarter_shell(&mut k, r, t);
|
||||
assert!(is_outward_solid(&mut k, &mut b).unwrap(), "r {r} t {t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rounded_box_is_enclosed_around_its_closed_form() {
|
||||
let v = 24000.0 - 40.0 * 9.0 * (1.0 - PI / 4.0);
|
||||
for (name, mut k) in [
|
||||
("default", Kernel::default()),
|
||||
("definitions", definitions_kernel()),
|
||||
] {
|
||||
let mut b = rounded_box(&mut k);
|
||||
let e = enclose_volume(&k, &mut b, 1e-6 * v).unwrap();
|
||||
assert!(holds(&e, v), "{name}: {e:?} misses {v}");
|
||||
assert!(e.width() <= 1e-6 * v, "{name}: {e:?} wider than 1e-6 V");
|
||||
let mut b = rounded_box(&mut k);
|
||||
let s = volume_sign(&k, &mut b).unwrap();
|
||||
assert!(
|
||||
matches!(s, VolumeSign::Positive(e) if holds(&e, v)),
|
||||
"{name}: {s:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_and_its_inside_out_twin_decide_their_signs() {
|
||||
let mut k = Kernel::default();
|
||||
let mut b = create_box(&mut k, Point3::new(1.0, 2.0, 3.0), 4.0, 5.0, 6.0).unwrap();
|
||||
let e = enclose_volume(&k, &mut b, 1e-9).unwrap();
|
||||
assert!(holds(&e, 120.0) && e.width() <= 1e-9, "{e:?}");
|
||||
// the material outside: every face's material side is the other one
|
||||
for r in regions(&b, None).unwrap() {
|
||||
let g = b.region_mut(r).unwrap();
|
||||
g.is_void = 1 - g.is_void;
|
||||
}
|
||||
match volume_sign(&k, &mut b).unwrap() {
|
||||
VolumeSign::Negative(e) => assert!(holds(&e, -120.0), "{e:?}"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sphere_is_enclosed_across_its_seam_and_poles() {
|
||||
let mut k = Kernel::default();
|
||||
let mut b = create_sphere(&mut k, Point3::new(0.0, 0.0, 0.0), 2.0).unwrap();
|
||||
let v = 4.0 / 3.0 * PI * 8.0;
|
||||
let e = enclose_volume(&k, &mut b, 1e-6).unwrap();
|
||||
assert!(holds(&e, v) && e.width() <= 1e-6, "{e:?} misses {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_between_faces_is_closed_by_its_strips() {
|
||||
let mut k = Kernel::default();
|
||||
let mut b = create_box(&mut k, Point3::new(1.0, 2.0, 3.0), 4.0, 5.0, 6.0).unwrap();
|
||||
// the side x = 5 (5 x 6) moved out by delta, its trims and its neighbours kept
|
||||
let delta = 1e-3;
|
||||
let side = faces(&b, None)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|&f| {
|
||||
let face = b.face(f).unwrap();
|
||||
let s = face.surface.as_ref().unwrap();
|
||||
s.evaluate_point(&k, face.uv_domain.mid())
|
||||
.is_ok_and(|p| (p.x - 5.0).abs() < 1e-12)
|
||||
})
|
||||
.unwrap();
|
||||
let mut shift = Axis2Placement::default();
|
||||
shift.translate(Vec3::new(delta, 0.0, 0.0));
|
||||
b.face_mut(side)
|
||||
.unwrap()
|
||||
.surface
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.transform(&k, &shift, None)
|
||||
.unwrap();
|
||||
let v = 120.0 + 30.0 * delta;
|
||||
let e = enclose_volume(&k, &mut b, 1.0).unwrap();
|
||||
assert!(holds(&e, v) && e.counts.strips > 0, "{e:?} misses {v}");
|
||||
assert!(matches!(
|
||||
volume_sign(&k, &mut b).unwrap(),
|
||||
VolumeSign::Positive(_)
|
||||
));
|
||||
}
|
||||
|
||||
/// `upstream/TestFiles/pt_TestFiles/Solids/nurb_fillet.smb`, read and healed; `None` where
|
||||
/// the upstream test files are not present.
|
||||
fn nurb_fillet(k: &mut Kernel) -> Option<Brep> {
|
||||
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
|
||||
use solidcore::geom::object::ObjectRef;
|
||||
use solidcore::topology::brep::p10::TopologyEnv;
|
||||
use solidcore::topology::brep::p7::read_from_file;
|
||||
use solidcore::topology::database_io::FileType;
|
||||
use solidcore::topology::topology::NotifyRecords;
|
||||
use solidcore::EntityRef;
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../upstream/TestFiles/pt_TestFiles/Solids/nurb_fillet.smb");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
assert!(
|
||||
std::env::var_os("SOLIDCORE_REQUIRE_UPSTREAM").is_none(),
|
||||
"SOLIDCORE_REQUIRE_UPSTREAM is set and the upstream test file is missing: {}",
|
||||
path.display()
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let mut b = Brep::new_with_infinite_region().unwrap();
|
||||
let mut records = NotifyRecords::new();
|
||||
let mut attributes = AttributeStore::default();
|
||||
let key = |_: EntityRef| ObjectRef {
|
||||
key: ObjectKey(0),
|
||||
type_code: 0,
|
||||
};
|
||||
let mut env = TopologyEnv {
|
||||
all_attributes: &[],
|
||||
attributes: &mut attributes,
|
||||
key_of: &key,
|
||||
records: &mut records,
|
||||
next_key: 1 << 50,
|
||||
};
|
||||
read_from_file(k, &mut b, bytes, FileType::Ascii, false, &mut env).unwrap();
|
||||
b.use_arena_naming().unwrap();
|
||||
Some(b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sphere_closed_along_its_pole_decides() {
|
||||
let mut k = Kernel::default();
|
||||
let Some(mut b) = nurb_fillet(&mut k) else {
|
||||
return;
|
||||
};
|
||||
let s = volume_sign(&k, &mut b).unwrap();
|
||||
assert!(matches!(s, VolumeSign::Positive(_)), "{s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solids_touching_along_an_edge_pair_their_sides() {
|
||||
let mut k = Kernel::default();
|
||||
let a = create_box(&mut k, Point3::new(0.0, 0.0, 0.0), 1.0, 1.0, 1.0).unwrap();
|
||||
let c = create_box(&mut k, Point3::new(1.0, 1.0, 0.0), 1.0, 1.0, 1.0).unwrap();
|
||||
let mut b = boolean_union(&mut k, a, c).unwrap();
|
||||
let e = enclose_volume(&k, &mut b, 1e-9).unwrap();
|
||||
assert!(holds(&e, 2.0) && e.width() <= 1e-9, "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_g2_round_decides_around_its_volume() {
|
||||
// the G^n section's flag and the one its plane rails need: the round a bare net
|
||||
let mut bare = Kernel::default();
|
||||
bare.fixes.fillet_gn_section_generator = true;
|
||||
bare.fixes.fillet_surface_plane_curve_on_plane_equation = true;
|
||||
let v = 24000.0 - 40.0 * 9.0 * (1.0 - PI / 4.0);
|
||||
for (name, mut k, certified) in [
|
||||
("bare net", bare, false),
|
||||
("definition", definitions_kernel(), true),
|
||||
] {
|
||||
let mut b = rounded_box_by(&mut k, 2, 2, GN_THUMBWEIGHT_EQUAL_AREA);
|
||||
// with the definitions on, the round is a procedural blend whose net is certified
|
||||
// within a bound above 0: it is read on its net
|
||||
let bounds: Vec<f64> = faces(&b, None)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter_map(|f| {
|
||||
let s = b.face(f).unwrap().surface.as_ref().unwrap();
|
||||
s.procedural_blend().map(|d| d.bound())
|
||||
})
|
||||
.collect();
|
||||
if certified {
|
||||
assert!(bounds.iter().any(|&e| e > 0.0), "{name}: {bounds:?}");
|
||||
} else {
|
||||
assert!(bounds.is_empty(), "{name}: {bounds:?}");
|
||||
}
|
||||
let e = enclose_volume(&k, &mut b, 1e-3 * v).unwrap();
|
||||
assert!(
|
||||
holds(&e, v) && e.width() <= 1e-3 * v,
|
||||
"{name}: {e:?} misses {v}"
|
||||
);
|
||||
let mut b = rounded_box_by(&mut k, 2, 2, GN_THUMBWEIGHT_EQUAL_AREA);
|
||||
let s = volume_sign(&k, &mut b).unwrap();
|
||||
assert!(matches!(s, VolumeSign::Positive(_)), "{name}: {s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_as_large_as_the_volume_leaves_the_sign_undecided() {
|
||||
let mut k = Kernel::default();
|
||||
let shell = |k: &mut Kernel| quarter_shell(k, 10.0, 1e-6);
|
||||
let mut b = shell(&mut k);
|
||||
let c = check_brep(&mut k, &mut b, CheckLevel::Level2).unwrap();
|
||||
assert!(c.lines().is_empty(), "{:?}", c.lines());
|
||||
let v = shell_volume(10.0, 1e-6);
|
||||
let mut b = shell(&mut k);
|
||||
match volume_sign(&k, &mut b).unwrap() {
|
||||
// undecided long before the budget: the strips settle
|
||||
VolumeSign::Undecided(e) => {
|
||||
assert!(holds(&e, v) && e.counts.cells < CELL_BUDGET / 4, "{e:?}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
// undecided is "not a solid" on purpose
|
||||
let mut b = shell(&mut k);
|
||||
assert!(!is_outward_solid(&mut k, &mut b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_face_without_a_nurbs_form_is_an_error_not_a_sign() {
|
||||
let mut k = Kernel::default();
|
||||
let mut b = create_box(&mut k, Point3::new(0.0, 0.0, 0.0), 4.0, 5.0, 6.0).unwrap();
|
||||
// one side's plane as an offset surface at distance 0: the same box
|
||||
let f = faces(&b, None).unwrap()[0];
|
||||
let base = b.face(f).unwrap().surface.as_ref().unwrap().clone();
|
||||
let off = OffsetSurface::new(&k, 0.0, base, true, 0).unwrap();
|
||||
b.face_mut(f).unwrap().surface = Some(Surface::from(off));
|
||||
let r = volume_sign(&k, &mut b);
|
||||
assert!(matches!(r, Err(SolidError::Unsupported { .. })), "{r:?}");
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! tangency-labs/solidcore#2975: the stitch reports, for every gap it closes, a
|
||||
//! certified bound on the gap and the tolerance growth its closure applied; an exact
|
||||
//! match's closure applies no growth.
|
||||
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing
|
||||
)]
|
||||
|
||||
use solidcore::geom::extent2d::Extent2d;
|
||||
use solidcore::geom::tol::USE_DEFAULT;
|
||||
use solidcore::math::{Point2, Vec3};
|
||||
use solidcore::surface::plane::Plane;
|
||||
use solidcore::surface::surface::Surface;
|
||||
use solidcore::topology::brep::p2::ArenaNotifyState;
|
||||
use solidcore::topology::brep::p3::new_brep;
|
||||
use solidcore::topology::brep::p5::{stitch_and_orient_reported, StitchAndOrientOptions};
|
||||
use solidcore::topology::brep::p8::{create_face_from_surface, FaceBuildEnv};
|
||||
use solidcore::topology::brep_cache::{arena_identity, CacheObject};
|
||||
use solidcore::topology::topology_traverser::Marks;
|
||||
use solidcore::{Brep, Kernel};
|
||||
|
||||
/// Compare stored tolerance accounting independently of the reported bound.
|
||||
fn check_accounting(entry: &solidcore::heal::stitch::p2::ClosedGap) {
|
||||
let increase = (entry.tolerance_after - entry.tolerance_before).max(0.0);
|
||||
assert!(
|
||||
entry.growth.is_finite() && entry.growth >= increase,
|
||||
"{entry:?}"
|
||||
);
|
||||
if entry.tolerance_after <= entry.tolerance_before {
|
||||
assert_eq!(entry.growth, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds one square planar face on the plane `z = 0` over the 1 x 1 domain whose low
|
||||
/// corner is `x` on x and `y` on y.
|
||||
fn add_square(k: &mut Kernel, b: &mut Brep, x: f64, y: f64) {
|
||||
let surface = Surface::from(Plane::from_normal(
|
||||
Vec3::new(0.0, 0.0, 0.0),
|
||||
Vec3::new(0.0, 0.0, 1.0),
|
||||
));
|
||||
let mut state = ArenaNotifyState::new(b);
|
||||
let mut env = state.env(None);
|
||||
let mut marks = Marks::default();
|
||||
let this_face = |face| arena_identity(CacheObject::Face(face));
|
||||
let mut fenv = FaceBuildEnv {
|
||||
env: &mut env,
|
||||
marks: &mut marks,
|
||||
this_face: &this_face,
|
||||
};
|
||||
let domain = Extent2d::from_points(Point2::new(x, y), Point2::new(x + 1.0, y + 1.0));
|
||||
create_face_from_surface(k, b, Some(surface), &domain, &mut fenv).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_known_gap_reports_its_bound_and_the_growth_its_closure_applied() {
|
||||
let gap = 1e-5;
|
||||
let mut k = Kernel::default();
|
||||
let mut b = new_brep(&mut k, USE_DEFAULT, USE_DEFAULT).unwrap();
|
||||
add_square(&mut k, &mut b, 0.0, 0.0);
|
||||
add_square(&mut k, &mut b, 1.0 + gap, 0.0);
|
||||
|
||||
let gaps = stitch_and_orient_reported(&mut k, &mut b, &StitchAndOrientOptions::default())
|
||||
.expect("the stitch runs");
|
||||
for entry in &gaps {
|
||||
check_accounting(entry);
|
||||
}
|
||||
// The two corners glue: each reports the gap it held (the known offset the two
|
||||
// squares put between them) and the growth the glue applied — the survivor's
|
||||
// tolerance rose by the same amount, the glue's rule keeping the closed distance
|
||||
// inside the surviving tolerance (solidcore#2975).
|
||||
let corners: Vec<_> = gaps
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
entry.kind,
|
||||
solidcore::heal::stitch::p2::ClosedGapKind::Vertex { .. }
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(corners.len(), 2, "{gaps:?}");
|
||||
for entry in corners {
|
||||
assert!(entry.gap >= (1.0 + gap) - 1.0, "{entry:?}");
|
||||
assert!(
|
||||
(entry.gap - gap).abs() <= 1e-9 * gap.max(1.0),
|
||||
"reported {entry:?} against the known {gap:.1e}"
|
||||
);
|
||||
assert!(
|
||||
(entry.growth - gap).abs() <= 1e-9 * gap.max(1.0),
|
||||
"growth {entry:?} against the known {gap:.1e}"
|
||||
);
|
||||
}
|
||||
// Gluing the vertices does not move the two underlying edge curves. Their
|
||||
// whole-curve gap is still the original horizontal separation.
|
||||
let shared = gaps
|
||||
.iter()
|
||||
.find(|entry| {
|
||||
matches!(
|
||||
entry.kind,
|
||||
solidcore::heal::stitch::p2::ClosedGapKind::Edge { .. }
|
||||
)
|
||||
})
|
||||
.expect("the shared edge closure");
|
||||
assert!(
|
||||
shared.gap >= (1.0 + gap) - 1.0 && shared.gap < gap + 1e-12,
|
||||
"{shared:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exact_match_reports_no_growth() {
|
||||
let mut k = Kernel::default();
|
||||
let mut b = new_brep(&mut k, USE_DEFAULT, USE_DEFAULT).unwrap();
|
||||
add_square(&mut k, &mut b, 1.0, 0.0);
|
||||
add_square(&mut k, &mut b, 0.0, 0.0);
|
||||
|
||||
let gaps = stitch_and_orient_reported(&mut k, &mut b, &StitchAndOrientOptions::default())
|
||||
.expect("the stitch runs");
|
||||
assert_eq!(
|
||||
gaps.len(),
|
||||
3,
|
||||
"two endpoint glues and their shared edge: {gaps:?}"
|
||||
);
|
||||
for gap in gaps {
|
||||
// Independently enclosing the two analytic line evaluations adds a
|
||||
// conservative rounding floor, even though the geometric boundary agrees.
|
||||
assert!(
|
||||
gap.gap.is_finite() && (0.0..=1e-12).contains(&gap.gap),
|
||||
"{gap:?}"
|
||||
);
|
||||
check_accounting(&gap);
|
||||
assert!(
|
||||
gap.growth == 0.0,
|
||||
"the exact match's closure applied no growth: {gap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interior_vertex_split_reports_the_original_curve_gap() {
|
||||
use solidcore::heal::stitch::p2::ClosedGapKind;
|
||||
let mut k = Kernel::default();
|
||||
let mut b = new_brep(&mut k, USE_DEFAULT, USE_DEFAULT).unwrap();
|
||||
let gap = 1e-5;
|
||||
add_square(&mut k, &mut b, 0.0, 0.0);
|
||||
add_square(&mut k, &mut b, 0.5, 1.0 + gap);
|
||||
let records =
|
||||
stitch_and_orient_reported(&mut k, &mut b, &StitchAndOrientOptions::default()).unwrap();
|
||||
let splits: Vec<_> = records
|
||||
.iter()
|
||||
.filter(|r| matches!(r.kind, ClosedGapKind::Split { .. }))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
splits.len(),
|
||||
2,
|
||||
"both ends of the partial shared boundary: {records:?}"
|
||||
);
|
||||
for entry in splits {
|
||||
assert!(
|
||||
entry.gap >= (1.0 + gap) - 1.0 && entry.gap < gap + 1e-12,
|
||||
"{entry:?}"
|
||||
);
|
||||
check_accounting(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two adjacent squares, with one shared edge represented by an exact cubic
|
||||
/// Hermite line. Stitching can compare it; this certificate does not support it.
|
||||
fn unsupported_shared_edge() -> (Kernel, Brep) {
|
||||
use solidcore::curve::hermite_curve::HermiteCurve;
|
||||
let mut k = Kernel::default();
|
||||
let mut b = new_brep(&mut k, USE_DEFAULT, USE_DEFAULT).unwrap();
|
||||
add_square(&mut k, &mut b, 0.0, 0.0);
|
||||
add_square(&mut k, &mut b, 1.0, 0.0);
|
||||
b.refresh_cached_topology();
|
||||
let ends: Vec<_> = b
|
||||
.edge_list()
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|e| {
|
||||
let data = b.edge(e).unwrap();
|
||||
let c = data.curve.as_ref().unwrap();
|
||||
let a = c.evaluate_point(&k, data.interval.min()).unwrap();
|
||||
let z = c.evaluate_point(&k, data.interval.max()).unwrap();
|
||||
(e, a, z)
|
||||
})
|
||||
.collect();
|
||||
let edge = ends
|
||||
.iter()
|
||||
.find(|(e, a, z)| {
|
||||
ends.iter()
|
||||
.any(|(other, p, q)| e != other && ((a == p && z == q) || (a == q && z == p)))
|
||||
})
|
||||
.expect("the exactly shared boundary")
|
||||
.0;
|
||||
let data = b.edge(edge).unwrap();
|
||||
let old = data.curve.as_ref().unwrap();
|
||||
let a = old.evaluate_point(&k, data.interval.min()).unwrap();
|
||||
let z = old.evaluate_point(&k, data.interval.max()).unwrap();
|
||||
let mut curve: solidcore::curve::curve::Curve = HermiteCurve::new(a, z - a, z, z - a, 3).into();
|
||||
curve.set_owner(
|
||||
old.owner(),
|
||||
Some(solidcore::topology::brep::p2::brep_ref(&b)),
|
||||
);
|
||||
b.edge_mut(edge).unwrap().curve = Some(curve);
|
||||
b.edge_mut(edge).unwrap().interval =
|
||||
solidcore::geom::extent1d::Extent1d::from_min_max(0.0, 1.0);
|
||||
(k, b)
|
||||
}
|
||||
|
||||
/// A deterministic binary record checks topology, geometry and stored tolerances;
|
||||
/// explicit id-list assertions below also cover the original arena handles.
|
||||
fn binary(k: &Kernel, b: &Brep) -> Vec<u8> {
|
||||
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
|
||||
use solidcore::topology::brep_data_write::FromBrepEnv;
|
||||
use solidcore::topology::topology::NotifyRecords;
|
||||
let attributes = AttributeStore::new();
|
||||
let records = NotifyRecords::new();
|
||||
let env = FromBrepEnv {
|
||||
attributes: &attributes,
|
||||
records: &records,
|
||||
key_of: &solidcore::topology::loopuse::notify_ref,
|
||||
curve_key: &|_| ObjectKey(0),
|
||||
uv_curve_key: &|_| ObjectKey(0),
|
||||
surface_key: &|_| ObjectKey(0),
|
||||
};
|
||||
solidcore::topology::api_files::write_brep_to_file(k, Some(b), &env, false).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificate_failure_propagates_and_rolls_back_earlier_closures_with_flags_off_or_on() {
|
||||
use solidcore::heal::stitch::p1::{DefaultStitchCallback, Stitch};
|
||||
use solidcore::heal::stitch::p2::{ClosedGapKind, MinGapRequest};
|
||||
use solidcore::SolidError;
|
||||
|
||||
// The unwrapped stitch demonstrates that failure really occurs AFTER the two
|
||||
// vertex glues. This is not merely a no-edit input-validation test.
|
||||
let (mut k, mut b) = unsupported_shared_edge();
|
||||
let mut callback = DefaultStitchCallback::new();
|
||||
let mut stitch = Stitch::new(&mut callback, b.tolerance() * 4.0, true, true);
|
||||
stitch.fast_edge_compare = true;
|
||||
stitch.report_gaps = Some(Vec::new());
|
||||
let failure = stitch
|
||||
.do_stitching(&mut k, &mut b, None, None, MinGapRequest::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
failure,
|
||||
SolidError::Unsupported {
|
||||
operation: "heal::stitch::bounds",
|
||||
..
|
||||
}
|
||||
),
|
||||
"{failure:?}"
|
||||
);
|
||||
let records = stitch.report_gaps.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
records
|
||||
.iter()
|
||||
.filter(|r| matches!(r.kind, ClosedGapKind::Vertex { .. }))
|
||||
.count(),
|
||||
2,
|
||||
"{records:?}"
|
||||
);
|
||||
b.refresh_cached_topology();
|
||||
assert_eq!(
|
||||
b.vertex_list().len(),
|
||||
6,
|
||||
"the direct stitch has already edited the operand"
|
||||
);
|
||||
|
||||
for statuses in [false, true] {
|
||||
let (mut k, mut b) = unsupported_shared_edge();
|
||||
k.fixes.brep_edit_statuses_pass_on = statuses;
|
||||
let before = binary(&k, &b);
|
||||
let ids = (
|
||||
b.face_list().to_vec(),
|
||||
b.edge_list().to_vec(),
|
||||
b.vertex_list().to_vec(),
|
||||
);
|
||||
let result = stitch_and_orient_reported(&mut k, &mut b, &StitchAndOrientOptions::default());
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(SolidError::Unsupported {
|
||||
operation: "heal::stitch::bounds",
|
||||
..
|
||||
})
|
||||
),
|
||||
"flag={statuses}: {result:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
binary(&k, &b),
|
||||
before,
|
||||
"flag={statuses}: the full operand must be restored"
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
b.face_list().to_vec(),
|
||||
b.edge_list().to_vec(),
|
||||
b.vertex_list().to_vec()
|
||||
),
|
||||
ids
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+595
@@ -0,0 +1,595 @@
|
||||
{
|
||||
"area": "booleans",
|
||||
"bodies": {
|
||||
"b14": {
|
||||
"keeping_splines": false,
|
||||
"trusted": true
|
||||
},
|
||||
"b4": {
|
||||
"keeping_splines": false,
|
||||
"trusted": true
|
||||
},
|
||||
"b5": {
|
||||
"keeping_splines": false,
|
||||
"trusted": true
|
||||
},
|
||||
"b6": {
|
||||
"keeping_splines": false,
|
||||
"trusted": true
|
||||
}
|
||||
},
|
||||
"calls": [
|
||||
{
|
||||
"inputs": [],
|
||||
"op": "extrude",
|
||||
"outputs": [
|
||||
"b1"
|
||||
],
|
||||
"params": {
|
||||
"cap": true,
|
||||
"corner": "curvature",
|
||||
"direction": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"distance": 124.0,
|
||||
"draft": 0.0,
|
||||
"profile": [
|
||||
{
|
||||
"arc": {
|
||||
"axis": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"center": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"end": 6.283185307179586,
|
||||
"radius": 16.5,
|
||||
"reference": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"start": 0.0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"op": "screw_sweep",
|
||||
"outputs": [
|
||||
"b2"
|
||||
],
|
||||
"params": {
|
||||
"direction": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"origin": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"pitch": -30.0,
|
||||
"profile": [
|
||||
{
|
||||
"line": {
|
||||
"end": [
|
||||
15.500000000033173,
|
||||
1.9916037001588853,
|
||||
124.0
|
||||
],
|
||||
"start": [
|
||||
16.082051957790647,
|
||||
2.5654660460962315,
|
||||
124.0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"arc": {
|
||||
"axis": [
|
||||
-0.0,
|
||||
-0.0,
|
||||
-1.0
|
||||
],
|
||||
"center": [
|
||||
0.0,
|
||||
0.0,
|
||||
124.0
|
||||
],
|
||||
"end": 0.1277903655261984,
|
||||
"radius": 15.627427341040864,
|
||||
"reference": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"start": -0.12779036552232945
|
||||
}
|
||||
},
|
||||
{
|
||||
"line": {
|
||||
"end": [
|
||||
16.08205195777746,
|
||||
-2.5654660462046173,
|
||||
124.0
|
||||
],
|
||||
"start": [
|
||||
15.50000000002603,
|
||||
-1.9916037002189337,
|
||||
124.0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"line": {
|
||||
"end": [
|
||||
16.222606424802095,
|
||||
-3.0128127705345857,
|
||||
124.0
|
||||
],
|
||||
"start": [
|
||||
16.08205195777746,
|
||||
-2.5654660462046173,
|
||||
124.0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"line": {
|
||||
"end": [
|
||||
17.205794692955955,
|
||||
-3.1954074838129767,
|
||||
124.0
|
||||
],
|
||||
"start": [
|
||||
16.222606424802095,
|
||||
-3.0128127705345857,
|
||||
124.0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"arc": {
|
||||
"axis": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"center": [
|
||||
0.0,
|
||||
0.0,
|
||||
124.0
|
||||
],
|
||||
"end": 0.1836248887672353,
|
||||
"radius": 17.50000000010735,
|
||||
"reference": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"start": -0.18362488876723534
|
||||
}
|
||||
},
|
||||
{
|
||||
"line": {
|
||||
"end": [
|
||||
16.222606424880645,
|
||||
3.012812770469558,
|
||||
124.0
|
||||
],
|
||||
"start": [
|
||||
17.205794692955955,
|
||||
3.1954074838129767,
|
||||
124.0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"line": {
|
||||
"end": [
|
||||
16.082051957790647,
|
||||
2.5654660460962315,
|
||||
124.0
|
||||
],
|
||||
"start": [
|
||||
16.222606424880645,
|
||||
3.012812770469558,
|
||||
124.0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"tolerance": 0.003,
|
||||
"turns": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"op": "screw_sweep",
|
||||
"outputs": [
|
||||
"b3"
|
||||
],
|
||||
"params": {
|
||||
"direction": [
|
||||
-0.0,
|
||||
-0.0,
|
||||
-1.0
|
||||
],
|
||||
"origin": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"pitch": 30.0,
|
||||
"profile": [
|
||||
{
|
||||
"line": {
|
||||
"end": [
|
||||
15.500000000033173,
|
||||
1.9916037001588853,
|
||||
124.0
|
||||
],
|
||||
"start": [
|
||||
16.082051957790647,
|
||||
2.5654660460962315,
|
||||
124.0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"arc": {
|
||||
"axis": [
|
||||
-0.0,
|
||||
-0.0,
|
||||
-1.0
|
||||
],
|
||||
"center": [
|
||||
0.0,
|
||||
0.0,
|
||||
124.0
|
||||
],
|
||||
"end": 0.1277903655261984,
|
||||
"radius": 15.627427341040864,
|
||||
"reference": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"start": -0.12779036552232945
|
||||
}
|
||||
},
|
||||
{
|
||||
"line": {
|
||||
"end": [
|
||||
16.08205195777746,
|
||||
-2.5654660462046173,
|
||||
124.0
|
||||
],
|
||||
"start": [
|
||||
15.50000000002603,
|
||||
-1.9916037002189337,
|
||||
124.0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"line": {
|
||||
"end": [
|
||||
16.222606424802095,
|
||||
-3.0128127705345857,
|
||||
124.0
|
||||
],
|
||||
"start": [
|
||||
16.08205195777746,
|
||||
-2.5654660462046173,
|
||||
124.0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"line": {
|
||||
"end": [
|
||||
17.205794692955955,
|
||||
-3.1954074838129767,
|
||||
124.0
|
||||
],
|
||||
"start": [
|
||||
16.222606424802095,
|
||||
-3.0128127705345857,
|
||||
124.0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"arc": {
|
||||
"axis": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"center": [
|
||||
0.0,
|
||||
0.0,
|
||||
124.0
|
||||
],
|
||||
"end": 0.1836248887672353,
|
||||
"radius": 17.50000000010735,
|
||||
"reference": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"start": -0.18362488876723534
|
||||
}
|
||||
},
|
||||
{
|
||||
"line": {
|
||||
"end": [
|
||||
16.222606424880645,
|
||||
3.012812770469558,
|
||||
124.0
|
||||
],
|
||||
"start": [
|
||||
17.205794692955955,
|
||||
3.1954074838129767,
|
||||
124.0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"line": {
|
||||
"end": [
|
||||
16.082051957790647,
|
||||
2.5654660460962315,
|
||||
124.0
|
||||
],
|
||||
"start": [
|
||||
16.222606424880645,
|
||||
3.012812770469558,
|
||||
124.0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"tolerance": 0.003,
|
||||
"turns": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
"b6"
|
||||
],
|
||||
"op": "copy",
|
||||
"outputs": [
|
||||
"b7"
|
||||
],
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
"b7"
|
||||
],
|
||||
"op": "copy",
|
||||
"outputs": [
|
||||
"b8"
|
||||
],
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
"b4"
|
||||
],
|
||||
"op": "copy",
|
||||
"outputs": [
|
||||
"b9"
|
||||
],
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
"b9"
|
||||
],
|
||||
"op": "copy",
|
||||
"outputs": [
|
||||
"b10"
|
||||
],
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
"b8"
|
||||
],
|
||||
"op": "tagged",
|
||||
"outputs": [
|
||||
"b11"
|
||||
],
|
||||
"params": {
|
||||
"operand": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
"b10"
|
||||
],
|
||||
"op": "tagged",
|
||||
"outputs": [
|
||||
"b12"
|
||||
],
|
||||
"params": {
|
||||
"operand": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
"b11",
|
||||
"b12"
|
||||
],
|
||||
"op": "boolean",
|
||||
"outputs": [
|
||||
"b13"
|
||||
],
|
||||
"params": {
|
||||
"op": "subtract"
|
||||
}
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
"b14"
|
||||
],
|
||||
"op": "copy",
|
||||
"outputs": [
|
||||
"b15"
|
||||
],
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
"b15"
|
||||
],
|
||||
"op": "copy",
|
||||
"outputs": [
|
||||
"b16"
|
||||
],
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
"b5"
|
||||
],
|
||||
"op": "copy",
|
||||
"outputs": [
|
||||
"b17"
|
||||
],
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
"b17"
|
||||
],
|
||||
"op": "copy",
|
||||
"outputs": [
|
||||
"b18"
|
||||
],
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
"b16"
|
||||
],
|
||||
"op": "tagged",
|
||||
"outputs": [
|
||||
"b19"
|
||||
],
|
||||
"params": {
|
||||
"operand": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
"b18"
|
||||
],
|
||||
"op": "tagged",
|
||||
"outputs": [
|
||||
"b20"
|
||||
],
|
||||
"params": {
|
||||
"operand": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
"b19",
|
||||
"b20"
|
||||
],
|
||||
"op": "boolean",
|
||||
"outputs": [
|
||||
"b21"
|
||||
],
|
||||
"params": {
|
||||
"op": "subtract"
|
||||
}
|
||||
}
|
||||
],
|
||||
"case": "13462-rod-cut-by-two-counter-wound-channel-coils",
|
||||
"expected": {
|
||||
"body_count": 1,
|
||||
"method": "Each horizontal section of a screw sweep of a profile D in a plane normal to the axis is one rotated copy of D. With D_in = D n disk(16.5), A_in its area, one sweep removes A_in*|p|; the two sweeps' sections at one height are D_in turned by +t and -t, whose overlap integrates over the turn to (|p|/2pi)*Int s(r)^2 r dr, s(r) the angle D_in covers on the circle of radius r. s(r) found exactly from the circle's crossings with the profile's lines and axis-centred arcs, integrated by adaptive Gauss-Kronrod: A_in = 4.214046963094, Int s^2 r dr = 1.279023422464. V = pi*16.5^2*124 - 2*A_in*30 + (30/2pi)*Int s^2 r dr. The one-sweep form agrees with a single cut to 3e-12. Tolerance 1e-6: the sweeps' walls are within 3e-3 of the screw surfaces (|p|*1e-4), which moves the two sweeps' overlap by about 2e-3 mm^3 (1.6e-8 here)",
|
||||
"source": "analytic",
|
||||
"sources": [
|
||||
"tangency-labs/solidcraft#13462",
|
||||
"tangency-labs/solidcore#3214"
|
||||
],
|
||||
"tolerance": {
|
||||
"rel": 1e-06
|
||||
},
|
||||
"volume_mm3": 105810.29046175262,
|
||||
"what": "a rod (r 16.5, 124 long) cut by two one-turn screw sweeps of one channel profile, one right-handed and one left-handed, pitch 30, both starting on the rod's top face: the channel's two radial walls meet the rod's surface, so each sweep's rail on it is a helix there and the two rails cross. The cut's new edge along a crossing rail took an interval its UV curve's trim had snapped 2.4e-12 past its 3D curve's end ('SmEdge test 2, Bad Domain')"
|
||||
},
|
||||
"expressible": true,
|
||||
"operation_expressible": true,
|
||||
"outcome": {
|
||||
"ran": true,
|
||||
"raw_counts": {
|
||||
"edges": 60,
|
||||
"faces": 30,
|
||||
"vertices": 32
|
||||
}
|
||||
},
|
||||
"records": "13462-rod-cut-by-two-counter-wound-channel-coils.bodies.json.gz",
|
||||
"result": [
|
||||
{
|
||||
"body": "Rod",
|
||||
"made": "b21"
|
||||
}
|
||||
],
|
||||
"rig": {
|
||||
"error": null,
|
||||
"known_failing": null,
|
||||
"measured": {
|
||||
"area_mm2": 14815.578874890229,
|
||||
"body_count": 1,
|
||||
"edges": 55,
|
||||
"faces": 25,
|
||||
"vertices": 32,
|
||||
"volume_mm3": 105810.28872357338
|
||||
},
|
||||
"refused_with": null,
|
||||
"status": "pass"
|
||||
},
|
||||
"sources": [
|
||||
"tangency-labs/solidcraft#13462",
|
||||
"tangency-labs/solidcore#3214"
|
||||
],
|
||||
"traced_ops": [
|
||||
"extrude",
|
||||
"screw_sweep",
|
||||
"screw_sweep",
|
||||
"copy",
|
||||
"copy",
|
||||
"copy",
|
||||
"copy",
|
||||
"tagged",
|
||||
"tagged",
|
||||
"boolean",
|
||||
"copy",
|
||||
"copy",
|
||||
"copy",
|
||||
"copy",
|
||||
"tagged",
|
||||
"tagged",
|
||||
"boolean"
|
||||
],
|
||||
"ungraded": [],
|
||||
"what": "a rod (r 16.5, 124 long) cut by two one-turn screw sweeps of one channel profile, one right-handed and one left-handed, pitch 30, both starting on the rod's top face: the channel's two radial walls meet the rod's surface, so each sweep's rail on it is a helix there and the two rails cross. The cut's new edge along a crossing rail took an interval its UV curve's trim had snapped 2.4e-12 past its 3D curve's end ('SmEdge test 2, Bad Domain')",
|
||||
"why_not": []
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 16 KiB |
+1
-1
@@ -128,5 +128,5 @@
|
||||
],
|
||||
"what": "the top rim of a cylinder r10 x 20 chamfered 2 across the top at 90.573 deg from it: refused",
|
||||
"why_not": [],
|
||||
"gallery_note": "at 90.573 degrees from the top the chamfer's line runs out through the wall: it meets the wall only below 90 degrees, pi less the wedge. The kernel refuses with the angle and that bound (BlendRefusal::ChamferAngleTooLarge). The new image shows the input at the top rim. The application currently reports generic invalid-input text, so its angle-wording check still fails. The inset shows the whole part."
|
||||
"gallery_note": "at 90.573 degrees from the top the chamfer's line runs out through the wall: it meets the wall only below 90 degrees, pi less the wedge. The kernel refuses with the angle and that bound (BlendRefusal::ChamferAngleTooLarge). The picture is the operand the refusal leaves; the chamfer would run round its top rim."
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 22 KiB |
Vendored
+1
-1
@@ -98,7 +98,7 @@
|
||||
"what": "a convex r10.01 round on an edge whose top face is 10 mm deep, 0.01 past it: it must build by subtraction (the round on the extended faces cut from the body), not be refused"
|
||||
},
|
||||
"expressible": true,
|
||||
"gallery_note": "The new view looks toward the bar end at x = 40, showing the overflowing R10.01 section and the consumed 10 mm top face. The inset shows the whole part.",
|
||||
"gallery_note": "R10.01 is 0.01 deeper than the 10 mm top face, so the round uses the top face up: the bar keeps its front, back, bottom and ends, and the round runs from z = 9.99 on the front over to the back face.",
|
||||
"operation_expressible": true,
|
||||
"outcome": {
|
||||
"ran": true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user