diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f0192..bf05f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -186,4 +186,34 @@ All notable changes to this project will be documented in this file. - Exception access violation during Undo/Redo - Sync missing armature bone Roll - Sync missing driver data_path -- Constraint replication \ No newline at end of file +- 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 diff --git a/README.md b/README.md index 7346cd5..beefd0f 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,8 @@ This tool aims to allow multiple users to work on the same scene over the networ ## Quick installation -1. Download latest release [multi_user.zip](https://gitlab.com/slumber/multi-user/-/jobs/artifacts/master/download?job=build). -2. Run blender as administrator (dependencies installation). -3. Install last_version.zip from your addon preferences. +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). +2. Install last_version.zip from your addon preferences. [Dependencies](#dependencies) will be automatically added to your blender python during installation. @@ -29,35 +28,35 @@ 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. -| Name | Status | Comment | -| -------------- | :----: | :----------------------------------------------------------: | -| action | ✔️ | | -| camera | ✔️ | | -| collection | ✔️ | | -| gpencil | ✔️ | | -| image | ✔️ | | -| mesh | ✔️ | | -| material | ✔️ | | -| node_groups | ✔️ | Material & Geometry only | -| geometry nodes | ✔️ | | -| metaball | ✔️ | | -| object | ✔️ | | -| texts | ✔️ | | -| scene | ✔️ | | -| world | ✔️ | | -| volumes | ✔️ | | -| lightprobes | ✔️ | | -| physics | ✔️ | | -| curve | ❗ | Nurbs surfaces not supported | -| textures | ❗ | Supported for modifiers/materials/geo nodes only | -| armature | ❗ | Not stable | -| particles | ❗ | The cache isn't syncing. | -| speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) | -| vse | ❗ | Mask and Clip not supported yet | -| libraries | ❗ | Partial | -| nla | ❌ | | -| texts | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/81) | -| compositing | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/46) | +| Name | Status | Comment | +| -------------- | :----: | :---------------------------------------------------------------------: | +| action | ✔️ | | +| camera | ✔️ | | +| collection | ✔️ | | +| gpencil | ✔️ | | +| image | ✔️ | | +| mesh | ✔️ | | +| material | ✔️ | | +| node_groups | ✔️ | Material & Geometry only | +| geometry nodes | ✔️ | | +| metaball | ✔️ | | +| object | ✔️ | | +| texts | ✔️ | | +| scene | ✔️ | | +| world | ✔️ | | +| volumes | ✔️ | | +| lightprobes | ✔️ | | +| physics | ✔️ | | +| textures | ✔️ | | +| curve | ❗ | Nurbs surfaces not supported | +| armature | ❗ | Only for Mesh. [Planned for GPencil](https://gitlab.com/slumber/multi-user/-/issues/161). Not stable yet | +| particles | ❗ | The cache isn't syncing. | +| speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) | +| vse | ❗ | Mask and Clip not supported yet | +| libraries | ❌ | | +| nla | ❌ | | +| texts | ❌ | [Planned for v0.5.0](https://gitlab.com/slumber/multi-user/-/issues/81) | +| compositing | ❌ | [Planned for v0.5.0](https://gitlab.com/slumber/multi-user/-/issues/46) | diff --git a/docs/getting_started/img/quickstart_presence.png b/docs/getting_started/img/quickstart_presence.png index cc39bb0..427eabd 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_replication.png b/docs/getting_started/img/quickstart_replication.png index 76fce66..9fc9916 100644 Binary files a/docs/getting_started/img/quickstart_replication.png and b/docs/getting_started/img/quickstart_replication.png differ diff --git a/docs/getting_started/img/quickstart_save_session_data.png b/docs/getting_started/img/quickstart_save_session_data.png index 08841b1..c870857 100644 Binary files a/docs/getting_started/img/quickstart_save_session_data.png and b/docs/getting_started/img/quickstart_save_session_data.png differ diff --git a/docs/getting_started/img/quickstart_save_session_data_cancel.png b/docs/getting_started/img/quickstart_save_session_data_cancel.png index 81e5793..c0f63ac 100644 Binary files a/docs/getting_started/img/quickstart_save_session_data_cancel.png and b/docs/getting_started/img/quickstart_save_session_data_cancel.png differ diff --git a/docs/getting_started/img/quickstart_status.png b/docs/getting_started/img/quickstart_status.png index 0a66d56..b498ad1 100644 Binary files a/docs/getting_started/img/quickstart_status.png and b/docs/getting_started/img/quickstart_status.png differ diff --git a/docs/getting_started/img/quickstart_users.png b/docs/getting_started/img/quickstart_users.png index 40ae4f2..d0fa47f 100644 Binary files a/docs/getting_started/img/quickstart_users.png and b/docs/getting_started/img/quickstart_users.png differ diff --git a/docs/getting_started/quickstart.rst b/docs/getting_started/quickstart.rst index f7ae7ff..cec1965 100644 --- a/docs/getting_started/quickstart.rst +++ b/docs/getting_started/quickstart.rst @@ -215,8 +215,10 @@ One of the most vital tools is the **Online user panel**. It lists all connected users' information including your own: * **Role** : if a user is an admin or a regular user. -* **Location**: Where the user is actually working. +* **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. * **Ping**: user's connection delay in milliseconds .. figure:: img/quickstart_users.png @@ -273,6 +275,7 @@ it draw users' related information in your viewport such as: * Username * User point of view +* User active mode * User selection .. figure:: img/quickstart_presence.png diff --git a/multi_user/__init__.py b/multi_user/__init__.py index 8d052f2..34aa94d 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, 5, 0), + "version": (0, 4, 0), "description": "Enable real-time collaborative workflow inside blender", "blender": (2, 82, 0), "location": "3D View > Sidebar > Multi-User tab", diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index 610f368..87aa200 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -162,7 +162,7 @@ def dump_layer(layer): 'hide', 'annotation_hide', 'lock', - # 'lock_frame', + 'lock_frame', # 'lock_material', # 'use_mask_layer', 'use_lights', @@ -170,12 +170,13 @@ def dump_layer(layer): 'select', 'show_points', 'show_in_front', + # 'thickness' # 'parent', # 'parent_type', # 'parent_bone', # 'matrix_inverse', ] - if layer.id_data.is_annotation: + if layer.thickness != 0: dumper.include_filter.append('thickness') dumped_layer = dumper.dump(layer) diff --git a/multi_user/presence.py b/multi_user/presence.py index 0a5957f..2da5096 100644 --- a/multi_user/presence.py +++ b/multi_user/presence.py @@ -94,18 +94,21 @@ def project_to_viewport(region: bpy.types.Region, rv3d: bpy.types.RegionView3D, return [target.x, target.y, target.z] -def bbox_from_obj(obj: bpy.types.Object) -> list: +def bbox_from_obj(obj: bpy.types.Object, index: int = 1) -> list: """ Generate a bounding box for a given object by using its world matrix :param obj: target object :type obj: bpy.types.Object + :param index: indice offset + :type index: int :return: list of 8 points [(x,y,z),...], list of 12 link between these points [(1,2),...] """ radius = 1.0 # Radius of the bounding box + index = 8*index 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)) + (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 @@ -117,9 +120,12 @@ def bbox_from_obj(obj: bpy.types.Object) -> list: radius = obj.data.display_size elif hasattr(obj, '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)) + (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 @@ -136,26 +142,21 @@ def bbox_from_obj(obj: bpy.types.Object) -> list: return vertex_pos, vertex_indices -def bbox_from_instance_collection(ic: bpy.types.Object) -> list: +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 radius: bounding box radius - :type radius: float + :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) + vertex_pos_temp, vertex_indices_temp = bbox_from_obj(obj, index=index+obj_index) vertex_pos += vertex_pos_temp - vertex_indices_list_temp = list(list(indice) for indice in vertex_indices_temp) - for indice in vertex_indices_list_temp: - indice[0] += 8*obj_index - indice[1] += 8*obj_index - vertex_indices_temp = tuple(tuple(indice) for indice in vertex_indices_list_temp) vertex_indices += vertex_indices_temp bbox_corners = [ic.matrix_world @ mathutils.Vector(vertex) for vertex in vertex_pos] @@ -223,7 +224,7 @@ def get_bb_coords_from_obj(object: bpy.types.Object, instance: bpy.types.Object bbox_corners = [base @ mathutils.Vector( corner) for corner in object.bound_box] - + return [(point.x, point.y, point.z) for point in bbox_corners] @@ -322,6 +323,8 @@ class UserSelectionWidget(Widget): username): self.username = username self.settings = bpy.context.window_manager.session + self.current_selection_ids = [] + self.current_selected_objects = [] @property def data(self): @@ -331,6 +334,15 @@ class UserSelectionWidget(Widget): else: 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): if self.data is None: return False @@ -344,27 +356,32 @@ class UserSelectionWidget(Widget): self.settings.presence_show_selected and \ self.settings.enable_presence - def draw(self): - user_selection = self.data.get('selected_objects') - for select_obj in user_selection: - obj = find_from_attr("uuid", select_obj, bpy.data.objects) - if not obj: - return - if obj.instance_collection: - vertex_pos, vertex_indices = bbox_from_instance_collection(obj) + def draw(self): + vertex_pos = [] + vertex_ind = [] + collection_offset = 0 + for obj_index, obj in enumerate(self.selected_objects): + if obj is None: + continue + obj_index+=collection_offset + if hasattr(obj, 'instance_collection') and obj.instance_collection: + bbox_pos, bbox_ind = bbox_from_instance_collection(obj, index=obj_index) + collection_offset+=len(obj.instance_collection.objects)-1 else : - vertex_pos, vertex_indices = bbox_from_obj(obj) + bbox_pos, bbox_ind = bbox_from_obj(obj, index=obj_index) + vertex_pos += bbox_pos + vertex_ind += bbox_ind - shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR') - batch = batch_for_shader( - shader, - 'LINES', - {"pos": vertex_pos}, - indices=vertex_indices) + shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR') + batch = batch_for_shader( + shader, + 'LINES', + {"pos": vertex_pos}, + indices=vertex_ind) - shader.bind() - shader.uniform_float("color", self.data.get('color')) - batch.draw(shader) + shader.bind() + shader.uniform_float("color", self.data.get('color')) + batch.draw(shader) class UserNameWidget(Widget): draw_type = 'POST_PIXEL' diff --git a/multi_user/timers.py b/multi_user/timers.py index 0bfe1cf..ad1555a 100644 --- a/multi_user/timers.py +++ b/multi_user/timers.py @@ -162,7 +162,7 @@ class AnnotationUpdates(Timer): logging.debug( "Getting the right on the annotation GP") porcelain.lock(session.repository, - registered_gp.uuid, + [registered_gp.uuid], ignore_warnings=True, affect_dependencies=False) @@ -172,14 +172,15 @@ class AnnotationUpdates(Timer): elif self._annotating: porcelain.unlock(session.repository, - registered_gp.uuid, + [registered_gp.uuid], ignore_warnings=True, affect_dependencies=False) + self._annotating = False class DynamicRightSelectTimer(Timer): def __init__(self, timeout=.1): super().__init__(timeout) - self._last_selection = [] + self._last_selection = set() self._user = None def execute(self): @@ -191,52 +192,46 @@ class DynamicRightSelectTimer(Timer): self._user = session.online_users.get(settings.username) if self._user: - current_selection = utils.get_selected_objects( + current_selection = set(utils.get_selected_objects( bpy.context.scene, bpy.data.window_managers['WinMan'].windows[0].view_layer - ) + )) if current_selection != self._last_selection: - obj_common = [ - o for o in self._last_selection if o not in current_selection] - obj_ours = [ - o for o in current_selection if o not in self._last_selection] + to_lock = list(current_selection.difference(self._last_selection)) + to_release = list(self._last_selection.difference(current_selection)) + instances_to_lock = list() - # change old selection right to common - for obj in obj_common: - node = session.repository.graph.get(obj) + for node_id in to_lock: + node = session.repository.graph.get(node_id) + 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 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: - porcelain.unlock(session.repository, - node.uuid, - ignore_warnings=True, - affect_dependencies=recursive) - except NonAuthorizedOperationError: - logging.warning( - 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: - porcelain.lock(session.repository, - node.uuid, - ignore_warnings=True, - affect_dependencies=recursive) - except NonAuthorizedOperationError: - logging.warning( - f"Not authorized to change {node} owner") - else: - return + if to_release: + try: + porcelain.unlock(session.repository, + to_release, + ignore_warnings=True, + affect_dependencies=True) + except NonAuthorizedOperationError as e: + logging.warning(e) + if to_lock: + try: + porcelain.lock(session.repository, + to_lock, + ignore_warnings=True, + affect_dependencies=True) + except NonAuthorizedOperationError as e: + logging.warning(e) self._last_selection = current_selection @@ -250,17 +245,16 @@ class DynamicRightSelectTimer(Timer): # Fix deselection until right managment refactoring (with Roles concepts) if len(current_selection) == 0 : owned_keys = [k for k, v in session.repository.graph.items() if v.owner==settings.username] - for key in owned_keys: - node = session.repository.graph.get(key) + if owned_keys: try: porcelain.unlock(session.repository, - key, + owned_keys, ignore_warnings=True, affect_dependencies=True) - except NonAuthorizedOperationError: - logging.warning( - f"Not authorized to change {key} owner") + except NonAuthorizedOperationError as e: + logging.warning(e) + # Objects selectability for obj in bpy.data.objects: object_uuid = getattr(obj, 'uuid', None) if object_uuid: