1458 lines
59 KiB
Python
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()
|