From fe9a096ab2f5c02b3208ea9ef2d53f153e4f48fb Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Thu, 5 Mar 2020 16:19:13 +0100 Subject: [PATCH 001/129] feat: store current scene in user metadata Related to #40 --- multi_user/delayable.py | 13 +++++++------ multi_user/ui.py | 13 +++++++++---- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/multi_user/delayable.py b/multi_user/delayable.py index 199cbee..4f492d5 100644 --- a/multi_user/delayable.py +++ b/multi_user/delayable.py @@ -236,7 +236,7 @@ class ClientUpdate(Timer): local_user_metadata = local_user.get('metadata') current_view_corners = presence.get_view_corners() - + scene_current = bpy.context.scene.name if not local_user_metadata or 'color' not in local_user_metadata.keys(): metadata = { 'view_corners': current_view_corners, @@ -245,7 +245,8 @@ class ClientUpdate(Timer): settings.client_color.g, settings.client_color.b, 1), - 'frame_current':bpy.context.scene.frame_current + 'frame_current':bpy.context.scene.frame_current, + 'scene_current': scene_current } session.update_user_metadata(metadata) elif current_view_corners != local_user_metadata['view_corners']: @@ -253,6 +254,9 @@ class ClientUpdate(Timer): local_user_metadata['view_corners'] = current_view_corners local_user_metadata['view_matrix'] = presence.get_view_matrix() session.update_user_metadata(local_user_metadata) + elif scene_current != local_user_metadata['scene_current']: + local_user_metadata['scene_current'] = scene_current + session.update_user_metadata(local_user_metadata) # sync online users session_users = operators.client.online_users @@ -260,12 +264,9 @@ class ClientUpdate(Timer): for index, user in enumerate(ui_users): if user.username not in session_users.keys(): - ui_users.remove(index) - + ui_users.remove(index) renderer.flush_selection() renderer.flush_users() - - break for user in session_users: diff --git a/multi_user/ui.py b/multi_user/ui.py index 37931d9..d2d9db6 100644 --- a/multi_user/ui.py +++ b/multi_user/ui.py @@ -266,8 +266,10 @@ class SESSION_PT_user(bpy.types.Panel): # Create a simple row. row = layout.row() box = row.box() - split = box.split(factor=0.5) + split = box.split(factor=0.3) split.label(text="user") + split = split.split(factor=0.5) + split.label(text="localisation") split.label(text="frame") split.label(text="ping") @@ -297,6 +299,7 @@ class SESSION_UL_users(bpy.types.UIList): is_local_user = item.username == settings.username ping = '-' frame_current = '-' + scene_current = '-' if session: user = session.online_users.get(item.username) if user: @@ -304,12 +307,14 @@ class SESSION_UL_users(bpy.types.UIList): metadata = user.get('metadata') if metadata and 'frame_current' in metadata: frame_current = str(metadata['frame_current']) - - split = layout.split(factor=0.5) + scene_current = metadata['scene_current'] + split = layout.split(factor=0.3) split.label(text=item.username) + split = split.split(factor=0.5) + split.label(text=scene_current) split.label(text=frame_current) split.label(text=ping) - + class SESSION_PT_presence(bpy.types.Panel): bl_idname = "MULTIUSER_MODULE_PT_panel" From 250cf91032384e6a5a8a6cb7a9c5291cfebf5637 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Thu, 5 Mar 2020 17:20:04 +0100 Subject: [PATCH 002/129] feat: show flag for user on an other scene feat: disable user drawing on other scene Related to #40 --- multi_user/__init__.py | 6 ++++++ multi_user/delayable.py | 16 +++++++++++----- multi_user/presence.py | 4 ++++ multi_user/ui.py | 5 ++++- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/multi_user/__init__.py b/multi_user/__init__.py index 7170121..1abd894 100644 --- a/multi_user/__init__.py +++ b/multi_user/__init__.py @@ -98,6 +98,12 @@ class SessionProps(bpy.types.PropertyGroup): default=True, update=presence.update_overlay_settings ) + presence_show_far_user: bpy.props.BoolProperty( + name="Show different scenes", + description="Show user on different scenes", + default=False, + update=presence.update_overlay_settings + ) filter_owned: bpy.props.BoolProperty( name="filter_owned", description='Show only owned datablocks', diff --git a/multi_user/delayable.py b/multi_user/delayable.py index 4f492d5..dcdf72f 100644 --- a/multi_user/delayable.py +++ b/multi_user/delayable.py @@ -204,15 +204,21 @@ class DrawClient(Draw): for user in users.values(): metadata = user.get('metadata') - - if 'color' in metadata: + color = metadata.get('color') + scene_current = metadata.get('scene_current') + user_showable = scene_current == bpy.context.scene.name or settings.presence_show_far_user + if color and scene_current and user_showable: if settings.presence_show_selected and 'selected_objects' in metadata.keys(): renderer.draw_client_selection( - user['id'], metadata['color'], metadata['selected_objects']) + user['id'], color, metadata['selected_objects']) if settings.presence_show_user and 'view_corners' in metadata: renderer.draw_client_camera( - user['id'], metadata['view_corners'], metadata['color']) - + user['id'], metadata['view_corners'], color) + if not user_showable: + # TODO: remove this when user event drivent update will be + # ready + renderer.flush_selection() + renderer.flush_users() class ClientUpdate(Timer): def __init__(self, timout=.5): diff --git a/multi_user/presence.py b/multi_user/presence.py index 36b5a3a..5494e19 100644 --- a/multi_user/presence.py +++ b/multi_user/presence.py @@ -219,6 +219,10 @@ class DrawFactory(object): 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': diff --git a/multi_user/ui.py b/multi_user/ui.py index d2d9db6..5e356f7 100644 --- a/multi_user/ui.py +++ b/multi_user/ui.py @@ -341,7 +341,10 @@ class SESSION_PT_presence(bpy.types.Panel): col = layout.column() col.prop(settings,"presence_show_selected") col.prop(settings,"presence_show_user") - row = layout.row() + row = layout.column() + row.active = settings.presence_show_user + row.prop(settings,"presence_show_far_user") + class SESSION_PT_services(bpy.types.Panel): bl_idname = "MULTIUSER_SERVICE_PT_panel" From c281ac439705eefdff9f25abab04fc1cb2a6f138 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Thu, 5 Mar 2020 17:31:12 +0100 Subject: [PATCH 003/129] feat: update snap user to support snaping accross scenes Related to #40 --- multi_user/operators.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/multi_user/operators.py b/multi_user/operators.py index 4172169..424418f 100644 --- a/multi_user/operators.py +++ b/multi_user/operators.py @@ -287,6 +287,10 @@ class SessionSnapUserOperator(bpy.types.Operator): target_ref = client.online_users.get(self.target_client) if target_ref: + target_scene = target_ref['metadata']['scene_current'] + if target_scene != context.scene.name: + bpy.context.window.scene = bpy.data.scenes[target_scene] + rv3d.view_matrix = mathutils.Matrix( target_ref['metadata']['view_matrix']) else: From a8da01c8ff49f2a4cdeaf44effa4572b1626a820 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Mon, 9 Mar 2020 15:59:30 +0100 Subject: [PATCH 004/129] feat: start to leanup datablock io api --- multi_user/bl_types/bl_action.py | 3 + multi_user/bl_types/bl_armature.py | 3 + multi_user/bl_types/bl_camera.py | 7 +- multi_user/bl_types/bl_collection.py | 2 +- multi_user/bl_types/bl_curve.py | 59 ++++++++++------ multi_user/bl_types/bl_datablock.py | 52 +++++++------- multi_user/bl_types/bl_gpencil.py | 21 +++--- multi_user/bl_types/bl_image.py | 5 +- multi_user/bl_types/bl_lattice.py | 7 +- multi_user/bl_types/bl_library.py | 7 +- multi_user/bl_types/bl_light.py | 3 + multi_user/bl_types/bl_lightprobe.py | 12 ++-- multi_user/bl_types/bl_material.py | 23 +++--- multi_user/bl_types/bl_mesh.py | 2 +- multi_user/bl_types/bl_metaball.py | 3 + multi_user/bl_types/bl_object.py | 6 +- multi_user/bl_types/bl_scene.py | 2 +- multi_user/bl_types/bl_speaker.py | 7 +- multi_user/bl_types/bl_world.py | 2 +- multi_user/libs/dump_anything.py | 102 +++++++++++++++------------ multi_user/utils.py | 13 ---- 21 files changed, 187 insertions(+), 154 deletions(-) diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py index 162ccce..a54be6e 100644 --- a/multi_user/bl_types/bl_action.py +++ b/multi_user/bl_types/bl_action.py @@ -127,6 +127,9 @@ class BlAction(BlDatablock): return data + def resolve_deps_implementation(self): + return [] + def is_valid(self): return bpy.data.actions.get(self.data['name']) diff --git a/multi_user/bl_types/bl_armature.py b/multi_user/bl_types/bl_armature.py index 513d55e..ce49411 100644 --- a/multi_user/bl_types/bl_armature.py +++ b/multi_user/bl_types/bl_armature.py @@ -127,5 +127,8 @@ class BlArmature(BlDatablock): item.name for item in container_users if isinstance(item, bpy.types.Scene)] return data + def resolve_deps_implementation(self): + return [] + def is_valid(self): return bpy.data.armatures.get(self.data['name']) diff --git a/multi_user/bl_types/bl_camera.py b/multi_user/bl_types/bl_camera.py index 66fa1dd..22fb70e 100644 --- a/multi_user/bl_types/bl_camera.py +++ b/multi_user/bl_types/bl_camera.py @@ -13,7 +13,7 @@ class BlCamera(BlDatablock): bl_automatic_push = True bl_icon = 'CAMERA_DATA' - def load(self, data, target): + def load_implementation(self, data, target): utils.dump_anything.load(target, data) dof_settings = data.get('dof') @@ -51,6 +51,9 @@ class BlCamera(BlDatablock): 'aperture_ratio', ] return dumper.dump(pointer) - + + def resolve_deps_implementation(self): + return [] + def is_valid(self): return bpy.data.cameras.get(self.data['name']) diff --git a/multi_user/bl_types/bl_collection.py b/multi_user/bl_types/bl_collection.py index 550411e..6c8134f 100644 --- a/multi_user/bl_types/bl_collection.py +++ b/multi_user/bl_types/bl_collection.py @@ -77,7 +77,7 @@ class BlCollection(BlDatablock): return data - def resolve_dependencies(self): + def resolve_deps_implementation(self): deps = [] for child in self.pointer.children: diff --git a/multi_user/bl_types/bl_curve.py b/multi_user/bl_types/bl_curve.py index 66ff445..f306906 100644 --- a/multi_user/bl_types/bl_curve.py +++ b/multi_user/bl_types/bl_curve.py @@ -1,8 +1,11 @@ import bpy +import bpy.types as T import mathutils from .. import utils from .bl_datablock import BlDatablock +from ..libs import dump_anything + class BlCurve(BlDatablock): bl_id = "curves" @@ -13,51 +16,61 @@ class BlCurve(BlDatablock): bl_icon = 'CURVE_DATA' def construct(self, data): - return bpy.data.curves.new(data["name"], 'CURVE') + return bpy.data.curves.new(data["name"], data["type"]) - def load(self, data, target): - utils.dump_anything.load(target, data) + def load_implementation(self, data, target): + dump_anything.load(target, data) target.splines.clear() # load splines for spline in data['splines']: new_spline = target.splines.new(data['splines'][spline]['type']) - utils.dump_anything.load(new_spline, data['splines'][spline]) - + dump_anything.load(new_spline, data['splines'][spline]) + # Load curve geometry data - for bezier_point_index in data['splines'][spline]["bezier_points"]: - if bezier_point_index != 0: - new_spline.bezier_points.add(1) - utils.dump_anything.load( - new_spline.bezier_points[bezier_point_index], data['splines'][spline]["bezier_points"][bezier_point_index]) - - for point_index in data['splines'][spline]["points"]: - new_spline.points.add(1) - utils.dump_anything.load( - new_spline.points[point_index], data['splines'][spline]["points"][point_index]) + if new_spline.type == 'BEZIER': + for bezier_point_index in data['splines'][spline]["bezier_points"]: + if bezier_point_index != 0: + new_spline.bezier_points.add(1) + dump_anything.load( + new_spline.bezier_points[bezier_point_index], data['splines'][spline]["bezier_points"][bezier_point_index]) + + # Not really working for now... + # See https://blender.stackexchange.com/questions/7020/create-nurbs-surface-with-python + if new_spline.type == 'NURBS': + new_spline.points.add(len(data['splines'][spline]["points"])-1) + for point_index in data['splines'][spline]["points"]: + dump_anything.load( + new_spline.points[point_index], data['splines'][spline]["points"][point_index]) def dump_implementation(self, data, pointer=None): assert(pointer) - data = utils.dump_datablock(pointer, 1) + dumper = dump_anything.Dumper() + + data = dumper.dump(pointer) data['splines'] = {} dumper = utils.dump_anything.Dumper() dumper.depth = 3 - - for index,spline in enumerate(pointer.splines): - spline_data = {} + + for index, spline in enumerate(pointer.splines): + spline_data = dump_anything.dump(spline) spline_data['points'] = dumper.dump(spline.points) spline_data['bezier_points'] = dumper.dump(spline.bezier_points) spline_data['type'] = dumper.dump(spline.type) data['splines'][index] = spline_data - if isinstance(pointer,'TextCurve'): - data['type'] = 'TEXT' - if isinstance(pointer,'SurfaceCurve'): + + if isinstance(pointer, T.SurfaceCurve): data['type'] = 'SURFACE' - if isinstance(pointer,'TextCurve'): + elif isinstance(pointer, T.TextCurve): + data['type'] = 'FONT' + elif isinstance(pointer, T.Curve): data['type'] = 'CURVE' return data + def resolve_deps_implementation(self): + return [] + def is_valid(self): return bpy.data.curves.get(self.data['name']) diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index f54cbb4..fc0a64c 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -3,11 +3,11 @@ import mathutils from .. import utils from ..libs.replication.replication.data import ReplicatedDatablock -from ..libs.replication.replication.constants import UP -from ..libs.replication.replication.constants import DIFF_BINARY +from ..libs.replication.replication.constants import (UP, DIFF_BINARY) +from ..libs import dump_anything def dump_driver(driver): - dumper = utils.dump_anything.Dumper() + dumper = dump_anything.Dumper() dumper.depth = 6 data = dumper.dump(driver) @@ -22,7 +22,7 @@ def load_driver(target_datablock, src_driver): # Settings new_driver.driver.type = src_driver_data['type'] new_driver.driver.expression = src_driver_data['expression'] - utils.dump_anything.load(new_driver, src_driver) + dump_anything.load(new_driver, src_driver) # Variables for src_variable in src_driver_data['variables']: @@ -35,7 +35,7 @@ def load_driver(target_datablock, src_driver): src_target_data = src_var_data['targets'][src_target] new_var.targets[src_target].id = utils.resolve_from_id( src_target_data['id'], src_target_data['id_type']) - utils.dump_anything.load( + dump_anything.load( new_var.targets[src_target], src_target_data) # Fcurve @@ -47,7 +47,7 @@ def load_driver(target_datablock, src_driver): for index, src_point in enumerate(src_driver['keyframe_points']): new_point = new_fcurve[index] - utils.dump_anything.load( + dump_anything.load( new_point, src_driver['keyframe_points'][src_point]) @@ -72,12 +72,6 @@ class BlDatablock(ReplicatedDatablock): pointer.library) or \ (self.data and 'library' in self.data) - if self.is_library: - self.load = self.load_library - self.dump = self.dump_library - self.diff = self.diff_library - self.resolve_dependencies = self.resolve_dependencies_library - if self.pointer and hasattr(self.pointer, 'uuid'): self.pointer.uuid = self.uuid @@ -93,19 +87,10 @@ class BlDatablock(ReplicatedDatablock): """Generic datablock diff""" return self.pointer.name != self.data['name'] - def construct_library(self, data): - return None - - def load_library(self, data, target): - pass - - def dump_library(self, pointer=None): - return utils.dump_datablock(pointer, 1) - def diff_library(self): return False - def resolve_dependencies_library(self): + def resolve_deps_library(self): return [self.pointer.library] def resolve(self): @@ -116,8 +101,7 @@ class BlDatablock(ReplicatedDatablock): # In case of lost uuid (ex: undo), resolve by name and reassign it # TODO: avoid reference storing if not datablock_ref: - datablock_ref = getattr( - bpy.data, self.bl_id).get(self.data['name']) + datablock_ref = getattr(bpy.data, self.bl_id).get(self.data['name']) if datablock_ref: setattr(datablock_ref, 'uuid', self.uuid) @@ -126,6 +110,7 @@ class BlDatablock(ReplicatedDatablock): def dump(self, pointer=None): data = {} + # Dump animation data if utils.has_action(pointer): dumper = utils.dump_anything.Dumper() dumper.include_filter = ['action'] @@ -138,7 +123,11 @@ class BlDatablock(ReplicatedDatablock): dump_driver(driver)) data.update(dumped_drivers) - data.update(self.dump_implementation(data, pointer=pointer)) + + if self.is_library: + data.update(dump_anything.dump(pointer)) + else: + data.update(self.dump_implementation(data, pointer=pointer)) return data @@ -161,18 +150,27 @@ class BlDatablock(ReplicatedDatablock): if 'action' in data['animation_data']: target.animation_data.action = bpy.data.actions[data['animation_data']['action']] - self.load_implementation(data, target) + if self.is_library: + return + else: + self.load_implementation(data, target) def load_implementation(self, data, target): raise NotImplementedError - def resolve_dependencies(self): + def resolve_deps(self): dependencies = [] if utils.has_action(self.pointer): dependencies.append(self.pointer.animation_data.action) + if not self.is_library: + dependencies.extend(self.resolve_deps_implementation()) + print(dependencies) return dependencies + def resolve_deps_implementation(self): + raise NotImplementedError + def is_valid(self): raise NotImplementedError diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index 1c6c1b7..c6313bc 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -1,13 +1,13 @@ import bpy import mathutils -from .. import utils +from ..libs import dump_anything from .bl_datablock import BlDatablock def load_gpencil_layer(target=None, data=None, create=False): - utils.dump_anything.load(target, data) + dump_anything.load(target, data) for k,v in target.frames.items(): target.frames.remove(v) @@ -15,13 +15,12 @@ def load_gpencil_layer(target=None, data=None, create=False): tframe = target.frames.new(data["frames"][frame]['frame_number']) - # utils.dump_anything.load(tframe, data["frames"][frame]) for stroke in data["frames"][frame]["strokes"]: try: tstroke = tframe.strokes[stroke] except: tstroke = tframe.strokes.new() - utils.dump_anything.load( + dump_anything.load( tstroke, data["frames"][frame]["strokes"][stroke]) for point in data["frames"][frame]["strokes"][stroke]["points"]: @@ -30,7 +29,7 @@ def load_gpencil_layer(target=None, data=None, create=False): tstroke.points.add(1) tpoint = tstroke.points[len(tstroke.points)-1] - utils.dump_anything.load(tpoint, p) + dump_anything.load(tpoint, p) class BlGpencil(BlDatablock): @@ -44,7 +43,7 @@ class BlGpencil(BlDatablock): def construct(self, data): return bpy.data.grease_pencils.new(data["name"]) - def load(self, data, target): + def load_implementation(self, data, target): for layer in target.layers: target.layers.remove(layer) @@ -57,7 +56,7 @@ class BlGpencil(BlDatablock): load_gpencil_layer( target=gp_layer, data=data["layers"][layer], create=True) - utils.dump_anything.load(target, data) + dump_anything.load(target, data) target.materials.clear() if "materials" in data.keys(): @@ -66,12 +65,12 @@ class BlGpencil(BlDatablock): def dump_implementation(self, data, pointer=None): assert(pointer) - data = utils.dump_datablock(pointer, 2) - utils.dump_datablock_attibutes( - pointer, ['layers'], 9, data) + data = dump_anything.dump(pointer, 2) + data['layers'] = dump_anything.dump(pointer.layers, 9) + return data - def resolve_dependencies(self): + def resolve_deps_implementation(self): deps = [] for material in self.pointer.materials: diff --git a/multi_user/bl_types/bl_image.py b/multi_user/bl_types/bl_image.py index dea2033..a2f2902 100644 --- a/multi_user/bl_types/bl_image.py +++ b/multi_user/bl_types/bl_image.py @@ -59,7 +59,7 @@ class BlImage(BlDatablock): image.colorspace_settings.name = data["colorspace_settings"]["name"] - def dump_implementation(self, data, pointer=None): + def dump(self, data, pointer=None): assert(pointer) data = {} data['pixels'] = dump_image(pointer) @@ -81,5 +81,8 @@ class BlImage(BlDatablock): def diff(self): return False + def resolve_deps_implementation(self): + return [] + def is_valid(self): return bpy.data.images.get(self.data['name']) diff --git a/multi_user/bl_types/bl_lattice.py b/multi_user/bl_types/bl_lattice.py index 46544ae..7d6a553 100644 --- a/multi_user/bl_types/bl_lattice.py +++ b/multi_user/bl_types/bl_lattice.py @@ -13,7 +13,7 @@ class BlLattice(BlDatablock): bl_automatic_push = True bl_icon = 'LATTICE_DATA' - def load(self, data, target): + def load_implementation(self, data, target): utils.dump_anything.load(target, data) for point in data['points']: @@ -21,7 +21,7 @@ class BlLattice(BlDatablock): def construct(self, data): return bpy.data.lattices.new(data["name"]) - def dump(self, pointer=None): + def dump_implementation(self, data, pointer=None): assert(pointer) dumper = utils.dump_anything.Dumper() @@ -45,6 +45,9 @@ class BlLattice(BlDatablock): return data + def resolve_deps_implementation(self): + return [] + def is_valid(self): return bpy.data.lattices.get(self.data['name']) diff --git a/multi_user/bl_types/bl_library.py b/multi_user/bl_types/bl_library.py index 179772f..c113268 100644 --- a/multi_user/bl_types/bl_library.py +++ b/multi_user/bl_types/bl_library.py @@ -1,7 +1,7 @@ import bpy import mathutils -from .. import utils +from ..libs import dump_anything from .bl_datablock import BlDatablock @@ -22,7 +22,10 @@ class BlLibrary(BlDatablock): def dump(self, pointer=None): assert(pointer) - return utils.dump_datablock(pointer, 1) + return dump_anything.dump(pointer, 1) + + def resolve_deps_implementation(self): + return [] def is_valid(self): return bpy.data.libraries.get(self.data['name']) \ No newline at end of file diff --git a/multi_user/bl_types/bl_light.py b/multi_user/bl_types/bl_light.py index d56ff6b..249d8ad 100644 --- a/multi_user/bl_types/bl_light.py +++ b/multi_user/bl_types/bl_light.py @@ -46,6 +46,9 @@ class BlLight(BlDatablock): data = dumper.dump(pointer) return data + def resolve_deps_implementation(self): + return [] + def is_valid(self): return bpy.data.lights.get(self.data['name']) diff --git a/multi_user/bl_types/bl_lightprobe.py b/multi_user/bl_types/bl_lightprobe.py index 218ec8f..56677e0 100644 --- a/multi_user/bl_types/bl_lightprobe.py +++ b/multi_user/bl_types/bl_lightprobe.py @@ -15,7 +15,7 @@ class BlLightprobe(BlDatablock): bl_automatic_push = True bl_icon = 'LIGHTPROBE_GRID' - def load(self, data, target): + def load_implementation(self, data, target): utils.dump_anything.load(target, data) def construct(self, data): @@ -24,12 +24,9 @@ class BlLightprobe(BlDatablock): if bpy.app.version[1] >= 83: return bpy.data.lightprobes.new(data["name"], type) else: - logger.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") + logger.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") - - - - def dump(self, pointer=None): + def dump_implementation(self, data, pointer=None): assert(pointer) if bpy.app.version[1] < 83: logger.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") @@ -59,5 +56,8 @@ class BlLightprobe(BlDatablock): return dumper.dump(pointer) + def resolve_deps_implementation(self): + return [] + def is_valid(self): return bpy.data.lattices.get(self.data['name']) diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index 8d2427e..722e49a 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -3,6 +3,7 @@ import mathutils import logging from .. import utils +from ..libs import dump_anything from .bl_datablock import BlDatablock logger = logging.getLogger(__name__) @@ -43,7 +44,7 @@ def load_node(target_node_tree, source): clean_color_ramp(target_node.color_ramp) if source['type'] == 'CURVE_RGB': load_mapping(target_node.mapping, source['mapping']) - utils.dump_anything.load( + dump_anything.load( target_node, source) @@ -84,7 +85,7 @@ class BlMaterial(BlDatablock): if not target.is_grease_pencil: bpy.data.materials.create_gpencil_data(target) - utils.dump_anything.load( + dump_anything.load( target.grease_pencil, data['grease_pencil']) utils.load_dict(data['grease_pencil'], target.grease_pencil) @@ -95,7 +96,7 @@ class BlMaterial(BlDatablock): target.node_tree.nodes.clear() - utils.dump_anything.load(target,data) + dump_anything.load(target,data) # Load nodes for node in data["node_tree"]["nodes"]: @@ -109,7 +110,7 @@ class BlMaterial(BlDatablock): def dump_implementation(self, data, pointer=None): assert(pointer) - mat_dumper = utils.dump_anything.Dumper() + mat_dumper = dump_anything.Dumper() mat_dumper.depth = 2 mat_dumper.exclude_filter = [ "preview", @@ -120,7 +121,7 @@ class BlMaterial(BlDatablock): "line_color", "view_center", ] - node_dumper = utils.dump_anything.Dumper() + node_dumper = dump_anything.Dumper() node_dumper.depth = 1 node_dumper.exclude_filter = [ "dimensions", @@ -138,10 +139,10 @@ class BlMaterial(BlDatablock): "outputs", "width_hidden" ] - input_dumper = utils.dump_anything.Dumper() + input_dumper = dump_anything.Dumper() input_dumper.depth = 2 input_dumper.include_filter = ["default_value"] - links_dumper = utils.dump_anything.Dumper() + links_dumper = dump_anything.Dumper() links_dumper.depth = 3 links_dumper.include_filter = [ "name", @@ -165,7 +166,7 @@ class BlMaterial(BlDatablock): nodes[node.name]['inputs'][i.name] = input_dumper.dump( i) if hasattr(node, 'color_ramp'): - ramp_dumper = utils.dump_anything.Dumper() + ramp_dumper = dump_anything.Dumper() ramp_dumper.depth = 4 ramp_dumper.include_filter = [ 'elements', @@ -175,7 +176,7 @@ class BlMaterial(BlDatablock): ] nodes[node.name]['color_ramp'] = ramp_dumper.dump(node.color_ramp) if hasattr(node, 'mapping'): - curve_dumper = utils.dump_anything.Dumper() + curve_dumper = dump_anything.Dumper() curve_dumper.depth = 5 curve_dumper.include_filter = [ 'curves', @@ -187,10 +188,10 @@ class BlMaterial(BlDatablock): data["node_tree"]["links"] = links_dumper.dump(pointer.node_tree.links) elif pointer.is_grease_pencil: - utils.dump_datablock_attibutes(pointer, ["grease_pencil"], 3, data) + data['grease_pencil'] = dump_anything.dump(pointer.grease_pencil, 3) return data - def resolve_dependencies(self): + def resolve_deps_implementation(self): # TODO: resolve node group deps deps = [] diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index c1527e6..3f02f5e 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -168,7 +168,7 @@ class BlMesh(BlDatablock): return data - def resolve_dependencies(self): + def resolve_deps_implementation(self): deps = [] for material in self.pointer.materials: diff --git a/multi_user/bl_types/bl_metaball.py b/multi_user/bl_types/bl_metaball.py index b3e6a23..de6d9ba 100644 --- a/multi_user/bl_types/bl_metaball.py +++ b/multi_user/bl_types/bl_metaball.py @@ -33,5 +33,8 @@ class BlMetaball(BlDatablock): data = dumper.dump(pointer) return data + def resolve_deps_implementation(self): + return [] + def is_valid(self): return bpy.data.metaballs.get(self.data['name']) diff --git a/multi_user/bl_types/bl_object.py b/multi_user/bl_types/bl_object.py index 6460483..6446a83 100644 --- a/multi_user/bl_types/bl_object.py +++ b/multi_user/bl_types/bl_object.py @@ -328,9 +328,9 @@ class BlObject(BlDatablock): return data - def resolve_dependencies(self): - deps = super().resolve_dependencies() - + def resolve_deps_implementation(self): + deps = [] + # Avoid Empty case if self.pointer.data: deps.append(self.pointer.data) diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index aee876c..9da85fe 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -65,7 +65,7 @@ class BlScene(BlDatablock): return data - def resolve_dependencies(self): + def resolve_deps_implementation(self): deps = [] # child collections diff --git a/multi_user/bl_types/bl_speaker.py b/multi_user/bl_types/bl_speaker.py index 7f5cec3..331723f 100644 --- a/multi_user/bl_types/bl_speaker.py +++ b/multi_user/bl_types/bl_speaker.py @@ -13,13 +13,13 @@ class BlSpeaker(BlDatablock): bl_automatic_push = True bl_icon = 'SPEAKER' - def load(self, data, target): + def load_implementation(self, data, target): utils.dump_anything.load(target, data) def construct(self, data): return bpy.data.speakers.new(data["name"]) - def dump(self, pointer=None): + def dump_implementation(self, data, pointer=None): assert(pointer) dumper = utils.dump_anything.Dumper() @@ -41,6 +41,9 @@ class BlSpeaker(BlDatablock): return dumper.dump(pointer) + def resolve_deps_implementation(self): + return [] + def is_valid(self): return bpy.data.lattices.get(self.data['name']) diff --git a/multi_user/bl_types/bl_world.py b/multi_user/bl_types/bl_world.py index 44b215f..588ef64 100644 --- a/multi_user/bl_types/bl_world.py +++ b/multi_user/bl_types/bl_world.py @@ -90,7 +90,7 @@ class BlWorld(BlDatablock): pointer.node_tree, ["links"], 3, data['node_tree']) return data - def resolve_dependencies(self): + def resolve_deps_implementation(self): deps = [] if self.pointer.use_nodes: diff --git a/multi_user/libs/dump_anything.py b/multi_user/libs/dump_anything.py index 874ebfd..9c4f3fc 100644 --- a/multi_user/libs/dump_anything.py +++ b/multi_user/libs/dump_anything.py @@ -20,9 +20,11 @@ def _is_dictionnary(v): def _dump_filter_type(t): return lambda x: isinstance(x, t) + def _dump_filter_type_by_name(t_name): return lambda x: t_name == x.__class__.__name__ + def _dump_filter_array(array): # only primitive type array if not isinstance(array, T.bpy_prop_array): @@ -57,9 +59,11 @@ def _load_filter_array(array): return False return True + def _load_filter_color(color): return color.__class__.__name__ == 'Color' + def _load_filter_default(default): if default.read() is None: return False @@ -69,7 +73,6 @@ def _load_filter_default(default): class Dumper: - def __init__(self): self.verbose = False self.depth = 1 @@ -94,28 +97,41 @@ class Dumper: self._dump_identity = (lambda x, depth: x, lambda x, depth: x) self._dump_ref = (lambda x, depth: x.name, self._dump_object_as_branch) self._dump_ID = (lambda x, depth: x.name, self._dump_default_as_branch) - self._dump_collection = (self._dump_default_as_leaf, self._dump_collection_as_branch) - self._dump_array = (self._dump_default_as_leaf, self._dump_array_as_branch) - self._dump_matrix = (self._dump_matrix_as_leaf, self._dump_matrix_as_leaf) - self._dump_vector = (self._dump_vector_as_leaf, self._dump_vector_as_leaf) - self._dump_quaternion = (self._dump_quaternion_as_leaf, self._dump_quaternion_as_leaf) - self._dump_default = (self._dump_default_as_leaf, self._dump_default_as_branch) + self._dump_collection = ( + self._dump_default_as_leaf, self._dump_collection_as_branch) + self._dump_array = (self._dump_default_as_leaf, + self._dump_array_as_branch) + self._dump_matrix = (self._dump_matrix_as_leaf, + self._dump_matrix_as_leaf) + self._dump_vector = (self._dump_vector_as_leaf, + self._dump_vector_as_leaf) + self._dump_quaternion = ( + self._dump_quaternion_as_leaf, self._dump_quaternion_as_leaf) + self._dump_default = (self._dump_default_as_leaf, + self._dump_default_as_branch) self._dump_color = (self._dump_color_as_leaf, self._dump_color_as_leaf) def _build_match_elements(self): self._match_type_bool = (_dump_filter_type(bool), self._dump_identity) self._match_type_int = (_dump_filter_type(int), self._dump_identity) - self._match_type_float = (_dump_filter_type(float), self._dump_identity) + self._match_type_float = ( + _dump_filter_type(float), self._dump_identity) self._match_type_string = (_dump_filter_type(str), self._dump_identity) self._match_type_ref = (_dump_filter_type(T.Object), self._dump_ref) self._match_type_ID = (_dump_filter_type(T.ID), self._dump_ID) - self._match_type_bpy_prop_collection = (_dump_filter_type(T.bpy_prop_collection), self._dump_collection) + self._match_type_bpy_prop_collection = ( + _dump_filter_type(T.bpy_prop_collection), self._dump_collection) self._match_type_array = (_dump_filter_array, self._dump_array) - self._match_type_matrix = (_dump_filter_type(mathutils.Matrix), self._dump_matrix) - self._match_type_vector = (_dump_filter_type(mathutils.Vector), self._dump_vector) - self._match_type_quaternion = (_dump_filter_type(mathutils.Quaternion), self._dump_quaternion) - self._match_type_euler = (_dump_filter_type(mathutils.Euler), self._dump_quaternion) - self._match_type_color = (_dump_filter_type_by_name("Color"), self._dump_color) + self._match_type_matrix = (_dump_filter_type( + mathutils.Matrix), self._dump_matrix) + self._match_type_vector = (_dump_filter_type( + mathutils.Vector), self._dump_vector) + self._match_type_quaternion = (_dump_filter_type( + mathutils.Quaternion), self._dump_quaternion) + self._match_type_euler = (_dump_filter_type( + mathutils.Euler), self._dump_quaternion) + self._match_type_color = ( + _dump_filter_type_by_name("Color"), self._dump_color) self._match_default = (_dump_filter_default, self._dump_default) def _dump_collection_as_branch(self, collection, depth): @@ -140,7 +156,7 @@ class Dumper: def _dump_vector_as_leaf(self, vector, depth): return list(vector) - + def _dump_quaternion_as_leaf(self, quaternion, depth): return list(quaternion) @@ -168,12 +184,13 @@ class Dumper: if p in ["bl_rna", "rna_type"]: return False return True - - all_property_names = [p for p in dir(default) if is_valid_property(p) and p != '' and p not in self.exclude_filter] + + all_property_names = [p for p in dir(default) if is_valid_property( + p) and p != '' and p not in self.exclude_filter] dump = {} for p in all_property_names: if (self.exclude_filter and p in self.exclude_filter) or\ - (self.include_filter and p not in self.include_filter): + (self.include_filter and p not in self.include_filter): return False dp = self._dump_any(getattr(default, p), depth) if not (dp is None): @@ -250,7 +267,8 @@ class Loader: def load(self, dst_data, src_dumped_data): self._load_any( - BlenderAPIElement(dst_data, occlude_read_only=self.occlude_read_only), + BlenderAPIElement( + dst_data, occlude_read_only=self.occlude_read_only), src_dumped_data ) @@ -259,7 +277,6 @@ class Loader: if filter_function(any): load_function(any, dump) return - def _load_identity(self, element, dump): element.write(dump) @@ -286,17 +303,20 @@ class Loader: } element_type = element.bl_rna_property.fixed_type constructor = constructors.get(type(element_type)) - if constructor is None: # collection type not supported + if constructor is None: # collection type not supported return for dumped_element in dump.values(): try: - constructor_parameters = [dumped_element[name] for name in constructor[1]] + constructor_parameters = [dumped_element[name] + for name in constructor[1]] except KeyError: print("Collection load error, missing parameters.") - continue # TODO handle error - new_element = getattr(element.read(), constructor[0])(*constructor_parameters) + continue # TODO handle error + new_element = getattr(element.read(), constructor[0])( + *constructor_parameters) self._load_any( - BlenderAPIElement(new_element, occlude_read_only=self.occlude_read_only), + BlenderAPIElement( + new_element, occlude_read_only=self.occlude_read_only), dumped_element ) @@ -322,10 +342,10 @@ class Loader: def _load_vector(self, vector, dump): vector.write(mathutils.Vector(dump)) - + def _load_quaternion(self, quaternion, dump): quaternion.write(mathutils.Quaternion(dump)) - + def _load_euler(self, euler, dump): euler.write(mathutils.Euler(dump)) @@ -341,11 +361,11 @@ class Loader: def _load_default(self, default, dump): if not _is_dictionnary(dump): - return # TODO error handling + return # TODO error handling for k in self._ordered_keys(dump.keys()): v = dump[k] if not hasattr(default.read(), k): - continue # TODO error handling + continue # TODO error handling try: self._load_any(default.extend(k), v) except: @@ -356,8 +376,10 @@ class Loader: return [ (_load_filter_type(T.BoolProperty), self._load_identity), (_load_filter_type(T.IntProperty), self._load_identity), - (_load_filter_type(mathutils.Matrix, use_bl_rna=False), self._load_matrix), # before float because bl_rna type of matrix if FloatProperty - (_load_filter_type(mathutils.Vector, use_bl_rna=False), self._load_vector), # before float because bl_rna type of vector if FloatProperty + # before float because bl_rna type of matrix if FloatProperty + (_load_filter_type(mathutils.Matrix, use_bl_rna=False), self._load_matrix), + # before float because bl_rna type of vector if FloatProperty + (_load_filter_type(mathutils.Vector, use_bl_rna=False), self._load_vector), (_load_filter_type(mathutils.Quaternion, use_bl_rna=False), self._load_quaternion), (_load_filter_type(mathutils.Euler, use_bl_rna=False), self._load_euler), (_load_filter_type(T.FloatProperty), self._load_identity), @@ -372,26 +394,12 @@ class Loader: # Utility functions - - def dump(any, depth=1): dumper = Dumper() - dumper.depath = depth + dumper.depth = depth return dumper.dump(any) -def dump_datablock(datablock, depth): - if datablock: - dumper = Dumper() - dumper.type_subset = dumper.match_subset_all - dumper.depth = depth - - datablock_type = datablock.bl_rna.name - key = "{}/{}".format(datablock_type, datablock.name) - data = dumper.dump(datablock) - - return data def load(dst, src): loader = Loader() - # loader.match_subset_all = loader.match_subset_all - loader.load(dst, src) \ No newline at end of file + loader.load(dst, src) diff --git a/multi_user/utils.py b/multi_user/utils.py index a7bf99c..db96e92 100644 --- a/multi_user/utils.py +++ b/multi_user/utils.py @@ -126,19 +126,6 @@ def load_dict(src_dict, target): pass -def dump_datablock(datablock, depth): - if datablock: - dumper = dump_anything.Dumper() - dumper.type_subset = dumper.match_subset_all - dumper.depth = depth - - datablock_type = datablock.bl_rna.name - key = "{}/{}".format(datablock_type, datablock.name) - data = dumper.dump(datablock) - - return data - - def dump_datablock_attibutes(datablock=None, attributes=[], depth=1, dickt=None): if datablock: dumper = dump_anything.Dumper() From 30f787f5073932c6fc324c84de2737e803543d0b Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Mon, 9 Mar 2020 16:12:18 +0100 Subject: [PATCH 005/129] refactor: progress on bl_data_io clean --- multi_user/bl_types/bl_action.py | 3 --- multi_user/bl_types/bl_armature.py | 3 --- multi_user/bl_types/bl_camera.py | 3 --- multi_user/bl_types/bl_collection.py | 3 --- multi_user/bl_types/bl_curve.py | 5 +---- multi_user/bl_types/bl_datablock.py | 2 +- multi_user/bl_types/bl_gpencil.py | 3 --- multi_user/bl_types/bl_image.py | 3 --- multi_user/bl_types/bl_lattice.py | 3 --- multi_user/bl_types/bl_library.py | 3 --- multi_user/bl_types/bl_light.py | 2 -- multi_user/bl_types/bl_lightprobe.py | 2 -- multi_user/bl_types/bl_material.py | 3 --- multi_user/bl_types/bl_mesh.py | 3 --- multi_user/bl_types/bl_metaball.py | 2 -- multi_user/bl_types/bl_object.py | 2 -- multi_user/bl_types/bl_scene.py | 3 --- multi_user/bl_types/bl_speaker.py | 2 -- multi_user/libs/replication | 2 +- 19 files changed, 3 insertions(+), 49 deletions(-) diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py index a54be6e..db7ebd9 100644 --- a/multi_user/bl_types/bl_action.py +++ b/multi_user/bl_types/bl_action.py @@ -130,7 +130,4 @@ class BlAction(BlDatablock): def resolve_deps_implementation(self): return [] - def is_valid(self): - return bpy.data.actions.get(self.data['name']) - diff --git a/multi_user/bl_types/bl_armature.py b/multi_user/bl_types/bl_armature.py index ce49411..c4d0757 100644 --- a/multi_user/bl_types/bl_armature.py +++ b/multi_user/bl_types/bl_armature.py @@ -129,6 +129,3 @@ class BlArmature(BlDatablock): def resolve_deps_implementation(self): return [] - - def is_valid(self): - return bpy.data.armatures.get(self.data['name']) diff --git a/multi_user/bl_types/bl_camera.py b/multi_user/bl_types/bl_camera.py index 22fb70e..cb11b04 100644 --- a/multi_user/bl_types/bl_camera.py +++ b/multi_user/bl_types/bl_camera.py @@ -54,6 +54,3 @@ class BlCamera(BlDatablock): def resolve_deps_implementation(self): return [] - - def is_valid(self): - return bpy.data.cameras.get(self.data['name']) diff --git a/multi_user/bl_types/bl_collection.py b/multi_user/bl_types/bl_collection.py index 6c8134f..38ac0b1 100644 --- a/multi_user/bl_types/bl_collection.py +++ b/multi_user/bl_types/bl_collection.py @@ -87,6 +87,3 @@ class BlCollection(BlDatablock): return deps - def is_valid(self): - return bpy.data.collections.get(self.data['name']) - diff --git a/multi_user/bl_types/bl_curve.py b/multi_user/bl_types/bl_curve.py index f306906..bd4445c 100644 --- a/multi_user/bl_types/bl_curve.py +++ b/multi_user/bl_types/bl_curve.py @@ -70,7 +70,4 @@ class BlCurve(BlDatablock): return data def resolve_deps_implementation(self): - return [] - - def is_valid(self): - return bpy.data.curves.get(self.data['name']) + return [] \ No newline at end of file diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index fc0a64c..6a97e5a 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -173,4 +173,4 @@ class BlDatablock(ReplicatedDatablock): raise NotImplementedError def is_valid(self): - raise NotImplementedError + return getattr(bpy.data, self.bl_id).get(self.data['name']) diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index c6313bc..d593e6d 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -77,6 +77,3 @@ class BlGpencil(BlDatablock): deps.append(material) return deps - - def is_valid(self): - return bpy.data.grease_pencils.get(self.data['name']) \ No newline at end of file diff --git a/multi_user/bl_types/bl_image.py b/multi_user/bl_types/bl_image.py index a2f2902..1e0cca5 100644 --- a/multi_user/bl_types/bl_image.py +++ b/multi_user/bl_types/bl_image.py @@ -83,6 +83,3 @@ class BlImage(BlDatablock): def resolve_deps_implementation(self): return [] - - def is_valid(self): - return bpy.data.images.get(self.data['name']) diff --git a/multi_user/bl_types/bl_lattice.py b/multi_user/bl_types/bl_lattice.py index 7d6a553..847c33b 100644 --- a/multi_user/bl_types/bl_lattice.py +++ b/multi_user/bl_types/bl_lattice.py @@ -48,8 +48,5 @@ class BlLattice(BlDatablock): def resolve_deps_implementation(self): return [] - def is_valid(self): - return bpy.data.lattices.get(self.data['name']) - diff --git a/multi_user/bl_types/bl_library.py b/multi_user/bl_types/bl_library.py index c113268..5787038 100644 --- a/multi_user/bl_types/bl_library.py +++ b/multi_user/bl_types/bl_library.py @@ -26,6 +26,3 @@ class BlLibrary(BlDatablock): def resolve_deps_implementation(self): return [] - - def is_valid(self): - return bpy.data.libraries.get(self.data['name']) \ No newline at end of file diff --git a/multi_user/bl_types/bl_light.py b/multi_user/bl_types/bl_light.py index 249d8ad..5402248 100644 --- a/multi_user/bl_types/bl_light.py +++ b/multi_user/bl_types/bl_light.py @@ -49,6 +49,4 @@ class BlLight(BlDatablock): def resolve_deps_implementation(self): return [] - def is_valid(self): - return bpy.data.lights.get(self.data['name']) diff --git a/multi_user/bl_types/bl_lightprobe.py b/multi_user/bl_types/bl_lightprobe.py index 56677e0..ad47739 100644 --- a/multi_user/bl_types/bl_lightprobe.py +++ b/multi_user/bl_types/bl_lightprobe.py @@ -59,5 +59,3 @@ class BlLightprobe(BlDatablock): def resolve_deps_implementation(self): return [] - def is_valid(self): - return bpy.data.lattices.get(self.data['name']) diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index 722e49a..fa33f40 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -204,6 +204,3 @@ class BlMaterial(BlDatablock): return deps - def is_valid(self): - return bpy.data.materials.get(self.data['name']) - diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index 3f02f5e..f502cae 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -176,6 +176,3 @@ class BlMesh(BlDatablock): deps.append(material) return deps - - def is_valid(self): - return bpy.data.meshes.get(self.data['name']) diff --git a/multi_user/bl_types/bl_metaball.py b/multi_user/bl_types/bl_metaball.py index de6d9ba..eba3bcc 100644 --- a/multi_user/bl_types/bl_metaball.py +++ b/multi_user/bl_types/bl_metaball.py @@ -36,5 +36,3 @@ class BlMetaball(BlDatablock): def resolve_deps_implementation(self): return [] - def is_valid(self): - return bpy.data.metaballs.get(self.data['name']) diff --git a/multi_user/bl_types/bl_object.py b/multi_user/bl_types/bl_object.py index 6446a83..26b5469 100644 --- a/multi_user/bl_types/bl_object.py +++ b/multi_user/bl_types/bl_object.py @@ -346,5 +346,3 @@ class BlObject(BlDatablock): return deps - def is_valid(self): - return bpy.data.objects.get(self.data['name']) diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index 9da85fe..bac83f8 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -85,6 +85,3 @@ class BlScene(BlDatablock): deps.append(self.pointer.grease_pencil) return deps - - def is_valid(self): - return bpy.data.scenes.get(self.data['name']) \ No newline at end of file diff --git a/multi_user/bl_types/bl_speaker.py b/multi_user/bl_types/bl_speaker.py index 331723f..8c775f1 100644 --- a/multi_user/bl_types/bl_speaker.py +++ b/multi_user/bl_types/bl_speaker.py @@ -44,6 +44,4 @@ class BlSpeaker(BlDatablock): def resolve_deps_implementation(self): return [] - def is_valid(self): - return bpy.data.lattices.get(self.data['name']) diff --git a/multi_user/libs/replication b/multi_user/libs/replication index 041bab1..f24d796 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit 041bab11e1fe7b807a453a2b157782c0962de515 +Subproject commit f24d796fe30c601d1ecee6231dd845ceab4707e1 From 9f59e7b6e895a720187eb83725cc6779320f1c84 Mon Sep 17 00:00:00 2001 From: Swann Date: Tue, 10 Mar 2020 18:00:23 +0100 Subject: [PATCH 006/129] fix: curve commit error Releated to #72 --- README.md | 32 +++++++++--------- multi_user/bl_types/bl_curve.py | 58 +++++++++++++++++++-------------- 2 files changed, 50 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 567059a..28ba30a 100644 --- a/README.md +++ b/README.md @@ -25,22 +25,22 @@ See the [documentation](https://multi-user.readthedocs.io/en/latest/) for detail Currently, not all data-block are supported for replication over the wire. The following list summarizes the status for each ones. -| Name | Status | Comment | -| ----------- | :----------------: | :------------: | -| action | :exclamation: | Not stable | -| armature | :exclamation: | Not stable | -| camera | :white_check_mark: | | -| collection | :white_check_mark: | | -| curve | :white_check_mark: | Not tested | -| gpencil | :white_check_mark: | | -| image | :exclamation: | Not stable yet | -| mesh | :white_check_mark: | | -| material | :white_check_mark: | | -| metaball | :white_check_mark: | | -| object | :white_check_mark: | | -| scene | :white_check_mark: | | -| world | :white_check_mark: | | -| lightprobes | :white_check_mark: | | +| Name | Status | Comment | +| ----------- | :----------------: | :--------------------------------: | +| action | :exclamation: | Not stable | +| armature | :exclamation: | Not stable | +| camera | :white_check_mark: | | +| collection | :white_check_mark: | | +| curve | :white_check_mark: | Nurbs surface don't load correctly | +| gpencil | :white_check_mark: | | +| image | :exclamation: | Not stable yet | +| mesh | :white_check_mark: | | +| material | :white_check_mark: | | +| metaball | :white_check_mark: | | +| object | :white_check_mark: | | +| scene | :white_check_mark: | | +| world | :white_check_mark: | | +| lightprobes | :white_check_mark: | | ### Performance issues diff --git a/multi_user/bl_types/bl_curve.py b/multi_user/bl_types/bl_curve.py index 66ff445..232377f 100644 --- a/multi_user/bl_types/bl_curve.py +++ b/multi_user/bl_types/bl_curve.py @@ -1,8 +1,11 @@ import bpy +import bpy.types as T import mathutils from .. import utils from .bl_datablock import BlDatablock +from ..libs import dump_anything + class BlCurve(BlDatablock): bl_id = "curves" @@ -13,51 +16,58 @@ class BlCurve(BlDatablock): bl_icon = 'CURVE_DATA' def construct(self, data): - return bpy.data.curves.new(data["name"], 'CURVE') + return bpy.data.curves.new(data["name"], data["type"]) - def load(self, data, target): - utils.dump_anything.load(target, data) + def load_implementation(self, data, target): + dump_anything.load(target, data) target.splines.clear() # load splines for spline in data['splines']: new_spline = target.splines.new(data['splines'][spline]['type']) - utils.dump_anything.load(new_spline, data['splines'][spline]) - + dump_anything.load(new_spline, data['splines'][spline]) + # Load curve geometry data - for bezier_point_index in data['splines'][spline]["bezier_points"]: - if bezier_point_index != 0: - new_spline.bezier_points.add(1) - utils.dump_anything.load( - new_spline.bezier_points[bezier_point_index], data['splines'][spline]["bezier_points"][bezier_point_index]) - - for point_index in data['splines'][spline]["points"]: - new_spline.points.add(1) - utils.dump_anything.load( - new_spline.points[point_index], data['splines'][spline]["points"][point_index]) + if new_spline.type == 'BEZIER': + for bezier_point_index in data['splines'][spline]["bezier_points"]: + if bezier_point_index != 0: + new_spline.bezier_points.add(1) + dump_anything.load( + new_spline.bezier_points[bezier_point_index], data['splines'][spline]["bezier_points"][bezier_point_index]) + + # Not really working for now... + # See https://blender.stackexchange.com/questions/7020/create-nurbs-surface-with-python + if new_spline.type == 'NURBS': + new_spline.points.add(len(data['splines'][spline]["points"])-1) + for point_index in data['splines'][spline]["points"]: + dump_anything.load( + new_spline.points[point_index], data['splines'][spline]["points"][point_index]) def dump_implementation(self, data, pointer=None): assert(pointer) - data = utils.dump_datablock(pointer, 1) + dumper = dump_anything.Dumper() + + data = dumper.dump(pointer) data['splines'] = {} dumper = utils.dump_anything.Dumper() dumper.depth = 3 - - for index,spline in enumerate(pointer.splines): - spline_data = {} + + for index, spline in enumerate(pointer.splines): + spline_data = dump_anything.dump(spline) spline_data['points'] = dumper.dump(spline.points) spline_data['bezier_points'] = dumper.dump(spline.bezier_points) spline_data['type'] = dumper.dump(spline.type) data['splines'][index] = spline_data - if isinstance(pointer,'TextCurve'): - data['type'] = 'TEXT' - if isinstance(pointer,'SurfaceCurve'): + + if isinstance(pointer, T.SurfaceCurve): data['type'] = 'SURFACE' - if isinstance(pointer,'TextCurve'): + elif isinstance(pointer, T.TextCurve): + data['type'] = 'FONT' + elif isinstance(pointer, T.Curve): data['type'] = 'CURVE' return data def is_valid(self): - return bpy.data.curves.get(self.data['name']) + return bpy.data.curves.get(self.data['name']) \ No newline at end of file From cd10dbb04d24a5d9cabddb68bf9a0a993d0e2c10 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Wed, 11 Mar 2020 15:09:52 +0100 Subject: [PATCH 007/129] feat: update submodule --- multi_user/libs/replication | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_user/libs/replication b/multi_user/libs/replication index f24d796..fee39e6 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit f24d796fe30c601d1ecee6231dd845ceab4707e1 +Subproject commit fee39e6ed68eba910438e0ae7b067de02d3e9035 From 75839e60f0f4c1276aa3770fc8bcf58980c11208 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Wed, 11 Mar 2020 15:17:13 +0100 Subject: [PATCH 008/129] refactor: cleanup --- multi_user/bl_types/bl_action.py | 3 +-- multi_user/bl_types/bl_armature.py | 3 +-- multi_user/bl_types/bl_camera.py | 3 +-- multi_user/bl_types/bl_curve.py | 2 -- multi_user/bl_types/bl_datablock.py | 2 +- multi_user/bl_types/bl_image.py | 3 +-- multi_user/bl_types/bl_lattice.py | 3 +-- multi_user/bl_types/bl_library.py | 3 +-- multi_user/bl_types/bl_light.py | 3 +-- multi_user/bl_types/bl_lightprobe.py | 3 +-- multi_user/bl_types/bl_metaball.py | 3 +-- multi_user/bl_types/bl_speaker.py | 3 +-- multi_user/libs/replication | 2 +- 13 files changed, 12 insertions(+), 24 deletions(-) diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py index db7ebd9..9d4c28f 100644 --- a/multi_user/bl_types/bl_action.py +++ b/multi_user/bl_types/bl_action.py @@ -127,7 +127,6 @@ class BlAction(BlDatablock): return data - def resolve_deps_implementation(self): - return [] + diff --git a/multi_user/bl_types/bl_armature.py b/multi_user/bl_types/bl_armature.py index c4d0757..8aca65c 100644 --- a/multi_user/bl_types/bl_armature.py +++ b/multi_user/bl_types/bl_armature.py @@ -127,5 +127,4 @@ class BlArmature(BlDatablock): item.name for item in container_users if isinstance(item, bpy.types.Scene)] return data - def resolve_deps_implementation(self): - return [] + diff --git a/multi_user/bl_types/bl_camera.py b/multi_user/bl_types/bl_camera.py index cb11b04..72700a2 100644 --- a/multi_user/bl_types/bl_camera.py +++ b/multi_user/bl_types/bl_camera.py @@ -52,5 +52,4 @@ class BlCamera(BlDatablock): ] return dumper.dump(pointer) - def resolve_deps_implementation(self): - return [] + diff --git a/multi_user/bl_types/bl_curve.py b/multi_user/bl_types/bl_curve.py index bd4445c..4a498e9 100644 --- a/multi_user/bl_types/bl_curve.py +++ b/multi_user/bl_types/bl_curve.py @@ -69,5 +69,3 @@ class BlCurve(BlDatablock): data['type'] = 'CURVE' return data - def resolve_deps_implementation(self): - return [] \ No newline at end of file diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index 6a97e5a..be6c8ed 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -170,7 +170,7 @@ class BlDatablock(ReplicatedDatablock): return dependencies def resolve_deps_implementation(self): - raise NotImplementedError + return [] def is_valid(self): return getattr(bpy.data, self.bl_id).get(self.data['name']) diff --git a/multi_user/bl_types/bl_image.py b/multi_user/bl_types/bl_image.py index 1e0cca5..8b965f1 100644 --- a/multi_user/bl_types/bl_image.py +++ b/multi_user/bl_types/bl_image.py @@ -81,5 +81,4 @@ class BlImage(BlDatablock): def diff(self): return False - def resolve_deps_implementation(self): - return [] + diff --git a/multi_user/bl_types/bl_lattice.py b/multi_user/bl_types/bl_lattice.py index 847c33b..5a7ac28 100644 --- a/multi_user/bl_types/bl_lattice.py +++ b/multi_user/bl_types/bl_lattice.py @@ -45,8 +45,7 @@ class BlLattice(BlDatablock): return data - def resolve_deps_implementation(self): - return [] + diff --git a/multi_user/bl_types/bl_library.py b/multi_user/bl_types/bl_library.py index 5787038..8e909ac 100644 --- a/multi_user/bl_types/bl_library.py +++ b/multi_user/bl_types/bl_library.py @@ -24,5 +24,4 @@ class BlLibrary(BlDatablock): assert(pointer) return dump_anything.dump(pointer, 1) - def resolve_deps_implementation(self): - return [] + diff --git a/multi_user/bl_types/bl_light.py b/multi_user/bl_types/bl_light.py index 5402248..6dfdd80 100644 --- a/multi_user/bl_types/bl_light.py +++ b/multi_user/bl_types/bl_light.py @@ -46,7 +46,6 @@ class BlLight(BlDatablock): data = dumper.dump(pointer) return data - def resolve_deps_implementation(self): - return [] + diff --git a/multi_user/bl_types/bl_lightprobe.py b/multi_user/bl_types/bl_lightprobe.py index ad47739..bc8edfd 100644 --- a/multi_user/bl_types/bl_lightprobe.py +++ b/multi_user/bl_types/bl_lightprobe.py @@ -56,6 +56,5 @@ class BlLightprobe(BlDatablock): return dumper.dump(pointer) - def resolve_deps_implementation(self): - return [] + diff --git a/multi_user/bl_types/bl_metaball.py b/multi_user/bl_types/bl_metaball.py index eba3bcc..7dec312 100644 --- a/multi_user/bl_types/bl_metaball.py +++ b/multi_user/bl_types/bl_metaball.py @@ -33,6 +33,5 @@ class BlMetaball(BlDatablock): data = dumper.dump(pointer) return data - def resolve_deps_implementation(self): - return [] + diff --git a/multi_user/bl_types/bl_speaker.py b/multi_user/bl_types/bl_speaker.py index 8c775f1..d675ee2 100644 --- a/multi_user/bl_types/bl_speaker.py +++ b/multi_user/bl_types/bl_speaker.py @@ -41,7 +41,6 @@ class BlSpeaker(BlDatablock): return dumper.dump(pointer) - def resolve_deps_implementation(self): - return [] + diff --git a/multi_user/libs/replication b/multi_user/libs/replication index fee39e6..99bf948 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit fee39e6ed68eba910438e0ae7b067de02d3e9035 +Subproject commit 99bf94874a07890dc747ec53519aa34443a95146 From dbaff5df85d932a486d3156d15ba588c0f5ba9a6 Mon Sep 17 00:00:00 2001 From: Swann Date: Wed, 11 Mar 2020 22:42:09 +0100 Subject: [PATCH 009/129] feat: auto_updater script --- .gitignore | 1 + multi_user/__init__.py | 6 +- multi_user/addon_updater.py | 1663 +++++++++++++++++++++++++++++++ multi_user/addon_updater_ops.py | 1454 +++++++++++++++++++++++++++ multi_user/preferences.py | 221 ++-- 5 files changed, 3255 insertions(+), 90 deletions(-) create mode 100644 multi_user/addon_updater.py create mode 100644 multi_user/addon_updater_ops.py diff --git a/.gitignore b/.gitignore index 9bdca26..bed8f4f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ __pycache__/ cache config *.code-workspace +multi_user_updater/ # sphinx build folder _build \ No newline at end of file diff --git a/multi_user/__init__.py b/multi_user/__init__.py index 1abd894..15392f2 100644 --- a/multi_user/__init__.py +++ b/multi_user/__init__.py @@ -1,7 +1,7 @@ bl_info = { "name": "Multi-User", "author": "Swann Martinez", - "version": (0, 0, 2), + "version": (0, 0, 1), "description": "Enable real-time collaborative workflow inside blender", "blender": (2, 80, 0), "location": "3D View > Sidebar > Multi-User tab", @@ -133,6 +133,7 @@ def register(): from . import operators from . import ui from . import preferences + from . import addon_updater_ops for cls in classes: bpy.utils.register_class(cls) @@ -146,6 +147,7 @@ def register(): bpy.types.WindowManager.user_index = bpy.props.IntProperty() preferences.register() + addon_updater_ops.register(bl_info) presence.register() operators.register() ui.register() @@ -155,8 +157,10 @@ def unregister(): from . import operators from . import ui from . import preferences + from . import addon_updater_ops presence.unregister() + addon_updater_ops.unregister() ui.unregister() operators.unregister() preferences.unregister() diff --git a/multi_user/addon_updater.py b/multi_user/addon_updater.py new file mode 100644 index 0000000..b369478 --- /dev/null +++ b/multi_user/addon_updater.py @@ -0,0 +1,1663 @@ +# ##### 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 2 +# 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, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + + +""" +See documentation for usage +https://github.com/CGCookie/blender-addon-updater + +""" + +import errno +import ssl +import urllib.request +import urllib +import os +import json +import zipfile +import shutil +import threading +import fnmatch +from datetime import datetime, timedelta + +# blender imports, used in limited cases +import bpy +import addon_utils + +# ----------------------------------------------------------------------------- +# Define error messages/notices & hard coded globals +# ----------------------------------------------------------------------------- + +# currently not used +DEFAULT_TIMEOUT = 10 +DEFAULT_PER_PAGE = 30 + + +# ----------------------------------------------------------------------------- +# The main class +# ----------------------------------------------------------------------------- + +class Singleton_updater(object): + """ + This is the singleton class to reference a copy from, + it is the shared module level class + """ + def __init__(self): + + self._engine = GithubEngine() + self._user = None + self._repo = None + self._website = None + self._current_version = None + self._subfolder_path = None + self._tags = [] + self._tag_latest = None + self._tag_names = [] + self._latest_release = None + self._use_releases = False + self._include_branches = False + self._include_branch_list = ['master'] + self._include_branch_autocheck = False + self._manual_only = False + self._version_min_update = None + self._version_max_update = None + + # by default, backup current addon if new is being loaded + self._backup_current = True + self._backup_ignore_patterns = None + + # set patterns for what files to overwrite on update + self._overwrite_patterns = ["*.py","*.pyc"] + self._remove_pre_update_patterns = [] + + # by default, don't auto enable/disable the addon on update + # as it is slightly less stable/won't always fully reload module + self._auto_reload_post_update = False + + # settings relating to frequency and whether to enable auto background check + self._check_interval_enable = False + self._check_interval_months = 0 + self._check_interval_days = 7 + self._check_interval_hours = 0 + self._check_interval_minutes = 0 + + # runtime variables, initial conditions + self._verbose = False + self._fake_install = False + self._async_checking = False # only true when async daemon started + self._update_ready = None + self._update_link = None + self._update_version = None + self._source_zip = None + self._check_thread = None + self._select_link = None + self.skip_tag = None + + # get from module data + self._addon = __package__.lower() + self._addon_package = __package__ # must not change + self._updater_path = os.path.join(os.path.dirname(__file__), + self._addon+"_updater") + self._addon_root = os.path.dirname(__file__) + self._json = {} + self._error = None + self._error_msg = None + self._prefiltered_tag_count = 0 + + # UI code only, ie not used within this module but still useful + # properties to have + + # to verify a valid import, in place of placeholder import + self.showpopups = True # used in UI to show or not show update popups + self.invalidupdater = False + + # pre-assign basic select-link function + def select_link_function(self, tag): + return tag["zipball_url"] + + self._select_link = select_link_function + + + # ------------------------------------------------------------------------- + # Getters and setters + # ------------------------------------------------------------------------- + + + @property + def addon(self): + return self._addon + @addon.setter + def addon(self, value): + self._addon = str(value) + + @property + def api_url(self): + return self._engine.api_url + @api_url.setter + def api_url(self, value): + if self.check_is_url(value) == False: + raise ValueError("Not a valid URL: " + value) + self._engine.api_url = value + + @property + def async_checking(self): + return self._async_checking + + @property + def auto_reload_post_update(self): + return self._auto_reload_post_update + @auto_reload_post_update.setter + def auto_reload_post_update(self, value): + try: + self._auto_reload_post_update = bool(value) + except: + raise ValueError("Must be a boolean value") + + @property + def backup_current(self): + return self._backup_current + @backup_current.setter + def backup_current(self, value): + if value == None: + self._backup_current = False + return + else: + self._backup_current = value + + @property + def backup_ignore_patterns(self): + return self._backup_ignore_patterns + @backup_ignore_patterns.setter + def backup_ignore_patterns(self, value): + if value == None: + self._backup_ignore_patterns = None + return + elif type(value) != type(['list']): + raise ValueError("Backup pattern must be in list format") + else: + self._backup_ignore_patterns = value + + @property + def check_interval(self): + return (self._check_interval_enable, + self._check_interval_months, + self._check_interval_days, + self._check_interval_hours, + self._check_interval_minutes) + + @property + def current_version(self): + return self._current_version + @current_version.setter + def current_version(self, tuple_values): + if tuple_values==None: + self._current_version = None + return + elif type(tuple_values) is not tuple: + try: + tuple(tuple_values) + except: + raise ValueError( + "Not a tuple! current_version must be a tuple of integers") + for i in tuple_values: + if type(i) is not int: + raise ValueError( + "Not an integer! current_version must be a tuple of integers") + self._current_version = tuple(tuple_values) + + @property + def engine(self): + return self._engine.name + @engine.setter + def engine(self, value): + if value.lower()=="github": + self._engine = GithubEngine() + elif value.lower()=="gitlab": + self._engine = GitlabEngine() + elif value.lower()=="bitbucket": + self._engine = BitbucketEngine() + else: + raise ValueError("Invalid engine selection") + + @property + def error(self): + return self._error + + @property + def error_msg(self): + return self._error_msg + + @property + def fake_install(self): + return self._fake_install + @fake_install.setter + def fake_install(self, value): + if type(value) != type(False): + raise ValueError("fake_install must be a boolean value") + self._fake_install = bool(value) + + # not currently used + @property + def include_branch_autocheck(self): + return self._include_branch_autocheck + @include_branch_autocheck.setter + def include_branch_autocheck(self, value): + try: + self._include_branch_autocheck = bool(value) + except: + raise ValueError("include_branch_autocheck must be a boolean value") + + @property + def include_branch_list(self): + return self._include_branch_list + @include_branch_list.setter + def include_branch_list(self, value): + try: + if value == None: + self._include_branch_list = ['master'] + elif type(value) != type(['master']) or value==[]: + raise ValueError("include_branch_list should be a list of valid branches") + else: + self._include_branch_list = value + except: + raise ValueError("include_branch_list should be a list of valid branches") + + @property + def include_branches(self): + return self._include_branches + @include_branches.setter + def include_branches(self, value): + try: + self._include_branches = bool(value) + except: + raise ValueError("include_branches must be a boolean value") + + @property + def json(self): + if self._json == {}: + self.set_updater_json() + return self._json + + @property + def latest_release(self): + if self._latest_release == None: + return None + return self._latest_release + + @property + def manual_only(self): + return self._manual_only + @manual_only.setter + def manual_only(self, value): + try: + self._manual_only = bool(value) + except: + raise ValueError("manual_only must be a boolean value") + + @property + def overwrite_patterns(self): + return self._overwrite_patterns + @overwrite_patterns.setter + def overwrite_patterns(self, value): + if value == None: + self._overwrite_patterns = ["*.py","*.pyc"] + elif type(value) != type(['']): + raise ValueError("overwrite_patterns needs to be in a list format") + else: + self._overwrite_patterns = value + + @property + def private_token(self): + return self._engine.token + @private_token.setter + def private_token(self, value): + if value==None: + self._engine.token = None + else: + self._engine.token = str(value) + + @property + def remove_pre_update_patterns(self): + return self._remove_pre_update_patterns + @remove_pre_update_patterns.setter + def remove_pre_update_patterns(self, value): + if value == None: + self._remove_pre_update_patterns = [] + elif type(value) != type(['']): + raise ValueError("remove_pre_update_patterns needs to be in a list format") + else: + self._remove_pre_update_patterns = value + + @property + def repo(self): + return self._repo + @repo.setter + def repo(self, value): + try: + self._repo = str(value) + except: + raise ValueError("User must be a string") + + @property + def select_link(self): + return self._select_link + @select_link.setter + def select_link(self, value): + # ensure it is a function assignment, with signature: + # input self, tag; returns link name + if not hasattr(value, "__call__"): + raise ValueError("select_link must be a function") + self._select_link = value + + @property + def stage_path(self): + return self._updater_path + @stage_path.setter + def stage_path(self, value): + if value == None: + if self._verbose: print("Aborting assigning stage_path, it's null") + return + elif value != None and not os.path.exists(value): + try: + os.makedirs(value) + except: + if self._verbose: print("Error trying to staging path") + return + self._updater_path = value + + @property + def subfolder_path(self): + return self._subfolder_path + @subfolder_path.setter + def subfolder_path(self, value): + self._subfolder_path = value + + @property + def tags(self): + if self._tags == []: + return [] + tag_names = [] + for tag in self._tags: + tag_names.append(tag["name"]) + return tag_names + + @property + def tag_latest(self): + if self._tag_latest == None: + return None + return self._tag_latest["name"] + + @property + def update_link(self): + return self._update_link + + @property + def update_ready(self): + return self._update_ready + + @property + def update_version(self): + return self._update_version + + @property + def use_releases(self): + return self._use_releases + @use_releases.setter + def use_releases(self, value): + try: + self._use_releases = bool(value) + except: + raise ValueError("use_releases must be a boolean value") + + @property + def user(self): + return self._user + @user.setter + def user(self, value): + try: + self._user = str(value) + except: + raise ValueError("User must be a string value") + + @property + def verbose(self): + return self._verbose + @verbose.setter + def verbose(self, value): + try: + self._verbose = bool(value) + if self._verbose == True: + print(self._addon+" updater verbose is enabled") + except: + raise ValueError("Verbose must be a boolean value") + + @property + def version_max_update(self): + return self._version_max_update + @version_max_update.setter + def version_max_update(self, value): + if value == None: + self._version_max_update = None + return + if type(value) != type((1,2,3)): + raise ValueError("Version maximum must be a tuple") + for subvalue in value: + if type(subvalue) != int: + raise ValueError("Version elements must be integers") + self._version_max_update = value + + @property + def version_min_update(self): + return self._version_min_update + @version_min_update.setter + def version_min_update(self, value): + if value == None: + self._version_min_update = None + return + if type(value) != type((1,2,3)): + raise ValueError("Version minimum must be a tuple") + for subvalue in value: + if type(subvalue) != int: + raise ValueError("Version elements must be integers") + self._version_min_update = value + + @property + def website(self): + return self._website + @website.setter + def website(self, value): + if self.check_is_url(value) == False: + raise ValueError("Not a valid URL: " + value) + self._website = value + + + # ------------------------------------------------------------------------- + # Parameter validation related functions + # ------------------------------------------------------------------------- + + + def check_is_url(self, url): + if not ("http://" in url or "https://" in url): + return False + if "." not in url: + return False + return True + + def get_tag_names(self): + tag_names = [] + self.get_tags() + for tag in self._tags: + tag_names.append(tag["name"]) + return tag_names + + def set_check_interval(self,enable=False,months=0,days=14,hours=0,minutes=0): + # enabled = False, default initially will not check against frequency + # if enabled, default is then 2 weeks + + if type(enable) is not bool: + raise ValueError("Enable must be a boolean value") + if type(months) is not int: + raise ValueError("Months must be an integer value") + if type(days) is not int: + raise ValueError("Days must be an integer value") + if type(hours) is not int: + raise ValueError("Hours must be an integer value") + if type(minutes) is not int: + raise ValueError("Minutes must be an integer value") + + if enable==False: + self._check_interval_enable = False + else: + self._check_interval_enable = True + + self._check_interval_months = months + self._check_interval_days = days + self._check_interval_hours = hours + self._check_interval_minutes = minutes + + # declare how the class gets printed + + def __repr__(self): + return "".format(a=__file__) + + def __str__(self): + return "Updater, with user: {a}, repository: {b}, url: {c}".format( + a=self._user, + b=self._repo, c=self.form_repo_url()) + + + # ------------------------------------------------------------------------- + # API-related functions + # ------------------------------------------------------------------------- + + def form_repo_url(self): + return self._engine.form_repo_url(self) + + def form_tags_url(self): + return self._engine.form_tags_url(self) + + def form_branch_url(self, branch): + return self._engine.form_branch_url(branch, self) + + def get_tags(self): + request = self.form_tags_url() + if self._verbose: print("Getting tags from server") + + # get all tags, internet call + all_tags = self._engine.parse_tags(self.get_api(request), self) + if all_tags is not None: + self._prefiltered_tag_count = len(all_tags) + else: + self._prefiltered_tag_count = 0 + all_tags = [] + + # pre-process to skip tags + if self.skip_tag != None: + self._tags = [tg for tg in all_tags if self.skip_tag(self, tg)==False] + else: + self._tags = all_tags + + # get additional branches too, if needed, and place in front + # Does NO checking here whether branch is valid + if self._include_branches == True: + temp_branches = self._include_branch_list.copy() + temp_branches.reverse() + for branch in temp_branches: + request = self.form_branch_url(branch) + include = { + "name":branch.title(), + "zipball_url":request + } + self._tags = [include] + self._tags # append to front + + if self._tags == None: + # some error occurred + self._tag_latest = None + self._tags = [] + return + elif self._prefiltered_tag_count == 0 and self._include_branches == False: + self._tag_latest = None + if self._error == None: # if not None, could have had no internet + self._error = "No releases found" + self._error_msg = "No releases or tags found on this repository" + if self._verbose: print("No releases or tags found on this repository") + elif self._prefiltered_tag_count == 0 and self._include_branches == True: + if not self._error: self._tag_latest = self._tags[0] + if self._verbose: + branch = self._include_branch_list[0] + print("{} branch found, no releases".format(branch), self._tags[0]) + elif (len(self._tags)-len(self._include_branch_list)==0 and self._include_branches==True) \ + or (len(self._tags)==0 and self._include_branches==False) \ + and self._prefiltered_tag_count > 0: + self._tag_latest = None + self._error = "No releases available" + self._error_msg = "No versions found within compatible version range" + if self._verbose: print("No versions found within compatible version range") + else: + if self._include_branches == False: + self._tag_latest = self._tags[0] + if self._verbose: print("Most recent tag found:",self._tags[0]['name']) + else: + # don't return branch if in list + n = len(self._include_branch_list) + self._tag_latest = self._tags[n] # guaranteed at least len()=n+1 + if self._verbose: print("Most recent tag found:",self._tags[n]['name']) + + + # all API calls to base url + def get_raw(self, url): + # print("Raw request:", url) + request = urllib.request.Request(url) + try: + context = ssl._create_unverified_context() + except: + # some blender packaged python versions don't have this, largely + # useful for local network setups otherwise minimal impact + context = None + + # setup private request headers if appropriate + if self._engine.token != None: + if self._engine.name == "gitlab": + request.add_header('PRIVATE-TOKEN',self._engine.token) + else: + if self._verbose: print("Tokens not setup for engine yet") + + # run the request + try: + if context: + result = urllib.request.urlopen(request, context=context) + else: + result = urllib.request.urlopen(request) + except urllib.error.HTTPError as e: + if str(e.code) == "403": + self._error = "HTTP error (access denied)" + self._error_msg = str(e.code) + " - server error response" + print(self._error, self._error_msg) + else: + self._error = "HTTP error" + self._error_msg = str(e.code) + print(self._error, self._error_msg) + self._update_ready = None + except urllib.error.URLError as e: + reason = str(e.reason) + if "TLSV1_ALERT" in reason or "SSL" in reason.upper(): + self._error = "Connection rejected, download manually" + self._error_msg = reason + print(self._error, self._error_msg) + else: + self._error = "URL error, check internet connection" + self._error_msg = reason + print(self._error, self._error_msg) + self._update_ready = None + return None + else: + result_string = result.read() + result.close() + return result_string.decode() + + + # result of all api calls, decoded into json format + def get_api(self, url): + # return the json version + get = None + get = self.get_raw(url) + if get != None: + try: + return json.JSONDecoder().decode(get) + except Exception as e: + self._error = "API response has invalid JSON format" + self._error_msg = str(e.reason) + self._update_ready = None + print(self._error, self._error_msg) + return None + else: + return None + + + # create a working directory and download the new files + def stage_repository(self, url): + + local = os.path.join(self._updater_path,"update_staging") + error = None + + # make/clear the staging folder + # ensure the folder is always "clean" + if self._verbose: print("Preparing staging folder for download:\n",local) + if os.path.isdir(local) == True: + try: + shutil.rmtree(local) + os.makedirs(local) + except: + error = "failed to remove existing staging directory" + else: + try: + os.makedirs(local) + except: + error = "failed to create staging directory" + + if error != None: + if self._verbose: print("Error: Aborting update, "+error) + self._error = "Update aborted, staging path error" + self._error_msg = "Error: {}".format(error) + return False + + if self._backup_current==True: + self.create_backup() + if self._verbose: print("Now retrieving the new source zip") + + self._source_zip = os.path.join(local,"source.zip") + + if self._verbose: print("Starting download update zip") + try: + request = urllib.request.Request(url) + context = ssl._create_unverified_context() + + # setup private token if appropriate + if self._engine.token != None: + if self._engine.name == "gitlab": + request.add_header('PRIVATE-TOKEN',self._engine.token) + else: + if self._verbose: print("Tokens not setup for selected engine yet") + self.urlretrieve(urllib.request.urlopen(request,context=context), self._source_zip) + # add additional checks on file size being non-zero + if self._verbose: print("Successfully downloaded update zip") + return True + except Exception as e: + self._error = "Error retrieving download, bad link?" + self._error_msg = "Error: {}".format(e) + if self._verbose: + print("Error retrieving download, bad link?") + print("Error: {}".format(e)) + return False + + + def create_backup(self): + if self._verbose: print("Backing up current addon folder") + local = os.path.join(self._updater_path,"backup") + tempdest = os.path.join(self._addon_root, + os.pardir, + self._addon+"_updater_backup_temp") + + if self._verbose: print("Backup destination path: ",local) + + if os.path.isdir(local): + try: + shutil.rmtree(local) + except: + if self._verbose:print("Failed to removed previous backup folder, contininuing") + + # remove the temp folder; shouldn't exist but could if previously interrupted + if os.path.isdir(tempdest): + try: + shutil.rmtree(tempdest) + except: + if self._verbose:print("Failed to remove existing temp folder, contininuing") + # make the full addon copy, which temporarily places outside the addon folder + if self._backup_ignore_patterns != None: + shutil.copytree( + self._addon_root,tempdest, + ignore=shutil.ignore_patterns(*self._backup_ignore_patterns)) + else: + shutil.copytree(self._addon_root,tempdest) + shutil.move(tempdest,local) + + # save the date for future ref + now = datetime.now() + self._json["backup_date"] = "{m}-{d}-{yr}".format( + m=now.strftime("%B"),d=now.day,yr=now.year) + self.save_updater_json() + + def restore_backup(self): + if self._verbose: print("Restoring backup") + + if self._verbose: print("Backing up current addon folder") + backuploc = os.path.join(self._updater_path,"backup") + tempdest = os.path.join(self._addon_root, + os.pardir, + self._addon+"_updater_backup_temp") + tempdest = os.path.abspath(tempdest) + + # make the copy + shutil.move(backuploc,tempdest) + shutil.rmtree(self._addon_root) + os.rename(tempdest,self._addon_root) + + self._json["backup_date"] = "" + self._json["just_restored"] = True + self._json["just_updated"] = True + self.save_updater_json() + + self.reload_addon() + + def unpack_staged_zip(self,clean=False): + """Unzip the downloaded file, and validate contents""" + if os.path.isfile(self._source_zip) == False: + if self._verbose: print("Error, update zip not found") + self._error = "Install failed" + self._error_msg = "Downloaded zip not found" + return -1 + + # clear the existing source folder in case previous files remain + outdir = os.path.join(self._updater_path, "source") + try: + shutil.rmtree(outdir) + if self._verbose: + print("Source folder cleared") + except: + pass + + # Create parent directories if needed, would not be relevant unless + # installing addon into another location or via an addon manager + try: + os.mkdir(outdir) + except Exception as err: + print("Error occurred while making extract dir:") + print(str(err)) + self._error = "Install failed" + self._error_msg = "Failed to make extract directory" + return -1 + + if not os.path.isdir(outdir): + print("Failed to create source directory") + self._error = "Install failed" + self._error_msg = "Failed to create extract directory" + return -1 + + if self._verbose: + print("Begin extracting source from zip:", self._source_zip) + zfile = zipfile.ZipFile(self._source_zip, "r") + + if not zfile: + if self._verbose: + print("Resulting file is not a zip, cannot extract") + self._error = "Install failed" + self._error_msg = "Resulting file is not a zip, cannot extract" + return -1 + + # Now extract directly from the first subfolder (not root) + # this avoids adding the first subfolder to the path length, + # which can be too long if the download has the SHA in the name + zsep = '/' #os.sep # might just always be / even on windows + for name in zfile.namelist(): + if zsep not in name: + continue + top_folder = name[:name.index(zsep)+1] + if name == top_folder + zsep: + continue # skip top level folder + subpath = name[name.index(zsep)+1:] + if name.endswith(zsep): + try: + os.mkdir(os.path.join(outdir, subpath)) + if self._verbose: + print("Extract - mkdir: ", os.path.join(outdir, subpath)) + except OSError as exc: + if exc.errno != errno.EEXIST: + self._error = "Install failed" + self._error_msg = "Could not create folder from zip" + return -1 + else: + with open(os.path.join(outdir, subpath), "wb") as outfile: + data = zfile.read(name) + outfile.write(data) + if self._verbose: + print("Extract - create:", os.path.join(outdir, subpath)) + + if self._verbose: + print("Extracted source") + + unpath = os.path.join(self._updater_path, "source") + if not os.path.isdir(unpath): + self._error = "Install failed" + self._error_msg = "Extracted path does not exist" + print("Extracted path does not exist: ", unpath) + return -1 + + if self._subfolder_path: + self._subfolder_path.replace('/', os.path.sep) + self._subfolder_path.replace('\\', os.path.sep) + + # either directly in root of zip/one subfolder, or use specified path + if os.path.isfile(os.path.join(unpath,"__init__.py")) == False: + dirlist = os.listdir(unpath) + if len(dirlist)>0: + if self._subfolder_path == "" or self._subfolder_path == None: + unpath = os.path.join(unpath, dirlist[0]) + else: + unpath = os.path.join(unpath, self._subfolder_path) + + # smarter check for additional sub folders for a single folder + # containing __init__.py + if os.path.isfile(os.path.join(unpath,"__init__.py")) == False: + if self._verbose: + print("not a valid addon found") + print("Paths:") + print(dirlist) + self._error = "Install failed" + self._error_msg = "No __init__ file found in new source" + return -1 + + # merge code with running addon directory, using blender default behavior + # plus any modifiers indicated by user (e.g. force remove/keep) + self.deepMergeDirectory(self._addon_root, unpath, clean) + + # Now save the json state + # Change to True, to trigger the handler on other side + # if allowing reloading within same blender instance + self._json["just_updated"] = True + self.save_updater_json() + self.reload_addon() + self._update_ready = False + return 0 + + + def deepMergeDirectory(self,base,merger,clean=False): + """Merge folder 'merger' into folder 'base' without deleting existing""" + if not os.path.exists(base): + if self._verbose: + print("Base path does not exist:", base) + return -1 + elif not os.path.exists(merger): + if self._verbose: + print("Merger path does not exist") + return -1 + + # paths to be aware of and not overwrite/remove/etc + staging_path = os.path.join(self._updater_path,"update_staging") + backup_path = os.path.join(self._updater_path,"backup") + + # If clean install is enabled, clear existing files ahead of time + # note: will not delete the update.json, update folder, staging, or staging + # but will delete all other folders/files in addon directory + error = None + if clean==True: + try: + # implement clearing of all folders/files, except the + # updater folder and updater json + # Careful, this deletes entire subdirectories recursively... + # make sure that base is not a high level shared folder, but + # is dedicated just to the addon itself + if self._verbose: print("clean=True, clearing addon folder to fresh install state") + + # remove root files and folders (except update folder) + files = [f for f in os.listdir(base) if os.path.isfile(os.path.join(base,f))] + folders = [f for f in os.listdir(base) if os.path.isdir(os.path.join(base,f))] + + for f in files: + os.remove(os.path.join(base,f)) + print("Clean removing file {}".format(os.path.join(base,f))) + for f in folders: + if os.path.join(base,f)==self._updater_path: continue + shutil.rmtree(os.path.join(base,f)) + print("Clean removing folder and contents {}".format(os.path.join(base,f))) + + except Exception as err: + error = "failed to create clean existing addon folder" + print(error, str(err)) + + # Walk through the base addon folder for rules on pre-removing + # but avoid removing/altering backup and updater file + for path, dirs, files in os.walk(base): + # prune ie skip updater folder + dirs[:] = [d for d in dirs if os.path.join(path,d) not in [self._updater_path]] + for file in files: + for ptrn in self.remove_pre_update_patterns: + if fnmatch.filter([file],ptrn): + try: + fl = os.path.join(path,file) + os.remove(fl) + if self._verbose: print("Pre-removed file "+file) + except OSError: + print("Failed to pre-remove "+file) + + # Walk through the temp addon sub folder for replacements + # this implements the overwrite rules, which apply after + # the above pre-removal rules. This also performs the + # actual file copying/replacements + for path, dirs, files in os.walk(merger): + # verify this structure works to prune updater sub folder overwriting + dirs[:] = [d for d in dirs if os.path.join(path,d) not in [self._updater_path]] + relPath = os.path.relpath(path, merger) + destPath = os.path.join(base, relPath) + if not os.path.exists(destPath): + os.makedirs(destPath) + for file in files: + # bring in additional logic around copying/replacing + # Blender default: overwrite .py's, don't overwrite the rest + destFile = os.path.join(destPath, file) + srcFile = os.path.join(path, file) + + # decide whether to replace if file already exists, and copy new over + if os.path.isfile(destFile): + # otherwise, check each file to see if matches an overwrite pattern + replaced=False + for ptrn in self._overwrite_patterns: + if fnmatch.filter([destFile],ptrn): + replaced=True + break + if replaced: + os.remove(destFile) + os.rename(srcFile, destFile) + if self._verbose: print("Overwrote file "+os.path.basename(destFile)) + else: + if self._verbose: print("Pattern not matched to "+os.path.basename(destFile)+", not overwritten") + else: + # file did not previously exist, simply move it over + os.rename(srcFile, destFile) + if self._verbose: print("New file "+os.path.basename(destFile)) + + # now remove the temp staging folder and downloaded zip + try: + shutil.rmtree(staging_path) + except: + error = "Error: Failed to remove existing staging directory, consider manually removing "+staging_path + if self._verbose: print(error) + + + def reload_addon(self): + # if post_update false, skip this function + # else, unload/reload addon & trigger popup + if self._auto_reload_post_update == False: + print("Restart blender to reload addon and complete update") + return + + if self._verbose: print("Reloading addon...") + addon_utils.modules(refresh=True) + bpy.utils.refresh_script_paths() + + # not allowed in restricted context, such as register module + # toggle to refresh + bpy.ops.wm.addon_disable(module=self._addon_package) + bpy.ops.wm.addon_refresh() + bpy.ops.wm.addon_enable(module=self._addon_package) + + + # ------------------------------------------------------------------------- + # Other non-api functions and setups + # ------------------------------------------------------------------------- + + def clear_state(self): + self._update_ready = None + self._update_link = None + self._update_version = None + self._source_zip = None + self._error = None + self._error_msg = None + + # custom urlretrieve implementation + def urlretrieve(self, urlfile, filepath): + chunk = 1024*8 + f = open(filepath, "wb") + while 1: + data = urlfile.read(chunk) + if not data: + #print("done.") + break + f.write(data) + #print("Read %s bytes"%len(data)) + f.close() + + + def version_tuple_from_text(self,text): + if text == None: return () + + # should go through string and remove all non-integers, + # and for any given break split into a different section + segments = [] + tmp = '' + for l in str(text): + if l.isdigit()==False: + if len(tmp)>0: + segments.append(int(tmp)) + tmp = '' + else: + tmp+=l + if len(tmp)>0: + segments.append(int(tmp)) + + if len(segments)==0: + if self._verbose: print("No version strings found text: ",text) + if self._include_branches == False: + return () + else: + return (text) + return tuple(segments) + + # called for running check in a background thread + def check_for_update_async(self, callback=None): + + if self._json != None and "update_ready" in self._json and self._json["version_text"]!={}: + if self._json["update_ready"] == True: + self._update_ready = True + self._update_link = self._json["version_text"]["link"] + self._update_version = str(self._json["version_text"]["version"]) + # cached update + callback(True) + return + + # do the check + if self._check_interval_enable == False: + return + elif self._async_checking == True: + if self._verbose: print("Skipping async check, already started") + return # already running the bg thread + elif self._update_ready == None: + self.start_async_check_update(False, callback) + + + def check_for_update_now(self, callback=None): + + self._error = None + self._error_msg = None + + if self._verbose: + print("Check update pressed, first getting current status") + if self._async_checking == True: + if self._verbose: print("Skipping async check, already started") + return # already running the bg thread + elif self._update_ready == None: + self.start_async_check_update(True, callback) + else: + self._update_ready = None + self.start_async_check_update(True, callback) + + + # this function is not async, will always return in sequential fashion + # but should have a parent which calls it in another thread + def check_for_update(self, now=False): + if self._verbose: print("Checking for update function") + + # clear the errors if any + self._error = None + self._error_msg = None + + # avoid running again in, just return past result if found + # but if force now check, then still do it + if self._update_ready != None and now == False: + return (self._update_ready,self._update_version,self._update_link) + + if self._current_version == None: + raise ValueError("current_version not yet defined") + if self._repo == None: + raise ValueError("repo not yet defined") + if self._user == None: + raise ValueError("username not yet defined") + + self.set_updater_json() # self._json + + if now == False and self.past_interval_timestamp()==False: + if self._verbose: + print("Aborting check for updated, check interval not reached") + return (False, None, None) + + # check if using tags or releases + # note that if called the first time, this will pull tags from online + if self._fake_install == True: + if self._verbose: + print("fake_install = True, setting fake version as ready") + self._update_ready = True + self._update_version = "(999,999,999)" + self._update_link = "http://127.0.0.1" + + return (self._update_ready, self._update_version, self._update_link) + + # primary internet call + self.get_tags() # sets self._tags and self._tag_latest + + self._json["last_check"] = str(datetime.now()) + self.save_updater_json() + + # can be () or ('master') in addition to branches, and version tag + new_version = self.version_tuple_from_text(self.tag_latest) + + if len(self._tags)==0: + self._update_ready = False + self._update_version = None + self._update_link = None + return (False, None, None) + if self._include_branches == False: + link = self.select_link(self, self._tags[0]) + else: + n = len(self._include_branch_list) + if len(self._tags)==n: + # effectively means no tags found on repo + # so provide the first one as default + link = self.select_link(self, self._tags[0]) + else: + link = self.select_link(self, self._tags[n]) + + if new_version == (): + self._update_ready = False + self._update_version = None + self._update_link = None + return (False, None, None) + elif str(new_version).lower() in self._include_branch_list: + # handle situation where master/whichever branch is included + # however, this code effectively is not triggered now + # as new_version will only be tag names, not branch names + if self._include_branch_autocheck == False: + # don't offer update as ready, + # but set the link for the default + # branch for installing + self._update_ready = False + self._update_version = new_version + self._update_link = link + self.save_updater_json() + return (True, new_version, link) + else: + raise ValueError("include_branch_autocheck: NOT YET DEVELOPED") + # bypass releases and look at timestamp of last update + # from a branch compared to now, see if commit values + # match or not. + + else: + # situation where branches not included + + if new_version > self._current_version: + + self._update_ready = True + self._update_version = new_version + self._update_link = link + self.save_updater_json() + return (True, new_version, link) + + # elif new_version != self._current_version: + # self._update_ready = False + # self._update_version = new_version + # self._update_link = link + # self.save_updater_json() + # return (True, new_version, link) + + # if no update, set ready to False from None + self._update_ready = False + self._update_version = None + self._update_link = None + return (False, None, None) + + + def set_tag(self, name): + """Assign the tag name and url to update to""" + tg = None + for tag in self._tags: + if name == tag["name"]: + tg = tag + break + if tg: + new_version = self.version_tuple_from_text(self.tag_latest) + self._update_version = new_version + self._update_link = self.select_link(self, tg) + elif self._include_branches and name in self._include_branch_list: + # scenario if reverting to a specific branch name instead of tag + tg = name + link = self.form_branch_url(tg) + self._update_version = name # this will break things + self._update_link = link + if not tg: + raise ValueError("Version tag not found: "+name) + + + def run_update(self,force=False,revert_tag=None,clean=False,callback=None): + """Runs an install, update, or reversion of an addon from online source + + Arguments: + force: Install assigned link, even if self.update_ready is False + revert_tag: Version to install, if none uses detected update link + clean: not used, but in future could use to totally refresh addon + callback: used to run function on update completion + """ + self._json["update_ready"] = False + self._json["ignore"] = False # clear ignore flag + self._json["version_text"] = {} + + if revert_tag != None: + self.set_tag(revert_tag) + self._update_ready = True + + # clear the errors if any + self._error = None + self._error_msg = None + + if self._verbose: print("Running update") + + if self._fake_install == True: + # change to True, to trigger the reload/"update installed" handler + if self._verbose: + print("fake_install=True") + print("Just reloading and running any handler triggers") + self._json["just_updated"] = True + self.save_updater_json() + if self._backup_current == True: + self.create_backup() + self.reload_addon() + self._update_ready = False + res = True # fake "success" zip download flag + + elif force==False: + if self._update_ready != True: + if self._verbose: + print("Update stopped, new version not ready") + if callback: + callback( + self._addon_package, + "Update stopped, new version not ready") + return "Update stopped, new version not ready" + elif self._update_link == None: + # this shouldn't happen if update is ready + if self._verbose: + print("Update stopped, update link unavailable") + if callback: + callback( + self._addon_package, + "Update stopped, update link unavailable") + return "Update stopped, update link unavailable" + + if self._verbose and revert_tag==None: + print("Staging update") + elif self._verbose: + print("Staging install") + + res = self.stage_repository(self._update_link) + if res !=True: + print("Error in staging repository: "+str(res)) + if callback != None: + callback(self._addon_package, self._error_msg) + return self._error_msg + res = self.unpack_staged_zip(clean) + if res<0: + if callback: + callback(self._addon_package, self._error_msg) + return res + + else: + if self._update_link == None: + if self._verbose: + print("Update stopped, could not get link") + return "Update stopped, could not get link" + if self._verbose: + print("Forcing update") + + res = self.stage_repository(self._update_link) + if res !=True: + print("Error in staging repository: "+str(res)) + if callback: + callback(self._addon_package, self._error_msg) + return self._error_msg + res = self.unpack_staged_zip(clean) + if res<0: + return res + # would need to compare against other versions held in tags + + # run the front-end's callback if provided + if callback: + callback(self._addon_package) + + # return something meaningful, 0 means it worked + return 0 + + + def past_interval_timestamp(self): + if self._check_interval_enable == False: + return True # ie this exact feature is disabled + + if "last_check" not in self._json or self._json["last_check"] == "": + return True + else: + now = datetime.now() + last_check = datetime.strptime(self._json["last_check"], + "%Y-%m-%d %H:%M:%S.%f") + next_check = last_check + offset = timedelta( + days=self._check_interval_days + 30*self._check_interval_months, + hours=self._check_interval_hours, + minutes=self._check_interval_minutes + ) + + delta = (now - offset) - last_check + if delta.total_seconds() > 0: + if self._verbose: + print("{} Updater: Time to check for updates!".format(self._addon)) + return True + else: + if self._verbose: + print("{} Updater: Determined it's not yet time to check for updates".format(self._addon)) + return False + + def get_json_path(self): + """Returns the full path to the JSON state file used by this updater. + + Will also rename old file paths to addon-specific path if found + """ + json_path = os.path.join(self._updater_path, + "{}_updater_status.json".format(self._addon_package)) + old_json_path = os.path.join(self._updater_path, "updater_status.json") + + # rename old file if it exists + try: + os.rename(old_json_path, json_path) + except FileNotFoundError: + pass + except Exception as err: + print("Other OS error occurred while trying to rename old JSON") + print(err) + return json_path + + def set_updater_json(self): + """Load or initialize JSON dictionary data for updater state""" + if self._updater_path == None: + raise ValueError("updater_path is not defined") + elif os.path.isdir(self._updater_path) == False: + os.makedirs(self._updater_path) + + jpath = self.get_json_path() + if os.path.isfile(jpath): + with open(jpath) as data_file: + self._json = json.load(data_file) + if self._verbose: + print("{} Updater: Read in JSON settings from file".format( + self._addon)) + else: + # set data structure + self._json = { + "last_check":"", + "backup_date":"", + "update_ready":False, + "ignore":False, + "just_restored":False, + "just_updated":False, + "version_text":{} + } + self.save_updater_json() + + + def save_updater_json(self): + # first save the state + if self._update_ready == True: + if type(self._update_version) == type((0,0,0)): + self._json["update_ready"] = True + self._json["version_text"]["link"]=self._update_link + self._json["version_text"]["version"]=self._update_version + else: + self._json["update_ready"] = False + self._json["version_text"] = {} + else: + self._json["update_ready"] = False + self._json["version_text"] = {} + + jpath = self.get_json_path() + outf = open(jpath,'w') + data_out = json.dumps(self._json, indent=4) + outf.write(data_out) + outf.close() + if self._verbose: + print(self._addon+": Wrote out updater JSON settings to file, with the contents:") + print(self._json) + + def json_reset_postupdate(self): + self._json["just_updated"] = False + self._json["update_ready"] = False + self._json["version_text"] = {} + self.save_updater_json() + + def json_reset_restore(self): + self._json["just_restored"] = False + self._json["update_ready"] = False + self._json["version_text"] = {} + self.save_updater_json() + self._update_ready = None # reset so you could check update again + + def ignore_update(self): + self._json["ignore"] = True + self.save_updater_json() + + + # ------------------------------------------------------------------------- + # ASYNC stuff + # ------------------------------------------------------------------------- + + def start_async_check_update(self, now=False, callback=None): + """Start a background thread which will check for updates""" + if self._async_checking is True: + return + if self._verbose: + print("{} updater: Starting background checking thread".format( + self._addon)) + check_thread = threading.Thread(target=self.async_check_update, + args=(now,callback,)) + check_thread.daemon = True + self._check_thread = check_thread + check_thread.start() + + def async_check_update(self, now, callback=None): + """Perform update check, run as target of background thread""" + self._async_checking = True + if self._verbose: + print("{} BG thread: Checking for update now in background".format( + self._addon)) + + try: + self.check_for_update(now=now) + except Exception as exception: + print("Checking for update error:") + print(exception) + if not self._error: + self._update_ready = False + self._update_version = None + self._update_link = None + self._error = "Error occurred" + self._error_msg = "Encountered an error while checking for updates" + + self._async_checking = False + self._check_thread = None + + if self._verbose: + print("{} BG thread: Finished checking for update, doing callback".format(self._addon)) + if callback: + callback(self._update_ready) + + def stop_async_check_update(self): + """Method to give impression of stopping check for update. + + Currently does nothing but allows user to retry/stop blocking UI from + hitting a refresh button. This does not actually stop the thread, as it + will complete after the connection timeout regardless. If the thread + does complete with a successful response, this will be still displayed + on next UI refresh (ie no update, or update available). + """ + if self._check_thread != None: + if self._verbose: print("Thread will end in normal course.") + # however, "There is no direct kill method on a thread object." + # better to let it run its course + #self._check_thread.stop() + self._async_checking = False + self._error = None + self._error_msg = None + + +# ----------------------------------------------------------------------------- +# Updater Engines +# ----------------------------------------------------------------------------- + + +class BitbucketEngine(object): + """Integration to Bitbucket API for git-formatted repositories""" + + def __init__(self): + self.api_url = 'https://api.bitbucket.org' + self.token = None + self.name = "bitbucket" + + def form_repo_url(self, updater): + return self.api_url+"/2.0/repositories/"+updater.user+"/"+updater.repo + + def form_tags_url(self, updater): + return self.form_repo_url(updater) + "/refs/tags?sort=-name" + + def form_branch_url(self, branch, updater): + return self.get_zip_url(branch, updater) + + def get_zip_url(self, name, updater): + return "https://bitbucket.org/{user}/{repo}/get/{name}.zip".format( + user=updater.user, + repo=updater.repo, + name=name) + + def parse_tags(self, response, updater): + if response == None: + return [] + return [{"name": tag["name"], "zipball_url": self.get_zip_url(tag["name"], updater)} for tag in response["values"]] + + +class GithubEngine(object): + """Integration to Github API""" + + def __init__(self): + self.api_url = 'https://api.github.com' + self.token = None + self.name = "github" + + def form_repo_url(self, updater): + return "{}{}{}{}{}".format(self.api_url,"/repos/",updater.user, + "/",updater.repo) + + def form_tags_url(self, updater): + if updater.use_releases: + return "{}{}".format(self.form_repo_url(updater),"/releases") + else: + return "{}{}".format(self.form_repo_url(updater),"/tags") + + def form_branch_list_url(self, updater): + return "{}{}".format(self.form_repo_url(updater),"/branches") + + def form_branch_url(self, branch, updater): + return "{}{}{}".format(self.form_repo_url(updater), + "/zipball/",branch) + + def parse_tags(self, response, updater): + if response == None: + return [] + return response + + +class GitlabEngine(object): + """Integration to GitLab API""" + + def __init__(self): + self.api_url = 'https://gitlab.com' + self.token = None + self.name = "gitlab" + + def form_repo_url(self, updater): + return "{}{}{}".format(self.api_url,"/api/v4/projects/",updater.repo) + + def form_tags_url(self, updater): + return "{}{}".format(self.form_repo_url(updater),"/repository/tags") + + def form_branch_list_url(self, updater): + # does not validate branch name. + return "{}{}".format( + self.form_repo_url(updater), + "/repository/branches") + + def form_branch_url(self, branch, updater): + # Could clash with tag names and if it does, it will + # download TAG zip instead of branch zip to get + # direct path, would need. + return "{}{}{}".format( + self.form_repo_url(updater), + "/repository/archive.zip?sha=", + branch) + + def get_zip_url(self, sha, updater): + return "{base}/repository/archive.zip?sha={sha}".format( + base=self.form_repo_url(updater), + sha=sha) + + # def get_commit_zip(self, id, updater): + # return self.form_repo_url(updater)+"/repository/archive.zip?sha:"+id + + def parse_tags(self, response, updater): + if response == None: + return [] + return [{"name": tag["name"], "zipball_url": self.get_zip_url(tag["commit"]["id"], updater)} for tag in response] + + +# ----------------------------------------------------------------------------- +# The module-shared class instance, +# should be what's imported to other files +# ----------------------------------------------------------------------------- + +Updater = Singleton_updater() diff --git a/multi_user/addon_updater_ops.py b/multi_user/addon_updater_ops.py new file mode 100644 index 0000000..cf79b3b --- /dev/null +++ b/multi_user/addon_updater_ops.py @@ -0,0 +1,1454 @@ +# ##### 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 2 +# 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, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +import os + +import bpy +from bpy.app.handlers import persistent + +# updater import, import safely +# Prevents popups for users with invalid python installs e.g. missing libraries +try: + from .addon_updater import Updater as updater +except Exception as e: + print("ERROR INITIALIZING UPDATER") + print(str(e)) + + class Singleton_updater_none(object): + def __init__(self): + self.addon = None + self.verbose = False + self.invalidupdater = True # used to distinguish bad install + self.error = None + self.error_msg = None + self.async_checking = None + + def clear_state(self): + self.addon = None + self.verbose = False + self.invalidupdater = True + self.error = None + self.error_msg = None + self.async_checking = None + + def run_update(self): pass + def check_for_update(self): pass + updater = Singleton_updater_none() + updater.error = "Error initializing updater module" + updater.error_msg = str(e) + +# Must declare this before classes are loaded +# otherwise the bl_idname's will not match and have errors. +# Must be all lowercase and no spaces +updater.addon = "multiuser" + + +# ----------------------------------------------------------------------------- +# Blender version utils +# ----------------------------------------------------------------------------- + + +def make_annotations(cls): + """Add annotation attribute to class fields to avoid Blender 2.8 warnings""" + if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80): + return cls + bl_props = {k: v for k, v in cls.__dict__.items() if isinstance(v, tuple)} + if bl_props: + if '__annotations__' not in cls.__dict__: + setattr(cls, '__annotations__', {}) + annotations = cls.__dict__['__annotations__'] + for k, v in bl_props.items(): + annotations[k] = v + delattr(cls, k) + return cls + + +def layout_split(layout, factor=0.0, align=False): + """Intermediate method for pre and post blender 2.8 split UI function""" + if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80): + return layout.split(percentage=factor, align=align) + return layout.split(factor=factor, align=align) + + +def get_user_preferences(context=None): + """Intermediate method for pre and post blender 2.8 grabbing preferences""" + if not context: + context = bpy.context + prefs = None + if hasattr(context, "user_preferences"): + prefs = context.user_preferences.addons.get(__package__, None) + elif hasattr(context, "preferences"): + prefs = context.preferences.addons.get(__package__, None) + if prefs: + return prefs.preferences + # To make the addon stable and non-exception prone, return None + # raise Exception("Could not fetch user preferences") + return None + + +# ----------------------------------------------------------------------------- +# Updater operators +# ----------------------------------------------------------------------------- + + +# simple popup for prompting checking for update & allow to install if available +class addon_updater_install_popup(bpy.types.Operator): + """Check and install update if available""" + bl_label = "Update {x} addon".format(x=updater.addon) + bl_idname = updater.addon+".updater_install_popup" + bl_description = "Popup menu to check and display current updates available" + bl_options = {'REGISTER', 'INTERNAL'} + + # if true, run clean install - ie remove all files before adding new + # equivalent to deleting the addon and reinstalling, except the + # updater folder/backup folder remains + clean_install = bpy.props.BoolProperty( + name="Clean install", + description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install", + default=False, + options={'HIDDEN'} + ) + ignore_enum = bpy.props.EnumProperty( + name="Process update", + description="Decide to install, ignore, or defer new addon update", + items=[ + ("install", "Update Now", "Install update now"), + ("ignore", "Ignore", "Ignore this update to prevent future popups"), + ("defer", "Defer", "Defer choice till next blender session") + ], + options={'HIDDEN'} + ) + + def check(self, context): + return True + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + if updater.invalidupdater == True: + layout.label(text="Updater module error") + return + elif updater.update_ready == True: + col = layout.column() + col.scale_y = 0.7 + col.label(text="Update {} ready!".format(str(updater.update_version)), + icon="LOOP_FORWARDS") + col.label( + text="Choose 'Update Now' & press OK to install, ", icon="BLANK1") + col.label(text="or click outside window to defer", icon="BLANK1") + row = col.row() + row.prop(self, "ignore_enum", expand=True) + col.split() + elif updater.update_ready == False: + col = layout.column() + col.scale_y = 0.7 + col.label(text="No updates available") + col.label(text="Press okay to dismiss dialog") + # add option to force install + else: + # case: updater.update_ready = None + # we have not yet checked for the update + layout.label(text="Check for update now?") + + # potentially in future, could have UI for 'check to select old version' + # to revert back to. + + def execute(self, context): + + # in case of error importing updater + if updater.invalidupdater == True: + return {'CANCELLED'} + + if updater.manual_only == True: + bpy.ops.wm.url_open(url=updater.website) + elif updater.update_ready == True: + + # action based on enum selection + if self.ignore_enum == 'defer': + return {'FINISHED'} + elif self.ignore_enum == 'ignore': + updater.ignore_update() + return {'FINISHED'} + # else: "install update now!" + + res = updater.run_update( + force=False, + callback=post_update_callback, + clean=self.clean_install) + # should return 0, if not something happened + if updater.verbose: + if res == 0: + print("Updater returned successful") + else: + print("Updater returned {}, error occurred".format(res)) + elif updater.update_ready == None: + _ = updater.check_for_update(now=True) + + # re-launch this dialog + atr = addon_updater_install_popup.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') + else: + if updater.verbose: + print("Doing nothing, not ready for update") + return {'FINISHED'} + + +# User preference check-now operator +class addon_updater_check_now(bpy.types.Operator): + bl_label = "Check now for "+updater.addon+" update" + bl_idname = updater.addon+".updater_check_now" + bl_description = "Check now for an update to the {x} addon".format( + x=updater.addon) + bl_options = {'REGISTER', 'INTERNAL'} + + def execute(self, context): + if updater.invalidupdater == True: + return {'CANCELLED'} + + if updater.async_checking == True and updater.error == None: + # Check already happened + # Used here to just avoid constant applying settings below + # Ignoring if error, to prevent being stuck on the error screen + return {'CANCELLED'} + + # apply the UI settings + settings = get_user_preferences(context) + if not settings: + if updater.verbose: + print("Could not get {} preferences, update check skipped".format( + __package__)) + return {'CANCELLED'} + updater.set_check_interval(enable=settings.auto_check_update, + months=settings.updater_intrval_months, + days=settings.updater_intrval_days, + hours=settings.updater_intrval_hours, + minutes=settings.updater_intrval_minutes + ) # optional, if auto_check_update + + # input is an optional callback function + # this function should take a bool input, if true: update ready + # if false, no update ready + updater.check_for_update_now(ui_refresh) + + return {'FINISHED'} + + +class addon_updater_update_now(bpy.types.Operator): + bl_label = "Update "+updater.addon+" addon now" + bl_idname = updater.addon+".updater_update_now" + bl_description = "Update to the latest version of the {x} addon".format( + x=updater.addon) + bl_options = {'REGISTER', 'INTERNAL'} + + # if true, run clean install - ie remove all files before adding new + # equivalent to deleting the addon and reinstalling, except the + # updater folder/backup folder remains + clean_install = bpy.props.BoolProperty( + name="Clean install", + description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install", + default=False, + options={'HIDDEN'} + ) + + def execute(self, context): + + # in case of error importing updater + if updater.invalidupdater == True: + return {'CANCELLED'} + + if updater.manual_only == True: + bpy.ops.wm.url_open(url=updater.website) + if updater.update_ready == True: + # if it fails, offer to open the website instead + try: + res = updater.run_update( + force=False, + callback=post_update_callback, + clean=self.clean_install) + + # should return 0, if not something happened + if updater.verbose: + if res == 0: + print("Updater returned successful") + else: + print("Updater returned "+str(res)+", error occurred") + except Exception as e: + updater._error = "Error trying to run update" + updater._error_msg = str(e) + atr = addon_updater_install_manually.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') + elif updater.update_ready == None: + (update_ready, version, link) = updater.check_for_update(now=True) + # re-launch this dialog + atr = addon_updater_install_popup.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') + + elif updater.update_ready == False: + self.report({'INFO'}, "Nothing to update") + else: + self.report( + {'ERROR'}, "Encountered problem while trying to update") + + return {'FINISHED'} + + +class addon_updater_update_target(bpy.types.Operator): + bl_label = updater.addon+" version target" + bl_idname = updater.addon+".updater_update_target" + bl_description = "Install a targeted version of the {x} addon".format( + x=updater.addon) + bl_options = {'REGISTER', 'INTERNAL'} + + def target_version(self, context): + # in case of error importing updater + if updater.invalidupdater == True: + ret = [] + + ret = [] + i = 0 + for tag in updater.tags: + ret.append((tag, tag, "Select to install "+tag)) + i += 1 + return ret + + target = bpy.props.EnumProperty( + name="Target version to install", + description="Select the version to install", + items=target_version + ) + + # if true, run clean install - ie remove all files before adding new + # equivalent to deleting the addon and reinstalling, except the + # updater folder/backup folder remains + clean_install = bpy.props.BoolProperty( + name="Clean install", + description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install", + default=False, + options={'HIDDEN'} + ) + + @classmethod + def poll(cls, context): + if updater.invalidupdater == True: + return False + return updater.update_ready != None and len(updater.tags) > 0 + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + if updater.invalidupdater == True: + layout.label(text="Updater error") + return + split = layout_split(layout, factor=0.66) + subcol = split.column() + subcol.label(text="Select install version") + subcol = split.column() + subcol.prop(self, "target", text="") + + def execute(self, context): + + # in case of error importing updater + if updater.invalidupdater == True: + return {'CANCELLED'} + + res = updater.run_update( + force=False, + revert_tag=self.target, + callback=post_update_callback, + clean=self.clean_install) + + # should return 0, if not something happened + if res == 0: + if updater.verbose: + print("Updater returned successful") + else: + if updater.verbose: + print("Updater returned "+str(res)+", error occurred") + return {'CANCELLED'} + + return {'FINISHED'} + + +class addon_updater_install_manually(bpy.types.Operator): + """As a fallback, direct the user to download the addon manually""" + bl_label = "Install update manually" + bl_idname = updater.addon+".updater_install_manually" + bl_description = "Proceed to manually install update" + bl_options = {'REGISTER', 'INTERNAL'} + + error = bpy.props.StringProperty( + name="Error Occurred", + default="", + options={'HIDDEN'} + ) + + def invoke(self, context, event): + return context.window_manager.invoke_popup(self) + + def draw(self, context): + layout = self.layout + + if updater.invalidupdater == True: + layout.label(text="Updater error") + return + + # use a "failed flag"? it shows this label if the case failed. + if self.error != "": + col = layout.column() + col.scale_y = 0.7 + col.label( + text="There was an issue trying to auto-install", icon="ERROR") + col.label( + text="Press the download button below and install", icon="BLANK1") + col.label(text="the zip file like a normal addon.", icon="BLANK1") + else: + col = layout.column() + col.scale_y = 0.7 + col.label(text="Install the addon manually") + col.label(text="Press the download button below and install") + col.label(text="the zip file like a normal addon.") + + # if check hasn't happened, i.e. accidentally called this menu + # allow to check here + + row = layout.row() + + if updater.update_link != None: + row.operator("wm.url_open", + text="Direct download").url = updater.update_link + else: + row.operator("wm.url_open", + text="(failed to retrieve direct download)") + row.enabled = False + + if updater.website != None: + row = layout.row() + row.operator("wm.url_open", text="Open website").url =\ + updater.website + else: + row = layout.row() + row.label(text="See source website to download the update") + + def execute(self, context): + + return {'FINISHED'} + + +class addon_updater_updated_successful(bpy.types.Operator): + """Addon in place, popup telling user it completed or what went wrong""" + bl_label = "Installation Report" + bl_idname = updater.addon+".updater_update_successful" + bl_description = "Update installation response" + bl_options = {'REGISTER', 'INTERNAL', 'UNDO'} + + error = bpy.props.StringProperty( + name="Error Occurred", + default="", + options={'HIDDEN'} + ) + + def invoke(self, context, event): + return context.window_manager.invoke_props_popup(self, event) + + def draw(self, context): + layout = self.layout + + if updater.invalidupdater == True: + layout.label(text="Updater error") + return + + saved = updater.json + if self.error != "": + col = layout.column() + col.scale_y = 0.7 + col.label(text="Error occurred, did not install", icon="ERROR") + if updater.error_msg: + msg = updater.error_msg + else: + msg = self.error + col.label(text=str(msg), icon="BLANK1") + rw = col.row() + rw.scale_y = 2 + rw.operator("wm.url_open", + text="Click for manual download.", + icon="BLANK1" + ).url = updater.website + # manual download button here + elif updater.auto_reload_post_update == False: + # tell user to restart blender + if "just_restored" in saved and saved["just_restored"] == True: + col = layout.column() + col.scale_y = 0.7 + col.label(text="Addon restored", icon="RECOVER_LAST") + col.label(text="Restart blender to reload.", icon="BLANK1") + updater.json_reset_restore() + else: + col = layout.column() + col.scale_y = 0.7 + col.label(text="Addon successfully installed", + icon="FILE_TICK") + col.label(text="Restart blender to reload.", icon="BLANK1") + + else: + # reload addon, but still recommend they restart blender + if "just_restored" in saved and saved["just_restored"] == True: + col = layout.column() + col.scale_y = 0.7 + col.label(text="Addon restored", icon="RECOVER_LAST") + col.label(text="Consider restarting blender to fully reload.", + icon="BLANK1") + updater.json_reset_restore() + else: + col = layout.column() + col.scale_y = 0.7 + col.label(text="Addon successfully installed", + icon="FILE_TICK") + col.label(text="Consider restarting blender to fully reload.", + icon="BLANK1") + + def execute(self, context): + return {'FINISHED'} + + +class addon_updater_restore_backup(bpy.types.Operator): + """Restore addon from backup""" + bl_label = "Restore backup" + bl_idname = updater.addon+".updater_restore_backup" + bl_description = "Restore addon from backup" + bl_options = {'REGISTER', 'INTERNAL'} + + @classmethod + def poll(cls, context): + try: + return os.path.isdir(os.path.join(updater.stage_path, "backup")) + except: + return False + + def execute(self, context): + # in case of error importing updater + if updater.invalidupdater == True: + return {'CANCELLED'} + updater.restore_backup() + return {'FINISHED'} + + +class addon_updater_ignore(bpy.types.Operator): + """Prevent future update notice popups""" + bl_label = "Ignore update" + bl_idname = updater.addon+".updater_ignore" + bl_description = "Ignore update to prevent future popups" + bl_options = {'REGISTER', 'INTERNAL'} + + @classmethod + def poll(cls, context): + if updater.invalidupdater == True: + return False + elif updater.update_ready == True: + return True + else: + return False + + def execute(self, context): + # in case of error importing updater + if updater.invalidupdater == True: + return {'CANCELLED'} + updater.ignore_update() + self.report({"INFO"}, "Open addon preferences for updater options") + return {'FINISHED'} + + +class addon_updater_end_background(bpy.types.Operator): + """Stop checking for update in the background""" + bl_label = "End background check" + bl_idname = updater.addon+".end_background_check" + bl_description = "Stop checking for update in the background" + bl_options = {'REGISTER', 'INTERNAL'} + + # @classmethod + # def poll(cls, context): + # if updater.async_checking == True: + # return True + # else: + # return False + + def execute(self, context): + # in case of error importing updater + if updater.invalidupdater == True: + return {'CANCELLED'} + updater.stop_async_check_update() + return {'FINISHED'} + + +# ----------------------------------------------------------------------------- +# Handler related, to create popups +# ----------------------------------------------------------------------------- + + +# global vars used to prevent duplicate popup handlers +ran_autocheck_install_popup = False +ran_update_sucess_popup = False + +# global var for preventing successive calls +ran_background_check = False + + +@persistent +def updater_run_success_popup_handler(scene): + global ran_update_sucess_popup + ran_update_sucess_popup = True + + # in case of error importing updater + if updater.invalidupdater == True: + return + + try: + bpy.app.handlers.scene_update_post.remove( + updater_run_success_popup_handler) + except: + pass + + atr = addon_updater_updated_successful.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') + + +@persistent +def updater_run_install_popup_handler(scene): + global ran_autocheck_install_popup + ran_autocheck_install_popup = True + + # in case of error importing updater + if updater.invalidupdater == True: + return + + try: + bpy.app.handlers.scene_update_post.remove( + updater_run_install_popup_handler) + except: + pass + + if "ignore" in updater.json and updater.json["ignore"] == True: + return # don't do popup if ignore pressed + # elif type(updater.update_version) != type((0,0,0)): + # # likely was from master or another branch, shouldn't trigger popup + # updater.json_reset_restore() + # return + elif "version_text" in updater.json and "version" in updater.json["version_text"]: + version = updater.json["version_text"]["version"] + ver_tuple = updater.version_tuple_from_text(version) + + if ver_tuple < updater.current_version: + # user probably manually installed to get the up to date addon + # in here. Clear out the update flag using this function + if updater.verbose: + print("{} updater: appears user updated, clearing flag".format( + updater.addon)) + updater.json_reset_restore() + return + atr = addon_updater_install_popup.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') + + +def background_update_callback(update_ready): + """Passed into the updater, background thread updater""" + global ran_autocheck_install_popup + + # in case of error importing updater + if updater.invalidupdater == True: + return + if updater.showpopups == False: + return + if update_ready != True: + return + if updater_run_install_popup_handler not in \ + bpy.app.handlers.scene_update_post and \ + ran_autocheck_install_popup == False: + bpy.app.handlers.scene_update_post.append( + updater_run_install_popup_handler) + ran_autocheck_install_popup = True + + +def post_update_callback(module_name, res=None): + """Callback for once the run_update function has completed + + Only makes sense to use this if "auto_reload_post_update" == False, + i.e. don't auto-restart the addon + + Arguments: + module_name: returns the module name from updater, but unused here + res: If an error occurred, this is the detail string + """ + + # in case of error importing updater + if updater.invalidupdater == True: + return + + if res == None: + # this is the same code as in conditional at the end of the register function + # ie if "auto_reload_post_update" == True, comment out this code + if updater.verbose: + print("{} updater: Running post update callback".format(updater.addon)) + # bpy.app.handlers.scene_update_post.append(updater_run_success_popup_handler) + + atr = addon_updater_updated_successful.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') + global ran_update_sucess_popup + ran_update_sucess_popup = True + else: + # some kind of error occurred and it was unable to install, + # offer manual download instead + atr = addon_updater_updated_successful.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT', error=res) + return + + +def ui_refresh(update_status): + # find a way to just re-draw self? + # callback intended for trigger by async thread + for windowManager in bpy.data.window_managers: + for window in windowManager.windows: + for area in window.screen.areas: + area.tag_redraw() + + +def check_for_update_background(): + """Function for asynchronous background check. + + *Could* be called on register, but would be bad practice. + """ + if updater.invalidupdater == True: + return + global ran_background_check + if ran_background_check == True: + # Global var ensures check only happens once + return + elif updater.update_ready != None or updater.async_checking == True: + # Check already happened + # Used here to just avoid constant applying settings below + return + + # apply the UI settings + settings = get_user_preferences(bpy.context) + if not settings: + return + updater.set_check_interval(enable=settings.auto_check_update, + months=settings.updater_intrval_months, + days=settings.updater_intrval_days, + hours=settings.updater_intrval_hours, + minutes=settings.updater_intrval_minutes + ) # optional, if auto_check_update + + # input is an optional callback function + # this function should take a bool input, if true: update ready + # if false, no update ready + if updater.verbose: + print("{} updater: Running background check for update".format( + updater.addon)) + updater.check_for_update_async(background_update_callback) + ran_background_check = True + + +def check_for_update_nonthreaded(self, context): + """Can be placed in front of other operators to launch when pressed""" + if updater.invalidupdater == True: + return + + # only check if it's ready, ie after the time interval specified + # should be the async wrapper call here + settings = get_user_preferences(bpy.context) + if not settings: + if updater.verbose: + print("Could not get {} preferences, update check skipped".format( + __package__)) + return + updater.set_check_interval(enable=settings.auto_check_update, + months=settings.updater_intrval_months, + days=settings.updater_intrval_days, + hours=settings.updater_intrval_hours, + minutes=settings.updater_intrval_minutes + ) # optional, if auto_check_update + + (update_ready, version, link) = updater.check_for_update(now=False) + if update_ready == True: + atr = addon_updater_install_popup.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') + else: + if updater.verbose: + print("No update ready") + self.report({'INFO'}, "No update ready") + + +def showReloadPopup(): + """For use in register only, to show popup after re-enabling the addon + + Must be enabled by developer + """ + if updater.invalidupdater == True: + return + saved_state = updater.json + global ran_update_sucess_popup + + a = saved_state != None + b = "just_updated" in saved_state + c = saved_state["just_updated"] + + if a and b and c: + updater.json_reset_postupdate() # so this only runs once + + # no handlers in this case + if updater.auto_reload_post_update == False: + return + + if updater_run_success_popup_handler not in \ + bpy.app.handlers.scene_update_post \ + and ran_update_sucess_popup == False: + bpy.app.handlers.scene_update_post.append( + updater_run_success_popup_handler) + ran_update_sucess_popup = True + + +# ----------------------------------------------------------------------------- +# Example UI integrations +# ----------------------------------------------------------------------------- + + +def update_notice_box_ui(self, context): + """ Panel - Update Available for placement at end/beginning of panel + + After a check for update has occurred, this function will draw a box + saying an update is ready, and give a button for: update now, open website, + or ignore popup. Ideal to be placed at the end / beginning of a panel + """ + + if updater.invalidupdater == True: + return + + saved_state = updater.json + if updater.auto_reload_post_update == False: + if "just_updated" in saved_state and saved_state["just_updated"] == True: + layout = self.layout + box = layout.box() + col = box.column() + col.scale_y = 0.7 + col.label(text="Restart blender", icon="ERROR") + col.label(text="to complete update") + return + + # if user pressed ignore, don't draw the box + if "ignore" in updater.json and updater.json["ignore"] == True: + return + if updater.update_ready != True: + return + + layout = self.layout + box = layout.box() + col = box.column(align=True) + col.label(text="Update ready!", icon="ERROR") + col.separator() + row = col.row(align=True) + split = row.split(align=True) + colL = split.column(align=True) + colL.scale_y = 1.5 + colL.operator(addon_updater_ignore.bl_idname, icon="X", text="Ignore") + colR = split.column(align=True) + colR.scale_y = 1.5 + if updater.manual_only == False: + colR.operator(addon_updater_update_now.bl_idname, + text="Update", icon="LOOP_FORWARDS") + col.operator("wm.url_open", text="Open website").url = updater.website + #col.operator("wm.url_open",text="Direct download").url=updater.update_link + col.operator(addon_updater_install_manually.bl_idname, + text="Install manually") + else: + #col.operator("wm.url_open",text="Direct download").url=updater.update_link + col.operator("wm.url_open", text="Get it now").url = updater.website + + +def update_settings_ui(self, context, element=None): + """Preferences - for drawing with full width inside user preferences + + Create a function that can be run inside user preferences panel for prefs UI + Place inside UI draw using: addon_updater_ops.updaterSettingsUI(self, context) + or by: addon_updater_ops.updaterSettingsUI(context) + """ + + # element is a UI element, such as layout, a row, column, or box + if element == None: + element = self.layout + box = element.box() + + # in case of error importing updater + if updater.invalidupdater == True: + box.label(text="Error initializing updater code:") + box.label(text=updater.error_msg) + return + settings = get_user_preferences(context) + if not settings: + box.label(text="Error getting updater preferences", icon='ERROR') + return + + # auto-update settings + box.label(text="Updater Settings") + row = box.row() + + # special case to tell user to restart blender, if set that way + if updater.auto_reload_post_update == False: + saved_state = updater.json + if "just_updated" in saved_state and saved_state["just_updated"] == True: + row.label(text="Restart blender to complete update", icon="ERROR") + return + + split = layout_split(row, factor=0.3) + subcol = split.column() + subcol.prop(settings, "auto_check_update") + subcol = split.column() + + if settings.auto_check_update == False: + subcol.enabled = False + subrow = subcol.row() + subrow.label(text="Interval between checks") + subrow = subcol.row(align=True) + checkcol = subrow.column(align=True) + checkcol.prop(settings, "updater_intrval_months") + checkcol = subrow.column(align=True) + checkcol.prop(settings, "updater_intrval_days") + checkcol = subrow.column(align=True) + checkcol.prop(settings, "updater_intrval_hours") + checkcol = subrow.column(align=True) + checkcol.prop(settings, "updater_intrval_minutes") + + # checking / managing updates + row = box.row() + col = row.column() + if updater.error != None: + subcol = col.row(align=True) + subcol.scale_y = 1 + split = subcol.split(align=True) + split.scale_y = 2 + if "ssl" in updater.error_msg.lower(): + split.enabled = True + split.operator(addon_updater_install_manually.bl_idname, + text=updater.error) + else: + split.enabled = False + split.operator(addon_updater_check_now.bl_idname, + text=updater.error) + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text="", icon="FILE_REFRESH") + + elif updater.update_ready == None and updater.async_checking == False: + col.scale_y = 2 + col.operator(addon_updater_check_now.bl_idname) + elif updater.update_ready == None: # async is running + subcol = col.row(align=True) + subcol.scale_y = 1 + split = subcol.split(align=True) + split.enabled = False + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text="Checking...") + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_end_background.bl_idname, + text="", icon="X") + + elif updater.include_branches == True and \ + len(updater.tags) == len(updater.include_branch_list) and \ + updater.manual_only == False: + # no releases found, but still show the appropriate branch + subcol = col.row(align=True) + subcol.scale_y = 1 + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_update_now.bl_idname, + text="Update directly to "+str(updater.include_branch_list[0])) + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text="", icon="FILE_REFRESH") + + elif updater.update_ready == True and updater.manual_only == False: + subcol = col.row(align=True) + subcol.scale_y = 1 + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_update_now.bl_idname, + text="Update now to "+str(updater.update_version)) + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text="", icon="FILE_REFRESH") + + elif updater.update_ready == True and updater.manual_only == True: + col.scale_y = 2 + col.operator("wm.url_open", + text="Download "+str(updater.update_version)).url = updater.website + else: # i.e. that updater.update_ready == False + subcol = col.row(align=True) + subcol.scale_y = 1 + split = subcol.split(align=True) + split.enabled = False + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text="Addon is up to date") + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text="", icon="FILE_REFRESH") + + if updater.manual_only == False: + col = row.column(align=True) + # col.operator(addon_updater_update_target.bl_idname, + if updater.include_branches == True and len(updater.include_branch_list) > 0: + branch = updater.include_branch_list[0] + col.operator(addon_updater_update_target.bl_idname, + text="Install latest {} / old version".format(branch)) + else: + col.operator(addon_updater_update_target.bl_idname, + text="Reinstall / install old version") + lastdate = "none found" + backuppath = os.path.join(updater.stage_path, "backup") + if "backup_date" in updater.json and os.path.isdir(backuppath): + if updater.json["backup_date"] == "": + lastdate = "Date not found" + else: + lastdate = updater.json["backup_date"] + backuptext = "Restore addon backup ({})".format(lastdate) + col.operator(addon_updater_restore_backup.bl_idname, text=backuptext) + + row = box.row() + row.scale_y = 0.7 + lastcheck = updater.json["last_check"] + if updater.error != None and updater.error_msg != None: + row.label(text=updater.error_msg) + elif lastcheck != "" and lastcheck != None: + lastcheck = lastcheck[0: lastcheck.index(".")] + row.label(text="Last update check: " + lastcheck) + else: + row.label(text="Last update check: Never") + + +def update_settings_ui_condensed(self, context, element=None): + """Preferences - Condensed drawing within preferences + + Alternate draw for user preferences or other places, does not draw a box + """ + + # element is a UI element, such as layout, a row, column, or box + if element == None: + element = self.layout + row = element.row() + + # in case of error importing updater + if updater.invalidupdater == True: + row.label(text="Error initializing updater code:") + row.label(text=updater.error_msg) + return + settings = get_user_preferences(context) + if not settings: + row.label(text="Error getting updater preferences", icon='ERROR') + return + + # special case to tell user to restart blender, if set that way + if updater.auto_reload_post_update == False: + saved_state = updater.json + if "just_updated" in saved_state and saved_state["just_updated"] == True: + row.label(text="Restart blender to complete update", icon="ERROR") + return + + col = row.column() + if updater.error != None: + subcol = col.row(align=True) + subcol.scale_y = 1 + split = subcol.split(align=True) + split.scale_y = 2 + if "ssl" in updater.error_msg.lower(): + split.enabled = True + split.operator(addon_updater_install_manually.bl_idname, + text=updater.error) + else: + split.enabled = False + split.operator(addon_updater_check_now.bl_idname, + text=updater.error) + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text="", icon="FILE_REFRESH") + + elif updater.update_ready == None and updater.async_checking == False: + col.scale_y = 2 + col.operator(addon_updater_check_now.bl_idname) + elif updater.update_ready == None: # async is running + subcol = col.row(align=True) + subcol.scale_y = 1 + split = subcol.split(align=True) + split.enabled = False + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text="Checking...") + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_end_background.bl_idname, + text="", icon="X") + + elif updater.include_branches == True and \ + len(updater.tags) == len(updater.include_branch_list) and \ + updater.manual_only == False: + # no releases found, but still show the appropriate branch + subcol = col.row(align=True) + subcol.scale_y = 1 + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_update_now.bl_idname, + text="Update directly to "+str(updater.include_branch_list[0])) + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text="", icon="FILE_REFRESH") + + elif updater.update_ready == True and updater.manual_only == False: + subcol = col.row(align=True) + subcol.scale_y = 1 + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_update_now.bl_idname, + text="Update now to "+str(updater.update_version)) + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text="", icon="FILE_REFRESH") + + elif updater.update_ready == True and updater.manual_only == True: + col.scale_y = 2 + col.operator("wm.url_open", + text="Download "+str(updater.update_version)).url = updater.website + else: # i.e. that updater.update_ready == False + subcol = col.row(align=True) + subcol.scale_y = 1 + split = subcol.split(align=True) + split.enabled = False + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text="Addon is up to date") + split = subcol.split(align=True) + split.scale_y = 2 + split.operator(addon_updater_check_now.bl_idname, + text="", icon="FILE_REFRESH") + + row = element.row() + row.prop(settings, "auto_check_update") + + row = element.row() + row.scale_y = 0.7 + lastcheck = updater.json["last_check"] + if updater.error != None and updater.error_msg != None: + row.label(text=updater.error_msg) + elif lastcheck != "" and lastcheck != None: + lastcheck = lastcheck[0: lastcheck.index(".")] + row.label(text="Last check: " + lastcheck) + else: + row.label(text="Last check: Never") + + +def skip_tag_function(self, tag): + """A global function for tag skipping + + A way to filter which tags are displayed, + e.g. to limit downgrading too far + input is a tag text, e.g. "v1.2.3" + output is True for skipping this tag number, + False if the tag is allowed (default for all) + Note: here, "self" is the acting updater shared class instance + """ + + # in case of error importing updater + if self.invalidupdater == True: + return False + + # ---- write any custom code here, return true to disallow version ---- # + # + # # Filter out e.g. if 'beta' is in name of release + # if 'beta' in tag.lower(): + # return True + # ---- write any custom code above, return true to disallow version --- # + + if self.include_branches == True: + for branch in self.include_branch_list: + if tag["name"].lower() == branch: + return False + + # function converting string to tuple, ignoring e.g. leading 'v' + tupled = self.version_tuple_from_text(tag["name"]) + if type(tupled) != type((1, 2, 3)): + return True + + # select the min tag version - change tuple accordingly + if self.version_min_update != None: + if tupled < self.version_min_update: + return True # skip if current version below this + + # select the max tag version + if self.version_max_update != None: + if tupled >= self.version_max_update: + return True # skip if current version at or above this + + # in all other cases, allow showing the tag for updating/reverting + return False + + +def select_link_function(self, tag): + """Only customize if trying to leverage "attachments" in *GitHub* releases + + A way to select from one or multiple attached donwloadable files from the + server, instead of downloading the default release/tag source code + """ + + # -- Default, universal case (and is the only option for GitLab/Bitbucket) + link = tag["zipball_url"] + + # -- Example: select the first (or only) asset instead source code -- + # if "assets" in tag and "browser_download_url" in tag["assets"][0]: + # link = tag["assets"][0]["browser_download_url"] + + # -- Example: select asset based on OS, where multiple builds exist -- + # # not tested/no error checking, modify to fit your own needs! + # # assume each release has three attached builds: + # # release_windows.zip, release_OSX.zip, release_linux.zip + # # This also would logically not be used with "branches" enabled + # if platform.system() == "Darwin": # ie OSX + # link = [asset for asset in tag["assets"] if 'OSX' in asset][0] + # elif platform.system() == "Windows": + # link = [asset for asset in tag["assets"] if 'windows' in asset][0] + # elif platform.system() == "Linux": + # link = [asset for asset in tag["assets"] if 'linux' in asset][0] + + return link + + +# ----------------------------------------------------------------------------- +# Register, should be run in the register module itself +# ----------------------------------------------------------------------------- + + +classes = ( + addon_updater_install_popup, + addon_updater_check_now, + addon_updater_update_now, + addon_updater_update_target, + addon_updater_install_manually, + addon_updater_updated_successful, + addon_updater_restore_backup, + addon_updater_ignore, + addon_updater_end_background +) + + +def register(bl_info): + """Registering the operators in this module""" + # safer failure in case of issue loading module + if updater.error: + print("Exiting updater registration, " + updater.error) + return + updater.clear_state() # clear internal vars, avoids reloading oddities + + # confirm your updater "engine" (Github is default if not specified) + updater.engine = "GitLab" + + # If using private repository, indicate the token here + # Must be set after assigning the engine. + # **WARNING** Depending on the engine, this token can act like a password!! + # Only provide a token if the project is *non-public*, see readme for + # other considerations and suggestions from a security standpoint + updater.private_token = None # "tokenstring" + + # choose your own username, must match website (not needed for GitLab) + updater.user = "slumber" + + # choose your own repository, must match git name + updater.repo = "10515801" + + # updater.addon = # define at top of module, MUST be done first + + # Website for manual addon download, optional but recommended to set + updater.website = "https://gitlab.com/slumber/multi-user/" + + # Addon subfolder path + # "sample/path/to/addon" + # default is "" or None, meaning root + updater.subfolder_path = "multi-user" + + # used to check/compare versions + updater.current_version = bl_info["version"] + + # Optional, to hard-set update frequency, use this here - however, + # this demo has this set via UI properties. + # updater.set_check_interval( + # enable=False,months=0,days=0,hours=0,minutes=2) + + # Optional, consider turning off for production or allow as an option + # This will print out additional debugging info to the console + updater.verbose = True # make False for production default + + # Optional, customize where the addon updater processing subfolder is, + # essentially a staging folder used by the updater on its own + # Needs to be within the same folder as the addon itself + # Need to supply a full, absolute path to folder + # updater.updater_path = # set path of updater folder, by default: + # /addons/{__package__}/{__package__}_updater + + # auto create a backup of the addon when installing other versions + updater.backup_current = True # True by default + + # Sample ignore patterns for when creating backup of current during update + updater.backup_ignore_patterns = ["__pycache__"] + # Alternate example patterns + # updater.backup_ignore_patterns = [".git", "__pycache__", "*.bat", ".gitignore", "*.exe"] + + # Patterns for files to actively overwrite if found in new update + # file and are also found in the currently installed addon. Note that + + # by default (ie if set to []), updates are installed in the same way as blender: + # .py files are replaced, but other file types (e.g. json, txt, blend) + # will NOT be overwritten if already present in current install. Thus + # if you want to automatically update resources/non py files, add them + # as a part of the pattern list below so they will always be overwritten by an + # update. If a pattern file is not found in new update, no action is taken + # This does NOT detele anything, only defines what is allowed to be overwritten + updater.overwrite_patterns = ["*.png", "*.jpg", "README.md", "LICENSE.txt"] + # updater.overwrite_patterns = [] + # other examples: + # ["*"] means ALL files/folders will be overwritten by update, was the behavior pre updater v1.0.4 + # [] or ["*.py","*.pyc"] matches default blender behavior, ie same effect if user installs update manually without deleting the existing addon first + # e.g. if existing install and update both have a resource.blend file, the existing installed one will remain + # ["some.py"] means if some.py is found in addon update, it will overwrite any existing some.py in current addon install, if any + # ["*.json"] means all json files found in addon update will overwrite those of same name in current install + # ["*.png","README.md","LICENSE.txt"] means the readme, license, and all pngs will be overwritten by update + + # Patterns for files to actively remove prior to running update + # Useful if wanting to remove old code due to changes in filenames + # that otherwise would accumulate. Note: this runs after taking + # a backup (if enabled) but before placing in new update. If the same + # file name removed exists in the update, then it acts as if pattern + # is placed in the overwrite_patterns property. Note this is effectively + # ignored if clean=True in the run_update method + updater.remove_pre_update_patterns = ["*.py", "*.pyc"] + # Note setting ["*"] here is equivalent to always running updates with + # clean = True in the run_update method, ie the equivalent of a fresh, + # new install. This would also delete any resources or user-made/modified + # files setting ["__pycache__"] ensures the pycache folder is always removed + # The configuration of ["*.py","*.pyc"] is a safe option as this + # will ensure no old python files/caches remain in event different addon + # versions have different filenames or structures + + # Allow branches like 'master' as an option to update to, regardless + # of release or version. + # Default behavior: releases will still be used for auto check (popup), + # but the user has the option from user preferences to directly + # update to the master branch or any other branches specified using + # the "install {branch}/older version" operator. + updater.include_branches = True + + # (GitHub only) This options allows the user to use releases over tags for data, + # which enables pulling down release logs/notes, as well as specify installs from + # release-attached zips (instead of just the auto-packaged code generated with + # a release/tag). Setting has no impact on BitBucket or GitLab repos + updater.use_releases = False + # note: Releases always have a tag, but a tag may not always be a release + # Therefore, setting True above will filter out any non-annoted tags + # note 2: Using this option will also display the release name instead of + # just the tag name, bear this in mind given the skip_tag_function filtering above + + # if using "include_branches", + # updater.include_branch_list defaults to ['master'] branch if set to none + # example targeting another multiple branches allowed to pull from + # updater.include_branch_list = ['master', 'dev'] # example with two branches + # None is the equivalent to setting ['master'] + updater.include_branch_list = None + + # Only allow manual install, thus prompting the user to open + # the addon's web page to download, specifically: updater.website + # Useful if only wanting to get notification of updates but not + # directly install. + updater.manual_only = False + + # Used for development only, "pretend" to install an update to test + # reloading conditions + updater.fake_install = False # Set to true to test callback/reloading + + # Show popups, ie if auto-check for update is enabled or a previous + # check for update in user preferences found a new version, show a popup + # (at most once per blender session, and it provides an option to ignore + # for future sessions); default behavior is set to True + updater.showpopups = True + # note: if set to false, there will still be an "update ready" box drawn + # using the `update_notice_box_ui` panel function. + + # Override with a custom function on what tags + # to skip showing for updater; see code for function above. + # Set the min and max versions allowed to install. + # Optional, default None + # min install (>=) will install this and higher + updater.version_min_update = (0, 0, 1) + # updater.version_min_update = None # if not wanting to define a min + + # max install (<) will install strictly anything lower + # updater.version_max_update = (9,9,9) + updater.version_max_update = None # set to None if not wanting to set max + + # Function defined above, customize as appropriate per repository + updater.skip_tag = skip_tag_function # min and max used in this function + + # Function defined above, customize as appropriate per repository; not required + updater.select_link = select_link_function + + # The register line items for all operators/panels + # If using bpy.utils.register_module(__name__) to register elsewhere + # in the addon, delete these lines (also from unregister) + for cls in classes: + # apply annotations to remove Blender 2.8 warnings, no effect on 2.7 + make_annotations(cls) + # comment out this line if using bpy.utils.register_module(__name__) + bpy.utils.register_class(cls) + + # special situation: we just updated the addon, show a popup + # to tell the user it worked + # should be enclosed in try/catch in case other issues arise + showReloadPopup() + + +def unregister(): + for cls in reversed(classes): + # comment out this line if using bpy.utils.unregister_module(__name__) + bpy.utils.unregister_class(cls) + + # clear global vars since they may persist if not restarting blender + updater.clear_state() # clear internal vars, avoids reloading oddities + + global ran_autocheck_install_popup + ran_autocheck_install_popup = False + + global ran_update_sucess_popup + ran_update_sucess_popup = False + + global ran_background_check + ran_background_check = False diff --git a/multi_user/preferences.py b/multi_user/preferences.py index ddf0edb..9a20c7a 100644 --- a/multi_user/preferences.py +++ b/multi_user/preferences.py @@ -1,10 +1,11 @@ import logging import bpy -from . import utils, bl_types, environment +from . import utils, bl_types, environment, addon_updater_ops logger = logging.getLogger(__name__) + class ReplicatedDatablock(bpy.types.PropertyGroup): type_name: bpy.props.StringProperty() bl_name: bpy.props.StringProperty() @@ -14,8 +15,9 @@ class ReplicatedDatablock(bpy.types.PropertyGroup): auto_push: bpy.props.BoolProperty(default=True) icon: bpy.props.StringProperty() + class SessionPrefs(bpy.types.AddonPreferences): - bl_idname = __package__ + bl_idname = __package__ ip: bpy.props.StringProperty( name="ip", @@ -33,19 +35,19 @@ class SessionPrefs(bpy.types.AddonPreferences): name="port", description='Distant host port', default=5555 - ) + ) supported_datablocks: bpy.props.CollectionProperty( type=ReplicatedDatablock, - ) + ) ipc_port: bpy.props.IntProperty( name="ipc_port", description='internal ttl port(only usefull for multiple local instances)', default=5561 - ) + ) start_empty: bpy.props.BoolProperty( name="start_empty", default=False - ) + ) right_strategy: bpy.props.EnumProperty( name='right_strategy', description='right strategy', @@ -58,16 +60,16 @@ class SessionPrefs(bpy.types.AddonPreferences): subtype="DIR_PATH", default=environment.DEFAULT_CACHE_DIR) # for UI - # category: bpy.props.EnumProperty( - # name="Category", - # description="Preferences Category", - # items=[ - # ('INFO', "Information", "Information about this add-on"), - # ('CONFIG', "Configuration", "Configuration about this add-on"), - # ('UPDATE', "Update", "Update this add-on"), - # ], - # default='INFO' - # ) + category: bpy.props.EnumProperty( + name="Category", + description="Preferences Category", + items=[ + ('INFO', "Information", "Information about this add-on"), + ('CONFIG', "Configuration", "Configuration about this add-on"), + ('UPDATE', "Update", "Update this add-on"), + ], + default='INFO' + ) conf_session_identity_expanded: bpy.props.BoolProperty( name="Identity", description="Identity", @@ -94,82 +96,119 @@ class SessionPrefs(bpy.types.AddonPreferences): default=False ) + auto_check_update: bpy.props.BoolProperty( + name="Auto-check for Update", + description="If enabled, auto-check for updates using an interval", + default=False, + ) + updater_intrval_months: bpy.props.IntProperty( + name='Months', + description="Number of months between checking for updates", + default=0, + min=0 + ) + updater_intrval_days: bpy.props.IntProperty( + name='Days', + description="Number of days between checking for updates", + default=7, + min=0, + max=31 + ) + updater_intrval_hours: bpy.props.IntProperty( + name='Hours', + description="Number of hours between checking for updates", + default=0, + min=0, + max=23 + ) + updater_intrval_minutes: bpy.props.IntProperty( + name='Minutes', + description="Number of minutes between checking for updates", + default=0, + min=0, + max=59 + ) def draw(self, context): layout = self.layout - # layout.row().prop(self, "category", expand=True) + layout.row().prop(self, "category", expand=True) - # if self.category == 'INFO': - # layout.separator() - # layout.label(text="Enable real-time collaborative workflow inside blender") - # if self.category == 'CONFIG': - grid = layout.column() + if self.category == 'INFO': + layout.separator() + layout.label(text="Enable real-time collaborative workflow inside blender") - # USER INFORMATIONS - box = grid.box() - box.prop( - self, "conf_session_identity_expanded", text="User informations", - icon='DISCLOSURE_TRI_DOWN' if self.conf_session_identity_expanded - else 'DISCLOSURE_TRI_RIGHT', emboss=False) - if self.conf_session_identity_expanded: - box.row().prop(self, "username", text="name") - box.row().prop(self, "client_color", text="color") - - # NETWORK SETTINGS - box = grid.box() - box.prop( - self, "conf_session_net_expanded", text="Netorking", - icon='DISCLOSURE_TRI_DOWN' if self.conf_session_net_expanded - else 'DISCLOSURE_TRI_RIGHT', emboss=False) - - if self.conf_session_net_expanded: - box.row().prop(self, "ip", text="Address") - row = box.row() - row.label(text="Port:") - row.prop(self, "port", text="Address") - row = box.row() - row.label(text="Start with an empty scene:") - row.prop(self, "start_empty", text="") - - table = box.box() - table.row().prop( - self, "conf_session_timing_expanded", text="Refresh rates", - icon='DISCLOSURE_TRI_DOWN' if self.conf_session_timing_expanded - else 'DISCLOSURE_TRI_RIGHT', emboss=False) + if self.category == 'CONFIG': + grid = layout.column() - if self.conf_session_timing_expanded: - line = table.row() - line.label(text=" ") - line.separator() - line.label(text="refresh (sec)") - line.label(text="apply (sec)") + # USER INFORMATIONS + box = grid.box() + box.prop( + self, "conf_session_identity_expanded", text="User informations", + icon='DISCLOSURE_TRI_DOWN' if self.conf_session_identity_expanded + else 'DISCLOSURE_TRI_RIGHT', emboss=False) + if self.conf_session_identity_expanded: + box.row().prop(self, "username", text="name") + box.row().prop(self, "client_color", text="color") - for item in self.supported_datablocks: - line = table.row(align=True) - line.label(text="", icon=item.icon) - line.prop(item, "bl_delay_refresh", text="") - line.prop(item, "bl_delay_apply", text="") - # HOST SETTINGS - box = grid.box() - box.prop( - self, "conf_session_hosting_expanded", text="Hosting", - icon='DISCLOSURE_TRI_DOWN' if self.conf_session_hosting_expanded - else 'DISCLOSURE_TRI_RIGHT', emboss=False) - if self.conf_session_hosting_expanded: - box.row().prop(self, "right_strategy", text="Right model") - row = box.row() - row.label(text="Start with an empty scene:") - row.prop(self, "start_empty", text="") - - # CACHE SETTINGS - box = grid.box() - box.prop( - self, "conf_session_cache_expanded", text="Cache", - icon='DISCLOSURE_TRI_DOWN' if self.conf_session_cache_expanded - else 'DISCLOSURE_TRI_RIGHT', emboss=False) - if self.conf_session_cache_expanded: - box.row().prop(self, "cache_directory", text="Cache directory") + # NETWORK SETTINGS + box = grid.box() + box.prop( + self, "conf_session_net_expanded", text="Netorking", + icon='DISCLOSURE_TRI_DOWN' if self.conf_session_net_expanded + else 'DISCLOSURE_TRI_RIGHT', emboss=False) + + if self.conf_session_net_expanded: + box.row().prop(self, "ip", text="Address") + row = box.row() + row.label(text="Port:") + row.prop(self, "port", text="Address") + row = box.row() + row.label(text="Start with an empty scene:") + row.prop(self, "start_empty", text="") + + table = box.box() + table.row().prop( + self, "conf_session_timing_expanded", text="Refresh rates", + icon='DISCLOSURE_TRI_DOWN' if self.conf_session_timing_expanded + else 'DISCLOSURE_TRI_RIGHT', emboss=False) + + if self.conf_session_timing_expanded: + line = table.row() + line.label(text=" ") + line.separator() + line.label(text="refresh (sec)") + line.label(text="apply (sec)") + + for item in self.supported_datablocks: + line = table.row(align=True) + line.label(text="", icon=item.icon) + line.prop(item, "bl_delay_refresh", text="") + line.prop(item, "bl_delay_apply", text="") + # HOST SETTINGS + box = grid.box() + box.prop( + self, "conf_session_hosting_expanded", text="Hosting", + icon='DISCLOSURE_TRI_DOWN' if self.conf_session_hosting_expanded + else 'DISCLOSURE_TRI_RIGHT', emboss=False) + if self.conf_session_hosting_expanded: + box.row().prop(self, "right_strategy", text="Right model") + row = box.row() + row.label(text="Start with an empty scene:") + row.prop(self, "start_empty", text="") + + # CACHE SETTINGS + box = grid.box() + box.prop( + self, "conf_session_cache_expanded", text="Cache", + icon='DISCLOSURE_TRI_DOWN' if self.conf_session_cache_expanded + else 'DISCLOSURE_TRI_RIGHT', emboss=False) + if self.conf_session_cache_expanded: + box.row().prop(self, "cache_directory", text="Cache directory") + + if self.category == 'UPDATE': + from . import addon_updater_ops + addon_updater_ops.update_settings_ui_condensed(self, context) def generate_supported_types(self): self.supported_datablocks.clear() @@ -181,19 +220,22 @@ class SessionPrefs(bpy.types.AddonPreferences): type_impl_name = "Bl{}".format(type.split('_')[1].capitalize()) type_module_class = getattr(type_module, type_impl_name) - new_db.name = 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 - new_db.bl_delay_apply =type_module_class.bl_delay_apply + new_db.bl_delay_apply = type_module_class.bl_delay_apply new_db.use_as_filter = True new_db.icon = type_module_class.bl_icon - new_db.auto_push =type_module_class.bl_automatic_push - new_db.bl_name=type_module_class.bl_id + new_db.auto_push = type_module_class.bl_automatic_push + new_db.bl_name = type_module_class.bl_id + classes = ( ReplicatedDatablock, SessionPrefs, ) + + def register(): from bpy.utils import register_class @@ -205,8 +247,9 @@ def register(): logger.info('Generating bl_types preferences') prefs.generate_supported_types() + def unregister(): from bpy.utils import unregister_class for cls in reversed(classes): - unregister_class(cls) \ No newline at end of file + unregister_class(cls) From ffb6c397b8f983342fe4da6f73082aca48608832 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Wed, 11 Mar 2020 22:35:50 +0000 Subject: [PATCH 010/129] Test addon build --- .gitlab-ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..88d99fb --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,27 @@ +image: python:latest + + +before_script: + - python -V # Print out python version for debugging + - pip install virtualenv + - virtualenv venv + - source venv/bin/activate + +test: + script: + - python setup.py test + - pip install tox flake8 # you can also use tox + - tox -e py36,flake8 + +run: + script: + - cd multi-user + - rm -rf cache tests .git .gitignore .gitmodules docs + # run the command here + - cd multi-user/libs/replication + - rm -rf tests .git .gitignore + + artifacts: + paths: + - multi-user + From 624f67a62176888c53df37361ef3107f7c07e57e Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Wed, 11 Mar 2020 22:36:26 +0000 Subject: [PATCH 011/129] Update .gitlab-ci.yml --- .gitlab-ci.yml | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 88d99fb..c22b2e1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,19 +1,6 @@ image: python:latest - -before_script: - - python -V # Print out python version for debugging - - pip install virtualenv - - virtualenv venv - - source venv/bin/activate - -test: - script: - - python setup.py test - - pip install tox flake8 # you can also use tox - - tox -e py36,flake8 - -run: +build: script: - cd multi-user - rm -rf cache tests .git .gitignore .gitmodules docs From 1e81a2de16483e6d97efd90eaeca4ef370a6d85f Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Wed, 11 Mar 2020 22:38:09 +0000 Subject: [PATCH 012/129] Update .gitlab-ci.yml --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c22b2e1..5ebb7e1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,6 +2,7 @@ image: python:latest build: script: + - ls - cd multi-user - rm -rf cache tests .git .gitignore .gitmodules docs # run the command here From 7db7382c68b7e794f9bd0bf4b393f8b3da23315e Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Wed, 11 Mar 2020 22:41:23 +0000 Subject: [PATCH 013/129] Update .gitlab-ci.yml --- .gitlab-ci.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5ebb7e1..84602f1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,14 +2,13 @@ image: python:latest build: script: - - ls - - cd multi-user - - rm -rf cache tests .git .gitignore .gitmodules docs - # run the command here - - cd multi-user/libs/replication + - git submodules init + - git submodules update + - cd multi_user/libs/replication - rm -rf tests .git .gitignore - + - cd ../../ + - ls artifacts: paths: - - multi-user + - multi_user From 08b9a3598116e6628b382a5bc9c10ad29247f2c9 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Wed, 11 Mar 2020 22:43:25 +0000 Subject: [PATCH 014/129] Update .gitlab-ci.yml --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 84602f1..ec18a11 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,8 +2,8 @@ image: python:latest build: script: - - git submodules init - - git submodules update + - git submodule init + - git submodule update - cd multi_user/libs/replication - rm -rf tests .git .gitignore - cd ../../ From 1c6e88ce61b69c38694d35f89f00208cb54a5103 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Thu, 12 Mar 2020 11:40:36 +0100 Subject: [PATCH 015/129] feat: get links from gitlab releases --- multi_user/addon_updater.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/multi_user/addon_updater.py b/multi_user/addon_updater.py index b369478..d4aa6b8 100644 --- a/multi_user/addon_updater.py +++ b/multi_user/addon_updater.py @@ -1624,7 +1624,10 @@ class GitlabEngine(object): return "{}{}{}".format(self.api_url,"/api/v4/projects/",updater.repo) def form_tags_url(self, updater): - return "{}{}".format(self.form_repo_url(updater),"/repository/tags") + if updater.use_releases: + return "{}{}".format(self.form_repo_url(updater),"/releases") + else: + return "{}{}".format(self.form_repo_url(updater),"/repository/tags") def form_branch_list_url(self, updater): # does not validate branch name. @@ -1652,7 +1655,8 @@ class GitlabEngine(object): def parse_tags(self, response, updater): if response == None: return [] - return [{"name": tag["name"], "zipball_url": self.get_zip_url(tag["commit"]["id"], updater)} for tag in response] + # Return asset links from release + return [{"name": tag["name"], "zipball_url": tag["assets"]["links"][0]["url"]} for tag in response] # ----------------------------------------------------------------------------- From ff5e56e36cc0beedc5d6b38bcf7c09945cb587af Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Thu, 12 Mar 2020 11:57:54 +0100 Subject: [PATCH 016/129] fix: use release --- multi_user/addon_updater.py | 6 +++++- multi_user/addon_updater_ops.py | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/multi_user/addon_updater.py b/multi_user/addon_updater.py index d4aa6b8..6750358 100644 --- a/multi_user/addon_updater.py +++ b/multi_user/addon_updater.py @@ -1656,7 +1656,11 @@ class GitlabEngine(object): if response == None: return [] # Return asset links from release - return [{"name": tag["name"], "zipball_url": tag["assets"]["links"][0]["url"]} for tag in response] + if updater.use_releases: + return [{"name": release["name"], "zipball_url": release["assets"]["links"][0]["url"]} for release in response] + else: + return [{"name": tag["name"], "zipball_url": self.get_zip_url(tag["commit"]["id"], updater)} for tag in response] + # ----------------------------------------------------------------------------- diff --git a/multi_user/addon_updater_ops.py b/multi_user/addon_updater_ops.py index cf79b3b..30c0e47 100644 --- a/multi_user/addon_updater_ops.py +++ b/multi_user/addon_updater_ops.py @@ -1368,11 +1368,11 @@ def register(bl_info): # the "install {branch}/older version" operator. updater.include_branches = True - # (GitHub only) This options allows the user to use releases over tags for data, + # (GitHub/Gitlab only) This options allows the user to use releases over tags for data, # which enables pulling down release logs/notes, as well as specify installs from # release-attached zips (instead of just the auto-packaged code generated with # a release/tag). Setting has no impact on BitBucket or GitLab repos - updater.use_releases = False + updater.use_releases = True # note: Releases always have a tag, but a tag may not always be a release # Therefore, setting True above will filter out any non-annoted tags # note 2: Using this option will also display the release name instead of From 931c683030b8ea50509fdbe5922093cd03c4056b Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Thu, 12 Mar 2020 12:04:52 +0000 Subject: [PATCH 017/129] Update .gitlab-ci.yml --- .gitlab-ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ec18a11..1602ceb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,9 +6,10 @@ build: - git submodule update - cd multi_user/libs/replication - rm -rf tests .git .gitignore - - cd ../../ - - ls + artifacts: paths: - - multi_user + - name: + - paths: + - multi_user From 824040660ba4e39030328801891f020b47e6ce7f Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Thu, 12 Mar 2020 12:06:27 +0000 Subject: [PATCH 018/129] Update .gitlab-ci.yml --- .gitlab-ci.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1602ceb..a9e4c9f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -8,8 +8,7 @@ build: - rm -rf tests .git .gitignore artifacts: - paths: - - name: - - paths: - - multi_user + name: multi_user + paths: + - multi_user From 1e580dbcd6250f8fe0ae4885a3ce7b6ba5339f08 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Thu, 12 Mar 2020 12:17:30 +0000 Subject: [PATCH 019/129] Update multi_user/__init__.py, .gitlab-ci.yml files --- .gitlab-ci.yml | 3 +++ multi_user/__init__.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a9e4c9f..000f8da 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -12,3 +12,6 @@ build: paths: - multi_user + only: + - master + - develop diff --git a/multi_user/__init__.py b/multi_user/__init__.py index 15392f2..96aa479 100644 --- a/multi_user/__init__.py +++ b/multi_user/__init__.py @@ -1,7 +1,7 @@ bl_info = { "name": "Multi-User", "author": "Swann Martinez", - "version": (0, 0, 1), + "version": (0, 0, 2), "description": "Enable real-time collaborative workflow inside blender", "blender": (2, 80, 0), "location": "3D View > Sidebar > Multi-User tab", From 511983c7ffc78849cbceec2b160e1b59dd19200b Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Thu, 12 Mar 2020 13:25:13 +0100 Subject: [PATCH 020/129] feat: remove useless information panel feat: doc_url field [2.83 update](https://developer.blender.org/D7015) --- multi_user/__init__.py | 1 + multi_user/preferences.py | 7 +------ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/multi_user/__init__.py b/multi_user/__init__.py index 96aa479..1fc7420 100644 --- a/multi_user/__init__.py +++ b/multi_user/__init__.py @@ -7,6 +7,7 @@ bl_info = { "location": "3D View > Sidebar > Multi-User tab", "warning": "Unstable addon, use it at your own risks", "category": "Collaboration", + "doc_url": "https://multi-user.readthedocs.io/en/develop/index.html", "wiki_url": "https://multi-user.readthedocs.io/en/develop/index.html", "tracker_url": "https://gitlab.com/slumber/multi-user/issues", "support": "COMMUNITY" diff --git a/multi_user/preferences.py b/multi_user/preferences.py index 9a20c7a..3230b6d 100644 --- a/multi_user/preferences.py +++ b/multi_user/preferences.py @@ -64,11 +64,10 @@ class SessionPrefs(bpy.types.AddonPreferences): name="Category", description="Preferences Category", items=[ - ('INFO', "Information", "Information about this add-on"), ('CONFIG', "Configuration", "Configuration about this add-on"), ('UPDATE', "Update", "Update this add-on"), ], - default='INFO' + default='CONFIG' ) conf_session_identity_expanded: bpy.props.BoolProperty( name="Identity", @@ -133,10 +132,6 @@ class SessionPrefs(bpy.types.AddonPreferences): layout = self.layout layout.row().prop(self, "category", expand=True) - - if self.category == 'INFO': - layout.separator() - layout.label(text="Enable real-time collaborative workflow inside blender") if self.category == 'CONFIG': grid = layout.column() From 3f7cb65393e162afa92c9aa0953882d821627580 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Thu, 12 Mar 2020 13:39:50 +0100 Subject: [PATCH 021/129] doc: update download links to job builds --- docs/getting_started/install.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/getting_started/install.rst b/docs/getting_started/install.rst index 888b2db..bf9bd16 100644 --- a/docs/getting_started/install.rst +++ b/docs/getting_started/install.rst @@ -4,6 +4,6 @@ Installation *The process is the same for linux, mac and windows.* -1. Download latest release `multi_user.zip `_. +1. Download latest `release `_ or `develop (unstable !) `_ build. 2. Run blender as administrator (to allow python dependencies auto-installation). -3. Install last_version.zip from your addon preferences. \ No newline at end of file +3. Install **multi-user.zip** from your addon preferences. \ No newline at end of file From 4a127e617cecb180aacc9d5271fb9a1e0d30fa88 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Fri, 13 Mar 2020 15:05:00 +0100 Subject: [PATCH 022/129] feat: cleanup --- multi_user/bl_types/bl_datablock.py | 16 ---------------- multi_user/libs/replication | 2 +- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index be6c8ed..f370bc6 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -77,22 +77,6 @@ class BlDatablock(ReplicatedDatablock): self.diff_method = DIFF_BINARY - def library_apply(self): - """Apply stored data - """ - # UP in case we want to reset our pointer data - self.state = UP - - def bl_diff(self): - """Generic datablock diff""" - return self.pointer.name != self.data['name'] - - def diff_library(self): - return False - - def resolve_deps_library(self): - return [self.pointer.library] - def resolve(self): datablock_ref = None datablock_root = getattr(bpy.data, self.bl_id) diff --git a/multi_user/libs/replication b/multi_user/libs/replication index 99bf948..f24d796 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit 99bf94874a07890dc747ec53519aa34443a95146 +Subproject commit f24d796fe30c601d1ecee6231dd845ceab4707e1 From 717a2da3de74b2b230adbd9ac589820fa27c31f1 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Fri, 13 Mar 2020 17:13:39 +0100 Subject: [PATCH 023/129] refactor: cleanup progression --- multi_user/bl_types/bl_action.py | 6 +++--- multi_user/bl_types/bl_armature.py | 2 +- multi_user/bl_types/bl_camera.py | 2 +- multi_user/bl_types/bl_collection.py | 4 ++-- multi_user/bl_types/bl_curve.py | 2 +- multi_user/bl_types/bl_datablock.py | 9 ++++----- multi_user/bl_types/bl_gpencil.py | 2 +- multi_user/bl_types/bl_image.py | 6 +++--- multi_user/bl_types/bl_lattice.py | 2 +- multi_user/bl_types/bl_library.py | 4 ++-- multi_user/bl_types/bl_light.py | 4 ++-- multi_user/bl_types/bl_lightprobe.py | 2 +- multi_user/bl_types/bl_material.py | 2 +- multi_user/bl_types/bl_mesh.py | 2 +- multi_user/bl_types/bl_metaball.py | 2 +- multi_user/bl_types/bl_object.py | 2 +- multi_user/bl_types/bl_scene.py | 4 ++-- multi_user/bl_types/bl_speaker.py | 2 +- multi_user/bl_types/bl_world.py | 7 ++----- multi_user/libs/replication | 2 +- 20 files changed, 32 insertions(+), 36 deletions(-) diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py index 9d4c28f..5a45c04 100644 --- a/multi_user/bl_types/bl_action.py +++ b/multi_user/bl_types/bl_action.py @@ -15,10 +15,10 @@ class BlAction(BlDatablock): bl_automatic_push = True bl_icon = 'ACTION_TWEAK' - def construct(self, data): + def _construct(self, data): return bpy.data.actions.new(data["name"]) - def load(self, data, target): + def _load(self, data, target): begin_frame = 100000 end_frame = -100000 @@ -90,7 +90,7 @@ class BlAction(BlDatablock): target.fcurves.remove(fcurve) target.id_root= data['id_root'] - def dump(self, pointer=None): + def _dump(self, pointer=None): assert(pointer) dumper = utils.dump_anything.Dumper() dumper.exclude_filter =[ diff --git a/multi_user/bl_types/bl_armature.py b/multi_user/bl_types/bl_armature.py index 8aca65c..ed271a6 100644 --- a/multi_user/bl_types/bl_armature.py +++ b/multi_user/bl_types/bl_armature.py @@ -17,7 +17,7 @@ class BlArmature(BlDatablock): bl_automatic_push = True bl_icon = 'ARMATURE_DATA' - def construct(self, data): + def _construct(self, data): return bpy.data.armatures.new(data["name"]) def load_implementation(self, data, target): diff --git a/multi_user/bl_types/bl_camera.py b/multi_user/bl_types/bl_camera.py index 72700a2..3dbd8f2 100644 --- a/multi_user/bl_types/bl_camera.py +++ b/multi_user/bl_types/bl_camera.py @@ -22,7 +22,7 @@ class BlCamera(BlDatablock): if dof_settings: utils.dump_anything.load(target.dof, dof_settings) - def construct(self, data): + def _construct(self, data): return bpy.data.cameras.new(data["name"]) def dump_implementation(self, data, pointer=None): diff --git a/multi_user/bl_types/bl_collection.py b/multi_user/bl_types/bl_collection.py index 38ac0b1..b9b114d 100644 --- a/multi_user/bl_types/bl_collection.py +++ b/multi_user/bl_types/bl_collection.py @@ -13,7 +13,7 @@ class BlCollection(BlDatablock): bl_delay_apply = 1 bl_automatic_push = True - def construct(self, data): + def _construct(self, data): if self.is_library: with bpy.data.libraries.load(filepath=bpy.data.libraries[self.data['library']].filepath, link=True) as (sourceData, targetData): targetData.collections = [ @@ -28,7 +28,7 @@ class BlCollection(BlDatablock): instance.uuid = self.uuid return instance - def load(self, data, target): + def load_implementation(self, data, target): # Load other meshes metadata # dump_anything.load(target, data) target.name = data["name"] diff --git a/multi_user/bl_types/bl_curve.py b/multi_user/bl_types/bl_curve.py index 4a498e9..44f687d 100644 --- a/multi_user/bl_types/bl_curve.py +++ b/multi_user/bl_types/bl_curve.py @@ -15,7 +15,7 @@ class BlCurve(BlDatablock): bl_automatic_push = True bl_icon = 'CURVE_DATA' - def construct(self, data): + def _construct(self, data): return bpy.data.curves.new(data["name"], data["type"]) def load_implementation(self, data, target): diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index f370bc6..3a891db 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -61,7 +61,6 @@ class BlDatablock(ReplicatedDatablock): bl_automatic_push : boolean bl_icon : type icon (blender icon name) """ - bl_id = "scenes" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -77,7 +76,7 @@ class BlDatablock(ReplicatedDatablock): self.diff_method = DIFF_BINARY - def resolve(self): + def _resolve(self): datablock_ref = None datablock_root = getattr(bpy.data, self.bl_id) datablock_ref = utils.find_from_attr('uuid', self.uuid, datablock_root) @@ -92,7 +91,7 @@ class BlDatablock(ReplicatedDatablock): self.pointer = datablock_ref - def dump(self, pointer=None): + def _dump(self, pointer=None): data = {} # Dump animation data if utils.has_action(pointer): @@ -118,7 +117,7 @@ class BlDatablock(ReplicatedDatablock): def dump_implementation(self, data, target): raise NotImplementedError - def load(self, data, target): + def _load(self, data, target): # Load animation data if 'animation_data' in data.keys(): if target.animation_data is None: @@ -150,7 +149,7 @@ class BlDatablock(ReplicatedDatablock): if not self.is_library: dependencies.extend(self.resolve_deps_implementation()) - print(dependencies) + return dependencies def resolve_deps_implementation(self): diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index d593e6d..a1796eb 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -40,7 +40,7 @@ class BlGpencil(BlDatablock): bl_automatic_push = True bl_icon = 'GREASEPENCIL' - def construct(self, data): + def _construct(self, data): return bpy.data.grease_pencils.new(data["name"]) def load_implementation(self, data, target): diff --git a/multi_user/bl_types/bl_image.py b/multi_user/bl_types/bl_image.py index 8b965f1..bdf561d 100644 --- a/multi_user/bl_types/bl_image.py +++ b/multi_user/bl_types/bl_image.py @@ -35,14 +35,14 @@ class BlImage(BlDatablock): bl_automatic_push = False bl_icon = 'IMAGE_DATA' - def construct(self, data): + def _construct(self, data): return bpy.data.images.new( name=data['name'], width=data['size'][0], height=data['size'][1] ) - def load(self, data, target): + def _load(self, data, target): image = target prefs = utils.get_preferences() @@ -59,7 +59,7 @@ class BlImage(BlDatablock): image.colorspace_settings.name = data["colorspace_settings"]["name"] - def dump(self, data, pointer=None): + def _dump(self, data, pointer=None): assert(pointer) data = {} data['pixels'] = dump_image(pointer) diff --git a/multi_user/bl_types/bl_lattice.py b/multi_user/bl_types/bl_lattice.py index 5a7ac28..160e819 100644 --- a/multi_user/bl_types/bl_lattice.py +++ b/multi_user/bl_types/bl_lattice.py @@ -18,7 +18,7 @@ class BlLattice(BlDatablock): for point in data['points']: utils.dump_anything.load(target.points[point], data["points"][point]) - def construct(self, data): + def _construct(self, data): return bpy.data.lattices.new(data["name"]) def dump_implementation(self, data, pointer=None): diff --git a/multi_user/bl_types/bl_library.py b/multi_user/bl_types/bl_library.py index 8e909ac..0d76396 100644 --- a/multi_user/bl_types/bl_library.py +++ b/multi_user/bl_types/bl_library.py @@ -13,11 +13,11 @@ class BlLibrary(BlDatablock): bl_automatic_push = True bl_icon = 'LIBRARY_DATA_DIRECT' - def construct(self, data): + def _construct(self, data): with bpy.data.libraries.load(filepath=data["filepath"], link=True) as (sourceData, targetData): targetData = sourceData return sourceData - def load(self, data, target): + def _load(self, data, target): pass def dump(self, pointer=None): diff --git a/multi_user/bl_types/bl_light.py b/multi_user/bl_types/bl_light.py index 6dfdd80..ac5cecc 100644 --- a/multi_user/bl_types/bl_light.py +++ b/multi_user/bl_types/bl_light.py @@ -13,10 +13,10 @@ class BlLight(BlDatablock): bl_automatic_push = True bl_icon = 'LIGHT_DATA' - def construct(self, data): + def _construct(self, data): return bpy.data.lights.new(data["name"], data["type"]) - def load(self, data, target): + def load_implementation(self, data, target): utils.dump_anything.load(target, data) def dump_implementation(self, data, pointer=None): diff --git a/multi_user/bl_types/bl_lightprobe.py b/multi_user/bl_types/bl_lightprobe.py index bc8edfd..3fae44c 100644 --- a/multi_user/bl_types/bl_lightprobe.py +++ b/multi_user/bl_types/bl_lightprobe.py @@ -18,7 +18,7 @@ class BlLightprobe(BlDatablock): def load_implementation(self, data, target): utils.dump_anything.load(target, data) - def construct(self, data): + def _construct(self, data): type = 'CUBE' if data['type'] == 'CUBEMAP' else data['type'] # See https://developer.blender.org/D6396 if bpy.app.version[1] >= 83: diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index fa33f40..4389037 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -76,7 +76,7 @@ class BlMaterial(BlDatablock): bl_automatic_push = True bl_icon = 'MATERIAL_DATA' - def construct(self, data): + def _construct(self, data): return bpy.data.materials.new(data["name"]) def load_implementation(self, data, target): diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index f502cae..68976db 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -86,7 +86,7 @@ class BlMesh(BlDatablock): bl_automatic_push = True bl_icon = 'MESH_DATA' - def construct(self, data): + def _construct(self, data): instance = bpy.data.meshes.new(data["name"]) instance.uuid = self.uuid return instance diff --git a/multi_user/bl_types/bl_metaball.py b/multi_user/bl_types/bl_metaball.py index 7dec312..4dac138 100644 --- a/multi_user/bl_types/bl_metaball.py +++ b/multi_user/bl_types/bl_metaball.py @@ -13,7 +13,7 @@ class BlMetaball(BlDatablock): bl_automatic_push = True bl_icon = 'META_BALL' - def construct(self, data): + def _construct(self, data): return bpy.data.metaballs.new(data["name"]) def load(self, data, target): diff --git a/multi_user/bl_types/bl_object.py b/multi_user/bl_types/bl_object.py index 26b5469..4f68938 100644 --- a/multi_user/bl_types/bl_object.py +++ b/multi_user/bl_types/bl_object.py @@ -38,7 +38,7 @@ class BlObject(BlDatablock): bl_automatic_push = True bl_icon = 'OBJECT_DATA' - def construct(self, data): + def _construct(self, data): pointer = None if self.is_library: diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index bac83f8..ad0740f 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -12,12 +12,12 @@ class BlScene(BlDatablock): bl_automatic_push = True bl_icon = 'SCENE_DATA' - def construct(self, data): + def _construct(self, data): instance = bpy.data.scenes.new(data["name"]) instance.uuid = self.uuid return instance - def load(self, data, target): + def load_implementation(self, data, target): target = self.pointer # Load other meshes metadata utils.dump_anything.load(target, data) diff --git a/multi_user/bl_types/bl_speaker.py b/multi_user/bl_types/bl_speaker.py index d675ee2..8116c01 100644 --- a/multi_user/bl_types/bl_speaker.py +++ b/multi_user/bl_types/bl_speaker.py @@ -16,7 +16,7 @@ class BlSpeaker(BlDatablock): def load_implementation(self, data, target): utils.dump_anything.load(target, data) - def construct(self, data): + def _construct(self, data): return bpy.data.speakers.new(data["name"]) def dump_implementation(self, data, pointer=None): diff --git a/multi_user/bl_types/bl_world.py b/multi_user/bl_types/bl_world.py index 588ef64..ac5cb1c 100644 --- a/multi_user/bl_types/bl_world.py +++ b/multi_user/bl_types/bl_world.py @@ -14,10 +14,10 @@ class BlWorld(BlDatablock): bl_automatic_push = True bl_icon = 'WORLD_DATA' - def construct(self, data): + def _construct(self, data): return bpy.data.worlds.new(data["name"]) - def load(self, data, target): + def load_implementation(self, data, target): if data["use_nodes"]: if target.node_tree is None: target.use_nodes = True @@ -101,6 +101,3 @@ class BlWorld(BlDatablock): deps.append(self.pointer.library) return deps - def is_valid(self): - return bpy.data.worlds.get(self.data['name']) - diff --git a/multi_user/libs/replication b/multi_user/libs/replication index f24d796..546fe5d 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit f24d796fe30c601d1ecee6231dd845ceab4707e1 +Subproject commit 546fe5d6394f46bff00d81e1c7658a34341aef16 From 5a0be0f6f99d3fcf52a346d452f9588a24718bee Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Sat, 14 Mar 2020 15:36:48 +0000 Subject: [PATCH 024/129] Update README.md --- README.md | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 28ba30a..cbabd14 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ > Enable real-time collaborative workflow inside blender -![demo](https://i.imgur.com/X0B7O1Q.gif) +[![Documentation Status](https://readthedocs.org/projects/multi-user/badge/?version=latest)](https://multi-user.readthedocs.io/en/latest/?badge=latest) [![Chat on Discord](https://img.shields.io/badge/chat-Discord-brightgreen.svg)](https://discord.gg/Q3TSmb4) [![pipeline status](https://gitlab.com/slumber/multi-user/badges/master/pipeline.svg)](https://gitlab.com/slumber/multi-user/-/commits/master) + + :warning: Under development, use it at your own risks. Currently tested on Windows platform. :warning: @@ -25,22 +27,28 @@ See the [documentation](https://multi-user.readthedocs.io/en/latest/) for detail Currently, not all data-block are supported for replication over the wire. The following list summarizes the status for each ones. -| Name | Status | Comment | -| ----------- | :----------------: | :--------------------------------: | -| action | :exclamation: | Not stable | -| armature | :exclamation: | Not stable | -| camera | :white_check_mark: | | -| collection | :white_check_mark: | | -| curve | :white_check_mark: | Nurbs surface don't load correctly | -| gpencil | :white_check_mark: | | -| image | :exclamation: | Not stable yet | -| mesh | :white_check_mark: | | -| material | :white_check_mark: | | -| metaball | :white_check_mark: | | -| object | :white_check_mark: | | -| scene | :white_check_mark: | | -| world | :white_check_mark: | | -| lightprobes | :white_check_mark: | | +| Name | Status | Comment | +| ----------- | :----: | :-----------------------------------------------------------: | +| action | ❗ | Not stable | +| armature | ❗ | Not stable | +| camera | ✔️ | | +| collection | ✔️ | | +| curve | ✔️ | Nurbs surface don't load correctly | +| gpencil | ✔️ | | +| image | ❗ | Not stable yet | +| mesh | ✔️ | | +| material | ✔️ | | +| metaball | ✔️ | | +| object | ✔️ | | +| scene | ✔️ | | +| world | ✔️ | | +| lightprobes | ✔️ | | +| particles | ❌ | [On-going](https://gitlab.com/slumber/multi-user/-/issues/24) | +| speakers | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/65) | +| vse | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) | +| physics | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) | +| libraries | ❗ | Partial | + ### Performance issues @@ -53,7 +61,6 @@ I'm working on it. | ------------ | :-----: | -----: | | ZeroMQ | latest | yes | | msgpack | latest | yes | -| PyYAML | latest | yes | | JsonDiff | latest | yes | @@ -65,4 +72,3 @@ See [contributing section](https://multi-user.readthedocs.io/en/latest/ways_to_c See [license](LICENSE) -[![Documentation Status](https://readthedocs.org/projects/multi-user/badge/?version=latest)](https://multi-user.readthedocs.io/en/latest/?badge=latest) From 85b3f6e2466fb7f4d9755185f5b19e9623537c1f Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Thu, 19 Mar 2020 10:25:21 +0000 Subject: [PATCH 025/129] feat: bug report template --- .gitlab-ci.yml => .gitlab/ci/.gitlab-ci.yml | 0 .gitlab/issue_templates/Bug.md | 46 +++++++++++++++++++++ 2 files changed, 46 insertions(+) rename .gitlab-ci.yml => .gitlab/ci/.gitlab-ci.yml (100%) create mode 100644 .gitlab/issue_templates/Bug.md diff --git a/.gitlab-ci.yml b/.gitlab/ci/.gitlab-ci.yml similarity index 100% rename from .gitlab-ci.yml rename to .gitlab/ci/.gitlab-ci.yml diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md new file mode 100644 index 0000000..8d48115 --- /dev/null +++ b/.gitlab/issue_templates/Bug.md @@ -0,0 +1,46 @@ + + +### Summary + +(Summarize the bug encountered concisely) + +* Addon version: (your addon-version) +* Blender version: (your blender version) +* OS: (your os windows/linux/mac) + + +### Steps to reproduce + +(How one can reproduce the issue - this is very important) + +### Example Project [optionnal] +(If possible, please create an example project that exhibits the problematic behavior, and link to it here in the bug report) + + +### What is the current *bug* behavior? + +(What actually happens) + + +### Relevant logs and/or screenshots + +(Paste any relevant logs - please use code blocks (```) to format console output, +logs, and code as it's tough to read otherwise.) + + +### Possible fixes [optionnal] + +(If you can, link to the line of code that might be responsible for the problem) + + +/label ~bug +/cc @project-manager From 7fa97704bd5e8855feb7584e4a948755f6806f21 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Thu, 19 Mar 2020 10:33:19 +0000 Subject: [PATCH 026/129] feat: gitlab-ci --- .gitlab-ci.yml | 2 ++ .gitlab/ci/{.gitlab-ci.yml => build.gitlab-ci.yml} | 0 2 files changed, 2 insertions(+) create mode 100644 .gitlab-ci.yml rename .gitlab/ci/{.gitlab-ci.yml => build.gitlab-ci.yml} (100%) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..8960bc8 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,2 @@ +include: + - local: .gitlab/ci/build.gitlab-ci.yml diff --git a/.gitlab/ci/.gitlab-ci.yml b/.gitlab/ci/build.gitlab-ci.yml similarity index 100% rename from .gitlab/ci/.gitlab-ci.yml rename to .gitlab/ci/build.gitlab-ci.yml From 56e5709a354f3d046515ff168c25305b33c9463b Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 19 Mar 2020 17:26:30 +0100 Subject: [PATCH 027/129] refactor: use pickle instead of msgpack --- README.md | 1 - multi_user/__init__.py | 4 +--- multi_user/bl_types/bl_datablock.py | 2 +- multi_user/libs/replication | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cbabd14..c764a65 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,6 @@ I'm working on it. | Dependencies | Version | Needed | | ------------ | :-----: | -----: | | ZeroMQ | latest | yes | -| msgpack | latest | yes | | JsonDiff | latest | yes | diff --git a/multi_user/__init__.py b/multi_user/__init__.py index 1fc7420..2990789 100644 --- a/multi_user/__init__.py +++ b/multi_user/__init__.py @@ -1,7 +1,7 @@ bl_info = { "name": "Multi-User", "author": "Swann Martinez", - "version": (0, 0, 2), + "version": (0, 0, 3), "description": "Enable real-time collaborative workflow inside blender", "blender": (2, 80, 0), "location": "3D View > Sidebar > Multi-User tab", @@ -29,8 +29,6 @@ from .libs.replication.replication.constants import RP_COMMON # TODO: remove dependency as soon as replication will be installed as a module DEPENDENCIES = { ("zmq","zmq"), - ("msgpack","msgpack"), - ("yaml","pyyaml"), ("jsondiff","jsondiff") } diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index be6c8ed..60c0a86 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -166,7 +166,7 @@ class BlDatablock(ReplicatedDatablock): if not self.is_library: dependencies.extend(self.resolve_deps_implementation()) - print(dependencies) + return dependencies def resolve_deps_implementation(self): diff --git a/multi_user/libs/replication b/multi_user/libs/replication index 99bf948..2e84bb6 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit 99bf94874a07890dc747ec53519aa34443a95146 +Subproject commit 2e84bb65b8a430f6b98533bd9a95028fb243f535 From 931301074eed4647483ba459a10bc13bcf87ad54 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Thu, 19 Mar 2020 17:08:59 +0000 Subject: [PATCH 028/129] refactor: remove badges from README --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index c764a65..4c340da 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ > Enable real-time collaborative workflow inside blender -[![Documentation Status](https://readthedocs.org/projects/multi-user/badge/?version=latest)](https://multi-user.readthedocs.io/en/latest/?badge=latest) [![Chat on Discord](https://img.shields.io/badge/chat-Discord-brightgreen.svg)](https://discord.gg/Q3TSmb4) [![pipeline status](https://gitlab.com/slumber/multi-user/badges/master/pipeline.svg)](https://gitlab.com/slumber/multi-user/-/commits/master) - From b8f46c2523de678a725e758de48ebdcf8b37e376 Mon Sep 17 00:00:00 2001 From: Swann Date: Fri, 20 Mar 2020 11:46:43 +0100 Subject: [PATCH 029/129] fix: service launching error Related to #76 --- multi_user/libs/replication | 2 +- multi_user/ui.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/multi_user/libs/replication b/multi_user/libs/replication index 2e84bb6..a892dce 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit 2e84bb65b8a430f6b98533bd9a95028fb243f535 +Subproject commit a892dcee04329f662e16bf8d353dd387d5fbcf13 diff --git a/multi_user/ui.py b/multi_user/ui.py index 5e356f7..21f4d48 100644 --- a/multi_user/ui.py +++ b/multi_user/ui.py @@ -6,7 +6,8 @@ from .libs.replication.replication.constants import (ADDED, ERROR, FETCHED, STATE_ACTIVE, STATE_AUTH, STATE_CONFIG, STATE_SYNCING, STATE_INITIAL, STATE_SRV_SYNC, - STATE_WAITING, STATE_QUITTING) + STATE_WAITING, STATE_QUITTING, + STATE_LAUNCHING_SERVICES) ICONS_PROP_STATES = ['TRIA_DOWN', # ADDED 'TRIA_UP', # COMMITED @@ -35,7 +36,7 @@ def printProgressBar (iteration, total, prefix = '', suffix = '', decimals = 1, return '{} |{}| {}/{}{}'.format(prefix, bar, iteration,total, suffix) def get_state_str(state): - state_str = 'None' + state_str = 'UNKNOWN' if state == STATE_WAITING: state_str = 'WARMING UP DATA' elif state == STATE_SYNCING: @@ -52,6 +53,9 @@ def get_state_str(state): state_str = 'INIT' elif state == STATE_QUITTING: state_str = 'QUITTING SESSION' + elif state == STATE_LAUNCHING_SERVICES: + state_str = 'LAUNCHING SERVICES' + return state_str class SESSION_PT_settings(bpy.types.Panel): From ba9b4ebe70f9a692b16a0785308fd46f8a24a6ce Mon Sep 17 00:00:00 2001 From: Swann Date: Fri, 20 Mar 2020 13:41:42 +0100 Subject: [PATCH 030/129] feat: update submodules --- multi_user/libs/replication | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_user/libs/replication b/multi_user/libs/replication index a892dce..114b2eb 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit a892dcee04329f662e16bf8d353dd387d5fbcf13 +Subproject commit 114b2ebe37ccd10b36544924d23443df0867581b From aa0b54a05464d08286404f157be3c5be3a0732a1 Mon Sep 17 00:00:00 2001 From: Swann Date: Fri, 20 Mar 2020 14:17:58 +0100 Subject: [PATCH 031/129] fix: ui refresh --- multi_user/delayable.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_user/delayable.py b/multi_user/delayable.py index dcdf72f..6c100ab 100644 --- a/multi_user/delayable.py +++ b/multi_user/delayable.py @@ -294,5 +294,5 @@ class ClientUpdate(Timer): presence.renderer.stop() # # ui update - elif session: + elif session.state['STATE'] != STATE_INITIAL: presence.refresh_3d_view() \ No newline at end of file From 2fcb4615be3d2d584854a5800452077590d8eb97 Mon Sep 17 00:00:00 2001 From: Swann Date: Fri, 20 Mar 2020 14:56:50 +0100 Subject: [PATCH 032/129] feat: GPL headers --- multi_user/__init__.py | 18 ++++++++++++++++++ multi_user/bl_types/__init__.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_action.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_armature.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_camera.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_collection.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_curve.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_datablock.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_gpencil.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_image.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_lattice.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_library.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_light.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_lightprobe.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_material.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_mesh.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_metaball.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_object.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_scene.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_speaker.py | 18 ++++++++++++++++++ multi_user/bl_types/bl_world.py | 18 ++++++++++++++++++ multi_user/delayable.py | 17 +++++++++++++++++ multi_user/environment.py | 18 ++++++++++++++++++ multi_user/libs/replication | 2 +- multi_user/operators.py | 18 ++++++++++++++++++ multi_user/preferences.py | 18 ++++++++++++++++++ multi_user/presence.py | 18 ++++++++++++++++++ multi_user/ui.py | 18 ++++++++++++++++++ multi_user/utils.py | 18 ++++++++++++++++++ 29 files changed, 504 insertions(+), 1 deletion(-) diff --git a/multi_user/__init__.py b/multi_user/__init__.py index 2990789..b401231 100644 --- a/multi_user/__init__.py +++ b/multi_user/__init__.py @@ -1,3 +1,21 @@ +# ##### 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 ##### + + bl_info = { "name": "Multi-User", "author": "Swann Martinez", diff --git a/multi_user/bl_types/__init__.py b/multi_user/bl_types/__init__.py index c3e9605..b5427c2 100644 --- a/multi_user/bl_types/__init__.py +++ b/multi_user/bl_types/__init__.py @@ -1,3 +1,21 @@ +# ##### 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 ##### + + __all__ = [ 'bl_object', 'bl_mesh', diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py index 5a45c04..d903a4f 100644 --- a/multi_user/bl_types/bl_action.py +++ b/multi_user/bl_types/bl_action.py @@ -1,3 +1,21 @@ +# ##### 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 import copy diff --git a/multi_user/bl_types/bl_armature.py b/multi_user/bl_types/bl_armature.py index ed271a6..c609be8 100644 --- a/multi_user/bl_types/bl_armature.py +++ b/multi_user/bl_types/bl_armature.py @@ -1,3 +1,21 @@ +# ##### 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 diff --git a/multi_user/bl_types/bl_camera.py b/multi_user/bl_types/bl_camera.py index 3dbd8f2..88112a3 100644 --- a/multi_user/bl_types/bl_camera.py +++ b/multi_user/bl_types/bl_camera.py @@ -1,3 +1,21 @@ +# ##### 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 diff --git a/multi_user/bl_types/bl_collection.py b/multi_user/bl_types/bl_collection.py index b9b114d..3d3f9f7 100644 --- a/multi_user/bl_types/bl_collection.py +++ b/multi_user/bl_types/bl_collection.py @@ -1,3 +1,21 @@ +# ##### 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 diff --git a/multi_user/bl_types/bl_curve.py b/multi_user/bl_types/bl_curve.py index 44f687d..ac6fbdc 100644 --- a/multi_user/bl_types/bl_curve.py +++ b/multi_user/bl_types/bl_curve.py @@ -1,3 +1,21 @@ +# ##### 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 bpy.types as T import mathutils diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index 3a891db..3a5d8b3 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -1,3 +1,21 @@ +# ##### 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 diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index a1796eb..8fc8a86 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -1,3 +1,21 @@ +# ##### 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 diff --git a/multi_user/bl_types/bl_image.py b/multi_user/bl_types/bl_image.py index bdf561d..c758fb0 100644 --- a/multi_user/bl_types/bl_image.py +++ b/multi_user/bl_types/bl_image.py @@ -1,3 +1,21 @@ +# ##### 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 import os diff --git a/multi_user/bl_types/bl_lattice.py b/multi_user/bl_types/bl_lattice.py index 160e819..ca6de99 100644 --- a/multi_user/bl_types/bl_lattice.py +++ b/multi_user/bl_types/bl_lattice.py @@ -1,3 +1,21 @@ +# ##### 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 diff --git a/multi_user/bl_types/bl_library.py b/multi_user/bl_types/bl_library.py index 0d76396..f53e6de 100644 --- a/multi_user/bl_types/bl_library.py +++ b/multi_user/bl_types/bl_library.py @@ -1,3 +1,21 @@ +# ##### 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 diff --git a/multi_user/bl_types/bl_light.py b/multi_user/bl_types/bl_light.py index ac5cecc..3d216f6 100644 --- a/multi_user/bl_types/bl_light.py +++ b/multi_user/bl_types/bl_light.py @@ -1,3 +1,21 @@ +# ##### 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 diff --git a/multi_user/bl_types/bl_lightprobe.py b/multi_user/bl_types/bl_lightprobe.py index 3fae44c..1a3968a 100644 --- a/multi_user/bl_types/bl_lightprobe.py +++ b/multi_user/bl_types/bl_lightprobe.py @@ -1,3 +1,21 @@ +# ##### 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 import logging diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index 4389037..89f3f3d 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -1,3 +1,21 @@ +# ##### 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 import logging diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index 68976db..d9195ae 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -1,3 +1,21 @@ +# ##### 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 bmesh import mathutils diff --git a/multi_user/bl_types/bl_metaball.py b/multi_user/bl_types/bl_metaball.py index 4dac138..36c998f 100644 --- a/multi_user/bl_types/bl_metaball.py +++ b/multi_user/bl_types/bl_metaball.py @@ -1,3 +1,21 @@ +# ##### 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 diff --git a/multi_user/bl_types/bl_object.py b/multi_user/bl_types/bl_object.py index 4f68938..f4bfd76 100644 --- a/multi_user/bl_types/bl_object.py +++ b/multi_user/bl_types/bl_object.py @@ -1,3 +1,21 @@ +# ##### 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 import logging diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index ad0740f..48ad025 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -1,3 +1,21 @@ +# ##### 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 diff --git a/multi_user/bl_types/bl_speaker.py b/multi_user/bl_types/bl_speaker.py index 8116c01..9d2f87c 100644 --- a/multi_user/bl_types/bl_speaker.py +++ b/multi_user/bl_types/bl_speaker.py @@ -1,3 +1,21 @@ +# ##### 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 diff --git a/multi_user/bl_types/bl_world.py b/multi_user/bl_types/bl_world.py index ac5cb1c..d23a65d 100644 --- a/multi_user/bl_types/bl_world.py +++ b/multi_user/bl_types/bl_world.py @@ -1,3 +1,21 @@ +# ##### 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 diff --git a/multi_user/delayable.py b/multi_user/delayable.py index 6c100ab..78598d4 100644 --- a/multi_user/delayable.py +++ b/multi_user/delayable.py @@ -1,3 +1,20 @@ +# ##### 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 logging import bpy diff --git a/multi_user/environment.py b/multi_user/environment.py index 077f451..716fcf8 100644 --- a/multi_user/environment.py +++ b/multi_user/environment.py @@ -1,3 +1,21 @@ +# ##### 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 collections import logging import os diff --git a/multi_user/libs/replication b/multi_user/libs/replication index 546fe5d..7077c12 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit 546fe5d6394f46bff00d81e1c7658a34341aef16 +Subproject commit 7077c125627d6cb2e7160db1d0e882d35980e4c9 diff --git a/multi_user/operators.py b/multi_user/operators.py index 424418f..22ac9cb 100644 --- a/multi_user/operators.py +++ b/multi_user/operators.py @@ -1,3 +1,21 @@ +# ##### 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 asyncio import logging import os diff --git a/multi_user/preferences.py b/multi_user/preferences.py index 3230b6d..7efd6ba 100644 --- a/multi_user/preferences.py +++ b/multi_user/preferences.py @@ -1,3 +1,21 @@ +# ##### 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 logging import bpy diff --git a/multi_user/presence.py b/multi_user/presence.py index 5494e19..975193d 100644 --- a/multi_user/presence.py +++ b/multi_user/presence.py @@ -1,3 +1,21 @@ +# ##### 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 copy import logging import math diff --git a/multi_user/ui.py b/multi_user/ui.py index 21f4d48..26f5c8d 100644 --- a/multi_user/ui.py +++ b/multi_user/ui.py @@ -1,3 +1,21 @@ +# ##### 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 from . import operators, utils diff --git a/multi_user/utils.py b/multi_user/utils.py index db96e92..5da0db8 100644 --- a/multi_user/utils.py +++ b/multi_user/utils.py @@ -1,3 +1,21 @@ +# ##### 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 json import logging import os From 2484028b5a08fbfa3e596657fbd1c79e326fdf82 Mon Sep 17 00:00:00 2001 From: Swann Date: Fri, 20 Mar 2020 16:14:54 +0100 Subject: [PATCH 033/129] feat: cleanup --- multi_user/libs/dump_anything.py | 18 +++ multi_user/libs/overrider.py | 219 ------------------------------- 2 files changed, 18 insertions(+), 219 deletions(-) delete mode 100644 multi_user/libs/overrider.py diff --git a/multi_user/libs/dump_anything.py b/multi_user/libs/dump_anything.py index 9c4f3fc..47a77df 100644 --- a/multi_user/libs/dump_anything.py +++ b/multi_user/libs/dump_anything.py @@ -1,3 +1,21 @@ +# ##### 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 bpy.types as T import mathutils diff --git a/multi_user/libs/overrider.py b/multi_user/libs/overrider.py deleted file mode 100644 index 964833d..0000000 --- a/multi_user/libs/overrider.py +++ /dev/null @@ -1,219 +0,0 @@ -""" -Context Manager allowing temporary override of attributes - -````python -import bpy -from overrider import Overrider - -with Overrider(name='bpy_', parent=bpy) as bpy_: - # set preview render settings - bpy_.context.scene.render.use_file_extension = False - bpy_.context.scene.render.resolution_x = 512 - bpy_.context.scene.render.resolution_y = 512 - bpy_.context.scene.render.use_file_extension = False - bpy_.context.scene.render.image_settings.file_format = "JPEG" - bpy_.context.scene.layers[10] = False - - frame_start = action.frame_range[0] - frame_end = action.frame_range[1] - if begin_frame is not None: - frame_start = begin_frame - if end_frame is not None: - frame_end = end_frame - - # render - window = bpy_.data.window_managers[0].windows[0] - screen = bpy_.data.window_managers[0].windows[0].screen - area = next(area for area in screen.areas if area.type == 'VIEW_3D') - space = next(space for space in area.spaces if space.type == 'VIEW_3D') - - space.viewport_shade = 'MATERIAL' - space.region_3d.view_perspective = 'CAMERA' - - override_context = { - "window": window._real_value_(), - "screen": screen._real_value_() - } - - if frame_start == frame_end: - bpy.context.scene.frame_set(int(frame_start)) - bpy_.context.scene.render.filepath = os.path.join(directory, "icon.jpg") - bpy.ops.render.opengl(override_context, write_still=True) - - else: - for icon_index, frame_number in enumerate(range(int(frame_start), int(frame_end) + 1)): - bpy.context.scene.frame_set(frame_number) - bpy.context.scene.render.filepath = os.path.join(directory, "icon", "{:04d}.jpg".format(icon_index)) - bpy.ops.render.opengl(override_context, write_still=True) -```` -""" -from collections import OrderedDict - - -class OverrideIter: - - def __init__(self, parent): - self.parent = parent - self.index = -1 - - def __next__(self): - self.index += 1 - try: - return self.parent[self.index] - except IndexError as e: - raise StopIteration - - -class OverrideBase: - - def __init__(self, context_manager, name=None, parent=None): - self._name__ = name - self._context_manager_ = context_manager - self._parent_ = parent - self._changed_attributes_ = OrderedDict() - self._changed_items_ = OrderedDict() - self._children_ = list() - self._original_value_ = self._real_value_() - - def __repr__(self): - return "<{}({})>".format(self.__class__.__name__, self._path_) - - @property - def _name_(self): - raise NotImplementedError() - - @property - def _path_(self): - if isinstance(self._parent_, OverrideBase): - return self._parent_._path_ + self._name_ - - return self._name_ - - def _real_value_(self): - raise NotImplementedError() - - def _restore_(self): - for attribute, original_value in reversed(self._changed_attributes_.items()): - setattr(self._real_value_(), attribute, original_value) - - for item, original_value in reversed(self._changed_items_.items()): - self._real_value_()[item] = original_value - - def __getattr__(self, attr): - new_attribute = OverrideAttribute(self._context_manager_, name=attr, parent=self) - self._children_.append(new_attribute) - return new_attribute - - def __getitem__(self, item): - new_item = OverrideItem(self._context_manager_, name=item, parent=self) - self._children_.append(new_item) - return new_item - - def __iter__(self): - return OverrideIter(self) - - def __setattr__(self, attr, value): - if attr in ( - '_name__', - '_context_manager_', - '_parent_', - '_children_', - '_original_value_', - '_changed_attributes_', - '_changed_items_' - ): - self.__dict__[attr] = value - return - - if attr not in self._changed_attributes_.keys(): - self._changed_attributes_[attr] = getattr(self._real_value_(), attr) - self._context_manager_.register_as_changed(self) - - setattr(self._real_value_(), attr, value) - - def __setitem__(self, item, value): - if item not in self._changed_items_.keys(): - self._changed_items_[item] = self._real_value_()[item] - self._context_manager_.register_as_changed(self) - - self._real_value_()[item] = value - - def __eq__(self, other): - return self._real_value_() == other - - def __gt__(self, other): - return self._real_value_() > other - - def __lt__(self, other): - return self._real_value_() < other - - def __ge__(self, other): - return self._real_value_() >= other - - def __le__(self, other): - return self._real_value_() <= other - - def __call__(self, *args, **kwargs): - # TODO : surround str value with quotes - arguments = list([str(arg) for arg in args]) + ['{}={}'.format(key, value) for key, value in kwargs.items()] - arguments = ', '.join(arguments) - raise RuntimeError('Overrider does not allow call to {}({})'.format(self._path_, arguments)) - - -class OverrideRoot(OverrideBase): - - @property - def _name_(self): - return self._name__ - - def _real_value_(self): - return self._parent_ - - -class OverrideAttribute(OverrideBase): - - @property - def _name_(self): - return '.{}'.format(self._name__) - - def _real_value_(self): - return getattr(self._parent_._real_value_(), self._name__) - - -class OverrideItem(OverrideBase): - - @property - def _name_(self): - if isinstance(self._name__, str): - return '["{}"]'.format(self._name__) - - return '[{}]'.format(self._name__) - - def _real_value_(self): - return self._parent_._real_value_()[self._name__] - - -class Overrider: - def __init__(self, name, parent): - self.name = name - self.parent = parent - self.override = None - self.registered_overrides = list() - - def __enter__(self): - self.override = OverrideRoot( - context_manager=self, - parent=self.parent, - name=self.name - ) - return self.override - - def __exit__(self, exc_type, exc_val, exc_tb): - self.restore() - - def register_as_changed(self, override): - self.registered_overrides.append(override) - - def restore(self): - for override in reversed(self.registered_overrides): - override._restore_() From 8ce53b8413db1f51792a4006d94179e6b79ac1a1 Mon Sep 17 00:00:00 2001 From: Swann Date: Fri, 20 Mar 2020 19:31:48 +0100 Subject: [PATCH 034/129] feat: bl_object clean Related to #29 --- multi_user/bl_types/bl_armature.py | 1 - multi_user/bl_types/bl_object.py | 62 ++---------------------------- multi_user/libs/dump_anything.py | 24 +++++++++--- multi_user/libs/replication | 2 +- 4 files changed, 23 insertions(+), 66 deletions(-) diff --git a/multi_user/bl_types/bl_armature.py b/multi_user/bl_types/bl_armature.py index c609be8..c4abaf7 100644 --- a/multi_user/bl_types/bl_armature.py +++ b/multi_user/bl_types/bl_armature.py @@ -19,7 +19,6 @@ import bpy import mathutils -from ..libs.overrider import Overrider from .. import utils from .. import presence, operators from .bl_datablock import BlDatablock diff --git a/multi_user/bl_types/bl_object.py b/multi_user/bl_types/bl_object.py index f4bfd76..9f17eb1 100644 --- a/multi_user/bl_types/bl_object.py +++ b/multi_user/bl_types/bl_object.py @@ -26,22 +26,6 @@ from .bl_datablock import BlDatablock logger = logging.getLogger(__name__) -def load_constraints(target, data): - for local_constraint in target.constraints: - if local_constraint.name not in data: - target.constraints.remove(local_constraint) - - for constraint in data: - target_constraint = target.constraints.get(constraint) - - if not target_constraint: - target_constraint = target.constraints.new( - data[constraint]['type']) - - utils.dump_anything.load( - target_constraint, data[constraint]) - - def load_pose(target_bone, data): target_bone.rotation_mode = data['rotation_mode'] @@ -105,32 +89,7 @@ class BlObject(BlDatablock): def load_implementation(self, data, target): # Load transformation data - rot_mode = 'rotation_quaternion' if data['rotation_mode'] == 'QUATERNION' else 'rotation_euler' - target.rotation_mode = data['rotation_mode'] - target.location = data['location'] - setattr(target, rot_mode, data[rot_mode]) - target.scale = data['scale'] - - target.name = data["name"] - # Load modifiers - if hasattr(target, 'modifiers'): - # TODO: smarter selective update - target.modifiers.clear() - - for modifier in data['modifiers']: - target_modifier = target.modifiers.get(modifier) - - if not target_modifier: - target_modifier = target.modifiers.new( - data['modifiers'][modifier]['name'], data['modifiers'][modifier]['type']) - - utils.dump_anything.load( - target_modifier, data['modifiers'][modifier]) - - # Load constraints - # Object - if hasattr(target, 'constraints') and 'constraints' in data: - load_constraints(target, data['constraints']) + utils.dump_anything.load(target, data) # Pose if 'pose' in data: @@ -153,28 +112,14 @@ class BlObject(BlDatablock): bone_data = data['pose']['bones'].get(bone) if 'constraints' in bone_data.keys(): - load_constraints( - target_bone, bone_data['constraints']) + utils.dump_anything.load(target_bone, bone_data['constraints']) + load_pose(target_bone, bone_data) if 'bone_index' in bone_data.keys(): target_bone.bone_group = target.pose.bone_group[bone_data['bone_group_index']] - # Load relations - if 'children' in data.keys(): - for child in data['children']: - bpy.data.objects[child].parent = self.pointer - - # Load empty representation - target.empty_display_size = data['empty_display_size'] - target.empty_display_type = data['empty_display_type'] - - # Instancing - target.instance_type = data['instance_type'] - if data['instance_type'] == 'COLLECTION': - target.instance_collection = bpy.data.collections[data['instance_collection']] - # vertex groups if 'vertex_groups' in data: target.vertex_groups.clear() @@ -238,7 +183,6 @@ class BlObject(BlDatablock): data["modifiers"] = {} for index, modifier in enumerate(pointer.modifiers): data["modifiers"][modifier.name] = dumper.dump(modifier) - data["modifiers"][modifier.name]['m_index'] = index # CONSTRAINTS # OBJECT diff --git a/multi_user/libs/dump_anything.py b/multi_user/libs/dump_anything.py index 47a77df..caa705e 100644 --- a/multi_user/libs/dump_anything.py +++ b/multi_user/libs/dump_anything.py @@ -15,11 +15,13 @@ # # ##### END GPL LICENSE BLOCK ##### +import logging import bpy import bpy.types as T import mathutils +logger = logging.getLogger(__name__) def remove_items_from_dict(d, keys, recursive=False): copy = dict(d) @@ -316,19 +318,29 @@ class Loader: CONSTRUCTOR_ADD = "add" constructors = { - T.ColorRampElement: (CONSTRUCTOR_NEW, ["position"]), - T.ParticleSettingsTextureSlot: (CONSTRUCTOR_ADD, []) + T.ColorRampElement: (CONSTRUCTOR_NEW, ["position"], True), + T.ParticleSettingsTextureSlot: (CONSTRUCTOR_ADD, [], False), + T.Modifier: (CONSTRUCTOR_NEW, ["name", "type"], True), + T.Constraint: (CONSTRUCTOR_NEW, ["type"], True), + # T.VertexGroup: (CONSTRUCTOR_NEW, ["name"], True), } element_type = element.bl_rna_property.fixed_type + constructor = constructors.get(type(element_type)) + if constructor is None: # collection type not supported return + + # Try to clear existing + if constructor[2]: + getattr(element.read(), 'clear')() + for dumped_element in dump.values(): try: constructor_parameters = [dumped_element[name] for name in constructor[1]] except KeyError: - print("Collection load error, missing parameters.") + logger.error("Collection load error, missing parameters.") continue # TODO handle error new_element = getattr(element.read(), constructor[0])( *constructor_parameters) @@ -354,6 +366,8 @@ class Loader: pointer.write(bpy.data.meshes.get(dump)) elif isinstance(rna_property_type, T.Material): pointer.write(bpy.data.materials.get(dump)) + elif isinstance(rna_property_type, T.Collection): + pointer.write(bpy.data.collections.get(dump)) def _load_matrix(self, matrix, dump): matrix.write(mathutils.Matrix(dump)) @@ -386,8 +400,8 @@ class Loader: continue # TODO error handling try: self._load_any(default.extend(k), v) - except: - pass + except Exception as e: + logger.error(e) @property def match_subset_all(self): diff --git a/multi_user/libs/replication b/multi_user/libs/replication index 7077c12..42b3a31 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit 7077c125627d6cb2e7160db1d0e882d35980e4c9 +Subproject commit 42b3a31b8ec44115f8fbc3697bdeeee74e608c6f From fb0760928e1e125875c4db17dee929e20d85c784 Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 23 Mar 2020 11:04:06 +0100 Subject: [PATCH 035/129] feat: verbose errors --- multi_user/bl_types/bl_object.py | 10 +++++----- multi_user/libs/dump_anything.py | 33 +++++++++++++++++--------------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/multi_user/bl_types/bl_object.py b/multi_user/bl_types/bl_object.py index 9f17eb1..0387a65 100644 --- a/multi_user/bl_types/bl_object.py +++ b/multi_user/bl_types/bl_object.py @@ -261,18 +261,18 @@ class BlObject(BlDatablock): data['vertex_groups'] = vg_data # SHAPE KEYS - pointer_data = pointer.data - if hasattr(pointer_data, 'shape_keys') and pointer_data.shape_keys: + object_data = pointer.data + if hasattr(object_data, 'shape_keys') and object_data.shape_keys: dumper = utils.dump_anything.Dumper() dumper.depth = 2 dumper.include_filter = [ 'reference_key', 'use_relative' ] - data['shape_keys'] = dumper.dump(pointer_data.shape_keys) - data['shape_keys']['reference_key'] = pointer_data.shape_keys.reference_key.name + data['shape_keys'] = dumper.dump(object_data.shape_keys) + data['shape_keys']['reference_key'] = object_data.shape_keys.reference_key.name key_blocks = {} - for key in pointer_data.shape_keys.key_blocks: + for key in object_data.shape_keys.key_blocks: dumper.depth = 3 dumper.include_filter = [ 'name', diff --git a/multi_user/libs/dump_anything.py b/multi_user/libs/dump_anything.py index caa705e..de239ed 100644 --- a/multi_user/libs/dump_anything.py +++ b/multi_user/libs/dump_anything.py @@ -93,8 +93,9 @@ def _load_filter_default(default): class Dumper: + # TODO: support occlude readonly def __init__(self): - self.verbose = False + self.verbose = True self.depth = 1 self.keep_compounds_as_leaves = False self.accept_read_only = True @@ -103,7 +104,6 @@ class Dumper: self.type_subset = self.match_subset_all self.include_filter = [] self.exclude_filter = [] - # self._atomic_types = [] # TODO future option? def dump(self, any): return self._dump_any(any, 0) @@ -195,7 +195,8 @@ class Dumper: if (self.include_filter and p not in self.include_filter): return False getattr(default, p) - except AttributeError: + except AttributeError as err: + logger.error(err) return False if p.startswith("__"): return False @@ -258,14 +259,15 @@ class BlenderAPIElement: def write(self, value): # take precaution if property is read-only - try: - if self.sub_element_name: - setattr(self.api_element, self.sub_element_name, value) - else: - self.api_element = value - except AttributeError as err: - if not self.occlude_read_only: - raise err + if self.api_element.is_property_readonly(self.sub_element_name) and \ + self.occlude_read_only: + logger.error(f"Skipping {self.sub_element_name}") + return + + if self.sub_element_name: + setattr(self.api_element, self.sub_element_name, value) + else: + self.api_element = value def extend(self, element_name): return BlenderAPIElement(self.read(), element_name) @@ -282,7 +284,7 @@ class BlenderAPIElement: class Loader: def __init__(self): self.type_subset = self.match_subset_all - self.occlude_read_only = True + self.occlude_read_only = False self.order = ['*'] def load(self, dst_data, src_dumped_data): @@ -307,6 +309,7 @@ class Loader: for i in range(len(dump)): element.read()[i] = dump[i] except AttributeError as err: + logger.error(err) if not self.occlude_read_only: raise err @@ -397,11 +400,11 @@ class Loader: for k in self._ordered_keys(dump.keys()): v = dump[k] if not hasattr(default.read(), k): - continue # TODO error handling + logger.error(f"Load default, skipping {default} : {k}") try: self._load_any(default.extend(k), v) - except Exception as e: - logger.error(e) + except Exception as err: + logger.error(f"Cannot load {k}: {err}") @property def match_subset_all(self): From 90a44eb5dbc3bda4a0c560f555de4ef9b9248211 Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 23 Mar 2020 12:09:59 +0100 Subject: [PATCH 036/129] fix resolve --- multi_user/bl_types/bl_datablock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index 3a5d8b3..a645488 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -94,7 +94,7 @@ class BlDatablock(ReplicatedDatablock): self.diff_method = DIFF_BINARY - def _resolve(self): + def resolve(self): datablock_ref = None datablock_root = getattr(bpy.data, self.bl_id) datablock_ref = utils.find_from_attr('uuid', self.uuid, datablock_root) From b55700862f6ead1e3827b3f918da974e0fdcd41d Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 23 Mar 2020 13:49:47 +0100 Subject: [PATCH 037/129] fix: loadimg error --- multi_user/libs/dump_anything.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/multi_user/libs/dump_anything.py b/multi_user/libs/dump_anything.py index de239ed..bd72dea 100644 --- a/multi_user/libs/dump_anything.py +++ b/multi_user/libs/dump_anything.py @@ -259,12 +259,9 @@ class BlenderAPIElement: def write(self, value): # take precaution if property is read-only - if self.api_element.is_property_readonly(self.sub_element_name) and \ - self.occlude_read_only: - logger.error(f"Skipping {self.sub_element_name}") - return + if self.sub_element_name and \ + not self.api_element.is_property_readonly(self.sub_element_name): - if self.sub_element_name: setattr(self.api_element, self.sub_element_name, value) else: self.api_element = value From 01faa94a9a369641f809f337ec5be01a14bcd767 Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 23 Mar 2020 15:29:34 +0100 Subject: [PATCH 038/129] fix: resolve error --- multi_user/bl_types/bl_datablock.py | 2 +- multi_user/bl_types/bl_object.py | 2 +- multi_user/libs/dump_anything.py | 10 +++++----- multi_user/operators.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index a645488..3a5d8b3 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -94,7 +94,7 @@ class BlDatablock(ReplicatedDatablock): self.diff_method = DIFF_BINARY - def resolve(self): + def _resolve(self): datablock_ref = None datablock_root = getattr(bpy.data, self.bl_id) datablock_ref = utils.find_from_attr('uuid', self.uuid, datablock_root) diff --git a/multi_user/bl_types/bl_object.py b/multi_user/bl_types/bl_object.py index 0387a65..2abb6ad 100644 --- a/multi_user/bl_types/bl_object.py +++ b/multi_user/bl_types/bl_object.py @@ -52,7 +52,7 @@ class BlObject(BlDatablock): instance.uuid = self.uuid return instance - # Object specific constructor... + # TODO: refactoring if "data" not in data: pass elif data["data"] in bpy.data.meshes.keys(): diff --git a/multi_user/libs/dump_anything.py b/multi_user/libs/dump_anything.py index bd72dea..051e0e9 100644 --- a/multi_user/libs/dump_anything.py +++ b/multi_user/libs/dump_anything.py @@ -196,7 +196,7 @@ class Dumper: return False getattr(default, p) except AttributeError as err: - logger.error(err) + logger.debug(err) return False if p.startswith("__"): return False @@ -306,7 +306,7 @@ class Loader: for i in range(len(dump)): element.read()[i] = dump[i] except AttributeError as err: - logger.error(err) + logger.debug(err) if not self.occlude_read_only: raise err @@ -340,7 +340,7 @@ class Loader: constructor_parameters = [dumped_element[name] for name in constructor[1]] except KeyError: - logger.error("Collection load error, missing parameters.") + logger.debug("Collection load error, missing parameters.") continue # TODO handle error new_element = getattr(element.read(), constructor[0])( *constructor_parameters) @@ -397,11 +397,11 @@ class Loader: for k in self._ordered_keys(dump.keys()): v = dump[k] if not hasattr(default.read(), k): - logger.error(f"Load default, skipping {default} : {k}") + logger.debug(f"Load default, skipping {default} : {k}") try: self._load_any(default.extend(k), v) except Exception as err: - logger.error(f"Cannot load {k}: {err}") + logger.debug(f"Cannot load {k}: {err}") @property def match_subset_all(self): diff --git a/multi_user/operators.py b/multi_user/operators.py index 22ac9cb..1d302c1 100644 --- a/multi_user/operators.py +++ b/multi_user/operators.py @@ -488,7 +488,7 @@ def sanitize_deps_graph(dummy): if client and client.state['STATE'] in [STATE_ACTIVE]: for node_key in client.list(): - client.get(node_key).resolve() + client.get(node_key)._resolve() @persistent From 90d4bb0e47628af6aacef1726d4d4f9af277a2c4 Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 23 Mar 2020 17:55:10 +0100 Subject: [PATCH 039/129] feat: mesh dump refactoring wip --- multi_user/bl_types/bl_mesh.py | 112 ++++++++++++++++++--------------- multi_user/utils.py | 6 +- 2 files changed, 67 insertions(+), 51 deletions(-) diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index d9195ae..6842623 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -19,82 +19,90 @@ import bpy import bmesh import mathutils +import logging +import numpy as np from .. import utils from ..libs.replication.replication.constants import DIFF_BINARY from .bl_datablock import BlDatablock +logger = logging.getLogger(__name__) def dump_mesh(mesh, data={}): import bmesh mesh_data = data - mesh_buffer = bmesh.new() - # https://blog.michelanders.nl/2016/02/copying-vertices-to-numpy-arrays-in_4.html - mesh_buffer.from_mesh(mesh) - uv_layer = mesh_buffer.loops.layers.uv.verify() - bevel_layer = mesh_buffer.verts.layers.bevel_weight.verify() - skin_layer = mesh_buffer.verts.layers.skin.verify() + # VERTICES + start = utils.current_milli_time() - verts = {} - for vert in mesh_buffer.verts: - v = {} - v["co"] = list(vert.co) + vert_count = len(mesh.vertices) + shape = (vert_count, 3) - # vert metadata - v['bevel'] = vert[bevel_layer] - v['normal'] = list(vert.normal) - # v['skin'] = list(vert[skin_layer]) + verts_co = np.empty(vert_count*3, dtype=np.float64) + mesh.vertices.foreach_get('co', verts_co) + # verts_co.shape = shape + mesh_data["verts_co"] = verts_co.tobytes() - verts[str(vert.index)] = v + verts_normal = np.empty(vert_count*3, dtype=np.float64) + mesh.vertices.foreach_get('normal', verts_normal) + # verts_normal.shape = shape + mesh_data["verts_normal"] = verts_normal.tobytes() + + verts_bevel = np.empty(vert_count, dtype=np.float64) + mesh.vertices.foreach_get('bevel_weight', verts_bevel) + mesh_data["verts_bevel"] = verts_bevel.tobytes() - mesh_data["verts"] = verts + logger.error(f"verts {utils.current_milli_time()-start} ms") + + # EDGES + start = utils.current_milli_time() + edge_count = len(mesh.edges) + + edges_vert = np.empty(edge_count*2, dtype=np.int) + mesh.edges.foreach_get('vertices', edges_vert) + # edges_vert.shape = (edge_count, 2) + mesh_data["egdes_vert"] = edges_vert.tobytes() - edges = {} - for edge in mesh_buffer.edges: - e = {} - e["verts"] = [edge.verts[0].index, edge.verts[1].index] + logger.error(f"edges {utils.current_milli_time()-start} ms") - # Edge metadata - e["smooth"] = edge.smooth + start = utils.current_milli_time() - edges[edge.index] = e - mesh_data["edges"] = edges + # POLYGONS + start = utils.current_milli_time() + poly_count = len(mesh.polygons) - faces = {} - for face in mesh_buffer.faces: - f = {} - fverts = [] - for vert in face.verts: - fverts.append(vert.index) + poly_mat = np.empty(poly_count, dtype=np.int) + mesh.polygons.foreach_get("material_index", poly_mat) + mesh_data["poly_mat"] = poly_mat.tobytes() - f["verts"] = fverts - f["material_index"] = face.material_index - f["smooth"] = face.smooth - f["normal"] = list(face.normal) - f["index"] = face.index + poly_loop_start = np.empty(poly_count, dtype=np.int) + mesh.polygons.foreach_get("loop_start", poly_loop_start) + mesh_data["poly_loop_start"] = poly_loop_start.tobytes() - uvs = [] - # Face metadata - for loop in face.loops: - loop_uv = loop[uv_layer] + poly_loop_total = np.empty(poly_count, dtype=np.int) + mesh.polygons.foreach_get("loop_total", poly_loop_total) + mesh_data["poly_loop_total"] = poly_loop_total.tobytes() - uvs.append(list(loop_uv.uv)) + poly_smooth = np.empty(poly_count, dtype=np.bool) + mesh.polygons.foreach_get("use_smooth", poly_smooth) + mesh_data["poly_smooth"] = poly_smooth.tobytes() + + logger.error(f"polygons {utils.current_milli_time()-start} ms") - f["uv"] = uvs - faces[face.index] = f + # UV + start = utils.current_milli_time() + mesh_data['uv_layers'] = {} + for layer in mesh.uv_layers: + mesh_data['uv_layers'][layer.name] = {} + + uv_layer = np.empty(len(layer.data)*2, dtype=np.float64) + layer.data.foreach_get("uv", uv_layer) - mesh_data["faces"] = faces - - uv_layers = [] - for uv_layer in mesh.uv_layers: - uv_layers.append(uv_layer.name) - - mesh_data["uv_layers"] = uv_layers - # return mesh_data + mesh_data['uv_layers'][layer.name]['data'] = uv_layer.tobytes() + logger.error(f"uvs {utils.current_milli_time()-start} ms") class BlMesh(BlDatablock): bl_id = "meshes" @@ -174,8 +182,12 @@ class BlMesh(BlDatablock): 'use_auto_smooth', 'auto_smooth_angle' ] + + data = dumper.dump(pointer) + dump_mesh(pointer, data) + # Fix material index m_list = [] for material in pointer.materials: diff --git a/multi_user/utils.py b/multi_user/utils.py index 5da0db8..cd69456 100644 --- a/multi_user/utils.py +++ b/multi_user/utils.py @@ -22,6 +22,7 @@ import os import random import string import sys +import time from uuid import uuid4 from collections.abc import Iterable @@ -175,4 +176,7 @@ def resolve_from_id(id, optionnal_type=None): def get_preferences(): - return bpy.context.preferences.addons[__package__].preferences \ No newline at end of file + return bpy.context.preferences.addons[__package__].preferences + +def current_milli_time(): + return int(round(time.time() * 1000)) \ No newline at end of file From 01fdf7b35b187c4a6840dcddb9b936eae295fb2a Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 23 Mar 2020 21:49:28 +0100 Subject: [PATCH 040/129] feat: mesh implementation cleanup progress --- multi_user/bl_types/bl_mesh.py | 115 ++++++++++++++++++++------------- 1 file changed, 70 insertions(+), 45 deletions(-) diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index 6842623..be5ac14 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -29,10 +29,7 @@ from .bl_datablock import BlDatablock logger = logging.getLogger(__name__) def dump_mesh(mesh, data={}): - import bmesh - mesh_data = data - # https://blog.michelanders.nl/2016/02/copying-vertices-to-numpy-arrays-in_4.html # VERTICES start = utils.current_milli_time() @@ -45,14 +42,14 @@ def dump_mesh(mesh, data={}): # verts_co.shape = shape mesh_data["verts_co"] = verts_co.tobytes() - verts_normal = np.empty(vert_count*3, dtype=np.float64) - mesh.vertices.foreach_get('normal', verts_normal) - # verts_normal.shape = shape - mesh_data["verts_normal"] = verts_normal.tobytes() + # verts_normal = np.empty(vert_count*3, dtype=np.float64) + # mesh.vertices.foreach_get('normal', verts_normal) + # # verts_normal.shape = shape + # mesh_data["verts_normal"] = verts_normal.tobytes() - verts_bevel = np.empty(vert_count, dtype=np.float64) - mesh.vertices.foreach_get('bevel_weight', verts_bevel) - mesh_data["verts_bevel"] = verts_bevel.tobytes() + # verts_bevel = np.empty(vert_count, dtype=np.float64) + # mesh.vertices.foreach_get('bevel_weight', verts_bevel) + # mesh_data["verts_bevel"] = verts_bevel.tobytes() logger.error(f"verts {utils.current_milli_time()-start} ms") @@ -64,7 +61,7 @@ def dump_mesh(mesh, data={}): mesh.edges.foreach_get('vertices', edges_vert) # edges_vert.shape = (edge_count, 2) mesh_data["egdes_vert"] = edges_vert.tobytes() - + mesh_data["egdes_count"] = len(mesh.edges) logger.error(f"edges {utils.current_milli_time()-start} ms") start = utils.current_milli_time() @@ -104,6 +101,27 @@ def dump_mesh(mesh, data={}): logger.error(f"uvs {utils.current_milli_time()-start} ms") + # LOOPS + start = utils.current_milli_time() + loop_count = len(mesh.loops) + + # loop_bitangent = np.empty(loop_count*3, dtype=np.float64) + # mesh.loops.foreach_get("bitangent", loop_bitangent) + + loop_tangent = np.empty(loop_count*3, dtype=np.float64) + mesh.loops.foreach_get("tangent", loop_tangent) + mesh_data["loop_tangent"] = loop_tangent.tobytes() + + loop_normal = np.empty(loop_count*3, dtype=np.float64) + mesh.loops.foreach_get("normal", loop_normal) + mesh_data["loop_normal"] = loop_normal.tobytes() + + loop_vertex_index = np.empty(loop_count, dtype=np.int) + mesh.loops.foreach_get("vertex_index", loop_vertex_index) + mesh_data["loop_vertex_index"] = loop_vertex_index.tobytes() + + logger.error(f"loops {utils.current_milli_time()-start} ms") + class BlMesh(BlDatablock): bl_id = "meshes" bl_class = bpy.types.Mesh @@ -127,49 +145,56 @@ class BlMesh(BlDatablock): target.materials.append(bpy.data.materials[m]) # 2 - LOAD GEOMETRY - mesh_buffer = bmesh.new() + # 2.a - VERTS + vertices = np.frombuffer(data["verts_co"], dtype=np.float64) + vert_count = int(len(vertices)/3) - for i in data["verts"]: - v = mesh_buffer.verts.new(data["verts"][i]["co"]) - v.normal = data["verts"][i]["normal"] - mesh_buffer.verts.ensure_lookup_table() + nb_vert_to_add = vert_count - len(target.vertices) + target.vertices.add(nb_vert_to_add) + target.vertices.foreach_set('co', vertices) - for i in data["edges"]: - verts = mesh_buffer.verts - v1 = data["edges"][i]["verts"][0] - v2 = data["edges"][i]["verts"][1] - edge = mesh_buffer.edges.new([verts[v1], verts[v2]]) - edge.smooth = data["edges"][i]["smooth"] - - mesh_buffer.edges.ensure_lookup_table() - for p in data["faces"]: - verts = [] - for v in data["faces"][p]["verts"]: - verts.append(mesh_buffer.verts[v]) + # 2.b - EDGES + egdes_vert = np.frombuffer(data["egdes_vert"], dtype=np.int) + edge_count = data["egdes_count"] - if len(verts) > 0: - f = mesh_buffer.faces.new(verts) + nb_edges_to_add = edge_count - len(target.edges) + target.edges.add(nb_edges_to_add) - uv_layer = mesh_buffer.loops.layers.uv.verify() + target.edges.foreach_set("vertices", egdes_vert) - f.smooth = data["faces"][p]["smooth"] - f.normal = data["faces"][p]["normal"] - f.index = data["faces"][p]["index"] - f.material_index = data["faces"][p]['material_index'] - # UV loading - for i, loop in enumerate(f.loops): - loop_uv = loop[uv_layer] - loop_uv.uv = data["faces"][p]["uv"][i] - mesh_buffer.faces.ensure_lookup_table() - mesh_buffer.to_mesh(target) + # 2.b - POLY + poly_smooth = np.frombuffer(data["poly_smooth"], dtype=np.int) + poly_count = len(poly_smooth) + + nb_poly_to_add = poly_count - len(target.polygons) + target.polygons.add(nb_poly_to_add) + + poly_loop_start = np.frombuffer(data["poly_loop_start"], dtype=np.int) + target.polygons.foreach_set("loop_start", poly_loop_start) + + poly_loop_total = np.frombuffer(data["poly_loop_total"], dtype=np.int) + target.polygons.foreach_set("loop_total", poly_loop_total) + + + # 2.c - LOOPS + loop_vertex_index = np.frombuffer(data['loop_vertex_index'], dtype=np.float64) + loops_count = len(loop_vertex_index) + + nb_loop_to_add = loops_count - len(target.loops) + target.loops.add(nb_loop_to_add) + + target.loops.foreach_set("vertex_intex", loop_vertex_index) + + + # target.loops.foreach_set("vertex_index", loops_vert_idx) + # target.polygons.foreach_set("loop_start", faces_loop_start) + # target.polygons.foreach_set("loop_total", faces_loop_total) # 3 - LOAD METADATA # uv's - utils.dump_anything.load(target.uv_layers, data['uv_layers']) - - bevel_layer = mesh_buffer.verts.layers.bevel_weight.verify() - skin_layer = mesh_buffer.verts.layers.skin.verify() + # utils.dump_anything.load(target.uv_layers, data['uv_layers']) + target.update() utils.dump_anything.load(target, data) def dump_implementation(self, data, pointer=None): From 757dbfd6ab723367137f39a0915280b71f7be671 Mon Sep 17 00:00:00 2001 From: Swann Date: Tue, 24 Mar 2020 19:09:02 +0100 Subject: [PATCH 041/129] feat: mesh loading progress --- multi_user/bl_types/bl_mesh.py | 110 ++++++++++++++++----------------- 1 file changed, 53 insertions(+), 57 deletions(-) diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index be5ac14..2c44377 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -59,7 +59,6 @@ def dump_mesh(mesh, data={}): edges_vert = np.empty(edge_count*2, dtype=np.int) mesh.edges.foreach_get('vertices', edges_vert) - # edges_vert.shape = (edge_count, 2) mesh_data["egdes_vert"] = edges_vert.tobytes() mesh_data["egdes_count"] = len(mesh.edges) logger.error(f"edges {utils.current_milli_time()-start} ms") @@ -69,10 +68,11 @@ def dump_mesh(mesh, data={}): # POLYGONS start = utils.current_milli_time() poly_count = len(mesh.polygons) + mesh_data["poly_count"] = poly_count - poly_mat = np.empty(poly_count, dtype=np.int) - mesh.polygons.foreach_get("material_index", poly_mat) - mesh_data["poly_mat"] = poly_mat.tobytes() + # poly_mat = np.empty(poly_count, dtype=np.int) + # mesh.polygons.foreach_get("material_index", poly_mat) + # mesh_data["poly_mat"] = poly_mat.tobytes() poly_loop_start = np.empty(poly_count, dtype=np.int) mesh.polygons.foreach_get("loop_start", poly_loop_start) @@ -88,6 +88,27 @@ def dump_mesh(mesh, data={}): logger.error(f"polygons {utils.current_milli_time()-start} ms") + # LOOPS + start = utils.current_milli_time() + loop_count = len(mesh.loops) + mesh_data["loop_count"] = loop_count + # loop_bitangent = np.empty(loop_count*3, dtype=np.float64) + # mesh.loops.foreach_get("bitangent", loop_bitangent) + + # loop_tangent = np.empty(loop_count*3, dtype=np.float64) + # mesh.loops.foreach_get("tangent", loop_tangent) + # mesh_data["loop_tangent"] = loop_tangent.tobytes() + + loop_normal = np.empty(loop_count*3, dtype=np.float64) + mesh.loops.foreach_get("normal", loop_normal) + mesh_data["loop_normal"] = loop_normal.tobytes() + + loop_vertex_index = np.empty(loop_count, dtype=np.int) + mesh.loops.foreach_get("vertex_index", loop_vertex_index) + mesh_data["loop_vertex_index"] = loop_vertex_index.tobytes() + + logger.error(f"loops {utils.current_milli_time()-start} ms") + # UV start = utils.current_milli_time() mesh_data['uv_layers'] = {} @@ -101,26 +122,7 @@ def dump_mesh(mesh, data={}): logger.error(f"uvs {utils.current_milli_time()-start} ms") - # LOOPS - start = utils.current_milli_time() - loop_count = len(mesh.loops) - - # loop_bitangent = np.empty(loop_count*3, dtype=np.float64) - # mesh.loops.foreach_get("bitangent", loop_bitangent) - - loop_tangent = np.empty(loop_count*3, dtype=np.float64) - mesh.loops.foreach_get("tangent", loop_tangent) - mesh_data["loop_tangent"] = loop_tangent.tobytes() - - loop_normal = np.empty(loop_count*3, dtype=np.float64) - mesh.loops.foreach_get("normal", loop_normal) - mesh_data["loop_normal"] = loop_normal.tobytes() - - loop_vertex_index = np.empty(loop_count, dtype=np.int) - mesh.loops.foreach_get("vertex_index", loop_vertex_index) - mesh_data["loop_vertex_index"] = loop_vertex_index.tobytes() - - logger.error(f"loops {utils.current_milli_time()-start} ms") + class BlMesh(BlDatablock): bl_id = "meshes" @@ -145,57 +147,51 @@ class BlMesh(BlDatablock): target.materials.append(bpy.data.materials[m]) # 2 - LOAD GEOMETRY + if target.vertices: + target.clear_geometry() + # 2.a - VERTS vertices = np.frombuffer(data["verts_co"], dtype=np.float64) vert_count = int(len(vertices)/3) - - nb_vert_to_add = vert_count - len(target.vertices) - target.vertices.add(nb_vert_to_add) - target.vertices.foreach_set('co', vertices) + target.vertices.add(vert_count) # 2.b - EDGES egdes_vert = np.frombuffer(data["egdes_vert"], dtype=np.int) edge_count = data["egdes_count"] - - nb_edges_to_add = edge_count - len(target.edges) - target.edges.add(nb_edges_to_add) - - target.edges.foreach_set("vertices", egdes_vert) - - # 2.b - POLY - poly_smooth = np.frombuffer(data["poly_smooth"], dtype=np.int) - poly_count = len(poly_smooth) - - nb_poly_to_add = poly_count - len(target.polygons) - target.polygons.add(nb_poly_to_add) - - poly_loop_start = np.frombuffer(data["poly_loop_start"], dtype=np.int) - target.polygons.foreach_set("loop_start", poly_loop_start) - - poly_loop_total = np.frombuffer(data["poly_loop_total"], dtype=np.int) - target.polygons.foreach_set("loop_total", poly_loop_total) - + target.edges.add(edge_count) # 2.c - LOOPS - loop_vertex_index = np.frombuffer(data['loop_vertex_index'], dtype=np.float64) - loops_count = len(loop_vertex_index) - - nb_loop_to_add = loops_count - len(target.loops) - target.loops.add(nb_loop_to_add) + loops_count = data["loop_count"] + target.loops.add(loops_count) - target.loops.foreach_set("vertex_intex", loop_vertex_index) + loop_vertex_index = np.frombuffer(data['loop_vertex_index'], dtype=np.int) + loop_normal = np.frombuffer(data['loop_normal'], dtype=np.float64) + + # 2.b - POLY + poly_count = data["poly_count"] + target.polygons.add(poly_count) + + poly_loop_start = np.frombuffer(data["poly_loop_start"], dtype=np.int) + poly_loop_total = np.frombuffer(data["poly_loop_total"], dtype=np.int) + poly_smooth = np.frombuffer(data["poly_smooth"], dtype=np.bool) + + + target.vertices.foreach_set('co', vertices) + target.edges.foreach_set("vertices", egdes_vert) + target.loops.foreach_set("vertex_index", loop_vertex_index) + target.loops.foreach_set("normal", loop_normal) + target.polygons.foreach_set("loop_total", poly_loop_total) + target.polygons.foreach_set("loop_start", poly_loop_start) + target.polygons.foreach_set("use_smooth", poly_smooth) - # target.loops.foreach_set("vertex_index", loops_vert_idx) - # target.polygons.foreach_set("loop_start", faces_loop_start) - # target.polygons.foreach_set("loop_total", faces_loop_total) # 3 - LOAD METADATA # uv's # utils.dump_anything.load(target.uv_layers, data['uv_layers']) target.update() - utils.dump_anything.load(target, data) + # utils.dump_anything.load(target, data) def dump_implementation(self, data, pointer=None): assert(pointer) From daff548010b0e1f6966927bbb7765a16b2d50e2f Mon Sep 17 00:00:00 2001 From: Swann Date: Tue, 24 Mar 2020 19:40:18 +0100 Subject: [PATCH 042/129] feat: uv_layer loading --- multi_user/bl_types/bl_mesh.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index 2c44377..8178270 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -70,9 +70,9 @@ def dump_mesh(mesh, data={}): poly_count = len(mesh.polygons) mesh_data["poly_count"] = poly_count - # poly_mat = np.empty(poly_count, dtype=np.int) - # mesh.polygons.foreach_get("material_index", poly_mat) - # mesh_data["poly_mat"] = poly_mat.tobytes() + poly_mat = np.empty(poly_count, dtype=np.int) + mesh.polygons.foreach_get("material_index", poly_mat) + mesh_data["poly_mat"] = poly_mat.tobytes() poly_loop_start = np.empty(poly_count, dtype=np.int) mesh.polygons.foreach_get("loop_start", poly_loop_start) @@ -160,14 +160,14 @@ class BlMesh(BlDatablock): edge_count = data["egdes_count"] target.edges.add(edge_count) - # 2.c - LOOPS + # # 2.c - LOOPS loops_count = data["loop_count"] target.loops.add(loops_count) loop_vertex_index = np.frombuffer(data['loop_vertex_index'], dtype=np.int) loop_normal = np.frombuffer(data['loop_normal'], dtype=np.float64) - # 2.b - POLY + # # 2.b - POLY poly_count = data["poly_count"] target.polygons.add(poly_count) @@ -175,6 +175,7 @@ class BlMesh(BlDatablock): poly_loop_total = np.frombuffer(data["poly_loop_total"], dtype=np.int) poly_smooth = np.frombuffer(data["poly_smooth"], dtype=np.bool) + poly_mat = np.frombuffer(data["poly_mat"], dtype=np.int) target.vertices.foreach_set('co', vertices) target.edges.foreach_set("vertices", egdes_vert) @@ -183,14 +184,23 @@ class BlMesh(BlDatablock): target.polygons.foreach_set("loop_total", poly_loop_total) target.polygons.foreach_set("loop_start", poly_loop_start) target.polygons.foreach_set("use_smooth", poly_smooth) - + target.polygons.foreach_set("material_index", poly_mat) # 3 - LOAD METADATA # uv's # utils.dump_anything.load(target.uv_layers, data['uv_layers']) + + for layer in data['uv_layers']: + if layer not in target.uv_layers: + target.uv_layers.new(name=layer) + + uv_buffer = np.frombuffer(data["uv_layers"][layer]['data']) - target.update() + target.uv_layers[layer].data.foreach_set('uv', uv_buffer) + + + target.validate () # utils.dump_anything.load(target, data) def dump_implementation(self, data, pointer=None): From c3ae56abd2e850af6a8c09dc5cdac5d38099c6d8 Mon Sep 17 00:00:00 2001 From: Swann Date: Wed, 25 Mar 2020 09:47:20 +0100 Subject: [PATCH 043/129] refactor: cleanup progress --- multi_user/bl_types/bl_mesh.py | 202 +++++++++++++-------------------- 1 file changed, 81 insertions(+), 121 deletions(-) diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index 8178270..4337493 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -28,101 +28,6 @@ from .bl_datablock import BlDatablock logger = logging.getLogger(__name__) -def dump_mesh(mesh, data={}): - mesh_data = data - - # VERTICES - start = utils.current_milli_time() - - vert_count = len(mesh.vertices) - shape = (vert_count, 3) - - verts_co = np.empty(vert_count*3, dtype=np.float64) - mesh.vertices.foreach_get('co', verts_co) - # verts_co.shape = shape - mesh_data["verts_co"] = verts_co.tobytes() - - # verts_normal = np.empty(vert_count*3, dtype=np.float64) - # mesh.vertices.foreach_get('normal', verts_normal) - # # verts_normal.shape = shape - # mesh_data["verts_normal"] = verts_normal.tobytes() - - # verts_bevel = np.empty(vert_count, dtype=np.float64) - # mesh.vertices.foreach_get('bevel_weight', verts_bevel) - # mesh_data["verts_bevel"] = verts_bevel.tobytes() - - logger.error(f"verts {utils.current_milli_time()-start} ms") - - # EDGES - start = utils.current_milli_time() - edge_count = len(mesh.edges) - - edges_vert = np.empty(edge_count*2, dtype=np.int) - mesh.edges.foreach_get('vertices', edges_vert) - mesh_data["egdes_vert"] = edges_vert.tobytes() - mesh_data["egdes_count"] = len(mesh.edges) - logger.error(f"edges {utils.current_milli_time()-start} ms") - - start = utils.current_milli_time() - - # POLYGONS - start = utils.current_milli_time() - poly_count = len(mesh.polygons) - mesh_data["poly_count"] = poly_count - - poly_mat = np.empty(poly_count, dtype=np.int) - mesh.polygons.foreach_get("material_index", poly_mat) - mesh_data["poly_mat"] = poly_mat.tobytes() - - poly_loop_start = np.empty(poly_count, dtype=np.int) - mesh.polygons.foreach_get("loop_start", poly_loop_start) - mesh_data["poly_loop_start"] = poly_loop_start.tobytes() - - poly_loop_total = np.empty(poly_count, dtype=np.int) - mesh.polygons.foreach_get("loop_total", poly_loop_total) - mesh_data["poly_loop_total"] = poly_loop_total.tobytes() - - poly_smooth = np.empty(poly_count, dtype=np.bool) - mesh.polygons.foreach_get("use_smooth", poly_smooth) - mesh_data["poly_smooth"] = poly_smooth.tobytes() - - logger.error(f"polygons {utils.current_milli_time()-start} ms") - - # LOOPS - start = utils.current_milli_time() - loop_count = len(mesh.loops) - mesh_data["loop_count"] = loop_count - # loop_bitangent = np.empty(loop_count*3, dtype=np.float64) - # mesh.loops.foreach_get("bitangent", loop_bitangent) - - # loop_tangent = np.empty(loop_count*3, dtype=np.float64) - # mesh.loops.foreach_get("tangent", loop_tangent) - # mesh_data["loop_tangent"] = loop_tangent.tobytes() - - loop_normal = np.empty(loop_count*3, dtype=np.float64) - mesh.loops.foreach_get("normal", loop_normal) - mesh_data["loop_normal"] = loop_normal.tobytes() - - loop_vertex_index = np.empty(loop_count, dtype=np.int) - mesh.loops.foreach_get("vertex_index", loop_vertex_index) - mesh_data["loop_vertex_index"] = loop_vertex_index.tobytes() - - logger.error(f"loops {utils.current_milli_time()-start} ms") - - # UV - start = utils.current_milli_time() - mesh_data['uv_layers'] = {} - for layer in mesh.uv_layers: - mesh_data['uv_layers'][layer.name] = {} - - uv_layer = np.empty(len(layer.data)*2, dtype=np.float64) - layer.data.foreach_get("uv", uv_layer) - - mesh_data['uv_layers'][layer.name]['data'] = uv_layer.tobytes() - - logger.error(f"uvs {utils.current_milli_time()-start} ms") - - class BlMesh(BlDatablock): bl_id = "meshes" @@ -139,44 +44,46 @@ class BlMesh(BlDatablock): def load_implementation(self, data, target): if not target or not target.is_editmode: - # 1 - LOAD MATERIAL SLOTS - # SLots + # MATERIAL SLOTS i = 0 for m in data["material_list"]: target.materials.append(bpy.data.materials[m]) - # 2 - LOAD GEOMETRY + # CLEAR GEOMETRY if target.vertices: target.clear_geometry() - # 2.a - VERTS + # VERTS vertices = np.frombuffer(data["verts_co"], dtype=np.float64) vert_count = int(len(vertices)/3) target.vertices.add(vert_count) - # 2.b - EDGES + # EDGES egdes_vert = np.frombuffer(data["egdes_vert"], dtype=np.int) edge_count = data["egdes_count"] target.edges.add(edge_count) - # # 2.c - LOOPS + # LOOPS loops_count = data["loop_count"] target.loops.add(loops_count) - loop_vertex_index = np.frombuffer(data['loop_vertex_index'], dtype=np.int) + loop_vertex_index = np.frombuffer( + data['loop_vertex_index'], dtype=np.int) loop_normal = np.frombuffer(data['loop_normal'], dtype=np.float64) - # # 2.b - POLY + # POLY poly_count = data["poly_count"] target.polygons.add(poly_count) - poly_loop_start = np.frombuffer(data["poly_loop_start"], dtype=np.int) - poly_loop_total = np.frombuffer(data["poly_loop_total"], dtype=np.int) + poly_loop_start = np.frombuffer( + data["poly_loop_start"], dtype=np.int) + poly_loop_total = np.frombuffer( + data["poly_loop_total"], dtype=np.int) poly_smooth = np.frombuffer(data["poly_smooth"], dtype=np.bool) poly_mat = np.frombuffer(data["poly_mat"], dtype=np.int) - + target.vertices.foreach_set('co', vertices) target.edges.foreach_set("vertices", egdes_vert) target.loops.foreach_set("vertex_index", loop_vertex_index) @@ -185,40 +92,93 @@ class BlMesh(BlDatablock): target.polygons.foreach_set("loop_start", poly_loop_start) target.polygons.foreach_set("use_smooth", poly_smooth) target.polygons.foreach_set("material_index", poly_mat) - - - # 3 - LOAD METADATA - # uv's - # utils.dump_anything.load(target.uv_layers, data['uv_layers']) + # UV Layers for layer in data['uv_layers']: if layer not in target.uv_layers: target.uv_layers.new(name=layer) - + uv_buffer = np.frombuffer(data["uv_layers"][layer]['data']) target.uv_layers[layer].data.foreach_set('uv', uv_buffer) - - target.validate () - # utils.dump_anything.load(target, data) + target.validate() + utils.dump_anything.load(target, data) def dump_implementation(self, data, pointer=None): assert(pointer) + mesh = pointer + dumper = utils.dump_anything.Dumper() - dumper.depth = 2 + dumper.depth = 1 dumper.include_filter = [ 'name', 'use_auto_smooth', 'auto_smooth_angle' ] - - data = dumper.dump(pointer) - - dump_mesh(pointer, data) - + data = dumper.dump(mesh) + + # VERTICES + vert_count = len(mesh.vertices) + + verts_co = np.empty(vert_count*3, dtype=np.float64) + mesh.vertices.foreach_get('co', verts_co) + data["verts_co"] = verts_co.tobytes() + + # EDGES + edge_count = len(mesh.edges) + + edges_vert = np.empty(edge_count*2, dtype=np.int) + mesh.edges.foreach_get('vertices', edges_vert) + data["egdes_vert"] = edges_vert.tobytes() + data["egdes_count"] = len(mesh.edges) + + # TODO: edge crease, bevel_weight + + # POLYGONS + poly_count = len(mesh.polygons) + data["poly_count"] = poly_count + + poly_mat = np.empty(poly_count, dtype=np.int) + mesh.polygons.foreach_get("material_index", poly_mat) + data["poly_mat"] = poly_mat.tobytes() + + poly_loop_start = np.empty(poly_count, dtype=np.int) + mesh.polygons.foreach_get("loop_start", poly_loop_start) + data["poly_loop_start"] = poly_loop_start.tobytes() + + poly_loop_total = np.empty(poly_count, dtype=np.int) + mesh.polygons.foreach_get("loop_total", poly_loop_total) + data["poly_loop_total"] = poly_loop_total.tobytes() + + poly_smooth = np.empty(poly_count, dtype=np.bool) + mesh.polygons.foreach_get("use_smooth", poly_smooth) + data["poly_smooth"] = poly_smooth.tobytes() + + # LOOPS + loop_count = len(mesh.loops) + data["loop_count"] = loop_count + + loop_normal = np.empty(loop_count*3, dtype=np.float64) + mesh.loops.foreach_get("normal", loop_normal) + data["loop_normal"] = loop_normal.tobytes() + + loop_vertex_index = np.empty(loop_count, dtype=np.int) + mesh.loops.foreach_get("vertex_index", loop_vertex_index) + data["loop_vertex_index"] = loop_vertex_index.tobytes() + + # UV Layers + data['uv_layers'] = {} + for layer in mesh.uv_layers: + data['uv_layers'][layer.name] = {} + + uv_layer = np.empty(len(layer.data)*2, dtype=np.float64) + layer.data.foreach_get("uv", uv_layer) + + data['uv_layers'][layer.name]['data'] = uv_layer.tobytes() + # Fix material index m_list = [] for material in pointer.materials: From d7964b645a7069f00cb4357eaaab09045f012aa2 Mon Sep 17 00:00:00 2001 From: Swann Date: Wed, 25 Mar 2020 10:35:31 +0100 Subject: [PATCH 044/129] fix: generated image path error --- multi_user/bl_types/bl_image.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/multi_user/bl_types/bl_image.py b/multi_user/bl_types/bl_image.py index c758fb0..8497725 100644 --- a/multi_user/bl_types/bl_image.py +++ b/multi_user/bl_types/bl_image.py @@ -28,8 +28,10 @@ def dump_image(image): if image.source == "GENERATED": prefs = utils.get_preferences() img_name = "{}.png".format(image.name) - + + # Cache the image on the disk image.filepath_raw = os.path.join(prefs.cache_directory, img_name) + os.makedirs(prefs.cache_directory, exist_ok=True) image.file_format = "PNG" image.save() @@ -77,7 +79,7 @@ class BlImage(BlDatablock): image.colorspace_settings.name = data["colorspace_settings"]["name"] - def _dump(self, data, pointer=None): + def _dump(self, pointer=None): assert(pointer) data = {} data['pixels'] = dump_image(pointer) From f9222d84eab0f33eb6e2eee06f0612d1dc38e393 Mon Sep 17 00:00:00 2001 From: Swann Date: Wed, 25 Mar 2020 11:36:29 +0100 Subject: [PATCH 045/129] feat: crease and bevel_wieght support --- multi_user/bl_types/bl_mesh.py | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index 4337493..d6a7a3b 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -44,6 +44,8 @@ class BlMesh(BlDatablock): def load_implementation(self, data, target): if not target or not target.is_editmode: + utils.dump_anything.load(target, data) + # MATERIAL SLOTS i = 0 @@ -60,9 +62,13 @@ class BlMesh(BlDatablock): target.vertices.add(vert_count) # EDGES + egdes_vert = np.frombuffer(data["egdes_vert"], dtype=np.int) + edge_count = data["egdes_count"] target.edges.add(edge_count) + + # LOOPS loops_count = data["loop_count"] @@ -84,8 +90,18 @@ class BlMesh(BlDatablock): poly_mat = np.frombuffer(data["poly_mat"], dtype=np.int) + # LOADING target.vertices.foreach_set('co', vertices) target.edges.foreach_set("vertices", egdes_vert) + + if data['use_customdata_edge_crease']: + edges_crease = np.frombuffer(data["edges_crease"], dtype=np.float64) + target.edges.foreach_set("crease", edges_crease) + + if data['use_customdata_edge_bevel']: + edges_bevel = np.frombuffer(data["edges_bevel"], dtype=np.float64) + target.edges.foreach_set("bevel_weight", edges_bevel) + target.loops.foreach_set("vertex_index", loop_vertex_index) target.loops.foreach_set("normal", loop_normal) target.polygons.foreach_set("loop_total", poly_loop_total) @@ -93,6 +109,7 @@ class BlMesh(BlDatablock): target.polygons.foreach_set("use_smooth", poly_smooth) target.polygons.foreach_set("material_index", poly_mat) + # UV Layers for layer in data['uv_layers']: if layer not in target.uv_layers: @@ -103,7 +120,7 @@ class BlMesh(BlDatablock): target.uv_layers[layer].data.foreach_set('uv', uv_buffer) target.validate() - utils.dump_anything.load(target, data) + def dump_implementation(self, data, pointer=None): assert(pointer) @@ -115,11 +132,14 @@ class BlMesh(BlDatablock): dumper.include_filter = [ 'name', 'use_auto_smooth', - 'auto_smooth_angle' + 'auto_smooth_angle', + 'use_customdata_edge_bevel', + 'use_customdata_edge_crease' ] data = dumper.dump(mesh) + # TODO: selective dump # VERTICES vert_count = len(mesh.vertices) @@ -135,7 +155,15 @@ class BlMesh(BlDatablock): data["egdes_vert"] = edges_vert.tobytes() data["egdes_count"] = len(mesh.edges) - # TODO: edge crease, bevel_weight + if mesh.use_customdata_edge_crease: + edges_crease = np.empty(edge_count, dtype=np.float64) + mesh.edges.foreach_get('crease', edges_crease) + data["edges_crease"] = edges_crease.tobytes() + + if mesh.use_customdata_edge_bevel: + edges_bevel = np.empty(edge_count, dtype=np.float64) + mesh.edges.foreach_get('bevel_weight', edges_bevel) + data["edges_bevel"] = edges_bevel.tobytes() # POLYGONS poly_count = len(mesh.polygons) From a84fccb3ced6e82400ab099bbb7460206a44e984 Mon Sep 17 00:00:00 2001 From: Swann Date: Wed, 25 Mar 2020 13:24:12 +0100 Subject: [PATCH 046/129] feat: camera cleanup --- multi_user/bl_types/bl_camera.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/multi_user/bl_types/bl_camera.py b/multi_user/bl_types/bl_camera.py index 88112a3..4876010 100644 --- a/multi_user/bl_types/bl_camera.py +++ b/multi_user/bl_types/bl_camera.py @@ -31,6 +31,10 @@ class BlCamera(BlDatablock): bl_automatic_push = True bl_icon = 'CAMERA_DATA' + def _construct(self, data): + return bpy.data.cameras.new(data["name"]) + + def load_implementation(self, data, target): utils.dump_anything.load(target, data) @@ -40,12 +44,11 @@ class BlCamera(BlDatablock): if dof_settings: utils.dump_anything.load(target.dof, dof_settings) - def _construct(self, data): - return bpy.data.cameras.new(data["name"]) - def dump_implementation(self, data, pointer=None): assert(pointer) + # TODO: background image support + dumper = utils.dump_anything.Dumper() dumper.depth = 2 dumper.include_filter = [ @@ -67,6 +70,14 @@ class BlCamera(BlDatablock): 'aperture_blades', 'aperture_rotation', 'aperture_ratio', + 'display_size', + 'show_limits', + 'show_mist', + 'show_sensor', + 'show_name', + 'sensor_fit', + 'sensor_height', + 'sensor_width', ] return dumper.dump(pointer) From 6a98e749f9148a5f10184f4c3dea1dd1b1561c4d Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 09:20:41 +0100 Subject: [PATCH 047/129] feat: gpencil dump refactoring --- multi_user/bl_types/bl_gpencil.py | 204 ++++++++++++++++++++++++----- multi_user/bl_types/bl_material.py | 27 +++- 2 files changed, 199 insertions(+), 32 deletions(-) diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index 8fc8a86..0b9bc1d 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -18,36 +18,170 @@ import bpy import mathutils +import numpy as np from ..libs import dump_anything from .bl_datablock import BlDatablock +# GPencil data api is structured as it follow: +# GP-Object --> GP-Layers --> GP-Frames --> GP-Strokes --> GP-Stroke-Points -def load_gpencil_layer(target=None, data=None, create=False): +def dump_stroke(stroke): + """ Dump a grease pencil stroke to a dict - dump_anything.load(target, data) - for k,v in target.frames.items(): - target.frames.remove(v) + :param stroke: target grease pencil stroke + :type stroke: bpy.types.GPencilStroke + :return: dict + """ +< + assert(stroke) + + dumper = dump_anything.Dumper() + dumper.include_filter = [ + "aspect", + "display_mode", + "draw_cyclic", + "end_cap_mode", + "hardeness", + "line_width", + "material_index", + "start_cap_mode", + "uv_rotation", + "uv_scale", + "uv_translation", + "vertex_color_fill", + ] + dumped_stroke = dump_anything.dump(stroke) + + # Stoke points + p_count = len(stroke.points) + dumped_stroke['p_count'] = p_count + + p_co = np.empty(p_count*3, dtype=np.float64) + stroke.points.foreach_get('co', p_co) + + p_pressure = np.empty(p_count, dtype=np.float64) + stroke.points.foreach_get('pressure', p_pressure) + + p_strength = np.empty(p_count, dtype=np.float64) + stroke.points.foreach_get('strength', p_strength) + + p_vertex_color = np.empty(p_count*4, dtype=np.float64) + stroke.points.foreach_get('vertex_color', p_vertex_color) + + # TODO: uv_factor, uv_rotation + + dumped_stroke['p_co'] = p_co.tobytes() + dumped_stroke['p_pressure'] = p_pressure.tobytes() + dumped_stroke['p_strength'] = p_strength.tobytes() + dumped_stroke['p_vertex_color'] = p_vertex_color.tobytes() + + return dumped_stroke + +def load_stroke(stroke_data, stroke): + """ Load a grease pencil stroke from a dict + + :param stroke_data: dumped grease pencil stroke + :type stroke_data: dict + :param stroke: target grease pencil stroke + :type stroke: bpy.types.GPencilStroke + """ + assert(stroke and stroke_data) + + dump_anything.load(stroke, stroke_data) + + p_co = np.frombuffer(stroke_data["p_co"], dtype=np.float64) + p_pressure = np.frombuffer(stroke_data["p_pressure"], dtype=np.float64) + p_strength = np.frombuffer(stroke_data["p_strength"], dtype=np.float64) + p_vertex_color = np.frombuffer(stroke_data["p_vertex_color"], dtype=np.float64) + + stroke.points.add(stroke_data["p_count"]) + + stroke.points.foreach_set('co', p_co) + stroke.points.foreach_set('pressure', p_pressure) + stroke.points.foreach_set('strength', p_strength) + stroke.points.foreach_set('vertex_color', p_vertex_color) + + +def dump_frame(frame): + """ Dump a grease pencil frame to a dict + + :param frame: target grease pencil stroke + :type frame: bpy.types.GPencilFrame + :return: dict + """ + + assert(frame) + + dumped_frame = dict() + dumped_frame['frame_number'] = frame.frame_number + dumped_frame['strokes'] = [] + + # TODO: took existing strokes in account + for stroke in frame.strokes: + dumped_frame['strokes'].append(dump_stroke(stroke)) + + return dumped_frame + +def load_frame(frame_data, frame): + """ Load a grease pencil frame from a dict + + :param frame_data: source grease pencil frame + :type frame_data: dict + :param frame: target grease pencil stroke + :type frame: bpy.types.GPencilFrame + """ + + assert(frame and frame_data) + + frame.frame_number = frame_data['frame_number'] + + # TODO: took existing stroke in account + + for stroke_data in frame_data['strokes']: + target_stroke = frame.strokes.new() - for frame in data["frames"]: - - tframe = target.frames.new(data["frames"][frame]['frame_number']) + load_stroke(stroke_data, target_stroke) - for stroke in data["frames"][frame]["strokes"]: - try: - tstroke = tframe.strokes[stroke] - except: - tstroke = tframe.strokes.new() - dump_anything.load( - tstroke, data["frames"][frame]["strokes"][stroke]) +def dump_layer(layer): + """ Dump a grease pencil layer - for point in data["frames"][frame]["strokes"][stroke]["points"]: - p = data["frames"][frame]["strokes"][stroke]["points"][point] + :param layer: target grease pencil stroke + :type layer: bpy.types.GPencilFrame + """ - tstroke.points.add(1) - tpoint = tstroke.points[len(tstroke.points)-1] + assert(layer) + + dumper = dump_anything.Dumper() + + dumper.exclude_filter = [ + 'parent_type' + ] + dumped_layer = dumper.dump(layer) + + dumped_layer['frames'] = [] + + for frame in layer.frames: + dumped_layer['frames'].append(dump_frame(frame)) + + return dumped_layer + +def load_layer(layer_data, layer): + """ Load a grease pencil layer from a dict + + :param layer_data: source grease pencil layer data + :type layer_data: dict + :param layer: target grease pencil stroke + :type layer: bpy.types.GPencilFrame + """ + # TODO: take existing data in account + dump_anything.load(layer, layer_data) + + for frame_data in layer_data["frames"]: + target_frame = layer.frames.new(frame_data['frame_number']) + + load_frame(frame_data, target_frame) - dump_anything.load(tpoint, p) class BlGpencil(BlDatablock): @@ -65,26 +199,34 @@ class BlGpencil(BlDatablock): for layer in target.layers: target.layers.remove(layer) - if "layers" in data.keys(): - for layer in data["layers"]: - if layer not in target.layers.keys(): - gp_layer = target.layers.new(data["layers"][layer]["info"]) - else: - gp_layer = target.layers[layer] - load_gpencil_layer( - target=gp_layer, data=data["layers"][layer], create=True) - - dump_anything.load(target, data) - target.materials.clear() if "materials" in data.keys(): for mat in data['materials']: target.materials.append(bpy.data.materials[mat]) + if "layers" in data.keys(): + for layer in data["layers"]: + layer_data = data["layers"].get(layer) + + if layer not in target.layers.keys(): + target_layer = target.layers.new(data["layers"][layer]["info"]) + else: + target_layer = target.layers[layer] + + load_layer(layer_data, target_layer) + + dump_anything.load(target, data) + + + def dump_implementation(self, data, pointer=None): assert(pointer) data = dump_anything.dump(pointer, 2) - data['layers'] = dump_anything.dump(pointer.layers, 9) + + data['layers'] = {} + + for layer in pointer.layers: + data['layers'][layer.info] = dump_layer(layer) return data diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index 89f3f3d..196aa33 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -206,7 +206,32 @@ class BlMaterial(BlDatablock): data["node_tree"]["links"] = links_dumper.dump(pointer.node_tree.links) elif pointer.is_grease_pencil: - data['grease_pencil'] = dump_anything.dump(pointer.grease_pencil, 3) + gp_mat_dumper = dump_anything.Dumper() + gp_mat_dumper.depth = 3 + + gp_mat_dumper.include_filter = [ + 'show_stroke', + 'mode', + 'stroke_style', + 'color', + 'use_overlap_strokes', + 'show_fill', + 'fill_style', + 'fill_color', + 'pass_index', + 'alignment_mode', + # 'fill_image', + 'texture_opacity', + 'mix_factor', + 'texture_offset', + 'texture_angle', + 'texture_scale', + 'texture_clamp', + 'gradient_type', + 'mix_color', + 'flip' + ] + data['grease_pencil'] = gp_mat_dumper.dump(pointer.grease_pencil) return data def resolve_deps_implementation(self): From 73019fc0b092e25dfe69668ef920764d440d04f3 Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 11:12:26 +0100 Subject: [PATCH 048/129] feat: grease pencil progress --- multi_user/bl_types/bl_gpencil.py | 59 ++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index 0b9bc1d..81682a3 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -33,7 +33,7 @@ def dump_stroke(stroke): :type stroke: bpy.types.GPencilStroke :return: dict """ -< + assert(stroke) dumper = dump_anything.Dumper() @@ -51,7 +51,7 @@ def dump_stroke(stroke): "uv_translation", "vertex_color_fill", ] - dumped_stroke = dump_anything.dump(stroke) + dumped_stroke = dumper.dump(stroke) # Stoke points p_count = len(stroke.points) @@ -134,13 +134,12 @@ def load_frame(frame_data, frame): assert(frame and frame_data) - frame.frame_number = frame_data['frame_number'] + # frame.frame_number = frame_data['frame_number'] # TODO: took existing stroke in account for stroke_data in frame_data['strokes']: target_stroke = frame.strokes.new() - load_stroke(stroke_data, target_stroke) def dump_layer(layer): @@ -154,8 +153,40 @@ def dump_layer(layer): dumper = dump_anything.Dumper() - dumper.exclude_filter = [ - 'parent_type' + dumper.include_filter = [ + 'info', + 'opacity', + 'channel_color', + 'color', + # 'thickness', + 'tint_color', + 'tint_factor', + 'vertex_paint_opacity', + 'line_change', + 'use_onion_skinning', + # 'use_annotation_onion_skinning', + # 'annotation_onion_before_range', + # 'annotation_onion_after_range', + # 'annotation_onion_before_color', + # 'annotation_onion_after_color', + 'pass_index', + # 'viewlayer_render', + 'blend_mode', + 'hide', + 'annotation_hide', + 'lock', + # 'lock_frame', + # 'lock_material', + # 'use_mask_layer', + 'use_lights', + 'use_solo_mode', + 'select', + 'show_points', + 'show_in_front', + # 'parent', + # 'parent_type', + # 'parent_bone', + # 'matrix_inverse', ] dumped_layer = dumper.dump(layer) @@ -196,22 +227,24 @@ class BlGpencil(BlDatablock): return bpy.data.grease_pencils.new(data["name"]) def load_implementation(self, data, target): - for layer in target.layers: - target.layers.remove(layer) - target.materials.clear() if "materials" in data.keys(): for mat in data['materials']: target.materials.append(bpy.data.materials[mat]) + # TODO: reuse existing layer + for layer in target.layers: + target.layers.remove(layer) + if "layers" in data.keys(): for layer in data["layers"]: layer_data = data["layers"].get(layer) - if layer not in target.layers.keys(): - target_layer = target.layers.new(data["layers"][layer]["info"]) - else: - target_layer = target.layers[layer] + # if layer not in target.layers.keys(): + target_layer = target.layers.new(data["layers"][layer]["info"]) + # else: + # target_layer = target.layers[layer] + # target_layer.clear() load_layer(layer_data, target_layer) From 25825f7aeb7df64d3d3c5e35b304419acc54d821 Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 11:45:41 +0100 Subject: [PATCH 049/129] feat: blender 2.83 api changes --- multi_user/bl_types/bl_gpencil.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index 81682a3..7ae4983 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -59,23 +59,23 @@ def dump_stroke(stroke): p_co = np.empty(p_count*3, dtype=np.float64) stroke.points.foreach_get('co', p_co) + dumped_stroke['p_co'] = p_co.tobytes() p_pressure = np.empty(p_count, dtype=np.float64) stroke.points.foreach_get('pressure', p_pressure) + dumped_stroke['p_pressure'] = p_pressure.tobytes() p_strength = np.empty(p_count, dtype=np.float64) stroke.points.foreach_get('strength', p_strength) + dumped_stroke['p_strength'] = p_strength.tobytes() - p_vertex_color = np.empty(p_count*4, dtype=np.float64) - stroke.points.foreach_get('vertex_color', p_vertex_color) + if bpy.app.version[1] >= 83: # new in blender 2.83 + p_vertex_color = np.empty(p_count*4, dtype=np.float64) + stroke.points.foreach_get('vertex_color', p_vertex_color) + dumped_stroke['p_vertex_color'] = p_vertex_color.tobytes() # TODO: uv_factor, uv_rotation - dumped_stroke['p_co'] = p_co.tobytes() - dumped_stroke['p_pressure'] = p_pressure.tobytes() - dumped_stroke['p_strength'] = p_strength.tobytes() - dumped_stroke['p_vertex_color'] = p_vertex_color.tobytes() - return dumped_stroke def load_stroke(stroke_data, stroke): @@ -91,16 +91,12 @@ def load_stroke(stroke_data, stroke): dump_anything.load(stroke, stroke_data) p_co = np.frombuffer(stroke_data["p_co"], dtype=np.float64) - p_pressure = np.frombuffer(stroke_data["p_pressure"], dtype=np.float64) - p_strength = np.frombuffer(stroke_data["p_strength"], dtype=np.float64) - p_vertex_color = np.frombuffer(stroke_data["p_vertex_color"], dtype=np.float64) - - stroke.points.add(stroke_data["p_count"]) - - stroke.points.foreach_set('co', p_co) - stroke.points.foreach_set('pressure', p_pressure) + p_pressure = np.frombuffer(strokD stroke.points.foreach_set('strength', p_strength) - stroke.points.foreach_set('vertex_color', p_vertex_color) + + if "p_vertex_color" in stroke_data: + p_vertex_color = np.frombuffer(stroke_data["p_vertex_color"], dtype=np.float64) + stroke.points.foreach_set('vertex_color', p_vertex_color) def dump_frame(frame): From 441378590343aa6e51046395bb03aa75badc0307 Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 12:07:12 +0100 Subject: [PATCH 050/129] feat: missing lines --- multi_user/bl_types/bl_gpencil.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index 7ae4983..a6c032d 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -91,7 +91,13 @@ def load_stroke(stroke_data, stroke): dump_anything.load(stroke, stroke_data) p_co = np.frombuffer(stroke_data["p_co"], dtype=np.float64) - p_pressure = np.frombuffer(strokD + p_pressure = np.frombuffer(stroke_data["p_pressure"], dtype=np.float64) + p_strength = np.frombuffer(stroke_data["p_strength"], dtype=np.float64) + + stroke.points.add(stroke_data["p_count"]) + + stroke.points.foreach_set('co', p_co) + stroke.points.foreach_set('pressure', p_pressure) stroke.points.foreach_set('strength', p_strength) if "p_vertex_color" in stroke_data: From 3680a751aa13f3f06efed27413609f3afd2c2463 Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 14:04:47 +0100 Subject: [PATCH 051/129] fix: jsondiff import error Related to #78 --- multi_user/__init__.py | 111 +++++--------------------------------- multi_user/preferences.py | 84 ++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 99 deletions(-) diff --git a/multi_user/__init__.py b/multi_user/__init__.py index 2990789..f27d541 100644 --- a/multi_user/__init__.py +++ b/multi_user/__init__.py @@ -22,7 +22,7 @@ import sys import bpy from bpy.app.handlers import persistent -from . import environment, utils, presence, preferences +from . import environment, utils from .libs.replication.replication.constants import RP_COMMON @@ -36,89 +36,6 @@ DEPENDENCIES = { logger = logging.getLogger(__name__) logger.setLevel(logging.WARNING) -def client_list_callback(scene, context): - from . import operators - - items = [(RP_COMMON, RP_COMMON, "")] - - username = utils.get_preferences().username - cli = operators.client - if cli: - client_ids = cli.online_users.keys() - for id in client_ids: - name_desc = id - if id == username: - name_desc += " (self)" - - items.append((id, name_desc, "")) - - return items - -class SessionUser(bpy.types.PropertyGroup): - """Session User - - Blender user information property - """ - username: bpy.props.StringProperty(name="username") - current_frame: bpy.props.IntProperty(name="current_frame") - - -class SessionProps(bpy.types.PropertyGroup): - is_admin: bpy.props.BoolProperty( - name="is_admin", - default=False - ) - session_mode: bpy.props.EnumProperty( - name='session_mode', - description='session mode', - items={ - ('HOST', 'hosting', 'host a session'), - ('CONNECT', 'connexion', 'connect to a session')}, - default='HOST') - clients: bpy.props.EnumProperty( - name="clients", - description="client enum", - items=client_list_callback) - enable_presence: bpy.props.BoolProperty( - name="Presence overlay", - description='Enable overlay drawing module', - default=True, - update=presence.update_presence - ) - presence_show_selected: bpy.props.BoolProperty( - name="Show selected objects", - description='Enable selection overlay ', - default=True, - update=presence.update_overlay_settings - ) - presence_show_user: bpy.props.BoolProperty( - name="Show users", - description='Enable user overlay ', - default=True, - update=presence.update_overlay_settings - ) - presence_show_far_user: bpy.props.BoolProperty( - name="Show different scenes", - description="Show user on different scenes", - default=False, - update=presence.update_overlay_settings - ) - filter_owned: bpy.props.BoolProperty( - name="filter_owned", - description='Show only owned datablocks', - default=True - ) - user_snap_running: bpy.props.BoolProperty( - default=False - ) - time_snap_running: bpy.props.BoolProperty( - default=False - ) - -classes = ( - SessionUser, - SessionProps, -) libs = os.path.dirname(os.path.abspath(__file__))+"\\libs\\replication\\replication" @@ -134,23 +51,20 @@ def register(): from . import preferences from . import addon_updater_ops - for cls in classes: - bpy.utils.register_class(cls) - - bpy.types.WindowManager.session = bpy.props.PointerProperty( - type=SessionProps) - bpy.types.ID.uuid = bpy.props.StringProperty(default="") - bpy.types.WindowManager.online_users = bpy.props.CollectionProperty( - type=SessionUser - ) - bpy.types.WindowManager.user_index = bpy.props.IntProperty() - preferences.register() addon_updater_ops.register(bl_info) presence.register() operators.register() ui.register() + bpy.types.WindowManager.session = bpy.props.PointerProperty( + type=preferences.SessionProps) + bpy.types.ID.uuid = bpy.props.StringProperty(default="") + bpy.types.WindowManager.online_users = bpy.props.CollectionProperty( + type=preferences.SessionUser + ) + bpy.types.WindowManager.user_index = bpy.props.IntProperty() + def unregister(): from . import presence from . import operators @@ -163,7 +77,8 @@ def unregister(): ui.unregister() operators.unregister() preferences.unregister() - del bpy.types.WindowManager.session - for cls in reversed(classes): - bpy.utils.unregister_class(cls) + del bpy.types.WindowManager.session + del bpy.types.ID.uuid + del bpy.types.WindowManager.online_users + del bpy.types.WindowManager.user_index diff --git a/multi_user/preferences.py b/multi_user/preferences.py index 3230b6d..e329e1d 100644 --- a/multi_user/preferences.py +++ b/multi_user/preferences.py @@ -1,7 +1,7 @@ import logging import bpy -from . import utils, bl_types, environment, addon_updater_ops +from . import utils, bl_types, environment, addon_updater_ops, presence logger = logging.getLogger(__name__) @@ -225,7 +225,89 @@ class SessionPrefs(bpy.types.AddonPreferences): new_db.bl_name = type_module_class.bl_id +def client_list_callback(scene, context): + from . import operators + + items = [(RP_COMMON, RP_COMMON, "")] + + username = utils.get_preferences().username + cli = operators.client + if cli: + client_ids = cli.online_users.keys() + for id in client_ids: + name_desc = id + if id == username: + name_desc += " (self)" + + items.append((id, name_desc, "")) + + return items + + +class SessionUser(bpy.types.PropertyGroup): + """Session User + + Blender user information property + """ + username: bpy.props.StringProperty(name="username") + current_frame: bpy.props.IntProperty(name="current_frame") + + +class SessionProps(bpy.types.PropertyGroup): + is_admin: bpy.props.BoolProperty( + name="is_admin", + default=False + ) + session_mode: bpy.props.EnumProperty( + name='session_mode', + description='session mode', + items={ + ('HOST', 'hosting', 'host a session'), + ('CONNECT', 'connexion', 'connect to a session')}, + default='HOST') + clients: bpy.props.EnumProperty( + name="clients", + description="client enum", + items=client_list_callback) + enable_presence: bpy.props.BoolProperty( + name="Presence overlay", + description='Enable overlay drawing module', + default=True, + update=presence.update_presence + ) + presence_show_selected: bpy.props.BoolProperty( + name="Show selected objects", + description='Enable selection overlay ', + default=True, + update=presence.update_overlay_settings + ) + presence_show_user: bpy.props.BoolProperty( + name="Show users", + description='Enable user overlay ', + default=True, + update=presence.update_overlay_settings + ) + presence_show_far_user: bpy.props.BoolProperty( + name="Show different scenes", + description="Show user on different scenes", + default=False, + update=presence.update_overlay_settings + ) + filter_owned: bpy.props.BoolProperty( + name="filter_owned", + description='Show only owned datablocks', + default=True + ) + user_snap_running: bpy.props.BoolProperty( + default=False + ) + time_snap_running: bpy.props.BoolProperty( + default=False + ) + classes = ( + SessionUser, + SessionProps, ReplicatedDatablock, SessionPrefs, ) From f0a2659b4377241e6db45b3fddc5491b9f938858 Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 16:10:27 +0100 Subject: [PATCH 052/129] fix: collection destructor during loading --- multi_user/libs/dump_anything.py | 50 ++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/multi_user/libs/dump_anything.py b/multi_user/libs/dump_anything.py index 051e0e9..b580779 100644 --- a/multi_user/libs/dump_anything.py +++ b/multi_user/libs/dump_anything.py @@ -317,13 +317,22 @@ class Loader: CONSTRUCTOR_NEW = "new" CONSTRUCTOR_ADD = "add" + DESTRUCTOR_REMOVE = "remove" + DESTRUCTOR_CLEAR = "clear" + constructors = { - T.ColorRampElement: (CONSTRUCTOR_NEW, ["position"], True), - T.ParticleSettingsTextureSlot: (CONSTRUCTOR_ADD, [], False), - T.Modifier: (CONSTRUCTOR_NEW, ["name", "type"], True), - T.Constraint: (CONSTRUCTOR_NEW, ["type"], True), + T.ColorRampElement: (CONSTRUCTOR_NEW, ["position"]), + T.ParticleSettingsTextureSlot: (CONSTRUCTOR_ADD, []), + T.Modifier: (CONSTRUCTOR_NEW, ["name", "type"]), + T.Constraint: (CONSTRUCTOR_NEW, ["type"]), # T.VertexGroup: (CONSTRUCTOR_NEW, ["name"], True), } + + destructors = { + T.ColorRampElement:DESTRUCTOR_REMOVE, + T.Modifier: DESTRUCTOR_CLEAR, + T.Constraint: CONSTRUCTOR_NEW, + } element_type = element.bl_rna_property.fixed_type constructor = constructors.get(type(element_type)) @@ -331,19 +340,30 @@ class Loader: if constructor is None: # collection type not supported return + destructor = destructors.get(type(element_type)) + # Try to clear existing - if constructor[2]: - getattr(element.read(), 'clear')() + if destructor: + if destructor == DESTRUCTOR_REMOVE: + collection = element.read() + for i in range(len(collection)-1): + collection.remove(collection[0]) + else: + getattr(element.read(), DESTRUCTOR_CLEAR)() - for dumped_element in dump.values(): - try: - constructor_parameters = [dumped_element[name] - for name in constructor[1]] - except KeyError: - logger.debug("Collection load error, missing parameters.") - continue # TODO handle error - new_element = getattr(element.read(), constructor[0])( - *constructor_parameters) + for dump_idx, dumped_element in enumerate(dump.values()): + if dump_idx == 0 and len(element.read())>0: + new_element = element.read()[0] + else: + try: + constructor_parameters = [dumped_element[name] + for name in constructor[1]] + except KeyError: + logger.debug("Collection load error, missing parameters.") + continue # TODO handle error + + new_element = getattr(element.read(), constructor[0])( + *constructor_parameters) self._load_any( BlenderAPIElement( new_element, occlude_read_only=self.occlude_read_only), From 2016af33b7931aeabcc17e98b6813bb77d147af1 Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 17:13:30 +0100 Subject: [PATCH 053/129] fix: missing light shape --- multi_user/bl_types/bl_light.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/multi_user/bl_types/bl_light.py b/multi_user/bl_types/bl_light.py index 3d216f6..67f8af9 100644 --- a/multi_user/bl_types/bl_light.py +++ b/multi_user/bl_types/bl_light.py @@ -59,7 +59,8 @@ class BlLight(BlDatablock): "contact_shadow_distance", "contact_shadow_soft_size", "contact_shadow_bias", - "contact_shadow_thickness" + "contact_shadow_thickness", + "shape" ] data = dumper.dump(pointer) return data From 6334bfdc011f18762a3a42b3ce5b36fba99394ba Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 17:13:48 +0100 Subject: [PATCH 054/129] fix: node_tree dump --- multi_user/bl_types/bl_material.py | 55 +++++++++++++++++++++--------- multi_user/bl_types/bl_world.py | 11 +++--- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index 196aa33..b56d3c4 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -77,14 +77,40 @@ def load_node(target_node_tree, source): except: logger.error("{} not supported, skipping".format(input)) -def load_link(target_node_tree, source): - input_socket = target_node_tree.nodes[source['to_node'] - ['name']].inputs[source['to_socket']['name']] - output_socket = target_node_tree.nodes[source['from_node'] - ['name']].outputs[source['from_socket']['name']] +def load_links(links_data, node_tree): + """ Load node_tree links from a list + + :arg links_data: dumped node links + :type links_data: list + :arg node_tree: node links collection + :type node_tree: bpy.types.NodeTree + """ - target_node_tree.links.new(input_socket, output_socket) + for link in links_data: + input_socket = node_tree.nodes[link['to_node']].inputs[int(link['to_socket'])] + output_socket = node_tree.nodes[link['from_node']].outputs[int(link['from_socket'])] + node_tree.links.new(input_socket, output_socket) + +def dump_links(links): + """ Dump node_tree links collection to a list + + :arg links: node links collection + :type links: bpy.types.NodeLinks + :retrun: list + """ + + links_data = [] + + for link in links: + links_data.append({ + 'to_node':link.to_node.name, + 'to_socket':link.to_socket.path_from_id()[-2:-1], + 'from_node':link.from_node.name, + 'from_socket':link.from_socket.path_from_id()[-2:-1], + }) + + return links_data class BlMaterial(BlDatablock): bl_id = "materials" @@ -123,8 +149,7 @@ class BlMaterial(BlDatablock): # Load nodes links target.node_tree.links.clear() - for link in data["node_tree"]["links"]: - load_link(target.node_tree, data["node_tree"]["links"][link]) + load_links(data["node_tree"]["links"], target.node_tree) def dump_implementation(self, data, pointer=None): assert(pointer) @@ -160,14 +185,7 @@ class BlMaterial(BlDatablock): input_dumper = dump_anything.Dumper() input_dumper.depth = 2 input_dumper.include_filter = ["default_value"] - links_dumper = dump_anything.Dumper() - links_dumper.depth = 3 - links_dumper.include_filter = [ - "name", - "to_node", - "from_node", - "from_socket", - "to_socket"] + data = mat_dumper.dump(pointer) if pointer.use_nodes: @@ -202,8 +220,11 @@ class BlMaterial(BlDatablock): 'location' ] nodes[node.name]['mapping'] = curve_dumper.dump(node.mapping) + data["node_tree"]['nodes'] = nodes - data["node_tree"]["links"] = links_dumper.dump(pointer.node_tree.links) + + + data["node_tree"]["links"] = dump_links(pointer.node_tree.links) elif pointer.is_grease_pencil: gp_mat_dumper = dump_anything.Dumper() diff --git a/multi_user/bl_types/bl_world.py b/multi_user/bl_types/bl_world.py index d23a65d..21dace2 100644 --- a/multi_user/bl_types/bl_world.py +++ b/multi_user/bl_types/bl_world.py @@ -21,7 +21,7 @@ import mathutils from .. import utils from .bl_datablock import BlDatablock -from .bl_material import load_link, load_node +from .bl_material import load_links, load_node, dump_links class BlWorld(BlDatablock): @@ -48,8 +48,8 @@ class BlWorld(BlDatablock): # Load nodes links target.node_tree.links.clear() - for link in data["node_tree"]["links"]: - load_link(target.node_tree, data["node_tree"]["links"][link]) + + load_links(data["node_tree"]["links"], target.node_tree) def dump_implementation(self, data, pointer=None): assert(pointer) @@ -104,8 +104,9 @@ class BlWorld(BlDatablock): nodes[node.name]['inputs'][i.name] = input_dumper.dump( i) data["node_tree"]['nodes'] = nodes - utils.dump_datablock_attibutes( - pointer.node_tree, ["links"], 3, data['node_tree']) + + data["node_tree"]['links'] = dump_links(pointer.node_tree.links) + return data def resolve_deps_implementation(self): From 667c3cd04d84e99f2f7a0e818679e87382c6e9d3 Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 17:13:59 +0100 Subject: [PATCH 055/129] fix: mesh update --- multi_user/bl_types/bl_mesh.py | 1 + multi_user/bl_types/bl_scene.py | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index d6a7a3b..5d772cf 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -120,6 +120,7 @@ class BlMesh(BlDatablock): target.uv_layers[layer].data.foreach_set('uv', uv_buffer) target.validate() + target.update() def dump_implementation(self, data, pointer=None): diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index 48ad025..5b7b9e5 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -73,7 +73,13 @@ class BlScene(BlDatablock): scene_dumper = utils.dump_anything.Dumper() scene_dumper.depth = 1 - scene_dumper.include_filter = ['name','world', 'id', 'camera', 'grease_pencil'] + scene_dumper.include_filter = [ + 'name', + 'world', + 'id', + 'camera', + 'grease_pencil' + ] data = scene_dumper.dump(pointer) scene_dumper.depth = 3 From ea9ee4ead1d351ada5fde2212353e7a45dc6fb5e Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 17:23:42 +0100 Subject: [PATCH 056/129] fix: mesh materials --- multi_user/bl_types/bl_mesh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index 5d772cf..185eca0 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -47,7 +47,7 @@ class BlMesh(BlDatablock): utils.dump_anything.load(target, data) # MATERIAL SLOTS - i = 0 + target.materials.clear() for m in data["material_list"]: target.materials.append(bpy.data.materials[m]) From d19932cc3b17391b2b533e8be51968abd90ec765 Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 17:30:45 +0100 Subject: [PATCH 057/129] refactor: change default timers for mesh and gpencil --- multi_user/bl_types/bl_gpencil.py | 8 ++++++-- multi_user/bl_types/bl_mesh.py | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index a6c032d..79ada32 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -78,6 +78,7 @@ def dump_stroke(stroke): return dumped_stroke + def load_stroke(stroke_data, stroke): """ Load a grease pencil stroke from a dict @@ -125,6 +126,7 @@ def dump_frame(frame): return dumped_frame + def load_frame(frame_data, frame): """ Load a grease pencil frame from a dict @@ -144,6 +146,7 @@ def load_frame(frame_data, frame): target_stroke = frame.strokes.new() load_stroke(stroke_data, target_stroke) + def dump_layer(layer): """ Dump a grease pencil layer @@ -199,6 +202,7 @@ def dump_layer(layer): return dumped_layer + def load_layer(layer_data, layer): """ Load a grease pencil layer from a dict @@ -220,8 +224,8 @@ def load_layer(layer_data, layer): class BlGpencil(BlDatablock): bl_id = "grease_pencils" bl_class = bpy.types.GreasePencil - bl_delay_refresh = 5 - bl_delay_apply = 5 + bl_delay_refresh = 2 + bl_delay_apply = 1 bl_automatic_push = True bl_icon = 'GREASEPENCIL' diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index 185eca0..51ca774 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -32,8 +32,8 @@ logger = logging.getLogger(__name__) class BlMesh(BlDatablock): bl_id = "meshes" bl_class = bpy.types.Mesh - bl_delay_refresh = 10 - bl_delay_apply = 10 + bl_delay_refresh = 2 + bl_delay_apply = 1 bl_automatic_push = True bl_icon = 'MESH_DATA' From cab4a8876b1cd2efcf571c3f933555d4e38ef99b Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 18:51:19 +0100 Subject: [PATCH 058/129] feat: CurveMapping support --- multi_user/libs/dump_anything.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/multi_user/libs/dump_anything.py b/multi_user/libs/dump_anything.py index b580779..d686085 100644 --- a/multi_user/libs/dump_anything.py +++ b/multi_user/libs/dump_anything.py @@ -67,7 +67,7 @@ def _load_filter_type(t, use_bl_rna=True): if use_bl_rna and x.bl_rna_property: return isinstance(x.bl_rna_property, t) else: - isinstance(x.read(), t) + return isinstance(x.read(), t) return filter_function @@ -370,6 +370,24 @@ class Loader: dumped_element ) + def _load_curve_mapping(self, element, dump): + mapping = element.read() + # cleanup existing curve + for curve in mapping.curves: + for idx in range(len(curve.points)): + if idx == 0: + break + + curve.points.remove(curve.points[1]) + for curve_index, curve in dump['curves'].items(): + for point_idx, point in curve['points'].items(): + pos = point['location'] + + if len(mapping.curves[curve_index].points) == 1: + mapping.curves[curve_index].points[int(point_idx)].location = pos + else: + mapping.curves[curve_index].points.new(pos[0],pos[1]) + def _load_pointer(self, pointer, dump): rna_property_type = pointer.bl_rna_property.fixed_type if not rna_property_type: @@ -434,6 +452,7 @@ class Loader: (_load_filter_type(mathutils.Vector, use_bl_rna=False), self._load_vector), (_load_filter_type(mathutils.Quaternion, use_bl_rna=False), self._load_quaternion), (_load_filter_type(mathutils.Euler, use_bl_rna=False), self._load_euler), + (_load_filter_type(T.CurveMapping, use_bl_rna=False), self._load_curve_mapping), (_load_filter_type(T.FloatProperty), self._load_identity), (_load_filter_type(T.StringProperty), self._load_identity), (_load_filter_type(T.EnumProperty), self._load_identity), From d7e47e5c14d317a0ae597f2cc85f3d026324e628 Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 18:51:35 +0100 Subject: [PATCH 059/129] refactor: clean material --- multi_user/bl_types/bl_material.py | 54 +++++++----------------------- multi_user/bl_types/bl_world.py | 2 +- 2 files changed, 13 insertions(+), 43 deletions(-) diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index b56d3c4..fb84a63 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -25,55 +25,25 @@ from ..libs import dump_anything from .bl_datablock import BlDatablock logger = logging.getLogger(__name__) -def clean_color_ramp(target_ramp): - # clear existing - try: - for key in target_ramp.elements: - target_ramp.elements.remove(key) - except: - pass - -def load_mapping(target_apping, source_mapping): - # clear existing curves - for curve in target_apping.curves: - for point in curve.points: - try: - curve.remove(point) - except: - continue - - # Load curves - for curve in source_mapping['curves']: - for point in source_mapping['curves'][curve]['points']: - pos = source_mapping['curves'][curve]['points'][point]['location'] - target_apping.curves[curve].points.new(pos[0],pos[1]) +def load_node(node_data, node_tree): + """ Load a node into a node_tree from a dict -def load_node(target_node_tree, source): - target_node = target_node_tree.nodes.get(source["name"]) + :arg node_data: dumped node data + :type node_data: dict + :arg node_tree: target node_tree + :type node_tree: bpy.types.NodeTree + """ + target_node = node_tree.nodes.new(type=node_data["bl_idname"]) - if target_node is None: - node_type = source["bl_idname"] + dump_anything.load(target_node, node_data) - target_node = target_node_tree.nodes.new(type=node_type) - - # Clean color ramp before loading it - if source['type'] == 'VALTORGB': - clean_color_ramp(target_node.color_ramp) - if source['type'] == 'CURVE_RGB': - load_mapping(target_node.mapping, source['mapping']) - dump_anything.load( - target_node, - source) - - if source['type'] == 'TEX_IMAGE': - target_node.image = bpy.data.images[source['image']] - for input in source["inputs"]: + for input in node_data["inputs"]: if hasattr(target_node.inputs[input], "default_value"): try: - target_node.inputs[input].default_value = source["inputs"][input]["default_value"] + target_node.inputs[input].default_value = node_data["inputs"][input]["default_value"] except: logger.error("{} not supported, skipping".format(input)) @@ -144,7 +114,7 @@ class BlMaterial(BlDatablock): # Load nodes for node in data["node_tree"]["nodes"]: - load_node(target.node_tree, data["node_tree"]["nodes"][node]) + load_node(data["node_tree"]["nodes"][node], target.node_tree) # Load nodes links target.node_tree.links.clear() diff --git a/multi_user/bl_types/bl_world.py b/multi_user/bl_types/bl_world.py index 21dace2..8f79dc1 100644 --- a/multi_user/bl_types/bl_world.py +++ b/multi_user/bl_types/bl_world.py @@ -43,7 +43,7 @@ class BlWorld(BlDatablock): target.node_tree.nodes.clear() for node in data["node_tree"]["nodes"]: - load_node(target.node_tree, data["node_tree"]["nodes"][node]) + load_node(data["node_tree"]["nodes"][node], target.node_tree) # Load nodes links target.node_tree.links.clear() From 3f0c31d771c5239cdf49e93eecdc3139f1e7dd4b Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 19:10:26 +0100 Subject: [PATCH 060/129] refactor: cleanup implementation --- multi_user/bl_types/bl_material.py | 59 ++++++++++++++++++------------ 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index fb84a63..228283d 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -126,6 +126,13 @@ class BlMaterial(BlDatablock): mat_dumper = dump_anything.Dumper() mat_dumper.depth = 2 mat_dumper.exclude_filter = [ + "is_embed_data", + "is_evaluated", + "name_full", + "bl_description", + "bl_icon", + "bl_idname", + "bl_label", "preview", "original", "uuid", @@ -134,40 +141,44 @@ class BlMaterial(BlDatablock): "line_color", "view_center", ] - node_dumper = dump_anything.Dumper() - node_dumper.depth = 1 - node_dumper.exclude_filter = [ - "dimensions", - "show_expanded" - "select", - "bl_height_min", - "bl_height_max", - "bl_width_min", - "bl_width_max", - "bl_width_default", - "hide", - "show_options", - "show_tetxures", - "show_preview", - "outputs", - "width_hidden" - ] - input_dumper = dump_anything.Dumper() - input_dumper.depth = 2 - input_dumper.include_filter = ["default_value"] - data = mat_dumper.dump(pointer) if pointer.use_nodes: nodes = {} - + node_dumper = dump_anything.Dumper() + node_dumper.depth = 1 + node_dumper.exclude_filter = [ + "dimensions", + "show_expanded", + "name_full", + "select", + "bl_height_min", + "bl_height_max", + "bl_width_min", + "bl_width_max", + "type", + "bl_icon", + "bl_width_default", + "bl_static_type", + "show_tetxure", + "hide", + "show_options", + "show_preview", + "outputs", + "width_hidden" + ] for node in pointer.node_tree.nodes: + nodes[node.name] = node_dumper.dump(node) if hasattr(node, 'inputs'): nodes[node.name]['inputs'] = {} - for i in node.inputs: + for i in node.inputs: + input_dumper = dump_anything.Dumper() + input_dumper.depth = 2 + input_dumper.include_filter = ["default_value"] + if hasattr(i, 'default_value'): nodes[node.name]['inputs'][i.name] = input_dumper.dump( i) From 98d86c050b3999031021b70de9404e37c561758b Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 26 Mar 2020 21:11:21 +0100 Subject: [PATCH 061/129] feat: update submodules --- multi_user/libs/replication | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_user/libs/replication b/multi_user/libs/replication index 42b3a31..70b2d24 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit 42b3a31b8ec44115f8fbc3697bdeeee74e608c6f +Subproject commit 70b2d24d15690540c0e0bee43bd82bf338f986c9 From ef9e9dbae8791773ca4c7ae8667def354b661ce1 Mon Sep 17 00:00:00 2001 From: Swann Date: Fri, 27 Mar 2020 15:50:25 +0100 Subject: [PATCH 062/129] feat: action refactoring --- multi_user/bl_types/bl_action.py | 261 +++++++++++++++++++++---------- 1 file changed, 179 insertions(+), 82 deletions(-) diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py index d903a4f..7d95e87 100644 --- a/multi_user/bl_types/bl_action.py +++ b/multi_user/bl_types/bl_action.py @@ -19,11 +19,179 @@ import bpy import mathutils import copy +import logging +import numpy as np +from enum import Enum from .. import utils from .bl_datablock import BlDatablock -# WIP +logger = logging.getLogger(__name__) + +ENUM_EASING_TYPE = [ + 'AUTO', + 'EAS_IN', + 'EASE_OUT', + 'EASE_IN_OUT'] + +ENUM_HANDLE_TYPE = [ + 'FREE', + 'ALIGNED', + 'VECTOR', + 'AUTO', + 'AUTO_CLAMPED'] + +ENUM_INTERPOLATION_TYPE = [ + 'CONSTANT', + 'LINEAR', + 'BEZIER', + 'SINE', + 'QUAD', + 'CUBIC', + 'QUART', + 'QUINT', + 'EXPO', + 'CIRC', + 'BACK', + 'BOUNCE', + 'ELASTIC'] + +ENUM_KEY_TYPE = [ + 'KEYFRAME', + 'BREAKDOWN', + 'MOVING_HOLD', + 'EXTREME', + 'JITTER'] + +#TODO: Automatic enum and numpy dump and loading + +def dump_fcurve(fcurve, use_numpy=True): + """ Dump a sigle curve to a dict + + :arg fcurve: fcurve to dump + :type fcurve: bpy.types.FCurve + :arg use_numpy: use numpy to eccelerate dump + :type use_numpy: bool + :return: dict + """ + fcurve_data = { + "data_path": fcurve.data_path, + "dumped_array_index": fcurve.array_index, + "use_numpy": use_numpy + } + + if use_numpy: + keyframes_count = len(fcurve.keyframe_points) + + k_amplitude = np.empty(keyframes_count, dtype=np.float64) + fcurve.keyframe_points.foreach_get('amplitude', k_amplitude) + k_co = np.empty(keyframes_count*2, dtype=np.float64) + fcurve.keyframe_points.foreach_get('co', k_co) + k_back = np.empty(keyframes_count, dtype=np.float64) + fcurve.keyframe_points.foreach_get('back', k_back) + k_handle_left = np.empty(keyframes_count*2, dtype=np.float64) + fcurve.keyframe_points.foreach_get('handle_left', k_handle_left) + k_handle_right = np.empty(keyframes_count*2, dtype=np.float64) + fcurve.keyframe_points.foreach_get('handle_right', k_handle_right) + + fcurve_data['amplitude'] = k_amplitude.tobytes() + fcurve_data['co'] = k_co.tobytes() + fcurve_data['back'] = k_back.tobytes() + fcurve_data['handle_left'] = k_handle_left.tobytes() + fcurve_data['handle_right'] = k_handle_right.tobytes() + + fcurve_data['easing'] = [ENUM_EASING_TYPE.index(p.easing) for p in fcurve.keyframe_points] + fcurve_data['handle_left_type'] = [ENUM_HANDLE_TYPE.index(p.handle_left_type) for p in fcurve.keyframe_points] + fcurve_data['handle_right_type'] = [ENUM_HANDLE_TYPE.index(p.handle_right_type) for p in fcurve.keyframe_points] + fcurve_data['type'] = [ENUM_KEY_TYPE.index(p.type) for p in fcurve.keyframe_points] + fcurve_data['interpolation'] = [ENUM_INTERPOLATION_TYPE.index(p.interpolation) for p in fcurve.keyframe_points] + + else: # Legacy method + dumper = utils.dump_anything.Dumper() + fcurve_data["keyframe_points"] = [] + + for k in fcurve.keyframe_points: + fcurve_data["keyframe_points"].append( + dumper.dump(k) + ) + + return fcurve_data + +def load_fcurve(fcurve_data, fcurve): + """ Load a dumped fcurve + + :arg fcurve_data: a dumped fcurve + :type fcurve_data: dict + :arg fcurve: fcurve to dump + :type fcurve: bpy.types.FCurve + """ + use_numpy = fcurve_data.get('use_numpy') + + keyframe_points = fcurve.keyframe_points + + # Remove all keyframe points + for i in range(len(keyframe_points)): + keyframe_points.remove(keyframe_points[0], fast=True) + + if use_numpy: + k_amplitude = np.frombuffer(fcurve_data['amplitude'], dtype=np.float64) + + keyframe_count = len(k_amplitude) + + k_co = np.frombuffer(fcurve_data['co'], dtype=np.float64) + k_back = np.frombuffer(fcurve_data['back'], dtype=np.float64) + k_amplitude = np.frombuffer(fcurve_data['amplitude'], dtype=np.float64) + k_handle_left= np.frombuffer(fcurve_data['handle_left'], dtype=np.float64) + k_handle_right= np.frombuffer(fcurve_data['handle_right'], dtype=np.float64) + + keyframe_points.add(keyframe_count) + + keyframe_points.foreach_set('co',k_co) + keyframe_points.foreach_set('back',k_back) + keyframe_points.foreach_set('amplitude',k_amplitude) + keyframe_points.foreach_set('handle_left',k_handle_left) + keyframe_points.foreach_set('handle_right',k_handle_right) + + for index, point in enumerate(keyframe_points): + point.type = ENUM_KEY_TYPE[fcurve_data['type'][index]] + point.easing = ENUM_EASING_TYPE[fcurve_data['easing'][index]] + point.handle_left_type = ENUM_HANDLE_TYPE[fcurve_data['handle_left_type'][index]] + point.handle_right_type = ENUM_HANDLE_TYPE[fcurve_data['handle_right_type'][index]] + point.interpolation = ENUM_INTERPOLATION_TYPE[fcurve_data['interpolation'][index]] + + else: + # paste dumped keyframes + for dumped_keyframe_point in fcurve_data["keyframe_points"]: + if dumped_keyframe_point['type'] == '': + dumped_keyframe_point['type'] = 'KEYFRAME' + + new_kf = keyframe_points.insert( + dumped_keyframe_point["co"][0], + dumped_keyframe_point["co"][1], + options={'FAST', 'REPLACE'} + ) + + keycache = copy.copy(dumped_keyframe_point) + keycache = utils.dump_anything.remove_items_from_dict( + keycache, + ["co", "handle_left", "handle_right", 'type'] + ) + + utils.dump_anything.load(new_kf, keycache) + + new_kf.type = dumped_keyframe_point['type'] + new_kf.handle_left = [ + dumped_keyframe_point["handle_left"][0], + dumped_keyframe_point["handle_left"][1] + ] + new_kf.handle_right = [ + dumped_keyframe_point["handle_right"][0], + dumped_keyframe_point["handle_right"][1] + ] + + fcurve.update() + + class BlAction(BlDatablock): bl_id = "actions" @@ -32,30 +200,11 @@ class BlAction(BlDatablock): bl_delay_apply = 1 bl_automatic_push = True bl_icon = 'ACTION_TWEAK' - + def _construct(self, data): return bpy.data.actions.new(data["name"]) def _load(self, data, target): - begin_frame = 100000 - end_frame = -100000 - - for dumped_fcurve in data["fcurves"]: - begin_frame = min( - begin_frame, - min( - [begin_frame] + [dkp["co"][0] for dkp in dumped_fcurve["keyframe_points"]] - ) - ) - end_frame = max( - end_frame, - max( - [end_frame] + [dkp["co"][0] for dkp in dumped_fcurve["keyframe_points"]] - ) - ) - begin_frame = 0 - - loader = utils.dump_anything.Loader() for dumped_fcurve in data["fcurves"]: dumped_data_path = dumped_fcurve["data_path"] dumped_array_index = dumped_fcurve["dumped_array_index"] @@ -65,53 +214,14 @@ class BlAction(BlDatablock): if fcurve is None: fcurve = target.fcurves.new(dumped_data_path, index=dumped_array_index) - - # remove keyframes within dumped_action range - for keyframe in reversed(fcurve.keyframe_points): - if end_frame >= (keyframe.co[0] + begin_frame ) >= begin_frame: - fcurve.keyframe_points.remove(keyframe, fast=True) - - # paste dumped keyframes - for dumped_keyframe_point in dumped_fcurve["keyframe_points"]: - if dumped_keyframe_point['type'] == '': - dumped_keyframe_point['type'] = 'KEYFRAME' - - new_kf = fcurve.keyframe_points.insert( - dumped_keyframe_point["co"][0] - begin_frame, - dumped_keyframe_point["co"][1], - options={'FAST', 'REPLACE'} - ) - - keycache = copy.copy(dumped_keyframe_point) - keycache = utils.dump_anything.remove_items_from_dict( - keycache, - ["co", "handle_left", "handle_right",'type'] - ) - - loader.load( - new_kf, - keycache - ) - - new_kf.type = dumped_keyframe_point['type'] - new_kf.handle_left = [ - dumped_keyframe_point["handle_left"][0] - begin_frame, - dumped_keyframe_point["handle_left"][1] - ] - new_kf.handle_right = [ - dumped_keyframe_point["handle_right"][0] - begin_frame, - dumped_keyframe_point["handle_right"][1] - ] - - # clearing (needed for blender to update well) - if len(fcurve.keyframe_points) == 0: - target.fcurves.remove(fcurve) - target.id_root= data['id_root'] + load_fcurve(dumped_fcurve, fcurve) + target.id_root = data['id_root'] def _dump(self, pointer=None): + start = utils.current_milli_time() assert(pointer) dumper = utils.dump_anything.Dumper() - dumper.exclude_filter =[ + dumper.exclude_filter = [ 'name_full', 'original', 'use_fake_user', @@ -124,27 +234,14 @@ class BlAction(BlDatablock): 'users' ] dumper.depth = 1 - data = dumper.dump(pointer) + data = dumper.dump(pointer) - data["fcurves"] = [] - dumper.depth = 2 + for fcurve in self.pointer.fcurves: - fc = { - "data_path": fcurve.data_path, - "dumped_array_index": fcurve.array_index, - "keyframe_points": [] - } - - for k in fcurve.keyframe_points: - fc["keyframe_points"].append( - dumper.dump(k) - ) - - data["fcurves"].append(fc) + data["fcurves"].append(dump_fcurve(fcurve, use_numpy=True)) + logger.error( + f"{self.pointer.name} dumping time: {utils.current_milli_time()-start} ms") return data - - - From 289a49251e1288d1b3fe78e1e8a24e9febec1f76 Mon Sep 17 00:00:00 2001 From: Swann Date: Fri, 27 Mar 2020 18:18:36 +0100 Subject: [PATCH 063/129] refactor: cleanup --- multi_user/bl_types/bl_action.py | 11 ++++++----- multi_user/bl_types/bl_armature.py | 2 -- multi_user/bl_types/bl_lightprobe.py | 8 ++++---- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py index 7d95e87..2ca2cf3 100644 --- a/multi_user/bl_types/bl_action.py +++ b/multi_user/bl_types/bl_action.py @@ -19,14 +19,12 @@ import bpy import mathutils import copy -import logging import numpy as np from enum import Enum from .. import utils from .bl_datablock import BlDatablock -logger = logging.getLogger(__name__) ENUM_EASING_TYPE = [ 'AUTO', @@ -34,6 +32,7 @@ ENUM_EASING_TYPE = [ 'EASE_OUT', 'EASE_IN_OUT'] + ENUM_HANDLE_TYPE = [ 'FREE', 'ALIGNED', @@ -41,6 +40,7 @@ ENUM_HANDLE_TYPE = [ 'AUTO', 'AUTO_CLAMPED'] + ENUM_INTERPOLATION_TYPE = [ 'CONSTANT', 'LINEAR', @@ -56,6 +56,7 @@ ENUM_INTERPOLATION_TYPE = [ 'BOUNCE', 'ELASTIC'] + ENUM_KEY_TYPE = [ 'KEYFRAME', 'BREAKDOWN', @@ -63,8 +64,10 @@ ENUM_KEY_TYPE = [ 'EXTREME', 'JITTER'] + #TODO: Automatic enum and numpy dump and loading + def dump_fcurve(fcurve, use_numpy=True): """ Dump a sigle curve to a dict @@ -218,7 +221,6 @@ class BlAction(BlDatablock): target.id_root = data['id_root'] def _dump(self, pointer=None): - start = utils.current_milli_time() assert(pointer) dumper = utils.dump_anything.Dumper() dumper.exclude_filter = [ @@ -241,7 +243,6 @@ class BlAction(BlDatablock): for fcurve in self.pointer.fcurves: data["fcurves"].append(dump_fcurve(fcurve, use_numpy=True)) - logger.error( - f"{self.pointer.name} dumping time: {utils.current_milli_time()-start} ms") + return data diff --git a/multi_user/bl_types/bl_armature.py b/multi_user/bl_types/bl_armature.py index c4abaf7..81d464f 100644 --- a/multi_user/bl_types/bl_armature.py +++ b/multi_user/bl_types/bl_armature.py @@ -23,8 +23,6 @@ from .. import utils from .. import presence, operators from .bl_datablock import BlDatablock -# WIP - class BlArmature(BlDatablock): bl_id = "armatures" diff --git a/multi_user/bl_types/bl_lightprobe.py b/multi_user/bl_types/bl_lightprobe.py index 1a3968a..abd20e9 100644 --- a/multi_user/bl_types/bl_lightprobe.py +++ b/multi_user/bl_types/bl_lightprobe.py @@ -33,16 +33,16 @@ class BlLightprobe(BlDatablock): bl_automatic_push = True bl_icon = 'LIGHTPROBE_GRID' - def load_implementation(self, data, target): - utils.dump_anything.load(target, data) - def _construct(self, data): type = 'CUBE' if data['type'] == 'CUBEMAP' else data['type'] # See https://developer.blender.org/D6396 if bpy.app.version[1] >= 83: return bpy.data.lightprobes.new(data["name"], type) else: - logger.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") + logger.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") + + def load_implementation(self, data, target): + utils.dump_anything.load(target, data) def dump_implementation(self, data, pointer=None): assert(pointer) From 922538dc3abeb0bdfc2b50da540548bd822c3584 Mon Sep 17 00:00:00 2001 From: Swann Date: Fri, 27 Mar 2020 18:52:23 +0100 Subject: [PATCH 064/129] feat: update submodule --- multi_user/libs/replication | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_user/libs/replication b/multi_user/libs/replication index 70b2d24..83caa04 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit 70b2d24d15690540c0e0bee43bd82bf338f986c9 +Subproject commit 83caa04e71ca7ded8ba44fb12cf8042e61308d7a From 79ba63ce8591a76d3b5ba8e3682540252a1aa459 Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 30 Mar 2020 14:31:35 +0200 Subject: [PATCH 065/129] feat: attribute dump function refactor: cleanup utils --- multi_user/bl_types/bl_action.py | 85 +++++++-------- multi_user/bl_types/bl_datablock.py | 25 ++++- multi_user/bl_types/bl_gpencil.py | 56 +++++----- multi_user/bl_types/bl_material.py | 1 - multi_user/bl_types/bl_mesh.py | 158 +++++++++------------------- multi_user/libs/dump_anything.py | 63 ++++++++++- multi_user/preferences.py | 20 +++- multi_user/utils.py | 92 ---------------- 8 files changed, 210 insertions(+), 290 deletions(-) diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py index 2ca2cf3..9e736be 100644 --- a/multi_user/bl_types/bl_action.py +++ b/multi_user/bl_types/bl_action.py @@ -23,6 +23,8 @@ import numpy as np from enum import Enum from .. import utils +from ..libs.dump_anything import ( + Dumper, Loader, dump_collection_attr, load_collection_attr) from .bl_datablock import BlDatablock @@ -65,7 +67,7 @@ ENUM_KEY_TYPE = [ 'JITTER'] -#TODO: Automatic enum and numpy dump and loading +# TODO: Automatic enum and numpy dump and loading def dump_fcurve(fcurve, use_numpy=True): @@ -84,32 +86,27 @@ def dump_fcurve(fcurve, use_numpy=True): } if use_numpy: - keyframes_count = len(fcurve.keyframe_points) + points = fcurve.keyframe_points + fcurve_data['keyframes_count'] = len(fcurve.keyframe_points) - k_amplitude = np.empty(keyframes_count, dtype=np.float64) - fcurve.keyframe_points.foreach_get('amplitude', k_amplitude) - k_co = np.empty(keyframes_count*2, dtype=np.float64) - fcurve.keyframe_points.foreach_get('co', k_co) - k_back = np.empty(keyframes_count, dtype=np.float64) - fcurve.keyframe_points.foreach_get('back', k_back) - k_handle_left = np.empty(keyframes_count*2, dtype=np.float64) - fcurve.keyframe_points.foreach_get('handle_left', k_handle_left) - k_handle_right = np.empty(keyframes_count*2, dtype=np.float64) - fcurve.keyframe_points.foreach_get('handle_right', k_handle_right) + fcurve_data['amplitude'] = dump_collection_attr(points, 'amplitude') + fcurve_data['co'] = dump_collection_attr(points, 'co') + fcurve_data['back'] = dump_collection_attr(points, 'back') + fcurve_data['handle_left'] = dump_collection_attr(points, 'handle_left') + fcurve_data['handle_right'] = dump_collection_attr(points, 'handle_right') - fcurve_data['amplitude'] = k_amplitude.tobytes() - fcurve_data['co'] = k_co.tobytes() - fcurve_data['back'] = k_back.tobytes() - fcurve_data['handle_left'] = k_handle_left.tobytes() - fcurve_data['handle_right'] = k_handle_right.tobytes() + fcurve_data['easing'] = [ENUM_EASING_TYPE.index( + p.easing) for p in fcurve.keyframe_points] + fcurve_data['handle_left_type'] = [ENUM_HANDLE_TYPE.index( + p.handle_left_type) for p in fcurve.keyframe_points] + fcurve_data['handle_right_type'] = [ENUM_HANDLE_TYPE.index( + p.handle_right_type) for p in fcurve.keyframe_points] + fcurve_data['type'] = [ENUM_KEY_TYPE.index( + p.type) for p in fcurve.keyframe_points] + fcurve_data['interpolation'] = [ENUM_INTERPOLATION_TYPE.index( + p.interpolation) for p in fcurve.keyframe_points] - fcurve_data['easing'] = [ENUM_EASING_TYPE.index(p.easing) for p in fcurve.keyframe_points] - fcurve_data['handle_left_type'] = [ENUM_HANDLE_TYPE.index(p.handle_left_type) for p in fcurve.keyframe_points] - fcurve_data['handle_right_type'] = [ENUM_HANDLE_TYPE.index(p.handle_right_type) for p in fcurve.keyframe_points] - fcurve_data['type'] = [ENUM_KEY_TYPE.index(p.type) for p in fcurve.keyframe_points] - fcurve_data['interpolation'] = [ENUM_INTERPOLATION_TYPE.index(p.interpolation) for p in fcurve.keyframe_points] - - else: # Legacy method + else: # Legacy method dumper = utils.dump_anything.Dumper() fcurve_data["keyframe_points"] = [] @@ -120,6 +117,7 @@ def dump_fcurve(fcurve, use_numpy=True): return fcurve_data + def load_fcurve(fcurve_data, fcurve): """ Load a dumped fcurve @@ -129,31 +127,21 @@ def load_fcurve(fcurve_data, fcurve): :type fcurve: bpy.types.FCurve """ use_numpy = fcurve_data.get('use_numpy') - + keyframe_points = fcurve.keyframe_points - + # Remove all keyframe points - for i in range(len(keyframe_points)): + for i in range(len(keyframe_points)): keyframe_points.remove(keyframe_points[0], fast=True) if use_numpy: - k_amplitude = np.frombuffer(fcurve_data['amplitude'], dtype=np.float64) + keyframe_points.add(fcurve_data['keyframes_count']) - keyframe_count = len(k_amplitude) - - k_co = np.frombuffer(fcurve_data['co'], dtype=np.float64) - k_back = np.frombuffer(fcurve_data['back'], dtype=np.float64) - k_amplitude = np.frombuffer(fcurve_data['amplitude'], dtype=np.float64) - k_handle_left= np.frombuffer(fcurve_data['handle_left'], dtype=np.float64) - k_handle_right= np.frombuffer(fcurve_data['handle_right'], dtype=np.float64) - - keyframe_points.add(keyframe_count) - - keyframe_points.foreach_set('co',k_co) - keyframe_points.foreach_set('back',k_back) - keyframe_points.foreach_set('amplitude',k_amplitude) - keyframe_points.foreach_set('handle_left',k_handle_left) - keyframe_points.foreach_set('handle_right',k_handle_right) + load_collection_attr(keyframe_points, 'co', fcurve_data['co']) + load_collection_attr(keyframe_points, 'back', fcurve_data['back']) + load_collection_attr(keyframe_points, 'amplitude', fcurve_data['amplitude']) + load_collection_attr(keyframe_points, 'handle_left', fcurve_data['handle_left']) + load_collection_attr(keyframe_points, 'handle_right', fcurve_data['handle_right']) for index, point in enumerate(keyframe_points): point.type = ENUM_KEY_TYPE[fcurve_data['type'][index]] @@ -162,7 +150,7 @@ def load_fcurve(fcurve_data, fcurve): point.handle_right_type = ENUM_HANDLE_TYPE[fcurve_data['handle_right_type'][index]] point.interpolation = ENUM_INTERPOLATION_TYPE[fcurve_data['interpolation'][index]] - else: + else: # paste dumped keyframes for dumped_keyframe_point in fcurve_data["keyframe_points"]: if dumped_keyframe_point['type'] == '': @@ -193,7 +181,6 @@ def load_fcurve(fcurve_data, fcurve): ] fcurve.update() - class BlAction(BlDatablock): @@ -213,9 +200,11 @@ class BlAction(BlDatablock): dumped_array_index = dumped_fcurve["dumped_array_index"] # create fcurve if needed - fcurve = target.fcurves.find(dumped_data_path, index=dumped_array_index) + fcurve = target.fcurves.find( + dumped_data_path, index=dumped_array_index) if fcurve is None: - fcurve = target.fcurves.new(dumped_data_path, index=dumped_array_index) + fcurve = target.fcurves.new( + dumped_data_path, index=dumped_array_index) load_fcurve(dumped_fcurve, fcurve) target.id_root = data['id_root'] @@ -243,6 +232,4 @@ class BlAction(BlDatablock): for fcurve in self.pointer.fcurves: data["fcurves"].append(dump_fcurve(fcurve, use_numpy=True)) - return data - diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index 3a5d8b3..1aeb8be 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -22,7 +22,24 @@ import mathutils from .. import utils from ..libs.replication.replication.data import ReplicatedDatablock from ..libs.replication.replication.constants import (UP, DIFF_BINARY) -from ..libs import dump_anything +from ..libs import dump_anything + + +def has_action(target): + """ Check if the target datablock has actions + """ + return (hasattr(target, 'animation_data') + and target.animation_data + and target.animation_data.action) + + +def has_driver(target): + """ Check if the target datablock is driven + """ + return (hasattr(target, 'animation_data') + and target.animation_data + and target.animation_data.drivers) + def dump_driver(driver): dumper = dump_anything.Dumper() @@ -112,12 +129,12 @@ class BlDatablock(ReplicatedDatablock): def _dump(self, pointer=None): data = {} # Dump animation data - if utils.has_action(pointer): + if has_action(pointer): dumper = utils.dump_anything.Dumper() dumper.include_filter = ['action'] data['animation_data'] = dumper.dump(pointer.animation_data) - if utils.has_driver(pointer): + if has_driver(pointer): dumped_drivers = {'animation_data': {'drivers': []}} for driver in pointer.animation_data.drivers: dumped_drivers['animation_data']['drivers'].append( @@ -162,7 +179,7 @@ class BlDatablock(ReplicatedDatablock): def resolve_deps(self): dependencies = [] - if utils.has_action(self.pointer): + if has_action(self.pointer): dependencies.append(self.pointer.animation_data.action) if not self.is_library: diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index 79ada32..7d99127 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -20,7 +20,10 @@ import bpy import mathutils import numpy as np -from ..libs import dump_anything +from ..libs.dump_anything import (Dumper, + Loader, + dump_collection_attr, + load_collection_attr) from .bl_datablock import BlDatablock # GPencil data api is structured as it follow: @@ -36,7 +39,7 @@ def dump_stroke(stroke): assert(stroke) - dumper = dump_anything.Dumper() + dumper = Dumper() dumper.include_filter = [ "aspect", "display_mode", @@ -56,23 +59,12 @@ def dump_stroke(stroke): # Stoke points p_count = len(stroke.points) dumped_stroke['p_count'] = p_count - - p_co = np.empty(p_count*3, dtype=np.float64) - stroke.points.foreach_get('co', p_co) - dumped_stroke['p_co'] = p_co.tobytes() - - p_pressure = np.empty(p_count, dtype=np.float64) - stroke.points.foreach_get('pressure', p_pressure) - dumped_stroke['p_pressure'] = p_pressure.tobytes() - - p_strength = np.empty(p_count, dtype=np.float64) - stroke.points.foreach_get('strength', p_strength) - dumped_stroke['p_strength'] = p_strength.tobytes() + dumped_stroke['p_co'] = dump_collection_attr(stroke.points,'co') + dumped_stroke['p_pressure'] = dump_collection_attr(stroke.points,'pressure') + dumped_stroke['p_strength'] = dump_collection_attr(stroke.points,'strength') if bpy.app.version[1] >= 83: # new in blender 2.83 - p_vertex_color = np.empty(p_count*4, dtype=np.float64) - stroke.points.foreach_get('vertex_color', p_vertex_color) - dumped_stroke['p_vertex_color'] = p_vertex_color.tobytes() + dumped_stroke['p_vertex_color'] = dump_collection_attr(stroke.points,'vertex_color') # TODO: uv_factor, uv_rotation @@ -89,21 +81,17 @@ def load_stroke(stroke_data, stroke): """ assert(stroke and stroke_data) - dump_anything.load(stroke, stroke_data) - - p_co = np.frombuffer(stroke_data["p_co"], dtype=np.float64) - p_pressure = np.frombuffer(stroke_data["p_pressure"], dtype=np.float64) - p_strength = np.frombuffer(stroke_data["p_strength"], dtype=np.float64) + loader = Loader() + loader.load(stroke, stroke_data) stroke.points.add(stroke_data["p_count"]) - stroke.points.foreach_set('co', p_co) - stroke.points.foreach_set('pressure', p_pressure) - stroke.points.foreach_set('strength', p_strength) + load_collection_attr(stroke.points, 'co',stroke_data["p_co"]) + load_collection_attr(stroke.points, 'pressure',stroke_data["p_pressure"]) + load_collection_attr(stroke.points, 'strength',stroke_data["p_strength"]) if "p_vertex_color" in stroke_data: - p_vertex_color = np.frombuffer(stroke_data["p_vertex_color"], dtype=np.float64) - stroke.points.foreach_set('vertex_color', p_vertex_color) + load_collection_attr(stroke.points, 'vertex_color',stroke_data["p_vertex_color"]) def dump_frame(frame): @@ -156,7 +144,7 @@ def dump_layer(layer): assert(layer) - dumper = dump_anything.Dumper() + dumper = Dumper() dumper.include_filter = [ 'info', @@ -212,7 +200,8 @@ def load_layer(layer_data, layer): :type layer: bpy.types.GPencilFrame """ # TODO: take existing data in account - dump_anything.load(layer, layer_data) + loader = Loader() + loader.load(layer, layer_data) for frame_data in layer_data["frames"]: target_frame = layer.frames.new(frame_data['frame_number']) @@ -253,14 +242,17 @@ class BlGpencil(BlDatablock): # target_layer.clear() load_layer(layer_data, target_layer) - - dump_anything.load(target, data) + + loader = Loader() + loader.load(target, data) def dump_implementation(self, data, pointer=None): assert(pointer) - data = dump_anything.dump(pointer, 2) + dumper = Dumper() + dumper.depth = 2 + data = dumper.dump(pointer) data['layers'] = {} diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index 228283d..a3ecdb0 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -102,7 +102,6 @@ class BlMaterial(BlDatablock): dump_anything.load( target.grease_pencil, data['grease_pencil']) - utils.load_dict(data['grease_pencil'], target.grease_pencil) elif data["use_nodes"]: if target.node_tree is None: diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index 51ca774..83a9358 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -22,7 +22,7 @@ import mathutils import logging import numpy as np -from .. import utils +from ..libs.dump_anything import Dumper, Loader, load_collection_attr, dump_collection_attr from ..libs.replication.replication.constants import DIFF_BINARY from .bl_datablock import BlDatablock @@ -44,8 +44,9 @@ class BlMesh(BlDatablock): def load_implementation(self, data, target): if not target or not target.is_editmode: - utils.dump_anything.load(target, data) - + loader = Loader() + loader.load(target, data) + # MATERIAL SLOTS target.materials.clear() @@ -56,60 +57,34 @@ class BlMesh(BlDatablock): if target.vertices: target.clear_geometry() - # VERTS - vertices = np.frombuffer(data["verts_co"], dtype=np.float64) - vert_count = int(len(vertices)/3) - target.vertices.add(vert_count) + target.vertices.add(data["vertex_count"]) + target.edges.add(data["egdes_count"]) + target.loops.add(data["loop_count"]) + target.polygons.add(data["poly_count"]) - # EDGES - - egdes_vert = np.frombuffer(data["egdes_vert"], dtype=np.int) - - edge_count = data["egdes_count"] - target.edges.add(edge_count) - - - - # LOOPS - loops_count = data["loop_count"] - target.loops.add(loops_count) - - loop_vertex_index = np.frombuffer( - data['loop_vertex_index'], dtype=np.int) - loop_normal = np.frombuffer(data['loop_normal'], dtype=np.float64) - - # POLY - poly_count = data["poly_count"] - target.polygons.add(poly_count) - - poly_loop_start = np.frombuffer( - data["poly_loop_start"], dtype=np.int) - poly_loop_total = np.frombuffer( - data["poly_loop_total"], dtype=np.int) - poly_smooth = np.frombuffer(data["poly_smooth"], dtype=np.bool) - - poly_mat = np.frombuffer(data["poly_mat"], dtype=np.int) - - # LOADING - target.vertices.foreach_set('co', vertices) - target.edges.foreach_set("vertices", egdes_vert) - + # LOADING + load_collection_attr(target.vertices, 'co', data["verts_co"]) + load_collection_attr(target.edges, "vertices", data["egdes_vert"]) if data['use_customdata_edge_crease']: - edges_crease = np.frombuffer(data["edges_crease"], dtype=np.float64) - target.edges.foreach_set("crease", edges_crease) - - if data['use_customdata_edge_bevel']: - edges_bevel = np.frombuffer(data["edges_bevel"], dtype=np.float64) - target.edges.foreach_set("bevel_weight", edges_bevel) + load_collection_attr( + target.edges, "crease", data["edges_crease"]) + + if data['use_customdata_edge_bevel']: + load_collection_attr( + target.edges, "bevel_weight", data["edges_bevel"]) + + load_collection_attr( + target.loops, 'vertex_index', data["loop_vertex_index"]) + load_collection_attr(target.loops, 'normal', data["loop_normal"]) + load_collection_attr( + target.polygons, 'loop_total', data["poly_loop_total"]) + load_collection_attr( + target.polygons, 'loop_start', data["poly_loop_start"]) + load_collection_attr( + target.polygons, 'use_smooth', data["poly_smooth"]) + load_collection_attr( + target.polygons, 'material_index', data["poly_mat"]) - target.loops.foreach_set("vertex_index", loop_vertex_index) - target.loops.foreach_set("normal", loop_normal) - target.polygons.foreach_set("loop_total", poly_loop_total) - target.polygons.foreach_set("loop_start", poly_loop_start) - target.polygons.foreach_set("use_smooth", poly_smooth) - target.polygons.foreach_set("material_index", poly_mat) - - # UV Layers for layer in data['uv_layers']: if layer not in target.uv_layers: @@ -121,14 +96,13 @@ class BlMesh(BlDatablock): target.validate() target.update() - def dump_implementation(self, data, pointer=None): assert(pointer) mesh = pointer - dumper = utils.dump_anything.Dumper() + dumper = Dumper() dumper.depth = 1 dumper.include_filter = [ 'name', @@ -140,73 +114,43 @@ class BlMesh(BlDatablock): data = dumper.dump(mesh) - # TODO: selective dump # VERTICES - vert_count = len(mesh.vertices) + data["vertex_count"] = len(mesh.vertices) + data["verts_co"] = dump_collection_attr(mesh.vertices, 'co') - verts_co = np.empty(vert_count*3, dtype=np.float64) - mesh.vertices.foreach_get('co', verts_co) - data["verts_co"] = verts_co.tobytes() - - # EDGES - edge_count = len(mesh.edges) - - edges_vert = np.empty(edge_count*2, dtype=np.int) - mesh.edges.foreach_get('vertices', edges_vert) - data["egdes_vert"] = edges_vert.tobytes() + # EDGES data["egdes_count"] = len(mesh.edges) + data["egdes_vert"] = dump_collection_attr(mesh.edges, 'vertices') if mesh.use_customdata_edge_crease: - edges_crease = np.empty(edge_count, dtype=np.float64) - mesh.edges.foreach_get('crease', edges_crease) - data["edges_crease"] = edges_crease.tobytes() + data["edges_crease"] = dump_collection_attr(mesh.edges, 'crease') if mesh.use_customdata_edge_bevel: - edges_bevel = np.empty(edge_count, dtype=np.float64) - mesh.edges.foreach_get('bevel_weight', edges_bevel) - data["edges_bevel"] = edges_bevel.tobytes() + data["edges_bevel"] = dump_collection_attr( + mesh.edges, 'edges_bevel') # POLYGONS - poly_count = len(mesh.polygons) - data["poly_count"] = poly_count - - poly_mat = np.empty(poly_count, dtype=np.int) - mesh.polygons.foreach_get("material_index", poly_mat) - data["poly_mat"] = poly_mat.tobytes() - - poly_loop_start = np.empty(poly_count, dtype=np.int) - mesh.polygons.foreach_get("loop_start", poly_loop_start) - data["poly_loop_start"] = poly_loop_start.tobytes() - - poly_loop_total = np.empty(poly_count, dtype=np.int) - mesh.polygons.foreach_get("loop_total", poly_loop_total) - data["poly_loop_total"] = poly_loop_total.tobytes() - - poly_smooth = np.empty(poly_count, dtype=np.bool) - mesh.polygons.foreach_get("use_smooth", poly_smooth) - data["poly_smooth"] = poly_smooth.tobytes() + data["poly_count"] = len(mesh.polygons) + data["poly_mat"] = dump_collection_attr( + mesh.polygons, 'material_index') + data["poly_loop_start"] = dump_collection_attr( + mesh.polygons, 'loop_start') + data["poly_loop_total"] = dump_collection_attr( + mesh.polygons, 'loop_total') + data["poly_smooth"] = dump_collection_attr(mesh.polygons, 'use_smooth') # LOOPS - loop_count = len(mesh.loops) - data["loop_count"] = loop_count - - loop_normal = np.empty(loop_count*3, dtype=np.float64) - mesh.loops.foreach_get("normal", loop_normal) - data["loop_normal"] = loop_normal.tobytes() - - loop_vertex_index = np.empty(loop_count, dtype=np.int) - mesh.loops.foreach_get("vertex_index", loop_vertex_index) - data["loop_vertex_index"] = loop_vertex_index.tobytes() + data["loop_count"] = len(mesh.loops) + data["loop_normal"] = dump_collection_attr(mesh.loops, 'normal') + data["loop_vertex_index"] = dump_collection_attr( + mesh.loops, 'vertex_index') # UV Layers data['uv_layers'] = {} for layer in mesh.uv_layers: data['uv_layers'][layer.name] = {} - - uv_layer = np.empty(len(layer.data)*2, dtype=np.float64) - layer.data.foreach_get("uv", uv_layer) - - data['uv_layers'][layer.name]['data'] = uv_layer.tobytes() + data['uv_layers'][layer.name]['data'] = dump_collection_attr( + layer.data, 'uv') # Fix material index m_list = [] diff --git a/multi_user/libs/dump_anything.py b/multi_user/libs/dump_anything.py index d686085..ba12f8e 100644 --- a/multi_user/libs/dump_anything.py +++ b/multi_user/libs/dump_anything.py @@ -20,9 +20,68 @@ import logging import bpy import bpy.types as T import mathutils +import numpy as np logger = logging.getLogger(__name__) +BPY_TO_NUMPY_TYPES = { + 'FLOAT': np.float, + 'INT': np.int, + 'BOOL': np.bool +} + + +def dump_collection_attr(collection, attribute): + """ Dump a collection attribute as a sequence + + !!! warning + Only work with int, float and bool attributes + + :arg collection: target collection + :type collection: bpy.types.CollectionProperty + :arg attribute: target attribute + :type attribute: str + :return: numpy byte buffer + """ + + attr_infos = collection[0].bl_rna.properties.get(attribute) + + assert(attr_infos.type in ['FLOAT', 'INT', 'BOOLEAN']) + + size = sum(attr_infos.array_dimensions) if attr_infos.is_array else 1 + + dumped_sequence = np.zeros( + len(collection)*size, + dtype=BPY_TO_NUMPY_TYPES.get(attr_infos.type)) + + collection.foreach_get(attribute, dumped_sequence) + + return dumped_sequence.tobytes() + + +def load_collection_attr(collection, attribute, sequence): + """ Load a collection attribute from a bytes sequence + + !!! warning + Only work with int, float and bool attributes + + :arg collection: target collection + :type collection: bpy.types.CollectionProperty + :arg attribute: target attribute + :type attribute: str + :return: numpy byte buffer + """ + + attr_infos = collection[0].bl_rna.properties.get(attribute) + + assert(attr_infos.type in ['FLOAT', 'INT', 'BOOLEAN']) + + # TODO: check types match + collection.foreach_set( + attribute, + np.frombuffer(sequence, dtype=BPY_TO_NUMPY_TYPES.get(attr_infos.type))) + + def remove_items_from_dict(d, keys, recursive=False): copy = dict(d) for k in keys: @@ -32,7 +91,7 @@ def remove_items_from_dict(d, keys, recursive=False): copy[k] = remove_items_from_dict(copy[k], keys, recursive) return copy - + def _is_dictionnary(v): return hasattr(v, "items") and callable(v.items) @@ -94,6 +153,7 @@ def _load_filter_default(default): class Dumper: # TODO: support occlude readonly + # TODO: use foreach_set/get on collection compatible properties def __init__(self): self.verbose = True self.depth = 1 @@ -325,7 +385,6 @@ class Loader: T.ParticleSettingsTextureSlot: (CONSTRUCTOR_ADD, []), T.Modifier: (CONSTRUCTOR_NEW, ["name", "type"]), T.Constraint: (CONSTRUCTOR_NEW, ["type"]), - # T.VertexGroup: (CONSTRUCTOR_NEW, ["name"], True), } destructors = { diff --git a/multi_user/preferences.py b/multi_user/preferences.py index 29f892c..24317c9 100644 --- a/multi_user/preferences.py +++ b/multi_user/preferences.py @@ -15,14 +15,28 @@ # # ##### END GPL LICENSE BLOCK ##### - +import random import logging import bpy +import string from . import utils, bl_types, environment, addon_updater_ops, presence logger = logging.getLogger(__name__) +def randomColor(): + """Generate a random color """ + r = random.random() + v = random.random() + b = random.random() + return [r, v, b] + + +def random_string_digits(stringLength=6): + """Generate a random string of letters and digits """ + lettersAndDigits = string.ascii_letters + string.digits + return ''.join(random.choices(lettersAndDigits, k=stringLength)) + class ReplicatedDatablock(bpy.types.PropertyGroup): type_name: bpy.props.StringProperty() @@ -43,12 +57,12 @@ class SessionPrefs(bpy.types.AddonPreferences): default="127.0.0.1") username: bpy.props.StringProperty( name="Username", - default="user_{}".format(utils.random_string_digits()) + default=f"user_{random_string_digits()}" ) client_color: bpy.props.FloatVectorProperty( name="client_instance_color", subtype='COLOR', - default=utils.randomColor()) + default=randomColor()) port: bpy.props.IntProperty( name="port", description='Distant host port', diff --git a/multi_user/utils.py b/multi_user/utils.py index cd69456..270766d 100644 --- a/multi_user/utils.py +++ b/multi_user/utils.py @@ -19,8 +19,6 @@ import json import logging import os -import random -import string import sys import time from uuid import uuid4 @@ -35,17 +33,6 @@ from .libs import dump_anything logger = logging.getLogger(__name__) logger.setLevel(logging.WARNING) -def has_action(target): - return (hasattr(target, 'animation_data') - and target.animation_data - and target.animation_data.action) - - -def has_driver(target): - return (hasattr(target, 'animation_data') - and target.animation_data - and target.animation_data.drivers) - def find_from_attr(attr_name, attr_value, list): for item in list: @@ -73,19 +60,6 @@ def get_datablock_users(datablock): return users -def random_string_digits(stringLength=6): - """Generate a random string of letters and digits """ - lettersAndDigits = string.ascii_letters + string.digits - return ''.join(random.choices(lettersAndDigits, k=stringLength)) - - -def randomColor(): - r = random.random() - v = random.random() - b = random.random() - return [r, v, b] - - def clean_scene(): for type_name in dir(bpy.data): try: @@ -96,76 +70,10 @@ def clean_scene(): continue -def revers(d): - l = [] - for i in d: - l.append(i) - - return l[::-1] - - -def get_armature_edition_context(armature): - - override = {} - # Set correct area - for area in bpy.data.window_managers[0].windows[0].screen.areas: - if area.type == 'VIEW_3D': - override = bpy.context.copy() - override['area'] = area - break - - # Set correct armature settings - override['window'] = bpy.data.window_managers[0].windows[0] - override['screen'] = bpy.data.window_managers[0].windows[0].screen - override['mode'] = 'EDIT_ARMATURE' - override['active_object'] = armature - override['selected_objects'] = [armature] - - for o in bpy.data.objects: - if o.data == armature: - override['edit_object'] = o - - break - - return override - - 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)] -def load_dict(src_dict, target): - try: - for item in src_dict: - # attr = - setattr(target, item, src_dict[item]) - - except Exception as e: - logger.error(e) - pass - - -def dump_datablock_attibutes(datablock=None, attributes=[], depth=1, dickt=None): - if datablock: - dumper = dump_anything.Dumper() - dumper.type_subset = dumper.match_subset_all - dumper.depth = depth - - datablock_type = datablock.bl_rna.name - - data = {} - - if dickt: - data = dickt - for attr in attributes: - try: - data[attr] = dumper.dump(getattr(datablock, attr)) - except: - pass - - return data - - def resolve_from_id(id, optionnal_type=None): for category in dir(bpy.data): root = getattr(bpy.data, category) From 219973930bcee60c07c5696cf5d223c3cd05a6b1 Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 30 Mar 2020 15:01:48 +0200 Subject: [PATCH 066/129] fix: annotation settings --- multi_user/bl_types/bl_gpencil.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index 7d99127..bb02332 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -151,17 +151,17 @@ def dump_layer(layer): 'opacity', 'channel_color', 'color', - # 'thickness', + 'thickness', 'tint_color', 'tint_factor', 'vertex_paint_opacity', 'line_change', 'use_onion_skinning', - # 'use_annotation_onion_skinning', - # 'annotation_onion_before_range', - # 'annotation_onion_after_range', - # 'annotation_onion_before_color', - # 'annotation_onion_after_color', + 'use_annotation_onion_skinning', + 'annotation_onion_before_range', + 'annotation_onion_after_range', + 'annotation_onion_before_color', + 'annotation_onion_after_color', 'pass_index', # 'viewlayer_render', 'blend_mode', From c20777f86055737c00903f69759a56d2256110c0 Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 30 Mar 2020 17:15:38 +0200 Subject: [PATCH 067/129] feat: update submodule --- multi_user/libs/replication | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_user/libs/replication b/multi_user/libs/replication index 70b2d24..83caa04 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit 70b2d24d15690540c0e0bee43bd82bf338f986c9 +Subproject commit 83caa04e71ca7ded8ba44fb12cf8042e61308d7a From acbf897f5d4d33ad733524a20b34b760c19960d2 Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 30 Mar 2020 17:21:28 +0200 Subject: [PATCH 068/129] refactor: rename def --- multi_user/bl_types/bl_armature.py | 4 ++-- multi_user/bl_types/bl_camera.py | 4 ++-- multi_user/bl_types/bl_collection.py | 6 +++--- multi_user/bl_types/bl_curve.py | 4 ++-- multi_user/bl_types/bl_datablock.py | 12 ++++++------ multi_user/bl_types/bl_gpencil.py | 6 +++--- multi_user/bl_types/bl_lattice.py | 4 ++-- multi_user/bl_types/bl_light.py | 4 ++-- multi_user/bl_types/bl_lightprobe.py | 4 ++-- multi_user/bl_types/bl_material.py | 6 +++--- multi_user/bl_types/bl_mesh.py | 6 +++--- multi_user/bl_types/bl_metaball.py | 2 +- multi_user/bl_types/bl_object.py | 6 +++--- multi_user/bl_types/bl_scene.py | 6 +++--- multi_user/bl_types/bl_speaker.py | 4 ++-- multi_user/bl_types/bl_world.py | 6 +++--- 16 files changed, 42 insertions(+), 42 deletions(-) diff --git a/multi_user/bl_types/bl_armature.py b/multi_user/bl_types/bl_armature.py index 81d464f..dcf85e3 100644 --- a/multi_user/bl_types/bl_armature.py +++ b/multi_user/bl_types/bl_armature.py @@ -35,7 +35,7 @@ class BlArmature(BlDatablock): def _construct(self, data): return bpy.data.armatures.new(data["name"]) - def load_implementation(self, data, target): + def _load_implementation(self, data, target): # Load parent object parent_object = utils.find_from_attr( 'uuid', @@ -107,7 +107,7 @@ class BlArmature(BlDatablock): if 'EDIT' in current_mode: bpy.ops.object.mode_set(mode='EDIT') - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) dumper = utils.dump_anything.Dumper() diff --git a/multi_user/bl_types/bl_camera.py b/multi_user/bl_types/bl_camera.py index 4876010..aa4005e 100644 --- a/multi_user/bl_types/bl_camera.py +++ b/multi_user/bl_types/bl_camera.py @@ -35,7 +35,7 @@ class BlCamera(BlDatablock): return bpy.data.cameras.new(data["name"]) - def load_implementation(self, data, target): + def _load_implementation(self, data, target): utils.dump_anything.load(target, data) dof_settings = data.get('dof') @@ -44,7 +44,7 @@ class BlCamera(BlDatablock): if dof_settings: utils.dump_anything.load(target.dof, dof_settings) - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) # TODO: background image support diff --git a/multi_user/bl_types/bl_collection.py b/multi_user/bl_types/bl_collection.py index 3d3f9f7..87ecc08 100644 --- a/multi_user/bl_types/bl_collection.py +++ b/multi_user/bl_types/bl_collection.py @@ -46,7 +46,7 @@ class BlCollection(BlDatablock): instance.uuid = self.uuid return instance - def load_implementation(self, data, target): + def _load_implementation(self, data, target): # Load other meshes metadata # dump_anything.load(target, data) target.name = data["name"] @@ -72,7 +72,7 @@ class BlCollection(BlDatablock): if collection.uuid not in data["children"]: target.children.unlink(collection) - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) data = {} data['name'] = pointer.name @@ -95,7 +95,7 @@ class BlCollection(BlDatablock): return data - def resolve_deps_implementation(self): + def _resolve_deps_implementation(self): deps = [] for child in self.pointer.children: diff --git a/multi_user/bl_types/bl_curve.py b/multi_user/bl_types/bl_curve.py index ac6fbdc..74a7fbe 100644 --- a/multi_user/bl_types/bl_curve.py +++ b/multi_user/bl_types/bl_curve.py @@ -36,7 +36,7 @@ class BlCurve(BlDatablock): def _construct(self, data): return bpy.data.curves.new(data["name"], data["type"]) - def load_implementation(self, data, target): + def _load_implementation(self, data, target): dump_anything.load(target, data) target.splines.clear() @@ -61,7 +61,7 @@ class BlCurve(BlDatablock): dump_anything.load( new_spline.points[point_index], data['splines'][spline]["points"][point_index]) - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) dumper = dump_anything.Dumper() diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index 1aeb8be..8ddb6eb 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -145,11 +145,11 @@ class BlDatablock(ReplicatedDatablock): if self.is_library: data.update(dump_anything.dump(pointer)) else: - data.update(self.dump_implementation(data, pointer=pointer)) + data.update(self._dump_implementation(data, pointer=pointer)) return data - def dump_implementation(self, data, target): + def _dump_implementation(self, data, target): raise NotImplementedError def _load(self, data, target): @@ -171,9 +171,9 @@ class BlDatablock(ReplicatedDatablock): if self.is_library: return else: - self.load_implementation(data, target) + self._load_implementation(data, target) - def load_implementation(self, data, target): + def _load_implementation(self, data, target): raise NotImplementedError def resolve_deps(self): @@ -183,11 +183,11 @@ class BlDatablock(ReplicatedDatablock): dependencies.append(self.pointer.animation_data.action) if not self.is_library: - dependencies.extend(self.resolve_deps_implementation()) + dependencies.extend(self._resolve_deps_implementation()) return dependencies - def resolve_deps_implementation(self): + def _resolve_deps_implementation(self): return [] def is_valid(self): diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index bb02332..7e26bfa 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -221,7 +221,7 @@ class BlGpencil(BlDatablock): def _construct(self, data): return bpy.data.grease_pencils.new(data["name"]) - def load_implementation(self, data, target): + def _load_implementation(self, data, target): target.materials.clear() if "materials" in data.keys(): for mat in data['materials']: @@ -248,7 +248,7 @@ class BlGpencil(BlDatablock): - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) dumper = Dumper() dumper.depth = 2 @@ -261,7 +261,7 @@ class BlGpencil(BlDatablock): return data - def resolve_deps_implementation(self): + def _resolve_deps_implementation(self): deps = [] for material in self.pointer.materials: diff --git a/multi_user/bl_types/bl_lattice.py b/multi_user/bl_types/bl_lattice.py index ca6de99..a19c4ce 100644 --- a/multi_user/bl_types/bl_lattice.py +++ b/multi_user/bl_types/bl_lattice.py @@ -31,7 +31,7 @@ class BlLattice(BlDatablock): bl_automatic_push = True bl_icon = 'LATTICE_DATA' - def load_implementation(self, data, target): + def _load_implementation(self, data, target): utils.dump_anything.load(target, data) for point in data['points']: @@ -39,7 +39,7 @@ class BlLattice(BlDatablock): def _construct(self, data): return bpy.data.lattices.new(data["name"]) - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) dumper = utils.dump_anything.Dumper() diff --git a/multi_user/bl_types/bl_light.py b/multi_user/bl_types/bl_light.py index 67f8af9..05b7dbb 100644 --- a/multi_user/bl_types/bl_light.py +++ b/multi_user/bl_types/bl_light.py @@ -34,10 +34,10 @@ class BlLight(BlDatablock): def _construct(self, data): return bpy.data.lights.new(data["name"], data["type"]) - def load_implementation(self, data, target): + def _load_implementation(self, data, target): utils.dump_anything.load(target, data) - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) dumper = utils.dump_anything.Dumper() dumper.depth = 3 diff --git a/multi_user/bl_types/bl_lightprobe.py b/multi_user/bl_types/bl_lightprobe.py index abd20e9..3941e19 100644 --- a/multi_user/bl_types/bl_lightprobe.py +++ b/multi_user/bl_types/bl_lightprobe.py @@ -41,10 +41,10 @@ class BlLightprobe(BlDatablock): else: logger.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") - def load_implementation(self, data, target): + def _load_implementation(self, data, target): utils.dump_anything.load(target, data) - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) if bpy.app.version[1] < 83: logger.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index a3ecdb0..1c9cb4e 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -93,7 +93,7 @@ class BlMaterial(BlDatablock): def _construct(self, data): return bpy.data.materials.new(data["name"]) - def load_implementation(self, data, target): + def _load_implementation(self, data, target): target.name = data['name'] if data['is_grease_pencil']: if not target.is_grease_pencil: @@ -120,7 +120,7 @@ class BlMaterial(BlDatablock): load_links(data["node_tree"]["links"], target.node_tree) - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) mat_dumper = dump_anything.Dumper() mat_dumper.depth = 2 @@ -235,7 +235,7 @@ class BlMaterial(BlDatablock): data['grease_pencil'] = gp_mat_dumper.dump(pointer.grease_pencil) return data - def resolve_deps_implementation(self): + def _resolve_deps_implementation(self): # TODO: resolve node group deps deps = [] diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index 83a9358..5738e58 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -42,7 +42,7 @@ class BlMesh(BlDatablock): instance.uuid = self.uuid return instance - def load_implementation(self, data, target): + def _load_implementation(self, data, target): if not target or not target.is_editmode: loader = Loader() loader.load(target, data) @@ -97,7 +97,7 @@ class BlMesh(BlDatablock): target.validate() target.update() - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) mesh = pointer @@ -162,7 +162,7 @@ class BlMesh(BlDatablock): return data - def resolve_deps_implementation(self): + def _resolve_deps_implementation(self): deps = [] for material in self.pointer.materials: diff --git a/multi_user/bl_types/bl_metaball.py b/multi_user/bl_types/bl_metaball.py index 36c998f..b0807e6 100644 --- a/multi_user/bl_types/bl_metaball.py +++ b/multi_user/bl_types/bl_metaball.py @@ -42,7 +42,7 @@ class BlMetaball(BlDatablock): new_element = target.elements.new(type=data["elements"][element]['type']) utils.dump_anything.load(new_element, data["elements"][element]) - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) dumper = utils.dump_anything.Dumper() dumper.depth = 3 diff --git a/multi_user/bl_types/bl_object.py b/multi_user/bl_types/bl_object.py index 2abb6ad..962a46a 100644 --- a/multi_user/bl_types/bl_object.py +++ b/multi_user/bl_types/bl_object.py @@ -87,7 +87,7 @@ class BlObject(BlDatablock): return instance - def load_implementation(self, data, target): + def _load_implementation(self, data, target): # Load transformation data utils.dump_anything.load(target, data) @@ -151,7 +151,7 @@ class BlObject(BlDatablock): target.data.shape_keys.key_blocks[key_block].relative_key = target.data.shape_keys.key_blocks[reference] - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) dumper = utils.dump_anything.Dumper() dumper.depth = 1 @@ -290,7 +290,7 @@ class BlObject(BlDatablock): return data - def resolve_deps_implementation(self): + def _resolve_deps_implementation(self): deps = [] # Avoid Empty case diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index 5b7b9e5..739f027 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -35,7 +35,7 @@ class BlScene(BlDatablock): instance.uuid = self.uuid return instance - def load_implementation(self, data, target): + def _load_implementation(self, data, target): target = self.pointer # Load other meshes metadata utils.dump_anything.load(target, data) @@ -67,7 +67,7 @@ class BlScene(BlDatablock): if 'grease_pencil' in data.keys(): target.grease_pencil = bpy.data.grease_pencils[data['grease_pencil']] - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) data = {} @@ -89,7 +89,7 @@ class BlScene(BlDatablock): return data - def resolve_deps_implementation(self): + def _resolve_deps_implementation(self): deps = [] # child collections diff --git a/multi_user/bl_types/bl_speaker.py b/multi_user/bl_types/bl_speaker.py index 9d2f87c..5e90780 100644 --- a/multi_user/bl_types/bl_speaker.py +++ b/multi_user/bl_types/bl_speaker.py @@ -31,13 +31,13 @@ class BlSpeaker(BlDatablock): bl_automatic_push = True bl_icon = 'SPEAKER' - def load_implementation(self, data, target): + def _load_implementation(self, data, target): utils.dump_anything.load(target, data) def _construct(self, data): return bpy.data.speakers.new(data["name"]) - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) dumper = utils.dump_anything.Dumper() diff --git a/multi_user/bl_types/bl_world.py b/multi_user/bl_types/bl_world.py index 8f79dc1..2650013 100644 --- a/multi_user/bl_types/bl_world.py +++ b/multi_user/bl_types/bl_world.py @@ -35,7 +35,7 @@ class BlWorld(BlDatablock): def _construct(self, data): return bpy.data.worlds.new(data["name"]) - def load_implementation(self, data, target): + def _load_implementation(self, data, target): if data["use_nodes"]: if target.node_tree is None: target.use_nodes = True @@ -51,7 +51,7 @@ class BlWorld(BlDatablock): load_links(data["node_tree"]["links"], target.node_tree) - def dump_implementation(self, data, pointer=None): + def _dump_implementation(self, data, pointer=None): assert(pointer) world_dumper = utils.dump_anything.Dumper() @@ -109,7 +109,7 @@ class BlWorld(BlDatablock): return data - def resolve_deps_implementation(self): + def _resolve_deps_implementation(self): deps = [] if self.pointer.use_nodes: From 820c6dad7ed3a569bbfad5413ca7b7cad9282e23 Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 30 Mar 2020 18:05:33 +0200 Subject: [PATCH 069/129] refactor: feat dump node --- multi_user/bl_types/bl_material.py | 132 ++++++++++++++++------------- multi_user/bl_types/bl_world.py | 36 +------- 2 files changed, 77 insertions(+), 91 deletions(-) diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index 1c9cb4e..c742ecc 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -47,6 +47,7 @@ def load_node(node_data, node_tree): except: logger.error("{} not supported, skipping".format(input)) + def load_links(links_data, node_tree): """ Load node_tree links from a list @@ -62,6 +63,7 @@ def load_links(links_data, node_tree): node_tree.links.new(input_socket, output_socket) + def dump_links(links): """ Dump node_tree links collection to a list @@ -82,6 +84,77 @@ def dump_links(links): return links_data + +def dump_node(node): + """ Dump a single node to a dict + + :arg node: target node + :type node: bpy.types.Node + :retrun: dict + """ + + node_dumper = dump_anything.Dumper() + node_dumper.depth = 1 + node_dumper.exclude_filter = [ + "dimensions", + "show_expanded", + "name_full", + "select", + "bl_height_min", + "bl_height_max", + "bl_height_default", + "bl_width_min", + "bl_width_max", + "type", + "bl_icon", + "bl_width_default", + "bl_static_type", + "show_tetxure", + "is_active_output", + "hide", + "show_options", + "show_preview", + "show_texture", + "outputs", + "width_hidden" + ] + + dumped_node = node_dumper.dump(node) + + if hasattr(node, 'inputs'): + dumped_node['inputs'] = {} + + for i in node.inputs: + input_dumper = dump_anything.Dumper() + input_dumper.depth = 2 + input_dumper.include_filter = ["default_value"] + + if hasattr(i, 'default_value'): + dumped_node['inputs'][i.name] = input_dumper.dump( + i) + if hasattr(node, 'color_ramp'): + ramp_dumper = dump_anything.Dumper() + ramp_dumper.depth = 4 + ramp_dumper.include_filter = [ + 'elements', + 'alpha', + 'color', + 'position' + ] + dumped_node['color_ramp'] = ramp_dumper.dump(node.color_ramp) + if hasattr(node, 'mapping'): + curve_dumper = dump_anything.Dumper() + curve_dumper.depth = 5 + curve_dumper.include_filter = [ + 'curves', + 'points', + 'location' + ] + dumped_node['mapping'] = curve_dumper.dump(node.mapping) + + return dumped_node + + class BlMaterial(BlDatablock): bl_id = "materials" bl_class = bpy.types.Material @@ -144,68 +217,11 @@ class BlMaterial(BlDatablock): if pointer.use_nodes: nodes = {} - node_dumper = dump_anything.Dumper() - node_dumper.depth = 1 - node_dumper.exclude_filter = [ - "dimensions", - "show_expanded", - "name_full", - "select", - "bl_height_min", - "bl_height_max", - "bl_width_min", - "bl_width_max", - "type", - "bl_icon", - "bl_width_default", - "bl_static_type", - "show_tetxure", - "hide", - "show_options", - "show_preview", - "outputs", - "width_hidden" - ] for node in pointer.node_tree.nodes: - - nodes[node.name] = node_dumper.dump(node) - - if hasattr(node, 'inputs'): - nodes[node.name]['inputs'] = {} - - for i in node.inputs: - input_dumper = dump_anything.Dumper() - input_dumper.depth = 2 - input_dumper.include_filter = ["default_value"] - - if hasattr(i, 'default_value'): - nodes[node.name]['inputs'][i.name] = input_dumper.dump( - i) - if hasattr(node, 'color_ramp'): - ramp_dumper = dump_anything.Dumper() - ramp_dumper.depth = 4 - ramp_dumper.include_filter = [ - 'elements', - 'alpha', - 'color', - 'position' - ] - nodes[node.name]['color_ramp'] = ramp_dumper.dump(node.color_ramp) - if hasattr(node, 'mapping'): - curve_dumper = dump_anything.Dumper() - curve_dumper.depth = 5 - curve_dumper.include_filter = [ - 'curves', - 'points', - 'location' - ] - nodes[node.name]['mapping'] = curve_dumper.dump(node.mapping) - + nodes[node.name] = dump_node(node) data["node_tree"]['nodes'] = nodes - data["node_tree"]["links"] = dump_links(pointer.node_tree.links) - elif pointer.is_grease_pencil: gp_mat_dumper = dump_anything.Dumper() gp_mat_dumper.depth = 3 diff --git a/multi_user/bl_types/bl_world.py b/multi_user/bl_types/bl_world.py index 2650013..de25218 100644 --- a/multi_user/bl_types/bl_world.py +++ b/multi_user/bl_types/bl_world.py @@ -21,7 +21,7 @@ import mathutils from .. import utils from .bl_datablock import BlDatablock -from .bl_material import load_links, load_node, dump_links +from .bl_material import load_links, load_node,dump_node, dump_links class BlWorld(BlDatablock): @@ -69,40 +69,10 @@ class BlWorld(BlDatablock): data = world_dumper.dump(pointer) if pointer.use_nodes: nodes = {} - dumper = utils.dump_anything.Dumper() - dumper.depth = 2 - dumper.exclude_filter = [ - "dimensions", - "select", - "bl_height_min", - "bl_height_max", - "bl_width_min", - "bl_width_max", - "bl_width_default", - "hide", - "show_options", - "show_tetxures", - "show_preview", - "outputs", - "preview", - "original", - "width_hidden", - - ] - + for node in pointer.node_tree.nodes: - nodes[node.name] = dumper.dump(node) + nodes[node.name] = dump_node(node) - if hasattr(node, 'inputs'): - nodes[node.name]['inputs'] = {} - - for i in node.inputs: - input_dumper = utils.dump_anything.Dumper() - input_dumper.depth = 2 - input_dumper.include_filter = ["default_value"] - if hasattr(i, 'default_value'): - nodes[node.name]['inputs'][i.name] = input_dumper.dump( - i) data["node_tree"]['nodes'] = nodes data["node_tree"]['links'] = dump_links(pointer.node_tree.links) From d46ea3a117921fe656d4a4ca8072ec1c6a122350 Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 30 Mar 2020 18:12:43 +0200 Subject: [PATCH 070/129] refactor: change default materials refrash rate to 1s --- multi_user/bl_types/bl_material.py | 4 ++-- multi_user/bl_types/bl_world.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index c742ecc..beee34c 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -158,8 +158,8 @@ def dump_node(node): class BlMaterial(BlDatablock): bl_id = "materials" bl_class = bpy.types.Material - bl_delay_refresh = 10 - bl_delay_apply = 10 + bl_delay_refresh = 1 + bl_delay_apply = 1 bl_automatic_push = True bl_icon = 'MATERIAL_DATA' diff --git a/multi_user/bl_types/bl_world.py b/multi_user/bl_types/bl_world.py index de25218..e78ca2a 100644 --- a/multi_user/bl_types/bl_world.py +++ b/multi_user/bl_types/bl_world.py @@ -27,8 +27,8 @@ from .bl_material import load_links, load_node,dump_node, dump_links class BlWorld(BlDatablock): bl_id = "worlds" bl_class = bpy.types.World - bl_delay_refresh = 4 - bl_delay_apply = 4 + bl_delay_refresh = 1 + bl_delay_apply = 1 bl_automatic_push = True bl_icon = 'WORLD_DATA' @@ -69,7 +69,7 @@ class BlWorld(BlDatablock): data = world_dumper.dump(pointer) if pointer.use_nodes: nodes = {} - + for node in pointer.node_tree.nodes: nodes[node.name] = dump_node(node) From 9c9d7a31bfafd7e8d99a720ba03bebf02819d39d Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 30 Mar 2020 18:50:54 +0200 Subject: [PATCH 071/129] feat: cleanup metaballs --- multi_user/bl_types/bl_metaball.py | 76 ++++++++++++++++++++++++++---- multi_user/libs/dump_anything.py | 27 +++++++++++ 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/multi_user/bl_types/bl_metaball.py b/multi_user/bl_types/bl_metaball.py index b0807e6..4bfdac9 100644 --- a/multi_user/bl_types/bl_metaball.py +++ b/multi_user/bl_types/bl_metaball.py @@ -20,8 +20,57 @@ import bpy import mathutils from .. import utils +from ..libs.dump_anything import ( + Dumper, Loader, dump_collection_attr, load_collection_attr, dump_collection_attr_to_dict, load_collection_attr_from_dict) + from .bl_datablock import BlDatablock +ENUM_METABALL_TYPE = [ + 'BALL', + 'CAPSULE', + 'PLANE', + 'ELLIPSOID', + 'CUBE' +] + + +ELEMENTS_FAST_ATTR = [ + 'co', + 'hide', + 'radius', + 'rotation', + 'size_x', + 'size_y', + 'size_z', + 'stiffness'] + + +def dump_metaball_elements(elements): + """ Dump a metaball element + + :arg element: metaball element + :type bpy.types.MetaElement + :return: dict + """ + + dumped_elements = {} + + dump_collection_attr_to_dict(dumped_elements, elements, ELEMENTS_FAST_ATTR) + + dumped_elements['type'] = [ENUM_METABALL_TYPE.index(e.type) for e in elements] + + return dumped_elements + + +def load_metaball_elements(elements_data, elements): + """ Dump a metaball element + + :arg element: metaball element + :type bpy.types.MetaElement + :return: dict + """ + load_collection_attr_from_dict(elements_data, elements, ELEMENTS_FAST_ATTR) + class BlMetaball(BlDatablock): bl_id = "metaballs" @@ -34,22 +83,31 @@ class BlMetaball(BlDatablock): def _construct(self, data): return bpy.data.metaballs.new(data["name"]) - def load(self, data, target): + def _load_implementation(self, data, target): utils.dump_anything.load(target, data) - + target.elements.clear() - for element in data["elements"]: - new_element = target.elements.new(type=data["elements"][element]['type']) - utils.dump_anything.load(new_element, data["elements"][element]) + + for element_type in data["elements"]['type']: + new_element = target.elements.new( + type=ENUM_METABALL_TYPE[element_type]) + + load_metaball_elements(data['elements'], target.elements) def _dump_implementation(self, data, pointer=None): assert(pointer) dumper = utils.dump_anything.Dumper() - dumper.depth = 3 - dumper.exclude_filter = ["is_editmode"] + dumper.depth = 1 + dumper.exclude_filter = [ + "is_editmode", + "is_evaluated", + "is_embedded_data", + "is_library_indirect", + "name_full" + ] data = dumper.dump(pointer) + data['elements'] = dump_metaball_elements(pointer.elements) + return data - - diff --git a/multi_user/libs/dump_anything.py b/multi_user/libs/dump_anything.py index ba12f8e..62c1f4b 100644 --- a/multi_user/libs/dump_anything.py +++ b/multi_user/libs/dump_anything.py @@ -30,6 +30,33 @@ BPY_TO_NUMPY_TYPES = { 'BOOL': np.bool } +def load_collection_attr_from_dict(dikt, collection, attributes): + """ Dump a list of attributes from the sane collection + to the target dikt + + :arg dikt: target dict + :type dikt: dict + :arg collection: source collection + :type collection: bpy.types.CollectionProperty + :arg attributes: list of attributes name + :type attributes: list + """ + for attr in attributes: + load_collection_attr(collection, attr, dikt[attr]) + +def dump_collection_attr_to_dict(dikt, collection, attributes): + """ Dump a list of attributes from the sane collection + to the target dikt + + :arg dikt: target dict + :type dikt: dict + :arg collection: source collection + :type collection: bpy.types.CollectionProperty + :arg attributes: list of attributes name + :type attributes: list + """ + for attr in attributes: + dikt[attr] = dump_collection_attr(collection, attr) def dump_collection_attr(collection, attribute): """ Dump a collection attribute as a sequence From 928eccfa2390fba3c5ed2ca5e11cb00d3c628c21 Mon Sep 17 00:00:00 2001 From: Swann Date: Tue, 31 Mar 2020 10:13:49 +0200 Subject: [PATCH 072/129] feat: vertex_color support --- multi_user/bl_types/bl_mesh.py | 57 ++++++++++++++++-------------- multi_user/bl_types/bl_metaball.py | 27 +++++++------- 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index 5738e58..cfc2bc5 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -66,33 +66,37 @@ class BlMesh(BlDatablock): load_collection_attr(target.vertices, 'co', data["verts_co"]) load_collection_attr(target.edges, "vertices", data["egdes_vert"]) if data['use_customdata_edge_crease']: - load_collection_attr( - target.edges, "crease", data["edges_crease"]) + load_collection_attr(target.edges, "crease", data["edges_crease"]) if data['use_customdata_edge_bevel']: - load_collection_attr( - target.edges, "bevel_weight", data["edges_bevel"]) + load_collection_attr(target.edges, "bevel_weight", data["edges_bevel"]) - load_collection_attr( - target.loops, 'vertex_index', data["loop_vertex_index"]) + load_collection_attr(target.loops, 'vertex_index', data["loop_vertex_index"]) load_collection_attr(target.loops, 'normal', data["loop_normal"]) - load_collection_attr( - target.polygons, 'loop_total', data["poly_loop_total"]) - load_collection_attr( - target.polygons, 'loop_start', data["poly_loop_start"]) - load_collection_attr( - target.polygons, 'use_smooth', data["poly_smooth"]) - load_collection_attr( - target.polygons, 'material_index', data["poly_mat"]) + load_collection_attr(target.polygons, 'loop_total', data["poly_loop_total"]) + load_collection_attr(target.polygons, 'loop_start', data["poly_loop_start"]) + load_collection_attr(target.polygons, 'use_smooth', data["poly_smooth"]) + load_collection_attr(target.polygons, 'material_index', data["poly_mat"]) # UV Layers for layer in data['uv_layers']: if layer not in target.uv_layers: target.uv_layers.new(name=layer) - uv_buffer = np.frombuffer(data["uv_layers"][layer]['data']) + load_collection_attr( + target.uv_layers[layer].data, + 'uv', + data["uv_layers"][layer]['data']) + + # Vertex color + for color_layer in data['vertex_colors']: + if color_layer not in target.vertex_colors: + target.vertex_colors.new(name=color_layer) - target.uv_layers[layer].data.foreach_set('uv', uv_buffer) + load_collection_attr( + target.vertex_colors[color_layer].data, + 'color', + data["vertex_colors"][color_layer]['data']) target.validate() target.update() @@ -131,26 +135,27 @@ class BlMesh(BlDatablock): # POLYGONS data["poly_count"] = len(mesh.polygons) - data["poly_mat"] = dump_collection_attr( - mesh.polygons, 'material_index') - data["poly_loop_start"] = dump_collection_attr( - mesh.polygons, 'loop_start') - data["poly_loop_total"] = dump_collection_attr( - mesh.polygons, 'loop_total') + data["poly_mat"] = dump_collection_attr(mesh.polygons, 'material_index') + data["poly_loop_start"] = dump_collection_attr(mesh.polygons, 'loop_start') + data["poly_loop_total"] = dump_collection_attr(mesh.polygons, 'loop_total') data["poly_smooth"] = dump_collection_attr(mesh.polygons, 'use_smooth') # LOOPS data["loop_count"] = len(mesh.loops) data["loop_normal"] = dump_collection_attr(mesh.loops, 'normal') - data["loop_vertex_index"] = dump_collection_attr( - mesh.loops, 'vertex_index') + data["loop_vertex_index"] = dump_collection_attr(mesh.loops, 'vertex_index') # UV Layers data['uv_layers'] = {} for layer in mesh.uv_layers: data['uv_layers'][layer.name] = {} - data['uv_layers'][layer.name]['data'] = dump_collection_attr( - layer.data, 'uv') + data['uv_layers'][layer.name]['data'] = dump_collection_attr(layer.data, 'uv') + + # Vertex color + data['vertex_colors'] = {} + for color_map in mesh.vertex_colors: + data['vertex_colors'][color_map.name] = {} + data['vertex_colors'][color_map.name]['data'] = dump_collection_attr(color_map.data, 'color') # Fix material index m_list = [] diff --git a/multi_user/bl_types/bl_metaball.py b/multi_user/bl_types/bl_metaball.py index 4bfdac9..be510a7 100644 --- a/multi_user/bl_types/bl_metaball.py +++ b/multi_user/bl_types/bl_metaball.py @@ -21,7 +21,8 @@ import mathutils from .. import utils from ..libs.dump_anything import ( - Dumper, Loader, dump_collection_attr, load_collection_attr, dump_collection_attr_to_dict, load_collection_attr_from_dict) + Dumper, Loader, dump_collection_attr, load_collection_attr, + dump_collection_attr_to_dict, load_collection_attr_from_dict) from .bl_datablock import BlDatablock @@ -35,14 +36,15 @@ ENUM_METABALL_TYPE = [ ELEMENTS_FAST_ATTR = [ - 'co', - 'hide', - 'radius', - 'rotation', - 'size_x', - 'size_y', - 'size_z', - 'stiffness'] + 'co', + 'hide', + 'radius', + 'rotation', + 'size_x', + 'size_y', + 'size_z', + 'stiffness' +] def dump_metaball_elements(elements): @@ -52,11 +54,11 @@ def dump_metaball_elements(elements): :type bpy.types.MetaElement :return: dict """ - + dumped_elements = {} dump_collection_attr_to_dict(dumped_elements, elements, ELEMENTS_FAST_ATTR) - + dumped_elements['type'] = [ENUM_METABALL_TYPE.index(e.type) for e in elements] return dumped_elements @@ -91,7 +93,7 @@ class BlMetaball(BlDatablock): for element_type in data["elements"]['type']: new_element = target.elements.new( type=ENUM_METABALL_TYPE[element_type]) - + load_metaball_elements(data['elements'], target.elements) def _dump_implementation(self, data, pointer=None): @@ -110,4 +112,3 @@ class BlMetaball(BlDatablock): data['elements'] = dump_metaball_elements(pointer.elements) return data - From 379e7cdf7150ad61cd1018888d56b37f806a5c85 Mon Sep 17 00:00:00 2001 From: Swann Date: Tue, 31 Mar 2020 17:28:32 +0200 Subject: [PATCH 073/129] refactor: cleanup implementation feat: np_load_collection for collection attr load/unload --- multi_user/bl_types/bl_action.py | 86 +++++---------------------- multi_user/bl_types/bl_curve.py | 69 ++++++++++++++-------- multi_user/bl_types/bl_gpencil.py | 31 +++++----- multi_user/bl_types/bl_mesh.py | 65 ++++++++++----------- multi_user/bl_types/bl_metaball.py | 31 ++++------ multi_user/libs/dump_anything.py | 93 +++++++++++++++++++++++++----- 6 files changed, 197 insertions(+), 178 deletions(-) diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py index 9e736be..40fc124 100644 --- a/multi_user/bl_types/bl_action.py +++ b/multi_user/bl_types/bl_action.py @@ -24,53 +24,27 @@ from enum import Enum from .. import utils from ..libs.dump_anything import ( - Dumper, Loader, dump_collection_attr, load_collection_attr) + Dumper, Loader, np_dump_collection, np_load_collection) from .bl_datablock import BlDatablock -ENUM_EASING_TYPE = [ - 'AUTO', - 'EAS_IN', - 'EASE_OUT', - 'EASE_IN_OUT'] - - -ENUM_HANDLE_TYPE = [ - 'FREE', - 'ALIGNED', - 'VECTOR', - 'AUTO', - 'AUTO_CLAMPED'] - - -ENUM_INTERPOLATION_TYPE = [ - 'CONSTANT', - 'LINEAR', - 'BEZIER', - 'SINE', - 'QUAD', - 'CUBIC', - 'QUART', - 'QUINT', - 'EXPO', - 'CIRC', - 'BACK', - 'BOUNCE', - 'ELASTIC'] - - -ENUM_KEY_TYPE = [ - 'KEYFRAME', - 'BREAKDOWN', - 'MOVING_HOLD', - 'EXTREME', - 'JITTER'] - +KEYFRAME = [ + 'amplitude', + 'co', + 'back', + 'handle_left', + 'handle_right', + 'easing', + 'handle_left_type', + 'handle_right_type', + 'type', + 'interpolation', +] # TODO: Automatic enum and numpy dump and loading -def dump_fcurve(fcurve, use_numpy=True): +def dump_fcurve(fcurve: bpy.types.FCurve, use_numpy:bool =True) -> dict: """ Dump a sigle curve to a dict :arg fcurve: fcurve to dump @@ -88,23 +62,7 @@ def dump_fcurve(fcurve, use_numpy=True): if use_numpy: points = fcurve.keyframe_points fcurve_data['keyframes_count'] = len(fcurve.keyframe_points) - - fcurve_data['amplitude'] = dump_collection_attr(points, 'amplitude') - fcurve_data['co'] = dump_collection_attr(points, 'co') - fcurve_data['back'] = dump_collection_attr(points, 'back') - fcurve_data['handle_left'] = dump_collection_attr(points, 'handle_left') - fcurve_data['handle_right'] = dump_collection_attr(points, 'handle_right') - - fcurve_data['easing'] = [ENUM_EASING_TYPE.index( - p.easing) for p in fcurve.keyframe_points] - fcurve_data['handle_left_type'] = [ENUM_HANDLE_TYPE.index( - p.handle_left_type) for p in fcurve.keyframe_points] - fcurve_data['handle_right_type'] = [ENUM_HANDLE_TYPE.index( - p.handle_right_type) for p in fcurve.keyframe_points] - fcurve_data['type'] = [ENUM_KEY_TYPE.index( - p.type) for p in fcurve.keyframe_points] - fcurve_data['interpolation'] = [ENUM_INTERPOLATION_TYPE.index( - p.interpolation) for p in fcurve.keyframe_points] + fcurve_data['keyframe_points'] = np_dump_collection(points, KEYFRAME) else: # Legacy method dumper = utils.dump_anything.Dumper() @@ -136,19 +94,7 @@ def load_fcurve(fcurve_data, fcurve): if use_numpy: keyframe_points.add(fcurve_data['keyframes_count']) - - load_collection_attr(keyframe_points, 'co', fcurve_data['co']) - load_collection_attr(keyframe_points, 'back', fcurve_data['back']) - load_collection_attr(keyframe_points, 'amplitude', fcurve_data['amplitude']) - load_collection_attr(keyframe_points, 'handle_left', fcurve_data['handle_left']) - load_collection_attr(keyframe_points, 'handle_right', fcurve_data['handle_right']) - - for index, point in enumerate(keyframe_points): - point.type = ENUM_KEY_TYPE[fcurve_data['type'][index]] - point.easing = ENUM_EASING_TYPE[fcurve_data['easing'][index]] - point.handle_left_type = ENUM_HANDLE_TYPE[fcurve_data['handle_left_type'][index]] - point.handle_right_type = ENUM_HANDLE_TYPE[fcurve_data['handle_right_type'][index]] - point.interpolation = ENUM_INTERPOLATION_TYPE[fcurve_data['interpolation'][index]] + np_load_collection(fcurve_data["keyframe_points"], keyframe_points, KEYFRAME) else: # paste dumped keyframes diff --git a/multi_user/bl_types/bl_curve.py b/multi_user/bl_types/bl_curve.py index 74a7fbe..c662829 100644 --- a/multi_user/bl_types/bl_curve.py +++ b/multi_user/bl_types/bl_curve.py @@ -19,11 +19,33 @@ import bpy import bpy.types as T import mathutils +import logging from .. import utils from .bl_datablock import BlDatablock -from ..libs import dump_anything +from ..libs.dump_anything import (Dumper, Loader, + np_load_collection, + np_dump_collection) +logger = logging.getLogger(__name__) + +SPLINE_BEZIER_POINT = [ + # "handle_left_type", + # "handle_right_type", + "handle_left", + "co", + "handle_right", + "tilt", + "weight_softbody", + "radius", +] + +SPLINE_POINT = [ + "co", + "tilt", + "weight_softbody", + "radius", +] class BlCurve(BlDatablock): bl_id = "curves" @@ -37,48 +59,46 @@ class BlCurve(BlDatablock): return bpy.data.curves.new(data["name"], data["type"]) def _load_implementation(self, data, target): - dump_anything.load(target, data) + loader = Loader() + loader.load(target, data) target.splines.clear() # load splines - for spline in data['splines']: - new_spline = target.splines.new(data['splines'][spline]['type']) - dump_anything.load(new_spline, data['splines'][spline]) + for spline in data['splines'].values(): + new_spline = target.splines.new(spline['type']) + # Load curve geometry data if new_spline.type == 'BEZIER': - for bezier_point_index in data['splines'][spline]["bezier_points"]: - if bezier_point_index != 0: - new_spline.bezier_points.add(1) - dump_anything.load( - new_spline.bezier_points[bezier_point_index], data['splines'][spline]["bezier_points"][bezier_point_index]) - + bezier_points = new_spline.bezier_points + bezier_points.add(spline['bezier_points_count']) + np_load_collection(spline['bezier_points'], bezier_points, SPLINE_BEZIER_POINT) + # Not really working for now... # See https://blender.stackexchange.com/questions/7020/create-nurbs-surface-with-python if new_spline.type == 'NURBS': - new_spline.points.add(len(data['splines'][spline]["points"])-1) - for point_index in data['splines'][spline]["points"]: - dump_anything.load( - new_spline.points[point_index], data['splines'][spline]["points"][point_index]) + logger.error("NURBS not supported.") + # new_spline.points.add(len(data['splines'][spline]["points"])-1) + # for point_index in data['splines'][spline]["points"]: + # loader.load( + # new_spline.points[point_index], data['splines'][spline]["points"][point_index]) + loader.load(new_spline, spline) def _dump_implementation(self, data, pointer=None): assert(pointer) - dumper = dump_anything.Dumper() + dumper = Dumper() data = dumper.dump(pointer) data['splines'] = {} - dumper = utils.dump_anything.Dumper() - dumper.depth = 3 - for index, spline in enumerate(pointer.splines): - spline_data = dump_anything.dump(spline) - spline_data['points'] = dumper.dump(spline.points) - spline_data['bezier_points'] = dumper.dump(spline.bezier_points) - spline_data['type'] = dumper.dump(spline.type) + dumper.depth = 2 + spline_data = dumper.dump(spline) + # spline_data['points'] = np_dump_collection(spline.points, SPLINE_POINT) + spline_data['bezier_points_count'] = len(spline.bezier_points)-1 + spline_data['bezier_points'] = np_dump_collection(spline.bezier_points, SPLINE_BEZIER_POINT) data['splines'][index] = spline_data - if isinstance(pointer, T.SurfaceCurve): data['type'] = 'SURFACE' elif isinstance(pointer, T.TextCurve): @@ -86,4 +106,3 @@ class BlCurve(BlDatablock): elif isinstance(pointer, T.Curve): data['type'] = 'CURVE' return data - diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index 7e26bfa..81acd16 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -22,13 +22,25 @@ import numpy as np from ..libs.dump_anything import (Dumper, Loader, - dump_collection_attr, - load_collection_attr) + np_dump_collection, + np_load_collection) from .bl_datablock import BlDatablock # GPencil data api is structured as it follow: # GP-Object --> GP-Layers --> GP-Frames --> GP-Strokes --> GP-Stroke-Points +STROKE_POINT = [ + 'co', + 'pressure', + 'strength', + 'uv_factor', + 'uv_rotation' + +] + +if bpy.app.version[1] >= 83: + STROKE_POINT.append('vertex_color') + def dump_stroke(stroke): """ Dump a grease pencil stroke to a dict @@ -59,12 +71,7 @@ def dump_stroke(stroke): # Stoke points p_count = len(stroke.points) dumped_stroke['p_count'] = p_count - dumped_stroke['p_co'] = dump_collection_attr(stroke.points,'co') - dumped_stroke['p_pressure'] = dump_collection_attr(stroke.points,'pressure') - dumped_stroke['p_strength'] = dump_collection_attr(stroke.points,'strength') - - if bpy.app.version[1] >= 83: # new in blender 2.83 - dumped_stroke['p_vertex_color'] = dump_collection_attr(stroke.points,'vertex_color') + dumped_stroke['points'] = np_dump_collection(stroke.points, STROKE_POINT) # TODO: uv_factor, uv_rotation @@ -86,13 +93,7 @@ def load_stroke(stroke_data, stroke): stroke.points.add(stroke_data["p_count"]) - load_collection_attr(stroke.points, 'co',stroke_data["p_co"]) - load_collection_attr(stroke.points, 'pressure',stroke_data["p_pressure"]) - load_collection_attr(stroke.points, 'strength',stroke_data["p_strength"]) - - if "p_vertex_color" in stroke_data: - load_collection_attr(stroke.points, 'vertex_color',stroke_data["p_vertex_color"]) - + np_load_collection(stroke_data['points'], stroke.points, STROKE_POINT) def dump_frame(frame): """ Dump a grease pencil frame to a dict diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index cfc2bc5..137cec8 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -22,12 +22,30 @@ import mathutils import logging import numpy as np -from ..libs.dump_anything import Dumper, Loader, load_collection_attr, dump_collection_attr +from ..libs.dump_anything import Dumper, Loader, np_load_collection_primitives, np_dump_collection_primitive, np_load_collection, np_dump_collection from ..libs.replication.replication.constants import DIFF_BINARY from .bl_datablock import BlDatablock logger = logging.getLogger(__name__) +VERTICE = ['co'] + +EDGE = [ + 'vertices', + 'crease', + 'bevel_weight', +] +LOOP = [ + 'vertex_index', + 'normal', +] + +POLYGON = [ + 'loop_total', + 'loop_start', + 'use_smooth', + 'material_index', +] class BlMesh(BlDatablock): bl_id = "meshes" @@ -63,27 +81,17 @@ class BlMesh(BlDatablock): target.polygons.add(data["poly_count"]) # LOADING - load_collection_attr(target.vertices, 'co', data["verts_co"]) - load_collection_attr(target.edges, "vertices", data["egdes_vert"]) - if data['use_customdata_edge_crease']: - load_collection_attr(target.edges, "crease", data["edges_crease"]) - - if data['use_customdata_edge_bevel']: - load_collection_attr(target.edges, "bevel_weight", data["edges_bevel"]) - - load_collection_attr(target.loops, 'vertex_index', data["loop_vertex_index"]) - load_collection_attr(target.loops, 'normal', data["loop_normal"]) - load_collection_attr(target.polygons, 'loop_total', data["poly_loop_total"]) - load_collection_attr(target.polygons, 'loop_start', data["poly_loop_start"]) - load_collection_attr(target.polygons, 'use_smooth', data["poly_smooth"]) - load_collection_attr(target.polygons, 'material_index', data["poly_mat"]) + np_load_collection(data['vertices'], target.vertices, VERTICE) + np_load_collection(data['edges'], target.edges, EDGE) + np_load_collection(data['loops'], target.loops, LOOP) + np_load_collection(data["polygons"],target.polygons, POLYGON) # UV Layers for layer in data['uv_layers']: if layer not in target.uv_layers: target.uv_layers.new(name=layer) - load_collection_attr( + np_load_collection_primitives( target.uv_layers[layer].data, 'uv', data["uv_layers"][layer]['data']) @@ -93,7 +101,7 @@ class BlMesh(BlDatablock): if color_layer not in target.vertex_colors: target.vertex_colors.new(name=color_layer) - load_collection_attr( + np_load_collection_primitives( target.vertex_colors[color_layer].data, 'color', data["vertex_colors"][color_layer]['data']) @@ -120,42 +128,31 @@ class BlMesh(BlDatablock): # VERTICES data["vertex_count"] = len(mesh.vertices) - data["verts_co"] = dump_collection_attr(mesh.vertices, 'co') + data["vertices"] = np_dump_collection(mesh.vertices, VERTICE) # EDGES data["egdes_count"] = len(mesh.edges) - data["egdes_vert"] = dump_collection_attr(mesh.edges, 'vertices') - - if mesh.use_customdata_edge_crease: - data["edges_crease"] = dump_collection_attr(mesh.edges, 'crease') - - if mesh.use_customdata_edge_bevel: - data["edges_bevel"] = dump_collection_attr( - mesh.edges, 'edges_bevel') + data["edges"] = np_dump_collection(mesh.edges, EDGE) # POLYGONS data["poly_count"] = len(mesh.polygons) - data["poly_mat"] = dump_collection_attr(mesh.polygons, 'material_index') - data["poly_loop_start"] = dump_collection_attr(mesh.polygons, 'loop_start') - data["poly_loop_total"] = dump_collection_attr(mesh.polygons, 'loop_total') - data["poly_smooth"] = dump_collection_attr(mesh.polygons, 'use_smooth') + data["polygons"] = np_dump_collection(mesh.polygons, POLYGON) # LOOPS data["loop_count"] = len(mesh.loops) - data["loop_normal"] = dump_collection_attr(mesh.loops, 'normal') - data["loop_vertex_index"] = dump_collection_attr(mesh.loops, 'vertex_index') + data["loops"] = np_dump_collection(mesh.loops, LOOP) # UV Layers data['uv_layers'] = {} for layer in mesh.uv_layers: data['uv_layers'][layer.name] = {} - data['uv_layers'][layer.name]['data'] = dump_collection_attr(layer.data, 'uv') + data['uv_layers'][layer.name]['data'] = np_dump_collection_primitive(layer.data, 'uv') # Vertex color data['vertex_colors'] = {} for color_map in mesh.vertex_colors: data['vertex_colors'][color_map.name] = {} - data['vertex_colors'][color_map.name]['data'] = dump_collection_attr(color_map.data, 'color') + data['vertex_colors'][color_map.name]['data'] = np_dump_collection_primitive(color_map.data, 'color') # Fix material index m_list = [] diff --git a/multi_user/bl_types/bl_metaball.py b/multi_user/bl_types/bl_metaball.py index be510a7..fd17b0d 100644 --- a/multi_user/bl_types/bl_metaball.py +++ b/multi_user/bl_types/bl_metaball.py @@ -21,21 +21,13 @@ import mathutils from .. import utils from ..libs.dump_anything import ( - Dumper, Loader, dump_collection_attr, load_collection_attr, - dump_collection_attr_to_dict, load_collection_attr_from_dict) + Dumper, Loader, np_dump_collection_primitive, np_load_collection_primitives, + np_dump_collection, np_load_collection) from .bl_datablock import BlDatablock -ENUM_METABALL_TYPE = [ - 'BALL', - 'CAPSULE', - 'PLANE', - 'ELLIPSOID', - 'CUBE' -] - -ELEMENTS_FAST_ATTR = [ +ELEMENT = [ 'co', 'hide', 'radius', @@ -43,7 +35,8 @@ ELEMENTS_FAST_ATTR = [ 'size_x', 'size_y', 'size_z', - 'stiffness' + 'stiffness', + 'type' ] @@ -55,11 +48,7 @@ def dump_metaball_elements(elements): :return: dict """ - dumped_elements = {} - - dump_collection_attr_to_dict(dumped_elements, elements, ELEMENTS_FAST_ATTR) - - dumped_elements['type'] = [ENUM_METABALL_TYPE.index(e.type) for e in elements] + dumped_elements = np_dump_collection(elements, ELEMENT) return dumped_elements @@ -71,7 +60,7 @@ def load_metaball_elements(elements_data, elements): :type bpy.types.MetaElement :return: dict """ - load_collection_attr_from_dict(elements_data, elements, ELEMENTS_FAST_ATTR) + np_load_collection(elements_data, elements, ELEMENT) class BlMetaball(BlDatablock): @@ -90,9 +79,9 @@ class BlMetaball(BlDatablock): target.elements.clear() - for element_type in data["elements"]['type']: - new_element = target.elements.new( - type=ENUM_METABALL_TYPE[element_type]) + for mtype in data["elements"]['type']: + print(mtype) + new_element = target.elements.new() load_metaball_elements(data['elements'], target.elements) diff --git a/multi_user/libs/dump_anything.py b/multi_user/libs/dump_anything.py index 62c1f4b..19b4a1d 100644 --- a/multi_user/libs/dump_anything.py +++ b/multi_user/libs/dump_anything.py @@ -30,9 +30,15 @@ BPY_TO_NUMPY_TYPES = { 'BOOL': np.bool } -def load_collection_attr_from_dict(dikt, collection, attributes): +PRIMITIVE_TYPES = ['FLOAT', 'INT', 'BOOLEAN'] + +NP_COMPATIBLE_TYPES = ['FLOAT', 'INT', 'BOOLEAN', 'ENUM'] + +def np_load_collection(dikt: dict, collection: bpy.types.CollectionProperty, attributes: list = None): """ Dump a list of attributes from the sane collection - to the target dikt + to the target dikt. + + Without attribute given, it try to load all entry from dikt. :arg dikt: target dict :type dikt: dict @@ -41,24 +47,59 @@ def load_collection_attr_from_dict(dikt, collection, attributes): :arg attributes: list of attributes name :type attributes: list """ - for attr in attributes: - load_collection_attr(collection, attr, dikt[attr]) + if attributes is None: + attributes = dikt.keys() -def dump_collection_attr_to_dict(dikt, collection, attributes): + for attr in attributes: + attr_type = collection[0].bl_rna.properties.get(attr).type + + if attr_type in PRIMITIVE_TYPES: + np_load_collection_primitives(collection, attr, dikt[attr]) + elif attr_type == 'ENUM': + np_load_collection_enum(collection, attr, dikt[attr]) + else: + logger.error(f"{attr} of type {attr_type} not supported.") + + +def np_dump_collection(collection, attributes=None): """ Dump a list of attributes from the sane collection to the target dikt - :arg dikt: target dict - :type dikt: dict + Without attributes given, it try to dump all properties + that matches NP_COMPATIBLE_TYPES. + :arg collection: source collection :type collection: bpy.types.CollectionProperty :arg attributes: list of attributes name :type attributes: list + :retrun: dict """ - for attr in attributes: - dikt[attr] = dump_collection_attr(collection, attr) + dumped_collection = {} -def dump_collection_attr(collection, attribute): + if len(collection) == 0: + return dumped_collection + + # TODO: find a way without getting the first item + properties = collection[0].bl_rna.properties + + if attributes is None: + attributes = [p.identifier for p in properties if p.type in NP_COMPATIBLE_TYPES and not p.is_readonly] + + logger.error(attributes) + + for attr in attributes: + attr_type = properties[attr].type + + if attr_type in PRIMITIVE_TYPES: + dumped_collection[attr] = np_dump_collection_primitive(collection, attr) + elif attr_type == 'ENUM': + dumped_collection[attr] = np_dump_collection_enum(collection, attr) + else: + logger.error(f"{attr} of type {attr_type} not supported. Only {PRIMITIVE_TYPES} and ENUM supported. Skipping it.") + + return dumped_collection + +def np_dump_collection_primitive(collection, attribute): """ Dump a collection attribute as a sequence !!! warning @@ -85,8 +126,35 @@ def dump_collection_attr(collection, attribute): return dumped_sequence.tobytes() +def np_dump_collection_enum(collection, attribute): + """ Dump a collection enum attribute to an index list -def load_collection_attr(collection, attribute, sequence): + :arg collection: target collection + :type collection: bpy.types.CollectionProperty + :arg attribute: target attribute + :type attribute: bpy.types.EnumProperty + :return: list of int + """ + attr_infos = collection[0].bl_rna.properties.get(attribute) + + assert(attr_infos.type == 'ENUM') + + enum_items = attr_infos.enum_items + return [enum_items[getattr(i, attribute)].value for i in collection] + + +def np_load_collection_enum(collection, attribute, sequence): + attr_infos = collection[0].bl_rna.properties.get(attribute) + + assert(attr_infos.type == 'ENUM') + + enum_items = attr_infos.enum_items + enum_idx = [i.value for i in enum_items] + for index, item in enumerate(sequence): + setattr(collection[index], attribute, enum_items[enum_idx.index(item)].identifier) + + +def np_load_collection_primitives(collection, attribute, sequence): """ Load a collection attribute from a bytes sequence !!! warning @@ -103,9 +171,8 @@ def load_collection_attr(collection, attribute, sequence): assert(attr_infos.type in ['FLOAT', 'INT', 'BOOLEAN']) - # TODO: check types match collection.foreach_set( - attribute, + attribute, np.frombuffer(sequence, dtype=BPY_TO_NUMPY_TYPES.get(attr_infos.type))) From e041b2cb91bfbb3e583f6352f94ca42f83157c2f Mon Sep 17 00:00:00 2001 From: Swann Date: Tue, 31 Mar 2020 17:42:14 +0200 Subject: [PATCH 074/129] feat: cleanup --- multi_user/libs/dump_anything.py | 103 +++++++++++++++++++------------ 1 file changed, 64 insertions(+), 39 deletions(-) diff --git a/multi_user/libs/dump_anything.py b/multi_user/libs/dump_anything.py index 19b4a1d..0129c4a 100644 --- a/multi_user/libs/dump_anything.py +++ b/multi_user/libs/dump_anything.py @@ -34,10 +34,11 @@ PRIMITIVE_TYPES = ['FLOAT', 'INT', 'BOOLEAN'] NP_COMPATIBLE_TYPES = ['FLOAT', 'INT', 'BOOLEAN', 'ENUM'] + def np_load_collection(dikt: dict, collection: bpy.types.CollectionProperty, attributes: list = None): """ Dump a list of attributes from the sane collection to the target dikt. - + Without attribute given, it try to load all entry from dikt. :arg dikt: target dict @@ -59,9 +60,9 @@ def np_load_collection(dikt: dict, collection: bpy.types.CollectionProperty, att np_load_collection_enum(collection, attr, dikt[attr]) else: logger.error(f"{attr} of type {attr_type} not supported.") - -def np_dump_collection(collection, attributes=None): + +def np_dump_collection(collection: bpy.types.CollectionProperty, attributes: list = None) -> dict: """ Dump a list of attributes from the sane collection to the target dikt @@ -78,28 +79,30 @@ def np_dump_collection(collection, attributes=None): if len(collection) == 0: return dumped_collection - + # TODO: find a way without getting the first item properties = collection[0].bl_rna.properties if attributes is None: - attributes = [p.identifier for p in properties if p.type in NP_COMPATIBLE_TYPES and not p.is_readonly] - - logger.error(attributes) + attributes = [ + p.identifier for p in properties if p.type in NP_COMPATIBLE_TYPES and not p.is_readonly] for attr in attributes: attr_type = properties[attr].type if attr_type in PRIMITIVE_TYPES: - dumped_collection[attr] = np_dump_collection_primitive(collection, attr) + dumped_collection[attr] = np_dump_collection_primitive( + collection, attr) elif attr_type == 'ENUM': dumped_collection[attr] = np_dump_collection_enum(collection, attr) else: - logger.error(f"{attr} of type {attr_type} not supported. Only {PRIMITIVE_TYPES} and ENUM supported. Skipping it.") - + logger.error( + f"{attr} of type {attr_type} not supported. Only {PRIMITIVE_TYPES} and ENUM supported. Skipping it.") + return dumped_collection -def np_dump_collection_primitive(collection, attribute): + +def np_dump_collection_primitive(collection: bpy.types.CollectionProperty, attribute: str) -> str: """ Dump a collection attribute as a sequence !!! warning @@ -126,7 +129,8 @@ def np_dump_collection_primitive(collection, attribute): return dumped_sequence.tobytes() -def np_dump_collection_enum(collection, attribute): + +def np_dump_collection_enum(collection: bpy.types.CollectionProperty, attribute: str) -> list: """ Dump a collection enum attribute to an index list :arg collection: target collection @@ -136,26 +140,42 @@ def np_dump_collection_enum(collection, attribute): :return: list of int """ attr_infos = collection[0].bl_rna.properties.get(attribute) - + assert(attr_infos.type == 'ENUM') enum_items = attr_infos.enum_items return [enum_items[getattr(i, attribute)].value for i in collection] -def np_load_collection_enum(collection, attribute, sequence): +def np_load_collection_enum(collection: bpy.types.CollectionProperty, attribute: str, sequence: list): + """ Load a collection enum attribute from a list sequence + + !!! warning + Only work with Enum + + :arg collection: target collection + :type collection: bpy.types.CollectionProperty + :arg attribute: target attribute + :type attribute: str + :arg sequence: enum data buffer + :type sequence: list + :return: numpy byte buffer + """ + attr_infos = collection[0].bl_rna.properties.get(attribute) assert(attr_infos.type == 'ENUM') enum_items = attr_infos.enum_items enum_idx = [i.value for i in enum_items] - for index, item in enumerate(sequence): - setattr(collection[index], attribute, enum_items[enum_idx.index(item)].identifier) - -def np_load_collection_primitives(collection, attribute, sequence): - """ Load a collection attribute from a bytes sequence + for index, item in enumerate(sequence): + setattr(collection[index], attribute, + enum_items[enum_idx.index(item)].identifier) + + +def np_load_collection_primitives(collection: bpy.types.CollectionProperty, attribute: str, sequence: str): + """ Load a collection attribute from a str bytes sequence !!! warning Only work with int, float and bool attributes @@ -164,6 +184,8 @@ def np_load_collection_primitives(collection, attribute, sequence): :type collection: bpy.types.CollectionProperty :arg attribute: target attribute :type attribute: str + :arg sequence: data buffer + :type sequence: str :return: numpy byte buffer """ @@ -174,7 +196,7 @@ def np_load_collection_primitives(collection, attribute, sequence): collection.foreach_set( attribute, np.frombuffer(sequence, dtype=BPY_TO_NUMPY_TYPES.get(attr_infos.type))) - + def remove_items_from_dict(d, keys, recursive=False): copy = dict(d) @@ -185,7 +207,7 @@ def remove_items_from_dict(d, keys, recursive=False): copy[k] = remove_items_from_dict(copy[k], keys, recursive) return copy - + def _is_dictionnary(v): return hasattr(v, "items") and callable(v.items) @@ -246,7 +268,7 @@ def _load_filter_default(default): class Dumper: - # TODO: support occlude readonly + # TODO: support occlude readonly # TODO: use foreach_set/get on collection compatible properties def __init__(self): self.verbose = True @@ -414,8 +436,8 @@ class BlenderAPIElement: def write(self, value): # take precaution if property is read-only if self.sub_element_name and \ - not self.api_element.is_property_readonly(self.sub_element_name): - + not self.api_element.is_property_readonly(self.sub_element_name): + setattr(self.api_element, self.sub_element_name, value) else: self.api_element = value @@ -473,7 +495,7 @@ class Loader: DESTRUCTOR_REMOVE = "remove" DESTRUCTOR_CLEAR = "clear" - + constructors = { T.ColorRampElement: (CONSTRUCTOR_NEW, ["position"]), T.ParticleSettingsTextureSlot: (CONSTRUCTOR_ADD, []), @@ -482,20 +504,20 @@ class Loader: } destructors = { - T.ColorRampElement:DESTRUCTOR_REMOVE, + T.ColorRampElement: DESTRUCTOR_REMOVE, T.Modifier: DESTRUCTOR_CLEAR, T.Constraint: CONSTRUCTOR_NEW, } element_type = element.bl_rna_property.fixed_type - + constructor = constructors.get(type(element_type)) if constructor is None: # collection type not supported return - destructor = destructors.get(type(element_type)) + destructor = destructors.get(type(element_type)) - # Try to clear existing + # Try to clear existing if destructor: if destructor == DESTRUCTOR_REMOVE: collection = element.read() @@ -503,18 +525,18 @@ class Loader: collection.remove(collection[0]) else: getattr(element.read(), DESTRUCTOR_CLEAR)() - + for dump_idx, dumped_element in enumerate(dump.values()): - if dump_idx == 0 and len(element.read())>0: - new_element = element.read()[0] + if dump_idx == 0 and len(element.read()) > 0: + new_element = element.read()[0] else: try: constructor_parameters = [dumped_element[name] - for name in constructor[1]] + for name in constructor[1]] except KeyError: logger.debug("Collection load error, missing parameters.") continue # TODO handle error - + new_element = getattr(element.read(), constructor[0])( *constructor_parameters) self._load_any( @@ -535,11 +557,12 @@ class Loader: for curve_index, curve in dump['curves'].items(): for point_idx, point in curve['points'].items(): pos = point['location'] - + if len(mapping.curves[curve_index].points) == 1: - mapping.curves[curve_index].points[int(point_idx)].location = pos + mapping.curves[curve_index].points[int( + point_idx)].location = pos else: - mapping.curves[curve_index].points.new(pos[0],pos[1]) + mapping.curves[curve_index].points.new(pos[0], pos[1]) def _load_pointer(self, pointer, dump): rna_property_type = pointer.bl_rna_property.fixed_type @@ -603,9 +626,11 @@ class Loader: (_load_filter_type(mathutils.Matrix, use_bl_rna=False), self._load_matrix), # before float because bl_rna type of vector if FloatProperty (_load_filter_type(mathutils.Vector, use_bl_rna=False), self._load_vector), - (_load_filter_type(mathutils.Quaternion, use_bl_rna=False), self._load_quaternion), + (_load_filter_type(mathutils.Quaternion, + use_bl_rna=False), self._load_quaternion), (_load_filter_type(mathutils.Euler, use_bl_rna=False), self._load_euler), - (_load_filter_type(T.CurveMapping, use_bl_rna=False), self._load_curve_mapping), + (_load_filter_type(T.CurveMapping, use_bl_rna=False), + self._load_curve_mapping), (_load_filter_type(T.FloatProperty), self._load_identity), (_load_filter_type(T.StringProperty), self._load_identity), (_load_filter_type(T.EnumProperty), self._load_identity), From 31feb2439d470cb062b0810c2a7f910e861b0d5d Mon Sep 17 00:00:00 2001 From: Swann Date: Tue, 31 Mar 2020 17:52:52 +0200 Subject: [PATCH 075/129] refactor: cleanup --- multi_user/bl_types/bl_metaball.py | 1 - multi_user/libs/dump_anything.py | 9 +++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/multi_user/bl_types/bl_metaball.py b/multi_user/bl_types/bl_metaball.py index fd17b0d..5663510 100644 --- a/multi_user/bl_types/bl_metaball.py +++ b/multi_user/bl_types/bl_metaball.py @@ -80,7 +80,6 @@ class BlMetaball(BlDatablock): target.elements.clear() for mtype in data["elements"]['type']: - print(mtype) new_element = target.elements.new() load_metaball_elements(data['elements'], target.elements) diff --git a/multi_user/libs/dump_anything.py b/multi_user/libs/dump_anything.py index 0129c4a..d47e84e 100644 --- a/multi_user/libs/dump_anything.py +++ b/multi_user/libs/dump_anything.py @@ -27,8 +27,7 @@ logger = logging.getLogger(__name__) BPY_TO_NUMPY_TYPES = { 'FLOAT': np.float, 'INT': np.int, - 'BOOL': np.bool -} + 'BOOL': np.bool} PRIMITIVE_TYPES = ['FLOAT', 'INT', 'BOOLEAN'] @@ -84,8 +83,7 @@ def np_dump_collection(collection: bpy.types.CollectionProperty, attributes: lis properties = collection[0].bl_rna.properties if attributes is None: - attributes = [ - p.identifier for p in properties if p.type in NP_COMPATIBLE_TYPES and not p.is_readonly] + attributes = [p.identifier for p in properties if p.type in NP_COMPATIBLE_TYPES and not p.is_readonly] for attr in attributes: attr_type = properties[attr].type @@ -96,8 +94,7 @@ def np_dump_collection(collection: bpy.types.CollectionProperty, attributes: lis elif attr_type == 'ENUM': dumped_collection[attr] = np_dump_collection_enum(collection, attr) else: - logger.error( - f"{attr} of type {attr_type} not supported. Only {PRIMITIVE_TYPES} and ENUM supported. Skipping it.") + logger.error(f"{attr} of type {attr_type} not supported. Only {PRIMITIVE_TYPES} and ENUM supported. Skipping it.") return dumped_collection From fef088e39bc923d0af1947ac68994dedbd1e9635 Mon Sep 17 00:00:00 2001 From: Swann Date: Wed, 1 Apr 2020 10:23:28 +0200 Subject: [PATCH 076/129] refactor: cleanup lattice --- multi_user/bl_types/bl_lattice.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/multi_user/bl_types/bl_lattice.py b/multi_user/bl_types/bl_lattice.py index a19c4ce..1c5443f 100644 --- a/multi_user/bl_types/bl_lattice.py +++ b/multi_user/bl_types/bl_lattice.py @@ -19,9 +19,11 @@ import bpy import mathutils -from .. import utils +from ..libs.dump_anything import Dumper, Loader, np_dump_collection, np_load_collection from .bl_datablock import BlDatablock +POINT = ['co', 'weight_softbody', 'co_deform'] + class BlLattice(BlDatablock): bl_id = "lattices" @@ -32,18 +34,19 @@ class BlLattice(BlDatablock): bl_icon = 'LATTICE_DATA' def _load_implementation(self, data, target): - utils.dump_anything.load(target, data) + loader = Loader() + loader.load(target, data) + + np_load_collection(data['points'], target.points, POINT) - for point in data['points']: - utils.dump_anything.load(target.points[point], data["points"][point]) def _construct(self, data): return bpy.data.lattices.new(data["name"]) def _dump_implementation(self, data, pointer=None): assert(pointer) - dumper = utils.dump_anything.Dumper() - dumper.depth = 3 + dumper = Dumper() + dumper.depth = 1 dumper.include_filter = [ "name", 'type', @@ -53,17 +56,10 @@ class BlLattice(BlDatablock): 'interpolation_type_u', 'interpolation_type_v', 'interpolation_type_w', - 'use_outside', - 'points', - 'co', - 'weight_softbody', - 'co_deform' + 'use_outside' ] data = dumper.dump(pointer) + data['points'] = np_dump_collection(pointer.points, POINT) return data - - - - From 776664149e95f03e0cf1c3eec335b97b9dc256c0 Mon Sep 17 00:00:00 2001 From: Swann Date: Wed, 1 Apr 2020 11:34:24 +0200 Subject: [PATCH 077/129] refactor: imports --- multi_user/bl_types/bl_action.py | 13 ++++++------- multi_user/bl_types/bl_armature.py | 10 ++++++---- multi_user/bl_types/bl_camera.py | 9 +++++---- multi_user/bl_types/bl_collection.py | 1 - multi_user/bl_types/bl_datablock.py | 17 +++++++++-------- multi_user/bl_types/bl_image.py | 3 ++- multi_user/bl_types/bl_library.py | 5 +++-- multi_user/bl_types/bl_light.py | 7 ++++--- multi_user/bl_types/bl_lightprobe.py | 7 ++++--- multi_user/bl_types/bl_material.py | 22 ++++++++++++---------- multi_user/bl_types/bl_metaball.py | 6 +++--- multi_user/bl_types/bl_object.py | 19 ++++++++++--------- multi_user/bl_types/bl_scene.py | 7 ++++--- multi_user/bl_types/bl_speaker.py | 7 ++++--- multi_user/bl_types/bl_world.py | 6 +++--- multi_user/presence.py | 6 ++---- multi_user/utils.py | 1 - 17 files changed, 77 insertions(+), 69 deletions(-) diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py index 40fc124..d1d2823 100644 --- a/multi_user/bl_types/bl_action.py +++ b/multi_user/bl_types/bl_action.py @@ -24,7 +24,7 @@ from enum import Enum from .. import utils from ..libs.dump_anything import ( - Dumper, Loader, np_dump_collection, np_load_collection) + Dumper, Loader, np_dump_collection, np_load_collection, remove_items_from_dict) from .bl_datablock import BlDatablock @@ -41,8 +41,6 @@ KEYFRAME = [ 'interpolation', ] -# TODO: Automatic enum and numpy dump and loading - def dump_fcurve(fcurve: bpy.types.FCurve, use_numpy:bool =True) -> dict: """ Dump a sigle curve to a dict @@ -65,7 +63,7 @@ def dump_fcurve(fcurve: bpy.types.FCurve, use_numpy:bool =True) -> dict: fcurve_data['keyframe_points'] = np_dump_collection(points, KEYFRAME) else: # Legacy method - dumper = utils.dump_anything.Dumper() + dumper = Dumper() fcurve_data["keyframe_points"] = [] for k in fcurve.keyframe_points: @@ -109,12 +107,13 @@ def load_fcurve(fcurve_data, fcurve): ) keycache = copy.copy(dumped_keyframe_point) - keycache = utils.dump_anything.remove_items_from_dict( + keycache = remove_items_from_dict( keycache, ["co", "handle_left", "handle_right", 'type'] ) - utils.dump_anything.load(new_kf, keycache) + loader = Loader() + loader.load(new_kf, keycache) new_kf.type = dumped_keyframe_point['type'] new_kf.handle_left = [ @@ -157,7 +156,7 @@ class BlAction(BlDatablock): def _dump(self, pointer=None): assert(pointer) - dumper = utils.dump_anything.Dumper() + dumper = Dumper() dumper.exclude_filter = [ 'name_full', 'original', diff --git a/multi_user/bl_types/bl_armature.py b/multi_user/bl_types/bl_armature.py index dcf85e3..b35271a 100644 --- a/multi_user/bl_types/bl_armature.py +++ b/multi_user/bl_types/bl_armature.py @@ -19,8 +19,9 @@ import bpy import mathutils -from .. import utils -from .. import presence, operators + +from ..libs.dump_anything import Loader, Dumper +from .. import presence, operators, utils from .bl_datablock import BlDatablock @@ -97,7 +98,8 @@ class BlArmature(BlDatablock): [bone]['parent']] new_bone.use_connect = bone_data['use_connect'] - utils.dump_anything.load(new_bone, bone_data) + loader = Loader() + loader.load(new_bone, bone_data) if bpy.context.mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT') @@ -110,7 +112,7 @@ class BlArmature(BlDatablock): def _dump_implementation(self, data, pointer=None): assert(pointer) - dumper = utils.dump_anything.Dumper() + dumper = Dumper() dumper.depth = 4 dumper.include_filter = [ 'bones', diff --git a/multi_user/bl_types/bl_camera.py b/multi_user/bl_types/bl_camera.py index aa4005e..b33b43b 100644 --- a/multi_user/bl_types/bl_camera.py +++ b/multi_user/bl_types/bl_camera.py @@ -19,7 +19,7 @@ import bpy import mathutils -from .. import utils +from ..libs.dump_anything import Loader, Dumper from .bl_datablock import BlDatablock @@ -36,20 +36,21 @@ class BlCamera(BlDatablock): def _load_implementation(self, data, target): - utils.dump_anything.load(target, data) + loader = Loader() + loader.load(target, data) dof_settings = data.get('dof') # DOF settings if dof_settings: - utils.dump_anything.load(target.dof, dof_settings) + loader.load(target.dof, dof_settings) def _dump_implementation(self, data, pointer=None): assert(pointer) # TODO: background image support - dumper = utils.dump_anything.Dumper() + dumper = Dumper() dumper.depth = 2 dumper.include_filter = [ "name", diff --git a/multi_user/bl_types/bl_collection.py b/multi_user/bl_types/bl_collection.py index 87ecc08..4a4d382 100644 --- a/multi_user/bl_types/bl_collection.py +++ b/multi_user/bl_types/bl_collection.py @@ -48,7 +48,6 @@ class BlCollection(BlDatablock): def _load_implementation(self, data, target): # Load other meshes metadata - # dump_anything.load(target, data) target.name = data["name"] # link objects diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index 8ddb6eb..802252b 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -20,9 +20,9 @@ import bpy import mathutils from .. import utils +from ..libs.dump_anything import Loader, Dumper from ..libs.replication.replication.data import ReplicatedDatablock from ..libs.replication.replication.constants import (UP, DIFF_BINARY) -from ..libs import dump_anything def has_action(target): @@ -42,7 +42,7 @@ def has_driver(target): def dump_driver(driver): - dumper = dump_anything.Dumper() + dumper = Dumper() dumper.depth = 6 data = dumper.dump(driver) @@ -50,6 +50,7 @@ def dump_driver(driver): def load_driver(target_datablock, src_driver): + loader = Loader() drivers = target_datablock.animation_data.drivers src_driver_data = src_driver['driver'] new_driver = drivers.new(src_driver['data_path']) @@ -57,7 +58,7 @@ def load_driver(target_datablock, src_driver): # Settings new_driver.driver.type = src_driver_data['type'] new_driver.driver.expression = src_driver_data['expression'] - dump_anything.load(new_driver, src_driver) + loader.load(new_driver, src_driver) # Variables for src_variable in src_driver_data['variables']: @@ -70,7 +71,7 @@ def load_driver(target_datablock, src_driver): src_target_data = src_var_data['targets'][src_target] new_var.targets[src_target].id = utils.resolve_from_id( src_target_data['id'], src_target_data['id_type']) - dump_anything.load( + loader.load( new_var.targets[src_target], src_target_data) # Fcurve @@ -82,8 +83,7 @@ def load_driver(target_datablock, src_driver): for index, src_point in enumerate(src_driver['keyframe_points']): new_point = new_fcurve[index] - dump_anything.load( - new_point, src_driver['keyframe_points'][src_point]) + loader.load(new_point, src_driver['keyframe_points'][src_point]) class BlDatablock(ReplicatedDatablock): @@ -127,10 +127,11 @@ class BlDatablock(ReplicatedDatablock): self.pointer = datablock_ref def _dump(self, pointer=None): + dumper = Dumper() data = {} # Dump animation data if has_action(pointer): - dumper = utils.dump_anything.Dumper() + dumper = Dumper() dumper.include_filter = ['action'] data['animation_data'] = dumper.dump(pointer.animation_data) @@ -143,7 +144,7 @@ class BlDatablock(ReplicatedDatablock): data.update(dumped_drivers) if self.is_library: - data.update(dump_anything.dump(pointer)) + data.update(dumper.dump(pointer)) else: data.update(self._dump_implementation(data, pointer=pointer)) diff --git a/multi_user/bl_types/bl_image.py b/multi_user/bl_types/bl_image.py index 8497725..d4aba73 100644 --- a/multi_user/bl_types/bl_image.py +++ b/multi_user/bl_types/bl_image.py @@ -21,6 +21,7 @@ import mathutils import os from .. import utils +from ..libs.dump_anything import Loader, Dumper from .bl_datablock import BlDatablock def dump_image(image): @@ -83,7 +84,7 @@ class BlImage(BlDatablock): assert(pointer) data = {} data['pixels'] = dump_image(pointer) - dumper = utils.dump_anything.Dumper() + dumper = Dumper() dumper.depth = 2 dumper.include_filter = [ "name", diff --git a/multi_user/bl_types/bl_library.py b/multi_user/bl_types/bl_library.py index f53e6de..fb3b0e8 100644 --- a/multi_user/bl_types/bl_library.py +++ b/multi_user/bl_types/bl_library.py @@ -19,7 +19,7 @@ import bpy import mathutils -from ..libs import dump_anything +from ..libs.dump_anything import Loader, Dumper from .bl_datablock import BlDatablock @@ -40,6 +40,7 @@ class BlLibrary(BlDatablock): def dump(self, pointer=None): assert(pointer) - return dump_anything.dump(pointer, 1) + dumper = Dumper() + return dumper.dump(pointer, 1) diff --git a/multi_user/bl_types/bl_light.py b/multi_user/bl_types/bl_light.py index 05b7dbb..6518c1f 100644 --- a/multi_user/bl_types/bl_light.py +++ b/multi_user/bl_types/bl_light.py @@ -19,7 +19,7 @@ import bpy import mathutils -from .. import utils +from ..libs.dump_anything import Loader, Dumper from .bl_datablock import BlDatablock @@ -35,11 +35,12 @@ class BlLight(BlDatablock): return bpy.data.lights.new(data["name"], data["type"]) def _load_implementation(self, data, target): - utils.dump_anything.load(target, data) + loader = Loader() + loader.load(target, data) def _dump_implementation(self, data, pointer=None): assert(pointer) - dumper = utils.dump_anything.Dumper() + dumper = Dumper() dumper.depth = 3 dumper.include_filter = [ "name", diff --git a/multi_user/bl_types/bl_lightprobe.py b/multi_user/bl_types/bl_lightprobe.py index 3941e19..9f869a5 100644 --- a/multi_user/bl_types/bl_lightprobe.py +++ b/multi_user/bl_types/bl_lightprobe.py @@ -20,7 +20,7 @@ import bpy import mathutils import logging -from .. import utils +from ..libs.dump_anything import Loader, Dumper from .bl_datablock import BlDatablock logger = logging.getLogger(__name__) @@ -42,14 +42,15 @@ class BlLightprobe(BlDatablock): logger.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") def _load_implementation(self, data, target): - utils.dump_anything.load(target, data) + loader = Loader() + loader.load(target, data) def _dump_implementation(self, data, pointer=None): assert(pointer) if bpy.app.version[1] < 83: logger.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") - dumper = utils.dump_anything.Dumper() + dumper = Dumper() dumper.depth = 1 dumper.include_filter = [ "name", diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index beee34c..7db3192 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -21,7 +21,7 @@ import mathutils import logging from .. import utils -from ..libs import dump_anything +from ..libs.dump_anything import Loader, Dumper from .bl_datablock import BlDatablock logger = logging.getLogger(__name__) @@ -34,9 +34,10 @@ def load_node(node_data, node_tree): :arg node_tree: target node_tree :type node_tree: bpy.types.NodeTree """ + loader = Loader() target_node = node_tree.nodes.new(type=node_data["bl_idname"]) - dump_anything.load(target_node, node_data) + loader.load(target_node, node_data) @@ -93,7 +94,7 @@ def dump_node(node): :retrun: dict """ - node_dumper = dump_anything.Dumper() + node_dumper = Dumper() node_dumper.depth = 1 node_dumper.exclude_filter = [ "dimensions", @@ -125,7 +126,7 @@ def dump_node(node): dumped_node['inputs'] = {} for i in node.inputs: - input_dumper = dump_anything.Dumper() + input_dumper = Dumper() input_dumper.depth = 2 input_dumper.include_filter = ["default_value"] @@ -133,7 +134,7 @@ def dump_node(node): dumped_node['inputs'][i.name] = input_dumper.dump( i) if hasattr(node, 'color_ramp'): - ramp_dumper = dump_anything.Dumper() + ramp_dumper = Dumper() ramp_dumper.depth = 4 ramp_dumper.include_filter = [ 'elements', @@ -143,7 +144,7 @@ def dump_node(node): ] dumped_node['color_ramp'] = ramp_dumper.dump(node.color_ramp) if hasattr(node, 'mapping'): - curve_dumper = dump_anything.Dumper() + curve_dumper = Dumper() curve_dumper.depth = 5 curve_dumper.include_filter = [ 'curves', @@ -167,12 +168,13 @@ class BlMaterial(BlDatablock): return bpy.data.materials.new(data["name"]) def _load_implementation(self, data, target): + loader = Loader() target.name = data['name'] if data['is_grease_pencil']: if not target.is_grease_pencil: bpy.data.materials.create_gpencil_data(target) - dump_anything.load( + loader.load( target.grease_pencil, data['grease_pencil']) @@ -182,7 +184,7 @@ class BlMaterial(BlDatablock): target.node_tree.nodes.clear() - dump_anything.load(target,data) + loader.load(target,data) # Load nodes for node in data["node_tree"]["nodes"]: @@ -195,7 +197,7 @@ class BlMaterial(BlDatablock): def _dump_implementation(self, data, pointer=None): assert(pointer) - mat_dumper = dump_anything.Dumper() + mat_dumper = Dumper() mat_dumper.depth = 2 mat_dumper.exclude_filter = [ "is_embed_data", @@ -223,7 +225,7 @@ class BlMaterial(BlDatablock): data["node_tree"]["links"] = dump_links(pointer.node_tree.links) elif pointer.is_grease_pencil: - gp_mat_dumper = dump_anything.Dumper() + gp_mat_dumper = Dumper() gp_mat_dumper.depth = 3 gp_mat_dumper.include_filter = [ diff --git a/multi_user/bl_types/bl_metaball.py b/multi_user/bl_types/bl_metaball.py index 5663510..865b846 100644 --- a/multi_user/bl_types/bl_metaball.py +++ b/multi_user/bl_types/bl_metaball.py @@ -19,7 +19,6 @@ import bpy import mathutils -from .. import utils from ..libs.dump_anything import ( Dumper, Loader, np_dump_collection_primitive, np_load_collection_primitives, np_dump_collection, np_load_collection) @@ -75,7 +74,8 @@ class BlMetaball(BlDatablock): return bpy.data.metaballs.new(data["name"]) def _load_implementation(self, data, target): - utils.dump_anything.load(target, data) + loader = Loader() + loader.load(target, data) target.elements.clear() @@ -86,7 +86,7 @@ class BlMetaball(BlDatablock): def _dump_implementation(self, data, pointer=None): assert(pointer) - dumper = utils.dump_anything.Dumper() + dumper = Dumper() dumper.depth = 1 dumper.exclude_filter = [ "is_editmode", diff --git a/multi_user/bl_types/bl_object.py b/multi_user/bl_types/bl_object.py index 962a46a..a02ee33 100644 --- a/multi_user/bl_types/bl_object.py +++ b/multi_user/bl_types/bl_object.py @@ -20,7 +20,7 @@ import bpy import mathutils import logging -from .. import utils +from ..libs.dump_anything import Loader, Dumper from .bl_datablock import BlDatablock logger = logging.getLogger(__name__) @@ -28,8 +28,8 @@ logger = logging.getLogger(__name__) def load_pose(target_bone, data): target_bone.rotation_mode = data['rotation_mode'] - - utils.dump_anything.load(target_bone, data) + loader = Loader() + loader.load(target_bone, data) class BlObject(BlDatablock): @@ -89,7 +89,8 @@ class BlObject(BlDatablock): def _load_implementation(self, data, target): # Load transformation data - utils.dump_anything.load(target, data) + loader = Loader() + loader.load(target, data) # Pose if 'pose' in data: @@ -103,7 +104,7 @@ class BlObject(BlDatablock): if not bg_target: bg_target = target.pose.bone_groups.new(name=bg_name) - utils.dump_anything.load(bg_target, bg_data) + loader.load(bg_target, bg_data) # target.pose.bone_groups.get # Bones @@ -112,7 +113,7 @@ class BlObject(BlDatablock): bone_data = data['pose']['bones'].get(bone) if 'constraints' in bone_data.keys(): - utils.dump_anything.load(target_bone, bone_data['constraints']) + loader.load(target_bone, bone_data['constraints']) load_pose(target_bone, bone_data) @@ -140,7 +141,7 @@ class BlObject(BlDatablock): key_data = data['shape_keys']['key_blocks'][key_block] target.shape_key_add(name=key_block) - utils.dump_anything.load( + loader.load( target.data.shape_keys.key_blocks[key_block], key_data) for vert in key_data['data']: target.data.shape_keys.key_blocks[key_block].data[vert].co = key_data['data'][vert]['co'] @@ -153,7 +154,7 @@ class BlObject(BlDatablock): def _dump_implementation(self, data, pointer=None): assert(pointer) - dumper = utils.dump_anything.Dumper() + dumper = Dumper() dumper.depth = 1 dumper.include_filter = [ "name", @@ -263,7 +264,7 @@ class BlObject(BlDatablock): # SHAPE KEYS object_data = pointer.data if hasattr(object_data, 'shape_keys') and object_data.shape_keys: - dumper = utils.dump_anything.Dumper() + dumper = Dumper() dumper.depth = 2 dumper.include_filter = [ 'reference_key', diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index 739f027..9f58d4f 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -19,7 +19,7 @@ import bpy import mathutils -from .. import utils +from ..libs.dump_anything import Loader, Dumper from .bl_datablock import BlDatablock class BlScene(BlDatablock): @@ -38,7 +38,8 @@ class BlScene(BlDatablock): def _load_implementation(self, data, target): target = self.pointer # Load other meshes metadata - utils.dump_anything.load(target, data) + loader = Loader() + loader.load(target, data) # Load master collection for object in data["collection"]["objects"]: @@ -71,7 +72,7 @@ class BlScene(BlDatablock): assert(pointer) data = {} - scene_dumper = utils.dump_anything.Dumper() + scene_dumper = Dumper() scene_dumper.depth = 1 scene_dumper.include_filter = [ 'name', diff --git a/multi_user/bl_types/bl_speaker.py b/multi_user/bl_types/bl_speaker.py index 5e90780..0928ee2 100644 --- a/multi_user/bl_types/bl_speaker.py +++ b/multi_user/bl_types/bl_speaker.py @@ -19,7 +19,7 @@ import bpy import mathutils -from .. import utils +from ..libs.dump_anything import Loader, Dumper from .bl_datablock import BlDatablock @@ -32,7 +32,8 @@ class BlSpeaker(BlDatablock): bl_icon = 'SPEAKER' def _load_implementation(self, data, target): - utils.dump_anything.load(target, data) + loader = Loader() + loader.load(target, data) def _construct(self, data): return bpy.data.speakers.new(data["name"]) @@ -40,7 +41,7 @@ class BlSpeaker(BlDatablock): def _dump_implementation(self, data, pointer=None): assert(pointer) - dumper = utils.dump_anything.Dumper() + dumper = Dumper() dumper.depth = 1 dumper.include_filter = [ "muted", diff --git a/multi_user/bl_types/bl_world.py b/multi_user/bl_types/bl_world.py index e78ca2a..75ca181 100644 --- a/multi_user/bl_types/bl_world.py +++ b/multi_user/bl_types/bl_world.py @@ -19,9 +19,9 @@ import bpy import mathutils -from .. import utils +from ..libs.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_links, load_node, dump_node, dump_links class BlWorld(BlDatablock): @@ -54,7 +54,7 @@ class BlWorld(BlDatablock): def _dump_implementation(self, data, pointer=None): assert(pointer) - world_dumper = utils.dump_anything.Dumper() + world_dumper = Dumper() world_dumper.depth = 2 world_dumper.exclude_filter = [ "preview", diff --git a/multi_user/presence.py b/multi_user/presence.py index 975193d..4a3ee19 100644 --- a/multi_user/presence.py +++ b/multi_user/presence.py @@ -135,10 +135,8 @@ def get_bb_coords_from_obj(object, parent=None): def get_view_matrix(): area, region, rv3d = view3d_find() - if area and region and rv3d: - matrix_dumper = utils.dump_anything.Dumper() - - return matrix_dumper.dump(rv3d.view_matrix) + if area and region and rv3d: + return [list(v) for v in rv3d.view_matrix] def update_presence(self, context): global renderer diff --git a/multi_user/utils.py b/multi_user/utils.py index 270766d..d221dff 100644 --- a/multi_user/utils.py +++ b/multi_user/utils.py @@ -28,7 +28,6 @@ import bpy import mathutils from . import environment, presence -from .libs import dump_anything logger = logging.getLogger(__name__) logger.setLevel(logging.WARNING) From 39766739d2a268bc762ac25ad561d8847822cc5f Mon Sep 17 00:00:00 2001 From: Swann Date: Wed, 1 Apr 2020 11:37:05 +0200 Subject: [PATCH 078/129] refactor: move dump_anything to the dump modules --- multi_user/bl_types/bl_action.py | 2 +- multi_user/bl_types/bl_armature.py | 2 +- multi_user/bl_types/bl_camera.py | 2 +- multi_user/bl_types/bl_curve.py | 2 +- multi_user/bl_types/bl_datablock.py | 2 +- multi_user/bl_types/bl_gpencil.py | 2 +- multi_user/bl_types/bl_image.py | 2 +- multi_user/bl_types/bl_lattice.py | 2 +- multi_user/bl_types/bl_library.py | 2 +- multi_user/bl_types/bl_light.py | 2 +- multi_user/bl_types/bl_lightprobe.py | 2 +- multi_user/bl_types/bl_material.py | 2 +- multi_user/bl_types/bl_mesh.py | 2 +- multi_user/bl_types/bl_metaball.py | 2 +- multi_user/bl_types/bl_object.py | 2 +- multi_user/bl_types/bl_scene.py | 2 +- multi_user/bl_types/bl_speaker.py | 2 +- multi_user/bl_types/bl_world.py | 2 +- multi_user/{libs => bl_types}/dump_anything.py | 0 19 files changed, 18 insertions(+), 18 deletions(-) rename multi_user/{libs => bl_types}/dump_anything.py (100%) diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py index d1d2823..1fc8138 100644 --- a/multi_user/bl_types/bl_action.py +++ b/multi_user/bl_types/bl_action.py @@ -23,7 +23,7 @@ import numpy as np from enum import Enum from .. import utils -from ..libs.dump_anything import ( +from .dump_anything import ( Dumper, Loader, np_dump_collection, np_load_collection, remove_items_from_dict) from .bl_datablock import BlDatablock diff --git a/multi_user/bl_types/bl_armature.py b/multi_user/bl_types/bl_armature.py index b35271a..98bef98 100644 --- a/multi_user/bl_types/bl_armature.py +++ b/multi_user/bl_types/bl_armature.py @@ -20,7 +20,7 @@ import bpy import mathutils -from ..libs.dump_anything import Loader, Dumper +from .dump_anything import Loader, Dumper from .. import presence, operators, utils from .bl_datablock import BlDatablock diff --git a/multi_user/bl_types/bl_camera.py b/multi_user/bl_types/bl_camera.py index b33b43b..178d578 100644 --- a/multi_user/bl_types/bl_camera.py +++ b/multi_user/bl_types/bl_camera.py @@ -19,7 +19,7 @@ import bpy import mathutils -from ..libs.dump_anything import Loader, Dumper +from .dump_anything import Loader, Dumper from .bl_datablock import BlDatablock diff --git a/multi_user/bl_types/bl_curve.py b/multi_user/bl_types/bl_curve.py index c662829..b2ec720 100644 --- a/multi_user/bl_types/bl_curve.py +++ b/multi_user/bl_types/bl_curve.py @@ -23,7 +23,7 @@ import logging from .. import utils from .bl_datablock import BlDatablock -from ..libs.dump_anything import (Dumper, Loader, +from .dump_anything import (Dumper, Loader, np_load_collection, np_dump_collection) diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index 802252b..ed58f27 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -20,7 +20,7 @@ import bpy import mathutils from .. import utils -from ..libs.dump_anything import Loader, Dumper +from .dump_anything import Loader, Dumper from ..libs.replication.replication.data import ReplicatedDatablock from ..libs.replication.replication.constants import (UP, DIFF_BINARY) diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index 81acd16..346d1b5 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -20,7 +20,7 @@ import bpy import mathutils import numpy as np -from ..libs.dump_anything import (Dumper, +from .dump_anything import (Dumper, Loader, np_dump_collection, np_load_collection) diff --git a/multi_user/bl_types/bl_image.py b/multi_user/bl_types/bl_image.py index d4aba73..4a9ce9c 100644 --- a/multi_user/bl_types/bl_image.py +++ b/multi_user/bl_types/bl_image.py @@ -21,7 +21,7 @@ import mathutils import os from .. import utils -from ..libs.dump_anything import Loader, Dumper +from .dump_anything import Loader, Dumper from .bl_datablock import BlDatablock def dump_image(image): diff --git a/multi_user/bl_types/bl_lattice.py b/multi_user/bl_types/bl_lattice.py index 1c5443f..d816a2c 100644 --- a/multi_user/bl_types/bl_lattice.py +++ b/multi_user/bl_types/bl_lattice.py @@ -19,7 +19,7 @@ import bpy import mathutils -from ..libs.dump_anything import Dumper, Loader, np_dump_collection, np_load_collection +from .dump_anything import Dumper, Loader, np_dump_collection, np_load_collection from .bl_datablock import BlDatablock POINT = ['co', 'weight_softbody', 'co_deform'] diff --git a/multi_user/bl_types/bl_library.py b/multi_user/bl_types/bl_library.py index fb3b0e8..1057026 100644 --- a/multi_user/bl_types/bl_library.py +++ b/multi_user/bl_types/bl_library.py @@ -19,7 +19,7 @@ import bpy import mathutils -from ..libs.dump_anything import Loader, Dumper +from .dump_anything import Loader, Dumper from .bl_datablock import BlDatablock diff --git a/multi_user/bl_types/bl_light.py b/multi_user/bl_types/bl_light.py index 6518c1f..3bccfe4 100644 --- a/multi_user/bl_types/bl_light.py +++ b/multi_user/bl_types/bl_light.py @@ -19,7 +19,7 @@ import bpy import mathutils -from ..libs.dump_anything import Loader, Dumper +from .dump_anything import Loader, Dumper from .bl_datablock import BlDatablock diff --git a/multi_user/bl_types/bl_lightprobe.py b/multi_user/bl_types/bl_lightprobe.py index 9f869a5..7da2b7e 100644 --- a/multi_user/bl_types/bl_lightprobe.py +++ b/multi_user/bl_types/bl_lightprobe.py @@ -20,7 +20,7 @@ import bpy import mathutils import logging -from ..libs.dump_anything import Loader, Dumper +from .dump_anything import Loader, Dumper from .bl_datablock import BlDatablock logger = logging.getLogger(__name__) diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index 7db3192..4699711 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -21,7 +21,7 @@ import mathutils import logging from .. import utils -from ..libs.dump_anything import Loader, Dumper +from .dump_anything import Loader, Dumper from .bl_datablock import BlDatablock logger = logging.getLogger(__name__) diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index 137cec8..bd8d618 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -22,7 +22,7 @@ import mathutils import logging import numpy as np -from ..libs.dump_anything import Dumper, Loader, np_load_collection_primitives, np_dump_collection_primitive, np_load_collection, np_dump_collection +from .dump_anything import Dumper, Loader, np_load_collection_primitives, np_dump_collection_primitive, np_load_collection, np_dump_collection from ..libs.replication.replication.constants import DIFF_BINARY from .bl_datablock import BlDatablock diff --git a/multi_user/bl_types/bl_metaball.py b/multi_user/bl_types/bl_metaball.py index 865b846..22eeea5 100644 --- a/multi_user/bl_types/bl_metaball.py +++ b/multi_user/bl_types/bl_metaball.py @@ -19,7 +19,7 @@ import bpy import mathutils -from ..libs.dump_anything import ( +from .dump_anything import ( Dumper, Loader, np_dump_collection_primitive, np_load_collection_primitives, np_dump_collection, np_load_collection) diff --git a/multi_user/bl_types/bl_object.py b/multi_user/bl_types/bl_object.py index a02ee33..9e45ed7 100644 --- a/multi_user/bl_types/bl_object.py +++ b/multi_user/bl_types/bl_object.py @@ -20,7 +20,7 @@ import bpy import mathutils import logging -from ..libs.dump_anything import Loader, Dumper +from .dump_anything import Loader, Dumper from .bl_datablock import BlDatablock logger = logging.getLogger(__name__) diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index 9f58d4f..39cbeb4 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -19,7 +19,7 @@ import bpy import mathutils -from ..libs.dump_anything import Loader, Dumper +from .dump_anything import Loader, Dumper from .bl_datablock import BlDatablock class BlScene(BlDatablock): diff --git a/multi_user/bl_types/bl_speaker.py b/multi_user/bl_types/bl_speaker.py index 0928ee2..3d419f9 100644 --- a/multi_user/bl_types/bl_speaker.py +++ b/multi_user/bl_types/bl_speaker.py @@ -19,7 +19,7 @@ import bpy import mathutils -from ..libs.dump_anything import Loader, Dumper +from .dump_anything import Loader, Dumper from .bl_datablock import BlDatablock diff --git a/multi_user/bl_types/bl_world.py b/multi_user/bl_types/bl_world.py index 75ca181..7fffb53 100644 --- a/multi_user/bl_types/bl_world.py +++ b/multi_user/bl_types/bl_world.py @@ -19,7 +19,7 @@ import bpy import mathutils -from ..libs.dump_anything import Loader, Dumper +from .dump_anything import Loader, Dumper from .bl_datablock import BlDatablock from .bl_material import load_links, load_node, dump_node, dump_links diff --git a/multi_user/libs/dump_anything.py b/multi_user/bl_types/dump_anything.py similarity index 100% rename from multi_user/libs/dump_anything.py rename to multi_user/bl_types/dump_anything.py From bfbf2727ea47f3aa127122ad16bab6dba678029f Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 2 Apr 2020 12:14:12 +0200 Subject: [PATCH 079/129] fix: material error --- multi_user/bl_types/bl_gpencil.py | 14 ++++++++++++-- multi_user/bl_types/bl_library.py | 2 +- multi_user/bl_types/bl_material.py | 5 +++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index 346d1b5..e3156ed 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -228,6 +228,9 @@ class BlGpencil(BlDatablock): for mat in data['materials']: target.materials.append(bpy.data.materials[mat]) + loader = Loader() + loader.load(target, data) + # TODO: reuse existing layer for layer in target.layers: target.layers.remove(layer) @@ -244,8 +247,7 @@ class BlGpencil(BlDatablock): load_layer(layer_data, target_layer) - loader = Loader() - loader.load(target, data) + @@ -253,6 +255,14 @@ class BlGpencil(BlDatablock): assert(pointer) dumper = Dumper() dumper.depth = 2 + dumper.include_filter = [ + 'materials', + 'name', + 'zdepth_offset', + 'stroke_thickness_space', + 'pixel_factor', + 'stroke_depth_order' + ] data = dumper.dump(pointer) data['layers'] = {} diff --git a/multi_user/bl_types/bl_library.py b/multi_user/bl_types/bl_library.py index 1057026..f5b1753 100644 --- a/multi_user/bl_types/bl_library.py +++ b/multi_user/bl_types/bl_library.py @@ -41,6 +41,6 @@ class BlLibrary(BlDatablock): def dump(self, pointer=None): assert(pointer) dumper = Dumper() - return dumper.dump(pointer, 1) + return dumper.dump(pointer) diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index 4699711..f230ef1 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -178,7 +178,7 @@ class BlMaterial(BlDatablock): target.grease_pencil, data['grease_pencil']) - elif data["use_nodes"]: + if data["use_nodes"]: if target.node_tree is None: target.use_nodes = True @@ -224,7 +224,8 @@ class BlMaterial(BlDatablock): data["node_tree"]['nodes'] = nodes data["node_tree"]["links"] = dump_links(pointer.node_tree.links) - elif pointer.is_grease_pencil: + + if pointer.is_grease_pencil: gp_mat_dumper = Dumper() gp_mat_dumper.depth = 3 From 0783c625d0cf4df6e3b5bf3ea110d3251168abe0 Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 2 Apr 2020 17:39:14 +0200 Subject: [PATCH 080/129] feat: initial render settings replication support --- multi_user/bl_types/bl_scene.py | 34 +++++++++++++++++++++++++--- multi_user/bl_types/dump_anything.py | 27 +++++++++++++--------- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index 39cbeb4..8530492 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -68,6 +68,16 @@ class BlScene(BlDatablock): if 'grease_pencil' in data.keys(): target.grease_pencil = bpy.data.grease_pencils[data['grease_pencil']] + if 'eevee' in data.keys(): + loader.load(target.eevee, data['eevee']) + + if 'cycles' in data.keys(): + loader.load(target.eevee, data['cycles']) + + if 'view_settings' in data.keys(): + loader.load(target.view_settings, data['view_settings']) + target.view_settings.curve_mapping.update() + def _dump_implementation(self, data, pointer=None): assert(pointer) data = {} @@ -79,15 +89,33 @@ class BlScene(BlDatablock): 'world', 'id', 'camera', - 'grease_pencil' + 'grease_pencil', ] data = scene_dumper.dump(pointer) scene_dumper.depth = 3 + scene_dumper.include_filter = ['children','objects','name'] data['collection'] = scene_dumper.dump(pointer.collection) - - + + scene_dumper.depth = 1 + scene_dumper.include_filter = None + + data['eevee'] = scene_dumper.dump(pointer.eevee) + data['cycles'] = scene_dumper.dump(pointer.cycles) + data['view_settings'] = scene_dumper.dump(pointer.view_settings) + data['view_settings']['curve_mapping'] = scene_dumper.dump(pointer.view_settings.curve_mapping) + + if pointer.view_settings.use_curve_mapping: + scene_dumper.depth = 5 + scene_dumper.include_filter = [ + 'curves', + 'points', + 'location' + ] + data['view_settings']['curve_mapping']['curves'] = scene_dumper.dump(pointer.view_settings.curve_mapping.curves) + + return data def _resolve_deps_implementation(self): diff --git a/multi_user/bl_types/dump_anything.py b/multi_user/bl_types/dump_anything.py index d47e84e..c428cdd 100644 --- a/multi_user/bl_types/dump_anything.py +++ b/multi_user/bl_types/dump_anything.py @@ -544,22 +544,27 @@ class Loader: def _load_curve_mapping(self, element, dump): mapping = element.read() - # cleanup existing curve - for curve in mapping.curves: - for idx in range(len(curve.points)): - if idx == 0: + curves = mapping.curves + + for curve_index, curve in dump['curves'].items(): + dst_curve = curves[curve_index] + + # cleanup existing curve + for idx in range(len(dst_curve.points), 0, -1): + try: + dst_curve.points.remove(dst_curve.points[0]) + except Exception: break - curve.points.remove(curve.points[1]) - for curve_index, curve in dump['curves'].items(): + default_point_count = len(dst_curve.points) + for point_idx, point in curve['points'].items(): pos = point['location'] - - if len(mapping.curves[curve_index].points) == 1: - mapping.curves[curve_index].points[int( - point_idx)].location = pos + + if point_idx < default_point_count: + dst_curve.points[int(point_idx)].location = pos else: - mapping.curves[curve_index].points.new(pos[0], pos[1]) + dst_curve.points.new(pos[0], pos[1]) def _load_pointer(self, pointer, dump): rna_property_type = pointer.bl_rna_property.fixed_type From 9c897745fd0666e7457cbe2ff42a550caf5567b6 Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 2 Apr 2020 18:42:41 +0200 Subject: [PATCH 081/129] feat: optionnal setting on host --- multi_user/bl_types/bl_scene.py | 31 ++++++++++++++++++------------- multi_user/preferences.py | 13 ++++++++++++- multi_user/ui.py | 3 +++ 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index 8530492..62ef7b9 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -22,6 +22,8 @@ import mathutils from .dump_anything import Loader, Dumper from .bl_datablock import BlDatablock +from ..utils import get_preferences + class BlScene(BlDatablock): bl_id = "scenes" bl_class = bpy.types.Scene @@ -101,19 +103,22 @@ class BlScene(BlDatablock): scene_dumper.depth = 1 scene_dumper.include_filter = None - data['eevee'] = scene_dumper.dump(pointer.eevee) - data['cycles'] = scene_dumper.dump(pointer.cycles) - data['view_settings'] = scene_dumper.dump(pointer.view_settings) - data['view_settings']['curve_mapping'] = scene_dumper.dump(pointer.view_settings.curve_mapping) - - if pointer.view_settings.use_curve_mapping: - scene_dumper.depth = 5 - scene_dumper.include_filter = [ - 'curves', - 'points', - 'location' - ] - data['view_settings']['curve_mapping']['curves'] = scene_dumper.dump(pointer.view_settings.curve_mapping.curves) + pref = get_preferences() + + if pref.sync_flags.sync_render_settings: + data['eevee'] = scene_dumper.dump(pointer.eevee) + data['cycles'] = scene_dumper.dump(pointer.cycles) + data['view_settings'] = scene_dumper.dump(pointer.view_settings) + data['view_settings']['curve_mapping'] = scene_dumper.dump(pointer.view_settings.curve_mapping) + + if pointer.view_settings.use_curve_mapping: + scene_dumper.depth = 5 + scene_dumper.include_filter = [ + 'curves', + 'points', + 'location' + ] + data['view_settings']['curve_mapping']['curves'] = scene_dumper.dump(pointer.view_settings.curve_mapping.curves) return data diff --git a/multi_user/preferences.py b/multi_user/preferences.py index 24317c9..f86bdcb 100644 --- a/multi_user/preferences.py +++ b/multi_user/preferences.py @@ -36,7 +36,7 @@ def random_string_digits(stringLength=6): """Generate a random string of letters and digits """ lettersAndDigits = string.ascii_letters + string.digits return ''.join(random.choices(lettersAndDigits, k=stringLength)) - + class ReplicatedDatablock(bpy.types.PropertyGroup): type_name: bpy.props.StringProperty() @@ -48,6 +48,13 @@ class ReplicatedDatablock(bpy.types.PropertyGroup): icon: bpy.props.StringProperty() +class ReplicationFlags(bpy.types.PropertyGroup): + sync_render_settings: bpy.props.BoolProperty( + name="Synchronize render settings", + description="Synchronize render settings (eevee and cycles only)", + default=True) + + class SessionPrefs(bpy.types.AddonPreferences): bl_idname = __package__ @@ -68,6 +75,9 @@ class SessionPrefs(bpy.types.AddonPreferences): description='Distant host port', default=5555 ) + sync_flags: bpy.props.PointerProperty( + type=ReplicationFlags + ) supported_datablocks: bpy.props.CollectionProperty( type=ReplicatedDatablock, ) @@ -340,6 +350,7 @@ class SessionProps(bpy.types.PropertyGroup): classes = ( SessionUser, SessionProps, + ReplicationFlags, ReplicatedDatablock, SessionPrefs, ) diff --git a/multi_user/ui.py b/multi_user/ui.py index 26f5c8d..d23f244 100644 --- a/multi_user/ui.py +++ b/multi_user/ui.py @@ -241,6 +241,9 @@ class SESSION_PT_settings_replication(bpy.types.Panel): # Right managment if runtime_settings.session_mode == 'HOST': + row = layout.row() + row.prop(settings.sync_flags,"sync_render_settings") + row = layout.row(align=True) row.label(text="Right strategy:") row.prop(settings,"right_strategy",text="") From b3b2296b068454d728a3850890c0bbf039748903 Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 2 Apr 2020 19:21:55 +0200 Subject: [PATCH 082/129] fix: black and white level errors --- multi_user/bl_types/bl_scene.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index 62ef7b9..d4fb3b7 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -78,6 +78,10 @@ class BlScene(BlDatablock): if 'view_settings' in data.keys(): loader.load(target.view_settings, data['view_settings']) + if target.view_settings.use_curve_mapping: + #TODO: change this ugly fix + target.view_settings.curve_mapping.white_level = data['view_settings']['curve_mapping']['white_level'] + target.view_settings.curve_mapping.black_level = data['view_settings']['curve_mapping']['black_level'] target.view_settings.curve_mapping.update() def _dump_implementation(self, data, pointer=None): @@ -106,6 +110,9 @@ class BlScene(BlDatablock): pref = get_preferences() if pref.sync_flags.sync_render_settings: + scene_dumper.exclude_filter = [ + 'gi_cache_info', + ] data['eevee'] = scene_dumper.dump(pointer.eevee) data['cycles'] = scene_dumper.dump(pointer.cycles) data['view_settings'] = scene_dumper.dump(pointer.view_settings) From 1b94e017e6b33f47de45a2b06080af6d6e1e517e Mon Sep 17 00:00:00 2001 From: Swann Date: Thu, 2 Apr 2020 19:36:28 +0200 Subject: [PATCH 083/129] feat: ignore wrong parameters --- multi_user/bl_types/bl_scene.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index d4fb3b7..3db21f9 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -112,6 +112,18 @@ class BlScene(BlDatablock): if pref.sync_flags.sync_render_settings: scene_dumper.exclude_filter = [ 'gi_cache_info', + 'feature_set', + 'debug_use_hair_bvh', + 'aa_samples', + 'blur_glossy', + 'glossy_bounces', + 'device', + 'max_bounces', + 'preview_aa_samples', + 'preview_samples', + 'sample_clamp_indirect', + 'samples', + 'volume_bounces' ] data['eevee'] = scene_dumper.dump(pointer.eevee) data['cycles'] = scene_dumper.dump(pointer.cycles) From 0fde356f4b9fda0d244f1c71069c14c93e70c278 Mon Sep 17 00:00:00 2001 From: Swann Date: Fri, 3 Apr 2020 14:59:33 +0200 Subject: [PATCH 084/129] feat: initial kick --- multi_user/__init__.py | 1 - multi_user/libs/replication | 2 +- multi_user/operators.py | 31 +++++++++++++++++++++++++++++++ multi_user/preferences.py | 1 + multi_user/ui.py | 8 +++++++- 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/multi_user/__init__.py b/multi_user/__init__.py index 1015e79..e328298 100644 --- a/multi_user/__init__.py +++ b/multi_user/__init__.py @@ -41,7 +41,6 @@ import bpy from bpy.app.handlers import persistent from . import environment, utils -from .libs.replication.replication.constants import RP_COMMON # TODO: remove dependency as soon as replication will be installed as a module diff --git a/multi_user/libs/replication b/multi_user/libs/replication index 83caa04..905d377 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit 83caa04e71ca7ded8ba44fb12cf8042e61308d7a +Subproject commit 905d377bc257966a61cbce1f6d278d2a9df82e83 diff --git a/multi_user/operators.py b/multi_user/operators.py index 1d302c1..4c7560c 100644 --- a/multi_user/operators.py +++ b/multi_user/operators.py @@ -199,6 +199,36 @@ class SessionStopOperator(bpy.types.Operator): return {"FINISHED"} +class SessionKickOperator(bpy.types.Operator): + bl_idname = "session.kick" + bl_label = "Kick" + bl_description = "Kick the user" + bl_options = {"REGISTER"} + + user: bpy.props.StringProperty() + + @classmethod + def poll(cls, context): + return True + + def execute(self, context): + global client, delayables, stop_modal_executor + assert(client) + + try: + client.kick(self.user) + except Exception as e: + self.report({'ERROR'}, repr(e)) + + return {"FINISHED"} + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + + def draw(self, context): + row = self.layout + row.label(text=f" Do you really want to kick {self.user} ? " ) class SessionPropertyRemoveOperator(bpy.types.Operator): bl_idname = "session.remove_prop" @@ -464,6 +494,7 @@ classes = ( SessionApply, SessionCommit, ApplyArmatureOperator, + SessionKickOperator, ) diff --git a/multi_user/preferences.py b/multi_user/preferences.py index f86bdcb..414c2ef 100644 --- a/multi_user/preferences.py +++ b/multi_user/preferences.py @@ -21,6 +21,7 @@ import bpy import string from . import utils, bl_types, environment, addon_updater_ops, presence +from .libs.replication.replication.constants import RP_COMMON logger = logging.getLogger(__name__) diff --git a/multi_user/ui.py b/multi_user/ui.py index d23f244..1c13367 100644 --- a/multi_user/ui.py +++ b/multi_user/ui.py @@ -286,7 +286,7 @@ class SESSION_PT_user(bpy.types.Panel): selected_user = context.window_manager.user_index settings = utils.get_preferences() active_user = online_users[selected_user] if len(online_users)-1>=selected_user else 0 - + runtime_settings = context.window_manager.session # Create a simple row. row = layout.row() @@ -316,6 +316,12 @@ class SESSION_PT_user(bpy.types.Panel): text="", icon='TIME').target_client = active_user.username + if runtime_settings.session_mode == 'HOST': + user_operations.operator( + "session.kick", + text="", + icon='CANCEL').user = active_user.username + class SESSION_UL_users(bpy.types.UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag): From cfd80dd426ff5f7789bf6fe6e2be61f7b94bd711 Mon Sep 17 00:00:00 2001 From: Swann Date: Fri, 3 Apr 2020 15:39:56 +0200 Subject: [PATCH 085/129] feat: update submodule --- multi_user/libs/replication | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_user/libs/replication b/multi_user/libs/replication index 905d377..a9a5b44 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit 905d377bc257966a61cbce1f6d278d2a9df82e83 +Subproject commit a9a5b4483a888532c88dc1f269161e22014a7233 From 408a16064c1d8cbe1b5d4ef66a248a52ce1f5fca Mon Sep 17 00:00:00 2001 From: Swann Date: Fri, 3 Apr 2020 15:43:04 +0200 Subject: [PATCH 086/129] feat: update submodule --- multi_user/libs/replication | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_user/libs/replication b/multi_user/libs/replication index a9a5b44..d3601d5 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit a9a5b4483a888532c88dc1f269161e22014a7233 +Subproject commit d3601d508235b32a268371f062a5ccb6f03c8406 From 49d676f4f267c556e14d1dd6e41ad1880463f1d8 Mon Sep 17 00:00:00 2001 From: Swann Date: Fri, 3 Apr 2020 17:27:56 +0200 Subject: [PATCH 087/129] feat: documentation update Added kick option Added show flag description Updated captures --- .../img/quickstart_advanced.png | Bin 11765 -> 9786 bytes .../img/quickstart_presence.png | Bin 0 -> 5977 bytes docs/getting_started/img/quickstart_users.png | Bin 9275 -> 12203 bytes docs/getting_started/quickstart.rst | 15 +++++++++++++++ 4 files changed, 15 insertions(+) create mode 100644 docs/getting_started/img/quickstart_presence.png diff --git a/docs/getting_started/img/quickstart_advanced.png b/docs/getting_started/img/quickstart_advanced.png index 3cb0ad89223cf9e86f341be3ceac8c16e47d5fe4..2416b2df4242a9a2ff9465f4cfcb8e553deb56d3 100644 GIT binary patch literal 9786 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 Zgj7eQwhccZ{@;s4OHEI;TG=M@{{RGd2Q>fy literal 11765 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 c&%N-!ag{KSZ?@|O3hc3zk literal 9275 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 Mex_8RU>frO0CFBd-T(jq diff --git a/docs/getting_started/quickstart.rst b/docs/getting_started/quickstart.rst index b7295bc..319960f 100644 --- a/docs/getting_started/quickstart.rst +++ b/docs/getting_started/quickstart.rst @@ -46,6 +46,8 @@ If 5555 is given in host settings, it will use 5555, 5556 (5555+1), 5557 (5555+2 .. image:: img/quickstart_advanced.png +**Synchronise render settings** (only host) enable replication of EEVEE and CYCLES render settings to match render between clients. + **Right strategy** (only host) enable you to choose between a strict and a relaxed pattern: - **Strict**: Host is the king, by default the host own each properties, only him can grant modification rights. @@ -76,6 +78,19 @@ By selecting a user in the list you'll have access to different **actions**: - The **camera button** allow you to snap on the user viewpoint. - The **time button** allow you to snap on the user time. +- The **cross button** [**host only**] allow the admin to kick users + +------------------- +Presence show flags +------------------- + +.. image:: img/quickstart_presence.png + +This pannel allow you to tweak users overlay in the viewport: + +- **Show selected objects**: display other users current selection +- **Show users**: display users current viewpoint +- **Show different scenes**: display users on other scenes --------------------- Replicated properties From 5e6f6ac4604d9180437b6b02adf82d33072205ff Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Fri, 3 Apr 2020 16:10:34 +0000 Subject: [PATCH 088/129] Update CHANGELOG.md --- CHANGELOG.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da2de97..6fde5e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,4 +35,18 @@ All notable changes to this project will be documented in this file. - Right management takes view-layer in account for object selection. - Use a basic BFS approach for replication graph pre-load. - Serialization is now based on marshal (2x performance improvements). -- Let pip chose python dependencies install path. \ No newline at end of file +- Let pip chose python dependencies install path. + +## [0.0.3] - Upcoming + +### Added + +- Auto updater support +- Performances improvements on Meshes, Gpencils, Actions +- Multi-scene workflow support +- Render setting synchronisation +- Kick command + +### Changed + +- Config is now stored in blender user preference From 3394299e8bf0de3dba76c102452a6f439e21476f Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 6 Apr 2020 10:38:31 +0200 Subject: [PATCH 089/129] fix: view_settings error fix: empty collection error --- multi_user/bl_types/bl_scene.py | 2 +- multi_user/bl_types/dump_anything.py | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index 3db21f9..08aeffe 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -82,7 +82,7 @@ class BlScene(BlDatablock): #TODO: change this ugly fix target.view_settings.curve_mapping.white_level = data['view_settings']['curve_mapping']['white_level'] target.view_settings.curve_mapping.black_level = data['view_settings']['curve_mapping']['black_level'] - target.view_settings.curve_mapping.update() + target.view_settings.curve_mapping.update() def _dump_implementation(self, data, pointer=None): assert(pointer) diff --git a/multi_user/bl_types/dump_anything.py b/multi_user/bl_types/dump_anything.py index c428cdd..e97fa63 100644 --- a/multi_user/bl_types/dump_anything.py +++ b/multi_user/bl_types/dump_anything.py @@ -47,6 +47,10 @@ def np_load_collection(dikt: dict, collection: bpy.types.CollectionProperty, att :arg attributes: list of attributes name :type attributes: list """ + if not dikt or len(collection) == 0: + logger.warning(f'Skipping collection') + return + if attributes is None: attributes = dikt.keys() @@ -111,11 +115,15 @@ def np_dump_collection_primitive(collection: bpy.types.CollectionProperty, attri :type attribute: str :return: numpy byte buffer """ + if len(collection) == 0: + logger.warning(f'Skipping empty {attribute} attribute') + return {} attr_infos = collection[0].bl_rna.properties.get(attribute) assert(attr_infos.type in ['FLOAT', 'INT', 'BOOLEAN']) + size = sum(attr_infos.array_dimensions) if attr_infos.is_array else 1 dumped_sequence = np.zeros( @@ -182,10 +190,12 @@ def np_load_collection_primitives(collection: bpy.types.CollectionProperty, attr :arg attribute: target attribute :type attribute: str :arg sequence: data buffer - :type sequence: str - :return: numpy byte buffer + :type sequence: strr """ - + if len(collection) == 0: + logger.warning(f"Skipping loadin {attribute}") + return + attr_infos = collection[0].bl_rna.properties.get(attribute) assert(attr_infos.type in ['FLOAT', 'INT', 'BOOLEAN']) From e65dd1939a2afb7f2e1f2e9baa309f67b3adacfa Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 6 Apr 2020 14:47:03 +0200 Subject: [PATCH 090/129] refactor: update screen only on client update --- multi_user/delayable.py | 48 ++++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/multi_user/delayable.py b/multi_user/delayable.py index 78598d4..63b8ac8 100644 --- a/multi_user/delayable.py +++ b/multi_user/delayable.py @@ -214,11 +214,13 @@ class DrawClient(Draw): def execute(self): session = getattr(operators, 'client', None) renderer = getattr(presence, 'renderer', None) + prefs = utils.get_preferences() if session and renderer and session.state['STATE'] == STATE_ACTIVE: settings = bpy.context.window_manager.session users = session.online_users + # Update users for user in users.values(): metadata = user.get('metadata') color = metadata.get('color') @@ -237,10 +239,12 @@ class DrawClient(Draw): renderer.flush_selection() renderer.flush_users() + class ClientUpdate(Timer): - def __init__(self, timout=.5): + def __init__(self, timout=.016): super().__init__(timout) self.handle_quit = False + self.users_metadata = {} def execute(self): settings = utils.get_preferences() @@ -252,17 +256,34 @@ class ClientUpdate(Timer): if session.state['STATE'] == 0: bpy.ops.session.stop() - local_user = operators.client.online_users.get( - settings.username) + local_user = operators.client.online_users.get(settings.username) + if not local_user: return + else: + for username, user_data in operators.client.online_users.items(): + if username != settings.username: + cached_user_data = self.users_metadata.get(username) + new_user_data = operators.client.online_users[username]['metadata'] + + if cached_user_data is None: + self.users_metadata[username] = user_data['metadata'] + elif 'view_matrix' in cached_user_data and 'view_matrix' in new_user_data and cached_user_data['view_matrix'] != new_user_data['view_matrix']: + presence.refresh_3d_view() + self.users_metadata[username] = user_data['metadata'] + break + else: + self.users_metadata[username] = user_data['metadata'] local_user_metadata = local_user.get('metadata') - current_view_corners = presence.get_view_corners() scene_current = bpy.context.scene.name + local_user = session.online_users.get(settings.username) + current_view_corners = presence.get_view_corners() + + # Init client metadata if not local_user_metadata or 'color' not in local_user_metadata.keys(): metadata = { - 'view_corners': current_view_corners, + 'view_corners': presence.get_view_matrix(), 'view_matrix': presence.get_view_matrix(), 'color': (settings.client_color.r, settings.client_color.g, @@ -272,15 +293,16 @@ class ClientUpdate(Timer): 'scene_current': scene_current } session.update_user_metadata(metadata) - elif current_view_corners != local_user_metadata['view_corners']: - logger.info('update user metadata') + + # Update client representation + # Update client current scene + elif scene_current != local_user_metadata['scene_current']: + local_user_metadata['scene_current'] = scene_current + session.update_user_metadata(local_user_metadata) + elif 'view_corners' in local_user_metadata and current_view_corners != local_user_metadata['view_corners']: local_user_metadata['view_corners'] = current_view_corners local_user_metadata['view_matrix'] = presence.get_view_matrix() session.update_user_metadata(local_user_metadata) - elif scene_current != local_user_metadata['scene_current']: - local_user_metadata['scene_current'] = scene_current - session.update_user_metadata(local_user_metadata) - # sync online users session_users = operators.client.online_users ui_users = bpy.context.window_manager.online_users @@ -299,7 +321,9 @@ class ClientUpdate(Timer): new_key.username = user # TODO: event drivent 3d view refresh - presence.refresh_3d_view() + + + elif session.state['STATE'] == STATE_QUITTING: presence.refresh_3d_view() self.handle_quit = True From 271f2105917ab1d15c74904bc6f308b2b82f20ae Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 6 Apr 2020 15:16:57 +0200 Subject: [PATCH 091/129] fix: light missing parameters --- multi_user/bl_types/bl_light.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/multi_user/bl_types/bl_light.py b/multi_user/bl_types/bl_light.py index 3bccfe4..891bb8f 100644 --- a/multi_user/bl_types/bl_light.py +++ b/multi_user/bl_types/bl_light.py @@ -61,7 +61,10 @@ class BlLight(BlDatablock): "contact_shadow_soft_size", "contact_shadow_bias", "contact_shadow_thickness", - "shape" + "shape", + "size_y", + "size", + "angle" ] data = dumper.dump(pointer) return data From 2d700e83bbc2fa7432286132c6cce18e4f12f56e Mon Sep 17 00:00:00 2001 From: Swann Date: Mon, 6 Apr 2020 15:36:28 +0200 Subject: [PATCH 092/129] feat: update submodule --- multi_user/libs/replication | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_user/libs/replication b/multi_user/libs/replication index d3601d5..cc66d1d 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit d3601d508235b32a268371f062a5ccb6f03c8406 +Subproject commit cc66d1d8e4b4a0351faeb6deeacf69693dcafcab From c23b5923ab9c7d306ebc667453ad85187dc5d0db Mon Sep 17 00:00:00 2001 From: Swann Date: Tue, 7 Apr 2020 09:59:04 +0200 Subject: [PATCH 093/129] refactor: cleanup --- multi_user/bl_types/bl_collection.py | 6 ++---- multi_user/bl_types/bl_lattice.py | 6 +++--- multi_user/bl_types/bl_scene.py | 1 - 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/multi_user/bl_types/bl_collection.py b/multi_user/bl_types/bl_collection.py index 4a4d382..1bf3e17 100644 --- a/multi_user/bl_types/bl_collection.py +++ b/multi_user/bl_types/bl_collection.py @@ -38,12 +38,10 @@ class BlCollection(BlDatablock): name for name in sourceData.collections if name == self.data['name']] instance = bpy.data.collections[self.data['name']] - instance.uuid = self.uuid return instance instance = bpy.data.collections.new(data["name"]) - instance.uuid = self.uuid return instance def _load_implementation(self, data, target): @@ -80,7 +78,7 @@ class BlCollection(BlDatablock): collection_objects = [] for object in pointer.objects: if object not in collection_objects: - collection_objects.append(object.uuid) + collection_objects.append(object.name) data['objects'] = collection_objects @@ -88,7 +86,7 @@ class BlCollection(BlDatablock): collection_children = [] for child in pointer.children: if child not in collection_children: - collection_children.append(child.uuid) + collection_children.append(child.name) data['children'] = collection_children diff --git a/multi_user/bl_types/bl_lattice.py b/multi_user/bl_types/bl_lattice.py index d816a2c..238a2a9 100644 --- a/multi_user/bl_types/bl_lattice.py +++ b/multi_user/bl_types/bl_lattice.py @@ -33,15 +33,15 @@ class BlLattice(BlDatablock): bl_automatic_push = True bl_icon = 'LATTICE_DATA' + def _construct(self, data): + return bpy.data.lattices.new(data["name"]) + def _load_implementation(self, data, target): loader = Loader() loader.load(target, data) np_load_collection(data['points'], target.points, POINT) - def _construct(self, data): - return bpy.data.lattices.new(data["name"]) - def _dump_implementation(self, data, pointer=None): assert(pointer) diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index 08aeffe..3e4b91e 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -34,7 +34,6 @@ class BlScene(BlDatablock): def _construct(self, data): instance = bpy.data.scenes.new(data["name"]) - instance.uuid = self.uuid return instance def _load_implementation(self, data, target): From fc15478dfa19708c2a0f5991dd6e1ecde18be43d Mon Sep 17 00:00:00 2001 From: Swann Date: Tue, 7 Apr 2020 14:49:43 +0200 Subject: [PATCH 094/129] refactor: remove pointer hard reference feat: light test --- .gitignore | 5 +- multi_user/bl_types/bl_action.py | 8 +-- multi_user/bl_types/bl_armature.py | 20 ++++---- multi_user/bl_types/bl_camera.py | 6 +-- multi_user/bl_types/bl_collection.py | 37 ++++++++------ multi_user/bl_types/bl_curve.py | 14 ++--- multi_user/bl_types/bl_datablock.py | 36 ++++++------- multi_user/bl_types/bl_gpencil.py | 11 ++-- multi_user/bl_types/bl_image.py | 8 +-- multi_user/bl_types/bl_io.py | 4 ++ multi_user/bl_types/bl_lattice.py | 8 +-- multi_user/bl_types/bl_library.py | 6 +-- multi_user/bl_types/bl_light.py | 6 +-- multi_user/bl_types/bl_lightprobe.py | 6 +-- multi_user/bl_types/bl_material.py | 22 ++++---- multi_user/bl_types/bl_mesh.py | 10 ++-- multi_user/bl_types/bl_metaball.py | 8 +-- multi_user/bl_types/bl_object.py | 74 +++++++++++++-------------- multi_user/bl_types/bl_scene.py | 34 ++++++------ multi_user/bl_types/bl_speaker.py | 6 +-- multi_user/bl_types/bl_world.py | 18 +++---- multi_user/bl_types/dump_anything.py | 34 ++++++------ multi_user/libs/replication | 2 +- multi_user/operators.py | 16 ------ scripts/test_addon.py | 25 +++++++++ tests/test_bl_types/light.blend | Bin 0 -> 564328 bytes tests/test_bl_types/test_lights.py | 24 +++++++++ 27 files changed, 247 insertions(+), 201 deletions(-) create mode 100644 multi_user/bl_types/bl_io.py create mode 100644 scripts/test_addon.py create mode 100644 tests/test_bl_types/light.blend create mode 100644 tests/test_bl_types/test_lights.py diff --git a/.gitignore b/.gitignore index bed8f4f..23786bf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,7 @@ config multi_user_updater/ # sphinx build folder -_build \ No newline at end of file +_build + +# ignore generated zip generated from blender_addon_tester +*.zip \ No newline at end of file diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py index 1fc8138..8b994e6 100644 --- a/multi_user/bl_types/bl_action.py +++ b/multi_user/bl_types/bl_action.py @@ -154,8 +154,8 @@ class BlAction(BlDatablock): load_fcurve(dumped_fcurve, fcurve) target.id_root = data['id_root'] - def _dump(self, pointer=None): - assert(pointer) + def _dump(self, instance=None): + assert(instance) dumper = Dumper() dumper.exclude_filter = [ 'name_full', @@ -170,11 +170,11 @@ class BlAction(BlDatablock): 'users' ] dumper.depth = 1 - data = dumper.dump(pointer) + data = dumper.dump(instance) data["fcurves"] = [] - for fcurve in self.pointer.fcurves: + for fcurve in self.instance.fcurves: data["fcurves"].append(dump_fcurve(fcurve, use_numpy=True)) return data diff --git a/multi_user/bl_types/bl_armature.py b/multi_user/bl_types/bl_armature.py index 98bef98..1f556ed 100644 --- a/multi_user/bl_types/bl_armature.py +++ b/multi_user/bl_types/bl_armature.py @@ -46,7 +46,7 @@ class BlArmature(BlDatablock): if parent_object is None: parent_object = bpy.data.objects.new( - data['user_name'], self.pointer) + data['user_name'], self.instance) parent_object.uuid = data['user'] is_object_in_master = ( @@ -81,10 +81,10 @@ class BlArmature(BlDatablock): bpy.ops.object.mode_set(mode='EDIT') for bone in data['bones']: - if bone not in self.pointer.edit_bones: - new_bone = self.pointer.edit_bones.new(bone) + if bone not in self.instance.edit_bones: + new_bone = self.instance.edit_bones.new(bone) else: - new_bone = self.pointer.edit_bones[bone] + new_bone = self.instance.edit_bones[bone] bone_data = data['bones'].get(bone) @@ -94,7 +94,7 @@ class BlArmature(BlDatablock): new_bone.head_radius = bone_data['head_radius'] if 'parent' in bone_data: - new_bone.parent = self.pointer.edit_bones[data['bones'] + new_bone.parent = self.instance.edit_bones[data['bones'] [bone]['parent']] new_bone.use_connect = bone_data['use_connect'] @@ -109,8 +109,8 @@ class BlArmature(BlDatablock): if 'EDIT' in current_mode: bpy.ops.object.mode_set(mode='EDIT') - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) dumper = Dumper() dumper.depth = 4 @@ -126,13 +126,13 @@ class BlArmature(BlDatablock): 'layers' ] - data = dumper.dump(pointer) + data = dumper.dump(instance) - for bone in pointer.bones: + for bone in instance.bones: if bone.parent: data['bones'][bone.name]['parent'] = bone.parent.name # get the parent Object - object_users = utils.get_datablock_users(pointer)[0] + object_users = utils.get_datablock_users(instance)[0] data['user'] = object_users.uuid data['user_name'] = object_users.name diff --git a/multi_user/bl_types/bl_camera.py b/multi_user/bl_types/bl_camera.py index 178d578..0ca33e6 100644 --- a/multi_user/bl_types/bl_camera.py +++ b/multi_user/bl_types/bl_camera.py @@ -45,8 +45,8 @@ class BlCamera(BlDatablock): if dof_settings: loader.load(target.dof, dof_settings) - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) # TODO: background image support @@ -80,6 +80,6 @@ class BlCamera(BlDatablock): 'sensor_height', 'sensor_width', ] - return dumper.dump(pointer) + return dumper.dump(instance) diff --git a/multi_user/bl_types/bl_collection.py b/multi_user/bl_types/bl_collection.py index 1bf3e17..312906b 100644 --- a/multi_user/bl_types/bl_collection.py +++ b/multi_user/bl_types/bl_collection.py @@ -48,35 +48,41 @@ class BlCollection(BlDatablock): # Load other meshes metadata target.name = data["name"] - # link objects + # Objects for object in data["objects"]: - object_ref = utils.find_from_attr('uuid', object, bpy.data.objects) - if object_ref and object_ref.name not in target.objects.keys(): + object_ref = bpy.data.objects.get(object) + + if object_ref is None: + continue + + if object not in target.objects.keys(): target.objects.link(object_ref) for object in target.objects: - if object.uuid not in data["objects"]: + if object.name not in data["objects"]: target.objects.unlink(object) # Link childrens for collection in data["children"]: - collection_ref = utils.find_from_attr( - 'uuid', collection, bpy.data.collections) - if collection_ref and collection_ref.name not in target.children.keys(): + collection_ref = bpy.data.collections.get(collection) + + if collection_ref is None: + continue + if collection_ref.name not in target.children.keys(): target.children.link(collection_ref) for collection in target.children: - if collection.uuid not in data["children"]: + if collection.name not in data["children"]: target.children.unlink(collection) - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) data = {} - data['name'] = pointer.name + data['name'] = instance.name # dump objects collection_objects = [] - for object in pointer.objects: + for object in instance.objects: if object not in collection_objects: collection_objects.append(object.name) @@ -84,7 +90,7 @@ class BlCollection(BlDatablock): # dump children collections collection_children = [] - for child in pointer.children: + for child in instance.children: if child not in collection_children: collection_children.append(child.name) @@ -95,10 +101,9 @@ class BlCollection(BlDatablock): def _resolve_deps_implementation(self): deps = [] - for child in self.pointer.children: + for child in self.instance.children: deps.append(child) - for object in self.pointer.objects: + for object in self.instance.objects: deps.append(object) return deps - diff --git a/multi_user/bl_types/bl_curve.py b/multi_user/bl_types/bl_curve.py index b2ec720..caa1754 100644 --- a/multi_user/bl_types/bl_curve.py +++ b/multi_user/bl_types/bl_curve.py @@ -84,14 +84,14 @@ class BlCurve(BlDatablock): # new_spline.points[point_index], data['splines'][spline]["points"][point_index]) loader.load(new_spline, spline) - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) dumper = Dumper() - data = dumper.dump(pointer) + data = dumper.dump(instance) data['splines'] = {} - for index, spline in enumerate(pointer.splines): + for index, spline in enumerate(instance.splines): dumper.depth = 2 spline_data = dumper.dump(spline) # spline_data['points'] = np_dump_collection(spline.points, SPLINE_POINT) @@ -99,10 +99,10 @@ class BlCurve(BlDatablock): spline_data['bezier_points'] = np_dump_collection(spline.bezier_points, SPLINE_BEZIER_POINT) data['splines'][index] = spline_data - if isinstance(pointer, T.SurfaceCurve): + if isinstance(instance, T.SurfaceCurve): data['type'] = 'SURFACE' - elif isinstance(pointer, T.TextCurve): + elif isinstance(instance, T.TextCurve): data['type'] = 'FONT' - elif isinstance(pointer, T.Curve): + elif isinstance(instance, T.Curve): data['type'] = 'CURVE' return data diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index ed58f27..fc8d03f 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -99,54 +99,54 @@ class BlDatablock(ReplicatedDatablock): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - pointer = kwargs.get('pointer', None) + instance = kwargs.get('instance', None) # TODO: use is_library_indirect - self.is_library = (pointer and hasattr(pointer, 'library') and - pointer.library) or \ + self.is_library = (instance and hasattr(instance, 'library') and + instance.library) or \ (self.data and 'library' in self.data) - if self.pointer and hasattr(self.pointer, 'uuid'): - self.pointer.uuid = self.uuid + if instance and hasattr(instance, 'uuid'): + instance.uuid = self.uuid self.diff_method = DIFF_BINARY - def _resolve(self): + @property + def instance(self): datablock_ref = None datablock_root = getattr(bpy.data, self.bl_id) datablock_ref = utils.find_from_attr('uuid', self.uuid, datablock_root) # In case of lost uuid (ex: undo), resolve by name and reassign it - # TODO: avoid reference storing if not datablock_ref: - datablock_ref = getattr(bpy.data, self.bl_id).get(self.data['name']) + datablock_ref = datablock_root.get(self.data['name']) if datablock_ref: setattr(datablock_ref, 'uuid', self.uuid) - self.pointer = datablock_ref + return datablock_ref - def _dump(self, pointer=None): + def _dump(self, instance=None): dumper = Dumper() data = {} # Dump animation data - if has_action(pointer): + if has_action(instance): dumper = Dumper() dumper.include_filter = ['action'] - data['animation_data'] = dumper.dump(pointer.animation_data) + data['animation_data'] = dumper.dump(instance.animation_data) - if has_driver(pointer): + if has_driver(instance): dumped_drivers = {'animation_data': {'drivers': []}} - for driver in pointer.animation_data.drivers: + for driver in instance.animation_data.drivers: dumped_drivers['animation_data']['drivers'].append( dump_driver(driver)) data.update(dumped_drivers) if self.is_library: - data.update(dumper.dump(pointer)) + data.update(dumper.dump(instance)) else: - data.update(self._dump_implementation(data, pointer=pointer)) + data.update(self._dump_implementation(data, instance=instance)) return data @@ -180,8 +180,8 @@ class BlDatablock(ReplicatedDatablock): def resolve_deps(self): dependencies = [] - if has_action(self.pointer): - dependencies.append(self.pointer.animation_data.action) + if has_action(self.instance): + dependencies.append(self.instance.animation_data.action) if not self.is_library: dependencies.extend(self._resolve_deps_implementation()) diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index e3156ed..126873a 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -95,6 +95,7 @@ def load_stroke(stroke_data, stroke): np_load_collection(stroke_data['points'], stroke.points, STROKE_POINT) + def dump_frame(frame): """ Dump a grease pencil frame to a dict @@ -251,8 +252,8 @@ class BlGpencil(BlDatablock): - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) dumper = Dumper() dumper.depth = 2 dumper.include_filter = [ @@ -263,11 +264,11 @@ class BlGpencil(BlDatablock): 'pixel_factor', 'stroke_depth_order' ] - data = dumper.dump(pointer) + data = dumper.dump(instance) data['layers'] = {} - for layer in pointer.layers: + for layer in instance.layers: data['layers'][layer.info] = dump_layer(layer) return data @@ -275,7 +276,7 @@ class BlGpencil(BlDatablock): def _resolve_deps_implementation(self): deps = [] - for material in self.pointer.materials: + for material in self.instance.materials: deps.append(material) return deps diff --git a/multi_user/bl_types/bl_image.py b/multi_user/bl_types/bl_image.py index 4a9ce9c..df62602 100644 --- a/multi_user/bl_types/bl_image.py +++ b/multi_user/bl_types/bl_image.py @@ -80,10 +80,10 @@ class BlImage(BlDatablock): image.colorspace_settings.name = data["colorspace_settings"]["name"] - def _dump(self, pointer=None): - assert(pointer) + def _dump(self, instance=None): + assert(instance) data = {} - data['pixels'] = dump_image(pointer) + data['pixels'] = dump_image(instance) dumper = Dumper() dumper.depth = 2 dumper.include_filter = [ @@ -95,7 +95,7 @@ class BlImage(BlDatablock): 'filepath', 'source', 'colorspace_settings'] - data.update(dumper.dump(pointer)) + data.update(dumper.dump(instance)) return data diff --git a/multi_user/bl_types/bl_io.py b/multi_user/bl_types/bl_io.py new file mode 100644 index 0000000..65bd139 --- /dev/null +++ b/multi_user/bl_types/bl_io.py @@ -0,0 +1,4 @@ +from . import * + +def dump(datablock): + pass \ No newline at end of file diff --git a/multi_user/bl_types/bl_lattice.py b/multi_user/bl_types/bl_lattice.py index 238a2a9..950cf7e 100644 --- a/multi_user/bl_types/bl_lattice.py +++ b/multi_user/bl_types/bl_lattice.py @@ -42,8 +42,8 @@ class BlLattice(BlDatablock): np_load_collection(data['points'], target.points, POINT) - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) dumper = Dumper() dumper.depth = 1 @@ -58,8 +58,8 @@ class BlLattice(BlDatablock): 'interpolation_type_w', 'use_outside' ] - data = dumper.dump(pointer) + data = dumper.dump(instance) - data['points'] = np_dump_collection(pointer.points, POINT) + data['points'] = np_dump_collection(instance.points, POINT) return data diff --git a/multi_user/bl_types/bl_library.py b/multi_user/bl_types/bl_library.py index f5b1753..e2f1adc 100644 --- a/multi_user/bl_types/bl_library.py +++ b/multi_user/bl_types/bl_library.py @@ -38,9 +38,9 @@ class BlLibrary(BlDatablock): def _load(self, data, target): pass - def dump(self, pointer=None): - assert(pointer) + def _dump(self, instance=None): + assert(instance) dumper = Dumper() - return dumper.dump(pointer) + return dumper.dump(instance) diff --git a/multi_user/bl_types/bl_light.py b/multi_user/bl_types/bl_light.py index 891bb8f..4f8c933 100644 --- a/multi_user/bl_types/bl_light.py +++ b/multi_user/bl_types/bl_light.py @@ -38,8 +38,8 @@ class BlLight(BlDatablock): loader = Loader() loader.load(target, data) - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) dumper = Dumper() dumper.depth = 3 dumper.include_filter = [ @@ -66,7 +66,7 @@ class BlLight(BlDatablock): "size", "angle" ] - data = dumper.dump(pointer) + data = dumper.dump(instance) return data diff --git a/multi_user/bl_types/bl_lightprobe.py b/multi_user/bl_types/bl_lightprobe.py index 7da2b7e..d1ae0b9 100644 --- a/multi_user/bl_types/bl_lightprobe.py +++ b/multi_user/bl_types/bl_lightprobe.py @@ -45,8 +45,8 @@ class BlLightprobe(BlDatablock): loader = Loader() loader.load(target, data) - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) if bpy.app.version[1] < 83: logger.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") @@ -73,7 +73,7 @@ class BlLightprobe(BlDatablock): 'visibility_blur' ] - return dumper.dump(pointer) + return dumper.dump(instance) diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index f230ef1..9250371 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -195,8 +195,8 @@ class BlMaterial(BlDatablock): load_links(data["node_tree"]["links"], target.node_tree) - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) mat_dumper = Dumper() mat_dumper.depth = 2 mat_dumper.exclude_filter = [ @@ -215,17 +215,17 @@ class BlMaterial(BlDatablock): "line_color", "view_center", ] - data = mat_dumper.dump(pointer) + data = mat_dumper.dump(instance) - if pointer.use_nodes: + if instance.use_nodes: nodes = {} - for node in pointer.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(pointer.node_tree.links) + data["node_tree"]["links"] = dump_links(instance.node_tree.links) - if pointer.is_grease_pencil: + if instance.is_grease_pencil: gp_mat_dumper = Dumper() gp_mat_dumper.depth = 3 @@ -251,19 +251,19 @@ class BlMaterial(BlDatablock): 'mix_color', 'flip' ] - data['grease_pencil'] = gp_mat_dumper.dump(pointer.grease_pencil) + data['grease_pencil'] = gp_mat_dumper.dump(instance.grease_pencil) return data def _resolve_deps_implementation(self): # TODO: resolve node group deps deps = [] - if self.pointer.use_nodes: - for node in self.pointer.node_tree.nodes: + if self.instance.use_nodes: + for node in self.instance.node_tree.nodes: if node.type == 'TEX_IMAGE': deps.append(node.image) if self.is_library: - deps.append(self.pointer.library) + deps.append(self.instance.library) return deps diff --git a/multi_user/bl_types/bl_mesh.py b/multi_user/bl_types/bl_mesh.py index bd8d618..4992655 100644 --- a/multi_user/bl_types/bl_mesh.py +++ b/multi_user/bl_types/bl_mesh.py @@ -109,10 +109,10 @@ class BlMesh(BlDatablock): target.validate() target.update() - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) - mesh = pointer + mesh = instance dumper = Dumper() dumper.depth = 1 @@ -156,7 +156,7 @@ class BlMesh(BlDatablock): # Fix material index m_list = [] - for material in pointer.materials: + for material in instance.materials: if material: m_list.append(material.name) @@ -167,7 +167,7 @@ class BlMesh(BlDatablock): def _resolve_deps_implementation(self): deps = [] - for material in self.pointer.materials: + for material in self.instance.materials: if material: deps.append(material) diff --git a/multi_user/bl_types/bl_metaball.py b/multi_user/bl_types/bl_metaball.py index 22eeea5..32d3f01 100644 --- a/multi_user/bl_types/bl_metaball.py +++ b/multi_user/bl_types/bl_metaball.py @@ -84,8 +84,8 @@ class BlMetaball(BlDatablock): load_metaball_elements(data['elements'], target.elements) - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) dumper = Dumper() dumper.depth = 1 dumper.exclude_filter = [ @@ -96,7 +96,7 @@ class BlMetaball(BlDatablock): "name_full" ] - data = dumper.dump(pointer) - data['elements'] = dump_metaball_elements(pointer.elements) + data = dumper.dump(instance) + data['elements'] = dump_metaball_elements(instance.elements) return data diff --git a/multi_user/bl_types/bl_object.py b/multi_user/bl_types/bl_object.py index 9e45ed7..5cf226b 100644 --- a/multi_user/bl_types/bl_object.py +++ b/multi_user/bl_types/bl_object.py @@ -41,7 +41,7 @@ class BlObject(BlDatablock): bl_icon = 'OBJECT_DATA' def _construct(self, data): - pointer = None + instance = None if self.is_library: with bpy.data.libraries.load(filepath=bpy.data.libraries[self.data['library']].filepath, link=True) as (sourceData, targetData): @@ -56,33 +56,33 @@ class BlObject(BlDatablock): if "data" not in data: pass elif data["data"] in bpy.data.meshes.keys(): - pointer = bpy.data.meshes[data["data"]] + instance = bpy.data.meshes[data["data"]] elif data["data"] in bpy.data.lights.keys(): - pointer = bpy.data.lights[data["data"]] + instance = bpy.data.lights[data["data"]] elif data["data"] in bpy.data.cameras.keys(): - pointer = bpy.data.cameras[data["data"]] + instance = bpy.data.cameras[data["data"]] elif data["data"] in bpy.data.curves.keys(): - pointer = bpy.data.curves[data["data"]] + instance = bpy.data.curves[data["data"]] elif data["data"] in bpy.data.metaballs.keys(): - pointer = bpy.data.metaballs[data["data"]] + instance = bpy.data.metaballs[data["data"]] elif data["data"] in bpy.data.armatures.keys(): - pointer = bpy.data.armatures[data["data"]] + instance = bpy.data.armatures[data["data"]] elif data["data"] in bpy.data.grease_pencils.keys(): - pointer = bpy.data.grease_pencils[data["data"]] + instance = bpy.data.grease_pencils[data["data"]] elif data["data"] in bpy.data.curves.keys(): - pointer = bpy.data.curves[data["data"]] + instance = bpy.data.curves[data["data"]] elif data["data"] in bpy.data.lattices.keys(): - pointer = bpy.data.lattices[data["data"]] + instance = bpy.data.lattices[data["data"]] elif data["data"] in bpy.data.speakers.keys(): - pointer = bpy.data.speakers[data["data"]] + instance = bpy.data.speakers[data["data"]] elif data["data"] in bpy.data.lightprobes.keys(): # Only supported since 2.83 if bpy.app.version[1] >= 83: - pointer = bpy.data.lightprobes[data["data"]] + instance = bpy.data.lightprobes[data["data"]] else: logger.warning( "Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") - instance = bpy.data.objects.new(data["name"], pointer) + instance = bpy.data.objects.new(data["name"], instance) instance.uuid = self.uuid return instance @@ -152,8 +152,8 @@ class BlObject(BlDatablock): target.data.shape_keys.key_blocks[key_block].relative_key = target.data.shape_keys.key_blocks[reference] - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) dumper = Dumper() dumper.depth = 1 dumper.include_filter = [ @@ -169,33 +169,33 @@ class BlObject(BlDatablock): "instance_type", "location", "scale", - 'rotation_quaternion' if pointer.rotation_mode == 'QUATERNION' else 'rotation_euler', + 'rotation_quaternion' if instance.rotation_mode == 'QUATERNION' else 'rotation_euler', ] - data = dumper.dump(pointer) + data = dumper.dump(instance) if self.is_library: return data # MODIFIERS - if hasattr(pointer, 'modifiers'): + if hasattr(instance, 'modifiers'): dumper.include_filter = None dumper.depth = 2 data["modifiers"] = {} - for index, modifier in enumerate(pointer.modifiers): + for index, modifier in enumerate(instance.modifiers): data["modifiers"][modifier.name] = dumper.dump(modifier) # CONSTRAINTS # OBJECT - if hasattr(pointer, 'constraints'): + if hasattr(instance, 'constraints'): dumper.depth = 3 - data["constraints"] = dumper.dump(pointer.constraints) + data["constraints"] = dumper.dump(instance.constraints) # POSE - if hasattr(pointer, 'pose') and pointer.pose: + if hasattr(instance, 'pose') and instance.pose: # BONES bones = {} - for bone in pointer.pose.bones: + for bone in instance.pose.bones: bones[bone.name] = {} dumper.depth = 1 rotation = 'rotation_quaternion' if bone.rotation_mode == 'QUATERNION' else 'rotation_euler' @@ -220,7 +220,7 @@ class BlObject(BlDatablock): # GROUPS bone_groups = {} - for group in pointer.pose.bone_groups: + for group in instance.pose.bone_groups: dumper.depth = 3 dumper.include_filter = [ 'name', @@ -230,24 +230,24 @@ class BlObject(BlDatablock): data['pose']['bone_groups'] = bone_groups # CHILDS - if len(pointer.children) > 0: + if len(instance.children) > 0: childs = [] - for child in pointer.children: + for child in instance.children: childs.append(child.name) data["children"] = childs # VERTEx GROUP - if len(pointer.vertex_groups) > 0: + if len(instance.vertex_groups) > 0: vg_data = [] - for vg in pointer.vertex_groups: + for vg in instance.vertex_groups: vg_idx = vg.index dumped_vg = {} dumped_vg['name'] = vg.name vertices = [] - for v in pointer.data.vertices: + for v in instance.data.vertices: for vg in v.groups: if vg.group == vg_idx: vertices.append({ @@ -262,7 +262,7 @@ class BlObject(BlDatablock): data['vertex_groups'] = vg_data # SHAPE KEYS - object_data = pointer.data + object_data = instance.data if hasattr(object_data, 'shape_keys') and object_data.shape_keys: dumper = Dumper() dumper.depth = 2 @@ -295,17 +295,17 @@ class BlObject(BlDatablock): deps = [] # Avoid Empty case - if self.pointer.data: - deps.append(self.pointer.data) - if len(self.pointer.children) > 0: - deps.extend(list(self.pointer.children)) + if self.instance.data: + deps.append(self.instance.data) + if len(self.instance.children) > 0: + deps.extend(list(self.instance.children)) if self.is_library: - deps.append(self.pointer.library) + deps.append(self.instance.library) - if self.pointer.instance_type == 'COLLECTION': + if self.instance.instance_type == 'COLLECTION': # TODO: uuid based - deps.append(self.pointer.instance_collection) + deps.append(self.instance.instance_collection) return deps diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index 3e4b91e..a563af3 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -37,7 +37,7 @@ class BlScene(BlDatablock): return instance def _load_implementation(self, data, target): - target = self.pointer + target = self.instance # Load other meshes metadata loader = Loader() loader.load(target, data) @@ -83,8 +83,8 @@ class BlScene(BlDatablock): target.view_settings.curve_mapping.black_level = data['view_settings']['curve_mapping']['black_level'] target.view_settings.curve_mapping.update() - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) data = {} scene_dumper = Dumper() @@ -96,12 +96,12 @@ class BlScene(BlDatablock): 'camera', 'grease_pencil', ] - data = scene_dumper.dump(pointer) + data = scene_dumper.dump(instance) scene_dumper.depth = 3 scene_dumper.include_filter = ['children','objects','name'] - data['collection'] = scene_dumper.dump(pointer.collection) + data['collection'] = scene_dumper.dump(instance.collection) scene_dumper.depth = 1 scene_dumper.include_filter = None @@ -124,19 +124,19 @@ class BlScene(BlDatablock): 'samples', 'volume_bounces' ] - data['eevee'] = scene_dumper.dump(pointer.eevee) - data['cycles'] = scene_dumper.dump(pointer.cycles) - data['view_settings'] = scene_dumper.dump(pointer.view_settings) - data['view_settings']['curve_mapping'] = scene_dumper.dump(pointer.view_settings.curve_mapping) + data['eevee'] = scene_dumper.dump(instance.eevee) + data['cycles'] = scene_dumper.dump(instance.cycles) + data['view_settings'] = scene_dumper.dump(instance.view_settings) + data['view_settings']['curve_mapping'] = scene_dumper.dump(instance.view_settings.curve_mapping) - if pointer.view_settings.use_curve_mapping: + if instance.view_settings.use_curve_mapping: scene_dumper.depth = 5 scene_dumper.include_filter = [ 'curves', 'points', 'location' ] - data['view_settings']['curve_mapping']['curves'] = scene_dumper.dump(pointer.view_settings.curve_mapping.curves) + data['view_settings']['curve_mapping']['curves'] = scene_dumper.dump(instance.view_settings.curve_mapping.curves) return data @@ -145,19 +145,19 @@ class BlScene(BlDatablock): deps = [] # child collections - for child in self.pointer.collection.children: + for child in self.instance.collection.children: deps.append(child) # childs objects - for object in self.pointer.objects: + for object in self.instance.objects: deps.append(object) # world - if self.pointer.world: - deps.append(self.pointer.world) + if self.instance.world: + deps.append(self.instance.world) # annotations - if self.pointer.grease_pencil: - deps.append(self.pointer.grease_pencil) + if self.instance.grease_pencil: + deps.append(self.instance.grease_pencil) return deps diff --git a/multi_user/bl_types/bl_speaker.py b/multi_user/bl_types/bl_speaker.py index 3d419f9..5276279 100644 --- a/multi_user/bl_types/bl_speaker.py +++ b/multi_user/bl_types/bl_speaker.py @@ -38,8 +38,8 @@ class BlSpeaker(BlDatablock): def _construct(self, data): return bpy.data.speakers.new(data["name"]) - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) dumper = Dumper() dumper.depth = 1 @@ -58,7 +58,7 @@ class BlSpeaker(BlDatablock): 'cone_volume_outer' ] - return dumper.dump(pointer) + return dumper.dump(instance) diff --git a/multi_user/bl_types/bl_world.py b/multi_user/bl_types/bl_world.py index 7fffb53..a9b8feb 100644 --- a/multi_user/bl_types/bl_world.py +++ b/multi_user/bl_types/bl_world.py @@ -51,8 +51,8 @@ class BlWorld(BlDatablock): load_links(data["node_tree"]["links"], target.node_tree) - def _dump_implementation(self, data, pointer=None): - assert(pointer) + def _dump_implementation(self, data, instance=None): + assert(instance) world_dumper = Dumper() world_dumper.depth = 2 @@ -66,27 +66,27 @@ class BlWorld(BlDatablock): "users", "view_center" ] - data = world_dumper.dump(pointer) - if pointer.use_nodes: + data = world_dumper.dump(instance) + if instance.use_nodes: nodes = {} - for node in pointer.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(pointer.node_tree.links) + data["node_tree"]['links'] = dump_links(instance.node_tree.links) return data def _resolve_deps_implementation(self): deps = [] - if self.pointer.use_nodes: - for node in self.pointer.node_tree.nodes: + if self.instance.use_nodes: + for node in self.instance.node_tree.nodes: if node.type == 'TEX_IMAGE': deps.append(node.image) if self.is_library: - deps.append(self.pointer.library) + deps.append(self.instance.library) return deps diff --git a/multi_user/bl_types/dump_anything.py b/multi_user/bl_types/dump_anything.py index e97fa63..93b3772 100644 --- a/multi_user/bl_types/dump_anything.py +++ b/multi_user/bl_types/dump_anything.py @@ -192,7 +192,7 @@ def np_load_collection_primitives(collection: bpy.types.CollectionProperty, attr :arg sequence: data buffer :type sequence: strr """ - if len(collection) == 0: + if len(collection) == 0 or not sequence: logger.warning(f"Skipping loadin {attribute}") return @@ -503,7 +503,7 @@ class Loader: DESTRUCTOR_REMOVE = "remove" DESTRUCTOR_CLEAR = "clear" - constructors = { + _constructors = { T.ColorRampElement: (CONSTRUCTOR_NEW, ["position"]), T.ParticleSettingsTextureSlot: (CONSTRUCTOR_ADD, []), T.Modifier: (CONSTRUCTOR_NEW, ["name", "type"]), @@ -517,9 +517,9 @@ class Loader: } element_type = element.bl_rna_property.fixed_type - constructor = constructors.get(type(element_type)) + _constructor = _constructors.get(type(element_type)) - if constructor is None: # collection type not supported + if _constructor is None: # collection type not supported return destructor = destructors.get(type(element_type)) @@ -538,14 +538,14 @@ class Loader: new_element = element.read()[0] else: try: - constructor_parameters = [dumped_element[name] - for name in constructor[1]] + _constructor_parameters = [dumped_element[name] + for name in _constructor[1]] except KeyError: logger.debug("Collection load error, missing parameters.") continue # TODO handle error - new_element = getattr(element.read(), constructor[0])( - *constructor_parameters) + new_element = getattr(element.read(), _constructor[0])( + *_constructor_parameters) self._load_any( BlenderAPIElement( new_element, occlude_read_only=self.occlude_read_only), @@ -576,24 +576,24 @@ class Loader: else: dst_curve.points.new(pos[0], pos[1]) - def _load_pointer(self, pointer, dump): - rna_property_type = pointer.bl_rna_property.fixed_type + def _load_pointer(self, instance, dump): + rna_property_type = instance.bl_rna_property.fixed_type if not rna_property_type: return if isinstance(rna_property_type, T.Image): - pointer.write(bpy.data.images.get(dump)) + instance.write(bpy.data.images.get(dump)) elif isinstance(rna_property_type, T.Texture): - pointer.write(bpy.data.textures.get(dump)) + instance.write(bpy.data.textures.get(dump)) elif isinstance(rna_property_type, T.ColorRamp): - self._load_default(pointer, dump) + self._load_default(instance, dump) elif isinstance(rna_property_type, T.Object): - pointer.write(bpy.data.objects.get(dump)) + instance.write(bpy.data.objects.get(dump)) elif isinstance(rna_property_type, T.Mesh): - pointer.write(bpy.data.meshes.get(dump)) + instance.write(bpy.data.meshes.get(dump)) elif isinstance(rna_property_type, T.Material): - pointer.write(bpy.data.materials.get(dump)) + instance.write(bpy.data.materials.get(dump)) elif isinstance(rna_property_type, T.Collection): - pointer.write(bpy.data.collections.get(dump)) + instance.write(bpy.data.collections.get(dump)) def _load_matrix(self, matrix, dump): matrix.write(mathutils.Matrix(dump)) diff --git a/multi_user/libs/replication b/multi_user/libs/replication index cc66d1d..c5a3997 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit cc66d1d8e4b4a0351faeb6deeacf69693dcafcab +Subproject commit c5a39975590c99b9728e6364d9b16b72bb6765ca diff --git a/multi_user/operators.py b/multi_user/operators.py index 4c7560c..2b69b86 100644 --- a/multi_user/operators.py +++ b/multi_user/operators.py @@ -507,19 +507,7 @@ def load_pre_handler(dummy): bpy.ops.session.stop() -@persistent -def sanitize_deps_graph(dummy): - """sanitize deps graph - Temporary solution to resolve each node pointers after a Undo. - A future solution should be to avoid storing dataclock reference... - - """ - global client - - if client and client.state['STATE'] in [STATE_ACTIVE]: - for node_key in client.list(): - client.get(node_key)._resolve() @persistent @@ -571,8 +559,6 @@ def register(): bpy.app.handlers.load_pre.append(load_pre_handler) - bpy.app.handlers.undo_post.append(sanitize_deps_graph) - bpy.app.handlers.redo_post.append(sanitize_deps_graph) bpy.app.handlers.frame_change_pre.append(update_client_frame) @@ -592,8 +578,6 @@ def unregister(): bpy.app.handlers.load_pre.remove(load_pre_handler) - bpy.app.handlers.undo_post.remove(sanitize_deps_graph) - bpy.app.handlers.redo_post.remove(sanitize_deps_graph) bpy.app.handlers.frame_change_pre.remove(update_client_frame) diff --git a/scripts/test_addon.py b/scripts/test_addon.py new file mode 100644 index 0000000..6008406 --- /dev/null +++ b/scripts/test_addon.py @@ -0,0 +1,25 @@ +import sys +try: + import blender_addon_tester as BAT +except Exception as e: + print(e) + sys.exit(1) + +def main(): + if len(sys.argv) > 1: + addon = sys.argv[1] + else: + addon = "multi_user" + if len(sys.argv) > 2: + blender_rev = sys.argv[2] + else: + blender_rev = "2.82" + + try: + exit_val = BAT.test_blender_addon(addon_path=addon, blender_revision=blender_rev) + except Exception as e: + print(e) + exit_val = 1 + sys.exit(exit_val) + +main() \ No newline at end of file diff --git a/tests/test_bl_types/light.blend b/tests/test_bl_types/light.blend new file mode 100644 index 0000000000000000000000000000000000000000..479bfe48fd3cdf6ff5c3f1a09e7f500ea9b6867a GIT binary patch literal 564328 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 literal 0 HcmV?d00001 diff --git a/tests/test_bl_types/test_lights.py b/tests/test_bl_types/test_lights.py new file mode 100644 index 0000000..c47ffe8 --- /dev/null +++ b/tests/test_bl_types/test_lights.py @@ -0,0 +1,24 @@ +import pytest +import os +# from deepdiff import DeepDiff + +from multi_user.bl_types.bl_light import BlLight +import bpy + +@pytest.fixture +def load_test_file(): + dir_path = os.path.dirname(os.path.realpath(__file__)) + bpy.ops.wm.open_mainfile(filepath=os.path.join(dir_path,"light.blend")) + + +def test_light(load_test_file): + from jsondiff import diff + blender_light = bpy.data.lights['point'] + light_dumper = BlLight(owner='None') + sample_data = light_dumper._dump(blender_light) + bpy.data.lights.remove(blender_light) + + test = light_dumper._construct(sample_data) + light_dumper._load(sample_data, test) + sample_2 = light_dumper._dump(test) + assert diff(sample_data,test), False From 673a9dd66983e79d8152a6bb4350ffb956c8efbe Mon Sep 17 00:00:00 2001 From: Swann Date: Tue, 7 Apr 2020 18:19:17 +0200 Subject: [PATCH 095/129] feat: more tests --- .gitlab-ci.yml | 1 + .gitlab/ci/test.gitlab-ci.yml | 9 +++++++ multi_user/__init__.py | 3 ++- multi_user/bl_types/bl_action.py | 7 +++-- multi_user/bl_types/bl_gpencil.py | 2 +- multi_user/bl_types/bl_io.py | 4 --- tests/test_bl_types/conftest.py | 25 ++++++++++++++++++ tests/test_bl_types/light.blend | Bin 564328 -> 0 bytes tests/test_bl_types/test_action.py | 38 +++++++++++++++++++++++++++ tests/test_bl_types/test_camera.py | 25 ++++++++++++++++++ tests/test_bl_types/test_gpencil.py | 23 ++++++++++++++++ tests/test_bl_types/test_lights.py | 33 +++++++++++------------ tests/test_bl_types/test_material.py | 28 ++++++++++++++++++++ 13 files changed, 171 insertions(+), 27 deletions(-) create mode 100644 .gitlab/ci/test.gitlab-ci.yml delete mode 100644 multi_user/bl_types/bl_io.py create mode 100644 tests/test_bl_types/conftest.py delete mode 100644 tests/test_bl_types/light.blend create mode 100644 tests/test_bl_types/test_action.py create mode 100644 tests/test_bl_types/test_camera.py create mode 100644 tests/test_bl_types/test_gpencil.py create mode 100644 tests/test_bl_types/test_material.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8960bc8..8be3c30 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,2 +1,3 @@ include: + - local: .gitlab/ci/test.gitlab-ci.yml - local: .gitlab/ci/build.gitlab-ci.yml diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml new file mode 100644 index 0000000..239cfe6 --- /dev/null +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -0,0 +1,9 @@ +image: python:latest + +test: + script: + - git submodule init + - git submodule update + - pip install pytest, blender-addon-tester + - python scripts\test_addon.py multi_user $BLENDER_VERSIONS + diff --git a/multi_user/__init__.py b/multi_user/__init__.py index e328298..e8fbcbe 100644 --- a/multi_user/__init__.py +++ b/multi_user/__init__.py @@ -46,7 +46,8 @@ from . import environment, utils # TODO: remove dependency as soon as replication will be installed as a module DEPENDENCIES = { ("zmq","zmq"), - ("jsondiff","jsondiff") + ("jsondiff","jsondiff"), + ("deepdiff", "deepdiff") } diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py index 8b994e6..34a81c1 100644 --- a/multi_user/bl_types/bl_action.py +++ b/multi_user/bl_types/bl_action.py @@ -139,7 +139,7 @@ class BlAction(BlDatablock): def _construct(self, data): return bpy.data.actions.new(data["name"]) - def _load(self, data, target): + def _load_implementation(self, data, target): for dumped_fcurve in data["fcurves"]: dumped_data_path = dumped_fcurve["data_path"] dumped_array_index = dumped_fcurve["dumped_array_index"] @@ -154,8 +154,7 @@ class BlAction(BlDatablock): load_fcurve(dumped_fcurve, fcurve) target.id_root = data['id_root'] - def _dump(self, instance=None): - assert(instance) + def _dump_implementation(self, data, instance=None): dumper = Dumper() dumper.exclude_filter = [ 'name_full', @@ -174,7 +173,7 @@ class BlAction(BlDatablock): data["fcurves"] = [] - for fcurve in self.instance.fcurves: + for fcurve in instance.fcurves: data["fcurves"].append(dump_fcurve(fcurve, use_numpy=True)) return data diff --git a/multi_user/bl_types/bl_gpencil.py b/multi_user/bl_types/bl_gpencil.py index 126873a..a52c81a 100644 --- a/multi_user/bl_types/bl_gpencil.py +++ b/multi_user/bl_types/bl_gpencil.py @@ -153,7 +153,7 @@ def dump_layer(layer): 'opacity', 'channel_color', 'color', - 'thickness', + # 'thickness', #TODO: enabling only for annotation 'tint_color', 'tint_factor', 'vertex_paint_opacity', diff --git a/multi_user/bl_types/bl_io.py b/multi_user/bl_types/bl_io.py deleted file mode 100644 index 65bd139..0000000 --- a/multi_user/bl_types/bl_io.py +++ /dev/null @@ -1,4 +0,0 @@ -from . import * - -def dump(datablock): - pass \ No newline at end of file diff --git a/tests/test_bl_types/conftest.py b/tests/test_bl_types/conftest.py new file mode 100644 index 0000000..a508896 --- /dev/null +++ b/tests/test_bl_types/conftest.py @@ -0,0 +1,25 @@ +import os + +import pytest + +import bpy + + +@pytest.fixture +def clear_blend(): + """ Remove all datablocks of a blend + """ + for type_name in dir(bpy.data): + try: + type_collection = getattr(bpy.data, type_name) + for item in type_collection: + type_collection.remove(item) + except Exception: + continue + + +@pytest.fixture +def load_blendfile(blendname): + print(f"loading {blendname}") + dir_path = os.path.dirname(os.path.realpath(__file__)) + bpy.ops.wm.open_mainfile(filepath=os.path.join(dir_path, blendname)) diff --git a/tests/test_bl_types/light.blend b/tests/test_bl_types/light.blend deleted file mode 100644 index 479bfe48fd3cdf6ff5c3f1a09e7f500ea9b6867a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 564328 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 diff --git a/tests/test_bl_types/test_action.py b/tests/test_bl_types/test_action.py new file mode 100644 index 0000000..3659777 --- /dev/null +++ b/tests/test_bl_types/test_action.py @@ -0,0 +1,38 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +import random +from multi_user.bl_types.bl_action import BlAction + +INTERPOLATION = ['CONSTANT', 'LINEAR', 'BEZIER', 'SINE', 'QUAD', 'CUBIC', 'QUART', 'QUINT', 'EXPO', 'CIRC', 'BACK', 'BOUNCE', 'ELASTIC'] + +# @pytest.mark.parametrize('blendname', ['test_action.blend']) +def test_action(clear_blend): + # Generate a random action + datablock = bpy.data.actions.new("sdsad") + fcurve_sample = datablock.fcurves.new('location') + fcurve_sample.keyframe_points.add(100) + datablock.id_root = 'MESH' + + for i, point in enumerate(fcurve_sample.keyframe_points): + point.co[0] = i + point.co[1] = random.randint(-10,10) + point.interpolation = INTERPOLATION[random.randint(0, len(INTERPOLATION)-1)] + + bpy.ops.mesh.primitive_plane_add() + bpy.data.objects[0].animation_data_create() + bpy.data.objects[0].animation_data.action = datablock + + # Test + implementation = BlAction() + expected = implementation._dump(datablock) + bpy.data.actions.remove(datablock) + + test = implementation._construct(expected) + implementation._load(expected, test) + result = implementation._dump(test) + + assert not DeepDiff(expected, result) diff --git a/tests/test_bl_types/test_camera.py b/tests/test_bl_types/test_camera.py new file mode 100644 index 0000000..c863293 --- /dev/null +++ b/tests/test_bl_types/test_camera.py @@ -0,0 +1,25 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +from multi_user.bl_types.bl_camera import BlCamera + + +@pytest.mark.parametrize('camera_type', ['PANO','PERSP','ORTHO']) +def test_camera(clear_blend, camera_type): + bpy.ops.object.camera_add() + + datablock = bpy.data.cameras[0] + datablock.type = camera_type + + camera_dumper = BlCamera() + expected = camera_dumper._dump(datablock) + bpy.data.cameras.remove(datablock) + + test = camera_dumper._construct(expected) + camera_dumper._load(expected, test) + result = camera_dumper._dump(test) + + assert not DeepDiff(expected, result) diff --git a/tests/test_bl_types/test_gpencil.py b/tests/test_bl_types/test_gpencil.py new file mode 100644 index 0000000..9e29bbe --- /dev/null +++ b/tests/test_bl_types/test_gpencil.py @@ -0,0 +1,23 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +from multi_user.bl_types.bl_gpencil import BlGpencil + + +def test_gpencil(clear_blend): + bpy.ops.object.gpencil_add(type='MONKEY') + + datablock = bpy.data.grease_pencils[0] + + implementation = BlGpencil() + expected = implementation._dump(datablock) + bpy.data.grease_pencils.remove(datablock) + + test = implementation._construct(expected) + implementation._load(expected, test) + result = implementation._dump(test) + + assert not DeepDiff(expected, result) diff --git a/tests/test_bl_types/test_lights.py b/tests/test_bl_types/test_lights.py index c47ffe8..ef88327 100644 --- a/tests/test_bl_types/test_lights.py +++ b/tests/test_bl_types/test_lights.py @@ -1,24 +1,23 @@ -import pytest import os -# from deepdiff import DeepDiff -from multi_user.bl_types.bl_light import BlLight +import pytest +from deepdiff import DeepDiff + import bpy - -@pytest.fixture -def load_test_file(): - dir_path = os.path.dirname(os.path.realpath(__file__)) - bpy.ops.wm.open_mainfile(filepath=os.path.join(dir_path,"light.blend")) +from multi_user.bl_types.bl_light import BlLight -def test_light(load_test_file): - from jsondiff import diff - blender_light = bpy.data.lights['point'] - light_dumper = BlLight(owner='None') - sample_data = light_dumper._dump(blender_light) +@pytest.mark.parametrize('light_type', ['SPOT','SUN','POINT','AREA']) +def test_light(clear_blend, light_type): + bpy.ops.object.light_add(type=light_type) + + blender_light = bpy.data.lights[0] + light_dumper = BlLight() + expected = light_dumper._dump(blender_light) bpy.data.lights.remove(blender_light) - test = light_dumper._construct(sample_data) - light_dumper._load(sample_data, test) - sample_2 = light_dumper._dump(test) - assert diff(sample_data,test), False + test = light_dumper._construct(expected) + light_dumper._load(expected, test) + result = light_dumper._dump(test) + + assert not DeepDiff(expected, result) diff --git a/tests/test_bl_types/test_material.py b/tests/test_bl_types/test_material.py new file mode 100644 index 0000000..910d4bc --- /dev/null +++ b/tests/test_bl_types/test_material.py @@ -0,0 +1,28 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +from multi_user.bl_types.bl_material import BlMaterial + + +def test_material(clear_blend): + nodes_types = [node.bl_rna.identifier for node in bpy.types.ShaderNode.__subclasses__()] + + datablock = bpy.data.materials.new("test") + datablock.use_nodes = True + bpy.data.materials.create_gpencil_data(datablock) + + for ntype in nodes_types: + datablock.node_tree.nodes.new(ntype) + + implementation = BlMaterial() + expected = implementation._dump(datablock) + bpy.data.materials.remove(datablock) + + test = implementation._construct(expected) + implementation._load(expected, test) + result = implementation._dump(test) + + assert not DeepDiff(expected, result) From 1304489748e1819deaa2adf9ff9b53edff442a7d Mon Sep 17 00:00:00 2001 From: Swann Date: Tue, 7 Apr 2020 18:19:50 +0200 Subject: [PATCH 096/129] feat: update submodule --- multi_user/libs/replication | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_user/libs/replication b/multi_user/libs/replication index c5a3997..3a53b98 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit c5a39975590c99b9728e6364d9b16b72bb6765ca +Subproject commit 3a53b98a9da0c2ceb6e12954713d43a64e7c703a From 7cc0c0ccf811d99a4c307da9e068c5ce9eeae40d Mon Sep 17 00:00:00 2001 From: Swann Date: Tue, 7 Apr 2020 18:22:01 +0200 Subject: [PATCH 097/129] refactor: update ci --- .gitlab/ci/test.gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 239cfe6..fd2bb04 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -4,6 +4,6 @@ test: script: - git submodule init - git submodule update - - pip install pytest, blender-addon-tester + - pip install blender-addon-tester - python scripts\test_addon.py multi_user $BLENDER_VERSIONS From 3b9a72281288d7a04eee58e392c8c969b6b2a8d3 Mon Sep 17 00:00:00 2001 From: Swann Date: Tue, 7 Apr 2020 18:23:34 +0200 Subject: [PATCH 098/129] refactor: update ci --- .gitlab/ci/test.gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index fd2bb04..5d356db 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -5,5 +5,5 @@ test: - git submodule init - git submodule update - pip install blender-addon-tester - - python scripts\test_addon.py multi_user $BLENDER_VERSIONS + - python scripts/test_addon.py multi_user $BLENDER_VERSIONS From dcb593db0757e36f23cdfe7580dfc995bb43c36a Mon Sep 17 00:00:00 2001 From: Swann Date: Tue, 7 Apr 2020 18:39:54 +0200 Subject: [PATCH 099/129] refactor: update ci --- .gitlab/ci/test.gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 5d356db..a595d22 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: python:latest +image: ubuntu:latest test: script: From e8d23426d7fa15ac7a31db51729888f8fe30b400 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 16:43:42 +0000 Subject: [PATCH 100/129] Update .gitlab/ci/build.gitlab-ci.yml --- .gitlab/ci/build.gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab/ci/build.gitlab-ci.yml b/.gitlab/ci/build.gitlab-ci.yml index 000f8da..4737d04 100644 --- a/.gitlab/ci/build.gitlab-ci.yml +++ b/.gitlab/ci/build.gitlab-ci.yml @@ -5,7 +5,7 @@ build: - git submodule init - git submodule update - cd multi_user/libs/replication - - rm -rf tests .git .gitignore + - rm -rf tests .git .gitignore script artifacts: name: multi_user From 7152bb825d8d034d61b6d8106e3709d3cf5c48ce Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 16:45:26 +0000 Subject: [PATCH 101/129] Update .gitlab/ci/test.gitlab-ci.yml, .gitlab/ci/build.gitlab-ci.yml files --- .gitlab/ci/build.gitlab-ci.yml | 3 +-- .gitlab/ci/test.gitlab-ci.yml | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.gitlab/ci/build.gitlab-ci.yml b/.gitlab/ci/build.gitlab-ci.yml index 4737d04..a33cbdb 100644 --- a/.gitlab/ci/build.gitlab-ci.yml +++ b/.gitlab/ci/build.gitlab-ci.yml @@ -1,6 +1,5 @@ -image: python:latest - build: + image: python:latest script: - git submodule init - git submodule update diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index a595d22..acfc90b 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -1,6 +1,5 @@ -image: ubuntu:latest - test: + image: ubuntu:latest script: - git submodule init - git submodule update From 09c724ac53a044c2e191104fdd3839024caaa16b Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 16:46:59 +0000 Subject: [PATCH 102/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index acfc90b..053429a 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -1,5 +1,8 @@ test: image: ubuntu:latest + + before-script: + - apt install python git script: - git submodule init - git submodule update From 7a09026b6c8106a55a4d324c42d6fc322b7ac567 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 16:47:52 +0000 Subject: [PATCH 103/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 053429a..418d694 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -1,6 +1,5 @@ test: image: ubuntu:latest - before-script: - apt install python git script: From e476d44527137efb25f86d8896025a39ec8d2d8b Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 16:48:40 +0000 Subject: [PATCH 104/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 418d694..4371681 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -1,8 +1,7 @@ test: image: ubuntu:latest - before-script: - - apt install python git script: + - apt install python git - git submodule init - git submodule update - pip install blender-addon-tester From 1f16e07a3ce2edc9565ec6b38f1afcb53ff2c3dc Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 16:52:29 +0000 Subject: [PATCH 105/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 4371681..2169501 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -1,7 +1,8 @@ test: image: ubuntu:latest script: - - apt install python git + - sudo apt update + - sudo apt install python git - git submodule init - git submodule update - pip install blender-addon-tester From e9e06bbf8f86a7d018ff7c55336132064b64a160 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 16:53:12 +0000 Subject: [PATCH 106/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 2169501..e3eaca6 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -1,8 +1,8 @@ test: image: ubuntu:latest script: - - sudo apt update - - sudo apt install python git + - apt update + - apt install python git - git submodule init - git submodule update - pip install blender-addon-tester From 52cbf79f392678425f05211e36d33f72d4478ad9 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 16:54:14 +0000 Subject: [PATCH 107/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index e3eaca6..840aad2 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -2,7 +2,7 @@ test: image: ubuntu:latest script: - apt update - - apt install python git + - apt install -y python git - git submodule init - git submodule update - pip install blender-addon-tester From 2e0414f9d5207ac620a5c5de1e474feb67c8d7f9 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 16:55:15 +0000 Subject: [PATCH 108/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 840aad2..d4db83b 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -2,7 +2,7 @@ test: image: ubuntu:latest script: - apt update - - apt install -y python git + - apt install -y python pip git - git submodule init - git submodule update - pip install blender-addon-tester From 1789ae20cfe6de36348c6e902a7cb63438068f6b Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 16:56:24 +0000 Subject: [PATCH 109/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index d4db83b..5c05638 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -2,7 +2,7 @@ test: image: ubuntu:latest script: - apt update - - apt install -y python pip git + - apt install -y python python-pip git - git submodule init - git submodule update - pip install blender-addon-tester From d4d14d57ffde5248fa742e136c0431ab48005e31 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 17:00:19 +0000 Subject: [PATCH 110/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 5c05638..a165759 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -2,7 +2,7 @@ test: image: ubuntu:latest script: - apt update - - apt install -y python python-pip git + - apt install -y python3.4 python3-pip git - git submodule init - git submodule update - pip install blender-addon-tester From ca141f9860cca5d8ef59101bc18d2bddff00c46b Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 17:03:15 +0000 Subject: [PATCH 111/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index a165759..823320d 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -2,9 +2,9 @@ test: image: ubuntu:latest script: - apt update - - apt install -y python3.4 python3-pip git + - apt install -y python3 python3-pip git - git submodule init - git submodule update - - pip install blender-addon-tester - - python scripts/test_addon.py multi_user $BLENDER_VERSIONS + - python3 -m pip pip install blender-addon-tester + - python3 scripts/test_addon.py multi_user $BLENDER_VERSIONS From 5aa7a1f140bd3663d237111e535dcec69278636d Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 17:04:56 +0000 Subject: [PATCH 112/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 823320d..1baec05 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -5,6 +5,6 @@ test: - apt install -y python3 python3-pip git - git submodule init - git submodule update - - python3 -m pip pip install blender-addon-tester + - python3 -m pip install blender-addon-tester - python3 scripts/test_addon.py multi_user $BLENDER_VERSIONS From e1688f7f129b6f5c9889dabf8c6019cd6fe13e32 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 17:09:12 +0000 Subject: [PATCH 113/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 1baec05..df53312 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -6,5 +6,6 @@ test: - git submodule init - git submodule update - python3 -m pip install blender-addon-tester + - cd multi-user - python3 scripts/test_addon.py multi_user $BLENDER_VERSIONS From b047b4873871b5b043e2d1fbca05d0f39f8ad273 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 17:11:05 +0000 Subject: [PATCH 114/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index df53312..93e18af 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -1,11 +1,12 @@ test: image: ubuntu:latest script: - - apt update - - apt install -y python3 python3-pip git - - git submodule init - - git submodule update - - python3 -m pip install blender-addon-tester - - cd multi-user - - python3 scripts/test_addon.py multi_user $BLENDER_VERSIONS + - ls + # - apt update + # - apt install -y python3 python3-pip git + # - git submodule init + # - git submodule update + # - python3 -m pip install blender-addon-tester + # - cd multi-user + # - python3 scripts/test_addon.py multi_user $BLENDER_VERSIONS From 607a1f25d39df553d622e08b37b84db74f968f9a Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 17:12:27 +0000 Subject: [PATCH 115/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 93e18af..d41975a 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -1,12 +1,11 @@ test: image: ubuntu:latest script: - - ls - # - apt update - # - apt install -y python3 python3-pip git - # - git submodule init - # - git submodule update - # - python3 -m pip install blender-addon-tester - # - cd multi-user - # - python3 scripts/test_addon.py multi_user $BLENDER_VERSIONS + - apt update + - apt install -y python3 python3-pip git + - git submodule init + - git submodule update + - python3 -m pip install blender-addon-tester + - cd multi-user + - python3 scripts/test_addon.py multi_user 2.82 From 99d64cd411e3c9c332560a4a9acf9e0452bdfa85 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 17:14:12 +0000 Subject: [PATCH 116/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index d41975a..59d39ed 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -6,6 +6,5 @@ test: - git submodule init - git submodule update - python3 -m pip install blender-addon-tester - - cd multi-user - python3 scripts/test_addon.py multi_user 2.82 From 5f1853bbe3511467774b110d4b960ec8e3243919 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 19:32:40 +0000 Subject: [PATCH 117/129] Update test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 59d39ed..6554882 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -1,10 +1,8 @@ test: - image: ubuntu:latest + image: python:latest script: - - apt update - - apt install -y python3 python3-pip git - git submodule init - git submodule update - - python3 -m pip install blender-addon-tester - - python3 scripts/test_addon.py multi_user 2.82 + - pip install blender-addon-tester + - python scripts/test_addon.py multi_user 2.82 From 3f614cdcef166c072ee4b59b0365f2272c93a848 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Tue, 7 Apr 2020 19:36:44 +0000 Subject: [PATCH 118/129] Update test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 6554882..66d374a 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -4,5 +4,6 @@ test: - git submodule init - git submodule update - pip install blender-addon-tester - - python scripts/test_addon.py multi_user 2.82 + - cd scripts + - python test_addon.py multi_user 2.82 From e7e8782c2894494c1fcad7c09a03fb61b74fd23c Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Wed, 8 Apr 2020 08:18:42 +0000 Subject: [PATCH 119/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 66d374a..4c1a982 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -3,6 +3,10 @@ test: script: - git submodule init - git submodule update + - python --version + - python -m pip install --upgrade pip + - apt update + - apt install -f -y blender - pip install blender-addon-tester - cd scripts - python test_addon.py multi_user 2.82 From 506284ad1f2fc85ec59dfb5a2bce73ac49373e31 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Wed, 8 Apr 2020 08:41:24 +0000 Subject: [PATCH 120/129] another ci test --- .gitlab/ci/test.gitlab-ci.yml | 1 - scripts/test_addon.py => test_addon.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) rename scripts/test_addon.py => test_addon.py (98%) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 4c1a982..1a9e102 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -8,6 +8,5 @@ test: - apt update - apt install -f -y blender - pip install blender-addon-tester - - cd scripts - python test_addon.py multi_user 2.82 diff --git a/scripts/test_addon.py b/test_addon.py similarity index 98% rename from scripts/test_addon.py rename to test_addon.py index 6008406..fdd68c3 100644 --- a/scripts/test_addon.py +++ b/test_addon.py @@ -22,4 +22,4 @@ def main(): exit_val = 1 sys.exit(exit_val) -main() \ No newline at end of file +main() From 582c908dc448a34a0c8834a60b3a527e0aaff5f4 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Wed, 8 Apr 2020 08:47:42 +0000 Subject: [PATCH 121/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 1a9e102..4f85a57 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -3,10 +3,8 @@ test: script: - git submodule init - git submodule update - - python --version - - python -m pip install --upgrade pip - - apt update - - apt install -f -y blender + # - apt update + # - apt install -f -y blender - pip install blender-addon-tester - python test_addon.py multi_user 2.82 From bb2fc2c32bbecf09f1025e46b14c1dae778fa0de Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Wed, 8 Apr 2020 09:00:47 +0000 Subject: [PATCH 122/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 4f85a57..15a3aa3 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -3,8 +3,10 @@ test: script: - git submodule init - git submodule update - # - apt update - # - apt install -f -y blender + - apt update + # install blender to get all required dependencies + # TODO: indtall only dependencies + - apt install -f -y blender - pip install blender-addon-tester - python test_addon.py multi_user 2.82 From d8f68640b52a8cd461ee25022bbddd08c329af09 Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Wed, 8 Apr 2020 09:06:36 +0000 Subject: [PATCH 123/129] refactor: test to move test_script --- .gitlab/ci/test.gitlab-ci.yml | 2 +- test_addon.py => scripts/test_addon.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename test_addon.py => scripts/test_addon.py (100%) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 15a3aa3..9298472 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -8,5 +8,5 @@ test: # TODO: indtall only dependencies - apt install -f -y blender - pip install blender-addon-tester - - python test_addon.py multi_user 2.82 + - python test_addon.py ../multi_user 2.82 diff --git a/test_addon.py b/scripts/test_addon.py similarity index 100% rename from test_addon.py rename to scripts/test_addon.py From 52b2c25014fea54c4fde4a2fc314c6ccaf5303ca Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Wed, 8 Apr 2020 09:09:09 +0000 Subject: [PATCH 124/129] fix: wrong dir --- .gitlab/ci/test.gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 9298472..692786c 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -8,5 +8,5 @@ test: # TODO: indtall only dependencies - apt install -f -y blender - pip install blender-addon-tester - - python test_addon.py ../multi_user 2.82 + - python scripts/test_addon.py ../multi_user 2.82 From 4f02134b6c564b8960453788c013a4a148f75c4d Mon Sep 17 00:00:00 2001 From: Swann Date: Wed, 8 Apr 2020 11:15:29 +0200 Subject: [PATCH 125/129] feat: expose connection TTL --- multi_user/libs/replication | 2 +- multi_user/operators.py | 7 +++++-- multi_user/preferences.py | 5 +++++ multi_user/ui.py | 3 +++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/multi_user/libs/replication b/multi_user/libs/replication index d3601d5..727bd6b 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit d3601d508235b32a268371f062a5ccb6f03c8406 +Subproject commit 727bd6bbb1e7ddd8319416dd42ddc21314f52236 diff --git a/multi_user/operators.py b/multi_user/operators.py index 4c7560c..17af6fc 100644 --- a/multi_user/operators.py +++ b/multi_user/operators.py @@ -130,7 +130,9 @@ class SessionStartOperator(bpy.types.Operator): id=settings.username, address=settings.ip, port=settings.port, - ipc_port=settings.ipc_port) + ipc_port=settings.ipc_port, + timeout=settings.connection_timeout + ) except Exception as e: self.report({'ERROR'}, repr(e)) logger.error(f"Error: {e}") @@ -146,7 +148,8 @@ class SessionStartOperator(bpy.types.Operator): id=settings.username, address=settings.ip, port=settings.port, - ipc_port=settings.ipc_port + ipc_port=settings.ipc_port, + timeout=settings.connection_timeout ) except Exception as e: self.report({'ERROR'}, repr(e)) diff --git a/multi_user/preferences.py b/multi_user/preferences.py index 414c2ef..a4631ae 100644 --- a/multi_user/preferences.py +++ b/multi_user/preferences.py @@ -102,6 +102,11 @@ class SessionPrefs(bpy.types.AddonPreferences): name="cache directory", subtype="DIR_PATH", default=environment.DEFAULT_CACHE_DIR) + connection_timeout: bpy.props.IntProperty( + name='connection timeout', + description='connection timeout before disconnection', + default=1000 + ) # for UI category: bpy.props.EnumProperty( name="Category", diff --git a/multi_user/ui.py b/multi_user/ui.py index 1c13367..e3ce2fd 100644 --- a/multi_user/ui.py +++ b/multi_user/ui.py @@ -179,6 +179,9 @@ class SESSION_PT_settings_network(bpy.types.Panel): row = box.row() row.label(text="IPC Port:") row.prop(settings, "ipc_port", text="") + row = box.row() + row.label(text="Timeout (ms):") + row.prop(settings, "connection_timeout", text="") if runtime_settings.session_mode == 'HOST': row = box.row() From f5452b8aeee56ccf3d791423f88f2f0e892b78ec Mon Sep 17 00:00:00 2001 From: Swann Martinez Date: Wed, 8 Apr 2020 10:20:13 +0000 Subject: [PATCH 126/129] Update .gitlab/ci/test.gitlab-ci.yml --- .gitlab/ci/test.gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 692786c..6b2a842 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -8,5 +8,5 @@ test: # TODO: indtall only dependencies - apt install -f -y blender - pip install blender-addon-tester - - python scripts/test_addon.py ../multi_user 2.82 + - python scripts/test_addon.py From 94aeff7b35a9fe84acf87110dc992bd2ac8835cb Mon Sep 17 00:00:00 2001 From: Swann Date: Wed, 8 Apr 2020 18:40:02 +0200 Subject: [PATCH 127/129] feat: new tests fix: curve diff errors fix: metaball diff error fix: scene error --- multi_user/bl_types/bl_curve.py | 15 ++++++++++++- multi_user/bl_types/bl_metaball.py | 15 ++++++++----- multi_user/bl_types/bl_scene.py | 1 - tests/test_bl_types/test_curve.py | 29 +++++++++++++++++++++++++ tests/test_bl_types/test_lattice.py | 23 ++++++++++++++++++++ tests/test_bl_types/test_lightprobes.py | 24 ++++++++++++++++++++ tests/test_bl_types/test_metaball.py | 23 ++++++++++++++++++++ tests/test_bl_types/test_scene.py | 22 +++++++++++++++++++ 8 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 tests/test_bl_types/test_curve.py create mode 100644 tests/test_bl_types/test_lattice.py create mode 100644 tests/test_bl_types/test_lightprobes.py create mode 100644 tests/test_bl_types/test_metaball.py create mode 100644 tests/test_bl_types/test_scene.py diff --git a/multi_user/bl_types/bl_curve.py b/multi_user/bl_types/bl_curve.py index caa1754..205f8cc 100644 --- a/multi_user/bl_types/bl_curve.py +++ b/multi_user/bl_types/bl_curve.py @@ -87,7 +87,20 @@ class BlCurve(BlDatablock): def _dump_implementation(self, data, instance=None): assert(instance) dumper = Dumper() - + # Conflicting attributes + # TODO: remove them with the NURBS support + dumper.exclude_filter = [ + 'users', + 'order_u', + 'order_v', + 'point_count_v', + 'point_count_u', + 'active_textbox' + ] + if instance.use_auto_texspace: + dumper.exclude_filter.extend([ + 'texspace_location', + 'texspace_size']) data = dumper.dump(instance) data['splines'] = {} diff --git a/multi_user/bl_types/bl_metaball.py b/multi_user/bl_types/bl_metaball.py index 32d3f01..503839c 100644 --- a/multi_user/bl_types/bl_metaball.py +++ b/multi_user/bl_types/bl_metaball.py @@ -88,12 +88,15 @@ class BlMetaball(BlDatablock): assert(instance) dumper = Dumper() dumper.depth = 1 - dumper.exclude_filter = [ - "is_editmode", - "is_evaluated", - "is_embedded_data", - "is_library_indirect", - "name_full" + dumper.include_filter = [ + 'name', + 'resolution', + 'render_resolution', + 'threshold', + 'update_method', + 'use_auto_texspace', + 'texspace_location', + 'texspace_size' ] data = dumper.dump(instance) diff --git a/multi_user/bl_types/bl_scene.py b/multi_user/bl_types/bl_scene.py index a563af3..95ce1cc 100644 --- a/multi_user/bl_types/bl_scene.py +++ b/multi_user/bl_types/bl_scene.py @@ -37,7 +37,6 @@ class BlScene(BlDatablock): return instance def _load_implementation(self, data, target): - target = self.instance # Load other meshes metadata loader = Loader() loader.load(target, data) diff --git a/tests/test_bl_types/test_curve.py b/tests/test_bl_types/test_curve.py new file mode 100644 index 0000000..4432d4d --- /dev/null +++ b/tests/test_bl_types/test_curve.py @@ -0,0 +1,29 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +import random +from multi_user.bl_types.bl_curve import BlCurve + +@pytest.mark.parametrize('curve_type', ['TEXT','BEZIER']) +def test_curve(clear_blend, curve_type): + if curve_type == 'TEXT': + bpy.ops.object.text_add(enter_editmode=False, align='WORLD', location=(0, 0, 0)) + elif curve_type == 'BEZIER': + bpy.ops.curve.primitive_bezier_curve_add(enter_editmode=False, align='WORLD', location=(0, 0, 0)) + else: #TODO: NURBS support + bpy.ops.surface.primitive_nurbs_surface_curve_add(radius=1, enter_editmode=False, align='WORLD', location=(0, 0, 0)) + + datablock = bpy.data.curves[0] + + implementation = BlCurve() + expected = implementation._dump(datablock) + bpy.data.curves.remove(datablock) + + test = implementation._construct(expected) + implementation._load(expected, test) + result = implementation._dump(test) + + assert not DeepDiff(expected, result) diff --git a/tests/test_bl_types/test_lattice.py b/tests/test_bl_types/test_lattice.py new file mode 100644 index 0000000..fc23e5f --- /dev/null +++ b/tests/test_bl_types/test_lattice.py @@ -0,0 +1,23 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +from multi_user.bl_types.bl_lattice import BlLattice + + +def test_lattice(clear_blend): + bpy.ops.object.add(type='LATTICE', enter_editmode=False, align='WORLD', location=(0, 0, 0)) + + datablock = bpy.data.lattices[0] + + implementation = BlLattice() + expected = implementation._dump(datablock) + bpy.data.lattices.remove(datablock) + + test = implementation._construct(expected) + implementation._load(expected, test) + result = implementation._dump(test) + + assert not DeepDiff(expected, result) diff --git a/tests/test_bl_types/test_lightprobes.py b/tests/test_bl_types/test_lightprobes.py new file mode 100644 index 0000000..391a391 --- /dev/null +++ b/tests/test_bl_types/test_lightprobes.py @@ -0,0 +1,24 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +from multi_user.bl_types.bl_lightprobe import BlLightprobe + + +@pytest.mark.skipif(bpy.app.version[1] < 83, reason="requires blender 2.83 or higher") +@pytest.mark.parametrize('lightprobe_type', ['PLANAR','GRID','CUBEMAP']) +def test_lightprobes(clear_blend, lightprobe_type): + bpy.ops.object.lightprobe_add(type=lightprobe_type) + + blender_light = bpy.data.lightprobes[0] + lightprobe_dumper = BlLightprobe() + expected = lightprobe_dumper._dump(blender_light) + bpy.data.lightprobes.remove(blender_light) + + test = lightprobe_dumper._construct(expected) + lightprobe_dumper._load(expected, test) + result = lightprobe_dumper._dump(test) + + assert not DeepDiff(expected, result) diff --git a/tests/test_bl_types/test_metaball.py b/tests/test_bl_types/test_metaball.py new file mode 100644 index 0000000..cb7a1f1 --- /dev/null +++ b/tests/test_bl_types/test_metaball.py @@ -0,0 +1,23 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +from multi_user.bl_types.bl_metaball import BlMetaball + + +@pytest.mark.parametrize('metaballs_type', ['PLANE','CAPSULE','BALL','ELLIPSOID','CUBE']) +def test_metaball(clear_blend, metaballs_type): + bpy.ops.object.metaball_add(type=metaballs_type) + + datablock = bpy.data.metaballs[0] + dumper = BlMetaball() + expected = dumper._dump(datablock) + bpy.data.metaballs.remove(datablock) + + test = dumper._construct(expected) + dumper._load(expected, test) + result = dumper._dump(test) + + assert not DeepDiff(expected, result) diff --git a/tests/test_bl_types/test_scene.py b/tests/test_bl_types/test_scene.py new file mode 100644 index 0000000..2772a00 --- /dev/null +++ b/tests/test_bl_types/test_scene.py @@ -0,0 +1,22 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +import random +from multi_user.bl_types.bl_scene import BlScene + +def test_scene(clear_blend): + datablock = bpy.data.scenes.new("toto") + + # Test + implementation = BlScene() + expected = implementation._dump(datablock) + bpy.data.scenes.remove(datablock) + + test = implementation._construct(expected) + implementation._load(expected, test) + result = implementation._dump(test) + + assert not DeepDiff(expected, result) From 03490af0428e22100ad169b471ed34c631a67272 Mon Sep 17 00:00:00 2001 From: Swann Date: Wed, 8 Apr 2020 19:28:02 +0200 Subject: [PATCH 128/129] feat: additional tests --- multi_user/bl_types/bl_armature.py | 10 ++-- tests/test_bl_types/test_armature.py | 22 +++++++++ tests/test_bl_types/test_collection.py | 28 +++++++++++ tests/test_bl_types/test_image.py | 21 +++++++++ tests/test_bl_types/test_mesh.py | 28 +++++++++++ tests/test_bl_types/test_object.py | 64 ++++++++++++++++++++++++++ tests/test_bl_types/test_speaker.py | 22 +++++++++ tests/test_bl_types/test_world.py | 22 +++++++++ 8 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 tests/test_bl_types/test_armature.py create mode 100644 tests/test_bl_types/test_collection.py create mode 100644 tests/test_bl_types/test_image.py create mode 100644 tests/test_bl_types/test_mesh.py create mode 100644 tests/test_bl_types/test_object.py create mode 100644 tests/test_bl_types/test_speaker.py create mode 100644 tests/test_bl_types/test_world.py diff --git a/multi_user/bl_types/bl_armature.py b/multi_user/bl_types/bl_armature.py index 1f556ed..2fa8152 100644 --- a/multi_user/bl_types/bl_armature.py +++ b/multi_user/bl_types/bl_armature.py @@ -46,7 +46,7 @@ class BlArmature(BlDatablock): if parent_object is None: parent_object = bpy.data.objects.new( - data['user_name'], self.instance) + data['user_name'], target) parent_object.uuid = data['user'] is_object_in_master = ( @@ -81,10 +81,10 @@ class BlArmature(BlDatablock): bpy.ops.object.mode_set(mode='EDIT') for bone in data['bones']: - if bone not in self.instance.edit_bones: - new_bone = self.instance.edit_bones.new(bone) + if bone not in target.edit_bones: + new_bone = target.edit_bones.new(bone) else: - new_bone = self.instance.edit_bones[bone] + new_bone = target.edit_bones[bone] bone_data = data['bones'].get(bone) @@ -94,7 +94,7 @@ class BlArmature(BlDatablock): new_bone.head_radius = bone_data['head_radius'] if 'parent' in bone_data: - new_bone.parent = self.instance.edit_bones[data['bones'] + new_bone.parent = target.edit_bones[data['bones'] [bone]['parent']] new_bone.use_connect = bone_data['use_connect'] diff --git a/tests/test_bl_types/test_armature.py b/tests/test_bl_types/test_armature.py new file mode 100644 index 0000000..9684d14 --- /dev/null +++ b/tests/test_bl_types/test_armature.py @@ -0,0 +1,22 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +import random +from multi_user.bl_types.bl_armature import BlArmature + +def test_armature(clear_blend): + bpy.ops.object.armature_add() + datablock = bpy.data.armatures[0] + + implementation = BlArmature() + expected = implementation._dump(datablock) + bpy.data.armatures.remove(datablock) + + test = implementation._construct(expected) + implementation._load(expected, test) + result = implementation._dump(test) + + assert not DeepDiff(expected, result) diff --git a/tests/test_bl_types/test_collection.py b/tests/test_bl_types/test_collection.py new file mode 100644 index 0000000..888a281 --- /dev/null +++ b/tests/test_bl_types/test_collection.py @@ -0,0 +1,28 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +import random +from multi_user.bl_types.bl_collection import BlCollection + +def test_collection(clear_blend): + # Generate a collection with childrens and a cube + datablock = bpy.data.collections.new("root") + datablock.children.link(bpy.data.collections.new("child")) + datablock.children.link(bpy.data.collections.new("child2")) + + bpy.ops.mesh.primitive_cube_add() + datablock.objects.link(bpy.data.objects[0]) + + # Test + implementation = BlCollection() + expected = implementation._dump(datablock) + bpy.data.collections.remove(datablock) + + test = implementation._construct(expected) + implementation._load(expected, test) + result = implementation._dump(test) + + assert not DeepDiff(expected, result) diff --git a/tests/test_bl_types/test_image.py b/tests/test_bl_types/test_image.py new file mode 100644 index 0000000..e597526 --- /dev/null +++ b/tests/test_bl_types/test_image.py @@ -0,0 +1,21 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +import random +from multi_user.bl_types.bl_image import BlImage + +def test_image(clear_blend): + datablock = bpy.data.images.new('asd',2000,2000) + + implementation = BlImage() + expected = implementation._dump(datablock) + bpy.data.images.remove(datablock) + + test = implementation._construct(expected) + implementation._load(expected, test) + result = implementation._dump(test) + + assert not DeepDiff(expected, result) diff --git a/tests/test_bl_types/test_mesh.py b/tests/test_bl_types/test_mesh.py new file mode 100644 index 0000000..454b858 --- /dev/null +++ b/tests/test_bl_types/test_mesh.py @@ -0,0 +1,28 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +import random +from multi_user.bl_types.bl_mesh import BlMesh + +@pytest.mark.parametrize('mesh_type', ['EMPTY','FILLED']) +def test_mesh(clear_blend, mesh_type): + if mesh_type == 'FILLED': + bpy.ops.mesh.primitive_monkey_add() + elif mesh_type == 'EMPTY': + bpy.data.meshes.new('empty_mesh') + + datablock = bpy.data.meshes[0] + + # Test + implementation = BlMesh() + expected = implementation._dump(datablock) + bpy.data.meshes.remove(datablock) + + test = implementation._construct(expected) + implementation._load(expected, test) + result = implementation._dump(test) + + assert not DeepDiff(expected, result) diff --git a/tests/test_bl_types/test_object.py b/tests/test_bl_types/test_object.py new file mode 100644 index 0000000..db2b9c2 --- /dev/null +++ b/tests/test_bl_types/test_object.py @@ -0,0 +1,64 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +import random +from multi_user.bl_types.bl_object import BlObject + +# Removed 'BUILD' modifier because the seed doesn't seems to be +# correctly initialized (#TODO: report the bug) +MOFIFIERS_TYPES = [ + 'DATA_TRANSFER', 'MESH_CACHE', 'MESH_SEQUENCE_CACHE', + 'NORMAL_EDIT', 'WEIGHTED_NORMAL', 'UV_PROJECT', 'UV_WARP', + 'VERTEX_WEIGHT_EDIT', 'VERTEX_WEIGHT_MIX', + 'VERTEX_WEIGHT_PROXIMITY', 'ARRAY', 'BEVEL', 'BOOLEAN', + 'DECIMATE', 'EDGE_SPLIT', 'MASK', 'MIRROR', + 'MULTIRES', 'REMESH', 'SCREW', 'SKIN', 'SOLIDIFY', + 'SUBSURF', 'TRIANGULATE', + 'WELD', 'WIREFRAME', 'ARMATURE', 'CAST', 'CURVE', + 'DISPLACE', 'HOOK', 'LAPLACIANDEFORM', 'LATTICE', + 'MESH_DEFORM', 'SHRINKWRAP', 'SIMPLE_DEFORM', 'SMOOTH', + 'CORRECTIVE_SMOOTH', 'LAPLACIANSMOOTH', 'SURFACE_DEFORM', + 'WARP', 'WAVE', 'CLOTH', 'COLLISION', 'DYNAMIC_PAINT', + 'EXPLODE', 'FLUID', 'OCEAN', 'PARTICLE_INSTANCE', + 'SOFT_BODY', 'SURFACE'] + +CONSTRAINTS_TYPES = [ + 'CAMERA_SOLVER', 'FOLLOW_TRACK', 'OBJECT_SOLVER', 'COPY_LOCATION', + 'COPY_ROTATION', 'COPY_SCALE', 'COPY_TRANSFORMS', 'LIMIT_DISTANCE', + 'LIMIT_LOCATION', 'LIMIT_ROTATION', 'LIMIT_SCALE', 'MAINTAIN_VOLUME', + 'TRANSFORM', 'TRANSFORM_CACHE', 'CLAMP_TO', 'DAMPED_TRACK', 'IK', + 'LOCKED_TRACK', 'SPLINE_IK', 'STRETCH_TO', 'TRACK_TO', 'ACTION', + 'ARMATURE', 'CHILD_OF', 'FLOOR', 'FOLLOW_PATH', 'PIVOT', 'SHRINKWRAP'] + +def test_object(clear_blend): + bpy.ops.mesh.primitive_cube_add( + enter_editmode=False, align='WORLD', location=(0, 0, 0)) + + datablock = bpy.data.objects[0] + + # Add modifiers + for mod_type in MOFIFIERS_TYPES: + datablock.modifiers.new(mod_type,mod_type) + + # Add constraints + for const_type in CONSTRAINTS_TYPES: + datablock.constraints.new(const_type) + + datablock.vertex_groups.new(name='vg') + datablock.vertex_groups.new(name='vg1') + datablock.shape_key_add(name='shape') + datablock.shape_key_add(name='shape2') + + + implementation = BlObject() + expected = implementation._dump(datablock) + bpy.data.objects.remove(datablock) + + test = implementation._construct(expected) + implementation._load(expected, test) + result = implementation._dump(test) + + assert not DeepDiff(expected, result) diff --git a/tests/test_bl_types/test_speaker.py b/tests/test_bl_types/test_speaker.py new file mode 100644 index 0000000..81afe0e --- /dev/null +++ b/tests/test_bl_types/test_speaker.py @@ -0,0 +1,22 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +import random +from multi_user.bl_types.bl_speaker import BlSpeaker + +def test_speaker(clear_blend): + bpy.ops.object.speaker_add() + datablock = bpy.data.speakers[0] + + implementation = BlSpeaker() + expected = implementation._dump(datablock) + bpy.data.speakers.remove(datablock) + + test = implementation._construct(expected) + implementation._load(expected, test) + result = implementation._dump(test) + + assert not DeepDiff(expected, result) diff --git a/tests/test_bl_types/test_world.py b/tests/test_bl_types/test_world.py new file mode 100644 index 0000000..4e42a50 --- /dev/null +++ b/tests/test_bl_types/test_world.py @@ -0,0 +1,22 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +import random +from multi_user.bl_types.bl_world import BlWorld + +def test_world(clear_blend): + datablock = bpy.data.worlds.new('test') + datablock.use_nodes = True + + implementation = BlWorld() + expected = implementation._dump(datablock) + bpy.data.worlds.remove(datablock) + + test = implementation._construct(expected) + implementation._load(expected, test) + result = implementation._dump(test) + + assert not DeepDiff(expected, result) From 95241d4148421e36bdb51f763bc23df20713bdcd Mon Sep 17 00:00:00 2001 From: Swann Date: Wed, 8 Apr 2020 19:41:57 +0200 Subject: [PATCH 129/129] feat: start to test operators --- multi_user/libs/replication | 2 +- tests/test_operators.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 tests/test_operators.py diff --git a/multi_user/libs/replication b/multi_user/libs/replication index 3a53b98..692b953 160000 --- a/multi_user/libs/replication +++ b/multi_user/libs/replication @@ -1 +1 @@ -Subproject commit 3a53b98a9da0c2ceb6e12954713d43a64e7c703a +Subproject commit 692b953af861c564ac481f40272bc0c88982619b diff --git a/tests/test_operators.py b/tests/test_operators.py new file mode 100644 index 0000000..29bbe8d --- /dev/null +++ b/tests/test_operators.py @@ -0,0 +1,20 @@ +import os + +import pytest +from deepdiff import DeepDiff + +import bpy +import random + + +def test_start_session(): + result = bpy.ops.session.start() + + + assert 'FINISHED' in result + +def test_stop_session(): + + result = bpy.ops.session.stop() + + assert 'FINISHED' in result