├── .gitignore ├── modules └── vg_pt_utils │ ├── ___init___.py │ ├── vg_project_info.py │ ├── vg_baking.py │ ├── vg_layerstack.py │ └── vg_export.py ├── LICENSE ├── README.md └── plugins └── vg_menu_launcher.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.code-workspace 3 | -------------------------------------------------------------------------------- /modules/vg_pt_utils/___init___.py: -------------------------------------------------------------------------------- 1 | ########################################################################## 2 | # 3 | # Copyright 2010-2024 Vincent GAULT - Adobe 4 | # All Rights Reserved. 5 | # 6 | ########################################################################## 7 | """ 8 | The ``vg_utils`` package is a collection of modules made by Vincent GAULT, 9 | that are meant to give access to functions made for `Substance 3D Painter` 10 | """ 11 | 12 | __author__ = "Vincent GAULT - Adobe" 13 | 14 | import vg_pt_utils -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Vincent Gault 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VG Painter Utilities (vg_pt_utils) 2 | 3 | **VG Painter Utilities (vg_pt_utils)** is a suite of tools and shortcuts designed to add functionalities to Substance 3D Painter. 4 | 5 | Feel free to use, reuse and adapt this content to your needs 6 | (Please read the [LICENSE](LICENSE) file for more information) 7 | 8 | Contact me for any issue of feedback: cgvinny@adobe.com 9 | 10 | ## Installation 11 | Installation and details in this [https://youtu.be/KjRgUkQnXDk ](video) here 12 | 13 | 1. Copy:the 'plugins' & 'modules' folders into the 'python' folder located here by default (please adapt: if your location is different) 14 | 15 | `C:\Users\[USER]\Documents\Adobe\Adobe Substance 3D Painter\python` 16 | 17 | 3. You will have a warning that this folder already exists: you can proceed safely, as it will just add content, or replace the previous version of the script. 18 | 19 | 20 | 21 | 22 | ## Usage 23 | 24 | ***VG Menu Launcher*** 25 | 26 | Once activated, a new "VG Utilities" will be added to the top bar, giving access to the different tools, and shortcuts will be activated. 27 | 28 | ### Activation 29 | In Substance 3D Painter; go to the "Python" top menu and reload the plugins folder: "vg_menu_launcher" should be present 30 | 31 | 32 | ### Features 33 | New Paint layer ( `Ctrl + P` ) 34 | 35 | --- 36 | New Fill layer with Base Color activated ( `Ctrl + F` ) 37 | 38 | New Fill layer with Height activated ( `Ctrl + Alt + F` ) 39 | 40 | New Fill layer, all channels activated ( `Ctrl + Shift + F` ) 41 | 42 | New Fill layer, no channels activated ( `Alt + F` ) 43 | 44 | --- 45 | Add mask to selected layer. If a mask is already present, it will switch it from black to white, or from white to black ( `Ctrl + M` ) 46 | 47 | Add mask with a fill effect to selected layer. If a mask is already present, it will switch it from black to white, or from white to black ( `Shift + M` ) 48 | 49 | Add black mask with AO Generator ( `Ctrl + Shift + M` ) 50 | 51 | Add black mask Curvature Generator ( `Ctrl + alt + M` ) 52 | 53 | --- 54 | Create new layer from what is visible in the stack (so you can delete these layers if you don't need to edit them anymore, thus improving performances.).Note that normal channel is deactivated, to avoid generatin normal information twice with the height map ( `Ctrl + shift + G` ) 55 | 56 | 57 | Create Reference point layer ( `Ctrl + R` ) 58 | 59 | --- 60 | Bake current Texture Set mesh maps ( `Ctrl + B` ) 61 | -------------------------------------------------------------------------------- /modules/vg_pt_utils/vg_project_info.py: -------------------------------------------------------------------------------- 1 | ########################################################################## 2 | # 3 | # Copyright 2010-2024 Vincent GAULT - Adobe 4 | # All Rights Reserved. 5 | # 6 | ########################################################################## 7 | 8 | """ 9 | This module contains classes to extract, gather, and organize relevant 10 | information from Substance 3D Painter Texture Sets. 11 | """ 12 | __author__ = "Vincent GAULT - Adobe" 13 | 14 | # Modules import 15 | from substance_painter import textureset 16 | 17 | # Classes definitions 18 | class TextureSetInfo: 19 | """ 20 | A class to gather and manage information about a specified texture set 21 | in Substance 3D Painter. If no texture set is provided, the currently 22 | active texture set will be used. 23 | """ 24 | 25 | def __init__(self, target_stack=None): 26 | """ 27 | Initialize the TextureSetInfo class. 28 | 29 | Parameters: 30 | target_stack (object): The target texture set stack. If None, 31 | the active texture set stack will be used. 32 | """ 33 | if target_stack is None: 34 | self.current_stack = textureset.get_active_stack() 35 | else: 36 | self.current_stack = target_stack 37 | 38 | # Retrieve the material (texture set) from the stack 39 | self.current_texture_set = self.current_stack.material() 40 | 41 | def get_texture_set_object(self): 42 | """ 43 | Retrieve the texture set object. 44 | 45 | Returns: 46 | object: The texture set object associated with the current stack. 47 | """ 48 | return self.current_texture_set 49 | 50 | def get_texture_set_name(self): 51 | """ 52 | Get the name of the texture set. 53 | 54 | Returns: 55 | str: The name of the texture set. 56 | """ 57 | textureset_name = str(self.current_stack.material()) # Texture Set Name 58 | return textureset_name 59 | 60 | def generate_uv_tiles_coord_list(self): 61 | """ 62 | Generate a list of UV tile coordinates for the current texture set. 63 | 64 | Returns: 65 | list: A list of [u, v] coordinates for each UV tile in the texture set. 66 | """ 67 | uv_tiles_list = self.current_texture_set.all_uv_tiles() 68 | uv_tiles_coordinates_list = [[tile.u, tile.v] for tile in uv_tiles_list] 69 | return uv_tiles_coordinates_list 70 | 71 | def fetch_texture_set_info_from_stack(self): 72 | """ 73 | Fetch and organize relevant information about the texture set, including 74 | its name, channels, and UV tile coordinates. 75 | 76 | Returns: 77 | dict: A dictionary containing texture set information: 78 | - "Texture Set": The texture set object. 79 | - "Name": The name of the texture set. 80 | - "Channels": All channels present in the texture set. 81 | - "UV Tiles coordinates": A list of UV tile coordinates. 82 | """ 83 | target_textureset = self.get_texture_set_object() 84 | texture_set_name = self.get_texture_set_name() 85 | texture_set_channels = self.current_stack.all_channels() 86 | uv_tiles_coordinates_list = self.generate_uv_tiles_coord_list() 87 | 88 | # Generate and return the info dictionary 89 | textureset_info = { 90 | "Texture Set": target_textureset, 91 | "Name": texture_set_name, 92 | "Channels": texture_set_channels, 93 | "UV Tiles coordinates": uv_tiles_coordinates_list, 94 | } 95 | 96 | return textureset_info 97 | -------------------------------------------------------------------------------- /modules/vg_pt_utils/vg_baking.py: -------------------------------------------------------------------------------- 1 | ########################################################################## 2 | # 3 | # Copyright 2010-2024 Vincent GAULT - Adobe 4 | # All Rights Reserved. 5 | # 6 | ########################################################################## 7 | 8 | """ 9 | This module contains different utilities related to mesh maps baking in Substance 3D Painter. 10 | """ 11 | __author__ = "Vincent GAULT - Adobe" 12 | 13 | # Modules import 14 | import math 15 | from substance_painter import baking, textureset, ui, event 16 | from vg_pt_utils import vg_project_info 17 | from substance_painter.baking import BakingParameters 18 | 19 | 20 | class BakingParameterConfigurator: 21 | """ 22 | Responsible for configuring baking parameters based on the texture set information. 23 | """ 24 | 25 | #Build a list of the mesh maps to bake for the baking params 26 | def mesh_maps_to_bake_list(self, id_list: list): 27 | map_id_list = id_list 28 | map_usage_list = [textureset.MeshMapUsage(id) for id in id_list] 29 | return map_usage_list 30 | 31 | 32 | def configure_baking_parameters(self, textureset_name, width, height, mesh_maps_to_bake): 33 | """ 34 | Configure the baking parameters based on the texture set name and resolution. 35 | 36 | Args: 37 | textureset_name (str): The name of the texture set. 38 | width (int): The width of the texture set in log2 format. 39 | height (int): The height of the texture set in log2 format. 40 | 41 | Returns: 42 | BakingParameters: Configured baking parameters. 43 | """ 44 | 45 | baking_params = BakingParameters.from_texture_set_name(textureset_name) 46 | common_params = baking_params.common() 47 | baking_params.set({common_params['OutputSize']: (width, height)}) 48 | map_usage_list = self.mesh_maps_to_bake_list(mesh_maps_to_bake) 49 | 50 | # Activate proper bakers 51 | baking_params.set_enabled_bakers(map_usage_list) 52 | 53 | return baking_params 54 | 55 | 56 | class BakingProcessManager: 57 | """ 58 | Responsible for managing the baking process and handling related events. 59 | """ 60 | 61 | def __init__(self): 62 | self._current_baking_settings = None 63 | 64 | def switch_to_paint_view(self, e): 65 | """ 66 | Event handler for when the baking process ends. Switches to paint view. 67 | """ 68 | print("Switching to paint view...") 69 | paint_mode = ui.UIMode(1) 70 | ui.switch_to_mode(paint_mode) 71 | print("Paint view activated.") 72 | 73 | event.DISPATCHER.disconnect(event.BakingProcessEnded, self.switch_to_paint_view) 74 | 75 | def start_baking(self, current_texture_set): 76 | """ 77 | Starts the baking process and connects the event handler. 78 | 79 | Args: 80 | current_texture_set (object): The current texture set to bake. 81 | """ 82 | # Connect the event to the function to switch to the paint view 83 | event.DISPATCHER.connect_strong(event.BakingProcessEnded, self.switch_to_paint_view) 84 | 85 | # Start baking process 86 | baking.bake_async(current_texture_set) 87 | print("Baking started...") 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | ##################### FUNCTIONS ##################### 96 | 97 | def quick_bake(): 98 | """ 99 | Perform a quick bake using the current texture set information and configured parameters. 100 | """ 101 | # Fetch texture set information 102 | export_manager = vg_project_info.TextureSetInfo() 103 | textureset_info = export_manager.fetch_texture_set_info_from_stack() 104 | textureset_name = textureset_info["Name"] 105 | current_texture_set = textureset_info["Texture Set"] 106 | 107 | # Calculate texture resolution in log2 format 108 | current_resolution = current_texture_set.get_resolution() 109 | width = int(math.log2(current_resolution.width)) 110 | height = int(math.log2(current_resolution.height)) 111 | 112 | # Get the current baking parameters to retrieve user-selected maps 113 | current_baking_params = BakingParameters.from_texture_set_name(textureset_name) 114 | enabled_bakers = current_baking_params.get_enabled_bakers() 115 | 116 | # Configure baking parameters 117 | baking_param_configurator = BakingParameterConfigurator() 118 | baking_params = baking_param_configurator.configure_baking_parameters(textureset_name, width, height, enabled_bakers) 119 | 120 | # Start baking 121 | baking_process_manager = BakingProcessManager() 122 | baking_process_manager.start_baking(current_texture_set) 123 | 124 | 125 | if __name__ == "__main__": 126 | quick_bake() 127 | -------------------------------------------------------------------------------- /plugins/vg_menu_launcher.py: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # This script creates a menu to host different tools for Substance 3D Painter 3 | # ___________________ 4 | # Copyright 2024 Vincent GAULT - Adobe 5 | # All Rights Reserved. 6 | ############################################################################### 7 | 8 | """ 9 | This module creates a menu to host various tools for Substance Painter. 10 | """ 11 | 12 | __author__ = "Vincent GAULT - Adobe" 13 | 14 | # Modules import 15 | from PySide6 import QtWidgets, QtGui 16 | from PySide6.QtGui import QKeySequence 17 | import importlib 18 | 19 | from substance_painter import ui, logging 20 | from vg_pt_utils import vg_baking, vg_export, vg_layerstack, vg_project_info 21 | 22 | plugin_menus_widgets = [] 23 | """Keeps track of added UI elements for cleanup.""" 24 | 25 | ######## FILL LAYER FUNCTIONS ######## 26 | 27 | def new_fill_layer_base(): 28 | """Create a new fill layer with Base Color activated.""" 29 | layer_manager = vg_layerstack.LayerManager() 30 | layer_manager.add_layer(layer_type='fill', active_channels=["BaseColor"], layer_name="New fill layer") 31 | 32 | def new_fill_layer_height(): 33 | """Create a new fill layer with Height channel activated.""" 34 | layer_manager = vg_layerstack.LayerManager() 35 | layer_manager.add_layer(layer_type='fill', active_channels=["Height"], layer_name="New fill layer") 36 | 37 | def new_fill_layer_all(): 38 | """Create a new fill layer with all channels activated.""" 39 | layer_manager = vg_layerstack.LayerManager() 40 | layer_manager.add_layer(layer_type='fill', layer_name="New fill layer") 41 | 42 | 43 | def new_fill_layer_empty(): 44 | """Create a new fill layer with no channels activated.""" 45 | layer_manager = vg_layerstack.LayerManager() 46 | layer_manager.add_layer(layer_type='fill', active_channels=[""], layer_name="New fill layer") 47 | 48 | 49 | ######## PAINT LAYER FUNCTIONS ######## 50 | 51 | def new_paint_layer(): 52 | """Create a new paint layer.""" 53 | layer_manager = vg_layerstack.LayerManager() 54 | layer_manager.add_layer(layer_type='paint', layer_name="New Paint layer") 55 | 56 | ######## MASK FUNCTIONS ######## 57 | 58 | def add_mask(): 59 | """Add a black mask to the selected layer.""" 60 | layer_manager = vg_layerstack.LayerManager() 61 | mask_manager = vg_layerstack.MaskManager(layer_manager) 62 | mask_manager.add_mask() 63 | 64 | def add_ao_mask(): 65 | """Add a black mask with AO Generator.""" 66 | layer_manager = vg_layerstack.LayerManager() 67 | mask_manager = vg_layerstack.MaskManager(layer_manager) 68 | mask_manager.add_black_mask_with_ao_generator() 69 | 70 | def add_curvature_mask(): 71 | """Add a black mask with Curvature Generator.""" 72 | layer_manager = vg_layerstack.LayerManager() 73 | mask_manager = vg_layerstack.MaskManager(layer_manager) 74 | mask_manager.add_black_mask_with_curvature_generator() 75 | 76 | def add_mask_with_fill_effect(): 77 | """Add a mask with a fill effect.""" 78 | layer_manager = vg_layerstack.LayerManager() 79 | mask_manager = vg_layerstack.MaskManager(layer_manager) 80 | mask_manager.add_mask_with_fill() 81 | 82 | 83 | ################ GENERATE CONTENT FROM STACK ####################### 84 | 85 | def create_layer_from_stack(): 86 | """Generate a layer from the visible content in the stack.""" 87 | vg_export.create_layer_from_stack() 88 | 89 | 90 | def flatten_stack(): 91 | """Flatten the stack by exporting and importing textures.""" 92 | vg_export.flatten_stack() 93 | 94 | 95 | ############## CREATE REFERENCE POINT LAYER ####################### 96 | 97 | def create_ref_point_layer(): 98 | """Create a reference point layer.""" 99 | stack_manager = vg_layerstack.LayerManager() 100 | stack_manager.generate_ref_point_layer() 101 | 102 | 103 | ########################### QUICK BAKE ########################### 104 | 105 | def launch_quick_bake(): 106 | """Quickly bake mesh maps of the current texture set.""" 107 | vg_baking.quick_bake() 108 | 109 | 110 | ################################################################# 111 | 112 | def create_menu(): 113 | """Create and populate the menu with actions.""" 114 | # Get the main window 115 | main_window = ui.get_main_window() 116 | 117 | # Create a new menu 118 | vg_utilities_menu = QtWidgets.QMenu("VG Utilities", main_window) 119 | ui.add_menu(vg_utilities_menu) 120 | plugin_menus_widgets.append(vg_utilities_menu) 121 | 122 | # Define actions, shortcuts, and separators 123 | actions_with_separators = [ 124 | ("New Paint Layer", new_paint_layer, "Ctrl+P"), 125 | None, # Separator 126 | ("New Fill Layer with Base Color", new_fill_layer_base, "Ctrl+F"), 127 | ("New Fill Layer with Height", new_fill_layer_height, "Ctrl+Alt+F"), 128 | ("New Fill Layer with All Channels", new_fill_layer_all, "Ctrl+Shift+F"), 129 | ("New Fill Layer, no channel", new_fill_layer_empty, "Alt+F"), 130 | None, # Separator 131 | ("Add Mask to Selected Layer", add_mask, "Ctrl+M"), 132 | ("Add Mask with Fill Effect", add_mask_with_fill_effect, "Shift+M"), 133 | ("Add Mask with AO Generator", add_ao_mask, "Ctrl+Shift+M"), 134 | ("Add Mask with Curvature Generator", add_curvature_mask, "Ctrl+Alt+M"), 135 | None, # Separator 136 | ("Create New Layer from Visible Stack", create_layer_from_stack, "Ctrl+Shift+G"), 137 | ("Flatten Stack", flatten_stack, None), 138 | None, # Separator 139 | ("Create Reference Point Layer", create_ref_point_layer, "Ctrl+R"), 140 | None, # Separator 141 | ("Quick Bake", launch_quick_bake, "Ctrl+B"), 142 | ] 143 | 144 | # Add actions and separators to the menu 145 | for item in actions_with_separators: 146 | if item is None: 147 | vg_utilities_menu.addSeparator() 148 | else: 149 | text, func, shortcut = item 150 | action = QtGui.QAction(text, vg_utilities_menu) 151 | action.triggered.connect(func) 152 | if shortcut: 153 | action.setShortcut(QKeySequence(shortcut)) 154 | vg_utilities_menu.addAction(action) 155 | 156 | 157 | 158 | def start_plugin(): 159 | """Called when the plugin is started.""" 160 | create_menu() 161 | logging.info("VG Menu Activated") 162 | 163 | 164 | def close_plugin(): 165 | """Called when the plugin is stopped.""" 166 | # Remove all added widgets from the UI. 167 | for widget in plugin_menus_widgets: 168 | ui.delete_ui_element(widget) 169 | plugin_menus_widgets.clear() 170 | logging.info("VG Menu deactivated") 171 | 172 | def reload_plugin(): 173 | """Reload plugin modules.""" 174 | importlib.reload(vg_layerstack) 175 | importlib.reload(vg_export) 176 | importlib.reload(vg_baking) 177 | importlib.reload(vg_project_info) 178 | 179 | if __name__ == "__main__": 180 | importlib.reload(vg_layerstack) 181 | importlib.reload(vg_export) 182 | importlib.reload(vg_baking) 183 | importlib.reload(vg_project_info) 184 | 185 | start_plugin() 186 | -------------------------------------------------------------------------------- /modules/vg_pt_utils/vg_layerstack.py: -------------------------------------------------------------------------------- 1 | ########################################################################## 2 | # 3 | # Copyright 2010-2024 Vincent GAULT - Adobe 4 | # All Rights Reserved. 5 | # 6 | ########################################################################## 7 | 8 | """ 9 | This module contains different utilities related to the layer stack in 10 | Substance 3D Painter. 11 | """ 12 | __author__ = "Vincent GAULT - Adobe" 13 | 14 | # Modules Import 15 | import string 16 | from substance_painter import textureset, layerstack, project, resource, logging, colormanagement 17 | 18 | 19 | class LayerManager: 20 | """ 21 | The `LayerManager` class provides a set of utilities for managing layers within the active texture stack in Adobe Substance 3D Painter. 22 | """ 23 | 24 | def __init__(self): 25 | """Class Initialization""" 26 | self._current_stack = None 27 | self._layer_selection = None 28 | self._stack_layers = None 29 | self._stack_layers_count = None 30 | 31 | if project.is_open(): 32 | self._current_stack = textureset.get_active_stack() 33 | self._layer_selection = layerstack.get_selected_nodes(self._current_stack) 34 | 35 | @property 36 | def current_stack(self): 37 | return self._current_stack 38 | 39 | @current_stack.setter 40 | def current_stack(self, value): 41 | self._current_stack = value 42 | 43 | @property 44 | def layer_selection(self): 45 | return self._layer_selection 46 | 47 | @layer_selection.setter 48 | def layer_selection(self, value): 49 | self._layer_selection = value 50 | 51 | @property 52 | def stack_layers(self): 53 | if self._stack_layers is None: 54 | self._stack_layers = layerstack.get_root_layer_nodes(self._current_stack) 55 | return self._stack_layers 56 | 57 | @property 58 | def stack_layers_count(self): 59 | if self._stack_layers_count is None: 60 | self._stack_layers_count = len(self.stack_layers) 61 | return self._stack_layers_count 62 | 63 | def refresh_layer_selection(self): 64 | self.layer_selection = layerstack.get_selected_nodes(self.current_stack) 65 | 66 | def add_layer(self, layer_type, layer_name ="New Layer", active_channels=None, layer_position="Above"): 67 | """Add a layer of specified type to the current stack with optional active channels""" 68 | 69 | if layer_position not in ["Above", "On Top"]: 70 | logging.error("layer_position parameter must be 'Above' or 'On Top'") 71 | return None 72 | 73 | current_layer_count = self._stack_layers_count 74 | if self._current_stack is None: 75 | logging.error("No active stack found") 76 | return None 77 | 78 | 79 | insert_position = None 80 | selected_layer = layerstack.get_selected_nodes(self._current_stack) 81 | 82 | if current_layer_count == 0: 83 | insert_position = layerstack.InsertPosition.from_textureset_stack(self._current_stack) 84 | 85 | elif layer_position == "Above": 86 | insert_position = layerstack.InsertPosition.above_node(selected_layer[0]) 87 | 88 | elif layer_position == "On Top": 89 | insert_position = layerstack.InsertPosition.from_textureset_stack(self._current_stack) 90 | 91 | 92 | new_layer = None 93 | 94 | if layer_type == 'fill': 95 | new_layer = layerstack.insert_fill(insert_position) 96 | 97 | elif layer_type == 'paint': 98 | new_layer = layerstack.insert_paint(insert_position) 99 | 100 | else: 101 | logging.error("Invalid layer type") 102 | return 103 | 104 | if active_channels: 105 | if active_channels==[""]: 106 | pass 107 | else: 108 | new_layer.active_channels = {getattr(textureset.ChannelType, channel) for channel in active_channels} 109 | else: 110 | active_channels = self._current_stack.all_channels() 111 | new_layer.active_channels = set(active_channels) 112 | 113 | 114 | new_layer.set_name(layer_name) 115 | layerstack.set_selected_nodes([new_layer]) 116 | 117 | return new_layer if new_layer else None 118 | 119 | 120 | def delete_stack_content(self): 121 | """Delete all layers in the current stack.""" 122 | current_layers = self.stack_layers 123 | for layer in current_layers: 124 | layerstack.delete_node(layer) 125 | 126 | 127 | def generate_ref_point_layer(self): 128 | """Generate a reference point layer with unique naming and specific effects.""" 129 | base_name = "REF POINT LAYER" 130 | all_nodes = layerstack.get_root_layer_nodes(self.current_stack) 131 | 132 | ref_point_count = 1 133 | for node in all_nodes: 134 | if node.get_name().startswith(base_name): 135 | ref_point_count += 1 136 | if node.get_type() == layerstack.NodeType.GroupLayer: 137 | sublayers = node.sub_layers() 138 | for sublayer in sublayers: 139 | if sublayer.get_name().startswith(base_name): 140 | ref_point_count += 1 141 | 142 | # Fotmat the counter to be 2 digit numbers 143 | formatted_ref_point_count = f"_{str(ref_point_count).zfill(2)}" 144 | 145 | # build ref pint name 146 | ref_point_name = f"{base_name}{formatted_ref_point_count}" 147 | 148 | # Add new layer with proper name 149 | ref_point_layer = self.add_layer("paint", layer_position="Above") 150 | ref_point_layer.set_name(ref_point_name) 151 | 152 | 153 | for new_layer_channel in ref_point_layer.active_channels: 154 | normal_blending = layerstack.BlendingMode(25) 155 | ref_point_layer.set_blending_mode(normal_blending, new_layer_channel) 156 | 157 | insert_position = layerstack.InsertPosition.inside_node(ref_point_layer, layerstack.NodeStack.Content) 158 | layerstack.insert_anchor_point_effect(insert_position, ref_point_name) 159 | 160 | 161 | class MaskManager: 162 | """ 163 | The `MaskManager` class provides utilities for managing masks within the active texture stack in Adobe Substance 3D Painter. 164 | """ 165 | 166 | def __init__(self, layer_manager): 167 | self.layer_manager = layer_manager 168 | 169 | def add_mask(self, mask_bkg_color=None): 170 | """Adds a mask to the currently selected layer with optional background color.""" 171 | 172 | color_map = { 173 | 'Black': layerstack.MaskBackground.Black, 174 | 'White': layerstack.MaskBackground.White 175 | } 176 | 177 | if mask_bkg_color and mask_bkg_color not in color_map: 178 | logging.error("Invalid mask color. Choose 'Black' or 'White'.") 179 | return 180 | 181 | if self.layer_manager.current_stack: 182 | current_layer = layerstack.get_selected_nodes(self.layer_manager.current_stack) 183 | 184 | for selectedLayer in current_layer: 185 | if selectedLayer.has_mask(): 186 | if mask_bkg_color: 187 | selectedLayer.remove_mask() 188 | selectedLayer.add_mask(color_map[mask_bkg_color]) 189 | else: 190 | current_mask_background = selectedLayer.get_mask_background() 191 | new_mask_background = (layerstack.MaskBackground.White if current_mask_background == layerstack.MaskBackground.Black 192 | else layerstack.MaskBackground.Black) 193 | selectedLayer.remove_mask() 194 | selectedLayer.add_mask(new_mask_background) 195 | else: 196 | mask_to_add = color_map.get(mask_bkg_color, layerstack.MaskBackground.Black) 197 | selectedLayer.add_mask(mask_to_add) 198 | 199 | def add_black_mask_with_ao_generator(self): 200 | """Adds a black mask with an ambient occlusion generator to the currently selected layer.""" 201 | self.add_mask('Black') 202 | 203 | if self.layer_manager.current_stack: 204 | current_layer = layerstack.get_selected_nodes(self.layer_manager.current_stack) 205 | generator_resource = resource.search("s:starterassets u:generator n:Ambient Occlusion")[0] 206 | 207 | insertion_positions = [ 208 | layerstack.InsertPosition.inside_node(layer, layerstack.NodeStack.Mask) 209 | for layer in current_layer 210 | ] 211 | for pos in insertion_positions: 212 | layerstack.insert_generator_effect(pos, generator_resource.identifier()) 213 | 214 | def add_black_mask_with_curvature_generator(self): 215 | """Adds a black mask with a curvature generator to the currently selected layer.""" 216 | self.add_mask('Black') 217 | 218 | if self.layer_manager.current_stack: 219 | current_layer = layerstack.get_selected_nodes(self.layer_manager.current_stack) 220 | generator_resource = resource.search("s:starterassets u:generator n:Curvature")[0] 221 | 222 | insertion_positions = [ 223 | layerstack.InsertPosition.inside_node(layer, layerstack.NodeStack.Mask) 224 | for layer in current_layer 225 | ] 226 | for pos in insertion_positions: 227 | layerstack.insert_generator_effect(pos, generator_resource.identifier()) 228 | 229 | def add_mask_with_fill(self): 230 | """Adds a black mask with a fill layer to the currently selected layer. 231 | """ 232 | current_layer = layerstack.get_selected_nodes(self.layer_manager.current_stack) 233 | self.add_mask() 234 | 235 | inside_mask = layerstack.InsertPosition.inside_node(current_layer[0], layerstack.NodeStack.Mask) 236 | my_fill_effect_mask = layerstack.insert_fill(inside_mask) 237 | 238 | pure_white = colormanagement.Color(1.0, 1.0, 1.0) 239 | my_fill_effect_mask.set_source(channeltype=None, source=pure_white) 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | -------------------------------------------------------------------------------- /modules/vg_pt_utils/vg_export.py: -------------------------------------------------------------------------------- 1 | ########################################################################## 2 | # 3 | # Copyright 2010-2024 Vincent GAULT - Adobe 4 | # All Rights Reserved. 5 | # 6 | ########################################################################## 7 | 8 | # Modules Import 9 | import os, time 10 | 11 | from substance_painter import export, textureset, resource, layerstack 12 | from vg_pt_utils import vg_layerstack, vg_project_info 13 | 14 | class TextureImporter: 15 | """ 16 | Responsible for importing textures from the file paths. 17 | """ 18 | 19 | def import_textures(self, textures_to_import): 20 | """ 21 | Import the textures from the specified paths. 22 | 23 | Args: 24 | textures_to_import (object): The exported textures to be imported. 25 | 26 | Returns: 27 | dict: A dictionary with the imported texture resources keyed by their respective paths. 28 | """ 29 | imported_textures = {} 30 | texture_paths = [] 31 | 32 | for texture_list_key in textures_to_import.textures.keys(): 33 | current_texture_list = textures_to_import.textures[texture_list_key] 34 | 35 | for texture_path in current_texture_list: 36 | texture_paths.append(texture_path) # Collect texture paths for cleanup later 37 | texture_resource = resource.import_project_resource(texture_path, resource.Usage.TEXTURE) 38 | imported_textures[texture_path] = texture_resource 39 | 40 | return imported_textures, texture_paths 41 | 42 | 43 | class ChannelTypeExtractor: 44 | """ 45 | Responsible for extracting the channel type from the texture file path. 46 | """ 47 | 48 | def extract_channel_type(self, texture_path): 49 | """ 50 | Extract the channel type from the texture file path. 51 | 52 | Args: 53 | texture_path (str): The file path of the texture. 54 | 55 | Returns: 56 | str: The extracted channel type. 57 | """ 58 | last_underscore_index = texture_path.rfind("_") 59 | extension_index = texture_path.rfind(".png") 60 | if last_underscore_index != -1 and extension_index != -1: 61 | channel_type_string = texture_path[last_underscore_index + 1:extension_index] 62 | channel_type_string = channel_type_string.split(".")[0] 63 | if channel_type_string == 'ambientOcclusion': 64 | channel_type_string = 'AO' 65 | return channel_type_string 66 | return None 67 | 68 | 69 | class LayerTextureAssigner: 70 | """ 71 | Responsible for assigning imported textures to the correct channels in a fill layer. 72 | """ 73 | 74 | def assign_textures_to_layer(self, new_layer, imported_textures): 75 | """ 76 | Assign the imported textures to the correct channels in the new fill layer. 77 | 78 | Args: 79 | new_layer (object): The fill layer where the textures will be assigned. 80 | imported_textures (dict): A dictionary of imported texture resources keyed by their file paths. 81 | """ 82 | extractor = ChannelTypeExtractor() 83 | 84 | for texture_path, texture_resource in imported_textures.items(): 85 | channel_type_string = extractor.extract_channel_type(texture_path) 86 | 87 | if channel_type_string: 88 | print(f"Assigning texture to channel: {channel_type_string}") 89 | channel_type = getattr(layerstack.ChannelType, channel_type_string) 90 | new_layer.set_source(channel_type, texture_resource.identifier()) 91 | 92 | print("Textures imported and assigned to the new fill layer.") 93 | 94 | 95 | 96 | class ResourceCleaner: 97 | """ 98 | Manages the cleanup of texture files after they are imported and assigned. 99 | """ 100 | 101 | def delete_texture_files(self, texture_paths): 102 | """ 103 | Deletes the texture files from disk after they have been imported. 104 | 105 | Args: 106 | texture_paths (list): List of texture file paths to delete. 107 | """ 108 | for texture_path in texture_paths: 109 | try: 110 | os.remove(texture_path) 111 | print(f"Deleted file: {texture_path}") 112 | except Exception as e: 113 | print(f"Error deleting file {texture_path}: {e}") 114 | 115 | 116 | class TextureAssignmentManager: 117 | """ 118 | Manages the process of importing exported textures into a new fill layer and assigning them to the appropriate channels. 119 | """ 120 | 121 | def __init__(self): 122 | self.texture_importer = TextureImporter() 123 | self.layer_assigner = LayerTextureAssigner() 124 | #self.resource_cleaner = ResourceCleaner() 125 | 126 | def import_and_assign_textures(self, new_layer, textures_to_import): 127 | """ 128 | Import the exported textures into a new fill layer and assign them to the correct channels. 129 | 130 | Args: 131 | new_layer (object): The fill layer where the textures will be assigned. 132 | textures_to_import (object): The exported textures to be imported. 133 | """ 134 | imported_textures, texture_paths = self.texture_importer.import_textures(textures_to_import) 135 | self.layer_assigner.assign_textures_to_layer(new_layer, imported_textures) 136 | #self.resource_cleaner.delete_texture_files(texture_paths) #do not uncomment 137 | 138 | 139 | class ExportConfigGenerator: 140 | """ 141 | Generates the export configuration for the active texture set. 142 | """ 143 | 144 | def __init__(self, export_path, preset_name="Current channels Export"): 145 | self.export_path = export_path 146 | self.preset_name = preset_name 147 | 148 | def generate_current_channels_maps_export(self): 149 | current_stack = textureset.get_active_stack() 150 | texture_set_info_manager = vg_project_info.TextureSetInfo(current_stack) 151 | current_textureset_info = texture_set_info_manager.fetch_texture_set_info_from_stack() 152 | 153 | udim_suffix = "" 154 | current_texture_set = current_textureset_info["Texture Set"] 155 | if current_texture_set.has_uv_tiles(): 156 | udim_suffix = '.$udim' 157 | 158 | raw_channels_list = current_textureset_info["Channels"] 159 | channels_names = [element.name for element in raw_channels_list] 160 | channels_names = [ 161 | 'ambientOcclusion' if element.name == 'AO' else element.name 162 | for element in raw_channels_list 163 | ] 164 | 165 | current_channels_info = [] 166 | 167 | for channel_name in channels_names: 168 | channels_info = [ 169 | { 170 | "destChannel": channel, 171 | "srcChannel": channel, 172 | "srcMapType": "DocumentMap", 173 | "srcMapName": channel_name 174 | } for channel in "RGBA" 175 | ] 176 | 177 | current_filename = f'$mesh_$textureSet_{channel_name}' 178 | current_filename = current_filename + udim_suffix 179 | 180 | current_channels_info.append({ 181 | 'fileName': current_filename, 182 | 'channels': channels_info, 183 | 'parameters': { 184 | 'bitDepth': '8', 185 | 'dithering': False, 186 | 'fileFormat': 'png' 187 | } 188 | }) 189 | 190 | return current_channels_info 191 | 192 | def generate_export_config(self): 193 | current_channels_export_preset = { 194 | "name": self.preset_name, 195 | "maps": self.generate_current_channels_maps_export() 196 | } 197 | 198 | texture_set_info_manager = vg_project_info.TextureSetInfo() 199 | current_textureset_info = texture_set_info_manager.fetch_texture_set_info_from_stack() 200 | 201 | export_config = { 202 | "exportPath": self.export_path, 203 | "exportShaderParams": False, 204 | "defaultExportPreset": self.preset_name, 205 | "exportPresets": [current_channels_export_preset], 206 | "exportList": [{"rootPath": current_textureset_info["Name"]}], 207 | "exportParameters": [ 208 | {"parameters": {"dithering": True, "paddingAlgorithm": "infinite"}} 209 | ], 210 | "uvTiles": current_textureset_info["UV Tiles coordinates"], 211 | } 212 | return export_config 213 | 214 | 215 | class TextureExporter: 216 | """ 217 | Handles the export of textures using a given export configuration. 218 | """ 219 | 220 | def export_textures(self, export_config): 221 | try: 222 | export_result = export.export_project_textures(export_config) 223 | 224 | if export_result.status == export.ExportStatus.Error: 225 | print("Error during texture export:", export_result.message) 226 | return None 227 | else: 228 | print("Export successful!") 229 | return export_result 230 | 231 | except Exception as e: 232 | print(f"Error during texture export: {e}") 233 | return None 234 | 235 | 236 | ##### FUNCTIONS USING THE CLASSES ##### 237 | 238 | def create_layer_from_stack(): 239 | """Generate a layer from the visible content in the stack.""" 240 | 241 | # Generate the export configuration 242 | export_path = export.get_default_export_path() 243 | config_generator = ExportConfigGenerator(export_path) 244 | export_config = config_generator.generate_export_config() 245 | 246 | # Perform the export 247 | exporter = TextureExporter() 248 | exported_textures = exporter.export_textures(export_config) 249 | 250 | # Import the textures to a new layer 251 | if exported_textures: 252 | texture_manager = TextureAssignmentManager() 253 | current_stack_manager = vg_layerstack.LayerManager() 254 | 255 | new_layer = current_stack_manager.add_layer("fill", layer_position="On Top", layer_name="Stack layer") 256 | new_layer.active_channels = set(new_layer.active_channels) 257 | 258 | for new_layer_channel in new_layer.active_channels: 259 | normal_blending = layerstack.BlendingMode(2) 260 | new_layer.set_blending_mode(normal_blending, new_layer_channel) 261 | 262 | texture_manager.import_and_assign_textures(new_layer, exported_textures) 263 | 264 | 265 | def flatten_stack(): 266 | """Flatten the stack by exporting and importing textures.""" 267 | 268 | export_path = export.get_default_export_path() 269 | config_generator = ExportConfigGenerator(export_path) 270 | 271 | # Generate the export configuration 272 | export_config = config_generator.generate_export_config() 273 | 274 | # Perform the export 275 | exporter = TextureExporter() 276 | stack_manager = vg_layerstack.LayerManager() 277 | exported_textures = exporter.export_textures(export_config) 278 | 279 | stack_manager.delete_stack_content() #delete before to reimport 280 | 281 | # Import the textures to a new layer 282 | if exported_textures: 283 | texture_manager = TextureAssignmentManager() 284 | new_layer = stack_manager.add_layer("fill", layer_position="On Top", layer_name="Stack layer") 285 | new_layer.active_channels = set(new_layer.active_channels) 286 | 287 | for new_layer_channel in new_layer.active_channels: 288 | normal_blending = layerstack.BlendingMode(2) 289 | new_layer.set_blending_mode(normal_blending, new_layer_channel) 290 | 291 | texture_manager.import_and_assign_textures(new_layer, exported_textures) 292 | 293 | 294 | ##################################### 295 | 296 | if __name__ == "__main__": 297 | # Initialize the export configuration generator 298 | export_path = export.get_default_export_path() 299 | config_generator = ExportConfigGenerator(export_path) 300 | 301 | # Generate the export configuration 302 | export_config = config_generator.generate_export_config() 303 | 304 | # Perform the export 305 | exporter = TextureExporter() 306 | exported_textures = exporter.export_textures(export_config) 307 | 308 | # Import the textures to a new layer 309 | if exported_textures: 310 | texture_manager = TextureAssignmentManager() 311 | current_stack_manager = vg_layerstack.LayerManager() 312 | new_layer = current_stack_manager.add_layer("fill", layer_position="On Top", layer_name="Stack layer") 313 | new_layer.active_channels = set(new_layer.active_channels) 314 | 315 | for new_layer_channel in new_layer.active_channels: 316 | normal_blending = layerstack.BlendingMode(2) 317 | new_layer.set_blending_mode(normal_blending, new_layer_channel) 318 | 319 | texture_manager.import_and_assign_textures(new_layer, exported_textures) 320 | --------------------------------------------------------------------------------