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