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,2 @@
# blender_visual_scripting_addon
Visual Scripting addon for blender with nodes
@@ -0,0 +1,155 @@
# 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
# MERCHANTIBILITY 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/>.
bl_info = {
"name": "Serpens",
"author": "Joshua Knauber, Finn Knauber",
"description": "Adds a node editor for building addons with nodes",
"blender": (4, 2, 0),
"version": (3, 4, 0),
"location": "Editors -> Visual Scripting Editor",
"doc_url": "https://joshuaknauber.notion.site/Serpens-Documentation-d44c98df6af64d7c9a7925020af11233",
"tracker_url": "https://discord.com/invite/NK6kyae",
"category": "Node",
}
import bpy
from bpy.utils import previews
import atexit
import os
from .keymaps.keymap import register_keymaps, unregister_keymaps
from .node_tree.node_categories import (
draw_node_menu,
register_node_menus,
unregister_node_menus,
)
from .interface.header.header import (
footer_status,
header_prepend,
header_append,
node_info_append,
)
from .interface.panels.warnings import append_warning
from .interface.menus.rightclick import serpens_right_click
from .msgbus import subscribe_to_name_change, unsubscribe_from_name_change
from .settings.addon_properties import SN_AddonProperties
from . import handlers
from . import auto_load
auto_load.init()
def register_icons():
bpy.types.Scene.sn_icons = bpy.utils.previews.new()
icons_dir = os.path.join(os.path.dirname(__file__), "assets", "icons")
icons = ["discord", "serpens"]
for icon in icons:
bpy.types.Scene.sn_icons.load(
icon, os.path.join(icons_dir, icon + ".png"), "IMAGE"
)
def unregister_icons():
bpy.utils.previews.remove(bpy.types.Scene.sn_icons)
def register():
# register the classes of the addon
auto_load.register()
# addon properties
bpy.types.Scene.sn = bpy.props.PointerProperty(
type=SN_AddonProperties, name="Serpens Properties"
)
# register the keymaps
register_keymaps()
# register the icons
register_icons()
# register node categories
register_node_menus()
bpy.types.NODE_MT_add.append(draw_node_menu)
# add the node tree header
bpy.types.NODE_HT_header.append(header_append)
bpy.types.NODE_MT_editor_menus.append(header_prepend)
bpy.types.NODE_PT_active_node_generic.append(node_info_append)
bpy.types.STATUSBAR_HT_header.append(footer_status)
# add no edit warnings
bpy.types.NODE_PT_node_tree_interface.append(append_warning)
# add name change update
subscribe_to_name_change()
# app handlers
bpy.app.handlers.depsgraph_update_post.append(handlers.depsgraph_handler)
bpy.app.handlers.load_post.append(handlers.load_handler)
bpy.app.handlers.load_pre.append(handlers.unload_handler)
bpy.app.handlers.undo_post.append(handlers.undo_post)
bpy.app.handlers.save_pre.append(handlers.save_pre)
atexit.register(handlers.unload_handler)
# add right click menu
bpy.types.WM_MT_button_context.append(serpens_right_click)
def unregister():
# remove the node tree header
bpy.types.NODE_MT_editor_menus.remove(header_prepend)
bpy.types.NODE_HT_header.remove(header_append)
bpy.types.NODE_PT_active_node_generic.remove(node_info_append)
bpy.types.STATUSBAR_HT_header.remove(footer_status)
# remove no edit warnings
bpy.types.NODE_PT_node_tree_interface.remove(append_warning)
# addon properties
del bpy.types.Scene.sn
# unregister the keymaps
unregister_keymaps()
# unregister the icons
unregister_icons()
# unregister node categories
bpy.types.NODE_MT_add.remove(draw_node_menu)
unregister_node_menus()
# remove handlers
bpy.app.handlers.depsgraph_update_post.remove(handlers.depsgraph_handler)
bpy.app.handlers.load_post.remove(handlers.load_handler)
bpy.app.handlers.load_pre.remove(handlers.unload_handler)
bpy.app.handlers.undo_post.remove(handlers.undo_post)
bpy.app.handlers.save_pre.remove(handlers.save_pre)
atexit.unregister(handlers.unload_handler)
# remove name change msgbus
unsubscribe_from_name_change()
# remove right click menu
if hasattr(bpy.types, "WM_MT_button_context"):
bpy.types.WM_MT_button_context.remove(serpens_right_click)
# unregister the addon classes
auto_load.unregister()
@@ -0,0 +1,186 @@
import bpy
from bpy_extras.io_utils import ImportHelper
import os
class SN_OT_AddAsset(bpy.types.Operator):
bl_idname = "sn.add_asset"
bl_label = "Add Asset"
bl_description = "Adds a asset to the addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
add_type: bpy.props.EnumProperty(default="FILE",
items=[("FILE", "File", "Import a single file"),
("DIRECTORY", "Directory", "Import a full directory")],
name="Type",
description="Add this directory or this file as an asset",
options={"SKIP_SAVE"})
def execute(self, context):
bpy.ops.sn.load_asset("INVOKE_DEFAULT", add_type=self.add_type)
return {"FINISHED"}
def draw(self, context):
layout = self.layout
layout.prop(self, "add_type", expand=True)
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self, width=200)
class SN_OT_LoadAsset(bpy.types.Operator, ImportHelper):
bl_idname = "sn.load_asset"
bl_label = "Add Asset"
bl_description = "Adds a asset to the addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
add_type: bpy.props.EnumProperty(default="FILE",
items=[("FILE", "File", "Import a single file"),
("DIRECTORY", "Directory", "Import a full directory")],
name="Type",
description="Add this directory or this file as an asset",
options={"SKIP_SAVE"})
def execute(self, context):
sn = context.scene.sn
new_asset = sn.assets.add()
if self.add_type == "DIRECTORY":
new_asset.path = os.path.dirname(self.filepath)
else:
new_asset.path = self.filepath
for index, asset in enumerate(sn.assets):
if asset == new_asset:
sn.asset_index = index
return {"FINISHED"}
class SN_OT_RemoveAsset(bpy.types.Operator):
bl_idname = "sn.remove_asset"
bl_label = "Remove Asset"
bl_description = "Removes this asset from the addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context):
return context.scene.sn.asset_index < len(context.scene.sn.assets)
def execute(self, context):
sn = context.scene.sn
asset = sn.assets[sn.asset_index]
# remove removed from asset nodes
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
for node in ntree.nodes:
if node.bl_idname == "SN_AssetNode":
if node.asset == asset.name:
node.asset = ""
# remove asset
sn.assets.remove(sn.asset_index)
sn.asset_index -= 1
return {"FINISHED"}
class SN_OT_FindNode(bpy.types.Operator):
bl_idname = "sn.find_node"
bl_label = "Find Node"
bl_description = "Find Node"
bl_options = {"REGISTER", "INTERNAL"}
node_tree: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
node: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
def execute(self, context):
ntree = bpy.data.node_groups[self.node_tree]
# set active graph and select
context.space_data.node_tree = ntree
for index, group in enumerate(bpy.data.node_groups):
if group == ntree:
context.scene.sn.node_tree_index = index
# select node and frame
for node in ntree.nodes:
node.select = node.name == self.node
bpy.ops.node.view_selected("INVOKE_DEFAULT")
return {"FINISHED"}
class SN_OT_FindAsset(bpy.types.Operator):
bl_idname = "sn.find_asset"
bl_label = "Find Asset"
bl_description = "Finds this asset in the addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context):
return {"FINISHED"}
def draw(self, context):
layout = self.layout
# init asset nodes
empty_nodes = []
asset_nodes = []
asset = None
if context.scene.sn.asset_index < len(context.scene.sn.assets):
asset = context.scene.sn.assets[context.scene.sn.asset_index]
# find assets nodes
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
for node in ntree.nodes:
if node.bl_idname == "SN_AssetNode":
if asset and node.asset == asset.name:
asset_nodes.append(node)
elif not node.asset:
empty_nodes.append(node)
# draw nodes for selected asset
if context.scene.sn.asset_index < len(context.scene.sn.assets):
col = layout.column()
row = col.row()
row.enabled = False
row.label(text=f"Asset: {asset.name}")
for node in asset_nodes:
op = col.operator("sn.find_node", text=node.name, icon="RESTRICT_SELECT_OFF")
op.node_tree = node.node_tree.name
op.node = node.name
if not asset_nodes:
col.label(text="No nodes found for this asset", icon="INFO")
# draw nodes with empty asset
col = layout.column()
row = col.row()
row.label(text="Empty Asset Nodes")
row.enabled = False
for node in empty_nodes:
op = col.operator("sn.find_node", text=node.name, icon="RESTRICT_SELECT_OFF")
op.node_tree = node.node_tree.name
op.node = node.name
if not empty_nodes:
col.label(text="No empty asset nodes found", icon="INFO")
def invoke(self, context, event):
return context.window_manager.invoke_popup(self, width=250)
class SN_OT_AddAssetNode(bpy.types.Operator):
bl_idname = "sn.add_asset_node"
bl_label = "Add Asset Node"
bl_description = "Adds an asset node"
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
bpy.ops.node.add_node("INVOKE_DEFAULT", type="SN_AssetNode", use_transform=True)
node = context.space_data.node_tree.nodes.active
if context.scene.sn.asset_index < len(context.scene.sn.assets):
node.asset = context.scene.sn.assets[context.scene.sn.asset_index].name
return {"FINISHED"}
@@ -0,0 +1,47 @@
import bpy
import os
class SN_AssetProperties(bpy.types.PropertyGroup):
def get_to_update(self):
to_update = []
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
for node in ntree.nodes:
if node.bl_idname == "SN_AssetNode":
if node.asset == self.name:
to_update.append(node)
return to_update
def update_file_path(self, context):
if not self.path == bpy.path.abspath(self.path):
self["path"] = bpy.path.abspath(self.path)
else:
if self.name == "Asset" and self.path:
self.name = os.path.basename(self.path)
for node in self.get_to_update():
node._evaluate(context)
def get_asset_name(self):
return self.get("name", "Asset")
def set_asset_name(self, new_name):
# update asset nodes that had this asset
to_update = self.get_to_update()
self["name"] = new_name
for node in to_update:
node.asset = new_name
name: bpy.props.StringProperty(name="Name",
description="Name of the asset",
default="Asset",
get=get_asset_name,
set=set_asset_name)
path: bpy.props.StringProperty(name="Path",
description="Path to the asset file",
subtype="FILE_PATH",
update=update_file_path)
@@ -0,0 +1,31 @@
import bpy
from .property_utils import get_sorted_props
def property_imperative_code():
""" Returns the imperative code for all properties """
props = get_sorted_props(bpy.context.scene.sn.properties.values())
imperative = ""
for prop in props:
imperative += prop.imperative_code + "\n"
return imperative
def property_register_code():
""" Returns the register code for all properties """
props = get_sorted_props(bpy.context.scene.sn.properties.values())
register = ""
for prop in props:
register += prop.register_code + "\n"
return register
def property_unregister_code():
""" Returns the unregister code for all properties """
props = get_sorted_props(bpy.context.scene.sn.properties.values())
props.reverse()
unregister = ""
for prop in props:
unregister += prop.unregister_code + "\n"
return unregister
@@ -0,0 +1,115 @@
import bpy
from ...nodes.compiler import compile_addon
from .property_basic import BasicProperty
from .settings.settings import id_items, id_data, property_icons
from .settings.group import SN_PT_GroupProperty
class FullBasicProperty(BasicProperty):
property_type: bpy.props.EnumProperty(name="Type",
description="The type of data this property can store",
update=BasicProperty.trigger_reference_update,
items=[("String", "String", "Stores text, can display a text input or a filepath field", property_icons["String"], 0),
("Boolean", "Boolean", "Stores True or False, can be used for a checkbox", property_icons["Boolean"], 1),
("Float", "Float", "Stores a decimal number or a vector", property_icons["Float"], 2),
("Integer", "Integer", "Stores an integer number or a vector", property_icons["Integer"], 3),
("Enum", "Enum", "Stores multiple entries to be used as dropdowns", property_icons["Enum"], 4),
("Pointer", "Pointer", "Stores a reference to certain types of blend data, collection or group properties", property_icons["Pointer"], 5),
("Collection", "Collection", "Stores a list of certain blend data or property groups to be displayed in lists", property_icons["Collection"], 6),
("Group", "Group", "Stores multiple properties to be used in a collection or pointer property", property_icons["Group"], 7)])
@property
def settings(self):
return {
"String": self.stngs_string,
"Boolean": self.stngs_boolean,
"Float": self.stngs_float,
"Integer": self.stngs_integer,
"Enum": self.stngs_enum,
"Pointer": self.stngs_pointer,
"Collection": self.stngs_collection,
"Group": self.stngs_group,
}[self.property_type]
stngs_group: bpy.props.PointerProperty(type=SN_PT_GroupProperty)
class SN_GeneralProperties(FullBasicProperty, bpy.types.PropertyGroup):
is_scene_prop = True
def draw(self, context, layout):
""" Draws the general property settings """
row = layout.row()
row.prop(self, "property_type")
row.operator("sn.tooltip", text="", emboss=False, icon="QUESTION").text = self.settings.type_description
if not self.property_type == "Group":
layout.prop(self, "attach_to")
layout.prop(self, "description")
if self.property_type in {"Float", "Integer", "Boolean"}:
layout.prop_enum(self, "prop_options", "ANIMATABLE", text="Animatable")
elif self.property_type == "String":
layout.prop_enum(self, "prop_options", "TEXTEDIT_UPDATE", text="Textedit Update")
@property
def data_path(self):
return f"{self.attach_to.upper()}_PLACEHOLDER.{self.python_name}"
@property
def register_code(self):
self.python_name
# register non group properties
if not self.property_type == "Group":
code = f"bpy.types.{self.attach_to}.{self.python_name} = bpy.props.{self.settings.prop_type_name}(name='{self.name}', description='{self.description}',{self.get_prop_options} {self.settings.register_options})"
# register group properties
else:
code = f"bpy.utils.register_class(SNA_GROUP_{self.python_name})"
return code
@property
def unregister_code(self):
# unregister non group properties
if not self.property_type == "Group":
return f"del bpy.types.{self.attach_to}.{self.python_name}"
# unregister group properties
else:
return f"bpy.utils.unregister_class(SNA_GROUP_{self.python_name})"
@property
def imperative_code(self):
if hasattr(self.settings, "imperative_code"):
return self.settings.imperative_code()
return ""
def compile(self, context=None):
""" Registers the property and unregisters previous version """
# print(f"Serpens Log: Property {self.name} received an update")
compile_addon()
def get_attach_to_items(self, context):
items = []
for item in id_items:
items.append((item, item, item))
return items
def get_attach_data(self):
return id_data[self.attach_to]
attach_to: bpy.props.EnumProperty(name="Attach To",
description="The type of blend data to attach this property to",
items=get_attach_to_items,
update=FullBasicProperty.trigger_reference_update)
def copy(self):
new_prop = super().copy()
new_prop.attach_to = self.attach_to
return new_prop
@@ -0,0 +1,292 @@
import bpy
from ...utils import get_python_name, unique_collection_name
from .settings.settings import property_icons
from .settings.string import SN_PT_StringProperty
from .settings.boolean import SN_PT_BooleanProperty
from .settings.float import SN_PT_FloatProperty
from .settings.integer import SN_PT_IntegerProperty
from .settings.enum import SN_PT_EnumProperty
from .settings.pointer import SN_PT_PointerProperty
from .settings.collection import SN_PT_CollectionProperty
_prop_collection_cache = {} # stores key, value of prop.as_pointer, prop collection
_prop_origin_cache = {} # stores key, value of prop.as_pointer, prop collection origin
class BasicProperty():
def draw(self, context, layout):
""" Draws the general property settings """
row = layout.row()
row.prop(self, "property_type")
row.operator("sn.tooltip", text="", emboss=False, icon="QUESTION").text = self.settings.type_description
layout.prop(self, "description")
col = layout.column(align=True)
col.prop_enum(self, "prop_options", "HIDDEN", text="Hidden")
col.prop_enum(self, "prop_options", "SKIP_SAVE", text="Skip Save")
if self.property_type in {"Float", "Integer", "Boolean"}:
col.prop_enum(self, "prop_options", "ANIMATABLE", text="Animatable")
if self.property_type == "String":
col.prop_enum(self, "prop_options", "TEXTEDIT_UPDATE", text="Textedit Update")
# cache python names so they only have to be generated once
cached_python_names = {}
cached_python_name: bpy.props.StringProperty()
cached_human_name: bpy.props.StringProperty()
@property
def python_name(self):
if self.name == self.cached_human_name and self.cached_python_name: return self.cached_python_name
if self.name in self.cached_python_names: return self.cached_python_names[self.name]
names = []
for prop in self.prop_collection:
if prop == self:
break
names.append(prop.python_name)
name = unique_collection_name(f"sna_{get_python_name(self.name, 'new_property')}", "new_property", names, "_")
try:
self.cached_python_name = name
self.cached_human_name = self.name
except AttributeError: pass
self.cached_python_names[self.name] = name
return name
@property
def get_prop_options(self):
options = ""
if self.prop_options:
options = " options={" + ", ".join(map(lambda option: f"'{option}'", list(self.prop_options))) + "},"
return options
@property
def register_code(self):
code = f"{self.python_name}: bpy.props.{self.settings.prop_type_name}(name='{self.name}', description='{self.description}',{self.get_prop_options} {self.settings.register_options})"
if hasattr(self.settings, "register_code"):
return self.settings.register_code(code)
return code
@property
def prop_collection(self):
""" Returns the collection this property lives in """
if self.id_data.bl_rna.identifier == "ScriptingNodesTree":
# find property in nodes to return
if not str(self.as_pointer()) in _prop_collection_cache:
for node in self.id_data.nodes:
if hasattr(node, "properties"):
for prop in node.properties:
if prop == self:
_prop_collection_cache[str(self.as_pointer())] = node.properties
break
elif prop.property_type == "Group":
for subprop in prop.settings.properties:
if subprop == self:
_prop_collection_cache[str(self.as_pointer())] = prop.settings.properties
break
return _prop_collection_cache[str(self.as_pointer())]
else:
path = "[".join(repr(self.path_resolve("name", False)).split("[")[:-1])
if path.endswith("scenes"):
path = "bpy.context.scene.sn.properties"
coll = eval(path)
return coll
@property
def prop_collection_origin(self):
""" Returns the source where the main property collection lives """
if self.id_data.bl_rna.identifier == "ScriptingNodesTree":
# find property in nodes to return
if not str(self.as_pointer()) in _prop_origin_cache:
for node in self.id_data.nodes:
if hasattr(node, "properties"):
for prop in node.properties:
if prop == self:
_prop_origin_cache[str(self.as_pointer())] = node
break
elif prop.property_type == "Group":
for subprop in prop.settings.properties:
if subprop == self:
_prop_origin_cache[str(self.as_pointer())] = node
break
return _prop_origin_cache[str(self.as_pointer())]
else:
parent_path = repr(self.path_resolve("name", False)).split("properties")[0][:-1]
parent = eval(parent_path)
return parent
@property
def full_prop_path(self):
""" Returns the full data path for this property """
main_prop_path = f"{repr(self.prop_collection_origin)}.properties"
if hasattr(self, "group_prop_parent"):
main_prop_path += f"['{self.group_prop_parent.name}'].settings.properties"
main_prop_path += f"['{self.name}']"
return main_prop_path
def _compile(self, context=None):
""" Update the property with the parent classes compile function """
if hasattr(self, "compile"):
self.compile(context)
def get_name(self):
return self.get("name", "Prop Default")
def get_unique_name(self, value):
names = list(map(lambda item: item.name, list(filter(lambda item: item!=self, self.prop_collection))))
return unique_collection_name(value, "New Property", names, " ")
def set_name(self, value):
value = self.get_unique_name(value)
# get nodes to update references
to_update_nodes = []
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
for node in ntree.nodes:
if getattr(node, "prop_name", None) == self.name or getattr(node, "prop_group", None) == self.name:
if self.property_type == "Group":
if hasattr(node, "get_prop_source") and node.get_prop_source() == self.settings:
to_update_nodes.append((node, "prop_group"))
else:
if hasattr(node, "get_prop_source") and node.get_prop_source() and node.get_prop_source().properties == self.prop_collection and self.name in node.get_prop_source().properties:
to_update_nodes.append((node, "prop_name"))
# get properties to update references
to_update_props = []
if self.property_type == "Group":
for prop in self.prop_collection:
if prop.property_type in ["Pointer", "Collection"] and prop.settings.prop_group == self.name:
to_update_props.append(prop)
elif prop.property_type == "Group" and prop != self:
for subprop in prop.settings.properties:
if subprop.property_type in ["Pointer", "Collection"] and subprop.settings.prop_group == self.name:
to_update_props.append(subprop)
# set value
self["name"] = value
# update property references
for prop in to_update_props:
prop.settings.prop_group = value
for node, key in to_update_nodes:
setattr(node, key, value)
name: bpy.props.StringProperty(name="Property Name",
description="Name of this property",
default="Prop Default",
get=get_name,
set=set_name,
update=_compile)
description: bpy.props.StringProperty(name="Description",
description="The description of this property, shown in tooltips",
update=_compile)
@property
def icon(self):
return property_icons[self.property_type]
def trigger_reference_update(self, context):
# get nodes to update references
to_update_nodes = []
key = "prop_group" if self.property_type == "Group" else "prop_name"
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
for node in ntree.nodes:
if hasattr(node, key) and getattr(node, key) == self.name:
to_update_nodes.append((node, key))
for node, key in to_update_nodes:
# trigger an update on the affected nodes
setattr(node, key, self.name)
self._compile()
def get_types(self, context):
items = [("String", "String", "Stores text, can display a text input or a filepath field", property_icons["String"], 0),
("Boolean", "Boolean", "Stores True or False, can be used for a checkbox", property_icons["Boolean"], 1),
("Float", "Float", "Stores a decimal number or a vector", property_icons["Float"], 2),
("Integer", "Integer", "Stores an integer number or a vector", property_icons["Integer"], 3),
("Enum", "Enum", "Stores multiple entries to be used as dropdowns", property_icons["Enum"], 4),
("Pointer", "Pointer", "Stores a reference to certain types of blend data, collection or group properties", property_icons["Pointer"], 5),
("Collection", "Collection", "Stores a list of certain blend data or property groups to be displayed in lists", property_icons["Collection"], 6)]
if not self.allow_pointers:
items.pop(5)
return items
property_type: bpy.props.EnumProperty(name="Type",
description="The type of data this property can store",
update=trigger_reference_update,
items=get_types)
allow_pointers: bpy.props.BoolProperty(default=True)
def get_prop_option_items(self, context):
items = [("HIDDEN", "Hidden", "Hide property from operator popups"),
("SKIP_SAVE", "Skip Save", "Don't save this property between calls"),
("ANIMATABLE", "Animatable", "Enable if this property should be animatable"),
("TEXTEDIT_UPDATE", "Textedit Update", "Calls the update function every time the property is edited (Only string properties; not operator popups)")]
return items
prop_options: bpy.props.EnumProperty(name="Options",
description="Options for this property",
options={"ENUM_FLAG"},
items=get_prop_option_items,
update=_compile)
@property
def settings(self):
return {
"String": self.stngs_string,
"Boolean": self.stngs_boolean,
"Float": self.stngs_float,
"Integer": self.stngs_integer,
"Enum": self.stngs_enum,
"Pointer": self.stngs_pointer,
"Collection": self.stngs_collection,
}[self.property_type]
stngs_string: bpy.props.PointerProperty(type=SN_PT_StringProperty)
stngs_boolean: bpy.props.PointerProperty(type=SN_PT_BooleanProperty)
stngs_float: bpy.props.PointerProperty(type=SN_PT_FloatProperty)
stngs_integer: bpy.props.PointerProperty(type=SN_PT_IntegerProperty)
stngs_enum: bpy.props.PointerProperty(type=SN_PT_EnumProperty)
stngs_pointer: bpy.props.PointerProperty(type=SN_PT_PointerProperty)
stngs_collection: bpy.props.PointerProperty(type=SN_PT_CollectionProperty)
category: bpy.props.StringProperty(name="Category", default="OTHER",
description="The category this property is displayed in")
def match_settings(self, new_prop):
new_prop["name"] = self.get_unique_name(self.get("name"))
new_prop["property_type"] = self.get("property_type")
new_prop["description"] = self.get("description")
new_prop["allow_pointers"] = self.get("allow_pointers")
new_prop["prop_options"] = self.get("prop_options")
new_prop["category"] = self.get("category")
for attr in self.settings.copy_attributes:
new_prop.settings[attr] = self.settings.get(attr)
self.settings.copy(new_prop.settings)
def copy(self):
new_prop = self.prop_collection.add()
self.match_settings(new_prop)
return new_prop
@@ -0,0 +1,135 @@
import bpy
from ...interface.panels.property_ui_list import get_selected_property
class SN_PropertyCategory(bpy.types.PropertyGroup):
def set_name(self, value):
for prop in bpy.context.scene.sn.properties:
if prop.category and prop.category == self.name:
prop.category = value
self["name"] = value
def get_name(self):
return self.get("name", "New Category")
name: bpy.props.StringProperty(name="Name", default="New Category",
description="The name of this property category",
set=set_name, get=get_name)
class SN_OT_AddPropertyCategory(bpy.types.Operator):
bl_idname = "sn.add_property_category"
bl_label = "Add Property Category"
bl_description = "Adds a property category"
bl_options = {"REGISTER", "INTERNAL", "UNDO"}
def execute(self, context):
context.scene.sn.property_categories.add()
return {"FINISHED"}
class SN_OT_RemovePropertyCategory(bpy.types.Operator):
bl_idname = "sn.remove_property_category"
bl_label = "Remove Property Category"
bl_description = "Removes a property category"
bl_options = {"REGISTER", "INTERNAL", "UNDO"}
index: bpy.props.IntProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
context.scene.sn.property_categories.remove(self.index)
return {"FINISHED"}
class SN_OT_EditPropertyCategories(bpy.types.Operator):
bl_idname = "sn.edit_property_categories"
bl_label = "Edit Property Categories"
bl_description = "Edit the addon property categories"
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
return {"FINISHED"}
def draw(self, context):
layout = self.layout
layout.label(text="Categories")
for i, cat in enumerate(context.scene.sn.property_categories):
row = layout.row()
row.scale_y = 1.2
row.prop(cat, "name", text="")
row.operator("sn.remove_property_category", text="", icon="REMOVE", emboss=False).index = i
if not context.scene.sn.property_categories:
row = layout.row()
row.enabled = False
row.label(text="No categories added", icon="ERROR")
row = layout.row()
row.scale_y = 1.2
row.operator("sn.add_property_category", text="Add Category", icon="ADD")
def invoke(self, context, event):
return context.window_manager.invoke_popup(self, width=250)
class SN_OT_MovePropertyToCategory(bpy.types.Operator):
bl_idname = "sn.move_property_to_category"
bl_label = "Move Property Category"
bl_description = "Move the selected property to a different category"
bl_options = {"REGISTER", "INTERNAL"}
category: bpy.props.IntProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
prop = get_selected_property()
if prop:
if self.category == -1:
prop.category = "OTHER"
else:
prop.category = context.scene.sn.property_categories[self.category].name
context.area.tag_redraw()
return {"FINISHED"}
class SN_OT_MovePropertyCategory(bpy.types.Operator):
bl_idname = "sn.move_property_category"
bl_label = "Move Property Category"
bl_description = "Move the selected property to a different category"
bl_options = {"REGISTER", "INTERNAL"}
index: bpy.props.IntProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
return {"FINISHED"}
def draw(self, context):
layout = self.layout
prop = get_selected_property()
layout.label(text="Categories")
for i, cat in enumerate(context.scene.sn.property_categories):
row = layout.row()
row.enabled = prop != None and prop.category != cat.name
row.scale_y = 1.2
row.operator("sn.move_property_to_category", text=f"Move to '{cat.name}'", icon="FORWARD").category = i
row = layout.row()
row.enabled = prop != None and prop.category and prop.category != "OTHER"
row.scale_y = 1.2
row.operator("sn.move_property_to_category", text=f"Remove Category", icon="REMOVE").category = -1
if not len(context.scene.sn.property_categories):
row = layout.row()
row.enabled = False
row.label(text="No categories added", icon="ERROR")
def invoke(self, context, event):
context.scene.sn.property_index = self.index
return context.window_manager.invoke_popup(self, width=250)
@@ -0,0 +1,333 @@
import bpy
from ...nodes.compiler import compile_addon
from ...interface.panels.property_ui_list import get_selected_property, get_selected_property_offset
class SN_OT_AddProperty(bpy.types.Operator):
bl_idname = "sn.add_property"
bl_label = "Add Property"
bl_description = "Adds a property to the addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context):
sn = context.scene.sn
new_prop = sn.properties.add()
new_prop.name = "New Property"
if sn.active_prop_category: new_prop.category = sn.active_prop_category
for index, property in enumerate(sn.properties):
if property == new_prop:
sn.property_index = index
return {"FINISHED"}
class SN_OT_RemoveProperty(bpy.types.Operator):
bl_idname = "sn.remove_property"
bl_label = "Remove Property"
bl_description = "Removes this property from the addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context):
return context.scene.sn.property_index < len(context.scene.sn.properties)
def execute(self, context):
sn = context.scene.sn
sn.properties.remove(sn.property_index)
sn.property_index -= 1
compile_addon()
return {"FINISHED"}
class SN_OT_RemoveGroupProperty(bpy.types.Operator):
bl_idname = "sn.remove_group_property"
bl_label = "Remove Property"
bl_description = "Removes this property from the addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
group_items_path: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
index: bpy.props.IntProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
items = eval(self.group_items_path)
items.remove(self.index)
return {"FINISHED"}
class SN_OT_MoveProperty(bpy.types.Operator):
bl_idname = "sn.move_property"
bl_label = "Move Property"
bl_description = "Moves this property"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
move_up: bpy.props.BoolProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
sn = context.scene.sn
if self.move_up:
before = get_selected_property_offset(-1)
new_index = list(sn.properties).index(before)
sn.properties.move(sn.property_index, new_index)
sn.property_index = new_index
else:
after = get_selected_property_offset(1)
new_index = list(sn.properties).index(after)
sn.properties.move(sn.property_index, new_index)
sn.property_index = new_index
return {"FINISHED"}
class SN_OT_DuplicateProperty(bpy.types.Operator):
bl_idname = "sn.duplicate_property"
bl_label = "Duplicate Property"
bl_description = "Duplicates the selected property"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context):
sn = context.scene.sn
prop = get_selected_property()
if prop:
prop.copy()
sn.properties.move(len(sn.properties)-1, sn.property_index+1)
sn.property_index += 1
return {"FINISHED"}
class SN_OT_MoveGroupProperty(bpy.types.Operator):
bl_idname = "sn.move_group_property"
bl_label = "Move Property"
bl_description = "Moves this property"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
group_items_path: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
index: bpy.props.IntProperty(options={"SKIP_SAVE", "HIDDEN"})
move_up: bpy.props.BoolProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
items = eval(self.group_items_path)
if self.move_up:
items.move(self.index, self.index - 1)
else:
items.move(self.index, self.index + 1)
return {"FINISHED"}
class SN_OT_CopyPythonName(bpy.types.Operator):
bl_idname = "sn.copy_python_name"
bl_label = "Copy Python Name"
bl_description = "Copies the python name of this item to use in scripts"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
name: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
context.window_manager.clipboard = self.name
self.report({"INFO"}, message="Copied!")
return {"FINISHED"}
class SN_OT_AddEnumItem(bpy.types.Operator):
bl_idname = "sn.add_enum_item"
bl_label = "Add Enum Item"
bl_description = "Adds an enum item to this property"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
item_data_path: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
items = eval(self.item_data_path)
item = items.add()
item.update(context)
return {"FINISHED"}
class SN_OT_RemoveEnumItem(bpy.types.Operator):
bl_idname = "sn.remove_enum_item"
bl_label = "Remove Enum Item"
bl_description = "Removes an enum item from this property"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
settings_data_path: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
item_index: bpy.props.IntProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
settings = eval(self.settings_data_path)
settings.items.remove(self.item_index)
settings.compile(context)
return {"FINISHED"}
class SN_OT_MoveEnumItem(bpy.types.Operator):
bl_idname = "sn.move_enum_item"
bl_label = "Move Enum Item"
bl_description = "Moves this enum item"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
settings_data_path: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
item_index: bpy.props.IntProperty(options={"SKIP_SAVE", "HIDDEN"})
move_up: bpy.props.BoolProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
settings = eval(self.settings_data_path)
if self.move_up:
settings.items.move(self.item_index, self.item_index-1)
else:
settings.items.move(self.item_index, self.item_index+1)
settings.compile(context)
return {"FINISHED"}
class SN_OT_AddPropertyItem(bpy.types.Operator):
bl_idname = "sn.add_property_item"
bl_label = "Add Property"
bl_description = "Adds a property to this group"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
group_data_path: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
prop = eval(self.group_data_path)
new_prop = prop.settings.properties.add()
new_prop.name = "New Property"
return {"FINISHED"}
class SN_OT_AddPropertyNodePopup(bpy.types.Operator):
bl_idname = "sn.add_property_node_popup"
bl_label = "Add Property Node Popup"
bl_description = "Opens a popup to let you choose a property node"
bl_options = {"REGISTER", "INTERNAL"}
node: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
return {"FINISHED"}
def draw(self, context):
layout = self.layout
col = layout.column(align=True)
col.scale_y = 1.5
op = col.operator("sn.add_property_node", text="Property", icon="ADD")
op.type = "SN_SerpensPropertyNode"
op.node = self.node
op = col.operator("sn.add_property_node", text="Display Property", icon="ADD")
op.type = "SN_DisplayPropertyNodeNew"
op.node = self.node
op = col.operator("sn.add_property_node", text="Set Property", icon="ADD")
op.type = "SN_SetPropertyNode"
op.node = self.node
op = col.operator("sn.add_property_node", text="On Property Update", icon="ADD")
op.type = "SN_OnPropertyUpdateNode"
op.node = self.node
def invoke(self, context, event):
return context.window_manager.invoke_popup(self)
class SN_OT_AddPropertyNode(bpy.types.Operator):
bl_idname = "sn.add_property_node"
bl_label = "Add Property Node"
bl_description = "Adds this node to the editor"
bl_options = {"REGISTER", "INTERNAL"}
type: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
node: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
bpy.ops.node.add_node("INVOKE_DEFAULT", type=self.type, use_transform=True)
node = context.space_data.node_tree.nodes.active
prop = None
if self.node:
prop_node = context.space_data.node_tree.nodes[self.node]
if prop_node.property_index < len(prop_node.properties):
prop = prop_node.properties[prop_node.property_index]
elif context.scene.sn.property_index < len(context.scene.sn.properties):
prop = context.scene.sn.properties[context.scene.sn.property_index]
if prop:
if self.type in ["SN_SerpensPropertyNode", "SN_OnPropertyUpdateNode"]:
if self.node:
node.prop_source = "NODE"
node.from_node = self.node
node.prop_name = prop.name
return {"FINISHED"}
class SN_OT_FindProperty(bpy.types.Operator):
bl_idname = "sn.find_property"
bl_label = "Find Property"
bl_description = "Finds this property in the addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context):
return {"FINISHED"}
def draw(self, context):
layout = self.layout
# init property nodes
empty_nodes = []
property_nodes = []
property = None
if context.scene.sn.property_index < len(context.scene.sn.properties):
property = context.scene.sn.properties[context.scene.sn.property_index]
# find property nodes
for ngroup in bpy.data.node_groups:
if ngroup.bl_idname == "ScriptingNodesTree":
for node in ngroup.nodes:
if node.bl_idname == "SN_SerpensPropertyNode":
prop_src = node.get_prop_source()
if prop_src and node.prop_name in prop_src.properties:
prop = prop_src.properties[node.prop_name]
if prop == property:
property_nodes.append(node)
elif not prop_src or not node.prop_name:
empty_nodes.append(node)
# draw nodes for selected property
if context.scene.sn.property_index < len(context.scene.sn.properties):
col = layout.column()
row = col.row()
row.enabled = False
row.label(text=f"Property: {property.name}")
for node in property_nodes:
op = col.operator("sn.find_node", text=node.name, icon="RESTRICT_SELECT_OFF")
op.node_tree = node.node_tree.name
op.node = node.name
if not property_nodes:
col.label(text="No nodes found for this property", icon="INFO")
# draw nodes with empty property
col = layout.column()
row = col.row()
row.label(text="Empty Propert Nodes")
row.enabled = False
for node in empty_nodes:
op = col.operator("sn.find_node", text=node.name, icon="RESTRICT_SELECT_OFF")
op.node_tree = node.node_tree.name
op.node = node.name
if not empty_nodes:
col.label(text="No empty property nodes found", icon="INFO")
def invoke(self, context, event):
return context.window_manager.invoke_popup(self, width=250)
@@ -0,0 +1,21 @@
def get_sorted_props(prop_list):
""" Returns a list of the properties, sorted for registering """
# sort groups to the top of the list
prop_list.sort(key=lambda prop: prop.property_type == "Group", reverse=True)
# split property groups with collections or pointers with use prop group enabled
prop_groups = list(filter(lambda prop: prop.property_type == "Group", prop_list))
other_props = list(filter(lambda prop: not prop in prop_groups, prop_list))
ref_prop_groups = list(filter(_is_propgroup_with_references, prop_groups))
prop_groups = list(filter(lambda prop: not prop in ref_prop_groups, prop_groups))
# TODO sort ref_prop_groups -> may fail if a prop group has a prop with a collection or pointer to another prop group with the same
return prop_groups + ref_prop_groups + other_props
def _is_propgroup_with_references(prop):
""" Returns if the given property group has pointer or collection props with references to other prop groups """
for subprop in prop.settings.properties:
if subprop.property_type == "Collection" and subprop.settings.prop_group in prop.prop_collection_origin.properties:
return True
elif subprop.property_type == "Pointer" and subprop.settings.use_prop_group and subprop.settings.prop_group in prop.prop_collection_origin.properties:
return True
return False
@@ -0,0 +1,64 @@
import bpy
from .settings import PropertySettings
class SN_PT_BooleanProperty(PropertySettings, bpy.types.PropertyGroup):
type_description = "Boolean properties can hold a value of True or False.\n" \
+ "They can also be turned into a vector which holds multiple of these.\n" \
+ "\n" \
+ "Booleans are displayed as checkboxes or toggles in the UI."
copy_attributes = ["default", "is_vector", "size", "vector_default"]
def draw(self, context, layout):
""" Draws the settings for this property type """
row = layout.row(heading="Default")
row.enabled = not self.is_vector
row.prop(self, "default", toggle=True, text=str(self.default))
layout.separator()
layout.prop(self, "is_vector")
col = layout.column()
col.enabled = self.is_vector
col.prop(self, "size")
sub_col = col.column(align=True, heading="Default")
for i in range(self.size):
sub_col.prop(self, "vector_default", index=i, text=str(self.vector_default[i]), toggle=True)
@property
def prop_type_name(self):
if self.is_vector:
return "BoolVectorProperty"
return "BoolProperty"
@property
def register_options(self):
if self.is_vector:
options = f"size={self.size}, default={tuple(list(self.vector_default)[:self.size])}"
else:
options = f"default={self.default}"
return options + self.update_option
default: bpy.props.BoolProperty(name="Default",
description="Default value of this property (This may not reset automatically for existing attached items)",
update=PropertySettings.compile)
def update_vector(self, context):
self.prop.trigger_reference_update(context)
is_vector: bpy.props.BoolProperty(name="Is Vector",
description="If this property is a vector",
update=update_vector)
size: bpy.props.IntProperty(name="Vector Size", min=2, max=32, default=3,
description="Length of the vector property",
update=PropertySettings.compile)
vector_default: bpy.props.BoolVectorProperty(name="Default",
description="Default value of this property (This may not reset automatically for existing attached items)",
size=32,
update=PropertySettings.compile)
@@ -0,0 +1,47 @@
import bpy
from .settings import PropertySettings
class SN_PT_CollectionProperty(PropertySettings, bpy.types.PropertyGroup):
type_description = "Integer properties can hold decimal number.\n" \
+ "They can also be turned into a vector which holds multiple of these.\n" \
+ "\n" \
+ "Integers are displayed as number inputs."
copy_attributes = ["prop_group"]
def draw(self, context, layout):
""" Draws the settings for this property type """
src = context.scene.sn
layout.prop_search(self, "prop_group", src, "properties")
row = layout.row()
row.alert = True
if self.prop_group in src.properties:
if not src.properties[self.prop_group].property_type == "Group":
row.label(text="The selected property is not a group!", icon="ERROR")
elif hasattr(self.prop, "group_prop_parent") and self.prop.group_prop_parent.name == self.prop_group:
row.label(text="Can't use self reference for this collection!", icon="ERROR")
else:
row.label(text="There is no valid property group selected!", icon="ERROR")
@property
def prop_type_name(self):
return "CollectionProperty"
@property
def register_options(self):
src = self.prop.prop_collection_origin
if self.prop_group in src.properties and src.properties[self.prop_group].property_type == "Group":
if not hasattr(self.prop, "group_prop_parent") or (hasattr(self.prop, "group_prop_parent") and self.prop.group_prop_parent.name != self.prop_group):
return f"type=SNA_GROUP_{src.properties[self.prop_group].python_name}"
return "type=bpy.types.PropertyGroup.__subclasses__()[0]"
prop_group: bpy.props.StringProperty(name="Property Group",
description="The property group you want to point to",
update=PropertySettings.compile)
@@ -0,0 +1,180 @@
import bpy
from ....utils import normalize_code
from .settings import PropertySettings
_enum_prop_cache = {} # stores key, value of enum.as_pointer, prop
class EnumItem(bpy.types.PropertyGroup):
@property
def prop(self):
if self.id_data.bl_rna.identifier == "ScriptingNodesTree":
# find property in nodes to return
if not str(self.as_pointer()) in _enum_prop_cache:
for node in self.id_data.nodes:
if hasattr(node, "properties"):
for prop in node.properties:
if prop.property_type == "Enum":
for item in prop.settings.items:
if item == self:
_enum_prop_cache[str(self.as_pointer())] = prop
break
elif prop.property_type == "Group":
for subprop in prop.settings.properties:
if subprop.property_type == "Enum":
for item in subprop.settings.items:
if item == self:
_enum_prop_cache[str(self.as_pointer())] = prop
break
return _enum_prop_cache[str(self.as_pointer())]
else:
path = ".".join(repr(self.path_resolve("name", False)).split(".")[:-2])
prop = eval(path)
return prop
def update(self, context):
self.prop.compile()
name: bpy.props.StringProperty(name="Name", default="New Item",
description="Name of this enum item",
update=update)
description: bpy.props.StringProperty(name="Description",
description="Description of this enum item",
update=update)
icon: bpy.props.IntProperty(name="Icon", default=0, min=0,
description="Icon value of this enum item",
update=update)
class SN_PT_EnumProperty(PropertySettings, bpy.types.PropertyGroup):
type_description = "Enum properties can hold multiple items with a name and description.\n" \
+ "\n" \
+ "Enum properties are displayed as dropdowns or a list of toggles.\n" \
+ "Dynamic enums can be used to display custom icons such as a list of asset images."
copy_attributes = ["enum_flag", "is_dynamic"]
def copy(self, new_settings):
for item in self.items:
new = new_settings.items.add()
new.name = item.name
new.description = item.description
new.icon = item.icon
def draw(self, context, layout):
""" Draws the settings for this property type """
layout.prop(self, "enum_flag")
layout.prop(self, "is_dynamic")
layout.separator()
row = layout.row()
row.scale_y = 1.2
if not self.is_dynamic:
op = row.operator("sn.add_enum_item", text="Add Item", icon="ADD")
op.item_data_path = f"{self.prop.full_prop_path}.settings.items"
for i, item in enumerate(self.items):
box = layout.box()
col = box.column()
box.use_property_split = False
row = col.row()
subrow = row.row(align=True)
subrow.prop(item, "name", text="")
op = subrow.operator("sn.select_icon", icon_value=item.icon if item.icon != 0 else 101, text="", emboss=item.icon==0)
op.icon_data_path = f"{self.prop.full_prop_path}.settings.items[{i}]"
subrow = row.row(align=True)
subcol = subrow.column(align=True)
subcol.enabled = i > 0
op = subcol.operator("sn.move_enum_item", text="", icon="TRIA_UP", emboss=False)
op.settings_data_path = f"{self.prop.full_prop_path}.settings"
op.item_index = i
op.move_up = True
subcol = subrow.column(align=True)
subcol.enabled = i < len(self.items)-1
op = subcol.operator("sn.move_enum_item", text="", icon="TRIA_DOWN", emboss=False)
op.settings_data_path = f"{self.prop.full_prop_path}.settings"
op.item_index = i
op.move_up = False
op = subrow.operator("sn.remove_enum_item", text="", icon="PANEL_CLOSE", emboss=False)
op.settings_data_path = f"{self.prop.full_prop_path}.settings"
op.item_index = i
col.prop(item, "description")
else:
op = row.operator("sn.add_generate_items_node", text="Generate Items", icon="ADD")
@property
def prop_type_name(self):
return "EnumProperty"
@property
def item_func_name(self):
name = f"{self.prop.python_name}_enum_items"
if hasattr(self.prop, "group_prop_parent"):
return f"{self.prop.group_prop_parent.python_name}_{name}"
return name
@property
def register_options(self):
options = ""
if not self.is_dynamic:
items = []
for i, item in enumerate(self.items):
if self.enum_flag:
i = 2 ** i
items.append(f"('{item.name}', '{item.name}', '{item.description}', {item.icon}, {i})")
options = f"items=[{', '.join(items)}]"
else:
options = f"items={self.item_func_name}"
if self.enum_flag:
options += ", options={'ENUM_FLAG'}"
return options + self.update_option
def imperative_code(self):
# node exists for this property
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
for ref in ntree.node_collection("SN_GenerateEnumItemsNode").refs:
node = ref.node
enum_src = node.get_prop_source()
if enum_src and node.prop_name in enum_src.properties and enum_src.properties[node.prop_name] == self.prop:
return ""
code = f"""
def {self.item_func_name}(self, context):
return [("No Items", "No Items", "No generate enum items node found to create items!", "ERROR", 0)]
"""
return normalize_code(code) + "\n" + self.update_function
def update_enum_flag(self, context):
self.prop.trigger_reference_update(context)
self.compile(context)
enum_flag: bpy.props.BoolProperty(name="Select Multiple (Enum Set)",
description="Lets you select multiple options from this property",
update=update_enum_flag)
is_dynamic: bpy.props.BoolProperty(name="Dynamic Items",
description="The items are generated with a function and aren't predefined",
update=PropertySettings.compile)
items: bpy.props.CollectionProperty(type=EnumItem,
name="Items",
description="Enum Items")
@@ -0,0 +1,206 @@
import bpy
from .settings import PropertySettings
class SN_PT_FloatProperty(PropertySettings, bpy.types.PropertyGroup):
type_description = "Float properties can hold decimal number.\n" \
+ "They can also be turned into a vector which holds multiple of these.\n" \
+ "\n" \
+ "Floats are displayed as number inputs or sliders.\n" \
+ "Float vectors can be used with subtypes, for example to make a color input."
copy_attributes = ["is_vector", "default", "subtype", "unit", "use_min", "min", "use_max", "use_soft_min",
"soft_min", "max", "use_soft_max", "soft_max", "step", "precision", "size", "vector_default"]
def draw(self, context, layout):
""" Draws the settings for this property type """
layout.prop(self, "subtype")
layout.prop(self, "unit")
row = layout.row()
row.enabled = not self.is_vector
row.prop(self, "default")
layout.separator()
layout.prop(self, "step")
layout.prop(self, "precision")
layout.separator()
row = layout.row(heading="Minimum")
row.prop(self, "use_min", text="")
sub_row = row.row()
sub_row.enabled = self.use_min
sub_row.prop(self, "min")
row = layout.row(heading="Maximum")
row.prop(self, "use_max", text="")
sub_row = row.row()
sub_row.enabled = self.use_max
sub_row.prop(self, "max")
row = layout.row(heading="Soft Minimum")
row.prop(self, "use_soft_min", text="")
sub_row = row.row()
sub_row.enabled = self.use_soft_min
sub_row.prop(self, "soft_min")
row = layout.row(heading="Soft Maximum")
row.prop(self, "use_soft_max", text="")
sub_row = row.row()
sub_row.enabled = self.use_soft_max
sub_row.prop(self, "soft_max")
layout.separator()
layout.prop(self, "is_vector")
col = layout.column()
col.enabled = self.is_vector
col.prop(self, "size")
row = col.row()
split = row.split(factor=0.4)
split.alignment = "RIGHT"
split.label(text="Default")
sub_col = split.column(align=True)
for i in range(self.size):
sub_col.prop(self, "vector_default", index=i, text="")
@property
def prop_type_name(self):
if self.is_vector:
return "FloatVectorProperty"
return "FloatProperty"
@property
def register_options(self):
if self.is_vector:
options = f"size={self.size}, default={tuple(list(self.vector_default)[:self.size])}"
else:
options = f"default={self.default}"
options += f", subtype='{self.subtype}'"
options += f", unit='{self.unit}'"
if self.use_min: options += f", min={self.min}"
if self.use_soft_min: options += f", soft_min={self.soft_min}"
if self.use_max: options += f", max={self.max}"
if self.use_soft_max: options += f", soft_max={self.soft_max}"
options += f", step={self.step}"
options += f", precision={self.precision}"
return options + self.update_option
default: bpy.props.FloatProperty(name="Default",
description="Default value of this property (This may not reset automatically for existing attached items)",
update=PropertySettings.compile)
def get_subtype_items(self, context):
items = [("NONE", "None", "No subtype, just a default float input"),
("PIXEL", "Pixel", "Pixel"),
("UNSIGNED", "Unsigned", "Unsigned"),
("PERCENTAGE", "Percentage", "Percentage"),
("FACTOR", "Factor", "Factor"),
("ANGLE", "Angle", "Angle"),
("TIME", "Time", "Time"),
("DISTANCE", "Distance", "Distance"),
("DISTANCE_CAMERA", "Distance Camera", "Distance Camera"),
("POWER", "Power", "Power"),
("TEMPERATURE", "Temperature", "Temperature")]
if self.is_vector:
items = [("NONE", "None", "No subtype, just a default float vector input"),
("COLOR", "Color", "Color"),
("TRANSLATION", "Translation", "Translation"),
("DIRECTION", "Direction", "Direction"),
("VELOCITY", "Velocity", "Velocity"),
("ACCELERATION", "Acceleration", "Acceleration"),
("MATRIX", "Matrix", "Matrix"),
("EULER", "Euler", "Euler"),
("QUATERNION", "Quaternion", "Quaternion"),
("AXISANGLE", "Axisangle", "Axisangle"),
("XYZ", "XYZ", "XYZ"),
("XYZ_LENGTH", "XYZ Length", "XYZ Length"),
("COLOR_GAMMA", "Color Gamma", "Color Gamma"),
("COORDINATES", "Coordinates", "Coordinates"),
("LAYER", "Layer", "Layer"),
("LAYER_MEMBER", "Layer Member", "Layer Member"),]
return items
subtype: bpy.props.EnumProperty(name="Subtype",
description="The subtype of this property. This changes how the property is displayed",
update=PropertySettings.compile,
items=get_subtype_items)
unit: bpy.props.EnumProperty(name="Unit",
description="The unit of this property. This changes how the property is displayed",
update=PropertySettings.compile,
items=[("NONE", "None", "No unit, just a default float input"),
("LENGTH", "Length", "Length"),
("AREA", "Area", "Area"),
("VOLUME", "Volume", "Volume"),
("ROTATION", "Rotation", "Rotation"),
("TIME", "Time", "Time"),
("VELOCITY", "Velocity", "Velocity"),
("ACCELERATION", "Acceleration", "Acceleration"),
("MASS", "Mass", "Mass"),
("CAMERA", "Camera", "Camera"),
("POWER", "Power", "Power")])
use_min: bpy.props.BoolProperty(name="Minimum",
description="Use a minimum property value",
update=PropertySettings.compile)
min: bpy.props.FloatProperty(name="Minimum", default=-0,
description="The minimum value of this property",
update=PropertySettings.compile)
use_max: bpy.props.BoolProperty(name="Maximum",
description="Use a maximum property value",
update=PropertySettings.compile)
use_soft_min: bpy.props.BoolProperty(name="Soft Minimum",
description="Use a soft minimum property value",
update=PropertySettings.compile)
soft_min: bpy.props.FloatProperty(name="Soft Minimum", default=-0,
description="The soft minimum value of this property",
update=PropertySettings.compile)
max: bpy.props.FloatProperty(name="Maximum", default=1,
description="The maximum value of this property",
update=PropertySettings.compile)
use_soft_max: bpy.props.BoolProperty(name="Soft Maximum",
description="Use a soft maximum property value",
update=PropertySettings.compile)
soft_max: bpy.props.FloatProperty(name="Soft Maximum", default=1,
description="The soft maximum value of this property",
update=PropertySettings.compile)
step: bpy.props.IntProperty(name="Step", min=1, max=100, default=3,
description="Step of increment/decrement in the UI",
update=PropertySettings.compile)
precision: bpy.props.IntProperty(name="Precision", min=0, max=6, default=6,
description="Maximum number of decimal digits to display",
update=PropertySettings.compile)
def update_vector(self, context):
self.prop.trigger_reference_update(context)
is_vector: bpy.props.BoolProperty(name="Is Vector",
description="If this property is a vector",
update=update_vector)
size: bpy.props.IntProperty(name="Vector Size", min=2, max=32, default=3,
description="Length of the vector property",
update=PropertySettings.compile)
vector_default: bpy.props.FloatVectorProperty(name="Default",
description="Default value of this property (This may not reset automatically for existing attached items)",
size=32,
update=PropertySettings.compile)
@@ -0,0 +1,120 @@
import bpy
from .settings import PropertySettings
from ..property_basic import BasicProperty
_group_prop_cache = {} # stores key, value of prop.as_pointer, prop
class SN_SimpleProperty(BasicProperty, bpy.types.PropertyGroup):
expand: bpy.props.BoolProperty(default=True, name="Expand", description="Expand this property")
@property
def group_prop_parent(self):
""" Returns the parent of the property collection this property lives in """
if self.id_data.bl_rna.identifier == "ScriptingNodesTree":
# find property in nodes to return
if not str(self.as_pointer()) in _group_prop_cache:
for node in self.id_data.nodes:
if hasattr(node, "properties"):
for prop in node.properties:
if prop.property_type == "Group":
for subprop in prop.settings.properties:
if subprop == self:
_group_prop_cache[str(self.as_pointer())] = prop
break
return _group_prop_cache[str(self.as_pointer())]
else:
coll_path = "[".join(repr(self.path_resolve("name", False)).split("[")[:-1])
parent_path = coll_path.split("stngs_group")[0][:-1]
return eval(parent_path)
@property
def python_name(self):
return super().python_name[4:] # cut of sna_ for props in prop group (mainly for name prop)
def compile(self, context=None):
self.group_prop_parent.compile()
class SN_PT_GroupProperty(PropertySettings, bpy.types.PropertyGroup):
type_description = "Group properties can hold multiple other properties.\n" \
+ "They are used in combination with a pointer or collection property.\n" \
+ "Use a property called 'Name' to find properties in a collection.\n" \
+ "\n" \
+ "A common use for group properties is to group your addons settings together."
copy_attributes = []
def copy(self, new_settings):
for prop in self.properties:
new_prop = new_settings.properties.add()
prop.match_settings(new_prop)
def draw(self, context, layout):
""" Draws the settings for this property type """
row = layout.row()
row.scale_y = 1.2
op = row.operator("sn.add_property_item", text="Add Property", icon="ADD")
op.group_data_path = f"{self.prop.full_prop_path}"
for i, prop in enumerate(self.properties):
box = layout.box()
row = box.row()
subrow = row.row()
subrow.prop(prop, "expand", text="", icon="DISCLOSURE_TRI_DOWN" if prop.expand else "DISCLOSURE_TRI_RIGHT", emboss=False)
row.prop(prop, "name", text="")
subrow = row.row(align=True)
subcol = subrow.column(align=True)
subcol.enabled = i > 0
op = subcol.operator("sn.move_group_property", text="", icon="TRIA_UP")
op.group_items_path = f"{self.prop.full_prop_path}.settings.properties"
op.index = i
op.move_up = True
subcol = subrow.column(align=True)
subcol.enabled = i < len(self.properties)-1
op = subcol.operator("sn.move_group_property", text="", icon="TRIA_DOWN")
op.group_items_path = f"{self.prop.full_prop_path}.settings.properties"
op.index = i
op.move_up = False
op = row.operator("sn.remove_group_property", text="", icon="TRASH", emboss=False)
op.group_items_path = f"{self.prop.full_prop_path}.settings.properties"
op.index = i
row.operator("sn.copy_python_name", text="", icon="COPYDOWN", emboss=False).name = "PROP_PATH_PLACEHOLDER."+prop.python_name
if prop.expand:
prop.draw(context, box)
box.separator()
prop.settings.draw(context, box)
@property
def prop_type_name(self):
return f"SNA_GROUP_{self.prop.python_name}"
@property
def register_options(self):
return f""
def imperative_code(self):
code = f"class {self.prop_type_name}(bpy.types.PropertyGroup):\n\n"
for prop in self.properties:
for line in prop.register_code.split("\n"):
code += " "*4 + line + "\n"
code += "\n"
if not len(self.properties):
code += " "*4 + "pass\n\n"
return code
properties: bpy.props.CollectionProperty(type=SN_SimpleProperty)
@@ -0,0 +1,172 @@
import bpy
from .settings import PropertySettings
class SN_PT_IntegerProperty(PropertySettings, bpy.types.PropertyGroup):
type_description = "Integer properties can hold decimal number.\n" \
+ "They can also be turned into a vector which holds multiple of these.\n" \
+ "\n" \
+ "Integers are displayed as number inputs."
copy_attributes = ["is_vector", "default", "subtype", "use_min", "min", "use_max", "use_soft_min",
"soft_min", "max", "use_soft_max", "soft_max", "step", "size", "vector_default"]
def draw(self, context, layout):
""" Draws the settings for this property type """
layout.prop(self, "subtype")
row = layout.row()
row.enabled = not self.is_vector
row.prop(self, "default")
layout.separator()
row = layout.row(heading="Minimum")
row.prop(self, "use_min", text="")
sub_row = row.row()
sub_row.enabled = self.use_min
sub_row.prop(self, "min")
row = layout.row(heading="Maximum")
row.prop(self, "use_max", text="")
sub_row = row.row()
sub_row.enabled = self.use_max
sub_row.prop(self, "max")
row = layout.row(heading="Soft Minimum")
row.prop(self, "use_soft_min", text="")
sub_row = row.row()
sub_row.enabled = self.use_soft_min
sub_row.prop(self, "soft_min")
row = layout.row(heading="Soft Maximum")
row.prop(self, "use_soft_max", text="")
sub_row = row.row()
sub_row.enabled = self.use_soft_max
sub_row.prop(self, "soft_max")
layout.separator()
layout.prop(self, "is_vector")
col = layout.column()
col.enabled = self.is_vector
col.prop(self, "size")
row = col.row()
split = row.split(factor=0.4)
split.alignment = "RIGHT"
split.label(text="Default")
sub_col = split.column(align=True)
for i in range(self.size):
sub_col.prop(self, "vector_default", index=i, text="")
@property
def prop_type_name(self):
if self.is_vector:
return "IntVectorProperty"
return "IntProperty"
@property
def register_options(self):
if self.is_vector:
options = f"size={self.size}, default={tuple(list(self.vector_default)[:self.size])}"
else:
options = f"default={self.default}"
options += f", subtype='{self.subtype}'"
if self.use_min: options += f", min={self.min}"
if self.use_soft_min: options += f", soft_min={self.soft_min}"
if self.use_max: options += f", max={self.max}"
if self.use_soft_max: options += f", soft_max={self.soft_max}"
return options + self.update_option
default: bpy.props.IntProperty(name="Default",
description="Default value of this property (This may not reset automatically for existing attached items)",
update=PropertySettings.compile)
def get_subtype_items(self, context):
items = [("NONE", "None", "No subtype, just a default float input"),
("PIXEL", "Pixel", "Pixel"),
("UNSIGNED", "Unsigned", "Unsigned"),
("PERCENTAGE", "Percentage", "Percentage"),
("FACTOR", "Factor", "Factor"),
("ANGLE", "Angle", "Angle"),
("TIME", "Time", "Time"),
("DISTANCE", "Distance", "Distance"),
("DISTANCE_CAMERA", "Distance Camera", "Distance Camera"),
("POWER", "Power", "Power"),
("TEMPERATURE", "Temperature", "Temperature")]
if self.is_vector:
items = [("NONE", "None", "No subtype, just a default float vector input"),
("COLOR", "Color", "Color"),
("TRANSLATION", "Translation", "Translation"),
("DIRECTION", "Direction", "Direction"),
("VELOCITY", "Velocity", "Velocity"),
("ACCELERATION", "Acceleration", "Acceleration"),
("MATRIX", "Matrix", "Matrix"),
("EULER", "Euler", "Euler"),
("QUATERNION", "Quaternion", "Quaternion"),
("AXISANGLE", "Axisangle", "Axisangle"),
("XYZ", "XYZ", "XYZ"),
("XYZ_LENGTH", "XYZ Length", "XYZ Length"),
("COLOR_GAMMA", "Color Gamma", "Color Gamma"),
("COORDINATES", "Coordinates", "Coordinates"),
("LAYER", "Layer", "Layer"),
("LAYER_MEMBER", "Layer Member", "Layer Member"),]
return items
subtype: bpy.props.EnumProperty(name="Subtype",
description="The subtype of this property. This changes how the property is displayed",
update=PropertySettings.compile,
items=get_subtype_items)
use_min: bpy.props.BoolProperty(name="Minimum",
description="Use a minimum property value",
update=PropertySettings.compile)
min: bpy.props.IntProperty(name="Minimum", default=-0,
description="The minimum value of this property",
update=PropertySettings.compile)
use_max: bpy.props.BoolProperty(name="Maximum",
description="Use a maximum property value",
update=PropertySettings.compile)
use_soft_min: bpy.props.BoolProperty(name="Soft Minimum",
description="Use a soft minimum property value",
update=PropertySettings.compile)
soft_min: bpy.props.IntProperty(name="Soft Minimum", default=-0,
description="The soft minimum value of this property",
update=PropertySettings.compile)
max: bpy.props.IntProperty(name="Maximum", default=1,
description="The maximum value of this property",
update=PropertySettings.compile)
use_soft_max: bpy.props.BoolProperty(name="Soft Maximum",
description="Use a soft maximum property value",
update=PropertySettings.compile)
soft_max: bpy.props.IntProperty(name="Soft Maximum", default=1,
description="The soft maximum value of this property",
update=PropertySettings.compile)
def update_vector(self, context):
self.prop.trigger_reference_update(context)
is_vector: bpy.props.BoolProperty(name="Is Vector",
description="If this property is a vector",
update=update_vector)
size: bpy.props.IntProperty(name="Vector Size", min=2, max=32, default=3,
description="Length of the vector property",
update=PropertySettings.compile)
vector_default: bpy.props.IntVectorProperty(name="Default",
description="Default value of this property (This may not reset automatically for existing attached items)",
size=32,
update=PropertySettings.compile)
@@ -0,0 +1,75 @@
import bpy
from .settings import PropertySettings, id_items
class SN_PT_PointerProperty(PropertySettings, bpy.types.PropertyGroup):
type_description = "Pointer properties can point to specific types of blend data or property groups.\n" \
+ "\n" \
+ "They are often used to point to your addons settings, which could live grouped\n" \
+ "in a property group and be attached to the scene.\n" \
+ "\n" \
+ "When used with blend data, you can use pointers to let the user select the data\n" \
+ "from a dropdown and get the blend data from the property."
copy_attributes = ["data_type", "use_prop_group", "prop_group"]
def draw(self, context, layout):
""" Draws the settings for this property type """
src = context.scene.sn
layout.prop(self, "use_prop_group")
if not self.use_prop_group:
layout.prop(self, "data_type")
else:
layout.prop_search(self, "prop_group", src, "properties")
row = layout.row()
row.alert = True
if self.prop_group and self.prop_group in src.properties:
if not src.properties[self.prop_group].property_type == "Group":
row.label(text="The selected property is not a group!", icon="ERROR")
elif hasattr(self.prop, "group_prop_parent") and self.prop.group_prop_parent.name == self.prop_group:
row.label(text="Can't use self reference for this collection!", icon="ERROR")
else:
row.label(text="There is no valid property group selected!", icon="ERROR")
@property
def prop_type_name(self):
return "PointerProperty"
@property
def register_options(self):
if not self.use_prop_group:
data_type = "bpy.types."+self.data_type
else:
src = self.prop.prop_collection_origin
data_type = "bpy.types.Scene"
if self.prop_group in src.properties and src.properties[self.prop_group].property_type == "Group":
if not hasattr(self.prop, "group_prop_parent") or (hasattr(self.prop, "group_prop_parent") and self.prop.group_prop_parent.name != self.prop_group):
data_type = f"SNA_GROUP_{bpy.context.scene.sn.properties[self.prop_group].python_name}"
return f"type={data_type}{self.update_option}"
def get_data_items(self, context):
items = []
for item in id_items:
items.append((item, item, item))
return items
data_type: bpy.props.EnumProperty(name="Data Type",
description="The type of blend data to have this property point to",
items=get_data_items,
update=PropertySettings.compile)
use_prop_group: bpy.props.BoolProperty(name="Use Property Group",
description="Point to a custom property group you created",
update=PropertySettings.compile)
prop_group: bpy.props.StringProperty(name="Property Group",
description="The property group you want to point to",
update=PropertySettings.compile)
@@ -0,0 +1,149 @@
import bpy
id_items = ["Scene", "Action", "Armature", "Brush", "CacheFile", "Camera",
"Collection", "Curve", "FreestyleLineStyle", "GreasePencil",
"Image", "Key", "Lattice", "Library", "Light", "LightProbe",
"Mask", "Material", "Mesh", "MetaBall", "MovieClip", "NodeTree",
"Object", "PaintCurve", "Palette", "ParticleSettings",
"Screen", "Sound", "Speaker", "Text", "Texture", "VectorFont",
"Volume", "WindowManager", "WorkSpace", "World"]
id_data = {"Scene": "scenes", "Action":"actions", "Armature":"armatures",
"Brush":"bruhes", "CacheFile":"cache_files", "Camera":"cameras",
"Collection":"collections", "Curve":"curves", "FreestyleLineStyle":"linestyles",
"GreasePencil":"grease_pencils", "Image": "images", "Key": "shape_keys",
"Lattice": "lattices", "Library": "libraries", "Light": "lights",
"LightProbe": "lightprobes", "Mask": "masks", "Material": "materials",
"Mesh": "meshes", "MetaBall": "metaballs", "MovieClip": "movieclips",
"NodeTree": "node_groups", "Object": "objects", "PaintCurve": "paint_curves",
"Palette": "palettes", "ParticleSettings": "particles", "Screen": "screens",
"Sound": "sounds", "Speaker": "speakers", "Text": "texts", "Texture": "textures",
"VectorFont": "fonts", "Volume": "volumes", "WindowManager": "window_managers",
"WorkSpace": "workspaces", "World": "worlds"}
property_icons = {
"String": "SYNTAX_OFF",
"Boolean": "FORCE_CHARGE",
"Boolean Vector": "FORCE_CHARGE",
"Float": "CON_TRANSLIKE",
"Float Vector": "CON_TRANSLIKE",
"Integer": "DRIVER_TRANSFORM",
"Integer Vector": "DRIVER_TRANSFORM",
"Enum": "PRESET",
"Enum Set": "PRESET",
"Pointer": "MONKEY",
"Property": "MONKEY",
"Collection": "ASSET_MANAGER",
"Collection Property": "ASSET_MANAGER",
"Group": "FILEBROWSER",
"List": "LONGDISPLAY",
"Data": "OBJECT_DATA",
"Icon": "DRIVER_TRANSFORM",
"Function": "FILE_SCRIPT",
"Built In Function": "SCRIPTPLUGINS",
}
property_socket = {
"String": "String",
"Boolean": "Boolean",
"Float": "Float",
"Integer": "Integer",
"Enum": "Enum",
"Pointer": "Property",
"Collection": "Collection Property",
"Group": "Data",
}
def prop_to_socket(prop):
socket_name = property_socket[prop.property_type]
if getattr(prop.settings, "enum_flag", False):
socket_name = "Enum Set"
if getattr(prop.settings, "is_vector", False):
socket_name += " Vector"
return socket_name
_prop_cache = {} # stores key, value of settings.as_pointer with prop for settings
class PropertySettings:
dummy: bpy.props.StringProperty(name="DUMMY", description="Dummy prop for resolving path")
copy_attributes = []
def copy(self, new_settings): pass
@property
def prop(self):
""" Returns the property these settings belong to """
if self.id_data.bl_rna.identifier == "ScriptingNodesTree":
# find property in nodes to return
if not str(self.as_pointer()) in _prop_cache:
for node in self.id_data.nodes:
if hasattr(node, "properties"):
for prop in node.properties:
if prop.settings == self:
_prop_cache[str(self.as_pointer())] = prop
break
elif prop.property_type == "Group":
for subprop in prop.settings.properties:
if subprop.settings == self:
_prop_cache[str(self.as_pointer())] = subprop
break
return _prop_cache[str(self.as_pointer())]
else:
path = ".".join(repr(self.path_resolve("dummy", False)).split(".")[:-2])
prop = eval(path)
return prop
def compile(self, context=None):
""" Compile the property for these settings """
self.prop.compile()
def imperative_code(self):
return self.update_function
def _update_function_names(self):
""" Returns the code for the on property update function """
updates = []
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
for node in ntree.node_collection("SN_OnPropertyUpdateNode").nodes:
prop_src = node.get_prop_source()
if prop_src and node.prop_name in prop_src.properties:
prop = prop_src.properties[node.prop_name]
if prop.name == self.prop.name:
updates.append((node.update_func_name(prop), node.order))
return list(map(lambda item: item[0], sorted(updates, key=lambda i: i[1])))
@property
def update_function(self):
""" Returns the code for the update function """
update_names = self._update_function_names()
if len(update_names) < 2:
return ""
else:
code = f"def sna_update_{self.prop.python_name}(self, context):\n"
for func in update_names:
code += " "*4 + f"{func}(self, context)\n"
return code
@property
def update_option(self):
""" Returns the code for the update function option """
update_names = self._update_function_names()
if len(update_names) == 0 or self.prop.property_type in ["Group", "Collection"]:
return ""
elif len(update_names) == 1:
return f", update={update_names[0]}"
return f", update=sna_update_{self.prop.python_name}"
@@ -0,0 +1,18 @@
import operator
import bpy
class SN_OT_AddGenerateItemsNode(bpy.types.Operator):
bl_idname = "sn.add_generate_items_node"
bl_label = "Generate Items"
bl_description = "Adds a node to generate dynamic enum items"
bl_options = {"REGISTER", "INTERNAL", "UNDO"}
def execute(self, context):
bpy.ops.node.add_node("INVOKE_DEFAULT", type="SN_GenerateEnumItemsNode", use_transform=True)
node = context.space_data.node_tree.nodes.active
if context.scene.sn.property_index < len(context.scene.sn.properties):
prop = context.scene.sn.properties[context.scene.sn.property_index]
node.prop_name = prop.name
return {"FINISHED"}
@@ -0,0 +1,54 @@
import bpy
from .settings import PropertySettings
class SN_PT_StringProperty(PropertySettings, bpy.types.PropertyGroup):
type_description = "String properties can hold a line of text.\n" \
+ "\n" \
+ "String properties are displayed as text inputs in the UI. \n" \
+ "There are subtypes to add a file selector to the string property."
copy_attributes = ["default", "subtype", "maxlen"]
def draw(self, context, layout):
""" Draws the settings for this property type """
layout.prop(self, "subtype")
layout.prop(self, "default")
layout.separator()
layout.prop(self, "maxlen")
@property
def prop_type_name(self):
return "StringProperty"
@property
def register_options(self):
return f"default='{self.default}', subtype='{self.subtype}', maxlen={self.maxlen}{self.update_option}"
default: bpy.props.StringProperty(name="Default",
description="Default value of this property (This may not reset automatically for existing attached items)",
update=PropertySettings.compile)
subtype: bpy.props.EnumProperty(name="Subtype",
description="The subtype of this property. This changes how the property is displayed",
update=PropertySettings.compile,
items=[("NONE", "None", "No subtype, just a default string input"),
("FILE_PATH", "File Path", "Display this property as a file path"),
("DIR_PATH", "Directory Path", "Display this property as a directory path"),
# ("FILE_NAME", "File Name", "Display that property as a file name"),
("BYTE_STRING", "Byte String", "Stores the string as a UTF-8 encoded byte string"),
("PASSWORD", "Password", "Displays asterisks in the UI to hide the typed string")])
maxlen: bpy.props.IntProperty(name="Max Length",
description="The maximum length of the string (0 is unlimited)",
min=0,
default=0,
update=PropertySettings.compile)
@@ -0,0 +1,18 @@
import bpy
def ntree_variable_register_code(ntree):
if len(ntree.variables) == 0: return ""
code = f"{ntree.python_name} = {{"
for var in ntree.variables:
code += f"'{var.python_name}': {var.var_default}, "
code += "}\n"
return code
def variable_register_code():
code = ""
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
code += ntree_variable_register_code(ntree)
return code
@@ -0,0 +1,177 @@
import bpy
from ...nodes.compiler import compile_addon
class SN_OT_AddVariable(bpy.types.Operator):
bl_idname = "sn.add_variable"
bl_label = "Add Variable"
bl_description = "Adds a variable to the addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
node_tree: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
ntree = bpy.data.node_groups[self.node_tree]
new_var = ntree.variables.add()
new_var.name = "New Variable"
ntree.variables.move(len(ntree.variables)-1, ntree.variable_index+1)
ntree.variable_index += 1
ntree.variable_index = min(ntree.variable_index, len(ntree.variables)-1)
return {"FINISHED"}
class SN_OT_RemoveVariable(bpy.types.Operator):
bl_idname = "sn.remove_variable"
bl_label = "Remove Variable"
bl_description = "Removes this variable from the addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
node_tree: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
ntree = bpy.data.node_groups[self.node_tree]
ntree.variables.remove(ntree.variable_index)
ntree.variable_index -= 1
compile_addon()
return {"FINISHED"}
class SN_OT_MoveVariable(bpy.types.Operator):
bl_idname = "sn.move_variable"
bl_label = "Move Variable"
bl_description = "Moves this variable"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
node_tree: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
move_up: bpy.props.BoolProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
ntree = bpy.data.node_groups[self.node_tree]
if self.move_up:
ntree.variables.move(ntree.variable_index, ntree.variable_index - 1)
ntree.variable_index -= 1
else:
ntree.variables.move(ntree.variable_index, ntree.variable_index + 1)
ntree.variable_index += 1
return {"FINISHED"}
class SN_OT_AddVariableNodePopup(bpy.types.Operator):
bl_idname = "sn.add_variable_node_popup"
bl_label = "Add Variable Node Popup"
bl_description = "Opens a popup to let you choose a variable node"
bl_options = {"REGISTER", "INTERNAL"}
node_tree: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
return {"FINISHED"}
def draw(self, context):
layout = self.layout
col = layout.column(align=True)
col.scale_y = 1.5
op = col.operator("sn.add_variable_node", text="Get Variable", icon="ADD")
op.type = "SN_GetVariableNode"
op.node_tree = self.node_tree
op = col.operator("sn.add_variable_node", text="Set Variable", icon="ADD")
op.type = "SN_SetVariableNode"
op.node_tree = self.node_tree
op = col.operator("sn.add_variable_node", text="Reset Variable", icon="ADD")
op.type = "SN_ResetVariableNode"
op.node_tree = self.node_tree
def invoke(self, context, event):
return context.window_manager.invoke_popup(self)
class SN_OT_AddVariableNode(bpy.types.Operator):
bl_idname = "sn.add_variable_node"
bl_label = "Add Variable Node"
bl_description = "Adds this node to the editor"
bl_options = {"REGISTER", "INTERNAL"}
node_tree: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
type: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
bpy.ops.node.add_node("INVOKE_DEFAULT", type=self.type, use_transform=True)
node = context.space_data.node_tree.nodes.active
ntree = bpy.data.node_groups[self.node_tree]
if ntree.variable_index < len(ntree.variables):
var = ntree.variables[ntree.variable_index]
node.ref_ntree = ntree
node.var_name = var.name
return {"FINISHED"}
class SN_OT_FindVariable(bpy.types.Operator):
bl_idname = "sn.find_variable"
bl_label = "Find Variable"
bl_description = "Finds this variable in the addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
node_tree: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
return {"FINISHED"}
def draw(self, context):
layout = self.layout
ntree = bpy.data.node_groups[self.node_tree]
# init variable nodes
empty_nodes = []
variable_nodes = []
variable = None
if ntree.variable_index < len(ntree.variables):
variable = ntree.variables[ntree.variable_index]
# find variable nodes
for ngroup in bpy.data.node_groups:
if ngroup.bl_idname == "ScriptingNodesTree":
for node in ngroup.nodes:
if hasattr(node, "var_name") and hasattr(node, "ref_ntree"):
if variable and node.var_name == variable.name and node.ref_ntree == ntree:
variable_nodes.append(node)
elif not node.var_name or not node.ref_ntree:
empty_nodes.append(node)
# draw nodes for selected variable
if ntree.variable_index < len(ntree.variables):
col = layout.column()
row = col.row()
row.enabled = False
row.label(text=f"Variable: {variable.name}")
for node in variable_nodes:
op = col.operator("sn.find_node", text=node.name, icon="RESTRICT_SELECT_OFF")
op.node_tree = node.node_tree.name
op.node = node.name
if not variable_nodes:
col.label(text="No nodes found for this variable", icon="INFO")
# draw nodes with empty variable
col = layout.column()
row = col.row()
row.label(text="Empty Variable Nodes")
row.enabled = False
for node in empty_nodes:
op = col.operator("sn.find_node", text=node.name, icon="RESTRICT_SELECT_OFF")
op.node_tree = node.node_tree.name
op.node = node.name
if not empty_nodes:
col.label(text="No empty variable nodes found", icon="INFO")
def invoke(self, context, event):
return context.window_manager.invoke_popup(self, width=250)
@@ -0,0 +1,126 @@
import bpy
from ...utils import get_python_name, unique_collection_name
from ..properties.settings.settings import property_icons
from ...nodes.compiler import compile_addon
class SN_VariableProperties(bpy.types.PropertyGroup):
@property
def node_tree(self):
return self.id_data
# cache python names so they only have to be generated once
cached_python_names = {}
cached_python_name: bpy.props.StringProperty()
cached_human_name: bpy.props.StringProperty()
@property
def python_name(self):
if self.name == self.cached_human_name and self.cached_python_name: return self.cached_python_name
if self.name in self.cached_python_names: return self.cached_python_names[self.name]
names = []
for var in self.node_tree.variables:
if var == self:
break
names.append(var.python_name)
name = unique_collection_name(f"sna_{get_python_name(self.name, 'sna_new_variable')}", "sna_new_variable", names, "_")
try:
self.cached_python_name = name
self.cached_human_name = self.name
except AttributeError: pass
self.cached_python_names[self.name] = name
return name
@property
def data_path(self):
return f"{self.node_tree.python_name}['{self.python_name}']"
@property
def icon(self):
return property_icons[self.variable_type]
def compile(self, context=None):
""" Registers the variable and unregisters previous version """
# print(f"Serpens Log: Variable {self.name} received an update")
compile_addon()
def get_name(self):
return self.get("name", "Variable Default")
def get_to_update_nodes(self):
to_update_nodes = []
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
for node in ntree.nodes:
if getattr(node, "var_name", None) == self.name:
to_update_nodes.append(node)
return to_update_nodes
def set_name(self, value):
names = list(map(lambda item: item.name, list(filter(lambda item: item!=self, self.node_tree.variables))))
value = unique_collection_name(value, "New Variable", names, " ")
to_update = self.get_to_update_nodes()
# set value
self["name"] = value
self.compile()
# update node references
for node in to_update:
node.var_name = value
name: bpy.props.StringProperty(name="Variable Name",
description="Name of this variable",
default="Variable Default",
get=get_name,
set=set_name,
update=compile)
def update_variable_type(self, context):
for node in self.get_to_update_nodes():
if hasattr(node, "on_var_changed"):
node.on_var_changed()
self.compile()
variable_type: bpy.props.EnumProperty(name="Type",
description="The type of data this variable stores",
update=update_variable_type,
items=[("Data", "Data", "Stores any type of data", property_icons["Data"], 0),
("String", "String", "Stores a string of characters", property_icons["String"], 1),
("Boolean", "Boolean", "Stores True or False", property_icons["Boolean"], 2),
("Float", "Float", "Stores a decimal number", property_icons["Float"], 3),
("Integer", "Integer", "Stores an integer number", property_icons["Integer"], 4),
("List", "List", "Stores a list of data", property_icons["List"], 5),
("Pointer", "Pointer", "Stores a reference to certain types of blend data, collection or group properties", property_icons["Pointer"], 6),
("Collection", "Collection", "Stores a list of certain blend data or property groups to be displayed in lists", property_icons["Collection"], 7)])
string_default: bpy.props.StringProperty(name="Default", description="Default value for the variable", update=compile)
boolean_default: bpy.props.BoolProperty(name="Default", description="Default value for the variable", update=compile)
float_default: bpy.props.FloatProperty(name="Default", description="Default value for the variable", update=compile)
integer_default: bpy.props.IntProperty(name="Default", description="Default value for the variable", update=compile)
@property
def var_default(self):
return {
"Data": None,
"String": f"'{self.string_default}'",
"Boolean": self.boolean_default,
"Float": self.float_default,
"Integer": self.integer_default,
"List": [],
"Pointer": None,
"Collection": None,
}[self.variable_type]
@@ -0,0 +1,163 @@
import os
import bpy
import sys
import typing
import inspect
import pkgutil
import importlib
from pathlib import Path
__all__ = (
"init",
"register",
"unregister",
)
blender_version = bpy.app.version
modules = None
ordered_classes = None
def init():
global modules
global ordered_classes
modules = get_all_submodules(Path(__file__).parent)
ordered_classes = get_ordered_classes_to_register(modules)
def register():
for cls in ordered_classes:
isContextMenu = getattr(cls, "__name__", None) == "WM_MT_button_context"
if not isContextMenu or (isContextMenu and getattr(bpy.types, "WM_MT_button_context", None) == None):
try: bpy.utils.register_class(cls)
except: pass
for module in modules:
if module.__name__ == __name__:
continue
if hasattr(module, "register"):
module.register()
def unregister():
for cls in reversed(ordered_classes):
isContextMenu = getattr(cls, "__name__", None) == "WM_MT_button_context"
if not isContextMenu:
try: bpy.utils.unregister_class(cls)
except: pass
for module in modules:
if module.__name__ == __name__:
continue
if hasattr(module, "unregister"):
module.unregister()
# Import modules
#################################################
def get_all_submodules(directory):
return list(iter_submodules(directory, directory.name))
def iter_submodules(path, package_name):
for name in sorted(iter_submodule_names(path)):
yield importlib.import_module("." + name, package_name)
def iter_submodule_names(path, root=""):
for _, module_name, is_package in pkgutil.iter_modules([str(path)]):
if is_package:
sub_path = path / module_name
sub_root = root + module_name + "."
yield from iter_submodule_names(sub_path, sub_root)
else:
yield root + module_name
# Find classes to register
#################################################
def get_ordered_classes_to_register(modules):
return toposort(get_register_deps_dict(modules))
def get_register_deps_dict(modules):
my_classes = set(iter_my_classes(modules))
my_classes_by_idname = {cls.bl_idname : cls for cls in my_classes if hasattr(cls, "bl_idname")}
deps_dict = {}
for cls in my_classes:
deps_dict[cls] = set(iter_my_register_deps(cls, my_classes, my_classes_by_idname))
return deps_dict
def iter_my_register_deps(cls, my_classes, my_classes_by_idname):
yield from iter_my_deps_from_annotations(cls, my_classes)
yield from iter_my_deps_from_parent_id(cls, my_classes_by_idname)
def iter_my_deps_from_annotations(cls, my_classes):
for value in typing.get_type_hints(cls, {}, {}).values():
dependency = get_dependency_from_annotation(value)
if dependency is not None:
if dependency in my_classes:
yield dependency
def get_dependency_from_annotation(value):
if blender_version >= (2, 93):
if isinstance(value, bpy.props._PropertyDeferred):
return value.keywords.get("type")
else:
if isinstance(value, tuple) and len(value) == 2:
if value[0] in (bpy.props.PointerProperty, bpy.props.CollectionProperty):
return value[1]["type"]
return None
def iter_my_deps_from_parent_id(cls, my_classes_by_idname):
if bpy.types.Panel in cls.__bases__:
parent_idname = getattr(cls, "bl_parent_id", None)
if parent_idname is not None:
parent_cls = my_classes_by_idname.get(parent_idname)
if parent_cls is not None:
yield parent_cls
def iter_my_classes(modules):
base_types = get_register_base_types()
for cls in get_classes_in_modules(modules):
if any(base in base_types for base in cls.__bases__):
if not getattr(cls, "is_registered", False):
yield cls
def get_classes_in_modules(modules):
classes = set()
for module in modules:
for cls in iter_classes_in_module(module):
classes.add(cls)
return classes
def iter_classes_in_module(module):
for value in module.__dict__.values():
if inspect.isclass(value):
yield value
def get_register_base_types():
return set(getattr(bpy.types, name) for name in [
"Panel", "Operator", "PropertyGroup",
"AddonPreferences", "Header", "Menu",
"Node", "NodeSocket", "NodeTree",
"UIList", "RenderEngine",
"Gizmo", "GizmoGroup",
])
# Find order to register to solve dependencies
#################################################
def toposort(deps_dict):
sorted_list = []
sorted_values = set()
while len(deps_dict) > 0:
unsorted = []
for value, deps in deps_dict.items():
if len(deps) == 0:
sorted_list.append(value)
sorted_values.add(value)
else:
unsorted.append(value)
deps_dict = {value : deps_dict[value] - sorted_values for value in unsorted}
return sorted_list
@@ -0,0 +1,140 @@
import bpy
import os
import shutil
import json
import zipfile
from bpy_extras.io_utils import ImportHelper
loaded_packages = [] # temp var for the loaded packages in a file
require_reload = False # set to true after a package is installed
class SN_OT_InstallPackage(bpy.types.Operator, ImportHelper):
bl_idname = "sn.install_package"
bl_label = "Install Package"
bl_description = "Let's you install a package from a zip file"
bl_options = {"REGISTER","UNDO","INTERNAL"}
filter_glob: bpy.props.StringProperty( default='*.zip', options={'HIDDEN'} )
def extract_zip(self):
node_directory = os.path.join(os.path.dirname(os.path.dirname(__file__)),"nodes")
names = []
with zipfile.ZipFile(self.filepath, 'r') as zip_ref:
zip_ref.extractall(node_directory)
names = zip_ref.namelist()
return names
def get_package_info(self):
info_path = os.path.join(os.path.dirname(os.path.dirname(__file__)),"nodes","package_info.json")
package_info = None
if os.path.exists(info_path):
with open(info_path) as info:
package_info = json.loads(info.read())
os.remove(info_path)
return package_info
def write_to_installed(self, new_package, extracted):
installed_path = os.path.join(os.path.dirname(__file__),"installed.json")
if not os.path.exists(installed_path):
with open(installed_path, "w") as data_file: data_file.write(json.dumps({ "packages": [], "snippets": [] }))
with open(installed_path, "r+") as installed:
data = json.loads(installed.read())
new_package["nodes"] = extracted
data["packages"].append(new_package)
installed.seek(0)
installed.write(json.dumps(data,indent=4))
installed.truncate()
def execute(self, context):
filename, extension = os.path.splitext(self.filepath)
if extension == ".zip":
extracted_files = self.extract_zip()
package_info = self.get_package_info()
if not package_info:
package_info = {
"name": os.path.basename(filename),
"author": "Unknown",
"description": "",
"version": "1.0.0",
"wiki": ""}
self.write_to_installed(package_info, extracted_files)
bpy.ops.sn.reload_packages()
global require_reload
require_reload = True
return {"FINISHED"}
class SN_OT_ReloadPackages(bpy.types.Operator):
bl_idname = "sn.reload_packages"
bl_label = "Reload Packages"
bl_description = "Reloads the installed packages list"
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
global loaded_packages
installed_path = os.path.join(os.path.dirname(__file__),"installed.json")
if os.path.exists(installed_path):
with open(installed_path, "r") as installed:
data = json.loads(installed.read())
loaded_packages = data["packages"]
else:
loaded_packages = []
return {"FINISHED"}
class SN_OT_UninstallPackage(bpy.types.Operator):
bl_idname = "sn.uninstall_package"
bl_label = "Uninstall Package"
bl_description = "Uninstalls this package"
bl_options = {"REGISTER","UNDO","INTERNAL"}
index: bpy.props.IntProperty()
def get_package(self):
installed_path = os.path.join(os.path.dirname(__file__),"installed.json")
if not os.path.exists(installed_path):
with open(installed_path, "w") as data_file: data_file.write(json.dumps({ "packages": [], "snippets": [] }))
package = None
with open(installed_path,"r+") as installed:
data = json.loads(installed.read())
package = data["packages"][self.index]
data["packages"].pop(self.index)
installed.seek(0)
installed.write(json.dumps(data,indent=4))
installed.truncate()
return package
def remove_nodes(self,nodes):
for name in nodes:
if not "__init__" in name:
path = os.path.join(os.path.dirname(os.path.dirname(__file__)),"nodes",name)
if os.path.exists(path) and not os.path.isdir(path):
os.remove(path)
def remove_empty_dirs(self):
dir = os.path.join(os.path.dirname(os.path.dirname(__file__)),"nodes")
for file in os.listdir(dir):
path = os.path.join(dir, file)
if os.path.isdir(path):
files = os.listdir(path)
if len(files) == 0 or (len(files) == 1 and "__init__.py" in files):
shutil.rmtree(path)
def execute(self, context):
package = self.get_package()
if package:
self.remove_nodes(package["nodes"])
self.remove_empty_dirs()
bpy.ops.sn.reload_packages()
return {"FINISHED"}
def invoke(self, context, event):
return context.window_manager.invoke_confirm(self, event)
@@ -0,0 +1,480 @@
import bpy
from bpy_extras.io_utils import ImportHelper, ExportHelper
import os
import shutil
import json
import zipfile
class SN_SnippetCategory(bpy.types.PropertyGroup):
name: bpy.props.StringProperty()
path: bpy.props.StringProperty()
expand: bpy.props.BoolProperty(default=True)
class SN_BoolCollection(bpy.types.PropertyGroup):
name: bpy.props.StringProperty()
enabled: bpy.props.BoolProperty(default=True)
loaded_snippets = [] # temp var for the loaded snippets
def load_snippets():
global loaded_snippets
sn = bpy.context.scene.sn
installed_path = os.path.join(os.path.dirname(__file__), "installed.json")
if os.path.exists(installed_path):
added = []
with open(installed_path, "r") as installed:
data = json.loads(installed.read())
loaded_snippets = data["snippets"]
for snippet in data["snippets"]:
if not type(snippet) == str:
added.append(snippet["name"])
if not snippet["name"] in sn.snippet_categories:
item = sn.snippet_categories.add()
else:
item = sn.snippet_categories[snippet["name"]]
item.name = snippet["name"]
item.path = os.path.join(os.path.dirname(
installed_path), "snippets", snippet["name"])
all_items = sn.snippet_categories.keys()
for name in all_items:
if not name in added:
sn.snippet_categories.remove(
sn.snippet_categories.find(name))
else:
loaded_snippets = []
class SN_OT_InstallSnippet(bpy.types.Operator, ImportHelper):
bl_idname = "sn.install_snippet"
bl_label = "Install Snippet"
bl_description = "Install a single or a zip file of snippets"
bl_options = {"REGISTER", "INTERNAL"}
filter_glob: bpy.props.StringProperty(
default='*.json;*.zip', options={'HIDDEN'})
def execute(self, context):
_, extension = os.path.splitext(self.filepath)
if extension in [".json", ".zip"]:
path = os.path.join(os.path.dirname(__file__), "installed.json")
if not os.path.exists(path):
with open(path, "w") as data_file:
data_file.write(json.dumps(
{"packages": [], "snippets": []}))
with open(path, "r+") as data_file:
data = json.loads(data_file.read())
name = os.path.basename(self.filepath)
if not name in data["snippets"]:
if extension == ".json":
data["snippets"].append(name)
shutil.copyfile(self.filepath, os.path.join(
os.path.dirname(__file__), "snippets", name))
if extension == ".zip":
name = name.split(".")[0]
path = os.path.join(os.path.dirname(
__file__), "snippets", name)
with zipfile.ZipFile(self.filepath, 'r') as zip_ref:
zip_ref.extractall(path)
data["snippets"].append({
"name": name,
"snippets": os.listdir(path)
})
data_file.seek(0)
data_file.write(json.dumps(data, indent=4))
data_file.truncate()
load_snippets()
self.report({"INFO"}, message="Snippet installed!")
else:
self.report({"ERROR"}, message="Please only install .json files!")
return {"CANCELLED"}
return {"FINISHED"}
class SN_OT_UninstallSnippet(bpy.types.Operator):
bl_idname = "sn.uninstall_snippet"
bl_label = "Uninstall Snippet"
bl_description = "Uninstalls this snippet"
bl_options = {"REGISTER", "INTERNAL"}
index: bpy.props.IntProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
path = os.path.join(os.path.dirname(__file__))
if not os.path.exists(path):
with open(path, "w") as data_file:
data_file.write(json.dumps({"packages": [], "snippets": []}))
with open(os.path.join(path, "installed.json"), "r+") as data_file:
data = json.loads(data_file.read())
snippet = data["snippets"].pop(self.index)
data_file.seek(0)
data_file.write(json.dumps(data, indent=4))
data_file.truncate()
if type(snippet) == str:
os.remove(os.path.join(path, "snippets", snippet))
else:
shutil.rmtree(os.path.join(path, "snippets", snippet["name"]))
load_snippets()
self.report({"INFO"}, message="Snippet uninstalled!")
return {"FINISHED"}
class SN_OT_AddSnippet(bpy.types.Operator):
bl_idname = "sn.add_snippet"
bl_label = "Add Snippet"
bl_description = "Adds this snippets node"
bl_options = {"REGISTER", "INTERNAL"}
path: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
bpy.ops.node.add_node(
"INVOKE_DEFAULT", type="SN_SnippetNode", use_transform=True)
context.space_data.node_tree.nodes.active.path = self.path
return {"FINISHED"}
class SN_OT_ExportSnippetDraw(bpy.types.Operator):
bl_idname = "sn.draw_export_snippet"
bl_label = "Draw Export Snippet Popup"
bl_description = "Draw Export Snippet Popup"
bl_options = {"REGISTER", "INTERNAL"}
node: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
tree: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def get_connected_functions(self, function_node):
nodes = []
for node in function_node._get_linked_nodes(started_at_trigger=True):
if node.bl_idname == "SN_RunFunctionNode":
parent_tree = node.ref_ntree if node.ref_ntree else node.node_tree
if node.ref_SN_FunctionNode in parent_tree.nodes:
new_node = parent_tree.nodes[node.ref_SN_FunctionNode]
nodes.append(new_node)
nodes += self.get_connected_functions(new_node)
return nodes
def invoke(self, context, event):
node = context.space_data.node_tree.nodes.active
function_node = node.node_tree.nodes[node.ref_SN_FunctionNode]
function_nodes = self.get_connected_functions(function_node)
vars = []
props = []
context.scene.sn.snippet_vars_customizable.clear()
context.scene.sn.snippet_props_customizable.clear()
for func_node in function_nodes + [function_node]:
for some_node in func_node._get_linked_nodes(started_at_trigger=True):
if hasattr(some_node, "var_name") and hasattr(some_node, "ref_ntree"):
var = some_node.get_var()
if var:
if not var.name in vars:
vars.append(var.name)
item = context.scene.sn.snippet_vars_customizable.add()
item.name = var.name
if hasattr(some_node, "prop_name"):
prop_src = some_node.get_prop_source()
if prop_src and some_node.prop_name in prop_src.properties:
prop = prop_src.properties[some_node.prop_name]
if not prop.name in props:
props.append(prop.name)
item = context.scene.sn.snippet_props_customizable.add()
item.name = prop.name
if len(context.scene.sn.snippet_props_customizable) or len(context.scene.sn.snippet_vars_customizable):
wm = context.window_manager
return wm.invoke_popup(self, width=200)
node = context.space_data.node_tree.nodes.active
bpy.ops.sn.export_snippet(
"INVOKE_DEFAULT", node=node.name, tree=node.node_tree.name)
return self.execute(context)
def draw(self, context):
node = context.space_data.node_tree.nodes.active
layout = self.layout
if len(context.scene.sn.snippet_vars_customizable):
layout.label(text="Select editable variables:")
for var in context.scene.sn.snippet_vars_customizable:
layout.prop(var, "enabled", text=var.name, toggle=True)
if len(context.scene.sn.snippet_props_customizable):
layout.separator()
layout.label(text="Select editable properties:")
for prop in context.scene.sn.snippet_props_customizable:
layout.prop(prop, "enabled", text=prop.name, toggle=True)
layout.separator()
row = layout.row()
row.scale_y = 1.5
op = row.operator("sn.export_snippet",
text="Export Snippet", icon="EXPORT")
op.node = node.name
op.tree = node.node_tree.name
def execute(self, context):
return {'FINISHED'}
class SN_OT_ExportSnippet(bpy.types.Operator, ExportHelper):
bl_idname = "sn.export_snippet"
bl_label = "Export Snippet"
bl_description = "Export this node as a snippet"
bl_options = {"REGISTER", "INTERNAL"}
filename_ext = ".json"
filter_glob: bpy.props.StringProperty(
default="*.json", options={'HIDDEN'}, maxlen=255)
node: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
tree: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def get_connected_functions(self, function_node):
nodes = []
for node in function_node._get_linked_nodes(started_at_trigger=True):
if node.bl_idname == "SN_RunFunctionNode":
parent_tree = node.ref_ntree if node.ref_ntree else node.node_tree
if node.ref_SN_FunctionNode in parent_tree.nodes:
new_node = parent_tree.nodes[node.ref_SN_FunctionNode]
nodes.append(new_node)
nodes += self.get_connected_functions(new_node)
return nodes
def get_connected_interface(self, function_node):
nodes = []
for node in function_node._get_linked_nodes(started_at_trigger=True):
if node.bl_idname == "SN_RunInterfaceFunctionNode":
parent_tree = node.ref_ntree if node.ref_ntree else node.node_tree
if node.ref_SN_InterfaceFunctionNode in parent_tree.nodes:
new_node = parent_tree.nodes[node.ref_SN_InterfaceFunctionNode]
nodes.append(new_node)
nodes += self.get_connected_functions(new_node)
return nodes
def execute(self, context):
data = {}
node = bpy.data.node_groups[self.tree].nodes[self.node]
parent_tree = node.ref_ntree if node.ref_ntree else node.node_tree
function_node = None
if node.bl_idname == "SN_RunFunctionNode" and node.ref_SN_FunctionNode in parent_tree.nodes:
function_node = parent_tree.nodes[node.ref_SN_FunctionNode]
if not function_node:
self.report({"ERROR"}, message="No function selected!")
return {"CANCELLED"}
data["version"] = 3
data["name"] = function_node.name
data["func_name"] = function_node.func_name
data["inputs"] = []
data["outputs"] = []
for inp in node.inputs:
if not inp.hide:
if inp.bl_idname in ["SN_EnumSocket", "SN_EnumSetSocket"]:
items = [item[0] for item in inp.get_items(None)]
data["inputs"].append({"idname": inp.bl_idname,"name": inp.name,"subtype": inp.subtype, "enum_items": str(items)})
elif "Vector" in inp.bl_idname:
data["inputs"].append({"idname": inp.bl_idname,"name": inp.name,"subtype": inp.subtype, "size": inp.size})
else:
data["inputs"].append(
{"idname": inp.bl_idname, "name": inp.name, "subtype": inp.subtype})
for out in node.outputs:
if not out.hide:
data["outputs"].append(
{"idname": out.bl_idname, "name": out.name, "subtype": out.subtype})
data["function"] = function_node._get_code()
data["import"] = function_node._get_code_import()
data["imperative"] = function_node._get_code_imperative()
data["register"] = function_node._get_code_register()
data["unregister"] = function_node._get_code_unregister()
function_nodes = self.get_connected_functions(function_node)
for func_node in function_nodes:
data["import"] += ("\n" + func_node._get_code_import()
) if func_node._get_code_import() else ""
data["imperative"] += "\n" + func_node._get_code() + "\n" + \
func_node._get_code_imperative()
data["register"] += ("\n" + func_node._get_code_register()
) if func_node._get_code_register() else ""
data["unregister"] += ("\n" + func_node._get_code_unregister()
) if func_node._get_code_unregister() else ""
variables = {}
used_vars = []
properties = {}
data["variables"] = []
data["properties"] = []
for func_node in function_nodes + [function_node]:
for node in func_node._get_linked_nodes(started_at_trigger=True):
if hasattr(node, "var_name") and hasattr(node, "ref_ntree"):
var = node.get_var()
if var:
if not var.node_tree.python_name + "_SNIPPET_VARS" in variables:
variables[var.node_tree.python_name +
"_SNIPPET_VARS"] = {}
if not var.name in used_vars:
used_vars.append(var.name)
customizable = context.scene.sn.snippet_vars_customizable[
var.name].enabled
data["variables"].append({"name": var.name, "python_name": var.python_name,
"tree": var.node_tree.python_name, "type": var.variable_type, "customizable": customizable})
variables[var.node_tree.python_name +
"_SNIPPET_VARS"][var.python_name] = str(var.var_default)
data["function"] = data["function"].replace(
var.node_tree.python_name + "[", var.node_tree.python_name + "_SNIPPET_VARS[")
data["imperative"] = data["imperative"].replace(
var.node_tree.python_name + "[", var.node_tree.python_name + "_SNIPPET_VARS[")
data["register"] = data["register"].replace(
var.node_tree.python_name + "[", var.node_tree.python_name + "_SNIPPET_VARS[")
data["unregister"] = data["unregister"].replace(
var.node_tree.python_name + "[", var.node_tree.python_name + "_SNIPPET_VARS[")
if hasattr(node, "prop_name"):
prop_src = node.get_prop_source()
if prop_src and node.prop_name in prop_src.properties:
prop = prop_src.properties[node.prop_name]
if not prop.name in properties:
properties[prop.python_name] = [prop.register_code.replace(
prop.python_name, prop.python_name+"_SNIPPET_VARS"), prop.unregister_code.replace(prop.python_name, prop.python_name+"_SNIPPET_VARS")]
customizable = context.scene.sn.snippet_props_customizable[
prop.name].enabled
data["properties"].append({"name": prop.name, "python_name": prop.python_name,
"type": prop.property_type, "attach_to": prop.attach_to, "customizable": customizable})
data["function"] = data["function"].replace(
prop.python_name, prop.python_name + "_SNIPPET_VARS")
data["imperative"] = data["imperative"].replace(
prop.python_name, prop.python_name + "_SNIPPET_VARS")
data["register"] = data["register"].replace(
prop.python_name, prop.python_name + "_SNIPPET_VARS")
data["unregister"] = data["unregister"].replace(
prop.python_name, prop.python_name + "_SNIPPET_VARS")
data["variable_defs"] = variables
data["properties_defs"] = properties
elif node.bl_idname == "SN_RunInterfaceFunctionNode" and node.ref_SN_InterfaceFunctionNode in parent_tree.nodes:
function_node = parent_tree.nodes[node.ref_SN_InterfaceFunctionNode]
if not function_node:
self.report({"ERROR"}, message="No function selected!")
return {"CANCELLED"}
data["version"] = 3
data["name"] = function_node.name
data["func_name"] = function_node.func_name
data["inputs"] = []
data["outputs"] = []
for inp in node.inputs:
if not inp.hide:
if inp.bl_idname == "SN_EnumSocket":
items = [item[0] for item in inp.get_items(None)]
data["inputs"].append(
{"idname": inp.bl_idname, "name": inp.name, "subtype": inp.subtype, "enum_items": str(items)})
else:
data["inputs"].append(
{"idname": inp.bl_idname, "name": inp.name, "subtype": inp.subtype})
data["function"] = function_node._get_code()
data["import"] = function_node._get_code_import()
data["imperative"] = function_node._get_code_imperative()
data["register"] = function_node._get_code_register()
data["unregister"] = function_node._get_code_unregister()
function_nodes = self.get_connected_functions(function_node)
for func_node in function_nodes:
data["import"] += ("\n" + func_node._get_code_import()
) if func_node._get_code_import() else ""
data["imperative"] += "\n" + func_node._get_code() + "\n" + \
func_node._get_code_imperative()
data["register"] += ("\n" + func_node._get_code_register()
) if func_node._get_code_register() else ""
data["unregister"] += ("\n" + func_node._get_code_unregister()
) if func_node._get_code_unregister() else ""
variables = {}
used_vars = []
properties = {}
data["variables"] = []
data["properties"] = []
for func_node in function_nodes + [function_node]:
for node in func_node._get_linked_nodes(started_at_trigger=True):
if hasattr(node, "var_name") and hasattr(node, "ref_ntree"):
var = node.get_var()
if var:
if not var.node_tree.python_name + "_SNIPPET_VARS" in variables:
variables[var.node_tree.python_name +
"_SNIPPET_VARS"] = {}
if not var.name in used_vars:
used_vars.append(var.name)
customizable = context.scene.sn.snippet_vars_customizable[
var.name].enabled
data["variables"].append({"name": var.name, "python_name": var.python_name,
"tree": var.node_tree.python_name, "type": var.variable_type, "customizable": customizable})
variables[var.node_tree.python_name +
"_SNIPPET_VARS"][var.python_name] = str(var.var_default)
data["function"] = data["function"].replace(
var.node_tree.python_name + "[", var.node_tree.python_name + "_SNIPPET_VARS[")
data["imperative"] = data["imperative"].replace(
var.node_tree.python_name + "[", var.node_tree.python_name + "_SNIPPET_VARS[")
data["register"] = data["register"].replace(
var.node_tree.python_name + "[", var.node_tree.python_name + "_SNIPPET_VARS[")
data["unregister"] = data["unregister"].replace(
var.node_tree.python_name + "[", var.node_tree.python_name + "_SNIPPET_VARS[")
if hasattr(node, "prop_name"):
prop_src = node.get_prop_source()
if prop_src and node.prop_name in prop_src.properties:
prop = prop_src.properties[node.prop_name]
if not prop.name in properties:
properties[prop.python_name] = [prop.register_code.replace(
prop.python_name, prop.python_name+"_SNIPPET_VARS"), prop.unregister_code.replace(prop.python_name, prop.python_name+"_SNIPPET_VARS")]
customizable = context.scene.sn.snippet_props_customizable[
prop.name].enabled
data["properties"].append(
{"name": prop.name, "python_name": prop.python_name, "type": prop.property_type, "customizable": customizable})
data["function"] = data["function"].replace(
prop.python_name, prop.python_name + "_SNIPPET_VARS")
data["imperative"] = data["imperative"].replace(
prop.python_name, prop.python_name + "_SNIPPET_VARS")
data["register"] = data["register"].replace(
prop.python_name, prop.python_name + "_SNIPPET_VARS")
data["unregister"] = data["unregister"].replace(
prop.python_name, prop.python_name + "_SNIPPET_VARS")
data["variable_defs"] = variables
data["properties_defs"] = properties
with open(self.filepath, "w") as data_file:
data_file.seek(0)
data_file.write(json.dumps(data, indent=4))
data_file.truncate()
if data:
self.report({"INFO"}, message="Snippet exported!")
bpy.ops.sn.snippet_info("INVOKE_DEFAULT")
return {"FINISHED"}
class SN_OT_SnippetInfo(bpy.types.Operator):
bl_idname = "sn.snippet_info"
bl_label = "Snippet Info"
bl_description = "Info"
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
return {"FINISHED"}
def draw(self, context):
layout = self.layout
layout.label(text="Awesome snippet!", icon="FUND")
col = layout.column(align=True)
col.label(text="Do you think others could use this too?")
col.label(text="Why not share it with the Serpens community?")
row = col.row()
row.operator("wm.url_open", text="Upload it to the #marketplace channel!",
icon_value=bpy.context.scene.sn_icons["discord"].icon_id).url = "https://discord.com/invite/NK6kyae"
def invoke(self, context, event):
return context.window_manager.invoke_popup(self, width=300)
@@ -0,0 +1,64 @@
import bpy
from bpy.app.handlers import persistent
from .interface.menus.rightclick import serpens_right_click
from . import bl_info
from .nodes.compiler import compile_addon, unregister_addon
from .settings.updates import check_serpens_updates
from .settings.easybpy import check_easy_bpy_install
from .settings.handle_script_changes import (
unwatch_script_changes,
watch_script_changes,
update_script_nodes,
)
from .extensions.snippet_ops import load_snippets
from .msgbus import subscribe_to_name_change
@persistent
def depsgraph_handler(dummy):
for group in bpy.data.node_groups:
if group.bl_idname == "ScriptingNodesTree":
group.use_fake_user = True
# add empty collection for node drawing
if not "empty" in group.node_refs:
group.node_refs.add().name = "empty"
@persistent
def load_handler(dummy):
if hasattr(bpy.context.scene, "sn"):
bpy.context.scene.sn.picker_active = False
subscribe_to_name_change()
check_easy_bpy_install()
if bpy.context.scene.sn.compile_on_load:
compile_addon()
check_serpens_updates(bl_info["version"])
bpy.ops.sn.reload_packages()
load_snippets()
bpy.context.scene.sn.hide_preferences = False
unwatch_script_changes()
if bpy.context.scene.sn.watch_script_changes:
watch_script_changes()
@persistent
def unload_handler(dummy=None):
if hasattr(bpy.context.scene, "sn"):
unwatch_script_changes()
unregister_addon()
@persistent
def undo_post(dummy=None):
if hasattr(bpy.context, "space_data") and hasattr(
bpy.context.space_data, "node_tree"
):
ntree = bpy.context.space_data.node_tree
if ntree.bl_idname == "ScriptingNodesTree":
compile_addon()
@persistent
def save_pre(dummy=None):
if bpy.context.scene.sn.watch_script_changes:
update_script_nodes(True)
@@ -0,0 +1,16 @@
import bpy
import os
class SN_OT_ClearConsole(bpy.types.Operator):
bl_idname = "sn.clear_console"
bl_label = "Clear System Console"
bl_description = "This operator clears the system console."
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
if os.name == "nt":
os.system("cls")
else:
os.system("clear")
return {"FINISHED"}
@@ -0,0 +1,92 @@
import bpy
import platform
class SN_PT_HeaderSettings(bpy.types.Panel):
bl_idname = "SN_PT_HeaderSettings"
bl_label = "Settings"
bl_space_type = "NODE_EDITOR"
bl_region_type = "HEADER"
def draw(self, context):
layout = self.layout
layout.prop(context.scene.sn, "insert_sockets")
layout.prop(
context.preferences.view,
"show_tooltips_python",
text="Show Python Tooltips",
)
def header_prepend(self, context):
if (
context.space_data.node_tree
and context.space_data.node_tree.bl_idname == "ScriptingNodesTree"
):
layout = self.layout
row = layout.row()
if len(context.space_data.node_tree.nodes) == 0:
row.operator(
"node.add_node", text="Tutorial", icon="PLAY", depress=True
).type = "SN_TutorialNode"
row.operator("sn.show_data_overview", text="Blend Data", icon="RNA")
subrow = row.row(align=True)
if platform.system() == "Windows":
subrow.operator("sn.clear_console", text="", icon="TRASH")
subrow.operator("wm.console_toggle", text="Console", icon="CONSOLE")
row.operator("screen.userpref_show", text="", icon="PREFERENCES")
row.popover("SN_PT_HeaderSettings", text="", icon="WINDOW")
if context.scene.sn.has_update:
row.separator()
row.operator("sn.update_message", text="Update!", icon="INFO", depress=True)
def header_append(self, context):
if (
context.space_data.node_tree
and context.space_data.node_tree.bl_idname == "ScriptingNodesTree"
):
layout = self.layout
row = layout.row()
sub_row = row.row(align=True)
col = sub_row.column(align=True)
col.scale_x = 1.5
col.operator("sn.force_compile", text="", icon="FILE_REFRESH")
sub_row.operator("sn.force_unregister", text="", icon="UNLINKED")
sub_row = row.row(align=True)
sub_row.operator(
"wm.url_open",
text="",
icon_value=bpy.context.scene.sn_icons["discord"].icon_id,
).url = "https://discord.com/invite/NK6kyae"
sub_row.operator(
"wm.url_open", text="", icon="HELP"
).url = "https://joshuaknauber.notion.site/Serpens-Documentation-d44c98df6af64d7c9a7925020af11233"
ms = round(context.scene.sn.compile_time * 1000, 2)
row.label(text=str(ms) + "ms")
row.prop(
context.scene.sn,
"pause_reregister",
text="",
icon="PLAY" if context.scene.sn.pause_reregister else "PAUSE",
)
def node_info_append(self, context):
layout = self.layout
node = context.space_data.node_tree.nodes.active
if getattr(node, "is_sn", False):
layout.operator(
"wm.url_open", text="Node Documentation", icon="QUESTION"
).url = "https://joshuaknauber.notion.site/555efb921f50426ea4d5812f1aa3e462?v=d781b590cc8f47449cb20812deab0cc6"
def footer_status(self, context):
layout = self.layout
sn = context.scene.sn
@@ -0,0 +1,154 @@
import json
import bpy
from ...nodes.base_node import SN_ScriptingBaseNode
class SN_OT_RemovePreset(bpy.types.Operator):
bl_idname = "sn.remove_preset"
bl_label = "Remove Preset"
bl_description = "Removes this preset"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
index: bpy.props.IntProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
prefs = bpy.context.preferences.addons[__name__.partition('.')[ 0]].preferences
prefs.presets.remove(self.index)
bpy.ops.wm.save_userpref()
return {"FINISHED"}
class SN_OT_RemovePresets(bpy.types.Operator):
bl_idname = "sn.remove_presets"
bl_label = "Remove Presets"
bl_description = "Remove presets"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context):
return {"FINISHED"}
def draw(self, context):
layout = self.layout
prefs = bpy.context.preferences.addons[__name__.partition('.')[ 0]].preferences
layout.label(text="Presets")
for i, preset in enumerate(prefs.presets):
layout.operator("sn.remove_preset", text=f"Remove '{preset.name}'", icon="REMOVE").index = i
if not len(prefs.presets):
layout.label(text="No presets", icon="INFO")
def invoke(self, context, event):
return context.window_manager.invoke_popup(self, width=300)
class SN_OT_AddPreset(bpy.types.Operator):
bl_idname = "sn.add_preset"
bl_label = "Add Preset"
bl_description = "Adds the active node as a preset"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context):
return context.space_data.node_tree.nodes.active
def get_save_value(self, data, attr):
value = getattr(data, attr)
if "bpy_prop_array" in str(type(value)) or "Color" in str(type(value)):
return tuple(value)
return value
def execute(self, context):
node = context.space_data.node_tree.nodes.active
prefs = bpy.context.preferences.addons[__name__.partition('.')[ 0]].preferences
item = prefs.presets.add()
item.name = node.label if node.label else node.name
item.idname = node.bl_idname
data = { "node": {}, "inputs": [], "outputs": [] }
# get node attributes
attributes = [a for a in dir(node) if not callable(getattr(node, a))]
data_attributes = ["width", "color", "use_custom_color"]
for attr in attributes:
if not attr.startswith("__") and not attr.startswith("bl_")\
and not attr == "code" and not attr.startswith("code_") and not attr.startswith("ref_")\
and not hasattr(SN_ScriptingBaseNode, attr) and not attr in bpy.types.Node.bl_rna.properties.keys()\
and not attr in ["active_layout", "disable_evaluation", "skip_export", "static_uid",]:
data_attributes.append(attr)
# save node attributes
for attr in data_attributes:
data["node"][attr] = self.get_save_value(node, attr)
socket_save_attributes = ["name", "disabled", "index_type", "data_type", "default_value"]
# get input attributes
for inp in node.inputs:
input_data = {}
if not inp.is_program:
for attr in socket_save_attributes:
if hasattr(inp, attr):
input_data[attr] = self.get_save_value(inp, attr)
data["inputs"].append(input_data)
# get output attributes
for out in node.outputs:
output_data = {}
if not out.is_program:
for attr in socket_save_attributes:
if hasattr(out, attr):
output_data[attr] = self.get_save_value(out, attr)
data["outputs"].append(output_data)
item.data = json.dumps(data)
bpy.ops.wm.save_userpref()
return {"FINISHED"}
class SN_OT_LoadPreset(bpy.types.Operator):
bl_idname = "sn.load_preset"
bl_label = "Load Preset"
bl_description = "Loads this preset node"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
index: bpy.props.IntProperty(options={"SKIP_SAVE", "HIDDEN"})
def get_write_value(self, value):
return list(value) if type(value) == list else value
def execute(self, context):
prefs = bpy.context.preferences.addons[__name__.partition('.')[ 0]].preferences
preset = prefs.presets[self.index]
bpy.ops.node.add_node("INVOKE_DEFAULT", type=preset.idname, use_transform=True)
node = context.space_data.node_tree.nodes.active
node.label = preset.name
data = json.loads(preset.data)
# load node data
node.disable_evaluation = True
for attr in data["node"].keys():
setattr(node, attr, self.get_write_value(data["node"][attr]))
# load input data
for i, inp_data in enumerate(data["inputs"]):
node.disable_evaluation = True
for attr in inp_data.keys():
setattr(node.inputs[i], attr, self.get_write_value(inp_data[attr]))
# load output data
for i, out_data in enumerate(data["outputs"]):
node.disable_evaluation = True
for attr in out_data.keys():
setattr(node.outputs[i], attr, self.get_write_value(out_data[attr]))
node.disable_evaluation = False
node._evaluate(context)
return {"FINISHED"}
@@ -0,0 +1,28 @@
import bpy
class SN_MT_PresetMenu(bpy.types.Menu):
bl_idname = "SN_MT_PresetMenu"
bl_label = "Presets"
def draw(self, context):
layout = self.layout
prefs = bpy.context.preferences.addons[__name__.partition('.')[ 0]].preferences
for i, preset in enumerate(prefs.presets):
layout.operator("sn.load_preset", text=preset.name).index = i
if not len(prefs.presets):
layout.label(text="No presets", icon="INFO")
layout.separator()
node = context.space_data.node_tree.nodes.active
if node:
layout.operator("sn.add_preset", icon="ADD", text=f"Add '{node.label if node.label else node.name}'")
else:
layout.operator("sn.add_preset", icon="ADD")
row = layout.row()
row.enabled = len(prefs.presets) > 0
row.operator("sn.remove_presets", text="Remove Preset", icon="REMOVE")
@@ -0,0 +1,30 @@
import bpy
class WM_MT_button_context(bpy.types.Menu):
bl_label = ""
def draw(self, context):
pass
def serpens_right_click(self, context):
layout = self.layout
property_pointer = getattr(context, "button_pointer", None)
property_value = getattr(context, "button_prop", None)
button_value = getattr(context, "button_operator", None)
if property_value or button_value:
layout.separator()
if property_value and property_pointer:
layout.operator("sn.copy_property", text="Get Serpens Property", icon="FILE_SCRIPT")
if button_value:
layout.operator("sn.copy_operator", text="Get Serpens Operator", icon="FILE_SCRIPT")
if context:
layout.operator("sn.copy_context", text="Copy Context", icon="COPYDOWN")
@@ -0,0 +1,147 @@
import bpy
REPLACE_NAMES = {
"ObjectBase": "bpy.data.objects['Object']", # outliner object hide
"LayerCollection": "bpy.context.view_layer.active_layer_collection", # outliner collection hide
"SpaceView3D": "bpy.context.screen.areas[0].spaces[0]", # 3d space data
"ToolSettings": "bpy.context.scene.tool_settings", # any space tool settings
}
class SN_OT_CopyProperty(bpy.types.Operator):
bl_idname = "sn.copy_property"
bl_label = "Copy Property"
bl_description = "Copy the path of this property"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context):
# get property details
property_pointer = getattr(context, "button_pointer", None)
property_value = getattr(context, "button_prop", None)
# copy data path if available
if bpy.ops.ui.copy_data_path_button.poll():
bpy.ops.ui.copy_data_path_button("INVOKE_DEFAULT", full_path=True)
context.scene.sn.last_copied_datatype = property_value.type.title()
path = context.window_manager.clipboard.replace('"', "'")
if path and path[-1] == "]" and path[:-1].split("[")[-1].isdigit():
path = "[".join(path.split("[")[:-1])
context.scene.sn.last_copied_datatype += " Vector"
elif getattr(property_value, "subtype", None) == "COLOR":
context.scene.sn.last_copied_datatype += " Vector"
context.window_manager.clipboard = path
if property_value.type == "ENUM" and property_value.is_enum_flag:
context.scene.sn.last_copied_datatype += " Set"
context.scene.sn.last_copied_datapath = context.window_manager.clipboard
self.report({"INFO"}, message="Copied!")
return {"FINISHED"}
# check if replacement is available
if property_pointer and property_value:
if property_pointer.bl_rna.identifier in REPLACE_NAMES:
context.window_manager.clipboard = f"{REPLACE_NAMES[property_pointer.bl_rna.identifier]}.{property_value.identifier}"
context.window_manager.clipboard = (
context.window_manager.clipboard.replace('"', "'")
)
context.scene.sn.last_copied_datatype = property_value.type.title()
if property_value.type == "ENUM" and property_value.is_enum_flag:
context.scene.sn.last_copied_datatype += " Set"
context.scene.sn.last_copied_datapath = context.window_manager.clipboard
self.report({"INFO"}, message="Copied!")
return {"FINISHED"}
# error when property not available
self.report(
{"ERROR"},
message="We can't copy this property yet! Please use the Blend Data browser to find it!",
)
print("Serpens Log: ", property_pointer, property_value)
return {"CANCELLED"}
class SN_OT_CopyOperator(bpy.types.Operator):
bl_idname = "sn.copy_operator"
bl_label = "Copy Operator"
bl_description = "Copy the path of this operator"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def find_ops_path_from_rna(self, rna_identifier):
for cat_name in dir(bpy.ops):
if cat_name[0].isalpha() and not cat_name == "class":
try:
cat = eval(f"bpy.ops.{cat_name}")
except:
cat = None
if cat:
for op_name in dir(cat):
if op_name[0].isalpha():
try:
op = eval(f"bpy.ops.{cat_name}.{op_name}")
except:
op = None
if op and op.get_rna_type().identifier == rna_identifier:
return f"bpy.ops.{cat_name}.{op_name}()"
return None
def execute(self, context):
# copy operator if available
if bpy.ops.ui.copy_python_command_button.poll():
bpy.ops.ui.copy_python_command_button("INVOKE_DEFAULT")
self.report({"INFO"}, message="Copied!")
return {"FINISHED"}
# get button details
button_value = getattr(context, "button_operator", None)
# check if value exists
if button_value:
op_path = self.find_ops_path_from_rna(button_value.bl_rna.identifier)
if op_path:
context.window_manager.clipboard = op_path
self.report({"INFO"}, message="Copied!")
return {"FINISHED"}
# error when button not available
self.report(
{"ERROR"},
message="We can't copy this operator yet! Please report this to the developers!",
)
print("Serpens Log: ", button_value)
return {"CANCELLED"}
def copy_context():
context = {}
for attribute in dir(bpy.context):
if (
attribute[0].isalpha()
and not attribute in ["property"]
and hasattr(bpy.context, attribute)
and not callable(getattr(bpy.context, attribute))
):
context[attribute] = getattr(bpy.context, attribute)
return context
class SN_OT_CopyContext(bpy.types.Operator):
bl_idname = "sn.copy_context"
bl_label = "Copy Context"
bl_description = "Copy the context from this area"
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
copied = copy_context()
context.scene.sn.copied_context.clear()
context.scene.sn.copied_context.append(copied)
context.scene.sn.hide_preferences = True
for screen in bpy.data.screens:
for area in screen.areas:
area.tag_redraw()
self.report({"INFO"}, message="Copied and reloaded!")
return {"FINISHED"}
@@ -0,0 +1,45 @@
import bpy
import os
from ...extensions import snippet_ops
class SN_MT_SnippetsMenu(bpy.types.Menu):
bl_idname = "SN_MT_SnippetsMenu"
bl_label = "Snippets"
def draw(self, context):
layout = self.layout
op = layout.operator("node.add_node", text="Snippet")
op.type = "SN_SnippetNode"
op.use_transform = True
no_cat_snippets = False
for snippet in snippet_ops.loaded_snippets:
if type(snippet) != str:
row = layout.row()
row.context_pointer_set("snippet", context.scene.sn.snippet_categories[snippet["name"]])
row.menu("SN_MT_SnippetMenu", text=snippet["name"])
else:
no_cat_snippets = True
if no_cat_snippets:
layout.menu("SN_MT_SnippetMenu", text="Others")
class SN_MT_SnippetMenu(bpy.types.Menu):
bl_idname = "SN_MT_SnippetMenu"
bl_label = "Snippets"
def draw(self, context):
layout = self.layout
if hasattr(context, "snippet"):
for data in snippet_ops.loaded_snippets:
if not type(data) == str and data["name"] == context.snippet.name:
for snippet in data["snippets"]:
layout.operator("sn.add_snippet", text=snippet.split(".")[0]).path = os.path.join(context.snippet.path, snippet)
else:
path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "extensions", "snippets")
for name in snippet_ops.loaded_snippets:
if type(name) == str:
layout.operator("sn.add_snippet", text=name.split(".")[0]).path = os.path.join(path, name)
@@ -0,0 +1,50 @@
import bpy
class SN_PT_AddonInfoPanel(bpy.types.Panel):
bl_idname = "SN_PT_AddonInfoPanel"
bl_label = ""
bl_space_type = 'NODE_EDITOR'
bl_region_type = 'UI'
bl_category = "Serpens"
bl_options = {"DEFAULT_CLOSED", "HEADER_LAYOUT_EXPAND"}
bl_order = 4
@classmethod
def poll(cls, context):
return context.space_data.tree_type == "ScriptingNodesTree" and context.space_data.node_tree
def draw_header(self, context):
layout = self.layout
layout.label(text="Addon")
layout.operator("wm.url_open", text="", icon="QUESTION", emboss=False).url = "https://joshuaknauber.notion.site/Export-496335f1abe44262885bde330efe59c0"
def draw(self, context):
layout = self.layout
sn = context.scene.sn
layout.use_property_split = True
layout.use_property_decorate = False
layout.prop(sn, "addon_name")
layout.prop(sn, "description")
layout.prop(sn, "author")
layout.prop(sn, "location")
layout.prop(sn, "warning")
layout.prop(sn, "doc_url")
layout.prop(sn, "tracker_url")
col = layout.column(align=True)
col.prop(sn, "category")
if sn.category == "CUSTOM":
col.prop(sn, "custom_category", text=" ")
layout.prop(sn, "version")
layout.prop(sn, "blender")
# layout.prop(sn, "multifile")
row = layout.row()
row.scale_y = 1.5
col = row.column(align=True)
col.operator("sn.export_addon", text="Save Addon", icon="EXPORT")
row = col.row()
row.scale_y = 0.7
row.operator("sn.export_to_marketplace",text="Add to Marketplace",icon_value=bpy.context.scene.sn_icons[ "discord" ].icon_id)
@@ -0,0 +1,88 @@
import bpy
class SN_PT_AddonSettingsPanel(bpy.types.Panel):
bl_idname = "SN_PT_AddonSettingsPanel"
bl_label = ""
bl_space_type = 'NODE_EDITOR'
bl_region_type = 'UI'
bl_category = "Serpens"
bl_options = {"DEFAULT_CLOSED", "HEADER_LAYOUT_EXPAND"}
bl_order = 8
@classmethod
def poll(cls, context):
return context.space_data.tree_type == "ScriptingNodesTree" and context.space_data.node_tree
def draw_header(self, context):
layout = self.layout
layout.label(text="Settings")
layout.operator("wm.url_open", text="", icon="QUESTION", emboss=False).url = "https://joshuaknauber.notion.site/Workflow-Introduction-d235d03178124dc9b752088d75a25192"
def draw(self, context):
layout = self.layout
sn = context.scene.sn
layout.use_property_split = True
layout.use_property_decorate = False
col = layout.column(heading="General")
col.prop(sn, "watch_script_changes")
col.prop(sn, "show_graph_categories")
col.prop(sn, "show_property_categories")
col.prop(sn, "overwrite_variable_graph")
col.prop(sn, "compile_on_load")
layout.separator()
col = layout.column(heading="Generated Code")
col.prop(sn, "debug_code", text="Keep Code File")
subcol = col.column()
subcol.enabled = sn.debug_code
subcol.prop(sn, "remove_duplicate_code")
subcol.prop(sn, "format_code")
layout.separator()
col = layout.column(heading="Debug")
col.prop(sn, "debug_compile_time", text="Log Compile Time")
col.prop(sn, "debug_python_nodes")
col.prop(sn, "debug_python_sockets")
subrow = col.row()
subrow.active = sn.debug_python_nodes or sn.debug_python_sockets
subrow.prop(sn, "debug_selected_only")
col.prop(sn, "debug_python_properties")
class SN_PT_EasyBpyPanel(bpy.types.Panel):
bl_idname = "SN_PT_EasyBpyPanel"
bl_parent_id = "SN_PT_AddonSettingsPanel"
bl_label = ""
bl_space_type = 'NODE_EDITOR'
bl_region_type = 'UI'
bl_category = "Serpens"
bl_order = 0
bl_options={"HEADER_LAYOUT_EXPAND"}
@classmethod
def poll(cls, context):
return context.space_data.tree_type == "ScriptingNodesTree" and context.space_data.node_tree
def draw_header(self, context):
layout = self.layout
layout.label(text="Easy BPY")
layout.operator("wm.url_open", text="", icon="QUESTION", emboss=False).url = "https://joshuaknauber.notion.site/Easy-BPY-e3a894c7bf4c469389e6caa7640c3219"
def draw(self, context):
layout = self.layout
sn = context.scene.sn
layout.use_property_split = True
layout.use_property_decorate = False
if sn.easy_bpy_path:
layout.label(text="Easy BPY installed", icon="CHECKMARK")
layout.operator("sn.open_explorer", text="Open Install", icon="FILE_FOLDER").path = sn.easy_bpy_path
else:
layout.label(text="Easy BPY not installed", icon="CANCEL")
layout.operator("wm.url_open", text="Documentation", icon="URL").url = "https://curtisholt.online/easybpy"
@@ -0,0 +1,11 @@
import bpy
import os
class SN_UL_AssetList(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row()
row.label(text="", icon="ASSET_MANAGER" if "." in item.path else "FILEBROWSER")
row.prop(item, "name", text="", emboss=False)
@@ -0,0 +1,44 @@
import bpy
class SN_PT_AssetsPanel(bpy.types.Panel):
bl_idname = "SN_PT_AssetsPanel"
bl_label = ""
bl_space_type = 'NODE_EDITOR'
bl_region_type = 'UI'
bl_category = "Serpens"
bl_order = 2
bl_options = {"DEFAULT_CLOSED", "HEADER_LAYOUT_EXPAND"}
@classmethod
def poll(cls, context):
return context.space_data.tree_type == "ScriptingNodesTree" and context.space_data.node_tree
def draw_header(self, context):
layout = self.layout
layout.label(text="Assets")
layout.operator("wm.url_open", text="", icon="QUESTION", emboss=False).url = "https://joshuaknauber.notion.site/Assets-c013c317a1b840b8824a4161da296614"
def draw(self, context):
layout = self.layout
sn = context.scene.sn
# draw asset list
row = layout.row(align=False)
col = row.column(align=True)
col.template_list("SN_UL_AssetList", "Assets", sn, "assets", sn, "asset_index", rows=3)
col.operator("sn.add_asset_node", text="Add Node", icon="ADD")
col = row.column(align=True)
col.operator("sn.add_asset", text="", icon="ADD")
col.operator("sn.find_asset", text="", icon="VIEWZOOM")
col.operator("sn.remove_asset", text="", icon="REMOVE")
# draw asset settings
if sn.asset_index < len(sn.assets):
asset = sn.assets[sn.asset_index]
col = layout.column()
col.use_property_split = True
col.use_property_decorate = False
col.prop(asset, "path", text="")
@@ -0,0 +1,252 @@
import bpy
from ...settings.data_properties import get_data_items, item_from_path, filter_items, filter_defaults
class SN_OT_ShowDataOverview(bpy.types.Operator):
bl_idname = "sn.show_data_overview"
bl_label = "Show Data Overview"
bl_description = "Opens a window that shows a data overview"
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
for area in context.screen.areas:
if area.type == "PREFERENCES":
break
else:
bpy.ops.screen.userpref_show("INVOKE_DEFAULT")
if not context.scene.sn.hide_preferences:
context.scene.sn.hide_preferences = True
return {"FINISHED"}
class SN_OT_ExitDataSearch(bpy.types.Operator):
bl_idname = "sn.exit_search"
bl_label = "Exit Data Search"
bl_description = "Exits the data search mode"
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
context.scene.sn.hide_preferences = False
return {"FINISHED"}
class SN_OT_ExpandData(bpy.types.Operator):
bl_idname = "sn.expand_data"
bl_label = "Expand Data"
bl_description = "Loads the items for the given item"
bl_options = {"REGISTER", "INTERNAL"}
path: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
sn = context.scene.sn
if "bpy.ops" in self.path:
item = sn.ops_items["operators"][self.path.split(".")[-1]]
item["expanded"] = not item["expanded"]
else:
item = item_from_path(sn.data_items, self.path)
item["expanded"] = not item["expanded"]
if not item["properties"]:
try:
item["data"]
item["properties"] = get_data_items(self.path, item["data"])
except:
item["has_properties"] = False
item["expanded"] = False
self.report({"ERROR"}, message="This data doesn't exist anymore!")
return {"FINISHED"}
class SN_OT_ExpandAllOperators(bpy.types.Operator):
bl_idname = "sn.expand_operators"
bl_label = "Expand Operators"
bl_description = "Expands all operators"
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
sn = context.scene.sn
if sn.ops_items["operators"]:
expand = not list(sn.ops_items["operators"].values())[0]["expanded"]
for item in sn.ops_items["operators"].values():
item["expanded"] = expand
return {"FINISHED"}
class SN_OT_FilterData(bpy.types.Operator):
bl_idname = "sn.filter_data"
bl_label = "Filter Data"
bl_description = "Filters this items data"
bl_options = {"REGISTER", "INTERNAL"}
path: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def update_filters(self, context):
item = item_from_path(context.scene.sn.data_items, self.path)
item["data_search"] = self.data_search
item["data_filter"] = self.data_filter
data_search: bpy.props.StringProperty(default="",
options={"SKIP_SAVE", "HIDDEN", "TEXTEDIT_UPDATE"},
update=update_filters)
data_filter: bpy.props.EnumProperty(name="Type",
options={"ENUM_FLAG"},
description="Filter by data type",
items=filter_items,
default=filter_defaults,
update=update_filters)
def update_reset(self, context):
if not self.reset:
self["reset"] = True
self.data_search = ""
self.data_filter = filter_defaults
reset: bpy.props.BoolProperty(name="Reset", default=True,
description="Reset the filters",
update=update_reset)
def execute(self, context):
return {"FINISHED"}
def draw(self, context):
layout = self.layout
layout.label(text="Search:")
row = layout.row()
row.prop(self, "data_search", text="")
row.prop(self, "reset", text="", icon="LOOP_BACK", invert_checkbox=True)
layout.separator()
col = layout.column()
col.prop(self, "data_filter", expand=True)
def invoke(self, context, event):
item = item_from_path(context.scene.sn.data_items, self.path)
last_filter = item["data_filter"]
last_search = item["data_search"]
self.data_filter = last_filter
self.data_search = last_search
return context.window_manager.invoke_popup(self, width=300)
class SN_OT_ResetFilters(bpy.types.Operator):
bl_idname = "sn.reset_filters"
bl_label = "Reset Filters"
bl_description = "Resets these filters"
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
context.scene.sn.data_filter = filter_defaults
context.scene.sn.data_search = ""
for area in context.screen.areas:
area.tag_redraw()
return {"FINISHED"}
class SN_OT_ReloadData(bpy.types.Operator):
bl_idname = "sn.reload_data"
bl_label = "Reload Data"
bl_description = "Reloads the listed scene data"
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
context.scene.sn.hide_preferences = True
return {"FINISHED"}
class SN_OT_ResetItemFilters(bpy.types.Operator):
bl_idname = "sn.reset_item_filters"
bl_label = "Reset Item Filters"
bl_description = "Reset this items filters"
bl_options = {"REGISTER", "INTERNAL"}
path: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
item = item_from_path(context.scene.sn.data_items, self.path)
try:
item["data"]
item["data_filter"] = filter_defaults
item["data_search"] = ""
except:
item["has_properties"] = False
item["expanded"] = False
self.report({"ERROR"}, message="This data doesn't exist anymore!")
return {"FINISHED"}
class SN_OT_ReloadItemData(bpy.types.Operator):
bl_idname = "sn.reload_item_data"
bl_label = "Reload Item Data"
bl_description = "Reloads this items data"
bl_options = {"REGISTER", "INTERNAL"}
path: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
item = item_from_path(context.scene.sn.data_items, self.path)
try:
item["data"]
item["properties"] = get_data_items(self.path, item["data"])
except:
item["has_properties"] = False
item["expanded"] = False
self.report({"ERROR"}, message="This data doesn't exist anymore!")
return {"FINISHED"}
class SN_OT_CopyDataPath(bpy.types.Operator):
bl_idname = "sn.copy_data_path"
bl_label = "Copy Data Path"
bl_description = "Copy data path to paste in a node"
bl_options = {"REGISTER", "INTERNAL"}
path: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
type: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
required: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
context.window_manager.clipboard = self.path
context.scene.sn.last_copied_datapath = self.path
context.scene.sn.last_copied_datatype = self.type
context.scene.sn.last_copied_required = self.required
self.report({"INFO"}, message="Copied!")
return {"FINISHED"}
class SN_OT_AddToSearch(bpy.types.Operator):
bl_idname = "sn.add_to_search"
bl_label = "Add To Search"
bl_description = "Adds this section to the search"
bl_options = {"REGISTER", "INTERNAL"}
section: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
sn = context.scene.sn
if sn.discover_search.startswith(self.section):
sn.discover_search = sn.discover_search[len(self.section)+1:]
elif sn.discover_search.endswith(self.section):
sn.discover_search = sn.discover_search[:-len(self.section)-1]
elif f",{self.section}," in sn.discover_search:
sn.discover_search = sn.discover_search.replace(f",{self.section},", ",")
else:
if sn.discover_search:
sn.discover_search += f",{self.section}"
else:
sn.discover_search = self.section
return {"FINISHED"}
@@ -0,0 +1,284 @@
import bpy
from ...addon.properties.settings.settings import property_icons
from ...settings.data_properties import filter_defaults
from ...settings import global_search
class SN_PT_navigation_bar(bpy.types.Panel):
bl_label = "Preferences Navigation"
bl_space_type = 'PREFERENCES'
bl_region_type = 'NAVIGATION_BAR'
bl_options = {'HIDE_HEADER'}
@classmethod
def poll(cls, context):
return context.scene.sn.hide_preferences
def draw(self, context):
layout = self.layout
sn = context.scene.sn
row = layout.row()
row.scale_y = 1.4
row.alert = True
row.operator("sn.exit_search", text="Exit", icon="PANEL_CLOSE")
layout.separator()
layout.operator("wm.url_open", text="How to", icon="QUESTION").url = "https://joshuaknauber.notion.site/Blend-Data-33e9f2ea40f44c2498cb26838662b621"
layout.separator(factor=2)
col = layout.column(align=True)
col.scale_y = 1.4
col.operator("sn.reload_data", text="Reload", icon="FILE_REFRESH")
col.separator()
col.prop_enum(sn, "data_category", value="discover", text="Discover (BETA)", icon="WORLD")
layout.separator()
layout.label(text="Source:")
col = layout.column(align=True)
col.scale_y = 1.4
col.prop_enum(sn, "data_category", value="app")
col.prop_enum(sn, "data_category", value="context")
col.prop_enum(sn, "data_category", value="data")
col.separator()
col.prop_enum(sn, "data_category", value="ops")
layout.separator()
col = layout.column()
row = col.row()
row.label(text="Filter Overview:")
row.operator("sn.reset_filters", text="", icon="LOOP_BACK", emboss=False)
if sn.data_category == "discover":
row = col.row()
row.enabled = False
row.label(text=f"Total: {len(global_search.data_flat)} items")
row = col.row()
row.enabled = False
row.label(text=f"Full Matches: {sn.discover_data['full_matches']} items")
row = col.row()
row.scale_y = 1.2
if sn.data_category == "discover":
row.prop(sn, "discover_search", text="", icon="VIEWZOOM")
else:
row.prop(sn, "data_search", text="", icon="VIEWZOOM")
subcol = col.column()
subcol.enabled = sn.data_category != "ops"
subcol.prop(sn, "data_filter", expand=True)
layout.separator()
layout.prop(sn, "show_path")
if sn.data_category == "discover":
layout.prop(sn, "discover_full_only")
layout.prop(sn, "discover_show_amount", text="Max Amount")
class SN_PT_FilterDataSettings(bpy.types.Panel):
bl_idname = "SN_PT_FilterDataSettings"
bl_label = "Filter"
bl_space_type = "PREFERENCES"
bl_region_type = "WINDOW"
bl_options = {"HIDE_HEADER"}
def draw(self, context):
layout = self.layout
if getattr(context, "sn_filter_path", None):
row = layout.row()
row.prop(context.sn_filter_path, "data_search", text="", icon="VIEWZOOM")
col = layout.column()
col.prop(context.sn_filter_path, "data_filter")
path_notes = {
"bpy.context.preferences.keymap": "Copy shortcuts from Context -> Window Manager -> Keyconfigs -> Your Shortcut -> Type",
"bpy.context.window_manager.keyconfigs": "To display a shortcut, find it in the User Key Config below, copy its Type property and check Full Shortcut on the node",
"bpy.context.active_object": "To set the active object use the active object output on the Objects node or copy the active object from the active view layer",
}
class SN_PT_data_search(bpy.types.Panel):
bl_space_type = 'PREFERENCES'
bl_region_type = 'WINDOW'
bl_label = "Display"
bl_options = {'HIDE_HEADER'}
@classmethod
def poll(cls, context):
return context.scene.sn.hide_preferences
def should_draw(self, item, search_value, filters):
if search_value.lower() in item["name"].lower():
return item["type"] in filters
return False
def draw_item(self, layout, item):
box = layout.box()
row = box.row()
if not item["has_properties"]:
row.scale_y = 0.75
else:
op = row.operator("sn.expand_data", text="", icon="TRIA_DOWN" if item["expanded"] else "TRIA_RIGHT", emboss=False)
op.path = item["path"]
subrow = row.row(align=True)
has_filters = item["data_search"] != "" or item["data_filter"] != filter_defaults
op = subrow.operator("sn.filter_data", text="", icon="FILTER", emboss=has_filters, depress=has_filters)
op.path = item["path"]
if has_filters:
op = subrow.operator("sn.reset_item_filters", text="", icon="LOOP_BACK", depress=True)
op.path = item["path"]
row.label(text=item["name"])
icon = property_icons[item["type"]] if item["type"] in property_icons else "ERROR"
subrow = row.row()
subrow.enabled = False
subrow.label(text=item["type"], icon=icon)
if bpy.context.scene.sn.show_path:
subrow = row.row()
subrow.enabled = False
subrow.label(text=item["path"])
if item["has_properties"]:
op = row.operator("sn.reload_item_data", text="", icon="FILE_REFRESH", emboss=False)
op.path = item["path"]
op = row.operator("sn.copy_data_path", text="", icon="COPYDOWN", emboss=False)
op.path = item["path"]
op.type = item["type"]
op.required = item["required"]
if item["expanded"]:
row = box.row()
split = row.split(factor=0.015)
split.label(text="")
col = split.column(align=True)
if item["path"] in path_notes:
box = col.box()
box.scale_y = 0.75
box.label(text=path_notes[item["path"]], icon="INFO")
is_empty = True
for key in item["properties"].keys():
sub_item = item["properties"][key]
if self.should_draw(sub_item, item["data_search"], item["data_filter"]):
self.draw_item(col, sub_item)
if sub_item["clamped"]:
box = col.box()
box.scale_y = 0.75
box.label(text="... Shortened because of too many items", icon="PLUS")
col.separator()
is_empty = False
if is_empty:
col.label(text="No Items for these filters!", icon="INFO")
def draw_operator_category(self, layout, category):
sn = bpy.context.scene.sn
box = layout.box()
row = box.row()
op = row.operator("sn.expand_data", text="", icon="TRIA_DOWN" if sn.ops_items["operators"][category]["expanded"] else "TRIA_RIGHT", emboss=False)
op.path = f"bpy.ops.{category}"
if category == "sn":
row.label(text="Serpens")
elif category == "sna":
row.label(text="Serpens Addon")
else:
row.label(text=category.replace("_", " ").title())
if sn.ops_items["operators"][category]["expanded"]:
row = box.row()
split = row.split(factor=0.015)
split.label(text="")
col = split.column(align=True)
for operator in sn.ops_items["operators"][category]["items"]:
if operator["operator"] in sn.ops_items["filtered"][category]:
path = f"bpy.ops.{category}.{operator['operator']}()"
box = col.box()
box.scale_y = 0.75
row = box.row()
row.label(text=operator["name"])
if bpy.context.scene.sn.show_path:
subrow = row.row()
subrow.enabled = False
subrow.label(text=path)
op = row.operator("sn.copy_python_name", text="", icon="COPYDOWN", emboss=False)
op.name = path
def draw_global_search(self, layout):
sn = bpy.context.scene.sn
def is_section_in_search(section):
if sn.discover_search.startswith(section) or \
sn.discover_search.endswith(section) or \
f",{section}," in sn.discover_search:
return True
return False
col = layout.column(align=True)
for path in bpy.context.scene.sn.discover_data["items"]:
item = global_search.data_flat[path]
box = col.box()
row = box.row()
subrow = row.row(align=True)
subrow.alignment = "LEFT"
for section in path.split("."):
if not section == "bpy":
display = section.replace("_", " ").title()
if "[" in display and "]" in display:
display = display.split("[")[0] + ": " + display.split("[")[1].replace("]", "")
subrow.operator("sn.add_to_search", text=display, emboss=not is_section_in_search(section)).section = section
row.label(text="")
if bpy.context.scene.sn.show_path:
subcol = row.column()
subcol.enabled = False
subcol.label(text=path)
row.label(text="")
op = row.operator("sn.copy_python_name", text="", icon="COPYDOWN", emboss=False)
op.name = path
def draw(self, context):
layout = self.layout
sn = context.scene.sn
col = layout.column(align=True)
if sn.data_category == "discover":
self.draw_global_search(col)
else:
is_empty = True
if sn.data_category == "ops":
row = col.row()
row.label(text="Use property functions instead of operators when possible!", icon="INFO")
row.operator("sn.expand_operators", text="", icon="FULLSCREEN_ENTER", emboss=False)
col.separator()
for cat in sn.ops_items["operators"].keys():
if cat in sn.ops_items["filtered"].keys():
self.draw_operator_category(col, cat)
is_empty = False
else:
for key in sn.data_items[sn.data_category].keys():
item = sn.data_items[sn.data_category][key]
if self.should_draw(item, sn.data_search, sn.data_filter):
self.draw_item(col, item)
is_empty = False
if is_empty:
layout.label(text="No Items for these filters!", icon="INFO")
@@ -0,0 +1,16 @@
import bpy
import subprocess
class SN_OT_OpenExplorer(bpy.types.Operator):
bl_idname = "sn.open_explorer"
bl_label = "Open Explorer"
bl_description = "Open the explorer"
bl_options = {"REGISTER", "INTERNAL"}
path: bpy.props.StringProperty({"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
subprocess.Popen(f'explorer /select,"{self.path}"')
return {"FINISHED"}
@@ -0,0 +1,67 @@
import bpy
from .graph_ui_list import get_selected_graph, get_selected_graph_offset
class SN_OT_GetPythonName(bpy.types.Operator):
bl_idname = "sn.get_python_name"
bl_label = "Get Python Name"
bl_description = "Get the python name for this element"
bl_options = {"REGISTER","UNDO","INTERNAL"}
to_copy: bpy.props.StringProperty()
def execute(self, context):
bpy.context.window_manager.clipboard = self.to_copy
self.report({"INFO"},message="Python path copied")
return {"FINISHED"}
class SN_PT_GraphPanel(bpy.types.Panel):
bl_idname = "SN_PT_GraphPanel"
bl_label = ""
bl_space_type = 'NODE_EDITOR'
bl_region_type = 'UI'
bl_category = "Serpens"
bl_order = 0
bl_options = {"HEADER_LAYOUT_EXPAND"}
@classmethod
def poll(cls, context):
return context.space_data.tree_type == "ScriptingNodesTree" and context.space_data.node_tree
def draw_header(self, context):
layout = self.layout
layout.label(text="Node Trees")
layout.operator("wm.url_open", text="", icon="QUESTION", emboss=False).url = "https://joshuaknauber.notion.site/Workflow-Introduction-d235d03178124dc9b752088d75a25192"
def draw(self, context):
layout = self.layout
sn = context.scene.sn
tree = get_selected_graph()
before = get_selected_graph_offset(-1)
after = get_selected_graph_offset(1)
row = layout.row(align=False)
col = row.column(align=True)
if sn.show_graph_categories:
subrow = col.row(align=True)
subrow.prop(sn, "active_graph_category", text="")
subrow.operator("sn.edit_graph_categories", text="", icon="GREASEPENCIL")
col.template_list("SN_UL_GraphList", "Graphs", bpy.data, "node_groups", sn, "node_tree_index", rows=4)
col = row.column(align=True)
col.operator("sn.add_graph", text="", icon="ADD")
col.operator("sn.append_graph", text="", icon="APPEND_BLEND")
subrow = col.row(align=True)
subrow.enabled = tree != None
subrow.operator("sn.remove_graph", text="", icon="REMOVE")
col.separator()
subrow = col.row(align=True)
subrow.enabled = tree != None and before != None
subrow.operator("sn.move_node_tree", text="", icon="TRIA_UP").move_up = True
subrow = col.row(align=True)
subrow.enabled = tree != None and after != None
subrow.operator("sn.move_node_tree", text="", icon="TRIA_DOWN").move_up = False
@@ -0,0 +1,110 @@
import bpy
def get_selected_graph():
sn = bpy.context.scene.sn
if sn.node_tree_index < len(bpy.data.node_groups):
ntree = bpy.data.node_groups[sn.node_tree_index]
if ntree.bl_idname == "ScriptingNodesTree":
cat_list = list(map(lambda cat: cat.name, sn.graph_categories))
if sn.active_graph_category == "ALL":
return ntree
elif sn.active_graph_category == "OTHER":
if ntree.category == "OTHER" or not ntree.category or not ntree.category in cat_list:
return ntree
elif ntree.category == sn.active_graph_category:
return ntree
return None
filtered_cache = {}
def get_filtered_graphs():
sn = bpy.context.scene.sn
key = "|".join(list(map(lambda ntree: getattr(ntree, "category", "SHADER") + "," + str(getattr(ntree, "index", 0)), bpy.data.node_groups))) + "|" + bpy.context.scene.sn.active_graph_category
if key in filtered_cache:
return filtered_cache[key]
filtered = []
cat_list = list(map(lambda cat: cat.name, sn.graph_categories))
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
if sn.active_graph_category == "ALL":
filtered.append(ntree)
elif sn.active_graph_category == "OTHER":
if ntree.category == "OTHER" or not ntree.category or not ntree.category in cat_list:
filtered.append(ntree)
elif ntree.category == sn.active_graph_category:
filtered.append(ntree)
filtered = list(sorted(filtered, key=lambda n: n.index))
filtered_cache[key] = filtered
return filtered
def get_selected_graph_offset(offset):
global filtered_cache
selected = get_selected_graph()
filtered = get_filtered_graphs()
if selected:
if not selected in filtered:
filtered_cache = {}
filtered = get_filtered_graphs()
i = filtered.index(selected)
i += offset
if i >= 0 and i < len(filtered):
return filtered[i]
return None
class SN_UL_GraphList(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row()
row.label(text="", icon="SCRIPT")
row.prop(item, "name", emboss=False, text="")
if context.scene.sn.show_graph_categories:
row.operator("sn.move_graph_category", text="",
icon="FORWARD", emboss=False).index = index
def filter_items(self, context, data, propname):
sn = context.scene.sn
node_trees = getattr(data, propname)
helper_funcs = bpy.types.UI_UL_list
_sort = [(idx, frame)
for idx, frame in enumerate(bpy.data.node_groups)]
flt_neworder = helper_funcs.sort_items_helper(
_sort, lambda e: getattr(e[1], "index", 0), False)
if sn.active_graph_category == "ALL":
flt_flags = helper_funcs.filter_items_by_name(
"ScriptingNodesTree", self.bitflag_filter_item, node_trees, "bl_idname", reverse=False)
elif sn.active_graph_category == "OTHER":
flt_flags = []
cat_list = list(map(lambda cat: cat.name, sn.graph_categories))
for tree in node_trees:
if not hasattr(tree, "category"):
flt_flags.append(0)
elif tree.category == "OTHER" or not tree.category or not tree.category in cat_list:
flt_flags.append(self.bitflag_filter_item)
else:
flt_flags.append(0)
else:
flt_flags = []
for tree in node_trees:
if not hasattr(tree, "category"):
flt_flags.append(0)
elif tree.category == sn.active_graph_category:
flt_flags.append(self.bitflag_filter_item)
else:
flt_flags.append(0)
for i in range(len(node_trees)):
if self.filter_name and not self.filter_name.lower() in node_trees[i].name.lower():
flt_flags[i] = 0
return flt_flags, flt_neworder
@@ -0,0 +1,205 @@
import bpy
from ...extensions import package_ops
from ...extensions import snippet_ops
class SN_PT_ExtensionsPanel(bpy.types.Panel):
bl_idname = "SN_PT_ExtensionsPanel"
bl_label = ""
bl_space_type = "NODE_EDITOR"
bl_region_type = "UI"
bl_category = "Serpens"
bl_order = 6
bl_options = {"DEFAULT_CLOSED", "HEADER_LAYOUT_EXPAND"}
@classmethod
def poll(cls, context):
return (
context.space_data.tree_type == "ScriptingNodesTree"
and context.space_data.node_tree
)
def draw_header(self, context):
layout = self.layout
layout.label(text="Node Extensions")
layout.operator("wm.url_open", text="", icon="QUESTION", emboss=False).url = (
"https://joshuaknauber.notion.site/Packages-Snippets-5fc9492b640146a2bcafb269d4a9e876"
)
def draw(self, context):
layout = self.layout
class SN_PT_SnippetsPanel(bpy.types.Panel):
bl_parent_id = "SN_PT_ExtensionsPanel"
bl_idname = "SN_PT_SnippetsPanel"
bl_label = ""
bl_space_type = "NODE_EDITOR"
bl_region_type = "UI"
bl_category = "Serpens"
bl_options = {"HEADER_LAYOUT_EXPAND"}
bl_order = 1
@classmethod
def poll(cls, context):
return (
context.space_data.tree_type == "ScriptingNodesTree"
and context.space_data.node_tree
)
def draw_header(self, context):
layout = self.layout
layout.label(text="Snippets")
def draw(self, context):
layout = self.layout
row = layout.row()
row.scale_y = 1.1
row.operator(
"sn.open_preferences", text="Get Snippets", icon="URL"
).navigation = "MARKET"
node = context.space_data.node_tree.nodes.active
row = layout.row()
row.scale_y = 1.1
if (
node
and node.select
and node.bl_idname
in ["SN_RunFunctionNode", "SN_RunInterfaceFunctionNodeNew"]
):
if getattr(node, "ref_SN_FunctionNode", None) or getattr(
node, "ref_SN_RunInterfaceFunctionNodeNew", None
):
op = row.operator(
"sn.draw_export_snippet",
text="Export Snippet",
icon="EXPORT",
depress=True,
)
op.node = node.name
op.tree = node.node_tree.name
else:
box = row.box()
box.label(
text="Select a valid Run Function node to export a snippet",
icon="EXPORT",
)
else:
box = row.box()
box.label(
text="Select Run Function node to export a snippet", icon="EXPORT"
)
layout.separator()
row = layout.row()
row.scale_y = 1.1
row.operator("sn.install_snippet", text="Install Snippets", icon="FILE_FOLDER")
for i, snippet in enumerate(snippet_ops.loaded_snippets):
box = layout.box()
row = box.row()
if type(snippet) == str:
row.label(text=snippet.split(".")[0])
row.operator(
"sn.uninstall_snippet", text="", icon="PANEL_CLOSE", emboss=False
).index = i
else:
cat = context.scene.sn.snippet_categories.get(snippet["name"])
row.prop(
cat,
"expand",
text="",
emboss=False,
icon=(
"DISCLOSURE_TRI_DOWN" if cat.expand else "DISCLOSURE_TRI_RIGHT"
),
)
row.label(text=snippet["name"])
row.operator(
"sn.uninstall_snippet", text="", icon="PANEL_CLOSE", emboss=False
).index = i
if cat.expand:
row = box.row()
split = row.split(factor=0.1)
split.label(text="")
col = split.column(align=True)
col.enabled = False
for name in snippet["snippets"]:
col.label(text=name.split(".")[0])
if not snippet_ops.loaded_snippets:
box = layout.box()
box.label(text="No snippets installed!", icon="INFO")
class SN_PT_PackagesPanel(bpy.types.Panel):
bl_parent_id = "SN_PT_ExtensionsPanel"
bl_idname = "SN_PT_PackagesPanel"
bl_label = ""
bl_space_type = "NODE_EDITOR"
bl_region_type = "UI"
bl_category = "Serpens"
bl_options = {"DEFAULT_CLOSED", "HEADER_LAYOUT_EXPAND"}
bl_order = 2
@classmethod
def poll(cls, context):
return (
context.space_data.tree_type == "ScriptingNodesTree"
and context.space_data.node_tree
)
def draw_header(self, context):
layout = self.layout
layout.label(text="Packages")
def draw(self, context):
layout = self.layout
row = layout.row()
row.scale_y = 1.1
row.operator(
"sn.open_preferences", text="Get Packages", icon="URL"
).navigation = "MARKET"
layout.separator()
row = layout.row(align=True)
row.scale_y = 1.1
row.operator("sn.install_package", text="Install Package", icon="FILE_FOLDER")
row.operator("sn.reload_packages", text="", icon="FILE_REFRESH")
for i, package in enumerate(package_ops.loaded_packages):
box = layout.box()
col = box.column(align=True)
row = col.row()
row.label(text=package["name"])
if package["wiki"]:
row.operator("wm.url_open", text="", icon="URL", emboss=False).url = (
package["wiki"]
)
row.operator(
"sn.uninstall_package", text="", icon="PANEL_CLOSE", emboss=False
).index = i
if package["description"]:
row = col.row()
row.enabled = False
row.label(text=package["description"])
if package["author"]:
row = col.row()
row.enabled = False
row.label(text="By: " + package["author"])
if package["version"]:
row = col.row()
row.enabled = False
row.label(text=package["version"])
if not package_ops.loaded_packages:
box = layout.box()
box.label(text="No packages installed!", icon="INFO")
if package_ops.require_reload:
row = layout.row()
row.alert = True
row.label(text="Restart blender to see package!", icon="INFO")
@@ -0,0 +1,93 @@
import bpy
from .property_ui_list import get_selected_property, get_selected_property_offset
class SN_PT_PropertyPanel(bpy.types.Panel):
bl_idname = "SN_PT_PropertyPanel"
bl_label = ""
bl_space_type = 'NODE_EDITOR'
bl_region_type = 'UI'
bl_category = "Serpens"
bl_order = 1
bl_options = {"HEADER_LAYOUT_EXPAND"}
@classmethod
def poll(cls, context):
return context.space_data.tree_type == "ScriptingNodesTree" and context.space_data.node_tree
def draw_header(self, context):
layout = self.layout
layout.label(text="Properties")
layout.operator("wm.url_open", text="", icon="QUESTION", emboss=False).url = "https://joshuaknauber.notion.site/Properties-6f7567be7bff4256b9bb0311e8d79f9d"
def draw(self, context):
layout = self.layout
sn = context.scene.sn
prop = get_selected_property()
before = get_selected_property_offset(-1)
after = get_selected_property_offset(1)
# draw property ui list
row = layout.row(align=False)
col = row.column(align=True)
if sn.show_property_categories:
subrow = col.row(align=True)
subrow.prop(sn, "active_prop_category", text="")
subrow.operator("sn.edit_property_categories", text="", icon="GREASEPENCIL")
col.template_list("SN_UL_PropertyList", "Properties", sn, "properties", sn, "property_index", rows=5)
col.operator("sn.add_property_node_popup", text="Add Node", icon="ADD")
col = row.column(align=True)
col.operator("sn.add_property", text="", icon="ADD")
col.operator("sn.find_property", text="", icon="VIEWZOOM")
subrow = col.row(align=True)
subrow.enabled = prop != None
subrow.operator("sn.remove_property", text="", icon="REMOVE")
col.separator()
subrow = col.row(align=True)
subrow.enabled = prop != None
op = subrow.operator("sn.duplicate_property", text="", icon="DUPLICATE")
col.separator()
subrow = col.row(align=True)
subrow.enabled = before != None and prop != None
op = subrow.operator("sn.move_property", text="", icon="TRIA_UP")
op.move_up = True
subrow = col.row(align=True)
subrow.enabled = after != None and prop != None
op = subrow.operator("sn.move_property", text="", icon="TRIA_DOWN")
op.move_up = False
if prop:
# draw property debug
if sn.debug_python_properties:
box = layout.box()
col = box.column(align=True)
row = col.row()
row.enabled = False
row.label(text="Register")
for line in prop.register_code.split("\n"):
col.label(text=line)
box = layout.box()
col = box.column(align=True)
row = col.row()
row.enabled = False
row.label(text="Unregister")
for line in prop.unregister_code.split("\n"):
col.label(text=line)
col = layout.column()
col.use_property_split = True
col.use_property_decorate = False
# draw general property settings
prop.draw(context, col)
# draw property specific settings
col.separator()
prop.settings.draw(context, col)
@@ -0,0 +1,86 @@
import bpy
def get_filtered_properties():
filtered = []
sn = bpy.context.scene.sn
for prop in sn.properties:
cat_list = list(map(lambda cat: cat.name, sn.property_categories))
if sn.active_prop_category == "ALL":
filtered.append(prop)
elif sn.active_prop_category == "OTHER":
if prop.category == "OTHER" or not prop.category or not prop.category in cat_list:
filtered.append(prop)
elif prop.category == sn.active_prop_category:
filtered.append(prop)
return filtered
def get_selected_property():
sn = bpy.context.scene.sn
if sn.property_index < len(sn.properties):
prop = sn.properties[sn.property_index]
cat_list = list(map(lambda cat: cat.name, sn.property_categories))
if sn.active_prop_category == "ALL":
return prop
elif sn.active_prop_category == "OTHER":
if prop.category == "OTHER" or not prop.category or not prop.category in cat_list:
return prop
elif prop.category == sn.active_prop_category:
return prop
return None
def get_selected_property_offset(offset):
selected = get_selected_property()
filtered = get_filtered_properties()
if selected:
i = filtered.index(selected)
i += offset
if i >= 0 and i < len(filtered):
return filtered[i]
return None
class SN_UL_PropertyList(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row()
row.label(text="", icon=item.icon)
row.prop(item, "name", emboss=False, text="")
if not item.property_type == "Group":
row.operator("sn.copy_python_name", text="", icon="COPYDOWN", emboss=False).name = item.data_path
if context.scene.sn.show_property_categories and item.prop_collection_origin == context.scene.sn:
row.operator("sn.move_property_category", text="", icon="FORWARD", emboss=False).index = index
def filter_items(self, context, data, propname):
sn = context.scene.sn
helper_funcs = bpy.types.UI_UL_list
if sn.active_prop_category == "ALL" or data != context.scene.sn:
flt_flags = helper_funcs.filter_items_by_name(self.filter_name, self.bitflag_filter_item, sn.properties, "name", reverse=False)
return flt_flags, []
elif sn.active_prop_category == "OTHER":
flt_flags = []
cat_list = list(map(lambda cat: cat.name, sn.property_categories))
for prop in sn.properties:
if prop.category == "OTHER" or not prop.category or not prop.category in cat_list:
if not self.filter_name or self.filter_name.lower() in prop.name.lower():
flt_flags.append(self.bitflag_filter_item)
else:
flt_flags.append(0)
else:
flt_flags.append(0)
return flt_flags, []
else:
flt_flags = []
for prop in sn.properties:
if prop.category == sn.active_prop_category:
if not self.filter_name or self.filter_name.lower() in prop.name.lower():
flt_flags.append(self.bitflag_filter_item)
else:
flt_flags.append(0)
else:
flt_flags.append(0)
return flt_flags, []
@@ -0,0 +1,10 @@
import bpy
class SN_UL_VariableList(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row()
row.label(text="", icon=item.icon)
row.prop(item, "name", emboss=False, text="")
row.operator("sn.copy_python_name", text="", icon="COPYDOWN", emboss=False).name = item.data_path
@@ -0,0 +1,79 @@
import bpy
class SN_PT_VariablePanel(bpy.types.Panel):
bl_parent_id = "SN_PT_GraphPanel"
bl_idname = "SN_PT_VariablePanel"
bl_label = ""
bl_space_type = 'NODE_EDITOR'
bl_region_type = 'UI'
bl_category = "Serpens"
bl_options = {"DEFAULT_CLOSED", "HEADER_LAYOUT_EXPAND"}
bl_order = 1
@classmethod
def poll(cls, context):
return context.space_data.tree_type == "ScriptingNodesTree" and context.space_data.node_tree
def draw_header(self, context):
layout = self.layout
layout.label(text="Variables")
layout.operator("wm.url_open", text="", icon="QUESTION", emboss=False).url = "https://joshuaknauber.notion.site/Variables-ff5e8ae2e4154c8fa9eed43ecaa0c165"
def draw(self, context):
layout = self.layout
sn = context.scene.sn
ntree = context.space_data.node_tree
# draw variable ui list
row = layout.row(align=False)
col = row.column(align=True)
if sn.overwrite_variable_graph:
col.prop(sn, "variable_graph", text="")
ntree = bpy.data.node_groups[sn.variable_graph]
col.template_list("SN_UL_VariableList", "Variables", ntree, "variables", ntree, "variable_index", rows=4)
op = col.operator("sn.add_variable_node_popup", text="Add Node", icon="ADD")
op.node_tree = ntree.name
col = row.column(align=True)
col.operator("sn.add_variable", text="", icon="ADD").node_tree = ntree.name
col.operator("sn.find_variable", text="", icon="VIEWZOOM").node_tree = ntree.name
col.operator("sn.remove_variable", text="", icon="REMOVE").node_tree = ntree.name
col.separator()
subrow = col.row(align=True)
subrow.enabled = ntree.variable_index > 0
op = subrow.operator("sn.move_variable", text="", icon="TRIA_UP")
op.move_up = True
op.node_tree = ntree.name
subrow = col.row(align=True)
subrow.enabled = ntree.variable_index < len(ntree.variables)-1
op = subrow.operator("sn.move_variable", text="", icon="TRIA_DOWN")
op.move_up = False
op.node_tree = ntree.name
layout.separator()
if ntree.variable_index < len(ntree.variables):
var = ntree.variables[ntree.variable_index]
col = layout.column()
col.use_property_split = True
col.use_property_decorate = False
col.prop(var, "variable_type")
if var.variable_type == "String":
col.separator()
col.prop(var, "string_default")
elif var.variable_type == "Boolean":
col.separator()
col.prop(var, "boolean_default")
elif var.variable_type == "Float":
col.separator()
col.prop(var, "float_default")
elif var.variable_type == "Integer":
col.separator()
col.prop(var, "integer_default")
@@ -0,0 +1,10 @@
import bpy
def append_warning(self, context):
if context.space_data.node_tree and context.space_data.node_tree.bl_idname == "ScriptingNodesTree":
layout = self.layout
row = layout.row()
row.alert = True
row.label(text="Do not edit these settings!", icon="ERROR")
@@ -0,0 +1,66 @@
import bpy
# keymaps
addon_keymaps = {}
def get_shortcut(idname):
""" Returns the shortcut struct for the given idname """
return bpy.context.window_manager.keyconfigs.user.keymaps["Node Editor"].keymap_items[idname]
def register_keymaps():
# registers the visual scripting keymaps
# create keymap
global addon_keymaps
wm = bpy.context.window_manager
kc = wm.keyconfigs.addon
km = kc.keymaps.new(name="Node Editor", space_type="NODE_EDITOR")
# shortcut for compiling
kmi = km.keymap_items.new(
idname="sn.force_compile",
type="R",
value="PRESS",
shift=True,
ctrl=False,
alt=False,
)
addon_keymaps["compile"] = (km, kmi)
# shortcut for docs
kmi = km.keymap_items.new(
idname="sn.open_node_docs",
type="F1",
value="PRESS",
shift=False,
ctrl=False,
alt=False,
)
addon_keymaps["docs"] = (km, kmi)
# shortcut for adding a node from copied path
kmi = km.keymap_items.new(
idname="sn.add_copied_node",
type="V",
value="PRESS",
shift=True,
ctrl=False,
alt=False,
)
addon_keymaps["copied"] = (km, kmi)
def unregister_keymaps():
# unregister visual scripting keymaps
global addon_keymaps
for key in addon_keymaps:
km, kmi = addon_keymaps[ key ]
km.keymap_items.remove(kmi)
addon_keymaps.clear()
@@ -0,0 +1,61 @@
import bpy
class SN_OT_OpenNodeDocs(bpy.types.Operator):
bl_idname = "sn.open_node_docs"
bl_label = "Open Node Docs"
bl_description = "Open Node Documentation"
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
if getattr(context.space_data.node_tree.nodes.active, "is_sn", False):
if context.space_data.node_tree.nodes.active.select:
bpy.ops.wm.url_open(url="https://joshuaknauber.notion.site/555efb921f50426ea4d5812f1aa3e462?v=d781b590cc8f47449cb20812deab0cc6")
return {"FINISHED"}
class SN_MT_AddOperatorMenu(bpy.types.Menu):
bl_idname = "SN_MT_AddOperatorMenu"
bl_label = "Add Operator Node"
def draw(self, context):
layout = self.layout.menu_pie()
layout.operator("sn.add_copied_operator_node", text="Run Operator", icon="POSE_HLT").is_button = False
layout.operator("sn.add_copied_operator_node", text="Button", icon="MOUSE_LMB").is_button = True
class SN_OT_AddCopiedNode(bpy.types.Operator):
bl_idname = "sn.add_copied_node"
bl_label = "Add Copied Node"
bl_description = "Adds a node from the copied path for operators and properties"
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
if "bpy." in context.window_manager.clipboard and ".ops." in context.window_manager.clipboard:
bpy.ops.wm.call_menu_pie(name="SN_MT_AddOperatorMenu")
elif "bpy." in context.window_manager.clipboard and not ".ops." in context.window_manager.clipboard:
bpy.ops.node.add_node("INVOKE_DEFAULT", type="SN_BlenderPropertyNode", use_transform=True)
node = context.space_data.node_tree.nodes.active
bpy.ops.sn.paste_data_path(node=node.name, node_tree=context.space_data.node_tree.name)
return {"FINISHED"}
class SN_OT_AddCopiedOperatorNode(bpy.types.Operator):
bl_idname = "sn.add_copied_operator_node"
bl_label = "Add Copied Operator Node"
bl_description = "Adds a node from the copied path for operators"
bl_options = {"REGISTER", "INTERNAL"}
is_button: bpy.props.BoolProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
if "bpy." in context.window_manager.clipboard and ".ops." in context.window_manager.clipboard:
if self.is_button:
bpy.ops.node.add_node("INVOKE_DEFAULT", type="SN_ButtonNodeNew", use_transform=True)
else:
bpy.ops.node.add_node("INVOKE_DEFAULT", type="SN_RunOperatorNode", use_transform=True)
node = context.space_data.node_tree.nodes.active
node.source_type = "BLENDER"
node.pasted_operator = context.window_manager.clipboard
return {"FINISHED"}
@@ -0,0 +1,37 @@
import bpy
owner = object()
def name_change_callback(cls):
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
key = (
cls.collection_key_overwrite
if cls.collection_key_overwrite
else cls.bl_idname
)
for ref in ntree.node_collection(key).refs:
node = ref.node
if node and node.name != ref.name:
ref.name = node.name
node.on_node_name_change()
node._evaluate(bpy.context)
return
def subscribe_to_name_change():
unsubscribe_from_name_change()
for cls in bpy.types.Node.__subclasses__():
if getattr(cls, "is_sn", False):
subscribe_to = (cls, "name")
bpy.msgbus.subscribe_rna(
key=subscribe_to,
owner=owner,
args=(cls,),
notify=name_change_callback,
)
def unsubscribe_from_name_change():
bpy.msgbus.clear_by_owner(owner)
@@ -0,0 +1,147 @@
from bpy_extras.io_utils import ExportHelper
import bpy
import os
import shutil
from ...nodes.compiler import (
format_blender_manifest,
format_multifile,
format_single_file,
)
from ...utils import normalize_code
class SN_OT_ExportAddon(bpy.types.Operator, ExportHelper):
bl_idname = "sn.export_addon"
bl_label = "Export Addon"
bl_description = "Exports this addon to an installable zip file"
bl_options = {"REGISTER", "INTERNAL"}
filepath: bpy.props.StringProperty(
name="File Path",
description="Filepath used for exporting the file",
maxlen=1024,
subtype="FILE_PATH",
)
filename_ext = ".zip"
filter_glob: bpy.props.StringProperty(default="*.zip", options={"HIDDEN"})
def add_easy_bpy(self, path, code):
"""Adds the easybpy file to the addon if needed"""
if "easybpy" in code and bpy.context.scene.sn.easy_bpy_path:
shutil.copyfile(
src=bpy.context.scene.sn.easy_bpy_path,
dst=os.path.join(path, "easybpy.py"),
)
def add_assets(self, asset_path):
"""Adds the addon assets to the folder"""
for asset in bpy.context.scene.sn.assets:
if os.path.exists(asset.path):
if os.path.isdir(asset.path):
dirname = os.path.basename(asset.path)
if not dirname:
dirname = os.path.basename(os.path.dirname(asset.path))
shutil.copytree(
asset.path,
os.path.join(asset_path, dirname),
dirs_exist_ok=True,
)
else:
shutil.copy(
asset.path,
os.path.join(asset_path, os.path.basename(asset.path)),
)
def add_icons(self, icon_path):
"""Adds the icons to the folder"""
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
for node in ntree.nodes:
if node.bl_idname == "SN_IconNode":
if node.icon_source == "CUSTOM" and node.icon_file:
img_path = bpy.path.abspath(node.icon_file.filepath)
if os.path.exists(img_path):
filepath = os.path.join(
icon_path, os.path.basename(img_path)
)
shutil.copy(img_path, filepath)
else:
raise FileNotFoundError(
f"Could not find the icon file at {icon_path}"
)
def add_code(self, path):
"""Creates the index file"""
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
ntree.reevaluate()
if bpy.context.scene.sn.multifile:
files = format_multifile()
else:
files = {
"__init__.py": format_single_file(),
"blender_manifest.toml": format_blender_manifest(),
}
for name in files.keys():
with open(os.path.join(path, name), "a") as code_file:
code = files[name]
code = code.replace("from easybpy import", "from .easybpy import")
code_file.write(code)
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
ntree.reevaluate()
return code
def create_files(self, path):
"""Creates the addon files in the folder structure"""
self.add_assets(os.path.join(path, "assets"))
self.add_icons(os.path.join(path, "icons"))
code = self.add_code(path)
self.add_easy_bpy(path, code)
def create_structure(self, path):
"""Sets up the addons folder structure at the given filepath"""
os.mkdir(path)
baseDir = os.path.join(path, bpy.context.scene.sn.module_name)
os.mkdir(baseDir)
os.mkdir(os.path.join(baseDir, "assets"))
os.mkdir(os.path.join(baseDir, "icons"))
return baseDir
def zip_addon(self, path):
"""Zips the given path"""
shutil.make_archive(path, "zip", root_dir=path)
try:
shutil.rmtree(path)
except OSError as e:
self.report({"WARNING"}, message=f"Error: {e.filename} - {e.strerror}.")
def execute(self, context):
bpy.context.scene.sn.is_exporting = True
context.window_manager.progress_begin(0, 100)
try:
name, _ = os.path.splitext(self.filepath)
if os.path.exists(name):
self.report(
{"ERROR"},
message=f"Please delete the '{os.path.basename(name)}' folder before exporting.",
)
else:
baseDir = self.create_structure(name)
context.window_manager.progress_update(30)
self.create_files(baseDir)
context.window_manager.progress_update(90)
self.zip_addon(name)
bpy.ops.sn.export_to_marketplace("INVOKE_DEFAULT")
except Exception as e:
self.report({"ERROR"}, message=f"Error: {e}")
bpy.context.scene.sn.is_exporting = False
context.window_manager.progress_end()
return {"FINISHED"}
def invoke(self, context, event):
version = ".".join([str(i) for i in context.scene.sn.version])
self.filepath = f"{context.scene.sn.module_name}_{version}.blend"
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
@@ -0,0 +1,135 @@
import bpy
from ...interface.panels.graph_ui_list import get_selected_graph
class SN_GraphCategory(bpy.types.PropertyGroup):
def set_name(self, value):
for ntree in bpy.data.node_groups:
if hasattr(ntree, "category"):
if ntree.category and ntree.category == self.name:
ntree.category = value
self["name"] = value
def get_name(self):
return self.get("name", "New Category")
name: bpy.props.StringProperty(name="Name", default="New Category",
description="The name of this graph category",
set=set_name, get=get_name)
class SN_OT_AddGraphCategory(bpy.types.Operator):
bl_idname = "sn.add_graph_category"
bl_label = "Add Graph Category"
bl_description = "Adds a graph category"
bl_options = {"REGISTER", "INTERNAL", "UNDO"}
def execute(self, context):
context.scene.sn.graph_categories.add()
return {"FINISHED"}
class SN_OT_RemoveGraphCategory(bpy.types.Operator):
bl_idname = "sn.remove_graph_category"
bl_label = "Remove Graph Category"
bl_description = "Removes a graph category"
bl_options = {"REGISTER", "INTERNAL", "UNDO"}
index: bpy.props.IntProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
context.scene.sn.graph_categories.remove(self.index)
return {"FINISHED"}
class SN_OT_EditGraphCategories(bpy.types.Operator):
bl_idname = "sn.edit_graph_categories"
bl_label = "Edit Graph Categories"
bl_description = "Edit the addon graph categories"
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
return {"FINISHED"}
def draw(self, context):
layout = self.layout
layout.label(text="Categories")
for i, cat in enumerate(context.scene.sn.graph_categories):
row = layout.row()
row.scale_y = 1.2
row.prop(cat, "name", text="")
row.operator("sn.remove_graph_category", text="", icon="REMOVE", emboss=False).index = i
if not context.scene.sn.graph_categories:
row = layout.row()
row.enabled = False
row.label(text="No categories added", icon="ERROR")
row = layout.row()
row.scale_y = 1.2
row.operator("sn.add_graph_category", text="Add Category", icon="ADD")
def invoke(self, context, event):
return context.window_manager.invoke_popup(self, width=250)
class SN_OT_MoveGraphToCategory(bpy.types.Operator):
bl_idname = "sn.move_graph_to_category"
bl_label = "Move Graph Category"
bl_description = "Move the selected graph to a different category"
bl_options = {"REGISTER", "INTERNAL"}
category: bpy.props.IntProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
ntree = get_selected_graph()
if ntree:
if self.category == -1:
ntree.category = "OTHER"
else:
ntree.category = context.scene.sn.graph_categories[self.category].name
context.area.tag_redraw()
return {"FINISHED"}
class SN_OT_MoveGraphCategory(bpy.types.Operator):
bl_idname = "sn.move_graph_category"
bl_label = "Move Graph Category"
bl_description = "Move the selected graph to a different category"
bl_options = {"REGISTER", "INTERNAL"}
index: bpy.props.IntProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
return {"FINISHED"}
def draw(self, context):
layout = self.layout
ntree = get_selected_graph()
layout.label(text="Categories")
for i, cat in enumerate(context.scene.sn.graph_categories):
row = layout.row()
row.enabled = ntree != None and ntree.category != cat.name
row.scale_y = 1.2
row.operator("sn.move_graph_to_category", text=f"Move to '{cat.name}'", icon="FORWARD").category = i
row = layout.row()
row.enabled = ntree != None and ntree.category and ntree.category != "OTHER"
row.scale_y = 1.2
row.operator("sn.move_graph_to_category", text=f"Remove Category", icon="REMOVE").category = -1
if not len(context.scene.sn.graph_categories):
row = layout.row()
row.enabled = False
row.label(text="No categories added", icon="ERROR")
def invoke(self, context, event):
context.scene.sn.node_tree_index = self.index
return context.window_manager.invoke_popup(self, width=250)
@@ -0,0 +1,235 @@
from bpy_extras.io_utils import ImportHelper
import bpy
import os
from ...interface.panels.graph_ui_list import get_selected_graph, get_selected_graph_offset
from ...nodes.compiler import unregister_addon, compile_addon
def get_serpens_graphs():
graphs = []
for group in bpy.data.node_groups:
if group.bl_idname == "ScriptingNodesTree":
graphs.append(group)
return graphs
def reassign_tree_indices():
trees = []
for ngroup in bpy.data.node_groups:
if ngroup.bl_idname == "ScriptingNodesTree":
trees.append(ngroup)
trees = sorted(trees, key=lambda tree: tree.index)
for i in range(len(trees)):
trees[i].index = i
return trees
class SN_OT_AddGraph(bpy.types.Operator):
bl_idname = "sn.add_graph"
bl_label = "Add Node Tree"
bl_description = "Adds a node tree to the addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context):
sn = context.scene.sn
trees = reassign_tree_indices()
curr_index = 0
if sn.node_tree_index < len(bpy.data.node_groups) and bpy.data.node_groups[sn.node_tree_index].bl_idname == "ScriptingNodesTree":
curr_index = bpy.data.node_groups[sn.node_tree_index].index
for i in range(curr_index+1, len(trees)):
trees[i].index += 1
graph = bpy.data.node_groups.new("NodeTree", "ScriptingNodesTree")
graph.index = curr_index - 1
if sn.active_graph_category != "ALL":
graph.category = sn.active_graph_category
for i, group in enumerate(bpy.data.node_groups):
if group == graph:
sn.node_tree_index = i
return {"FINISHED"}
class SN_OT_RemoveGraph(bpy.types.Operator):
bl_idname = "sn.remove_graph"
bl_label = "Remove Node Tree"
bl_description = "Removes this node tree from the addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context):
if context.scene.sn.node_tree_index < len(bpy.data.node_groups):
return bpy.data.node_groups[context.scene.sn.node_tree_index].bl_idname == "ScriptingNodesTree"
def execute(self, context):
sn = context.scene.sn
group = bpy.data.node_groups[sn.node_tree_index]
curr_index = group.index
bpy.data.node_groups.remove(group)
trees = reassign_tree_indices()
for tree in trees:
if tree.index == curr_index:
for i, ntree in enumerate(bpy.data.node_groups):
if ntree == tree:
sn.node_tree_index = i
break
elif tree.index == curr_index - 1:
for i, ntree in enumerate(bpy.data.node_groups):
if ntree == tree:
sn.node_tree_index = i
break
else:
sn.node_tree_index = 0
compile_addon()
return {"FINISHED"}
def invoke(self, context, event):
return context.window_manager.invoke_confirm(self, event)
class SN_OT_AppendGraph(bpy.types.Operator, ImportHelper):
bl_idname = "sn.append_graph"
bl_label = "Append Node Tree"
bl_description = "Appends a node tree from another file to this addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
filter_glob: bpy.props.StringProperty( default='*.blend', options={'HIDDEN'} )
def execute(self, context):
_, extension = os.path.splitext(self.filepath)
if extension == ".blend":
bpy.ops.sn.append_popup("INVOKE_DEFAULT", path=self.filepath)
return {"FINISHED"}
class SN_OT_AppendPopup(bpy.types.Operator):
bl_idname = "sn.append_popup"
bl_label = "Append Node Tree"
bl_description = "Appends this node tree from the addon"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def get_graph_items(self, context):
""" Returns all node trees that can be found in the selected file """
items = []
with bpy.data.libraries.load(self.path) as (data_from, _):
for group in data_from.node_groups:
items.append((group, group, group))
if not items:
items = [("NONE", "NONE", "NONE")]
return items
path: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
graph: bpy.props.EnumProperty(name="Node Tree",
description="Node Tree to import",
items=get_graph_items,
options={"HIDDEN", "SKIP_SAVE"})
def execute(self, context):
if self.graph != "NONE":
# save previous groups
prev_groups = bpy.data.node_groups.values()
# append node group
with bpy.data.libraries.load(self.path) as (_, data_to):
data_to.node_groups = [self.graph]
# register new graph
new_groups = set(prev_groups) ^ set(bpy.data.node_groups.values())
for group in new_groups:
context.scene.sn.node_tree_index = bpy.data.node_groups.values().index(group)
compile_addon()
# redraw screen
context.area.tag_redraw()
return {"FINISHED"}
def draw(self, context):
if self.graph == "NONE":
self.layout.label(text="No Node Trees found in this blend file",icon="ERROR")
else:
self.layout.prop(self, "graph", text="Node Tree")
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
class SN_OT_ForceCompile(bpy.types.Operator):
bl_idname = "sn.force_compile"
bl_label = "This might be slow for large addons!"
bl_description = "Forces all node trees to compile"
bl_options = {"REGISTER", "INTERNAL"}
def fix_compile_order(self, refs):
for node in refs.nodes:
if node.order == 0:
node.order = 3
def execute(self, context):
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
for refs in ntree.node_refs:
refs.clear_unused_refs()
refs.fix_ref_names()
if refs.name == "SN_OnKeypressNode":
self.fix_compile_order(refs)
ntree.reevaluate()
compile_addon()
self.report({"INFO"}, message="Compiled successfully!")
return {"FINISHED"}
def invoke(self, context, event):
return context.window_manager.invoke_confirm(self, event)
class SN_OT_ForceUnregister(bpy.types.Operator):
bl_idname = "sn.force_unregister"
bl_label = "Force Unregister"
bl_description = "Forces all node trees to unregister"
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
unregister_addon()
return {"FINISHED"}
class SN_OT_MoveNodeTree(bpy.types.Operator):
bl_idname = "sn.move_node_tree"
bl_label = "Move Node Tree"
bl_description = "Moves this node tree in the list"
bl_options = {"REGISTER", "INTERNAL"}
move_up: bpy.props.IntProperty(options={"SKIP_SAVE", "HIDDEN"})
def execute(self, context):
reassign_tree_indices()
ntree = get_selected_graph()
before = get_selected_graph_offset(-1)
after = get_selected_graph_offset(1)
# move trees
if ntree:
if self.move_up and before:
temp_index = ntree.index
ntree.index = before.index
before.index = temp_index
elif not self.move_up and after:
temp_index = ntree.index
ntree.index = after.index
after.index = temp_index
return {"FINISHED"}
@@ -0,0 +1,138 @@
import bpy
import json
class SN_OT_CopyCommand(bpy.types.Operator):
bl_idname = "sn.copy_command"
bl_label = "Copy Command"
bl_description = "Copies the command to post in the discord server"
bl_options = {"REGISTER","UNDO","INTERNAL"}
url: bpy.props.StringProperty(default="",options={"SKIP_SAVE"})
price: bpy.props.StringProperty(default="Free",options={"SKIP_SAVE"})
blender: bpy.props.BoolProperty(default=False,options={"SKIP_SAVE"})
def execute(self, context):
sn = bpy.context.scene.sn
addon_info = {
"name": sn.addon_name,
"description": sn.description,
"category": sn.category if not sn.category == 'CUSTOM' else sn.custom_category,
"author": sn.author,
"blender_version": list(tuple(sn.blender)),
"addon_version": list(tuple(sn.version)),
"external": self.url != "",
"url": self.url,
"price": self.price,
"blend": self.blender,
"blend_url": "",
"user": 0,
"serpens_version": 3
}
if self.url == "":
addon_info["url"] = ""
addon_info["price"] = ""
bpy.context.window_manager.clipboard = json.dumps(addon_info)
self.report({"INFO"},message="Copied successfully!")
return {"FINISHED"}
class SN_OT_ExportToMarketplaceAddon(bpy.types.Operator):
bl_idname = "sn.export_to_marketplace"
bl_label = "Export Addon To Marketplace"
bl_description = "Exports the active node tree to the marketplace"
bl_options = {"REGISTER","UNDO","INTERNAL"}
url: bpy.props.StringProperty(default="",name="Addon URL",description="Enter the url to your addon here")
price: bpy.props.StringProperty(default="Free",name="Addon Price",description="Enter the price of your addon here")
blender: bpy.props.BoolProperty(default=False,options={"SKIP_SAVE"})
upload_type: bpy.props.EnumProperty(name="Upload Type",items=[("DIRECT","Direct Upload","Upload the addon directly"),("URL","External Link","Provide an external url for your addon")])
expand_1: bpy.props.BoolProperty(default=True,name="Expand")
expand_2: bpy.props.BoolProperty(default=False,name="Expand")
expand_3: bpy.props.BoolProperty(default=False,name="Expand")
@classmethod
def poll(cls, context):
return True
def execute(self, context):
return {"FINISHED"}
def draw(self,context):
box = self.layout.box()
box.label(text="If you think your addon is interesting to others you can share it!",icon="FUND")
self.layout.separator()
self.layout.prop(self,"upload_type",expand=True,text=" ")
self.layout.separator()
box = self.layout.box()
box.prop(self,"expand_1",text="Step 1",emboss=False,toggle=True,icon="DISCLOSURE_TRI_DOWN" if self.expand_1 else "DISCLOSURE_TRI_RIGHT")
if self.expand_1:
col = box.column(align=True)
col.label(text=" • Set your addons name, description, version, ...")
col.label(text=" • If you want to update an addon use the same name")
col.label(text=" • Select if you want to upload your blend file with the node tree")
col.separator()
row = col.row(align=True)
row.label(icon="BLANK1")
row.prop(self,"blender", text="Upload blend file")
row.label(icon="BLANK1")
self.layout.separator()
box = self.layout.box()
box.prop(self,"expand_2",text="Step 2",emboss=False,toggle=True,icon="DISCLOSURE_TRI_DOWN" if self.expand_2 else "DISCLOSURE_TRI_RIGHT")
if self.expand_2:
col = box.column(align=False)
if self.upload_type == "DIRECT":
col.label(text=" • Go in the #marketplace channel on discord and post the following:")
row = col.row(align=True)
split = row.split(factor=0.03)
split.label(text=" ")
op = split.operator("sn.copy_command",text="Click To Copy!",icon="COPYDOWN")
op.blender = self.blender
row.operator("wm.url_open",text="",icon_value=bpy.context.scene.sn_icons[ "discord" ].icon_id).url = "https://discord.com/invite/NK6kyae"
row.label(text=" ")
col.separator()
col.label(text=" • You will be asked to upload your addon. Export it and do so.")
elif self.upload_type == "URL":
row = col.row()
row.label(text=" • Paste the link to your addon in here:")
row.prop(self,"url", text="")
col.separator()
row = col.row()
row.label(text=" • Enter the price of your addon here:")
row.prop(self,"price", text="")
if self.upload_type == "URL":
self.layout.separator()
box = self.layout.box()
box.prop(self,"expand_3",text="Step 3",emboss=False,toggle=True,icon="DISCLOSURE_TRI_DOWN" if self.expand_3 else "DISCLOSURE_TRI_RIGHT")
if self.expand_3:
col = box.column(align=False)
col.label(text=" • Go in the #marketplace channel on discord and post the following:")
row = col.row(align=True)
split = row.split(factor=0.03)
split.label(text=" ")
op = split.operator("sn.copy_command",text="Click To Copy!",icon="COPYDOWN")
op.url = self.url
op.price = self.price
op.blender = self.blender
row.operator("wm.url_open",text="",icon_value=bpy.context.scene.sn_icons[ "discord" ].icon_id).url = "https://discord.com/invite/NK6kyae"
row.label(text=" ")
def invoke(self, context, event):
return context.window_manager.invoke_popup(self, width=500)
@@ -0,0 +1,85 @@
import bpy
node_cache = {} # stores a cache of the nodes with key f"{node_tree.name};{node.static_uid}"
class NodeRef(bpy.types.PropertyGroup):
@property
def node(self):
node_tree = self.id_data
# retrieve node from cache
if f"{node_tree.name};{self.uid}" in node_cache:
return node_cache[f"{node_tree.name};{self.uid}"]
# save node to cache
for node in node_tree.nodes:
if getattr(node, "static_uid", None) == self.uid:
node_cache[f"{node_tree.name};{node.static_uid}"] = node
return node
return None
def set_name(self, value):
prev_name = self.get("name", "")
self["name"] = value
# update references
if prev_name:
ref_node = self.node
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
for node in ntree.nodes:
if getattr(node, "ref_ntree", None) == ref_node.node_tree:
# update specific node type references
if getattr(node, f"ref_{ref_node.collection_key}", None) == prev_name:
setattr(node, f"ref_{ref_node.collection_key}", value)
# update node names for any type of node
elif getattr(node, "from_node", None) == prev_name:
setattr(node, f"from_node", value)
def get_name(self):
return self.get("name", "")
uid: bpy.props.StringProperty(name="UID",
description="The static_uid of the node that belongs to this reference")
name: bpy.props.StringProperty(name="Name",
set=set_name,
get=get_name,
description="The name of the node this reference belongs to")
class NodeRefCollection(bpy.types.PropertyGroup):
name: bpy.props.StringProperty(name="Node Name",
description="The idname of the nodes in this collection")
refs: bpy.props.CollectionProperty(type=NodeRef,
name="References",
description="References to the nodes of this type")
def get_ref_by_uid(self, uid):
for ref in self.refs:
if ref.uid == uid:
return ref
return None
def clear_unused_refs(self):
""" Removes all references that don't match a node """
for i in range(len(self.refs)-1, -1, -1):
if not self.refs[i].node:
self.refs.remove(i)
def fix_ref_names(self):
""" Makes sure all ref names match the node names """
for ref in self.refs:
if ref.get("name") != ref.node.name:
ref["name"] = ref.node.name
ref.node.on_node_name_change()
# ref.node._evaluate(bpy.context)
@property
def nodes(self):
""" Returns all the nodes for this collection """
return [ref.node for ref in self.refs]
@@ -0,0 +1,295 @@
import bpy
from ..sockets.conversions import CONVERSIONS
from .node_refs import NodeRefCollection
from ...addon.variables.variables import SN_VariableProperties
from ...utils import unique_collection_name, get_python_name
class ScriptingNodesTree(bpy.types.NodeTree):
bl_idname = "ScriptingNodesTree"
bl_label = "Visual Scripting Editor"
bl_icon = "FILE_SCRIPT"
is_sn = True
type: bpy.props.EnumProperty(items=[("SCRIPTING", "Scripting", "Scripting")
],
name="Type")
index: bpy.props.IntProperty(
default=0,
description="The index of this node tree in the node tree list",
name="Index",
)
category: bpy.props.StringProperty(
name="Category",
default="OTHER",
description="The category this property is displayed in",
)
link_cache = (
{}
) # stores cache of the links from the previous update for all node trees based on their memory adress
variables: bpy.props.CollectionProperty(
type=SN_VariableProperties,
name="Variables",
description="The variables of this node tree",
)
variable_index: bpy.props.IntProperty(
name="Variable Index",
min=0,
description="Index of the selected variable")
node_refs: bpy.props.CollectionProperty(
type=NodeRefCollection,
name="Node References",
description=
"A collection of groups that hold references to nodes of a specific idname",
)
show_debug: bpy.props.BoolProperty(
name="Show Debug",
default=True,
description="Show node tree data in the debug panel",
)
# cache python names so they only have to be generated once
cached_python_names = {}
cached_python_name: bpy.props.StringProperty()
cached_human_name: bpy.props.StringProperty()
@property
def python_name(self):
if self.name == self.cached_human_name and self.cached_python_name:
return self.cached_python_name
if self.name in self.cached_python_names:
return self.cached_python_names[self.name]
names = []
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
if ntree == self:
break
names.append(ntree.python_name)
name = unique_collection_name(
f"{get_python_name(self.name, 'node_tree')}", "node_tree", names,
"_")
try:
self.cached_python_name = name
self.cached_human_name = self.name
except AttributeError:
pass
self.cached_python_names[self.name] = name
return name
def node_collection(self, idname):
"""Returns the collection for the given node idname refs in this node trees"""
if idname in self.node_refs:
return self.node_refs[idname]
return self.node_refs["empty"]
def _map_link_to_sockets(self, link):
"""Maps the given link to a tuple of the from socket, to socket and the link itself"""
from_real = None
if getattr(link.to_socket, "is_sn", False):
from_real = link.to_socket.from_socket()
return (link.from_socket, link.to_socket, from_real, link)
def is_valid_connection(self, from_out, to_inp):
"""Check if a connection between the given sockets would be valid"""
if from_out and from_out.is_program == to_inp.is_program:
# check if multiple program sockets are connected
if to_inp.is_program:
to_sockets = from_out.to_sockets(check_validity=False)
if from_out.bl_label == to_inp.bl_label:
# check if first same program socket
for socket in to_sockets:
if socket.bl_label == to_inp.bl_label:
if socket == to_inp:
return True
else:
break
return False
# data types are the same
elif from_out.bl_label == to_inp.bl_label:
return True
# check if data types are convertible
else:
if not to_inp.convert_data:
return True
if from_out.bl_label in CONVERSIONS:
if to_inp.bl_label in CONVERSIONS[from_out.bl_label]:
return True
return False
return False
def is_valid_link(self, link):
"""Checks if the given link is valid"""
# all links connected to reroutes inputs are valid
if not getattr(link.to_socket, "is_sn", False):
return True
# get the sockets and return their validity
to_inp = link.to_socket
from_out = link.to_socket.from_socket()
return self.is_valid_connection(from_out, to_inp)
def _insert_define_data_nodes(self, links):
"""Inserts define data nodes for all links invalid links"""
for link in links:
# add define data node
if not getattr(link.from_socket, "changeable", False):
node = self.nodes.new("SN_DefineDataType")
if link.to_socket.bl_idname in list(
map(lambda item: item[0],
node.get_data_items(bpy.context))):
node.convert_to = link.to_socket.bl_idname
node.location = (
(link.from_node.location[0] + link.to_node.location[0]) /
2,
(link.from_node.location[1] + link.to_node.location[1]) /
2,
)
self.links.new(link.from_socket, node.inputs[0])
self.links.new(node.outputs[0], link.to_socket)
# change data output
elif link.from_socket.bl_label == "Data":
to_sockets = link.from_socket.to_sockets(False)
if not link.from_socket.dynamic and len(to_sockets) == 1:
to_socket = to_sockets[0]
if to_socket.bl_idname in list(
map(
lambda item: item[0],
link.from_socket.get_data_type_items(
bpy.context),
)):
link.from_socket.data_type = to_socket.bl_idname
# link.from_socket.subtype = to_socket.subtype
def _update_post(self):
"""Only do visual aspects in here as this is run after evaluating the nodes"""
# mark links as invalid
data_links = []
for link in self.links:
if not self.is_valid_link(link):
link.is_valid = False
if link.from_socket.bl_label == "Data":
data_links.append(link)
self._insert_define_data_nodes(data_links)
def _find_node_from_socket(self, socket):
for node in self.nodes:
for s in [*node.inputs, *node.outputs]:
if s == socket:
return node
return None
def _update_changed_links(self, links):
"""Forces the affected nodes to update depending on if it's a program or data socket"""
for from_out, to_inp, _, _ in links:
to_inp_node = self._find_node_from_socket(to_inp)
from_out_node = self._find_node_from_socket(from_out)
# update data sockets
try:
if (getattr(to_inp, "is_sn", False) and to_inp_node
and not to_inp.is_program):
to_inp.force_update()
# update program sockets
elif (from_out_node and getattr(from_out, "is_sn", False)
and from_out.is_program):
from_out.force_update()
except:
pass
def _call_link_inserts(self, added):
"""Calls link_insert for all new links"""
for from_inp, to_inp, from_real, _ in added:
if from_real:
from_real.node.link_insert(from_real, to_inp, is_output=True)
to_inp.node.link_insert(from_real, to_inp, is_output=False)
elif (from_inp and getattr(from_inp.node, "is_sn", False)
and to_inp and getattr(to_inp.node, "is_sn", False)):
from_inp.node.link_insert(from_inp, to_inp, is_output=True)
def _call_link_removes(self, removed):
"""Calls link_remove for all removed links"""
for _, to_inp, from_real, _ in removed:
if from_real:
node = self._find_node_from_socket(from_real)
if node:
node.link_remove(from_real, to_inp, is_output=True)
node = self._find_node_from_socket(to_inp)
if node:
node.link_remove(from_real, to_inp, is_output=False)
def _update_added_links(self, added):
"""Triggers an update on the given links data outputs and program inputs to update the affected program"""
self._update_changed_links(added)
self._call_link_inserts(added)
def _update_removed_links(self, removed):
"""Triggers an update on the given links data inputs and program outputs to update the affected program"""
self._update_changed_links(removed)
self._call_link_removes(removed)
def _update_tree_links(self):
"""Finds all changed node links and updates the connections"""
# get current links
curr_links = list(map(self._map_link_to_sockets, self.links.values()))
if id(self) in self.link_cache:
# update added links
added = list(set(curr_links) - set(self.link_cache[id(self)]))
self._update_added_links(added)
# update removed links
removed = list(set(self.link_cache[id(self)]) - set(curr_links))
self._update_removed_links(removed)
# update cached current links
self.link_cache[id(self)] = curr_links
# calls a function after the links are realized
bpy.app.timers.register(self._update_post, first_interval=0.001)
def _update_reroutes(self):
"""Updates all inputs and display shapes of the reroutes in this node tree"""
for reroute in self.nodes:
if reroute.bl_idname == "NodeReroute":
try:
connections_left = [
x.from_socket for x in reroute.inputs[0].links
]
connections_right = [
x.to_socket for x in reroute.outputs[0].links
]
if reroute.inputs[0].bl_idname != "SN_RerouteSocket":
reroute.inputs.remove(reroute.inputs[0])
reroute.outputs.remove(reroute.outputs[0])
i = reroute.inputs.new("SN_RerouteSocket", "Input")
o = reroute.outputs.new("SN_RerouteSocket", "Output")
for c in connections_left:
self.links.new(c, i)
for c in connections_right:
self.links.new(c, o)
reroute.inputs[0].display_shape = (
connections_left[0].display_shape
if connections_left else "CIRCLE")
reroute.outputs[0].display_shape = (
connections_left[0].display_shape
if connections_left else "CIRCLE")
except:
pass
def update(self):
# update tree links
self._update_tree_links()
self._update_reroutes()
def reevaluate(self):
"""Reevaluates all nodes in this node tree"""
# evaluate all nodes
for node in self.nodes:
if getattr(node, "is_sn", False):
node._evaluate(bpy.context)
@@ -0,0 +1,69 @@
import bpy
class SN_OT_FindReferencingNodes(bpy.types.Operator):
bl_idname = "sn.find_referencing_nodes"
bl_label = "Find Referencing Nodes"
bl_description = "Find all nodes that reference this node"
bl_options = {'REGISTER', 'UNDO', "INTERNAL"}
node: bpy.props.StringProperty(name="Node", options={'HIDDEN', 'SKIP_SAVE'})
add_node: bpy.props.StringProperty(name="Add Node", options={'HIDDEN', 'SKIP_SAVE'})
references = {}
def execute(self, context):
pass
def draw(self, context):
layout = self.layout
if not self.node in context.space_data.node_tree.nodes: return
ref_node = context.space_data.node_tree.nodes[self.node]
for key in self.references:
layout.label(text=key)
for ref in self.references[key]:
op = layout.operator("sn.find_node", text=ref, icon="RESTRICT_SELECT_OFF")
op.node_tree = key
op.node = ref
layout.separator()
if not self.references:
layout.label(text="No references found", icon="INFO")
if self.add_node:
op = layout.operator("sn.add_referencing_node", text="Add Node", icon="FORWARD")
op.idname = self.add_node
key = ref_node.bl_idname if not ref_node.collection_key_overwrite else ref_node.collection_key_overwrite
op.ref_attr = f"ref_{key}"
op.node = ref_node.name
def invoke(self, context, event):
self.references = {}
ref_node = context.space_data.node_tree.nodes[self.node]
for ngroup in bpy.data.node_groups:
for node in ngroup.nodes:
idname = ref_node.collection_key_overwrite if ref_node.collection_key_overwrite else ref_node.bl_idname
if getattr(node, f"ref_{idname}", None) == ref_node.name and getattr(node, "ref_ntree", None) == ref_node.node_tree:
if not ngroup.name in self.references:
self.references[ngroup.name] = []
self.references[ngroup.name].append(node.name)
return context.window_manager.invoke_popup(self, width=250)
class SN_OT_AddReferencingNode(bpy.types.Operator):
bl_idname = "sn.add_referencing_node"
bl_label = "Add Node"
bl_description = "Adds the referenced node to the node tree"
bl_options = {"REGISTER", "INTERNAL"}
idname: bpy.props.StringProperty(name="ID Name", options={'HIDDEN', 'SKIP_SAVE'})
ref_attr: bpy.props.StringProperty(name="Attribute", options={'HIDDEN', 'SKIP_SAVE'})
node: bpy.props.StringProperty(name="Node", options={'HIDDEN', 'SKIP_SAVE'})
def execute(self, context):
bpy.ops.node.add_node("INVOKE_DEFAULT", type=self.idname, use_transform=True)
node = context.space_data.node_tree.nodes.active
setattr(node, self.ref_attr, self.node)
return {"FINISHED"}
@@ -0,0 +1,178 @@
from .. import auto_load
import os
import inspect
import bpy
from ..extensions import snippet_ops
def flatten_snippets(data):
flat = []
if type(data) == str:
flat.append(data)
else:
cat = bpy.context.scene.sn.snippet_categories[data["name"]]
for item in data["snippets"]:
flat.extend(
list(map(lambda x: os.path.join(cat.path, x), flatten_snippets(item)))
)
return flat
def get_snippet_list():
flat_snippets = []
for snippet in snippet_ops.loaded_snippets:
flat_snippets.extend(flatten_snippets(snippet))
flat_snippets = list(set(flat_snippets))
for i, snippet in enumerate(flat_snippets):
if os.path.basename(snippet) == snippet:
flat_snippets[i] = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"extensions",
"snippets",
snippet,
)
return flat_snippets
class SN_MT_LayoutMenu(bpy.types.Menu):
bl_idname = "SN_MT_LayoutMenu"
bl_label = "Layout"
def draw(self, context):
layout = self.layout
op = layout.operator("node.add_node", text="Frame")
op.type = "NodeFrame"
op.use_transform = True
op = layout.operator("node.add_node", text="Portal")
op.type = "SN_PortalNode"
op.use_transform = True
op = layout.operator("node.add_node", text="Reroute")
op.type = "NodeReroute"
op.use_transform = True
_node_categories = {}
def get_node_categories():
global _node_categories
if _node_categories:
return _node_categories
else:
node_categories = {}
for cls in auto_load.ordered_classes:
if cls.bl_rna.base and cls.bl_rna.base.identifier == "Node":
path = os.path.dirname(inspect.getfile(cls))
dirs = path.split(os.sep)
if "nodes" in dirs:
node_path = dirs[dirs.index("nodes") + 1 :]
parent = node_categories
for dir in node_path:
if not dir in parent:
parent[dir] = {}
parent = parent[dir]
if not "nodes" in parent:
parent["nodes"] = []
parent["nodes"].append(cls)
_node_categories = node_categories
return node_categories
blocklist = ["nodes", "Snippets", "Layout", "Legacy"]
_registered_menus = []
def register_node_menus():
categories = get_node_categories()
for cat in sorted(categories.keys()):
if not cat in blocklist:
register_menu(cat, cat)
register_category_menus(categories[cat], cat)
def register_category_menus(category, path):
for cat in sorted(category.keys()):
if not cat in blocklist:
register_menu(cat, f"{path}.{cat}")
register_category_menus(category[cat], f"{path}.{cat}")
@classmethod
def poll(cls, context):
return context.space_data.tree_type == "ScriptingNodesTree"
def register_menu(name, path):
menu_type = type(
"SN_MT_category_" + name.replace(" ", "_"),
(bpy.types.Menu,),
{
"bl_space_type": "NODE_EDITOR",
"bl_label": name.replace("_", " ").title(),
"path": path,
"poll": poll,
"draw": draw_submenu,
},
)
bpy.utils.register_class(menu_type)
_registered_menus.append(menu_type)
def unregister_node_menus():
for menu in _registered_menus:
try:
bpy.utils.unregister_class(menu)
except:
pass
_registered_menus.clear()
def draw_submenu(self, context):
layout = self.layout
category = get_node_categories()
for path in self.path.split("."):
category = category[path]
for cat in sorted(category.keys()):
if not cat in blocklist:
layout.menu(
"SN_MT_category_" + cat.replace(" ", "_"),
text=cat.replace("_", " ").title(),
)
if "nodes" in category and len(category["nodes"]) and len(category.keys()) > 1:
layout.separator()
if "nodes" in category:
for node in sorted(category["nodes"], key=lambda n: n.bl_label):
op = layout.operator("node.add_node", text=node.bl_label)
op.type = node.bl_idname
op.use_transform = True
def draw_node_menu(self, context):
if context.space_data.tree_type != "ScriptingNodesTree":
return
categories = get_node_categories()
layout = self.layout
layout.separator()
for cat in sorted(categories.keys()):
if not cat in blocklist:
layout.menu(
"SN_MT_category_" + cat.replace(" ", "_"),
text=cat.replace("_", " ").title(),
)
layout.menu("SN_MT_LayoutMenu", text="Layout")
layout.separator()
layout.menu("SN_MT_PresetMenu", text="Presets")
layout.menu("SN_MT_SnippetsMenu", text="Snippets")
@@ -0,0 +1,498 @@
import bpy
from .conversions import CONVERSIONS
from ...addon.properties.settings.settings import property_icons
import time
class ScriptingSocket:
### SOCKET GENERAL
is_sn = True
output_limit = 9999
# OVERWRITE
socket_shape = "CIRCLE" # CIRCLE | SQUARE | DIAMOND
def update_socket_name(self, context):
self.node.on_socket_name_change(self)
name: bpy.props.StringProperty(name="Socket Name",
description="Name of this socket",
update=update_socket_name)
### SOCKET OPTIONS
# OVERWRITE
is_program = False # Only Interface and Execute sockets are programs
dynamic: bpy.props.BoolProperty(
default=False,
name="Dynamic",
description="If this socket adds another socket when connected",
)
prev_dynamic: bpy.props.BoolProperty(
default=False,
name="Previously Dynamic",
description=
"True if this socket was previously dynamic and can now be removed",
)
def update_conversion(self, context):
if self.is_linked:
from_out = self.links[0].from_socket
self.node.node_tree.links.remove(self.links[0])
self.node.node_tree.links.new(from_out, self)
convert_data: bpy.props.BoolProperty(
default=True,
name="Convert Data",
description="Convert the incoming data to this sockets type",
update=update_conversion,
)
def update_disabled(self, context):
self.force_update()
disabled: bpy.props.BoolProperty(
default=False,
name="Disabled",
description="Disable this socket for this node",
update=update_disabled,
)
can_be_disabled: bpy.props.BoolProperty(
default=False,
name="Can Be Hidden",
description=
"Lets the user disable this socket which can be used for evaluation",
)
# OVERWRITE
subtypes = [
"NONE"
] # possible subtypes for this data socket. Vector sockets should be seperate socket types, not subtypes (their size is a subtype)!
subtype_values = {
"NONE": "default_value"
} # the matching propertie names for this data sockets subtype
def on_subtype_update(self):
pass
def get_subtype_items(self, _):
return [(name, name, name) for name in self.subtypes]
def update_subtype(self, _):
self.force_update()
self.on_subtype_update()
self.node.location = self.node.location
subtype: bpy.props.EnumProperty(
name="Subtype",
description="The subtype of this socket",
items=get_subtype_items,
update=update_subtype,
)
@property
def subtype_attr(self):
return self.subtype_values[self.subtype]
# INDEXING OPTIONS
def set_hide(self, value):
"""Sets the hide value of this socket and disconnects all links if hidden"""
if value:
for link in self.links:
self.node.node_tree.links.remove(link)
self.hide = value
def update_index_type(self, context):
if self.indexable and self.bl_idname != self.node.socket_names[
self.index_type]:
# hide all index sockets before blend data input
hide = self.index_type == "Property"
for inp in self.node.inputs:
if inp == self:
hide = False
if inp.indexable:
inp.set_hide(hide)
# convert socket
self.node.convert_socket(self,
self.node.socket_names[self.index_type])
indexable: bpy.props.BoolProperty(
default=False,
name="Indexable",
description=
"If this socket is indexable. Switches between String, Integer and Blend Data",
)
index_type: bpy.props.EnumProperty(
name="Index Type",
description="The type of index this socket indexes the property with",
items=[
("String", "Name", "Name", "SYNTAX_OFF", 0),
("Integer", "Index", "Index", "DRIVER_TRANSFORM", 1),
("Property", "Property", "Property", "MONKEY", 2),
],
update=update_index_type,
)
def update_data_type(self, context):
if self.changeable and self.data_type != self.bl_idname:
self.node.convert_socket(self, self.data_type)
def get_data_type_items(self, context):
items = []
used_idnames = []
for name in list(self.node.socket_names.keys())[2:]:
if not self.node.socket_names[name] in used_idnames:
items.append((
self.node.socket_names[name],
name,
name,
property_icons[name],
len(items),
))
used_idnames.append(self.node.socket_names[name])
return items
changeable: bpy.props.BoolProperty(
default=False,
name="Changeable",
description="If this data socket type can be changed",
)
data_type: bpy.props.EnumProperty(
name="The type this socket has right now",
update=update_data_type,
items=get_data_type_items,
)
# VARIABLE SOCKET OPTIONS
is_variable: bpy.props.BoolProperty(
name="Is Variable",
description="If this socket is a variable socket that can be renamed",
)
### DRAW SOCKET
# OVERWRITE
def draw_socket(self, context, layout, node, text, minimal=False):
pass
def _draw_removable_socket(self, layout, node):
"""Draws the operators for removable sockets"""
op = layout.operator("sn.remove_socket",
text="",
emboss=False,
icon="REMOVE")
op.node = node.name
op.is_output = self.is_output
op.index = self.index
def _draw_dynamic_socket(self, layout, node, text):
"""Draws the operators for dynamic sockets"""
# draw socket label
if self.is_output:
layout.label(text=text)
# draw add operator
op = layout.operator("sn.add_dynamic",
text="",
emboss=False,
icon="ADD")
op.node = node.name
op.is_output = self.is_output
op.insert_above = False
op.index = self.index
# draw socket label
if not self.is_output:
layout.label(text=text)
def _draw_prev_dynamic_socket(self, context, layout, node):
"""Draws the operators for previously dynamic sockets"""
# draw remove socket
self._draw_removable_socket(layout, node)
# draw add above operator
if context.scene.sn.insert_sockets:
op = layout.operator("sn.add_dynamic",
text="",
emboss=False,
icon="TRIA_UP")
op.node = node.name
op.is_output = self.is_output
op.insert_above = True
op.index = self.index
def draw(self, context, layout, node, text):
"""Draws this socket"""
sn = context.scene.sn
text = self.name
# draw debug text for sockets
if sn.debug_python_sockets and self.python_value:
if not sn.debug_selected_only or (sn.debug_selected_only
and self.node.select):
text = self.python_value.replace("\n", " || ")
# draw dynamic sockets
if self.dynamic:
self._draw_dynamic_socket(layout, node, text)
# draw variable socket
elif self.is_variable:
# draw previously dynamic socket (with insert socket)
if not self.is_output and self.prev_dynamic:
self._draw_prev_dynamic_socket(context, layout, node)
layout.prop(self, "name", text="")
self.draw_socket(context, layout, node, "", minimal=True)
# draw changeable socket
if self.changeable:
layout.separator()
layout.prop(self, "data_type", icon_only=True)
# draw previously dynamic socket (with insert socket)
if self.is_output and self.prev_dynamic:
self._draw_prev_dynamic_socket(context, layout, node)
# draw normal socket
else:
# draw output
if self.is_output:
self.draw_socket(context, layout, node, text)
# draw changeable socket
if self.changeable:
layout.separator()
layout.prop(self, "data_type", icon_only=True)
# draw previously dynamic socket (with insert socket)
if self.prev_dynamic:
self._draw_prev_dynamic_socket(context, layout, node)
# draw inputs
if not self.is_output:
# draw disable icon
if self.can_be_disabled:
layout.prop(
self,
"disabled",
icon_only=True,
icon="HIDE_ON" if self.disabled else "HIDE_OFF",
emboss=False,
)
layout = layout.row()
layout.enabled = not self.disabled
# draw disabled socket
if self.can_be_disabled and self.disabled:
layout.label(text=text)
# draw enabled socket
else:
self.draw_socket(context, layout, node, text)
# draw indexable socket
if self.indexable:
layout.prop(self, "index_type", icon_only=True)
# draw changeable socket
if self.changeable:
layout.separator()
layout.prop(self, "data_type", icon_only=True)
### SOCKET COLOR
# OVERWRITE
def get_color(self, context, node):
return (0, 0, 0)
def draw_color(self, context, node):
"""Draws the color of this node based on the get_color function and the status of this socket"""
c = self.get_color(context, node)
alpha = 1
# if self.dynamic:
# alpha = 0
return (c[0], c[1], c[2], alpha)
### PASS CODE AND DATA
# OVERWRITE
default_python_value = "None"
default_prop_value = ""
def get_python_repr(self):
return "None"
def reset_value(self):
"""Resets this sockets python value back to the default"""
self.python_value = self.default_python_value
def _get_python(self):
"""Returns the python value for this socket"""
if self.is_program:
if self.is_output:
# returns the connected program inputs python value or this sockets default
to_socket = self.to_sockets()
if to_socket:
return to_socket[0].python_value
return self.get("python_value", self.default_python_value)
else:
# returns this program inputs python value or its default
return self.get("python_value", self.default_python_value)
else:
if self.is_output:
# returns this data outputs current python value or its default
return self.get("python_value", self.default_python_value)
else:
# returns the connected data outputs current python value or the python representation for this input
from_out = self.from_socket()
if from_out:
value = from_out.python_value
if self.convert_data:
# convert different socket types
if from_out.bl_label != self.bl_label:
value = CONVERSIONS[from_out.bl_label][
self.bl_label](from_out, self)
# convert convertable subtypes of the same socket
elif from_out.subtype != self.subtype:
if from_out.subtype in CONVERSIONS[
from_out.bl_label]:
if (self.subtype in CONVERSIONS[
from_out.bl_label][from_out.subtype]):
value = CONVERSIONS[from_out.bl_label][
from_out.subtype][self.subtype](
from_out, self)
return value
return self.get_python_repr()
def _set_python(self, value):
"""Sets the python value of this socket if it has changed and triggers an update"""
if self.get("python_value") == None or value != self["python_value"]:
self["python_value"] = value
self._trigger_update()
def _trigger_update(self):
"""Triggers node evaluation depending on the type of this socket"""
if self.is_program:
# evaluate this node if this is a program output
if self.is_output:
self.node._evaluate(bpy.context)
# evaluate all connected nodes if this is a program input
else:
from_socket = self.from_socket()
if from_socket:
from_socket.node._evaluate(bpy.context)
else:
# evaluate all connected nodes if this is a data output
if self.is_output:
for socket in self.to_sockets():
socket.node._evaluate(bpy.context)
# evaluate this node if this is a data input
else:
self.node._evaluate(bpy.context)
python_value: bpy.props.StringProperty(
name="Python Value",
description="Python representation of this sockets value",
get=_get_python,
set=_set_python,
)
def _get_value(self):
"""Returns the current value of this socket"""
return self.get(self.subtype_attr, self.default_prop_value)
def _set_value(self, value):
"""Sets the default value depending on the current subtype and updates the python value"""
self[self.subtype_attr] = value
self.python_value = self._get_python()
def _update_value(self, _):
"""Update function for the subtype properties to force an update on the node"""
self.force_update()
# OVERWRITE
default_value: bpy.props.StringProperty(name="Value",
description="Value of this socket",
get=_get_value,
set=_set_value)
def force_update(self):
"""Triggers an update to the connected sockets, for both data and program sockets. Used to pretend the data of this node changed"""
self._trigger_update()
### CONNECTED SOCKETS
def _get_to_sockets(self, socket, check_validity=True):
"""Recursively returns the inputs connected to the given output, skipping over reroutes"""
to_sockets = []
# recursively find all sockets when splitting at reroutes
if socket.node.bl_idname == "NodeReroute":
for link in socket.node.outputs[0].links:
to_sockets += self._get_to_sockets(link.to_socket,
check_validity)
else:
# check validity of connection
if not check_validity or self.node.node_tree.is_valid_connection(
self, socket):
to_sockets.append(socket)
return to_sockets
def to_sockets(self, check_validity=True):
"""Returns all inputs connected to this output, skipping over reroutes"""
sockets = []
for link in self.links:
sockets += self._get_to_sockets(link.to_socket, check_validity)
return sockets
def from_socket(self, check_validity=True):
"""Returns the socket this input comes from skipping over reroutes"""
if len(self.links) > 0:
from_out = self.links[0].from_socket
# find the first socket that is not a reroute
while from_out.node.bl_idname == "NodeReroute":
if len(from_out.node.inputs[0].links) > 0:
from_out = from_out.node.inputs[0].links[0].from_socket
else:
return None
# check connection validity
if not check_validity or self.node.node_tree.is_valid_connection(
from_out, self):
return from_out
return None
### DYNAMIC SOCKETS
@property
def index(self):
"""Returns the index of this socket on the node or -1 if it can't be found"""
for index, socket in enumerate(
self.node.outputs if self.is_output else self.node.inputs):
if socket == self:
return index
return -1
def trigger_dynamic(self, insert_above=False):
"""Adds another socket like this one after itself and turns itself into a normal socket"""
if self.dynamic or self.prev_dynamic:
# add new socket
if self.is_output:
socket = self.node._add_output(self.bl_idname, self.name)
# move socket
self.node.outputs.move(
len(self.node.outputs) - 1,
self.index + 1 if not insert_above else self.index,
)
else:
socket = self.node._add_input(self.bl_idname, self.name)
# move socket
self.node.inputs.move(
len(self.node.inputs) - 1,
self.index + 1 if not insert_above else self.index,
)
self.node.location = self.node.location
# set new socket
socket.dynamic = self.dynamic
socket.prev_dynamic = self.prev_dynamic
socket.subtype = self.subtype
socket.changeable = self.changeable
socket.is_variable = self.is_variable
socket.data_type = self.data_type
if hasattr(socket, "passthrough_layout_type"):
socket.passthrough_layout_type = self.passthrough_layout_type
# set this socket
self.dynamic = False
self.prev_dynamic = True
if socket.dynamic:
self.node.on_dynamic_socket_add(self)
else:
self.node.on_dynamic_socket_add(socket)
socket.node._evaluate(bpy.context)
@@ -0,0 +1,36 @@
import bpy
from .base_socket import ScriptingSocket
class SN_BooleanSocket(bpy.types.NodeSocket, ScriptingSocket):
bl_idname = "SN_BooleanSocket"
group = "DATA"
bl_label = "Boolean"
default_python_value = "False"
default_prop_value = False
def get_python_repr(self):
return f"{self.default_value}"
default_value: bpy.props.BoolProperty(name="Value",
default=False,
description="Value of this socket",
get=ScriptingSocket._get_value,
set=ScriptingSocket._set_value)
subtypes = ["NONE"]
subtype_values = {"NONE": "default_value"}
def get_color(self, context, node):
return (0.95, 0.73, 1)
def draw_socket(self, context, layout, node, text, minimal=False):
if self.is_output or self.is_linked:
layout.label(text=text)
else:
layout.prop(self, self.subtype_attr, text=text)
@@ -0,0 +1,68 @@
import bpy
import mathutils
from .base_socket import ScriptingSocket
class SN_BooleanVectorSocket(bpy.types.NodeSocket, ScriptingSocket):
bl_idname = "SN_BooleanVectorSocket"
group = "DATA"
bl_label = "Boolean Vector"
default_python_value = "(False, False, False)"
default_prop_value = tuple([False]*32)
def get_python_repr(self):
return f"{tuple(getattr(self, self.subtype_attr))[:self.size]}"
def _get_value(self):
value = ScriptingSocket._get_value(self)
value = tuple(map(lambda x: bool(x), value))
return tuple(value)
def _set_value(self, value):
value = list(value)
while len(value) < 32:
value.append(False)
ScriptingSocket._set_value(self, tuple(value))
def update_size(self, context):
self.default_python_value = str(tuple([False]*self.size))
self._set_value(self.default_value)
self.node.on_socket_type_change(self)
size: bpy.props.IntProperty(default=3, min=2, max=32,
name="Size",
description="Size of this boolean vector",
update=update_size)
size_editable: bpy.props.BoolProperty(default=False,
name="Size Editable",
description="Let's you edit the vectors size on the socket")
default_value: bpy.props.BoolVectorProperty(name="Value",
size=32,
description="Value of this socket",
get=_get_value,
set=_set_value)
subtypes = ["NONE"]
subtype_values = {"NONE": "default_value"}
def get_color(self, context, node):
return (0.38, 0.34, 0.84)
def draw_socket(self, context, layout, node, text, minimal=False):
if self.is_output or self.is_linked:
layout.label(text=text)
else:
col = layout.column(heading=text, align=True)
for i in range(self.size):
col.prop(self, self.subtype_attr, index=i, text=str(getattr(self, self.subtype_attr)[i]), toggle=True)
if self.size_editable:
layout.prop(self, "size", text="")

Some files were not shown because too many files have changed in this diff Show More