Plane_Trimmer #2

Merged
theoryshaw merged 3 commits from Plane_Trimmer into main 2026-02-12 15:12:06 +00:00
2 changed files with 340 additions and 0 deletions
Showing only changes of commit ab02d46b69 - Show all commits
Ryan Schultz 2026-02-11 12:57:54 -06:00

View file

@ -0,0 +1,338 @@
"""
Miter Planes Trim at Intersection
======================================
Select 2+ mesh objects in Object Mode, then run this script.
Each original is cut at all intersection lines, then only the
largest face is kept like a miter/trim tool for planes.
Collinear vertices (both mid-edge and fold-back) are removed.
Usage:
1. Select your plane objects in Object Mode
2. Run this script
"""
import bpy
import bmesh
from mathutils import Vector
import math
def get_world_verts(obj):
return [obj.matrix_world @ v.co for v in obj.data.vertices]
def compute_plane(world_verts):
if len(world_verts) < 3:
return None, None
v0, v1, v2 = world_verts[0], world_verts[1], world_verts[2]
normal = (v1 - v0).cross(v2 - v0)
if normal.length < 1e-8:
return None, None
normal.normalize()
return normal, v0
def distance_to_plane(point, plane_normal, plane_point):
return (point - plane_point).dot(plane_normal)
def bmesh_face_area(face):
verts = [v.co for v in face.verts]
if len(verts) < 3:
return 0.0
total = 0.0
v0 = verts[0]
for i in range(1, len(verts) - 1):
total += (verts[i] - v0).cross(verts[i + 1] - v0).length / 2.0
return total
def remove_collinear_verts(verts, tolerance_degrees=1.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:
return verts
tolerance_rad = math.radians(tolerance_degrees)
cleaned = []
n = len(verts)
for i in range(n):
prev_v = verts[(i - 1) % n]
curr_v = verts[i]
next_v = verts[(i + 1) % n]
edge_in = prev_v - curr_v
edge_out = next_v - curr_v
if edge_in.length < 1e-8 or edge_out.length < 1e-8:
# Degenerate (duplicate vert), skip
print(f" vert {i}: DEGENERATE (zero-length edge), removing")
continue
edge_in_n = edge_in.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_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_deg = math.degrees(deviation)
is_collinear = deviation < tolerance_rad
print(f" vert {i}: angle={angle_deg:.2f}°, deviation from straight={deviation_deg:.2f}°, collinear={is_collinear}")
if is_collinear:
continue
else:
cleaned.append(curr_v)
return cleaned
def log_object_verts(label, obj):
local_verts = [v.co.copy() for v in obj.data.vertices]
world_verts = get_world_verts(obj)
print(f"\n [{label}] '{obj.name}'")
print(f" Location: {obj.location}")
print(f" Rotation: {obj.rotation_euler}")
print(f" Verts: {len(local_verts)} | Edges: {len(obj.data.edges)} | Faces: {len(obj.data.polygons)}")
print(f" {'idx':<5} {'LOCAL (x, y, z)':<35} {'WORLD (x, y, z)':<35}")
print(f" {'---':<5} {'---------------':<35} {'---------------':<35}")
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" Faces:")
for i, poly in enumerate(obj.data.polygons):
print(f" face[{i}] verts: {list(poly.vertices)} normal: ({poly.normal.x:.4f}, {poly.normal.y:.4f}, {poly.normal.z:.4f})")
def miter_planes():
print("\n" + "=" * 70)
print("MITER PLANES — TRIM AT INTERSECTION")
print("=" * 70)
# --- Validate selection ---
selected = [obj for obj in bpy.context.selected_objects if obj.type == 'MESH']
if len(selected) < 2:
print("ERROR: Please select at least 2 mesh objects.")
return {'CANCELLED'}
print(f"Processing {len(selected)} objects: {[o.name for o in selected]}")
# =========================================================
# LOG BEFORE STATE
# =========================================================
print(f"\n{'='*70}")
print(f"BEFORE — Original objects ({len(selected)})")
print(f"{'='*70}")
for obj in selected:
log_object_verts("BEFORE", obj)
# =========================================================
# STEP 1: Store original data + plane equations
# =========================================================
print(f"\n[STEP 1] Storing original data...")
orig_data = {}
for obj in selected:
wv = get_world_verts(obj)
plane_normal, plane_point = compute_plane(wv)
orig_data[obj.name] = {
'object': obj,
'world_verts': wv,
'matrix_world': obj.matrix_world.copy(),
'matrix_world_inv': obj.matrix_world.inverted(),
'plane_normal': plane_normal,
'plane_point': plane_point,
}
if plane_normal:
print(f" '{obj.name}' plane: normal=({plane_normal.x:.4f}, {plane_normal.y:.4f}, {plane_normal.z:.4f})")
# =========================================================
# STEP 2: Duplicate, apply transforms, join, intersect
# =========================================================
print(f"\n[STEP 2] Duplicating, joining, intersecting...")
bpy.ops.object.select_all(action='DESELECT')
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 dup in duplicates:
dup.select_set(True)
bpy.context.view_layer.objects.active = duplicates[0]
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
bpy.ops.object.join()
temp_joined = bpy.context.active_object
print(f" Joined → {len(temp_joined.data.vertices)} verts, {len(temp_joined.data.polygons)} faces")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
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:
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:
face_data[matches[0][0]].append((fi, area, face_verts_co))
elif len(matches) > 1:
best = min(matches, key=lambda x: x[1])
face_data[best[0]].append((fi, area, face_verts_co))
else:
best_name = None
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
# =========================================================
print(f"\n[STEP 4] Selecting largest face per original...")
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():
if name not in winning_faces:
print(f" '{name}': no winning face — skipping")
continue
orig_obj = data['object']
mat_inv = data['matrix_world_inv']
world_verts = winning_faces[name]
local_verts = [mat_inv @ wv for wv in world_verts]
print(f"\n '{name}' — raw verts ({len(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})")
# Remove collinear verts (catches both mid-edge and fold-back)
print(f" Collinear check:")
cleaned_local = remove_collinear_verts(local_verts, tolerance_degrees=1.0)
removed = len(local_verts) - len(cleaned_local)
print(f" '{name}' — cleaned: {len(local_verts)}{len(cleaned_local)} verts (removed {removed})")
for i, lv in enumerate(cleaned_local):
print(f" {i:<3} local: ({lv.x:9.4f}, {lv.y:9.4f}, {lv.z:9.4f})")
if len(cleaned_local) < 3:
print(f" WARNING: fewer than 3 verts after cleanup — skipping")
continue
# Build mesh
bm = bmesh.new()
bm_verts = [bm.verts.new(v) for v in cleaned_local]
bm.verts.ensure_lookup_table()
try:
bm.faces.new(bm_verts)
print(f" Face created OK")
except ValueError as e:
print(f" Face creation FAILED: {e}")
bm.free()
continue
bm.to_mesh(orig_obj.data)
bm.free()
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")
# =========================================================
# STEP 6: Delete temp object
# =========================================================
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')
for name, data in orig_data.items():
data['object'].select_set(True)
bpy.context.view_layer.objects.active = selected[0]
# =========================================================
# LOG AFTER STATE
# =========================================================
print(f"\n{'='*70}")
print(f"AFTER — Original objects ({len(selected)})")
print(f"{'='*70}")
for obj in selected:
log_object_verts("AFTER", obj)
print(f"\n{'='*70}")
print(f"DONE! Mitered {len(selected)} objects.")
print(f"{'='*70}\n")
return {'FINISHED'}
# --- Run ---
miter_planes()

View file

@ -0,0 +1,2 @@
[InternetShortcut]
URL=https://claude.ai/chat/90a80236-330f-4b70-bd95-1a07278542ab