2025-07-01

This commit is contained in:
2026-03-17 14:30:01 -06:00
parent f9a22056dd
commit 62b5978595
4579 changed files with 1257472 additions and 0 deletions
@@ -0,0 +1,137 @@
"""
Copyright (C) 2023 Adobe.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
# file: ops/material.py
# brief: Material operators
# author Adobe - 3D & Immersive
# copyright 2023 Adobe Inc. All rights reserved.
import bpy
from ..api import SUBSTANCE_Api
from ..utils import SUBSTANCE_Utils
from ..common import Code_Response, Code_RequestType
from ..sbsar.sbsar import SBSAR
from ..thread_ops import SUBSTANCE_Threads
from ..sbsar.async_ops import _render_sbsar
class SUBSTANCE_OT_Version(bpy.types.Operator):
bl_idname = 'substance.version'
bl_label = 'Version'
bl_description = 'Get the current plugin version'
def execute(self, context):
_version = SUBSTANCE_Api.version()
SUBSTANCE_Utils.log_data(
"INFO",
"Substance Addon version {}.{}.{}".format(_version[0], _version[1], _version[2]),
display=True)
return {'FINISHED'}
class SUBSTANCE_OT_Message(bpy.types.Operator):
bl_idname = 'substance.send_message'
bl_label = 'Message'
bl_description = "Send the user a message"
type: bpy.props.StringProperty(default="INFO") # noqa
message: bpy.props.StringProperty(default="") # noqa
_timer = None
def modal(self, context, event):
if event.type == 'TIMER':
self.cancel(context)
self.report({self.type}, self.message)
return {'FINISHED'}
return {'PASS_THROUGH'}
def execute(self, context):
wm = context.window_manager
self._timer = wm.event_timer_add(time_step=1, window=context.window)
wm.modal_handler_add(self)
return {'RUNNING_MODAL'}
def cancel(self, context):
wm = context.window_manager
wm.event_timer_remove(self._timer)
def load_sbsar(filename, filepath, uuid=None, type=Code_RequestType.r_async):
_addon_prefs, _selected_shader_idx, _selected_shader_preset = SUBSTANCE_Utils.get_selected_shader(bpy.context)
_normal_format = _addon_prefs.default_normal_format
_output_size = _addon_prefs.default_resolution.get()
_unique_name = SUBSTANCE_Utils.get_unique_name(filename, bpy.context)
SUBSTANCE_Utils.log_data("INFO", "Begin loading substance from file [{}]".format(filename), display=True)
if uuid:
_data = {
"uuid": uuid,
"filepath": filepath,
"name": _unique_name,
"$outputsize": _output_size,
"normal_format": 0 if _normal_format == "DirectX" else 1
}
else:
_data = {
"filepath": filepath,
"name": _unique_name,
"$outputsize": _output_size,
"normal_format": 0 if _normal_format == "DirectX" else 1
}
# Load the sbsar to the SRE
_result = SUBSTANCE_Api.sbsar_load(_data)
if _result[0] != Code_Response.success:
SUBSTANCE_Utils.log_data(
"ERROR",
"Substance file [{}] located at [{}] could not be loaded".format(filename, filepath),
display=True)
return
if _result[1]["result"] != Code_Response.success.value:
SUBSTANCE_Utils.log_data(
"ERROR",
"An error ocurred while loading Substance file [{}]. Failed with error [{}]".format(
filepath, _result[1]["result"]),
display=True)
return
_sbsar = SBSAR(_unique_name, filename, _result[1]["data"], _addon_prefs)
_loaded_sbsar = bpy.context.scene.loaded_sbsars.add()
_loaded_sbsar.init(_sbsar)
_loaded_sbsar.init_shader(_selected_shader_idx, _selected_shader_preset, _addon_prefs.get_default_output())
SUBSTANCE_Api.sbsar_register(_sbsar)
bpy.context.scene.sbsar_index = len(bpy.context.scene.loaded_sbsars) - 1
if type == Code_RequestType.r_async:
for _idx, _graph in enumerate(_loaded_sbsar.graphs):
SUBSTANCE_Threads.alt_thread_run(_render_sbsar, (bpy.context, _loaded_sbsar, _idx))
SUBSTANCE_Utils.log_data("INFO", "Substance from file [{}] loaded".format(filename))
def load_usdz(filename, filepath, uuid=None):
def load_file():
bpy.ops.wm.usd_import(filepath=filepath, relative_path=True, set_material_blend=False)
SUBSTANCE_Utils.log_data(
"INFO",
"Substance Connector file [{}] loaded".format(filename),
display=True)
SUBSTANCE_Threads.main_thread_run(load_file)
@@ -0,0 +1,70 @@
"""
Copyright (C) 2023 Adobe.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
# file: ops/inputs.py
# brief: Parameter Operators
# author Adobe - 3D & Immersive
# copyright 2023 Adobe Inc. All rights reserved.
import bpy
from random import randint
from ..utils import SUBSTANCE_Utils
from ..common import INPUTS_MAX_RANDOM_SEED, Code_InputIdentifier
class SUBSTANCE_OT_RandomizeSeed(bpy.types.Operator):
bl_idname = 'substance.randomize_seed'
bl_label = 'Randomize'
bl_description = 'Generate a new random value for the current SBSAR randomseed parameter'
def execute(self, context):
_selected_graph = SUBSTANCE_Utils.get_selected_graph(context)
_parms = getattr(context.scene, _selected_graph.inputs_class_name)
_value = randint(0, INPUTS_MAX_RANDOM_SEED)
setattr(_parms, Code_InputIdentifier.randomseed.value, _value)
return {'FINISHED'}
class SUBSTANCE_OT_InputGroupsCollapse(bpy.types.Operator):
bl_idname = 'substance.input_groups_collapse'
bl_label = 'Collapse Groups'
bl_description = 'Collapse all groups'
def execute(self, context):
_selected_graph = SUBSTANCE_Utils.get_selected_graph(context)
for _group in _selected_graph.input_groups:
_group.collapsed = True
return {'FINISHED'}
class SUBSTANCE_OT_InputGroupsExpand(bpy.types.Operator):
bl_idname = 'substance.input_groups_expand'
bl_label = 'Expand Groups'
bl_description = 'Expand all groups'
def execute(self, context):
_selected_graph = SUBSTANCE_Utils.get_selected_graph(context)
for _group in _selected_graph.input_groups:
_group.collapsed = False
return {'FINISHED'}
@@ -0,0 +1,85 @@
"""
Copyright (C) 2023 Adobe.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
# file: ops/material.py
# brief: Material operators
# author Adobe - 3D & Immersive
# copyright 2023 Adobe Inc. All rights reserved.
import bpy
import json
import traceback
from ..utils import SUBSTANCE_Utils
from ..common import Code_SbsarLoadSuffix
from ..material.manager import SUBSTANCE_MaterialManager
class SUBSTANCE_OT_SetMaterial(bpy.types.Operator):
bl_idname = 'substance.set_material'
bl_label = 'Set material'
bl_description = "Set the material shader network"
data: bpy.props.StringProperty(default="") # noqa
def execute(self, context):
_data = json.loads(self.data)
if len(_data["outputs"]) == 0:
SUBSTANCE_Utils.log_data("INFO", "No render changes")
return {'FINISHED'}
_sbsar = None
for _item in context.scene.loaded_sbsars:
if _item.uuid == _data["uuid"]:
_sbsar = _item
if _sbsar is None:
SUBSTANCE_Utils.log_data("ERROR", "Substance file cannot be found", display=True)
return {'FINISHED'}
try:
_graph = None
for _item in _sbsar.graphs:
_uid = str(_data["graph_uid"])
if _uid == _item.uid:
_graph = _item
break
if _graph is None:
SUBSTANCE_Utils.log_data("ERROR", "Substance graph cannot be found", display=True)
return {'FINISHED'}
_graph.set_thumbnail(_data)
_outputs = {}
for _output in _data["outputs"]:
if _output["usage"] == "UNKNOWN":
_outputs[_output["identifier"]] = _output
else:
_outputs[_output["usage"]] = _output
_data["outputs"] = _outputs
SUBSTANCE_MaterialManager.create_shader_network(context, _sbsar, _graph, _data)
except Exception:
_sbsar.suffix = Code_SbsarLoadSuffix.error.value[0]
_sbsar.icon = Code_SbsarLoadSuffix.error.value[1]
SUBSTANCE_Utils.log_data("ERROR", "Exception - Susbtance material creation error:")
SUBSTANCE_Utils.log_traceback(traceback.format_exc())
return {'FINISHED'}
@@ -0,0 +1,301 @@
"""
Copyright (C) 2023 Adobe.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
# file: ops/presets.py
# brief:Presets Operators
# author Adobe - 3D & Immersive
# copyright 2023 Adobe Inc. All rights reserved.
import os
import bpy
from bpy_extras.io_utils import ImportHelper
from ..common import PRESET_DEFAULT, PRESET_CUSTOM, PRESET_EXTENSION, Code_Response
from ..utils import SUBSTANCE_Utils
from ..api import SUBSTANCE_Api
from ..sbsar.sbsar import SBS_Preset
class SUBSTANCE_OT_AddPreset(bpy.types.Operator):
bl_idname = 'substance.add_preset'
bl_label = 'New Preset'
bl_description = "Set the current parameters as a new preset"
preset_name: bpy.props.StringProperty(name="Preset Name") # noqa
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
_layout = self.layout
_row = _layout.row()
_row.label(text="Preset Name: ")
_row.prop(self, 'preset_name', text="")
def execute(self, context):
if len(self.preset_name) > 0:
_sbsar = context.scene.loaded_sbsars[context.scene.sbsar_index]
_graph = SUBSTANCE_Utils.get_selected_graph(context)
_result = SUBSTANCE_Api.sbsar_preset_add(_sbsar, _graph, self.preset_name)
if _result[0] != Code_Response.success:
SUBSTANCE_Utils.log_data(
"ERROR",
"[{}] Error while creating the preset".format(Code_Response.preset_create_get_error))
return {'FINISHED'}
if _result[1]["result"] != Code_Response.success.value:
SUBSTANCE_Utils.log_data(
"ERROR",
"[{}] Error while getting the newly created preset".format(
Code_Response.preset_create_get_error))
return {'FINISHED'}
_preset_value = _result[1]["data"]["value"]
_obj = {
"index": len(_graph.presets),
"label": self.preset_name,
"embedded": False,
"icon": "LOCKED",
"value": _preset_value
}
_preset = SBS_Preset(_obj)
_new_preset = _graph.presets.add()
_new_preset.init(_preset)
_graph.preset_callback = False
_graph.presets_list = _preset.index
_graph.preset_callback = True
SUBSTANCE_Utils.log_data("INFO", "Preset [{}] created".format(self.preset_name), display=True)
self.preset_name = ""
else:
SUBSTANCE_Utils.log_data(
"ERROR",
"[{}] Error while creating new preset, please set a name for the preset".format(
Code_Response.preset_create_no_name_error),
display=True)
return {'FINISHED'}
class SUBSTANCE_OT_DeletePreset(bpy.types.Operator):
bl_idname = 'substance.delete_preset'
bl_label = 'Delete selected preset?'
bl_description = "Delete the current preset"
@classmethod
def poll(cls, context):
_selected_graph = SUBSTANCE_Utils.get_selected_graph(context)
_selected_preset_idx = int(_selected_graph.presets_list)
_selected_preset = _selected_graph.presets[_selected_preset_idx]
return not _selected_preset.embedded and _selected_preset.icon != "UNLOCKED"
def execute(self, context):
_selected_graph = SUBSTANCE_Utils.get_selected_graph(context)
_selected_preset_idx = int(_selected_graph.presets_list)
_selected_graph.presets_list = "0"
_selected_graph.presets.remove(_selected_preset_idx)
SUBSTANCE_Utils.log_data("INFO", "Preset deleted", display=True)
return {'FINISHED'}
class SUBSTANCE_OT_DeletePresetModal(bpy.types.Operator):
bl_idname = 'substance.delete_preset_modal'
bl_label = 'Delete selected preset?'
bl_description = "Delete the current preset"
@classmethod
def poll(cls, context):
_selected_graph = SUBSTANCE_Utils.get_selected_graph(context)
_selected_preset_idx = int(_selected_graph.presets_list)
_selected_preset = _selected_graph.presets[_selected_preset_idx]
return not _selected_preset.embedded and _selected_preset.icon != "UNLOCKED"
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
_layout = self.layout
_row = _layout.row()
_row.label(text="")
def execute(self, context):
_selected_graph = SUBSTANCE_Utils.get_selected_graph(context)
_selected_preset_idx = int(_selected_graph.presets_list)
_selected_graph.presets_list = "0"
_selected_graph.presets.remove(_selected_preset_idx)
SUBSTANCE_Utils.log_data("INFO", "Preset deleted", display=True)
return {'FINISHED'}
class SUBSTANCE_OT_ImportPreset(bpy.types.Operator, ImportHelper):
bl_idname = 'substance.import_preset'
bl_label = 'Import preset'
bl_description = "Import a preset from a file"
filter_glob: bpy.props.StringProperty(default='*' + PRESET_EXTENSION, options={'HIDDEN'}) # noqa
files: bpy.props.CollectionProperty(name='SBSAR Presets Files', type=bpy.types.OperatorFileListElement) # noqa
directory: bpy.props.StringProperty(subtype="DIR_PATH") # noqa
def __init__(self):
self.filepath = ''
def execute(self, context):
_sbsar = context.scene.loaded_sbsars[context.scene.sbsar_index]
for _f in self.files:
_filepath = os.path.join(self.directory, _f.name)
_result = SUBSTANCE_Api.sbsar_preset_read(_filepath)
if _result[0] != Code_Response.success:
SUBSTANCE_Utils.log_data("ERROR", "[{}] Error while reading preset from file".format(_result))
continue
_obj = _result[1]
_not_found = len(_sbsar.graphs)
for _graph in _sbsar.graphs:
if _graph.identifier not in _obj:
_not_found -= 1
continue
for _preset in _obj[_graph.identifier]:
if _preset["label"] in _graph.presets:
if _preset["label"] == PRESET_CUSTOM or _preset["label"] == PRESET_DEFAULT:
SUBSTANCE_Utils.log_data(
"ERROR",
"[{}] Error [{}] cannot be overwritten".format(
Code_Response.preset_import_protected_error,
_preset["label"]),
display=True)
continue
for _existing_preset in _graph.presets:
if _preset["label"] != _existing_preset.name:
continue
if _existing_preset.embedded:
SUBSTANCE_Utils.log_data(
"ERROR",
"[{}] Error [{}] cannot be overwritten".format(
Code_Response.preset_import_protected_error,
_preset["label"]),
display=True)
continue
_existing_preset.value = _preset["value"]
SUBSTANCE_Utils.log_data(
"INFO",
"Preset [{}] updated.".format(_preset["label"]),
display=True)
else:
_obj = {
"index": len(_graph.presets),
"label": _preset["label"],
"embedded": False,
"icon": "LOCKED",
"value": _preset["value"]
}
_preset = SBS_Preset(_obj)
_new_preset = _graph.presets.add()
_new_preset.init(_preset)
SUBSTANCE_Utils.log_data(
"INFO",
"Preset [{}] imported.".format(_preset.label),
display=True)
if _not_found == 0:
SUBSTANCE_Utils.log_data(
"ERROR",
"[{}] Error Preset [{}] cannot be imported in this substance".format(
Code_Response.preset_import_not_graph,
_f.name),
display=True)
return {'FINISHED'}
return {'FINISHED'}
class SUBSTANCE_OT_ExportPreset(bpy.types.Operator, ImportHelper):
bl_idname = 'substance.export_preset'
bl_label = 'Export preset'
bl_description = "Export current preset to a file"
preset_name: bpy.props.StringProperty(name="Preset Name") # noqa
def __init__(self):
self.filepath = ''
@classmethod
def poll(cls, context):
_selected_graph = SUBSTANCE_Utils.get_selected_graph(context)
_selected_preset_idx = int(_selected_graph.presets_list)
_selected_preset = _selected_graph.presets[_selected_preset_idx]
return _selected_preset.name != PRESET_DEFAULT and _selected_preset.name != PRESET_CUSTOM
def execute(self, context):
_selected_graph = SUBSTANCE_Utils.get_selected_graph(context)
_selected_preset_idx = int(_selected_graph.presets_list)
_selected_preset = _selected_graph.presets[_selected_preset_idx]
_result = SUBSTANCE_Api.sbsar_preset_write(
self.filepath,
_selected_preset)
if _result != Code_Response.success:
SUBSTANCE_Utils.log_data(
"ERROR",
"[{}] Error while writting the current preset".format(_result),
display=True)
else:
SUBSTANCE_Utils.log_data("INFO", "Preset exported", display=True)
return {'FINISHED'}
class SUBSTANCE_OT_ExportAll(bpy.types.Operator, ImportHelper):
bl_idname = 'substance.export_all'
bl_label = 'Export all custom presets'
bl_description = "Export all custom presets"
def __init__(self):
self.filepath = ''
def execute(self, context):
_selected_graph = SUBSTANCE_Utils.get_selected_graph(context)
for _preset in _selected_graph.presets:
if _preset.embedded or _preset.name == PRESET_CUSTOM:
continue
_result = SUBSTANCE_Api.sbsar_preset_write(
self.filepath,
_preset)
if _result != Code_Response.success:
SUBSTANCE_Utils.log_data(
"ERROR",
"[{}] Error while writting the current preset".format(_result),
display=True)
else:
SUBSTANCE_Utils.log_data("INFO", "Preset {} exported".format(_preset.label), display=True)
return {'FINISHED'}
@@ -0,0 +1,378 @@
"""
Copyright (C) 2023 Adobe.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
# file: ops/sbsar.py
# brief: Sinstance Operators
# author Adobe - 3D & Immersive
# copyright 2023 Adobe Inc. All rights reserved.
import os
import bpy
from bpy_extras.io_utils import ImportHelper
from ..api import SUBSTANCE_Api
from ..material.manager import SUBSTANCE_MaterialManager
from ..utils import SUBSTANCE_Utils
from ..thread_ops import SUBSTANCE_Threads
from ..sbsar.sbsar import SBSAR
from ..sbsar.async_ops import _render_sbsar
from .common import load_sbsar
from ..common import (
Code_SbsarOp,
Code_Response,
ADDON_PACKAGE,
TOOLKIT_EXPECTED_VERSION
)
class SUBSTANCE_OT_LoadSBSAR(bpy.types.Operator, ImportHelper):
bl_idname = 'substance.load_sbsar'
bl_label = 'Load Substance Material'
bl_description = 'Open file browser to select a Substance 3D material'
bl_options = {'REGISTER'}
filename_ext = '.sbsar'
filter_glob: bpy.props.StringProperty(default='*.sbsar', options={'HIDDEN'}) # noqa
files: bpy.props.CollectionProperty(name='Substance 3D material files', type=bpy.types.OperatorFileListElement) # noqa
directory: bpy.props.StringProperty(subtype="DIR_PATH") # noqa
def __init__(self):
self.filepath = ''
@classmethod
def poll(cls, context):
_, _toolkit_version = SUBSTANCE_Api.toolkit_version_get()
return _toolkit_version is not None and _toolkit_version in TOOLKIT_EXPECTED_VERSION
def invoke(self, context, event):
if not SUBSTANCE_Api.currently_running:
# Initialize SUBSTANCE_Api
_result = SUBSTANCE_Api.initialize()
if _result[0] != Code_Response.success:
SUBSTANCE_Utils.log_data("ERROR", "[{}] The SRE cannot initialize...".format(_result))
return {'RUNNING_MODAL'}
_addon_prefs = context.preferences.addons[ADDON_PACKAGE].preferences
self.filepath = _addon_prefs.path_library
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
SUBSTANCE_Threads.cursor_push('WAIT')
for _f in self.files:
if _f.name.endswith('.sbsar'):
if len(_f.name) > 0:
_filepath = os.path.join(self.directory, _f.name)
load_sbsar(_f.name, _filepath)
else:
SUBSTANCE_Utils.log_data("ERROR", "[{}] is not a valid sbsar file".format(_f.name), display=True)
SUBSTANCE_Threads.cursor_pop()
return {'FINISHED'}
class SUBSTANCE_OT_ApplySBSAR(bpy.types.Operator):
bl_idname = 'substance.apply_sbsar'
bl_label = 'Apply the Substance 3D material'
@classmethod
def description(cls, context, properties):
_selected_geo = SUBSTANCE_Utils.get_selected_geo(context.selected_objects)
if len(context.scene.loaded_sbsars) > 0 and len(_selected_geo) > 0:
_selected_sbsar = context.scene.loaded_sbsars[context.scene.sbsar_index]
if _selected_sbsar.load_success:
return "Applies the selected Substance 3D material to the selected object(s)"
else:
return "This Substance 3D material loaded incorrectly"
else:
return "Please load at least one Substance file and select an object in the scene"
@classmethod
def poll(cls, context):
_selected_geo = SUBSTANCE_Utils.get_selected_geo(context.selected_objects)
if len(context.scene.loaded_sbsars) > 0 and len(_selected_geo) > 0:
_selected_sbsar = context.scene.loaded_sbsars[context.scene.sbsar_index]
return _selected_sbsar.load_success
return False
def execute(self, context):
_selected_graph = SUBSTANCE_Utils.get_selected_graph(context)
_material = SUBSTANCE_MaterialManager.get_existing_material(_selected_graph.material.name)
if _material is None:
SUBSTANCE_Utils.log_data(
"ERROR",
"Substance Material needs to finish rendering before applying",
display=True)
return {'FINISHED'}
_selected_objects = context.selected_objects
if len(_selected_objects) == 0:
SUBSTANCE_Utils.log_data(
"ERROR",
"Material [{}] cannot be added, there are no selected objects".format(_material.name),
display=True)
return {'FINISHED'}
SUBSTANCE_MaterialManager.apply_material(context, _selected_objects, _material)
SUBSTANCE_Utils.log_data(
"INFO",
"Material slot with substance [{}] added to the object".format(_material.name),
display=True)
return {'FINISHED'}
class SUBSTANCE_OT_RemoveSBSAR(bpy.types.Operator):
bl_idname = 'substance.remove_sbsar'
bl_label = 'Remove the Substance 3D material'
@classmethod
def description(cls, context, properties):
if len(context.scene.loaded_sbsars) > 0:
return "Remove the selected Substance 3D material"
else:
return "Please load at least one Substance file"
@classmethod
def poll(cls, context):
if len(context.scene.loaded_sbsars) > 0:
return True
return False
def execute(self, context):
_idx = context.scene.sbsar_index
_selected_sbsar = context.scene.loaded_sbsars[_idx]
_sbsar_uuid = _selected_sbsar.uuid
_sbsar_name = _selected_sbsar.name
context.scene.loaded_sbsars.remove(_idx)
if context.scene.sbsar_index != 0 and context.scene.sbsar_index >= len(context.scene.loaded_sbsars):
context.scene.sbsar_index = len(context.scene.loaded_sbsars) - 1
_result = SUBSTANCE_Api.sbsar_remove(_sbsar_uuid)
if _result[0] != Code_Response.success:
SUBSTANCE_Utils.log_data(
"ERROR",
"Substance [{}] could not be removed".format(_sbsar_name),
display=True)
if _result[1]["result"] != Code_Response.success.value:
SUBSTANCE_Utils.log_data(
"ERROR",
"An error ocurred while removing Substance [{}]. Failed with error [{}]".format(
_sbsar_name, _result[1]["result"]),
display=True)
_result = SUBSTANCE_Api.sbsar_unregister(_sbsar_uuid)
if _result[0] != Code_Response.success:
SUBSTANCE_Utils.log_data(
"ERROR",
"Classes of Substance [{}] could not be removed".format(_sbsar_name),
display=True)
context.area.tag_redraw()
SUBSTANCE_Utils.log_data("INFO", "Substance [{}] was removed correctly".format(_sbsar_name), display=True)
return {'FINISHED'}
class SUBSTANCE_OT_ReloadSBSAR(bpy.types.Operator):
bl_idname = 'substance.reload_sbsar'
bl_label = 'Reload the Substance 3D material'
@classmethod
def description(cls, context, properties):
if len(context.scene.loaded_sbsars) > 0:
return "Reload the selected Substance 3D material"
else:
return "Please load at least one Substance file"
@classmethod
def poll(cls, context):
if len(context.scene.loaded_sbsars) > 0:
return True
return False
def execute(self, context):
_idx = context.scene.sbsar_index
_selected_sbsar = context.scene.loaded_sbsars[_idx]
_sbsar_uuid = _selected_sbsar.uuid
_sbsar_name = _selected_sbsar.name
_sbsar_filename = _selected_sbsar.filename
_sbsar_filepath = _selected_sbsar.filepath
_addon_prefs, _selected_shader_idx, _selected_shader_preset = SUBSTANCE_Utils.get_selected_shader(context)
_normal_format = _addon_prefs.default_normal_format
_output_size = _addon_prefs.default_resolution.get()
# REMOVE OLD SBSAR
context.scene.loaded_sbsars.remove(_idx)
if context.scene.sbsar_index != 0 and context.scene.sbsar_index >= len(context.scene.loaded_sbsars):
context.scene.sbsar_index = len(context.scene.loaded_sbsars) - 1
_result = SUBSTANCE_Api.sbsar_remove(_sbsar_uuid)
if _result[0] != Code_Response.success:
SUBSTANCE_Utils.log_data(
"ERROR",
"Substance [{}] could not be removed".format(_sbsar_name),
display=True)
return {'FINISHED'}
if _result[1]["result"] != Code_Response.success.value:
SUBSTANCE_Utils.log_data(
"ERROR",
"An error ocurred while removing Substance [{}]. Failed with error [{}]".format(
_sbsar_name, _result[1]["result"]),
display=True)
return {'FINISHED'}
_result = SUBSTANCE_Api.sbsar_unregister(_sbsar_uuid)
if _result[0] != Code_Response.success:
SUBSTANCE_Utils.log_data(
"ERROR",
"Classes of Substance [{}] could not be removed".format(_sbsar_name),
display=True)
return {'FINISHED'}
# LOAD NEW SBSAR
SUBSTANCE_Utils.log_data("INFO", "Begin loading substance from file [{}]".format(_sbsar_filename))
_data = {
"filepath": _sbsar_filepath,
"name": _sbsar_name,
"$outputsize": _output_size,
"normal_format": 0 if _normal_format == "DirectX" else 1
}
# Load the sbsar to the SRE
_result = SUBSTANCE_Api.sbsar_load(_data)
if _result[0] != Code_Response.success:
SUBSTANCE_Utils.log_data(
"ERROR",
"Substance file [{}] located at [{}] could not be loaded".format(_sbsar_filename, _sbsar_filepath),
display=True)
return {'FINISHED'}
if _result[1]["result"] != Code_Response.success.value:
SUBSTANCE_Utils.log_data(
"ERROR",
"An error ocurred while loading Substance file [{}]. Failed with error [{}]".format(
_sbsar_filepath, _result[1]["result"]),
display=True)
return {'FINISHED'}
_sbsar = SBSAR(_sbsar_name, _sbsar_filename, _result[1]["data"], _addon_prefs)
_loaded_sbsar = context.scene.loaded_sbsars.add()
_loaded_sbsar.init(_sbsar)
_loaded_sbsar.init_shader(_selected_shader_idx, _selected_shader_preset, _addon_prefs.get_default_output())
SUBSTANCE_Api.sbsar_register(_sbsar)
context.scene.sbsar_index = len(context.scene.loaded_sbsars) - 1
for _idx, _graph in enumerate(_loaded_sbsar.graphs):
SUBSTANCE_Threads.alt_thread_run(_render_sbsar, (context, _loaded_sbsar, _idx))
SUBSTANCE_Utils.log_data("INFO", "Substance from file [{}] loaded".format(_sbsar_filename))
context.area.tag_redraw()
SUBSTANCE_Utils.log_data("INFO", "Substance [{}] was removed correctly".format(_sbsar_name), display=True)
return {'FINISHED'}
class SUBSTANCE_OT_DuplicateSBSAR(bpy.types.Operator):
bl_idname = 'substance.duplicate_sbsar'
bl_label = 'Duplicate the Substance 3D material'
@classmethod
def description(cls, context, properties):
if len(context.scene.loaded_sbsars) > 0:
_selected_sbsar = context.scene.loaded_sbsars[context.scene.sbsar_index]
if _selected_sbsar.load_success:
return "Duplicate the selected Substance 3D material"
else:
return "This Substance 3D material loaded incorrectly"
else:
return "Please load at least one Substance file"
@classmethod
def poll(cls, context):
if len(context.scene.loaded_sbsars) > 0:
return True
return False
def execute(self, context):
SUBSTANCE_Threads.cursor_push('WAIT')
_tmp_sbsar = context.scene.loaded_sbsars[context.scene.sbsar_index]
_sbsar_name = _tmp_sbsar.name
_sbsar = context.scene.loaded_sbsars[_sbsar_name]
_unique_name = SUBSTANCE_Utils.get_unique_name(_sbsar.name, context)
_data = {
"filepath": _sbsar.filepath,
"uuid": _sbsar.uuid,
"name": _unique_name
}
# Load the sbsar to the SRE
_result = SUBSTANCE_Api.sbsar_load(_data, operation=Code_SbsarOp.duplicate)
if _result[0] != Code_Response.success:
SUBSTANCE_Utils.log_data(
"ERROR",
"Substance file [{}] located at [{}] could not be loaded".format(_sbsar.filename, _sbsar.filepath),
display=True)
return {'FINISHED'}
if _result[1]["result"] != Code_Response.success.value:
SUBSTANCE_Utils.log_data(
"ERROR",
"An error ocurred while loading Substance file [{}]. Failed with error [{}]".format(
_sbsar.filepath, _result[1]["result"]),
display=True)
return {'FINISHED'}
_addon_prefs = bpy.context.preferences.addons[ADDON_PACKAGE].preferences
_new_sbsar = SBSAR(_unique_name, _sbsar.filename, _result[1]["data"], _addon_prefs)
for _graph_obj in _new_sbsar.graphs:
_presets = []
_preset_list = _sbsar.graphs[int(_graph_obj.index)].presets
for _item in _preset_list:
_obj = {
"index": int(_item.index),
"label": _item.label,
"value": _item.value,
"icon": _item.icon,
"embedded": _item.embedded
}
_presets.append(_obj)
_graph_obj.reset_presets(_presets)
_loaded_sbsar = context.scene.loaded_sbsars.add()
# Reset _sbsar reference otherwise we get a memory error
_sbsar = context.scene.loaded_sbsars[_sbsar_name]
_loaded_sbsar.init(_new_sbsar)
_loaded_sbsar.init_shader_duplicate(_sbsar)
_loaded_sbsar.init_tiling(_sbsar)
_loaded_sbsar.init_outputs(_sbsar)
_loaded_sbsar.init_presets(_sbsar)
SUBSTANCE_Api.sbsar_register(_new_sbsar)
context.scene.sbsar_index = len(context.scene.loaded_sbsars) - 1
for _idx, _graph in enumerate(_loaded_sbsar.graphs):
SUBSTANCE_Threads.alt_thread_run(_render_sbsar, (context, _loaded_sbsar, _idx))
SUBSTANCE_Utils.log_data(
"INFO",
"Substance [{}] duplicated succesfully".format(_sbsar.name),
display=True)
SUBSTANCE_Threads.cursor_pop()
return {'FINISHED'}
@@ -0,0 +1,151 @@
"""
Copyright (C) 2023 Adobe.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
# file: ops/shader.py
# brief: Shader Operators
# author Adobe - 3D & Immersive
# copyright 2023 Adobe Inc. All rights reserved.
import bpy
import os
from ..common import ADDON_ROOT, ADDON_PACKAGE, Code_Response
from ..api import SUBSTANCE_Api
from ..utils import SUBSTANCE_Utils
from ..shader.shader import ShaderPreset
class SUBSTANCE_OT_LoadShaderPresets(bpy.types.Operator):
bl_idname = 'substance.load_shader_presets'
bl_label = 'Initialize shader preset'
bl_description = "Initialize the shader presets"
def execute(self, context):
_addon_prefs = context.preferences.addons[ADDON_PACKAGE].preferences
_addon_prefs.shaders.clear()
_shaders_dir = os.path.join(ADDON_ROOT, "_presets/default").replace('\\', '/')
for _filepath in os.listdir(_shaders_dir):
# Load the shader presets
_result = SUBSTANCE_Api.shader_load(_filepath)
if _result[0] != Code_Response.success:
SUBSTANCE_Utils.log_data(
"ERROR",
"Substance file [{}] could not be loaded".format(
_filepath),
display=True)
continue
_shader_preset = ShaderPreset(_filepath, _result[1])
_new_shader = _addon_prefs.shaders.add()
_new_shader.init(_shader_preset)
SUBSTANCE_Api.shader_register(_shader_preset)
SUBSTANCE_Utils.log_data("INFO", "Shader Presets [{}] initialized...".format(_filepath))
return {'FINISHED'}
class SUBSTANCE_OT_RemoveShaderPresets(bpy.types.Operator):
bl_idname = 'substance.remove_shader_presets'
bl_label = 'Remove shader preset'
bl_description = "Remove the shader presets"
def execute(self, context):
_addon_prefs = context.preferences.addons[ADDON_PACKAGE].preferences
_result = SUBSTANCE_Api.shader_presets_remove(_addon_prefs.shaders)
if _result != Code_Response.success:
SUBSTANCE_Utils.log_data("ERROR", "Shader Presets could not be removed...")
_addon_prefs.shader_presets.clear()
return {'FINISHED'}
SUBSTANCE_Utils.log_data("INFO", "Shader Presets fully removed...")
_addon_prefs.shaders.clear()
return {'FINISHED'}
class SUBSTANCE_OT_SaveShaderPresets(bpy.types.Operator):
bl_idname = 'substance.save_shader_presets'
bl_label = 'Save shader preset'
bl_description = "Save the shader presets"
def execute(self, context):
_addon_prefs = context.preferences.addons[ADDON_PACKAGE].preferences
for _shader_preset in _addon_prefs.shaders:
if _shader_preset.modified:
_inputs = getattr(context.scene, _shader_preset.inputs_class_name)
_outputs = {}
for _output in _shader_preset.outputs:
_outputs[_output.id] = _output.to_json()
_obj = {
"label": _shader_preset.label,
"filename": _shader_preset.filename,
"inputs": _inputs.get(),
"outputs": _outputs,
}
_result = SUBSTANCE_Api.shader_presets_save(_obj)
if _result != Code_Response.success:
SUBSTANCE_Utils.log_data(
"ERROR",
"Shader Presets [{}] could not be saved...[{}]".format(_obj["label"], _result))
SUBSTANCE_Utils.log_data("INFO", "Shader Presets [{}] saved...".format(_obj["label"]))
return {'FINISHED'}
class SUBSTANCE_OT_ResetShaderPreset(bpy.types.Operator):
bl_idname = 'substance.reset_shader_preset'
bl_label = 'Reset shader preset'
bl_description = "Reset the shader preset"
@classmethod
def poll(cls, context):
_addons_prefs, _shader_idx, _shader = SUBSTANCE_Utils.get_selected_shader(context)
return _shader.modified
def execute(self, context):
_addons_prefs, _shader_idx, _shader = SUBSTANCE_Utils.get_selected_shader(context)
_inputs = getattr(context.scene, _shader.inputs_class_name)
_inputs.reset()
# Load the shader presets
_result = SUBSTANCE_Api.shader_load(_shader.filename)
if _result[0] != Code_Response.success:
SUBSTANCE_Utils.log_data(
"ERROR",
"Substance file [{}] could not be loaded".format(
_shader.filename),
display=True)
return {'FINISHED'}
_outputs = _result[1]["outputs"]
for _output in _shader.outputs:
_output.enabled = _outputs[_output.id]["enabled"]
_output.colorspace = _outputs[_output.id]["colorspace"]
_output.format = _outputs[_output.id]["format"]
_output.bitdepth = _outputs[_output.id]["bitdepth"]
_shader.modified = False
SUBSTANCE_Utils.log_data(
"INFO",
"Shader Presets [{}] is back to default values".format(_shader.label),
display=True)
return {'FINISHED'}
@@ -0,0 +1,62 @@
"""
Copyright (C) 2023 Adobe.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
# file: ops/sync.py
# brief: Sinstance Operators
# author Adobe - 3D & Immersive
# copyright 2023 Adobe Inc. All rights reserved.
import os
import bpy
from ..api import SUBSTANCE_Api
from ..utils import SUBSTANCE_Utils
from .common import load_sbsar, Code_RequestType, Code_Response
class SUBSTANCE_OT_SyncInitTools(bpy.types.Operator):
bl_idname = 'substance.init_tools_sync'
bl_label = 'Initialize Substance Tools'
def execute(self, context):
if not SUBSTANCE_Api.currently_running:
# Initialize SUBSTANCE_Api
_result = SUBSTANCE_Api.initialize()
if _result[0] != Code_Response.success:
SUBSTANCE_Utils.log_data("ERROR", "[{}] The SRE cannot initialize...".format(_result))
return {'FINISHED'}
class SUBSTANCE_OT_SyncLoadSBSAR(bpy.types.Operator):
bl_idname = 'substance.load_sbsar_sync'
bl_label = 'Load a Substance 3D file'
filepath: bpy.props.StringProperty(name="filepath", default="") # noqa
def execute(self, context):
if len(self.filepath) > 0:
if self.filepath.endswith('.sbsar'):
_filename = os.path.basename(self.filepath)
_filename = _filename.replace(".sbsar", "")
load_sbsar(_filename, self.filepath, type=Code_RequestType.r_sync)
else:
SUBSTANCE_Utils.log_data("ERROR", "[{}] is not a valid sbsar file".format(self.filepath))
else:
SUBSTANCE_Utils.log_data("ERROR", "[{}] is empty, is not a valid sbsar file".format(self.filepath))
return {'FINISHED'}
@@ -0,0 +1,142 @@
"""
Copyright (C) 2023 Adobe.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
# file: ops/toolkit.py
# brief:SRE Toolkit Operators
# author Adobe - 3D & Immersive
# copyright 2023 Adobe Inc. All rights reserved.
import os
import bpy
import pathlib
import subprocess
import platform
from bpy_extras.io_utils import ImportHelper
from ..api import SUBSTANCE_Api
from ..common import TOOLKIT_EXT, Code_Response, ADDON_PACKAGE, SRE_DIR
from ..thread_ops import SUBSTANCE_Threads
from ..utils import SUBSTANCE_Utils
class SUBSTANCE_OT_OpenTools(bpy.types.Operator):
bl_idname = 'substance.open_tools'
bl_label = 'Open tools folder'
bl_options = {'REGISTER'}
def execute(self, context):
_addon_prefs = context.preferences.addons[ADDON_PACKAGE].preferences
_result = _addon_prefs.path_tools
if platform.system() == "Windows":
os.startfile(_result)
elif platform.system() == "Darwin":
subprocess.Popen(["open", _result])
SUBSTANCE_Utils.log_data(
"INFO",
"Substance 3D Integration Tools folder [{}] opened correctly".format(_result),
display=True)
return {'FINISHED'}
class SUBSTANCE_OT_ResetTools(bpy.types.Operator):
bl_idname = 'substance.reset_tools'
bl_label = 'Reset tools folder'
bl_options = {'REGISTER'}
def execute(self, context):
_addon_prefs = context.preferences.addons[ADDON_PACKAGE].preferences
_home = pathlib.Path.home()
_result = os.path.join(_home, SRE_DIR)
_addon_prefs.path_tools = _result
SUBSTANCE_Utils.log_data(
"INFO",
"Substance 3D Integration Tools folder [{}] reset correctly".format(_result),
display=True)
return {'FINISHED'}
class SUBSTANCE_OT_InstallTools(bpy.types.Operator, ImportHelper):
bl_idname = 'substance.install_tools'
bl_label = 'Install tools'
bl_options = {'REGISTER'}
filter_glob: bpy.props.StringProperty(default='*{}'.format(TOOLKIT_EXT), options={'HIDDEN'}) # noqa
def __init__(self):
self.filepath = ''
def execute(self, context):
SUBSTANCE_Threads.cursor_push('WAIT')
_result = SUBSTANCE_Api.toolkit_install(self.filepath)
SUBSTANCE_Threads.cursor_pop()
if _result[0] == Code_Response.success:
SUBSTANCE_Utils.log_data("INFO", "Substance 3D Integration Tools installed correctly", display=True)
else:
SUBSTANCE_Utils.log_data(
"ERROR",
"[{}] Error while installing the Substance 3D Integration Tools".format(_result[0]),
display=True)
return {'FINISHED'}
class SUBSTANCE_OT_UpdateTools(bpy.types.Operator, ImportHelper):
bl_idname = 'substance.update_tools'
bl_label = 'Update tools'
bl_options = {'REGISTER'}
filter_glob: bpy.props.StringProperty(default='*{}'.format(TOOLKIT_EXT), options={'HIDDEN'}) # noqa
def __init__(self):
self.filepath = ''
def execute(self, context):
SUBSTANCE_Threads.cursor_push('WAIT')
_result = SUBSTANCE_Api.toolkit_install(self.filepath)
SUBSTANCE_Threads.cursor_pop()
if _result[0] == Code_Response.success:
SUBSTANCE_Utils.log_data("INFO", "Substance 3D Integration Tools updated correctly", display=True)
else:
SUBSTANCE_Utils.log_data(
"ERROR",
"[{}] Error while updating the Substance 3D Integration Tools".format(_result[0]),
display=True)
return {'FINISHED'}
class SUBSTANCE_OT_UninstallTools(bpy.types.Operator):
bl_idname = 'substance.uninstall_tools'
bl_label = 'UnInstall tools'
bl_options = {'REGISTER'}
def execute(self, context):
SUBSTANCE_Threads.cursor_push('WAIT')
_result = SUBSTANCE_Api.toolkit_uninstall()
SUBSTANCE_Threads.cursor_pop()
if _result[0] == Code_Response.success:
SUBSTANCE_Utils.log_data("INFO", "Substance 3D Integration Tools fully removed", display=True)
else:
SUBSTANCE_Utils.log_data(
"ERROR",
"[{}] Error while removing the Substance 3D Integration Tools".format(_result[0]),
display=True)
return {'FINISHED'}
@@ -0,0 +1,90 @@
"""
Copyright (C) 2023 Adobe.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
# file: ops/web.py
# brief: Web Operators
# author Adobe - 3D & Immersive
# copyright 2023 Adobe Inc. All rights reserved.
import bpy
from ..common import (
WEB_SUBSTANCE_TOOLS,
WEB_SUBSTANCE_SHARE,
WEB_SUBSTANCE_SOURCE,
WEB_SUBSTANCE_DOCS,
WEB_SUBSTANCE_FORUMS,
WEB_SUBSTANCE_DISCORD
)
class SUBSTANCE_OT_GoToWebsite(bpy.types.Operator):
bl_idname = 'substance.goto_web'
bl_label = 'Substance 3D Open Website'
bl_description = 'Go to Website'
bl_options = {'REGISTER'}
url: bpy.props.StringProperty()
def execute(self, context):
bpy.ops.wm.url_open(url=self.url)
return {'FINISHED'}
def invoke(self, context, event):
return self.execute(context)
class SUBSTANCE_OT_GetTools(SUBSTANCE_OT_GoToWebsite):
bl_idname = 'substance.goto_tools'
bl_label = 'Substance 3D Integration Tools'
bl_description = 'Go to Substance 3D Integration Tools'
url: bpy.props.StringProperty(default=WEB_SUBSTANCE_TOOLS)
class SUBSTANCE_OT_GoToShare(SUBSTANCE_OT_GoToWebsite):
bl_idname = 'substance.goto_share'
bl_label = 'Substance 3D Community Assets'
bl_description = 'Go to Substance 3D Community Assets'
url: bpy.props.StringProperty(default=WEB_SUBSTANCE_SHARE)
class SUBSTANCE_OT_GoToSource(SUBSTANCE_OT_GoToWebsite):
bl_idname = 'substance.goto_source'
bl_label = 'Substance 3D Assets'
bl_description = 'Go to Substance 3D Assets'
url: bpy.props.StringProperty(default=WEB_SUBSTANCE_SOURCE)
class SUBSTANCE_OT_GoToDocs(SUBSTANCE_OT_GoToWebsite):
bl_idname = 'substance.goto_docs'
bl_label = 'Substance Plugin for Blender Documentation'
bl_description = 'Go to Substance Plugin for Blender Documentation'
url: bpy.props.StringProperty(default=WEB_SUBSTANCE_DOCS)
class SUBSTANCE_OT_GoToForums(SUBSTANCE_OT_GoToWebsite):
bl_idname = 'substance.goto_forums'
bl_label = 'Substance 3D Forums'
bl_description = 'Go to Substance 3D Forums'
url: bpy.props.StringProperty(default=WEB_SUBSTANCE_FORUMS)
class SUBSTANCE_OT_GoToDiscord(SUBSTANCE_OT_GoToWebsite):
bl_idname = 'substance.goto_discord'
bl_label = 'Substance 3D Discord Server'
bl_description = 'Go to Substance 3D Discord Server'
url: bpy.props.StringProperty(default=WEB_SUBSTANCE_DISCORD)