Compare commits

..

1 Commits

Author SHA1 Message Date
Swann 63ba6d35d5 feat: basic file interface 2020-09-20 00:02:52 +02:00
46 changed files with 1159 additions and 2133 deletions
+1 -4
View File
@@ -1,10 +1,7 @@
stages:
- test
- build
- deploy
include:
- local: .gitlab/ci/test.gitlab-ci.yml
- local: .gitlab/ci/build.gitlab-ci.yml
- local: .gitlab/ci/deploy.gitlab-ci.yml
- local: .gitlab/ci/build.gitlab-ci.yml
+4 -2
View File
@@ -3,11 +3,13 @@ build:
image: debian:stable-slim
script:
- rm -rf tests .git .gitignore script
artifacts:
name: multi_user
paths:
- multi_user
only:
refs:
- master
- develop
- master
- develop
-23
View File
@@ -1,23 +0,0 @@
deploy:
stage: deploy
image: slumber/docker-python
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "/certs"
services:
- docker:19.03.12-dind
script:
- RP_VERSION="$(python scripts/get_replication_version.py)"
- VERSION="$(python scripts/get_addon_version.py)"
- echo "Building docker image with replication ${RP_VERSION}"
- docker build --build-arg replication_version=${RP_VERSION} --build-arg version={VERSION} -t registry.gitlab.com/slumber/multi-user/multi-user-server:${VERSION} ./scripts/docker_server
- echo "Pushing to gitlab registry ${VERSION}"
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push registry.gitlab.com/slumber/multi-user/multi-user-server:${VERSION}
only:
refs:
- master
- develop
+5
View File
@@ -3,3 +3,8 @@ test:
image: slumber/blender-addon-testing:latest
script:
- python3 scripts/test_addon.py
only:
refs:
- master
- develop
+6 -45
View File
@@ -65,63 +65,24 @@ All notable changes to this project will be documented in this file.
- Unused strict right management strategy
- Legacy config management system
## [0.1.0] - 2020-10-05
## [0.0.4] - preview
### Added
- Dependency graph driven updates [experimental]
- Edit Mode updates
- Optional Edit Mode update
- Late join mechanism
- Sync Axis lock replication
- Sync collection offset
- Sync camera orthographic scale
- Sync custom fonts
- Sync sound files
- Logging configuration (file output and level)
- Object visibility type replication
- Optionnal sync for active camera
- Curve->Mesh conversion
- Mesh->gpencil conversion
- Logging basic configuration (file output and level)
### Changed
- Auto updater now handle installation from branches
- Use uuid for collection loading
- Moved session instance to replication package
- use uuid for collection loading
### Fixed
- Prevent unsupported data types to crash the session
- Modifier vertex group assignation
- World sync
- Snapshot UUID error
- The world is not synchronized
## [0.1.1] - 2020-10-16
### Added
- Session status widget
- Affect dependencies during change owner
- Dedicated server managment scripts(@brybalicious)
### Changed
- Refactored presence.py
- Reset button UI icon
- Documentation `How to contribute` improvements (@brybalicious)
- Documentation `Hosting guide` improvements (@brybalicious)
- Show flags are now available from the viewport overlay
### Fixed
- Render sync race condition (causing scene errors)
- Binary differentials
- Hybrid session crashes between Linux/Windows
- Materials node default output value
- Right selection
- Client node rights changed to COMMON after disconnecting from the server
- Collection instances selection draw
- Packed image save error
- Material replication
- UI spelling errors (@brybalicious)
- Prevent unsuported datatypes to crash the session
- Modifier vertex group assignation
+22 -27
View File
@@ -25,32 +25,27 @@ See the [documentation](https://multi-user.readthedocs.io/en/latest/) for detail
Currently, not all data-block are supported for replication over the wire. The following list summarizes the status for each ones.
| Name | Status | Comment |
| ----------- | :----: | :--------------------------------------------------------------------------: |
| action | ✔️ | |
| armature | ❗ | Not stable |
| camera | ✔️ | |
| collection | ✔️ | |
| curve | ❗ | Nurbs not supported |
| gpencil | ✔️ | [Airbrush not supported](https://gitlab.com/slumber/multi-user/-/issues/123) |
| image | ✔️ | |
| mesh | ✔️ | |
| material | ✔️ | |
| metaball | ✔️ | |
| object | ✔️ | |
| texts | ✔️ | |
| scene | ✔️ | |
| world | ✔️ | |
| lightprobes | ✔️ | |
| compositing | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/46) |
| texts | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/81) |
| nla | ❌ | |
| volumes | ❌ | |
| particles | ❌ | [On-going](https://gitlab.com/slumber/multi-user/-/issues/24) |
| speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) |
| vse | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) |
| physics | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) |
| libraries | ❗ | Partial |
| Name | Status | Comment |
| ----------- | :----: | :-----------------------------------------------------------: |
| action | ❗ | Not stable |
| armature | ❗ | Not stable |
| camera | ✔️ | |
| collection | ✔️ | |
| curve | ✔️ | Nurbs surface don't load correctly |
| gpencil | ✔️ | |
| image | ❗ | Not stable yet |
| mesh | ✔️ | |
| material | ✔️ | |
| metaball | ✔️ | |
| object | ✔️ | |
| scene | ✔️ | |
| world | ✔️ | |
| lightprobes | ✔️ | |
| particles | ❌ | [On-going](https://gitlab.com/slumber/multi-user/-/issues/24) |
| speakers | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/65) |
| vse | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) |
| physics | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) |
| libraries | ❗ | Partial |
### Performance issues
@@ -62,7 +57,7 @@ I'm working on it.
| Dependencies | Version | Needed |
| ------------ | :-----: | -----: |
| Replication | latest | yes |
| Replication | latest | yes |
+1 -1
View File
@@ -22,7 +22,7 @@ copyright = '2020, Swann Martinez'
author = 'Swann Martinez'
# The full version, including alpha/beta/rc tags
release = '0.1.0'
release = '0.0.2'
# -- General configuration ---------------------------------------------------
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 B

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-39
View File
@@ -161,19 +161,6 @@ The collaboration quality directly depend on the communication quality. This sec
various tools made in an effort to ease the communication between the different session users.
Feel free to suggest any idea for communication tools `here <https://gitlab.com/slumber/multi-user/-/issues/75>`_ .
---------------------------
Change replication behavior
---------------------------
During a session, the multi-user will replicate your modifications to other instances.
In order to avoid annoying other users when you are experimenting, some of those modifications can be ignored via
various flags present at the top of the panel (see red area in the image bellow). Those flags are explained in the :ref:`replication` section.
.. figure:: img/quickstart_replication.png
:align: center
Session replication flags
--------------------
Monitor online users
--------------------
@@ -255,8 +242,6 @@ various drawn parts via the following flags:
- **Show users**: display users current viewpoint
- **Show different scenes**: display users working on other scenes
-----------
Manage data
-----------
@@ -345,8 +330,6 @@ of the multi-user are using the same IPC port it will create conflict !
**Timeout (in milliseconds)** is the maximum ping authorized before auto-disconnecting.
You should only increase it if you have a bad connection.
.. _replication:
-----------
Replication
-----------
@@ -358,8 +341,6 @@ Replication
**Synchronize render settings** (only host) enable replication of EEVEE and CYCLES render settings to match render between clients.
**Synchronize active camera** sync the scene active camera.
**Edit Mode Updates** enable objects update while you are in Edit_Mode.
.. warning:: Edit Mode Updates kill performances with complex objects (heavy meshes, gpencil, etc...).
@@ -374,26 +355,6 @@ Replication
- **Refresh**: pushed data update rate (in second)
- **Apply**: pulled data update rate (in second)
-----
Cache
-----
The multi-user allows to replicate external blend dependencies such as images, movies sounds.
On each client, those files are stored into the cache folder.
.. figure:: img/quickstart_advanced_cache.png
:align: center
Advanced cache settings
**cache_directory** allows to choose where cached files (images, sound, movies) will be saved.
**Clear memory filecache** will save memory space at runtime by removing the file content from memory as soon as it have been written to the disk.
**Clear cache** will remove all file from the cache folder.
.. warning:: Clear cash could break your scene image/movie/sound if they are used into the blend !
---
Log
---
+15 -54
View File
@@ -144,7 +144,7 @@ Let's check the connection status. Right click on the tray icon and click on **S
Network status.
The network status must be **OK** for each user(like in the picture above) otherwise it means that you are not connected to the network.
If you see something like **ACCESS_DENIED**, it means that you were not authorized to join the network. Please check the section :ref:`network-authorization`
If you see something like **ACCESS_DENIED**, it means that you were not authorized to join the network. Please check the :ref:`network-authorization` section.
This is it for the ZeroTier network setup. Now everything should be setup to use the multi-user add-on over internet ! You can now follow the :ref:`quickstart` guide to start using the multi-user add-on !
@@ -171,28 +171,26 @@ From the dedicated server
run it at home for LAN but for internet hosting you need to follow the :ref:`port-forwarding` setup first.
The dedicated server allow you to host a session with simplicity from any location.
It was developed to improve internet hosting performance.
It was developed to improve intaernet hosting performance.
The dedicated server can be run in two ways:
The dedicated server can be run in tow ways:
- :ref:`cmd-line`
- :ref:`docker`
.. Note:: There are shell scripts to conveniently start a dedicated server via either of these approaches available in the gitlab repository. See section: :ref:`serverstartscripts`
.. _cmd-line:
Using a regular command line
----------------------------
You can run the dedicated server on any platform by following these steps:
You can run the dedicated server on any platform by following those steps:
1. Firstly, download and intall python 3 (3.6 or above).
2. Install the latest version of the replication library:
2. Install the replication library:
.. code-block:: bash
python -m pip install replication==0.0.21a15
python -m pip install replication
4. Launch the server with:
@@ -201,20 +199,17 @@ You can run the dedicated server on any platform by following these steps:
replication.serve
.. 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 optionnal argument
.. code-block:: bash
replication.serve -p 5555 -pwd admin -t 1000 -l INFO -lf server.log
Here, for example, a server is instantiated on port 5555, with password 'admin', a 1 second timeout, and logging enabled.
As soon as the dedicated server is running, you can connect to it from blender by following :ref:`how-to-join`.
replication.serve -p 5555 -pwd toto -t 1000 -l INFO -lf server.log
As soon as the dedicated server is running, you can connect to it from blender (follow :ref:`how-to-join`).
.. hint::
Some commands are available to enable an administrator to manage the session. Check :ref:`dedicated-management` to learn more.
Some commands are available to manage the session. Check :ref:`dedicated-management` to learn more.
.. _docker:
@@ -222,56 +217,22 @@ As soon as the dedicated server is running, you can connect to it from blender b
Using a pre-configured image on docker engine
---------------------------------------------
Launching the dedicated server from a docker server is simple as running:
Launching the dedicated server from a docker server is simple as:
.. code-block:: bash
docker run -d \
-p 5555-5560:5555-5560 \
-e port=5555 \
-e log_level=DEBUG \
-e password=admin \
-e timeout=1000 \
registry.gitlab.com/slumber/multi-user/multi-user-server:0.1.0
registry.gitlab.com/slumber/multi-user/multi-user-server:0.0.3
As soon as the dedicated server is running, you can connect to it from blender by following :ref:`how-to-join`.
You can check your container is running, and find its ID with:
.. code-block:: bash
docker ps
Logs for the server running in the docker container can be accessed by outputting the following to a log file:
.. code-block:: bash
docker log your-container-id >& dockerserver.log
.. Note:: If using WSL2 on Windows 10 (Windows Subsystem for Linux), it is preferable to run a dedicated server via regular command line approach (or the associated startup script) from within Windows - docker desktop for windows 10 usually uses the WSL2 backend where it is available.
.. _serverstartscripts:
Server startup scripts
----------------------
Convenient scripts are available in the Gitlab repository: https://gitlab.com/slumber/multi-user/scripts/startup_scripts/
Simply run the relevant script in a shell on the host machine to start a server with one line of code via replication directly or via a docker container. Choose between the two methods:
.. code-block:: bash
./start-server.sh
or
.. code-block:: bash
./run-dockerfile.sh
As soon as the dedicated server is running, you can connect to it from blender.
You can check the :ref:`how-to-join` section.
.. hint::
Once your server is up and running, some commands are available to manage the session :ref:`dedicated-management`
Some commands are available to manage the session. Check :ref:`dedicated-management` to learn more.
.. _dedicated-management:
+7 -74
View File
@@ -21,11 +21,11 @@ In order to help with the testing, you have several possibilities:
- Test `development branch <https://gitlab.com/slumber/multi-user/-/branches>`_
--------------------------
Filing an issue on Gitlab
Filling an issue on Gitlab
--------------------------
The `gitlab issue tracker <https://gitlab.com/slumber/multi-user/issues>`_ is used for bug report and enhancement suggestion.
You will need a Gitlab account to be able to open a new issue there and click on "New issue" button in the main multi-user project.
You will need a Gitlab account to be able to open a new issue there and click on "New issue" button.
Here are some useful information you should provide in a bug report:
@@ -35,75 +35,8 @@ Here are some useful information you should provide in a bug report:
Contributing code
=================
In general, this project follows the `Gitflow Workflow <https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow>`_. It may help to understand that there are three different repositories - the upstream (main multi-user project repository, designated in git by 'upstream'), remote (forked repository, designated in git by 'origin'), and the local repository on your machine.
The following example suggests how to contribute a feature.
1. Fork the project into a new repository:
https://gitlab.com/yourname/multi-user
2. Clone the new repository locally:
.. code-block:: bash
git clone https://gitlab.com/yourname/multi-user.git
3. Keep your fork in sync with the main repository by setting up the upstream pointer once. cd into your git repo and then run:
.. code-block:: bash
git remote add upstream https://gitlab.com/slumber/multi-user.git
4. Now, locally check out the develop branch, upon which to base your new feature branch:
.. code-block:: bash
git checkout develop
5. Fetch any changes from the main upstream repository into your fork (especially if some time has passed since forking):
.. code-block:: bash
git fetch upstream
'Fetch' downloads objects and refs from the repository, but doesn’t apply them to the branch we are working on. We want to apply the updates to the branch we will work from, which we checked out in step 4.
6. Let's merge any recent changes from the remote upstream (original repository's) 'develop' branch into our local 'develop' branch:
.. code-block:: bash
git merge upstream/develop
7. Update your forked repository's remote 'develop' branch with the fetched changes, just to keep things tidy. Make sure you haven't committed any local changes in the interim:
.. code-block:: bash
git push origin develop
8. Locally create your own new feature branch from the develop branch, using the syntax:
.. code-block:: bash
git checkout -b feature/yourfeaturename
...where 'feature/' designates a feature branch, and 'yourfeaturename' is a name of your choosing
9. Add and commit your changes, including a commit message:
.. code-block:: bash
git commit -am 'Add fooBar'
10. Push committed changes to the remote copy of your new feature branch which will be created in this step:
.. code-block:: bash
git push -u origin feature/yourfeaturename
If it's been some time since performing steps 4 through 7, make sure to checkout 'develop' again and pull the latest changes from upstream before checking out and creating feature/yourfeaturename and pushing changes. Alternatively, checkout 'feature/yourfeaturename' and simply run:
.. code-block:: bash
git rebase upstream/develop
and your staged commits will be merged along with the changes. More information on `rebasing here <https://git-scm.com/book/en/v2/Git-Branching-Rebasing>`_
.. Hint:: -u option sets up your locally created new branch to follow a remote branch which is now created with the same name on your remote repository.
11. Finally, create a new Pull/Merge Request on Gitlab to merge the remote version of this new branch with commited updates, back into the upstream develop branch, finalising the integration of the new feature.
12. Thanks for contributing!
.. Note:: For hotfixes, replace 'feature/' with 'hotfix/' and base the new branch off the parent 'master' branch instead of 'develop' branch. Make sure to checkout 'master' before running step 8
.. Note:: Let's follow the Atlassian `Gitflow Workflow <https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow>`_, except for one main difference - submitting a pull request rather than merging by ourselves.
.. Note:: See `here <https://philna.sh/blog/2018/08/21/git-commands-to-keep-a-fork-up-to-date/>`_ or `here <https://stefanbauer.me/articles/how-to-keep-your-git-fork-up-to-date>`_ for instructions on how to keep a fork up to date.
1. Fork it (https://gitlab.com/yourname/yourproject/fork)
2. Create your feature branch (git checkout -b feature/fooBar)
3. Commit your changes (git commit -am 'Add some fooBar')
4. Push to the branch (git push origin feature/fooBar)
5. Create a new Pull Request
+17 -19
View File
@@ -19,7 +19,7 @@
bl_info = {
"name": "Multi-User",
"author": "Swann Martinez",
"version": (0, 1, 1),
"version": (0, 0, 3),
"description": "Enable real-time collaborative workflow inside blender",
"blender": (2, 82, 0),
"location": "3D View > Sidebar > Multi-User tab",
@@ -40,16 +40,14 @@ import sys
import bpy
from bpy.app.handlers import persistent
from . import environment
from . import environment, utils
DEPENDENCIES = {
("replication", '0.1.3'),
("replication", '0.0.21a8'),
}
module_error_msg = "Insufficient rights to install the multi-user \
dependencies, aunch blender with administrator rights."
def register():
# Setup logging policy
logging.basicConfig(
@@ -59,22 +57,22 @@ def register():
try:
environment.setup(DEPENDENCIES, bpy.app.binary_path_python)
except ModuleNotFoundError:
logging.fatal("Fail to install multi-user dependencies, try to execute blender with admin rights.")
return
from . import presence
from . import operators
from . import ui
from . import preferences
from . import addon_updater_ops
from . import presence
from . import operators
from . import ui
from . import preferences
from . import addon_updater_ops
preferences.register()
addon_updater_ops.register(bl_info)
presence.register()
operators.register()
ui.register()
preferences.register()
addon_updater_ops.register(bl_info)
presence.register()
operators.register()
ui.register()
except ModuleNotFoundError as e:
raise Exception(module_error_msg)
logging.error(module_error_msg)
bpy.types.WindowManager.session = bpy.props.PointerProperty(
type=preferences.SessionProps)
bpy.types.ID.uuid = bpy.props.StringProperty(
+1 -2
View File
@@ -36,8 +36,7 @@ __all__ = [
'bl_lightprobe',
'bl_speaker',
'bl_font',
'bl_sound',
'bl_file'
'bl_sound'
] # Order here defines execution order
from . import *
+8 -107
View File
@@ -46,98 +46,6 @@ SPLINE_POINT = [
"radius",
]
CURVE_METADATA = [
'align_x',
'align_y',
'bevel_depth',
'bevel_factor_end',
'bevel_factor_mapping_end',
'bevel_factor_mapping_start',
'bevel_factor_start',
'bevel_object',
'bevel_resolution',
'body',
'body_format',
'dimensions',
'eval_time',
'extrude',
'family',
'fill_mode',
'follow_curve',
'font',
'font_bold',
'font_bold_italic',
'font_italic',
'make_local',
'materials',
'name',
'offset',
'offset_x',
'offset_y',
'overflow',
'original',
'override_create',
'override_library',
'path_duration',
'preview',
'render_resolution_u',
'render_resolution_v',
'resolution_u',
'resolution_v',
'shape_keys',
'shear',
'size',
'small_caps_scale',
'space_character',
'space_line',
'space_word',
'type',
'taper_object',
'texspace_location',
'texspace_size',
'transform',
'twist_mode',
'twist_smooth',
'underline_height',
'underline_position',
'use_auto_texspace',
'use_deform_bounds',
'use_fake_user',
'use_fill_caps',
'use_fill_deform',
'use_map_taper',
'use_path',
'use_path_follow',
'use_radius',
'use_stretch',
]
SPLINE_METADATA = [
'hide',
'material_index',
# 'order_u',
# 'order_v',
# 'point_count_u',
# 'point_count_v',
'points',
'radius_interpolation',
'resolution_u',
'resolution_v',
'tilt_interpolation',
'type',
'use_bezier_u',
'use_bezier_v',
'use_cyclic_u',
'use_cyclic_v',
'use_endpoint_u',
'use_endpoint_v',
'use_smooth',
]
class BlCurve(BlDatablock):
bl_id = "curves"
bl_class = bpy.types.Curve
@@ -154,8 +62,12 @@ class BlCurve(BlDatablock):
loader = Loader()
loader.load(target, data)
# if isinstance(curve, T.TextCurve):
# curve.font = data['font']
# curve.font_bold = data['font']
# curve.font_bold_italic = data['font']
# curve.font_italic = data['font']
target.splines.clear()
# load splines
for spline in data['splines'].values():
new_spline = target.splines.new(spline['type'])
@@ -166,12 +78,8 @@ class BlCurve(BlDatablock):
bezier_points = new_spline.bezier_points
bezier_points.add(spline['bezier_points_count'])
np_load_collection(spline['bezier_points'], bezier_points, SPLINE_BEZIER_POINT)
if new_spline.type == 'POLY':
points = new_spline.points
points.add(spline['points_count'])
np_load_collection(spline['points'], points, SPLINE_POINT)
# Not working for now...
# Not really working for now...
# See https://blender.stackexchange.com/questions/7020/create-nurbs-surface-with-python
if new_spline.type == 'NURBS':
logging.error("NURBS not supported.")
@@ -187,8 +95,6 @@ class BlCurve(BlDatablock):
dumper = Dumper()
# Conflicting attributes
# TODO: remove them with the NURBS support
dumper.include_filter = CURVE_METADATA
dumper.exclude_filter = [
'users',
'order_u',
@@ -206,13 +112,8 @@ class BlCurve(BlDatablock):
for index, spline in enumerate(instance.splines):
dumper.depth = 2
dumper.include_filter = SPLINE_METADATA
spline_data = dumper.dump(spline)
if spline.type == 'POLY':
spline_data['points_count'] = len(spline.points)-1
spline_data['points'] = np_dump_collection(spline.points, SPLINE_POINT)
# spline_data['points'] = np_dump_collection(spline.points, SPLINE_POINT)
spline_data['bezier_points_count'] = len(spline.bezier_points)-1
spline_data['bezier_points'] = np_dump_collection(spline.bezier_points, SPLINE_BEZIER_POINT)
data['splines'][index] = spline_data
+4 -19
View File
@@ -16,16 +16,14 @@
# ##### END GPL LICENSE BLOCK #####
import logging
from collections.abc import Iterable
import bpy
import mathutils
from replication.constants import DIFF_BINARY, UP
from replication.data import ReplicatedDatablock
import logging
from .. import utils
from .dump_anything import Dumper, Loader
from .dump_anything import Loader, Dumper
from replication.data import ReplicatedDatablock
from replication.constants import (UP, DIFF_BINARY)
def has_action(target):
@@ -89,19 +87,6 @@ def load_driver(target_datablock, src_driver):
loader.load(new_point, src_driver['keyframe_points'][src_point])
def get_datablock_from_uuid(uuid, default, ignore=[]):
if not uuid:
return default
for category in dir(bpy.data):
root = getattr(bpy.data, category)
if isinstance(root, Iterable) and category not in ignore:
for item in root:
if getattr(item, 'uuid', None) == uuid:
return item
return default
class BlDatablock(ReplicatedDatablock):
"""BlDatablock
-143
View File
@@ -1,143 +0,0 @@
# ##### 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 logging
import os
import sys
from pathlib import Path
import bpy
import mathutils
from replication.constants import DIFF_BINARY, UP
from replication.data import ReplicatedDatablock
from .. import utils
from .dump_anything import Dumper, Loader
def get_filepath(filename):
"""
Construct the local filepath
"""
return str(Path(
utils.get_preferences().cache_directory,
filename
))
def ensure_unpacked(datablock):
if datablock.packed_file:
logging.info(f"Unpacking {datablock.name}")
filename = Path(bpy.path.abspath(datablock.filepath)).name
datablock.filepath = get_filepath(filename)
datablock.unpack(method="WRITE_ORIGINAL")
class BlFile(ReplicatedDatablock):
bl_id = 'file'
bl_name = "file"
bl_class = Path
bl_delay_refresh = 0
bl_delay_apply = 1
bl_automatic_push = True
bl_check_common = False
bl_icon = 'FILE'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.instance = kwargs.get('instance', None)
if self.instance and not self.instance.exists():
raise FileNotFoundError(str(self.instance))
self.preferences = utils.get_preferences()
self.diff_method = DIFF_BINARY
def resolve(self):
if self.data:
self.instance = Path(get_filepath(self.data['name']))
if not self.instance.exists():
logging.debug("File don't exist, loading it.")
self._load(self.data, self.instance)
def push(self, socket, identity=None):
super().push(socket, identity=None)
if self.preferences.clear_memory_filecache:
del self.data['file']
def _dump(self, instance=None):
"""
Read the file and return a dict as:
{
name : filename
extension :
file: file content
}
"""
logging.info(f"Extracting file metadata")
data = {
'name': self.instance.name,
}
logging.info(
f"Reading {self.instance.name} content: {self.instance.stat().st_size} bytes")
try:
file = open(self.instance, "rb")
data['file'] = file.read()
file.close()
except IOError:
logging.warning(f"{self.instance} doesn't exist, skipping")
else:
file.close()
return data
def _load(self, data, target):
"""
Writing the file
"""
# TODO: check for empty data
if target.exists() and not self.diff():
logging.info(f"{data['name']} already on the disk, skipping.")
return
try:
file = open(target, "wb")
file.write(data['file'])
if self.preferences.clear_memory_filecache:
del self.data['file']
except IOError:
logging.warning(f"{target} doesn't exist, skipping")
else:
file.close()
def diff(self):
if self.preferences.clear_memory_filecache:
return False
else:
memory_size = sys.getsizeof(self.data['file'])-33
disk_size = self.instance.stat().st_size
return memory_size == disk_size
+166
View File
@@ -0,0 +1,166 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# ##### END GPL LICENSE BLOCK #####
import bpy
import mathutils
import logging
import pathlib
import os
from .. import utils
from .dump_anything import Loader, Dumper
from replication.data import ReplicatedDatablock
from replication.constants import (UP, DIFF_BINARY)
class BlFileDatablock(ReplicatedDatablock):
"""BlDatablock
bl_id : blender internal storage identifier
bl_class : blender internal type
bl_delay_refresh : refresh rate in second for observers
bl_delay_apply : refresh rate in sec for apply
bl_automatic_push : boolean
bl_icon : type icon (blender icon name)
bl_check_common: enable check even in common rights
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
instance = kwargs.get('instance', None)
self.preferences = utils.get_preferences()
if instance and hasattr(instance, 'uuid'):
instance.uuid = self.uuid
self.diff_method = DIFF_BINARY
def resolve(self):
datablock_ref = None
datablock_root = getattr(bpy.data, self.bl_id)
datablock_ref = utils.find_from_attr('uuid', self.uuid, datablock_root)
if not datablock_ref:
try:
datablock_ref = datablock_root[self.data['name']]
except Exception:
name = self.data.get('name')
logging.debug(f"Constructing {name}")
datablock_ref = self._construct(data=self.data)
if datablock_ref:
setattr(datablock_ref, 'uuid', self.uuid)
self.instance = datablock_ref
def remove_instance(self):
"""
Remove instance from blender data
"""
assert(self.instance)
datablock_root = getattr(bpy.data, self.bl_id)
datablock_root.remove(self.instance)
def get_filepath(self):
ext = pathlib.Path(self.data['filepath']).suffix
if ext:
name = f"{self.uuid}{ext}"
return os.path.join(self.preferences.cache_directory, name)
else:
return self.data['filepath']
def _construct(self, data):
filepath = self.get_filepath()
# Step 1: load content
if 'file' in data.keys():
self._write_content(data['file'], filepath)
else:
logging.warning("No data to write, skipping.")
# Step 2: construct the file
root = getattr(bpy.data, self.bl_id)
# Step 3: construct the datablock
return root.load(filepath)
def _dump(self, instance=None):
# Step 1: dump related metadata
data = self._dump_metadata(instance=instance)
# Step 2: dump file content
file_content = self._read_content(instance.filepath)
if file_content:
data['file'] = file_content
return data
def _load(self, target, data):
self._load_metadata(target, data)
def _dump_metadata(self, data, target):
"""
Dump datablock metadata
"""
raise NotImplementedError()
def _read_content(self, filepath):
"""
Dump file content
"""
logging.info("Reading file content")
content = None
try:
file = open(bpy.path.abspath(self.instance.filepath), 'rb')
content = file.read()
except IOError:
logging.warning(f"{filepath} doesn't exist, skipping")
else:
file.close()
return content
def _load_metadata(self, target, data):
raise NotImplementedError
def _write_content(self, content, filepath):
"""
Write content on the disk
"""
logging.info("Writing file content")
try:
file = open(filepath, 'wb')
file.write(content)
except IOError:
logging.warning(f"{self.uuid} writing error, skipping.")
else:
file.close()
def resolve_deps(self):
return []
def is_valid(self):
return getattr(bpy.data, self.bl_id).get(self.data['name'])
def diff(self):
return False
+12 -37
View File
@@ -16,18 +16,17 @@
# ##### END GPL LICENSE BLOCK #####
import logging
import os
from pathlib import Path
import bpy
from .bl_datablock import BlDatablock
from .bl_file import get_filepath, ensure_unpacked
from .dump_anything import Dumper, Loader
import mathutils
import os
import logging
import pathlib
from .. import utils
from .dump_anything import Loader, Dumper
from .bl_file_datablock import BlFileDatablock
class BlFont(BlDatablock):
class BlFont(BlFileDatablock):
bl_id = "fonts"
bl_class = bpy.types.VectorFont
bl_delay_refresh = 1
@@ -36,39 +35,15 @@ class BlFont(BlDatablock):
bl_check_common = False
bl_icon = 'FILE_FONT'
def _construct(self, data):
filename = data.get('filename')
if filename == '<builtin>':
return bpy.data.fonts.load(filename)
else:
return bpy.data.fonts.load(get_filepath(filename))
def _load(self, data, target):
def _load_metadata(self, data, target):
# No metadate for fonts
pass
def _dump(self, instance=None):
if instance.filepath == '<builtin>':
filename = '<builtin>'
else:
filename = Path(instance.filepath).name
if not filename:
raise FileExistsError(instance.filepath)
def _dump_metadata(self, instance=None):
return {
'filename': filename,
'filepath': instance.filepath,
'name': instance.name
}
def diff(self):
return False
def _resolve_deps_implementation(self):
deps = []
if self.instance.filepath and self.instance.filepath != '<builtin>':
ensure_unpacked(self.instance)
deps.append(Path(bpy.path.abspath(self.instance.filepath)))
return deps
+47 -40
View File
@@ -16,17 +16,13 @@
# ##### END GPL LICENSE BLOCK #####
import logging
import os
from pathlib import Path
import bpy
import mathutils
import os
import logging
from .. import utils
from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock
from .dump_anything import Dumper, Loader
from .bl_file import get_filepath, ensure_unpacked
format_to_ext = {
'BMP': 'bmp',
@@ -57,6 +53,29 @@ class BlImage(BlDatablock):
bl_check_common = False
bl_icon = 'IMAGE_DATA'
def dump_image(self, image):
pixels = None
if image.source == "GENERATED" or image.packed_file is not None:
prefs = utils.get_preferences()
img_name = f"{self.uuid}.{format_to_ext[image.file_format]}"
# Cache the image on the disk
image.filepath_raw = os.path.join(prefs.cache_directory, img_name)
os.makedirs(prefs.cache_directory, exist_ok=True)
image.save()
if image.source == "FILE":
image_path = bpy.path.abspath(image.filepath_raw)
image_directory = os.path.dirname(image_path)
os.makedirs(image_directory, exist_ok=True)
image.save()
file = open(image_path, "rb")
pixels = file.read()
file.close()
else:
raise ValueError()
return pixels
def _construct(self, data):
return bpy.data.images.new(
name=data['name'],
@@ -65,23 +84,28 @@ class BlImage(BlDatablock):
)
def _load(self, data, target):
image = target
prefs = utils.get_preferences()
img_format = data['file_format']
img_name = f"{self.uuid}.{format_to_ext[img_format]}"
img_path = os.path.join(prefs.cache_directory, img_name)
os.makedirs(prefs.cache_directory, exist_ok=True)
file = open(img_path, 'wb')
file.write(data["pixels"])
file.close()
image.source = 'FILE'
image.filepath = img_path
image.colorspace_settings.name = data["colorspace_settings"]["name"]
loader = Loader()
loader.load(data, target)
target.source = 'FILE'
target.filepath_raw = get_filepath(data['filename'])
target.colorspace_settings.name = data["colorspace_settings"]["name"]
def _dump(self, instance=None):
assert(instance)
filename = Path(instance.filepath).name
data = {
"filename": filename
}
data = {}
data['pixels'] = self.dump_image(instance)
dumper = Dumper()
dumper.depth = 2
dumper.include_filter = [
@@ -90,9 +114,13 @@ class BlImage(BlDatablock):
'height',
'alpha',
'float_buffer',
'file_format',
'alpha_mode',
'filepath',
'source',
'colorspace_settings']
data.update(dumper.dump(instance))
return data
def diff(self):
@@ -100,24 +128,3 @@ class BlImage(BlDatablock):
return True
else:
return False
def _resolve_deps_implementation(self):
deps = []
if self.instance.filepath:
if self.instance.packed_file:
filename = Path(bpy.path.abspath(self.instance.filepath)).name
self.instance.filepath_raw = get_filepath(filename)
self.instance.save()
# An image can't be unpacked to the modified path
# TODO: make a bug report
self.instance.unpack(method="REMOVE")
elif self.instance.source == "GENERATED":
filename = f"{self.instance.name}.png"
self.instance.filepath = get_filepath(filename)
self.instance.save()
deps.append(Path(bpy.path.abspath(self.instance.filepath)))
return deps
+53 -91
View File
@@ -21,12 +21,12 @@ import mathutils
import logging
import re
from ..utils import get_datablock_from_uuid
from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock, get_datablock_from_uuid
from .bl_datablock import BlDatablock
NODE_SOCKET_INDEX = re.compile('\[(\d*)\]')
def load_node(node_data, node_tree):
""" Load a node into a node_tree from a dict
@@ -42,7 +42,7 @@ def load_node(node_data, node_tree):
image_uuid = node_data.get('image_uuid', None)
if image_uuid and not target_node.image:
target_node.image = get_datablock_from_uuid(image_uuid, None)
target_node.image = get_datablock_from_uuid(image_uuid,None)
for input in node_data["inputs"]:
if hasattr(target_node.inputs[input], "default_value"):
@@ -52,14 +52,6 @@ def load_node(node_data, node_tree):
logging.error(
f"Material {input} parameter not supported, skipping")
for output in node_data["outputs"]:
if hasattr(target_node.outputs[output], "default_value"):
try:
target_node.outputs[output].default_value = node_data["outputs"][output]["default_value"]
except:
logging.error(
f"Material {output} parameter not supported, skipping")
def load_links(links_data, node_tree):
""" Load node_tree links from a list
@@ -71,10 +63,8 @@ def load_links(links_data, node_tree):
"""
for link in links_data:
input_socket = node_tree.nodes[link['to_node']
].inputs[int(link['to_socket'])]
output_socket = node_tree.nodes[link['from_node']].outputs[int(
link['from_socket'])]
input_socket = node_tree.nodes[link['to_node']].inputs[int(link['to_socket'])]
output_socket = node_tree.nodes[link['from_node']].outputs[int(link['from_socket'])]
node_tree.links.new(input_socket, output_socket)
@@ -89,10 +79,8 @@ def dump_links(links):
links_data = []
for link in links:
to_socket = NODE_SOCKET_INDEX.search(
link.to_socket.path_from_id()).group(1)
from_socket = NODE_SOCKET_INDEX.search(
link.from_socket.path_from_id()).group(1)
to_socket = NODE_SOCKET_INDEX.search(link.to_socket.path_from_id()).group(1)
from_socket = NODE_SOCKET_INDEX.search(link.from_socket.path_from_id()).group(1)
links_data.append({
'to_node': link.to_node.name,
'to_socket': to_socket,
@@ -118,7 +106,6 @@ def dump_node(node):
"show_expanded",
"name_full",
"select",
"bl_label",
"bl_height_min",
"bl_height_max",
"bl_height_default",
@@ -150,17 +137,8 @@ def dump_node(node):
input_dumper.include_filter = ["default_value"]
if hasattr(i, 'default_value'):
dumped_node['inputs'][i.name] = input_dumper.dump(i)
dumped_node['outputs'] = {}
for i in node.outputs:
output_dumper = Dumper()
output_dumper.depth = 2
output_dumper.include_filter = ["default_value"]
if hasattr(i, 'default_value'):
dumped_node['outputs'][i.name] = output_dumper.dump(i)
dumped_node['inputs'][i.name] = input_dumper.dump(
i)
if hasattr(node, 'color_ramp'):
ramp_dumper = Dumper()
ramp_dumper.depth = 4
@@ -185,12 +163,6 @@ def dump_node(node):
return dumped_node
def get_node_tree_dependencies(node_tree: bpy.types.NodeTree) -> list:
has_image = lambda node : (node.type in ['TEX_IMAGE', 'TEX_ENVIRONMENT'] and node.image)
return [node.image for node in node_tree.nodes if has_image(node)]
class BlMaterial(BlDatablock):
bl_id = "materials"
bl_class = bpy.types.Material
@@ -205,22 +177,22 @@ class BlMaterial(BlDatablock):
def _load_implementation(self, data, target):
loader = Loader()
is_grease_pencil = data.get('is_grease_pencil')
use_nodes = data.get('use_nodes')
loader.load(target, data)
if is_grease_pencil:
target.name = data['name']
if data['is_grease_pencil']:
if not target.is_grease_pencil:
bpy.data.materials.create_gpencil_data(target)
loader.load(target.grease_pencil, data['grease_pencil'])
elif use_nodes:
loader.load(
target.grease_pencil, data['grease_pencil'])
if data["use_nodes"]:
if target.node_tree is None:
target.use_nodes = True
target.node_tree.nodes.clear()
loader.load(target, data)
# Load nodes
for node in data["node_tree"]["nodes"]:
load_node(data["node_tree"]["nodes"][node], target.node_tree)
@@ -234,69 +206,57 @@ class BlMaterial(BlDatablock):
assert(instance)
mat_dumper = Dumper()
mat_dumper.depth = 2
mat_dumper.include_filter = [
'name',
'blend_method',
'shadow_method',
'alpha_threshold',
'show_transparent_back',
'use_backface_culling',
'use_screen_refraction',
'use_sss_translucency',
'refraction_depth',
'preview_render_type',
'use_preview_world',
'pass_index',
'use_nodes',
'diffuse_color',
'specular_color',
'roughness',
'specular_intensity',
'metallic',
'line_color',
'line_priority',
'is_grease_pencil'
mat_dumper.exclude_filter = [
"is_embed_data",
"is_evaluated",
"name_full",
"bl_description",
"bl_icon",
"bl_idname",
"bl_label",
"preview",
"original",
"uuid",
"users",
"alpha_threshold",
"line_color",
"view_center",
]
data = mat_dumper.dump(instance)
if instance.use_nodes:
nodes = {}
data["node_tree"] = {}
for node in instance.node_tree.nodes:
nodes[node.name] = dump_node(node)
data["node_tree"]['nodes'] = nodes
data["node_tree"]["links"] = dump_links(instance.node_tree.links)
elif instance.is_grease_pencil:
if instance.is_grease_pencil:
gp_mat_dumper = Dumper()
gp_mat_dumper.depth = 3
gp_mat_dumper.include_filter = [
'color',
'fill_color',
'mix_color',
'mix_factor',
'mix_stroke_factor',
# 'texture_angle',
# 'texture_scale',
# 'texture_offset',
'pixel_size',
'hide',
'lock',
'ghost',
# 'texture_clamp',
'flip',
'use_overlap_strokes',
'show_stroke',
'show_fill',
'alignment_mode',
'pass_index',
'mode',
'stroke_style',
# 'stroke_image',
'color',
'use_overlap_strokes',
'show_fill',
'fill_style',
'gradient_type',
'fill_color',
'pass_index',
'alignment_mode',
# 'fill_image',
'texture_opacity',
'mix_factor',
'texture_offset',
'texture_angle',
'texture_scale',
'texture_clamp',
'gradient_type',
'mix_color',
'flip'
]
data['grease_pencil'] = gp_mat_dumper.dump(instance.grease_pencil)
return data
@@ -306,7 +266,9 @@ class BlMaterial(BlDatablock):
deps = []
if self.instance.use_nodes:
deps.extend(get_node_tree_dependencies(self.instance.node_tree))
for node in self.instance.node_tree.nodes:
if node.type in ['TEX_IMAGE','TEX_ENVIRONMENT']:
deps.append(node.image)
if self.is_library:
deps.append(self.instance.library)
+23 -27
View File
@@ -89,26 +89,24 @@ class BlMesh(BlDatablock):
np_load_collection(data["polygons"],target.polygons, POLYGON)
# UV Layers
if 'uv_layers' in data.keys():
for layer in data['uv_layers']:
if layer not in target.uv_layers:
target.uv_layers.new(name=layer)
for layer in data['uv_layers']:
if layer not in target.uv_layers:
target.uv_layers.new(name=layer)
np_load_collection_primitives(
target.uv_layers[layer].data,
'uv',
data["uv_layers"][layer]['data'])
np_load_collection_primitives(
target.uv_layers[layer].data,
'uv',
data["uv_layers"][layer]['data'])
# Vertex color
if 'vertex_colors' in data.keys():
for color_layer in data['vertex_colors']:
if color_layer not in target.vertex_colors:
target.vertex_colors.new(name=color_layer)
for color_layer in data['vertex_colors']:
if color_layer not in target.vertex_colors:
target.vertex_colors.new(name=color_layer)
np_load_collection_primitives(
target.vertex_colors[color_layer].data,
'color',
data["vertex_colors"][color_layer]['data'])
np_load_collection_primitives(
target.vertex_colors[color_layer].data,
'color',
data["vertex_colors"][color_layer]['data'])
target.validate()
target.update()
@@ -116,7 +114,7 @@ class BlMesh(BlDatablock):
def _dump_implementation(self, data, instance=None):
assert(instance)
if instance.is_editmode and not self.preferences.sync_flags.sync_during_editmode:
if instance.is_editmode and not self.preferences.enable_editmode_updates:
raise ContextError("Mesh is in edit mode")
mesh = instance
@@ -149,18 +147,16 @@ class BlMesh(BlDatablock):
data["loops"] = np_dump_collection(mesh.loops, LOOP)
# UV Layers
if mesh.uv_layers:
data['uv_layers'] = {}
for layer in mesh.uv_layers:
data['uv_layers'][layer.name] = {}
data['uv_layers'][layer.name]['data'] = np_dump_collection_primitive(layer.data, 'uv')
data['uv_layers'] = {}
for layer in mesh.uv_layers:
data['uv_layers'][layer.name] = {}
data['uv_layers'][layer.name]['data'] = np_dump_collection_primitive(layer.data, 'uv')
# Vertex color
if mesh.vertex_colors:
data['vertex_colors'] = {}
for color_map in mesh.vertex_colors:
data['vertex_colors'][color_map.name] = {}
data['vertex_colors'][color_map.name]['data'] = np_dump_collection_primitive(color_map.data, 'color')
data['vertex_colors'] = {}
for color_map in mesh.vertex_colors:
data['vertex_colors'][color_map.name] = {}
data['vertex_colors'][color_map.name]['data'] = np_dump_collection_primitive(color_map.data, 'color')
# Fix material index
m_list = []
+3 -20
View File
@@ -22,7 +22,8 @@ import bpy
import mathutils
from replication.exception import ContextError
from .bl_datablock import BlDatablock, get_datablock_from_uuid
from ..utils import get_datablock_from_uuid
from .bl_datablock import BlDatablock
from .dump_anything import Dumper, Loader
from replication.exception import ReparentException
@@ -160,8 +161,6 @@ class BlObject(BlDatablock):
# Load transformation data
loader.load(target, data)
loader.load(target.display, data['display'])
# Pose
if 'pose' in data:
if not target.pose:
@@ -200,7 +199,7 @@ class BlObject(BlDatablock):
assert(instance)
if _is_editmode(instance):
if self.preferences.sync_flags.sync_during_editmode:
if self.preferences.enable_editmode_updates:
instance.update_from_editmode()
else:
raise ContextError("Object is in edit-mode.")
@@ -231,27 +230,11 @@ class BlObject(BlDatablock):
'lock_location',
'lock_rotation',
'lock_scale',
'hide_render',
'display_type',
'display_bounds_type',
'show_bounds',
'show_name',
'show_axis',
'show_wire',
'show_all_edges',
'show_texture_space',
'show_in_front',
'type',
'rotation_quaternion' if instance.rotation_mode == 'QUATERNION' else 'rotation_euler',
]
data = dumper.dump(instance)
dumper.include_filter = [
'show_shadows',
]
data['display'] = dumper.dump(instance.display)
data['data_uuid'] = getattr(instance.data, 'uuid', None)
if self.is_library:
return data
+53 -312
View File
@@ -22,244 +22,7 @@ import mathutils
from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock
from .bl_collection import dump_collection_children, dump_collection_objects, load_collection_childrens, load_collection_objects
from replication.constants import (DIFF_JSON, MODIFIED)
from deepdiff import DeepDiff
import logging
RENDER_SETTINGS = [
'dither_intensity',
'engine',
'film_transparent',
'filter_size',
'fps',
'fps_base',
'frame_map_new',
'frame_map_old',
'hair_subdiv',
'hair_type',
'line_thickness',
'line_thickness_mode',
'metadata_input',
'motion_blur_shutter',
'pixel_aspect_x',
'pixel_aspect_y',
'preview_pixel_size',
'preview_start_resolution',
'resolution_percentage',
'resolution_x',
'resolution_y',
'sequencer_gl_preview',
'use_bake_clear',
'use_bake_lores_mesh',
'use_bake_multires',
'use_bake_selected_to_active',
'use_bake_user_scale',
'use_border',
'use_compositing',
'use_crop_to_border',
'use_file_extension',
'use_freestyle',
'use_full_sample',
'use_high_quality_normals',
'use_lock_interface',
'use_motion_blur',
'use_multiview',
'use_sequencer',
'use_sequencer_override_scene_strip',
'use_single_layer',
'views_format',
]
EVEE_SETTINGS = [
'gi_diffuse_bounces',
'gi_cubemap_resolution',
'gi_visibility_resolution',
'gi_irradiance_smoothing',
'gi_glossy_clamp',
'gi_filter_quality',
'gi_show_irradiance',
'gi_show_cubemaps',
'gi_irradiance_display_size',
'gi_cubemap_display_size',
'gi_auto_bake',
'taa_samples',
'taa_render_samples',
'use_taa_reprojection',
'sss_samples',
'sss_jitter_threshold',
'use_ssr',
'use_ssr_refraction',
'use_ssr_halfres',
'ssr_quality',
'ssr_max_roughness',
'ssr_thickness',
'ssr_border_fade',
'ssr_firefly_fac',
'volumetric_start',
'volumetric_end',
'volumetric_tile_size',
'volumetric_samples',
'volumetric_sample_distribution',
'use_volumetric_lights',
'volumetric_light_clamp',
'use_volumetric_shadows',
'volumetric_shadow_samples',
'use_gtao',
'use_gtao_bent_normals',
'use_gtao_bounce',
'gtao_factor',
'gtao_quality',
'gtao_distance',
'bokeh_max_size',
'bokeh_threshold',
'use_bloom',
'bloom_threshold',
'bloom_color',
'bloom_knee',
'bloom_radius',
'bloom_clamp',
'bloom_intensity',
'use_motion_blur',
'motion_blur_shutter',
'motion_blur_depth_scale',
'motion_blur_max',
'motion_blur_steps',
'shadow_cube_size',
'shadow_cascade_size',
'use_shadow_high_bitdepth',
'gi_diffuse_bounces',
'gi_cubemap_resolution',
'gi_visibility_resolution',
'gi_irradiance_smoothing',
'gi_glossy_clamp',
'gi_filter_quality',
'gi_show_irradiance',
'gi_show_cubemaps',
'gi_irradiance_display_size',
'gi_cubemap_display_size',
'gi_auto_bake',
'taa_samples',
'taa_render_samples',
'use_taa_reprojection',
'sss_samples',
'sss_jitter_threshold',
'use_ssr',
'use_ssr_refraction',
'use_ssr_halfres',
'ssr_quality',
'ssr_max_roughness',
'ssr_thickness',
'ssr_border_fade',
'ssr_firefly_fac',
'volumetric_start',
'volumetric_end',
'volumetric_tile_size',
'volumetric_samples',
'volumetric_sample_distribution',
'use_volumetric_lights',
'volumetric_light_clamp',
'use_volumetric_shadows',
'volumetric_shadow_samples',
'use_gtao',
'use_gtao_bent_normals',
'use_gtao_bounce',
'gtao_factor',
'gtao_quality',
'gtao_distance',
'bokeh_max_size',
'bokeh_threshold',
'use_bloom',
'bloom_threshold',
'bloom_color',
'bloom_knee',
'bloom_radius',
'bloom_clamp',
'bloom_intensity',
'use_motion_blur',
'motion_blur_shutter',
'motion_blur_depth_scale',
'motion_blur_max',
'motion_blur_steps',
'shadow_cube_size',
'shadow_cascade_size',
'use_shadow_high_bitdepth',
]
CYCLES_SETTINGS = [
'shading_system',
'progressive',
'use_denoising',
'denoiser',
'use_square_samples',
'samples',
'aa_samples',
'diffuse_samples',
'glossy_samples',
'transmission_samples',
'ao_samples',
'mesh_light_samples',
'subsurface_samples',
'volume_samples',
'sampling_pattern',
'use_layer_samples',
'sample_all_lights_direct',
'sample_all_lights_indirect',
'light_sampling_threshold',
'use_adaptive_sampling',
'adaptive_threshold',
'adaptive_min_samples',
'min_light_bounces',
'min_transparent_bounces',
'caustics_reflective',
'caustics_refractive',
'blur_glossy',
'max_bounces',
'diffuse_bounces',
'glossy_bounces',
'transmission_bounces',
'volume_bounces',
'transparent_max_bounces',
'volume_step_rate',
'volume_max_steps',
'dicing_rate',
'max_subdivisions',
'dicing_camera',
'offscreen_dicing_scale',
'film_exposure',
'film_transparent_glass',
'film_transparent_roughness',
'filter_type',
'pixel_filter_type',
'filter_width',
'seed',
'use_animated_seed',
'sample_clamp_direct',
'sample_clamp_indirect',
'tile_order',
'use_progressive_refine',
'bake_type',
'use_camera_cull',
'camera_cull_margin',
'use_distance_cull',
'distance_cull_margin',
'motion_blur_position',
'rolling_shutter_type',
'rolling_shutter_duration',
'texture_limit',
'texture_limit_render',
'ao_bounces',
'ao_bounces_render',
]
VIEW_SETTINGS = [
'look',
'view_transform',
'exposure',
'gamma',
'use_curve_mapping',
'white_level',
'black_level'
]
from ..utils import get_preferences
class BlScene(BlDatablock):
bl_id = "scenes"
@@ -270,11 +33,6 @@ class BlScene(BlDatablock):
bl_check_common = True
bl_icon = 'SCENE_DATA'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.diff_method = DIFF_JSON
def _construct(self, data):
instance = bpy.data.scenes.new(data["name"])
return instance
@@ -285,38 +43,32 @@ class BlScene(BlDatablock):
loader.load(target, data)
# Load master collection
load_collection_objects(
data['collection']['objects'], target.collection)
load_collection_childrens(
data['collection']['children'], target.collection)
load_collection_objects(data['collection']['objects'], target.collection)
load_collection_childrens(data['collection']['children'], target.collection)
if 'world' in data.keys():
target.world = bpy.data.worlds[data['world']]
# Annotation
if 'grease_pencil' in data.keys():
target.grease_pencil = bpy.data.grease_pencils[data['grease_pencil']]
if self.preferences.sync_flags.sync_render_settings:
if 'eevee' in data.keys():
loader.load(target.eevee, data['eevee'])
if 'eevee' in data.keys():
loader.load(target.eevee, data['eevee'])
if 'cycles' in data.keys():
loader.load(target.eevee, data['cycles'])
if 'cycles' in data.keys():
loader.load(target.cycles, data['cycles'])
if 'render' in data.keys():
loader.load(target.render, data['render'])
if 'render' in data.keys():
loader.load(target.render, data['render'])
if 'view_settings' in data.keys():
loader.load(target.view_settings, data['view_settings'])
if target.view_settings.use_curve_mapping and \
'curve_mapping' in data['view_settings']:
# TODO: change this ugly fix
target.view_settings.curve_mapping.white_level = data[
'view_settings']['curve_mapping']['white_level']
target.view_settings.curve_mapping.black_level = data[
'view_settings']['curve_mapping']['black_level']
target.view_settings.curve_mapping.update()
if 'view_settings' in data.keys():
loader.load(target.view_settings, data['view_settings'])
if target.view_settings.use_curve_mapping:
#TODO: change this ugly fix
target.view_settings.curve_mapping.white_level = data['view_settings']['curve_mapping']['white_level']
target.view_settings.curve_mapping.black_level = data['view_settings']['curve_mapping']['black_level']
target.view_settings.curve_mapping.update()
def _dump_implementation(self, data, instance=None):
assert(instance)
@@ -328,55 +80,58 @@ class BlScene(BlDatablock):
'name',
'world',
'id',
'camera',
'grease_pencil',
'frame_start',
'frame_end',
'frame_step',
]
if self.preferences.sync_flags.sync_active_camera:
scene_dumper.include_filter.append('camera')
data = scene_dumper.dump(instance)
scene_dumper.depth = 3
scene_dumper.include_filter = ['children', 'objects', 'name']
scene_dumper.include_filter = ['children','objects','name']
data['collection'] = {}
data['collection']['children'] = dump_collection_children(
instance.collection)
data['collection']['objects'] = dump_collection_objects(
instance.collection)
data['collection']['children'] = dump_collection_children(instance.collection)
data['collection']['objects'] = dump_collection_objects(instance.collection)
scene_dumper.depth = 1
scene_dumper.include_filter = None
pref = get_preferences()
if self.preferences.sync_flags.sync_render_settings:
scene_dumper.include_filter = RENDER_SETTINGS
if pref.sync_flags.sync_render_settings:
scene_dumper.exclude_filter = [
'gi_cache_info',
'feature_set',
'debug_use_hair_bvh',
'aa_samples',
'blur_glossy',
'glossy_bounces',
'device',
'max_bounces',
'preview_aa_samples',
'preview_samples',
'sample_clamp_indirect',
'samples',
'volume_bounces'
]
data['eevee'] = scene_dumper.dump(instance.eevee)
data['cycles'] = scene_dumper.dump(instance.cycles)
data['view_settings'] = scene_dumper.dump(instance.view_settings)
data['render'] = scene_dumper.dump(instance.render)
if instance.render.engine == 'BLENDER_EEVEE':
scene_dumper.include_filter = EVEE_SETTINGS
data['eevee'] = scene_dumper.dump(instance.eevee)
elif instance.render.engine == 'CYCLES':
scene_dumper.include_filter = CYCLES_SETTINGS
data['cycles'] = scene_dumper.dump(instance.cycles)
scene_dumper.include_filter = VIEW_SETTINGS
data['view_settings'] = scene_dumper.dump(instance.view_settings)
if instance.view_settings.use_curve_mapping:
data['view_settings']['curve_mapping'] = scene_dumper.dump(
instance.view_settings.curve_mapping)
data['view_settings']['curve_mapping'] = scene_dumper.dump(instance.view_settings.curve_mapping)
scene_dumper.depth = 5
scene_dumper.include_filter = [
'curves',
'points',
'location',
'location'
]
data['view_settings']['curve_mapping']['curves'] = scene_dumper.dump(
instance.view_settings.curve_mapping.curves)
data['view_settings']['curve_mapping']['curves'] = scene_dumper.dump(instance.view_settings.curve_mapping.curves)
return data
def _resolve_deps_implementation(self):
@@ -385,31 +140,17 @@ class BlScene(BlDatablock):
# child collections
for child in self.instance.collection.children:
deps.append(child)
# childs objects
for object in self.instance.collection.objects:
for object in self.instance.objects:
deps.append(object)
# world
if self.instance.world:
deps.append(self.instance.world)
# annotations
if self.instance.grease_pencil:
deps.append(self.instance.grease_pencil)
return deps
def diff(self):
exclude_path = []
if not self.preferences.sync_flags.sync_render_settings:
exclude_path.append("root['eevee']")
exclude_path.append("root['cycles']")
exclude_path.append("root['view_settings']")
exclude_path.append("root['render']")
if not self.preferences.sync_flags.sync_active_camera:
exclude_path.append("root['camera']")
return DeepDiff(self.data, self._dump(instance=self.instance), exclude_paths=exclude_path)
+31 -26
View File
@@ -16,16 +16,14 @@
# ##### END GPL LICENSE BLOCK #####
import logging
import os
from pathlib import Path
import bpy
from .bl_file import get_filepath, ensure_unpacked
import mathutils
import os
import logging
import pathlib
from .. import utils
from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock
from .dump_anything import Dumper, Loader
class BlSound(BlDatablock):
bl_id = "sounds"
@@ -37,33 +35,40 @@ class BlSound(BlDatablock):
bl_icon = 'SOUND'
def _construct(self, data):
filename = data.get('filename')
if 'file' in data.keys():
prefs = utils.get_preferences()
ext = data['filepath'].split(".")[-1]
sound_name = f"{self.uuid}.{ext}"
sound_path = os.path.join(prefs.cache_directory, sound_name)
os.makedirs(prefs.cache_directory, exist_ok=True)
file = open(sound_path, 'wb')
file.write(data["file"])
file.close()
return bpy.data.sounds.load(get_filepath(filename))
logging.info(f'loading {sound_path}')
return bpy.data.sounds.load(sound_path)
def _load(self, data, target):
loader = Loader()
loader.load(target, data)
def diff(self):
return False
def _dump(self, instance=None):
filename = Path(instance.filepath).name
if not instance.packed_file:
# prefs = utils.get_preferences()
# ext = pathlib.Path(instance.filepath).suffix
# sound_name = f"{self.uuid}{ext}"
# sound_path = os.path.join(prefs.cache_directory, sound_name)
# instance.filepath = sound_path
instance.pack()
#TODO:use file locally with unpack(method='USE_ORIGINAL') ?
if not filename:
raise FileExistsError(instance.filepath)
return {
'filename': filename,
'name': instance.name
'filepath':instance.filepath,
'name':instance.name,
'file': instance.packed_file.data
}
def _resolve_deps_implementation(self):
deps = []
if self.instance.filepath and self.instance.filepath != '<builtin>':
ensure_unpacked(self.instance)
deps.append(Path(bpy.path.abspath(self.instance.filepath)))
return deps
def diff(self):
return False
+6 -10
View File
@@ -21,11 +21,7 @@ import mathutils
from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock
from .bl_material import (load_links,
load_node,
dump_node,
dump_links,
get_node_tree_dependencies)
from .bl_material import load_links, load_node, dump_node, dump_links
class BlWorld(BlDatablock):
@@ -41,9 +37,6 @@ class BlWorld(BlDatablock):
return bpy.data.worlds.new(data["name"])
def _load_implementation(self, data, target):
loader = Loader()
loader.load(target, data)
if data["use_nodes"]:
if target.node_tree is None:
target.use_nodes = True
@@ -56,6 +49,7 @@ class BlWorld(BlDatablock):
# Load nodes links
target.node_tree.links.clear()
load_links(data["node_tree"]["links"], target.node_tree)
def _dump_implementation(self, data, instance=None):
@@ -66,7 +60,6 @@ class BlWorld(BlDatablock):
world_dumper.include_filter = [
"use_nodes",
"name",
"color"
]
data = world_dumper.dump(instance)
if instance.use_nodes:
@@ -86,7 +79,10 @@ class BlWorld(BlDatablock):
deps = []
if self.instance.use_nodes:
deps.extend(get_node_tree_dependencies(self.instance.node_tree))
for node in self.instance.node_tree.nodes:
if node.type in ['TEX_IMAGE','TEX_ENVIRONMENT']:
deps.append(node.image)
if self.is_library:
deps.append(self.instance.library)
return deps
+5 -5
View File
@@ -24,8 +24,8 @@ import numpy as np
BPY_TO_NUMPY_TYPES = {
'FLOAT': np.float32,
'INT': np.int32,
'FLOAT': np.float,
'INT': np.int,
'BOOL': np.bool}
PRIMITIVE_TYPES = ['FLOAT', 'INT', 'BOOLEAN']
@@ -47,7 +47,7 @@ def np_load_collection(dikt: dict, collection: bpy.types.CollectionProperty, att
:type attributes: list
"""
if not dikt or len(collection) == 0:
logging.debug(f'Skipping collection {collection}')
logging.warning(f'Skipping collection')
return
if attributes is None:
@@ -626,11 +626,11 @@ class Loader:
for k in self._ordered_keys(dump.keys()):
v = dump[k]
if not hasattr(default.read(), k):
continue
logging.debug(f"Load default, skipping {default} : {k}")
try:
self._load_any(default.extend(k), v)
except Exception as err:
logging.debug(f"Skipping {k}")
logging.debug(f"Cannot load {k}: {err}")
@property
def match_subset_all(self):
+104 -93
View File
@@ -19,15 +19,7 @@ import logging
import bpy
from . import utils
from .presence import (renderer,
UserFrustumWidget,
UserNameWidget,
UserSelectionWidget,
refresh_3d_view,
generate_user_camera,
get_view_matrix,
refresh_sidebar_view)
from . import operators, presence, utils
from replication.constants import (FETCHED,
UP,
RP_COMMON,
@@ -39,16 +31,11 @@ from replication.constants import (FETCHED,
STATE_SRV_SYNC,
REPARENT)
from replication.interface import session
from replication.exception import NonAuthorizedOperationError
class Delayable():
"""Delayable task interface
"""
def __init__(self):
self.is_registered = False
def register(self):
raise NotImplementedError
@@ -66,21 +53,13 @@ class Timer(Delayable):
"""
def __init__(self, duration=1):
super().__init__()
self._timeout = duration
self._running = True
def register(self):
"""Register the timer into the blender timer system
"""
if not self.is_registered:
bpy.app.timers.register(self.main)
self.is_registered = True
logging.debug(f"Register {self.__class__.__name__}")
else:
logging.debug(
f"Timer {self.__class__.__name__} already registered")
bpy.app.timers.register(self.main)
def main(self):
self.execute()
@@ -108,28 +87,29 @@ class ApplyTimer(Timer):
super().__init__(timout)
def execute(self):
if session and session.state['STATE'] == STATE_ACTIVE:
client = operators.client
if client and client.state['STATE'] == STATE_ACTIVE:
if self._type:
nodes = session.list(filter=self._type)
nodes = client.list(filter=self._type)
else:
nodes = session.list()
nodes = client.list()
for node in nodes:
node_ref = session.get(uuid=node)
node_ref = client.get(uuid=node)
if node_ref.state == FETCHED:
try:
session.apply(node)
client.apply(node, force=True)
except Exception as e:
logging.error(f"Fail to apply {node_ref.uuid}: {e}")
elif node_ref.state == REPARENT:
# Reload the node
node_ref.remove_instance()
node_ref.resolve()
session.apply(node)
for parent in session._graph.find_parents(node):
client.apply(node, force=True)
for parent in client._graph.find_parents(node):
logging.info(f"Applying parent {parent}")
session.apply(parent, force=True)
client.apply(parent, force=True)
node_ref.state = UP
@@ -141,6 +121,7 @@ class DynamicRightSelectTimer(Timer):
self._right_strategy = RP_COMMON
def execute(self):
session = operators.client
settings = utils.get_preferences()
if session and session.state['STATE'] == STATE_ACTIVE:
@@ -167,13 +148,10 @@ class DynamicRightSelectTimer(Timer):
recursive = True
if node.data and 'instance_type' in node.data.keys():
recursive = node.data['instance_type'] != 'COLLECTION'
try:
session.change_owner(
node.uuid,
RP_COMMON,
recursive=recursive)
except NonAuthorizedOperationError:
logging.warning(f"Not authorized to change {node} owner")
session.change_owner(
node.uuid,
RP_COMMON,
recursive=recursive)
# change new selection to our
for obj in obj_ours:
@@ -184,13 +162,10 @@ class DynamicRightSelectTimer(Timer):
if node.data and 'instance_type' in node.data.keys():
recursive = node.data['instance_type'] != 'COLLECTION'
try:
session.change_owner(
node.uuid,
settings.username,
recursive=recursive)
except NonAuthorizedOperationError:
logging.warning(f"Not authorized to change {node} owner")
session.change_owner(
node.uuid,
settings.username,
recursive=recursive)
else:
return
@@ -209,20 +184,73 @@ class DynamicRightSelectTimer(Timer):
filter_owner=settings.username)
for key in owned_keys:
node = session.get(uuid=key)
try:
session.change_owner(
key,
RP_COMMON,
recursive=recursive)
except NonAuthorizedOperationError:
logging.warning(f"Not authorized to change {key} owner")
for obj in bpy.data.objects:
object_uuid = getattr(obj, 'uuid', None)
if object_uuid:
is_selectable = not session.is_readonly(object_uuid)
if obj.hide_select != is_selectable:
obj.hide_select = is_selectable
session.change_owner(
key,
RP_COMMON,
recursive=recursive)
for user, user_info in session.online_users.items():
if user != settings.username:
metadata = user_info.get('metadata')
if 'selected_objects' in metadata:
# Update selectionnable objects
for obj in bpy.data.objects:
if obj.hide_select and obj.uuid not in metadata['selected_objects']:
obj.hide_select = False
elif not obj.hide_select and obj.uuid in metadata['selected_objects']:
obj.hide_select = True
class Draw(Delayable):
def __init__(self):
self._handler = None
def register(self):
self._handler = bpy.types.SpaceView3D.draw_handler_add(
self.execute, (), 'WINDOW', 'POST_VIEW')
def execute(self):
raise NotImplementedError()
def unregister(self):
try:
bpy.types.SpaceView3D.draw_handler_remove(
self._handler, "WINDOW")
except:
pass
class DrawClient(Draw):
def execute(self):
session = getattr(operators, 'client', None)
renderer = getattr(presence, 'renderer', None)
prefs = utils.get_preferences()
if session and renderer and session.state['STATE'] == STATE_ACTIVE:
settings = bpy.context.window_manager.session
users = session.online_users
# Update users
for user in users.values():
metadata = user.get('metadata')
color = metadata.get('color')
scene_current = metadata.get('scene_current')
user_showable = scene_current == bpy.context.scene.name or settings.presence_show_far_user
if color and scene_current and user_showable:
if settings.presence_show_selected and 'selected_objects' in metadata.keys():
renderer.draw_client_selection(
user['id'], color, metadata['selected_objects'])
if settings.presence_show_user and 'view_corners' in metadata:
renderer.draw_client_camera(
user['id'], metadata['view_corners'], color)
if not user_showable:
# TODO: remove this when user event drivent update will be
# ready
renderer.flush_selection()
renderer.flush_users()
class ClientUpdate(Timer):
def __init__(self, timout=.1):
@@ -232,25 +260,27 @@ class ClientUpdate(Timer):
def execute(self):
settings = utils.get_preferences()
session = getattr(operators, 'client', None)
renderer = getattr(presence, 'renderer', None)
if session and renderer:
if session.state['STATE'] in [STATE_ACTIVE, STATE_LOBBY]:
local_user = session.online_users.get(
local_user = operators.client.online_users.get(
settings.username)
if not local_user:
return
else:
for username, user_data in session.online_users.items():
for username, user_data in operators.client.online_users.items():
if username != settings.username:
cached_user_data = self.users_metadata.get(
username)
new_user_data = session.online_users[username]['metadata']
new_user_data = operators.client.online_users[username]['metadata']
if cached_user_data is None:
self.users_metadata[username] = user_data['metadata']
elif 'view_matrix' in cached_user_data and 'view_matrix' in new_user_data and cached_user_data['view_matrix'] != new_user_data['view_matrix']:
refresh_3d_view()
presence.refresh_3d_view()
self.users_metadata[username] = user_data['metadata']
break
else:
@@ -259,13 +289,13 @@ class ClientUpdate(Timer):
local_user_metadata = local_user.get('metadata')
scene_current = bpy.context.scene.name
local_user = session.online_users.get(settings.username)
current_view_corners = generate_user_camera()
current_view_corners = presence.get_view_corners()
# Init client metadata
if not local_user_metadata or 'color' not in local_user_metadata.keys():
metadata = {
'view_corners': get_view_matrix(),
'view_matrix': get_view_matrix(),
'view_corners': presence.get_view_matrix(),
'view_matrix': presence.get_view_matrix(),
'color': (settings.client_color.r,
settings.client_color.g,
settings.client_color.b,
@@ -282,7 +312,7 @@ class ClientUpdate(Timer):
session.update_user_metadata(local_user_metadata)
elif 'view_corners' in local_user_metadata and current_view_corners != local_user_metadata['view_corners']:
local_user_metadata['view_corners'] = current_view_corners
local_user_metadata['view_matrix'] = get_view_matrix(
local_user_metadata['view_matrix'] = presence.get_view_matrix(
)
session.update_user_metadata(local_user_metadata)
@@ -292,27 +322,27 @@ class SessionStatusUpdate(Timer):
super().__init__(timout)
def execute(self):
refresh_sidebar_view()
presence.refresh_sidebar_view()
class SessionUserSync(Timer):
def __init__(self, timout=1):
super().__init__(timout)
self.settings = utils.get_preferences()
def execute(self):
session = getattr(operators, 'client', None)
renderer = getattr(presence, 'renderer', None)
if session and renderer:
# sync online users
session_users = session.online_users
session_users = operators.client.online_users
ui_users = bpy.context.window_manager.online_users
for index, user in enumerate(ui_users):
if user.username not in session_users.keys() and \
user.username != self.settings.username:
renderer.remove_widget(f"{user.username}_cam")
renderer.remove_widget(f"{user.username}_select")
renderer.remove_widget(f"{user.username}_name")
if user.username not in session_users.keys():
ui_users.remove(index)
renderer.flush_selection()
renderer.flush_users()
break
for user in session_users:
@@ -320,22 +350,3 @@ class SessionUserSync(Timer):
new_key = ui_users.add()
new_key.name = user
new_key.username = user
if user != self.settings.username:
renderer.add_widget(
f"{user}_cam", UserFrustumWidget(user))
renderer.add_widget(
f"{user}_select", UserSelectionWidget(user))
renderer.add_widget(
f"{user}_name", UserNameWidget(user))
class MainThreadExecutor(Timer):
def __init__(self, timout=1, execution_queue=None):
super().__init__(timout)
self.execution_queue = execution_queue
def execute(self):
while not self.execution_queue.empty():
function = self.execution_queue.get()
logging.debug(f"Executing {function.__name__}")
function()
+1 -1
View File
@@ -64,7 +64,7 @@ def install_package(name, version):
def check_package_version(name, required_version):
logging.info(f"Checking {name} version...")
out = subprocess.run([str(PYTHON_PATH), "-m", "pip", "show", name], capture_output=True)
out = subprocess.run(f"{str(PYTHON_PATH)} -m pip show {name}", capture_output=True)
version = VERSION_EXPR.search(out.stdout.decode())
if version and version.group() == required_version:
+137 -194
View File
@@ -21,101 +21,34 @@ import logging
import os
import queue
import random
import shutil
import string
import time
from operator import itemgetter
from pathlib import Path
from queue import Queue
from subprocess import PIPE, Popen, TimeoutExpired
import zmq
import bpy
import mathutils
from bpy.app.handlers import persistent
from replication.constants import (FETCHED, RP_COMMON, STATE_ACTIVE,
STATE_INITIAL, STATE_SYNCING, UP)
from . import bl_types, delayable, environment, presence, ui, utils
from replication.constants import (FETCHED, STATE_ACTIVE,
STATE_INITIAL,
STATE_SYNCING, RP_COMMON, UP)
from replication.data import ReplicatedDataFactory
from replication.exception import NonAuthorizedOperationError
from replication.interface import session
from replication.interface import Session
from . import bl_types, delayable, environment, ui, utils
from .presence import (SessionStatusWidget, renderer, view3d_find)
background_execution_queue = Queue()
client = None
delayables = []
stop_modal_executor = False
def session_callback(name):
""" Session callback wrapper
This allow to encapsulate session callbacks to background_execution_queue.
By doing this way callback are executed from the main thread.
"""
def func_wrapper(func):
@session.register(name)
def add_background_task():
background_execution_queue.put(func)
return add_background_task
return func_wrapper
@session_callback('on_connection')
def initialize_session():
"""Session connection init hander
"""
settings = utils.get_preferences()
runtime_settings = bpy.context.window_manager.session
# Step 1: Constrect nodes
for node in session._graph.list_ordered():
node_ref = session.get(node)
if node_ref.state == FETCHED:
node_ref.resolve()
# Step 2: Load nodes
for node in session._graph.list_ordered():
node_ref = session.get(node)
if node_ref.state == FETCHED:
node_ref.apply()
# Step 4: Register blender timers
for d in delayables:
d.register()
if settings.update_method == 'DEPSGRAPH':
bpy.app.handlers.depsgraph_update_post.append(depsgraph_evaluation)
bpy.ops.session.apply_armature_operator('INVOKE_DEFAULT')
@session_callback('on_exit')
def on_connection_end():
"""Session connection finished handler
"""
global delayables, stop_modal_executor
settings = utils.get_preferences()
# Step 1: Unregister blender timers
for d in delayables:
try:
d.unregister()
except:
continue
stop_modal_executor = True
if settings.update_method == 'DEPSGRAPH':
bpy.app.handlers.depsgraph_update_post.remove(
depsgraph_evaluation)
# Step 3: remove file handled
logger = logging.getLogger()
for handler in logger.handlers:
if isinstance(handler, logging.FileHandler):
logger.removeHandler(handler)
# OPERATORS
class SessionStartOperator(bpy.types.Operator):
bl_idname = "session.start"
bl_label = "start"
@@ -128,7 +61,7 @@ class SessionStartOperator(bpy.types.Operator):
return True
def execute(self, context):
global delayables
global client, delayables
settings = utils.get_preferences()
runtime_settings = context.window_manager.session
@@ -137,20 +70,20 @@ class SessionStartOperator(bpy.types.Operator):
use_extern_update = settings.update_method == 'DEPSGRAPH'
users.clear()
delayables.clear()
logger = logging.getLogger()
if len(logger.handlers) == 1:
if len(logger.handlers)==1:
formatter = logging.Formatter(
fmt='%(asctime)s CLIENT %(levelname)-8s %(message)s',
datefmt='%H:%M:%S'
)
log_directory = os.path.join(
settings.cache_directory,
"multiuser_client.log")
os.makedirs(settings.cache_directory, exist_ok=True)
handler = logging.FileHandler(log_directory, mode='w')
logger.addHandler(handler)
@@ -171,11 +104,7 @@ class SessionStartOperator(bpy.types.Operator):
supported_bl_types.append(type_module_class.bl_id)
if type_impl_name not in settings.supported_datablocks:
logging.info(f"{type_impl_name} not found, \
regenerate type settings...")
settings.generate_supported_types()
# Retreive local replicated types settings
type_local_config = settings.supported_datablocks[type_impl_name]
bpy_factory.register_type(
@@ -192,7 +121,7 @@ class SessionStartOperator(bpy.types.Operator):
timout=type_local_config.bl_delay_apply,
target_type=type_module_class))
session.configure(
client = Session(
factory=bpy_factory,
python_path=bpy.app.binary_path_python,
external_update_handling=use_extern_update)
@@ -209,11 +138,11 @@ class SessionStartOperator(bpy.types.Operator):
runtime_settings.is_host = True
runtime_settings.internet_ip = environment.get_ip()
try:
for scene in bpy.data.scenes:
session.add(scene)
for scene in bpy.data.scenes:
client.add(scene)
session.host(
try:
client.host(
id=settings.username,
port=settings.port,
ipc_port=settings.ipc_port,
@@ -231,11 +160,11 @@ class SessionStartOperator(bpy.types.Operator):
else:
if not runtime_settings.admin:
utils.clean_scene()
# regular session, no password needed
# regular client, no password needed
admin_pass = None
try:
session.connect(
client.connect(
id=settings.username,
address=settings.ip,
port=settings.port,
@@ -249,22 +178,62 @@ class SessionStartOperator(bpy.types.Operator):
# Background client updates service
delayables.append(delayable.ClientUpdate())
delayables.append(delayable.DrawClient())
delayables.append(delayable.DynamicRightSelectTimer())
session_update = delayable.SessionStatusUpdate()
session_user_sync = delayable.SessionUserSync()
session_background_executor = delayable.MainThreadExecutor(
execution_queue=background_execution_queue)
session_update.register()
session_user_sync.register()
session_background_executor.register()
delayables.append(session_background_executor)
delayables.append(session_update)
delayables.append(session_user_sync)
@client.register('on_connection')
def initialize_session():
settings = utils.get_preferences()
for node in client._graph.list_ordered():
node_ref = client.get(node)
if node_ref.state == FETCHED:
node_ref.resolve()
for node in client._graph.list_ordered():
node_ref = client.get(node)
if node_ref.state == FETCHED:
node_ref.apply()
# Launch drawing module
if runtime_settings.enable_presence:
presence.renderer.run()
# Register blender main thread tools
for d in delayables:
d.register()
if settings.update_method == 'DEPSGRAPH':
bpy.app.handlers.depsgraph_update_post.append(
depsgraph_evaluation)
@client.register('on_exit')
def desinitialize_session():
global delayables, stop_modal_executor
settings = utils.get_preferences()
for d in delayables:
try:
d.unregister()
except:
continue
stop_modal_executor = True
presence.renderer.stop()
if settings.update_method == 'DEPSGRAPH':
bpy.app.handlers.depsgraph_update_post.remove(
depsgraph_evaluation)
bpy.ops.session.apply_armature_operator()
self.report(
{'INFO'},
@@ -300,13 +269,15 @@ class SessionInitOperator(bpy.types.Operator):
return wm.invoke_props_dialog(self)
def execute(self, context):
global client
if self.init_method == 'EMPTY':
utils.clean_scene()
for scene in bpy.data.scenes:
session.add(scene)
client.add(scene)
session.init()
client.init()
return {"FINISHED"}
@@ -322,12 +293,11 @@ class SessionStopOperator(bpy.types.Operator):
return True
def execute(self, context):
global delayables, stop_modal_executor
global client, delayables, stop_modal_executor
if session:
if client:
try:
session.disconnect()
client.disconnect()
except Exception as e:
self.report({'ERROR'}, repr(e))
else:
@@ -339,7 +309,7 @@ class SessionStopOperator(bpy.types.Operator):
class SessionKickOperator(bpy.types.Operator):
bl_idname = "session.kick"
bl_label = "Kick"
bl_description = "Kick the target user"
bl_description = "Kick the user"
bl_options = {"REGISTER"}
user: bpy.props.StringProperty()
@@ -349,11 +319,11 @@ class SessionKickOperator(bpy.types.Operator):
return True
def execute(self, context):
global delayables, stop_modal_executor
assert(session)
global client, delayables, stop_modal_executor
assert(client)
try:
session.kick(self.user)
client.kick(self.user)
except Exception as e:
self.report({'ERROR'}, repr(e))
@@ -369,9 +339,8 @@ class SessionKickOperator(bpy.types.Operator):
class SessionPropertyRemoveOperator(bpy.types.Operator):
bl_idname = "session.remove_prop"
bl_label = "Delete cache"
bl_description = "Stop tracking modification on the target datablock." + \
"The datablock will no longer be updated for others client. "
bl_label = "remove"
bl_description = "broadcast a property to connected client_instances"
bl_options = {"REGISTER"}
property_path: bpy.props.StringProperty(default="None")
@@ -381,8 +350,9 @@ class SessionPropertyRemoveOperator(bpy.types.Operator):
return True
def execute(self, context):
global client
try:
session.remove(self.property_path)
client.remove(self.property_path)
return {"FINISHED"}
except: # NonAuthorizedOperationError:
@@ -394,12 +364,11 @@ class SessionPropertyRemoveOperator(bpy.types.Operator):
class SessionPropertyRightOperator(bpy.types.Operator):
bl_idname = "session.right"
bl_label = "Change modification rights"
bl_description = "Modify the owner of the target datablock"
bl_label = "Change owner to"
bl_description = "Change owner of specified datablock"
bl_options = {"REGISTER"}
key: bpy.props.StringProperty(default="None")
recursive: bpy.props.BoolProperty(default=True)
@classmethod
def poll(cls, context):
@@ -413,20 +382,15 @@ class SessionPropertyRightOperator(bpy.types.Operator):
layout = self.layout
runtime_settings = context.window_manager.session
row = layout.row()
row.label(text="Give the owning rights to:")
row.prop(runtime_settings, "clients", text="")
row = layout.row()
row.label(text="Affect dependencies")
row.prop(self, "recursive", text="")
col = layout.column()
col.prop(runtime_settings, "clients")
def execute(self, context):
runtime_settings = context.window_manager.session
global client
if session:
session.change_owner(self.key,
runtime_settings.clients,
recursive=self.recursive)
if client:
client.change_owner(self.key, runtime_settings.clients)
return {"FINISHED"}
@@ -472,10 +436,11 @@ class SessionSnapUserOperator(bpy.types.Operator):
return {'CANCELLED'}
if event.type == 'TIMER':
area, region, rv3d = view3d_find()
area, region, rv3d = presence.view3d_find()
global client
if session:
target_ref = session.online_users.get(self.target_client)
if client:
target_ref = client.online_users.get(self.target_client)
if target_ref:
target_scene = target_ref['metadata']['scene_current']
@@ -546,8 +511,10 @@ class SessionSnapTimeOperator(bpy.types.Operator):
return {'CANCELLED'}
if event.type == 'TIMER':
if session:
target_ref = session.online_users.get(self.target_client)
global client
if client:
target_ref = client.online_users.get(self.target_client)
if target_ref:
context.scene.frame_current = target_ref['metadata']['frame_current']
@@ -559,31 +526,28 @@ class SessionSnapTimeOperator(bpy.types.Operator):
class SessionApply(bpy.types.Operator):
bl_idname = "session.apply"
bl_label = "Revert"
bl_description = "Revert the selected datablock from his cached" + \
" version."
bl_label = "apply selected block into blender"
bl_description = "Apply selected block into blender"
bl_options = {"REGISTER"}
target: bpy.props.StringProperty()
reset_dependencies: bpy.props.BoolProperty(default=False)
@classmethod
def poll(cls, context):
return True
def execute(self, context):
logging.debug(f"Running apply on {self.target}")
session.apply(self.target,
force=True,
force_dependencies=self.reset_dependencies)
global client
client.apply(self.target)
return {"FINISHED"}
class SessionCommit(bpy.types.Operator):
bl_idname = "session.commit"
bl_label = "Force server update"
bl_description = "Commit and push the target datablock to server"
bl_label = "commit and push selected datablock to server"
bl_description = "commit and push selected datablock to server"
bl_options = {"REGISTER"}
target: bpy.props.StringProperty()
@@ -593,9 +557,10 @@ class SessionCommit(bpy.types.Operator):
return True
def execute(self, context):
# session.get(uuid=target).diff()
session.commit(uuid=self.target)
session.push(self.target)
global client
# client.get(uuid=target).diff()
client.commit(uuid=self.target)
client.push(self.target)
return {"FINISHED"}
@@ -613,15 +578,16 @@ class ApplyArmatureOperator(bpy.types.Operator):
return {'CANCELLED'}
if event.type == 'TIMER':
if session and session.state['STATE'] == STATE_ACTIVE:
nodes = session.list(filter=bl_types.bl_armature.BlArmature)
global client
if client and client.state['STATE'] == STATE_ACTIVE:
nodes = client.list(filter=bl_types.bl_armature.BlArmature)
for node in nodes:
node_ref = session.get(uuid=node)
node_ref = client.get(uuid=node)
if node_ref.state == FETCHED:
try:
session.apply(node)
client.apply(node)
except Exception as e:
logging.error("Fail to apply armature: {e}")
@@ -642,35 +608,6 @@ class ApplyArmatureOperator(bpy.types.Operator):
stop_modal_executor = False
class ClearCache(bpy.types.Operator):
"Clear local session cache"
bl_idname = "session.clear_cache"
bl_label = "Modal Executor Operator"
@classmethod
def poll(cls, context):
return True
def execute(self, context):
cache_dir = utils.get_preferences().cache_directory
try:
for root, dirs, files in os.walk(cache_dir):
for name in files:
Path(root, name).unlink()
except Exception as e:
self.report({'ERROR'}, repr(e))
return {"FINISHED"}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
row = self.layout
row.label(text=f" Do you really want to remove local cache ? ")
classes = (
SessionStartOperator,
SessionStopOperator,
@@ -683,7 +620,7 @@ classes = (
ApplyArmatureOperator,
SessionKickOperator,
SessionInitOperator,
ClearCache,
)
@@ -695,28 +632,32 @@ def sanitize_deps_graph(dummy):
A future solution should be to avoid storing dataclock reference...
"""
if session and session.state['STATE'] == STATE_ACTIVE:
for node_key in session.list():
session.get(node_key).resolve()
global client
if client and client.state['STATE'] == STATE_ACTIVE:
for node_key in client.list():
client.get(node_key).resolve()
@persistent
def load_pre_handler(dummy):
if session and session.state['STATE'] in [STATE_ACTIVE, STATE_SYNCING]:
global client
if client and client.state['STATE'] in [STATE_ACTIVE, STATE_SYNCING]:
bpy.ops.session.stop()
@persistent
def update_client_frame(scene):
if session and session.state['STATE'] == STATE_ACTIVE:
session.update_user_metadata({
if client and client.state['STATE'] == STATE_ACTIVE:
client.update_user_metadata({
'frame_current': scene.frame_current
})
@persistent
def depsgraph_evaluation(scene):
if session and session.state['STATE'] == STATE_ACTIVE:
if client and client.state['STATE'] == STATE_ACTIVE:
context = bpy.context
blender_depsgraph = bpy.context.view_layer.depsgraph
dependency_updates = [u for u in blender_depsgraph.updates]
@@ -728,19 +669,19 @@ def depsgraph_evaluation(scene):
# Is the object tracked ?
if update.id.uuid:
# Retrieve local version
node = session.get(update.id.uuid)
node = client.get(update.id.uuid)
# Check our right on this update:
# - if its ours or ( under common and diff), launch the
# update process
# - if its to someone else, ignore the update (go deeper ?)
if node and node.owner in [session.id, RP_COMMON] and node.state == UP:
if node and node.owner in [client.id, RP_COMMON] and node.state == UP:
# Avoid slow geometry update
if 'EDIT' in context.mode and \
not settings.sync_during_editmode:
not settings.enable_editmode_updates:
break
session.stash(node.uuid)
client.stash(node.uuid)
else:
# Distant update
continue
@@ -751,7 +692,6 @@ def depsgraph_evaluation(scene):
def register():
from bpy.utils import register_class
for cls in classes:
register_class(cls)
@@ -763,8 +703,11 @@ def register():
def unregister():
if session and session.state['STATE'] == STATE_ACTIVE:
session.disconnect()
global client
if client and client.state['STATE'] == 2:
client.disconnect()
client = None
from bpy.utils import unregister_class
for cls in reversed(classes):
+20 -81
View File
@@ -20,14 +20,10 @@ import logging
import bpy
import string
import re
import os
from pathlib import Path
from . import bl_types, environment, addon_updater_ops, presence, ui
from .utils import get_preferences, get_expanded_icon
from replication.constants import RP_COMMON
from replication.interface import session
IP_EXPR = re.compile('\d+\.\d+\.\d+\.\d+')
@@ -41,7 +37,7 @@ def randomColor():
def random_string_digits(stringLength=6):
"""Generate a random string of letters and digits"""
"""Generate a random string of letters and digits """
lettersAndDigits = string.ascii_letters + string.digits
return ''.join(random.choices(lettersAndDigits, k=stringLength))
@@ -68,20 +64,10 @@ def update_port(self, context):
if self.ipc_port < max_port and \
self['ipc_port'] >= self.port:
logging.error(
"IPC Port in conflict with the port, assigning a random value")
"IPC Port in conflic with the port, assigning a random value")
self['ipc_port'] = random.randrange(self.port+4, 10000)
def update_directory(self, context):
new_dir = Path(self.cache_directory)
if new_dir.exists() and any(Path(self.cache_directory).iterdir()):
logging.error("The folder is not empty, choose another one.")
self['cache_directory'] = environment.DEFAULT_CACHE_DIR
elif not new_dir.exists():
logging.info("Target cache folder doesn't exist, creating it.")
os.makedirs(self.cache_directory, exist_ok=True)
def set_log_level(self, value):
logging.getLogger().setLevel(value)
@@ -100,49 +86,11 @@ class ReplicatedDatablock(bpy.types.PropertyGroup):
icon: bpy.props.StringProperty()
def set_sync_render_settings(self, value):
self['sync_render_settings'] = value
if session and bpy.context.scene.uuid and value:
bpy.ops.session.apply('INVOKE_DEFAULT',
target=bpy.context.scene.uuid,
reset_dependencies=False)
def set_sync_active_camera(self, value):
self['sync_active_camera'] = value
if session and bpy.context.scene.uuid and value:
bpy.ops.session.apply('INVOKE_DEFAULT',
target=bpy.context.scene.uuid,
reset_dependencies=False)
class ReplicationFlags(bpy.types.PropertyGroup):
def get_sync_render_settings(self):
return self.get('sync_render_settings', True)
def get_sync_active_camera(self):
return self.get('sync_active_camera', True)
sync_render_settings: bpy.props.BoolProperty(
name="Synchronize render settings",
description="Synchronize render settings (eevee and cycles only)",
default=False,
set=set_sync_render_settings,
get=get_sync_render_settings
)
sync_during_editmode: bpy.props.BoolProperty(
name="Edit mode updates",
description="Enable objects update in edit mode (! Impact performances !)",
default=False
)
sync_active_camera: bpy.props.BoolProperty(
name="Synchronize active camera",
description="Synchronize the active camera",
default=True,
get=get_sync_active_camera,
set=set_sync_active_camera
)
default=True)
class SessionPrefs(bpy.types.AddonPreferences):
@@ -174,9 +122,9 @@ class SessionPrefs(bpy.types.AddonPreferences):
)
ipc_port: bpy.props.IntProperty(
name="ipc_port",
description='internal ttl port(only useful for multiple local instances)',
default=random.randrange(5570, 70000),
update=update_port,
description='internal ttl port(only usefull for multiple local instances)',
default=5561,
update=update_port
)
init_method: bpy.props.EnumProperty(
name='init_method',
@@ -188,8 +136,7 @@ class SessionPrefs(bpy.types.AddonPreferences):
cache_directory: bpy.props.StringProperty(
name="cache directory",
subtype="DIR_PATH",
default=environment.DEFAULT_CACHE_DIR,
update=update_directory)
default=environment.DEFAULT_CACHE_DIR)
connection_timeout: bpy.props.IntProperty(
name='connection timeout',
description='connection timeout before disconnection',
@@ -210,9 +157,9 @@ class SessionPrefs(bpy.types.AddonPreferences):
description='Dependency graph uppdate rate (milliseconds)',
default=100
)
clear_memory_filecache: bpy.props.BoolProperty(
name="Clear memory filecache",
description="Remove filecache from memory",
enable_editmode_updates: bpy.props.BoolProperty(
name="Edit mode updates",
description="Enable objects update in edit mode (! Impact performances !)",
default=False
)
# for UI
@@ -220,7 +167,7 @@ class SessionPrefs(bpy.types.AddonPreferences):
name="Category",
description="Preferences Category",
items=[
('CONFIG', "Configuration", "Configuration of this add-on"),
('CONFIG', "Configuration", "Configuration about this add-on"),
('UPDATE', "Update", "Update this add-on"),
],
default='CONFIG'
@@ -283,12 +230,6 @@ class SessionPrefs(bpy.types.AddonPreferences):
description="sidebar_advanced_net_expanded",
default=False
)
sidebar_advanced_cache_expanded: bpy.props.BoolProperty(
name="sidebar_advanced_cache_expanded",
description="sidebar_advanced_cache_expanded",
default=False
)
auto_check_update: bpy.props.BoolProperty(
name="Auto-check for Update",
description="If enabled, auto-check for updates using an interval",
@@ -339,7 +280,7 @@ class SessionPrefs(bpy.types.AddonPreferences):
# USER INFORMATIONS
box = grid.box()
box.prop(
self, "conf_session_identity_expanded", text="User information",
self, "conf_session_identity_expanded", text="User informations",
icon=get_expanded_icon(self.conf_session_identity_expanded),
emboss=False)
if self.conf_session_identity_expanded:
@@ -349,7 +290,7 @@ class SessionPrefs(bpy.types.AddonPreferences):
# NETWORK SETTINGS
box = grid.box()
box.prop(
self, "conf_session_net_expanded", text="Networking",
self, "conf_session_net_expanded", text="Netorking",
icon=get_expanded_icon(self.conf_session_net_expanded),
emboss=False)
@@ -402,7 +343,6 @@ class SessionPrefs(bpy.types.AddonPreferences):
emboss=False)
if self.conf_session_cache_expanded:
box.row().prop(self, "cache_directory", text="Cache directory")
box.row().prop(self, "clear_memory_filecache", text="Clear memory filecache")
# INTERFACE SETTINGS
box = grid.box()
@@ -443,9 +383,9 @@ def client_list_callback(scene, context):
items = [(RP_COMMON, RP_COMMON, "")]
username = get_preferences().username
if session:
client_ids = session.online_users.keys()
cli = operators.client
if cli:
client_ids = cli.online_users.keys()
for id in client_ids:
name_desc = id
if id == username:
@@ -481,26 +421,25 @@ class SessionProps(bpy.types.PropertyGroup):
name="Presence overlay",
description='Enable overlay drawing module',
default=True,
update=presence.update_presence
)
presence_show_selected: bpy.props.BoolProperty(
name="Show selected objects",
description='Enable selection overlay ',
default=True,
update=presence.update_overlay_settings
)
presence_show_user: bpy.props.BoolProperty(
name="Show users",
description='Enable user overlay ',
default=True,
update=presence.update_overlay_settings
)
presence_show_far_user: bpy.props.BoolProperty(
name="Show users on different scenes",
description="Show user on different scenes",
default=False,
)
presence_show_session_status: bpy.props.BoolProperty(
name="Show session status ",
description="Show session status on the viewport",
default=True,
update=presence.update_overlay_settings
)
filter_owned: bpy.props.BoolProperty(
name="filter_owned",
+224 -357
View File
@@ -19,7 +19,6 @@
import copy
import logging
import math
import sys
import traceback
import bgl
@@ -29,17 +28,13 @@ import gpu
import mathutils
from bpy_extras import view3d_utils
from gpu_extras.batch import batch_for_shader
from replication.constants import (STATE_ACTIVE, STATE_AUTH, STATE_CONFIG,
STATE_INITIAL, STATE_LAUNCHING_SERVICES,
STATE_LOBBY, STATE_QUITTING, STATE_SRV_SYNC,
STATE_SYNCING, STATE_WAITING)
from replication.interface import session
from .utils import find_from_attr, get_state_str
from . import utils
# Helper functions
renderer = None
def view3d_find() -> tuple:
def view3d_find():
""" Find the first 'VIEW_3D' windows found in areas
:return: tuple(Area, Region, RegionView3D)
@@ -61,48 +56,36 @@ def refresh_3d_view():
if area and region and rv3d:
area.tag_redraw()
def refresh_sidebar_view():
""" Refresh the blender viewport sidebar
""" Refresh the blender sidebar
"""
area, region, rv3d = view3d_find()
if area:
area.regions[3].tag_redraw()
def project_to_viewport(region: bpy.types.Region, rv3d: bpy.types.RegionView3D, coords: list, distance: float = 1.0) -> list:
""" Compute a projection from 2D to 3D viewport coordinate
:param region: target windows region
:type region: bpy.types.Region
:param rv3d: view 3D
:type rv3d: bpy.types.RegionView3D
:param coords: coordinate to project
:type coords: list
:param distance: distance offset into viewport
:type distance: float
:return: list of coordinates [x,y,z]
"""
def get_target(region, rv3d, coord):
target = [0, 0, 0]
if coords and region and rv3d:
view_vector = view3d_utils.region_2d_to_vector_3d(region, rv3d, coords)
ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coords)
target = ray_origin + view_vector * distance
if coord and region and rv3d:
view_vector = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord)
target = ray_origin + view_vector
return [target.x, target.y, target.z]
def bbox_from_obj(obj: bpy.types.Object, radius: float) -> list:
""" Generate a bounding box for a given object by using its world matrix
def get_target_far(region, rv3d, coord, distance):
target = [0, 0, 0]
:param obj: target object
:type obj: bpy.types.Object
:param radius: bounding box radius
:type radius: float
:return: list of 8 points [(x,y,z),...]
"""
if coord and region and rv3d:
view_vector = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord)
target = ray_origin + view_vector * distance
return [target.x, target.y, target.z]
def get_default_bbox(obj, radius):
coords = [
(-radius, -radius, -radius), (+radius, -radius, -radius),
(-radius, +radius, -radius), (+radius, +radius, -radius),
@@ -110,380 +93,264 @@ def bbox_from_obj(obj: bpy.types.Object, radius: float) -> list:
(-radius, +radius, +radius), (+radius, +radius, +radius)]
base = obj.matrix_world
bbox_corners = [base @ mathutils.Vector(corner) for corner in coords]
bbox_corners = [base @ mathutils.Vector(corner) for corner in coords]
return [(point.x, point.y, point.z)
for point in bbox_corners]
for point in bbox_corners]
def generate_user_camera() -> list:
""" Generate a basic camera represention of the user point of view
:return: list of 7 points
"""
def get_view_corners():
area, region, rv3d = view3d_find()
v1 = v2 = v3 = v4 = v5 = v6 = v7 = [0, 0, 0]
v1 = [0, 0, 0]
v2 = [0, 0, 0]
v3 = [0, 0, 0]
v4 = [0, 0, 0]
v5 = [0, 0, 0]
v6 = [0, 0, 0]
v7 = [0, 0, 0]
if area and region and rv3d:
width = region.width
height = region.height
v1 = project_to_viewport(region, rv3d, (0, 0))
v3 = project_to_viewport(region, rv3d, (0, height))
v2 = project_to_viewport(region, rv3d, (width, height))
v4 = project_to_viewport(region, rv3d, (width, 0))
v1 = get_target(region, rv3d, (0, 0))
v3 = get_target(region, rv3d, (0, height))
v2 = get_target(region, rv3d, (width, height))
v4 = get_target(region, rv3d, (width, 0))
v5 = project_to_viewport(region, rv3d, (width/2, height/2))
v5 = get_target(region, rv3d, (width/2, height/2))
v6 = list(rv3d.view_location)
v7 = project_to_viewport(
region, rv3d, (width/2, height/2), distance=-.8)
v7 = get_target_far(region, rv3d, (width/2, height/2), -.8)
coords = [v1, v2, v3, v4, v5, v6, v7]
return coords
def project_to_screen(coords: list) -> list:
""" Project 3D coordinate to 2D screen coordinates
:param coords: 3D coordinates (x,y,z)
:type coords: list
:return: list of 2D coordinates [x,y]
"""
def get_client_2d(coords):
area, region, rv3d = view3d_find()
if area and region and rv3d:
return view3d_utils.location_3d_to_region_2d(region, rv3d, coords)
else:
return (0, 0)
def get_bb_coords_from_obj(object: bpy.types.Object, instance: bpy.types.Object = None) -> list:
""" Generate bounding box in world coordinate from object bound box
:param object: target object
:type object: bpy.types.Object
:param instance: optionnal instance
:type instance: bpy.types.Object
:return: list of 8 points [(x,y,z),...]
"""
base = object.matrix_world
if instance:
scale = mathutils.Matrix.Diagonal(object.matrix_world.to_scale())
base = instance.matrix_world @ scale.to_4x4()
def get_bb_coords_from_obj(object, parent=None):
base = object.matrix_world if parent is None else parent.matrix_world
bbox_corners = [base @ mathutils.Vector(
corner) for corner in object.bound_box]
corner) for corner in object.bound_box]
return [(point.x, point.y, point.z) for point in bbox_corners]
return [(point.x, point.y, point.z)
for point in bbox_corners]
def get_view_matrix() -> list:
""" Return the 3d viewport view matrix
:return: view matrix as a 4x4 list
"""
def get_view_matrix():
area, region, rv3d = view3d_find()
if area and region and rv3d:
if area and region and rv3d:
return [list(v) for v in rv3d.view_matrix]
def update_presence(self, context):
global renderer
class Widget(object):
""" Base class to define an interface element
"""
draw_type: str = 'POST_VIEW' # Draw event type
def poll(self) -> bool:
"""Test if the widget can be drawn or not
:return: bool
"""
return True
def draw(self):
"""How to draw the widget
"""
raise NotImplementedError()
class UserFrustumWidget(Widget):
# Camera widget indices
indices = ((1, 3), (2, 1), (3, 0),
(2, 0), (4, 5), (1, 6),
(2, 6), (3, 6), (0, 6))
def __init__(
self,
username):
self.username = username
self.settings = bpy.context.window_manager.session
@property
def data(self):
user = session.online_users.get(self.username)
if user:
return user.get('metadata')
if 'renderer' in globals() and hasattr(renderer, 'run'):
if self.enable_presence:
renderer.run()
else:
return None
renderer.stop()
def poll(self):
if self.data is None:
return False
scene_current = self.data.get('scene_current')
view_corners = self.data.get('view_corners')
def update_overlay_settings(self, context):
global renderer
return (scene_current == bpy.context.scene.name or
self.settings.presence_show_far_user) and \
view_corners and \
self.settings.presence_show_user and \
self.settings.enable_presence
if renderer and not self.presence_show_selected:
renderer.flush_selection()
if renderer and not self.presence_show_user:
renderer.flush_users()
def draw(self):
location = self.data.get('view_corners')
class DrawFactory(object):
def __init__(self):
self.d3d_items = {}
self.d2d_items = {}
self.draw3d_handle = None
self.draw2d_handle = None
self.draw_event = None
self.coords = None
self.active_object = None
def run(self):
self.register_handlers()
def stop(self):
self.flush_users()
self.flush_selection()
self.unregister_handlers()
refresh_3d_view()
def register_handlers(self):
self.draw3d_handle = bpy.types.SpaceView3D.draw_handler_add(
self.draw3d_callback, (), 'WINDOW', 'POST_VIEW')
self.draw2d_handle = bpy.types.SpaceView3D.draw_handler_add(
self.draw2d_callback, (), 'WINDOW', 'POST_PIXEL')
def unregister_handlers(self):
if self.draw2d_handle:
bpy.types.SpaceView3D.draw_handler_remove(
self.draw2d_handle, "WINDOW")
self.draw2d_handle = None
if self.draw3d_handle:
bpy.types.SpaceView3D.draw_handler_remove(
self.draw3d_handle, "WINDOW")
self.draw3d_handle = None
self.d3d_items.clear()
self.d2d_items.clear()
def flush_selection(self, user=None):
key_to_remove = []
select_key = f"{user}_select" if user else "select"
for k in self.d3d_items.keys():
if select_key in k:
key_to_remove.append(k)
for k in key_to_remove:
del self.d3d_items[k]
def flush_users(self):
key_to_remove = []
for k in self.d3d_items.keys():
if "select" not in k:
key_to_remove.append(k)
for k in key_to_remove:
del self.d3d_items[k]
self.d2d_items.clear()
def draw_client_selection(self, client_id, client_color, client_selection):
local_user = utils.get_preferences().username
if local_user != client_id:
self.flush_selection(client_id)
for select_ob in client_selection:
drawable_key = f"{client_id}_select_{select_ob}"
ob = utils.find_from_attr("uuid", select_ob, bpy.data.objects)
if not ob:
return
if ob.type == 'EMPTY':
# TODO: Child case
# Collection instance case
indices = (
(0, 1), (1, 2), (2, 3), (0, 3),
(4, 5), (5, 6), (6, 7), (4, 7),
(0, 4), (1, 5), (2, 6), (3, 7))
if ob.instance_collection:
for obj in ob.instance_collection.objects:
if obj.type == 'MESH':
self.append_3d_item(
drawable_key,
client_color,
get_bb_coords_from_obj(obj, parent=ob),
indices)
if ob.type in ['MESH','META']:
indices = (
(0, 1), (1, 2), (2, 3), (0, 3),
(4, 5), (5, 6), (6, 7), (4, 7),
(0, 4), (1, 5), (2, 6), (3, 7))
self.append_3d_item(
drawable_key,
client_color,
get_bb_coords_from_obj(ob),
indices)
else:
indices = (
(0, 1), (0, 2), (1, 3), (2, 3),
(4, 5), (4, 6), (5, 7), (6, 7),
(0, 4), (1, 5), (2, 6), (3, 7))
self.append_3d_item(
drawable_key,
client_color,
get_default_bbox(ob, ob.scale.x),
indices)
def append_3d_item(self,key,color, coords, indices):
shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR')
positions = [tuple(coord) for coord in location]
if len(positions) != 7:
return
color = color
batch = batch_for_shader(
shader,
'LINES',
{"pos": positions},
indices=self.indices)
shader, 'LINES', {"pos": coords}, indices=indices)
self.d3d_items[key] = (shader, batch, color)
def draw_client_camera(self, client_id, client_location, client_color):
if client_location:
local_user = utils.get_preferences().username
if local_user != client_id:
try:
indices = (
(1, 3), (2, 1), (3, 0),
(2, 0), (4, 5), (1, 6),
(2, 6), (3, 6), (0, 6)
)
shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR')
position = [tuple(coord) for coord in client_location]
color = client_color
batch = batch_for_shader(
shader, 'LINES', {"pos": position}, indices=indices)
self.d3d_items[client_id] = (shader, batch, color)
self.d2d_items[client_id] = (position[1], client_id, color)
except Exception as e:
logging.debug(f"Draw client exception: {e} \n {traceback.format_exc()}\n pos:{position},ind:{indices}")
def draw3d_callback(self):
bgl.glLineWidth(2.)
bgl.glEnable(bgl.GL_DEPTH_TEST)
bgl.glEnable(bgl.GL_BLEND)
bgl.glEnable(bgl.GL_LINE_SMOOTH)
shader.bind()
shader.uniform_float("color", self.data.get('color'))
batch.draw(shader)
class UserSelectionWidget(Widget):
def __init__(
self,
username):
self.username = username
self.settings = bpy.context.window_manager.session
@property
def data(self):
user = session.online_users.get(self.username)
if user:
return user.get('metadata')
else:
return None
def poll(self):
if self.data is None:
return False
user_selection = self.data.get('selected_objects')
scene_current = self.data.get('scene_current')
return (scene_current == bpy.context.scene.name or
self.settings.presence_show_far_user) and \
user_selection and \
self.settings.presence_show_selected and \
self.settings.enable_presence
def draw(self):
user_selection = self.data.get('selected_objects')
for select_ob in user_selection:
ob = find_from_attr("uuid", select_ob, bpy.data.objects)
if not ob:
return
position = None
if ob.type == 'EMPTY':
# TODO: Child case
# Collection instance case
indices = (
(0, 1), (1, 2), (2, 3), (0, 3),
(4, 5), (5, 6), (6, 7), (4, 7),
(0, 4), (1, 5), (2, 6), (3, 7))
if ob.instance_collection:
for obj in ob.instance_collection.objects:
if obj.type == 'MESH' and hasattr(obj, 'bound_box'):
positions = get_bb_coords_from_obj(obj, instance=ob)
break
elif hasattr(ob, 'bound_box'):
indices = (
(0, 1), (1, 2), (2, 3), (0, 3),
(4, 5), (5, 6), (6, 7), (4, 7),
(0, 4), (1, 5), (2, 6), (3, 7))
positions = get_bb_coords_from_obj(ob)
if positions is None:
indices = (
(0, 1), (0, 2), (1, 3), (2, 3),
(4, 5), (4, 6), (5, 7), (6, 7),
(0, 4), (1, 5), (2, 6), (3, 7))
positions = bbox_from_obj(ob, ob.scale.x)
shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR')
batch = batch_for_shader(
shader,
'LINES',
{"pos": positions},
indices=indices)
shader.bind()
shader.uniform_float("color", self.data.get('color'))
batch.draw(shader)
class UserNameWidget(Widget):
draw_type = 'POST_PIXEL'
def __init__(
self,
username):
self.username = username
self.settings = bpy.context.window_manager.session
@property
def data(self):
user = session.online_users.get(self.username)
if user:
return user.get('metadata')
else:
return None
def poll(self):
if self.data is None:
return False
scene_current = self.data.get('scene_current')
view_corners = self.data.get('view_corners')
return (scene_current == bpy.context.scene.name or
self.settings.presence_show_far_user) and \
view_corners and \
self.settings.presence_show_user and \
self.settings.enable_presence
def draw(self):
view_corners = self.data.get('view_corners')
color = self.data.get('color')
position = [tuple(coord) for coord in view_corners]
coords = project_to_screen(position[1])
if coords:
blf.position(0, coords[0], coords[1]+10, 0)
blf.size(0, 16, 72)
blf.color(0, color[0], color[1], color[2], color[3])
blf.draw(0, self.username)
class SessionStatusWidget(Widget):
draw_type = 'POST_PIXEL'
@property
def settings(self):
return getattr(bpy.context.window_manager, 'session', None)
def poll(self):
return self.settings and self.settings.presence_show_session_status and \
self.settings.enable_presence
def draw(self):
color = [1, 1, 0, 1]
state = session.state.get('STATE')
state_str = f"{get_state_str(state)}"
if state == STATE_ACTIVE:
color = [0, 1, 0, 1]
elif state == STATE_INITIAL:
color = [1, 0, 0, 1]
blf.position(0, 10, 20, 0)
blf.size(0, 16, 45)
blf.color(0, color[0], color[1], color[2], color[3])
blf.draw(0, state_str)
class DrawFactory(object):
def __init__(self):
self.post_view_handle = None
self.post_pixel_handle = None
self.widgets = {}
def add_widget(self, name: str, widget: Widget):
self.widgets[name] = widget
def remove_widget(self, name: str):
if name in self.widgets:
del self.widgets[name]
else:
logging.error(f"Widget {name} not existing")
def clear_widgets(self):
self.widgets.clear()
def register_handlers(self):
self.post_view_handle = bpy.types.SpaceView3D.draw_handler_add(
self.post_view_callback,
(),
'WINDOW',
'POST_VIEW')
self.post_pixel_handle = bpy.types.SpaceView3D.draw_handler_add(
self.post_pixel_callback,
(),
'WINDOW',
'POST_PIXEL')
def unregister_handlers(self):
if self.post_pixel_handle:
bpy.types.SpaceView3D.draw_handler_remove(
self.post_pixel_handle,
"WINDOW")
self.post_pixel_handle = None
if self.post_view_handle:
bpy.types.SpaceView3D.draw_handler_remove(
self.post_view_handle,
"WINDOW")
self.post_view_handle = None
def post_view_callback(self):
try:
for widget in self.widgets.values():
if widget.draw_type == 'POST_VIEW' and widget.poll():
widget.draw()
except Exception as e:
logging.error(
f"Post view widget exception: {e} \n {traceback.print_exc()}")
for shader, batch, color in self.d3d_items.values():
shader.bind()
shader.uniform_float("color", color)
batch.draw(shader)
except Exception:
logging.error("3D Exception")
def post_pixel_callback(self):
try:
for widget in self.widgets.values():
if widget.draw_type == 'POST_PIXEL' and widget.poll():
widget.draw()
except Exception as e:
logging.error(
f"Post pixel widget Exception: {e} \n {traceback.print_exc()}")
def draw2d_callback(self):
for position, font, color in self.d2d_items.values():
try:
coords = get_client_2d(position)
if coords:
blf.position(0, coords[0], coords[1]+10, 0)
blf.size(0, 16, 72)
blf.color(0, color[0], color[1], color[2], color[3])
blf.draw(0, font)
this = sys.modules[__name__]
this.renderer = DrawFactory()
except Exception:
logging.error("2D EXCEPTION")
def register():
this.renderer.register_handlers()
this.renderer.add_widget("session_status", SessionStatusWidget())
global renderer
renderer = DrawFactory()
def unregister():
this.renderer.unregister_handlers()
global renderer
renderer.unregister_handlers()
this.renderer.clear_widgets()
del renderer
+144 -114
View File
@@ -18,7 +18,8 @@
import bpy
from .utils import get_preferences, get_expanded_icon, get_folder_size, get_state_str
from . import operators
from .utils import get_preferences, get_expanded_icon
from replication.constants import (ADDED, ERROR, FETCHED,
MODIFIED, RP_COMMON, UP,
STATE_ACTIVE, STATE_AUTH,
@@ -28,15 +29,13 @@ from replication.constants import (ADDED, ERROR, FETCHED,
STATE_LOBBY,
STATE_LAUNCHING_SERVICES)
from replication import __version__
from replication.interface import session
ICONS_PROP_STATES = ['TRIA_DOWN', # ADDED
'TRIA_UP', # COMMITED
'KEYTYPE_KEYFRAME_VEC', # PUSHED
'TRIA_DOWN', # FETCHED
'RECOVER_LAST', # RESET
'TRIA_UP', # CHANGED
'ERROR'] # ERROR
'FILE_REFRESH', # UP
'TRIA_UP'] # CHANGED
def printProgressBar(iteration, total, prefix='', suffix='', decimals=1, length=100, fill='█', fill_empty=' '):
@@ -60,6 +59,32 @@ def printProgressBar(iteration, total, prefix='', suffix='', decimals=1, length=
return f"{prefix} |{bar}| {iteration}/{total}{suffix}"
def get_state_str(state):
state_str = 'UNKNOWN'
if state == STATE_WAITING:
state_str = 'WARMING UP DATA'
elif state == STATE_SYNCING:
state_str = 'FETCHING'
elif state == STATE_AUTH:
state_str = 'AUTHENTIFICATION'
elif state == STATE_CONFIG:
state_str = 'CONFIGURATION'
elif state == STATE_ACTIVE:
state_str = 'ONLINE'
elif state == STATE_SRV_SYNC:
state_str = 'PUSHING'
elif state == STATE_INITIAL:
state_str = 'INIT'
elif state == STATE_QUITTING:
state_str = 'QUITTING'
elif state == STATE_LAUNCHING_SERVICES:
state_str = 'LAUNCHING SERVICES'
elif state == STATE_LOBBY:
state_str = 'LOBBY'
return state_str
class SESSION_PT_settings(bpy.types.Panel):
"""Settings panel"""
bl_idname = "MULTIUSER_SETTINGS_PT_panel"
@@ -70,9 +95,9 @@ class SESSION_PT_settings(bpy.types.Panel):
def draw_header(self, context):
layout = self.layout
if session and session.state['STATE'] != STATE_INITIAL:
cli_state = session.state
state = session.state.get('STATE')
if operators.client and operators.client.state['STATE'] != STATE_INITIAL:
cli_state = operators.client.state
state = operators.client.state.get('STATE')
connection_icon = "KEYTYPE_MOVING_HOLD_VEC"
if state == STATE_ACTIVE:
@@ -93,43 +118,65 @@ class SESSION_PT_settings(bpy.types.Panel):
if hasattr(context.window_manager, 'session'):
# STATE INITIAL
if not session \
or (session and session.state['STATE'] == STATE_INITIAL):
if not operators.client \
or (operators.client and operators.client.state['STATE'] == STATE_INITIAL):
pass
else:
cli_state = session.state
cli_state = operators.client.state
row = layout.row()
current_state = cli_state['STATE']
info_msg = None
# STATE ACTIVE
if current_state in [STATE_ACTIVE]:
row = row.split(factor=0.3)
row.prop(settings.sync_flags, "sync_render_settings",text="",icon_only=True, icon='SCENE')
row.prop(settings.sync_flags, "sync_during_editmode", text="",icon_only=True, icon='EDITMODE_HLT')
row.prop(settings.sync_flags, "sync_active_camera", text="",icon_only=True, icon='OBJECT_DATAMODE')
row= layout.row()
if current_state in [STATE_ACTIVE] and runtime_settings.is_host:
info_msg = f"LAN: {runtime_settings.internet_ip}"
row.operator("session.stop", icon='QUIT', text="Exit")
row = layout.row()
if runtime_settings.is_host:
row = row.box()
row.label(text=f"LAN: {runtime_settings.internet_ip}", icon='INFO')
row = layout.row()
if current_state == STATE_LOBBY:
info_msg = "Waiting for the session to start."
row = row.box()
row.label(text=f"Waiting the session to start", icon='INFO')
row = layout.row()
row.operator("session.stop", icon='QUIT', text="Exit")
# CONNECTION STATE
elif current_state in [STATE_SRV_SYNC,
STATE_SYNCING,
STATE_AUTH,
STATE_CONFIG,
STATE_WAITING]:
if info_msg:
info_box = row.box()
info_box.row().label(text=info_msg,icon='INFO')
if cli_state['STATE'] in [STATE_SYNCING, STATE_SRV_SYNC, STATE_WAITING]:
box = row.box()
box.label(text=printProgressBar(
cli_state['CURRENT'],
cli_state['TOTAL'],
length=16
))
# Progress bar
if current_state in [STATE_SYNCING, STATE_SRV_SYNC, STATE_WAITING]:
info_box = row.box()
info_box.row().label(text=printProgressBar(
cli_state['CURRENT'],
cli_state['TOTAL'],
row = layout.row()
row.operator("session.stop", icon='QUIT', text="CANCEL")
elif current_state == STATE_QUITTING:
row = layout.row()
box = row.box()
num_online_services = 0
for name, state in operators.client.services_state.items():
if state == STATE_ACTIVE:
num_online_services += 1
total_online_services = len(
operators.client.services_state)
box.label(text=printProgressBar(
total_online_services-num_online_services,
total_online_services,
length=16
))
layout.row().operator("session.stop", icon='QUIT', text="Exit")
class SESSION_PT_settings_network(bpy.types.Panel):
bl_idname = "MULTIUSER_SETTINGS_NETWORK_PT_panel"
@@ -140,8 +187,8 @@ class SESSION_PT_settings_network(bpy.types.Panel):
@classmethod
def poll(cls, context):
return not session \
or (session and session.state['STATE'] == 0)
return not operators.client \
or (operators.client and operators.client.state['STATE'] == 0)
def draw_header(self, context):
self.layout.label(text="", icon='URL')
@@ -198,8 +245,8 @@ class SESSION_PT_settings_user(bpy.types.Panel):
@classmethod
def poll(cls, context):
return not session \
or (session and session.state['STATE'] == 0)
return not operators.client \
or (operators.client and operators.client.state['STATE'] == 0)
def draw_header(self, context):
self.layout.label(text="", icon='USER')
@@ -229,8 +276,8 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
@classmethod
def poll(cls, context):
return not session \
or (session and session.state['STATE'] == 0)
return not operators.client \
or (operators.client and operators.client.state['STATE'] == 0)
def draw_header(self, context):
self.layout.label(text="", icon='PREFERENCES')
@@ -273,12 +320,10 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
replication_section_row = replication_section.row()
replication_section_row.prop(settings.sync_flags, "sync_render_settings")
replication_section_row = replication_section.row()
replication_section_row.prop(settings.sync_flags, "sync_active_camera")
replication_section_row = replication_section.row()
replication_section_row.prop(settings.sync_flags, "sync_during_editmode")
replication_section_row.prop(settings, "enable_editmode_updates")
replication_section_row = replication_section.row()
if settings.sync_flags.sync_during_editmode:
if settings.enable_editmode_updates:
warning = replication_section_row.box()
warning.label(text="Don't use this with heavy meshes !", icon='ERROR')
replication_section_row = replication_section.row()
@@ -311,23 +356,6 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
replication_timers.label(text="Update rate (ms):")
replication_timers.prop(settings, "depsgraph_update_rate", text="")
cache_section = layout.row().box()
cache_section.prop(
settings,
"sidebar_advanced_cache_expanded",
text="Cache",
icon=get_expanded_icon(settings.sidebar_advanced_cache_expanded),
emboss=False)
if settings.sidebar_advanced_cache_expanded:
cache_section_row = cache_section.row()
cache_section_row.label(text="Cache directory:")
cache_section_row = cache_section.row()
cache_section_row.prop(settings, "cache_directory", text="")
cache_section_row = cache_section.row()
cache_section_row.label(text="Clear memory filecache:")
cache_section_row.prop(settings, "clear_memory_filecache", text="")
cache_section_row = cache_section.row()
cache_section_row.operator('session.clear_cache', text=f"Clear cache ({get_folder_size(settings.cache_directory)})")
log_section = layout.row().box()
log_section.prop(
settings,
@@ -349,7 +377,7 @@ class SESSION_PT_user(bpy.types.Panel):
@classmethod
def poll(cls, context):
return session and session.state['STATE'] in [STATE_ACTIVE, STATE_LOBBY]
return operators.client and operators.client.state['STATE'] in [STATE_ACTIVE, STATE_LOBBY]
def draw_header(self, context):
self.layout.label(text="", icon='USER')
@@ -380,7 +408,7 @@ class SESSION_PT_user(bpy.types.Panel):
if active_user != 0 and active_user.username != settings.username:
row = layout.row()
user_operations = row.split()
if session.state['STATE'] == STATE_ACTIVE:
if operators.client.state['STATE'] == STATE_ACTIVE:
user_operations.alert = context.window_manager.session.time_snap_running
user_operations.operator(
@@ -394,7 +422,7 @@ class SESSION_PT_user(bpy.types.Panel):
text="",
icon='TIME').target_client = active_user.username
if session.online_users[settings.username]['admin']:
if operators.client.online_users[settings.username]['admin']:
user_operations.operator(
"session.kick",
text="",
@@ -403,6 +431,7 @@ class SESSION_PT_user(bpy.types.Panel):
class SESSION_UL_users(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag):
session = operators.client
settings = get_preferences()
is_local_user = item.username == settings.username
ping = '-'
@@ -437,8 +466,8 @@ class SESSION_PT_presence(bpy.types.Panel):
@classmethod
def poll(cls, context):
return not session \
or (session and session.state['STATE'] in [STATE_INITIAL, STATE_ACTIVE])
return not operators.client \
or (operators.client and operators.client.state['STATE'] in [STATE_INITIAL, STATE_ACTIVE])
def draw_header(self, context):
self.layout.prop(context.window_manager.session,
@@ -450,25 +479,54 @@ class SESSION_PT_presence(bpy.types.Panel):
settings = context.window_manager.session
layout.active = settings.enable_presence
col = layout.column()
col.prop(settings, "presence_show_session_status")
col.prop(settings, "presence_show_selected")
col.prop(settings, "presence_show_user")
row = layout.column()
row.active = settings.presence_show_user
row.prop(settings, "presence_show_far_user")
class SESSION_PT_services(bpy.types.Panel):
bl_idname = "MULTIUSER_SERVICE_PT_panel"
bl_label = "Services"
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 operators.client and operators.client.state['STATE'] == 2
def draw_header(self, context):
self.layout.label(text="", icon='FILE_CACHE')
def draw(self, context):
layout = self.layout
online_users = context.window_manager.online_users
selected_user = context.window_manager.user_index
settings = context.window_manager.session
active_user = online_users[selected_user] if len(online_users)-1 >= selected_user else 0
# Create a simple row.
for name, state in operators.client.services_state.items():
row = layout.row()
row.label(text=name)
row.label(text=get_state_str(state))
def draw_property(context, parent, property_uuid, level=0):
settings = get_preferences()
runtime_settings = context.window_manager.session
item = session.get(uuid=property_uuid)
area_msg = parent.row(align=True)
item = operators.client.get(uuid=property_uuid)
if item.state == ERROR:
area_msg.alert=True
else:
area_msg.alert=False
return
area_msg = parent.row(align=True)
if level > 0:
for i in range(level):
area_msg.label(text="")
line = area_msg.box()
name = item.data['name'] if item.data else item.uuid
@@ -481,8 +539,8 @@ def draw_property(context, parent, property_uuid, level=0):
# Operations
have_right_to_modify = (item.owner == settings.username or \
item.owner == RP_COMMON) and item.state != ERROR
have_right_to_modify = item.owner == settings.username or \
item.owner == RP_COMMON
if have_right_to_modify:
detail_item_box.operator(
@@ -492,12 +550,10 @@ def draw_property(context, parent, property_uuid, level=0):
detail_item_box.separator()
if item.state in [FETCHED, UP]:
apply = detail_item_box.operator(
detail_item_box.operator(
"session.apply",
text="",
icon=ICONS_PROP_STATES[item.state])
apply.target = item.uuid
apply.reset_dependencies = True
icon=ICONS_PROP_STATES[item.state]).target = item.uuid
elif item.state in [MODIFIED, ADDED]:
detail_item_box.operator(
"session.commit",
@@ -520,6 +576,7 @@ def draw_property(context, parent, property_uuid, level=0):
else:
detail_item_box.label(text="", icon="DECORATE_LOCKED")
class SESSION_PT_repository(bpy.types.Panel):
bl_idname = "MULTIUSER_PROPERTIES_PT_panel"
bl_label = "Repository"
@@ -529,6 +586,7 @@ class SESSION_PT_repository(bpy.types.Panel):
@classmethod
def poll(cls, context):
session = operators.client
settings = get_preferences()
admin = False
@@ -537,9 +595,9 @@ class SESSION_PT_repository(bpy.types.Panel):
if usr:
admin = usr['admin']
return hasattr(context.window_manager, 'session') and \
session and \
(session.state['STATE'] == STATE_ACTIVE or \
session.state['STATE'] == STATE_LOBBY and admin)
operators.client and \
(operators.client.state['STATE'] == STATE_ACTIVE or \
operators.client.state['STATE'] == STATE_LOBBY and admin)
def draw_header(self, context):
self.layout.label(text="", icon='OUTLINER_OB_GROUP_INSTANCE')
@@ -551,6 +609,7 @@ class SESSION_PT_repository(bpy.types.Panel):
settings = get_preferences()
runtime_settings = context.window_manager.session
session = operators.client
usr = session.online_users.get(settings.username)
row = layout.row()
@@ -576,11 +635,11 @@ class SESSION_PT_repository(bpy.types.Panel):
types_filter = [t.type_name for t in settings.supported_datablocks
if t.use_as_filter]
key_to_filter = session.list(
filter_owner=settings.username) if runtime_settings.filter_owned else session.list()
key_to_filter = operators.client.list(
filter_owner=settings.username) if runtime_settings.filter_owned else operators.client.list()
client_keys = [key for key in key_to_filter
if session.get(uuid=key).str_type
if operators.client.get(uuid=key).str_type
in types_filter]
if client_keys:
@@ -596,36 +655,6 @@ class SESSION_PT_repository(bpy.types.Panel):
else:
row.label(text="Waiting to start")
class VIEW3D_PT_overlay_session(bpy.types.Panel):
bl_space_type = 'VIEW_3D'
bl_region_type = 'HEADER'
bl_parent_id = 'VIEW3D_PT_overlay'
bl_label = "Multi-user"
@classmethod
def poll(cls, context):
return True
def draw(self, context):
layout = self.layout
view = context.space_data
overlay = view.overlay
display_all = overlay.show_overlays
col = layout.column()
row = col.row(align=True)
settings = context.window_manager.session
layout.active = settings.enable_presence
col = layout.column()
col.prop(settings, "presence_show_session_status")
col.prop(settings, "presence_show_selected")
col.prop(settings, "presence_show_user")
row = layout.column()
row.active = settings.presence_show_user
row.prop(settings, "presence_show_far_user")
classes = (
SESSION_UL_users,
@@ -635,8 +664,9 @@ classes = (
SESSION_PT_presence,
SESSION_PT_advanced_settings,
SESSION_PT_user,
SESSION_PT_services,
SESSION_PT_repository,
VIEW3D_PT_overlay_session,
)
+14 -96
View File
@@ -21,22 +21,13 @@ import logging
import os
import sys
import time
from collections.abc import Iterable
from pathlib import Path
from uuid import uuid4
import math
from collections.abc import Iterable
import bpy
import mathutils
from . import environment
from replication.constants import (STATE_ACTIVE, STATE_AUTH,
STATE_CONFIG, STATE_SYNCING,
STATE_INITIAL, STATE_SRV_SYNC,
STATE_WAITING, STATE_QUITTING,
STATE_LOBBY,
STATE_LAUNCHING_SERVICES)
from . import environment, presence
def find_from_attr(attr_name, attr_value, list):
@@ -56,7 +47,7 @@ def get_datablock_users(datablock):
if hasattr(datablock, 'users_group') and datablock.users_scene:
users.extend(list(datablock.users_scene))
for datatype in supported_types:
if datatype.bl_name != 'users' and hasattr(bpy.data, datatype.bl_name):
if datatype.bl_name != 'users':
root = getattr(bpy.data, datatype.bl_name)
for item in root:
if hasattr(item, 'data') and datablock == item.data or \
@@ -65,32 +56,6 @@ def get_datablock_users(datablock):
return users
def get_state_str(state):
state_str = 'UNKOWN'
if state == STATE_WAITING:
state_str = 'WARMING UP DATA'
elif state == STATE_SYNCING:
state_str = 'FETCHING'
elif state == STATE_AUTH:
state_str = 'AUTHENTICATION'
elif state == STATE_CONFIG:
state_str = 'CONFIGURATION'
elif state == STATE_ACTIVE:
state_str = 'ONLINE'
elif state == STATE_SRV_SYNC:
state_str = 'PUSHING'
elif state == STATE_INITIAL:
state_str = 'OFFLINE'
elif state == STATE_QUITTING:
state_str = 'QUITTING'
elif state == STATE_LAUNCHING_SERVICES:
state_str = 'LAUNCHING SERVICES'
elif state == STATE_LOBBY:
state_str = 'LOBBY'
return state_str
def clean_scene():
for type_name in dir(bpy.data):
try:
@@ -113,6 +78,17 @@ def resolve_from_id(id, optionnal_type=None):
return root[id]
return None
def get_datablock_from_uuid(uuid, default, ignore=[]):
if not uuid:
return default
for category in dir(bpy.data):
root = getattr(bpy.data, category)
if isinstance(root, Iterable) and category not in ignore:
for item in root:
if getattr(item, 'uuid', None) == uuid:
return item
return default
def get_preferences():
return bpy.context.preferences.addons[__package__].preferences
@@ -127,61 +103,3 @@ def get_expanded_icon(prop: bpy.types.BoolProperty) -> str:
return 'DISCLOSURE_TRI_DOWN'
else:
return 'DISCLOSURE_TRI_RIGHT'
# Taken from here: https://stackoverflow.com/a/55659577
def get_folder_size(folder):
return ByteSize(sum(file.stat().st_size for file in Path(folder).rglob('*')))
class ByteSize(int):
_kB = 1024
_suffixes = 'B', 'kB', 'MB', 'GB', 'PB'
def __new__(cls, *args, **kwargs):
return super().__new__(cls, *args, **kwargs)
def __init__(self, *args, **kwargs):
self.bytes = self.B = int(self)
self.kilobytes = self.kB = self / self._kB**1
self.megabytes = self.MB = self / self._kB**2
self.gigabytes = self.GB = self / self._kB**3
self.petabytes = self.PB = self / self._kB**4
*suffixes, last = self._suffixes
suffix = next((
suffix
for suffix in suffixes
if 1 < getattr(self, suffix) < self._kB
), last)
self.readable = suffix, getattr(self, suffix)
super().__init__()
def __str__(self):
return self.__format__('.2f')
def __repr__(self):
return '{}({})'.format(self.__class__.__name__, super().__repr__())
def __format__(self, format_spec):
suffix, val = self.readable
return '{val:{fmt}} {suf}'.format(val=math.ceil(val), fmt=format_spec, suf=suffix)
def __sub__(self, other):
return self.__class__(super().__sub__(other))
def __add__(self, other):
return self.__class__(super().__add__(other))
def __mul__(self, other):
return self.__class__(super().__mul__(other))
def __rsub__(self, other):
return self.__class__(super().__sub__(other))
def __radd__(self, other):
return self.__class__(super().__add__(other))
def __rmul__(self, other):
return self.__class__(super().__rmul__(other))
-24
View File
@@ -1,24 +0,0 @@
# Download base image debian jessie
FROM python:slim
ARG replication_version=0.0.21
ARG version=0.1.1
# Infos
LABEL maintainer="Swann Martinez"
LABEL version=$version
LABEL description="Blender multi-user addon \
dedicated server image."
# Argument
ENV password='admin'
ENV port=5555
ENV timeout=3000
ENV log_level=INFO
ENV log_file="multiuser_server.log"
#Install replication
RUN pip install replication==$replication_version
# Run the server with parameters
CMD replication.serve -pwd ${password} -p ${port} -t ${timeout} -l ${log_level} -lf ${log_file}
-6
View File
@@ -1,6 +0,0 @@
import re
init_py = open("multi_user/__init__.py").read()
version = re.search("\d+, \d+, \d+", init_py).group(0)
digits = version.split(',')
print('.'.join(digits).replace(" ",""))
-4
View File
@@ -1,4 +0,0 @@
import re
init_py = open("multi_user/__init__.py").read()
print(re.search("\d+\.\d+\.\d+\w\d+|\d+\.\d+\.\d+", init_py).group(0))
-10
View File
@@ -1,10 +0,0 @@
#! /bin/bash
# Start server in docker container, from image hosted on the multi-user gitlab's container registry
docker run -d \
-p 5555-5560:5555-5560 \
-e port=5555 \
-e log-level DEBUG \
-e password=admin \
-e timeout=1000 \
registry.gitlab.com/slumber/multi-user/multi-user-server:0.1.0
-5
View File
@@ -1,5 +0,0 @@
#! /bin/bash
# Start replication server locally, and include logging (requires replication_version=0.0.21a15)
clear
replication.serve -p 5555 -pwd admin -t 1000 -l DEBUG -lf server.log
+21
View File
@@ -0,0 +1,21 @@
import os
import pytest
from deepdiff import DeepDiff
import bpy
import random
from multi_user.bl_types.bl_image import BlImage
def test_image(clear_blend):
datablock = bpy.data.images.new('asd',2000,2000)
implementation = BlImage()
expected = implementation._dump(datablock)
bpy.data.images.remove(datablock)
test = implementation._construct(expected)
implementation._load(expected, test)
result = implementation._dump(test)
assert not DeepDiff(expected, result)
+4 -18
View File
@@ -7,12 +7,13 @@ import bpy
from multi_user.bl_types.bl_material import BlMaterial
def test_material_nodes(clear_blend):
def test_material(clear_blend):
nodes_types = [node.bl_rna.identifier for node in bpy.types.ShaderNode.__subclasses__()]
datablock = bpy.data.materials.new("test")
datablock.use_nodes = True
bpy.data.materials.create_gpencil_data(datablock)
for ntype in nodes_types:
datablock.node_tree.nodes.new(ntype)
@@ -25,18 +26,3 @@ def test_material_nodes(clear_blend):
result = implementation._dump(test)
assert not DeepDiff(expected, result)
def test_material_gpencil(clear_blend):
datablock = bpy.data.materials.new("test")
bpy.data.materials.create_gpencil_data(datablock)
implementation = BlMaterial()
expected = implementation._dump(datablock)
bpy.data.materials.remove(datablock)
test = implementation._construct(expected)
implementation._load(expected, test)
result = implementation._dump(test)
assert not DeepDiff(expected, result)
-3
View File
@@ -6,11 +6,8 @@ from deepdiff import DeepDiff
import bpy
import random
from multi_user.bl_types.bl_scene import BlScene
from multi_user.utils import get_preferences
def test_scene(clear_blend):
get_preferences().sync_flags.sync_render_settings = True
datablock = bpy.data.scenes.new("toto")
datablock.view_settings.use_curve_mapping = True
# Test