Skip to content

Commit bc926e3

Browse files
TMHSDigitalclaude
andauthored
fix(snippets): hull only the points so convex colliders come out closed (#407)
bmesh.ops.convex_hull adds hull triangles but keeps every source edge and face whose verts lie on the hull, and geom_interior/geom_unused stay empty on convex input. Hulling a cube gave V8 E18 F18 with 12 non-manifold edges (6 source quads on top of 12 hull tris); on the pipeline fixture the collider kept 4512 verts. Remove the source edges first (edges.remove keeps the verts), hull, then drop verts left with no faces. The cube now gives F12, all tris, 0 non-manifold edges. The pipeline template asserts the hull is closed and exits 7 otherwise, so a regression cannot ship a broken collider silently. The ai-mesh-cleanup skill describes the corrected recipe. Showcase copies are fixed separately. Refs #386 Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9548e80 commit bc926e3

4 files changed

Lines changed: 41 additions & 18 deletions

File tree

‎skills/ai-mesh-cleanup/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ Snippet: [`snippets/decimate_to_budget.py`](https://github.com/TMHSDigital/Blend
155155

156156
### 8. Generate collider
157157

158-
Convex hull via `bmesh.ops.convex_hull`, then delete `geom_interior` and `geom_unused`. Copy `matrix_world` onto the collider. Hull a coarse cage when the render mesh would blow a per-piece face budget. Witness: [`examples/collision-hull-proxy/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/collision-hull-proxy).
158+
Convex hull via `bmesh.ops.convex_hull` over the points only: remove the source edges first (`bm.edges.remove`, which keeps the verts), hull, then delete verts left with no faces. Hulling a mesh that still has faces keeps every source face lying on the hull, so a cube collider comes out with 18 faces and 12 non-manifold edges instead of 12 closed triangles. Copy `matrix_world` onto the collider. Hull a coarse cage when the render mesh would blow a per-piece face budget. Witness: [`examples/collision-hull-proxy/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/collision-hull-proxy).
159159

160160
Snippet: [`snippets/convex_hull_collider.py`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/snippets/convex_hull_collider.py).
161161

‎snippets/convex_hull_collider.py‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
# Build a convex hull collision mesh via bmesh.ops.convex_hull.
2-
# Delete geom_interior and geom_unused from the result dict so leftover
3-
# input verts do not remain inside the hull. Copy matrix_world onto
4-
# the collider so it sits on the source object.
2+
# Strip the source edges/faces first: the op adds hull triangles but keeps
3+
# source faces lying on the hull (a cube came out V8 E18 F18 with 12
4+
# non-manifold edges). Then drop verts left inside the hull. The result is
5+
# a closed, all-triangle hull (cube: F12, 0 non-manifold edges). A flat
6+
# input has no volume and yields an open hull. Copy matrix_world onto the
7+
# collider so it sits on the source object.
58
#
69
# bmesh.new() must be paired with bm.free() in try/finally.
710
#
@@ -18,13 +21,15 @@ def convex_hull_collider(obj, name=None):
1821
bm = bmesh.new()
1922
try:
2023
bm.from_mesh(obj.data)
21-
result = bmesh.ops.convex_hull(bm, input=bm.verts)
22-
interior = result.get("geom_interior") or []
23-
unused = result.get("geom_unused") or []
24-
if interior:
25-
bmesh.ops.delete(bm, geom=interior, context="VERTS")
26-
if unused:
27-
bmesh.ops.delete(bm, geom=unused, context="VERTS")
24+
# Hull the points, not the surface: convex_hull keeps any source
25+
# edge or face whose verts lie on the hull, leaving duplicate,
26+
# non-manifold faces. edges.remove() drops faces but keeps verts.
27+
for edge in bm.edges[:]:
28+
bm.edges.remove(edge)
29+
bmesh.ops.convex_hull(bm, input=bm.verts[:])
30+
loose = [v for v in bm.verts if not v.link_faces]
31+
if loose:
32+
bmesh.ops.delete(bm, geom=loose, context="VERTS")
2833
bm.to_mesh(mesh)
2934
mesh.update()
3035
finally:

‎templates/ai-asset-pipeline-template/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ as `export-preset-axis` number their own checks independently.
6868
| 4 | `--lod-budgets` missing, non-positive, or not strictly decreasing |
6969
| 5 | Import produced no mesh |
7070
| 6 | `outdir` is not a directory, or glTF export failed / wrote no file |
71+
| 7 | `--collider convex` produced a hull that is not closed (an edge without exactly two faces), e.g. a flat input |
7172

7273
## Expected environment
7374

‎templates/ai-asset-pipeline-template/pipeline.py‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
# docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.fbx
2929

3030
import argparse
31+
import collections
3132
import os
3233
import sys
3334

@@ -183,13 +184,15 @@ def convex_hull_collider(obj, name=None):
183184
bm = bmesh.new()
184185
try:
185186
bm.from_mesh(obj.data)
186-
result = bmesh.ops.convex_hull(bm, input=bm.verts)
187-
interior = result.get("geom_interior") or []
188-
unused = result.get("geom_unused") or []
189-
if interior:
190-
bmesh.ops.delete(bm, geom=interior, context="VERTS")
191-
if unused:
192-
bmesh.ops.delete(bm, geom=unused, context="VERTS")
187+
# Hull the points, not the surface: convex_hull keeps any source
188+
# edge or face whose verts lie on the hull, leaving duplicate,
189+
# non-manifold faces. edges.remove() drops faces but keeps verts.
190+
for edge in bm.edges[:]:
191+
bm.edges.remove(edge)
192+
bmesh.ops.convex_hull(bm, input=bm.verts[:])
193+
loose = [v for v in bm.verts if not v.link_faces]
194+
if loose:
195+
bmesh.ops.delete(bm, geom=loose, context="VERTS")
193196
bm.to_mesh(mesh)
194197
mesh.update()
195198
finally:
@@ -200,6 +203,14 @@ def convex_hull_collider(obj, name=None):
200203
return collider
201204

202205

206+
def is_closed_hull(mesh):
207+
"""True when every edge borders exactly two faces (a closed surface)."""
208+
if not mesh.polygons:
209+
return False
210+
counts = collections.Counter(k for p in mesh.polygons for k in p.edge_keys)
211+
return all(counts[e.key] == 2 for e in mesh.edges)
212+
213+
203214
def box_collider(obj, name=None):
204215
mesh_in = obj.data
205216
n = len(mesh_in.vertices)
@@ -374,6 +385,12 @@ def main():
374385
if args.collider == "convex":
375386
collider = convex_hull_collider(hero)
376387
print(f"collider={collider.name} verts={len(collider.data.vertices)}")
388+
if not is_closed_hull(collider.data):
389+
print(
390+
f"ERROR: convex collider {collider.name} is not a closed hull",
391+
file=sys.stderr,
392+
)
393+
return 7
377394
elif args.collider == "box":
378395
collider = box_collider(hero)
379396
print(f"collider={collider.name} verts={len(collider.data.vertices)}")

0 commit comments

Comments
 (0)