Compare commits

...

99 Commits

Author SHA1 Message Date
48eded1a9b feat: update replay 2022-01-21 09:06:56 +01:00
3fbe769928 feat: groundd work on legacy snapshots loading 2021-12-10 16:57:03 +01:00
fe998214be feat: replay duration 2021-12-08 17:42:20 +01:00
3a4f691b8f feat: previous message 2021-12-08 16:30:56 +01:00
0e20d35e7d refactor: move clean to operator.py
feat: timeline action mapping for replay
feat: persistent  collection
fix: draw users in the right scene according to the snapshot
2021-12-08 16:30:40 +01:00
8d15e69b50 fix: replay count 2021-11-24 19:12:29 +01:00
8000ce9931 feat: basic file sequence loading 2021-11-24 18:56:17 +01:00
b96f600f15 feat: initial inteface 2021-11-18 18:01:35 +01:00
de32bd89e3 Merge branch '237-add-draw-user-option-for-the-session-snapshot-importer' into 'develop'
Resolve "Add draw user option for the session snapshot importer"

See merge request slumber/multi-user!156
2021-11-18 15:21:36 +00:00
50e86aea15 fix user drawing options 2021-11-18 16:05:24 +01:00
c05a12343c feat: selection drawing 2021-11-18 15:22:07 +01:00
a09193fba2 feat: expose user radius and intensity 2021-11-18 11:53:24 +01:00
60e21f2b8e fix: load user 2021-11-18 11:43:01 +01:00
421f00879f feat draw users 2021-11-18 11:40:56 +01:00
964e6a8c63 feat: uesr meshes 2021-11-16 09:55:13 +01:00
80c81dc934 Merge branch '240-adding-music-to-the-sequencer-isn-t-replicating' into 'develop'
Resolve "Adding music to the sequencer isn't replicating"

See merge request slumber/multi-user!159
2021-11-09 09:29:58 +00:00
563fdb693d fix: sound not loading
Related to #240
2021-11-09 10:26:47 +01:00
a64eea3cea Merge branch '239-blender-3-x-compatibility' into 'develop'
Ensure blender 3.x compatibility : Fix geometry node outputs replication

See merge request slumber/multi-user!158
2021-11-09 08:48:30 +00:00
03ad7c0066 fix: geometry nodes input / output 2021-11-08 17:34:02 +01:00
d685573834 Merge branch '239-blender-3-x-compatibility' into 'develop'
Ensure blender 3.x version check

See merge request slumber/multi-user!157
2021-11-05 15:20:35 +00:00
0681b53141 fix: version check 2021-11-05 15:39:46 +01:00
6f02b38b0e fix(replication): missing version update 2021-11-03 16:37:12 +01:00
92c773dae9 Merge branch 'develop' of gitlab.com:slumber/multi-user into develop 2021-11-03 16:34:43 +01:00
f48ade6390 fix python 3.10 compatibility (@NotFood) 2021-11-03 16:32:40 +01:00
63c4501b88 Merge branch '236-crash-with-empty-after-a-reconnection' into 'develop'
Resolve "Crash with empty after a reconnection"

See merge request slumber/multi-user!155
2021-10-29 09:40:04 +00:00
06e21c86ce fix none attribute error 2021-10-21 12:19:46 +02:00
9d484b00e9 Merge branch '234-user-info-in-side-panel' into 'develop'
User Info in side panel

See merge request slumber/multi-user!153
2021-08-19 16:09:24 +00:00
de9255f71c feat: presence overlay button+UInfo in side panel 2021-08-19 18:04:07 +02:00
99528ea3e0 Merge branch '232-fix-ui-host-and-lobby' into 'develop'
Resolve "fix ui host and lobby"

See merge request slumber/multi-user!152
2021-08-16 14:03:16 +00:00
bb342951a5 fix: lobby init 2021-08-16 15:59:19 +02:00
438a79177b fix: host solo 2021-08-16 12:02:10 +02:00
08fc49c40f fix: session private by default 2021-07-30 14:09:40 +02:00
d7e25b1192 fix: clean docker file 2021-07-30 13:47:31 +02:00
1671422143 Merge branch 'develop' of gitlab.com:slumber/multi-user into develop 2021-07-30 13:17:29 +02:00
a9620c0752 fix: docker server command 2021-07-30 13:16:43 +02:00
583beaf6fe Merge branch '231-server-public-session-private-issue' into 'develop'
Server "public session" private issue

See merge request slumber/multi-user!151
2021-07-28 15:34:24 +00:00
126d2338f2 fix: server psrwd issue 2021-07-28 17:33:07 +02:00
24b0c0ed8a fix: get active server preset 2021-07-27 17:03:44 +02:00
07fc1cf000 fix: enable tests back 2021-07-27 11:08:57 +02:00
8e0131b3a8 feat: temporary disable test before fixing blender addon_tester 2021-07-26 18:29:06 +02:00
912a2d524c feat: try disable operator tests 2021-07-26 18:19:24 +02:00
82a5124d64 fix: unit tests 2021-07-26 18:16:21 +02:00
cca5bf903b fix: replication deployment 2021-07-26 18:05:36 +02:00
4c0d4cb1c7 Merge branch '218-new-ui-ux-implementation' into 'develop'
New UI/UX implementation

See merge request slumber/multi-user!140
2021-07-26 15:52:19 +00:00
ca64797641 Merge branch 'develop' into 218-new-ui-ux-implementation 2021-07-26 17:51:01 +02:00
a49d9ee437 feat: server ping timeout preferences 2021-07-26 17:42:13 +02:00
4c1cd6b8f8 fix: review 2021-07-26 17:30:56 +02:00
d6cda709a6 fix: replication conflict 2021-07-26 15:46:29 +02:00
4bc0feb3a5 fix: ReferenceError in update_external dependency by removing orphan nodes. 2021-07-23 19:35:56 +02:00
59aab7159a fix: remove logging.info 2021-07-23 17:10:10 +02:00
0a798bb21b feat: clean files+add repository sync icons 2021-07-23 12:51:16 +02:00
beaafce4fa Merge branch 'develop' into 218-new-ui-ux-implementation 2021-07-22 11:01:59 +02:00
6f77337832 feat: request (with bug) 2021-07-22 10:55:18 +02:00
07252d62df feat: fonctional UI, no server pswd/ping 2021-07-22 09:38:01 +02:00
ac615cd134 feat: first+server list UI, ping/pswd unfonctional 2021-07-21 11:12:17 +02:00
a4f9f6e051 fix: replication dependencies conflicts 2021-07-20 16:19:53 +02:00
10de88cdc9 fix: old replication installation conflicts 2021-07-20 16:06:24 +02:00
e4fa34c984 fix: addon version number 2021-07-20 15:37:11 +02:00
0dd685d009 doc: add missing presence flags 2021-07-20 15:11:38 +02:00
3e8c30c0ab fix: supported datablocks in readme 2021-07-20 14:59:30 +02:00
21cc3cd917 fix: update readme to reflect changes 2021-07-20 14:57:52 +02:00
81e620ee3d fix: documentations capture for 0.4.0 2021-07-20 14:50:33 +02:00
fb9bd108bd feat: update changelog to reflect v0.4.0 version 2021-07-20 14:19:33 +02:00
4846fbb589 fix: server list working (no ping/lock/pop-up) 2021-07-19 16:03:12 +02:00
cab6625399 Merge branch '219-lock-annotation-doesn-t-sync' into 'develop'
Resolve "Lock annotation doesn't sync"

See merge request slumber/multi-user!143
2021-07-14 10:41:32 +00:00
1b81251a11 fix: annotation lock 2021-07-14 12:38:30 +02:00
cf44e547a2 fix: presence_text_distance rename 2021-07-13 17:15:34 +02:00
0269363c63 fix: overlay UI 2021-07-13 17:14:32 +02:00
4ffca17c54 fix: edit>prefs UI/UX 2021-07-13 16:40:26 +02:00
77bf269fb5 Merge branch '221-optimize-user-selection-draw-code' into 'develop'
Resolve "Optimize user selection draw code"

See merge request slumber/multi-user!142
2021-07-13 14:37:09 +00:00
1e675132d4 fix: collection instances index offset 2021-07-13 16:33:46 +02:00
781287c390 refactor: use one drawcall for all selection bbox 2021-07-13 15:45:08 +02:00
fc91b252f4 feat: edit>preferences + serverpassword ui 2021-07-12 18:01:35 +02:00
41c7c569ca fix: link session status icons to session header 2021-07-12 14:55:41 +02:00
a82d263f05 feat: add "icons" folder + init 2021-07-12 14:53:18 +02:00
d4476baa1b Merge branch '220-batch-right-selection-update' into 'develop'
Resolve "Batch right selection update"

See merge request slumber/multi-user!141
2021-07-12 10:20:23 +00:00
467e98906e feat: Batch right selection update
Related to https://gitlab.com/slumber/multi-user/-/issues/220
2021-07-12 12:06:45 +02:00
64a25f94a3 fix: gpencil material loading error
Now loading gpencil materials from uuid
2021-07-09 16:59:59 +02:00
e6996316be Merge branch '215-annotations-doesn-t-sync-correctly' into 'develop'
Resolve "Annotations doesn't sync correctly"

See merge request slumber/multi-user!138
2021-07-07 08:18:49 +00:00
cf4cd94096 refactor: remove gpencil dump stroke legacy
Related to #166 and #215
2021-07-07 10:15:23 +02:00
e9ab633aac fix: annotations updates
Related to #215
2021-07-06 16:06:14 +02:00
297639e80f fix: crash on changing workspace change 2021-07-06 15:39:19 +02:00
f0cc63b6f0 Merge branch '214-animated-object-transform-not-correctly-sync' into 'develop'
Resolve "Animated object transform not correctly sync"

See merge request slumber/multi-user!137
2021-07-06 12:32:39 +00:00
d433e8f241 fix: transform offset for object animated with a curve constraint
Related to #214
2021-07-06 14:29:20 +02:00
963a551a1e Merge branch '206-draw-active-mode-in-the-object-presence-overlay-2' into 'develop'
Draw active mode in the object presence overlay

See merge request slumber/multi-user!131
2021-07-01 12:57:01 +00:00
8926ab44e1 Merge branch '201-improved-image-support' into 'develop'
Resolve "Improved image support"

See merge request slumber/multi-user!136
2021-07-01 09:55:47 +00:00
a207c51973 fix: image renamin support
fix: sync Color Space Settings

related to #201
2021-06-29 15:59:26 +02:00
e706c8e0bf Merge branch '209-adding-a-scene-create-node-duplicates' into 'develop'
Resolve "Adding a scene create node duplicates"

See merge request slumber/multi-user!135
2021-06-28 08:30:22 +00:00
e590e896da fix: scene duplicates by using data instead of the update id
Related to #209
2021-06-28 10:27:04 +02:00
4140b62a8e Merge branch '119-add-timeline-marker-sync' into 'develop'
Resolve "Add timeline marker sync"

See merge request slumber/multi-user!133
2021-06-24 15:52:12 +00:00
6d9c9c4532 fix: timeline marker selection
feat: basic test
2021-06-24 17:45:34 +02:00
e9e1911840 Merge branch '208-late-update-logging-error' into 'develop'
Resolve "Late update logging error"

See merge request slumber/multi-user!134
2021-06-24 15:28:56 +00:00
ab350ca7bc fix: late update logging error
Related to #208
2021-06-24 17:24:08 +02:00
2238a15c11 feat: initial markers support 2021-06-24 15:51:01 +02:00
de73f022e6 merge 2021-06-24 14:52:07 +02:00
f517205647 fix: doc authors 2021-06-24 14:51:00 +02:00
f33c3d8481 fix: doc version 2021-06-24 14:50:12 +02:00
71c69000ec Merge branch '207-repository-panel-filtering-is-boken' into 'develop'
Resolve "Repository panel filtering is boken"

See merge request slumber/multi-user!132
2021-06-24 12:49:06 +00:00
de1e684b3c fix: name filtering 2021-06-24 14:35:59 +02:00
41 changed files with 1707 additions and 794 deletions

View File

@ -187,3 +187,33 @@ All notable changes to this project will be documented in this file.
- Sync missing armature bone Roll - Sync missing armature bone Roll
- Sync missing driver data_path - Sync missing driver data_path
- Constraint replication - Constraint replication
## [0.4.0] - 2021-07-20
### Added
- Connection preset system (@Kysios)
- Display connected users active mode (users pannel and viewport) (@Kysios)
- Delta-based replication
- Sync timeline marker
- Sync images settings (@Kysios)
- Sync parent relation type (@Kysios)
- Sync uv project modifier
- Sync FCurves modifiers
### Changed
- User selection optimizations (draw and sync) (@Kysios)
- Improved shapekey syncing performances
- Improved gpencil syncing performances
- Integrate replication as a submodule
- The dependencies are now installed in a folder(blender addon folder) that no longer requires administrative rights
- Presence overlay UI optimization (@Kysios)
### Fixed
- User selection bounding box glitches for non-mesh objects (@Kysios)
- Transforms replication for animated objects
- GPencil fill stroke
- Sculpt and GPencil brushes deleted when joining a session (@Kysios)
- Auto-updater doesn't work for master and develop builds

View File

@ -11,9 +11,8 @@ This tool aims to allow multiple users to work on the same scene over the networ
## Quick installation ## Quick installation
1. Download latest release [multi_user.zip](https://gitlab.com/slumber/multi-user/-/jobs/artifacts/master/download?job=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 (dependencies installation). 2. Install last_version.zip from your addon preferences.
3. Install last_version.zip from your addon preferences.
[Dependencies](#dependencies) will be automatically added to your blender python during installation. [Dependencies](#dependencies) will be automatically added to your blender python during installation.
@ -29,35 +28,35 @@ See the [troubleshooting guide](https://slumber.gitlab.io/multi-user/getting_sta
Currently, not all data-block are supported for replication over the wire. The following list summarizes the status for each ones. Currently, not all data-block are supported for replication over the wire. The following list summarizes the status for each ones.
| Name | Status | Comment | | Name | Status | Comment |
| -------------- | :----: | :----------------------------------------------------------: | | -------------- | :----: | :---------------------------------------------------------------------: |
| action | ✔️ | | | action | ✔️ | |
| camera | ✔️ | | | camera | ✔️ | |
| collection | ✔️ | | | collection | ✔️ | |
| gpencil | ✔️ | | | gpencil | ✔️ | |
| image | ✔️ | | | image | ✔️ | |
| mesh | ✔️ | | | mesh | ✔️ | |
| material | ✔️ | | | material | ✔️ | |
| node_groups | ✔️ | Material & Geometry only | | node_groups | ✔️ | Material & Geometry only |
| geometry nodes | ✔️ | | | geometry nodes | ✔️ | |
| metaball | ✔️ | | | metaball | ✔️ | |
| object | ✔️ | | | object | ✔️ | |
| texts | ✔️ | | | texts | ✔️ | |
| scene | ✔️ | | | scene | ✔️ | |
| world | ✔️ | | | world | ✔️ | |
| volumes | ✔️ | | | volumes | ✔️ | |
| lightprobes | ✔️ | | | lightprobes | ✔️ | |
| physics | ✔️ | | | physics | ✔️ | |
| curve | ❗ | Nurbs surfaces not supported | | textures | ✔️ | |
| textures | ❗ | Supported for modifiers/materials/geo nodes only | | curve | ❗ | Nurbs surfaces not supported |
| armature | ❗ | Not stable | | armature | ❗ | Only for Mesh. [Planned for GPencil](https://gitlab.com/slumber/multi-user/-/issues/161). Not stable yet |
| particles | ❗ | The cache isn't syncing. | | particles | ❗ | The cache isn't syncing. |
| speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) | | speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) |
| vse | ❗ | Mask and Clip not supported yet | | vse | ❗ | Mask and Clip not supported yet |
| libraries | | Partial | | libraries | | |
| nla | ❌ | | | nla | ❌ | |
| texts | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/81) | | texts | ❌ | [Planned for v0.5.0](https://gitlab.com/slumber/multi-user/-/issues/81) |
| compositing | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/46) | | compositing | ❌ | [Planned for v0.5.0](https://gitlab.com/slumber/multi-user/-/issues/46) |

View File

@ -19,10 +19,10 @@ import sys
project = 'multi-user' project = 'multi-user'
copyright = '2020, Swann Martinez' copyright = '2020, Swann Martinez'
author = 'Swann Martinez, with contributions from Poochy' author = 'Swann Martinez, Poochy, Fabian'
# The full version, including alpha/beta/rc tags # The full version, including alpha/beta/rc tags
release = '0.2.0' release = '0.5.0-develop'
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -215,8 +215,10 @@ One of the most vital tools is the **Online user panel**. It lists all connected
users' information including your own: users' information including your own:
* **Role** : if a 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. * **Username** : Name of the user.
* **Mode** : User's active editing mode (edit_mesh, paint,etc.).
* **Frame**: When (on which frame) the user is working. * **Frame**: When (on which frame) the user is working.
* **Location**: Where the user is actually working.
* **Ping**: user's connection delay in milliseconds * **Ping**: user's connection delay in milliseconds
.. figure:: img/quickstart_users.png .. figure:: img/quickstart_users.png
@ -273,6 +275,7 @@ it draw users' related information in your viewport such as:
* Username * Username
* User point of view * User point of view
* User active mode
* User selection * User selection
.. figure:: img/quickstart_presence.png .. figure:: img/quickstart_presence.png

View File

@ -212,14 +212,14 @@ You can run the dedicated server on any platform by following these steps:
.. code-block:: bash .. code-block:: bash
replication.server replication.serve
.. hint:: .. hint::
You can also specify a custom **port** (-p), **timeout** (-t), **admin password** (-pwd), **log level (ERROR, WARNING, INFO or DEBUG)** (-l) and **log file** (-lf) with the following optional arguments 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 .. code-block:: bash
replication.server -p 5555 -pwd admin -t 5000 -l INFO -lf server.log replication.serve -p 5555 -pwd admin -t 5000 -l INFO -lf server.log
Here, for example, a server is instantiated on port 5555, with password 'admin', a 5 second timeout, and logging enabled. Here, for example, a server is instantiated on port 5555, with password 'admin', a 5 second timeout, and logging enabled.
@ -562,7 +562,7 @@ The default Docker image essentially runs the equivalent of:
.. code-block:: bash .. code-block:: bash
replication.server -pwd admin -p 5555 -t 5000 -l DEBUG -lf multiuser_server.log replication.serve -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. 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.
@ -572,7 +572,7 @@ For example, I would like to launch my server with a different administrator pas
.. code-block:: bash .. code-block:: bash
python3 -m replication.server -pwd supersecretpassword -p 5555 -t 3000 -l DEBUG -lf logname.log replication.serve -pwd supersecretpassword -p 5555 -t 3000 -l DEBUG -lf logname.log
Now, my configuration should look like this: Now, my configuration should look like this:
@ -691,7 +691,7 @@ We're finally ready to launch the server. Simply run:
.. code-block:: bash .. code-block:: bash
python3 -m replication.server -p 5555 -pwd admin -t 5000 -l INFO -lf server.log replication.serve -p 5555 -pwd admin -t 5000 -l INFO -lf server.log
See :ref:`cmd-line` for a description of optional parameters See :ref:`cmd-line` for a description of optional parameters

View File

@ -19,7 +19,7 @@
bl_info = { bl_info = {
"name": "Multi-User", "name": "Multi-User",
"author": "Swann Martinez", "author": "Swann Martinez",
"version": (0, 5, 0), "version": (0, 4, 0),
"description": "Enable real-time collaborative workflow inside blender", "description": "Enable real-time collaborative workflow inside blender",
"blender": (2, 82, 0), "blender": (2, 82, 0),
"location": "3D View > Sidebar > Multi-User tab", "location": "3D View > Sidebar > Multi-User tab",
@ -61,6 +61,7 @@ def register():
from . import operators from . import operators
from . import handlers from . import handlers
from . import ui from . import ui
from . import icons
from . import preferences from . import preferences
from . import addon_updater_ops from . import addon_updater_ops
@ -70,6 +71,7 @@ def register():
operators.register() operators.register()
handlers.register() handlers.register()
ui.register() ui.register()
icons.register()
except ModuleNotFoundError as e: except ModuleNotFoundError as e:
raise Exception(module_error_msg) raise Exception(module_error_msg)
logging.error(module_error_msg) logging.error(module_error_msg)
@ -83,7 +85,9 @@ def register():
type=preferences.SessionUser type=preferences.SessionUser
) )
bpy.types.WindowManager.user_index = bpy.props.IntProperty() bpy.types.WindowManager.user_index = bpy.props.IntProperty()
bpy.types.WindowManager.server_index = bpy.props.IntProperty()
bpy.types.TOPBAR_MT_file_import.append(operators.menu_func_import) bpy.types.TOPBAR_MT_file_import.append(operators.menu_func_import)
bpy.types.TOPBAR_MT_file_export.append(operators.menu_func_export)
def unregister(): def unregister():
@ -91,14 +95,17 @@ def unregister():
from . import operators from . import operators
from . import handlers from . import handlers
from . import ui from . import ui
from . import icons
from . import preferences from . import preferences
from . import addon_updater_ops from . import addon_updater_ops
bpy.types.TOPBAR_MT_file_import.remove(operators.menu_func_import) bpy.types.TOPBAR_MT_file_import.remove(operators.menu_func_import)
bpy.types.TOPBAR_MT_file_export.remove(operators.menu_func_export)
presence.unregister() presence.unregister()
addon_updater_ops.unregister() addon_updater_ops.unregister()
ui.unregister() ui.unregister()
icons.unregister()
handlers.unregister() handlers.unregister()
operators.unregister() operators.unregister()
preferences.unregister() preferences.unregister()
@ -107,5 +114,6 @@ def unregister():
del bpy.types.ID.uuid del bpy.types.ID.uuid
del bpy.types.WindowManager.online_users del bpy.types.WindowManager.online_users
del bpy.types.WindowManager.user_index del bpy.types.WindowManager.user_index
del bpy.types.WindowManager.server_index
environment.unregister() environment.unregister()

View File

@ -43,7 +43,7 @@ __all__ = [
"bl_particle", "bl_particle",
] # Order here defines execution order ] # Order here defines execution order
if bpy.app.version[1] >= 91: if bpy.app.version >= (2,91,0):
__all__.append('bl_volume') __all__.append('bl_volume')
from . import * from . import *

View File

@ -56,7 +56,7 @@ class BlCamera(ReplicatedDatablock):
background_images = data.get('background_images') background_images = data.get('background_images')
datablock.background_images.clear() datablock.background_images.clear()
# TODO: Use image uuid
if background_images: if background_images:
for img_name, img_data in background_images.items(): for img_name, img_data in background_images.items():
img_id = img_data.get('image') img_id = img_data.get('image')

View File

@ -28,7 +28,8 @@ from replication.protocol import ReplicatedDatablock
from .bl_datablock import resolve_datablock_from_uuid from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
from ..utils import get_preferences from ..utils import get_preferences
from ..timers import is_annotating
from .bl_material import load_materials_slots, dump_materials_slots
STROKE_POINT = [ STROKE_POINT = [
'co', 'co',
@ -52,12 +53,12 @@ STROKE = [
"uv_translation", "uv_translation",
"vertex_color_fill", "vertex_color_fill",
] ]
if bpy.app.version[1] >= 91: if bpy.app.version >= (2,91,0):
STROKE.append('use_cyclic') STROKE.append('use_cyclic')
else: else:
STROKE.append('draw_cyclic') STROKE.append('draw_cyclic')
if bpy.app.version[1] >= 83: if bpy.app.version >= (2,83,0):
STROKE_POINT.append('vertex_color') STROKE_POINT.append('vertex_color')
def dump_stroke(stroke): def dump_stroke(stroke):
@ -65,36 +66,9 @@ def dump_stroke(stroke):
:param stroke: target grease pencil stroke :param stroke: target grease pencil stroke
:type stroke: bpy.types.GPencilStroke :type stroke: bpy.types.GPencilStroke
:return: dict :return: (p_count, p_data)
""" """
return (len(stroke.points), np_dump_collection(stroke.points, STROKE_POINT))
assert(stroke)
dumper = Dumper()
dumper.include_filter = [
"aspect",
"display_mode",
"draw_cyclic",
"end_cap_mode",
"hardeness",
"line_width",
"material_index",
"start_cap_mode",
"uv_rotation",
"uv_scale",
"uv_translation",
"vertex_color_fill",
]
dumped_stroke = dumper.dump(stroke)
# Stoke points
p_count = len(stroke.points)
dumped_stroke['p_count'] = p_count
dumped_stroke['points'] = np_dump_collection(stroke.points, STROKE_POINT)
# TODO: uv_factor, uv_rotation
return dumped_stroke
def load_stroke(stroke_data, stroke): def load_stroke(stroke_data, stroke):
@ -107,12 +81,12 @@ def load_stroke(stroke_data, stroke):
""" """
assert(stroke and stroke_data) assert(stroke and stroke_data)
stroke.points.add(stroke_data["p_count"]) stroke.points.add(stroke_data[0])
np_load_collection(stroke_data['points'], stroke.points, STROKE_POINT) np_load_collection(stroke_data[1], stroke.points, STROKE_POINT)
# HACK: Temporary fix to trigger a BKE_gpencil_stroke_geometry_update to # HACK: Temporary fix to trigger a BKE_gpencil_stroke_geometry_update to
# fix fill issues # fix fill issues
stroke.uv_scale = stroke_data["uv_scale"] stroke.uv_scale = 1.0
def dump_frame(frame): def dump_frame(frame):
@ -147,10 +121,12 @@ def load_frame(frame_data, frame):
assert(frame and frame_data) assert(frame and frame_data)
# Load stroke points
for stroke_data in frame_data['strokes_points']: for stroke_data in frame_data['strokes_points']:
target_stroke = frame.strokes.new() target_stroke = frame.strokes.new()
load_stroke(stroke_data, target_stroke) load_stroke(stroke_data, target_stroke)
# Load stroke metadata
np_load_collection(frame_data['strokes'], frame.strokes, STROKE) np_load_collection(frame_data['strokes'], frame.strokes, STROKE)
@ -170,7 +146,6 @@ def dump_layer(layer):
'opacity', 'opacity',
'channel_color', 'channel_color',
'color', 'color',
# 'thickness', #TODO: enabling only for annotation
'tint_color', 'tint_color',
'tint_factor', 'tint_factor',
'vertex_paint_opacity', 'vertex_paint_opacity',
@ -187,7 +162,7 @@ def dump_layer(layer):
'hide', 'hide',
'annotation_hide', 'annotation_hide',
'lock', 'lock',
# 'lock_frame', 'lock_frame',
# 'lock_material', # 'lock_material',
# 'use_mask_layer', # 'use_mask_layer',
'use_lights', 'use_lights',
@ -195,12 +170,13 @@ def dump_layer(layer):
'select', 'select',
'show_points', 'show_points',
'show_in_front', 'show_in_front',
# 'thickness'
# 'parent', # 'parent',
# 'parent_type', # 'parent_type',
# 'parent_bone', # 'parent_bone',
# 'matrix_inverse', # 'matrix_inverse',
] ]
if layer.id_data.is_annotation: if layer.thickness != 0:
dumper.include_filter.append('thickness') dumper.include_filter.append('thickness')
dumped_layer = dumper.dump(layer) dumped_layer = dumper.dump(layer)
@ -255,10 +231,10 @@ class BlGpencil(ReplicatedDatablock):
@staticmethod @staticmethod
def load(data: dict, datablock: object): def load(data: dict, datablock: object):
datablock.materials.clear() # MATERIAL SLOTS
if "materials" in data.keys(): src_materials = data.get('materials', None)
for mat in data['materials']: if src_materials:
datablock.materials.append(bpy.data.materials[mat]) load_materials_slots(src_materials, datablock.materials)
loader = Loader() loader = Loader()
loader.load(datablock, data) loader.load(datablock, data)
@ -286,7 +262,6 @@ class BlGpencil(ReplicatedDatablock):
dumper = Dumper() dumper = Dumper()
dumper.depth = 2 dumper.depth = 2
dumper.include_filter = [ dumper.include_filter = [
'materials',
'name', 'name',
'zdepth_offset', 'zdepth_offset',
'stroke_thickness_space', 'stroke_thickness_space',
@ -294,7 +269,7 @@ class BlGpencil(ReplicatedDatablock):
'stroke_depth_order' 'stroke_depth_order'
] ]
data = dumper.dump(datablock) data = dumper.dump(datablock)
data['materials'] = dump_materials_slots(datablock.materials)
data['layers'] = {} data['layers'] = {}
for layer in datablock.layers: for layer in datablock.layers:
@ -323,7 +298,8 @@ class BlGpencil(ReplicatedDatablock):
return bpy.context.mode == 'OBJECT' \ return bpy.context.mode == 'OBJECT' \
or layer_changed(datablock, data) \ or layer_changed(datablock, data) \
or frame_changed(data) \ or frame_changed(data) \
or get_preferences().sync_flags.sync_during_editmode or get_preferences().sync_flags.sync_during_editmode \
or is_annotating(bpy.context)
_type = bpy.types.GreasePencil _type = bpy.types.GreasePencil
_class = BlGpencil _class = BlGpencil

View File

@ -69,11 +69,12 @@ class BlImage(ReplicatedDatablock):
@staticmethod @staticmethod
def load(data: dict, datablock: object): def load(data: dict, datablock: object):
loader = Loader() loader = Loader()
loader.load(data, datablock) loader.load(datablock, data)
# datablock.name = data.get('name')
datablock.source = 'FILE' datablock.source = 'FILE'
datablock.filepath_raw = get_filepath(data['filename']) datablock.filepath_raw = get_filepath(data['filename'])
color_space_name = data["colorspace_settings"]["name"] color_space_name = data.get("colorspace")
if color_space_name: if color_space_name:
datablock.colorspace_settings.name = color_space_name datablock.colorspace_settings.name = color_space_name
@ -92,12 +93,10 @@ class BlImage(ReplicatedDatablock):
"name", "name",
# 'source', # 'source',
'size', 'size',
'height', 'alpha_mode']
'alpha',
'float_buffer',
'alpha_mode',
'colorspace_settings']
data.update(dumper.dump(datablock)) data.update(dumper.dump(datablock))
data['colorspace'] = datablock.colorspace_settings.name
return data return data
@staticmethod @staticmethod
@ -132,10 +131,7 @@ class BlImage(ReplicatedDatablock):
if datablock.is_dirty: if datablock.is_dirty:
datablock.save() datablock.save()
if not data or (datablock and (datablock.name != data.get('name'))): return True
return True
else:
return False
_type = bpy.types.Image _type = bpy.types.Image
_class = BlImage _class = BlImage

View File

@ -37,7 +37,7 @@ class BlLightprobe(ReplicatedDatablock):
def construct(data: dict) -> object: def construct(data: dict) -> object:
type = 'CUBE' if data['type'] == 'CUBEMAP' else data['type'] type = 'CUBE' if data['type'] == 'CUBEMAP' else data['type']
# See https://developer.blender.org/D6396 # See https://developer.blender.org/D6396
if bpy.app.version[1] >= 83: if bpy.app.version >= (2,83,0):
return bpy.data.lightprobes.new(data["name"], type) return bpy.data.lightprobes.new(data["name"], type)
else: else:
logging.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") logging.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396")
@ -49,7 +49,7 @@ class BlLightprobe(ReplicatedDatablock):
@staticmethod @staticmethod
def dump(datablock: object) -> dict: def dump(datablock: object) -> dict:
if bpy.app.version[1] < 83: if bpy.app.version < (2,83,0):
logging.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") logging.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396")
dumper = Dumper() dumper = Dumper()

View File

@ -124,8 +124,7 @@ def dump_node(node: bpy.types.ShaderNode) -> dict:
"show_preview", "show_preview",
"show_texture", "show_texture",
"outputs", "outputs",
"width_hidden", "width_hidden"
"image"
] ]
dumped_node = node_dumper.dump(node) dumped_node = node_dumper.dump(node)
@ -388,11 +387,10 @@ def load_materials_slots(src_materials: list, dst_materials: bpy.types.bpy_prop_
for mat_uuid, mat_name in src_materials: for mat_uuid, mat_name in src_materials:
mat_ref = None mat_ref = None
if mat_uuid is not None: if mat_uuid:
mat_ref = get_datablock_from_uuid(mat_uuid, None) mat_ref = get_datablock_from_uuid(mat_uuid, None)
else: else:
mat_ref = bpy.data.materials[mat_name] mat_ref = bpy.data.materials[mat_name]
dst_materials.append(mat_ref) dst_materials.append(mat_ref)

View File

@ -48,7 +48,7 @@ SHAPEKEY_BLOCK_ATTR = [
] ]
if bpy.app.version[1] >= 93: if bpy.app.version >= (2,93,0):
SUPPORTED_GEOMETRY_NODE_PARAMETERS = (int, str, float) SUPPORTED_GEOMETRY_NODE_PARAMETERS = (int, str, float)
else: else:
SUPPORTED_GEOMETRY_NODE_PARAMETERS = (int, str) SUPPORTED_GEOMETRY_NODE_PARAMETERS = (int, str)
@ -56,14 +56,24 @@ else:
blender 2.92.") blender 2.92.")
def get_node_group_inputs(node_group): def get_node_group_properties_identifiers(node_group):
inputs = [] props_ids = []
# Inputs
for inpt in node_group.inputs: for inpt in node_group.inputs:
if inpt.type in IGNORED_SOCKETS: if inpt.type in IGNORED_SOCKETS:
continue continue
else: else:
inputs.append(inpt) props_ids.append((inpt.identifier, inpt.type))
return inputs
if inpt.type in ['INT', 'VALUE', 'BOOLEAN', 'RGBA', 'VECTOR']:
props_ids.append((f"{inpt.identifier}_attribute_name",'STR'))
props_ids.append((f"{inpt.identifier}_use_attribute", 'BOOL'))
for outpt in node_group.outputs:
if outpt.type not in IGNORED_SOCKETS and outpt.type in ['INT', 'VALUE', 'BOOLEAN', 'RGBA', 'VECTOR']:
props_ids.append((f"{outpt.identifier}_attribute_name", 'STR'))
return props_ids
# return [inpt.identifer for inpt in node_group.inputs if inpt.type not in IGNORED_SOCKETS] # return [inpt.identifer for inpt in node_group.inputs if inpt.type not in IGNORED_SOCKETS]
@ -122,29 +132,35 @@ def load_physics(dumped_settings: dict, target: bpy.types.Object):
bpy.ops.rigidbody.constraint_remove({"object": target}) bpy.ops.rigidbody.constraint_remove({"object": target})
def dump_modifier_geometry_node_inputs(modifier: bpy.types.Modifier) -> list: def dump_modifier_geometry_node_props(modifier: bpy.types.Modifier) -> list:
""" Dump geometry node modifier input properties """ Dump geometry node modifier input properties
:arg modifier: geometry node modifier to dump :arg modifier: geometry node modifier to dump
:type modifier: bpy.type.Modifier :type modifier: bpy.type.Modifier
""" """
dumped_inputs = [] dumped_props = []
for inpt in get_node_group_inputs(modifier.node_group):
input_value = modifier[inpt.identifier]
dumped_input = None for prop_value, prop_type in get_node_group_properties_identifiers(modifier.node_group):
if isinstance(input_value, bpy.types.ID): try:
dumped_input = input_value.uuid prop_value = modifier[prop_value]
elif isinstance(input_value, SUPPORTED_GEOMETRY_NODE_PARAMETERS): except KeyError as e:
dumped_input = input_value logging.error(f"fail to dump geomety node modifier property : {prop_value} ({e})")
elif hasattr(input_value, 'to_list'): else:
dumped_input = input_value.to_list() dump = None
dumped_inputs.append(dumped_input) if isinstance(prop_value, bpy.types.ID):
dump = prop_value.uuid
elif isinstance(prop_value, SUPPORTED_GEOMETRY_NODE_PARAMETERS):
dump = prop_value
elif hasattr(prop_value, 'to_list'):
dump = prop_value.to_list()
return dumped_inputs dumped_props.append((dump, prop_type))
# logging.info(prop_value)
return dumped_props
def load_modifier_geometry_node_inputs(dumped_modifier: dict, target_modifier: bpy.types.Modifier): def load_modifier_geometry_node_props(dumped_modifier: dict, target_modifier: bpy.types.Modifier):
""" Load geometry node modifier inputs """ Load geometry node modifier inputs
:arg dumped_modifier: source dumped modifier to load :arg dumped_modifier: source dumped modifier to load
@ -153,17 +169,17 @@ def load_modifier_geometry_node_inputs(dumped_modifier: dict, target_modifier: b
:type target_modifier: bpy.type.Modifier :type target_modifier: bpy.type.Modifier
""" """
for input_index, inpt in enumerate(get_node_group_inputs(target_modifier.node_group)): for input_index, inpt in enumerate(get_node_group_properties_identifiers(target_modifier.node_group)):
dumped_value = dumped_modifier['inputs'][input_index] dumped_value, dumped_type = dumped_modifier['props'][input_index]
input_value = target_modifier[inpt.identifier] input_value = target_modifier[inpt[0]]
if isinstance(input_value, SUPPORTED_GEOMETRY_NODE_PARAMETERS): if dumped_type in ['INT', 'VALUE', 'STR']:
target_modifier[inpt.identifier] = dumped_value logging.info(f"{inpt[0]}/{dumped_value}")
elif hasattr(input_value, 'to_list'): target_modifier[inpt[0]] = dumped_value
elif dumped_type in ['RGBA', 'VECTOR']:
for index in range(len(input_value)): for index in range(len(input_value)):
input_value[index] = dumped_value[index] input_value[index] = dumped_value[index]
elif inpt.type in ['COLLECTION', 'OBJECT']: elif dumped_type in ['COLLECTION', 'OBJECT', 'IMAGE', 'TEXTURE', 'MATERIAL']:
target_modifier[inpt.identifier] = get_datablock_from_uuid( target_modifier[inpt[0]] = get_datablock_from_uuid(dumped_value, None)
dumped_value, None)
def load_pose(target_bone, data): def load_pose(target_bone, data):
@ -198,12 +214,12 @@ def find_data_from_name(name=None):
instance = bpy.data.speakers[name] instance = bpy.data.speakers[name]
elif name in bpy.data.lightprobes.keys(): elif name in bpy.data.lightprobes.keys():
# Only supported since 2.83 # Only supported since 2.83
if bpy.app.version[1] >= 83: if bpy.app.version >= (2,83,0):
instance = bpy.data.lightprobes[name] instance = bpy.data.lightprobes[name]
else: else:
logging.warning( logging.warning(
"Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") "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(): elif bpy.app.version >= (2,91,0) and name in bpy.data.volumes.keys():
# Only supported since 2.91 # Only supported since 2.91
instance = bpy.data.volumes[name] instance = bpy.data.volumes[name]
return instance return instance
@ -250,10 +266,11 @@ def find_geometry_nodes_dependencies(modifiers: bpy.types.bpy_prop_collection) -
for mod in modifiers: for mod in modifiers:
if mod.type == 'NODES' and mod.node_group: if mod.type == 'NODES' and mod.node_group:
dependencies.append(mod.node_group) dependencies.append(mod.node_group)
# for inpt in get_node_group_inputs(mod.node_group): for inpt, inpt_type in get_node_group_properties_identifiers(mod.node_group):
# parameter = mod.get(inpt.identifier) inpt_value = mod.get(inpt)
# if parameter and isinstance(parameter, bpy.types.ID): # Avoid to handle 'COLLECTION', 'OBJECT' to avoid circular dependencies
# dependencies.append(parameter) if inpt_type in ['IMAGE', 'TEXTURE', 'MATERIAL'] and inpt_value:
dependencies.append(inpt_value)
return dependencies return dependencies
@ -387,10 +404,7 @@ def dump_modifiers(modifiers: bpy.types.bpy_prop_collection)->dict:
dumped_modifier = dumper.dump(modifier) dumped_modifier = dumper.dump(modifier)
# hack to dump geometry nodes inputs # hack to dump geometry nodes inputs
if modifier.type == 'NODES': if modifier.type == 'NODES':
dumped_inputs = dump_modifier_geometry_node_inputs( dumped_modifier['props'] = dump_modifier_geometry_node_props(modifier)
modifier)
dumped_modifier['inputs'] = dumped_inputs
elif modifier.type == 'PARTICLE_SYSTEM': elif modifier.type == 'PARTICLE_SYSTEM':
dumper.exclude_filter = [ dumper.exclude_filter = [
"is_edited", "is_edited",
@ -455,7 +469,7 @@ def load_modifiers(dumped_modifiers: list, modifiers: bpy.types.bpy_prop_collect
loader.load(loaded_modifier, dumped_modifier) loader.load(loaded_modifier, dumped_modifier)
if loaded_modifier.type == 'NODES': if loaded_modifier.type == 'NODES':
load_modifier_geometry_node_inputs(dumped_modifier, loaded_modifier) load_modifier_geometry_node_props(dumped_modifier, loaded_modifier)
elif loaded_modifier.type == 'PARTICLE_SYSTEM': elif loaded_modifier.type == 'PARTICLE_SYSTEM':
default = loaded_modifier.particle_system.settings default = loaded_modifier.particle_system.settings
dumped_particles = dumped_modifier['particle_system'] dumped_particles = dumped_modifier['particle_system']
@ -620,10 +634,8 @@ class BlObject(ReplicatedDatablock):
transform = data.get('transforms', None) transform = data.get('transforms', None)
if transform: if transform:
datablock.matrix_parent_inverse = mathutils.Matrix( datablock.matrix_parent_inverse = mathutils.Matrix(transform['matrix_parent_inverse'])
transform['matrix_parent_inverse'])
datablock.matrix_basis = mathutils.Matrix(transform['matrix_basis']) datablock.matrix_basis = mathutils.Matrix(transform['matrix_basis'])
datablock.matrix_local = mathutils.Matrix(transform['matrix_local'])
@staticmethod @staticmethod

View File

@ -403,8 +403,9 @@ class BlScene(ReplicatedDatablock):
datablock.world = bpy.data.worlds[data['world']] datablock.world = bpy.data.worlds[data['world']]
# Annotation # Annotation
if 'grease_pencil' in data.keys(): gpencil_uid = data.get('grease_pencil')
datablock.grease_pencil = bpy.data.grease_pencils[data['grease_pencil']] if gpencil_uid:
datablock.grease_pencil = resolve_datablock_from_uuid(gpencil_uid, bpy.data.grease_pencils)
if get_preferences().sync_flags.sync_render_settings: if get_preferences().sync_flags.sync_render_settings:
if 'eevee' in data.keys(): if 'eevee' in data.keys():
@ -439,12 +440,21 @@ class BlScene(ReplicatedDatablock):
if seq.name not in sequences: if seq.name not in sequences:
vse.sequences.remove(seq) vse.sequences.remove(seq)
# Load existing sequences # Load existing sequences
for seq_data in sequences.value(): for seq_data in sequences.values():
load_sequence(seq_data, vse) load_sequence(seq_data, vse)
# If the sequence is no longer used, clear it # If the sequence is no longer used, clear it
elif datablock.sequence_editor and not sequences: elif datablock.sequence_editor and not sequences:
datablock.sequence_editor_clear() datablock.sequence_editor_clear()
# Timeline markers
markers = data.get('timeline_markers')
if markers:
datablock.timeline_markers.clear()
for name, frame, camera in markers:
marker = datablock.timeline_markers.new(name, frame=frame)
if camera:
marker.camera = resolve_datablock_from_uuid(camera, bpy.data.objects)
marker.select = False
# FIXME: Find a better way after the replication big refacotoring # FIXME: Find a better way after the replication big refacotoring
# Keep other user from deleting collection object by flushing their history # Keep other user from deleting collection object by flushing their history
flush_history() flush_history()
@ -461,7 +471,6 @@ class BlScene(ReplicatedDatablock):
'name', 'name',
'world', 'world',
'id', 'id',
'grease_pencil',
'frame_start', 'frame_start',
'frame_end', 'frame_end',
'frame_step', 'frame_step',
@ -517,6 +526,13 @@ class BlScene(ReplicatedDatablock):
dumped_sequences[seq.name] = dump_sequence(seq) dumped_sequences[seq.name] = dump_sequence(seq)
data['sequences'] = dumped_sequences data['sequences'] = dumped_sequences
# Timeline markers
if datablock.timeline_markers:
data['timeline_markers'] = [(m.name, m.frame, getattr(m.camera, 'uuid', None)) for m in datablock.timeline_markers]
if datablock.grease_pencil:
data['grease_pencil'] = datablock.grease_pencil.uuid
return data return data
@staticmethod @staticmethod

View File

@ -134,7 +134,7 @@ def install_modules(dependencies: list, python_path: str, install_dir: str):
module_can_be_imported(package_name) module_can_be_imported(package_name)
def register(): def register():
if bpy.app.version[1] >= 91: if bpy.app.version >= (2,91,0):
python_binary_path = sys.executable python_binary_path = sys.executable
else: else:
python_binary_path = bpy.app.binary_path_python python_binary_path = bpy.app.binary_path_python

View File

@ -52,7 +52,8 @@ def sanitize_deps_graph(remove_nodes: bool = False):
def update_external_dependencies(): def update_external_dependencies():
"""Force external dependencies(files such as images) evaluation """Force external dependencies(files such as images) evaluation
""" """
nodes_ids = [n.uuid for n in session.repository.graph.values() if n.data['type_id'] in ['WindowsPath', 'PosixPath']] external_types = ['WindowsPath', 'PosixPath', 'Image']
nodes_ids = [n.uuid for n in session.repository.graph.values() if n.data['type_id'] in external_types]
for node_id in nodes_ids: for node_id in nodes_ids:
node = session.repository.graph.get(node_id) node = session.repository.graph.get(node_id)
if node and node.owner in [session.repository.username, RP_COMMON]: if node and node.owner in [session.repository.username, RP_COMMON]:
@ -78,8 +79,6 @@ def on_scene_update(scene):
logging.debug(f"Ignoring distant update of {dependency_updates[0].id.name}") logging.debug(f"Ignoring distant update of {dependency_updates[0].id.name}")
return return
update_external_dependencies()
# NOTE: maybe we don't need to check each update but only the first # NOTE: maybe we don't need to check each update but only the first
for update in reversed(dependency_updates): for update in reversed(dependency_updates):
update_uuid = getattr(update.id, 'uuid', None) update_uuid = getattr(update.id, 'uuid', None)
@ -103,10 +102,16 @@ def on_scene_update(scene):
else: else:
continue continue
elif isinstance(update.id, bpy.types.Scene): elif isinstance(update.id, bpy.types.Scene):
scn_uuid = porcelain.add(session.repository, update.id) scene = bpy.data.scenes.get(update.id.name)
scn_uuid = porcelain.add(session.repository, scene)
porcelain.commit(session.repository, scn_uuid) porcelain.commit(session.repository, scn_uuid)
porcelain.push(session.repository, 'origin', scn_uuid) porcelain.push(session.repository, 'origin', scn_uuid)
scene_graph_changed = [u for u in reversed(dependency_updates) if getattr(u.id, 'uuid', None) and isinstance(u.id,(bpy.types.Scene,bpy.types.Collection))]
if scene_graph_changed:
porcelain.purge_orphan_nodes(session.repository)
update_external_dependencies()
@persistent @persistent
def resolve_deps_graph(dummy): def resolve_deps_graph(dummy):
@ -125,14 +130,29 @@ def load_pre_handler(dummy):
if session and session.state in [STATE_ACTIVE, STATE_SYNCING]: if session and session.state in [STATE_ACTIVE, STATE_SYNCING]:
bpy.ops.session.stop() bpy.ops.session.stop()
@persistent @persistent
def update_client_frame(scene): def update_client_frame(scene):
setting = bpy.context.window_manager.session
if setting.replay_mode == 'TIMELINE' and \
setting.replay_files and \
scene.active_replay_file != setting.replay_frame_current :
index = bpy.context.scene.active_replay_file
bpy.ops.session.load(filepath=bpy.context.window_manager.session.replay_files[index].name,
draw_users=True,
replay=True)
setting.replay_frame_current = index
if session and session.state == STATE_ACTIVE: if session and session.state == STATE_ACTIVE:
porcelain.update_user_metadata(session.repository, { porcelain.update_user_metadata(session.repository, {
'frame_current': scene.frame_current 'frame_current': scene.frame_current
}) })
@persistent
def post_frame_update(scene):
if bpy.context.window_manager.session.replay_mode == 'TIMELINE' and \
not bpy.context.scene.animation_data:
bpy.context.scene.animation_data_create()
bpy.context.scene.animation_data.action = bpy.data.actions.get('replay_action')
def register(): def register():
bpy.app.handlers.undo_post.append(resolve_deps_graph) bpy.app.handlers.undo_post.append(resolve_deps_graph)

View 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 os
from pathlib import Path
import bpy.utils.previews
def register():
global icons_col
pcoll = bpy.utils.previews.new()
icons_dir = os.path.join(os.path.dirname(__file__), ".")
for png in Path(icons_dir).rglob("*.png"):
pcoll.load(png.stem, str(png), "IMAGE")
icons_col = pcoll
def unregister():
global icons_col
try:
bpy.utils.previews.remove(icons_col)
except Exception:
pass
icons_col = None

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -20,6 +20,7 @@ import asyncio
import copy import copy
import gzip import gzip
import logging import logging
from multi_user.preferences import ServerPreset
import os import os
import queue import queue
import random import random
@ -28,13 +29,16 @@ import string
import sys import sys
import time import time
import traceback import traceback
from uuid import uuid4
from datetime import datetime from datetime import datetime
from operator import itemgetter from operator import itemgetter
from pathlib import Path from pathlib import Path
from queue import Queue from queue import Queue
from time import gmtime, strftime from time import gmtime, strftime
from numpy import interp
from bpy.props import FloatProperty from bpy.props import FloatProperty
import bmesh
try: try:
import _pickle as pickle import _pickle as pickle
@ -56,13 +60,164 @@ from replication.repository import Repository
from . import bl_types, environment, shared_data, timers, ui, utils from . import bl_types, environment, shared_data, timers, ui, utils
from .handlers import on_scene_update, sanitize_deps_graph from .handlers import on_scene_update, sanitize_deps_graph
from .presence import SessionStatusWidget, renderer, view3d_find from .presence import SessionStatusWidget, renderer, view3d_find, refresh_sidebar_view, bbox_from_obj
from .timers import registry from .timers import registry
background_execution_queue = Queue() background_execution_queue = Queue()
deleyables = [] deleyables = []
stop_modal_executor = False stop_modal_executor = False
CLEARED_DATABLOCKS = ['actions', 'armatures', 'cache_files', 'cameras',
'collections', 'curves', 'fonts',
'grease_pencils', 'images', 'lattices', 'libraries',
'lightprobes', 'lights', 'linestyles', 'masks',
'materials', 'meshes', 'metaballs', 'movieclips',
'node_groups', 'objects', 'paint_curves', 'particles',
'scenes', 'shape_keys', 'sounds', 'speakers', 'texts',
'textures', 'volumes', 'worlds']
PERSISTENT_DATABLOCKS = ['LineStyle', 'Dots Stroke', 'replay_action']
def clean_scene(ignored_datablocks: list = None):
"""
Delete all datablock of the scene except PERSISTENT_DATABLOCKS and ignored
ones in ignored_datablocks.
"""
PERSISTENT_DATABLOCKS.extend(ignored_datablocks)
# Avoid to trigger a runtime error by keeping the last scene
PERSISTENT_DATABLOCKS.append(bpy.data.scenes[0].name)
for type_name in CLEARED_DATABLOCKS:
type_collection = getattr(bpy.data, type_name)
for datablock in type_collection:
if datablock.name in PERSISTENT_DATABLOCKS:
logging.debug(f"Skipping {datablock.name}")
continue
else:
logging.debug(f"Removing {datablock.name}")
type_collection.remove(datablock)
# Clear sequencer
bpy.context.scene.sequence_editor_clear()
def draw_user(username, metadata, radius=0.01, intensity=10.0):
"""
Generate a mesh representation of a given user frustum and
sight of view.
"""
view_corners = metadata.get('view_corners')
color = metadata.get('color', (1,1,1,0))
objects = metadata.get('selected_objects', None)
scene = metadata.get('scene_current', bpy.context.scene.name)
user_collection = bpy.data.collections.new(username)
# User Color
user_mat = bpy.data.materials.new(username)
user_mat.use_nodes = True
nodes = user_mat.node_tree.nodes
nodes.remove(nodes['Principled BSDF'])
emission_node = nodes.new('ShaderNodeEmission')
emission_node.inputs['Color'].default_value = color
emission_node.inputs['Strength'].default_value = intensity
output_node = nodes['Material Output']
user_mat.node_tree.links.new(
emission_node.outputs['Emission'], output_node.inputs['Surface'])
# Generate camera mesh
camera_vertices = view_corners[:4]
camera_vertices.append(view_corners[6])
camera_mesh = bpy.data.meshes.new(f"{username}_camera")
camera_obj = bpy.data.objects.new(f"{username}_camera", camera_mesh)
frustum_bm = bmesh.new()
frustum_bm.from_mesh(camera_mesh)
for p in camera_vertices:
frustum_bm.verts.new(p)
frustum_bm.verts.ensure_lookup_table()
frustum_bm.edges.new((frustum_bm.verts[0], frustum_bm.verts[2]))
frustum_bm.edges.new((frustum_bm.verts[2], frustum_bm.verts[1]))
frustum_bm.edges.new((frustum_bm.verts[1], frustum_bm.verts[3]))
frustum_bm.edges.new((frustum_bm.verts[3], frustum_bm.verts[0]))
frustum_bm.edges.new((frustum_bm.verts[0], frustum_bm.verts[4]))
frustum_bm.edges.new((frustum_bm.verts[2], frustum_bm.verts[4]))
frustum_bm.edges.new((frustum_bm.verts[1], frustum_bm.verts[4]))
frustum_bm.edges.new((frustum_bm.verts[3], frustum_bm.verts[4]))
frustum_bm.edges.ensure_lookup_table()
frustum_bm.to_mesh(camera_mesh)
frustum_bm.free() # free and prevent further access
camera_obj.modifiers.new("wireframe", "SKIN")
camera_obj.data.skin_vertices[0].data[0].use_root = True
for v in camera_obj.data.skin_vertices[0].data:
v.radius = [radius, radius]
camera_mesh.materials.append(user_mat)
user_collection.objects.link(camera_obj)
# Generate sight mesh
sight_mesh = bpy.data.meshes.new(f"{username}_sight")
sight_obj = bpy.data.objects.new(f"{username}_sight", sight_mesh)
sight_verts = view_corners[4:6]
sight_bm = bmesh.new()
sight_bm.from_mesh(sight_mesh)
for p in sight_verts:
sight_bm.verts.new(p)
sight_bm.verts.ensure_lookup_table()
sight_bm.edges.new((sight_bm.verts[0], sight_bm.verts[1]))
sight_bm.edges.ensure_lookup_table()
sight_bm.to_mesh(sight_mesh)
sight_bm.free()
sight_obj.modifiers.new("wireframe", "SKIN")
sight_obj.data.skin_vertices[0].data[0].use_root = True
for v in sight_mesh.skin_vertices[0].data:
v.radius = [radius, radius]
sight_mesh.materials.append(user_mat)
user_collection.objects.link(sight_obj)
# Draw selected objects
if objects:
for o in list(objects):
instance = bl_types.bl_datablock.get_datablock_from_uuid(o, None)
if instance:
bbox_mesh = bpy.data.meshes.new(f"{instance.name}_bbox")
bbox_obj = bpy.data.objects.new(
f"{instance.name}_bbox", bbox_mesh)
bbox_verts, bbox_ind = bbox_from_obj(instance, index=0)
bbox_bm = bmesh.new()
bbox_bm.from_mesh(bbox_mesh)
for p in bbox_verts:
bbox_bm.verts.new(p)
bbox_bm.verts.ensure_lookup_table()
for e in bbox_ind:
bbox_bm.edges.new(
(bbox_bm.verts[e[0]], bbox_bm.verts[e[1]]))
bbox_bm.to_mesh(bbox_mesh)
bbox_bm.free()
bpy.data.collections[username].objects.link(bbox_obj)
bbox_obj.modifiers.new("wireframe", "SKIN")
bbox_obj.data.skin_vertices[0].data[0].use_root = True
for v in bbox_mesh.skin_vertices[0].data:
v.radius = [radius, radius]
bbox_mesh.materials.append(user_mat)
bpy.data.scenes[scene].collection.children.link(user_collection)
def session_callback(name): def session_callback(name):
""" Session callback wrapper """ Session callback wrapper
@ -81,7 +236,6 @@ def session_callback(name):
def initialize_session(): def initialize_session():
"""Session connection init hander """Session connection init hander
""" """
settings = utils.get_preferences()
runtime_settings = bpy.context.window_manager.session runtime_settings = bpy.context.window_manager.session
if not runtime_settings.is_host: if not runtime_settings.is_host:
@ -108,7 +262,6 @@ def initialize_session():
for d in deleyables: for d in deleyables:
d.register() d.register()
# Step 5: Clearing history # Step 5: Clearing history
utils.flush_history() utils.flush_history()
@ -142,17 +295,73 @@ def on_connection_end(reason="none"):
if isinstance(handler, logging.FileHandler): if isinstance(handler, logging.FileHandler):
logger.removeHandler(handler) logger.removeHandler(handler)
if reason != "user": if reason != "user":
bpy.ops.session.notify('INVOKE_DEFAULT', message=f"Disconnected from session. Reason: {reason}. ") bpy.ops.session.notify('INVOKE_DEFAULT', message=f"Disconnected from session. Reason: {reason}. ") #TODO: change op session.notify to add ui + change reason (in replication->interface)
def setup_logging():
""" Session setup logging (host/connect)
"""
settings = utils.get_preferences()
logger = logging.getLogger()
if len(logger.handlers) == 1:
formatter = logging.Formatter(
fmt='%(asctime)s CLIENT %(levelname)-8s %(message)s',
datefmt='%H:%M:%S'
)
start_time = datetime.now().strftime('%Y_%m_%d_%H-%M-%S')
log_directory = os.path.join(
settings.cache_directory,
f"multiuser_{start_time}.log")
os.makedirs(settings.cache_directory, exist_ok=True)
handler = logging.FileHandler(log_directory, mode='w')
logger.addHandler(handler)
for handler in logger.handlers:
if isinstance(handler, logging.NullHandler):
continue
handler.setFormatter(formatter)
def setup_timer():
""" Session setup timer (host/connect)
"""
settings = utils.get_preferences()
deleyables.append(timers.ClientUpdate())
deleyables.append(timers.DynamicRightSelectTimer())
deleyables.append(timers.ApplyTimer(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_listen = timers.SessionListenTimer(timeout=0.001)
session_listen.register()
session_update.register()
session_user_sync.register()
session_background_executor.register()
deleyables.append(session_background_executor)
deleyables.append(session_update)
deleyables.append(session_user_sync)
deleyables.append(session_listen)
deleyables.append(timers.AnnotationUpdates())
def get_active_server_preset(context):
active_index = context.window_manager.server_index
server_presets = utils.get_preferences().server_preset
active_index = active_index if active_index <= len(server_presets)-1 else 0
return server_presets[active_index]
# OPERATORS # OPERATORS
class SessionStartOperator(bpy.types.Operator): class SessionConnectOperator(bpy.types.Operator):
bl_idname = "session.start" bl_idname = "session.connect"
bl_label = "start" bl_label = "connect"
bl_description = "connect to a net server" bl_description = "connect to a net server"
host: bpy.props.BoolProperty(default=False)
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
return True return True
@ -161,35 +370,15 @@ class SessionStartOperator(bpy.types.Operator):
global deleyables global deleyables
settings = utils.get_preferences() settings = utils.get_preferences()
runtime_settings = context.window_manager.session
users = bpy.data.window_managers['WinMan'].online_users users = bpy.data.window_managers['WinMan'].online_users
admin_pass = settings.password active_server = get_active_server_preset(context)
admin_pass = active_server.admin_password if active_server.use_admin_password else None
server_pass = active_server.server_password if active_server.use_server_password else ''
users.clear() users.clear()
deleyables.clear() deleyables.clear()
logger = logging.getLogger() setup_logging()
if len(logger.handlers) == 1:
formatter = logging.Formatter(
fmt='%(asctime)s CLIENT %(levelname)-8s %(message)s',
datefmt='%H:%M:%S'
)
start_time = datetime.now().strftime('%Y_%m_%d_%H-%M-%S')
log_directory = os.path.join(
settings.cache_directory,
f"multiuser_{start_time}.log")
os.makedirs(settings.cache_directory, exist_ok=True)
handler = logging.FileHandler(log_directory, mode='w')
logger.addHandler(handler)
for handler in logger.handlers:
if isinstance(handler, logging.NullHandler):
continue
handler.setFormatter(formatter)
bpy_protocol = bl_types.get_data_translation_protocol() bpy_protocol = bl_types.get_data_translation_protocol()
@ -202,7 +391,78 @@ class SessionStartOperator(bpy.types.Operator):
settings.generate_supported_types() settings.generate_supported_types()
if bpy.app.version[1] >= 91: if bpy.app.version >= (2,91,0):
python_binary_path = sys.executable
else:
python_binary_path = bpy.app.binary_path_python
repo = Repository(
rdp=bpy_protocol,
username=settings.username)
# Join a session
if not active_server.use_admin_password:
clean_scene()
try:
porcelain.remote_add(
repo,
'origin',
active_server.ip,
active_server.port,
server_password=server_pass,
admin_password=admin_pass)
session.connect(
repository= repo,
timeout=settings.connection_timeout,
server_password=server_pass,
admin_password=admin_pass
)
except Exception as e:
self.report({'ERROR'}, str(e))
logging.error(str(e))
# Background client updates service
setup_timer()
return {"FINISHED"}
class SessionHostOperator(bpy.types.Operator):
bl_idname = "session.host"
bl_label = "host"
bl_description = "host server"
@classmethod
def poll(cls, context):
return True
def execute(self, context):
global deleyables
settings = utils.get_preferences()
runtime_settings = context.window_manager.session
users = bpy.data.window_managers['WinMan'].online_users
admin_pass = settings.host_admin_password if settings.host_use_admin_password else None
server_pass = settings.host_server_password if settings.host_use_server_password else ''
users.clear()
deleyables.clear()
setup_logging()
bpy_protocol = bl_types.get_data_translation_protocol()
# Check if supported_datablocks are up to date before starting the
# the session
for dcc_type_id in bpy_protocol.implementations.keys():
if dcc_type_id not in settings.supported_datablocks:
logging.info(f"{dcc_type_id} not found, \
regenerate type settings...")
settings.generate_supported_types()
if bpy.app.version >= (2,91,0):
python_binary_path = sys.executable python_binary_path = sys.executable
else: else:
python_binary_path = bpy.app.binary_path_python python_binary_path = bpy.app.binary_path_python
@ -212,80 +472,38 @@ class SessionStartOperator(bpy.types.Operator):
username=settings.username) username=settings.username)
# Host a session # Host a session
if self.host: if settings.init_method == 'EMPTY':
if settings.init_method == 'EMPTY': clean_scene()
utils.clean_scene()
runtime_settings.is_host = True try:
runtime_settings.internet_ip = environment.get_ip() # Init repository
for scene in bpy.data.scenes:
porcelain.add(repo, scene)
try: porcelain.remote_add(
# Init repository repo,
for scene in bpy.data.scenes: 'origin',
porcelain.add(repo, scene) '127.0.0.1',
settings.host_port,
porcelain.remote_add( server_password=server_pass,
repo, admin_password=admin_pass)
'origin', session.host(
'127.0.0.1', repository= repo,
settings.port, remote='origin',
admin_password=admin_pass) timeout=settings.connection_timeout,
session.host( server_password=server_pass,
repository= repo, admin_password=admin_pass,
remote='origin', cache_directory=settings.cache_directory,
timeout=settings.connection_timeout, server_log_level=logging.getLevelName(
password=admin_pass, logging.getLogger().level),
cache_directory=settings.cache_directory, )
server_log_level=logging.getLevelName( except Exception as e:
logging.getLogger().level), self.report({'ERROR'}, repr(e))
) logging.error(f"Error: {e}")
except Exception as e: traceback.print_exc()
self.report({'ERROR'}, repr(e))
logging.error(f"Error: {e}")
traceback.print_exc()
# Join a session
else:
if not runtime_settings.admin:
utils.clean_scene()
# regular session, no password needed
admin_pass = None
try:
porcelain.remote_add(
repo,
'origin',
settings.ip,
settings.port,
admin_password=admin_pass)
session.connect(
repository= repo,
timeout=settings.connection_timeout,
password=admin_pass
)
except Exception as e:
self.report({'ERROR'}, str(e))
logging.error(str(e))
# Background client updates service # Background client updates service
deleyables.append(timers.ClientUpdate()) setup_timer()
deleyables.append(timers.DynamicRightSelectTimer())
deleyables.append(timers.ApplyTimer(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_listen = timers.SessionListenTimer(timeout=0.001)
session_listen.register()
session_update.register()
session_user_sync.register()
session_background_executor.register()
deleyables.append(session_background_executor)
deleyables.append(session_update)
deleyables.append(session_user_sync)
deleyables.append(session_listen)
return {"FINISHED"} return {"FINISHED"}
@ -319,7 +537,7 @@ class SessionInitOperator(bpy.types.Operator):
def execute(self, context): def execute(self, context):
if self.init_method == 'EMPTY': if self.init_method == 'EMPTY':
utils.clean_scene() clean_scene()
for scene in bpy.data.scenes: for scene in bpy.data.scenes:
porcelain.add(session.repository, scene) porcelain.add(session.repository, scene)
@ -689,7 +907,6 @@ class SessionPurgeOperator(bpy.types.Operator):
def execute(self, context): def execute(self, context):
try: try:
sanitize_deps_graph(remove_nodes=True)
porcelain.purge_orphan_nodes(session.repository) porcelain.purge_orphan_nodes(session.repository)
except Exception as e: except Exception as e:
self.report({'ERROR'}, repr(e)) self.report({'ERROR'}, repr(e))
@ -784,7 +1001,6 @@ class SessionStopAutoSaveOperator(bpy.types.Operator):
return {'FINISHED'} return {'FINISHED'}
class SessionLoadSaveOperator(bpy.types.Operator, ImportHelper): class SessionLoadSaveOperator(bpy.types.Operator, ImportHelper):
bl_idname = "session.load" bl_idname = "session.load"
bl_label = "Load session save" bl_label = "Load session save"
@ -800,14 +1016,89 @@ class SessionLoadSaveOperator(bpy.types.Operator, ImportHelper):
maxlen=255, # Max internal buffer length, longer would be clamped. maxlen=255, # Max internal buffer length, longer would be clamped.
) )
draw_users: bpy.props.BoolProperty(
name="Load users",
description="Draw users in the scene",
default=False,
)
replay: bpy.props.BoolProperty(
name="Replay mode",
description="Enable replay functions",
default=False,
)
user_skin_radius: bpy.props.FloatProperty(
name="Wireframe radius",
description="Wireframe radius",
default=0.01,
)
user_color_intensity: bpy.props.FloatProperty(
name="Shading intensity",
description="Shading intensity",
default=1.0,
)
files: bpy.props.CollectionProperty(
name='File paths',
type=bpy.types.OperatorFileListElement
)
def draw(self, context):
pass
def execute(self, context): def execute(self, context):
from replication.repository import Repository from replication.repository import Repository
runtime_settings = context.window_manager.session
# init the factory with supported types # init the factory with supported types
bpy_protocol = bl_types.get_data_translation_protocol() bpy_protocol = bl_types.get_data_translation_protocol()
repo = Repository(bpy_protocol) repo = Repository(bpy_protocol)
repo.loads(self.filepath)
utils.clean_scene() try:
repo.loads(self.filepath)
except TypeError:
# Load legacy snapshots
db = pickle.load(gzip.open(self.filepath, "rb"))
nodes = db.get("nodes")
logging.info(f"Loading legacy {len(nodes)} node")
repo.object_store.clear()
for node, node_data in nodes:
instance = Node(
uuid=node,
data=node_data.get('data'),
owner=node_data.get('owner'),
dependencies=node_data.get('dependencies'),
state=FETCHED)
# Patch data for compatibility
type_id = node_data.get('str_type')[2:]
if type_id == "File":
type_id = "WindowsPath"
instance.data['type_id'] = type_id
repo.do_commit(instance)
instance.state = FETCHED
# Persitstent collection
ignored_datablocks = []
persistent_collection = bpy.data.collections.get("multiuser_timelapse")
if self.replay and \
runtime_settings.replay_persistent_collection and \
persistent_collection:
collection_repo = Repository(
rdp=bpy_protocol,
username="None")
porcelain.add(collection_repo, persistent_collection)
porcelain.commit(collection_repo, persistent_collection.uuid)
for node in collection_repo.graph.values():
ignored_datablocks.append(node.data.get('name'))
clean_scene(ignored_datablocks=ignored_datablocks)
nodes = [repo.graph.get(n) for n in repo.index_sorted] nodes = [repo.graph.get(n) for n in repo.index_sorted]
@ -822,6 +1113,69 @@ class SessionLoadSaveOperator(bpy.types.Operator, ImportHelper):
for node in nodes: for node in nodes:
porcelain.apply(repo, node.uuid) porcelain.apply(repo, node.uuid)
if len(self.files) > 1:
runtime_settings.replay_files.clear()
context.scene.active_replay_file = len(self.files)-1
directory = Path(self.filepath).parent
file_list = [f['name'] for f in self.files]
file_list.sort()
for f in file_list:
snap = runtime_settings.replay_files.add()
snap.name = str(Path(directory, f))
print(f)
if runtime_settings.replay_mode == 'TIMELINE':
replay_action = bpy.data.actions.get('replay_action', bpy.data.actions.new('replay_action'))
bpy.context.scene.animation_data_create()
bpy.context.scene.animation_data.action = replay_action
if len(replay_action.fcurves) > 0 and replay_action.fcurves[0].data_path == 'active_replay_file':
replay_fcurve = replay_action.fcurves[0]
else:
replay_fcurve = replay_action.fcurves.new('active_replay_file')
for p in reversed(replay_fcurve.keyframe_points):
replay_fcurve.keyframe_points.remove(p, fast=True)
duration = runtime_settings.replay_duration
file_count = len(self.files)-1
for index in range(0, file_count):
frame = interp(index, [0, file_count], [bpy.context.scene.frame_start, duration])
replay_fcurve.keyframe_points.insert(frame, index)
if self.draw_users:
f = gzip.open(self.filepath, "rb")
db = pickle.load(f)
users = db.get("users")
for username, user_data in users.items():
metadata = user_data['metadata']
if metadata:
draw_user(username, metadata, radius=self.user_skin_radius, intensity=self.user_color_intensity)
# Relink the persistent collection
if self.replay and persistent_collection:
logging.info(f"Relinking {persistent_collection.name}")
bpy.context.scene.collection.children.link(persistent_collection)
# Reasign scene action
if self.replay and \
runtime_settings.replay_mode == 'TIMELINE' and \
not bpy.context.scene.animation_data :
bpy.context.scene.animation_data_create()
bpy.context.scene.animation_data.action = bpy.data.actions.get('replay_action')
bpy.context.scene.frame_end = runtime_settings.replay_duration
# Reasign the scene camera
if self.replay and \
runtime_settings.replay_persistent_collection and \
runtime_settings.replay_camera:
bpy.context.scene.camera = runtime_settings.replay_camera
return {'FINISHED'} return {'FINISHED'}
@ -829,14 +1183,125 @@ class SessionLoadSaveOperator(bpy.types.Operator, ImportHelper):
def poll(cls, context): def poll(cls, context):
return True return True
class SESSION_PT_ImportUser(bpy.types.Panel):
bl_space_type = 'FILE_BROWSER'
bl_region_type = 'TOOL_PROPS'
bl_label = "Users"
bl_parent_id = "FILE_PT_operator"
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
sfile = context.space_data
operator = sfile.active_operator
return operator.bl_idname == "SESSION_OT_load"
def draw_header(self, context):
sfile = context.space_data
operator = sfile.active_operator
self.layout.prop(operator, "draw_users", text="")
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False # No animation.
sfile = context.space_data
operator = sfile.active_operator
layout.enabled = operator.draw_users
layout.prop(operator, "user_skin_radius")
layout.prop(operator, "user_color_intensity")
class SessionPresetServerAdd(bpy.types.Operator): class SessionPresetServerAdd(bpy.types.Operator):
"""Add a server to the server list preset""" """Add a server to the server list preset"""
bl_idname = "session.preset_server_add" bl_idname = "session.preset_server_add"
bl_label = "add server preset" bl_label = "Add server preset"
bl_description = "add the current server to the server preset list" bl_description = "add a server to the server preset list"
bl_options = {"REGISTER"} bl_options = {"REGISTER"}
name : bpy.props.StringProperty(default="server_preset") server_name: bpy.props.StringProperty(default="")
ip: bpy.props.StringProperty(default="127.0.0.1")
port: bpy.props.IntProperty(default=5555)
use_server_password: bpy.props.BoolProperty(default=False)
server_password: bpy.props.StringProperty(default="", subtype = "PASSWORD")
use_admin_password: bpy.props.BoolProperty(default=False)
admin_password: bpy.props.StringProperty(default="", subtype = "PASSWORD")
@classmethod
def poll(cls, context):
return True
def invoke(self, context, event):
self.server_name = ""
self.ip = "127.0.0.1"
self.port = 5555
self.use_server_password = False
self.server_password = ""
self.use_admin_password = False
self.admin_password = ""
assert(context)
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
row = layout.row()
row.prop(self, "server_name", text="Server name")
row = layout.row(align = True)
row.prop(self, "ip", text="IP+port")
row.prop(self, "port", text="")
row = layout.row()
col = row.column()
col.prop(self, "use_server_password", text="Server password:")
col = row.column()
col.enabled = True if self.use_server_password else False
col.prop(self, "server_password", text="")
row = layout.row()
col = row.column()
col.prop(self, "use_admin_password", text="Admin password:")
col = row.column()
col.enabled = True if self.use_admin_password else False
col.prop(self, "admin_password", text="")
def execute(self, context):
assert(context)
settings = utils.get_preferences()
existing_preset = settings.get_server_preset(self.server_name)
new_server = existing_preset if existing_preset else settings.server_preset.add()
new_server.name = str(uuid4())
new_server.server_name = self.server_name
new_server.ip = self.ip
new_server.port = self.port
new_server.use_server_password = self.use_server_password
new_server.server_password = self.server_password
new_server.use_admin_password = self.use_admin_password
new_server.admin_password = self.admin_password
refresh_sidebar_view()
if new_server == existing_preset :
self.report({'INFO'}, "Server '" + self.server_name + "' edited")
else :
self.report({'INFO'}, "New '" + self.server_name + "' server preset")
return {'FINISHED'}
class SessionPresetServerEdit(bpy.types.Operator): # TODO : use preset, not settings
"""Edit a server to the server list preset"""
bl_idname = "session.preset_server_edit"
bl_label = "Edit server preset"
bl_description = "Edit a server from the server preset list"
bl_options = {"REGISTER"}
target_server_name: bpy.props.StringProperty(default="None")
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
@ -848,35 +1313,39 @@ class SessionPresetServerAdd(bpy.types.Operator):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
col = layout.column()
settings = utils.get_preferences() settings = utils.get_preferences()
settings_active_server = settings.server_preset.get(self.target_server_name)
col.prop(settings, "server_name", text="server name") row = layout.row()
row.prop(settings_active_server, "server_name", text="Server name")
row = layout.row(align = True)
row.prop(settings_active_server, "ip", text="IP+port")
row.prop(settings_active_server, "port", text="")
row = layout.row()
col = row.column()
col.prop(settings_active_server, "use_server_password", text="Server password:")
col = row.column()
col.enabled = True if settings_active_server.use_server_password else False
col.prop(settings_active_server, "server_password", text="")
row = layout.row()
col = row.column()
col.prop(settings_active_server, "use_admin_password", text="Admin password:")
col = row.column()
col.enabled = True if settings_active_server.use_admin_password else False
col.prop(settings_active_server, "admin_password", text="")
def execute(self, context): def execute(self, context):
assert(context) assert(context)
settings = utils.get_preferences() settings = utils.get_preferences()
settings_active_server = settings.server_preset.get(self.target_server_name)
existing_preset = settings.server_preset.get(settings.server_name) refresh_sidebar_view()
new_server = existing_preset if existing_preset else settings.server_preset.add() self.report({'INFO'}, "Server '" + settings_active_server.server_name + "' edited")
new_server.name = settings.server_name
new_server.server_ip = settings.ip
new_server.server_port = settings.port
new_server.server_password = settings.password
settings.server_preset_interface = settings.server_name
if new_server == existing_preset :
self.report({'INFO'}, "Server '" + settings.server_name + "' override")
else :
self.report({'INFO'}, "New '" + settings.server_name + "' server preset")
return {'FINISHED'} return {'FINISHED'}
class SessionPresetServerRemove(bpy.types.Operator): class SessionPresetServerRemove(bpy.types.Operator):
"""Remove a server to the server list preset""" """Remove a server to the server list preset"""
bl_idname = "session.preset_server_remove" bl_idname = "session.preset_server_remove"
@ -884,6 +1353,8 @@ class SessionPresetServerRemove(bpy.types.Operator):
bl_description = "remove the current server from the server preset list" bl_description = "remove the current server from the server preset list"
bl_options = {"REGISTER"} bl_options = {"REGISTER"}
target_server_name: bpy.props.StringProperty(default="None")
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
return True return True
@ -892,19 +1363,97 @@ class SessionPresetServerRemove(bpy.types.Operator):
assert(context) assert(context)
settings = utils.get_preferences() settings = utils.get_preferences()
settings.server_preset.remove(settings.server_preset.find(self.target_server_name))
settings.server_preset.remove(settings.server_preset.find(settings.server_preset_interface))
return {'FINISHED'} return {'FINISHED'}
class RefreshServerStatus(bpy.types.Operator):
bl_idname = "session.get_info"
bl_label = "Get session info"
bl_description = "Get session info"
target_server: bpy.props.StringProperty(default="127.0.0.1:5555")
@classmethod
def poll(cls, context):
return (session.state != STATE_ACTIVE)
def execute(self, context):
settings = utils.get_preferences()
for server in settings.server_preset:
infos = porcelain.request_session_info(f"{server.ip}:{server.port}", timeout=settings.ping_timeout)
server.is_online = True if infos else False
if server.is_online:
server.is_private = infos.get("private")
return {'FINISHED'}
class GetDoc(bpy.types.Operator):
"""Get the documentation of the addon"""
bl_idname = "doc.get"
bl_label = "Multi-user's doc"
bl_description = "Go to the doc of the addon"
@classmethod
def poll(cls, context):
return True
def execute(self, context):
assert(context)
bpy.ops.wm.url_open(url="https://slumber.gitlab.io/multi-user/index.html")
return {'FINISHED'}
class FirstLaunch(bpy.types.Operator):
"""First time lauching the addon"""
bl_idname = "firstlaunch.verify"
bl_label = "First launch"
bl_description = "First time lauching the addon"
@classmethod
def poll(cls, context):
return True
def execute(self, context):
assert(context)
settings = utils.get_preferences()
settings.is_first_launch = False
settings.server_preset.clear()
prefs = bpy.context.preferences.addons[__package__].preferences
prefs.generate_default_presets()
return {'FINISHED'}
def menu_func_import(self, context): def menu_func_import(self, context):
self.layout.operator(SessionLoadSaveOperator.bl_idname, text='Multi-user session snapshot (.db)') self.layout.operator(SessionLoadSaveOperator.bl_idname, text='Multi-user session snapshot (.db)')
def menu_func_export(self, context):
self.layout.operator(SessionSaveBackupOperator.bl_idname, text='Multi-user session snapshot (.db)')
class SessionRenderReplay(bpy.types.Operator):
bl_idname = "session.render_replay"
bl_label = "Render Replay"
bl_description = "Render Replay"
@classmethod
def poll(cls, context):
return context.window_manager.session.replay_files
def execute(self, context):
base_path = str(context.scene.render.filepath)
for frame in range(0,context.scene.frame_end):
logging.info(f"Rendering frame {frame} to {base_path}_{frame}.png")
context.scene.frame_current = frame
filename = Path(bpy.context.window_manager.session.replay_files[context.scene.active_replay_file].name)
context.scene.render.filepath = f"{base_path}{frame}_{filename.stem}"
bpy.ops.render.render(write_still=True)
context.scene.render.filepath = base_path
return {'FINISHED'}
classes = ( classes = (
SessionStartOperator, SessionConnectOperator,
SessionHostOperator,
SessionStopOperator, SessionStopOperator,
SessionPropertyRemoveOperator, SessionPropertyRemoveOperator,
SessionSnapUserOperator, SessionSnapUserOperator,
@ -918,10 +1467,16 @@ classes = (
SessionNotifyOperator, SessionNotifyOperator,
SessionSaveBackupOperator, SessionSaveBackupOperator,
SessionLoadSaveOperator, SessionLoadSaveOperator,
SESSION_PT_ImportUser,
SessionStopAutoSaveOperator, SessionStopAutoSaveOperator,
SessionPurgeOperator, SessionPurgeOperator,
SessionPresetServerAdd, SessionPresetServerAdd,
SessionPresetServerEdit,
SessionPresetServerRemove, SessionPresetServerRemove,
RefreshServerStatus,
GetDoc,
FirstLaunch,
SessionRenderReplay,
) )

View File

@ -17,15 +17,17 @@
import random import random
import logging import logging
from uuid import uuid4
import bpy import bpy
import string import string
import re import re
import os import os
from numpy import interp
from pathlib import Path from pathlib import Path
from . import bl_types, environment, addon_updater_ops, presence, ui from . import bl_types, environment, addon_updater_ops, presence, ui
from .utils import get_preferences, get_expanded_icon from .utils import get_preferences, get_expanded_icon, get_folder_size
from replication.constants import RP_COMMON from replication.constants import RP_COMMON
from replication.interface import session from replication.interface import session
@ -33,15 +35,21 @@ from replication.interface import session
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])$") 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])$") 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])$")
#SERVER PRESETS AT LAUNCH
DEFAULT_PRESETS = { DEFAULT_PRESETS = {
"localhost" : { "localhost" : {
"server_ip": "localhost", "server_name": "localhost",
"server_port": 5555, "ip": "localhost",
"server_password": "admin" "port": 5555,
"use_admin_password": True,
"admin_password": "admin",
"server_password": ""
}, },
"public session" : { "public session" : {
"server_ip": "51.75.71.183", "server_name": "public session",
"server_port": 5555, "ip": "51.75.71.183",
"port": 5555,
"admin_password": "",
"server_password": "" "server_password": ""
}, },
} }
@ -78,11 +86,6 @@ def update_ip(self, context):
logging.error("Wrong IP format") logging.error("Wrong IP format")
self['ip'] = "127.0.0.1" self['ip'] = "127.0.0.1"
def update_server_preset_interface(self, context):
self.server_name = self.server_preset.get(self.server_preset_interface).name
self.ip = self.server_preset.get(self.server_preset_interface).server_ip
self.port = self.server_preset.get(self.server_preset_interface).server_port
self.password = self.server_preset.get(self.server_preset_interface).server_password
def update_directory(self, context): def update_directory(self, context):
new_dir = Path(self.cache_directory) new_dir = Path(self.cache_directory)
@ -97,6 +100,77 @@ def update_directory(self, context):
def set_log_level(self, value): def set_log_level(self, value):
logging.getLogger().setLevel(value) logging.getLogger().setLevel(value)
def set_active_replay(self, value):
files_count = len(bpy.context.window_manager.session.replay_files)
if files_count == 0:
return
max_index = files_count-1
if value > max_index:
value = max_index
if hasattr(self, 'active_replay_file'):
self["active_replay_file"] = value
else:
self.active_replay_file = value
if bpy.context.window_manager.session.replay_mode == 'MANUAL':
bpy.ops.session.load(
filepath=bpy.context.window_manager.session.replay_files[value].name,
draw_users=True,
replay=True)
def get_active_replay(self):
return self.get('active_replay_file', 0)
def set_replay_persistent_collection(self, value):
if hasattr(self, 'replay_persistent_collection'):
self["replay_persistent_collection"] = value
else:
self.replay_persistent_collection = value
collection = bpy.data.collections.get("multiuser_timelapse", None)
if collection is None and value:
collection = bpy.data.collections.new('multiuser_timelapse')
bpy.context.scene.collection.children.link(collection)
elif collection and not value:
for o in collection.objects:
bpy.data.objects.remove(o)
bpy.data.collections.remove(collection)
def get_replay_persistent_collection(self):
return self.get('replay_persistent_collection', False)
def set_replay_duration(self, value):
if hasattr(self, 'replay_duration'):
self["replay_duration"] = value
else:
self.replay_duration = value
# Update the animation fcurve
replay_action = bpy.data.actions.get('replay_action')
replay_fcurve = None
for fcurve in replay_action.fcurves:
if fcurve.data_path == 'active_replay_file':
replay_fcurve = fcurve
if replay_fcurve:
for p in reversed(replay_fcurve.keyframe_points):
replay_fcurve.keyframe_points.remove(p, fast=True)
bpy.context.scene.frame_end = value
files_count = len(bpy.context.window_manager.session.replay_files)-1
for index in range(0, files_count):
frame = interp(index,[0, files_count],[bpy.context.scene.frame_start, value])
replay_fcurve.keyframe_points.insert(frame, index)
def get_replay_duration(self):
return self.get('replay_duration', 10)
def get_log_level(self): def get_log_level(self):
return logging.getLogger().level return logging.getLogger().level
@ -110,9 +184,15 @@ class ReplicatedDatablock(bpy.types.PropertyGroup):
icon: bpy.props.StringProperty() icon: bpy.props.StringProperty()
class ServerPreset(bpy.types.PropertyGroup): class ServerPreset(bpy.types.PropertyGroup):
server_ip: bpy.props.StringProperty() server_name: bpy.props.StringProperty(default="")
server_port: bpy.props.IntProperty(default=5555) ip: bpy.props.StringProperty(default="127.0.0.1", update=update_ip)
server_password: bpy.props.StringProperty(default="admin", subtype = "PASSWORD") port: bpy.props.IntProperty(default=5555)
use_server_password: bpy.props.BoolProperty(default=False)
server_password: bpy.props.StringProperty(default="", subtype = "PASSWORD")
use_admin_password: bpy.props.BoolProperty(default=False)
admin_password: bpy.props.StringProperty(default="", subtype = "PASSWORD")
is_online: bpy.props.BoolProperty(default=False)
is_private: bpy.props.BoolProperty(default=False)
def set_sync_render_settings(self, value): def set_sync_render_settings(self, value):
self['sync_render_settings'] = value self['sync_render_settings'] = value
@ -162,35 +242,60 @@ class ReplicationFlags(bpy.types.PropertyGroup):
class SessionPrefs(bpy.types.AddonPreferences): class SessionPrefs(bpy.types.AddonPreferences):
bl_idname = __package__ bl_idname = __package__
ip: bpy.props.StringProperty( # User settings
name="ip",
description='Distant host ip',
default="localhost",
update=update_ip)
username: bpy.props.StringProperty( username: bpy.props.StringProperty(
name="Username", name="Username",
default=f"user_{random_string_digits()}" default=f"user_{random_string_digits()}"
) )
client_color: bpy.props.FloatVectorProperty( client_color: bpy.props.FloatVectorProperty(
name="client_instance_color", name="client_instance_color",
description='User color',
subtype='COLOR', subtype='COLOR',
default=randomColor()) default=randomColor()
port: bpy.props.IntProperty(
name="port",
description='Distant host port',
default=5555
) )
# Current server settings
server_name: bpy.props.StringProperty( server_name: bpy.props.StringProperty(
name="server_name", name="server_name",
description="Custom name of the server", description="Custom name of the server",
default='localhost', default='localhost',
) )
password: bpy.props.StringProperty( server_index: bpy.props.IntProperty(
name="password", name="server_index",
default=random_string_digits(), description="index of the server",
)
# User host session settings
host_port: bpy.props.IntProperty(
name="host_port",
description='Distant host port',
default=5555
)
host_use_server_password: bpy.props.BoolProperty(
name="use_server_password",
description='Use session password',
default=False
)
host_server_password: bpy.props.StringProperty(
name="server_password",
description='Session password', description='Session password',
subtype='PASSWORD' subtype='PASSWORD'
) )
host_use_admin_password: bpy.props.BoolProperty(
name="use_admin_password",
description='Use admin password',
default=True
)
host_admin_password: bpy.props.StringProperty(
name="admin_password",
description='Admin password',
subtype='PASSWORD',
default='admin'
)
# Other
is_first_launch: bpy.props.BoolProperty(
name="is_fnirst_launch",
description="First time lauching the addon",
default=True
)
sync_flags: bpy.props.PointerProperty( sync_flags: bpy.props.PointerProperty(
type=ReplicationFlags type=ReplicationFlags
) )
@ -214,6 +319,11 @@ class SessionPrefs(bpy.types.AddonPreferences):
description='connection timeout before disconnection', description='connection timeout before disconnection',
default=5000 default=5000
) )
ping_timeout: bpy.props.IntProperty(
name='ping timeout',
description='check if servers are online',
default=500
)
# Replication update settings # Replication update settings
depsgraph_update_rate: bpy.props.FloatProperty( depsgraph_update_rate: bpy.props.FloatProperty(
name='depsgraph update rate (s)', name='depsgraph update rate (s)',
@ -225,11 +335,12 @@ class SessionPrefs(bpy.types.AddonPreferences):
description="Remove filecache from memory", description="Remove filecache from memory",
default=False default=False
) )
# for UI # For UI
category: bpy.props.EnumProperty( category: bpy.props.EnumProperty(
name="Category", name="Category",
description="Preferences Category", description="Preferences Category",
items=[ items=[
('PREF', "Preferences", "Preferences of this add-on"),
('CONFIG', "Configuration", "Configuration of this add-on"), ('CONFIG', "Configuration", "Configuration of this add-on"),
('UPDATE', "Update", "Update this add-on"), ('UPDATE', "Update", "Update this add-on"),
], ],
@ -273,38 +384,58 @@ class SessionPrefs(bpy.types.AddonPreferences):
step=1, step=1,
subtype='PERCENTAGE', subtype='PERCENTAGE',
) )
presence_mode_distance: bpy.props.FloatProperty( presence_text_distance: bpy.props.FloatProperty(
name="Distance mode visibilty", name="Distance text visibilty",
description="Adjust the distance visibilty of user's mode", description="Adjust the distance visibilty of user's mode/name",
min=0.1, min=0.1,
max=1000, max=10000,
default=100, default=100,
) )
conf_session_identity_expanded: bpy.props.BoolProperty( conf_session_identity_expanded: bpy.props.BoolProperty(
name="Identity", name="Identity",
description="Identity", description="Identity",
default=True default=False
) )
conf_session_net_expanded: bpy.props.BoolProperty( conf_session_net_expanded: bpy.props.BoolProperty(
name="Net", name="Net",
description="net", description="net",
default=True default=False
) )
conf_session_hosting_expanded: bpy.props.BoolProperty( conf_session_hosting_expanded: bpy.props.BoolProperty(
name="Rights", name="Rights",
description="Rights", description="Rights",
default=False default=False
) )
conf_session_rep_expanded: bpy.props.BoolProperty(
name="Replication",
description="Replication",
default=False
)
conf_session_cache_expanded: bpy.props.BoolProperty( conf_session_cache_expanded: bpy.props.BoolProperty(
name="Cache", name="Cache",
description="cache", description="cache",
default=False default=False
) )
conf_session_log_expanded: bpy.props.BoolProperty(
name="conf_session_log_expanded",
description="conf_session_log_expanded",
default=False
)
conf_session_ui_expanded: bpy.props.BoolProperty( conf_session_ui_expanded: bpy.props.BoolProperty(
name="Interface", name="Interface",
description="Interface", description="Interface",
default=False default=False
) )
sidebar_repository_shown: bpy.props.BoolProperty(
name="sidebar_repository_shown",
description="sidebar_repository_shown",
default=False
)
sidebar_advanced_shown: bpy.props.BoolProperty(
name="sidebar_advanced_shown",
description="sidebar_advanced_shown",
default=False
)
sidebar_advanced_rep_expanded: bpy.props.BoolProperty( sidebar_advanced_rep_expanded: bpy.props.BoolProperty(
name="sidebar_advanced_rep_expanded", name="sidebar_advanced_rep_expanded",
description="sidebar_advanced_rep_expanded", description="sidebar_advanced_rep_expanded",
@ -315,6 +446,11 @@ class SessionPrefs(bpy.types.AddonPreferences):
description="sidebar_advanced_log_expanded", description="sidebar_advanced_log_expanded",
default=False default=False
) )
sidebar_advanced_uinfo_expanded: bpy.props.BoolProperty(
name="sidebar_advanced_uinfo_expanded",
description="sidebar_advanced_uinfo_expanded",
default=False
)
sidebar_advanced_net_expanded: bpy.props.BoolProperty( sidebar_advanced_net_expanded: bpy.props.BoolProperty(
name="sidebar_advanced_net_expanded", name="sidebar_advanced_net_expanded",
description="sidebar_advanced_net_expanded", description="sidebar_advanced_net_expanded",
@ -371,12 +507,6 @@ class SessionPrefs(bpy.types.AddonPreferences):
name="server preset", name="server preset",
type=ServerPreset, type=ServerPreset,
) )
server_preset_interface: bpy.props.EnumProperty(
name="servers",
description="servers enum",
items=server_list_callback,
update=update_server_preset_interface,
)
# Custom panel # Custom panel
panel_category: bpy.props.StringProperty( panel_category: bpy.props.StringProperty(
@ -386,38 +516,28 @@ class SessionPrefs(bpy.types.AddonPreferences):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
layout.row().prop(self, "category", expand=True) layout.row().prop(self, "category", expand=True)
if self.category == 'PREF':
grid = layout.column()
box = grid.box()
row = box.row()
# USER SETTINGS
split = row.split(factor=0.7, align=True)
split.prop(self, "username", text="User")
split.prop(self, "client_color", text="")
row = box.row()
row.label(text="Hide settings:")
row = box.row()
row.prop(self, "sidebar_advanced_shown", text="Hide “Advanced” settings in side pannel (Not in session)")
row = box.row()
row.prop(self, "sidebar_repository_shown", text="Hide “Repository” settings in side pannel (In session)")
if self.category == 'CONFIG': if self.category == 'CONFIG':
grid = layout.column() grid = layout.column()
# USER INFORMATIONS
box = grid.box()
box.prop(
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:
box.row().prop(self, "username", text="name")
box.row().prop(self, "client_color", text="color")
# NETWORK SETTINGS
box = grid.box()
box.prop(
self, "conf_session_net_expanded", text="Networking",
icon=get_expanded_icon(self.conf_session_net_expanded),
emboss=False)
if self.conf_session_net_expanded:
box.row().prop(self, "ip", text="Address")
row = box.row()
row.label(text="Port:")
row.prop(self, "port", text="")
row = box.row()
row.label(text="Init the session from:")
row.prop(self, "init_method", text="")
# HOST SETTINGS # HOST SETTINGS
box = grid.box() box = grid.box()
box.prop( box.prop(
@ -425,9 +545,57 @@ class SessionPrefs(bpy.types.AddonPreferences):
icon=get_expanded_icon(self.conf_session_hosting_expanded), icon=get_expanded_icon(self.conf_session_hosting_expanded),
emboss=False) emboss=False)
if self.conf_session_hosting_expanded: if self.conf_session_hosting_expanded:
row = box.row()
row.prop(self, "host_port", text="Port: ")
row = box.row() row = box.row()
row.label(text="Init the session from:") row.label(text="Init the session from:")
row.prop(self, "init_method", text="") row.prop(self, "init_method", text="")
row = box.row()
col = row.column()
col.prop(self, "host_use_server_password", text="Server password:")
col = row.column()
col.enabled = True if self.host_use_server_password else False
col.prop(self, "host_server_password", text="")
row = box.row()
col = row.column()
col.prop(self, "host_use_admin_password", text="Admin password:")
col = row.column()
col.enabled = True if self.host_use_admin_password else False
col.prop(self, "host_admin_password", text="")
# NETWORKING
box = grid.box()
box.prop(
self, "conf_session_net_expanded", text="Network",
icon=get_expanded_icon(self.conf_session_net_expanded),
emboss=False)
if self.conf_session_net_expanded:
row = box.row()
row.label(text="Timeout (ms):")
row.prop(self, "connection_timeout", text="")
row = box.row()
row.label(text="Server ping (ms):")
row.prop(self, "ping_timeout", text="")
# REPLICATION
box = grid.box()
box.prop(
self, "conf_session_rep_expanded", text="Replication",
icon=get_expanded_icon(self.conf_session_rep_expanded),
emboss=False)
if self.conf_session_rep_expanded:
row = box.row()
row.prop(self.sync_flags, "sync_render_settings")
row = box.row()
row.prop(self.sync_flags, "sync_active_camera")
row = box.row()
row.prop(self.sync_flags, "sync_during_editmode")
row = box.row()
if self.sync_flags.sync_during_editmode:
warning = row.box()
warning.label(text="Don't use this with heavy meshes !", icon='ERROR')
row = box.row()
row.prop(self, "depsgraph_update_rate", text="Apply delay")
# CACHE SETTINGS # CACHE SETTINGS
box = grid.box() box = grid.box()
@ -438,25 +606,18 @@ class SessionPrefs(bpy.types.AddonPreferences):
if self.conf_session_cache_expanded: if self.conf_session_cache_expanded:
box.row().prop(self, "cache_directory", text="Cache directory") box.row().prop(self, "cache_directory", text="Cache directory")
box.row().prop(self, "clear_memory_filecache", text="Clear memory filecache") box.row().prop(self, "clear_memory_filecache", text="Clear memory filecache")
box.row().operator('session.clear_cache', text=f"Clear cache ({get_folder_size(self.cache_directory)})")
# INTERFACE SETTINGS # LOGGING
box = grid.box() box = grid.box()
box.prop( box.prop(
self, "conf_session_ui_expanded", text="Interface", self, "conf_session_log_expanded", text="Logging",
icon=get_expanded_icon(self.conf_session_ui_expanded), icon=get_expanded_icon(self.conf_session_log_expanded),
emboss=False) emboss=False)
if self.conf_session_ui_expanded: if self.conf_session_log_expanded:
box.row().prop(self, "panel_category", text="Panel category", expand=True)
row = box.row() row = box.row()
row.label(text="Session widget:") row.label(text="Log level:")
row.prop(self, 'logging_level', text="")
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)
col.prop(self, "presence_mode_distance", expand=True)
if self.category == 'UPDATE': if self.category == 'UPDATE':
from . import addon_updater_ops from . import addon_updater_ops
@ -477,18 +638,31 @@ class SessionPrefs(bpy.types.AddonPreferences):
new_db.icon = impl.bl_icon new_db.icon = impl.bl_icon
new_db.bl_name = impl.bl_id new_db.bl_name = impl.bl_id
# Get a server preset through its name
def get_server_preset(self, name):
existing_preset = None
# custom at launch server preset for server_preset in self.server_preset :
if server_preset.server_name == name :
existing_preset = server_preset
return existing_preset
# Custom at launch server preset
def generate_default_presets(self): def generate_default_presets(self):
for preset_name, preset_data in DEFAULT_PRESETS.items(): for preset_name, preset_data in DEFAULT_PRESETS.items():
existing_preset = self.server_preset.get(preset_name) existing_preset = self.get_server_preset(preset_name)
if existing_preset : if existing_preset :
continue continue
new_server = self.server_preset.add() new_server = self.server_preset.add()
new_server.name = preset_name new_server.name = str(uuid4())
new_server.server_ip = preset_data.get('server_ip') new_server.server_name = preset_data.get('server_name')
new_server.server_port = preset_data.get('server_port') new_server.ip = preset_data.get('ip')
new_server.port = preset_data.get('port')
new_server.use_server_password = preset_data.get('use_server_password',False)
new_server.server_password = preset_data.get('server_password',None) new_server.server_password = preset_data.get('server_password',None)
new_server.use_admin_password = preset_data.get('use_admin_password',False)
new_server.admin_password = preset_data.get('admin_password',None)
def client_list_callback(scene, context): def client_list_callback(scene, context):
@ -576,11 +750,6 @@ class SessionProps(bpy.types.PropertyGroup):
description='Connect as admin', description='Connect as admin',
default=False default=False
) )
internet_ip: bpy.props.StringProperty(
name="internet ip",
default="no found",
description='Internet interface ip',
)
user_snap_running: bpy.props.BoolProperty( user_snap_running: bpy.props.BoolProperty(
default=False default=False
) )
@ -590,6 +759,37 @@ class SessionProps(bpy.types.PropertyGroup):
is_host: bpy.props.BoolProperty( is_host: bpy.props.BoolProperty(
default=False default=False
) )
replay_files: bpy.props.CollectionProperty(
name='File paths',
type=bpy.types.OperatorFileListElement
)
replay_persistent_collection: bpy.props.BoolProperty(
name="replay_persistent_collection",
description='Enable a collection that persist accross frames loading',
get=get_replay_persistent_collection,
set=set_replay_persistent_collection,
)
replay_mode: bpy.props.EnumProperty(
name='replay method',
description='Replay in keyframe (timeline) or manually',
items={
('TIMELINE', 'TIMELINE', 'Replay from the timeline.'),
('MANUAL', 'MANUAL', 'Replay manually, from the replay frame widget.')},
default='TIMELINE')
replay_duration: bpy.props.IntProperty(
name='replay interval',
default=250,
min=10,
set=set_replay_duration,
get=get_replay_duration,
)
replay_frame_current: bpy.props.IntProperty(
name='replay_frame_current',
)
replay_camera: bpy.props.PointerProperty(
name='Replay camera',
type=bpy.types.Object
)
classes = ( classes = (
@ -616,6 +816,16 @@ def register():
# at launch server presets # at launch server presets
prefs.generate_default_presets() prefs.generate_default_presets()
bpy.types.Scene.active_replay_file = bpy.props.IntProperty(
name="active_replay_file",
default=0,
min=0,
description='Active snapshot',
set=set_active_replay,
get=get_active_replay,
options={'ANIMATABLE'}
)
def unregister(): def unregister():
@ -623,3 +833,5 @@ def unregister():
for cls in reversed(classes): for cls in reversed(classes):
unregister_class(cls) unregister_class(cls)
del bpy.types.Scene.active_replay_file

View File

@ -94,18 +94,21 @@ def project_to_viewport(region: bpy.types.Region, rv3d: bpy.types.RegionView3D,
return [target.x, target.y, target.z] return [target.x, target.y, target.z]
def bbox_from_obj(obj: bpy.types.Object) -> list: def bbox_from_obj(obj: bpy.types.Object, index: int = 1) -> list:
""" Generate a bounding box for a given object by using its world matrix """ Generate a bounding box for a given object by using its world matrix
:param obj: target object :param obj: target object
:type obj: bpy.types.Object :type obj: bpy.types.Object
:param index: indice offset
:type index: int
:return: list of 8 points [(x,y,z),...], list of 12 link between these points [(1,2),...] :return: list of 8 points [(x,y,z),...], list of 12 link between these points [(1,2),...]
""" """
radius = 1.0 # Radius of the bounding box radius = 1.0 # Radius of the bounding box
index = 8*index
vertex_indices = ( vertex_indices = (
(0, 1), (0, 2), (1, 3), (2, 3), (0+index, 1+index), (0+index, 2+index), (1+index, 3+index), (2+index, 3+index),
(4, 5), (4, 6), (5, 7), (6, 7), (4+index, 5+index), (4+index, 6+index), (5+index, 7+index), (6+index, 7+index),
(0, 4), (1, 5), (2, 6), (3, 7)) (0+index, 4+index), (1+index, 5+index), (2+index, 6+index), (3+index, 7+index))
if obj.type == 'EMPTY': if obj.type == 'EMPTY':
radius = obj.empty_display_size radius = obj.empty_display_size
@ -117,9 +120,12 @@ def bbox_from_obj(obj: bpy.types.Object) -> list:
radius = obj.data.display_size radius = obj.data.display_size
elif hasattr(obj, 'bound_box'): elif hasattr(obj, 'bound_box'):
vertex_indices = ( vertex_indices = (
(0, 1), (1, 2), (2, 3), (0, 3), (0+index, 1+index), (1+index, 2+index),
(4, 5), (5, 6), (6, 7), (4, 7), (2+index, 3+index), (0+index, 3+index),
(0, 4), (1, 5), (2, 6), (3, 7)) (4+index, 5+index), (5+index, 6+index),
(6+index, 7+index), (4+index, 7+index),
(0+index, 4+index), (1+index, 5+index),
(2+index, 6+index), (3+index, 7+index))
vertex_pos = get_bb_coords_from_obj(obj) vertex_pos = get_bb_coords_from_obj(obj)
return vertex_pos, vertex_indices return vertex_pos, vertex_indices
@ -136,26 +142,21 @@ def bbox_from_obj(obj: bpy.types.Object) -> list:
return vertex_pos, vertex_indices return vertex_pos, vertex_indices
def bbox_from_instance_collection(ic: bpy.types.Object) -> list: def bbox_from_instance_collection(ic: bpy.types.Object, index: int = 0) -> list:
""" Generate a bounding box for a given instance collection by using its objects """ Generate a bounding box for a given instance collection by using its objects
:param ic: target instance collection :param ic: target instance collection
:type ic: bpy.types.Object :type ic: bpy.types.Object
:param radius: bounding box radius :param index: indice offset
:type radius: float :type index: int
:return: list of 8*objs points [(x,y,z),...], tuple of 12*objs link between these points [(1,2),...] :return: list of 8*objs points [(x,y,z),...], tuple of 12*objs link between these points [(1,2),...]
""" """
vertex_pos = [] vertex_pos = []
vertex_indices = () vertex_indices = ()
for obj_index, obj in enumerate(ic.instance_collection.objects): for obj_index, obj in enumerate(ic.instance_collection.objects):
vertex_pos_temp, vertex_indices_temp = bbox_from_obj(obj) vertex_pos_temp, vertex_indices_temp = bbox_from_obj(obj, index=index+obj_index)
vertex_pos += vertex_pos_temp vertex_pos += vertex_pos_temp
vertex_indices_list_temp = list(list(indice) for indice in vertex_indices_temp)
for indice in vertex_indices_list_temp:
indice[0] += 8*obj_index
indice[1] += 8*obj_index
vertex_indices_temp = tuple(tuple(indice) for indice in vertex_indices_list_temp)
vertex_indices += vertex_indices_temp vertex_indices += vertex_indices_temp
bbox_corners = [ic.matrix_world @ mathutils.Vector(vertex) for vertex in vertex_pos] bbox_corners = [ic.matrix_world @ mathutils.Vector(vertex) for vertex in vertex_pos]
@ -322,6 +323,8 @@ class UserSelectionWidget(Widget):
username): username):
self.username = username self.username = username
self.settings = bpy.context.window_manager.session self.settings = bpy.context.window_manager.session
self.current_selection_ids = []
self.current_selected_objects = []
@property @property
def data(self): def data(self):
@ -331,6 +334,15 @@ class UserSelectionWidget(Widget):
else: else:
return None return None
@property
def selected_objects(self):
user_selection = self.data.get('selected_objects')
if self.current_selection_ids != user_selection:
self.current_selected_objects = [find_from_attr("uuid", uid, bpy.data.objects) for uid in user_selection]
self.current_selection_ids = user_selection
return self.current_selected_objects
def poll(self): def poll(self):
if self.data is None: if self.data is None:
return False return False
@ -345,26 +357,31 @@ class UserSelectionWidget(Widget):
self.settings.enable_presence self.settings.enable_presence
def draw(self): def draw(self):
user_selection = self.data.get('selected_objects') vertex_pos = []
for select_obj in user_selection: vertex_ind = []
obj = find_from_attr("uuid", select_obj, bpy.data.objects) collection_offset = 0
if not obj: for obj_index, obj in enumerate(self.selected_objects):
return if obj is None:
if obj.instance_collection: continue
vertex_pos, vertex_indices = bbox_from_instance_collection(obj) obj_index+=collection_offset
if hasattr(obj, 'instance_collection') and obj.instance_collection:
bbox_pos, bbox_ind = bbox_from_instance_collection(obj, index=obj_index)
collection_offset+=len(obj.instance_collection.objects)-1
else : else :
vertex_pos, vertex_indices = bbox_from_obj(obj) bbox_pos, bbox_ind = bbox_from_obj(obj, index=obj_index)
vertex_pos += bbox_pos
vertex_ind += bbox_ind
shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR') shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR')
batch = batch_for_shader( batch = batch_for_shader(
shader, shader,
'LINES', 'LINES',
{"pos": vertex_pos}, {"pos": vertex_pos},
indices=vertex_indices) indices=vertex_ind)
shader.bind() shader.bind()
shader.uniform_float("color", self.data.get('color')) shader.uniform_float("color", self.data.get('color'))
batch.draw(shader) batch.draw(shader)
class UserNameWidget(Widget): class UserNameWidget(Widget):
draw_type = 'POST_PIXEL' draw_type = 'POST_PIXEL'

View File

@ -41,7 +41,8 @@ this.registry = dict()
def is_annotating(context: bpy.types.Context): def is_annotating(context: bpy.types.Context):
""" Check if the annotate mode is enabled """ Check if the annotate mode is enabled
""" """
return bpy.context.workspace.tools.from_space_view3d_mode('OBJECT', create=False).idname == 'builtin.annotate' active_tool = bpy.context.workspace.tools.from_space_view3d_mode('OBJECT', create=False)
return (active_tool and active_tool.idname == 'builtin.annotate')
class Timer(object): class Timer(object):
@ -136,12 +137,51 @@ class ApplyTimer(Timer):
force=True) force=True)
class AnnotationUpdates(Timer):
def __init__(self, timeout=1):
self._annotating = False
self._settings = utils.get_preferences()
super().__init__(timeout)
def execute(self):
if session and session.state == STATE_ACTIVE:
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.repository.graph.get(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")
porcelain.lock(session.repository,
[registered_gp.uuid],
ignore_warnings=True,
affect_dependencies=False)
if registered_gp.owner == self._settings.username:
porcelain.commit(session.repository, annotation_gp.uuid)
porcelain.push(session.repository, 'origin', annotation_gp.uuid)
elif self._annotating:
porcelain.unlock(session.repository,
[registered_gp.uuid],
ignore_warnings=True,
affect_dependencies=False)
self._annotating = False
class DynamicRightSelectTimer(Timer): class DynamicRightSelectTimer(Timer):
def __init__(self, timeout=.1): def __init__(self, timeout=.1):
super().__init__(timeout) super().__init__(timeout)
self._last_selection = [] self._last_selection = set()
self._user = None self._user = None
self._annotating = False
def execute(self): def execute(self):
settings = utils.get_preferences() settings = utils.get_preferences()
@ -152,83 +192,47 @@ class DynamicRightSelectTimer(Timer):
self._user = session.online_users.get(settings.username) self._user = session.online_users.get(settings.username)
if self._user: if self._user:
ctx = bpy.context current_selection = set(utils.get_selected_objects(
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.repository.graph.get(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")
porcelain.lock(session.repository,
registered_gp.uuid,
ignore_warnings=True,
affect_dependencies=False)
if registered_gp.owner == settings.username:
gp_node = session.repository.graph.get(annotation_gp.uuid)
porcelain.commit(session.repository, gp_node.uuid)
porcelain.push(session.repository, 'origin', gp_node.uuid)
elif self._annotating:
porcelain.unlock(session.repository,
registered_gp.uuid,
ignore_warnings=True,
affect_dependencies=False)
current_selection = utils.get_selected_objects(
bpy.context.scene, bpy.context.scene,
bpy.data.window_managers['WinMan'].windows[0].view_layer bpy.data.window_managers['WinMan'].windows[0].view_layer
) ))
if current_selection != self._last_selection: if current_selection != self._last_selection:
obj_common = [ to_lock = list(current_selection.difference(self._last_selection))
o for o in self._last_selection if o not in current_selection] to_release = list(self._last_selection.difference(current_selection))
obj_ours = [ instances_to_lock = list()
o for o in current_selection if o not in self._last_selection]
# change old selection right to common for node_id in to_lock:
for obj in obj_common: node = session.repository.graph.get(node_id)
node = session.repository.graph.get(obj) if node and hasattr(node,'data'):
instance_mode = node.data.get('instance_type')
if instance_mode and instance_mode == 'COLLECTION':
to_lock.remove(node_id)
instances_to_lock.append(node_id)
if instances_to_lock:
try:
porcelain.lock(session.repository,
instances_to_lock,
ignore_warnings=True,
affect_dependencies=False)
except NonAuthorizedOperationError as e:
logging.warning(e)
if node and (node.owner == settings.username or node.owner == RP_COMMON): if to_release:
recursive = True try:
if node.data and 'instance_type' in node.data.keys(): porcelain.unlock(session.repository,
recursive = node.data['instance_type'] != 'COLLECTION' to_release,
try: ignore_warnings=True,
porcelain.unlock(session.repository, affect_dependencies=True)
node.uuid, except NonAuthorizedOperationError as e:
ignore_warnings=True, logging.warning(e)
affect_dependencies=recursive) if to_lock:
except NonAuthorizedOperationError: try:
logging.warning( porcelain.lock(session.repository,
f"Not authorized to change {node} owner") to_lock,
ignore_warnings=True,
# change new selection to our affect_dependencies=True)
for obj in obj_ours: except NonAuthorizedOperationError as e:
node = session.repository.graph.get(obj) logging.warning(e)
if node and node.owner == RP_COMMON:
recursive = True
if node.data and 'instance_type' in node.data.keys():
recursive = node.data['instance_type'] != 'COLLECTION'
try:
porcelain.lock(session.repository,
node.uuid,
ignore_warnings=True,
affect_dependencies=recursive)
except NonAuthorizedOperationError:
logging.warning(
f"Not authorized to change {node} owner")
else:
return
self._last_selection = current_selection self._last_selection = current_selection
@ -242,17 +246,16 @@ class DynamicRightSelectTimer(Timer):
# Fix deselection until right managment refactoring (with Roles concepts) # Fix deselection until right managment refactoring (with Roles concepts)
if len(current_selection) == 0 : if len(current_selection) == 0 :
owned_keys = [k for k, v in session.repository.graph.items() if v.owner==settings.username] owned_keys = [k for k, v in session.repository.graph.items() if v.owner==settings.username]
for key in owned_keys: if owned_keys:
node = session.repository.graph.get(key)
try: try:
porcelain.unlock(session.repository, porcelain.unlock(session.repository,
key, owned_keys,
ignore_warnings=True, ignore_warnings=True,
affect_dependencies=True) affect_dependencies=True)
except NonAuthorizedOperationError: except NonAuthorizedOperationError as e:
logging.warning( logging.warning(e)
f"Not authorized to change {key} owner")
# Objects selectability
for obj in bpy.data.objects: for obj in bpy.data.objects:
object_uuid = getattr(obj, 'uuid', None) object_uuid = getattr(obj, 'uuid', None)
if object_uuid: if object_uuid:

View File

@ -16,7 +16,9 @@
# ##### END GPL LICENSE BLOCK ##### # ##### END GPL LICENSE BLOCK #####
from logging import log
import bpy import bpy
import bpy.utils.previews
from .utils import get_preferences, get_expanded_icon, get_folder_size, get_state_str from .utils import get_preferences, get_expanded_icon, get_folder_size, get_state_str
from replication.constants import (ADDED, ERROR, FETCHED, from replication.constants import (ADDED, ERROR, FETCHED,
@ -71,162 +73,132 @@ class SESSION_PT_settings(bpy.types.Panel):
def draw_header(self, context): def draw_header(self, context):
layout = self.layout layout = self.layout
settings = get_preferences()
from multi_user import icons
offline_icon = icons.icons_col["session_status_offline"]
waiting_icon = icons.icons_col["session_status_waiting"]
online_icon = icons.icons_col["session_status_online"]
if session and session.state != STATE_INITIAL: if session and session.state != STATE_INITIAL:
cli_state = session.state cli_state = session.state
state = session.state state = session.state
connection_icon = "KEYTYPE_MOVING_HOLD_VEC" connection_icon = offline_icon
if state == STATE_ACTIVE: if state == STATE_ACTIVE:
connection_icon = 'PROP_ON' connection_icon = online_icon
else: else:
connection_icon = 'PROP_CON' connection_icon = waiting_icon
layout.label(text=f"Session - {get_state_str(cli_state)}", icon=connection_icon) layout.label(text=f"{str(settings.server_name)} - {get_state_str(cli_state)}", icon_value=connection_icon.icon_id)
else: else:
layout.label(text=f"Session - v{__version__}",icon="PROP_OFF") layout.label(text=f"Multi-user - v{__version__}", icon="ANTIALIASED")
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
row = layout.row()
runtime_settings = context.window_manager.session runtime_settings = context.window_manager.session
settings = get_preferences() settings = get_preferences()
if hasattr(context.window_manager, 'session'): if settings.is_first_launch:
# STATE INITIAL # USER SETTINGS
if not session \ row = layout.row()
or (session and session.state == STATE_INITIAL): row.label(text="1. Enter your username and color:")
pass row = layout.row()
else: split = row.split(factor=0.7, align=True)
progress = session.state_progress split.prop(settings, "username", text="")
row = layout.row() split.prop(settings, "client_color", text="")
current_state = session.state # DOC
info_msg = None row = layout.row()
row.label(text="2. New here ? See the doc:")
row = layout.row()
row.operator("doc.get", text="Documentation", icon="HELP")
if current_state in [STATE_ACTIVE]: # START
row = row.grid_flow(row_major=True, columns=0, even_columns=True, even_rows=False, align=True) row = layout.row()
row.prop(settings.sync_flags, "sync_render_settings",text="",icon_only=True, icon='SCENE') row.label(text="3: Start the Multi-user:")
row.prop(settings.sync_flags, "sync_during_editmode", text="",icon_only=True, icon='EDITMODE_HLT') row = layout.row()
row.prop(settings.sync_flags, "sync_active_camera", text="",icon_only=True, icon='VIEW_CAMERA') row.scale_y = 2
row.operator("firstlaunch.verify", text="Continue")
row= layout.row() if not settings.is_first_launch:
if hasattr(context.window_manager, 'session'):
# STATE INITIAL
if not session \
or (session and session.state == STATE_INITIAL):
layout = self.layout
settings = get_preferences()
server_preset = settings.server_preset
selected_server = context.window_manager.server_index if context.window_manager.server_index<=len(server_preset)-1 else 0
active_server_name = server_preset[selected_server].name if len(server_preset)>=1 else ""
is_server_selected = True if active_server_name else False
if current_state in [STATE_ACTIVE] and runtime_settings.is_host: # SERVER LIST
info_msg = f"LAN: {runtime_settings.internet_ip}" row = layout.row()
if current_state == STATE_LOBBY: box = row.box()
info_msg = "Waiting for the session to start." box.scale_y = 0.7
split = box.split(factor=0.7)
split.label(text="Server")
split.label(text="Online")
if info_msg: col = row.column(align=True)
info_box = row.box() col.operator("session.get_info", icon="FILE_REFRESH", text="")
info_box.row().label(text=info_msg,icon='INFO')
# Progress bar row = layout.row()
if current_state in [STATE_SYNCING, STATE_SRV_SYNC, STATE_WAITING]: col = row.column(align=True)
info_box = row.box() col.template_list("SESSION_UL_network", "", settings, "server_preset", context.window_manager, "server_index")
info_box.row().label(text=printProgressBar( col.separator()
progress['current'], connectOp = col.row()
progress['total'], connectOp.enabled =is_server_selected
length=16 connectOp.operator("session.connect", text="Connect")
))
layout.row().operator("session.stop", icon='QUIT', text="Exit") col = row.column(align=True)
col.operator("session.preset_server_add", icon="ADD", text="") # TODO : add conditions (need a name, etc..)
row_visible = col.row(align=True)
col_visible = row_visible.column(align=True)
col_visible.enabled = is_server_selected
col_visible.operator("session.preset_server_remove", icon="REMOVE", text="").target_server_name = active_server_name
col_visible.separator()
col_visible.operator("session.preset_server_edit", icon="GREASEPENCIL", text="").target_server_name = active_server_name
class SESSION_PT_settings_network(bpy.types.Panel): else:
bl_idname = "MULTIUSER_SETTINGS_NETWORK_PT_panel" exitbutton = layout.row()
bl_label = "Network" exitbutton.scale_y = 1.5
bl_space_type = 'VIEW_3D' exitbutton.operator("session.stop", icon='QUIT', text="Disconnect")
bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
@classmethod progress = session.state_progress
def poll(cls, context): current_state = session.state
return not session \ info_msg = None
or (session and session.state == 0)
def draw_header(self, context): if current_state == STATE_LOBBY:
self.layout.label(text="", icon='URL') usr = session.online_users.get(settings.username)
row= layout.row()
info_msg = "Waiting for the session to start."
if usr and usr['admin']:
info_msg = "Init the session to start."
info_box = layout.row()
info_box.label(text=info_msg,icon='INFO')
init_row = layout.row()
init_row.operator("session.init", icon='TOOL_SETTINGS', text="Init")
else:
info_box = layout.row()
info_box.row().label(text=info_msg,icon='INFO')
def draw(self, context): # PROGRESS BAR
layout = self.layout if current_state in [STATE_SYNCING, STATE_SRV_SYNC, STATE_WAITING]:
row= layout.row()
row.label(text=f"Status: {get_state_str(current_state)}")
row= layout.row()
info_box = row.box()
info_box.label(text=printProgressBar(
progress['current'],
progress['total'],
length=16
))
runtime_settings = context.window_manager.session class SESSION_PT_host_settings(bpy.types.Panel):
settings = get_preferences() bl_idname = "MULTIUSER_SETTINGS_HOST_PT_panel"
bl_label = "Hosting"
# USER SETTINGS
row = layout.row()
row.prop(runtime_settings, "session_mode", expand=True)
row = layout.row()
col = row.row(align=True)
col.prop(settings, "server_preset_interface", text="")
col.operator("session.preset_server_add", icon='ADD', text="")
col.operator("session.preset_server_remove", icon='REMOVE', text="")
row = layout.row()
box = row.box()
if runtime_settings.session_mode == 'HOST':
row = box.row()
row.label(text="Port:")
row.prop(settings, "port", text="")
row = box.row()
row.label(text="Start from:")
row.prop(settings, "init_method", text="")
row = box.row()
row.label(text="Admin password:")
row.prop(settings, "password", text="")
row = box.row()
row.operator("session.start", text="HOST").host = True
else:
row = box.row()
row.prop(settings, "ip", text="IP")
row = box.row()
row.label(text="Port:")
row.prop(settings, "port", text="")
row = box.row()
row.prop(runtime_settings, "admin", text='Connect as admin', icon='DISCLOSURE_TRI_DOWN' if runtime_settings.admin
else 'DISCLOSURE_TRI_RIGHT')
if runtime_settings.admin:
row = box.row()
row.label(text="Password:")
row.prop(settings, "password", text="")
row = box.row()
row.operator("session.start", text="CONNECT").host = False
class SESSION_PT_settings_user(bpy.types.Panel):
bl_idname = "MULTIUSER_SETTINGS_USER_PT_panel"
bl_label = "User info"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
@classmethod
def poll(cls, context):
return not session \
or (session and session.state == 0)
def draw_header(self, context):
self.layout.label(text="", icon='USER')
def draw(self, context):
layout = self.layout
runtime_settings = context.window_manager.session
settings = get_preferences()
row = layout.row()
# USER SETTINGS
row.prop(settings, "username", text="name")
row = layout.row()
row.prop(settings, "client_color", text="color")
row = layout.row()
class SESSION_PT_advanced_settings(bpy.types.Panel):
bl_idname = "MULTIUSER_SETTINGS_REPLICATION_PT_panel"
bl_label = "Advanced"
bl_space_type = 'VIEW_3D' bl_space_type = 'VIEW_3D'
bl_region_type = 'UI' bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel' bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
@ -234,19 +206,82 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
settings = get_preferences()
return not session \ return not session \
or (session and session.state == 0) or (session and session.state == 0) \
and not settings.sidebar_advanced_shown \
and not settings.is_first_launch
def draw_header(self, context):
self.layout.label(text="", icon='NETWORK_DRIVE')
def draw(self, context):
layout = self.layout
settings = get_preferences()
#HOST
host_selection = layout.row().box()
host_selection_row = host_selection.row()
host_selection_row.label(text="Init the session from:")
host_selection_row.prop(settings, "init_method", text="")
host_selection_row = host_selection.row()
host_selection_row.label(text="Port:")
host_selection_row.prop(settings, "host_port", text="")
host_selection_row = host_selection.row()
host_selection_col = host_selection_row.column()
host_selection_col.prop(settings, "host_use_server_password", text="Server password:")
host_selection_col = host_selection_row.column()
host_selection_col.enabled = True if settings.host_use_server_password else False
host_selection_col.prop(settings, "host_server_password", text="")
host_selection_row = host_selection.row()
host_selection_col = host_selection_row.column()
host_selection_col.prop(settings, "host_use_admin_password", text="Admin password:")
host_selection_col = host_selection_row.column()
host_selection_col.enabled = True if settings.host_use_admin_password else False
host_selection_col.prop(settings, "host_admin_password", text="")
host_selection = layout.column()
host_selection.operator("session.host", text="Host")
class SESSION_PT_advanced_settings(bpy.types.Panel):
bl_idname = "MULTIUSER_SETTINGS_REPLICATION_PT_panel"
bl_label = "General Settings"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
settings = get_preferences()
return not session \
or (session and session.state == 0) \
and not settings.sidebar_advanced_shown \
and not settings.is_first_launch
def draw_header(self, context): def draw_header(self, context):
self.layout.label(text="", icon='PREFERENCES') self.layout.label(text="", icon='PREFERENCES')
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
runtime_settings = context.window_manager.session
settings = get_preferences() settings = get_preferences()
#ADVANCED USER INFO
uinfo_section = layout.row().box()
uinfo_section.prop(
settings,
"sidebar_advanced_uinfo_expanded",
text="User Info",
icon=get_expanded_icon(settings.sidebar_advanced_uinfo_expanded),
emboss=False)
if settings.sidebar_advanced_uinfo_expanded:
uinfo_section_row = uinfo_section.row()
uinfo_section_split = uinfo_section_row.split(factor=0.7, align=True)
uinfo_section_split.prop(settings, "username", text="")
uinfo_section_split.prop(settings, "client_color", text="")
#ADVANCED NET
net_section = layout.row().box() net_section = layout.row().box()
net_section.prop( net_section.prop(
settings, settings,
@ -254,12 +289,15 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
text="Network", text="Network",
icon=get_expanded_icon(settings.sidebar_advanced_net_expanded), icon=get_expanded_icon(settings.sidebar_advanced_net_expanded),
emboss=False) emboss=False)
if settings.sidebar_advanced_net_expanded: if settings.sidebar_advanced_net_expanded:
net_section_row = net_section.row() net_section_row = net_section.row()
net_section_row.label(text="Timeout (ms):") net_section_row.label(text="Timeout (ms):")
net_section_row.prop(settings, "connection_timeout", text="") net_section_row.prop(settings, "connection_timeout", text="")
net_section_row = net_section.row()
net_section_row.label(text="Server ping (ms):")
net_section_row.prop(settings, "ping_timeout", text="")
#ADVANCED REPLICATION
replication_section = layout.row().box() replication_section = layout.row().box()
replication_section.prop( replication_section.prop(
settings, settings,
@ -267,16 +305,12 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
text="Replication", text="Replication",
icon=get_expanded_icon(settings.sidebar_advanced_rep_expanded), icon=get_expanded_icon(settings.sidebar_advanced_rep_expanded),
emboss=False) emboss=False)
if settings.sidebar_advanced_rep_expanded: if settings.sidebar_advanced_rep_expanded:
replication_section_row = replication_section.row()
replication_section_row = replication_section.row() replication_section_row = replication_section.row()
replication_section_row.prop(settings.sync_flags, "sync_render_settings") replication_section_row.prop(settings.sync_flags, "sync_render_settings")
replication_section_row = replication_section.row() replication_section_row = replication_section.row()
replication_section_row.prop(settings.sync_flags, "sync_active_camera") replication_section_row.prop(settings.sync_flags, "sync_active_camera")
replication_section_row = replication_section.row() replication_section_row = replication_section.row()
replication_section_row.prop(settings.sync_flags, "sync_during_editmode") replication_section_row.prop(settings.sync_flags, "sync_during_editmode")
replication_section_row = replication_section.row() replication_section_row = replication_section.row()
if settings.sync_flags.sync_during_editmode: if settings.sync_flags.sync_during_editmode:
@ -285,7 +319,7 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
replication_section_row = replication_section.row() replication_section_row = replication_section.row()
replication_section_row.prop(settings, "depsgraph_update_rate", text="Apply delay") replication_section_row.prop(settings, "depsgraph_update_rate", text="Apply delay")
#ADVANCED CACHE
cache_section = layout.row().box() cache_section = layout.row().box()
cache_section.prop( cache_section.prop(
settings, settings,
@ -303,6 +337,8 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
cache_section_row.prop(settings, "clear_memory_filecache", text="") cache_section_row.prop(settings, "clear_memory_filecache", text="")
cache_section_row = cache_section.row() cache_section_row = cache_section.row()
cache_section_row.operator('session.clear_cache', text=f"Clear cache ({get_folder_size(settings.cache_directory)})") cache_section_row.operator('session.clear_cache', text=f"Clear cache ({get_folder_size(settings.cache_directory)})")
#ADVANCED LOG
log_section = layout.row().box() log_section = layout.row().box()
log_section.prop( log_section.prop(
settings, settings,
@ -310,11 +346,11 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
text="Logging", text="Logging",
icon=get_expanded_icon(settings.sidebar_advanced_log_expanded), icon=get_expanded_icon(settings.sidebar_advanced_log_expanded),
emboss=False) emboss=False)
if settings.sidebar_advanced_log_expanded: if settings.sidebar_advanced_log_expanded:
log_section_row = log_section.row() log_section_row = log_section.row()
log_section_row.label(text="Log level:") log_section_row.label(text="Log level:")
log_section_row.prop(settings, 'logging_level', text="") log_section_row.prop(settings, 'logging_level', text="")
class SESSION_PT_user(bpy.types.Panel): class SESSION_PT_user(bpy.types.Panel):
bl_idname = "MULTIUSER_USER_PT_panel" bl_idname = "MULTIUSER_USER_PT_panel"
bl_label = "Online users" bl_label = "Online users"
@ -324,7 +360,8 @@ class SESSION_PT_user(bpy.types.Panel):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
return session and session.state in [STATE_ACTIVE, STATE_LOBBY] return session \
and session.state in [STATE_ACTIVE, STATE_LOBBY]
def draw_header(self, context): def draw_header(self, context):
self.layout.label(text="", icon='USER') self.layout.label(text="", icon='USER')
@ -336,9 +373,8 @@ class SESSION_PT_user(bpy.types.Panel):
settings = get_preferences() settings = get_preferences()
active_user = online_users[selected_user] if len( active_user = online_users[selected_user] if len(
online_users)-1 >= selected_user else 0 online_users)-1 >= selected_user else 0
runtime_settings = context.window_manager.session
# Create a simple row. #USER LIST
row = layout.row() row = layout.row()
box = row.box() box = row.box()
split = box.split(factor=0.35) split = box.split(factor=0.35)
@ -353,6 +389,7 @@ class SESSION_PT_user(bpy.types.Panel):
layout.template_list("SESSION_UL_users", "", context.window_manager, layout.template_list("SESSION_UL_users", "", context.window_manager,
"online_users", context.window_manager, "user_index") "online_users", context.window_manager, "user_index")
#OPERATOR ON USER
if active_user != 0 and active_user.username != settings.username: if active_user != 0 and active_user.username != settings.username:
row = layout.row() row = layout.row()
user_operations = row.split() user_operations = row.split()
@ -436,57 +473,8 @@ class SESSION_UL_users(bpy.types.UIList):
split.label(text=scene_current) split.label(text=scene_current)
split.label(text=ping) split.label(text=ping)
class SESSION_PT_presence(bpy.types.Panel):
bl_idname = "MULTIUSER_MODULE_PT_panel"
bl_label = "Presence overlay"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
return not session \
or (session and session.state in [STATE_INITIAL, STATE_ACTIVE])
def draw_header(self, context):
self.layout.prop(context.window_manager.session,
"enable_presence", text="",icon='OVERLAY')
def draw(self, context):
layout = self.layout
settings = context.window_manager.session
pref = get_preferences()
layout.active = settings.enable_presence
row = layout.row()
row = row.grid_flow(row_major=True, columns=0, even_columns=True, even_rows=False, align=True)
row.prop(settings, "presence_show_selected",text="",icon_only=True, icon='CUBE')
row.prop(settings, "presence_show_user", text="",icon_only=True, icon='CAMERA_DATA')
row.prop(settings, "presence_show_mode", text="",icon_only=True, icon='OBJECT_DATAMODE')
row.prop(settings, "presence_show_far_user", text="",icon_only=True, icon='SCENE_DATA')
col = layout.column()
if settings.presence_show_mode :
row = col.column()
row.prop(pref, "presence_mode_distance", expand=True)
col.prop(settings, "presence_show_session_status")
if 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)
def draw_property(context, parent, property_uuid, level=0): def draw_property(context, parent, property_uuid, level=0):
settings = get_preferences() settings = get_preferences()
runtime_settings = context.window_manager.session
item = session.repository.graph.get(property_uuid) item = session.repository.graph.get(property_uuid)
type_id = item.data.get('type_id') type_id = item.data.get('type_id')
area_msg = parent.row(align=True) area_msg = parent.row(align=True)
@ -506,15 +494,18 @@ def draw_property(context, parent, property_uuid, level=0):
detail_item_box.label(text=f"{name}") detail_item_box.label(text=f"{name}")
# Operations # Operations
have_right_to_modify = (item.owner == settings.username or \ have_right_to_modify = (item.owner == settings.username or \
item.owner == RP_COMMON) and item.state != ERROR item.owner == RP_COMMON) and item.state != ERROR
from multi_user import icons
sync_status = icons.icons_col["repository_push"] #TODO: Link all icons to the right sync (push/merge/issue). For issue use "UNLINKED" for icon
# sync_status = icons.icons_col["repository_merge"]
if have_right_to_modify: if have_right_to_modify:
detail_item_box.operator( detail_item_box.operator(
"session.commit", "session.commit",
text="", text="",
icon='TRIA_UP').target = item.uuid icon_value=sync_status.icon_id).target = item.uuid
detail_item_box.separator() detail_item_box.separator()
if item.state in [FETCHED, UP]: if item.state in [FETCHED, UP]:
@ -546,12 +537,72 @@ def draw_property(context, parent, property_uuid, level=0):
else: else:
detail_item_box.label(text="", icon="DECORATE_LOCKED") detail_item_box.label(text="", icon="DECORATE_LOCKED")
class SESSION_PT_sync(bpy.types.Panel):
bl_idname = "MULTIUSER_SYNC_PT_panel"
bl_label = "Synchronize"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
return session \
and session.state in [STATE_ACTIVE]
def draw_header(self, context):
self.layout.label(text="", icon='UV_SYNC_SELECT')
def draw(self, context):
layout = self.layout
settings = get_preferences()
row= layout.row()
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='VIEW_CAMERA')
class SESSION_PT_replay(bpy.types.Panel):
bl_idname = "MULTIUSER_REPLAY_PT_panel"
bl_label = "Replay"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
return context.window_manager.session.replay_files
def draw_header(self, context):
self.layout.label(text="", icon='RECOVER_LAST')
def draw(self, context):
layout = self.layout
settings = context.window_manager.session
row= layout.row()
row.prop(settings,'replay_mode', toggle=True, expand=True)
row= layout.row()
if settings.replay_mode == 'MANUAL':
row.prop(bpy.context.scene, 'active_replay_file', text="Snapshot index")
else:
row.prop(settings, 'replay_duration', text="Replay Duration")
row= layout.row()
row.prop(settings, 'replay_persistent_collection', text="persistent collection", toggle=True, icon='OUTLINER_COLLECTION')
if settings.replay_persistent_collection:
row= layout.row()
row.prop(settings, 'replay_camera', text="", icon='VIEW_CAMERA')
class SESSION_PT_repository(bpy.types.Panel): class SESSION_PT_repository(bpy.types.Panel):
bl_idname = "MULTIUSER_PROPERTIES_PT_panel" bl_idname = "MULTIUSER_PROPERTIES_PT_panel"
bl_label = "Repository" bl_label = "Repository"
bl_space_type = 'VIEW_3D' bl_space_type = 'VIEW_3D'
bl_region_type = 'UI' bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel' bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
bl_options = {'DEFAULT_CLOSED'}
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
@ -564,8 +615,8 @@ class SESSION_PT_repository(bpy.types.Panel):
admin = usr['admin'] admin = usr['admin']
return hasattr(context.window_manager, 'session') and \ return hasattr(context.window_manager, 'session') and \
session and \ session and \
(session.state == STATE_ACTIVE or \ session.state == STATE_ACTIVE and \
session.state == STATE_LOBBY and admin) not settings.sidebar_repository_shown
def draw_header(self, context): def draw_header(self, context):
self.layout.label(text="", icon='OUTLINER_OB_GROUP_INSTANCE') self.layout.label(text="", icon='OUTLINER_OB_GROUP_INSTANCE')
@ -579,19 +630,18 @@ class SESSION_PT_repository(bpy.types.Panel):
usr = session.online_users.get(settings.username) usr = session.online_users.get(settings.username)
row = layout.row()
if session.state == STATE_ACTIVE: if session.state == STATE_ACTIVE:
if 'SessionBackupTimer' in registry: if 'SessionBackupTimer' in registry:
row = layout.row()
row.alert = True row.alert = True
row.operator('session.cancel_autosave', icon="CANCEL") row.operator('session.cancel_autosave', icon="CANCEL")
row.alert = False row.alert = False
else: # else:
row.operator('session.save', icon="FILE_TICK") # row.operator('session.save', icon="FILE_TICK")
box = layout.box() box = layout.box()
row = box.row() row = box.row()
row.prop(runtime_settings, "filter_owned", text="Show only owned Nodes", icon_only=True, icon="DECORATE_UNLOCKED") row.prop(runtime_settings, "filter_owned", text="Only show owned data blocks", icon_only=True, icon="DECORATE_UNLOCKED")
row = box.row() row = box.row()
row.prop(runtime_settings, "filter_name", text="Filter") row.prop(runtime_settings, "filter_name", text="Filter")
row = box.row() row = box.row()
@ -599,27 +649,18 @@ class SESSION_PT_repository(bpy.types.Panel):
# Properties # Properties
owned_nodes = [k for k, v in session.repository.graph.items() if v.owner==settings.username] owned_nodes = [k for k, v in session.repository.graph.items() if v.owner==settings.username]
filtered_node = owned_nodes if runtime_settings.filter_owned else session.repository.graph.keys() filtered_node = owned_nodes if runtime_settings.filter_owned else list(session.repository.graph.keys())
if runtime_settings.filter_name: if runtime_settings.filter_name:
for node_id in filtered_node: filtered_node = [n for n in filtered_node if runtime_settings.filter_name.lower() in session.repository.graph.get(n).data.get('name').lower()]
node_instance = session.repository.graph.get(node_id)
name = node_instance.data.get('name')
if runtime_settings.filter_name not in name:
filtered_node.remove(node_id)
if filtered_node: if filtered_node:
col = layout.column(align=True) col = layout.column(align=True)
for key in filtered_node: for key in filtered_node:
draw_property(context, col, key) draw_property(context, col, key)
else: else:
layout.row().label(text="Empty") layout.row().label(text="Empty")
elif session.state == STATE_LOBBY and usr and usr['admin']:
row.operator("session.init", icon='TOOL_SETTINGS', text="Init")
else:
row.label(text="Waiting to start")
class VIEW3D_PT_overlay_session(bpy.types.Panel): class VIEW3D_PT_overlay_session(bpy.types.Panel):
bl_space_type = 'VIEW_3D' bl_space_type = 'VIEW_3D'
@ -639,41 +680,70 @@ class VIEW3D_PT_overlay_session(bpy.types.Panel):
layout.active = settings.enable_presence layout.active = settings.enable_presence
row = layout.row() row = layout.row()
row = row.grid_flow(row_major=True, columns=0, even_columns=True, even_rows=False, align=True) row.prop(settings, "enable_presence",text="Presence Overlay")
row.prop(settings, "presence_show_selected",text="",icon_only=True, icon='CUBE')
row.prop(settings, "presence_show_user", text="",icon_only=True, icon='CAMERA_DATA') row = layout.row()
row.prop(settings, "presence_show_mode", text="",icon_only=True, icon='OBJECT_DATAMODE') row.prop(settings, "presence_show_selected",text="Selected Objects")
row.prop(settings, "presence_show_far_user", text="",icon_only=True, icon='SCENE_DATA')
row = layout.row(align=True)
row.prop(settings, "presence_show_user", text="Users camera")
row.prop(settings, "presence_show_mode", text="Users mode")
col = layout.column() col = layout.column()
if settings.presence_show_mode : if settings.presence_show_mode or settings.presence_show_user:
row = col.column() row = col.column()
row.prop(pref, "presence_mode_distance", expand=True) row.prop(pref, "presence_text_distance", expand=True)
row = col.column()
row.prop(settings, "presence_show_far_user", text="Users on different scenes")
col.prop(settings, "presence_show_session_status") col.prop(settings, "presence_show_session_status")
if settings.presence_show_session_status : if settings.presence_show_session_status :
row = col.column() split = layout.split()
row.active = settings.presence_show_session_status text_pos = split.column(align=True)
row.prop(pref, "presence_hud_scale", expand=True) text_pos.active = settings.presence_show_session_status
row = col.column(align=True) text_pos.prop(pref, "presence_hud_hpos", expand=True)
row.active = settings.presence_show_session_status text_pos.prop(pref, "presence_hud_vpos", expand=True)
row.prop(pref, "presence_hud_hpos", expand=True) text_scale = split.column()
row.prop(pref, "presence_hud_vpos", expand=True) text_scale.active = settings.presence_show_session_status
text_scale.prop(pref, "presence_hud_scale", expand=True)
class SESSION_UL_network(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag):
settings = get_preferences()
server_name = '-'
server_status = 'BLANK1'
server_private = 'BLANK1'
server_name = item.server_name
split = layout.split(factor=0.7)
if item.is_private:
server_private = 'LOCKED'
split.label(text=server_name, icon=server_private)
else:
split.label(text=server_name)
from multi_user import icons
server_status = icons.icons_col["server_offline"]
if item.is_online:
server_status = icons.icons_col["server_online"]
split.label(icon_value=server_status.icon_id)
classes = ( classes = (
SESSION_UL_users, SESSION_UL_users,
SESSION_UL_network,
SESSION_PT_settings, SESSION_PT_settings,
SESSION_PT_settings_user, SESSION_PT_host_settings,
SESSION_PT_settings_network,
SESSION_PT_presence,
SESSION_PT_advanced_settings, SESSION_PT_advanced_settings,
SESSION_PT_user, SESSION_PT_user,
SESSION_PT_sync,
SESSION_PT_repository, SESSION_PT_repository,
VIEW3D_PT_overlay_session, VIEW3D_PT_overlay_session,
SESSION_PT_replay,
) )
register, unregister = bpy.utils.register_classes_factory(classes) register, unregister = bpy.utils.register_classes_factory(classes)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -38,15 +38,6 @@ from replication.constants import (STATE_ACTIVE, STATE_AUTH,
STATE_LOBBY, STATE_LOBBY,
CONNECTING) CONNECTING)
CLEARED_DATABLOCKS = ['actions', 'armatures', 'cache_files', 'cameras',
'collections', 'curves', 'filepath', 'fonts',
'grease_pencils', 'images', 'lattices', 'libraries',
'lightprobes', 'lights', 'linestyles', 'masks',
'materials', 'meshes', 'metaballs', 'movieclips',
'node_groups', 'objects', 'paint_curves', 'particles',
'scenes', 'shape_keys', 'sounds', 'speakers', 'texts',
'textures', 'volumes', 'worlds']
def find_from_attr(attr_name, attr_value, list): def find_from_attr(attr_name, attr_value, list):
for item in list: for item in list:
if getattr(item, attr_name, None) == attr_value: if getattr(item, attr_name, None) == attr_value:
@ -108,26 +99,6 @@ def get_state_str(state):
return state_str return state_str
def clean_scene():
for type_name in CLEARED_DATABLOCKS:
sub_collection_to_avoid = [
bpy.data.linestyles.get('LineStyle'),
bpy.data.materials.get('Dots Stroke')
]
type_collection = getattr(bpy.data, type_name)
items_to_remove = [i for i in type_collection if i not in sub_collection_to_avoid]
for item in items_to_remove:
try:
type_collection.remove(item)
logging.info(item.name)
except:
continue
# Clear sequencer
bpy.context.scene.sequence_editor_clear()
def get_selected_objects(scene, active_view_layer): 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)] return [obj.uuid for obj in scene.objects if obj.select_get(view_layer=active_view_layer)]

View File

@ -1,7 +1,7 @@
# Download base image debian jessie # Download base image debian jessie
FROM python:slim FROM python:slim
ARG replication_version=0.1.13 ARG replication_version=0.9.1
ARG version=0.1.1 ARG version=0.1.1
# Infos # Infos
@ -22,4 +22,4 @@ RUN pip install replication==$replication_version
# Run the server with parameters # Run the server with parameters
ENTRYPOINT ["/bin/sh", "-c"] ENTRYPOINT ["/bin/sh", "-c"]
CMD ["python3 -m replication.server -pwd ${password} -p ${port} -t ${timeout} -l ${log_level} -lf ${log_file}"] CMD ["replication.serve -apwd ${password} -spwd '' -p ${port} -t ${timeout} -l ${log_level} -lf ${log_file}"]

View File

@ -7,7 +7,7 @@ import bpy
from multi_user.bl_types.bl_lightprobe import BlLightprobe from multi_user.bl_types.bl_lightprobe import BlLightprobe
@pytest.mark.skipif(bpy.app.version[1] < 83, reason="requires blender 2.83 or higher") @pytest.mark.skipif(bpy.app.version < (2,83,0), reason="requires blender 2.83 or higher")
@pytest.mark.parametrize('lightprobe_type', ['PLANAR','GRID','CUBEMAP']) @pytest.mark.parametrize('lightprobe_type', ['PLANAR','GRID','CUBEMAP'])
def test_lightprobes(clear_blend, lightprobe_type): def test_lightprobes(clear_blend, lightprobe_type):
bpy.ops.object.lightprobe_add(type=lightprobe_type) bpy.ops.object.lightprobe_add(type=lightprobe_type)

View File

@ -12,6 +12,8 @@ def test_scene(clear_blend):
get_preferences().sync_flags.sync_render_settings = True get_preferences().sync_flags.sync_render_settings = True
datablock = bpy.data.scenes.new("toto") datablock = bpy.data.scenes.new("toto")
datablock.timeline_markers.new('toto', frame=10)
datablock.timeline_markers.new('tata', frame=1)
datablock.view_settings.use_curve_mapping = True datablock.view_settings.use_curve_mapping = True
# Test # Test
implementation = BlScene() implementation = BlScene()

View File

@ -1,20 +0,0 @@
import os
import pytest
from deepdiff import DeepDiff
import bpy
import random
def test_start_session():
result = bpy.ops.session.start()
assert 'FINISHED' in result
def test_stop_session():
result = bpy.ops.session.stop()
assert 'FINISHED' in result