yum-archive/2ner

A toon shader for Unity's BIRP.

git clone https://git.yummers.dev/yum-archive/2ner

yumadd pixel padding to blender pluginb57eb08

master
71.9 KiB1981 linesraw
1bl_info = {
2    "name": "Bake Vertex to Target Vector",
3    "blender": (4, 2, 0),
4    "category": "Mesh",
5    "version": (2, 3, 0),
6    "author": "yum_food",
7    "description": "Bake vertex vectors with automatic center and scale calculation in Edit Mode, with submesh deduplication"
8}
9
10import bpy
11import mathutils
12import bmesh
13import math
14from bpy.props import BoolProperty, FloatProperty, IntProperty, PointerProperty
15from bpy.types import Panel, Operator, PropertyGroup
16from collections import deque, defaultdict
17
18
19class BakeVertexSettings(PropertyGroup):
20    correction_angle_x: FloatProperty(
21        name="X",
22        description="Correction angle for the X-axis",
23        default=math.pi,
24        subtype='ANGLE'
25    )
26    correction_angle_y: FloatProperty(
27        name="Y",
28        description="Correction angle for the Y-axis",
29        default=0.0,
30        subtype='ANGLE'
31    )
32    correction_angle_z: FloatProperty(
33        name="Z",
34        description="Correction angle for the Z-axis",
35        default=0.0,
36        subtype='ANGLE'
37    )
38
39
40class MeshUtils:
41    """Utility functions for mesh operations"""
42
43    @staticmethod
44    def with_mode(mode):
45        """Decorator to handle mode switching"""
46        def decorator(func):
47            def wrapper(self, context, *args, **kwargs):
48                original_mode = context.mode
49                if mode == 'OBJECT' and context.mode != 'OBJECT':
50                    bpy.ops.object.mode_set(mode='OBJECT')
51                elif mode == 'EDIT' and context.mode != 'EDIT_MESH':
52                    bpy.ops.object.mode_set(mode='EDIT')
53
54                try:
55                    result = func(self, context, *args, **kwargs)
56                finally:
57                    if original_mode == 'EDIT_MESH' and context.mode != 'EDIT_MESH':
58                        bpy.ops.object.mode_set(mode='EDIT')
59                    elif original_mode == 'OBJECT' and context.mode != 'OBJECT':
60                        bpy.ops.object.mode_set(mode='OBJECT')
61
62                return result
63            return wrapper
64        return decorator
65
66    @staticmethod
67    def with_multi_object_support(process_func_name='process_object'):
68        """Decorator to add multi-object support to operators"""
69        def decorator(func):
70            def wrapper(self, context, *args, **kwargs):
71                original_active = context.active_object
72                selected_objects = [obj for obj in context.selected_objects if obj.type == 'MESH']
73                
74                if not selected_objects:
75                    self.report({'WARNING'}, "No mesh objects selected")
76                    return {'CANCELLED'}
77
78                total_stats = {}
79                processed_count = 0
80
81                for obj in selected_objects:
82                    context.view_layer.objects.active = obj
83
84                    if hasattr(self, process_func_name):
85                        success, stats = getattr(self, process_func_name)(context, obj)
86                    else:
87                        result = func(self, context, obj, *args, **kwargs)
88                        if isinstance(result, tuple) and len(result) == 2:
89                            success, stats = result
90                        else:
91                            success, stats = (result == {'FINISHED'}), {}
92
93                    if success:
94                        processed_count += 1
95                        for key, value in stats.items():
96                            if isinstance(value, (int, float)):
97                                total_stats[key] = total_stats.get(key, 0) + value
98                            else:
99                                total_stats[key] = value
100
101                context.view_layer.objects.active = original_active
102
103                total_stats['object_count'] = processed_count
104                if hasattr(self, 'format_report'):
105                    message = self.format_report(total_stats)
106                else:
107                    if processed_count == 0:
108                        self.report({'WARNING'}, "No objects processed")
109                        return {'CANCELLED'}
110                    message = f"Processed {processed_count} object(s)"
111
112                if message:
113                    self.report({'INFO'}, message)
114
115                return {'FINISHED'}
116            return wrapper
117        return decorator
118
119    @staticmethod
120    def get_selected_vertices(mesh):
121        """Get indices of selected vertices"""
122        return {v.index for v in mesh.vertices if v.select}
123
124    @staticmethod
125    def build_adjacency(mesh, vertex_indices):
126        """Build adjacency list for given vertices"""
127        adjacency = {idx: set() for idx in vertex_indices}
128        for edge in mesh.edges:
129            v0, v1 = edge.vertices
130            if v0 in adjacency and v1 in adjacency:
131                adjacency[v0].add(v1)
132                adjacency[v1].add(v0)
133        return adjacency
134
135    @staticmethod
136    def flood_fill(start_nodes, adjacency_func):
137        """Generic flood fill algorithm"""
138        visited = set()
139        result = set()
140        queue = deque(start_nodes)
141
142        while queue:
143            current = queue.popleft()
144            if current in visited:
145                continue
146            visited.add(current)
147            result.add(current)
148
149            for neighbor in adjacency_func(current):
150                if neighbor not in visited:
151                    queue.append(neighbor)
152
153        return result
154
155    @staticmethod
156    def find_islands(nodes, adjacency_func):
157        """Find connected components"""
158        islands = []
159        visited = set()
160
161        for node in nodes:
162            if node in visited:
163                continue
164
165            island = MeshUtils.flood_fill([node], lambda n: adjacency_func.get(n, []))
166            visited.update(island)
167            islands.append(island)
168
169        return islands
170
171    @staticmethod
172    def select_edges_and_faces(mesh):
173        """Select edges and faces based on selected vertices"""
174        for edge in mesh.edges:
175            v0, v1 = edge.vertices
176            if mesh.vertices[v0].select and mesh.vertices[v1].select:
177                edge.select = True
178
179        for face in mesh.polygons:
180            if all(mesh.vertices[v].select for v in face.vertices):
181                face.select = True
182
183    @staticmethod
184    def build_position_map(vertices, scale):
185        """Build a map of vertices by quantized position"""
186        position_map = defaultdict(list)
187        for v in vertices:
188            if hasattr(v, 'index'):
189                key = tuple(int(v.co[i] * scale) for i in range(3))
190                position_map[key].append(v.index)
191            else:
192                key = tuple(int(v[i] * scale) for i in range(3))
193                position_map[key].append(v)
194        return position_map
195
196    @staticmethod
197    def get_or_create_uv_layer(mesh, name):
198        """Get or create a UV layer by name"""
199        return mesh.uv_layers.get(name) or mesh.uv_layers.new(name=name)
200
201
202class BaseSubmeshOperator(Operator):
203    """Base class for submesh operations with common functionality"""
204    bl_options = {'REGISTER', 'UNDO'}
205
206    @classmethod
207    def poll(cls, context):
208        obj = context.active_object
209        return obj and obj.type == 'MESH' and context.mode == 'EDIT_MESH'
210
211    def get_selected_submeshes(self, mesh):
212        """Get selected vertices grouped by submesh"""
213        selected = MeshUtils.get_selected_vertices(mesh)
214        if not selected:
215            return []
216        adjacency = MeshUtils.build_adjacency(mesh, selected)
217        return MeshUtils.find_islands(selected, adjacency)
218
219    @MeshUtils.with_multi_object_support('process_object')
220    def execute(self, context):
221        pass
222
223
224class ToleranceOperatorMixin:
225    """Mixin for operators that use tolerance values"""
226    
227    def get_scale_from_tolerance(self, tolerance):
228        """Convert tolerance to scale factor for quantization"""
229        return min(1.0 / tolerance, 1e7) if tolerance > 0 else 1e7
230
231
232class MESH_OT_select_all_linked(BaseSubmeshOperator):
233    bl_idname = "mesh.select_all_linked"
234    bl_label = "Select All Linked Submeshes"
235    bl_description = "Select all vertices in any submesh that has at least one vertex selected"
236
237    def process_object(self, context, obj):
238        mesh = obj.data
239        
240        # Get BMesh for edit mode operations
241        bm = bmesh.from_edit_mesh(mesh)
242        bm.verts.ensure_lookup_table()
243        
244        initially_selected = {v.index for v in bm.verts if v.select}
245
246        if not initially_selected:
247            return False, {}
248
249        # Build adjacency using BMesh
250        all_vertices = set(range(len(bm.verts)))
251        adjacency = {idx: set() for idx in all_vertices}
252        
253        for edge in bm.edges:
254            v0, v1 = edge.verts[0].index, edge.verts[1].index
255            adjacency[v0].add(v1)
256            adjacency[v1].add(v0)
257        
258        islands = MeshUtils.find_islands(all_vertices, adjacency)
259
260        expanded_count = 0
261        affected_islands = 0
262
263        for island in islands:
264            if island & initially_selected:
265                new_selections = island - initially_selected
266                if new_selections:
267                    expanded_count += len(new_selections)
268                    affected_islands += 1
269                # Select all vertices in the island using BMesh
270                for idx in island:
271                    bm.verts[idx].select = True
272
273        # Select edges and faces based on selected vertices
274        for edge in bm.edges:
275            if edge.verts[0].select and edge.verts[1].select:
276                edge.select = True
277
278        for face in bm.faces:
279            if all(v.select for v in face.verts):
280                face.select = True
281
282        # Update the mesh
283        bmesh.update_edit_mesh(mesh)
284
285        return True, {
286            'affected_islands': affected_islands,
287            'expanded_count': expanded_count
288        }
289
290    def format_report(self, stats):
291        if stats['object_count'] == 0:
292            return "No objects with selected vertices found"
293        return f"Expanded selection in {stats['affected_islands']} submeshes ({stats['expanded_count']} new vertices) across {stats['object_count']} object(s)"
294
295
296class MESH_OT_select_linked_across_boundaries(BaseSubmeshOperator, ToleranceOperatorMixin):
297    bl_idname = "mesh.select_linked_across_boundaries"
298    bl_label = "Select Linked (Cross Boundaries)"
299    bl_description = "Select linked vertices, crossing submesh boundaries where vertices share locations"
300
301    epsilon: FloatProperty(
302        name="Location Tolerance",
303        description="Maximum distance for vertices to be considered at the same location",
304        default=0.0001,
305        min=0.0,
306        max=1.0,
307        precision=6,
308        subtype='DISTANCE'
309    )
310
311    def build_combined_adjacency(self, bm):
312        """Build adjacency including both edges and position-based connections"""
313        # Build edge adjacency
314        edge_adjacency = {v.index: set() for v in bm.verts}
315        
316        for edge in bm.edges:
317            v0, v1 = edge.verts[0].index, edge.verts[1].index
318            edge_adjacency[v0].add(v1)
319            edge_adjacency[v1].add(v0)
320
321        # Build position-based adjacency
322        scale = self.get_scale_from_tolerance(self.epsilon)
323        position_map = defaultdict(list)
324        
325        for v in bm.verts:
326            key = tuple(int(v.co[i] * scale) for i in range(3))
327            position_map[key].append(v.index)
328
329        position_adjacency = {}
330        for vertices_at_pos in position_map.values():
331            if len(vertices_at_pos) > 1:
332                for i, v1 in enumerate(vertices_at_pos):
333                    if v1 not in position_adjacency:
334                        position_adjacency[v1] = set()
335                    position_adjacency[v1].update(vertices_at_pos[i+1:])
336                    for v2 in vertices_at_pos[i+1:]:
337                        if v2 not in position_adjacency:
338                            position_adjacency[v2] = set()
339                        position_adjacency[v2].add(v1)
340
341        def combined_adjacency(vertex):
342            neighbors = set()
343            if vertex in edge_adjacency:
344                neighbors.update(edge_adjacency[vertex])
345            if vertex in position_adjacency:
346                neighbors.update(position_adjacency[vertex])
347            return neighbors
348
349        return combined_adjacency
350
351    def process_object(self, context, obj):
352        mesh = obj.data
353        
354        # Get BMesh for edit mode operations
355        bm = bmesh.from_edit_mesh(mesh)
356        bm.verts.ensure_lookup_table()
357        
358        initially_selected = {v.index for v in bm.verts if v.select}
359
360        if not initially_selected:
361            return False, {}
362
363        combined_adjacency = self.build_combined_adjacency(bm)
364        visited = MeshUtils.flood_fill(initially_selected, combined_adjacency)
365
366        # Select vertices using BMesh
367        for idx in visited:
368            bm.verts[idx].select = True
369
370        # Select edges and faces based on selected vertices
371        for edge in bm.edges:
372            if edge.verts[0].select and edge.verts[1].select:
373                edge.select = True
374
375        for face in bm.faces:
376            if all(v.select for v in face.verts):
377                face.select = True
378
379        # Update the mesh
380        bmesh.update_edit_mesh(mesh)
381        
382        expanded_count = len(visited) - len(initially_selected)
383
384        return True, {
385            'selected': len(visited),
386            'expanded': expanded_count
387        }
388
389    def format_report(self, stats):
390        if stats['object_count'] == 0:
391            return "No objects with selected vertices found"
392        return f"Selected {stats['selected']} vertices ({stats['expanded']} new) across {stats['object_count']} object(s)"
393
394    def draw(self, context):
395        layout = self.layout
396        layout.prop(self, "epsilon")
397        layout.label(text="Connects vertices at same location", icon='INFO')
398
399
400class MESH_OT_deduplicate_submeshes(BaseSubmeshOperator, ToleranceOperatorMixin):
401    bl_idname = "mesh.deduplicate_submeshes"
402    bl_label = "Deduplicate Submeshes"
403    bl_description = "Remove duplicate submeshes from selection that have vertices at the same locations"
404
405    tolerance: FloatProperty(
406        name="Position Tolerance",
407        description="Maximum distance for vertices to be considered at the same position",
408        default=0.0001,
409        min=0.0,
410        max=1.0,
411        precision=6
412    )
413
414    def get_island_signature(self, island_verts):
415        """Create a hash for an island based on vertex positions"""
416        decimal_places = 6 if self.tolerance == 0 else max(0, int(-math.log10(self.tolerance)))
417
418        positions = []
419        for v in island_verts:
420            co = v.co
421            rounded = tuple(round(co[i], decimal_places) for i in range(3))
422            positions.append(rounded)
423
424        positions.sort()
425        return tuple(positions)
426
427    def process_object(self, context, obj):
428        mesh = obj.data
429        
430        # Get BMesh for edit mode operations
431        bm = bmesh.from_edit_mesh(mesh)
432        bm.verts.ensure_lookup_table()
433        
434        # Get selected vertices
435        selected_indices = {v.index for v in bm.verts if v.select}
436        if not selected_indices:
437            return False, {}
438        
439        # Build adjacency
440        adjacency = {idx: set() for idx in selected_indices}
441        for edge in bm.edges:
442            v0, v1 = edge.verts[0].index, edge.verts[1].index
443            if v0 in selected_indices and v1 in selected_indices:
444                adjacency[v0].add(v1)
445                adjacency[v1].add(v0)
446        
447        # Find islands
448        island_indices = MeshUtils.find_islands(selected_indices, adjacency)
449        
450        if len(island_indices) <= 1:
451            return False, {}
452
453        # Create island vertex lists
454        islands = []
455        for island_idx_set in island_indices:
456            island_verts = [bm.verts[idx] for idx in island_idx_set]
457            islands.append(island_verts)
458
459        # Group islands by signature
460        island_groups = defaultdict(list)
461        for island in islands:
462            signature = self.get_island_signature(island)
463            island_groups[signature].append(island)
464
465        vertices_to_delete = set()
466        duplicate_count = 0
467
468        # Mark duplicate islands for deletion
469        for group in island_groups.values():
470            if len(group) > 1:
471                # Keep the first island, delete the rest
472                for island in group[1:]:
473                    for v in island:
474                        vertices_to_delete.add(v)
475                    duplicate_count += 1
476
477        if not vertices_to_delete:
478            return False, {}
479
480        # Delete vertices using BMesh
481        bmesh.ops.delete(bm, geom=list(vertices_to_delete), context='VERTS')
482        
483        # Update the mesh
484        bmesh.update_edit_mesh(mesh)
485
486        return True, {
487            'duplicates': duplicate_count,
488            'vertices_deleted': len(vertices_to_delete)
489        }
490
491    def format_report(self, stats):
492        if stats['object_count'] == 0:
493            return "No objects processed"
494        
495        return f"Removed {stats['duplicates']} duplicate submeshes ({stats['vertices_deleted']} vertices) across {stats['object_count']} object(s)"
496
497    def draw(self, context):
498        layout = self.layout
499        layout.prop(self, "tolerance")
500        layout.label(text="Set to 0 for exact matching", icon='INFO')
501
502
503class MESH_OT_select_hidden_faces(BaseSubmeshOperator, ToleranceOperatorMixin):
504    bl_idname = "mesh.select_hidden_faces"
505    bl_label = "Select Hidden Faces"
506    bl_description = "Select faces that are hidden behind other faces (overlapping with opposing normals and same material)"
507
508    position_tolerance: FloatProperty(
509        name="Position Tolerance",
510        description="Maximum distance for vertices to be considered at the same position",
511        default=0.001,
512        min=0.0,
513        max=0.1,
514        precision=6,
515        subtype='DISTANCE'
516    )
517
518    normal_tolerance: FloatProperty(
519        name="Normal Tolerance",
520        description="Maximum angle difference for normals to be considered opposing",
521        default=0.1,
522        min=0.0,
523        max=math.pi,
524        precision=3,
525        subtype='ANGLE'
526    )
527
528    def process_object(self, context, obj):
529        mesh = obj.data
530        
531        # Get BMesh for edit mode operations
532        bm = bmesh.from_edit_mesh(mesh)
533        bm.faces.ensure_lookup_table()
534        
535        selected_faces = [f for f in bm.faces if f.select]
536        faces_to_check = selected_faces if selected_faces else list(bm.faces)
537        
538        if len(faces_to_check) < 2:
539            return False, {}
540
541        scale = self.get_scale_from_tolerance(self.position_tolerance)
542        
543        def get_center_hash_variations(center):
544            """Get hash variations for a point near grid boundaries"""
545            variations = []
546            boundary_threshold = 0.1  # If within 10% of grid boundary
547            
548            # For each dimension, determine which cells to hash to
549            cells = []
550            for i in range(3):
551                scaled = center[i] * scale
552                floored = int(scaled)
553                frac = scaled - floored
554                
555                if frac < boundary_threshold:
556                    # Near lower boundary, include previous cell
557                    cells.append([floored - 1, floored])
558                elif frac > (1.0 - boundary_threshold):
559                    # Near upper boundary, include next cell
560                    cells.append([floored, floored + 1])
561                else:
562                    # Not near boundary
563                    cells.append([floored])
564            
565            # Generate all combinations (max 8 for a corner)
566            for x in cells[0]:
567                for y in cells[1]:
568                    for z in cells[2]:
569                        variations.append((x, y, z))
570            
571            return variations
572        
573        def faces_match(face1, face2):
574            """Check if two faces have matching vertices at same positions"""
575            if len(face1.verts) != len(face2.verts):
576                return False
577            
578            # For each vertex in face1, check if there's a matching vertex in face2
579            tolerance_sq = self.position_tolerance * self.position_tolerance
580            
581            for v1 in face1.verts:
582                found_match = False
583                for v2 in face2.verts:
584                    if (v1.co - v2.co).length_squared <= tolerance_sq:
585                        found_match = True
586                        break
587                if not found_match:
588                    return False
589            
590            return True
591
592        # Group faces by center position for finding candidates
593        center_hash_map = defaultdict(list)
594        face_data = {}
595        
596        for face in faces_to_check:
597            center = face.calc_center_median()
598            
599            # Store face data
600            face_data[face.index] = {
601                'normal': face.normal.normalized(),
602                'face': face,
603                'center': center,
604                'material_index': face.material_index
605            }
606            
607            # Hash by center position (with boundary handling)
608            center_variations = get_center_hash_variations(center)
609            for center_hash in center_variations:
610                center_hash_map[center_hash].append(face.index)
611
612        hidden_faces = set()
613        checked_pairs = 0
614        checked_face_pairs = set()
615        
616        # Check faces that have the same center hash
617        for center_hash, face_indices in center_hash_map.items():
618            if len(face_indices) < 2:
619                continue
620            
621            for i in range(len(face_indices)):
622                for j in range(i + 1, len(face_indices)):
623                    face1_idx = face_indices[i]
624                    face2_idx = face_indices[j]
625                    
626                    # Skip if we've already checked this pair
627                    pair = (min(face1_idx, face2_idx), max(face1_idx, face2_idx))
628                    if pair in checked_face_pairs:
629                        continue
630                    checked_face_pairs.add(pair)
631                    
632                    face1_data = face_data[face1_idx]
633                    face2_data = face_data[face2_idx]
634                    
635                    # Check if faces use the same material
636                    if face1_data['material_index'] != face2_data['material_index']:
637                        continue
638                    
639                    # Quick check: opposing normals
640                    dot_product = face1_data['normal'].dot(face2_data['normal'])
641                    if dot_product >= 0:
642                        continue
643                    
644                    # Check angle tolerance
645                    angle_diff = math.acos(min(1.0, max(-1.0, abs(dot_product))))
646                    if angle_diff >= self.normal_tolerance:
647                        continue
648                    
649                    # Detailed vertex comparison
650                    checked_pairs += 1
651                    if faces_match(face1_data['face'], face2_data['face']):
652                        hidden_faces.add(face1_idx)
653                        hidden_faces.add(face2_idx)
654        
655        # Select faces using BMesh
656        for face_idx in hidden_faces:
657            if face_idx in face_data:
658                face_data[face_idx]['face'].select = True
659        
660        # Update the mesh
661        bmesh.update_edit_mesh(mesh)
662        
663        return len(hidden_faces) > 0, {
664            'hidden_faces': len(hidden_faces),
665            'checked_faces': len(faces_to_check),
666            'checked_pairs': checked_pairs,
667            'hash_groups': len([g for g in center_hash_map.values() if len(g) > 1])
668        }
669
670    def format_report(self, stats):
671        if stats['object_count'] == 0:
672            return "No objects processed"
673        if stats['hidden_faces'] == 0:
674            groups = stats.get('hash_groups', 0)
675            pairs = stats.get('checked_pairs', 0)
676            return f"No hidden faces found among {stats['checked_faces']} faces ({groups} overlapping groups, {pairs} pairs checked) in {stats['object_count']} object(s)"
677        
678        groups = stats.get('hash_groups', 0)
679        pairs = stats.get('checked_pairs', 0)
680        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)"
681
682    def draw(self, context):
683        layout = self.layout
684        layout.prop(self, "position_tolerance")
685        layout.prop(self, "normal_tolerance")
686        layout.label(text="Selects overlapping faces with opposing normals", icon='INFO')
687        layout.label(text="Only considers faces with the same material", icon='INFO')
688
689
690class UVOperatorMixin:
691    """Mixin for UV-related operations"""
692    
693    @classmethod
694    def poll(cls, context):
695        obj = context.active_object
696        return (obj and obj.type == 'MESH' and 
697                context.mode == 'EDIT_MESH' and 
698                obj.data.uv_layers.active)
699    
700    def get_uv_islands(self, bm, uv_layer):
701        """Find all UV islands in the mesh"""
702        uv_vert_map = {}
703        uv_adjacency = defaultdict(set)
704
705        for face in bm.faces:
706            if not face.select:
707                continue
708
709            face_uvs = []
710            for loop in face.loops:
711                uv = loop[uv_layer].uv
712                uv_key = (round(uv.x, 6), round(uv.y, 6))
713                face_uvs.append(uv_key)
714
715                if uv_key not in uv_vert_map:
716                    uv_vert_map[uv_key] = set()
717                uv_vert_map[uv_key].add(loop.vert.index)
718
719            for i in range(len(face_uvs)):
720                j = (i + 1) % len(face_uvs)
721                uv_adjacency[face_uvs[i]].add(face_uvs[j])
722                uv_adjacency[face_uvs[j]].add(face_uvs[i])
723
724        islands = []
725        for start_uv in uv_vert_map:
726            if any(start_uv in island['uvs'] for island in islands):
727                continue
728
729            island_uvs = MeshUtils.flood_fill([start_uv], lambda uv: uv_adjacency.get(uv, []))
730            island_vert_indices = set()
731            for uv in island_uvs:
732                island_vert_indices.update(uv_vert_map[uv])
733
734            islands.append({
735                'uvs': island_uvs,
736                'vert_indices': island_vert_indices,
737                'loops': []
738            })
739
740        return islands
741
742
743class MESH_OT_pack_uv_islands_by_submesh_z(BaseSubmeshOperator, UVOperatorMixin):
744    bl_idname = "mesh.pack_uv_islands_by_submesh_z"
745    bl_label = "Pack UV Islands by Submesh Z"
746    bl_description = "Pack UV islands vertically sorted by submesh Z position"
747
748    padding: FloatProperty(
749        name="Island Padding (px)",
750        description="Padding between UV islands (in pixels, evaluated against the current render resolution). A value of 4.0 yields ~4 pixels of gap in the final packed result.",
751        default=4.0,
752        min=0.0,
753        max=256.0,
754        precision=1
755    )
756
757    max_islands_per_row: IntProperty(
758        name="Max Islands Per Row",
759        description="Maximum number of islands per row",
760        default=100,
761        min=1,
762        max=1000
763    )
764
765    lock_overlapping: BoolProperty(
766        name="Lock Overlapping Islands",
767        description="Treat overlapping UV islands as a single island",
768        default=False
769    )
770
771    skip_overlap_check: BoolProperty(
772        name="Skip Overlap Check",
773        description="Skip overlap detection for better performance",
774        default=False
775    )
776
777    def execute(self, context):
778        obj = context.active_object
779        mesh = obj.data
780
781        # Get BMesh and ensure we have a UV layer
782        bm = bmesh.from_edit_mesh(mesh)
783        bm.verts.ensure_lookup_table()
784        bm.faces.ensure_lookup_table()
785        
786        if not bm.loops.layers.uv:
787            self.report({'WARNING'}, "No UV layer found")
788            return {'CANCELLED'}
789            
790        bm_uv_layer = bm.loops.layers.uv.active
791
792        # Get UV islands
793        uv_islands = self.get_uv_islands(bm, bm_uv_layer)
794        if not uv_islands:
795            self.report({'WARNING'}, "No UV islands found")
796            return {'CANCELLED'}
797
798        # Get selected vertices and build submeshes
799        selected_indices = {v.index for v in bm.verts if v.select}
800        if not selected_indices:
801            self.report({'WARNING'}, "No vertices selected")
802            return {'CANCELLED'}
803            
804        # Build adjacency
805        adjacency = {idx: set() for idx in selected_indices}
806        for edge in bm.edges:
807            v0, v1 = edge.verts[0].index, edge.verts[1].index
808            if v0 in selected_indices and v1 in selected_indices:
809                adjacency[v0].add(v1)
810                adjacency[v1].add(v0)
811        
812        # Find submeshes
813        submesh_indices = MeshUtils.find_islands(selected_indices, adjacency)
814        
815        # Calculate average Z for each submesh
816        submesh_z_values = []
817        for submesh_idx_set in submesh_indices:
818            avg_z = sum(bm.verts[idx].co.z for idx in submesh_idx_set) / len(submesh_idx_set)
819            submesh_z_values.append(avg_z)
820
821        # Map vertices to submeshes
822        vertex_to_submesh = {}
823        for i, submesh_idx_set in enumerate(submesh_indices):
824            for v_idx in submesh_idx_set:
825                vertex_to_submesh[v_idx] = i
826
827        # Build UV to loops mapping
828        uv_to_loops = defaultdict(list)
829        for face in bm.faces:
830            if face.select:
831                for loop in face.loops:
832                    uv = loop[bm_uv_layer].uv
833                    uv_key = (round(uv.x, 6), round(uv.y, 6))
834                    uv_to_loops[uv_key].append(loop)
835
836        # Process islands
837        island_data = []
838        for island in uv_islands:
839            island['loops'] = []
840            for uv_key in island['uvs']:
841                island['loops'].extend(uv_to_loops.get(uv_key, []))
842
843            if not island['loops']:
844                continue
845
846            # Find submesh for this island
847            submesh_idx = None
848            for v_idx in island['vert_indices']:
849                if v_idx in vertex_to_submesh:
850                    submesh_idx = vertex_to_submesh[v_idx]
851                    break
852
853            if submesh_idx is not None:
854                # Calculate bounds
855                min_u = min_v = float('inf')
856                max_u = max_v = float('-inf')
857
858                for loop in island['loops']:
859                    uv = loop[bm_uv_layer].uv
860                    min_u = min(min_u, uv.x)
861                    max_u = max(max_u, uv.x)
862                    min_v = min(min_v, uv.y)
863                    max_v = max(max_v, uv.y)
864
865                width = max_u - min_u
866                height = max_v - min_v
867
868                if width > 0 and height > 0:
869                    island_data.append({
870                        'island': island,
871                        'submesh_z': submesh_z_values[submesh_idx],
872                        'bounds': (min_u, min_v, max_u, max_v),
873                        'width': width,
874                        'height': height
875                    })
876
877        # Sort by Z position
878        island_data.sort(key=lambda x: x['submesh_z'], reverse=True)
879
880        # Pack islands
881        # Convert pixel padding to UV units based on render resolution (largest axis)
882        render = context.scene.render
883        tex_size = max(
884            render.resolution_x * render.resolution_percentage / 100.0,
885            render.resolution_y * render.resolution_percentage / 100.0,
886        ) or 1024.0  # Fallback to 1024 if resolution is unset/zero
887
888        padding_uv = self.padding / tex_size
889
890        total_area = sum(
891            (d['width'] + padding_uv) * (d['height'] + padding_uv)
892            for d in island_data
893        )
894        target_size = min(0.95, math.sqrt(total_area) * 1.2)
895        scale_factor = 0.95 / target_size if target_size > 1.0 else 1.0
896
897        current_v = 0.95
898        current_row = []
899
900        for data in island_data:
901            width = data['width'] * scale_factor
902            height = data['height'] * scale_factor
903
904            padding_scaled = padding_uv * scale_factor
905
906            # Check if we need to start a new row
907            if current_row and (
908                sum(d['width'] * scale_factor + padding_scaled for d in current_row) + width > target_size
909                or len(current_row) >= self.max_islands_per_row):
910                # Place current row
911                current_u = 0.025
912                row_height = max(d['height'] * scale_factor for d in current_row)
913
914                for row_data in current_row:
915                    offset_u = current_u - row_data['bounds'][0] * scale_factor
916                    offset_v = current_v - row_data['bounds'][3] * scale_factor
917
918                    for loop in row_data['island']['loops']:
919                        uv = loop[bm_uv_layer].uv
920                        uv.x = uv.x * scale_factor + offset_u
921                        uv.y = uv.y * scale_factor + offset_v
922
923                    current_u += row_data['width'] * scale_factor + padding_scaled
924
925                current_v -= row_height + padding_scaled
926                current_row = []
927
928            current_row.append(data)
929
930        # Place final row
931        if current_row:
932            current_u = 0.025
933            for row_data in current_row:
934                offset_u = current_u - row_data['bounds'][0] * scale_factor
935                offset_v = current_v - row_data['bounds'][3] * scale_factor
936
937                for loop in row_data['island']['loops']:
938                    uv = loop[bm_uv_layer].uv
939                    uv.x = uv.x * scale_factor + offset_u
940                    uv.y = uv.y * scale_factor + offset_v
941
942                current_u += row_data['width'] * scale_factor + padding_scaled
943
944        # Update the mesh
945        bmesh.update_edit_mesh(mesh)
946        
947        self.report({'INFO'}, f"Packed {len(island_data)} UV islands from {len(submesh_indices)} submeshes")
948        return {'FINISHED'}
949
950    def draw(self, context):
951        layout = self.layout
952        layout.prop(self, "padding")
953        layout.prop(self, "max_islands_per_row")
954        layout.prop(self, "lock_overlapping")
955        layout.prop(self, "skip_overlap_check")
956
957
958class MESH_OT_merge_by_distance_per_submesh(BaseSubmeshOperator):
959    bl_idname = "mesh.merge_by_distance_per_submesh"
960    bl_label = "Merge by Distance (Per Submesh)"
961    bl_description = "Merge vertices by distance within each submesh separately"
962
963    merge_distance: FloatProperty(
964        name="Merge Distance",
965        description="Maximum distance for merging vertices",
966        default=0.001,
967        min=0.0,
968        max=1.0,
969        precision=6,
970        subtype='DISTANCE'
971    )
972
973    def process_object(self, context, obj):
974        mesh = obj.data
975        bm = bmesh.from_edit_mesh(mesh)
976        bm.verts.ensure_lookup_table()
977
978        selected_verts = [v for v in bm.verts if v.select]
979        if not selected_verts:
980            return False, {}
981
982        selected_set = set(selected_verts)
983        island_verts = []
984        visited = set()
985
986        for start_v in selected_verts:
987            if start_v in visited:
988                continue
989
990            island = []
991            stack = [start_v]
992
993            while stack:
994                current = stack.pop()
995                if current in visited:
996                    continue
997                visited.add(current)
998                island.append(current)
999
1000                for edge in current.link_edges:
1001                    other = edge.other_vert(current)
1002                    if other in selected_set and other not in visited:
1003                        stack.append(other)
1004
1005            island_verts.append(island)
1006
1007        merge_targets = {}
1008        merge_dist_sq = self.merge_distance ** 2
1009        merged_count = 0
1010
1011        for verts in island_verts:
1012            if len(verts) < 2:
1013                continue
1014
1015            processed = set()
1016            for i, v1 in enumerate(verts):
1017                if v1 in merge_targets or v1 in processed:
1018                    continue
1019                processed.add(v1)
1020
1021                for v2 in verts[i+1:]:
1022                    if v2 in merge_targets or v2 in processed:
1023                        continue
1024
1025                    if (v1.co - v2.co).length_squared <= merge_dist_sq:
1026                        merge_targets[v2] = v1
1027                        processed.add(v2)
1028                        merged_count += 1
1029
1030        if merge_targets:
1031            targetmap = {v: merge_targets[v] for v in merge_targets if v.is_valid}
1032            if targetmap:
1033                bmesh.ops.weld_verts(bm, targetmap=targetmap)
1034
1035        bmesh.update_edit_mesh(mesh)
1036
1037        return merged_count > 0, {'merged': merged_count}
1038
1039    def format_report(self, stats):
1040        if stats.get('merged', 0) > 0:
1041            return f"Merged {stats['merged']} vertices across {stats['object_count']} object(s)"
1042        else:
1043            return "No vertices close enough to merge"
1044
1045    def draw(self, context):
1046        layout = self.layout
1047        layout.prop(self, "merge_distance")
1048
1049
1050class MESH_OT_smart_uv_project_normal_groups(BaseSubmeshOperator, UVOperatorMixin):
1051    bl_idname = "mesh.smart_uv_project_normal_groups"
1052    bl_label = "Smart UV Project (Normal Groups)"
1053    bl_description = "Project UVs by grouping faces with similar normals across disconnected geometry"
1054    
1055    angle_threshold: FloatProperty(
1056        name="Angle Threshold",
1057        description="Maximum angle difference for faces to be grouped together",
1058        default=0.087266,
1059        min=0.0,
1060        max=math.pi/2,
1061        precision=3,
1062        subtype='ANGLE'
1063    )
1064    
1065    gap_distance: FloatProperty(
1066        name="Gap Distance",
1067        description="Maximum distance to bridge gaps between faces",
1068        default=0.001,
1069        min=0.0,
1070        max=0.1,
1071        precision=6,
1072        subtype='DISTANCE'
1073    )
1074    
1075    island_margin: FloatProperty(
1076        name="Island Margin",
1077        description="Margin between UV islands",
1078        default=0.003,
1079        min=0.0,
1080        max=0.5,
1081        precision=3
1082    )
1083    
1084    def build_face_spatial_cache(self, bm, selected_faces):
1085        """Build spatial cache for fast proximity queries"""
1086        face_centers = {}
1087        face_bounds = {}
1088        
1089        for face_idx in selected_faces:
1090            if face_idx >= len(bm.faces):
1091                continue
1092            face = bm.faces[face_idx]
1093            if face.hide:
1094                continue
1095                
1096            # Calculate center and bounds
1097            min_co = mathutils.Vector((float('inf'), float('inf'), float('inf')))
1098            max_co = mathutils.Vector((float('-inf'), float('-inf'), float('-inf')))
1099            center = mathutils.Vector((0, 0, 0))
1100            
1101            for vert in face.verts:
1102                co = vert.co
1103                center += co
1104                min_co.x = min(min_co.x, co.x)
1105                min_co.y = min(min_co.y, co.y)
1106                min_co.z = min(min_co.z, co.z)
1107                max_co.x = max(max_co.x, co.x)
1108                max_co.y = max(max_co.y, co.y)
1109                max_co.z = max(max_co.z, co.z)
1110            
1111            center /= len(face.verts)
1112            face_centers[face_idx] = center
1113            face_bounds[face_idx] = (min_co, max_co)
1114        
1115        return face_centers, face_bounds
1116    
1117    def build_combined_adjacency(self, bm, selected_faces, face_centers, face_bounds):
1118        """Build adjacency including both edge connections and spatial proximity"""
1119        angle_threshold_cos = math.cos(self.angle_threshold)
1120        gap_dist_sq = self.gap_distance * self.gap_distance
1121        
1122        # Build edge-based adjacency
1123        edge_adjacency = defaultdict(set)
1124        selected_set = set(selected_faces)
1125        
1126        for edge in bm.edges:
1127            if len(edge.link_faces) == 2:
1128                f1, f2 = edge.link_faces
1129                if f1.index in selected_set and f2.index in selected_set:
1130                    edge_adjacency[f1.index].add(f2.index)
1131                    edge_adjacency[f2.index].add(f1.index)
1132        
1133        # Build spatial grid for efficient proximity queries
1134        grid_size = max(self.gap_distance * 2, 0.01)
1135        spatial_grid = defaultdict(list)
1136        
1137        for face_idx in face_centers:
1138            center = face_centers[face_idx]
1139            grid_key = (
1140                int(center.x / grid_size),
1141                int(center.y / grid_size),
1142                int(center.z / grid_size)
1143            )
1144            
1145            # Add to neighboring grid cells as well
1146            for dx in [-1, 0, 1]:
1147                for dy in [-1, 0, 1]:
1148                    for dz in [-1, 0, 1]:
1149                        neighbor_key = (
1150                            grid_key[0] + dx,
1151                            grid_key[1] + dy,
1152                            grid_key[2] + dz
1153                        )
1154                        spatial_grid[neighbor_key].append(face_idx)
1155        
1156        # Build proximity adjacency if gap distance is significant
1157        proximity_adjacency = defaultdict(set)
1158        
1159        if self.gap_distance > 0.0001:
1160            # Build face-to-vertex mapping with spatial hashing
1161            face_vertex_grid = defaultdict(set)
1162            
1163            for face_idx in selected_faces:
1164                face = bm.faces[face_idx]
1165                for vert in face.verts:
1166                    v_key = (
1167                        int(vert.co.x / grid_size),
1168                        int(vert.co.y / grid_size),
1169                        int(vert.co.z / grid_size)
1170                    )
1171                    face_vertex_grid[v_key].add(face_idx)
1172            
1173            # Find proximity connections
1174            processed_pairs = set()
1175            
1176            for face_idx in selected_faces:
1177                if face_idx not in face_centers:
1178                    continue
1179                    
1180                face = bm.faces[face_idx]
1181                face_normal = face.normal.normalized()
1182                
1183                # Find candidate faces through vertex proximity
1184                candidates = set()
1185                
1186                for vert in face.verts:
1187                    v_key = (
1188                        int(vert.co.x / grid_size),
1189                        int(vert.co.y / grid_size),
1190                        int(vert.co.z / grid_size)
1191                    )
1192                    
1193                    # Check neighboring grid cells
1194                    for dx in [-1, 0, 1]:
1195                        for dy in [-1, 0, 1]:
1196                            for dz in [-1, 0, 1]:
1197                                neighbor_key = (v_key[0] + dx, v_key[1] + dy, v_key[2] + dz)
1198                                candidates.update(face_vertex_grid.get(neighbor_key, set()))
1199                
1200                candidates.discard(face_idx)
1201                
1202                # Check each candidate
1203                for other_idx in candidates:
1204                    pair = (min(face_idx, other_idx), max(face_idx, other_idx))
1205                    if pair in processed_pairs:
1206                        continue
1207                    processed_pairs.add(pair)
1208                    
1209                    other_face = bm.faces[other_idx]
1210                    
1211                    # Check normal similarity first
1212                    if face_normal.dot(other_face.normal.normalized()) < angle_threshold_cos:
1213                        continue
1214                    
1215                    # Quick bounds check
1216                    bounds1 = face_bounds[face_idx]
1217                    bounds2 = face_bounds[other_idx]
1218                    
1219                    min_dist_sq = 0.0
1220                    for i in range(3):
1221                        if bounds1[1][i] < bounds2[0][i]:
1222                            d = bounds2[0][i] - bounds1[1][i]
1223                            min_dist_sq += d * d
1224                        elif bounds2[1][i] < bounds1[0][i]:
1225                            d = bounds1[0][i] - bounds2[1][i]
1226                            min_dist_sq += d * d
1227                    
1228                    if min_dist_sq > gap_dist_sq:
1229                        continue
1230                    
1231                    # Check if any vertices are close
1232                    found_close = False
1233                    for v1 in face.verts:
1234                        for v2 in other_face.verts:
1235                            if (v1.co - v2.co).length_squared <= gap_dist_sq:
1236                                found_close = True
1237                                break
1238                        if found_close:
1239                            break
1240                    
1241                    if found_close:
1242                        proximity_adjacency[face_idx].add(other_idx)
1243                        proximity_adjacency[other_idx].add(face_idx)
1244        
1245        # Combine adjacencies - ensure ALL selected faces are in the result
1246        combined = defaultdict(set)
1247        
1248        # First, add all selected faces (even isolated ones)
1249        for face_idx in selected_faces:
1250            combined[face_idx] = set()
1251        
1252        # Then add adjacency information
1253        for face_idx in edge_adjacency:
1254            combined[face_idx].update(edge_adjacency[face_idx])
1255        
1256        for face_idx in proximity_adjacency:
1257            combined[face_idx].update(proximity_adjacency[face_idx])
1258        
1259        return combined
1260    
1261    def find_face_groups_flood_fill(self, selected_faces, adjacency, bm):
1262        """Efficient flood fill to find connected face groups respecting angle threshold"""
1263        visited = set()
1264        groups = []
1265        angle_threshold_cos = math.cos(self.angle_threshold)
1266        
1267        for start_face in selected_faces:
1268            if start_face in visited:
1269                continue
1270            
1271            # Flood fill from this face
1272            group = []
1273            queue = deque([start_face])
1274            
1275            while queue:
1276                face_idx = queue.popleft()
1277                if face_idx in visited:
1278                    continue
1279                
1280                visited.add(face_idx)
1281                group.append(face_idx)
1282                
1283                # Get current face normal
1284                current_face = bm.faces[face_idx]
1285                current_normal = current_face.normal.normalized()
1286                
1287                # Add unvisited neighbors if their normals are similar enough
1288                for neighbor in adjacency.get(face_idx, []):
1289                    if neighbor not in visited:
1290                        neighbor_face = bm.faces[neighbor]
1291                        neighbor_normal = neighbor_face.normal.normalized()
1292                        
1293                        # Check angle threshold
1294                        if current_normal.dot(neighbor_normal) >= angle_threshold_cos:
1295                            queue.append(neighbor)
1296            
1297            if group:
1298                groups.append(group)
1299        
1300        return groups
1301    
1302    def calculate_projection_matrix(self, face_group, bm):
1303        """Calculate optimal projection matrix for a group of faces"""
1304        if not face_group:
1305            return None, None
1306        
1307        # Calculate weighted average normal and center
1308        avg_normal = mathutils.Vector((0, 0, 0))
1309        total_area = 0.0
1310        center = mathutils.Vector((0, 0, 0))
1311        
1312        for face_idx in face_group:
1313            if face_idx < len(bm.faces):
1314                face = bm.faces[face_idx]
1315                area = face.calc_area()
1316                if area > 0:
1317                    avg_normal += face.normal * area
1318                    total_area += area
1319                    center += face.calc_center_median() * area
1320        
1321        if total_area <= 0:
1322            return None, None
1323        
1324        avg_normal /= total_area
1325        center /= total_area
1326        avg_normal.normalize()
1327        
1328        # Create orthonormal basis
1329        if abs(avg_normal.z) < 0.9:
1330            u_axis = mathutils.Vector((0, 0, 1)).cross(avg_normal)
1331        else:
1332            u_axis = mathutils.Vector((1, 0, 0)).cross(avg_normal)
1333        u_axis.normalize()
1334        
1335        v_axis = avg_normal.cross(u_axis)
1336        v_axis.normalize()
1337        
1338        # Create projection matrix
1339        projection_matrix = mathutils.Matrix((
1340            (u_axis.x, u_axis.y, u_axis.z),
1341            (v_axis.x, v_axis.y, v_axis.z)
1342        ))
1343        
1344        return projection_matrix, center
1345    
1346    def project_faces_to_uv(self, face_group, projection_matrix, center, bm, uv_layer):
1347        """Project faces in a group to UV coordinates"""
1348        if not face_group or projection_matrix is None:
1349            return None
1350        
1351        # Project all vertices
1352        projected_uvs = {}
1353        min_uv = mathutils.Vector((float('inf'), float('inf')))
1354        max_uv = mathutils.Vector((float('-inf'), float('-inf')))
1355        
1356        for face_idx in face_group:
1357            if face_idx >= len(bm.faces):
1358                continue
1359                
1360            face = bm.faces[face_idx]
1361            for loop in face.loops:
1362                relative_pos = loop.vert.co - center
1363                uv_2d = projection_matrix @ relative_pos
1364                uv = mathutils.Vector((uv_2d.x, uv_2d.y))
1365                
1366                projected_uvs[loop] = uv
1367                min_uv.x = min(min_uv.x, uv.x)
1368                min_uv.y = min(min_uv.y, uv.y)
1369                max_uv.x = max(max_uv.x, uv.x)
1370                max_uv.y = max(max_uv.y, uv.y)
1371        
1372        if not projected_uvs:
1373            return None
1374        
1375        size = max_uv - min_uv
1376        if size.x <= 0 or size.y <= 0:
1377            return None
1378        
1379        return {
1380            'min_uv': min_uv,
1381            'max_uv': max_uv,
1382            'size': size,
1383            'projected_uvs': projected_uvs,
1384            'face_count': len(face_group),
1385            'face_indices': face_group
1386        }
1387    
1388    def pack_uv_islands_growing(self, islands, bm, uv_layer):
1389        """Pack UV islands using shelf packing with uniform texel density
1390        
1391        Algorithm:
1392        1. Calculates a uniform scale factor based on 3D surface area to normalize texel density
1393        2. Sorts islands by height (tallest first) for efficient shelf packing
1394        3. Places islands on horizontal shelves with smart height matching (50%-200% tolerance)
1395        4. Maintains consistent margins between islands
1396        5. Uses two-pass approach to achieve near-square aspect ratio
1397        6. Scales result to fit within UV space (0-1 range)
1398        
1399        This produces approximately square UV layouts even with significant padding.
1400        """
1401        import math
1402        
1403        if not islands:
1404            return
1405        
1406        # Filter valid islands and calculate 3D surface area
1407        valid_islands = []
1408        total_3d_area = 0.0
1409        
1410        for island in islands:
1411            if island and island['size'].x > 0 and island['size'].y > 0:
1412                # Calculate 3D surface area for this island
1413                surface_area_3d = sum(
1414                    bm.faces[face_idx].calc_area() 
1415                    for face_idx in island.get('face_indices', [])
1416                    if face_idx < len(bm.faces)
1417                )
1418                
1419                island['surface_area_3d'] = surface_area_3d
1420                total_3d_area += surface_area_3d
1421                valid_islands.append(island)
1422        
1423        if not valid_islands:
1424            return
1425        
1426        # Calculate uniform scale to normalize texel density
1427        target_coverage = 0.8  # Use 80% of UV space
1428        uniform_scale = math.sqrt(target_coverage / total_3d_area) if total_3d_area > 0 else 1.0
1429        
1430        # Apply uniform scale to all islands and inflate by margin
1431        for island in valid_islands:
1432            island['scaled_size'] = island['size'] * uniform_scale
1433            
1434            # Check if rotating by 90 degrees would be beneficial (make it wider than tall)
1435            if island['scaled_size'].y > island['scaled_size'].x:
1436                # Rotate the island by swapping dimensions
1437                island['rotated'] = True
1438                island['scaled_size'] = mathutils.Vector((
1439                    island['scaled_size'].y,
1440                    island['scaled_size'].x
1441                ))
1442            else:
1443                island['rotated'] = False
1444            
1445            # Inflate the island size by margin on all sides for packing
1446            island['inflated_size'] = mathutils.Vector((
1447                island['scaled_size'].x + 2 * self.island_margin,
1448                island['scaled_size'].y + 2 * self.island_margin
1449            ))
1450        
1451        # Sort by height (tallest first) for shelf packing
1452        valid_islands.sort(key=lambda x: x['inflated_size'].y, reverse=True)
1453        
1454        # Calculate total area using inflated sizes
1455        total_area_inflated = sum(island['inflated_size'].x * island['inflated_size'].y for island in valid_islands)
1456        
1457        # Initial target width based on inflated area
1458        target_width = math.sqrt(total_area_inflated)
1459        
1460        # Two-pass packing for better aspect ratio
1461        for pass_num in range(2):
1462            # Shelf packing
1463            shelves = []
1464            current_y = 0
1465            
1466            for island in valid_islands:
1467                width = island['inflated_size'].x
1468                height = island['inflated_size'].y
1469                
1470                # Find a suitable shelf
1471                placed = False
1472                for shelf in shelves:
1473                    # Check horizontal space
1474                    if shelf['current_x'] + width <= target_width:
1475                        # Place on this shelf (no height restriction)
1476                        island['pack_position'] = mathutils.Vector((
1477                            shelf['current_x'],
1478                            shelf['y_position']
1479                        ))
1480                        shelf['current_x'] += width
1481                        placed = True
1482                        break
1483                
1484                if not placed:
1485                    # Create new shelf
1486                    island['pack_position'] = mathutils.Vector((0, current_y))
1487                    shelves.append({
1488                        'y_position': current_y,
1489                        'height': height,
1490                        'current_x': width
1491                    })
1492                    current_y += height
1493            
1494            # Calculate actual packed dimensions
1495            pack_width = max(shelf['current_x'] for shelf in shelves) if shelves else target_width
1496            pack_height = current_y
1497            
1498            # After first pass, calculate better target width based on actual dimensions
1499            if pass_num == 0:
1500                if pack_width > pack_height:
1501                    # Too wide, need narrower target
1502                    target_width = math.sqrt(total_area_inflated * pack_height / pack_width)
1503                else:
1504                    # Too tall, need wider target
1505                    target_width = math.sqrt(total_area_inflated * pack_width / pack_height)
1506        
1507        # Improvement 3: Trim unused space from each shelf
1508        for shelf in shelves:
1509            shelf['actual_width'] = shelf['current_x']  # Store actual used width
1510        
1511        # Improvement 4: Compact shelves vertically
1512        # Sort shelves by height for better vertical packing
1513        shelves.sort(key=lambda s: s['height'], reverse=True)
1514        
1515        # Re-stack shelves with actual widths
1516        compacted_y = 0
1517        for shelf in shelves:
1518            # Update all islands on this shelf with new Y position
1519            y_offset = compacted_y - shelf['y_position']
1520            for island in valid_islands:
1521                if 'pack_position' in island and abs(island['pack_position'].y - shelf['y_position']) < 0.0001:
1522                    island['pack_position'].y += y_offset
1523            
1524            shelf['y_position'] = compacted_y
1525            compacted_y += shelf['height']
1526        
1527        # Update pack height after compaction
1528        pack_height = compacted_y
1529        
1530        # Recalculate pack width after compaction using actual shelf widths
1531        pack_width = max(shelf['actual_width'] for shelf in shelves) if shelves else target_width
1532        
1533        # Calculate final scale to fit UV space
1534        scale_factor = min(1.0 / pack_width, 1.0 / pack_height)
1535        
1536        # Apply UV coordinates
1537        for island in valid_islands:
1538            if 'pack_position' not in island:
1539                continue
1540            
1541            # Scale the inflated position and then add margin to get the actual position
1542            scaled_position = island['pack_position'] * scale_factor
1543            # The actual island position is the inflated position plus the margin
1544            actual_position = scaled_position + mathutils.Vector((self.island_margin, self.island_margin))
1545            
1546            size_scale = uniform_scale * scale_factor
1547            min_uv = island['min_uv']
1548            
1549            # Apply to all loops
1550            for loop, original_uv in island['projected_uvs'].items():
1551                # Apply rotation if needed
1552                if island.get('rotated', False):
1553                    # Rotate 90 degrees counterclockwise: (x, y) -> (-y, x)
1554                    # But we want to keep it in positive space, so we do (x, y) -> (y, -x) then flip
1555                    rotated_uv = mathutils.Vector((
1556                        original_uv.y - min_uv.y,
1557                        -(original_uv.x - min_uv.x) + (island['max_uv'].x - island['min_uv'].x)
1558                    ))
1559                    new_uv = rotated_uv * size_scale + actual_position
1560                else:
1561                    new_uv = (original_uv - min_uv) * size_scale + actual_position
1562                loop[uv_layer].uv = new_uv
1563        
1564        # Report results
1565        # Calculate actual area (non-inflated) for efficiency calculation
1566        total_area = sum(island['scaled_size'].x * island['scaled_size'].y for island in valid_islands)
1567        efficiency = total_area / (pack_width * pack_height) if pack_height > 0 else 0
1568        aspect_ratio = pack_width / pack_height if pack_height > 0 else 1.0
1569        rotated_count = sum(1 for island in valid_islands if island.get('rotated', False))
1570        
1571        print(f"\n  Packed {len(valid_islands)} UV islands ({rotated_count} rotated)")
1572        print(f"  Shelves: {len(shelves)}, Aspect ratio: {aspect_ratio:.2f}, Efficiency: {efficiency:.0%}")
1573    
1574    def process_object(self, context, obj):
1575        import time
1576        
1577        print(f"\nProcessing object: {obj.name}")
1578        start_time = time.time()
1579        
1580        bm = bmesh.from_edit_mesh(obj.data)
1581        bm.faces.ensure_lookup_table()
1582        
1583        # Get or create UV layer
1584        if not bm.loops.layers.uv:
1585            bm.loops.layers.uv.new("UVMap")
1586        uv_layer = bm.loops.layers.uv.active
1587        
1588        if not uv_layer:
1589            print("  ERROR: No UV layer available")
1590            return False, {}
1591        
1592        # Get selected faces
1593        selected_faces = [face.index for face in bm.faces if face.select and not face.hide]
1594        
1595        print(f"  Selected faces: {len(selected_faces)}")
1596        
1597        if not selected_faces:
1598            return False, {}
1599        
1600        # Build spatial cache
1601        face_centers, face_bounds = self.build_face_spatial_cache(bm, selected_faces)
1602        
1603        # Build combined adjacency
1604        adjacency = self.build_combined_adjacency(bm, selected_faces, face_centers, face_bounds)
1605        
1606        # Find face groups using flood fill
1607        face_groups = self.find_face_groups_flood_fill(selected_faces, adjacency, bm)
1608        print(f"  Found {len(face_groups)} face groups")
1609        
1610        if not face_groups:
1611            return False, {}
1612        
1613        # Process each group
1614        islands = []
1615        processed_faces = 0
1616        
1617        for group in face_groups:
1618            projection_matrix, center = self.calculate_projection_matrix(group, bm)
1619            if projection_matrix is not None:
1620                island_data = self.project_faces_to_uv(group, projection_matrix, center, bm, uv_layer)
1621                if island_data:
1622                    islands.append(island_data)
1623                    processed_faces += island_data['face_count']
1624        
1625        print(f"  Created {len(islands)} UV islands")
1626        
1627        # Pack islands
1628        if islands:
1629            self.pack_uv_islands_growing(islands, bm, uv_layer)
1630        
1631        bmesh.update_edit_mesh(obj.data)
1632        
1633        print(f"  Total time: {time.time()-start_time:.2f}s")
1634        
1635        return True, {
1636            'groups': len(face_groups),
1637            'islands': len(islands),
1638            'faces': processed_faces
1639        }
1640    
1641    def format_report(self, stats):
1642        if stats['object_count'] == 0:
1643            return "No objects processed"
1644        
1645        return f"Created {stats['islands']} UV islands from {stats['groups']} face groups ({stats['faces']} faces) across {stats['object_count']} object(s)"
1646    
1647    def draw(self, context):
1648        layout = self.layout
1649        layout.prop(self, "angle_threshold")
1650        layout.prop(self, "gap_distance")
1651        layout.prop(self, "island_margin")
1652
1653
1654class MESH_OT_bake_origin_and_orientation_combined(BaseSubmeshOperator):
1655    bl_idname = "mesh.bake_submesh_origin_and_orientation"
1656    bl_label = "Bake Submesh Data"
1657    bl_description = "Bake vertex vectors and orientation quaternions"
1658
1659    contiguous_mode: BoolProperty(
1660        name="Contiguous Groups",
1661        description="Process each contiguous group separately",
1662        default=True
1663    )
1664
1665    normal_epsilon: FloatProperty(
1666        name="Normal Epsilon",
1667        description="Minimum angle difference between normals",
1668        default=0.1,
1669        min=0.01,
1670        max=1.0,
1671        precision=3
1672    )
1673
1674    use_cache: BoolProperty(
1675        name="Cache Identical Submeshes",
1676        description="Cache calculations for identical submeshes to avoid recomputing basis and scale",
1677        default=True
1678    )
1679
1680    def calculate_island_center(self, vertices):
1681        """Calculate center for an island"""
1682        if not vertices:
1683            return None
1684
1685        center = mathutils.Vector((0.0, 0.0, 0.0))
1686        for v in vertices:
1687            center += v.co
1688        center /= len(vertices)
1689
1690        return center
1691    
1692    def calculate_island_scale(self, vertices, center, basis_inv):
1693        """Calculate scale using L-infinity norm in rotated basis"""
1694        if not vertices:
1695            return 1.0
1696        
1697        max_coord = 0.0
1698        for v in vertices:
1699            offset = v.co - center
1700            local_pos = basis_inv @ offset
1701            
1702            max_coord = max(max_coord, abs(local_pos.x), abs(local_pos.y), abs(local_pos.z))
1703        
1704        scale = 1.0 / max_coord if max_coord > 0 else 1.0
1705        return scale
1706
1707    def build_basis_from_faces(self, faces, epsilon):
1708        """Build orthonormal basis from face normals"""
1709        if not faces:
1710            return mathutils.Matrix.Identity(3)
1711
1712        # Sort faces by area
1713        sorted_faces = sorted(faces, key=lambda f: f.calc_area(), reverse=True)
1714        x_axis = sorted_faces[0].normal.normalized()
1715
1716        epsilon_cos = math.cos(epsilon)
1717        y_axis = None
1718
1719        for face in sorted_faces[1:]:
1720            normal = face.normal.normalized()
1721            if abs(normal.dot(x_axis)) < epsilon_cos:
1722                y_axis = normal - normal.dot(x_axis) * x_axis
1723                y_axis.normalize()
1724                break
1725
1726        if not y_axis:
1727            if abs(x_axis.z) < 0.9:
1728                y_axis = mathutils.Vector((-x_axis.y, x_axis.x, 0))
1729            else:
1730                y_axis = mathutils.Vector((0, -x_axis.z, x_axis.y))
1731            y_axis.normalize()
1732
1733        z_axis = x_axis.cross(y_axis)
1734
1735        matrix = mathutils.Matrix((x_axis, y_axis, z_axis)).transposed()
1736        if matrix.determinant() < 0:
1737            matrix[2] = -matrix[2]
1738
1739        return matrix
1740
1741    def create_submesh_signature(self, vertices, center):
1742        """Create signature for caching - based on relative positions only"""
1743        tolerance = 0.0001
1744        relative_positions = []
1745
1746        for v in vertices:
1747            relative_pos = v.co - center
1748            rounded = tuple(round(relative_pos[i] / tolerance) * tolerance for i in range(3))
1749            relative_positions.append(rounded)
1750
1751        relative_positions.sort()
1752        return (len(vertices), tuple(relative_positions))
1753
1754    def process_object(self, context, obj):
1755        mesh = obj.data
1756        
1757        # Switch to object mode temporarily to ensure vertex colors exist
1758        bpy.ops.object.mode_set(mode='OBJECT')
1759        
1760        if not mesh.vertex_colors:
1761            mesh.vertex_colors.new(name="BakedVectors")
1762        color_layer = mesh.vertex_colors.active
1763
1764        uv_layer0 = MeshUtils.get_or_create_uv_layer(mesh, "BakedOriginAngle0")
1765        uv_layer1 = MeshUtils.get_or_create_uv_layer(mesh, "BakedOriginAngle1")
1766        
1767        # Switch back to edit mode
1768        bpy.ops.object.mode_set(mode='EDIT')
1769        
1770        # Get BMesh for edit mode operations
1771        bm = bmesh.from_edit_mesh(mesh)
1772        bm.verts.ensure_lookup_table()
1773        bm.faces.ensure_lookup_table()
1774        
1775        # Get vertex color and UV layers in BMesh
1776        if not bm.loops.layers.color:
1777            bm.loops.layers.color.new("BakedVectors")
1778        bm_color_layer = bm.loops.layers.color.active
1779        
1780        uv_layers = bm.loops.layers.uv
1781        bm_uv_layer0 = uv_layers.get("BakedOriginAngle0")
1782        bm_uv_layer1 = uv_layers.get("BakedOriginAngle1")
1783        
1784        if not bm_uv_layer0:
1785            bm_uv_layer0 = uv_layers.new("BakedOriginAngle0")
1786        if not bm_uv_layer1:
1787            bm_uv_layer1 = uv_layers.new("BakedOriginAngle1")
1788        
1789        selected_verts = [v for v in bm.verts if v.select]
1790        if not selected_verts:
1791            return False, {}
1792
1793        settings = context.scene.bake_vertex_settings
1794        correction = mathutils.Euler(
1795            (settings.correction_angle_x, settings.correction_angle_y, settings.correction_angle_z), 'XYZ'
1796        ).to_quaternion()
1797
1798        # Get selected faces
1799        selected_faces = []
1800        for face in bm.faces:
1801            if all(v.select for v in face.verts):
1802                selected_faces.append(face)
1803
1804        # Build islands using BMesh vertices
1805        if self.contiguous_mode:
1806            selected_indices = {v.index for v in selected_verts}
1807            adjacency = {v.index: set() for v in selected_verts}
1808            
1809            for edge in bm.edges:
1810                v0, v1 = edge.verts[0].index, edge.verts[1].index
1811                if v0 in selected_indices and v1 in selected_indices:
1812                    adjacency[v0].add(v1)
1813                    adjacency[v1].add(v0)
1814            
1815            island_indices = MeshUtils.find_islands(selected_indices, adjacency)
1816            islands = []
1817            for island_idx_set in island_indices:
1818                island_verts = [bm.verts[idx] for idx in island_idx_set]
1819                islands.append(island_verts)
1820        else:
1821            islands = [selected_verts]
1822
1823        world_matrix = obj.matrix_world
1824        world_inv = world_matrix.inverted()
1825        
1826        submesh_cache = {} if self.use_cache else None
1827
1828        for island_verts in islands:
1829            center = self.calculate_island_center(island_verts)
1830            if center is None:
1831                continue
1832
1833            cache_hit = False
1834            if self.use_cache:
1835                signature = self.create_submesh_signature(island_verts, center)
1836                if signature in submesh_cache:
1837                    scale, basis, quat, basis_inv = submesh_cache[signature]
1838                    cache_hit = True
1839
1840            if not cache_hit:
1841                # Get faces for this island
1842                island_faces = []
1843                island_vert_set = set(island_verts)
1844                for face in selected_faces:
1845                    if all(v in island_vert_set for v in face.verts):
1846                        island_faces.append(face)
1847                
1848                basis = self.build_basis_from_faces(island_faces, self.normal_epsilon)
1849                basis_inv = basis.inverted()
1850                
1851                scale = self.calculate_island_scale(island_verts, center, basis_inv)
1852                
1853                quat = basis.to_quaternion()
1854                quat.normalize()
1855                if quat.w < 0:
1856                    quat.negate()
1857                quat = correction @ quat
1858                
1859                if self.use_cache:
1860                    submesh_cache[signature] = (scale, basis, quat, basis_inv)
1861
1862            center_world = world_matrix @ center
1863
1864            # Apply to each vertex in the island
1865            for vert in island_verts:
1866                vert_world = world_matrix @ vert.co
1867                offset = world_inv.to_3x3() @ (vert_world - center_world)
1868                local_pos = basis_inv @ offset
1869
1870                color = mathutils.Vector((
1871                    (local_pos.x * scale + 1.0) * 0.5,
1872                    (local_pos.y * scale + 1.0) * 0.5,
1873                    (local_pos.z * scale + 1.0) * 0.5,
1874                    scale
1875                ))
1876
1877                # Apply to all loops of this vertex
1878                for face in vert.link_faces:
1879                    if face.select:
1880                        for loop in face.loops:
1881                            if loop.vert == vert:
1882                                loop[bm_color_layer] = color
1883                                loop[bm_uv_layer0].uv = (quat.x, quat.y)
1884                                loop[bm_uv_layer1].uv = (quat.z, quat.w)
1885
1886        # Update the mesh
1887        bmesh.update_edit_mesh(mesh)
1888
1889        return True, {
1890            'islands': len(islands),
1891            'vertices': len(selected_verts)
1892        }
1893
1894    def format_report(self, stats):
1895        if stats['object_count'] == 0:
1896            return "No objects processed"
1897        
1898        return f"Baked {stats['islands']} island(s) with {stats['vertices']} vertices across {stats['object_count']} object(s)"
1899
1900    def draw(self, context):
1901        layout = self.layout
1902        layout.prop(self, "contiguous_mode")
1903        layout.prop(self, "use_cache")
1904        layout.prop(self, "normal_epsilon")
1905
1906        settings = context.scene.bake_vertex_settings
1907        box = layout.box()
1908        box.label(text="Bake Rotation Correction (Degrees)")
1909        row = box.row(align=True)
1910        row.prop(settings, "correction_angle_x")
1911        row.prop(settings, "correction_angle_y")
1912        row.prop(settings, "correction_angle_z")
1913
1914
1915class MESH_PT_bake_vertex_panel(Panel):
1916    bl_label = "Bake Submesh Data"
1917    bl_idname = "MESH_PT_bake_submesh_data"
1918    bl_space_type = 'VIEW_3D'
1919    bl_region_type = 'UI'
1920    bl_category = "Tool"
1921
1922    def draw(self, context):
1923        layout = self.layout
1924        obj = context.active_object
1925
1926        if obj and obj.type == 'MESH' and context.mode == 'EDIT_MESH':
1927            col = layout.column()
1928            col.operator("mesh.bake_submesh_origin_and_orientation", icon='EXPORT')
1929            col.operator("mesh.select_all_linked", icon='SELECT_EXTEND')
1930            col.operator("mesh.select_linked_across_boundaries", icon='LINKED')
1931            col.operator("mesh.select_hidden_faces", icon='GHOST_ENABLED')
1932            col.operator("mesh.deduplicate_submeshes", icon='DUPLICATE')
1933            col.operator("mesh.merge_by_distance_per_submesh", icon='AUTOMERGE_ON')
1934            col.operator("mesh.pack_uv_islands_by_submesh_z", icon='UV')
1935            col.operator("mesh.smart_uv_project_normal_groups", icon='UV_DATA')
1936        else:
1937            layout.label(text="Enter Edit Mode to use tools", icon='INFO')
1938
1939
1940classes = [
1941    BakeVertexSettings,
1942    MESH_OT_select_all_linked,
1943    MESH_OT_select_linked_across_boundaries,
1944    MESH_OT_deduplicate_submeshes,
1945    MESH_OT_select_hidden_faces,
1946    MESH_OT_pack_uv_islands_by_submesh_z,
1947    MESH_OT_merge_by_distance_per_submesh,
1948    MESH_OT_smart_uv_project_normal_groups,
1949    MESH_OT_bake_origin_and_orientation_combined,
1950    MESH_PT_bake_vertex_panel
1951]
1952
1953
1954def menu_func(self, context):
1955    self.layout.separator()
1956    self.layout.operator("mesh.select_all_linked", icon='SELECT_EXTEND')
1957    self.layout.operator("mesh.select_linked_across_boundaries", icon='LINKED')
1958    self.layout.operator("mesh.select_hidden_faces", icon='GHOST_ENABLED')
1959    self.layout.operator("mesh.deduplicate_submeshes", icon='DUPLICATE')
1960    self.layout.operator("mesh.merge_by_distance_per_submesh", icon='AUTOMERGE_ON')
1961    self.layout.operator("mesh.pack_uv_islands_by_submesh_z", icon='UV')
1962    self.layout.operator("mesh.smart_uv_project_normal_groups", icon='UV_DATA')
1963    self.layout.operator("mesh.bake_submesh_origin_and_orientation", icon='EXPORT')
1964
1965
1966def register():
1967    for cls in classes:
1968        bpy.utils.register_class(cls)
1969    bpy.types.VIEW3D_MT_edit_mesh.append(menu_func)
1970    bpy.types.Scene.bake_vertex_settings = PointerProperty(type=BakeVertexSettings)
1971
1972
1973def unregister():
1974    bpy.types.VIEW3D_MT_edit_mesh.remove(menu_func)
1975    del bpy.types.Scene.bake_vertex_settings
1976    for cls in reversed(classes):
1977        bpy.utils.unregister_class(cls)
1978
1979
1980if __name__ == "__main__":
1981    register()