|
| 1 | +import os |
| 2 | +import time |
| 3 | +import subprocess |
| 4 | + |
| 5 | +import bmesh |
| 6 | +import bpy |
| 7 | +from bpy.types import Operator |
| 8 | + |
| 9 | +from ..bmesh_operations.mesh_edit import bmesh_join |
| 10 | +from ..collider_shapes.add_bounding_primitive import OBJECT_OT_add_bounding_object |
| 11 | + |
| 12 | + |
| 13 | +class COACD_OT_convex_decomposition(OBJECT_OT_add_bounding_object, Operator): |
| 14 | + bl_idname = 'collision.coacd' |
| 15 | + bl_label = 'Auto Convex (BETA)' |
| 16 | + bl_description = ('Create multiple convex hull colliders using CoACD (Collision-Aware Concavity and tree ' |
| 17 | + 'search), the successor to V-HACD. This operator is still in BETA') |
| 18 | + bl_options = {'REGISTER', 'PRESET', 'UNDO'} |
| 19 | + |
| 20 | + @staticmethod |
| 21 | + def overwrite_executable_path(path): |
| 22 | + """Users can overwrite the default executable path.""" |
| 23 | + executable_path = bpy.path.abspath(path) |
| 24 | + return executable_path if os.path.isfile(executable_path) else False |
| 25 | + |
| 26 | + @staticmethod |
| 27 | + def set_temp_data_path(path): |
| 28 | + """Set folder to temporarily store the exported data.""" |
| 29 | + if not path or not os.path.isdir(os.path.normpath(bpy.path.abspath(path))): |
| 30 | + import tempfile |
| 31 | + fallback_path = tempfile.gettempdir() |
| 32 | + print(f"Warning: Path is invalid or not set. Falling back to: {fallback_path}") |
| 33 | + return fallback_path |
| 34 | + |
| 35 | + data_path = os.path.normpath(bpy.path.abspath(path)) |
| 36 | + if os.path.isdir(data_path) and os.access(data_path, os.W_OK): |
| 37 | + return data_path |
| 38 | + else: |
| 39 | + import tempfile |
| 40 | + fallback_path = tempfile.gettempdir() |
| 41 | + print(f"Warning: Path '{data_path}' is not writable. Falling back to: {fallback_path}") |
| 42 | + return fallback_path |
| 43 | + |
| 44 | + def __init__(self, *args, **kwargs): |
| 45 | + super().__init__(*args, **kwargs) |
| 46 | + self.use_decimation = True |
| 47 | + self.use_geo_nodes_hull = True |
| 48 | + self.use_modifier_stack = True |
| 49 | + self.use_recenter_origin = True |
| 50 | + self.shape = 'convex_shape' |
| 51 | + |
| 52 | + def invoke(self, context, event): |
| 53 | + return super().invoke(context, event) |
| 54 | + |
| 55 | + def modal(self, context, event): |
| 56 | + status = super().modal(context, event) |
| 57 | + if status == {'FINISHED'}: |
| 58 | + return {'FINISHED'} |
| 59 | + if status == {'CANCELLED'}: |
| 60 | + return {'CANCELLED'} |
| 61 | + if status == {'PASS_THROUGH'}: |
| 62 | + return {'PASS_THROUGH'} |
| 63 | + |
| 64 | + if event.type == 'P' and event.value == 'RELEASE': |
| 65 | + self.my_use_modifier_stack = not self.my_use_modifier_stack |
| 66 | + self.execute(context) |
| 67 | + |
| 68 | + return {'RUNNING_MODAL'} |
| 69 | + |
| 70 | + def cancel(self, context): |
| 71 | + context.space_data.shading.color_type = self.color_type |
| 72 | + try: |
| 73 | + bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') |
| 74 | + except ValueError: |
| 75 | + pass |
| 76 | + return {'CANCELLED'} |
| 77 | + |
| 78 | + def validate_paths_and_settings(self, context): |
| 79 | + """Validate executable and data paths, and report errors if invalid.""" |
| 80 | + overwrite_path = self.overwrite_executable_path(self.prefs.coacd_executable_path) |
| 81 | + coacd_exe = self.prefs.coacd_default_executable_path if not overwrite_path else overwrite_path |
| 82 | + data_path = self.set_temp_data_path(self.prefs.data_path) |
| 83 | + print(f"Using data path: {data_path}") |
| 84 | + |
| 85 | + if not coacd_exe: |
| 86 | + self.report({'ERROR'}, |
| 87 | + 'CoACD executable is required for Auto Convex (BETA) to work. Please follow the ' |
| 88 | + 'installation instructions and try it again') |
| 89 | + return None, None |
| 90 | + if not data_path: |
| 91 | + self.report({'ERROR'}, 'Invalid temporary data path') |
| 92 | + return None, None |
| 93 | + |
| 94 | + return coacd_exe, data_path |
| 95 | + |
| 96 | + def preprocess_objects_and_collect_data(self, context): |
| 97 | + """Preprocess selected objects and collect mesh data for convex decomposition.""" |
| 98 | + collider_data = [] |
| 99 | + meshes = [] |
| 100 | + matrices = [] |
| 101 | + |
| 102 | + objs = self.get_pre_processed_mesh_objs(context, default_world_spc=True) |
| 103 | + |
| 104 | + for base_ob, obj in objs: |
| 105 | + context.view_layer.objects.active = obj |
| 106 | + |
| 107 | + if self.obj_mode == "EDIT" and base_ob.type == 'MESH' and self.active_obj.type == 'MESH' and not self.use_loose_mesh: |
| 108 | + new_mesh = self.get_mesh_Edit(obj, use_modifiers=self.my_use_modifier_stack) |
| 109 | + else: |
| 110 | + new_mesh = self.mesh_from_selection(obj, use_modifiers=self.my_use_modifier_stack) |
| 111 | + |
| 112 | + if new_mesh is None: |
| 113 | + continue |
| 114 | + |
| 115 | + creation_mode = self.creation_mode[self.creation_mode_idx] if self.obj_mode == 'OBJECT' else \ |
| 116 | + self.creation_mode_edit[self.creation_mode_idx] |
| 117 | + if creation_mode in ['INDIVIDUAL'] or self.use_loose_mesh: |
| 118 | + convex_collision_data = {'parent': base_ob, 'mtx_world': base_ob.matrix_world.copy(), 'mesh': new_mesh} |
| 119 | + collider_data.append(convex_collision_data) |
| 120 | + else: |
| 121 | + meshes.append(new_mesh) |
| 122 | + matrices.append(obj.matrix_world) |
| 123 | + |
| 124 | + if self.creation_mode[self.creation_mode_idx] == 'SELECTION': |
| 125 | + convex_collision_data = {'parent': self.active_obj, 'mtx_world': self.active_obj.matrix_world.copy()} |
| 126 | + bmeshes = [bmesh.new() for mesh in meshes] |
| 127 | + for bm, mesh in zip(bmeshes, meshes): |
| 128 | + bm.from_mesh(mesh) |
| 129 | + joined_mesh = bmesh_join(bmeshes, matrices) |
| 130 | + convex_collision_data['mesh'] = joined_mesh |
| 131 | + collider_data = [convex_collision_data] |
| 132 | + |
| 133 | + bpy.ops.object.mode_set(mode='OBJECT') |
| 134 | + return collider_data |
| 135 | + |
| 136 | + def export_mesh_for_coacd(self, context, parent, mesh, data_path): |
| 137 | + """Export the mesh to OBJ format for CoACD processing.""" |
| 138 | + joined_obj = bpy.data.objects.new('debug_joined_mesh', mesh.copy()) |
| 139 | + context.scene.collection.objects.link(joined_obj) |
| 140 | + |
| 141 | + filename = ''.join(c for c in parent.name if c.isalnum() or c in (' ', '.', '_')).rstrip() |
| 142 | + obj_filename = os.path.join(data_path, f'{filename}.obj') |
| 143 | + |
| 144 | + print(f'\nExporting mesh for CoACD: {obj_filename}...') |
| 145 | + |
| 146 | + joined_obj.select_set(True) |
| 147 | + |
| 148 | + bpy.ops.wm.obj_export(filepath=obj_filename, check_existing=False, export_selected_objects=True, |
| 149 | + export_materials=False, export_uv=False, export_normals=False, |
| 150 | + forward_axis='Y', up_axis='Z') |
| 151 | + |
| 152 | + if self.prefs.debug: |
| 153 | + joined_obj.color = (1.0, 0.1, 0.1, 1.0) |
| 154 | + joined_obj.select_set(False) |
| 155 | + else: |
| 156 | + bpy.data.objects.remove(joined_obj) |
| 157 | + |
| 158 | + return obj_filename |
| 159 | + |
| 160 | + def run_coacd_decomposition(self, coacd_exe, obj_filename, data_path): |
| 161 | + """Run the CoACD decomposition process.""" |
| 162 | + col_settings = bpy.context.scene.simple_collider |
| 163 | + prefs = self.prefs |
| 164 | + |
| 165 | + basename = os.path.splitext(os.path.basename(obj_filename))[0] |
| 166 | + output_filename = os.path.join(data_path, f'{basename}_coacd.obj') |
| 167 | + remesh_filename = os.path.join(data_path, f'{basename}_coacd_remesh.obj') |
| 168 | + |
| 169 | + cmd_line = ( |
| 170 | + f'"{coacd_exe}" -i "{obj_filename}" -o "{output_filename}" -ro "{remesh_filename}" ' |
| 171 | + f'-t {col_settings.coacd_threshold} -c {col_settings.coacd_maxConvexHulls} ' |
| 172 | + f'-pm {prefs.coacd_preprocessMode} -pr {prefs.coacd_prepResolution} ' |
| 173 | + f'-mi {prefs.coacd_mctsIterations} -md {prefs.coacd_mctsDepth} -mn {prefs.coacd_mctsNodes} ' |
| 174 | + f'-r {prefs.coacd_resolution}' |
| 175 | + ) |
| 176 | + |
| 177 | + if col_settings.coacd_decimate: |
| 178 | + cmd_line += f' -d -dt {col_settings.coacd_maxHullVertCount}' |
| 179 | + if prefs.coacd_noMerge: |
| 180 | + cmd_line += ' -nm' |
| 181 | + if prefs.coacd_pca: |
| 182 | + cmd_line += ' --pca' |
| 183 | + |
| 184 | + print('Running CoACD...\n{}\n'.format(cmd_line)) |
| 185 | + print(f"Using data path for CoACD: {data_path}") |
| 186 | + |
| 187 | + coacd_process = subprocess.Popen(cmd_line, bufsize=-1, close_fds=True, shell=True, cwd=data_path) |
| 188 | + coacd_process.wait() |
| 189 | + |
| 190 | + if not os.path.isfile(output_filename): |
| 191 | + return None |
| 192 | + |
| 193 | + return output_filename |
| 194 | + |
| 195 | + def import_decomposed_meshes(self, obj_path): |
| 196 | + """Import the decomposed meshes from the CoACD output OBJ file.""" |
| 197 | + imported = [] |
| 198 | + |
| 199 | + bpy.ops.wm.obj_import(filepath=obj_path, forward_axis='Y', up_axis='Z') |
| 200 | + imported.extend(bpy.context.selected_objects) |
| 201 | + |
| 202 | + for ob in imported: |
| 203 | + ob.select_set(False) |
| 204 | + |
| 205 | + return imported |
| 206 | + |
| 207 | + def postprocess_colliders(self, context, convex_decomposition_data): |
| 208 | + """Postprocess the imported colliders: naming, parenting, and final setup.""" |
| 209 | + context.view_layer.objects.active = self.active_obj |
| 210 | + |
| 211 | + for convex_collisions_data in convex_decomposition_data: |
| 212 | + convex_collision = convex_collisions_data['colliders'] |
| 213 | + parent = convex_collisions_data['parent'] |
| 214 | + mtx_world = convex_collisions_data['mtx_world'] |
| 215 | + |
| 216 | + for new_collider in convex_collision: |
| 217 | + new_collider.name = super().collider_name(basename=parent.name) |
| 218 | + |
| 219 | + if self.creation_mode[self.creation_mode_idx] == 'INDIVIDUAL': |
| 220 | + if not self.use_loose_mesh: |
| 221 | + new_collider.matrix_world = mtx_world |
| 222 | + self.apply_transform(new_collider, rotation=True, scale=True) |
| 223 | + |
| 224 | + self.custom_set_parent(context, parent, new_collider) |
| 225 | + collections = parent.users_collection |
| 226 | + self.primitive_postprocessing(context, new_collider, collections) |
| 227 | + self.new_colliders_list.append(new_collider) |
| 228 | + |
| 229 | + def execute(self, context): |
| 230 | + """Main execution method for CoACD convex decomposition.""" |
| 231 | + super().execute(context) |
| 232 | + |
| 233 | + coacd_exe, data_path = self.validate_paths_and_settings(context) |
| 234 | + if not coacd_exe or not data_path: |
| 235 | + return self.cancel(context) |
| 236 | + |
| 237 | + for obj in self.selected_objects.copy(): |
| 238 | + obj.select_set(False) |
| 239 | + |
| 240 | + collider_data = self.preprocess_objects_and_collect_data(context) |
| 241 | + |
| 242 | + convex_decomposition_data = [] |
| 243 | + |
| 244 | + for convex_collision_data in collider_data: |
| 245 | + parent = convex_collision_data['parent'] |
| 246 | + mesh = convex_collision_data['mesh'] |
| 247 | + |
| 248 | + obj_filename = self.export_mesh_for_coacd(context, parent, mesh, data_path) |
| 249 | + if obj_filename is None: |
| 250 | + return self.cancel(context) |
| 251 | + |
| 252 | + output_obj = self.run_coacd_decomposition(coacd_exe, obj_filename, data_path) |
| 253 | + |
| 254 | + if output_obj is None: |
| 255 | + self.report({'WARNING'}, f'CoACD failed to generate colliders for {parent.name}') |
| 256 | + bpy.data.meshes.remove(mesh) |
| 257 | + continue |
| 258 | + |
| 259 | + imported = self.import_decomposed_meshes(output_obj) |
| 260 | + |
| 261 | + convex_collisions_data = {'colliders': imported, 'parent': parent, 'mtx_world': parent.matrix_world.copy()} |
| 262 | + convex_decomposition_data.append(convex_collisions_data) |
| 263 | + |
| 264 | + bpy.data.meshes.remove(mesh) |
| 265 | + |
| 266 | + self.postprocess_colliders(context, convex_decomposition_data) |
| 267 | + |
| 268 | + if len(self.new_colliders_list) < 1: |
| 269 | + self.report({'WARNING'}, 'No meshes to process!') |
| 270 | + return {'CANCELLED'} |
| 271 | + |
| 272 | + if self.join_primitives: |
| 273 | + super().join_primitives(context) |
| 274 | + |
| 275 | + super().reset_to_initial_state(context) |
| 276 | + elapsed_time = self.get_time_elapsed() |
| 277 | + super().print_generation_time("Auto Convex (BETA) Colliders", elapsed_time) |
| 278 | + self.report({'INFO'}, f"Auto Convex (BETA) Colliders: {elapsed_time}") |
| 279 | + |
| 280 | + return {'FINISHED'} |
0 commit comments