Compare commits

..

2 Commits

Author SHA1 Message Date
c06febed45 fix: compositor working 2021-06-18 17:20:12 +02:00
8c3e510231 feat: add bl_compositor + node_tree 2021-06-17 16:10:42 +02:00
58 changed files with 1377 additions and 2468 deletions

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

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

View File

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

View File

@ -19,9 +19,9 @@
bl_info = { bl_info = {
"name": "Multi-User", "name": "Multi-User",
"author": "Swann Martinez", "author": "Swann Martinez",
"version": (0, 4, 0), "version": (0, 5, 0),
"description": "Enable real-time collaborative workflow inside blender", "description": "Enable real-time collaborative workflow inside blender",
"blender": (2, 82, 0), "blender": (2, 93, 0),
"location": "3D View > Sidebar > Multi-User tab", "location": "3D View > Sidebar > Multi-User tab",
"warning": "Unstable addon, use it at your own risks", "warning": "Unstable addon, use it at your own risks",
"category": "Collaboration", "category": "Collaboration",
@ -59,9 +59,7 @@ def register():
from . import presence from . import presence
from . import operators from . import operators
from . import handlers
from . import ui from . import ui
from . import icons
from . import preferences from . import preferences
from . import addon_updater_ops from . import addon_updater_ops
@ -69,9 +67,7 @@ def register():
addon_updater_ops.register(bl_info) addon_updater_ops.register(bl_info)
presence.register() presence.register()
operators.register() operators.register()
handlers.register()
ui.register() ui.register()
icons.register()
except ModuleNotFoundError as e: except ModuleNotFoundError as e:
raise Exception(module_error_msg) raise Exception(module_error_msg)
logging.error(module_error_msg) logging.error(module_error_msg)
@ -85,28 +81,21 @@ def register():
type=preferences.SessionUser type=preferences.SessionUser
) )
bpy.types.WindowManager.user_index = bpy.props.IntProperty() bpy.types.WindowManager.user_index = bpy.props.IntProperty()
bpy.types.WindowManager.server_index = bpy.props.IntProperty()
bpy.types.TOPBAR_MT_file_import.append(operators.menu_func_import) bpy.types.TOPBAR_MT_file_import.append(operators.menu_func_import)
bpy.types.TOPBAR_MT_file_export.append(operators.menu_func_export)
def unregister(): def unregister():
from . import presence from . import presence
from . import operators from . import operators
from . import handlers
from . import ui from . import ui
from . import icons
from . import preferences from . import preferences
from . import addon_updater_ops from . import addon_updater_ops
bpy.types.TOPBAR_MT_file_import.remove(operators.menu_func_import) bpy.types.TOPBAR_MT_file_import.remove(operators.menu_func_import)
bpy.types.TOPBAR_MT_file_export.remove(operators.menu_func_export)
presence.unregister() presence.unregister()
addon_updater_ops.unregister() addon_updater_ops.unregister()
ui.unregister() ui.unregister()
icons.unregister()
handlers.unregister()
operators.unregister() operators.unregister()
preferences.unregister() preferences.unregister()
@ -114,6 +103,5 @@ def unregister():
del bpy.types.ID.uuid del bpy.types.ID.uuid
del bpy.types.WindowManager.online_users del bpy.types.WindowManager.online_users
del bpy.types.WindowManager.user_index del bpy.types.WindowManager.user_index
del bpy.types.WindowManager.server_index
environment.unregister() environment.unregister()

View File

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

View File

@ -219,7 +219,7 @@ def load_fcurve(fcurve_data, fcurve):
def dump_animation_data(datablock): def dump_animation_data(datablock):
animation_data = {} animation_data = {}
if has_action(datablock): if has_action(datablock):
animation_data['action'] = datablock.animation_data.action.uuid animation_data['action'] = datablock.animation_data.action.name
if has_driver(datablock): if has_driver(datablock):
animation_data['drivers'] = [] animation_data['drivers'] = []
for driver in datablock.animation_data.drivers: for driver in datablock.animation_data.drivers:
@ -241,10 +241,8 @@ def load_animation_data(animation_data, datablock):
for driver in animation_data['drivers']: for driver in animation_data['drivers']:
load_driver(datablock, driver) load_driver(datablock, driver)
action = animation_data.get('action') if 'action' in animation_data:
if action: datablock.animation_data.action = bpy.data.actions[animation_data['action']]
action = resolve_datablock_from_uuid(action, bpy.data.actions)
datablock.animation_data.action = action
elif datablock.animation_data.action: elif datablock.animation_data.action:
datablock.animation_data.action = None datablock.animation_data.action = None
@ -261,8 +259,6 @@ def resolve_animation_dependencies(datablock):
class BlAction(ReplicatedDatablock): class BlAction(ReplicatedDatablock):
use_delta = True
bl_id = "actions" bl_id = "actions"
bl_class = bpy.types.Action bl_class = bpy.types.Action
bl_check_common = False bl_check_common = False

View File

@ -37,8 +37,6 @@ def get_roll(bone: bpy.types.Bone) -> float:
class BlArmature(ReplicatedDatablock): class BlArmature(ReplicatedDatablock):
use_delta = True
bl_id = "armatures" bl_id = "armatures"
bl_class = bpy.types.Armature bl_class = bpy.types.Armature
bl_check_common = False bl_check_common = False

View File

@ -26,8 +26,6 @@ from .bl_action import dump_animation_data, load_animation_data, resolve_animati
class BlCamera(ReplicatedDatablock): class BlCamera(ReplicatedDatablock):
use_delta = True
bl_id = "cameras" bl_id = "cameras"
bl_class = bpy.types.Camera bl_class = bpy.types.Camera
bl_check_common = False bl_check_common = False
@ -56,7 +54,7 @@ class BlCamera(ReplicatedDatablock):
background_images = data.get('background_images') background_images = data.get('background_images')
datablock.background_images.clear() datablock.background_images.clear()
# TODO: Use image uuid
if background_images: if background_images:
for img_name, img_data in background_images.items(): for img_name, img_data in background_images.items():
img_id = img_data.get('image') img_id = img_data.get('image')

View File

@ -0,0 +1,81 @@
# ##### 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 re
from uuid import uuid4
from .dump_anything import Loader, Dumper
from replication.protocol import ReplicatedDatablock
from .bl_datablock import get_datablock_from_uuid, resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
from .node_tree import load_node_tree, dump_node_tree, get_node_tree_dependencies
class BlCompositor(ReplicatedDatablock):
bl_id = "compositor"
bl_class = bpy.types.CompositorNodeTree
bl_check_common = True
bl_icon = 'COMPOSITOR_NODE'
bl_reload_parent = False
@staticmethod
def construct(data: dict) -> object:
return bpy.data.scenes["Scene"].node_tree # TODO: resolve_datablock_from_uuid for multiple scenes
@staticmethod
def load(data: dict, datablock: object):
load_animation_data(data.get('animation_data'), datablock)
loader = Loader()
loader.load(datablock, data)
load_node_tree(data['node_tree'], datablock)
@staticmethod
def dump(datablock: object) -> dict:
comp_dumper = Dumper()
comp_dumper.depth = 1
comp_dumper.include_filter = [
'use_nodes',
'name',
]
data = comp_dumper.dump(datablock)
data['node_tree'] = dump_node_tree(datablock)
data['animation_data'] = dump_animation_data(datablock)
return data
@staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.scenes["Scene"].node_tree)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = []
deps.extend(get_node_tree_dependencies(datablock))
deps.extend(resolve_animation_dependencies(datablock))
return deps
_type = bpy.types.CompositorNodeTree
_class = BlCompositor

View File

@ -137,8 +137,6 @@ SPLINE_METADATA = [
class BlCurve(ReplicatedDatablock): class BlCurve(ReplicatedDatablock):
use_delta = True
bl_id = "curves" bl_id = "curves"
bl_class = bpy.types.Curve bl_class = bpy.types.Curve
bl_check_common = False bl_check_common = False

View File

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

View File

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

View File

@ -29,8 +29,6 @@ POINT = ['co', 'weight_softbody', 'co_deform']
class BlLattice(ReplicatedDatablock): class BlLattice(ReplicatedDatablock):
use_delta = True
bl_id = "lattices" bl_id = "lattices"
bl_class = bpy.types.Lattice bl_class = bpy.types.Lattice
bl_check_common = False bl_check_common = False

View File

@ -26,8 +26,6 @@ from .bl_action import dump_animation_data, load_animation_data, resolve_animati
class BlLight(ReplicatedDatablock): class BlLight(ReplicatedDatablock):
use_delta = True
bl_id = "lights" bl_id = "lights"
bl_class = bpy.types.Light bl_class = bpy.types.Light
bl_check_common = False bl_check_common = False

View File

@ -25,8 +25,6 @@ from replication.protocol import ReplicatedDatablock
from .bl_datablock import resolve_datablock_from_uuid from .bl_datablock import resolve_datablock_from_uuid
class BlLightprobe(ReplicatedDatablock): class BlLightprobe(ReplicatedDatablock):
use_delta = True
bl_id = "lightprobes" bl_id = "lightprobes"
bl_class = bpy.types.LightProbe bl_class = bpy.types.LightProbe
bl_check_common = False bl_check_common = False
@ -37,7 +35,7 @@ class BlLightprobe(ReplicatedDatablock):
def construct(data: dict) -> object: def construct(data: dict) -> object:
type = 'CUBE' if data['type'] == 'CUBEMAP' else data['type'] type = 'CUBE' if data['type'] == 'CUBEMAP' else data['type']
# See https://developer.blender.org/D6396 # See https://developer.blender.org/D6396
if bpy.app.version >= (2,83,0): if bpy.app.version[1] >= 83:
return bpy.data.lightprobes.new(data["name"], type) return bpy.data.lightprobes.new(data["name"], type)
else: else:
logging.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") logging.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396")
@ -49,7 +47,7 @@ class BlLightprobe(ReplicatedDatablock):
@staticmethod @staticmethod
def dump(datablock: object) -> dict: def dump(datablock: object) -> dict:
if bpy.app.version < (2,83,0): if bpy.app.version[1] < 83:
logging.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") logging.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396")
dumper = Dumper() dumper = Dumper()

View File

@ -28,341 +28,7 @@ from replication.protocol import ReplicatedDatablock
from .bl_datablock import get_datablock_from_uuid, resolve_datablock_from_uuid from .bl_datablock import get_datablock_from_uuid, resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
from .node_tree import load_node_tree, dump_node_tree, get_node_tree_dependencies
NODE_SOCKET_INDEX = re.compile('\[(\d*)\]')
IGNORED_SOCKETS = ['GEOMETRY', 'SHADER', 'CUSTOM']
def load_node(node_data: dict, node_tree: bpy.types.ShaderNodeTree):
""" Load a node into a node_tree from a dict
:arg node_data: dumped node data
:type node_data: dict
:arg node_tree: target node_tree
:type node_tree: bpy.types.NodeTree
"""
loader = Loader()
target_node = node_tree.nodes.new(type=node_data["bl_idname"])
target_node.select = False
loader.load(target_node, node_data)
image_uuid = node_data.get('image_uuid', None)
node_tree_uuid = node_data.get('node_tree_uuid', None)
if image_uuid and not target_node.image:
image = resolve_datablock_from_uuid(image_uuid, bpy.data.images)
if image is None:
logging.error(f"Fail to find material image from uuid {image_uuid}")
else:
target_node.image = image
if node_tree_uuid:
target_node.node_tree = get_datablock_from_uuid(node_tree_uuid, None)
inputs_data = node_data.get('inputs')
if inputs_data:
inputs = [i for i in target_node.inputs if i.type not in IGNORED_SOCKETS]
for idx, inpt in enumerate(inputs):
if idx < len(inputs_data) and hasattr(inpt, "default_value"):
loaded_input = inputs_data[idx]
try:
if inpt.type in ['OBJECT', 'COLLECTION']:
inpt.default_value = get_datablock_from_uuid(loaded_input, None)
else:
inpt.default_value = loaded_input
except Exception as e:
logging.warning(f"Node {target_node.name} input {inpt.name} parameter not supported, skipping ({e})")
else:
logging.warning(f"Node {target_node.name} input length mismatch.")
outputs_data = node_data.get('outputs')
if outputs_data:
outputs = [o for o in target_node.outputs if o.type not in IGNORED_SOCKETS]
for idx, output in enumerate(outputs):
if idx < len(outputs_data) and hasattr(output, "default_value"):
loaded_output = outputs_data[idx]
try:
if output.type in ['OBJECT', 'COLLECTION']:
output.default_value = get_datablock_from_uuid(loaded_output, None)
else:
output.default_value = loaded_output
except Exception as e:
logging.warning(
f"Node {target_node.name} output {output.name} parameter not supported, skipping ({e})")
else:
logging.warning(
f"Node {target_node.name} output length mismatch.")
def dump_node(node: bpy.types.ShaderNode) -> dict:
""" Dump a single node to a dict
:arg node: target node
:type node: bpy.types.Node
:retrun: dict
"""
node_dumper = Dumper()
node_dumper.depth = 1
node_dumper.exclude_filter = [
"dimensions",
"show_expanded",
"name_full",
"select",
"bl_label",
"bl_height_min",
"bl_height_max",
"bl_height_default",
"bl_width_min",
"bl_width_max",
"type",
"bl_icon",
"bl_width_default",
"bl_static_type",
"show_tetxure",
"is_active_output",
"hide",
"show_options",
"show_preview",
"show_texture",
"outputs",
"width_hidden"
]
dumped_node = node_dumper.dump(node)
if node.parent:
dumped_node['parent'] = node.parent.name
dump_io_needed = (node.type not in ['REROUTE', 'OUTPUT_MATERIAL'])
if dump_io_needed:
io_dumper = Dumper()
io_dumper.depth = 2
io_dumper.include_filter = ["default_value"]
if hasattr(node, 'inputs'):
dumped_node['inputs'] = []
inputs = [i for i in node.inputs if i.type not in IGNORED_SOCKETS]
for idx, inpt in enumerate(inputs):
if hasattr(inpt, 'default_value'):
if isinstance(inpt.default_value, bpy.types.ID):
dumped_input = inpt.default_value.uuid
else:
dumped_input = io_dumper.dump(inpt.default_value)
dumped_node['inputs'].append(dumped_input)
if hasattr(node, 'outputs'):
dumped_node['outputs'] = []
for idx, output in enumerate(node.outputs):
if output.type not in IGNORED_SOCKETS:
if hasattr(output, 'default_value'):
dumped_node['outputs'].append(
io_dumper.dump(output.default_value))
if hasattr(node, 'color_ramp'):
ramp_dumper = Dumper()
ramp_dumper.depth = 4
ramp_dumper.include_filter = [
'elements',
'alpha',
'color',
'position',
'interpolation',
'hue_interpolation',
'color_mode'
]
dumped_node['color_ramp'] = ramp_dumper.dump(node.color_ramp)
if hasattr(node, 'mapping'):
curve_dumper = Dumper()
curve_dumper.depth = 5
curve_dumper.include_filter = [
'curves',
'points',
'location'
]
dumped_node['mapping'] = curve_dumper.dump(node.mapping)
if hasattr(node, 'image') and getattr(node, 'image'):
dumped_node['image_uuid'] = node.image.uuid
if hasattr(node, 'node_tree') and getattr(node, 'node_tree'):
dumped_node['node_tree_uuid'] = node.node_tree.uuid
return dumped_node
def load_links(links_data, node_tree):
""" Load node_tree links from a list
:arg links_data: dumped node links
:type links_data: list
:arg node_tree: node links collection
:type node_tree: bpy.types.NodeTree
"""
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'])]
node_tree.links.new(input_socket, output_socket)
def dump_links(links):
""" Dump node_tree links collection to a list
:arg links: node links collection
:type links: bpy.types.NodeLinks
:retrun: list
"""
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)
links_data.append({
'to_node': link.to_node.name,
'to_socket': to_socket,
'from_node': link.from_node.name,
'from_socket': from_socket,
})
return links_data
def dump_node_tree(node_tree: bpy.types.ShaderNodeTree) -> dict:
""" Dump a shader node_tree to a dict including links and nodes
:arg node_tree: dumped shader node tree
:type node_tree: bpy.types.ShaderNodeTree
:return: dict
"""
node_tree_data = {
'nodes': {node.name: dump_node(node) for node in node_tree.nodes},
'links': dump_links(node_tree.links),
'name': node_tree.name,
'type': type(node_tree).__name__
}
for socket_id in ['inputs', 'outputs']:
socket_collection = getattr(node_tree, socket_id)
node_tree_data[socket_id] = dump_node_tree_sockets(socket_collection)
return node_tree_data
def dump_node_tree_sockets(sockets: bpy.types.Collection) -> dict:
""" dump sockets of a shader_node_tree
:arg target_node_tree: target node_tree
:type target_node_tree: bpy.types.NodeTree
:arg socket_id: socket identifer
:type socket_id: str
:return: dict
"""
sockets_data = []
for socket in sockets:
try:
socket_uuid = socket['uuid']
except Exception:
socket_uuid = str(uuid4())
socket['uuid'] = socket_uuid
sockets_data.append((socket.name, socket.bl_socket_idname, socket_uuid))
return sockets_data
def load_node_tree_sockets(sockets: bpy.types.Collection,
sockets_data: dict):
""" load sockets of a shader_node_tree
:arg target_node_tree: target node_tree
:type target_node_tree: bpy.types.NodeTree
:arg socket_id: socket identifer
:type socket_id: str
:arg socket_data: dumped socket data
:type socket_data: dict
"""
# Check for removed sockets
for socket in sockets:
if not [s for s in sockets_data if 'uuid' in socket and socket['uuid'] == s[2]]:
sockets.remove(socket)
# Check for new sockets
for idx, socket_data in enumerate(sockets_data):
try:
checked_socket = sockets[idx]
if checked_socket.name != socket_data[0]:
checked_socket.name = socket_data[0]
except Exception:
s = sockets.new(socket_data[1], socket_data[0])
s['uuid'] = socket_data[2]
def load_node_tree(node_tree_data: dict, target_node_tree: bpy.types.ShaderNodeTree) -> dict:
"""Load a shader node_tree from dumped data
:arg node_tree_data: dumped node data
:type node_tree_data: dict
:arg target_node_tree: target node_tree
:type target_node_tree: bpy.types.NodeTree
"""
# TODO: load only required nodes
target_node_tree.nodes.clear()
if not target_node_tree.is_property_readonly('name'):
target_node_tree.name = node_tree_data['name']
if 'inputs' in node_tree_data:
socket_collection = getattr(target_node_tree, 'inputs')
load_node_tree_sockets(socket_collection, node_tree_data['inputs'])
if 'outputs' in node_tree_data:
socket_collection = getattr(target_node_tree, 'outputs')
load_node_tree_sockets(socket_collection, node_tree_data['outputs'])
# Load nodes
for node in node_tree_data["nodes"]:
load_node(node_tree_data["nodes"][node], target_node_tree)
for node_id, node_data in node_tree_data["nodes"].items():
target_node = target_node_tree.nodes.get(node_id, None)
if target_node is None:
continue
elif 'parent' in node_data:
target_node.parent = target_node_tree.nodes[node_data['parent']]
else:
target_node.parent = None
# TODO: load only required nodes links
# Load nodes links
target_node_tree.links.clear()
load_links(node_tree_data["links"], target_node_tree)
def get_node_tree_dependencies(node_tree: bpy.types.NodeTree) -> list:
def has_image(node): return (
node.type in ['TEX_IMAGE', 'TEX_ENVIRONMENT'] and node.image)
def has_node_group(node): return (
hasattr(node, 'node_tree') and node.node_tree)
def has_texture(node): return (
node.type in ['ATTRIBUTE_SAMPLE_TEXTURE','TEXTURE'] and node.texture)
deps = []
for node in node_tree.nodes:
if has_image(node):
deps.append(node.image)
elif has_node_group(node):
deps.append(node.node_tree)
elif has_texture(node):
deps.append(node.texture)
return deps
def dump_materials_slots(materials: bpy.types.bpy_prop_collection) -> list: def dump_materials_slots(materials: bpy.types.bpy_prop_collection) -> list:
""" Dump material slots collection """ Dump material slots collection
@ -387,22 +53,20 @@ def load_materials_slots(src_materials: list, dst_materials: bpy.types.bpy_prop_
for mat_uuid, mat_name in src_materials: for mat_uuid, mat_name in src_materials:
mat_ref = None mat_ref = None
if mat_uuid: if mat_uuid is not None:
mat_ref = get_datablock_from_uuid(mat_uuid, None) mat_ref = get_datablock_from_uuid(mat_uuid, None)
else: else:
mat_ref = bpy.data.materials[mat_name] mat_ref = bpy.data.materials[mat_name]
dst_materials.append(mat_ref) dst_materials.append(mat_ref)
class BlMaterial(ReplicatedDatablock): class BlMaterial(ReplicatedDatablock):
use_delta = True
bl_id = "materials" bl_id = "materials"
bl_class = bpy.types.Material bl_class = bpy.types.Material
bl_check_common = False bl_check_common = False
bl_icon = 'MATERIAL_DATA' bl_icon = 'MATERIAL_DATA'
bl_reload_parent = False bl_reload_parent = False
bl_reload_child = True
@staticmethod @staticmethod
def construct(data: dict) -> object: def construct(data: dict) -> object:
@ -410,6 +74,8 @@ class BlMaterial(ReplicatedDatablock):
@staticmethod @staticmethod
def load(data: dict, datablock: object): def load(data: dict, datablock: object):
load_animation_data(data.get('animation_data'), datablock)
loader = Loader() loader = Loader()
is_grease_pencil = data.get('is_grease_pencil') is_grease_pencil = data.get('is_grease_pencil')
@ -426,8 +92,6 @@ class BlMaterial(ReplicatedDatablock):
datablock.use_nodes = True datablock.use_nodes = True
load_node_tree(data['node_tree'], datablock.node_tree) load_node_tree(data['node_tree'], datablock.node_tree)
load_animation_data(data.get('nodes_animation_data'), datablock.node_tree)
load_animation_data(data.get('animation_data'), datablock)
@staticmethod @staticmethod
def dump(datablock: object) -> dict: def dump(datablock: object) -> dict:
@ -495,10 +159,8 @@ class BlMaterial(ReplicatedDatablock):
data['grease_pencil'] = gp_mat_dumper.dump(datablock.grease_pencil) data['grease_pencil'] = gp_mat_dumper.dump(datablock.grease_pencil)
elif datablock.use_nodes: elif datablock.use_nodes:
data['node_tree'] = dump_node_tree(datablock.node_tree) data['node_tree'] = dump_node_tree(datablock.node_tree)
data['nodes_animation_data'] = dump_animation_data(datablock.node_tree)
data['animation_data'] = dump_animation_data(datablock) data['animation_data'] = dump_animation_data(datablock)
return data return data
@staticmethod @staticmethod
@ -512,7 +174,7 @@ class BlMaterial(ReplicatedDatablock):
if datablock.use_nodes: if datablock.use_nodes:
deps.extend(get_node_tree_dependencies(datablock.node_tree)) deps.extend(get_node_tree_dependencies(datablock.node_tree))
deps.extend(resolve_animation_dependencies(datablock.node_tree))
deps.extend(resolve_animation_dependencies(datablock)) deps.extend(resolve_animation_dependencies(datablock))
return deps return deps

View File

@ -55,8 +55,6 @@ POLYGON = [
] ]
class BlMesh(ReplicatedDatablock): class BlMesh(ReplicatedDatablock):
use_delta = True
bl_id = "meshes" bl_id = "meshes"
bl_class = bpy.types.Mesh bl_class = bpy.types.Mesh
bl_check_common = False bl_check_common = False

View File

@ -65,8 +65,6 @@ def load_metaball_elements(elements_data, elements):
class BlMetaball(ReplicatedDatablock): class BlMetaball(ReplicatedDatablock):
use_delta = True
bl_id = "metaballs" bl_id = "metaballs"
bl_class = bpy.types.MetaBall bl_class = bpy.types.MetaBall
bl_check_common = False bl_check_common = False

View File

@ -28,8 +28,6 @@ from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
class BlNodeGroup(ReplicatedDatablock): class BlNodeGroup(ReplicatedDatablock):
use_delta = True
bl_id = "node_groups" bl_id = "node_groups"
bl_class = bpy.types.NodeTree bl_class = bpy.types.NodeTree
bl_check_common = False bl_check_common = False

View File

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

View File

@ -3,8 +3,7 @@ import mathutils
from . import dump_anything from . import dump_anything
from replication.protocol import ReplicatedDatablock from replication.protocol import ReplicatedDatablock
from .bl_datablock import get_datablock_from_uuid from .bl_datablock import get_datablock_from_uuid, resolve_datablock_from_uuid
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
@ -41,8 +40,6 @@ IGNORED_ATTR = [
] ]
class BlParticle(ReplicatedDatablock): class BlParticle(ReplicatedDatablock):
use_delta = True
bl_id = "particles" bl_id = "particles"
bl_class = bpy.types.ParticleSettings bl_class = bpy.types.ParticleSettings
bl_icon = "PARTICLES" bl_icon = "PARTICLES"

View File

@ -19,6 +19,7 @@
import logging import logging
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
import re
import bpy import bpy
import mathutils import mathutils
@ -29,10 +30,12 @@ from replication.protocol import ReplicatedDatablock
from ..utils import flush_history, get_preferences from ..utils import flush_history, get_preferences
from .bl_action import (dump_animation_data, load_animation_data, from .bl_action import (dump_animation_data, load_animation_data,
resolve_animation_dependencies) resolve_animation_dependencies)
from .node_tree import (get_node_tree_dependencies, load_node_tree,
dump_node_tree)
from .bl_collection import (dump_collection_children, dump_collection_objects, from .bl_collection import (dump_collection_children, dump_collection_objects,
load_collection_childrens, load_collection_objects, load_collection_childrens, load_collection_objects,
resolve_collection_dependencies) resolve_collection_dependencies)
from .bl_datablock import resolve_datablock_from_uuid from .bl_datablock import get_datablock_from_uuid, resolve_datablock_from_uuid
from .bl_file import get_filepath from .bl_file import get_filepath
from .dump_anything import Dumper, Loader from .dump_anything import Dumper, Loader
@ -303,7 +306,6 @@ def dump_sequence(sequence: bpy.types.Sequence) -> dict:
return data return data
def load_sequence(sequence_data: dict, def load_sequence(sequence_data: dict,
sequence_editor: bpy.types.SequenceEditor): sequence_editor: bpy.types.SequenceEditor):
""" Load sequence from dumped data """ Load sequence from dumped data
@ -370,7 +372,6 @@ def load_sequence(sequence_data: dict,
loader.load(sequence, sequence_data) loader.load(sequence, sequence_data)
sequence.select = False sequence.select = False
class BlScene(ReplicatedDatablock): class BlScene(ReplicatedDatablock):
is_root = True is_root = True
use_delta = True use_delta = True
@ -403,9 +404,8 @@ class BlScene(ReplicatedDatablock):
datablock.world = bpy.data.worlds[data['world']] datablock.world = bpy.data.worlds[data['world']]
# Annotation # Annotation
gpencil_uid = data.get('grease_pencil') if 'grease_pencil' in data.keys():
if gpencil_uid: datablock.grease_pencil = bpy.data.grease_pencils[data['grease_pencil']]
datablock.grease_pencil = resolve_datablock_from_uuid(gpencil_uid, bpy.data.grease_pencils)
if get_preferences().sync_flags.sync_render_settings: if get_preferences().sync_flags.sync_render_settings:
if 'eevee' in data.keys(): if 'eevee' in data.keys():
@ -440,23 +440,22 @@ class BlScene(ReplicatedDatablock):
if seq.name not in sequences: if seq.name not in sequences:
vse.sequences.remove(seq) vse.sequences.remove(seq)
# Load existing sequences # Load existing sequences
for seq_data in sequences.values(): for seq_data in sequences.value():
load_sequence(seq_data, vse) load_sequence(seq_data, vse)
# If the sequence is no longer used, clear it # If the sequence is no longer used, clear it
elif datablock.sequence_editor and not sequences: elif datablock.sequence_editor and not sequences:
datablock.sequence_editor_clear() datablock.sequence_editor_clear()
# Timeline markers
markers = data.get('timeline_markers')
if markers:
datablock.timeline_markers.clear()
for name, frame, camera in markers:
marker = datablock.timeline_markers.new(name, frame=frame)
if camera:
marker.camera = resolve_datablock_from_uuid(camera, bpy.data.objects)
marker.select = False
# FIXME: Find a better way after the replication big refacotoring # FIXME: Find a better way after the replication big refacotoring
# Keep other user from deleting collection object by flushing their history # Keep other user from deleting collection object by flushing their history
# Compositor
if data["use_nodes"]:
if datablock.node_tree is None:
datablock.use_nodes = True
load_node_tree(data['node_tree'], datablock.node_tree)
flush_history() flush_history()
@staticmethod @staticmethod
@ -468,9 +467,11 @@ class BlScene(ReplicatedDatablock):
scene_dumper = Dumper() scene_dumper = Dumper()
scene_dumper.depth = 1 scene_dumper.depth = 1
scene_dumper.include_filter = [ scene_dumper.include_filter = [
'use_nodes',
'name', 'name',
'world', 'world',
'id', 'id',
'grease_pencil',
'frame_start', 'frame_start',
'frame_end', 'frame_end',
'frame_step', 'frame_step',
@ -526,12 +527,10 @@ class BlScene(ReplicatedDatablock):
dumped_sequences[seq.name] = dump_sequence(seq) dumped_sequences[seq.name] = dump_sequence(seq)
data['sequences'] = dumped_sequences data['sequences'] = dumped_sequences
# Timeline markers # Compositor
if datablock.timeline_markers: if datablock.use_nodes:
data['timeline_markers'] = [(m.name, m.frame, getattr(m.camera, 'uuid', None)) for m in datablock.timeline_markers] data['node_tree'] = dump_node_tree(datablock.node_tree)
data['animation_data'] = dump_animation_data(datablock)
if datablock.grease_pencil:
data['grease_pencil'] = datablock.grease_pencil.uuid
return data return data
@ -566,6 +565,12 @@ class BlScene(ReplicatedDatablock):
Path(bpy.path.abspath(sequence.directory), Path(bpy.path.abspath(sequence.directory),
elem.filename)) elem.filename))
# Compositor
if datablock.use_nodes:
deps.extend(get_node_tree_dependencies(datablock.node_tree))
deps.extend(resolve_animation_dependencies(datablock))
return deps return deps
@staticmethod @staticmethod

View File

@ -25,8 +25,6 @@ from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
class BlSpeaker(ReplicatedDatablock): class BlSpeaker(ReplicatedDatablock):
use_delta = True
bl_id = "speakers" bl_id = "speakers"
bl_class = bpy.types.Speaker bl_class = bpy.types.Speaker
bl_check_common = False bl_check_common = False

View File

@ -26,8 +26,6 @@ from .bl_action import dump_animation_data, load_animation_data, resolve_animati
import bpy.types as T import bpy.types as T
class BlTexture(ReplicatedDatablock): class BlTexture(ReplicatedDatablock):
use_delta = True
bl_id = "textures" bl_id = "textures"
bl_class = bpy.types.Texture bl_class = bpy.types.Texture
bl_check_common = False bl_check_common = False

View File

@ -27,8 +27,6 @@ from .bl_material import dump_materials_slots, load_materials_slots
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
class BlVolume(ReplicatedDatablock): class BlVolume(ReplicatedDatablock):
use_delta = True
bl_id = "volumes" bl_id = "volumes"
bl_class = bpy.types.Volume bl_class = bpy.types.Volume
bl_check_common = False bl_check_common = False

View File

@ -21,7 +21,7 @@ import mathutils
from .dump_anything import Loader, Dumper from .dump_anything import Loader, Dumper
from replication.protocol import ReplicatedDatablock from replication.protocol import ReplicatedDatablock
from .bl_material import (load_node_tree, from .node_tree import (load_node_tree,
dump_node_tree, dump_node_tree,
get_node_tree_dependencies) get_node_tree_dependencies)
@ -30,8 +30,6 @@ from .bl_action import dump_animation_data, load_animation_data, resolve_animati
class BlWorld(ReplicatedDatablock): class BlWorld(ReplicatedDatablock):
use_delta = True
bl_id = "worlds" bl_id = "worlds"
bl_class = bpy.types.World bl_class = bpy.types.World
bl_check_common = True bl_check_common = True

View File

@ -0,0 +1,362 @@
# ##### 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 re
from uuid import uuid4
from .dump_anything import Loader, Dumper
from .bl_datablock import get_datablock_from_uuid, resolve_datablock_from_uuid
IGNORED_SOCKETS = ['GEOMETRY', 'SHADER', 'CUSTOM']
NODE_SOCKET_INDEX = re.compile('\[(\d*)\]')
def load_node(node_data: dict, node_tree: bpy.types.NodeTree):
""" Load a node into a node_tree from a dict
:arg node_data: dumped node data
:type node_data: dict
:arg node_tree: target node_tree
:type node_tree: bpy.types.NodeTree
"""
loader = Loader()
target_node = node_tree.nodes.new(type=node_data["bl_idname"])
target_node.select = False
loader.load(target_node, node_data)
image_uuid = node_data.get('image_uuid', None)
node_tree_uuid = node_data.get('node_tree_uuid', None)
if image_uuid and not target_node.image:
image = resolve_datablock_from_uuid(image_uuid, bpy.data.images)
if image is None:
logging.error(f"Fail to find material image from uuid {image_uuid}")
else:
target_node.image = image
if node_tree_uuid:
target_node.node_tree = get_datablock_from_uuid(node_tree_uuid, None)
inputs_data = node_data.get('inputs')
if inputs_data:
inputs = [i for i in target_node.inputs if i.type not in IGNORED_SOCKETS]
for idx, inpt in enumerate(inputs):
if idx < len(inputs_data) and hasattr(inpt, "default_value"):
loaded_input = inputs_data[idx]
try:
if inpt.type in ['OBJECT', 'COLLECTION']:
inpt.default_value = get_datablock_from_uuid(loaded_input, None)
else:
inpt.default_value = loaded_input
except Exception as e:
logging.warning(f"Node {target_node.name} input {inpt.name} parameter not supported, skipping ({e})")
else:
logging.warning(f"Node {target_node.name} input length mismatch.")
outputs_data = node_data.get('outputs')
if outputs_data:
outputs = [o for o in target_node.outputs if o.type not in IGNORED_SOCKETS]
for idx, output in enumerate(outputs):
if idx < len(outputs_data) and hasattr(output, "default_value"):
loaded_output = outputs_data[idx]
try:
if output.type in ['OBJECT', 'COLLECTION']:
output.default_value = get_datablock_from_uuid(loaded_output, None)
else:
output.default_value = loaded_output
except Exception as e:
logging.warning(
f"Node {target_node.name} output {output.name} parameter not supported, skipping ({e})")
else:
logging.warning(
f"Node {target_node.name} output length mismatch.")
def dump_node(node: bpy.types.Node) -> dict:
""" Dump a single node to a dict
:arg node: target node
:type node: bpy.types.Node
:retrun: dict
"""
node_dumper = Dumper()
node_dumper.depth = 1
node_dumper.exclude_filter = [
"dimensions",
"show_expanded",
"name_full",
"select",
"bl_label",
"bl_height_min",
"bl_height_max",
"bl_height_default",
"bl_width_min",
"bl_width_max",
"type",
"bl_icon",
"bl_width_default",
"bl_static_type",
"show_tetxure",
"is_active_output",
"hide",
"show_options",
"show_preview",
"show_texture",
"outputs",
"width_hidden",
"image"
]
dumped_node = node_dumper.dump(node)
if node.parent:
dumped_node['parent'] = node.parent.name
dump_io_needed = (node.type not in ['REROUTE', 'OUTPUT_MATERIAL'])
if dump_io_needed:
io_dumper = Dumper()
io_dumper.depth = 2
io_dumper.include_filter = ["default_value"]
if hasattr(node, 'inputs'):
dumped_node['inputs'] = []
inputs = [i for i in node.inputs if i.type not in IGNORED_SOCKETS]
for idx, inpt in enumerate(inputs):
if hasattr(inpt, 'default_value'):
if isinstance(inpt.default_value, bpy.types.ID):
dumped_input = inpt.default_value.uuid
else:
dumped_input = io_dumper.dump(inpt.default_value)
dumped_node['inputs'].append(dumped_input)
if hasattr(node, 'outputs'):
dumped_node['outputs'] = []
for idx, output in enumerate(node.outputs):
if output.type not in IGNORED_SOCKETS:
if hasattr(output, 'default_value'):
dumped_node['outputs'].append(
io_dumper.dump(output.default_value))
if hasattr(node, 'color_ramp'):
ramp_dumper = Dumper()
ramp_dumper.depth = 4
ramp_dumper.include_filter = [
'elements',
'alpha',
'color',
'position',
'interpolation',
'hue_interpolation',
'color_mode'
]
dumped_node['color_ramp'] = ramp_dumper.dump(node.color_ramp)
if hasattr(node, 'mapping'):
curve_dumper = Dumper()
curve_dumper.depth = 5
curve_dumper.include_filter = [
'curves',
'points',
'location'
]
dumped_node['mapping'] = curve_dumper.dump(node.mapping)
if hasattr(node, 'image') and getattr(node, 'image'):
dumped_node['image_uuid'] = node.image.uuid
if hasattr(node, 'node_tree') and getattr(node, 'node_tree'):
dumped_node['node_tree_uuid'] = node.node_tree.uuid
return dumped_node
def load_links(links_data, node_tree):
""" Load node_tree links from a list
:arg links_data: dumped node links
:type links_data: list
:arg node_tree: node links collection
:type node_tree: bpy.types.NodeTree
"""
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'])]
node_tree.links.new(input_socket, output_socket)
def dump_links(links):
""" Dump node_tree links collection to a list
:arg links: node links collection
:type links: bpy.types.NodeLinks
:retrun: list
"""
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)
links_data.append({
'to_node': link.to_node.name,
'to_socket': to_socket,
'from_node': link.from_node.name,
'from_socket': from_socket,
})
return links_data
def dump_node_tree(node_tree: bpy.types.NodeTree) -> dict:
""" Dump a node_tree to a dict including links and nodes
:arg node_tree: dumped node tree
:type node_tree: bpy.types.NodeTree
:return: dict
"""
node_tree_data = {
'nodes': {node.name: dump_node(node) for node in node_tree.nodes},
'links': dump_links(node_tree.links),
'name': node_tree.name,
'type': type(node_tree).__name__
}
for socket_id in ['inputs', 'outputs']:
socket_collection = getattr(node_tree, socket_id)
node_tree_data[socket_id] = dump_node_tree_sockets(socket_collection)
return node_tree_data
def dump_node_tree_sockets(sockets: bpy.types.Collection) -> dict:
""" dump sockets of a shader_node_tree
:arg target_node_tree: target node_tree
:type target_node_tree: bpy.types.NodeTree
:arg socket_id: socket identifer
:type socket_id: str
:return: dict
"""
sockets_data = []
for socket in sockets:
try:
socket_uuid = socket['uuid']
except Exception:
socket_uuid = str(uuid4())
socket['uuid'] = socket_uuid
sockets_data.append((socket.name, socket.bl_socket_idname, socket_uuid))
return sockets_data
def load_node_tree_sockets(sockets: bpy.types.Collection,
sockets_data: dict):
""" load sockets of a shader_node_tree
:arg target_node_tree: target node_tree
:type target_node_tree: bpy.types.NodeTree
:arg socket_id: socket identifer
:type socket_id: str
:arg socket_data: dumped socket data
:type socket_data: dict
"""
# Check for removed sockets
for socket in sockets:
if not [s for s in sockets_data if 'uuid' in socket and socket['uuid'] == s[2]]:
sockets.remove(socket)
# Check for new sockets
for idx, socket_data in enumerate(sockets_data):
try:
checked_socket = sockets[idx]
if checked_socket.name != socket_data[0]:
checked_socket.name = socket_data[0]
except Exception:
s = sockets.new(socket_data[1], socket_data[0])
s['uuid'] = socket_data[2]
def load_node_tree(node_tree_data: dict, target_node_tree: bpy.types.NodeTree) -> dict:
"""Load a shader node_tree from dumped data
:arg node_tree_data: dumped node data
:type node_tree_data: dict
:arg target_node_tree: target node_tree
:type target_node_tree: bpy.types.NodeTree
"""
# TODO: load only required nodes
target_node_tree.nodes.clear()
if not target_node_tree.is_property_readonly('name'):
target_node_tree.name = node_tree_data['name']
if 'inputs' in node_tree_data:
socket_collection = getattr(target_node_tree, 'inputs')
load_node_tree_sockets(socket_collection, node_tree_data['inputs'])
if 'outputs' in node_tree_data:
socket_collection = getattr(target_node_tree, 'outputs')
load_node_tree_sockets(socket_collection, node_tree_data['outputs'])
# Load nodes
for node in node_tree_data["nodes"]:
load_node(node_tree_data["nodes"][node], target_node_tree)
for node_id, node_data in node_tree_data["nodes"].items():
target_node = target_node_tree.nodes.get(node_id, None)
if target_node is None:
continue
elif 'parent' in node_data:
target_node.parent = target_node_tree.nodes[node_data['parent']]
else:
target_node.parent = None
# TODO: load only required nodes links
# Load nodes links
target_node_tree.links.clear()
load_links(node_tree_data["links"], target_node_tree)
def get_node_tree_dependencies(node_tree: bpy.types.NodeTree) -> list:
def has_image(node): return (
node.type in ['TEX_IMAGE', 'TEX_ENVIRONMENT','IMAGE','R_LAYER'] and node.image)
def has_node_group(node): return (
hasattr(node, 'node_tree') and node.node_tree)
def has_texture(node): return (
node.type in ['ATTRIBUTE_SAMPLE_TEXTURE','TEXTURE'] and node.texture)
deps = []
for node in node_tree.nodes:
if has_image(node):
deps.append(node.image)
elif has_node_group(node):
deps.append(node.node_tree)
elif has_texture(node):
deps.append(node.texture)
return deps

View File

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

View File

@ -1,170 +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 bpy
from bpy.app.handlers import persistent
from replication import porcelain
from replication.constants import RP_COMMON, STATE_ACTIVE, STATE_SYNCING, UP
from replication.exception import ContextError, NonAuthorizedOperationError
from replication.interface import session
from . import shared_data, utils
def sanitize_deps_graph(remove_nodes: bool = False):
""" Cleanup the replication graph
"""
if session and session.state == STATE_ACTIVE:
start = utils.current_milli_time()
rm_cpt = 0
for node in session.repository.graph.values():
node.instance = session.repository.rdp.resolve(node.data)
if node is None \
or (node.state == UP and not node.instance):
if remove_nodes:
try:
porcelain.rm(session.repository,
node.uuid,
remove_dependencies=False)
logging.info(f"Removing {node.uuid}")
rm_cpt += 1
except NonAuthorizedOperationError:
continue
logging.info(f"Sanitize took { utils.current_milli_time()-start} ms, removed {rm_cpt} nodes")
def update_external_dependencies():
"""Force external dependencies(files such as images) evaluation
"""
external_types = ['WindowsPath', 'PosixPath', 'Image']
nodes_ids = [n.uuid for n in session.repository.graph.values() if n.data['type_id'] in external_types]
for node_id in nodes_ids:
node = session.repository.graph.get(node_id)
if node and node.owner in [session.repository.username, RP_COMMON]:
porcelain.commit(session.repository, node_id)
porcelain.push(session.repository, 'origin', node_id)
@persistent
def on_scene_update(scene):
"""Forward blender depsgraph update to replication
"""
if session and session.state == STATE_ACTIVE:
context = bpy.context
blender_depsgraph = bpy.context.view_layer.depsgraph
dependency_updates = [u for u in blender_depsgraph.updates]
settings = utils.get_preferences()
incoming_updates = shared_data.session.applied_updates
distant_update = [getattr(u.id, 'uuid', None) for u in dependency_updates if getattr(u.id, 'uuid', None) in incoming_updates]
if distant_update:
for u in distant_update:
shared_data.session.applied_updates.remove(u)
logging.debug(f"Ignoring distant update of {dependency_updates[0].id.name}")
return
# NOTE: maybe we don't need to check each update but only the first
for update in reversed(dependency_updates):
update_uuid = getattr(update.id, 'uuid', None)
if update_uuid:
node = session.repository.graph.get(update.id.uuid)
check_common = session.repository.rdp.get_implementation(update.id).bl_check_common
if node and (node.owner == session.repository.username or check_common):
logging.debug(f"Evaluate {update.id.name}")
if node.state == UP:
try:
porcelain.commit(session.repository, node.uuid)
porcelain.push(session.repository,
'origin', node.uuid)
except ReferenceError:
logging.debug(f"Reference error {node.uuid}")
except ContextError as e:
logging.debug(e)
except Exception as e:
logging.error(e)
else:
continue
elif isinstance(update.id, bpy.types.Scene):
scene = bpy.data.scenes.get(update.id.name)
scn_uuid = porcelain.add(session.repository, scene)
porcelain.commit(session.repository, scn_uuid)
porcelain.push(session.repository, 'origin', scn_uuid)
scene_graph_changed = [u for u in reversed(dependency_updates) if getattr(u.id, 'uuid', None) and isinstance(u.id,(bpy.types.Scene,bpy.types.Collection))]
if scene_graph_changed:
porcelain.purge_orphan_nodes(session.repository)
update_external_dependencies()
@persistent
def resolve_deps_graph(dummy):
"""Resolve deps graph
Temporary solution to resolve each node pointers after a Undo.
A future solution should be to avoid storing dataclock reference...
"""
if session and session.state == STATE_ACTIVE:
sanitize_deps_graph(remove_nodes=True)
@persistent
def load_pre_handler(dummy):
if session and session.state in [STATE_ACTIVE, STATE_SYNCING]:
bpy.ops.session.stop()
@persistent
def update_client_frame(scene):
setting = bpy.context.window_manager.session
if setting.replay_mode == 'TIMELINE' and \
setting.replay_files and \
scene.active_replay_file != setting.replay_frame_current :
index = bpy.context.scene.active_replay_file
bpy.ops.session.load(filepath=bpy.context.window_manager.session.replay_files[index].name,
draw_users=True,
replay=True)
setting.replay_frame_current = index
if session and session.state == STATE_ACTIVE:
porcelain.update_user_metadata(session.repository, {
'frame_current': scene.frame_current
})
@persistent
def post_frame_update(scene):
if bpy.context.window_manager.session.replay_mode == 'TIMELINE' and \
not bpy.context.scene.animation_data:
bpy.context.scene.animation_data_create()
bpy.context.scene.animation_data.action = bpy.data.actions.get('replay_action')
def register():
bpy.app.handlers.undo_post.append(resolve_deps_graph)
bpy.app.handlers.redo_post.append(resolve_deps_graph)
bpy.app.handlers.load_pre.append(load_pre_handler)
bpy.app.handlers.frame_change_pre.append(update_client_frame)
def unregister():
bpy.app.handlers.undo_post.remove(resolve_deps_graph)
bpy.app.handlers.redo_post.remove(resolve_deps_graph)
bpy.app.handlers.load_pre.remove(load_pre_handler)
bpy.app.handlers.frame_change_pre.remove(update_client_frame)

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -94,41 +94,15 @@ def project_to_viewport(region: bpy.types.Region, rv3d: bpy.types.RegionView3D,
return [target.x, target.y, target.z] return [target.x, target.y, target.z]
def bbox_from_obj(obj: bpy.types.Object, index: int = 1) -> list: def bbox_from_obj(obj: bpy.types.Object, radius: float) -> list:
""" Generate a bounding box for a given object by using its world matrix """ Generate a bounding box for a given object by using its world matrix
:param obj: target object :param obj: target object
:type obj: bpy.types.Object :type obj: bpy.types.Object
:param index: indice offset :param radius: bounding box radius
:type index: int :type radius: float
:return: list of 8 points [(x,y,z),...], list of 12 link between these points [(1,2),...] :return: list of 8 points [(x,y,z),...]
""" """
radius = 1.0 # Radius of the bounding box
index = 8*index
vertex_indices = (
(0+index, 1+index), (0+index, 2+index), (1+index, 3+index), (2+index, 3+index),
(4+index, 5+index), (4+index, 6+index), (5+index, 7+index), (6+index, 7+index),
(0+index, 4+index), (1+index, 5+index), (2+index, 6+index), (3+index, 7+index))
if obj.type == 'EMPTY':
radius = obj.empty_display_size
elif obj.type == 'LIGHT':
radius = obj.data.shadow_soft_size
elif obj.type == 'LIGHT_PROBE':
radius = obj.data.influence_distance
elif obj.type == 'CAMERA':
radius = obj.data.display_size
elif hasattr(obj, 'bound_box'):
vertex_indices = (
(0+index, 1+index), (1+index, 2+index),
(2+index, 3+index), (0+index, 3+index),
(4+index, 5+index), (5+index, 6+index),
(6+index, 7+index), (4+index, 7+index),
(0+index, 4+index), (1+index, 5+index),
(2+index, 6+index), (3+index, 7+index))
vertex_pos = get_bb_coords_from_obj(obj)
return vertex_pos, vertex_indices
coords = [ coords = [
(-radius, -radius, -radius), (+radius, -radius, -radius), (-radius, -radius, -radius), (+radius, -radius, -radius),
(-radius, +radius, -radius), (+radius, +radius, -radius), (-radius, +radius, -radius), (+radius, +radius, -radius),
@ -138,32 +112,9 @@ def bbox_from_obj(obj: bpy.types.Object, index: int = 1) -> list:
base = obj.matrix_world base = obj.matrix_world
bbox_corners = [base @ mathutils.Vector(corner) for corner in coords] bbox_corners = [base @ mathutils.Vector(corner) for corner in coords]
vertex_pos = [(point.x, point.y, point.z) for point in bbox_corners] return [(point.x, point.y, point.z)
for point in bbox_corners]
return vertex_pos, vertex_indices
def bbox_from_instance_collection(ic: bpy.types.Object, index: int = 0) -> list:
""" Generate a bounding box for a given instance collection by using its objects
:param ic: target instance collection
:type ic: bpy.types.Object
:param index: indice offset
:type index: int
:return: list of 8*objs points [(x,y,z),...], tuple of 12*objs link between these points [(1,2),...]
"""
vertex_pos = []
vertex_indices = ()
for obj_index, obj in enumerate(ic.instance_collection.objects):
vertex_pos_temp, vertex_indices_temp = bbox_from_obj(obj, index=index+obj_index)
vertex_pos += vertex_pos_temp
vertex_indices += vertex_indices_temp
bbox_corners = [ic.matrix_world @ mathutils.Vector(vertex) for vertex in vertex_pos]
vertex_pos = [(point.x, point.y, point.z) for point in bbox_corners]
return vertex_pos, vertex_indices
def generate_user_camera() -> list: def generate_user_camera() -> list:
""" Generate a basic camera represention of the user point of view """ Generate a basic camera represention of the user point of view
@ -252,13 +203,6 @@ class Widget(object):
""" """
return True return True
def configure_bgl(self):
bgl.glLineWidth(2.)
bgl.glEnable(bgl.GL_DEPTH_TEST)
bgl.glEnable(bgl.GL_BLEND)
bgl.glEnable(bgl.GL_LINE_SMOOTH)
def draw(self): def draw(self):
"""How to draw the widget """How to draw the widget
""" """
@ -312,6 +256,11 @@ class UserFrustumWidget(Widget):
{"pos": positions}, {"pos": positions},
indices=self.indices) indices=self.indices)
bgl.glLineWidth(2.)
bgl.glEnable(bgl.GL_DEPTH_TEST)
bgl.glEnable(bgl.GL_BLEND)
bgl.glEnable(bgl.GL_LINE_SMOOTH)
shader.bind() shader.bind()
shader.uniform_float("color", self.data.get('color')) shader.uniform_float("color", self.data.get('color'))
batch.draw(shader) batch.draw(shader)
@ -323,8 +272,6 @@ class UserSelectionWidget(Widget):
username): username):
self.username = username self.username = username
self.settings = bpy.context.window_manager.session self.settings = bpy.context.window_manager.session
self.current_selection_ids = []
self.current_selected_objects = []
@property @property
def data(self): def data(self):
@ -334,15 +281,6 @@ class UserSelectionWidget(Widget):
else: else:
return None return None
@property
def selected_objects(self):
user_selection = self.data.get('selected_objects')
if self.current_selection_ids != user_selection:
self.current_selected_objects = [find_from_attr("uuid", uid, bpy.data.objects) for uid in user_selection]
self.current_selection_ids = user_selection
return self.current_selected_objects
def poll(self): def poll(self):
if self.data is None: if self.data is None:
return False return False
@ -357,32 +295,50 @@ class UserSelectionWidget(Widget):
self.settings.enable_presence self.settings.enable_presence
def draw(self): def draw(self):
vertex_pos = [] user_selection = self.data.get('selected_objects')
vertex_ind = [] for select_ob in user_selection:
collection_offset = 0 ob = find_from_attr("uuid", select_ob, bpy.data.objects)
for obj_index, obj in enumerate(self.selected_objects): if not ob:
if obj is None: return
continue
obj_index+=collection_offset vertex_pos = bbox_from_obj(ob, 1.0)
if hasattr(obj, 'instance_collection') and obj.instance_collection: vertex_indices = (
bbox_pos, bbox_ind = bbox_from_instance_collection(obj, index=obj_index) (0, 1), (1, 2), (2, 3), (0, 3),
collection_offset+=len(obj.instance_collection.objects)-1 (4, 5), (5, 6), (6, 7), (4, 7),
else : (0, 4), (1, 5), (2, 6), (3, 7))
bbox_pos, bbox_ind = bbox_from_obj(obj, index=obj_index)
vertex_pos += bbox_pos if ob.instance_collection:
vertex_ind += bbox_ind for obj in ob.instance_collection.objects:
if obj.type == 'MESH' and hasattr(obj, 'bound_box'):
vertex_pos = get_bb_coords_from_obj(obj, instance=ob)
break
elif ob.type == 'EMPTY':
vertex_pos = bbox_from_obj(ob, ob.empty_display_size)
elif ob.type == 'LIGHT':
vertex_pos = bbox_from_obj(ob, ob.data.shadow_soft_size)
elif ob.type == 'LIGHT_PROBE':
vertex_pos = bbox_from_obj(ob, ob.data.influence_distance)
elif ob.type == 'CAMERA':
vertex_pos = bbox_from_obj(ob, ob.data.display_size)
elif hasattr(ob, 'bound_box'):
vertex_indices = (
(0, 1), (1, 2), (2, 3), (0, 3),
(4, 5), (5, 6), (6, 7), (4, 7),
(0, 4), (1, 5), (2, 6), (3, 7))
vertex_pos = get_bb_coords_from_obj(ob)
shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR') shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR')
batch = batch_for_shader( batch = batch_for_shader(
shader, shader,
'LINES', 'LINES',
{"pos": vertex_pos}, {"pos": vertex_pos},
indices=vertex_ind) indices=vertex_indices)
shader.bind() shader.bind()
shader.uniform_float("color", self.data.get('color')) shader.uniform_float("color", self.data.get('color'))
batch.draw(shader) batch.draw(shader)
class UserNameWidget(Widget): class UserNameWidget(Widget):
draw_type = 'POST_PIXEL' draw_type = 'POST_PIXEL'
@ -425,62 +381,6 @@ class UserNameWidget(Widget):
blf.color(0, color[0], color[1], color[2], color[3]) blf.color(0, color[0], color[1], color[2], color[3])
blf.draw(0, self.username) blf.draw(0, self.username)
class UserModeWidget(Widget):
draw_type = 'POST_PIXEL'
def __init__(
self,
username):
self.username = username
self.settings = bpy.context.window_manager.session
self.preferences = get_preferences()
@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')
mode_current = self.data.get('mode_current')
user_selection = self.data.get('selected_objects')
return (scene_current == bpy.context.scene.name or
mode_current == bpy.context.mode or
self.settings.presence_show_far_user) and \
user_selection and \
self.settings.presence_show_mode and \
self.settings.enable_presence
def draw(self):
user_selection = self.data.get('selected_objects')
area, region, rv3d = view3d_find()
viewport_coord = project_to_viewport(region, rv3d, (0, 0))
obj = find_from_attr("uuid", user_selection[0], bpy.data.objects)
if not obj:
return
mode_current = self.data.get('mode_current')
color = self.data.get('color')
origin_coord = project_to_screen(obj.location)
distance_viewport_object = math.sqrt((viewport_coord[0]-obj.location[0])**2+(viewport_coord[1]-obj.location[1])**2+(viewport_coord[2]-obj.location[2])**2)
if distance_viewport_object > self.preferences.presence_mode_distance :
return
if origin_coord :
blf.position(0, origin_coord[0]+8, origin_coord[1]-15, 0)
blf.size(0, 16, 72)
blf.color(0, color[0], color[1], color[2], color[3])
blf.draw(0, mode_current)
class SessionStatusWidget(Widget): class SessionStatusWidget(Widget):
draw_type = 'POST_PIXEL' draw_type = 'POST_PIXEL'
@ -563,7 +463,6 @@ class DrawFactory(object):
try: try:
for widget in self.widgets.values(): for widget in self.widgets.values():
if widget.draw_type == 'POST_VIEW' and widget.poll(): if widget.draw_type == 'POST_VIEW' and widget.poll():
widget.configure_bgl()
widget.draw() widget.draw()
except Exception as e: except Exception as e:
logging.error( logging.error(
@ -573,7 +472,6 @@ class DrawFactory(object):
try: try:
for widget in self.widgets.values(): for widget in self.widgets.values():
if widget.draw_type == 'POST_PIXEL' and widget.poll(): if widget.draw_type == 'POST_PIXEL' and widget.poll():
widget.configure_bgl()
widget.draw() widget.draw()
except Exception as e: except Exception as e:
logging.error( logging.error(
@ -587,7 +485,6 @@ this.renderer = DrawFactory()
def register(): def register():
this.renderer.register_handlers() this.renderer.register_handlers()
this.renderer.add_widget("session_status", SessionStatusWidget()) this.renderer.add_widget("session_status", SessionStatusWidget())

View File

@ -1,48 +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 #####
from replication.constants import STATE_INITIAL
class SessionData():
""" A structure to share easily the current session data across the addon
modules.
This object will completely replace the Singleton lying in replication
interface module.
"""
def __init__(self):
self.repository = None # The current repository
self.remote = None # The active remote
self.server = None
self.applied_updates = []
@property
def state(self):
if self.remote is None:
return STATE_INITIAL
else:
return self.remote.connection_status
def clear(self):
self.remote = None
self.repository = None
self.server = None
self.applied_updates = []
session = SessionData()

View File

@ -27,12 +27,10 @@ from replication.interface import session
from replication import porcelain from replication import porcelain
from . import operators, utils from . import operators, utils
from .presence import (UserFrustumWidget, UserNameWidget, UserModeWidget, UserSelectionWidget, from .presence import (UserFrustumWidget, UserNameWidget, UserSelectionWidget,
generate_user_camera, get_view_matrix, refresh_3d_view, generate_user_camera, get_view_matrix, refresh_3d_view,
refresh_sidebar_view, renderer) refresh_sidebar_view, renderer)
from . import shared_data
this = sys.modules[__name__] this = sys.modules[__name__]
# Registered timers # Registered timers
@ -41,8 +39,7 @@ this.registry = dict()
def is_annotating(context: bpy.types.Context): def is_annotating(context: bpy.types.Context):
""" Check if the annotate mode is enabled """ Check if the annotate mode is enabled
""" """
active_tool = bpy.context.workspace.tools.from_space_view3d_mode('OBJECT', create=False) return bpy.context.workspace.tools.from_space_view3d_mode('OBJECT', create=False).idname == 'builtin.annotate'
return (active_tool and active_tool.idname == 'builtin.annotate')
class Timer(object): class Timer(object):
@ -117,7 +114,6 @@ class ApplyTimer(Timer):
if node_ref.state == FETCHED: if node_ref.state == FETCHED:
try: try:
shared_data.session.applied_updates.append(node)
porcelain.apply(session.repository, node) porcelain.apply(session.repository, node)
except Exception as e: except Exception as e:
logging.error(f"Fail to apply {node_ref.uuid}") logging.error(f"Fail to apply {node_ref.uuid}")
@ -130,22 +126,24 @@ class ApplyTimer(Timer):
porcelain.apply(session.repository, porcelain.apply(session.repository,
parent.uuid, parent.uuid,
force=True) force=True)
if hasattr(impl, 'bl_reload_child') and impl.bl_reload_child:
for dep in node_ref.dependencies:
porcelain.apply(session.repository,
dep,
force=True)
class AnnotationUpdates(Timer): class DynamicRightSelectTimer(Timer):
def __init__(self, timeout=1): def __init__(self, timeout=.1):
self._annotating = False
self._settings = utils.get_preferences()
super().__init__(timeout) super().__init__(timeout)
self._last_selection = []
self._user = None
self._annotating = False
def execute(self): def execute(self):
settings = utils.get_preferences()
if session and session.state == STATE_ACTIVE: if session and session.state == STATE_ACTIVE:
# Find user
if self._user is None:
self._user = session.online_users.get(settings.username)
if self._user:
ctx = bpy.context ctx = bpy.context
annotation_gp = ctx.scene.grease_pencil annotation_gp = ctx.scene.grease_pencil
@ -162,77 +160,67 @@ class AnnotationUpdates(Timer):
logging.debug( logging.debug(
"Getting the right on the annotation GP") "Getting the right on the annotation GP")
porcelain.lock(session.repository, porcelain.lock(session.repository,
[registered_gp.uuid], registered_gp.uuid,
ignore_warnings=True, ignore_warnings=True,
affect_dependencies=False) affect_dependencies=False)
if registered_gp.owner == self._settings.username: if registered_gp.owner == settings.username:
porcelain.commit(session.repository, annotation_gp.uuid) gp_node = session.repository.graph.get(annotation_gp.uuid)
porcelain.push(session.repository, 'origin', annotation_gp.uuid) porcelain.commit(session.repository, gp_node.uuid)
porcelain.push(session.repository, 'origin', gp_node.uuid)
elif self._annotating: elif self._annotating:
porcelain.unlock(session.repository, porcelain.unlock(session.repository,
[registered_gp.uuid], registered_gp.uuid,
ignore_warnings=True, ignore_warnings=True,
affect_dependencies=False) affect_dependencies=False)
self._annotating = False
class DynamicRightSelectTimer(Timer): current_selection = utils.get_selected_objects(
def __init__(self, timeout=.1):
super().__init__(timeout)
self._last_selection = set()
self._user = None
def execute(self):
settings = utils.get_preferences()
if session and session.state == STATE_ACTIVE:
# Find user
if self._user is None:
self._user = session.online_users.get(settings.username)
if self._user:
current_selection = set(utils.get_selected_objects(
bpy.context.scene, bpy.context.scene,
bpy.data.window_managers['WinMan'].windows[0].view_layer bpy.data.window_managers['WinMan'].windows[0].view_layer
)) )
if current_selection != self._last_selection: if current_selection != self._last_selection:
to_lock = list(current_selection.difference(self._last_selection)) obj_common = [
to_release = list(self._last_selection.difference(current_selection)) o for o in self._last_selection if o not in current_selection]
instances_to_lock = list() obj_ours = [
o for o in current_selection if o not in self._last_selection]
for node_id in to_lock: # change old selection right to common
node = session.repository.graph.get(node_id) for obj in obj_common:
if node and hasattr(node,'data'): node = session.repository.graph.get(obj)
instance_mode = node.data.get('instance_type')
if instance_mode and instance_mode == 'COLLECTION':
to_lock.remove(node_id)
instances_to_lock.append(node_id)
if instances_to_lock:
try:
porcelain.lock(session.repository,
instances_to_lock,
ignore_warnings=True,
affect_dependencies=False)
except NonAuthorizedOperationError as e:
logging.warning(e)
if to_release: if node and (node.owner == settings.username or node.owner == RP_COMMON):
recursive = True
if node.data and 'instance_type' in node.data.keys():
recursive = node.data['instance_type'] != 'COLLECTION'
try: try:
porcelain.unlock(session.repository, porcelain.unlock(session.repository,
to_release, node.uuid,
ignore_warnings=True, ignore_warnings=True,
affect_dependencies=True) affect_dependencies=recursive)
except NonAuthorizedOperationError as e: except NonAuthorizedOperationError:
logging.warning(e) logging.warning(
if to_lock: f"Not authorized to change {node} owner")
# change new selection to our
for obj in obj_ours:
node = session.repository.graph.get(obj)
if node and node.owner == RP_COMMON:
recursive = True
if node.data and 'instance_type' in node.data.keys():
recursive = node.data['instance_type'] != 'COLLECTION'
try: try:
porcelain.lock(session.repository, porcelain.lock(session.repository,
to_lock, node.uuid,
ignore_warnings=True, ignore_warnings=True,
affect_dependencies=True) affect_dependencies=recursive)
except NonAuthorizedOperationError as e: except NonAuthorizedOperationError:
logging.warning(e) logging.warning(
f"Not authorized to change {node} owner")
else:
return
self._last_selection = current_selection self._last_selection = current_selection
@ -246,23 +234,23 @@ class DynamicRightSelectTimer(Timer):
# Fix deselection until right managment refactoring (with Roles concepts) # Fix deselection until right managment refactoring (with Roles concepts)
if len(current_selection) == 0 : if len(current_selection) == 0 :
owned_keys = [k for k, v in session.repository.graph.items() if v.owner==settings.username] owned_keys = [k for k, v in session.repository.graph.items() if v.owner==settings.username]
if owned_keys: for key in owned_keys:
node = session.repository.graph.get(key)
try: try:
porcelain.unlock(session.repository, porcelain.unlock(session.repository,
owned_keys, key,
ignore_warnings=True, ignore_warnings=True,
affect_dependencies=True) affect_dependencies=True)
except NonAuthorizedOperationError as e: except NonAuthorizedOperationError:
logging.warning(e) logging.warning(
f"Not authorized to change {key} owner")
# Objects selectability
for obj in bpy.data.objects: for obj in bpy.data.objects:
object_uuid = getattr(obj, 'uuid', None) object_uuid = getattr(obj, 'uuid', None)
if object_uuid: if object_uuid:
is_selectable = not session.repository.is_node_readonly(object_uuid) is_selectable = not session.repository.is_node_readonly(object_uuid)
if obj.hide_select != is_selectable: if obj.hide_select != is_selectable:
obj.hide_select = is_selectable obj.hide_select = is_selectable
shared_data.session.applied_updates.append(object_uuid)
class ClientUpdate(Timer): class ClientUpdate(Timer):
@ -312,8 +300,7 @@ class ClientUpdate(Timer):
settings.client_color.b, settings.client_color.b,
1), 1),
'frame_current': bpy.context.scene.frame_current, 'frame_current': bpy.context.scene.frame_current,
'scene_current': scene_current, 'scene_current': scene_current
'mode_current': bpy.context.mode
} }
porcelain.update_user_metadata(session.repository, metadata) porcelain.update_user_metadata(session.repository, metadata)
@ -327,9 +314,6 @@ class ClientUpdate(Timer):
local_user_metadata['view_matrix'] = get_view_matrix( local_user_metadata['view_matrix'] = get_view_matrix(
) )
porcelain.update_user_metadata(session.repository, local_user_metadata) porcelain.update_user_metadata(session.repository, local_user_metadata)
elif bpy.context.mode != local_user_metadata['mode_current']:
local_user_metadata['mode_current'] = bpy.context.mode
porcelain.update_user_metadata(session.repository, local_user_metadata)
class SessionStatusUpdate(Timer): class SessionStatusUpdate(Timer):
@ -357,7 +341,6 @@ class SessionUserSync(Timer):
renderer.remove_widget(f"{user.username}_cam") renderer.remove_widget(f"{user.username}_cam")
renderer.remove_widget(f"{user.username}_select") renderer.remove_widget(f"{user.username}_select")
renderer.remove_widget(f"{user.username}_name") renderer.remove_widget(f"{user.username}_name")
renderer.remove_widget(f"{user.username}_mode")
ui_users.remove(index) ui_users.remove(index)
break break
@ -373,8 +356,6 @@ class SessionUserSync(Timer):
f"{user}_select", UserSelectionWidget(user)) f"{user}_select", UserSelectionWidget(user))
renderer.add_widget( renderer.add_widget(
f"{user}_name", UserNameWidget(user)) f"{user}_name", UserNameWidget(user))
renderer.add_widget(
f"{user}_mode", UserModeWidget(user))
class MainThreadExecutor(Timer): class MainThreadExecutor(Timer):

View File

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

View File

@ -38,6 +38,7 @@ from replication.constants import (STATE_ACTIVE, STATE_AUTH,
STATE_LOBBY, STATE_LOBBY,
CONNECTING) CONNECTING)
def find_from_attr(attr_name, attr_value, list): def find_from_attr(attr_name, attr_value, list):
for item in list: for item in list:
if getattr(item, attr_name, None) == attr_value: if getattr(item, attr_name, None) == attr_value:
@ -99,6 +100,24 @@ def get_state_str(state):
return state_str return state_str
def clean_scene():
to_delete = [f for f in dir(bpy.data) if f not in ['brushes', 'palettes']]
for type_name in to_delete:
try:
sub_collection_to_avoid = [bpy.data.linestyles['LineStyle'], bpy.data.materials['Dots Stroke']]
type_collection = getattr(bpy.data, type_name)
items_to_remove = [i for i in type_collection if i not in sub_collection_to_avoid]
for item in items_to_remove:
try:
type_collection.remove(item)
except:
continue
except:
continue
# Clear sequencer
bpy.context.scene.sequence_editor_clear()
def get_selected_objects(scene, active_view_layer): def get_selected_objects(scene, active_view_layer):
return [obj.uuid for obj in scene.objects if obj.select_get(view_layer=active_view_layer)] return [obj.uuid for obj in scene.objects if obj.select_get(view_layer=active_view_layer)]

View File

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

View File

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

View File

@ -8,7 +8,7 @@ from multi_user.bl_types.bl_material import BlMaterial
def test_material_nodes(clear_blend): def test_material_nodes(clear_blend):
nodes_types = [node.bl_rna.identifier for node in bpy.types.ShaderNode.__subclasses__()] nodes_types = [node.bl_rna.identifier for node in bpy.types.ShaderNode.__subclasses__()] # Faire un peu comme ici
datablock = bpy.data.materials.new("test") datablock = bpy.data.materials.new("test")
datablock.use_nodes = True datablock.use_nodes = True

View File

@ -11,9 +11,8 @@ from multi_user.utils import get_preferences
def test_scene(clear_blend): def test_scene(clear_blend):
get_preferences().sync_flags.sync_render_settings = True get_preferences().sync_flags.sync_render_settings = True
datablock = bpy.data.scenes.new("toto") # datablock = bpy.data.scenes.new("toto") # TODO: trouver datablock -> active compositing 'Use nodes'
datablock.timeline_markers.new('toto', frame=10) datablock = bpy.data.scenes["Scene"].use_nodes
datablock.timeline_markers.new('tata', frame=1)
datablock.view_settings.use_curve_mapping = True datablock.view_settings.use_curve_mapping = True
# Test # Test
implementation = BlScene() implementation = BlScene()

20
tests/test_operators.py Normal file
View File

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