diff --git a/CHANGELOG.md b/CHANGELOG.md index 293e81d..49f0192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,4 +157,33 @@ All notable changes to this project will be documented in this file. - Empty and Light object selection highlights - Material renaming - Default material nodes input parameters -- blender 2.91 python api compatibility \ No newline at end of file +- blender 2.91 python api compatibility + +## [0.3.0] - 2021-04-14 + +### Added + +- Curve material support +- Cycle visibility settings +- Session save/load operator +- Add new scene support +- Physic initial support +- Geometry node initial support +- Blender 2.93 compatibility +### Changed + +- Host documentation on Gitlab Page +- Event driven update (from the blender deps graph) + +### Fixed + +- Vertex group assignation +- Parent relation can't be removed +- Separate object +- Delete animation +- Sync missing holdout option for grease pencil material +- Sync missing `skin_vertices` +- Exception access violation during Undo/Redo +- Sync missing armature bone Roll +- Sync missing driver data_path +- Constraint replication \ No newline at end of file diff --git a/README.md b/README.md index d2c8991..051ef32 100644 --- a/README.md +++ b/README.md @@ -29,35 +29,35 @@ See the [troubleshooting guide](https://slumber.gitlab.io/multi-user/getting_sta Currently, not all data-block are supported for replication over the wire. The following list summarizes the status for each ones. -| Name | Status | Comment | -| -------------- | :----: | :--------------------------------------------------------------------------: | -| action | ✔️ | | -| armature | ❗ | Not stable | -| camera | ✔️ | | -| collection | ✔️ | | -| curve | ❗ | Nurbs surfaces not supported | -| gpencil | ✔️ | [Airbrush not supported](https://gitlab.com/slumber/multi-user/-/issues/123) | -| image | ✔️ | | -| mesh | ✔️ | | -| material | ✔️ | | -| node_groups | ❗ | Material only | -| geometry nodes | ✔️ | | -| metaball | ✔️ | | -| object | ✔️ | | -| textures | ❗ | Supported for modifiers/materials only | -| texts | ✔️ | | -| scene | ✔️ | | -| world | ✔️ | | -| lightprobes | ✔️ | | -| compositing | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/46) | -| texts | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/81) | -| nla | ❌ | | -| volumes | ✔️ | | -| particles | ❌ | [On-going](https://gitlab.com/slumber/multi-user/-/issues/24) | -| speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) | -| vse | ❗ | Mask and Clip not supported yet | -| physics | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) | -| libraries | ❗ | Partial | +| Name | Status | Comment | +| -------------- | :----: | :----------------------------------------------------------: | +| action | ✔️ | | +| armature | ❗ | Not stable | +| camera | ✔️ | | +| collection | ✔️ | | +| curve | ❗ | Nurbs surfaces not supported | +| gpencil | ✔️ | | +| image | ✔️ | | +| mesh | ✔️ | | +| material | ✔️ | | +| node_groups | ❗ | Material & Geometry only | +| geometry nodes | ✔️ | | +| metaball | ✔️ | | +| object | ✔️ | | +| textures | ❗ | Supported for modifiers/materials/geo nodes only | +| texts | ✔️ | | +| scene | ✔️ | | +| world | ✔️ | | +| lightprobes | ✔️ | | +| compositing | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/46) | +| texts | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/81) | +| nla | ❌ | | +| volumes | ✔️ | | +| particles | ❗ | The cache isn't syncing. | +| speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) | +| vse | ❗ | Mask and Clip not supported yet | +| physics | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) | +| libraries | ❗ | Partial | @@ -70,7 +70,7 @@ I'm working on it. | Dependencies | Version | Needed | | ------------ | :-----: | -----: | -| Replication | latest | yes | +| Replication | latest | yes | diff --git a/multi_user/addon_updater_ops.py b/multi_user/addon_updater_ops.py index 9dd3960..efe7641 100644 --- a/multi_user/addon_updater_ops.py +++ b/multi_user/addon_updater_ops.py @@ -122,13 +122,13 @@ class addon_updater_install_popup(bpy.types.Operator): # 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( + 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( + ignore_enum: bpy.props.EnumProperty( name="Process update", description="Decide to install, ignore, or defer new addon update", items=[ @@ -264,7 +264,7 @@ class addon_updater_update_now(bpy.types.Operator): # 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( + 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, @@ -332,7 +332,7 @@ class addon_updater_update_target(bpy.types.Operator): i+=1 return ret - target = bpy.props.EnumProperty( + target: bpy.props.EnumProperty( name="Target version to install", description="Select the version to install", items=target_version @@ -341,7 +341,7 @@ class addon_updater_update_target(bpy.types.Operator): # 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( + 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, @@ -399,7 +399,7 @@ class addon_updater_install_manually(bpy.types.Operator): bl_description = "Proceed to manually install update" bl_options = {'REGISTER', 'INTERNAL'} - error = bpy.props.StringProperty( + error: bpy.props.StringProperty( name="Error Occurred", default="", options={'HIDDEN'} @@ -461,7 +461,7 @@ class addon_updater_updated_successful(bpy.types.Operator): bl_description = "Update installation response" bl_options = {'REGISTER', 'INTERNAL', 'UNDO'} - error = bpy.props.StringProperty( + error: bpy.props.StringProperty( name="Error Occurred", default="", options={'HIDDEN'} diff --git a/multi_user/bl_types/__init__.py b/multi_user/bl_types/__init__.py index 0e8871f..0aa2e8d 100644 --- a/multi_user/bl_types/__init__.py +++ b/multi_user/bl_types/__init__.py @@ -42,6 +42,7 @@ __all__ = [ # 'bl_sequencer', 'bl_node_group', 'bl_texture', + "bl_particle", ] # Order here defines execution order if bpy.app.version[1] >= 91: diff --git a/multi_user/bl_types/bl_action.py b/multi_user/bl_types/bl_action.py index 8672fb6..893fe30 100644 --- a/multi_user/bl_types/bl_action.py +++ b/multi_user/bl_types/bl_action.py @@ -61,7 +61,6 @@ def dump_fcurve(fcurve: bpy.types.FCurve, use_numpy: bool = True) -> dict: points = fcurve.keyframe_points fcurve_data['keyframes_count'] = len(fcurve.keyframe_points) fcurve_data['keyframe_points'] = np_dump_collection(points, KEYFRAME) - else: # Legacy method dumper = Dumper() fcurve_data["keyframe_points"] = [] @@ -71,6 +70,18 @@ def dump_fcurve(fcurve: bpy.types.FCurve, use_numpy: bool = True) -> dict: dumper.dump(k) ) + if fcurve.modifiers: + dumper = Dumper() + dumper.exclude_filter = [ + 'is_valid', + 'active' + ] + dumped_modifiers = [] + for modfifier in fcurve.modifiers: + dumped_modifiers.append(dumper.dump(modfifier)) + + fcurve_data['modifiers'] = dumped_modifiers + return fcurve_data @@ -83,7 +94,7 @@ def load_fcurve(fcurve_data, fcurve): :type fcurve: bpy.types.FCurve """ use_numpy = fcurve_data.get('use_numpy') - + loader = Loader() keyframe_points = fcurve.keyframe_points # Remove all keyframe points @@ -128,6 +139,21 @@ def load_fcurve(fcurve_data, fcurve): fcurve.update() + dumped_fcurve_modifiers = fcurve_data.get('modifiers', None) + + if dumped_fcurve_modifiers: + # clear modifiers + for fmod in fcurve.modifiers: + fcurve.modifiers.remove(fmod) + + # Load each modifiers in order + for modifier_data in dumped_fcurve_modifiers: + modifier = fcurve.modifiers.new(modifier_data['type']) + + loader.load(modifier, modifier_data) + elif fcurve.modifiers: + for fmod in fcurve.modifiers: + fcurve.modifiers.remove(fmod) class BlAction(BlDatablock): bl_id = "actions" diff --git a/multi_user/bl_types/bl_camera.py b/multi_user/bl_types/bl_camera.py index 486726c..244ad77 100644 --- a/multi_user/bl_types/bl_camera.py +++ b/multi_user/bl_types/bl_camera.py @@ -56,6 +56,11 @@ class BlCamera(BlDatablock): target_img.image = bpy.data.images[img_id] loader.load(target_img, img_data) + img_user = img_data.get('image_user') + if img_user: + loader.load(target_img.image_user, img_user) + + def _dump_implementation(self, data, instance=None): assert(instance) @@ -101,10 +106,19 @@ class BlCamera(BlDatablock): 'scale', 'use_flip_x', 'use_flip_y', - 'image' + 'image_user', + 'image', + 'frame_duration', + 'frame_start', + 'frame_offset', + 'use_cyclic', + 'use_auto_refresh' ] - return dumper.dump(instance) - + data = dumper.dump(instance) + for index, image in enumerate(instance.background_images): + if image.image_user: + data['background_images'][index]['image_user'] = dumper.dump(image.image_user) + return data def _resolve_deps_implementation(self): deps = [] for background in self.instance.background_images: diff --git a/multi_user/bl_types/bl_datablock.py b/multi_user/bl_types/bl_datablock.py index c3ab5a7..7cdb2d1 100644 --- a/multi_user/bl_types/bl_datablock.py +++ b/multi_user/bl_types/bl_datablock.py @@ -72,10 +72,10 @@ def load_driver(target_datablock, src_driver): for src_target in src_var_data['targets']: 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']) - loader.load( - new_var.targets[src_target], src_target_data) + src_id = src_target_data.get('id') + if src_id: + new_var.targets[src_target].id = utils.resolve_from_id(src_target_data['id'], src_target_data['id_type']) + loader.load(new_var.targets[src_target], src_target_data) # Fcurve new_fcurve = new_driver.keyframe_points @@ -161,19 +161,17 @@ class BlDatablock(ReplicatedDatablock): def _dump(self, instance=None): dumper = Dumper() data = {} + animation_data = {} # Dump animation data if has_action(instance): - dumper = Dumper() - dumper.include_filter = ['action'] - data['animation_data'] = dumper.dump(instance.animation_data) - + animation_data['action'] = instance.animation_data.action.name if has_driver(instance): - dumped_drivers = {'animation_data': {'drivers': []}} + animation_data['drivers'] = [] for driver in instance.animation_data.drivers: - dumped_drivers['animation_data']['drivers'].append( - dump_driver(driver)) + animation_data['drivers'].append(dump_driver(driver)) - data.update(dumped_drivers) + if animation_data: + data['animation_data'] = animation_data if self.is_library: data.update(dumper.dump(instance)) @@ -200,6 +198,9 @@ class BlDatablock(ReplicatedDatablock): if 'action' in data['animation_data']: target.animation_data.action = bpy.data.actions[data['animation_data']['action']] + elif target.animation_data.action: + target.animation_data.action = None + # Remove existing animation data if there is not more to load elif hasattr(target, 'animation_data') and target.animation_data: target.animation_data_clear() diff --git a/multi_user/bl_types/bl_image.py b/multi_user/bl_types/bl_image.py index c559938..3a248c6 100644 --- a/multi_user/bl_types/bl_image.py +++ b/multi_user/bl_types/bl_image.py @@ -66,9 +66,12 @@ class BlImage(BlDatablock): loader = Loader() loader.load(data, target) - target.source = 'FILE' + target.source = data['source'] target.filepath_raw = get_filepath(data['filename']) - target.colorspace_settings.name = data["colorspace_settings"]["name"] + color_space_name = data["colorspace_settings"]["name"] + + if color_space_name: + target.colorspace_settings.name = color_space_name def _dump(self, instance=None): assert(instance) @@ -83,6 +86,7 @@ class BlImage(BlDatablock): dumper.depth = 2 dumper.include_filter = [ "name", + 'source', 'size', 'height', 'alpha', diff --git a/multi_user/bl_types/bl_material.py b/multi_user/bl_types/bl_material.py index 12964ed..f6fec50 100644 --- a/multi_user/bl_types/bl_material.py +++ b/multi_user/bl_types/bl_material.py @@ -27,7 +27,7 @@ from .dump_anything import Loader, Dumper from .bl_datablock import BlDatablock, get_datablock_from_uuid NODE_SOCKET_INDEX = re.compile('\[(\d*)\]') -IGNORED_SOCKETS = ['GEOMETRY', 'SHADER'] +IGNORED_SOCKETS = ['GEOMETRY', 'SHADER', 'CUSTOM'] def load_node(node_data: dict, node_tree: bpy.types.ShaderNodeTree): """ Load a node into a node_tree from a dict @@ -54,8 +54,8 @@ def load_node(node_data: dict, node_tree: bpy.types.ShaderNodeTree): if inputs_data: inputs = [i for i in target_node.inputs if i.type not in IGNORED_SOCKETS] for idx, inpt in enumerate(inputs): - loaded_input = inputs_data[idx] if idx < len(inputs_data) and hasattr(inpt, "default_value"): + loaded_input = inputs_data[idx] try: if inpt.type in ['OBJECT', 'COLLECTION']: inpt.default_value = get_datablock_from_uuid(loaded_input, None) @@ -69,13 +69,17 @@ def load_node(node_data: dict, node_tree: bpy.types.ShaderNodeTree): outputs_data = node_data.get('outputs') if outputs_data: outputs = [o for o in target_node.outputs if o.type not in IGNORED_SOCKETS] - for idx, output in enumerate(outputs_data): - if idx < len(outputs) and hasattr(outputs[idx], "default_value"): + for idx, output in enumerate(outputs): + if idx < len(outputs_data) and hasattr(output, "default_value"): + loaded_output = outputs_data[idx] try: - outputs[idx].default_value = output + if output.type in ['OBJECT', 'COLLECTION']: + output.default_value = get_datablock_from_uuid(loaded_output, None) + else: + output.default_value = loaded_output except Exception as e: logging.warning( - f"Node {target_node.name} output {outputs[idx].name} parameter not supported, skipping ({e})") + f"Node {target_node.name} output {output.name} parameter not supported, skipping ({e})") else: logging.warning( f"Node {target_node.name} output length mismatch.") @@ -119,6 +123,9 @@ def dump_node(node: bpy.types.ShaderNode) -> dict: dumped_node = node_dumper.dump(node) + if node.parent: + dumped_node['parent'] = node.parent.name + dump_io_needed = (node.type not in ['REROUTE', 'OUTPUT_MATERIAL']) if dump_io_needed: @@ -155,6 +162,7 @@ def dump_node(node: bpy.types.ShaderNode) -> dict: 'color', 'position', 'interpolation', + 'hue_interpolation', 'color_mode' ] dumped_node['color_ramp'] = ramp_dumper.dump(node.color_ramp) @@ -313,6 +321,14 @@ def load_node_tree(node_tree_data: dict, target_node_tree: bpy.types.ShaderNodeT for node in node_tree_data["nodes"]: load_node(node_tree_data["nodes"][node], target_node_tree) + for node_id, node_data in node_tree_data["nodes"].items(): + target_node = target_node_tree.nodes.get(node_id, None) + if target_node is None: + continue + elif 'parent' in node_data: + target_node.parent = target_node_tree.nodes[node_data['parent']] + else: + target_node.parent = None # TODO: load only required nodes links # Load nodes links target_node_tree.links.clear() @@ -327,6 +343,8 @@ def get_node_tree_dependencies(node_tree: bpy.types.NodeTree) -> list: def has_node_group(node): return ( hasattr(node, 'node_tree') and node.node_tree) + def has_texture(node): return ( + node.type in ['ATTRIBUTE_SAMPLE_TEXTURE','TEXTURE'] and node.texture) deps = [] for node in node_tree.nodes: @@ -334,6 +352,8 @@ def get_node_tree_dependencies(node_tree: bpy.types.NodeTree) -> list: deps.append(node.image) elif has_node_group(node): deps.append(node.node_tree) + elif has_texture(node): + deps.append(node.texture) return deps @@ -364,10 +384,7 @@ def load_materials_slots(src_materials: list, dst_materials: bpy.types.bpy_prop_ if mat_uuid is not None: mat_ref = get_datablock_from_uuid(mat_uuid, None) else: - mat_ref = bpy.data.materials.get(mat_name, None) - - if mat_ref is None: - raise Exception(f"Material {mat_name} doesn't exist") + mat_ref = bpy.data.materials[mat_name] dst_materials.append(mat_ref) diff --git a/multi_user/bl_types/bl_object.py b/multi_user/bl_types/bl_object.py index fdb4ba7..0220646 100644 --- a/multi_user/bl_types/bl_object.py +++ b/multi_user/bl_types/bl_object.py @@ -23,6 +23,7 @@ import mathutils from replication.exception import ContextError from .bl_datablock import BlDatablock, get_datablock_from_uuid +from .bl_material import IGNORED_SOCKETS from .dump_anything import ( Dumper, Loader, @@ -30,32 +31,97 @@ from .dump_anything import ( np_dump_collection) - SKIN_DATA = [ 'radius', 'use_loose', 'use_root' ] -def get_input_index(e): - return int(re.findall('[0-9]+', e)[0]) +if bpy.app.version[1] >= 93: + SUPPORTED_GEOMETRY_NODE_PARAMETERS = (int, str, float) +else: + SUPPORTED_GEOMETRY_NODE_PARAMETERS = (int, str) + logging.warning("Geometry node Float parameter not supported in \ + blender 2.92.") + +def get_node_group_inputs(node_group): + inputs = [] + for inpt in node_group.inputs: + if inpt.type in IGNORED_SOCKETS: + continue + else: + inputs.append(inpt) + return inputs + # return [inpt.identifer for inpt in node_group.inputs if inpt.type not in IGNORED_SOCKETS] +def dump_physics(target: bpy.types.Object)->dict: + """ + Dump all physics settings from a given object excluding modifier + related physics settings (such as softbody, cloth, dynapaint and fluid) + """ + dumper = Dumper() + dumper.depth = 1 + physics_data = {} + + # Collisions (collision) + if target.collision and target.collision.use: + physics_data['collision'] = dumper.dump(target.collision) + + # Field (field) + if target.field and target.field.type != "NONE": + physics_data['field'] = dumper.dump(target.field) + + # Rigid Body (rigid_body) + if target.rigid_body: + physics_data['rigid_body'] = dumper.dump(target.rigid_body) + + # Rigid Body constraint (rigid_body_constraint) + if target.rigid_body_constraint: + physics_data['rigid_body_constraint'] = dumper.dump(target.rigid_body_constraint) + + return physics_data + +def load_physics(dumped_settings: dict, target: bpy.types.Object): + """ Load all physics settings from a given object excluding modifier + related physics settings (such as softbody, cloth, dynapaint and fluid) + """ + loader = Loader() + + if 'collision' in dumped_settings: + loader.load(target.collision, dumped_settings['collision']) + + if 'field' in dumped_settings: + loader.load(target.field, dumped_settings['field']) + + if 'rigid_body' in dumped_settings: + if not target.rigid_body: + bpy.ops.rigidbody.object_add({"object": target}) + loader.load(target.rigid_body, dumped_settings['rigid_body']) + elif target.rigid_body: + bpy.ops.rigidbody.object_remove({"object": target}) + + if 'rigid_body_constraint' in dumped_settings: + if not target.rigid_body_constraint: + bpy.ops.rigidbody.constraint_add({"object": target}) + loader.load(target.rigid_body_constraint, dumped_settings['rigid_body_constraint']) + elif target.rigid_body_constraint: + bpy.ops.rigidbody.constraint_remove({"object": target}) + def dump_modifier_geometry_node_inputs(modifier: bpy.types.Modifier) -> list: """ Dump geometry node modifier input properties :arg modifier: geometry node modifier to dump :type modifier: bpy.type.Modifier """ - inputs_name = [p for p in dir(modifier) if "Input_" in p] - inputs_name.sort(key=get_input_index) dumped_inputs = [] - for inputs_index, input_name in enumerate(inputs_name): - input_value = modifier[input_name] + for inpt in get_node_group_inputs(modifier.node_group): + input_value = modifier[inpt.identifier] + dumped_input = None if isinstance(input_value, bpy.types.ID): dumped_input = input_value.uuid - elif type(input_value) in [int, str, float]: + elif isinstance(input_value, SUPPORTED_GEOMETRY_NODE_PARAMETERS): dumped_input = input_value elif hasattr(input_value, 'to_list'): dumped_input = input_value.to_list() @@ -73,18 +139,16 @@ def load_modifier_geometry_node_inputs(dumped_modifier: dict, target_modifier: b :type target_modifier: bpy.type.Modifier """ - inputs_name = [p for p in dir(target_modifier) if "Input_" in p] - inputs_name.sort(key=get_input_index) - for input_index, input_name in enumerate(inputs_name): + for input_index, inpt in enumerate(get_node_group_inputs(target_modifier.node_group)): dumped_value = dumped_modifier['inputs'][input_index] - input_value = target_modifier[input_name] - if type(input_value) in [int, str, float]: - input_value = dumped_value + input_value = target_modifier[inpt.identifier] + if isinstance(input_value, SUPPORTED_GEOMETRY_NODE_PARAMETERS): + target_modifier[inpt.identifier] = dumped_value elif hasattr(input_value, 'to_list'): for index in range(len(input_value)): input_value[index] = dumped_value[index] - else: - target_modifier[input_name] = get_datablock_from_uuid( + elif inpt.type in ['COLLECTION', 'OBJECT']: + target_modifier[inpt.identifier] = get_datablock_from_uuid( dumped_value, None) @@ -161,19 +225,24 @@ def find_textures_dependencies(modifiers: bpy.types.bpy_prop_collection) -> [bpy return textures -def find_geometry_nodes(modifiers: bpy.types.bpy_prop_collection) -> [bpy.types.NodeTree]: - """ Find geometry nodes group from a modifier stack +def find_geometry_nodes_dependencies(modifiers: bpy.types.bpy_prop_collection) -> [bpy.types.NodeTree]: + """ Find geometry nodes dependencies from a modifier stack :arg modifiers: modifiers collection :type modifiers: bpy.types.bpy_prop_collection :return: list of bpy.types.NodeTree pointers """ - nodes_groups = [] - for item in modifiers: - if item.type == 'NODES' and item.node_group: - nodes_groups.append(item.node_group) + dependencies = [] + for mod in modifiers: + if mod.type == 'NODES' and mod.node_group: + dependencies.append(mod.node_group) + # for inpt in get_node_group_inputs(mod.node_group): + # parameter = mod.get(inpt.identifier) + # if parameter and isinstance(parameter, bpy.types.ID): + # dependencies.append(parameter) + + return dependencies - return nodes_groups def dump_vertex_groups(src_object: bpy.types.Object) -> dict: """ Dump object's vertex groups @@ -219,6 +288,7 @@ def load_vertex_groups(dumped_vertex_groups: dict, target_object: bpy.types.Obje for index, weight in vg['vertices']: vertex_group.add([index], weight, 'REPLACE') + class BlObject(BlDatablock): bl_id = "objects" bl_class = bpy.types.Object @@ -301,9 +371,9 @@ class BlObject(BlDatablock): loader.load(target.display, data['display']) # Parenting - parent_id = data.get('parent_id') + parent_id = data.get('parent_uid') if parent_id: - parent = bpy.data.objects[parent_id] + parent = get_datablock_from_uuid(parent_id[0], bpy.data.objects[parent_id[1]]) # Avoid reloading if target.parent != parent and parent is not None: target.parent = parent @@ -354,21 +424,49 @@ class BlObject(BlDatablock): SKIN_DATA) if hasattr(target, 'cycles_visibility') \ - and 'cycles_visibility' in data: + and 'cycles_visibility' in data: loader.load(target.cycles_visibility, data['cycles_visibility']) # TODO: handle geometry nodes input from dump_anything if hasattr(target, 'modifiers'): - nodes_modifiers = [mod for mod in target.modifiers if mod.type == 'NODES'] + nodes_modifiers = [ + mod for mod in target.modifiers if mod.type == 'NODES'] for modifier in nodes_modifiers: - load_modifier_geometry_node_inputs(data['modifiers'][modifier.name], modifier) + load_modifier_geometry_node_inputs( + data['modifiers'][modifier.name], modifier) + + particles_modifiers = [ + mod for mod in target.modifiers if mod.type == 'PARTICLE_SYSTEM'] + + for mod in particles_modifiers: + default = mod.particle_system.settings + dumped_particles = data['modifiers'][mod.name]['particle_system'] + loader.load(mod.particle_system, dumped_particles) + + settings = get_datablock_from_uuid(dumped_particles['settings_uuid'], None) + if settings: + mod.particle_system.settings = settings + # Hack to remove the default generated particle settings + if not default.uuid: + bpy.data.particles.remove(default) + + phys_modifiers = [ + mod for mod in target.modifiers if mod.type in ['SOFT_BODY', 'CLOTH']] + + for mod in phys_modifiers: + loader.load(mod.settings, data['modifiers'][mod.name]['settings']) + + # PHYSICS + load_physics(data, target) transform = data.get('transforms', None) if transform: - target.matrix_parent_inverse = mathutils.Matrix(transform['matrix_parent_inverse']) + target.matrix_parent_inverse = mathutils.Matrix( + transform['matrix_parent_inverse']) target.matrix_basis = mathutils.Matrix(transform['matrix_basis']) target.matrix_local = mathutils.Matrix(transform['matrix_local']) + def _dump_implementation(self, data, instance=None): assert(instance) @@ -431,7 +529,7 @@ class BlObject(BlDatablock): # PARENTING if instance.parent: - data['parent_id'] = instance.parent.name + data['parent_uid'] = (instance.parent.uuid, instance.parent.name) # MODIFIERS if hasattr(instance, 'modifiers'): @@ -440,12 +538,29 @@ class BlObject(BlDatablock): if modifiers: dumper.include_filter = None dumper.depth = 1 + dumper.exclude_filter = ['is_active'] for index, modifier in enumerate(modifiers): - data["modifiers"][modifier.name] = dumper.dump(modifier) + dumped_modifier = dumper.dump(modifier) # hack to dump geometry nodes inputs if modifier.type == 'NODES': - dumped_inputs = dump_modifier_geometry_node_inputs(modifier) - data["modifiers"][modifier.name]['inputs'] = dumped_inputs + dumped_inputs = dump_modifier_geometry_node_inputs( + modifier) + dumped_modifier['inputs'] = dumped_inputs + + elif modifier.type == 'PARTICLE_SYSTEM': + dumper.exclude_filter = [ + "is_edited", + "is_editable", + "is_global_hair" + ] + dumped_modifier['particle_system'] = dumper.dump(modifier.particle_system) + dumped_modifier['particle_system']['settings_uuid'] = modifier.particle_system.settings.uuid + + elif modifier.type in ['SOFT_BODY', 'CLOTH']: + dumped_modifier['settings'] = dumper.dump(modifier.settings) + + data["modifiers"][modifier.name] = dumped_modifier + gp_modifiers = getattr(instance, 'grease_pencil_modifiers', None) if gp_modifiers: @@ -467,6 +582,7 @@ class BlObject(BlDatablock): 'location'] gp_mod_data['curve'] = curve_dumper.dump(modifier.curve) + # CONSTRAINTS if hasattr(instance, 'constraints'): dumper.include_filter = None @@ -511,7 +627,6 @@ class BlObject(BlDatablock): bone_groups[group.name] = dumper.dump(group) data['pose']['bone_groups'] = bone_groups - # VERTEx GROUP if len(instance.vertex_groups) > 0: data['vertex_groups'] = dump_vertex_groups(instance) @@ -548,7 +663,8 @@ class BlObject(BlDatablock): if hasattr(object_data, 'skin_vertices') and object_data.skin_vertices: skin_vertices = list() for skin_data in object_data.skin_vertices: - skin_vertices.append(np_dump_collection(skin_data.data, SKIN_DATA)) + skin_vertices.append( + np_dump_collection(skin_data.data, SKIN_DATA)) data['skin_vertices'] = skin_vertices # CYCLE SETTINGS @@ -563,6 +679,9 @@ class BlObject(BlDatablock): ] data['cycles_visibility'] = dumper.dump(instance.cycles_visibility) + # PHYSICS + data.update(dump_physics(instance)) + return data def _resolve_deps_implementation(self): @@ -572,10 +691,14 @@ class BlObject(BlDatablock): if self.instance.data: deps.append(self.instance.data) + # Particle systems + for particle_slot in self.instance.particle_systems: + deps.append(particle_slot.settings) + if self.is_library: deps.append(self.instance.library) - if self.instance.parent : + if self.instance.parent: deps.append(self.instance.parent) if self.instance.instance_type == 'COLLECTION': @@ -584,6 +707,6 @@ class BlObject(BlDatablock): if self.instance.modifiers: deps.extend(find_textures_dependencies(self.instance.modifiers)) - deps.extend(find_geometry_nodes(self.instance.modifiers)) + deps.extend(find_geometry_nodes_dependencies(self.instance.modifiers)) return deps diff --git a/multi_user/bl_types/bl_particle.py b/multi_user/bl_types/bl_particle.py new file mode 100644 index 0000000..2ec6fac --- /dev/null +++ b/multi_user/bl_types/bl_particle.py @@ -0,0 +1,90 @@ +import bpy +import mathutils + +from . import dump_anything +from .bl_datablock import BlDatablock, get_datablock_from_uuid + + +def dump_textures_slots(texture_slots: bpy.types.bpy_prop_collection) -> list: + """ Dump every texture slot collection as the form: + [(index, slot_texture_uuid, slot_texture_name), (), ...] + """ + dumped_slots = [] + for index, slot in enumerate(texture_slots): + if slot and slot.texture: + dumped_slots.append((index, slot.texture.uuid, slot.texture.name)) + + return dumped_slots + + +def load_texture_slots(dumped_slots: list, target_slots: bpy.types.bpy_prop_collection): + """ + """ + for index, slot in enumerate(target_slots): + if slot: + target_slots.clear(index) + + for index, slot_uuid, slot_name in dumped_slots: + target_slots.create(index).texture = get_datablock_from_uuid( + slot_uuid, slot_name + ) + +IGNORED_ATTR = [ + "is_embedded_data", + "is_evaluated", + "is_fluid", + "is_library_indirect", + "users" +] + +class BlParticle(BlDatablock): + bl_id = "particles" + bl_class = bpy.types.ParticleSettings + bl_icon = "PARTICLES" + bl_check_common = False + bl_reload_parent = False + + def _construct(self, data): + instance = bpy.data.particles.new(data["name"]) + instance.uuid = self.uuid + return instance + + def _load_implementation(self, data, target): + dump_anything.load(target, data) + + dump_anything.load(target.effector_weights, data["effector_weights"]) + + # Force field + force_field_1 = data.get("force_field_1", None) + if force_field_1: + dump_anything.load(target.force_field_1, force_field_1) + + force_field_2 = data.get("force_field_2", None) + if force_field_2: + dump_anything.load(target.force_field_2, force_field_2) + + # Texture slots + load_texture_slots(data["texture_slots"], target.texture_slots) + + def _dump_implementation(self, data, instance=None): + assert instance + + dumper = dump_anything.Dumper() + dumper.depth = 1 + dumper.exclude_filter = IGNORED_ATTR + data = dumper.dump(instance) + + # Particle effectors + data["effector_weights"] = dumper.dump(instance.effector_weights) + if instance.force_field_1: + data["force_field_1"] = dumper.dump(instance.force_field_1) + if instance.force_field_2: + data["force_field_2"] = dumper.dump(instance.force_field_2) + + # Texture slots + data["texture_slots"] = dump_textures_slots(instance.texture_slots) + + return data + + def _resolve_deps_implementation(self): + return [t.texture for t in self.instance.texture_slots if t and t.texture] diff --git a/multi_user/bl_types/dump_anything.py b/multi_user/bl_types/dump_anything.py index 4765fbf..2b97ecb 100644 --- a/multi_user/bl_types/dump_anything.py +++ b/multi_user/bl_types/dump_anything.py @@ -610,6 +610,8 @@ class Loader: instance.write(bpy.data.fonts.get(dump)) elif isinstance(rna_property_type, T.Sound): instance.write(bpy.data.sounds.get(dump)) + # elif isinstance(rna_property_type, T.ParticleSettings): + # instance.write(bpy.data.particles.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 new file mode 160000 index 0000000..001fbdc --- /dev/null +++ b/multi_user/libs/replication @@ -0,0 +1 @@ +Subproject commit 001fbdc60da58a5e3b7006f1d782d6f472c12809 diff --git a/multi_user/operators.py b/multi_user/operators.py index 9a99097..8afce01 100644 --- a/multi_user/operators.py +++ b/multi_user/operators.py @@ -213,8 +213,6 @@ class SessionStartOperator(bpy.types.Operator): type_module_class, check_common=type_module_class.bl_check_common) - deleyables.append(timers.ApplyTimer(timeout=settings.depsgraph_update_rate)) - if bpy.app.version[1] >= 91: python_binary_path = sys.executable else: @@ -272,6 +270,11 @@ class SessionStartOperator(bpy.types.Operator): # Background client updates service deleyables.append(timers.ClientUpdate()) deleyables.append(timers.DynamicRightSelectTimer()) + deleyables.append(timers.ApplyTimer(timeout=settings.depsgraph_update_rate)) + # deleyables.append(timers.PushTimer( + # queue=stagging, + # timeout=settings.depsgraph_update_rate + # )) session_update = timers.SessionStatusUpdate() session_user_sync = timers.SessionUserSync() session_background_executor = timers.MainThreadExecutor( diff --git a/multi_user/preferences.py b/multi_user/preferences.py index 7df200b..7ebc2a8 100644 --- a/multi_user/preferences.py +++ b/multi_user/preferences.py @@ -181,7 +181,7 @@ class SessionPrefs(bpy.types.AddonPreferences): connection_timeout: bpy.props.IntProperty( name='connection timeout', description='connection timeout before disconnection', - default=1000 + default=5000 ) # Replication update settings depsgraph_update_rate: bpy.props.FloatProperty( diff --git a/multi_user/timers.py b/multi_user/timers.py index 22e6a64..c184b4c 100644 --- a/multi_user/timers.py +++ b/multi_user/timers.py @@ -18,7 +18,6 @@ import logging import sys import traceback - import bpy from replication.constants import (FETCHED, RP_COMMON, STATE_ACTIVE, STATE_INITIAL, STATE_LOBBY, STATE_QUITTING, @@ -118,6 +117,7 @@ class ApplyTimer(Timer): try: apply(session.repository, node) except Exception as e: + logging.error(f"Fail to apply {node_ref.uuid}") traceback.print_exc() else: if node_ref.bl_reload_parent: diff --git a/scripts/test_addon.py b/scripts/test_addon.py index 2f524dd..96575a9 100644 --- a/scripts/test_addon.py +++ b/scripts/test_addon.py @@ -13,7 +13,7 @@ def main(): if len(sys.argv) > 2: blender_rev = sys.argv[2] else: - blender_rev = "2.91.0" + blender_rev = "2.92.0" try: exit_val = BAT.test_blender_addon(addon_path=addon, blender_revision=blender_rev) diff --git a/tests/test_bl_types/test_action.py b/tests/test_bl_types/test_action.py index 3659777..0c95b8c 100644 --- a/tests/test_bl_types/test_action.py +++ b/tests/test_bl_types/test_action.py @@ -8,6 +8,7 @@ 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'] +FMODIFIERS = ['GENERATOR', 'FNGENERATOR', 'ENVELOPE', 'CYCLES', 'NOISE', 'LIMITS', 'STEPPED'] # @pytest.mark.parametrize('blendname', ['test_action.blend']) def test_action(clear_blend): @@ -22,6 +23,9 @@ def test_action(clear_blend): point.co[1] = random.randint(-10,10) point.interpolation = INTERPOLATION[random.randint(0, len(INTERPOLATION)-1)] + for mod_type in FMODIFIERS: + fcurve_sample.modifiers.new(mod_type) + bpy.ops.mesh.primitive_plane_add() bpy.data.objects[0].animation_data_create() bpy.data.objects[0].animation_data.action = datablock diff --git a/tests/test_bl_types/test_object.py b/tests/test_bl_types/test_object.py index f73b848..db63981 100644 --- a/tests/test_bl_types/test_object.py +++ b/tests/test_bl_types/test_object.py @@ -7,7 +7,7 @@ import bpy import random from multi_user.bl_types.bl_object import BlObject -# Removed 'BUILD' modifier because the seed doesn't seems to be +# Removed 'BUILD', 'SOFT_BODY' modifier because the seed doesn't seems to be # correctly initialized (#TODO: report the bug) MOFIFIERS_TYPES = [ 'DATA_TRANSFER', 'MESH_CACHE', 'MESH_SEQUENCE_CACHE', @@ -22,8 +22,7 @@ MOFIFIERS_TYPES = [ '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'] + 'EXPLODE', 'FLUID', 'OCEAN', 'PARTICLE_INSTANCE', 'SURFACE'] GP_MODIFIERS_TYPE = [ 'GP_ARRAY', 'GP_BUILD', 'GP_MIRROR', 'GP_MULTIPLY', @@ -72,5 +71,5 @@ def test_object(clear_blend): test = implementation._construct(expected) implementation._load(expected, test) result = implementation._dump(test) - + print(DeepDiff(expected, result)) assert not DeepDiff(expected, result)