From ad4e7e39993f5ff3b18a96a39932f2c5952d27fc Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 8 Feb 2026 09:09:20 -0600 Subject: [PATCH 1/6] working kinda --- .../addons/Mesh Outline Projector/__init__.py | 459 ++++++++++++++++++ ...ing object outlines onto mesh - Claude.url | 2 + 2 files changed, 461 insertions(+) create mode 100644 Blender/addons/Mesh Outline Projector/__init__.py create mode 100644 Blender/addons/Mesh Outline Projector/claude.ai_chat-a1d5c296-c664-46cd-aba6-aafaaa3ec67b_Blender plugin for projecting object outlines onto mesh - Claude.url diff --git a/Blender/addons/Mesh Outline Projector/__init__.py b/Blender/addons/Mesh Outline Projector/__init__.py new file mode 100644 index 0000000..e404244 --- /dev/null +++ b/Blender/addons/Mesh Outline Projector/__init__.py @@ -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() \ No newline at end of file diff --git a/Blender/addons/Mesh Outline Projector/claude.ai_chat-a1d5c296-c664-46cd-aba6-aafaaa3ec67b_Blender plugin for projecting object outlines onto mesh - Claude.url b/Blender/addons/Mesh Outline Projector/claude.ai_chat-a1d5c296-c664-46cd-aba6-aafaaa3ec67b_Blender plugin for projecting object outlines onto mesh - Claude.url new file mode 100644 index 0000000..8698f82 --- /dev/null +++ b/Blender/addons/Mesh Outline Projector/claude.ai_chat-a1d5c296-c664-46cd-aba6-aafaaa3ec67b_Blender plugin for projecting object outlines onto mesh - Claude.url @@ -0,0 +1,2 @@ +[InternetShortcut] +URL=https://claude.ai/chat/a1d5c296-c664-46cd-aba6-aafaaa3ec67b -- 2.34.1 From ebf1ba65652994122b81809de3133dc75a6df64e Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 8 Feb 2026 13:18:04 -0600 Subject: [PATCH 2/6] 1:12 PM --- .../addons/Mesh Outline Projector/__init__.py | 309 +++++++++++++----- 1 file changed, 234 insertions(+), 75 deletions(-) diff --git a/Blender/addons/Mesh Outline Projector/__init__.py b/Blender/addons/Mesh Outline Projector/__init__.py index e404244..d83186b 100644 --- a/Blender/addons/Mesh Outline Projector/__init__.py +++ b/Blender/addons/Mesh Outline Projector/__init__.py @@ -14,6 +14,61 @@ from mathutils import Vector from mathutils.bvhtree import BVHTree +def point_in_polygon_2d(point, polygon_verts): + """Check if a 2D point is inside a 2D polygon using ray casting algorithm""" + x, y = point[0], point[1] + n = len(polygon_verts) + inside = False + + p1x, p1y = polygon_verts[0] + for i in range(1, n + 1): + p2x, p2y = polygon_verts[i % n] + if y > min(p1y, p2y): + if y <= max(p1y, p2y): + if x <= max(p1x, p2x): + if p1y != p2y: + xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x + if p1x == p2x or x <= xinters: + inside = not inside + p1x, p1y = p2x, p2y + + return inside + + +def point_near_polygon_edge(point, polygon_verts, tolerance=0.05): + """Check if a point is very close to any polygon edge""" + x, y = point[0], point[1] + n = len(polygon_verts) + + for i in range(n): + p1x, p1y = polygon_verts[i] + p2x, p2y = polygon_verts[(i + 1) % n] + + # Calculate distance from point to line segment + # Vector from p1 to p2 + dx = p2x - p1x + dy = p2y - p1y + + if dx == 0 and dy == 0: + # Degenerate edge + dist = ((x - p1x)**2 + (y - p1y)**2)**0.5 + else: + # Parameter t for closest point on line segment + t = max(0, min(1, ((x - p1x) * dx + (y - p1y) * dy) / (dx * dx + dy * dy))) + + # Closest point on line segment + closest_x = p1x + t * dx + closest_y = p1y + t * dy + + # Distance to closest point + dist = ((x - closest_x)**2 + (y - closest_y)**2)**0.5 + + if dist < tolerance: + return True + + return False + + 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} ===") @@ -232,94 +287,157 @@ def project_outline_to_mesh(context, source_obj, target_obj, outline_edges): bm.faces.ensure_lookup_table() bm.edges.ensure_lookup_table() - print(f" Creating vertical cutting planes from outline edges...") + print(f" Creating outline curve for knife projection...") # 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 + # Get horizontal perimeter edges 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") + print(f" Found {len(horizontal_edges)} horizontal perimeter edges (before deduplication)") - # 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 + # Deduplicate edges + unique_edges_normalized = [] + tolerance = 0.01 - print(f" Performed {cuts_performed} cutting plane operations") + for v1, v2 in horizontal_edges: + if v1.x < v2.x or (abs(v1.x - v2.x) < tolerance and v1.y < v2.y): + edge_normalized = (v1, v2) + else: + edge_normalized = (v2, v1) + + is_duplicate = False + for existing_v1, existing_v2 in unique_edges_normalized: + if (abs(edge_normalized[0].x - existing_v1.x) < tolerance and + abs(edge_normalized[0].y - existing_v1.y) < tolerance and + abs(edge_normalized[1].x - existing_v2.x) < tolerance and + abs(edge_normalized[1].y - existing_v2.y) < tolerance): + is_duplicate = True + break + + if not is_duplicate: + unique_edges_normalized.append(edge_normalized) - bmesh.update_edit_mesh(target_obj.data) + print(f" Deduplicated to {len(unique_edges_normalized)} unique edges") - # Refresh bmesh references after cuts + # Build ordered polygon from edges for point-in-polygon testing + # Start with first edge + polygon_verts_2d = [unique_edges_normalized[0][0], unique_edges_normalized[0][1]] + used_edges = {0} + + # Connect edges to form a closed loop + for _ in range(len(unique_edges_normalized) - 1): + last_point = polygon_verts_2d[-1] + + # Find next connected edge + for i, (v1, v2) in enumerate(unique_edges_normalized): + if i in used_edges: + continue + + # Check if this edge connects to our last point + v1_2d = (v1.x, v1.y) + v2_2d = (v2.x, v2.y) + last_2d = (last_point.x, last_point.y) + + tolerance = 0.01 + if abs(v1_2d[0] - last_2d[0]) < tolerance and abs(v1_2d[1] - last_2d[1]) < tolerance: + polygon_verts_2d.append(v2) + used_edges.add(i) + break + elif abs(v2_2d[0] - last_2d[0]) < tolerance and abs(v2_2d[1] - last_2d[1]) < tolerance: + polygon_verts_2d.append(v1) + used_edges.add(i) + break + + # Remove duplicate endpoint if polygon is closed + if len(polygon_verts_2d) > 0: + first = polygon_verts_2d[0] + last = polygon_verts_2d[-1] + if abs(first.x - last.x) < 0.01 and abs(first.y - last.y) < 0.01: + polygon_verts_2d = polygon_verts_2d[:-1] + + # Convert to 2D coordinates in world space for polygon testing + polygon_2d_world = [(v.x, v.y) for v in polygon_verts_2d] + + print(f" Built polygon with {len(polygon_2d_world)} vertices for point-in-polygon testing") + print(f" Polygon vertices (world space):") + for i, (x, y) in enumerate(polygon_2d_world): + print(f" Vertex {i}: ({x:.2f}, {y:.2f})") + + # Create curve object from unique edges + curve_data = bpy.data.curves.new(name=f"{source_obj.name}_outline", type='CURVE') + curve_data.dimensions = '3D' + + for v1, v2 in unique_edges_normalized: + spline = curve_data.splines.new('POLY') + spline.points.add(1) + spline.points[0].co = (v1.x, v1.y, v1.z, 1) + spline.points[1].co = (v2.x, v2.y, v2.z, 1) + + curve_obj = bpy.data.objects.new(f"{source_obj.name}_outline", curve_data) + context.collection.objects.link(curve_obj) + + print(f" Created curve with {len(unique_edges_normalized)} splines") + + # Set view to top orthographic + original_view_data = None + for area in context.screen.areas: + if area.type == 'VIEW_3D': + for space in area.spaces: + if space.type == 'VIEW_3D': + from mathutils import Quaternion + original_view_data = { + 'rotation': space.region_3d.view_rotation.copy(), + 'perspective': space.region_3d.view_perspective, + 'distance': space.region_3d.view_distance + } + space.region_3d.view_rotation = Quaternion((1.0, 0.0, 0.0, 0.0)) + space.region_3d.view_perspective = 'ORTHO' + break + + # Switch back to object mode to select objects + bpy.ops.object.mode_set(mode='OBJECT') + + # Deselect all + bpy.ops.object.select_all(action='DESELECT') + + # Select curve and target + curve_obj.select_set(True) + target_obj.select_set(True) + context.view_layer.objects.active = target_obj + + # Switch to edit mode for knife_project + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='DESELECT') + + # Use knife project + print(f" Running knife_project...") + bpy.ops.mesh.knife_project(cut_through=False) + + # Restore view + if original_view_data: + for area in context.screen.areas: + if area.type == 'VIEW_3D': + for space in area.spaces: + if space.type == 'VIEW_3D': + space.region_3d.view_rotation = original_view_data['rotation'] + space.region_3d.view_perspective = original_view_data['perspective'] + space.region_3d.view_distance = original_view_data['distance'] + break + + # Get bmesh for material assignment 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) @@ -327,24 +445,60 @@ def project_outline_to_mesh(context, source_obj, target_obj, 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)") + print(f" Material assignment: ALL vertices inside or on edge (tolerance: 0.05)") faces_assigned = 0 + faces_rejected_outside_polygon = 0 + faces_rejected_z = 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): + # First check Z (quick rejection) + below_z = face_center.z < source_max_z_local + if not below_z: + faces_rejected_z += 1 + continue + + # Check if ALL vertices are inside or on/near the polygon boundary + all_verts_inside = True + verts_checked = 0 + + for vert in face.verts: + vert_world = target_obj.matrix_world @ vert.co + # Check Z first + if vert_world.z >= source_max_z_world: + all_verts_inside = False + break + + # Check if vertex is inside polygon OR on/near the edge (with tolerance) + inside = point_in_polygon_2d((vert_world.x, vert_world.y), polygon_2d_world) + on_edge = point_near_polygon_edge((vert_world.x, vert_world.y), polygon_2d_world, tolerance=0.05) + + if not (inside or on_edge): + all_verts_inside = False + break + + verts_checked += 1 + + if all_verts_inside and verts_checked > 0: 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]") + if faces_assigned <= 5: + face_center_world = target_obj.matrix_world @ face_center + print(f" ✓ Face {faces_assigned}: ({face_center.x:.2f}, {face_center.y:.2f}, {face_center.z:.2f}) [local] -> ({face_center_world.x:.2f}, {face_center_world.y:.2f}) [world]") + else: + faces_rejected_outside_polygon += 1 + if faces_rejected_outside_polygon <= 5: + face_center_world = target_obj.matrix_world @ face_center + print(f" ✗ Outside polygon: ({face_center.x:.2f}, {face_center.y:.2f}, {face_center.z:.2f}) [local] -> ({face_center_world.x:.2f}, {face_center_world.y:.2f}) [world]") - print(f" Assigned material to {faces_assigned} faces") + print(f" Material assignment results:") + print(f" Assigned: {faces_assigned}") + print(f" Rejected - Outside polygon: {faces_rejected_outside_polygon}") + print(f" Rejected - Z too high: {faces_rejected_z}") bmesh.update_edit_mesh(target_obj.data) @@ -352,6 +506,11 @@ def project_outline_to_mesh(context, source_obj, target_obj, outline_edges): bpy.ops.object.mode_set(mode='OBJECT') print(f" Returned to OBJECT mode") + # Clean up temporary curve object + bpy.data.objects.remove(curve_obj, do_unlink=True) + bpy.data.curves.remove(curve_data) + print(f" Cleaned up temporary curve") + # Verify material assignment if material_index >= 0: print(f"\n Post-assignment verification:") -- 2.34.1 From a6658acfc20a02b525b7211b91fd708bccb337f3 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 8 Feb 2026 13:46:44 -0600 Subject: [PATCH 3/6] 1:43 PM --- .../addons/Mesh Outline Projector/__init__.py | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/Blender/addons/Mesh Outline Projector/__init__.py b/Blender/addons/Mesh Outline Projector/__init__.py index d83186b..bebe6ed 100644 --- a/Blender/addons/Mesh Outline Projector/__init__.py +++ b/Blender/addons/Mesh Outline Projector/__init__.py @@ -386,22 +386,6 @@ def project_outline_to_mesh(context, source_obj, target_obj, outline_edges): print(f" Created curve with {len(unique_edges_normalized)} splines") - # Set view to top orthographic - original_view_data = None - for area in context.screen.areas: - if area.type == 'VIEW_3D': - for space in area.spaces: - if space.type == 'VIEW_3D': - from mathutils import Quaternion - original_view_data = { - 'rotation': space.region_3d.view_rotation.copy(), - 'perspective': space.region_3d.view_perspective, - 'distance': space.region_3d.view_distance - } - space.region_3d.view_rotation = Quaternion((1.0, 0.0, 0.0, 0.0)) - space.region_3d.view_perspective = 'ORTHO' - break - # Switch back to object mode to select objects bpy.ops.object.mode_set(mode='OBJECT') @@ -421,17 +405,6 @@ def project_outline_to_mesh(context, source_obj, target_obj, outline_edges): print(f" Running knife_project...") bpy.ops.mesh.knife_project(cut_through=False) - # Restore view - if original_view_data: - for area in context.screen.areas: - if area.type == 'VIEW_3D': - for space in area.spaces: - if space.type == 'VIEW_3D': - space.region_3d.view_rotation = original_view_data['rotation'] - space.region_3d.view_perspective = original_view_data['perspective'] - space.region_3d.view_distance = original_view_data['distance'] - break - # Get bmesh for material assignment bm = bmesh.from_edit_mesh(target_obj.data) bm.faces.ensure_lookup_table() @@ -555,6 +528,32 @@ class MESH_OT_outline_project(bpy.types.Operator): original_mode = context.mode print(f"Original mode: {original_mode}") + # RESET TARGET MATERIALS ONCE (not per source object) + print(f"\nResetting target materials...") + print(f"Target currently has {len(target_obj.material_slots)} material slot(s)") + + if len(target_obj.material_slots) > 0: + first_material = target_obj.material_slots[0].material + print(f"First material: {first_material.name if first_material else 'None'}") + + # Apply first material to all faces + for poly in target_obj.data.polygons: + poly.material_index = 0 + + print(f"✓ Applied first material to all {len(target_obj.data.polygons)} faces") + + # Remove all material slots except the first one + slots_removed = 0 + while len(target_obj.material_slots) > 1: + target_obj.data.materials.pop(index=1) + slots_removed += 1 + + if slots_removed > 0: + print(f"✓ Removed {slots_removed} material slot(s)") + print(f"Target now has {len(target_obj.material_slots)} material slot(s)") + else: + print(f"WARNING: Target has no materials!") + total_verts = 0 for source_obj in source_objects: -- 2.34.1 From 32657c742332e8c1232696b3eeac03740848fbe5 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 8 Feb 2026 13:57:32 -0600 Subject: [PATCH 4/6] 1:50 PM --- .../addons/Mesh Outline Projector/__init__.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Blender/addons/Mesh Outline Projector/__init__.py b/Blender/addons/Mesh Outline Projector/__init__.py index bebe6ed..bfc3448 100644 --- a/Blender/addons/Mesh Outline Projector/__init__.py +++ b/Blender/addons/Mesh Outline Projector/__init__.py @@ -499,6 +499,16 @@ class MESH_OT_outline_project(bpy.types.Operator): bl_label = "Project Outline" bl_options = {'REGISTER', 'UNDO'} + dissolve_angle: bpy.props.FloatProperty( + name="Dissolve Angle", + description="Angle threshold for Limit Dissolve (degrees)", + default=0.04, + min=0.0, + max=180.0, + step=1, + precision=3 + ) + @classmethod def poll(cls, context): return (context.active_object is not None and @@ -528,6 +538,29 @@ class MESH_OT_outline_project(bpy.types.Operator): original_mode = context.mode print(f"Original mode: {original_mode}") + # MESH CLEANUP: Remove previous knife cuts + print(f"\nCleaning up target mesh...") + print(f"Dissolve angle: {self.dissolve_angle} degrees") + + # Switch to edit mode for cleanup operations + bpy.context.view_layer.objects.active = target_obj + bpy.ops.object.mode_set(mode='EDIT') + + # Select all faces + bpy.ops.mesh.select_all(action='SELECT') + + # Run Limit Dissolve to remove unnecessary edges + bpy.ops.mesh.dissolve_limited(angle_limit=self.dissolve_angle * 3.14159 / 180.0) + print(f"✓ Ran Limit Dissolve") + + # Convert quads to tris + bpy.ops.mesh.quads_convert_to_tris() + print(f"✓ Converted quads to tris") + + # Return to object mode + bpy.ops.object.mode_set(mode='OBJECT') + print(f"Mesh cleanup complete") + # RESET TARGET MATERIALS ONCE (not per source object) print(f"\nResetting target materials...") print(f"Target currently has {len(target_obj.material_slots)} material slot(s)") @@ -597,6 +630,7 @@ class VIEW3D_PT_outline_project(bpy.types.Panel): 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') + layout.label(text="3. Press Numpad 7 (top view)", icon='CAMERA_DATA') row = layout.row() row.scale_y = 1.5 -- 2.34.1 From 6ac8c858ad4036f6043bb57357a95c74430e85ae Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 8 Feb 2026 14:01:30 -0600 Subject: [PATCH 5/6] Feb 8, 2026, 1:58 PM - add limited dissolve variable --- .../addons/Mesh Outline Projector/__init__.py | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/Blender/addons/Mesh Outline Projector/__init__.py b/Blender/addons/Mesh Outline Projector/__init__.py index bfc3448..f6ffdff 100644 --- a/Blender/addons/Mesh Outline Projector/__init__.py +++ b/Blender/addons/Mesh Outline Projector/__init__.py @@ -632,19 +632,42 @@ class VIEW3D_PT_outline_project(bpy.types.Panel): layout.label(text="2. Active = target mesh", icon='OBJECT_DATA') layout.label(text="3. Press Numpad 7 (top view)", icon='CAMERA_DATA') + # Add settings box + box = layout.box() + box.label(text="Settings:") + box.prop(context.scene, 'outline_dissolve_angle', text="Dissolve Angle", slider=True) + row = layout.row() row.scale_y = 1.5 - row.operator("mesh.outline_project", icon='MOD_UVPROJECT') + op = row.operator("mesh.outline_project", icon='MOD_UVPROJECT') + + # Pass the scene value to the operator + op.dissolve_angle = context.scene.outline_dissolve_angle def register(): bpy.utils.register_class(MESH_OT_outline_project) bpy.utils.register_class(VIEW3D_PT_outline_project) + + # Register scene property for UI + bpy.types.Scene.outline_dissolve_angle = bpy.props.FloatProperty( + name="Dissolve Angle", + description="Angle threshold for Limit Dissolve (degrees)", + default=0.04, + min=0.0, + max=5.0, + step=0.01, + precision=3 + ) def unregister(): bpy.utils.unregister_class(VIEW3D_PT_outline_project) bpy.utils.unregister_class(MESH_OT_outline_project) + + # Unregister scene property + if hasattr(bpy.types.Scene, 'outline_dissolve_angle'): + del bpy.types.Scene.outline_dissolve_angle if __name__ == "__main__": -- 2.34.1 From 37ac4d659da7474bc05ae88c14b88ab1a79326d3 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 8 Feb 2026 14:53:21 -0600 Subject: [PATCH 6/6] readme --- .../addons/Mesh Outline Projector/README.md | 219 ++++++++++++++++++ .../addons/Mesh Outline Projector/__init__.py | 113 ++++++++- 2 files changed, 326 insertions(+), 6 deletions(-) create mode 100644 Blender/addons/Mesh Outline Projector/README.md diff --git a/Blender/addons/Mesh Outline Projector/README.md b/Blender/addons/Mesh Outline Projector/README.md new file mode 100644 index 0000000..7e67780 --- /dev/null +++ b/Blender/addons/Mesh Outline Projector/README.md @@ -0,0 +1,219 @@ +# Mesh Outline Projector + +A Blender addon that projects the vertical outline of selected objects onto a target mesh and transfers materials, perfect for architectural visualization and terrain mapping. + +## Overview + +Mesh Outline Projector takes the 2D silhouette of objects (when viewed from above) and "stamps" them onto a mesh below, automatically cutting the mesh and applying the source object's material to the projected area. Think of it as a cookie-cutter that works vertically through 3D space. + +### Key Features + +- **Vertical Projection**: Projects object outlines straight down (Z-axis) onto target surfaces +- **Material Transfer**: Automatically applies source object materials to projected regions +- **Clean Cutting**: Uses Blender's knife project for precise edge creation +- **Smart Material Assignment**: Point-in-polygon detection with edge tolerance for accurate coverage +- **Mesh Cleanup**: Automatically removes previous cuts with Limit Dissolve +- **Intersection Detection**: Validates that objects actually overlap before processing +- **Multiple Objects**: Process multiple source objects in a single operation + +## Installation + +1. Download `mesh_outline_projector.py` +2. Open Blender → Edit → Preferences → Add-ons +3. Click "Install..." and select the downloaded file +4. Enable "Mesh: Mesh Outline Projector" in the add-ons list +5. The panel appears in the 3D Viewport sidebar under the "Outline Projector" tab + +## Quick Start + +### Basic Workflow + +1. **Setup Scene** + - Position your source objects (e.g., building footprints) above your target mesh (e.g., terrain) + - Objects can be at any height - only horizontal (XY) overlap matters + +2. **Select Objects** + - Select one or more source objects (the objects whose outlines you want to project) + - Select the target mesh **last** (it becomes the active object - shown in orange) + +3. **Set View** + - Press **Numpad 7** to switch to top orthographic view + - This is required for knife project to work correctly + +4. **Run Operator** + - Open the sidebar (N key) → "Outline Projector" tab + - Adjust settings if needed (see below) + - Click "Project Outline" + +### What Happens + +The addon performs these steps automatically: + +1. **Validates** that source objects overlap with target in XY plane +2. **Cleans up** the target mesh (Limit Dissolve + Quads to Tris) +3. **Resets materials** to the first material slot +4. **For each source object**: + - Extracts the horizontal perimeter edges (2D outline) + - Creates a temporary curve from these edges + - Uses knife project to cut the target mesh + - Assigns the source material to faces within the projected area +5. **Reports** results with detailed console output + +## Settings + +### Dissolve Angle + +- **Default**: 0.04 degrees +- **Range**: 0.0 - 5.0 degrees +- **Purpose**: Controls how aggressively Limit Dissolve removes edges from previous operations + +**When to adjust:** +- **Increase** (0.5-2.0°) if working with curved or organic surfaces +- **Keep low** (0.01-0.1°) for architectural/flat surfaces +- Higher values = more aggressive cleanup, fewer edges + +### Edge Tolerance + +- **Default**: 0.05 units +- **Range**: 0.0 - 1.0 units +- **Purpose**: How close a vertex must be to a polygon edge to be considered "on the edge" + +**When to adjust:** +- **Increase** (0.1-0.5) if missing faces at the boundaries +- **Decrease** (0.01-0.03) if getting material bleeding outside the outline +- Affects the precision of material assignment at polygon boundaries + +## Use Cases + +### Architectural Site Plans + +Project building footprints onto terrain meshes: +- Import IFC building models +- Create terrain mesh from topography data +- Project building outlines to create material zones + +### Terrain Texturing + +Stamp different material zones onto landscapes: +- Roads, paths, and sidewalks +- Water bodies and wetlands +- Vegetation zones +- Building pads + +### Urban Planning + +Visualize zoning and land use: +- Different materials for residential/commercial/industrial zones +- Overlay multiple boundary shapes +- Iterate quickly as plans change + +## Tips & Best Practices + +### Before Running + +✅ **Do:** +- Ensure source objects are above or at the same level as target mesh +- Use simple, clean geometry for source objects +- Press Numpad 7 (top view) before running +- Save your work before first use + +❌ **Don't:** +- Use extremely complex source meshes (simplify first) +- Forget to set top view +- Run on unsaved files (though Undo works) + +### Iteration Workflow + +The addon is designed for iteration: + +1. Run projection with objects A and B +2. Adjust position/scale of object A +3. Run projection again - mesh is automatically cleaned up +4. Materials from both objects are preserved + +**The mesh reset happens automatically** - you can run the operator multiple times without manual cleanup. + +### Material Management + +- The addon preserves the **first material slot** as the "base" material +- Source materials are added as new slots (slot 1, 2, 3, etc.) +- Each run resets everything to the base material before applying projections +- If you want a different base material, assign it to slot 0 before running + +## Troubleshooting + +### "No source objects intersect with target mesh" + +**Problem**: Objects don't overlap in the XY plane (when viewed from above) + +**Solutions**: +- Check bounding boxes in debug output +- Move objects horizontally to overlap +- Objects can be at different Z heights - that's fine +- Ensure you're not selecting the target as a source object + +### Materials not appearing correctly + +**Problem**: Materials bleeding outside boundaries or missing at edges + +**Solutions**: +- Adjust **Edge Tolerance** setting +- Increase if missing edge faces (try 0.1-0.2) +- Decrease if bleeding outside (try 0.02-0.03) +- Check that source objects have materials assigned + +### Blender crashes + +**Problem**: Crash during operation (rare) + +**Solutions**: +- Ensure you're in **top orthographic view** (Numpad 7) +- Simplify source mesh geometry +- Update to latest Blender version +- Check console for error messages + +### Unexpected results after multiple runs + +**Problem**: Results look different on subsequent runs + +**Solutions**: +- This is normal - each run subdivides the mesh differently +- Knife project creates new edges each time +- Undo (Ctrl+Z) to return to previous state +- The dissolve/cleanup helps but won't make mesh identical + +## Technical Details + +### How Outline Detection Works + +The addon uses three methods to find outline edges: + +1. **Boundary edges**: Edges with only one connected face +2. **Silhouette edges**: Edges where connected faces have different vertical orientations +3. **Perimeter edges**: Vertical edges at the outer boundary + +These are combined to form the complete 2D outline when viewed from above. + +### Material Assignment Algorithm + +1. **Extract horizontal edges** from the 3D outline +2. **Build a 2D polygon** from these edges (in world XY space) +3. **For each face** in the target mesh: + - Check if **all vertices** are inside the polygon OR near an edge + - Uses ray-casting for point-in-polygon test + - Uses perpendicular distance for edge proximity +4. **Assign material** only if all vertices pass + +This ensures clean boundaries without bleeding. + + +## Known Limitations + +- Requires **top orthographic view** (Numpad 7) to work correctly +- Works best with **relatively flat** target meshes +- Very **dense meshes** may slow down processing +- **Complex source geometry** may produce unexpected outlines +- Knife project can create **overlapping edges** in some cases +- **Z-axis projection only** - cannot project at angles + + diff --git a/Blender/addons/Mesh Outline Projector/__init__.py b/Blender/addons/Mesh Outline Projector/__init__.py index f6ffdff..f7009f6 100644 --- a/Blender/addons/Mesh Outline Projector/__init__.py +++ b/Blender/addons/Mesh Outline Projector/__init__.py @@ -69,6 +69,50 @@ def point_near_polygon_edge(point, polygon_verts, tolerance=0.05): return False +def check_bounding_box_intersection(obj1, obj2): + """Check if two objects' bounding boxes intersect in the XY plane (horizontal) + + Note: Only checks X and Y overlap, not Z, because this is a vertical projection. + The source objects are expected to be above or below the target. + """ + # Get world space bounding box corners + bbox1 = [obj1.matrix_world @ Vector(corner) for corner in obj1.bound_box] + bbox2 = [obj2.matrix_world @ Vector(corner) for corner in obj2.bound_box] + + # Get min/max for each axis + min1 = Vector((min(v.x for v in bbox1), min(v.y for v in bbox1), min(v.z for v in bbox1))) + max1 = Vector((max(v.x for v in bbox1), max(v.y for v in bbox1), max(v.z for v in bbox1))) + + min2 = Vector((min(v.x for v in bbox2), min(v.y for v in bbox2), min(v.z for v in bbox2))) + max2 = Vector((max(v.x for v in bbox2), max(v.y for v in bbox2), max(v.z for v in bbox2))) + + # Debug output + print(f"\n Bounding Box Check: {obj1.name} vs {obj2.name}") + print(f" {obj1.name} bounds:") + print(f" X: [{min1.x:.2f}, {max1.x:.2f}]") + print(f" Y: [{min1.y:.2f}, {max1.y:.2f}]") + print(f" Z: [{min1.z:.2f}, {max1.z:.2f}]") + print(f" {obj2.name} bounds:") + print(f" X: [{min2.x:.2f}, {max2.x:.2f}]") + print(f" Y: [{min2.y:.2f}, {max2.y:.2f}]") + print(f" Z: [{min2.z:.2f}, {max2.z:.2f}]") + + # Check for overlap on X and Y axes only (horizontal plane) + # Z is ignored because we're projecting vertically + x_overlap = max1.x >= min2.x and max2.x >= min1.x + y_overlap = max1.y >= min2.y and max2.y >= min1.y + + print(f" Overlap check (XY plane only - vertical projection):") + print(f" X overlap: {x_overlap} (max1:{max1.x:.2f} >= min2:{min2.x:.2f} and max2:{max2.x:.2f} >= min1:{min1.x:.2f})") + print(f" Y overlap: {y_overlap} (max1:{max1.y:.2f} >= min2:{min2.y:.2f} and max2:{max2.y:.2f} >= min1:{min1.y:.2f})") + print(f" Z check: SKIPPED (vertical projection - objects can be above/below each other)") + + result = x_overlap and y_overlap + print(f" Result: {'INTERSECTS (XY)' if result else 'NO INTERSECTION'}") + + return result + + 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} ===") @@ -230,7 +274,7 @@ def get_object_outline_edges_2d(obj, context): # View manipulation functions removed - now using direct bisect projection -def project_outline_to_mesh(context, source_obj, target_obj, outline_edges): +def project_outline_to_mesh(context, source_obj, target_obj, outline_edges, edge_tolerance=0.05): """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} ===") @@ -418,7 +462,7 @@ def project_outline_to_mesh(context, source_obj, target_obj, 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)") - print(f" Material assignment: ALL vertices inside or on edge (tolerance: 0.05)") + print(f" Material assignment: ALL vertices inside or on edge (tolerance: {edge_tolerance})") faces_assigned = 0 faces_rejected_outside_polygon = 0 @@ -447,7 +491,7 @@ def project_outline_to_mesh(context, source_obj, target_obj, outline_edges): # Check if vertex is inside polygon OR on/near the edge (with tolerance) inside = point_in_polygon_2d((vert_world.x, vert_world.y), polygon_2d_world) - on_edge = point_near_polygon_edge((vert_world.x, vert_world.y), polygon_2d_world, tolerance=0.05) + on_edge = point_near_polygon_edge((vert_world.x, vert_world.y), polygon_2d_world, tolerance=edge_tolerance) if not (inside or on_edge): all_verts_inside = False @@ -509,6 +553,16 @@ class MESH_OT_outline_project(bpy.types.Operator): precision=3 ) + edge_tolerance: bpy.props.FloatProperty( + name="Edge Tolerance", + description="Distance tolerance for vertices near polygon edges (world units)", + default=0.05, + min=0.0, + max=1.0, + step=0.01, + precision=3 + ) + @classmethod def poll(cls, context): return (context.active_object is not None and @@ -534,6 +588,38 @@ class MESH_OT_outline_project(bpy.types.Operator): self.report({'WARNING'}, "No source mesh objects selected") return {'CANCELLED'} + # Check which source objects actually intersect with the target + print(f"\nChecking bounding box intersections...") + intersecting_objects = [] + non_intersecting_objects = [] + + for source_obj in source_objects: + intersects = check_bounding_box_intersection(source_obj, target_obj) + if intersects: + intersecting_objects.append(source_obj) + print(f" ✓ {source_obj.name} intersects with target") + else: + non_intersecting_objects.append(source_obj) + print(f" ✗ {source_obj.name} does NOT intersect with target") + + # Report non-intersecting objects + if non_intersecting_objects: + non_intersecting_names = ", ".join([obj.name for obj in non_intersecting_objects]) + print(f"\nWARNING: {len(non_intersecting_objects)} object(s) do not intersect: {non_intersecting_names}") + self.report({'WARNING'}, f"{len(non_intersecting_objects)} object(s) do not intersect with target") + + # If no objects intersect, cancel the operation + if not intersecting_objects: + print("\nERROR: No source objects intersect with the target mesh!") + print("Make sure your source objects overlap with the target mesh in 3D space.") + self.report({'ERROR'}, "No source objects intersect with target mesh - nothing to project!") + return {'CANCELLED'} + + print(f"\nProceeding with {len(intersecting_objects)} intersecting object(s)") + + # Use only intersecting objects from here on + source_objects = intersecting_objects + # Store original mode original_mode = context.mode print(f"Original mode: {original_mode}") @@ -541,6 +627,7 @@ class MESH_OT_outline_project(bpy.types.Operator): # MESH CLEANUP: Remove previous knife cuts print(f"\nCleaning up target mesh...") print(f"Dissolve angle: {self.dissolve_angle} degrees") + print(f"Edge tolerance: {self.edge_tolerance} units") # Switch to edit mode for cleanup operations bpy.context.view_layer.objects.active = target_obj @@ -601,7 +688,7 @@ class MESH_OT_outline_project(bpy.types.Operator): continue # Project onto target - num_verts = project_outline_to_mesh(context, source_obj, target_obj, outline_edges) + num_verts = project_outline_to_mesh(context, source_obj, target_obj, outline_edges, self.edge_tolerance) total_verts += num_verts print(f"Projected {len(outline_edges)} edges from {source_obj.name}") @@ -622,7 +709,7 @@ class VIEW3D_PT_outline_project(bpy.types.Panel): bl_idname = "VIEW3D_PT_outline_project" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' - bl_category = 'Edit' + bl_category = 'Outline Projector' def draw(self, context): layout = self.layout @@ -636,13 +723,15 @@ class VIEW3D_PT_outline_project(bpy.types.Panel): box = layout.box() box.label(text="Settings:") box.prop(context.scene, 'outline_dissolve_angle', text="Dissolve Angle", slider=True) + box.prop(context.scene, 'outline_edge_tolerance', text="Edge Tolerance", slider=True) row = layout.row() row.scale_y = 1.5 op = row.operator("mesh.outline_project", icon='MOD_UVPROJECT') - # Pass the scene value to the operator + # Pass the scene values to the operator op.dissolve_angle = context.scene.outline_dissolve_angle + op.edge_tolerance = context.scene.outline_edge_tolerance def register(): @@ -659,6 +748,16 @@ def register(): step=0.01, precision=3 ) + + bpy.types.Scene.outline_edge_tolerance = bpy.props.FloatProperty( + name="Edge Tolerance", + description="Distance tolerance for vertices near polygon edges (world units)", + default=0.05, + min=0.0, + max=1.0, + step=0.01, + precision=3 + ) def unregister(): @@ -668,6 +767,8 @@ def unregister(): # Unregister scene property if hasattr(bpy.types.Scene, 'outline_dissolve_angle'): del bpy.types.Scene.outline_dissolve_angle + if hasattr(bpy.types.Scene, 'outline_edge_tolerance'): + del bpy.types.Scene.outline_edge_tolerance if __name__ == "__main__": -- 2.34.1