diff options
Diffstat (limited to 'Scripts')
| -rw-r--r-- | Scripts/BakeVertexData.py | 1084 | ||||
| -rw-r--r-- | Scripts/Editor/DecodeVertexData.cs | 213 |
2 files changed, 1297 insertions, 0 deletions
diff --git a/Scripts/BakeVertexData.py b/Scripts/BakeVertexData.py new file mode 100644 index 0000000..3eecf05 --- /dev/null +++ b/Scripts/BakeVertexData.py @@ -0,0 +1,1084 @@ +bl_info = { + "name": "Bake Vertex to Target Vector", + "blender": (4, 2, 0), + "category": "Mesh", + "version": (2, 3, 0), + "author": "yum_food", + "description": "Bake vertex vectors with automatic center and scale calculation in Edit Mode, with submesh deduplication" +} + +import bpy +import mathutils +import bmesh +import math +from bpy.props import BoolProperty, FloatProperty, IntProperty, PointerProperty +from bpy.types import Panel, Operator, PropertyGroup +from collections import deque, defaultdict + + +class BakeVertexSettings(PropertyGroup): + correction_angle_x: FloatProperty( + name="X", + description="Correction angle for the X-axis", + default=math.pi, + subtype='ANGLE' + ) + correction_angle_y: FloatProperty( + name="Y", + description="Correction angle for the Y-axis", + default=0.0, + subtype='ANGLE' + ) + correction_angle_z: FloatProperty( + name="Z", + description="Correction angle for the Z-axis", + default=0.0, + subtype='ANGLE' + ) + + +class MeshUtils: + """Utility functions for mesh operations""" + + @staticmethod + def with_mode(mode): + """Decorator to handle mode switching""" + def decorator(func): + def wrapper(self, context, *args, **kwargs): + original_mode = context.mode + if mode == 'OBJECT' and context.mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') + elif mode == 'EDIT' and context.mode != 'EDIT_MESH': + bpy.ops.object.mode_set(mode='EDIT') + + try: + result = func(self, context, *args, **kwargs) + finally: + if original_mode == 'EDIT_MESH' and context.mode != 'EDIT_MESH': + bpy.ops.object.mode_set(mode='EDIT') + elif original_mode == 'OBJECT' and context.mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') + + return result + return wrapper + return decorator + + @staticmethod + def with_multi_object_support(process_func_name='process_object'): + """Decorator to add multi-object support to operators + + The decorated execute method should return a tuple of (success, stats_dict) + where stats_dict contains the statistics to aggregate across objects. + """ + def decorator(func): + def wrapper(self, context, *args, **kwargs): + # Store the original active object + original_active = context.active_object + + # Get all selected mesh objects + selected_objects = [obj for obj in context.selected_objects if obj.type == 'MESH'] + if not selected_objects: + self.report({'WARNING'}, "No mesh objects selected") + return {'CANCELLED'} + + # Initialize aggregated stats + total_stats = {} + processed_count = 0 + + # Process each selected object + for obj in selected_objects: + # Make this object active temporarily + context.view_layer.objects.active = obj + + # Call the actual processing method + if hasattr(self, process_func_name): + success, stats = getattr(self, process_func_name)(context, obj) + else: + # Fallback to calling the decorated function + result = func(self, context, obj, *args, **kwargs) + if isinstance(result, tuple) and len(result) == 2: + success, stats = result + else: + success, stats = (result == {'FINISHED'}), {} + + if success: + processed_count += 1 + # Aggregate statistics + for key, value in stats.items(): + if isinstance(value, (int, float)): + total_stats[key] = total_stats.get(key, 0) + value + else: + # For non-numeric values, just store the last one + total_stats[key] = value + + # Restore original active object + context.view_layer.objects.active = original_active + + # Report results + total_stats['object_count'] = processed_count + if hasattr(self, 'format_report'): + message = self.format_report(total_stats) + else: + # Default reporting + if processed_count == 0: + self.report({'WARNING'}, "No objects processed") + return {'CANCELLED'} + message = f"Processed {processed_count} object(s)" + + if message: + self.report({'INFO'}, message) + + return {'FINISHED'} + return wrapper + return decorator + + @staticmethod + def get_selected_vertices(mesh): + """Get indices of selected vertices""" + return {v.index for v in mesh.vertices if v.select} + + @staticmethod + def build_adjacency(mesh, vertex_indices): + """Build adjacency list for given vertices""" + adjacency = {idx: set() for idx in vertex_indices} + for edge in mesh.edges: + v0, v1 = edge.vertices + if v0 in adjacency and v1 in adjacency: + adjacency[v0].add(v1) + adjacency[v1].add(v0) + return adjacency + + @staticmethod + def flood_fill(start_nodes, adjacency_func): + """Generic flood fill algorithm""" + visited = set() + result = set() + queue = deque(start_nodes) + + while queue: + current = queue.popleft() + if current in visited: + continue + visited.add(current) + result.add(current) + + for neighbor in adjacency_func(current): + if neighbor not in visited: + queue.append(neighbor) + + return result + + @staticmethod + def find_islands(nodes, adjacency_func): + """Find connected components""" + islands = [] + visited = set() + + for node in nodes: + if node in visited: + continue + + island = MeshUtils.flood_fill([node], lambda n: adjacency_func.get(n, [])) + visited.update(island) + islands.append(island) + + return islands + + @staticmethod + def select_edges_and_faces(mesh): + """Select edges and faces based on selected vertices""" + for edge in mesh.edges: + v0, v1 = edge.vertices + if mesh.vertices[v0].select and mesh.vertices[v1].select: + edge.select = True + + for face in mesh.polygons: + if all(mesh.vertices[v].select for v in face.vertices): + face.select = True + + +class BaseSubmeshOperator(Operator): + """Base class for submesh operations""" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + obj = context.active_object + return obj and obj.type == 'MESH' and context.mode == 'EDIT_MESH' + + def get_selected_submeshes(self, mesh): + """Get selected vertices grouped by submesh""" + selected = MeshUtils.get_selected_vertices(mesh) + if not selected: + return [] + adjacency = MeshUtils.build_adjacency(mesh, selected) + return MeshUtils.find_islands(selected, adjacency) + + +class MESH_OT_select_all_linked(BaseSubmeshOperator): + bl_idname = "mesh.select_all_linked" + bl_label = "Select All Linked Submeshes" + bl_description = "Select all vertices in any submesh that has at least one vertex selected" + + def process_object(self, context, obj): + """Process a single object and return (success, stats)""" + mesh = obj.data + initially_selected = MeshUtils.get_selected_vertices(mesh) + + if not initially_selected: + return False, {} + + all_vertices = set(range(len(mesh.vertices))) + adjacency = MeshUtils.build_adjacency(mesh, all_vertices) + islands = MeshUtils.find_islands(all_vertices, adjacency) + + expanded_count = 0 + affected_islands = 0 + + for island in islands: + if island & initially_selected: + new_selections = island - initially_selected + if new_selections: + expanded_count += len(new_selections) + affected_islands += 1 + for idx in island: + mesh.vertices[idx].select = True + + MeshUtils.select_edges_and_faces(mesh) + + return True, { + 'affected_islands': affected_islands, + 'expanded_count': expanded_count + } + + def format_report(self, stats): + """Format the aggregated statistics into a report message""" + if stats['object_count'] == 0: + return "No objects with selected vertices found" + return f"Expanded selection in {stats['affected_islands']} submeshes ({stats['expanded_count']} new vertices) across {stats['object_count']} object(s)" + + @MeshUtils.with_mode('OBJECT') + @MeshUtils.with_multi_object_support('process_object') + def execute(self, context): + # The decorator handles everything + pass + + +class MESH_OT_select_linked_across_boundaries(BaseSubmeshOperator): + bl_idname = "mesh.select_linked_across_boundaries" + bl_label = "Select Linked (Cross Boundaries)" + bl_description = "Select linked vertices, crossing submesh boundaries where vertices share locations" + + epsilon: FloatProperty( + name="Location Tolerance", + description="Maximum distance for vertices to be considered at the same location", + default=0.0001, + min=0.0, + max=1.0, + precision=6, + subtype='DISTANCE' + ) + + def build_combined_adjacency(self, mesh): + """Build adjacency including both edges and position-based connections""" + edge_adjacency = MeshUtils.build_adjacency(mesh, set(range(len(mesh.vertices)))) + + # Build position adjacency + scale = min(1.0 / self.epsilon, 1e7) if self.epsilon > 0 else 1e7 + position_map = defaultdict(list) + + for v in mesh.vertices: + key = tuple(int(v.co[i] * scale) for i in range(3)) + position_map[key].append(v.index) + + position_adjacency = {} + for vertices_at_pos in position_map.values(): + if len(vertices_at_pos) > 1: + for i, v1 in enumerate(vertices_at_pos): + if v1 not in position_adjacency: + position_adjacency[v1] = set() + position_adjacency[v1].update(vertices_at_pos[i+1:]) + for v2 in vertices_at_pos[i+1:]: + if v2 not in position_adjacency: + position_adjacency[v2] = set() + position_adjacency[v2].add(v1) + + # Combine adjacencies + def combined_adjacency(vertex): + neighbors = set() + if vertex in edge_adjacency: + neighbors.update(edge_adjacency[vertex]) + if vertex in position_adjacency: + neighbors.update(position_adjacency[vertex]) + return neighbors + + return combined_adjacency + + def process_object(self, context, obj): + """Process a single object and return (success, stats)""" + mesh = obj.data + initially_selected = MeshUtils.get_selected_vertices(mesh) + + if not initially_selected: + return False, {} + + combined_adjacency = self.build_combined_adjacency(mesh) + visited = MeshUtils.flood_fill(initially_selected, combined_adjacency) + + for idx in visited: + mesh.vertices[idx].select = True + + MeshUtils.select_edges_and_faces(mesh) + expanded_count = len(visited) - len(initially_selected) + + return True, { + 'selected': len(visited), + 'expanded': expanded_count + } + + def format_report(self, stats): + """Format the aggregated statistics into a report message""" + if stats['object_count'] == 0: + return "No objects with selected vertices found" + return f"Selected {stats['selected']} vertices ({stats['expanded']} new) across {stats['object_count']} object(s)" + + @MeshUtils.with_mode('OBJECT') + @MeshUtils.with_multi_object_support('process_object') + def execute(self, context): + # The decorator handles everything + pass + + def draw(self, context): + layout = self.layout + layout.prop(self, "epsilon") + layout.label(text="Connects vertices at same location", icon='INFO') + + +class MESH_OT_deduplicate_submeshes(BaseSubmeshOperator): + bl_idname = "mesh.deduplicate_submeshes" + bl_label = "Deduplicate Submeshes" + bl_description = "Remove duplicate submeshes from selection that have vertices at the same locations" + + tolerance: FloatProperty( + name="Position Tolerance", + description="Maximum distance for vertices to be considered at the same position", + default=0.0001, + min=0.0, + max=1.0, + precision=6 + ) + + def get_island_signature(self, mesh, island_indices): + """Create a hash for an island based on vertex positions""" + decimal_places = 6 if self.tolerance == 0 else max(0, int(-math.log10(self.tolerance))) + + positions = [] + for idx in island_indices: + co = mesh.vertices[idx].co + rounded = tuple(round(co[i], decimal_places) for i in range(3)) + positions.append(rounded) + + positions.sort() + return tuple(positions) + + def process_object(self, context, obj): + """Process a single object and return (success, stats)""" + mesh = obj.data + islands = self.get_selected_submeshes(mesh) + + if len(islands) <= 1: + return False, {} + + # Group islands by signature + island_groups = defaultdict(list) + for island in islands: + signature = self.get_island_signature(mesh, island) + island_groups[signature].append(island) + + # Find duplicates to remove + vertices_to_delete = set() + duplicate_count = 0 + + for group in island_groups.values(): + if len(group) > 1: + for island in group[1:]: + vertices_to_delete.update(island) + duplicate_count += 1 + + if not vertices_to_delete: + return False, {} + + # Select vertices to delete + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='DESELECT') + bpy.ops.object.mode_set(mode='OBJECT') + + for idx in vertices_to_delete: + mesh.vertices[idx].select = True + + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.delete(type='VERT') + bpy.ops.object.mode_set(mode='OBJECT') + + return True, { + 'duplicates': duplicate_count, + 'vertices_deleted': len(vertices_to_delete) + } + + def format_report(self, stats): + """Format the aggregated statistics into a report message""" + if stats['object_count'] == 0: + return "No duplicate submeshes found" + return f"Removed {stats['duplicates']} duplicate submeshes ({stats['vertices_deleted']} vertices) across {stats['object_count']} object(s)" + + @MeshUtils.with_mode('OBJECT') + @MeshUtils.with_multi_object_support('process_object') + def execute(self, context): + # The decorator handles everything + pass + + def draw(self, context): + layout = self.layout + layout.prop(self, "tolerance") + layout.label(text="Set to 0 for exact matching", icon='INFO') + + +class MESH_OT_pack_uv_islands_by_submesh_z(BaseSubmeshOperator): + bl_idname = "mesh.pack_uv_islands_by_submesh_z" + bl_label = "Pack UV Islands by Submesh Z" + bl_description = "Pack UV islands vertically sorted by submesh Z position" + + padding: FloatProperty( + name="Island Padding", + description="Padding between UV islands", + default=0.02, + min=0.0, + max=0.1, + precision=3 + ) + + max_islands_per_row: IntProperty( + name="Max Islands Per Row", + description="Maximum number of islands per row", + default=100, + min=1, + max=1000 + ) + + lock_overlapping: BoolProperty( + name="Lock Overlapping Islands", + description="Treat overlapping UV islands as a single island", + default=False + ) + + skip_overlap_check: BoolProperty( + name="Skip Overlap Check", + description="Skip overlap detection for better performance", + default=False + ) + + @classmethod + def poll(cls, context): + obj = context.active_object + return (obj and obj.type == 'MESH' and + context.mode == 'EDIT_MESH' and + obj.data.uv_layers.active) + + def get_uv_islands(self, bm, uv_layer): + """Find all UV islands in the mesh""" + uv_vert_map = {} + uv_adjacency = defaultdict(set) + + for face in bm.faces: + if not face.select: + continue + + face_uvs = [] + for loop in face.loops: + uv = loop[uv_layer].uv + uv_key = (round(uv.x, 6), round(uv.y, 6)) + face_uvs.append(uv_key) + + if uv_key not in uv_vert_map: + uv_vert_map[uv_key] = set() + uv_vert_map[uv_key].add(loop.vert.index) + + # Create UV edges + for i in range(len(face_uvs)): + j = (i + 1) % len(face_uvs) + uv_adjacency[face_uvs[i]].add(face_uvs[j]) + uv_adjacency[face_uvs[j]].add(face_uvs[i]) + + # Find connected components + islands = [] + for start_uv in uv_vert_map: + if any(start_uv in island['uvs'] for island in islands): + continue + + island_uvs = MeshUtils.flood_fill([start_uv], lambda uv: uv_adjacency.get(uv, [])) + island_vert_indices = set() + for uv in island_uvs: + island_vert_indices.update(uv_vert_map[uv]) + + islands.append({ + 'uvs': island_uvs, + 'vert_indices': island_vert_indices, + 'loops': [] + }) + + return islands + + def execute(self, context): + obj = context.active_object + mesh = obj.data + + bm = bmesh.from_edit_mesh(mesh) + bm_uv_layer = bm.loops.layers.uv.active + + # Get UV islands + uv_islands = self.get_uv_islands(bm, bm_uv_layer) + if not uv_islands: + self.report({'WARNING'}, "No UV islands found") + return {'CANCELLED'} + + # Get submeshes and their Z values + bpy.ops.object.mode_set(mode='OBJECT') + submeshes = self.get_selected_submeshes(mesh) + + submesh_z_values = [] + for submesh in submeshes: + avg_z = sum(mesh.vertices[idx].co.z for idx in submesh) / len(submesh) + submesh_z_values.append(avg_z) + + # Build vertex to submesh mapping + vertex_to_submesh = {} + for i, submesh in enumerate(submeshes): + for v in submesh: + vertex_to_submesh[v] = i + + bpy.ops.object.mode_set(mode='EDIT') + bm = bmesh.from_edit_mesh(mesh) + bm_uv_layer = bm.loops.layers.uv.active + + # Build UV to loops mapping + uv_to_loops = defaultdict(list) + for face in bm.faces: + if face.select: + for loop in face.loops: + uv = loop[bm_uv_layer].uv + uv_key = (round(uv.x, 6), round(uv.y, 6)) + uv_to_loops[uv_key].append(loop) + + # Assign loops and create island data + island_data = [] + for island in uv_islands: + island['loops'] = [] + for uv_key in island['uvs']: + island['loops'].extend(uv_to_loops.get(uv_key, [])) + + if not island['loops']: + continue + + # Find submesh + submesh_idx = None + for v_idx in island['vert_indices']: + if v_idx in vertex_to_submesh: + submesh_idx = vertex_to_submesh[v_idx] + break + + if submesh_idx is not None: + # Get bounds + min_u = min_v = float('inf') + max_u = max_v = float('-inf') + + for loop in island['loops']: + uv = loop[bm_uv_layer].uv + min_u = min(min_u, uv.x) + max_u = max(max_u, uv.x) + min_v = min(min_v, uv.y) + max_v = max(max_v, uv.y) + + width = max_u - min_u + height = max_v - min_v + + if width > 0 and height > 0: + island_data.append({ + 'island': island, + 'submesh_z': submesh_z_values[submesh_idx], + 'bounds': (min_u, min_v, max_u, max_v), + 'width': width, + 'height': height + }) + + # Sort by submesh Z + island_data.sort(key=lambda x: x['submesh_z'], reverse=True) + + # Calculate scale + total_area = sum((d['width'] + self.padding) * (d['height'] + self.padding) + for d in island_data) + target_size = min(0.95, math.sqrt(total_area) * 1.2) + scale_factor = 0.95 / target_size if target_size > 1.0 else 1.0 + + # Pack islands + current_v = 0.95 + current_row = [] + + for data in island_data: + width = data['width'] * scale_factor + height = data['height'] * scale_factor + + # Start new row if needed + if current_row and (sum(d['width'] * scale_factor + self.padding for d in current_row) + width > target_size + or len(current_row) >= self.max_islands_per_row): + # Place current row + current_u = 0.025 + row_height = max(d['height'] * scale_factor for d in current_row) + + for row_data in current_row: + offset_u = current_u - row_data['bounds'][0] * scale_factor + offset_v = current_v - row_data['bounds'][3] * scale_factor + + for loop in row_data['island']['loops']: + uv = loop[bm_uv_layer].uv + uv.x = uv.x * scale_factor + offset_u + uv.y = uv.y * scale_factor + offset_v + + current_u += row_data['width'] * scale_factor + self.padding + + current_v -= row_height + self.padding + current_row = [] + + current_row.append(data) + + # Place final row + if current_row: + current_u = 0.025 + for row_data in current_row: + offset_u = current_u - row_data['bounds'][0] * scale_factor + offset_v = current_v - row_data['bounds'][3] * scale_factor + + for loop in row_data['island']['loops']: + uv = loop[bm_uv_layer].uv + uv.x = uv.x * scale_factor + offset_u + uv.y = uv.y * scale_factor + offset_v + + current_u += row_data['width'] * scale_factor + self.padding + + bmesh.update_edit_mesh(mesh) + self.report({'INFO'}, f"Packed {len(island_data)} UV islands from {len(submeshes)} submeshes") + return {'FINISHED'} + + def draw(self, context): + layout = self.layout + layout.prop(self, "padding") + layout.prop(self, "max_islands_per_row") + layout.prop(self, "lock_overlapping") + layout.prop(self, "skip_overlap_check") + + +class MESH_OT_merge_by_distance_per_submesh(BaseSubmeshOperator): + bl_idname = "mesh.merge_by_distance_per_submesh" + bl_label = "Merge by Distance (Per Submesh)" + bl_description = "Merge vertices by distance within each submesh separately" + + merge_distance: FloatProperty( + name="Merge Distance", + description="Maximum distance for merging vertices", + default=0.001, + min=0.0, + max=1.0, + precision=6, + subtype='DISTANCE' + ) + + def process_object(self, context, obj): + """Process a single object and return (success, stats)""" + mesh = obj.data + bm = bmesh.from_edit_mesh(mesh) + + selected_verts = [v for v in bm.verts if v.select] + if not selected_verts: + return False, {} + + # Build islands using BMesh + selected_set = set(selected_verts) + island_verts = [] + visited = set() + + for start_v in selected_verts: + if start_v in visited: + continue + + # Find island + island = [] + stack = [start_v] + + while stack: + current = stack.pop() + if current in visited: + continue + visited.add(current) + island.append(current) + + for edge in current.link_edges: + other = edge.other_vert(current) + if other in selected_set and other not in visited: + stack.append(other) + + island_verts.append(island) + + # Merge within each island + merge_targets = {} + merge_dist_sq = self.merge_distance ** 2 + merged_count = 0 + + for verts in island_verts: + if len(verts) < 2: + continue + + processed = set() + for i, v1 in enumerate(verts): + if v1 in merge_targets or v1 in processed: + continue + processed.add(v1) + + for v2 in verts[i+1:]: + if v2 in merge_targets or v2 in processed: + continue + + if (v1.co - v2.co).length_squared <= merge_dist_sq: + merge_targets[v2] = v1 + processed.add(v2) + merged_count += 1 + + # Perform merges + if merge_targets: + targetmap = {v: merge_targets[v] for v in merge_targets if v.is_valid} + if targetmap: + bmesh.ops.weld_verts(bm, targetmap=targetmap) + + bmesh.update_edit_mesh(mesh) + + return merged_count > 0, {'merged': merged_count} + + def format_report(self, stats): + """Format the aggregated statistics into a report message""" + if stats.get('merged', 0) > 0: + return f"Merged {stats['merged']} vertices across {stats['object_count']} object(s)" + else: + return "No vertices close enough to merge" + + @MeshUtils.with_multi_object_support('process_object') + def execute(self, context): + # The decorator handles everything + pass + + def draw(self, context): + layout = self.layout + layout.prop(self, "merge_distance") + + +class MESH_OT_bake_origin_and_orientation_combined(BaseSubmeshOperator): + bl_idname = "mesh.bake_submesh_origin_and_orientation" + bl_label = "Bake Submesh Data" + bl_description = "Bake vertex vectors and orientation quaternions" + + contiguous_mode: BoolProperty( + name="Contiguous Groups", + description="Process each contiguous group separately", + default=True + ) + + normal_epsilon: FloatProperty( + name="Normal Epsilon", + description="Minimum angle difference between normals", + default=0.1, + min=0.01, + max=1.0, + precision=3 + ) + + use_cache: BoolProperty( + name="Cache Identical Submeshes", + description="Cache calculations for identical submeshes to avoid recomputing basis and scale", + default=True + ) + + def calculate_island_center(self, mesh, island_indices): + """Calculate center for an island""" + if not island_indices: + return None + + center = mathutils.Vector((0.0, 0.0, 0.0)) + for idx in island_indices: + center += mesh.vertices[idx].co + center /= len(island_indices) + + return center + + def calculate_island_scale(self, mesh, island_indices, center, basis_inv): + """Calculate scale using L-infinity norm in rotated basis""" + if not island_indices: + return 1.0 + + max_coord = 0.0 + for idx in island_indices: + # Transform to local rotated basis + offset = mesh.vertices[idx].co - center + local_pos = basis_inv @ offset + + # L-infinity norm: max of absolute values + max_coord = max(max_coord, abs(local_pos.x), abs(local_pos.y), abs(local_pos.z)) + + scale = 1.0 / max_coord if max_coord > 0 else 1.0 + return scale + + def build_basis_from_faces(self, mesh, face_indices, epsilon): + """Build orthonormal basis from face normals""" + if not face_indices: + return mathutils.Matrix.Identity(3) + + # Get largest face normal + faces = sorted(face_indices, key=lambda i: mesh.polygons[i].area, reverse=True) + x_axis = mesh.polygons[faces[0]].normal.normalized() + + # Find second normal + epsilon_cos = math.cos(epsilon) + y_axis = None + + for face_idx in faces[1:]: + normal = mesh.polygons[face_idx].normal.normalized() + if abs(normal.dot(x_axis)) < epsilon_cos: + y_axis = normal - normal.dot(x_axis) * x_axis + y_axis.normalize() + break + + if not y_axis: + if abs(x_axis.z) < 0.9: + y_axis = mathutils.Vector((-x_axis.y, x_axis.x, 0)) + else: + y_axis = mathutils.Vector((0, -x_axis.z, x_axis.y)) + y_axis.normalize() + + z_axis = x_axis.cross(y_axis) + + matrix = mathutils.Matrix((x_axis, y_axis, z_axis)).transposed() + if matrix.determinant() < 0: + matrix[2] = -matrix[2] + + return matrix + + def create_submesh_signature(self, mesh, island_indices, center): + """Create signature for caching - based on relative positions only""" + tolerance = 0.0001 + relative_positions = [] + + for idx in island_indices: + relative_pos = mesh.vertices[idx].co - center + # Round to tolerance to handle floating point precision + rounded = tuple(round(relative_pos[i] / tolerance) * tolerance for i in range(3)) + relative_positions.append(rounded) + + relative_positions.sort() + return (len(island_indices), tuple(relative_positions)) + + def process_object(self, context, obj): + """Process a single object and return (success, stats)""" + mesh = obj.data + selected = MeshUtils.get_selected_vertices(mesh) + + if not selected: + return False, {} + + # Create/get data layers + if not mesh.vertex_colors: + mesh.vertex_colors.new(name="BakedVectors") + color_layer = mesh.vertex_colors.active + + uv_layer0 = mesh.uv_layers.get("BakedOriginAngle0") or mesh.uv_layers.new(name="BakedOriginAngle0") + uv_layer1 = mesh.uv_layers.get("BakedOriginAngle1") or mesh.uv_layers.new(name="BakedOriginAngle1") + + # Get correction quaternion + settings = context.scene.bake_vertex_settings + correction = mathutils.Euler( + (settings.correction_angle_x, settings.correction_angle_y, settings.correction_angle_z), 'XYZ' + ).to_quaternion() + + # Build vertex to loops mapping + vertex_loops = defaultdict(list) + selected_faces = [] + + for poly in mesh.polygons: + face_verts = set(poly.vertices) + if face_verts & selected: + if face_verts <= selected: + selected_faces.append(poly.index) + + for loop_idx in poly.loop_indices: + vert_idx = mesh.loops[loop_idx].vertex_index + if vert_idx in selected: + vertex_loops[vert_idx].append(loop_idx) + + # Get islands + islands = self.get_selected_submeshes(mesh) if self.contiguous_mode else [selected] + + # Process each island + world_matrix = obj.matrix_world + world_inv = world_matrix.inverted() + + # Cache for storing computed basis, scale, and quaternion for identical submeshes + # Key: geometry signature, Value: (scale, basis, quaternion, basis_inverse) + submesh_cache = {} if self.use_cache else None + + for island in islands: + center = self.calculate_island_center(mesh, island) + if center is None: + continue + + # Check cache first (if enabled) + cache_hit = False + if self.use_cache: + signature = self.create_submesh_signature(mesh, island, center) + if signature in submesh_cache: + scale, basis, quat, basis_inv = submesh_cache[signature] + cache_hit = True + + # Only calculate if not in cache + if not cache_hit: + # Calculate basis (compute-intensive) + island_faces = [f for f in selected_faces + if all(v in island for v in mesh.polygons[f].vertices)] + basis = self.build_basis_from_faces(mesh, island_faces, self.normal_epsilon) + basis_inv = basis.inverted() + + # Calculate scale using L-infinity norm in rotated basis (compute-intensive) + scale = self.calculate_island_scale(mesh, island, center, basis_inv) + + # Calculate quaternion + quat = basis.to_quaternion() + quat.normalize() + if quat.w < 0: + quat.negate() + quat = correction @ quat + + # Store in cache if enabled + if self.use_cache: + submesh_cache[signature] = (scale, basis, quat, basis_inv) + + # Transform vertices + center_world = world_matrix @ center + + for vert_idx in island: + vert_world = world_matrix @ mesh.vertices[vert_idx].co + offset = world_inv.to_3x3() @ (vert_world - center_world) + local_pos = basis_inv @ offset + + # Scale and convert to color + color = mathutils.Vector(( + (local_pos.x * scale + 1.0) * 0.5, + (local_pos.y * scale + 1.0) * 0.5, + (local_pos.z * scale + 1.0) * 0.5, + scale + )) + + # Apply to loops + for loop_idx in vertex_loops.get(vert_idx, []): + color_layer.data[loop_idx].color = color + uv_layer0.data[loop_idx].uv = (quat.x, quat.y) + uv_layer1.data[loop_idx].uv = (quat.z, quat.w) + + mesh.update() + + return True, { + 'islands': len(islands), + 'vertices': len(selected) + } + + def format_report(self, stats): + """Format the aggregated statistics into a report message""" + if stats['object_count'] == 0: + return "No objects with selected vertices found" + return f"Baked {stats['islands']} island(s) with {stats['vertices']} vertices across {stats['object_count']} object(s)" + + @MeshUtils.with_mode('OBJECT') + @MeshUtils.with_multi_object_support('process_object') + def execute(self, context): + # The decorator handles everything + pass + + def draw(self, context): + layout = self.layout + layout.prop(self, "contiguous_mode") + layout.prop(self, "use_cache") + layout.prop(self, "normal_epsilon") + + settings = context.scene.bake_vertex_settings + box = layout.box() + box.label(text="Bake Rotation Correction (Degrees)") + row = box.row(align=True) + row.prop(settings, "correction_angle_x") + row.prop(settings, "correction_angle_y") + row.prop(settings, "correction_angle_z") + + +class MESH_PT_bake_vertex_panel(Panel): + bl_label = "Bake Submesh Data" + bl_idname = "MESH_PT_bake_submesh_data" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "Tool" + + def draw(self, context): + layout = self.layout + obj = context.active_object + + if obj and obj.type == 'MESH' and context.mode == 'EDIT_MESH': + col = layout.column() + col.operator("mesh.bake_submesh_origin_and_orientation", icon='EXPORT') + col.operator("mesh.select_all_linked", icon='SELECT_EXTEND') + col.operator("mesh.select_linked_across_boundaries", icon='LINKED') + col.operator("mesh.deduplicate_submeshes", icon='DUPLICATE') + col.operator("mesh.merge_by_distance_per_submesh", icon='AUTOMERGE_ON') + col.operator("mesh.pack_uv_islands_by_submesh_z", icon='UV') + else: + layout.label(text="Enter Edit Mode to use tools", icon='INFO') + + +classes = [ + BakeVertexSettings, + MESH_OT_select_all_linked, + MESH_OT_select_linked_across_boundaries, + MESH_OT_deduplicate_submeshes, + MESH_OT_pack_uv_islands_by_submesh_z, + MESH_OT_merge_by_distance_per_submesh, + MESH_OT_bake_origin_and_orientation_combined, + MESH_PT_bake_vertex_panel +] + + +def menu_func(self, context): + self.layout.separator() + self.layout.operator("mesh.select_all_linked", icon='SELECT_EXTEND') + self.layout.operator("mesh.select_linked_across_boundaries", icon='LINKED') + self.layout.operator("mesh.deduplicate_submeshes", icon='DUPLICATE') + self.layout.operator("mesh.merge_by_distance_per_submesh", icon='AUTOMERGE_ON') + self.layout.operator("mesh.pack_uv_islands_by_submesh_z", icon='UV') + self.layout.operator("mesh.bake_submesh_origin_and_orientation", icon='EXPORT') + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.VIEW3D_MT_edit_mesh.append(menu_func) + bpy.types.Scene.bake_vertex_settings = PointerProperty(type=BakeVertexSettings) + + +def unregister(): + bpy.types.VIEW3D_MT_edit_mesh.remove(menu_func) + del bpy.types.Scene.bake_vertex_settings + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + + +if __name__ == "__main__": + register() diff --git a/Scripts/Editor/DecodeVertexData.cs b/Scripts/Editor/DecodeVertexData.cs new file mode 100644 index 0000000..d248773 --- /dev/null +++ b/Scripts/Editor/DecodeVertexData.cs @@ -0,0 +1,213 @@ +using UnityEngine; +using System.Collections.Generic; +using System.Linq; + +public class DecodeVertexVectors : MonoBehaviour +{ + [Header("Display Settings")] + [SerializeField] private int maxVertices = 100; + [SerializeField] private float vectorScale = 0.3f; + + [Header("Edge Visualization")] + [SerializeField] private bool showEdges = true; + [SerializeField] private int edgeSubdivisions = 2; + + [Header("Orientation")] + [SerializeField] private bool showOrientations = true; + [SerializeField] private float orientationScale = 1.0f; + + [Header("UV Channels")] + [SerializeField] private int quaternionXYChannel = 1; + [SerializeField] private int quaternionZWChannel = 2; + + [Header("Colors")] + [SerializeField] private Color vectorColor = new Color(0.5f, 0.8f, 1f); + [SerializeField] private Color correctedVectorColor = new Color(1f, 0.5f, 0.2f); + [SerializeField] private Color forwardColor = Color.blue; + + private void OnDrawGizmos() + { + var meshFilter = GetComponent<MeshFilter>(); + if (!meshFilter || !meshFilter.sharedMesh) return; + + var mesh = meshFilter.sharedMesh; + var vertices = mesh.vertices; + var colors = mesh.colors; + + // Draw vertex vectors from colors + if (colors != null && colors.Length > 0) + { + DrawVertexVectors(mesh, vertices, colors); + } + + // Draw orientations from UVs + if (showOrientations) + { + DrawOrientations(mesh, vertices); + } + } + + void DrawVertexVectors(Mesh mesh, Vector3[] vertices, Color[] colors) + { + Vector2[] uvXY = GetUVData(mesh, quaternionXYChannel); + Vector2[] uvZW = GetUVData(mesh, quaternionZWChannel); + bool hasQuaternions = uvXY != null && uvZW != null; + + int vertexStep = Mathf.Max(1, vertices.Length / maxVertices); + + // Draw vectors at vertices + for (int i = 0; i < vertices.Length; i += vertexStep) + { + if (i >= colors.Length) break; + + Vector3 worldPos = transform.TransformPoint(vertices[i]); + Vector3 decodedVector = DecodeVectorFromColor(colors[i]); + + // Basic vector + Gizmos.color = vectorColor; + DrawVector(worldPos, transform.TransformDirection(decodedVector), vectorScale); + + // Quaternion-corrected vector + if (hasQuaternions && i < uvXY.Length && i < uvZW.Length) + { + Quaternion quat = GetQuaternionFromUV(uvXY[i], uvZW[i]); + Vector3 corrected = quat * decodedVector; + + Gizmos.color = correctedVectorColor; + DrawVector(worldPos, transform.TransformDirection(corrected), vectorScale); + } + } + + // Draw edge interpolations + if (showEdges && edgeSubdivisions > 0) + { + DrawEdgeInterpolations(mesh, vertices, colors, uvXY, uvZW); + } + } + + void DrawEdgeInterpolations(Mesh mesh, Vector3[] vertices, Color[] colors, Vector2[] uvXY, Vector2[] uvZW) + { + var triangles = mesh.triangles; + HashSet<(int, int)> drawnEdges = new HashSet<(int, int)>(); + bool hasQuaternions = uvXY != null && uvZW != null; + + for (int i = 0; i < triangles.Length && drawnEdges.Count < maxVertices/2; i += 3) + { + for (int j = 0; j < 3; j++) + { + int v1 = triangles[i + j]; + int v2 = triangles[i + ((j + 1) % 3)]; + + var edge = v1 < v2 ? (v1, v2) : (v2, v1); + if (!drawnEdges.Add(edge)) continue; + + if (v1 >= vertices.Length || v2 >= vertices.Length || + v1 >= colors.Length || v2 >= colors.Length) continue; + + // Draw subdivisions along edge + for (int k = 1; k < edgeSubdivisions; k++) + { + float t = k / (float)edgeSubdivisions; + Vector3 pos = Vector3.Lerp(vertices[v1], vertices[v2], t); + Color col = Color.Lerp(colors[v1], colors[v2], t); + + Vector3 worldPos = transform.TransformPoint(pos); + Vector3 vec = DecodeVectorFromColor(col); + + // Basic vector + Gizmos.color = vectorColor * 0.7f; // Slightly dimmer for edge points + DrawVector(worldPos, transform.TransformDirection(vec), vectorScale * 0.8f); + + // Quaternion-corrected vector + if (hasQuaternions && v1 < uvXY.Length && v2 < uvXY.Length && + v1 < uvZW.Length && v2 < uvZW.Length) + { + Vector2 interpXY = Vector2.Lerp(uvXY[v1], uvXY[v2], t); + Vector2 interpZW = Vector2.Lerp(uvZW[v1], uvZW[v2], t); + Quaternion interpQuat = GetQuaternionFromUV(interpXY, interpZW); + Vector3 corrected = interpQuat * vec; + + Gizmos.color = correctedVectorColor * 0.7f; // Slightly dimmer for edge points + DrawVector(worldPos, transform.TransformDirection(corrected), vectorScale * 0.8f); + } + } + } + } + } + + void DrawOrientations(Mesh mesh, Vector3[] vertices) + { + Vector2[] uvXY = GetUVData(mesh, quaternionXYChannel); + Vector2[] uvZW = GetUVData(mesh, quaternionZWChannel); + + if (uvXY == null || uvZW == null) return; + + int vertexStep = Mathf.Max(1, vertices.Length / maxVertices); + + for (int i = 0; i < vertices.Length; i += vertexStep) + { + if (i >= uvXY.Length || i >= uvZW.Length) break; + + Vector3 worldPos = transform.TransformPoint(vertices[i]); + Quaternion quat = GetQuaternionFromUV(uvXY[i], uvZW[i]); + + // Draw forward direction + Gizmos.color = forwardColor; + Vector3 forward = transform.TransformDirection(quat * Vector3.forward); + DrawArrow(worldPos, forward, orientationScale); + } + } + + void DrawVector(Vector3 origin, Vector3 direction, float scale) + { + Vector3 end = origin + direction * scale; + Gizmos.DrawLine(origin, end); + Gizmos.DrawSphere(end, 0.02f); + } + + void DrawArrow(Vector3 origin, Vector3 direction, float length) + { + Vector3 end = origin + direction * length; + Gizmos.DrawLine(origin, end); + + // Simple arrowhead + Vector3 right = Vector3.Cross(direction, Vector3.up).normalized; + if (right.magnitude < 0.01f) + right = Vector3.Cross(direction, Vector3.right).normalized; + + Vector3 arrowBack = -direction * length * 0.2f; + Vector3 arrowSide = right * length * 0.1f; + + Gizmos.DrawLine(end, end + arrowBack + arrowSide); + Gizmos.DrawLine(end, end + arrowBack - arrowSide); + } + + Quaternion GetQuaternionFromUV(Vector2 xy, Vector2 zw) + { + return new Quaternion(xy.x, xy.y, zw.x, zw.y).normalized; + } + + Vector3 DecodeVectorFromColor(Color color) + { + return new Vector3( + color.r * 2.0f - 1.0f, + color.g * 2.0f - 1.0f, + color.b * 2.0f - 1.0f) / color.a; + } + + Vector2[] GetUVData(Mesh mesh, int channel) + { + switch (channel) + { + case 0: return mesh.uv; + case 1: return mesh.uv2; + case 2: return mesh.uv3; + case 3: return mesh.uv4; + case 4: return mesh.uv5; + case 5: return mesh.uv6; + case 6: return mesh.uv7; + case 7: return mesh.uv8; + default: return null; + } + } +} |
