mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 22:24:17 +01:00
Compare commits
693 Commits
v0.6.12
...
2cff1b7c9e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cff1b7c9e | ||
|
|
4b4d63b289 | ||
|
|
58f7453b0b | ||
|
|
07cec00b1a | ||
|
|
4ed83e32a4 | ||
|
|
ea1cd46d22 | ||
|
|
8092075422 | ||
|
|
65cc7144fa | ||
|
|
3ca1544fc0 | ||
|
|
d63e274bb6 | ||
|
|
617f83f6f1 | ||
|
|
76df2f5ca5 | ||
|
|
b7f26dbfcb | ||
|
|
4c4c8c766c | ||
|
|
1c414d920d | ||
|
|
6dba535906 | ||
|
|
eaf09203da | ||
|
|
2393080240 | ||
|
|
df890b798c | ||
|
|
cde0e6137b | ||
|
|
8a8cc8e5a8 | ||
|
|
7863611a51 | ||
|
|
367fa1b4e4 | ||
|
|
7b553b635b | ||
|
|
24cf291cdd | ||
|
|
9c133a6d50 | ||
|
|
d44ea072d5 | ||
|
|
014622ffaa | ||
|
|
23b50c38ca | ||
|
|
8154ac9502 | ||
|
|
aaac6d4245 | ||
|
|
91f5084532 | ||
|
|
e957e0a937 | ||
|
|
e480d297e8 | ||
|
|
4439647faa | ||
|
|
4f2c9eda11 | ||
|
|
ad8e75d1da | ||
|
|
14022f170c | ||
|
|
6302fc023c | ||
|
|
3685ed13c0 | ||
|
|
b2c815daf7 | ||
|
|
630bb7d90b | ||
|
|
aa2c2e570b | ||
|
|
5855458ff0 | ||
|
|
41170118c3 | ||
|
|
efa689bfe4 | ||
|
|
971a791617 | ||
|
|
1d1228e044 | ||
|
|
55ba73a635 | ||
|
|
b51135ba39 | ||
|
|
95755f6ad3 | ||
|
|
82efea0786 | ||
|
|
0a66e62575 | ||
|
|
4f6bdbe1ad | ||
|
|
92d09d52d3 | ||
|
|
d012d68cfc | ||
|
|
496df80e26 | ||
|
|
8df15a9cad | ||
|
|
03eac030c4 | ||
|
|
8f49a5deee | ||
|
|
070488f8c6 | ||
|
|
44fb50869e | ||
|
|
c8e1ba5f07 | ||
|
|
cb2e09a6c2 | ||
|
|
daf9fb16f7 | ||
|
|
98bc0407b3 | ||
|
|
57bcd52944 | ||
|
|
ce03ea001d | ||
|
|
b6a2bde5e1 | ||
|
|
9079ccc0b3 | ||
|
|
aed7680d48 | ||
|
|
6fc4f922fd | ||
|
|
e5ee7a45be | ||
|
|
c9c3fe5bd9 | ||
|
|
cc39079c70 | ||
|
|
a52e503e81 | ||
|
|
dd5c4253a7 | ||
|
|
209b40e9a7 | ||
|
|
96c5c8a63f | ||
|
|
0aa761dcf4 | ||
|
|
be9f3994a8 | ||
|
|
ed1296f858 | ||
|
|
1c908bdfba | ||
|
|
7434652379 | ||
|
|
f59e3048d4 | ||
|
|
ed2431f7da | ||
|
|
c4d47afe69 | ||
|
|
ec91ab90f2 | ||
|
|
3f573c75cc | ||
|
|
4eee767afb | ||
|
|
3f9ec31799 | ||
|
|
54a22b1499 | ||
|
|
438d315b53 | ||
|
|
0cc6d81e9e | ||
|
|
2468aeed08 | ||
|
|
a8ec19e2d4 | ||
|
|
741a658b01 | ||
|
|
19663354cc | ||
|
|
e5f2246ff1 | ||
|
|
4cb8a65582 | ||
|
|
fc737583fd | ||
|
|
37ddf9ab63 | ||
|
|
5977172096 | ||
|
|
888239d898 | ||
|
|
fdfc5cdcad | ||
|
|
e2707ac8db | ||
|
|
f0a55efff1 | ||
|
|
71e95ec393 | ||
|
|
454a7249cd | ||
|
|
1501f8c3b5 | ||
|
|
32d34d9ec4 | ||
|
|
9b3b679631 | ||
|
|
dd389f34a3 | ||
|
|
21cdaf2c33 | ||
|
|
ace4d5aa5e | ||
|
|
289677a4ac | ||
|
|
a97c2807d8 | ||
|
|
629aa939ca | ||
|
|
e21fa7d680 | ||
|
|
3933d4842b | ||
|
|
95ffb769e2 | ||
|
|
16627a3cd4 | ||
|
|
0e1f6b7aa0 | ||
|
|
0ff8776a12 | ||
|
|
64e9e26da1 | ||
|
|
7eaba92057 | ||
|
|
6a0aa265b6 | ||
|
|
fbf2f3e9a1 | ||
|
|
74491fc867 | ||
|
|
cc0b087cf7 | ||
|
|
fcacf8172d | ||
|
|
0c93a7e05d | ||
|
|
e50e3c7f09 | ||
|
|
9f76df8668 | ||
|
|
6c7f7382f6 | ||
|
|
c7aee20aac | ||
|
|
8598f7f75e | ||
|
|
7a8ff04cc7 | ||
|
|
ac8bf8cb06 | ||
|
|
7a425e3ccc | ||
|
|
ff3c3d6f2f | ||
|
|
8792ab7139 | ||
|
|
291e3c726d | ||
|
|
be400fde29 | ||
|
|
32fb3230bb | ||
|
|
a63175dc3a | ||
|
|
3c013de8ba | ||
|
|
a659b0561f | ||
|
|
ccd48bcae3 | ||
|
|
1a9cac08cd | ||
|
|
3fb07300b8 | ||
|
|
938c554919 | ||
|
|
5308ececae | ||
|
|
64e2622954 | ||
|
|
2e8b8bd65f | ||
|
|
854de26695 | ||
|
|
e0503983f1 | ||
|
|
021cfd89a1 | ||
|
|
9d96effb30 | ||
|
|
dff2d36b44 | ||
|
|
810647276b | ||
|
|
a54b91f704 | ||
|
|
099726035a | ||
|
|
c383b8573a | ||
|
|
f1c0a21699 | ||
|
|
8df0401fef | ||
|
|
2c7adf8188 | ||
|
|
d2b1b3b952 | ||
|
|
db3bbf2d32 | ||
|
|
1d6230ef1e | ||
|
|
7d22ca465e | ||
|
|
7c38ccc522 | ||
|
|
06a95d4875 | ||
|
|
56fc556c0f | ||
|
|
fe54f48ad7 | ||
|
|
c47f961b0d | ||
|
|
f86b714c08 | ||
|
|
2ae7c86f8d | ||
|
|
0908216bfc | ||
|
|
7871a5070d | ||
|
|
5173f00d29 | ||
|
|
c2ef910783 | ||
|
|
5a5f948224 | ||
|
|
dbeae5632e | ||
|
|
59116e4a1a | ||
|
|
0be0103c3c | ||
|
|
d6deef9e7f | ||
|
|
ad956762f4 | ||
|
|
a4acd2f3d3 | ||
|
|
c877fef490 | ||
|
|
0855a9c6c7 | ||
|
|
657b34d098 | ||
|
|
ad83f1c937 | ||
|
|
38be4c638b | ||
|
|
63f2b716d6 | ||
|
|
9e70a3998d | ||
|
|
c7c4adc250 | ||
|
|
393decd876 | ||
|
|
111f417905 | ||
|
|
7296b15a67 | ||
|
|
88190b0d1e | ||
|
|
f2a607bb00 | ||
|
|
0b6ed2fc34 | ||
|
|
d1e5658e07 | ||
|
|
3545785cae | ||
|
|
1df938b067 | ||
|
|
42ae6384f0 | ||
|
|
b10b228fcc | ||
|
|
ffaf6f26ba | ||
|
|
17357aef25 | ||
|
|
0e6118ed94 | ||
|
|
8a435b5f1a | ||
|
|
a63d018850 | ||
|
|
0460e939e4 | ||
|
|
b54453ca06 | ||
|
|
cc4e3f427e | ||
|
|
fc32393635 | ||
|
|
ae2a5f9bb0 | ||
|
|
46cf45e4ce | ||
|
|
3423c99f8d | ||
|
|
667a08d2c6 | ||
|
|
1733337331 | ||
|
|
0c51b614e9 | ||
|
|
9ef372f576 | ||
|
|
a2275f2897 | ||
|
|
b597b1e3a5 | ||
|
|
4af4315984 | ||
|
|
83bc4c767d | ||
|
|
10ed5e2e9e | ||
|
|
41662944d6 | ||
|
|
7f00a3a4ee | ||
|
|
7549c2543b | ||
|
|
fb7a525a71 | ||
|
|
c14a823dc1 | ||
|
|
3845720d53 | ||
|
|
16c109f399 | ||
|
|
7b58b00231 | ||
|
|
05a159ac6c | ||
|
|
458f81f51d | ||
|
|
f3545bebb8 | ||
|
|
90d05f08c4 | ||
|
|
5b4865978f | ||
|
|
9ec2de8e4a | ||
|
|
0c98c273b8 | ||
|
|
014a68c33d | ||
|
|
16bf550f75 | ||
|
|
f4a74c2f1c | ||
|
|
1fddd984aa | ||
|
|
5809b88cee | ||
|
|
5cd7fb5d9d | ||
|
|
dbfc72c03b | ||
|
|
437e8eb4ad | ||
|
|
7051a71710 | ||
|
|
28282f4b06 | ||
|
|
7f43367459 | ||
|
|
ba64f70300 | ||
|
|
05963d58f2 | ||
|
|
405c061f93 | ||
|
|
e0a00b2d32 | ||
|
|
90f4611ee8 | ||
|
|
ee954622bb | ||
|
|
f5be0618ad | ||
|
|
f92701a969 | ||
|
|
949f92a28f | ||
|
|
7618581ef5 | ||
|
|
5460d19fdb | ||
|
|
f84292e4b7 | ||
|
|
b597223228 | ||
|
|
8ec9a3f507 | ||
|
|
0ec339283e | ||
|
|
1d50dc20ba | ||
|
|
35331746b1 | ||
|
|
287f76c0ad | ||
|
|
dadb2b7a39 | ||
|
|
722432dfb0 | ||
|
|
a9184b224f | ||
|
|
3c732d7c85 | ||
|
|
d8fa1f1e0b | ||
|
|
1b03699677 | ||
|
|
7f6f777b47 | ||
|
|
68fb3b1a67 | ||
|
|
cd4a1d523b | ||
|
|
84cd6ba710 | ||
|
|
aa8492cd3d | ||
|
|
6f8710bd7a | ||
|
|
8b175b369a | ||
|
|
0be05967ca | ||
|
|
51f6165290 | ||
|
|
f147c83604 | ||
|
|
881de107c7 | ||
|
|
6b533dfbe1 | ||
|
|
2e3fb8beae | ||
|
|
e8baf5cd52 | ||
|
|
6c0289208f | ||
|
|
496f90fb56 | ||
|
|
696333e105 | ||
|
|
099a7aa972 | ||
|
|
b2aa568eb2 | ||
|
|
e685c8adcf | ||
|
|
19ddb670db | ||
|
|
8f1cd3e203 | ||
|
|
6b0fcd743f | ||
|
|
1d275936a4 | ||
|
|
f7c28a42c0 | ||
|
|
fa54829328 | ||
|
|
c8a7a3ac67 | ||
|
|
791b2608c5 | ||
|
|
ba8e40bc48 | ||
|
|
712626e791 | ||
|
|
734387a866 | ||
|
|
0988db9269 | ||
|
|
97d620b982 | ||
|
|
857d0a602f | ||
|
|
23f8aa8bdd | ||
|
|
03a04bd3fd | ||
|
|
57f91d046f | ||
|
|
758bc9b874 | ||
|
|
490a86796c | ||
|
|
f44270d913 | ||
|
|
4eed1b063e | ||
|
|
e3fe562d53 | ||
|
|
84ba81a5e5 | ||
|
|
d469f5da40 | ||
|
|
98e8de75dd | ||
|
|
e8d7985dba | ||
|
|
6aa680bf43 | ||
|
|
b629f07f5e | ||
|
|
9e4f571808 | ||
|
|
eaad9f3774 | ||
|
|
79f6359af1 | ||
|
|
ca5e9e03ab | ||
|
|
2ebdee2d42 | ||
|
|
c1773fb156 | ||
|
|
e05cc70f3a | ||
|
|
7d97ed5e93 | ||
|
|
823ee5462f | ||
|
|
64aebb5cf7 | ||
|
|
979713bb48 | ||
|
|
3b8efd628c | ||
|
|
f91033ef3d | ||
|
|
0f881a6de5 | ||
|
|
fe919f539e | ||
|
|
0b2efb006c | ||
|
|
7584af683e | ||
|
|
64358469ae | ||
|
|
28ad995982 | ||
|
|
3efb47fef1 | ||
|
|
1ca655f2f4 | ||
|
|
4fd6fc6e23 | ||
|
|
b6d21e7ef1 | ||
|
|
fb484b61da | ||
|
|
1ebfa3dd3f | ||
|
|
35bfb8679a | ||
|
|
dce407ca2b | ||
|
|
f9e90bee25 | ||
|
|
ea44096200 | ||
|
|
f3d19911c7 | ||
|
|
2214a4812c | ||
|
|
8b08afc1ea | ||
|
|
4d77723fe6 | ||
|
|
bd0364fcea | ||
|
|
3b466c0291 | ||
|
|
ade6faa6a3 | ||
|
|
c88959cc11 | ||
|
|
1e7fe81a60 | ||
|
|
726e3927f4 | ||
|
|
5f6b6d7ba0 | ||
|
|
62bc825bc8 | ||
|
|
de789ad8a9 | ||
|
|
e657acaee3 | ||
|
|
ba94f64ca0 | ||
|
|
26722781d2 | ||
|
|
2434df71aa | ||
|
|
f02d561d8b | ||
|
|
73c8715517 | ||
|
|
1a0a8cae67 | ||
|
|
a00665a3a1 | ||
|
|
126d514ee3 | ||
|
|
c0014f989f | ||
|
|
874413c3c2 | ||
|
|
bff2140b9f | ||
|
|
0aa20c7e8e | ||
|
|
8bf29b18da | ||
|
|
8330060bd9 | ||
|
|
b09d5a87fa | ||
|
|
b976976cda | ||
|
|
2a82d37da1 | ||
|
|
632f09382f | ||
|
|
558cc306e2 | ||
|
|
f1476befe2 | ||
|
|
cb386f0020 | ||
|
|
ef37fad7fc | ||
|
|
f42fa9515b | ||
|
|
fca2018279 | ||
|
|
1e0e755b0e | ||
|
|
757007ad7b | ||
|
|
bc2cc76b29 | ||
|
|
6cc997d4ee | ||
|
|
a2f24be087 | ||
|
|
dd38a66d2f | ||
|
|
c8c6927962 | ||
|
|
be7c4e3c8e | ||
|
|
c20849222c | ||
|
|
1e0cee46cd | ||
|
|
77f6a0ae89 | ||
|
|
b9bff03db1 | ||
|
|
74126b54ab | ||
|
|
48e81bc3bc | ||
|
|
0f98f0d1a3 | ||
|
|
494b20ba15 | ||
|
|
13886186c0 | ||
|
|
1611a8dded | ||
|
|
24fb41bb77 | ||
|
|
8977963b58 | ||
|
|
b5f50b1a5f | ||
|
|
0f52a0b6c6 | ||
|
|
9a783750b7 | ||
|
|
ec083fb951 | ||
|
|
e894dbb997 | ||
|
|
b36bc2523c | ||
|
|
0c0db36718 | ||
|
|
4343c52466 | ||
|
|
ea181dac0b | ||
|
|
09525daae8 | ||
|
|
874f9e8d6e | ||
|
|
822672c288 | ||
|
|
383586c5a4 | ||
|
|
b4d557534d | ||
|
|
e6921144fb | ||
|
|
f95509f4f4 | ||
|
|
5a0228d4fc | ||
|
|
5c96e4e44b | ||
|
|
4012bac31f | ||
|
|
4439c436fc | ||
|
|
b23119e44c | ||
|
|
e428d9cb5a | ||
|
|
6574db6f81 | ||
|
|
844d25483f | ||
|
|
2f6e7def42 | ||
|
|
dbf29c02a9 | ||
|
|
8303dd2d37 | ||
|
|
3e24a29e73 | ||
|
|
e3b32a7e92 | ||
|
|
20def41a6c | ||
|
|
aff3a367e6 | ||
|
|
80c94fec80 | ||
|
|
2d8d8178ba | ||
|
|
6ac1365e27 | ||
|
|
ef3938ca4a | ||
|
|
334a23c04d | ||
|
|
57be98ede3 | ||
|
|
52f97963f0 | ||
|
|
61a3f157ac | ||
|
|
bf4bf38dd2 | ||
|
|
4119656f69 | ||
|
|
3834e8d506 | ||
|
|
b4380d3a2f | ||
|
|
22ea0617e2 | ||
|
|
09c0994a34 | ||
|
|
0939e25da2 | ||
|
|
712e0a06e6 | ||
|
|
e73f745800 | ||
|
|
773ea797a1 | ||
|
|
aae20aeedf | ||
|
|
a3004e59fb | ||
|
|
5e76193f43 | ||
|
|
50a2627b55 | ||
|
|
cbddacb7b8 | ||
|
|
887e71b7b2 | ||
|
|
7c5bd2aea0 | ||
|
|
b9c3b34416 | ||
|
|
b2f9880fd0 | ||
|
|
4b0fdd3459 | ||
|
|
503501df4b | ||
|
|
62fa310ec7 | ||
|
|
83cc12e59d | ||
|
|
016db36c50 | ||
|
|
583c244769 | ||
|
|
79919041f2 | ||
|
|
263671b4a9 | ||
|
|
e3fc8f1e74 | ||
|
|
7b63f574a1 | ||
|
|
56929ca136 | ||
|
|
dda5abfc94 | ||
|
|
3f9971a171 | ||
|
|
f4326c5779 | ||
|
|
96e73c0e7f | ||
|
|
2f1d1d51b3 | ||
|
|
fb3027fe66 | ||
|
|
d2b1e04e88 | ||
|
|
8c75f02c57 | ||
|
|
68317b32e4 | ||
|
|
49f91383a0 | ||
|
|
15b0636df9 | ||
|
|
e880a9f53a | ||
|
|
0ab8126e18 | ||
|
|
d9da038a06 | ||
|
|
186ca9d5bc | ||
|
|
739ce7f6c3 | ||
|
|
ba42fcfbd4 | ||
|
|
a0144a5432 | ||
|
|
66fd788398 | ||
|
|
9d2166dbe6 | ||
|
|
66396dfaee | ||
|
|
c539538d74 | ||
|
|
7d0ea24e23 | ||
|
|
c7ec469796 | ||
|
|
0c78964316 | ||
|
|
04c18e5198 | ||
|
|
25f0c04915 | ||
|
|
723efa232e | ||
|
|
49d154eeb0 | ||
|
|
5892c454d3 | ||
|
|
08d89836da | ||
|
|
e5e9493b35 | ||
|
|
775f5d3700 | ||
|
|
1ae0e508a8 | ||
|
|
2c5d73e957 | ||
|
|
6e0afef6bf | ||
|
|
4884030bd6 | ||
|
|
64ce65a568 | ||
|
|
3982706365 | ||
|
|
e71ebd75b3 | ||
|
|
c013916299 | ||
|
|
3787625230 | ||
|
|
ff712be60a | ||
|
|
ab0c1b1482 | ||
|
|
32246a890e | ||
|
|
72afb4b439 | ||
|
|
1b2312f0e5 | ||
|
|
87c6b12365 | ||
|
|
3b4982bb24 | ||
|
|
673814e1dd | ||
|
|
4b59ef2d1a | ||
|
|
cdec62f5b4 | ||
|
|
48ef6d2470 | ||
|
|
74fcc1b6b6 | ||
|
|
36da97e8c8 | ||
|
|
6a8ca13de3 | ||
|
|
60de0833b5 | ||
|
|
263a8e0c14 | ||
|
|
4d825ed763 | ||
|
|
5e3841186b | ||
|
|
18f22407c9 | ||
|
|
14817b6505 | ||
|
|
82b5e305d9 | ||
|
|
da94809cd2 | ||
|
|
933ec17293 | ||
|
|
dc91e4b497 | ||
|
|
9e740db791 | ||
|
|
c26cb78cc4 | ||
|
|
24c892c061 | ||
|
|
ec63c9efdc | ||
|
|
6f90e3c448 | ||
|
|
2205901752 | ||
|
|
ad5bb1b8a3 | ||
|
|
8ad0ffa147 | ||
|
|
96dbda0374 | ||
|
|
90c03ea99a | ||
|
|
50a7efa4ac | ||
|
|
dd6154955c | ||
|
|
d3847aae12 | ||
|
|
c74b695907 | ||
|
|
c720a8c03e | ||
|
|
a05c19c25e | ||
|
|
9437ccd987 | ||
|
|
20300ca2d9 | ||
|
|
42d0e0e158 | ||
|
|
4cd3e0ebfb | ||
|
|
83d34e5f21 | ||
|
|
94e7878c86 | ||
|
|
56b8dbadf7 | ||
|
|
a54873314e | ||
|
|
c7d672875e | ||
|
|
9d261f1718 | ||
|
|
db0f139cf8 | ||
|
|
3b394d5325 | ||
|
|
1d4e9efc5c | ||
|
|
097b17386b | ||
|
|
c65b871404 | ||
|
|
64a2c9e630 | ||
|
|
62aa53be27 | ||
|
|
6c9cba552e | ||
|
|
624fcb6fcf | ||
|
|
835d72d6dc | ||
|
|
4c0a752b3b | ||
|
|
f6b6039c5f | ||
|
|
ad7caae417 | ||
|
|
c96b910f5e | ||
|
|
8c5dc4fefd | ||
|
|
fd824f611f | ||
|
|
66fd1d88c7 | ||
|
|
3539918b12 | ||
|
|
a385ff9cb5 | ||
|
|
a80cfffcda | ||
|
|
4f0da2bee1 | ||
|
|
62808e3e52 | ||
|
|
2f901f78c1 | ||
|
|
9fa15f1a49 | ||
|
|
01431bc46c | ||
|
|
073f9086e0 | ||
|
|
36f9b09bd7 | ||
|
|
135023c950 | ||
|
|
dcc206ec78 | ||
|
|
e8a23f2ef1 | ||
|
|
35f8c047df | ||
|
|
0b670896f4 | ||
|
|
d5a9f6a1f5 | ||
|
|
258bd831b3 | ||
|
|
507f5141df | ||
|
|
31bd324d0f | ||
|
|
a1bd159a84 | ||
|
|
b472f1adfb | ||
|
|
9fd6a81d98 | ||
|
|
14d51a4cb0 | ||
|
|
456b593ad0 | ||
|
|
9af50dccd6 | ||
|
|
21e85b1ce6 | ||
|
|
1f30c2fd0a | ||
|
|
ba05a8072b | ||
|
|
27f540be23 | ||
|
|
e8c0f683c5 | ||
|
|
345636e478 | ||
|
|
9a0fb03526 | ||
|
|
2037621afc | ||
|
|
2ba0e18479 | ||
|
|
eca2bbfa7c | ||
|
|
86180c424e | ||
|
|
e42d6be074 | ||
|
|
e2d6a3cb00 | ||
|
|
9e453b7890 | ||
|
|
0b8faa9e8b | ||
|
|
00bc2a15e0 | ||
|
|
432abcf85c | ||
|
|
4b6d3f6266 | ||
|
|
255ae72ed2 | ||
|
|
77dd9fb43e | ||
|
|
5dc2ae2f8d | ||
|
|
58440723bd | ||
|
|
bfdd656316 | ||
|
|
7408823c02 | ||
|
|
856ffbc4c5 | ||
|
|
d0f8463bbf | ||
|
|
162d3e22a2 | ||
|
|
4b06559ab8 | ||
|
|
9afa2e5786 | ||
|
|
7196fb2f32 | ||
|
|
8ec60faa04 | ||
|
|
13bbdd5956 | ||
|
|
3675d2f447 | ||
|
|
efc7a1d3b6 | ||
|
|
7166f9fe3d | ||
|
|
3405de38e7 | ||
|
|
2bd927f2a8 | ||
|
|
9718172fdd | ||
|
|
472a7a8309 | ||
|
|
7a7627f57e | ||
|
|
064d9aeb35 | ||
|
|
eed0baccac | ||
|
|
72480d82c8 | ||
|
|
3de710c8b5 | ||
|
|
8ebf48cb36 | ||
|
|
5083037b73 | ||
|
|
632e7e93c6 | ||
|
|
ec7139c809 | ||
|
|
752d6dc778 | ||
|
|
6d60b7b003 | ||
|
|
675e2e970a | ||
|
|
70bef528c5 | ||
|
|
78319fead5 | ||
|
|
6944f69110 | ||
|
|
1d01c75448 | ||
|
|
cb0a7bdf0c | ||
|
|
a7dba6fd1b | ||
|
|
981d923e5e | ||
|
|
6f95a2f3ad | ||
|
|
63c74461b2 | ||
|
|
e85dc36fea | ||
|
|
43d30b0fdd | ||
|
|
acba91322c | ||
|
|
ba7ce3727d | ||
|
|
d168806744 | ||
|
|
919c05eb9d | ||
|
|
2370fd72ed | ||
|
|
aef047a658 | ||
|
|
d5cdd094e8 | ||
|
|
9c71573934 | ||
|
|
8fc5ed7544 | ||
|
|
1fd932dbc6 | ||
|
|
539ac40e3d | ||
|
|
9c2656d7db | ||
|
|
161d76ee69 | ||
|
|
431c41a615 |
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"append": [
|
"append": [
|
||||||
"assets/fox.glb.license",
|
"assets/fox.glb.license",
|
||||||
|
"assets/qwantani_afternoon_1k_hdr.jpg.license",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"replace": {
|
"replace": {
|
||||||
|
|||||||
26
.github/dependabot.yml
vendored
26
.github/dependabot.yml
vendored
@@ -1,26 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: "pip"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
day: "saturday"
|
|
||||||
time: "09:00"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
day: "saturday"
|
|
||||||
time: "09:00"
|
|
||||||
- package-ecosystem: "pip"
|
|
||||||
directory: "/example"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
day: "saturday"
|
|
||||||
time: "09:00"
|
|
||||||
- package-ecosystem: "github-actions"
|
|
||||||
directory: "/.github/workflows/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
day: "saturday"
|
|
||||||
time: "09:00"
|
|
||||||
14
.github/renovate.json5
vendored
Normal file
14
.github/renovate.json5
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended",
|
||||||
|
":disableDependencyDashboard"
|
||||||
|
],
|
||||||
|
"automerge": true,
|
||||||
|
"automergeType": "branch",
|
||||||
|
"schedule": [ "* * * * 0,6" ],
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true,
|
||||||
|
"schedule": [ "* * * * 0,6" ]
|
||||||
|
}
|
||||||
|
}
|
||||||
35
.github/workflows/autoupdate.yml
vendored
35
.github/workflows/autoupdate.yml
vendored
@@ -1,35 +0,0 @@
|
|||||||
on: "pull_request_target"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pull-requests: "write"
|
|
||||||
contents: "write"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
dependabot:
|
|
||||||
runs-on: "ubuntu-latest"
|
|
||||||
# Checking the actor will prevent your Action run failing on non-Dependabot
|
|
||||||
# PRs but also ensures that it only does work for Dependabot PRs.
|
|
||||||
if: "${{ github.actor == 'dependabot[bot]' }}"
|
|
||||||
steps:
|
|
||||||
# This first step will fail if there's no metadata and so the approval
|
|
||||||
# will not occur.
|
|
||||||
- name: "Dependabot metadata"
|
|
||||||
id: "dependabot-metadata"
|
|
||||||
uses: "dependabot/fetch-metadata@v1"
|
|
||||||
with:
|
|
||||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
|
||||||
# Here the PR gets approved.
|
|
||||||
- uses: "actions/checkout@v4"
|
|
||||||
- name: "Approve a PR"
|
|
||||||
run: "gh pr review --approve $PR_URL"
|
|
||||||
env:
|
|
||||||
PR_URL: "${{ github.event.pull_request.html_url }}"
|
|
||||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
|
||||||
# Finally, this sets the PR to allow auto-merging for patch and minor
|
|
||||||
# updates if all checks pass
|
|
||||||
- name: "Enable auto-merge for Dependabot PRs"
|
|
||||||
#if: "${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }}"
|
|
||||||
run: "gh pr merge --auto --squash $PR_URL"
|
|
||||||
env:
|
|
||||||
PR_URL: "${{ github.event.pull_request.html_url }}"
|
|
||||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
|
||||||
59
.github/workflows/build.yml
vendored
59
.github/workflows/build.yml
vendored
@@ -1,10 +1,6 @@
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- "master"
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
|
||||||
- "master"
|
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
ref:
|
||||||
@@ -18,13 +14,15 @@ jobs:
|
|||||||
name: "Build frontend"
|
name: "Build frontend"
|
||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v4"
|
- uses: "actions/checkout@v6"
|
||||||
- uses: "actions/setup-node@v4"
|
with:
|
||||||
|
ref: "${{ inputs.ref }}"
|
||||||
|
- uses: "actions/setup-node@v6"
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
- run: "yarn install"
|
- run: "yarn install"
|
||||||
- run: "yarn build"
|
- run: "yarn build"
|
||||||
- uses: "actions/upload-artifact@v4"
|
- uses: "actions/upload-artifact@v6"
|
||||||
with:
|
with:
|
||||||
name: "frontend"
|
name: "frontend"
|
||||||
path: "dist"
|
path: "dist"
|
||||||
@@ -34,28 +32,34 @@ jobs:
|
|||||||
name: "Build backend"
|
name: "Build backend"
|
||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v4"
|
- uses: "actions/checkout@v6"
|
||||||
- run: "pipx install poetry"
|
|
||||||
- uses: "actions/setup-python@v5"
|
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
ref: "${{ inputs.ref }}"
|
||||||
|
- run: "pipx install poetry"
|
||||||
|
- uses: "actions/setup-python@v6"
|
||||||
|
with:
|
||||||
|
python-version: "3.14"
|
||||||
cache: "poetry"
|
cache: "poetry"
|
||||||
- run: "SKIP_BUILD_FRONTEND=true poetry install"
|
- run: "poetry lock"
|
||||||
- run: "SKIP_BUILD_FRONTEND=true poetry build"
|
- run: "poetry install"
|
||||||
|
- run: "poetry build" # Skips building frontend (not using task)
|
||||||
|
|
||||||
build-logo:
|
build-logo:
|
||||||
name: "Build logo"
|
name: "Build logo"
|
||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v4"
|
- uses: "actions/checkout@v6"
|
||||||
- run: "pipx install poetry"
|
|
||||||
- uses: "actions/setup-python@v5"
|
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
ref: "${{ inputs.ref }}"
|
||||||
|
- run: "pipx install poetry"
|
||||||
|
- uses: "actions/setup-python@v6"
|
||||||
|
with:
|
||||||
|
python-version: "3.14"
|
||||||
cache: "poetry"
|
cache: "poetry"
|
||||||
- run: "SKIP_BUILD_FRONTEND=true poetry install"
|
- run: "poetry lock"
|
||||||
|
- run: "poetry install"
|
||||||
- run: "poetry run python yacv_server/logo.py"
|
- run: "poetry run python yacv_server/logo.py"
|
||||||
- uses: "actions/upload-artifact@v4"
|
- uses: "actions/upload-artifact@v6"
|
||||||
with:
|
with:
|
||||||
name: "logo"
|
name: "logo"
|
||||||
path: "assets/logo_build"
|
path: "assets/logo_build"
|
||||||
@@ -65,15 +69,18 @@ jobs:
|
|||||||
name: "Build example"
|
name: "Build example"
|
||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v4"
|
- uses: "actions/checkout@v6"
|
||||||
- run: "pipx install poetry"
|
|
||||||
- uses: "actions/setup-python@v5"
|
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
ref: "${{ inputs.ref }}"
|
||||||
|
- run: "pipx install poetry"
|
||||||
|
- uses: "actions/setup-python@v6"
|
||||||
|
with:
|
||||||
|
python-version: "3.14"
|
||||||
cache: "poetry"
|
cache: "poetry"
|
||||||
- run: "SKIP_BUILD_FRONTEND=true poetry install"
|
- run: "poetry lock"
|
||||||
- run: "PYTHONPATH=yacv_server YACV_DISABLE_SERVER=true poetry run python example/object.py"
|
- run: "poetry install"
|
||||||
- uses: "actions/upload-artifact@v4"
|
- run: "YACV_DISABLE_SERVER=true poetry run python example/object.py"
|
||||||
|
- uses: "actions/upload-artifact@v6"
|
||||||
with:
|
with:
|
||||||
name: "example"
|
name: "example"
|
||||||
path: "export"
|
path: "export"
|
||||||
|
|||||||
22
.github/workflows/deploy1.yml
vendored
22
.github/workflows/deploy1.yml
vendored
@@ -12,32 +12,37 @@ jobs:
|
|||||||
|
|
||||||
update-versions:
|
update-versions:
|
||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
|
outputs:
|
||||||
|
skip: "${{ steps.check_recursive.outputs.skip || 'false' }}" # Default to false if not set
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v4"
|
- uses: "actions/checkout@v6"
|
||||||
with: # Ensure we are not in a detached HEAD state
|
with: # Ensure we are not in a detached HEAD state
|
||||||
ref: "master"
|
ref: "master"
|
||||||
|
token: "${{ secrets.GH_PAT }}"
|
||||||
# Check that the tag commit is the latest master commit
|
# Check that the tag commit is the latest master commit
|
||||||
- run: |
|
- id: check_recursive
|
||||||
|
run: |
|
||||||
git fetch --tags
|
git fetch --tags
|
||||||
tag_commit=$(git rev-parse ${{ github.ref }})
|
tag_commit=$(git rev-parse ${{ github.ref }})
|
||||||
master_commit=$(git rev-parse master)
|
master_commit=$(git rev-parse master)
|
||||||
if [ "$tag_commit" != "$master_commit" ]; then
|
if [ "$tag_commit" != "$master_commit" ]; then
|
||||||
echo "The tag commit ($tag_commit) is not the latest master commit ($master_commit)"
|
echo "::warning ::The tag commit $tag_commit does not match the latest master commit $master_commit. This is probably a recursive tag push that will be ignored."
|
||||||
exit 1
|
echo "skip=true" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
- run: "echo 'CLEAN_VERSION=${{ github.ref }}' | sed 's,refs/tags/v,,g' >> $GITHUB_ENV"
|
- run: "echo 'CLEAN_VERSION=${{ github.ref }}' | sed 's,refs/tags/v,,g' >> $GITHUB_ENV"
|
||||||
# Write the new version to package.json
|
# Write the new version to package.json
|
||||||
- uses: "actions/setup-node@v4"
|
- uses: "actions/setup-node@v6"
|
||||||
- run: "yarn version --new-version $CLEAN_VERSION --no-git-tag-version"
|
- run: "yarn version --new-version $CLEAN_VERSION --no-git-tag-version"
|
||||||
# Write the new version to pyproject.toml
|
# Write the new version to pyproject.toml
|
||||||
- run: "pipx install poetry"
|
- run: "pipx install poetry"
|
||||||
- uses: "actions/setup-python@v5"
|
- uses: "actions/setup-python@v6"
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.14"
|
||||||
cache: "poetry"
|
cache: "poetry"
|
||||||
- run: "poetry version $CLEAN_VERSION"
|
- run: "poetry version $CLEAN_VERSION"
|
||||||
# Commit the changes and move the tag!
|
# Commit the changes and move the tag!
|
||||||
- run: |
|
- if: "steps.check_recursive.outputs.skip != 'true'"
|
||||||
|
run: |
|
||||||
git config --global user.email "yeicor@users.noreply.github.com"
|
git config --global user.email "yeicor@users.noreply.github.com"
|
||||||
git config --global user.name "Yeicor"
|
git config --global user.name "Yeicor"
|
||||||
if git commit -am "Automatically update version to $CLEAN_VERSION"; then
|
if git commit -am "Automatically update version to $CLEAN_VERSION"; then
|
||||||
@@ -53,6 +58,7 @@ jobs:
|
|||||||
|
|
||||||
deploy: # Makes sure all artifacts are updated and use the new version for the next deployment steps
|
deploy: # Makes sure all artifacts are updated and use the new version for the next deployment steps
|
||||||
needs: "update-versions"
|
needs: "update-versions"
|
||||||
|
if: "needs.update-versions.outputs.skip != 'true'" # Only run if the update-versions job did not skip
|
||||||
uses: "./.github/workflows/deploy2.yml"
|
uses: "./.github/workflows/deploy2.yml"
|
||||||
secrets: "inherit" # Inherit the secrets from the parent workflow
|
secrets: "inherit" # Inherit the secrets from the parent workflow
|
||||||
with:
|
with:
|
||||||
|
|||||||
23
.github/workflows/deploy2.yml
vendored
23
.github/workflows/deploy2.yml
vendored
@@ -32,16 +32,12 @@ jobs:
|
|||||||
name: "github-pages"
|
name: "github-pages"
|
||||||
url: "${{ steps.deployment.outputs.page_url }}"
|
url: "${{ steps.deployment.outputs.page_url }}"
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/download-artifact@v4"
|
- uses: "actions/download-artifact@v7"
|
||||||
with: # Downloads all artifacts from the build job
|
with: # Downloads all artifacts from the build job
|
||||||
path: "./public"
|
path: "./public"
|
||||||
- run: | # Merge the subdirectories of public into a single directory
|
merge-multiple: true
|
||||||
for dir in public/*; do
|
- uses: "actions/configure-pages@v5"
|
||||||
mv "$dir/"* public/
|
- uses: "actions/upload-pages-artifact@v4"
|
||||||
rmdir "$dir"
|
|
||||||
done
|
|
||||||
- uses: "actions/configure-pages@v4"
|
|
||||||
- uses: "actions/upload-pages-artifact@v3"
|
|
||||||
with:
|
with:
|
||||||
path: 'public'
|
path: 'public'
|
||||||
- id: "deployment"
|
- id: "deployment"
|
||||||
@@ -58,18 +54,19 @@ jobs:
|
|||||||
needs: "rebuild"
|
needs: "rebuild"
|
||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v4"
|
- uses: "actions/checkout@v6"
|
||||||
with:
|
with:
|
||||||
ref: "${{ inputs.ref }}"
|
ref: "${{ inputs.ref }}"
|
||||||
- uses: "actions/setup-node@v4"
|
- uses: "actions/setup-node@v6"
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
- run: "pipx install poetry"
|
- run: "pipx install poetry"
|
||||||
- uses: "actions/setup-python@v5"
|
- uses: "actions/setup-python@v6"
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.14"
|
||||||
cache: "poetry"
|
cache: "poetry"
|
||||||
- run: "poetry install"
|
- run: "poetry install"
|
||||||
- run: "poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }}"
|
- run: "poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }}"
|
||||||
- run: "poetry publish --build"
|
- run: "poetry run task build" # This task also builds the frontend (with reduced features for less size)
|
||||||
|
- run: "poetry publish"
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@
|
|||||||
|
|
||||||
*.iml
|
*.iml
|
||||||
venv/
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
|||||||
15
CONTRIBUTING.md
Normal file
15
CONTRIBUTING.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Contributing to Yet Another CAD Viewer
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to this project!
|
||||||
|
|
||||||
|
## How to contribute
|
||||||
|
|
||||||
|
- For **non-trivial changes** or new features, please [open an issue](https://github.com/yeicor-3d/yet-another-cad-viewer/issues) first to discuss your idea before submitting a pull request.
|
||||||
|
- For **small fixes** (such as typo corrections or minor improvements), feel free to submit a pull request directly.
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
- Make sure your changes are clear and concise.
|
||||||
|
- Describe what your pull request does and reference any related issues.
|
||||||
|
|
||||||
|
We appreciate your help in improving this project!
|
||||||
21
README.md
21
README.md
@@ -10,19 +10,32 @@ in a web browser.
|
|||||||
- All [GLTF 2.0](https://www.khronos.org/gltf/) features (textures, PBR materials, animations...).
|
- All [GLTF 2.0](https://www.khronos.org/gltf/) features (textures, PBR materials, animations...).
|
||||||
- All [model-viewer](https://modelviewer.dev/) features (smooth controls, augmented reality...).
|
- All [model-viewer](https://modelviewer.dev/) features (smooth controls, augmented reality...).
|
||||||
- Load multiple models at once, load external models and even images as quads.
|
- Load multiple models at once, load external models and even images as quads.
|
||||||
|
- Control clipping planes, transparency, edge/vertex sizes and explode each model.
|
||||||
- View and interact with topological entities: faces, edges, vertices and locations.
|
- View and interact with topological entities: faces, edges, vertices and locations.
|
||||||
- Control clipping planes and transparency of each model.
|
|
||||||
- Select any entity and measure bounding box size and distances.
|
- Select any entity and measure bounding box size and distances.
|
||||||
- Fully-featured static deployment: just upload the viewer and models to your server.
|
|
||||||
- Hot reloading while editing the CAD model (using the `yacv-server` package).
|
- Hot reloading while editing the CAD model (using the `yacv-server` package).
|
||||||
|
- Fully-featured static deployment: just upload the viewer and models to your server.
|
||||||
|
- Build123d playground! Code and build your model fully inside the
|
||||||
|
browser: [demo](https://yeicor-3d.github.io/yet-another-cad-viewer/#pg_code=https://raw.githubusercontent.com/gumyr/build123d/refs/heads/dev/examples/toy_truck.py).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
The [example](example) is a fully working project that shows how to use the viewer.
|
The [example](example) is a fully working project that shows how to use the viewer.
|
||||||
|
|
||||||
You can play with the latest
|
You can play with the latest
|
||||||
demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=base.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)
|
demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=logo.glb&preload=logo_hl.glb&preload=logo_hl_tex.glb&preload=fox.glb&preload=img.glb&preload=location.glb)
|
||||||
(or
|
(or
|
||||||
[without animation](https://yeicor-3d.github.io/yet-another-cad-viewer/?autoplay=false&preload=base.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)).
|
[without animation](https://yeicor-3d.github.io/yet-another-cad-viewer/?autoplay=false&preload=logo.glb&preload=logo_hl.glb&preload=logo_hl_tex.glb&preload=fox.glb&preload=img.glb&preload=location.glb)).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## Related projects
|
||||||
|
|
||||||
|
- [cq-studio](https://github.com/ccazabon/cq-studio) provides an alternative workflow that detects file changes instead
|
||||||
|
of relying on an interactive environment like Jupyter for hot-reloading.
|
||||||
|
Uses the same backend and frontend behind the scenes.
|
||||||
|
- [build123d-docker](https://github.com/derhuerst/build123d-docker/pkgs/container/build123d) provides docker images for
|
||||||
|
Yet Another CAD Viewer and other projects, with automatic updates.
|
||||||
|
- [OCP.wasm](https://github.com/yeicor/OCP.wasm/) ports OCP (OpenCASCADE for Python) and supporting libraries to
|
||||||
|
WebAssembly, enabling full in-browser CAD model generation and manipulation. This powers the build123d playground
|
||||||
|
provided by this viewer.
|
||||||
|
|||||||
1433
assets/licenses.txt
1433
assets/licenses.txt
File diff suppressed because it is too large
Load Diff
BIN
assets/qwantani_afternoon_1k_hdr.jpg
Normal file
BIN
assets/qwantani_afternoon_1k_hdr.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
2
assets/qwantani_afternoon_1k_hdr.jpg.license
Normal file
2
assets/qwantani_afternoon_1k_hdr.jpg.license
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
CC0: Qwantani Afternoon by Greg Zaal (Photography) and Jarod Guest (Processing)
|
||||||
|
https://polyhaven.com/a/qwantani_afternoon
|
||||||
9
build.py
9
build.py
@@ -1,9 +0,0 @@
|
|||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Building the frontend is optional
|
|
||||||
if os.getenv('SKIP_BUILD_FRONTEND') is None and os.path.exists('package.json'):
|
|
||||||
# When building the backend, make sure the frontend is built first
|
|
||||||
subprocess.run(['yarn', 'install'], check=True)
|
|
||||||
subprocess.run(['yarn', 'build', '--outDir', 'yacv_server/frontend'], check=True)
|
|
||||||
@@ -3,13 +3,12 @@
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
1. Download the contents of this folder.
|
1. Download the contents of this folder.
|
||||||
2. Assuming you have a recent version of Python installed, install the required packages:
|
2. Assuming you have a recent version of Python 3 installed, install the required packages:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
|
. venv/bin/activate # Execute this line every time you change the terminal
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
# Do this every time you change the terminal:
|
|
||||||
. venv/bin/activate
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -37,5 +36,5 @@ Once you have the `object.glb` file, you can host it on any static file server a
|
|||||||
|
|
||||||
For the example model, the build process is set up in [build.yml](../.github/workflows/build.yml), the upload process
|
For the example model, the build process is set up in [build.yml](../.github/workflows/build.yml), the upload process
|
||||||
is set up in [deploy.yml](../.github/workflows/deploy.yml), and the final link is:
|
is set up in [deploy.yml](../.github/workflows/deploy.yml), and the final link is:
|
||||||
https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=example.glb
|
https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=example.glb&preload=example_hl.glb
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from build123d import * # Also works with cadquery objects!
|
from build123d import * # Also works with cadquery objects!
|
||||||
|
from build123d import Compound
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
@@ -15,8 +16,17 @@ with BuildPart() as example:
|
|||||||
Box(10, 10, 5)
|
Box(10, 10, 5)
|
||||||
Cylinder(4, 5, mode=Mode.SUBTRACT)
|
Cylinder(4, 5, mode=Mode.SUBTRACT)
|
||||||
|
|
||||||
# Show it in the frontend with hot-reloading
|
# Custom colors (optional)
|
||||||
show(example)
|
example.color = (0.1, 0.3, 0.1, 1) # RGBA
|
||||||
|
to_highlight = example.edges().group_by(Axis.Z)[-1]
|
||||||
|
example_hl = Compound(to_highlight).translate((0, 0, 1e-3)) # To avoid z-fighting
|
||||||
|
example_hl.color = (1, 1, .0, 1)
|
||||||
|
|
||||||
|
# Show it in the frontend with hot-reloading (texture and other keyword arguments are optional)
|
||||||
|
texture = ( # MIT License Framework7 Line Icons: https://www.svgrepo.com/svg/437552/checkmark-seal
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASAQAAAAB+tbP6AAAAQ0lEQVQI12P4b3+A4Z/8AYYHBw8w"
|
||||||
|
"HHxwgOH8HyD+AsRPDjDMP+fAYD+fgcESiGfYOTCcqTnAcK4GogakFqQHpBdoBgAbGiPSbdzkhgAAAABJRU5ErkJggg==")
|
||||||
|
show(example, example_hl, texture=texture)
|
||||||
|
|
||||||
# %%
|
# %%
|
||||||
|
|
||||||
|
|||||||
133
frontend/App.vue
133
frontend/App.vue
@@ -1,17 +1,18 @@
|
|||||||
<!--suppress SillyAssignmentJS -->
|
<!--suppress SillyAssignmentJS -->
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {defineAsyncComponent, provide, type Ref, ref, shallowRef, triggerRef} from "vue";
|
import {defineAsyncComponent, provide, type Ref, ref, shallowRef, triggerRef, watch} from "vue";
|
||||||
import Sidebar from "./misc/Sidebar.vue";
|
import Sidebar from "./misc/Sidebar.vue";
|
||||||
import Loading from "./misc/Loading.vue";
|
import Loading from "./misc/Loading.vue";
|
||||||
import Tools from "./tools/Tools.vue";
|
import Tools from "./tools/Tools.vue";
|
||||||
import Models from "./models/Models.vue";
|
import Models from "./models/Models.vue";
|
||||||
import {VBtn, VLayout, VMain, VToolbarTitle} from "vuetify/lib/components/index.mjs";
|
import {VBtn, VLayout, VMain, VToolbarTitle} from "vuetify/lib/components/index.mjs";
|
||||||
import {settings} from "./misc/settings";
|
import {settings} from "./misc/settings";
|
||||||
import {NetworkManager, NetworkUpdateEvent} from "./misc/network";
|
import {NetworkManager, NetworkUpdateEvent, NetworkUpdateEventModel} from "./misc/network";
|
||||||
import {SceneMgr} from "./misc/scene";
|
import {SceneMgr} from "./misc/scene";
|
||||||
import {Document} from "@gltf-transform/core";
|
import {Document} from "@gltf-transform/core";
|
||||||
import type ModelViewerWrapperT from "./viewer/ModelViewerWrapper.vue";
|
import type ModelViewerWrapperT from "./viewer/ModelViewerWrapper.vue";
|
||||||
import {mdiPlus} from '@mdi/js'
|
import {mdiCube, mdiPlus, mdiScriptTextPlay} from '@mdi/js'
|
||||||
|
// @ts-expect-error
|
||||||
import SvgIcon from '@jamescoyle/vue-icon';
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
|
|
||||||
// NOTE: The ModelViewer library is big (THREE.js), so we split it and import it asynchronously
|
// NOTE: The ModelViewer library is big (THREE.js), so we split it and import it asynchronously
|
||||||
@@ -21,51 +22,115 @@ const ModelViewerWrapper = defineAsyncComponent({
|
|||||||
delay: 0,
|
delay: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
let openSidebarsByDefault: Ref<boolean> = ref(window.innerWidth > 1200);
|
let openSidebarsByDefault: Ref<boolean> = ref(window.innerWidth > window.innerHeight);
|
||||||
|
|
||||||
const sceneUrl = ref("")
|
const sceneUrl = ref("")
|
||||||
const viewer: Ref<InstanceType<typeof ModelViewerWrapperT> | null> = ref(null);
|
const viewer: Ref<InstanceType<typeof ModelViewerWrapperT> | null> = ref(null);
|
||||||
const sceneDocument = shallowRef(new Document());
|
const sceneDocument = shallowRef(new Document());
|
||||||
provide('sceneDocument', {sceneDocument});
|
provide('sceneDocument', {sceneDocument});
|
||||||
const models: Ref<InstanceType<typeof Models> | null> = ref(null)
|
const models: Ref<InstanceType<typeof Models> | null> = ref(null)
|
||||||
|
const tools: Ref<InstanceType<typeof Tools> | null> = ref(null)
|
||||||
const disableTap = ref(false);
|
const disableTap = ref(false);
|
||||||
const setDisableTap = (val: boolean) => disableTap.value = val;
|
const setDisableTap = (val: boolean) => disableTap.value = val;
|
||||||
provide('disableTap', {disableTap, setDisableTap});
|
provide('disableTap', {disableTap, setDisableTap});
|
||||||
|
|
||||||
async function onModelUpdateRequest(event: NetworkUpdateEvent) {
|
async function onModelUpdateRequest(event: NetworkUpdateEvent) {
|
||||||
|
// Trigger progress bar as soon as possible (also triggered earlier for each raw notification)
|
||||||
|
if (viewer.value && event.models.length > 0) {
|
||||||
|
viewer.value.onProgress(0.10);
|
||||||
|
}
|
||||||
// Load/unload a new batch of models to optimize rendering time
|
// Load/unload a new batch of models to optimize rendering time
|
||||||
console.log("Received model update request", event.models);
|
console.log("Received model update request", event.models);
|
||||||
|
let shutdownRequestIndex = event.models.findIndex((model) => model.isRemove == null);
|
||||||
|
let shutdownRequest = null;
|
||||||
|
if (shutdownRequestIndex !== -1) {
|
||||||
|
console.log("Will shut down the connection after this load, as requested by the server");
|
||||||
|
shutdownRequest = event.models.splice(shutdownRequestIndex, 1)[0];
|
||||||
|
}
|
||||||
let doc = sceneDocument.value;
|
let doc = sceneDocument.value;
|
||||||
for (let modelIndex in event.models) {
|
for (let modelIndex in event.models) {
|
||||||
let isLast = parseInt(modelIndex) === event.models.length - 1;
|
let isLast = parseInt(modelIndex) === event.models.length - 1;
|
||||||
let model = event.models[modelIndex];
|
let model = event.models[modelIndex];
|
||||||
if (!model.isRemove) {
|
if (!model) continue;
|
||||||
doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast, isLast);
|
tools.value?.removeObjectSelections(model.name);
|
||||||
} else {
|
try {
|
||||||
doc = await SceneMgr.removeModel(sceneUrl, doc, model.name, isLast);
|
let loadHelpers = (await settings).loadHelpers;
|
||||||
|
if (!model.isRemove) {
|
||||||
|
doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast && loadHelpers, isLast);
|
||||||
|
} else {
|
||||||
|
doc = await SceneMgr.removeModel(sceneUrl, doc, model.name, isLast && loadHelpers, isLast);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error loading model", model, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (shutdownRequest !== null) {
|
||||||
|
console.log("Shutting down the connection as requested by the server");
|
||||||
|
event.disconnect();
|
||||||
|
}
|
||||||
sceneDocument.value = doc
|
sceneDocument.value = doc
|
||||||
triggerRef(sceneDocument); // Why not triggered automatically?
|
triggerRef(sceneDocument); // Why not triggered automatically?
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onModelRemoveRequest(name: string) {
|
async function onModelRemoveRequest(name: string) {
|
||||||
sceneDocument.value = await SceneMgr.removeModel(sceneUrl, sceneDocument.value, name);
|
await onModelUpdateRequest(new NetworkUpdateEvent([new NetworkUpdateEventModel(name, "", null, true)], () => {
|
||||||
triggerRef(sceneDocument); // Why not triggered automatically?
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up the load model event listener
|
// Set up the load model event listener
|
||||||
let networkMgr = new NetworkManager();
|
let networkMgr = new NetworkManager();
|
||||||
|
networkMgr.addEventListener('update-early',
|
||||||
|
(e) => viewer.value?.onProgress((e as CustomEvent<Array<any>>).detail.length * 0.01));
|
||||||
networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUpdateEvent));
|
networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUpdateEvent));
|
||||||
// Start loading all configured models ASAP
|
let preloadingModels = ref<Array<string>>([]);
|
||||||
for (let model of settings.preload) {
|
(async () => { // Start loading all configured models ASAP
|
||||||
networkMgr.load(model);
|
let sett = await settings;
|
||||||
}
|
if (sett.preload.length > 0) {
|
||||||
|
watch(viewer, (newViewer) => {
|
||||||
|
if (newViewer) {
|
||||||
|
newViewer.setPosterText('<tspan x="50%" dy="1.2em">Trying to load' +
|
||||||
|
' models from:</tspan>' + sett.preload.map((url: string) => '<tspan x="50%" dy="1.2em">- ' + url + '</tspan>').join(""));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (let model of sett.preload) {
|
||||||
|
preloadingModels.value.push(model);
|
||||||
|
let removeFromPreloadingModels = () => {
|
||||||
|
preloadingModels.value = preloadingModels.value.filter((m) => m !== model);
|
||||||
|
};
|
||||||
|
networkMgr.load(model).then(removeFromPreloadingModels).catch((e) => {
|
||||||
|
removeFromPreloadingModels()
|
||||||
|
console.error("Error preloading model", model, e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} // else No preloaded models (useful for playground mode)
|
||||||
|
})();
|
||||||
|
|
||||||
async function loadModelManual() {
|
async function loadModelManual() {
|
||||||
const modelUrl = prompt("For an improved experience in viewing CAD/GLTF models with automatic updates, it's recommended to use the official yacv_server Python package. This ensures seamless serving of models and automatic updates.\n\nOtherwise, enter the URL of the model to load:");
|
const modelUrl = prompt("For an improved experience in viewing CAD/GLTF models with automatic updates, it's recommended to use the official yacv_server Python package. This ensures seamless serving of models and automatic updates.\n\nOtherwise, enter the URL of the model to load:");
|
||||||
if (modelUrl) await networkMgr.load(modelUrl);
|
if (modelUrl) await networkMgr.load(modelUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadDemoModels() {
|
||||||
|
for (let name of ['fox.glb', 'img.glb', 'location.glb', 'logo.glb', 'logo_hl.glb', 'logo_hl_tex.glb']) {
|
||||||
|
networkMgr.load(`https://yeicor-3d.github.io/yet-another-cad-viewer/${name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect dropped .glb files and load them manually
|
||||||
|
document.body.addEventListener("dragover", e => {
|
||||||
|
e.preventDefault(); // Allow drop
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener("drop", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = e.dataTransfer?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
if (ext === 'glb' || ext === 'gltf') {
|
||||||
|
await networkMgr.load(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -73,28 +138,50 @@ async function loadModelManual() {
|
|||||||
|
|
||||||
<!-- The main content of the app is the model-viewer with the SVG "2D" overlay -->
|
<!-- The main content of the app is the model-viewer with the SVG "2D" overlay -->
|
||||||
<v-main id="main">
|
<v-main id="main">
|
||||||
<model-viewer-wrapper ref="viewer" :src="sceneUrl"/>
|
<model-viewer-wrapper v-if="sceneDocument.getRoot().listMeshes().length > 0" ref="viewer" :src="sceneUrl"/>
|
||||||
|
<!-- A nice no model loaded alternative to avoid breaking model-viewer-wrapper -->
|
||||||
|
<div v-else style="height: 100%; overflow-y: auto">
|
||||||
|
<v-toolbar-title class="text-center ma-16 text-h5">No model loaded</v-toolbar-title>
|
||||||
|
<v-btn @click="() => tools?.openPlayground()" class="mx-auto d-block my-4">
|
||||||
|
<svg-icon :path="mdiScriptTextPlay" type="mdi"/> Open playground...
|
||||||
|
</v-btn>
|
||||||
|
<v-btn @click="loadDemoModels" class="mx-auto d-block my-4">
|
||||||
|
<svg-icon :path="mdiCube" type="mdi"/> Load demo models...
|
||||||
|
</v-btn>
|
||||||
|
<v-btn @click="loadModelManual" class="mx-auto d-block my-4">
|
||||||
|
<svg-icon :path="mdiPlus" type="mdi"/> Load model manually...
|
||||||
|
</v-btn>
|
||||||
|
<span v-if="preloadingModels.length > 0" class="d-block text-center my-16">
|
||||||
|
<span class="d-block text-center text-h6">Still trying to load the following:</span>
|
||||||
|
<span class="d-block text-center" v-for="(model, index) in preloadingModels" :key="index">
|
||||||
|
<template v-if="model !== undefined">
|
||||||
|
{{ model }}<span v-if="index < preloadingModels.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
</v-main>
|
</v-main>
|
||||||
|
|
||||||
<!-- The left collapsible sidebar has the list of models -->
|
<!-- The left collapsible sidebar has the list of models -->
|
||||||
<sidebar :opened-init="openSidebarsByDefault" side="left" :width="300">
|
<sidebar :opened-init="openSidebarsByDefault" :width="300" side="left">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<v-toolbar-title>Models</v-toolbar-title>
|
<v-toolbar-title>Models</v-toolbar-title>
|
||||||
</template>
|
</template>
|
||||||
<template #toolbar-items>
|
<template #toolbar-items>
|
||||||
<v-btn icon="" @click="loadModelManual">
|
<v-btn icon="" @click="loadModelManual">
|
||||||
<svg-icon type="mdi" :path="mdiPlus"/>
|
<svg-icon :path="mdiPlus" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<models ref="models" :viewer="viewer" @remove="onModelRemoveRequest"/>
|
<models ref="models" :viewer="viewer" @remove-model="onModelRemoveRequest"/>
|
||||||
</sidebar>
|
</sidebar>
|
||||||
|
|
||||||
<!-- The right collapsible sidebar has the list of tools -->
|
<!-- The right collapsible sidebar has the list of tools -->
|
||||||
<sidebar :opened-init="openSidebarsByDefault" side="right" :width="48 * 3 /* buttons */ + 1 /* border? */">
|
<sidebar :opened-init="openSidebarsByDefault" :width="48 * 3 /* buttons */ + 1 /* border? */" side="right">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<v-toolbar-title>Tools</v-toolbar-title>
|
<v-toolbar-title>Tools</v-toolbar-title>
|
||||||
</template>
|
</template>
|
||||||
<tools :viewer="viewer" @findModel="(name) => models?.findModel(name)"/>
|
<tools ref="tools" :viewer="viewer" @find-model="models?.findModel" @update-model="onModelUpdateRequest"/>
|
||||||
</sidebar>
|
</sidebar>
|
||||||
|
|
||||||
</v-layout>
|
</v-layout>
|
||||||
@@ -104,6 +191,6 @@ async function loadModelManual() {
|
|||||||
<style>
|
<style>
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
31
frontend/misc/IfNotSmallBuild.vue
Normal file
31
frontend/misc/IfNotSmallBuild.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {mdiLockQuestion} from "@mdi/js";
|
||||||
|
import {VBtn, VTooltip} from "vuetify/lib/components/index.mjs";
|
||||||
|
// @ts-expect-error
|
||||||
|
import SvgIcon from "@jamescoyle/vue-icon";
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
let isSmallBuild = typeof __YACV_SMALL_BUILD__ !== 'undefined' && __YACV_SMALL_BUILD__;
|
||||||
|
|
||||||
|
function clickedButton() { // Redirect to the main build
|
||||||
|
window.open("https://yeicor-3d.github.io/yet-another-cad-viewer/" + window.location.search + window.location.hash, '_blank');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- @ts-ignore-->
|
||||||
|
<!-- Include the children as this is a full build -->
|
||||||
|
<slot v-if="!isSmallBuild"/>
|
||||||
|
<!-- A small info button saying that a feature is missing, and linking to the main build -->
|
||||||
|
<v-btn v-else icon @click="clickedButton" base-color="#a00" style="margin: auto; display: block;">
|
||||||
|
<v-tooltip activator="parent">
|
||||||
|
This feature is not available in the small build.<br/>
|
||||||
|
Click to go to the main build.
|
||||||
|
</v-tooltip>
|
||||||
|
<svg-icon :path="mdiLockQuestion" type="mdi"/>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {VContainer, VRow, VCol, VProgressCircular} from "vuetify/lib/components/index.mjs";
|
import {VCol, VContainer, VProgressCircular, VRow} from "vuetify/lib/components/index.mjs";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
import {VBtn, VNavigationDrawer, VToolbar, VToolbarItems} from "vuetify/lib/components/index.mjs";
|
import {VBtn, VNavigationDrawer, VToolbar, VToolbarItems} from "vuetify/lib/components/index.mjs";
|
||||||
import {mdiChevronLeft, mdiChevronRight, mdiClose} from '@mdi/js'
|
import {mdiChevronLeft, mdiChevronRight, mdiClose} from '@mdi/js'
|
||||||
|
// @ts-expect-error
|
||||||
import SvgIcon from '@jamescoyle/vue-icon';
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -16,22 +17,22 @@ const openIcon = props.side === 'left' ? mdiChevronRight : mdiChevronLeft;
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-btn icon @click="opened = !opened" class="open-button" :class="side">
|
<v-btn :class="side" class="open-button" icon @click="opened = !opened">
|
||||||
<svg-icon type="mdi" :path="openIcon"/>
|
<svg-icon :path="openIcon" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-navigation-drawer v-model="opened" permanent :location="side" :width="props.width">
|
<v-navigation-drawer v-model="opened" :location="side" :width="props.width" permanent>
|
||||||
<v-toolbar density="compact">
|
<v-toolbar density="compact">
|
||||||
<v-toolbar-items v-if="side == 'right'">
|
<v-toolbar-items v-if="side == 'right'">
|
||||||
<slot name="toolbar-items"></slot>
|
<slot name="toolbar-items"></slot>
|
||||||
<v-btn icon @click="opened = !opened">
|
<v-btn icon @click="opened = !opened">
|
||||||
<svg-icon type="mdi" :path="mdiClose"/>
|
<svg-icon :path="mdiClose" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-toolbar-items>
|
</v-toolbar-items>
|
||||||
<slot name="toolbar"></slot>
|
<slot name="toolbar"></slot>
|
||||||
<v-toolbar-items v-if="side == 'left'">
|
<v-toolbar-items v-if="side == 'left'">
|
||||||
<slot name="toolbar-items"></slot>
|
<slot name="toolbar-items"></slot>
|
||||||
<v-btn icon @click="opened = !opened">
|
<v-btn icon @click="opened = !opened">
|
||||||
<svg-icon type="mdi" :path="mdiClose"/>
|
<svg-icon :path="mdiClose" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-toolbar-items>
|
</v-toolbar-items>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
|
|||||||
@@ -1,75 +1,88 @@
|
|||||||
import {BufferAttribute, InterleavedBufferAttribute, Vector3} from 'three';
|
import { BufferAttribute, InterleavedBufferAttribute, Vector3 } from "three";
|
||||||
import type {MObject3D} from "../tools/Selection.vue";
|
import type { MObject3D } from "../tools/Selection.vue";
|
||||||
import type { ModelScene } from '@google/model-viewer/lib/three-components/ModelScene';
|
import type { ModelScene } from "@google/model-viewer/lib/three-components/ModelScene";
|
||||||
|
import type { SelectionInfo } from "../tools/selection";
|
||||||
|
|
||||||
|
function getCenterAndVertexList(
|
||||||
function getCenterAndVertexList(obj: MObject3D, scene: ModelScene): {
|
selInfo: SelectionInfo,
|
||||||
center: Vector3,
|
scene: ModelScene,
|
||||||
vertices: Array<Vector3>
|
): {
|
||||||
|
center: Vector3;
|
||||||
|
vertices: Array<Vector3>;
|
||||||
} {
|
} {
|
||||||
obj.updateMatrixWorld();
|
if (!selInfo.object?.geometry) {
|
||||||
let pos: BufferAttribute | InterleavedBufferAttribute = obj.geometry.getAttribute('position');
|
throw new Error("selInfo.object or geometry is undefined");
|
||||||
let ind: BufferAttribute | null = obj.geometry.index;
|
}
|
||||||
if (!ind) {
|
let pos = selInfo.object.geometry.getAttribute("position");
|
||||||
ind = new BufferAttribute(new Uint16Array(pos.count), 1);
|
let ind = selInfo.object.geometry.index;
|
||||||
for (let i = 0; i < pos.count; i++) {
|
if (ind === null) {
|
||||||
ind.array[i] = i;
|
ind = new BufferAttribute(new Uint16Array(pos.count), 1);
|
||||||
}
|
for (let i = 0; i < pos.count; i++) {
|
||||||
|
ind.array[i] = i;
|
||||||
}
|
}
|
||||||
let center = new Vector3();
|
}
|
||||||
let vertices = [];
|
let center = new Vector3();
|
||||||
for (let i = 0; i < ind.count; i++) {
|
let vertices = [];
|
||||||
let index = ind.array[i];
|
for (let i = selInfo.indices[0]; i < selInfo.indices[1]; i++) {
|
||||||
let vertex = new Vector3(pos.getX(index), pos.getY(index), pos.getZ(index));
|
let index = ind.getX(i);
|
||||||
vertex = scene.target.worldToLocal(obj.localToWorld(vertex));
|
let vertex = new Vector3(pos.getX(index), pos.getY(index), pos.getZ(index));
|
||||||
center.add(vertex);
|
vertex = scene.target.worldToLocal(selInfo.object.localToWorld(vertex));
|
||||||
vertices.push(vertex);
|
center.add(vertex);
|
||||||
}
|
vertices.push(vertex);
|
||||||
center = center.divideScalar(ind.count);
|
}
|
||||||
return {center, vertices};
|
center = center.divideScalar(selInfo.indices[1] - selInfo.indices[0]);
|
||||||
|
return { center, vertices };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given two THREE.Object3D objects, returns their closest and farthest vertices, and the geometric centers.
|
* Given two THREE.Object3D objects, returns their closest and farthest vertices, and the geometric centers.
|
||||||
* All of them are approximated and should not be used for precise calculations.
|
* All of them are approximated and should not be used for precise calculations.
|
||||||
*/
|
*/
|
||||||
export function distances(a: MObject3D, b: MObject3D, scene: ModelScene): {
|
export function distances(
|
||||||
min: Array<Vector3>,
|
a: SelectionInfo,
|
||||||
center: Array<Vector3>,
|
b: SelectionInfo,
|
||||||
max: Array<Vector3>
|
scene: ModelScene,
|
||||||
|
): {
|
||||||
|
min: Array<Vector3>;
|
||||||
|
center: Array<Vector3>;
|
||||||
|
max: Array<Vector3>;
|
||||||
} {
|
} {
|
||||||
// Simplify this problem (approximate) by using the distance between each of their vertices.
|
// Simplify this problem (approximate) by using the distance between each of their vertices.
|
||||||
// Find the center of each object.
|
// Find the center of each object.
|
||||||
let {center: aCenter, vertices: aVertices} = getCenterAndVertexList(a, scene);
|
let { center: aCenter, vertices: aVertices } = getCenterAndVertexList(a, scene);
|
||||||
let {center: bCenter, vertices: bVertices} = getCenterAndVertexList(b, scene);
|
let { center: bCenter, vertices: bVertices } = getCenterAndVertexList(b, scene);
|
||||||
|
|
||||||
// Find the closest and farthest vertices.
|
// Find the closest and farthest vertices.
|
||||||
// TODO: Compute actual min and max distances between the two objects.
|
// TODO: Compute actual min and max distances between the two objects.
|
||||||
// FIXME: Really slow... (use a BVH or something)
|
// FIXME: Really slow... (use a BVH or something)
|
||||||
let minDistance = Infinity;
|
let minDistance = Infinity;
|
||||||
let minDistanceVertices = [new Vector3(), new Vector3()];
|
let minDistanceVertices = [new Vector3(), new Vector3()];
|
||||||
let maxDistance = -Infinity;
|
let maxDistance = -Infinity;
|
||||||
let maxDistanceVertices = [new Vector3(), new Vector3()];
|
let maxDistanceVertices = [new Vector3(), new Vector3()];
|
||||||
for (let i = 0; i < aVertices.length; i++) {
|
for (let i = 0; i < aVertices.length; i++) {
|
||||||
for (let j = 0; j < bVertices.length; j++) {
|
for (let j = 0; j < bVertices.length; j++) {
|
||||||
let distance = aVertices[i].distanceTo(bVertices[j]);
|
const aVertex = aVertices[i];
|
||||||
if (distance < minDistance) {
|
const bVertex = bVertices[j];
|
||||||
minDistance = distance;
|
if (aVertex && bVertex) {
|
||||||
minDistanceVertices[0] = aVertices[i];
|
let distance = aVertex.distanceTo(bVertex);
|
||||||
minDistanceVertices[1] = bVertices[j];
|
if (distance < minDistance) {
|
||||||
}
|
minDistance = distance;
|
||||||
if (distance > maxDistance) {
|
minDistanceVertices[0] = aVertex;
|
||||||
maxDistance = distance;
|
minDistanceVertices[1] = bVertex;
|
||||||
maxDistanceVertices[0] = aVertices[i];
|
|
||||||
maxDistanceVertices[1] = bVertices[j];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (distance > maxDistance) {
|
||||||
|
maxDistance = distance;
|
||||||
|
maxDistanceVertices[0] = aVertex;
|
||||||
|
maxDistanceVertices[1] = bVertex;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Return the results.
|
// Return the results.
|
||||||
return {
|
return {
|
||||||
min: minDistanceVertices,
|
min: minDistanceVertices,
|
||||||
center: [aCenter, bCenter],
|
center: [aCenter, bCenter],
|
||||||
max: maxDistanceVertices
|
max: maxDistanceVertices,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import {Document, Scene, type Transform, WebIO, Buffer} from "@gltf-transform/core";
|
import { Buffer, Document, Scene, type Transform, WebIO } from "@gltf-transform/core";
|
||||||
import {unpartition} from "@gltf-transform/functions";
|
import { mergeDocuments, unpartition } from "@gltf-transform/functions";
|
||||||
|
import { retrieveFile } from "../tools/upload-file.ts";
|
||||||
|
|
||||||
let io = new WebIO();
|
let io = new WebIO();
|
||||||
export let extrasNameKey = "__yacv_name";
|
export let extrasNameKey = "__yacv_name";
|
||||||
export let extrasNameValueHelpers = "__helpers";
|
export let extrasNameValueHelpers = "__helpers";
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
let isSmallBuild = typeof __YACV_SMALL_BUILD__ !== "undefined" && __YACV_SMALL_BUILD__;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads a GLB model from a URL and adds it to the document or replaces it if the names match.
|
* Loads a GLB model from a URL and adds it to the document or replaces it if the names match.
|
||||||
*
|
*
|
||||||
@@ -12,71 +16,148 @@ export let extrasNameValueHelpers = "__helpers";
|
|||||||
*
|
*
|
||||||
* Remember to call mergeFinalize after all models have been merged (slower required operations).
|
* Remember to call mergeFinalize after all models have been merged (slower required operations).
|
||||||
*/
|
*/
|
||||||
export async function mergePartial(url: string, name: string, document: Document): Promise<Document> {
|
export async function mergePartial(
|
||||||
// Load the new document
|
url: string | Blob,
|
||||||
let newDoc = await io.read(url);
|
name: string,
|
||||||
|
document: Document,
|
||||||
|
networkFinished: () => void = () => {},
|
||||||
|
): Promise<Document> {
|
||||||
|
// Fetch the complete document from the network
|
||||||
|
// This could be done at the same time as the document is being processed, but I wanted better metrics
|
||||||
|
let response = await fetchOrRead(url);
|
||||||
|
let buffer = await response.arrayBuffer();
|
||||||
|
networkFinished();
|
||||||
|
|
||||||
// Remove any previous model with the same name
|
// Load the new document
|
||||||
await document.transform(dropByName(name));
|
let newDoc = null;
|
||||||
|
let alreadyTried: { [name: string]: boolean } = {};
|
||||||
|
while (newDoc == null) {
|
||||||
|
// Retry adding extensions as required until the document is loaded
|
||||||
|
try {
|
||||||
|
// Try to load fast if no extensions are used
|
||||||
|
newDoc = await io.readBinary(new Uint8Array(buffer));
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to wait for download and register big extensions
|
||||||
|
if (!isSmallBuild && e instanceof Error && e.message.toLowerCase().includes("khr_draco_mesh_compression")) {
|
||||||
|
if (alreadyTried["draco"]) throw e;
|
||||||
|
else alreadyTried["draco"] = true;
|
||||||
|
// WARNING: Draco decompression on web is really slow for non-trivial models! (it should work?)
|
||||||
|
let { KHRDracoMeshCompression } = await import("@gltf-transform/extensions");
|
||||||
|
// @ts-expect-error
|
||||||
|
let dracoDecoderWeb = await import("three/examples/jsm/libs/draco/draco_decoder.js");
|
||||||
|
// @ts-expect-error
|
||||||
|
let dracoEncoderWeb = await import("three/examples/jsm/libs/draco/draco_encoder.js");
|
||||||
|
io.registerExtensions([KHRDracoMeshCompression]).registerDependencies({
|
||||||
|
"draco3d.decoder": await dracoDecoderWeb.default({}),
|
||||||
|
"draco3d.encoder": await dracoEncoderWeb.default({}),
|
||||||
|
});
|
||||||
|
} else if (!isSmallBuild && e instanceof Error && e.message.toLowerCase().includes("ext_texture_webp")) {
|
||||||
|
if (alreadyTried["webp"]) throw e;
|
||||||
|
else alreadyTried["webp"] = true;
|
||||||
|
let { EXTTextureWebP } = await import("@gltf-transform/extensions");
|
||||||
|
io.registerExtensions([EXTTextureWebP]);
|
||||||
|
} else {
|
||||||
|
// TODO: Add more extensions as required
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure consistent names
|
// Remove any previous model with the same name
|
||||||
// noinspection TypeScriptValidateJSTypes
|
await document.transform(dropByName(name));
|
||||||
await newDoc.transform(setNames(name));
|
|
||||||
|
|
||||||
// Merge the new document into the current one
|
// Ensure consistent names
|
||||||
return document.merge(newDoc);
|
// noinspection TypeScriptValidateJSTypes
|
||||||
|
await newDoc.transform(setNames(name));
|
||||||
|
|
||||||
|
// Merge the new document into the current one
|
||||||
|
mergeDocuments(document, newDoc);
|
||||||
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mergeFinalize(document: Document): Promise<Document> {
|
export async function mergeFinalize(document: Document): Promise<Document> {
|
||||||
// Single scene & buffer required before loading & rendering
|
// Single scene & buffer required before loading & rendering
|
||||||
return await document.transform(mergeScenes(), unpartition());
|
return await document.transform(mergeScenes(), unpartition());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toBuffer(doc: Document): Promise<Uint8Array> {
|
export async function toBuffer(doc: Document): Promise<Uint8Array> {
|
||||||
return io.writeBinary(doc);
|
return io.writeBinary(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeModel(name: string, document: Document): Promise<Document> {
|
export async function removeModel(name: string, document: Document): Promise<Document> {
|
||||||
return await document.transform(dropByName(name));
|
return await document.transform(dropByName(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Given a parsed GLTF document and a name, it forces the names of all elements to be identified by the name (or derivatives) */
|
/** Given a parsed GLTF document and a name, it forces the names of all elements to be identified by the name (or derivatives) */
|
||||||
function setNames(name: string): Transform {
|
function setNames(name: string): Transform {
|
||||||
return (doc: Document) => {
|
return (doc: Document) => {
|
||||||
// Do this automatically for all elements changing any name
|
// Do this automatically for all elements changing any name
|
||||||
for (let elem of doc.getGraph().listEdges().map(e => e.getChild())) {
|
for (let elem of doc
|
||||||
if (!elem.getExtras()) elem.setExtras({});
|
.getGraph()
|
||||||
elem.getExtras()[extrasNameKey] = name;
|
.listEdges()
|
||||||
}
|
.map((e) => e.getChild())) {
|
||||||
return doc;
|
if (!elem.getExtras()) elem.setExtras({});
|
||||||
|
elem.getExtras()[extrasNameKey] = name;
|
||||||
}
|
}
|
||||||
|
return doc;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ensures that all elements with the given name are removed from the document */
|
/** Ensures that all elements with the given name are removed from the document */
|
||||||
function dropByName(name: string): Transform {
|
function dropByName(name: string): Transform {
|
||||||
return (doc: Document) => {
|
return (doc: Document) => {
|
||||||
for (let elem of doc.getGraph().listEdges().map(e => e.getChild())) {
|
for (let elem of doc
|
||||||
if (elem.getExtras() == null || elem instanceof Scene || elem instanceof Buffer) continue;
|
.getGraph()
|
||||||
if ((elem.getExtras()[extrasNameKey]?.toString() ?? "") == name) {
|
.listEdges()
|
||||||
elem.dispose();
|
.map((e) => e.getChild())) {
|
||||||
}
|
if (elem.getExtras() == null || elem instanceof Scene || elem instanceof Buffer) continue;
|
||||||
}
|
if ((elem.getExtras()[extrasNameKey]?.toString() ?? "") == name) {
|
||||||
return doc;
|
elem.dispose();
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Merges all scenes in the document into a single default scene */
|
/** Merges all scenes in the document into a single default scene */
|
||||||
function mergeScenes(): Transform {
|
function mergeScenes(): Transform {
|
||||||
return (doc: Document) => {
|
return (doc: Document) => {
|
||||||
let root = doc.getRoot();
|
let root = doc.getRoot();
|
||||||
let scene = root.getDefaultScene() ?? root.listScenes()[0];
|
let scene = root.getDefaultScene() ?? root.listScenes()[0];
|
||||||
for (let dropScene of root.listScenes()) {
|
if (!scene) {
|
||||||
if (dropScene === scene) continue;
|
throw new Error("No scene found in GLTF document");
|
||||||
for (let node of dropScene.listChildren()) {
|
|
||||||
scene.addChild(node);
|
|
||||||
}
|
|
||||||
dropScene.dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
for (let dropScene of root.listScenes()) {
|
||||||
|
if (dropScene === scene) continue;
|
||||||
|
for (let node of dropScene.listChildren()) {
|
||||||
|
scene.addChild(node);
|
||||||
|
}
|
||||||
|
dropScene.dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches a URL or reads it if it is a Blob URL */
|
||||||
|
async function fetchOrRead(url: string | Blob) {
|
||||||
|
if (url instanceof Blob) {
|
||||||
|
// Use the FileReader API as fetch does not support Blob URLs
|
||||||
|
return new Promise<Response>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = (event: ProgressEvent<FileReader>) => {
|
||||||
|
if (event.target && event.target.result) {
|
||||||
|
resolve(new Response(event.target.result));
|
||||||
|
} else {
|
||||||
|
reject(new Error("Failed to read Blob URL: " + url));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = (error) => {
|
||||||
|
reject(new Error("Error reading Blob URL: " + url + " - " + error));
|
||||||
|
};
|
||||||
|
// Read the Blob URL as an ArrayBuffer
|
||||||
|
reader.readAsArrayBuffer(new Blob([url]));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fetch the URL
|
||||||
|
return retrieveFile(url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,112 @@
|
|||||||
import {Document, type TypedArray} from '@gltf-transform/core'
|
// noinspection JSVoidFunctionReturnValueUsed,JSUnresolvedReference
|
||||||
import {Vector2} from "three/src/math/Vector2.js"
|
|
||||||
import {Vector3} from "three/src/math/Vector3.js"
|
|
||||||
import {Matrix4} from "three/src/math/Matrix4.js"
|
|
||||||
|
|
||||||
|
import { Document, type TypedArray } from "@gltf-transform/core";
|
||||||
|
import { Vector2 } from "three/src/math/Vector2.js";
|
||||||
|
import { Vector3 } from "three/src/math/Vector3.js";
|
||||||
|
import { Matrix4 } from "three/src/math/Matrix4.js";
|
||||||
|
|
||||||
/** Exports the colors used for the axes, primary and secondary. They match the orientation gizmo. */
|
/** Exports the colors used for the axes, primary and secondary. They match the orientation gizmo. */
|
||||||
export const AxesColors = {
|
export const AxesColors = {
|
||||||
x: [[247, 60, 60], [148, 36, 36]],
|
x: [
|
||||||
z: [[108, 203, 38], [65, 122, 23]],
|
[247, 60, 60],
|
||||||
y: [[23, 140, 240], [14, 84, 144]]
|
[148, 36, 36],
|
||||||
}
|
],
|
||||||
|
z: [
|
||||||
|
[108, 203, 38],
|
||||||
|
[65, 122, 23],
|
||||||
|
],
|
||||||
|
y: [
|
||||||
|
[23, 140, 240],
|
||||||
|
[14, 84, 144],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: number[], rawColors: number[] | null, transform: Matrix4, name: string = '__helper', mode: number = WebGL2RenderingContext.LINES) {
|
function buildSimpleGltf(
|
||||||
const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer(name + 'Buffer')
|
doc: Document,
|
||||||
const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0] ?? doc.createScene(name + 'Scene')
|
rawPositions: number[],
|
||||||
const positions = doc.createAccessor(name + 'Position')
|
rawIndices: number[],
|
||||||
.setArray(new Float32Array(rawPositions) as TypedArray)
|
rawColors: number[] | null,
|
||||||
.setType('VEC3')
|
transform: Matrix4,
|
||||||
.setBuffer(buffer)
|
name: string = "__helper",
|
||||||
const indices = doc.createAccessor(name + 'Indices')
|
mode: number = WebGL2RenderingContext.LINES,
|
||||||
.setArray(new Uint32Array(rawIndices) as TypedArray)
|
) {
|
||||||
.setType('SCALAR')
|
const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer(name + "Buffer");
|
||||||
.setBuffer(buffer)
|
const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0] ?? doc.createScene(name + "Scene");
|
||||||
let colors = null;
|
if (!scene) throw new Error("Scene is undefined");
|
||||||
if (rawColors) {
|
if (!rawPositions) throw new Error("rawPositions is undefined");
|
||||||
colors = doc.createAccessor(name + 'Color')
|
const positions = doc
|
||||||
.setArray(new Float32Array(rawColors) as TypedArray)
|
.createAccessor(name + "Position")
|
||||||
.setType('VEC3')
|
.setArray(new Float32Array(rawPositions) as TypedArray)
|
||||||
.setBuffer(buffer);
|
.setType("VEC3")
|
||||||
}
|
.setBuffer(buffer);
|
||||||
const material = doc.createMaterial(name + 'Material')
|
const indices = doc
|
||||||
.setAlphaMode('OPAQUE')
|
.createAccessor(name + "Indices")
|
||||||
const geometry = doc.createPrimitive()
|
.setArray(new Uint32Array(rawIndices) as TypedArray)
|
||||||
.setIndices(indices)
|
.setType("SCALAR")
|
||||||
.setAttribute('POSITION', positions)
|
.setBuffer(buffer);
|
||||||
.setMode(mode as any)
|
let colors = null;
|
||||||
.setMaterial(material)
|
if (rawColors) {
|
||||||
if (rawColors) {
|
colors = doc
|
||||||
geometry.setAttribute('COLOR_0', colors)
|
.createAccessor(name + "Color")
|
||||||
}
|
.setArray(new Float32Array(rawColors) as TypedArray)
|
||||||
const mesh = doc.createMesh(name + 'Mesh').addPrimitive(geometry)
|
.setType("VEC4")
|
||||||
const node = doc.createNode(name + 'Node').setMesh(mesh).setMatrix(transform.elements as any)
|
.setBuffer(buffer);
|
||||||
scene.addChild(node)
|
}
|
||||||
|
const material = doc.createMaterial(name + "Material").setAlphaMode("OPAQUE");
|
||||||
|
const geometry = doc
|
||||||
|
.createPrimitive()
|
||||||
|
.setIndices(indices)
|
||||||
|
.setAttribute("POSITION", positions)
|
||||||
|
.setMode(mode as any)
|
||||||
|
.setMaterial(material);
|
||||||
|
if (rawColors) {
|
||||||
|
geometry.setAttribute("COLOR_0", colors);
|
||||||
|
}
|
||||||
|
if (mode == WebGL2RenderingContext.TRIANGLES) {
|
||||||
|
geometry.setExtras({
|
||||||
|
face_triangles_end: [
|
||||||
|
rawIndices.length / 6,
|
||||||
|
(rawIndices.length * 2) / 6,
|
||||||
|
(rawIndices.length * 3) / 6,
|
||||||
|
(rawIndices.length * 4) / 6,
|
||||||
|
(rawIndices.length * 5) / 6,
|
||||||
|
rawIndices.length,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else if (mode == WebGL2RenderingContext.LINES) {
|
||||||
|
geometry.setExtras({ edge_points_end: [rawIndices.length / 3, (rawIndices.length * 2) / 3, rawIndices.length] });
|
||||||
|
}
|
||||||
|
const mesh = doc.createMesh(name + "Mesh").addPrimitive(geometry);
|
||||||
|
const node = doc
|
||||||
|
.createNode(name + "Node")
|
||||||
|
.setMesh(mesh)
|
||||||
|
.setMatrix(transform.elements as any);
|
||||||
|
scene.addChild(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new Axes helper as a GLTF model, useful for debugging positions and orientations.
|
* Create a new Axes helper as a GLTF model, useful for debugging positions and orientations.
|
||||||
*/
|
*/
|
||||||
export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
|
export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
|
||||||
let rawPositions = [
|
let rawIndices = [0, 1, 2, 3, 4, 5];
|
||||||
[0, 0, 0, size.x, 0, 0],
|
let rawPositions = [0, 0, 0, size.x, 0, 0, 0, 0, 0, 0, size.y, 0, 0, 0, 0, 0, 0, -size.z];
|
||||||
[0, 0, 0, 0, size.y, 0],
|
let rawColors = [
|
||||||
[0, 0, 0, 0, 0, -size.z],
|
...(AxesColors.x[0] ?? [255, 0, 0]),
|
||||||
];
|
255,
|
||||||
let rawIndices = [0, 1];
|
...(AxesColors.x[1] ?? [255, 0, 0]),
|
||||||
let rawColors = [
|
255,
|
||||||
[...(AxesColors.x[0]), ...(AxesColors.x[1])],
|
...(AxesColors.y[0] ?? [0, 255, 0]),
|
||||||
[...(AxesColors.y[0]), ...(AxesColors.y[1])],
|
255,
|
||||||
[...(AxesColors.z[0]), ...(AxesColors.z[1])],
|
...(AxesColors.y[1] ?? [0, 255, 0]),
|
||||||
].map(g => g.map(x => x / 255.0));
|
255,
|
||||||
buildSimpleGltf(doc, rawPositions[0], rawIndices, rawColors[0], transform, '__helper_axes');
|
...(AxesColors.z[0] ?? [0, 0, 255]),
|
||||||
buildSimpleGltf(doc, rawPositions[1], rawIndices, rawColors[1], transform, '__helper_axes');
|
255,
|
||||||
buildSimpleGltf(doc, rawPositions[2], rawIndices, rawColors[2], transform, '__helper_axes');
|
...(AxesColors.z[1] ?? [0, 0, 255]),
|
||||||
buildSimpleGltf(doc, [0, 0, 0], [0], null, transform, '__helper_axes', WebGL2RenderingContext.POINTS);
|
255,
|
||||||
|
].map((x) => x / 255.0);
|
||||||
|
// Axes at (0, 0, 0)
|
||||||
|
buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, new Matrix4(), "__helper_axes");
|
||||||
|
buildSimpleGltf(doc, [0, 0, 0], [0], [1, 1, 1, 1], new Matrix4(), "__helper_axes", WebGL2RenderingContext.POINTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,48 +115,62 @@ export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
|
|||||||
* The grid is built as a box of triangles (representing lines) looking to the inside of the box.
|
* The grid is built as a box of triangles (representing lines) looking to the inside of the box.
|
||||||
* This ensures that only the back of the grid is always visible, regardless of the camera position.
|
* This ensures that only the back of the grid is always visible, regardless of the camera position.
|
||||||
*/
|
*/
|
||||||
export function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4 = new Matrix4(), divisions = 10) {
|
export function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4, divisions = 10) {
|
||||||
// Create transformed positions for the inner faces of the box
|
// Create transformed positions for the inner faces of the box
|
||||||
for (let axis of [new Vector3(1, 0, 0), new Vector3(0, 1, 0), new Vector3(0, 0, -1)]) {
|
let allPositions: number[] = [];
|
||||||
for (let positive of [1, -1]) {
|
let allIndices: number[] = [];
|
||||||
let offset = axis.clone().multiply(size.clone().multiplyScalar(0.5 * positive));
|
for (let axis of [new Vector3(1, 0, 0), new Vector3(0, 1, 0), new Vector3(0, 0, -1)]) {
|
||||||
let translation = new Matrix4().makeTranslation(offset.x, offset.y, offset.z)
|
for (let positive of [1, -1]) {
|
||||||
let rotation = new Matrix4().lookAt(new Vector3(), offset, new Vector3(0, 1, 0))
|
let offset = axis.clone().multiply(size.clone().multiplyScalar(0.5 * positive));
|
||||||
let size2 = new Vector2();
|
let translation = new Matrix4().makeTranslation(offset.x, offset.y, offset.z);
|
||||||
if (axis.x) size2.set(size.z, size.y);
|
let rotation = new Matrix4().lookAt(new Vector3(), offset, new Vector3(0, 1, 0));
|
||||||
if (axis.y) size2.set(size.x, size.z);
|
let size2 = new Vector2();
|
||||||
if (axis.z) size2.set(size.x, size.y);
|
if (axis.x) size2.set(size.z, size.y);
|
||||||
let transform = baseTransform.clone().multiply(translation).multiply(rotation);
|
if (axis.y) size2.set(size.x, size.z);
|
||||||
newGridPlane(doc, size2, transform, divisions);
|
if (axis.z) size2.set(size.x, size.y);
|
||||||
}
|
let transform = new Matrix4().multiply(translation).multiply(rotation);
|
||||||
|
let [rawPositions, rawIndices] = newGridPlane(size2, divisions);
|
||||||
|
let baseIndex = allPositions.length / 3;
|
||||||
|
for (let i of rawIndices) {
|
||||||
|
allIndices.push(i + baseIndex);
|
||||||
|
}
|
||||||
|
// Apply transform to the positions before adding them to the list
|
||||||
|
for (let i = 0; i < rawPositions.length; i += 3) {
|
||||||
|
let pos = new Vector3(rawPositions[i], rawPositions[i + 1], rawPositions[i + 2]);
|
||||||
|
pos.applyMatrix4(transform);
|
||||||
|
allPositions.push(pos.x, pos.y, pos.z);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
let colors = new Array((allPositions.length / 3) * 4).fill(1);
|
||||||
|
buildSimpleGltf(doc, allPositions, allIndices, colors, baseTransform, "__helper_grid", WebGL2RenderingContext.TRIANGLES);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newGridPlane(doc: Document, size: Vector2, transform: Matrix4 = new Matrix4(), divisions = 10, divisionWidth = 0.002) {
|
export function newGridPlane(size: Vector2, divisions = 10, divisionWidth = 0.002): [number[], number[]] {
|
||||||
const rawPositions = [];
|
const rawPositions = [];
|
||||||
const rawIndices = [];
|
const rawIndices = [];
|
||||||
// Build the grid as triangles
|
// Build the grid as triangles
|
||||||
for (let i = 0; i <= divisions; i++) {
|
for (let i = 0; i <= divisions; i++) {
|
||||||
const x = -size.x / 2 + size.x * i / divisions;
|
const x = -size.x / 2 + (size.x * i) / divisions;
|
||||||
const y = -size.y / 2 + size.y * i / divisions;
|
const y = -size.y / 2 + (size.y * i) / divisions;
|
||||||
|
|
||||||
// Vertical quad (two triangles)
|
// Vertical quad (two triangles)
|
||||||
rawPositions.push(x - divisionWidth * size.x / 2, -size.y / 2, 0);
|
rawPositions.push(x - (divisionWidth * size.x) / 2, -size.y / 2, 0);
|
||||||
rawPositions.push(x + divisionWidth * size.x / 2, -size.y / 2, 0);
|
rawPositions.push(x + (divisionWidth * size.x) / 2, -size.y / 2, 0);
|
||||||
rawPositions.push(x + divisionWidth * size.x / 2, size.y / 2, 0);
|
rawPositions.push(x + (divisionWidth * size.x) / 2, size.y / 2, 0);
|
||||||
rawPositions.push(x - divisionWidth * size.x / 2, size.y / 2, 0);
|
rawPositions.push(x - (divisionWidth * size.x) / 2, size.y / 2, 0);
|
||||||
const baseIndex = i * 4;
|
const baseIndex = i * 4;
|
||||||
rawIndices.push(baseIndex, baseIndex + 1, baseIndex + 2);
|
rawIndices.push(baseIndex, baseIndex + 1, baseIndex + 2);
|
||||||
rawIndices.push(baseIndex, baseIndex + 2, baseIndex + 3);
|
rawIndices.push(baseIndex, baseIndex + 2, baseIndex + 3);
|
||||||
|
|
||||||
// Horizontal quad (two triangles)
|
// Horizontal quad (two triangles)
|
||||||
rawPositions.push(-size.x / 2, y - divisionWidth * size.y / 2, 0);
|
rawPositions.push(-size.x / 2, y - (divisionWidth * size.y) / 2, 0);
|
||||||
rawPositions.push(size.x / 2, y - divisionWidth * size.y / 2, 0);
|
rawPositions.push(size.x / 2, y - (divisionWidth * size.y) / 2, 0);
|
||||||
rawPositions.push(size.x / 2, y + divisionWidth * size.y / 2, 0);
|
rawPositions.push(size.x / 2, y + (divisionWidth * size.y) / 2, 0);
|
||||||
rawPositions.push(-size.x / 2, y + divisionWidth * size.y / 2, 0);
|
rawPositions.push(-size.x / 2, y + (divisionWidth * size.y) / 2, 0);
|
||||||
const baseIndex2 = (divisions + 1 + i) * 4;
|
const baseIndex2 = (divisions + 1 + i) * 4;
|
||||||
rawIndices.push(baseIndex2, baseIndex2 + 1, baseIndex2 + 2);
|
rawIndices.push(baseIndex2, baseIndex2 + 1, baseIndex2 + 2);
|
||||||
rawIndices.push(baseIndex2, baseIndex2 + 2, baseIndex2 + 3);
|
rawIndices.push(baseIndex2, baseIndex2 + 2, baseIndex2 + 3);
|
||||||
}
|
}
|
||||||
buildSimpleGltf(doc, rawPositions, rawIndices, null, transform, '__helper_grid', WebGL2RenderingContext.TRIANGLES);
|
return [rawPositions, rawIndices];
|
||||||
}
|
}
|
||||||
|
|||||||
55
frontend/misc/lines.ts
Normal file
55
frontend/misc/lines.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {BufferGeometry} from 'three/src/core/BufferGeometry.js';
|
||||||
|
import {Vector2} from 'three/src/math/Vector2.js';
|
||||||
|
|
||||||
|
// The following imports must be done dynamically to be able to import three.js separately (smaller bundle sizee)
|
||||||
|
// import {LineSegments2} from "three/examples/jsm/lines/LineSegments2.js";
|
||||||
|
// import {LineMaterial} from "three/examples/jsm/lines/LineMaterial.js";
|
||||||
|
// import {LineSegmentsGeometry} from 'three/examples/jsm/lines/LineSegmentsGeometry.js';
|
||||||
|
const LineSegments2Import = import('three/examples/jsm/lines/LineSegments2.js');
|
||||||
|
const LineMaterialImport = import('three/examples/jsm/lines/LineMaterial.js');
|
||||||
|
const LineSegmentsGeometryImport = import('three/examples/jsm/lines/LineSegmentsGeometry.js');
|
||||||
|
|
||||||
|
export async function toLineSegments(bufferGeometry: BufferGeometry, lineWidth: number = 0.1) {
|
||||||
|
const LineSegments2 = (await LineSegments2Import).LineSegments2;
|
||||||
|
const LineMaterial = (await LineMaterialImport).LineMaterial;
|
||||||
|
return new LineSegments2(await toLineSegmentsGeometry(bufferGeometry), new LineMaterial({
|
||||||
|
color: 0xffffffff,
|
||||||
|
vertexColors: true,
|
||||||
|
linewidth: lineWidth, // mm
|
||||||
|
worldUnits: true,
|
||||||
|
resolution: new Vector2(1, 1), // Update resolution on resize!!!
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toLineSegmentsGeometry(bufferGeometry: BufferGeometry) {
|
||||||
|
const LineSegmentsGeometry = (await LineSegmentsGeometryImport).LineSegmentsGeometry;
|
||||||
|
const lg = new LineSegmentsGeometry();
|
||||||
|
|
||||||
|
const position = bufferGeometry.getAttribute('position');
|
||||||
|
const indexAttribute = bufferGeometry.index!!;
|
||||||
|
const positions = [];
|
||||||
|
for (let index = 0; index != indexAttribute.count; ++index) {
|
||||||
|
const i = indexAttribute.getX(index);
|
||||||
|
const x = position.getX(i);
|
||||||
|
const y = position.getY(i);
|
||||||
|
const z = position.getZ(i);
|
||||||
|
positions.push(x, y, z);
|
||||||
|
}
|
||||||
|
lg.setPositions(positions);
|
||||||
|
|
||||||
|
const colors = [];
|
||||||
|
const color = bufferGeometry.getAttribute('color');
|
||||||
|
if (color) {
|
||||||
|
for (let index = 0; index != indexAttribute.count; ++index) {
|
||||||
|
const i = indexAttribute.getX(index);
|
||||||
|
const r = color.getX(i);
|
||||||
|
const g = color.getY(i);
|
||||||
|
const b = color.getZ(i);
|
||||||
|
colors.push(r, g, b);
|
||||||
|
}
|
||||||
|
lg.setColors(colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
lg.userData = bufferGeometry.userData;
|
||||||
|
return lg;
|
||||||
|
}
|
||||||
@@ -2,27 +2,29 @@ import {settings} from "./settings";
|
|||||||
|
|
||||||
const batchTimeout = 250; // ms
|
const batchTimeout = 250; // ms
|
||||||
|
|
||||||
class NetworkUpdateEventModel {
|
export class NetworkUpdateEventModel {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string | Blob;
|
||||||
// TODO: Detect and manage instances of the same object (same hash, different name)
|
// TODO: Detect and manage instances of the same object (same hash, different name)
|
||||||
hash: string | null;
|
hash: string | null;
|
||||||
isRemove: boolean;
|
isRemove: boolean | null; // This is null for a shutdown event
|
||||||
|
|
||||||
constructor(name: string, url: string, hash: string | null, isDelete: boolean) {
|
constructor(name: string, url: string | Blob, hash: string | null, isRemove: boolean | null) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.hash = hash;
|
this.hash = hash;
|
||||||
this.isRemove = isDelete;
|
this.isRemove = isRemove;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NetworkUpdateEvent extends Event {
|
export class NetworkUpdateEvent extends Event {
|
||||||
models: NetworkUpdateEventModel[];
|
models: NetworkUpdateEventModel[];
|
||||||
|
disconnect: () => void;
|
||||||
|
|
||||||
constructor(models: NetworkUpdateEventModel[]) {
|
constructor(models: NetworkUpdateEventModel[], disconnect: () => void) {
|
||||||
super("update");
|
super("update");
|
||||||
this.models = models;
|
this.models = models;
|
||||||
|
this.disconnect = disconnect;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,65 +42,118 @@ export class NetworkManager extends EventTarget {
|
|||||||
*
|
*
|
||||||
* Updates will be emitted as "update" events, including the download URL and the model name.
|
* Updates will be emitted as "update" events, including the download URL and the model name.
|
||||||
*/
|
*/
|
||||||
async load(url: string) {
|
async load(url: string | Blob) {
|
||||||
if (url.startsWith("dev+")) {
|
if (!(url instanceof Blob) && (url.startsWith("dev+") || url.startsWith("dev "))) {
|
||||||
let baseUrl = new URL(url.slice(4));
|
let baseUrl = new URL(url.slice(4));
|
||||||
baseUrl.searchParams.set("api_updates", "true");
|
baseUrl.searchParams.set("api_updates", "true");
|
||||||
await this.monitorDevServer(baseUrl);
|
await this.monitorDevServer(baseUrl);
|
||||||
} else {
|
} else {
|
||||||
// Get the last part of the URL as the "name" of the model
|
let name;
|
||||||
let name = url.split("/").pop();
|
let hash = null;
|
||||||
name = name?.split(".")[0] || `unknown-${Math.random()}`;
|
if (url instanceof Blob) {
|
||||||
// Use a head request to get the hash of the file
|
if (url instanceof File) name = (url as File).name
|
||||||
let response = await fetch(url, {method: "HEAD"});
|
else name = `blob-${Math.random()}`;
|
||||||
let hash = response.headers.get("etag");
|
name = name.replace('.glb', '').replace('.gltf', '');
|
||||||
|
} else {
|
||||||
|
// If there is a #name parameter in the URL, use it as the name
|
||||||
|
let hashParams: URLSearchParams
|
||||||
|
try {
|
||||||
|
let urlObj = new URL(url, window.location.href);
|
||||||
|
hashParams = new URLSearchParams(urlObj.hash.slice(1));
|
||||||
|
} catch (e) {
|
||||||
|
hashParams = new URLSearchParams();
|
||||||
|
}
|
||||||
|
if (hashParams.has("name")) {
|
||||||
|
name = hashParams.get("name") || `unknown-${Math.random()}`;
|
||||||
|
} else { // Default to the last part of the URL as the "name" of the model
|
||||||
|
name = url.split("/").pop();
|
||||||
|
}
|
||||||
|
name = name?.split(".")[0] || `unknown-${Math.random()}`;
|
||||||
|
// Use a head request to get the hash of the file
|
||||||
|
let response = await fetch(url, {method: "HEAD"});
|
||||||
|
hash = response.headers.get("etag");
|
||||||
|
}
|
||||||
// Only trigger an update if the hash has changed
|
// Only trigger an update if the hash has changed
|
||||||
this.foundModel(name, hash, url, false);
|
this.foundModel(name, hash, url, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async monitorDevServer(url: URL) {
|
private async monitorDevServer(url: URL, stop: () => boolean = () => false) {
|
||||||
try {
|
while (!stop()) {
|
||||||
// WARNING: This will spam the console logs with failed requests when the server is down
|
let monitorEveryMs = (await settings).monitorEveryMs;
|
||||||
let response = await fetch(url.toString());
|
try {
|
||||||
// console.log("Monitoring", url.toString(), response);
|
// WARNING: This will spam the console logs with failed requests when the server is down
|
||||||
if (response.status === 200) {
|
const controller = new AbortController();
|
||||||
let lines = readLinesStreamings(response.body!.getReader());
|
let response = await fetch(url.toString(), {signal: controller.signal});
|
||||||
for await (let line of lines) {
|
// console.log("Monitoring", url.toString(), response);
|
||||||
if (!line || !line.startsWith("data:")) continue;
|
if (response.status === 200) {
|
||||||
let data = JSON.parse(line.slice(5));
|
let lines = readLinesStreamings(response.body!.getReader());
|
||||||
// console.debug("WebSocket message", data);
|
for await (let line of lines) {
|
||||||
let urlObj = new URL(url);
|
if (stop()) break;
|
||||||
urlObj.searchParams.delete("api_updates");
|
if (!line || !line.startsWith("data:")) continue;
|
||||||
urlObj.searchParams.set("api_object", data.name);
|
let data: { name: string, hash: string, is_remove: boolean | null } = JSON.parse(line.slice(5));
|
||||||
this.foundModel(data.name, data.hash, urlObj.toString(), data.is_remove);
|
// console.debug("WebSocket message", data);
|
||||||
|
let urlObj = new URL(url);
|
||||||
|
urlObj.searchParams.delete("api_updates");
|
||||||
|
urlObj.searchParams.set("api_object", data.name);
|
||||||
|
this.foundModel(data.name, data.hash, urlObj.toString(), data.is_remove, async () => {
|
||||||
|
controller.abort(); // Notify the server that we are done
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Server is down, wait a little longer before retrying
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10 * monitorEveryMs));
|
||||||
}
|
}
|
||||||
|
controller.abort();
|
||||||
|
} catch (e) { // Ignore errors (retry very soon)
|
||||||
}
|
}
|
||||||
} catch (e) { // Ignore errors (retry very soon)
|
await new Promise(resolve => setTimeout(resolve, monitorEveryMs));
|
||||||
}
|
}
|
||||||
setTimeout(() => this.monitorDevServer(url), settings.monitorEveryMs);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private foundModel(name: string, hash: string | null, url: string, isRemove: boolean) {
|
private foundModel(name: string, hash: string | null, url: string | Blob, isRemove: boolean | null, disconnect: () => void = () => {
|
||||||
let prevHash = this.knownObjectHashes[name];
|
}) {
|
||||||
// console.debug("Found model", name, "with hash", hash, "and previous hash", prevHash);
|
// console.debug("Found model", name, "with hash", hash, "at", url, "isRemove", isRemove);
|
||||||
if (!hash || hash !== prevHash || isRemove) {
|
|
||||||
if (!isRemove) {
|
|
||||||
this.knownObjectHashes[name] = hash;
|
|
||||||
} else {
|
|
||||||
delete this.knownObjectHashes[name];
|
|
||||||
}
|
|
||||||
let newModel = new NetworkUpdateEventModel(name, url, hash, isRemove);
|
|
||||||
this.bufferedUpdates.push(newModel);
|
|
||||||
|
|
||||||
// Optimization: try to batch updates automatically for faster rendering
|
// We only care about the latest update per model name
|
||||||
if (this.batchTimeout !== null) clearTimeout(this.batchTimeout);
|
this.bufferedUpdates = this.bufferedUpdates.filter(m => m.name !== name);
|
||||||
this.batchTimeout = setTimeout(() => {
|
|
||||||
this.dispatchEvent(new NetworkUpdateEvent(this.bufferedUpdates));
|
// Add the new model to the list of updates and dispatch the early update
|
||||||
this.bufferedUpdates = [];
|
let upd = new NetworkUpdateEventModel(name, url, hash, isRemove);
|
||||||
}, batchTimeout);
|
this.bufferedUpdates.push(upd);
|
||||||
}
|
this.dispatchEvent(new CustomEvent("update-early", {detail: this.bufferedUpdates}));
|
||||||
|
|
||||||
|
// Optimization: try to batch updates automatically for faster rendering
|
||||||
|
if (this.batchTimeout !== null) clearTimeout(this.batchTimeout);
|
||||||
|
this.batchTimeout = setTimeout(() => {
|
||||||
|
// Update known hashes for minimal updates
|
||||||
|
for (let model of this.bufferedUpdates) {
|
||||||
|
if (model.isRemove == false && model.hash && model.hash === this.knownObjectHashes[model.name]) {
|
||||||
|
// Delete this useless update
|
||||||
|
let foundFirst = false;
|
||||||
|
this.bufferedUpdates = this.bufferedUpdates.filter(m => {
|
||||||
|
if (m === model) {
|
||||||
|
if (!foundFirst) { // Remove only first full match
|
||||||
|
foundFirst = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Keep this update and update the last known hash
|
||||||
|
if (model.isRemove == true) {
|
||||||
|
if (model.name in this.knownObjectHashes) delete this.knownObjectHashes[model.name];
|
||||||
|
} else if (model.isRemove == false) {
|
||||||
|
this.knownObjectHashes[model.name] = model.hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch the event to actually update the models
|
||||||
|
this.dispatchEvent(new NetworkUpdateEvent(this.bufferedUpdates, disconnect));
|
||||||
|
this.bufferedUpdates = [];
|
||||||
|
}, batchTimeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {type Ref} from 'vue';
|
import {type Ref} from 'vue';
|
||||||
import {Document} from '@gltf-transform/core';
|
import {Buffer, Document, Scene} from '@gltf-transform/core';
|
||||||
import {extrasNameKey, extrasNameValueHelpers, mergeFinalize, mergePartial, removeModel, toBuffer} from "./gltf";
|
import {extrasNameKey, extrasNameValueHelpers, mergeFinalize, mergePartial, removeModel, toBuffer} from "./gltf";
|
||||||
import {newAxes, newGridBox} from "./helpers";
|
import {newAxes, newGridBox} from "./helpers";
|
||||||
import {Vector3} from "three/src/math/Vector3.js"
|
import {Vector3} from "three/src/math/Vector3.js"
|
||||||
@@ -9,43 +9,34 @@ import {Matrix4} from "three/src/math/Matrix4.js"
|
|||||||
/** This class helps manage SceneManagerData. All methods are static to support reactivity... */
|
/** This class helps manage SceneManagerData. All methods are static to support reactivity... */
|
||||||
export class SceneMgr {
|
export class SceneMgr {
|
||||||
/** Loads a GLB model from a URL and adds it to the viewer or replaces it if the names match */
|
/** Loads a GLB model from a URL and adds it to the viewer or replaces it if the names match */
|
||||||
static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string, updateHelpers: boolean = true, reloadScene: boolean = true): Promise<Document> {
|
static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string | Blob, updateHelpers: boolean = true, reloadScene: boolean = true): Promise<Document> {
|
||||||
let loadStart = performance.now();
|
let loadStart = performance.now();
|
||||||
|
let loadNetworkEnd: number;
|
||||||
|
|
||||||
// Start merging into the current document, replacing or adding as needed
|
try {
|
||||||
document = await mergePartial(url, name, document);
|
// Start merging into the current document, replacing or adding as needed
|
||||||
|
document = await mergePartial(url, name, document, () => loadNetworkEnd = performance.now());
|
||||||
|
|
||||||
console.log("Model", name, "loaded in", performance.now() - loadStart, "ms");
|
console.log("Model", name, "loaded in", performance.now() - loadNetworkEnd!, "ms after",
|
||||||
|
loadNetworkEnd! - loadStart, "ms of transferring data (maybe building the object on the server)");
|
||||||
|
} finally {
|
||||||
|
if (updateHelpers) {
|
||||||
|
// Reload the helpers to fit the new model
|
||||||
|
await this.reloadHelpers(sceneUrl, document, reloadScene);
|
||||||
|
reloadScene = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (updateHelpers) {
|
if (reloadScene) {
|
||||||
// Reload the helpers to fit the new model
|
// Display the final fully loaded model
|
||||||
await this.reloadHelpers(sceneUrl, document, reloadScene);
|
let displayStart = performance.now();
|
||||||
reloadScene = false;
|
document = await this.showCurrentDoc(sceneUrl, document);
|
||||||
}
|
console.log("Scene displayed in", performance.now() - displayStart, "ms");
|
||||||
|
}
|
||||||
if (reloadScene) {
|
|
||||||
// Display the final fully loaded model
|
|
||||||
let displayStart = performance.now();
|
|
||||||
document = await this.showCurrentDoc(sceneUrl, document);
|
|
||||||
console.log("Scene displayed in", performance.now() - displayStart, "ms");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document, reloadScene: boolean): Promise<Document> {
|
|
||||||
let bb = SceneMgr.getBoundingBox(document);
|
|
||||||
if (!bb) return document;
|
|
||||||
|
|
||||||
// Create the helper axes and grid box
|
|
||||||
let helpersDoc = new Document();
|
|
||||||
let transform = (new Matrix4()).makeTranslation(bb.getCenter(new Vector3()));
|
|
||||||
newAxes(helpersDoc, bb.getSize(new Vector3()).multiplyScalar(0.5), transform);
|
|
||||||
newGridBox(helpersDoc, bb.getSize(new Vector3()), transform);
|
|
||||||
let helpersUrl = URL.createObjectURL(new Blob([await toBuffer(helpersDoc)]));
|
|
||||||
return await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl, false, reloadScene);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getBoundingBox(document: Document): Box3 | null {
|
static getBoundingBox(document: Document): Box3 | null {
|
||||||
if (document.getRoot().listNodes().length === 0) return null;
|
if (document.getRoot().listNodes().length === 0) return null;
|
||||||
// Get bounding box of the model and use it to set the size of the helpers
|
// Get bounding box of the model and use it to set the size of the helpers
|
||||||
@@ -87,6 +78,33 @@ export class SceneMgr {
|
|||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document, reloadScene: boolean): Promise<Document> {
|
||||||
|
let bb = SceneMgr.getBoundingBox(document);
|
||||||
|
if (!bb) return document; // Empty document, no helpers to show
|
||||||
|
|
||||||
|
// If only the helpers remain, go back to the empty scene
|
||||||
|
let noOtherModels = true;
|
||||||
|
for (let elem of document.getGraph().listEdges().map(e => e.getChild())) {
|
||||||
|
if (elem.getExtras() && !(elem instanceof Scene) && !(elem instanceof Buffer) &&
|
||||||
|
elem.getExtras()[extrasNameKey] !== extrasNameValueHelpers) {
|
||||||
|
// There are other elements in the document, so we can show the helpers
|
||||||
|
noOtherModels = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (noOtherModels) return await removeModel(extrasNameValueHelpers, document);
|
||||||
|
|
||||||
|
// Create the helper axes and grid box
|
||||||
|
let helpersDoc = new Document();
|
||||||
|
let transform = (new Matrix4()).makeTranslation(bb.getCenter(new Vector3()));
|
||||||
|
newAxes(helpersDoc, bb.getSize(new Vector3()).multiplyScalar(0.5), transform);
|
||||||
|
newGridBox(helpersDoc, bb.getSize(new Vector3()), transform);
|
||||||
|
let helpersUrl = URL.createObjectURL(new Blob([await toBuffer(helpersDoc) as ArrayBufferView<ArrayBuffer>]));
|
||||||
|
let newDocument = await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl, false, reloadScene);
|
||||||
|
URL.revokeObjectURL(helpersUrl);
|
||||||
|
return newDocument;
|
||||||
|
}
|
||||||
|
|
||||||
/** Serializes the current document into a GLB and updates the viewerSrc */
|
/** Serializes the current document into a GLB and updates the viewerSrc */
|
||||||
private static async showCurrentDoc(sceneUrl: Ref<string>, document: Document): Promise<Document> {
|
private static async showCurrentDoc(sceneUrl: Ref<string>, document: Document): Promise<Document> {
|
||||||
// Make sure the document is fully loaded and ready to be shown
|
// Make sure the document is fully loaded and ready to be shown
|
||||||
@@ -94,8 +112,9 @@ export class SceneMgr {
|
|||||||
|
|
||||||
// Serialize the document into a GLB and update the viewerSrc
|
// Serialize the document into a GLB and update the viewerSrc
|
||||||
let buffer = await toBuffer(document);
|
let buffer = await toBuffer(document);
|
||||||
let blob = new Blob([buffer], {type: 'model/gltf-binary'});
|
let blob = new Blob([buffer as ArrayBufferView<ArrayBuffer>], {type: 'model/gltf-binary'});
|
||||||
console.debug("Showing current doc", document, "as", Array.from(buffer));
|
console.debug("Showing current doc", document, "with", buffer.length, "total bytes");
|
||||||
|
if (sceneUrl.value.startsWith("blob:")) URL.revokeObjectURL(sceneUrl.value);
|
||||||
sceneUrl.value = URL.createObjectURL(blob);
|
sceneUrl.value = URL.createObjectURL(blob);
|
||||||
|
|
||||||
return document;
|
return document;
|
||||||
|
|||||||
@@ -1,30 +1,115 @@
|
|||||||
// These are the default values for the settings, which are overridden below
|
// These are the default values for the settings, which are overridden below
|
||||||
export const settings = {
|
import {ungzip} from "pako";
|
||||||
preload: [
|
import {b64UrlDecode} from "../tools/b64.ts";
|
||||||
// @ts-ignore
|
import {retrieveFile} from "../tools/upload-file.ts";
|
||||||
// new URL('../../assets/fox.glb', import.meta.url).href,
|
|
||||||
// @ts-ignore
|
|
||||||
// new URL('../../assets/logo_build/base.glb', import.meta.url).href,
|
|
||||||
// @ts-ignore
|
|
||||||
// new URL('../../assets/logo_build/location.glb', import.meta.url).href,
|
|
||||||
// @ts-ignore
|
|
||||||
// new URL('../../assets/logo_build/img.jpg.glb', import.meta.url).href,
|
|
||||||
// Websocket URLs automatically listen for new models from the python backend
|
|
||||||
"dev+http://127.0.0.1:32323/"
|
|
||||||
],
|
|
||||||
displayLoadingEveryMs: 1000, /* How often to display partially loaded models */
|
|
||||||
monitorEveryMs: 100,
|
|
||||||
monitorOpenTimeoutMs: 1000,
|
|
||||||
// ModelViewer settings
|
|
||||||
autoplay: true,
|
|
||||||
arModes: 'webxr scene-viewer quick-look',
|
|
||||||
exposure: 1,
|
|
||||||
shadowIntensity: 0,
|
|
||||||
background: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstTimeNames: Array<string> = []; // Needed for array values, which clear the array when overridden
|
const firstTimeNames: Array<string> = []; // Needed for array values, which clear the array when overridden
|
||||||
function parseSetting(name: string, value: string): any {
|
export const settings = (async () => {
|
||||||
|
let settings = {
|
||||||
|
preload: [
|
||||||
|
// @ts-ignore
|
||||||
|
// new URL('../../assets/fox.glb', import.meta.url).href,
|
||||||
|
// @ts-ignore
|
||||||
|
// new URL('../../assets/logo_build/base.glb', import.meta.url).href,
|
||||||
|
// @ts-ignore
|
||||||
|
// new URL('../../assets/logo_build/location.glb', import.meta.url).href,
|
||||||
|
// @ts-ignore
|
||||||
|
// new URL('../../assets/logo_build/img.glb', import.meta.url).href,
|
||||||
|
// Websocket URLs automatically listen for new models from the python backend
|
||||||
|
'<auto>', // Get the default preload URL if not overridden
|
||||||
|
],
|
||||||
|
loadHelpers: true,
|
||||||
|
edgeWidth: 0, /* The default line size for edges, set to 0 to use basic gl.LINEs */
|
||||||
|
displayLoadingEveryMs: 1000, /* How often to display partially loaded models */
|
||||||
|
monitorEveryMs: 100,
|
||||||
|
monitorOpenTimeoutMs: 1000,
|
||||||
|
// ModelViewer settings
|
||||||
|
autoplay: true, // Global animation toggle
|
||||||
|
arModes: 'webxr scene-viewer quick-look',
|
||||||
|
zoomSensitivity: 0.25,
|
||||||
|
orbitSensitivity: 1,
|
||||||
|
panSensitivity: 1,
|
||||||
|
exposure: 1,
|
||||||
|
shadowIntensity: 0,
|
||||||
|
// Nice low-res outdoor/high-contrast HDRI image (CC0 licensed) for lighting
|
||||||
|
environment: new URL('../../assets/qwantani_afternoon_1k_hdr.jpg', import.meta.url).href,
|
||||||
|
environmentIntensity: 1.0,
|
||||||
|
// Uniform (1x1 pixel) medium gray background for visibility (following dark/light mode)
|
||||||
|
skybox: (window.matchMedia("(prefers-color-scheme: dark)").matches ?
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEU4ODiyn42XAAAACklEQVQI" +
|
||||||
|
"12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg==" :
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEW6urpaLVq8AAAACklEQVQI" +
|
||||||
|
"12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="),
|
||||||
|
|
||||||
|
// Playground settings
|
||||||
|
pg_code: "", // Automatically loaded and executed code for the playground
|
||||||
|
pg_opacity_loading: -1, // Opacity of the code during first load and run (< 0 is 0.0 if preload and 0.9 if not)
|
||||||
|
pg_opacity_loaded: 0.9, // Opacity of the code after it has been run for the first time
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-override any settings from the URL (either GET parameters or hash)
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.forEach((value, key) => {
|
||||||
|
if (key in settings) (settings as any)[key] = parseSetting(key, value, settings);
|
||||||
|
})
|
||||||
|
if (url.hash.length > 0) { // Hash has bigger limits as it is not sent to the server
|
||||||
|
const hash = url.hash.slice(1);
|
||||||
|
const hashParams = new URLSearchParams(hash);
|
||||||
|
hashParams.forEach((value, key) => {
|
||||||
|
if (key in settings) (settings as any)[key] = parseSetting(key, value, settings);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the default preload URL if not overridden (requires a fetch that is avoided if possible)
|
||||||
|
for (let i = 0; i < settings.preload.length; i++) {
|
||||||
|
let url = settings.preload[i];
|
||||||
|
// Ignore empty preload URLs to allow overriding default auto behavior
|
||||||
|
if (url === '') {
|
||||||
|
settings.preload = settings.preload.slice(0, i).concat(settings.preload.slice(i + 1));
|
||||||
|
continue; // Skip this preload URL
|
||||||
|
}
|
||||||
|
// Handle special <auto> preload URL
|
||||||
|
if (url === '<auto>') {
|
||||||
|
if (settings.pg_code != "") { // <auto> means no preload URL if code is set
|
||||||
|
settings.preload = settings.preload.slice(0, i).concat(settings.preload.slice(i + 1));
|
||||||
|
continue; // Skip this preload URL
|
||||||
|
}
|
||||||
|
const possibleBackend = new URL("./?api_updates=true", window.location.href)
|
||||||
|
await fetch(possibleBackend, {method: "HEAD"}).then((response) => {
|
||||||
|
if (response.ok && response.headers.get("Content-Type") === "text/event-stream") {
|
||||||
|
// Frontend served by the backend: default to this URL for updates
|
||||||
|
url = "dev+" + possibleBackend.href;
|
||||||
|
}
|
||||||
|
}).catch((error) => console.error("Failed to check for backend:", error));
|
||||||
|
if (url === '<auto>') { // Fallback to the default preload URL of localhost
|
||||||
|
url = "dev+http://localhost:32323";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settings.preload[i] = url ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-decompress the code and other playground settings
|
||||||
|
if (settings.pg_code.length > 0) {
|
||||||
|
// pg_code has a few possible formats: URL, base64url+gzipped, or raw code (try them in that order)
|
||||||
|
try {
|
||||||
|
new URL(settings.pg_code); // Check if it's a valid absolute URL
|
||||||
|
settings.pg_code = await (await retrieveFile(settings.pg_code)).text();
|
||||||
|
} catch (error1) { // Not a valid URL, try base64url+gzipped
|
||||||
|
try {
|
||||||
|
settings.pg_code = ungzip(b64UrlDecode(settings.pg_code), {to: 'string'});
|
||||||
|
} catch (error2) { // Not base64url+gzipped, assume it's raw code
|
||||||
|
console.log("pg_code is not a URL (", error1, ") or base64url+gzipped (", error2, "), using it as raw code:", settings.pg_code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (settings.pg_opacity_loading < 0) {
|
||||||
|
// If the opacity is not set, use 0.0 if preload is set, otherwise 0.9
|
||||||
|
settings.pg_opacity_loading = settings.preload.length > 0 ? 0.0 : 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
})()
|
||||||
|
|
||||||
|
function parseSetting(name: string, value: string, settings: any): any {
|
||||||
let arrayElem = name.endsWith(".0")
|
let arrayElem = name.endsWith(".0")
|
||||||
if (arrayElem) name = name.slice(0, -2);
|
if (arrayElem) name = name.slice(0, -2);
|
||||||
let prevValue = (settings as any)[name];
|
let prevValue = (settings as any)[name];
|
||||||
@@ -37,7 +122,7 @@ function parseSetting(name: string, value: string): any {
|
|||||||
} else {
|
} else {
|
||||||
toExtend = prevValue;
|
toExtend = prevValue;
|
||||||
}
|
}
|
||||||
toExtend.push(parseSetting(name + ".0", value));
|
toExtend.push(parseSetting(name + ".0", value, settings));
|
||||||
return toExtend;
|
return toExtend;
|
||||||
} else {
|
} else {
|
||||||
prevValue = prevValue[0];
|
prevValue = prevValue[0];
|
||||||
@@ -54,9 +139,3 @@ function parseSetting(name: string, value: string): any {
|
|||||||
throw new Error(`Unknown setting type: ${typeof prevValue} -- ${prevValue}`);
|
throw new Error(`Unknown setting type: ${typeof prevValue} -- ${prevValue}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-override any settings from the URL
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.forEach((value, key) => {
|
|
||||||
if (key in settings) (settings as any)[key] = parseSetting(key, value);
|
|
||||||
})
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {
|
import {
|
||||||
VBtn,
|
VBtn,
|
||||||
VBtnToggle,
|
VBtnToggle,
|
||||||
@@ -12,26 +12,32 @@ import {
|
|||||||
VTooltip,
|
VTooltip,
|
||||||
} from "vuetify/lib/components/index.mjs";
|
} from "vuetify/lib/components/index.mjs";
|
||||||
import {extrasNameKey, extrasNameValueHelpers} from "../misc/gltf";
|
import {extrasNameKey, extrasNameValueHelpers} from "../misc/gltf";
|
||||||
import {Document, Mesh} from "@gltf-transform/core";
|
import {Mesh} from "@gltf-transform/core";
|
||||||
import {inject, ref, type ShallowRef, watch} from "vue";
|
import {nextTick, ref, watch} from "vue";
|
||||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||||
import {
|
import {
|
||||||
|
mdiArrowExpand,
|
||||||
mdiCircleOpacity,
|
mdiCircleOpacity,
|
||||||
mdiCube,
|
mdiCube,
|
||||||
mdiDelete,
|
mdiDelete,
|
||||||
mdiRectangle,
|
mdiRectangle,
|
||||||
mdiRectangleOutline,
|
mdiRectangleOutline,
|
||||||
mdiSwapHorizontal,
|
mdiSwapHorizontal,
|
||||||
|
mdiVectorLine,
|
||||||
mdiVectorRectangle
|
mdiVectorRectangle
|
||||||
} from '@mdi/js'
|
} from '@mdi/js'
|
||||||
|
// @ts-expect-error
|
||||||
import SvgIcon from '@jamescoyle/vue-icon';
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
import {SceneMgr} from "../misc/scene";
|
|
||||||
import {BackSide, FrontSide} from "three/src/constants.js";
|
import {BackSide, FrontSide} from "three/src/constants.js";
|
||||||
import {Box3} from "three/src/math/Box3.js";
|
import {Box3} from "three/src/math/Box3.js";
|
||||||
import {Color} from "three/src/math/Color.js";
|
import {Color} from "three/src/math/Color.js";
|
||||||
import {Plane} from "three/src/math/Plane.js";
|
import {Plane} from "three/src/math/Plane.js";
|
||||||
import {Vector3} from "three/src/math/Vector3.js";
|
import {Vector3} from "three/src/math/Vector3.js";
|
||||||
import type {MObject3D} from "../tools/Selection.vue";
|
import type {MObject3D} from "../tools/Selection.vue";
|
||||||
|
import {toLineSegments} from "../misc/lines.js";
|
||||||
|
import {settings} from "../misc/settings.js"
|
||||||
|
import {currentSceneRotation} from "../viewer/lighting.ts";
|
||||||
|
import {Matrix4} from "three/src/math/Matrix4.js";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
meshes: Array<Mesh>,
|
meshes: Array<Mesh>,
|
||||||
@@ -39,11 +45,14 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{ remove: [] }>()
|
const emit = defineEmits<{ remove: [] }>()
|
||||||
|
|
||||||
let modelName = props.meshes[0].getExtras()[extrasNameKey] // + " blah blah blah blah blag blah blah blah"
|
let modelName = props.meshes[0]?.getExtras()?.[extrasNameKey] // + " blah blah blah blah blag blah blah blah"
|
||||||
|
|
||||||
// Reactive properties
|
// Count the number of faces, edges and vertices
|
||||||
const enabledFeatures = defineModel<Array<number>>("enabledFeatures", {default: [0, 1, 2]});
|
let faceCount = ref(-1);
|
||||||
const opacity = defineModel<number>("opacity", {default: 1});
|
let edgeCount = ref(-1);
|
||||||
|
let vertexCount = ref(-1);
|
||||||
|
|
||||||
|
// Clipping planes are handled in y-up space (swapped on interface, Z inverted later)
|
||||||
const clipPlaneX = ref(1);
|
const clipPlaneX = ref(1);
|
||||||
const clipPlaneSwappedX = ref(false);
|
const clipPlaneSwappedX = ref(false);
|
||||||
const clipPlaneY = ref(1);
|
const clipPlaneY = ref(1);
|
||||||
@@ -51,15 +60,20 @@ const clipPlaneSwappedY = ref(false);
|
|||||||
const clipPlaneZ = ref(1);
|
const clipPlaneZ = ref(1);
|
||||||
const clipPlaneSwappedZ = ref(false);
|
const clipPlaneSwappedZ = ref(false);
|
||||||
|
|
||||||
// Count the number of faces, edges and vertices
|
const edgeWidth = ref(0);
|
||||||
let faceCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES).length).reduce((a, b) => a + b, 0)
|
const explodeStrength = ref(0);
|
||||||
let edgeCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES]).length).reduce((a, b) => a + b, 0)
|
const explodeSwapped = ref(false);
|
||||||
let vertexCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS).length).reduce((a, b) => a + b, 0)
|
|
||||||
|
|
||||||
// Set initial defaults for the enabled features
|
// Load the settings for the default edge width
|
||||||
if (faceCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0)
|
(async () => {
|
||||||
if (edgeCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 1)
|
let s = await settings;
|
||||||
if (vertexCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 2)
|
edgeWidth.value = s.edgeWidth;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Misc properties
|
||||||
|
const enabledFeatures = defineModel<Array<number>>("enabledFeatures", {default: [0, 1, 2]});
|
||||||
|
const opacity = defineModel<number>("opacity", {default: 1});
|
||||||
|
const wireframe = ref(false);
|
||||||
|
|
||||||
// Listeners for changes in the properties (or viewer reloads)
|
// Listeners for changes in the properties (or viewer reloads)
|
||||||
function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
|
function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
|
||||||
@@ -67,13 +81,10 @@ function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
|
|||||||
let scene = props.viewer?.scene;
|
let scene = props.viewer?.scene;
|
||||||
let sceneModel = (scene as any)?._model;
|
let sceneModel = (scene as any)?._model;
|
||||||
if (!scene || !sceneModel) return;
|
if (!scene || !sceneModel) return;
|
||||||
// Iterate all primitives of the mesh and set their visibility based on the enabled features
|
|
||||||
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
|
|
||||||
// of not actually removing the primitives from the scene graph
|
|
||||||
sceneModel.traverse((child: MObject3D) => {
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
if (child.userData[extrasNameKey] === modelName) {
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
let childIsFace = child.type == 'Mesh' || child.type == 'SkinnedMesh'
|
let childIsFace = child.type == 'Mesh' || child.type == 'SkinnedMesh'
|
||||||
let childIsEdge = child.type == 'Line' || child.type == 'LineSegments'
|
let childIsEdge = child.type == 'Line' || child.type == 'LineSegments' || child.type == 'LineSegments2'
|
||||||
let childIsVertex = child.type == 'Points'
|
let childIsVertex = child.type == 'Points'
|
||||||
if (childIsFace || childIsEdge || childIsVertex) {
|
if (childIsFace || childIsEdge || childIsVertex) {
|
||||||
let visible = newEnabledFeatures.includes(childIsFace ? 0 : childIsEdge ? 1 : childIsVertex ? 2 : -1);
|
let visible = newEnabledFeatures.includes(childIsFace ? 0 : childIsEdge ? 1 : childIsVertex ? 2 : -1);
|
||||||
@@ -87,16 +98,12 @@ function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
|
|||||||
scene.queueRender()
|
scene.queueRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(enabledFeatures, onEnabledFeaturesChange);
|
watch(enabledFeatures, onEnabledFeaturesChange, {deep: true});
|
||||||
|
|
||||||
function onOpacityChange(newOpacity: number) {
|
function onOpacityChange(newOpacity: number) {
|
||||||
let scene = props.viewer?.scene;
|
let scene = props.viewer?.scene;
|
||||||
let sceneModel = (scene as any)?._model;
|
let sceneModel = (scene as any)?._model;
|
||||||
if (!scene || !sceneModel) return;
|
if (!scene || !sceneModel) return;
|
||||||
// Iterate all primitives of the mesh and set their opacity based on the enabled features
|
|
||||||
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
|
|
||||||
// of not actually removing the primitives from the scene graph
|
|
||||||
// console.log('Opacity may have changed', newOpacity)
|
|
||||||
sceneModel.traverse((child: MObject3D) => {
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
if (child.userData[extrasNameKey] === modelName) {
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
if (child.material && child.material.opacity !== newOpacity) {
|
if (child.material && child.material.opacity !== newOpacity) {
|
||||||
@@ -111,7 +118,23 @@ function onOpacityChange(newOpacity: number) {
|
|||||||
|
|
||||||
watch(opacity, onOpacityChange);
|
watch(opacity, onOpacityChange);
|
||||||
|
|
||||||
let {sceneDocument} = inject<{ sceneDocument: ShallowRef<Document> }>('sceneDocument')!!;
|
function onWireframeChange(newWireframe: boolean) {
|
||||||
|
let scene = props.viewer?.scene;
|
||||||
|
let sceneModel = (scene as any)?._model;
|
||||||
|
if (!scene || !sceneModel) return;
|
||||||
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
|
let childIsFace = child.type == 'Mesh' || child.type == 'SkinnedMesh'
|
||||||
|
if (child.material && child.material.wireframe !== newWireframe && childIsFace) {
|
||||||
|
child.material.wireframe = newWireframe;
|
||||||
|
child.material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scene.queueRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(wireframe, onWireframeChange);
|
||||||
|
|
||||||
function onClipPlanesChange() {
|
function onClipPlanesChange() {
|
||||||
let scene = props.viewer?.scene;
|
let scene = props.viewer?.scene;
|
||||||
@@ -125,26 +148,30 @@ function onClipPlanesChange() {
|
|||||||
if (props.viewer?.renderer && (enabledX || enabledY || enabledZ)) {
|
if (props.viewer?.renderer && (enabledX || enabledY || enabledZ)) {
|
||||||
// Global value for all models, once set it cannot be unset (unknown for other models...)
|
// Global value for all models, once set it cannot be unset (unknown for other models...)
|
||||||
props.viewer.renderer.threeRenderer.localClippingEnabled = true;
|
props.viewer.renderer.threeRenderer.localClippingEnabled = true;
|
||||||
// Due to model-viewer's camera manipulation, the bounding box needs to be transformed
|
// Get the bounding box containing all features of this model
|
||||||
let boundingBox = SceneMgr.getBoundingBox(sceneDocument.value);
|
bbox = new Box3();
|
||||||
if (!boundingBox) return; // No models. Should not happen.
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
bbox = boundingBox.translate(scene.getTarget());
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
|
bbox.expandByObject(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
sceneModel.traverse((child: MObject3D) => {
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
if (child.userData[extrasNameKey] === modelName) {
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
if (child.material) {
|
if (child.material) {
|
||||||
if (bbox) {
|
if (bbox?.isEmpty() == false) {
|
||||||
let offsetX = bbox.min.x + clipPlaneX.value * (bbox.max.x - bbox.min.x);
|
let offsetX = bbox.min.x + clipPlaneX.value * (bbox.max.x - bbox.min.x);
|
||||||
let offsetY = bbox.min.z + clipPlaneY.value * (bbox.max.z - bbox.min.z);
|
let offsetY = bbox.min.y + clipPlaneY.value * (bbox.max.y - bbox.min.y);
|
||||||
let offsetZ = bbox.min.y + clipPlaneZ.value * (bbox.max.y - bbox.min.y);
|
let offsetZ = bbox.min.z + (1 - clipPlaneZ.value) * (bbox.max.z - bbox.min.z);
|
||||||
|
let rotSceneMatrix = new Matrix4().makeRotationY(currentSceneRotation);
|
||||||
let planes = [
|
let planes = [
|
||||||
new Plane(new Vector3(-1, 0, 0), offsetX),
|
new Plane(new Vector3(-1, 0, 0), offsetX).applyMatrix4(rotSceneMatrix),
|
||||||
new Plane(new Vector3(0, 0, 1), offsetY),
|
new Plane(new Vector3(0, -1, 0), offsetY).applyMatrix4(rotSceneMatrix),
|
||||||
new Plane(new Vector3(0, -1, 0), offsetZ),
|
new Plane(new Vector3(0, 0, 1), -offsetZ).applyMatrix4(rotSceneMatrix),
|
||||||
];
|
];
|
||||||
if (clipPlaneSwappedX.value) planes[0].negate();
|
if (clipPlaneSwappedX.value) planes[0]?.negate();
|
||||||
if (clipPlaneSwappedY.value) planes[1].negate();
|
if (clipPlaneSwappedY.value) planes[1]?.negate();
|
||||||
if (clipPlaneSwappedZ.value) planes[2].negate();
|
if (clipPlaneSwappedZ.value) planes[2]?.negate();
|
||||||
if (!enabledZ) planes.pop();
|
if (!enabledZ) planes.pop();
|
||||||
if (!enabledY) planes.splice(1, 1);
|
if (!enabledY) planes.splice(1, 1);
|
||||||
if (!enabledX) planes.shift();
|
if (!enabledX) planes.shift();
|
||||||
@@ -166,20 +193,177 @@ watch(clipPlaneZ, onClipPlanesChange);
|
|||||||
watch(clipPlaneSwappedX, onClipPlanesChange);
|
watch(clipPlaneSwappedX, onClipPlanesChange);
|
||||||
watch(clipPlaneSwappedY, onClipPlanesChange);
|
watch(clipPlaneSwappedY, onClipPlanesChange);
|
||||||
watch(clipPlaneSwappedZ, onClipPlanesChange);
|
watch(clipPlaneSwappedZ, onClipPlanesChange);
|
||||||
// Clip planes are also affected by the camera position, so we need to listen to camera changes
|
|
||||||
props.viewer!!.onElemReady((elem) => elem.addEventListener('camera-change', onClipPlanesChange))
|
let edgeWidthChangeCleanup = [] as Array<() => void>;
|
||||||
|
|
||||||
|
function onEdgeWidthChange(newEdgeWidth: number) {
|
||||||
|
let scene = props.viewer?.scene;
|
||||||
|
let sceneModel = (scene as any)?._model;
|
||||||
|
if (!scene || !sceneModel) return;
|
||||||
|
edgeWidthChangeCleanup.forEach((f) => f());
|
||||||
|
edgeWidthChangeCleanup = [];
|
||||||
|
let linesToImprove: Array<MObject3D> = [];
|
||||||
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
|
if (child.type == 'Line' || child.type == 'LineSegments') {
|
||||||
|
// child.material.linewidth = 3; // Not supported in WebGL2
|
||||||
|
// Swap geometry with LineGeometry to support widths
|
||||||
|
// https://threejs.org/examples/?q=line#webgl_lines_fat
|
||||||
|
if (newEdgeWidth > 0) linesToImprove.push(child);
|
||||||
|
}
|
||||||
|
if (child.type == 'Points') {
|
||||||
|
(child.material as any).size = newEdgeWidth > 0 ? newEdgeWidth * 50 : 5;
|
||||||
|
child.material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
linesToImprove.forEach(async (line: MObject3D) => {
|
||||||
|
let line2 = await toLineSegments(line.geometry, newEdgeWidth);
|
||||||
|
// Update resolution on resize
|
||||||
|
let resizeListener = (elem: HTMLElement) => {
|
||||||
|
line2.material.resolution.set(elem.clientWidth, elem.clientHeight);
|
||||||
|
line2.material.needsUpdate = true;
|
||||||
|
};
|
||||||
|
props.viewer!!.onElemReady((elem) => {
|
||||||
|
elem.addEventListener('resize', () => resizeListener(elem));
|
||||||
|
resizeListener(elem);
|
||||||
|
});
|
||||||
|
// Copy the transform of the original line
|
||||||
|
line2.position.copy(line.position);
|
||||||
|
line2.computeLineDistances();
|
||||||
|
line2.userData = Object.assign({}, line.userData);
|
||||||
|
line.parent!.add(line2);
|
||||||
|
line.children.forEach((o) => line2.add(o));
|
||||||
|
line.visible = false;
|
||||||
|
line.userData.niceLine = line2;
|
||||||
|
// line.parent!.remove(line); // Keep it for better raycast and selection!
|
||||||
|
line2.userData.noHit = true;
|
||||||
|
line2.visible = enabledFeatures.value.includes(1);
|
||||||
|
edgeWidthChangeCleanup.push(() => {
|
||||||
|
line2.parent!.remove(line2);
|
||||||
|
line.visible = enabledFeatures.value.includes(1);
|
||||||
|
props.viewer!!.onElemReady((elem) => {
|
||||||
|
elem.removeEventListener('resize', () => resizeListener(elem));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
scene.queueRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(edgeWidth, onEdgeWidthChange);
|
||||||
|
|
||||||
|
// Explode the model
|
||||||
|
function onExplodeChange(newExplodeStrength: number) {
|
||||||
|
let scene = props.viewer?.scene;
|
||||||
|
let sceneModel = (scene as any)?._model;
|
||||||
|
if (!scene || !sceneModel) return;
|
||||||
|
|
||||||
|
// Get direction and size of the explosion in a first pass
|
||||||
|
const meBbox = new Box3();
|
||||||
|
const othersBbox = new Box3();
|
||||||
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
|
if (child == sceneModel) return; // Skip the scene itself
|
||||||
|
const isMe = child.userData[extrasNameKey] === modelName;
|
||||||
|
if ((child.type === 'Mesh' || child.type === 'SkinnedMesh' ||
|
||||||
|
child.type === 'Line' || child.type === 'LineSegments' ||
|
||||||
|
child.type === 'Points') && !child.userData.noHit) {
|
||||||
|
if (isMe) {
|
||||||
|
meBbox.expandByObject(child);
|
||||||
|
} else if (!isMe && child.userData[extrasNameKey]) {
|
||||||
|
othersBbox.expandByObject(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const modelSize = new Vector3();
|
||||||
|
meBbox.getSize(modelSize);
|
||||||
|
const maxDimension = Math.max(modelSize.x, modelSize.y, modelSize.z);
|
||||||
|
const pushDirection = new Vector3().subVectors(meBbox.getCenter(new Vector3()), othersBbox.getCenter(new Vector3())).normalize();
|
||||||
|
|
||||||
|
|
||||||
|
// Use absolute value for strength calculation
|
||||||
|
let strength = Math.abs(newExplodeStrength);
|
||||||
|
if (explodeSwapped.value) strength = -strength;
|
||||||
|
|
||||||
|
// Apply explosion
|
||||||
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
|
if ((child.type === 'Mesh' || child.type === 'SkinnedMesh' ||
|
||||||
|
child.type === 'Line' || child.type === 'LineSegments' ||
|
||||||
|
child.type === 'Points')) {
|
||||||
|
|
||||||
|
// Handle zero vector case (if object is at origin)
|
||||||
|
const direction = pushDirection.clone();
|
||||||
|
if (direction.lengthSq() < 0.0001) {
|
||||||
|
direction.set(0, 1, 0);
|
||||||
|
console.warn("Explode direction was zero, using (0, 1, 0) instead");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new position based on model size
|
||||||
|
const factor = strength * maxDimension;
|
||||||
|
const newPosition = new Vector3().add(direction.multiplyScalar(factor));
|
||||||
|
|
||||||
|
// Apply new position
|
||||||
|
child.position.copy(newPosition);
|
||||||
|
|
||||||
|
// Update related objects (back is automatically updated)
|
||||||
|
if (child.userData.niceLine) {
|
||||||
|
child.userData.niceLine.position.copy(newPosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
scene.queueRender();
|
||||||
|
onClipPlanesChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add watchers for explode variables
|
||||||
|
watch(explodeStrength, (newVal) => onExplodeChange(newVal));
|
||||||
|
watch(explodeSwapped, () => onExplodeChange(explodeStrength.value));
|
||||||
|
|
||||||
|
|
||||||
function onModelLoad() {
|
function onModelLoad() {
|
||||||
let scene = props.viewer?.scene;
|
let scene = props.viewer?.scene;
|
||||||
let sceneModel = (scene as any)?._model;
|
let sceneModel = (scene as any)?._model;
|
||||||
if (!scene || !sceneModel) return;
|
if (!scene || !sceneModel) return;
|
||||||
// Iterate all primitives of the mesh and set their visibility based on the enabled features
|
|
||||||
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
|
// Count the number of faces, edges and vertices
|
||||||
// of not actually removing the primitives from the scene graph
|
const isFirstLoad = faceCount.value === -1;
|
||||||
|
faceCount.value = props.meshes
|
||||||
|
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES))
|
||||||
|
.map(p => (p.getExtras()?.face_triangles_end as any)?.length ?? 1)
|
||||||
|
.reduce((a, b) => a + b, 0)
|
||||||
|
edgeCount.value = props.meshes
|
||||||
|
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES]))
|
||||||
|
.map(p => (p.getExtras()?.edge_points_end as any)?.length ?? 0)
|
||||||
|
.reduce((a, b) => a + b, 0)
|
||||||
|
vertexCount.value = props.meshes
|
||||||
|
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS))
|
||||||
|
.map(p => (p.getAttribute("POSITION")?.getCount() ?? 0))
|
||||||
|
.reduce((a, b) => a + b, 0)
|
||||||
|
|
||||||
|
// First time: set the enabled features to all provided features
|
||||||
|
if (isFirstLoad) {
|
||||||
|
if (faceCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0)
|
||||||
|
else if (!enabledFeatures.value.includes(0)) enabledFeatures.value.push(0)
|
||||||
|
if (edgeCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 1)
|
||||||
|
else if (!enabledFeatures.value.includes(1)) enabledFeatures.value.push(1)
|
||||||
|
if (vertexCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 2)
|
||||||
|
else if (!enabledFeatures.value.includes(2)) enabledFeatures.value.push(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add darkened back faces for all face objects to improve cutting planes
|
||||||
let childrenToAdd: Array<MObject3D> = [];
|
let childrenToAdd: Array<MObject3D> = [];
|
||||||
sceneModel.traverse((child: MObject3D) => {
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
|
child.updateMatrixWorld(); // Objects are mostly static, so ensure updated matrices
|
||||||
if (child.userData[extrasNameKey] === modelName) {
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
if (child.type == 'Mesh' || child.type == 'SkinnedMesh') {
|
if (child.type == 'Mesh' || child.type == 'SkinnedMesh') {
|
||||||
|
// Compute a BVH for faster raycasting (MUCH faster selection)
|
||||||
|
// @ts-ignore
|
||||||
|
child.geometry?.computeBoundsTree({indirect: true}); // indirect to avoid changing index order
|
||||||
|
// TODO: Accelerated raycast for lines and points (https://github.com/gkjohnson/three-mesh-bvh/issues/243)
|
||||||
|
// TODO: ParallelMeshBVHWorker
|
||||||
|
|
||||||
// We could implement cutting planes using the stencil buffer:
|
// We could implement cutting planes using the stencil buffer:
|
||||||
// https://threejs.org/examples/?q=clipping#webgl_clipping_stencil
|
// https://threejs.org/examples/?q=clipping#webgl_clipping_stencil
|
||||||
// But this is buggy for lots of models, so instead we just draw
|
// But this is buggy for lots of models, so instead we just draw
|
||||||
@@ -193,112 +377,143 @@ function onModelLoad() {
|
|||||||
backChild.material = child.material.clone();
|
backChild.material = child.material.clone();
|
||||||
backChild.material.side = BackSide;
|
backChild.material.side = BackSide;
|
||||||
backChild.material.color = new Color(0.25, 0.25, 0.25)
|
backChild.material.color = new Color(0.25, 0.25, 0.25)
|
||||||
|
backChild.userData.noHit = true;
|
||||||
child.userData.backChild = backChild;
|
child.userData.backChild = backChild;
|
||||||
childrenToAdd.push(backChild as MObject3D);
|
childrenToAdd.push(backChild as MObject3D);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// if (child.type == 'Line' || child.type == 'LineSegments') {
|
|
||||||
// child.material.linewidth = 3; // Not supported in WebGL2
|
|
||||||
// If wide lines are really needed, we need https://threejs.org/examples/?q=line#webgl_lines_fat
|
|
||||||
// }
|
|
||||||
if (child.type == 'Points') {
|
|
||||||
(child.material as any).size = 5;
|
|
||||||
child.material.needsUpdate = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
childrenToAdd.forEach((child: MObject3D) => sceneModel.add(child));
|
childrenToAdd.forEach((child: MObject3D) => sceneModel.add(child));
|
||||||
scene.queueRender()
|
|
||||||
|
|
||||||
// Furthermore...
|
// Furthermore...
|
||||||
// Enabled features may have been reset after a reload
|
// Enabled features may have been reset after a reload
|
||||||
onEnabledFeaturesChange(enabledFeatures.value)
|
onEnabledFeaturesChange(enabledFeatures.value);
|
||||||
// Opacity may have been reset after a reload
|
// Opacity may have been reset after a reload
|
||||||
onOpacityChange(opacity.value)
|
onOpacityChange(opacity.value);
|
||||||
|
// Wireframe may have been reset after a reload
|
||||||
|
onWireframeChange(wireframe.value);
|
||||||
// Clip planes may have been reset after a reload
|
// Clip planes may have been reset after a reload
|
||||||
onClipPlanesChange()
|
onClipPlanesChange();
|
||||||
|
// Edge width may have been reset after a reload
|
||||||
|
onEdgeWidthChange(edgeWidth.value);
|
||||||
|
// Explode may have been reset after a reload
|
||||||
|
if (explodeStrength.value > 0) nextTick(() => onExplodeChange(explodeStrength.value));
|
||||||
|
|
||||||
|
scene.queueRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
// props.viewer.elem may not yet be available, so we need to wait for it
|
// props.viewer.elem may not yet be available, so we need to wait for it
|
||||||
props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
|
const onViewerReady = (viewer: InstanceType<typeof ModelViewerWrapper>) => {
|
||||||
|
viewer?.onElemReady((elem: HTMLElement) => {
|
||||||
|
elem.addEventListener('before-render', onModelLoad);
|
||||||
|
elem.addEventListener('camera-change', onClipPlanesChange);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (props.viewer) onViewerReady(props.viewer); else watch((() => props.viewer) as any, onViewerReady);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-expansion-panel :value="modelName">
|
<v-expansion-panel :value="modelName">
|
||||||
<v-expansion-panel-title expand-icon="hide-this-icon" collapse-icon="hide-this-icon">
|
<v-expansion-panel-title>
|
||||||
<v-btn-toggle v-model="enabledFeatures" multiple @click.stop color="surface-light">
|
<v-btn-toggle v-model="enabledFeatures" color="surface-light" multiple @click.stop>
|
||||||
<v-btn icon>
|
<v-btn icon>
|
||||||
<v-tooltip activator="parent">Toggle Faces ({{ faceCount }})</v-tooltip>
|
<v-tooltip activator="parent">Toggle Faces ({{ faceCount }})</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiRectangle" :rotate="90"></svg-icon>
|
<svg-icon :path="mdiRectangle" :rotate="90" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon>
|
<v-btn icon>
|
||||||
<v-tooltip activator="parent">Toggle Edges ({{ edgeCount }})</v-tooltip>
|
<v-tooltip activator="parent">Toggle Edges ({{ edgeCount }})</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiRectangleOutline" :rotate="90"></svg-icon>
|
<svg-icon :path="mdiRectangleOutline" :rotate="90" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon>
|
<v-btn icon>
|
||||||
<v-tooltip activator="parent">Toggle Vertices ({{ vertexCount }})</v-tooltip>
|
<v-tooltip activator="parent">Toggle Vertices ({{ vertexCount }})</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiVectorRectangle" :rotate="90"></svg-icon>
|
<svg-icon :path="mdiVectorRectangle" :rotate="90" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-btn-toggle>
|
</v-btn-toggle>
|
||||||
<div class="model-name">{{ modelName }}</div>
|
<div class="model-name">{{ modelName }}</div>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<v-btn icon @click.stop="emit('remove')">
|
<v-btn icon @click.stop="emit('remove')">
|
||||||
<v-tooltip activator="parent">Remove</v-tooltip>
|
<v-tooltip activator="parent">Remove</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiDelete"></svg-icon>
|
<svg-icon :path="mdiDelete" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-expansion-panel-title>
|
</v-expansion-panel-title>
|
||||||
<v-expansion-panel-text>
|
<v-expansion-panel-text>
|
||||||
<v-slider v-model="opacity" hide-details min="0" max="1" :step="0.1">
|
<v-slider v-model="opacity" :step="0.1" hide-details max="1" min="0">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-tooltip activator="parent">Change opacity</v-tooltip>
|
<v-tooltip activator="parent">Change opacity</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCircleOpacity"></svg-icon>
|
<svg-icon :path="mdiCircleOpacity" type="mdi"></svg-icon>
|
||||||
|
</template>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-tooltip activator="parent">Wireframe</v-tooltip>
|
||||||
|
<v-checkbox-btn v-model="wireframe" falseIcon="mdi-triangle" trueIcon="mdi-triangle-outline"></v-checkbox-btn>
|
||||||
|
</template>
|
||||||
|
</v-slider>
|
||||||
|
<v-slider v-model="explodeStrength" hide-details max="1" min="0">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-tooltip activator="parent">Explode model</v-tooltip>
|
||||||
|
<svg-icon :path="mdiArrowExpand" type="mdi"></svg-icon>
|
||||||
|
</template>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-tooltip activator="parent">Swap explode direction (may go crazy)</v-tooltip>
|
||||||
|
<v-checkbox-btn v-model="explodeSwapped" falseIcon="mdi-checkbox-blank-outline"
|
||||||
|
trueIcon="mdi-checkbox-marked-outline">
|
||||||
|
<template v-slot:label>
|
||||||
|
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
|
||||||
|
</template>
|
||||||
|
</v-checkbox-btn>
|
||||||
|
</template>
|
||||||
|
</v-slider>
|
||||||
|
<v-slider v-if="edgeCount > 0 || vertexCount > 0" v-model="edgeWidth" hide-details max="1" min="0">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-tooltip activator="parent">Edge and vertex sizes</v-tooltip>
|
||||||
|
<svg-icon :path="mdiVectorLine" type="mdi"></svg-icon>
|
||||||
</template>
|
</template>
|
||||||
</v-slider>
|
</v-slider>
|
||||||
<v-divider></v-divider>
|
<v-divider></v-divider>
|
||||||
<v-slider v-model="clipPlaneX" hide-details min="0" max="1">
|
<v-slider v-model="clipPlaneX" hide-details max="1" min="0">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-tooltip activator="parent">Clip plane X</v-tooltip>
|
<v-tooltip activator="parent">Clip plane X</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCube" :rotate="120"></svg-icon>
|
<svg-icon :path="mdiCube" :rotate="120" type="mdi"></svg-icon>
|
||||||
X
|
X
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<v-tooltip activator="parent">Swap clip plane X</v-tooltip>
|
<v-tooltip activator="parent">Swap clip plane X</v-tooltip>
|
||||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
<v-checkbox-btn v-model="clipPlaneSwappedX" falseIcon="mdi-checkbox-blank-outline"
|
||||||
v-model="clipPlaneSwappedX">
|
trueIcon="mdi-checkbox-marked-outline">
|
||||||
<template v-slot:label>
|
<template v-slot:label>
|
||||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox-btn>
|
</v-checkbox-btn>
|
||||||
</template>
|
</template>
|
||||||
</v-slider>
|
</v-slider>
|
||||||
<v-slider v-model="clipPlaneY" hide-details min="0" max="1">
|
<v-slider v-model="clipPlaneZ" hide-details max="1" min="0">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-tooltip activator="parent">Clip plane Y</v-tooltip>
|
<v-tooltip activator="parent">Clip plane Y</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCube" :rotate="-120"></svg-icon>
|
<svg-icon :path="mdiCube" :rotate="-120" type="mdi"></svg-icon>
|
||||||
Y
|
Y
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<v-tooltip activator="parent">Swap clip plane Y</v-tooltip>
|
<v-tooltip activator="parent">Swap clip plane Y</v-tooltip>
|
||||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
<v-checkbox-btn v-model="clipPlaneSwappedZ" falseIcon="mdi-checkbox-blank-outline"
|
||||||
v-model="clipPlaneSwappedY">
|
trueIcon="mdi-checkbox-marked-outline">
|
||||||
<template v-slot:label>
|
<template v-slot:label>
|
||||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox-btn>
|
</v-checkbox-btn>
|
||||||
</template>
|
</template>
|
||||||
</v-slider>
|
</v-slider>
|
||||||
<v-slider v-model="clipPlaneZ" hide-details min="0" max="1">
|
<v-slider v-model="clipPlaneY" hide-details max="1" min="0">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-tooltip activator="parent">Clip plane Z</v-tooltip>
|
<v-tooltip activator="parent">Clip plane Z</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCube"></svg-icon>
|
<svg-icon :path="mdiCube" type="mdi"></svg-icon>
|
||||||
Z
|
Z
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<v-tooltip activator="parent">Swap clip plane Z</v-tooltip>
|
<v-tooltip activator="parent">Swap clip plane Z</v-tooltip>
|
||||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
<v-checkbox-btn v-model="clipPlaneSwappedY" falseIcon="mdi-checkbox-blank-outline"
|
||||||
v-model="clipPlaneSwappedZ">
|
trueIcon="mdi-checkbox-marked-outline">
|
||||||
<template v-slot:label>
|
<template v-slot:label>
|
||||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox-btn>
|
</v-checkbox-btn>
|
||||||
</template>
|
</template>
|
||||||
@@ -314,10 +529,6 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* More compact accordions */
|
/* More compact accordions */
|
||||||
.v-expansion-panel {
|
|
||||||
margin-top: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-expansion-panel-title {
|
.v-expansion-panel-title {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -328,15 +539,16 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
|
|||||||
}
|
}
|
||||||
|
|
||||||
.v-btn {
|
.v-btn {
|
||||||
--v-btn-height: 16px;
|
--v-btn-height: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-name {
|
.model-name {
|
||||||
width: 130px;
|
width: 172px;
|
||||||
min-height: 1.15em; /* HACK: Avoid eating the bottom of the text when using 1 line */
|
font-size: 110%;
|
||||||
max-height: 2em;
|
overflow-x: clip;
|
||||||
|
overflow-y: visible; /* HACK: bottom of text is lost otherwise (due to buggy -webkit-box bounds?) */
|
||||||
|
word-wrap: break-word;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2; /* https://caniuse.com/?search=line-clamp */
|
-webkit-line-clamp: 2; /* https://caniuse.com/?search=line-clamp */
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
@@ -348,10 +560,6 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
|
|||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hide-this-icon {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mdi-checkbox-blank-outline { /* HACK: mdi is not fully imported, only required icons... */
|
.mdi-checkbox-blank-outline { /* HACK: mdi is not fully imported, only required icons... */
|
||||||
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,5V19H5V5H19Z"/></svg>');
|
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,5V19H5V5H19Z"/></svg>');
|
||||||
}
|
}
|
||||||
@@ -359,4 +567,12 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
|
|||||||
.mdi-checkbox-marked-outline { /* HACK: mdi is not fully imported, only required icons... */
|
.mdi-checkbox-marked-outline { /* HACK: mdi is not fully imported, only required icons... */
|
||||||
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M19,19H5V5H15V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V11H19M7.91,10.08L6.5,11.5L11,16L21,6L19.59,4.58L11,13.17L7.91,10.08Z"/></svg>');
|
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M19,19H5V5H15V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V11H19M7.91,10.08L6.5,11.5L11,16L21,6L19.59,4.58L11,13.17L7.91,10.08Z"/></svg>');
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
.mdi-triangle { /* HACK: mdi is not fully imported, only required icons... */
|
||||||
|
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M1 21h22L12 2"/></svg>');
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdi-triangle-outline { /* HACK: mdi is not fully imported, only required icons... */
|
||||||
|
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M12 2L1 21h22M12 6l7.53 13H4.47"/></svg>');
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {VExpansionPanels} from "vuetify/lib/components/index.mjs";
|
import {VExpansionPanels} from "vuetify/lib/components/index.mjs";
|
||||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||||
import {Document, Mesh} from "@gltf-transform/core";
|
import {Document, Mesh} from "@gltf-transform/core";
|
||||||
@@ -7,17 +7,17 @@ import Model from "./Model.vue";
|
|||||||
import {inject, ref, type Ref} from "vue";
|
import {inject, ref, type Ref} from "vue";
|
||||||
|
|
||||||
const props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
const props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
||||||
const emit = defineEmits<{ remove: [string] }>()
|
const emit = defineEmits<{ removeModel: [string] }>()
|
||||||
|
|
||||||
let {sceneDocument} = inject<{ sceneDocument: Ref<Document> }>('sceneDocument')!!;
|
let {sceneDocument} = inject<{ sceneDocument: Ref<Document> }>('sceneDocument')!!;
|
||||||
|
|
||||||
let expandedNames = ref<Array<string>>([]);
|
const expandedNames = ref<Array<string>>([]);
|
||||||
|
|
||||||
function meshesList(sceneDocument: Document): Array<Array<Mesh>> {
|
function meshesList(sceneDocument: Document): Array<Array<Mesh>> {
|
||||||
// Grouped by shared name
|
// Grouped by shared name
|
||||||
return sceneDocument.getRoot().listMeshes().reduce((acc, mesh) => {
|
return sceneDocument.getRoot().listMeshes().reduce((acc, mesh) => {
|
||||||
let name = mesh.getExtras()[extrasNameKey]?.toString() ?? 'Unnamed';
|
let name = mesh.getExtras()[extrasNameKey]?.toString() ?? 'Unnamed';
|
||||||
let group = acc.find((group) => meshName(group[0]) === name);
|
let group = acc.find((group) => group[0] && meshName(group[0]) === name);
|
||||||
if (group) {
|
if (group) {
|
||||||
group.push(mesh);
|
group.push(mesh);
|
||||||
} else {
|
} else {
|
||||||
@@ -32,22 +32,20 @@ function meshName(mesh: Mesh) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onRemove(mesh: Mesh) {
|
function onRemove(mesh: Mesh) {
|
||||||
emit('remove', meshName(mesh))
|
emit('removeModel', meshName(mesh))
|
||||||
}
|
}
|
||||||
|
|
||||||
function findModel(name: string) {
|
function findModel(name: string) {
|
||||||
console.log('Find model', name);
|
|
||||||
if (!expandedNames.value.includes(name)) expandedNames.value.push(name);
|
if (!expandedNames.value.includes(name)) expandedNames.value.push(name);
|
||||||
console.log('Expanded', expandedNames.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({findModel})
|
defineExpose({findModel})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-expansion-panels v-for="meshes in meshesList(sceneDocument)" :key="meshName(meshes[0])"
|
<v-expansion-panels v-for="meshes in meshesList(sceneDocument)" :key="meshes[0] ? meshName(meshes[0]) : 'unnamed'"
|
||||||
v-model="expandedNames" multiple>
|
v-model="expandedNames as any" multiple>
|
||||||
<model :meshes="meshes" :viewer="props.viewer" @remove="onRemove(meshes[0])"/>
|
<model :meshes="meshes" :viewer="props.viewer" @remove="meshes[0] ? onRemove(meshes[0]) : undefined"/>
|
||||||
</v-expansion-panels>
|
</v-expansion-panels>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -63,4 +61,4 @@ defineExpose({findModel})
|
|||||||
.v-overlay--active > .v-overlay__content {
|
.v-overlay--active > .v-overlay__content {
|
||||||
display: block !important; /* HACK: Fix buggy tooltips not showing? */
|
display: block !important; /* HACK: Fix buggy tooltips not showing? */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
3
frontend/shims.d.ts
vendored
3
frontend/shims.d.ts
vendored
@@ -1,3 +0,0 @@
|
|||||||
// Avoids typescript error when importing some files
|
|
||||||
declare module '@jamescoyle/vue-icon'
|
|
||||||
declare module 'three-orientation-gizmo/src/OrientationGizmo'
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
// License text for all dependencies, only downloaded when/if needed
|
// License text for all dependencies, only downloaded when/if needed
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import licenseText from "../../assets/licenses.txt?raw";
|
import licenseText from "../../assets/licenses.txt?raw";
|
||||||
|
|||||||
@@ -1,27 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {onMounted, onUpdated, ref} from "vue";
|
import {onMounted, onUpdated, ref} from "vue";
|
||||||
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||||
|
// @ts-expect-error
|
||||||
import * as OrientationGizmoRaw from "three-orientation-gizmo/src/OrientationGizmo";
|
import * as OrientationGizmoRaw from "three-orientation-gizmo/src/OrientationGizmo";
|
||||||
import type {ModelViewerElement} from '@google/model-viewer';
|
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||||
|
import {currentSceneRotation} from "../viewer/lighting.ts";
|
||||||
|
|
||||||
// Optimized minimal dependencies from three
|
// Optimized minimal dependencies from three
|
||||||
import {Vector3} from "three/src/math/Vector3.js";
|
import {Vector3} from "three/src/math/Vector3.js";
|
||||||
import {Matrix4} from "three/src/math/Matrix4.js";
|
import {Matrix4} from "three/src/math/Matrix4.js";
|
||||||
|
import {Euler} from "three/src/math/Euler.js";
|
||||||
|
|
||||||
globalThis.THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
|
(globalThis as any).THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
|
||||||
|
|
||||||
const OrientationGizmo = OrientationGizmoRaw.default;
|
const props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> }>();
|
||||||
|
|
||||||
const props = defineProps<{ elem: ModelViewerElement | null, scene: ModelScene }>();
|
|
||||||
|
|
||||||
function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElement {
|
function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElement {
|
||||||
// noinspection SpellCheckingInspection
|
// noinspection SpellCheckingInspection
|
||||||
let gizmo = new OrientationGizmoRaw.default(scene.camera, {
|
let gizmo = new OrientationGizmoRaw.default(scene.camera, {
|
||||||
size: expectedParent.clientWidth,
|
size: expectedParent.clientWidth,
|
||||||
bubbleSizePrimary: expectedParent.clientWidth / 12,
|
bubbleSizePrimary: expectedParent.clientWidth / 12,
|
||||||
bubbleSizeSeconday: expectedParent.clientWidth / 14,
|
bubbleSizeSeconday: expectedParent.clientWidth / 12,
|
||||||
fontSize: (expectedParent.clientWidth / 10) + "px"
|
fontSize: (expectedParent.clientWidth / 10) + "px",
|
||||||
});
|
});
|
||||||
|
// Make sure all bubbles are labeled
|
||||||
|
for (let bubble of gizmo.bubbles) {
|
||||||
|
bubble.label = bubble.axis.toUpperCase();
|
||||||
|
}
|
||||||
// HACK: Swap axes to fake the CAD orientation
|
// HACK: Swap axes to fake the CAD orientation
|
||||||
for (let swap of [["y", "-z"], ["z", "-y"], ["z", "-z"]]) {
|
for (let swap of [["y", "-z"], ["z", "-y"], ["z", "-z"]]) {
|
||||||
let indexA = gizmo.bubbles.findIndex((bubble: any) => bubble.axis == swap[0])
|
let indexA = gizmo.bubbles.findIndex((bubble: any) => bubble.axis == swap[0])
|
||||||
@@ -33,21 +38,26 @@ function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElemen
|
|||||||
}
|
}
|
||||||
// Append and listen for events
|
// Append and listen for events
|
||||||
gizmo.onAxisSelected = (axis: { direction: { x: any; y: any; z: any; }; }) => {
|
gizmo.onAxisSelected = (axis: { direction: { x: any; y: any; z: any; }; }) => {
|
||||||
let lookFrom = scene.getCamera().position.clone();
|
if (!props.viewer.elem || !props.viewer.controls) return;
|
||||||
let lookAt = scene.getTarget().clone().add(scene.target.position);
|
// Animate the controls to the new wanted angle
|
||||||
let magnitude = lookFrom.clone().sub(lookAt).length()
|
const controls = props.viewer.controls;
|
||||||
let direction = new Vector3(axis.direction.x, axis.direction.y, axis.direction.z);
|
const {theta: curTheta/*, phi: curPhi*/} = (controls as any).goalSpherical;
|
||||||
let newLookFrom = lookAt.clone().add(direction.clone().multiplyScalar(magnitude));
|
let wantedTheta = NaN;
|
||||||
//console.log("New camera position", newLookFrom)
|
let wantedPhi = NaN;
|
||||||
scene.getCamera().position.copy(newLookFrom);
|
let attempt = 0
|
||||||
scene.getCamera().lookAt(lookAt);
|
while ((attempt == 0 || curTheta == wantedTheta) && attempt < 2) {
|
||||||
if ((scene as any).__perspectiveCamera) { // HACK: Make the hacky ortho also work
|
if (attempt > 0) { // Flip the camera if the user clicks on the same axis
|
||||||
(scene as any).__perspectiveCamera.position.copy(newLookFrom);
|
axis.direction.x = -axis.direction.x;
|
||||||
(scene as any).__perspectiveCamera.lookAt(lookAt);
|
axis.direction.y = -axis.direction.y;
|
||||||
|
axis.direction.z = -axis.direction.z;
|
||||||
|
}
|
||||||
|
wantedTheta = Math.atan2(axis.direction.x, axis.direction.z) + currentSceneRotation;
|
||||||
|
wantedPhi = Math.asin(-axis.direction.y) + Math.PI / 2;
|
||||||
|
attempt++;
|
||||||
}
|
}
|
||||||
|
controls.setOrbit(wantedTheta, wantedPhi);
|
||||||
|
props.viewer.elem?.dispatchEvent(new CustomEvent('camera-change', {detail: {source: 'none'}}))
|
||||||
scene.queueRender();
|
scene.queueRender();
|
||||||
requestIdleCallback(() => props.elem?.dispatchEvent(
|
|
||||||
new CustomEvent('camera-change', {detail: {source: 'none'}})), {timeout: 100})
|
|
||||||
}
|
}
|
||||||
return gizmo;
|
return gizmo;
|
||||||
}
|
}
|
||||||
@@ -59,15 +69,20 @@ let gizmo: HTMLElement & { update: () => void }
|
|||||||
|
|
||||||
function updateGizmo() {
|
function updateGizmo() {
|
||||||
if (gizmo.isConnected) {
|
if (gizmo.isConnected) {
|
||||||
|
// HACK: Update camera temporarily to match skybox rotation before updating the gizmo and go back
|
||||||
|
let prevRot = ((gizmo as any).camera).rotation.clone() as Euler;
|
||||||
|
let thetaMat = new Matrix4().makeRotationY(-currentSceneRotation);
|
||||||
|
((gizmo as any).camera).rotation.setFromRotationMatrix(thetaMat.multiply(new Matrix4().makeRotationFromEuler(prevRot)));
|
||||||
gizmo.update();
|
gizmo.update();
|
||||||
|
((gizmo as any).camera).rotation.set(prevRot.x, prevRot.y, prevRot.z);
|
||||||
requestIdleCallback(updateGizmo, {timeout: 250});
|
requestIdleCallback(updateGizmo, {timeout: 250});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let reinstall = () => {
|
let reinstall = () => {
|
||||||
if(!container.value) return;
|
if (!container.value) return;
|
||||||
if (gizmo) container.value.removeChild(gizmo);
|
if (gizmo) container.value.removeChild(gizmo);
|
||||||
gizmo = createGizmo(container.value, props.scene as ModelScene) as typeof gizmo;
|
gizmo = createGizmo(container.value, props.viewer.scene!! as any) as typeof gizmo;
|
||||||
container.value.appendChild(gizmo);
|
container.value.appendChild(gizmo);
|
||||||
requestIdleCallback(updateGizmo, {timeout: 250}); // Low priority updates
|
requestIdleCallback(updateGizmo, {timeout: 250}); // Low priority updates
|
||||||
}
|
}
|
||||||
|
|||||||
414
frontend/tools/PlaygroundDialogContent.vue
Normal file
414
frontend/tools/PlaygroundDialogContent.vue
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {setupMonaco} from "./monaco.ts";
|
||||||
|
import {VueMonacoEditor} from '@guolao/vue-monaco-editor'
|
||||||
|
import {nextTick, onMounted, ref, shallowRef} from "vue";
|
||||||
|
import Loading from "../misc/Loading.vue";
|
||||||
|
import {newPyodideWorker} from "./pyodide-worker-api.ts";
|
||||||
|
import {
|
||||||
|
mdiBroom,
|
||||||
|
mdiCircleOpacity,
|
||||||
|
mdiClose,
|
||||||
|
mdiContentSave,
|
||||||
|
mdiFolderOpen,
|
||||||
|
mdiPlay,
|
||||||
|
mdiReload,
|
||||||
|
mdiShare,
|
||||||
|
mdiUpload
|
||||||
|
} from "@mdi/js";
|
||||||
|
import {VBtn, VCard, VCardText, VSlider, VSpacer, VToolbar, VToolbarTitle, VTooltip} from "vuetify/components";
|
||||||
|
// @ts-expect-error
|
||||||
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
|
import {version as pyodideVersion} from "pyodide";
|
||||||
|
import {gzip} from 'pako';
|
||||||
|
import {b64UrlEncode} from "./b64.ts";
|
||||||
|
import {Base64} from 'js-base64'; // More compatible with binary data from python...
|
||||||
|
import {NetworkUpdateEvent, NetworkUpdateEventModel} from "../misc/network.ts";
|
||||||
|
import {settings} from "../misc/settings.ts";
|
||||||
|
// @ts-expect-error
|
||||||
|
import playgroundStartupCode from './PlaygroundStartup.py?raw';
|
||||||
|
import {uploadFile} from "./upload-file.ts";
|
||||||
|
|
||||||
|
const model = defineModel<{ code: string, firstTime: boolean }>({required: true}); // Initial code should only be set on first load!
|
||||||
|
const emit = defineEmits<{ close: [], updateModel: [NetworkUpdateEvent] }>()
|
||||||
|
|
||||||
|
// ============ LOAD MONACO EDITOR ============
|
||||||
|
setupMonaco() // Must be called before using the editor
|
||||||
|
|
||||||
|
const outputText = ref(``);
|
||||||
|
|
||||||
|
function output(text: string) {
|
||||||
|
outputText.value += text; // Append to output
|
||||||
|
// Avoid too much output, keep it reasonable
|
||||||
|
let max_output = 10000; // 10k characters
|
||||||
|
if (outputText.value.length > max_output) {
|
||||||
|
outputText.value = outputText.value.slice(-max_output); // Keep only the last 10k characters
|
||||||
|
}
|
||||||
|
console.log(text); // Also log to console
|
||||||
|
nextTick(() => { // Scroll to bottom
|
||||||
|
const consoleElement = document.querySelector('.playground-console');
|
||||||
|
if (consoleElement) {
|
||||||
|
consoleElement.scrollTop = consoleElement.scrollHeight;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONACO_EDITOR_OPTIONS = {
|
||||||
|
automaticLayout: true,
|
||||||
|
formatOnType: true,
|
||||||
|
formatOnPaste: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? `vs-dark` : `vs`
|
||||||
|
const editor = shallowRef()
|
||||||
|
const handleMount = (editorInstance: typeof VueMonacoEditor) => (editor.value = editorInstance)
|
||||||
|
const opacity = ref(0.9); // Opacity for the editor (overriden when settings are loaded)
|
||||||
|
|
||||||
|
// ============ LOAD PYODIDE (ASYNC) ============
|
||||||
|
let pyodideWorker: ReturnType<typeof newPyodideWorker> | null = (import.meta as any).hot?.data?.pyodideWorker || null;
|
||||||
|
const running = ref(true);
|
||||||
|
|
||||||
|
async function setupPyodide(first: boolean, loadSnapshot: Uint8Array | undefined = undefined) {
|
||||||
|
running.value = true;
|
||||||
|
if (opacity.value == 0.0 && !first) opacity.value = 0.9; // User doesn't know how to show code again, reset after reopening
|
||||||
|
if (pyodideWorker === null) {
|
||||||
|
output("Creating new Pyodide worker...\n");
|
||||||
|
pyodideWorker = newPyodideWorker(Object.assign({
|
||||||
|
// Note: python wheels are downloaded from the CDN, as we can't know which ones are needed in advance to bundle them
|
||||||
|
// Furthermore, this lets us use the latest version of all wheels including ocp-specific ones without app updates
|
||||||
|
indexURL: `https://cdn.jsdelivr.net/pyodide/v${pyodideVersion}/full/`,
|
||||||
|
packages: ["micropip", "sqlite3"], // Faster load if done here
|
||||||
|
// _makeSnapshot: true, // Enable snapshotting for faster startup (still experimental: breaks loading any packages)
|
||||||
|
}, (loadSnapshot ? {_loadSnapshot: loadSnapshot} : {}))); // Load snapshot if provided
|
||||||
|
if ((import.meta as any).hot) (import.meta as any).hot.data.pyodideWorker = pyodideWorker
|
||||||
|
} else {
|
||||||
|
output("Reusing existing Pyodide instance...\n");
|
||||||
|
}
|
||||||
|
output("Preloading packages...\n");
|
||||||
|
await pyodideWorker.asyncRun(playgroundStartupCode, output, output); // Also import yacv_server and mock ocp_vscode here for faster custom code execution
|
||||||
|
running.value = false; // Indicate that Pyodide is ready
|
||||||
|
output("Pyodide worker ready.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCode() {
|
||||||
|
if (pyodideWorker === null) {
|
||||||
|
output("Pyodide worker is not initialized. Please wait...\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (running.value) {
|
||||||
|
output("Pyodide is already running. Please wait...\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
output("Running code...\n");
|
||||||
|
try {
|
||||||
|
running.value = true;
|
||||||
|
await pyodideWorker.asyncRun(model.value.code, output, (msg: string) => {
|
||||||
|
// Detect models printed to console (since http server is not available in pyodide)
|
||||||
|
if (msg.startsWith(yacvServerModelPrefix)) {
|
||||||
|
const modelData = msg.slice(yacvServerModelPrefix.length);
|
||||||
|
onModelData(modelData);
|
||||||
|
} else {
|
||||||
|
output(msg); // Print other messages directly
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
output(`Error running code: ${e}\n`);
|
||||||
|
} finally {
|
||||||
|
output("Code execution finished.\n");
|
||||||
|
running.value = false; // Indicate that Pyodide is ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const yacvServerModelPrefix = "yacv_server://model/";
|
||||||
|
|
||||||
|
function onModelData(modelData: string) {
|
||||||
|
output(`Model data detected... ${modelData.length}B\n`);
|
||||||
|
// Decode the model data and emit the event for the interface to handle
|
||||||
|
// - Start by finding the end of the initial json object by looking for brackets.
|
||||||
|
let i = 0;
|
||||||
|
let openBrackets = 0;
|
||||||
|
for (; i < modelData.length; i++) {
|
||||||
|
if (modelData[i] === '{') openBrackets++;
|
||||||
|
else if (modelData[i] === '}') openBrackets--;
|
||||||
|
if (openBrackets === 0) break; // Found the end of the JSON object
|
||||||
|
}
|
||||||
|
if (openBrackets !== 0) throw `Error: Invalid model data received: ${modelData}\n`
|
||||||
|
const jsonData = modelData.slice(0, i + 1); // Extract the JSON part and parse it into the proper class
|
||||||
|
let modelMetadataRaw = JSON.parse(jsonData);
|
||||||
|
const modelMetadata: any = new NetworkUpdateEventModel(modelMetadataRaw.name, "", modelMetadataRaw.hash, modelMetadataRaw.is_remove)
|
||||||
|
// console.debug(`Model metadata:`, modelMetadata);
|
||||||
|
output(`Model metadata: ${JSON.stringify(modelMetadata)}\n`);
|
||||||
|
// - Now decode the rest of the model data which is a single base64 encoded glb file (or an empty string)
|
||||||
|
if (!modelMetadata.isRemove) {
|
||||||
|
const binaryData = Base64.toUint8Array(modelData.slice(i + 1)); // Extract the base64 part
|
||||||
|
console.assert(binaryData.slice(0, 4).toString() == "103,108,84,70", // Ugly...
|
||||||
|
"Invalid GLTF binary data received: " + binaryData.slice(0, 4).toString());
|
||||||
|
// - Save for upload and share link feature
|
||||||
|
builtModelsGlb[modelMetadata.name] = binaryData;
|
||||||
|
// - Create a Blob from the binary data to be used as a URL
|
||||||
|
const blob = new Blob([binaryData as ArrayBufferView<ArrayBuffer>], {type: 'model/gltf-binary'});
|
||||||
|
modelMetadata.url = URL.createObjectURL(blob); // Set the hacked URL in the model metadata XXX: revoked on App.vue
|
||||||
|
} else {
|
||||||
|
delete builtModelsGlb[modelMetadata.name]; // Remove from built models if it's a remove request
|
||||||
|
}
|
||||||
|
// - Emit the event with the model metadata and URL
|
||||||
|
let networkUpdateEvent = new NetworkUpdateEvent([modelMetadata], () => {
|
||||||
|
});
|
||||||
|
emit('updateModel', networkUpdateEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWorker(loadSnapshot: Uint8Array | undefined = undefined) {
|
||||||
|
if (pyodideWorker) {
|
||||||
|
pyodideWorker.terminate(); // Terminate existing worker
|
||||||
|
pyodideWorker = null; // Reset worker reference
|
||||||
|
}
|
||||||
|
outputText.value = ``; // Clear output text
|
||||||
|
setupPyodide(false, loadSnapshot); // Reinitialize Pyodide
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareLinkCommon(added: Record<string, Array<string> | string>, forgotten: Array<string>) {
|
||||||
|
const baseUrl = window.location
|
||||||
|
const searchParams = new URLSearchParams(baseUrl.search);
|
||||||
|
for (const k of forgotten) searchParams.delete(k);
|
||||||
|
const hashParams = new URLSearchParams(baseUrl.hash.slice(1)); // Keep all previous URL parameters
|
||||||
|
for (const k of forgotten) hashParams.delete(k);
|
||||||
|
for (const k in added) {
|
||||||
|
if (Array.isArray(added[k])) {
|
||||||
|
for (const v of added[k]) {
|
||||||
|
hashParams.append(k, v); // Prefer hash to GET
|
||||||
|
}
|
||||||
|
} else if (typeof added[k] === 'string') {
|
||||||
|
hashParams.set(k, added[k]); // Prefer hash to GET
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const shareUrl = `${baseUrl.origin}${baseUrl.pathname}?${searchParams}#${hashParams}`;
|
||||||
|
output(`Share link ready: ${shareUrl}\n`)
|
||||||
|
if (navigator.clipboard?.writeText === undefined) {
|
||||||
|
output("Clipboard API not available. Please copy the link manually.\n");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(shareUrl)
|
||||||
|
.then(() => output("Link copied to clipboard!\n"))
|
||||||
|
.catch(err => output(`Failed to copy link: ${err}\n`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareLink() {
|
||||||
|
shareLinkCommon({'pg_code': b64UrlEncode(gzip(model.value.code, {level: 9}))}, ['pg_code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtModelsGlb: Record<string, Uint8Array> = {}; // Store built models to support uploading
|
||||||
|
async function uploadAndShareLink() {
|
||||||
|
try {
|
||||||
|
output("Uploading files...\n");
|
||||||
|
|
||||||
|
// Upload code.py
|
||||||
|
const codeBlob = new Blob([model.value.code], {type: 'text/x-python'});
|
||||||
|
const newParams: Record<string, string | Array<string>> = {
|
||||||
|
'pg_code': await uploadFile('code.py', new Uint8Array(await codeBlob.arrayBuffer()))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload all models
|
||||||
|
newParams['preload'] = []
|
||||||
|
for (const name in builtModelsGlb) {
|
||||||
|
const glb: any = builtModelsGlb[name];
|
||||||
|
const url = await uploadFile(name + '.glb', glb)
|
||||||
|
newParams['preload'].push(url); // Add to preload list
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build share URL
|
||||||
|
return shareLinkCommon(newParams, ['pg_code'])
|
||||||
|
} catch (e) {
|
||||||
|
output(`Error uploading/sharing files: ${e}. Falling back to private share link.\n`);
|
||||||
|
return shareLink(); // Fallback to private share link if upload fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSnapshot() {
|
||||||
|
throw new Error("Not implemented yet!"); // TODO: Implement snapshot saving
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSnapshot() {
|
||||||
|
throw new Error("Not implemented yet!"); // TODO: Implement snapshot loading
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const sett = await settings
|
||||||
|
if (model.value.firstTime) opacity.value = sett.pg_opacity_loading
|
||||||
|
await setupPyodide(true);
|
||||||
|
if (model.value.firstTime) {
|
||||||
|
await runCode();
|
||||||
|
opacity.value = sett.pg_opacity_loaded
|
||||||
|
model.value.firstTime = false
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Add keyboard shortcuts
|
||||||
|
const editorRef = ref<HTMLElement | null>(null);
|
||||||
|
onMounted(() => {
|
||||||
|
if (editorRef.value) {
|
||||||
|
editorRef.value.addEventListener('keydown', (event: Event) => {
|
||||||
|
if (!(event instanceof KeyboardEvent)) return; // Ensure event is a KeyboardEvent
|
||||||
|
if (event.key === 'F10') { // Run code on F10
|
||||||
|
event.preventDefault(); // Prevent default behavior of the key
|
||||||
|
runCode();
|
||||||
|
} else if (event.key === 'Escape') { // Close on Escape key
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-card class="popup-card"
|
||||||
|
:style="opacity == 0 ? `position: absolute; top: calc(-50vh + 24px); width: calc(100vw - 64px);` : ``">
|
||||||
|
<v-toolbar class="popup">
|
||||||
|
<v-toolbar-title style="flex: 0 1 auto">Playground</v-toolbar-title>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
|
||||||
|
<span style="display: inline-flex; margin-right: 16px;">
|
||||||
|
<svg-icon :path="mdiCircleOpacity" type="mdi" style="height: 32px"></svg-icon>
|
||||||
|
<v-slider v-model="opacity" :max="1" :min="0" :step="0.1"
|
||||||
|
style="width: 100px; height: 32px">
|
||||||
|
</v-slider>
|
||||||
|
<v-tooltip activator="parent"
|
||||||
|
location="bottom">Opacity of the editor (0 = hidden, 1 = fully visible)</v-tooltip>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span style="padding-left: 12px; width: 48px;"><!-- This span is only needed to force tooltip to work while button is disabled -->
|
||||||
|
<v-btn icon disabled @click="saveSnapshot()">
|
||||||
|
<svg-icon :path="mdiContentSave" type="mdi"/>
|
||||||
|
</v-btn>
|
||||||
|
<v-tooltip activator="parent"
|
||||||
|
location="bottom">Save current state to a snapshot for fast startup (WIP)</v-tooltip>
|
||||||
|
</span>
|
||||||
|
<span style="padding-right: 12px; width: 48px;"><!-- This span is only needed to force tooltip to work while button is disabled -->
|
||||||
|
<v-btn icon disabled @click="loadSnapshot()">
|
||||||
|
<svg-icon :path="mdiFolderOpen" type="mdi"/>
|
||||||
|
</v-btn>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Load snapshot for fast startup (WIP)</v-tooltip>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<v-btn icon @click="shareLink()" style="padding-left: 12px;">
|
||||||
|
<svg-icon :path="mdiShare" type="mdi"/>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Share link that automatically runs the code.<br/>Only people
|
||||||
|
with the link can see the code.
|
||||||
|
</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn icon @click="uploadAndShareLink()" style="padding-right: 12px">
|
||||||
|
<svg-icon :path="mdiShare" type="mdi" style="position: absolute; scale: 75%; top: 6px;"/>
|
||||||
|
<svg-icon :path="mdiUpload" type="mdi" style="position: absolute; scale: 75%; bottom: 6px;"/>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Uploads all models and code and then shares a link to them.<br/>Useful
|
||||||
|
to view the models while the playground loads, but uses third-party storage.
|
||||||
|
</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn icon @click="resetWorker()" style="padding-left: 12px;">
|
||||||
|
<svg-icon :path="mdiReload" type="mdi"/>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Reset Pyodide worker (this forgets all previous state and will
|
||||||
|
take a little while)
|
||||||
|
</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn icon @click="runCode()" :disabled="running" style="padding-right: 12px">
|
||||||
|
<svg-icon :path="mdiPlay" type="mdi"/>
|
||||||
|
<Loading v-if="running" style="position: absolute; top: -16%; left: -28%"/><!-- Ugly positioning -->
|
||||||
|
<v-tooltip activator="parent" location="bottom">Run code</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn icon @click="emit('close')">
|
||||||
|
<svg-icon :path="mdiClose" type="mdi"/>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Close (Pyodide remains loaded)</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
<v-card-text class="popup-card-text" :style="opacity == 0 ? `display: none` : ``">
|
||||||
|
<!-- Only show content if opacity is greater than 0 -->
|
||||||
|
<div class="playground-container">
|
||||||
|
<div class="playground-editor" ref="editorRef">
|
||||||
|
<VueMonacoEditor v-model:value="model.code" :theme="editorTheme" :options="MONACO_EDITOR_OPTIONS"
|
||||||
|
language="python" @mount="handleMount"/>
|
||||||
|
</div>
|
||||||
|
<div class="playground-console">
|
||||||
|
<h3 style="display:flex; align-items: center; justify-content: space-between; margin: 0;">
|
||||||
|
Console Output
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn @click="outputText = ''">
|
||||||
|
<svg-icon :path="mdiBroom" type="mdi" class="h-"/>
|
||||||
|
</v-btn>
|
||||||
|
</h3>
|
||||||
|
<pre>{{ outputText }}</pre> <!-- Placeholder for console output -->
|
||||||
|
<Loading v-if="running"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.popup-card {
|
||||||
|
background-color: #00000000; /* Transparent background */
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-toolbar.popup > * {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-card-text {
|
||||||
|
background-color: #1e1e1e; /* Matches the Monaco editor background */
|
||||||
|
opacity: v-bind(opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-editor {
|
||||||
|
flex: 1;
|
||||||
|
height: calc(100vh - 150px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-console {
|
||||||
|
flex: 0.5;
|
||||||
|
padding: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-width: 100px;
|
||||||
|
height: calc(100vh - 150px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-console pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-height: 100vw) {
|
||||||
|
/* Adjust layout for vertical space */
|
||||||
|
.playground-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-editor {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-editor > * {
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-console {
|
||||||
|
max-height: calc(40vh - 150px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: Adjust more colors on bright mode */
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* https://stackoverflow.com/questions/47017753/monaco-editor-dynamically-resizable/71876526#71876526 */
|
||||||
|
.monaco-editor {
|
||||||
|
position: absolute !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
24
frontend/tools/PlaygroundStartup.py
Normal file
24
frontend/tools/PlaygroundStartup.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import micropip
|
||||||
|
|
||||||
|
# Prioritize the OCP.wasm package repository for finding the ported dependencies.
|
||||||
|
micropip.set_index_urls(["https://yeicor.github.io/OCP.wasm", "https://pypi.org/simple"])
|
||||||
|
|
||||||
|
# For build123d < 0.10.0, we need to install the mock the py-lib3mf package (before the main install).
|
||||||
|
await micropip.install("lib3mf")
|
||||||
|
micropip.add_mock_package("py-lib3mf", "2.4.1", modules={"py_lib3mf": 'from lib3mf import *'})
|
||||||
|
|
||||||
|
# Install the yacv_server package, which is the main server for the OCP.wasm playground; and also preinstalls build123d.
|
||||||
|
await micropip.install("yacv_server", pre=True)
|
||||||
|
|
||||||
|
# Preimport the yacv_server package to ensure it is available in the global scope, and mock the ocp_vscode package.
|
||||||
|
from yacv_server import *
|
||||||
|
|
||||||
|
micropip.add_mock_package("ocp-vscode", "2.8.9", modules={"ocp_vscode": 'from yacv_server import *'})
|
||||||
|
show_object = show
|
||||||
|
|
||||||
|
# Preinstall the font-fetcher package and install its hook to automatically download any requested font.
|
||||||
|
await micropip.install("font-fetcher", pre=True)
|
||||||
|
|
||||||
|
from font_fetcher.ocp import install_ocp_font_hook
|
||||||
|
|
||||||
|
install_ocp_font_hook()
|
||||||
@@ -1,49 +1,56 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {defineModel, inject, ref, type ShallowRef, watch} from "vue";
|
import {inject, ref, type ShallowRef, watch} from "vue";
|
||||||
import {VBtn, VSelect, VTooltip} from "vuetify/lib/components/index.mjs";
|
import {VBtn, VSelect, VTooltip} from "vuetify/lib/components/index.mjs";
|
||||||
|
// @ts-expect-error
|
||||||
import SvgIcon from '@jamescoyle/vue-icon';
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
import type {ModelViewerElement} from '@google/model-viewer';
|
import type {ModelViewerElement} from '@google/model-viewer';
|
||||||
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||||
import {mdiCubeOutline, mdiCursorDefaultClick, mdiFeatureSearch, mdiRuler} from '@mdi/js';
|
import {mdiCubeOutline, mdiCursorDefaultClick, mdiFeatureSearch, mdiRuler} from '@mdi/js';
|
||||||
import type {Intersection, Material, Mesh, Object3D} from "three";
|
import type {Intersection, Material, Mesh, Object3D} from "three";
|
||||||
import {Box3, Matrix4, Raycaster, Vector3} from "three";
|
import {Box3, Color, Raycaster, Vector3} from "three";
|
||||||
import type ModelViewerWrapperT from "../viewer/ModelViewerWrapper.vue";
|
import type ModelViewerWrapperT from "../viewer/ModelViewerWrapper.vue";
|
||||||
import {extrasNameKey} from "../misc/gltf";
|
import {extrasNameKey} from "../misc/gltf";
|
||||||
import {SceneMgr} from "../misc/scene";
|
import {SceneMgr} from "../misc/scene";
|
||||||
import {Document} from "@gltf-transform/core";
|
import {Document} from "@gltf-transform/core";
|
||||||
import {AxesColors} from "../misc/helpers";
|
import {AxesColors} from "../misc/helpers";
|
||||||
import {distances} from "../misc/distances";
|
import {distances} from "../misc/distances";
|
||||||
|
import {highlight, highlightUndo, hitToSelectionInfo, type SelectionInfo} from "./selection";
|
||||||
|
|
||||||
export type MObject3D = Mesh & {
|
export type MObject3D = Mesh & {
|
||||||
userData: { noHit?: boolean },
|
userData: { noHit?: boolean },
|
||||||
material: Material & { color: { r: number, g: number, b: number }, __prevBaseColorFactor?: [number, number, number] }
|
material: Material & {
|
||||||
|
color: Color,
|
||||||
|
wireframe?: boolean
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let props = defineProps<{ viewer: typeof ModelViewerWrapperT | null }>();
|
let props = defineProps<{ viewer: typeof ModelViewerWrapperT | null }>();
|
||||||
let emit = defineEmits<{ findModel: [string] }>();
|
let emit = defineEmits<{ findModel: [string] }>();
|
||||||
let {setDisableTap} = inject<{ setDisableTap: (arg0: boolean) => void }>('disableTap')!!;
|
let {setDisableTap} = inject<{ setDisableTap: (arg0: boolean) => void }>('disableTap')!!;
|
||||||
let selectionEnabled = ref(false);
|
let selectionEnabled = ref(false);
|
||||||
let selected = defineModel<Array<Intersection<MObject3D>>>({default: []});
|
let selected = defineModel<Array<SelectionInfo>>({default: []});
|
||||||
let highlightNextSelection = ref([false, false]); // Second is whether selection was enabled before
|
let openNextSelection = ref([false, false]); // Second is whether selection was enabled before
|
||||||
let showBoundingBox = ref<Boolean>(false); // Enabled automatically on start
|
let showBoundingBox = ref<Boolean>(false); // Enabled automatically on start
|
||||||
let showDistances = ref<Boolean>(true);
|
let showDistances = ref<Boolean>(true);
|
||||||
|
|
||||||
let mouseDownAt: [number, number] | null = null;
|
let mouseDownAt: [number, number] | null = null;
|
||||||
|
let mouseDownTime = 0;
|
||||||
let selectFilter = ref('Any (S)');
|
let selectFilter = ref('Any (S)');
|
||||||
const raycaster = new Raycaster();
|
const raycaster = new Raycaster();
|
||||||
|
|
||||||
|
|
||||||
let selectionMoveListener = (event: MouseEvent) => {
|
let mouseDownListener = (event: MouseEvent) => {
|
||||||
mouseDownAt = [event.clientX, event.clientY];
|
mouseDownAt = [event.clientX, event.clientY];
|
||||||
|
mouseDownTime = performance.now();
|
||||||
if (!selectionEnabled.value) return;
|
if (!selectionEnabled.value) return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let selectionListener = (event: MouseEvent) => {
|
let mouseUpListener = (event: MouseEvent) => {
|
||||||
// If the mouse moved while clicked (dragging), avoid selection logic
|
// If the mouse moved while clicked (dragging), avoid selection logic
|
||||||
if (mouseDownAt) {
|
if (mouseDownAt) {
|
||||||
let [x, y] = mouseDownAt;
|
let [x, y] = mouseDownAt;
|
||||||
mouseDownAt = null;
|
mouseDownAt = null;
|
||||||
if (Math.abs(event.clientX - x) > 5 || Math.abs(event.clientY - y) > 5) {
|
if (Math.abs(event.clientX - x) > 5 || Math.abs(event.clientY - y) > 5 || performance.now() - mouseDownTime > 500) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,8 +99,27 @@ let selectionListener = (event: MouseEvent) => {
|
|||||||
// let lineHandle = props.viewer?.addLine3D(actualFrom, actualTo, "Ray")
|
// let lineHandle = props.viewer?.addLine3D(actualFrom, actualTo, "Ray")
|
||||||
// setTimeout(() => props.viewer?.removeLine3D(lineHandle), 30000)
|
// setTimeout(() => props.viewer?.removeLine3D(lineHandle), 30000)
|
||||||
|
|
||||||
// Find all hit objects and select the wanted one based on the filter
|
// Find all hit objects and raycast the wanted ones based on the filter
|
||||||
const hits = raycaster.intersectObject(scene, true);
|
let objects: Array<any> = [];
|
||||||
|
scene.traverse((obj) => {
|
||||||
|
const kind = obj.type
|
||||||
|
let isFace = kind === 'Mesh' || kind === 'SkinnedMesh';
|
||||||
|
let isEdge = kind === 'Line' || kind === 'LineSegments';
|
||||||
|
let isVertex = kind === 'Points';
|
||||||
|
if (obj.userData.noHit !== true &&
|
||||||
|
((selectFilter.value === 'Any (S)' && (isFace || isEdge || isVertex)) ||
|
||||||
|
(selectFilter.value === '(F)aces' && isFace) ||
|
||||||
|
(selectFilter.value === '(E)dges' && isEdge) ||
|
||||||
|
(selectFilter.value === '(V)ertices' && isVertex))) {
|
||||||
|
objects.push(obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
//console.log("Raycasting objects", objects)
|
||||||
|
|
||||||
|
// Run the raycaster on the selected objects only searching for the first hit
|
||||||
|
// @ts-ignore
|
||||||
|
raycaster.firstHitOnly = true;
|
||||||
|
const hits = raycaster.intersectObjects(objects, false);
|
||||||
let hit = hits
|
let hit = hits
|
||||||
// Check feasibility
|
// Check feasibility
|
||||||
.filter((hit: Intersection<Object3D>) => {
|
.filter((hit: Intersection<Object3D>) => {
|
||||||
@@ -106,7 +132,7 @@ let selectionListener = (event: MouseEvent) => {
|
|||||||
(isFace && selectFilter.value === '(F)aces') ||
|
(isFace && selectFilter.value === '(F)aces') ||
|
||||||
(isEdge && selectFilter.value === '(E)dges') ||
|
(isEdge && selectFilter.value === '(E)dges') ||
|
||||||
(isVertex && selectFilter.value === '(V)ertices');
|
(isVertex && selectFilter.value === '(V)ertices');
|
||||||
return (!isFace || hit.object.visible) && !hit.object.userData.noHit && kindOk;
|
return (!isFace || hit.object.visible) && kindOk;
|
||||||
})
|
})
|
||||||
// Sort for highlighting partially hidden edges/vertices
|
// Sort for highlighting partially hidden edges/vertices
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -123,17 +149,19 @@ let selectionListener = (event: MouseEvent) => {
|
|||||||
})
|
})
|
||||||
// Return the best hit
|
// Return the best hit
|
||||||
[0] as Intersection<MObject3D> | undefined;
|
[0] as Intersection<MObject3D> | undefined;
|
||||||
// console.log('Hit', hit)
|
|
||||||
|
|
||||||
if (!highlightNextSelection.value[0]) {
|
if (!openNextSelection.value[0]) {
|
||||||
// If we are selecting, toggle the selection or deselect all if no hit
|
// If we are selecting, toggle the selection or deselect all if no hit
|
||||||
if (hit) {
|
let selInfo: SelectionInfo | null = null;
|
||||||
|
if (hit) selInfo = hitToSelectionInfo(hit);
|
||||||
|
//console.log('Hit', hit, 'SelInfo', selInfo);
|
||||||
|
if (hit && selInfo !== null) {
|
||||||
// Toggle selection
|
// Toggle selection
|
||||||
const wasSelected = selected.value.find((m) => m.object.name === hit?.object?.name) !== undefined;
|
const wasSelected = selected.value.find((m) => m.getKey() === selInfo.getKey()) !== undefined;
|
||||||
if (wasSelected) {
|
if (wasSelected) {
|
||||||
deselect(hit)
|
deselect(selInfo)
|
||||||
} else {
|
} else {
|
||||||
select(hit)
|
select(selInfo)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
deselectAll();
|
deselectAll();
|
||||||
@@ -144,39 +172,27 @@ let selectionListener = (event: MouseEvent) => {
|
|||||||
// Otherwise, highlight the model that owns the hit
|
// Otherwise, highlight the model that owns the hit
|
||||||
emit('findModel', hit.object.userData[extrasNameKey])
|
emit('findModel', hit.object.userData[extrasNameKey])
|
||||||
// And reset the selection mode
|
// And reset the selection mode
|
||||||
toggleHighlightNextSelection()
|
toggleOpenNextSelection()
|
||||||
}
|
}
|
||||||
scene.queueRender() // Force rerender of model-viewer
|
scene.queueRender() // Force rerender of model-viewer
|
||||||
}
|
}
|
||||||
|
|
||||||
function select(hit: Intersection<MObject3D>) {
|
function select(selInfo: SelectionInfo) {
|
||||||
// console.log('Selecting', hit.object.name)
|
// console.log('Selecting', selInfo.object.name)
|
||||||
if (selected.value.find((m) => m.object.name === hit.object.name) === undefined) {
|
if (selected.value.find((m) => m.getKey() === selInfo.getKey()) === undefined) {
|
||||||
selected.value.push(hit);
|
selected.value.push(selInfo);
|
||||||
}
|
}
|
||||||
hit.object.material.__prevBaseColorFactor = [
|
highlight(selInfo);
|
||||||
hit.object.material.color.r,
|
|
||||||
hit.object.material.color.g,
|
|
||||||
hit.object.material.color.b,
|
|
||||||
];
|
|
||||||
hit.object.material.color.r = 1;
|
|
||||||
hit.object.material.color.g = 0;
|
|
||||||
hit.object.material.color.b = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deselect(hit: Intersection<MObject3D>, alsoRemove = true) {
|
function deselect(selInfo: SelectionInfo, alsoRemove = true) {
|
||||||
// console.log('Deselecting', hit.object.name)
|
// console.log('Deselecting', selInfo.object.name)
|
||||||
if (alsoRemove) {
|
if (alsoRemove) {
|
||||||
// Remove the matching object from the selection
|
// Remove the matching object from the selection
|
||||||
let toRemove = selected.value.findIndex((m) => m.object.name === hit.object.name);
|
let toRemove = selected.value.findIndex((m) => m.getKey() === selInfo.getKey());
|
||||||
selected.value.splice(toRemove, 1);
|
selected.value.splice(toRemove, 1);
|
||||||
}
|
}
|
||||||
if (hit.object.material.__prevBaseColorFactor) {
|
highlightUndo(selInfo);
|
||||||
hit.object.material.color.r = hit.object.material.__prevBaseColorFactor[0]
|
|
||||||
hit.object.material.color.g = hit.object.material.__prevBaseColorFactor[1]
|
|
||||||
hit.object.material.color.b = hit.object.material.__prevBaseColorFactor[2]
|
|
||||||
delete hit.object.material.__prevBaseColorFactor;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deselectAll(alsoRemove = true) {
|
function deselectAll(alsoRemove = true) {
|
||||||
@@ -194,17 +210,17 @@ function toggleSelection() {
|
|||||||
setDisableTap(selectionEnabled.value);
|
setDisableTap(selectionEnabled.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleHighlightNextSelection() {
|
function toggleOpenNextSelection() {
|
||||||
highlightNextSelection.value = [
|
openNextSelection.value = [
|
||||||
!highlightNextSelection.value[0],
|
!openNextSelection.value[0],
|
||||||
highlightNextSelection.value[0] ? highlightNextSelection.value[1] : selectionEnabled.value
|
openNextSelection.value[0] ? (openNextSelection.value[1] ?? false) : selectionEnabled.value
|
||||||
];
|
];
|
||||||
if (highlightNextSelection.value[0]) {
|
if (openNextSelection.value[0]) {
|
||||||
// Reuse selection code to identify the model
|
// Reuse selection code to identify the model
|
||||||
if (!selectionEnabled.value) toggleSelection()
|
if (!selectionEnabled.value) toggleSelection()
|
||||||
} else {
|
} else {
|
||||||
if (selectionEnabled.value !== highlightNextSelection.value[1]) toggleSelection()
|
if (selectionEnabled.value !== openNextSelection.value[1]) toggleSelection()
|
||||||
highlightNextSelection.value = [false, false];
|
openNextSelection.value = [false, false];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,14 +257,29 @@ let onViewerReady = (viewer: typeof ModelViewerWrapperT) => {
|
|||||||
viewer.onElemReady((elem: ModelViewerElement) => {
|
viewer.onElemReady((elem: ModelViewerElement) => {
|
||||||
if (hasListeners) return;
|
if (hasListeners) return;
|
||||||
hasListeners = true;
|
hasListeners = true;
|
||||||
elem.addEventListener('mouseup', selectionListener);
|
elem.addEventListener('mousedown', mouseDownListener); // Avoid clicking when dragging
|
||||||
elem.addEventListener('mousedown', selectionMoveListener); // Avoid clicking when dragging
|
elem.addEventListener('mouseup', mouseUpListener);
|
||||||
elem.addEventListener('load', () => {
|
elem.addEventListener('before-render', () => {
|
||||||
|
// After a reload of the scene, we need to recover object references and highlight them again
|
||||||
|
for (let sel of selected.value) {
|
||||||
|
let scene = props.viewer?.scene;
|
||||||
|
if (!scene) continue;
|
||||||
|
let foundObject = null;
|
||||||
|
scene.traverse((obj: MObject3D) => {
|
||||||
|
if (sel.matches(obj)) {
|
||||||
|
foundObject = obj as MObject3D;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (foundObject) {
|
||||||
|
sel.object = foundObject;
|
||||||
|
highlight(sel);
|
||||||
|
} else {
|
||||||
|
selected.value = selected.value.filter((m) => m.getKey() !== sel.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
if (firstLoad) {
|
if (firstLoad) {
|
||||||
toggleShowBoundingBox();
|
toggleShowBoundingBox();
|
||||||
firstLoad = false;
|
firstLoad = false;
|
||||||
} else {
|
|
||||||
updateBoundingBox();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
elem.addEventListener('camera-change', onCameraChange);
|
elem.addEventListener('camera-change', onCameraChange);
|
||||||
@@ -273,9 +304,8 @@ function updateBoundingBox() {
|
|||||||
if (selected.value.length > 0) {
|
if (selected.value.length > 0) {
|
||||||
bb = new Box3();
|
bb = new Box3();
|
||||||
for (let hit of selected.value) {
|
for (let hit of selected.value) {
|
||||||
bb.expandByObject(hit.object);
|
bb.union(hit.getBox())
|
||||||
}
|
}
|
||||||
bb.applyMatrix4(new Matrix4().makeTranslation(props.viewer?.scene.getTarget()));
|
|
||||||
} else {
|
} else {
|
||||||
let boundingBox = SceneMgr.getBoundingBox(sceneDocument.value);
|
let boundingBox = SceneMgr.getBoundingBox(sceneDocument.value);
|
||||||
if (!boundingBox) return; // No models. Should not happen.
|
if (!boundingBox) return; // No models. Should not happen.
|
||||||
@@ -300,14 +330,20 @@ function updateBoundingBox() {
|
|||||||
// Only draw one edge per axis, the 2nd closest one to the camera
|
// Only draw one edge per axis, the 2nd closest one to the camera
|
||||||
for (let edgeI in edgesByAxis) {
|
for (let edgeI in edgesByAxis) {
|
||||||
let axisEdges = edgesByAxis[edgeI];
|
let axisEdges = edgesByAxis[edgeI];
|
||||||
let edge: Array<number> = axisEdges[0];
|
if (!axisEdges || axisEdges.length === 0) continue;
|
||||||
|
let edge: Array<number> = axisEdges[0] ?? [];
|
||||||
for (let i = 0; i < 2; i++) { // Find the 2nd closest one by running twice dropping the first
|
for (let i = 0; i < 2; i++) { // Find the 2nd closest one by running twice dropping the first
|
||||||
edge = axisEdges[0];
|
if (!axisEdges || axisEdges.length === 0) break;
|
||||||
|
edge = axisEdges[0] ?? [];
|
||||||
let edgeDist = Infinity;
|
let edgeDist = Infinity;
|
||||||
let cameraPos: Vector3 = props.viewer?.scene.camera.position;
|
let cameraPos: Vector3 = props.viewer?.scene?.camera?.position ?? new Vector3();
|
||||||
for (let testEdge of axisEdges) {
|
for (let testEdge of axisEdges) {
|
||||||
let from = new Vector3(...corners[testEdge[0]]);
|
if (!testEdge || testEdge.length < 2) continue;
|
||||||
let to = new Vector3(...corners[testEdge[1]]);
|
let cornerA = corners[testEdge[0] ?? 0];
|
||||||
|
let cornerB = corners[testEdge[1] ?? 0];
|
||||||
|
if (!cornerA || !cornerB) continue;
|
||||||
|
let from = new Vector3(...cornerA);
|
||||||
|
let to = new Vector3(...cornerB);
|
||||||
let mid = from.clone().add(to).multiplyScalar(0.5);
|
let mid = from.clone().add(to).multiplyScalar(0.5);
|
||||||
let newDist = cameraPos.distanceTo(mid);
|
let newDist = cameraPos.distanceTo(mid);
|
||||||
if (newDist < edgeDist) {
|
if (newDist < edgeDist) {
|
||||||
@@ -317,11 +353,16 @@ function updateBoundingBox() {
|
|||||||
}
|
}
|
||||||
axisEdges = axisEdges.filter((e) => e !== edge);
|
axisEdges = axisEdges.filter((e) => e !== edge);
|
||||||
}
|
}
|
||||||
let from = new Vector3(...corners[edge[0]]);
|
if (!edge || edge.length < 2) continue;
|
||||||
let to = new Vector3(...corners[edge[1]]);
|
let cornerA = corners[edge[0] ?? 0];
|
||||||
|
let cornerB = corners[edge[1] ?? 0];
|
||||||
|
if (!cornerA || !cornerB) continue;
|
||||||
|
let from = new Vector3(...cornerA);
|
||||||
|
let to = new Vector3(...cornerB);
|
||||||
let length = to.clone().sub(from).length();
|
let length = to.clone().sub(from).length();
|
||||||
if (length < 0.05) continue; // Skip very small edges (e.g. a single point)
|
if (length < 0.05) continue; // Skip very small edges (e.g. a single point)
|
||||||
let color = [AxesColors.x, AxesColors.y, AxesColors.z][edgeI][1]; // Secondary colors
|
let colorArray = [AxesColors.x, AxesColors.y, AxesColors.z][parseInt(edgeI)];
|
||||||
|
let color = colorArray ? colorArray[1] : [255, 255, 255]; // Secondary colors
|
||||||
let lineCacheKey = JSON.stringify([from, to]);
|
let lineCacheKey = JSON.stringify([from, to]);
|
||||||
let matchingLine = boundingBoxLines[lineCacheKey];
|
let matchingLine = boundingBoxLines[lineCacheKey];
|
||||||
if (matchingLine) {
|
if (matchingLine) {
|
||||||
@@ -329,7 +370,7 @@ function updateBoundingBox() {
|
|||||||
} else {
|
} else {
|
||||||
let newLineId = props.viewer?.addLine3D(from, to,
|
let newLineId = props.viewer?.addLine3D(from, to,
|
||||||
length.toFixed(1) + "mm", {
|
length.toFixed(1) + "mm", {
|
||||||
"stroke": "rgb(" + color.join(',') + ")",
|
"stroke": "rgb(" + (color ?? [255, 255, 255]).join(',') + ")",
|
||||||
"stroke-width": "2"
|
"stroke-width": "2"
|
||||||
});
|
});
|
||||||
if (newLineId) {
|
if (newLineId) {
|
||||||
@@ -380,12 +421,17 @@ function updateDistances() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add lines (if not already added)
|
// Add lines (if not already added)
|
||||||
let objA = selected.value[0].object;
|
if (!selected.value[0] || !selected.value[1] || !props.viewer?.scene) return;
|
||||||
let objB = selected.value[1].object;
|
let {min, center, max} = distances(selected.value[0], selected.value[1], props.viewer.scene);
|
||||||
let {min, center, max} = distances(objA, objB, props.viewer?.scene);
|
if (max[0] && max[1]) {
|
||||||
ensureLine(max[0], max[1], max[1].distanceTo(max[0]).toFixed(1) + "mm", "orange");
|
ensureLine(max[0], max[1], max[1].distanceTo(max[0]).toFixed(1) + "mm", "orange");
|
||||||
ensureLine(center[0], center[1], center[1].distanceTo(center[0]).toFixed(1) + "mm", "green");
|
}
|
||||||
ensureLine(min[0], min[1], min[1].distanceTo(min[0]).toFixed(1) + "mm", "cyan");
|
if (center[0] && center[1]) {
|
||||||
|
ensureLine(center[0], center[1], center[1].distanceTo(center[0]).toFixed(1) + "mm", "green");
|
||||||
|
}
|
||||||
|
if (min[0] && min[1]) {
|
||||||
|
ensureLine(min[0], min[1], min[1].distanceTo(min[0]).toFixed(1) + "mm", "cyan");
|
||||||
|
}
|
||||||
|
|
||||||
// Remove the lines that are no longer needed
|
// Remove the lines that are no longer needed
|
||||||
for (let lineLocator of distanceLinesToRemove) {
|
for (let lineLocator of distanceLinesToRemove) {
|
||||||
@@ -396,8 +442,14 @@ function updateDistances() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({deselect, updateBoundingBox, updateDistances});
|
||||||
|
|
||||||
// Add keyboard shortcuts
|
// Add keyboard shortcuts
|
||||||
window.addEventListener('keydown', (event) => {
|
window.addEventListener('keydown', (event) => {
|
||||||
|
if ((event.target as any)?.tagName && ((event.target as any).tagName === 'INPUT' || (event.target as any).tagName === 'TEXTAREA')) {
|
||||||
|
// Ignore key events when an input is focused, except for text inputs
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.key === 's') {
|
if (event.key === 's') {
|
||||||
if (selectFilter.value == 'Any (S)') toggleSelection();
|
if (selectFilter.value == 'Any (S)') toggleSelection();
|
||||||
else {
|
else {
|
||||||
@@ -426,59 +478,48 @@ window.addEventListener('keydown', (event) => {
|
|||||||
toggleShowBoundingBox();
|
toggleShowBoundingBox();
|
||||||
} else if (event.key === 'd') {
|
} else if (event.key === 'd') {
|
||||||
toggleShowDistances();
|
toggleShowDistances();
|
||||||
} else if (event.key === 'h') {
|
} else if (event.key === 'o') {
|
||||||
toggleHighlightNextSelection();
|
toggleOpenNextSelection();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="select-parent">
|
<v-btn :color="selectionEnabled ? 'surface-light' : ''" icon @click="toggleSelection">
|
||||||
<v-btn icon @click="toggleSelection" :color="selectionEnabled ? 'surface-light' : ''">
|
<v-tooltip activator="parent">{{ selectionEnabled ? 'Disable (s)election mode' : 'Enable (s)election mode' }}
|
||||||
<v-tooltip activator="parent">{{ selectionEnabled ? 'Disable (s)election mode' : 'Enable (s)election mode' }}
|
|
||||||
</v-tooltip>
|
|
||||||
<svg-icon type="mdi" :path="mdiCursorDefaultClick"/>
|
|
||||||
</v-btn>
|
|
||||||
<v-tooltip :text="'Select only ' + selectFilter.toString().toLocaleLowerCase()" :open-on-click="false">
|
|
||||||
<template v-slot:activator="{ props }">
|
|
||||||
<v-select v-bind="props" class="select-only" variant="underlined"
|
|
||||||
:items="['Any (S)', '(F)aces', '(E)dges', '(V)ertices']"
|
|
||||||
v-model="selectFilter"/>
|
|
||||||
</template>
|
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</div>
|
<svg-icon :path="mdiCursorDefaultClick" type="mdi"/>
|
||||||
<v-btn icon @click="toggleHighlightNextSelection" :color="highlightNextSelection[0] ? 'surface-light' : ''">
|
|
||||||
<v-tooltip activator="parent">(H)ighlight the next clicked element in the models list</v-tooltip>
|
|
||||||
<svg-icon type="mdi" :path="mdiFeatureSearch"/>
|
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon @click="toggleShowBoundingBox" :color="showBoundingBox ? 'surface-light' : ''">
|
<v-tooltip :open-on-click="false" :text="'Select only ' + selectFilter.toString().toLocaleLowerCase()">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-select v-model="selectFilter" :items="['Any (S)', '(F)aces', '(E)dges', '(V)ertices']" class="select-only"
|
||||||
|
v-bind="props"
|
||||||
|
variant="underlined"/>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
<v-btn :color="openNextSelection[0] ? 'surface-light' : ''" icon @click="toggleOpenNextSelection">
|
||||||
|
<v-tooltip activator="parent">(O)pen the next clicked element in the models list</v-tooltip>
|
||||||
|
<svg-icon :path="mdiFeatureSearch" type="mdi"/>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn :color="showBoundingBox ? 'surface-light' : ''" icon @click="toggleShowBoundingBox">
|
||||||
<v-tooltip activator="parent">{{ showBoundingBox ? 'Hide selection (b)ounds' : 'Show selection (b)ounds' }}
|
<v-tooltip activator="parent">{{ showBoundingBox ? 'Hide selection (b)ounds' : 'Show selection (b)ounds' }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCubeOutline"/>
|
<svg-icon :path="mdiCubeOutline" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon @click="toggleShowDistances" :color="showDistances ? 'surface-light' : ''">
|
<v-btn :color="showDistances ? 'surface-light' : ''" icon @click="toggleShowDistances">
|
||||||
<v-tooltip activator="parent">
|
<v-tooltip activator="parent">
|
||||||
{{ showDistances ? 'Hide selection (d)istances' : 'Show (d)istances (when a pair of features is selected)' }}
|
{{ showDistances ? 'Hide selection (d)istances' : 'Show (d)istances (when a pair of features is selected)' }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiRuler"/>
|
<svg-icon :path="mdiRuler" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Very hacky styling... */
|
|
||||||
.select-parent {
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-parent .v-btn {
|
|
||||||
position: relative;
|
|
||||||
top: -20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-only {
|
.select-only {
|
||||||
display: inline-block;
|
float: right;
|
||||||
width: calc(100% - 48px);
|
height: 36px;
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -12px;
|
top: -12px;
|
||||||
|
width: calc(100% - 48px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {
|
import {
|
||||||
VBtn,
|
VBtn,
|
||||||
VCard,
|
VCard,
|
||||||
@@ -13,14 +13,26 @@ import {
|
|||||||
import OrientationGizmo from "./OrientationGizmo.vue";
|
import OrientationGizmo from "./OrientationGizmo.vue";
|
||||||
import type {PerspectiveCamera} from "three/src/cameras/PerspectiveCamera.js";
|
import type {PerspectiveCamera} from "three/src/cameras/PerspectiveCamera.js";
|
||||||
import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
|
import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
|
||||||
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
|
import {
|
||||||
|
mdiClose,
|
||||||
|
mdiCrosshairsGps,
|
||||||
|
mdiDownload,
|
||||||
|
mdiGithub,
|
||||||
|
mdiLicense,
|
||||||
|
mdiLightbulb,
|
||||||
|
mdiProjector,
|
||||||
|
mdiScriptTextPlay
|
||||||
|
} from '@mdi/js'
|
||||||
|
// @ts-expect-error
|
||||||
import SvgIcon from '@jamescoyle/vue-icon';
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
import type {ModelViewerElement} from '@google/model-viewer';
|
import type {ModelViewerElement} from '@google/model-viewer';
|
||||||
import type {Intersection} from "three";
|
|
||||||
import type {MObject3D} from "./Selection.vue";
|
|
||||||
import Loading from "../misc/Loading.vue";
|
import Loading from "../misc/Loading.vue";
|
||||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||||
import {defineAsyncComponent, type Ref, ref} from "vue";
|
import {defineAsyncComponent, ref} from "vue";
|
||||||
|
import type {SelectionInfo} from "./selection";
|
||||||
|
import {settings} from "../misc/settings.ts";
|
||||||
|
import type {NetworkUpdateEvent} from "../misc/network.ts";
|
||||||
|
import IfNotSmallBuild from "../misc/IfNotSmallBuild.vue";
|
||||||
|
|
||||||
const SelectionComponent = defineAsyncComponent({
|
const SelectionComponent = defineAsyncComponent({
|
||||||
loader: () => import("./Selection.vue"),
|
loader: () => import("./Selection.vue"),
|
||||||
@@ -35,14 +47,29 @@ const LicensesDialogContent = defineAsyncComponent({
|
|||||||
delay: 0,
|
delay: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const PlaygroundDialogContent = defineAsyncComponent({
|
||||||
|
loader: () => import("./PlaygroundDialogContent.vue"),
|
||||||
|
loadingComponent: Loading,
|
||||||
|
delay: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
||||||
const emit = defineEmits<{ findModel: [string] }>()
|
const emit = defineEmits<{ findModel: [string], updateModel: [NetworkUpdateEvent] }>()
|
||||||
|
|
||||||
let selection: Ref<Array<Intersection<MObject3D>>> = ref([]);
|
const sett = ref<any | null>(null);
|
||||||
let selectionFaceCount = () => selection.value.filter((s) => s.object.type == "Mesh" || s.object.type == "SkinnedMesh").length
|
const showPlaygroundDialog = ref(false);
|
||||||
let selectionEdgeCount = () => selection.value.filter((s) => s.object.type == "Line").length
|
const pg_model = ref({code: '# Loading...', firstTime: false});
|
||||||
let selectionVertexCount = () => selection.value.filter((s) => s.object.type == "Points").length
|
(async () => {
|
||||||
|
sett.value = await settings;
|
||||||
|
pg_model.value = {code: sett.value.pg_code, firstTime: true};
|
||||||
|
showPlaygroundDialog.value = pg_model.value.code != "";
|
||||||
|
})();
|
||||||
|
|
||||||
|
let selection = ref<Array<SelectionInfo>>([]);
|
||||||
|
let selectionFaceCount = () => selection.value.filter((s) => s.kind == 'face').length
|
||||||
|
let selectionEdgeCount = () => selection.value.filter((s) => s.kind == 'edge').length
|
||||||
|
let selectionVertexCount = () => selection.value.filter((s) => s.kind == "vertex").length
|
||||||
|
|
||||||
function syncOrthoCamera(force: boolean) {
|
function syncOrthoCamera(force: boolean) {
|
||||||
let scene = props.viewer?.scene;
|
let scene = props.viewer?.scene;
|
||||||
@@ -57,14 +84,14 @@ function syncOrthoCamera(force: boolean) {
|
|||||||
let h = perspectiveWidthAtCenter / scene.aspect;
|
let h = perspectiveWidthAtCenter / scene.aspect;
|
||||||
(scene as any).camera = new OrthographicCamera(-w, w, h, -h, perspectiveCam.near, perspectiveCam.far);
|
(scene as any).camera = new OrthographicCamera(-w, w, h, -h, perspectiveCam.near, perspectiveCam.far);
|
||||||
scene.camera.position.copy(perspectiveCam.position);
|
scene.camera.position.copy(perspectiveCam.position);
|
||||||
scene.camera.lookAt(lookAtCenter);
|
scene.camera.rotation.copy(perspectiveCam.rotation);
|
||||||
if (force) scene.queueRender() // Force rerender of model-viewer
|
if (force) scene.queueRender() // Force rerender of model-viewer
|
||||||
requestAnimationFrame(() => syncOrthoCamera(false));
|
requestAnimationFrame(() => syncOrthoCamera(false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let toggleProjectionText = ref('PERSP'); // Default to perspective camera
|
let toggleProjectionText = ref('PERSP'); // Default to perspective camera
|
||||||
function toggleProjection() {
|
async function toggleProjection() {
|
||||||
let scene = props.viewer?.scene;
|
let scene = props.viewer?.scene;
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
let prevCam = scene.camera;
|
let prevCam = scene.camera;
|
||||||
@@ -79,16 +106,16 @@ function toggleProjection() {
|
|||||||
scene.queueRender() // Force rerender of model-viewer
|
scene.queueRender() // Force rerender of model-viewer
|
||||||
}
|
}
|
||||||
toggleProjectionText.value = wasPerspectiveCamera ? 'ORTHO' : 'PERSP';
|
toggleProjectionText.value = wasPerspectiveCamera ? 'ORTHO' : 'PERSP';
|
||||||
// The camera change may take a few frames to take effect, dispatch the event after a delay
|
// The camera change may take a frame to take effect, dispatch the event after a delay
|
||||||
requestIdleCallback(() => props.viewer?.elem?.dispatchEvent(
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
new CustomEvent('camera-change', {detail: {source: 'none'}})), {timeout: 100})
|
props.viewer?.elem?.dispatchEvent(new CustomEvent('camera-change', {detail: {source: 'none'}}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function centerCamera() {
|
async function centerCamera() {
|
||||||
let viewerEl: ModelViewerElement | null | undefined = props.viewer?.elem;
|
let viewerEl: ModelViewerElement | null | undefined = props.viewer?.elem;
|
||||||
if (!viewerEl) return;
|
if (!viewerEl) return;
|
||||||
await viewerEl.updateFraming();
|
props.viewer?.scene?.setTarget(0, 0, 0); // Center the target
|
||||||
viewerEl.zoom(3);
|
viewerEl.zoom(-1000000); // Max zoom out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -101,15 +128,29 @@ async function downloadSceneGlb() {
|
|||||||
link.download = file.name;
|
link.download = file.name;
|
||||||
link.href = URL.createObjectURL(file);
|
link.href = URL.createObjectURL(file);
|
||||||
link.click();
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openGithub() {
|
async function openGithub() {
|
||||||
window.open('https://github.com/yeicor-3d/yet-another-cad-viewer', '_blank')
|
window.open('https://github.com/yeicor-3d/yet-another-cad-viewer', '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeObjectSelections(objName: string) {
|
||||||
|
for (let selInfo of selection.value.filter((s) => s.getObjectName() === objName)) {
|
||||||
|
selectionComp.value?.deselect(selInfo);
|
||||||
|
}
|
||||||
|
selectionComp.value?.updateBoundingBox();
|
||||||
|
selectionComp.value?.updateDistances();
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({removeObjectSelections, openPlayground: () => showPlaygroundDialog.value = true});
|
||||||
|
|
||||||
// Add keyboard shortcuts
|
// Add keyboard shortcuts
|
||||||
window.addEventListener('keydown', (event) => {
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if ((event.target as any)?.tagName && ((event.target as any).tagName === 'INPUT' || (event.target as any).tagName === 'TEXTAREA')) {
|
||||||
|
// Ignore key events when an input is focused, except for text inputs
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.key === 'p') toggleProjection();
|
if (event.key === 'p') toggleProjection();
|
||||||
else if (event.key === 'c') centerCamera();
|
else if (event.key === 'c') centerCamera();
|
||||||
else if (event.key === 'd') downloadSceneGlb();
|
else if (event.key === 'd') downloadSceneGlb();
|
||||||
@@ -118,45 +159,66 @@ window.addEventListener('keydown', (event) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<orientation-gizmo :scene="props.viewer.scene as any" :elem="props.viewer.elem" v-if="props.viewer?.scene"/>
|
<orientation-gizmo v-if="props.viewer?.scene" :viewer="props.viewer"/>
|
||||||
<v-divider/>
|
<v-divider/>
|
||||||
<h5>Camera</h5>
|
<h5>Camera</h5>
|
||||||
<v-btn icon @click="toggleProjection"><span class="icon-detail">{{ toggleProjectionText }}</span>
|
<v-btn icon @click="toggleProjection"><span class="icon-detail">{{ toggleProjectionText }}</span>
|
||||||
<v-tooltip activator="parent">Toggle (P)rojection<br/>(currently
|
<v-tooltip activator="parent">Toggle (P)rojection<br/>(currently
|
||||||
{{ toggleProjectionText === 'PERSP' ? 'perspective' : 'orthographic' }})
|
{{ toggleProjectionText === 'PERSP' ? 'perspective' : 'orthographic' }})
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiProjector"></svg-icon>
|
<svg-icon :path="mdiProjector" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon @click="centerCamera">
|
<v-btn icon @click="centerCamera">
|
||||||
<v-tooltip activator="parent">Re(c)enter Camera</v-tooltip>
|
<v-tooltip activator="parent">Re(c)enter Camera</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCrosshairsGps"/>
|
<svg-icon :path="mdiCrosshairsGps" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
<span>
|
||||||
|
<v-tooltip activator="parent">To rotate the light hold shift and drag the mouse or use two fingers<br/>
|
||||||
|
Note that this breaks slightly clipping planes for now... (restart to fix)</v-tooltip>
|
||||||
|
<v-btn icon disabled style="background: black;">
|
||||||
|
<svg-icon :path="mdiLightbulb" type="mdi"/>
|
||||||
|
</v-btn>
|
||||||
|
</span>
|
||||||
<v-divider/>
|
<v-divider/>
|
||||||
<h5>Selection ({{ selectionFaceCount() }}F {{ selectionEdgeCount() }}E {{ selectionVertexCount() }}V)</h5>
|
<h5>Selection ({{ selectionFaceCount() }}F {{ selectionEdgeCount() }}E {{ selectionVertexCount() }}V)</h5>
|
||||||
<selection-component :ref="selectionComp as any" :viewer="props.viewer as any" v-model="selection"
|
<selection-component ref="selectionComp" v-model="selection" :viewer="props.viewer as any"
|
||||||
@findModel="(name) => emit('findModel', name)"/>
|
@findModel="(name: string) => emit('findModel', name)"/>
|
||||||
<v-divider/>
|
<v-divider/>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<h5>Extras</h5>
|
<h5>Extras</h5>
|
||||||
<v-btn icon @click="downloadSceneGlb">
|
<v-dialog v-model="showPlaygroundDialog" persistent :scrim="false" attach="body">
|
||||||
<v-tooltip activator="parent">(D)ownload Scene</v-tooltip>
|
|
||||||
<svg-icon type="mdi" :path="mdiDownload"/>
|
|
||||||
</v-btn>
|
|
||||||
<v-dialog id="licenses-dialog" fullscreen>
|
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-btn icon v-bind="props">
|
<v-btn v-bind="props" style="width: 100%">
|
||||||
<v-tooltip activator="parent">Show Licenses</v-tooltip>
|
<v-tooltip activator="parent">Open a python editor and build models directly in the browser!</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiLicense"/>
|
<svg-icon :path="mdiScriptTextPlay" type="mdi"/>
|
||||||
|
Sandbox
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:default="{ isActive }">
|
<template v-slot:default="{ isActive }">
|
||||||
<v-card>
|
<if-not-small-build>
|
||||||
<v-toolbar>
|
<playground-dialog-content v-if="sett != null" v-model="pg_model" @close="isActive.value = false"
|
||||||
|
@update-model="(event: NetworkUpdateEvent) => emit('updateModel', event)"/>
|
||||||
|
</if-not-small-build>
|
||||||
|
</template>
|
||||||
|
</v-dialog>
|
||||||
|
<v-btn icon @click="downloadSceneGlb">
|
||||||
|
<v-tooltip activator="parent">(D)ownload Scene</v-tooltip>
|
||||||
|
<svg-icon :path="mdiDownload" type="mdi"/>
|
||||||
|
</v-btn>
|
||||||
|
<v-dialog>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn icon v-bind="props">
|
||||||
|
<v-tooltip activator="parent">Show Licenses</v-tooltip>
|
||||||
|
<svg-icon :path="mdiLicense" type="mdi"/>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<template v-slot:default="{ isActive }">
|
||||||
|
<v-card style="height: 90vh">
|
||||||
|
<v-toolbar class="popup">
|
||||||
<v-toolbar-title>Licenses</v-toolbar-title>
|
<v-toolbar-title>Licenses</v-toolbar-title>
|
||||||
<v-spacer>
|
<v-spacer></v-spacer>
|
||||||
</v-spacer>
|
|
||||||
<v-btn icon @click="isActive.value = false">
|
<v-btn icon @click="isActive.value = false">
|
||||||
<svg-icon type="mdi" :path="mdiClose"/>
|
<svg-icon :path="mdiClose" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
@@ -167,7 +229,7 @@ window.addEventListener('keydown', (event) => {
|
|||||||
</v-dialog>
|
</v-dialog>
|
||||||
<v-btn icon @click="openGithub">
|
<v-btn icon @click="openGithub">
|
||||||
<v-tooltip activator="parent">Open (G)itHub</v-tooltip>
|
<v-tooltip activator="parent">Open (G)itHub</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiGithub"/>
|
<svg-icon :path="mdiGithub" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<div ref="statsHolder"></div>
|
<div ref="statsHolder"></div>
|
||||||
</template>
|
</template>
|
||||||
@@ -187,4 +249,21 @@ window.addEventListener('keydown', (event) => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
top: 5px;
|
top: 5px;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
h5 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-toolbar {
|
||||||
|
position: sticky !important;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-toolbar.popup {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-toolbar.popup > div {
|
||||||
|
height: 32px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
21
frontend/tools/b64.ts
Normal file
21
frontend/tools/b64.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export function b64UrlEncode(data: Uint8Array): string {
|
||||||
|
const base64 = btoa(String.fromCharCode(...data));
|
||||||
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function b64UrlDecode(encoded: string): Uint8Array {
|
||||||
|
// Replace URL-safe characters with standard base64 characters
|
||||||
|
let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
// Add padding if necessary
|
||||||
|
const padding = base64.length % 4;
|
||||||
|
if (padding) {
|
||||||
|
base64 += '='.repeat(4 - padding);
|
||||||
|
}
|
||||||
|
// Decode the base64 string to a byte array
|
||||||
|
const binaryString = atob(base64);
|
||||||
|
const byteArray = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
byteArray[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return byteArray;
|
||||||
|
}
|
||||||
35
frontend/tools/monaco.ts
Normal file
35
frontend/tools/monaco.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import {loader} from "@guolao/vue-monaco-editor"
|
||||||
|
|
||||||
|
import * as monaco from "monaco-editor"
|
||||||
|
//@ts-ignore
|
||||||
|
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"
|
||||||
|
//@ts-ignore
|
||||||
|
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"
|
||||||
|
//@ts-ignore
|
||||||
|
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"
|
||||||
|
//@ts-ignore
|
||||||
|
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"
|
||||||
|
//@ts-ignore
|
||||||
|
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"
|
||||||
|
|
||||||
|
(self as any).MonacoEnvironment = {
|
||||||
|
getWorker(_: any, label: string) {
|
||||||
|
if (label === "json") {
|
||||||
|
return new jsonWorker()
|
||||||
|
}
|
||||||
|
if (label === "css" || label === "scss" || label === "less") {
|
||||||
|
return new cssWorker()
|
||||||
|
}
|
||||||
|
if (label === "html" || label === "handlebars" || label === "razor") {
|
||||||
|
return new htmlWorker()
|
||||||
|
}
|
||||||
|
if (label === "typescript" || label === "javascript") {
|
||||||
|
return new tsWorker()
|
||||||
|
}
|
||||||
|
return new editorWorker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupMonaco() {
|
||||||
|
loader.config({monaco})
|
||||||
|
}
|
||||||
45
frontend/tools/pyodide-worker-api.ts
Normal file
45
frontend/tools/pyodide-worker-api.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type {loadPyodide} from "pyodide";
|
||||||
|
import type {MessageEventDataIn} from "./pyodide-worker.ts";
|
||||||
|
|
||||||
|
let requestId = 0;
|
||||||
|
|
||||||
|
/** Simple API for the Pyodide worker. */
|
||||||
|
export function newPyodideWorker(initOpts: Parameters<typeof loadPyodide>[0]) {
|
||||||
|
let worker = new Worker(new URL('./pyodide-worker.ts', import.meta.url), {type: "module"});
|
||||||
|
worker.postMessage(initOpts);
|
||||||
|
const commonRequestResponse = (event: MessageEventDataIn, stdout?: (msg: string) => void, stderr?: (msg: string) => void) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
worker.addEventListener("message", function listener(event: MessageEvent) {
|
||||||
|
if (stdout && event.data?.stdout) {
|
||||||
|
stdout(event.data.stdout); // No clue if associated with this request, but we handle it anyway.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stderr && event.data?.stderr) {
|
||||||
|
stderr(event.data.stderr); // No clue if associated with this request, but we handle it anyway.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.data?.id !== event.data.id) return; // Ignore messages that are not for this request.
|
||||||
|
if (event.data?.error) {
|
||||||
|
worker.removeEventListener("message", listener);
|
||||||
|
reject(event.data.error);
|
||||||
|
} else if (event.data?.hasOwnProperty("result")) {
|
||||||
|
worker.removeEventListener("message", listener);
|
||||||
|
resolve(event.data.result);
|
||||||
|
} else {
|
||||||
|
throw new Error("Unexpected message from worker: " + JSON.stringify(event.data));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
worker.postMessage(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
asyncRun: (code: string, stdout: (msg: string) => void, stderr: (msg: string) => void) =>
|
||||||
|
commonRequestResponse({type: "asyncRun", id: requestId++, code}, stdout, stderr),
|
||||||
|
mkdirTree: (path: string) => commonRequestResponse({type: "mkdirTree", id: requestId++, path}),
|
||||||
|
writeFile: (path: string, content: string) =>
|
||||||
|
commonRequestResponse({type: "writeFile", id: requestId++, path, content}),
|
||||||
|
makeSnapshot: () => commonRequestResponse({type: "makeSnapshot", id: requestId++}),
|
||||||
|
terminate: () => worker.terminate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
83
frontend/tools/pyodide-worker.ts
Normal file
83
frontend/tools/pyodide-worker.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import {loadPyodide, type PyodideAPI} from "pyodide";
|
||||||
|
|
||||||
|
let myLoadPyodide = (initOpts: Parameters<typeof loadPyodide>[0]) => loadPyodide({
|
||||||
|
...initOpts,
|
||||||
|
stdout: (msg) => self.postMessage({stdout: msg + "\n"}), // Add newline for better readability
|
||||||
|
stderr: (msg) => self.postMessage({stderr: msg + "\n"}), // Add newline for better readability
|
||||||
|
stdin: () => {
|
||||||
|
console.warn("Input requested by Python code, but stdin is not supported in this playground.");
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let pyodideReadyPromise: Promise<PyodideAPI> | null = null;
|
||||||
|
|
||||||
|
export type MessageEventDataIn = {
|
||||||
|
type: 'asyncRun';
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
} | {
|
||||||
|
type: 'mkdirTree';
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
} | {
|
||||||
|
type: 'writeFile';
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
} | {
|
||||||
|
type: 'makeSnapshot';
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = async (event: MessageEvent<MessageEventDataIn>) => {
|
||||||
|
if (!pyodideReadyPromise) { // First message is always the init message
|
||||||
|
// If we haven't loaded Pyodide yet, do so now.
|
||||||
|
// This is a singleton, so we only load it once.
|
||||||
|
pyodideReadyPromise = myLoadPyodide(event.data as Parameters<typeof loadPyodide>[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.data.type === 'mkdirTree') {
|
||||||
|
// Create a directory tree in the Pyodide filesystem.
|
||||||
|
const pyodide = await pyodideReadyPromise;
|
||||||
|
try {
|
||||||
|
await pyodide.FS.mkdirTree(event.data.path);
|
||||||
|
self.postMessage({id: event.data.id, result: true});
|
||||||
|
} catch (error: any) {
|
||||||
|
self.postMessage({id: event.data.id, error: error.message});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if (event.data.type === 'writeFile') {
|
||||||
|
// Write a file to the Pyodide filesystem.
|
||||||
|
const pyodide = await pyodideReadyPromise;
|
||||||
|
try {
|
||||||
|
await pyodide.FS.writeFile(event.data.path, event.data.content);
|
||||||
|
self.postMessage({id: event.data.id, result: true});
|
||||||
|
} catch (error: any) {
|
||||||
|
self.postMessage({id: event.data.id, error: error.message});
|
||||||
|
}
|
||||||
|
} else if (event.data.type === 'asyncRun') {
|
||||||
|
let code = event.data.code;
|
||||||
|
// make sure loading is done
|
||||||
|
const pyodide = await pyodideReadyPromise;
|
||||||
|
// Now load any packages we need, run the code, and send the result back.
|
||||||
|
await pyodide.loadPackagesFromImports(code);
|
||||||
|
try {
|
||||||
|
self.postMessage({id: event.data.id, result: await pyodide.runPythonAsync(code)});
|
||||||
|
} catch (error: any) {
|
||||||
|
self.postMessage({id: event.data.id, error: error.message});
|
||||||
|
}
|
||||||
|
} else if (event.data.type === 'makeSnapshot') {
|
||||||
|
// Take a snapshot of the current Pyodide filesystem.
|
||||||
|
const pyodide = await pyodideReadyPromise;
|
||||||
|
try {
|
||||||
|
const snapshot = pyodide.makeMemorySnapshot();
|
||||||
|
self.postMessage({id: event.data.id, result: snapshot});
|
||||||
|
} catch (error: any) {
|
||||||
|
self.postMessage({id: event.data.id, error: error.message});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("Unknown message type:", (event.data as any)?.type);
|
||||||
|
self.postMessage({id: (event.data as any)?.id, error: "Unknown message type: " + (event.data as any)?.type});
|
||||||
|
}
|
||||||
|
};
|
||||||
182
frontend/tools/selection.ts
Normal file
182
frontend/tools/selection.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// Model management from the graphics side
|
||||||
|
|
||||||
|
import type { MObject3D } from "./Selection.vue";
|
||||||
|
import type { Intersection } from "three";
|
||||||
|
import { Box3 } from "three";
|
||||||
|
import { extrasNameKey } from "../misc/gltf";
|
||||||
|
|
||||||
|
/** Information about a single item in the selection */
|
||||||
|
export class SelectionInfo {
|
||||||
|
/** The object which was (partially) selected */
|
||||||
|
object: MObject3D;
|
||||||
|
/** The type of the selection */
|
||||||
|
kind: "face" | "edge" | "vertex";
|
||||||
|
/** Start and end indices of the primitives in the geometry */
|
||||||
|
indices: [number, number];
|
||||||
|
|
||||||
|
constructor(object: MObject3D, kind: "face" | "edge" | "vertex", indices: [number, number]) {
|
||||||
|
this.object = object;
|
||||||
|
this.kind = kind;
|
||||||
|
this.indices = indices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getObjectName() {
|
||||||
|
return this.object.userData[extrasNameKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
public matches(object: MObject3D) {
|
||||||
|
return (
|
||||||
|
this.getObjectName() === object.userData[extrasNameKey] &&
|
||||||
|
((this.kind === "face" && (object.type === "Mesh" || object.type === "SkinnedMesh")) ||
|
||||||
|
(this.kind === "edge" && (object.type === "Line" || object.type === "LineSegments")) ||
|
||||||
|
(this.kind === "vertex" && object.type === "Points"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getKey() {
|
||||||
|
return this.object.uuid + this.kind + this.indices[0].toFixed() + this.indices[1].toFixed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBox(): Box3 {
|
||||||
|
let index = this.object.geometry.index || { getX: (i: number) => i };
|
||||||
|
let pos = this.object.geometry.getAttribute("position");
|
||||||
|
let min = [Infinity, Infinity, Infinity];
|
||||||
|
let max = [-Infinity, -Infinity, -Infinity];
|
||||||
|
for (let i = this.indices[0]; i < this.indices[1]; i++) {
|
||||||
|
let vertIndex = index!.getX(i);
|
||||||
|
let x = pos.getX(vertIndex);
|
||||||
|
let y = pos.getY(vertIndex);
|
||||||
|
let z = pos.getZ(vertIndex);
|
||||||
|
min[0] = Math.min(min[0] ?? Infinity, x);
|
||||||
|
min[1] = Math.min(min[1] ?? Infinity, y);
|
||||||
|
min[2] = Math.min(min[2] ?? Infinity, z);
|
||||||
|
max[0] = Math.max(max[0] ?? -Infinity, x);
|
||||||
|
max[1] = Math.max(max[1] ?? -Infinity, y);
|
||||||
|
max[2] = Math.max(max[2] ?? -Infinity, z);
|
||||||
|
}
|
||||||
|
return new Box3().setFromArray([...min, ...max]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hitToSelectionInfo(hit: Intersection<MObject3D>): SelectionInfo | null {
|
||||||
|
let kind = hit.object.type;
|
||||||
|
if (kind == "Mesh" || kind == "SkinnedMesh") {
|
||||||
|
let indices = hitFaceTriangleIndices(hit);
|
||||||
|
if (indices === null) return null;
|
||||||
|
return new SelectionInfo(hit.object, "face", indices);
|
||||||
|
} else if (kind == "Line" || kind == "LineSegments") {
|
||||||
|
// Select raw lines, not the wide meshes representing them
|
||||||
|
// This is because the indices refer to the raw lines, not the wide meshes
|
||||||
|
// Furthermore, this allows better "fuzzy" raycasting logic
|
||||||
|
let indices = hitEdgePointIndices(hit);
|
||||||
|
if (indices === null) return null;
|
||||||
|
return new SelectionInfo(hit.object, "edge", indices);
|
||||||
|
} else if (kind == "Points") {
|
||||||
|
if (hit.index === undefined) return null;
|
||||||
|
return new SelectionInfo(hit.object, "vertex", [hit.index, hit.index + 1]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitFaceTriangleIndices(hit: Intersection<MObject3D>): [number, number] | null {
|
||||||
|
let faceTrianglesEnd = hit?.object?.geometry?.userData?.face_triangles_end;
|
||||||
|
if (!hit.faceIndex) return null;
|
||||||
|
if (!faceTrianglesEnd) {
|
||||||
|
// Fallback to selecting the whole imported mesh
|
||||||
|
//console.log("No face_triangles_end found, selecting the whole mesh");
|
||||||
|
return [0, (hit.object.geometry.index ?? hit.object.geometry.attributes.position)?.count ?? 0];
|
||||||
|
} else {
|
||||||
|
// Normal CAD model
|
||||||
|
let rawIndex = hit.faceIndex * 3; // Faces are triangles with 3 indices
|
||||||
|
for (let i = 0; i < faceTrianglesEnd.length; i++) {
|
||||||
|
let faceSwapIndex = faceTrianglesEnd[i];
|
||||||
|
if (rawIndex < faceSwapIndex) {
|
||||||
|
let start = i === 0 ? 0 : faceTrianglesEnd[i - 1];
|
||||||
|
return [start, faceTrianglesEnd[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitEdgePointIndices(hit: Intersection<MObject3D>): [number, number] | null {
|
||||||
|
let edgePointsEnd = hit?.object?.geometry?.userData?.edge_points_end;
|
||||||
|
if (!edgePointsEnd || hit.index === undefined) return null;
|
||||||
|
let rawIndex = hit.index; // Faces are triangles with 3 indices
|
||||||
|
for (let i = 0; i < edgePointsEnd.length; i++) {
|
||||||
|
let edgeSwapIndex = edgePointsEnd[i];
|
||||||
|
if (rawIndex < edgeSwapIndex) {
|
||||||
|
let start = i === 0 ? 0 : edgePointsEnd[i - 1];
|
||||||
|
return [start, edgePointsEnd[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyColor(
|
||||||
|
selInfo: SelectionInfo,
|
||||||
|
colorAttribute: any,
|
||||||
|
color: [number, number, number, number],
|
||||||
|
): [number, number, number, number] {
|
||||||
|
let index = selInfo.object.geometry.index;
|
||||||
|
let prevColor: [number, number, number, number] | null = null;
|
||||||
|
if (colorAttribute !== undefined) {
|
||||||
|
for (let i = selInfo.indices[0]; i < selInfo.indices[1]; i++) {
|
||||||
|
let vertIndex = index!.getX(i);
|
||||||
|
if (prevColor === null)
|
||||||
|
prevColor = [
|
||||||
|
colorAttribute.getX(vertIndex),
|
||||||
|
colorAttribute.getY(vertIndex),
|
||||||
|
colorAttribute.getZ(vertIndex),
|
||||||
|
colorAttribute.getW(vertIndex),
|
||||||
|
];
|
||||||
|
colorAttribute.setXYZW(vertIndex, color[0], color[1], color[2], color[3]);
|
||||||
|
}
|
||||||
|
colorAttribute.needsUpdate = true;
|
||||||
|
if (selInfo.object.userData.niceLine !== undefined) {
|
||||||
|
// Need to update the color of the nice line as well
|
||||||
|
let indexAttribute = selInfo.object.geometry.index!!;
|
||||||
|
let allNewColors = [];
|
||||||
|
for (let i = 0; i < indexAttribute.count; i++) {
|
||||||
|
if (indexAttribute.getX(i) >= selInfo.indices[0] && indexAttribute.getX(i) < selInfo.indices[1]) {
|
||||||
|
allNewColors.push(color[0], color[1], color[2]);
|
||||||
|
} else {
|
||||||
|
allNewColors.push(
|
||||||
|
colorAttribute.getX(indexAttribute.getX(i)),
|
||||||
|
colorAttribute.getY(indexAttribute.getX(i)),
|
||||||
|
colorAttribute.getZ(indexAttribute.getX(i)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selInfo.object.userData.niceLine.geometry.setColors(allNewColors);
|
||||||
|
for (let attribute of Object.values(selInfo.object.userData.niceLine.geometry.attributes)) {
|
||||||
|
(attribute as any).needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to tinting the whole mesh for imported models
|
||||||
|
//console.log("No color attribute found, tinting the whole mesh")
|
||||||
|
let tmpPrevColor = selInfo.object.material.color;
|
||||||
|
prevColor = [tmpPrevColor.r, tmpPrevColor.g, tmpPrevColor.b, 1];
|
||||||
|
selInfo.object.material.color.setRGB(color[0], color[1], color[2]);
|
||||||
|
selInfo.object.material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
return prevColor!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlight(selInfo: SelectionInfo): void {
|
||||||
|
// Update the color of all the triangles in the face
|
||||||
|
let geometry = selInfo.object.geometry;
|
||||||
|
let colorAttr = selInfo.object.geometry.getAttribute("color");
|
||||||
|
geometry.userData.savedColor = geometry.userData.savedColor || {};
|
||||||
|
geometry.userData.savedColor[selInfo.getKey()] = applyColor(selInfo, colorAttr, [1.0, 0.0, 0.0, 1.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightUndo(selInfo: SelectionInfo): void {
|
||||||
|
// Update the color of all the triangles in the face
|
||||||
|
let geometry = selInfo.object.geometry;
|
||||||
|
let colorAttr = selInfo.object.geometry.getAttribute("color");
|
||||||
|
let savedColor = geometry.userData.savedColor[selInfo.getKey()];
|
||||||
|
applyColor(selInfo, colorAttr, savedColor);
|
||||||
|
delete geometry.userData.savedColor[selInfo.getKey()];
|
||||||
|
}
|
||||||
91
frontend/tools/upload-file.ts
Normal file
91
frontend/tools/upload-file.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
//@ts-expect-error
|
||||||
|
import encryptCode from "tanmayo7lock?raw";
|
||||||
|
|
||||||
|
function encrypt(msg: string, secret: string = "hudfhgd8fghdfgh3uhuifdgh"): string {
|
||||||
|
let exports: any = {};
|
||||||
|
eval(encryptCode.replace("exports.encrypt = encrypt;", "exports.LargeDataCrypto = LargeDataCrypto;\nexports.encrypt = encrypt;"));
|
||||||
|
return exports.LargeDataCrypto.encrypt(msg, secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function check(lockerName: string) {
|
||||||
|
const fileUrl = `https://vouz-backend.onrender.com/api/check_key`;
|
||||||
|
const response = await fetch(fileUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({name: encrypt(lockerName), key: encrypt(lockerName)}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Failed to get file URL: ${response.status} ${response.statusText} -- ${await response.text()}`);
|
||||||
|
const status = await response.json();
|
||||||
|
return {response, status};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFile(name: string, data: Uint8Array): Promise<string> {
|
||||||
|
// "Free" storage, let's see how long it lasts...
|
||||||
|
// Create a locker
|
||||||
|
const lockerUrl = `https://vouz-backend.onrender.com/api/locker`
|
||||||
|
const lockerName = `yacv-pg-${name}-${Date.now()}`; // Unique locker name
|
||||||
|
let responsePromise = fetch(lockerUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({name: encrypt(lockerName), passkey: encrypt(lockerName)}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The previous request never answers 🤮
|
||||||
|
responsePromise.then((response) => console.warn(`Locker creation response: ${response.status} ${response.statusText} -- ${response.headers.get('Content-Type')}`));
|
||||||
|
// Instead, poll the check endpoint until the locker is created
|
||||||
|
let i: number;
|
||||||
|
for (i = 0; i < 10; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 250)); // Wait a bit before checking
|
||||||
|
try {
|
||||||
|
let {status} = await check(lockerName);
|
||||||
|
if (status && status.data && status.data.length == 0) break // Locker is created
|
||||||
|
} catch (e) { // Ignore errors, they will be thrown later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i >= 10) throw new Error(`Failed to create locker after 10 attempts: ${lockerName}`);
|
||||||
|
|
||||||
|
// Upload file to the locker
|
||||||
|
const uploadUrl = `https://vouz-backend.onrender.com/api/upload`;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', new Blob([data as ArrayBufferView<ArrayBuffer>], {type: 'application/octet-stream'}), name);
|
||||||
|
formData.append("name", encrypt(lockerName));
|
||||||
|
formData.append("passkey", encrypt(lockerName));
|
||||||
|
const response = await fetch(uploadUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`Failed to upload file: ${response.status} ${response.statusText} -- ${await response.text()}`);
|
||||||
|
|
||||||
|
// Fake URL for retrieveFile to work
|
||||||
|
return "https://vouz.tech#name=" + encodeURIComponent(name) + "&locker=" + encodeURIComponent(lockerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Given any URL, it retrieves the file, with custom code for the vouz.tech locker. */
|
||||||
|
export async function retrieveFile(url: string): Promise<Response> {
|
||||||
|
let realUrl = url;// Normal fetch if the URL is not a vouz.tech locker URL
|
||||||
|
if (url.indexOf("https://vouz.tech#") !== -1) { // Check if the URL is a vouz.tech locker URL
|
||||||
|
// Parse the URL to get the locker name and file name
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const hashParams = new URLSearchParams(urlObj.hash.slice(1)); // Remove the leading '#'
|
||||||
|
const lockerName = hashParams.get('locker') || (() => {
|
||||||
|
throw new Error("Locker name not found in URL hash")
|
||||||
|
})();
|
||||||
|
const name = hashParams.get('name') || (() => {
|
||||||
|
throw new Error("File name not found in URL hash")
|
||||||
|
})();
|
||||||
|
// Get the URL of the uploaded file
|
||||||
|
let {status} = await check(lockerName);
|
||||||
|
if (!status || !status.data || status.data.length == 0 || !status.data[0].url) {
|
||||||
|
throw new Error(`No file URL found in response: ${JSON.stringify(status)}`);
|
||||||
|
}
|
||||||
|
console.debug("File access requested successfully, URL:", status.data[0].url);
|
||||||
|
realUrl = "https://corsproxy.io/?url=" + status.data[0].url + "#name=" + encodeURIComponent(name) + "&locker=" + encodeURIComponent(lockerName);
|
||||||
|
}
|
||||||
|
return await fetch(realUrl);
|
||||||
|
}
|
||||||
@@ -1,41 +1,122 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {settings} from "../misc/settings";
|
import {settings} from "../misc/settings";
|
||||||
import {inject, onMounted, type Ref, ref, watch} from "vue";
|
import {inject, onUpdated, type Ref, ref, watch} from "vue";
|
||||||
import {VList, VListItem} from "vuetify/lib/components/index.mjs";
|
|
||||||
import {$renderer, $scene} from "@google/model-viewer/lib/model-viewer-base";
|
import {$renderer, $scene} from "@google/model-viewer/lib/model-viewer-base";
|
||||||
import Loading from "../misc/Loading.vue";
|
import {$controls} from '@google/model-viewer/lib/features/controls.js';
|
||||||
|
import {type SmoothControls} from '@google/model-viewer/lib/three-components/SmoothControls';
|
||||||
import {ModelViewerElement} from '@google/model-viewer';
|
import {ModelViewerElement} from '@google/model-viewer';
|
||||||
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||||
import {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot";
|
import {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot";
|
||||||
import type {Renderer} from "@google/model-viewer/lib/three-components/Renderer";
|
import type {Renderer} from "@google/model-viewer/lib/three-components/Renderer";
|
||||||
import type {Vector3} from "three";
|
import type {Vector3} from "three";
|
||||||
|
import {BufferGeometry, Mesh} from "three";
|
||||||
|
import {acceleratedRaycast, computeBoundsTree, disposeBoundsTree} from 'three-mesh-bvh';
|
||||||
|
import {setupLighting} from "./lighting.ts";
|
||||||
|
|
||||||
ModelViewerElement.modelCacheSize = 0; // Also needed to avoid tree shaking
|
ModelViewerElement.modelCacheSize = 0; // Also needed to avoid tree shaking
|
||||||
|
//@ts-ignore
|
||||||
const emit = defineEmits<{ load: [] }>()
|
BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
|
||||||
|
//@ts-ignore
|
||||||
|
BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree;
|
||||||
|
//@ts-ignore
|
||||||
|
Mesh.prototype.raycast = acceleratedRaycast;
|
||||||
|
|
||||||
const props = defineProps<{ src: string }>();
|
const props = defineProps<{ src: string }>();
|
||||||
|
|
||||||
const elem = ref<ModelViewerElement | null>(null);
|
const elem = ref<ModelViewerElement | null>(null);
|
||||||
const scene = ref<ModelScene | null>(null);
|
const scene = ref<ModelScene | null>(null);
|
||||||
const renderer = ref<Renderer | null>(null);
|
const renderer = ref<Renderer | null>(null);
|
||||||
|
const controls = ref<SmoothControls | null>(null);
|
||||||
|
|
||||||
onMounted(() => {
|
const sett = ref<any | null>(null);
|
||||||
if (!elem.value) return;
|
(async () => sett.value = await settings)();
|
||||||
elem.value.addEventListener('load', async () => {
|
|
||||||
if (!elem.value) return;
|
let lastCameraTargetPosition: Vector3 | undefined = undefined;
|
||||||
// Delete the initial load banner
|
let lastCameraZoom: number | undefined = undefined;
|
||||||
let banner = elem.value.querySelector('.initial-load-banner');
|
let lastCameraUrl = props.src.toString();
|
||||||
if (banner) banner.remove();
|
let initialized = false
|
||||||
// Set the scene and renderer
|
onUpdated(() => {
|
||||||
|
if (!elem.value) return; // Not ready yet
|
||||||
|
if (initialized) return; // Already initialized
|
||||||
|
initialized = true;
|
||||||
|
elem.value.addEventListener('before-render', () => {
|
||||||
|
if (!elem.value) return
|
||||||
|
// Extract internals of model-viewer in order to hack unsupported features
|
||||||
scene.value = elem.value[$scene] as ModelScene;
|
scene.value = elem.value[$scene] as ModelScene;
|
||||||
renderer.value = elem.value[$renderer] as Renderer;
|
renderer.value = elem.value[$renderer] as Renderer;
|
||||||
// Emit the load event
|
controls.value = (elem.value as any)[$controls] as SmoothControls;
|
||||||
emit('load')
|
// Recover the camera position if it was set before
|
||||||
|
if (lastCameraTargetPosition) {
|
||||||
|
// console.log("RESTORING camera position?", lastCameraTargetPosition);
|
||||||
|
scene.value.setTarget(-lastCameraTargetPosition.x, -lastCameraTargetPosition.y, -lastCameraTargetPosition.z);
|
||||||
|
scene.value.jumpToGoal(); // Avoid move animation
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
let tries = 0
|
||||||
|
while (tries++ < 25) {
|
||||||
|
if (!lastCameraZoom || !elem.value?.getCameraOrbit()?.radius) break;
|
||||||
|
let change = lastCameraZoom - elem.value.getCameraOrbit().radius;
|
||||||
|
//console.log("Zooming to", lastCameraZoom, "from", elem.value.getCameraOrbit().radius, "change", change);
|
||||||
|
if (Math.abs(change) < 0.001) break;
|
||||||
|
elem.value.zoom(-Math.sign(change) * (Math.pow(Math.abs(change) + 1, 0.9) - 1)); // Arbitrary, experimental
|
||||||
|
elem.value.jumpCameraToGoal();
|
||||||
|
await elem.value.updateComplete;
|
||||||
|
}
|
||||||
|
//console.log("Ready to save!")
|
||||||
|
lastCameraUrl = props.src.toString();
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
elem.value.addEventListener('camera-change', onCameraChange);
|
elem.value.addEventListener('camera-change', onCameraChange);
|
||||||
|
elem.value.addEventListener('progress', (ev) => onProgress((ev as any).detail.totalProgress));
|
||||||
|
setupLighting(elem.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function onCameraChange() {
|
||||||
|
// Remember the camera position to keep it in case of scene changes
|
||||||
|
if (scene.value && props.src.toString() == lastCameraUrl) { // Don't overwrite with initial unwanted positions
|
||||||
|
lastCameraTargetPosition = scene.value.target.position.clone();
|
||||||
|
lastCameraZoom = elem.value?.getCameraOrbit()?.radius;
|
||||||
|
//console.log("Saving camera?", lastCameraTargetPosition, lastCameraZoom);
|
||||||
|
}
|
||||||
|
// Also need to update the SVG overlay
|
||||||
|
for (let lineId in lines.value) {
|
||||||
|
onCameraChangeLine(lineId as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles loading the events for <model-viewer>'s slotted progress bar
|
||||||
|
const progressBar = ref<HTMLElement | null>(null);
|
||||||
|
const updateBar = ref<HTMLElement | null>(null);
|
||||||
|
let onProgressHideTimeout: number | null = null;
|
||||||
|
const onProgress = (totalProgress: number) => {
|
||||||
|
if (!progressBar.value || !updateBar.value) return;
|
||||||
|
// Update the progress bar and ensure it's visible
|
||||||
|
progressBar.value.style.display = 'block';
|
||||||
|
progressBar.value.style.opacity = '1'; // Fade in
|
||||||
|
updateBar.value.style.width = `${totalProgress * 100}%`;
|
||||||
|
// Auto-hide smoothly when no progress is made for a while
|
||||||
|
if (onProgressHideTimeout) clearTimeout(onProgressHideTimeout);
|
||||||
|
onProgressHideTimeout = setTimeout(() => {
|
||||||
|
if (!progressBar.value) return;
|
||||||
|
progressBar.value.style.opacity = '0'; // Fade out
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!progressBar.value) return;
|
||||||
|
progressBar.value.style.display = 'none'; // Actually hide
|
||||||
|
}, 300); // 0.3s fade out
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const poster = ref<string>("")
|
||||||
|
const setPosterText = (newText: string) => {
|
||||||
|
poster.value = "data:image/svg+xml;charset=utf-8;base64," + btoa(
|
||||||
|
'<svg width="800" height="600" xmlns="http://www.w3.org/2000/svg" fill="gray">' +
|
||||||
|
'<text x="50%" y="0%" dominant-baseline="middle" text-anchor="middle" font-size="48px">' +
|
||||||
|
newText +
|
||||||
|
'</text>' +
|
||||||
|
'</svg>')
|
||||||
|
}
|
||||||
|
setPosterText("Loading...")
|
||||||
|
|
||||||
class Line3DData {
|
class Line3DData {
|
||||||
startHotspot: HTMLElement = document.body
|
startHotspot: HTMLElement = document.body
|
||||||
endHotspot: HTMLElement = document.body
|
endHotspot: HTMLElement = document.body
|
||||||
@@ -80,38 +161,31 @@ function addLine3D(p1: Vector3, p2: Vector3, centerText?: string, lineAttrs: { [
|
|||||||
function removeLine3D(id: number): boolean {
|
function removeLine3D(id: number): boolean {
|
||||||
if (!scene.value || !(id in lines.value)) return false;
|
if (!scene.value || !(id in lines.value)) return false;
|
||||||
scene.value.removeHotspot(new Hotspot({name: 'line' + id + '_start'}));
|
scene.value.removeHotspot(new Hotspot({name: 'line' + id + '_start'}));
|
||||||
lines.value[id].startHotspot.parentElement?.remove()
|
lines.value[id]?.startHotspot.parentElement?.remove()
|
||||||
scene.value.removeHotspot(new Hotspot({name: 'line' + id + '_end'}));
|
scene.value.removeHotspot(new Hotspot({name: 'line' + id + '_end'}));
|
||||||
lines.value[id].endHotspot.parentElement?.remove()
|
lines.value[id]?.endHotspot.parentElement?.remove()
|
||||||
delete lines.value[id];
|
delete lines.value[id];
|
||||||
scene.value.queueRender() // Needed to update the hotspots
|
scene.value.queueRender() // Needed to update the hotspots
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCameraChange() {
|
|
||||||
// Need to update the SVG overlay
|
|
||||||
for (let lineId in lines.value) {
|
|
||||||
onCameraChangeLine(lineId as any);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let svg = ref<SVGElement | null>(null);
|
let svg = ref<SVGElement | null>(null);
|
||||||
|
|
||||||
function onCameraChangeLine(lineId: number) {
|
function onCameraChangeLine(lineId: number) {
|
||||||
if (!(lineId in lines.value) || !(elem.value)) return // Silently ignore (not updated yet)
|
if (!(lineId in lines.value) || !(elem.value)) return // Silently ignore (not updated yet)
|
||||||
// Update start and end 2D positions
|
// Update start and end 2D positions
|
||||||
let {x: xB, y: yB} = elem.value.getBoundingClientRect();
|
let {x: xB, y: yB} = elem.value.getBoundingClientRect();
|
||||||
let {x, y} = lines.value[lineId].startHotspot.getBoundingClientRect();
|
let {x, y} = lines.value[lineId]?.startHotspot.getBoundingClientRect() ?? {x: 0, y: 0};
|
||||||
lines.value[lineId].start2D = [x - xB, y - yB];
|
if (lines.value[lineId]) lines.value[lineId].start2D = [x - xB, y - yB];
|
||||||
let {x: x2, y: y2} = lines.value[lineId].endHotspot.getBoundingClientRect();
|
let {x: x2, y: y2} = lines.value[lineId]?.endHotspot.getBoundingClientRect() ?? {x: 0, y: 0};
|
||||||
lines.value[lineId].end2D = [x2 - xB, y2 - yB];
|
if (lines.value[lineId]) lines.value[lineId].end2D = [x2 - xB, y2 - yB];
|
||||||
|
|
||||||
// Update the center text size if needed
|
// Update the center text size if needed
|
||||||
if (svg.value && lines.value[lineId].centerText && lines.value[lineId].centerTextSize[0] === 0) {
|
if (svg.value && lines.value[lineId]?.centerText && lines.value[lineId]?.centerTextSize[0] === 0) {
|
||||||
let text = svg.value.getElementsByClassName('line' + lineId + '_text')[0] as SVGTextElement | undefined;
|
let text = svg.value.getElementsByClassName('line' + lineId + '_text')[0] as SVGTextElement | undefined;
|
||||||
if (text) {
|
if (text) {
|
||||||
let bbox = text.getBBox();
|
let bbox = text.getBBox();
|
||||||
lines.value[lineId].centerTextSize = [bbox.width, bbox.height];
|
if (lines.value[lineId]) lines.value[lineId].centerTextSize = [bbox.width, bbox.height];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,48 +204,47 @@ function entries(lines: { [id: number]: Line3DData }): [string, Line3DData][] {
|
|||||||
return Object.entries(lines);
|
return Object.entries(lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({elem, onElemReady, scene, renderer, addLine3D, removeLine3D});
|
defineExpose({elem, onElemReady, scene, renderer, controls, addLine3D, removeLine3D, onProgress, setPosterText});
|
||||||
|
|
||||||
let {disableTap} = inject<{ disableTap: Ref<boolean> }>('disableTap')!!;
|
let {disableTap} = inject<{ disableTap: Ref<boolean> }>('disableTap')!!;
|
||||||
watch(disableTap, (value) => {
|
watch(disableTap, (newDisableTap) => {
|
||||||
// Rerender not auto triggered? This works anyway...
|
if (elem.value) elem.value.disableTap = newDisableTap;
|
||||||
if (value) elem.value?.setAttribute('disable-tap', '');
|
|
||||||
else elem.value?.removeAttribute('disable-tap');
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- The main 3D model viewer -->
|
<!-- The main 3D model viewer -->
|
||||||
<model-viewer ref="elem" style="width: 100%; height: 100%" :src="props.src" alt="The 3D model(s)" camera-controls
|
<model-viewer ref="elem" v-if="sett != null" :ar="sett.arModes.length > 0" :ar-modes="sett.arModes"
|
||||||
camera-orbit="30deg 75deg auto" max-camera-orbit="Infinity 180deg auto"
|
:environment-image="sett.environment" :exposure="sett.exposure" :autoplay="sett.autoplay"
|
||||||
min-camera-orbit="-Infinity 0deg 5%" :disable-tap="disableTap" :exposure="settings.exposure"
|
:orbit-sensitivity="sett.orbitSensitivity" :pan-sensitivity="sett.panSensitivity"
|
||||||
:shadow-intensity="settings.shadowIntensity" interaction-prompt="none" :autoplay="settings.autoplay"
|
:poster="poster" :shadow-intensity="sett.shadowIntensity" :skybox-image="sett.skybox"
|
||||||
:ar="settings.arModes.length > 0" :ar-modes="settings.arModes" :skybox-image="settings.background"
|
:src="props.src" :zoom-sensitivity="sett.zoomSensitivity" alt="The 3D model(s)" camera-controls
|
||||||
:environment-image="settings.background">
|
camera-orbit="45deg 45deg auto" interaction-prompt="none" max-camera-orbit="Infinity 180deg auto"
|
||||||
<slot></slot> <!-- Controls, annotations, etc. -->
|
min-camera-orbit="-Infinity 0deg 5%" style="width: 100%; height: 100%">
|
||||||
<div class="annotation initial-load-banner">
|
<slot></slot>
|
||||||
Trying to load models from...
|
<!-- Add a progress bar to the top of the model viewer -->
|
||||||
<v-list v-for="src in settings.preload" :key="src">
|
<div ref="progressBar" slot="progress-bar" class="progress-bar">
|
||||||
<v-list-item>{{ src }}</v-list-item>
|
<div ref="updateBar" class="update-bar"/>
|
||||||
</v-list>
|
|
||||||
<loading></loading>
|
|
||||||
</div>
|
</div>
|
||||||
</model-viewer>
|
</model-viewer>
|
||||||
|
|
||||||
<!-- The SVG overlay for fake 3D lines attached to the model -->
|
<!-- The SVG overlay for fake 3D lines attached to the model -->
|
||||||
<div class="overlay-svg-wrapper">
|
<div class="overlay-svg-wrapper">
|
||||||
<svg ref="svg" class="overlay-svg" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
<svg ref="svg" class="overlay-svg" height="100%" width="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g v-for="[lineId, line] in entries(lines)" :key="lineId">
|
<g v-for="[lineId, line] in entries(lines)" :key="lineId">
|
||||||
<line :x1="line.start2D[0]" :y1="line.start2D[1]" :x2="line.end2D[0]"
|
<line :x1="line.start2D[0]" :x2="line.end2D[0]" :y1="line.start2D[1]"
|
||||||
:y2="line.end2D[1]" v-bind="line.lineAttrs"/>
|
:y2="line.end2D[1]" v-bind="line.lineAttrs"/>
|
||||||
<g v-if="line.centerText !== undefined">
|
<g v-if="line.centerText !== undefined">
|
||||||
<rect :x="(line.start2D[0] + line.end2D[0]) / 2 - line.centerTextSize[0]/2 - 4"
|
<rect v-if="line.centerText"
|
||||||
:y="(line.start2D[1] + line.end2D[1]) / 2 - line.centerTextSize[1]/2 - 2"
|
:height="line.centerTextSize[1] + 4"
|
||||||
:width="line.centerTextSize[0] + 8" :height="line.centerTextSize[1] + 4"
|
:width="line.centerTextSize[0] + 8"
|
||||||
fill="gray" fill-opacity="0.75" rx="4" ry="4" stroke="black" v-if="line.centerText"/>
|
:x="(line.start2D[0] + line.end2D[0]) / 2 - line.centerTextSize[0]/2 - 4"
|
||||||
<text :x="(line.start2D[0] + line.end2D[0]) / 2" :y="(line.start2D[1] + line.end2D[1]) / 2"
|
:y="(line.start2D[1] + line.end2D[1]) / 2 - line.centerTextSize[1]/2 - 2" fill="gray"
|
||||||
text-anchor="middle" dominant-baseline="middle" font-size="16" fill="black"
|
fill-opacity="0.75" rx="4" ry="4" stroke="black"/>
|
||||||
:class="'line' + lineId + '_text'" v-if="line.centerText">
|
<text v-if="line.centerText" :class="'line' + lineId + '_text'"
|
||||||
|
:x="(line.start2D[0] + line.end2D[0]) / 2" :y="(line.start2D[1] + line.end2D[1]) / 2"
|
||||||
|
dominant-baseline="middle" fill="black"
|
||||||
|
font-size="16" text-anchor="middle">
|
||||||
{{ line.centerText }}
|
{{ line.centerText }}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@@ -206,14 +279,29 @@ watch(disableTap, (value) => {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.initial-load-banner {
|
.progress-bar {
|
||||||
width: 300px;
|
display: block;
|
||||||
margin: auto;
|
pointer-events: none;
|
||||||
margin-top: 3em;
|
width: 100%;
|
||||||
overflow: hidden;
|
height: 10%;
|
||||||
|
max-height: 2%;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
transform: translate3d(-50%, 0%, 0);
|
||||||
|
border-radius: 25px;
|
||||||
|
box-shadow: 0 3px 10px 3px rgba(0, 0, 0, 0.5), 0 0 5px 1px rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
transition: opacity 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.initial-load-banner .v-list-item {
|
.update-bar {
|
||||||
overflow: hidden;
|
background-color: rgba(255, 255, 255, 0.9);
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 25px;
|
||||||
|
float: left;
|
||||||
|
transition: width 0.3s;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
96
frontend/viewer/lighting.ts
Normal file
96
frontend/viewer/lighting.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { ModelViewerElement } from "@google/model-viewer";
|
||||||
|
import { $scene } from "@google/model-viewer/lib/model-viewer-base";
|
||||||
|
import { settings } from "../misc/settings.ts";
|
||||||
|
|
||||||
|
export let currentSceneRotation = 0; // radians, 0 is the default rotation
|
||||||
|
|
||||||
|
export async function setupLighting(modelViewer: ModelViewerElement) {
|
||||||
|
modelViewer[$scene].environmentIntensity = (await settings).environmentIntensity;
|
||||||
|
// Code is mostly copied from the example at: https://modelviewer.dev/examples/stagingandcameras/#turnSkybox
|
||||||
|
let lastX: number;
|
||||||
|
let panning = false;
|
||||||
|
let radiansPerPixel: number;
|
||||||
|
|
||||||
|
const startPan = () => {
|
||||||
|
const orbit = modelViewer.getCameraOrbit();
|
||||||
|
const { radius } = orbit;
|
||||||
|
radiansPerPixel = (-1 * radius) / modelViewer.getBoundingClientRect().height;
|
||||||
|
modelViewer.interactionPrompt = "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePan = (thisX: number) => {
|
||||||
|
const delta = (thisX - lastX) * radiansPerPixel;
|
||||||
|
lastX = thisX;
|
||||||
|
currentSceneRotation += delta;
|
||||||
|
const orbit = modelViewer.getCameraOrbit();
|
||||||
|
orbit.theta += delta;
|
||||||
|
modelViewer.cameraOrbit = orbit.toString();
|
||||||
|
modelViewer.resetTurntableRotation(currentSceneRotation);
|
||||||
|
modelViewer.jumpCameraToGoal();
|
||||||
|
};
|
||||||
|
|
||||||
|
modelViewer.addEventListener(
|
||||||
|
"mousedown",
|
||||||
|
(event) => {
|
||||||
|
panning = event.metaKey || event.shiftKey;
|
||||||
|
if (!panning) return;
|
||||||
|
|
||||||
|
lastX = event.clientX;
|
||||||
|
startPan();
|
||||||
|
event.stopPropagation();
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
modelViewer.addEventListener(
|
||||||
|
"touchstart",
|
||||||
|
(event) => {
|
||||||
|
const { targetTouches, touches } = event;
|
||||||
|
panning = targetTouches.length === 2 && targetTouches.length === touches.length;
|
||||||
|
if (!panning) return;
|
||||||
|
|
||||||
|
lastX = 0.5 * ((targetTouches[0]?.clientX ?? 0) + (targetTouches[1]?.clientX ?? 0));
|
||||||
|
startPan();
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"mousemove",
|
||||||
|
(event) => {
|
||||||
|
if (!panning) return;
|
||||||
|
|
||||||
|
updatePan(event.clientX);
|
||||||
|
event.stopPropagation();
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
modelViewer.addEventListener(
|
||||||
|
"touchmove",
|
||||||
|
(event) => {
|
||||||
|
if (!panning || event.targetTouches.length !== 2) return;
|
||||||
|
|
||||||
|
const { targetTouches } = event;
|
||||||
|
const thisX = 0.5 * ((targetTouches[0]?.clientX ?? 0) + (targetTouches[1]?.clientX ?? 0));
|
||||||
|
updatePan(thisX);
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"mouseup",
|
||||||
|
(event) => {
|
||||||
|
panning = false;
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
modelViewer.addEventListener(
|
||||||
|
"touchend",
|
||||||
|
(event) => {
|
||||||
|
panning = false;
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
52
package.json
52
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "yet-another-cad-viewer",
|
"name": "yet-another-cad-viewer",
|
||||||
"version": "0.6.12",
|
"version": "0.10.11",
|
||||||
"description": "",
|
"description": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -15,31 +15,41 @@
|
|||||||
"update-licenses": "generate-license-file --input package.json --output assets/licenses.txt --overwrite"
|
"update-licenses": "generate-license-file --input package.json --output assets/licenses.txt --overwrite"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gltf-transform/core": "^3.10.0",
|
"@gltf-transform/core": "^4.1.0",
|
||||||
"@gltf-transform/functions": "^3.10.0",
|
"@gltf-transform/extensions": "^4.1.0",
|
||||||
"@google/model-viewer": "^3.4.0",
|
"@gltf-transform/functions": "^4.1.0",
|
||||||
|
"@google/model-viewer": "^4.0.0",
|
||||||
|
"@guolao/vue-monaco-editor": "^1.5.5",
|
||||||
"@jamescoyle/vue-icon": "^0.1.2",
|
"@jamescoyle/vue-icon": "^0.1.2",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@mdi/svg": "^7.4.47",
|
"@mdi/svg": "^7.4.47",
|
||||||
"three": "^0.160.1",
|
"js-base64": "^3.7.7",
|
||||||
"three-orientation-gizmo": "https://github.com/jrj2211/three-orientation-gizmo",
|
"monaco-editor": "^0.55.0",
|
||||||
"vue": "^3.4.21",
|
"pako": "^2.1.0",
|
||||||
"vuetify": "^3.5.8"
|
"pyodide": "^0.29.0",
|
||||||
|
"tanmayo7lock": "^1.0.18",
|
||||||
|
"three": "^0.182.0",
|
||||||
|
"three-mesh-bvh": "^0.9.0",
|
||||||
|
"three-orientation-gizmo": "git+https://github.com/jrj2211/three-orientation-gizmo.git",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vuetify": "^3.7.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node20": "^20.1.2",
|
"@tsconfig/node20": "^20.1.4",
|
||||||
"@types/node": "^20.11.25",
|
"@types/node": "^24.0.0",
|
||||||
"@types/three": "^0.160.0",
|
"@types/pako": "^2.0.3",
|
||||||
"@vitejs/plugin-vue": "^5.0.3",
|
"@types/three": "^0.182.0",
|
||||||
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
"@vue/tsconfig": "^0.5.1",
|
"@vitejs/plugin-vue-jsx": "^5.0.0",
|
||||||
|
"@vue/tsconfig": "^0.8.0",
|
||||||
"buffer": "^5.5.0||^6.0.0",
|
"buffer": "^5.5.0||^6.0.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^14.0.0",
|
||||||
"generate-license-file": "^3.0.1",
|
"generate-license-file": "^4.0.0",
|
||||||
"npm-run-all2": "^6.1.1",
|
"npm-run-all2": "^8.0.0",
|
||||||
"terser": "^5.29.1",
|
"terser": "^5.36.0",
|
||||||
"typescript": "~5.4.2",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^5.1.5",
|
"vite": "^7.0.0",
|
||||||
"vue-tsc": "^2.0.6"
|
"vite-plugin-static-copy": "^3.1.1",
|
||||||
|
"vue-tsc": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1755
poetry.lock
generated
1755
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,34 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core==2.1.3", "taskipy==1.14.1"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "yacv-server"
|
name = "yacv-server"
|
||||||
version = "0.6.12"
|
version = "0.10.11"
|
||||||
description = "Yet Another CAD Viewer (server)"
|
description = "Yet Another CAD Viewer (server)"
|
||||||
authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"]
|
authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
include = [
|
include = [
|
||||||
{ path = 'yacv_server/frontend/*', format = 'wheel' },
|
{ path = 'yacv_server/frontend/**/*', format = 'wheel' },
|
||||||
{ path = 'yacv_server/frontend/*', format = 'sdist' },
|
{ path = 'yacv_server/frontend/**/*', format = 'sdist' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.taskipy.tasks]
|
||||||
|
build = "task build_frontend && task build_backend"
|
||||||
|
build_frontend = "rm -rf yacv_server/frontend || true && yarn install && YACV_SMALL_BUILD=true yarn build --outDir yacv_server/frontend"
|
||||||
|
build_backend = "poetry build --format wheel"
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.9"
|
python = ">=3.14,<3.15" # Due to vtk transitive dependency of build123d -> cadquery-ocp -> vtk
|
||||||
|
|
||||||
# CAD
|
# CAD
|
||||||
build123d = "^0.4.0"
|
build123d = ">=0.10,<0.11"
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
pygltflib = "^1.16.2"
|
pygltflib = "^1.16.2"
|
||||||
pillow = "^10.2.0"
|
pillow = ">=12,<12.1"
|
||||||
iterators = "^0.2.0"
|
poetry-core = "==2.2.1"
|
||||||
|
|
||||||
[tool.poetry.build]
|
[tool.poetry.group.dev.dependencies]
|
||||||
generate-setup-file = false
|
taskipy = "^1.14.1"
|
||||||
script = "build.py"
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["poetry-core"]
|
|
||||||
build-backend = "poetry.core.masonry.api"
|
|
||||||
|
|||||||
@@ -8,12 +8,11 @@
|
|||||||
"frontend/**/__tests__/*"
|
"frontend/**/__tests__/*"
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"declaration": false,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import {fileURLToPath, URL} from 'node:url'
|
import {fileURLToPath, URL} from 'node:url'
|
||||||
|
|
||||||
import {defineConfig} from 'vite'
|
import {defineConfig} from 'vite'
|
||||||
|
// @ts-ignore
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
// @ts-ignore
|
||||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||||
import {name, version} from './package.json'
|
import {name, version} from './package.json'
|
||||||
import {execSync} from 'child_process'
|
import {execSync} from 'child_process'
|
||||||
|
import {viteStaticCopy} from "vite-plugin-static-copy";
|
||||||
|
import {dirname, join} from "path";
|
||||||
|
import {version as pyodideVersion} from "pyodide";
|
||||||
|
|
||||||
|
let wantsSmallBuild = process.env.YACV_SMALL_BUILD == "true";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -13,12 +20,14 @@ export default defineConfig({
|
|||||||
vue({
|
vue({
|
||||||
template: {
|
template: {
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
isCustomElement: tag => tag == 'model-viewer'
|
isCustomElement: (tag: string) => tag == 'model-viewer'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
vueJsx(),
|
vueJsx(),
|
||||||
|
viteStaticCopyPyodide(),
|
||||||
],
|
],
|
||||||
|
optimizeDeps: {exclude: ["pyodide"]},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -26,13 +35,53 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
assetsDir: '.',
|
assetsDir: '.', // Support deploying to a subdirectory using relative URLs
|
||||||
cssCodeSplit: false, // Small enough to inline
|
cssCodeSplit: false, // Small enough to inline
|
||||||
|
chunkSizeWarningLimit: 1024, // KB. Three.js is big. Draco is even bigger but not likely to be used.
|
||||||
|
sourcemap: true, // For debugging production
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
experimentalMinChunkSize: 512000, // 512KB (avoid too many small chunks)
|
||||||
|
},
|
||||||
|
external: wantsSmallBuild ? [
|
||||||
|
// Exclude some large optional dependencies if small build is requested (for embedding in python package)
|
||||||
|
"pyodide",
|
||||||
|
/.*\/pyodide-worker.*/,
|
||||||
|
"monaco-editor",
|
||||||
|
/monaco-editor\/.*/,
|
||||||
|
"@guolao/vue-monaco-editor",
|
||||||
|
/three\/examples\/jsm\/libs\/draco\/draco_(en|de)coder\.js/,
|
||||||
|
] : [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
worker: {
|
||||||
|
format: 'es', // Use ES modules for workers (IIFE is not supported with code-splitting)
|
||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
__APP_NAME__: JSON.stringify(name),
|
__APP_NAME__: JSON.stringify(name),
|
||||||
__APP_VERSION__: JSON.stringify(version),
|
__APP_VERSION__: JSON.stringify(version),
|
||||||
__APP_GIT_SHA__: JSON.stringify(execSync('git rev-parse HEAD').toString().trim()),
|
__APP_GIT_SHA__: JSON.stringify(execSync('git rev-parse HEAD').toString().trim()),
|
||||||
__APP_GIT_DIRTY__: JSON.stringify(execSync('git diff --quiet || echo dirty').toString().trim()),
|
__APP_GIT_DIRTY__: JSON.stringify(execSync('git diff --quiet || echo dirty').toString().trim()),
|
||||||
|
__YACV_SMALL_BUILD__: JSON.stringify(wantsSmallBuild)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
function viteStaticCopyPyodide() {
|
||||||
|
const PYODIDE_EXCLUDE = [
|
||||||
|
"!**/*.{md,html}",
|
||||||
|
"!**/*.d.ts",
|
||||||
|
"!**/*.whl",
|
||||||
|
"!**/node_modules",
|
||||||
|
];
|
||||||
|
// @ts-ignore
|
||||||
|
const pyodideDir = dirname(fileURLToPath(import.meta.resolve("pyodide")));
|
||||||
|
return viteStaticCopy({
|
||||||
|
targets: wantsSmallBuild ? [] : [
|
||||||
|
{
|
||||||
|
src: [join(pyodideDir, "*")].concat(PYODIDE_EXCLUDE),
|
||||||
|
dest: "pyodide-v" + pyodideVersion, // It would be better to use hashed names instead of folder...
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from cad import image_to_gltf
|
from yacv_server.cad import image_to_gltf
|
||||||
from yacv_server.yacv import YACV
|
from yacv_server.yacv import YACV
|
||||||
|
|
||||||
yacv = YACV()
|
yacv = YACV()
|
||||||
@@ -15,7 +15,7 @@ if 'YACV_DISABLE_SERVER' not in os.environ:
|
|||||||
# Expose some nice aliases using the default server instance
|
# Expose some nice aliases using the default server instance
|
||||||
show = yacv.show
|
show = yacv.show
|
||||||
show_all = yacv.show_cad_all
|
show_all = yacv.show_cad_all
|
||||||
prepare_image = image_to_gltf
|
image_to_gltf = image_to_gltf
|
||||||
export_all = yacv.export_all
|
export_all = yacv.export_all
|
||||||
remove = yacv.remove
|
remove = yacv.remove
|
||||||
clear = yacv.clear
|
clear = yacv.clear
|
||||||
|
|||||||
@@ -2,15 +2,38 @@
|
|||||||
Utilities to work with CAD objects
|
Utilities to work with CAD objects
|
||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
from typing import Optional, Union, List, Tuple
|
import io
|
||||||
|
import re
|
||||||
|
from typing import Optional, Union, Tuple
|
||||||
|
|
||||||
|
from OCP.TopExp import TopExp
|
||||||
from OCP.TopLoc import TopLoc_Location
|
from OCP.TopLoc import TopLoc_Location
|
||||||
|
from OCP.TopTools import TopTools_IndexedMapOfShape
|
||||||
from OCP.TopoDS import TopoDS_Shape
|
from OCP.TopoDS import TopoDS_Shape
|
||||||
|
from build123d import Compound, Color
|
||||||
|
|
||||||
from yacv_server.gltf import GLTFMgr
|
from yacv_server.gltf import GLTFMgr
|
||||||
|
|
||||||
CADCoreLike = Union[TopoDS_Shape, TopLoc_Location] # Faces, Edges, Vertices and Locations for now
|
CADCoreLike = Union[TopoDS_Shape, TopLoc_Location] # Faces, Edges, Vertices and Locations for now
|
||||||
CADLike = Union[CADCoreLike, any] # build123d and cadquery types
|
CADLike = Union[CADCoreLike, any] # build123d and cadquery types
|
||||||
|
ColorTuple = Tuple[float, float, float, float]
|
||||||
|
|
||||||
|
|
||||||
|
def get_color(obj: any) -> Optional[ColorTuple]:
|
||||||
|
"""Get color from a CAD Object or any other color-like object"""
|
||||||
|
if 'color' in dir(obj):
|
||||||
|
obj = obj.color
|
||||||
|
if isinstance(obj, tuple):
|
||||||
|
c = None
|
||||||
|
if len(obj) == 3:
|
||||||
|
c = obj + (1,)
|
||||||
|
elif len(obj) == 4:
|
||||||
|
c = obj
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
return [min(max(float(x), 0), 1) for x in c]
|
||||||
|
if isinstance(obj, Color):
|
||||||
|
return obj.to_tuple()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_shape(obj: CADLike, error: bool = True) -> Optional[CADCoreLike]:
|
def get_shape(obj: CADLike, error: bool = True) -> Optional[CADCoreLike]:
|
||||||
@@ -40,27 +63,49 @@ def get_shape(obj: CADLike, error: bool = True) -> Optional[CADCoreLike]:
|
|||||||
if isinstance(obj, TopoDS_Shape):
|
if isinstance(obj, TopoDS_Shape):
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
# Handle iterables like Build123d ShapeList by extracting all sub-shapes and making a compound
|
||||||
|
if isinstance(obj, list) or isinstance(obj, tuple) or isinstance(obj, set) or isinstance(obj, dict):
|
||||||
|
try:
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
obj_iter = iter(obj.values())
|
||||||
|
else:
|
||||||
|
obj_iter = iter(obj)
|
||||||
|
# print(obj, ' -> ', obj_iter)
|
||||||
|
shapes_raw = [get_shape(sub_obj, error=False) for sub_obj in obj_iter]
|
||||||
|
# Silently drop non-shapes
|
||||||
|
shapes_raw_filtered = [shape for shape in shapes_raw if shape is not None]
|
||||||
|
if len(shapes_raw_filtered) > 0: # Continue if we found at least one shape
|
||||||
|
# Sorting is required to improve hashcode consistency
|
||||||
|
shapes_raw_filtered_sorted = sorted(shapes_raw_filtered, key=lambda x: _hashcode(x))
|
||||||
|
# Build a single compound shape (skip locations/axes here, they can't be in a Compound)
|
||||||
|
shapes_bd = [Compound(shape) for shape in shapes_raw_filtered_sorted if
|
||||||
|
shape is not None and not isinstance(shape, TopLoc_Location)]
|
||||||
|
return get_shape(Compound(shapes_bd), error)
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
raise ValueError(f'Cannot show object of type {type(obj)} (submit issue?)')
|
raise ValueError(f'Cannot show object of type {type(obj)} (submit issue?)')
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def grab_all_cad() -> List[Tuple[str, CADCoreLike]]:
|
def grab_all_cad() -> set[Tuple[str, CADCoreLike]]:
|
||||||
""" Grab all shapes by inspecting the stack """
|
""" Grab all shapes by inspecting the stack """
|
||||||
import inspect
|
import inspect
|
||||||
stack = inspect.stack()
|
stack = inspect.stack()
|
||||||
shapes = []
|
shapes = set()
|
||||||
for frame in stack:
|
for frame in stack:
|
||||||
for key, value in frame.frame.f_locals.items():
|
for key, value in frame.frame.f_locals.items():
|
||||||
shape = get_shape(value, error=False)
|
shape = get_shape(value, error=False)
|
||||||
if shape:
|
if shape and shape not in shapes:
|
||||||
shapes.append((key, shape))
|
shapes.add((key, shape))
|
||||||
return shapes
|
return shapes
|
||||||
|
|
||||||
|
|
||||||
def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = None, height: Optional[float] = None,
|
def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = None, height: Optional[float] = None,
|
||||||
name: Optional[str] = None, save_mime: str = 'image/jpeg') -> Tuple[bytes, str]:
|
name: Optional[str] = None, save_mime: str = 'image/jpeg', power_of_two: bool = True) \
|
||||||
|
-> Tuple[bytes, str]:
|
||||||
"""Convert an image to a GLTF CAD object."""
|
"""Convert an image to a GLTF CAD object."""
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import io
|
import io
|
||||||
@@ -72,16 +117,16 @@ def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = Non
|
|||||||
# Handle arguments
|
# Handle arguments
|
||||||
if name is None:
|
if name is None:
|
||||||
if isinstance(source, str):
|
if isinstance(source, str):
|
||||||
name = os.path.basename(source)
|
name, _ = os.path.splitext(os.path.basename(source))
|
||||||
else:
|
else:
|
||||||
hasher = hashlib.md5()
|
hasher = hashlib.md5()
|
||||||
hasher.update(source)
|
hasher.update(source)
|
||||||
name = 'image_' + hasher.hexdigest()
|
name = 'image_' + hasher.hexdigest()
|
||||||
format: str
|
_format: str
|
||||||
if save_mime == 'image/jpeg':
|
if save_mime == 'image/jpeg':
|
||||||
format = 'JPEG'
|
_format = 'JPEG'
|
||||||
elif save_mime == 'image/png':
|
elif save_mime == 'image/png':
|
||||||
format = 'PNG'
|
_format = 'PNG'
|
||||||
else:
|
else:
|
||||||
raise ValueError(f'Unsupported save MIME type (for GLTF files): {save_mime}')
|
raise ValueError(f'Unsupported save MIME type (for GLTF files): {save_mime}')
|
||||||
|
|
||||||
@@ -90,34 +135,41 @@ def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = Non
|
|||||||
if not isinstance(center_loc, TopLoc_Location):
|
if not isinstance(center_loc, TopLoc_Location):
|
||||||
raise ValueError('Center location not valid')
|
raise ValueError('Center location not valid')
|
||||||
plane = Plane(Location(center_loc))
|
plane = Plane(Location(center_loc))
|
||||||
# Convert coordinates system
|
|
||||||
plane.origin = Vector(plane.origin.X, plane.origin.Z, -plane.origin.Y)
|
|
||||||
plane.z_dir = -plane.y_dir
|
|
||||||
plane.y_dir = plane.z_dir
|
|
||||||
|
|
||||||
def vert(v: Vector) -> Tuple[float, float, float]:
|
|
||||||
return v.X, v.Y, v.Z
|
|
||||||
|
|
||||||
# Load the image to a byte buffer
|
# Load the image to a byte buffer
|
||||||
img = Image.open(source)
|
img = Image.open(source)
|
||||||
img_buf = io.BytesIO()
|
img_buf = io.BytesIO()
|
||||||
img.save(img_buf, format=format)
|
|
||||||
img_buf = img_buf.getvalue()
|
|
||||||
|
|
||||||
# Build the gltf
|
# Use the original dimensions for scaling the model
|
||||||
mgr = GLTFMgr(image=(img_buf, save_mime))
|
|
||||||
if width is None and height is None:
|
if width is None and height is None:
|
||||||
raise ValueError('At least one of width or height must be specified') # Fallback to pixels == mm?
|
raise ValueError('At least one of width or height must be specified') # Fallback to pixels == mm?
|
||||||
elif width is None:
|
elif width is None:
|
||||||
width = img.width / img.height * height
|
width = img.width / img.height * height
|
||||||
elif height is None:
|
elif height is None:
|
||||||
height = height or img.height / img.width * width # Apply default aspect ratio if unspecified
|
height = height or img.height / img.width * width # Apply default aspect ratio if unspecified
|
||||||
|
|
||||||
|
# Resize the image to a power of two if requested (recommended for GLTF)
|
||||||
|
if power_of_two:
|
||||||
|
new_width = 2 ** (img.width - 1).bit_length()
|
||||||
|
new_height = 2 ** (img.height - 1).bit_length()
|
||||||
|
img = img.resize((new_width, new_height))
|
||||||
|
|
||||||
|
# Save the image to a buffer
|
||||||
|
img.save(img_buf, format=_format)
|
||||||
|
img_buf = img_buf.getvalue()
|
||||||
|
|
||||||
|
# Convert coordinates system as a last step (gltf is Y-up instead of Z-up)
|
||||||
|
def vert(v: Vector) -> Vector:
|
||||||
|
return Vector(v.X, v.Z, -v.Y)
|
||||||
|
|
||||||
|
# Build the gltf
|
||||||
|
mgr = GLTFMgr(image=(img_buf, save_mime))
|
||||||
mgr.add_face([
|
mgr.add_face([
|
||||||
vert(plane.origin - plane.x_dir * width / 2 - plane.y_dir * height / 2),
|
|
||||||
vert(plane.origin + plane.x_dir * width / 2 - plane.y_dir * height / 2),
|
|
||||||
vert(plane.origin + plane.x_dir * width / 2 + plane.y_dir * height / 2),
|
|
||||||
vert(plane.origin - plane.x_dir * width / 2 + plane.y_dir * height / 2),
|
vert(plane.origin - plane.x_dir * width / 2 + plane.y_dir * height / 2),
|
||||||
], [
|
vert(plane.origin + plane.x_dir * width / 2 + plane.y_dir * height / 2),
|
||||||
|
vert(plane.origin + plane.x_dir * width / 2 - plane.y_dir * height / 2),
|
||||||
|
vert(plane.origin - plane.x_dir * width / 2 - plane.y_dir * height / 2),
|
||||||
|
], [vert(plane.z_dir)] * 4, [
|
||||||
(0, 2, 1),
|
(0, 2, 1),
|
||||||
(0, 3, 2),
|
(0, 3, 2),
|
||||||
], [
|
], [
|
||||||
@@ -125,7 +177,36 @@ def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = Non
|
|||||||
(1, 0),
|
(1, 0),
|
||||||
(1, 1),
|
(1, 1),
|
||||||
(0, 1),
|
(0, 1),
|
||||||
])
|
], (1, 1, 1, 1))
|
||||||
|
|
||||||
# Return the GLTF binary blob and the suggested name of the image
|
# Return the GLTF binary blob and the suggested name of the image
|
||||||
return b''.join(mgr.gltf.save_to_bytes()), name
|
return b''.join(mgr.build().save_to_bytes()), name
|
||||||
|
|
||||||
|
|
||||||
|
def _hashcode(obj: Union[bytes, CADCoreLike], **extras) -> str:
|
||||||
|
"""Utility to compute the STABLE hash code of a shape"""
|
||||||
|
# NOTE: obj.HashCode(MAX_HASH_CODE) is not stable across different runs of the same program
|
||||||
|
# This is best-effort and not guaranteed to be unique
|
||||||
|
hasher = hashlib.md5(usedforsecurity=False)
|
||||||
|
for k, v in extras.items():
|
||||||
|
hasher.update(str(k).encode())
|
||||||
|
hasher.update(str(v).encode())
|
||||||
|
if isinstance(obj, bytes):
|
||||||
|
hasher.update(obj)
|
||||||
|
elif isinstance(obj, TopLoc_Location):
|
||||||
|
sub_data = io.BytesIO()
|
||||||
|
obj.DumpJson(sub_data)
|
||||||
|
hasher.update(sub_data.getvalue())
|
||||||
|
elif isinstance(obj, TopoDS_Shape):
|
||||||
|
map_of_shapes = TopTools_IndexedMapOfShape()
|
||||||
|
TopExp.MapShapes_s(obj, map_of_shapes)
|
||||||
|
for i in range(1, map_of_shapes.Extent() + 1):
|
||||||
|
sub_shape = map_of_shapes.FindKey(i)
|
||||||
|
sub_data = io.BytesIO()
|
||||||
|
TopoDS_Shape.DumpJson(sub_shape, sub_data)
|
||||||
|
val = sub_data.getvalue()
|
||||||
|
val = re.sub(b'"this": "[^"]*"', b'', val) # Remove memory address
|
||||||
|
hasher.update(val)
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Cannot hash object of type {type(obj)}')
|
||||||
|
return hasher.hexdigest()
|
||||||
|
|||||||
@@ -4,51 +4,107 @@ import numpy as np
|
|||||||
from build123d import Location, Plane, Vector
|
from build123d import Location, Plane, Vector
|
||||||
from pygltflib import *
|
from pygltflib import *
|
||||||
|
|
||||||
_checkerboard_image_bytes = base64.decodebytes(
|
|
||||||
b'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAF0lEQVQI12N49OjR////Gf'
|
def get_version() -> str:
|
||||||
b'/////48WMATwULS8tcyj8AAAAASUVORK5CYII=')
|
try:
|
||||||
|
return importlib.metadata.version("yacv_server")
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
class GLTFMgr:
|
class GLTFMgr:
|
||||||
"""A utility class to build our GLTF2 objects easily and incrementally"""
|
"""A utility class to build our GLTF2 objects easily and incrementally"""
|
||||||
|
|
||||||
def __init__(self, image: Tuple[bytes, str] = (_checkerboard_image_bytes, 'image/png')):
|
gltf: GLTF2
|
||||||
|
|
||||||
|
# Intermediate data to be filled by the add_* methods and merged into the GLTF object
|
||||||
|
# - Face data
|
||||||
|
face_indices: List[int] # 3 indices per triangle
|
||||||
|
face_positions: List[float] # x, y, z
|
||||||
|
face_normals: List[float] # x, y, z
|
||||||
|
face_tex_coords: List[float] # u, v
|
||||||
|
face_colors: List[float] # r, g, b, a
|
||||||
|
image: Optional[Tuple[bytes, str]] # image/png
|
||||||
|
# - Edge data
|
||||||
|
edge_indices: List[int] # 2 indices per edge
|
||||||
|
edge_positions: List[float] # x, y, z
|
||||||
|
edge_colors: List[float] # r, g, b, a
|
||||||
|
# - Vertex data
|
||||||
|
vertex_indices: List[int] # 1 index per vertex
|
||||||
|
vertex_positions: List[float] # x, y, z
|
||||||
|
vertex_colors: List[float] # r, g, b, a
|
||||||
|
|
||||||
|
def __init__(self, image: Optional[Tuple[bytes, str]] = None):
|
||||||
self.gltf = GLTF2(
|
self.gltf = GLTF2(
|
||||||
asset=Asset(generator=f"yacv_server@{importlib.metadata.version('yacv_server')}"),
|
asset=Asset(generator=f"yacv_server@{get_version()}"),
|
||||||
scene=0,
|
scene=0,
|
||||||
scenes=[Scene(nodes=[0])],
|
scenes=[Scene(nodes=[0])],
|
||||||
nodes=[Node(mesh=0)],
|
nodes=[Node(mesh=0)], # TODO: Server-side detection of shallow copies --> nodes
|
||||||
meshes=[Mesh(primitives=[])],
|
meshes=[Mesh(primitives=[
|
||||||
accessors=[],
|
Primitive(indices=-1, attributes=Attributes(), mode=TRIANGLES, material=0,
|
||||||
bufferViews=[BufferView(buffer=0, byteLength=len(image[0]), byteOffset=0)],
|
extras={"face_triangles_end": []}),
|
||||||
buffers=[Buffer(byteLength=len(image[0]))],
|
Primitive(indices=-1, attributes=Attributes(), mode=LINES, material=0,
|
||||||
samplers=[Sampler(magFilter=NEAREST)],
|
extras={"edge_points_end": []}),
|
||||||
textures=[Texture(source=0, sampler=0)],
|
Primitive(indices=-1, attributes=Attributes(), mode=POINTS, material=0),
|
||||||
images=[Image(bufferView=0, mimeType=image[1])],
|
])],
|
||||||
|
materials=[Material(pbrMetallicRoughness=PbrMetallicRoughness(metallicFactor=0.1, roughnessFactor=1.0),
|
||||||
|
alphaCutoff=None)],
|
||||||
)
|
)
|
||||||
self.gltf.set_binary_blob(image[0])
|
self.face_indices = []
|
||||||
|
self.face_positions = []
|
||||||
|
self.face_normals = []
|
||||||
|
self.face_tex_coords = []
|
||||||
|
self.face_colors = []
|
||||||
|
self.image = image
|
||||||
|
self.edge_indices = []
|
||||||
|
self.edge_positions = []
|
||||||
|
self.edge_colors = []
|
||||||
|
self.vertex_indices = []
|
||||||
|
self.vertex_positions = []
|
||||||
|
self.vertex_colors = []
|
||||||
|
|
||||||
def add_face(self, vertices_raw: List[Tuple[float, float, float]], indices_raw: List[Tuple[int, int, int]],
|
@property
|
||||||
tex_coord_raw: List[Tuple[float, float]]):
|
def _faces_primitive(self) -> Primitive:
|
||||||
"""Add a face to the GLTF as a new primitive of the unique mesh"""
|
return [p for p in self.gltf.meshes[0].primitives if p.mode == TRIANGLES][0]
|
||||||
vertices = np.array([[v[0], v[1], v[2]] for v in vertices_raw], dtype=np.float32)
|
|
||||||
indices = np.array([[i[0], i[1], i[2]] for i in indices_raw], dtype=np.uint32)
|
|
||||||
tex_coord = np.array([[t[0], t[1]] for t in tex_coord_raw], dtype=np.float32)
|
|
||||||
self._add_any(vertices, indices, tex_coord, mode=TRIANGLES, material="face")
|
|
||||||
|
|
||||||
def add_edge(self, vertices_raw: List[Tuple[float, float, float]], mat: str = None):
|
@property
|
||||||
"""Add an edge to the GLTF as a new primitive of the unique mesh"""
|
def _edges_primitive(self) -> Primitive:
|
||||||
vertices = np.array([[v[0], v[1], v[2]] for v in vertices_raw], dtype=np.float32)
|
return [p for p in self.gltf.meshes[0].primitives if p.mode == LINES][0]
|
||||||
indices = np.array(list(map(lambda i: [i, i + 1], range(len(vertices) - 1))), dtype=np.uint32)
|
|
||||||
tex_coord = np.array([])
|
|
||||||
self._add_any(vertices, indices, tex_coord, mode=LINE_STRIP, material=mat or "edge")
|
|
||||||
|
|
||||||
def add_vertex(self, vertex: Tuple[float, float, float]):
|
@property
|
||||||
"""Add a vertex to the GLTF as a new primitive of the unique mesh"""
|
def _vertices_primitive(self) -> Primitive:
|
||||||
vertices = np.array([[vertex[0], vertex[1], vertex[2]]])
|
return [p for p in self.gltf.meshes[0].primitives if p.mode == POINTS][0]
|
||||||
indices = np.array([[0]], dtype=np.uint32)
|
|
||||||
tex_coord = np.array([], dtype=np.float32)
|
def add_face(self, vertices_raw: List[Vector], normals: List[Vector], indices_raw: List[Tuple[int, int, int]],
|
||||||
self._add_any(vertices, indices, tex_coord, mode=POINTS, material="vertex")
|
tex_coord_raw: List[Tuple[float, float]], color: Tuple[float, float, float, float]):
|
||||||
|
"""Add a face to the GLTF mesh"""
|
||||||
|
# assert len(vertices_raw) == len(tex_coord_raw), f"Vertices and texture coordinates have different lengths"
|
||||||
|
# assert min([i for t in indices_raw for i in t]) == 0, f"Face indices start at {min(indices_raw)}"
|
||||||
|
# assert max([e for t in indices_raw for e in t]) < len(vertices_raw), f"Indices have non-existing vertices"
|
||||||
|
base_index = len(self.face_positions) // 3 # All the new indices reference the new vertices
|
||||||
|
self.face_indices.extend([base_index + i for t in indices_raw for i in t])
|
||||||
|
self.face_positions.extend([v for t in vertices_raw for v in t])
|
||||||
|
self.face_normals.extend([n for t in normals for n in t])
|
||||||
|
self.face_tex_coords.extend([c for t in tex_coord_raw for c in t])
|
||||||
|
self.face_colors.extend([col for _ in range(len(vertices_raw)) for col in color])
|
||||||
|
self._faces_primitive.extras["face_triangles_end"].append(len(self.face_indices))
|
||||||
|
|
||||||
|
def add_edge(self, vertices_raw: List[Tuple[Tuple[float, float, float], Tuple[float, float, float]]],
|
||||||
|
color: Tuple[float, float, float, float]):
|
||||||
|
"""Add an edge to the GLTF mesh"""
|
||||||
|
vertices_flat = [v for t in vertices_raw for v in t] # Line from 0 to 1, 2 to 3, 4 to 5, etc.
|
||||||
|
base_index = len(self.edge_positions) // 3
|
||||||
|
self.edge_indices.extend([base_index + i for i in range(len(vertices_flat))])
|
||||||
|
self.edge_positions.extend([v for t in vertices_flat for v in t])
|
||||||
|
self.edge_colors.extend([col for _ in range(len(vertices_flat)) for col in color])
|
||||||
|
self._edges_primitive.extras["edge_points_end"].append(len(self.edge_indices))
|
||||||
|
|
||||||
|
def add_vertex(self, vertex: Tuple[float, float, float], color: Tuple[float, float, float, float]):
|
||||||
|
"""Add a vertex to the GLTF mesh"""
|
||||||
|
base_index = len(self.vertex_positions) // 3
|
||||||
|
self.vertex_indices.append(base_index)
|
||||||
|
self.vertex_positions.extend(vertex)
|
||||||
|
self.vertex_colors.extend(color)
|
||||||
|
|
||||||
def add_location(self, loc: Location):
|
def add_location(self, loc: Location):
|
||||||
"""Add a location to the GLTF as a new primitive of the unique mesh"""
|
"""Add a location to the GLTF as a new primitive of the unique mesh"""
|
||||||
@@ -58,121 +114,96 @@ class GLTFMgr:
|
|||||||
return v.X, v.Y, v.Z
|
return v.X, v.Y, v.Z
|
||||||
|
|
||||||
# Add 1 origin vertex and 3 edges with custom colors to identify the X, Y and Z axis
|
# Add 1 origin vertex and 3 edges with custom colors to identify the X, Y and Z axis
|
||||||
self.add_vertex(vert(pl.origin))
|
# The colors are hardcoded. You can add vertices and edges manually to change them.
|
||||||
self.add_edge([vert(pl.origin), vert(pl.origin + pl.x_dir)], mat="locX")
|
self.add_vertex(vert(pl.origin), color=(0.1, 0.1, 0.1, 1.0))
|
||||||
self.add_edge([vert(pl.origin), vert(pl.origin + pl.y_dir)], mat="locY")
|
self.add_edge([(vert(pl.origin), vert(pl.origin + pl.x_dir))], color=(0.97, 0.24, 0.24, 1.0))
|
||||||
self.add_edge([vert(pl.origin), vert(pl.origin + pl.z_dir)], mat="locZ")
|
self.add_edge([(vert(pl.origin), vert(pl.origin + pl.y_dir))], color=(0.42, 0.8, 0.15, 1.0))
|
||||||
|
self.add_edge([(vert(pl.origin), vert(pl.origin + pl.z_dir))], color=(0.09, 0.55, 0.94, 1.0))
|
||||||
|
|
||||||
def add_material(self, kind: str) -> int:
|
def build(self) -> GLTF2:
|
||||||
"""It is important to use a different material for each primitive to be able to change them at runtime"""
|
"""Merge the intermediate data into the GLTF object and return it"""
|
||||||
new_material: Material
|
buffers_list: List[Tuple[Accessor, BufferView, bytes]] = []
|
||||||
if kind == "face":
|
|
||||||
new_material = Material(name="face", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
if len(self.face_indices) > 0:
|
||||||
baseColorTexture=TextureInfo(index=0), baseColorFactor=[1, 1, 0.5, 1]), doubleSided=True)
|
self._faces_primitive.indices = len(buffers_list)
|
||||||
elif kind == "edge":
|
buffers_list.append(_gen_buffer_metadata(self.face_indices, 1))
|
||||||
new_material = Material(name="edge", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
self._faces_primitive.attributes.POSITION = len(buffers_list)
|
||||||
baseColorFactor=[0, 0, 0.5, 1]))
|
buffers_list.append(_gen_buffer_metadata(self.face_positions, 3))
|
||||||
elif kind == "vertex":
|
self._faces_primitive.attributes.NORMAL = len(buffers_list)
|
||||||
new_material = Material(name="vertex", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
buffers_list.append(_gen_buffer_metadata(self.face_normals, 3))
|
||||||
baseColorFactor=[0, 0.3, 0.3, 1]))
|
self._faces_primitive.attributes.TEXCOORD_0 = len(buffers_list)
|
||||||
elif kind == "locX":
|
buffers_list.append(_gen_buffer_metadata(self.face_tex_coords, 2))
|
||||||
new_material = Material(name="locX", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
self._faces_primitive.attributes.COLOR_0 = len(buffers_list)
|
||||||
baseColorFactor=[0.97, 0.24, 0.24, 1]))
|
buffers_list.append(_gen_buffer_metadata(self.face_colors, 4))
|
||||||
elif kind == "locY":
|
|
||||||
new_material = Material(name="locY", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
|
||||||
baseColorFactor=[0.42, 0.8, 0.15, 1]))
|
|
||||||
elif kind == "locZ":
|
|
||||||
new_material = Material(name="locZ", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
|
||||||
baseColorFactor=[0.09, 0.55, 0.94, 1]))
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown material kind {kind}")
|
self.image = None # Unused image
|
||||||
self.gltf.materials.append(new_material)
|
self.gltf.meshes[0].primitives = list( # Remove unused faces primitive
|
||||||
return len(self.gltf.materials) - 1
|
filter(lambda p: p.mode != TRIANGLES, self.gltf.meshes[0].primitives))
|
||||||
|
|
||||||
def _add_any(self, vertices: np.ndarray, indices: np.ndarray, tex_coord: np.ndarray, mode: int = TRIANGLES,
|
edges_and_vertices_mat = 0
|
||||||
material: str = "face"):
|
if self.image is not None and (len(self.edge_indices) > 0 or len(self.vertex_indices) > 0):
|
||||||
assert vertices.ndim == 2
|
# Create a material without texture for edges and vertices
|
||||||
assert vertices.shape[1] == 3
|
edges_and_vertices_mat = len(self.gltf.materials)
|
||||||
vertices = vertices.astype(np.float32)
|
new_mat = copy.deepcopy(self.gltf.materials[0])
|
||||||
vertices_blob = vertices.tobytes()
|
new_mat.pbrMetallicRoughness.baseColorTexture = None
|
||||||
|
self.gltf.materials.append(new_mat)
|
||||||
|
|
||||||
assert indices.ndim == 2
|
# Treat edges and vertices the same way
|
||||||
assert indices.shape[1] == 3 and mode == TRIANGLES or indices.shape[1] == 2 and mode == LINE_STRIP or \
|
for (indices, positions, colors, primitive, kind) in [
|
||||||
indices.shape[1] == 1 and mode == POINTS
|
(self.edge_indices, self.edge_positions, self.edge_colors, self._edges_primitive, LINES),
|
||||||
indices = indices.astype(np.uint32)
|
(self.vertex_indices, self.vertex_positions, self.vertex_colors, self._vertices_primitive, POINTS)
|
||||||
indices_blob = indices.flatten().tobytes()
|
]:
|
||||||
|
if len(indices) > 0:
|
||||||
|
primitive.material = edges_and_vertices_mat
|
||||||
|
primitive.indices = len(buffers_list)
|
||||||
|
buffers_list.append(_gen_buffer_metadata(indices, 1))
|
||||||
|
primitive.attributes.POSITION = len(buffers_list)
|
||||||
|
buffers_list.append(_gen_buffer_metadata(positions, 3))
|
||||||
|
primitive.attributes.COLOR_0 = len(buffers_list)
|
||||||
|
buffers_list.append(_gen_buffer_metadata(colors, 4))
|
||||||
|
else:
|
||||||
|
self.gltf.meshes[0].primitives = list( # Remove unused edges primitive
|
||||||
|
filter(lambda p: p.mode != kind, self.gltf.meshes[0].primitives))
|
||||||
|
|
||||||
# Check that all vertices are referenced by the indices
|
if self.image is not None: # Add texture last as it creates a fake accessor that is not added!
|
||||||
# This can happen on broken faces like on some fonts
|
self.gltf.images = [Image(bufferView=len(buffers_list), mimeType=self.image[1])]
|
||||||
# assert indices.max() == len(vertices) - 1, f"{indices.max()} != {len(vertices) - 1}"
|
self.gltf.textures = [Texture(source=0, sampler=0)]
|
||||||
# assert indices.min() == 0, f"min({indices}) != 0"
|
self.gltf.samplers = [Sampler(magFilter=NEAREST)]
|
||||||
# assert np.unique(indices.flatten()).size == len(vertices)
|
# noinspection PyPep8Naming
|
||||||
|
self.gltf.materials[0].pbrMetallicRoughness.baseColorTexture = TextureInfo(index=0)
|
||||||
|
buffers_list.append((Accessor(), BufferView(), self.image[0]))
|
||||||
|
|
||||||
assert len(tex_coord) == 0 or tex_coord.ndim == 2
|
# Once all the data is ready, we can concatenate the buffers updating the accessors and views
|
||||||
assert len(tex_coord) == 0 or tex_coord.shape[1] == 2
|
prev_binary_blob = self.gltf.binary_blob() or b''
|
||||||
tex_coord = tex_coord.astype(np.float32)
|
|
||||||
tex_coord_blob = tex_coord.tobytes()
|
|
||||||
|
|
||||||
accessor_base = len(self.gltf.accessors)
|
|
||||||
self.gltf.meshes[0].primitives.append(
|
|
||||||
Primitive(
|
|
||||||
attributes=Attributes(POSITION=accessor_base + 1, TEXCOORD_0=accessor_base + 2)
|
|
||||||
if len(tex_coord) > 0 else Attributes(POSITION=accessor_base + 1),
|
|
||||||
indices=accessor_base,
|
|
||||||
mode=mode,
|
|
||||||
material=self.add_material(material),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
buffer_view_base = len(self.gltf.bufferViews)
|
|
||||||
self.gltf.accessors.extend([it for it in [
|
|
||||||
Accessor(
|
|
||||||
bufferView=buffer_view_base,
|
|
||||||
componentType=UNSIGNED_INT,
|
|
||||||
count=indices.size,
|
|
||||||
type=SCALAR,
|
|
||||||
max=[int(indices.max())],
|
|
||||||
min=[int(indices.min())],
|
|
||||||
),
|
|
||||||
Accessor(
|
|
||||||
bufferView=buffer_view_base + 1,
|
|
||||||
componentType=FLOAT,
|
|
||||||
count=len(vertices),
|
|
||||||
type=VEC3,
|
|
||||||
max=vertices.max(axis=0).tolist(),
|
|
||||||
min=vertices.min(axis=0).tolist(),
|
|
||||||
),
|
|
||||||
Accessor(
|
|
||||||
bufferView=buffer_view_base + 2,
|
|
||||||
componentType=FLOAT,
|
|
||||||
count=len(tex_coord),
|
|
||||||
type=VEC2,
|
|
||||||
max=tex_coord.max(axis=0).tolist(),
|
|
||||||
min=tex_coord.min(axis=0).tolist(),
|
|
||||||
) if len(tex_coord) > 0 else None
|
|
||||||
] if it is not None])
|
|
||||||
|
|
||||||
prev_binary_blob = self.gltf.binary_blob()
|
|
||||||
byte_offset_base = len(prev_binary_blob)
|
byte_offset_base = len(prev_binary_blob)
|
||||||
self.gltf.bufferViews.extend([bv for bv in [
|
for accessor, bufferView, blob in buffers_list:
|
||||||
BufferView(
|
|
||||||
buffer=0,
|
|
||||||
byteOffset=byte_offset_base,
|
|
||||||
byteLength=len(indices_blob),
|
|
||||||
target=ELEMENT_ARRAY_BUFFER,
|
|
||||||
),
|
|
||||||
BufferView(
|
|
||||||
buffer=0,
|
|
||||||
byteOffset=byte_offset_base + len(indices_blob),
|
|
||||||
byteLength=len(vertices_blob),
|
|
||||||
target=ARRAY_BUFFER,
|
|
||||||
),
|
|
||||||
BufferView(
|
|
||||||
buffer=0,
|
|
||||||
byteOffset=byte_offset_base + len(indices_blob) + len(vertices_blob),
|
|
||||||
byteLength=len(tex_coord_blob),
|
|
||||||
target=ARRAY_BUFFER,
|
|
||||||
)
|
|
||||||
] if bv.byteLength > 0])
|
|
||||||
|
|
||||||
self.gltf.set_binary_blob(prev_binary_blob + indices_blob + vertices_blob + tex_coord_blob)
|
if accessor.componentType is not None: # Remove accessor of texture
|
||||||
|
buffer_view_base = len(self.gltf.bufferViews)
|
||||||
|
accessor.bufferView = buffer_view_base
|
||||||
|
self.gltf.accessors.append(accessor)
|
||||||
|
|
||||||
|
bufferView.buffer = 0
|
||||||
|
bufferView.byteOffset = byte_offset_base
|
||||||
|
bufferView.byteLength = len(blob)
|
||||||
|
self.gltf.bufferViews.append(bufferView)
|
||||||
|
|
||||||
|
byte_offset_base += len(blob)
|
||||||
|
prev_binary_blob += blob
|
||||||
|
|
||||||
|
self.gltf.buffers.append(Buffer(byteLength=byte_offset_base))
|
||||||
|
self.gltf.set_binary_blob(prev_binary_blob)
|
||||||
|
|
||||||
|
return self.gltf
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_buffer_metadata(data: List[any], chunk: int) -> Tuple[Accessor, BufferView, bytes]:
|
||||||
|
return Accessor(
|
||||||
|
componentType={1: UNSIGNED_INT, 2: FLOAT, 3: FLOAT, 4: FLOAT}[chunk],
|
||||||
|
count=len(data) // chunk,
|
||||||
|
type={1: SCALAR, 2: VEC2, 3: VEC3, 4: VEC4}[chunk],
|
||||||
|
max=[max(data[i::chunk]) for i in range(chunk)],
|
||||||
|
min=[min(data[i::chunk]) for i in range(chunk)],
|
||||||
|
), BufferView(
|
||||||
|
target={1: ELEMENT_ARRAY_BUFFER, 2: ARRAY_BUFFER, 3: ARRAY_BUFFER, 4: ARRAY_BUFFER}[chunk],
|
||||||
|
), np.array(data, dtype={1: np.uint32, 2: np.float32, 3: np.float32, 4: np.float32}[chunk]).tobytes()
|
||||||
|
|||||||
@@ -6,29 +6,41 @@ from build123d import *
|
|||||||
ASSETS_DIR = os.getenv('ASSETS_DIR', os.path.join(os.path.dirname(__file__), '..', 'assets'))
|
ASSETS_DIR = os.getenv('ASSETS_DIR', os.path.join(os.path.dirname(__file__), '..', 'assets'))
|
||||||
|
|
||||||
|
|
||||||
def build_logo(text: bool = True) -> Dict[str, Union[Part, Location, str]]:
|
def build_logo(text: bool = True) -> Dict[str, Union[Compound, Location, str]]:
|
||||||
"""Builds the CAD part of the logo"""
|
"""Builds the CAD part of the logo"""
|
||||||
with BuildPart(Plane.XY.offset(50)) as logo_obj:
|
with BuildPart(Plane.XY.offset(50)) as logo_obj:
|
||||||
Box(22, 40, 30)
|
Box(22, 40, 30)
|
||||||
fillet(edges().filter_by(Axis.Y).group_by(Axis.Z)[-1], 10)
|
fillet(edges().filter_by(Axis.Y).group_by(Axis.Z)[-1], 10)
|
||||||
offset(solid(), 2, openings=faces().group_by(Axis.Z)[0] + faces().filter_by(Plane.XZ))
|
offset(solid(), 2, openings=faces().group_by(Axis.Z)[0] + faces().filter_by(Plane.XZ))
|
||||||
if text:
|
if text:
|
||||||
text_at_plane = Plane.YZ
|
with BuildSketch(Plane.YZ.move(Pos(faces().group_by(Axis.X)[-1].face().center()))):
|
||||||
text_at_plane.origin = faces().group_by(Axis.X)[-1].face().center()
|
Text('Yet Another\nCAD Viewer', 6, font_path='/usr/share/fonts/TTF/Hack-Regular.ttf')
|
||||||
with BuildSketch(text_at_plane.location):
|
|
||||||
Text('Yet Another\nCAD Viewer', 7, font_path='/usr/share/fonts/TTF/OpenSans-Regular.ttf')
|
|
||||||
extrude(amount=1)
|
extrude(amount=1)
|
||||||
|
logo_face_curved_front = faces().filter_by(GeomType.CYLINDER).group_by(Axis.X)[-1].face()
|
||||||
|
|
||||||
logo_img_location = logo_obj.faces().group_by(Axis.X)[0].face().center_location # Avoid overlapping:
|
# Highlight text edges with a custom color
|
||||||
logo_img_location.position = Vector(logo_img_location.position.X - 4e-2, logo_img_location.position.Y,
|
to_highlight = logo_obj.edges().group_by(Axis.X)[-1]
|
||||||
logo_img_location.position.Z)
|
logo_obj_hl = Compound(to_highlight).translate((1e-3, 0, 0)) # To avoid z-fighting
|
||||||
|
logo_obj_hl.color = (0, 0.3, 0.3, 1)
|
||||||
|
|
||||||
|
# Highlight face with custom texture
|
||||||
|
logo_face_curved_front.yacv_texture = \
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAF0lEQVQI12N49OjR////Gf' \
|
||||||
|
'/////48WMATwULS8tcyj8AAAAASUVORK5CYII='
|
||||||
|
logo_face_curved_front.color = (0, 0.5, 0.0, 1)
|
||||||
|
logo_obj = Compound(logo_obj.faces() - ShapeList([logo_face_curved_front])) # Remove face from the main object
|
||||||
|
|
||||||
|
# Add a logo image to the CAD part
|
||||||
|
logo_img_location = logo_obj.faces().group_by(Axis.X)[0].face().center_location
|
||||||
|
logo_img_location *= Location((0, 0, 4e-2), (0, 0, 90)) # Avoid overlapping and adjust placement
|
||||||
logo_img_path = os.path.join(ASSETS_DIR, 'img.jpg')
|
logo_img_path = os.path.join(ASSETS_DIR, 'img.jpg')
|
||||||
img_bytes, img_name = prepare_image(logo_img_path, logo_img_location, height=18)
|
img_glb_bytes, img_name = image_to_gltf(logo_img_path, logo_img_location, height=18)
|
||||||
|
|
||||||
|
# Add an animated fox to the CAD part
|
||||||
fox_glb_bytes = open(os.path.join(ASSETS_DIR, 'fox.glb'), 'rb').read()
|
fox_glb_bytes = open(os.path.join(ASSETS_DIR, 'fox.glb'), 'rb').read()
|
||||||
|
|
||||||
return {'fox': fox_glb_bytes, 'logo': logo_obj, 'location': logo_img_location, img_name: img_bytes}
|
return {'fox': fox_glb_bytes, 'logo': logo_obj, 'logo_hl': logo_obj_hl, 'logo_hl_tex': logo_face_curved_front,
|
||||||
|
'location': logo_img_location, img_name: img_glb_bytes}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -43,7 +55,7 @@ if __name__ == "__main__":
|
|||||||
# If this is not set, the server will auto-start on import and show_* calls will provide live updates
|
# If this is not set, the server will auto-start on import and show_* calls will provide live updates
|
||||||
os.environ['YACV_DISABLE_SERVER'] = 'True'
|
os.environ['YACV_DISABLE_SERVER'] = 'True'
|
||||||
|
|
||||||
from yacv_server import export_all, remove, prepare_image, show
|
from yacv_server import export_all, show, image_to_gltf
|
||||||
|
|
||||||
# Build the CAD part of the logo
|
# Build the CAD part of the logo
|
||||||
logo = build_logo()
|
logo = build_logo()
|
||||||
@@ -52,7 +64,8 @@ if __name__ == "__main__":
|
|||||||
show(*[obj for obj in logo.values()], names=[name for name in logo.keys()])
|
show(*[obj for obj in logo.values()], names=[name for name in logo.keys()])
|
||||||
|
|
||||||
if testing_server:
|
if testing_server:
|
||||||
remove('location') # Test removing a part
|
# remove('location') # Test removing a part
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
# Save the complete logo to multiple GLB files
|
# Save the complete logo to multiple GLB files
|
||||||
export_all(os.path.join(ASSETS_DIR, 'logo_build'))
|
export_all(os.path.join(ASSETS_DIR, 'logo_build'))
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
from os import system
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Just a reminder that a hot-reloading server can be started with the following command:
|
|
||||||
# Need to disable auto-start to avoid conflicts with the hot-reloading server
|
|
||||||
system('YACV_DISABLE_SERVER=true aiohttp-devtools runserver __init__.py --port 32323 --app-factory _get_app')
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import threading
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus, HTTPMethod
|
||||||
from http.server import SimpleHTTPRequestHandler
|
from http.server import SimpleHTTPRequestHandler
|
||||||
|
|
||||||
from iterators import TimeoutIterator
|
from yacv_server.mylogger import logger
|
||||||
|
|
||||||
from mylogger import logger
|
|
||||||
|
|
||||||
# Find the frontend folder (optional, but recommended)
|
# Find the frontend folder (optional, but recommended)
|
||||||
FILE_DIR = os.path.dirname(__file__)
|
FILE_DIR = os.path.dirname(__file__)
|
||||||
@@ -26,13 +23,9 @@ OBJECTS_API_PATH = '/api/object' # /{name}
|
|||||||
|
|
||||||
class HTTPHandler(SimpleHTTPRequestHandler):
|
class HTTPHandler(SimpleHTTPRequestHandler):
|
||||||
yacv: 'yacv.YACV'
|
yacv: 'yacv.YACV'
|
||||||
frontend_lock: threading.Lock # To avoid exiting too early while frontend makes requests
|
|
||||||
at_least_one_client: threading.Event
|
|
||||||
|
|
||||||
def __init__(self, *args, yacv: 'yacv.YACV', **kwargs):
|
def __init__(self, *args, yacv: 'yacv.YACV', **kwargs):
|
||||||
self.yacv = yacv
|
self.yacv = yacv
|
||||||
self.frontend_lock = threading.Lock()
|
|
||||||
self.at_least_one_client = threading.Event()
|
|
||||||
super().__init__(*args, **kwargs, directory=FRONTEND_BASE_PATH)
|
super().__init__(*args, **kwargs, directory=FRONTEND_BASE_PATH)
|
||||||
|
|
||||||
def log_message(self, fmt, *args):
|
def log_message(self, fmt, *args):
|
||||||
@@ -49,8 +42,8 @@ class HTTPHandler(SimpleHTTPRequestHandler):
|
|||||||
path = super().translate_path(path)
|
path = super().translate_path(path)
|
||||||
path = os.path.realpath(path) # Avoid symlink hacks
|
path = os.path.realpath(path) # Avoid symlink hacks
|
||||||
if self.directory: # Ensure proper subdirectory
|
if self.directory: # Ensure proper subdirectory
|
||||||
base = os.path.abspath(self.directory)
|
base = os.path.realpath(self.directory)
|
||||||
if not os.path.abspath(path).startswith(base):
|
if not path.startswith(base):
|
||||||
self.send_error(HTTPStatus.FORBIDDEN, "Path is not in the frontend directory")
|
self.send_error(HTTPStatus.FORBIDDEN, "Path is not in the frontend directory")
|
||||||
return ''
|
return ''
|
||||||
return path
|
return path
|
||||||
@@ -77,69 +70,72 @@ class HTTPHandler(SimpleHTTPRequestHandler):
|
|||||||
|
|
||||||
def _api_updates(self):
|
def _api_updates(self):
|
||||||
"""Handles a publish-only websocket connection that send show_object events along with their hashes and URLs"""
|
"""Handles a publish-only websocket connection that send show_object events along with their hashes and URLs"""
|
||||||
|
|
||||||
self.send_response(HTTPStatus.OK)
|
self.send_response(HTTPStatus.OK)
|
||||||
self.send_header("Content-Type", "text/event-stream")
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
self.send_header("Cache-Control", "no-cache")
|
self.send_header("Cache-Control", "no-cache")
|
||||||
# Chunked transfer encoding!
|
if not self.requestline.startswith(HTTPMethod.HEAD):
|
||||||
self.send_header("Transfer-Encoding", "chunked")
|
# Chunked transfer encoding!
|
||||||
|
self.send_header("Transfer-Encoding", "chunked")
|
||||||
|
else:
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.at_least_one_client.set()
|
|
||||||
logger.debug('Updates client connected')
|
|
||||||
|
|
||||||
def write_chunk(_chunk_data: str):
|
if self.requestline.startswith(HTTPMethod.HEAD):
|
||||||
self.wfile.write(hex(len(_chunk_data))[2:].encode('utf-8'))
|
return
|
||||||
self.wfile.write(b'\r\n')
|
|
||||||
self.wfile.write(_chunk_data.encode('utf-8'))
|
|
||||||
self.wfile.write(b'\r\n')
|
|
||||||
self.wfile.flush()
|
|
||||||
|
|
||||||
write_chunk('retry: 100\n\n')
|
# Keep a shared read lock to know if any frontend is still working before shutting down
|
||||||
|
with self.yacv.frontend_lock.r_locked():
|
||||||
|
|
||||||
# Send buffered events first, while keeping a lock
|
# Avoid accepting new connections while shutting down
|
||||||
with self.frontend_lock:
|
if self.yacv.shutting_down.is_set() and self.yacv.at_least_one_client.is_set():
|
||||||
for data in self.yacv.show_events.buffer():
|
self.send_error(HTTPStatus.SERVICE_UNAVAILABLE, 'Server is shutting down')
|
||||||
logger.debug('Sending info about %s: %s', data.name, data)
|
return
|
||||||
# noinspection PyUnresolvedReferences
|
self.yacv.at_least_one_client.set()
|
||||||
to_send = data.to_json()
|
logger.debug('Updates client connected')
|
||||||
write_chunk(f'data: {to_send}\n\n')
|
|
||||||
|
|
||||||
# Send future events over the same connection
|
def write_chunk(_chunk_data: str):
|
||||||
# Also send keep-alive to know if the client is still connected
|
self.wfile.write(hex(len(_chunk_data))[2:].encode('utf-8'))
|
||||||
subscription = self.yacv.show_events.subscribe(include_buffered=False)
|
self.wfile.write(b'\r\n')
|
||||||
it = TimeoutIterator(subscription, sentinel=None, reset_on_next=True, timeout=5.0) # Keep-alive interval
|
self.wfile.write(_chunk_data.encode('utf-8'))
|
||||||
try:
|
self.wfile.write(b'\r\n')
|
||||||
for data in it:
|
self.wfile.flush()
|
||||||
if data is None:
|
|
||||||
write_chunk(':keep-alive\n\n')
|
write_chunk('retry: 100\n\n')
|
||||||
else:
|
|
||||||
logger.debug('Sending info about %s: %s', data.name, data)
|
subscription = self.yacv.show_events.subscribe(yield_timeout=1.0) # Keep-alive interval
|
||||||
# noinspection PyUnresolvedReferences
|
|
||||||
to_send = data.to_json()
|
|
||||||
write_chunk(f'data: {to_send}\n\n')
|
|
||||||
except BrokenPipeError: # Client disconnected normally
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
logger.debug('Updates client disconnected')
|
|
||||||
try:
|
try:
|
||||||
it.interrupt()
|
for data in subscription:
|
||||||
next(it) # Make sure the iterator is interrupted before trying to close the subscription
|
if data is None:
|
||||||
|
write_chunk(':keep-alive\n\n')
|
||||||
|
else:
|
||||||
|
logger.debug('Sending info about %s: %s', data.name, data)
|
||||||
|
# noinspection PyUnresolvedReferences
|
||||||
|
to_send = data.to_json()
|
||||||
|
write_chunk(f'data: {to_send}\n\n')
|
||||||
|
except (BrokenPipeError, ConnectionResetError): # Client disconnected normally
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
subscription.close()
|
subscription.close()
|
||||||
except BaseException as e:
|
|
||||||
logger.debug('Ignoring error while closing subscription: %s', e)
|
logger.debug('Updates client disconnected')
|
||||||
|
|
||||||
def _api_object(self, obj_name: str):
|
def _api_object(self, obj_name: str):
|
||||||
"""Returns the object file with the matching name, building it if necessary."""
|
"""Returns the object file with the matching name, building it if necessary."""
|
||||||
with self.frontend_lock:
|
# Export the object (or fail if not found)
|
||||||
# Export the object (or fail if not found)
|
_export = self.yacv.export(obj_name)
|
||||||
exported_glb = self.yacv.export(obj_name)
|
if _export is None:
|
||||||
if exported_glb is None:
|
self.send_error(HTTPStatus.NOT_FOUND, f'Object {obj_name} not found')
|
||||||
self.send_error(HTTPStatus.NOT_FOUND, f'Object {obj_name} not found')
|
return io.BytesIO()
|
||||||
return io.BytesIO()
|
|
||||||
|
|
||||||
# Wrap the GLB in a response and return it
|
exported_glb, _hash = _export
|
||||||
self.send_response(HTTPStatus.OK)
|
|
||||||
self.send_header('Content-Type', 'model/gltf-binary')
|
# Wrap the GLB in a response and return it
|
||||||
self.send_header('Content-Length', str(len(exported_glb)))
|
self.send_response(HTTPStatus.OK)
|
||||||
self.send_header('Content-Disposition', f'attachment; filename="{obj_name}.glb"')
|
self.send_header('Content-Type', 'model/gltf-binary')
|
||||||
self.end_headers()
|
self.send_header('Content-Length', str(len(exported_glb)))
|
||||||
self.wfile.write(exported_glb)
|
self.send_header('Content-Disposition', f'attachment; filename="{obj_name}.glb"')
|
||||||
|
self.send_header('E-Tag', f'"{_hash}"')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(exported_glb)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import threading
|
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
from typing import List, TypeVar, \
|
from typing import List, TypeVar, Generic, Generator
|
||||||
Generic, Generator
|
|
||||||
|
|
||||||
from yacv_server.mylogger import logger
|
from yacv_server.mylogger import logger
|
||||||
|
|
||||||
T = TypeVar('T')
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
_end_of_queue = object()
|
||||||
|
|
||||||
|
|
||||||
class BufferedPubSub(Generic[T]):
|
class BufferedPubSub(Generic[T]):
|
||||||
"""A simple implementation of publish-subscribe pattern using threading and buffering all previous events"""
|
"""A simple implementation of publish-subscribe pattern using threading and buffering all previous events"""
|
||||||
@@ -45,7 +45,7 @@ class BufferedPubSub(Generic[T]):
|
|||||||
for event in self._buffer:
|
for event in self._buffer:
|
||||||
q.put(event)
|
q.put(event)
|
||||||
if not include_future:
|
if not include_future:
|
||||||
q.put(None)
|
q.put(_end_of_queue)
|
||||||
return q
|
return q
|
||||||
|
|
||||||
def _unsubscribe(self, q: queue.Queue[T]):
|
def _unsubscribe(self, q: queue.Queue[T]):
|
||||||
@@ -54,14 +54,18 @@ class BufferedPubSub(Generic[T]):
|
|||||||
self._subscribers.remove(q)
|
self._subscribers.remove(q)
|
||||||
logger.debug(f"Unsubscribed from %s (%d subscribers)", self, len(self._subscribers))
|
logger.debug(f"Unsubscribed from %s (%d subscribers)", self, len(self._subscribers))
|
||||||
|
|
||||||
def subscribe(self, include_buffered: bool = True, include_future: bool = True) -> Generator[T, None, None]:
|
def subscribe(self, include_buffered: bool = True, include_future: bool = True,
|
||||||
"""Subscribes to events as an generator that yields events and automatically unsubscribes"""
|
yield_timeout: float | None = 0.0) -> Generator[T, None, None]:
|
||||||
|
"""Subscribes to events as a generator that yields events and automatically unsubscribes"""
|
||||||
q = self._subscribe(include_buffered, include_future)
|
q = self._subscribe(include_buffered, include_future)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
v = q.get()
|
try:
|
||||||
|
v = q.get(timeout=yield_timeout)
|
||||||
|
except queue.Empty:
|
||||||
|
v = None
|
||||||
# include_future is incompatible with None values as they are used to signal the end of the stream
|
# include_future is incompatible with None values as they are used to signal the end of the stream
|
||||||
if v is None and not include_future:
|
if v is _end_of_queue:
|
||||||
break
|
break
|
||||||
yield v
|
yield v
|
||||||
finally: # When aclose() is called
|
finally: # When aclose() is called
|
||||||
@@ -80,4 +84,4 @@ class BufferedPubSub(Generic[T]):
|
|||||||
def clear(self):
|
def clear(self):
|
||||||
"""Clears the buffer"""
|
"""Clears the buffer"""
|
||||||
with self._buffer_lock:
|
with self._buffer_lock:
|
||||||
self._buffer.clear()
|
self._buffer.clear()
|
||||||
|
|||||||
0
yacv_server/py.typed
Normal file
0
yacv_server/py.typed
Normal file
96
yacv_server/rwlock.py
Normal file
96
yacv_server/rwlock.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
""" rwlock.py
|
||||||
|
|
||||||
|
A class to implement read-write locks on top of the standard threading
|
||||||
|
library.
|
||||||
|
|
||||||
|
This is implemented with two mutexes (threading.Lock instances) as per this
|
||||||
|
wikipedia pseudocode:
|
||||||
|
|
||||||
|
https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock#Using_two_mutexes
|
||||||
|
|
||||||
|
Code written by Tyler Neylon at Unbox Research.
|
||||||
|
|
||||||
|
This file is public domain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# _______________________________________________________________________
|
||||||
|
# Imports
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
|
||||||
|
# _______________________________________________________________________
|
||||||
|
# Class
|
||||||
|
|
||||||
|
class RWLock(object):
|
||||||
|
""" RWLock class; this is meant to allow an object to be read from by
|
||||||
|
multiple threads, but only written to by a single thread at a time. See:
|
||||||
|
https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
from rwlock import RWLock
|
||||||
|
|
||||||
|
my_obj_rwlock = RWLock()
|
||||||
|
|
||||||
|
# When reading from my_obj:
|
||||||
|
with my_obj_rwlock.r_locked():
|
||||||
|
do_read_only_things_with(my_obj)
|
||||||
|
|
||||||
|
# When writing to my_obj:
|
||||||
|
with my_obj_rwlock.w_locked():
|
||||||
|
mutate(my_obj)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
|
||||||
|
self.w_lock = Lock()
|
||||||
|
self.num_r_lock = Lock()
|
||||||
|
self.num_r = 0
|
||||||
|
|
||||||
|
# ___________________________________________________________________
|
||||||
|
# Reading methods.
|
||||||
|
|
||||||
|
def r_acquire(self, *args, **kwargs):
|
||||||
|
self.num_r_lock.acquire(*args, **kwargs)
|
||||||
|
self.num_r += 1
|
||||||
|
if self.num_r == 1:
|
||||||
|
self.w_lock.acquire(*args, **kwargs)
|
||||||
|
self.num_r_lock.release()
|
||||||
|
|
||||||
|
def r_release(self, *args, **kwargs):
|
||||||
|
assert self.num_r > 0
|
||||||
|
self.num_r_lock.acquire(*args, **kwargs)
|
||||||
|
self.num_r -= 1
|
||||||
|
if self.num_r == 0:
|
||||||
|
self.w_lock.release()
|
||||||
|
self.num_r_lock.release()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def r_locked(self, *args, **kwargs):
|
||||||
|
""" This method is designed to be used via the `with` statement. """
|
||||||
|
try:
|
||||||
|
self.r_acquire(*args, **kwargs)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self.r_release()
|
||||||
|
|
||||||
|
# ___________________________________________________________________
|
||||||
|
# Writing methods.
|
||||||
|
|
||||||
|
def w_acquire(self, *args, **kwargs):
|
||||||
|
self.w_lock.acquire(*args, **kwargs)
|
||||||
|
|
||||||
|
def w_release(self):
|
||||||
|
self.w_lock.release()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def w_locked(self, *args, **kwargs):
|
||||||
|
""" This method is designed to be used via the `with` statement. """
|
||||||
|
try:
|
||||||
|
self.w_acquire(*args, **kwargs)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self.w_release()
|
||||||
@@ -1,76 +1,91 @@
|
|||||||
import hashlib
|
from typing import List, Dict, Tuple, Optional
|
||||||
import io
|
|
||||||
import re
|
|
||||||
from typing import List, Dict, Tuple, Union
|
|
||||||
|
|
||||||
from OCP.BRep import BRep_Tool
|
from OCP.BRep import BRep_Tool
|
||||||
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
||||||
from OCP.GCPnts import GCPnts_TangentialDeflection
|
from OCP.GCPnts import GCPnts_TangentialDeflection
|
||||||
from OCP.TopExp import TopExp
|
from OCP.BRepLib import BRepLib_ToolTriangulatedShape
|
||||||
|
from OCP.TopAbs import TopAbs_Orientation
|
||||||
from OCP.TopLoc import TopLoc_Location
|
from OCP.TopLoc import TopLoc_Location
|
||||||
from OCP.TopTools import TopTools_IndexedMapOfShape
|
|
||||||
from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Shape, TopoDS_Vertex
|
from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Shape, TopoDS_Vertex
|
||||||
from build123d import Shape, Vertex, Face, Location
|
from build123d import Vertex, Face, Location, Compound, Vector
|
||||||
from pygltflib import GLTF2
|
from pygltflib import GLTF2
|
||||||
|
|
||||||
from yacv_server.cad import CADCoreLike
|
from yacv_server.cad import CADCoreLike, ColorTuple
|
||||||
from yacv_server.gltf import GLTFMgr
|
from yacv_server.gltf import GLTFMgr
|
||||||
from yacv_server.mylogger import logger
|
from yacv_server.mylogger import logger
|
||||||
|
|
||||||
|
|
||||||
def tessellate(
|
def tessellate(
|
||||||
cad_like: CADCoreLike,
|
cad_like: CADCoreLike, color_faces: ColorTuple, color_edges: ColorTuple, color_vertices: ColorTuple,
|
||||||
tolerance: float = 0.1,
|
color_obj: Optional[ColorTuple] = None, tolerance: float = 0.1, angular_tolerance: float = 0.1,
|
||||||
angular_tolerance: float = 0.1,
|
faces: bool = True, edges: bool = True, vertices: bool = True, texture: Optional[Tuple[bytes, str]] = None,
|
||||||
faces: bool = True,
|
|
||||||
edges: bool = True,
|
|
||||||
vertices: bool = True,
|
|
||||||
) -> GLTF2:
|
) -> GLTF2:
|
||||||
"""Tessellate a whole shape into a list of triangle vertices and a list of triangle indices."""
|
"""Tessellate a whole shape into a list of triangle vertices and a list of triangle indices."""
|
||||||
mgr = GLTFMgr()
|
if texture is None:
|
||||||
|
mgr = GLTFMgr()
|
||||||
|
else:
|
||||||
|
mgr = GLTFMgr(texture)
|
||||||
|
|
||||||
if isinstance(cad_like, TopLoc_Location):
|
if isinstance(cad_like, TopLoc_Location):
|
||||||
mgr.add_location(Location(cad_like))
|
mgr.add_location(Location(cad_like))
|
||||||
|
|
||||||
elif isinstance(cad_like, TopoDS_Shape):
|
elif isinstance(cad_like, TopoDS_Shape):
|
||||||
shape = Shape(cad_like)
|
shape = Compound(cad_like)
|
||||||
|
|
||||||
# Perform tessellation tasks
|
# Perform tessellation tasks
|
||||||
edge_to_faces: Dict[TopoDS_Edge, List[TopoDS_Face]] = {}
|
edge_to_faces: Dict[str, List[TopoDS_Face]] = {}
|
||||||
vertex_to_faces: Dict[TopoDS_Vertex, List[TopoDS_Face]] = {}
|
vertex_to_faces: Dict[str, List[TopoDS_Face]] = {}
|
||||||
if faces:
|
if faces and hasattr(shape, 'faces'):
|
||||||
for face in shape.faces():
|
shape_faces = shape.faces()
|
||||||
_tessellate_face(mgr, face.wrapped, tolerance, angular_tolerance)
|
for face in shape_faces:
|
||||||
|
_tessellate_face(mgr, face.wrapped, color_obj or color_faces, tolerance, angular_tolerance)
|
||||||
if edges:
|
if edges:
|
||||||
for edge in face.edges():
|
for edge in face.edges():
|
||||||
edge_to_faces[edge.wrapped] = edge_to_faces.get(edge.wrapped, []) + [face.wrapped]
|
edge_to_faces[edge.wrapped] = edge_to_faces.get(edge.wrapped, []) + [face.wrapped]
|
||||||
if vertices:
|
if vertices:
|
||||||
for vertex in face.vertices():
|
for vertex in face.vertices():
|
||||||
vertex_to_faces[vertex.wrapped] = vertex_to_faces.get(vertex.wrapped, []) + [face.wrapped]
|
vertex_to_faces[vertex.wrapped] = vertex_to_faces.get(vertex.wrapped, []) + [face.wrapped]
|
||||||
if edges:
|
if len(shape_faces) > 0: color_obj = None # Don't color edges/vertices if faces are colored
|
||||||
for edge in shape.edges():
|
if edges and hasattr(shape, 'edges'):
|
||||||
_tessellate_edge(mgr, edge.wrapped, edge_to_faces.get(edge.wrapped, []), angular_tolerance,
|
shape_edges = shape.edges()
|
||||||
angular_tolerance)
|
for edge in shape_edges:
|
||||||
if vertices:
|
_tessellate_edge(mgr, edge.wrapped, edge_to_faces.get(edge.wrapped, []), color_obj or color_edges,
|
||||||
|
angular_tolerance, angular_tolerance)
|
||||||
|
if len(shape_edges) > 0: color_obj = None # Don't color vertices if edges are colored
|
||||||
|
if vertices and hasattr(shape, 'vertices'):
|
||||||
for vertex in shape.vertices():
|
for vertex in shape.vertices():
|
||||||
_tessellate_vertex(mgr, vertex.wrapped, vertex_to_faces.get(vertex.wrapped, []))
|
_tessellate_vertex(mgr, vertex.wrapped, vertex_to_faces.get(vertex.wrapped, []),
|
||||||
|
color_obj or color_vertices)
|
||||||
|
|
||||||
return mgr.gltf
|
else:
|
||||||
|
raise TypeError(f"Unsupported type: {type(cad_like)}: {cad_like}")
|
||||||
|
|
||||||
|
return mgr.build()
|
||||||
|
|
||||||
|
|
||||||
def _tessellate_face(
|
def _tessellate_face(
|
||||||
mgr: GLTFMgr,
|
mgr: GLTFMgr,
|
||||||
ocp_face: TopoDS_Face,
|
ocp_face: TopoDS_Face,
|
||||||
|
color: ColorTuple,
|
||||||
tolerance: float = 1e-3,
|
tolerance: float = 1e-3,
|
||||||
angular_tolerance: float = 0.1
|
angular_tolerance: float = 0.1,
|
||||||
):
|
):
|
||||||
face = Shape(ocp_face)
|
face = Compound(ocp_face)
|
||||||
face.mesh(tolerance, angular_tolerance)
|
# face.mesh(tolerance, angular_tolerance)
|
||||||
|
tri_mesh = face.tessellate(tolerance, angular_tolerance)
|
||||||
|
# noinspection PyArgumentList
|
||||||
poly = BRep_Tool.Triangulation_s(face.wrapped, TopLoc_Location())
|
poly = BRep_Tool.Triangulation_s(face.wrapped, TopLoc_Location())
|
||||||
if poly is None:
|
if poly is None:
|
||||||
logger.warn("No triangulation found for face")
|
logger.warn("No triangulation found for face")
|
||||||
return GLTF2()
|
return GLTF2()
|
||||||
tri_mesh = face.tessellate(tolerance, angular_tolerance)
|
|
||||||
|
# Get the normal for each vertex (for smooth instead of flat shading!)
|
||||||
|
BRepLib_ToolTriangulatedShape.ComputeNormals_s(face.wrapped, poly)
|
||||||
|
reversed_face = face.wrapped.Orientation() == TopAbs_Orientation.TopAbs_REVERSED
|
||||||
|
normals = [
|
||||||
|
-Vector(v) if reversed_face else Vector(v)
|
||||||
|
for v in (poly.Normal(i) for i in range(1, poly.NbNodes() + 1))
|
||||||
|
]
|
||||||
|
|
||||||
# Get UV of each face from the parameters
|
# Get UV of each face from the parameters
|
||||||
uv = [
|
uv = [
|
||||||
@@ -78,9 +93,10 @@ def _tessellate_face(
|
|||||||
for v in (poly.UVNode(i) for i in range(1, poly.NbNodes() + 1))
|
for v in (poly.UVNode(i) for i in range(1, poly.NbNodes() + 1))
|
||||||
]
|
]
|
||||||
|
|
||||||
vertices = [(v.X, v.Y, v.Z) for v in tri_mesh[0]]
|
vertices = tri_mesh[0]
|
||||||
indices = tri_mesh[1]
|
indices = tri_mesh[1]
|
||||||
mgr.add_face(vertices, indices, uv)
|
mgr.add_face(vertices, normals, indices, uv, color)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _push_point(v: Tuple[float, float, float], faces: List[TopoDS_Face]) -> Tuple[float, float, float]:
|
def _push_point(v: Tuple[float, float, float], faces: List[TopoDS_Face]) -> Tuple[float, float, float]:
|
||||||
@@ -91,9 +107,9 @@ def _push_point(v: Tuple[float, float, float], faces: List[TopoDS_Face]) -> Tupl
|
|||||||
push_dir = (push_dir[0] + normal.X, push_dir[1] + normal.Y, push_dir[2] + normal.Z)
|
push_dir = (push_dir[0] + normal.X, push_dir[1] + normal.Y, push_dir[2] + normal.Z)
|
||||||
if push_dir != (0, 0, 0):
|
if push_dir != (0, 0, 0):
|
||||||
# Normalize the push direction by the number of faces and a constant factor
|
# Normalize the push direction by the number of faces and a constant factor
|
||||||
# NOTE: Don't overdo it, or metrics will be wrong
|
# NOTE: Don't overdo it, or metrics will be (more) wrong
|
||||||
n = len(faces) / 1e-3
|
n = 1e-3 / len(faces)
|
||||||
push_dir = (push_dir[0] / n, push_dir[1] / n, push_dir[2] / n)
|
push_dir = (push_dir[0] * n, push_dir[1] * n, push_dir[2] * n)
|
||||||
# Push the vertex by the normal
|
# Push the vertex by the normal
|
||||||
v = (v[0] + push_dir[0], v[1] + push_dir[1], v[2] + push_dir[2])
|
v = (v[0] + push_dir[0], v[1] + push_dir[1], v[2] + push_dir[2])
|
||||||
return v
|
return v
|
||||||
@@ -103,6 +119,7 @@ def _tessellate_edge(
|
|||||||
mgr: GLTFMgr,
|
mgr: GLTFMgr,
|
||||||
ocp_edge: TopoDS_Edge,
|
ocp_edge: TopoDS_Edge,
|
||||||
faces: List[TopoDS_Face],
|
faces: List[TopoDS_Face],
|
||||||
|
color: ColorTuple,
|
||||||
angular_deflection: float = 0.1,
|
angular_deflection: float = 0.1,
|
||||||
curvature_deflection: float = 0.1,
|
curvature_deflection: float = 0.1,
|
||||||
):
|
):
|
||||||
@@ -119,38 +136,12 @@ def _tessellate_edge(
|
|||||||
for i in range(1, discretizer.NbPoints() + 1)
|
for i in range(1, discretizer.NbPoints() + 1)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
mgr.add_edge(vertices)
|
|
||||||
|
# Convert strip of vertices to a list of pairs of vertices
|
||||||
|
vertices = [(vertices[i], vertices[i + 1]) for i in range(len(vertices) - 1)]
|
||||||
|
mgr.add_edge(vertices, color)
|
||||||
|
|
||||||
|
|
||||||
def _tessellate_vertex(mgr: GLTFMgr, ocp_vertex: TopoDS_Vertex, faces: List[TopoDS_Face]):
|
def _tessellate_vertex(mgr: GLTFMgr, ocp_vertex: TopoDS_Vertex, faces: List[TopoDS_Face], color: ColorTuple):
|
||||||
c = Vertex(ocp_vertex).center()
|
c = Vertex(ocp_vertex).center()
|
||||||
mgr.add_vertex(_push_point((c.X, c.Y, c.Z), faces))
|
mgr.add_vertex(_push_point((c.X, c.Y, c.Z), faces), color)
|
||||||
|
|
||||||
|
|
||||||
def _hashcode(obj: Union[bytes, TopoDS_Shape], **extras) -> str:
|
|
||||||
"""Utility to compute the hash code of a shape recursively without the need to tessellate it"""
|
|
||||||
# NOTE: obj.HashCode(MAX_HASH_CODE) is not stable across different runs of the same program
|
|
||||||
# This is best-effort and not guaranteed to be unique
|
|
||||||
hasher = hashlib.md5(usedforsecurity=False)
|
|
||||||
for k, v in extras.items():
|
|
||||||
hasher.update(str(k).encode())
|
|
||||||
hasher.update(str(v).encode())
|
|
||||||
if isinstance(obj, bytes):
|
|
||||||
hasher.update(obj)
|
|
||||||
elif isinstance(obj, TopLoc_Location):
|
|
||||||
sub_data = io.BytesIO()
|
|
||||||
obj.DumpJson(sub_data)
|
|
||||||
hasher.update(sub_data.getvalue())
|
|
||||||
elif isinstance(obj, TopoDS_Shape):
|
|
||||||
map_of_shapes = TopTools_IndexedMapOfShape()
|
|
||||||
TopExp.MapShapes_s(obj, map_of_shapes)
|
|
||||||
for i in range(1, map_of_shapes.Extent() + 1):
|
|
||||||
sub_shape = map_of_shapes.FindKey(i)
|
|
||||||
sub_data = io.BytesIO()
|
|
||||||
TopoDS_Shape.DumpJson(sub_shape, sub_data)
|
|
||||||
val = sub_data.getvalue()
|
|
||||||
val = re.sub(b'"this": "[^"]*"', b'', val) # Remove memory address
|
|
||||||
hasher.update(val)
|
|
||||||
else:
|
|
||||||
raise ValueError(f'Cannot hash object of type {type(obj)}')
|
|
||||||
return hasher.hexdigest()
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import atexit
|
import atexit
|
||||||
|
import base64
|
||||||
import copy
|
import copy
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
@@ -7,22 +8,27 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum, auto
|
||||||
from http.server import ThreadingHTTPServer
|
from http.server import ThreadingHTTPServer
|
||||||
from importlib.metadata import version
|
from io import BytesIO
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from typing import Optional, Dict, Union, Callable, List
|
from typing import Optional, Dict, Union, Callable, List, Tuple
|
||||||
|
|
||||||
from OCP.TopLoc import TopLoc_Location
|
from OCP.TopLoc import TopLoc_Location
|
||||||
from OCP.TopoDS import TopoDS_Shape
|
from OCP.TopoDS import TopoDS_Shape
|
||||||
|
from PIL import Image
|
||||||
# noinspection PyProtectedMember
|
# noinspection PyProtectedMember
|
||||||
from build123d import Shape, Axis, Location, Vector
|
from build123d import Shape, Axis, Location, Vector
|
||||||
from dataclasses_json import dataclass_json
|
from dataclasses_json import dataclass_json
|
||||||
|
|
||||||
from myhttp import HTTPHandler
|
from yacv_server.cad import _hashcode, get_color, ColorTuple
|
||||||
from yacv_server.cad import get_shape, grab_all_cad, CADCoreLike, CADLike
|
from yacv_server.cad import get_shape, grab_all_cad, CADCoreLike, CADLike
|
||||||
|
from yacv_server.gltf import get_version
|
||||||
|
from yacv_server.myhttp import HTTPHandler
|
||||||
from yacv_server.mylogger import logger
|
from yacv_server.mylogger import logger
|
||||||
from yacv_server.pubsub import BufferedPubSub
|
from yacv_server.pubsub import BufferedPubSub
|
||||||
from yacv_server.tessellate import _hashcode, tessellate
|
from yacv_server.rwlock import RWLock
|
||||||
|
from yacv_server.tessellate import tessellate
|
||||||
|
|
||||||
|
|
||||||
@dataclass_json
|
@dataclass_json
|
||||||
@@ -33,8 +39,8 @@ class UpdatesApiData:
|
|||||||
"""Name of the object. Should be unique unless you want to overwrite the previous object"""
|
"""Name of the object. Should be unique unless you want to overwrite the previous object"""
|
||||||
hash: str
|
hash: str
|
||||||
"""Hash of the object, to detect changes without rebuilding the object"""
|
"""Hash of the object, to detect changes without rebuilding the object"""
|
||||||
is_remove: bool
|
is_remove: Optional[bool]
|
||||||
"""Whether to remove the object from the scene"""
|
"""Whether to remove the object from the scene. If None, this is a shutdown request"""
|
||||||
|
|
||||||
|
|
||||||
YACVSupported = Union[bytes, CADCoreLike]
|
YACVSupported = Union[bytes, CADCoreLike]
|
||||||
@@ -42,11 +48,11 @@ YACVSupported = Union[bytes, CADCoreLike]
|
|||||||
|
|
||||||
class UpdatesApiFullData(UpdatesApiData):
|
class UpdatesApiFullData(UpdatesApiData):
|
||||||
obj: YACVSupported
|
obj: YACVSupported
|
||||||
"""The OCCT object, if any (not serialized)"""
|
"""The OCCT object (not serialized)"""
|
||||||
kwargs: Optional[Dict[str, any]]
|
kwargs: Optional[Dict[str, any]]
|
||||||
"""The show_object options, if any (not serialized)"""
|
"""The show_object options, if any (not serialized)"""
|
||||||
|
|
||||||
def __init__(self, obj: YACVSupported, name: str, _hash: str, is_remove: bool = False,
|
def __init__(self, obj: YACVSupported, name: str, _hash: str, is_remove: Optional[bool] = False,
|
||||||
kwargs: Optional[Dict[str, any]] = None):
|
kwargs: Optional[Dict[str, any]] = None):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.hash = _hash
|
self.hash = _hash
|
||||||
@@ -59,27 +65,100 @@ class UpdatesApiFullData(UpdatesApiData):
|
|||||||
return super().to_json()
|
return super().to_json()
|
||||||
|
|
||||||
|
|
||||||
|
class YACVProtocol(Enum):
|
||||||
|
"""Enum of communication protocols supported by the server"""
|
||||||
|
HTTP = auto()
|
||||||
|
"""The recommended protocol for any platform that can run a web server."""
|
||||||
|
STDERR = auto()
|
||||||
|
"""Prints the updates one by one to stderr (first metadata, then base64 of glb file) using a special prefix. Required for Pyodide support."""
|
||||||
|
|
||||||
|
|
||||||
class YACV:
|
class YACV:
|
||||||
|
"""The main yacv_server class, which manages the web server and the CAD objects."""
|
||||||
|
|
||||||
|
# Startup
|
||||||
|
protocol: YACVProtocol
|
||||||
|
"""The protocol used by the server. Defaults to HTTP, but can be set to STDERR for Pyodide support."""
|
||||||
server_thread: Optional[Thread]
|
server_thread: Optional[Thread]
|
||||||
|
"""The main thread running the server (will spawn other threads for each request)"""
|
||||||
server: Optional[ThreadingHTTPServer]
|
server: Optional[ThreadingHTTPServer]
|
||||||
|
"""The server object"""
|
||||||
startup_complete: threading.Event
|
startup_complete: threading.Event
|
||||||
|
"""Event to signal when the server has started"""
|
||||||
|
|
||||||
|
# Running
|
||||||
show_events: BufferedPubSub[UpdatesApiFullData]
|
show_events: BufferedPubSub[UpdatesApiFullData]
|
||||||
|
"""PubSub for show events (objects to be shown in/removed from the scene)"""
|
||||||
build_events: Dict[str, BufferedPubSub[bytes]]
|
build_events: Dict[str, BufferedPubSub[bytes]]
|
||||||
object_events_lock: threading.Lock
|
"""PubSub for build events (objects that were built)"""
|
||||||
|
build_events_lock: threading.Lock
|
||||||
|
"""Lock to ensure that objects are only built once"""
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
at_least_one_client: threading.Event
|
||||||
|
"""Event to signal when at least one client has connected"""
|
||||||
|
shutting_down: threading.Event
|
||||||
|
"""Event to signal when the server is shutting down"""
|
||||||
|
frontend_lock: RWLock
|
||||||
|
"""Lock to ensure that the frontend has finished working before we shut down"""
|
||||||
|
|
||||||
|
texture: Optional[Tuple[bytes, str]]
|
||||||
|
"""Default texture to use for model faces, in (data, mimetype) format.
|
||||||
|
If left as None, no texture will be used.
|
||||||
|
|
||||||
|
It can be set with the YACV_TEXTURE=<uri> and overridden by the custom `yacv_texture` attribute of an object.
|
||||||
|
The <uri> can be file:<path> or data:<mime>;base64,<data> where <mime> is the mime type and
|
||||||
|
<data> is the base64 encoded image."""
|
||||||
|
|
||||||
|
color_faces: Optional[ColorTuple]
|
||||||
|
"""Overrides the default color to use for model faces. Applies even if a texture is used.
|
||||||
|
|
||||||
|
You can use `show(..., color_faces=...)` or the standard way of setting colors for build123d/cadquery objects to
|
||||||
|
override this color.
|
||||||
|
|
||||||
|
It can be set with the YACV_COLOR_FACES=<color> environment variable, where <color> is a color
|
||||||
|
in the hexadecimal format #RRGGBB or #RRGGBBAA."""
|
||||||
|
|
||||||
|
color_edges: Optional[ColorTuple]
|
||||||
|
"""Overrides the default color to use for model edges.
|
||||||
|
|
||||||
|
You can use `show(..., color_edges=...) or the standard way of setting colors for build123d/cadquery objects to
|
||||||
|
override this color.
|
||||||
|
|
||||||
|
It can be set with the YACV_COLOR_EDGES=<color> environment variable, where <color> is a color
|
||||||
|
in the hexadecimal format #RRGGBB or #RRGGBBAA."""
|
||||||
|
|
||||||
|
color_vertices: Optional[ColorTuple]
|
||||||
|
"""Overrides the default color to use for model vertices.
|
||||||
|
|
||||||
|
You can use `show(..., color_vertices=...)` or the standard way of setting colors for build123d/cadquery objects to
|
||||||
|
override this color.
|
||||||
|
|
||||||
|
It can be set with the YACV_COLOR_VERTICES=<color> environment variable, where <color> is a color
|
||||||
|
in the hexadecimal format #RRGGBB or #RRGGBBAA."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
"""Initializes the YACV server"""
|
||||||
|
raw_protocol = os.getenv('YACV_PROTOCOL', 'http' if sys.platform != 'emscripten' else 'stderr').upper()
|
||||||
|
self.protocol = YACVProtocol[raw_protocol] if raw_protocol in YACVProtocol.__members__ else YACVProtocol.HTTP
|
||||||
self.server_thread = None
|
self.server_thread = None
|
||||||
self.server = None
|
self.server = None
|
||||||
self.startup_complete = threading.Event()
|
self.startup_complete = threading.Event()
|
||||||
self.at_least_one_client = threading.Event()
|
|
||||||
self.show_events = BufferedPubSub()
|
self.show_events = BufferedPubSub()
|
||||||
self.build_events = {}
|
self.build_events = {}
|
||||||
self.object_events_lock = threading.Lock()
|
self.build_events_lock = threading.Lock()
|
||||||
self.frontend_lock = threading.Lock()
|
self.at_least_one_client = threading.Event()
|
||||||
logger.info('Using yacv-server v%s', version('yacv-server'))
|
self.shutting_down = threading.Event()
|
||||||
|
self.frontend_lock = RWLock()
|
||||||
|
self.texture = _read_texture_uri(os.getenv("YACV_TEXTURE"))
|
||||||
|
self.color_faces = _read_color(os.getenv("YACV_COLOR_FACES", "#ffbf00")) # Default yellow
|
||||||
|
self.color_edges = _read_color(os.getenv("YACV_COLOR_EDGES", "#1a1aff")) # Default blue
|
||||||
|
self.color_vertices = _read_color(os.getenv("YACV_COLOR_VERTICES", "#1a1a1a")) # Default dark gray
|
||||||
|
logger.info('Using yacv-server v%s', get_version())
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Starts the web server in the background"""
|
"""Starts the web server in the background"""
|
||||||
|
if self.protocol == YACVProtocol.STDERR: return # No server to start, just print to stderr
|
||||||
assert self.server_thread is None, "Server currently running, cannot start another one"
|
assert self.server_thread is None, "Server currently running, cannot start another one"
|
||||||
assert self.startup_complete.is_set() is False, "Server already started"
|
assert self.startup_complete.is_set() is False, "Server already started"
|
||||||
# Start the server in a separate daemon thread
|
# Start the server in a separate daemon thread
|
||||||
@@ -96,46 +175,48 @@ class YACV:
|
|||||||
# noinspection PyUnusedLocal
|
# noinspection PyUnusedLocal
|
||||||
def stop(self, *args):
|
def stop(self, *args):
|
||||||
"""Stops the web server"""
|
"""Stops the web server"""
|
||||||
|
if self.protocol == YACVProtocol.STDERR: return # No server to stop, just print to stderr
|
||||||
|
# The remainder is for the HTTP protocol only
|
||||||
if self.server_thread is None:
|
if self.server_thread is None:
|
||||||
logger.error('Cannot stop server because it is not running')
|
logger.error('Cannot stop server because it is not running')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Inform the server that we are shutting down
|
||||||
|
self.shutting_down.set()
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
self._show_event(UpdatesApiFullData(name='__shutdown', _hash='', is_remove=None, obj=None))
|
||||||
|
|
||||||
|
# If we were too fast, ensure that at least one client has connected
|
||||||
graceful_secs_connect = float(os.getenv('YACV_GRACEFUL_SECS_CONNECT', 12.0))
|
graceful_secs_connect = float(os.getenv('YACV_GRACEFUL_SECS_CONNECT', 12.0))
|
||||||
graceful_secs_request = float(os.getenv('YACV_GRACEFUL_SECS_REQUEST', 5.0))
|
if graceful_secs_connect > 0:
|
||||||
# Make sure we can hold the lock for more than 100ms (to avoid exiting too early)
|
start = time.time()
|
||||||
logger.info('Stopping server (waiting for at least one frontend request first, cancel with CTRL+C)...')
|
try:
|
||||||
start = time.time()
|
if not self.at_least_one_client.is_set():
|
||||||
try:
|
logger.warning(
|
||||||
while not self.at_least_one_client.wait(
|
'Waiting for at least one frontend request before stopping server, cancel with CTRL+C...')
|
||||||
graceful_secs_connect / 10) and time.time() - start < graceful_secs_connect:
|
while (not self.at_least_one_client.wait(graceful_secs_connect / 10) and
|
||||||
time.sleep(0.01)
|
time.time() - start < graceful_secs_connect):
|
||||||
except KeyboardInterrupt:
|
time.sleep(0.01)
|
||||||
pass
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
logger.info('Stopping server (waiting for no more frontend requests)...')
|
# Wait for the server to stop gracefully (all frontends to stop working)
|
||||||
start = time.time()
|
graceful_secs_request = float(os.getenv('YACV_GRACEFUL_SECS_WORK', 1000000))
|
||||||
try:
|
with self.frontend_lock.w_locked(timeout=graceful_secs_request):
|
||||||
while time.time() - start < graceful_secs_request:
|
# Stop the server
|
||||||
if self.frontend_lock.locked():
|
self.server.shutdown()
|
||||||
start = time.time()
|
|
||||||
time.sleep(0.01)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Stop the server in the background
|
# Wait for the server thread to stop
|
||||||
self.server.shutdown()
|
self.server_thread.join(timeout=30)
|
||||||
logger.info('Stopping server (sent)...')
|
self.server_thread = None
|
||||||
|
if len(args) >= 1 and args[0] in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
sys.exit(0) # Exit with success
|
||||||
|
|
||||||
# Wait for the server to stop gracefully
|
_yacvServerModelPrefix = "yacv_server://model/"
|
||||||
self.server_thread.join(timeout=30)
|
|
||||||
self.server_thread = None
|
|
||||||
logger.info('Stopping server (confirmed)...')
|
|
||||||
if len(args) >= 1 and args[0] in (signal.SIGINT, signal.SIGTERM):
|
|
||||||
sys.exit(0) # Exit with success
|
|
||||||
|
|
||||||
def _run_server(self):
|
def _run_server(self):
|
||||||
"""Runs the web server"""
|
"""Runs the web server"""
|
||||||
logger.info('Starting server...')
|
logger.info('Starting server in %s mode...', self.protocol.name)
|
||||||
self.server = ThreadingHTTPServer(
|
self.server = ThreadingHTTPServer(
|
||||||
(os.getenv('YACV_HOST', 'localhost'), int(os.getenv('YACV_PORT', 32323))),
|
(os.getenv('YACV_HOST', 'localhost'), int(os.getenv('YACV_PORT', 32323))),
|
||||||
lambda a, b, c: HTTPHandler(a, b, c, yacv=self))
|
lambda a, b, c: HTTPHandler(a, b, c, yacv=self))
|
||||||
@@ -144,13 +225,50 @@ class YACV:
|
|||||||
self.startup_complete.set()
|
self.startup_complete.set()
|
||||||
self.server.serve_forever()
|
self.server.serve_forever()
|
||||||
|
|
||||||
|
def _show_event(self, event: UpdatesApiFullData):
|
||||||
|
"""Handles a show event by publishing it to the show events buffer (and special handling for stderr protocol)."""
|
||||||
|
self.show_events.publish(event)
|
||||||
|
# If the protocol is STDERR, we need to print the event to stderr
|
||||||
|
if self.protocol == YACVProtocol.STDERR:
|
||||||
|
msg = f'{self._yacvServerModelPrefix}{event.to_json()}'
|
||||||
|
if not event.is_remove:
|
||||||
|
# Always build the object even if the interface already has it (optimization disabled for Pyodide)
|
||||||
|
glb_and_hash = self.export(event.name)
|
||||||
|
if glb_and_hash is None:
|
||||||
|
logger.warning('Object %s not found, ignoring it...', event.name)
|
||||||
|
return
|
||||||
|
glb = glb_and_hash[0]
|
||||||
|
msg += f'{base64.b64encode(glb).decode("utf-8")}'
|
||||||
|
print(msg, file=sys.stderr, flush=True)
|
||||||
|
|
||||||
def show(self, *objs: List[YACVSupported], names: Optional[Union[str, List[str]]] = None, **kwargs):
|
def show(self, *objs: List[YACVSupported], names: Optional[Union[str, List[str]]] = None, **kwargs):
|
||||||
|
"""
|
||||||
|
Shows the given CAD objects in the frontend. The objects will be tessellated and converted to GLTF. Optionally,
|
||||||
|
the following keyword arguments can be used:
|
||||||
|
|
||||||
|
- auto_clear: Whether to clear the previous objects before showing the new ones (default: True)
|
||||||
|
- texture: The texture to use for the faces of the object (see `YACV.texture` for more info)
|
||||||
|
- color: The default color to use for the objects (can be overridden by the `color` attribute of each object)
|
||||||
|
- tolerance: The tolerance for tessellating the object (default: 0.1)
|
||||||
|
- angular_tolerance: The angular tolerance for tessellating the object (default: 0.1)
|
||||||
|
- faces: Whether to tessellate and show the faces of the object (default: True)
|
||||||
|
- edges: Whether to tessellate and show the edges of the object (default: True)
|
||||||
|
- vertices: Whether to tessellate and show the vertices of the object (default: True)
|
||||||
|
|
||||||
|
:param objs: The CAD objects to show. Can be CAD-like objects (solids, locations, etc.) or bytes (GLTF) objects.
|
||||||
|
:param names: The names of the objects. If None, the variable names will be used (if possible). The number of
|
||||||
|
names must match the number of objects. An object of the same name will be replaced in the frontend.
|
||||||
|
:param kwargs: Additional options for the show_object event.
|
||||||
|
"""
|
||||||
# Prepare the arguments
|
# Prepare the arguments
|
||||||
start = time.time()
|
start = time.time()
|
||||||
names = names or [_find_var_name(obj) for obj in objs]
|
names = names or [_find_var_name(obj) for obj in objs]
|
||||||
if isinstance(names, str):
|
if isinstance(names, str):
|
||||||
names = [names]
|
names = [names]
|
||||||
assert len(names) == len(objs), 'Number of names must match the number of objects'
|
assert len(names) == len(objs), 'Number of names must match the number of objects'
|
||||||
|
for color_name in ('color_faces', 'color_edges', 'color_vertices'):
|
||||||
|
if color_name in kwargs:
|
||||||
|
kwargs[color_name] = get_color(kwargs[color_name]) or _read_color(kwargs[color_name])
|
||||||
|
|
||||||
# Handle auto clearing of previous objects
|
# Handle auto clearing of previous objects
|
||||||
if kwargs.get('auto_clear', True):
|
if kwargs.get('auto_clear', True):
|
||||||
@@ -165,17 +283,23 @@ class YACV:
|
|||||||
|
|
||||||
# Publish the show event
|
# Publish the show event
|
||||||
for obj, name in zip(objs, names):
|
for obj, name in zip(objs, names):
|
||||||
|
obj_color = get_color(obj)
|
||||||
|
# Some properties may be lost in preprocessing, so save them in kwargs
|
||||||
|
_kwargs = kwargs.copy()
|
||||||
|
if obj_color is not None:
|
||||||
|
_kwargs['color_obj'] = obj_color # Only applies to highest-dimensional objects
|
||||||
|
_kwargs['texture'] = _read_texture_uri(getattr(obj, 'yacv_texture', None) or kwargs.get('texture', None))
|
||||||
if not isinstance(obj, bytes):
|
if not isinstance(obj, bytes):
|
||||||
obj = _preprocess_cad(obj, **kwargs)
|
obj = _preprocess_cad(obj, **_kwargs)
|
||||||
_hash = _hashcode(obj, **kwargs)
|
_hash = _hashcode(obj, **_kwargs)
|
||||||
event = UpdatesApiFullData(name=name, _hash=_hash, obj=obj, kwargs=kwargs or {})
|
event = UpdatesApiFullData(name=name, _hash=_hash, obj=obj, kwargs=_kwargs or {})
|
||||||
self.show_events.publish(event)
|
self._show_event(event)
|
||||||
|
|
||||||
logger.info('show %s took %.3f seconds', names, time.time() - start)
|
logger.info('show %s took %.3f seconds', names, time.time() - start)
|
||||||
|
|
||||||
def show_cad_all(self, **kwargs):
|
def show_cad_all(self, **kwargs):
|
||||||
"""Publishes all CAD objects in the current scope to the server"""
|
"""Publishes all CAD objects in the current scope to the server. See `show` for more details."""
|
||||||
all_cad = grab_all_cad()
|
all_cad = list(grab_all_cad()) # List for reproducible iteration order
|
||||||
self.show(*[cad for _, cad in all_cad], names=[name for name, _ in all_cad], **kwargs)
|
self.show(*[cad for _, cad in all_cad], names=[name for name, _ in all_cad], **kwargs)
|
||||||
|
|
||||||
def remove(self, name: str):
|
def remove(self, name: str):
|
||||||
@@ -187,14 +311,14 @@ class YACV:
|
|||||||
self.show_events.delete(old_show_event)
|
self.show_events.delete(old_show_event)
|
||||||
|
|
||||||
# Delete any cached object builds
|
# Delete any cached object builds
|
||||||
with self.object_events_lock:
|
with self.build_events_lock:
|
||||||
if name in self.build_events:
|
if name in self.build_events:
|
||||||
del self.build_events[name]
|
del self.build_events[name]
|
||||||
|
|
||||||
# Publish the remove event
|
# Publish the remove event
|
||||||
show_event = copy.copy(show_events[-1])
|
show_event = copy.copy(show_events[-1])
|
||||||
show_event.is_remove = True
|
show_event.is_remove = True
|
||||||
self.show_events.publish(show_event)
|
self._show_event(show_event)
|
||||||
|
|
||||||
def clear(self, except_names: List[str] = None):
|
def clear(self, except_names: List[str] = None):
|
||||||
"""Clears all previously-shown objects from the scene"""
|
"""Clears all previously-shown objects from the scene"""
|
||||||
@@ -206,13 +330,13 @@ class YACV:
|
|||||||
|
|
||||||
def shown_object_names(self, apply_removes: bool = True) -> List[str]:
|
def shown_object_names(self, apply_removes: bool = True) -> List[str]:
|
||||||
"""Returns the names of all objects that have been shown"""
|
"""Returns the names of all objects that have been shown"""
|
||||||
res = []
|
res = set()
|
||||||
for obj in self.show_events.buffer():
|
for obj in self.show_events.buffer():
|
||||||
if not obj.is_remove or not apply_removes:
|
if not obj.is_remove or not apply_removes:
|
||||||
res.append(obj.name)
|
res.add(obj.name)
|
||||||
else:
|
else:
|
||||||
res.remove(obj.name)
|
res.discard(obj.name)
|
||||||
return res
|
return list(res)
|
||||||
|
|
||||||
def _show_events(self, name: str, apply_removes: bool = True) -> List[UpdatesApiFullData]:
|
def _show_events(self, name: str, apply_removes: bool = True) -> List[UpdatesApiFullData]:
|
||||||
"""Returns the show events with the given name"""
|
"""Returns the show events with the given name"""
|
||||||
@@ -228,8 +352,8 @@ class YACV:
|
|||||||
res.remove(old_event)
|
res.remove(old_event)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def export(self, name: str) -> Optional[bytes]:
|
def export(self, name: str) -> Optional[Tuple[bytes, str]]:
|
||||||
"""Export the given previously-shown object to a single GLB file, building it if necessary."""
|
"""Export the given previously-shown object to a single GLB blob, building it if necessary."""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
|
||||||
# Check that the object to build exists and grab it if it does
|
# Check that the object to build exists and grab it if it does
|
||||||
@@ -240,7 +364,7 @@ class YACV:
|
|||||||
event = events[-1]
|
event = events[-1]
|
||||||
|
|
||||||
# Use the lock to ensure that we don't build the object twice
|
# Use the lock to ensure that we don't build the object twice
|
||||||
with self.object_events_lock:
|
with self.build_events_lock:
|
||||||
# If there are no object events for this name, we need to build the object
|
# If there are no object events for this name, we need to build the object
|
||||||
if name not in self.build_events:
|
if name not in self.build_events:
|
||||||
logger.debug('Building object %s with hash %s', name, event.hash)
|
logger.debug('Building object %s with hash %s', name, event.hash)
|
||||||
@@ -253,21 +377,29 @@ class YACV:
|
|||||||
if isinstance(event.obj, bytes): # Already a GLTF
|
if isinstance(event.obj, bytes): # Already a GLTF
|
||||||
publish_to.publish(event.obj)
|
publish_to.publish(event.obj)
|
||||||
else: # CAD object to tessellate and convert to GLTF
|
else: # CAD object to tessellate and convert to GLTF
|
||||||
gltf = tessellate(event.obj, tolerance=event.kwargs.get('tolerance', 0.1),
|
gltf = tessellate(
|
||||||
angular_tolerance=event.kwargs.get('angular_tolerance', 0.1),
|
event.obj,
|
||||||
faces=event.kwargs.get('faces', True),
|
color_faces=event.kwargs.get('color_faces', self.color_faces),
|
||||||
edges=event.kwargs.get('edges', True),
|
color_edges=event.kwargs.get('color_edges', self.color_edges),
|
||||||
vertices=event.kwargs.get('vertices', True))
|
color_vertices=event.kwargs.get('color_vertices', self.color_vertices),
|
||||||
|
color_obj=event.kwargs.get('color_obj', None),
|
||||||
|
tolerance=event.kwargs.get('tolerance', 0.1),
|
||||||
|
angular_tolerance=event.kwargs.get('angular_tolerance', 0.1),
|
||||||
|
faces=event.kwargs.get('faces', True), edges=event.kwargs.get('edges', True),
|
||||||
|
vertices=event.kwargs.get('vertices', True),
|
||||||
|
texture=event.kwargs.get('texture', self.texture))
|
||||||
glb_list_of_bytes = gltf.save_to_bytes()
|
glb_list_of_bytes = gltf.save_to_bytes()
|
||||||
publish_to.publish(b''.join(glb_list_of_bytes))
|
glb_bytes = b''.join(glb_list_of_bytes)
|
||||||
logger.info('export(%s) took %.3f seconds, %d parts', name, time.time() - start,
|
publish_to.publish(glb_bytes)
|
||||||
len(gltf.meshes[0].primitives))
|
logger.info('export(%s) took %.3f seconds, %s', name, time.time() - start,
|
||||||
|
sizeof_fmt(len(glb_bytes)))
|
||||||
|
|
||||||
# In either case return the elements of a subscription to the async generator
|
# In either case return the elements of a subscription to the async generator
|
||||||
subscription = self.build_events[name].subscribe()
|
subscription = self.build_events[name].subscribe()
|
||||||
try:
|
try:
|
||||||
return next(subscription)
|
return next(subscription), event.hash
|
||||||
finally:
|
finally:
|
||||||
|
# noinspection PyInconsistentReturns
|
||||||
subscription.close()
|
subscription.close()
|
||||||
|
|
||||||
def export_all(self, folder: str,
|
def export_all(self, folder: str,
|
||||||
@@ -277,7 +409,40 @@ class YACV:
|
|||||||
for name in self.shown_object_names():
|
for name in self.shown_object_names():
|
||||||
if export_filter(name, self._show_events(name)[-1].obj):
|
if export_filter(name, self._show_events(name)[-1].obj):
|
||||||
with open(os.path.join(folder, f'{name}.glb'), 'wb') as f:
|
with open(os.path.join(folder, f'{name}.glb'), 'wb') as f:
|
||||||
f.write(self.export(name))
|
f.write(self.export(name)[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _read_texture_uri(uri: str) -> Optional[Tuple[bytes, str]]:
|
||||||
|
if uri is None:
|
||||||
|
return None
|
||||||
|
if uri.startswith("file:"):
|
||||||
|
path = uri[len("file:"):]
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
buf = BytesIO(data)
|
||||||
|
img = Image.open(buf)
|
||||||
|
mtype = img.get_format_mimetype()
|
||||||
|
return data, mtype
|
||||||
|
if uri.startswith("data:"): # https://en.wikipedia.org/wiki/Data_URI_scheme#Syntax (limited)
|
||||||
|
mtype_and_data = uri[len("data:"):]
|
||||||
|
mtype = mtype_and_data.split(";", 1)[0]
|
||||||
|
data_str = mtype_and_data.split(",", 1)[1]
|
||||||
|
data = base64.b64decode(data_str)
|
||||||
|
return data, mtype
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_color(color: str) -> Optional[ColorTuple]:
|
||||||
|
"""Reads a color from a string in the format #RRGGBB or #RRGGBBAA"""
|
||||||
|
if color is None:
|
||||||
|
return None
|
||||||
|
if not color.startswith('#') or len(color) not in (7, 9):
|
||||||
|
raise ValueError(f'Invalid color format: {color}')
|
||||||
|
r = float(int(color[1:3], 16)) / 255.0
|
||||||
|
g = float(int(color[3:5], 16)) / 255.0
|
||||||
|
b = float(int(color[5:7], 16)) / 255.0
|
||||||
|
a = float(int(color[7:9], 16)) / 255.0 if len(color) == 9 else 1.0
|
||||||
|
return r, g, b, a
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyUnusedLocal
|
# noinspection PyUnusedLocal
|
||||||
@@ -299,15 +464,36 @@ def _preprocess_cad(obj: CADLike, **kwargs) -> CADCoreLike:
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
_find_var_name_count = 0
|
_obj_name_counts = {}
|
||||||
|
|
||||||
|
|
||||||
def _find_var_name(obj: any) -> str:
|
def _find_var_name(obj: any, avoid_levels: int = 2) -> str:
|
||||||
"""A hacky way to get a stable name for an object that may change over time"""
|
"""A hacky way to get a stable name for an object that may change over time"""
|
||||||
global _find_var_name_count
|
|
||||||
for frame in inspect.stack():
|
# Build123d objects have a "label" property, CadQuery Assembly's have "name"
|
||||||
|
for f in ('label', 'name'):
|
||||||
|
if hasattr(obj, f):
|
||||||
|
v = getattr(obj, f)
|
||||||
|
if v != '':
|
||||||
|
return v;
|
||||||
|
|
||||||
|
# Otherwise walk up our stack to see if there's a local variable that points to it
|
||||||
|
obj_shape = get_shape(obj, error=False) or obj
|
||||||
|
for frame in inspect.stack()[avoid_levels:]:
|
||||||
for key, value in frame.frame.f_locals.items():
|
for key, value in frame.frame.f_locals.items():
|
||||||
if value is obj:
|
if get_shape(value, error=False) is obj_shape:
|
||||||
return key
|
return key
|
||||||
_find_var_name_count += 1
|
|
||||||
return 'unknown_var_' + str(_find_var_name_count)
|
# Last resort, name it for its type with a disambiguating number
|
||||||
|
global _obj_name_counts
|
||||||
|
t = obj.__class__.__name__
|
||||||
|
_obj_name_counts[t] = 1 if t not in _obj_name_counts else _obj_name_counts[t] + 1
|
||||||
|
return t + str(_obj_name_counts[t])
|
||||||
|
|
||||||
|
|
||||||
|
def sizeof_fmt(num, suffix="B"):
|
||||||
|
for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"):
|
||||||
|
if abs(num) < 1024.0:
|
||||||
|
return f"{num:3.1f}{unit}{suffix}"
|
||||||
|
num /= 1024.0
|
||||||
|
return f"{num:.1f}Yi{suffix}"
|
||||||
|
|||||||
Reference in New Issue
Block a user