diff --git a/README.md b/README.md
index 80525cb..fdc5a78 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,7 @@ Currently, not all data-block are supported for replication over the wire. The f
| image | ✔️ | |
| mesh | ✔️ | |
| material | ✔️ | |
+| node_groups | ❗ | Material only |
| metaball | ✔️ | |
| object | ✔️ | |
| texts | ✔️ | |
@@ -48,7 +49,7 @@ Currently, not all data-block are supported for replication over the wire. The f
| volumes | ❌ | |
| particles | ❌ | [On-going](https://gitlab.com/slumber/multi-user/-/issues/24) |
| speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) |
-| vse | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) |
+| vse | ❗ | Mask and Clip not supported yet |
| physics | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) |
| libraries | ❗ | Partial |
diff --git a/docs/getting_started/img/quickstart_presence.png b/docs/getting_started/img/quickstart_presence.png
index 771ca9f..cc39bb0 100644
Binary files a/docs/getting_started/img/quickstart_presence.png and b/docs/getting_started/img/quickstart_presence.png differ
diff --git a/docs/getting_started/img/quickstart_status.png b/docs/getting_started/img/quickstart_status.png
new file mode 100644
index 0000000..0a66d56
Binary files /dev/null and b/docs/getting_started/img/quickstart_status.png differ
diff --git a/docs/getting_started/quickstart.rst b/docs/getting_started/quickstart.rst
index 2f99140..dae7346 100644
--- a/docs/getting_started/quickstart.rst
+++ b/docs/getting_started/quickstart.rst
@@ -251,6 +251,14 @@ it draw users related information in your viewport such as:
The presence overlay panel (see image above) allow you to enable/disable
various drawn parts via the following flags:
+- **Show session statut**: display the session status in the viewport
+
+ .. figure:: img/quickstart_status.png
+ :align: center
+
+ - **Text scale**: session status text size
+ - **Vertical/Horizontal position**: session position in the viewport
+
- **Show selected objects**: display other users current selection
- **Show users**: display users current viewpoint
- **Show different scenes**: display users working on other scenes
diff --git a/multi_user/__init__.py b/multi_user/__init__.py
index 41e248b..389d4c8 100644
--- a/multi_user/__init__.py
+++ b/multi_user/__init__.py
@@ -19,7 +19,7 @@
bl_info = {
"name": "Multi-User",
"author": "Swann Martinez",
- "version": (0, 1, 1),
+ "version": (0, 2, 0),
"description": "Enable real-time collaborative workflow inside blender",
"blender": (2, 82, 0),
"location": "3D View > Sidebar > Multi-User tab",
@@ -44,7 +44,7 @@ from . import environment
DEPENDENCIES = {
- ("replication", '0.1.3'),
+ ("replication", '0.1.9'),
}
diff --git a/multi_user/bl_types/__init__.py b/multi_user/bl_types/__init__.py
index add7058..07a07f1 100644
--- a/multi_user/bl_types/__init__.py
+++ b/multi_user/bl_types/__init__.py
@@ -37,7 +37,9 @@ __all__ = [
'bl_speaker',
'bl_font',
'bl_sound',
- 'bl_file'
+ 'bl_file',
+ 'bl_sequencer',
+ 'bl_node_group'
] # Order here defines execution order
from . import *
diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py
index 15d3622..253a13e 100644
--- a/multi_user/bl_types/bl_action.py
+++ b/multi_user/bl_types/bl_action.py
@@ -42,7 +42,7 @@ KEYFRAME = [
]
-def dump_fcurve(fcurve: bpy.types.FCurve, use_numpy:bool =True) -> dict:
+def dump_fcurve(fcurve: bpy.types.FCurve, use_numpy: bool = True) -> dict:
""" Dump a sigle curve to a dict
:arg fcurve: fcurve to dump
@@ -59,7 +59,7 @@ def dump_fcurve(fcurve: bpy.types.FCurve, use_numpy:bool =True) -> dict:
if use_numpy:
points = fcurve.keyframe_points
- fcurve_data['keyframes_count'] = len(fcurve.keyframe_points)
+ fcurve_data['keyframes_count'] = len(fcurve.keyframe_points)
fcurve_data['keyframe_points'] = np_dump_collection(points, KEYFRAME)
else: # Legacy method
@@ -92,7 +92,8 @@ def load_fcurve(fcurve_data, fcurve):
if use_numpy:
keyframe_points.add(fcurve_data['keyframes_count'])
- np_load_collection(fcurve_data["keyframe_points"], keyframe_points, KEYFRAME)
+ np_load_collection(
+ fcurve_data["keyframe_points"], keyframe_points, KEYFRAME)
else:
# paste dumped keyframes
@@ -153,7 +154,11 @@ class BlAction(BlDatablock):
dumped_data_path, index=dumped_array_index)
load_fcurve(dumped_fcurve, fcurve)
- target.id_root = data['id_root']
+
+ id_root = data.get('id_root')
+
+ if id_root:
+ target.id_root = id_root
def _dump_implementation(self, data, instance=None):
dumper = Dumper()
diff --git a/multi_user/bl_types/bl_camera.py b/multi_user/bl_types/bl_camera.py
index e65b85a..22f58ae 100644
--- a/multi_user/bl_types/bl_camera.py
+++ b/multi_user/bl_types/bl_camera.py
@@ -48,12 +48,15 @@ class BlCamera(BlDatablock):
background_images = data.get('background_images')
+ target.background_images.clear()
+
if background_images:
- target.background_images.clear()
for img_name, img_data in background_images.items():
- target_img = target.background_images.new()
- target_img.image = bpy.data.images[img_name]
- loader.load(target_img, img_data)
+ img_id = img_data.get('image')
+ if img_id:
+ target_img = target.background_images.new()
+ target_img.image = bpy.data.images[img_id]
+ loader.load(target_img, img_data)
def _dump_implementation(self, data, instance=None):
assert(instance)
diff --git a/multi_user/bl_types/bl_collection.py b/multi_user/bl_types/bl_collection.py
index 542f49f..12b4948 100644
--- a/multi_user/bl_types/bl_collection.py
+++ b/multi_user/bl_types/bl_collection.py
@@ -71,6 +71,15 @@ def load_collection_childrens(dumped_childrens, collection):
if child_collection.uuid not in dumped_childrens:
collection.children.unlink(child_collection)
+def resolve_collection_dependencies(collection):
+ deps = []
+
+ for child in collection.children:
+ deps.append(child)
+ for object in collection.objects:
+ deps.append(object)
+
+ return deps
class BlCollection(BlDatablock):
bl_id = "collections"
@@ -124,11 +133,4 @@ class BlCollection(BlDatablock):
return data
def _resolve_deps_implementation(self):
- deps = []
-
- for child in self.instance.children:
- deps.append(child)
- for object in self.instance.objects:
- deps.append(object)
-
- return deps
+ return resolve_collection_dependencies(self.instance)
diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py
index c7996e3..c75fc20 100644
--- a/multi_user/bl_types/bl_datablock.py
+++ b/multi_user/bl_types/bl_datablock.py
@@ -21,7 +21,7 @@ from collections.abc import Iterable
import bpy
import mathutils
-from replication.constants import DIFF_BINARY, UP
+from replication.constants import DIFF_BINARY, DIFF_JSON, UP
from replication.data import ReplicatedDatablock
from .. import utils
@@ -92,7 +92,6 @@ def load_driver(target_datablock, src_driver):
def get_datablock_from_uuid(uuid, default, ignore=[]):
if not uuid:
return default
-
for category in dir(bpy.data):
root = getattr(bpy.data, category)
if isinstance(root, Iterable) and category not in ignore:
@@ -123,12 +122,15 @@ class BlDatablock(ReplicatedDatablock):
# TODO: use is_library_indirect
self.is_library = (instance and hasattr(instance, 'library') and
instance.library) or \
- (self.data and 'library' in self.data)
+ (hasattr(self,'data') and self.data and 'library' in self.data)
if instance and hasattr(instance, 'uuid'):
instance.uuid = self.uuid
- self.diff_method = DIFF_BINARY
+ if logging.getLogger().level == logging.DEBUG:
+ self.diff_method = DIFF_JSON
+ else:
+ self.diff_method = DIFF_BINARY
def resolve(self):
datablock_ref = None
@@ -217,7 +219,7 @@ class BlDatablock(ReplicatedDatablock):
if not self.is_library:
dependencies.extend(self._resolve_deps_implementation())
- logging.debug(f"{self.instance.name} dependencies: {dependencies}")
+ logging.debug(f"{self.instance} dependencies: {dependencies}")
return dependencies
def _resolve_deps_implementation(self):
diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py
index 81fc6b5..080c515 100644
--- a/multi_user/bl_types/bl_material.py
+++ b/multi_user/bl_types/bl_material.py
@@ -21,6 +21,8 @@ import mathutils
import logging
import re
+from uuid import uuid4
+
from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock, get_datablock_from_uuid
@@ -37,28 +39,34 @@ def load_node(node_data, node_tree):
"""
loader = Loader()
target_node = node_tree.nodes.new(type=node_data["bl_idname"])
-
+ target_node.select = False
loader.load(target_node, node_data)
image_uuid = node_data.get('image_uuid', None)
+ node_tree_uuid = node_data.get('node_tree_uuid', None)
if image_uuid and not target_node.image:
target_node.image = get_datablock_from_uuid(image_uuid, None)
- for input in node_data["inputs"]:
- if hasattr(target_node.inputs[input], "default_value"):
- try:
- target_node.inputs[input].default_value = node_data["inputs"][input]["default_value"]
- except:
- logging.error(
- f"Material {input} parameter not supported, skipping")
+ if node_tree_uuid:
+ target_node.node_tree = get_datablock_from_uuid(node_tree_uuid, None)
- for output in node_data["outputs"]:
- if hasattr(target_node.outputs[output], "default_value"):
- try:
- target_node.outputs[output].default_value = node_data["outputs"][output]["default_value"]
- except:
- logging.error(
- f"Material {output} parameter not supported, skipping")
+ inputs = node_data.get('inputs')
+ if inputs:
+ for idx, inpt in enumerate(inputs):
+ if hasattr(target_node.inputs[idx], "default_value"):
+ try:
+ target_node.inputs[idx].default_value = inpt["default_value"]
+ except:
+ logging.error(f"Material input {inpt.keys()} parameter not supported, skipping")
+
+ outputs = node_data.get('outputs')
+ if outputs:
+ for idx, output in enumerate(outputs):
+ if hasattr(target_node.outputs[idx], "default_value"):
+ try:
+ target_node.outputs[idx].default_value = output["default_value"]
+ except:
+ logging.error(f"Material output {output.keys()} parameter not supported, skipping")
def load_links(links_data, node_tree):
@@ -142,24 +150,20 @@ def dump_node(node):
dumped_node = node_dumper.dump(node)
if hasattr(node, 'inputs'):
- dumped_node['inputs'] = {}
+ dumped_node['inputs'] = []
- for i in node.inputs:
- input_dumper = Dumper()
- input_dumper.depth = 2
- input_dumper.include_filter = ["default_value"]
+ io_dumper = Dumper()
+ io_dumper.depth = 2
+ io_dumper.include_filter = ["default_value"]
- if hasattr(i, 'default_value'):
- dumped_node['inputs'][i.name] = input_dumper.dump(i)
+ for idx, inpt in enumerate(node.inputs):
+ if hasattr(inpt, 'default_value'):
+ dumped_node['inputs'].append(io_dumper.dump(inpt))
- dumped_node['outputs'] = {}
- for i in node.outputs:
- output_dumper = Dumper()
- output_dumper.depth = 2
- output_dumper.include_filter = ["default_value"]
-
- if hasattr(i, 'default_value'):
- dumped_node['outputs'][i.name] = output_dumper.dump(i)
+ dumped_node['outputs'] = []
+ for idx, output in enumerate(node.outputs):
+ if hasattr(output, 'default_value'):
+ dumped_node['outputs'].append(io_dumper.dump(output))
if hasattr(node, 'color_ramp'):
ramp_dumper = Dumper()
@@ -182,13 +186,126 @@ def dump_node(node):
dumped_node['mapping'] = curve_dumper.dump(node.mapping)
if hasattr(node, 'image') and getattr(node, 'image'):
dumped_node['image_uuid'] = node.image.uuid
+ if hasattr(node, 'node_tree') and getattr(node, 'node_tree'):
+ dumped_node['node_tree_uuid'] = node.node_tree.uuid
return dumped_node
+def dump_shader_node_tree(node_tree: bpy.types.ShaderNodeTree) -> dict:
+ """ Dump a shader node_tree to a dict including links and nodes
+
+ :arg node_tree: dumped shader node tree
+ :type node_tree: bpy.types.ShaderNodeTree
+ :return: dict
+ """
+ node_tree_data = {
+ 'nodes': {node.name: dump_node(node) for node in node_tree.nodes},
+ 'links': dump_links(node_tree.links),
+ 'name': node_tree.name,
+ 'type': type(node_tree).__name__
+ }
+
+ for socket_id in ['inputs', 'outputs']:
+ socket_collection = getattr(node_tree, socket_id)
+ node_tree_data[socket_id] = dump_node_tree_sockets(socket_collection)
+
+ return node_tree_data
+
+
+def dump_node_tree_sockets(sockets: bpy.types.Collection)->dict:
+ """ dump sockets of a shader_node_tree
+
+ :arg target_node_tree: target node_tree
+ :type target_node_tree: bpy.types.NodeTree
+ :arg socket_id: socket identifer
+ :type socket_id: str
+ :return: dict
+ """
+ sockets_data = []
+ for socket in sockets:
+ try:
+ socket_uuid = socket['uuid']
+ except Exception:
+ socket_uuid = str(uuid4())
+ socket['uuid'] = socket_uuid
+
+ sockets_data.append((socket.name, socket.bl_socket_idname, socket_uuid))
+
+ return sockets_data
+
+def load_node_tree_sockets(sockets: bpy.types.Collection,
+ sockets_data: dict):
+ """ load sockets of a shader_node_tree
+
+ :arg target_node_tree: target node_tree
+ :type target_node_tree: bpy.types.NodeTree
+ :arg socket_id: socket identifer
+ :type socket_id: str
+ :arg socket_data: dumped socket data
+ :type socket_data: dict
+ """
+ # Check for removed sockets
+ for socket in sockets:
+ if not [s for s in sockets_data if socket['uuid'] == s[2]]:
+ sockets.remove(socket)
+
+ # Check for new sockets
+ for idx, socket_data in enumerate(sockets_data):
+ try:
+ checked_socket = sockets[idx]
+ if checked_socket.name != socket_data[0]:
+ checked_socket.name = socket_data[0]
+ except Exception:
+ s = sockets.new(socket_data[1], socket_data[0])
+ s['uuid'] = socket_data[2]
+
+
+def load_shader_node_tree(node_tree_data:dict, target_node_tree:bpy.types.ShaderNodeTree)->dict:
+ """Load a shader node_tree from dumped data
+
+ :arg node_tree_data: dumped node data
+ :type node_tree_data: dict
+ :arg target_node_tree: target node_tree
+ :type target_node_tree: bpy.types.NodeTree
+ """
+ # TODO: load only required nodes
+ target_node_tree.nodes.clear()
+
+ if not target_node_tree.is_property_readonly('name'):
+ target_node_tree.name = node_tree_data['name']
+
+ if 'inputs' in node_tree_data:
+ socket_collection = getattr(target_node_tree, 'inputs')
+ load_node_tree_sockets(socket_collection, node_tree_data['inputs'])
+
+ if 'outputs' in node_tree_data:
+ socket_collection = getattr(target_node_tree, 'outputs')
+ load_node_tree_sockets(socket_collection,node_tree_data['outputs'])
+
+ # Load nodes
+ for node in node_tree_data["nodes"]:
+ load_node(node_tree_data["nodes"][node], target_node_tree)
+
+ # TODO: load only required nodes links
+ # Load nodes links
+ target_node_tree.links.clear()
+
+ load_links(node_tree_data["links"], target_node_tree)
+
+
def get_node_tree_dependencies(node_tree: bpy.types.NodeTree) -> list:
has_image = lambda node : (node.type in ['TEX_IMAGE', 'TEX_ENVIRONMENT'] and node.image)
+ has_node_group = lambda node : (hasattr(node,'node_tree') and node.node_tree)
- return [node.image for node in node_tree.nodes if has_image(node)]
+ deps = []
+
+ for node in node_tree.nodes:
+ if has_image(node):
+ deps.append(node.image)
+ elif has_node_group(node):
+ deps.append(node.node_tree)
+
+ return deps
class BlMaterial(BlDatablock):
@@ -219,16 +336,7 @@ class BlMaterial(BlDatablock):
if target.node_tree is None:
target.use_nodes = True
- target.node_tree.nodes.clear()
-
- # Load nodes
- for node in data["node_tree"]["nodes"]:
- load_node(data["node_tree"]["nodes"][node], target.node_tree)
-
- # Load nodes links
- target.node_tree.links.clear()
-
- load_links(data["node_tree"]["links"], target.node_tree)
+ load_shader_node_tree(data['node_tree'], target.node_tree)
def _dump_implementation(self, data, instance=None):
assert(instance)
@@ -259,15 +367,7 @@ class BlMaterial(BlDatablock):
]
data = mat_dumper.dump(instance)
- if instance.use_nodes:
- nodes = {}
- data["node_tree"] = {}
- for node in instance.node_tree.nodes:
- nodes[node.name] = dump_node(node)
- data["node_tree"]['nodes'] = nodes
-
- data["node_tree"]["links"] = dump_links(instance.node_tree.links)
- elif instance.is_grease_pencil:
+ if instance.is_grease_pencil:
gp_mat_dumper = Dumper()
gp_mat_dumper.depth = 3
@@ -299,6 +399,9 @@ class BlMaterial(BlDatablock):
# 'fill_image',
]
data['grease_pencil'] = gp_mat_dumper.dump(instance.grease_pencil)
+ elif instance.use_nodes:
+ data['node_tree'] = dump_shader_node_tree(instance.node_tree)
+
return data
def _resolve_deps_implementation(self):
diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py
index 7ee32d5..70546b7 100644
--- a/multi_user/bl_types/bl_mesh.py
+++ b/multi_user/bl_types/bl_mesh.py
@@ -25,7 +25,7 @@ import numpy as np
from .dump_anything import Dumper, Loader, np_load_collection_primitives, np_dump_collection_primitive, np_load_collection, np_dump_collection
from replication.constants import DIFF_BINARY
from replication.exception import ContextError
-from .bl_datablock import BlDatablock
+from .bl_datablock import BlDatablock, get_datablock_from_uuid
VERTICE = ['co']
@@ -70,8 +70,17 @@ class BlMesh(BlDatablock):
# MATERIAL SLOTS
target.materials.clear()
- for m in data["material_list"]:
- target.materials.append(bpy.data.materials[m])
+ for mat_uuid, mat_name in data["material_list"]:
+ mat_ref = None
+ if mat_uuid is not None:
+ mat_ref = get_datablock_from_uuid(mat_uuid, None)
+ else:
+ mat_ref = bpy.data.materials.get(mat_name, None)
+
+ if mat_ref is None:
+ raise Exception("Material doesn't exist")
+
+ target.materials.append(mat_ref)
# CLEAR GEOMETRY
if target.vertices:
@@ -163,12 +172,7 @@ class BlMesh(BlDatablock):
data['vertex_colors'][color_map.name]['data'] = np_dump_collection_primitive(color_map.data, 'color')
# Fix material index
- m_list = []
- for material in instance.materials:
- if material:
- m_list.append(material.name)
-
- data['material_list'] = m_list
+ data['material_list'] = [(m.uuid, m.name) for m in instance.materials if m]
return data
diff --git a/multi_user/bl_types/bl_node_group.py b/multi_user/bl_types/bl_node_group.py
new file mode 100644
index 0000000..8ebf568
--- /dev/null
+++ b/multi_user/bl_types/bl_node_group.py
@@ -0,0 +1,47 @@
+# ##### 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 .
+#
+# ##### END GPL LICENSE BLOCK #####
+
+
+import bpy
+import mathutils
+
+from .dump_anything import Dumper, Loader, np_dump_collection, np_load_collection
+from .bl_datablock import BlDatablock
+from .bl_material import (dump_shader_node_tree,
+ load_shader_node_tree,
+ get_node_tree_dependencies)
+
+class BlNodeGroup(BlDatablock):
+ bl_id = "node_groups"
+ bl_class = bpy.types.ShaderNodeTree
+ bl_delay_refresh = 1
+ bl_delay_apply = 1
+ bl_automatic_push = True
+ bl_check_common = False
+ bl_icon = 'NODETREE'
+
+ def _construct(self, data):
+ return bpy.data.node_groups.new(data["name"], data["type"])
+
+ def _load_implementation(self, data, target):
+ load_shader_node_tree(data, target)
+
+ def _dump_implementation(self, data, instance=None):
+ return dump_shader_node_tree(instance)
+
+ def _resolve_deps_implementation(self):
+ return get_node_tree_dependencies(self.instance)
\ No newline at end of file
diff --git a/multi_user/bl_types/bl_object.py b/multi_user/bl_types/bl_object.py
index 021fb35..4d0bc79 100644
--- a/multi_user/bl_types/bl_object.py
+++ b/multi_user/bl_types/bl_object.py
@@ -24,7 +24,6 @@ from replication.exception import ContextError
from .bl_datablock import BlDatablock, get_datablock_from_uuid
from .dump_anything import Dumper, Loader
-from replication.exception import ReparentException
def load_pose(target_bone, data):
@@ -120,9 +119,7 @@ class BlObject(BlDatablock):
data_uuid = data.get("data_uuid")
data_id = data.get("data")
- if target.type != data['type']:
- raise ReparentException()
- elif target.data and (target.data.name != data_id):
+ if target.data and (target.data.name != data_id):
target.data = get_datablock_from_uuid(data_uuid, find_data_from_name(data_id), ignore=['images'])
# vertex groups
@@ -191,10 +188,10 @@ class BlObject(BlDatablock):
target_bone.bone_group = target.pose.bone_group[bone_data['bone_group_index']]
# TODO: find another way...
- if target.type == 'EMPTY':
+ if target.empty_display_type == "IMAGE":
img_uuid = data.get('data_uuid')
if target.data is None and img_uuid:
- target.data = get_datablock_from_uuid(img_uuid, None)#bpy.data.images.get(img_key, None)
+ target.data = get_datablock_from_uuid(img_uuid, None)
def _dump_implementation(self, data, instance=None):
assert(instance)
diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py
index 5597493..02f0c89 100644
--- a/multi_user/bl_types/bl_scene.py
+++ b/multi_user/bl_types/bl_scene.py
@@ -16,15 +16,18 @@
# ##### END GPL LICENSE BLOCK #####
+import logging
+
import bpy
import mathutils
-
-from .dump_anything import Loader, Dumper
-from .bl_datablock import BlDatablock
-from .bl_collection import dump_collection_children, dump_collection_objects, load_collection_childrens, load_collection_objects
-from replication.constants import (DIFF_JSON, MODIFIED)
from deepdiff import DeepDiff
-import logging
+from replication.constants import DIFF_JSON, MODIFIED
+
+from .bl_collection import (dump_collection_children, dump_collection_objects,
+ load_collection_childrens, load_collection_objects,
+ resolve_collection_dependencies)
+from .bl_datablock import BlDatablock
+from .dump_anything import Dumper, Loader
RENDER_SETTINGS = [
'dither_intensity',
@@ -261,6 +264,12 @@ VIEW_SETTINGS = [
'black_level'
]
+
+
+
+
+
+
class BlScene(BlDatablock):
bl_id = "scenes"
bl_class = bpy.types.Scene
@@ -310,7 +319,7 @@ class BlScene(BlDatablock):
if 'view_settings' in data.keys():
loader.load(target.view_settings, data['view_settings'])
if target.view_settings.use_curve_mapping and \
- 'curve_mapping' in data['view_settings']:
+ 'curve_mapping' in data['view_settings']:
# TODO: change this ugly fix
target.view_settings.curve_mapping.white_level = data[
'view_settings']['curve_mapping']['white_level']
@@ -320,8 +329,8 @@ class BlScene(BlDatablock):
def _dump_implementation(self, data, instance=None):
assert(instance)
- data = {}
+ # Metadata
scene_dumper = Dumper()
scene_dumper.depth = 1
scene_dumper.include_filter = [
@@ -336,11 +345,9 @@ class BlScene(BlDatablock):
if self.preferences.sync_flags.sync_active_camera:
scene_dumper.include_filter.append('camera')
- data = scene_dumper.dump(instance)
+ data.update(scene_dumper.dump(instance))
- scene_dumper.depth = 3
-
- scene_dumper.include_filter = ['children', 'objects', 'name']
+ # Master collection
data['collection'] = {}
data['collection']['children'] = dump_collection_children(
instance.collection)
@@ -350,6 +357,7 @@ class BlScene(BlDatablock):
scene_dumper.depth = 1
scene_dumper.include_filter = None
+ # Render settings
if self.preferences.sync_flags.sync_render_settings:
scene_dumper.include_filter = RENDER_SETTINGS
@@ -377,18 +385,18 @@ class BlScene(BlDatablock):
data['view_settings']['curve_mapping']['curves'] = scene_dumper.dump(
instance.view_settings.curve_mapping.curves)
+ if instance.sequence_editor:
+ data['has_sequence'] = True
+ else:
+ data['has_sequence'] = False
+
return data
def _resolve_deps_implementation(self):
deps = []
- # child collections
- for child in self.instance.collection.children:
- deps.append(child)
-
- # childs objects
- for object in self.instance.collection.objects:
- deps.append(object)
+ # Master Collection
+ deps.extend(resolve_collection_dependencies(self.instance.collection))
# world
if self.instance.world:
@@ -398,6 +406,11 @@ class BlScene(BlDatablock):
if self.instance.grease_pencil:
deps.append(self.instance.grease_pencil)
+ # Sequences
+ # deps.extend(list(self.instance.sequence_editor.sequences_all))
+ if self.instance.sequence_editor:
+ deps.append(self.instance.sequence_editor)
+
return deps
def diff(self):
diff --git a/multi_user/bl_types/bl_sequencer.py b/multi_user/bl_types/bl_sequencer.py
new file mode 100644
index 0000000..b2376fd
--- /dev/null
+++ b/multi_user/bl_types/bl_sequencer.py
@@ -0,0 +1,197 @@
+# ##### 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 .
+#
+# ##### END GPL LICENSE BLOCK #####
+
+
+import bpy
+import mathutils
+from pathlib import Path
+import logging
+
+from .bl_file import get_filepath
+from .dump_anything import Loader, Dumper
+from .bl_datablock import BlDatablock, get_datablock_from_uuid
+
+def dump_sequence(sequence: bpy.types.Sequence) -> dict:
+ """ Dump a sequence to a dict
+
+ :arg sequence: sequence to dump
+ :type sequence: bpy.types.Sequence
+ :return dict:
+ """
+ dumper = Dumper()
+ dumper.exclude_filter = [
+ 'lock',
+ 'select',
+ 'select_left_handle',
+ 'select_right_handle',
+ 'strobe'
+ ]
+ dumper.depth = 1
+ data = dumper.dump(sequence)
+
+
+ # TODO: Support multiple images
+ if sequence.type == 'IMAGE':
+ data['filenames'] = [e.filename for e in sequence.elements]
+
+
+ # Effect strip inputs
+ input_count = getattr(sequence, 'input_count', None)
+ if input_count:
+ for n in range(input_count):
+ input_name = f"input_{n+1}"
+ data[input_name] = getattr(sequence, input_name).name
+
+ return data
+
+
+def load_sequence(sequence_data: dict, sequence_editor: bpy.types.SequenceEditor):
+ """ Load sequence from dumped data
+
+ :arg sequence_data: sequence to dump
+ :type sequence_data:dict
+ :arg sequence_editor: root sequence editor
+ :type sequence_editor: bpy.types.SequenceEditor
+ """
+ strip_type = sequence_data.get('type')
+ strip_name = sequence_data.get('name')
+ strip_channel = sequence_data.get('channel')
+ strip_frame_start = sequence_data.get('frame_start')
+
+ sequence = sequence_editor.sequences_all.get(strip_name, None)
+
+ if sequence is None:
+ if strip_type == 'SCENE':
+ strip_scene = bpy.data.scenes.get(sequence_data.get('scene'))
+ sequence = sequence_editor.sequences.new_scene(strip_name,
+ strip_scene,
+ strip_channel,
+ strip_frame_start)
+ elif strip_type == 'MOVIE':
+ filepath = get_filepath(Path(sequence_data['filepath']).name)
+ sequence = sequence_editor.sequences.new_movie(strip_name,
+ filepath,
+ strip_channel,
+ strip_frame_start)
+ elif strip_type == 'SOUND':
+ filepath = bpy.data.sounds[sequence_data['sound']].filepath
+ sequence = sequence_editor.sequences.new_sound(strip_name,
+ filepath,
+ strip_channel,
+ strip_frame_start)
+ elif strip_type == 'IMAGE':
+ images_name = sequence_data.get('filenames')
+ filepath = get_filepath(images_name[0])
+ sequence = sequence_editor.sequences.new_image(strip_name,
+ filepath,
+ strip_channel,
+ strip_frame_start)
+ # load other images
+ if len(images_name)>1:
+ for img_idx in range(1,len(images_name)):
+ sequence.elements.append((images_name[img_idx]))
+ else:
+ seq = {}
+
+ for i in range(sequence_data['input_count']):
+ seq[f"seq{i+1}"] = sequence_editor.sequences_all.get(sequence_data.get(f"input_{i+1}", None))
+
+ sequence = sequence_editor.sequences.new_effect(name=strip_name,
+ type=strip_type,
+ channel=strip_channel,
+ frame_start=strip_frame_start,
+ frame_end=sequence_data['frame_final_end'],
+ **seq)
+
+ loader = Loader()
+ loader.load(sequence, sequence_data)
+ sequence.select = False
+
+
+class BlSequencer(BlDatablock):
+ bl_id = "scenes"
+ bl_class = bpy.types.SequenceEditor
+ bl_delay_refresh = 1
+ bl_delay_apply = 1
+ bl_automatic_push = True
+ bl_check_common = True
+ bl_icon = 'SEQUENCE'
+
+ def _construct(self, data):
+ # Get the scene
+ scene_id = data.get('name')
+ scene = bpy.data.scenes.get(scene_id, None)
+
+ # Create sequencer data
+ scene.sequence_editor_clear()
+ scene.sequence_editor_create()
+
+ return scene.sequence_editor
+
+ def resolve(self):
+ scene = bpy.data.scenes.get(self.data['name'], None)
+ if scene:
+ if scene.sequence_editor is None:
+ self.instance = self._construct(self.data)
+ else:
+ self.instance = scene.sequence_editor
+ else:
+ logging.warning("Sequencer editor scene not found")
+
+ def _load_implementation(self, data, target):
+ loader = Loader()
+ # Sequencer
+ sequences = data.get('sequences')
+ if sequences:
+ for seq in target.sequences_all:
+ if seq.name not in sequences:
+ target.sequences.remove(seq)
+ for seq_name, seq_data in sequences.items():
+ load_sequence(seq_data, target)
+
+ def _dump_implementation(self, data, instance=None):
+ assert(instance)
+ sequence_dumper = Dumper()
+ sequence_dumper.depth = 1
+ sequence_dumper.include_filter = [
+ 'proxy_storage',
+ ]
+ data = {}#sequence_dumper.dump(instance)
+ # Sequencer
+ sequences = {}
+
+ for seq in instance.sequences_all:
+ sequences[seq.name] = dump_sequence(seq)
+
+ data['sequences'] = sequences
+ data['name'] = instance.id_data.name
+
+ return data
+
+
+ def _resolve_deps_implementation(self):
+ deps = []
+
+ for seq in self.instance.sequences_all:
+ if seq.type == 'MOVIE' and seq.filepath:
+ deps.append(Path(bpy.path.abspath(seq.filepath)))
+ elif seq.type == 'SOUND' and seq.sound:
+ deps.append(seq.sound)
+ elif seq.type == 'IMAGE':
+ for e in seq.elements:
+ deps.append(Path(bpy.path.abspath(seq.directory), e.filename))
+ return deps
diff --git a/multi_user/bl_types/bl_world.py b/multi_user/bl_types/bl_world.py
index f641c9f..99ba1ae 100644
--- a/multi_user/bl_types/bl_world.py
+++ b/multi_user/bl_types/bl_world.py
@@ -21,10 +21,8 @@ import mathutils
from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock
-from .bl_material import (load_links,
- load_node,
- dump_node,
- dump_links,
+from .bl_material import (load_shader_node_tree,
+ dump_shader_node_tree,
get_node_tree_dependencies)
@@ -48,15 +46,7 @@ class BlWorld(BlDatablock):
if target.node_tree is None:
target.use_nodes = True
- target.node_tree.nodes.clear()
-
- for node in data["node_tree"]["nodes"]:
- load_node(data["node_tree"]["nodes"][node], target.node_tree)
-
- # Load nodes links
- target.node_tree.links.clear()
-
- load_links(data["node_tree"]["links"], target.node_tree)
+ load_shader_node_tree(data['node_tree'], target.node_tree)
def _dump_implementation(self, data, instance=None):
assert(instance)
@@ -70,15 +60,7 @@ class BlWorld(BlDatablock):
]
data = world_dumper.dump(instance)
if instance.use_nodes:
- data['node_tree'] = {}
- nodes = {}
-
- for node in instance.node_tree.nodes:
- nodes[node.name] = dump_node(node)
-
- data["node_tree"]['nodes'] = nodes
-
- data["node_tree"]['links'] = dump_links(instance.node_tree.links)
+ data['node_tree'] = dump_shader_node_tree(instance.node_tree)
return data
diff --git a/multi_user/delayable.py b/multi_user/delayable.py
index 5fefee8..70ecfe6 100644
--- a/multi_user/delayable.py
+++ b/multi_user/delayable.py
@@ -36,8 +36,7 @@ from replication.constants import (FETCHED,
STATE_ACTIVE,
STATE_SYNCING,
STATE_LOBBY,
- STATE_SRV_SYNC,
- REPARENT)
+ STATE_SRV_SYNC)
from replication.interface import session
from replication.exception import NonAuthorizedOperationError
@@ -122,15 +121,6 @@ class ApplyTimer(Timer):
session.apply(node)
except Exception as e:
logging.error(f"Fail to apply {node_ref.uuid}: {e}")
- elif node_ref.state == REPARENT:
- # Reload the node
- node_ref.remove_instance()
- node_ref.resolve()
- session.apply(node)
- for parent in session._graph.find_parents(node):
- logging.info(f"Applying parent {parent}")
- session.apply(parent, force=True)
- node_ref.state = UP
class DynamicRightSelectTimer(Timer):
@@ -171,7 +161,8 @@ class DynamicRightSelectTimer(Timer):
session.change_owner(
node.uuid,
RP_COMMON,
- recursive=recursive)
+ ignore_warnings=True,
+ affect_dependencies=recursive)
except NonAuthorizedOperationError:
logging.warning(f"Not authorized to change {node} owner")
@@ -188,7 +179,8 @@ class DynamicRightSelectTimer(Timer):
session.change_owner(
node.uuid,
settings.username,
- recursive=recursive)
+ ignore_warnings=True,
+ affect_dependencies=recursive)
except NonAuthorizedOperationError:
logging.warning(f"Not authorized to change {node} owner")
else:
@@ -213,7 +205,8 @@ class DynamicRightSelectTimer(Timer):
session.change_owner(
key,
RP_COMMON,
- recursive=recursive)
+ ignore_warnings=True,
+ affect_dependencies=recursive)
except NonAuthorizedOperationError:
logging.warning(f"Not authorized to change {key} owner")
diff --git a/multi_user/environment.py b/multi_user/environment.py
index 8796a4c..5fc47a3 100644
--- a/multi_user/environment.py
+++ b/multi_user/environment.py
@@ -62,6 +62,9 @@ def install_package(name, version):
del env["PIP_REQUIRE_VIRTUALENV"]
subprocess.run([str(PYTHON_PATH), "-m", "pip", "install", f"{name}=={version}"], env=env)
+ if name in sys.modules:
+ del sys.modules[name]
+
def check_package_version(name, required_version):
logging.info(f"Checking {name} version...")
out = subprocess.run([str(PYTHON_PATH), "-m", "pip", "show", name], capture_output=True)
diff --git a/multi_user/operators.py b/multi_user/operators.py
index 4a7adbf..dc8611c 100644
--- a/multi_user/operators.py
+++ b/multi_user/operators.py
@@ -166,7 +166,8 @@ class SessionStartOperator(bpy.types.Operator):
# init the factory with supported types
for type in bl_types.types_to_register():
type_module = getattr(bl_types, type)
- type_impl_name = f"Bl{type.split('_')[1].capitalize()}"
+ name = [e.capitalize() for e in type.split('_')[1:]]
+ type_impl_name = 'Bl'+''.join(name)
type_module_class = getattr(type_module, type_impl_name)
supported_bl_types.append(type_module_class.bl_id)
@@ -226,7 +227,8 @@ class SessionStartOperator(bpy.types.Operator):
except Exception as e:
self.report({'ERROR'}, repr(e))
logging.error(f"Error: {e}")
-
+ import traceback
+ traceback.print_exc()
# Join a session
else:
if not runtime_settings.admin:
@@ -426,7 +428,8 @@ class SessionPropertyRightOperator(bpy.types.Operator):
if session:
session.change_owner(self.key,
runtime_settings.clients,
- recursive=self.recursive)
+ ignore_warnings=True,
+ affect_dependencies=self.recursive)
return {"FINISHED"}
diff --git a/multi_user/preferences.py b/multi_user/preferences.py
index d728c9c..0548da7 100644
--- a/multi_user/preferences.py
+++ b/multi_user/preferences.py
@@ -29,8 +29,9 @@ from .utils import get_preferences, get_expanded_icon
from replication.constants import RP_COMMON
from replication.interface import session
-IP_EXPR = re.compile('\d+\.\d+\.\d+\.\d+')
-
+# From https://stackoverflow.com/a/106223
+IP_REGEX = re.compile("^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$")
+HOSTNAME_REGEX = re.compile("^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$")
def randomColor():
"""Generate a random color """
@@ -53,10 +54,13 @@ def update_panel_category(self, context):
def update_ip(self, context):
- ip = IP_EXPR.search(self.ip)
+ ip = IP_REGEX.search(self.ip)
+ dns = HOSTNAME_REGEX.search(self.ip)
if ip:
self['ip'] = ip.group()
+ elif dns:
+ self['ip'] = dns.group()
else:
logging.error("Wrong IP format")
self['ip'] = "127.0.0.1"
@@ -238,6 +242,31 @@ class SessionPrefs(bpy.types.AddonPreferences):
set=set_log_level,
get=get_log_level
)
+ presence_hud_scale: bpy.props.FloatProperty(
+ name="Text scale",
+ description="Adjust the session widget text scale",
+ min=7,
+ max=90,
+ default=25,
+ )
+ presence_hud_hpos: bpy.props.FloatProperty(
+ name="Horizontal position",
+ description="Adjust the session widget horizontal position",
+ min=1,
+ max=90,
+ default=1,
+ step=1,
+ subtype='PERCENTAGE',
+ )
+ presence_hud_vpos: bpy.props.FloatProperty(
+ name="Vertical position",
+ description="Adjust the session widget vertical position",
+ min=1,
+ max=94,
+ default=1,
+ step=1,
+ subtype='PERCENTAGE',
+ )
conf_session_identity_expanded: bpy.props.BoolProperty(
name="Identity",
description="Identity",
@@ -412,6 +441,15 @@ class SessionPrefs(bpy.types.AddonPreferences):
emboss=False)
if self.conf_session_ui_expanded:
box.row().prop(self, "panel_category", text="Panel category", expand=True)
+ row = box.row()
+ row.label(text="Session widget:")
+
+ col = box.column(align=True)
+ col.prop(self, "presence_hud_scale", expand=True)
+
+
+ col.prop(self, "presence_hud_hpos", expand=True)
+ col.prop(self, "presence_hud_vpos", expand=True)
if self.category == 'UPDATE':
from . import addon_updater_ops
@@ -424,9 +462,9 @@ class SessionPrefs(bpy.types.AddonPreferences):
new_db = self.supported_datablocks.add()
type_module = getattr(bl_types, type)
- type_impl_name = f"Bl{type.split('_')[1].capitalize()}"
+ name = [e.capitalize() for e in type.split('_')[1:]]
+ type_impl_name = 'Bl'+''.join(name)
type_module_class = getattr(type_module, type_impl_name)
-
new_db.name = type_impl_name
new_db.type_name = type_impl_name
new_db.bl_delay_refresh = type_module_class.bl_delay_refresh
diff --git a/multi_user/presence.py b/multi_user/presence.py
index b885013..4776e03 100644
--- a/multi_user/presence.py
+++ b/multi_user/presence.py
@@ -35,7 +35,7 @@ from replication.constants import (STATE_ACTIVE, STATE_AUTH, STATE_CONFIG,
STATE_SYNCING, STATE_WAITING)
from replication.interface import session
-from .utils import find_from_attr, get_state_str
+from .utils import find_from_attr, get_state_str, get_preferences
# Helper functions
@@ -300,41 +300,38 @@ class UserSelectionWidget(Widget):
ob = find_from_attr("uuid", select_ob, bpy.data.objects)
if not ob:
return
-
- position = None
- if ob.type == 'EMPTY':
- # TODO: Child case
- # Collection instance case
- indices = (
- (0, 1), (1, 2), (2, 3), (0, 3),
- (4, 5), (5, 6), (6, 7), (4, 7),
- (0, 4), (1, 5), (2, 6), (3, 7))
- if ob.instance_collection:
- for obj in ob.instance_collection.objects:
- if obj.type == 'MESH' and hasattr(obj, 'bound_box'):
- positions = get_bb_coords_from_obj(obj, instance=ob)
- break
+ vertex_pos = bbox_from_obj(ob, 1.0)
+ vertex_indices = ((0, 1), (0, 2), (1, 3), (2, 3),
+ (4, 5), (4, 6), (5, 7), (6, 7),
+ (0, 4), (1, 5), (2, 6), (3, 7))
+
+ if ob.instance_collection:
+ for obj in ob.instance_collection.objects:
+ if obj.type == 'MESH' and hasattr(obj, 'bound_box'):
+ vertex_pos = get_bb_coords_from_obj(obj, instance=ob)
+ break
+ elif ob.type == 'EMPTY':
+ vertex_pos = bbox_from_obj(ob, ob.empty_display_size)
+ elif ob.type == 'LIGHT':
+ vertex_pos = bbox_from_obj(ob, ob.data.shadow_soft_size)
+ elif ob.type == 'LIGHT_PROBE':
+ vertex_pos = bbox_from_obj(ob, ob.data.influence_distance)
+ elif ob.type == 'CAMERA':
+ vertex_pos = bbox_from_obj(ob, ob.data.display_size)
elif hasattr(ob, 'bound_box'):
- indices = (
+ 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))
- positions = get_bb_coords_from_obj(ob)
- if positions is None:
- indices = (
- (0, 1), (0, 2), (1, 3), (2, 3),
- (4, 5), (4, 6), (5, 7), (6, 7),
- (0, 4), (1, 5), (2, 6), (3, 7))
-
- positions = bbox_from_obj(ob, ob.scale.x)
+ vertex_pos = get_bb_coords_from_obj(ob)
shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR')
batch = batch_for_shader(
shader,
'LINES',
- {"pos": positions},
- indices=indices)
+ {"pos": vertex_pos},
+ indices=vertex_indices)
shader.bind()
shader.uniform_float("color", self.data.get('color'))
@@ -387,6 +384,9 @@ class UserNameWidget(Widget):
class SessionStatusWidget(Widget):
draw_type = 'POST_PIXEL'
+ def __init__(self):
+ self.preferences = get_preferences()
+
@property
def settings(self):
return getattr(bpy.context.window_manager, 'session', None)
@@ -396,6 +396,8 @@ class SessionStatusWidget(Widget):
self.settings.enable_presence
def draw(self):
+ text_scale = self.preferences.presence_hud_scale
+ ui_scale = bpy.context.preferences.view.ui_scale
color = [1, 1, 0, 1]
state = session.state.get('STATE')
state_str = f"{get_state_str(state)}"
@@ -404,9 +406,11 @@ class SessionStatusWidget(Widget):
color = [0, 1, 0, 1]
elif state == STATE_INITIAL:
color = [1, 0, 0, 1]
+ hpos = (self.preferences.presence_hud_hpos*bpy.context.area.width)/100
+ vpos = (self.preferences.presence_hud_vpos*bpy.context.area.height)/100
- blf.position(0, 10, 20, 0)
- blf.size(0, 16, 45)
+ blf.position(0, hpos, vpos, 0)
+ blf.size(0, int(text_scale*ui_scale), 72)
blf.color(0, color[0], color[1], color[2], color[3])
blf.draw(0, state_str)
diff --git a/multi_user/ui.py b/multi_user/ui.py
index 1f90ec8..bf3fbb3 100644
--- a/multi_user/ui.py
+++ b/multi_user/ui.py
@@ -448,9 +448,17 @@ class SESSION_PT_presence(bpy.types.Panel):
layout = self.layout
settings = context.window_manager.session
+ pref = get_preferences()
layout.active = settings.enable_presence
col = layout.column()
col.prop(settings, "presence_show_session_status")
+ row = col.column()
+ row.active = settings.presence_show_session_status
+ row.prop(pref, "presence_hud_scale", expand=True)
+ row = col.column(align=True)
+ row.active = settings.presence_show_session_status
+ row.prop(pref, "presence_hud_hpos", expand=True)
+ row.prop(pref, "presence_hud_vpos", expand=True)
col.prop(settings, "presence_show_selected")
col.prop(settings, "presence_show_user")
row = layout.column()
@@ -622,7 +630,7 @@ class VIEW3D_PT_overlay_session(bpy.types.Panel):
col.prop(settings, "presence_show_session_status")
col.prop(settings, "presence_show_selected")
col.prop(settings, "presence_show_user")
-
+
row = layout.column()
row.active = settings.presence_show_user
row.prop(settings, "presence_show_far_user")
diff --git a/multi_user/utils.py b/multi_user/utils.py
index a8317c3..57ed532 100644
--- a/multi_user/utils.py
+++ b/multi_user/utils.py
@@ -99,7 +99,9 @@ def clean_scene():
type_collection.remove(item)
except:
continue
-
+
+ # Clear sequencer
+ bpy.context.scene.sequence_editor_clear()
def get_selected_objects(scene, active_view_layer):
return [obj.uuid for obj in scene.objects if obj.select_get(view_layer=active_view_layer)]