Compare commits
282 Commits
v0.1.0
...
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 | |||
7a94c21187 |
@ -2,8 +2,11 @@ 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/deploy.gitlab-ci.yml
|
||||
- local: .gitlab/ci/doc.gitlab-ci.yml
|
||||
|
@ -7,4 +7,8 @@ build:
|
||||
name: multi_user
|
||||
paths:
|
||||
- multi_user
|
||||
only:
|
||||
refs:
|
||||
- master
|
||||
|
||||
|
||||
|
@ -15,4 +15,9 @@ deploy:
|
||||
- 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 push registry.gitlab.com/slumber/multi-user/multi-user-server:${VERSION}
|
||||
- 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
|
||||
|
||||
|
@ -3,3 +3,6 @@ test:
|
||||
image: slumber/blender-addon-testing:latest
|
||||
script:
|
||||
- python3 scripts/test_addon.py
|
||||
only:
|
||||
refs:
|
||||
- master
|
||||
|
66
CHANGELOG.md
@ -65,7 +65,7 @@ All notable changes to this project will be documented in this file.
|
||||
- Unused strict right management strategy
|
||||
- Legacy config management system
|
||||
|
||||
## [0.1.0] - preview
|
||||
## [0.1.0] - 2020-10-05
|
||||
|
||||
### Added
|
||||
|
||||
@ -95,4 +95,66 @@ All notable changes to this project will be documented in this file.
|
||||
- Modifier vertex group assignation
|
||||
- World sync
|
||||
- Snapshot UUID error
|
||||
- The world is not synchronized
|
||||
- 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
|
10
README.md
@ -21,6 +21,10 @@ This tool aims to allow multiple users to work on the same scene over the networ
|
||||
|
||||
See the [documentation](https://multi-user.readthedocs.io/en/latest/) for details.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
See the [troubleshooting guide](https://multi-user.readthedocs.io/en/latest/getting_started/troubleshooting.html) for tips on the most common issues.
|
||||
|
||||
## Current development status
|
||||
|
||||
Currently, not all data-block are supported for replication over the wire. The following list summarizes the status for each ones.
|
||||
@ -36,8 +40,10 @@ Currently, not all data-block are supported for replication over the wire. The f
|
||||
| image | ✔️ | |
|
||||
| mesh | ✔️ | |
|
||||
| material | ✔️ | |
|
||||
| node_groups | ❗ | Material only |
|
||||
| metaball | ✔️ | |
|
||||
| object | ✔️ | |
|
||||
| textures | ❗ | Supported for modifiers only |
|
||||
| texts | ✔️ | |
|
||||
| scene | ✔️ | |
|
||||
| world | ✔️ | |
|
||||
@ -45,10 +51,10 @@ Currently, not all data-block are supported for replication over the wire. The f
|
||||
| compositing | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/46) |
|
||||
| texts | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/81) |
|
||||
| nla | ❌ | |
|
||||
| volumes | ❌ | |
|
||||
| volumes | ✔️ | |
|
||||
| particles | ❌ | [On-going](https://gitlab.com/slumber/multi-user/-/issues/24) |
|
||||
| speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) |
|
||||
| vse | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) |
|
||||
| vse | ❗ | Mask and Clip not supported yet |
|
||||
| physics | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) |
|
||||
| libraries | ❗ | Partial |
|
||||
|
||||
|
@ -19,10 +19,10 @@ import sys
|
||||
|
||||
project = 'multi-user'
|
||||
copyright = '2020, Swann Martinez'
|
||||
author = 'Swann Martinez'
|
||||
author = 'Swann Martinez, with contributions from Poochy'
|
||||
|
||||
# The full version, including alpha/beta/rc tags
|
||||
release = '0.0.2'
|
||||
release = '0.2.0'
|
||||
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
@ -9,14 +9,14 @@ Glossary
|
||||
|
||||
administrator
|
||||
|
||||
*A session administrator can manage users (kick) and have a write access on
|
||||
each datablock. He could also init a dedicated server repository.*
|
||||
*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 show
|
||||
*Located in the title of the multi-user panel, the session status shows
|
||||
you the connection state.*
|
||||
|
||||
.. figure:: img/quickstart_session_status.png
|
||||
@ -24,7 +24,7 @@ Glossary
|
||||
|
||||
Session status in panel title bar
|
||||
|
||||
All possible state are listed here with their meaning:*
|
||||
All possible connection states are listed here with their meaning:*
|
||||
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
| State | Description |
|
||||
@ -33,7 +33,7 @@ Glossary
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
| FETCHING | Dowloading snapshot from the server |
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
| AUTHENTIFICATION | Initial server authentication |
|
||||
| AUTHENTICATION | Initial server authentication |
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
| ONLINE | Connected to the session |
|
||||
+--------------------+---------------------------------------------------------------------------------------------+
|
||||
@ -55,5 +55,5 @@ Glossary
|
||||
|
||||
common right
|
||||
|
||||
When a data block is under common right, it is available for everyone to modification.
|
||||
The rights will be given to the user that select it first.
|
||||
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_cancel_save_session_data.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
docs/getting_started/img/quickstart_import_session_data.png
Normal file
After Width: | Height: | Size: 106 KiB |
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 559 B |
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_status.png
Normal file
After Width: | Height: | Size: 70 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 |
@ -8,4 +8,5 @@ Getting started
|
||||
|
||||
install
|
||||
quickstart
|
||||
troubleshooting
|
||||
glossary
|
||||
|
@ -5,9 +5,54 @@ Installation
|
||||
.. hint::
|
||||
The process is the same for linux, mac and windows.
|
||||
|
||||
1. Download latest `release <https://gitlab.com/slumber/multi-user/-/jobs/artifacts/master/download?job=build>`_ or `develop (unstable !) <https://gitlab.com/slumber/multi-user/-/jobs/artifacts/develop/download?job=build>`_ build.
|
||||
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.
|
||||
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
|
@ -5,10 +5,10 @@ Quick start
|
||||
===========
|
||||
|
||||
.. hint::
|
||||
*All session related settings are located under: `View3D -> Sidebar -> Multiuser panel`*
|
||||
*All session-related settings are located under: `View3D -> Sidebar -> Multiuser panel`*
|
||||
|
||||
The multi-user is based on a session management system.
|
||||
In this this guide you will quickly learn how to use the collaborative session system in three part:
|
||||
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`
|
||||
@ -19,22 +19,22 @@ In this this guide you will quickly learn how to use the collaborative session s
|
||||
How to host a session
|
||||
=====================
|
||||
|
||||
The multi-user add-on rely on a Client-Server architecture.
|
||||
The server is the heart of the collaborative session,
|
||||
it will allow each users to communicate with each others.
|
||||
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 said **local server** I mean accessible from the LAN (Local Area Network).
|
||||
When I say **local server** I mean a server which is accessible from the LAN (Local Area Network) without requiring an internet connection.
|
||||
|
||||
However sometime you will need to host a session over the internet,
|
||||
in this case I strongly recommand you to read the :ref:`internet-guide` tutorial.
|
||||
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 your user information
|
||||
-----------------------------
|
||||
--------------------------------
|
||||
1. Fill in your user information
|
||||
--------------------------------
|
||||
|
||||
The **User Info** panel (See image below) allow you to constomize your online identity.
|
||||
The **User Info** panel (See image below) allows you to customise your online identity.
|
||||
|
||||
.. figure:: img/quickstart_user_info.png
|
||||
:align: center
|
||||
@ -42,38 +42,38 @@ The **User Info** panel (See image below) allow you to constomize your online id
|
||||
User info panel
|
||||
|
||||
|
||||
Let's fill those tow field:
|
||||
Let's fill in those two fields:
|
||||
|
||||
- **name**: your online name.
|
||||
- **color**: a color used to represent you into other user workspace(see image below).
|
||||
- **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 hilghlited in your profile color.
|
||||
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
|
||||
User viewport representation aka 'User Presence'
|
||||
|
||||
--------------------
|
||||
2. Setup the network
|
||||
--------------------
|
||||
---------------------
|
||||
2. Set up the network
|
||||
---------------------
|
||||
|
||||
When the hosting process will start, the multi-user addon will lauch a local server instance.
|
||||
In the nerwork panel select **HOST**.
|
||||
The **Host sub-panel** (see image below) allow you to configure the server according to:
|
||||
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 wich the server is listening.
|
||||
* **Port**: Port on which the server is listening.
|
||||
* **Start from**: The session initialisation method.
|
||||
|
||||
* **current scenes**: Start with the current blendfile datas.
|
||||
* **an empty scene**: Clear a data and start over.
|
||||
* **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, all of the blend data will be removed !
|
||||
Ensure to save your existing work before launching the session.
|
||||
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.
|
||||
|
||||
@ -84,16 +84,16 @@ The **Host sub-panel** (see image below) allow you to configure the server accor
|
||||
Host network panel
|
||||
|
||||
|
||||
.. note:: Additionnal configuration setting can be found in the :ref:`advanced` section.
|
||||
.. note:: Additional configuration setting can be found in the :ref:`advanced` section.
|
||||
|
||||
Once everything is setup you can hit the **HOST** button to launch the session !
|
||||
Once everything is set up, you can hit the **HOST** button to launch the session!
|
||||
|
||||
It will do two things:
|
||||
This will do two things:
|
||||
|
||||
* Start a local server
|
||||
* Connect you to it as an :ref:`admin`
|
||||
|
||||
During online session, various actions are available to you, go to :ref:`how-to-manage` section to
|
||||
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:
|
||||
@ -101,12 +101,12 @@ learn more about them.
|
||||
How to join a session
|
||||
=====================
|
||||
|
||||
This section describe how join a launched session.
|
||||
Before starting make sure that you have access to the session ip and port.
|
||||
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 your user information
|
||||
-----------------------------
|
||||
--------------------------------
|
||||
1. Fill in your user information
|
||||
--------------------------------
|
||||
|
||||
Follow the user-info_ section for this step.
|
||||
|
||||
@ -114,9 +114,9 @@ Follow the user-info_ section for this step.
|
||||
2. Network setup
|
||||
----------------
|
||||
|
||||
In the nerwork panel select **JOIN**.
|
||||
The **join sub-panel** (see image below) allow you configure the client to join a
|
||||
collaborative session.
|
||||
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
|
||||
@ -124,32 +124,32 @@ collaborative session.
|
||||
|
||||
Connection panel
|
||||
|
||||
Fill those field with your information:
|
||||
Fill in the fields with your information:
|
||||
|
||||
- **IP**: the host ip.
|
||||
- **Port**: the host port.
|
||||
- **Connect as admin**: connect you with **admin rights** (see :ref:`admin` ) to the session.
|
||||
- **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::
|
||||
Additionnal configuration setting can be found in the :ref:`advanced` section.
|
||||
Additional configuration settings can be found in the :ref:`advanced` section.
|
||||
|
||||
Once you've set every field, hit the button **CONNECT** to join the session !
|
||||
When the :ref:`session-status` is **ONLINE** you are online and ready to start to collaborate.
|
||||
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::
|
||||
On the **dedicated server** startup, the session status will get you to the **LOBBY** waiting a admin to start it.
|
||||
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 that an admin launch it.
|
||||
If you are the admin, you just need to init the repository to start the session (see image below).
|
||||
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 online session, various actions are available to you, go to :ref:`how-to-manage` section to
|
||||
During an online session, various actions are available to you. Go to :ref:`how-to-manage` to
|
||||
learn more about them.
|
||||
|
||||
.. _how-to-manage:
|
||||
@ -157,17 +157,17 @@ learn more about them.
|
||||
How to manage a session
|
||||
=======================
|
||||
|
||||
The collaboration quality directly depend on the communication quality. This section describes
|
||||
various tools made in an effort to ease the communication between the different session users.
|
||||
Feel free to suggest any idea for communication tools `here <https://gitlab.com/slumber/multi-user/-/issues/75>`_ .
|
||||
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, the multi-user will replicate your modifications to other instances.
|
||||
In order to avoid annoying other users when you are experimenting, some of those modifications can be ignored via
|
||||
various flags present at the top of the panel (see red area in the image bellow). Those flags are explained in the :ref:`replication` section.
|
||||
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
|
||||
@ -178,32 +178,31 @@ various flags present at the top of the panel (see red area in the image bellow)
|
||||
Monitor online users
|
||||
--------------------
|
||||
|
||||
One of the most vital tool is the **Online user panel**. It list all connected
|
||||
users information's including yours such as :
|
||||
One of the most vital tools is the **Online user panel**. It lists all connected
|
||||
users' information including your own:
|
||||
|
||||
* **Role** : if user is an admin or a regular user.
|
||||
* **Role** : if a user is an admin or a regular user.
|
||||
* **Location**: Where the user is actually working.
|
||||
* **Frame**: When (in frame) the user working.
|
||||
* **Ping**: user connection delay in milliseconds
|
||||
* **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 user related **actions**.
|
||||
Those operators allow you reach the selected user state in tow different dimensions: **SPACE** and **TIME**.
|
||||
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 on
|
||||
the user viewpoint. To disable the snap, click back on the button. This action
|
||||
served different purposes such as easing the review process, working together on
|
||||
wide world.
|
||||
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 localized on another scene, the **snap view** operator will send you to his scene.
|
||||
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
|
||||
@ -211,11 +210,11 @@ wide world.
|
||||
Snap view in action
|
||||
|
||||
Snapping in time
|
||||
---------------
|
||||
----------------
|
||||
|
||||
The **CLOCK button** (Also called **snap time** operator) allow you to snap on
|
||||
the user time (current frame). To disable the snap, click back on the button.
|
||||
This action is built to help various actors to work on the same temporality
|
||||
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
|
||||
@ -230,14 +229,14 @@ Kick a user
|
||||
.. warning:: Only available for :ref:`admin` !
|
||||
|
||||
|
||||
The **CROSS button** (Also called **kick** operator) allow the admin to kick the selected user. On the target user side, the session will properly disconnect.
|
||||
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 users display. During the session,
|
||||
it draw users related information in your viewport such as:
|
||||
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
|
||||
@ -248,11 +247,19 @@ it draw users related information in your viewport such as:
|
||||
|
||||
Presence show flags
|
||||
|
||||
The presence overlay panel (see image above) allow you to enable/disable
|
||||
The presence overlay panel (see image above) allows you to enable/disable
|
||||
various drawn parts via the following flags:
|
||||
|
||||
- **Show selected objects**: display other users current selection
|
||||
- **Show users**: display users current viewpoint
|
||||
- **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
|
||||
|
||||
|
||||
@ -261,40 +268,40 @@ various drawn parts via the following flags:
|
||||
Manage data
|
||||
-----------
|
||||
|
||||
In order to understand replication data managment, a quick introduction to the multi-user data workflow is required.
|
||||
First thing to know: until now, the addon rely on a data-based replication. In simple words, it means that it replicate
|
||||
user's action results.
|
||||
To replicate datablocks between clients the multi-user rely on what tends to be a distributed architecture:
|
||||
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 store the "master" version of the work.
|
||||
- Each client have a local version of the work.
|
||||
- The server stores the "master" version of the work.
|
||||
- Each client has a local version of the work.
|
||||
|
||||
When an artist modified something in the scene, here is what is happening in the background:
|
||||
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 is getting updates, they are stored locally and pushed to every other clients
|
||||
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 right management system prevent
|
||||
multiple users from modifying same data at same time. A datablock may belong to
|
||||
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 right management system will support roles to allow multiple users to
|
||||
work on different aspect of the same datablock.
|
||||
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) allow you to monitor, change datablock states and right manually.
|
||||
The Repository panel (see image below) allows you to monitor, change datablock states and rights manually.
|
||||
|
||||
.. figure:: img/quickstart_properties.png
|
||||
.. figure:: img/quickstart_save_session_data.png
|
||||
:align: center
|
||||
|
||||
Repository panel
|
||||
|
||||
The **show only owned** flag allow you to see which datablocks you are currently modifying.
|
||||
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 fag enabled, it means that you do
|
||||
not have right granted to modify it. So it won't be updated to other client !
|
||||
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:
|
||||
|
||||
@ -312,6 +319,40 @@ Here is a quick list of available actions:
|
||||
| .. 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
|
||||
@ -334,12 +375,12 @@ Network
|
||||
Advanced network settings
|
||||
|
||||
**IPC Port** is the port used for Inter Process Communication. This port is used
|
||||
by the multi-users subprocesses to communicate with each others. If different instances
|
||||
of the multi-user are using the same IPC port it will create conflict !
|
||||
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 it if you need to launch multiple clients from the same
|
||||
computer(or if you try to host and join on the same computer). You should just enter a different
|
||||
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.
|
||||
@ -356,20 +397,20 @@ Replication
|
||||
|
||||
Advanced replication settings
|
||||
|
||||
**Synchronize render settings** (only host) enable replication of EEVEE and CYCLES render settings to match render between clients.
|
||||
**Synchronize render settings** (only host) enable replication of EEVEE and CYCLES render settings to match renders between clients.
|
||||
|
||||
**Synchronize active camera** sync the scene active camera.
|
||||
**Synchronize active camera** sync the scene's active camera.
|
||||
|
||||
**Edit Mode Updates** enable objects update while you are in Edit_Mode.
|
||||
**Edit Mode Updates** enable objects to update while you are in Edit_Mode.
|
||||
|
||||
.. warning:: Edit Mode Updates kill performances with complex objects (heavy meshes, gpencil, etc...).
|
||||
.. warning:: Edit Mode Updates kills the session's performance with complex objects (heavy meshes, gpencil, etc...).
|
||||
|
||||
**Update method** allow you to change how replication update are triggered. Until now two update methode are implemented:
|
||||
**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.
|
||||
- **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 gird** allow to set a custom replication frequency for each type of data-block:
|
||||
**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)
|
||||
@ -378,21 +419,21 @@ Replication
|
||||
Cache
|
||||
-----
|
||||
|
||||
The multi-user allows to replicate external blend dependencies such as images, movies sounds.
|
||||
On each client, those files are stored into the cache folder.
|
||||
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** allows to choose where cached files (images, sound, movies) will be saved.
|
||||
**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 have been written to the disk.
|
||||
**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 file from the cache folder.
|
||||
**Clear cache** will remove all files from the cache folder.
|
||||
|
||||
.. warning:: Clear cash could break your scene image/movie/sound if they are used into the blend !
|
||||
.. 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
|
||||
@ -403,16 +444,16 @@ Log
|
||||
|
||||
Advanced log settings
|
||||
|
||||
**log level** allow to set the logging level of detail. Here is the detail for each values:
|
||||
**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 error |
|
||||
| ERROR | Shows only critical errors |
|
||||
+-----------+-----------------------------------------------+
|
||||
| WARNING | Shows only errors (all kind) |
|
||||
| WARNING | Shows only errors (of all kinds) |
|
||||
+-----------+-----------------------------------------------+
|
||||
| INFO | Shows only status related messages and errors |
|
||||
| INFO | Shows only status-related messages and errors |
|
||||
+-----------+-----------------------------------------------+
|
||||
| DEBUG | Shows every possible information. |
|
||||
| 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.
|
@ -49,6 +49,7 @@ Documentation is organized into the following sections:
|
||||
getting_started/install
|
||||
getting_started/quickstart
|
||||
getting_started/glossary
|
||||
getting_started/troubleshooting
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
@ -1,36 +1,51 @@
|
||||
.. _internet-guide:
|
||||
|
||||
===================
|
||||
Hosting on internet
|
||||
===================
|
||||
=======================
|
||||
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>`_).
|
||||
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 to host a collaborative Session on internet.
|
||||
Hosting a session can be done is several ways:
|
||||
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 server access to people from internet you have tow main option:
|
||||
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 one is the most unsecure, if you have no networking knowledge, you should definitively go to :ref:`connection-sharing`.
|
||||
* 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 its free and open source.
|
||||
For the example I'm gonna use ZeroTier because it's free and open source.
|
||||
|
||||
.. _zt-installation:
|
||||
|
||||
1. Installation
|
||||
^^^^^^^^^^^^^^^
|
||||
@ -47,7 +62,7 @@ To create a ZeroTier private network you need to register a ZeroTier account `on
|
||||
(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).
|
||||
Head up to the **Network** section (highlighted in red in the image below).
|
||||
|
||||
.. figure:: img/hosting_guide_head_network.png
|
||||
:align: center
|
||||
@ -86,7 +101,7 @@ Now let's connect everyone.
|
||||
3. Network authorization
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Since your ZeroTier network is Private, you will need to authorize each new users
|
||||
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:
|
||||
|
||||
@ -104,7 +119,7 @@ For each user you want to add, do the following step:
|
||||
:align: center
|
||||
:width: 450px
|
||||
|
||||
Add the client to network authorized users
|
||||
Add the client to network-authorized users
|
||||
|
||||
4. Network connection
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
@ -144,7 +159,7 @@ Let's check the connection status. Right click on the tray icon and click on **S
|
||||
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 :ref:`network-authorization` section.
|
||||
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 !
|
||||
|
||||
@ -153,12 +168,12 @@ This is it for the ZeroTier network setup. Now everything should be setup to use
|
||||
Using port-forwarding
|
||||
---------------------
|
||||
|
||||
The port forwarding method consist to configure you Network route to allow internet trafic throught specific ports.
|
||||
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 port are used by the add-on, check the :ref:`port-setup` section.
|
||||
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 start using the multi-user add-on !
|
||||
Once you have set up the network you can follow the :ref:`quickstart` guide to begin using the multi-user add-on !
|
||||
|
||||
.. _host-dedicated:
|
||||
|
||||
@ -167,49 +182,52 @@ From the dedicated server
|
||||
--------------------------
|
||||
|
||||
.. warning::
|
||||
The dedicated server is developed to run directly on internet server (like VPS). You can also
|
||||
run it at home for LAN but for internet hosting you need to follow the :ref:`port-forwarding` setup first.
|
||||
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 allow you to host a session with simplicity from any location.
|
||||
It was developed to improve intaernet hosting performance.
|
||||
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 tow ways:
|
||||
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 those steps:
|
||||
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 replication library:
|
||||
2. Install the latest version of the replication library:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python -m pip install replication
|
||||
python -m pip install replication==0.1.13
|
||||
|
||||
4. Launch the server with:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
replication.serve
|
||||
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 optionnal argument
|
||||
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.serve -p 5555 -pwd toto -t 1000 -l INFO -lf server.log
|
||||
replication.server -p 5555 -pwd admin -t 5000 -l INFO -lf server.log
|
||||
|
||||
As soon as the dedicated server is running, you can connect to it from blender (follow :ref:`how-to-join`).
|
||||
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 commands are available to manage the session. Check :ref:`dedicated-management` to learn more.
|
||||
Some server commands are available to enable administrators to manage a multi-user session. Check :ref:`dedicated-management` to learn more.
|
||||
|
||||
|
||||
.. _docker:
|
||||
@ -217,22 +235,134 @@ As soon as the dedicated server is running, you can connect to it from blender (
|
||||
Using a pre-configured image on docker engine
|
||||
---------------------------------------------
|
||||
|
||||
Launching the dedicated server from a docker server is simple as:
|
||||
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=1000 \
|
||||
registry.gitlab.com/slumber/multi-user/multi-user-server:0.0.3
|
||||
-e timeout=5000 \
|
||||
registry.gitlab.com/slumber/multi-user/multi-user-server:latest
|
||||
|
||||
As soon as the dedicated server is running, you can connect to it from blender.
|
||||
You can check the :ref:`how-to-join` section.
|
||||
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::
|
||||
Some commands are available to manage the session. Check :ref:`dedicated-management` to learn more.
|
||||
Once your server is up and running, some commands are available to manage the session :ref:`dedicated-management`
|
||||
|
||||
.. _dedicated-management:
|
||||
|
||||
@ -241,11 +371,30 @@ Dedicated server management
|
||||
|
||||
Here is the list of available commands from the dedicated server:
|
||||
|
||||
- ``help``: Show all commands.
|
||||
- ``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:
|
||||
|
||||
@ -253,14 +402,14 @@ Here is the list of available commands from the dedicated server:
|
||||
Port setup
|
||||
----------
|
||||
|
||||
The multi-user network architecture is based on a clients-server model. The communication protocol use four ports to communicate with client:
|
||||
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.) [given port]
|
||||
* 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 preference.
|
||||
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
|
||||
@ -268,11 +417,315 @@ To know which ports will be used, you just have to read the port in your prefere
|
||||
:width: 200px
|
||||
|
||||
Port in host settings
|
||||
In the picture below we have setup our port to **5555** so it will be:
|
||||
|
||||
* Commands: 5555 (**5555**)
|
||||
* Subscriber: 5556 (**5555** +1)
|
||||
* Publisher: 5557 (**5555** +2)
|
||||
* TTL: 5558 (**5555** +3)
|
||||
In the picture below we have setup our port to **5555** so the four ports will be:
|
||||
|
||||
Those four ports need to be accessible from the client otherwise it won't work at all !
|
||||
* 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_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 |
BIN
docs/tutorials/img/hosting_guide_gcloud_7.jpg
Normal file
After Width: | Height: | Size: 59 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_8a.jpg
Normal file
After Width: | Height: | Size: 109 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_8b.jpg
Normal file
After Width: | Height: | Size: 252 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_8c.jpg
Normal file
After Width: | Height: | Size: 262 KiB |
BIN
docs/tutorials/img/hosting_guide_gcloud_9.jpg
Normal file
After Width: | Height: | Size: 233 KiB |
@ -21,11 +21,11 @@ In order to help with the testing, you have several possibilities:
|
||||
- Test `development branch <https://gitlab.com/slumber/multi-user/-/branches>`_
|
||||
|
||||
--------------------------
|
||||
Filling an issue on Gitlab
|
||||
Filing an issue on Gitlab
|
||||
--------------------------
|
||||
|
||||
The `gitlab issue tracker <https://gitlab.com/slumber/multi-user/issues>`_ is used for bug report and enhancement suggestion.
|
||||
You will need a Gitlab account to be able to open a new issue there and click on "New issue" button.
|
||||
You will need a Gitlab account to be able to open a new issue there and click on "New issue" button in the main multi-user project.
|
||||
|
||||
Here are some useful information you should provide in a bug report:
|
||||
|
||||
@ -35,8 +35,77 @@ Here are some useful information you should provide in a bug report:
|
||||
Contributing code
|
||||
=================
|
||||
|
||||
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
|
||||
In general, this project follows the `Gitflow Workflow <https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow>`_. It may help to understand that there are three different repositories - the upstream (main multi-user project repository, designated in git by 'upstream'), remote (forked repository, designated in git by 'origin'), and the local repository on your machine.
|
||||
The following example suggests how to contribute a feature.
|
||||
|
||||
1. Fork the project into a new repository:
|
||||
https://gitlab.com/yourname/multi-user
|
||||
|
||||
2. Clone the new repository locally:
|
||||
.. code-block:: bash
|
||||
|
||||
git clone https://gitlab.com/yourname/multi-user.git
|
||||
|
||||
3. Keep your fork in sync with the main repository by setting up the upstream pointer once. cd into your git repo and then run:
|
||||
.. code-block:: bash
|
||||
|
||||
git remote add upstream https://gitlab.com/slumber/multi-user.git
|
||||
|
||||
4. Now, locally check out the develop branch, upon which to base your new feature branch:
|
||||
.. code-block:: bash
|
||||
|
||||
git checkout develop
|
||||
|
||||
5. Fetch any changes from the main upstream repository into your fork (especially if some time has passed since forking):
|
||||
.. code-block:: bash
|
||||
|
||||
git fetch upstream
|
||||
|
||||
'Fetch' downloads objects and refs from the repository, but doesn’t apply them to the branch we are working on. We want to apply the updates to the branch we will work from, which we checked out in step 4.
|
||||
|
||||
6. Let's merge any recent changes from the remote upstream (original repository's) 'develop' branch into our local 'develop' branch:
|
||||
.. code-block:: bash
|
||||
|
||||
git merge upstream/develop
|
||||
|
||||
7. Update your forked repository's remote 'develop' branch with the fetched changes, just to keep things tidy. Make sure you haven't committed any local changes in the interim:
|
||||
.. code-block:: bash
|
||||
|
||||
git push origin develop
|
||||
|
||||
8. Locally create your own new feature branch from the develop branch, using the syntax:
|
||||
.. code-block:: bash
|
||||
|
||||
git checkout -b feature/yourfeaturename
|
||||
|
||||
...where 'feature/' designates a feature branch, and 'yourfeaturename' is a name of your choosing
|
||||
|
||||
9. Add and commit your changes, including a commit message:
|
||||
.. code-block:: bash
|
||||
|
||||
git commit -am 'Add fooBar'
|
||||
|
||||
10. Push committed changes to the remote copy of your new feature branch which will be created in this step:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git push -u origin feature/yourfeaturename
|
||||
|
||||
If it's been some time since performing steps 4 through 7, make sure to checkout 'develop' again and pull the latest changes from upstream before checking out and creating feature/yourfeaturename and pushing changes. Alternatively, checkout 'feature/yourfeaturename' and simply run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git rebase upstream/develop
|
||||
|
||||
and your staged commits will be merged along with the changes. More information on `rebasing here <https://git-scm.com/book/en/v2/Git-Branching-Rebasing>`_
|
||||
|
||||
.. Hint:: -u option sets up your locally created new branch to follow a remote branch which is now created with the same name on your remote repository.
|
||||
|
||||
11. Finally, create a new Pull/Merge Request on Gitlab to merge the remote version of this new branch with commited updates, back into the upstream 'develop' branch, finalising the integration of the new feature.
|
||||
Make sure to set the target branch to 'develop' for features and 'master' for hotfixes. Also, include any milestones or labels, and assignees that may be relevant. By default, the Merge option to 'delete source branch when merge request is activated' will be checked.
|
||||
|
||||
12. Thanks for contributing!
|
||||
|
||||
.. Note:: For hotfixes, replace 'feature/' with 'hotfix/' and base the new branch off the parent 'master' branch instead of 'develop' branch. Make sure to checkout 'master' before running step 8
|
||||
.. Note:: Let's follow the Atlassian `Gitflow Workflow <https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow>`_, except for one main difference - submitting a pull request rather than merging by ourselves.
|
||||
.. Note:: See `here <https://philna.sh/blog/2018/08/21/git-commands-to-keep-a-fork-up-to-date/>`_ or `here <https://stefanbauer.me/articles/how-to-keep-your-git-fork-up-to-date>`_ for instructions on how to keep a fork up to date.
|
@ -19,7 +19,7 @@
|
||||
bl_info = {
|
||||
"name": "Multi-User",
|
||||
"author": "Swann Martinez",
|
||||
"version": (0, 1, 0),
|
||||
"version": (0, 3, 0),
|
||||
"description": "Enable real-time collaborative workflow inside blender",
|
||||
"blender": (2, 82, 0),
|
||||
"location": "3D View > Sidebar > Multi-User tab",
|
||||
@ -40,11 +40,11 @@ import sys
|
||||
import bpy
|
||||
from bpy.app.handlers import persistent
|
||||
|
||||
from . import environment, utils
|
||||
from . import environment
|
||||
|
||||
|
||||
DEPENDENCIES = {
|
||||
("replication", '0.0.21a15'),
|
||||
("replication", '0.1.26'),
|
||||
}
|
||||
|
||||
|
||||
@ -58,7 +58,12 @@ def register():
|
||||
level=logging.INFO)
|
||||
|
||||
try:
|
||||
environment.setup(DEPENDENCIES, bpy.app.binary_path_python)
|
||||
if bpy.app.version[1] >= 91:
|
||||
python_binary_path = sys.executable
|
||||
else:
|
||||
python_binary_path = bpy.app.binary_path_python
|
||||
|
||||
environment.setup(DEPENDENCIES, python_binary_path)
|
||||
|
||||
from . import presence
|
||||
from . import operators
|
||||
@ -84,6 +89,8 @@ def register():
|
||||
type=preferences.SessionUser
|
||||
)
|
||||
bpy.types.WindowManager.user_index = bpy.props.IntProperty()
|
||||
bpy.types.TOPBAR_MT_file_import.append(operators.menu_func_import)
|
||||
|
||||
|
||||
def unregister():
|
||||
from . import presence
|
||||
@ -92,6 +99,8 @@ def unregister():
|
||||
from . import preferences
|
||||
from . import addon_updater_ops
|
||||
|
||||
bpy.types.TOPBAR_MT_file_import.remove(operators.menu_func_import)
|
||||
|
||||
presence.unregister()
|
||||
addon_updater_ops.unregister()
|
||||
ui.unregister()
|
||||
|
@ -15,6 +15,7 @@
|
||||
#
|
||||
# ##### END GPL LICENSE BLOCK #####
|
||||
|
||||
import bpy
|
||||
|
||||
__all__ = [
|
||||
'bl_object',
|
||||
@ -37,9 +38,15 @@ __all__ = [
|
||||
'bl_speaker',
|
||||
'bl_font',
|
||||
'bl_sound',
|
||||
'bl_file'
|
||||
'bl_file',
|
||||
# 'bl_sequencer',
|
||||
'bl_node_group',
|
||||
'bl_texture',
|
||||
] # Order here defines execution order
|
||||
|
||||
if bpy.app.version[1] >= 91:
|
||||
__all__.append('bl_volume')
|
||||
|
||||
from . import *
|
||||
from replication.data import ReplicatedDataFactory
|
||||
|
||||
|
@ -42,7 +42,7 @@ KEYFRAME = [
|
||||
]
|
||||
|
||||
|
||||
def dump_fcurve(fcurve: bpy.types.FCurve, use_numpy:bool =True) -> dict:
|
||||
def dump_fcurve(fcurve: bpy.types.FCurve, use_numpy: bool = True) -> dict:
|
||||
""" Dump a sigle curve to a dict
|
||||
|
||||
:arg fcurve: fcurve to dump
|
||||
@ -59,7 +59,7 @@ def dump_fcurve(fcurve: bpy.types.FCurve, use_numpy:bool =True) -> dict:
|
||||
|
||||
if use_numpy:
|
||||
points = fcurve.keyframe_points
|
||||
fcurve_data['keyframes_count'] = len(fcurve.keyframe_points)
|
||||
fcurve_data['keyframes_count'] = len(fcurve.keyframe_points)
|
||||
fcurve_data['keyframe_points'] = np_dump_collection(points, KEYFRAME)
|
||||
|
||||
else: # Legacy method
|
||||
@ -92,7 +92,8 @@ def load_fcurve(fcurve_data, fcurve):
|
||||
|
||||
if use_numpy:
|
||||
keyframe_points.add(fcurve_data['keyframes_count'])
|
||||
np_load_collection(fcurve_data["keyframe_points"], keyframe_points, KEYFRAME)
|
||||
np_load_collection(
|
||||
fcurve_data["keyframe_points"], keyframe_points, KEYFRAME)
|
||||
|
||||
else:
|
||||
# paste dumped keyframes
|
||||
@ -131,11 +132,9 @@ def load_fcurve(fcurve_data, fcurve):
|
||||
class BlAction(BlDatablock):
|
||||
bl_id = "actions"
|
||||
bl_class = bpy.types.Action
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'ACTION_TWEAK'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
return bpy.data.actions.new(data["name"])
|
||||
@ -153,7 +152,11 @@ class BlAction(BlDatablock):
|
||||
dumped_data_path, index=dumped_array_index)
|
||||
|
||||
load_fcurve(dumped_fcurve, fcurve)
|
||||
target.id_root = data['id_root']
|
||||
|
||||
id_root = data.get('id_root')
|
||||
|
||||
if id_root:
|
||||
target.id_root = id_root
|
||||
|
||||
def _dump_implementation(self, data, instance=None):
|
||||
dumper = Dumper()
|
||||
|
@ -25,15 +25,23 @@ from .. import presence, operators, utils
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
|
||||
def get_roll(bone: bpy.types.Bone) -> float:
|
||||
""" Compute the actuall roll of a pose bone
|
||||
|
||||
:arg pose_bone: target pose bone
|
||||
:type pose_bone: bpy.types.PoseBone
|
||||
:return: float
|
||||
"""
|
||||
return bone.AxisRollFromMatrix(bone.matrix_local.to_3x3())[1]
|
||||
|
||||
|
||||
class BlArmature(BlDatablock):
|
||||
bl_id = "armatures"
|
||||
bl_class = bpy.types.Armature
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 0
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'ARMATURE_DATA'
|
||||
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
return bpy.data.armatures.new(data["name"])
|
||||
|
||||
@ -43,8 +51,8 @@ class BlArmature(BlDatablock):
|
||||
'uuid',
|
||||
data['user'],
|
||||
bpy.data.objects
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
if parent_object is None:
|
||||
parent_object = bpy.data.objects.new(
|
||||
data['user_name'], target)
|
||||
@ -93,16 +101,16 @@ class BlArmature(BlDatablock):
|
||||
new_bone.head = bone_data['head_local']
|
||||
new_bone.tail_radius = bone_data['tail_radius']
|
||||
new_bone.head_radius = bone_data['head_radius']
|
||||
# new_bone.roll = bone_data['roll']
|
||||
|
||||
new_bone.roll = bone_data['roll']
|
||||
|
||||
if 'parent' in bone_data:
|
||||
new_bone.parent = target.edit_bones[data['bones']
|
||||
[bone]['parent']]
|
||||
[bone]['parent']]
|
||||
new_bone.use_connect = bone_data['use_connect']
|
||||
|
||||
loader = Loader()
|
||||
loader.load(new_bone, bone_data)
|
||||
|
||||
|
||||
if bpy.context.mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.context.view_layer.objects.active = current_active_object
|
||||
@ -126,8 +134,6 @@ class BlArmature(BlDatablock):
|
||||
'parent',
|
||||
'name',
|
||||
'layers',
|
||||
# 'roll',
|
||||
|
||||
]
|
||||
data = dumper.dump(instance)
|
||||
|
||||
@ -135,6 +141,7 @@ class BlArmature(BlDatablock):
|
||||
if bone.parent:
|
||||
data['bones'][bone.name]['parent'] = bone.parent.name
|
||||
# get the parent Object
|
||||
# TODO: Use id_data instead
|
||||
object_users = utils.get_datablock_users(instance)[0]
|
||||
data['user'] = object_users.uuid
|
||||
data['user_name'] = object_users.name
|
||||
@ -145,6 +152,8 @@ class BlArmature(BlDatablock):
|
||||
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)]
|
||||
|
||||
for bone in instance.bones:
|
||||
data['bones'][bone.name]['roll'] = get_roll(bone)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
|
@ -26,11 +26,9 @@ from .bl_datablock import BlDatablock
|
||||
class BlCamera(BlDatablock):
|
||||
bl_id = "cameras"
|
||||
bl_class = bpy.types.Camera
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'CAMERA_DATA'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
return bpy.data.cameras.new(data["name"])
|
||||
@ -48,12 +46,15 @@ class BlCamera(BlDatablock):
|
||||
|
||||
background_images = data.get('background_images')
|
||||
|
||||
target.background_images.clear()
|
||||
|
||||
if background_images:
|
||||
target.background_images.clear()
|
||||
for img_name, img_data in background_images.items():
|
||||
target_img = target.background_images.new()
|
||||
target_img.image = bpy.data.images[img_name]
|
||||
loader.load(target_img, img_data)
|
||||
img_id = img_data.get('image')
|
||||
if img_id:
|
||||
target_img = target.background_images.new()
|
||||
target_img.image = bpy.data.images[img_id]
|
||||
loader.load(target_img, img_data)
|
||||
|
||||
def _dump_implementation(self, data, instance=None):
|
||||
assert(instance)
|
||||
|
@ -71,15 +71,22 @@ def load_collection_childrens(dumped_childrens, collection):
|
||||
if child_collection.uuid not in dumped_childrens:
|
||||
collection.children.unlink(child_collection)
|
||||
|
||||
def resolve_collection_dependencies(collection):
|
||||
deps = []
|
||||
|
||||
for child in collection.children:
|
||||
deps.append(child)
|
||||
for object in collection.objects:
|
||||
deps.append(object)
|
||||
|
||||
return deps
|
||||
|
||||
class BlCollection(BlDatablock):
|
||||
bl_id = "collections"
|
||||
bl_icon = 'FILE_FOLDER'
|
||||
bl_class = bpy.types.Collection
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = True
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
if self.is_library:
|
||||
@ -104,6 +111,10 @@ class BlCollection(BlDatablock):
|
||||
# Link childrens
|
||||
load_collection_childrens(data['children'], target)
|
||||
|
||||
# FIXME: Find a better way after the replication big refacotoring
|
||||
# Keep other user from deleting collection object by flushing their history
|
||||
utils.flush_history()
|
||||
|
||||
def _dump_implementation(self, data, instance=None):
|
||||
assert(instance)
|
||||
|
||||
@ -124,11 +135,4 @@ class BlCollection(BlDatablock):
|
||||
return data
|
||||
|
||||
def _resolve_deps_implementation(self):
|
||||
deps = []
|
||||
|
||||
for child in self.instance.children:
|
||||
deps.append(child)
|
||||
for object in self.instance.objects:
|
||||
deps.append(object)
|
||||
|
||||
return deps
|
||||
return resolve_collection_dependencies(self.instance)
|
||||
|
@ -24,9 +24,10 @@ import logging
|
||||
from .. import utils
|
||||
from .bl_datablock import BlDatablock
|
||||
from .dump_anything import (Dumper, Loader,
|
||||
np_load_collection,
|
||||
np_dump_collection)
|
||||
|
||||
np_load_collection,
|
||||
np_dump_collection)
|
||||
from .bl_datablock import get_datablock_from_uuid
|
||||
from .bl_material import dump_materials_slots, load_materials_slots
|
||||
|
||||
SPLINE_BEZIER_POINT = [
|
||||
# "handle_left_type",
|
||||
@ -68,8 +69,6 @@ CURVE_METADATA = [
|
||||
'font_bold',
|
||||
'font_bold_italic',
|
||||
'font_italic',
|
||||
'make_local',
|
||||
'materials',
|
||||
'name',
|
||||
'offset',
|
||||
'offset_x',
|
||||
@ -79,7 +78,6 @@ CURVE_METADATA = [
|
||||
'override_create',
|
||||
'override_library',
|
||||
'path_duration',
|
||||
'preview',
|
||||
'render_resolution_u',
|
||||
'render_resolution_v',
|
||||
'resolution_u',
|
||||
@ -113,8 +111,6 @@ CURVE_METADATA = [
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
SPLINE_METADATA = [
|
||||
'hide',
|
||||
'material_index',
|
||||
@ -141,11 +137,9 @@ SPLINE_METADATA = [
|
||||
class BlCurve(BlDatablock):
|
||||
bl_id = "curves"
|
||||
bl_class = bpy.types.Curve
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'CURVE_DATA'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
return bpy.data.curves.new(data["name"], data["type"])
|
||||
@ -159,29 +153,28 @@ class BlCurve(BlDatablock):
|
||||
# load splines
|
||||
for spline in data['splines'].values():
|
||||
new_spline = target.splines.new(spline['type'])
|
||||
|
||||
|
||||
# Load curve geometry data
|
||||
if new_spline.type == 'BEZIER':
|
||||
bezier_points = new_spline.bezier_points
|
||||
bezier_points = new_spline.bezier_points
|
||||
bezier_points.add(spline['bezier_points_count'])
|
||||
np_load_collection(spline['bezier_points'], bezier_points, SPLINE_BEZIER_POINT)
|
||||
|
||||
if new_spline.type == 'POLY':
|
||||
points = new_spline.points
|
||||
np_load_collection(
|
||||
spline['bezier_points'],
|
||||
bezier_points,
|
||||
SPLINE_BEZIER_POINT)
|
||||
|
||||
if new_spline.type in ['POLY', 'NURBS']:
|
||||
points = new_spline.points
|
||||
points.add(spline['points_count'])
|
||||
np_load_collection(spline['points'], points, SPLINE_POINT)
|
||||
# Not working for now...
|
||||
# See https://blender.stackexchange.com/questions/7020/create-nurbs-surface-with-python
|
||||
if new_spline.type == 'NURBS':
|
||||
logging.error("NURBS not supported.")
|
||||
# new_spline.points.add(len(data['splines'][spline]["points"])-1)
|
||||
# for point_index in data['splines'][spline]["points"]:
|
||||
# loader.load(
|
||||
# new_spline.points[point_index], data['splines'][spline]["points"][point_index])
|
||||
|
||||
loader.load(new_spline, spline)
|
||||
|
||||
|
||||
# MATERIAL SLOTS
|
||||
src_materials = data.get('materials', None)
|
||||
if src_materials:
|
||||
load_materials_slots(src_materials, target.materials)
|
||||
|
||||
def _dump_implementation(self, data, instance=None):
|
||||
assert(instance)
|
||||
dumper = Dumper()
|
||||
@ -209,12 +202,13 @@ class BlCurve(BlDatablock):
|
||||
dumper.include_filter = SPLINE_METADATA
|
||||
spline_data = dumper.dump(spline)
|
||||
|
||||
if spline.type == 'POLY':
|
||||
spline_data['points_count'] = len(spline.points)-1
|
||||
spline_data['points'] = np_dump_collection(spline.points, SPLINE_POINT)
|
||||
spline_data['points_count'] = len(spline.points)-1
|
||||
spline_data['points'] = np_dump_collection(
|
||||
spline.points, SPLINE_POINT)
|
||||
|
||||
spline_data['bezier_points_count'] = len(spline.bezier_points)-1
|
||||
spline_data['bezier_points'] = np_dump_collection(spline.bezier_points, SPLINE_BEZIER_POINT)
|
||||
spline_data['bezier_points'] = np_dump_collection(
|
||||
spline.bezier_points, SPLINE_BEZIER_POINT)
|
||||
data['splines'][index] = spline_data
|
||||
|
||||
if isinstance(instance, T.SurfaceCurve):
|
||||
@ -223,6 +217,9 @@ class BlCurve(BlDatablock):
|
||||
data['type'] = 'FONT'
|
||||
elif isinstance(instance, T.Curve):
|
||||
data['type'] = 'CURVE'
|
||||
|
||||
data['materials'] = dump_materials_slots(instance.materials)
|
||||
|
||||
return data
|
||||
|
||||
def _resolve_deps_implementation(self):
|
||||
@ -236,5 +233,16 @@ class BlCurve(BlDatablock):
|
||||
curve.font_bold,
|
||||
curve.font_bold_italic,
|
||||
curve.font_italic])
|
||||
|
||||
return deps
|
||||
|
||||
for material in self.instance.materials:
|
||||
if material:
|
||||
deps.append(material)
|
||||
|
||||
return deps
|
||||
|
||||
def diff(self):
|
||||
if 'EDIT' in bpy.context.mode \
|
||||
and not self.preferences.sync_flags.sync_during_editmode:
|
||||
return False
|
||||
else:
|
||||
return super().diff()
|
@ -21,7 +21,7 @@ from collections.abc import Iterable
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
from replication.constants import DIFF_BINARY, UP
|
||||
from replication.constants import DIFF_BINARY, DIFF_JSON, UP
|
||||
from replication.data import ReplicatedDatablock
|
||||
|
||||
from .. import utils
|
||||
@ -56,7 +56,7 @@ def load_driver(target_datablock, src_driver):
|
||||
loader = Loader()
|
||||
drivers = target_datablock.animation_data.drivers
|
||||
src_driver_data = src_driver['driver']
|
||||
new_driver = drivers.new(src_driver['data_path'])
|
||||
new_driver = drivers.new(src_driver['data_path'], index=src_driver['array_index'])
|
||||
|
||||
# Settings
|
||||
new_driver.driver.type = src_driver_data['type']
|
||||
@ -92,7 +92,6 @@ def load_driver(target_datablock, src_driver):
|
||||
def get_datablock_from_uuid(uuid, default, ignore=[]):
|
||||
if not uuid:
|
||||
return default
|
||||
|
||||
for category in dir(bpy.data):
|
||||
root = getattr(bpy.data, category)
|
||||
if isinstance(root, Iterable) and category not in ignore:
|
||||
@ -107,13 +106,12 @@ class BlDatablock(ReplicatedDatablock):
|
||||
|
||||
bl_id : blender internal storage identifier
|
||||
bl_class : blender internal type
|
||||
bl_delay_refresh : refresh rate in second for observers
|
||||
bl_delay_apply : refresh rate in sec for apply
|
||||
bl_automatic_push : boolean
|
||||
bl_icon : type icon (blender icon name)
|
||||
bl_check_common: enable check even in common rights
|
||||
bl_reload_parent: reload parent
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
instance = kwargs.get('instance', None)
|
||||
@ -123,15 +121,12 @@ class BlDatablock(ReplicatedDatablock):
|
||||
# TODO: use is_library_indirect
|
||||
self.is_library = (instance and hasattr(instance, 'library') and
|
||||
instance.library) or \
|
||||
(self.data and 'library' in self.data)
|
||||
(hasattr(self,'data') and self.data and 'library' in self.data)
|
||||
|
||||
if instance and hasattr(instance, 'uuid'):
|
||||
instance.uuid = self.uuid
|
||||
|
||||
self.diff_method = DIFF_BINARY
|
||||
|
||||
def resolve(self):
|
||||
datablock_ref = None
|
||||
def resolve(self, construct = True):
|
||||
datablock_root = getattr(bpy.data, self.bl_id)
|
||||
datablock_ref = utils.find_from_attr('uuid', self.uuid, datablock_root)
|
||||
|
||||
@ -139,14 +134,20 @@ class BlDatablock(ReplicatedDatablock):
|
||||
try:
|
||||
datablock_ref = datablock_root[self.data['name']]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if construct and not datablock_ref:
|
||||
name = self.data.get('name')
|
||||
logging.debug(f"Constructing {name}")
|
||||
datablock_ref = self._construct(data=self.data)
|
||||
|
||||
if datablock_ref:
|
||||
setattr(datablock_ref, 'uuid', self.uuid)
|
||||
|
||||
self.instance = datablock_ref
|
||||
if datablock_ref is not None:
|
||||
setattr(datablock_ref, 'uuid', self.uuid)
|
||||
self.instance = datablock_ref
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def remove_instance(self):
|
||||
"""
|
||||
@ -199,6 +200,9 @@ class BlDatablock(ReplicatedDatablock):
|
||||
|
||||
if 'action' in data['animation_data']:
|
||||
target.animation_data.action = bpy.data.actions[data['animation_data']['action']]
|
||||
# Remove existing animation data if there is not more to load
|
||||
elif hasattr(target, 'animation_data') and target.animation_data:
|
||||
target.animation_data_clear()
|
||||
|
||||
if self.is_library:
|
||||
return
|
||||
@ -217,7 +221,7 @@ class BlDatablock(ReplicatedDatablock):
|
||||
if not self.is_library:
|
||||
dependencies.extend(self._resolve_deps_implementation())
|
||||
|
||||
logging.debug(f"{self.instance.name} dependencies: {dependencies}")
|
||||
logging.debug(f"{self.instance} dependencies: {dependencies}")
|
||||
return dependencies
|
||||
|
||||
def _resolve_deps_implementation(self):
|
||||
|
@ -54,32 +54,32 @@ class BlFile(ReplicatedDatablock):
|
||||
bl_id = 'file'
|
||||
bl_name = "file"
|
||||
bl_class = Path
|
||||
bl_delay_refresh = 0
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'FILE'
|
||||
bl_reload_parent = True
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.instance = kwargs.get('instance', None)
|
||||
|
||||
if self.instance and not self.instance.exists():
|
||||
raise FileNotFoundError(self.instance)
|
||||
raise FileNotFoundError(str(self.instance))
|
||||
|
||||
self.preferences = utils.get_preferences()
|
||||
self.diff_method = DIFF_BINARY
|
||||
|
||||
def resolve(self):
|
||||
if self.data:
|
||||
self.instance = Path(get_filepath(self.data['name']))
|
||||
def resolve(self, construct = True):
|
||||
self.instance = Path(get_filepath(self.data['name']))
|
||||
|
||||
file_exists = self.instance.exists()
|
||||
if not file_exists:
|
||||
logging.debug("File don't exist, loading it.")
|
||||
self._load(self.data, self.instance)
|
||||
|
||||
return file_exists
|
||||
|
||||
if not self.instance.exists():
|
||||
logging.debug("File don't exist, loading it.")
|
||||
self._load(self.data, self.instance)
|
||||
|
||||
def push(self, socket, identity=None):
|
||||
super().push(socket, identity=None)
|
||||
def push(self, socket, identity=None, check_data=False):
|
||||
super().push(socket, identity=None, check_data=False)
|
||||
|
||||
if self.preferences.clear_memory_filecache:
|
||||
del self.data['file']
|
||||
@ -118,11 +118,7 @@ class BlFile(ReplicatedDatablock):
|
||||
"""
|
||||
Writing the file
|
||||
"""
|
||||
# TODO: check for empty data
|
||||
|
||||
if target.exists() and not self.diff():
|
||||
logging.info(f"{data['name']} already on the disk, skipping.")
|
||||
return
|
||||
try:
|
||||
file = open(target, "wb")
|
||||
file.write(data['file'])
|
||||
@ -135,6 +131,9 @@ class BlFile(ReplicatedDatablock):
|
||||
file.close()
|
||||
|
||||
def diff(self):
|
||||
memory_size = sys.getsizeof(self.data['file'])-33
|
||||
disk_size = self.instance.stat().st_size
|
||||
return memory_size == disk_size
|
||||
if self.preferences.clear_memory_filecache:
|
||||
return False
|
||||
else:
|
||||
memory_size = sys.getsizeof(self.data['file'])-33
|
||||
disk_size = self.instance.stat().st_size
|
||||
return memory_size != disk_size
|
||||
|
@ -30,11 +30,9 @@ from .dump_anything import Dumper, Loader
|
||||
class BlFont(BlDatablock):
|
||||
bl_id = "fonts"
|
||||
bl_class = bpy.types.VectorFont
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'FILE_FONT'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
filename = data.get('filename')
|
||||
|
@ -38,6 +38,24 @@ STROKE_POINT = [
|
||||
|
||||
]
|
||||
|
||||
STROKE = [
|
||||
"aspect",
|
||||
"display_mode",
|
||||
"end_cap_mode",
|
||||
"hardness",
|
||||
"line_width",
|
||||
"material_index",
|
||||
"start_cap_mode",
|
||||
"uv_rotation",
|
||||
"uv_scale",
|
||||
"uv_translation",
|
||||
"vertex_color_fill",
|
||||
]
|
||||
if bpy.app.version[1] >= 91:
|
||||
STROKE.append('use_cyclic')
|
||||
else:
|
||||
STROKE.append('draw_cyclic')
|
||||
|
||||
if bpy.app.version[1] >= 83:
|
||||
STROKE_POINT.append('vertex_color')
|
||||
|
||||
@ -88,13 +106,12 @@ def load_stroke(stroke_data, stroke):
|
||||
"""
|
||||
assert(stroke and stroke_data)
|
||||
|
||||
loader = Loader()
|
||||
loader.load(stroke, stroke_data)
|
||||
|
||||
stroke.points.add(stroke_data["p_count"])
|
||||
|
||||
np_load_collection(stroke_data['points'], stroke.points, STROKE_POINT)
|
||||
|
||||
# HACK: Temporary fix to trigger a BKE_gpencil_stroke_geometry_update to
|
||||
# fix fill issues
|
||||
stroke.uv_scale = stroke_data["uv_scale"]
|
||||
|
||||
def dump_frame(frame):
|
||||
""" Dump a grease pencil frame to a dict
|
||||
@ -108,11 +125,11 @@ def dump_frame(frame):
|
||||
|
||||
dumped_frame = dict()
|
||||
dumped_frame['frame_number'] = frame.frame_number
|
||||
dumped_frame['strokes'] = []
|
||||
|
||||
# TODO: took existing strokes in account
|
||||
dumped_frame['strokes'] = np_dump_collection(frame.strokes, STROKE)
|
||||
dumped_frame['strokes_points'] = []
|
||||
|
||||
for stroke in frame.strokes:
|
||||
dumped_frame['strokes'].append(dump_stroke(stroke))
|
||||
dumped_frame['strokes_points'].append(dump_stroke(stroke))
|
||||
|
||||
return dumped_frame
|
||||
|
||||
@ -128,14 +145,11 @@ def load_frame(frame_data, frame):
|
||||
|
||||
assert(frame and frame_data)
|
||||
|
||||
# frame.frame_number = frame_data['frame_number']
|
||||
|
||||
# TODO: took existing stroke in account
|
||||
|
||||
for stroke_data in frame_data['strokes']:
|
||||
for stroke_data in frame_data['strokes_points']:
|
||||
target_stroke = frame.strokes.new()
|
||||
load_stroke(stroke_data, target_stroke)
|
||||
|
||||
np_load_collection(frame_data['strokes'], frame.strokes, STROKE)
|
||||
|
||||
def dump_layer(layer):
|
||||
""" Dump a grease pencil layer
|
||||
@ -183,13 +197,16 @@ def dump_layer(layer):
|
||||
# 'parent_bone',
|
||||
# 'matrix_inverse',
|
||||
]
|
||||
if layer.id_data.is_annotation:
|
||||
dumper.include_filter.append('thickness')
|
||||
|
||||
dumped_layer = dumper.dump(layer)
|
||||
|
||||
dumped_layer['frames'] = []
|
||||
|
||||
for frame in layer.frames:
|
||||
dumped_layer['frames'].append(dump_frame(frame))
|
||||
|
||||
|
||||
return dumped_layer
|
||||
|
||||
|
||||
@ -211,15 +228,12 @@ def load_layer(layer_data, layer):
|
||||
load_frame(frame_data, target_frame)
|
||||
|
||||
|
||||
|
||||
class BlGpencil(BlDatablock):
|
||||
bl_id = "grease_pencils"
|
||||
bl_class = bpy.types.GreasePencil
|
||||
bl_delay_refresh = 2
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'GREASEPENCIL'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
return bpy.data.grease_pencils.new(data["name"])
|
||||
@ -232,7 +246,7 @@ class BlGpencil(BlDatablock):
|
||||
|
||||
loader = Loader()
|
||||
loader.load(target, data)
|
||||
|
||||
|
||||
# TODO: reuse existing layer
|
||||
for layer in target.layers:
|
||||
target.layers.remove(layer)
|
||||
@ -248,8 +262,8 @@ class BlGpencil(BlDatablock):
|
||||
# target_layer.clear()
|
||||
|
||||
load_layer(layer_data, target_layer)
|
||||
|
||||
|
||||
|
||||
target.layers.update()
|
||||
|
||||
|
||||
|
||||
@ -268,10 +282,12 @@ class BlGpencil(BlDatablock):
|
||||
data = dumper.dump(instance)
|
||||
|
||||
data['layers'] = {}
|
||||
|
||||
|
||||
for layer in instance.layers:
|
||||
data['layers'][layer.info] = dump_layer(layer)
|
||||
|
||||
data["active_layers"] = instance.layers.active.info
|
||||
data["eval_frame"] = bpy.context.scene.frame_current
|
||||
return data
|
||||
|
||||
def _resolve_deps_implementation(self):
|
||||
@ -281,3 +297,18 @@ class BlGpencil(BlDatablock):
|
||||
deps.append(material)
|
||||
|
||||
return deps
|
||||
|
||||
def layer_changed(self):
|
||||
return self.instance.layers.active.info != self.data["active_layers"]
|
||||
|
||||
def frame_changed(self):
|
||||
return bpy.context.scene.frame_current != self.data["eval_frame"]
|
||||
|
||||
def diff(self):
|
||||
if self.layer_changed() \
|
||||
or self.frame_changed() \
|
||||
or bpy.context.mode == 'OBJECT' \
|
||||
or self.preferences.sync_flags.sync_during_editmode:
|
||||
return super().diff()
|
||||
else:
|
||||
return False
|
||||
|
@ -51,11 +51,9 @@ format_to_ext = {
|
||||
class BlImage(BlDatablock):
|
||||
bl_id = "images"
|
||||
bl_class = bpy.types.Image
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'IMAGE_DATA'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
return bpy.data.images.new(
|
||||
@ -71,7 +69,6 @@ class BlImage(BlDatablock):
|
||||
target.source = 'FILE'
|
||||
target.filepath_raw = get_filepath(data['filename'])
|
||||
target.colorspace_settings.name = data["colorspace_settings"]["name"]
|
||||
|
||||
|
||||
def _dump(self, instance=None):
|
||||
assert(instance)
|
||||
@ -96,6 +93,9 @@ class BlImage(BlDatablock):
|
||||
return data
|
||||
|
||||
def diff(self):
|
||||
if self.instance.is_dirty:
|
||||
self.instance.save()
|
||||
|
||||
if self.instance and (self.instance.name != self.data['name']):
|
||||
return True
|
||||
else:
|
||||
@ -103,21 +103,21 @@ class BlImage(BlDatablock):
|
||||
|
||||
def _resolve_deps_implementation(self):
|
||||
deps = []
|
||||
|
||||
if self.instance.packed_file:
|
||||
filename = Path(bpy.path.abspath(self.instance.filepath)).name
|
||||
self.instance.filepath_raw = get_filepath(filename)
|
||||
self.instance.save()
|
||||
# An image can't be unpacked to the modified path
|
||||
# TODO: make a bug report
|
||||
self.instance.unpack(method="REMOVE")
|
||||
|
||||
elif self.instance.source == "GENERATED":
|
||||
filename = f"{self.instance.name}.png"
|
||||
self.instance.filepath = get_filepath(filename)
|
||||
self.instance.save()
|
||||
|
||||
if self.instance.filepath:
|
||||
|
||||
if self.instance.packed_file:
|
||||
filename = Path(bpy.path.abspath(self.instance.filepath)).name
|
||||
self.instance.filepath = get_filepath(filename)
|
||||
self.instance.save()
|
||||
# An image can't be unpacked to the modified path
|
||||
# TODO: make a bug report
|
||||
self.instance.unpack(method="REMOVE")
|
||||
|
||||
elif self.instance.source == "GENERATED":
|
||||
filename = f"{self.instance.name}.png"
|
||||
self.instance.filepath = get_filepath(filename)
|
||||
self.instance.save()
|
||||
|
||||
deps.append(Path(bpy.path.abspath(self.instance.filepath)))
|
||||
|
||||
return deps
|
||||
|
@ -29,11 +29,9 @@ POINT = ['co', 'weight_softbody', 'co_deform']
|
||||
class BlLattice(BlDatablock):
|
||||
bl_id = "lattices"
|
||||
bl_class = bpy.types.Lattice
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'LATTICE_DATA'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
return bpy.data.lattices.new(data["name"])
|
||||
|
@ -26,11 +26,9 @@ from .bl_datablock import BlDatablock
|
||||
class BlLibrary(BlDatablock):
|
||||
bl_id = "libraries"
|
||||
bl_class = bpy.types.Library
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'LIBRARY_DATA_DIRECT'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
with bpy.data.libraries.load(filepath=data["filepath"], link=True) as (sourceData, targetData):
|
||||
|
@ -26,11 +26,9 @@ from .bl_datablock import BlDatablock
|
||||
class BlLight(BlDatablock):
|
||||
bl_id = "lights"
|
||||
bl_class = bpy.types.Light
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'LIGHT_DATA'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
return bpy.data.lights.new(data["name"], data["type"])
|
||||
|
@ -27,11 +27,9 @@ from .bl_datablock import BlDatablock
|
||||
class BlLightprobe(BlDatablock):
|
||||
bl_id = "lightprobes"
|
||||
bl_class = bpy.types.LightProbe
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'LIGHTPROBE_GRID'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
type = 'CUBE' if data['type'] == 'CUBEMAP' else data['type']
|
||||
|
@ -21,12 +21,15 @@ import mathutils
|
||||
import logging
|
||||
import re
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from .dump_anything import Loader, Dumper
|
||||
from .bl_datablock import BlDatablock, get_datablock_from_uuid
|
||||
|
||||
NODE_SOCKET_INDEX = re.compile('\[(\d*)\]')
|
||||
IGNORED_SOCKETS = ['GEOMETRY']
|
||||
|
||||
def load_node(node_data, node_tree):
|
||||
def load_node(node_data: dict, node_tree: bpy.types.ShaderNodeTree):
|
||||
""" Load a node into a node_tree from a dict
|
||||
|
||||
:arg node_data: dumped node data
|
||||
@ -36,20 +39,43 @@ def load_node(node_data, node_tree):
|
||||
"""
|
||||
loader = Loader()
|
||||
target_node = node_tree.nodes.new(type=node_data["bl_idname"])
|
||||
|
||||
target_node.select = False
|
||||
loader.load(target_node, node_data)
|
||||
image_uuid = node_data.get('image_uuid', None)
|
||||
node_tree_uuid = node_data.get('node_tree_uuid', None)
|
||||
|
||||
if image_uuid and not target_node.image:
|
||||
target_node.image = get_datablock_from_uuid(image_uuid,None)
|
||||
target_node.image = get_datablock_from_uuid(image_uuid, None)
|
||||
|
||||
for input in node_data["inputs"]:
|
||||
if hasattr(target_node.inputs[input], "default_value"):
|
||||
try:
|
||||
target_node.inputs[input].default_value = node_data["inputs"][input]["default_value"]
|
||||
except:
|
||||
logging.error(
|
||||
f"Material {input} parameter not supported, skipping")
|
||||
if node_tree_uuid:
|
||||
target_node.node_tree = get_datablock_from_uuid(node_tree_uuid, None)
|
||||
|
||||
inputs_data = node_data.get('inputs')
|
||||
if inputs_data:
|
||||
inputs = [i for i in target_node.inputs if i.type not in IGNORED_SOCKETS]
|
||||
|
||||
for idx, inpt in enumerate(inputs_data):
|
||||
if idx < len(inputs) and hasattr(inputs[idx], "default_value"):
|
||||
try:
|
||||
inputs[idx].default_value = inpt
|
||||
except Exception as e:
|
||||
logging.warning(f"Node {target_node.name} input {inputs[idx].name} parameter not supported, skipping ({e})")
|
||||
else:
|
||||
logging.warning(f"Node {target_node.name} input length mismatch.")
|
||||
|
||||
outputs_data = node_data.get('outputs')
|
||||
if outputs_data:
|
||||
outputs = [o for o in target_node.outputs if o.type not in IGNORED_SOCKETS]
|
||||
for idx, output in enumerate(outputs_data):
|
||||
if idx < len(outputs) and hasattr(outputs[idx], "default_value"):
|
||||
try:
|
||||
outputs[idx].default_value = output
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Node {target_node.name} output {outputs[idx].name} parameter not supported, skipping ({e})")
|
||||
else:
|
||||
logging.warning(
|
||||
f"Node {target_node.name} output length mismatch.")
|
||||
|
||||
|
||||
def load_links(links_data, node_tree):
|
||||
@ -62,8 +88,10 @@ def load_links(links_data, node_tree):
|
||||
"""
|
||||
|
||||
for link in links_data:
|
||||
input_socket = node_tree.nodes[link['to_node']].inputs[int(link['to_socket'])]
|
||||
output_socket = node_tree.nodes[link['from_node']].outputs[int(link['from_socket'])]
|
||||
input_socket = node_tree.nodes[link['to_node']
|
||||
].inputs[int(link['to_socket'])]
|
||||
output_socket = node_tree.nodes[link['from_node']].outputs[int(
|
||||
link['from_socket'])]
|
||||
node_tree.links.new(input_socket, output_socket)
|
||||
|
||||
|
||||
@ -78,8 +106,10 @@ def dump_links(links):
|
||||
links_data = []
|
||||
|
||||
for link in links:
|
||||
to_socket = NODE_SOCKET_INDEX.search(link.to_socket.path_from_id()).group(1)
|
||||
from_socket = NODE_SOCKET_INDEX.search(link.from_socket.path_from_id()).group(1)
|
||||
to_socket = NODE_SOCKET_INDEX.search(
|
||||
link.to_socket.path_from_id()).group(1)
|
||||
from_socket = NODE_SOCKET_INDEX.search(
|
||||
link.from_socket.path_from_id()).group(1)
|
||||
links_data.append({
|
||||
'to_node': link.to_node.name,
|
||||
'to_socket': to_socket,
|
||||
@ -90,7 +120,7 @@ def dump_links(links):
|
||||
return links_data
|
||||
|
||||
|
||||
def dump_node(node):
|
||||
def dump_node(node: bpy.types.ShaderNode) -> dict:
|
||||
""" Dump a single node to a dict
|
||||
|
||||
:arg node: target node
|
||||
@ -105,6 +135,7 @@ def dump_node(node):
|
||||
"show_expanded",
|
||||
"name_full",
|
||||
"select",
|
||||
"bl_label",
|
||||
"bl_height_min",
|
||||
"bl_height_max",
|
||||
"bl_height_default",
|
||||
@ -127,17 +158,27 @@ def dump_node(node):
|
||||
|
||||
dumped_node = node_dumper.dump(node)
|
||||
|
||||
if hasattr(node, 'inputs'):
|
||||
dumped_node['inputs'] = {}
|
||||
dump_io_needed = (node.type not in ['REROUTE', 'OUTPUT_MATERIAL'])
|
||||
|
||||
for i in node.inputs:
|
||||
input_dumper = Dumper()
|
||||
input_dumper.depth = 2
|
||||
input_dumper.include_filter = ["default_value"]
|
||||
if dump_io_needed:
|
||||
io_dumper = Dumper()
|
||||
io_dumper.depth = 2
|
||||
io_dumper.include_filter = ["default_value"]
|
||||
|
||||
if hasattr(node, 'inputs'):
|
||||
dumped_node['inputs'] = []
|
||||
for idx, inpt in enumerate(node.inputs):
|
||||
if hasattr(inpt, 'default_value'):
|
||||
dumped_node['inputs'].append(
|
||||
io_dumper.dump(inpt.default_value))
|
||||
|
||||
if hasattr(node, 'outputs'):
|
||||
dumped_node['outputs'] = []
|
||||
for idx, output in enumerate(node.outputs):
|
||||
if hasattr(output, 'default_value'):
|
||||
dumped_node['outputs'].append(
|
||||
io_dumper.dump(output.default_value))
|
||||
|
||||
if hasattr(i, 'default_value'):
|
||||
dumped_node['inputs'][i.name] = input_dumper.dump(
|
||||
i)
|
||||
if hasattr(node, 'color_ramp'):
|
||||
ramp_dumper = Dumper()
|
||||
ramp_dumper.depth = 4
|
||||
@ -145,7 +186,9 @@ def dump_node(node):
|
||||
'elements',
|
||||
'alpha',
|
||||
'color',
|
||||
'position'
|
||||
'position',
|
||||
'interpolation',
|
||||
'color_mode'
|
||||
]
|
||||
dumped_node['color_ramp'] = ramp_dumper.dump(node.color_ramp)
|
||||
if hasattr(node, 'mapping'):
|
||||
@ -159,105 +202,261 @@ def dump_node(node):
|
||||
dumped_node['mapping'] = curve_dumper.dump(node.mapping)
|
||||
if hasattr(node, 'image') and getattr(node, 'image'):
|
||||
dumped_node['image_uuid'] = node.image.uuid
|
||||
if hasattr(node, 'node_tree') and getattr(node, 'node_tree'):
|
||||
dumped_node['node_tree_uuid'] = node.node_tree.uuid
|
||||
return dumped_node
|
||||
|
||||
|
||||
def dump_node_tree(node_tree: bpy.types.ShaderNodeTree) -> dict:
|
||||
""" Dump a shader node_tree to a dict including links and nodes
|
||||
|
||||
:arg node_tree: dumped shader node tree
|
||||
:type node_tree: bpy.types.ShaderNodeTree
|
||||
:return: dict
|
||||
"""
|
||||
node_tree_data = {
|
||||
'nodes': {node.name: dump_node(node) for node in node_tree.nodes},
|
||||
'links': dump_links(node_tree.links),
|
||||
'name': node_tree.name,
|
||||
'type': type(node_tree).__name__
|
||||
}
|
||||
|
||||
for socket_id in ['inputs', 'outputs']:
|
||||
socket_collection = getattr(node_tree, socket_id)
|
||||
node_tree_data[socket_id] = dump_node_tree_sockets(socket_collection)
|
||||
|
||||
return node_tree_data
|
||||
|
||||
|
||||
def dump_node_tree_sockets(sockets: bpy.types.Collection) -> dict:
|
||||
""" dump sockets of a shader_node_tree
|
||||
|
||||
:arg target_node_tree: target node_tree
|
||||
:type target_node_tree: bpy.types.NodeTree
|
||||
:arg socket_id: socket identifer
|
||||
:type socket_id: str
|
||||
:return: dict
|
||||
"""
|
||||
sockets_data = []
|
||||
for socket in sockets:
|
||||
try:
|
||||
socket_uuid = socket['uuid']
|
||||
except Exception:
|
||||
socket_uuid = str(uuid4())
|
||||
socket['uuid'] = socket_uuid
|
||||
|
||||
sockets_data.append((socket.name, socket.bl_socket_idname, socket_uuid))
|
||||
|
||||
return sockets_data
|
||||
|
||||
|
||||
def load_node_tree_sockets(sockets: bpy.types.Collection,
|
||||
sockets_data: dict):
|
||||
""" load sockets of a shader_node_tree
|
||||
|
||||
:arg target_node_tree: target node_tree
|
||||
:type target_node_tree: bpy.types.NodeTree
|
||||
:arg socket_id: socket identifer
|
||||
:type socket_id: str
|
||||
:arg socket_data: dumped socket data
|
||||
:type socket_data: dict
|
||||
"""
|
||||
# Check for removed sockets
|
||||
for socket in sockets:
|
||||
if not [s for s in sockets_data if socket['uuid'] == s[2]]:
|
||||
sockets.remove(socket)
|
||||
|
||||
# Check for new sockets
|
||||
for idx, socket_data in enumerate(sockets_data):
|
||||
try:
|
||||
checked_socket = sockets[idx]
|
||||
if checked_socket.name != socket_data[0]:
|
||||
checked_socket.name = socket_data[0]
|
||||
except Exception:
|
||||
s = sockets.new(socket_data[1], socket_data[0])
|
||||
s['uuid'] = socket_data[2]
|
||||
|
||||
|
||||
def load_node_tree(node_tree_data: dict, target_node_tree: bpy.types.ShaderNodeTree) -> dict:
|
||||
"""Load a shader node_tree from dumped data
|
||||
|
||||
:arg node_tree_data: dumped node data
|
||||
:type node_tree_data: dict
|
||||
:arg target_node_tree: target node_tree
|
||||
:type target_node_tree: bpy.types.NodeTree
|
||||
"""
|
||||
# TODO: load only required nodes
|
||||
target_node_tree.nodes.clear()
|
||||
|
||||
if not target_node_tree.is_property_readonly('name'):
|
||||
target_node_tree.name = node_tree_data['name']
|
||||
|
||||
if 'inputs' in node_tree_data:
|
||||
socket_collection = getattr(target_node_tree, 'inputs')
|
||||
load_node_tree_sockets(socket_collection, node_tree_data['inputs'])
|
||||
|
||||
if 'outputs' in node_tree_data:
|
||||
socket_collection = getattr(target_node_tree, 'outputs')
|
||||
load_node_tree_sockets(socket_collection, node_tree_data['outputs'])
|
||||
|
||||
# Load nodes
|
||||
for node in node_tree_data["nodes"]:
|
||||
load_node(node_tree_data["nodes"][node], target_node_tree)
|
||||
|
||||
# TODO: load only required nodes links
|
||||
# Load nodes links
|
||||
target_node_tree.links.clear()
|
||||
|
||||
load_links(node_tree_data["links"], target_node_tree)
|
||||
|
||||
|
||||
def get_node_tree_dependencies(node_tree: bpy.types.NodeTree) -> list:
|
||||
def has_image(node): return (
|
||||
node.type in ['TEX_IMAGE', 'TEX_ENVIRONMENT'] and node.image)
|
||||
|
||||
def has_node_group(node): return (
|
||||
hasattr(node, 'node_tree') and node.node_tree)
|
||||
|
||||
deps = []
|
||||
|
||||
for node in node_tree.nodes:
|
||||
if has_image(node):
|
||||
deps.append(node.image)
|
||||
elif has_node_group(node):
|
||||
deps.append(node.node_tree)
|
||||
|
||||
return deps
|
||||
|
||||
|
||||
def dump_materials_slots(materials: bpy.types.bpy_prop_collection) -> list:
|
||||
""" Dump material slots collection
|
||||
|
||||
:arg materials: material slots collection to dump
|
||||
:type materials: bpy.types.bpy_prop_collection
|
||||
:return: list of tuples (mat_uuid, mat_name)
|
||||
"""
|
||||
return [(m.uuid, m.name) for m in materials if m]
|
||||
|
||||
|
||||
def load_materials_slots(src_materials: list, dst_materials: bpy.types.bpy_prop_collection):
|
||||
""" Load material slots
|
||||
|
||||
:arg src_materials: dumped material collection (ex: object.materials)
|
||||
:type src_materials: list of tuples (uuid, name)
|
||||
:arg dst_materials: target material collection pointer
|
||||
:type dst_materials: bpy.types.bpy_prop_collection
|
||||
"""
|
||||
# MATERIAL SLOTS
|
||||
dst_materials.clear()
|
||||
|
||||
for mat_uuid, mat_name in src_materials:
|
||||
mat_ref = None
|
||||
if mat_uuid is not None:
|
||||
mat_ref = get_datablock_from_uuid(mat_uuid, None)
|
||||
else:
|
||||
mat_ref = bpy.data.materials.get(mat_name, None)
|
||||
|
||||
if mat_ref is None:
|
||||
raise Exception(f"Material {mat_name} doesn't exist")
|
||||
|
||||
dst_materials.append(mat_ref)
|
||||
|
||||
|
||||
class BlMaterial(BlDatablock):
|
||||
bl_id = "materials"
|
||||
bl_class = bpy.types.Material
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'MATERIAL_DATA'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
return bpy.data.materials.new(data["name"])
|
||||
|
||||
def _load_implementation(self, data, target):
|
||||
loader = Loader()
|
||||
target.name = data['name']
|
||||
if data['is_grease_pencil']:
|
||||
|
||||
is_grease_pencil = data.get('is_grease_pencil')
|
||||
use_nodes = data.get('use_nodes')
|
||||
|
||||
loader.load(target, data)
|
||||
|
||||
if is_grease_pencil:
|
||||
if not target.is_grease_pencil:
|
||||
bpy.data.materials.create_gpencil_data(target)
|
||||
|
||||
loader.load(
|
||||
target.grease_pencil, data['grease_pencil'])
|
||||
|
||||
if data["use_nodes"]:
|
||||
loader.load(target.grease_pencil, data['grease_pencil'])
|
||||
elif use_nodes:
|
||||
if target.node_tree is None:
|
||||
target.use_nodes = True
|
||||
|
||||
target.node_tree.nodes.clear()
|
||||
|
||||
loader.load(target, data)
|
||||
|
||||
# Load nodes
|
||||
for node in data["node_tree"]["nodes"]:
|
||||
load_node(data["node_tree"]["nodes"][node], target.node_tree)
|
||||
|
||||
# Load nodes links
|
||||
target.node_tree.links.clear()
|
||||
|
||||
load_links(data["node_tree"]["links"], target.node_tree)
|
||||
load_node_tree(data['node_tree'], target.node_tree)
|
||||
|
||||
def _dump_implementation(self, data, instance=None):
|
||||
assert(instance)
|
||||
mat_dumper = Dumper()
|
||||
mat_dumper.depth = 2
|
||||
mat_dumper.exclude_filter = [
|
||||
"is_embed_data",
|
||||
"is_evaluated",
|
||||
"name_full",
|
||||
"bl_description",
|
||||
"bl_icon",
|
||||
"bl_idname",
|
||||
"bl_label",
|
||||
"preview",
|
||||
"original",
|
||||
"uuid",
|
||||
"users",
|
||||
"alpha_threshold",
|
||||
"line_color",
|
||||
"view_center",
|
||||
mat_dumper.include_filter = [
|
||||
'name',
|
||||
'blend_method',
|
||||
'shadow_method',
|
||||
'alpha_threshold',
|
||||
'show_transparent_back',
|
||||
'use_backface_culling',
|
||||
'use_screen_refraction',
|
||||
'use_sss_translucency',
|
||||
'refraction_depth',
|
||||
'preview_render_type',
|
||||
'use_preview_world',
|
||||
'pass_index',
|
||||
'use_nodes',
|
||||
'diffuse_color',
|
||||
'specular_color',
|
||||
'roughness',
|
||||
'specular_intensity',
|
||||
'metallic',
|
||||
'line_color',
|
||||
'line_priority',
|
||||
'is_grease_pencil'
|
||||
]
|
||||
data = mat_dumper.dump(instance)
|
||||
|
||||
if instance.use_nodes:
|
||||
nodes = {}
|
||||
for node in instance.node_tree.nodes:
|
||||
nodes[node.name] = dump_node(node)
|
||||
data["node_tree"]['nodes'] = nodes
|
||||
|
||||
data["node_tree"]["links"] = dump_links(instance.node_tree.links)
|
||||
|
||||
if instance.is_grease_pencil:
|
||||
gp_mat_dumper = Dumper()
|
||||
gp_mat_dumper.depth = 3
|
||||
|
||||
gp_mat_dumper.include_filter = [
|
||||
'color',
|
||||
'fill_color',
|
||||
'mix_color',
|
||||
'mix_factor',
|
||||
'mix_stroke_factor',
|
||||
# 'texture_angle',
|
||||
# 'texture_scale',
|
||||
# 'texture_offset',
|
||||
'pixel_size',
|
||||
'hide',
|
||||
'lock',
|
||||
'ghost',
|
||||
# 'texture_clamp',
|
||||
'flip',
|
||||
'use_overlap_strokes',
|
||||
'show_stroke',
|
||||
'show_fill',
|
||||
'alignment_mode',
|
||||
'pass_index',
|
||||
'mode',
|
||||
'stroke_style',
|
||||
'color',
|
||||
'use_overlap_strokes',
|
||||
'show_fill',
|
||||
# 'stroke_image',
|
||||
'fill_style',
|
||||
'fill_color',
|
||||
'pass_index',
|
||||
'alignment_mode',
|
||||
# 'fill_image',
|
||||
'texture_opacity',
|
||||
'mix_factor',
|
||||
'texture_offset',
|
||||
'texture_angle',
|
||||
'texture_scale',
|
||||
'texture_clamp',
|
||||
'gradient_type',
|
||||
'mix_color',
|
||||
'flip'
|
||||
# 'fill_image',
|
||||
'use_stroke_holdout',
|
||||
'use_overlap_strokes',
|
||||
'use_fill_holdout',
|
||||
]
|
||||
data['grease_pencil'] = gp_mat_dumper.dump(instance.grease_pencil)
|
||||
elif instance.use_nodes:
|
||||
data['node_tree'] = dump_node_tree(instance.node_tree)
|
||||
|
||||
return data
|
||||
|
||||
def _resolve_deps_implementation(self):
|
||||
@ -265,9 +464,7 @@ class BlMaterial(BlDatablock):
|
||||
deps = []
|
||||
|
||||
if self.instance.use_nodes:
|
||||
for node in self.instance.node_tree.nodes:
|
||||
if node.type in ['TEX_IMAGE','TEX_ENVIRONMENT']:
|
||||
deps.append(node.image)
|
||||
deps.extend(get_node_tree_dependencies(self.instance.node_tree))
|
||||
if self.is_library:
|
||||
deps.append(self.instance.library)
|
||||
|
||||
|
@ -25,7 +25,8 @@ import numpy as np
|
||||
from .dump_anything import Dumper, Loader, np_load_collection_primitives, np_dump_collection_primitive, np_load_collection, np_dump_collection
|
||||
from replication.constants import DIFF_BINARY
|
||||
from replication.exception import ContextError
|
||||
from .bl_datablock import BlDatablock
|
||||
from .bl_datablock import BlDatablock, get_datablock_from_uuid
|
||||
from .bl_material import dump_materials_slots, load_materials_slots
|
||||
|
||||
VERTICE = ['co']
|
||||
|
||||
@ -33,6 +34,8 @@ EDGE = [
|
||||
'vertices',
|
||||
'crease',
|
||||
'bevel_weight',
|
||||
'use_seam',
|
||||
'use_edge_sharp',
|
||||
]
|
||||
LOOP = [
|
||||
'vertex_index',
|
||||
@ -49,11 +52,9 @@ POLYGON = [
|
||||
class BlMesh(BlDatablock):
|
||||
bl_id = "meshes"
|
||||
bl_class = bpy.types.Mesh
|
||||
bl_delay_refresh = 2
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'MESH_DATA'
|
||||
bl_reload_parent = True
|
||||
|
||||
def _construct(self, data):
|
||||
instance = bpy.data.meshes.new(data["name"])
|
||||
@ -68,10 +69,9 @@ class BlMesh(BlDatablock):
|
||||
loader.load(target, data)
|
||||
|
||||
# MATERIAL SLOTS
|
||||
target.materials.clear()
|
||||
|
||||
for m in data["material_list"]:
|
||||
target.materials.append(bpy.data.materials[m])
|
||||
src_materials = data.get('materials', None)
|
||||
if src_materials:
|
||||
load_materials_slots(src_materials, target.materials)
|
||||
|
||||
# CLEAR GEOMETRY
|
||||
if target.vertices:
|
||||
@ -116,7 +116,7 @@ class BlMesh(BlDatablock):
|
||||
def _dump_implementation(self, data, instance=None):
|
||||
assert(instance)
|
||||
|
||||
if instance.is_editmode and not self.preferences.sync_flags.sync_during_editmode:
|
||||
if (instance.is_editmode or bpy.context.mode == "SCULPT") and not self.preferences.sync_flags.sync_during_editmode:
|
||||
raise ContextError("Mesh is in edit mode")
|
||||
mesh = instance
|
||||
|
||||
@ -162,14 +162,8 @@ class BlMesh(BlDatablock):
|
||||
data['vertex_colors'][color_map.name] = {}
|
||||
data['vertex_colors'][color_map.name]['data'] = np_dump_collection_primitive(color_map.data, 'color')
|
||||
|
||||
# Fix material index
|
||||
m_list = []
|
||||
for material in instance.materials:
|
||||
if material:
|
||||
m_list.append(material.name)
|
||||
|
||||
data['material_list'] = m_list
|
||||
|
||||
# Materials
|
||||
data['materials'] = dump_materials_slots(instance.materials)
|
||||
return data
|
||||
|
||||
def _resolve_deps_implementation(self):
|
||||
@ -180,3 +174,10 @@ class BlMesh(BlDatablock):
|
||||
deps.append(material)
|
||||
|
||||
return deps
|
||||
|
||||
def diff(self):
|
||||
if 'EDIT' in bpy.context.mode \
|
||||
and not self.preferences.sync_flags.sync_during_editmode:
|
||||
return False
|
||||
else:
|
||||
return super().diff()
|
||||
|
@ -65,11 +65,9 @@ def load_metaball_elements(elements_data, elements):
|
||||
class BlMetaball(BlDatablock):
|
||||
bl_id = "metaballs"
|
||||
bl_class = bpy.types.MetaBall
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'META_BALL'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
return bpy.data.metaballs.new(data["name"])
|
||||
|
45
multi_user/bl_types/bl_node_group.py
Normal file
@ -0,0 +1,45 @@
|
||||
# ##### BEGIN GPL LICENSE BLOCK #####
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# ##### END GPL LICENSE BLOCK #####
|
||||
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
|
||||
from .dump_anything import Dumper, Loader, np_dump_collection, np_load_collection
|
||||
from .bl_datablock import BlDatablock
|
||||
from .bl_material import (dump_node_tree,
|
||||
load_node_tree,
|
||||
get_node_tree_dependencies)
|
||||
|
||||
class BlNodeGroup(BlDatablock):
|
||||
bl_id = "node_groups"
|
||||
bl_class = bpy.types.NodeTree
|
||||
bl_check_common = False
|
||||
bl_icon = 'NODETREE'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
return bpy.data.node_groups.new(data["name"], data["type"])
|
||||
|
||||
def _load_implementation(self, data, target):
|
||||
load_node_tree(data, target)
|
||||
|
||||
def _dump_implementation(self, data, instance=None):
|
||||
return dump_node_tree(instance)
|
||||
|
||||
def _resolve_deps_implementation(self):
|
||||
return get_node_tree_dependencies(self.instance)
|
@ -17,14 +17,75 @@
|
||||
|
||||
|
||||
import logging
|
||||
|
||||
import re
|
||||
import bpy
|
||||
import mathutils
|
||||
from replication.exception import ContextError
|
||||
|
||||
from .bl_datablock import BlDatablock, get_datablock_from_uuid
|
||||
from .dump_anything import Dumper, Loader
|
||||
from replication.exception import ReparentException
|
||||
from .dump_anything import (
|
||||
Dumper,
|
||||
Loader,
|
||||
np_load_collection,
|
||||
np_dump_collection)
|
||||
|
||||
|
||||
|
||||
SKIN_DATA = [
|
||||
'radius',
|
||||
'use_loose',
|
||||
'use_root'
|
||||
]
|
||||
|
||||
def get_input_index(e):
|
||||
return int(re.findall('[0-9]+', e)[0])
|
||||
|
||||
|
||||
def dump_modifier_geometry_node_inputs(modifier: bpy.types.Modifier) -> list:
|
||||
""" Dump geometry node modifier input properties
|
||||
|
||||
:arg modifier: geometry node modifier to dump
|
||||
:type modifier: bpy.type.Modifier
|
||||
"""
|
||||
inputs_name = [p for p in dir(modifier) if "Input_" in p]
|
||||
inputs_name.sort(key=get_input_index)
|
||||
dumped_inputs = []
|
||||
for inputs_index, input_name in enumerate(inputs_name):
|
||||
input_value = modifier[input_name]
|
||||
dumped_input = None
|
||||
if isinstance(input_value, bpy.types.ID):
|
||||
dumped_input = input_value.uuid
|
||||
elif type(input_value) in [int, str, float]:
|
||||
dumped_input = input_value
|
||||
elif hasattr(input_value, 'to_list'):
|
||||
dumped_input = input_value.to_list()
|
||||
dumped_inputs.append(dumped_input)
|
||||
|
||||
return dumped_inputs
|
||||
|
||||
|
||||
def load_modifier_geometry_node_inputs(dumped_modifier: dict, target_modifier: bpy.types.Modifier):
|
||||
""" Load geometry node modifier inputs
|
||||
|
||||
:arg dumped_modifier: source dumped modifier to load
|
||||
:type dumped_modifier: dict
|
||||
:arg target_modifier: target geometry node modifier
|
||||
:type target_modifier: bpy.type.Modifier
|
||||
"""
|
||||
|
||||
inputs_name = [p for p in dir(target_modifier) if "Input_" in p]
|
||||
inputs_name.sort(key=get_input_index)
|
||||
for input_index, input_name in enumerate(inputs_name):
|
||||
dumped_value = dumped_modifier['inputs'][input_index]
|
||||
input_value = target_modifier[input_name]
|
||||
if type(input_value) in [int, str, float]:
|
||||
input_value = dumped_value
|
||||
elif hasattr(input_value, 'to_list'):
|
||||
for index in range(len(input_value)):
|
||||
input_value[index] = dumped_value[index]
|
||||
else:
|
||||
target_modifier[input_name] = get_datablock_from_uuid(
|
||||
dumped_value, None)
|
||||
|
||||
|
||||
def load_pose(target_bone, data):
|
||||
@ -36,7 +97,7 @@ def load_pose(target_bone, data):
|
||||
def find_data_from_name(name=None):
|
||||
instance = None
|
||||
if not name:
|
||||
pass
|
||||
pass
|
||||
elif name in bpy.data.meshes.keys():
|
||||
instance = bpy.data.meshes[name]
|
||||
elif name in bpy.data.lights.keys():
|
||||
@ -64,6 +125,9 @@ def find_data_from_name(name=None):
|
||||
else:
|
||||
logging.warning(
|
||||
"Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396")
|
||||
elif bpy.app.version[1] >= 91 and name in bpy.data.volumes.keys():
|
||||
# Only supported since 2.91
|
||||
instance = bpy.data.volumes[name]
|
||||
return instance
|
||||
|
||||
|
||||
@ -79,14 +143,88 @@ def _is_editmode(object: bpy.types.Object) -> bool:
|
||||
child_data.is_editmode)
|
||||
|
||||
|
||||
def find_textures_dependencies(modifiers: bpy.types.bpy_prop_collection) -> [bpy.types.Texture]:
|
||||
""" Find textures lying in a modifier stack
|
||||
|
||||
:arg modifiers: modifiers collection
|
||||
:type modifiers: bpy.types.bpy_prop_collection
|
||||
:return: list of bpy.types.Texture pointers
|
||||
"""
|
||||
textures = []
|
||||
for mod in modifiers:
|
||||
modifier_attributes = [getattr(mod, attr_name)
|
||||
for attr_name in mod.bl_rna.properties.keys()]
|
||||
for attr in modifier_attributes:
|
||||
if issubclass(type(attr), bpy.types.Texture) and attr is not None:
|
||||
textures.append(attr)
|
||||
|
||||
return textures
|
||||
|
||||
|
||||
def find_geometry_nodes(modifiers: bpy.types.bpy_prop_collection) -> [bpy.types.NodeTree]:
|
||||
""" Find geometry nodes group from a modifier stack
|
||||
|
||||
:arg modifiers: modifiers collection
|
||||
:type modifiers: bpy.types.bpy_prop_collection
|
||||
:return: list of bpy.types.NodeTree pointers
|
||||
"""
|
||||
nodes_groups = []
|
||||
for item in modifiers:
|
||||
if item.type == 'NODES' and item.node_group:
|
||||
nodes_groups.append(item.node_group)
|
||||
|
||||
return nodes_groups
|
||||
|
||||
def dump_vertex_groups(src_object: bpy.types.Object) -> dict:
|
||||
""" Dump object's vertex groups
|
||||
|
||||
:param target_object: dump vertex groups of this object
|
||||
:type target_object: bpy.types.Object
|
||||
"""
|
||||
if isinstance(src_object.data, bpy.types.GreasePencil):
|
||||
logging.warning(
|
||||
"Grease pencil vertex groups are not supported yet. More info: https://gitlab.com/slumber/multi-user/-/issues/161")
|
||||
else:
|
||||
points_attr = 'vertices' if isinstance(
|
||||
src_object.data, bpy.types.Mesh) else 'points'
|
||||
dumped_vertex_groups = {}
|
||||
|
||||
# Vertex group metadata
|
||||
for vg in src_object.vertex_groups:
|
||||
dumped_vertex_groups[vg.index] = {
|
||||
'name': vg.name,
|
||||
'vertices': []
|
||||
}
|
||||
|
||||
# Vertex group assignation
|
||||
for vert in getattr(src_object.data, points_attr):
|
||||
for vg in vert.groups:
|
||||
vertices = dumped_vertex_groups.get(vg.group)['vertices']
|
||||
vertices.append((vert.index, vg.weight))
|
||||
|
||||
return dumped_vertex_groups
|
||||
|
||||
|
||||
def load_vertex_groups(dumped_vertex_groups: dict, target_object: bpy.types.Object):
|
||||
""" Load object vertex groups
|
||||
|
||||
:param dumped_vertex_groups: vertex_groups to load
|
||||
:type dumped_vertex_groups: dict
|
||||
:param target_object: object to load the vertex groups into
|
||||
:type target_object: bpy.types.Object
|
||||
"""
|
||||
target_object.vertex_groups.clear()
|
||||
for vg in dumped_vertex_groups.values():
|
||||
vertex_group = target_object.vertex_groups.new(name=vg['name'])
|
||||
for index, weight in vg['vertices']:
|
||||
vertex_group.add([index], weight, 'REPLACE')
|
||||
|
||||
class BlObject(BlDatablock):
|
||||
bl_id = "objects"
|
||||
bl_class = bpy.types.Object
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'OBJECT_DATA'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
instance = None
|
||||
@ -106,9 +244,13 @@ class BlObject(BlDatablock):
|
||||
data_id = data.get("data")
|
||||
|
||||
object_data = get_datablock_from_uuid(
|
||||
data_uuid,
|
||||
data_uuid,
|
||||
find_data_from_name(data_id),
|
||||
ignore=['images']) #TODO: use resolve_from_id
|
||||
ignore=['images']) # TODO: use resolve_from_id
|
||||
|
||||
if object_data is None and data_uuid:
|
||||
raise Exception(f"Fail to load object {data['name']}({self.uuid})")
|
||||
|
||||
instance = bpy.data.objects.new(object_name, object_data)
|
||||
instance.uuid = self.uuid
|
||||
|
||||
@ -120,31 +262,25 @@ class BlObject(BlDatablock):
|
||||
data_uuid = data.get("data_uuid")
|
||||
data_id = data.get("data")
|
||||
|
||||
if target.type != data['type']:
|
||||
raise ReparentException()
|
||||
elif target.data and (target.data.name != data_id):
|
||||
target.data = get_datablock_from_uuid(data_uuid, find_data_from_name(data_id), ignore=['images'])
|
||||
if target.data and (target.data.name != data_id):
|
||||
target.data = get_datablock_from_uuid(
|
||||
data_uuid, find_data_from_name(data_id), ignore=['images'])
|
||||
|
||||
# vertex groups
|
||||
if 'vertex_groups' in data:
|
||||
target.vertex_groups.clear()
|
||||
for vg in data['vertex_groups']:
|
||||
vertex_group=target.vertex_groups.new(name = vg['name'])
|
||||
point_attr='vertices' if 'vertices' in vg else 'points'
|
||||
for vert in vg[point_attr]:
|
||||
vertex_group.add(
|
||||
[vert['index']], vert['weight'], 'REPLACE')
|
||||
vertex_groups = data.get('vertex_groups', None)
|
||||
if vertex_groups:
|
||||
load_vertex_groups(vertex_groups, target)
|
||||
|
||||
object_data = target.data
|
||||
|
||||
# SHAPE KEYS
|
||||
if 'shape_keys' in data:
|
||||
target.shape_key_clear()
|
||||
|
||||
object_data=target.data
|
||||
|
||||
# Create keys and load vertices coords
|
||||
for key_block in data['shape_keys']['key_blocks']:
|
||||
key_data=data['shape_keys']['key_blocks'][key_block]
|
||||
target.shape_key_add(name = key_block)
|
||||
key_data = data['shape_keys']['key_blocks'][key_block]
|
||||
target.shape_key_add(name=key_block)
|
||||
|
||||
loader.load(
|
||||
target.data.shape_keys.key_blocks[key_block], key_data)
|
||||
@ -160,7 +296,8 @@ class BlObject(BlDatablock):
|
||||
# Load transformation data
|
||||
loader.load(target, data)
|
||||
|
||||
loader.load(target.display, data['display'])
|
||||
if 'display' in data:
|
||||
loader.load(target.display, data['display'])
|
||||
|
||||
# Pose
|
||||
if 'pose' in data:
|
||||
@ -191,10 +328,29 @@ class BlObject(BlDatablock):
|
||||
target_bone.bone_group = target.pose.bone_group[bone_data['bone_group_index']]
|
||||
|
||||
# TODO: find another way...
|
||||
if target.type == 'EMPTY':
|
||||
if target.empty_display_type == "IMAGE":
|
||||
img_uuid = data.get('data_uuid')
|
||||
if target.data is None and img_uuid:
|
||||
target.data = get_datablock_from_uuid(img_uuid, None)#bpy.data.images.get(img_key, None)
|
||||
target.data = get_datablock_from_uuid(img_uuid, None)
|
||||
|
||||
if hasattr(object_data, 'skin_vertices') \
|
||||
and object_data.skin_vertices\
|
||||
and 'skin_vertices' in data:
|
||||
for index, skin_data in enumerate(object_data.skin_vertices):
|
||||
np_load_collection(
|
||||
data['skin_vertices'][index],
|
||||
skin_data.data,
|
||||
SKIN_DATA)
|
||||
|
||||
if hasattr(target, 'cycles_visibility') \
|
||||
and 'cycles_visibility' in data:
|
||||
loader.load(target.cycles_visibility, data['cycles_visibility'])
|
||||
|
||||
# TODO: handle geometry nodes input from dump_anything
|
||||
if hasattr(target, 'modifiers'):
|
||||
nodes_modifiers = [mod for mod in target.modifiers if mod.type == 'NODES']
|
||||
for modifier in nodes_modifiers:
|
||||
load_modifier_geometry_node_inputs(data['modifiers'][modifier.name], modifier)
|
||||
|
||||
def _dump_implementation(self, data, instance=None):
|
||||
assert(instance)
|
||||
@ -212,7 +368,6 @@ class BlObject(BlDatablock):
|
||||
"rotation_mode",
|
||||
"parent",
|
||||
"data",
|
||||
"children",
|
||||
"library",
|
||||
"empty_display_type",
|
||||
"empty_display_size",
|
||||
@ -258,14 +413,41 @@ class BlObject(BlDatablock):
|
||||
|
||||
# MODIFIERS
|
||||
if hasattr(instance, 'modifiers'):
|
||||
data["modifiers"] = {}
|
||||
modifiers = getattr(instance, 'modifiers', None)
|
||||
if modifiers:
|
||||
dumper.include_filter = None
|
||||
dumper.depth = 1
|
||||
for index, modifier in enumerate(modifiers):
|
||||
data["modifiers"][modifier.name] = dumper.dump(modifier)
|
||||
# hack to dump geometry nodes inputs
|
||||
if modifier.type == 'NODES':
|
||||
dumped_inputs = dump_modifier_geometry_node_inputs(modifier)
|
||||
data["modifiers"][modifier.name]['inputs'] = dumped_inputs
|
||||
gp_modifiers = getattr(instance, 'grease_pencil_modifiers', None)
|
||||
|
||||
if gp_modifiers:
|
||||
dumper.include_filter = None
|
||||
dumper.depth = 1
|
||||
data["modifiers"] = {}
|
||||
for index, modifier in enumerate(instance.modifiers):
|
||||
data["modifiers"][modifier.name] = dumper.dump(modifier)
|
||||
gp_modifiers_data = data["grease_pencil_modifiers"] = {}
|
||||
|
||||
for index, modifier in enumerate(gp_modifiers):
|
||||
gp_mod_data = gp_modifiers_data[modifier.name] = dict()
|
||||
gp_mod_data.update(dumper.dump(modifier))
|
||||
|
||||
if hasattr(modifier, 'use_custom_curve') \
|
||||
and modifier.use_custom_curve:
|
||||
curve_dumper = Dumper()
|
||||
curve_dumper.depth = 5
|
||||
curve_dumper.include_filter = [
|
||||
'curves',
|
||||
'points',
|
||||
'location']
|
||||
gp_mod_data['curve'] = curve_dumper.dump(modifier.curve)
|
||||
|
||||
# CONSTRAINTS
|
||||
if hasattr(instance, 'constraints'):
|
||||
dumper.include_filter = None
|
||||
dumper.depth = 3
|
||||
data["constraints"] = dumper.dump(instance.constraints)
|
||||
|
||||
@ -307,39 +489,10 @@ class BlObject(BlDatablock):
|
||||
bone_groups[group.name] = dumper.dump(group)
|
||||
data['pose']['bone_groups'] = bone_groups
|
||||
|
||||
# CHILDS
|
||||
if len(instance.children) > 0:
|
||||
childs = []
|
||||
for child in instance.children:
|
||||
childs.append(child.name)
|
||||
|
||||
data["children"] = childs
|
||||
|
||||
# VERTEx GROUP
|
||||
if len(instance.vertex_groups) > 0:
|
||||
points_attr = 'vertices' if isinstance(
|
||||
instance.data, bpy.types.Mesh) else 'points'
|
||||
vg_data = []
|
||||
for vg in instance.vertex_groups:
|
||||
vg_idx = vg.index
|
||||
dumped_vg = {}
|
||||
dumped_vg['name'] = vg.name
|
||||
|
||||
vertices = []
|
||||
|
||||
for i, v in enumerate(getattr(instance.data, points_attr)):
|
||||
for vg in v.groups:
|
||||
if vg.group == vg_idx:
|
||||
vertices.append({
|
||||
'index': i,
|
||||
'weight': vg.weight
|
||||
})
|
||||
|
||||
dumped_vg['vertices'] = vertices
|
||||
|
||||
vg_data.append(dumped_vg)
|
||||
|
||||
data['vertex_groups'] = vg_data
|
||||
data['vertex_groups'] = dump_vertex_groups(instance)
|
||||
|
||||
# SHAPE KEYS
|
||||
object_data = instance.data
|
||||
@ -369,6 +522,25 @@ class BlObject(BlDatablock):
|
||||
key_blocks[key.name]['relative_key'] = key.relative_key.name
|
||||
data['shape_keys']['key_blocks'] = key_blocks
|
||||
|
||||
# SKIN VERTICES
|
||||
if hasattr(object_data, 'skin_vertices') and object_data.skin_vertices:
|
||||
skin_vertices = list()
|
||||
for skin_data in object_data.skin_vertices:
|
||||
skin_vertices.append(np_dump_collection(skin_data.data, SKIN_DATA))
|
||||
data['skin_vertices'] = skin_vertices
|
||||
|
||||
# CYCLE SETTINGS
|
||||
if hasattr(instance, 'cycles_visibility'):
|
||||
dumper.include_filter = [
|
||||
'camera',
|
||||
'diffuse',
|
||||
'glossy',
|
||||
'transmission',
|
||||
'scatter',
|
||||
'shadow',
|
||||
]
|
||||
data['cycles_visibility'] = dumper.dump(instance.cycles_visibility)
|
||||
|
||||
return data
|
||||
|
||||
def _resolve_deps_implementation(self):
|
||||
@ -377,8 +549,8 @@ class BlObject(BlDatablock):
|
||||
# Avoid Empty case
|
||||
if self.instance.data:
|
||||
deps.append(self.instance.data)
|
||||
if len(self.instance.children) > 0:
|
||||
deps.extend(list(self.instance.children))
|
||||
if self.instance.parent :
|
||||
deps.append(self.instance.parent)
|
||||
|
||||
if self.is_library:
|
||||
deps.append(self.instance.library)
|
||||
@ -387,4 +559,8 @@ class BlObject(BlDatablock):
|
||||
# TODO: uuid based
|
||||
deps.append(self.instance.instance_collection)
|
||||
|
||||
if self.instance.modifiers:
|
||||
deps.extend(find_textures_dependencies(self.instance.modifiers))
|
||||
deps.extend(find_geometry_nodes(self.instance.modifiers))
|
||||
|
||||
return deps
|
||||
|
@ -16,32 +16,368 @@
|
||||
# ##### END GPL LICENSE BLOCK #####
|
||||
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
|
||||
from .dump_anything import Loader, Dumper
|
||||
from .bl_datablock import BlDatablock
|
||||
from .bl_collection import dump_collection_children, dump_collection_objects, load_collection_childrens, load_collection_objects
|
||||
from replication.constants import (DIFF_JSON, MODIFIED)
|
||||
from deepdiff import DeepDiff
|
||||
import logging
|
||||
from replication.constants import DIFF_JSON, MODIFIED
|
||||
|
||||
from ..utils import flush_history
|
||||
from .bl_collection import (dump_collection_children, dump_collection_objects,
|
||||
load_collection_childrens, load_collection_objects,
|
||||
resolve_collection_dependencies)
|
||||
from .bl_datablock import BlDatablock
|
||||
from .bl_file import get_filepath
|
||||
from .dump_anything import Dumper, Loader
|
||||
|
||||
RENDER_SETTINGS = [
|
||||
'dither_intensity',
|
||||
'engine',
|
||||
'film_transparent',
|
||||
'filter_size',
|
||||
'fps',
|
||||
'fps_base',
|
||||
'frame_map_new',
|
||||
'frame_map_old',
|
||||
'hair_subdiv',
|
||||
'hair_type',
|
||||
'line_thickness',
|
||||
'line_thickness_mode',
|
||||
'metadata_input',
|
||||
'motion_blur_shutter',
|
||||
'pixel_aspect_x',
|
||||
'pixel_aspect_y',
|
||||
'preview_pixel_size',
|
||||
'preview_start_resolution',
|
||||
'resolution_percentage',
|
||||
'resolution_x',
|
||||
'resolution_y',
|
||||
'sequencer_gl_preview',
|
||||
'use_bake_clear',
|
||||
'use_bake_lores_mesh',
|
||||
'use_bake_multires',
|
||||
'use_bake_selected_to_active',
|
||||
'use_bake_user_scale',
|
||||
'use_border',
|
||||
'use_compositing',
|
||||
'use_crop_to_border',
|
||||
'use_file_extension',
|
||||
'use_freestyle',
|
||||
'use_full_sample',
|
||||
'use_high_quality_normals',
|
||||
'use_lock_interface',
|
||||
'use_motion_blur',
|
||||
'use_multiview',
|
||||
'use_sequencer',
|
||||
'use_sequencer_override_scene_strip',
|
||||
'use_single_layer',
|
||||
'views_format',
|
||||
]
|
||||
|
||||
EVEE_SETTINGS = [
|
||||
'gi_diffuse_bounces',
|
||||
'gi_cubemap_resolution',
|
||||
'gi_visibility_resolution',
|
||||
'gi_irradiance_smoothing',
|
||||
'gi_glossy_clamp',
|
||||
'gi_filter_quality',
|
||||
'gi_show_irradiance',
|
||||
'gi_show_cubemaps',
|
||||
'gi_irradiance_display_size',
|
||||
'gi_cubemap_display_size',
|
||||
'gi_auto_bake',
|
||||
'taa_samples',
|
||||
'taa_render_samples',
|
||||
'use_taa_reprojection',
|
||||
'sss_samples',
|
||||
'sss_jitter_threshold',
|
||||
'use_ssr',
|
||||
'use_ssr_refraction',
|
||||
'use_ssr_halfres',
|
||||
'ssr_quality',
|
||||
'ssr_max_roughness',
|
||||
'ssr_thickness',
|
||||
'ssr_border_fade',
|
||||
'ssr_firefly_fac',
|
||||
'volumetric_start',
|
||||
'volumetric_end',
|
||||
'volumetric_tile_size',
|
||||
'volumetric_samples',
|
||||
'volumetric_sample_distribution',
|
||||
'use_volumetric_lights',
|
||||
'volumetric_light_clamp',
|
||||
'use_volumetric_shadows',
|
||||
'volumetric_shadow_samples',
|
||||
'use_gtao',
|
||||
'use_gtao_bent_normals',
|
||||
'use_gtao_bounce',
|
||||
'gtao_factor',
|
||||
'gtao_quality',
|
||||
'gtao_distance',
|
||||
'bokeh_max_size',
|
||||
'bokeh_threshold',
|
||||
'use_bloom',
|
||||
'bloom_threshold',
|
||||
'bloom_color',
|
||||
'bloom_knee',
|
||||
'bloom_radius',
|
||||
'bloom_clamp',
|
||||
'bloom_intensity',
|
||||
'use_motion_blur',
|
||||
'motion_blur_shutter',
|
||||
'motion_blur_depth_scale',
|
||||
'motion_blur_max',
|
||||
'motion_blur_steps',
|
||||
'shadow_cube_size',
|
||||
'shadow_cascade_size',
|
||||
'use_shadow_high_bitdepth',
|
||||
'gi_diffuse_bounces',
|
||||
'gi_cubemap_resolution',
|
||||
'gi_visibility_resolution',
|
||||
'gi_irradiance_smoothing',
|
||||
'gi_glossy_clamp',
|
||||
'gi_filter_quality',
|
||||
'gi_show_irradiance',
|
||||
'gi_show_cubemaps',
|
||||
'gi_irradiance_display_size',
|
||||
'gi_cubemap_display_size',
|
||||
'gi_auto_bake',
|
||||
'taa_samples',
|
||||
'taa_render_samples',
|
||||
'use_taa_reprojection',
|
||||
'sss_samples',
|
||||
'sss_jitter_threshold',
|
||||
'use_ssr',
|
||||
'use_ssr_refraction',
|
||||
'use_ssr_halfres',
|
||||
'ssr_quality',
|
||||
'ssr_max_roughness',
|
||||
'ssr_thickness',
|
||||
'ssr_border_fade',
|
||||
'ssr_firefly_fac',
|
||||
'volumetric_start',
|
||||
'volumetric_end',
|
||||
'volumetric_tile_size',
|
||||
'volumetric_samples',
|
||||
'volumetric_sample_distribution',
|
||||
'use_volumetric_lights',
|
||||
'volumetric_light_clamp',
|
||||
'use_volumetric_shadows',
|
||||
'volumetric_shadow_samples',
|
||||
'use_gtao',
|
||||
'use_gtao_bent_normals',
|
||||
'use_gtao_bounce',
|
||||
'gtao_factor',
|
||||
'gtao_quality',
|
||||
'gtao_distance',
|
||||
'bokeh_max_size',
|
||||
'bokeh_threshold',
|
||||
'use_bloom',
|
||||
'bloom_threshold',
|
||||
'bloom_color',
|
||||
'bloom_knee',
|
||||
'bloom_radius',
|
||||
'bloom_clamp',
|
||||
'bloom_intensity',
|
||||
'use_motion_blur',
|
||||
'motion_blur_shutter',
|
||||
'motion_blur_depth_scale',
|
||||
'motion_blur_max',
|
||||
'motion_blur_steps',
|
||||
'shadow_cube_size',
|
||||
'shadow_cascade_size',
|
||||
'use_shadow_high_bitdepth',
|
||||
]
|
||||
|
||||
CYCLES_SETTINGS = [
|
||||
'shading_system',
|
||||
'progressive',
|
||||
'use_denoising',
|
||||
'denoiser',
|
||||
'use_square_samples',
|
||||
'samples',
|
||||
'aa_samples',
|
||||
'diffuse_samples',
|
||||
'glossy_samples',
|
||||
'transmission_samples',
|
||||
'ao_samples',
|
||||
'mesh_light_samples',
|
||||
'subsurface_samples',
|
||||
'volume_samples',
|
||||
'sampling_pattern',
|
||||
'use_layer_samples',
|
||||
'sample_all_lights_direct',
|
||||
'sample_all_lights_indirect',
|
||||
'light_sampling_threshold',
|
||||
'use_adaptive_sampling',
|
||||
'adaptive_threshold',
|
||||
'adaptive_min_samples',
|
||||
'min_light_bounces',
|
||||
'min_transparent_bounces',
|
||||
'caustics_reflective',
|
||||
'caustics_refractive',
|
||||
'blur_glossy',
|
||||
'max_bounces',
|
||||
'diffuse_bounces',
|
||||
'glossy_bounces',
|
||||
'transmission_bounces',
|
||||
'volume_bounces',
|
||||
'transparent_max_bounces',
|
||||
'volume_step_rate',
|
||||
'volume_max_steps',
|
||||
'dicing_rate',
|
||||
'max_subdivisions',
|
||||
'dicing_camera',
|
||||
'offscreen_dicing_scale',
|
||||
'film_exposure',
|
||||
'film_transparent_glass',
|
||||
'film_transparent_roughness',
|
||||
'filter_type',
|
||||
'pixel_filter_type',
|
||||
'filter_width',
|
||||
'seed',
|
||||
'use_animated_seed',
|
||||
'sample_clamp_direct',
|
||||
'sample_clamp_indirect',
|
||||
'tile_order',
|
||||
'use_progressive_refine',
|
||||
'bake_type',
|
||||
'use_camera_cull',
|
||||
'camera_cull_margin',
|
||||
'use_distance_cull',
|
||||
'distance_cull_margin',
|
||||
'motion_blur_position',
|
||||
'rolling_shutter_type',
|
||||
'rolling_shutter_duration',
|
||||
'texture_limit',
|
||||
'texture_limit_render',
|
||||
'ao_bounces',
|
||||
'ao_bounces_render',
|
||||
]
|
||||
|
||||
VIEW_SETTINGS = [
|
||||
'look',
|
||||
'view_transform',
|
||||
'exposure',
|
||||
'gamma',
|
||||
'use_curve_mapping',
|
||||
'white_level',
|
||||
'black_level'
|
||||
]
|
||||
|
||||
|
||||
def dump_sequence(sequence: bpy.types.Sequence) -> dict:
|
||||
""" Dump a sequence to a dict
|
||||
|
||||
:arg sequence: sequence to dump
|
||||
:type sequence: bpy.types.Sequence
|
||||
:return dict:
|
||||
"""
|
||||
dumper = Dumper()
|
||||
dumper.exclude_filter = [
|
||||
'lock',
|
||||
'select',
|
||||
'select_left_handle',
|
||||
'select_right_handle',
|
||||
'strobe'
|
||||
]
|
||||
dumper.depth = 1
|
||||
data = dumper.dump(sequence)
|
||||
|
||||
|
||||
# TODO: Support multiple images
|
||||
if sequence.type == 'IMAGE':
|
||||
data['filenames'] = [e.filename for e in sequence.elements]
|
||||
|
||||
|
||||
# Effect strip inputs
|
||||
input_count = getattr(sequence, 'input_count', None)
|
||||
if input_count:
|
||||
for n in range(input_count):
|
||||
input_name = f"input_{n+1}"
|
||||
data[input_name] = getattr(sequence, input_name).name
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def load_sequence(sequence_data: dict, sequence_editor: bpy.types.SequenceEditor):
|
||||
""" Load sequence from dumped data
|
||||
|
||||
:arg sequence_data: sequence to dump
|
||||
:type sequence_data:dict
|
||||
:arg sequence_editor: root sequence editor
|
||||
:type sequence_editor: bpy.types.SequenceEditor
|
||||
"""
|
||||
strip_type = sequence_data.get('type')
|
||||
strip_name = sequence_data.get('name')
|
||||
strip_channel = sequence_data.get('channel')
|
||||
strip_frame_start = sequence_data.get('frame_start')
|
||||
|
||||
sequence = sequence_editor.sequences_all.get(strip_name, None)
|
||||
|
||||
if sequence is None:
|
||||
if strip_type == 'SCENE':
|
||||
strip_scene = bpy.data.scenes.get(sequence_data.get('scene'))
|
||||
sequence = sequence_editor.sequences.new_scene(strip_name,
|
||||
strip_scene,
|
||||
strip_channel,
|
||||
strip_frame_start)
|
||||
elif strip_type == 'MOVIE':
|
||||
filepath = get_filepath(Path(sequence_data['filepath']).name)
|
||||
sequence = sequence_editor.sequences.new_movie(strip_name,
|
||||
filepath,
|
||||
strip_channel,
|
||||
strip_frame_start)
|
||||
elif strip_type == 'SOUND':
|
||||
filepath = bpy.data.sounds[sequence_data['sound']].filepath
|
||||
sequence = sequence_editor.sequences.new_sound(strip_name,
|
||||
filepath,
|
||||
strip_channel,
|
||||
strip_frame_start)
|
||||
elif strip_type == 'IMAGE':
|
||||
images_name = sequence_data.get('filenames')
|
||||
filepath = get_filepath(images_name[0])
|
||||
sequence = sequence_editor.sequences.new_image(strip_name,
|
||||
filepath,
|
||||
strip_channel,
|
||||
strip_frame_start)
|
||||
# load other images
|
||||
if len(images_name)>1:
|
||||
for img_idx in range(1,len(images_name)):
|
||||
sequence.elements.append((images_name[img_idx]))
|
||||
else:
|
||||
seq = {}
|
||||
|
||||
for i in range(sequence_data['input_count']):
|
||||
seq[f"seq{i+1}"] = sequence_editor.sequences_all.get(sequence_data.get(f"input_{i+1}", None))
|
||||
|
||||
sequence = sequence_editor.sequences.new_effect(name=strip_name,
|
||||
type=strip_type,
|
||||
channel=strip_channel,
|
||||
frame_start=strip_frame_start,
|
||||
frame_end=sequence_data['frame_final_end'],
|
||||
**seq)
|
||||
|
||||
loader = Loader()
|
||||
# TODO: Support filepath updates
|
||||
loader.exclure_filter = ['filepath', 'sound', 'filenames','fps']
|
||||
loader.load(sequence, sequence_data)
|
||||
sequence.select = False
|
||||
|
||||
|
||||
class BlScene(BlDatablock):
|
||||
bl_id = "scenes"
|
||||
bl_class = bpy.types.Scene
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = True
|
||||
bl_icon = 'SCENE_DATA'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.diff_method = DIFF_JSON
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
instance = bpy.data.scenes.new(data["name"])
|
||||
instance.uuid = self.uuid
|
||||
|
||||
return instance
|
||||
|
||||
def _load_implementation(self, data, target):
|
||||
@ -50,12 +386,14 @@ class BlScene(BlDatablock):
|
||||
loader.load(target, data)
|
||||
|
||||
# Load master collection
|
||||
load_collection_objects(data['collection']['objects'], target.collection)
|
||||
load_collection_childrens(data['collection']['children'], target.collection)
|
||||
load_collection_objects(
|
||||
data['collection']['objects'], target.collection)
|
||||
load_collection_childrens(
|
||||
data['collection']['children'], target.collection)
|
||||
|
||||
if 'world' in data.keys():
|
||||
target.world = bpy.data.worlds[data['world']]
|
||||
|
||||
|
||||
# Annotation
|
||||
if 'grease_pencil' in data.keys():
|
||||
target.grease_pencil = bpy.data.grease_pencils[data['grease_pencil']]
|
||||
@ -65,23 +403,49 @@ class BlScene(BlDatablock):
|
||||
loader.load(target.eevee, data['eevee'])
|
||||
|
||||
if 'cycles' in data.keys():
|
||||
loader.load(target.eevee, data['cycles'])
|
||||
loader.load(target.cycles, data['cycles'])
|
||||
|
||||
if 'render' in data.keys():
|
||||
loader.load(target.render, data['render'])
|
||||
|
||||
if 'view_settings' in data.keys():
|
||||
loader.load(target.view_settings, data['view_settings'])
|
||||
if target.view_settings.use_curve_mapping:
|
||||
#TODO: change this ugly fix
|
||||
target.view_settings.curve_mapping.white_level = data['view_settings']['curve_mapping']['white_level']
|
||||
target.view_settings.curve_mapping.black_level = data['view_settings']['curve_mapping']['black_level']
|
||||
if target.view_settings.use_curve_mapping and \
|
||||
'curve_mapping' in data['view_settings']:
|
||||
# TODO: change this ugly fix
|
||||
target.view_settings.curve_mapping.white_level = data[
|
||||
'view_settings']['curve_mapping']['white_level']
|
||||
target.view_settings.curve_mapping.black_level = data[
|
||||
'view_settings']['curve_mapping']['black_level']
|
||||
target.view_settings.curve_mapping.update()
|
||||
|
||||
# Sequencer
|
||||
sequences = data.get('sequences')
|
||||
|
||||
if sequences:
|
||||
# Create sequencer data
|
||||
target.sequence_editor_create()
|
||||
vse = target.sequence_editor
|
||||
|
||||
# Clear removed sequences
|
||||
for seq in vse.sequences_all:
|
||||
if seq.name not in sequences:
|
||||
vse.sequences.remove(seq)
|
||||
# Load existing sequences
|
||||
for seq_name, seq_data in sequences.items():
|
||||
load_sequence(seq_data, vse)
|
||||
# If the sequence is no longer used, clear it
|
||||
elif target.sequence_editor and not sequences:
|
||||
target.sequence_editor_clear()
|
||||
|
||||
# FIXME: Find a better way after the replication big refacotoring
|
||||
# Keep other user from deleting collection object by flushing their history
|
||||
flush_history()
|
||||
|
||||
def _dump_implementation(self, data, instance=None):
|
||||
assert(instance)
|
||||
data = {}
|
||||
|
||||
# Metadata
|
||||
scene_dumper = Dumper()
|
||||
scene_dumper.depth = 1
|
||||
scene_dumper.include_filter = [
|
||||
@ -96,73 +460,85 @@ class BlScene(BlDatablock):
|
||||
if self.preferences.sync_flags.sync_active_camera:
|
||||
scene_dumper.include_filter.append('camera')
|
||||
|
||||
data = scene_dumper.dump(instance)
|
||||
data.update(scene_dumper.dump(instance))
|
||||
|
||||
scene_dumper.depth = 3
|
||||
|
||||
scene_dumper.include_filter = ['children','objects','name']
|
||||
# Master collection
|
||||
data['collection'] = {}
|
||||
data['collection']['children'] = dump_collection_children(instance.collection)
|
||||
data['collection']['objects'] = dump_collection_objects(instance.collection)
|
||||
|
||||
data['collection']['children'] = dump_collection_children(
|
||||
instance.collection)
|
||||
data['collection']['objects'] = dump_collection_objects(
|
||||
instance.collection)
|
||||
|
||||
scene_dumper.depth = 1
|
||||
scene_dumper.include_filter = None
|
||||
|
||||
# Render settings
|
||||
if self.preferences.sync_flags.sync_render_settings:
|
||||
scene_dumper.exclude_filter = [
|
||||
'gi_cache_info',
|
||||
'feature_set',
|
||||
'debug_use_hair_bvh',
|
||||
'aa_samples',
|
||||
'blur_glossy',
|
||||
'glossy_bounces',
|
||||
'device',
|
||||
'max_bounces',
|
||||
'preview_aa_samples',
|
||||
'preview_samples',
|
||||
'sample_clamp_indirect',
|
||||
'samples',
|
||||
'volume_bounces',
|
||||
'file_extension',
|
||||
'use_denoising'
|
||||
]
|
||||
data['eevee'] = scene_dumper.dump(instance.eevee)
|
||||
data['cycles'] = scene_dumper.dump(instance.cycles)
|
||||
data['view_settings'] = scene_dumper.dump(instance.view_settings)
|
||||
scene_dumper.include_filter = RENDER_SETTINGS
|
||||
|
||||
data['render'] = scene_dumper.dump(instance.render)
|
||||
|
||||
if instance.render.engine == 'BLENDER_EEVEE':
|
||||
scene_dumper.include_filter = EVEE_SETTINGS
|
||||
data['eevee'] = scene_dumper.dump(instance.eevee)
|
||||
elif instance.render.engine == 'CYCLES':
|
||||
scene_dumper.include_filter = CYCLES_SETTINGS
|
||||
data['cycles'] = scene_dumper.dump(instance.cycles)
|
||||
|
||||
scene_dumper.include_filter = VIEW_SETTINGS
|
||||
data['view_settings'] = scene_dumper.dump(instance.view_settings)
|
||||
|
||||
if instance.view_settings.use_curve_mapping:
|
||||
data['view_settings']['curve_mapping'] = scene_dumper.dump(instance.view_settings.curve_mapping)
|
||||
data['view_settings']['curve_mapping'] = scene_dumper.dump(
|
||||
instance.view_settings.curve_mapping)
|
||||
scene_dumper.depth = 5
|
||||
scene_dumper.include_filter = [
|
||||
'curves',
|
||||
'points',
|
||||
'location'
|
||||
'location',
|
||||
]
|
||||
data['view_settings']['curve_mapping']['curves'] = scene_dumper.dump(instance.view_settings.curve_mapping.curves)
|
||||
|
||||
|
||||
data['view_settings']['curve_mapping']['curves'] = scene_dumper.dump(
|
||||
instance.view_settings.curve_mapping.curves)
|
||||
|
||||
# Sequence
|
||||
vse = instance.sequence_editor
|
||||
if vse:
|
||||
dumped_sequences = {}
|
||||
for seq in vse.sequences_all:
|
||||
dumped_sequences[seq.name] = dump_sequence(seq)
|
||||
data['sequences'] = dumped_sequences
|
||||
|
||||
|
||||
return data
|
||||
|
||||
def _resolve_deps_implementation(self):
|
||||
deps = []
|
||||
|
||||
# child collections
|
||||
for child in self.instance.collection.children:
|
||||
deps.append(child)
|
||||
|
||||
# childs objects
|
||||
for object in self.instance.objects:
|
||||
deps.append(object)
|
||||
|
||||
# Master Collection
|
||||
deps.extend(resolve_collection_dependencies(self.instance.collection))
|
||||
|
||||
# world
|
||||
if self.instance.world:
|
||||
deps.append(self.instance.world)
|
||||
|
||||
|
||||
# annotations
|
||||
if self.instance.grease_pencil:
|
||||
deps.append(self.instance.grease_pencil)
|
||||
|
||||
# Sequences
|
||||
vse = self.instance.sequence_editor
|
||||
if vse:
|
||||
for sequence in vse.sequences_all:
|
||||
if sequence.type == 'MOVIE' and sequence.filepath:
|
||||
deps.append(Path(bpy.path.abspath(sequence.filepath)))
|
||||
elif sequence.type == 'SOUND' and sequence.sound:
|
||||
deps.append(sequence.sound)
|
||||
elif sequence.type == 'IMAGE':
|
||||
for elem in sequence.elements:
|
||||
sequence.append(
|
||||
Path(bpy.path.abspath(sequence.directory),
|
||||
elem.filename))
|
||||
|
||||
return deps
|
||||
|
||||
def diff(self):
|
||||
@ -177,4 +553,4 @@ class BlScene(BlDatablock):
|
||||
if not self.preferences.sync_flags.sync_active_camera:
|
||||
exclude_path.append("root['camera']")
|
||||
|
||||
return DeepDiff(self.data, self._dump(instance=self.instance),exclude_paths=exclude_path, cache_size=5000)
|
||||
return DeepDiff(self.data, self._dump(instance=self.instance), exclude_paths=exclude_path)
|
||||
|
@ -30,11 +30,9 @@ from .dump_anything import Dumper, Loader
|
||||
class BlSound(BlDatablock):
|
||||
bl_id = "sounds"
|
||||
bl_class = bpy.types.Sound
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'SOUND'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
filename = data.get('filename')
|
||||
|
@ -26,11 +26,9 @@ from .bl_datablock import BlDatablock
|
||||
class BlSpeaker(BlDatablock):
|
||||
bl_id = "speakers"
|
||||
bl_class = bpy.types.Speaker
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = False
|
||||
bl_icon = 'SPEAKER'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _load_implementation(self, data, target):
|
||||
loader = Loader()
|
||||
|
75
multi_user/bl_types/bl_texture.py
Normal file
@ -0,0 +1,75 @@
|
||||
# ##### BEGIN GPL LICENSE BLOCK #####
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# ##### END GPL LICENSE BLOCK #####
|
||||
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
|
||||
from .dump_anything import Loader, Dumper
|
||||
from .bl_datablock import BlDatablock
|
||||
|
||||
|
||||
class BlTexture(BlDatablock):
|
||||
bl_id = "textures"
|
||||
bl_class = bpy.types.Texture
|
||||
bl_check_common = False
|
||||
bl_icon = 'TEXTURE'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _load_implementation(self, data, target):
|
||||
loader = Loader()
|
||||
loader.load(target, data)
|
||||
|
||||
def _construct(self, data):
|
||||
return bpy.data.textures.new(data["name"], data["type"])
|
||||
|
||||
def _dump_implementation(self, data, instance=None):
|
||||
assert(instance)
|
||||
|
||||
dumper = Dumper()
|
||||
dumper.depth = 1
|
||||
dumper.exclude_filter = [
|
||||
'tag',
|
||||
'original',
|
||||
'users',
|
||||
'uuid',
|
||||
'is_embedded_data',
|
||||
'is_evaluated',
|
||||
'name_full'
|
||||
]
|
||||
|
||||
data = dumper.dump(instance)
|
||||
color_ramp = getattr(instance, 'color_ramp', None)
|
||||
|
||||
if color_ramp:
|
||||
dumper.depth = 4
|
||||
data['color_ramp'] = dumper.dump(color_ramp)
|
||||
|
||||
return data
|
||||
|
||||
def _resolve_deps_implementation(self):
|
||||
# TODO: resolve material
|
||||
deps = []
|
||||
|
||||
image = getattr(self.instance,"image", None)
|
||||
|
||||
if image:
|
||||
deps.append(image)
|
||||
|
||||
return deps
|
||||
|
||||
|
86
multi_user/bl_types/bl_volume.py
Normal file
@ -0,0 +1,86 @@
|
||||
# ##### BEGIN GPL LICENSE BLOCK #####
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# ##### END GPL LICENSE BLOCK #####
|
||||
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
from pathlib import Path
|
||||
|
||||
from .dump_anything import Loader, Dumper
|
||||
from .bl_datablock import BlDatablock, get_datablock_from_uuid
|
||||
from .bl_material import dump_materials_slots, load_materials_slots
|
||||
|
||||
class BlVolume(BlDatablock):
|
||||
bl_id = "volumes"
|
||||
bl_class = bpy.types.Volume
|
||||
bl_check_common = False
|
||||
bl_icon = 'VOLUME_DATA'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _load_implementation(self, data, target):
|
||||
loader = Loader()
|
||||
loader.load(target, data)
|
||||
loader.load(target.display, data['display'])
|
||||
|
||||
# MATERIAL SLOTS
|
||||
src_materials = data.get('materials', None)
|
||||
if src_materials:
|
||||
load_materials_slots(src_materials, target.materials)
|
||||
|
||||
def _construct(self, data):
|
||||
return bpy.data.volumes.new(data["name"])
|
||||
|
||||
def _dump_implementation(self, data, instance=None):
|
||||
assert(instance)
|
||||
|
||||
dumper = Dumper()
|
||||
dumper.depth = 1
|
||||
dumper.exclude_filter = [
|
||||
'tag',
|
||||
'original',
|
||||
'users',
|
||||
'uuid',
|
||||
'is_embedded_data',
|
||||
'is_evaluated',
|
||||
'name_full',
|
||||
'use_fake_user'
|
||||
]
|
||||
|
||||
data = dumper.dump(instance)
|
||||
|
||||
data['display'] = dumper.dump(instance.display)
|
||||
|
||||
# Fix material index
|
||||
data['materials'] = dump_materials_slots(instance.materials)
|
||||
|
||||
return data
|
||||
|
||||
def _resolve_deps_implementation(self):
|
||||
# TODO: resolve material
|
||||
deps = []
|
||||
|
||||
external_vdb = Path(bpy.path.abspath(self.instance.filepath))
|
||||
if external_vdb.exists() and not external_vdb.is_dir():
|
||||
deps.append(external_vdb)
|
||||
|
||||
for material in self.instance.materials:
|
||||
if material:
|
||||
deps.append(material)
|
||||
|
||||
return deps
|
||||
|
||||
|
@ -21,17 +21,17 @@ import mathutils
|
||||
|
||||
from .dump_anything import Loader, Dumper
|
||||
from .bl_datablock import BlDatablock
|
||||
from .bl_material import load_links, load_node, dump_node, dump_links
|
||||
from .bl_material import (load_node_tree,
|
||||
dump_node_tree,
|
||||
get_node_tree_dependencies)
|
||||
|
||||
|
||||
class BlWorld(BlDatablock):
|
||||
bl_id = "worlds"
|
||||
bl_class = bpy.types.World
|
||||
bl_delay_refresh = 1
|
||||
bl_delay_apply = 1
|
||||
bl_automatic_push = True
|
||||
bl_check_common = True
|
||||
bl_icon = 'WORLD_DATA'
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
return bpy.data.worlds.new(data["name"])
|
||||
@ -39,21 +39,12 @@ class BlWorld(BlDatablock):
|
||||
def _load_implementation(self, data, target):
|
||||
loader = Loader()
|
||||
loader.load(target, data)
|
||||
|
||||
|
||||
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(data["node_tree"]["nodes"][node], target.node_tree)
|
||||
|
||||
# Load nodes links
|
||||
target.node_tree.links.clear()
|
||||
|
||||
|
||||
load_links(data["node_tree"]["links"], target.node_tree)
|
||||
load_node_tree(data['node_tree'], target.node_tree)
|
||||
|
||||
def _dump_implementation(self, data, instance=None):
|
||||
assert(instance)
|
||||
@ -67,15 +58,7 @@ class BlWorld(BlDatablock):
|
||||
]
|
||||
data = world_dumper.dump(instance)
|
||||
if instance.use_nodes:
|
||||
data['node_tree'] = {}
|
||||
nodes = {}
|
||||
|
||||
for node in instance.node_tree.nodes:
|
||||
nodes[node.name] = dump_node(node)
|
||||
|
||||
data["node_tree"]['nodes'] = nodes
|
||||
|
||||
data["node_tree"]['links'] = dump_links(instance.node_tree.links)
|
||||
data['node_tree'] = dump_node_tree(instance.node_tree)
|
||||
|
||||
return data
|
||||
|
||||
@ -83,10 +66,7 @@ class BlWorld(BlDatablock):
|
||||
deps = []
|
||||
|
||||
if self.instance.use_nodes:
|
||||
for node in self.instance.node_tree.nodes:
|
||||
if node.type in ['TEX_IMAGE','TEX_ENVIRONMENT']:
|
||||
deps.append(node.image)
|
||||
deps.extend(get_node_tree_dependencies(self.instance.node_tree))
|
||||
if self.is_library:
|
||||
deps.append(self.instance.library)
|
||||
return deps
|
||||
|
||||
|
@ -24,8 +24,8 @@ import numpy as np
|
||||
|
||||
|
||||
BPY_TO_NUMPY_TYPES = {
|
||||
'FLOAT': np.float,
|
||||
'INT': np.int,
|
||||
'FLOAT': np.float32,
|
||||
'INT': np.int32,
|
||||
'BOOL': np.bool}
|
||||
|
||||
PRIMITIVE_TYPES = ['FLOAT', 'INT', 'BOOLEAN']
|
||||
@ -47,7 +47,7 @@ def np_load_collection(dikt: dict, collection: bpy.types.CollectionProperty, att
|
||||
:type attributes: list
|
||||
"""
|
||||
if not dikt or len(collection) == 0:
|
||||
logging.warning(f'Skipping collection')
|
||||
logging.debug(f'Skipping collection {collection}')
|
||||
return
|
||||
|
||||
if attributes is None:
|
||||
@ -465,6 +465,7 @@ class Loader:
|
||||
self.type_subset = self.match_subset_all
|
||||
self.occlude_read_only = False
|
||||
self.order = ['*']
|
||||
self.exclure_filter = []
|
||||
|
||||
def load(self, dst_data, src_dumped_data):
|
||||
self._load_any(
|
||||
@ -475,7 +476,8 @@ class Loader:
|
||||
|
||||
def _load_any(self, any, dump):
|
||||
for filter_function, load_function in self.type_subset:
|
||||
if filter_function(any):
|
||||
if filter_function(any) and \
|
||||
any.sub_element_name not in self.exclure_filter:
|
||||
load_function(any, dump)
|
||||
return
|
||||
|
||||
@ -506,13 +508,15 @@ class Loader:
|
||||
T.ColorRampElement: (CONSTRUCTOR_NEW, ["position"]),
|
||||
T.ParticleSettingsTextureSlot: (CONSTRUCTOR_ADD, []),
|
||||
T.Modifier: (CONSTRUCTOR_NEW, ["name", "type"]),
|
||||
T.GpencilModifier: (CONSTRUCTOR_NEW, ["name", "type"]),
|
||||
T.Constraint: (CONSTRUCTOR_NEW, ["type"]),
|
||||
}
|
||||
|
||||
destructors = {
|
||||
T.ColorRampElement: DESTRUCTOR_REMOVE,
|
||||
T.Modifier: DESTRUCTOR_CLEAR,
|
||||
T.Constraint: CONSTRUCTOR_NEW,
|
||||
T.GpencilModifier: DESTRUCTOR_CLEAR,
|
||||
T.Constraint: DESTRUCTOR_REMOVE,
|
||||
}
|
||||
element_type = element.bl_rna_property.fixed_type
|
||||
|
||||
@ -527,7 +531,13 @@ class Loader:
|
||||
if destructor:
|
||||
if destructor == DESTRUCTOR_REMOVE:
|
||||
collection = element.read()
|
||||
for i in range(len(collection)-1):
|
||||
elems_to_remove = len(collection)
|
||||
|
||||
# Color ramp doesn't allow to remove all elements
|
||||
if type(element_type) == T.ColorRampElement:
|
||||
elems_to_remove -= 1
|
||||
|
||||
for i in range(elems_to_remove):
|
||||
collection.remove(collection[0])
|
||||
else:
|
||||
getattr(element.read(), DESTRUCTOR_CLEAR)()
|
||||
@ -574,6 +584,7 @@ class Loader:
|
||||
dst_curve.points[int(point_idx)].location = pos
|
||||
else:
|
||||
dst_curve.points.new(pos[0], pos[1])
|
||||
curves.update()
|
||||
|
||||
def _load_pointer(self, instance, dump):
|
||||
rna_property_type = instance.bl_rna_property.fixed_type
|
||||
@ -585,6 +596,8 @@ class Loader:
|
||||
instance.write(bpy.data.textures.get(dump))
|
||||
elif isinstance(rna_property_type, T.ColorRamp):
|
||||
self._load_default(instance, dump)
|
||||
elif isinstance(rna_property_type, T.NodeTree):
|
||||
instance.write(bpy.data.node_groups.get(dump))
|
||||
elif isinstance(rna_property_type, T.Object):
|
||||
instance.write(bpy.data.objects.get(dump))
|
||||
elif isinstance(rna_property_type, T.Mesh):
|
||||
@ -626,11 +639,11 @@ class Loader:
|
||||
for k in self._ordered_keys(dump.keys()):
|
||||
v = dump[k]
|
||||
if not hasattr(default.read(), k):
|
||||
logging.debug(f"Load default, skipping {default} : {k}")
|
||||
continue
|
||||
try:
|
||||
self._load_any(default.extend(k), v)
|
||||
except Exception as err:
|
||||
logging.debug(f"Cannot load {k}: {err}")
|
||||
logging.debug(f"Skipping {k}")
|
||||
|
||||
@property
|
||||
def match_subset_all(self):
|
||||
|
@ -25,7 +25,7 @@ from pathlib import Path
|
||||
import socket
|
||||
import re
|
||||
|
||||
VERSION_EXPR = re.compile('\d+\.\d+\.\d+\w\d+')
|
||||
VERSION_EXPR = re.compile('\d+.\d+.\d+')
|
||||
|
||||
THIRD_PARTY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "libs")
|
||||
DEFAULT_CACHE_DIR = os.path.join(
|
||||
@ -62,6 +62,9 @@ def install_package(name, version):
|
||||
del env["PIP_REQUIRE_VIRTUALENV"]
|
||||
subprocess.run([str(PYTHON_PATH), "-m", "pip", "install", f"{name}=={version}"], env=env)
|
||||
|
||||
if name in sys.modules:
|
||||
del sys.modules[name]
|
||||
|
||||
def check_package_version(name, required_version):
|
||||
logging.info(f"Checking {name} version...")
|
||||
out = subprocess.run([str(PYTHON_PATH), "-m", "pip", "show", name], capture_output=True)
|
||||
|
@ -17,36 +17,45 @@
|
||||
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import random
|
||||
import shutil
|
||||
import string
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from operator import itemgetter
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from time import gmtime, strftime
|
||||
|
||||
try:
|
||||
import _pickle as pickle
|
||||
except ImportError:
|
||||
import pickle
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
from bpy.app.handlers import persistent
|
||||
|
||||
from . import bl_types, delayable, environment, presence, ui, utils
|
||||
from replication.constants import (FETCHED, STATE_ACTIVE,
|
||||
STATE_INITIAL,
|
||||
STATE_SYNCING, RP_COMMON, UP)
|
||||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
from replication.constants import (COMMITED, FETCHED, RP_COMMON, STATE_ACTIVE,
|
||||
STATE_INITIAL, STATE_SYNCING, UP)
|
||||
from replication.data import ReplicatedDataFactory
|
||||
from replication.exception import NonAuthorizedOperationError
|
||||
from replication.exception import NonAuthorizedOperationError, ContextError
|
||||
from replication.interface import session
|
||||
|
||||
from . import bl_types, environment, timers, ui, utils
|
||||
from .presence import SessionStatusWidget, renderer, view3d_find
|
||||
from .timers import registry
|
||||
|
||||
background_execution_queue = Queue()
|
||||
delayables = []
|
||||
deleyables = []
|
||||
stop_modal_executor = False
|
||||
|
||||
|
||||
def session_callback(name):
|
||||
""" Session callback wrapper
|
||||
|
||||
@ -55,8 +64,8 @@ def session_callback(name):
|
||||
"""
|
||||
def func_wrapper(func):
|
||||
@session.register(name)
|
||||
def add_background_task():
|
||||
background_execution_queue.put(func)
|
||||
def add_background_task(**kwargs):
|
||||
background_execution_queue.put((func, kwargs))
|
||||
return add_background_task
|
||||
return func_wrapper
|
||||
|
||||
@ -65,61 +74,70 @@ def session_callback(name):
|
||||
def initialize_session():
|
||||
"""Session connection init hander
|
||||
"""
|
||||
logging.info("Intializing the scene")
|
||||
settings = utils.get_preferences()
|
||||
runtime_settings = bpy.context.window_manager.session
|
||||
|
||||
# Step 1: Constrect nodes
|
||||
logging.info("Constructing nodes")
|
||||
for node in session._graph.list_ordered():
|
||||
node_ref = session.get(node)
|
||||
if node_ref.state == FETCHED:
|
||||
node_ref = session.get(uuid=node)
|
||||
if node_ref is None:
|
||||
logging.error(f"Can't construct node {node}")
|
||||
elif node_ref.state == FETCHED:
|
||||
node_ref.resolve()
|
||||
|
||||
|
||||
# Step 2: Load nodes
|
||||
logging.info("Loading nodes")
|
||||
for node in session._graph.list_ordered():
|
||||
node_ref = session.get(node)
|
||||
if node_ref.state == FETCHED:
|
||||
node_ref = session.get(uuid=node)
|
||||
|
||||
if node_ref is None:
|
||||
logging.error(f"Can't load node {node}")
|
||||
elif node_ref.state == FETCHED:
|
||||
node_ref.apply()
|
||||
|
||||
# Step 3: Launch presence overlay
|
||||
if runtime_settings.enable_presence:
|
||||
presence.renderer.run()
|
||||
|
||||
logging.info("Registering timers")
|
||||
# Step 4: Register blender timers
|
||||
for d in delayables:
|
||||
for d in deleyables:
|
||||
d.register()
|
||||
|
||||
if settings.update_method == 'DEPSGRAPH':
|
||||
bpy.app.handlers.depsgraph_update_post.append(depsgraph_evaluation)
|
||||
bpy.ops.session.apply_armature_operator('INVOKE_DEFAULT')
|
||||
|
||||
# Step 5: Clearing history
|
||||
utils.flush_history()
|
||||
|
||||
# Step 6: Launch deps graph update handling
|
||||
bpy.app.handlers.depsgraph_update_post.append(depsgraph_evaluation)
|
||||
|
||||
|
||||
@session_callback('on_exit')
|
||||
def on_connection_end():
|
||||
def on_connection_end(reason="none"):
|
||||
"""Session connection finished handler
|
||||
"""
|
||||
global delayables, stop_modal_executor
|
||||
global deleyables, stop_modal_executor
|
||||
settings = utils.get_preferences()
|
||||
|
||||
# Step 1: Unregister blender timers
|
||||
for d in delayables:
|
||||
for d in deleyables:
|
||||
try:
|
||||
d.unregister()
|
||||
except:
|
||||
continue
|
||||
deleyables.clear()
|
||||
|
||||
stop_modal_executor = True
|
||||
|
||||
# Step 2: Unregister presence renderer
|
||||
presence.renderer.stop()
|
||||
|
||||
if settings.update_method == 'DEPSGRAPH':
|
||||
bpy.app.handlers.depsgraph_update_post.remove(
|
||||
depsgraph_evaluation)
|
||||
if depsgraph_evaluation in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.remove(depsgraph_evaluation)
|
||||
|
||||
# Step 3: remove file handled
|
||||
logger = logging.getLogger()
|
||||
for handler in logger.handlers:
|
||||
if isinstance(handler, logging.FileHandler):
|
||||
logger.removeHandler(handler)
|
||||
if reason != "user":
|
||||
bpy.ops.session.notify('INVOKE_DEFAULT', message=f"Disconnected from session. Reason: {reason}. ")
|
||||
|
||||
|
||||
# OPERATORS
|
||||
@ -135,15 +153,15 @@ class SessionStartOperator(bpy.types.Operator):
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
global delayables
|
||||
global deleyables
|
||||
|
||||
settings = utils.get_preferences()
|
||||
runtime_settings = context.window_manager.session
|
||||
users = bpy.data.window_managers['WinMan'].online_users
|
||||
admin_pass = runtime_settings.password
|
||||
use_extern_update = settings.update_method == 'DEPSGRAPH'
|
||||
|
||||
users.clear()
|
||||
delayables.clear()
|
||||
deleyables.clear()
|
||||
|
||||
logger = logging.getLogger()
|
||||
if len(logger.handlers) == 1:
|
||||
@ -152,9 +170,10 @@ class SessionStartOperator(bpy.types.Operator):
|
||||
datefmt='%H:%M:%S'
|
||||
)
|
||||
|
||||
start_time = datetime.now().strftime('%Y_%m_%d_%H-%M-%S')
|
||||
log_directory = os.path.join(
|
||||
settings.cache_directory,
|
||||
"multiuser_client.log")
|
||||
f"multiuser_{start_time}.log")
|
||||
|
||||
os.makedirs(settings.cache_directory, exist_ok=True)
|
||||
|
||||
@ -173,7 +192,8 @@ class SessionStartOperator(bpy.types.Operator):
|
||||
# init the factory with supported types
|
||||
for type in bl_types.types_to_register():
|
||||
type_module = getattr(bl_types, type)
|
||||
type_impl_name = f"Bl{type.split('_')[1].capitalize()}"
|
||||
name = [e.capitalize() for e in type.split('_')[1:]]
|
||||
type_impl_name = 'Bl'+''.join(name)
|
||||
type_module_class = getattr(type_module, type_impl_name)
|
||||
|
||||
supported_bl_types.append(type_module_class.bl_id)
|
||||
@ -188,25 +208,19 @@ class SessionStartOperator(bpy.types.Operator):
|
||||
bpy_factory.register_type(
|
||||
type_module_class.bl_class,
|
||||
type_module_class,
|
||||
timer=type_local_config.bl_delay_refresh*1000,
|
||||
automatic=type_local_config.auto_push,
|
||||
check_common=type_module_class.bl_check_common)
|
||||
|
||||
if settings.update_method == 'DEFAULT':
|
||||
if type_local_config.bl_delay_apply > 0:
|
||||
delayables.append(
|
||||
delayable.ApplyTimer(
|
||||
timout=type_local_config.bl_delay_apply,
|
||||
target_type=type_module_class))
|
||||
deleyables.append(timers.ApplyTimer(timeout=settings.depsgraph_update_rate))
|
||||
|
||||
if bpy.app.version[1] >= 91:
|
||||
python_binary_path = sys.executable
|
||||
else:
|
||||
python_binary_path = bpy.app.binary_path_python
|
||||
|
||||
session.configure(
|
||||
factory=bpy_factory,
|
||||
python_path=bpy.app.binary_path_python,
|
||||
external_update_handling=use_extern_update)
|
||||
|
||||
if settings.update_method == 'DEPSGRAPH':
|
||||
delayables.append(delayable.ApplyTimer(
|
||||
settings.depsgraph_update_rate/1000))
|
||||
python_path=python_binary_path,
|
||||
external_update_handling=True)
|
||||
|
||||
# Host a session
|
||||
if self.host:
|
||||
@ -233,7 +247,8 @@ class SessionStartOperator(bpy.types.Operator):
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, repr(e))
|
||||
logging.error(f"Error: {e}")
|
||||
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
# Join a session
|
||||
else:
|
||||
if not runtime_settings.admin:
|
||||
@ -255,24 +270,26 @@ class SessionStartOperator(bpy.types.Operator):
|
||||
logging.error(str(e))
|
||||
|
||||
# Background client updates service
|
||||
delayables.append(delayable.ClientUpdate())
|
||||
delayables.append(delayable.DrawClient())
|
||||
delayables.append(delayable.DynamicRightSelectTimer())
|
||||
|
||||
session_update = delayable.SessionStatusUpdate()
|
||||
session_user_sync = delayable.SessionUserSync()
|
||||
session_background_executor = delayable.MainThreadExecutor(
|
||||
deleyables.append(timers.ClientUpdate())
|
||||
deleyables.append(timers.DynamicRightSelectTimer())
|
||||
# deleyables.append(timers.PushTimer(
|
||||
# queue=stagging,
|
||||
# timeout=settings.depsgraph_update_rate
|
||||
# ))
|
||||
session_update = timers.SessionStatusUpdate()
|
||||
session_user_sync = timers.SessionUserSync()
|
||||
session_background_executor = timers.MainThreadExecutor(
|
||||
execution_queue=background_execution_queue)
|
||||
|
||||
session_update.register()
|
||||
session_user_sync.register()
|
||||
session_background_executor.register()
|
||||
|
||||
delayables.append(session_background_executor)
|
||||
delayables.append(session_update)
|
||||
delayables.append(session_user_sync)
|
||||
deleyables.append(session_background_executor)
|
||||
deleyables.append(session_update)
|
||||
deleyables.append(session_user_sync)
|
||||
|
||||
bpy.ops.session.apply_armature_operator()
|
||||
|
||||
|
||||
self.report(
|
||||
{'INFO'},
|
||||
@ -330,7 +347,7 @@ class SessionStopOperator(bpy.types.Operator):
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
global delayables, stop_modal_executor
|
||||
global deleyables, stop_modal_executor
|
||||
|
||||
if session:
|
||||
try:
|
||||
@ -347,7 +364,7 @@ class SessionStopOperator(bpy.types.Operator):
|
||||
class SessionKickOperator(bpy.types.Operator):
|
||||
bl_idname = "session.kick"
|
||||
bl_label = "Kick"
|
||||
bl_description = "Kick the user"
|
||||
bl_description = "Kick the target user"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
user: bpy.props.StringProperty()
|
||||
@ -357,7 +374,7 @@ class SessionKickOperator(bpy.types.Operator):
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
global delayables, stop_modal_executor
|
||||
global deleyables, stop_modal_executor
|
||||
assert(session)
|
||||
|
||||
try:
|
||||
@ -377,8 +394,9 @@ class SessionKickOperator(bpy.types.Operator):
|
||||
|
||||
class SessionPropertyRemoveOperator(bpy.types.Operator):
|
||||
bl_idname = "session.remove_prop"
|
||||
bl_label = "remove"
|
||||
bl_description = "broadcast a property to connected client_instances"
|
||||
bl_label = "Delete cache"
|
||||
bl_description = "Stop tracking modification on the target datablock." + \
|
||||
"The datablock will no longer be updated for others client. "
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
property_path: bpy.props.StringProperty(default="None")
|
||||
@ -401,11 +419,12 @@ class SessionPropertyRemoveOperator(bpy.types.Operator):
|
||||
|
||||
class SessionPropertyRightOperator(bpy.types.Operator):
|
||||
bl_idname = "session.right"
|
||||
bl_label = "Change owner to"
|
||||
bl_description = "Change owner of specified datablock"
|
||||
bl_label = "Change modification rights"
|
||||
bl_description = "Modify the owner of the target datablock"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
key: bpy.props.StringProperty(default="None")
|
||||
recursive: bpy.props.BoolProperty(default=True)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
@ -419,14 +438,21 @@ class SessionPropertyRightOperator(bpy.types.Operator):
|
||||
layout = self.layout
|
||||
runtime_settings = context.window_manager.session
|
||||
|
||||
col = layout.column()
|
||||
col.prop(runtime_settings, "clients")
|
||||
row = layout.row()
|
||||
row.label(text="Give the owning rights to:")
|
||||
row.prop(runtime_settings, "clients", text="")
|
||||
row = layout.row()
|
||||
row.label(text="Affect dependencies")
|
||||
row.prop(self, "recursive", text="")
|
||||
|
||||
def execute(self, context):
|
||||
runtime_settings = context.window_manager.session
|
||||
|
||||
if session:
|
||||
session.change_owner(self.key, runtime_settings.clients)
|
||||
session.change_owner(self.key,
|
||||
runtime_settings.clients,
|
||||
ignore_warnings=True,
|
||||
affect_dependencies=self.recursive)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
@ -472,7 +498,7 @@ class SessionSnapUserOperator(bpy.types.Operator):
|
||||
return {'CANCELLED'}
|
||||
|
||||
if event.type == 'TIMER':
|
||||
area, region, rv3d = presence.view3d_find()
|
||||
area, region, rv3d = view3d_find()
|
||||
|
||||
if session:
|
||||
target_ref = session.online_users.get(self.target_client)
|
||||
@ -559,26 +585,40 @@ class SessionSnapTimeOperator(bpy.types.Operator):
|
||||
|
||||
class SessionApply(bpy.types.Operator):
|
||||
bl_idname = "session.apply"
|
||||
bl_label = "apply selected block into blender"
|
||||
bl_description = "Apply selected block into blender"
|
||||
bl_label = "Revert"
|
||||
bl_description = "Revert the selected datablock from his cached" + \
|
||||
" version."
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
target: bpy.props.StringProperty()
|
||||
reset_dependencies: bpy.props.BoolProperty(default=False)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
session.apply(self.target)
|
||||
logging.debug(f"Running apply on {self.target}")
|
||||
try:
|
||||
node_ref = session.get(uuid=self.target)
|
||||
session.apply(self.target,
|
||||
force=True,
|
||||
force_dependencies=self.reset_dependencies)
|
||||
if node_ref.bl_reload_parent:
|
||||
for parent in session._graph.find_parents(self.target):
|
||||
logging.debug(f"Refresh parent {parent}")
|
||||
session.apply(parent, force=True)
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, repr(e))
|
||||
return {"CANCELED"}
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SessionCommit(bpy.types.Operator):
|
||||
bl_idname = "session.commit"
|
||||
bl_label = "commit and push selected datablock to server"
|
||||
bl_description = "commit and push selected datablock to server"
|
||||
bl_label = "Force server update"
|
||||
bl_description = "Commit and push the target datablock to server"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
target: bpy.props.StringProperty()
|
||||
@ -588,11 +628,13 @@ class SessionCommit(bpy.types.Operator):
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
# session.get(uuid=target).diff()
|
||||
session.commit(uuid=self.target)
|
||||
session.push(self.target)
|
||||
return {"FINISHED"}
|
||||
|
||||
try:
|
||||
session.commit(uuid=self.target)
|
||||
session.push(self.target)
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, repr(e))
|
||||
return {"CANCELED"}
|
||||
|
||||
class ApplyArmatureOperator(bpy.types.Operator):
|
||||
"""Operator which runs its self from a timer"""
|
||||
@ -637,7 +679,7 @@ class ApplyArmatureOperator(bpy.types.Operator):
|
||||
stop_modal_executor = False
|
||||
|
||||
|
||||
class ClearCache(bpy.types.Operator):
|
||||
class SessionClearCache(bpy.types.Operator):
|
||||
"Clear local session cache"
|
||||
bl_idname = "session.clear_cache"
|
||||
bl_label = "Modal Executor Operator"
|
||||
@ -665,6 +707,198 @@ class ClearCache(bpy.types.Operator):
|
||||
row = self.layout
|
||||
row.label(text=f" Do you really want to remove local cache ? ")
|
||||
|
||||
class SessionPurgeOperator(bpy.types.Operator):
|
||||
"Remove node with lost references"
|
||||
bl_idname = "session.purge"
|
||||
bl_label = "Purge session data"
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
sanitize_deps_graph(remove_nodes=True)
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, repr(e))
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context):
|
||||
row = self.layout
|
||||
row.label(text=f" Do you really want to remove local cache ? ")
|
||||
|
||||
|
||||
class SessionNotifyOperator(bpy.types.Operator):
|
||||
"""Dialog only operator"""
|
||||
bl_idname = "session.notify"
|
||||
bl_label = "Multi-user"
|
||||
bl_description = "multiuser notification"
|
||||
|
||||
message: bpy.props.StringProperty()
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
return {'FINISHED'}
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.row().label(text=self.message)
|
||||
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
|
||||
class SessionSaveBackupOperator(bpy.types.Operator, ExportHelper):
|
||||
bl_idname = "session.save"
|
||||
bl_label = "Save session data"
|
||||
bl_description = "Save a snapshot of the collaborative session"
|
||||
|
||||
# ExportHelper mixin class uses this
|
||||
filename_ext = ".db"
|
||||
|
||||
filter_glob: bpy.props.StringProperty(
|
||||
default="*.db",
|
||||
options={'HIDDEN'},
|
||||
maxlen=255, # Max internal buffer length, longer would be clamped.
|
||||
)
|
||||
|
||||
enable_autosave: bpy.props.BoolProperty(
|
||||
name="Auto-save",
|
||||
description="Enable session auto-save",
|
||||
default=True,
|
||||
)
|
||||
save_interval: bpy.props.FloatProperty(
|
||||
name="Auto save interval",
|
||||
description="auto-save interval (seconds)",
|
||||
default=10,
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
if self.enable_autosave:
|
||||
recorder = timers.SessionBackupTimer(
|
||||
filepath=self.filepath,
|
||||
timeout=self.save_interval)
|
||||
recorder.register()
|
||||
deleyables.append(recorder)
|
||||
else:
|
||||
session.save(self.filepath)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return session.state['STATE'] == STATE_ACTIVE
|
||||
|
||||
class SessionStopAutoSaveOperator(bpy.types.Operator):
|
||||
bl_idname = "session.cancel_autosave"
|
||||
bl_label = "Cancel auto-save"
|
||||
bl_description = "Cancel session auto-save"
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return (session.state['STATE'] == STATE_ACTIVE and 'SessionBackupTimer' in registry)
|
||||
|
||||
def execute(self, context):
|
||||
autosave_timer = registry.get('SessionBackupTimer')
|
||||
autosave_timer.unregister()
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class SessionLoadSaveOperator(bpy.types.Operator, ImportHelper):
|
||||
bl_idname = "session.load"
|
||||
bl_label = "Load session save"
|
||||
bl_description = "Load a Multi-user session save"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
# ExportHelper mixin class uses this
|
||||
filename_ext = ".db"
|
||||
|
||||
filter_glob: bpy.props.StringProperty(
|
||||
default="*.db",
|
||||
options={'HIDDEN'},
|
||||
maxlen=255, # Max internal buffer length, longer would be clamped.
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
from replication.graph import ReplicationGraph
|
||||
|
||||
# TODO: add filechecks
|
||||
|
||||
try:
|
||||
f = gzip.open(self.filepath, "rb")
|
||||
db = pickle.load(f)
|
||||
except OSError as e:
|
||||
f = open(self.filepath, "rb")
|
||||
db = pickle.load(f)
|
||||
|
||||
if db:
|
||||
logging.info(f"Reading {self.filepath}")
|
||||
nodes = db.get("nodes")
|
||||
|
||||
logging.info(f"{len(nodes)} Nodes to load")
|
||||
|
||||
|
||||
|
||||
# init the factory with supported types
|
||||
bpy_factory = ReplicatedDataFactory()
|
||||
for type in bl_types.types_to_register():
|
||||
type_module = getattr(bl_types, type)
|
||||
name = [e.capitalize() for e in type.split('_')[1:]]
|
||||
type_impl_name = 'Bl'+''.join(name)
|
||||
type_module_class = getattr(type_module, type_impl_name)
|
||||
|
||||
|
||||
bpy_factory.register_type(
|
||||
type_module_class.bl_class,
|
||||
type_module_class)
|
||||
|
||||
graph = ReplicationGraph()
|
||||
|
||||
for node, node_data in nodes:
|
||||
node_type = node_data.get('str_type')
|
||||
|
||||
impl = bpy_factory.get_implementation_from_net(node_type)
|
||||
|
||||
if impl:
|
||||
logging.info(f"Loading {node}")
|
||||
instance = impl(owner=node_data['owner'],
|
||||
uuid=node,
|
||||
dependencies=node_data['dependencies'],
|
||||
data=node_data['data'])
|
||||
instance.store(graph)
|
||||
instance.state = FETCHED
|
||||
|
||||
logging.info("Graph succefully loaded")
|
||||
|
||||
utils.clean_scene()
|
||||
|
||||
# Step 1: Construct nodes
|
||||
for node in graph.list_ordered():
|
||||
graph[node].resolve()
|
||||
|
||||
# Step 2: Load nodes
|
||||
for node in graph.list_ordered():
|
||||
graph[node].apply()
|
||||
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return True
|
||||
|
||||
def menu_func_import(self, context):
|
||||
self.layout.operator(SessionLoadSaveOperator.bl_idname, text='Multi-user session snapshot (.db)')
|
||||
|
||||
|
||||
classes = (
|
||||
SessionStartOperator,
|
||||
@ -678,22 +912,53 @@ classes = (
|
||||
ApplyArmatureOperator,
|
||||
SessionKickOperator,
|
||||
SessionInitOperator,
|
||||
ClearCache,
|
||||
SessionClearCache,
|
||||
SessionNotifyOperator,
|
||||
SessionSaveBackupOperator,
|
||||
SessionLoadSaveOperator,
|
||||
SessionStopAutoSaveOperator,
|
||||
SessionPurgeOperator,
|
||||
)
|
||||
|
||||
def update_external_dependencies():
|
||||
nodes_ids = session.list(filter=bl_types.bl_file.BlFile)
|
||||
for node_id in nodes_ids:
|
||||
node = session.get(node_id)
|
||||
if node and node.owner in [session.id, RP_COMMON] \
|
||||
and node.has_changed():
|
||||
session.commit(node_id)
|
||||
session.push(node_id, check_data=False)
|
||||
|
||||
def sanitize_deps_graph(remove_nodes: bool = False):
|
||||
""" Cleanup the replication graph
|
||||
"""
|
||||
if session and session.state['STATE'] == STATE_ACTIVE:
|
||||
start = utils.current_milli_time()
|
||||
rm_cpt = 0
|
||||
for node_key in session.list():
|
||||
node = session.get(node_key)
|
||||
if node is None \
|
||||
or (node.state == UP and not node.resolve(construct=False)):
|
||||
if remove_nodes:
|
||||
try:
|
||||
session.remove(node.uuid, remove_dependencies=False)
|
||||
logging.info(f"Removing {node.uuid}")
|
||||
rm_cpt += 1
|
||||
except NonAuthorizedOperationError:
|
||||
continue
|
||||
logging.info(f"Sanitize took { utils.current_milli_time()-start} ms")
|
||||
|
||||
|
||||
@persistent
|
||||
def sanitize_deps_graph(dummy):
|
||||
"""sanitize deps graph
|
||||
def resolve_deps_graph(dummy):
|
||||
"""Resolve deps graph
|
||||
|
||||
Temporary solution to resolve each node pointers after a Undo.
|
||||
A future solution should be to avoid storing dataclock reference...
|
||||
|
||||
"""
|
||||
if session and session.state['STATE'] == STATE_ACTIVE:
|
||||
for node_key in session.list():
|
||||
session.get(node_key).resolve()
|
||||
|
||||
sanitize_deps_graph(remove_nodes=True)
|
||||
|
||||
@persistent
|
||||
def load_pre_handler(dummy):
|
||||
@ -717,40 +982,53 @@ def depsgraph_evaluation(scene):
|
||||
dependency_updates = [u for u in blender_depsgraph.updates]
|
||||
settings = utils.get_preferences()
|
||||
|
||||
# NOTE: maybe we don't need to check each update but only the first
|
||||
update_external_dependencies()
|
||||
|
||||
# NOTE: maybe we don't need to check each update but only the first
|
||||
for update in reversed(dependency_updates):
|
||||
# Is the object tracked ?
|
||||
if update.id.uuid:
|
||||
# Retrieve local version
|
||||
node = session.get(update.id.uuid)
|
||||
|
||||
node = session.get(uuid=update.id.uuid)
|
||||
|
||||
# Check our right on this update:
|
||||
# - if its ours or ( under common and diff), launch the
|
||||
# update process
|
||||
# - if its to someone else, ignore the update (go deeper ?)
|
||||
if node and node.owner in [session.id, RP_COMMON] and node.state == UP:
|
||||
# Avoid slow geometry update
|
||||
if 'EDIT' in context.mode and \
|
||||
not settings.sync_during_editmode:
|
||||
break
|
||||
|
||||
session.stash(node.uuid)
|
||||
# - if its to someone else, ignore the update
|
||||
if node and node.owner in [session.id, RP_COMMON]:
|
||||
if node.state == UP:
|
||||
try:
|
||||
if node.has_changed():
|
||||
session.commit(node.uuid)
|
||||
session.push(node.uuid, check_data=False)
|
||||
except ReferenceError:
|
||||
logging.debug(f"Reference error {node.uuid}")
|
||||
if not node.is_valid():
|
||||
session.remove(node.uuid)
|
||||
except ContextError as e:
|
||||
logging.debug(e)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
else:
|
||||
# Distant update
|
||||
continue
|
||||
# else:
|
||||
# # New items !
|
||||
# logger.error("UPDATE: ADD")
|
||||
|
||||
|
||||
# A new scene is created
|
||||
elif isinstance(update.id, bpy.types.Scene):
|
||||
ref = session.get(reference=update.id)
|
||||
if ref:
|
||||
ref.resolve()
|
||||
else:
|
||||
scn_uuid = session.add(update.id)
|
||||
session.commit(scn_uuid)
|
||||
session.push(scn_uuid, check_data=False)
|
||||
def register():
|
||||
from bpy.utils import register_class
|
||||
for cls in classes:
|
||||
|
||||
for cls in classes:
|
||||
register_class(cls)
|
||||
|
||||
bpy.app.handlers.undo_post.append(sanitize_deps_graph)
|
||||
bpy.app.handlers.redo_post.append(sanitize_deps_graph)
|
||||
|
||||
bpy.app.handlers.undo_post.append(resolve_deps_graph)
|
||||
bpy.app.handlers.redo_post.append(resolve_deps_graph)
|
||||
|
||||
bpy.app.handlers.load_pre.append(load_pre_handler)
|
||||
bpy.app.handlers.frame_change_pre.append(update_client_frame)
|
||||
@ -764,8 +1042,8 @@ def unregister():
|
||||
for cls in reversed(classes):
|
||||
unregister_class(cls)
|
||||
|
||||
bpy.app.handlers.undo_post.remove(sanitize_deps_graph)
|
||||
bpy.app.handlers.redo_post.remove(sanitize_deps_graph)
|
||||
bpy.app.handlers.undo_post.remove(resolve_deps_graph)
|
||||
bpy.app.handlers.redo_post.remove(resolve_deps_graph)
|
||||
|
||||
bpy.app.handlers.load_pre.remove(load_pre_handler)
|
||||
bpy.app.handlers.frame_change_pre.remove(update_client_frame)
|
||||
|
@ -29,8 +29,9 @@ from .utils import get_preferences, get_expanded_icon
|
||||
from replication.constants import RP_COMMON
|
||||
from replication.interface import session
|
||||
|
||||
IP_EXPR = re.compile('\d+\.\d+\.\d+\.\d+')
|
||||
|
||||
# From https://stackoverflow.com/a/106223
|
||||
IP_REGEX = re.compile("^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$")
|
||||
HOSTNAME_REGEX = re.compile("^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$")
|
||||
|
||||
def randomColor():
|
||||
"""Generate a random color """
|
||||
@ -41,7 +42,7 @@ def randomColor():
|
||||
|
||||
|
||||
def random_string_digits(stringLength=6):
|
||||
"""Generate a random string of letters and digits """
|
||||
"""Generate a random string of letters and digits"""
|
||||
lettersAndDigits = string.ascii_letters + string.digits
|
||||
return ''.join(random.choices(lettersAndDigits, k=stringLength))
|
||||
|
||||
@ -53,10 +54,13 @@ def update_panel_category(self, context):
|
||||
|
||||
|
||||
def update_ip(self, context):
|
||||
ip = IP_EXPR.search(self.ip)
|
||||
ip = IP_REGEX.search(self.ip)
|
||||
dns = HOSTNAME_REGEX.search(self.ip)
|
||||
|
||||
if ip:
|
||||
self['ip'] = ip.group()
|
||||
elif dns:
|
||||
self['ip'] = dns.group()
|
||||
else:
|
||||
logging.error("Wrong IP format")
|
||||
self['ip'] = "127.0.0.1"
|
||||
@ -68,7 +72,7 @@ def update_port(self, context):
|
||||
if self.ipc_port < max_port and \
|
||||
self['ipc_port'] >= self.port:
|
||||
logging.error(
|
||||
"IPC Port in conflic with the port, assigning a random value")
|
||||
"IPC Port in conflict with the port, assigning a random value")
|
||||
self['ipc_port'] = random.randrange(self.port+4, 10000)
|
||||
|
||||
|
||||
@ -93,8 +97,6 @@ def get_log_level(self):
|
||||
class ReplicatedDatablock(bpy.types.PropertyGroup):
|
||||
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()
|
||||
@ -103,14 +105,18 @@ class ReplicatedDatablock(bpy.types.PropertyGroup):
|
||||
def set_sync_render_settings(self, value):
|
||||
self['sync_render_settings'] = value
|
||||
if session and bpy.context.scene.uuid and value:
|
||||
bpy.ops.session.apply('INVOKE_DEFAULT', target=bpy.context.scene.uuid)
|
||||
bpy.ops.session.apply('INVOKE_DEFAULT',
|
||||
target=bpy.context.scene.uuid,
|
||||
reset_dependencies=False)
|
||||
|
||||
|
||||
def set_sync_active_camera(self, value):
|
||||
self['sync_active_camera'] = value
|
||||
|
||||
if session and bpy.context.scene.uuid and value:
|
||||
bpy.ops.session.apply('INVOKE_DEFAULT', target=bpy.context.scene.uuid)
|
||||
bpy.ops.session.apply('INVOKE_DEFAULT',
|
||||
target=bpy.context.scene.uuid,
|
||||
reset_dependencies=False)
|
||||
|
||||
|
||||
class ReplicationFlags(bpy.types.PropertyGroup):
|
||||
@ -123,9 +129,10 @@ class ReplicationFlags(bpy.types.PropertyGroup):
|
||||
sync_render_settings: bpy.props.BoolProperty(
|
||||
name="Synchronize render settings",
|
||||
description="Synchronize render settings (eevee and cycles only)",
|
||||
default=True,
|
||||
default=False,
|
||||
set=set_sync_render_settings,
|
||||
get=get_sync_render_settings)
|
||||
get=get_sync_render_settings
|
||||
)
|
||||
sync_during_editmode: bpy.props.BoolProperty(
|
||||
name="Edit mode updates",
|
||||
description="Enable objects update in edit mode (! Impact performances !)",
|
||||
@ -169,7 +176,7 @@ class SessionPrefs(bpy.types.AddonPreferences):
|
||||
)
|
||||
ipc_port: bpy.props.IntProperty(
|
||||
name="ipc_port",
|
||||
description='internal ttl port(only usefull for multiple local instances)',
|
||||
description='internal ttl port(only useful for multiple local instances)',
|
||||
default=random.randrange(5570, 70000),
|
||||
update=update_port,
|
||||
)
|
||||
@ -190,20 +197,11 @@ class SessionPrefs(bpy.types.AddonPreferences):
|
||||
description='connection timeout before disconnection',
|
||||
default=1000
|
||||
)
|
||||
update_method: bpy.props.EnumProperty(
|
||||
name='update method',
|
||||
description='replication update method',
|
||||
items=[
|
||||
('DEFAULT', "Default", "Default: Use threads to monitor databloc changes"),
|
||||
('DEPSGRAPH', "Depsgraph",
|
||||
"Experimental: Use the blender dependency graph to trigger updates"),
|
||||
],
|
||||
)
|
||||
# Replication update settings
|
||||
depsgraph_update_rate: bpy.props.IntProperty(
|
||||
name='depsgraph update rate',
|
||||
description='Dependency graph uppdate rate (milliseconds)',
|
||||
default=100
|
||||
depsgraph_update_rate: bpy.props.FloatProperty(
|
||||
name='depsgraph update rate (s)',
|
||||
description='Dependency graph uppdate rate (s)',
|
||||
default=1
|
||||
)
|
||||
clear_memory_filecache: bpy.props.BoolProperty(
|
||||
name="Clear memory filecache",
|
||||
@ -215,7 +213,7 @@ class SessionPrefs(bpy.types.AddonPreferences):
|
||||
name="Category",
|
||||
description="Preferences Category",
|
||||
items=[
|
||||
('CONFIG', "Configuration", "Configuration about this add-on"),
|
||||
('CONFIG', "Configuration", "Configuration of this add-on"),
|
||||
('UPDATE', "Update", "Update this add-on"),
|
||||
],
|
||||
default='CONFIG'
|
||||
@ -233,6 +231,31 @@ class SessionPrefs(bpy.types.AddonPreferences):
|
||||
set=set_log_level,
|
||||
get=get_log_level
|
||||
)
|
||||
presence_hud_scale: bpy.props.FloatProperty(
|
||||
name="Text scale",
|
||||
description="Adjust the session widget text scale",
|
||||
min=7,
|
||||
max=90,
|
||||
default=25,
|
||||
)
|
||||
presence_hud_hpos: bpy.props.FloatProperty(
|
||||
name="Horizontal position",
|
||||
description="Adjust the session widget horizontal position",
|
||||
min=1,
|
||||
max=90,
|
||||
default=1,
|
||||
step=1,
|
||||
subtype='PERCENTAGE',
|
||||
)
|
||||
presence_hud_vpos: bpy.props.FloatProperty(
|
||||
name="Vertical position",
|
||||
description="Adjust the session widget vertical position",
|
||||
min=1,
|
||||
max=94,
|
||||
default=1,
|
||||
step=1,
|
||||
subtype='PERCENTAGE',
|
||||
)
|
||||
conf_session_identity_expanded: bpy.props.BoolProperty(
|
||||
name="Identity",
|
||||
description="Identity",
|
||||
@ -248,11 +271,6 @@ class SessionPrefs(bpy.types.AddonPreferences):
|
||||
description="Rights",
|
||||
default=False
|
||||
)
|
||||
conf_session_timing_expanded: bpy.props.BoolProperty(
|
||||
name="timings",
|
||||
description="timings",
|
||||
default=False
|
||||
)
|
||||
conf_session_cache_expanded: bpy.props.BoolProperty(
|
||||
name="Cache",
|
||||
description="cache",
|
||||
@ -334,7 +352,7 @@ class SessionPrefs(bpy.types.AddonPreferences):
|
||||
# USER INFORMATIONS
|
||||
box = grid.box()
|
||||
box.prop(
|
||||
self, "conf_session_identity_expanded", text="User informations",
|
||||
self, "conf_session_identity_expanded", text="User information",
|
||||
icon=get_expanded_icon(self.conf_session_identity_expanded),
|
||||
emboss=False)
|
||||
if self.conf_session_identity_expanded:
|
||||
@ -344,7 +362,7 @@ class SessionPrefs(bpy.types.AddonPreferences):
|
||||
# NETWORK SETTINGS
|
||||
box = grid.box()
|
||||
box.prop(
|
||||
self, "conf_session_net_expanded", text="Netorking",
|
||||
self, "conf_session_net_expanded", text="Networking",
|
||||
icon=get_expanded_icon(self.conf_session_net_expanded),
|
||||
emboss=False)
|
||||
|
||||
@ -356,28 +374,7 @@ class SessionPrefs(bpy.types.AddonPreferences):
|
||||
row = box.row()
|
||||
row.label(text="Init the session from:")
|
||||
row.prop(self, "init_method", text="")
|
||||
row = box.row()
|
||||
row.label(text="Update method:")
|
||||
row.prop(self, "update_method", text="")
|
||||
|
||||
table = box.box()
|
||||
table.row().prop(
|
||||
self, "conf_session_timing_expanded", text="Refresh rates",
|
||||
icon=get_expanded_icon(self.conf_session_timing_expanded),
|
||||
emboss=False)
|
||||
|
||||
if self.conf_session_timing_expanded:
|
||||
line = table.row()
|
||||
line.label(text=" ")
|
||||
line.separator()
|
||||
line.label(text="refresh (sec)")
|
||||
line.label(text="apply (sec)")
|
||||
|
||||
for item in self.supported_datablocks:
|
||||
line = table.row(align=True)
|
||||
line.label(text="", icon=item.icon)
|
||||
line.prop(item, "bl_delay_refresh", text="")
|
||||
line.prop(item, "bl_delay_apply", text="")
|
||||
# HOST SETTINGS
|
||||
box = grid.box()
|
||||
box.prop(
|
||||
@ -407,6 +404,15 @@ class SessionPrefs(bpy.types.AddonPreferences):
|
||||
emboss=False)
|
||||
if self.conf_session_ui_expanded:
|
||||
box.row().prop(self, "panel_category", text="Panel category", expand=True)
|
||||
row = box.row()
|
||||
row.label(text="Session widget:")
|
||||
|
||||
col = box.column(align=True)
|
||||
col.prop(self, "presence_hud_scale", expand=True)
|
||||
|
||||
|
||||
col.prop(self, "presence_hud_hpos", expand=True)
|
||||
col.prop(self, "presence_hud_vpos", expand=True)
|
||||
|
||||
if self.category == 'UPDATE':
|
||||
from . import addon_updater_ops
|
||||
@ -419,16 +425,13 @@ class SessionPrefs(bpy.types.AddonPreferences):
|
||||
new_db = self.supported_datablocks.add()
|
||||
|
||||
type_module = getattr(bl_types, type)
|
||||
type_impl_name = f"Bl{type.split('_')[1].capitalize()}"
|
||||
name = [e.capitalize() for e in type.split('_')[1:]]
|
||||
type_impl_name = 'Bl'+''.join(name)
|
||||
type_module_class = getattr(type_module, type_impl_name)
|
||||
|
||||
new_db.name = type_impl_name
|
||||
new_db.type_name = type_impl_name
|
||||
new_db.bl_delay_refresh = type_module_class.bl_delay_refresh
|
||||
new_db.bl_delay_apply = type_module_class.bl_delay_apply
|
||||
new_db.use_as_filter = True
|
||||
new_db.icon = type_module_class.bl_icon
|
||||
new_db.auto_push = type_module_class.bl_automatic_push
|
||||
new_db.bl_name = type_module_class.bl_id
|
||||
|
||||
|
||||
@ -476,25 +479,26 @@ class SessionProps(bpy.types.PropertyGroup):
|
||||
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
|
||||
)
|
||||
presence_show_far_user: bpy.props.BoolProperty(
|
||||
name="Show users on different scenes",
|
||||
description="Show user on different scenes",
|
||||
default=False,
|
||||
update=presence.update_overlay_settings
|
||||
)
|
||||
presence_show_session_status: bpy.props.BoolProperty(
|
||||
name="Show session status ",
|
||||
description="Show session status on the viewport",
|
||||
default=True,
|
||||
)
|
||||
filter_owned: bpy.props.BoolProperty(
|
||||
name="filter_owned",
|
||||
|
@ -19,6 +19,7 @@
|
||||
import copy
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
import bgl
|
||||
@ -28,13 +29,17 @@ import gpu
|
||||
import mathutils
|
||||
from bpy_extras import view3d_utils
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from replication.constants import (STATE_ACTIVE, STATE_AUTH, STATE_CONFIG,
|
||||
STATE_INITIAL, STATE_LAUNCHING_SERVICES,
|
||||
STATE_LOBBY, STATE_QUITTING, STATE_SRV_SYNC,
|
||||
STATE_SYNCING, STATE_WAITING)
|
||||
from replication.interface import session
|
||||
|
||||
from . import utils
|
||||
from .utils import find_from_attr, get_state_str, get_preferences
|
||||
|
||||
renderer = None
|
||||
# Helper functions
|
||||
|
||||
|
||||
def view3d_find():
|
||||
def view3d_find() -> tuple:
|
||||
""" Find the first 'VIEW_3D' windows found in areas
|
||||
|
||||
:return: tuple(Area, Region, RegionView3D)
|
||||
@ -56,36 +61,48 @@ def refresh_3d_view():
|
||||
if area and region and rv3d:
|
||||
area.tag_redraw()
|
||||
|
||||
|
||||
def refresh_sidebar_view():
|
||||
""" Refresh the blender sidebar
|
||||
""" Refresh the blender viewport sidebar
|
||||
"""
|
||||
area, region, rv3d = view3d_find()
|
||||
|
||||
if area:
|
||||
area.regions[3].tag_redraw()
|
||||
|
||||
def get_target(region, rv3d, coord):
|
||||
|
||||
def project_to_viewport(region: bpy.types.Region, rv3d: bpy.types.RegionView3D, coords: list, distance: float = 1.0) -> list:
|
||||
""" Compute a projection from 2D to 3D viewport coordinate
|
||||
|
||||
:param region: target windows region
|
||||
:type region: bpy.types.Region
|
||||
:param rv3d: view 3D
|
||||
:type rv3d: bpy.types.RegionView3D
|
||||
:param coords: coordinate to project
|
||||
:type coords: list
|
||||
:param distance: distance offset into viewport
|
||||
:type distance: float
|
||||
:return: list of coordinates [x,y,z]
|
||||
"""
|
||||
target = [0, 0, 0]
|
||||
|
||||
if coord and region and rv3d:
|
||||
view_vector = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
|
||||
ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord)
|
||||
target = ray_origin + view_vector
|
||||
|
||||
return [target.x, target.y, target.z]
|
||||
|
||||
|
||||
def get_target_far(region, rv3d, coord, distance):
|
||||
target = [0, 0, 0]
|
||||
|
||||
if coord and region and rv3d:
|
||||
view_vector = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
|
||||
ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord)
|
||||
if coords and region and rv3d:
|
||||
view_vector = view3d_utils.region_2d_to_vector_3d(region, rv3d, coords)
|
||||
ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coords)
|
||||
target = ray_origin + view_vector * distance
|
||||
|
||||
return [target.x, target.y, target.z]
|
||||
|
||||
def get_default_bbox(obj, radius):
|
||||
|
||||
def bbox_from_obj(obj: bpy.types.Object, radius: float) -> list:
|
||||
""" Generate a bounding box for a given object by using its world matrix
|
||||
|
||||
:param obj: target object
|
||||
:type obj: bpy.types.Object
|
||||
:param radius: bounding box radius
|
||||
:type radius: float
|
||||
:return: list of 8 points [(x,y,z),...]
|
||||
"""
|
||||
coords = [
|
||||
(-radius, -radius, -radius), (+radius, -radius, -radius),
|
||||
(-radius, +radius, -radius), (+radius, +radius, -radius),
|
||||
@ -93,264 +110,384 @@ def get_default_bbox(obj, radius):
|
||||
(-radius, +radius, +radius), (+radius, +radius, +radius)]
|
||||
|
||||
base = obj.matrix_world
|
||||
bbox_corners = [base @ mathutils.Vector(corner) for corner in coords]
|
||||
bbox_corners = [base @ mathutils.Vector(corner) for corner in coords]
|
||||
|
||||
return [(point.x, point.y, point.z)
|
||||
for point in bbox_corners]
|
||||
for point in bbox_corners]
|
||||
|
||||
def get_view_corners():
|
||||
|
||||
def generate_user_camera() -> list:
|
||||
""" Generate a basic camera represention of the user point of view
|
||||
|
||||
:return: list of 7 points
|
||||
"""
|
||||
area, region, rv3d = view3d_find()
|
||||
|
||||
v1 = [0, 0, 0]
|
||||
v2 = [0, 0, 0]
|
||||
v3 = [0, 0, 0]
|
||||
v4 = [0, 0, 0]
|
||||
v5 = [0, 0, 0]
|
||||
v6 = [0, 0, 0]
|
||||
v7 = [0, 0, 0]
|
||||
v1 = v2 = v3 = v4 = v5 = v6 = v7 = [0, 0, 0]
|
||||
|
||||
if area and region and rv3d:
|
||||
width = region.width
|
||||
height = region.height
|
||||
|
||||
v1 = get_target(region, rv3d, (0, 0))
|
||||
v3 = get_target(region, rv3d, (0, height))
|
||||
v2 = get_target(region, rv3d, (width, height))
|
||||
v4 = get_target(region, rv3d, (width, 0))
|
||||
v1 = project_to_viewport(region, rv3d, (0, 0))
|
||||
v3 = project_to_viewport(region, rv3d, (0, height))
|
||||
v2 = project_to_viewport(region, rv3d, (width, height))
|
||||
v4 = project_to_viewport(region, rv3d, (width, 0))
|
||||
|
||||
v5 = get_target(region, rv3d, (width/2, height/2))
|
||||
v5 = project_to_viewport(region, rv3d, (width/2, height/2))
|
||||
v6 = list(rv3d.view_location)
|
||||
v7 = get_target_far(region, rv3d, (width/2, height/2), -.8)
|
||||
v7 = project_to_viewport(
|
||||
region, rv3d, (width/2, height/2), distance=-.8)
|
||||
|
||||
coords = [v1, v2, v3, v4, v5, v6, v7]
|
||||
|
||||
return coords
|
||||
|
||||
|
||||
def get_client_2d(coords):
|
||||
def project_to_screen(coords: list) -> list:
|
||||
""" Project 3D coordinate to 2D screen coordinates
|
||||
|
||||
:param coords: 3D coordinates (x,y,z)
|
||||
:type coords: list
|
||||
:return: list of 2D coordinates [x,y]
|
||||
"""
|
||||
area, region, rv3d = view3d_find()
|
||||
if area and region and rv3d:
|
||||
return view3d_utils.location_3d_to_region_2d(region, rv3d, coords)
|
||||
else:
|
||||
return (0, 0)
|
||||
|
||||
def get_bb_coords_from_obj(object, parent=None):
|
||||
base = object.matrix_world if parent is None else parent.matrix_world
|
||||
|
||||
def get_bb_coords_from_obj(object: bpy.types.Object, instance: bpy.types.Object = None) -> list:
|
||||
""" Generate bounding box in world coordinate from object bound box
|
||||
|
||||
:param object: target object
|
||||
:type object: bpy.types.Object
|
||||
:param instance: optionnal instance
|
||||
:type instance: bpy.types.Object
|
||||
:return: list of 8 points [(x,y,z),...]
|
||||
"""
|
||||
base = object.matrix_world
|
||||
|
||||
if instance:
|
||||
scale = mathutils.Matrix.Diagonal(object.matrix_world.to_scale())
|
||||
base = instance.matrix_world @ scale.to_4x4()
|
||||
|
||||
bbox_corners = [base @ mathutils.Vector(
|
||||
corner) for corner in object.bound_box]
|
||||
corner) for corner in object.bound_box]
|
||||
|
||||
|
||||
return [(point.x, point.y, point.z)
|
||||
for point in bbox_corners]
|
||||
return [(point.x, point.y, point.z) for point in bbox_corners]
|
||||
|
||||
|
||||
def get_view_matrix():
|
||||
def get_view_matrix() -> list:
|
||||
""" Return the 3d viewport view matrix
|
||||
|
||||
:return: view matrix as a 4x4 list
|
||||
"""
|
||||
area, region, rv3d = view3d_find()
|
||||
|
||||
if area and region and rv3d:
|
||||
if area and region and rv3d:
|
||||
return [list(v) for v in rv3d.view_matrix]
|
||||
|
||||
def update_presence(self, context):
|
||||
global renderer
|
||||
|
||||
if 'renderer' in globals() and hasattr(renderer, 'run'):
|
||||
if self.enable_presence:
|
||||
renderer.run()
|
||||
class Widget(object):
|
||||
""" Base class to define an interface element
|
||||
"""
|
||||
draw_type: str = 'POST_VIEW' # Draw event type
|
||||
|
||||
def poll(self) -> bool:
|
||||
"""Test if the widget can be drawn or not
|
||||
|
||||
:return: bool
|
||||
"""
|
||||
return True
|
||||
|
||||
def draw(self):
|
||||
"""How to draw the widget
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class UserFrustumWidget(Widget):
|
||||
# Camera widget indices
|
||||
indices = ((1, 3), (2, 1), (3, 0),
|
||||
(2, 0), (4, 5), (1, 6),
|
||||
(2, 6), (3, 6), (0, 6))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username):
|
||||
self.username = username
|
||||
self.settings = bpy.context.window_manager.session
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
user = session.online_users.get(self.username)
|
||||
if user:
|
||||
return user.get('metadata')
|
||||
else:
|
||||
renderer.stop()
|
||||
return None
|
||||
|
||||
def poll(self):
|
||||
if self.data is None:
|
||||
return False
|
||||
|
||||
def update_overlay_settings(self, context):
|
||||
global renderer
|
||||
scene_current = self.data.get('scene_current')
|
||||
view_corners = self.data.get('view_corners')
|
||||
|
||||
if renderer and not self.presence_show_selected:
|
||||
renderer.flush_selection()
|
||||
if renderer and not self.presence_show_user:
|
||||
renderer.flush_users()
|
||||
return (scene_current == bpy.context.scene.name or
|
||||
self.settings.presence_show_far_user) and \
|
||||
view_corners and \
|
||||
self.settings.presence_show_user and \
|
||||
self.settings.enable_presence
|
||||
|
||||
|
||||
class DrawFactory(object):
|
||||
def __init__(self):
|
||||
self.d3d_items = {}
|
||||
self.d2d_items = {}
|
||||
self.draw3d_handle = None
|
||||
self.draw2d_handle = None
|
||||
self.draw_event = None
|
||||
self.coords = None
|
||||
self.active_object = None
|
||||
|
||||
def run(self):
|
||||
self.register_handlers()
|
||||
|
||||
def stop(self):
|
||||
self.flush_users()
|
||||
self.flush_selection()
|
||||
self.unregister_handlers()
|
||||
|
||||
refresh_3d_view()
|
||||
|
||||
def register_handlers(self):
|
||||
self.draw3d_handle = bpy.types.SpaceView3D.draw_handler_add(
|
||||
self.draw3d_callback, (), 'WINDOW', 'POST_VIEW')
|
||||
self.draw2d_handle = bpy.types.SpaceView3D.draw_handler_add(
|
||||
self.draw2d_callback, (), 'WINDOW', 'POST_PIXEL')
|
||||
|
||||
def unregister_handlers(self):
|
||||
if self.draw2d_handle:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(
|
||||
self.draw2d_handle, "WINDOW")
|
||||
self.draw2d_handle = None
|
||||
|
||||
if self.draw3d_handle:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(
|
||||
self.draw3d_handle, "WINDOW")
|
||||
self.draw3d_handle = None
|
||||
|
||||
self.d3d_items.clear()
|
||||
self.d2d_items.clear()
|
||||
|
||||
def flush_selection(self, user=None):
|
||||
key_to_remove = []
|
||||
select_key = f"{user}_select" if user else "select"
|
||||
for k in self.d3d_items.keys():
|
||||
|
||||
if select_key in k:
|
||||
key_to_remove.append(k)
|
||||
|
||||
for k in key_to_remove:
|
||||
del self.d3d_items[k]
|
||||
|
||||
def flush_users(self):
|
||||
key_to_remove = []
|
||||
for k in self.d3d_items.keys():
|
||||
if "select" not in k:
|
||||
key_to_remove.append(k)
|
||||
|
||||
for k in key_to_remove:
|
||||
del self.d3d_items[k]
|
||||
|
||||
self.d2d_items.clear()
|
||||
|
||||
def draw_client_selection(self, client_id, client_color, client_selection):
|
||||
local_user = utils.get_preferences().username
|
||||
|
||||
if local_user != client_id:
|
||||
self.flush_selection(client_id)
|
||||
|
||||
for select_ob in client_selection:
|
||||
drawable_key = f"{client_id}_select_{select_ob}"
|
||||
|
||||
ob = utils.find_from_attr("uuid", select_ob, bpy.data.objects)
|
||||
if not ob:
|
||||
return
|
||||
|
||||
if ob.type == 'EMPTY':
|
||||
# TODO: Child case
|
||||
# Collection instance case
|
||||
indices = (
|
||||
(0, 1), (1, 2), (2, 3), (0, 3),
|
||||
(4, 5), (5, 6), (6, 7), (4, 7),
|
||||
(0, 4), (1, 5), (2, 6), (3, 7))
|
||||
if ob.instance_collection:
|
||||
for obj in ob.instance_collection.objects:
|
||||
if obj.type == 'MESH':
|
||||
self.append_3d_item(
|
||||
drawable_key,
|
||||
client_color,
|
||||
get_bb_coords_from_obj(obj, parent=ob),
|
||||
indices)
|
||||
|
||||
if ob.type in ['MESH','META']:
|
||||
indices = (
|
||||
(0, 1), (1, 2), (2, 3), (0, 3),
|
||||
(4, 5), (5, 6), (6, 7), (4, 7),
|
||||
(0, 4), (1, 5), (2, 6), (3, 7))
|
||||
|
||||
self.append_3d_item(
|
||||
drawable_key,
|
||||
client_color,
|
||||
get_bb_coords_from_obj(ob),
|
||||
indices)
|
||||
else:
|
||||
indices = (
|
||||
(0, 1), (0, 2), (1, 3), (2, 3),
|
||||
(4, 5), (4, 6), (5, 7), (6, 7),
|
||||
(0, 4), (1, 5), (2, 6), (3, 7))
|
||||
|
||||
self.append_3d_item(
|
||||
drawable_key,
|
||||
client_color,
|
||||
get_default_bbox(ob, ob.scale.x),
|
||||
indices)
|
||||
|
||||
def append_3d_item(self,key,color, coords, indices):
|
||||
def draw(self):
|
||||
location = self.data.get('view_corners')
|
||||
shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR')
|
||||
color = color
|
||||
positions = [tuple(coord) for coord in location]
|
||||
|
||||
if len(positions) != 7:
|
||||
return
|
||||
|
||||
batch = batch_for_shader(
|
||||
shader, 'LINES', {"pos": coords}, indices=indices)
|
||||
shader,
|
||||
'LINES',
|
||||
{"pos": positions},
|
||||
indices=self.indices)
|
||||
|
||||
self.d3d_items[key] = (shader, batch, color)
|
||||
|
||||
def draw_client_camera(self, client_id, client_location, client_color):
|
||||
if client_location:
|
||||
local_user = utils.get_preferences().username
|
||||
|
||||
if local_user != client_id:
|
||||
try:
|
||||
indices = (
|
||||
(1, 3), (2, 1), (3, 0),
|
||||
(2, 0), (4, 5), (1, 6),
|
||||
(2, 6), (3, 6), (0, 6)
|
||||
)
|
||||
|
||||
shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR')
|
||||
position = [tuple(coord) for coord in client_location]
|
||||
color = client_color
|
||||
|
||||
batch = batch_for_shader(
|
||||
shader, 'LINES', {"pos": position}, indices=indices)
|
||||
|
||||
self.d3d_items[client_id] = (shader, batch, color)
|
||||
self.d2d_items[client_id] = (position[1], client_id, color)
|
||||
|
||||
except Exception as e:
|
||||
logging.debug(f"Draw client exception: {e} \n {traceback.format_exc()}\n pos:{position},ind:{indices}")
|
||||
|
||||
def draw3d_callback(self):
|
||||
bgl.glLineWidth(2.)
|
||||
bgl.glEnable(bgl.GL_DEPTH_TEST)
|
||||
bgl.glEnable(bgl.GL_BLEND)
|
||||
bgl.glEnable(bgl.GL_LINE_SMOOTH)
|
||||
|
||||
shader.bind()
|
||||
shader.uniform_float("color", self.data.get('color'))
|
||||
batch.draw(shader)
|
||||
|
||||
|
||||
class UserSelectionWidget(Widget):
|
||||
def __init__(
|
||||
self,
|
||||
username):
|
||||
self.username = username
|
||||
self.settings = bpy.context.window_manager.session
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
user = session.online_users.get(self.username)
|
||||
if user:
|
||||
return user.get('metadata')
|
||||
else:
|
||||
return None
|
||||
|
||||
def poll(self):
|
||||
if self.data is None:
|
||||
return False
|
||||
|
||||
user_selection = self.data.get('selected_objects')
|
||||
scene_current = self.data.get('scene_current')
|
||||
|
||||
return (scene_current == bpy.context.scene.name or
|
||||
self.settings.presence_show_far_user) and \
|
||||
user_selection and \
|
||||
self.settings.presence_show_selected and \
|
||||
self.settings.enable_presence
|
||||
|
||||
def draw(self):
|
||||
user_selection = self.data.get('selected_objects')
|
||||
for select_ob in user_selection:
|
||||
ob = find_from_attr("uuid", select_ob, bpy.data.objects)
|
||||
if not ob:
|
||||
return
|
||||
|
||||
vertex_pos = bbox_from_obj(ob, 1.0)
|
||||
vertex_indices = ((0, 1), (0, 2), (1, 3), (2, 3),
|
||||
(4, 5), (4, 6), (5, 7), (6, 7),
|
||||
(0, 4), (1, 5), (2, 6), (3, 7))
|
||||
|
||||
if ob.instance_collection:
|
||||
for obj in ob.instance_collection.objects:
|
||||
if obj.type == 'MESH' and hasattr(obj, 'bound_box'):
|
||||
vertex_pos = get_bb_coords_from_obj(obj, instance=ob)
|
||||
break
|
||||
elif ob.type == 'EMPTY':
|
||||
vertex_pos = bbox_from_obj(ob, ob.empty_display_size)
|
||||
elif ob.type == 'LIGHT':
|
||||
vertex_pos = bbox_from_obj(ob, ob.data.shadow_soft_size)
|
||||
elif ob.type == 'LIGHT_PROBE':
|
||||
vertex_pos = bbox_from_obj(ob, ob.data.influence_distance)
|
||||
elif ob.type == 'CAMERA':
|
||||
vertex_pos = bbox_from_obj(ob, ob.data.display_size)
|
||||
elif hasattr(ob, 'bound_box'):
|
||||
vertex_indices = (
|
||||
(0, 1), (1, 2), (2, 3), (0, 3),
|
||||
(4, 5), (5, 6), (6, 7), (4, 7),
|
||||
(0, 4), (1, 5), (2, 6), (3, 7))
|
||||
vertex_pos = get_bb_coords_from_obj(ob)
|
||||
|
||||
shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR')
|
||||
batch = batch_for_shader(
|
||||
shader,
|
||||
'LINES',
|
||||
{"pos": vertex_pos},
|
||||
indices=vertex_indices)
|
||||
|
||||
shader.bind()
|
||||
shader.uniform_float("color", self.data.get('color'))
|
||||
batch.draw(shader)
|
||||
|
||||
|
||||
class UserNameWidget(Widget):
|
||||
draw_type = 'POST_PIXEL'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username):
|
||||
self.username = username
|
||||
self.settings = bpy.context.window_manager.session
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
user = session.online_users.get(self.username)
|
||||
if user:
|
||||
return user.get('metadata')
|
||||
else:
|
||||
return None
|
||||
|
||||
def poll(self):
|
||||
if self.data is None:
|
||||
return False
|
||||
|
||||
scene_current = self.data.get('scene_current')
|
||||
view_corners = self.data.get('view_corners')
|
||||
|
||||
return (scene_current == bpy.context.scene.name or
|
||||
self.settings.presence_show_far_user) and \
|
||||
view_corners and \
|
||||
self.settings.presence_show_user and \
|
||||
self.settings.enable_presence
|
||||
|
||||
def draw(self):
|
||||
view_corners = self.data.get('view_corners')
|
||||
color = self.data.get('color')
|
||||
position = [tuple(coord) for coord in view_corners]
|
||||
coords = project_to_screen(position[1])
|
||||
|
||||
if coords:
|
||||
blf.position(0, coords[0], coords[1]+10, 0)
|
||||
blf.size(0, 16, 72)
|
||||
blf.color(0, color[0], color[1], color[2], color[3])
|
||||
blf.draw(0, self.username)
|
||||
|
||||
|
||||
class SessionStatusWidget(Widget):
|
||||
draw_type = 'POST_PIXEL'
|
||||
|
||||
def __init__(self):
|
||||
self.preferences = get_preferences()
|
||||
|
||||
@property
|
||||
def settings(self):
|
||||
return getattr(bpy.context.window_manager, 'session', None)
|
||||
|
||||
def poll(self):
|
||||
return self.settings and self.settings.presence_show_session_status and \
|
||||
self.settings.enable_presence
|
||||
|
||||
def draw(self):
|
||||
text_scale = self.preferences.presence_hud_scale
|
||||
ui_scale = bpy.context.preferences.view.ui_scale
|
||||
color = [1, 1, 0, 1]
|
||||
state = session.state.get('STATE')
|
||||
state_str = f"{get_state_str(state)}"
|
||||
|
||||
if state == STATE_ACTIVE:
|
||||
color = [0, 1, 0, 1]
|
||||
elif state == STATE_INITIAL:
|
||||
color = [1, 0, 0, 1]
|
||||
hpos = (self.preferences.presence_hud_hpos*bpy.context.area.width)/100
|
||||
vpos = (self.preferences.presence_hud_vpos*bpy.context.area.height)/100
|
||||
|
||||
blf.position(0, hpos, vpos, 0)
|
||||
blf.size(0, int(text_scale*ui_scale), 72)
|
||||
blf.color(0, color[0], color[1], color[2], color[3])
|
||||
blf.draw(0, state_str)
|
||||
|
||||
|
||||
class DrawFactory(object):
|
||||
def __init__(self):
|
||||
self.post_view_handle = None
|
||||
self.post_pixel_handle = None
|
||||
self.widgets = {}
|
||||
|
||||
def add_widget(self, name: str, widget: Widget):
|
||||
self.widgets[name] = widget
|
||||
|
||||
def remove_widget(self, name: str):
|
||||
if name in self.widgets:
|
||||
del self.widgets[name]
|
||||
else:
|
||||
logging.error(f"Widget {name} not existing")
|
||||
|
||||
def clear_widgets(self):
|
||||
self.widgets.clear()
|
||||
|
||||
def register_handlers(self):
|
||||
self.post_view_handle = bpy.types.SpaceView3D.draw_handler_add(
|
||||
self.post_view_callback,
|
||||
(),
|
||||
'WINDOW',
|
||||
'POST_VIEW')
|
||||
self.post_pixel_handle = bpy.types.SpaceView3D.draw_handler_add(
|
||||
self.post_pixel_callback,
|
||||
(),
|
||||
'WINDOW',
|
||||
'POST_PIXEL')
|
||||
|
||||
def unregister_handlers(self):
|
||||
if self.post_pixel_handle:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(
|
||||
self.post_pixel_handle,
|
||||
"WINDOW")
|
||||
self.post_pixel_handle = None
|
||||
|
||||
if self.post_view_handle:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(
|
||||
self.post_view_handle,
|
||||
"WINDOW")
|
||||
self.post_view_handle = None
|
||||
|
||||
def post_view_callback(self):
|
||||
try:
|
||||
for shader, batch, color in self.d3d_items.values():
|
||||
shader.bind()
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
except Exception:
|
||||
logging.error("3D Exception")
|
||||
for widget in self.widgets.values():
|
||||
if widget.draw_type == 'POST_VIEW' and widget.poll():
|
||||
widget.draw()
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Post view widget exception: {e} \n {traceback.print_exc()}")
|
||||
|
||||
def draw2d_callback(self):
|
||||
for position, font, color in self.d2d_items.values():
|
||||
try:
|
||||
coords = get_client_2d(position)
|
||||
def post_pixel_callback(self):
|
||||
try:
|
||||
for widget in self.widgets.values():
|
||||
if widget.draw_type == 'POST_PIXEL' and widget.poll():
|
||||
widget.draw()
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Post pixel widget Exception: {e} \n {traceback.print_exc()}")
|
||||
|
||||
if coords:
|
||||
blf.position(0, coords[0], coords[1]+10, 0)
|
||||
blf.size(0, 16, 72)
|
||||
blf.color(0, color[0], color[1], color[2], color[3])
|
||||
blf.draw(0, font)
|
||||
|
||||
except Exception:
|
||||
logging.error("2D EXCEPTION")
|
||||
this = sys.modules[__name__]
|
||||
this.renderer = DrawFactory()
|
||||
|
||||
|
||||
def register():
|
||||
global renderer
|
||||
renderer = DrawFactory()
|
||||
this.renderer.register_handlers()
|
||||
|
||||
this.renderer.add_widget("session_status", SessionStatusWidget())
|
||||
|
||||
|
||||
def unregister():
|
||||
global renderer
|
||||
renderer.unregister_handlers()
|
||||
this.renderer.unregister_handlers()
|
||||
|
||||
del renderer
|
||||
this.renderer.clear_widgets()
|
||||
|
@ -16,66 +16,65 @@
|
||||
# ##### END GPL LICENSE BLOCK #####
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
from . import presence, utils
|
||||
from replication.constants import (FETCHED,
|
||||
UP,
|
||||
RP_COMMON,
|
||||
STATE_INITIAL,
|
||||
STATE_QUITTING,
|
||||
STATE_ACTIVE,
|
||||
STATE_SYNCING,
|
||||
STATE_LOBBY,
|
||||
STATE_SRV_SYNC,
|
||||
REPARENT)
|
||||
|
||||
from replication.constants import (FETCHED, RP_COMMON, STATE_ACTIVE,
|
||||
STATE_INITIAL, STATE_LOBBY, STATE_QUITTING,
|
||||
STATE_SRV_SYNC, STATE_SYNCING, UP)
|
||||
from replication.exception import NonAuthorizedOperationError, ContextError
|
||||
from replication.interface import session
|
||||
|
||||
class Delayable():
|
||||
"""Delayable task interface
|
||||
from . import operators, utils
|
||||
from .presence import (UserFrustumWidget, UserNameWidget, UserSelectionWidget,
|
||||
generate_user_camera, get_view_matrix, refresh_3d_view,
|
||||
refresh_sidebar_view, renderer)
|
||||
|
||||
this = sys.modules[__name__]
|
||||
|
||||
# Registered timers
|
||||
this.registry = dict()
|
||||
|
||||
def is_annotating(context: bpy.types.Context):
|
||||
""" Check if the annotate mode is enabled
|
||||
"""
|
||||
def __init__(self):
|
||||
self.is_registered = False
|
||||
|
||||
def register(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def execute(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def unregister(self):
|
||||
raise NotImplementedError
|
||||
return bpy.context.workspace.tools.from_space_view3d_mode('OBJECT', create=False).idname == 'builtin.annotate'
|
||||
|
||||
|
||||
class Timer(Delayable):
|
||||
class Timer(object):
|
||||
"""Timer binder interface for blender
|
||||
|
||||
Run a bpy.app.Timer in the background looping at the given rate
|
||||
"""
|
||||
|
||||
def __init__(self, duration=1):
|
||||
super().__init__()
|
||||
self._timeout = duration
|
||||
self._running = True
|
||||
def __init__(self, timeout=10, id=None):
|
||||
self._timeout = timeout
|
||||
self.is_running = False
|
||||
self.id = id if id else self.__class__.__name__
|
||||
|
||||
def register(self):
|
||||
"""Register the timer into the blender timer system
|
||||
"""
|
||||
|
||||
if not self.is_registered:
|
||||
|
||||
if not self.is_running:
|
||||
this.registry[self.id] = self
|
||||
bpy.app.timers.register(self.main)
|
||||
self.is_registered = True
|
||||
self.is_running = True
|
||||
logging.debug(f"Register {self.__class__.__name__}")
|
||||
else:
|
||||
logging.debug(f"Timer {self.__class__.__name__} already registered")
|
||||
logging.debug(
|
||||
f"Timer {self.__class__.__name__} already registered")
|
||||
|
||||
def main(self):
|
||||
self.execute()
|
||||
|
||||
if self._running:
|
||||
return self._timeout
|
||||
try:
|
||||
self.execute()
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.unregister()
|
||||
session.disconnect()
|
||||
else:
|
||||
if self.is_running:
|
||||
return self._timeout
|
||||
|
||||
def execute(self):
|
||||
"""Main timer loop
|
||||
@ -86,48 +85,47 @@ class Timer(Delayable):
|
||||
"""Unnegister the timer of the blender timer system
|
||||
"""
|
||||
if bpy.app.timers.is_registered(self.main):
|
||||
logging.info(f"Unregistering {self.id}")
|
||||
bpy.app.timers.unregister(self.main)
|
||||
|
||||
del this.registry[self.id]
|
||||
self.is_running = False
|
||||
|
||||
self._running = False
|
||||
class SessionBackupTimer(Timer):
|
||||
def __init__(self, timeout=10, filepath=None):
|
||||
self._filepath = filepath
|
||||
super().__init__(timeout)
|
||||
|
||||
|
||||
class ApplyTimer(Timer):
|
||||
def __init__(self, timout=1, target_type=None):
|
||||
self._type = target_type
|
||||
super().__init__(timout)
|
||||
|
||||
def execute(self):
|
||||
session.save(self._filepath)
|
||||
|
||||
class ApplyTimer(Timer):
|
||||
def execute(self):
|
||||
if session and session.state['STATE'] == STATE_ACTIVE:
|
||||
if self._type:
|
||||
nodes = session.list(filter=self._type)
|
||||
else:
|
||||
nodes = session.list()
|
||||
nodes = session.list()
|
||||
|
||||
for node in nodes:
|
||||
node_ref = session.get(uuid=node)
|
||||
|
||||
if node_ref.state == FETCHED:
|
||||
try:
|
||||
session.apply(node, force=True)
|
||||
session.apply(node)
|
||||
except Exception as e:
|
||||
logging.error(f"Fail to apply {node_ref.uuid}: {e}")
|
||||
elif node_ref.state == REPARENT:
|
||||
# Reload the node
|
||||
node_ref.remove_instance()
|
||||
node_ref.resolve()
|
||||
session.apply(node, force=True)
|
||||
for parent in session._graph.find_parents(node):
|
||||
logging.info(f"Applying parent {parent}")
|
||||
session.apply(parent, force=True)
|
||||
node_ref.state = UP
|
||||
else:
|
||||
if node_ref.bl_reload_parent:
|
||||
for parent in session._graph.find_parents(node):
|
||||
logging.debug("Refresh parent {node}")
|
||||
session.apply(parent, force=True)
|
||||
|
||||
|
||||
class DynamicRightSelectTimer(Timer):
|
||||
def __init__(self, timout=.1):
|
||||
super().__init__(timout)
|
||||
def __init__(self, timeout=.1):
|
||||
super().__init__(timeout)
|
||||
self._last_selection = []
|
||||
self._user = None
|
||||
self._right_strategy = RP_COMMON
|
||||
self._annotating = False
|
||||
|
||||
def execute(self):
|
||||
settings = utils.get_preferences()
|
||||
@ -138,6 +136,40 @@ class DynamicRightSelectTimer(Timer):
|
||||
self._user = session.online_users.get(settings.username)
|
||||
|
||||
if self._user:
|
||||
ctx = bpy.context
|
||||
annotation_gp = ctx.scene.grease_pencil
|
||||
|
||||
if annotation_gp and not annotation_gp.uuid:
|
||||
ctx.scene.update_tag()
|
||||
|
||||
# if an annotation exist and is tracked
|
||||
if annotation_gp and annotation_gp.uuid:
|
||||
registered_gp = session.get(uuid=annotation_gp.uuid)
|
||||
if is_annotating(bpy.context):
|
||||
# try to get the right on it
|
||||
if registered_gp.owner == RP_COMMON:
|
||||
self._annotating = True
|
||||
logging.debug(
|
||||
"Getting the right on the annotation GP")
|
||||
session.change_owner(
|
||||
registered_gp.uuid,
|
||||
settings.username,
|
||||
ignore_warnings=True,
|
||||
affect_dependencies=False)
|
||||
|
||||
if registered_gp.owner == settings.username:
|
||||
gp_node = session.get(uuid=annotation_gp.uuid)
|
||||
if gp_node.has_changed():
|
||||
session.commit(gp_node.uuid)
|
||||
session.push(gp_node.uuid, check_data=False)
|
||||
|
||||
elif self._annotating:
|
||||
session.change_owner(
|
||||
registered_gp.uuid,
|
||||
RP_COMMON,
|
||||
ignore_warnings=True,
|
||||
affect_dependencies=False)
|
||||
|
||||
current_selection = utils.get_selected_objects(
|
||||
bpy.context.scene,
|
||||
bpy.data.window_managers['WinMan'].windows[0].view_layer
|
||||
@ -156,10 +188,15 @@ class DynamicRightSelectTimer(Timer):
|
||||
recursive = True
|
||||
if node.data and 'instance_type' in node.data.keys():
|
||||
recursive = node.data['instance_type'] != 'COLLECTION'
|
||||
session.change_owner(
|
||||
node.uuid,
|
||||
RP_COMMON,
|
||||
recursive=recursive)
|
||||
try:
|
||||
session.change_owner(
|
||||
node.uuid,
|
||||
RP_COMMON,
|
||||
ignore_warnings=True,
|
||||
affect_dependencies=recursive)
|
||||
except NonAuthorizedOperationError:
|
||||
logging.warning(
|
||||
f"Not authorized to change {node} owner")
|
||||
|
||||
# change new selection to our
|
||||
for obj in obj_ours:
|
||||
@ -170,10 +207,15 @@ class DynamicRightSelectTimer(Timer):
|
||||
if node.data and 'instance_type' in node.data.keys():
|
||||
recursive = node.data['instance_type'] != 'COLLECTION'
|
||||
|
||||
session.change_owner(
|
||||
node.uuid,
|
||||
settings.username,
|
||||
recursive=recursive)
|
||||
try:
|
||||
session.change_owner(
|
||||
node.uuid,
|
||||
settings.username,
|
||||
ignore_warnings=True,
|
||||
affect_dependencies=recursive)
|
||||
except NonAuthorizedOperationError:
|
||||
logging.warning(
|
||||
f"Not authorized to change {node} owner")
|
||||
else:
|
||||
return
|
||||
|
||||
@ -187,92 +229,37 @@ class DynamicRightSelectTimer(Timer):
|
||||
logging.debug("Update selection")
|
||||
|
||||
# Fix deselection until right managment refactoring (with Roles concepts)
|
||||
if len(current_selection) == 0 and self._right_strategy == RP_COMMON:
|
||||
if len(current_selection) == 0 :
|
||||
owned_keys = session.list(
|
||||
filter_owner=settings.username)
|
||||
for key in owned_keys:
|
||||
node = session.get(uuid=key)
|
||||
try:
|
||||
session.change_owner(
|
||||
key,
|
||||
RP_COMMON,
|
||||
ignore_warnings=True,
|
||||
affect_dependencies=recursive)
|
||||
except NonAuthorizedOperationError:
|
||||
logging.warning(
|
||||
f"Not authorized to change {key} owner")
|
||||
|
||||
session.change_owner(
|
||||
key,
|
||||
RP_COMMON,
|
||||
recursive=recursive)
|
||||
|
||||
for user, user_info in session.online_users.items():
|
||||
if user != settings.username:
|
||||
metadata = user_info.get('metadata')
|
||||
|
||||
if 'selected_objects' in metadata:
|
||||
# Update selectionnable objects
|
||||
for obj in bpy.data.objects:
|
||||
if obj.hide_select and obj.uuid not in metadata['selected_objects']:
|
||||
obj.hide_select = False
|
||||
elif not obj.hide_select and obj.uuid in metadata['selected_objects']:
|
||||
obj.hide_select = True
|
||||
|
||||
|
||||
class Draw(Delayable):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._handler = None
|
||||
|
||||
def register(self):
|
||||
if not self.is_registered:
|
||||
self._handler = bpy.types.SpaceView3D.draw_handler_add(
|
||||
self.execute, (), 'WINDOW', 'POST_VIEW')
|
||||
logging.debug(f"Register {self.__class__.__name__}")
|
||||
else:
|
||||
logging.debug(f"Drow {self.__class__.__name__} already registered")
|
||||
|
||||
def execute(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def unregister(self):
|
||||
try:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(
|
||||
self._handler, "WINDOW")
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
class DrawClient(Draw):
|
||||
def execute(self):
|
||||
renderer = getattr(presence, 'renderer', None)
|
||||
prefs = utils.get_preferences()
|
||||
|
||||
if session and renderer and session.state['STATE'] == STATE_ACTIVE:
|
||||
settings = bpy.context.window_manager.session
|
||||
users = session.online_users
|
||||
|
||||
# Update users
|
||||
for user in users.values():
|
||||
metadata = user.get('metadata')
|
||||
color = metadata.get('color')
|
||||
scene_current = metadata.get('scene_current')
|
||||
user_showable = scene_current == bpy.context.scene.name or settings.presence_show_far_user
|
||||
if color and scene_current and user_showable:
|
||||
if settings.presence_show_selected and 'selected_objects' in metadata.keys():
|
||||
renderer.draw_client_selection(
|
||||
user['id'], color, metadata['selected_objects'])
|
||||
if settings.presence_show_user and 'view_corners' in metadata:
|
||||
renderer.draw_client_camera(
|
||||
user['id'], metadata['view_corners'], color)
|
||||
if not user_showable:
|
||||
# TODO: remove this when user event drivent update will be
|
||||
# ready
|
||||
renderer.flush_selection()
|
||||
renderer.flush_users()
|
||||
for obj in bpy.data.objects:
|
||||
object_uuid = getattr(obj, 'uuid', None)
|
||||
if object_uuid:
|
||||
is_selectable = not session.is_readonly(object_uuid)
|
||||
if obj.hide_select != is_selectable:
|
||||
obj.hide_select = is_selectable
|
||||
|
||||
|
||||
class ClientUpdate(Timer):
|
||||
def __init__(self, timout=.1):
|
||||
super().__init__(timout)
|
||||
def __init__(self, timeout=.1):
|
||||
super().__init__(timeout)
|
||||
self.handle_quit = False
|
||||
self.users_metadata = {}
|
||||
|
||||
def execute(self):
|
||||
settings = utils.get_preferences()
|
||||
renderer = getattr(presence, 'renderer', None)
|
||||
|
||||
if session and renderer:
|
||||
if session.state['STATE'] in [STATE_ACTIVE, STATE_LOBBY]:
|
||||
@ -291,7 +278,7 @@ class ClientUpdate(Timer):
|
||||
if cached_user_data is None:
|
||||
self.users_metadata[username] = user_data['metadata']
|
||||
elif 'view_matrix' in cached_user_data and 'view_matrix' in new_user_data and cached_user_data['view_matrix'] != new_user_data['view_matrix']:
|
||||
presence.refresh_3d_view()
|
||||
refresh_3d_view()
|
||||
self.users_metadata[username] = user_data['metadata']
|
||||
break
|
||||
else:
|
||||
@ -300,13 +287,13 @@ class ClientUpdate(Timer):
|
||||
local_user_metadata = local_user.get('metadata')
|
||||
scene_current = bpy.context.scene.name
|
||||
local_user = session.online_users.get(settings.username)
|
||||
current_view_corners = presence.get_view_corners()
|
||||
current_view_corners = generate_user_camera()
|
||||
|
||||
# Init client metadata
|
||||
if not local_user_metadata or 'color' not in local_user_metadata.keys():
|
||||
metadata = {
|
||||
'view_corners': presence.get_view_matrix(),
|
||||
'view_matrix': presence.get_view_matrix(),
|
||||
'view_corners': get_view_matrix(),
|
||||
'view_matrix': get_view_matrix(),
|
||||
'color': (settings.client_color.r,
|
||||
settings.client_color.g,
|
||||
settings.client_color.b,
|
||||
@ -323,36 +310,37 @@ class ClientUpdate(Timer):
|
||||
session.update_user_metadata(local_user_metadata)
|
||||
elif 'view_corners' in local_user_metadata and current_view_corners != local_user_metadata['view_corners']:
|
||||
local_user_metadata['view_corners'] = current_view_corners
|
||||
local_user_metadata['view_matrix'] = presence.get_view_matrix(
|
||||
local_user_metadata['view_matrix'] = get_view_matrix(
|
||||
)
|
||||
session.update_user_metadata(local_user_metadata)
|
||||
|
||||
|
||||
class SessionStatusUpdate(Timer):
|
||||
def __init__(self, timout=1):
|
||||
super().__init__(timout)
|
||||
def __init__(self, timeout=1):
|
||||
super().__init__(timeout)
|
||||
|
||||
def execute(self):
|
||||
presence.refresh_sidebar_view()
|
||||
refresh_sidebar_view()
|
||||
|
||||
|
||||
class SessionUserSync(Timer):
|
||||
def __init__(self, timout=1):
|
||||
super().__init__(timout)
|
||||
def __init__(self, timeout=1):
|
||||
super().__init__(timeout)
|
||||
self.settings = utils.get_preferences()
|
||||
|
||||
def execute(self):
|
||||
renderer = getattr(presence, 'renderer', None)
|
||||
|
||||
if session and renderer:
|
||||
# sync online users
|
||||
session_users = session.online_users
|
||||
ui_users = bpy.context.window_manager.online_users
|
||||
|
||||
for index, user in enumerate(ui_users):
|
||||
if user.username not in session_users.keys():
|
||||
if user.username not in session_users.keys() and \
|
||||
user.username != self.settings.username:
|
||||
renderer.remove_widget(f"{user.username}_cam")
|
||||
renderer.remove_widget(f"{user.username}_select")
|
||||
renderer.remove_widget(f"{user.username}_name")
|
||||
ui_users.remove(index)
|
||||
renderer.flush_selection()
|
||||
renderer.flush_users()
|
||||
break
|
||||
|
||||
for user in session_users:
|
||||
@ -360,15 +348,22 @@ class SessionUserSync(Timer):
|
||||
new_key = ui_users.add()
|
||||
new_key.name = user
|
||||
new_key.username = user
|
||||
if user != self.settings.username:
|
||||
renderer.add_widget(
|
||||
f"{user}_cam", UserFrustumWidget(user))
|
||||
renderer.add_widget(
|
||||
f"{user}_select", UserSelectionWidget(user))
|
||||
renderer.add_widget(
|
||||
f"{user}_name", UserNameWidget(user))
|
||||
|
||||
|
||||
class MainThreadExecutor(Timer):
|
||||
def __init__(self, timout=1, execution_queue=None):
|
||||
super().__init__(timout)
|
||||
def __init__(self, timeout=1, execution_queue=None):
|
||||
super().__init__(timeout)
|
||||
self.execution_queue = execution_queue
|
||||
|
||||
|
||||
def execute(self):
|
||||
while not self.execution_queue.empty():
|
||||
function = self.execution_queue.get()
|
||||
function, kwargs = self.execution_queue.get()
|
||||
logging.debug(f"Executing {function.__name__}")
|
||||
function()
|
||||
function(**kwargs)
|
@ -18,7 +18,7 @@
|
||||
|
||||
import bpy
|
||||
|
||||
from .utils import get_preferences, get_expanded_icon, get_folder_size
|
||||
from .utils import get_preferences, get_expanded_icon, get_folder_size, get_state_str
|
||||
from replication.constants import (ADDED, ERROR, FETCHED,
|
||||
MODIFIED, RP_COMMON, UP,
|
||||
STATE_ACTIVE, STATE_AUTH,
|
||||
@ -29,14 +29,15 @@ from replication.constants import (ADDED, ERROR, FETCHED,
|
||||
STATE_LAUNCHING_SERVICES)
|
||||
from replication import __version__
|
||||
from replication.interface import session
|
||||
from .timers import registry
|
||||
|
||||
ICONS_PROP_STATES = ['TRIA_DOWN', # ADDED
|
||||
'TRIA_UP', # COMMITED
|
||||
'KEYTYPE_KEYFRAME_VEC', # PUSHED
|
||||
'TRIA_DOWN', # FETCHED
|
||||
'FILE_REFRESH', # UP
|
||||
'TRIA_UP',
|
||||
'ERROR'] # CHANGED
|
||||
'RECOVER_LAST', # RESET
|
||||
'TRIA_UP', # CHANGED
|
||||
'ERROR'] # ERROR
|
||||
|
||||
|
||||
def printProgressBar(iteration, total, prefix='', suffix='', decimals=1, length=100, fill='█', fill_empty=' '):
|
||||
@ -60,32 +61,6 @@ def printProgressBar(iteration, total, prefix='', suffix='', decimals=1, length=
|
||||
return f"{prefix} |{bar}| {iteration}/{total}{suffix}"
|
||||
|
||||
|
||||
def get_state_str(state):
|
||||
state_str = 'UNKNOWN'
|
||||
if state == STATE_WAITING:
|
||||
state_str = 'WARMING UP DATA'
|
||||
elif state == STATE_SYNCING:
|
||||
state_str = 'FETCHING'
|
||||
elif state == STATE_AUTH:
|
||||
state_str = 'AUTHENTIFICATION'
|
||||
elif state == STATE_CONFIG:
|
||||
state_str = 'CONFIGURATION'
|
||||
elif state == STATE_ACTIVE:
|
||||
state_str = 'ONLINE'
|
||||
elif state == STATE_SRV_SYNC:
|
||||
state_str = 'PUSHING'
|
||||
elif state == STATE_INITIAL:
|
||||
state_str = 'INIT'
|
||||
elif state == STATE_QUITTING:
|
||||
state_str = 'QUITTING'
|
||||
elif state == STATE_LAUNCHING_SERVICES:
|
||||
state_str = 'LAUNCHING SERVICES'
|
||||
elif state == STATE_LOBBY:
|
||||
state_str = 'LOBBY'
|
||||
|
||||
return state_str
|
||||
|
||||
|
||||
class SESSION_PT_settings(bpy.types.Panel):
|
||||
"""Settings panel"""
|
||||
bl_idname = "MULTIUSER_SETTINGS_PT_panel"
|
||||
@ -112,7 +87,6 @@ class SESSION_PT_settings(bpy.types.Panel):
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.use_property_split = True
|
||||
row = layout.row()
|
||||
runtime_settings = context.window_manager.session
|
||||
settings = get_preferences()
|
||||
@ -130,17 +104,17 @@ class SESSION_PT_settings(bpy.types.Panel):
|
||||
info_msg = None
|
||||
|
||||
if current_state in [STATE_ACTIVE]:
|
||||
row = row.split(factor=0.3)
|
||||
row = row.grid_flow(row_major=True, columns=0, even_columns=True, even_rows=False, align=True)
|
||||
row.prop(settings.sync_flags, "sync_render_settings",text="",icon_only=True, icon='SCENE')
|
||||
row.prop(settings.sync_flags, "sync_during_editmode", text="",icon_only=True, icon='EDITMODE_HLT')
|
||||
row.prop(settings.sync_flags, "sync_active_camera", text="",icon_only=True, icon='OBJECT_DATAMODE')
|
||||
|
||||
|
||||
row= layout.row()
|
||||
|
||||
if current_state in [STATE_ACTIVE] and runtime_settings.is_host:
|
||||
info_msg = f"LAN: {runtime_settings.internet_ip}"
|
||||
if current_state == STATE_LOBBY:
|
||||
info_msg = "Waiting the session to start."
|
||||
info_msg = "Waiting for the session to start."
|
||||
|
||||
if info_msg:
|
||||
info_box = row.box()
|
||||
@ -295,7 +269,6 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
|
||||
if settings.sidebar_advanced_rep_expanded:
|
||||
replication_section_row = replication_section.row()
|
||||
|
||||
replication_section_row.label(text="Sync flags", icon='COLLECTION_NEW')
|
||||
replication_section_row = replication_section.row()
|
||||
replication_section_row.prop(settings.sync_flags, "sync_render_settings")
|
||||
replication_section_row = replication_section.row()
|
||||
@ -308,34 +281,8 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
|
||||
warning = replication_section_row.box()
|
||||
warning.label(text="Don't use this with heavy meshes !", icon='ERROR')
|
||||
replication_section_row = replication_section.row()
|
||||
replication_section_row.prop(settings, "depsgraph_update_rate", text="Apply delay")
|
||||
|
||||
replication_section_row.label(text="Update method", icon='RECOVER_LAST')
|
||||
replication_section_row = replication_section.row()
|
||||
replication_section_row.prop(settings, "update_method", expand=True)
|
||||
replication_section_row = replication_section.row()
|
||||
replication_timers = replication_section_row.box()
|
||||
replication_timers.label(text="Replication timers", icon='TIME')
|
||||
if settings.update_method == "DEFAULT":
|
||||
replication_timers = replication_timers.row()
|
||||
# Replication frequencies
|
||||
flow = replication_timers.grid_flow(
|
||||
row_major=True, columns=0, even_columns=True, even_rows=False, align=True)
|
||||
line = flow.row(align=True)
|
||||
line.label(text=" ")
|
||||
line.separator()
|
||||
line.label(text="refresh (sec)")
|
||||
line.label(text="apply (sec)")
|
||||
|
||||
for item in settings.supported_datablocks:
|
||||
line = flow.row(align=True)
|
||||
line.prop(item, "auto_push", text="", icon=item.icon)
|
||||
line.separator()
|
||||
line.prop(item, "bl_delay_refresh", text="")
|
||||
line.prop(item, "bl_delay_apply", text="")
|
||||
else:
|
||||
replication_timers = replication_timers.row()
|
||||
replication_timers.label(text="Update rate (ms):")
|
||||
replication_timers.prop(settings, "depsgraph_update_rate", text="")
|
||||
|
||||
cache_section = layout.row().box()
|
||||
cache_section.prop(
|
||||
@ -474,8 +421,17 @@ class SESSION_PT_presence(bpy.types.Panel):
|
||||
layout = self.layout
|
||||
|
||||
settings = context.window_manager.session
|
||||
pref = get_preferences()
|
||||
layout.active = settings.enable_presence
|
||||
col = layout.column()
|
||||
col.prop(settings, "presence_show_session_status")
|
||||
row = col.column()
|
||||
row.active = settings.presence_show_session_status
|
||||
row.prop(pref, "presence_hud_scale", expand=True)
|
||||
row = col.column(align=True)
|
||||
row.active = settings.presence_show_session_status
|
||||
row.prop(pref, "presence_hud_hpos", expand=True)
|
||||
row.prop(pref, "presence_hud_vpos", expand=True)
|
||||
col.prop(settings, "presence_show_selected")
|
||||
col.prop(settings, "presence_show_user")
|
||||
row = layout.column()
|
||||
@ -517,10 +473,12 @@ def draw_property(context, parent, property_uuid, level=0):
|
||||
detail_item_box.separator()
|
||||
|
||||
if item.state in [FETCHED, UP]:
|
||||
detail_item_box.operator(
|
||||
apply = detail_item_box.operator(
|
||||
"session.apply",
|
||||
text="",
|
||||
icon=ICONS_PROP_STATES[item.state]).target = item.uuid
|
||||
icon=ICONS_PROP_STATES[item.state])
|
||||
apply.target = item.uuid
|
||||
apply.reset_dependencies = True
|
||||
elif item.state in [MODIFIED, ADDED]:
|
||||
detail_item_box.operator(
|
||||
"session.commit",
|
||||
@ -579,6 +537,13 @@ class SESSION_PT_repository(bpy.types.Panel):
|
||||
row = layout.row()
|
||||
|
||||
if session.state['STATE'] == STATE_ACTIVE:
|
||||
if 'SessionBackupTimer' in registry:
|
||||
row.alert = True
|
||||
row.operator('session.cancel_autosave', icon="CANCEL")
|
||||
row.alert = False
|
||||
else:
|
||||
row.operator('session.save', icon="FILE_TICK")
|
||||
|
||||
flow = layout.grid_flow(
|
||||
row_major=True,
|
||||
columns=0,
|
||||
@ -637,14 +602,15 @@ class VIEW3D_PT_overlay_session(bpy.types.Panel):
|
||||
display_all = overlay.show_overlays
|
||||
|
||||
col = layout.column()
|
||||
col.active = display_all
|
||||
|
||||
row = col.row(align=True)
|
||||
settings = context.window_manager.session
|
||||
layout.active = settings.enable_presence
|
||||
col = layout.column()
|
||||
col.prop(settings, "presence_show_session_status")
|
||||
col.prop(settings, "presence_show_selected")
|
||||
col.prop(settings, "presence_show_user")
|
||||
|
||||
row = layout.column()
|
||||
row.active = settings.presence_show_user
|
||||
row.prop(settings, "presence_show_far_user")
|
||||
|
@ -29,7 +29,14 @@ import math
|
||||
import bpy
|
||||
import mathutils
|
||||
|
||||
from . import environment, presence
|
||||
from . import environment
|
||||
|
||||
from replication.constants import (STATE_ACTIVE, STATE_AUTH,
|
||||
STATE_CONFIG, STATE_SYNCING,
|
||||
STATE_INITIAL, STATE_SRV_SYNC,
|
||||
STATE_WAITING, STATE_QUITTING,
|
||||
STATE_LOBBY,
|
||||
STATE_LAUNCHING_SERVICES)
|
||||
|
||||
|
||||
def find_from_attr(attr_name, attr_value, list):
|
||||
@ -58,6 +65,41 @@ def get_datablock_users(datablock):
|
||||
return users
|
||||
|
||||
|
||||
def flush_history():
|
||||
try:
|
||||
logging.debug("Flushing history")
|
||||
for i in range(bpy.context.preferences.edit.undo_steps+1):
|
||||
bpy.ops.ed.undo_push(message="Multiuser history flush")
|
||||
except RuntimeError:
|
||||
logging.error("Fail to overwrite history")
|
||||
|
||||
|
||||
def get_state_str(state):
|
||||
state_str = 'UNKOWN'
|
||||
if state == STATE_WAITING:
|
||||
state_str = 'WARMING UP DATA'
|
||||
elif state == STATE_SYNCING:
|
||||
state_str = 'FETCHING'
|
||||
elif state == STATE_AUTH:
|
||||
state_str = 'AUTHENTICATION'
|
||||
elif state == STATE_CONFIG:
|
||||
state_str = 'CONFIGURATION'
|
||||
elif state == STATE_ACTIVE:
|
||||
state_str = 'ONLINE'
|
||||
elif state == STATE_SRV_SYNC:
|
||||
state_str = 'PUSHING'
|
||||
elif state == STATE_INITIAL:
|
||||
state_str = 'OFFLINE'
|
||||
elif state == STATE_QUITTING:
|
||||
state_str = 'QUITTING'
|
||||
elif state == STATE_LAUNCHING_SERVICES:
|
||||
state_str = 'LAUNCHING SERVICES'
|
||||
elif state == STATE_LOBBY:
|
||||
state_str = 'LOBBY'
|
||||
|
||||
return state_str
|
||||
|
||||
|
||||
def clean_scene():
|
||||
for type_name in dir(bpy.data):
|
||||
try:
|
||||
@ -66,7 +108,9 @@ def clean_scene():
|
||||
type_collection.remove(item)
|
||||
except:
|
||||
continue
|
||||
|
||||
|
||||
# Clear sequencer
|
||||
bpy.context.scene.sequence_editor_clear()
|
||||
|
||||
def get_selected_objects(scene, active_view_layer):
|
||||
return [obj.uuid for obj in scene.objects if obj.select_get(view_layer=active_view_layer)]
|
||||
|
@ -1,8 +1,8 @@
|
||||
# Download base image debian jessie
|
||||
FROM python:slim
|
||||
|
||||
ARG replication_version=0.0.21a15
|
||||
ARG version=0.1.0
|
||||
ARG replication_version=0.1.13
|
||||
ARG version=0.1.1
|
||||
|
||||
# Infos
|
||||
LABEL maintainer="Swann Martinez"
|
||||
@ -13,12 +13,13 @@ LABEL description="Blender multi-user addon \
|
||||
# Argument
|
||||
ENV password='admin'
|
||||
ENV port=5555
|
||||
ENV timeout=3000
|
||||
ENV log_level=INFO
|
||||
ENV timeout=5000
|
||||
ENV log_level=DEBUG
|
||||
ENV log_file="multiuser_server.log"
|
||||
|
||||
#Install replication
|
||||
RUN pip install replication==$replication_version
|
||||
|
||||
# Run the server with parameters
|
||||
CMD replication.serve -pwd ${password} -p ${port} -t ${timeout} -l ${log_level} -lf ${log_file}
|
||||
ENTRYPOINT ["/bin/sh", "-c"]
|
||||
CMD ["python3 -m replication.server -pwd ${password} -p ${port} -t ${timeout} -l ${log_level} -lf ${log_file}"]
|
10
scripts/start_server/run-dockerfile.sh
Normal file
@ -0,0 +1,10 @@
|
||||
#! /bin/bash
|
||||
|
||||
# Start server in docker container, from image hosted on the multi-user gitlab's container registry
|
||||
docker run -d \
|
||||
-p 5555-5560:5555-5560 \
|
||||
-e port=5555 \
|
||||
-e log-level DEBUG \
|
||||
-e password=admin \
|
||||
-e timeout=1000 \
|
||||
registry.gitlab.com/slumber/multi-user/multi-user-server:0.1.0
|
5
scripts/start_server/start-server.sh
Normal file
@ -0,0 +1,5 @@
|
||||
#! /bin/bash
|
||||
|
||||
# Start replication server locally, and include logging (requires replication_version=0.0.21a15)
|
||||
clear
|
||||
replication.serve -p 5555 -pwd admin -t 1000 -l DEBUG -lf server.log
|
@ -13,7 +13,7 @@ def main():
|
||||
if len(sys.argv) > 2:
|
||||
blender_rev = sys.argv[2]
|
||||
else:
|
||||
blender_rev = "2.90.0"
|
||||
blender_rev = "2.91.0"
|
||||
|
||||
try:
|
||||
exit_val = BAT.test_blender_addon(addon_path=addon, blender_revision=blender_rev)
|
||||
|
@ -7,13 +7,12 @@ import bpy
|
||||
from multi_user.bl_types.bl_material import BlMaterial
|
||||
|
||||
|
||||
def test_material(clear_blend):
|
||||
def test_material_nodes(clear_blend):
|
||||
nodes_types = [node.bl_rna.identifier for node in bpy.types.ShaderNode.__subclasses__()]
|
||||
|
||||
|
||||
datablock = bpy.data.materials.new("test")
|
||||
datablock.use_nodes = True
|
||||
bpy.data.materials.create_gpencil_data(datablock)
|
||||
|
||||
|
||||
for ntype in nodes_types:
|
||||
datablock.node_tree.nodes.new(ntype)
|
||||
|
||||
@ -26,3 +25,18 @@ def test_material(clear_blend):
|
||||
result = implementation._dump(test)
|
||||
|
||||
assert not DeepDiff(expected, result)
|
||||
|
||||
|
||||
def test_material_gpencil(clear_blend):
|
||||
datablock = bpy.data.materials.new("test")
|
||||
bpy.data.materials.create_gpencil_data(datablock)
|
||||
|
||||
implementation = BlMaterial()
|
||||
expected = implementation._dump(datablock)
|
||||
bpy.data.materials.remove(datablock)
|
||||
|
||||
test = implementation._construct(expected)
|
||||
implementation._load(expected, test)
|
||||
result = implementation._dump(test)
|
||||
|
||||
assert not DeepDiff(expected, result)
|
||||
|
@ -23,7 +23,14 @@ MOFIFIERS_TYPES = [
|
||||
'CORRECTIVE_SMOOTH', 'LAPLACIANSMOOTH', 'SURFACE_DEFORM',
|
||||
'WARP', 'WAVE', 'CLOTH', 'COLLISION', 'DYNAMIC_PAINT',
|
||||
'EXPLODE', 'FLUID', 'OCEAN', 'PARTICLE_INSTANCE',
|
||||
'SOFT_BODY', 'SURFACE']
|
||||
'SOFT_BODY', 'SURFACE']
|
||||
|
||||
GP_MODIFIERS_TYPE = [
|
||||
'GP_ARRAY', 'GP_BUILD', 'GP_MIRROR', 'GP_MULTIPLY',
|
||||
'GP_SIMPLIFY', 'GP_SUBDIV', 'GP_ARMATURE',
|
||||
'GP_HOOK', 'GP_LATTICE', 'GP_NOISE', 'GP_OFFSET',
|
||||
'GP_SMOOTH', 'GP_THICK', 'GP_TIME', 'GP_COLOR',
|
||||
'GP_OPACITY', 'GP_TEXTURE', 'GP_TINT']
|
||||
|
||||
CONSTRAINTS_TYPES = [
|
||||
'CAMERA_SOLVER', 'FOLLOW_TRACK', 'OBJECT_SOLVER', 'COPY_LOCATION',
|
||||
@ -33,17 +40,21 @@ CONSTRAINTS_TYPES = [
|
||||
'LOCKED_TRACK', 'STRETCH_TO', 'TRACK_TO', 'ACTION',
|
||||
'ARMATURE', 'CHILD_OF', 'FLOOR', 'FOLLOW_PATH', 'PIVOT', 'SHRINKWRAP']
|
||||
|
||||
#temporary disabled 'SPLINE_IK' until its fixed
|
||||
# temporary disabled 'SPLINE_IK' until its fixed
|
||||
|
||||
|
||||
def test_object(clear_blend):
|
||||
bpy.ops.mesh.primitive_cube_add(
|
||||
enter_editmode=False, align='WORLD', location=(0, 0, 0))
|
||||
|
||||
datablock = bpy.data.objects[0]
|
||||
|
||||
|
||||
# Add modifiers
|
||||
for mod_type in MOFIFIERS_TYPES:
|
||||
datablock.modifiers.new(mod_type,mod_type)
|
||||
datablock.modifiers.new(mod_type, mod_type)
|
||||
|
||||
for mod_type in GP_MODIFIERS_TYPE:
|
||||
datablock.grease_pencil_modifiers.new(mod_type,mod_type)
|
||||
|
||||
# Add constraints
|
||||
for const_type in CONSTRAINTS_TYPES:
|
||||
@ -54,7 +65,6 @@ def test_object(clear_blend):
|
||||
datablock.shape_key_add(name='shape')
|
||||
datablock.shape_key_add(name='shape2')
|
||||
|
||||
|
||||
implementation = BlObject()
|
||||
expected = implementation._dump(datablock)
|
||||
bpy.data.objects.remove(datablock)
|
||||
|
@ -6,8 +6,11 @@ from deepdiff import DeepDiff
|
||||
import bpy
|
||||
import random
|
||||
from multi_user.bl_types.bl_scene import BlScene
|
||||
from multi_user.utils import get_preferences
|
||||
|
||||
def test_scene(clear_blend):
|
||||
get_preferences().sync_flags.sync_render_settings = True
|
||||
|
||||
datablock = bpy.data.scenes.new("toto")
|
||||
datablock.view_settings.use_curve_mapping = True
|
||||
# Test
|
||||
|
24
tests/test_bl_types/test_texture.py
Normal file
@ -0,0 +1,24 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from deepdiff import DeepDiff
|
||||
|
||||
import bpy
|
||||
import random
|
||||
from multi_user.bl_types.bl_texture import BlTexture
|
||||
|
||||
TEXTURE_TYPES = ['NONE', 'BLEND', 'CLOUDS', 'DISTORTED_NOISE', 'IMAGE', 'MAGIC', 'MARBLE', 'MUSGRAVE', 'NOISE', 'STUCCI', 'VORONOI', 'WOOD']
|
||||
|
||||
@pytest.mark.parametrize('texture_type', TEXTURE_TYPES)
|
||||
def test_texture(clear_blend, texture_type):
|
||||
datablock = bpy.data.textures.new('test', texture_type)
|
||||
|
||||
implementation = BlTexture()
|
||||
expected = implementation._dump(datablock)
|
||||
bpy.data.textures.remove(datablock)
|
||||
|
||||
test = implementation._construct(expected)
|
||||
implementation._load(expected, test)
|
||||
result = implementation._dump(test)
|
||||
|
||||
assert not DeepDiff(expected, result)
|
21
tests/test_bl_types/test_volume.py
Normal file
@ -0,0 +1,21 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from deepdiff import DeepDiff
|
||||
|
||||
import bpy
|
||||
import random
|
||||
from multi_user.bl_types.bl_volume import BlVolume
|
||||
|
||||
def test_volume(clear_blend):
|
||||
datablock = bpy.data.volumes.new("Test")
|
||||
|
||||
implementation = BlVolume()
|
||||
expected = implementation._dump(datablock)
|
||||
bpy.data.volumes.remove(datablock)
|
||||
|
||||
test = implementation._construct(expected)
|
||||
implementation._load(expected, test)
|
||||
result = implementation._dump(test)
|
||||
|
||||
assert not DeepDiff(expected, result)
|