From ab02d46b69f040eca8308022e438f029619b6122 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 11 Feb 2026 12:57:54 -0600 Subject: [PATCH 1/3] 1st --- Blender/addons/Plane Trimmer/__init__.py | 338 ++++++++++++++++++ ...splitting intersecting planes - Claude.url | 2 + 2 files changed, 340 insertions(+) create mode 100644 Blender/addons/Plane Trimmer/__init__.py create mode 100644 Blender/addons/Plane Trimmer/claude.ai_chat-90a80236-330f-4b70-bd95-1a07278542ab_Blender tool for splitting intersecting planes - Claude.url diff --git a/Blender/addons/Plane Trimmer/__init__.py b/Blender/addons/Plane Trimmer/__init__.py new file mode 100644 index 0000000..2e3b318 --- /dev/null +++ b/Blender/addons/Plane Trimmer/__init__.py @@ -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() \ No newline at end of file diff --git a/Blender/addons/Plane Trimmer/claude.ai_chat-90a80236-330f-4b70-bd95-1a07278542ab_Blender tool for splitting intersecting planes - Claude.url b/Blender/addons/Plane Trimmer/claude.ai_chat-90a80236-330f-4b70-bd95-1a07278542ab_Blender tool for splitting intersecting planes - Claude.url new file mode 100644 index 0000000..5cb49e9 --- /dev/null +++ b/Blender/addons/Plane Trimmer/claude.ai_chat-90a80236-330f-4b70-bd95-1a07278542ab_Blender tool for splitting intersecting planes - Claude.url @@ -0,0 +1,2 @@ +[InternetShortcut] +URL=https://claude.ai/chat/90a80236-330f-4b70-bd95-1a07278542ab -- 2.34.1 From 3bcf1010831a1a0c4fa84c54ad71271f4c6643f2 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 11 Feb 2026 15:59:18 -0600 Subject: [PATCH 2/3] v11 --- Blender/addons/Plane Trimmer/__init__.py | 360 ++++++++++++----------- 1 file changed, 187 insertions(+), 173 deletions(-) diff --git a/Blender/addons/Plane Trimmer/__init__.py b/Blender/addons/Plane Trimmer/__init__.py index 2e3b318..04ffc4c 100644 --- a/Blender/addons/Plane Trimmer/__init__.py +++ b/Blender/addons/Plane Trimmer/__init__.py @@ -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. -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. +Each plane's polygon is clipped by every other plane's infinite plane, +keeping the side toward the global centroid. Uses Sutherland-Hodgman +polygon clipping — no mesh.intersect, no temp objects, no face analysis. Usage: 1. Select your plane objects in Object Mode @@ -18,51 +18,116 @@ from mathutils import Vector 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): + """Compute plane normal and point from vertices.""" 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 + for i in range(len(world_verts)): + for j in range(i + 1, len(world_verts)): + for k in range(j + 1, len(world_verts)): + normal = (world_verts[j] - world_verts[i]).cross(world_verts[k] - world_verts[i]) + if normal.length > 1e-8: + normal.normalize() + return normal, world_verts[i] + return None, None def distance_to_plane(point, plane_normal, plane_point): + """Signed distance from point to plane.""" 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 clip_polygon_by_plane(polygon_verts, plane_normal, plane_point, keep_side_point): + """ + Sutherland-Hodgman: clip polygon, keeping the side that contains keep_side_point. + Returns clipped polygon vertices (list of Vector), or empty list if fully clipped away. + """ + if len(polygon_verts) < 3: + return polygon_verts + + # 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): - """ - 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. - """ + """Remove collinear vertices — catches both mid-edge (≈180°) and fold-back (≈0°).""" 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] @@ -72,52 +137,62 @@ def remove_collinear_verts(verts, tolerance_degrees=1.0): 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 + continue # duplicate vertex - 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))) + dot = max(-1.0, min(1.0, edge_in.normalized().dot(edge_out.normalized()))) 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 + if deviation < tolerance_rad: + continue # collinear else: cleaned.append(curr_v) 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): - local_verts = [v.co.copy() for v in obj.data.vertices] - world_verts = get_world_verts(obj) + mesh = obj.data + 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" 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" 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" {'---':<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): + 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})") +# ───────────────────────────────────────────── +# Main +# ───────────────────────────────────────────── + def miter_planes(): print("\n" + "=" * 70) - print("MITER PLANES — TRIM AT INTERSECTION") + print("MITER PLANES — TRIM AT INTERSECTION (v11 — Sutherland-Hodgman)") print("=" * 70) # --- Validate selection --- @@ -138,182 +213,121 @@ def miter_planes(): 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...") orig_data = {} + all_world_verts = [] + for obj in selected: - wv = get_world_verts(obj) - plane_normal, plane_point = compute_plane(wv) + wv_ordered = get_world_verts_ordered(obj) + all_world_verts.extend(wv_ordered) + plane_normal, plane_point = compute_plane(wv_ordered) orig_data[obj.name] = { 'object': obj, - 'world_verts': wv, - 'matrix_world': obj.matrix_world.copy(), + 'world_verts': wv_ordered, '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})") + 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...") - 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) + print(f"\n[STEP 2] Clipping polygons...") + clipped_polys = {} - 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) + for name_a, data_a in orig_data.items(): + polygon = list(data_a['world_verts']) + print(f"\n Clipping '{name_a}' (starting with {len(polygon)} verts):") - 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") + for name_b, data_b in orig_data.items(): + if name_b == name_a: + continue - 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'] + pn = data_b['plane_normal'] + pp = data_b['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)) + before_count = len(polygon) + polygon = clip_polygon_by_plane(polygon, pn, pp, centroid) + polygon = remove_duplicate_verts(polygon) + print(f" vs '{name_b}': {before_count} → {len(polygon)} verts") + + 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: - 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)) + print(f" '{name_a}': fully clipped away — skipping") # ========================================================= - # STEP 4: Keep only the largest face per original + # STEP 3: Rebuild meshes # ========================================================= - 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...") + print(f"\n[STEP 3] Rebuilding original meshes...") for name, data in orig_data.items(): - if name not in winning_faces: - print(f" '{name}': no winning face — skipping") + if name not in clipped_polys: + print(f" '{name}': no clipped polygon — skipping") continue orig_obj = data['object'] 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] 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) + # Clean up + cleaned = remove_collinear_verts(local_verts, tolerance_degrees=1.0) + cleaned = remove_duplicate_verts(cleaned) - 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): + removed = len(local_verts) - len(cleaned) + print(f" '{name}' — cleaned: {len(local_verts)} → {len(cleaned)} verts (removed {removed})") + for i, lv in enumerate(cleaned): 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") continue - # Build mesh - bm = bmesh.new() - bm_verts = [bm.verts.new(v) for v in cleaned_local] - bm.verts.ensure_lookup_table() + # Rebuild mesh + bm_out = bmesh.new() + bm_verts = [bm_out.verts.new(v) for v in cleaned] + bm_out.verts.ensure_lookup_table() try: - bm.faces.new(bm_verts) + bm_out.faces.new(bm_verts) print(f" Face created OK") except ValueError as e: print(f" Face creation FAILED: {e}") - bm.free() + bm_out.free() continue - bm.to_mesh(orig_obj.data) - bm.free() + bm_out.to_mesh(orig_obj.data) + bm_out.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 + # 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') for name, data in orig_data.items(): data['object'].select_set(True) -- 2.34.1 From 5a32cbe951d48ae310ae08faf104f0d7c862a0f6 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 12 Feb 2026 08:26:28 -0600 Subject: [PATCH 3/3] plugin working --- Blender/addons/Plane Trimmer/__init__.py | 322 +++++++++++------------ 1 file changed, 158 insertions(+), 164 deletions(-) diff --git a/Blender/addons/Plane Trimmer/__init__.py b/Blender/addons/Plane Trimmer/__init__.py index 04ffc4c..e412bff 100644 --- a/Blender/addons/Plane Trimmer/__init__.py +++ b/Blender/addons/Plane Trimmer/__init__.py @@ -1,16 +1,12 @@ -""" -Miter Planes — Trim at Intersection (v11) -============================================ -Select 2+ mesh objects in Object Mode, then run this script. - -Each plane's polygon is clipped by every other plane's infinite plane, -keeping the side toward the global centroid. Uses Sutherland-Hodgman -polygon clipping — no mesh.intersect, no temp objects, no face analysis. - -Usage: - 1. Select your plane objects in Object Mode - 2. Run this script -""" +bl_info = { + "name": "Miter Planes", + "author": "Claude", + "version": (1, 0, 0), + "blender": (3, 0, 0), + "location": "View3D > Object > Miter Planes, or Sidebar > Miter tab", + "description": "Trim selected plane objects at their mutual intersections, keeping interior portions", + "category": "Mesh", +} import bpy import bmesh @@ -23,12 +19,11 @@ import math # ───────────────────────────────────────────── def get_world_verts_ordered(obj): - """Get world-space vertices in face winding order (polygon[0]).""" + """Get world-space vertices in face winding order.""" 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) @@ -59,7 +54,9 @@ def compute_plane(world_verts): for i in range(len(world_verts)): for j in range(i + 1, len(world_verts)): for k in range(j + 1, len(world_verts)): - normal = (world_verts[j] - world_verts[i]).cross(world_verts[k] - world_verts[i]) + normal = (world_verts[j] - world_verts[i]).cross( + world_verts[k] - world_verts[i] + ) if normal.length > 1e-8: normal.normalize() return normal, world_verts[i] @@ -74,15 +71,13 @@ def distance_to_plane(point, plane_normal, plane_point): def clip_polygon_by_plane(polygon_verts, plane_normal, plane_point, keep_side_point): """ Sutherland-Hodgman: clip polygon, keeping the side that contains keep_side_point. - Returns clipped polygon vertices (list of Vector), or empty list if fully clipped away. + Returns clipped polygon vertices, or empty list if fully clipped. """ if len(polygon_verts) < 3: return polygon_verts - # 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 = [] @@ -95,32 +90,41 @@ def clip_polygon_by_plane(polygon_verts, plane_normal, plane_point, keep_side_po 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) + curr_inside = curr_dist * keep_dist >= -1e-9 + 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) + clipped.append(curr.lerp(next_v, t)) 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) + clipped.append(curr.lerp(next_v, t)) return clipped +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]) + if len(cleaned) > 1 and (cleaned[-1] - cleaned[0]).length <= tolerance: + cleaned.pop() + return cleaned + + def remove_collinear_verts(verts, tolerance_degrees=1.0): - """Remove collinear vertices — catches both mid-edge (≈180°) and fold-back (≈0°).""" + """Remove collinear vertices (mid-edge ≈180° and fold-back ≈0°).""" if len(verts) < 3: return verts @@ -137,92 +141,42 @@ def remove_collinear_verts(verts, tolerance_degrees=1.0): edge_out = next_v - curr_v if edge_in.length < 1e-8 or edge_out.length < 1e-8: - continue # duplicate vertex + continue dot = max(-1.0, min(1.0, edge_in.normalized().dot(edge_out.normalized()))) angle = math.acos(dot) deviation = min(angle, abs(math.pi - angle)) if deviation < tolerance_rad: - continue # collinear - else: - cleaned.append(curr_v) + continue + cleaned.append(curr_v) 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 +# Core miter logic # ───────────────────────────────────────────── -def log_object_verts(label, obj): - mesh = obj.data - 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" Location: {obj.location}") - print(f" Rotation: {obj.rotation_euler}") - 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" {'---':<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(mesh.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(selected_objects): + """ + Clip each selected plane object by the infinite planes of all others, + keeping the interior (centroid-side) portion. - -# ───────────────────────────────────────────── -# Main -# ───────────────────────────────────────────── - -def miter_planes(): - print("\n" + "=" * 70) - print("MITER PLANES — TRIM AT INTERSECTION (v11 — Sutherland-Hodgman)") - 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: Gather data + compute centroid - # ========================================================= - print(f"\n[STEP 1] Storing original data...") + Returns (success: bool, message: str) + """ + # Gather data and compute centroid orig_data = {} all_world_verts = [] - for obj in selected: + for obj in selected_objects: wv_ordered = get_world_verts_ordered(obj) all_world_verts.extend(wv_ordered) plane_normal, plane_point = compute_plane(wv_ordered) + + if plane_normal is None: + return False, f"Could not compute plane for '{obj.name}'" + orig_data[obj.name] = { 'object': obj, 'world_verts': wv_ordered, @@ -230,59 +184,38 @@ def miter_planes(): '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})") - 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: Clip each polygon by all other planes - # ========================================================= - print(f"\n[STEP 2] Clipping polygons...") + # Clip each polygon by all other planes (Sutherland-Hodgman) clipped_polys = {} - for name_a, data_a in orig_data.items(): polygon = list(data_a['world_verts']) - print(f"\n Clipping '{name_a}' (starting with {len(polygon)} verts):") for name_b, data_b in orig_data.items(): if name_b == name_a: continue - pn = data_b['plane_normal'] - pp = data_b['plane_point'] - if pn is None: - continue - - before_count = len(polygon) - polygon = clip_polygon_by_plane(polygon, pn, pp, centroid) + polygon = clip_polygon_by_plane( + polygon, + data_b['plane_normal'], + data_b['plane_point'], + centroid, + ) polygon = remove_duplicate_verts(polygon) - print(f" vs '{name_b}': {before_count} → {len(polygon)} verts") 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: - print(f" '{name_a}': fully clipped away — skipping") - # ========================================================= - # STEP 3: Rebuild meshes - # ========================================================= - print(f"\n[STEP 3] Rebuilding original meshes...") + if not clipped_polys: + return False, "All polygons were fully clipped — check object positions" + # Rebuild meshes for name, data in orig_data.items(): if name not in clipped_polys: - print(f" '{name}': no clipped polygon — skipping") continue orig_obj = data['object'] @@ -290,32 +223,19 @@ def miter_planes(): world_verts = clipped_polys[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})") - # Clean up cleaned = remove_collinear_verts(local_verts, tolerance_degrees=1.0) cleaned = remove_duplicate_verts(cleaned) - removed = len(local_verts) - len(cleaned) - print(f" '{name}' — cleaned: {len(local_verts)} → {len(cleaned)} verts (removed {removed})") - for i, lv in enumerate(cleaned): - print(f" {i:<3} local: ({lv.x:9.4f}, {lv.y:9.4f}, {lv.z:9.4f})") - if len(cleaned) < 3: - print(f" WARNING: fewer than 3 verts after cleanup — skipping") continue - # Rebuild mesh bm_out = bmesh.new() bm_verts = [bm_out.verts.new(v) for v in cleaned] bm_out.verts.ensure_lookup_table() try: bm_out.faces.new(bm_verts) - print(f" Face created OK") - except ValueError as e: - print(f" Face creation FAILED: {e}") + except ValueError: bm_out.free() continue @@ -323,30 +243,104 @@ def miter_planes(): bm_out.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") - - # ========================================================= - # Reselect originals - # ========================================================= - 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'} + count = len(clipped_polys) + return True, f"Mitered {count} plane{'s' if count != 1 else ''}" -# --- Run --- -miter_planes() \ No newline at end of file +# ───────────────────────────────────────────── +# Blender Operator +# ───────────────────────────────────────────── + +class MESH_OT_miter_planes(bpy.types.Operator): + """Trim selected planes at their mutual intersections""" + bl_idname = "mesh.miter_planes" + bl_label = "Miter Planes" + bl_description = "Clip each selected plane by all other selected planes, keeping the interior portion" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + if context.mode != 'OBJECT': + return False + mesh_objects = [o for o in context.selected_objects if o.type == 'MESH'] + return len(mesh_objects) >= 2 + + def execute(self, context): + selected = [o for o in context.selected_objects if o.type == 'MESH'] + success, message = miter_planes(selected) + + if success: + self.report({'INFO'}, message) + return {'FINISHED'} + else: + self.report({'ERROR'}, message) + return {'CANCELLED'} + + +# ───────────────────────────────────────────── +# UI Panel (Sidebar → Miter tab) +# ───────────────────────────────────────────── + +class VIEW3D_PT_miter_planes(bpy.types.Panel): + """Miter Planes panel in the 3D Viewport sidebar""" + bl_label = "Miter Planes" + bl_idname = "VIEW3D_PT_miter_planes" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "Miter" + + def draw(self, context): + layout = self.layout + + mesh_objects = [o for o in context.selected_objects if o.type == 'MESH'] + count = len(mesh_objects) + + col = layout.column(align=True) + + if context.mode != 'OBJECT': + col.label(text="Switch to Object Mode", icon='ERROR') + elif count < 2: + col.label(text=f"Select 2+ plane objects", icon='INFO') + col.label(text=f" ({count} selected)") + else: + col.label(text=f"{count} planes selected", icon='MESH_PLANE') + + col.separator() + row = col.row() + row.scale_y = 1.5 + row.operator("mesh.miter_planes", icon='MOD_BEVEL') + + +# ───────────────────────────────────────────── +# Menu integration +# ───────────────────────────────────────────── + +def menu_func(self, context): + self.layout.separator() + self.layout.operator(MESH_OT_miter_planes.bl_idname, icon='MOD_BEVEL') + + +# ───────────────────────────────────────────── +# Registration +# ───────────────────────────────────────────── + +classes = ( + MESH_OT_miter_planes, + VIEW3D_PT_miter_planes, +) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.VIEW3D_MT_object.append(menu_func) + + +def unregister(): + bpy.types.VIEW3D_MT_object.remove(menu_func) + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + + +if __name__ == "__main__": + register() \ No newline at end of file -- 2.34.1