diff --git a/README.md b/README.md index 8768e28..8c4f186 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Stop Motion OBJ [Tutorial](https://github.com/neverhood311/Stop-motion-OBJ/wiki#quick-start) | [Forum](https://blenderartists.org/t/stop-motion-obj-obj-stl-ply-sequence-importer/670105) | [Documentation](https://github.com/neverhood311/Stop-motion-OBJ/wiki) | [How to Support](#how-to-support) -Stop Motion OBJ is a tool that lets you import a sequence of mesh files (.obj, .stl, or .ply), play them back in real time, then render them out to an animation. Each mesh may have a different vertex count, poly count, and even different UVs. This is especially useful for importing fluid simulations, for visualization of scientific data, and for rendering of 4D scan data. +Stop Motion OBJ is a tool that lets you import a sequence of mesh files (.obj, .stl, .ply, .x3d, or .wrl), play them back in real time, then render them out to an animation. Each mesh may have a different vertex count, poly count, and even different UVs. This is especially useful for importing fluid simulations, for visualization of scientific data, and for rendering of 4D scan data. Once a sequence is imported, you may perform many of the same operations on the sequence that you would on a single mesh. Many of the Object Modifiers work on the entire mesh sequence. Mesh sequences may be rendered in both Cycles and Eevee. @@ -20,10 +20,12 @@ Stop Motion OBJ is able to import very complex mesh sequences that cannot fit in # Download & Install -For Blender 2.80+, download the latest release [here](https://github.com/neverhood311/Stop-motion-OBJ/releases/latest). Make sure to download the file named Stop-motion-OBJ-v2.x.x.zip (don't download either of the “Source code” files). +For Blender 2.92+, download the latest release [here](https://github.com/neverhood311/Stop-motion-OBJ/releases/latest). Make sure to download the file named Stop-motion-OBJ-v2.x.x.zip (don't download either of the “Source code” files). To install, just follow the normal procedure for installing Blender addons. Open Blender and click Edit > Preferences... > Add-ons. Then click Install… and find the .zip file you previously downloaded. Once you’ve enabled the add-on, it should be ready to use immediately. +For Blender 2.91 and earlier, download [r2.1.1](https://github.com/neverhood311/Stop-motion-OBJ/releases/tag/v2.1.1) + For Blender 2.79 and earlier, download [r1.1.1](https://github.com/neverhood311/Stop-motion-OBJ/releases/tag/0.2.79.2). Note that this version is deprecated and will not be supported. # Quick Start Tutorial diff --git a/src/__init__.py b/src/__init__.py index d3a556f..3554a89 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,7 +1,7 @@ # ##### BEGIN GPL LICENSE BLOCK ##### # # Stop motion OBJ: A Mesh sequence importer for Blender -# Copyright (C) 2016-2020 Justin Jensen +# Copyright (C) 2016-2024 Justin Jensen # # 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 @@ -23,10 +23,10 @@ bl_info = { "name": "Stop motion OBJ", - "description": "Import a sequence of OBJ (or STL or PLY) files and display them each as a single frame of animation. This add-on also supports the .STL and .PLY file formats.", + "description": "Import a sequence of OBJ (or STL or PLY or X3D or VRML2) files and display them each as a single frame of animation. This add-on also supports the .STL, .PLY, .X3D, and .WRL file formats.", "author": "Justin Jensen", - "version": (2, 1, 1), - "blender": (2, 80, 0), + "version": (2, 2, 0, "beta.1"), + "blender": (2, 92, 0), "location": "File > Import > Mesh Sequence", "warning": "", "category": "Import", @@ -34,9 +34,14 @@ "tracker_url": "https://github.com/neverhood311/Stop-motion-OBJ/issues" } +SMOKeymaps = [] def register(): + bpy.app.handlers.frame_change_pre.append(checkMeshChangesFrameChangePre) + bpy.app.handlers.frame_change_post.append(checkMeshChangesFrameChangePost) + bpy.types.Mesh.inMeshSequence = bpy.props.BoolProperty() + bpy.types.Mesh.meshHash = bpy.props.StringProperty() bpy.utils.register_class(SequenceVersion) bpy.utils.register_class(MeshImporter) bpy.utils.register_class(MeshNameProp) @@ -44,20 +49,29 @@ def register(): bpy.types.Object.mesh_sequence_settings = bpy.props.PointerProperty(type=MeshSequenceSettings) bpy.app.handlers.load_post.append(initializeSequences) bpy.app.handlers.frame_change_pre.append(updateFrame) + + # note: Blender tends to crash in Rendered viewport mode if we set the depsgraph_update_post instead of depsgraph_update_pre + bpy.app.handlers.depsgraph_update_pre.append(updateFrame) bpy.utils.register_class(ReloadMeshSequence) bpy.utils.register_class(BatchShadeSmooth) bpy.utils.register_class(BatchShadeFlat) bpy.utils.register_class(BakeMeshSequence) bpy.utils.register_class(DeepDeleteSequence) + bpy.utils.register_class(MergeDuplicateMaterials) + bpy.utils.register_class(ConvertToMeshSequence) + bpy.utils.register_class(DuplicateMeshFrame) bpy.utils.register_class(SMO_PT_MeshSequencePanel) + # note: the order of the next few panels is the order they appear in the UI bpy.utils.register_class(SMO_PT_MeshSequencePlaybackPanel) bpy.utils.register_class(SMO_PT_MeshSequenceStreamingPanel) + bpy.utils.register_class(SMO_PT_MeshSequenceExportPanel) bpy.utils.register_class(SMO_PT_MeshSequenceAdvancedPanel) bpy.app.handlers.render_init.append(renderInitHandler) bpy.app.handlers.render_complete.append(renderCompleteHandler) bpy.app.handlers.render_cancel.append(renderCancelHandler) bpy.types.TOPBAR_MT_file_import.append(menu_func_import_sequence) + bpy.types.VIEW3D_MT_object.append(menu_func_convert_to_sequence) # the order here is important since it is the order in which these sections will be drawn bpy.utils.register_class(SMO_PT_FileImportSettingsPanel) @@ -69,10 +83,21 @@ def register(): bpy.app.handlers.load_post.append(makeDirPathsRelative) bpy.app.handlers.save_pre.append(makeDirPathsRelative) + keyConfig = bpy.context.window_manager.keyconfigs.addon + if keyConfig: + spaceTypes = [('3D View', 'VIEW_3D'), ('Dopesheet', 'DOPESHEET_EDITOR'), ('Graph Editor', 'GRAPH_EDITOR')] + for spaceType in spaceTypes: + keyMap = keyConfig.keymaps.new(name=spaceType[0], space_type=spaceType[1]) + keyMapItem = keyMap.keymap_items.new('ms.duplicate_mesh_frame', type='D', value='PRESS', shift=True, ctrl=True) + SMOKeymaps.append((keyMap, keyMapItem)) def unregister(): + bpy.app.handlers.frame_change_pre.remove(checkMeshChangesFrameChangePre) + bpy.app.handlers.frame_change_post.remove(checkMeshChangesFrameChangePost) + bpy.app.handlers.load_post.remove(initializeSequences) bpy.app.handlers.frame_change_pre.remove(updateFrame) + bpy.app.handlers.depsgraph_update_pre.remove(updateFrame) bpy.app.handlers.render_init.remove(renderInitHandler) bpy.app.handlers.render_complete.remove(renderCompleteHandler) bpy.app.handlers.render_cancel.remove(renderCancelHandler) @@ -81,14 +106,19 @@ def unregister(): bpy.utils.unregister_class(BatchShadeFlat) bpy.utils.unregister_class(BakeMeshSequence) bpy.utils.unregister_class(DeepDeleteSequence) + bpy.utils.unregister_class(MergeDuplicateMaterials) + bpy.utils.unregister_class(ConvertToMeshSequence) + bpy.utils.unregister_class(DuplicateMeshFrame) bpy.utils.unregister_class(SMO_PT_MeshSequencePanel) bpy.utils.unregister_class(SMO_PT_MeshSequencePlaybackPanel) bpy.utils.unregister_class(SMO_PT_MeshSequenceStreamingPanel) + bpy.utils.unregister_class(SMO_PT_MeshSequenceExportPanel) bpy.utils.unregister_class(SMO_PT_MeshSequenceAdvancedPanel) bpy.utils.unregister_class(MeshSequenceSettings) bpy.utils.unregister_class(MeshNameProp) bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_sequence) + bpy.types.VIEW3D_MT_object.remove(menu_func_convert_to_sequence) bpy.utils.unregister_class(SMO_PT_FileImportSettingsPanel) bpy.utils.unregister_class(SMO_PT_TransformSettingsPanel) bpy.utils.unregister_class(SMO_PT_SequenceImportSettingsPanel) @@ -102,5 +132,9 @@ def unregister(): bpy.app.handlers.load_post.remove(makeDirPathsRelative) bpy.app.handlers.save_pre.remove(makeDirPathsRelative) + for km, kmi in SMOKeymaps: + km.keymap_items.remove(kmi) + SMOKeymaps.clear() + if __name__ == "__main__": register() diff --git a/src/panels.py b/src/panels.py index 6720374..8aa94f1 100644 --- a/src/panels.py +++ b/src/panels.py @@ -1,7 +1,7 @@ # ##### BEGIN GPL LICENSE BLOCK ##### # # Stop motion OBJ: A Mesh sequence importer for Blender -# Copyright (C) 2016-2020 Justin Jensen +# Copyright (C) 2016-2024 Justin Jensen # # 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 @@ -29,17 +29,20 @@ # The properties panel added to the Object Properties Panel list class SMO_PT_MeshSequencePanel(bpy.types.Panel): bl_idname = 'OBJ_SEQUENCE_PT_properties' - bl_label = 'Mesh Sequence' + bl_label = 'Stop Motion OBJ' bl_space_type = 'PROPERTIES' bl_region_type = 'WINDOW' bl_context = 'object' @classmethod def poll(cls, context): - return context.object.mesh_sequence_settings.initialized == True + return context.object.type == 'MESH' def draw(self, context): - pass + if context.object.type == 'MESH' and context.object.mesh_sequence_settings.initialized == False: + # show button to convert the object into a mesh sequence + self.layout.operator(ConvertToMeshSequence.bl_idname, icon="ONIONSKIN_ON") + class SMO_PT_MeshSequencePlaybackPanel(bpy.types.Panel): @@ -59,9 +62,18 @@ def draw(self, context): layout.use_property_split = True layout.use_property_decorate = False col = layout.column(align=False) - col.prop(objSettings, "startFrame") col.prop(objSettings, "frameMode") - col.prop(objSettings, "speed") + + # keyframed playback + if objSettings.frameMode == '4': + row = col.row() + if objSettings.curKeyframeMeshIdx <= 0 or objSettings.curKeyframeMeshIdx > objSettings.numMeshes - 1: + row.alert = True + row.prop(objSettings, "curKeyframeMeshIdx") + # all other playback modes + else: + col.prop(objSettings, "startFrame") + col.prop(objSettings, "speed") class SMO_PT_MeshSequenceStreamingPanel(bpy.types.Panel): @@ -85,6 +97,42 @@ def draw(self, context): col.prop(objSettings, "streamDuringPlayback") +class SMO_PT_MeshSequenceExportPanel(bpy.types.Panel): + bl_label = 'Export' + bl_parent_id = "OBJ_SEQUENCE_PT_properties" + bl_space_type = 'PROPERTIES' + bl_region_type = 'WINDOW' + bl_options = {'DEFAULT_CLOSED'} + + @classmethod + def poll(cls, context): + mss = context.object.mesh_sequence_settings + return mss.initialized == True and mss.isImported == True + + def draw(self, context): + layout = self.layout + objSettings = context.object.mesh_sequence_settings + inObjectMode = context.mode == 'OBJECT' + inSculptMode = context.mode == 'SCULPT' + + if objSettings.isImported is True: + # non-imported sequences won't have a fileName or dirPath and cannot be exported (for now) + row = layout.row() + row.enabled = inObjectMode or inSculptMode + row.prop(objSettings, "autoExportChanges") + + exportEnabled = objSettings.autoExportChanges + + row = layout.row() + row.enabled = (inObjectMode or inSculptMode) and exportEnabled + row.prop(objSettings, "overwriteSrcDir") + + row = layout.row() + row.enabled = (inObjectMode or inSculptMode) and objSettings.overwriteSrcDir is False and exportEnabled + row.alert = objSettings.exportDir == '' and objSettings.overwriteSrcDir is False + + row.prop(objSettings, "exportDir") + class SMO_PT_MeshSequenceAdvancedPanel(bpy.types.Panel): bl_label = 'Advanced' bl_parent_id = "OBJ_SEQUENCE_PT_properties" @@ -99,27 +147,54 @@ def poll(cls, context): def draw(self, context): layout = self.layout objSettings = context.object.mesh_sequence_settings + inObjectMode = context.mode == 'OBJECT' + inSculptMode = context.mode == 'SCULPT' if objSettings.loaded is True: - if objSettings.cacheMode == 'cached': + # only allow mesh duplication for non-imported sequences in Keyframe playback mode + if objSettings.isImported is False and objSettings.frameMode == '4': row = layout.row(align=True) - row.enabled = context.mode == 'OBJECT' + row.enabled = inObjectMode or inSculptMode + row.operator("ms.duplicate_mesh_frame") + + if objSettings.cacheMode == 'cached' or objSettings.cacheMode == 'streaming': + row = layout.row(align=True) + row.enabled = inObjectMode row.label(text="Shading:") row.operator("ms.batch_shade_smooth") row.operator("ms.batch_shade_flat") + + if objSettings.cacheMode == 'cached': + row = layout.row(align=True) + row.enabled = inObjectMode + row.operator("ms.merge_duplicate_materials") - row = layout.row() - row.enabled = context.mode == 'OBJECT' - row.operator("ms.reload_mesh_sequence") + if objSettings.isImported is True: + # non-imported sequences won't have a fileName or dirPath and cannot be reloaded + row = layout.row() + row.enabled = inObjectMode + row.operator("ms.reload_mesh_sequence") row = layout.row() - row.enabled = context.mode == 'OBJECT' + row.enabled = inObjectMode row.operator("ms.bake_sequence") + + row = layout.row() - row.enabled = context.mode == 'OBJECT' + row.enabled = inObjectMode row.operator("ms.deep_delete_sequence") layout.row().separator() + + # we have to subtract one because numMeshes includes the empty mesh + layout.row().label(text="Sequence size: " + str(objSettings.numMeshes - 1) + " meshes") + + if objSettings.cacheMode == 'streaming': + layout.row().label(text="Cached meshes: " + str(objSettings.numMeshesInMemory) + " meshes") + + if objSettings.isImported is True: + # non-imported sequences won't have a dirPath to display + layout.row().label(text="Mesh directory: " + objSettings.dirPath) layout.row().label(text="Sequence version: " + objSettings.version.toString()) @@ -140,7 +215,9 @@ class SequenceImportSettings(bpy.types.PropertyGroup): fileFormat: bpy.props.EnumProperty( items=[('obj', 'OBJ', 'Wavefront OBJ'), ('stl', 'STL', 'STereoLithography'), - ('ply', 'PLY', 'Stanford PLY')], + ('ply', 'PLY', 'Stanford PLY'), + ('x3d', 'X3D', 'X3D Extensible 3D'), + ('wrl', 'WRL', 'VRML2 (WRL)')], name='File Format', default='obj') dirPathIsRelative: bpy.props.BoolProperty( @@ -160,7 +237,7 @@ class ImportSequence(bpy.types.Operator, ImportHelper): sequenceSettings: bpy.props.PointerProperty(type=SequenceImportSettings) # for now, we'll just show any file type that Stop Motion OBJ supports - filter_glob: bpy.props.StringProperty(default="*.stl;*.obj;*.mtl;*.ply") + filter_glob: bpy.props.StringProperty(default="*.stl;*.obj;*.mtl;*.ply;*.x3d;*.wrl") directory: bpy.props.StringProperty(subtype='DIR_PATH') @@ -168,6 +245,14 @@ class ImportSequence(bpy.types.Operator, ImportHelper): axis_up: bpy.props.StringProperty(default="Y") def execute(self, context): + if bpy.app.version >= (4, 0, 0): + showError("This version of Stop Motion OBJ doesn't support Blender 4.0") + return {'CANCELLED'} + + if bpy.app.version < (2, 92, 0): + showError("This version of Stop Motion OBJ requires at least Blender 2.92") + return {'CANCELLED'} + if self.sequenceSettings.fileNamePrefix == "": self.report({'ERROR_INVALID_INPUT'}, "Please enter a file name prefix") return {'CANCELLED'} @@ -175,61 +260,109 @@ def execute(self, context): self.importSettings.axis_forward = self.axis_forward self.importSettings.axis_up = self.axis_up - # the input parameters should be stored on 'self' - # create a new mesh sequence - seqObj = newMeshSequence() - global_matrix = axis_conversion(from_forward=self.axis_forward,from_up=self.axis_up).to_4x4() - seqObj.matrix_world = global_matrix - - mss = seqObj.mesh_sequence_settings - - # deep copy self.sequenceSettings data into the new object's mss data, including dirPath - mss.dirPath = self.directory - mss.fileName = self.sequenceSettings.fileNamePrefix - mss.perFrameMaterial = self.sequenceSettings.perFrameMaterial - mss.cacheMode = self.sequenceSettings.cacheMode - mss.fileFormat = self.sequenceSettings.fileFormat - mss.dirPathIsRelative = self.sequenceSettings.dirPathIsRelative - - # this needs to be set to True if dirPath is supposed to be relative - # once the path is made relative, it will be set to False - mss.dirPathNeedsRelativizing = mss.dirPathIsRelative - - self.copyImportSettings(self.importSettings, mss.fileImporter) - - meshCount = 0 - - # cached - if mss.cacheMode == 'cached': - meshCount = loadSequenceFromMeshFiles(seqObj, mss.dirPath, mss.fileName) - - # streaming - elif mss.cacheMode == 'streaming': - meshCount = loadStreamingSequenceFromMeshFiles(seqObj, mss.dirPath, mss.fileName) - - self.resetToDefaults() - - if meshCount == 0: - self.report({'ERROR'}, "No matching files found. Make sure the Root Folder, File Name, and File Format are correct.") - return {'CANCELLED'} + b_axis_forward = self.axis_forward + b_axis_up = self.axis_up + + fileNames = self.sequenceSettings.fileNamePrefix.split(';') + noMatchFileNames = [] + for fileNameRaw in filter(lambda f: f.strip() != '', fileNames): # filter out any empty/whitespace strings + # remove leading and trailing whitespace (might still contain folder name) + fileName = fileNameRaw.strip() + + # construct the full absolute path + absWildcardPath = os.path.join(self.directory, fileName + '*.' + self.sequenceSettings.fileFormat) + + # separate the directory from the file name + basenamePrefix = os.path.basename(absWildcardPath).split('*')[0] + + # get the final directory path + dirPath = os.path.dirname(absWildcardPath) + + # see whether these files exist before creating a sequence object + if countMatchingFiles(dirPath, basenamePrefix, fileExtensionFromType(self.sequenceSettings.fileFormat)) > 0: + # the input parameters should be stored on 'self' + # create a new mesh sequence + seqObj = newMeshSequence() + global_matrix = axis_conversion(from_forward=b_axis_forward,from_up=b_axis_up).to_4x4() + seqObj.matrix_world = global_matrix + + # global scale for OBJ, PLY, and STL + globalScale = 1.0 + if self.sequenceSettings.fileFormat == 'obj': + globalScale = self.importSettings.obj_global_scale + elif self.sequenceSettings.fileFormat == 'stl': + globalScale = self.importSettings.stl_global_scale + elif self.sequenceSettings.fileFormat == 'ply': + globalScale = self.importSettings.ply_global_scale + + seqObj.scale = (globalScale, globalScale, globalScale) + + mss = seqObj.mesh_sequence_settings + + # deep copy self.sequenceSettings data into the new object's mss data, including dirPath + mss.dirPath = dirPath + mss.fileName = basenamePrefix + mss.perFrameMaterial = self.sequenceSettings.perFrameMaterial + mss.cacheMode = self.sequenceSettings.cacheMode + mss.fileFormat = self.sequenceSettings.fileFormat + mss.dirPathIsRelative = self.sequenceSettings.dirPathIsRelative + + # this needs to be set to True if dirPath is supposed to be relative + # once the path is made relative, it will be set to False + mss.dirPathNeedsRelativizing = mss.dirPathIsRelative + + self.copyImportSettings(self.importSettings, mss.fileImporter) + + meshCount = 0 + + # cached + if mss.cacheMode == 'cached': + meshCount = loadSequenceFromMeshFiles(seqObj, mss.dirPath, mss.fileName) + + # streaming + elif mss.cacheMode == 'streaming': + meshCount = loadStreamingSequenceFromMeshFiles(seqObj, mss.dirPath, mss.fileName) + + self.resetToDefaults() + + if meshCount == 0: + # this section shouldn't be needed because we check above before creating the mesh sequence object + bpy.data.objects.remove(seqObj, do_unlink=True) + self.report({'ERROR'}, "No matching files found. Make sure the Root Folder, File Name, and File Format are correct.") + return {'CANCELLED'} + + # get the name of the first mesh, remove trailing numbers and _ and . + firstMeshName = os.path.splitext(mss.meshNameArray[1].basename)[0].rstrip('._0123456789') + seqObj.name = createUniqueName(firstMeshName + '_sequence', bpy.data.objects) + seqObj.mesh_sequence_settings.isImported = True + else: + # this filename prefix had no matching files + noMatchFileNames.append(fileName) - # get the name of the first mesh, remove trailing numbers and _ and . - firstMeshName = os.path.splitext(mss.meshNameArray[1].basename)[0].rstrip('._0123456789') - seqObj.name = firstMeshName + '_sequence' + if len(noMatchFileNames) > 0: + self.report({'ERROR'}, "No matching files found for: " + " ".join(noMatchFileNames) + ". Make sure the File Name and Format are correct.") return {'FINISHED'} def copyImportSettings(self, source, dest): dest.axis_forward = source.axis_forward dest.axis_up = source.axis_up + dest.obj_use_edges = source.obj_use_edges dest.obj_use_smooth_groups = source.obj_use_smooth_groups dest.obj_use_split_objects = False dest.obj_use_split_groups = False - dest.obj_use_groups_as_vgroups = source.obj_use_groups_as_vgroups + dest.obj_import_vertex_groups = source.obj_import_vertex_groups dest.obj_use_image_search = source.obj_use_image_search dest.obj_split_mode = "OFF" - dest.obj_global_clamp_size = source.obj_global_clamp_size + dest.obj_clamp_size = source.obj_clamp_size + dest.obj_global_scale = source.obj_global_scale + + dest.ply_global_scale = source.ply_global_scale + dest.ply_use_scene_unit = source.ply_use_scene_unit + dest.ply_merge_verts = source.ply_merge_verts + dest.ply_import_colors = source.ply_import_colors + dest.stl_global_scale = source.stl_global_scale dest.stl_use_scene_unit = source.stl_use_scene_unit dest.stl_use_facet_normal = source.stl_use_facet_normal @@ -268,17 +401,25 @@ def draw(self, context): layout.prop(op.importSettings, 'obj_use_image_search') layout.prop(op.importSettings, 'obj_use_smooth_groups') layout.prop(op.importSettings, 'obj_use_edges') - layout.prop(op.importSettings, 'obj_global_clamp_size') + layout.prop(op.importSettings, 'obj_clamp_size') + layout.prop(op.importSettings, 'obj_global_scale') col = layout.column() - col.prop(op.importSettings, "obj_use_groups_as_vgroups") + col.prop(op.importSettings, "obj_import_vertex_groups") elif op.sequenceSettings.fileFormat == 'stl': layout.row().prop(op.importSettings, "stl_global_scale") layout.row().prop(op.importSettings, "stl_use_scene_unit") layout.row().prop(op.importSettings, "stl_use_facet_normal") elif op.sequenceSettings.fileFormat == 'ply': - layout.label(text="No .ply settings") + layout.prop(op.importSettings, 'ply_global_scale') + layout.prop(op.importSettings, 'ply_use_scene_unit') + layout.prop(op.importSettings, 'ply_merge_verts') + layout.prop(op.importSettings, 'ply_import_colors') + elif op.sequenceSettings.fileFormat == 'x3d': + layout.label(text="No .x3d settings") + elif op.sequenceSettings.fileFormat == 'wrl': + layout.label(text="No .wrl settings") class SMO_PT_TransformSettingsPanel(bpy.types.Panel): @@ -331,3 +472,104 @@ def draw(self, context): def menu_func_import_sequence(self, context): self.layout.operator(ImportSequence.bl_idname, text="Mesh Sequence") + + +class ConvertToMeshSequence(bpy.types.Operator): + """Convert to Mesh Sequence""" + bl_idname = "ms.convert_to_mesh_sequence" + bl_label = "Convert to Mesh Sequence" + bl_options = {'UNDO'} + + def execute(self, context): + obj = context.object + + # if this object is alread a mesh sequence object, return Failure + if obj.mesh_sequence_settings.initialized is True: + self.report({'ERROR'}, "The selected object is already a mesh sequence") + return {'CANCELLED'} + + # hijack the mesh from the selected object and add it to a new mesh sequence + msObj = newMeshSequence() + msObj.mesh_sequence_settings.isImported = False + addMeshToSequence(msObj, obj.data) + + # make sure the new mesh sequence has the same transform (especially the location) as context.object + msObj.location = obj.location + msObj.scale = obj.scale + msObj.rotation_euler = obj.rotation_euler + msObj.rotation_quaternion = obj.rotation_quaternion + + objName = obj.name + msObj.name = objName + '_sequence' + + # delete the old object but not its mesh + bpy.data.objects.remove(obj) + + # set the mesh sequence playback mode to Keyframe + msObj.mesh_sequence_settings.frameMode = '4' + + # create a keyframe for this mesh at the current frame + msObj.mesh_sequence_settings.curKeyframeMeshIdx = 1 + msObj.keyframe_insert(data_path='mesh_sequence_settings.curKeyframeMeshIdx', frame=context.scene.frame_current) + + # make the interpolation constant for the first keyframe + meshIdxCurve = next((curve for curve in msObj.animation_data.action.fcurves if 'curKeyframeMeshIdx' in curve.data_path), None) + keyAtFrame = next((keyframe for keyframe in meshIdxCurve.keyframe_points if keyframe.co.x == context.scene.frame_current), None) + keyAtFrame.interpolation = 'CONSTANT' + + return {'FINISHED'} + +def menu_func_convert_to_sequence(self, context): + if context.object is not None: + if context.object.type == 'MESH' and context.object.mesh_sequence_settings.initialized is False: + self.layout.separator() + self.layout.operator(ConvertToMeshSequence.bl_idname, icon="ONIONSKIN_ON") + + +class DuplicateMeshFrame(bpy.types.Operator): + """Make a copy of the current mesh and create a keyframe for it at the current frame""" + bl_idname = "ms.duplicate_mesh_frame" + bl_label = "Duplicate Mesh Frame" + bl_options = {'UNDO'} + + def execute(self, context): + obj = context.object + + if obj is None: + return {'CANCELLED'} + + # if this object is not a mesh sequence object, return Failure + if obj.mesh_sequence_settings.initialized is False: + self.report({'ERROR'}, "The selected object is not a mesh sequence") + return {'CANCELLED'} + + # if the object doesn't have a 'curKeyframeMeshIdx' fcurve, we can't add a mesh to it + meshIdxCurve = next((curve for curve in obj.animation_data.action.fcurves if 'curKeyframeMeshIdx' in curve.data_path), None) + if meshIdxCurve is None: + self.report({'ERROR'}, "The selected mesh sequence has no keyframe curve") + return {'CANCELLED'} + + # if the keyframe curve already has a keyframe at this frame number, we can't create another one here + keyAtFrame = next((keyframe for keyframe in meshIdxCurve.keyframe_points if keyframe.co.x == context.scene.frame_current), None) + if keyAtFrame is not None: + self.report({'ERROR'}, "There is already a keyframe at the current frame") + return {'CANCELLED'} + + # make a copy of the current mesh + newMesh = obj.data.copy() + newMesh.use_fake_user = True + newMesh.inMeshSequence = True + + # add the mesh to the end of the sequence + meshIdx = addMeshToSequence(obj, newMesh) + + # add a new keyframe at this frame number for the new mesh + obj.mesh_sequence_settings.curKeyframeMeshIdx = meshIdx + obj.keyframe_insert(data_path='mesh_sequence_settings.curKeyframeMeshIdx', frame=context.scene.frame_current) + + # make the interpolation constant for this keyframe + newKeyAtFrame = next((keyframe for keyframe in meshIdxCurve.keyframe_points if keyframe.co.x == context.scene.frame_current), None) + newKeyAtFrame.interpolation = 'CONSTANT' + + return {'FINISHED'} + diff --git a/src/stop_motion_obj.py b/src/stop_motion_obj.py index 0817617..6a8f3c8 100644 --- a/src/stop_motion_obj.py +++ b/src/stop_motion_obj.py @@ -1,7 +1,7 @@ # ##### BEGIN GPL LICENSE BLOCK ##### # # Stop motion OBJ: A Mesh sequence importer for Blender -# Copyright (C) 2016-2021 Justin Jensen +# Copyright (C) 2016-2024 Justin Jensen # # 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 @@ -24,8 +24,25 @@ import re import glob from bpy.app.handlers import persistent +import time from .version import * +# global variables +storedUseLockInterface = False +forceMeshLoad = False +loadingSequenceLock = False +inRenderMode = False +lockFrameSwitch = False + +def convertOldToNewAxisStr(oldAxisStr): + if oldAxisStr == '-X': + return 'NEGATIVE_X' + elif oldAxisStr == '-Y': + return 'NEGATIVE_Y' + elif oldAxisStr == '-Z': + return 'NEGATIVE_Z' + else: + return oldAxisStr def alphanumKey(string): """ Turn a string into a list of string and number chunks. @@ -33,11 +50,174 @@ def alphanumKey(string): """ return [int(c) if c.isdigit() else c for c in re.split('([0-9]+)', string)] +def clamp(value, minVal, maxVal): + return max(minVal, min(value, maxVal)) + +def selectOnly(obj): + deselectAll() + obj.select_set(state=True) def deselectAll(): for ob in bpy.context.scene.objects: ob.select_set(state=False) +@persistent +def checkMeshChangesFrameChangePre(scene): + global inRenderMode + if inRenderMode == True: + return + + # just in case we're still in the Render context + if hasattr(bpy.context, "object") is False: + return + + obj = bpy.context.object + + # make sure an object is selected + if obj is None: + return + + # if the selected object is not a loaded and initialized mesh sequence, return + mss = obj.mesh_sequence_settings + if mss.initialized is False or mss.loaded is False: + return + + # if the selected object is not in auto-export mode, return + if mss.autoExportChanges is False: + return + + # if we're not in Sculpt or Object mode, return + cMode = bpy.context.mode + if cMode != 'SCULPT' and cMode != 'OBJECT': + return + + # generate the mesh hash for the current mesh (just before the frame switches) + meshHashStr = getMeshHashStr(obj.data) + + # if the generated mesh hash does not match the mesh's stored hash + # for some reason we also have to check whether the meshHash has not been calculated yet + if obj.data.meshHash != '' and meshHashStr != obj.data.meshHash: + # lock frame switching until we're done exporting (so that we export the correct frame beofre the next one is loaded) + global lockFrameSwitch + lockFrameSwitch = True + + # update the mesh hash + obj.data.meshHash = meshHashStr + + # export this updated mesh + absDir = '' + if mss.overwriteSrcDir is True: + # writing over the original meshes + absDir = bpy.path.abspath(mss.dirPath) + else: + # use the user-provided export directory + absDir = bpy.path.abspath(mss.exportDir) + if mss.exportDir == '' or os.path.isdir(absDir) is False: + # if the dirpath is invalid or empty, alert the user + showError("Invalid export directory") + return + + filename = os.path.join(absDir, mss.meshNameArray[mss.curVisibleMeshIdx].basename) + + # select only this object so that this object is the only one that will be exported + selectOnly(obj) + + # actually export the file + mss.fileImporter.export(mss.fileFormat, filename) + + # show an unobtrusive message that the mesh has been exported + msg = "Mesh exported: " + filename + bpy.context.workspace.status_text_set(text=msg) + + # once the export operation has fully finished, unlock importing/changing meshes, then trigger an updateFrame call + lockFrameSwitch = False + updateFrame(0) + + +def showError(message=""): + def draw(self, context): + self.layout.label(text=message) + bpy.context.window_manager.popup_menu(draw, title='Stop Motion OBJ Error', icon='ERROR') + +@persistent +def checkMeshChangesFrameChangePost(scene): + # make sure we're not rendering + global inRenderMode + if inRenderMode == True: + return + + # if we're not in Sculpt mode or Object mode, return + #if bpy.context.mode != 'SCULPT' and bpy.context.mode != 'OBJECT': + if bpy.context.mode != 'SCULPT': + return + + if hasattr(bpy.context, "object") and bpy.context.object != None: + # if the selected object is not a loaded and initialized mesh sequence, return + mss = bpy.context.object.mesh_sequence_settings + if mss.initialized is False or mss.loaded is False: + return + + # if the selected object is not in auto-export mode, return + if mss.autoExportChanges is False: + return + + # generate the mesh hash for the current mesh and store that value on the mesh + meshHashStr = getMeshHashStr(bpy.context.object.data) + bpy.context.object.data.meshHash = meshHashStr + +def getMeshSignature(mesh): + # Build a string composed of the following elements: + # the number of vertices + # the number of faces + nVerts = len(mesh.vertices) + nFaces = len(mesh.polygons) + + groupCount = 16 + vtxLoc = [] + polyLoc = [] + polyVtxs = [] + for idx in range(groupCount): + vtxLoc.append([0.0, 0.0, 0.0]) + polyLoc.append([0.0, 0.0, 0.0]) + polyVtxs.append([0.0, 0.0, 0.0]) + + # the average vertex location for 16 equally-sized groups of vertices (interlaced) + for idx, vtx in enumerate(mesh.vertices): + groupIdx = idx % groupCount + vtxLoc[groupIdx][0] += vtx.co.x + vtxLoc[groupIdx][1] += vtx.co.y + vtxLoc[groupIdx][2] += vtx.co.z + + # convert the 16 vtxLoc groups into strings + vtxLocList = list(map(lambda x: f'{x[0]:.5f},{x[1]:.5f},{x[2]:.5f}', vtxLoc)) + vtxLocStr = " ".join(vtxLocList) + + # the average center location for 16 equally-sized groups of polygons (interlaced) + # the 3 average vertex indices for 16 equally-sized groups of polygons (interlaced) + for pIdx, poly in enumerate(mesh.polygons): + groupIdx = pIdx % groupCount + polyLoc[groupIdx][0] += poly.center.x + polyLoc[groupIdx][1] += poly.center.y + polyLoc[groupIdx][2] += poly.center.z + + for ptIdx, vIdx in enumerate(poly.vertices): + polyVtxs[groupIdx][ptIdx % 3] += vIdx + + # convert the 16 polyLoc groups into strings + polyLocList = list(map(lambda x: f'{x[0]:.5f},{x[1]:.5f},{x[2]:.5f}', polyLoc)) + polyLocStr = " ".join(polyLocList) + + # convert the 16 polyVtx groups into strings + polyVtxList = list(map(lambda x: f'{x[0]:.0f},{x[1]:.0f},{x[2]:.0f}', polyVtxs)) + polyVtxStr = " ".join(polyVtxList) + + return f'{nVerts} {nFaces} {vtxLocStr} {polyLocStr} {polyVtxStr}' + + +def getMeshHashStr(mesh): + # get the mesh signature and hash it + return str(hash(getMeshSignature(mesh))) + # We have to use this function instead of bpy.context.selected_objects because there's no "selected_objects" within the render context def getSelectedObjects(): @@ -48,31 +228,34 @@ def getSelectedObjects(): return selected_objects -def createUniqueMeshName(basename): +def createUniqueName(basename, collection): attempts = 1 uniqueName = basename - while attempts < 1000 and uniqueName in bpy.data.meshes: + while attempts < 1000 and uniqueName in collection: uniqueName = "%s.%03d" % (basename, attempts) attempts += 1 return uniqueName - def renderLockInterface(): for scene in bpy.data.scenes: scene.render.use_lock_interface = True +def lockLoadingSequence(lock): + global loadingSequenceLock + loadingSequenceLock = lock # set the frame number for all mesh sequence objects @persistent def updateFrame(scene): - scn = bpy.context.scene - setFrameNumber(scn.frame_current) - - -# global variables -storedUseLockInterface = False -forceMeshLoad = False + global lockFrameSwitch + if lockFrameSwitch is True: + # we're not ready to switch frames yet + return + global loadingSequenceLock + if loadingSequenceLock is False: + scn = bpy.context.scene + setFrameNumber(scn.frame_current) @persistent @@ -82,6 +265,8 @@ def renderInitHandler(scene): bpy.data.scenes["Scene"].render.use_lock_interface = True global forceMeshLoad forceMeshLoad = True + global inRenderMode + inRenderMode = True @persistent @@ -99,6 +284,8 @@ def renderStopped(): bpy.data.scenes["Scene"].render.use_lock_interface = storedUseLockInterface global forceMeshLoad forceMeshLoad = False + global inRenderMode + inRenderMode = False @persistent @@ -123,6 +310,18 @@ def handlePlaybackChange(self, context): return None +# runs every time the "Auto-export Changes" checkbox is changed +def handleAutoExportChange(self, context): + obj = context.object + # if the selected object's mesh is part of a mesh sequence + if obj.data.inMeshSequence is True: + # if the user just set it to True + if obj.mesh_sequence_settings.autoExportChanges is True: + # calculate the mesh hash for the current mesh and store it on the mesh + meshHashStr = getMeshHashStr(obj.data) + obj.data.meshHash = meshHashStr + + # runs every time the cache size changes def resizeCache(self, context): obj = context.object @@ -162,6 +361,10 @@ def fileExtensionFromType(_type): return 'stl' elif(_type == 'ply'): return 'ply' + elif(_type == 'x3d'): + return 'x3d' + elif(_type == 'wrl'): + return 'wrl' return '' @@ -193,9 +396,9 @@ class MeshImporter(bpy.types.PropertyGroup): # name="Split", # items=(('ON', "Split", "Split geometry, omits unused vertices"), # ('OFF', "Keep Vert Order", "Keep vertex order from file"))) - obj_use_groups_as_vgroups: bpy.props.BoolProperty(name="Poly Groups", description="Import OBJ groups as vertex groups", default=False) + obj_import_vertex_groups: bpy.props.BoolProperty(name="Poly Groups", description="Import OBJ groups as vertex groups", default=False) obj_use_image_search: bpy.props.BoolProperty(name="Image Search", description="Search subdirs for any associated images (Warning: may be slow)", default=True) - obj_global_clamp_size: bpy.props.FloatProperty( + obj_clamp_size: bpy.props.FloatProperty( name="Clamp Size", description="Clamp bounds under this value (zero to disable)", min=0.0, @@ -203,6 +406,13 @@ class MeshImporter(bpy.types.PropertyGroup): soft_min=0.0, soft_max=1000.0, default=0.0) + obj_global_scale: bpy.props.FloatProperty( + name="Scale", + soft_min=0.001, + soft_max=1000.0, + min=1e-6, + max=1e6, + default=1.0) # STL import parameters stl_global_scale: bpy.props.FloatProperty( @@ -221,7 +431,29 @@ class MeshImporter(bpy.types.PropertyGroup): description="Use (import) facet normals (note that this will still give flat shading)", default=False) - # (PLY has no import parameters) + # PLY import parameters + ply_global_scale: bpy.props.FloatProperty( + name="Scale", + soft_min=0.001, + soft_max=1000.0, + min=1e-6, + max=1e6, + default=1.0) + ply_use_scene_unit: bpy.props.BoolProperty( + name="Scene Unit", + description="Apply current scene's unit (as defined by unit scale) to imported data", + default=False) + ply_merge_verts: bpy.props.BoolProperty( + name="Merge Vertices", + description="Merge vertices by distance", + default=False) + ply_import_colors: bpy.props.EnumProperty( + name="Import Vertex color attributes", + items=(('NONE', "None", "Do not import/export color attributes"), + ('SRGB', "sRGB", "Vertex colors in the file are in sRGB color space"), + ('LINEAR', "Linear", "Vertex colors in the file are in linear color space"))) + # (X3D has no import parameters) + # (WRL has no import parameters) # Shared import parameters axis_forward: bpy.props.StringProperty(name="Axis Forward",default="-Z") axis_up: bpy.props.StringProperty(name="Axis Up",default="Y") @@ -229,44 +461,64 @@ class MeshImporter(bpy.types.PropertyGroup): def draw(self): pass - def load(self, fileType, filePath): + def load(self, fileType, filePath, streaming=False): if fileType == 'obj': - self.loadOBJ(filePath) + self.loadOBJ(filePath, streaming) elif fileType == 'stl': self.loadSTL(filePath) elif fileType == 'ply': - self.loadPLY(filePath) + self.loadPLY(filePath, streaming) + elif fileType == 'x3d' or fileType == 'wrl': + self.loadX3D(filePath) + + def export(self, fileType, filePath): + # get the context mode and store it + contextMode = bpy.context.mode - def loadOBJ(self, filePath): - # call the obj load function with all the correct parameters - if bpy.app.version >= (2, 92, 0): - bpy.ops.import_scene.obj( + # export the object + if fileType == 'obj': + self.exportOBJ(filePath) + elif fileType == 'stl': + self.exportSTL(filePath) + elif fileType == 'ply': + self.exportPLY(filePath) + elif fileType == 'x3d' or fileType == 'wrl': + self.exportX3D(filePath) + + # set the context mode back to the one it was in before + # (the OBJ exporter likes to switch to Object mode during the export) + bpy.ops.object.mode_set(mode=contextMode) + + def loadOBJ(self, filePath, streaming=False): + if bpy.app.version >= (4, 0, 0): + showError("This version of Stop Motion OBJ doesn't support Blender 4.0") + elif bpy.app.version < (2, 92, 0): + showError("This version of Stop Motion OBJ requires at least Blender 2.92") + # if we're not streaming and the fast OBJ importer is available, use it + elif bpy.app.version >= (3, 3, 0) and streaming is False: + # convert '-Z' to 'NEGATIVE_Z' + newForwardAxisStr = convertOldToNewAxisStr(self.axis_forward) + newUpAxisStr = convertOldToNewAxisStr(self.axis_up) + bpy.ops.wm.obj_import( filepath=filePath, - use_edges=self.obj_use_edges, - use_smooth_groups=self.obj_use_smooth_groups, + global_scale=self.obj_global_scale, + clamp_size=self.obj_clamp_size, + forward_axis=newForwardAxisStr, + up_axis=newUpAxisStr, use_split_objects=False, - use_split_groups=False, - use_groups_as_vgroups=self.obj_use_groups_as_vgroups, - use_image_search=self.obj_use_image_search, - split_mode="OFF", - global_clamp_size=self.obj_global_clamp_size, - axis_forward=self.axis_forward, - axis_up=self.axis_up) - else: - # Note the parameter called "global_clight_size", which is probably a typo - # It was corrected to "global_clamp_size" in Blender 2.92 + use_split_groups=False) + # if we are streaming or we're running an older version of Blender, use the legacy OBJ importer + elif bpy.app.version < (3, 3, 0) or streaming is True: bpy.ops.import_scene.obj( filepath=filePath, use_edges=self.obj_use_edges, - use_smooth_groups=self.obj_use_smooth_groups, - use_split_objects=False, - use_split_groups=False, - use_groups_as_vgroups=self.obj_use_groups_as_vgroups, + use_groups_as_vgroups=self.obj_import_vertex_groups, use_image_search=self.obj_use_image_search, split_mode="OFF", - global_clight_size=self.obj_global_clamp_size, + global_clamp_size=self.obj_clamp_size, axis_forward=self.axis_forward, axis_up=self.axis_up) + def loadSTL(self, filePath): # call the stl load function with all the correct parameters @@ -278,9 +530,92 @@ def loadSTL(self, filePath): axis_forward=self.axis_forward, axis_up=self.axis_up) - def loadPLY(self, filePath): + def loadPLY(self, filePath, streaming=False): # call the ply load function with all the correct parameters - bpy.ops.import_mesh.ply(filepath=filePath) + if bpy.app.version >= (4, 0, 0): + showError("This version of Stop Motion OBJ doesn't support Blender 4.0") + elif bpy.app.version >= (3, 3, 0): + if streaming is False: + newForwardAxisStr = convertOldToNewAxisStr(self.axis_forward) + newUpAxisStr = convertOldToNewAxisStr(self.axis_up) + bpy.ops.wm.ply_import( + filepath=filePath, + global_scale=self.ply_global_scale, + use_scene_unit=self.ply_use_scene_unit, + forward_axis=newForwardAxisStr, + up_axis=newUpAxisStr, + merge_verts=self.ply_merge_verts, + import_colors=self.ply_import_colors) + else: + bpy.ops.import_mesh.ply(filepath=filePath) + + def loadX3D(self, filePath): + bpy.ops.import_scene.x3d( + filepath=filePath, + axis_forward=self.axis_forward, + axis_up=self.axis_up) + + def exportOBJ(self, filePath): + if bpy.app.version >= (4, 0, 0): + showError("This version of Stop Motion OBJ doesn't support Blender 4.0") + elif bpy.app.version < (2, 92, 0): + showError("This version of Stop Motion OBJ requires at least Blender 2.92") + elif bpy.app.version >= (3, 3, 0): + newForwardAxisStr = convertOldToNewAxisStr(self.axis_forward) + newUpAxisStr = convertOldToNewAxisStr(self.axis_up) + bpy.ops.wm.obj_export( + filepath=filePath, + check_existing=False, + export_selected_objects=True, + export_animation=False, + export_triangulated_mesh=False, + forward_axis=newForwardAxisStr, + up_axis=newUpAxisStr) + elif bpy.app.version < (3, 3, 0): + bpy.ops.export_scene.obj( + filepath=filePath, + check_existing=False, + use_selection=True, + use_animation=False, + use_edges=self.obj_use_edges, + use_smooth_groups=self.obj_use_smooth_groups, + use_materials=False, + keep_vertex_order=True, + axis_forward=self.axis_forward, + axis_up=self.axis_up) + # TODO: apply modifiers? global_scale? + + def exportSTL(self, filePath): + bpy.ops.export_mesh.stl( + filepath=filePath, + check_existing=False, + use_selection=True, + axis_forward=self.axis_forward, + axis_up=self.axis_up) + + def exportPLY(self, filePath): + if bpy.app.version >= (4, 0, 0): + showError("This version of Stop Motion OBJ doesn't support Blender 4.0") + elif bpy.app.version >= (3, 3, 0): + newForwardAxisStr = convertOldToNewAxisStr(self.axis_forward) + newUpAxisStr = convertOldToNewAxisStr(self.axis_up) + bpy.ops.wm.ply_export( + filepath=filePath, + check_existing=False, + export_selected_objects=True, + export_animation=False, + export_triangulated_mesh=False, + forward_axis=newForwardAxisStr, + up_axis=newUpAxisStr) + # TODO: apply modifiers? global_scale? + + def exportX3D(self, filePath): + bpy.ops.export_scene.x3d( + filepath=filePath, + check_existing=False, + use_selection=True, + axis_forward=self.axis_forward, + axis_up=self.axis_up) class MeshNameProp(bpy.types.PropertyGroup): @@ -290,6 +625,10 @@ class MeshNameProp(bpy.types.PropertyGroup): class MeshSequenceSettings(bpy.types.PropertyGroup): + isImported: bpy.props.BoolProperty( + name="Sequence Is Imported", + description="Whether the sequence was loaded from files on disk (True), or created in Blender (False)", + default=True) version: bpy.props.PointerProperty(type=SequenceVersion) fileImporter: bpy.props.PointerProperty(type=MeshImporter) @@ -323,7 +662,9 @@ class MeshSequenceSettings(bpy.types.PropertyGroup): fileFormat: bpy.props.EnumProperty( items=[('obj', 'OBJ', 'Wavefront OBJ'), ('stl', 'STL', 'STereoLithography'), - ('ply', 'PLY', 'Stanford PLY')], + ('ply', 'PLY', 'Stanford PLY'), + ('x3d', 'X3D', 'X3D Extensible 3D'), + ('wrl', 'WRL', 'VRML2 (WRL)')], name='File Format', default='obj') @@ -331,24 +672,50 @@ class MeshSequenceSettings(bpy.types.PropertyGroup): name='Start Frame', update=handlePlaybackChange, default=1) + + # this is the property that is keyframed by the artist when the sequence is in Keyframe playback mode + curKeyframeMeshIdx: bpy.props.IntProperty( + name='Active mesh', + default=1) + + # this is the index of the currently-visible mesh + curVisibleMeshIdx: bpy.props.IntProperty( + name='Visible mesh', + default=1 + ) + + autoExportChanges: bpy.props.BoolProperty( + name='Auto-export changes (slow)', + description='Automatically export meshes that have been modified while in Sculpt Mode', + update=handleAutoExportChange, + default=False) + + overwriteSrcDir: bpy.props.BoolProperty( + name='Overwrite Source', + description='Save updated meshes over the original mesh files', + default=False) + + exportDir: bpy.props.StringProperty( + name='Export Folder', + description='The path to the folder where exported files will be stored. If none is specified, a temp folder will be created', + subtype='DIR_PATH') - # TODO: deprecate meshNames in version 3.0.0. This will break backwards compatibility with version 2.0.2 and earlier - meshNames: bpy.props.StringProperty() meshNameArray: bpy.props.CollectionProperty(type=MeshNameProp) numMeshes: bpy.props.IntProperty() numMeshesInMemory: bpy.props.IntProperty(default=0) initialized: bpy.props.BoolProperty(default=False) loaded: bpy.props.BoolProperty(default=False) - # out-of-range frame mode + # frame progression mode frameMode: bpy.props.EnumProperty( items=[('0', 'Blank', 'Object disappears when frame is out of range'), ('1', 'Extend', 'First and last frames are duplicated'), ('2', 'Repeat', 'Repeat the animation'), - ('3', 'Bounce', 'Play in reverse at the end of the frame range')], + ('3', 'Bounce', 'Play in reverse at the end of the frame range'), + ('4', 'Keyframe', 'Use keyframe curves to control sequence playback')], name='Mode', default='1', - update=handlePlaybackChange) + update=handlePlaybackChange) # the number of frames to keep in memory if you're in streaming mode cacheSize: bpy.props.IntProperty( @@ -371,6 +738,14 @@ class MeshSequenceSettings(bpy.types.PropertyGroup): precision=2, default=1, update=handlePlaybackChange) + + # note: this is really only used for streaming sequences + shadingMode: bpy.props.EnumProperty( + name='Shading Mode', + items=[('flat', 'Flat', 'Flat shading'), + ('smooth', 'Smooth', 'Smooth shading'), + ('imported', 'As Imported', 'Allow the importer to read the shading mode from the file')], + default='imported') @persistent @@ -378,6 +753,12 @@ def initializeSequences(scene): for obj in bpy.data.objects: if obj.mesh_sequence_settings.initialized is True: loadSequenceFromBlendFile(obj) + + # If auto-export is enabled, we'll need to recalculate the mesh hash for the current mesh. + # This is because Python's hash function produces different values for each run of Python + # (i.e. every time you start Blender) + if obj.mesh_sequence_settings.autoExportChanges is True: + obj.data.meshHash = getMeshHashStr(obj.data) freeUnusedMeshes() @@ -404,12 +785,11 @@ def deleteLinkedMeshMaterials(mesh, maxMaterialUsers=1, maxImageUsers=0): def newMeshSequence(): - bpy.ops.object.add(type='MESH') + theMesh = bpy.data.meshes.new(createUniqueName('emptyMesh', bpy.data.meshes)) + theObj = bpy.data.objects.new(createUniqueName('sequence', bpy.data.objects), theMesh) + bpy.context.collection.objects.link(theObj) + # this new object should be the currently-selected object - theObj = bpy.context.object - theObj.name = 'sequence' - theMesh = theObj.data - theMesh.name = createUniqueMeshName('emptyMesh') theMesh.use_fake_user = True theMesh.inMeshSequence = True @@ -419,6 +799,9 @@ def newMeshSequence(): emptyMeshNameElement.key = theMesh.name emptyMeshNameElement.inMemory = True + mss.numMeshes = 1 + mss.numMeshesInMemory = 1 + deselectAll() theObj.select_set(state=True) @@ -486,20 +869,42 @@ def loadSequenceFromMeshFiles(_obj, _dir, _file): sortedFiles = sorted(unsortedFiles, key=alphanumKey) mss = _obj.mesh_sequence_settings + + # get the basename without frame numbers + firstBaseName = os.path.basename(sortedFiles[0]) + commonMeshName = os.path.splitext(firstBaseName)[0].rstrip('._0123456789') deselectAll() for file in sortedFiles: # import the mesh file - mss.fileImporter.load(mss.fileFormat, file) - tmpObject = bpy.context.selected_objects[0] + mss.fileImporter.load(mss.fileFormat, file, False) + + # get the first object of type MESH + # TODO: eventually, let's pull out all MESH objects and put them into their own individual sequences + tmpObject = next(filter(lambda meshObj: meshObj.type == 'MESH', bpy.context.selected_objects), None) + + tmpMesh = None + + # if the mesh is None, we need to create an empty mesh, otherwise it will fail and/or leave gaps in the sequence + if (tmpObject is None): + meshBaseName = os.path.splitext(os.path.basename(file))[0] + tmpMesh = bpy.data.meshes.new(meshBaseName) + else: + # IMPORTANT: don't copy it; just copy the pointer. This cuts memory usage in half. + tmpMesh = tmpObject.data - # IMPORTANT: don't copy it; just copy the pointer. This cuts memory usage in half. - tmpMesh = tmpObject.data tmpMesh.use_fake_user = True tmpMesh.inMeshSequence = True + + # make a list of the objects we're going to delete + objsToDelete = bpy.context.selected_objects.copy() + + # now, delete all selected objects. Yes, even our precious mesh object. We already saved its mesh data + for obj in objsToDelete: + bpy.data.objects.remove(obj, do_unlink=True) + + # deselect everything just to be safe deselectAll() - tmpObject.select_set(state=True) - bpy.ops.object.delete() # if this is not the first frame, remove any materials and/or images imported with the mesh if numFrames >= 1 and mss.perFrameMaterial is False: @@ -527,18 +932,6 @@ def loadSequenceFromBlendFile(_obj): scn = bpy.context.scene mss = _obj.mesh_sequence_settings - # if meshNames is not blank, we have an old file that must be converted to the new CollectionProperty format - if mss.meshNames: - # split meshNames - # store them in mesh_sequence_settings.meshNameArray - for meshName in mss.meshNames.split('/'): - meshNameArrayElement = mss.meshNameArray.add() - meshNameArrayElement.key = meshName - meshNameArrayElement.inMemory = True - - # make sure the meshNames is not saved to the .blend file - mss.meshNames = '' - # count the number of mesh names (helps with backwards compatibility) mss.numMeshes = len(mss.meshNameArray) @@ -629,15 +1022,16 @@ def setFrameNumber(frameNum): def getMeshIdxFromFrameNumber(_obj, frameNum): - numRealMeshes = _obj.mesh_sequence_settings.numMeshes - 1 + mss = _obj.mesh_sequence_settings + numRealMeshes = mss.numMeshes - 1 # convert the frame number into a zero-based array index - offsetFromStart = frameNum - _obj.mesh_sequence_settings.startFrame + offsetFromStart = frameNum - mss.startFrame # adjust for playback speed - scaledIdxFloat = offsetFromStart * _obj.mesh_sequence_settings.speed + scaledIdxFloat = offsetFromStart * mss.speed finalIdx = 0 - frameMode = _obj.mesh_sequence_settings.frameMode + frameMode = mss.frameMode # 0: Blank if frameMode == '0': finalIdx = int(scaledIdxFloat) @@ -669,57 +1063,82 @@ def getMeshIdxFromFrameNumber(_obj, frameNum): if(numCycles % 2 == 1): finalIdx = (numRealMeshes - 1) - finalIdx + # 4: Keyframe + elif frameMode == '4': + finalIdx = 0 + if _obj.animation_data != None: + # we can't just look at mss.curKeyframeMeshIdx since it hasn't yet been updated + # instead we have to evaluate the actual keyframe curve at this new frame number + meshIdxCurve = next(curve for curve in _obj.animation_data.action.fcurves if 'curKeyframeMeshIdx' in curve.data_path) + + # make sure the 1-based index is in-bounds + curveValue = clamp(meshIdxCurve.evaluate(frameNum), 1, numRealMeshes) + + # subtract one since the keyframe curve is 1-based, but we're looking for a 0-based index + finalIdx = int(curveValue) - 1 + + # account for the fact that everything is shifted by 1 because of "emptyMesh" at index 0 return finalIdx + 1 def setFrameObj(_obj, frameNum): # store the current mesh for grabbing the material later - prev_mesh = _obj.data + prevMesh = _obj.data idx = getMeshIdxFromFrameNumber(_obj, frameNum) - next_mesh = getMeshFromIndex(_obj, idx) + _obj.mesh_sequence_settings.curVisibleMeshIdx = idx + nextMesh = getMeshFromIndex(_obj, idx) - if (next_mesh != prev_mesh): + if nextMesh != prevMesh: # swap the meshes - _obj.data = next_mesh + _obj.data = nextMesh if _obj.mesh_sequence_settings.perFrameMaterial is False: # if the previous mesh had a material, copy it to the new one - if(len(prev_mesh.materials) > 0): + if len(prevMesh.materials) > 0: _obj.data.materials.clear() - for material in prev_mesh.materials: + for material in prevMesh.materials: _obj.data.materials.append(material) def setFrameObjStreamed(obj, frameNum, forceLoad=False, deleteMaterials=False): mss = obj.mesh_sequence_settings idx = getMeshIdxFromFrameNumber(obj, frameNum) + mss.curVisibleMeshIdx = idx nextMeshProp = getMeshPropFromIndex(obj, idx) # if we want to load new meshes as needed and it's not already loaded if nextMeshProp.inMemory is False and (mss.streamDuringPlayback is True or forceLoad is True): importStreamedFile(obj, idx) + obj.select_set(state=True) if deleteMaterials is True: - next_mesh = getMeshFromIndex(obj, idx) - deleteLinkedMeshMaterials(next_mesh) + nextMesh = getMeshFromIndex(obj, idx) + deleteLinkedMeshMaterials(nextMesh) # if the mesh is in memory, show it if nextMeshProp.inMemory is True: - next_mesh = getMeshFromIndex(obj, idx) + nextMesh = getMeshFromIndex(obj, idx) + + # if the user has enabled auto-shading + if (mss.shadingMode != 'imported'): + # shade smooth/flat the mesh based on the sequence settings + useSmooth = True if mss.shadingMode == 'smooth' else False + shadeMesh(nextMesh, useSmooth) # store the current mesh for grabbing the material later - prev_mesh = obj.data - if next_mesh != prev_mesh: + prevMesh = obj.data + if nextMesh != prevMesh: # swap the old one with the new one - obj.data = next_mesh + obj.data = nextMesh # if we need to, copy the materials from the old one onto the new one if obj.mesh_sequence_settings.perFrameMaterial is False: - if len(prev_mesh.materials) > 0: + if len(prevMesh.materials) > 0: obj.data.materials.clear() - for material in prev_mesh.materials: + for material in prevMesh.materials: obj.data.materials.append(material) + if mss.cacheSize > 0 and mss.numMeshesInMemory > mss.cacheSize: idxToDelete = nextCachedMeshToDelete(obj, idx) if idxToDelete >= 0: @@ -744,16 +1163,34 @@ def importStreamedFile(obj, idx): absDirectory = bpy.path.abspath(mss.dirPath) filename = os.path.join(absDirectory, mss.meshNameArray[idx].basename) deselectAll() - mss.fileImporter.load(mss.fileFormat, filename) - tmpObject = getSelectedObjects()[0] - tmpMesh = tmpObject.data + + lockLoadingSequence(True) + mss.fileImporter.load(mss.fileFormat, filename, True) + lockLoadingSequence(False) + + selectedObjects = getSelectedObjects() + tmpObject = next(filter(lambda meshObj: meshObj.type == 'MESH', selectedObjects), None) + + tmpMesh = None + + # if tmpObject is None, we'll need to create an empty mesh to take its place + if (tmpObject is None): + # take the frame numbers off the basename + meshBaseName = os.path.splitext(mss.meshNameArray[idx].basename)[0] + tmpMesh = bpy.data.meshes.new(meshBaseName) + else: + tmpMesh = tmpObject.data + + # make a list of the objects we're going to delete + objsToDelete = selectedObjects.copy() + + # now delete all selected objects + for obj in objsToDelete: + bpy.data.objects.remove(obj, do_unlink=True) # we want to make sure the cached meshes are saved to the .blend file tmpMesh.use_fake_user = True tmpMesh.inMeshSequence = True - deselectAll() - tmpObject.select_set(state=True) - bpy.data.objects.remove(tmpObject) mss.meshNameArray[idx].key = tmpMesh.name mss.meshNameArray[idx].inMemory = True mss.numMeshesInMemory += 1 @@ -779,21 +1216,36 @@ def removeMeshFromScene(meshKey, removeOwnedMaterials): meshToRemove.use_fake_user = False bpy.data.meshes.remove(meshToRemove) +# shadeMesh function +def shadeMesh(mesh, smooth): + mesh.polygons.foreach_set('use_smooth', [smooth] * len(mesh.polygons)) + + # update the mesh to force a UI update + mesh.update() -def shadeSequence(_obj, smooth): - deselectAll() - _obj.select_set(state=True) - # grab the current mesh so we can put it back later - origMesh = _obj.data - for idx in range(1, _obj.mesh_sequence_settings.numMeshes): - _obj.data = getMeshFromIndex(_obj, idx) - if(smooth): - bpy.ops.object.shade_smooth() - else: - bpy.ops.object.shade_flat() - # reset the sequence's mesh to the right one based on the current frame - _obj.data = origMesh +def shadeSequence(obj, smooth): + mss = obj.mesh_sequence_settings + mss.shadingMode = 'smooth' if smooth else 'flat' + + # if this is a cached sequence, simply smooth/flatten all the faces in every mesh + if (mss.cacheMode == 'cached'): + for idx in range(1, mss.numMeshes): + mesh = getMeshFromIndex(obj, idx) + shadeMesh(mesh, smooth) + + elif (mss.cacheMode == 'streaming'): + useSmooth = True if mss.shadingMode == 'smooth' else False + + # iterate over the cached meshes, smoothing/flattening each mesh + for idx in range(1, len(mss.meshNameArray)): + meshNameObj = mss.meshNameArray[idx] + + # if the mesh is in memory, shade it smooth/flat + if (meshNameObj.inMemory is True): + mesh = bpy.data.meshes[meshNameObj.key] + shadeMesh(mesh, useSmooth) + def bakeSequence(_obj): scn = bpy.context.scene @@ -931,6 +1383,42 @@ def deepDeleteSequence(obj): bpy.data.images.remove(bpy.data.images[imageKey]) +def mergeDuplicateMaterials(obj): + matBaseNames = {} + materialRemapList = [] + + mss = obj.mesh_sequence_settings + + # for each mesh in the sequence: + for mesh in mss.meshNameArray: + meshName = mesh.key + + # get the materials + mats = bpy.data.meshes[meshName].materials + + for idx, mat in enumerate(mats): + matName = mat.name_full + + # strip off the ".00x" at the end of its name + dotIdx = matName.rfind('.') + matBaseName = matName if dotIdx == -1 else matName[0:dotIdx] + + # if this name prefix is not already in dictionary + if matBaseName not in matBaseNames: + # add it to the dictionary as a set (namePrefix, name_full) + matBaseNames[matBaseName] = matName + else: + # add an entry to the material remap list: (mesh name, material index, new material name_full) + materialRemapList.append((meshName, idx, matBaseNames[matBaseName])) + + # for each entry in the material remap list: + for item in materialRemapList: + newMat = bpy.data.materials[item[2]] + + # assign the new material to the correct material slot on the given mesh + bpy.data.meshes[item[0]].materials[item[1]] = newMat + + def freeUnusedMeshes(): numFreed = 0 for t_mesh in bpy.data.meshes: @@ -1035,3 +1523,47 @@ def execute(self, context): deepDeleteSequence(obj) return {'FINISHED'} + +class MergeDuplicateMaterials(bpy.types.Operator): + """Merge Duplicate Materials""" + bl_idname = "ms.merge_duplicate_materials" + bl_label = "Merge Duplicate Materials" + bl_options = {'UNDO'} + + def execute(self, context): + obj = context.object + mss = obj.mesh_sequence_settings + if mss.initialized is False or mss.loaded is False: + self.report({'ERROR'}, "Mesh sequence is not loaded") + return {'CANCELLED'} + + mergeDuplicateMaterials(obj) + return {'FINISHED'} + +# 'mesh' is a Blender mesh +# TODO: write another version that accepts a list of vertices and triangles +# and creates a new Blender mesh +def addMeshToSequence(seqObj, mesh): + mesh.inMeshSequence = True + + mss = seqObj.mesh_sequence_settings + + # add the new mesh to meshNameArray + newMeshNameElement = mss.meshNameArray.add() + newMeshNameElement.key = mesh.name_full + newMeshNameElement.inMemory = True + + # increment numMeshes + mss.numMeshes = mss.numMeshes + 1 + + # increment numMeshesInMemory + mss.numMeshesInMemory = mss.numMeshesInMemory + 1 + + # set initialized to True + mss.initialized = True + + # set loaded to True + mss.loaded = True + + return mss.numMeshes - 1 + diff --git a/src/version.py b/src/version.py index 18d3ddb..cb6fefd 100644 --- a/src/version.py +++ b/src/version.py @@ -2,5 +2,5 @@ # (major, minor, revision, development) # example dev version: (1, 2, 3, "beta.4") # example release version: (2, 3, 4) -currentScriptVersion = (2, 1, 1) +currentScriptVersion = (2, 2, 0) legacyScriptVersion = (2, 0, 2, "legacy")