Compare commits
938 Commits
v0.0.1a
...
doc-testin
Author | SHA1 | Date | |
---|---|---|---|
1896994418 | |||
ded9592317 | |||
9db30877b9 | |||
87c2cecf14 | |||
cdbf59619e | |||
5b2211d95b | |||
6586647eac | |||
fb6f170d60 | |||
c1c39438e3 | |||
317fc03f87 | |||
505f3ab770 | |||
209062af4f | |||
88bab2a4c6 | |||
a91bae3506 | |||
0a96643a9f | |||
261d4d9610 | |||
3293741969 | |||
3eee8db1ae | |||
031b143843 | |||
7dd6e38e3f | |||
1dd0235061 | |||
cdcb2de786 | |||
0b88631250 | |||
c00b2a2d7d | |||
1f0f44fdbf | |||
8262fb9d4e | |||
c2114b593e | |||
7e28ca3fa1 | |||
d0bd4193d9 | |||
d09479fd47 | |||
07cfb85561 | |||
cf0d7a1122 | |||
3f335c7031 | |||
2180db5206 | |||
e6110b4cea | |||
819598ebd9 | |||
1be43f0336 | |||
6a5ff9a097 | |||
86cb3d29fb | |||
589702dab7 | |||
ed76210270 | |||
75c4f42796 | |||
1fd54769b9 | |||
c4484b4b51 | |||
4eb787cc0f | |||
c855b5a424 | |||
ee4083c134 | |||
0325e9d0bd | |||
21dc95b5a7 | |||
d8161f22f3 | |||
94f8bff231 | |||
d3a1094cc2 | |||
d08e0a80a4 | |||
26148e9934 | |||
0a7be03c6f | |||
b6449a7da2 | |||
36038effdf | |||
cb90c196a5 | |||
ae3c9fe43e | |||
2983195af4 | |||
4d69faf186 | |||
2304563b6e | |||
1cfb4e797e | |||
8f95158f08 | |||
5949e3c5cc | |||
790f145022 | |||
ec40862dd0 | |||
9cc1c92e0e | |||
7a716b4c37 | |||
a4ef8a6344 | |||
c5e20085f0 | |||
f4463f9cfe | |||
a212445927 | |||
89a8891073 | |||
52ebb874b0 | |||
2913e6d5a7 | |||
6a00b58600 | |||
2bde136bb6 | |||
f5c77fec3a | |||
d3211199c4 | |||
2d90ea7679 | |||
abd846fc8d | |||
e3bd7ea445 | |||
12bd4a603b | |||
3c31fb5118 | |||
c24f70fad5 | |||
ca2d8e49b5 | |||
4b1499f6ae | |||
f0c1fe9c87 | |||
dfaf1be4ff | |||
51cb099c4c | |||
64731a9198 | |||
213523c8d6 | |||
4fdb72f874 | |||
1412fc638c | |||
7920f67aea | |||
a0c0f781e2 | |||
ec74ea0038 | |||
2c016833fd | |||
3d9da73ab0 | |||
2f4e30f432 | |||
cd1e535a56 | |||
8a8cc0b322 | |||
1e64e17ff4 | |||
66b6c06a2c | |||
45fbc46d8d | |||
39e3c1dbd5 | |||
f043b03128 | |||
d4a0f782aa | |||
9273adbd01 | |||
03b92eb5e7 | |||
d0dc61bf66 | |||
056b3524e5 | |||
16fc4b8c54 | |||
e62f0682a2 | |||
f463cb82e7 | |||
54f416e8c3 | |||
682c983a64 | |||
8015881e29 | |||
6e2d36cd00 | |||
affab2414c | |||
af6d54296a | |||
2ab0a75608 | |||
ccb9e55920 | |||
31254d13c0 | |||
6fddbb6f5e | |||
9ee3f26b80 | |||
19c5ca0928 | |||
4bc35d7da4 | |||
8d19ffd52f | |||
4f506c461a | |||
49fcc40db9 | |||
8492b537a8 | |||
e6e4f6ab7a | |||
e073182028 | |||
78eb5d948c | |||
ac9a0f3733 | |||
4e7ade8f38 | |||
0b25264375 | |||
1d03fe4975 | |||
1273ab2371 | |||
7dd2ee5e70 | |||
c72bb21f20 | |||
5b43520353 | |||
7e25ca4c84 | |||
4a4cd5db50 | |||
7f19d45b71 | |||
37b3d6d094 | |||
64bb715aff | |||
3024b479d3 | |||
abf4462da6 | |||
aa6827303f | |||
254e8e8ca1 | |||
a63bf66023 | |||
983c5f9020 | |||
9f61335a85 | |||
ed0c5d9431 | |||
46414aef13 | |||
3841ed3330 | |||
47a281faff | |||
f0442861a8 | |||
039225a41c | |||
f1ffe37ac7 | |||
6ca7b42ab4 | |||
fbb32147b9 | |||
d85db295a5 | |||
0f1850bf2c | |||
8f4de7adbf | |||
320745aab3 | |||
40cec39d27 | |||
498616147b | |||
30b2f5d32e | |||
f7e98abb59 | |||
4022f300b3 | |||
cef45dad3c | |||
30d734c2c1 | |||
4391510d7b | |||
04a4f7668a | |||
908c0fa4af | |||
c718e62b33 | |||
2f34bba1fd | |||
db4e495183 | |||
c00a7184ff | |||
9c83df45fc | |||
17949003f7 | |||
371d793a13 | |||
c710111887 | |||
664f7635cc | |||
babecf5ae7 | |||
0bad6895da | |||
66e55a7eec | |||
4e2377cd7f | |||
f90c12b27f | |||
3573db0969 | |||
92bde00a5a | |||
2c82560d24 | |||
6f364d2b88 | |||
760b52c02b | |||
4dd932fc56 | |||
ba1a03cbfa | |||
18b5fa795c | |||
1a82ec72e4 | |||
804747c73b | |||
7ee705332f | |||
716c78e380 | |||
5e4ce4556f | |||
aa9ea08151 | |||
f56890128e | |||
8865556229 | |||
5bc9b10c12 | |||
7db3c18213 | |||
ff35e34032 | |||
9f8222afa7 | |||
1828bfac22 | |||
3a1087ecb8 | |||
b398541787 | |||
f0b33d8471 | |||
5a282a3e22 | |||
4283fc0fff | |||
753f4d3f27 | |||
9dd02b2756 | |||
c74d12c843 | |||
e1d9982276 | |||
8861986213 | |||
1cb9fb410c | |||
c4a8cc4606 | |||
187f11071c | |||
530fae8cb4 | |||
6771c371a1 | |||
c844c6e54f | |||
a4d0b1a68b | |||
2fdc11692d | |||
dbfca4568f | |||
069a528276 | |||
030f2661fd | |||
e589e3eec4 | |||
04140ced1b | |||
0d9ce43e74 | |||
d3969b4fd4 | |||
e21f64ac98 | |||
b25b380d21 | |||
1146d9d304 | |||
51b60521e6 | |||
035f8a1dcd | |||
cefaef5c4b | |||
4714e60ff7 | |||
3eca25ae19 | |||
96346f8a25 | |||
a258c2c182 | |||
6862df5331 | |||
f271a9d0e3 | |||
bdff6eb5c9 | |||
b661407952 | |||
d5eb7fda02 | |||
35e8ac9c33 | |||
4453d256b8 | |||
299e330ec6 | |||
34b9f7ae27 | |||
9d100d84ad | |||
2f677c399e | |||
e967b35c38 | |||
7bd0a196b4 | |||
7892b5e9b6 | |||
f779678c0e | |||
629fc2d223 | |||
724c2345df | |||
673c4e69a4 | |||
fbfff6c7ec | |||
f592294335 | |||
8e7be5afde | |||
fc76b2a8e6 | |||
1a8bcddb74 | |||
60fba5b9df | |||
be0eb1fa42 | |||
93d9bea3ae | |||
022b7f7822 | |||
ae34846509 | |||
d328077cb0 | |||
0c4740eef8 | |||
d7b2c7e2f6 | |||
efbb9e7096 | |||
e0b56d8990 | |||
7a94c21187 | |||
0687090f05 | |||
920744334c | |||
dfa7f98126 | |||
ea530f0f96 | |||
c3546ff74f | |||
83aa9b57ec | |||
28a265be68 | |||
7dfabb16c7 | |||
ea5d9371ca | |||
3df73a0716 | |||
ae3c994ff1 | |||
bd73b385b6 | |||
f054b1c5f2 | |||
d083100a2a | |||
b813b8df9e | |||
d0e966ff1a | |||
56cbf14fe1 | |||
8bf55ebd46 | |||
edbc5ee343 | |||
4a92511582 | |||
b42df2cf4a | |||
7549466824 | |||
423e71476d | |||
3bc4b20035 | |||
9966a24b5e | |||
577c01a594 | |||
3d72796c10 | |||
edcbd7b02a | |||
b368c985b8 | |||
cab1a71eaa | |||
33cb188509 | |||
0a3dd9b5b8 | |||
7fbdbdcc21 | |||
8f9d5aabf9 | |||
824d4d6a83 | |||
5f4bccbcd9 | |||
8e8e54fe7d | |||
04b13cc0b7 | |||
ba98875560 | |||
a9fb84a5c6 | |||
2f139178d3 | |||
e466f81600 | |||
cb836e30f5 | |||
152e356dad | |||
7b13e8978b | |||
e0839fe1fb | |||
aec3e8b8bf | |||
a89564de6b | |||
e301a10456 | |||
cfc6ce91bc | |||
4f731c6640 | |||
9b1b8f11fd | |||
e742c824fc | |||
6757bbbd30 | |||
f6a39e4290 | |||
410d8d2f1a | |||
bd64c17f05 | |||
dc063b5954 | |||
0ae34d5702 | |||
167b39f15e | |||
9adc0d7d6e | |||
fb622fa098 | |||
c533d4b86a | |||
6c47e095be | |||
f992d06b03 | |||
af3afc1124 | |||
b77ab2dd05 | |||
150054d19c | |||
8d2b9e5580 | |||
6870331c34 | |||
6f73b7fc29 | |||
6385830f53 | |||
b705228f4a | |||
73d2da4c47 | |||
b28e7c2149 | |||
38f06683be | |||
62221c9e49 | |||
e9f416f682 | |||
3108a06e89 | |||
470df50dc2 | |||
d8a94e3f5e | |||
47a0efef27 | |||
ca5aebfeff | |||
fe6ffd19b4 | |||
b9a6ddafe9 | |||
ae71d7757e | |||
34ed5da6f0 | |||
2c16f07ae7 | |||
60f25359d1 | |||
975b50a988 | |||
66417dc84a | |||
514f90d602 | |||
086876ad2e | |||
71c179f32f | |||
2399096b07 | |||
0c4d1aaa5f | |||
de8fbb0629 | |||
d7396e578c | |||
7f5b5866f2 | |||
3eb1af406b | |||
79ccac915f | |||
f5232ccea0 | |||
c599a4e6ea | |||
b3230177d8 | |||
f2da4cb8e9 | |||
605bcc7581 | |||
e31d76a641 | |||
97c2118b7e | |||
352977e442 | |||
a46d5fa227 | |||
ade736d8a5 | |||
d7f7e86015 | |||
5e7d1e1dda | |||
fa5f0c7296 | |||
f14d0915c8 | |||
d1e088d229 | |||
aa35da9c56 | |||
f26c3b2606 | |||
00d60be75b | |||
bb5b9fe4c8 | |||
c6af49492e | |||
6158ef5171 | |||
6475b4fc08 | |||
e4e09d63ff | |||
4b07ae0cc3 | |||
49a419cbe2 | |||
5d52fb2460 | |||
f1e09c1507 | |||
f915c52bd0 | |||
dee2e77552 | |||
7953a2a177 | |||
3f0082927e | |||
07ffe05a84 | |||
09ee1cf826 | |||
61bcec98c3 | |||
1c85d436fd | |||
03318026d4 | |||
7a0b142d69 | |||
eb874110f8 | |||
6e0c7bc332 | |||
ee83e61b09 | |||
99b2dc0539 | |||
53f1118181 | |||
2791264a92 | |||
6c2ee0cad3 | |||
20f8c25f55 | |||
0224f55104 | |||
644702ebdf | |||
9377b2be9b | |||
29cbf23142 | |||
a645f71d19 | |||
909d92a7a1 | |||
7ee9089087 | |||
6201c82392 | |||
0faf7d9436 | |||
e69e61117a | |||
25e988d423 | |||
8a3ab895e0 | |||
06a8e3c0ab | |||
c1c1628a38 | |||
022e3354d9 | |||
211cb848b9 | |||
25e233f328 | |||
9bc3d9b29d | |||
15debf339d | |||
56df7d182d | |||
26e1579e35 | |||
a0e290ad6d | |||
092384b2e4 | |||
2dc3654e6c | |||
f37a9efc60 | |||
0c5d323063 | |||
b9f1b8a871 | |||
2f6d8e1701 | |||
9e64584f2d | |||
154aaf71c8 | |||
ac24ab69ff | |||
ad431378f8 | |||
784506cd95 | |||
eb7542b1dd | |||
2bc0d18120 | |||
27f9b8c659 | |||
ae08b40e8b | |||
6ce4cc2d47 | |||
f96d5e0e2f | |||
8ec80b5150 | |||
691c45b2c2 | |||
25f3e27b7f | |||
e2cdd26b7c | |||
eb52deade6 | |||
0e8bdf7fe5 | |||
b2d1cec7f4 | |||
29e19c7e05 | |||
dda252729d | |||
4e4d366a57 | |||
fca53a6725 | |||
80f37583cc | |||
5f763906c3 | |||
f64d36d7ed | |||
dc6975171c | |||
e5d3c664a7 | |||
d11035fd6c | |||
406039aa21 | |||
5e8c4c1455 | |||
92efa89e35 | |||
d806570ee0 | |||
b414ca31b1 | |||
59bfcede97 | |||
9d8e3a3e7b | |||
788477502d | |||
226c01df8b | |||
c57fb0ca67 | |||
745c7ca04a | |||
8ddb86556d | |||
b1ccbf72f3 | |||
f85001cb1a | |||
a7371c0566 | |||
3d9c20cc03 | |||
661065e51f | |||
c1fe033ff3 | |||
3ea45b3cf6 | |||
b385a821d4 | |||
ac1e1f39b7 | |||
41140286e1 | |||
c50313f8b1 | |||
2ad626197f | |||
e927676e3e | |||
4531b7fd6d | |||
5199a810cd | |||
2bdbfb082b | |||
9e6b1a141d | |||
9c3afdbd81 | |||
32669cef22 | |||
cc6d1a35bc | |||
63a36ad5d4 | |||
bcdefca32c | |||
88e69711ba | |||
1ccfd59e65 | |||
a201ae4ea6 | |||
e9029b1414 | |||
19946794f6 | |||
3f15092b3a | |||
838df92217 | |||
54b01e4513 | |||
c065b198d4 | |||
12c0dab881 | |||
7759234ea3 | |||
ad99a349f7 | |||
fdc7e4678c | |||
8d040cc304 | |||
f1c95d03f8 | |||
b55faf2d1c | |||
258f27d96e | |||
8027e541c3 | |||
fc1108ab61 | |||
54bcd41267 | |||
25c19471bb | |||
9e4e646bb1 | |||
95524fa3e9 | |||
07a646aa18 | |||
67fc19dae1 | |||
1070cabf7a | |||
fcc9292e02 | |||
f3d8f15ab1 | |||
4c44c2f1a0 | |||
e7d948049d | |||
0ad0f4d62d | |||
7df6ab1852 | |||
b4d1e04b87 | |||
2e60bb985f | |||
f8fa407a45 | |||
b2085e80f8 | |||
95241d4148 | |||
03490af042 | |||
94aeff7b35 | |||
f5452b8aee | |||
4f02134b6c | |||
52b2c25014 | |||
d8f68640b5 | |||
bb2fc2c32b | |||
582c908dc4 | |||
506284ad1f | |||
e7e8782c28 | |||
3f614cdcef | |||
5f1853bbe3 | |||
99d64cd411 | |||
607a1f25d3 | |||
b047b48738 | |||
e1688f7f12 | |||
5aa7a1f140 | |||
ca141f9860 | |||
d4d14d57ff | |||
1789ae20cf | |||
2e0414f9d5 | |||
52cbf79f39 | |||
e9e06bbf8f | |||
1f16e07a3c | |||
e476d44527 | |||
7a09026b6c | |||
09c724ac53 | |||
7152bb825d | |||
e8d23426d7 | |||
dcb593db07 | |||
3b9a722812 | |||
7cc0c0ccf8 | |||
1304489748 | |||
673a9dd669 | |||
fc15478dfa | |||
c23b5923ab | |||
2d700e83bb | |||
271f210591 | |||
e65dd1939a | |||
76be9092c8 | |||
3394299e8b | |||
5e6f6ac460 | |||
49d676f4f2 | |||
4dd4cea3ed | |||
408a16064c | |||
cfd80dd426 | |||
0fde356f4b | |||
427b36ddaf | |||
1b94e017e6 | |||
b3b2296b06 | |||
9c897745fd | |||
0783c625d0 | |||
a1036956c3 | |||
bfbf2727ea | |||
39766739d2 | |||
776664149e | |||
fef088e39b | |||
31feb2439d | |||
e041b2cb91 | |||
379e7cdf71 | |||
928eccfa23 | |||
9c9d7a31bf | |||
d46ea3a117 | |||
820c6dad7e | |||
acbf897f5d | |||
981ac03855 | |||
c20777f860 | |||
219973930b | |||
79ba63ce85 | |||
922538dc3a | |||
5574059b46 | |||
289a49251e | |||
ef9e9dbae8 | |||
98d86c050b | |||
3f0c31d771 | |||
d7e47e5c14 | |||
cab4a8876b | |||
d19932cc3b | |||
ea9ee4ead1 | |||
667c3cd04d | |||
6334bfdc01 | |||
2016af33b7 | |||
f0a2659b43 | |||
489502a783 | |||
cb6e26513c | |||
3680a751aa | |||
4413785903 | |||
25825f7aeb | |||
73019fc0b0 | |||
6a98e749f9 | |||
a84fccb3ce | |||
f9222d84ea | |||
d7964b645a | |||
c3ae56abd2 | |||
daff548010 | |||
757dbfd6ab | |||
01fdf7b35b | |||
90d4bb0e47 | |||
01faa94a9a | |||
b55700862f | |||
90a44eb5db | |||
fb0760928e | |||
8ce53b8413 | |||
2484028b5a | |||
2fcb4615be | |||
653cf7e25a | |||
aa0b54a054 | |||
8d2755060e | |||
ba9b4ebe70 | |||
b8f46c2523 | |||
153ff5b129 | |||
931301074e | |||
56e5709a35 | |||
7fa97704bd | |||
85b3f6e246 | |||
a0676f4e37 | |||
5a0be0f6f9 | |||
717a2da3de | |||
4a127e617c | |||
3f7cb65393 | |||
cd00813aed | |||
511983c7ff | |||
1e580dbcd6 | |||
824040660b | |||
931c683030 | |||
ff5e56e36c | |||
1c6e88ce61 | |||
08b9a35981 | |||
7db7382c68 | |||
1e81a2de16 | |||
624f67a621 | |||
ffb6c397b8 | |||
dbaff5df85 | |||
75839e60f0 | |||
cd10dbb04d | |||
7c3ac6aeed | |||
d30f4452be | |||
61a05dc347 | |||
9f59e7b6e8 | |||
30f787f507 | |||
a8da01c8ff | |||
9df7cd4659 | |||
c281ac4397 | |||
250cf91032 | |||
fe9a096ab2 | |||
a6e1566f89 | |||
adeb694b2d | |||
50d14e663e | |||
9b8d69042d | |||
b2475081b6 | |||
aef1d8987c | |||
d8f49ff298 | |||
efa243211b | |||
f03a3aadff | |||
16147ae2ba | |||
8e600778ab | |||
292f76aea5 | |||
28c4ccf1f3 | |||
a7641d6fc9 | |||
c7584964fe | |||
02938d1e40 | |||
2baddd1fc8 | |||
2703e8c15e | |||
8468be2690 | |||
d22586cd6e | |||
99feeb9b5e | |||
d3bb1f043d | |||
8d609a82ff | |||
0cbdc265a3 | |||
57a243798b | |||
a811211061 | |||
3d2ff10f80 | |||
ded2d3490f | |||
450b18cf16 | |||
414af6388c | |||
549b0b3784 | |||
fc9ab1a7e6 | |||
44bffc1850 | |||
e9f556f690 | |||
d15566daf7 | |||
badd4cc247 | |||
0a52b3cf80 | |||
26cec517c0 | |||
d546bc257a | |||
e71893c5b6 | |||
77979acc42 | |||
a141e9bfe7 | |||
5d576d6f25 | |||
6142a09283 | |||
7d989faae6 | |||
56a625ae48 | |||
07ce5ddb83 | |||
93472db01b | |||
9db7bcb73c | |||
536cf4cf7b | |||
e21995a36d | |||
67f1149f65 | |||
699bdf5fe0 | |||
e3d76c37db | |||
3d9a320612 | |||
2d352ec14a | |||
386af902a4 | |||
b9d144f24a | |||
32b24164cf | |||
5f2dca4032 | |||
6a0705a73e | |||
f1020fb072 | |||
65525ca2e1 | |||
af53e54aa8 | |||
6975edfb66 | |||
c42c0cb017 | |||
378f52a1ab | |||
b182632723 | |||
8a2d178a4f | |||
81752e9a23 | |||
22eb65358b | |||
2034773df6 | |||
5422518a47 | |||
210e8aa0fa | |||
b0c195c34f | |||
0753882015 | |||
cbdf8c94d7 | |||
6c47e72766 | |||
63294d2fd8 | |||
4825e6876c | |||
d1501da2f1 | |||
3b459d5387 | |||
6a667b0745 | |||
4828a91bd5 | |||
b6614768c4 | |||
30278126f8 | |||
fcf4604153 | |||
332287415e | |||
8e5c4b73a8 | |||
ff12746fe8 | |||
356b61c9ff | |||
a5e949d52e | |||
6191e4ac58 | |||
e7744708d9 | |||
e878bc62ba | |||
e4b5fd3170 | |||
da76386dd4 | |||
3181be0887 | |||
e9141c4970 | |||
ece88f3dda | |||
c8827c5e4b | |||
8800053fa4 | |||
87fa6e02e6 | |||
8b7716ac3c | |||
44e525aec7 | |||
76a51032b3 | |||
e22b036cc9 | |||
e253ccd52d | |||
da5406952c | |||
73a2bb56e2 | |||
011eba8cc9 | |||
07df42d087 | |||
ff932b0cdf | |||
fdb7995cbf | |||
e99e6965ec | |||
65fd73d6c0 | |||
0cb75af583 | |||
cb031bab55 | |||
f8bb544005 | |||
95768f4ea1 | |||
278aa6607d | |||
b6f923399f | |||
c2fe5c456b | |||
5008212739 | |||
aa3d24f991 | |||
3c176d25ed | |||
2eec228c3e | |||
f55319c453 | |||
28ca489fc3 | |||
ea3c9d701b | |||
a8d97aa43b | |||
c56b52d069 | |||
d12fe9dd70 | |||
315c2903c0 | |||
a12d32341c | |||
67b9e1a8cf | |||
dedc5a04e0 | |||
efa2d0eeef | |||
b1f5ca5660 | |||
2666444007 | |||
89035ebb85 | |||
c43207e9bf | |||
0b74f54268 | |||
af923feecd | |||
12ba867ee1 | |||
805dd4f319 | |||
f405662fb8 | |||
43eeb8299e | |||
ebe6af8082 | |||
27e310bfbf | |||
8c340d83ac | |||
8e2a831015 | |||
9e19b3ca91 | |||
5d3f81081a | |||
88e7fde848 | |||
07b6b552be | |||
e54ce5962e | |||
8e42b729e9 | |||
b0ea3f1acd | |||
7bf676020d | |||
99649f588c | |||
1b3c655829 | |||
cfadc91299 | |||
345a5d55e7 | |||
8f97091118 | |||
7e4d3e6d3f | |||
bd27da7aa6 | |||
0abd81f9d1 | |||
98c65e80fe | |||
7336819268 | |||
89ba3bcf56 | |||
9d0350ae22 | |||
4aac68e746 | |||
db5bcfdd11 | |||
25fa05ab20 | |||
6c3d6a5dd9 | |||
5bae286774 | |||
2f5869ea9c | |||
f577878fff | |||
320e824dbf | |||
fbf62244fb | |||
58962f755c | |||
ca0fb7c76f | |||
485e2527fe | |||
d322531dc0 | |||
6969e386e2 | |||
c9c359e226 | |||
add01961f2 | |||
f84b441321 | |||
0f863749f5 | |||
68d28e4a66 | |||
7423bd95c2 | |||
f5e81b2d92 | |||
9915fa83e1 | |||
e98ff91a5b | |||
9b79b441cf | |||
727394de5d | |||
1d041dcbdd | |||
b3ba76614a | |||
d293b36f1e | |||
960b699e3d | |||
2f002be7a8 | |||
f2b0e35dcf | |||
b5910ca5b4 | |||
5e1f019f24 | |||
e047874dbd | |||
688b57578c | |||
411d77643e | |||
5867d39a52 | |||
7eccef5c82 | |||
7afb80864e | |||
2e9c3a48a2 | |||
117928de48 | |||
16974ac0de | |||
8155836a12 | |||
2fb0069214 | |||
3d69e96c9f | |||
7c9decdded | |||
98999f09bb | |||
5819a40a30 | |||
93be6ae6cc | |||
14ccb68cf7 | |||
0ec7da0210 | |||
49552bbdb4 | |||
2aef9a3e63 | |||
11eb31af98 | |||
207199adc9 | |||
92a5a875a4 | |||
e47dba5775 | |||
4dccf8d319 | |||
0919579dd1 | |||
b2d3f5de6a | |||
c39e741618 | |||
f60c3937e5 | |||
5d3aa19170 | |||
b8eb3680c2 | |||
07ae67edaf |
9
.gitignore
vendored
@ -6,4 +6,11 @@ __pycache__/
|
||||
.vscode
|
||||
cache
|
||||
config
|
||||
*.code-workspace
|
||||
*.code-workspace
|
||||
multi_user_updater/
|
||||
|
||||
# sphinx build folder
|
||||
_build
|
||||
|
||||
# ignore generated zip generated from blender_addon_tester
|
||||
*.zip
|
12
.gitlab-ci.yml
Normal file
@ -0,0 +1,12 @@
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
- doc
|
||||
|
||||
|
||||
include:
|
||||
- local: .gitlab/ci/test.gitlab-ci.yml
|
||||
- local: .gitlab/ci/build.gitlab-ci.yml
|
||||
- local: .gitlab/ci/deploy.gitlab-ci.yml
|
||||
- local: .gitlab/ci/doc.gitlab-ci.yml
|
14
.gitlab/ci/build.gitlab-ci.yml
Normal file
@ -0,0 +1,14 @@
|
||||
build:
|
||||
stage: build
|
||||
image: debian:stable-slim
|
||||
script:
|
||||
- rm -rf tests .git .gitignore script
|
||||
artifacts:
|
||||
name: multi_user
|
||||
paths:
|
||||
- multi_user
|
||||
only:
|
||||
refs:
|
||||
- master
|
||||
|
||||
|
23
.gitlab/ci/deploy.gitlab-ci.yml
Normal file
@ -0,0 +1,23 @@
|
||||
deploy:
|
||||
stage: deploy
|
||||
image: slumber/docker-python
|
||||
variables:
|
||||
DOCKER_DRIVER: overlay2
|
||||
DOCKER_TLS_CERTDIR: "/certs"
|
||||
|
||||
services:
|
||||
- docker:19.03.12-dind
|
||||
|
||||
script:
|
||||
- RP_VERSION="$(python scripts/get_replication_version.py)"
|
||||
- VERSION="$(python scripts/get_addon_version.py)"
|
||||
- echo "Building docker image with replication ${RP_VERSION}"
|
||||
- docker build --build-arg replication_version=${RP_VERSION} --build-arg version={VERSION} -t registry.gitlab.com/slumber/multi-user/multi-user-server:${VERSION} ./scripts/docker_server
|
||||
- echo "Pushing to gitlab registry ${VERSION}"
|
||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
||||
- docker tag registry.gitlab.com/slumber/multi-user/multi-user-server:${VERSION} registry.gitlab.com/slumber/multi-user/multi-user-server:${CI_COMMIT_REF_NAME}
|
||||
- docker push registry.gitlab.com/slumber/multi-user/multi-user-server
|
||||
only:
|
||||
refs:
|
||||
- master
|
||||
|
11
.gitlab/ci/doc.gitlab-ci.yml
Normal file
@ -0,0 +1,11 @@
|
||||
pages:
|
||||
stage: doc
|
||||
image: python:3.7-alpine
|
||||
script:
|
||||
- pip install -U sphinx sphinx_rtd_theme
|
||||
- sphinx-build -b html ./docs public
|
||||
artifacts:
|
||||
paths:
|
||||
- public
|
||||
|
||||
|
8
.gitlab/ci/test.gitlab-ci.yml
Normal file
@ -0,0 +1,8 @@
|
||||
test:
|
||||
stage: test
|
||||
image: slumber/blender-addon-testing:latest
|
||||
script:
|
||||
- python3 scripts/test_addon.py
|
||||
only:
|
||||
refs:
|
||||
- master
|
46
.gitlab/issue_templates/Bug.md
Normal file
@ -0,0 +1,46 @@
|
||||
<!---
|
||||
Please read this!
|
||||
|
||||
Before opening a new issue, make sure to search for keywords in the issues
|
||||
filtered by the "bug" label:
|
||||
|
||||
- https://gitlab.com/slumber/multi-user/-/issues?scope=all&utf8=✓&label_name[]=bug
|
||||
|
||||
and verify the issue you're about to submit isn't a duplicate.
|
||||
--->
|
||||
|
||||
### Summary
|
||||
|
||||
(Summarize the bug encountered concisely)
|
||||
|
||||
* Addon version: (your addon-version)
|
||||
* Blender version: (your blender version)
|
||||
* OS: (your os windows/linux/mac)
|
||||
|
||||
|
||||
### Steps to reproduce
|
||||
|
||||
(How one can reproduce the issue - this is very important)
|
||||
|
||||
### Example Project [optionnal]
|
||||
(If possible, please create an example project that exhibits the problematic behavior, and link to it here in the bug report)
|
||||
|
||||
|
||||
### What is the current *bug* behavior?
|
||||
|
||||
(What actually happens)
|
||||
|
||||
|
||||
### Relevant logs and/or screenshots
|
||||
|
||||
(Paste any relevant logs - please use code blocks (```) to format console output,
|
||||
logs, and code as it's tough to read otherwise.)
|
||||
|
||||
|
||||
### Possible fixes [optionnal]
|
||||
|
||||
(If you can, link to the line of code that might be responsible for the problem)
|
||||
|
||||
|
||||
/label ~type::bug
|
||||
/cc @project-manager
|
30
.gitlab/issue_templates/Documentation.md
Normal file
@ -0,0 +1,30 @@
|
||||
### Problem to solve
|
||||
|
||||
<!-- Include the following detail as necessary:
|
||||
* What feature(s) affected?
|
||||
* What docs or doc section affected? Include links or paths.
|
||||
* Is there a problem with a specific document, or a feature/process that's not addressed sufficiently in docs?
|
||||
* Any other ideas or requests?
|
||||
-->
|
||||
|
||||
### Further details
|
||||
|
||||
<!--
|
||||
* Any concepts, procedures, reference info we could add to make it easier to successfully use the multi-user addom?
|
||||
* Include use cases, benefits, and/or goals for this work.
|
||||
-->
|
||||
|
||||
### Proposal
|
||||
|
||||
<!-- Further specifics for how can we solve the problem. -->
|
||||
|
||||
### Who can address the issue
|
||||
|
||||
<!-- What if any special expertise is required to resolve this issue? -->
|
||||
|
||||
### Other links/references
|
||||
|
||||
<!-- E.g. related GitLab issues/MRs -->
|
||||
|
||||
/label ~type::documentation
|
||||
/cc @project-manager
|
18
.gitlab/issue_templates/Feature Proposal.md
Normal file
@ -0,0 +1,18 @@
|
||||
### Problem to solve
|
||||
|
||||
<!-- What problem do we solve? Try to define the who/what/why of the opportunity as a user story. For example, "As a (who), I want (what), so I can (why/value)." -->
|
||||
|
||||
|
||||
### Proposal
|
||||
|
||||
<!-- How are we going to solve the problem?-->
|
||||
|
||||
### Further details
|
||||
|
||||
<!-- Include use cases, benefits, goals, or any other details that will help us understand the problem better. -->
|
||||
|
||||
|
||||
### Links / references
|
||||
|
||||
/label ~type::feature request
|
||||
/cc @project-manager
|
34
.gitlab/issue_templates/Refactoring.md
Normal file
@ -0,0 +1,34 @@
|
||||
## Summary
|
||||
|
||||
<!--
|
||||
Please briefly describe what part of the code base needs to be refactored.
|
||||
-->
|
||||
|
||||
## Improvements
|
||||
|
||||
<!--
|
||||
Explain the benefits of refactoring this code.
|
||||
-->
|
||||
|
||||
## Risks
|
||||
|
||||
<!--
|
||||
Please list features that can break because of this refactoring and how you intend to solve that.
|
||||
-->
|
||||
|
||||
## Involved components
|
||||
|
||||
<!--
|
||||
List files or directories that will be changed by the refactoring.
|
||||
-->
|
||||
|
||||
## Optional: Intended side effects
|
||||
|
||||
<!--
|
||||
If the refactoring involves changes apart from the main improvements (such as a better UI), list them here.
|
||||
It may be a good idea to create separate issues and link them here.
|
||||
-->
|
||||
|
||||
|
||||
/label ~type::refactoring
|
||||
/cc @project-manager
|
3
.gitmodules
vendored
@ -1,3 +0,0 @@
|
||||
[submodule "libs/replication"]
|
||||
path = libs/replication
|
||||
url = git@gitlab.com:slumber/replication.git
|
||||
|
26
CHANGELOG
@ -1,26 +0,0 @@
|
||||
# Changelog
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v0.0.1]
|
||||
### Added
|
||||
- Functionnal chat test
|
||||
|
||||
## [v0.0.2]
|
||||
### Added
|
||||
- Property map shared between clients
|
||||
- Callbacks to push modifications to the blender scene
|
||||
- Observer Pattern for live property monitoring
|
||||
- ID to identify properties owner (For primitive right managment)
|
||||
|
||||
### Removed
|
||||
- Chat mecanism ( Will come back after the complete modularity refactoring)
|
||||
- Unused files and sub-modules
|
||||
|
||||
## [v0.0.5]
|
||||
### Added
|
||||
|
||||
- queued communication between threads
|
||||
- various fixes
|
||||
- multithreaded archiecture
|
||||
- curve, gpencil support
|
||||
- rightmanagment
|
160
CHANGELOG.md
Normal file
@ -0,0 +1,160 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.0.2] - 2020-02-28
|
||||
|
||||
### Added
|
||||
|
||||
- Blender animation features support (alpha).
|
||||
- Action.
|
||||
- Armature (Unstable).
|
||||
- Shape key.
|
||||
- Drivers.
|
||||
- Constraints.
|
||||
- Snap to user timeline tool.
|
||||
- Light probes support (only since 2.83).
|
||||
- Metaballs support.
|
||||
- Improved modifiers support.
|
||||
- Online documentation.
|
||||
- Improved Undo handling.
|
||||
- Improved overall session handling:
|
||||
- Time To Leave : ensure clients/server disconnect automatically on connection lost.
|
||||
- Ping: show clients latency.
|
||||
- Non-blocking connection.
|
||||
- Connection state tracking.
|
||||
- Service communication layer to manage background daemons.
|
||||
|
||||
### Changed
|
||||
|
||||
- UI revamp:
|
||||
- Show users frame.
|
||||
- Expose IPC(inter process communication) port.
|
||||
- New user list.
|
||||
- Progress bar to track connection status.
|
||||
- Right management takes view-layer in account for object selection.
|
||||
- Use a basic BFS approach for replication graph pre-load.
|
||||
- Serialization is now based on marshal (2x performance improvements).
|
||||
- Let pip chose python dependencies install path.
|
||||
|
||||
## [0.0.3] - 2020-07-29
|
||||
|
||||
### Added
|
||||
|
||||
- Auto updater support
|
||||
- Big Performances improvements on Meshes, Gpencils, Actions
|
||||
- Multi-scene workflow support
|
||||
- Render setting synchronization
|
||||
- Kick command
|
||||
- Dedicated server with a basic command set
|
||||
- Administrator session status
|
||||
- Tests
|
||||
- Blender 2.83-2.90 support
|
||||
|
||||
### Changed
|
||||
|
||||
- Config is now stored in blender user preference
|
||||
- Documentation update
|
||||
- Connection protocol
|
||||
- UI revamp:
|
||||
- user localization
|
||||
- repository init
|
||||
|
||||
### Removed
|
||||
|
||||
- Unused strict right management strategy
|
||||
- Legacy config management system
|
||||
|
||||
## [0.1.0] - 2020-10-05
|
||||
|
||||
### Added
|
||||
|
||||
- Dependency graph driven updates [experimental]
|
||||
- Edit Mode updates
|
||||
- Late join mechanism
|
||||
- Sync Axis lock replication
|
||||
- Sync collection offset
|
||||
- Sync camera orthographic scale
|
||||
- Sync custom fonts
|
||||
- Sync sound files
|
||||
- Logging configuration (file output and level)
|
||||
- Object visibility type replication
|
||||
- Optionnal sync for active camera
|
||||
- Curve->Mesh conversion
|
||||
- Mesh->gpencil conversion
|
||||
|
||||
### Changed
|
||||
|
||||
- Auto updater now handle installation from branches
|
||||
- Use uuid for collection loading
|
||||
- Moved session instance to replication package
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent unsupported data types to crash the session
|
||||
- Modifier vertex group assignation
|
||||
- World sync
|
||||
- Snapshot UUID error
|
||||
- The world is not synchronized
|
||||
|
||||
## [0.1.1] - 2020-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Session status widget
|
||||
- Affect dependencies during change owner
|
||||
- Dedicated server managment scripts(@brybalicious)
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored presence.py
|
||||
- Reset button UI icon
|
||||
- Documentation `How to contribute` improvements (@brybalicious)
|
||||
- Documentation `Hosting guide` improvements (@brybalicious)
|
||||
- Show flags are now available from the viewport overlay
|
||||
|
||||
### Fixed
|
||||
|
||||
- Render sync race condition (causing scene errors)
|
||||
- Binary differentials
|
||||
- Hybrid session crashes between Linux/Windows
|
||||
- Materials node default output value
|
||||
- Right selection
|
||||
- Client node rights changed to COMMON after disconnecting from the server
|
||||
- Collection instances selection draw
|
||||
- Packed image save error
|
||||
- Material replication
|
||||
- UI spelling errors (@brybalicious)
|
||||
|
||||
|
||||
## [0.2.0] - 2020-12-17
|
||||
|
||||
### Added
|
||||
|
||||
- Documentation `Troubleshouting` section (@brybalicious)
|
||||
- Documentation `Update` section (@brybalicious)
|
||||
- Documentation `Cloud Hosting Walkthrough` (@brybalicious)
|
||||
- Support DNS name
|
||||
- Sync annotations
|
||||
- Sync volume objects
|
||||
- Sync material node_goups
|
||||
- Sync VSE
|
||||
- Sync grease pencil modifiers
|
||||
- Sync textures (modifier only)
|
||||
- Session status widget
|
||||
- Disconnection popup
|
||||
- Popup with disconnection reason
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved GPencil performances
|
||||
|
||||
### Fixed
|
||||
|
||||
- Texture paint update
|
||||
- Various documentation fixes section (@brybalicious)
|
||||
- Empty and Light object selection highlights
|
||||
- Material renaming
|
||||
- Default material nodes input parameters
|
||||
- blender 2.91 python api compatibility
|
132
README.md
@ -1,108 +1,84 @@
|
||||
# Multi-user blender addon
|
||||
# MULTI-USER for blender
|
||||
|
||||
> Enable real-time collaborative workflow inside blender
|
||||
|
||||
<img src="https://i.imgur.com/X0B7O1Q.gif" width=600>
|
||||
|
||||
|
||||
:warning: Under development, use it at your own risks. Currently tested on Windows platform. :warning:
|
||||
|
||||
This tool aims to allow multiple users to work on the same scene over the network. Based on a Clients / Server architecture, the data-oriented replication schema replicate blender data-blocks across the wire.
|
||||
|
||||
This tool aims to allow multiple users to work on the same scene over the network. Based on a Clients / Server architecture, the data-oriented replication schema replicate blender datablocks across the wire.
|
||||
## Quick installation
|
||||
|
||||
## Installation
|
||||
1. Download latest release [multi_user.zip](https://gitlab.com/slumber/multi-user/-/jobs/artifacts/master/download?job=build).
|
||||
2. Run blender as administrator (dependencies installation).
|
||||
3. Install last_version.zip from your addon preferences.
|
||||
|
||||
1. Download lastest release here.
|
||||
2. Install last_version.zip from your addon preferences
|
||||
[Dependencies](#dependencies) will be automatically added to your blender python during installation.
|
||||
|
||||
## Usage
|
||||
|
||||
Settings are under: `View3D -> Sidebar -> Multiuser`
|
||||
See the [documentation](https://multi-user.readthedocs.io/en/latest/) for details.
|
||||
|
||||
### Before starting
|
||||
## Troubleshooting
|
||||
|
||||
#### 1. User
|
||||
See the [troubleshooting guide](https://multi-user.readthedocs.io/en/latest/getting_started/troubleshooting.html) for tips on the most common issues.
|
||||
|
||||
_All user-related infos fields._
|
||||
## Current development status
|
||||
|
||||

|
||||
Currently, not all data-block are supported for replication over the wire. The following list summarizes the status for each ones.
|
||||
|
||||
- **name**: username.
|
||||
- **color**: color used to represent the user into other user workspaces.
|
||||
| Name | Status | Comment |
|
||||
| ----------- | :----: | :--------------------------------------------------------------------------: |
|
||||
| action | ✔️ | |
|
||||
| armature | ❗ | Not stable |
|
||||
| camera | ✔️ | |
|
||||
| collection | ✔️ | |
|
||||
| curve | ❗ | Nurbs not supported |
|
||||
| gpencil | ✔️ | [Airbrush not supported](https://gitlab.com/slumber/multi-user/-/issues/123) |
|
||||
| image | ✔️ | |
|
||||
| mesh | ✔️ | |
|
||||
| material | ✔️ | |
|
||||
| node_groups | ❗ | Material only |
|
||||
| metaball | ✔️ | |
|
||||
| object | ✔️ | |
|
||||
| textures | ❗ | Supported for modifiers only |
|
||||
| texts | ✔️ | |
|
||||
| scene | ✔️ | |
|
||||
| world | ✔️ | |
|
||||
| lightprobes | ✔️ | |
|
||||
| compositing | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/46) |
|
||||
| texts | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/81) |
|
||||
| nla | ❌ | |
|
||||
| volumes | ✔️ | |
|
||||
| particles | ❌ | [On-going](https://gitlab.com/slumber/multi-user/-/issues/24) |
|
||||
| speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) |
|
||||
| vse | ❗ | Mask and Clip not supported yet |
|
||||
| physics | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) |
|
||||
| libraries | ❗ | Partial |
|
||||
|
||||
#### 2. Network
|
||||
|
||||
_Session-related managment tools_
|
||||
|
||||
:warning: If you host a session over internet, special network configuration is needed :warning:
|
||||
|
||||
| Host | Join |
|
||||
| :------------------------------------------- | :----------------------------- |
|
||||
| asd |
|
||||
|  |  |
|
||||
| Start empty: Cleanup the file before hosting | IP: host ip |
|
||||
| | Port: host port |
|
||||
|
||||
#### 2.1 Advanced
|
||||
|
||||

|
||||
|
||||
**Right strategy** (only host) enable you to choose between a strict and a relaxed pattern:
|
||||
|
||||
- **Strict**: Host is the king, by default the host own each properties, only him can grant modification rights.
|
||||
- **Common**: Each properties are under common rights by default, on selection, a property is only modifiable by the owner.
|
||||
|
||||
_On each strategy, when a user is the owner he can choose to pass his rights to somemone else._
|
||||
|
||||
**Propertie frequency gird** allow to set a custom replication frequency for each type of datablock
|
||||
|
||||
### In-session
|
||||
|
||||
#### Connected users
|
||||
|
||||

|
||||
|
||||
This pannel displays all connected users, including you. The **camera button** allow you to focus on a user.
|
||||
|
||||
#### Properties outliner
|
||||
|
||||

|
||||
|
||||
## Current development statut
|
||||
|
||||
Actually, not all datablock are supported for replication over the wire. The following list summarizes the status for each ones.
|
||||
|
||||
| Name | Statut | Comment |
|
||||
| ---------- | :----------------: | :--------: |
|
||||
| action | :x: | WIP |
|
||||
| armature | :x: | WIP |
|
||||
| camera | :white_check_mark: | |
|
||||
| collection | :white_check_mark: | |
|
||||
| curve | :white_check_mark: | Not tested |
|
||||
| gpencil | :white_check_mark: | |
|
||||
| image | :white_check_mark: | Local only |
|
||||
| mesh | :white_check_mark: | |
|
||||
| material | :white_check_mark: | |
|
||||
| metaball | :x: | |
|
||||
| object | :white_check_mark: | |
|
||||
| scene | :white_check_mark: | |
|
||||
| world | :white_check_mark: | |
|
||||
|
||||
### Performance issues
|
||||
|
||||
Since this addon is writen in pure python for a prototyping purpose, perfomance could be better from all perspective. Soon I will start to port the multi-user addon concept to a blender branch.
|
||||
Since this addon is written in pure python for a research purpose, performances could be better from all perspective.
|
||||
I'm working on it.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependencies | Version | Needed |
|
||||
| ------------ | :-----: | -----: |
|
||||
| ZeroMQ | latest | yes |
|
||||
| msgpack | latest | yes |
|
||||
| PyYAML | latest | yes |
|
||||
| Replication | latest | yes |
|
||||
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork it (<https://gitlab.com/yourname/yourproject/fork>)
|
||||
2. Create your feature branch (`git checkout -b feature/fooBar`)
|
||||
3. Commit your changes (`git commit -am 'Add some fooBar'`)
|
||||
4. Push to the branch (`git push origin feature/fooBar`)
|
||||
5. Create a new Pull Request
|
||||
See [contributing section](https://multi-user.readthedocs.io/en/latest/ways_to_contribute.html) of the documentation.
|
||||
|
||||
Feel free to [join the discord server](https://discord.gg/aBPvGws) to chat, seek help and contribute.
|
||||
|
||||
## Licensing
|
||||
|
||||
See [license](LICENSE)
|
||||
|
||||
|
287
__init__.py
@ -1,287 +0,0 @@
|
||||
bl_info = {
|
||||
"name": "Multi-User",
|
||||
"author": "CUBE CREATIVE",
|
||||
"description": "",
|
||||
"blender": (2, 80, 0),
|
||||
"location": "",
|
||||
"warning": "Unstable addon, use it at your own risks",
|
||||
"category": "Collaboration"
|
||||
}
|
||||
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from bpy.app.handlers import persistent
|
||||
|
||||
from . import environment, utils, presence
|
||||
from .libs.replication.replication.constants import RP_COMMON
|
||||
|
||||
|
||||
# TODO: remove dependency as soon as replication will be installed as a module
|
||||
DEPENDENCIES = {
|
||||
("zmq","zmq"),
|
||||
("msgpack","msgpack"),
|
||||
("yaml","pyyaml"),
|
||||
("jsondiff","jsondiff")
|
||||
}
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.ERROR)
|
||||
|
||||
#TODO: refactor config
|
||||
# UTILITY FUNCTIONS
|
||||
def generate_supported_types():
|
||||
stype_dict = {'supported_types':{}}
|
||||
for type in bl_types.types_to_register():
|
||||
_type = getattr(bl_types, type)
|
||||
props = {}
|
||||
props['bl_delay_refresh']=_type.bl_delay_refresh
|
||||
props['bl_delay_apply']=_type.bl_delay_apply
|
||||
props['use_as_filter'] = False
|
||||
props['icon'] = _type.bl_icon
|
||||
props['auto_push']=_type.bl_automatic_push
|
||||
props['bl_name']=_type.bl_id
|
||||
|
||||
stype_dict['supported_types'][_type.bl_rep_class.__name__] = props
|
||||
|
||||
return stype_dict
|
||||
|
||||
|
||||
def client_list_callback(scene, context):
|
||||
from . import operators
|
||||
from .bl_types.bl_user import BlUser
|
||||
|
||||
items = [(RP_COMMON, RP_COMMON, "")]
|
||||
|
||||
username = bpy.context.window_manager.session.username
|
||||
cli = operators.client
|
||||
if cli:
|
||||
client_keys = cli.list(filter=BlUser)
|
||||
for k in client_keys:
|
||||
name = cli.get(uuid=k).buffer["name"]
|
||||
|
||||
name_desc = name
|
||||
if name == username:
|
||||
name_desc += " (self)"
|
||||
|
||||
items.append((name, name_desc, ""))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def randomColor():
|
||||
r = random.random()
|
||||
v = random.random()
|
||||
b = random.random()
|
||||
return [r, v, b]
|
||||
|
||||
class ReplicatedDatablock(bpy.types.PropertyGroup):
|
||||
'''name = StringProperty() '''
|
||||
type_name: bpy.props.StringProperty()
|
||||
bl_name: bpy.props.StringProperty()
|
||||
bl_delay_refresh: bpy.props.FloatProperty()
|
||||
bl_delay_apply: bpy.props.FloatProperty()
|
||||
use_as_filter: bpy.props.BoolProperty(default=True)
|
||||
auto_push: bpy.props.BoolProperty(default=True)
|
||||
icon: bpy.props.StringProperty()
|
||||
|
||||
class SessionProps(bpy.types.PropertyGroup):
|
||||
username: bpy.props.StringProperty(
|
||||
name="Username",
|
||||
default="user_{}".format(utils.random_string_digits())
|
||||
)
|
||||
ip: bpy.props.StringProperty(
|
||||
name="ip",
|
||||
description='Distant host ip',
|
||||
default="127.0.0.1"
|
||||
)
|
||||
user_uuid: bpy.props.StringProperty(
|
||||
name="user_uuid",
|
||||
default="None"
|
||||
)
|
||||
port: bpy.props.IntProperty(
|
||||
name="port",
|
||||
description='Distant host port',
|
||||
default=5555
|
||||
)
|
||||
add_property_depth: bpy.props.IntProperty(
|
||||
name="add_property_depth",
|
||||
default=1
|
||||
)
|
||||
outliner_filter: bpy.props.StringProperty(name="None")
|
||||
is_admin: bpy.props.BoolProperty(
|
||||
name="is_admin",
|
||||
default=False
|
||||
)
|
||||
init_scene: bpy.props.BoolProperty(
|
||||
name="init_scene",
|
||||
default=True
|
||||
)
|
||||
start_empty: bpy.props.BoolProperty(
|
||||
name="start_empty",
|
||||
default=True
|
||||
)
|
||||
active_object: bpy.props.PointerProperty(
|
||||
name="active_object", type=bpy.types.Object)
|
||||
session_mode: bpy.props.EnumProperty(
|
||||
name='session_mode',
|
||||
description='session mode',
|
||||
items={
|
||||
('HOST', 'hosting', 'host a session'),
|
||||
('CONNECT', 'connexion', 'connect to a session')},
|
||||
default='HOST')
|
||||
right_strategy: bpy.props.EnumProperty(
|
||||
name='right_strategy',
|
||||
description='right strategy',
|
||||
items={
|
||||
('STRICT', 'strict', 'strict right repartition'),
|
||||
('COMMON', 'common', 'relaxed right repartition')},
|
||||
default='COMMON')
|
||||
client_color: bpy.props.FloatVectorProperty(
|
||||
name="client_instance_color",
|
||||
subtype='COLOR',
|
||||
default=randomColor())
|
||||
clients: bpy.props.EnumProperty(
|
||||
name="clients",
|
||||
description="client enum",
|
||||
items=client_list_callback)
|
||||
enable_presence: bpy.props.BoolProperty(
|
||||
name="Presence overlay",
|
||||
description='Enable overlay drawing module',
|
||||
default=True,
|
||||
update=presence.update_presence
|
||||
)
|
||||
presence_show_selected: bpy.props.BoolProperty(
|
||||
name="Show selected objects",
|
||||
description='Enable selection overlay ',
|
||||
default=True,
|
||||
update=presence.update_overlay_settings
|
||||
)
|
||||
presence_show_user: bpy.props.BoolProperty(
|
||||
name="Show users",
|
||||
description='Enable user overlay ',
|
||||
default=True,
|
||||
update=presence.update_overlay_settings
|
||||
)
|
||||
supported_datablock: bpy.props.CollectionProperty(
|
||||
type=ReplicatedDatablock,
|
||||
)
|
||||
session_filter: bpy.props.CollectionProperty(
|
||||
type=ReplicatedDatablock,
|
||||
)
|
||||
filter_owned: bpy.props.BoolProperty(
|
||||
name="filter_owned",
|
||||
description='Show only owned datablocks',
|
||||
default=True
|
||||
)
|
||||
use_select_right: bpy.props.BoolProperty(
|
||||
name="Selection right",
|
||||
description='Change right on selection',
|
||||
default=True
|
||||
)
|
||||
|
||||
def load(self):
|
||||
config = environment.load_config()
|
||||
if "username" in config.keys():
|
||||
self.username = config["username"]
|
||||
self.ip = config["ip"]
|
||||
self.port = config["port"]
|
||||
self.start_empty = config["start_empty"]
|
||||
self.enable_presence = config["enable_presence"]
|
||||
self.client_color = config["client_color"]
|
||||
else:
|
||||
logger.error("Fail to read user config")
|
||||
|
||||
if len(self.supported_datablock)>0:
|
||||
self.supported_datablock.clear()
|
||||
if "supported_types" not in config:
|
||||
config = generate_supported_types()
|
||||
for datablock in config["supported_types"].keys():
|
||||
rep_value = self.supported_datablock.add()
|
||||
rep_value.name = datablock
|
||||
rep_value.type_name = datablock
|
||||
|
||||
config_block = config["supported_types"][datablock]
|
||||
rep_value.bl_delay_refresh = config_block['bl_delay_refresh']
|
||||
rep_value.bl_delay_apply = config_block['bl_delay_apply']
|
||||
rep_value.icon = config_block['icon']
|
||||
rep_value.auto_push = config_block['auto_push']
|
||||
rep_value.bl_name = config_block['bl_name']
|
||||
|
||||
def save(self,context):
|
||||
config = environment.load_config()
|
||||
|
||||
config["username"] = self.username
|
||||
config["ip"] = self.ip
|
||||
config["port"] = self.port
|
||||
config["start_empty"] = self.start_empty
|
||||
config["enable_presence"] = self.enable_presence
|
||||
config["client_color"] = [self.client_color.r,self.client_color.g,self.client_color.b]
|
||||
|
||||
|
||||
for bloc in self.supported_datablock:
|
||||
config_block = config["supported_types"][bloc.type_name]
|
||||
config_block['bl_delay_refresh'] = bloc.bl_delay_refresh
|
||||
config_block['bl_delay_apply'] = bloc.bl_delay_apply
|
||||
config_block['use_as_filter'] = bloc.use_as_filter
|
||||
config_block['icon'] = bloc.icon
|
||||
config_block['auto_push'] = bloc.auto_push
|
||||
config_block['bl_name'] = bloc.bl_name
|
||||
environment.save_config(config)
|
||||
|
||||
|
||||
classes = (
|
||||
ReplicatedDatablock,
|
||||
SessionProps,
|
||||
|
||||
)
|
||||
|
||||
libs = os.path.dirname(os.path.abspath(__file__))+"\\libs\\replication"
|
||||
|
||||
@persistent
|
||||
def load_handler(dummy):
|
||||
import bpy
|
||||
bpy.context.window_manager.session.load()
|
||||
|
||||
def register():
|
||||
if libs not in sys.path:
|
||||
sys.path.append(libs)
|
||||
|
||||
environment.setup(DEPENDENCIES,bpy.app.binary_path_python)
|
||||
|
||||
from . import presence
|
||||
from . import operators
|
||||
from . import ui
|
||||
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
bpy.types.WindowManager.session = bpy.props.PointerProperty(
|
||||
type=SessionProps)
|
||||
bpy.types.ID.uuid = bpy.props.StringProperty(default="")
|
||||
|
||||
bpy.context.window_manager.session.load()
|
||||
|
||||
presence.register()
|
||||
operators.register()
|
||||
ui.register()
|
||||
bpy.app.handlers.load_post.append(load_handler)
|
||||
|
||||
def unregister():
|
||||
from . import presence
|
||||
from . import operators
|
||||
from . import ui
|
||||
|
||||
presence.unregister()
|
||||
ui.unregister()
|
||||
operators.unregister()
|
||||
|
||||
del bpy.types.WindowManager.session
|
||||
|
||||
for cls in reversed(classes):
|
||||
bpy.utils.unregister_class(cls)
|
@ -1,25 +0,0 @@
|
||||
__all__ = [
|
||||
'bl_user',
|
||||
'bl_object',
|
||||
'bl_mesh',
|
||||
'bl_camera',
|
||||
'bl_collection',
|
||||
'bl_curve',
|
||||
'bl_gpencil',
|
||||
'bl_image',
|
||||
'bl_light',
|
||||
'bl_scene',
|
||||
'bl_material',
|
||||
'bl_library',
|
||||
'bl_armature',
|
||||
'bl_action',
|
||||
'bl_world',
|
||||
'bl_metaball'
|
||||
] # Order here defines execution order
|
||||
|
||||
from . import *
|
||||
from ..libs.replication.replication.data import ReplicatedDataFactory
|
||||
|
||||
def types_to_register():
|
||||
return __all__
|
||||
|
@ -1,69 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
from jsondiff import diff
|
||||
|
||||
from .. import utils
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
# WIP
|
||||
|
||||
class BlAction(BlDatablock):
|
||||
def load(self, data, target):
|
||||
utils.dump_anything.load(target, data)
|
||||
|
||||
def construct(self, data):
|
||||
return bpy.data.actions.new(data["name"])
|
||||
|
||||
def load(self, data, target):
|
||||
pass
|
||||
# # find target object
|
||||
# object_ = bpy.context.scene.objects.active
|
||||
# if object_ is None:
|
||||
# raise RuntimeError("Nothing is selected.")
|
||||
# if object_.mode != 'POSE': # object must be in pose mode
|
||||
# raise RuntimeError("Object must be in pose mode.")
|
||||
# if object_.animation_data.action is None:
|
||||
# raise RuntimeError("Object needs an active action.")
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
data = utils.dump_datablock(pointer, 1)
|
||||
|
||||
dumper = utils.dump_anything.Dumper()
|
||||
dumper.depth = 2
|
||||
|
||||
|
||||
data["fcurves"] = []
|
||||
for fcurve in self.pointer.fcurves:
|
||||
fc = {
|
||||
"data_path": fcurve.data_path,
|
||||
"dumped_array_index": fcurve.array_index,
|
||||
"keyframe_points": []
|
||||
}
|
||||
|
||||
for k in fcurve.keyframe_points:
|
||||
fc["keyframe_points"].append(
|
||||
dumper.dump(k)
|
||||
)
|
||||
|
||||
data["fcurves"].append(fc)
|
||||
|
||||
return data
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
self.pointer = bpy.data.actions.get(self.buffer['name'])
|
||||
|
||||
def diff(self):
|
||||
return False
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.actions.get(self.buffer['name'])
|
||||
|
||||
bl_id = "actions"
|
||||
bl_class = bpy.types.Action
|
||||
bl_rep_class = BlAction
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_icon = 'ACTION_TWEAK'
|
@ -1,107 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
from jsondiff import diff
|
||||
|
||||
from ..libs.overrider import Overrider
|
||||
from .. import utils
|
||||
from .. import presence
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
# WIP
|
||||
class BlArmature(BlDatablock):
|
||||
def construct(self, data):
|
||||
return bpy.data.armatures.new(data["name"])
|
||||
|
||||
def load(self, data, target):
|
||||
# Load parent object
|
||||
if data['user'] not in bpy.data.objects.keys():
|
||||
parent_object = bpy.data.objects.new(data['user'], self.pointer)
|
||||
else:
|
||||
parent_object = bpy.data.objects[data['user']]
|
||||
|
||||
is_object_in_master = (data['user_collection'][0] == "Master Collection")
|
||||
#TODO: recursive parent collection loading
|
||||
# Link parent object to the collection
|
||||
if is_object_in_master:
|
||||
parent_collection = bpy.data.scenes[data['user_scene'][0]].collection
|
||||
elif data['user_collection'][0] not in bpy.data.collections.keys():
|
||||
parent_collection = bpy.data.collections.new(data['user_collection'][0])
|
||||
else:
|
||||
parent_collection = bpy.data.collections[data['user_collection'][0]]
|
||||
|
||||
if parent_object.name not in parent_collection.objects:
|
||||
parent_collection.objects.link(parent_object)
|
||||
|
||||
# Link parent collection to the scene master collection
|
||||
if not is_object_in_master and parent_collection.name not in bpy.data.scenes[data['user_scene'][0]].collection.children:
|
||||
bpy.data.scenes[data['user_scene'][0]].collection. children.link(parent_collection)
|
||||
|
||||
|
||||
# utils.dump_anything.load(target, data)
|
||||
# with Overrider(name="bpy_",parent=bpy.context) as bpy_:
|
||||
area, region, rv3d = presence.view3d_find()
|
||||
|
||||
|
||||
|
||||
bpy.context.view_layer.objects.active = parent_object
|
||||
# override = bpy.context.copy()
|
||||
# override['window'] = bpy.data.window_managers[0].windows[0]
|
||||
# override['mode'] = 'EDIT_ARMATURE'
|
||||
# override['window_manager'] = bpy.data.window_managers[0]
|
||||
# override['area'] = area
|
||||
# override['region'] = region
|
||||
# override['screen'] = bpy.data.window_managers[0].windows[0].screen
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for bone in data['bones']:
|
||||
if bone not in self.pointer.edit_bones:
|
||||
new_bone = self.pointer.edit_bones.new(bone)
|
||||
else:
|
||||
new_bone = self.pointer.edit_bones[bone]
|
||||
|
||||
new_bone.tail = data['bones'][bone]['tail_local']
|
||||
new_bone.head = data['bones'][bone]['head_local']
|
||||
new_bone.tail_radius = data['bones'][bone]['tail_radius']
|
||||
new_bone.head_radius = data['bones'][bone]['head_radius']
|
||||
|
||||
if 'parent' in data['bones'][bone]:
|
||||
new_bone.parent = self.pointer.edit_bones[data['bones'][bone]['parent']['name']]
|
||||
new_bone.use_connect = data['bones'][bone]['use_connect']
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# bpy_.mode = 'EDIT_ARMATURE'
|
||||
|
||||
# bpy_.active_object = armature
|
||||
# bpy_.selected_objects = [armature]
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
data = utils.dump_datablock(pointer, 4)
|
||||
|
||||
#get the parent Object
|
||||
object_users = utils.get_datablock_users(pointer)[0]
|
||||
data['user'] = object_users.name
|
||||
|
||||
#get parent collection
|
||||
container_users = utils.get_datablock_users(object_users)
|
||||
data['user_collection'] = [item.name for item in container_users if isinstance(item,bpy.types.Collection)]
|
||||
data['user_scene'] = [item.name for item in container_users if isinstance(item,bpy.types.Scene)]
|
||||
return data
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
self.pointer = bpy.data.armatures.get(self.buffer['name'])
|
||||
|
||||
def diff(self):
|
||||
False
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.armatures.get(self.buffer['name'])
|
||||
|
||||
bl_id = "armatures"
|
||||
bl_class = bpy.types.Armature
|
||||
bl_rep_class = BlArmature
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 0
|
||||
bl_automatic_push = True
|
||||
bl_icon = 'ARMATURE_DATA'
|
@ -1,68 +0,0 @@
|
||||
from jsondiff import diff
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
|
||||
from .. import utils
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
|
||||
class BlCamera(BlDatablock):
|
||||
def load(self, data, target):
|
||||
utils.dump_anything.load(target, data)
|
||||
|
||||
dof_settings = data.get('dof')
|
||||
|
||||
# DOF settings
|
||||
if dof_settings:
|
||||
utils.dump_anything.load(target.dof, dof_settings)
|
||||
|
||||
def construct(self, data):
|
||||
return bpy.data.cameras.new(data["name"])
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
|
||||
dumper = utils.dump_anything.Dumper()
|
||||
dumper.depth = 2
|
||||
dumper.include_filter = [
|
||||
"name",
|
||||
'type',
|
||||
'lens',
|
||||
'lens_unit',
|
||||
'shift_x',
|
||||
'shift_y',
|
||||
'clip_start',
|
||||
'clip_end',
|
||||
'dof',
|
||||
'use_dof',
|
||||
'sensor_fit',
|
||||
'sensor_width',
|
||||
'focus_object',
|
||||
'focus_distance',
|
||||
'aperture_fstop',
|
||||
'aperture_blades',
|
||||
'aperture_rotation',
|
||||
'aperture_ratio',
|
||||
]
|
||||
return dumper.dump(pointer)
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
self.pointer = bpy.data.cameras.get(self.buffer['name'])
|
||||
|
||||
def diff(self):
|
||||
return (self.bl_diff() or
|
||||
len(diff(self.dump(pointer=self.pointer), self.buffer)))
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.cameras.get(self.buffer['name'])
|
||||
|
||||
|
||||
bl_id = "cameras"
|
||||
bl_class = bpy.types.Camera
|
||||
bl_rep_class = BlCamera
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_icon = 'CAMERA_DATA'
|
@ -1,71 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
|
||||
from .. import utils
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
|
||||
class BlCollection(BlDatablock):
|
||||
def construct(self,data):
|
||||
return bpy.data.collections.new(data["name"])
|
||||
|
||||
def load(self, data, target):
|
||||
# Load other meshes metadata
|
||||
# dump_anything.load(target, data)
|
||||
|
||||
# link objects
|
||||
for object in data["objects"]:
|
||||
if object not in target.objects.keys():
|
||||
target.objects.link(bpy.data.objects[object])
|
||||
|
||||
for object in target.objects.keys():
|
||||
if object not in data["objects"]:
|
||||
target.objects.unlink(bpy.data.objects[object])
|
||||
|
||||
# Link childrens
|
||||
for collection in data["children"]:
|
||||
if collection not in target.children.keys():
|
||||
# if bpy.data.collections.find(collection) == -1:
|
||||
target.children.link(
|
||||
bpy.data.collections[collection])
|
||||
|
||||
for collection in target.children.keys():
|
||||
if collection not in data["children"]:
|
||||
target.collection.children.unlink(
|
||||
bpy.data.collections[collection])
|
||||
|
||||
utils.dump_anything.load(target, data)
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
return utils.dump_datablock(pointer, 4)
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
self.pointer = bpy.data.collections.get(self.buffer['name'])
|
||||
|
||||
def diff(self):
|
||||
return (self.bl_diff() or
|
||||
len(self.pointer.objects) != len(self.buffer['objects']) or
|
||||
len(self.pointer.children) != len(self.buffer['children']))
|
||||
|
||||
def resolve_dependencies(self):
|
||||
deps = []
|
||||
|
||||
for child in self.pointer.children:
|
||||
deps.append(child)
|
||||
for object in self.pointer.objects:
|
||||
deps.append(object)
|
||||
|
||||
return deps
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.collections.get(self.buffer['name'])
|
||||
|
||||
bl_id = "collections"
|
||||
bl_icon = 'FILE_FOLDER'
|
||||
bl_class = bpy.types.Collection
|
||||
bl_rep_class = BlCollection
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
@ -1,65 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
from jsondiff import diff
|
||||
|
||||
from .. import utils
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
class BlCurve(BlDatablock):
|
||||
def construct(self, data):
|
||||
return bpy.data.curves.new(data["name"], 'CURVE')
|
||||
|
||||
def load(self, data, target):
|
||||
utils.dump_anything.load(target, data)
|
||||
|
||||
target.splines.clear()
|
||||
# load splines
|
||||
for spline in data['splines']:
|
||||
new_spline = target.splines.new(data['splines'][spline]['type'])
|
||||
utils.dump_anything.load(new_spline, data['splines'][spline])
|
||||
|
||||
# Load curve geometry data
|
||||
for bezier_point_index in data['splines'][spline]["bezier_points"]:
|
||||
if bezier_point_index != 0:
|
||||
new_spline.bezier_points.add(1)
|
||||
utils.dump_anything.load(
|
||||
new_spline.bezier_points[bezier_point_index], data['splines'][spline]["bezier_points"][bezier_point_index])
|
||||
|
||||
for point_index in data['splines'][spline]["points"]:
|
||||
new_spline.points.add(1)
|
||||
utils.dump_anything.load(
|
||||
new_spline.points[point_index], data['splines'][spline]["points"][point_index])
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
data = utils.dump_datablock(pointer, 1)
|
||||
data['splines'] = {}
|
||||
|
||||
dumper = utils.dump_anything.Dumper()
|
||||
dumper.depth = 3
|
||||
|
||||
for index,spline in enumerate(pointer.splines):
|
||||
spline_data = {}
|
||||
spline_data['points'] = dumper.dump(spline.points)
|
||||
spline_data['bezier_points'] = dumper.dump(spline.bezier_points)
|
||||
spline_data['type'] = dumper.dump(spline.type)
|
||||
data['splines'][index] = spline_data
|
||||
return data
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
self.pointer = bpy.data.curves.get(self.buffer['name'])
|
||||
|
||||
def diff(self):
|
||||
return (self.bl_diff() or
|
||||
len(diff(self.dump(pointer=self.pointer), self.buffer)) > 1)
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.curves.get(self.buffer['name'])
|
||||
bl_id = "curves"
|
||||
bl_class = bpy.types.Curve
|
||||
bl_rep_class = BlCurve
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_icon = 'CURVE_DATA'
|
@ -1,62 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
|
||||
from .. import utils
|
||||
from ..libs.replication.replication.data import ReplicatedDatablock
|
||||
from ..libs.replication.replication.constants import UP
|
||||
|
||||
|
||||
class BlDatablock(ReplicatedDatablock):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
pointer = kwargs.get('pointer', None)
|
||||
buffer = self.buffer
|
||||
|
||||
# TODO: use is_library_indirect
|
||||
self.is_library = (pointer and hasattr(pointer, 'library') and
|
||||
pointer.library) or \
|
||||
(buffer and 'library' in buffer)
|
||||
|
||||
if self.is_library:
|
||||
self.load = self.load_library
|
||||
self.dump = self.dump_library
|
||||
self.diff = self.diff_library
|
||||
|
||||
if self.pointer and hasattr(self.pointer, 'uuid'):
|
||||
self.pointer.uuid = self.uuid
|
||||
|
||||
def library_apply(self):
|
||||
"""Apply stored data
|
||||
"""
|
||||
# UP in case we want to reset our pointer data
|
||||
self.state = UP
|
||||
|
||||
def bl_diff(self):
|
||||
"""Generic datablock diff"""
|
||||
return self.pointer.name != self.buffer['name']
|
||||
|
||||
def construct_library(self, data):
|
||||
return None
|
||||
|
||||
def load_library(self, data, target):
|
||||
pass
|
||||
|
||||
def dump_library(self, pointer=None):
|
||||
return utils.dump_datablock(pointer, 1)
|
||||
|
||||
def diff_library(self):
|
||||
return False
|
||||
|
||||
def resolve_dependencies_library(self):
|
||||
return [self.pointer.library]
|
||||
|
||||
def resolve_dependencies(self):
|
||||
dependencies = []
|
||||
|
||||
if hasattr(self.pointer,'animation_data') and self.pointer.animation_data:
|
||||
dependencies.append(self.pointer.animation_data.action)
|
||||
|
||||
return dependencies
|
||||
|
||||
def is_valid(self):
|
||||
raise NotImplementedError
|
@ -1,91 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
from jsondiff import diff
|
||||
|
||||
from .. import utils
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
|
||||
def load_gpencil_layer(target=None, data=None, create=False):
|
||||
|
||||
utils.dump_anything.load(target, data)
|
||||
|
||||
for frame in data["frames"]:
|
||||
try:
|
||||
tframe = target.frames[frame]
|
||||
except:
|
||||
tframe = target.frames.new(frame)
|
||||
utils.dump_anything.load(tframe, data["frames"][frame])
|
||||
for stroke in data["frames"][frame]["strokes"]:
|
||||
try:
|
||||
tstroke = tframe.strokes[stroke]
|
||||
except:
|
||||
tstroke = tframe.strokes.new()
|
||||
utils.dump_anything.load(
|
||||
tstroke, data["frames"][frame]["strokes"][stroke])
|
||||
|
||||
for point in data["frames"][frame]["strokes"][stroke]["points"]:
|
||||
p = data["frames"][frame]["strokes"][stroke]["points"][point]
|
||||
|
||||
tstroke.points.add(1)
|
||||
tpoint = tstroke.points[len(tstroke.points)-1]
|
||||
|
||||
utils.dump_anything.load(tpoint, p)
|
||||
|
||||
|
||||
class BlGpencil(BlDatablock):
|
||||
def construct(self, data):
|
||||
return bpy.data.grease_pencils.new(data["name"])
|
||||
|
||||
def load(self, data, target):
|
||||
for layer in target.layers:
|
||||
target.layers.remove(layer)
|
||||
|
||||
if "layers" in data.keys():
|
||||
for layer in data["layers"]:
|
||||
if layer not in target.layers.keys():
|
||||
gp_layer = target.layers.new(data["layers"][layer]["info"])
|
||||
else:
|
||||
gp_layer = target.layers[layer]
|
||||
load_gpencil_layer(
|
||||
target=gp_layer, data=data["layers"][layer], create=True)
|
||||
|
||||
utils.dump_anything.load(target, data)
|
||||
|
||||
target.materials.clear()
|
||||
if "materials" in data.keys():
|
||||
for mat in data['materials']:
|
||||
target.materials.append(bpy.data.materials[mat])
|
||||
|
||||
def diff(self):
|
||||
return (self.bl_diff())
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
data = utils.dump_datablock(pointer, 2)
|
||||
utils.dump_datablock_attibutes(
|
||||
pointer, ['layers'], 9, data)
|
||||
return data
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
self.pointer = bpy.data.grease_pencils.get(self.buffer['name'])
|
||||
|
||||
def resolve_dependencies(self):
|
||||
deps = []
|
||||
|
||||
for material in self.pointer.materials:
|
||||
deps.append(material)
|
||||
|
||||
return deps
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.grease_pencils.get(self.buffer['name'])
|
||||
|
||||
bl_id = "grease_pencils"
|
||||
bl_class = bpy.types.GreasePencil
|
||||
bl_rep_class = BlGpencil
|
||||
bl_delay_refresh = 5
|
||||
bl_delay_apply = 5
|
||||
bl_automatic_push = True
|
||||
bl_icon = 'GREASEPENCIL'
|
@ -1,76 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
import os
|
||||
|
||||
from .. import utils, environment
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
def dump_image(image):
|
||||
pixels = None
|
||||
if image.source == "GENERATED":
|
||||
img_name = "{}.png".format(image.name)
|
||||
|
||||
image.filepath_raw = os.path.join(environment.CACHE_DIR, img_name)
|
||||
image.file_format = "PNG"
|
||||
image.save()
|
||||
|
||||
if image.source == "FILE":
|
||||
image.save()
|
||||
file = open(image.filepath_raw, "rb")
|
||||
pixels = file.read()
|
||||
file.close()
|
||||
else:
|
||||
raise ValueError()
|
||||
return pixels
|
||||
|
||||
class BlImage(BlDatablock):
|
||||
def construct(self, data):
|
||||
return bpy.data.images.new(
|
||||
name=data['name'],
|
||||
width=data['size'][0],
|
||||
height=data['size'][1]
|
||||
)
|
||||
|
||||
def load(self, data, target):
|
||||
image = target
|
||||
|
||||
img_name = "{}.png".format(image.name)
|
||||
|
||||
img_path = os.path.join(environment.CACHE_DIR, img_name)
|
||||
|
||||
file = open(img_path, 'wb')
|
||||
file.write(data["pixels"])
|
||||
file.close()
|
||||
|
||||
image.source = 'FILE'
|
||||
image.filepath = img_path
|
||||
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
data = {}
|
||||
data['pixels'] = dump_image(pointer)
|
||||
utils.dump_datablock_attibutes(pointer, [], 2, data)
|
||||
data = utils.dump_datablock_attibutes(
|
||||
pointer,
|
||||
["name", 'size', 'height', 'alpha', 'float_buffer', 'filepath', 'source'],
|
||||
2,
|
||||
data)
|
||||
return data
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
self.pointer = bpy.data.images.get(self.buffer['name'])
|
||||
|
||||
def diff(self):
|
||||
return False
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.images.get(self.buffer['name'])
|
||||
bl_id = "images"
|
||||
bl_class = bpy.types.Image
|
||||
bl_rep_class = BlImage
|
||||
bl_delay_refresh = 0
|
||||
bl_delay_apply = 0
|
||||
bl_automatic_push = False
|
||||
bl_icon = 'IMAGE_DATA'
|
@ -1,37 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
from jsondiff import diff
|
||||
|
||||
from .. import utils
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
|
||||
class BlLibrary(BlDatablock):
|
||||
def construct(self, data):
|
||||
with bpy.data.libraries.load(filepath=data["filepath"], link=True) as (sourceData, targetData):
|
||||
targetData = sourceData
|
||||
return sourceData
|
||||
def load(self, data, target):
|
||||
pass
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
return utils.dump_datablock(pointer, 1)
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
self.pointer = bpy.data.libraries.get(self.buffer['name'])
|
||||
|
||||
def diff(self):
|
||||
return (self.bl_diff())
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.libraries.get(self.buffer['name'])
|
||||
|
||||
bl_id = "libraries"
|
||||
bl_class = bpy.types.Library
|
||||
bl_rep_class = BlLibrary
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_icon = 'LIBRARY_DATA_DIRECT'
|
@ -1,60 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
from jsondiff import diff
|
||||
|
||||
from .. import utils
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
|
||||
class BlLight(BlDatablock):
|
||||
def construct(self, data):
|
||||
return bpy.data.lights.new(data["name"], data["type"])
|
||||
|
||||
def load(self, data, target):
|
||||
utils.dump_anything.load(target, data)
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
dumper = utils.dump_anything.Dumper()
|
||||
dumper.depth = 3
|
||||
dumper.include_filter = [
|
||||
"name",
|
||||
"type",
|
||||
"color",
|
||||
"energy",
|
||||
"specular_factor",
|
||||
"uuid",
|
||||
"shadow_soft_size",
|
||||
"use_custom_distance",
|
||||
"cutoff_distance",
|
||||
"use_shadow",
|
||||
"shadow_buffer_clip_start",
|
||||
"shadow_buffer_soft",
|
||||
"shadow_buffer_bias",
|
||||
"shadow_buffer_bleed_bias",
|
||||
"contact_shadow_distance",
|
||||
"contact_shadow_soft_size",
|
||||
"contact_shadow_bias",
|
||||
"contact_shadow_thickness"
|
||||
]
|
||||
data = dumper.dump(pointer)
|
||||
return data
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
self.pointer = bpy.data.lights.get(self.buffer['name'])
|
||||
|
||||
def diff(self):
|
||||
return (self.bl_diff() or
|
||||
len(diff(self.dump(pointer=self.pointer), self.buffer)) > 1)
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.lights.get(self.buffer['name'])
|
||||
|
||||
bl_id = "lights"
|
||||
bl_class = bpy.types.Light
|
||||
bl_rep_class = BlLight
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_icon = 'LIGHT_DATA'
|
@ -1,158 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
from jsondiff import diff
|
||||
|
||||
from .. import utils
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
|
||||
def load_node(target_node_tree, source):
|
||||
target_node = target_node_tree.nodes.get(source["name"])
|
||||
|
||||
if target_node is None:
|
||||
node_type = source["bl_idname"]
|
||||
|
||||
target_node = target_node_tree.nodes.new(type=node_type)
|
||||
|
||||
utils.dump_anything.load(
|
||||
target_node, source)
|
||||
|
||||
if source['type'] == 'TEX_IMAGE':
|
||||
target_node.image = bpy.data.images[source['image']['name']]
|
||||
|
||||
for input in source["inputs"]:
|
||||
if hasattr(target_node.inputs[input], "default_value"):
|
||||
target_node.inputs[input].default_value = source["inputs"][input]["default_value"]
|
||||
|
||||
|
||||
def load_link(target_node_tree, source):
|
||||
input_socket = target_node_tree.nodes[source['to_node']
|
||||
['name']].inputs[source['to_socket']['name']]
|
||||
output_socket = target_node_tree.nodes[source['from_node']
|
||||
['name']].outputs[source['from_socket']['name']]
|
||||
|
||||
target_node_tree.links.new(input_socket, output_socket)
|
||||
|
||||
|
||||
class BlMaterial(BlDatablock):
|
||||
def construct(self, data):
|
||||
return bpy.data.materials.new(data["name"])
|
||||
|
||||
def load(self, data, target):
|
||||
if data['is_grease_pencil']:
|
||||
if not target.is_grease_pencil:
|
||||
bpy.data.materials.create_gpencil_data(target)
|
||||
|
||||
utils.dump_anything.load(
|
||||
target.grease_pencil, data['grease_pencil'])
|
||||
|
||||
utils.load_dict(data['grease_pencil'], target.grease_pencil)
|
||||
|
||||
elif data["use_nodes"]:
|
||||
if target.node_tree is None:
|
||||
target.use_nodes = True
|
||||
|
||||
target.node_tree.nodes.clear()
|
||||
|
||||
# Load nodes
|
||||
for node in data["node_tree"]["nodes"]:
|
||||
load_node(target.node_tree, data["node_tree"]["nodes"][node])
|
||||
|
||||
# Load nodes links
|
||||
target.node_tree.links.clear()
|
||||
|
||||
for link in data["node_tree"]["links"]:
|
||||
load_link(target.node_tree, data["node_tree"]["links"][link])
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
mat_dumper = utils.dump_anything.Dumper()
|
||||
mat_dumper.depth = 2
|
||||
mat_dumper.exclude_filter = [
|
||||
"preview",
|
||||
"original",
|
||||
"uuid",
|
||||
"users",
|
||||
"alpha_threshold",
|
||||
"line_color",
|
||||
"view_center",
|
||||
]
|
||||
node_dumper = utils.dump_anything.Dumper()
|
||||
node_dumper.depth = 1
|
||||
node_dumper.exclude_filter = [
|
||||
"dimensions",
|
||||
"select",
|
||||
"bl_height_min",
|
||||
"bl_height_max",
|
||||
"bl_width_min",
|
||||
"bl_width_max",
|
||||
"bl_width_default",
|
||||
"hide",
|
||||
"show_options",
|
||||
"show_tetxures",
|
||||
"show_preview",
|
||||
"outputs",
|
||||
"width_hidden"
|
||||
]
|
||||
input_dumper = utils.dump_anything.Dumper()
|
||||
input_dumper.depth = 2
|
||||
input_dumper.include_filter = ["default_value"]
|
||||
links_dumper = utils.dump_anything.Dumper()
|
||||
links_dumper.depth = 3
|
||||
links_dumper.exclude_filter = ["dimensions"]
|
||||
data = mat_dumper.dump(pointer)
|
||||
|
||||
if pointer.use_nodes:
|
||||
nodes = {}
|
||||
|
||||
for node in pointer.node_tree.nodes:
|
||||
nodes[node.name] = node_dumper.dump(node)
|
||||
|
||||
if hasattr(node, 'inputs'):
|
||||
nodes[node.name]['inputs'] = {}
|
||||
|
||||
for i in node.inputs:
|
||||
|
||||
if hasattr(i, 'default_value'):
|
||||
nodes[node.name]['inputs'][i.name] = input_dumper.dump(
|
||||
i)
|
||||
data["node_tree"]['nodes'] = nodes
|
||||
data["node_tree"]["links"] = links_dumper.dump(pointer.node_tree.links)
|
||||
|
||||
elif pointer.is_grease_pencil:
|
||||
utils.dump_datablock_attibutes(pointer, ["grease_pencil"], 3, data)
|
||||
return data
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
self.pointer = bpy.data.materials.get(self.buffer['name'])
|
||||
|
||||
def diff(self):
|
||||
diff_rev = diff(self.dump(pointer=self.pointer), self.buffer)
|
||||
return (self.bl_diff() or
|
||||
len(diff_rev.keys()) > 0)
|
||||
|
||||
def resolve_dependencies(self):
|
||||
# TODO: resolve node group deps
|
||||
deps = []
|
||||
|
||||
if self.pointer.use_nodes:
|
||||
for node in self.pointer.node_tree.nodes:
|
||||
if node.type == 'TEX_IMAGE':
|
||||
deps.append(node.image)
|
||||
if self.is_library:
|
||||
deps.append(self.pointer.library)
|
||||
|
||||
return deps
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.materials.get(self.buffer['name'])
|
||||
|
||||
|
||||
bl_id = "materials"
|
||||
bl_class = bpy.types.Material
|
||||
bl_rep_class = BlMaterial
|
||||
bl_delay_refresh = 10
|
||||
bl_delay_apply = 10
|
||||
bl_automatic_push = True
|
||||
bl_icon = 'MATERIAL_DATA'
|
@ -1,181 +0,0 @@
|
||||
import bpy
|
||||
import bmesh
|
||||
import mathutils
|
||||
|
||||
from .. import utils
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
def dump_mesh(mesh, data={}):
|
||||
import bmesh
|
||||
|
||||
mesh_data = data
|
||||
mesh_buffer = bmesh.new()
|
||||
|
||||
mesh_buffer.from_mesh(mesh)
|
||||
|
||||
uv_layer = mesh_buffer.loops.layers.uv.verify()
|
||||
bevel_layer = mesh_buffer.verts.layers.bevel_weight.verify()
|
||||
skin_layer = mesh_buffer.verts.layers.skin.verify()
|
||||
|
||||
verts = {}
|
||||
for vert in mesh_buffer.verts:
|
||||
v = {}
|
||||
v["co"] = list(vert.co)
|
||||
|
||||
# vert metadata
|
||||
v['bevel'] = vert[bevel_layer]
|
||||
v['normal'] = list(vert.normal)
|
||||
# v['skin'] = list(vert[skin_layer])
|
||||
|
||||
verts[str(vert.index)] = v
|
||||
|
||||
mesh_data["verts"] = verts
|
||||
|
||||
edges = {}
|
||||
for edge in mesh_buffer.edges:
|
||||
e = {}
|
||||
e["verts"] = [edge.verts[0].index, edge.verts[1].index]
|
||||
|
||||
# Edge metadata
|
||||
e["smooth"] = edge.smooth
|
||||
|
||||
edges[edge.index] = e
|
||||
mesh_data["edges"] = edges
|
||||
|
||||
faces = {}
|
||||
for face in mesh_buffer.faces:
|
||||
f = {}
|
||||
fverts = []
|
||||
for vert in face.verts:
|
||||
fverts.append(vert.index)
|
||||
|
||||
f["verts"] = fverts
|
||||
f["material_index"] = face.material_index
|
||||
f["smooth"] = face.smooth
|
||||
f["normal"] = list(face.normal)
|
||||
f["index"] = face.index
|
||||
|
||||
uvs = []
|
||||
# Face metadata
|
||||
for loop in face.loops:
|
||||
loop_uv = loop[uv_layer]
|
||||
|
||||
uvs.append(list(loop_uv.uv))
|
||||
|
||||
f["uv"] = uvs
|
||||
faces[face.index] = f
|
||||
|
||||
mesh_data["faces"] = faces
|
||||
|
||||
uv_layers = []
|
||||
for uv_layer in mesh.uv_layers:
|
||||
uv_layers.append(uv_layer.name)
|
||||
|
||||
mesh_data["uv_layers"] = uv_layers
|
||||
return mesh_data
|
||||
|
||||
class BlMesh(BlDatablock):
|
||||
def construct(self, data):
|
||||
return bpy.data.meshes.new(data["name"])
|
||||
|
||||
def load(self, data, target):
|
||||
if not target or not target.is_editmode:
|
||||
# 1 - LOAD MATERIAL SLOTS
|
||||
material_to_load = []
|
||||
material_to_load = utils.revers(data["materials"])
|
||||
target.materials.clear()
|
||||
# SLots
|
||||
i = 0
|
||||
|
||||
for m in data["material_list"]:
|
||||
target.materials.append(bpy.data.materials[m])
|
||||
|
||||
# 2 - LOAD GEOMETRY
|
||||
mesh_buffer = bmesh.new()
|
||||
|
||||
for i in data["verts"]:
|
||||
v = mesh_buffer.verts.new(data["verts"][i]["co"])
|
||||
v.normal = data["verts"][i]["normal"]
|
||||
mesh_buffer.verts.ensure_lookup_table()
|
||||
|
||||
for i in data["edges"]:
|
||||
verts = mesh_buffer.verts
|
||||
v1 = data["edges"][i]["verts"][0]
|
||||
v2 = data["edges"][i]["verts"][1]
|
||||
edge = mesh_buffer.edges.new([verts[v1], verts[v2]])
|
||||
edge.smooth = data["edges"][i]["smooth"]
|
||||
for p in data["faces"]:
|
||||
verts = []
|
||||
for v in data["faces"][p]["verts"]:
|
||||
verts.append(mesh_buffer.verts[v])
|
||||
|
||||
if len(verts) > 0:
|
||||
f = mesh_buffer.faces.new(verts)
|
||||
|
||||
uv_layer = mesh_buffer.loops.layers.uv.verify()
|
||||
|
||||
f.smooth = data["faces"][p]["smooth"]
|
||||
f.normal = data["faces"][p]["normal"]
|
||||
f.index = data["faces"][p]["index"]
|
||||
f.material_index = data["faces"][p]['material_index']
|
||||
# UV loading
|
||||
for i, loop in enumerate(f.loops):
|
||||
loop_uv = loop[uv_layer]
|
||||
loop_uv.uv = data["faces"][p]["uv"][i]
|
||||
mesh_buffer.faces.ensure_lookup_table()
|
||||
mesh_buffer.to_mesh(target)
|
||||
|
||||
# 3 - LOAD METADATA
|
||||
# uv's
|
||||
for uv_layer in data['uv_layers']:
|
||||
target.uv_layers.new(name=uv_layer)
|
||||
|
||||
bevel_layer = mesh_buffer.verts.layers.bevel_weight.verify()
|
||||
skin_layer = mesh_buffer.verts.layers.skin.verify()
|
||||
|
||||
utils.dump_anything.load(target, data)
|
||||
|
||||
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
|
||||
data = utils.dump_datablock(pointer, 2)
|
||||
data = dump_mesh(pointer, data)
|
||||
# Fix material index
|
||||
m_list = []
|
||||
for material in pointer.materials:
|
||||
if material:
|
||||
m_list.append(material.name)
|
||||
|
||||
data['material_list'] = m_list
|
||||
|
||||
return data
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
self.pointer = bpy.data.meshes.get(self.buffer['name'])
|
||||
|
||||
def diff(self):
|
||||
return (self.bl_diff() or
|
||||
len(self.pointer.vertices) != len(self.buffer['verts']) or
|
||||
len(self.pointer.materials) != len(self.buffer['materials']))
|
||||
|
||||
def resolve_dependencies(self):
|
||||
deps = []
|
||||
|
||||
for material in self.pointer.materials:
|
||||
deps.append(material)
|
||||
|
||||
return deps
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.meshes.get(self.buffer['name'])
|
||||
|
||||
bl_id = "meshes"
|
||||
bl_class = bpy.types.Mesh
|
||||
bl_rep_class = BlMesh
|
||||
bl_delay_refresh = 10
|
||||
bl_delay_apply = 10
|
||||
bl_automatic_push = True
|
||||
bl_icon = 'MESH_DATA'
|
@ -1,48 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
from jsondiff import diff
|
||||
|
||||
from .. import utils
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
|
||||
class BlMetaball(BlDatablock):
|
||||
def construct(self, data):
|
||||
return bpy.data.metaballs.new(data["name"])
|
||||
|
||||
def load(self, data, target):
|
||||
utils.dump_anything.load(target, data)
|
||||
|
||||
target.elements.clear()
|
||||
for element in data["elements"]:
|
||||
new_element = target.elements.new(type=data["elements"][element]['type'])
|
||||
utils.dump_anything.load(new_element, data["elements"][element])
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
dumper = utils.dump_anything.Dumper()
|
||||
dumper.depth = 3
|
||||
dumper.exclude_filter = ["is_editmode"]
|
||||
|
||||
data = dumper.dump(pointer)
|
||||
return data
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
self.pointer = bpy.data.metaballs.get(self.buffer['name'])
|
||||
|
||||
def diff(self):
|
||||
rev = diff(self.dump(pointer=self.pointer), self.buffer)
|
||||
return (self.bl_diff() or
|
||||
len(rev) > 0)
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.metaballs.get(self.buffer['name'])
|
||||
|
||||
bl_id = "metaballs"
|
||||
bl_class = bpy.types.MetaBall
|
||||
bl_rep_class = BlMetaball
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_icon = 'META_BALL'
|
@ -1,135 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
|
||||
from .. import utils
|
||||
from .bl_datablock import BlDatablock
|
||||
from jsondiff import diff
|
||||
|
||||
|
||||
class BlObject(BlDatablock):
|
||||
def construct(self, data):
|
||||
pointer = None
|
||||
|
||||
if self.is_library:
|
||||
with bpy.data.libraries.load(filepath=bpy.data.libraries[self.buffer['library']].filepath, link=True) as (sourceData, targetData):
|
||||
targetData.objects = [
|
||||
name for name in sourceData.objects if name == self.buffer['name']]
|
||||
|
||||
return targetData.objects[self.buffer['name']]
|
||||
|
||||
# Object specific constructor...
|
||||
if "data" not in data:
|
||||
pass
|
||||
elif data["data"] in bpy.data.meshes.keys():
|
||||
pointer = bpy.data.meshes[data["data"]]
|
||||
elif data["data"] in bpy.data.lights.keys():
|
||||
pointer = bpy.data.lights[data["data"]]
|
||||
elif data["data"] in bpy.data.cameras.keys():
|
||||
pointer = bpy.data.cameras[data["data"]]
|
||||
elif data["data"] in bpy.data.curves.keys():
|
||||
pointer = bpy.data.curves[data["data"]]
|
||||
elif data["data"] in bpy.data.metaballs.keys():
|
||||
pointer = bpy.data.metaballs[data["data"]]
|
||||
elif data["data"] in bpy.data.armatures.keys():
|
||||
pointer = bpy.data.armatures[data["data"]]
|
||||
elif data["data"] in bpy.data.grease_pencils.keys():
|
||||
pointer = bpy.data.grease_pencils[data["data"]]
|
||||
elif data["data"] in bpy.data.curves.keys():
|
||||
pointer = bpy.data.curves[data["data"]]
|
||||
|
||||
return bpy.data.objects.new(data["name"], pointer)
|
||||
|
||||
def load(self, data, target):
|
||||
target.uuid = data['uuid']
|
||||
target.matrix_world = mathutils.Matrix(data["matrix_world"])
|
||||
|
||||
# Load modifiers
|
||||
if hasattr(target, 'modifiers'):
|
||||
for local_modifier in target.modifiers:
|
||||
if local_modifier.name not in data['modifiers']:
|
||||
target.modifiers.remove(local_modifier)
|
||||
for modifier in data['modifiers']:
|
||||
target_modifier = target.modifiers.get(modifier)
|
||||
|
||||
if not target_modifier:
|
||||
target_modifier = target.modifiers.new(
|
||||
data['modifiers'][modifier]['name'], data['modifiers'][modifier]['type'])
|
||||
|
||||
utils.dump_anything.load(
|
||||
target_modifier, data['modifiers'][modifier])
|
||||
|
||||
if 'children' in data.keys():
|
||||
for child in data['children']:
|
||||
bpy.data.objects[child].parent = self.pointer
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
dumper = utils.dump_anything.Dumper()
|
||||
dumper.depth = 1
|
||||
dumper.include_filter = [
|
||||
"name",
|
||||
"matrix_world",
|
||||
"rotation_mode",
|
||||
"parent",
|
||||
"data",
|
||||
"uuid",
|
||||
"children",
|
||||
"library"
|
||||
]
|
||||
data = dumper.dump(pointer)
|
||||
|
||||
if self.is_library:
|
||||
return data
|
||||
|
||||
if hasattr(pointer, 'modifiers'):
|
||||
dumper.include_filter = None
|
||||
dumper.depth = 3
|
||||
data["modifiers"] = dumper.dump(pointer.modifiers)
|
||||
|
||||
if len(pointer.children) > 0:
|
||||
childs = []
|
||||
for child in pointer.children:
|
||||
childs.append(child.name)
|
||||
|
||||
data["children"] = childs
|
||||
# deps.extend(list(self.pointer.children))
|
||||
return data
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
object_name = self.buffer['name']
|
||||
try:
|
||||
self.pointer = bpy.data.objects[object_name]
|
||||
except:
|
||||
pass
|
||||
|
||||
def diff(self):
|
||||
diff_rev = diff(self.dump(pointer=self.pointer), self.buffer)
|
||||
return (self.bl_diff() or
|
||||
len(diff_rev.keys()))
|
||||
|
||||
def resolve_dependencies(self):
|
||||
deps = super().resolve_dependencies()
|
||||
|
||||
# Avoid Empty case
|
||||
if self.pointer.data:
|
||||
deps.append(self.pointer.data)
|
||||
if len(self.pointer.children) > 0:
|
||||
deps.extend(list(self.pointer.children))
|
||||
|
||||
if self.is_library:
|
||||
deps.append(self.pointer.library)
|
||||
|
||||
return deps
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.objects.get(self.buffer['name'])
|
||||
|
||||
|
||||
bl_id = "objects"
|
||||
bl_class = bpy.types.Object
|
||||
bl_rep_class = BlObject
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_icon = 'OBJECT_DATA'
|
@ -1,92 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
from jsondiff import diff
|
||||
|
||||
from .. import utils
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
class BlScene(BlDatablock):
|
||||
def construct(self, data):
|
||||
return bpy.data.scenes.new(data["name"])
|
||||
|
||||
def load(self, data, target):
|
||||
target = self.pointer
|
||||
# Load other meshes metadata
|
||||
utils.dump_anything.load(target, data)
|
||||
|
||||
# Load master collection
|
||||
for object in data["collection"]["objects"]:
|
||||
if object not in target.collection.objects.keys():
|
||||
target.collection.objects.link(bpy.data.objects[object])
|
||||
|
||||
for object in target.collection.objects.keys():
|
||||
if object not in data["collection"]["objects"]:
|
||||
target.collection.objects.unlink(bpy.data.objects[object])
|
||||
|
||||
# load collections
|
||||
for collection in data["collection"]["children"]:
|
||||
if collection not in target.collection.children.keys():
|
||||
target.collection.children.link(
|
||||
bpy.data.collections[collection])
|
||||
|
||||
for collection in target.collection.children.keys():
|
||||
if collection not in data["collection"]["children"]:
|
||||
target.collection.children.unlink(
|
||||
bpy.data.collections[collection])
|
||||
|
||||
if 'world' in data.keys():
|
||||
target.world = bpy.data.worlds[data['world']]
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
data = {}
|
||||
|
||||
scene_dumper = utils.dump_anything.Dumper()
|
||||
scene_dumper.depth = 1
|
||||
scene_dumper.include_filter = ['name','world', 'id', 'camera', 'grease_pencil']
|
||||
data = scene_dumper.dump(pointer)
|
||||
|
||||
scene_dumper.depth = 3
|
||||
scene_dumper.include_filter = ['children','objects','name']
|
||||
data['collection'] = scene_dumper.dump(pointer.collection)
|
||||
|
||||
|
||||
return data
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
scene_name = self.buffer['name']
|
||||
|
||||
self.pointer = bpy.data.scenes.get(scene_name)
|
||||
|
||||
def diff(self):
|
||||
return (self.bl_diff() or
|
||||
len(self.pointer.collection.objects) != len(self.buffer['collection']['objects']) or
|
||||
len(self.pointer.collection.children) != len(self.buffer['collection']['children']))
|
||||
|
||||
def resolve_dependencies(self):
|
||||
deps = []
|
||||
|
||||
# child collections
|
||||
for child in self.pointer.collection.children:
|
||||
deps.append(child)
|
||||
|
||||
# childs objects
|
||||
for object in self.pointer.objects:
|
||||
deps.append(object)
|
||||
|
||||
# world
|
||||
if self.pointer.world:
|
||||
deps.append(self.pointer.world)
|
||||
|
||||
return deps
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.scenes.get(self.buffer['name'])
|
||||
bl_id = "scenes"
|
||||
bl_class = bpy.types.Scene
|
||||
bl_rep_class = BlScene
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_icon = 'SCENE_DATA'
|
@ -1,66 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
|
||||
from .. import utils
|
||||
from .. import presence
|
||||
from .bl_datablock import BlDatablock
|
||||
from ..libs.replication.replication.constants import UP
|
||||
|
||||
class BlUser(BlDatablock):
|
||||
def construct(self, name):
|
||||
return presence.User()
|
||||
|
||||
def load(self, data, target):
|
||||
target.name = data['name']
|
||||
target.location = data['location']
|
||||
target.selected_objects = data['selected_objects']
|
||||
utils.dump_anything.load(target, data)
|
||||
|
||||
def apply(self):
|
||||
if self.pointer is None:
|
||||
self.pointer = self.construct(self.buffer)
|
||||
|
||||
if self.pointer:
|
||||
self.load(data=self.buffer, target=self.pointer)
|
||||
|
||||
settings = bpy.context.window_manager.session
|
||||
self.state = UP
|
||||
#TODO: refactor in order to redraw in cleaner ways
|
||||
area, region, rv3d = presence.view3d_find()
|
||||
if presence.renderer and area and region and rv3d and settings.enable_presence:
|
||||
if settings.presence_show_user:
|
||||
presence.renderer.draw_client_camera(self.buffer['name'], self.buffer['location'],self.buffer['color'])
|
||||
if settings.presence_show_selected:
|
||||
presence.renderer.draw_client_selection(self.buffer['name'], self.buffer['color'],self.buffer['selected_objects'])
|
||||
presence.refresh_3d_view()
|
||||
|
||||
|
||||
def dump(self,pointer=None):
|
||||
data = utils.dump_anything.dump(pointer)
|
||||
data['location'] = pointer.location
|
||||
data['color'] = pointer.color
|
||||
data['selected_objects'] = pointer.selected_objects
|
||||
return data
|
||||
|
||||
|
||||
def diff(self):
|
||||
if not self.pointer:
|
||||
return False
|
||||
if self.pointer.is_dirty:
|
||||
self.pointer.is_dirty = False
|
||||
return True
|
||||
|
||||
for i,coord in enumerate(self.pointer.location):
|
||||
if coord != self.buffer['location'][i]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_valid(self):
|
||||
return True
|
||||
bl_id = "users"
|
||||
bl_class = presence.User
|
||||
bl_rep_class = BlUser
|
||||
bl_delay_refresh = .2
|
||||
bl_delay_apply = .2
|
||||
bl_automatic_push = True
|
||||
bl_icon = 'CON_ARMATURE'
|
@ -1,114 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
from jsondiff import diff
|
||||
|
||||
from .. import utils
|
||||
from .bl_datablock import BlDatablock
|
||||
from .bl_material import load_link, load_node
|
||||
|
||||
|
||||
class BlWorld(BlDatablock):
|
||||
def construct(self, data):
|
||||
return bpy.data.worlds.new(data["name"])
|
||||
|
||||
def load(self, data, target):
|
||||
if data["use_nodes"]:
|
||||
if target.node_tree is None:
|
||||
target.use_nodes = True
|
||||
|
||||
target.node_tree.nodes.clear()
|
||||
|
||||
for node in data["node_tree"]["nodes"]:
|
||||
load_node(target.node_tree, data["node_tree"]["nodes"][node])
|
||||
|
||||
# Load nodes links
|
||||
target.node_tree.links.clear()
|
||||
|
||||
for link in data["node_tree"]["links"]:
|
||||
load_link(target.node_tree, data["node_tree"]["links"][link])
|
||||
|
||||
def dump(self, pointer=None):
|
||||
assert(pointer)
|
||||
|
||||
world_dumper = utils.dump_anything.Dumper()
|
||||
world_dumper.depth = 2
|
||||
world_dumper.exclude_filter = [
|
||||
"preview",
|
||||
"original",
|
||||
"uuid",
|
||||
"color",
|
||||
"cycles",
|
||||
"light_settings"
|
||||
]
|
||||
data = world_dumper.dump(pointer)
|
||||
if pointer.use_nodes:
|
||||
nodes = {}
|
||||
dumper = utils.dump_anything.Dumper()
|
||||
dumper.depth = 2
|
||||
dumper.exclude_filter = [
|
||||
"dimensions",
|
||||
"select",
|
||||
"bl_height_min",
|
||||
"bl_height_max",
|
||||
"bl_width_min",
|
||||
"bl_width_max",
|
||||
"bl_width_default",
|
||||
"hide",
|
||||
"show_options",
|
||||
"show_tetxures",
|
||||
"show_preview",
|
||||
"outputs",
|
||||
"preview",
|
||||
"original",
|
||||
"width_hidden"
|
||||
]
|
||||
|
||||
for node in pointer.node_tree.nodes:
|
||||
nodes[node.name] = dumper.dump(node)
|
||||
|
||||
if hasattr(node, 'inputs'):
|
||||
nodes[node.name]['inputs'] = {}
|
||||
|
||||
for i in node.inputs:
|
||||
input_dumper = utils.dump_anything.Dumper()
|
||||
input_dumper.depth = 2
|
||||
input_dumper.include_filter = ["default_value"]
|
||||
if hasattr(i, 'default_value'):
|
||||
nodes[node.name]['inputs'][i.name] = input_dumper.dump(
|
||||
i)
|
||||
data["node_tree"]['nodes'] = nodes
|
||||
utils.dump_datablock_attibutes(
|
||||
pointer.node_tree, ["links"], 3, data['node_tree'])
|
||||
return data
|
||||
|
||||
def resolve(self):
|
||||
assert(self.buffer)
|
||||
self.pointer = bpy.data.worlds.get(self.buffer['name'])
|
||||
|
||||
def diff(self):
|
||||
diff_rev = diff(self.dump(pointer=self.pointer), self.buffer)
|
||||
return (self.bl_diff() or
|
||||
len(diff_rev.keys()) > 0)
|
||||
|
||||
def resolve_dependencies(self):
|
||||
deps = []
|
||||
|
||||
if self.pointer.use_nodes:
|
||||
for node in self.pointer.node_tree.nodes:
|
||||
if node.type == 'TEX_IMAGE':
|
||||
deps.append(node.image)
|
||||
if self.is_library:
|
||||
deps.append(self.pointer.library)
|
||||
return deps
|
||||
|
||||
def is_valid(self):
|
||||
return bpy.data.worlds.get(self.buffer['name'])
|
||||
|
||||
|
||||
bl_id = "worlds"
|
||||
bl_class = bpy.types.World
|
||||
bl_rep_class = BlWorld
|
||||
bl_delay_refresh = 4
|
||||
bl_delay_apply = 4
|
||||
bl_automatic_push = True
|
||||
bl_icon = 'WORLD_DATA'
|
169
delayable.py
@ -1,169 +0,0 @@
|
||||
import logging
|
||||
|
||||
import bpy
|
||||
|
||||
from . import operators, presence, utils
|
||||
from .bl_types.bl_user import BlUser
|
||||
from .libs.replication.replication.constants import FETCHED, RP_COMMON
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Delayable():
|
||||
"""Delayable task interface
|
||||
"""
|
||||
|
||||
def register(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def execute(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def unregister(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Timer(Delayable):
|
||||
"""Timer binder interface for blender
|
||||
|
||||
Run a bpy.app.Timer in the background looping at the given rate
|
||||
"""
|
||||
|
||||
def __init__(self, duration=1):
|
||||
self._timeout = duration
|
||||
self._running = True
|
||||
|
||||
def register(self):
|
||||
"""Register the timer into the blender timer system
|
||||
"""
|
||||
bpy.app.timers.register(self.main)
|
||||
|
||||
def main(self):
|
||||
self.execute()
|
||||
|
||||
if self._running:
|
||||
return self._timeout
|
||||
|
||||
def execute(self):
|
||||
"""Main timer loop
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def unregister(self):
|
||||
"""Unnegister the timer of the blender timer system
|
||||
"""
|
||||
if bpy.app.timers.is_registered(self.main):
|
||||
bpy.app.timers.unregister(self.main)
|
||||
|
||||
self._running = False
|
||||
|
||||
|
||||
class ApplyTimer(Timer):
|
||||
def __init__(self, timout=1, target_type=None):
|
||||
self._type = target_type
|
||||
super().__init__(timout)
|
||||
|
||||
def execute(self):
|
||||
if operators.client:
|
||||
nodes = operators.client.list(filter=self._type)
|
||||
|
||||
for node in nodes:
|
||||
node_ref = operators.client.get(uuid=node)
|
||||
|
||||
if node_ref.state == FETCHED:
|
||||
operators.client.apply(uuid=node)
|
||||
|
||||
|
||||
class DynamicRightSelectTimer(Timer):
|
||||
def __init__(self, timout=1):
|
||||
super().__init__(timout)
|
||||
self.last_selection = []
|
||||
|
||||
def execute(self):
|
||||
if operators.client:
|
||||
users = operators.client.list(filter=BlUser)
|
||||
|
||||
for user in users:
|
||||
user_ref = operators.client.get(uuid=user)
|
||||
settings = bpy.context.window_manager.session
|
||||
|
||||
# Other user
|
||||
if user_ref.buffer['name'] != settings.username:
|
||||
user_selection = user_ref.buffer['selected_objects']
|
||||
for obj in bpy.data.objects:
|
||||
obj.hide_select = obj.name in user_selection
|
||||
# Local user
|
||||
elif user_ref.pointer:
|
||||
current_selection = utils.get_selected_objects(
|
||||
bpy.context.scene)
|
||||
if current_selection != self.last_selection:
|
||||
user_ref.pointer.update_selected_objects(bpy.context)
|
||||
right_strategy = operators.client.get_config()[
|
||||
'right_strategy']
|
||||
if right_strategy == RP_COMMON:
|
||||
obj_common = [
|
||||
o for o in self.last_selection if o not in current_selection]
|
||||
obj_ours = [
|
||||
o for o in current_selection if o not in self.last_selection]
|
||||
|
||||
# change old selection right to common
|
||||
for obj in obj_common:
|
||||
_object = bpy.data.objects.get(obj)
|
||||
|
||||
node = operators.client.get(reference=_object)
|
||||
if node and node.owner == settings.username:
|
||||
operators.client.change_owner(
|
||||
node.uuid, RP_COMMON)
|
||||
|
||||
# change new selection to our
|
||||
for obj in obj_ours:
|
||||
node = operators.client.get(
|
||||
reference=bpy.data.objects[obj])
|
||||
if node and node.owner == RP_COMMON:
|
||||
operators.client.change_owner(
|
||||
node.uuid, settings.username)
|
||||
|
||||
self.last_selection = current_selection
|
||||
|
||||
|
||||
class RedrawTimer(Timer):
|
||||
def __init__(self, timout=1, target_type=None):
|
||||
self._type = target_type
|
||||
super().__init__(timout)
|
||||
|
||||
def execute(self):
|
||||
if presence.renderer:
|
||||
presence.refresh_3d_view()
|
||||
|
||||
|
||||
class Draw(Delayable):
|
||||
def __init__(self):
|
||||
self._handler = None
|
||||
|
||||
def register(self):
|
||||
self._handler = bpy.types.SpaceView3D.draw_handler_add(
|
||||
self.execute, (), 'WINDOW', 'POST_VIEW')
|
||||
|
||||
def execute(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def unregister(self):
|
||||
try:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(
|
||||
self._handler, "WINDOW")
|
||||
except:
|
||||
logger.error("draw already unregistered")
|
||||
|
||||
|
||||
class ClientUpdate(Draw):
|
||||
def __init__(self, client_uuid=None):
|
||||
assert(client_uuid)
|
||||
self._client_uuid = client_uuid
|
||||
super().__init__()
|
||||
|
||||
def execute(self):
|
||||
if self._client_uuid and operators.client:
|
||||
client = operators.client.get(uuid=self._client_uuid)
|
||||
|
||||
if client:
|
||||
client.pointer.update_location()
|
20
docs/Makefile
Normal file
@ -0,0 +1,20 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = _build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
BIN
docs/about/img/about_chain.gif
Normal file
After Width: | Height: | Size: 5.8 MiB |
8
docs/about/index.rst
Normal file
@ -0,0 +1,8 @@
|
||||
About
|
||||
=====
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:name: toc-about
|
||||
|
||||
introduction
|
15
docs/about/introduction.rst
Normal file
@ -0,0 +1,15 @@
|
||||
============
|
||||
Introduction
|
||||
============
|
||||
|
||||
|
||||
A film is an idea carved along the whole production process by many different peoples. A traditional animation pipeline involve a linear succession of tasks. From storyboard to compositing by passing upon different step, its fundamental work flow is similar to an industrial assembly line. Since each step is almost a department, its common that one person on department B doesn't know what another person did on a previous step in a department A. This lack of visibility/communication could be a source of problems which could produce a bad impact on the final production result.
|
||||
|
||||
.. figure:: img/about_chain.gif
|
||||
:align: center
|
||||
|
||||
The linear workflow problems
|
||||
|
||||
Nowadays it's a known fact that real-time rendering technologies allows to speedup traditional linear production by reducing drastically the iteration time across different steps. All majors industrial CG solutions are moving toward real-time horizons to bring innovative interactive workflows. But this is a microscopic, per-task/solution vision of real-time rendering benefits for the animation production. What if we step-back, get a macroscopic picture of an animation movie pipeline and ask ourself how real-time could change our global workflow ? Could-it bring better ways of working together by giving more visibility between departments during the whole production ?
|
||||
|
||||
The multi-user addon is an attempt to experiment real-time parallelism between different production stage. By replicating blender data blocks over the networks, it allows different artists to collaborate on a same scene in real-time.
|
98
docs/conf.py
Normal file
@ -0,0 +1,98 @@
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# This file only contains a selection of the most common options. For a full
|
||||
# list see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
# -- Path setup --------------------------------------------------------------
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#
|
||||
import os
|
||||
import sys
|
||||
# sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = 'multi-user'
|
||||
copyright = '2020, Swann Martinez'
|
||||
author = 'Swann Martinez, with contributions from Poochy'
|
||||
|
||||
# The full version, including alpha/beta/rc tags
|
||||
release = '0.2.0'
|
||||
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
# The suffix of source filenames.
|
||||
source_suffix = ".rst"
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = "index"
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = 'python'
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This pattern also affects html_static_path and html_extra_path.
|
||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
|
||||
# on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org
|
||||
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
|
||||
|
||||
import sphinx_rtd_theme
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
if on_rtd:
|
||||
using_rtd_theme = True
|
||||
|
||||
# Theme options
|
||||
html_theme_options = {
|
||||
# 'typekit_id': 'hiw1hhg',
|
||||
# 'analytics_id': '',
|
||||
# 'sticky_navigation': True # Set to False to disable the sticky nav while scrolling.
|
||||
# 'logo_only': True, # if we have a html_logo below, this shows /only/ the logo with no title text
|
||||
'collapse_navigation': False, # Collapse navigation (False makes it tree-like)
|
||||
# 'display_version': True, # Display the docs version
|
||||
# 'navigation_depth': 4, # Depth of the headers shown in the navigation bar
|
||||
}
|
||||
|
||||
|
||||
# -- Options for HTMLHelp output ------------------------------------------
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'multiusrdoc'
|
||||
|
||||
# sphinx-notfound-page
|
||||
# https://github.com/readthedocs/sphinx-notfound-page
|
||||
notfound_context = {
|
||||
'title': 'Page Not Found',
|
||||
'body': '''
|
||||
<h1>Page Not Found</h1>
|
||||
<p>Sorry, we couldn't find that page.</p>
|
||||
<p>Try using the search box or go to the homepage.</p>
|
||||
''',
|
||||
}
|
||||
|
||||
# Enable directives that insert the contents of external files
|
||||
file_insertion_enabled = False
|
59
docs/getting_started/glossary.rst
Normal file
@ -0,0 +1,59 @@
|
||||
========
|
||||
Glossary
|
||||
========
|
||||
|
||||
|
||||
.. glossary::
|
||||
|
||||
.. _admin:
|
||||
|
||||
administrator
|
||||
|
||||
*A session administrator can manage users (kick) and hold write access on
|
||||
each datablock. They can also init a dedicated server repository.*
|
||||
|
||||
.. _session-status:
|
||||
|
||||
session status
|
||||
|
||||
*Located in the title of the multi-user panel, the session status shows
|
||||
you the connection state.*
|
||||
|
||||
.. figure:: img/quickstart_session_status.png
|
||||
:align: center
|
||||
|
||||
Session status in panel title bar
|
||||
|
||||
All possible connection states are listed here with their meaning:*
|
||||
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
| State | Description |
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
| WARMING UP DATA | Commiting local data |
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
| FETCHING | Dowloading snapshot from the server |
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
| AUTHENTICATION | Initial server authentication |
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
| ONLINE | Connected to the session |
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
| PUSHING | Init the server repository by pushing ours |
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
| INIT | Initial state |
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
| QUITTING | Exiting the session |
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
| LAUNCHING SERVICES | Launching local services. Services are spetialized daemons running in the background. ) |
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
| LOBBY | The lobby is a waiting state triggered when the server repository hasn't been initiated yet |
|
||||
| | |
|
||||
| | Once initialized, the server will automatically launch all client in the **LOBBY**. |
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
|
||||
|
||||
.. _common-right:
|
||||
|
||||
common right
|
||||
|
||||
When a data block is under common right, it is available to everyone for modification.
|
||||
The rights will be given to the user that selects it first.
|
BIN
docs/getting_started/img/quickstart_advanced.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
BIN
docs/getting_started/img/quickstart_advanced_cache.png
Normal file
After Width: | Height: | Size: 7.6 KiB |
BIN
docs/getting_started/img/quickstart_advanced_logging.png
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
docs/getting_started/img/quickstart_advanced_network.png
Normal file
After Width: | Height: | Size: 4.1 KiB |
BIN
docs/getting_started/img/quickstart_advanced_replication.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
docs/getting_started/img/quickstart_cancel_save_session_data.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
docs/getting_started/img/quickstart_host.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
docs/getting_started/img/quickstart_import_session_data.png
Normal file
After Width: | Height: | Size: 106 KiB |
BIN
docs/getting_started/img/quickstart_join.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
docs/getting_started/img/quickstart_presence.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
docs/getting_started/img/quickstart_properties.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
docs/getting_started/img/quickstart_pull.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
BIN
docs/getting_started/img/quickstart_push.png
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
docs/getting_started/img/quickstart_refresh.png
Normal file
After Width: | Height: | Size: 559 B |
BIN
docs/getting_started/img/quickstart_remove.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
docs/getting_started/img/quickstart_replication.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
docs/getting_started/img/quickstart_save_session_data.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
docs/getting_started/img/quickstart_save_session_data_cancel.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
docs/getting_started/img/quickstart_save_session_data_dialog.png
Normal file
After Width: | Height: | Size: 80 KiB |
BIN
docs/getting_started/img/quickstart_session_init.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
docs/getting_started/img/quickstart_session_status.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
docs/getting_started/img/quickstart_snap_time.gif
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/getting_started/img/quickstart_snap_view.gif
Normal file
After Width: | Height: | Size: 5.2 MiB |
BIN
docs/getting_started/img/quickstart_status.png
Normal file
After Width: | Height: | Size: 70 KiB |
BIN
docs/getting_started/img/quickstart_unlock.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
docs/getting_started/img/quickstart_user_info.png
Normal file
After Width: | Height: | Size: 5.4 KiB |
BIN
docs/getting_started/img/quickstart_user_representation.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
docs/getting_started/img/quickstart_users.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
docs/getting_started/img/update_1.jpg
Normal file
After Width: | Height: | Size: 223 KiB |
BIN
docs/getting_started/img/update_2.jpg
Normal file
After Width: | Height: | Size: 209 KiB |
BIN
docs/getting_started/img/update_3.jpg
Normal file
After Width: | Height: | Size: 217 KiB |
BIN
docs/getting_started/img/update_4.jpg
Normal file
After Width: | Height: | Size: 226 KiB |
12
docs/getting_started/index.rst
Normal file
@ -0,0 +1,12 @@
|
||||
===============
|
||||
Getting started
|
||||
===============
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:name: toc-getting-started
|
||||
|
||||
install
|
||||
quickstart
|
||||
troubleshooting
|
||||
glossary
|
58
docs/getting_started/install.rst
Normal file
@ -0,0 +1,58 @@
|
||||
============
|
||||
Installation
|
||||
============
|
||||
|
||||
.. hint::
|
||||
The process is the same for linux, mac and windows.
|
||||
|
||||
1. Download `LATEST build <https://gitlab.com/slumber/multi-user/-/jobs/artifacts/develop/download?job=build>`_ or `STABLE build <https://gitlab.com/slumber/multi-user/-/jobs/artifacts/master/download?job=build>`_.
|
||||
2. Run blender as administrator (to allow python dependencies auto-installation).
|
||||
3. Install **multi-user.zip** from your addon preferences.
|
||||
|
||||
Once the addon is succesfully installed, I strongly recommend you to follow the :ref:`quickstart`
|
||||
tutorial.
|
||||
|
||||
.. _update-version:
|
||||
|
||||
Updates
|
||||
=======
|
||||
|
||||
Multi-User has a built-in auto-update function.
|
||||
|
||||
1. Navigate to Edit >> Preferences pane in Blender, and go to the 'Add-ons' section.
|
||||
2. Search 'multi-user', select the 'Update' tab, click 'Auto-check for Update' and choose the frequency you'd like.
|
||||
3. Make sure to click the three bars in the bottom-left, and save this to your preferences (userpref.blend).
|
||||
|
||||
Sometimes you'd like to perform manual update, or even side-grade or rollback your multi-user version. Perhaps you are trying out new features from the 'develop' branch in a test session.
|
||||
|
||||
1. Click on 'Check now for multiuser update'. Multi-user will now find new versions
|
||||
|
||||
.. figure:: img/update_1.jpg
|
||||
:align: center
|
||||
:width: 300px
|
||||
|
||||
Check for updates
|
||||
|
||||
2. Select 'Install latest master / old version'
|
||||
|
||||
.. figure:: img/update_2.jpg
|
||||
:align: center
|
||||
:width: 300px
|
||||
|
||||
Install
|
||||
|
||||
3. In most cases, select 'master' branch for the latest stable release. The unstable 'develop' branch and older releases are available
|
||||
|
||||
.. figure:: img/update_3.jpg
|
||||
:align: center
|
||||
:width: 300px
|
||||
|
||||
Select version
|
||||
|
||||
4. Finally, restart blender to use the updated version
|
||||
|
||||
.. figure:: img/update_4.jpg
|
||||
:align: center
|
||||
:width: 300px
|
||||
|
||||
Restart blender
|
459
docs/getting_started/quickstart.rst
Normal file
@ -0,0 +1,459 @@
|
||||
.. _quickstart:
|
||||
|
||||
===========
|
||||
Quick start
|
||||
===========
|
||||
|
||||
.. hint::
|
||||
*All session-related settings are located under: `View3D -> Sidebar -> Multiuser panel`*
|
||||
|
||||
The multi-user addon provides a session management system.
|
||||
In this guide, you will quickly learn how to use the collaborative session management system in three parts:
|
||||
|
||||
- :ref:`how-to-host`
|
||||
- :ref:`how-to-join`
|
||||
- :ref:`how-to-manage`
|
||||
|
||||
.. _how-to-host:
|
||||
|
||||
How to host a session
|
||||
=====================
|
||||
|
||||
The multi-user add-on relies on a Client-Server architecture.
|
||||
The server is the heart of the collaborative session.
|
||||
It is what allows user's blender instances to communicate with each other.
|
||||
In simple terms, *Hosting a session* means *run a local server and connect the local client to it*.
|
||||
When I say **local server** I mean a server which is accessible from the LAN (Local Area Network) without requiring an internet connection.
|
||||
|
||||
However, there are times when you will need to host a session over the internet.
|
||||
In this case, I strongly recommend that you read the :ref:`internet-guide` tutorial.
|
||||
|
||||
.. _user-info:
|
||||
|
||||
--------------------------------
|
||||
1. Fill in your user information
|
||||
--------------------------------
|
||||
|
||||
The **User Info** panel (See image below) allows you to customise your online identity.
|
||||
|
||||
.. figure:: img/quickstart_user_info.png
|
||||
:align: center
|
||||
|
||||
User info panel
|
||||
|
||||
|
||||
Let's fill in those two fields:
|
||||
|
||||
- **name**: your online name.
|
||||
- **color**: a color used to represent you in other users' workspaces (see image below).
|
||||
|
||||
|
||||
During online sessions, other users will see your selected object and camera highlighted in your profile color.
|
||||
|
||||
.. _user-representation:
|
||||
|
||||
.. figure:: img/quickstart_user_representation.png
|
||||
:align: center
|
||||
|
||||
User viewport representation aka 'User Presence'
|
||||
|
||||
---------------------
|
||||
2. Set up the network
|
||||
---------------------
|
||||
|
||||
When the hosting process starts, the multi-user addon will launch a local server instance.
|
||||
In the network panel, select **HOST**.
|
||||
The **Host sub-panel** (see image below) allows you to configure the server according to:
|
||||
|
||||
* **Port**: Port on which the server is listening.
|
||||
* **Start from**: The session initialisation method.
|
||||
|
||||
* **current scenes**: Start with the data loaded in the current blend file.
|
||||
* **an empty scene**: Clear the blend file's data and start over.
|
||||
|
||||
.. danger::
|
||||
By starting from an empty scene, all of the blend data will be removed!
|
||||
Be sure to save your existing work before launching the session.
|
||||
|
||||
* **Admin password**: The session administration password.
|
||||
|
||||
.. figure:: img/quickstart_host.png
|
||||
:align: center
|
||||
:alt: host menu
|
||||
|
||||
Host network panel
|
||||
|
||||
|
||||
.. note:: Additional configuration setting can be found in the :ref:`advanced` section.
|
||||
|
||||
Once everything is set up, you can hit the **HOST** button to launch the session!
|
||||
|
||||
This will do two things:
|
||||
|
||||
* Start a local server
|
||||
* Connect you to it as an :ref:`admin`
|
||||
|
||||
During an online session, various actions are available to you, go to :ref:`how-to-manage` section to
|
||||
learn more about them.
|
||||
|
||||
.. _how-to-join:
|
||||
|
||||
How to join a session
|
||||
=====================
|
||||
|
||||
This section describes how join a launched session.
|
||||
Before starting make sure that you have access to the session IP address and port number.
|
||||
|
||||
--------------------------------
|
||||
1. Fill in your user information
|
||||
--------------------------------
|
||||
|
||||
Follow the user-info_ section for this step.
|
||||
|
||||
----------------
|
||||
2. Network setup
|
||||
----------------
|
||||
|
||||
In the network panel, select **JOIN**.
|
||||
The **join sub-panel** (see image below) allows you to configure your client to join a
|
||||
collaborative session which is already hosted.
|
||||
|
||||
.. figure:: img/quickstart_join.png
|
||||
:align: center
|
||||
:alt: Connect menu
|
||||
|
||||
Connection panel
|
||||
|
||||
Fill in the fields with your information:
|
||||
|
||||
- **IP**: the host's IP address.
|
||||
- **Port**: the host's port number.
|
||||
- **Connect as admin**: connect yourself with **admin rights** (see :ref:`admin` ) to the session.
|
||||
|
||||
.. Maybe something more explicit here
|
||||
|
||||
.. note::
|
||||
Additional configuration settings can be found in the :ref:`advanced` section.
|
||||
|
||||
Once you've configured every field, hit the button **CONNECT** to join the session !
|
||||
When the :ref:`session-status` is **ONLINE** you are online and ready to start co-creating.
|
||||
|
||||
.. note::
|
||||
When starting a **dedicated server**, the session status screen will take you to the **LOBBY**, awaiting an admin to start the session.
|
||||
|
||||
If the session status is set to **LOBBY** and you are a regular user, you need to wait for the admin to launch the scene.
|
||||
If you are the admin, you just need to initialise the repository to start the session (see image below).
|
||||
|
||||
.. figure:: img/quickstart_session_init.png
|
||||
:align: center
|
||||
|
||||
Session initialisation for dedicated server
|
||||
|
||||
During an online session, various actions are available to you. Go to :ref:`how-to-manage` to
|
||||
learn more about them.
|
||||
|
||||
.. _how-to-manage:
|
||||
|
||||
How to manage a session
|
||||
=======================
|
||||
|
||||
The quality of a collaborative session directly depends on the quality of the network connection, and the communication between the users. This section describes
|
||||
various tools which have been made in an effort to ease the communication between your fellow creators.
|
||||
Feel free to suggest any ideas for communication tools `here <https://gitlab.com/slumber/multi-user/-/issues/75>`_ .
|
||||
|
||||
---------------------------
|
||||
Change replication behavior
|
||||
---------------------------
|
||||
|
||||
During a session, multi-user will replicate all of your local modifications to the scene, to all other users' blender instances.
|
||||
In order to avoid annoying other users when you are experimenting, you can flag some of your local modifications to be ignored via
|
||||
various flags present at the top of the panel (see red area in the image below). Those flags are explained in the :ref:`replication` section.
|
||||
|
||||
.. figure:: img/quickstart_replication.png
|
||||
:align: center
|
||||
|
||||
Session replication flags
|
||||
|
||||
--------------------
|
||||
Monitor online users
|
||||
--------------------
|
||||
|
||||
One of the most vital tools is the **Online user panel**. It lists all connected
|
||||
users' information including your own:
|
||||
|
||||
* **Role** : if a user is an admin or a regular user.
|
||||
* **Location**: Where the user is actually working.
|
||||
* **Frame**: When (on which frame) the user is working.
|
||||
* **Ping**: user's connection delay in milliseconds
|
||||
|
||||
.. figure:: img/quickstart_users.png
|
||||
:align: center
|
||||
|
||||
Online user panel
|
||||
|
||||
By selecting a user in the list you'll have access to different users' related **actions**.
|
||||
Those operators allow you to experience the selected user's state in two different dimensions: **SPACE** and **TIME**.
|
||||
|
||||
Snapping in space
|
||||
-----------------
|
||||
|
||||
The **CAMERA button** (Also called **snap view** operator) allow you to snap to
|
||||
the user's viewpoint. To disable the snap, click on the button once again. This action
|
||||
serves different purposes such as easing the review process, and working together on a large or populated world.
|
||||
|
||||
.. hint::
|
||||
If the target user is located in another scene, the **snap view** operator will send you to their scene.
|
||||
|
||||
.. figure:: img/quickstart_snap_view.gif
|
||||
:align: center
|
||||
|
||||
Snap view in action
|
||||
|
||||
Snapping in time
|
||||
----------------
|
||||
|
||||
The **CLOCK button** (Also called **snap time** operator) allows you to snap to
|
||||
the user's time (current frame). To disable the snap, click on the button once again.
|
||||
This action helps various multiple creators to work in the same time-frame
|
||||
(for instance multiple animators).
|
||||
|
||||
.. figure:: img/quickstart_snap_time.gif
|
||||
:align: center
|
||||
|
||||
Snap time in action
|
||||
|
||||
|
||||
Kick a user
|
||||
-----------
|
||||
|
||||
.. warning:: Only available for :ref:`admin` !
|
||||
|
||||
|
||||
The **CROSS button** (Also called **kick** operator) allows the administrator to kick the selected user. This can be helpful if a user is acting unruly, but more importantly, if they are experiencing a high ping which is slowing down the scene. Meanwhile, in the target user's world, the session will properly disconnect.
|
||||
|
||||
|
||||
Change users display
|
||||
--------------------
|
||||
|
||||
Presence is the multi-user module responsible for displaying user presence. During the session,
|
||||
it draw users' related information in your viewport such as:
|
||||
|
||||
* Username
|
||||
* User point of view
|
||||
* User selection
|
||||
|
||||
.. figure:: img/quickstart_presence.png
|
||||
:align: center
|
||||
|
||||
Presence show flags
|
||||
|
||||
The presence overlay panel (see image above) allows you to enable/disable
|
||||
various drawn parts via the following flags:
|
||||
|
||||
- **Show session status**: display the session status in the viewport
|
||||
|
||||
.. figure:: img/quickstart_status.png
|
||||
:align: center
|
||||
|
||||
- **Text scale**: session status text size
|
||||
- **Vertical/Horizontal position**: session position in the viewport
|
||||
|
||||
- **Show selected objects**: display other users' current selections
|
||||
- **Show users**: display users' current viewpoint
|
||||
- **Show different scenes**: display users working on other scenes
|
||||
|
||||
|
||||
|
||||
-----------
|
||||
Manage data
|
||||
-----------
|
||||
|
||||
In order to understand replication data managment, a quick introduction to the multi-user data workflow is in order.
|
||||
The first thing to know: until now, the addon relies on data-based replication. In simple words, it means that it replicates
|
||||
the resultant output of a user's actions.
|
||||
To replicate datablocks between clients, multi-user relies on a standard distributed architecture:
|
||||
|
||||
- The server stores the "master" version of the work.
|
||||
- Each client has a local version of the work.
|
||||
|
||||
When an artist modifies something in the scene, here is what is happening in the background:
|
||||
|
||||
1. Modified data are **COMMITTED** to the local repository.
|
||||
2. Once committed locally, they are **PUSHED** to the server
|
||||
3. As soon as the server receives updates, they are stored locally and pushed to every other client
|
||||
|
||||
At the top of this data management system, a rights management system prevents
|
||||
multiple users from modifying the same data at the same time. A datablock may belong to
|
||||
a connected user or be under :ref:`common-right<**COMMON**>` rights.
|
||||
|
||||
.. note::
|
||||
In a near future, the rights management system will support roles to allow multiple users to
|
||||
work on different aspects of the same datablock.
|
||||
|
||||
The Repository panel (see image below) allows you to monitor, change datablock states and rights manually.
|
||||
|
||||
.. figure:: img/quickstart_save_session_data.png
|
||||
:align: center
|
||||
|
||||
Repository panel
|
||||
|
||||
The **show only owned** flag allows you to see which datablocks you are currently modifying.
|
||||
|
||||
.. warning::
|
||||
If you are editing a datablock not listed with this flag enabled, it means that you have not been granted the rights to modify it.
|
||||
So, it won't be updated to other clients!
|
||||
|
||||
Here is a quick list of available actions:
|
||||
|
||||
+---------------------------------------+-------------------+------------------------------------------------------------------------------------+
|
||||
| icon | Action | Description |
|
||||
+=======================================+===================+====================================================================================+
|
||||
| .. image:: img/quickstart_push.png | **Push** | push data-block to other clients |
|
||||
+---------------------------------------+-------------------+------------------------------------------------------------------------------------+
|
||||
| .. image:: img/quickstart_pull.png | **Pull** | pull last version into blender |
|
||||
+---------------------------------------+-------------------+------------------------------------------------------------------------------------+
|
||||
| .. image:: img/quickstart_refresh.png | **Reset** | Reset local change to the server version |
|
||||
+---------------------------------------+-------------------+------------------------------------------------------------------------------------+
|
||||
| .. image:: img/quickstart_unlock.png | **Lock/Unlock** | If locked, does nothing. If unlocked, grant modification rights to another user. |
|
||||
+---------------------------------------+-------------------+------------------------------------------------------------------------------------+
|
||||
| .. image:: img/quickstart_remove.png | **Delete** | Remove the data-block from network replication |
|
||||
+---------------------------------------+-------------------+------------------------------------------------------------------------------------+
|
||||
|
||||
Save session data
|
||||
-----------------
|
||||
|
||||
.. danger::
|
||||
This is an experimental feature, until the stable release it is highly recommended to use regular .blend save.
|
||||
|
||||
The save session data allows you to create a backup of the session data.
|
||||
|
||||
When you hit the **save session data** button, the following popup dialog will appear.
|
||||
It allows you to choose the destination folder and if you want to run an auto-save.
|
||||
|
||||
.. figure:: img/quickstart_save_session_data_dialog.png
|
||||
:align: center
|
||||
|
||||
Save session data dialog.
|
||||
|
||||
If you enabled the auto-save option, you can cancel it from the **Cancel auto-save** button.
|
||||
|
||||
.. figure:: img/quickstart_save_session_data_cancel.png
|
||||
:align: center
|
||||
|
||||
Cancel session autosave.
|
||||
|
||||
|
||||
To import session data backups, use the following **Multiuser session snapshot** import dialog
|
||||
|
||||
.. figure:: img/quickstart_import_session_data.png
|
||||
:align: center
|
||||
|
||||
Import session data dialog.
|
||||
|
||||
.. note::
|
||||
It is not yet possible to start a session directly from a backup.
|
||||
|
||||
.. _advanced:
|
||||
|
||||
Advanced settings
|
||||
=================
|
||||
|
||||
This section contains optional settings to configure the session behavior.
|
||||
|
||||
.. figure:: img/quickstart_advanced.png
|
||||
:align: center
|
||||
|
||||
Advanced configuration panel
|
||||
|
||||
-------
|
||||
Network
|
||||
-------
|
||||
|
||||
.. figure:: img/quickstart_advanced_network.png
|
||||
:align: center
|
||||
|
||||
Advanced network settings
|
||||
|
||||
**IPC Port** is the port used for Inter Process Communication. This port is used
|
||||
by the multi-user subprocesses to communicate with each other. If different instances
|
||||
of multi-user are using the same IPC port, this will create conflict !
|
||||
|
||||
.. note::
|
||||
You only need to modify this setting if you need to launch multiple clients from the same
|
||||
computer (or if you try to host and join from the same computer). To resolve this, you simply need to enter a different
|
||||
**IPC port** for each blender instance.
|
||||
|
||||
**Timeout (in milliseconds)** is the maximum ping authorized before auto-disconnecting.
|
||||
You should only increase it if you have a bad connection.
|
||||
|
||||
.. _replication:
|
||||
|
||||
-----------
|
||||
Replication
|
||||
-----------
|
||||
|
||||
.. figure:: img/quickstart_advanced_replication.png
|
||||
:align: center
|
||||
|
||||
Advanced replication settings
|
||||
|
||||
**Synchronize render settings** (only host) enable replication of EEVEE and CYCLES render settings to match renders between clients.
|
||||
|
||||
**Synchronize active camera** sync the scene's active camera.
|
||||
|
||||
**Edit Mode Updates** enable objects to update while you are in Edit_Mode.
|
||||
|
||||
.. warning:: Edit Mode Updates kills the session's performance with complex objects (heavy meshes, gpencil, etc...).
|
||||
|
||||
**Update method** allows you to change how replication updates are triggered. Until now, two update methods are implemented:
|
||||
|
||||
- **Default**: Use external threads to monitor datablocks changes. Slower and less accurate.
|
||||
- **Despgraph ⚠️**: Use the blender dependency graph to trigger updates. Faster but experimental and unstable !
|
||||
|
||||
**Properties frequency grid** set a custom replication frequency for each type of data-block:
|
||||
|
||||
- **Refresh**: pushed data update rate (in second)
|
||||
- **Apply**: pulled data update rate (in second)
|
||||
|
||||
-----
|
||||
Cache
|
||||
-----
|
||||
|
||||
Multi-user allows you to replicate external dependencies such as images (textures, hdris, etc...), movies, and sounds.
|
||||
On each client, the files will be stored in the multi-user cache folder.
|
||||
|
||||
.. figure:: img/quickstart_advanced_cache.png
|
||||
:align: center
|
||||
|
||||
Advanced cache settings
|
||||
|
||||
**cache_directory** choose where cached files (images, sound, movies) will be saved.
|
||||
|
||||
**Clear memory filecache** will save memory space at runtime by removing the file content from memory as soon as it has been written to the disk.
|
||||
|
||||
**Clear cache** will remove all files from the cache folder.
|
||||
|
||||
.. warning:: Clearing the cache could break your scene images/movies/sounds if they are used in a blend file! Try saving the blend file and choosing 'Pack all into blend' before clearing the cache.
|
||||
|
||||
---
|
||||
Log
|
||||
---
|
||||
|
||||
.. figure:: img/quickstart_advanced_logging.png
|
||||
:align: center
|
||||
|
||||
Advanced log settings
|
||||
|
||||
**log level** allows you to set the level of detail captured in multi-user's logging output. Here is a brief description on the level of detail for each value of the logging parameter:
|
||||
|
||||
+-----------+-----------------------------------------------+
|
||||
| Log level | Description |
|
||||
+===========+===============================================+
|
||||
| ERROR | Shows only critical errors |
|
||||
+-----------+-----------------------------------------------+
|
||||
| WARNING | Shows only errors (of all kinds) |
|
||||
+-----------+-----------------------------------------------+
|
||||
| INFO | Shows only status-related messages and errors |
|
||||
+-----------+-----------------------------------------------+
|
||||
| DEBUG | Shows all possible information |
|
||||
+-----------+-----------------------------------------------+
|
19
docs/getting_started/troubleshooting.rst
Normal file
@ -0,0 +1,19 @@
|
||||
.. _troubleshooting:
|
||||
|
||||
===============
|
||||
Troubleshooting
|
||||
===============
|
||||
|
||||
The majority of issues new users experience when first using Multi-User can be solved with a few quick checks.
|
||||
|
||||
- Run Blender in Administrator mode
|
||||
- Update the multi-user addon to the latest version
|
||||
- Make sure to allow Blender through your firewall
|
||||
|
||||
.. hint:: Your firewall may have additional settings like Ransomware protection, or you may need to enable both Blender and Python on private and/or public Networks
|
||||
|
||||
- Solve problems with your connection quality
|
||||
- Minimise the use of large textures or file sizes
|
||||
- Avoid using 'Undo'. Use 'delete' instead
|
||||
|
||||
Use the #support channel on the multi-user `discord server <https://discord.gg/aBPvGws>`_ to chat, seek help and contribute.
|
BIN
docs/img/homepage_ban.png
Normal file
After Width: | Height: | Size: 409 KiB |
BIN
docs/img/homepage_roadmap.png
Normal file
After Width: | Height: | Size: 274 KiB |
66
docs/index.rst
Normal file
@ -0,0 +1,66 @@
|
||||
=====================================
|
||||
Welcome to Multi-user's documentation
|
||||
=====================================
|
||||
|
||||
.. image:: img/homepage_ban.png
|
||||
|
||||
The multi-user addon is a free and open source blender plugin. It tool aims to bring multiple users to work on the same .blend over the network.
|
||||
|
||||
.. warning:: Under development, use it at your own risks.
|
||||
|
||||
|
||||
|
||||
Main Features
|
||||
=============
|
||||
|
||||
- Collaborative workflow in blender
|
||||
- Viewport users presence (active selection, POV)
|
||||
- Datablocks right managment
|
||||
- Tested under Windows
|
||||
|
||||
Community
|
||||
=========
|
||||
|
||||
A `discord server <https://discord.gg/aBPvGws>`_ have been created to provide help for new users and
|
||||
organize collaborative creation sessions.
|
||||
|
||||
Status
|
||||
======
|
||||
|
||||
.. image:: img/homepage_roadmap.png
|
||||
|
||||
|
||||
Follow the `roadmap <https://gitlab.com/slumber/multi-user/-/boards/929107>`_ to be aware of last news.
|
||||
|
||||
Documentation is organized into the following sections:
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: About
|
||||
:name: sec-about
|
||||
|
||||
about/introduction
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Getting started
|
||||
:name: sec-learn
|
||||
|
||||
getting_started/install
|
||||
getting_started/quickstart
|
||||
getting_started/glossary
|
||||
getting_started/troubleshooting
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Tutorials
|
||||
:name: sec-tutorials
|
||||
|
||||
tutorials/hosting_guide
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Community
|
||||
:name: sec-community
|
||||
|
||||
ways_to_contribute
|
35
docs/make.bat
Normal file
@ -0,0 +1,35 @@
|
||||
@ECHO OFF
|
||||
|
||||
pushd %~dp0
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set SOURCEDIR=.
|
||||
set BUILDDIR=_build
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
%SPHINXBUILD% >NUL 2>NUL
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.http://sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
goto end
|
||||
|
||||
:help
|
||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
|
||||
:end
|
||||
popd
|
731
docs/tutorials/hosting_guide.rst
Normal file
@ -0,0 +1,731 @@
|
||||
.. _internet-guide:
|
||||
|
||||
=======================
|
||||
Hosting on the internet
|
||||
=======================
|
||||
|
||||
.. warning::
|
||||
Until now, those communications are not encrypted but are planned to be in a mid-term future (`status <https://gitlab.com/slumber/multi-user/issues/62>`_).
|
||||
|
||||
This tutorial aims to guide you toward hosting a collaborative multi-user session on the internet.
|
||||
Hosting a session can be achieved in several ways:
|
||||
|
||||
- :ref:`host-blender`: hosting a session directly from the blender add-on panel.
|
||||
- :ref:`host-dedicated`: hosting a session directly from the command line interface on a computer without blender.
|
||||
- :ref:`host-cloud`: hosting a session on a dedicated cloud server such as Google Cloud's free tier.
|
||||
|
||||
|
||||
.. _host-blender:
|
||||
|
||||
--------------------
|
||||
From blender
|
||||
--------------------
|
||||
By default your router doesn't allow anyone to share you connection.
|
||||
In order grant the server access to people from internet you have two main option:
|
||||
|
||||
* The :ref:`connection-sharing`: the easiest way.
|
||||
* The :ref:`port-forwarding`: this way is the most unsecure. If you have no networking knowledge, you should definitely follow :ref:`connection-sharing`.
|
||||
|
||||
.. _connection-sharing:
|
||||
|
||||
Using a connection sharing solution
|
||||
-----------------------------------
|
||||
|
||||
You can either follow `Pierre Schiller's <https://www.youtube.com/c/activemotionpictures/featured>`_ excellent video tutorial or jump to the `text tutorial <zt-installation_>`_.
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<p>
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/xV4R5AukkVw" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
|
||||
Many third party software like `ZEROTIER <https://www.zerotier.com/download/>`_ (Free) or `HAMACHI <https://vpn.net/>`_ (Free until 5 users) allow you to share your private network with other people.
|
||||
For the example I'm gonna use ZeroTier because it's free and open source.
|
||||
|
||||
.. _zt-installation:
|
||||
|
||||
1. Installation
|
||||
^^^^^^^^^^^^^^^
|
||||
|
||||
Let's start by downloading and installing ZeroTier:
|
||||
https://www.zerotier.com/download/
|
||||
|
||||
Once installed, launch it.
|
||||
|
||||
2. Network creation
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
To create a ZeroTier private network you need to register a ZeroTier account `on my.zerotier.com <https://my.zerotier.com/login>`_
|
||||
(click on **login** then register on the bottom)
|
||||
|
||||
Once you account it activated, you can connect to `my.zerotier.com <https://my.zerotier.com/login>`_.
|
||||
Head up to the **Network** section (highlighted in red in the image below).
|
||||
|
||||
.. figure:: img/hosting_guide_head_network.png
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
ZeroTier user homepage
|
||||
|
||||
Hit 'Create a network'(see image below) and go to the network settings.
|
||||
|
||||
.. figure:: img/hosting_guide_create_network.png
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Network page
|
||||
|
||||
Now that the network is created, let's configure it.
|
||||
|
||||
In the Settings section(see image below), you can change the network name to what you want.
|
||||
Make sure that the field **Access Control** is set to **PRIVATE**.
|
||||
|
||||
.. hint::
|
||||
If you set the Access Control to PUBLIC, anyone will be able to join without
|
||||
your confirmation. It is easier to set up but less secure.
|
||||
|
||||
.. figure:: img/hosting_guide_network_settings.png
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Network settings
|
||||
|
||||
That's all for the network setup !
|
||||
Now let's connect everyone.
|
||||
|
||||
.. _network-authorization:
|
||||
|
||||
3. Network authorization
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Since your ZeroTier network is Private, you will need to authorize each new user
|
||||
to connect to it.
|
||||
For each user you want to add, do the following step:
|
||||
|
||||
1. Get the client **ZeroTier id** by right clicking on the ZeroTier tray icon and click on the `Node ID`, it will copy it.
|
||||
|
||||
.. figure:: img/hosting_guide_get_node.png
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Get the ZeroTier client id
|
||||
|
||||
2. Go to the network settings in the Member section and paste the Node ID into the Manually Add Member field.
|
||||
|
||||
.. figure:: img/hosting_guide_add_node.png
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Add the client to network-authorized users
|
||||
|
||||
4. Network connection
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
To connect to the ZeroTier network, get the network id from the network settings (see image).
|
||||
|
||||
.. figure:: img/hosting_guide_get_id.png
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Now we are ready to join the network !
|
||||
Right click on the ZeroTier tray icon and select **Join Network** !
|
||||
|
||||
.. figure:: img/hosting_guide_join_network.png
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
.. figure:: img/hosting_guide_join.png
|
||||
:align: center
|
||||
|
||||
Joining the network
|
||||
|
||||
Past the network id and check ``Allow Managed`` then click on join !
|
||||
You should be connected to the network.
|
||||
|
||||
Let's check the connection status. Right click on the tray icon and click on **Show Networks...**.
|
||||
|
||||
.. figure:: img/hosting_guide_show_network.png
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Show network status
|
||||
|
||||
.. figure:: img/hosting_guide_network_status.png
|
||||
:align: center
|
||||
|
||||
Network status.
|
||||
|
||||
The network status must be **OK** for each user(like in the picture above) otherwise it means that you are not connected to the network.
|
||||
If you see something like **ACCESS_DENIED**, it means that you were not authorized to join the network. Please check the section :ref:`network-authorization`
|
||||
|
||||
This is it for the ZeroTier network setup. Now everything should be setup to use the multi-user add-on over internet ! You can now follow the :ref:`quickstart` guide to start using the multi-user add-on !
|
||||
|
||||
.. _port-forwarding:
|
||||
|
||||
Using port-forwarding
|
||||
---------------------
|
||||
|
||||
The port forwarding method consists of configuring your network router to deny most traffic with a firewall, but to then allow particular internet traffic (like a multiuser connection) through the firewall on specified ports.
|
||||
|
||||
In order to know which ports are used by the add-on, please check the :ref:`port-setup` section.
|
||||
To set up port forwarding for each port you can follow this `guide <https://www.wikihow.com/Set-Up-Port-Forwarding-on-a-Router>`_ for example.
|
||||
|
||||
Once you have set up the network you can follow the :ref:`quickstart` guide to begin using the multi-user add-on !
|
||||
|
||||
.. _host-dedicated:
|
||||
|
||||
--------------------------
|
||||
From the dedicated server
|
||||
--------------------------
|
||||
|
||||
.. warning::
|
||||
The dedicated server is developed to run directly on an internet server (like a VPS (Virtual Private Server)). You can also run it at home on a LAN but for internet hosting you need to follow the :ref:`port-forwarding` setup first. Please see :ref:`host-cloud` for a detailed walkthrough of cloud hosting using Google Cloud.
|
||||
|
||||
The dedicated server allows you to host a session with simplicity from any location.
|
||||
It was developed to improve internet hosting performance (for example poor latency).
|
||||
|
||||
The dedicated server can be run in two ways:
|
||||
|
||||
- :ref:`cmd-line`
|
||||
- :ref:`docker`
|
||||
|
||||
.. Note:: There are shell scripts to conveniently start a dedicated server via either of these approaches available in the gitlab repository. See section: :ref:`serverstartscripts`
|
||||
|
||||
.. _cmd-line:
|
||||
|
||||
Using a regular command line
|
||||
----------------------------
|
||||
|
||||
You can run the dedicated server on any platform by following these steps:
|
||||
|
||||
1. Firstly, download and intall python 3 (3.6 or above).
|
||||
2. Install the latest version of the replication library:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python -m pip install replication==0.1.13
|
||||
|
||||
4. Launch the server with:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
replication.server
|
||||
|
||||
.. hint::
|
||||
You can also specify a custom **port** (-p), **timeout** (-t), **admin password** (-pwd), **log level (ERROR, WARNING, INFO or DEBUG)** (-l) and **log file** (-lf) with the following optional arguments
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
replication.server -p 5555 -pwd admin -t 5000 -l INFO -lf server.log
|
||||
|
||||
Here, for example, a server is instantiated on port 5555, with password 'admin', a 5 second timeout, and logging enabled.
|
||||
|
||||
As soon as the dedicated server is running, you can connect to it from blender by following :ref:`how-to-join`.
|
||||
|
||||
|
||||
.. hint::
|
||||
Some server commands are available to enable administrators to manage a multi-user session. Check :ref:`dedicated-management` to learn more.
|
||||
|
||||
|
||||
.. _docker:
|
||||
|
||||
Using a pre-configured image on docker engine
|
||||
---------------------------------------------
|
||||
|
||||
Launching the dedicated server from a docker server is simple as running:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
docker run -d \
|
||||
-p 5555-5560:5555-5560 \
|
||||
-e port=5555 \
|
||||
-e log_level=DEBUG \
|
||||
-e password=admin \
|
||||
-e timeout=5000 \
|
||||
registry.gitlab.com/slumber/multi-user/multi-user-server:latest
|
||||
|
||||
Please use the :latest tag, or otherwise use the URL of the most recent container available in the `multi-user container registry <https://gitlab.com/slumber/multi-user/container_registry/1174180>`_. As soon as the dedicated server is running, you can connect to it from blender by following :ref:`how-to-join`.
|
||||
|
||||
You can check that your container is running, and find its ID and name with:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
docker ps
|
||||
|
||||
.. _docker-logs:
|
||||
|
||||
Viewing logs in a docker container
|
||||
----------------------------------
|
||||
|
||||
Logs for the server running in a docker container can be accessed by outputting the container logs to a log file. First, you'll need to know your container ID, which you can find by running:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
docker ps
|
||||
|
||||
Then, output the container logs to a file:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
docker logs your-container-id >& dockerserver.log
|
||||
|
||||
.. Note:: If using WSL2 on Windows 10 (Windows Subsystem for Linux), it is preferable to run a dedicated server via regular command line approach (or the associated startup script) from within Windows - docker desktop for windows 10 usually uses the WSL2 backend where it is available.
|
||||
|
||||
.. This may not be true. Need to write up how to locally start a docker container from WSL2
|
||||
|
||||
|
||||
Downloading logs from a docker container on a cloud-hosted server
|
||||
-----------------------------------------------------------------
|
||||
|
||||
If you'd like to pull the log files from a cloud-hosted server to submit to a developer for review, a simple process using SSH and SCP is as follows:
|
||||
|
||||
First SSH into your instance. You can either open the `VM Instances console <https://console.cloud.google.com/compute/instances>`_ and use the browser terminal provided by Google Cloud (I had the best luck using the Google Chrome browser)... or you can see `here <https://cloud.google.com/compute/docs/instances/connecting-advanced#thirdpartytools>`_ for how to set up your instance for SSH access from your local terminal.
|
||||
|
||||
If using SSH from your terminal, first generate SSH keys (setting their access permissions to e.g. chmod 400 level whereby only the user has permissions) and submit the public key to the cloud-hosted VM instance, storing the private key on your local machine.
|
||||
Then, SSH into your cloud server from your local terminal, with the following command:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
ssh -i PATH_TO_PRIVATE_KEY USERNAME@EXTERNAL_IP_ADDRESS
|
||||
|
||||
Use the private key which corresponds to the public key you uploaded, and the username associated with that key (visible in the Google Cloud console for your VM Instance). Use the external IP address for the server, available from the `VM Instances console <https://console.cloud.google.com/compute/instances>`_
|
||||
e.g.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
ssh -i ~/.ssh/id_rsa user@xxx.xxx.xxx.xxx
|
||||
|
||||
Once you've connected to the server's secure shell, you can generate a log file from the docker container running the replication server. First, you'll need to know your container ID, which you can find by running:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
docker ps
|
||||
|
||||
If you're cloud-hosting with e.g. Google Cloud, your container will be the one associated with the `registry address <https://gitlab.com/slumber/multi-user/container_registry/1174180>`_ where your Docker image was located. e.g. registry.gitlab.com/slumber/multi-user/multi-user-server:latest
|
||||
|
||||
To view the docker container logs, run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
docker logs your-container-name
|
||||
|
||||
OR
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
docker logs your-container-id
|
||||
|
||||
To save the output to a file, run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
docker logs your-container-id >& dockerserver.log
|
||||
|
||||
Now that the server logs are available in a file, we can disconnect from the secure shell (SSH), and then copy the file to the local machine using SCP. In your local terminal, execute the following:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
scp -i PATH_TO_PRIVATE_KEY USERNAME@EXTERNAL_IP_ADDRESS:"dockerserver.log" LOCAL_PATH_TO_COPY_FILE_TO
|
||||
|
||||
e.g.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
scp -i ~/.ssh/id_rsa user@xxx.xxx.xxx.xxx:"dockerserver.log" .
|
||||
|
||||
This copies the file dockerserver.log generated in the previous step to the current directory on the local machine. From there, you can send it to the multi-user maintainers for review.
|
||||
|
||||
|
||||
.. Note:: See these `notes <https://cloud.google.com/compute/docs/containers/deploying-containers?_ga=2.113663175.-1396941296.1606125558#viewing_container_logs>`_ for how to check server logs on Google Cloud using other tools.
|
||||
|
||||
|
||||
.. _serverstartscripts:
|
||||
|
||||
Server startup scripts
|
||||
----------------------
|
||||
|
||||
Convenient scripts are available in the Gitlab repository: https://gitlab.com/slumber/multi-user/scripts/startup_scripts/
|
||||
|
||||
Simply run the relevant script in a shell on the host machine to start a server with one line of code via replication directly or via a docker container. Choose between the two methods:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
./start-server.sh
|
||||
|
||||
or
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
./run-dockerfile.sh
|
||||
|
||||
.. hint::
|
||||
Once your server is up and running, some commands are available to manage the session :ref:`dedicated-management`
|
||||
|
||||
.. _dedicated-management:
|
||||
|
||||
Dedicated server management
|
||||
---------------------------
|
||||
|
||||
Here is the list of available commands from the dedicated server:
|
||||
|
||||
- ``help`` or ``?``: Show all commands. Or, use ``help <command>`` to learn about another command
|
||||
- ``exit`` or ``Ctrl+C`` : Stop the server.
|
||||
- ``kick username``: kick the provided user.
|
||||
- ``users``: list all online users.
|
||||
|
||||
Also, see :ref:`how-to-manage` for more details on managing a server.
|
||||
|
||||
.. _cloud-dockermanage:
|
||||
|
||||
Managing a docker server from the command line
|
||||
----------------------------------------------
|
||||
If you want to be able to manage a server running within a docker container, open the terminal on the host machine (or SSH in, if you are using cloud hosting), and then run
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
docker ps
|
||||
|
||||
to find your container id, and then
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
docker attach your-container-id
|
||||
|
||||
to attach to the STDOUT from the container. There, you can issue the server management commands detailed in :ref:`dedicated-management`. Type ``?`` and hit return/enter to see the available commands. Also, see :ref:`how-to-manage` for more details on managing a server.
|
||||
|
||||
.. _port-setup:
|
||||
|
||||
----------
|
||||
Port setup
|
||||
----------
|
||||
|
||||
The multi-user network architecture is based on a client-server model. The communication protocol uses four ports to communicate with clients:
|
||||
|
||||
* Commands: command transmission (such as **snapshots**, **change_rights**, etc.) [user-nominated port]
|
||||
* Subscriber : pull data [Commands port + 1]
|
||||
* Publisher : push data [Commands port + 2]
|
||||
* TTL (time to leave) : used to ping each client [Commands port + 3]
|
||||
|
||||
To know which ports will be used, you just have to read the port in your preferences.
|
||||
|
||||
.. figure:: img/hosting_guide_port.png
|
||||
:align: center
|
||||
:alt: Port
|
||||
:width: 200px
|
||||
|
||||
Port in host settings
|
||||
|
||||
In the picture below we have setup our port to **5555** so the four ports will be:
|
||||
|
||||
* Commands: **5555** (5555)
|
||||
* Subscriber: **5556** (5555 +1)
|
||||
* Publisher: **5557** (5555 +2)
|
||||
* TTL: **5558** (5555 +3)
|
||||
|
||||
Those four ports need to be accessible from the client otherwise multi-user won't work at all !
|
||||
|
||||
.. _host-cloud:
|
||||
|
||||
-------------------------
|
||||
Cloud Hosting Walkthrough
|
||||
-------------------------
|
||||
|
||||
The following is a walkthrough for how to set up a multi-user dedicated server instance on a cloud hosting provider - in this case, `Google Cloud <https://www.cloud.google.com>`_. Google Cloud is a powerful hosting service with a worldwide network of servers. It offers a free trial which provides free cloud hosting for 90 days, and then a free tier which runs indefinitely thereafter, so long as you stay within the `usage limits <https://cloud.google.com/free/docs/gcp-free-tier#free-tier-usage-limits>`_. ^^Thanks to community member @NotFood for the tip!
|
||||
|
||||
Cloud hosting is a little more complicated to set up, but it can be valuable if you are trying to host a session with multiple friends scattered about planet earth. This can resolve issues with data replication or slowdowns due to poor latency of some users (high ping). This guide may seem technical, but if you follow the steps, you should be able to succeed in hosting an internet server to co-create with other multi-user creators around the world.
|
||||
|
||||
Setup Process
|
||||
-------------
|
||||
|
||||
1. Sign Up for Google Cloud
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Let's start by activating an account with Google Cloud. Go to https://www.cloud.google.com and click 'Get Started For Free'
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_1.jpg
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Google will ask you to login/signup, and to set up a billing account (Don't worry. It will not be charged unless you explicitly enable billing and then run over your `free credit allowance <https://cloud.google.com/free/docs/gcp-free-tier>`_). You will need to choose a billing country (relevant for `tax purposes <https://cloud.google.com/billing/docs/resources/vat-overview>`_). You will choose your server location at a later step.
|
||||
|
||||
2. Enable Billing and Compute Engine API
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
From here on, we will mostly stick to the instructions provided `here <https://cloud.google.com/compute/docs/quickstart-linux>`_. Nevertheless, the instructions for multi-user specifically are as follows.
|
||||
|
||||
In order to set up a Virtual Machine (VM) to host your server, you will need to enable the billing account which was created during your signup process. From your `console <https://console.cloud.google.com/getting-started>`_, click on 'Go to Checklist' and then 'Create a Billing Account', following the prompts to choose the billing account that was created for you upon signup.
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_2.jpg
|
||||
:align: center
|
||||
:width: 300px
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_3.jpg
|
||||
:align: center
|
||||
:width: 300px
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_4.jpg
|
||||
:align: center
|
||||
:width: 300px
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_5.jpg
|
||||
:align: center
|
||||
:width: 300px
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_6.jpg
|
||||
:align: center
|
||||
:width: 300px
|
||||
|
||||
Now hit 'Set Account', and go back to your `console <https://console.cloud.google.com/getting-started>`_.
|
||||
|
||||
Now enable the Compute Engine API. Click `here <https://console.cloud.google.com/apis/api/compute.googleapis.com/overview>`_ to enable.
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_7.jpg
|
||||
:align: center
|
||||
:width: 300px
|
||||
|
||||
3. Create a Linux Virtual Machine Instance
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
Continue following the `instructions <https://cloud.google.com/compute/docs/quickstart-linux#create_a_virtual_machine_instance>`_ to create a VM instance. However, once you've finished step 2 of 'Create a virtual machine instance', use the settings and steps for multi-user as follows.
|
||||
|
||||
.. _server-location:
|
||||
|
||||
3.1 Choose a Server Location
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The most important settings which you will need to choose for your specific case are the server Region and Zone. You must choose a location which will provide the best ping for all of your fellow creators.
|
||||
|
||||
All you need to know is that you'll probably want to choose a location near to where most of your collaborators are located. If your friends are spread out, somewhere in the middle which distributes the ping evenly to all users is best.
|
||||
|
||||
You can use `this map <https://cloud.google.com/about/locations/>`_ to make a rough guess of the best server location, if you know your friends' locations.
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_9.jpg
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
A much better approach is to have your users run a ping test for Google Cloud's servers at https://www.gcping.com/
|
||||
|
||||
Have your collaborators open this webpage from their fastest browser, and press the play button. The play button turns to a stop icon while the ping test is running. When it is complete, the play button returns. You may need to refresh your browser to get this to work. You can replay the test to add more server locations to the scan, and stop when you are satisfied that the results are consistent.
|
||||
|
||||
Now, gather your friends' data, and work down each user's list from the top, until you find the first location which gives roughly the same ping for all users.
|
||||
|
||||
In general, global (using load balancing) will provide the best results, but beyond that, the US Central servers e.g. IOWA generally turn out best for a globally distributed bunch of creators. When in doubt, choose between the servers offered under the `free tier <https://cloud.google.com/free/docs/gcp-free-tier>`_
|
||||
|
||||
- Oregon: *us-west1*
|
||||
|
||||
- Iowa: *us-central1*
|
||||
|
||||
- South Carolina: *us-east1*
|
||||
|
||||
For the following example, the server which gave the most balanced, and lowest average ping between two friends based in Europe and Australia was in Iowa. Salt Lake City would also be an excellent choice.
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_10.jpg
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Left - European User | Right - Australian User
|
||||
|
||||
Now, input this server location in the 'Region' field for your instance, and leave the default zone which is then populated.
|
||||
|
||||
.. Note:: You can read `here <https://cloud.google.com/solutions/best-practices-compute-engine-region-selection>`_ for a deeper understanding about how to choose a good server location.
|
||||
|
||||
3.2 Configure the VM
|
||||
^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
You can deploy the replication server to your VM in either of the ways mentioned at :ref:`host-dedicated`. That is, you can set it up :ref:`cmd-line` or :ref:`docker`. We will go through both options in this walkthrough. See :ref:`container_v_direct` for more details on how to choose. Deploying a container is the recommended approach.
|
||||
|
||||
.. _cloud-container:
|
||||
|
||||
Option 1 - Deploy a container
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
If you are familiar with Docker, you'll appreciate that it makes life a little simpler for us. While configuring your instance, you can check **Deploy a container to this VM instance** and copy in the URL of the latest docker image available from the `multi-user container registry <https://gitlab.com/slumber/multi-user/container_registry/1174180>`_ to the *Container image* field, or use the tag ``:latest``
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_8b.jpg
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Your configuration with Docker should look like this
|
||||
|
||||
Make sure to choose the amount of memory you'd like your server to be able to handle (how much memory does your blender scene require?). In this example, I've chosen 4GB of RAM.
|
||||
|
||||
Click on **Advanced container options** and turn on *Allocate a buffer for STDIN* and *Allocate a pseudo-TTY* just in case you want to run an interactive shell in your container.
|
||||
|
||||
.. _cloud-optional-parameters:
|
||||
|
||||
Optional server parameters
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The default Docker image essentially runs the equivalent of:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
replication.server -pwd admin -p 5555 -t 5000 -l DEBUG -lf multiuser_server.log
|
||||
|
||||
This means the server will be launched with 'admin' as the administrator password, run on ports 5555:5558, use a timeout of 5 seconds, verbose 'DEBUG' log level, and with log files written to 'multiuser_server.log'. See :ref:`cmd-line` for a description of optional parameters.
|
||||
|
||||
.. Note:: If you'd like to configure different server options from the default docker configuration, you can insert your options here by expanding 'Advanced container options'
|
||||
|
||||
For example, I would like to launch my server with a different administrator password than the default, my own log filename, and a shorter 3-second (3000ms) timeout. I'll click *Add argument* under **Command arguments** and paste the following command with options into the "command arguments" field:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python3 -m replication.server -pwd supersecretpassword -p 5555 -t 3000 -l DEBUG -lf logname.log
|
||||
|
||||
Now, my configuration should look like this:
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_8c.jpg
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
The rest of the settings are now complete. Hit **Create** and your instance will go live. If you've taken this approach, you're already almost there! Skip to :ref:`cloud-firewall`.
|
||||
|
||||
.. hint:: You can find further information on configuration options `here <https://cloud.google.com/compute/docs/containers/configuring-options-to-run-containers>`_. Also, see these `notes <https://cloud.google.com/compute/docs/containers/deploying-containers?_ga=2.113663175.-1396941296.1606125558#viewing_container_logs>`_ for other options when deploying your server inside a container, including how to access the server's logs.
|
||||
|
||||
.. _cloud-direct:
|
||||
|
||||
Option 2 - Over SSH
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Otherwise, we can run the dedicated server ourselves from the command-line over SSH.
|
||||
|
||||
While creating your instance, keep the default settings mentioned in the `guide <https://cloud.google.com/compute/docs/quickstart-linux#create_a_virtual_machine_instance>`_, however at step 4, choose Debian version 10. Also, there is no need to enable HTTP so skip step 6.
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_8a.jpg
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Your configuration should look like this
|
||||
|
||||
Make sure to choose the amount of memory you'd like your server to be able to handle (how much memory does your blender scene require?). In this example, I've chosen 4GB of RAM.
|
||||
|
||||
Now, finally, click 'Create' to generate your Virtual Machine Instance.
|
||||
|
||||
.. _cloud-firewall:
|
||||
|
||||
4. Setting up Firewall and opening Ports
|
||||
----------------------------------------
|
||||
|
||||
Now that your VM is instanced, you'll need to set up firewall rules, and open the ports required by multi-user. The documentation for VM firewalls on google cloud is `here <https://cloud.google.com/vpc/docs/using-firewalls#listing-rules-vm>`_.
|
||||
|
||||
First, go to the dashboard showing your `VM instances <https://console.cloud.google.com/compute/instances>`_ and note the 'External IP' address for later. This is the address of your server. Then, click 'Set up Firewall Rules'.
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_11.jpg
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Note down your External IP
|
||||
|
||||
Now you will need to create two rules. One to enable communication inbound to your server (ingress), and another to enable outbound communication from your server (egress). Click 'Create Firewall'
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_12.jpg
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Now create a rule exactly as in the image below for the outbound communication (egress).
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_13.jpg
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Egress
|
||||
|
||||
.. Note:: If you set a different port number in :ref:`cloud-optional-parameters`, then use the ports indicated in :ref:`port-setup`
|
||||
|
||||
And another rule exactly as in the image below for the inbound communication (ingress).
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_14.jpg
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Ingress
|
||||
|
||||
Finally, your firewall configuration should look like this.
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_15.jpg
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Final Firewall Configuration
|
||||
|
||||
|
||||
5. Install Replication Server into Virtual Machine
|
||||
--------------------------------------------------
|
||||
|
||||
.. Note:: Skip to :ref:`initialise-server` if you've opted to launch the server by deploying a container. Your server is already live!
|
||||
|
||||
Now that we have set up our Virtual Machine instance, we can SSH into it, and install the Replication Server. Open the `VM Instances console <https://console.cloud.google.com/compute/instances>`_ once more, and SSH into your instance. It's easiest to use the browser terminal provided by Google Cloud (I had the best luck using the Google Chrome browser), but you can also see `here <https://cloud.google.com/compute/docs/instances/connecting-advanced#thirdpartytools>`_ for how to set up your instance for SSH access from your terminal.
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_16.jpg
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Now, a terminal window should pop up in a new browser window looking something like this:
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_17.jpg
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Remember, you had set up the VM with Debian 10. This comes with Python 3.7.3 already installed. The only dependency missing is to set up pip3. So, run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sudo apt install python3-pip
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_18.jpg
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
And now lets install the latest version of replication:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sudo pip3 install replication==0.1.13
|
||||
|
||||
6. Launch Replication Server on VM Instance
|
||||
-------------------------------------------
|
||||
|
||||
We're finally ready to launch the server. Simply run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python3 -m replication.server -p 5555 -pwd admin -t 5000 -l INFO -lf server.log
|
||||
|
||||
See :ref:`cmd-line` for a description of optional parameters
|
||||
|
||||
And your replication server is live! It should stay running in the terminal window until you close it. Copy the external IP that you noted down earlier, available `here <https://console.cloud.google.com/networking/addresses/list>`_ and now you can open Blender and connect to your server!
|
||||
|
||||
.. _initialise-server:
|
||||
|
||||
7. Initialise your Server in Blender
|
||||
------------------------------------
|
||||
|
||||
Once in Blender, make sure your multi-user addon is updated to the latest version. :ref:`update-version`. Then, follow the instructions from :ref:`how-to-join` and connect as an admin user, using the password you launched the server with. Input your external IP, and make sure you're set to JOIN the server. Then, click CONNECT.
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_19.jpg
|
||||
:align: center
|
||||
:width: 200px
|
||||
|
||||
Now as the admin user, you can choose whether to initialise the server with a preloaded scene, or an empty scene
|
||||
|
||||
.. figure:: img/hosting_guide_gcloud_20.jpg
|
||||
:align: center
|
||||
:width: 200px
|
||||
|
||||
Press okay, and now your session is live!
|
||||
|
||||
If you made it this far, congratulations! You can now go ahead and share the external IP address with your friends and co-creators and have fun with real-time collaboration in Blender!
|
||||
|
||||
Hopefully, your cloud server setup has improved your group's overall ping readings, and you're in for a smooth and trouble-free co-creation session.
|
||||
|
||||
.. Note:: If you should so desire, pay attention to your credit and follow the steps `here <https://cloud.google.com/compute/docs/quickstart-linux#clean-up>`_ to close your instance at your discretion.
|
||||
|
||||
.. _container_v_direct:
|
||||
|
||||
Should I deploy a Docker Container or launch a server from Linux VM command-line?
|
||||
------------------------------------------------------
|
||||
|
||||
- Directly from Linux VM - This approach gives you control over your session more easily. However, your server may time out once your SSH link to the server is interrupted (for example, if the admin's computer goes to sleep).
|
||||
- Deploy a Docker Container - This is the recommended approach. This approach is better for leaving a session running without supervision. It can however be more complicated to manage. Use this approach if you'd like a consistent experience with others in the multi-user community, pulling from the most up-to-date docker image maintained by @swann in the multi-user container registry.
|
BIN
docs/tutorials/img/hosting_guide_add_node.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
docs/tutorials/img/hosting_guide_create_network.png
Normal file
After Width: | Height: | Size: 9.8 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_1.jpg
Normal file
After Width: | Height: | Size: 757 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_10.jpg
Normal file
After Width: | Height: | Size: 214 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_11.jpg
Normal file
After Width: | Height: | Size: 249 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_12.jpg
Normal file
After Width: | Height: | Size: 88 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_13.jpg
Normal file
After Width: | Height: | Size: 116 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_14.jpg
Normal file
After Width: | Height: | Size: 97 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_15.jpg
Normal file
After Width: | Height: | Size: 230 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_16.jpg
Normal file
After Width: | Height: | Size: 136 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_17.jpg
Normal file
After Width: | Height: | Size: 687 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_18.jpg
Normal file
After Width: | Height: | Size: 635 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_19.jpg
Normal file
After Width: | Height: | Size: 51 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_2.jpg
Normal file
After Width: | Height: | Size: 204 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_20.jpg
Normal file
After Width: | Height: | Size: 60 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_3.jpg
Normal file
After Width: | Height: | Size: 153 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_4.jpg
Normal file
After Width: | Height: | Size: 67 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_5.jpg
Normal file
After Width: | Height: | Size: 104 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_6.jpg
Normal file
After Width: | Height: | Size: 65 KiB |