Files
Nexus/2023/scripts/modeling_tools/lodfast.py
2025-11-30 14:49:16 +08:00

1458 lines
59 KiB
Python

#!/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()