Skip to content

Commit 8b84f9c

Browse files
Weislclaude
andcommitted
#583 Add Auto Convex (BETA) using CoACD
Adds a new convex decomposition operator built on CoACD (https://github.com/SarahWeiii/CoACD), the successor to V-HACD, alongside the existing Auto Convex operator. Bundles official CoACD 1.0.11 CLI builds for Windows, Linux, and macOS (ARM64), wires up a matching operator/UI/preferences flow, and merges both engines' settings into a single "Auto Convex" preferences tab. Also fixes a crash in set_origin_to_center_of_mass() where the center-of- mass offset was applied by indexing into the evaluated (post-modifier) mesh's vertex array while iterating the base mesh's vertices - a mismatch whenever a modifier changes vertex count, as the Decimate/Convex Hull modifiers used by Auto Convex colliders do. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 409dc83 commit 8b84f9c

13 files changed

Lines changed: 633 additions & 23 deletions

auto_Convex/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from . import add_bounding_auto_convex
2+
from . import add_bounding_auto_convex_coacd
23

34
classes = (
45
add_bounding_auto_convex.VHACD_OT_convex_decomposition,
6+
add_bounding_auto_convex_coacd.COACD_OT_convex_decomposition,
57
)
68

79

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

coACD_app/LICENSE.txt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
MIT License
2+
3+
Copyright (c) 2022 Xinyue Wei, Minghua Liu
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
22+
23+
---
24+
25+
Binaries in this folder (coACD_app/) are unmodified builds of the "main" CLI
26+
target from the CoACD project (https://github.com/SarahWeiii/CoACD), version
27+
1.0.11, compiled via CoACD's own GitHub Actions build workflow.
6.33 MB
Binary file not shown.
6.68 MB
Binary file not shown.
5.97 MB
Binary file not shown.

collider_shapes/add_bounding_primitive.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,13 @@ def set_origin_to_center_of_mass(obj, depsgraph=None):
128128
# Calculate the offset
129129
offset = obj.matrix_world.inverted() @ mathutils.Vector(com)
130130

131-
# Apply the offset to the object's data in a batch
132-
# Only update mesh once at the end
133-
for i, vertex in enumerate(obj.data.vertices):
134-
vertex.co = mathutils.Vector(verts_local[i]) - offset
131+
# Apply the offset to the object's own (base) mesh vertices. Note:
132+
# `verts_local`/`mesh` above are the evaluated (post-modifier) mesh, used
133+
# only to compute the center of mass - modifiers such as the convex hull
134+
# / decimate modifiers used by Auto Convex can change the vertex count,
135+
# so its vertices no longer line up 1:1 with obj.data.vertices here.
136+
for vertex in obj.data.vertices:
137+
vertex.co = vertex.co - offset
135138

136139
# Move the object's origin to the center of mass
137140
obj.location = mathutils.Vector(com)

0 commit comments

Comments
 (0)