bl_info = { "name": "Bake Vertex to Target Vector", "blender": (4, 2, 0), "category": "Mesh", "version": (2, 3, 0), "author": "yum_food", "description": "Bake vertex vectors with automatic center and scale calculation in Edit Mode, with submesh deduplication" } import bpy import mathutils import bmesh import math from bpy.props import BoolProperty, FloatProperty, IntProperty, PointerProperty from bpy.types import Panel, Operator, PropertyGroup from collections import deque, defaultdict class BakeVertexSettings(PropertyGroup): correction_angle_x: FloatProperty( name="X", description="Correction angle for the X-axis", default=math.pi, subtype='ANGLE' ) correction_angle_y: FloatProperty( name="Y", description="Correction angle for the Y-axis", default=0.0, subtype='ANGLE' ) correction_angle_z: FloatProperty( name="Z", description="Correction angle for the Z-axis", default=0.0, subtype='ANGLE' ) class MeshUtils: """Utility functions for mesh operations""" @staticmethod def with_mode(mode): """Decorator to handle mode switching""" def decorator(func): def wrapper(self, context, *args, **kwargs): original_mode = context.mode if mode == 'OBJECT' and context.mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT') elif mode == 'EDIT' and context.mode != 'EDIT_MESH': bpy.ops.object.mode_set(mode='EDIT') try: result = func(self, context, *args, **kwargs) finally: if original_mode == 'EDIT_MESH' and context.mode != 'EDIT_MESH': bpy.ops.object.mode_set(mode='EDIT') elif original_mode == 'OBJECT' and context.mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT') return result return wrapper return decorator @staticmethod def with_multi_object_support(process_func_name='process_object'): """Decorator to add multi-object support to operators""" def decorator(func): def wrapper(self, context, *args, **kwargs): original_active = context.active_object selected_objects = [obj for obj in context.selected_objects if obj.type == 'MESH'] if not selected_objects: self.report({'WARNING'}, "No mesh objects selected") return {'CANCELLED'} total_stats = {} processed_count = 0 for obj in selected_objects: context.view_layer.objects.active = obj if hasattr(self, process_func_name): success, stats = getattr(self, process_func_name)(context, obj) else: result = func(self, context, obj, *args, **kwargs) if isinstance(result, tuple) and len(result) == 2: success, stats = result else: success, stats = (result == {'FINISHED'}), {} if success: processed_count += 1 for key, value in stats.items(): if isinstance(value, (int, float)): total_stats[key] = total_stats.get(key, 0) + value else: total_stats[key] = value context.view_layer.objects.active = original_active total_stats['object_count'] = processed_count if hasattr(self, 'format_report'): message = self.format_report(total_stats) else: if processed_count == 0: self.report({'WARNING'}, "No objects processed") return {'CANCELLED'} message = f"Processed {processed_count} object(s)" if message: self.report({'INFO'}, message) return {'FINISHED'} return wrapper return decorator @staticmethod def get_selected_vertices(mesh): """Get indices of selected vertices""" return {v.index for v in mesh.vertices if v.select} @staticmethod def build_adjacency(mesh, vertex_indices): """Build adjacency list for given vertices""" adjacency = {idx: set() for idx in vertex_indices} for edge in mesh.edges: v0, v1 = edge.vertices if v0 in adjacency and v1 in adjacency: adjacency[v0].add(v1) adjacency[v1].add(v0) return adjacency @staticmethod def flood_fill(start_nodes, adjacency_func): """Generic flood fill algorithm""" visited = set() result = set() queue = deque(start_nodes) while queue: current = queue.popleft() if current in visited: continue visited.add(current) result.add(current) for neighbor in adjacency_func(current): if neighbor not in visited: queue.append(neighbor) return result @staticmethod def find_islands(nodes, adjacency_func): """Find connected components""" islands = [] visited = set() for node in nodes: if node in visited: continue island = MeshUtils.flood_fill([node], lambda n: adjacency_func.get(n, [])) visited.update(island) islands.append(island) return islands @staticmethod def select_edges_and_faces(mesh): """Select edges and faces based on selected vertices""" for edge in mesh.edges: v0, v1 = edge.vertices if mesh.vertices[v0].select and mesh.vertices[v1].select: edge.select = True for face in mesh.polygons: if all(mesh.vertices[v].select for v in face.vertices): face.select = True @staticmethod def build_position_map(vertices, scale): """Build a map of vertices by quantized position""" position_map = defaultdict(list) for v in vertices: if hasattr(v, 'index'): key = tuple(int(v.co[i] * scale) for i in range(3)) position_map[key].append(v.index) else: key = tuple(int(v[i] * scale) for i in range(3)) position_map[key].append(v) return position_map @staticmethod def get_or_create_uv_layer(mesh, name): """Get or create a UV layer by name""" return mesh.uv_layers.get(name) or mesh.uv_layers.new(name=name) class BaseSubmeshOperator(Operator): """Base class for submesh operations with common functionality""" bl_options = {'REGISTER', 'UNDO'} @classmethod def poll(cls, context): obj = context.active_object return obj and obj.type == 'MESH' and context.mode == 'EDIT_MESH' def get_selected_submeshes(self, mesh): """Get selected vertices grouped by submesh""" selected = MeshUtils.get_selected_vertices(mesh) if not selected: return [] adjacency = MeshUtils.build_adjacency(mesh, selected) return MeshUtils.find_islands(selected, adjacency) @MeshUtils.with_multi_object_support('process_object') def execute(self, context): pass class ToleranceOperatorMixin: """Mixin for operators that use tolerance values""" def get_scale_from_tolerance(self, tolerance): """Convert tolerance to scale factor for quantization""" return min(1.0 / tolerance, 1e7) if tolerance > 0 else 1e7 class MESH_OT_select_all_linked(BaseSubmeshOperator): bl_idname = "mesh.select_all_linked" bl_label = "Select All Linked Submeshes" bl_description = "Select all vertices in any submesh that has at least one vertex selected" def process_object(self, context, obj): mesh = obj.data # Get BMesh for edit mode operations bm = bmesh.from_edit_mesh(mesh) bm.verts.ensure_lookup_table() initially_selected = {v.index for v in bm.verts if v.select} if not initially_selected: return False, {} # Build adjacency using BMesh all_vertices = set(range(len(bm.verts))) adjacency = {idx: set() for idx in all_vertices} for edge in bm.edges: v0, v1 = edge.verts[0].index, edge.verts[1].index adjacency[v0].add(v1) adjacency[v1].add(v0) islands = MeshUtils.find_islands(all_vertices, adjacency) expanded_count = 0 affected_islands = 0 for island in islands: if island & initially_selected: new_selections = island - initially_selected if new_selections: expanded_count += len(new_selections) affected_islands += 1 # Select all vertices in the island using BMesh for idx in island: bm.verts[idx].select = True # Select edges and faces based on selected vertices for edge in bm.edges: if edge.verts[0].select and edge.verts[1].select: edge.select = True for face in bm.faces: if all(v.select for v in face.verts): face.select = True # Update the mesh bmesh.update_edit_mesh(mesh) return True, { 'affected_islands': affected_islands, 'expanded_count': expanded_count } def format_report(self, stats): if stats['object_count'] == 0: return "No objects with selected vertices found" return f"Expanded selection in {stats['affected_islands']} submeshes ({stats['expanded_count']} new vertices) across {stats['object_count']} object(s)" class MESH_OT_select_linked_across_boundaries(BaseSubmeshOperator, ToleranceOperatorMixin): bl_idname = "mesh.select_linked_across_boundaries" bl_label = "Select Linked (Cross Boundaries)" bl_description = "Select linked vertices, crossing submesh boundaries where vertices share locations" epsilon: FloatProperty( name="Location Tolerance", description="Maximum distance for vertices to be considered at the same location", default=0.0001, min=0.0, max=1.0, precision=6, subtype='DISTANCE' ) def build_combined_adjacency(self, bm): """Build adjacency including both edges and position-based connections""" # Build edge adjacency edge_adjacency = {v.index: set() for v in bm.verts} for edge in bm.edges: v0, v1 = edge.verts[0].index, edge.verts[1].index edge_adjacency[v0].add(v1) edge_adjacency[v1].add(v0) # Build position-based adjacency scale = self.get_scale_from_tolerance(self.epsilon) position_map = defaultdict(list) for v in bm.verts: key = tuple(int(v.co[i] * scale) for i in range(3)) position_map[key].append(v.index) position_adjacency = {} for vertices_at_pos in position_map.values(): if len(vertices_at_pos) > 1: for i, v1 in enumerate(vertices_at_pos): if v1 not in position_adjacency: position_adjacency[v1] = set() position_adjacency[v1].update(vertices_at_pos[i+1:]) for v2 in vertices_at_pos[i+1:]: if v2 not in position_adjacency: position_adjacency[v2] = set() position_adjacency[v2].add(v1) def combined_adjacency(vertex): neighbors = set() if vertex in edge_adjacency: neighbors.update(edge_adjacency[vertex]) if vertex in position_adjacency: neighbors.update(position_adjacency[vertex]) return neighbors return combined_adjacency def process_object(self, context, obj): mesh = obj.data # Get BMesh for edit mode operations bm = bmesh.from_edit_mesh(mesh) bm.verts.ensure_lookup_table() initially_selected = {v.index for v in bm.verts if v.select} if not initially_selected: return False, {} combined_adjacency = self.build_combined_adjacency(bm) visited = MeshUtils.flood_fill(initially_selected, combined_adjacency) # Select vertices using BMesh for idx in visited: bm.verts[idx].select = True # Select edges and faces based on selected vertices for edge in bm.edges: if edge.verts[0].select and edge.verts[1].select: edge.select = True for face in bm.faces: if all(v.select for v in face.verts): face.select = True # Update the mesh bmesh.update_edit_mesh(mesh) expanded_count = len(visited) - len(initially_selected) return True, { 'selected': len(visited), 'expanded': expanded_count } def format_report(self, stats): if stats['object_count'] == 0: return "No objects with selected vertices found" return f"Selected {stats['selected']} vertices ({stats['expanded']} new) across {stats['object_count']} object(s)" def draw(self, context): layout = self.layout layout.prop(self, "epsilon") layout.label(text="Connects vertices at same location", icon='INFO') class MESH_OT_deduplicate_submeshes(BaseSubmeshOperator, ToleranceOperatorMixin): bl_idname = "mesh.deduplicate_submeshes" bl_label = "Deduplicate Submeshes" bl_description = "Remove duplicate submeshes from selection that have vertices at the same locations" tolerance: FloatProperty( name="Position Tolerance", description="Maximum distance for vertices to be considered at the same position", default=0.0001, min=0.0, max=1.0, precision=6 ) def get_island_signature(self, island_verts): """Create a hash for an island based on vertex positions""" decimal_places = 6 if self.tolerance == 0 else max(0, int(-math.log10(self.tolerance))) positions = [] for v in island_verts: co = v.co rounded = tuple(round(co[i], decimal_places) for i in range(3)) positions.append(rounded) positions.sort() return tuple(positions) def process_object(self, context, obj): mesh = obj.data # Get BMesh for edit mode operations bm = bmesh.from_edit_mesh(mesh) bm.verts.ensure_lookup_table() # Get selected vertices selected_indices = {v.index for v in bm.verts if v.select} if not selected_indices: return False, {} # Build adjacency adjacency = {idx: set() for idx in selected_indices} for edge in bm.edges: v0, v1 = edge.verts[0].index, edge.verts[1].index if v0 in selected_indices and v1 in selected_indices: adjacency[v0].add(v1) adjacency[v1].add(v0) # Find islands island_indices = MeshUtils.find_islands(selected_indices, adjacency) if len(island_indices) <= 1: return False, {} # Create island vertex lists islands = [] for island_idx_set in island_indices: island_verts = [bm.verts[idx] for idx in island_idx_set] islands.append(island_verts) # Group islands by signature island_groups = defaultdict(list) for island in islands: signature = self.get_island_signature(island) island_groups[signature].append(island) vertices_to_delete = set() duplicate_count = 0 # Mark duplicate islands for deletion for group in island_groups.values(): if len(group) > 1: # Keep the first island, delete the rest for island in group[1:]: for v in island: vertices_to_delete.add(v) duplicate_count += 1 if not vertices_to_delete: return False, {} # Delete vertices using BMesh bmesh.ops.delete(bm, geom=list(vertices_to_delete), context='VERTS') # Update the mesh bmesh.update_edit_mesh(mesh) return True, { 'duplicates': duplicate_count, 'vertices_deleted': len(vertices_to_delete) } def format_report(self, stats): if stats['object_count'] == 0: return "No objects processed" return f"Removed {stats['duplicates']} duplicate submeshes ({stats['vertices_deleted']} vertices) across {stats['object_count']} object(s)" def draw(self, context): layout = self.layout layout.prop(self, "tolerance") layout.label(text="Set to 0 for exact matching", icon='INFO') class MESH_OT_select_hidden_faces(BaseSubmeshOperator, ToleranceOperatorMixin): bl_idname = "mesh.select_hidden_faces" bl_label = "Select Hidden Faces" bl_description = "Select faces that are hidden behind other faces (overlapping with opposing normals)" position_tolerance: FloatProperty( name="Position Tolerance", description="Maximum distance for vertices to be considered at the same position", default=0.001, min=0.0, max=0.1, precision=6, subtype='DISTANCE' ) normal_tolerance: FloatProperty( name="Normal Tolerance", description="Maximum angle difference for normals to be considered opposing", default=0.1, min=0.0, max=math.pi, precision=3, subtype='ANGLE' ) def process_object(self, context, obj): mesh = obj.data # Get BMesh for edit mode operations bm = bmesh.from_edit_mesh(mesh) bm.faces.ensure_lookup_table() selected_faces = [f for f in bm.faces if f.select] faces_to_check = selected_faces if selected_faces else list(bm.faces) if len(faces_to_check) < 2: return False, {} scale = self.get_scale_from_tolerance(self.position_tolerance) def get_center_hash_variations(center): """Get hash variations for a point near grid boundaries""" variations = [] boundary_threshold = 0.1 # If within 10% of grid boundary # For each dimension, determine which cells to hash to cells = [] for i in range(3): scaled = center[i] * scale floored = int(scaled) frac = scaled - floored if frac < boundary_threshold: # Near lower boundary, include previous cell cells.append([floored - 1, floored]) elif frac > (1.0 - boundary_threshold): # Near upper boundary, include next cell cells.append([floored, floored + 1]) else: # Not near boundary cells.append([floored]) # Generate all combinations (max 8 for a corner) for x in cells[0]: for y in cells[1]: for z in cells[2]: variations.append((x, y, z)) return variations def faces_match(face1, face2): """Check if two faces have matching vertices at same positions""" if len(face1.verts) != len(face2.verts): return False # For each vertex in face1, check if there's a matching vertex in face2 tolerance_sq = self.position_tolerance * self.position_tolerance for v1 in face1.verts: found_match = False for v2 in face2.verts: if (v1.co - v2.co).length_squared <= tolerance_sq: found_match = True break if not found_match: return False return True # Group faces by center position for finding candidates center_hash_map = defaultdict(list) face_data = {} for face in faces_to_check: center = face.calc_center_median() # Store face data face_data[face.index] = { 'normal': face.normal.normalized(), 'face': face, 'center': center } # Hash by center position (with boundary handling) center_variations = get_center_hash_variations(center) for center_hash in center_variations: center_hash_map[center_hash].append(face.index) hidden_faces = set() checked_pairs = 0 checked_face_pairs = set() # Check faces that have the same center hash for center_hash, face_indices in center_hash_map.items(): if len(face_indices) < 2: continue for i in range(len(face_indices)): for j in range(i + 1, len(face_indices)): face1_idx = face_indices[i] face2_idx = face_indices[j] # Skip if we've already checked this pair pair = (min(face1_idx, face2_idx), max(face1_idx, face2_idx)) if pair in checked_face_pairs: continue checked_face_pairs.add(pair) face1_data = face_data[face1_idx] face2_data = face_data[face2_idx] # Quick check: opposing normals dot_product = face1_data['normal'].dot(face2_data['normal']) if dot_product >= 0: continue # Check angle tolerance angle_diff = math.acos(min(1.0, max(-1.0, abs(dot_product)))) if angle_diff >= self.normal_tolerance: continue # Detailed vertex comparison checked_pairs += 1 if faces_match(face1_data['face'], face2_data['face']): hidden_faces.add(face1_idx) hidden_faces.add(face2_idx) # Select faces using BMesh for face_idx in hidden_faces: if face_idx in face_data: face_data[face_idx]['face'].select = True # Update the mesh bmesh.update_edit_mesh(mesh) return len(hidden_faces) > 0, { 'hidden_faces': len(hidden_faces), 'checked_faces': len(faces_to_check), 'checked_pairs': checked_pairs, 'hash_groups': len([g for g in center_hash_map.values() if len(g) > 1]) } def format_report(self, stats): if stats['object_count'] == 0: return "No objects processed" if stats['hidden_faces'] == 0: groups = stats.get('hash_groups', 0) pairs = stats.get('checked_pairs', 0) return f"No hidden faces found among {stats['checked_faces']} faces ({groups} overlapping groups, {pairs} pairs checked) in {stats['object_count']} object(s)" groups = stats.get('hash_groups', 0) pairs = stats.get('checked_pairs', 0) return f"Selected {stats['hidden_faces']} hidden faces from {stats['checked_faces']} faces ({groups} overlapping groups, {pairs} pairs checked) across {stats['object_count']} object(s)" def draw(self, context): layout = self.layout layout.prop(self, "position_tolerance") layout.prop(self, "normal_tolerance") layout.label(text="Selects overlapping faces with opposing normals", icon='INFO') class UVOperatorMixin: """Mixin for UV-related operations""" @classmethod def poll(cls, context): obj = context.active_object return (obj and obj.type == 'MESH' and context.mode == 'EDIT_MESH' and obj.data.uv_layers.active) def get_uv_islands(self, bm, uv_layer): """Find all UV islands in the mesh""" uv_vert_map = {} uv_adjacency = defaultdict(set) for face in bm.faces: if not face.select: continue face_uvs = [] for loop in face.loops: uv = loop[uv_layer].uv uv_key = (round(uv.x, 6), round(uv.y, 6)) face_uvs.append(uv_key) if uv_key not in uv_vert_map: uv_vert_map[uv_key] = set() uv_vert_map[uv_key].add(loop.vert.index) for i in range(len(face_uvs)): j = (i + 1) % len(face_uvs) uv_adjacency[face_uvs[i]].add(face_uvs[j]) uv_adjacency[face_uvs[j]].add(face_uvs[i]) islands = [] for start_uv in uv_vert_map: if any(start_uv in island['uvs'] for island in islands): continue island_uvs = MeshUtils.flood_fill([start_uv], lambda uv: uv_adjacency.get(uv, [])) island_vert_indices = set() for uv in island_uvs: island_vert_indices.update(uv_vert_map[uv]) islands.append({ 'uvs': island_uvs, 'vert_indices': island_vert_indices, 'loops': [] }) return islands class MESH_OT_pack_uv_islands_by_submesh_z(BaseSubmeshOperator, UVOperatorMixin): bl_idname = "mesh.pack_uv_islands_by_submesh_z" bl_label = "Pack UV Islands by Submesh Z" bl_description = "Pack UV islands vertically sorted by submesh Z position" padding: FloatProperty( name="Island Padding", description="Padding between UV islands", default=0.02, min=0.0, max=0.1, precision=3 ) max_islands_per_row: IntProperty( name="Max Islands Per Row", description="Maximum number of islands per row", default=100, min=1, max=1000 ) lock_overlapping: BoolProperty( name="Lock Overlapping Islands", description="Treat overlapping UV islands as a single island", default=False ) skip_overlap_check: BoolProperty( name="Skip Overlap Check", description="Skip overlap detection for better performance", default=False ) def execute(self, context): obj = context.active_object mesh = obj.data # Get BMesh and ensure we have a UV layer bm = bmesh.from_edit_mesh(mesh) bm.verts.ensure_lookup_table() bm.faces.ensure_lookup_table() if not bm.loops.layers.uv: self.report({'WARNING'}, "No UV layer found") return {'CANCELLED'} bm_uv_layer = bm.loops.layers.uv.active # Get UV islands uv_islands = self.get_uv_islands(bm, bm_uv_layer) if not uv_islands: self.report({'WARNING'}, "No UV islands found") return {'CANCELLED'} # Get selected vertices and build submeshes selected_indices = {v.index for v in bm.verts if v.select} if not selected_indices: self.report({'WARNING'}, "No vertices selected") return {'CANCELLED'} # Build adjacency adjacency = {idx: set() for idx in selected_indices} for edge in bm.edges: v0, v1 = edge.verts[0].index, edge.verts[1].index if v0 in selected_indices and v1 in selected_indices: adjacency[v0].add(v1) adjacency[v1].add(v0) # Find submeshes submesh_indices = MeshUtils.find_islands(selected_indices, adjacency) # Calculate average Z for each submesh submesh_z_values = [] for submesh_idx_set in submesh_indices: avg_z = sum(bm.verts[idx].co.z for idx in submesh_idx_set) / len(submesh_idx_set) submesh_z_values.append(avg_z) # Map vertices to submeshes vertex_to_submesh = {} for i, submesh_idx_set in enumerate(submesh_indices): for v_idx in submesh_idx_set: vertex_to_submesh[v_idx] = i # Build UV to loops mapping uv_to_loops = defaultdict(list) for face in bm.faces: if face.select: for loop in face.loops: uv = loop[bm_uv_layer].uv uv_key = (round(uv.x, 6), round(uv.y, 6)) uv_to_loops[uv_key].append(loop) # Process islands island_data = [] for island in uv_islands: island['loops'] = [] for uv_key in island['uvs']: island['loops'].extend(uv_to_loops.get(uv_key, [])) if not island['loops']: continue # Find submesh for this island submesh_idx = None for v_idx in island['vert_indices']: if v_idx in vertex_to_submesh: submesh_idx = vertex_to_submesh[v_idx] break if submesh_idx is not None: # Calculate bounds min_u = min_v = float('inf') max_u = max_v = float('-inf') for loop in island['loops']: uv = loop[bm_uv_layer].uv min_u = min(min_u, uv.x) max_u = max(max_u, uv.x) min_v = min(min_v, uv.y) max_v = max(max_v, uv.y) width = max_u - min_u height = max_v - min_v if width > 0 and height > 0: island_data.append({ 'island': island, 'submesh_z': submesh_z_values[submesh_idx], 'bounds': (min_u, min_v, max_u, max_v), 'width': width, 'height': height }) # Sort by Z position island_data.sort(key=lambda x: x['submesh_z'], reverse=True) # Pack islands total_area = sum((d['width'] + self.padding) * (d['height'] + self.padding) for d in island_data) target_size = min(0.95, math.sqrt(total_area) * 1.2) scale_factor = 0.95 / target_size if target_size > 1.0 else 1.0 current_v = 0.95 current_row = [] for data in island_data: width = data['width'] * scale_factor height = data['height'] * scale_factor # Check if we need to start a new row if current_row and (sum(d['width'] * scale_factor + self.padding for d in current_row) + width > target_size or len(current_row) >= self.max_islands_per_row): # Place current row current_u = 0.025 row_height = max(d['height'] * scale_factor for d in current_row) for row_data in current_row: offset_u = current_u - row_data['bounds'][0] * scale_factor offset_v = current_v - row_data['bounds'][3] * scale_factor for loop in row_data['island']['loops']: uv = loop[bm_uv_layer].uv uv.x = uv.x * scale_factor + offset_u uv.y = uv.y * scale_factor + offset_v current_u += row_data['width'] * scale_factor + self.padding current_v -= row_height + self.padding current_row = [] current_row.append(data) # Place final row if current_row: current_u = 0.025 for row_data in current_row: offset_u = current_u - row_data['bounds'][0] * scale_factor offset_v = current_v - row_data['bounds'][3] * scale_factor for loop in row_data['island']['loops']: uv = loop[bm_uv_layer].uv uv.x = uv.x * scale_factor + offset_u uv.y = uv.y * scale_factor + offset_v current_u += row_data['width'] * scale_factor + self.padding # Update the mesh bmesh.update_edit_mesh(mesh) self.report({'INFO'}, f"Packed {len(island_data)} UV islands from {len(submesh_indices)} submeshes") return {'FINISHED'} def draw(self, context): layout = self.layout layout.prop(self, "padding") layout.prop(self, "max_islands_per_row") layout.prop(self, "lock_overlapping") layout.prop(self, "skip_overlap_check") class MESH_OT_merge_by_distance_per_submesh(BaseSubmeshOperator): bl_idname = "mesh.merge_by_distance_per_submesh" bl_label = "Merge by Distance (Per Submesh)" bl_description = "Merge vertices by distance within each submesh separately" merge_distance: FloatProperty( name="Merge Distance", description="Maximum distance for merging vertices", default=0.001, min=0.0, max=1.0, precision=6, subtype='DISTANCE' ) def process_object(self, context, obj): mesh = obj.data bm = bmesh.from_edit_mesh(mesh) bm.verts.ensure_lookup_table() selected_verts = [v for v in bm.verts if v.select] if not selected_verts: return False, {} selected_set = set(selected_verts) island_verts = [] visited = set() for start_v in selected_verts: if start_v in visited: continue island = [] stack = [start_v] while stack: current = stack.pop() if current in visited: continue visited.add(current) island.append(current) for edge in current.link_edges: other = edge.other_vert(current) if other in selected_set and other not in visited: stack.append(other) island_verts.append(island) merge_targets = {} merge_dist_sq = self.merge_distance ** 2 merged_count = 0 for verts in island_verts: if len(verts) < 2: continue processed = set() for i, v1 in enumerate(verts): if v1 in merge_targets or v1 in processed: continue processed.add(v1) for v2 in verts[i+1:]: if v2 in merge_targets or v2 in processed: continue if (v1.co - v2.co).length_squared <= merge_dist_sq: merge_targets[v2] = v1 processed.add(v2) merged_count += 1 if merge_targets: targetmap = {v: merge_targets[v] for v in merge_targets if v.is_valid} if targetmap: bmesh.ops.weld_verts(bm, targetmap=targetmap) bmesh.update_edit_mesh(mesh) return merged_count > 0, {'merged': merged_count} def format_report(self, stats): if stats.get('merged', 0) > 0: return f"Merged {stats['merged']} vertices across {stats['object_count']} object(s)" else: return "No vertices close enough to merge" def draw(self, context): layout = self.layout layout.prop(self, "merge_distance") class MESH_OT_smart_uv_project_normal_groups(BaseSubmeshOperator, UVOperatorMixin): bl_idname = "mesh.smart_uv_project_normal_groups" bl_label = "Smart UV Project (Normal Groups)" bl_description = "Project UVs by grouping faces with similar normals across disconnected geometry" angle_threshold: FloatProperty( name="Angle Threshold", description="Maximum angle difference for faces to be grouped together", default=0.087266, min=0.0, max=math.pi/2, precision=3, subtype='ANGLE' ) gap_distance: FloatProperty( name="Gap Distance", description="Maximum distance to bridge gaps between faces", default=0.001, min=0.0, max=0.1, precision=6, subtype='DISTANCE' ) island_margin: FloatProperty( name="Island Margin", description="Margin between UV islands", default=0.01, min=0.0, max=0.5, precision=3 ) def build_face_spatial_cache(self, bm, selected_faces): """Build spatial cache for fast proximity queries""" face_centers = {} face_bounds = {} for face_idx in selected_faces: if face_idx >= len(bm.faces): continue face = bm.faces[face_idx] if face.hide: continue # Calculate center and bounds min_co = mathutils.Vector((float('inf'), float('inf'), float('inf'))) max_co = mathutils.Vector((float('-inf'), float('-inf'), float('-inf'))) center = mathutils.Vector((0, 0, 0)) for vert in face.verts: co = vert.co center += co min_co.x = min(min_co.x, co.x) min_co.y = min(min_co.y, co.y) min_co.z = min(min_co.z, co.z) max_co.x = max(max_co.x, co.x) max_co.y = max(max_co.y, co.y) max_co.z = max(max_co.z, co.z) center /= len(face.verts) face_centers[face_idx] = center face_bounds[face_idx] = (min_co, max_co) return face_centers, face_bounds def build_combined_adjacency(self, bm, selected_faces, face_centers, face_bounds): """Build adjacency including both edge connections and spatial proximity""" import time angle_threshold_cos = math.cos(self.angle_threshold) gap_dist_sq = self.gap_distance * self.gap_distance # Build edge-based adjacency edge_start = time.time() edge_adjacency = defaultdict(set) selected_set = set(selected_faces) # Convert to set for O(1) lookup for edge in bm.edges: if len(edge.link_faces) == 2: f1, f2 = edge.link_faces if f1.index in selected_set and f2.index in selected_set: # For edge connections, always connect if they share an edge # The angle threshold will be applied during island grouping edge_adjacency[f1.index].add(f2.index) edge_adjacency[f2.index].add(f1.index) print(f" Edge adjacency took {time.time() - edge_start:.3f}s") # Build spatial grid for efficient proximity queries grid_start = time.time() grid_size = max(self.gap_distance * 2, 0.01) spatial_grid = defaultdict(list) for face_idx in face_centers: center = face_centers[face_idx] grid_key = ( int(center.x / grid_size), int(center.y / grid_size), int(center.z / grid_size) ) # Add to neighboring grid cells as well for dx in [-1, 0, 1]: for dy in [-1, 0, 1]: for dz in [-1, 0, 1]: neighbor_key = ( grid_key[0] + dx, grid_key[1] + dy, grid_key[2] + dz ) spatial_grid[neighbor_key].append(face_idx) print(f" Spatial grid took {time.time() - grid_start:.3f}s") # Only build proximity adjacency if gap distance is significant proximity_adjacency = defaultdict(set) proximity_start = time.time() if self.gap_distance > 0.0001: # Build face-to-vertex mapping with spatial hashing face_vertex_grid = defaultdict(set) for face_idx in selected_faces: face = bm.faces[face_idx] # Add face to grid cells of all its vertices for vert in face.verts: v_key = ( int(vert.co.x / grid_size), int(vert.co.y / grid_size), int(vert.co.z / grid_size) ) face_vertex_grid[v_key].add(face_idx) # Find proximity connections processed_pairs = set() for face_idx in selected_faces: if face_idx not in face_centers: continue face = bm.faces[face_idx] face_normal = face.normal.normalized() # Find candidate faces through vertex proximity candidates = set() for vert in face.verts: v_key = ( int(vert.co.x / grid_size), int(vert.co.y / grid_size), int(vert.co.z / grid_size) ) # Check neighboring grid cells for dx in [-1, 0, 1]: for dy in [-1, 0, 1]: for dz in [-1, 0, 1]: neighbor_key = (v_key[0] + dx, v_key[1] + dy, v_key[2] + dz) candidates.update(face_vertex_grid.get(neighbor_key, set())) # Remove self candidates.discard(face_idx) # Check each candidate for other_idx in candidates: pair = (min(face_idx, other_idx), max(face_idx, other_idx)) if pair in processed_pairs: continue processed_pairs.add(pair) other_face = bm.faces[other_idx] # Check normal similarity first if face_normal.dot(other_face.normal.normalized()) < angle_threshold_cos: continue # Quick bounds check bounds1 = face_bounds[face_idx] bounds2 = face_bounds[other_idx] min_dist_sq = 0.0 for i in range(3): if bounds1[1][i] < bounds2[0][i]: d = bounds2[0][i] - bounds1[1][i] min_dist_sq += d * d elif bounds2[1][i] < bounds1[0][i]: d = bounds1[0][i] - bounds2[1][i] min_dist_sq += d * d if min_dist_sq > gap_dist_sq: continue # Check if any vertices are close found_close = False for v1 in face.verts: for v2 in other_face.verts: if (v1.co - v2.co).length_squared <= gap_dist_sq: found_close = True break if found_close: break if found_close: proximity_adjacency[face_idx].add(other_idx) proximity_adjacency[other_idx].add(face_idx) print(f" Proximity adjacency took {time.time() - proximity_start:.3f}s") # Combine adjacencies - ensure ALL selected faces are in the result combined = defaultdict(set) # First, add all selected faces (even isolated ones) for face_idx in selected_faces: combined[face_idx] = set() # Then add adjacency information for face_idx in edge_adjacency: combined[face_idx].update(edge_adjacency[face_idx]) for face_idx in proximity_adjacency: combined[face_idx].update(proximity_adjacency[face_idx]) return combined def find_face_groups_flood_fill(self, selected_faces, adjacency, bm): """Efficient flood fill to find connected face groups respecting angle threshold""" visited = set() groups = [] angle_threshold_cos = math.cos(self.angle_threshold) for start_face in selected_faces: if start_face in visited: continue # Flood fill from this face group = [] queue = deque([start_face]) while queue: face_idx = queue.popleft() if face_idx in visited: continue visited.add(face_idx) group.append(face_idx) # Get current face normal current_face = bm.faces[face_idx] current_normal = current_face.normal.normalized() # Add unvisited neighbors if their normals are similar enough for neighbor in adjacency.get(face_idx, []): if neighbor not in visited: neighbor_face = bm.faces[neighbor] neighbor_normal = neighbor_face.normal.normalized() # Check angle threshold if current_normal.dot(neighbor_normal) >= angle_threshold_cos: queue.append(neighbor) if group: groups.append(group) return groups def calculate_projection_matrix(self, face_group, bm): """Calculate optimal projection matrix for a group of faces""" if not face_group: return None, None # Calculate weighted average normal and center avg_normal = mathutils.Vector((0, 0, 0)) total_area = 0.0 center = mathutils.Vector((0, 0, 0)) for face_idx in face_group: if face_idx < len(bm.faces): face = bm.faces[face_idx] area = face.calc_area() if area > 0: avg_normal += face.normal * area total_area += area center += face.calc_center_median() * area if total_area <= 0: return None, None avg_normal /= total_area center /= total_area avg_normal.normalize() # Create orthonormal basis if abs(avg_normal.z) < 0.9: u_axis = mathutils.Vector((0, 0, 1)).cross(avg_normal) else: u_axis = mathutils.Vector((1, 0, 0)).cross(avg_normal) u_axis.normalize() v_axis = avg_normal.cross(u_axis) v_axis.normalize() # Create projection matrix projection_matrix = mathutils.Matrix(( (u_axis.x, u_axis.y, u_axis.z), (v_axis.x, v_axis.y, v_axis.z) )) return projection_matrix, center def project_faces_to_uv(self, face_group, projection_matrix, center, bm, uv_layer): """Project faces in a group to UV coordinates""" if not face_group or projection_matrix is None: return None # Project all vertices projected_uvs = {} min_uv = mathutils.Vector((float('inf'), float('inf'))) max_uv = mathutils.Vector((float('-inf'), float('-inf'))) loop_count = 0 for face_idx in face_group: if face_idx >= len(bm.faces): print(f" Warning: Invalid face index {face_idx}") continue face = bm.faces[face_idx] for loop in face.loops: relative_pos = loop.vert.co - center uv_2d = projection_matrix @ relative_pos uv = mathutils.Vector((uv_2d.x, uv_2d.y)) projected_uvs[loop] = uv min_uv.x = min(min_uv.x, uv.x) min_uv.y = min(min_uv.y, uv.y) max_uv.x = max(max_uv.x, uv.x) max_uv.y = max(max_uv.y, uv.y) loop_count += 1 if not projected_uvs: print(f" Warning: No UVs projected for group with {len(face_group)} faces") return None size = max_uv - min_uv if size.x <= 0 or size.y <= 0: print(f" Warning: Invalid UV size: {size} for group with {len(face_group)} faces") return None # Add some validation if size.x > 1000 or size.y > 1000: print(f" Warning: Extremely large UV size: {size} - this might indicate a projection issue") return { 'min_uv': min_uv, 'max_uv': max_uv, 'size': size, 'projected_uvs': projected_uvs, 'face_count': len(face_group), 'loop_count': loop_count, 'face_indices': face_group # Store face indices for 3D area calculation } def pack_uv_islands_growing(self, islands, bm, uv_layer): """Pack UV islands using an improved bin packing algorithm with uniform texel density""" import math if not islands: print("UV Packing: No islands to pack") return # Filter valid islands and calculate areas valid_islands = [] island_areas = [] total_3d_area = 0.0 total_uv_area = 0.0 for i, island in enumerate(islands): if island is not None: uv_area = island['size'].x * island['size'].y if uv_area > 0: # Calculate 3D surface area for this island surface_area_3d = 0.0 for face_idx in island.get('face_indices', []): if face_idx < len(bm.faces): surface_area_3d += bm.faces[face_idx].calc_area() # Store both UV and 3D areas island['uv_area'] = uv_area island['surface_area_3d'] = surface_area_3d total_3d_area += surface_area_3d total_uv_area += uv_area valid_islands.append(island) island_areas.append(uv_area) if not valid_islands: print("UV Packing: No valid islands after filtering") return # Calculate uniform texel density scale # The goal is to make UV area proportional to 3D surface area target_total_uv_area = 0.8 # Use 80% of UV space to leave room for margins # Calculate the scale needed to achieve uniform texel density uniform_scale = 1.0 if total_3d_area > 0: # Direct calculation: total UV area should equal target area # Each island's UV size should be proportional to sqrt(3D area) uniform_scale = math.sqrt(target_total_uv_area / total_3d_area) print(f"\nUV Packing Statistics:") print(f" Total islands: {len(valid_islands)}") print(f" Total 3D surface area: {total_3d_area:.6f}") print(f" Uniform texel density scale: {uniform_scale:.3f}") # Create histogram of island sizes (logarithmic bins) if island_areas: min_area = min(island_areas) max_area = max(island_areas) print(f" UV area range: {min_area:.6f} to {max_area:.6f}") if min_area > 0 and max_area > min_area: log_min = math.log10(min_area) log_max = math.log10(max_area) num_bins = min(10, len(valid_islands)) if log_max - log_min > 0.01: bins = [0] * num_bins bin_edges = [] for i in range(num_bins + 1): edge = log_min + (log_max - log_min) * i / num_bins bin_edges.append(10 ** edge) for area in island_areas: for i in range(num_bins): if bin_edges[i] <= area < bin_edges[i + 1]: bins[i] += 1 break else: bins[-1] += 1 print("\n Island size histogram (logarithmic):") for i in range(num_bins): print(f" [{bin_edges[i]:.6f} - {bin_edges[i+1]:.6f}): {bins[i]} islands") # Apply uniform scale to all islands for island in valid_islands: island['uniform_scale'] = uniform_scale island['scaled_size'] = mathutils.Vector(( island['size'].x * uniform_scale, island['size'].y * uniform_scale )) # Sort by height (tallest first) for better shelf packing indexed_islands = [(i, island, island['scaled_size'].y) for i, island in enumerate(valid_islands)] indexed_islands.sort(key=lambda x: x[2], reverse=True) # Debug: track pack order pack_order = [] # Use simple shelf packing with better space utilization shelves = [] packed_count = 0 # Calculate total area to determine target width for square packing total_area = 0.0 for island in valid_islands: width = island['scaled_size'].x + self.island_margin height = island['scaled_size'].y + self.island_margin total_area += width * height # Target width for approximately square result target_width = math.sqrt(total_area) * 1.1 # Add 10% for inefficiency # Width-constrained shelf packing shelves = [] packed_count = 0 current_y = self.island_margin # Pack islands for idx, (original_idx, island, sort_height) in enumerate(indexed_islands): width = island['scaled_size'].x + self.island_margin height = island['scaled_size'].y + self.island_margin # Skip if island is wider than target if width > target_width: print(f" Warning: Island {original_idx} width {width:.3f} exceeds target {target_width:.3f}") target_width = width + self.island_margin # Find shelf with space placed = False for shelf_idx, shelf in enumerate(shelves): if shelf['remaining_width'] >= width: # Height compatibility check height_ratio = height / shelf['height'] if shelf['height'] > 0 else float('inf') if 0.7 <= height_ratio <= 1.3: # Allow 30% height variation # Place on this shelf island['pack_position'] = mathutils.Vector((shelf['current_x'], shelf['y_position'])) if idx < 5: # Debug first few print(f" Island {idx} placed on shelf {shelf_idx} at ({shelf['current_x']:.3f}, {shelf['y_position']:.3f})") shelf['current_x'] += width shelf['remaining_width'] -= width packed_count += 1 placed = True pack_order.append((original_idx, island['pack_position'].copy())) break if not placed: # Create new shelf new_shelf = { 'y_position': current_y, 'height': height, 'current_x': width, # Start after this island 'remaining_width': target_width - width, } shelves.append(new_shelf) island['pack_position'] = mathutils.Vector((0, current_y)) if idx < 5: # Debug first few print(f" Island {idx} created new shelf at y={current_y:.3f}") current_y += height packed_count += 1 pack_order.append((original_idx, island['pack_position'].copy())) # Calculate final dimensions max_x = target_width max_y = current_y print(f"\n Packed {packed_count} islands with uniform texel density") print(f" Created {len(shelves)} shelves with target width {target_width:.3f}") # Calculate packing results if packed_count > 0: # Calculate efficiency total_island_area = sum(island['scaled_size'].x * island['scaled_size'].y for island in valid_islands) total_used_area = max_x * max_y efficiency = total_island_area / total_used_area if total_used_area > 0 else 0 aspect_ratio = max_x / max_y if max_y > 0 else 1.0 print(f" Pack dimensions: {max_x:.3f} x {max_y:.3f}") print(f" Aspect ratio: {aspect_ratio:.2f} (1.0 = perfect square)") print(f" Packing efficiency: {efficiency:.1%}") # Scale to fit in UV space scale_factor = min(0.95 / max_x, 0.95 / max_y) if max(max_x, max_y) > 0 else 1.0 # Apply scale to all positions for island in valid_islands: if 'pack_position' in island: island['pack_position'] *= scale_factor total_height = max_y * scale_factor final_scale = scale_factor else: total_height = 0 final_scale = 1.0 if total_height > 0.95: print(f" Warning: Not enough UV space for desired texel density. Consider increasing angle threshold.") # Apply UV coordinates with uniform texel density applied_count = 0 debug_islands = 0 for island in valid_islands: if 'pack_position' not in island: print(f" Warning: Island without pack position!") continue # Get the scale needed to match packed size position = island['pack_position'] * final_scale # Scale to match the packed size (which includes uniform scale) size_scale = island['uniform_scale'] * final_scale min_uv = island['min_uv'] # Debug first few islands if debug_islands < 3: expected_size = island['size'] * size_scale print(f"\n Debug island {debug_islands}:") print(f" Original size: {island['size']}") print(f" Scaled size: {island['scaled_size']}") print(f" Pack position: {island['pack_position']}") print(f" Final position: {position}") print(f" Size scale: {size_scale:.4f}") print(f" Expected final size: {expected_size}") debug_islands += 1 # Apply to all loops for loop, original_uv in island['projected_uvs'].items(): # Scale and translate the UV new_uv = (original_uv - min_uv) * size_scale + position loop[uv_layer].uv = new_uv applied_count += 1 print(f" Applied UVs to {applied_count} loops") print(f" Final UV space usage: {min(max_y * final_scale, 1.0):.1%}") print(f" Final texel density scale: {uniform_scale * final_scale:.4f}") # Debug: print first few islands in pack order if pack_order: print("\n First 5 islands in pack order:") for i in range(min(5, len(pack_order))): original_idx, position = pack_order[i] island = indexed_islands[i][1] # Get the island from sorted list print(f" Pack order {i}: orig_idx={original_idx}, size={island['scaled_size']}, pos={position}") print("") # Empty line for readability def process_object(self, context, obj): import time print(f"\nProcessing object: {obj.name}") start_time = time.time() bm = bmesh.from_edit_mesh(obj.data) bm.faces.ensure_lookup_table() # Get or create UV layer if not bm.loops.layers.uv: bm.loops.layers.uv.new("UVMap") print(" Created new UV layer") uv_layer = bm.loops.layers.uv.active if not uv_layer: print(" ERROR: No UV layer available") return False, {} # Get selected faces t1 = time.time() selected_faces = [] for face in bm.faces: if face.select and not face.hide: selected_faces.append(face.index) print(f" Selected faces: {len(selected_faces)} (took {time.time()-t1:.3f}s)") if not selected_faces: return False, {} # Build spatial cache t2 = time.time() face_centers, face_bounds = self.build_face_spatial_cache(bm, selected_faces) print(f" Built spatial cache for {len(face_centers)} faces (took {time.time()-t2:.3f}s)") # Build combined adjacency t3 = time.time() adjacency = self.build_combined_adjacency(bm, selected_faces, face_centers, face_bounds) print(f" Built adjacency (took {time.time()-t3:.3f}s)") # Count adjacency connections t4 = time.time() edge_connections = 0 proximity_connections = 0 isolated_faces = 0 for face_idx in selected_faces: neighbors = adjacency.get(face_idx, set()) if not neighbors: isolated_faces += 1 for neighbor in neighbors: # Check if they share an edge face = bm.faces[face_idx] neighbor_face = bm.faces[neighbor] shares_edge = False for edge in face.edges: if edge in neighbor_face.edges: shares_edge = True break if shares_edge: edge_connections += 1 else: proximity_connections += 1 print(f" Adjacency stats: {edge_connections//2} edge connections, {proximity_connections//2} proximity connections, {isolated_faces} isolated faces") print(f" (adjacency analysis took {time.time()-t4:.3f}s)") # Debug: print some adjacency info if len(selected_faces) > 0: sample_face = selected_faces[0] print(f" Debug - Face {sample_face} has {len(adjacency.get(sample_face, set()))} neighbors") # Find face groups using flood fill t5 = time.time() face_groups = self.find_face_groups_flood_fill(selected_faces, adjacency, bm) print(f" Found {len(face_groups)} face groups (took {time.time()-t5:.3f}s)") if face_groups: group_sizes = [len(g) for g in face_groups] print(f" Group sizes: min={min(group_sizes)}, max={max(group_sizes)}, avg={sum(group_sizes)/len(group_sizes):.1f}") if not face_groups: return False, {} # Process each group t6 = time.time() islands = [] processed_faces = 0 failed_projections = 0 # Only show warnings for first few failures max_warnings = 5 warning_count = 0 for i, group in enumerate(face_groups): projection_matrix, center = self.calculate_projection_matrix(group, bm) if projection_matrix is not None: island_data = self.project_faces_to_uv(group, projection_matrix, center, bm, uv_layer) if island_data: islands.append(island_data) processed_faces += island_data['face_count'] else: failed_projections += 1 if warning_count < max_warnings: print(f" Warning: Failed to project group {i} with {len(group)} faces") warning_count += 1 else: failed_projections += 1 if warning_count < max_warnings: print(f" Warning: Failed to calculate projection for group {i} with {len(group)} faces") warning_count += 1 print(f" Created {len(islands)} UV islands from {len(face_groups)} groups (took {time.time()-t6:.3f}s)") if failed_projections > 0: print(f" Failed projections: {failed_projections}") # Pack islands t7 = time.time() if islands: self.pack_uv_islands_growing(islands, bm, uv_layer) print(f" Packing completed (took {time.time()-t7:.3f}s)") else: print(" ERROR: No islands to pack!") bmesh.update_edit_mesh(obj.data) print(f" Total processing time: {time.time()-start_time:.3f}s") return True, { 'groups': len(face_groups), 'islands': len(islands), 'faces': processed_faces } def format_report(self, stats): if stats['object_count'] == 0: return "No objects processed" return f"Created {stats['islands']} UV islands from {stats['groups']} face groups ({stats['faces']} faces) across {stats['object_count']} object(s)" def draw(self, context): layout = self.layout layout.prop(self, "angle_threshold") layout.prop(self, "gap_distance") layout.prop(self, "island_margin") class MESH_OT_bake_origin_and_orientation_combined(BaseSubmeshOperator): bl_idname = "mesh.bake_submesh_origin_and_orientation" bl_label = "Bake Submesh Data" bl_description = "Bake vertex vectors and orientation quaternions" contiguous_mode: BoolProperty( name="Contiguous Groups", description="Process each contiguous group separately", default=True ) normal_epsilon: FloatProperty( name="Normal Epsilon", description="Minimum angle difference between normals", default=0.1, min=0.01, max=1.0, precision=3 ) use_cache: BoolProperty( name="Cache Identical Submeshes", description="Cache calculations for identical submeshes to avoid recomputing basis and scale", default=True ) def calculate_island_center(self, vertices): """Calculate center for an island""" if not vertices: return None center = mathutils.Vector((0.0, 0.0, 0.0)) for v in vertices: center += v.co center /= len(vertices) return center def calculate_island_scale(self, vertices, center, basis_inv): """Calculate scale using L-infinity norm in rotated basis""" if not vertices: return 1.0 max_coord = 0.0 for v in vertices: offset = v.co - center local_pos = basis_inv @ offset max_coord = max(max_coord, abs(local_pos.x), abs(local_pos.y), abs(local_pos.z)) scale = 1.0 / max_coord if max_coord > 0 else 1.0 return scale def build_basis_from_faces(self, faces, epsilon): """Build orthonormal basis from face normals""" if not faces: return mathutils.Matrix.Identity(3) # Sort faces by area sorted_faces = sorted(faces, key=lambda f: f.calc_area(), reverse=True) x_axis = sorted_faces[0].normal.normalized() epsilon_cos = math.cos(epsilon) y_axis = None for face in sorted_faces[1:]: normal = face.normal.normalized() if abs(normal.dot(x_axis)) < epsilon_cos: y_axis = normal - normal.dot(x_axis) * x_axis y_axis.normalize() break if not y_axis: if abs(x_axis.z) < 0.9: y_axis = mathutils.Vector((-x_axis.y, x_axis.x, 0)) else: y_axis = mathutils.Vector((0, -x_axis.z, x_axis.y)) y_axis.normalize() z_axis = x_axis.cross(y_axis) matrix = mathutils.Matrix((x_axis, y_axis, z_axis)).transposed() if matrix.determinant() < 0: matrix[2] = -matrix[2] return matrix def create_submesh_signature(self, vertices, center): """Create signature for caching - based on relative positions only""" tolerance = 0.0001 relative_positions = [] for v in vertices: relative_pos = v.co - center rounded = tuple(round(relative_pos[i] / tolerance) * tolerance for i in range(3)) relative_positions.append(rounded) relative_positions.sort() return (len(vertices), tuple(relative_positions)) def process_object(self, context, obj): mesh = obj.data # Switch to object mode temporarily to ensure vertex colors exist bpy.ops.object.mode_set(mode='OBJECT') if not mesh.vertex_colors: mesh.vertex_colors.new(name="BakedVectors") color_layer = mesh.vertex_colors.active uv_layer0 = MeshUtils.get_or_create_uv_layer(mesh, "BakedOriginAngle0") uv_layer1 = MeshUtils.get_or_create_uv_layer(mesh, "BakedOriginAngle1") # Switch back to edit mode bpy.ops.object.mode_set(mode='EDIT') # Get BMesh for edit mode operations bm = bmesh.from_edit_mesh(mesh) bm.verts.ensure_lookup_table() bm.faces.ensure_lookup_table() # Get vertex color and UV layers in BMesh if not bm.loops.layers.color: bm.loops.layers.color.new("BakedVectors") bm_color_layer = bm.loops.layers.color.active uv_layers = bm.loops.layers.uv bm_uv_layer0 = uv_layers.get("BakedOriginAngle0") bm_uv_layer1 = uv_layers.get("BakedOriginAngle1") if not bm_uv_layer0: bm_uv_layer0 = uv_layers.new("BakedOriginAngle0") if not bm_uv_layer1: bm_uv_layer1 = uv_layers.new("BakedOriginAngle1") selected_verts = [v for v in bm.verts if v.select] if not selected_verts: return False, {} settings = context.scene.bake_vertex_settings correction = mathutils.Euler( (settings.correction_angle_x, settings.correction_angle_y, settings.correction_angle_z), 'XYZ' ).to_quaternion() # Get selected faces selected_faces = [] for face in bm.faces: if all(v.select for v in face.verts): selected_faces.append(face) # Build islands using BMesh vertices if self.contiguous_mode: selected_indices = {v.index for v in selected_verts} adjacency = {v.index: set() for v in selected_verts} for edge in bm.edges: v0, v1 = edge.verts[0].index, edge.verts[1].index if v0 in selected_indices and v1 in selected_indices: adjacency[v0].add(v1) adjacency[v1].add(v0) island_indices = MeshUtils.find_islands(selected_indices, adjacency) islands = [] for island_idx_set in island_indices: island_verts = [bm.verts[idx] for idx in island_idx_set] islands.append(island_verts) else: islands = [selected_verts] world_matrix = obj.matrix_world world_inv = world_matrix.inverted() submesh_cache = {} if self.use_cache else None for island_verts in islands: center = self.calculate_island_center(island_verts) if center is None: continue cache_hit = False if self.use_cache: signature = self.create_submesh_signature(island_verts, center) if signature in submesh_cache: scale, basis, quat, basis_inv = submesh_cache[signature] cache_hit = True if not cache_hit: # Get faces for this island island_faces = [] island_vert_set = set(island_verts) for face in selected_faces: if all(v in island_vert_set for v in face.verts): island_faces.append(face) basis = self.build_basis_from_faces(island_faces, self.normal_epsilon) basis_inv = basis.inverted() scale = self.calculate_island_scale(island_verts, center, basis_inv) quat = basis.to_quaternion() quat.normalize() if quat.w < 0: quat.negate() quat = correction @ quat if self.use_cache: submesh_cache[signature] = (scale, basis, quat, basis_inv) center_world = world_matrix @ center # Apply to each vertex in the island for vert in island_verts: vert_world = world_matrix @ vert.co offset = world_inv.to_3x3() @ (vert_world - center_world) local_pos = basis_inv @ offset color = mathutils.Vector(( (local_pos.x * scale + 1.0) * 0.5, (local_pos.y * scale + 1.0) * 0.5, (local_pos.z * scale + 1.0) * 0.5, scale )) # Apply to all loops of this vertex for face in vert.link_faces: if face.select: for loop in face.loops: if loop.vert == vert: loop[bm_color_layer] = color loop[bm_uv_layer0].uv = (quat.x, quat.y) loop[bm_uv_layer1].uv = (quat.z, quat.w) # Update the mesh bmesh.update_edit_mesh(mesh) return True, { 'islands': len(islands), 'vertices': len(selected_verts) } def format_report(self, stats): if stats['object_count'] == 0: return "No objects processed" return f"Baked {stats['islands']} island(s) with {stats['vertices']} vertices across {stats['object_count']} object(s)" def draw(self, context): layout = self.layout layout.prop(self, "contiguous_mode") layout.prop(self, "use_cache") layout.prop(self, "normal_epsilon") settings = context.scene.bake_vertex_settings box = layout.box() box.label(text="Bake Rotation Correction (Degrees)") row = box.row(align=True) row.prop(settings, "correction_angle_x") row.prop(settings, "correction_angle_y") row.prop(settings, "correction_angle_z") class MESH_PT_bake_vertex_panel(Panel): bl_label = "Bake Submesh Data" bl_idname = "MESH_PT_bake_submesh_data" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = "Tool" def draw(self, context): layout = self.layout obj = context.active_object if obj and obj.type == 'MESH' and context.mode == 'EDIT_MESH': col = layout.column() col.operator("mesh.bake_submesh_origin_and_orientation", icon='EXPORT') col.operator("mesh.select_all_linked", icon='SELECT_EXTEND') col.operator("mesh.select_linked_across_boundaries", icon='LINKED') col.operator("mesh.select_hidden_faces", icon='GHOST_ENABLED') col.operator("mesh.deduplicate_submeshes", icon='DUPLICATE') col.operator("mesh.merge_by_distance_per_submesh", icon='AUTOMERGE_ON') col.operator("mesh.pack_uv_islands_by_submesh_z", icon='UV') col.operator("mesh.smart_uv_project_normal_groups", icon='UV_DATA') else: layout.label(text="Enter Edit Mode to use tools", icon='INFO') classes = [ BakeVertexSettings, MESH_OT_select_all_linked, MESH_OT_select_linked_across_boundaries, MESH_OT_deduplicate_submeshes, MESH_OT_select_hidden_faces, MESH_OT_pack_uv_islands_by_submesh_z, MESH_OT_merge_by_distance_per_submesh, MESH_OT_smart_uv_project_normal_groups, MESH_OT_bake_origin_and_orientation_combined, MESH_PT_bake_vertex_panel ] def menu_func(self, context): self.layout.separator() self.layout.operator("mesh.select_all_linked", icon='SELECT_EXTEND') self.layout.operator("mesh.select_linked_across_boundaries", icon='LINKED') self.layout.operator("mesh.select_hidden_faces", icon='GHOST_ENABLED') self.layout.operator("mesh.deduplicate_submeshes", icon='DUPLICATE') self.layout.operator("mesh.merge_by_distance_per_submesh", icon='AUTOMERGE_ON') self.layout.operator("mesh.pack_uv_islands_by_submesh_z", icon='UV') self.layout.operator("mesh.smart_uv_project_normal_groups", icon='UV_DATA') self.layout.operator("mesh.bake_submesh_origin_and_orientation", icon='EXPORT') def register(): for cls in classes: bpy.utils.register_class(cls) bpy.types.VIEW3D_MT_edit_mesh.append(menu_func) bpy.types.Scene.bake_vertex_settings = PointerProperty(type=BakeVertexSettings) def unregister(): bpy.types.VIEW3D_MT_edit_mesh.remove(menu_func) del bpy.types.Scene.bake_vertex_settings for cls in reversed(classes): bpy.utils.unregister_class(cls) if __name__ == "__main__": register()