Created
August 13, 2025 10:25
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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