Plane_Trimmer #2

Merged
theoryshaw merged 3 commits from Plane_Trimmer into main 2026-02-12 15:12:06 +00:00
Showing only changes of commit 3bcf101083 - Show all commits
Ryan Schultz 2026-02-11 15:59:18 -06:00

View file

@ -1,11 +1,11 @@
""" """
Miter Planes Trim at Intersection Miter Planes Trim at Intersection (v11)
====================================== ============================================
Select 2+ mesh objects in Object Mode, then run this script. Select 2+ mesh objects in Object Mode, then run this script.
Each original is cut at all intersection lines, then only the Each plane's polygon is clipped by every other plane's infinite plane,
largest face is kept like a miter/trim tool for planes. keeping the side toward the global centroid. Uses Sutherland-Hodgman
Collinear vertices (both mid-edge and fold-back) are removed. polygon clipping no mesh.intersect, no temp objects, no face analysis.
Usage: Usage:
1. Select your plane objects in Object Mode 1. Select your plane objects in Object Mode
@ -18,51 +18,116 @@ from mathutils import Vector
import math import math
def get_world_verts(obj): # ─────────────────────────────────────────────
return [obj.matrix_world @ v.co for v in obj.data.vertices] # Geometry helpers
# ─────────────────────────────────────────────
def get_world_verts_ordered(obj):
"""Get world-space vertices in face winding order (polygon[0])."""
mesh = obj.data
if len(mesh.polygons) == 0:
return [obj.matrix_world @ v.co for v in mesh.vertices]
# If multiple faces (triangulated), dissolve first to get single polygon
if len(mesh.polygons) > 1:
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.dissolve_limit(
bm,
angle_limit=math.radians(5.0),
use_dissolve_boundaries=False,
verts=bm.verts[:],
edges=bm.edges[:],
)
bm.faces.ensure_lookup_table()
if len(bm.faces) > 0:
face = bm.faces[0]
world_verts = [obj.matrix_world @ v.co for v in face.verts]
else:
world_verts = [obj.matrix_world @ v.co for v in mesh.vertices]
bm.free()
return world_verts
face = mesh.polygons[0]
return [obj.matrix_world @ mesh.vertices[i].co for i in face.vertices]
def compute_plane(world_verts): def compute_plane(world_verts):
"""Compute plane normal and point from vertices."""
if len(world_verts) < 3: if len(world_verts) < 3:
return None, None return None, None
v0, v1, v2 = world_verts[0], world_verts[1], world_verts[2] for i in range(len(world_verts)):
normal = (v1 - v0).cross(v2 - v0) for j in range(i + 1, len(world_verts)):
if normal.length < 1e-8: for k in range(j + 1, len(world_verts)):
return None, None normal = (world_verts[j] - world_verts[i]).cross(world_verts[k] - world_verts[i])
normal.normalize() if normal.length > 1e-8:
return normal, v0 normal.normalize()
return normal, world_verts[i]
return None, None
def distance_to_plane(point, plane_normal, plane_point): def distance_to_plane(point, plane_normal, plane_point):
"""Signed distance from point to plane."""
return (point - plane_point).dot(plane_normal) return (point - plane_point).dot(plane_normal)
def bmesh_face_area(face): def clip_polygon_by_plane(polygon_verts, plane_normal, plane_point, keep_side_point):
verts = [v.co for v in face.verts] """
if len(verts) < 3: Sutherland-Hodgman: clip polygon, keeping the side that contains keep_side_point.
return 0.0 Returns clipped polygon vertices (list of Vector), or empty list if fully clipped away.
total = 0.0 """
v0 = verts[0] if len(polygon_verts) < 3:
for i in range(1, len(verts) - 1): return polygon_verts
total += (verts[i] - v0).cross(verts[i + 1] - v0).length / 2.0
return total # Which side to keep?
keep_dist = distance_to_plane(keep_side_point, plane_normal, plane_point)
if abs(keep_dist) < 1e-9:
# Centroid is ON the plane — can't determine side, skip this clip
return polygon_verts
clipped = []
n = len(polygon_verts)
for i in range(n):
curr = polygon_verts[i]
next_v = polygon_verts[(i + 1) % n]
curr_dist = distance_to_plane(curr, plane_normal, plane_point)
next_dist = distance_to_plane(next_v, plane_normal, plane_point)
curr_inside = (curr_dist * keep_dist >= -1e-9) # same side as keep point (with tolerance)
next_inside = (next_dist * keep_dist >= -1e-9)
if curr_inside:
clipped.append(curr)
if not next_inside:
# Exiting — compute intersection point
denom = curr_dist - next_dist
if abs(denom) > 1e-12:
t = curr_dist / denom
intersection = curr.lerp(next_v, t)
clipped.append(intersection)
else:
if next_inside:
# Entering — compute intersection point
denom = curr_dist - next_dist
if abs(denom) > 1e-12:
t = curr_dist / denom
intersection = curr.lerp(next_v, t)
clipped.append(intersection)
return clipped
def remove_collinear_verts(verts, tolerance_degrees=1.0): def remove_collinear_verts(verts, tolerance_degrees=1.0):
""" """Remove collinear vertices — catches both mid-edge (≈180°) and fold-back (≈0°)."""
Remove collinear vertices from an ordered polygon vert list.
Catches TWO cases:
- angle 180°: vert is between its neighbors on a line (mid-edge point)
- angle 0°: vert is a fold-back where both edges go same direction
Both mean the 3 consecutive verts are collinear.
"""
if len(verts) < 3: if len(verts) < 3:
return verts return verts
tolerance_rad = math.radians(tolerance_degrees) tolerance_rad = math.radians(tolerance_degrees)
cleaned = [] cleaned = []
n = len(verts) n = len(verts)
for i in range(n): for i in range(n):
prev_v = verts[(i - 1) % n] prev_v = verts[(i - 1) % n]
curr_v = verts[i] curr_v = verts[i]
@ -72,52 +137,62 @@ def remove_collinear_verts(verts, tolerance_degrees=1.0):
edge_out = next_v - curr_v edge_out = next_v - curr_v
if edge_in.length < 1e-8 or edge_out.length < 1e-8: if edge_in.length < 1e-8 or edge_out.length < 1e-8:
# Degenerate (duplicate vert), skip continue # duplicate vertex
print(f" vert {i}: DEGENERATE (zero-length edge), removing")
continue
edge_in_n = edge_in.normalized() dot = max(-1.0, min(1.0, edge_in.normalized().dot(edge_out.normalized())))
edge_out_n = edge_out.normalized()
dot = max(-1.0, min(1.0, edge_in_n.dot(edge_out_n)))
angle = math.acos(dot) angle = math.acos(dot)
angle_deg = math.degrees(angle)
# Collinear if angle is near 0° (fold-back) or near 180° (mid-edge)
deviation = min(angle, abs(math.pi - angle)) deviation = min(angle, abs(math.pi - angle))
deviation_deg = math.degrees(deviation)
is_collinear = deviation < tolerance_rad if deviation < tolerance_rad:
continue # collinear
print(f" vert {i}: angle={angle_deg:.2f}°, deviation from straight={deviation_deg:.2f}°, collinear={is_collinear}")
if is_collinear:
continue
else: else:
cleaned.append(curr_v) cleaned.append(curr_v)
return cleaned return cleaned
def remove_duplicate_verts(verts, tolerance=1e-6):
"""Remove consecutive duplicate vertices."""
if len(verts) < 2:
return verts
cleaned = [verts[0]]
for i in range(1, len(verts)):
if (verts[i] - cleaned[-1]).length > tolerance:
cleaned.append(verts[i])
# Check last vs first
if len(cleaned) > 1 and (cleaned[-1] - cleaned[0]).length <= tolerance:
cleaned.pop()
return cleaned
# ─────────────────────────────────────────────
# Logging
# ─────────────────────────────────────────────
def log_object_verts(label, obj): def log_object_verts(label, obj):
local_verts = [v.co.copy() for v in obj.data.vertices] mesh = obj.data
world_verts = get_world_verts(obj) local_verts = [v.co.copy() for v in mesh.vertices]
world_verts = [obj.matrix_world @ v.co for v in mesh.vertices]
print(f"\n [{label}] '{obj.name}'") print(f"\n [{label}] '{obj.name}'")
print(f" Location: {obj.location}") print(f" Location: {obj.location}")
print(f" Rotation: {obj.rotation_euler}") print(f" Rotation: {obj.rotation_euler}")
print(f" Verts: {len(local_verts)} | Edges: {len(obj.data.edges)} | Faces: {len(obj.data.polygons)}") print(f" Verts: {len(local_verts)} | Edges: {len(mesh.edges)} | Faces: {len(mesh.polygons)}")
print(f" {'idx':<5} {'LOCAL (x, y, z)':<35} {'WORLD (x, y, z)':<35}") print(f" {'idx':<5} {'LOCAL (x, y, z)':<35} {'WORLD (x, y, z)':<35}")
print(f" {'---':<5} {'---------------':<35} {'---------------':<35}") print(f" {'---':<5} {'---------------':<35} {'---------------':<35}")
for i, (lv, wv) in enumerate(zip(local_verts, world_verts)): for i, (lv, wv) in enumerate(zip(local_verts, world_verts)):
print(f" {i:<5} ({lv.x:9.4f}, {lv.y:9.4f}, {lv.z:9.4f}) ({wv.x:9.4f}, {wv.y:9.4f}, {wv.z:9.4f})") print(f" {i:<5} ({lv.x:9.4f}, {lv.y:9.4f}, {lv.z:9.4f}) ({wv.x:9.4f}, {wv.y:9.4f}, {wv.z:9.4f})")
print(f" Faces:") print(f" Faces:")
for i, poly in enumerate(obj.data.polygons): for i, poly in enumerate(mesh.polygons):
print(f" face[{i}] verts: {list(poly.vertices)} normal: ({poly.normal.x:.4f}, {poly.normal.y:.4f}, {poly.normal.z:.4f})") print(f" face[{i}] verts: {list(poly.vertices)} normal: ({poly.normal.x:.4f}, {poly.normal.y:.4f}, {poly.normal.z:.4f})")
# ─────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────
def miter_planes(): def miter_planes():
print("\n" + "=" * 70) print("\n" + "=" * 70)
print("MITER PLANES — TRIM AT INTERSECTION") print("MITER PLANES — TRIM AT INTERSECTION (v11 — Sutherland-Hodgman)")
print("=" * 70) print("=" * 70)
# --- Validate selection --- # --- Validate selection ---
@ -138,182 +213,121 @@ def miter_planes():
log_object_verts("BEFORE", obj) log_object_verts("BEFORE", obj)
# ========================================================= # =========================================================
# STEP 1: Store original data + plane equations # STEP 1: Gather data + compute centroid
# ========================================================= # =========================================================
print(f"\n[STEP 1] Storing original data...") print(f"\n[STEP 1] Storing original data...")
orig_data = {} orig_data = {}
all_world_verts = []
for obj in selected: for obj in selected:
wv = get_world_verts(obj) wv_ordered = get_world_verts_ordered(obj)
plane_normal, plane_point = compute_plane(wv) all_world_verts.extend(wv_ordered)
plane_normal, plane_point = compute_plane(wv_ordered)
orig_data[obj.name] = { orig_data[obj.name] = {
'object': obj, 'object': obj,
'world_verts': wv, 'world_verts': wv_ordered,
'matrix_world': obj.matrix_world.copy(),
'matrix_world_inv': obj.matrix_world.inverted(), 'matrix_world_inv': obj.matrix_world.inverted(),
'plane_normal': plane_normal, 'plane_normal': plane_normal,
'plane_point': plane_point, 'plane_point': plane_point,
} }
if plane_normal: if plane_normal:
print(f" '{obj.name}' plane: normal=({plane_normal.x:.4f}, {plane_normal.y:.4f}, {plane_normal.z:.4f})") print(f" '{obj.name}' plane: normal=({plane_normal.x:.4f}, {plane_normal.y:.4f}, {plane_normal.z:.4f})")
print(f" '{obj.name}' polygon ({len(wv_ordered)} verts):")
for i, v in enumerate(wv_ordered):
print(f" {i}: ({v.x:.4f}, {v.y:.4f}, {v.z:.4f})")
centroid = sum(all_world_verts, Vector((0, 0, 0))) / len(all_world_verts)
print(f" Global centroid: ({centroid.x:.4f}, {centroid.y:.4f}, {centroid.z:.4f})")
# ========================================================= # =========================================================
# STEP 2: Duplicate, apply transforms, join, intersect # STEP 2: Clip each polygon by all other planes
# ========================================================= # =========================================================
print(f"\n[STEP 2] Duplicating, joining, intersecting...") print(f"\n[STEP 2] Clipping polygons...")
bpy.ops.object.select_all(action='DESELECT') clipped_polys = {}
duplicates = []
for obj in selected:
new_mesh = obj.data.copy()
new_obj = obj.copy()
new_obj.data = new_mesh
bpy.context.collection.objects.link(new_obj)
new_obj.name = f"_temp_{obj.name}"
duplicates.append(new_obj)
bpy.ops.object.select_all(action='DESELECT') for name_a, data_a in orig_data.items():
for dup in duplicates: polygon = list(data_a['world_verts'])
dup.select_set(True) print(f"\n Clipping '{name_a}' (starting with {len(polygon)} verts):")
bpy.context.view_layer.objects.active = duplicates[0]
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
bpy.ops.object.join() for name_b, data_b in orig_data.items():
temp_joined = bpy.context.active_object if name_b == name_a:
print(f" Joined → {len(temp_joined.data.vertices)} verts, {len(temp_joined.data.polygons)} faces") continue
bpy.ops.object.mode_set(mode='EDIT') pn = data_b['plane_normal']
bpy.ops.mesh.select_all(action='SELECT') pp = data_b['plane_point']
result = bpy.ops.mesh.intersect(mode='SELECT', separate_mode='ALL', threshold=1e-06)
print(f" intersect result: {result}")
# =========================================================
# STEP 3: Iterate faces in bmesh — assign and find largest
# =========================================================
print(f"\n[STEP 3] Analyzing faces...")
bm = bmesh.from_edit_mesh(temp_joined.data)
bm.verts.ensure_lookup_table()
bm.faces.ensure_lookup_table()
print(f" Total faces after intersect: {len(bm.faces)}")
face_data = {name: [] for name in orig_data}
for fi, face in enumerate(bm.faces):
face_verts_co = [v.co.copy() for v in face.verts]
area = bmesh_face_area(face)
matches = []
for name, data in orig_data.items():
pn, pp = data['plane_normal'], data['plane_point']
if pn is None: if pn is None:
continue continue
distances = [abs(distance_to_plane(v, pn, pp)) for v in face_verts_co]
max_dist = max(distances)
avg_dist = sum(distances) / len(distances)
is_coplanar = max_dist <= 0.001
print(f" face[{fi}] area={area:.6f} vs '{name}': max_dist={max_dist:.6f}, coplanar={is_coplanar}")
if is_coplanar:
matches.append((name, avg_dist))
if len(matches) == 1: before_count = len(polygon)
face_data[matches[0][0]].append((fi, area, face_verts_co)) polygon = clip_polygon_by_plane(polygon, pn, pp, centroid)
elif len(matches) > 1: polygon = remove_duplicate_verts(polygon)
best = min(matches, key=lambda x: x[1]) print(f" vs '{name_b}': {before_count}{len(polygon)} verts")
face_data[best[0]].append((fi, area, face_verts_co))
if len(polygon) < 3:
print(f" WARNING: polygon degenerate after clipping by '{name_b}'")
break
if len(polygon) >= 3:
print(f" '{name_a}' clipped polygon ({len(polygon)} verts):")
for i, v in enumerate(polygon):
print(f" {i}: ({v.x:.4f}, {v.y:.4f}, {v.z:.4f})")
clipped_polys[name_a] = polygon
else: else:
best_name = None print(f" '{name_a}': fully clipped away — skipping")
best_avg = float('inf')
for name, data in orig_data.items():
pn, pp = data['plane_normal'], data['plane_point']
if pn is None:
continue
avg = sum(abs(distance_to_plane(v, pn, pp)) for v in face_verts_co) / len(face_verts_co)
if avg < best_avg:
best_avg = avg
best_name = name
if best_name:
face_data[best_name].append((fi, area, face_verts_co))
# ========================================================= # =========================================================
# STEP 4: Keep only the largest face per original # STEP 3: Rebuild meshes
# ========================================================= # =========================================================
print(f"\n[STEP 4] Selecting largest face per original...") print(f"\n[STEP 3] Rebuilding original meshes...")
winning_faces = {}
for name, faces in face_data.items():
if not faces:
print(f" '{name}': no faces — skipping")
continue
print(f" '{name}' has {len(faces)} faces:")
for fi, area, verts in faces:
print(f" face[{fi}]: area={area:.6f}")
largest = max(faces, key=lambda x: x[1])
print(f" → keeping face[{largest[0]}] (area: {largest[1]:.6f})")
winning_faces[name] = largest[2]
bm.free()
bpy.ops.object.mode_set(mode='OBJECT')
# =========================================================
# STEP 5: Rebuild each original, removing collinear verts
# =========================================================
print(f"\n[STEP 5] Rebuilding original meshes...")
for name, data in orig_data.items(): for name, data in orig_data.items():
if name not in winning_faces: if name not in clipped_polys:
print(f" '{name}': no winning face — skipping") print(f" '{name}': no clipped polygon — skipping")
continue continue
orig_obj = data['object'] orig_obj = data['object']
mat_inv = data['matrix_world_inv'] mat_inv = data['matrix_world_inv']
world_verts = winning_faces[name] world_verts = clipped_polys[name]
local_verts = [mat_inv @ wv for wv in world_verts] local_verts = [mat_inv @ wv for wv in world_verts]
print(f"\n '{name}' — raw verts ({len(local_verts)}):") print(f"\n '{name}' — raw verts ({len(local_verts)}):")
for i, (wv, lv) in enumerate(zip(world_verts, local_verts)): for i, (wv, lv) in enumerate(zip(world_verts, local_verts)):
print(f" {i:<3} world: ({wv.x:9.4f}, {wv.y:9.4f}, {wv.z:9.4f}) local: ({lv.x:9.4f}, {lv.y:9.4f}, {lv.z:9.4f})") print(f" {i:<3} world: ({wv.x:9.4f}, {wv.y:9.4f}, {wv.z:9.4f}) local: ({lv.x:9.4f}, {lv.y:9.4f}, {lv.z:9.4f})")
# Remove collinear verts (catches both mid-edge and fold-back) # Clean up
print(f" Collinear check:") cleaned = remove_collinear_verts(local_verts, tolerance_degrees=1.0)
cleaned_local = remove_collinear_verts(local_verts, tolerance_degrees=1.0) cleaned = remove_duplicate_verts(cleaned)
removed = len(local_verts) - len(cleaned_local) removed = len(local_verts) - len(cleaned)
print(f" '{name}' — cleaned: {len(local_verts)}{len(cleaned_local)} verts (removed {removed})") print(f" '{name}' — cleaned: {len(local_verts)}{len(cleaned)} verts (removed {removed})")
for i, lv in enumerate(cleaned_local): for i, lv in enumerate(cleaned):
print(f" {i:<3} local: ({lv.x:9.4f}, {lv.y:9.4f}, {lv.z:9.4f})") print(f" {i:<3} local: ({lv.x:9.4f}, {lv.y:9.4f}, {lv.z:9.4f})")
if len(cleaned_local) < 3: if len(cleaned) < 3:
print(f" WARNING: fewer than 3 verts after cleanup — skipping") print(f" WARNING: fewer than 3 verts after cleanup — skipping")
continue continue
# Build mesh # Rebuild mesh
bm = bmesh.new() bm_out = bmesh.new()
bm_verts = [bm.verts.new(v) for v in cleaned_local] bm_verts = [bm_out.verts.new(v) for v in cleaned]
bm.verts.ensure_lookup_table() bm_out.verts.ensure_lookup_table()
try: try:
bm.faces.new(bm_verts) bm_out.faces.new(bm_verts)
print(f" Face created OK") print(f" Face created OK")
except ValueError as e: except ValueError as e:
print(f" Face creation FAILED: {e}") print(f" Face creation FAILED: {e}")
bm.free() bm_out.free()
continue continue
bm.to_mesh(orig_obj.data) bm_out.to_mesh(orig_obj.data)
bm.free() bm_out.free()
orig_obj.data.update() orig_obj.data.update()
print(f" '{name}': → {len(orig_obj.data.vertices)} verts, {len(orig_obj.data.edges)} edges, {len(orig_obj.data.polygons)} faces") print(f" '{name}': → {len(orig_obj.data.vertices)} verts, {len(orig_obj.data.edges)} edges, {len(orig_obj.data.polygons)} faces")
# ========================================================= # =========================================================
# STEP 6: Delete temp object # Reselect originals
# ========================================================= # =========================================================
print(f"\n[STEP 6] Cleaning up...")
bpy.ops.object.select_all(action='DESELECT')
temp_joined.select_set(True)
bpy.context.view_layer.objects.active = temp_joined
bpy.ops.object.delete()
bpy.ops.object.select_all(action='DESELECT') bpy.ops.object.select_all(action='DESELECT')
for name, data in orig_data.items(): for name, data in orig_data.items():
data['object'].select_set(True) data['object'].select_set(True)