Skip to content

Instantly share code, notes, and snippets.

@tinkerer-red
Created August 13, 2025 10:25
Show Gist options
  • Save tinkerer-red/500eba2ac5c874edfd45005c759b20c5 to your computer and use it in GitHub Desktop.
Save tinkerer-red/500eba2ac5c874edfd45005c759b20c5 to your computer and use it in GitHub Desktop.
Initial attempts at generating point clouds for mesh collisions based of poses. With the intent to assist modders to automatically create non-convex Hull collisions using only Boxes, Spheres, or Cylinders
# Pose-to-Point-Matrix (Empties) with Precision, BVH-inside, and KDTree distance pruning
# - Inside/Outside by BVH ray-parity on the remeshed surface
# - Precision decimation via striding per axis
# - Exactly two extra nodes per axis AFTER precision
# - Drop points whose nearest vertex is farther than 32 * spacing
#
# ASCII-only. Blender 4.2.
import bpy
import bmesh
from mathutils import Vector
from mathutils import bvhtree
from mathutils.kdtree import KDTree
from math import ceil
import contextlib
# ---------------------------
# Configuration
# ---------------------------
REMESH_SETTINGS = {
"mode": "SMOOTH", # "BLOCKS", "SMOOTH", "SHARP"
"octree_depth": 9,
"scale": 0.90,
"remove_disconnected": False,
"threshold": 1.0,
"use_smooth_shade": False
}
# We do not pre-expand the grid anymore; we add explicit post precision nodes instead.
PRE_EXPAND_STEPS = 0
# Exactly two extra nodes beyond the bounds per axis, after precision stride
POST_EXTRA_NODES = 2
# Distance pruning: keep points only if nearest vertex distance <= spacing * this
DISTANCE_BUFFER_STEPS = 16
# Precision decimation:
# 1.0 => keep all samples
# 0.05 => keep 1 of every 20 per axis (~1/8000 overall)
# 0.01 => keep 1 of every 100 per axis (~1e-6 overall)
PRECISION_FACTOR = 0.2
# Empty visuals
EMPTY_DISPLAY_TYPE = "SPHERE" # "PLAIN_AXES", "ARROWS", "SINGLE_ARROW", "CIRCLE", "CUBE", "SPHERE", "CONE"
EMPTY_SIZE_SCALE = 0.25 # size = spacing * this scale
COLLECTION_PREFIX = "PointMatrix"
TEMP_MERGE_NAME = "__PPM_MergeSrc"
TEMP_REMESH_NAME = "__PPM_RemeshCopy"
# ---------------------------
# Utilities
# ---------------------------
@contextlib.contextmanager
def active_object_guard():
previous_object = bpy.context.view_layer.objects.active
try:
yield
finally:
with context_override():
try:
bpy.context.view_layer.objects.active = previous_object
except Exception:
pass
@contextlib.contextmanager
def context_override():
override = {
"window": bpy.context.window,
"screen": bpy.context.screen,
"scene": bpy.context.scene,
"view_layer": bpy.context.view_layer
}
with bpy.context.temp_override(**{k: v for k, v in override.items() if v is not None}):
yield
def safe_object_name(object_maybe):
try:
return object_maybe.name
except ReferenceError:
return None
except AttributeError:
return None
def report_info(message_text):
print("[PPM] " + message_text)
def ensure_collection(collection_name):
existing = bpy.data.collections.get(collection_name)
if existing is None:
existing = bpy.data.collections.new(collection_name)
bpy.context.scene.collection.children.link(existing)
return existing
def duplicate_object_to_single_mesh(object_list, result_name):
with context_override():
bpy.ops.object.select_all(action="DESELECT")
for source_object in object_list:
source_object.select_set(True)
bpy.context.view_layer.objects.active = object_list[0]
bpy.ops.object.duplicate()
duplicated = list(bpy.context.selected_objects)
for obj in duplicated:
obj.name = obj.name + "_PPM"
if len(duplicated) > 1:
bpy.ops.object.convert(target="MESH")
bpy.ops.object.join()
joined = bpy.context.active_object
else:
if duplicated[0].type != "MESH":
bpy.ops.object.convert(target="MESH")
joined = bpy.context.active_object
joined.name = result_name
return joined
def apply_all_modifiers_to_mesh(obj_mesh_eval_name):
with context_override():
obj = bpy.data.objects[obj_mesh_eval_name]
bpy.context.view_layer.objects.active = obj
for modifier in list(obj.modifiers):
try:
bpy.ops.object.modifier_apply(modifier=modifier.name)
except Exception:
pass
return obj
def evaluate_with_armature_pose(base_mesh_objects, armature_object):
depsgraph = bpy.context.evaluated_depsgraph_get()
posed_objects = []
for source_object in base_mesh_objects:
if source_object.type != "MESH":
continue
evaluated_object = source_object.evaluated_get(depsgraph)
baked_mesh = bpy.data.meshes.new_from_object(
evaluated_object,
preserve_all_data_layers=True,
depsgraph=depsgraph
)
new_object = bpy.data.objects.new(source_object.name + "_PoseObj", baked_mesh)
bpy.context.scene.collection.objects.link(new_object)
posed_objects.append(new_object)
return posed_objects
def add_remesh_modifier_and_apply(obj, settings):
with context_override():
bpy.context.view_layer.objects.active = obj
bpy.ops.object.modifier_add(type="REMESH")
modifier = obj.modifiers[-1]
modifier.mode = settings["mode"]
modifier.octree_depth = int(settings["octree_depth"])
modifier.scale = float(settings["scale"])
modifier.use_remove_disconnected = bool(settings["remove_disconnected"])
modifier.threshold = float(settings["threshold"])
modifier.use_smooth_shade = bool(settings["use_smooth_shade"])
bpy.ops.object.modifier_apply(modifier=modifier.name)
return obj
def compute_average_edge_length(obj):
mesh_data = obj.data
bm = bmesh.new()
bm.from_mesh(mesh_data)
if len(bm.edges) == 0:
bm.free()
return 0.0
total_length = 0.0
for edge in bm.edges:
total_length += edge.calc_length()
average_length = total_length / float(len(bm.edges))
bm.free()
return average_length
def object_world_bounds(obj):
coords = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
world_min = Vector((min(c.x for c in coords), min(c.y for c in coords), min(c.z for c in coords)))
world_max = Vector((max(c.x for c in coords), max(c.y for c in coords), max(c.z for c in coords)))
return world_min, world_max
def sampled_range(end_count, stride):
if end_count <= 0:
return []
result = list(range(0, end_count, stride))
last_index = end_count - 1
if not result or result[-1] != last_index:
result.append(last_index)
return result
def extend_indices_with_post_nodes(base_indices, stride, extra_nodes):
if not base_indices:
return base_indices
first_index = base_indices[0]
last_index = base_indices[-1]
prefix = [first_index - stride * steps for steps in range(extra_nodes, 0, -1)]
suffix = [last_index + stride * steps for steps in range(1, extra_nodes + 1)]
return prefix + base_indices + suffix
def build_point_grid(min_world, max_world, spacing, pre_expand_steps, precision_factor, post_extra_nodes):
if spacing <= 0.0:
raise ValueError("Point spacing is zero or negative; remesh likely failed.")
# precision to stride
precision = max(0.0, min(1.0, float(precision_factor)))
if precision > 0.0:
stride = max(1, int(round(1.0 / precision)))
else:
stride = 10**9
# pre expansion disabled here by default
expand = spacing * float(pre_expand_steps)
grid_min = Vector((min_world.x - expand, min_world.y - expand, min_world.z - expand))
grid_max = Vector((max_world.x + expand, max_world.y + expand, max_world.z + expand))
count_x = max(1, int(ceil((grid_max.x - grid_min.x) / spacing)) + 1)
count_y = max(1, int(ceil((grid_max.y - grid_min.y) / spacing)) + 1)
count_z = max(1, int(ceil((grid_max.z - grid_min.z) / spacing)) + 1)
base_x = sampled_range(count_x, stride)
base_y = sampled_range(count_y, stride)
base_z = sampled_range(count_z, stride)
index_x = extend_indices_with_post_nodes(base_x, stride, post_extra_nodes)
index_y = extend_indices_with_post_nodes(base_y, stride, post_extra_nodes)
index_z = extend_indices_with_post_nodes(base_z, stride, post_extra_nodes)
points = []
for index_k in index_z:
for index_j in index_y:
for index_i in index_x:
point_x = grid_min.x + float(index_i) * spacing
point_y = grid_min.y + float(index_j) * spacing
point_z = grid_min.z + float(index_k) * spacing
points.append((point_x, point_y, point_z))
kept_counts = (len(index_x), len(index_y), len(index_z))
full_counts = (count_x, count_y, count_z)
return points, grid_min, grid_max, kept_counts, full_counts, stride
def build_vertex_kdtree_world(remesh_object):
mesh_data = remesh_object.data
vertex_count = len(mesh_data.vertices)
if vertex_count == 0:
return None
kd = KDTree(vertex_count)
mat = remesh_object.matrix_world
for index_vertex, vertex in enumerate(mesh_data.vertices):
kd.insert(mat @ vertex.co, index_vertex)
kd.balance()
return kd
def filter_points_by_nearest_vertex(points_world, kd_tree, limit_distance):
if kd_tree is None:
return points_world
kept = []
for tuple_point in points_world:
nearest = kd_tree.find(Vector(tuple_point))
if nearest is None:
continue
nearest_co, nearest_index, nearest_dist = nearest
if nearest_dist <= limit_distance:
kept.append(tuple_point)
return kept
# ---------------------------
# Inside vs Outside classification using BVH parity
# ---------------------------
def classify_points_inside_outside_by_bvh(remesh_object, points_world, spacing_hint):
deps = bpy.context.evaluated_depsgraph_get()
bvh = bvhtree.BVHTree.FromObject(remesh_object, depsgraph=deps, epsilon=0.0)
mat_inv = remesh_object.matrix_world.inverted()
directions = [
Vector((1.0, 0.0, 0.0)),
Vector((0.0, 1.0, 0.0)),
Vector((0.0, 0.0, 1.0))
]
eps = max(spacing_hint * 0.25, 1e-6)
step_past = eps * 10.0
max_dist = 1e6
inside_points = []
outside_points = []
for tuple_point in points_world:
point_local = mat_inv @ Vector(tuple_point)
inside_votes = 0
for direction_vec in directions:
origin_local = point_local + direction_vec * eps
remaining = max_dist
count_hits = 0
while remaining > 0.0:
hit = bvh.ray_cast(origin_local, direction_vec, remaining)
if hit is None:
break
hit_loc, hit_normal, hit_index, hit_dist = hit
if hit_loc is None:
break
count_hits += 1
origin_local = hit_loc + direction_vec * step_past
remaining = remaining - hit_dist - step_past
if remaining <= 0.0:
break
if (count_hits % 2) == 1:
inside_votes += 1
if inside_votes >= 2:
inside_points.append(tuple_point)
else:
outside_points.append(tuple_point)
return inside_points, outside_points
def delete_objects_safely(object_or_name_list):
with context_override():
bpy.ops.object.select_all(action="DESELECT")
names_to_delete = []
for entry in object_or_name_list:
if isinstance(entry, str):
if entry in bpy.data.objects:
names_to_delete.append(entry)
else:
name_value = safe_object_name(entry)
if name_value and name_value in bpy.data.objects:
names_to_delete.append(name_value)
for name_value in names_to_delete:
obj_ref = bpy.data.objects.get(name_value)
if obj_ref is not None:
obj_ref.select_set(True)
if bpy.context.selected_objects:
bpy.ops.object.delete(use_global=False)
# ---------------------------
# Empties creation
# ---------------------------
def make_points_as_empties(points_list, base_name, target_collection, empty_size):
created = []
link_collection = target_collection or bpy.context.scene.collection
for index_point, tuple_point in enumerate(points_list):
empty_name = "{}_{:06d}".format(base_name, index_point)
empty_object = bpy.data.objects.new(empty_name, None)
empty_object.empty_display_type = EMPTY_DISPLAY_TYPE
empty_object.empty_display_size = float(empty_size)
empty_object.location = tuple_point
link_collection.objects.link(empty_object)
created.append(empty_object)
return created
# ---------------------------
# Core pipeline
# ---------------------------
def generate_point_matrix_pipeline():
selection = list(bpy.context.selected_objects)
if not selection:
raise RuntimeError("Select an Armature or one or more Mesh objects before running.")
active = bpy.context.view_layer.objects.active or selection[0]
is_armature_mode = (active.type == "ARMATURE")
if is_armature_mode:
report_info("Mode: Armature. Gathering visible bound meshes.")
source_armature = active
source_meshes = []
for obj in bpy.context.view_layer.objects:
if obj.type == "MESH" and obj.visible_get():
for modifier in obj.modifiers:
if modifier.type == "ARMATURE" and modifier.object == source_armature:
source_meshes.append(obj)
break
if not source_meshes:
raise RuntimeError("No visible meshes bound to the active Armature.")
pose_list = []
action = source_armature.animation_data.action if source_armature.animation_data else None
if action is not None and hasattr(action, "pose_markers") and action.pose_markers:
for marker in action.pose_markers:
pose_list.append((marker.name, int(marker.frame)))
if not pose_list:
pose_list.append(("Current", bpy.context.scene.frame_current))
else:
report_info("Mode: Mesh selection. Using selected mesh objects.")
source_meshes = [obj for obj in selection if obj.type == "MESH"]
if not source_meshes:
raise RuntimeError("No mesh objects selected.")
pose_list = [("Static", None)]
for pose_name, pose_frame in pose_list:
report_info("Processing pose tag: " + pose_name)
if is_armature_mode and pose_frame is not None:
bpy.context.scene.frame_set(int(pose_frame))
if is_armature_mode:
posed_objects = evaluate_with_armature_pose(source_meshes, source_armature)
merge_source = duplicate_object_to_single_mesh(posed_objects, TEMP_MERGE_NAME)
else:
posed_objects = []
merge_source = duplicate_object_to_single_mesh(source_meshes, TEMP_MERGE_NAME)
merge_source = apply_all_modifiers_to_mesh(merge_source.name)
with context_override():
bpy.ops.object.select_all(action="DESELECT")
merge_source.select_set(True)
bpy.context.view_layer.objects.active = merge_source
bpy.ops.object.duplicate()
remesh_copy = bpy.context.active_object
remesh_copy.name = TEMP_REMESH_NAME
remesh_copy = add_remesh_modifier_and_apply(remesh_copy, REMESH_SETTINGS)
spacing_value = compute_average_edge_length(remesh_copy)
if spacing_value <= 0.0:
remesh_min_tmp, remesh_max_tmp = object_world_bounds(remesh_copy)
approx_diag = (remesh_max_tmp - remesh_min_tmp).length
spacing_value = max(approx_diag / 256.0, 0.001)
report_info("Edge length estimate failed; used fallback spacing.")
remesh_min, remesh_max = object_world_bounds(remesh_copy)
# Build grid (no pre expand) and add two post nodes after precision
points_all, grid_min, grid_max, kept_counts, full_counts, stride = build_point_grid(
remesh_min, remesh_max, spacing_value, PRE_EXPAND_STEPS, PRECISION_FACTOR, POST_EXTRA_NODES
)
# Distance pruning by nearest vertex (world space)
kd = build_vertex_kdtree_world(remesh_copy)
limit_distance = float(DISTANCE_BUFFER_STEPS) * float(spacing_value)
points_near = filter_points_by_nearest_vertex(points_all, kd, limit_distance)
# Inside vs Outside by BVH parity
points_inside, points_outside = classify_points_inside_outside_by_bvh(
remesh_copy, points_near, spacing_value
)
inside_collection_name = "{}_{}_Inside".format(COLLECTION_PREFIX, pose_name)
outside_collection_name = "{}_{}_Outside".format(COLLECTION_PREFIX, pose_name)
inside_collection = ensure_collection(inside_collection_name)
outside_collection = ensure_collection(outside_collection_name)
empty_size = float(spacing_value) * float(EMPTY_SIZE_SCALE)
created_inside = make_points_as_empties(points_inside, inside_collection_name, inside_collection, empty_size)
created_outside = make_points_as_empties(points_outside, outside_collection_name, outside_collection, empty_size)
report_info(
"Pose {}: stride {}, kept {} of {}, post_extra {}, near_pruned {}/{}, empties inside {}, outside {}, spacing {:.6f}".format(
pose_name,
stride,
kept_counts,
full_counts,
POST_EXTRA_NODES,
len(points_near),
len(points_all),
len(created_inside),
len(created_outside),
spacing_value
)
)
# Cleanup temp geometry
delete_objects_safely([remesh_copy.name, merge_source.name])
if posed_objects:
delete_objects_safely([o.name for o in posed_objects if safe_object_name(o)])
report_info("Done.")
# ---------------------------
# Run
# ---------------------------
if __name__ == "__main__":
with active_object_guard():
generate_point_matrix_pipeline()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment