Created
May 2, 2025 18:24
-
-
Save Pakmanv/2f8a67f0a64519431a650edd0143da3a to your computer and use it in GitHub Desktop.
Vtx Snappy VV 1.4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # -*- coding: utf-8 -*- | |
| from maya import cmds | |
| import maya.api.OpenMaya as om | |
| import math | |
| print("Imported maya.cmds, maya.api.OpenMaya") | |
| try: | |
| from PySide2 import QtWidgets, QtCore, QtGui | |
| print("Imported PySide2") | |
| except ImportError: | |
| cmds.error("PySide2 not found. Ensure you're running this in Maya 2017 or later with PySide2 available.") | |
| raise | |
| class VVvertsnap(QtWidgets.QDialog): | |
| def __init__(self, parent=None): | |
| maya_main_window = None | |
| for obj in QtWidgets.QApplication.topLevelWidgets(): | |
| print(f"Found top-level widget: {obj.objectName()}") | |
| if obj.objectName() == "MayaWindow": | |
| maya_main_window = obj | |
| break | |
| if not maya_main_window: | |
| print("Maya main window not found, using no parent.") | |
| super(VVvertsnap, self).__init__(parent=maya_main_window or None) | |
| self.setWindowTitle("Vtx Snappy VV 1.4") | |
| self.setMinimumSize(300, 680) | |
| self.setStyleSheet("QDialog:focus { border: none; }") | |
| self.rebuild_after_creation = False | |
| self.live_projected_cv = False | |
| self.offset_mode = "noOffset" | |
| self.offset_value = 0.0 | |
| self.tolerance_value = 10.0 | |
| self.cleanup_tween_value = 0.5 | |
| self.use_tolerance_tweening = False | |
| self.symmetry_tolerance_value = 0.001 | |
| self.use_stored_target = False | |
| self.use_conform_list = False | |
| self.stored_target = None | |
| self.stored_mesh_list = [] | |
| self.last_vertices = [] | |
| self.last_targets = [] | |
| self.created_curve = None | |
| self.selection_callback = None | |
| self.list_height = 200 | |
| print("Initializing Vtx Snappy VV UI...") | |
| self.create_ui() | |
| self.setup_selection_callback() | |
| # Connect buttons and controls | |
| self.snap_btn.clicked.connect(self.snap_vertices) | |
| self.snap_btn.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) | |
| self.snap_btn.customContextMenuRequested.connect(self.show_snap_context_menu) | |
| self.match_mesh_btn.clicked.connect(self.match_mesh) | |
| self.match_mesh_btn.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) | |
| self.match_mesh_btn.customContextMenuRequested.connect(self.show_match_mesh_context_menu) | |
| self.cv_project_btn.clicked.connect(self.cv_project) | |
| self.cv_project_btn.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) | |
| self.cv_project_btn.customContextMenuRequested.connect(self.show_cv_context_menu) | |
| self.rebuild_curve_btn.clicked.connect(self.rebuild_curve) | |
| self.edges_to_curve_btn.clicked.connect(self.edges_to_curve) | |
| self.cleanup_edge_flow_btn.clicked.connect(self.cleanup_edge_flow) | |
| self.check_symmetry_btn.clicked.connect(self.check_symmetry) | |
| self.compare_verts_btn.clicked.connect(self.compare_verts) | |
| self.compare_verts_btn.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) | |
| self.compare_verts_btn.customContextMenuRequested.connect(self.show_compare_verts_context_menu) | |
| self.store_mesh_btn.clicked.connect(self.store_mesh_in_list) | |
| self.revert_mesh_btn.clicked.connect(self.revert_to_stored_mesh) | |
| self.revert_mesh_btn.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) | |
| self.revert_mesh_btn.customContextMenuRequested.connect(self.show_revert_context_menu) | |
| self.offset_dropdown.currentTextChanged.connect(self.update_offset_mode) | |
| self.offset_slider.valueChanged.connect(self.update_offset_value) | |
| self.offset_field.valueChanged.connect(self.update_offset_field) | |
| self.tolerance_slider.valueChanged.connect(self.update_tolerance_value) | |
| self.tolerance_field.valueChanged.connect(self.update_tolerance_field) | |
| self.cleanup_tween_cb.stateChanged.connect(self.update_tolerance_tweening) | |
| self.cleanup_tween_slider.valueChanged.connect(self.update_cleanup_tween_value) | |
| self.cleanup_tween_field.valueChanged.connect(self.update_cleanup_tween_field) | |
| self.symmetry_tolerance_field.valueChanged.connect(self.update_symmetry_tolerance_field) | |
| self.use_stored_target_cb.stateChanged.connect(self.update_use_stored_target) | |
| self.stored_mesh_list_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) | |
| self.stored_mesh_list_widget.customContextMenuRequested.connect(self.show_mesh_list_context_menu) | |
| self.sort_dropdown.currentTextChanged.connect(self.sort_conform_list) | |
| print("Button and control connections established.") | |
| def create_ui(self): | |
| layout = QtWidgets.QVBoxLayout() | |
| layout.setSpacing(10) | |
| # Snap Tolerance and Snap Vertices (same row) | |
| snap_layout = QtWidgets.QHBoxLayout() | |
| tolerance_layout = QtWidgets.QVBoxLayout() | |
| tolerance_label = QtWidgets.QLabel("Snap Tolerance (world units):") | |
| tolerance_controls_layout = QtWidgets.QHBoxLayout() | |
| self.tolerance_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) | |
| self.tolerance_slider.setRange(0, 10000) | |
| self.tolerance_slider.setValue(100) | |
| self.tolerance_slider.setToolTip("Adjust snap tolerance (0 to 1000 units) for snapping vertices or CVs.") | |
| self.tolerance_field = QtWidgets.QDoubleSpinBox() | |
| self.tolerance_field.setRange(0.0, 1000.0) | |
| self.tolerance_field.setValue(10.0) | |
| self.tolerance_field.setSingleStep(0.1) | |
| self.tolerance_field.setToolTip("Enter precise snap tolerance (0 to 1000 units) for snapping vertices or CVs.") | |
| tolerance_controls_layout.addWidget(self.tolerance_slider) | |
| tolerance_controls_layout.addWidget(self.tolerance_field) | |
| tolerance_layout.addWidget(tolerance_label) | |
| tolerance_layout.addLayout(tolerance_controls_layout) | |
| self.snap_btn = QtWidgets.QPushButton("Snap Vertices") | |
| self.snap_btn.setStyleSheet("background-color: #FFC7A1; color: black; padding: 5px;") | |
| self.snap_btn.setToolTip("Snap source mesh vertices to closest target mesh vertices within tolerance. Select target mesh first, then source mesh.") | |
| snap_layout.addLayout(tolerance_layout) | |
| snap_layout.addWidget(self.snap_btn) | |
| layout.addLayout(snap_layout) | |
| # Separator | |
| snap_match_spacer = QtWidgets.QFrame() | |
| snap_match_spacer.setFrameShape(QtWidgets.QFrame.HLine) | |
| snap_match_spacer.setFrameShadow(QtWidgets.QFrame.Sunken) | |
| layout.addWidget(snap_match_spacer) | |
| # Match Mesh note | |
| match_mesh_label = QtWidgets.QLabel("Match Mesh: Copies mesh without history, ideal for corrective shapes.") | |
| match_mesh_label.setStyleSheet("color: gray;") | |
| layout.addWidget(match_mesh_label) | |
| # Match Mesh | |
| copy_layout = QtWidgets.QHBoxLayout() | |
| self.match_mesh_btn = QtWidgets.QPushButton("Match Mesh") | |
| self.match_mesh_btn.setStyleSheet("background-color: #A1C7FF; color: black; padding: 5px;") | |
| self.match_mesh_btn.setToolTip("Match all vertex positions of the source mesh(es) to the target mesh in the selected space. Right-click to store target in memory.") | |
| self.space_dropdown = QtWidgets.QComboBox() | |
| self.space_dropdown.addItems(["World Space", "Local Space"]) | |
| self.space_dropdown.setCurrentText("Local Space") | |
| self.space_dropdown.setToolTip("Choose whether to match vertex positions in world or local space.") | |
| self.use_stored_target_cb = QtWidgets.QCheckBox("Use Stored Target") | |
| self.use_stored_target_cb.setToolTip("When checked, matches selected mesh(es) to the stored target mesh if available, requiring only source mesh(es) selected.") | |
| copy_layout.addWidget(self.match_mesh_btn) | |
| copy_layout.addWidget(self.space_dropdown) | |
| copy_layout.addWidget(self.use_stored_target_cb) | |
| layout.addLayout(copy_layout) | |
| # Separator | |
| spacer = QtWidgets.QFrame() | |
| spacer.setFrameShape(QtWidgets.QFrame.HLine) | |
| spacer.setFrameShadow(QtWidgets.QFrame.Sunken) | |
| layout.addWidget(spacer) | |
| # Curve rebuild | |
| rebuild_layout = QtWidgets.QHBoxLayout() | |
| degree_label = QtWidgets.QLabel("Curve Degree:") | |
| self.degree_dropdown = QtWidgets.QComboBox() | |
| self.degree_dropdown.addItems(["1 Linear", "2", "3 Cubic", "5", "7"]) | |
| self.degree_dropdown.setCurrentText("3 Cubic") | |
| self.degree_dropdown.setToolTip("Degree for creating and rebuilding NURBS curves.") | |
| spans_label = QtWidgets.QLabel("Spans/CVs:") | |
| self.spans_field = QtWidgets.QSpinBox() | |
| self.spans_field.setRange(1, 100) | |
| self.spans_field.setValue(4) | |
| self.spans_field.setToolTip("Number of spans (or CVs) for rebuilt curves.") | |
| self.rebuild_curve_btn = QtWidgets.QPushButton("Rebuild Curve") | |
| self.rebuild_curve_btn.setToolTip("Select a NURBS curve and rebuild it with specified degree and spans/CVs.") | |
| rebuild_layout.addWidget(degree_label) | |
| rebuild_layout.addWidget(self.degree_dropdown) | |
| rebuild_layout.addWidget(spans_label) | |
| rebuild_layout.addWidget(self.spans_field) | |
| rebuild_layout.addWidget(self.rebuild_curve_btn) | |
| layout.addLayout(rebuild_layout) | |
| # Edges to Curve | |
| self.edges_to_curve_btn = QtWidgets.QPushButton("Edges to Curve") | |
| self.edges_to_curve_btn.setStyleSheet("background-color: #FFFACD; color: black; padding: 5px;") | |
| self.edges_to_curve_btn.setToolTip("Select edges on a polygonal mesh to convert to a NURBS curve.") | |
| layout.addWidget(self.edges_to_curve_btn) | |
| # Separator | |
| spacer_cv = QtWidgets.QFrame() | |
| spacer_cv.setFrameShape(QtWidgets.QFrame.HLine) | |
| spacer_cv.setFrameShadow(QtWidgets.QFrame.Sunken) | |
| layout.addWidget(spacer_cv) | |
| # CV Project | |
| cv_project_label = QtWidgets.QLabel("CV Project: Select vertices or CVs, then a target mesh, NURBS surface, or curve.") | |
| cv_project_label.setStyleSheet("color: gray;") | |
| layout.addWidget(cv_project_label) | |
| self.cv_project_btn = QtWidgets.QPushButton("CV Project") | |
| self.cv_project_btn.setStyleSheet("background-color: #A3E4D7; color: black; padding: 5px;") | |
| self.cv_project_btn.setToolTip("Project selected vertices or CVs to the nearest point on the target. Right-click to toggle live projection.") | |
| layout.addWidget(self.cv_project_btn) | |
| # Offset Options | |
| offset_frame = QtWidgets.QFrame() | |
| offset_layout = QtWidgets.QVBoxLayout() | |
| offset_layout.setSpacing(5) | |
| offset_label = QtWidgets.QLabel("Offset Options:") | |
| self.offset_dropdown = QtWidgets.QComboBox() | |
| self.offset_dropdown.addItems(["No offset", "distance from surface", "travel percentage"]) | |
| self.offset_dropdown.setToolTip("Select offset mode for CV Project.") | |
| offset_layout.addWidget(offset_label) | |
| offset_layout.addWidget(self.offset_dropdown) | |
| offset_value_layout = QtWidgets.QHBoxLayout() | |
| offset_value_label = QtWidgets.QLabel("Offset Value:") | |
| self.offset_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) | |
| self.offset_slider.setRange(-1000, 1000) | |
| self.offset_slider.setValue(0) | |
| self.offset_slider.setToolTip("Adjust offset distance or percentage.") | |
| self.offset_slider.setEnabled(False) | |
| self.offset_field = QtWidgets.QDoubleSpinBox() | |
| self.offset_field.setRange(-100.0, 100.0) | |
| self.offset_field.setValue(0.0) | |
| self.offset_field.setSingleStep(0.01) | |
| self.offset_field.setToolTip("Enter precise offset distance or percentage.") | |
| self.offset_field.setEnabled(False) | |
| offset_value_layout.addWidget(offset_value_label) | |
| offset_value_layout.addWidget(self.offset_slider) | |
| offset_value_layout.addWidget(self.offset_field) | |
| offset_layout.addLayout(offset_value_layout) | |
| offset_frame.setLayout(offset_layout) | |
| layout.addWidget(offset_frame) | |
| # Separator | |
| spacer_cleanup = QtWidgets.QFrame() | |
| spacer_cleanup.setFrameShape(QtWidgets.QFrame.HLine) | |
| spacer_cleanup.setFrameShadow(QtWidgets.QFrame.Sunken) | |
| layout.addWidget(spacer_cleanup) | |
| # Cleanup Edge Flow | |
| self.cleanup_edge_flow_btn = QtWidgets.QPushButton("Cleanup Edge Flow") | |
| self.cleanup_edge_flow_btn.setStyleSheet("background-color: #D8B4FE; color: black; padding: 5px;") | |
| self.cleanup_edge_flow_btn.setToolTip("Select edges to clean up edge flow.") | |
| layout.addWidget(self.cleanup_edge_flow_btn) | |
| # Cleanup Tween | |
| cleanup_tween_layout = QtWidgets.QHBoxLayout() | |
| self.cleanup_tween_cb = QtWidgets.QCheckBox("Use Tolerance") | |
| self.cleanup_tween_cb.setToolTip("Enable to use tween value for CV count interpolation.") | |
| self.cleanup_tween_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) | |
| self.cleanup_tween_slider.setRange(0, 50) | |
| self.cleanup_tween_slider.setValue(25) | |
| self.cleanup_tween_slider.setToolTip("Adjust tween value (0 to 0.5).") | |
| self.cleanup_tween_slider.setEnabled(False) | |
| self.cleanup_tween_field = QtWidgets.QDoubleSpinBox() | |
| self.cleanup_tween_field.setRange(0.0, 0.5) | |
| self.cleanup_tween_field.setValue(0.25) | |
| self.cleanup_tween_field.setSingleStep(0.01) | |
| self.cleanup_tween_field.setToolTip("Enter precise tween value (0 to 0.5).") | |
| self.cleanup_tween_field.setEnabled(False) | |
| cleanup_tween_layout.addWidget(self.cleanup_tween_cb) | |
| cleanup_tween_layout.addWidget(self.cleanup_tween_slider) | |
| cleanup_tween_layout.addWidget(self.cleanup_tween_field) | |
| layout.addLayout(cleanup_tween_layout) | |
| # Separator | |
| spacer_new_features = QtWidgets.QFrame() | |
| spacer_new_features.setFrameShape(QtWidgets.QFrame.HLine) | |
| spacer_new_features.setFrameShadow(QtWidgets.QFrame.Sunken) | |
| layout.addWidget(spacer_new_features) | |
| # Symmetry and Compare Tolerance | |
| symmetry_tolerance_layout = QtWidgets.QHBoxLayout() | |
| symmetry_tolerance_layout.setSpacing(4) | |
| symmetry_tolerance_label = QtWidgets.QLabel("Symmetry/Compare Tolerance:") | |
| self.symmetry_tolerance_field = QtWidgets.QDoubleSpinBox() | |
| self.symmetry_tolerance_field.setDecimals(4) | |
| self.symmetry_tolerance_field.setRange(0.0, 10.0) | |
| self.symmetry_tolerance_field.setValue(0.001) | |
| self.symmetry_tolerance_field.setSingleStep(0.00001) | |
| self.symmetry_tolerance_field.setToolTip("Enter tolerance for Check Symmetry and Compare Verts (in local space units).") | |
| symmetry_tolerance_layout.addWidget(symmetry_tolerance_label) | |
| symmetry_tolerance_layout.addWidget(self.symmetry_tolerance_field) | |
| layout.addLayout(symmetry_tolerance_layout) | |
| # Check Symmetry | |
| check_symmetry_layout = QtWidgets.QHBoxLayout() | |
| check_symmetry_layout.setSpacing(4) | |
| self.check_symmetry_btn = QtWidgets.QPushButton("Check Symmetry") | |
| self.check_symmetry_btn.setToolTip("Check symmetry in local space across the chosen axis for selected mesh(es).") | |
| self.symmetry_axis_dropdown = QtWidgets.QComboBox() | |
| self.symmetry_axis_dropdown.addItems(["X", "Y", "Z"]) | |
| self.symmetry_axis_dropdown.setToolTip("Select axis for symmetry check.") | |
| check_symmetry_layout.addWidget(self.check_symmetry_btn) | |
| check_symmetry_layout.addWidget(self.symmetry_axis_dropdown) | |
| layout.addLayout(check_symmetry_layout) | |
| # Compare Verts | |
| self.compare_verts_btn = QtWidgets.QPushButton("Compare Verts") | |
| self.compare_verts_btn.setToolTip("Select two or more meshes to compare vertices, or use Conform Mesh List with right-click option.") | |
| layout.addWidget(self.compare_verts_btn) | |
| # Separator | |
| spacer_revert = QtWidgets.QFrame() | |
| spacer_revert.setFrameShape(QtWidgets.QFrame.HLine) | |
| spacer_revert.setFrameShadow(QtWidgets.QFrame.Sunken) | |
| layout.addWidget(spacer_revert) | |
| # Conform Mesh List Section | |
| conform_label_layout = QtWidgets.QHBoxLayout() | |
| conform_label = QtWidgets.QLabel("Conform Mesh List:") | |
| conform_label.setStyleSheet("color: gray;") | |
| self.sort_dropdown = QtWidgets.QComboBox() | |
| self.sort_dropdown.addItems(["Alphabetical A-Z", "Alphabetical Z-A", "Time"]) | |
| self.sort_dropdown.setCurrentText("Time") | |
| self.sort_dropdown.setToolTip("Sort the Conform Mesh List by name or order of addition.") | |
| conform_label_layout.addWidget(conform_label) | |
| conform_label_layout.addStretch() | |
| conform_label_layout.addWidget(self.sort_dropdown) | |
| layout.addLayout(conform_label_layout) | |
| # Revert Mesh List with Scroll Area | |
| self.stored_mesh_list_widget = QtWidgets.QListWidget() | |
| self.stored_mesh_list_widget.setSelectionMode(QtWidgets.QListWidget.MultiSelection) | |
| self.stored_mesh_list_widget.setToolTip("Stored mesh references for reversion. Select multiple meshes with Ctrl+click; single-click to select one. Right-click to remove, rename, or add/remove tag.") | |
| self.stored_mesh_list_widget.itemClicked.connect(self.handle_item_clicked) | |
| scroll_area = QtWidgets.QScrollArea() | |
| scroll_area.setWidget(self.stored_mesh_list_widget) | |
| scroll_area.setWidgetResizable(True) | |
| scroll_area.setMinimumHeight(self.list_height) | |
| scroll_area.setMaximumHeight(self.list_height) | |
| layout.addWidget(scroll_area) | |
| # Store and Revert Buttons | |
| revert_buttons_layout = QtWidgets.QHBoxLayout() | |
| revert_buttons_layout.setSpacing(4) | |
| self.store_mesh_btn = QtWidgets.QPushButton("Store Mesh in List") | |
| self.store_mesh_btn.setToolTip("Store a reference to the selected mesh(es) for reversion.") | |
| self.revert_mesh_btn = QtWidgets.QPushButton("Revert") | |
| self.revert_mesh_btn.setToolTip("Revert selected mesh(es) or vertices to the current state of selected reference mesh(es).") | |
| revert_buttons_layout.addWidget(self.store_mesh_btn) | |
| revert_buttons_layout.addWidget(self.revert_mesh_btn) | |
| layout.addLayout(revert_buttons_layout) | |
| # History Label | |
| self.history_label = QtWidgets.QLabel("No actions performed.") | |
| self.history_label.setStyleSheet("font-size: 12px; color: gray;") | |
| self.history_label.setAlignment(QtCore.Qt.AlignLeft) | |
| layout.addWidget(self.history_label) | |
| # Credit | |
| credit_label = QtWidgets.QLabel("Tool made by Vypac Voeur and should not be distributed") | |
| credit_label.setStyleSheet("font-size: 10px; color: gray; margin-top: 10px;") | |
| credit_label.setAlignment(QtCore.Qt.AlignLeft) | |
| layout.addWidget(credit_label) | |
| self.setLayout(layout) | |
| print("Vtx Snappy VV UI created successfully.") | |
| def handle_item_clicked(self, item): | |
| modifiers = QtWidgets.QApplication.keyboardModifiers() | |
| print(f"Item clicked: {item.text()}, Modifiers: Ctrl={bool(modifiers & QtCore.Qt.ControlModifier)}, Shift={bool(modifiers & QtCore.Qt.ShiftModifier)}") | |
| if not (modifiers & QtCore.Qt.ControlModifier or modifiers & QtCore.Qt.ShiftModifier): | |
| self.stored_mesh_list_widget.clearSelection() | |
| item.setSelected(True) | |
| print(f"Selected only: {item.text()}") | |
| def show_mesh_list_context_menu(self, pos): | |
| menu = QtWidgets.QMenu(self) | |
| selected_items = self.stored_mesh_list_widget.selectedItems() | |
| if selected_items: | |
| remove_action = menu.addAction("Remove Mesh") | |
| remove_action.setToolTip("Remove the selected mesh(es) from the list.") | |
| remove_action.triggered.connect(self.remove_mesh_from_list) | |
| rename_action = menu.addAction("Rename Mesh") | |
| rename_action.setToolTip("Change the display name of the selected mesh in the list.") | |
| rename_action.triggered.connect(self.rename_mesh_in_list) | |
| add_tag_action = menu.addAction("Add Custom Tag") | |
| add_tag_action.setToolTip("Add a custom tag prefix to the selected mesh in the list.") | |
| add_tag_action.triggered.connect(self.add_custom_tag_to_mesh) | |
| remove_tag_action = menu.addAction("Remove Tag") | |
| remove_tag_action.setToolTip("Remove the custom tag prefix from the selected mesh in the list.") | |
| remove_tag_action.triggered.connect(self.remove_tag_from_mesh) | |
| menu.addSeparator() | |
| extend_action = menu.addAction("Extend List") | |
| extend_action.setToolTip("Increase the height of the mesh list.") | |
| extend_action.triggered.connect(self.extend_list_height) | |
| shorten_action = menu.addAction("Shorten List") | |
| shorten_action.setToolTip("Decrease the height of the mesh list.") | |
| shorten_action.triggered.connect(self.shorten_list_height) | |
| print("Showing mesh list context menu") | |
| menu.exec_(self.stored_mesh_list_widget.mapToGlobal(pos)) | |
| def add_custom_tag_to_mesh(self): | |
| selected_items = self.stored_mesh_list_widget.selectedItems() | |
| if not selected_items: | |
| cmds.warning("No mesh selected to add tag.") | |
| print("Add Custom Tag aborted: No mesh selected.") | |
| return | |
| tag_name, ok = QtWidgets.QInputDialog.getText(self, "Add Custom Tag", "Enter tag name:", text="TAG") | |
| if not ok or not tag_name: | |
| cmds.warning("No tag name provided.") | |
| print("Add Custom Tag aborted: No tag name provided.") | |
| self.history_label.setText("Add Tag failed: No tag name.") | |
| return | |
| try: | |
| for item in selected_items: | |
| index = self.stored_mesh_list_widget.row(item) | |
| if index < len(self.stored_mesh_list): | |
| current_name = item.text() | |
| # Remove existing tag if present | |
| if current_name.startswith("["): | |
| current_name = current_name.split("]", 1)[-1] | |
| new_name = f"[{tag_name}]{current_name}" | |
| self.stored_mesh_list[index]["name"] = new_name | |
| item.setText(new_name) | |
| cmds.warning(f"Added tag [{tag_name}] to {current_name}.") | |
| print(f"Added tag: {new_name}") | |
| self.history_label.setText(f"Added tag [{tag_name}] to {len(selected_items)} mesh(es).") | |
| self.stored_mesh_list_widget.repaint() | |
| except Exception as e: | |
| cmds.warning(f"Error adding custom tag: {str(e)}") | |
| print(f"Error in add_custom_tag_to_mesh: {e}") | |
| self.history_label.setText("Add Tag failed: Error occurred.") | |
| def remove_tag_from_mesh(self): | |
| selected_items = self.stored_mesh_list_widget.selectedItems() | |
| if not selected_items: | |
| cmds.warning("No mesh selected to remove tag.") | |
| print("Remove Tag aborted: No mesh selected.") | |
| return | |
| try: | |
| for item in selected_items: | |
| index = self.stored_mesh_list_widget.row(item) | |
| if index < len(self.stored_mesh_list): | |
| current_name = item.text() | |
| if current_name.startswith("["): | |
| new_name = current_name.split("]", 1)[-1] | |
| self.stored_mesh_list[index]["name"] = new_name | |
| item.setText(new_name) | |
| cmds.warning(f"Removed tag from {current_name}.") | |
| print(f"Removed tag: {new_name}") | |
| else: | |
| print(f"Skipping {current_name}: No tag to remove.") | |
| self.history_label.setText(f"Removed tag from {len(selected_items)} mesh(es).") | |
| self.stored_mesh_list_widget.repaint() | |
| except Exception as e: | |
| cmds.warning(f"Error removing tag: {str(e)}") | |
| print(f"Error in remove_tag_from_mesh: {e}") | |
| self.history_label.setText("Remove Tag failed: Error occurred.") | |
| def sort_conform_list(self, sort_mode): | |
| try: | |
| if sort_mode == "Alphabetical A-Z": | |
| self.stored_mesh_list.sort(key=lambda x: x["name"].lower()) | |
| elif sort_mode == "Alphabetical Z-A": | |
| self.stored_mesh_list.sort(key=lambda x: x["name"].lower(), reverse=True) | |
| else: # Time | |
| self.stored_mesh_list.sort(key=lambda x: x.get("time_added", 0)) | |
| self.stored_mesh_list_widget.clear() | |
| for mesh_data in self.stored_mesh_list: | |
| item = QtWidgets.QListWidgetItem(mesh_data["name"]) | |
| self.stored_mesh_list_widget.addItem(item) | |
| mesh_data["item"] = item | |
| cmds.warning(f"Conform Mesh List sorted by {sort_mode}.") | |
| self.history_label.setText(f"Sorted list by {sort_mode}.") | |
| print(f"Sorted Conform Mesh List by {sort_mode}") | |
| except Exception as e: | |
| cmds.warning(f"Error sorting list: {str(e)}") | |
| print(f"Error in sort_conform_list: {e}") | |
| self.history_label.setText("Sort failed: Error occurred.") | |
| def store_mesh_in_list(self): | |
| sel = cmds.ls(sl=True, o=True) | |
| if not sel: | |
| cmds.warning("No meshes selected to store in list.") | |
| print("Store Mesh in List aborted: No meshes selected.") | |
| self.history_label.setText("Store Mesh failed: No meshes selected.") | |
| return | |
| try: | |
| cmds.undoInfo(openChunk=True, chunkName="storeMeshInList") | |
| stored_count = 0 | |
| existing_names = [mesh_data["name"].lstrip("[") for mesh_data in self.stored_mesh_list] | |
| import time | |
| for mesh in sel: | |
| if mesh in existing_names: | |
| cmds.warning(f"Mesh {mesh} already stored in list. Skipping.") | |
| print(f"Skipping {mesh}: Already stored in list.") | |
| continue | |
| shapes = cmds.listRelatives(mesh, shapes=True, fullPath=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "mesh": | |
| cmds.warning(f"Skipping {mesh}: Not a polygonal mesh.") | |
| print(f"Skipping {mesh}: Not a polygonal mesh.") | |
| continue | |
| verts = cmds.ls(f"{mesh}.vtx[*]", fl=True) | |
| if not verts: | |
| cmds.warning(f"No vertices found in {mesh}.") | |
| print(f"No vertices in {mesh}.") | |
| continue | |
| vertex_indices = [int(v.split("[")[-1].split("]")[0]) for v in verts] | |
| mesh_data = { | |
| "name": mesh, | |
| "vertex_count": len(verts), | |
| "vertex_indices": vertex_indices, | |
| "item": None, | |
| "time_added": time.time() | |
| } | |
| self.stored_mesh_list.append(mesh_data) | |
| item = QtWidgets.QListWidgetItem(mesh) | |
| self.stored_mesh_list_widget.addItem(item) | |
| mesh_data["item"] = item | |
| stored_count += 1 | |
| print(f"Stored reference to mesh {mesh} with {len(verts)} vertices in list.") | |
| if stored_count > 0: | |
| cmds.warning(f"Stored {stored_count} mesh reference(s) in list.") | |
| self.history_label.setText(f"Stored {stored_count} mesh(es) in list.") | |
| else: | |
| cmds.warning("No valid meshes stored in list.") | |
| print("Store Mesh in List aborted: No valid meshes stored.") | |
| self.history_label.setText("Store Mesh failed: No valid meshes.") | |
| self.stored_mesh_list_widget.repaint() | |
| except Exception as e: | |
| cmds.warning(f"Error storing meshes in list: {str(e)}") | |
| print(f"Error in store_mesh_in_list: {e}") | |
| self.history_label.setText("Store Mesh failed: Error occurred.") | |
| finally: | |
| cmds.undoInfo(closeChunk=True) | |
| def remove_mesh_from_list(self): | |
| selected_items = self.stored_mesh_list_widget.selectedItems() | |
| if not selected_items: | |
| cmds.warning("No mesh selected to remove.") | |
| print("Remove Mesh aborted: No mesh selected.") | |
| return | |
| try: | |
| for item in selected_items: | |
| index = self.stored_mesh_list_widget.row(item) | |
| if index < len(self.stored_mesh_list): | |
| mesh_name = self.stored_mesh_list[index]["name"] | |
| self.stored_mesh_list.pop(index) | |
| self.stored_mesh_list_widget.takeItem(index) | |
| cmds.warning(f"Removed mesh {mesh_name} from list.") | |
| print(f"Removed mesh {mesh_name} from list.") | |
| else: | |
| cmds.warning("Invalid mesh index in list.") | |
| print("Remove Mesh aborted: Invalid index.") | |
| self.history_label.setText(f"Removed {len(selected_items)} mesh(es) from list.") | |
| self.stored_mesh_list_widget.repaint() | |
| except Exception as e: | |
| cmds.warning(f"Error removing mesh: {str(e)}") | |
| print(f"Error in remove_mesh_from_list: {e}") | |
| self.history_label.setText("Remove Mesh failed: Error occurred.") | |
| def rename_mesh_in_list(self): | |
| selected_items = self.stored_mesh_list_widget.selectedItems() | |
| if not selected_items: | |
| cmds.warning("No mesh selected to rename.") | |
| print("Rename Mesh aborted: No mesh selected.") | |
| return | |
| item = selected_items[0] | |
| current_name = item.text() | |
| tag = "" | |
| if current_name.startswith("["): | |
| tag = current_name[:current_name.find("]") + 1] | |
| current_name = current_name.lstrip(tag) | |
| new_name, ok = QtWidgets.QInputDialog.getText(self, "Rename Mesh", "Enter new display name:", text=current_name) | |
| if ok and new_name: | |
| try: | |
| index = self.stored_mesh_list_widget.row(item) | |
| if index < len(self.stored_mesh_list): | |
| new_display_name = f"{tag}{new_name}" if tag else new_name | |
| self.stored_mesh_list[index]["name"] = new_display_name | |
| item.setText(new_display_name) | |
| cmds.warning(f"Renamed mesh to {new_display_name} in list.") | |
| print(f"Renamed mesh to {new_display_name} in list.") | |
| self.history_label.setText(f"Renamed mesh to {new_display_name}.") | |
| else: | |
| cmds.warning("Invalid mesh index in list.") | |
| print("Rename Mesh aborted: Invalid index.") | |
| self.stored_mesh_list_widget.repaint() | |
| except Exception as e: | |
| cmds.warning(f"Error renaming mesh: {str(e)}") | |
| print(f"Error in rename_mesh_in_list: {e}") | |
| self.history_label.setText("Rename Mesh failed: Error occurred.") | |
| def extend_list_height(self): | |
| current_height = self.stored_mesh_list_widget.parent().height() | |
| new_height = min(current_height + 50, 500) | |
| self.stored_mesh_list_widget.parent().setMinimumHeight(new_height) | |
| self.stored_mesh_list_widget.parent().setMaximumHeight(new_height) | |
| cmds.warning(f"List height set to {new_height} pixels.") | |
| print(f"List height extended to {new_height} pixels.") | |
| self.history_label.setText(f"Extended list height to {new_height}px.") | |
| def shorten_list_height(self): | |
| current_height = self.stored_mesh_list_widget.parent().height() | |
| new_height = max(current_height - 50, 100) | |
| self.stored_mesh_list_widget.parent().setMinimumHeight(new_height) | |
| self.stored_mesh_list_widget.parent().setMaximumHeight(new_height) | |
| cmds.warning(f"List height set to {new_height} pixels.") | |
| print(f"List height shortened to {new_height} pixels.") | |
| self.history_label.setText(f"Shortened list height to {new_height}px.") | |
| def show_revert_context_menu(self, pos): | |
| menu = QtWidgets.QMenu(self) | |
| # Placeholder for future revert options | |
| menu.exec_(self.revert_mesh_btn.mapToGlobal(pos)) | |
| def show_compare_verts_context_menu(self, pos): | |
| menu = QtWidgets.QMenu(self) | |
| use_conform_action = menu.addAction("Use Conform List") | |
| use_conform_action.setCheckable(True) | |
| use_conform_action.setChecked(self.use_conform_list) | |
| use_conform_action.setToolTip("Toggle to use the first selected mesh in the Conform Mesh List as the reference for Compare Verts.") | |
| use_conform_action.triggered.connect(self.toggle_use_conform_list) | |
| menu.exec_(self.compare_verts_btn.mapToGlobal(pos)) | |
| def toggle_use_conform_list(self): | |
| self.use_conform_list = not self.use_conform_list | |
| cmds.warning(f"Use Conform List for Compare Verts: {'Enabled' if self.use_conform_list else 'Disabled'}") | |
| self.history_label.setText(f"Use Conform List: {'Enabled' if self.use_conform_list else 'Disabled'}.") | |
| print(f"Use Conform List: {'Enabled' if self.use_conform_list else 'Disabled'}") | |
| def revert_to_stored_mesh(self): | |
| sel = cmds.ls(sl=True, fl=True) | |
| if not sel: | |
| cmds.warning("Select at least one mesh or vertices to revert.") | |
| print("Revert aborted: No selection.") | |
| self.history_label.setText("Revert failed: No selection.") | |
| return | |
| selected_items = self.stored_mesh_list_widget.selectedItems() | |
| if not selected_items: | |
| cmds.warning("Select at least one stored mesh from the list to revert to.") | |
| print("Revert aborted: No stored mesh selected.") | |
| self.history_label.setText("Revert failed: No stored mesh selected.") | |
| return | |
| try: | |
| cmds.undoInfo(openChunk=True, chunkName="revertToStoredMesh") | |
| selected_meshes = [s for s in cmds.ls(sl=True, o=True) if cmds.listRelatives(s, shapes=True, fullPath=True) and cmds.objectType(cmds.listRelatives(s, shapes=True, fullPath=True)[0]) == "mesh"] | |
| selected_vertices = [s for s in sel if ".vtx[" in s] | |
| space = om.MSpace.kObject if self.space_dropdown.currentText() == "Local Space" else om.MSpace.kWorld | |
| total_reverted_items = [] | |
| # Estimate operation time based on vertex count | |
| total_vertices = sum(len(cmds.ls(f"{m}.vtx[*]", fl=True) or []) for m in selected_meshes) + len(selected_vertices) | |
| use_progress = total_vertices > 10000 # Rough threshold for >1s operation | |
| progress = None | |
| if use_progress: | |
| progress = QtWidgets.QProgressDialog("Reverting to stored mesh...", "Cancel", 0, 100, self) | |
| progress.setWindowModality(QtCore.Qt.WindowModal) | |
| progress.setMinimumDuration(0) | |
| progress.setValue(0) | |
| QtWidgets.QApplication.processEvents() | |
| item_count = len(selected_items) | |
| for idx, item in enumerate(selected_items): | |
| if use_progress: | |
| progress.setValue(int((idx / item_count) * 100)) | |
| if progress.wasCanceled(): | |
| cmds.warning("Revert operation canceled by user.") | |
| self.history_label.setText("Revert canceled.") | |
| return | |
| QtWidgets.QApplication.processEvents() | |
| target_name = item.text() | |
| if target_name.startswith("["): | |
| target_name = target_name.split("]", 1)[-1] | |
| target_data = next((m for m in self.stored_mesh_list if m["name"].endswith(target_name)), None) | |
| if not target_data: | |
| cmds.warning(f"Skipping {target_name}: Not found in stored list") | |
| print(f"Skipping {target_name}: Not found in stored list") | |
| continue | |
| target_vertex_count = target_data["vertex_count"] | |
| target_indices = target_data["vertex_indices"] | |
| if not cmds.objExists(target_name): | |
| cmds.warning(f"Skipping {target_name}: Mesh does not exist") | |
| print(f"Skipping {target_name}: Mesh does not exist") | |
| continue | |
| target_verts = cmds.ls(f"{target_name}.vtx[*]", fl=True) | |
| if len(target_verts) != target_vertex_count: | |
| cmds.warning(f"Skipping {target_name}: Vertex count mismatch: Current ({len(target_verts)}) vs Stored ({target_vertex_count})") | |
| print(f"Skipping {target_name}: Vertex count mismatch") | |
| continue | |
| target_positions = [cmds.pointPosition(v, local=True) for v in target_verts] | |
| print(f"Queried current state of {target_name} with {target_vertex_count} vertices") | |
| reverted_items = [] | |
| if selected_vertices: | |
| for vert in selected_vertices: | |
| mesh = vert.split(".vtx[")[0] | |
| vert_index_str = vert.split("[")[-1].split("]")[0] | |
| try: | |
| vert_index = int(vert_index_str) | |
| except ValueError: | |
| cmds.warning(f"Skipping {vert}: Invalid vertex index") | |
| print(f"Skipping {vert}: Invalid vertex index") | |
| continue | |
| shapes = cmds.listRelatives(mesh, shapes=True, fullPath=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "mesh": | |
| cmds.warning(f"Skipping {vert}: Not part of a polygonal mesh") | |
| print(f"Skipping {vert}: Not part of a polygonal mesh") | |
| continue | |
| if mesh != target_name: | |
| print(f"Mesh {mesh} does not match referenced mesh {target_name}. Proceeding with vertex count check") | |
| if vert_index in target_indices: | |
| target_idx = target_indices.index(vert_index) | |
| target_pos = target_positions[target_idx] | |
| cmds.xform(vert, t=target_pos, ws=(space == om.MSpace.kWorld)) | |
| reverted_items.append(vert) | |
| total_reverted_items.append(vert) | |
| print(f"Reverted vertex {vert} to position {target_pos} from {target_name}") | |
| else: | |
| cmds.warning(f"Skipping {vert}: Vertex index {vert_index} not in referenced mesh {target_name}") | |
| print(f"Skipping {vert}: Vertex index not in referenced mesh") | |
| else: | |
| for mesh in selected_meshes: | |
| shapes = cmds.listRelatives(mesh, shapes=True, fullPath=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "mesh": | |
| cmds.warning(f"Skipping {mesh}: Not a polygonal mesh") | |
| print(f"Skipping {mesh}: Not a polygonal mesh") | |
| continue | |
| if mesh != target_name: | |
| print(f"Mesh {mesh} does not match referenced mesh {target_name}. Proceeding with vertex count check") | |
| mesh_list = om.MSelectionList() | |
| mesh_list.add(mesh) | |
| mesh_dag = mesh_list.getDagPath(0) | |
| mesh_mfn = om.MFnMesh(mesh_dag) | |
| mesh_points = mesh_mfn.getPoints(space) | |
| mesh_vertex_count = len(mesh_points) | |
| if mesh_vertex_count != target_vertex_count: | |
| cmds.warning(f"Skipping {mesh}: Vertex count mismatch: Mesh ({mesh_vertex_count}) vs Referenced ({target_vertex_count})") | |
| print(f"Skipping {mesh}: Vertex count mismatch") | |
| continue | |
| target_points = [om.MPoint(p[0], p[1], p[2]) for p in target_positions] | |
| mesh_mfn.setPoints(target_points, space) | |
| reverted_items.append(mesh) | |
| total_reverted_items.append(mesh) | |
| print(f"Reverted mesh {mesh} to current state of {target_name}") | |
| if reverted_items: | |
| cmds.warning(f"Reverted {len(reverted_items)} item(s) to current state of {target_name}") | |
| print(f"Reverted {len(reverted_items)} item(s) to current state of {target_name}") | |
| if use_progress: | |
| progress.setValue(100) | |
| progress.deleteLater() | |
| if not total_reverted_items: | |
| cmds.warning("No meshes or vertices reverted due to validation errors.") | |
| print("Revert aborted: No valid items reverted") | |
| self.history_label.setText("Revert failed: No valid items.") | |
| return | |
| cmds.select(total_reverted_items, r=True) | |
| cmds.warning(f"Reverted {len(total_reverted_items)} item(s) to selected reference mesh(es)") | |
| self.history_label.setText(f"Reverted {len(total_reverted_items)} item(s).") | |
| print(f"Revert complete: Reverted {len(total_reverted_items)} items") | |
| except Exception as e: | |
| cmds.warning(f"Error reverting to referenced mesh: {str(e)}") | |
| print(f"Error in revert_to_stored_mesh: {e}") | |
| self.history_label.setText("Revert failed: Error occurred.") | |
| finally: | |
| cmds.undoInfo(closeChunk=True) | |
| def show_snap_context_menu(self, pos): | |
| menu = QtWidgets.QMenu(self) | |
| match_action = menu.addAction("Match Position") | |
| match_action.setToolTip("Align source mesh's transform to target mesh.") | |
| match_action.triggered.connect(self.match_position) | |
| move_origin_action = menu.addAction("Move to Origin") | |
| move_origin_action.setToolTip("Move selected objects to world origin.") | |
| move_origin_action.triggered.connect(self.move_to_origin) | |
| menu.exec_(self.snap_btn.mapToGlobal(pos)) | |
| def show_match_mesh_context_menu(self, pos): | |
| menu = QtWidgets.QMenu(self) | |
| store_target_action = menu.addAction("Register Target") | |
| store_target_action.setToolTip("Store first selected mesh as target for Match Mesh.") | |
| store_target_action.triggered.connect(self.store_target_mesh) | |
| menu.exec_(self.match_mesh_btn.mapToGlobal(pos)) | |
| def store_target_mesh(self): | |
| sel = cmds.ls(sl=True, o=True) | |
| if not sel: | |
| cmds.warning("No mesh selected to store as target.") | |
| print("Store Target aborted: No mesh selected.") | |
| self.history_label.setText("Store Target failed: No mesh selected.") | |
| return | |
| mesh = sel[0] | |
| shapes = cmds.listRelatives(mesh, shapes=True, fullPath=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "mesh": | |
| cmds.warning("Selected object must be a polygonal mesh.") | |
| print("Store Target aborted: Not a polygonal mesh.") | |
| self.history_label.setText("Store Target failed: Not a mesh.") | |
| return | |
| self.stored_target = mesh | |
| self.use_stored_target_cb.setChecked(True) | |
| self.use_stored_target = True | |
| cmds.warning(f"Stored target mesh: {mesh}") | |
| self.history_label.setText(f"Stored target mesh: {mesh}") | |
| print(f"Stored target mesh: {mesh}") | |
| def match_position(self): | |
| sel = cmds.ls(sl=True, o=True) | |
| if len(sel) != 2: | |
| cmds.warning("Select exactly two objects: target first, then source.") | |
| print("Match Position aborted: Need exactly two selected objects.") | |
| self.history_label.setText("Match Position failed: Need two objects.") | |
| return | |
| target, source = sel | |
| shapes = cmds.listRelatives(target, shapes=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "mesh": | |
| cmds.warning("Target must be a polygonal mesh.") | |
| print("Match Position aborted: Target is not a polygonal mesh.") | |
| self.history_label.setText("Match Position failed: Target not a mesh.") | |
| return | |
| shapes = cmds.listRelatives(source, shapes=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "mesh": | |
| cmds.warning("Source must be a polygonal mesh.") | |
| print("Match Position aborted: Source is not a polygonal mesh.") | |
| self.history_label.setText("Match Position failed: Source not a mesh.") | |
| return | |
| try: | |
| cmds.undoInfo(openChunk=True, chunkName="matchPosition") | |
| target_trans = cmds.xform(target, q=True, t=True, ws=True) | |
| target_rot = cmds.xform(target, q=True, ro=True, ws=True) | |
| target_scale = cmds.xform(target, q=True, s=True, ws=True) | |
| cmds.xform(source, t=target_trans, ws=True) | |
| cmds.xform(source, ro=target_rot, ws=True) | |
| cmds.xform(source, s=target_scale, ws=True) | |
| print(f"Matched source {source} to target {target}") | |
| cmds.warning("Match Position complete.") | |
| self.history_label.setText(f"Matched {source} to {target}.") | |
| except Exception as e: | |
| cmds.warning(f"Error during Match Position: {str(e)}") | |
| print(f"Error in match_position: {e}") | |
| self.history_label.setText("Match Position failed: Error occurred.") | |
| finally: | |
| cmds.undoInfo(closeChunk=True) | |
| def move_to_origin(self): | |
| sel = cmds.ls(sl=True, o=True) | |
| if not sel: | |
| cmds.warning("No objects selected to move to origin.") | |
| print("Move to Origin aborted: No objects selected.") | |
| self.history_label.setText("Move to Origin failed: No objects.") | |
| return | |
| try: | |
| cmds.undoInfo(openChunk=True, chunkName="moveToOrigin") | |
| for obj in sel: | |
| cmds.xform(obj, t=[0, 0, 0], ws=True) | |
| print(f"Moved {obj} to world origin") | |
| cmds.warning(f"Moved {len(sel)} object(s) to origin.") | |
| self.history_label.setText(f"Moved {len(sel)} object(s) to origin.") | |
| except Exception as e: | |
| cmds.warning(f"Error moving to origin: {str(e)}") | |
| print(f"Error in move_to_origin: {e}") | |
| self.history_label.setText("Move to Origin failed: Error occurred.") | |
| finally: | |
| cmds.undoInfo(closeChunk=True) | |
| def get_closest_vertex(self, source_pos, target_obj, tolerance): | |
| try: | |
| sel_list = om.MSelectionList() | |
| sel_list.add(target_obj) | |
| dag_path = sel_list.getDagPath(0) | |
| mesh_fn = om.MFnMesh(dag_path) | |
| point = om.MPoint(source_pos[0], source_pos[1], source_pos[2]) | |
| vertex_iterator = om.MItMeshVertex(dag_path) | |
| min_dist = float('inf') | |
| closest_vertex_pos = None | |
| while not vertex_iterator.isDone(): | |
| vertex_pos = vertex_iterator.position(om.MSpace.kWorld) | |
| dist = (point - vertex_pos).length() | |
| if dist < min_dist: | |
| min_dist = dist | |
| closest_vertex_pos = [vertex_pos.x, vertex_pos.y, vertex_pos.z] | |
| vertex_iterator.next() | |
| if min_dist <= tolerance: | |
| print(f"Found closest vertex at {closest_vertex_pos}, distance: {min_dist}") | |
| return closest_vertex_pos | |
| else: | |
| print(f"No target vertex within tolerance {tolerance} (distance: {min_dist})") | |
| return None | |
| except Exception as e: | |
| cmds.warning(f"Error finding closest vertex: {str(e)}") | |
| print(f"Error in get_closest_vertex: {e}") | |
| return None | |
| def snap_vertices(self): | |
| sel = cmds.ls(sl=True, o=True) | |
| if len(sel) != 2: | |
| cmds.warning("Select exactly two objects: target first, then source.") | |
| print("Snap Vertices aborted: Need exactly two selected objects.") | |
| self.history_label.setText("Snap Vertices failed: Need two objects.") | |
| return | |
| target, source = sel | |
| shapes = cmds.listRelatives(target, shapes=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "mesh": | |
| cmds.warning("Target must be a polygonal mesh.") | |
| print("Snap Vertices aborted: Target is not a polygonal mesh.") | |
| self.history_label.setText("Snap Vertices failed: Target not a mesh.") | |
| return | |
| shapes = cmds.listRelatives(source, shapes=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "mesh": | |
| cmds.warning("Source must be a polygonal mesh.") | |
| print("Snap Vertices aborted: Source is not a polygonal mesh.") | |
| self.history_label.setText("Snap Vertices failed: Source not a mesh.") | |
| return | |
| try: | |
| cmds.undoInfo(openChunk=True, chunkName="snapVertices") | |
| tolerance = self.tolerance_value | |
| print(f"Using snap tolerance: {tolerance} world units") | |
| source_verts = cmds.ls(f"{source}.vtx[*]", fl=True) | |
| print(f"Processing {len(source_verts)} vertices...") | |
| snapped_count = 0 | |
| for vert in source_verts: | |
| source_pos = cmds.pointPosition(vert, world=True) | |
| target_pos = self.get_closest_vertex(source_pos, target, tolerance) | |
| if target_pos: | |
| cmds.xform(vert, t=target_pos, ws=True) | |
| snapped_count += 1 | |
| print(f"Snapped vertex {vert} to {target_pos}") | |
| else: | |
| print(f"Skipping vertex {vert}: No target vertex within tolerance") | |
| cmds.select(source) | |
| cmds.warning(f"Vertex snapping complete! Snapped {snapped_count}/{len(source_verts)} vertices.") | |
| self.history_label.setText(f"Snapped {snapped_count}/{len(source_verts)} vertices.") | |
| print(f"Snapping complete: {snapped_count}/{len(source_verts)} vertices snapped.") | |
| except Exception as e: | |
| cmds.warning(f"Error snapping vertices: {str(e)}") | |
| print(f"Error in snap_vertices: {e}") | |
| self.history_label.setText("Snap Vertices failed: Error occurred.") | |
| finally: | |
| cmds.undoInfo(closeChunk=True) | |
| def setup_selection_callback(self): | |
| try: | |
| self.selection_callback = om.MEventMessage.addEventCallback("SelectionChanged", self.on_selection_changed) | |
| print("Selection change callback registered.") | |
| except Exception as e: | |
| print(f"Failed to register selection callback: {e}") | |
| def on_selection_changed(self, *args): | |
| if self.live_projected_cv: | |
| selection = cmds.ls(sl=True, fl=True) | |
| vertices = [s for s in selection if ".vtx[" in s or ".cv[" in s] | |
| targets = [s for s in selection if ".vtx[" not in s and ".cv[" not in s] | |
| if not vertices or not targets: | |
| self.last_vertices = [] | |
| self.last_targets = [] | |
| print("Live Projected (CV Project) skipped: Invalid selection.") | |
| else: | |
| self.last_vertices = vertices | |
| self.last_targets = targets | |
| self.cv_project() | |
| print("Live Projected (CV Project) updated.") | |
| def closeEvent(self, event): | |
| if self.selection_callback: | |
| try: | |
| om.MMessage.removeCallback(self.selection_callback) | |
| print("Selection callback removed.") | |
| except: | |
| pass | |
| super(VVvertsnap, self).closeEvent(event) | |
| def show_cv_context_menu(self, pos): | |
| menu = QtWidgets.QMenu(self) | |
| live_action = menu.addAction("Toggle Live Projection") | |
| live_action.setCheckable(True) | |
| live_action.setChecked(self.live_projected_cv) | |
| live_action.triggered.connect(self.toggle_live_projected_cv) | |
| menu.exec_(self.cv_project_btn.mapToGlobal(pos)) | |
| def toggle_live_projected_cv(self): | |
| self.live_projected_cv = not self.live_projected_cv | |
| if self.live_projected_cv: | |
| self.cv_project_btn.setStyleSheet("background-color: #70C4B4; color: black; padding: 5px;") | |
| selection = cmds.ls(sl=True, fl=True) | |
| vertices = [s for s in selection if ".vtx[" in s or ".cv[" in s] | |
| targets = [s for s in selection if ".vtx[" not in s and ".cv[" not in s] | |
| if vertices and targets: | |
| self.last_vertices = vertices | |
| self.last_targets = targets | |
| self.cv_project() | |
| print("Live Projected (CV Project) enabled and triggered.") | |
| else: | |
| print("Live Projected (CV Project) enabled but no valid selection.") | |
| else: | |
| self.cv_project_btn.setStyleSheet("background-color: #A3E4D7; color: black; padding: 5px;") | |
| print("Live Projected (CV Project) disabled.") | |
| self.history_label.setText(f"Live Projection {'enabled' if self.live_projected_cv else 'disabled'}.") | |
| def update_offset_mode(self, mode_text): | |
| mode_map = { | |
| "No offset": "noOffset", | |
| "distance from surface": "offsetMode0", | |
| "travel percentage": "offsetMode1" | |
| } | |
| self.offset_mode = mode_map.get(mode_text, "noOffset") | |
| if self.offset_mode == "offsetMode1": | |
| self.offset_slider.setRange(0, 100) | |
| self.offset_field.setRange(0.0, 100.0) | |
| self.offset_slider.setValue(100) | |
| self.offset_field.setValue(100.0) | |
| else: | |
| self.offset_slider.setRange(-1000, 1000) | |
| self.offset_field.setRange(-100.0, 100.0) | |
| self.offset_slider.setValue(0) | |
| self.offset_field.setValue(0.0) | |
| self.offset_slider.setEnabled(self.offset_mode != "noOffset") | |
| self.offset_field.setEnabled(self.offset_mode != "noOffset") | |
| print(f"Offset mode set to: {self.offset_mode}") | |
| self.history_label.setText(f"Offset mode set to: {mode_text}.") | |
| if self.live_projected_cv and self.last_vertices and self.last_targets: | |
| self.cv_project() | |
| def update_offset_value(self, value): | |
| if self.offset_mode == "offsetMode1": | |
| self.offset_value = value / 100.0 | |
| self.offset_field.setValue(value) | |
| else: | |
| self.offset_value = value / 10.0 | |
| self.offset_field.setValue(self.offset_value) | |
| print(f"Offset value set to: {self.offset_value}") | |
| self.history_label.setText(f"Offset value set to: {self.offset_value}.") | |
| if self.live_projected_cv and self.last_vertices and self.last_targets: | |
| self.cv_project() | |
| def update_offset_field(self, value): | |
| if self.offset_mode == "offsetMode1": | |
| self.offset_value = value / 100.0 | |
| self.offset_slider.setValue(int(value)) | |
| else: | |
| self.offset_value = value | |
| self.offset_slider.setValue(int(value * 10)) | |
| print(f"Offset value set to: {self.offset_value}") | |
| self.history_label.setText(f"Offset value set to: {self.offset_value}.") | |
| if self.live_projected_cv and self.last_vertices and self.last_targets: | |
| self.cv_project() | |
| def update_tolerance_value(self, value): | |
| self.tolerance_value = value / 10.0 | |
| self.tolerance_field.setValue(self.tolerance_value) | |
| print(f"Tolerance value set to: {self.tolerance_value}") | |
| self.history_label.setText(f"Tolerance set to: {self.tolerance_value}.") | |
| if self.live_projected_cv and self.last_vertices and self.last_targets: | |
| self.cv_project() | |
| def update_tolerance_field(self, value): | |
| self.tolerance_value = value | |
| self.tolerance_slider.setValue(int(value * 10)) | |
| print(f"Tolerance value set to: {self.tolerance_value}") | |
| self.history_label.setText(f"Tolerance set to: {self.tolerance_value}.") | |
| if self.live_projected_cv and self.last_vertices and self.last_targets: | |
| self.cv_project() | |
| def update_tolerance_tweening(self, state): | |
| self.use_tolerance_tweening = state == QtCore.Qt.Checked | |
| self.cleanup_tween_slider.setEnabled(self.use_tolerance_tweening) | |
| self.cleanup_tween_field.setEnabled(self.use_tolerance_tweening) | |
| print(f"Use Tolerance: {'Enabled' if self.use_tolerance_tweening else 'Disabled'}") | |
| self.history_label.setText(f"Use Tolerance: {'Enabled' if self.use_tolerance_tweening else 'Disabled'}.") | |
| def update_cleanup_tween_value(self, value): | |
| self.cleanup_tween_value = value / 100.0 | |
| self.cleanup_tween_field.setValue(self.cleanup_tween_value) | |
| print(f"Cleanup Tween value set to: {self.cleanup_tween_value}") | |
| self.history_label.setText(f"Cleanup Tween set to: {self.cleanup_tween_value}.") | |
| def update_cleanup_tween_field(self, value): | |
| self.cleanup_tween_value = value | |
| self.cleanup_tween_slider.setValue(int(value * 100)) | |
| print(f"Cleanup Tween value set to: {self.cleanup_tween_value}") | |
| self.history_label.setText(f"Cleanup Tween set to: {self.cleanup_tween_value}.") | |
| def update_symmetry_tolerance_field(self, value): | |
| self.symmetry_tolerance_value = value | |
| print(f"Symmetry/Compare Tolerance set to: {self.symmetry_tolerance_value}") | |
| self.history_label.setText(f"Symmetry Tolerance set to: {self.symmetry_tolerance_value}.") | |
| def update_use_stored_target(self, state): | |
| self.use_stored_target = state == QtCore.Qt.Checked | |
| print(f"Use Stored Target: {'Enabled' if self.use_stored_target else 'Disabled'}") | |
| self.history_label.setText(f"Use Stored Target: {'Enabled' if self.use_stored_target else 'Disabled'}.") | |
| def rebuild_curve(self): | |
| sel = cmds.ls(selection=True, fl=True) | |
| if not sel: | |
| cmds.warning("Select a NURBS curve to rebuild.") | |
| self.history_label.setText("Rebuild Curve failed: No curve selected.") | |
| return | |
| shapes = cmds.listRelatives(sel[0], shapes=True, fullPath=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "nurbsCurve": | |
| cmds.warning("Selected object is not a NURBS curve.") | |
| self.history_label.setText("Rebuild Curve failed: Not a curve.") | |
| return | |
| try: | |
| cmds.undoInfo(openChunk=True, chunkName="rebuildCurve") | |
| curve = sel[0] | |
| degree = int(self.degree_dropdown.currentText().split()[0]) | |
| spans = self.spans_field.value() | |
| if spans < degree: | |
| spans = degree | |
| self.spans_field.setValue(spans) | |
| cmds.rebuildCurve(curve, ch=False, rpo=True, rt=0, end=1, kr=0, kcp=0, kep=1, kt=0, s=spans, d=degree) | |
| cmds.warning(f"Curve {curve} rebuilt with degree {degree}, {spans} spans/CVs.") | |
| self.history_label.setText(f"Rebuilt curve: {curve}.") | |
| print(f"Rebuilt curve: {curve}") | |
| except Exception as e: | |
| cmds.warning(f"Error rebuilding curve: {str(e)}") | |
| self.history_label.setText("Rebuild Curve failed: Error occurred.") | |
| finally: | |
| cmds.undoInfo(closeChunk=True) | |
| def edges_to_curve(self): | |
| sel = cmds.ls(selection=True, fl=True) | |
| if not sel or not any(".e[" in s for s in sel): | |
| cmds.warning("Select edges on a polygonal mesh.") | |
| self.history_label.setText("Edges to Curve failed: No edges selected.") | |
| return | |
| mesh = sel[0].split(".e[")[0] | |
| shapes = cmds.listRelatives(mesh, shapes=True, fullPath=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "mesh": | |
| cmds.warning(f"Selected edges do not belong to a polygonal mesh.") | |
| self.history_label.setText("Edges to Curve failed: Not a mesh.") | |
| return | |
| try: | |
| cmds.undoInfo(openChunk=True, chunkName="edgesToCurve") | |
| degree = int(self.degree_dropdown.currentText().split()[0]) | |
| curve = cmds.polyToCurve(form=2, degree=degree, name="edgeCurve_#")[0] | |
| curve_shapes = cmds.listRelatives(curve, shapes=True, fullPath=True) | |
| if cmds.getAttr(f"{curve_shapes[0]}.form") != 0: | |
| cmds.openCloseCurve(curve, ch=False, ps=0, rpo=True) | |
| cmds.select(curve) | |
| cmds.warning(f"Edges converted to curve: {curve}.") | |
| self.history_label.setText(f"Created curve: {curve}.") | |
| print(f"Created curve: {curve}") | |
| except Exception as e: | |
| cmds.warning(f"Error converting edges to curve: {str(e)}") | |
| self.history_label.setText("Edges to Curve failed: Error occurred.") | |
| finally: | |
| cmds.undoInfo(closeChunk=True) | |
| def cleanup_edge_flow(self): | |
| sel = cmds.ls(selection=True, fl=True) | |
| if not sel: | |
| cmds.warning("Please select edges on a polygonal mesh.") | |
| print("Cleanup Edge Flow aborted: No edges selected.") | |
| self.history_label.setText("Cleanup Edge Flow failed: No edges selected.") | |
| return | |
| edges = [s for s in sel if ".e[" in s] | |
| if not edges: | |
| cmds.warning("No edges selected. Please select edge components.") | |
| print("Cleanup Edge Flow aborted: No edge components selected.") | |
| self.history_label.setText("Cleanup Edge Flow failed: No edges selected.") | |
| return | |
| mesh = edges[0].split(".e[")[0] | |
| shapes = cmds.listRelatives(mesh, shapes=True, fullPath=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "mesh": | |
| cmds.warning(f"Selected edges do not belong to a polygonal mesh.") | |
| print(f"Cleanup Edge Flow aborted: {mesh} is not a polygonal mesh.") | |
| self.history_label.setText("Cleanup Edge Flow failed: Not a mesh.") | |
| return | |
| try: | |
| cmds.undoInfo(openChunk=True, chunkName="cleanupEdgeFlow") | |
| cmds.select(edges, r=True) | |
| cmds.ConvertSelectionToVertices() | |
| vertices = cmds.ls(sl=True, fl=True) | |
| if not vertices or not all(".vtx[" in v for v in vertices): | |
| cmds.warning("Failed to convert edges to vertices.") | |
| print("Cleanup Edge Flow aborted: No vertices from edge conversion.") | |
| self.history_label.setText("Cleanup Edge Flow failed: No vertices.") | |
| return | |
| print(f"Converted {len(edges)} edges to {len(vertices)} vertices") | |
| initial_positions = {v: cmds.pointPosition(v, world=True) for v in vertices} | |
| print(f"Initial vertex positions captured") | |
| cmds.select(edges, r=True) | |
| curve = cmds.polyToCurve(form=2, degree=3, name="edgeFlowCurve_#")[0] | |
| self.created_curve = curve | |
| print(f"Created curve from edges: {curve}") | |
| curve_shapes = cmds.listRelatives(curve, shapes=True, fullPath=True) | |
| if not curve_shapes: | |
| raise RuntimeError(f"Curve {curve} has no shape node.") | |
| curve_form = cmds.getAttr(f"{curve_shapes[0]}.form") | |
| if curve_form != 0: | |
| print(f"Curve {curve} is closed, forcing open.") | |
| cmds.openCloseCurve(curve, ch=False, ps=0, rpo=True) | |
| print(f"Forced curve {curve} to open.") | |
| degree = 3 | |
| num_edges = len(edges) | |
| if self.use_tolerance_tweening: | |
| max_cvs = max(degree + 1, num_edges) | |
| min_cvs = max(degree + 1, math.ceil(num_edges / 30.0)) | |
| t = self.cleanup_tween_value | |
| initial_cvs = int(max_cvs - t * (max_cvs - min_cvs)) | |
| final_cvs = initial_cvs * 3 | |
| print(f"Use Tolerance enabled: initial_cvs={initial_cvs}, final_cvs={final_cvs}") | |
| else: | |
| initial_cvs = 5 | |
| final_cvs = 15 | |
| print(f"Use Tolerance disabled: initial_cvs={initial_cvs}, final_cvs={final_cvs}") | |
| initial_spans = initial_cvs - degree | |
| if initial_spans < 1: | |
| initial_spans = 1 | |
| initial_cvs = initial_spans + degree | |
| print(f"Adjusted initial_cvs to {initial_cvs}") | |
| cmds.rebuildCurve(curve, ch=False, rpo=True, rt=0, end=1, kr=0, kcp=0, kep=1, kt=0, s=initial_spans, d=degree) | |
| print(f"Initial rebuild: {curve} with {initial_cvs} CVs") | |
| final_spans = final_cvs - degree | |
| if final_spans < 1: | |
| final_spans = 1 | |
| final_cvs = final_spans + degree | |
| print(f"Adjusted final_cvs to {final_cvs}") | |
| cmds.rebuildCurve(curve, ch=False, rpo=True, rt=0, end=1, kr=0, kcp=0, kep=1, kt=0, s=final_spans, d=degree) | |
| print(f"Final rebuild: {curve} with {final_cvs} CVs") | |
| if self.use_tolerance_tweening: | |
| cmds.delete(curve, ch=True) | |
| print(f"Deleted history on curve: {curve}") | |
| cmds.select(clear=True) | |
| for vertex in vertices: | |
| cmds.select(vertex, add=True) | |
| cmds.select(curve, add=True) | |
| print(f"Selected for CV Project: {vertices} and curve: {curve}") | |
| self.cv_project() | |
| snapped_positions = {v: cmds.pointPosition(v, world=True) for v in vertices} | |
| snapped_count = 0 | |
| for v in vertices: | |
| initial = initial_positions[v] | |
| snapped = snapped_positions[v] | |
| distance = self.calculate_distance(initial, snapped) | |
| if distance > 1e-6: | |
| snapped_count += 1 | |
| print(f"Vertex {v} snapped: distance={distance}") | |
| else: | |
| print(f"Vertex {v} did not move") | |
| if snapped_count == 0: | |
| cmds.warning("No vertices snapped. Check tolerance or curve alignment.") | |
| print("CV Project failed to snap vertices.") | |
| self.history_label.setText("Cleanup Edge Flow failed: No vertices snapped.") | |
| return | |
| cmds.delete(curve) | |
| print(f"Deleted curve: {curve}") | |
| self.created_curve = None | |
| cmds.select(vertices, r=True) | |
| cmds.warning(f"Cleanup Edge Flow complete! Snapped {snapped_count}/{len(vertices)} vertices.") | |
| self.history_label.setText(f"Snapped {snapped_count}/{len(vertices)} vertices in edge flow.") | |
| print(f"Cleanup Edge Flow complete: {snapped_count} vertices snapped.") | |
| except Exception as e: | |
| cmds.warning(f"Error in Cleanup Edge Flow: {str(e)}") | |
| print(f"Error in Cleanup Edge Flow: {e}") | |
| self.history_label.setText("Cleanup Edge Flow failed: Error occurred.") | |
| finally: | |
| cmds.undoInfo(closeChunk=True) | |
| def calculate_distance(self, p1, p2): | |
| return ((p2[0] - p1[0])**2 + (p2[1] - p1[1])**2 + (p2[2] - p1[2])**2)**0.5 | |
| def normalize_vector(self, vec): | |
| length = (vec[0]**2 + vec[1]**2 + vec[2]**2)**0.5 | |
| return [vec[0]/length, vec[1]/length, vec[2]/length] if length > 0 else [0, 0, 0] | |
| def get_closest_point_xyz(self, input_xyz, target_obj): | |
| try: | |
| null_group = cmds.group(em=True, n="tempNull_#") | |
| cmds.xform(null_group, ws=True, t=input_xyz) | |
| cmds.select(target_obj, null_group, r=True) | |
| geo_const = cmds.geometryConstraint(weight=1)[0] | |
| location = cmds.xform(geo_const, q=True, t=True, ws=True) | |
| cmds.delete(null_group) | |
| return location | |
| except Exception as e: | |
| print(f"Error in get_closest_point_xyz: {e}") | |
| return None | |
| def get_closest_surface_point(self, source_pos, target_obj, tolerance, is_curve=False): | |
| temp_curve = None | |
| temp_curve_copy = None | |
| loft_surface = None | |
| try: | |
| shapes = cmds.listRelatives(target_obj, shapes=True, fullPath=True) | |
| if not shapes: | |
| return None | |
| target_shape = shapes[0] | |
| target_type = cmds.objectType(target_shape) | |
| if is_curve or target_type == "nurbsCurve": | |
| degree = int(self.degree_dropdown.currentText().split()[0]) | |
| spans = self.spans_field.value() | |
| if spans < degree: | |
| spans = degree | |
| temp_curve = cmds.duplicate(target_obj, n="tempCurve_#")[0] | |
| cmds.rebuildCurve(temp_curve, ch=False, rpo=True, rt=0, end=1, kr=0, kcp=0, kep=1, kt=0, s=spans, d=degree) | |
| temp_curve_copy = cmds.duplicate(temp_curve, n="tempCurveCopy_#")[0] | |
| cmds.xform(temp_curve_copy, t=[0, 0.01, 0], ws=True) | |
| loft_surface = cmds.loft(temp_curve, temp_curve_copy, n="tempLoftSurface_#", ch=False, u=True, ss=1)[0] | |
| loft_shapes = cmds.listRelatives(loft_surface, shapes=True, fullPath=True) | |
| target_shape = loft_shapes[0] | |
| print(f"Created loft surface: {loft_surface}") | |
| closest_pos = self.get_closest_point_xyz(source_pos, target_obj) | |
| if not closest_pos: | |
| return None | |
| closest_point_node = cmds.createNode("closestPointOnSurface", n="tempClosestPoint_#") | |
| cmds.connectAttr(f"{target_shape}.worldSpace[0]", f"{closest_point_node}.inputSurface") | |
| cmds.setAttr(f"{closest_point_node}.inPosition", *source_pos) | |
| u_value = cmds.getAttr(f"{closest_point_node}.parameterU") | |
| v_value = cmds.getAttr(f"{closest_point_node}.parameterV") | |
| cmds.delete(closest_point_node) | |
| u_range = cmds.getAttr(f"{target_shape}.minMaxRangeU")[0] | |
| v_range = cmds.getAttr(f"{target_shape}.minMaxRangeV")[0] | |
| if not (u_range[0] <= u_value <= u_range[1] and v_range[0] <= v_value <= v_range[1]): | |
| return None | |
| distance = self.calculate_distance(source_pos, closest_pos) | |
| if distance > tolerance: | |
| return None | |
| return closest_pos | |
| except Exception as e: | |
| print(f"Error in get_closest_surface_point: {e}") | |
| return None | |
| finally: | |
| if any([temp_curve, temp_curve_copy, loft_surface]): | |
| try: | |
| cmds.delete([x for x in [temp_curve, temp_curve_copy, loft_surface] if x]) | |
| except: | |
| pass | |
| def get_closest_mesh_point(self, source_pos, target_obj, tolerance): | |
| try: | |
| sel_list = om.MSelectionList() | |
| sel_list.add(target_obj) | |
| dag_path = sel_list.getDagPath(0) | |
| mesh_fn = om.MFnMesh(dag_path) | |
| point = om.MPoint(source_pos[0], source_pos[1], source_pos[2]) | |
| closest_point, _ = mesh_fn.getClosestPoint(point, om.MSpace.kWorld) | |
| closest_pos = [closest_point.x, closest_point.y, closest_point.z] | |
| distance = (point - closest_point).length() | |
| if distance > tolerance: | |
| return None | |
| return closest_pos | |
| except Exception as e: | |
| print(f"Error in get_closest_mesh_point: {e}") | |
| return None | |
| def match_mesh(self): | |
| sel = cmds.ls(sl=True, o=True) | |
| if self.use_stored_target and self.stored_target: | |
| if not sel: | |
| cmds.warning("Select at least one source mesh.") | |
| print("Match Mesh aborted: No source meshes selected.") | |
| self.history_label.setText("Match Mesh failed: No source meshes.") | |
| return | |
| target = self.stored_target | |
| sources = sel | |
| else: | |
| if len(sel) < 2: | |
| cmds.warning("Select at least two meshes: target first, then source(s).") | |
| print("Match Mesh aborted: Need at least two selected meshes.") | |
| self.history_label.setText("Match Mesh failed: Need two meshes.") | |
| return | |
| target = sel[0] | |
| sources = sel[1:] | |
| shapes = cmds.listRelatives(target, shapes=True, fullPath=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "mesh": | |
| cmds.warning("Target must be a polygonal mesh.") | |
| print("Match Mesh aborted: Target is not a polygonal mesh.") | |
| self.history_label.setText("Match Mesh failed: Target not a mesh.") | |
| return | |
| try: | |
| cmds.undoInfo(openChunk=True, chunkName="matchMesh") | |
| space = om.MSpace.kWorld if self.space_dropdown.currentText() == "World Space" else om.MSpace.kObject | |
| sel_list = om.MSelectionList() | |
| sel_list.add(target) | |
| target_dag = sel_list.getDagPath(0) | |
| target_mfn = om.MFnMesh(target_dag) | |
| target_points = target_mfn.getPoints(space) | |
| target_vertex_count = len(target_points) | |
| print(f"Target mesh: {target} with {target_vertex_count} vertices") | |
| matched_meshes = [] | |
| for source in sources: | |
| shapes = cmds.listRelatives(source, shapes=True, fullPath=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "mesh": | |
| cmds.warning(f"Skipping {source}: Not a polygonal mesh.") | |
| print(f"Skipping {source}: Not a polygonal mesh.") | |
| continue | |
| sel_list.clear() | |
| sel_list.add(source) | |
| source_dag = sel_list.getDagPath(0) | |
| source_mfn = om.MFnMesh(source_dag) | |
| source_points = source_mfn.getPoints(space) | |
| source_vertex_count = len(source_points) | |
| if source_vertex_count != target_vertex_count: | |
| cmds.warning(f"Vertex count mismatch for {source}: Source ({source_vertex_count}) vs Target ({target_vertex_count}).") | |
| print(f"Skipping {source}: Vertex count mismatch.") | |
| continue | |
| source_mfn.setPoints(target_points, space) | |
| matched_meshes.append(source) | |
| print(f"Matched source {source} to target {target}") | |
| if not matched_meshes: | |
| cmds.warning("No meshes matched due to validation errors.") | |
| print("Match Mesh aborted: No valid meshes matched.") | |
| self.history_label.setText("Match Mesh failed: No valid meshes.") | |
| return | |
| cmds.select(matched_meshes, r=True) | |
| cmds.warning(f"Matched {len(matched_meshes)} mesh(es) to target {target}.") | |
| self.history_label.setText(f"Matched {len(matched_meshes)} mesh(es) to {target}.") | |
| print(f"Match Mesh complete: {len(matched_meshes)} meshes matched.") | |
| except Exception as e: | |
| cmds.warning(f"Error matching meshes: {str(e)}") | |
| print(f"Error in match_mesh: {e}") | |
| self.history_label.setText("Match Mesh failed: Error occurred.") | |
| finally: | |
| cmds.undoInfo(closeChunk=True) | |
| def cv_project(self): | |
| selection = cmds.ls(sl=True, fl=True) | |
| vertices = [s for s in selection if ".vtx[" in s or ".cv[" in s] | |
| targets = [s for s in selection if ".vtx[" not in s and ".cv[" not in s] | |
| if not vertices or not targets: | |
| cmds.warning("Select vertices or CVs and a target (mesh, surface, or curve).") | |
| print("CV Project aborted: Invalid selection.") | |
| self.history_label.setText("CV Project failed: Invalid selection.") | |
| return | |
| if len(targets) > 1: | |
| cmds.warning("Multiple targets selected. Using the first one.") | |
| print("CV Project: Multiple targets detected, using first target.") | |
| targets = [targets[0]] | |
| target = targets[0] | |
| shapes = cmds.listRelatives(target, shapes=True, fullPath=True) | |
| if not shapes: | |
| cmds.warning("Target has no valid shape.") | |
| print("CV Project aborted: No valid target shape.") | |
| self.history_label.setText("CV Project failed: No target shape.") | |
| return | |
| target_type = cmds.objectType(shapes[0]) | |
| is_curve = target_type == "nurbsCurve" | |
| is_surface = target_type in ["nurbsSurface", "nurbsCurve"] | |
| is_mesh = target_type == "mesh" | |
| try: | |
| cmds.undoInfo(openChunk=True, chunkName="cvProject") | |
| tolerance = self.tolerance_value | |
| projected_count = 0 | |
| for vertex in vertices: | |
| source_pos = cmds.pointPosition(vertex, world=True) | |
| closest_pos = None | |
| if is_mesh: | |
| closest_pos = self.get_closest_mesh_point(source_pos, target, tolerance) | |
| elif is_surface or is_curve: | |
| closest_pos = self.get_closest_surface_point(source_pos, target, tolerance, is_curve) | |
| else: | |
| cmds.warning(f"Unsupported target type for {target}: {target_type}") | |
| print(f"Skipping {vertex}: Unsupported target type.") | |
| continue | |
| if closest_pos: | |
| if self.offset_mode == "offsetMode0" and self.offset_value != 0.0: | |
| normal = self.get_surface_normal(target, closest_pos) if is_surface else [0, 0, 1] | |
| closest_pos = [ | |
| closest_pos[0] + normal[0] * self.offset_value, | |
| closest_pos[1] + normal[1] * self.offset_value, | |
| closest_pos[2] + normal[2] * self.offset_value | |
| ] | |
| elif self.offset_mode == "offsetMode1" and is_curve: | |
| closest_pos = self.adjust_curve_position(target, closest_pos, self.offset_value) | |
| cmds.xform(vertex, t=closest_pos, ws=True) | |
| projected_count += 1 | |
| print(f"Projected {vertex} to {closest_pos}") | |
| else: | |
| print(f"Skipping {vertex}: No valid projection point within tolerance.") | |
| cmds.select(vertices, r=True) | |
| cmds.warning(f"CV Project complete! Projected {projected_count}/{len(vertices)} vertices.") | |
| self.history_label.setText(f"Projected {projected_count}/{len(vertices)} vertices.") | |
| print(f"CV Project complete: {projected_count}/{len(vertices)} vertices projected.") | |
| except Exception as e: | |
| cmds.warning(f"Error projecting vertices: {str(e)}") | |
| print(f"Error in cv_project: {e}") | |
| self.history_label.setText("CV Project failed: Error occurred.") | |
| finally: | |
| cmds.undoInfo(closeChunk=True) | |
| def get_surface_normal(self, target, point): | |
| try: | |
| closest_point_node = cmds.createNode("closestPointOnSurface", n="tempClosestPoint_#") | |
| shapes = cmds.listRelatives(target, shapes=True, fullPath=True) | |
| cmds.connectAttr(f"{shapes[0]}.worldSpace[0]", f"{closest_point_node}.inputSurface") | |
| cmds.setAttr(f"{closest_point_node}.inPosition", *point) | |
| normal = cmds.getAttr(f"{closest_point_node}.normal")[0] | |
| cmds.delete(closest_point_node) | |
| return self.normalize_vector(normal) | |
| except: | |
| return [0, 0, 1] | |
| def adjust_curve_position(self, curve, point, percentage): | |
| try: | |
| closest_point_node = cmds.createNode("pointOnCurveInfo", n="tempPointOnCurve_#") | |
| shapes = cmds.listRelatives(curve, shapes=True, fullPath=True) | |
| cmds.connectAttr(f"{shapes[0]}.worldSpace[0]", f"{closest_point_node}.inputCurve") | |
| cmds.setAttr(f"{closest_point_node}.position", *point) | |
| param = cmds.getAttr(f"{closest_point_node}.parameter") | |
| param += percentage | |
| cmds.setAttr(f"{closest_point_node}.parameter", param) | |
| new_pos = cmds.getAttr(f"{closest_point_node}.position")[0] | |
| cmds.delete(closest_point_node) | |
| return new_pos | |
| except: | |
| return point | |
| def check_symmetry(self): | |
| sel = cmds.ls(sl=True, o=True) | |
| if not sel: | |
| cmds.warning("Select at least one polygonal mesh.") | |
| print("Check Symmetry aborted: No meshes selected.") | |
| self.history_label.setText("Check Symmetry failed: No meshes selected.") | |
| return | |
| try: | |
| cmds.undoInfo(openChunk=True, chunkName="checkSymmetry") | |
| tolerance = self.symmetry_tolerance_value | |
| axis = self.symmetry_axis_dropdown.currentText().lower() | |
| axis_index = {"x": 0, "y": 1, "z": 2}[axis] | |
| print(f"Checking symmetry across {axis}-axis with tolerance {tolerance}") | |
| # Estimate operation time based on total vertex count | |
| total_vertices = sum(len(cmds.ls(f"{m}.vtx[*]", fl=True) or []) for m in sel) | |
| use_progress = total_vertices > 10000 # Rough threshold for >1s operation | |
| progress = None | |
| if use_progress: | |
| progress = QtWidgets.QProgressDialog("Checking symmetry...", "Cancel", 0, len(sel), self) | |
| progress.setWindowModality(QtCore.Qt.WindowModal) | |
| progress.setMinimumDuration(0) | |
| progress.setValue(0) | |
| QtWidgets.QApplication.processEvents() | |
| asymmetric_vertices = [] | |
| total_asymmetric = 0 | |
| centerline_tolerance = 1e-6 | |
| for idx, mesh in enumerate(sel): | |
| if use_progress: | |
| progress.setValue(idx) | |
| if progress.wasCanceled(): | |
| cmds.warning("Symmetry check canceled by user.") | |
| self.history_label.setText("Check Symmetry canceled.") | |
| return | |
| QtWidgets.QApplication.processEvents() | |
| shapes = cmds.listRelatives(mesh, shapes=True, fullPath=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "mesh": | |
| cmds.warning(f"Skipping {mesh}: Not a polygonal mesh.") | |
| print(f"Skipping {mesh}: Not a polygonal mesh.") | |
| continue | |
| sel_list = om.MSelectionList() | |
| sel_list.add(mesh) | |
| dag_path = sel_list.getDagPath(0) | |
| mesh_fn = om.MFnMesh(dag_path) | |
| points = mesh_fn.getPoints(om.MSpace.kObject) | |
| vertex_count = len(points) | |
| mesh_asymmetric = [] | |
| for i in range(vertex_count): | |
| pos = points[i] | |
| if abs(pos[axis_index]) < centerline_tolerance: | |
| print(f"Skipping vertex {i} in {mesh}: On centerline (pos[{axis}]={pos[axis_index]})") | |
| continue | |
| mirror_pos = [pos[0], pos[1], pos[2]] | |
| mirror_pos[axis_index] = -mirror_pos[axis_index] | |
| mirror_point = om.MPoint(*mirror_pos) | |
| found_match = False | |
| for j in range(vertex_count): | |
| if i == j: | |
| continue | |
| other_pos = points[j] | |
| if (mirror_point - other_pos).length() < tolerance: | |
| found_match = True | |
| break | |
| if not found_match: | |
| mesh_asymmetric.append(f"{mesh}.vtx[{i}]") | |
| print(f"Vertex {i} in {mesh} at {pos} has no symmetric match.") | |
| if mesh_asymmetric: | |
| asymmetric_vertices.extend(mesh_asymmetric) | |
| total_asymmetric += len(mesh_asymmetric) | |
| print(f"Mesh {mesh}: Found {len(mesh_asymmetric)} asymmetric vertices") | |
| else: | |
| print(f"Mesh {mesh}: Symmetric within tolerance") | |
| if use_progress: | |
| progress.setValue(len(sel)) | |
| progress.deleteLater() | |
| if asymmetric_vertices: | |
| cmds.select(asymmetric_vertices, r=True) | |
| cmds.warning(f"Found {total_asymmetric} asymmetric vertices across {len(sel)} mesh(es).") | |
| self.history_label.setText(f"Found {total_asymmetric} asymmetric vertices.") | |
| print(f"Total asymmetric vertices: {total_asymmetric}") | |
| else: | |
| cmds.select(clear=True) | |
| cmds.warning("All meshes are symmetric within tolerance.") | |
| self.history_label.setText("All meshes symmetric.") | |
| print("All meshes symmetric within tolerance.") | |
| except Exception as e: | |
| cmds.warning(f"Error checking symmetry: {str(e)}") | |
| print(f"Error in check_symmetry: {e}") | |
| self.history_label.setText("Check Symmetry failed: Error occurred.") | |
| finally: | |
| cmds.undoInfo(closeChunk=True) | |
| def compare_verts(self): | |
| if self.use_conform_list: | |
| selected_items = self.stored_mesh_list_widget.selectedItems() | |
| if not selected_items: | |
| cmds.warning("Select at least one mesh from the Conform Mesh List.") | |
| print("Compare Verts aborted: No mesh selected from list.") | |
| self.history_label.setText("Compare Verts failed: No list mesh selected.") | |
| return | |
| reference_item = selected_items[0] | |
| reference_name = reference_item.text() | |
| if reference_name.startswith("["): | |
| reference_name = reference_name.split("]", 1)[-1] | |
| sel = cmds.ls(sl=True, o=True) | |
| if not sel: | |
| cmds.warning("Select at least one additional mesh to compare.") | |
| print("Compare Verts aborted: No additional meshes selected.") | |
| self.history_label.setText("Compare Verts failed: No additional meshes.") | |
| return | |
| meshes = [reference_name] + sel | |
| else: | |
| meshes = cmds.ls(sl=True, o=True) | |
| if len(meshes) < 2: | |
| cmds.warning("Select at least two polygonal meshes to compare vertices.") | |
| print("Compare Verts aborted: Need at least two selected meshes.") | |
| self.history_label.setText("Compare Verts failed: Need two meshes.") | |
| return | |
| try: | |
| cmds.undoInfo(openChunk=True, chunkName="compareVerts") | |
| tolerance = self.symmetry_tolerance_value | |
| space = om.MSpace.kObject if self.space_dropdown.currentText() == "Local Space" else om.MSpace.kWorld | |
| print(f"Comparing vertices in {'local' if space == om.MSpace.kObject else 'world'} space with tolerance {tolerance}") | |
| # Collect vertex data for all meshes | |
| mesh_data = [] | |
| total_vertices = 0 | |
| for mesh in meshes: | |
| if not cmds.objExists(mesh): | |
| cmds.warning(f"Skipping {mesh}: Mesh does not exist.") | |
| print(f"Skipping {mesh}: Mesh does not exist.") | |
| continue | |
| shapes = cmds.listRelatives(mesh, shapes=True, fullPath=True) | |
| if not shapes or cmds.objectType(shapes[0]) != "mesh": | |
| cmds.warning(f"Skipping {mesh}: Not a polygonal mesh.") | |
| print(f"Skipping {mesh}: Not a polygonal mesh.") | |
| continue | |
| sel_list = om.MSelectionList() | |
| sel_list.add(mesh) | |
| dag_path = sel_list.getDagPath(0) | |
| mesh_fn = om.MFnMesh(dag_path) | |
| points = mesh_fn.getPoints(space) | |
| vertex_count = len(points) | |
| total_vertices += vertex_count | |
| mesh_data.append({"name": mesh, "points": points, "vertex_count": vertex_count}) | |
| if len(mesh_data) < 2: | |
| cmds.warning("Not enough valid meshes to compare.") | |
| print("Compare Verts aborted: Insufficient valid meshes.") | |
| self.history_label.setText("Compare Verts failed: Insufficient meshes.") | |
| return | |
| # Find reference vertex count | |
| vertex_counts = [data["vertex_count"] for data in mesh_data] | |
| if len(set(vertex_counts)) > 1: | |
| cmds.warning("Vertex count mismatch between meshes.") | |
| print("Compare Verts aborted: Vertex count mismatch.") | |
| self.history_label.setText("Compare Verts failed: Vertex count mismatch.") | |
| return | |
| vertex_count = vertex_counts[0] | |
| # Estimate operation time | |
| use_progress = total_vertices > 10000 # Rough threshold for >1s operation | |
| progress = None | |
| if use_progress: | |
| progress = QtWidgets.QProgressDialog("Comparing vertices...", "Cancel", 0, vertex_count, self) | |
| progress.setWindowModality(QtCore.Qt.WindowModal) | |
| progress.setMinimumDuration(0) | |
| progress.setValue(0) | |
| QtWidgets.QApplication.processEvents() | |
| # Compare vertices across meshes | |
| differing_vertices = [] | |
| reference_pos = mesh_data[0]["points"] | |
| for i in range(vertex_count): | |
| if use_progress: | |
| progress.setValue(i) | |
| if progress.wasCanceled(): | |
| cmds.warning("Vertex comparison canceled by user.") | |
| self.history_label.setText("Compare Verts canceled.") | |
| return | |
| QtWidgets.QApplication.processEvents() | |
| ref_pos = reference_pos[i] | |
| for j in range(1, len(mesh_data)): | |
| other_pos = mesh_data[j]["points"][i] | |
| if (ref_pos - other_pos).length() > tolerance: | |
| differing_vertices.append(f"{mesh_data[j]['name']}.vtx[{i}]") | |
| if use_progress: | |
| progress.setValue(vertex_count) | |
| progress.deleteLater() | |
| if differing_vertices: | |
| cmds.select(differing_vertices, r=True) | |
| cmds.warning(f"Found {len(differing_vertices)} vertices differing by more than {tolerance} units.") | |
| self.history_label.setText(f"{'Compared using list: ' if self.use_conform_list else ''}Found {len(differing_vertices)} differing vertices.") | |
| print(f"Differing vertices: {differing_vertices}") | |
| else: | |
| cmds.select(clear=True) | |
| cmds.warning("All vertices match within tolerance.") | |
| self.history_label.setText(f"{'Compared using list: ' if self.use_conform_list else ''}All vertices match.") | |
| print("All vertices match within tolerance.") | |
| except Exception as e: | |
| cmds.warning(f"Error comparing vertices: {str(e)}") | |
| print(f"Error in compare_verts: {e}") | |
| self.history_label.setText("Compare Verts failed: Error occurred.") | |
| finally: | |
| cmds.undoInfo(closeChunk=True) | |
| def show_vvvertsnap(): | |
| try: | |
| if cmds.window("vertsnappyWin", exists=True): | |
| cmds.deleteUI("vertsnappyWin", window=True) | |
| print("Creating Vtx Snappy VV window...") | |
| window = VVvertsnap() | |
| window.setObjectName("vertsnappyWin") | |
| window.show() | |
| print("Vtx Snappy VV window shown.") | |
| except Exception as e: | |
| cmds.warning(f"Failed to launch Vtx Snappy VV: {e}") | |
| print(f"Error launching Vtx Snappy VV: {e}") | |
| if __name__ == "__main__": | |
| print("Starting Vtx Snappy VV 1.4...") | |
| show_vvvertsnap() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment