diff options
| author | yum <yum.food.vr@gmail.com> | 2025-06-13 03:12:56 -0700 |
|---|---|---|
| committer | yum <yum.food.vr@gmail.com> | 2025-06-13 03:12:56 -0700 |
| commit | fb0bf6553eecb64f99c98a9f628d5fbd84be2e9a (patch) | |
| tree | c3d104181265e6029af4ef24a4274e9d7837aebd /Scripts/BakeVertexData.py | |
| parent | 9e5f80c045a648348bdb3b36b8181b928dfbb0ba (diff) | |
More c30 work
Add ability to select hidden faces, and begin work on smart UV
projection algo
Diffstat (limited to 'Scripts/BakeVertexData.py')
| -rw-r--r-- | Scripts/BakeVertexData.py | 1494 |
1 files changed, 1267 insertions, 227 deletions
diff --git a/Scripts/BakeVertexData.py b/Scripts/BakeVertexData.py index 3eecf05..1f11e06 100644 --- a/Scripts/BakeVertexData.py +++ b/Scripts/BakeVertexData.py @@ -65,36 +65,25 @@ class MeshUtils: @staticmethod def with_multi_object_support(process_func_name='process_object'): - """Decorator to add multi-object support to operators - - The decorated execute method should return a tuple of (success, stats_dict) - where stats_dict contains the statistics to aggregate across objects. - """ + """Decorator to add multi-object support to operators""" def decorator(func): def wrapper(self, context, *args, **kwargs): - # Store the original active object original_active = context.active_object - - # Get all selected mesh objects 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'} - # Initialize aggregated stats total_stats = {} processed_count = 0 - # Process each selected object for obj in selected_objects: - # Make this object active temporarily context.view_layer.objects.active = obj - # Call the actual processing method if hasattr(self, process_func_name): success, stats = getattr(self, process_func_name)(context, obj) else: - # Fallback to calling the decorated function result = func(self, context, obj, *args, **kwargs) if isinstance(result, tuple) and len(result) == 2: success, stats = result @@ -103,23 +92,18 @@ class MeshUtils: if success: processed_count += 1 - # Aggregate statistics for key, value in stats.items(): if isinstance(value, (int, float)): total_stats[key] = total_stats.get(key, 0) + value else: - # For non-numeric values, just store the last one total_stats[key] = value - # Restore original active object context.view_layer.objects.active = original_active - # Report results total_stats['object_count'] = processed_count if hasattr(self, 'format_report'): message = self.format_report(total_stats) else: - # Default reporting if processed_count == 0: self.report({'WARNING'}, "No objects processed") return {'CANCELLED'} @@ -196,9 +180,27 @@ class MeshUtils: 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""" + """Base class for submesh operations with common functionality""" bl_options = {'REGISTER', 'UNDO'} @classmethod @@ -214,6 +216,18 @@ class BaseSubmeshOperator(Operator): 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" @@ -221,15 +235,26 @@ class MESH_OT_select_all_linked(BaseSubmeshOperator): bl_description = "Select all vertices in any submesh that has at least one vertex selected" def process_object(self, context, obj): - """Process a single object and return (success, stats)""" mesh = obj.data - initially_selected = MeshUtils.get_selected_vertices(mesh) + + # 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, {} - all_vertices = set(range(len(mesh.vertices))) - adjacency = MeshUtils.build_adjacency(mesh, all_vertices) + # 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 @@ -241,10 +266,21 @@ class MESH_OT_select_all_linked(BaseSubmeshOperator): if new_selections: expanded_count += len(new_selections) affected_islands += 1 + # Select all vertices in the island using BMesh for idx in island: - mesh.vertices[idx].select = True + bm.verts[idx].select = True - MeshUtils.select_edges_and_faces(mesh) + # 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, @@ -252,19 +288,12 @@ class MESH_OT_select_all_linked(BaseSubmeshOperator): } def format_report(self, stats): - """Format the aggregated statistics into a report message""" 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)" - @MeshUtils.with_mode('OBJECT') - @MeshUtils.with_multi_object_support('process_object') - def execute(self, context): - # The decorator handles everything - pass - -class MESH_OT_select_linked_across_boundaries(BaseSubmeshOperator): +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" @@ -279,15 +308,21 @@ class MESH_OT_select_linked_across_boundaries(BaseSubmeshOperator): subtype='DISTANCE' ) - def build_combined_adjacency(self, mesh): + def build_combined_adjacency(self, bm): """Build adjacency including both edges and position-based connections""" - edge_adjacency = MeshUtils.build_adjacency(mesh, set(range(len(mesh.vertices)))) + # 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 adjacency - scale = min(1.0 / self.epsilon, 1e7) if self.epsilon > 0 else 1e7 + # Build position-based adjacency + scale = self.get_scale_from_tolerance(self.epsilon) position_map = defaultdict(list) - - for v in mesh.vertices: + + for v in bm.verts: key = tuple(int(v.co[i] * scale) for i in range(3)) position_map[key].append(v.index) @@ -303,7 +338,6 @@ class MESH_OT_select_linked_across_boundaries(BaseSubmeshOperator): position_adjacency[v2] = set() position_adjacency[v2].add(v1) - # Combine adjacencies def combined_adjacency(vertex): neighbors = set() if vertex in edge_adjacency: @@ -315,20 +349,36 @@ class MESH_OT_select_linked_across_boundaries(BaseSubmeshOperator): return combined_adjacency def process_object(self, context, obj): - """Process a single object and return (success, stats)""" mesh = obj.data - initially_selected = MeshUtils.get_selected_vertices(mesh) + + # 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(mesh) + combined_adjacency = self.build_combined_adjacency(bm) visited = MeshUtils.flood_fill(initially_selected, combined_adjacency) + # Select vertices using BMesh for idx in visited: - mesh.vertices[idx].select = True + 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 - MeshUtils.select_edges_and_faces(mesh) + # Update the mesh + bmesh.update_edit_mesh(mesh) + expanded_count = len(visited) - len(initially_selected) return True, { @@ -337,24 +387,17 @@ class MESH_OT_select_linked_across_boundaries(BaseSubmeshOperator): } def format_report(self, stats): - """Format the aggregated statistics into a report message""" 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)" - @MeshUtils.with_mode('OBJECT') - @MeshUtils.with_multi_object_support('process_object') - def execute(self, context): - # The decorator handles everything - pass - 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): +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" @@ -368,13 +411,13 @@ class MESH_OT_deduplicate_submeshes(BaseSubmeshOperator): precision=6 ) - def get_island_signature(self, mesh, island_indices): + 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 idx in island_indices: - co = mesh.vertices[idx].co + for v in island_verts: + co = v.co rounded = tuple(round(co[i], decimal_places) for i in range(3)) positions.append(rounded) @@ -382,43 +425,63 @@ class MESH_OT_deduplicate_submeshes(BaseSubmeshOperator): return tuple(positions) def process_object(self, context, obj): - """Process a single object and return (success, stats)""" mesh = obj.data - islands = self.get_selected_submeshes(mesh) - - if len(islands) <= 1: + + # 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(mesh, island) + signature = self.get_island_signature(island) island_groups[signature].append(island) - # Find duplicates to remove 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:]: - vertices_to_delete.update(island) + for v in island: + vertices_to_delete.add(v) duplicate_count += 1 if not vertices_to_delete: return False, {} - # Select vertices to delete - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.mesh.select_all(action='DESELECT') - bpy.ops.object.mode_set(mode='OBJECT') - - for idx in vertices_to_delete: - mesh.vertices[idx].select = True - - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.mesh.delete(type='VERT') - bpy.ops.object.mode_set(mode='OBJECT') + # 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, @@ -426,64 +489,208 @@ class MESH_OT_deduplicate_submeshes(BaseSubmeshOperator): } def format_report(self, stats): - """Format the aggregated statistics into a report message""" if stats['object_count'] == 0: - return "No duplicate submeshes found" + return "No objects processed" + return f"Removed {stats['duplicates']} duplicate submeshes ({stats['vertices_deleted']} vertices) across {stats['object_count']} object(s)" - @MeshUtils.with_mode('OBJECT') - @MeshUtils.with_multi_object_support('process_object') - def execute(self, context): - # The decorator handles everything - pass - 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_pack_uv_islands_by_submesh_z(BaseSubmeshOperator): - 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" +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)" - padding: FloatProperty( - name="Island Padding", - description="Padding between UV islands", - default=0.02, + 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=3 + precision=6, + subtype='DISTANCE' ) - max_islands_per_row: IntProperty( - name="Max Islands Per Row", - description="Maximum number of islands per row", - default=100, - min=1, - max=1000 + 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' ) - lock_overlapping: BoolProperty( - name="Lock Overlapping Islands", - description="Treat overlapping UV islands as a single island", - default=False - ) + 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, {} - skip_overlap_check: BoolProperty( - name="Skip Overlap Check", - description="Skip overlap detection for better performance", - default=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 = {} @@ -503,13 +710,11 @@ class MESH_OT_pack_uv_islands_by_submesh_z(BaseSubmeshOperator): uv_vert_map[uv_key] = set() uv_vert_map[uv_key].add(loop.vert.index) - # Create UV edges 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]) - # Find connected components islands = [] for start_uv in uv_vert_map: if any(start_uv in island['uvs'] for island in islands): @@ -528,11 +733,54 @@ class MESH_OT_pack_uv_islands_by_submesh_z(BaseSubmeshOperator): 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 @@ -541,24 +789,34 @@ class MESH_OT_pack_uv_islands_by_submesh_z(BaseSubmeshOperator): self.report({'WARNING'}, "No UV islands found") return {'CANCELLED'} - # Get submeshes and their Z values - bpy.ops.object.mode_set(mode='OBJECT') - submeshes = self.get_selected_submeshes(mesh) - + # 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 in submeshes: - avg_z = sum(mesh.vertices[idx].co.z for idx in submesh) / len(submesh) + 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) - # Build vertex to submesh mapping + # Map vertices to submeshes vertex_to_submesh = {} - for i, submesh in enumerate(submeshes): - for v in submesh: - vertex_to_submesh[v] = i - - bpy.ops.object.mode_set(mode='EDIT') - bm = bmesh.from_edit_mesh(mesh) - bm_uv_layer = bm.loops.layers.uv.active + 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) @@ -569,7 +827,7 @@ class MESH_OT_pack_uv_islands_by_submesh_z(BaseSubmeshOperator): uv_key = (round(uv.x, 6), round(uv.y, 6)) uv_to_loops[uv_key].append(loop) - # Assign loops and create island data + # Process islands island_data = [] for island in uv_islands: island['loops'] = [] @@ -579,7 +837,7 @@ class MESH_OT_pack_uv_islands_by_submesh_z(BaseSubmeshOperator): if not island['loops']: continue - # Find submesh + # Find submesh for this island submesh_idx = None for v_idx in island['vert_indices']: if v_idx in vertex_to_submesh: @@ -587,7 +845,7 @@ class MESH_OT_pack_uv_islands_by_submesh_z(BaseSubmeshOperator): break if submesh_idx is not None: - # Get bounds + # Calculate bounds min_u = min_v = float('inf') max_u = max_v = float('-inf') @@ -610,16 +868,15 @@ class MESH_OT_pack_uv_islands_by_submesh_z(BaseSubmeshOperator): 'height': height }) - # Sort by submesh Z + # Sort by Z position island_data.sort(key=lambda x: x['submesh_z'], reverse=True) - # Calculate scale + # 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 - # Pack islands current_v = 0.95 current_row = [] @@ -627,7 +884,7 @@ class MESH_OT_pack_uv_islands_by_submesh_z(BaseSubmeshOperator): width = data['width'] * scale_factor height = data['height'] * scale_factor - # Start new row if needed + # 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 @@ -664,8 +921,10 @@ class MESH_OT_pack_uv_islands_by_submesh_z(BaseSubmeshOperator): 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(submeshes)} submeshes") + + self.report({'INFO'}, f"Packed {len(island_data)} UV islands from {len(submesh_indices)} submeshes") return {'FINISHED'} def draw(self, context): @@ -692,15 +951,14 @@ class MESH_OT_merge_by_distance_per_submesh(BaseSubmeshOperator): ) def process_object(self, context, obj): - """Process a single object and return (success, stats)""" 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, {} - # Build islands using BMesh selected_set = set(selected_verts) island_verts = [] visited = set() @@ -709,7 +967,6 @@ class MESH_OT_merge_by_distance_per_submesh(BaseSubmeshOperator): if start_v in visited: continue - # Find island island = [] stack = [start_v] @@ -727,7 +984,6 @@ class MESH_OT_merge_by_distance_per_submesh(BaseSubmeshOperator): island_verts.append(island) - # Merge within each island merge_targets = {} merge_dist_sq = self.merge_distance ** 2 merged_count = 0 @@ -751,7 +1007,6 @@ class MESH_OT_merge_by_distance_per_submesh(BaseSubmeshOperator): processed.add(v2) merged_count += 1 - # Perform merges if merge_targets: targetmap = {v: merge_targets[v] for v in merge_targets if v.is_valid} if targetmap: @@ -762,22 +1017,783 @@ class MESH_OT_merge_by_distance_per_submesh(BaseSubmeshOperator): return merged_count > 0, {'merged': merged_count} def format_report(self, stats): - """Format the aggregated statistics into a report message""" 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" - @MeshUtils.with_multi_object_support('process_object') - def execute(self, context): - # The decorator handles everything - pass - 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" @@ -804,50 +1820,47 @@ class MESH_OT_bake_origin_and_orientation_combined(BaseSubmeshOperator): default=True ) - def calculate_island_center(self, mesh, island_indices): + def calculate_island_center(self, vertices): """Calculate center for an island""" - if not island_indices: + if not vertices: return None center = mathutils.Vector((0.0, 0.0, 0.0)) - for idx in island_indices: - center += mesh.vertices[idx].co - center /= len(island_indices) + for v in vertices: + center += v.co + center /= len(vertices) return center - def calculate_island_scale(self, mesh, island_indices, center, basis_inv): + def calculate_island_scale(self, vertices, center, basis_inv): """Calculate scale using L-infinity norm in rotated basis""" - if not island_indices: + if not vertices: return 1.0 max_coord = 0.0 - for idx in island_indices: - # Transform to local rotated basis - offset = mesh.vertices[idx].co - center + for v in vertices: + offset = v.co - center local_pos = basis_inv @ offset - # L-infinity norm: max of absolute values 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, mesh, face_indices, epsilon): + def build_basis_from_faces(self, faces, epsilon): """Build orthonormal basis from face normals""" - if not face_indices: + if not faces: return mathutils.Matrix.Identity(3) - # Get largest face normal - faces = sorted(face_indices, key=lambda i: mesh.polygons[i].area, reverse=True) - x_axis = mesh.polygons[faces[0]].normal.normalized() + # Sort faces by area + sorted_faces = sorted(faces, key=lambda f: f.calc_area(), reverse=True) + x_axis = sorted_faces[0].normal.normalized() - # Find second normal epsilon_cos = math.cos(epsilon) y_axis = None - for face_idx in faces[1:]: - normal = mesh.polygons[face_idx].normal.normalized() + 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() @@ -868,112 +1881,135 @@ class MESH_OT_bake_origin_and_orientation_combined(BaseSubmeshOperator): return matrix - def create_submesh_signature(self, mesh, island_indices, center): + def create_submesh_signature(self, vertices, center): """Create signature for caching - based on relative positions only""" tolerance = 0.0001 relative_positions = [] - for idx in island_indices: - relative_pos = mesh.vertices[idx].co - center - # Round to tolerance to handle floating point precision + 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(island_indices), tuple(relative_positions)) + return (len(vertices), tuple(relative_positions)) def process_object(self, context, obj): - """Process a single object and return (success, stats)""" mesh = obj.data - selected = MeshUtils.get_selected_vertices(mesh) - - if not selected: - return False, {} - - # Create/get data layers + + # 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 = mesh.uv_layers.get("BakedOriginAngle0") or mesh.uv_layers.new(name="BakedOriginAngle0") - uv_layer1 = mesh.uv_layers.get("BakedOriginAngle1") or mesh.uv_layers.new(name="BakedOriginAngle1") + 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, {} - # Get correction quaternion settings = context.scene.bake_vertex_settings correction = mathutils.Euler( (settings.correction_angle_x, settings.correction_angle_y, settings.correction_angle_z), 'XYZ' ).to_quaternion() - # Build vertex to loops mapping - vertex_loops = defaultdict(list) + # Get selected faces selected_faces = [] + for face in bm.faces: + if all(v.select for v in face.verts): + selected_faces.append(face) - for poly in mesh.polygons: - face_verts = set(poly.vertices) - if face_verts & selected: - if face_verts <= selected: - selected_faces.append(poly.index) - - for loop_idx in poly.loop_indices: - vert_idx = mesh.loops[loop_idx].vertex_index - if vert_idx in selected: - vertex_loops[vert_idx].append(loop_idx) - - # Get islands - islands = self.get_selected_submeshes(mesh) if self.contiguous_mode else [selected] + # 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] - # Process each island world_matrix = obj.matrix_world world_inv = world_matrix.inverted() - # Cache for storing computed basis, scale, and quaternion for identical submeshes - # Key: geometry signature, Value: (scale, basis, quaternion, basis_inverse) submesh_cache = {} if self.use_cache else None - for island in islands: - center = self.calculate_island_center(mesh, island) + for island_verts in islands: + center = self.calculate_island_center(island_verts) if center is None: continue - # Check cache first (if enabled) cache_hit = False if self.use_cache: - signature = self.create_submesh_signature(mesh, island, center) + signature = self.create_submesh_signature(island_verts, center) if signature in submesh_cache: scale, basis, quat, basis_inv = submesh_cache[signature] cache_hit = True - # Only calculate if not in cache if not cache_hit: - # Calculate basis (compute-intensive) - island_faces = [f for f in selected_faces - if all(v in island for v in mesh.polygons[f].vertices)] - basis = self.build_basis_from_faces(mesh, island_faces, self.normal_epsilon) + # 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() - # Calculate scale using L-infinity norm in rotated basis (compute-intensive) - scale = self.calculate_island_scale(mesh, island, center, basis_inv) + scale = self.calculate_island_scale(island_verts, center, basis_inv) - # Calculate quaternion quat = basis.to_quaternion() quat.normalize() if quat.w < 0: quat.negate() quat = correction @ quat - # Store in cache if enabled if self.use_cache: submesh_cache[signature] = (scale, basis, quat, basis_inv) - # Transform vertices center_world = world_matrix @ center - for vert_idx in island: - vert_world = world_matrix @ mesh.vertices[vert_idx].co + # 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 - # Scale and convert to color color = mathutils.Vector(( (local_pos.x * scale + 1.0) * 0.5, (local_pos.y * scale + 1.0) * 0.5, @@ -981,31 +2017,29 @@ class MESH_OT_bake_origin_and_orientation_combined(BaseSubmeshOperator): scale )) - # Apply to loops - for loop_idx in vertex_loops.get(vert_idx, []): - color_layer.data[loop_idx].color = color - uv_layer0.data[loop_idx].uv = (quat.x, quat.y) - uv_layer1.data[loop_idx].uv = (quat.z, quat.w) + # 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) - mesh.update() + # Update the mesh + bmesh.update_edit_mesh(mesh) return True, { 'islands': len(islands), - 'vertices': len(selected) + 'vertices': len(selected_verts) } def format_report(self, stats): - """Format the aggregated statistics into a report message""" if stats['object_count'] == 0: - return "No objects with selected vertices found" + return "No objects processed" + return f"Baked {stats['islands']} island(s) with {stats['vertices']} vertices across {stats['object_count']} object(s)" - @MeshUtils.with_mode('OBJECT') - @MeshUtils.with_multi_object_support('process_object') - def execute(self, context): - # The decorator handles everything - pass - def draw(self, context): layout = self.layout layout.prop(self, "contiguous_mode") @@ -1037,9 +2071,11 @@ class MESH_PT_bake_vertex_panel(Panel): 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') @@ -1049,8 +2085,10 @@ classes = [ 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 ] @@ -1060,9 +2098,11 @@ 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') |
