Compare commits

...

69 Commits

Author SHA1 Message Date
e0b56d8990 Merge branch 'develop' into 'master'
v0.1.0

See merge request slumber/multi-user!43
2020-10-05 13:42:16 +00:00
0687090f05 feat: update changelog 2020-10-05 15:18:01 +02:00
920744334c Merge branch '125-autobuild-docker-image' into 'develop'
Resolve "Autobuild docker image"

See merge request slumber/multi-user!53
2020-10-05 09:32:57 +00:00
dfa7f98126 refactor: remove useless script 2020-10-05 11:28:45 +02:00
ea530f0f96 featL enable tast and build back 2020-10-03 00:30:37 +02:00
c3546ff74f fix: var name 2020-10-03 00:28:11 +02:00
83aa9b57ec feat: automatic image version 2020-10-03 00:26:44 +02:00
28a265be68 test: login in script 2020-10-03 00:12:39 +02:00
7dfabb16c7 fix: tls 2020-10-03 00:07:07 +02:00
ea5d9371ca feat: login 2020-10-03 00:00:42 +02:00
3df73a0716 feat: find replication version 2020-10-02 23:58:08 +02:00
ae3c994ff1 feat: dind tests 2020-10-02 23:55:04 +02:00
bd73b385b6 feat: dind 2020-10-02 23:52:19 +02:00
f054b1c5f2 fix: trying to use a standard docker image 2020-10-02 23:38:09 +02:00
d083100a2a fix: image directory path 2020-10-02 23:33:50 +02:00
b813b8df9e feat: docker build and push 2020-10-02 23:32:06 +02:00
d0e966ff1a fix: path 2020-10-02 23:29:48 +02:00
56cbf14fe1 refactor: use custom image 2020-10-02 23:27:45 +02:00
8bf55ebd46 feat: apk update 2020-10-02 23:19:34 +02:00
edbc5ee343 feat: apt install 2020-10-02 23:16:46 +02:00
4a92511582 feat: test install python 2020-10-02 23:14:49 +02:00
b42df2cf4a feat: retrieve version 2020-10-02 23:07:25 +02:00
7549466824 fix: ci deploy name 2020-10-02 18:59:25 +02:00
423e71476d feat: update ci 2020-10-02 18:57:50 +02:00
3bc4b20035 feat: CI file and docker image 2020-10-02 18:56:26 +02:00
9966a24b5e feat: update supported types in README.md 2020-10-02 18:04:32 +02:00
577c01a594 Merge branch '124-use-a-global-session-instance-in-replication' into 'develop'
Resolve "use a global session instance in replication"

See merge request slumber/multi-user!52
2020-10-02 15:51:30 +00:00
3d72796c10 refactor: remove old client ref
feat: update changelog
2020-10-02 17:48:56 +02:00
edcbd7b02a feat: display node in error in the repository view 2020-10-02 17:22:14 +02:00
b368c985b8 refactor: session handler encapsulation 2020-10-02 12:11:53 +02:00
cab1a71eaa fix: version 2020-10-02 09:52:21 +02:00
33cb188509 refactor: use replication session global instance 2020-10-02 00:05:33 +02:00
0a3dd9b5b8 fix: missing get_datablock_from_uuid 2020-10-02 00:00:34 +02:00
7fbdbdcc21 feat: show flag in blender overlays panel 2020-10-01 22:55:06 +02:00
8f9d5aabf9 refactor: moved get_datablock_from_uuid to bl_datablock 2020-10-01 22:51:48 +02:00
824d4d6a83 feat: upgrade replication version to fix duplicate during REPARENT
Related to #113
2020-10-01 15:34:36 +02:00
5f4bccbcd9 feat: POLY curves support
During a mesh->curve conversion, curve type spline is changed to POLY. This is adding the support for POLY curves.

Related to #113
2020-10-01 15:33:10 +02:00
8e8e54fe7d Merge branch '122-crash-on-connection' into 'develop'
Resolve "Crash on connection"

See merge request slumber/multi-user!50
2020-10-01 09:17:59 +00:00
04b13cc0b7 refactor: moveconnection handlers to the main thread 2020-10-01 10:58:30 +02:00
ba98875560 fix: version check command format 2020-09-29 17:33:39 +02:00
a9fb84a5c6 fix: world viewport color sync 2020-09-29 11:47:48 +02:00
2f139178d3 feat: update replication version 2020-09-28 22:59:43 +02:00
e466f81600 fix: file handler not properly closed 2020-09-28 22:51:07 +02:00
cb836e30f5 fix: empty uv useless update 2020-09-28 22:50:42 +02:00
152e356dad fix: font/sound loading 2020-09-28 10:40:07 +02:00
7b13e8978b fix: close file handler after quitting the session 2020-09-28 10:32:41 +02:00
e0839fe1fb Merge branch '118-optionnal-active-camera-sync-flag' into 'develop'
Resolve "Optionnal active camera sync flag"

See merge request slumber/multi-user!49
2020-09-25 14:09:31 +00:00
aec3e8b8bf doc: update replication flag section 2020-09-25 15:27:01 +02:00
a89564de6b feat: append synchronization flags to the top
refactor: enable sync render settings by default
2020-09-25 14:26:31 +02:00
e301a10456 feat: active camera sync flag 2020-09-25 11:33:35 +02:00
cfc6ce91bc feat: initial live syncflag support 2020-09-25 11:23:36 +02:00
4f731c6640 fix: implementation not found if a new type is added 2020-09-23 17:37:21 +02:00
9b1b8f11fd feat: sync object hide_render 2020-09-23 16:48:17 +02:00
e742c824fc feat: sync all object show flags except hide_viewport. 2020-09-23 16:47:51 +02:00
6757bbbd30 fix: enable DIFF_BINARY by default 2020-09-23 16:04:31 +02:00
f6a39e4290 fix: scene differential error
fix: bl_file loading error
feat: update replication version
2020-09-23 14:24:57 +02:00
410d8d2f1a feat: display sync 2020-09-23 10:00:08 +02:00
bd64c17f05 feat: update version 2020-09-22 16:36:59 +02:00
dc063b5954 fix: handle file not found exception 2020-09-21 18:52:27 +02:00
0ae34d5702 Merge branch 'file_replication' into 'develop'
Basic file replication interface

See merge request slumber/multi-user!48
2020-09-21 16:17:58 +00:00
167b39f15e doc: added a cache section to the quickstart 2020-09-21 18:14:30 +02:00
9adc0d7d6e clean: remove image testing (until the file replication interface is done) 2020-09-21 17:48:07 +02:00
fb622fa098 fix: get_datablock_users attribute error 2020-09-21 17:37:06 +02:00
c533d4b86a ci: run tests on every branch 2020-09-21 17:31:07 +02:00
6c47e095be feat: cache managenent utility 2020-09-21 16:47:49 +02:00
f992d06b03 feat: handle packed datablock
feat: filecache settings
2020-09-21 12:12:19 +02:00
af3afc1124 feat: use bl_file in bl_image 2020-09-21 00:11:37 +02:00
b77ab2dd05 feat: use bl_file to replicate font and sound files 2020-09-20 23:31:24 +02:00
150054d19c feat: generic file replication ground work 2020-09-20 19:53:51 +02:00
34 changed files with 1072 additions and 528 deletions

View File

@ -1,7 +1,9 @@
stages:
- test
- build
- deploy
include:
- local: .gitlab/ci/test.gitlab-ci.yml
- local: .gitlab/ci/build.gitlab-ci.yml
- local: .gitlab/ci/build.gitlab-ci.yml
- local: .gitlab/ci/deploy.gitlab-ci.yml

View File

@ -3,13 +3,8 @@ build:
image: debian:stable-slim
script:
- rm -rf tests .git .gitignore script
artifacts:
name: multi_user
paths:
- multi_user
only:
refs:
- master
- develop

View File

@ -0,0 +1,18 @@
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}

View File

@ -3,8 +3,3 @@ test:
image: slumber/blender-addon-testing:latest
script:
- python3 scripts/test_addon.py
only:
refs:
- master
- develop

View File

@ -65,24 +65,34 @@ All notable changes to this project will be documented in this file.
- Unused strict right management strategy
- Legacy config management system
## [0.0.4] - preview
## [0.1.0] - preview
### Added
- Dependency graph driven updates [experimental]
- Optional Edit Mode update
- Edit Mode updates
- Late join mechanism
- Sync Axis lock replication
- Sync collection offset
- Sync camera orthographic scale
- Logging basic configuration (file output and level)
- Sync custom fonts
- Sync sound files
- Logging configuration (file output and level)
- Object visibility type replication
- Optionnal sync for active camera
- Curve->Mesh conversion
- Mesh->gpencil conversion
### Changed
- Auto updater now handle installation from branches
- use uuid for collection loading
- Use uuid for collection loading
- Moved session instance to replication package
### Fixed
- Prevent unsuported datatypes to crash the session
- Modifier vertex group assignation
- Prevent unsupported data types to crash the session
- Modifier vertex group assignation
- World sync
- Snapshot UUID error
- The world is not synchronized

View File

@ -25,27 +25,32 @@ 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 | | 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 |
| 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 |
### Performance issues
@ -57,7 +62,7 @@ I'm working on it.
| Dependencies | Version | Needed |
| ------------ | :-----: | -----: |
| Replication | latest | yes |
| Replication | latest | yes |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -161,6 +161,19 @@ 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
--------------------
@ -242,6 +255,8 @@ various drawn parts via the following flags:
- **Show users**: display users current viewpoint
- **Show different scenes**: display users working on other scenes
-----------
Manage data
-----------
@ -330,6 +345,8 @@ 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
-----------
@ -341,6 +358,8 @@ 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...).
@ -355,6 +374,26 @@ 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
---

View File

@ -19,7 +19,7 @@
bl_info = {
"name": "Multi-User",
"author": "Swann Martinez",
"version": (0, 0, 3),
"version": (0, 1, 0),
"description": "Enable real-time collaborative workflow inside blender",
"blender": (2, 82, 0),
"location": "3D View > Sidebar > Multi-User tab",
@ -44,10 +44,12 @@ from . import environment, utils
DEPENDENCIES = {
("replication", '0.0.21a8'),
("replication", '0.0.21a15'),
}
module_error_msg = "Insufficient rights to install the multi-user \
dependencies, aunch blender with administrator rights."
def register():
# Setup logging policy
logging.basicConfig(
@ -57,22 +59,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
preferences.register()
addon_updater_ops.register(bl_info)
presence.register()
operators.register()
ui.register()
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()
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(

View File

@ -36,7 +36,8 @@ __all__ = [
'bl_lightprobe',
'bl_speaker',
'bl_font',
'bl_sound'
'bl_sound',
'bl_file'
] # Order here defines execution order
from . import *

View File

@ -46,6 +46,98 @@ 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
@ -62,12 +154,8 @@ 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'])
@ -78,8 +166,12 @@ 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)
# Not really working for now...
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...
# See https://blender.stackexchange.com/questions/7020/create-nurbs-surface-with-python
if new_spline.type == 'NURBS':
logging.error("NURBS not supported.")
@ -95,6 +187,8 @@ 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',
@ -112,8 +206,13 @@ class BlCurve(BlDatablock):
for index, spline in enumerate(instance.splines):
dumper.depth = 2
dumper.include_filter = SPLINE_METADATA
spline_data = dumper.dump(spline)
# spline_data['points'] = np_dump_collection(spline.points, SPLINE_POINT)
if spline.type == 'POLY':
spline_data['points_count'] = len(spline.points)-1
spline_data['points'] = np_dump_collection(spline.points, SPLINE_POINT)
spline_data['bezier_points_count'] = len(spline.bezier_points)-1
spline_data['bezier_points'] = np_dump_collection(spline.bezier_points, SPLINE_BEZIER_POINT)
data['splines'][index] = spline_data

View File

@ -16,14 +16,16 @@
# ##### END GPL LICENSE BLOCK #####
import logging
from collections.abc import Iterable
import bpy
import mathutils
import logging
from replication.constants import DIFF_BINARY, UP
from replication.data import ReplicatedDatablock
from .. import utils
from .dump_anything import Loader, Dumper
from replication.data import ReplicatedDatablock
from replication.constants import (UP, DIFF_BINARY)
from .dump_anything import Dumper, Loader
def has_action(target):
@ -87,6 +89,19 @@ 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
@ -113,7 +128,7 @@ class BlDatablock(ReplicatedDatablock):
if instance and hasattr(instance, 'uuid'):
instance.uuid = self.uuid
# self.diff_method = DIFF_BINARY
self.diff_method = DIFF_BINARY
def resolve(self):
datablock_ref = None

View File

@ -0,0 +1,140 @@
# ##### 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(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):
memory_size = sys.getsizeof(self.data['file'])-33
disk_size = self.instance.stat().st_size
return memory_size == disk_size

View File

@ -16,14 +16,16 @@
# ##### END GPL LICENSE BLOCK #####
import bpy
import mathutils
import os
import logging
import pathlib
from .. import utils
from .dump_anything import Loader, Dumper
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
class BlFont(BlDatablock):
bl_id = "fonts"
@ -35,35 +37,38 @@ class BlFont(BlDatablock):
bl_icon = 'FILE_FONT'
def _construct(self, data):
if data['filepath'] == '<builtin>':
return bpy.data.fonts.load(data['filepath'])
elif 'font_file' in data.keys():
prefs = utils.get_preferences()
ext = pathlib.Path(data['filepath']).suffix
font_name = f"{self.uuid}{ext}"
font_path = os.path.join(prefs.cache_directory, font_name)
os.makedirs(prefs.cache_directory, exist_ok=True)
file = open(font_path, 'wb')
file.write(data["font_file"])
file.close()
filename = data.get('filename')
logging.info(f'loading {font_path}')
return bpy.data.fonts.load(font_path)
if filename == '<builtin>':
return bpy.data.fonts.load(filename)
else:
return bpy.data.fonts.load(get_filepath(filename))
def _load(self, data, target):
pass
def _dump(self, instance=None):
data = {
'filepath':instance.filepath,
'name':instance.name
if instance.filepath == '<builtin>':
filename = '<builtin>'
else:
filename = Path(instance.filepath).name
if not filename:
raise FileExistsError(instance.filepath)
return {
'filename': filename,
'name': instance.name
}
if instance.filepath != '<builtin>' and not instance.is_embedded_data:
file = open(instance.filepath, "rb")
data['font_file'] = file.read()
file.close()
return data
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

View File

@ -16,13 +16,17 @@
# ##### 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',
@ -53,29 +57,6 @@ 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'],
@ -84,28 +65,23 @@ 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)
data = {}
data['pixels'] = self.dump_image(instance)
filename = Path(instance.filepath).name
data = {
"filename": filename
}
dumper = Dumper()
dumper.depth = 2
dumper.include_filter = [
@ -114,13 +90,9 @@ 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):
@ -128,3 +100,24 @@ 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 = 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

View File

@ -21,9 +21,8 @@ import mathutils
import logging
import re
from ..utils import get_datablock_from_uuid
from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock
from .bl_datablock import BlDatablock, get_datablock_from_uuid
NODE_SOCKET_INDEX = re.compile('\[(\d*)\]')

View File

@ -89,24 +89,26 @@ class BlMesh(BlDatablock):
np_load_collection(data["polygons"],target.polygons, POLYGON)
# UV Layers
for layer in data['uv_layers']:
if layer not in target.uv_layers:
target.uv_layers.new(name=layer)
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)
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
for color_layer in data['vertex_colors']:
if color_layer not in target.vertex_colors:
target.vertex_colors.new(name=color_layer)
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)
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()
@ -114,7 +116,7 @@ class BlMesh(BlDatablock):
def _dump_implementation(self, data, instance=None):
assert(instance)
if instance.is_editmode and not self.preferences.enable_editmode_updates:
if instance.is_editmode and not self.preferences.sync_flags.sync_during_editmode:
raise ContextError("Mesh is in edit mode")
mesh = instance
@ -147,16 +149,18 @@ class BlMesh(BlDatablock):
data["loops"] = np_dump_collection(mesh.loops, LOOP)
# 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')
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')
# Vertex 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')
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')
# Fix material index
m_list = []

View File

@ -22,8 +22,7 @@ import bpy
import mathutils
from replication.exception import ContextError
from ..utils import get_datablock_from_uuid
from .bl_datablock import BlDatablock
from .bl_datablock import BlDatablock, get_datablock_from_uuid
from .dump_anything import Dumper, Loader
from replication.exception import ReparentException
@ -161,6 +160,8 @@ 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:
@ -199,7 +200,7 @@ class BlObject(BlDatablock):
assert(instance)
if _is_editmode(instance):
if self.preferences.enable_editmode_updates:
if self.preferences.sync_flags.sync_during_editmode:
instance.update_from_editmode()
else:
raise ContextError("Object is in edit-mode.")
@ -230,11 +231,27 @@ 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

View File

@ -22,7 +22,9 @@ 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 ..utils import get_preferences
from replication.constants import (DIFF_JSON, MODIFIED)
from deepdiff import DeepDiff
import logging
class BlScene(BlDatablock):
bl_id = "scenes"
@ -33,6 +35,11 @@ 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
@ -53,22 +60,23 @@ class BlScene(BlDatablock):
if 'grease_pencil' in data.keys():
target.grease_pencil = bpy.data.grease_pencils[data['grease_pencil']]
if 'eevee' in data.keys():
loader.load(target.eevee, data['eevee'])
if 'cycles' in data.keys():
loader.load(target.eevee, data['cycles'])
if self.preferences.sync_flags.sync_render_settings:
if 'eevee' in data.keys():
loader.load(target.eevee, data['eevee'])
if 'render' in data.keys():
loader.load(target.render, data['render'])
if 'cycles' in data.keys():
loader.load(target.eevee, data['cycles'])
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()
if 'render' in data.keys():
loader.load(target.render, data['render'])
if 'view_settings' in data.keys():
loader.load(target.view_settings, data['view_settings'])
if target.view_settings.use_curve_mapping:
#TODO: change this ugly fix
target.view_settings.curve_mapping.white_level = data['view_settings']['curve_mapping']['white_level']
target.view_settings.curve_mapping.black_level = data['view_settings']['curve_mapping']['black_level']
target.view_settings.curve_mapping.update()
def _dump_implementation(self, data, instance=None):
assert(instance)
@ -80,12 +88,14 @@ 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
@ -97,10 +107,8 @@ class BlScene(BlDatablock):
scene_dumper.depth = 1
scene_dumper.include_filter = None
pref = get_preferences()
if pref.sync_flags.sync_render_settings:
if self.preferences.sync_flags.sync_render_settings:
scene_dumper.exclude_filter = [
'gi_cache_info',
'feature_set',
@ -114,7 +122,9 @@ class BlScene(BlDatablock):
'preview_samples',
'sample_clamp_indirect',
'samples',
'volume_bounces'
'volume_bounces',
'file_extension',
'use_denoising'
]
data['eevee'] = scene_dumper.dump(instance.eevee)
data['cycles'] = scene_dumper.dump(instance.cycles)
@ -154,3 +164,17 @@ class BlScene(BlDatablock):
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, cache_size=5000)

View File

@ -16,14 +16,16 @@
# ##### END GPL LICENSE BLOCK #####
import bpy
import mathutils
import os
import logging
import pathlib
from .. import utils
from .dump_anything import Loader, Dumper
import os
from pathlib import Path
import bpy
from .bl_file import get_filepath, ensure_unpacked
from .bl_datablock import BlDatablock
from .dump_anything import Dumper, Loader
class BlSound(BlDatablock):
bl_id = "sounds"
@ -35,40 +37,33 @@ class BlSound(BlDatablock):
bl_icon = 'SOUND'
def _construct(self, data):
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()
filename = data.get('filename')
logging.info(f'loading {sound_path}')
return bpy.data.sounds.load(sound_path)
return bpy.data.sounds.load(get_filepath(filename))
def _load(self, data, target):
loader = Loader()
loader.load(target, data)
def _dump(self, instance=None):
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') ?
return {
'filepath':instance.filepath,
'name':instance.name,
'file': instance.packed_file.data
}
def diff(self):
return False
def _dump(self, instance=None):
filename = Path(instance.filepath).name
if not filename:
raise FileExistsError(instance.filepath)
return {
'filename': filename,
'name': instance.name
}
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

View File

@ -37,6 +37,9 @@ 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
@ -60,6 +63,7 @@ class BlWorld(BlDatablock):
world_dumper.include_filter = [
"use_nodes",
"name",
"color"
]
data = world_dumper.dump(instance)
if instance.use_nodes:

View File

@ -19,7 +19,7 @@ import logging
import bpy
from . import operators, presence, utils
from . import presence, utils
from replication.constants import (FETCHED,
UP,
RP_COMMON,
@ -31,10 +31,13 @@ from replication.constants import (FETCHED,
STATE_SRV_SYNC,
REPARENT)
from replication.interface import session
class Delayable():
"""Delayable task interface
"""
def __init__(self):
self.is_registered = False
def register(self):
raise NotImplementedError
@ -53,13 +56,20 @@ 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
"""
bpy.app.timers.register(self.main)
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")
def main(self):
self.execute()
@ -87,29 +97,28 @@ class ApplyTimer(Timer):
super().__init__(timout)
def execute(self):
client = operators.client
if client and client.state['STATE'] == STATE_ACTIVE:
if session and session.state['STATE'] == STATE_ACTIVE:
if self._type:
nodes = client.list(filter=self._type)
nodes = session.list(filter=self._type)
else:
nodes = client.list()
nodes = session.list()
for node in nodes:
node_ref = client.get(uuid=node)
node_ref = session.get(uuid=node)
if node_ref.state == FETCHED:
try:
client.apply(node, force=True)
session.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()
client.apply(node, force=True)
for parent in client._graph.find_parents(node):
session.apply(node, force=True)
for parent in session._graph.find_parents(node):
logging.info(f"Applying parent {parent}")
client.apply(parent, force=True)
session.apply(parent, force=True)
node_ref.state = UP
@ -121,7 +130,6 @@ 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:
@ -205,11 +213,16 @@ class DynamicRightSelectTimer(Timer):
class Draw(Delayable):
def __init__(self):
super().__init__()
self._handler = None
def register(self):
self._handler = bpy.types.SpaceView3D.draw_handler_add(
self.execute, (), 'WINDOW', 'POST_VIEW')
if not self.is_registered:
self._handler = bpy.types.SpaceView3D.draw_handler_add(
self.execute, (), 'WINDOW', 'POST_VIEW')
logging.debug(f"Register {self.__class__.__name__}")
else:
logging.debug(f"Drow {self.__class__.__name__} already registered")
def execute(self):
raise NotImplementedError()
@ -224,7 +237,6 @@ class Draw(Delayable):
class DrawClient(Draw):
def execute(self):
session = getattr(operators, 'client', None)
renderer = getattr(presence, 'renderer', None)
prefs = utils.get_preferences()
@ -260,22 +272,21 @@ 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 = operators.client.online_users.get(
local_user = session.online_users.get(
settings.username)
if not local_user:
return
else:
for username, user_data in operators.client.online_users.items():
for username, user_data in session.online_users.items():
if username != settings.username:
cached_user_data = self.users_metadata.get(
username)
new_user_data = operators.client.online_users[username]['metadata']
new_user_data = session.online_users[username]['metadata']
if cached_user_data is None:
self.users_metadata[username] = user_data['metadata']
@ -330,12 +341,11 @@ class SessionUserSync(Timer):
super().__init__(timout)
def execute(self):
session = getattr(operators, 'client', None)
renderer = getattr(presence, 'renderer', None)
if session and renderer:
# sync online users
session_users = operators.client.online_users
session_users = session.online_users
ui_users = bpy.context.window_manager.online_users
for index, user in enumerate(ui_users):
@ -350,3 +360,15 @@ class SessionUserSync(Timer):
new_key = ui_users.add()
new_key.name = user
new_key.username = 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()

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(f"{str(PYTHON_PATH)} -m pip show {name}", capture_output=True)
out = subprocess.run([str(PYTHON_PATH), "-m", "pip", "show", name], capture_output=True)
version = VERSION_EXPR.search(out.stdout.decode())
if version and version.group() == required_version:

View File

@ -25,8 +25,9 @@ import string
import time
from operator import itemgetter
from pathlib import Path
from subprocess import PIPE, Popen, TimeoutExpired
import zmq
import shutil
from pathlib import Path
from queue import Queue
import bpy
import mathutils
@ -38,17 +39,90 @@ from replication.constants import (FETCHED, STATE_ACTIVE,
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
client = None
background_execution_queue = Queue()
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 3: Launch presence overlay
if runtime_settings.enable_presence:
presence.renderer.run()
# 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)
@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
# Step 2: Unregister presence renderer
presence.renderer.stop()
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"
@ -61,7 +135,7 @@ class SessionStartOperator(bpy.types.Operator):
return True
def execute(self, context):
global client, delayables
global delayables
settings = utils.get_preferences()
runtime_settings = context.window_manager.session
@ -70,20 +144,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)
@ -104,7 +178,11 @@ class SessionStartOperator(bpy.types.Operator):
supported_bl_types.append(type_module_class.bl_id)
# Retreive local replicated types settings
if type_impl_name not in settings.supported_datablocks:
logging.info(f"{type_impl_name} not found, \
regenerate type settings...")
settings.generate_supported_types()
type_local_config = settings.supported_datablocks[type_impl_name]
bpy_factory.register_type(
@ -121,7 +199,7 @@ class SessionStartOperator(bpy.types.Operator):
timout=type_local_config.bl_delay_apply,
target_type=type_module_class))
client = Session(
session.configure(
factory=bpy_factory,
python_path=bpy.app.binary_path_python,
external_update_handling=use_extern_update)
@ -138,11 +216,11 @@ class SessionStartOperator(bpy.types.Operator):
runtime_settings.is_host = True
runtime_settings.internet_ip = environment.get_ip()
for scene in bpy.data.scenes:
client.add(scene)
try:
client.host(
for scene in bpy.data.scenes:
session.add(scene)
session.host(
id=settings.username,
port=settings.port,
ipc_port=settings.ipc_port,
@ -160,11 +238,11 @@ class SessionStartOperator(bpy.types.Operator):
else:
if not runtime_settings.admin:
utils.clean_scene()
# regular client, no password needed
# regular session, no password needed
admin_pass = None
try:
client.connect(
session.connect(
id=settings.username,
address=settings.ip,
port=settings.port,
@ -183,56 +261,17 @@ class SessionStartOperator(bpy.types.Operator):
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(
@ -269,15 +308,13 @@ 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:
client.add(scene)
session.add(scene)
client.init()
session.init()
return {"FINISHED"}
@ -293,11 +330,12 @@ class SessionStopOperator(bpy.types.Operator):
return True
def execute(self, context):
global client, delayables, stop_modal_executor
global delayables, stop_modal_executor
if client:
if session:
try:
client.disconnect()
session.disconnect()
except Exception as e:
self.report({'ERROR'}, repr(e))
else:
@ -319,11 +357,11 @@ class SessionKickOperator(bpy.types.Operator):
return True
def execute(self, context):
global client, delayables, stop_modal_executor
assert(client)
global delayables, stop_modal_executor
assert(session)
try:
client.kick(self.user)
session.kick(self.user)
except Exception as e:
self.report({'ERROR'}, repr(e))
@ -350,9 +388,8 @@ class SessionPropertyRemoveOperator(bpy.types.Operator):
return True
def execute(self, context):
global client
try:
client.remove(self.property_path)
session.remove(self.property_path)
return {"FINISHED"}
except: # NonAuthorizedOperationError:
@ -387,10 +424,9 @@ class SessionPropertyRightOperator(bpy.types.Operator):
def execute(self, context):
runtime_settings = context.window_manager.session
global client
if client:
client.change_owner(self.key, runtime_settings.clients)
if session:
session.change_owner(self.key, runtime_settings.clients)
return {"FINISHED"}
@ -437,10 +473,9 @@ class SessionSnapUserOperator(bpy.types.Operator):
if event.type == 'TIMER':
area, region, rv3d = presence.view3d_find()
global client
if client:
target_ref = client.online_users.get(self.target_client)
if session:
target_ref = session.online_users.get(self.target_client)
if target_ref:
target_scene = target_ref['metadata']['scene_current']
@ -511,10 +546,8 @@ class SessionSnapTimeOperator(bpy.types.Operator):
return {'CANCELLED'}
if event.type == 'TIMER':
global client
if client:
target_ref = client.online_users.get(self.target_client)
if session:
target_ref = session.online_users.get(self.target_client)
if target_ref:
context.scene.frame_current = target_ref['metadata']['frame_current']
@ -537,9 +570,7 @@ class SessionApply(bpy.types.Operator):
return True
def execute(self, context):
global client
client.apply(self.target)
session.apply(self.target)
return {"FINISHED"}
@ -557,10 +588,9 @@ class SessionCommit(bpy.types.Operator):
return True
def execute(self, context):
global client
# client.get(uuid=target).diff()
client.commit(uuid=self.target)
client.push(self.target)
# session.get(uuid=target).diff()
session.commit(uuid=self.target)
session.push(self.target)
return {"FINISHED"}
@ -578,16 +608,15 @@ class ApplyArmatureOperator(bpy.types.Operator):
return {'CANCELLED'}
if event.type == 'TIMER':
global client
if client and client.state['STATE'] == STATE_ACTIVE:
nodes = client.list(filter=bl_types.bl_armature.BlArmature)
if session and session.state['STATE'] == STATE_ACTIVE:
nodes = session.list(filter=bl_types.bl_armature.BlArmature)
for node in nodes:
node_ref = client.get(uuid=node)
node_ref = session.get(uuid=node)
if node_ref.state == FETCHED:
try:
client.apply(node)
session.apply(node)
except Exception as e:
logging.error("Fail to apply armature: {e}")
@ -608,6 +637,35 @@ 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,
@ -620,7 +678,7 @@ classes = (
ApplyArmatureOperator,
SessionKickOperator,
SessionInitOperator,
ClearCache,
)
@ -632,32 +690,28 @@ def sanitize_deps_graph(dummy):
A future solution should be to avoid storing dataclock reference...
"""
global client
if client and client.state['STATE'] == STATE_ACTIVE:
for node_key in client.list():
client.get(node_key).resolve()
if session and session.state['STATE'] == STATE_ACTIVE:
for node_key in session.list():
session.get(node_key).resolve()
@persistent
def load_pre_handler(dummy):
global client
if client and client.state['STATE'] in [STATE_ACTIVE, STATE_SYNCING]:
if session and session.state['STATE'] in [STATE_ACTIVE, STATE_SYNCING]:
bpy.ops.session.stop()
@persistent
def update_client_frame(scene):
if client and client.state['STATE'] == STATE_ACTIVE:
client.update_user_metadata({
if session and session.state['STATE'] == STATE_ACTIVE:
session.update_user_metadata({
'frame_current': scene.frame_current
})
@persistent
def depsgraph_evaluation(scene):
if client and client.state['STATE'] == STATE_ACTIVE:
if session and session.state['STATE'] == STATE_ACTIVE:
context = bpy.context
blender_depsgraph = bpy.context.view_layer.depsgraph
dependency_updates = [u for u in blender_depsgraph.updates]
@ -669,19 +723,19 @@ def depsgraph_evaluation(scene):
# Is the object tracked ?
if update.id.uuid:
# Retrieve local version
node = client.get(update.id.uuid)
node = session.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 [client.id, RP_COMMON] and node.state == UP:
if node and node.owner in [session.id, RP_COMMON] and node.state == UP:
# Avoid slow geometry update
if 'EDIT' in context.mode and \
not settings.enable_editmode_updates:
not settings.sync_during_editmode:
break
client.stash(node.uuid)
session.stash(node.uuid)
else:
# Distant update
continue
@ -703,11 +757,8 @@ def register():
def unregister():
global client
if client and client.state['STATE'] == 2:
client.disconnect()
client = None
if session and session.state['STATE'] == STATE_ACTIVE:
session.disconnect()
from bpy.utils import unregister_class
for cls in reversed(classes):

View File

@ -20,10 +20,14 @@ 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+')
@ -68,6 +72,16 @@ def update_port(self, context):
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)
@ -86,11 +100,44 @@ 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)
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)
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=True)
default=True,
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
)
class SessionPrefs(bpy.types.AddonPreferences):
@ -123,8 +170,8 @@ class SessionPrefs(bpy.types.AddonPreferences):
ipc_port: bpy.props.IntProperty(
name="ipc_port",
description='internal ttl port(only usefull for multiple local instances)',
default=5561,
update=update_port
default=random.randrange(5570, 70000),
update=update_port,
)
init_method: bpy.props.EnumProperty(
name='init_method',
@ -136,7 +183,8 @@ class SessionPrefs(bpy.types.AddonPreferences):
cache_directory: bpy.props.StringProperty(
name="cache directory",
subtype="DIR_PATH",
default=environment.DEFAULT_CACHE_DIR)
default=environment.DEFAULT_CACHE_DIR,
update=update_directory)
connection_timeout: bpy.props.IntProperty(
name='connection timeout',
description='connection timeout before disconnection',
@ -157,9 +205,9 @@ class SessionPrefs(bpy.types.AddonPreferences):
description='Dependency graph uppdate rate (milliseconds)',
default=100
)
enable_editmode_updates: bpy.props.BoolProperty(
name="Edit mode updates",
description="Enable objects update in edit mode (! Impact performances !)",
clear_memory_filecache: bpy.props.BoolProperty(
name="Clear memory filecache",
description="Remove filecache from memory",
default=False
)
# for UI
@ -230,6 +278,12 @@ 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",
@ -343,6 +397,7 @@ 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()
@ -383,9 +438,9 @@ def client_list_callback(scene, context):
items = [(RP_COMMON, RP_COMMON, "")]
username = get_preferences().username
cli = operators.client
if cli:
client_ids = cli.online_users.keys()
if session:
client_ids = session.online_users.keys()
for id in client_ids:
name_desc = id
if id == username:

View File

@ -18,8 +18,7 @@
import bpy
from . import operators
from .utils import get_preferences, get_expanded_icon
from .utils import get_preferences, get_expanded_icon, get_folder_size
from replication.constants import (ADDED, ERROR, FETCHED,
MODIFIED, RP_COMMON, UP,
STATE_ACTIVE, STATE_AUTH,
@ -29,13 +28,15 @@ 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
'FILE_REFRESH', # UP
'TRIA_UP'] # CHANGED
'TRIA_UP',
'ERROR'] # CHANGED
def printProgressBar(iteration, total, prefix='', suffix='', decimals=1, length=100, fill='', fill_empty=' '):
@ -95,9 +96,9 @@ class SESSION_PT_settings(bpy.types.Panel):
def draw_header(self, context):
layout = self.layout
if operators.client and operators.client.state['STATE'] != STATE_INITIAL:
cli_state = operators.client.state
state = operators.client.state.get('STATE')
if session and session.state['STATE'] != STATE_INITIAL:
cli_state = session.state
state = session.state.get('STATE')
connection_icon = "KEYTYPE_MOVING_HOLD_VEC"
if state == STATE_ACTIVE:
@ -118,65 +119,43 @@ class SESSION_PT_settings(bpy.types.Panel):
if hasattr(context.window_manager, 'session'):
# STATE INITIAL
if not operators.client \
or (operators.client and operators.client.state['STATE'] == STATE_INITIAL):
if not session \
or (session and session.state['STATE'] == STATE_INITIAL):
pass
else:
cli_state = operators.client.state
cli_state = session.state
row = layout.row()
current_state = cli_state['STATE']
info_msg = None
# STATE ACTIVE
if current_state in [STATE_ACTIVE]:
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()
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}"
if current_state == STATE_LOBBY:
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]:
info_msg = "Waiting the session to start."
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
))
if info_msg:
info_box = row.box()
info_box.row().label(text=info_msg,icon='INFO')
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,
# 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'],
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"
@ -187,8 +166,8 @@ class SESSION_PT_settings_network(bpy.types.Panel):
@classmethod
def poll(cls, context):
return not operators.client \
or (operators.client and operators.client.state['STATE'] == 0)
return not session \
or (session and session.state['STATE'] == 0)
def draw_header(self, context):
self.layout.label(text="", icon='URL')
@ -245,8 +224,8 @@ class SESSION_PT_settings_user(bpy.types.Panel):
@classmethod
def poll(cls, context):
return not operators.client \
or (operators.client and operators.client.state['STATE'] == 0)
return not session \
or (session and session.state['STATE'] == 0)
def draw_header(self, context):
self.layout.label(text="", icon='USER')
@ -276,8 +255,8 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
@classmethod
def poll(cls, context):
return not operators.client \
or (operators.client and operators.client.state['STATE'] == 0)
return not session \
or (session and session.state['STATE'] == 0)
def draw_header(self, context):
self.layout.label(text="", icon='PREFERENCES')
@ -320,10 +299,12 @@ 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, "enable_editmode_updates")
replication_section_row.prop(settings.sync_flags, "sync_active_camera")
replication_section_row = replication_section.row()
if settings.enable_editmode_updates:
replication_section_row.prop(settings.sync_flags, "sync_during_editmode")
replication_section_row = replication_section.row()
if settings.sync_flags.sync_during_editmode:
warning = replication_section_row.box()
warning.label(text="Don't use this with heavy meshes !", icon='ERROR')
replication_section_row = replication_section.row()
@ -356,6 +337,23 @@ 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,
@ -377,7 +375,7 @@ class SESSION_PT_user(bpy.types.Panel):
@classmethod
def poll(cls, context):
return operators.client and operators.client.state['STATE'] in [STATE_ACTIVE, STATE_LOBBY]
return session and session.state['STATE'] in [STATE_ACTIVE, STATE_LOBBY]
def draw_header(self, context):
self.layout.label(text="", icon='USER')
@ -408,7 +406,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 operators.client.state['STATE'] == STATE_ACTIVE:
if session.state['STATE'] == STATE_ACTIVE:
user_operations.alert = context.window_manager.session.time_snap_running
user_operations.operator(
@ -422,7 +420,7 @@ class SESSION_PT_user(bpy.types.Panel):
text="",
icon='TIME').target_client = active_user.username
if operators.client.online_users[settings.username]['admin']:
if session.online_users[settings.username]['admin']:
user_operations.operator(
"session.kick",
text="",
@ -431,7 +429,6 @@ 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 = '-'
@ -466,8 +463,8 @@ class SESSION_PT_presence(bpy.types.Panel):
@classmethod
def poll(cls, context):
return not operators.client \
or (operators.client and operators.client.state['STATE'] in [STATE_INITIAL, STATE_ACTIVE])
return not session \
or (session and session.state['STATE'] in [STATE_INITIAL, STATE_ACTIVE])
def draw_header(self, context):
self.layout.prop(context.window_manager.session,
@ -485,48 +482,18 @@ class SESSION_PT_presence(bpy.types.Panel):
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 = operators.client.get(uuid=property_uuid)
if item.state == ERROR:
return
item = session.get(uuid=property_uuid)
area_msg = parent.row(align=True)
if level > 0:
for i in range(level):
area_msg.label(text="")
if item.state == ERROR:
area_msg.alert=True
else:
area_msg.alert=False
line = area_msg.box()
name = item.data['name'] if item.data else item.uuid
@ -539,8 +506,8 @@ def draw_property(context, parent, property_uuid, level=0):
# Operations
have_right_to_modify = item.owner == settings.username or \
item.owner == RP_COMMON
have_right_to_modify = (item.owner == settings.username or \
item.owner == RP_COMMON) and item.state != ERROR
if have_right_to_modify:
detail_item_box.operator(
@ -576,7 +543,6 @@ 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"
@ -586,7 +552,6 @@ class SESSION_PT_repository(bpy.types.Panel):
@classmethod
def poll(cls, context):
session = operators.client
settings = get_preferences()
admin = False
@ -595,9 +560,9 @@ class SESSION_PT_repository(bpy.types.Panel):
if usr:
admin = usr['admin']
return hasattr(context.window_manager, 'session') and \
operators.client and \
(operators.client.state['STATE'] == STATE_ACTIVE or \
operators.client.state['STATE'] == STATE_LOBBY and admin)
session and \
(session.state['STATE'] == STATE_ACTIVE or \
session.state['STATE'] == STATE_LOBBY and admin)
def draw_header(self, context):
self.layout.label(text="", icon='OUTLINER_OB_GROUP_INSTANCE')
@ -609,7 +574,6 @@ 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()
@ -635,11 +599,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 = operators.client.list(
filter_owner=settings.username) if runtime_settings.filter_owned else operators.client.list()
key_to_filter = session.list(
filter_owner=settings.username) if runtime_settings.filter_owned else session.list()
client_keys = [key for key in key_to_filter
if operators.client.get(uuid=key).str_type
if session.get(uuid=key).str_type
in types_filter]
if client_keys:
@ -655,6 +619,35 @@ 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()
col.active = display_all
row = col.row(align=True)
settings = context.window_manager.session
layout.active = settings.enable_presence
col = layout.column()
col.prop(settings, "presence_show_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,
@ -664,9 +657,8 @@ classes = (
SESSION_PT_presence,
SESSION_PT_advanced_settings,
SESSION_PT_user,
SESSION_PT_services,
SESSION_PT_repository,
VIEW3D_PT_overlay_session,
)

View File

@ -21,8 +21,10 @@ import logging
import os
import sys
import time
from uuid import uuid4
from collections.abc import Iterable
from pathlib import Path
from uuid import uuid4
import math
import bpy
import mathutils
@ -47,7 +49,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':
if datatype.bl_name != 'users' and hasattr(bpy.data, datatype.bl_name):
root = getattr(bpy.data, datatype.bl_name)
for item in root:
if hasattr(item, 'data') and datablock == item.data or \
@ -78,17 +80,6 @@ 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
@ -103,3 +94,61 @@ 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))

View File

@ -0,0 +1,24 @@
# Download base image debian jessie
FROM python:slim
ARG replication_version=0.0.21a15
ARG version=0.1.0
# 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}

View File

@ -0,0 +1,6 @@
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(" ",""))

View File

@ -0,0 +1,4 @@
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))

View File

@ -1,21 +0,0 @@
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)