Plane_Trimmer #2
2 changed files with 348 additions and 0 deletions
346
Blender/addons/Plane Trimmer/__init__.py
Normal file
346
Blender/addons/Plane Trimmer/__init__.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
[InternetShortcut]
|
||||||
|
URL=https://claude.ai/chat/90a80236-330f-4b70-bd95-1a07278542ab
|
||||||
Loading…
Reference in a new issue