#!/usr/bin/env python # -*- coding: utf-8 -*- # @Site : Virtuos Games # @Author : Cai Jianbo """ Maya Batch Extrusion Shell Mesh Tool Features: 1. Copy selected model to a specified number of layers 2. Extrude each layer, automatically deleting original layers to prevent overlap 3. Set vertex colors increasing from the inside out 4. Support model merging """ import maya.cmds as cmds import maya.mel as mel # UI Style configurations MESSAGE_BUTTON_STYLE = """ QPushButton { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #D97706, stop:1 #B45309); color: white; border: 1px solid #92400E; border-radius: 8px; padding: 10px 16px; font-weight: 600; font-size: 12px; min-width: 100px; } QPushButton:hover { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #F59E0B, stop:1 #D97706); border-color: #D97706; } QPushButton:pressed { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #B45309, stop:1 #92400E); border-color: #78350F; } """ SUCCESS_BUTTON_STYLE = """ QPushButton { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #3B82F6, stop:1 #2563EB); color: white; border: none; border-radius: 10px; padding: 12px 20px; font-weight: 600; font-size: 13px; min-width: 110px; } QPushButton:hover { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #60A5FA, stop:1 #3B82F6); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); } QPushButton:pressed { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #2563EB, stop:1 #1D4ED8); transform: translateY(0px); } """ WARNING_BUTTON_STYLE = """ QPushButton { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #F59E0B, stop:1 #D97706); color: white; border: none; border-radius: 10px; padding: 12px 20px; font-weight: 600; font-size: 13px; min-width: 110px; } QPushButton:hover { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #FBBF24, stop:1 #F59E0B); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3); } QPushButton:pressed { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #D97706, stop:1 #B45309); transform: translateY(0px); } """ class BatchExtrusion: def __init__(self): self.window_name = "BatchExtrusionWindow" self.layers = 7 # Default number of layers self.thickness = 0.1 # Default thickness self.min_color = [0.0, 0.0, 0.0] # Inner layer color (black) self.max_color = [1.0, 1.0, 1.0] # Outer layer color (white) self.merge_layers = False # Whether to merge all layers self.original_objects = [] # Original object list self.generated_objects = [] # Generated object list self.is_updating = False # Prevent recursive updates self.button_info = [] # Store button information for delayed style application self.qt_available = self.check_qt_availability() # Check Qt availability def check_qt_availability(self): """Check Qt and related module availability""" try: maya_version = int(cmds.about(version=True)) print(f"Maya version: {maya_version}") # Try PySide6 first (Maya 2022+) try: from PySide6 import QtWidgets, QtCore import shiboken6 as shiboken print("Using PySide6 (Maya 2022+)") return "PySide6" except ImportError: try: from PySide2 import QtWidgets, QtCore import shiboken2 as shiboken print("Using PySide2 (Maya 2020-2021)") return "PySide2" except ImportError: print("PySide module is not available, using Maya native style") return None except Exception as e: print(f"Qt availability check failed: {str(e)}") return None def create_ui(self): """Create user interface""" # If the window already exists, delete it if cmds.window(self.window_name, exists=True): cmds.deleteUI(self.window_name) # Reset window preferences to avoid obscuring buttons due to old window dimensions if cmds.windowPref(self.window_name, exists=True): cmds.windowPref(self.window_name, remove=True) # Create window cmds.window(self.window_name, title="Batch Extrusion Shell Mesh", widthHeight=(420, 600), sizeable=True) # Create main layout (using scroll layout to prevent small window obscuring bottom buttons) scroll = cmds.scrollLayout(horizontalScrollBarThickness=0, verticalScrollBarThickness=12) main_layout = cmds.columnLayout(adjustableColumn=True, rowSpacing=10, columnOffset=('both', 10)) # Title cmds.text(label="Batch Extrusion Shell Mesh", font="boldLabelFont", height=30) cmds.separator(height=10) # Number of layers control cmds.text(label="Layers:", align="left", font="boldLabelFont") self.layers_slider = cmds.intSliderGrp( label="Layers: ", field=True, minValue=1, maxValue=20, value=self.layers, step=1, changeCommand=self.on_layers_changed ) # Thickness control cmds.text(label="Thickness:", align="left", font="boldLabelFont") self.thickness_slider = cmds.floatSliderGrp( label="Thickness: ", field=True, minValue=0.000001, maxValue=10.0, value=self.thickness, precision=4, step=0.01, changeCommand=self.on_thickness_changed ) # Vertex color control cmds.text(label="Vertex Color:", align="left", font="boldLabelFont") self.min_color_field = cmds.colorSliderGrp( label="Inner layer: ", rgb=self.min_color, changeCommand=self.on_color_changed ) self.max_color_field = cmds.colorSliderGrp( label="Outer layer: ", rgb=self.max_color, changeCommand=self.on_color_changed ) # Merge options cmds.text(label="Merge:", align="left", font="boldLabelFont") self.merge_checkbox = cmds.checkBox( label="Merge All Extruded Layers", value=self.merge_layers, changeCommand=self.on_merge_changed ) # Preview area cmds.text(label="Vertex Color Setting Preview:", align="left", font="boldLabelFont") self.preview_text = cmds.scrollField( editable=False, wordWrap=True, height=100, backgroundColor=[0.2, 0.2, 0.2] ) cmds.separator(height=10) # Operation buttons self.create_styled_button("Extrude Shell Mesh", self.select_base_objects, SUCCESS_BUTTON_STYLE) self.create_styled_button("Clear All Extruded Layers", self.clear_all_layers, WARNING_BUTTON_STYLE) # Delay style application to ensure Qt controls are fully created cmds.evalDeferred(self.apply_delayed_styles) # Show window cmds.showWindow(self.window_name) # Initial preview self.preview_colors() def create_styled_button(self, label, command, style): """Create styled button""" # Set base color based on style type base_color = [0.4, 0.4, 0.4] # Default gray if "SUCCESS" in style: base_color = [0.2, 0.5, 0.8] # Blue elif "WARNING" in style: base_color = [0.8, 0.6, 0.2] # Orange elif "MESSAGE" in style: base_color = [0.7, 0.4, 0.1] # Deep orange # Create Maya button, set base color first button_name = cmds.button( label=label, command=command, height=35, backgroundColor=base_color ) # Store button info for delayed style application self.button_info.append({ 'name': button_name, 'label': label, 'style': style }) # Immediately try to apply Qt style self.apply_style_to_button(button_name, label, style) return button_name def apply_style_to_button(self, button_name, label, style): """Apply style to a single button""" # If Qt is not available, return False if not self.qt_available: print(f"Qt is not available, skipping style application: {label}") return False try: # Import corresponding modules based on detected Qt version if self.qt_available == "PySide6": from PySide6 import QtWidgets, QtCore import shiboken6 as shiboken elif self.qt_available == "PySide2": from PySide2 import QtWidgets, QtCore import shiboken2 as shiboken else: return False import maya.OpenMayaUI as omui # Get the Qt object of the button button_ptr = omui.MQtUtil.findControl(button_name) if button_ptr: qt_button = shiboken.wrapInstance(int(button_ptr), QtWidgets.QPushButton) if qt_button: qt_button.setStyleSheet(style) print(f"✓ Successfully applied Qt style: {label}") return True else: print(f"✗ Failed to get Qt button object: {label}") else: print(f"✗ Failed to find button control: {label}") except Exception as e: print(f"✗ Qt style application failed ({label}): {str(e)}") return False def apply_delayed_styles(self): """Delay style application to all buttons""" print("=== Starting delayed style application ===") success_count = 0 for button_info in self.button_info: if cmds.control(button_info['name'], exists=True): if self.apply_style_to_button( button_info['name'], button_info['label'], button_info['style'] ): success_count += 1 else: print(f"✗ Button control does not exist: {button_info['label']}") print(f"=== Style application completed: {success_count}/{len(self.button_info)} successful ===") def show_dialog(self, title, message): """Show styled confirmation dialog""" dialog_name = "StyledConfirmDialog" # If the dialog already exists, delete it if cmds.window(dialog_name, exists=True): cmds.deleteUI(dialog_name) # Create dialog window cmds.window(dialog_name, title=title, widthHeight=(300, 120), sizeable=False) # Main layout main_layout = cmds.columnLayout(adjustableColumn=True, rowSpacing=10, columnOffset=('both', 15)) # Message text cmds.text(label=message, align="center", wordWrap=True, height=40) cmds.separator(height=5) # Confirm button self.create_styled_button("Yes", lambda *args: self.close_dialog(dialog_name), MESSAGE_BUTTON_STYLE) # Show dialog cmds.showWindow(dialog_name) def close_dialog(self, dialog_name): """Close dialog""" if cmds.window(dialog_name, exists=True): cmds.deleteUI(dialog_name) def calculate_vertex_colors(self, total_layers): """Calculate vertex colors for each layer""" if total_layers <= 1: return [[1.0, 1.0, 1.0]] colors = [] for i in range(total_layers): # Calculate interpolation factor (0.0 to 1.0) factor = i / (total_layers - 1) if total_layers > 1 else 0.0 # Interpolate between minimum and maximum colors r = self.min_color[0] + (self.max_color[0] - self.min_color[0]) * factor g = self.min_color[1] + (self.max_color[1] - self.min_color[1]) * factor b = self.min_color[2] + (self.max_color[2] - self.min_color[2]) * factor colors.append([r, g, b]) return colors def on_layers_changed(self, *args): """Callback function when the number of layers changes - Real-time update""" if self.is_updating: return self.layers = cmds.intSliderGrp(self.layers_slider, query=True, value=True) self.preview_colors() # Update logic if self.original_objects: # If there are base models if self.merge_layers: print("The models have been merged, please manually clean up and re-extrude!") else: print(f"Number of layers updated to {self.layers}, regenerating shell layers...") # Clean up existing layers self.clear_generated_objects() # Regenerate layers self.generate_layers() def on_thickness_changed(self, *args): """Callback function when thickness changes - Real-time update""" if self.is_updating: return self.thickness = cmds.floatSliderGrp(self.thickness_slider, query=True, value=True) self.preview_colors() # Update logic if self.original_objects and self.generated_objects: # If there are base models and generated layers if self.merge_layers: print("The models have been merged, please manually clean up and re-extrude!") else: print(f"Thickness updated to {self.thickness}, updating thickness for all layers...") self.update_thickness() def on_merge_changed(self, *args): """Callback function when merge option changes""" if self.is_updating: return self.merge_layers = cmds.checkBox(self.merge_checkbox, query=True, value=True) self.preview_colors() def on_color_changed(self, *args): """Callback function when colors change - Real-time update""" if self.is_updating: return self.min_color = cmds.colorSliderGrp(self.min_color_field, query=True, rgb=True) self.max_color = cmds.colorSliderGrp(self.max_color_field, query=True, rgb=True) self.preview_colors() # Update logic if self.original_objects and self.generated_objects: # If there are base models and generated layers if self.merge_layers: print("The models have been merged, please manually clean up and re-extrude!") else: print("Colors updated, updating vertex colors for all layers...") self.update_colors_for_all_layers() def preview_colors(self, *args): """Preview color distribution""" layers = self.layers total_layers = layers + 1 # Include original layer colors = self.calculate_vertex_colors(total_layers) preview_text = f"Colors preview ({total_layers} layers):\n\n" for i, color in enumerate(colors): r, g, b = color if i == 0: layer_name = "Original layer" else: layer_name = f"Layer {i}" preview_text += f"{layer_name}: RGB({r:.2f}, {g:.2f}, {b:.2f})\n" cmds.scrollField(self.preview_text, edit=True, text=preview_text) def select_base_objects(self, *args): """Select base objects and start extrusion""" selected = cmds.ls(selection=True, type='transform') if not selected: cmds.warning("Please select the models to process first") return # Clean up existing layers self.clear_generated_objects() # Save original objects self.original_objects = selected[:] # Generate layers immediately self.generate_layers() self.show_dialog("Completed", f"Processed {len(selected)} objects, generated {self.layers} shell layers") def clear_all_layers(self, *args): """Clear all generated layers""" # Check if merge option is checked if self.merge_layers: print("The models have been merged, please manually clean up and re-extrude!") return self.clear_generated_objects() self.show_dialog("Completed", "All generated layers have been cleared") def clear_generated_objects(self): """Clear generated objects""" for obj in self.generated_objects: if cmds.objExists(obj): cmds.delete(obj) self.generated_objects = [] def generate_layers(self): """Generate shell layers""" if not self.original_objects: print("Error: No original objects selected") return try: print(f"=== Starting generation of {self.layers} shell layers ===") created_objects = [] for obj_index, original_obj in enumerate(self.original_objects): print(f"Processing object {obj_index + 1}: {original_obj}") # Collect all layer objects (including original object) all_layers = [original_obj] # Create layers one by one (always from the original object) for layer_num in range(1, self.layers + 1): print(f" Creating layer {layer_num}...") # Key: Always copy from the original object, not from the previous layer new_layer = self.extrude_layer_correct(original_obj, layer_num, self.thickness) if new_layer: all_layers.append(new_layer) created_objects.append(new_layer) print(f" ✓ Layer {layer_num} completed") else: print(f" ✗ Layer {layer_num} failed, stopping") break # Set vertex colors print(f" Setting vertex colors for {len(all_layers)} layers...") self.apply_colors(all_layers) # Save results self.generated_objects = created_objects print(f"=== Completed! Created {len(created_objects)} layers ===") # Check if merge option is checked if self.merge_layers and len(created_objects) > 0: print("=== Starting simple merge ===") self.simple_merge_objects() else: print("=== Skipping merge (not checked or no objects) ===") except Exception as e: print(f"Generation failed: {str(e)}") # Cleanup for obj in created_objects: if cmds.objExists(obj): try: cmds.delete(obj) except: pass def simple_merge_objects(self): """Simple merge method to avoid complex operations""" try: all_objects = self.original_objects + self.generated_objects print(f"Simple merge {len(all_objects)} objects") if len(all_objects) > 1: # Only do basic merge existing_objects = [obj for obj in all_objects if cmds.objExists(obj)] if len(existing_objects) > 1: cmds.select(existing_objects, replace=True) merged = cmds.polyUnite(existing_objects, name="BatchExtruded_Simple")[0] print(f"Simple merge completed: {merged}") # Update object list self.original_objects = [merged] self.generated_objects = [] else: print("Not enough objects to merge") else: print("Not enough objects to merge") except Exception as e: print(f"Simple merge failed: {str(e)}") def extrude_layer_correct(self, obj, layer_index, thickness): """Follow the correct Maya workflow: duplicate -> extrude -> delete by inverse selection""" duplicated = None try: print(f" === Correct workflow for layer {layer_index} ===") # 1. Verify object if not cmds.objExists(obj): print(f" Error: Object {obj} does not exist") return None # 2. Copy model duplicated = cmds.duplicate(obj, name=f"Layer_{layer_index}")[0] print(f" Copy completed: {duplicated}") # 3. Switch to face mode and select all faces print(f" Switching to face mode and selecting all faces...") # Switch to component mode cmds.selectMode(component=True) cmds.selectType(facet=True) # Select all faces directly (more reliable method) try: cmds.select(f"{duplicated}.f[*]", replace=True) selected_faces = cmds.ls(selection=True, flatten=True) print(f" Selected faces count: {len(selected_faces)}") if len(selected_faces) == 0: # Backup method 1: Select object first then convert print(f" Direct selection failed, trying conversion method...") cmds.selectMode(object=True) cmds.select(duplicated, replace=True) cmds.selectMode(component=True) cmds.selectType(facet=True) # Use mel command to convert try: mel.eval("ConvertSelectionToFaces;") selected_faces = cmds.ls(selection=True, flatten=True) print(f" Selected faces count: {len(selected_faces)}") except Exception as mel_error: print(f" MEL conversion failed: {str(mel_error)}") # Backup method 2: Use polyEvaluate to get face count, then select try: face_count = cmds.polyEvaluate(duplicated, face=True) if face_count > 0: face_range = f"{duplicated}.f[0:{face_count-1}]" cmds.select(face_range, replace=True) selected_faces = cmds.ls(selection=True, flatten=True) print(f" Selected faces count: {len(selected_faces)}") except Exception as backup_error: print(f" Backup method also failed: {str(backup_error)}") except Exception as select_error: print(f" Face selection failed: {str(select_error)}") raise select_error # Verify face selection success if len(selected_faces) == 0: print(f" Error: No faces selected") return None print(f" Selected {len(selected_faces)} faces, starting extrusion...") # Execute extrusion (thickness=0) extrude_result = cmds.polyExtrudeFacet( constructionHistory=True, keepFacesTogether=True, divisions=1, twist=0, taper=1, off=0, thickness=0, smoothingAngle=30 ) print(f" Extrusion command completed: {extrude_result}") # Set cumulative thickness (each layer's thickness is cumulative) if extrude_result: extrude_node = extrude_result[0] # Cumulative thickness = base thickness * layer number cumulative_thickness = thickness * layer_index cmds.setAttr(f"{extrude_node}.thickness", cumulative_thickness) print(f" Set cumulative thickness: {cumulative_thickness} (Layer {layer_index})") # 4. Invert selection and delete internal faces print(f" Inverting selection and deleting internal faces...") # After extrusion, the original faces are still selected # We need to keep these original faces and delete the newly generated faces original_selected_faces = cmds.ls(selection=True, flatten=True) print(f" Original selected faces count: {len(original_selected_faces)}") # Get all faces after extrusion all_faces_after_extrude = cmds.ls(f"{duplicated}.f[*]", flatten=True) print(f" Total faces after extrusion: {len(all_faces_after_extrude)}") # Calculate newly generated faces (faces to delete) if len(all_faces_after_extrude) > len(original_selected_faces): # New faces = All faces - Original faces new_faces = [face for face in all_faces_after_extrude if face not in original_selected_faces] if new_faces: print(f" Deleting {len(new_faces)} newly generated faces") cmds.select(new_faces, replace=True) cmds.delete() print(f" Deleted successfully, keeping original faces as shell") else: print(f" No newly generated faces found") else: print(f" No faces added, skipping deletion") # 5. Return to object mode cmds.selectMode(object=True) cmds.select(clear=True) print(f" === Layer {layer_index} completed ===") return duplicated except Exception as e: print(f" Layer {layer_index} failed: {str(e)}") if duplicated and cmds.objExists(duplicated): try: cmds.delete(duplicated) except: pass return None def apply_colors(self, layer_objects): """Simple vertex color application""" try: total_layers = len(layer_objects) colors = self.calculate_vertex_colors(total_layers) print(f" Setting colors for {total_layers} layers...") for i, obj in enumerate(layer_objects): if cmds.objExists(obj) and i < len(colors): color = colors[i] print(f" Layer {i}: {obj} -> RGB{color}") # Set vertex colors try: # Select all vertices cmds.select(f"{obj}.vtx[*]", replace=True) # Apply color cmds.polyColorPerVertex(rgb=color, colorDisplayOption=True) # Enable vertex color display cmds.setAttr(f"{obj}.displayColors", 1) except Exception as color_error: print(f" Setting vertex colors failed: {str(color_error)}") # Clear selection cmds.select(clear=True) print(f" Vertex colors set successfully") except Exception as e: print(f" Vertex color application failed: {str(e)}") def update_thickness(self): """Update thickness for all layers""" try: print(f"=== Updating thickness to {self.thickness} ===") for layer_index, obj in enumerate(self.generated_objects, 1): if cmds.objExists(obj): print(f" Updating layer {layer_index}: {obj}") # Find extrude node history = cmds.listHistory(obj, pruneDagObjects=True) extrude_nodes = [node for node in history if cmds.nodeType(node) == 'polyExtrudeFace'] if extrude_nodes: extrude_node = extrude_nodes[0] # Take the latest extrude node # Set cumulative thickness cumulative_thickness = self.thickness * layer_index cmds.setAttr(f"{extrude_node}.thickness", cumulative_thickness) print(f" ✓ Updated thickness: {cumulative_thickness}") else: print(f" ✗ No extrude node found") print(f"=== Thickness updated successfully ===") except Exception as e: print(f"Thickness update failed: {str(e)}") def update_colors_for_all_layers(self): """Update colors for all layers""" try: print(f"=== Updating colors for all layers ===") # Collect all layer objects (including original objects) all_layers = self.original_objects + self.generated_objects # Reapply colors self.apply_colors(all_layers) print(f"=== Colors updated successfully ===") except Exception as e: print(f"Colors update failed: {str(e)}") # Create and display UI function def show_batch_extrusion_ui(): """Show batch extrusion UI""" batch_extrusion = BatchExtrusion() batch_extrusion.create_ui() # If run this script directly if __name__ == "__main__": show_batch_extrusion_ui()