Plane_Trimmer #2
1 changed files with 159 additions and 165 deletions
plugin working
commit
5a32cbe951
|
|
@ -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()
|
||||
# ─────────────────────────────────────────────
|
||||
# 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue