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)]