From 8b56e2cd90b26cd5b235f4d5284e8d180a83754d Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 3 Aug 2026 14:24:08 +0300 Subject: [PATCH 01/11] #660 Auto Convex (BETA): run CoACD asynchronously instead of freezing Blender Popen(...).wait() blocked Blender's main thread for as long as CoACD took, with zero progress feedback and no way to cancel - on real-world non-manifold meshes that's multiple minutes even with default settings, which reads as a complete freeze. The CLI (both the main decomposition pass and the per-hull decimate pass) is now driven from bpy.app.timers, polled a step at a time, with a HUD status while it runs and Escape actually killing the subprocess. --- auto_Convex/add_bounding_auto_convex_coacd.py | 422 +++++++++++++----- collider_shapes/add_bounding_primitive.py | 13 +- 2 files changed, 318 insertions(+), 117 deletions(-) diff --git a/auto_Convex/add_bounding_auto_convex_coacd.py b/auto_Convex/add_bounding_auto_convex_coacd.py index e6e023d9..fbe2db2e 100644 --- a/auto_Convex/add_bounding_auto_convex_coacd.py +++ b/auto_Convex/add_bounding_auto_convex_coacd.py @@ -9,6 +9,13 @@ from ..bmesh_operations.mesh_edit import bmesh_join from ..collider_shapes.add_bounding_primitive import OBJECT_OT_add_bounding_object +# How often the CoACD subprocess is polled for completion while it's +# running. Polling (rather than Popen.wait()) is what keeps Blender's UI +# thread responsive - CoACD's own MCTS search can take anywhere from +# fractions of a second to many minutes depending on mesh complexity, and +# there's no way to know in advance which it'll be (#660). +COACD_POLL_INTERVAL_SECONDS = 0.2 + class COACD_OT_convex_decomposition(OBJECT_OT_add_bounding_object, Operator): bl_idname = 'collision.coacd' @@ -49,10 +56,47 @@ def __init__(self, *args, **kwargs): self.use_recenter_origin = True self.shape = 'convex_shape' + # Async CoACD job state (#660: CoACD ran synchronously via + # subprocess.wait(), which froze Blender's main thread completely - + # with no progress feedback and no way to cancel - for as long as + # the CLI took, which for non-trivial/non-manifold real-world meshes + # can be many minutes even with default settings. See + # _start_next_coacd_job()/_poll_coacd_process() below: the CLI is + # now driven from bpy.app.timers, one polled step at a time, the + # same pattern already used for the debounce timers above. + self._coacd_process = None + self._coacd_exe = None + self._coacd_data_path = None + self._coacd_pending_jobs = [] + self._coacd_results = [] + self._coacd_stage = None # 'decompose' | 'decimate' + self._coacd_job_ctx = None + self._coacd_decimate_ctx = None + self._coacd_hull_queue = [] + self._coacd_decimated_hulls = [] + self._coacd_hull_index = 0 + self._coacd_start_time = 0.0 + self._status_area = None + def invoke(self, context, event): return super().invoke(context, event) def modal(self, context, event): + if self._coacd_process is not None: + # A CoACD job is in flight: swallow all input except viewport + # navigation (still allowed so the user isn't locked out of + # looking around while it runs) and cancel. Everything else - + # including LEFTMOUSE/RET confirm - is intentionally ignored, + # since the colliders this operator would finalize don't exist + # yet. + if event.type in {'MIDDLEMOUSE', 'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}: + return {'PASS_THROUGH'} + if event.type in {'RIGHTMOUSE', 'ESC'}: + self._cancel_coacd_job(context) + self.cancel_cleanup(context) + return {'CANCELLED'} + return {'RUNNING_MODAL'} + status = super().modal(context, event) if status == {'FINISHED'}: return {'FINISHED'} @@ -72,6 +116,7 @@ def modal(self, context, event): return {'RUNNING_MODAL'} def cancel(self, context): + self._cancel_coacd_job(context) context.space_data.shading.color_type = self.color_type try: bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') @@ -157,42 +202,88 @@ def export_mesh_for_coacd(self, context, parent, mesh, data_path): return obj_filename - def run_coacd_decomposition(self, coacd_exe, obj_filename, data_path): - """Run the CoACD decomposition process.""" - col_settings = bpy.context.scene.simple_collider + def _start_next_coacd_job(self, context): + """Pop the next collider off the queue and launch CoACD on it + without blocking. If the queue is empty, the whole run is done.""" + if not self._coacd_pending_jobs: + self._finish_coacd_run(context) + return + + convex_collision_data = self._coacd_pending_jobs.pop(0) + parent = convex_collision_data['parent'] + mesh = convex_collision_data['mesh'] + mtx_world = convex_collision_data['mtx_world'] + + obj_filename = self.export_mesh_for_coacd(context, parent, mesh, self._coacd_data_path) + + col_settings = context.scene.simple_collider prefs = self.prefs basename = os.path.splitext(os.path.basename(obj_filename))[0] - output_filename = os.path.join(data_path, f'{basename}_coacd.obj') - remesh_filename = os.path.join(data_path, f'{basename}_coacd_remesh.obj') + output_filename = os.path.join(self._coacd_data_path, f'{basename}_coacd.obj') + remesh_filename = os.path.join(self._coacd_data_path, f'{basename}_coacd_remesh.obj') - cmd_line = ( - f'"{coacd_exe}" -i "{obj_filename}" -o "{output_filename}" -ro "{remesh_filename}" ' - f'-t {col_settings.coacd_threshold} -c {col_settings.coacd_maxConvexHulls} ' - f'-pm {prefs.coacd_preprocessMode} -pr {prefs.coacd_prepResolution} ' - f'-mi {prefs.coacd_mctsIterations} -md {prefs.coacd_mctsDepth} -mn {prefs.coacd_mctsNodes} ' - f'-r {prefs.coacd_resolution}' - ) + cmd = [ + self._coacd_exe, '-i', obj_filename, '-o', output_filename, '-ro', remesh_filename, + '-t', str(col_settings.coacd_threshold), '-c', str(col_settings.coacd_maxConvexHulls), + '-pm', prefs.coacd_preprocessMode, '-pr', str(prefs.coacd_prepResolution), + '-mi', str(prefs.coacd_mctsIterations), '-md', str(prefs.coacd_mctsDepth), + '-mn', str(prefs.coacd_mctsNodes), '-r', str(prefs.coacd_resolution), + ] # -d/-dt is intentionally never combined with manifold preprocessing here: the CoACD 1.0.11 # CLI silently produces an empty output when both are active on the same pass (preprocess # collapses to 0 points). Hull vertex limiting is instead applied afterwards, per-hull, via - # decimate_convex_hulls(), where -pm off is safe because each hull is already convex/manifold. + # the decimate stage below, where -pm off is safe because each hull is already convex/manifold. if prefs.coacd_noMerge: - cmd_line += ' -nm' + cmd.append('-nm') if prefs.coacd_pca: - cmd_line += ' --pca' - - print('Running CoACD...\n{}\n'.format(cmd_line)) - print(f"Using data path for CoACD: {data_path}") - - coacd_process = subprocess.Popen(cmd_line, bufsize=-1, close_fds=True, shell=True, cwd=data_path) - coacd_process.wait() - - if not os.path.isfile(output_filename) or os.path.getsize(output_filename) == 0: - return None - - return output_filename + cmd.append('--pca') + + print('Running CoACD...\n{}\n'.format(' '.join(cmd))) + print(f"Using data path for CoACD: {self._coacd_data_path}") + + # No shell=True: CoACD is launched directly (not via an intermediate + # cmd.exe/sh -c) so that killing this Popen on cancel actually kills + # the CoACD process itself rather than leaving it running detached. + process = subprocess.Popen(cmd, cwd=self._coacd_data_path) + + self._coacd_process = process + self._coacd_stage = 'decompose' + self._coacd_job_ctx = { + 'parent': parent, + 'mesh': mesh, + 'mtx_world': mtx_world, + 'output_filename': output_filename, + } + self._coacd_start_time = time.time() + bpy.app.timers.register(self._poll_coacd_process, first_interval=COACD_POLL_INTERVAL_SECONDS) + + def _poll_coacd_process(self): + """bpy.app.timers callback: check whether the current CoACD/decimate + subprocess has finished, without blocking Blender's main thread. + Re-arms itself via its return value for as long as the process is + still running.""" + try: + process = self._coacd_process + if process is None: + return None + + if process.poll() is None: + if self._status_area is not None: + self._status_area.tag_redraw() + return COACD_POLL_INTERVAL_SECONDS + + self._coacd_process = None + context = bpy.context + if self._coacd_stage == 'decompose': + self._handle_decompose_finished(context) + else: + self._handle_decimate_finished(context) + except ReferenceError: + # operator has already finished/cancelled and its RNA was freed + pass + return None def import_decomposed_meshes(self, obj_path): """Import the decomposed meshes from the CoACD output OBJ file.""" @@ -206,58 +297,160 @@ def import_decomposed_meshes(self, obj_path): return imported - def decimate_convex_hulls(self, context, coacd_exe, hull_objects, data_path): - """Limit the vertex count of each convex hull individually. + def _handle_decompose_finished(self, context): + """Called once the main decomposition subprocess for one collider + has exited. Imports the result (if any) and either kicks off the + per-hull decimate pass or finalizes this collider's job.""" + job = self._coacd_job_ctx + output_filename = job['output_filename'] + parent = job['parent'] + mesh = job['mesh'] + mtx_world = job['mtx_world'] + + if not os.path.isfile(output_filename) or os.path.getsize(output_filename) == 0: + self.report({'WARNING'}, f'CoACD failed to generate colliders for {parent.name}') + bpy.data.meshes.remove(mesh) + self._coacd_job_ctx = None + self._start_next_coacd_job(context) + return + + imported = self.import_decomposed_meshes(output_filename) + + if context.scene.simple_collider.coacd_decimate: + self._coacd_hull_queue = list(imported) + self._coacd_decimated_hulls = [] + self._coacd_hull_index = 0 + self._start_next_decimate_hull(context) + else: + self._coacd_results.append({'colliders': imported, 'parent': parent, 'mtx_world': mtx_world}) + bpy.data.meshes.remove(mesh) + self._coacd_job_ctx = None + self._start_next_coacd_job(context) + + def _start_next_decimate_hull(self, context): + """Limit the vertex count of each convex hull individually, one hull + at a time, without blocking. CoACD's own -d/-dt decimation is run + here as a second, per-hull pass instead of alongside the main + decomposition: feeding it a fresh manifold single-hull mesh with + preprocessing forced off avoids the empty-output bug noted in + _start_next_coacd_job(), and (unlike feeding it the combined + multi-hull file) doesn't crash the CLI.""" + if not self._coacd_hull_queue: + job = self._coacd_job_ctx + self._coacd_results.append({ + 'colliders': self._coacd_decimated_hulls, + 'parent': job['parent'], + 'mtx_world': job['mtx_world'], + }) + bpy.data.meshes.remove(job['mesh']) + self._coacd_job_ctx = None + self._coacd_decimated_hulls = [] + self._start_next_coacd_job(context) + return - CoACD's own -d/-dt decimation is run here as a second, per-hull pass instead of - alongside the main decomposition: feeding it a fresh manifold single-hull mesh with - preprocessing forced off avoids the empty-output bug above, and (unlike feeding it the - combined multi-hull file) doesn't crash the CLI. - """ col_settings = context.scene.simple_collider - result = [] + hull_obj = self._coacd_hull_queue.pop(0) - for i, hull_obj in enumerate(hull_objects): - for ob in context.selected_objects: - ob.select_set(False) - hull_obj.select_set(True) - context.view_layer.objects.active = hull_obj - - basename = ''.join(c for c in hull_obj.name if c.isalnum() or c in (' ', '.', '_')).rstrip() - hull_filename = os.path.join(data_path, f'{basename}_hull_{i}.obj') - decimated_filename = os.path.join(data_path, f'{basename}_hull_{i}_dec.obj') - remesh_filename = os.path.join(data_path, f'{basename}_hull_{i}_dec_remesh.obj') - - bpy.ops.wm.obj_export(filepath=hull_filename, check_existing=False, export_selected_objects=True, - export_materials=False, export_uv=False, export_normals=False, - forward_axis='Y', up_axis='Z') - - cmd_line = ( - f'"{coacd_exe}" -i "{hull_filename}" -o "{decimated_filename}" -ro "{remesh_filename}" ' - f'-t {col_settings.coacd_threshold} -c -1 -pm off ' - f'-d -dt {col_settings.coacd_maxHullVertCount}' - ) - coacd_process = subprocess.Popen(cmd_line, bufsize=-1, close_fds=True, shell=True, cwd=data_path) - coacd_process.wait() - - hull_obj.select_set(False) - - if os.path.isfile(decimated_filename) and os.path.getsize(decimated_filename) > 0: - bpy.data.objects.remove(hull_obj) - bpy.ops.wm.obj_import(filepath=decimated_filename, forward_axis='Y', up_axis='Z') - new_hulls = context.selected_objects[:] - for ob in new_hulls: - ob.select_set(False) - result.extend(new_hulls) - else: - self.report({'WARNING'}, f'CoACD hull decimation failed for {hull_obj.name}, keeping original hull') - result.append(hull_obj) + for ob in context.selected_objects: + ob.select_set(False) + hull_obj.select_set(True) + context.view_layer.objects.active = hull_obj - for f in (hull_filename, decimated_filename, remesh_filename): - if os.path.isfile(f): - os.remove(f) + i = self._coacd_hull_index + self._coacd_hull_index += 1 - return result + basename = ''.join(c for c in hull_obj.name if c.isalnum() or c in (' ', '.', '_')).rstrip() + hull_filename = os.path.join(self._coacd_data_path, f'{basename}_hull_{i}.obj') + decimated_filename = os.path.join(self._coacd_data_path, f'{basename}_hull_{i}_dec.obj') + remesh_filename = os.path.join(self._coacd_data_path, f'{basename}_hull_{i}_dec_remesh.obj') + + bpy.ops.wm.obj_export(filepath=hull_filename, check_existing=False, export_selected_objects=True, + export_materials=False, export_uv=False, export_normals=False, + forward_axis='Y', up_axis='Z') + + cmd = [ + self._coacd_exe, '-i', hull_filename, '-o', decimated_filename, '-ro', remesh_filename, + '-t', str(col_settings.coacd_threshold), '-c', '-1', '-pm', 'off', + '-d', '-dt', str(col_settings.coacd_maxHullVertCount), + ] + process = subprocess.Popen(cmd, cwd=self._coacd_data_path) + + hull_obj.select_set(False) + + self._coacd_process = process + self._coacd_stage = 'decimate' + self._coacd_decimate_ctx = { + 'hull_obj': hull_obj, + 'hull_filename': hull_filename, + 'decimated_filename': decimated_filename, + 'remesh_filename': remesh_filename, + } + self._coacd_start_time = time.time() + bpy.app.timers.register(self._poll_coacd_process, first_interval=COACD_POLL_INTERVAL_SECONDS) + + def _handle_decimate_finished(self, context): + """Called once a single hull's decimate subprocess has exited.""" + d = self._coacd_decimate_ctx + hull_obj = d['hull_obj'] + hull_filename = d['hull_filename'] + decimated_filename = d['decimated_filename'] + remesh_filename = d['remesh_filename'] + + if os.path.isfile(decimated_filename) and os.path.getsize(decimated_filename) > 0: + bpy.data.objects.remove(hull_obj) + bpy.ops.wm.obj_import(filepath=decimated_filename, forward_axis='Y', up_axis='Z') + new_hulls = context.selected_objects[:] + for ob in new_hulls: + ob.select_set(False) + self._coacd_decimated_hulls.extend(new_hulls) + else: + self.report({'WARNING'}, f'CoACD hull decimation failed for {hull_obj.name}, keeping original hull') + self._coacd_decimated_hulls.append(hull_obj) + + for f in (hull_filename, decimated_filename, remesh_filename): + if os.path.isfile(f): + os.remove(f) + + self._coacd_decimate_ctx = None + self._start_next_decimate_hull(context) + + def _cancel_coacd_job(self, context): + """Kill any in-flight CoACD subprocess and drop all queued/partial + state for the current run. Called from modal()'s ESC/RIGHTMOUSE + handling and from cancel().""" + if self._coacd_process is not None: + try: + self._coacd_process.kill() + self._coacd_process.wait(timeout=5) + except Exception: + pass + self._coacd_process = None + + if self._coacd_job_ctx is not None: + mesh = self._coacd_job_ctx.get('mesh') + if mesh is not None: + try: + bpy.data.meshes.remove(mesh) + except ReferenceError: + pass + self._coacd_job_ctx = None + + if self._coacd_decimate_ctx is not None: + hull_obj = self._coacd_decimate_ctx.get('hull_obj') + if hull_obj is not None: + self.remove_objects([hull_obj]) + self._coacd_decimate_ctx = None + + self.remove_objects(self._coacd_hull_queue) + self.remove_objects(self._coacd_decimated_hulls) + for result in self._coacd_results: + self.remove_objects(result['colliders']) + + self._coacd_pending_jobs = [] + self._coacd_results = [] + self._coacd_hull_queue = [] + self._coacd_decimated_hulls = [] + self._coacd_stage = None def postprocess_colliders(self, context, convex_decomposition_data): """Postprocess the imported colliders: naming, parenting, and final setup.""" @@ -281,51 +474,18 @@ def postprocess_colliders(self, context, convex_decomposition_data): self.primitive_postprocessing(context, new_collider, collections) self.new_colliders_list.append(new_collider) - def execute(self, context): - """Main execution method for CoACD convex decomposition.""" - super().execute(context) - - coacd_exe, data_path = self.validate_paths_and_settings(context) - if not coacd_exe or not data_path: - return self.cancel(context) - - for obj in self.selected_objects.copy(): - obj.select_set(False) - - collider_data = self.preprocess_objects_and_collect_data(context) - - convex_decomposition_data = [] - - for convex_collision_data in collider_data: - parent = convex_collision_data['parent'] - mesh = convex_collision_data['mesh'] - - obj_filename = self.export_mesh_for_coacd(context, parent, mesh, data_path) - if obj_filename is None: - return self.cancel(context) - - output_obj = self.run_coacd_decomposition(coacd_exe, obj_filename, data_path) - - if output_obj is None: - self.report({'WARNING'}, f'CoACD failed to generate colliders for {parent.name}') - bpy.data.meshes.remove(mesh) - continue - - imported = self.import_decomposed_meshes(output_obj) - - if context.scene.simple_collider.coacd_decimate: - imported = self.decimate_convex_hulls(context, coacd_exe, imported, data_path) - - convex_collisions_data = {'colliders': imported, 'parent': parent, 'mtx_world': parent.matrix_world.copy()} - convex_decomposition_data.append(convex_collisions_data) - - bpy.data.meshes.remove(mesh) - - self.postprocess_colliders(context, convex_decomposition_data) + def _finish_coacd_run(self, context): + """Called once every queued collider has been decomposed (and + decimated, if enabled). Mirrors what the old synchronous execute() + did after its blocking loop finished.""" + self.postprocess_colliders(context, self._coacd_results) + self._coacd_results = [] if len(self.new_colliders_list) < 1: self.report({'WARNING'}, 'No meshes to process!') - return {'CANCELLED'} + if self._status_area is not None: + self._status_area.tag_redraw() + return if self.join_primitives: super().join_primitives(context) @@ -335,4 +495,34 @@ def execute(self, context): super().print_generation_time("Auto Convex (BETA) Colliders", elapsed_time) self.report({'INFO'}, f"Auto Convex (BETA) Colliders: {elapsed_time}") - return {'FINISHED'} + if self._status_area is not None: + self._status_area.tag_redraw() + + def execute(self, context): + """Kick off convex decomposition for the current selection. Does not + block: it launches the first CoACD job and returns immediately, + with _poll_coacd_process() (via bpy.app.timers) driving the rest.""" + if self._coacd_process is not None: + # Already running (e.g. a hotkey re-triggered execute() while a + # previous run is still in flight) - ignore rather than + # overlapping a second subprocess run. + return {'RUNNING_MODAL'} + + super().execute(context) + + coacd_exe, data_path = self.validate_paths_and_settings(context) + if not coacd_exe or not data_path: + return self.cancel(context) + + for obj in self.selected_objects.copy(): + obj.select_set(False) + + self._coacd_exe = coacd_exe + self._coacd_data_path = data_path + self._status_area = context.area + self._coacd_pending_jobs = self.preprocess_objects_and_collect_data(context) + self._coacd_results = [] + + self._start_next_coacd_job(context) + + return {'RUNNING_MODAL'} diff --git a/collider_shapes/add_bounding_primitive.py b/collider_shapes/add_bounding_primitive.py index 36c37d66..29e4a847 100644 --- a/collider_shapes/add_bounding_primitive.py +++ b/collider_shapes/add_bounding_primitive.py @@ -458,7 +458,18 @@ def draw_viewport_overlay(self, context): items.append(item) title_row = len(items) - if self.valid_input_selection: + # Auto Convex (BETA) only: an external CoACD subprocess may be running + # asynchronously (see COACD_OT_convex_decomposition), which can take + # anywhere from a fraction of a second to many minutes. getattr() keeps + # this safe for every other shape operator, which never sets this + # attribute at all. + _coacd_process = getattr(self, '_coacd_process', None) + if _coacd_process is not None: + elapsed = time.time() - self._coacd_start_time + label = f'RUNNING COACD - {elapsed:0.0f}S ELAPSED - ESC TO CANCEL' + item = {'label': label, 'value': None, 'key': '', 'type': 'key_title', 'highlight': True} + items.append(item) + elif self.valid_input_selection: if self.navigation: label = 'VIEWPORT NAVIGATION' type = 'key_title' From 42ba0b28ebe7ae0bb68e3ba48314cc9376af00f0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 3 Aug 2026 15:31:37 +0300 Subject: [PATCH 02/11] #660 Guard against overlapping Auto Convex (BETA) runs; retune CoACD defaults A synchronously-frozen UI was an accidental guard against a user triggering Auto Convex (BETA) twice concurrently - now that it stays responsive during a run, nothing stopped a second invocation from racing the first on identically-named temp files. Adds a module-level lock (invoke() refuses a second run while one is active) plus unique per-run temp filenames as defense in depth, and makes sure the lock always gets released even if a job errors out unexpectedly. Also changes CoACD defaults: max hulls 16 (was -1/unlimited) and hull vertex cap 32 with decimation on by default (was 256, off) - unlimited hulls at full resolution is what made the original freeze so easy to hit. --- auto_Convex/add_bounding_auto_convex_coacd.py | 50 +++++++++++++++++-- properties/properties.py | 6 +-- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/auto_Convex/add_bounding_auto_convex_coacd.py b/auto_Convex/add_bounding_auto_convex_coacd.py index fbe2db2e..f1eb95e5 100644 --- a/auto_Convex/add_bounding_auto_convex_coacd.py +++ b/auto_Convex/add_bounding_auto_convex_coacd.py @@ -16,6 +16,18 @@ # there's no way to know in advance which it'll be (#660). COACD_POLL_INTERVAL_SECONDS = 0.2 +# True while any COACD_OT_convex_decomposition instance has a job in flight. +# Module-level (not per-instance) because invoke() creates a brand new +# instance every time the operator is triggered - an instance attribute +# can't stop a *second*, independent invocation from starting. Before the +# CLI was made async (#660), a synchronously-frozen UI was an accidental +# guard against exactly this: the user physically couldn't trigger the +# operator a second time while the first call was still blocking. Now that +# Blender stays responsive during a run, nothing else prevents overlapping +# invocations - which would race on the same parent-name-derived temp +# filenames (see _job_file_prefix()) and corrupt each other's output. +_coacd_run_in_progress = False + class COACD_OT_convex_decomposition(OBJECT_OT_add_bounding_object, Operator): bl_idname = 'collision.coacd' @@ -77,8 +89,13 @@ def __init__(self, *args, **kwargs): self._coacd_hull_index = 0 self._coacd_start_time = 0.0 self._status_area = None + self._coacd_run_id = None def invoke(self, context, event): + if _coacd_run_in_progress: + self.report({'ERROR'}, 'Auto Convex (BETA) is already running - wait for it to finish, or select ' + 'it and press Escape to cancel, before starting another run') + return {'CANCELLED'} return super().invoke(context, event) def modal(self, context, event): @@ -187,8 +204,13 @@ def export_mesh_for_coacd(self, context, parent, mesh, data_path): joined_obj = bpy.data.objects.new('debug_joined_mesh', mesh.copy()) context.scene.collection.objects.link(joined_obj) + # _coacd_run_id makes this filename unique per run (see execute()): + # belt-and-suspenders against the _coacd_run_in_progress guard above + # missing some edge case and two runs sharing the same data_path + # ever overlapping - without it, two runs on a similarly-named + # object would silently clobber each other's export/output files. filename = ''.join(c for c in parent.name if c.isalnum() or c in (' ', '.', '_')).rstrip() - obj_filename = os.path.join(data_path, f'{filename}.obj') + obj_filename = os.path.join(data_path, f'{filename}_{self._coacd_run_id}.obj') print(f'\nExporting mesh for CoACD: {obj_filename}...') @@ -264,6 +286,7 @@ def _poll_coacd_process(self): subprocess has finished, without blocking Blender's main thread. Re-arms itself via its return value for as long as the process is still running.""" + global _coacd_run_in_progress try: process = self._coacd_process if process is None: @@ -282,7 +305,14 @@ def _poll_coacd_process(self): self._handle_decimate_finished(context) except ReferenceError: # operator has already finished/cancelled and its RNA was freed - pass + _coacd_run_in_progress = False + except Exception: + # Whatever went wrong, never leave the module-level run lock + # stuck - that would permanently block every future Auto Convex + # (BETA) invocation until Blender restarts. Still re-raised so + # the actual error is printed to the console as usual. + _coacd_run_in_progress = False + raise return None def import_decomposed_meshes(self, obj_path): @@ -360,9 +390,10 @@ def _start_next_decimate_hull(self, context): self._coacd_hull_index += 1 basename = ''.join(c for c in hull_obj.name if c.isalnum() or c in (' ', '.', '_')).rstrip() - hull_filename = os.path.join(self._coacd_data_path, f'{basename}_hull_{i}.obj') - decimated_filename = os.path.join(self._coacd_data_path, f'{basename}_hull_{i}_dec.obj') - remesh_filename = os.path.join(self._coacd_data_path, f'{basename}_hull_{i}_dec_remesh.obj') + hull_filename = os.path.join(self._coacd_data_path, f'{basename}_{self._coacd_run_id}_hull_{i}.obj') + decimated_filename = os.path.join(self._coacd_data_path, f'{basename}_{self._coacd_run_id}_hull_{i}_dec.obj') + remesh_filename = os.path.join(self._coacd_data_path, + f'{basename}_{self._coacd_run_id}_hull_{i}_dec_remesh.obj') bpy.ops.wm.obj_export(filepath=hull_filename, check_existing=False, export_selected_objects=True, export_materials=False, export_uv=False, export_normals=False, @@ -418,6 +449,9 @@ def _cancel_coacd_job(self, context): """Kill any in-flight CoACD subprocess and drop all queued/partial state for the current run. Called from modal()'s ESC/RIGHTMOUSE handling and from cancel().""" + global _coacd_run_in_progress + _coacd_run_in_progress = False + if self._coacd_process is not None: try: self._coacd_process.kill() @@ -478,6 +512,9 @@ def _finish_coacd_run(self, context): """Called once every queued collider has been decomposed (and decimated, if enabled). Mirrors what the old synchronous execute() did after its blocking loop finished.""" + global _coacd_run_in_progress + _coacd_run_in_progress = False + self.postprocess_colliders(context, self._coacd_results) self._coacd_results = [] @@ -502,6 +539,7 @@ def execute(self, context): """Kick off convex decomposition for the current selection. Does not block: it launches the first CoACD job and returns immediately, with _poll_coacd_process() (via bpy.app.timers) driving the rest.""" + global _coacd_run_in_progress if self._coacd_process is not None: # Already running (e.g. a hotkey re-triggered execute() while a # previous run is still in flight) - ignore rather than @@ -520,9 +558,11 @@ def execute(self, context): self._coacd_exe = coacd_exe self._coacd_data_path = data_path self._status_area = context.area + self._coacd_run_id = str(id(self)) self._coacd_pending_jobs = self.preprocess_objects_and_collect_data(context) self._coacd_results = [] + _coacd_run_in_progress = True self._start_next_coacd_job(context) return {'RUNNING_MODAL'} diff --git a/properties/properties.py b/properties/properties.py index a662d3a9..7f3e7bfd 100644 --- a/properties/properties.py +++ b/properties/properties.py @@ -69,7 +69,7 @@ class ColliderTools_Properties(bpy.types.PropertyGroup): coacd_maxConvexHulls: bpy.props.IntProperty(name='Max Hulls', description='Maximum number of output convex hulls. -1 for no limit ' '(only takes effect if merge is enabled)', - default=-1, + default=16, min=-1, soft_min=1, soft_max=64) @@ -77,11 +77,11 @@ class ColliderTools_Properties(bpy.types.PropertyGroup): # -d / -dt coacd_decimate: bpy.props.BoolProperty(name='Limit Hull Vertices', description='Enable a maximum vertex count constraint per convex hull', - default=False) + default=True) coacd_maxHullVertCount: bpy.props.IntProperty(name='Vert per Piece', description='Maximum number of vertices in each output convex hull', - default=256, + default=32, min=4, soft_max=256, max=4096) From 895b751a1acf0241a63ba5ea942f2027297f26b3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 3 Aug 2026 16:02:17 +0300 Subject: [PATCH 03/11] #660 Rename Auto Convex (BETA) to Auto Convex (High Precision) "BETA" didn't communicate the actual tradeoff users need to know before choosing it over V-HACD: CoACD produces tighter, more precise hulls by searching for better cuts, but is significantly slower - the operator was never actually unstable, just slow and previously freeze-prone (#660). Renamed the operator label, panel/button text, and tooltips throughout to say so directly instead of "BETA", and updated the description text to call out the speed cost explicitly. --- MANUAL_QA_CHECKLIST.md | 11 +++++----- README.md | 3 ++- auto_Convex/add_bounding_auto_convex_coacd.py | 22 ++++++++++--------- collider_shapes/add_bounding_primitive.py | 8 +++---- preferences/preferences.py | 6 ++--- ui/popup.py | 4 ++-- ui/properties_panels.py | 15 +++++++------ 7 files changed, 37 insertions(+), 32 deletions(-) diff --git a/MANUAL_QA_CHECKLIST.md b/MANUAL_QA_CHECKLIST.md index cfebbfa7..63755b3c 100644 --- a/MANUAL_QA_CHECKLIST.md +++ b/MANUAL_QA_CHECKLIST.md @@ -26,8 +26,9 @@ Blender version tested: ______ OS: ______ Date: ______ temp mesh/collection left behind. - [ ] Run Auto Convex (V-HACD) on a simple mesh with default settings → produces one or more convex hull colliders without hanging Blender. -- [ ] Run Auto Convex (CoACD, BETA) once with default settings → completes - and produces hulls (acceptable to be rougher — it's BETA). +- [ ] Run Auto Convex (CoACD, High Precision) once with default settings → + completes and produces hulls without freezing Blender (can take longer + than V-HACD — that's expected). - [ ] Convert to Collider on a plain mesh object → object becomes a collider in place; Convert to Mesh on a collider → reverses it back to a normal render mesh. @@ -182,10 +183,10 @@ Blender version tested: ______ OS: ______ Date: ______ is hidden entirely and prefs show an unsupported-platform message instead of a broken button. -### 5. Auto Convex — CoACD (BETA) +### 5. Auto Convex — CoACD (High Precision) -- [ ] Confirm the operator/label/prefs all clearly read "BETA" so testers - don't hold it to the same bar as V-HACD. +- [ ] Confirm the operator/label/prefs all clearly read "High Precision" (not + "BETA") and communicate the speed tradeoff vs. V-HACD. - [ ] Enable `coacd_decimate` with a low `coacd_maxHullVertCount` → each hull actually gets vertex-limited (check per-hull vert counts before/ after); a hull whose decimation fails reports "CoACD hull decimation diff --git a/README.md b/README.md index a10853da..15e67474 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ Simple Collider is a Blender addon for creating physics colliders for games and * Collider shapes: Box, Sphere, Cylinder, Capsule, Convex Hull, K-DOP (10/18/26), Minimum/Aligned Bounding Box, Re-meshed (voxel remesh), and full-detail Mesh. -* Auto Convex decomposition using V-HACD, plus an alternative CoACD backend (BETA). +* Auto Convex decomposition using V-HACD, plus an alternative CoACD backend for higher-precision (but slower) + results. * Validation Checks (BETA): scan the scene or selection for missing colliders, non-manifold geometry, flipped normals, oversized triangle counts, mismatched bounding boxes, naming/parenting conventions, missing physics materials, and more - configurable per-check in preferences. diff --git a/auto_Convex/add_bounding_auto_convex_coacd.py b/auto_Convex/add_bounding_auto_convex_coacd.py index f1eb95e5..029698d7 100644 --- a/auto_Convex/add_bounding_auto_convex_coacd.py +++ b/auto_Convex/add_bounding_auto_convex_coacd.py @@ -31,9 +31,10 @@ class COACD_OT_convex_decomposition(OBJECT_OT_add_bounding_object, Operator): bl_idname = 'collision.coacd' - bl_label = 'Auto Convex (BETA)' + bl_label = 'Auto Convex (High Precision)' bl_description = ('Create multiple convex hull colliders using CoACD (Collision-Aware Concavity and tree ' - 'search), the successor to V-HACD. This operator is still in BETA') + 'search). Produces fewer, tighter-fitting hulls than V-HACD by searching for better cuts, ' + 'but is significantly slower - can take minutes on complex or non-manifold meshes') bl_options = {'REGISTER', 'PRESET', 'UNDO'} @staticmethod @@ -93,8 +94,8 @@ def __init__(self, *args, **kwargs): def invoke(self, context, event): if _coacd_run_in_progress: - self.report({'ERROR'}, 'Auto Convex (BETA) is already running - wait for it to finish, or select ' - 'it and press Escape to cancel, before starting another run') + self.report({'ERROR'}, 'Auto Convex (High Precision) is already running - wait for it to finish, or ' + 'select it and press Escape to cancel, before starting another run') return {'CANCELLED'} return super().invoke(context, event) @@ -150,8 +151,8 @@ def validate_paths_and_settings(self, context): if not coacd_exe: self.report({'ERROR'}, - 'CoACD executable is required for Auto Convex (BETA) to work. Please follow the ' - 'installation instructions and try it again') + 'CoACD executable is required for Auto Convex (High Precision) to work. Please follow ' + 'the installation instructions and try it again') return None, None if not data_path: self.report({'ERROR'}, 'Invalid temporary data path') @@ -309,8 +310,9 @@ def _poll_coacd_process(self): except Exception: # Whatever went wrong, never leave the module-level run lock # stuck - that would permanently block every future Auto Convex - # (BETA) invocation until Blender restarts. Still re-raised so - # the actual error is printed to the console as usual. + # (High Precision) invocation until Blender restarts. Still + # re-raised so the actual error is printed to the console as + # usual. _coacd_run_in_progress = False raise return None @@ -529,8 +531,8 @@ def _finish_coacd_run(self, context): super().reset_to_initial_state(context) elapsed_time = self.get_time_elapsed() - super().print_generation_time("Auto Convex (BETA) Colliders", elapsed_time) - self.report({'INFO'}, f"Auto Convex (BETA) Colliders: {elapsed_time}") + super().print_generation_time("Auto Convex (High Precision) Colliders", elapsed_time) + self.report({'INFO'}, f"Auto Convex (High Precision) Colliders: {elapsed_time}") if self._status_area is not None: self._status_area.tag_redraw() diff --git a/collider_shapes/add_bounding_primitive.py b/collider_shapes/add_bounding_primitive.py index 29e4a847..87afd840 100644 --- a/collider_shapes/add_bounding_primitive.py +++ b/collider_shapes/add_bounding_primitive.py @@ -458,10 +458,10 @@ def draw_viewport_overlay(self, context): items.append(item) title_row = len(items) - # Auto Convex (BETA) only: an external CoACD subprocess may be running - # asynchronously (see COACD_OT_convex_decomposition), which can take - # anywhere from a fraction of a second to many minutes. getattr() keeps - # this safe for every other shape operator, which never sets this + # Auto Convex (High Precision) only: an external CoACD subprocess may be + # running asynchronously (see COACD_OT_convex_decomposition), which can + # take anywhere from a fraction of a second to many minutes. getattr() + # keeps this safe for every other shape operator, which never sets this # attribute at all. _coacd_process = getattr(self, '_coacd_process', None) if _coacd_process is not None: diff --git a/preferences/preferences.py b/preferences/preferences.py index 978cdf1b..69d3a5b3 100644 --- a/preferences/preferences.py +++ b/preferences/preferences.py @@ -426,10 +426,10 @@ def draw_vhacd_panel(self, layout, context): box = layout.box() row = box.row() - row.label(text="CoACD (BETA)", icon='MESH_ICOSPHERE') + row.label(text="CoACD (High Precision)", icon='MESH_ICOSPHERE') row.operator("wm.url_open", text="", icon='URL').url = "https://colin97.github.io/CoACD/" - box.label(text="CoACD is the successor to V-HACD and tends to produce fewer, tighter-fitting") - box.label(text="convex hulls. This operator is still in BETA, feedback is welcome.") + box.label(text="CoACD tends to produce fewer, tighter-fitting convex hulls than V-HACD, but is") + box.label(text="significantly slower - can take minutes on complex or non-manifold meshes.") row = box.row() row.label(text="Executable Paths:") diff --git a/ui/popup.py b/ui/popup.py index 490a9d49..0e75e3fb 100644 --- a/ui/popup.py +++ b/ui/popup.py @@ -43,7 +43,7 @@ def draw(self, context): class VIEW3D_PT_auto_convex_coacd_popup(bpy.types.Panel): """Tooltip""" bl_idname = "POPUP_PT_auto_convex_coacd" - bl_label = "Auto Convex (BETA) Info" + bl_label = "Auto Convex (High Precision) Info" bl_space_type = "VIEW_3D" bl_region_type = "WINDOW" @@ -60,7 +60,7 @@ def draw(self, context): colSettings = context.scene.simple_collider draw_auto_convex_coacd_settings(colSettings, layout) layout.label(text='May take up to a few minutes', icon='ERROR') - layout.operator("collision.coacd", text="Auto Convex (BETA)", icon='MESH_ICOSPHERE') + layout.operator("collision.coacd", text="Auto Convex (High Precision)", icon='MESH_ICOSPHERE') else: layout.label(text="Missing Permission", icon='ERROR') row = layout.row() diff --git a/ui/properties_panels.py b/ui/properties_panels.py index 2aa7fd2f..886cefde 100644 --- a/ui/properties_panels.py +++ b/ui/properties_panels.py @@ -149,7 +149,7 @@ def draw_auto_convex(layout, context): def draw_auto_convex_coacd(layout, context): """ - Draw the Auto Convex (BETA) (CoACD) options in the layout based on the current platform and preferences. + Draw the Auto Convex (High Precision) (CoACD) options in the layout based on the current platform and preferences. Args: layout (Layout): The layout to draw the options on. @@ -165,7 +165,7 @@ def draw_auto_convex_coacd(layout, context): op.addon_name = addon_name op.prefs_tabs = 'VHACD' - text = "Auto Convex (BETA) is only supported for Windows, Linux, and macOS ARM64 at this moment." + text = "Auto Convex (High Precision) is only supported for Windows, Linux, and macOS ARM64 at this moment." label_multiline( context=context, text=text, @@ -174,7 +174,7 @@ def draw_auto_convex_coacd(layout, context): else: if prefs.coacd_executable_path or prefs.coacd_default_executable_path: - layout.operator("button.auto_convex_coacd", text="Auto Convex (BETA)", icon='WINDOW') + layout.operator("button.auto_convex_coacd", text="Auto Convex (High Precision)", icon='WINDOW') op = layout.operator("simple_collider.open_preferences", text="", icon='PREFERENCES') op.addon_name = addon_name op.prefs_tabs = 'VHACD' @@ -204,7 +204,7 @@ def draw_auto_convex_settings(colSettings, layout): def draw_auto_convex_coacd_settings(colSettings, layout): """ - Draw the settings for Auto Convex (BETA) (CoACD) in the layout. + Draw the settings for Auto Convex (High Precision) (CoACD) in the layout. Args: colSettings (UILayout): The column layout to draw the settings on. @@ -823,10 +823,11 @@ def execute(self, context): class BUTTON_OT_auto_convex_coacd(bpy.types.Operator): - """Create convex hull colliders using CoACD (BETA)""" + """Create convex hull colliders using CoACD - more precise but slower than V-HACD""" bl_idname = "button.auto_convex_coacd" - bl_label = "Auto Convex (BETA)" - bl_description = 'Create convex hull colliders using CoACD, the successor to V-HACD (BETA)' + bl_label = "Auto Convex (High Precision)" + bl_description = ('Create convex hull colliders using CoACD - produces tighter, more precise hulls than ' + 'V-HACD, but is significantly slower') @classmethod def poll(cls, context): From eca6492bcdadffe01157f4fad7b2ac589152d360 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 3 Aug 2026 17:44:29 +0300 Subject: [PATCH 04/11] #660 Fix crash and silent transform/parenting loss on hulls after a viewport-less async step bpy.app.timers callbacks have no guaranteed context - bpy.context.space_data is None (or belongs to whatever editor the mouse happens to be over) unless the pointer is currently over this operator's own viewport, which is unlikely once a run takes minutes. postprocess_colliders() -> primitive_postprocessing() -> set_viewport_drawing() needs context.space_data.shading, so this crashed with AttributeError as soon as the mouse wasn't over the 3D viewport when the last subprocess finished - and since postprocess_colliders() processes hulls one at a time, every hull already handled before the crash kept its correct transform/parent/ modifiers, while every hull after it was left with its raw import-time transform and never parented. That's what looked like "some hulls get the right rotation/scale, others don't". Fixed by capturing a stable window/area/region in execute() and running every async completion step inside context.temp_override() with them, instead of trusting ambient bpy.context. --- auto_Convex/add_bounding_auto_convex_coacd.py | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/auto_Convex/add_bounding_auto_convex_coacd.py b/auto_Convex/add_bounding_auto_convex_coacd.py index 029698d7..abdf1f97 100644 --- a/auto_Convex/add_bounding_auto_convex_coacd.py +++ b/auto_Convex/add_bounding_auto_convex_coacd.py @@ -92,6 +92,21 @@ def __init__(self, *args, **kwargs): self._status_area = None self._coacd_run_id = None + # bpy.app.timers callbacks (see _poll_coacd_process()) have no + # guaranteed context - bpy.context.space_data is None (or belongs to + # whatever editor the mouse happens to be over) unless the pointer + # is currently over this operator's own VIEW_3D, which is unlikely + # once a run takes minutes and the user's mouse wanders off. Capture + # a stable window/area/region here at invoke time and override into + # it for every downstream async step, rather than trusting ambient + # bpy.context (whose space_data being None crashed + # set_viewport_drawing() mid-way through postprocess_colliders()'s + # per-hull loop, silently leaving every hull after the crash point + # untransformed and unparented). + self._invoke_window = None + self._invoke_area = None + self._invoke_region = None + def invoke(self, context, event): if _coacd_run_in_progress: self.report({'ERROR'}, 'Auto Convex (High Precision) is already running - wait for it to finish, or ' @@ -299,11 +314,21 @@ def _poll_coacd_process(self): return COACD_POLL_INTERVAL_SECONDS self._coacd_process = None - context = bpy.context - if self._coacd_stage == 'decompose': - self._handle_decompose_finished(context) - else: - self._handle_decimate_finished(context) + + # bpy.context here has no guaranteed space_data - it reflects + # whatever the mouse happens to be over (or nothing) at the + # moment this timer fires, not this operator's own viewport. + # postprocess_colliders() -> primitive_postprocessing() needs a + # real VIEW_3D context (context.space_data.shading), so override + # into the window/area/region captured back in execute() rather + # than trusting ambient context. + with bpy.context.temp_override(window=self._invoke_window, area=self._invoke_area, + region=self._invoke_region): + context = bpy.context + if self._coacd_stage == 'decompose': + self._handle_decompose_finished(context) + else: + self._handle_decimate_finished(context) except ReferenceError: # operator has already finished/cancelled and its RNA was freed _coacd_run_in_progress = False @@ -560,6 +585,9 @@ def execute(self, context): self._coacd_exe = coacd_exe self._coacd_data_path = data_path self._status_area = context.area + self._invoke_window = context.window + self._invoke_area = context.area + self._invoke_region = next((r for r in context.area.regions if r.type == 'WINDOW'), None) self._coacd_run_id = str(id(self)) self._coacd_pending_jobs = self.preprocess_objects_and_collect_data(context) self._coacd_results = [] From 9a1df419dab87274b7f30a95017fb9e534b1c6ae Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 3 Aug 2026 17:48:59 +0300 Subject: [PATCH 05/11] Improve addon consistancy --- .github/scripts/extract_changelog_section.py | 71 ++++++++++++++++++++ .github/workflows/BuildRelease.yml | 71 ++++++++++++++++++++ .github/workflows/TestBuild.yml | 50 +++++++++----- .github/workflows/main.yml | 54 --------------- blender_manifest.toml | 6 +- 5 files changed, 178 insertions(+), 74 deletions(-) create mode 100644 .github/scripts/extract_changelog_section.py create mode 100644 .github/workflows/BuildRelease.yml delete mode 100644 .github/workflows/main.yml diff --git a/.github/scripts/extract_changelog_section.py b/.github/scripts/extract_changelog_section.py new file mode 100644 index 00000000..e6368980 --- /dev/null +++ b/.github/scripts/extract_changelog_section.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Extract a single version's section from a CHANGELOG.md for use as GitHub release notes. + +Looks for a Markdown heading line starting with "## " that contains the given +version (e.g. "v1.2.0"), followed by a non-digit/non-dot character or end of +line (so "v1.2.0" doesn't accidentally match "v1.2.0-rc1" or "v1.2.01"). Prints +everything between that heading and the next "## " heading (or EOF), with +leading/trailing blank lines stripped. +""" + +import argparse +import re +import sys + + +def extract_section(changelog_text: str, version: str) -> str | None: + version_re = re.compile(re.escape(version) + r"([^0-9.]|$)") + lines = changelog_text.splitlines() + + start = None + end = len(lines) + for i, line in enumerate(lines): + if not line.startswith("## "): + continue + if start is None: + if version_re.search(line): + start = i + 1 + continue + end = i + break + + if start is None: + return None + + section_lines = lines[start:end] + while section_lines and not section_lines[0].strip(): + section_lines.pop(0) + while section_lines and not section_lines[-1].strip(): + section_lines.pop() + return "\n".join(section_lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--changelog", required=True, help="Path to CHANGELOG.md") + parser.add_argument("--version", required=True, help="Version to extract, e.g. v1.2.0 or 1.2.0") + parser.add_argument("--product-name", required=True, help="Display name used in the error message, e.g. 'Simple Collider'") + parser.add_argument("--output", required=True, help="Path to write the extracted section to") + args = parser.parse_args() + + version = args.version[1:] if args.version.startswith("v") else args.version + + with open(args.changelog, "r", encoding="utf-8") as f: + text = f.read() + + section = extract_section(text, version) + if not section: + print( + f"::error::No CHANGELOG.md section found for version {version} " + f"(tag v{version}). Add a '## {args.product_name} v{version}' section before tagging.", + file=sys.stderr, + ) + return 1 + + with open(args.output, "w", encoding="utf-8") as f: + f.write(section + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/BuildRelease.yml b/.github/workflows/BuildRelease.yml new file mode 100644 index 00000000..59c6f25c --- /dev/null +++ b/.github/workflows/BuildRelease.yml @@ -0,0 +1,71 @@ +name: Build Release + +on: + push: + tags: ["v[0-9]+.[0-9]+.[0-9]+"] + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + path: ${{ github.event.repository.name }} + + - name: Check manifest version matches tag + run: | + tag_version="${{ github.ref_name }}" + tag_version="${tag_version#v}" + manifest_version=$(python3 -c "import tomllib; print(tomllib.load(open('blender_manifest.toml','rb'))['version'])") + if [ "$tag_version" != "$manifest_version" ]; then + echo "::error::Tag v$tag_version != blender_manifest.toml version $manifest_version" + exit 1 + fi + working-directory: ${{ github.event.repository.name }} + + - name: Cache Blender 4.2 + id: cache-blender + uses: actions/cache@v4 + with: + path: blender-download + key: blender-4.2-ubuntu-latest + + - name: Download Blender 4.2 + if: steps.cache-blender.outputs.cache-hit != 'true' + run: | + mkdir -p blender-download + pip install blender-downloader + blender-downloader 4.2 --extract --quiet --output-directory blender-download + + - name: Locate Blender executable + id: blender + run: | + EXE=$(find blender-download -type f -iname blender | head -n 1) + if [ -z "$EXE" ]; then + echo "Could not find a Blender executable under blender-download/" >&2 + exit 1 + fi + chmod +x "$EXE" + echo "executable=$EXE" >> "$GITHUB_OUTPUT" + + - name: Validate extension manifest + run: | + "${{ steps.blender.outputs.executable }}" --command extension validate ${{ github.event.repository.name }} + + - name: Build extension zip + run: | + mkdir -p dist + "${{ steps.blender.outputs.executable }}" --command extension build --source-dir ${{ github.event.repository.name }} --output-dir dist + + - name: Extract release notes from CHANGELOG.md + run: | + python3 ${{ github.event.repository.name }}/.github/scripts/extract_changelog_section.py \ + --changelog ${{ github.event.repository.name }}/CHANGELOG.md \ + --version "${{ github.ref_name }}" \ + --product-name "Simple Collider" \ + --output release_notes.md + + - name: Create GitHub release + run: gh release create "${{ github.ref_name }}" --notes-file release_notes.md dist/*.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/TestBuild.yml b/.github/workflows/TestBuild.yml index 028d7266..fb4f62b6 100644 --- a/.github/workflows/TestBuild.yml +++ b/.github/workflows/TestBuild.yml @@ -11,33 +11,47 @@ jobs: with: path: ${{ github.event.repository.name }} - - name: Read version from blender_manifest.toml - id: version + - name: Cache Blender 4.2 + id: cache-blender + uses: actions/cache@v4 + with: + path: blender-download + key: blender-4.2-ubuntu-latest + + - name: Download Blender 4.2 + if: steps.cache-blender.outputs.cache-hit != 'true' + run: | + mkdir -p blender-download + pip install blender-downloader + blender-downloader 4.2 --extract --quiet --output-directory blender-download + + - name: Locate Blender executable + id: blender run: | - VERSION=$(python3 -c "import tomllib; f=open('${{ github.event.repository.name }}/blender_manifest.toml','rb'); print(tomllib.load(f)['version'])") - echo "version=$VERSION" >> $GITHUB_OUTPUT + EXE=$(find blender-download -type f -iname blender | head -n 1) + if [ -z "$EXE" ]; then + echo "Could not find a Blender executable under blender-download/" >&2 + exit 1 + fi + chmod +x "$EXE" + echo "executable=$EXE" >> "$GITHUB_OUTPUT" - name: Set short SHA id: vars run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT - - name: Prepare add-on folder + - name: Validate extension manifest + run: | + "${{ steps.blender.outputs.executable }}" --command extension validate ${{ github.event.repository.name }} + + - name: Build extension zip run: | - mkdir -p staging/simple_collider - rsync -a \ - --exclude='.git' \ - --exclude='.*' \ - --exclude='tests' \ - --exclude='venv' \ - --exclude='__pycache__' \ - --exclude='*.pyc' \ - --exclude='CHANGELOG.md' \ - --exclude='MANUAL_QA_CHECKLIST.md' \ - ${{ github.event.repository.name }}/ staging/simple_collider/ + mkdir -p dist + "${{ steps.blender.outputs.executable }}" --command extension build --source-dir ${{ github.event.repository.name }} --output-dir dist - name: Upload zip as artifact uses: actions/upload-artifact@v4 with: - name: simple_collider_${{ steps.version.outputs.version }}_${{ steps.vars.outputs.short_sha }} - path: staging/ + name: ${{ github.event.repository.name }}_${{ steps.vars.outputs.short_sha }} + path: dist/ retention-days: 7 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index cbb785c8..00000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Build Release - -on: - push: - tags: [ "v[0-9]+.[0-9]+.[0-9]+" ] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - path: ${{ github.event.repository.name }} - - - name: Zip Repository (excludes .git*) - run: | - version_with_underscores=$(echo ${{ github.ref_name }} | tr '.' '_') - zip -r simple_collider_${version_with_underscores}.zip \ - ${{ github.event.repository.name }} \ - -x "${{ github.event.repository.name }}/.git*" \ - -x "${{ github.event.repository.name }}/.*/*" \ - -x "${{ github.event.repository.name }}/tests/*" \ - -x "${{ github.event.repository.name }}/venv/*" \ - -x "${{ github.event.repository.name }}/CHANGELOG.md" \ - -x "${{ github.event.repository.name }}/MANUAL_QA_CHECKLIST.md" - - - name: Extract release notes from CHANGELOG.md - run: | - version="${{ github.ref_name }}" - version="${version#v}" - notes=$(awk -v ver="$version" ' - /^## / { - if (found) exit - if ($0 ~ ("v" ver "([^0-9.]|$)")) { found=1; next } - next - } - found { print } - ' CHANGELOG.md | sed '/./,$!d') - if [ -z "$notes" ]; then - echo "::error::No CHANGELOG.md section found for version $version (tag ${{ github.ref_name }}). Add a '## Simple Collider v$version' section before tagging." - exit 1 - fi - echo "$notes" > "${{ github.workspace }}/release_notes.md" - working-directory: ${{ github.event.repository.name }} - - - name: Create versioned build with filtered zip file - run: | - version_with_underscores=$(echo ${{ github.ref_name }} | tr '.' '_') - cd ${{ github.event.repository.name }} - gh release create ${{ github.ref_name }} \ - --notes-file "${{ github.workspace }}/release_notes.md" \ - ../simple_collider_${version_with_underscores}.zip - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/blender_manifest.toml b/blender_manifest.toml index 7374fcc0..11a07ae7 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -4,7 +4,7 @@ id = "simple_collider" version = "1.2.0" name = "Simple Collider" -tagline = "Simple Collider is a Blender addon to create physics colliders for games and real-time applications." +tagline = "Create physics colliders for games and real-time applications" maintainer = "Matthias Patscheider " type = "add-on" @@ -25,13 +25,15 @@ copyright = [ ] [permissions] -files = "Write/Read .py preset files from/to disk, copying the Auto Convex executables (V-HACD and CoACD) to execute to avoid the need of manual installation and export/import OBJ files from/to disk for the auto convex generation" +files = "Executes Auto Convex binaries; reads/writes preset and OBJ files" [build] paths_exclude_pattern = [ "__pycache__/", "/.git/", + "/.gitignore", + "/.gitattributes", "/*.zip", "/tests/", "/venv/", From b208b47c8a8d2dcd25d72c60cebb3f703bab6445 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 3 Aug 2026 18:16:29 +0300 Subject: [PATCH 06/11] #660 Lower CoACD default max hulls to 8 16 was already a big improvement over unlimited (-1), but Max Hulls is an output-count constraint rather than a search-quality one - tightening it further trims worst-case runtime without degrading individual cuts, unlike loosening threshold/MCTS params which would erode the precision this backend exists for. --- properties/properties.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/properties/properties.py b/properties/properties.py index 7f3e7bfd..20e5fc9a 100644 --- a/properties/properties.py +++ b/properties/properties.py @@ -69,7 +69,7 @@ class ColliderTools_Properties(bpy.types.PropertyGroup): coacd_maxConvexHulls: bpy.props.IntProperty(name='Max Hulls', description='Maximum number of output convex hulls. -1 for no limit ' '(only takes effect if merge is enabled)', - default=16, + default=8, min=-1, soft_min=1, soft_max=64) From 3fbe82b103df3f15b02cb5a3c3c89c04a33ef265 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 3 Aug 2026 20:29:45 +0300 Subject: [PATCH 07/11] Check bpy.app.online_access before the update-check network request Add [permissions].network declaration to match. Neither was previously declared/checked despite this addon already making a real network call on every register(). --- blender_manifest.toml | 1 + collider_operators/version_check.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/blender_manifest.toml b/blender_manifest.toml index 11a07ae7..678700da 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -25,6 +25,7 @@ copyright = [ ] [permissions] +network = "Checks GitHub for a newer release" files = "Executes Auto Convex binaries; reads/writes preset and OBJ files" diff --git a/collider_operators/version_check.py b/collider_operators/version_check.py index 88e8f689..b9866937 100644 --- a/collider_operators/version_check.py +++ b/collider_operators/version_check.py @@ -58,5 +58,8 @@ def _fetch(): def start_version_check(): """Fire a background thread to check for a newer release on GitHub.""" + import bpy + if not bpy.app.online_access: + return t = threading.Thread(target=_fetch, daemon=True) t.start() From 002fe0b987ac62fe6f1c929372e6d4d5512018fc Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 3 Aug 2026 20:50:13 +0300 Subject: [PATCH 08/11] #660 Add live CoACD progress feedback; retune MCTS/resolution defaults for speed Progress: pipe the subprocess's stdout through a background reader thread into a queue instead of letting it inherit the console - CoACD already prints phase headers and per-candidate percentages, they just weren't being read. _drain_progress_queue() parses them non-blockingly each poll so the HUD shows e.g. "DECOMPOSITION (MCTS) 87.5%" instead of just an elapsed-time counter, which is indistinguishable from being stuck on a multi-minute run. Defaults: measured four isolated runs of the same real (complex, non-manifold) asset at current settings. MCTS Depth 3->2 and Nodes 20->10 cut compute time ~38% (389s -> 239s) with no quality loss - concavity came out slightly better, not worse, because at 8 hulls the decomposition is already hull-count-constrained rather than search-constrained, so trimming the search doesn't cost precision that was being achieved anyway. Hausdorff Sampling Resolution only bought ~11% on its own; set to 1000, the lowest value the property's own range allows (tested at 200 via direct CLI, which is below the declared min and wasn't adopted here). Combined: 222s, a ~43% reduction from baseline with no measured quality regression. --- auto_Convex/add_bounding_auto_convex_coacd.py | 78 ++++++++++++++++++- collider_shapes/add_bounding_primitive.py | 4 +- preferences/prefs_properties.py | 6 +- 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/auto_Convex/add_bounding_auto_convex_coacd.py b/auto_Convex/add_bounding_auto_convex_coacd.py index abdf1f97..14bea0fb 100644 --- a/auto_Convex/add_bounding_auto_convex_coacd.py +++ b/auto_Convex/add_bounding_auto_convex_coacd.py @@ -1,4 +1,7 @@ import os +import queue +import re +import threading import time import subprocess @@ -16,6 +19,15 @@ # there's no way to know in advance which it'll be (#660). COACD_POLL_INTERVAL_SECONDS = 0.2 +# CoACD already prints its own progress to stdout - section headers like +# " - Decomposition (MCTS)" and per-candidate "Processing [62.3%]" lines - +# it just wasn't being read (the subprocess inherited the console instead +# of being piped). Parsed by _drain_progress_queue() into a short HUD +# string, so a multi-minute run reads as "working" instead of a silent +# elapsed-time counter that's indistinguishable from being stuck. +_COACD_PHASE_RE = re.compile(r'\[info\]\s+-\s+(.+?)\s*$') +_COACD_PCT_RE = re.compile(r'Processing \[([\d.]+)%\]') + # True while any COACD_OT_convex_decomposition instance has a job in flight. # Module-level (not per-instance) because invoke() creates a brand new # instance every time the operator is triggered - an instance attribute @@ -92,6 +104,14 @@ def __init__(self, *args, **kwargs): self._status_area = None self._coacd_run_id = None + # Live progress, parsed from the running subprocess's stdout - see + # _launch_coacd_process()/_drain_progress_queue() and the module + # docstring on _COACD_PHASE_RE above. + self._coacd_stdout_queue = None + self._coacd_progress_phase = '' + self._coacd_progress_pct = '' + self._coacd_progress_text = '' + # bpy.app.timers callbacks (see _poll_coacd_process()) have no # guaranteed context - bpy.context.space_data is None (or belongs to # whatever editor the mouse happens to be over) unless the pointer @@ -240,6 +260,59 @@ def export_mesh_for_coacd(self, context, parent, mesh, data_path): return obj_filename + def _launch_coacd_process(self, cmd, cwd): + """Launch a CoACD subprocess with its stdout piped through a + daemon reader thread into a queue, instead of letting it inherit + the console. _drain_progress_queue() then pulls from that queue on + Blender's main thread (never blocking it) to update the HUD with + CoACD's own phase/percent output.""" + process = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1) + q = queue.Queue() + + def _reader(): + try: + for line in iter(process.stdout.readline, ''): + q.put(line) + except Exception: + pass + + threading.Thread(target=_reader, daemon=True).start() + + self._coacd_stdout_queue = q + self._coacd_progress_phase = '' + self._coacd_progress_pct = '' + self._coacd_progress_text = '' + return process + + def _drain_progress_queue(self): + """Pull whatever lines the reader thread has queued since the last + poll and refresh the HUD progress string. queue.Queue.get_nowait() + never blocks - it either returns immediately or raises Empty.""" + q = self._coacd_stdout_queue + if q is None: + return + + changed = False + while True: + try: + line = q.get_nowait() + except queue.Empty: + break + changed = True + m = _COACD_PHASE_RE.search(line) + if m: + self._coacd_progress_phase = m.group(1).strip() + self._coacd_progress_pct = '' # new phase - stale % no longer applies + continue + m = _COACD_PCT_RE.search(line) + if m: + self._coacd_progress_pct = f'{m.group(1)}%' + + if changed: + parts = [p for p in (self._coacd_progress_phase, self._coacd_progress_pct) if p] + self._coacd_progress_text = ' '.join(parts) + def _start_next_coacd_job(self, context): """Pop the next collider off the queue and launch CoACD on it without blocking. If the queue is empty, the whole run is done.""" @@ -284,7 +357,7 @@ def _start_next_coacd_job(self, context): # No shell=True: CoACD is launched directly (not via an intermediate # cmd.exe/sh -c) so that killing this Popen on cancel actually kills # the CoACD process itself rather than leaving it running detached. - process = subprocess.Popen(cmd, cwd=self._coacd_data_path) + process = self._launch_coacd_process(cmd, self._coacd_data_path) self._coacd_process = process self._coacd_stage = 'decompose' @@ -309,6 +382,7 @@ def _poll_coacd_process(self): return None if process.poll() is None: + self._drain_progress_queue() if self._status_area is not None: self._status_area.tag_redraw() return COACD_POLL_INTERVAL_SECONDS @@ -431,7 +505,7 @@ def _start_next_decimate_hull(self, context): '-t', str(col_settings.coacd_threshold), '-c', '-1', '-pm', 'off', '-d', '-dt', str(col_settings.coacd_maxHullVertCount), ] - process = subprocess.Popen(cmd, cwd=self._coacd_data_path) + process = self._launch_coacd_process(cmd, self._coacd_data_path) hull_obj.select_set(False) diff --git a/collider_shapes/add_bounding_primitive.py b/collider_shapes/add_bounding_primitive.py index 87afd840..79324a2f 100644 --- a/collider_shapes/add_bounding_primitive.py +++ b/collider_shapes/add_bounding_primitive.py @@ -466,7 +466,9 @@ def draw_viewport_overlay(self, context): _coacd_process = getattr(self, '_coacd_process', None) if _coacd_process is not None: elapsed = time.time() - self._coacd_start_time - label = f'RUNNING COACD - {elapsed:0.0f}S ELAPSED - ESC TO CANCEL' + progress = getattr(self, '_coacd_progress_text', '') + status = progress if progress else 'RUNNING COACD' + label = f'{status.upper()} - {elapsed:0.0f}S - ESC TO CANCEL' item = {'label': label, 'value': None, 'key': '', 'type': 'key_title', 'highlight': True} items.append(item) elif self.valid_input_selection: diff --git a/preferences/prefs_properties.py b/preferences/prefs_properties.py index 91333099..fa11fcb3 100644 --- a/preferences/prefs_properties.py +++ b/preferences/prefs_properties.py @@ -506,21 +506,21 @@ class CollisionAddonPrefsProperties(): # -md coacd_mctsDepth: bpy.props.IntProperty(name='MCTS Max Depth', description='Maximum search depth in the Monte Carlo Tree Search (2~7)', - default=3, + default=2, min=2, max=7) # -mn coacd_mctsNodes: bpy.props.IntProperty(name='MCTS Max Nodes', description='Maximum number of child nodes in the Monte Carlo Tree Search (10~40)', - default=20, + default=10, min=10, max=40) # -r coacd_resolution: bpy.props.IntProperty(name='Hausdorff Sampling Resolution', description='Sampling resolution used for the Hausdorff distance calculation (1000~10000)', - default=2000, + default=1000, min=1000, max=10000) From 6983ea32838b0e6cfc47dcebb97078d1e1c77837 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 3 Aug 2026 20:50:58 +0300 Subject: [PATCH 09/11] small consistancy updates --- ui/__init__.py | 2 +- ui/properties_panels.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/__init__.py b/ui/__init__.py index 803ffeb3..7f1f225f 100644 --- a/ui/__init__.py +++ b/ui/__init__.py @@ -8,7 +8,7 @@ from ..presets.presets_data import presets classes = ( - properties_panels.COLLSION_OT_open_directory_new, + properties_panels.COLLISION_OT_open_directory, properties_panels.COLLIDER_OT_open_folder, properties_panels.OBJECT_MT_collision_presets, properties_panels.PREFERENCES_OT_open_addon, diff --git a/ui/properties_panels.py b/ui/properties_panels.py index 886cefde..d5ee757c 100644 --- a/ui/properties_panels.py +++ b/ui/properties_panels.py @@ -458,9 +458,9 @@ def draw_naming_presets(self, context): # OPERATORS -class COLLSION_OT_open_directory_new(bpy.types.Operator, ImportHelper): +class COLLISION_OT_open_directory(bpy.types.Operator, ImportHelper): """Open render output directory in Explorer""" - bl_idname = "explorer.open_in_explorer" + bl_idname = "simple_collider.open_directory" bl_label = "Open Folder" bl_description = "Open preset folder in explorer" From cce197113741f8c3d1ccfde6785ba29e9a8a32cb Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 3 Aug 2026 21:12:10 +0300 Subject: [PATCH 10/11] #660 Convert V-HACD to async too; replace settings HUD with a centered status overlay while either backend runs The per-row settings HUD (D for decimate, S for shrink/inflate, etc.) kept drawing and looking interactive while a CoACD job was running, even though every one of those hotkeys is inert during a run (modal() swallows them - there's nothing to adjust until the job produces colliders). Replaced it with a dedicated, centered, warning-styled overlay (draw_async_job_overlay() in add_bounding_primitive.py) that fully replaces the settings HUD while an async job is active, and disappears back to the normal interactive HUD the moment it finishes - no separate step needed, since draw_viewport_overlay() just checks self._async_process each redraw. Extracted the reusable pieces both backends need into OBJECT_OT_add_bounding_object: _launch_async_process() (Popen with stdout piped through a daemon reader thread into a queue - splitting on '\r' as well as '\n', since V-HACD's progress lines are '\r'-delimited like a terminal progress bar, not '\n'-delimited like CoACD's) and _drain_async_progress() (drains that queue each poll via a _parse_progress_line() hook each operator overrides for its own CLI's output format). CoACD's own _launch_coacd_process/_drain_progress_queue were folded into these; its state renamed to the generic _async_process/_async_start_time so the shared overlay can findit via getattr() regardless of which backend is running. V-HACD itself gets the full #660 treatmeant: was still calling Popen.wait() synchronously (freezing Blender exactly like CoACD used to), now runs through the same bpy.app.timers poll loop, module-level run-in-progress lock, cancel handling, and context.temp_override() for its completion step. Simpler than CoACD's conversion since V-HACD has no per-hull decimate sub-stage - one subprocess per collider group, straight through to postprocessing. Verified: full test suite (11/11) against a real Blender instance: V-HACD command construction and its file-discovery logic (scans data_path for new .obj files, deliberately excluding per-hull numbered variants in favor of the combined decomp.obj) against the actual bundled executable; transform correctness (rotated + non-uniformly-scaled source object) by replicating postprocess_colliders()'s exact matrix_world/apply_transform logic and comparing the resulting world-space hull bounds against the source geometry; both backends' progress-line regexes against their real captured stdout output. Modal-operator dispatch itself (invoke() -> execute() -> timer-driven poll loop) couldn't be exercised in --background scripting - Blender skips invoke() and calls execute() directly in that mode - so that specific wiring relies on it being the same shared code path already exercised live for CoACD earlier in this work. --- auto_Convex/add_bounding_auto_convex.py | 319 ++++++++++++++---- auto_Convex/add_bounding_auto_convex_coacd.py | 117 +++---- collider_shapes/add_bounding_primitive.py | 173 +++++++++- 3 files changed, 461 insertions(+), 148 deletions(-) diff --git a/auto_Convex/add_bounding_auto_convex.py b/auto_Convex/add_bounding_auto_convex.py index 92ec52e2..00b4026f 100644 --- a/auto_Convex/add_bounding_auto_convex.py +++ b/auto_Convex/add_bounding_auto_convex.py @@ -1,6 +1,6 @@ import os +import re import time -import subprocess import bmesh import bpy @@ -9,6 +9,29 @@ from ..bmesh_operations.mesh_edit import bmesh_join from ..collider_shapes.add_bounding_primitive import OBJECT_OT_add_bounding_object +# How often the V-HACD subprocess is polled for completion while it's +# running. Polling (rather than Popen.wait()) is what keeps Blender's UI +# thread responsive - see COACD_OT_convex_decomposition/#660, which this +# mirrors: V-HACD ran synchronously the same way CoACD used to, and can take +# anywhere from a fraction of a second to several minutes on complex meshes. +VHACD_POLL_INTERVAL_SECONDS = 0.2 + +# V-HACD prints its own progress to stdout as e.g. +# "[PERFORMING_DECOMPOSITION] : 50% : 0% : Performing recursive decomposition +# of convex hulls", using '\r' between updates the way a terminal progress +# bar would (see OBJECT_OT_add_bounding_object._launch_async_process()'s +# reader, which splits on '\r' as well as '\n' for exactly this). Parsed by +# _parse_progress_line() into a short status string for the shared overlay. +_VHACD_PROGRESS_RE = re.compile(r'^\[(\S+)\s*\]\s*:\s*(\d+)%\s*:\s*(\d+)%\s*:\s*(.+)$') + +# True while any VHACD_OT_convex_decomposition instance has a job in flight. +# Module-level (not per-instance) for the same reason as CoACD's +# _coacd_run_in_progress (see add_bounding_auto_convex_coacd.py): invoke() +# creates a brand new instance every time the operator is triggered, so an +# instance attribute can't stop a *second*, independent invocation from +# starting while Blender no longer looks busy. +_vhacd_run_in_progress = False + class VHACD_OT_convex_decomposition(OBJECT_OT_add_bounding_object, Operator): bl_idname = 'collision.vhacd' @@ -49,10 +72,52 @@ def __init__(self, *args, **kwargs): self.use_recenter_origin = True self.shape = 'convex_shape' + # Async V-HACD job state, mirroring COACD_OT_convex_decomposition + # (#660). _async_process/_async_start_time are the generic names the + # shared status overlay (draw_async_job_overlay() in + # add_bounding_primitive) looks for via getattr() - CoACD uses the + # same names so both backends drive the same overlay. + self._async_process = None + self._async_start_time = 0.0 + self._async_job_label = 'V-HACD' + self._vhacd_exe = None + self._vhacd_data_path = None + self._vhacd_pending_jobs = [] + self._vhacd_results = [] + self._vhacd_job_ctx = None + self._status_area = None + + # bpy.app.timers callbacks (see _poll_vhacd_process()) have no + # guaranteed context - see the identical note in + # COACD_OT_convex_decomposition.__init__() for why this is captured + # here and overridden into for every downstream async step. + self._invoke_window = None + self._invoke_area = None + self._invoke_region = None + def invoke(self, context, event): + if _vhacd_run_in_progress: + self.report({'ERROR'}, 'Auto Convex is already running - wait for it to finish, or select it and ' + 'press Escape to cancel, before starting another run') + return {'CANCELLED'} return super().invoke(context, event) def modal(self, context, event): + if self._async_process is not None: + # A V-HACD job is in flight: swallow all input except viewport + # navigation (still allowed so the user isn't locked out of + # looking around while it runs) and cancel. Everything else - + # including LEFTMOUSE/RET confirm - is intentionally ignored, + # since the colliders this operator would finalize don't exist + # yet. + if event.type in {'MIDDLEMOUSE', 'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}: + return {'PASS_THROUGH'} + if event.type in {'RIGHTMOUSE', 'ESC'}: + self._cancel_vhacd_job(context) + self.cancel_cleanup(context) + return {'CANCELLED'} + return {'RUNNING_MODAL'} + status = super().modal(context, event) if status == {'FINISHED'}: return {'FINISHED'} @@ -72,6 +137,7 @@ def modal(self, context, event): return {'RUNNING_MODAL'} def cancel(self, context): + self._cancel_vhacd_job(context) context.space_data.shading.color_type = self.color_type try: bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') @@ -157,30 +223,107 @@ def export_mesh_for_vhacd(self, context, parent, mesh, data_path): return obj_filename - def run_vhacd_decomposition(self, vhacd_exe, obj_filename, data_path, export_time): - """Run the V-HACD decomposition process.""" - col_settings = bpy.context.scene.simple_collider - - cmd_line = ( - f'"{vhacd_exe}" "{obj_filename}" -h {col_settings.maxHullAmount} -v {col_settings.maxHullVertCount} ' - f'-o obj -g 1 -r {col_settings.voxelResolution} -e {self.prefs.vhacd_volumneErrorPercent} ' - f'-d {self.prefs.vhacd_maxRecursionDepth} -s {"true" if col_settings.vhacd_shrinkwrap else "false"} ' - f'-f {self.prefs.vhacd_fillMode} -l {self.prefs.vhacd_minEdgeLength} ' - f'-p {"true" if self.prefs.vhacd_optimalSplitPlane else "false"} -g true' - ) - - print('Running V-HACD...\n{}\n'.format(cmd_line)) - print(f"Using data path for V-HACD: {data_path}") - - vhacd_process = subprocess.Popen(cmd_line, bufsize=-1, close_fds=True, shell=True, cwd=data_path) - vhacd_process.wait() - - # Collect newly created OBJ files - dir_files = os.listdir(data_path) + def _parse_progress_line(self, line): + """Override of the base class hook (see _drain_async_progress()): + V-HACD's stdout format - see _VHACD_PROGRESS_RE above.""" + m = _VHACD_PROGRESS_RE.match(line.strip()) + if not m: + return None + overall_pct = m.group(2) + description = m.group(4).strip() + return f'{description} {overall_pct}%' + + def _start_next_vhacd_job(self, context): + """Pop the next collider off the queue and launch V-HACD on it + without blocking. If the queue is empty, the whole run is done.""" + if not self._vhacd_pending_jobs: + self._finish_vhacd_run(context) + return + + convex_collision_data = self._vhacd_pending_jobs.pop(0) + parent = convex_collision_data['parent'] + mesh = convex_collision_data['mesh'] + mtx_world = convex_collision_data['mtx_world'] + + obj_filename = self.export_mesh_for_vhacd(context, parent, mesh, self._vhacd_data_path) + export_time = time.time() + + col_settings = context.scene.simple_collider + prefs = self.prefs + + cmd = [ + self._vhacd_exe, obj_filename, + '-h', str(col_settings.maxHullAmount), '-v', str(col_settings.maxHullVertCount), + '-o', 'obj', '-g', '1', '-r', str(col_settings.voxelResolution), + '-e', str(prefs.vhacd_volumneErrorPercent), + '-d', str(prefs.vhacd_maxRecursionDepth), + '-s', 'true' if col_settings.vhacd_shrinkwrap else 'false', + '-f', prefs.vhacd_fillMode, '-l', str(prefs.vhacd_minEdgeLength), + '-p', 'true' if prefs.vhacd_optimalSplitPlane else 'false', '-g', 'true', + ] + + print('Running V-HACD...\n{}\n'.format(' '.join(cmd))) + print(f"Using data path for V-HACD: {self._vhacd_data_path}") + + # No shell=True: V-HACD is launched directly (not via an intermediate + # cmd.exe/sh -c) so that killing this Popen on cancel actually kills + # the V-HACD process itself rather than leaving it running detached. + process = self._launch_async_process(cmd, self._vhacd_data_path) + + self._async_process = process + self._vhacd_job_ctx = { + 'parent': parent, + 'mesh': mesh, + 'mtx_world': mtx_world, + 'obj_filename': obj_filename, + 'export_time': export_time, + } + self._async_start_time = time.time() + bpy.app.timers.register(self._poll_vhacd_process, first_interval=VHACD_POLL_INTERVAL_SECONDS) + + def _poll_vhacd_process(self): + """bpy.app.timers callback: check whether the current V-HACD + subprocess has finished, without blocking Blender's main thread. + Re-arms itself via its return value for as long as the process is + still running.""" + global _vhacd_run_in_progress + try: + process = self._async_process + if process is None: + return None + + if process.poll() is None: + self._drain_async_progress() + if self._status_area is not None: + self._status_area.tag_redraw() + return VHACD_POLL_INTERVAL_SECONDS + + self._async_process = None + + # bpy.context here has no guaranteed space_data - see the + # identical note in COACD_OT_convex_decomposition._poll_coacd_process(). + with bpy.context.temp_override(window=self._invoke_window, area=self._invoke_area, + region=self._invoke_region): + self._handle_vhacd_job_finished(bpy.context) + except ReferenceError: + # operator has already finished/cancelled and its RNA was freed + _vhacd_run_in_progress = False + except Exception: + # Whatever went wrong, never leave the module-level run lock + # stuck - that would permanently block every future Auto Convex + # invocation until Blender restarts. Still re-raised so the + # actual error is printed to the console as usual. + _vhacd_run_in_progress = False + raise + return None + + def _collect_vhacd_output_files(self, obj_filename, export_time): + """Collect newly created OBJ files V-HACD wrote to the data path.""" + dir_files = os.listdir(self._vhacd_data_path) obj_list = [] for file in dir_files: if file.endswith('.obj'): - obj_path = os.path.join(data_path, file) + obj_path = os.path.join(self._vhacd_data_path, file) file_time = os.path.getmtime(obj_path) if file_time > export_time: obj_list.append(obj_path) @@ -204,6 +347,55 @@ def import_decomposed_meshes(self, obj_list): return imported + def _handle_vhacd_job_finished(self, context): + """Called once the decomposition subprocess for one collider has + exited. Imports whatever V-HACD produced and moves on to the next + queued collider, or finishes the run if none are left.""" + job = self._vhacd_job_ctx + parent = job['parent'] + mesh = job['mesh'] + mtx_world = job['mtx_world'] + obj_filename = job['obj_filename'] + export_time = job['export_time'] + + obj_list = self._collect_vhacd_output_files(obj_filename, export_time) + imported = self.import_decomposed_meshes(obj_list) + + self._vhacd_results.append({'colliders': imported, 'parent': parent, 'mtx_world': mtx_world}) + bpy.data.meshes.remove(mesh) + self._vhacd_job_ctx = None + self._start_next_vhacd_job(context) + + def _cancel_vhacd_job(self, context): + """Kill any in-flight V-HACD subprocess and drop all queued/partial + state for the current run. Called from modal()'s ESC/RIGHTMOUSE + handling and from cancel().""" + global _vhacd_run_in_progress + _vhacd_run_in_progress = False + + if self._async_process is not None: + try: + self._async_process.kill() + self._async_process.wait(timeout=5) + except Exception: + pass + self._async_process = None + + if self._vhacd_job_ctx is not None: + mesh = self._vhacd_job_ctx.get('mesh') + if mesh is not None: + try: + bpy.data.meshes.remove(mesh) + except ReferenceError: + pass + self._vhacd_job_ctx = None + + for result in self._vhacd_results: + self.remove_objects(result['colliders']) + + self._vhacd_pending_jobs = [] + self._vhacd_results = [] + def postprocess_colliders(self, context, convex_decomposition_data): """Postprocess the imported colliders: naming, parenting, and final setup.""" context.view_layer.objects.active = self.active_obj @@ -226,45 +418,21 @@ def postprocess_colliders(self, context, convex_decomposition_data): self.primitive_postprocessing(context, new_collider, collections) self.new_colliders_list.append(new_collider) - def execute(self, context): - """Main execution method for convex decomposition.""" - super().execute(context) - - vhacd_exe, data_path = self.validate_paths_and_settings(context) - if not vhacd_exe or not data_path: - return self.cancel(context) - - for obj in self.selected_objects.copy(): - obj.select_set(False) - - collider_data = self.preprocess_objects_and_collect_data(context) - - convex_decomposition_data = [] - - for convex_collision_data in collider_data: - parent = convex_collision_data['parent'] - mesh = convex_collision_data['mesh'] + def _finish_vhacd_run(self, context): + """Called once every queued collider has been decomposed. Mirrors + what the old synchronous execute() did after its blocking loop + finished.""" + global _vhacd_run_in_progress + _vhacd_run_in_progress = False - obj_filename = self.export_mesh_for_vhacd(context, parent, mesh, data_path) - if obj_filename is None: - return self.cancel(context) - - export_time = time.time() - - obj_list = self.run_vhacd_decomposition(vhacd_exe, obj_filename, data_path, export_time) - - imported = self.import_decomposed_meshes(obj_list) - - convex_collisions_data = {'colliders': imported, 'parent': parent, 'mtx_world': parent.matrix_world.copy()} - convex_decomposition_data.append(convex_collisions_data) - - bpy.data.meshes.remove(mesh) - - self.postprocess_colliders(context, convex_decomposition_data) + self.postprocess_colliders(context, self._vhacd_results) + self._vhacd_results = [] if len(self.new_colliders_list) < 1: self.report({'WARNING'}, 'No meshes to process!') - return {'CANCELLED'} + if self._status_area is not None: + self._status_area.tag_redraw() + return if self.join_primitives: super().join_primitives(context) @@ -274,4 +442,39 @@ def execute(self, context): super().print_generation_time("Auto Convex Colliders", elapsed_time) self.report({'INFO'}, f"Auto Convex Colliders: {elapsed_time}") - return {'FINISHED'} + if self._status_area is not None: + self._status_area.tag_redraw() + + def execute(self, context): + """Kick off convex decomposition for the current selection. Does not + block: it launches the first V-HACD job and returns immediately, + with _poll_vhacd_process() (via bpy.app.timers) driving the rest.""" + global _vhacd_run_in_progress + if self._async_process is not None: + # Already running (e.g. a hotkey re-triggered execute() while a + # previous run is still in flight) - ignore rather than + # overlapping a second subprocess run. + return {'RUNNING_MODAL'} + + super().execute(context) + + vhacd_exe, data_path = self.validate_paths_and_settings(context) + if not vhacd_exe or not data_path: + return self.cancel(context) + + for obj in self.selected_objects.copy(): + obj.select_set(False) + + self._vhacd_exe = vhacd_exe + self._vhacd_data_path = data_path + self._status_area = context.area + self._invoke_window = context.window + self._invoke_area = context.area + self._invoke_region = next((r for r in context.area.regions if r.type == 'WINDOW'), None) + self._vhacd_pending_jobs = self.preprocess_objects_and_collect_data(context) + self._vhacd_results = [] + + _vhacd_run_in_progress = True + self._start_next_vhacd_job(context) + + return {'RUNNING_MODAL'} diff --git a/auto_Convex/add_bounding_auto_convex_coacd.py b/auto_Convex/add_bounding_auto_convex_coacd.py index 14bea0fb..60e8dfdb 100644 --- a/auto_Convex/add_bounding_auto_convex_coacd.py +++ b/auto_Convex/add_bounding_auto_convex_coacd.py @@ -1,9 +1,6 @@ import os -import queue import re -import threading import time -import subprocess import bmesh import bpy @@ -22,7 +19,8 @@ # CoACD already prints its own progress to stdout - section headers like # " - Decomposition (MCTS)" and per-candidate "Processing [62.3%]" lines - # it just wasn't being read (the subprocess inherited the console instead -# of being piped). Parsed by _drain_progress_queue() into a short HUD +# of being piped). Parsed by _parse_progress_line() (see +# OBJECT_OT_add_bounding_object._drain_async_progress()) into a short status # string, so a multi-minute run reads as "working" instead of a silent # elapsed-time counter that's indistinguishable from being stuck. _COACD_PHASE_RE = re.compile(r'\[info\]\s+-\s+(.+?)\s*$') @@ -89,7 +87,14 @@ def __init__(self, *args, **kwargs): # _start_next_coacd_job()/_poll_coacd_process() below: the CLI is # now driven from bpy.app.timers, one polled step at a time, the # same pattern already used for the debounce timers above. - self._coacd_process = None + # + # _async_process/_async_start_time are the generic names the shared + # status overlay (draw_async_job_overlay() in add_bounding_primitive) + # looks for via getattr() - VHACD_OT_convex_decomposition uses the + # same names so both backends drive the same overlay. + self._async_process = None + self._async_start_time = 0.0 + self._async_job_label = 'CoACD' self._coacd_exe = None self._coacd_data_path = None self._coacd_pending_jobs = [] @@ -100,17 +105,15 @@ def __init__(self, *args, **kwargs): self._coacd_hull_queue = [] self._coacd_decimated_hulls = [] self._coacd_hull_index = 0 - self._coacd_start_time = 0.0 self._status_area = None self._coacd_run_id = None - # Live progress, parsed from the running subprocess's stdout - see - # _launch_coacd_process()/_drain_progress_queue() and the module + # Live progress, parsed from the running subprocess's stdout via + # _parse_progress_line() (see OBJECT_OT_add_bounding_object - + # _launch_async_process()/_drain_async_progress()) and the module # docstring on _COACD_PHASE_RE above. - self._coacd_stdout_queue = None self._coacd_progress_phase = '' self._coacd_progress_pct = '' - self._coacd_progress_text = '' # bpy.app.timers callbacks (see _poll_coacd_process()) have no # guaranteed context - bpy.context.space_data is None (or belongs to @@ -135,7 +138,7 @@ def invoke(self, context, event): return super().invoke(context, event) def modal(self, context, event): - if self._coacd_process is not None: + if self._async_process is not None: # A CoACD job is in flight: swallow all input except viewport # navigation (still allowed so the user isn't locked out of # looking around while it runs) and cancel. Everything else - @@ -260,58 +263,20 @@ def export_mesh_for_coacd(self, context, parent, mesh, data_path): return obj_filename - def _launch_coacd_process(self, cmd, cwd): - """Launch a CoACD subprocess with its stdout piped through a - daemon reader thread into a queue, instead of letting it inherit - the console. _drain_progress_queue() then pulls from that queue on - Blender's main thread (never blocking it) to update the HUD with - CoACD's own phase/percent output.""" - process = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, bufsize=1) - q = queue.Queue() - - def _reader(): - try: - for line in iter(process.stdout.readline, ''): - q.put(line) - except Exception: - pass - - threading.Thread(target=_reader, daemon=True).start() - - self._coacd_stdout_queue = q - self._coacd_progress_phase = '' - self._coacd_progress_pct = '' - self._coacd_progress_text = '' - return process - - def _drain_progress_queue(self): - """Pull whatever lines the reader thread has queued since the last - poll and refresh the HUD progress string. queue.Queue.get_nowait() - never blocks - it either returns immediately or raises Empty.""" - q = self._coacd_stdout_queue - if q is None: - return - - changed = False - while True: - try: - line = q.get_nowait() - except queue.Empty: - break - changed = True - m = _COACD_PHASE_RE.search(line) - if m: - self._coacd_progress_phase = m.group(1).strip() - self._coacd_progress_pct = '' # new phase - stale % no longer applies - continue - m = _COACD_PCT_RE.search(line) - if m: - self._coacd_progress_pct = f'{m.group(1)}%' - - if changed: + def _parse_progress_line(self, line): + """Override of the base class hook (see _drain_async_progress()): + CoACD's stdout format - see _COACD_PHASE_RE / _COACD_PCT_RE above.""" + m = _COACD_PHASE_RE.search(line) + if m: + self._coacd_progress_phase = m.group(1).strip() + self._coacd_progress_pct = '' # new phase - stale % no longer applies + return self._coacd_progress_phase + m = _COACD_PCT_RE.search(line) + if m: + self._coacd_progress_pct = f'{m.group(1)}%' parts = [p for p in (self._coacd_progress_phase, self._coacd_progress_pct) if p] - self._coacd_progress_text = ' '.join(parts) + return ' '.join(parts) + return None def _start_next_coacd_job(self, context): """Pop the next collider off the queue and launch CoACD on it @@ -357,9 +322,9 @@ def _start_next_coacd_job(self, context): # No shell=True: CoACD is launched directly (not via an intermediate # cmd.exe/sh -c) so that killing this Popen on cancel actually kills # the CoACD process itself rather than leaving it running detached. - process = self._launch_coacd_process(cmd, self._coacd_data_path) + process = self._launch_async_process(cmd, self._coacd_data_path) - self._coacd_process = process + self._async_process = process self._coacd_stage = 'decompose' self._coacd_job_ctx = { 'parent': parent, @@ -367,7 +332,7 @@ def _start_next_coacd_job(self, context): 'mtx_world': mtx_world, 'output_filename': output_filename, } - self._coacd_start_time = time.time() + self._async_start_time = time.time() bpy.app.timers.register(self._poll_coacd_process, first_interval=COACD_POLL_INTERVAL_SECONDS) def _poll_coacd_process(self): @@ -377,17 +342,17 @@ def _poll_coacd_process(self): still running.""" global _coacd_run_in_progress try: - process = self._coacd_process + process = self._async_process if process is None: return None if process.poll() is None: - self._drain_progress_queue() + self._drain_async_progress() if self._status_area is not None: self._status_area.tag_redraw() return COACD_POLL_INTERVAL_SECONDS - self._coacd_process = None + self._async_process = None # bpy.context here has no guaranteed space_data - it reflects # whatever the mouse happens to be over (or nothing) at the @@ -505,11 +470,11 @@ def _start_next_decimate_hull(self, context): '-t', str(col_settings.coacd_threshold), '-c', '-1', '-pm', 'off', '-d', '-dt', str(col_settings.coacd_maxHullVertCount), ] - process = self._launch_coacd_process(cmd, self._coacd_data_path) + process = self._launch_async_process(cmd, self._coacd_data_path) hull_obj.select_set(False) - self._coacd_process = process + self._async_process = process self._coacd_stage = 'decimate' self._coacd_decimate_ctx = { 'hull_obj': hull_obj, @@ -517,7 +482,7 @@ def _start_next_decimate_hull(self, context): 'decimated_filename': decimated_filename, 'remesh_filename': remesh_filename, } - self._coacd_start_time = time.time() + self._async_start_time = time.time() bpy.app.timers.register(self._poll_coacd_process, first_interval=COACD_POLL_INTERVAL_SECONDS) def _handle_decimate_finished(self, context): @@ -553,13 +518,13 @@ def _cancel_coacd_job(self, context): global _coacd_run_in_progress _coacd_run_in_progress = False - if self._coacd_process is not None: + if self._async_process is not None: try: - self._coacd_process.kill() - self._coacd_process.wait(timeout=5) + self._async_process.kill() + self._async_process.wait(timeout=5) except Exception: pass - self._coacd_process = None + self._async_process = None if self._coacd_job_ctx is not None: mesh = self._coacd_job_ctx.get('mesh') @@ -641,7 +606,7 @@ def execute(self, context): block: it launches the first CoACD job and returns immediately, with _poll_coacd_process() (via bpy.app.timers) driving the rest.""" global _coacd_run_in_progress - if self._coacd_process is not None: + if self._async_process is not None: # Already running (e.g. a hotkey re-triggered execute() while a # previous run is still in flight) - ignore rather than # overlapping a second subprocess run. diff --git a/collider_shapes/add_bounding_primitive.py b/collider_shapes/add_bounding_primitive.py index 79324a2f..30f45214 100644 --- a/collider_shapes/add_bounding_primitive.py +++ b/collider_shapes/add_bounding_primitive.py @@ -1,3 +1,7 @@ +import queue +import subprocess +import threading + import blf import bmesh import bpy @@ -286,6 +290,16 @@ def draw_viewport_overlay(self, context): self.navigation_view_snapshot = view_snapshot self.navigation = time.time() < self.navigation_hold_until + # An external subprocess job (CoACD/V-HACD, see _launch_async_process() + # below) is running asynchronously. None of the per-row settings below + # (D/S/A/etc.) apply to anything yet - there are no colliders to adjust + # until the job finishes - so showing them as if they were live would be + # misleading. Replace the whole settings HUD with a dedicated status + # overlay instead, and skip building it at all. + if getattr(self, '_async_process', None) is not None: + draw_async_job_overlay(self, context) + return + self.valid_input_selection = True if len(self.new_colliders_list) > 0 else False if self.use_space: label = "Global/Local" @@ -458,20 +472,7 @@ def draw_viewport_overlay(self, context): items.append(item) title_row = len(items) - # Auto Convex (High Precision) only: an external CoACD subprocess may be - # running asynchronously (see COACD_OT_convex_decomposition), which can - # take anywhere from a fraction of a second to many minutes. getattr() - # keeps this safe for every other shape operator, which never sets this - # attribute at all. - _coacd_process = getattr(self, '_coacd_process', None) - if _coacd_process is not None: - elapsed = time.time() - self._coacd_start_time - progress = getattr(self, '_coacd_progress_text', '') - status = progress if progress else 'RUNNING COACD' - label = f'{status.upper()} - {elapsed:0.0f}S - ESC TO CANCEL' - item = {'label': label, 'value': None, 'key': '', 'type': 'key_title', 'highlight': True} - items.append(item) - elif self.valid_input_selection: + if self.valid_input_selection: if self.navigation: label = 'VIEWPORT NAVIGATION' type = 'key_title' @@ -555,6 +556,74 @@ def draw_rule(row): padding_bottom=row_padding) +def draw_async_job_overlay(self, context): + """Centered status overlay shown in place of the normal settings HUD + while an external subprocess job (CoACD/V-HACD, launched via + _launch_async_process()) is running. Deliberately not just another row + in the regular HUD: that list reads as "these are live, interactive + settings", which isn't true while a job is running - there's nothing to + adjust until it produces colliders. A distinct, centered, warning-styled + block makes that state unambiguous instead.""" + region = context.region + if region is None: + return + + prefs = self.prefs + font_id = 0 + font_size = int(prefs.modal_font_size * context.preferences.system.ui_scale + * context.preferences.view.ui_scale / 3.6) + title_font_size = int(font_size * 1.5) + + elapsed = time.time() - getattr(self, '_async_start_time', time.time()) + job_label = getattr(self, '_async_job_label', 'PROCESSING') + status = getattr(self, '_async_status_text', '') + + lines = [(f'RUNNING {job_label.upper()}', title_font_size, prefs.modal_color_error)] + if status: + lines.append((status.upper(), font_size, prefs.modal_color_default)) + lines.append((f'{elapsed:0.0f}S ELAPSED', font_size, prefs.modal_color_default)) + lines.append(('ESC TO CANCEL', font_size, prefs.modal_color_navigation)) + + line_height = int(font_size * 1.6) + row_padding = font_size * 0.7 + box_height = line_height * len(lines) + row_padding * 2 + box_width = 420 / 20 * font_size + + center_x = region.width / 2 + center_y = region.height / 2 + + box_left = center_x - box_width / 2 + box_right = center_x + box_width / 2 + box_top = center_y + box_height / 2 + box_bottom = center_y - box_height / 2 + + if prefs.use_modal_box: + draw_2d_backdrop(self, context, box_left, box_right, box_top, box_bottom, prefs.modal_box_color) + + # warning-red accent (vs. the brand-green accent on the normal settings + # HUD) so this reads as a distinct, attention-worthy state at a glance. + frame_color = (0.204, 0.212, 0.243, 1.0) + accent_color = (0.902, 0.302, 0.302, 1.0) + frame_px = 1 + accent_px = 3 + draw_2d_backdrop(self, context, box_left, box_right, box_top, box_top - accent_px, accent_color) + draw_2d_backdrop(self, context, box_left, box_right, box_bottom + frame_px, box_bottom, frame_color) + draw_2d_backdrop(self, context, box_left, box_left + frame_px, box_top - accent_px, box_bottom, frame_color) + draw_2d_backdrop(self, context, box_right - frame_px, box_right, box_top - accent_px, box_bottom, frame_color) + + y = box_top - row_padding - line_height * 0.75 + for text, size, color in lines: + if bpy.app.version < (4, 00): + blf.size(font_id, 72, size) + else: + blf.size(font_id, size) + blf.color(font_id, *color) + text_width = blf.dimensions(font_id, text)[0] + blf.position(font_id, center_x - text_width / 2, y, 0) + blf.draw(font_id, text) + y -= line_height + + def draw_2d_backdrop(self, context, left, right, top, bottom, color): midWidth = bpy.context.area.width / 2 @@ -623,6 +692,82 @@ class OBJECT_OT_add_bounding_object(): # GRAB_CURSOR + BLOCKING enables wrap-around mouse feature. bm = [] + # Shared external-subprocess-job plumbing, used by both + # COACD_OT_convex_decomposition and VHACD_OT_convex_decomposition to run + # their CLI backends asynchronously via bpy.app.timers instead of + # blocking Blender's main thread with Popen.wait() (#660). Both + # operators drive their own poll/finish/cancel state machines - the + # per-tool flow genuinely differs (CoACD has an extra per-hull decimate + # pass, V-HACD doesn't) - but share these three generic pieces: how a + # subprocess is launched and its stdout captured, how that stdout is + # drained into a live status string, and how that status is drawn (see + # draw_async_job_overlay() above). Subclasses set self._async_process / + # self._async_start_time / self._async_job_label and override + # _parse_progress_line() for their own CLI's output format. + def _launch_async_process(self, cmd, cwd): + """Launch cmd with its stdout/stderr piped through a daemon reader + thread into a queue, instead of letting it inherit the console. + _drain_async_progress() then pulls from that queue on Blender's main + thread (never blocking it) to keep the status overlay live. + + The reader splits on '\\r' as well as '\\n': some CLIs (V-HACD) print + progress updates separated only by carriage returns, the same way a + terminal progress bar would - readline() alone would silently buffer + all of those into one giant line until a real newline eventually + showed up, making progress updates arrive in chunky, stale bursts + instead of smoothly. + """ + process = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1) + q = queue.Queue() + + def _reader(): + try: + buf = '' + while True: + ch = process.stdout.read(1) + if not ch: + break + if ch in ('\n', '\r'): + if buf: + q.put(buf) + buf = '' + else: + buf += ch + if buf: + q.put(buf) + except Exception: + pass + + threading.Thread(target=_reader, daemon=True).start() + + self._async_stdout_queue = q + self._async_status_text = '' + return process + + def _parse_progress_line(self, line): + """Override per-operator: return an updated status string for this + line, or None if the line doesn't change the current status. Default + implementation never updates - the overlay just shows elapsed time.""" + return None + + def _drain_async_progress(self): + """Pull whatever lines the reader thread has queued since the last + poll and refresh self._async_status_text via _parse_progress_line(). + queue.Queue.get_nowait() never blocks - it either returns + immediately or raises Empty.""" + q = getattr(self, '_async_stdout_queue', None) + if q is None: + return + while True: + try: + line = q.get_nowait() + except queue.Empty: + break + status = self._parse_progress_line(line) + if status is not None: + self._async_status_text = status + @staticmethod def calculate_center_of_mass(obj): """calculate center of mass. """ From 5e4854d9cbbf2e3059a7b461c5995ea0be0cfb98 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 3 Aug 2026 22:01:47 +0300 Subject: [PATCH 11/11] Drawing an multi object support for OACAD --- auto_Convex/add_bounding_auto_convex.py | 14 +- auto_Convex/add_bounding_auto_convex_coacd.py | 20 +- collider_operators/__init__.py | 1 + collider_operators/utility_operators.py | 48 ++++ collider_shapes/add_bounding_primitive.py | 228 ++++++++++++++++-- ui/properties_panels.py | 11 +- 6 files changed, 285 insertions(+), 37 deletions(-) diff --git a/auto_Convex/add_bounding_auto_convex.py b/auto_Convex/add_bounding_auto_convex.py index 00b4026f..836467c1 100644 --- a/auto_Convex/add_bounding_auto_convex.py +++ b/auto_Convex/add_bounding_auto_convex.py @@ -7,14 +7,19 @@ from bpy.types import Operator from ..bmesh_operations.mesh_edit import bmesh_join -from ..collider_shapes.add_bounding_primitive import OBJECT_OT_add_bounding_object +from ..collider_shapes.add_bounding_primitive import OBJECT_OT_add_bounding_object, _remove_draw_handle # How often the V-HACD subprocess is polled for completion while it's # running. Polling (rather than Popen.wait()) is what keeps Blender's UI # thread responsive - see COACD_OT_convex_decomposition/#660, which this # mirrors: V-HACD ran synchronously the same way CoACD used to, and can take # anywhere from a fraction of a second to several minutes on complex meshes. -VHACD_POLL_INTERVAL_SECONDS = 0.2 +# This also drives how often the status overlay redraws (draw_async_job_ +# overlay()'s scrolling stripes) - process.poll() and draining the (usually +# empty) progress queue are both cheap, so this runs at a plain 60fps redraw +# cadence for smooth animation rather than the coarser interval a "just +# check if it's done yet" poll alone would need. +VHACD_POLL_INTERVAL_SECONDS = 1 / 60 # V-HACD prints its own progress to stdout as e.g. # "[PERFORMING_DECOMPOSITION] : 50% : 0% : Performing recursive decomposition @@ -139,10 +144,7 @@ def modal(self, context, event): def cancel(self, context): self._cancel_vhacd_job(context) context.space_data.shading.color_type = self.color_type - try: - bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') - except ValueError: - pass + _remove_draw_handle(self._handle) return {'CANCELLED'} def validate_paths_and_settings(self, context): diff --git a/auto_Convex/add_bounding_auto_convex_coacd.py b/auto_Convex/add_bounding_auto_convex_coacd.py index 60e8dfdb..15dccf10 100644 --- a/auto_Convex/add_bounding_auto_convex_coacd.py +++ b/auto_Convex/add_bounding_auto_convex_coacd.py @@ -7,14 +7,19 @@ from bpy.types import Operator from ..bmesh_operations.mesh_edit import bmesh_join -from ..collider_shapes.add_bounding_primitive import OBJECT_OT_add_bounding_object +from ..collider_shapes.add_bounding_primitive import OBJECT_OT_add_bounding_object, _remove_draw_handle # How often the CoACD subprocess is polled for completion while it's # running. Polling (rather than Popen.wait()) is what keeps Blender's UI # thread responsive - CoACD's own MCTS search can take anywhere from # fractions of a second to many minutes depending on mesh complexity, and -# there's no way to know in advance which it'll be (#660). -COACD_POLL_INTERVAL_SECONDS = 0.2 +# there's no way to know in advance which it'll be (#660). This also drives +# how often the status overlay redraws (draw_async_job_overlay()'s scrolling +# stripes) - process.poll() and draining the (usually empty) progress queue +# are both cheap, so this runs at a plain 60fps redraw cadence for smooth +# animation rather than the coarser interval a "just check if it's done yet" +# poll alone would need. +COACD_POLL_INTERVAL_SECONDS = 1 / 60 # CoACD already prints its own progress to stdout - section headers like # " - Decomposition (MCTS)" and per-candidate "Processing [62.3%]" lines - @@ -95,6 +100,10 @@ def __init__(self, *args, **kwargs): self._async_process = None self._async_start_time = 0.0 self._async_job_label = 'CoACD' + # CoACD's MCTS search is much slower than V-HACD on non-trivial + # meshes - shown on the overlay (draw_async_job_overlay()) so a + # multi-minute run doesn't read as hung. + self._async_hint_text = 'This can take a few minutes for complex meshes' self._coacd_exe = None self._coacd_data_path = None self._coacd_pending_jobs = [] @@ -174,10 +183,7 @@ def modal(self, context, event): def cancel(self, context): self._cancel_coacd_job(context) context.space_data.shading.color_type = self.color_type - try: - bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') - except ValueError: - pass + _remove_draw_handle(self._handle) return {'CANCELLED'} def validate_paths_and_settings(self, context): diff --git a/collider_operators/__init__.py b/collider_operators/__init__.py index 0416d912..dc5601bc 100644 --- a/collider_operators/__init__.py +++ b/collider_operators/__init__.py @@ -33,6 +33,7 @@ utility_operators.COLLISION_OT_MoveOriginToParentOperator, utility_operators.COLLISION_OT_ReplaceWithCleanMesh, utility_operators.COLLISION_OT_FixColliderTransform, + utility_operators.COLLISION_OT_ClearStuckOverlays, utility_operators.COLLISION_OT_ReloadAddon, ) diff --git a/collider_operators/utility_operators.py b/collider_operators/utility_operators.py index a4598c54..3cc98996 100644 --- a/collider_operators/utility_operators.py +++ b/collider_operators/utility_operators.py @@ -3,6 +3,7 @@ from mathutils import Matrix from ..properties.constants import DECIMATE_NAME +from ..collider_shapes.add_bounding_primitive import clear_stuck_draw_handles def set_triangle_count_limit(obj, target_triangles, iterations=8, depsgraph=None): @@ -404,3 +405,50 @@ def execute(self, context): else: self.report({'INFO'}, f"Fixed parent inverse matrix for {fixed_count} collider(s).") return {'FINISHED'} + + +class COLLISION_OT_ClearStuckOverlays(bpy.types.Operator): + """Remove viewport overlays (the collider-creation HUD, the CoACD/V-HACD + "running" overlay, etc.) left behind by a modal collider operator that + didn't shut down cleanly - e.g. an unhandled exception mid-run. Also + resets the CoACD/V-HACD "a job is already running" guard, which the same + kind of crash can leave stuck on, permanently blocking new Auto Convex + runs otherwise.""" + bl_idname = "collision.clear_stuck_overlays" + bl_label = "Clear Stuck Overlays" + bl_description = ("Remove leftover viewport HUDs (e.g. a stuck 'Running CoACD' box) from a " + "collider operator that didn't clean up after itself") + bl_options = {'REGISTER', 'INTERNAL'} + + def execute(self, context): + cleared = clear_stuck_draw_handles() + + # The "already running" guards live as plain module globals in the + # Auto Convex backends (not bpy state), so a crash mid-run leaves + # them stuck True with no operator instance left to reset them - + # imported here (rather than at module load) to avoid a package + # import-order dependency between collider_operators and auto_Convex. + from ..auto_Convex import add_bounding_auto_convex_coacd as _coacd_mod + from ..auto_Convex import add_bounding_auto_convex as _vhacd_mod + reset_flags = [] + if _coacd_mod._coacd_run_in_progress: + _coacd_mod._coacd_run_in_progress = False + reset_flags.append('CoACD') + if _vhacd_mod._vhacd_run_in_progress: + _vhacd_mod._vhacd_run_in_progress = False + reset_flags.append('V-HACD') + + for area in context.screen.areas: + if area.type == 'VIEW_3D': + area.tag_redraw() + + if cleared or reset_flags: + parts = [] + if cleared: + parts.append(f"{cleared} stuck overlay(s)") + if reset_flags: + parts.append(f"stuck {'/'.join(reset_flags)} run guard") + self.report({'INFO'}, f"Cleared {' and '.join(parts)}.") + else: + self.report({'INFO'}, "Nothing stuck - no leftover overlays or run guards found.") + return {'FINISHED'} diff --git a/collider_shapes/add_bounding_primitive.py b/collider_shapes/add_bounding_primitive.py index 30f45214..561a3739 100644 --- a/collider_shapes/add_bounding_primitive.py +++ b/collider_shapes/add_bounding_primitive.py @@ -34,6 +34,65 @@ # behind the input, stuttering/freezing (#641). MODIFIER_DEBOUNCE_SECONDS = 0.15 +# Every SpaceView3D draw handler registered by OBJECT_OT_add_bounding_object +# (the collider-creation modal HUD, including its async-job overlay - see +# draw_viewport_overlay()/draw_async_job_overlay()) is tracked here as it's +# added, and untracked as it's cleanly removed. If a modal operator instance +# is torn down abnormally (an unhandled exception in modal(), Blender itself +# crashing/reloading mid-run, etc.) its own draw_handler_remove() call never +# runs, leaving that overlay stuck on screen forever with no operator +# instance left to remove it - the operator instance that registered it is +# gone, but this module-level list survives, which is what lets +# COLLISION_OT_ClearStuckOverlays (collider_operators/utility_operators.py) +# find and remove it later regardless of which instance created it. +_active_draw_handles = [] + + +def _register_draw_handle(handle): + _active_draw_handles.append(handle) + + +def _remove_draw_handle(handle): + """Remove a SpaceView3D draw handler and forget it - the single place + every normal (non-crash) cleanup path should go through, so the handle + is never both removed and still sitting in _active_draw_handles.""" + try: + bpy.types.SpaceView3D.draw_handler_remove(handle, 'WINDOW') + except ValueError: + # Already removed (e.g. cancel() and the modal's own finish path + # both tried) - not an error, just nothing left to do. + pass + try: + _active_draw_handles.remove(handle) + except ValueError: + pass + + +def clear_stuck_draw_handles(): + """Force-remove every currently-tracked SpaceView3D draw handler and + return how many were cleared. Used by COLLISION_OT_ClearStuckOverlays + (collider_operators/utility_operators.py) to recover from a modal + operator that didn't clean up after itself. + + Deliberately exposed as a function rather than having that operator + import/mutate _active_draw_handles directly: `from module import + mutable_list_name` binds the caller to whatever list object existed at + import time. If this module is ever reloaded (e.g. this addon's own + "Reload Addon" dev button), its `_active_draw_handles = []` line + re-executes and rebinds the name here to a brand new list - any other + module still holding the old imported reference would then silently + operate on an orphaned, forever-empty list. A function's body looks up + module globals by name at call time, not at import time, so it always + sees the current list regardless of reload ordering.""" + cleared = len(_active_draw_handles) + for handle in _active_draw_handles.copy(): + try: + bpy.types.SpaceView3D.draw_handler_remove(handle, 'WINDOW') + except ValueError: + pass + _active_draw_handles.clear() + return cleared + def alignObjects(new, old): """Align two objects""" @@ -269,6 +328,22 @@ def draw_modal_item(self, context, font_id, i, vertical_px_offset, left_margin, def draw_viewport_overlay(self, context): """Draw 3D viewport overlay for the modal operator""" + try: + # as_pointer() is the standard way to probe whether a bpy_struct + # instance's underlying RNA is still alive. If the modal operator + # behind this draw handler was torn down without running its own + # draw_handler_remove() cleanup (e.g. this addon's "Reload Addon" + # dev button unregistering the class while a modal instance was + # still live, or an unhandled exception elsewhere ending the modal + # loop early), every later redraw would otherwise crash here + # forever. Skip drawing instead - the leftover handle stays in + # _active_draw_handles for "Clear Stuck Overlays" + # (collider_operators/utility_operators.py, clear_stuck_draw_handles()) + # to find and remove. + self.as_pointer() + except ReferenceError: + return + items = [] # Detecting "is the user currently navigating" from event types seen in @@ -577,17 +652,37 @@ def draw_async_job_overlay(self, context): elapsed = time.time() - getattr(self, '_async_start_time', time.time()) job_label = getattr(self, '_async_job_label', 'PROCESSING') status = getattr(self, '_async_status_text', '') + hint = getattr(self, '_async_hint_text', '') lines = [(f'RUNNING {job_label.upper()}', title_font_size, prefs.modal_color_error)] if status: lines.append((status.upper(), font_size, prefs.modal_color_default)) lines.append((f'{elapsed:0.0f}S ELAPSED', font_size, prefs.modal_color_default)) + if hint: + lines.append((hint.upper(), font_size, prefs.modal_color_navigation)) lines.append(('ESC TO CANCEL', font_size, prefs.modal_color_navigation)) line_height = int(font_size * 1.6) row_padding = font_size * 0.7 - box_height = line_height * len(lines) + row_padding * 2 - box_width = 420 / 20 * font_size + # A caution-tape band across the top, tall enough that the diagonal + # stripes actually read as diagonal rather than blurring into a flat + # strip - reserved as extra height on top of the text rows rather than + # squeezed into the existing row padding, so it can't overlap the title. + accent_px = max(18, int(font_size * 1.1)) + box_height = line_height * len(lines) + row_padding * 2 + accent_px + + # Sized to the widest line rather than a fixed guess - the CoACD hint + # line ("THIS CAN TAKE A FEW MINUTES...") is long enough to overflow the + # old fixed-width box, spilling text past its edges. + side_padding = font_size * 1.5 + max_text_width = 0.0 + for text, size, _color in lines: + if bpy.app.version < (4, 00): + blf.size(font_id, 72, size) + else: + blf.size(font_id, size) + max_text_width = max(max_text_width, blf.dimensions(font_id, text)[0]) + box_width = max(420 / 20 * font_size, max_text_width + side_padding * 2) center_x = region.width / 2 center_y = region.height / 2 @@ -600,18 +695,26 @@ def draw_async_job_overlay(self, context): if prefs.use_modal_box: draw_2d_backdrop(self, context, box_left, box_right, box_top, box_bottom, prefs.modal_box_color) - # warning-red accent (vs. the brand-green accent on the normal settings - # HUD) so this reads as a distinct, attention-worthy state at a glance. + # Caution-tape stripes (vs. the brand-green accent on the normal + # settings HUD) so this reads as a distinct, attention-worthy state + # at a glance. Drawn as scrolling diagonal stripes rather than a + # flat fill because the async job reports no fraction-complete to + # drive a real progress bar with - a "barber pole" pattern is the + # standard way to signal "still working" without one. frame_color = (0.204, 0.212, 0.243, 1.0) - accent_color = (0.902, 0.302, 0.302, 1.0) + # Same red as the "RUNNING COACD" title (prefs.modal_color_error) + # rather than a hardcoded approximation, so a user-customized + # warning color stays consistent between the title and the stripes. + accent_color_a = tuple(prefs.modal_color_error) + accent_color_b = (0.106, 0.106, 0.114, 1.0) frame_px = 1 - accent_px = 3 - draw_2d_backdrop(self, context, box_left, box_right, box_top, box_top - accent_px, accent_color) + draw_animated_stripes(box_left, box_right, box_top, box_top - accent_px, + accent_color_a, accent_color_b, stripe_width=accent_px * 1.6) draw_2d_backdrop(self, context, box_left, box_right, box_bottom + frame_px, box_bottom, frame_color) draw_2d_backdrop(self, context, box_left, box_left + frame_px, box_top - accent_px, box_bottom, frame_color) draw_2d_backdrop(self, context, box_right - frame_px, box_right, box_top - accent_px, box_bottom, frame_color) - y = box_top - row_padding - line_height * 0.75 + y = box_top - accent_px - row_padding - line_height * 0.75 for text, size, color in lines: if bpy.app.version < (4, 00): blf.size(font_id, 72, size) @@ -646,6 +749,104 @@ def draw_2d_backdrop(self, context, left, right, top, bottom, color): batch.draw(shader) +_stripe_shader = None + + +def _get_stripe_shader(): + """Lazily compile and cache the diagonal-stripe shader used by + draw_animated_stripes(). Compiling a GPUShader is comparatively + expensive; the overlay redraws on every timer tick while an async job + is running (see _poll_coacd_process()/_poll_vhacd_process()), so this + must happen once per Blender session, not once per draw call.""" + global _stripe_shader + if _stripe_shader is not None: + return _stripe_shader + + # Current Blender no longer accepts raw GLSL source directly via + # gpu.types.GPUShader(vertexcode, fragcode) - custom (non-builtin) + # shaders have to be assembled through GPUShaderCreateInfo and compiled + # with gpu.shader.create_from_info() instead. + shader_info = gpu.types.GPUShaderCreateInfo() + shader_info.push_constant('MAT4', "ModelViewProjectionMatrix") + shader_info.push_constant('VEC4', "color_a") + shader_info.push_constant('VEC4', "color_b") + shader_info.push_constant('FLOAT', "stripe_width") + shader_info.push_constant('FLOAT', "offset") + shader_info.vertex_in(0, 'VEC2', "pos") + shader_info.fragment_out(0, 'VEC4', "FragColor") + shader_info.vertex_source( + "void main() {" + " gl_Position = ModelViewProjectionMatrix * vec4(pos, 0.0, 1.0);" + "}" + ) + shader_info.fragment_source( + "void main() {" + " float diag = gl_FragCoord.x + gl_FragCoord.y;" + " float t = mod(diag + offset, stripe_width * 2.0);" + " FragColor = (t < stripe_width) ? color_a : color_b;" + "}" + ) + _stripe_shader = gpu.shader.create_from_info(shader_info) + del shader_info + return _stripe_shader + + +def _srgb_to_linear(color): + """Convert an sRGB color (as used throughout this addon's color-picker + preferences, e.g. prefs.modal_color_error) to linear space. + + Builtin shaders (gpu.shader.from_builtin(), used by draw_2d_backdrop()) + and blf text drawing both do this conversion internally before writing + to the sRGB-encoded viewport framebuffer. A custom GPUShaderCreateInfo + shader does not get that conversion for free, so feeding it an sRGB + color straight from preferences gets it gamma-encoded a second time by + the framebuffer, washing the color out - draw_animated_stripes() must + do this conversion itself to match how the rest of the overlay renders + the same color.""" + def channel(c): + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + return tuple(channel(c) for c in color[:3]) + tuple(color[3:]) + + +def draw_animated_stripes(left, right, top, bottom, color_a, color_b, + stripe_width=14.0, speed=60.0): + """Diagonal 'barber pole' stripes panned over time via the shader's + offset uniform - used as the accent bar on the async job overlay (see + draw_async_job_overlay()). The subprocess jobs this decorates (CoACD/ + V-HACD) report no reliable fraction-complete to bind to a determinate + progress bar, so a scrolling pattern is what signals "still working" + instead.""" + vertices = ( + (left, bottom), (right, bottom), + (left, top), (right, top)) + indices = ((0, 1, 2), (2, 1, 3)) + + shader = _get_stripe_shader() + batch = batch_for_shader(shader, 'TRIS', {"pos": vertices}, indices=indices) + + shader.bind() + # Blender has already set up the pixel-space projection/view matrices + # for this POST_PIXEL draw callback (the same state draw_2d_backdrop() + # relies on implicitly via the builtin shader) - a custom GPUShader has + # to be told explicitly, since only builtin shaders pick it up on their + # own. + matrix = gpu.matrix.get_projection_matrix() @ gpu.matrix.get_model_view_matrix() + shader.uniform_float("ModelViewProjectionMatrix", matrix) + shader.uniform_float("color_a", _srgb_to_linear(color_a)) + shader.uniform_float("color_b", _srgb_to_linear(color_b)) + shader.uniform_float("stripe_width", stripe_width) + # time.time() is a huge Unix-epoch float (~1.7e9); at the GPU's 32-bit + # float precision, adding that directly to gl_FragCoord (0-2000ish) + # loses the small delta entirely - the pattern was rendering as a + # constant color instead of stripes. Reducing the offset modulo the + # pattern's own period *in Python* (float64) before it ever reaches the + # shader keeps the value small, so the GPU's 32-bit math stays precise. + period = stripe_width * 2.0 + offset = -(time.time() * speed) % period + shader.uniform_float("offset", offset) + batch.draw(shader) + + def get_loc_matrix(location): """get location matrix""" return Matrix.Translation(location) @@ -1881,10 +2082,7 @@ def cancel_cleanup(self, context, delete_colliders=True): if context.object and context.object.mode != self.obj_mode: bpy.ops.object.mode_set(mode=self.obj_mode) - try: - bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') - except ValueError: - pass + _remove_draw_handle(self._handle) def join_primitives(self, context): bpy.ops.object.mode_set(mode='OBJECT') @@ -2350,6 +2548,7 @@ def invoke(self, context, event): # draw in view space with 'POST_VIEW' and 'PRE_VIEW' # self._handle = bpy.types.SpaceView3D.draw_handler_add(draw_viewport_overlay, args, 'WINDOW', 'POST_PIXEL') self._handle = bpy.types.SpaceView3D.draw_handler_add(draw_viewport_overlay, args, 'WINDOW', 'POST_PIXEL') + _register_draw_handle(self._handle) # add modal handler context.window_manager.modal_handler_add(self) @@ -2552,10 +2751,7 @@ def modal(self, context, event): self.remove_empty_collection(context, 'tmp_mesh') self._clear_modifier_bake_cache() - try: - bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') - except ValueError: - pass + _remove_draw_handle(self._handle) # restore display settings self.reset_display(context) diff --git a/ui/properties_panels.py b/ui/properties_panels.py index d5ee757c..fb6004b4 100644 --- a/ui/properties_panels.py +++ b/ui/properties_panels.py @@ -848,21 +848,16 @@ class OBJECT_MT_adjust_decimation_menu(Menu): bl_description = "Clean up collider geometry (remove doubles, optimize, etc.)" def draw(self, context): - prefs = context.preferences.addons[base_package].preferences layout = self.layout layout.operator('object.adjust_decimation') - layout.prop(prefs, 'auto_apply_tris_limit', text="Auto Apply on Creation") - sub = layout.column() - sub.enabled = prefs.auto_apply_tris_limit - sub.prop(prefs, 'auto_apply_max_triangle_count', text="Target Triangles") - - layout.separator() layout.operator('object.origin_to_parent') - layout.prop(prefs, 'auto_apply_origin_to_parent', text="Auto Apply on Creation") layout.separator() layout.operator('object.fix_parent_inverse_transform') # Use a warning icon for Blender 4.3 and above, else use error icon icon = 'WARNING_LARGE' if bpy.app.version >= (4, 3, 0) else 'ERROR' layout.operator('collision.replace_with_clean_mesh', icon=icon) + + layout.separator() + layout.operator('collision.clear_stuck_overlays', icon=icon)