#!/usr/bin/env python # -*- coding: utf-8 -*- """ Maya LOD Fast Retopology Tool - Simplified Version Single-page interface with streamlined workflow """ import maya.cmds as cmds import maya.api.OpenMaya as om import maya.mel as mel import math import heapq from collections import defaultdict try: import numpy as np HAS_NUMPY = True except ImportError: HAS_NUMPY = False class NumpyCompat: @staticmethod def array(data): return list(data) if isinstance(data, (list, tuple)) else [data] @staticmethod def zeros(shape): if isinstance(shape, tuple) and len(shape) == 2: return [[0.0]*shape[1] for _ in range(shape[0])] return [0.0]*shape @staticmethod def outer(a, b): return [[a[i]*b[j] for j in range(len(b))] for i in range(len(a))] @staticmethod def cross(a, b): return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]] @staticmethod def dot(a, b): return sum(x*y for x, y in zip(a, b)) @staticmethod def norm(vec): return math.sqrt(sum(x*x for x in vec)) class linalg: @staticmethod def norm(vec): return math.sqrt(sum(x*x for x in vec)) np = NumpyCompat() from Qt import QtWidgets, QtCore, QtGui # ===================== Core Algorithm ===================== class QuadricErrorMetric: def __init__(self): self.Q = [[0.0]*4 for _ in range(4)] if not HAS_NUMPY else np.zeros((4,4), dtype=np.float64) def from_plane(self, a, b, c, d): p = [a, b, c, d] self.Q = np.outer(p, p) return self def from_triangle(self, v0, v1, v2): if HAS_NUMPY: edge1, edge2 = v1-v0, v2-v0 normal = np.cross(edge1, edge2) normal_len = np.linalg.norm(normal) else: edge1 = [v1[i]-v0[i] for i in range(3)] edge2 = [v2[i]-v0[i] for i in range(3)] normal = np.cross(edge1, edge2) normal_len = np.norm(normal) if normal_len > 1e-10: if HAS_NUMPY: normal = normal/normal_len d = -np.dot(normal, v0) else: normal = [n/normal_len for n in normal] d = -np.dot(normal, v0) return self.from_plane(normal[0], normal[1], normal[2], d) return self def add(self, other): if HAS_NUMPY: self.Q += other.Q else: for i in range(4): for j in range(4): self.Q[i][j] += other.Q[i][j] return self def error(self, v): v_homo = [v[0], v[1], v[2], 1.0] if HAS_NUMPY: v_homo = np.array(v_homo, dtype=np.float64) return abs(v_homo @ self.Q @ v_homo) else: temp = [sum(self.Q[i][j]*v_homo[j] for j in range(4)) for i in range(4)] return abs(sum(temp[i]*v_homo[i] for i in range(4))) def optimal_position(self, v0, v1): return [(v0[i]+v1[i])*0.5 for i in range(3)] class EdgeCollapse: def __init__(self, v0, v1, error, position): self.v0, self.v1, self.error, self.position = v0, v1, error, position def __lt__(self, other): return self.error < other.error class MeshSimplifier: def __init__(self, mesh_fn, vc_strength=3.0): self.mesh_fn = mesh_fn self.vertices, self.faces = [], [] self.edges, self.vertex_faces = defaultdict(set), defaultdict(set) self.quadrics, self.vertex_colors = [], [] self.boundary_edges, self.feature_edges = set(), set() self.vc_strength = vc_strength # Vertex color strength multiplier self.silhouette_weight = 3.0 # Silhouette protection weight self.keep_quads = True # Try to maintain quad topology self.original_quads = set() # Track original quad faces self._extract_mesh_data() self._compute_quadrics() self._detect_original_quads() def _detect_original_quads(self): """Detect which faces in the original mesh were quads""" for poly_id in range(self.mesh_fn.numPolygons): poly_verts = self.mesh_fn.getPolygonVertices(poly_id) if len(poly_verts) == 4: # Mark vertices that were part of quad faces for v_idx in poly_verts: self.original_quads.add(v_idx) def _extract_mesh_data(self): points = self.mesh_fn.getPoints(om.MSpace.kWorld) self.vertices = [np.array([p.x,p.y,p.z]) if HAS_NUMPY else [p.x,p.y,p.z] for p in points] for poly_id in range(self.mesh_fn.numPolygons): # Get polygon vertices poly_verts = self.mesh_fn.getPolygonVertices(poly_id) num_verts = len(poly_verts) # Simple fan triangulation if num_verts == 3: triangles = [[poly_verts[0], poly_verts[1], poly_verts[2]]] elif num_verts == 4: triangles = [ [poly_verts[0], poly_verts[1], poly_verts[2]], [poly_verts[0], poly_verts[2], poly_verts[3]] ] else: triangles = [] for i in range(1, num_verts - 1): triangles.append([poly_verts[0], poly_verts[i], poly_verts[i+1]]) # Add triangles for tri in triangles: v0, v1, v2 = tri[0], tri[1], tri[2] self.faces.append([v0, v1, v2]) for edge in [(v0,v1), (v1,v2), (v2,v0)]: e = tuple(sorted(edge)) self.edges[e].add(len(self.faces)-1) for v in [v0, v1, v2]: self.vertex_faces[v].add(len(self.faces)-1) self._identify_feature_edges() self._extract_vertex_colors() def _identify_feature_edges(self): for edge, faces in self.edges.items(): if len(faces) == 1: self.boundary_edges.add(edge) elif len(faces) == 2: face_ids = list(faces) n0 = self._compute_face_normal(self.faces[face_ids[0]]) n1 = self._compute_face_normal(self.faces[face_ids[1]]) dot = max(-1.0, min(1.0, np.dot(n0, n1))) if math.acos(dot) > math.radians(60): self.feature_edges.add(edge) def _compute_face_normal(self, face): v0, v1, v2 = [self.vertices[i] for i in face] if HAS_NUMPY: edge1, edge2 = v1-v0, v2-v0 normal = np.cross(edge1, edge2) normal_len = np.linalg.norm(normal) return normal/normal_len if normal_len > 1e-10 else np.array([0,0,1]) else: edge1 = [v1[i]-v0[i] for i in range(3)] edge2 = [v2[i]-v0[i] for i in range(3)] normal = np.cross(edge1, edge2) normal_len = np.norm(normal) return [n/normal_len for n in normal] if normal_len > 1e-10 else [0,0,1] def _extract_vertex_colors(self): """ Extract vertex colors for multi-channel density control Red: Preserve areas (high detail protection) Green: Reduce areas (aggressive simplification) Blue: Quad flow areas (maintain quad topology) """ try: color_sets = self.mesh_fn.getColorSetNames() if len(color_sets) > 0: colors = self.mesh_fn.getVertexColors(color_sets[0]) # Store all three channels self.vertex_colors = [(c.r, c.g, c.b) for c in colors] else: self.vertex_colors = [(0.0, 0.0, 0.0)]*len(self.vertices) except: self.vertex_colors = [(0.0, 0.0, 0.0)]*len(self.vertices) def _compute_quadrics(self): self.quadrics = [QuadricErrorMetric() for _ in self.vertices] for face in self.faces: v0, v1, v2 = [self.vertices[i] for i in face] q = QuadricErrorMetric().from_triangle(v0, v1, v2) for v_idx in face: self.quadrics[v_idx].add(q) def compute_edge_collapse_cost(self, v0, v1, protect_silhouette=True): q = QuadricErrorMetric() if HAS_NUMPY: q.Q = self.quadrics[v0].Q + self.quadrics[v1].Q else: for i in range(4): for j in range(4): q.Q[i][j] = self.quadrics[v0].Q[i][j] + self.quadrics[v1].Q[i][j] new_pos = q.optimal_position(self.vertices[v0], self.vertices[v1]) error = q.error(new_pos) # Get vertex colors (RGB tuples) c0 = self.vertex_colors[v0] if v0 < len(self.vertex_colors) else (0.0, 0.0, 0.0) c1 = self.vertex_colors[v1] if v1 < len(self.vertex_colors) else (0.0, 0.0, 0.0) # Red channel: preserve detail (increase error to protect) r0, g0, b0 = c0 r1, g1, b1 = c1 preserve_factor = max(r0, r1) error *= (1.0 + preserve_factor * self.vc_strength) # Green channel: reduce detail (decrease error to simplify more) reduce_factor = max(g0, g1) error *= (1.0 - reduce_factor * 0.5) # Blue channel OR keep_quads: maintain quad topology if self.keep_quads: # Check if both vertices were part of original quads if v0 in self.original_quads and v1 in self.original_quads: error *= 1.5 # Slightly increase cost to preserve quad areas # Blue channel from vertex color: explicit quad preservation blue_factor = max(b0, b1) if blue_factor > 0.1: error *= (1.0 + blue_factor * 2.0) # Strong quad preservation edge = tuple(sorted([v0, v1])) if edge in self.boundary_edges: error *= 10.0 if edge in self.feature_edges: error *= 5.0 if protect_silhouette: silhouette_factor = self._compute_silhouette_importance(v0, v1) error *= (1.0 + silhouette_factor * self.silhouette_weight) return error, new_pos def _compute_silhouette_importance(self, v0, v1): normals = [] for face_id in self.vertex_faces[v0].union(self.vertex_faces[v1]): if face_id < len(self.faces): normals.append(self._compute_face_normal(self.faces[face_id])) if len(normals) < 2: return 0.0 if HAS_NUMPY: variance = np.var(np.array(normals), axis=0).sum() else: mean = [sum(n[i] for n in normals)/len(normals) for i in range(3)] variance = sum(sum((n[i]-mean[i])**2 for n in normals)/len(normals) for i in range(3)) return min(variance/2.0, 1.0) def simplify(self, target_face_count, protect_silhouette=True): current_face_count = len(self.faces) if target_face_count >= current_face_count: return self.vertices, self.faces collapse_heap = [] for edge in self.edges.keys(): error, position = self.compute_edge_collapse_cost(edge[0], edge[1], protect_silhouette) heapq.heappush(collapse_heap, EdgeCollapse(edge[0], edge[1], error, position)) removed_vertices = set() while current_face_count > target_face_count and collapse_heap: collapse = heapq.heappop(collapse_heap) if collapse.v0 in removed_vertices or collapse.v1 in removed_vertices: continue self.vertices[collapse.v0] = collapse.position removed_vertices.add(collapse.v1) self.quadrics[collapse.v0].add(self.quadrics[collapse.v1]) faces_to_remove = set() for face_id in list(self.vertex_faces[collapse.v0].union(self.vertex_faces[collapse.v1])): if face_id >= len(self.faces): continue # Check if face still exists (not None) if self.faces[face_id] is None: continue face = [collapse.v0 if v==collapse.v1 else v for v in self.faces[face_id]] if len(set(face)) < 3: faces_to_remove.add(face_id) else: self.faces[face_id] = face for face_id in faces_to_remove: if face_id < len(self.faces): self.faces[face_id] = None current_face_count -= len(faces_to_remove) valid_faces = [f for f in self.faces if f is not None] used_vertices = set() for face in valid_faces: used_vertices.update(face) vertex_remap = {} new_vertices = [] for old_idx in sorted(used_vertices): vertex_remap[old_idx] = len(new_vertices) new_vertices.append(self.vertices[old_idx]) new_faces = [[vertex_remap[v] for v in face] for face in valid_faces] return new_vertices, new_faces class AOCalculator: """Ambient Occlusion Calculator""" @staticmethod def compute_vertex_ao(mesh_fn, samples=16, max_distance=1.0): vertex_count = mesh_fn.numVertices ao_values = [] for i in range(vertex_count): point = mesh_fn.getPoint(i, om.MSpace.kWorld) normal = mesh_fn.getVertexNormal(i, True, om.MSpace.kWorld) hits = 0 for j in range(samples): phi = 2.0 * math.pi * (j / float(samples)) theta = math.acos(1.0 - (j / float(samples))) dir_local = om.MVector( math.sin(theta) * math.cos(phi), math.sin(theta) * math.sin(phi), math.cos(theta) ) direction = normal * dir_local.z + dir_local direction.normalize() ray_source = om.MFloatPoint(point) ray_dir = om.MFloatVector(direction) try: hit_points = om.MFloatPointArray() result = mesh_fn.allIntersections( ray_source, ray_dir, om.MSpace.kWorld, max_distance, False, hit_points=hit_points ) if result and len(hit_points) > 0: hits += 1 except: pass ao = 1.0 - (hits / float(samples)) ao_values.append(ao) return ao_values class JointDetector: """Joint Position Detector""" @staticmethod def find_joint_influences(mesh_name): influences = {} try: history = cmds.listHistory(mesh_name, pruneDagObjects=True) skin_clusters = cmds.ls(history, type='skinCluster') if not skin_clusters: return influences skin_cluster = skin_clusters[0] vertex_count = cmds.polyEvaluate(mesh_name, vertex=True) for i in range(vertex_count): try: weights = cmds.skinPercent(skin_cluster, f'{mesh_name}.vtx[{i}]', query=True, value=True) if weights: mean = sum(weights) / len(weights) variance = sum((w - mean) ** 2 for w in weights) / len(weights) influences[i] = variance except: pass except: pass return influences # ===================== UI ===================== class LODTableWidget(QtWidgets.QTableWidget): """LOD Level Table""" def __init__(self, parent=None): super(LODTableWidget, self).__init__(parent) self.setColumnCount(3) self.setHorizontalHeaderLabels(['LOD Level', 'Target Faces', 'Percentage']) self.horizontalHeader().setStretchLastSection(True) self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.setMinimumHeight(200) def add_lod_level(self, level, faces, percentage): row = self.rowCount() self.insertRow(row) self.setItem(row, 0, QtWidgets.QTableWidgetItem(f'LOD{level}')) face_item = QtWidgets.QTableWidgetItem(str(faces)) face_item.setFlags(face_item.flags() | QtCore.Qt.ItemIsEditable) self.setItem(row, 1, face_item) pct_item = QtWidgets.QTableWidgetItem(f'{percentage:.1f}%') self.setItem(row, 2, pct_item) def get_lod_levels(self): levels = [] for row in range(self.rowCount()): level_text = self.item(row, 0).text() level = int(level_text.replace('LOD', '')) faces = int(self.item(row, 1).text()) levels.append({'index': level, 'target_faces': faces}) return levels class LODFastUI(QtWidgets.QWidget): def __init__(self, parent=None): super(LODFastUI, self).__init__(parent) self.setWindowTitle("LODFast - Quick Retopology Tool v2.0") self.setMinimumSize(650, 850) self.resize(650, 900) self.setWindowFlags(QtCore.Qt.Window | QtCore.Qt.WindowStaysOnTopHint) self.source_mesh = None self.preview_mesh = None self.lod_meshes = [] self.original_colors = {} self.paint_copy = None # Temporary copy for painting self._setup_ui() self._apply_style() def _setup_ui(self): layout = QtWidgets.QVBoxLayout(self) layout.setSpacing(15) layout.setContentsMargins(20, 20, 20, 20) # Title title = QtWidgets.QLabel("🎯 LODFast - Intelligent Mesh Simplification") title.setStyleSheet("font-size: 16px; font-weight: bold; color: #00bcd4; padding: 10px;") title.setAlignment(QtCore.Qt.AlignCenter) layout.addWidget(title) # Mesh Selection mesh_group = QtWidgets.QGroupBox("Source Mesh") mesh_layout = QtWidgets.QHBoxLayout() self.mesh_label = QtWidgets.QLabel("No mesh selected") self.mesh_label.setStyleSheet("color: #ff9800; font-weight: bold;") mesh_layout.addWidget(self.mesh_label, 1) self.select_btn = QtWidgets.QPushButton("Select Mesh") self.select_btn.clicked.connect(self.select_mesh) mesh_layout.addWidget(self.select_btn) mesh_group.setLayout(mesh_layout) layout.addWidget(mesh_group) # Quick Controls quick_group = QtWidgets.QGroupBox("Quick Controls") quick_layout = QtWidgets.QVBoxLayout() # Target Percentage target_layout = QtWidgets.QHBoxLayout() target_layout.addWidget(QtWidgets.QLabel("Target:")) self.target_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) self.target_slider.setRange(5, 100) self.target_slider.setValue(50) self.target_slider.valueChanged.connect(self.on_slider_changed) target_layout.addWidget(self.target_slider, 1) self.target_label = QtWidgets.QLabel("50%") self.target_label.setMinimumWidth(50) self.target_label.setStyleSheet("font-weight: bold; color: #00bcd4;") target_layout.addWidget(self.target_label) quick_layout.addLayout(target_layout) # Quality Options with Parameters options_group = QtWidgets.QGroupBox("Quality Options") options_layout = QtWidgets.QVBoxLayout() # Row 1: Checkboxes (5 options) options_row1 = QtWidgets.QHBoxLayout() self.protect_silhouette = QtWidgets.QCheckBox("Protect Silhouette") self.protect_silhouette.setChecked(True) options_row1.addWidget(self.protect_silhouette) self.use_vertex_color = QtWidgets.QCheckBox("Use Vertex Color") options_row1.addWidget(self.use_vertex_color) self.calc_ao = QtWidgets.QCheckBox("Calculate AO") options_row1.addWidget(self.calc_ao) self.detect_joints = QtWidgets.QCheckBox("Detect Joints") options_row1.addWidget(self.detect_joints) self.keep_quads = QtWidgets.QCheckBox("Keep Quads") self.keep_quads.setChecked(True) # 默认开启 self.keep_quads.setToolTip("Try to maintain quad topology during simplification") options_row1.addWidget(self.keep_quads) options_layout.addLayout(options_row1) # Row 2: Parameters - Sliders with labels (2 per row) params_row1 = QtWidgets.QHBoxLayout() # Silhouette Weight Slider params_row1.addWidget(QtWidgets.QLabel("Silhouette:")) self.silhouette_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) self.silhouette_slider.setRange(10, 100) # 1.0-10.0 * 10 self.silhouette_slider.setValue(30) # 3.0 * 10 self.silhouette_slider.valueChanged.connect(self.on_silhouette_changed) params_row1.addWidget(self.silhouette_slider, 1) self.silhouette_label = QtWidgets.QLabel("3.0") self.silhouette_label.setMinimumWidth(40) self.silhouette_label.setStyleSheet("font-weight: bold; color: #00bcd4;") params_row1.addWidget(self.silhouette_label) # Vertex Color Strength Slider params_row1.addWidget(QtWidgets.QLabel("VC Strength:")) self.vc_strength_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) self.vc_strength_slider.setRange(5, 50) # 0.5-5.0 * 10 self.vc_strength_slider.setValue(30) # 3.0 * 10 self.vc_strength_slider.valueChanged.connect(self.on_vc_strength_changed) params_row1.addWidget(self.vc_strength_slider, 1) self.vc_strength_label = QtWidgets.QLabel("3.0") self.vc_strength_label.setMinimumWidth(40) self.vc_strength_label.setStyleSheet("font-weight: bold; color: #00bcd4;") params_row1.addWidget(self.vc_strength_label) options_layout.addLayout(params_row1) # Row 3: Parameters - Sliders with labels (2 per row) params_row2 = QtWidgets.QHBoxLayout() # AO Samples Slider params_row2.addWidget(QtWidgets.QLabel("AO Samples:")) self.ao_samples_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) self.ao_samples_slider.setRange(8, 64) self.ao_samples_slider.setValue(16) self.ao_samples_slider.setSingleStep(8) self.ao_samples_slider.valueChanged.connect(self.on_ao_samples_changed) params_row2.addWidget(self.ao_samples_slider, 1) self.ao_samples_label = QtWidgets.QLabel("16") self.ao_samples_label.setMinimumWidth(40) self.ao_samples_label.setStyleSheet("font-weight: bold; color: #00bcd4;") params_row2.addWidget(self.ao_samples_label) # Joint Weight Slider params_row2.addWidget(QtWidgets.QLabel("Joint Weight:")) self.joint_weight_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) self.joint_weight_slider.setRange(1, 10) # 0.1-1.0 * 10 self.joint_weight_slider.setValue(5) # 0.5 * 10 self.joint_weight_slider.valueChanged.connect(self.on_joint_weight_changed) params_row2.addWidget(self.joint_weight_slider, 1) self.joint_weight_label = QtWidgets.QLabel("0.5") self.joint_weight_label.setMinimumWidth(40) self.joint_weight_label.setStyleSheet("font-weight: bold; color: #00bcd4;") params_row2.addWidget(self.joint_weight_label) options_layout.addLayout(params_row2) options_group.setLayout(options_layout) quick_layout.addWidget(options_group) # Vertex Paint Controls (Simplified) paint_group = QtWidgets.QGroupBox("Vertex Paint (🔴Preserve 🟢Reduce 🔵Quad)") paint_layout = QtWidgets.QHBoxLayout() self.channel_combo = QtWidgets.QComboBox() self.channel_combo.addItems(["🔴 Red (Preserve)", "🟢 Green (Reduce)", "🔵 Blue (Quad)"]) self.channel_combo.currentIndexChanged.connect(self.on_channel_changed) paint_layout.addWidget(QtWidgets.QLabel("Channel:")) paint_layout.addWidget(self.channel_combo, 1) self.paint_btn = QtWidgets.QPushButton("🎨 Start Paint") self.paint_btn.clicked.connect(self.open_paint_tool) paint_layout.addWidget(self.paint_btn) self.transfer_btn = QtWidgets.QPushButton("✓ Apply Colors") self.transfer_btn.clicked.connect(self.apply_painted_colors) self.transfer_btn.setEnabled(False) paint_layout.addWidget(self.transfer_btn) paint_group.setLayout(paint_layout) quick_layout.addWidget(paint_group) quick_group.setLayout(quick_layout) layout.addWidget(quick_group) # LOD Levels Table table_group = QtWidgets.QGroupBox("LOD Levels (Edit Face Count Directly)") table_layout = QtWidgets.QVBoxLayout() self.lod_table = LODTableWidget() table_layout.addWidget(self.lod_table) table_btn_layout = QtWidgets.QHBoxLayout() self.gen_levels_btn = QtWidgets.QPushButton("Auto Generate 3 Levels") self.gen_levels_btn.clicked.connect(self.generate_levels) table_btn_layout.addWidget(self.gen_levels_btn) self.add_level_btn = QtWidgets.QPushButton("Add Level") self.add_level_btn.clicked.connect(self.add_level) table_btn_layout.addWidget(self.add_level_btn) self.remove_level_btn = QtWidgets.QPushButton("Remove Selected") self.remove_level_btn.clicked.connect(self.remove_level) table_btn_layout.addWidget(self.remove_level_btn) table_layout.addLayout(table_btn_layout) self.batch_btn = QtWidgets.QPushButton("Batch Generate All LODs") self.batch_btn.clicked.connect(self.batch_generate) table_layout.addWidget(self.batch_btn) table_group.setLayout(table_layout) layout.addWidget(table_group) # Progress self.progress = QtWidgets.QProgressBar() self.progress.setVisible(False) layout.addWidget(self.progress) # Status self.status_label = QtWidgets.QLabel("Ready") self.status_label.setStyleSheet("color: #9e9e9e; font-size: 10px;") layout.addWidget(self.status_label) def _apply_style(self): self.setStyleSheet(""" QWidget { background-color: #2b2b2b; color: #e0e0e0; font-family: 'Segoe UI', Arial; font-size: 11px; } QGroupBox { border: 2px solid #404040; border-radius: 6px; margin-top: 10px; padding-top: 10px; font-weight: bold; color: #00bcd4; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; } QPushButton { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #3B82F6, stop:1 #2563EB); color: white; border: none; border-radius: 4px; padding: 8px 16px; font-weight: 600; min-height: 32px; } QPushButton:hover { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #60A5FA, stop:1 #3B82F6); } QPushButton:pressed { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #2563EB, stop:1 #1E40AF); } QSlider::groove:horizontal { border: 1px solid #404040; height: 6px; background: #1e1e1e; border-radius: 3px; } QSlider::handle:horizontal { background: #00bcd4; border: 2px solid #00acc1; width: 16px; margin: -6px 0; border-radius: 8px; } QTableWidget { background-color: #1e1e1e; border: 1px solid #404040; border-radius: 4px; gridline-color: #404040; } QTableWidget::item:selected { background: #00bcd4; } QHeaderView::section { background-color: #404040; color: white; padding: 5px; border: none; } QCheckBox { spacing: 8px; } QCheckBox::indicator { width: 18px; height: 18px; border: 2px solid #404040; border-radius: 4px; background: #1e1e1e; } QCheckBox::indicator:checked { background: #00bcd4; border-color: #00acc1; } QProgressBar { border: 1px solid #404040; border-radius: 4px; text-align: center; background: #1e1e1e; } QProgressBar::chunk { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #00bcd4, stop:1 #00acc1); border-radius: 3px; } """) def select_mesh(self): selection = cmds.ls(selection=True, type='transform') if not selection: QtWidgets.QMessageBox.warning(self, "Warning", "Please select a mesh!") return shapes = cmds.listRelatives(selection[0], shapes=True, type='mesh') if not shapes: QtWidgets.QMessageBox.warning(self, "Warning", "Selected object is not a mesh!") return self.source_mesh = selection[0] self.mesh_label.setText(f"✓ {self.source_mesh}") self.mesh_label.setStyleSheet("color: #4caf50; font-weight: bold;") self.status_label.setText(f"Mesh selected: {self.source_mesh}") # Get face count for LOD table face_count = cmds.polyEvaluate(self.source_mesh, face=True) self.source_face_count = face_count def on_channel_changed(self, index): """Update paint color when channel changes - now works directly on original mesh""" if not self.source_mesh or not cmds.objExists(self.source_mesh): return context_name = 'artAttrColorPerVertexContext' if not cmds.artAttrPaintVertexCtx(context_name, exists=True): return try: color_map = { 0: (1, 0, 0, 1), # Red 1: (0, 1, 0, 1), # Green 2: (0, 0, 1, 1) # Blue } cmds.artAttrPaintVertexCtx(context_name, edit=True, colorRGBAValue=color_map[index]) channel_names = ["RED (Preserve)", "GREEN (Reduce)", "BLUE (Quad)"] self.status_label.setText(f"Switched to {channel_names[index]} channel") except: pass def on_slider_changed(self, value): self.target_label.setText(f"{value}%") def on_silhouette_changed(self, value): """Silhouette weight slider changed (1.0-10.0)""" self.silhouette_label.setText(f"{value/10.0:.1f}") def on_vc_strength_changed(self, value): """Vertex color strength slider changed (0.5-5.0)""" self.vc_strength_label.setText(f"{value/10.0:.1f}") def on_ao_samples_changed(self, value): """AO samples slider changed (8-64)""" self.ao_samples_label.setText(f"{value}") def on_joint_weight_changed(self, value): """Joint weight slider changed (0.1-1.0)""" self.joint_weight_label.setText(f"{value/10.0:.1f}") def backup_vertex_colors(self): """Backup vertex colors before painting""" if not self.source_mesh: return try: sel = om.MSelectionList() sel.add(self.source_mesh) dag_path = sel.getDagPath(0) mesh_fn = om.MFnMesh(dag_path) color_sets = mesh_fn.getColorSetNames() self.original_colors[self.source_mesh] = {} for color_set in color_sets: try: colors = mesh_fn.getVertexColors(color_set) self.original_colors[self.source_mesh][color_set] = [(c.r, c.g, c.b, c.a) for c in colors] except: pass print(f"LODFast: Backed up vertex colors for {self.source_mesh}") except Exception as e: print(f"LODFast: Backup failed: {e}") def restore_vertex_colors(self): """Restore original vertex colors""" if not self.source_mesh or self.source_mesh not in self.original_colors: return try: sel = om.MSelectionList() sel.add(self.source_mesh) dag_path = sel.getDagPath(0) mesh_fn = om.MFnMesh(dag_path) for color_set, colors in self.original_colors[self.source_mesh].items(): try: color_array = om.MColorArray() for r, g, b, a in colors: color_array.append(om.MColor([r, g, b, a])) vertex_list = om.MIntArray(range(len(colors))) mesh_fn.setVertexColors(color_array, vertex_list, color_set) except Exception as e: print(f"LODFast: Failed to restore color set {color_set}: {e}") print(f"LODFast: Restored vertex colors for {self.source_mesh}") except Exception as e: print(f"LODFast: Restore failed: {e}") def open_paint_tool(self): """ 完全重写的绘制工具 - 使用最简单可靠的方法 直接在原始网格上创建并绘制顶点色 """ if not self.source_mesh: QtWidgets.QMessageBox.warning(self, "Warning", "请先选择一个网格!") return try: # 步骤1: 获取网格shape节点 shapes = cmds.listRelatives(self.source_mesh, shapes=True, type='mesh') if not shapes: QtWidgets.QMessageBox.warning(self, "Warning", "选中的对象没有网格shape!") return mesh_shape = shapes[0] print(f"LODFast Paint: Working on mesh shape: {mesh_shape}") # 步骤2: 确保有颜色集 color_sets = cmds.polyColorSet(mesh_shape, query=True, allColorSets=True) if not color_sets or len(color_sets) == 0: print("LODFast Paint: Creating new color set...") cmds.polyColorSet(mesh_shape, create=True, colorSet='lodfast_density', clamped=False, representation='RGB') cmds.polyColorSet(mesh_shape, currentColorSet=True, colorSet='lodfast_density') # 初始化所有顶点为黑色(0,0,0) vertex_count = cmds.polyEvaluate(mesh_shape, vertex=True) print(f"LODFast Paint: Initializing {vertex_count} vertices to black...") # 使用更高效的方法初始化 vtx_list = [f"{mesh_shape}.vtx[{i}]" for i in range(vertex_count)] cmds.polyColorPerVertex(vtx_list, rgb=(0, 0, 0), colorDisplayOption=True) else: print(f"LODFast Paint: Using existing color set: {color_sets[0]}") cmds.polyColorSet(mesh_shape, currentColorSet=True, colorSet=color_sets[0]) # 步骤3: 启用颜色显示 cmds.polyOptions(mesh_shape, colorShadedDisplay=True) print("LODFast Paint: Enabled color display") # 步骤4: 选择网格(非常重要!) cmds.select(mesh_shape, replace=True) print(f"LODFast Paint: Selected {mesh_shape}") # 步骤5: 获取当前通道的颜色 channel_index = self.channel_combo.currentIndex() color_map = { 0: (1.0, 0.0, 0.0), # Red 1: (0.0, 1.0, 0.0), # Green 2: (0.0, 0.0, 1.0) # Blue } paint_color = color_map[channel_index] print(f"LODFast Paint: Using color {paint_color} for channel {channel_index}") # 步骤6: 使用最简单的MEL命令激活绘制工具 # 这是Maya最稳定的方式 mel.eval('ArtPaintAttrToolOptions;') # 打开工具选项窗口 mel.eval('artSetToolAndSelectAttr("artAttrColorPerVertex", "color");') # 激活顶点色绘制 # 步骤7: 配置绘制上下文 context_name = 'artAttrColorPerVertexContext' # 等待上下文创建 cmds.refresh() # 验证上下文是否存在 if not cmds.artAttrPaintVertexCtx(context_name, exists=True): print("LODFast Paint: Context not found, creating manually...") # 如果上下文不存在,再次尝试创建 mel.eval('ArtPaintAttrTool;') cmds.refresh() # 配置绘制参数 if cmds.artAttrPaintVertexCtx(context_name, exists=True): print("LODFast Paint: Configuring paint context...") cmds.artAttrPaintVertexCtx( context_name, edit=True, colorRGBAValue=(paint_color[0], paint_color[1], paint_color[2], 1.0), opacity=1.0, radius=0.05, selectedattroper='absolute' # 绝对值模式 ) # 激活工具 cmds.setToolTo(context_name) print("LODFast Paint: Paint tool activated!") else: raise Exception("无法创建绘制上下文") # 步骤8: 更新UI状态 self.paint_btn.setText("✓ 正在绘制...") self.paint_btn.setEnabled(False) channel_names = ["红色 (保留细节)", "绿色 (减少面数)", "蓝色 (保持四边形)"] self.status_label.setText( f"🎨 正在绘制 {channel_names[channel_index]} - " f"使用下拉菜单切换通道 | 按 Q 退出绘制模式" ) print("LODFast Paint: Setup complete!") except Exception as e: error_msg = f"绘制工具启动失败:\n{str(e)}" print(f"LODFast Paint ERROR: {error_msg}") QtWidgets.QMessageBox.critical(self, "错误", error_msg) import traceback traceback.print_exc() # 重置UI状态 self.paint_btn.setText("🎨 Start Paint") self.paint_btn.setEnabled(True) def apply_painted_colors(self): """ TODO: 此函数已废弃 - 现在直接在原始网格上绘制,不需要转移颜色 保留此函数以防万一需要恢复旧逻辑 """ QtWidgets.QMessageBox.information( self, "Info", "Colors are painted directly on the original mesh.\n" "No transfer needed. Enable 'Use Vertex Color' to use them." ) # Reset UI state self.paint_btn.setText("🎨 Start Paint") self.paint_btn.setEnabled(True) self.transfer_btn.setEnabled(False) self.status_label.setText("Ready - Enable 'Use Vertex Color' option to use painted colors") def preview_simplification(self): if not self.source_mesh: QtWidgets.QMessageBox.warning(self, "Warning", "Please select a mesh first!") return self.clear_preview() try: percentage = self.target_slider.value() / 100.0 current_faces = cmds.polyEvaluate(self.source_mesh, face=True) target_faces = int(current_faces * percentage) target_faces = max(10, target_faces) # Duplicate for preview self.preview_mesh = cmds.duplicate(self.source_mesh, name=f"{self.source_mesh}_preview")[0] # Simplify self._simplify_mesh(self.preview_mesh, target_faces) # Display settings cmds.setAttr(f"{self.source_mesh}.overrideEnabled", 1) cmds.setAttr(f"{self.source_mesh}.overrideDisplayType", 2) self.status_label.setText(f"Preview: {current_faces} → {target_faces} faces ({percentage*100:.0f}%)") except Exception as e: QtWidgets.QMessageBox.critical(self, "Error", f"Preview failed:\n{str(e)}") def clear_preview(self): """Clear preview mesh""" if self.preview_mesh and cmds.objExists(self.preview_mesh): try: cmds.delete(self.preview_mesh) self.preview_mesh = None except: pass if self.source_mesh and cmds.objExists(self.source_mesh): try: cmds.setAttr(f"{self.source_mesh}.overrideEnabled", 0) except: pass self.status_label.setText("Preview cleared") def generate_levels(self): if not self.source_mesh: QtWidgets.QMessageBox.warning(self, "Warning", "Please select a mesh first!") return self.lod_table.setRowCount(0) current_faces = cmds.polyEvaluate(self.source_mesh, face=True) for i in range(3): ratio = math.pow(0.5, i+1) target_faces = int(current_faces * ratio) self.lod_table.add_lod_level(i, target_faces, ratio*100) def add_level(self): if not self.source_mesh: QtWidgets.QMessageBox.warning(self, "Warning", "Please select a mesh first!") return # 获取表格最后一行的面数,如果有的话 row_count = self.lod_table.rowCount() if row_count > 0: # 获取最后一行的面数 last_faces_item = self.lod_table.item(row_count - 1, 1) last_faces = int(last_faces_item.text()) # 新增行的默认面数是最后一行的50% default_faces = last_faces // 2 else: # 如果表格为空,使用源网格面数的50% current_faces = cmds.polyEvaluate(self.source_mesh, face=True) default_faces = current_faces // 2 level = row_count percentage = (default_faces / cmds.polyEvaluate(self.source_mesh, face=True)) * 100 self.lod_table.add_lod_level(level, default_faces, percentage) def remove_level(self): current_row = self.lod_table.currentRow() if current_row >= 0: self.lod_table.removeRow(current_row) def batch_generate(self): if not self.source_mesh: QtWidgets.QMessageBox.warning(self, "Warning", "Please select a mesh first!") return levels = self.lod_table.get_lod_levels() if not levels: QtWidgets.QMessageBox.warning(self, "Warning", "Please add LOD levels first!") return reply = QtWidgets.QMessageBox.question( self, "Confirm", f"Generate {len(levels)} LOD levels?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No ) if reply != QtWidgets.QMessageBox.Yes: return try: self.progress.setVisible(True) self.progress.setRange(0, len(levels)) # Create list to store newly generated LOD meshes batch_lod_meshes = [] for i, level in enumerate(levels): self.progress.setValue(i) lod_name = f"{self.source_mesh}_LOD{level['index']}" lod_mesh = cmds.duplicate(self.source_mesh, name=lod_name)[0] self._simplify_mesh(lod_mesh, level['target_faces']) batch_lod_meshes.append(lod_mesh) self.lod_meshes.append(lod_mesh) self.status_label.setText(f"Generated LOD{level['index']}: {level['target_faces']} faces") self.progress.setValue(len(levels)) # Create group with newly generated meshes if batch_lod_meshes: group_name = f"{self.source_mesh}_LOD_Group" cmds.group(batch_lod_meshes, name=group_name) QtWidgets.QMessageBox.information( self, "Success", f"Generated {len(levels)} LOD levels!" ) except Exception as e: QtWidgets.QMessageBox.critical(self, "Error", f"Batch failed:\n{str(e)}") import traceback traceback.print_exc() finally: self.progress.setVisible(False) def _simplify_mesh(self, mesh_name, target_faces): sel = om.MSelectionList() sel.add(mesh_name) dag_path = sel.getDagPath(0) mesh_fn = om.MFnMesh(dag_path) # Get parameters from UI sliders vc_strength = self.vc_strength_slider.value() / 10.0 # Convert back to 0.5-5.0 silhouette_weight = self.silhouette_slider.value() / 10.0 # Convert back to 1.0-10.0 ao_samples = self.ao_samples_slider.value() # Already integer 8-64 joint_weight = self.joint_weight_slider.value() / 10.0 # Convert back to 0.1-1.0 simplifier = MeshSimplifier(mesh_fn, vc_strength=vc_strength) # Apply vertex color if enabled if self.use_vertex_color.isChecked(): pass # Already extracted in simplifier # Apply AO density control if self.calc_ao.isChecked(): try: ao_values = AOCalculator.compute_vertex_ao(mesh_fn, samples=ao_samples, max_distance=1.0) for i, ao in enumerate(ao_values): if i < len(simplifier.vertex_colors): r, g, b = simplifier.vertex_colors[i] # AO逻辑: 白色(1.0)=不闭塞=减得多, 黑色(0.0)=闭塞=减得少 # ao值高(白)=不需要保护, ao值低(黑)=需要保护 g = max(g, ao * 0.8) # 白色增加绿色通道=减得多 simplifier.vertex_colors[i] = (r, g, b) except Exception as e: print(f"AO calculation failed: {e}") # Apply joint density control if self.detect_joints.isChecked(): try: joint_influences = JointDetector.find_joint_influences(mesh_name) for v_idx, influence in joint_influences.items(): if v_idx < len(simplifier.vertex_colors): r, g, b = simplifier.vertex_colors[v_idx] r = max(r, influence * joint_weight) simplifier.vertex_colors[v_idx] = (r, g, b) except Exception as e: print(f"Joint detection failed: {e}") protect_silhouette = self.protect_silhouette.isChecked() keep_quads = self.keep_quads.isChecked() simplifier.silhouette_weight = silhouette_weight simplifier.keep_quads = keep_quads new_vertices, new_faces = simplifier.simplify(target_faces, protect_silhouette) self._rebuild_mesh(mesh_name, new_vertices, new_faces) def _rebuild_mesh(self, mesh_name, vertices, faces): """ 重建网格并保持蒙皮权重 """ try: # 1. 检查是否有蒙皮权重需要保存 skin_data = self._save_skin_weights(mesh_name) # 2. 执行网格简化 current_faces = cmds.polyEvaluate(mesh_name, face=True) target_faces = len(faces) reduction_percentage = max(0, min(100, int((1.0 - target_faces / float(current_faces)) * 100))) if reduction_percentage > 0: # 使用polyReduce进行简化,保持四边形 cmds.polyReduce( mesh_name, percentage=reduction_percentage, keepQuadsWeight=1.0 if self.keep_quads.isChecked() else 0.5, cachingReduce=True, constructionHistory=False ) print(f"LODFast: Used polyReduce with {reduction_percentage}% reduction") # 3. Keep Quads后处理 - 尝试将三角面转换为四边面 if self.keep_quads.isChecked(): print(f"\nLODFast: Keep Quads 选项已启用,开始后处理...") self._convert_tris_to_quads(mesh_name) else: print(f"LODFast: Keep Quads 选项未启用,跳过四边面转换") # 4. 恢复蒙皮权重到最近顶点 if skin_data: self._restore_skin_weights(mesh_name, skin_data) # 刷新视图 cmds.refresh() except Exception as e: print(f"LODFast: Mesh rebuild error: {e}") import traceback traceback.print_exc() QtWidgets.QMessageBox.warning( None, "Warning", f"Mesh rebuild encountered issues:\n{str(e)}\n\nPlease check the mesh manually." ) def _save_skin_weights(self, mesh_name): """ 保存蒙皮权重数据 返回: { 'skin_cluster': skinCluster节点名称, 'influences': [影响骨骼列表], 'weights': {vertex_index: {influence: weight, ...}, ...}, 'positions': [顶点位置列表] } """ try: # 查找skinCluster history = cmds.listHistory(mesh_name, pruneDagObjects=True) skin_clusters = cmds.ls(history, type='skinCluster') if not skin_clusters: return None skin_cluster = skin_clusters[0] # 获取影响骨骼 influences = cmds.skinCluster(skin_cluster, query=True, influence=True) # 获取顶点数量 vertex_count = cmds.polyEvaluate(mesh_name, vertex=True) # 保存每个顶点的权重和位置 weights = {} positions = [] for i in range(vertex_count): # 获取顶点位置 pos = cmds.xform(f"{mesh_name}.vtx[{i}]", query=True, worldSpace=True, translation=True) positions.append(pos) # 获取顶点权重 vertex_weights = cmds.skinPercent( skin_cluster, f"{mesh_name}.vtx[{i}]", query=True, value=True ) # 只保存非零权重 weights[i] = {} for j, weight in enumerate(vertex_weights): if weight > 0.0001: # 忽略很小的权重 weights[i][influences[j]] = weight print(f"LODFast: Saved skin weights for {vertex_count} vertices from {skin_cluster}") return { 'skin_cluster': skin_cluster, 'influences': influences, 'weights': weights, 'positions': positions } except Exception as e: print(f"LODFast: Failed to save skin weights: {e}") return None def _restore_skin_weights(self, mesh_name, skin_data): """ 恢复蒙皮权重到最近的顶点 """ try: if not skin_data: return skin_cluster = skin_data['skin_cluster'] old_positions = skin_data['positions'] old_weights = skin_data['weights'] # 检查skinCluster是否还存在 if not cmds.objExists(skin_cluster): print(f"LODFast: SkinCluster {skin_cluster} no longer exists") return # 获取新的顶点数量 new_vertex_count = cmds.polyEvaluate(mesh_name, vertex=True) # 为每个新顶点找到最近的旧顶点并复制权重 for i in range(new_vertex_count): # 获取新顶点位置 new_pos = cmds.xform(f"{mesh_name}.vtx[{i}]", query=True, worldSpace=True, translation=True) # 找到最近的旧顶点 min_dist = float('inf') closest_idx = 0 for old_idx, old_pos in enumerate(old_positions): # 计算距离 dist = sum((new_pos[j] - old_pos[j])**2 for j in range(3)) if dist < min_dist: min_dist = dist closest_idx = old_idx # 应用最近顶点的权重 if closest_idx in old_weights: weights_dict = old_weights[closest_idx] # 构建权重列表 transform_value = [] for influence, weight in weights_dict.items(): transform_value.append((influence, weight)) # 应用权重 if transform_value: cmds.skinPercent( skin_cluster, f"{mesh_name}.vtx[{i}]", transformValue=transform_value, normalize=True ) print(f"LODFast: Restored skin weights to {new_vertex_count} vertices") except Exception as e: print(f"LODFast: Failed to restore skin weights: {e}") import traceback traceback.print_exc() def _convert_tris_to_quads(self, mesh_name): """ Keep Quads后处理: 尝试将三角面合并为四边面 增强版本,带详细调试输出 """ try: print(f"\n=== LODFast Keep Quads: 开始处理 {mesh_name} ===") # 获取处理前的面数统计 total_faces_before = cmds.polyEvaluate(mesh_name, face=True) print(f"处理前总面数: {total_faces_before}") # 统计处理前的三角面和四边面 cmds.select(mesh_name, replace=True) # 获取三角面 cmds.polySelectConstraint(mode=3, type=0x0008, size=1) # type=face, size=3 vertices triangles_before = cmds.ls(selection=True, flatten=True) tri_count_before = len(triangles_before) if triangles_before else 0 cmds.polySelectConstraint(disable=True) # 获取四边面 cmds.select(mesh_name, replace=True) cmds.polySelectConstraint(mode=3, type=0x0008, size=2) # size=4 vertices (quads) quads_before = cmds.ls(selection=True, flatten=True) quad_count_before = len(quads_before) if quads_before else 0 cmds.polySelectConstraint(disable=True) print(f"处理前: {tri_count_before} 个三角面, {quad_count_before} 个四边面") if tri_count_before == 0: print("LODFast Keep Quads: 没有三角面需要转换") cmds.select(clear=True) return # 选择所有三角面准备转换 cmds.select(triangles_before, replace=True) print(f"已选择 {len(triangles_before)} 个三角面准备合并...") # 执行polyQuad转换 try: print("执行 polyQuad 命令...") result = cmds.polyQuad( angle=30, # 最大合并角度(度) kqb=True, # 保持四边形边界 ktb=True, # 保持三角形边界 khe=True, # 保持硬边 ws=1 # 世界空间 ) print(f"polyQuad 命令执行成功,返回: {result}") except Exception as e: print(f"LODFast Keep Quads: polyQuad 命令执行失败: {e}") import traceback traceback.print_exc() cmds.select(clear=True) return # 统计转换后的结果 total_faces_after = cmds.polyEvaluate(mesh_name, face=True) # 重新统计三角面 cmds.select(mesh_name, replace=True) cmds.polySelectConstraint(mode=3, type=0x0008, size=1) triangles_after = cmds.ls(selection=True, flatten=True) tri_count_after = len(triangles_after) if triangles_after else 0 cmds.polySelectConstraint(disable=True) # 重新统计四边面 cmds.select(mesh_name, replace=True) cmds.polySelectConstraint(mode=3, type=0x0008, size=2) quads_after = cmds.ls(selection=True, flatten=True) quad_count_after = len(quads_after) if quads_after else 0 cmds.polySelectConstraint(disable=True) # 输出详细的转换结果 print(f"\n转换结果:") print(f" 总面数: {total_faces_before} → {total_faces_after} (变化: {total_faces_after - total_faces_before})") print(f" 三角面: {tri_count_before} → {tri_count_after} (减少: {tri_count_before - tri_count_after})") print(f" 四边面: {quad_count_before} → {quad_count_after} (增加: {quad_count_after - quad_count_before})") # 计算转换率 if tri_count_before > 0: conversion_rate = ((tri_count_before - tri_count_after) / float(tri_count_before)) * 100 print(f" 转换率: {conversion_rate:.1f}%") print(f"=== LODFast Keep Quads: 处理完成 ===\n") # 清除选择 cmds.select(clear=True) except Exception as e: print(f"\nLODFast Keep Quads: 处理失败: {e}") import traceback traceback.print_exc() cmds.select(clear=True) def _rebuild_mesh_fallback(self, mesh_name, vertices, faces): """ TODO: Fallback方法也需要重写 此方法会产生大量碎片和重复顶点 暂时禁用,依赖主重建方法的polyReduce方案 """ print("LODFast: Fallback method disabled - using polyReduce instead") QtWidgets.QMessageBox.warning( None, "Warning", "Mesh rebuild fallback is disabled.\nPlease try with different settings or report the issue." ) def closeEvent(self, event): self.clear_preview() self.restore_vertex_colors() super(LODFastUI, self).closeEvent(event) # ===================== Global Function ===================== _lodfast_window = None def show_lodfast_ui(): global _lodfast_window try: if _lodfast_window is not None: _lodfast_window.close() _lodfast_window.deleteLater() except: pass _lodfast_window = LODFastUI() _lodfast_window.show() return _lodfast_window if __name__ == "__main__": show_lodfast_ui()