diff --git a/README.md b/README.md index 810d88b..f342028 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ ![](images/logo_as_path.svg) -This is an addon for Blender 3.1+ (might work with 2.8+ but has not been tested) that enables loading of file sequences. The addon comes bundled together with [meshio](https://github.com/nschloe/meshio) which enables the loading of geometric data from a multitude of file formats. As stated there, the supported formats are listed in the following. Note that not all of the formats have been tested and some issues may still occur. +![GitHub release (latest by date)](https://img.shields.io/github/v/release/InteractiveComputerGraphics/blender-sequence-loader) +[![Documentation Status](https://readthedocs.org/projects/blender-sequence-loader/badge/?version=latest)](https://blender-sequence-loader.readthedocs.io/en/latest/?badge=latest) + +This is an addon for Blender 3.4+ (might work with 2.8+ but is not extensively tested on less recent versions) that enables loading of file sequences. The addon comes bundled together with [meshio](https://github.com/nschloe/meshio) which enables the loading of geometric data from a multitude of file formats. As stated there, the supported formats are listed in the following. Note that not all of the formats have been tested and some issues may still occur. > [Abaqus](http://abaqus.software.polimi.it/v6.14/index.html) (`.inp`), > ANSYS msh (`.msh`), diff --git a/__init__.py b/__init__.py index 2f08102..cb4bed4 100644 --- a/__init__.py +++ b/__init__.py @@ -2,8 +2,8 @@ "name": "Sequence Loader", "description": "Loader for meshio supported mesh files/ simulation sequences", "author": "Interactive Computer Graphics", - "version": (0, 1, 3), - "blender": (3, 1, 0), + "version": (0, 1, 4), + "blender": (3, 4, 0), "warning": "", "support": "COMMUNITY", "category": "Import-Export", @@ -21,6 +21,7 @@ bpy.context.preferences.filepaths.use_relative_paths = False from bseq import * +from bseq.operators import menu_func_import classes = [ BSEQ_obj_property, @@ -42,6 +43,11 @@ BSEQ_OT_disable_selected, BSEQ_OT_enable_selected, BSEQ_OT_refresh_seq, + BSEQ_OT_disable_all, + BSEQ_OT_enable_all, + BSEQ_OT_refresh_sequences, + WM_OT_batchSequences, + WM_OT_MeshioObject ] @@ -54,6 +60,7 @@ def register(): bpy.types.Object.BSEQ = bpy.props.PointerProperty(type=BSEQ_obj_property) bpy.types.Mesh.BSEQ = bpy.props.PointerProperty(type=BSEQ_mesh_property) + bpy.types.TOPBAR_MT_file_import.append(menu_func_import) # manually call this function once # so when addon being installed, it can run correctly @@ -67,6 +74,7 @@ def unregister(): del bpy.types.Scene.BSEQ del bpy.types.Object.BSEQ bpy.app.handlers.load_post.remove(BSEQ_initialize) + bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) unsubscribe_to_selected() diff --git a/bseq/__init__.py b/bseq/__init__.py index edaf58c..361914c 100644 --- a/bseq/__init__.py +++ b/bseq/__init__.py @@ -1,5 +1,5 @@ from bseq.utils import refresh_obj -from .operators import BSEQ_OT_load, BSEQ_OT_edit, BSEQ_OT_resetpt, BSEQ_OT_resetmesh, BSEQ_OT_resetins, BSEQ_OT_set_as_split_norm, BSEQ_OT_remove_split_norm, BSEQ_OT_disable_selected, BSEQ_OT_enable_selected, BSEQ_OT_refresh_seq +from .operators import BSEQ_OT_load, BSEQ_OT_edit, BSEQ_OT_resetpt, BSEQ_OT_resetmesh, BSEQ_OT_resetins, BSEQ_OT_set_as_split_norm, BSEQ_OT_remove_split_norm, BSEQ_OT_disable_selected, BSEQ_OT_enable_selected, BSEQ_OT_refresh_seq, BSEQ_OT_disable_all, BSEQ_OT_enable_all, BSEQ_OT_refresh_sequences, WM_OT_batchSequences, WM_OT_MeshioObject from .properties import BSEQ_scene_property, BSEQ_obj_property, BSEQ_mesh_property from .panels import BSEQ_UL_Obj_List, BSEQ_List_Panel, BSEQ_Settings, BSEQ_Import, BSEQ_Templates, BSEQ_UL_Att_List, draw_template from .messenger import subscribe_to_selected, unsubscribe_to_selected @@ -43,4 +43,9 @@ def BSEQ_initialize(scene): "BSEQ_OT_disable_selected", "BSEQ_OT_enable_selected", "BSEQ_OT_refresh_seq", + "BSEQ_OT_disable_all", + "BSEQ_OT_enable_all", + "BSEQ_OT_refresh_sequences", + "WM_OT_batchSequences", + "WM_OT_MeshioObject" ] diff --git a/bseq/callback.py b/bseq/callback.py index a2c6ece..e89bb6b 100644 --- a/bseq/callback.py +++ b/bseq/callback.py @@ -3,6 +3,7 @@ # Code here are mostly about the callback/update/items functions used in properties.py +file_sequences = [] def update_path(self, context): # When the path has been changed, reset the selected sequence to None @@ -10,12 +11,10 @@ def update_path(self, context): context.scene.BSEQ.use_pattern = False context.scene.BSEQ.pattern = "" - -def item_fileseq(self, context): ''' Detects all the file sequences in the directory ''' - + p = context.scene.BSEQ.path try: f = fileseq.findSequencesOnDisk(p) @@ -24,15 +23,19 @@ def item_fileseq(self, context): if not f: return [("None", "No sequence detected", "", 1)] - file_seq = [] + + file_sequences.clear() if len(f) >= 20: - file_seq.append(("None", "Too much sequence detected, could be false detection, please use pattern below", "", 1)) + file_sequences.append(("None", "Too much sequence detected, could be false detection, please use pattern below", "", 1)) else: count = 1 for seq in f: - file_seq.append((str(seq), seq.basename() + "@" + seq.extension(), "", count)) + file_sequences.append((str(seq), seq.basename() + "@" + seq.extension(), "", count)) count += 1 - return file_seq + + +def item_fileseq(self, context): + return file_sequences def update_selected_obj_num(self, context): diff --git a/bseq/globals.py b/bseq/globals.py index 5577a71..e875ff0 100644 --- a/bseq/globals.py +++ b/bseq/globals.py @@ -38,4 +38,4 @@ def auto_refresh(scene, depsgraph=None): continue if obj.mode != "OBJECT": continue - refresh_obj(obj) \ No newline at end of file + refresh_obj(obj, scene) \ No newline at end of file diff --git a/bseq/importer.py b/bseq/importer.py index 640971b..d5dbc93 100644 --- a/bseq/importer.py +++ b/bseq/importer.py @@ -1,7 +1,9 @@ import bpy +import mathutils import meshio import traceback import fileseq +import os from .utils import show_message_box import numpy as np from mathutils import Matrix @@ -51,6 +53,37 @@ def extract_faces(cell: meshio.CellBlock): show_message_box(cell.type + " is unsupported mesh format yet") return np.array([]) +def has_keyframe(obj, attr): + animdata = obj.animation_data + if animdata is not None and animdata.action is not None: + for fcurve in animdata.action.fcurves: + if fcurve.data_path == attr: + return len(fcurve.keyframe_points) > 0 + return False + +def apply_transformation(meshio_mesh, obj, depsgraph): + # evaluate the keyframe animation system + eval_location = obj.evaluated_get(depsgraph).location if has_keyframe(obj, "location") else None + eval_scale = obj.evaluated_get(depsgraph).scale if has_keyframe(obj, "scale") else None + + if has_keyframe(obj, "rotation_quaternion"): + eval_rotation = obj.evaluated_get(depsgraph).rotation_quaternion + elif has_keyframe(obj, "rotation_axis_angle"): + eval_rotation = obj.evaluated_get(depsgraph).rotation_axis_angle + elif has_keyframe(obj, "rotation_euler"): + eval_rotation = obj.evaluated_get(depsgraph).rotation_euler + else: + eval_rotation = None + + eval_transform_matrix = mathutils.Matrix.LocRotScale(eval_location, eval_rotation, eval_scale) + + # evaluate the rigid body transformations (only relevant for .bin format) + rigid_body_transformation = mathutils.Matrix.Identity(4) + if meshio_mesh.field_data.get("transformation_matrix") is not None: + rigid_body_transformation = meshio_mesh.field_data["transformation_matrix"] + + # multiply everything together (with custom transform matrix) + obj.matrix_world = rigid_body_transformation @ obj.BSEQ.initial_transform_matrix @ eval_transform_matrix def update_mesh(meshio_mesh, mesh): # extract information from the meshio mesh @@ -59,7 +92,8 @@ def update_mesh(meshio_mesh, mesh): n_poly = 0 n_loop = 0 n_verts = len(mesh_vertices) - + if n_verts == 0: + return faces_loop_start = np.array([], dtype=np.uint64) faces_loop_total = np.array([], dtype=np.uint64) loops_vert_idx = np.array([], dtype=np.uint64) @@ -136,8 +170,27 @@ def update_mesh(meshio_mesh, mesh): mesh.use_auto_smooth = True mesh.normals_split_custom_set_from_vertices(v) +# function to create a single meshio object +def create_meshio_obj(filepath): + meshio_mesh = None + try: + meshio_mesh = meshio.read(filepath) + except Exception as e: + show_message_box("Error when reading: " + filepath + ",\n" + traceback.format_exc(), + "Meshio Loading Error" + str(e), + icon="ERROR") + + # create the object + name = os.path.basename(filepath) + mesh = bpy.data.meshes.new(name) + object = bpy.data.objects.new(name, mesh) + update_mesh(meshio_mesh, object.data) + bpy.context.collection.objects.link(object) + bpy.ops.object.select_all(action="DESELECT") + bpy.context.view_layer.objects.active = object + -def create_obj(fileseq, use_relaitve, transform_matrix=Matrix([[1, 0, 0, 0], [0, 0, -1, 0], [0, 1, 0, 0], [0, 0, 0, 1]])): +def create_obj(fileseq, use_relative, root_path, transform_matrix=Matrix([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]])): current_frame = bpy.context.scene.frame_current filepath = fileseq[current_frame % len(fileseq)] @@ -156,14 +209,18 @@ def create_obj(fileseq, use_relaitve, transform_matrix=Matrix([[1, 0, 0, 0], [0, name = fileseq.basename() + "@" + fileseq.extension() mesh = bpy.data.meshes.new(name) object = bpy.data.objects.new(name, mesh) - object.BSEQ.use_relative = use_relaitve - if use_relaitve: - object.BSEQ.pattern = bpy.path.relpath(str(fileseq)) + object.BSEQ.use_relative = use_relative + if use_relative: + if root_path != "": + object.BSEQ.pattern = bpy.path.relpath(str(fileseq), start=root_path) + else: + object.BSEQ.pattern = bpy.path.relpath(str(fileseq)) else: object.BSEQ.pattern = str(fileseq) object.BSEQ.init = True object.BSEQ.enabled = enabled - object.matrix_world = transform_matrix + # Flatten custom transformation matrix for the property + object.BSEQ.initial_transform_matrix = [transform_matrix[j][i] for i in range(4) for j in range(4)] driver = object.driver_add("BSEQ.frame") driver.driver.expression = 'frame' if enabled: @@ -191,7 +248,11 @@ def update_obj(scene, depsgraph=None): meshio_mesh = None pattern = obj.BSEQ.pattern if obj.BSEQ.use_relative: - pattern = bpy.path.abspath(pattern) + if scene.BSEQ.root_path != "": + pattern = bpy.path.abspath(pattern, start=scene.BSEQ.root_path) + else: + pattern = bpy.path.abspath(pattern) + # in case the blender file was created on windows system, but opened in linux system pattern = bpy.path.native_pathsep(pattern) fs = fileseq.FileSequence(pattern) @@ -239,3 +300,6 @@ def update_obj(scene, depsgraph=None): show_message_box('function preprocess does not return meshio object', "ERROR") continue update_mesh(meshio_mesh, obj.data) + + apply_transformation(meshio_mesh, obj, depsgraph) + \ No newline at end of file diff --git a/bseq/operators.py b/bseq/operators.py index 05592d1..0190329 100644 --- a/bseq/operators.py +++ b/bseq/operators.py @@ -1,9 +1,10 @@ import bpy +from mathutils import Matrix import fileseq from .messenger import * import traceback from .utils import refresh_obj, show_message_box -from .importer import create_obj +from .importer import create_obj, create_meshio_obj import numpy as np @@ -41,7 +42,13 @@ def execute(self, context): show_message_box(traceback.format_exc(), "Can't find sequence: " + str(fs), "ERROR") return {"CANCELLED"} - create_obj(fs, importer_prop.relative) + transform_matrix = (Matrix.LocRotScale( + importer_prop.custom_location, + importer_prop.custom_rotation, + importer_prop.custom_scale) + if importer_prop.use_custom_transform else Matrix.Identity(4)) + + create_obj(fs, importer_prop.relative, importer_prop.root_path, transform_matrix=transform_matrix) return {"FINISHED"} @@ -270,6 +277,95 @@ class BSEQ_OT_refresh_seq(bpy.types.Operator): def execute(self, context): scene = context.scene obj = bpy.data.objects[scene.BSEQ.selected_obj_num] - refresh_obj(obj) + refresh_obj(obj, scene) + + return {"FINISHED"} + +class BSEQ_OT_disable_all(bpy.types.Operator): + '''This operator disable all selected sequence''' + bl_label = "Disable All Sequences" + bl_idname = "bseq.disableall" + bl_options = {"UNDO"} + + def execute(self, context): + for obj in bpy.context.scene.collection.all_objects: + if obj.BSEQ.init and obj.BSEQ.enabled: + obj.BSEQ.enabled = False + return {"FINISHED"} + +class BSEQ_OT_enable_all(bpy.types.Operator): + '''This operator enable all selected sequence''' + bl_label = "Enable All Sequences" + bl_idname = "bseq.enableall" + bl_options = {"UNDO"} + + def execute(self, context): + for obj in bpy.context.scene.collection.all_objects: + if obj.BSEQ.init and not obj.BSEQ.enabled: + obj.BSEQ.enabled = True + return {"FINISHED"} +class BSEQ_OT_refresh_sequences(bpy.types.Operator): + '''This operator refreshes all found sequences''' + bl_label = "" #"Refresh Found Sequences" + bl_idname = "bseq.refreshseqs" + bl_options = {"UNDO"} + + def execute(self, context): + scene = context.scene + # call the update function of path by setting it to its own value + scene.BSEQ.path = scene.BSEQ.path return {"FINISHED"} + +from pathlib import Path +import meshio +from bpy_extras.io_utils import ImportHelper + +class WM_OT_batchSequences(bpy.types.Operator, ImportHelper): + """Batch Import Sequences""" + bl_idname = "wm.seq_import_batch" + bl_label = "Import multiple sequences" + bl_options = {'PRESET', 'UNDO'} + + files: bpy.props.CollectionProperty(type=bpy.types.PropertyGroup) + + def execute(self, context): + scene = context.scene + importer_prop = scene.BSEQ + + folder = Path(self.filepath) + used_seqs = set() + + for selection in self.files: + # Check if there exists a matching file sequence for every selection + fp = str(Path(folder.parent, selection.name)) + seqs = fileseq.findSequencesOnDisk(str(folder.parent)) + matching_seqs = [s for s in seqs if fp in list(s) and s not in used_seqs] + + if matching_seqs: + transform_matrix = (Matrix.LocRotScale(importer_prop.custom_location, importer_prop.custom_rotation, importer_prop.custom_scale) + if importer_prop.use_custom_transform else Matrix.Identity(4)) + create_obj(matching_seqs[0], False, importer_prop.root_path, transform_matrix=transform_matrix) + used_seqs.add(matching_seqs[0]) + return {'FINISHED'} + +class WM_OT_MeshioObject(bpy.types.Operator, ImportHelper): + """Batch Import Meshio Objects""" + bl_idname = "wm.meshio_import_batch" + bl_label = "Import multiple Meshio objects" + bl_options = {'PRESET', 'UNDO'} + + files: bpy.props.CollectionProperty(type=bpy.types.PropertyGroup) + + def execute(self, context): + folder = Path(self.filepath) + + for selection in self.files: + fp = Path(folder.parent, selection.name) + create_meshio_obj(str(fp)) + return {'FINISHED'} + +def menu_func_import(self, context): + self.layout.operator( + WM_OT_MeshioObject.bl_idname, + text="MeshIO Object") diff --git a/bseq/panels.py b/bseq/panels.py index 4ef50e5..c35ec7a 100644 --- a/bseq/panels.py +++ b/bseq/panels.py @@ -74,6 +74,10 @@ def draw(self, context): row.operator("bseq.enableselected", text="Enable Selected") row.operator("bseq.disableselected", text="Disable Selected") row.operator("bseq.refresh", text="Refresh") + row = layout.row() + row.operator("bseq.enableall", text="Enable All") + row.operator("bseq.disableall", text="Disable All") + class BSEQ_Settings(bpy.types.Panel): @@ -187,12 +191,23 @@ def draw(self, context): if importer_prop.use_pattern: col2.prop(importer_prop, "pattern", text="") else: - col2.prop(importer_prop, "fileseq", text="") + split2 = col2.split(factor=0.75) + col3 = split2.column() + col4 = split2.column() + col3.prop(importer_prop, "fileseq", text="") + col4.operator("bseq.refreshseqs", icon="FILE_REFRESH") col1.label(text="Use Relative Path") col2.prop(importer_prop, "relative", text="") + if importer_prop.relative: + col1.label(text="Root Directory") + col2.prop(importer_prop, "root_path", text="") + layout.operator("sequence.load") + + layout.operator("wm.seq_import_batch") + split = layout.split() col1 = split.column() col2 = split.column() @@ -219,7 +234,19 @@ def draw(self, context): col2.prop(importer_prop, "print", text="") col1.label(text="Auto refresh all the sequence every frame") col2.prop(importer_prop, "auto_refresh", text="") + col1.label(text="Use custom transformation matrix") + col2.prop(importer_prop, "use_custom_transform", text="") + + if importer_prop.use_custom_transform: + box.label(text="Location:") + box.prop(importer_prop, "custom_location", text="") + + box.label(text="Rotation:") + box.prop(importer_prop, "custom_rotation", text="") + box.label(text="Scale:") + box.prop(importer_prop, "custom_scale", text="") + class BSEQ_Templates(bpy.types.Menu): ''' diff --git a/bseq/properties.py b/bseq/properties.py index 73e1ded..d839791 100644 --- a/bseq/properties.py +++ b/bseq/properties.py @@ -1,6 +1,6 @@ import bpy from .callback import * - +from mathutils import Matrix class BSEQ_scene_property(bpy.types.PropertyGroup): path: bpy.props.StringProperty(name="Directory", @@ -8,6 +8,10 @@ class BSEQ_scene_property(bpy.types.PropertyGroup): description="You need to go to the folder with the sequence, then click \"Accept\". ", update=update_path) relative: bpy.props.BoolProperty(name='Use relative path', description="whether or not to use reletive path", default=False) + root_path: bpy.props.StringProperty(name="Root Directory", + subtype="DIR_PATH", + description="Select a root folder for all relative paths. When not set the current filename is used.", + update=update_path) fileseq: bpy.props.EnumProperty( name="File Sequences", description="Please choose the file sequences you want", @@ -18,6 +22,10 @@ class BSEQ_scene_property(bpy.types.PropertyGroup): default=False) pattern: bpy.props.StringProperty(name="Pattern", description="You can specify the pattern here, in case the sequence can't be deteced.") + + file_paths: bpy.props.StringProperty(name="File", + subtype="FILE_PATH", + description="Select a root folder for all relative paths. When not set the current filename is used.") selected_obj_deselectall_flag: bpy.props.BoolProperty(default=True, description="the flag to determine whether call deselect all or not ") @@ -44,7 +52,27 @@ class BSEQ_scene_property(bpy.types.PropertyGroup): auto_refresh: bpy.props.BoolProperty(name='auto refresh', description="whether or not to auto refresh all the sequence every frame", default=False) + + use_custom_transform: bpy.props.BoolProperty(name='Use custom transformation matrix', + description="Whether or not to use a custom transformation matrix", + default=False) + custom_location: bpy.props.FloatVectorProperty(name='Custom Location', + description='Set custom location vector', + size=3, + subtype="TRANSLATION") + + custom_rotation: bpy.props.FloatVectorProperty(name='Custom Rotation', + description='Set custom rotation vector', + size=3, + subtype="EULER", + default=[0,0,0]) + + custom_scale: bpy.props.FloatVectorProperty(name='Custom Scale', + description='Set custom scaling vector', + size=3, + subtype="COORDINATES", + default=[1,1,1]) class BSEQ_obj_property(bpy.types.PropertyGroup): init: bpy.props.BoolProperty(default=False) @@ -55,7 +83,10 @@ class BSEQ_obj_property(bpy.types.PropertyGroup): use_relative: bpy.props.BoolProperty(default=False) pattern: bpy.props.StringProperty() frame: bpy.props.IntProperty() - + initial_transform_matrix: bpy.props.FloatVectorProperty(name='Custom Transformation Matrix', + description='Set custom transformation', + size=16, + subtype="MATRIX") # set this property for mesh, not object (maybe change later?) class BSEQ_mesh_property(bpy.types.PropertyGroup): diff --git a/bseq/utils.py b/bseq/utils.py index 775ee6b..af686b6 100644 --- a/bseq/utils.py +++ b/bseq/utils.py @@ -26,13 +26,13 @@ def stop_animation(): -def refresh_obj(obj): +def refresh_obj(obj, scene): fs = obj.BSEQ.pattern if obj.BSEQ.use_relative: - fs = bpy.path.abspath(fs) + fs = bpy.path.abspath(fs, start=scene.BSEQ.root_path) fs = fileseq.findSequenceOnDisk(fs) fs = fileseq.findSequenceOnDisk(fs.dirname() + fs.basename() + "@" + fs.extension()) fs = str(fs) if obj.BSEQ.use_relative: - fs = bpy.path.relpath(fs) + fs = bpy.path.relpath(fs, start=scene.BSEQ.root_path) obj.BSEQ.pattern = fs \ No newline at end of file