diff --git a/Blender/addons/Plane Trimmer/__init__.py b/Blender/addons/Plane Trimmer/__init__.py new file mode 100644 index 0000000..e412bff --- /dev/null +++ b/Blender/addons/Plane Trimmer/__init__.py @@ -0,0 +1,346 @@ +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 +from mathutils import Vector +import math + + +# ───────────────────────────────────────────── +# Geometry helpers +# ───────────────────────────────────────────── + +def get_world_verts_ordered(obj): + """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 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 + 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 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, or empty list if fully clipped. + """ + if len(polygon_verts) < 3: + return polygon_verts + + keep_dist = distance_to_plane(keep_side_point, plane_normal, plane_point) + if abs(keep_dist) < 1e-9: + 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 + next_inside = next_dist * keep_dist >= -1e-9 + + if curr_inside: + clipped.append(curr) + if not next_inside: + denom = curr_dist - next_dist + if abs(denom) > 1e-12: + t = curr_dist / denom + clipped.append(curr.lerp(next_v, t)) + else: + if next_inside: + denom = curr_dist - next_dist + if abs(denom) > 1e-12: + t = curr_dist / denom + 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 (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] + 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: + 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 + cleaned.append(curr_v) + + return cleaned + + +# ───────────────────────────────────────────── +# Core miter logic +# ───────────────────────────────────────────── + +def miter_planes(selected_objects): + """ + Clip each selected plane object by the infinite planes of all others, + keeping the interior (centroid-side) portion. + + Returns (success: bool, message: str) + """ + # Gather data and compute centroid + orig_data = {} + all_world_verts = [] + + 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, + 'matrix_world_inv': obj.matrix_world.inverted(), + 'plane_normal': plane_normal, + 'plane_point': plane_point, + } + + centroid = sum(all_world_verts, Vector((0, 0, 0))) / len(all_world_verts) + + # 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']) + + for name_b, data_b in orig_data.items(): + if name_b == name_a: + continue + + polygon = clip_polygon_by_plane( + polygon, + data_b['plane_normal'], + data_b['plane_point'], + centroid, + ) + polygon = remove_duplicate_verts(polygon) + + if len(polygon) < 3: + break + + if len(polygon) >= 3: + clipped_polys[name_a] = polygon + + 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: + continue + + orig_obj = data['object'] + mat_inv = data['matrix_world_inv'] + world_verts = clipped_polys[name] + local_verts = [mat_inv @ wv for wv in world_verts] + + # Clean up + cleaned = remove_collinear_verts(local_verts, tolerance_degrees=1.0) + cleaned = remove_duplicate_verts(cleaned) + + if len(cleaned) < 3: + continue + + 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) + except ValueError: + bm_out.free() + continue + + bm_out.to_mesh(orig_obj.data) + bm_out.free() + orig_obj.data.update() + + count = len(clipped_polys) + return True, f"Mitered {count} plane{'s' if count != 1 else ''}" + + +# ───────────────────────────────────────────── +# 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 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