mesh_outline_projection #1
2 changed files with 461 additions and 0 deletions
459
Blender/addons/Mesh Outline Projector/__init__.py
Normal file
459
Blender/addons/Mesh Outline Projector/__init__.py
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
bl_info = {
|
||||
"name": "Mesh Outline Projector",
|
||||
"author": "Claude",
|
||||
"version": (1, 0, 0),
|
||||
"blender": (3, 0, 0),
|
||||
"location": "View3D > Sidebar > Edit Tab",
|
||||
"description": "Projects outline of selected objects onto active mesh with material transfer",
|
||||
"category": "Mesh",
|
||||
}
|
||||
|
||||
import bpy
|
||||
import bmesh
|
||||
from mathutils import Vector
|
||||
from mathutils.bvhtree import BVHTree
|
||||
|
||||
|
||||
def get_object_outline_edges_2d(obj, context):
|
||||
"""Get the outline edges of an object when viewed from above (Z-axis)"""
|
||||
print(f"\n=== Getting outline edges for: {obj.name} ===")
|
||||
|
||||
# Show object location and rotation
|
||||
print(f" Object transform:")
|
||||
print(f" Location: ({obj.location.x:.2f}, {obj.location.y:.2f}, {obj.location.z:.2f})")
|
||||
print(f" Rotation: ({obj.rotation_euler.x:.2f}, {obj.rotation_euler.y:.2f}, {obj.rotation_euler.z:.2f})")
|
||||
print(f" Scale: ({obj.scale.x:.2f}, {obj.scale.y:.2f}, {obj.scale.z:.2f})")
|
||||
|
||||
# Show source object's actual world-space bounding box
|
||||
bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
||||
bbox_min_x = min(v.x for v in bbox_corners)
|
||||
bbox_max_x = max(v.x for v in bbox_corners)
|
||||
bbox_min_y = min(v.y for v in bbox_corners)
|
||||
bbox_max_y = max(v.y for v in bbox_corners)
|
||||
bbox_min_z = min(v.z for v in bbox_corners)
|
||||
bbox_max_z = max(v.z for v in bbox_corners)
|
||||
print(f" Object bounding box (world space):")
|
||||
print(f" X: [{bbox_min_x:.2f}, {bbox_max_x:.2f}]")
|
||||
print(f" Y: [{bbox_min_y:.2f}, {bbox_max_y:.2f}]")
|
||||
print(f" Z: [{bbox_min_z:.2f}, {bbox_max_z:.2f}]")
|
||||
|
||||
# Show local space bounds for comparison
|
||||
local_min_x = min(v[0] for v in obj.bound_box)
|
||||
local_max_x = max(v[0] for v in obj.bound_box)
|
||||
local_min_y = min(v[1] for v in obj.bound_box)
|
||||
local_max_y = max(v[1] for v in obj.bound_box)
|
||||
print(f" Object bounding box (local space):")
|
||||
print(f" X: [{local_min_x:.2f}, {local_max_x:.2f}]")
|
||||
print(f" Y: [{local_min_y:.2f}, {local_max_y:.2f}]")
|
||||
|
||||
# Create a temporary bmesh from the object
|
||||
depsgraph = context.evaluated_depsgraph_get()
|
||||
eval_obj = obj.evaluated_get(depsgraph)
|
||||
mesh = eval_obj.to_mesh()
|
||||
|
||||
print(f" Mesh has {len(mesh.vertices)} vertices, {len(mesh.edges)} edges, {len(mesh.polygons)} faces")
|
||||
|
||||
# Show first few vertex positions in local space
|
||||
print(f" First 4 vertices (local space):")
|
||||
for i, v in enumerate(mesh.vertices[:4]):
|
||||
print(f" Vertex {i}: ({v.co.x:.2f}, {v.co.y:.2f}, {v.co.z:.2f})")
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
|
||||
# Show vertices BEFORE world transform
|
||||
print(f" First 4 BMesh vertices (before world transform):")
|
||||
for i, v in enumerate(list(bm.verts)[:4]):
|
||||
print(f" Vertex {i}: ({v.co.x:.2f}, {v.co.y:.2f}, {v.co.z:.2f})")
|
||||
|
||||
bm.transform(obj.matrix_world)
|
||||
|
||||
# Show vertices AFTER world transform
|
||||
print(f" First 4 BMesh vertices (after world transform):")
|
||||
for i, v in enumerate(list(bm.verts)[:4]):
|
||||
print(f" Vertex {i}: ({v.co.x:.2f}, {v.co.y:.2f}, {v.co.z:.2f})")
|
||||
|
||||
print(f" BMesh has {len(bm.verts)} verts, {len(bm.edges)} edges, {len(bm.faces)} faces")
|
||||
|
||||
# Strategy: Find edges that form the outline when viewed from above
|
||||
# We'll use multiple methods to catch all outline edges
|
||||
outline_edges = []
|
||||
boundary_count = 0
|
||||
silhouette_count = 0
|
||||
perimeter_count = 0
|
||||
|
||||
for edge in bm.edges:
|
||||
is_outline = False
|
||||
|
||||
# Method 1: Boundary edges (only one face)
|
||||
if len(edge.link_faces) == 1:
|
||||
is_outline = True
|
||||
boundary_count += 1
|
||||
|
||||
# Method 2: Silhouette edges (more lenient check)
|
||||
elif len(edge.link_faces) == 2:
|
||||
face1 = edge.link_faces[0]
|
||||
face2 = edge.link_faces[1]
|
||||
|
||||
# Get Z component of normals
|
||||
n1_z = face1.normal.z
|
||||
n2_z = face2.normal.z
|
||||
|
||||
# Threshold for considering a face as horizontal
|
||||
horizontal_threshold = 0.1
|
||||
|
||||
# Edge is outline if:
|
||||
# 1. One face points up/down and the other is more horizontal
|
||||
# 2. Faces point in significantly different Z directions
|
||||
if abs(n1_z) > horizontal_threshold or abs(n2_z) > horizontal_threshold:
|
||||
# At least one face has a vertical component
|
||||
if (n1_z > horizontal_threshold and n2_z < horizontal_threshold) or \
|
||||
(n1_z < -horizontal_threshold and n2_z > -horizontal_threshold) or \
|
||||
(n1_z < horizontal_threshold and n2_z > horizontal_threshold) or \
|
||||
(n1_z > -horizontal_threshold and n2_z < -horizontal_threshold):
|
||||
is_outline = True
|
||||
silhouette_count += 1
|
||||
|
||||
# Method 3: For objects with no clear silhouette (like cubes),
|
||||
# detect edges that are on the perimeter when viewed from above
|
||||
# An edge is on the perimeter if its vertices are at the extremes in X or Y
|
||||
if not is_outline and len(edge.link_faces) >= 1:
|
||||
v1 = edge.verts[0].co
|
||||
v2 = edge.verts[1].co
|
||||
|
||||
# Check if edge is vertical (both verts have similar X,Y but different Z)
|
||||
xy_dist = ((v1.x - v2.x)**2 + (v1.y - v2.y)**2)**0.5
|
||||
z_dist = abs(v1.z - v2.z)
|
||||
|
||||
# If it's a vertical edge, check if both verts are on the perimeter
|
||||
if xy_dist < 0.001 and z_dist > 0.001:
|
||||
# For each vert, check if any edge connected to it goes outward in XY
|
||||
for vert in edge.verts:
|
||||
# Check if this vertex is on the outer boundary
|
||||
connected_edges = vert.link_edges
|
||||
has_outward_edge = False
|
||||
|
||||
for other_edge in connected_edges:
|
||||
if other_edge != edge:
|
||||
other_vert = other_edge.other_vert(vert)
|
||||
# Check if this edge goes outward in XY plane
|
||||
dx = other_vert.co.x - vert.co.x
|
||||
dy = other_vert.co.y - vert.co.y
|
||||
if abs(dx) > 0.001 or abs(dy) > 0.001:
|
||||
has_outward_edge = True
|
||||
break
|
||||
|
||||
if has_outward_edge:
|
||||
is_outline = True
|
||||
perimeter_count += 1
|
||||
break
|
||||
|
||||
if is_outline:
|
||||
v1 = edge.verts[0].co
|
||||
v2 = edge.verts[1].co
|
||||
outline_edges.append((v1.copy(), v2.copy()))
|
||||
|
||||
print(f" Found {boundary_count} boundary edges, {silhouette_count} silhouette edges, {perimeter_count} perimeter edges")
|
||||
print(f" Total outline edges: {len(outline_edges)}")
|
||||
|
||||
# If still no edges found, use ALL edges as a fallback
|
||||
if len(outline_edges) == 0:
|
||||
print(f" WARNING: No outline detected with normal methods, using ALL edges as fallback")
|
||||
for edge in bm.edges:
|
||||
v1 = edge.verts[0].co
|
||||
v2 = edge.verts[1].co
|
||||
outline_edges.append((v1.copy(), v2.copy()))
|
||||
print(f" Fallback: Using all {len(outline_edges)} edges")
|
||||
|
||||
bm.free()
|
||||
eval_obj.to_mesh_clear()
|
||||
|
||||
return outline_edges
|
||||
|
||||
|
||||
# Curve creation function removed - now using direct bisect planes instead of knife_project
|
||||
|
||||
# View manipulation functions removed - now using direct bisect projection
|
||||
|
||||
def project_outline_to_mesh(context, source_obj, target_obj, outline_edges):
|
||||
"""Project outline edges vertically (Z-axis) onto target mesh using direct cutting"""
|
||||
|
||||
print(f"\n=== Projecting outline from {source_obj.name} to {target_obj.name} ===")
|
||||
|
||||
# Check target object transform
|
||||
print(f" Target object transform:")
|
||||
print(f" Location: ({target_obj.location.x:.2f}, {target_obj.location.y:.2f}, {target_obj.location.z:.2f})")
|
||||
print(f" Rotation: ({target_obj.rotation_euler.x:.2f}, {target_obj.rotation_euler.y:.2f}, {target_obj.rotation_euler.z:.2f})")
|
||||
print(f" Scale: ({target_obj.scale.x:.2f}, {target_obj.scale.y:.2f}, {target_obj.scale.z:.2f})")
|
||||
|
||||
# Check if target has parent
|
||||
if target_obj.parent:
|
||||
print(f" ⚠ WARNING: Target has parent object: {target_obj.parent.name}")
|
||||
print(f" Parent location: ({target_obj.parent.location.x:.2f}, {target_obj.parent.location.y:.2f}, {target_obj.parent.location.z:.2f})")
|
||||
|
||||
# Get or add material to target
|
||||
source_material = None
|
||||
if len(source_obj.material_slots) > 0:
|
||||
source_material = source_obj.material_slots[0].material
|
||||
print(f" Source material: {source_material.name if source_material else 'None'}")
|
||||
print(f" Source object has {len(source_obj.material_slots)} material slot(s)")
|
||||
else:
|
||||
print(f" WARNING: Source object has no materials!")
|
||||
|
||||
material_index = -1
|
||||
if source_material:
|
||||
print(f" Target object currently has {len(target_obj.material_slots)} material slot(s)")
|
||||
|
||||
# Check if material already exists on target
|
||||
for i, slot in enumerate(target_obj.material_slots):
|
||||
print(f" Slot {i}: {slot.material.name if slot.material else 'Empty'}")
|
||||
if slot.material == source_material:
|
||||
material_index = i
|
||||
print(f" ✓ Material '{source_material.name}' already exists at index {i}")
|
||||
break
|
||||
|
||||
# Add material if not found
|
||||
if material_index == -1:
|
||||
target_obj.data.materials.append(source_material)
|
||||
material_index = len(target_obj.material_slots) - 1
|
||||
print(f" ✓ Added material '{source_material.name}' at index {material_index}")
|
||||
print(f" Target now has {len(target_obj.material_slots)} material slot(s)")
|
||||
else:
|
||||
print(f" Cannot assign material - source has no material")
|
||||
|
||||
# Switch to edit mode
|
||||
bpy.context.view_layer.objects.active = target_obj
|
||||
original_mode = target_obj.mode
|
||||
print(f" Switching to EDIT mode")
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
bm = bmesh.from_edit_mesh(target_obj.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.faces.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
|
||||
print(f" Creating vertical cutting planes from outline edges...")
|
||||
|
||||
# Get transformation matrix to convert from world to target local space
|
||||
target_matrix_inv = target_obj.matrix_world.inverted()
|
||||
|
||||
# Identify horizontal edges (those in the XY plane that form the perimeter)
|
||||
# These are edges where both vertices have similar Z values
|
||||
horizontal_edges = []
|
||||
for v1, v2 in outline_edges:
|
||||
z_diff = abs(v1.z - v2.z)
|
||||
xy_dist = ((v1.x - v2.x)**2 + (v1.y - v2.y)**2)**0.5
|
||||
|
||||
# If edge is mostly horizontal (small Z difference, significant XY distance)
|
||||
if z_diff < 0.01 and xy_dist > 0.01:
|
||||
horizontal_edges.append((v1, v2))
|
||||
|
||||
print(f" Found {len(horizontal_edges)} horizontal perimeter edges")
|
||||
|
||||
# For each horizontal edge, create a vertical cutting plane
|
||||
cuts_performed = 0
|
||||
for i, (v1_world, v2_world) in enumerate(horizontal_edges):
|
||||
# Transform edge vertices to target's local space
|
||||
v1_local = target_matrix_inv @ v1_world
|
||||
v2_local = target_matrix_inv @ v2_world
|
||||
|
||||
# Edge vector in XY (local space)
|
||||
edge_vec = Vector((v2_local.x - v1_local.x, v2_local.y - v1_local.y, 0))
|
||||
edge_vec.normalize()
|
||||
|
||||
# Plane normal perpendicular to edge (in XY, pointing inward/outward)
|
||||
plane_normal = Vector((-edge_vec.y, edge_vec.x, 0))
|
||||
plane_normal.normalize()
|
||||
|
||||
# Plane point (use v1 in local space)
|
||||
plane_co = v1_local.copy()
|
||||
|
||||
if i < 3:
|
||||
print(f" Cut {i}: plane at ({plane_co.x:.2f}, {plane_co.y:.2f}) [local], normal ({plane_normal.x:.2f}, {plane_normal.y:.2f})")
|
||||
|
||||
# Perform bisect
|
||||
geom = bm.verts[:] + bm.edges[:] + bm.faces[:]
|
||||
result = bmesh.ops.bisect_plane(
|
||||
bm,
|
||||
geom=geom,
|
||||
dist=0.001,
|
||||
plane_co=plane_co,
|
||||
plane_no=plane_normal,
|
||||
clear_outer=False,
|
||||
clear_inner=False
|
||||
)
|
||||
cuts_performed += 1
|
||||
|
||||
print(f" Performed {cuts_performed} cutting plane operations")
|
||||
|
||||
bmesh.update_edit_mesh(target_obj.data)
|
||||
|
||||
# Refresh bmesh references after cuts
|
||||
bm = bmesh.from_edit_mesh(target_obj.data)
|
||||
bm.faces.ensure_lookup_table()
|
||||
|
||||
print(f" Mesh cutting complete, now assigning materials...")
|
||||
|
||||
# Calculate 2D bounding box of the outline (in world space)
|
||||
min_x_world = min(min(v1.x, v2.x) for v1, v2 in outline_edges)
|
||||
max_x_world = max(max(v1.x, v2.x) for v1, v2 in outline_edges)
|
||||
min_y_world = min(min(v1.y, v2.y) for v1, v2 in outline_edges)
|
||||
max_y_world = max(max(v1.y, v2.y) for v1, v2 in outline_edges)
|
||||
|
||||
print(f" Outline 2D bounds (world space): X=[{min_x_world:.2f}, {max_x_world:.2f}], Y=[{min_y_world:.2f}, {max_y_world:.2f}]")
|
||||
|
||||
# Transform bounds to target object's local space (reuse matrix from cutting)
|
||||
# Transform the 4 corners of the bounding box
|
||||
corner_min_min = target_matrix_inv @ Vector((min_x_world, min_y_world, 0))
|
||||
corner_min_max = target_matrix_inv @ Vector((min_x_world, max_y_world, 0))
|
||||
corner_max_min = target_matrix_inv @ Vector((max_x_world, min_y_world, 0))
|
||||
corner_max_max = target_matrix_inv @ Vector((max_x_world, max_y_world, 0))
|
||||
|
||||
# Get new bounds in local space
|
||||
all_x = [corner_min_min.x, corner_min_max.x, corner_max_min.x, corner_max_max.x]
|
||||
all_y = [corner_min_min.y, corner_min_max.y, corner_max_min.y, corner_max_max.y]
|
||||
|
||||
min_x_local = min(all_x)
|
||||
max_x_local = max(all_x)
|
||||
min_y_local = min(all_y)
|
||||
max_y_local = max(all_y)
|
||||
|
||||
print(f" Outline 2D bounds (target local space): X=[{min_x_local:.2f}, {max_x_local:.2f}], Y=[{min_y_local:.2f}, {max_y_local:.2f}]")
|
||||
|
||||
# Assign material to faces within bounds
|
||||
if material_index >= 0:
|
||||
source_min_z_world = min(min(v1.z, v2.z) for v1, v2 in outline_edges)
|
||||
source_max_z_world = max(max(v1.z, v2.z) for v1, v2 in outline_edges)
|
||||
source_max_z_local = (target_matrix_inv @ Vector((0, 0, source_max_z_world))).z
|
||||
|
||||
print(f" Source Z max: {source_max_z_world:.2f} (world), {source_max_z_local:.2f} (target local)")
|
||||
|
||||
faces_assigned = 0
|
||||
for face in bm.faces:
|
||||
face_center = face.calc_center_median()
|
||||
|
||||
# Face center is already in target's local space (we're in edit mode)
|
||||
# Check if within XY bounds and below source
|
||||
if (min_x_local <= face_center.x <= max_x_local and
|
||||
min_y_local <= face_center.y <= max_y_local and
|
||||
face_center.z < source_max_z_local):
|
||||
|
||||
face.material_index = material_index
|
||||
faces_assigned += 1
|
||||
|
||||
if faces_assigned <= 3:
|
||||
print(f" Assigned material to face at ({face_center.x:.2f}, {face_center.y:.2f}, {face_center.z:.2f}) [local]")
|
||||
|
||||
print(f" Assigned material to {faces_assigned} faces")
|
||||
|
||||
bmesh.update_edit_mesh(target_obj.data)
|
||||
|
||||
# Return to object mode
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
print(f" Returned to OBJECT mode")
|
||||
|
||||
# Verify material assignment
|
||||
if material_index >= 0:
|
||||
print(f"\n Post-assignment verification:")
|
||||
polygons_with_material = sum(1 for p in target_obj.data.polygons if p.material_index == material_index)
|
||||
print(f" Polygons with material index {material_index}: {polygons_with_material}")
|
||||
|
||||
return len(outline_edges)
|
||||
|
||||
|
||||
class MESH_OT_outline_project(bpy.types.Operator):
|
||||
"""Project outline of selected objects onto active mesh"""
|
||||
bl_idname = "mesh.outline_project"
|
||||
bl_label = "Project Outline"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return (context.active_object is not None and
|
||||
context.active_object.type == 'MESH' and
|
||||
len(context.selected_objects) > 1)
|
||||
|
||||
def execute(self, context):
|
||||
print("\n" + "="*60)
|
||||
print("MESH OUTLINE PROJECTOR - STARTING")
|
||||
print("="*60)
|
||||
|
||||
target_obj = context.active_object
|
||||
print(f"Active object (target): {target_obj.name}")
|
||||
|
||||
source_objects = [obj for obj in context.selected_objects
|
||||
if obj != target_obj and obj.type == 'MESH']
|
||||
|
||||
print(f"Selected objects: {[obj.name for obj in context.selected_objects]}")
|
||||
print(f"Source objects (after filtering): {[obj.name for obj in source_objects]}")
|
||||
|
||||
if not source_objects:
|
||||
print("ERROR: No source mesh objects selected")
|
||||
self.report({'WARNING'}, "No source mesh objects selected")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Store original mode
|
||||
original_mode = context.mode
|
||||
print(f"Original mode: {original_mode}")
|
||||
|
||||
total_verts = 0
|
||||
|
||||
for source_obj in source_objects:
|
||||
print(f"\n--- Processing source object: {source_obj.name} ---")
|
||||
|
||||
# Get outline edges
|
||||
outline_edges = get_object_outline_edges_2d(source_obj, context)
|
||||
|
||||
if not outline_edges:
|
||||
print(f"WARNING: No outline edges found for {source_obj.name}")
|
||||
self.report({'WARNING'}, f"No outline edges found for {source_obj.name}")
|
||||
continue
|
||||
|
||||
# Project onto target
|
||||
num_verts = project_outline_to_mesh(context, source_obj, target_obj, outline_edges)
|
||||
total_verts += num_verts
|
||||
|
||||
print(f"Projected {len(outline_edges)} edges from {source_obj.name}")
|
||||
self.report({'INFO'}, f"Projected {len(outline_edges)} edges from {source_obj.name}")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"COMPLETED - Created {total_verts} edge projections total")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
self.report({'INFO'}, f"Created {total_verts} edge projections total")
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class VIEW3D_PT_outline_project(bpy.types.Panel):
|
||||
"""Panel for Outline Projector"""
|
||||
bl_label = "Outline Projector"
|
||||
bl_idname = "VIEW3D_PT_outline_project"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Edit'
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
layout.label(text="Project Outline:")
|
||||
layout.label(text="1. Select source objects", icon='RESTRICT_SELECT_OFF')
|
||||
layout.label(text="2. Active = target mesh", icon='OBJECT_DATA')
|
||||
|
||||
row = layout.row()
|
||||
row.scale_y = 1.5
|
||||
row.operator("mesh.outline_project", icon='MOD_UVPROJECT')
|
||||
|
||||
|
||||
def register():
|
||||
bpy.utils.register_class(MESH_OT_outline_project)
|
||||
bpy.utils.register_class(VIEW3D_PT_outline_project)
|
||||
|
||||
|
||||
def unregister():
|
||||
bpy.utils.unregister_class(VIEW3D_PT_outline_project)
|
||||
bpy.utils.unregister_class(MESH_OT_outline_project)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
[InternetShortcut]
|
||||
URL=https://claude.ai/chat/a1d5c296-c664-46cd-aba6-aafaaa3ec67b
|
||||
Loading…
Reference in a new issue