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,214 @@
import bpy
from ..keymaps.keymap import get_shortcut
from .preset_data import PresetData
class SN_PT_MarketFilters(bpy.types.Panel):
bl_idname = "SN_PT_MarketFilters"
bl_label = "Filter"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
def draw(self, context):
layout = self.layout
addon_prefs = bpy.context.preferences.addons[__name__.partition('.')[ 0]].preferences
layout.prop(addon_prefs, "only_serpens_3")
class SN_AddonPreferences(bpy.types.AddonPreferences):
bl_idname = __name__.partition('.')[0]
navigation: bpy.props.EnumProperty(name="Navigation",
description="Preferences Navigation",
items=[("SETTINGS", "Settings", "Serpens settings", "PREFERENCES", 0),
("MARKET", "Marketplace", "Get things from the marketplace", "OUTLINER_OB_GROUP_INSTANCE", 1),
("CUSTOM", "Custom", "Preview your addons preferences", "FILE_SCRIPT", 2)])
market_navigation: bpy.props.EnumProperty(name="Navigation",
description="Marketplace Navigation",
items=[("PACKAGES", "Packages", "Get packages for Serpens"),
("SNIPPETS", "Snippets", "Get snippets made with and for Serpens"),
("ADDONS", "Addons", "Get addons made with Serpens")])
check_for_updates: bpy.props.BoolProperty(name="Check For Updates",
description="Check for updates online when loading the addon",
default=True)
use_colors: bpy.props.BoolProperty(name="Use Colored Nodes",
description="Color nodes to match their category. Does not apply to existing nodes",
default=True)
keep_last_error_file: bpy.props.BoolProperty(name="Keep Error File",
description="Keeps a copy of any compiled file that threw an error as 'serpens_error' in the text editor",
default=False)
search_addons: bpy.props.StringProperty(name="Search",
description="Search through the loaded addons",
options={"TEXTEDIT_UPDATE"})
search_packages: bpy.props.StringProperty(name="Search",
description="Search through the loaded packages",
options={"TEXTEDIT_UPDATE"})
search_snippets: bpy.props.StringProperty(name="Search",
description="Search through the loaded snippets",
options={"TEXTEDIT_UPDATE"})
only_serpens_3: bpy.props.BoolProperty(name="Only Serpens 3",
description="Hide all results from previous serpens versions")
presets: bpy.props.CollectionProperty(name="Presets",
description="Preset nodes",
type=PresetData)
def draw_serpens_prefs(self, context, layout):
row = layout.row()
col = row.column(heading="General")
col.prop(self, "check_for_updates")
col.prop(self, "use_colors")
col.prop(get_shortcut("sn.force_compile"), "type", full_event=True, text="Force Compile")
col.prop(get_shortcut("sn.open_node_docs"), "type", full_event=True, text="Node Docs")
col.prop(get_shortcut("sn.add_copied_node"), "type", full_event=True, text="Add Node From Copied")
col = row.column(heading="Debugging")
col.prop(self, "keep_last_error_file")
def draw_market_addon(self, addon_data):
if not addon_data.name == "placeholder" and \
(not self.only_serpens_3 or self.only_serpens_3 and addon_data.serpens_version == 3) and \
(self.search_addons.lower() in addon_data.name.lower() or \
self.search_addons.lower() in addon_data.description.lower() or \
self.search_addons.lower() in addon_data.author.lower() or \
self.search_addons.lower() in addon_data.category.lower()):
box = self.layout.box()
row = box.row()
row.label(text=f"{addon_data.category}: {addon_data.name}")
subrow = row.row()
subrow.alignment = "RIGHT"
subrow.label(text=addon_data.author)
row = box.row()
row.enabled = False
row.label(text=addon_data.description)
row = box.row()
row.enabled = False
row.label(text="Blender: " + ".".join(list(map(lambda i: str(i), list(addon_data.blender_version)))))
row.label(text="Addon: " + ".".join(list(map(lambda i: str(i), list(addon_data.addon_version)))))
row = box.row()
row.operator("wm.url_open", text=addon_data.price if addon_data.price else "Free", icon="URL" if addon_data.is_external else "IMPORT").url = addon_data.addon_url
if addon_data.has_blend:
serpens_version = "" if addon_data.serpens_version == 3 else " (Serpens 2)"
row.operator("wm.url_open", text=f"Download .blend{serpens_version}", icon="IMPORT").url = addon_data.blend_url
return True
return False
def draw_market_package(self, package_data):
if not package_data.name == "placeholder" and \
(not self.only_serpens_3 or self.only_serpens_3 and package_data.serpens_version != 2) and \
(self.search_packages.lower() in package_data.name.lower() or \
self.search_packages.lower() in package_data.description.lower() or \
self.search_packages.lower() in package_data.author.lower()):
box = self.layout.box()
row = box.row()
row.label(text=package_data.name)
subrow = row.row()
subrow.alignment = "RIGHT"
subrow.label(text=package_data.author)
row = box.row()
row.enabled = False
row.label(text=package_data.description)
serpens_version = "" if package_data.serpens_version != 2 else " (Serpens 2)"
box.operator("wm.url_open", text=f"{package_data.price}{serpens_version}" if package_data.price else f"Free {serpens_version}", icon="URL").url = package_data.url
return True
return False
def draw_market_snippet(self, snippet_data):
if not snippet_data.name == "placeholder" and \
(not self.only_serpens_3 or self.only_serpens_3 and snippet_data.serpens_version == 3) and \
(self.search_snippets.lower() in snippet_data.name.lower() or \
self.search_snippets.lower() in snippet_data.description.lower() or \
self.search_snippets.lower() in snippet_data.author.lower()):
box = self.layout.box()
row = box.row()
row.label(text=snippet_data.name)
subrow = row.row()
subrow.alignment = "RIGHT"
subrow.label(text=snippet_data.author)
row = box.row()
row.enabled = False
row.label(text=snippet_data.description)
row = box.row()
serpens_version = "" if snippet_data.serpens_version == 3 else " (Serpens 2)"
row.operator("wm.url_open", text=f"{snippet_data.price}{serpens_version}" if snippet_data.price else f"Free {serpens_version}", icon="URL").url = snippet_data.url
if snippet_data.blend_url:
row.operator("wm.url_open", text=f"Download .blend{serpens_version}", icon="IMPORT").url = snippet_data.blend_url
return True
return False
def draw_serpens_market(self, layout):
sn = bpy.context.scene.sn
row = layout.row()
row.prop(self, "market_navigation", expand=True)
row = layout.row()
row.scale_y = 1
found_results = False
if self.market_navigation == "PACKAGES":
found_results = not sn.packages
if sn.packages:
row.prop(self, "search_packages", text="", icon="VIEWZOOM")
row.popover("SN_PT_MarketFilters", text="", icon="FILTER")
row.operator("sn.load_packages", text="Load Packages" if not sn.packages else "Reload", icon="FILE_REFRESH")
for package in sn.packages:
found_results = self.draw_market_package(package) or found_results
elif self.market_navigation == "SNIPPETS":
found_results = not sn.snippets
if sn.snippets:
row.prop(self, "search_snippets", text="", icon="VIEWZOOM")
row.popover("SN_PT_MarketFilters", text="", icon="FILTER")
row.operator("sn.load_snippets", text="Load Snippets" if not sn.snippets else "Reload", icon="FILE_REFRESH")
for snippet in sn.snippets:
found_results = self.draw_market_snippet(snippet) or found_results
elif self.market_navigation == "ADDONS":
found_results = not sn.addons
if sn.addons:
row.prop(self, "search_addons", text="", icon="VIEWZOOM")
row.popover("SN_PT_MarketFilters", text="", icon="FILTER")
row.operator("sn.load_addons", text="Load Addons" if not sn.addons else "Reload", icon="FILE_REFRESH")
for addon in sn.addons:
found_results = self.draw_market_addon(addon) or found_results
if not found_results:
layout.label(text="No results found!")
def draw_custom_prefs(self, context, layout):
if context.scene.sn.preferences:
layout.label(text="This will be shown in your preferences:", icon="INFO")
context.scene.sn.preferences[0](layout)
else:
layout.label(text="No preferences node added to your addon.", icon="ERROR")
def draw(self, context):
layout = self.layout
row = layout.row()
row.scale_y = 1.2
row.prop(self, "navigation", expand=True)
if self.navigation == "SETTINGS":
self.draw_serpens_prefs(context, layout)
if self.navigation == "MARKET":
self.draw_serpens_market(layout)
elif self.navigation == "CUSTOM":
self.draw_custom_prefs(context, layout)
# addon_prefs = bpy.context.preferences.addons[__name__.partition('.')[ 0]].preferences
@@ -0,0 +1,674 @@
import bpy
from bl_ui import space_userpref
from uuid import uuid4
from .data_properties import get_data_items, filter_items, filter_defaults
from .handle_script_changes import unwatch_script_changes, watch_script_changes
from ..addon.properties.properties import SN_GeneralProperties
from ..addon.properties.property_category import SN_PropertyCategory
from ..node_tree.graphs.graph_category_ops import SN_GraphCategory
from ..addon.assets.assets import SN_AssetProperties
from ..utils import get_python_name
from .load_markets import SN_Addon, SN_Package, SN_Snippet
from ..extensions.snippet_ops import SN_BoolCollection, SN_SnippetCategory
from ..nodes.compiler import compile_addon
from . import global_search
_item_map = dict()
class SN_AddonProperties(bpy.types.PropertyGroup):
# stores the unregister function for the addon when its compiled
addon_unregister = []
addon_modules = []
# stores the preferences draw function while compiling the addon to draw in the serpens preferences
preferences = []
# stores the custom icon property collections while developing an addon
preview_collections = {}
# stores functions that need to be called during developement
function_store = {}
# stores the module store for the current addon
module_store = []
compile_time: bpy.props.FloatProperty(
name="Compile Time", description="Time the addon took to compile"
)
@property
def module_name(self):
return get_python_name(
bpy.context.scene.sn.addon_name,
replacement=f"addon_{uuid4().hex[:5].upper()}",
).replace("__", "_")
def update_reregister(self, context):
if not self.pause_reregister:
compile_addon()
pause_reregister: bpy.props.BoolProperty(
default=False,
name="Pause Reregistering",
description="Pauses reregistering the addon when changes are made",
update=update_reregister,
)
snippet_vars_customizable: bpy.props.CollectionProperty(
type=SN_BoolCollection,
name="Variables Customizable",
description="Saves customizable setting of snippet variables",
)
snippet_props_customizable: bpy.props.CollectionProperty(
type=SN_BoolCollection,
name="Properties Customizable",
description="Saves customizable setting of snippet properties",
)
is_exporting: bpy.props.BoolProperty(
default=False,
name="Is Exporting",
description="Saves the current status of exporting to evaluate nodes differently",
)
picker_active: bpy.props.BoolProperty(
default=False,
name="Picker Is Active",
description="This is enabled when a location picker for panels or similar is active",
)
last_copied_datatype: bpy.props.StringProperty(
default="",
name="Last Copied Data Type",
description="The type of data last copied with the copy property button",
)
last_copied_datapath: bpy.props.StringProperty(
default="",
name="Last Copied Data Path",
description="The path of data last copied with the copy property button",
)
last_copied_required: bpy.props.StringProperty(
default="",
name="Last Copied Required Properties",
description="The identifiers of the last copied required properties separated by ;",
)
copied_context = []
show_wrap_settings: bpy.props.BoolProperty(
default=False,
name="Show Wrap Settings",
description="If this is enabled, the wrapping settings for the text in these panels are show.",
)
line_length: bpy.props.IntProperty(
default=40,
min=10,
name="Line Wrap",
description="The amount of characters shown in a single line in the panel.",
)
has_update: bpy.props.BoolProperty(
name="Has Update",
description="If Serpens has an available update or not. This is set on file load.",
default=False,
)
def update_recompile(self, context):
bpy.ops.sn.force_compile()
debug_python_nodes: bpy.props.BoolProperty(
default=False, name="Debug Nodes", description="Debug internal node code"
)
debug_python_sockets: bpy.props.BoolProperty(
default=False, name="Debug Sockets", description="Debug internal socket code"
)
debug_selected_only: bpy.props.BoolProperty(
default=False,
name="Debug Selected Only",
description="Debug only selected nodes",
)
debug_code: bpy.props.BoolProperty(
default=False,
name="Keep Code",
update=update_recompile,
description="Keeps a python file in the text editor when the code changes",
)
debug_python_properties: bpy.props.BoolProperty(
default=False,
name="Debug Properties",
description="Debug internal property code",
)
debug_compile_time: bpy.props.BoolProperty(
default=False,
name="Debug Compile Time",
description="Prints the time it takes to compile the code",
)
insert_sockets: bpy.props.BoolProperty(
default=False,
name="Insert Socket Buttons",
description="Show an insert button on dynamic sockets to insert a new socket above",
)
compile_on_load: bpy.props.BoolProperty(
default=True,
name="Compile On Load",
description="Compile this addon when the file is loaded",
)
easy_bpy_path: bpy.props.StringProperty(
default="",
name="Easy BPY Path",
description="Gets set when a file is loaded. Set to the easy bpy file path.",
)
def update_node_tree_index(self, context):
if len(bpy.data.node_groups):
if hasattr(bpy.context.space_data, "node_tree"):
bpy.context.space_data.node_tree = bpy.data.node_groups[
self.node_tree_index
]
node_tree_index: bpy.props.IntProperty(
default=0,
min=0,
name="Active Node Tree",
description="The node tree you're currently editing",
update=update_node_tree_index,
)
properties: bpy.props.CollectionProperty(type=SN_GeneralProperties)
property_index: bpy.props.IntProperty(
default=0,
min=0,
name="Active Property",
description="The property you're currently editing",
)
def update_show_property_categories(self, context):
self.active_prop_category = "ALL"
show_property_categories: bpy.props.BoolProperty(
name="Show Property Categories",
description="Show categories for your addon properties",
default=False,
update=update_show_property_categories,
)
property_categories: bpy.props.CollectionProperty(type=SN_PropertyCategory)
def prop_category_items(self, context):
cat_list = list(map(lambda cat: cat.name, self.property_categories))
no_cat = 0
for prop in self.properties:
if (
not prop.category
or prop.category == "OTHER"
or not prop.category in cat_list
):
no_cat += 1
items = [
("ALL", f"All Properties ({len(self.properties)})", "Show all properties"),
(
"OTHER",
f"Uncategorized Properties ({no_cat})",
"Properties without a category",
),
]
for item in self.property_categories:
amount = 0
for prop in self.properties:
if prop.category and prop.category == item.name:
amount += 1
items.append(
(item.name if item.name else "-", f"{item.name} ({amount})", item.name)
)
return items
active_prop_category: bpy.props.EnumProperty(
name="Category", description="The properties shown", items=prop_category_items
)
assets: bpy.props.CollectionProperty(type=SN_AssetProperties)
asset_index: bpy.props.IntProperty(
default=0,
min=0,
name="Active Asset",
description="The asset you're currently editing",
)
addon_name: bpy.props.StringProperty(
default="My Addon", name="Addon Name", description="The name of the addon"
)
description: bpy.props.StringProperty(
default="", name="Description", description="The description of the addon"
)
author: bpy.props.StringProperty(
default="Your Name", name="Author", description="The author of this addon"
)
version: bpy.props.IntVectorProperty(
default=(1, 0, 0),
size=3,
min=0,
name="Version",
description="The author of this addon",
)
blender: bpy.props.IntVectorProperty(
default=(4, 2, 0),
size=3,
min=0,
name="Minimum Blender",
description="Minimum blender version required for this addon",
)
location: bpy.props.StringProperty(
default="",
name="Location",
description="Describes where the addons functionality can be found",
)
warning: bpy.props.StringProperty(
default="",
name="Warning",
description="Used if there is a bug or a problem that the user should be aware of",
)
doc_url: bpy.props.StringProperty(
default="", name="Doc URL", description="URL to the addons documentation"
)
tracker_url: bpy.props.StringProperty(
default="", name="Tracker URL", description="URL to the addons bug tracker"
)
def get_categories(self, context):
categories = [
"3D View",
"Add Mesh",
"Add Curve",
"Animation",
"Compositing",
"Development",
None,
"Game Engine",
"Import-Export",
"Lighting",
"Material",
"Mesh",
"Node",
None,
"Object",
"Paint",
"Physics",
"Render",
"Rigging",
"Scene",
None,
"Sequencer",
"System",
"Text Editor",
"UV",
"User Interface",
]
items = []
for cat in categories:
if cat:
items.append((cat, cat, cat))
else:
items.append(("", "", ""))
return items + [("CUSTOM", "- Custom Category -", "Add your own category")]
category: bpy.props.EnumProperty(
items=get_categories,
name="Category",
description="The category the addon will be displayed in",
)
custom_category: bpy.props.StringProperty(
default="My Category",
name="Custom Category",
description="Your custom category",
)
data_items = {"app": {}, "context": {}, "data": {}}
ops_items = {"operators": {}, "filtered": {}}
def overwrite_data_items(self, data):
self.data_items["data"] = data["data"]
self.data_items["context"] = data["context"]
self.data_items["app"] = data["app"]
def reload_data_category(self, category):
"""Reloads the basic data for a category"""
if category != "context":
self.data_items[category] = get_data_items(
f"bpy.{category}", getattr(bpy, category)
)
else:
ctxt = self.copied_context[0] if self.copied_context else bpy.context.copy()
self.data_items[category] = get_data_items(f"bpy.context", ctxt)
def refresh_filtered_ops(self):
"""Sets the filtered operators"""
filtered = {}
for cat in self.ops_items["operators"]:
cat_ops = []
for op in self.ops_items["operators"][cat]["items"]:
if (
self.data_search.lower() in op["name"].lower()
or self.data_search.lower() in op["operator"].lower()
):
cat_ops.append(op["operator"])
if cat_ops:
filtered[cat] = cat_ops
self.ops_items["filtered"] = filtered
def get_category_ops(self, category, cat_name):
"""Gets the operators for a category"""
ops = []
for op_name in dir(category):
if op_name[0].isalpha():
try:
op = eval(f"bpy.ops.{cat_name}.{op_name}")
except:
op = None
if op:
rna = op.get_rna_type()
ops.append(
{
"name": (
getattr(rna, "name", op_name)
if getattr(rna, "name", op_name)
else op_name.replace("_", " ").title()
),
"operator": op_name,
}
)
return ops
def load_operators(self):
"""Reloads the list of operators"""
self.ops_items["operators"] = {}
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:
self.ops_items["operators"][cat_name] = {
"expanded": False,
"items": self.get_category_ops(cat, cat_name),
}
self.refresh_filtered_ops()
def load_categories(self):
"""Loads the data for the bpy categories"""
self.reload_data_category("app")
self.reload_data_category("context")
self.reload_data_category("data")
self.load_operators()
def update_hide_preferences(self, context):
for cls in space_userpref.classes:
try:
if self.hide_preferences:
bpy.utils.unregister_class(cls)
else:
bpy.utils.register_class(cls)
except:
pass
if self.hide_preferences:
self.load_categories()
hide_preferences: bpy.props.BoolProperty(
default=False,
name="Hide Preferences",
description="Hides all panels in the preferences window",
update=update_hide_preferences,
)
global_search_active: bpy.props.BoolProperty(
default=False,
name="Global Search",
description="If the global search is active",
)
def make_enum_item(self, _id, name, descr, preview_id, uid):
lookup = (
str(_id)
+ "\\0"
+ str(name)
+ "\\0"
+ str(descr)
+ "\\0"
+ str(preview_id)
+ "\\0"
+ str(uid)
)
if not lookup in _item_map:
_item_map[lookup] = (_id, name, descr, preview_id, uid)
return _item_map[lookup]
def get_categories(self, context):
ctxt = "Preferences"
if context.scene.sn.copied_context:
ctxt = f"{self.copied_context[0]['area'].type.replace('_', ' ').title()} {self.copied_context[0]['region'].type.replace('_', ' ').title()}"
items = [
self.make_enum_item("app", "App", "bpy.app", 0, 0),
self.make_enum_item("context", f"Context ({ctxt})", "bpy.context", 0, 1),
self.make_enum_item("data", "Data", "bpy.data", 0, 2),
self.make_enum_item("ops", "Operators", "bpy.ops", 0, 3),
self.make_enum_item(
"discover", "Discover", "Discover items in the global search", 0, 4
),
]
return items
def update_data_search(self, context):
self.refresh_filtered_ops()
def update_categories(self, context):
if self.data_category == "discover":
global_search.start_get_data(context)
discover_data = {"items": [], "full_matches": 0}
def update_discover(self, context):
counted_paths = []
full_matches = 0
queries = self.discover_search.lower().replace(", ", ",").split(",")
for path in global_search.data_flat.keys():
matches = 0
for query in queries:
if query in path.lower():
matches += 1
if matches == len(queries):
full_matches += 1
counted_paths.append((path, matches))
elif not self.discover_full_only:
counted_paths.append((path, matches))
ordered_paths = list(
map(lambda x: x[0], sorted(counted_paths, key=lambda x: x[1], reverse=True))
)
ordered_paths = ordered_paths[: self.discover_show_amount]
self.discover_data["items"] = ordered_paths
self.discover_data["full_matches"] = full_matches
discover_search: bpy.props.StringProperty(
name="Search",
description="Searches for items in the global search (Separate multiple queries with commas)",
default="",
update=update_discover,
)
discover_show_amount: bpy.props.IntProperty(
name="Show Amount",
description="The amount of items to show in the discover tab",
default=100,
min=1,
update=update_discover,
)
discover_full_only: bpy.props.BoolProperty(
name="Full Matches Only",
description="Only show items that match the full search",
default=True,
update=update_discover,
)
data_category: bpy.props.EnumProperty(
name="Category",
items=get_categories,
update=update_categories,
description="Category of blend data",
)
data_filter: bpy.props.EnumProperty(
name="Type",
options={"ENUM_FLAG"},
description="Filter by data type",
items=filter_items,
default=filter_defaults,
)
data_search: bpy.props.StringProperty(
name="Search",
description="Search data",
options={"TEXTEDIT_UPDATE"},
update=update_data_search,
)
show_path: bpy.props.BoolProperty(
name="Show Path", description="Show python path of properties", default=False
)
addons: bpy.props.CollectionProperty(type=SN_Addon)
packages: bpy.props.CollectionProperty(type=SN_Package)
snippets: bpy.props.CollectionProperty(type=SN_Snippet)
snippet_categories: bpy.props.CollectionProperty(type=SN_SnippetCategory)
def update_show_graph_categories(self, context):
self.active_graph_category = "ALL"
show_graph_categories: bpy.props.BoolProperty(
name="Show Graph Categories",
description="Show categories for your addon graphs",
default=False,
update=update_show_graph_categories,
)
graph_categories: bpy.props.CollectionProperty(type=SN_GraphCategory)
def graph_category_items(self, context):
cat_list = list(map(lambda cat: cat.name, self.graph_categories))
no_cat = 0
ntree_amount = 0
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
ntree_amount += 1
if (
not ntree.category
or ntree.category == "OTHER"
or not ntree.category in cat_list
):
no_cat += 1
items = [
("ALL", f"All Graphs ({ntree_amount})", "Show all graphs"),
("OTHER", f"Uncategorized Graphs ({no_cat})", "Graphs without a category"),
]
for item in self.graph_categories:
amount = 0
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
if ntree.category and ntree.category == item.name:
amount += 1
items.append(
(item.name if item.name else "-", f"{item.name} ({amount})", item.name)
)
return items
active_graph_category: bpy.props.EnumProperty(
name="Category", description="The graphs shown", items=graph_category_items
)
overwrite_variable_graph: bpy.props.BoolProperty(
name="Overwrite Variable Graph",
description="Let's you pick a graph to show the variable list from",
default=False,
)
def get_variable_graph_items(self, context):
items = []
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
items.append((ntree.name, ntree.name, ntree.name))
return items
variable_graph: bpy.props.EnumProperty(
name="Variable Graph",
description="Graph to display variables from",
items=get_variable_graph_items,
)
remove_duplicate_code: bpy.props.BoolProperty(
name="Remove Duplicate Code",
description="Removes duplicate code in the generated code (small performance impact for large addons)",
default=True,
update=update_recompile,
)
format_code: bpy.props.BoolProperty(
name="Format Code",
description="Formats linebreaks in the generated code (small performance impact for large addons)",
default=True,
update=update_recompile,
)
def update_watch_scripts(self, context):
if self.watch_script_changes:
watch_script_changes()
else:
unwatch_script_changes()
watch_script_changes: bpy.props.BoolProperty(
name="Watch Script Changes",
description="Will watch for changes in the scripts of your run script nodes and recompile the addon when you save the file",
default=False,
update=update_watch_scripts,
)
multifile: bpy.props.BoolProperty(
name="Multifile",
description="Export the separate node trees as separate python files",
default=False,
)
@@ -0,0 +1,378 @@
import bpy
from ..addon.properties.settings.settings import property_icons
def is_valid_attribute(attr):
ignore_attributes = ["rna_type", "original",
"bl_rna", "evaluated_depsgraph_get"]
return not attr in ignore_attributes and not attr[0] == "_"
filter_items = [("Pointer", "Pointer", "Pointer", property_icons["Property"], 1),
("Collection", "Collection", "Collection",
property_icons["Collection"], 2),
("List", "List", "List", property_icons["List"], 4),
("String", "String/Enum", "Strings and Enums",
property_icons["String"], 8),
("Enum Set", "Enum Set", "Enum Set",
property_icons["Enum Set"], 32),
("Boolean", "Boolean", "Boolean",
property_icons["Boolean"], 64),
("Boolean Vector", "Boolean Vector",
"Boolean Vector", property_icons["Boolean"], 128),
("Integer", "Integer", "Integer",
property_icons["Integer"], 256),
("Integer Vector", "Integer Vector",
"Integer Vector", property_icons["Integer"], 512),
("Float", "Float", "Float", property_icons["Float"], 1024),
("Float Vector", "Float Vector",
"Float Vector", property_icons["Float"], 2048),
("Function", "Function", "Function", property_icons["Function"], 4096)]
filter_defaults = {"Pointer", "Collection", "List", "String", "Enum Set", "Boolean", "Boolean Vector",
"Integer", "Integer Vector", "Float", "Float Vector", "Function"}
def is_iterable(data):
if hasattr(data, "keys") and hasattr(data, "values"):
return hasattr(data, "__getitem__")
return False
def add_additional_data(data_dict, path):
if ".outputs[" in path:
data_dict["links"] = list(eval(path+".links"))
return data_dict
def get_data_items(path, data):
data_items = {}
# get attributes
data_dict = validate_data_dict(data) if type(
data) == dict else data_to_dict(data)
data_dict = add_additional_data(data_dict, path)
for key in data_dict.keys():
item = get_data_item(data, data_dict[key], path, key)
if item:
data_items[key] = item
# get items for iterable
if is_iterable(data) and not type(data) == dict:
# get keyed items
if len(data.keys()) == len(data.values()):
for key in data.keys():
if hasattr(data[key], "bl_rna"):
item = get_data_item(data, data[key], path, f"'{key}'")
if item:
data_items[f"'{key}'"] = item
# get indexed items
else:
max_items = 21
for i in range(min(len(data.values()), max_items)):
item = get_data_item(data, data[i], path, f"{i}")
if item:
data_items[f"{i}"] = item
if i == max_items-1 and len(data.values()) > max_items:
data_items[f"{i}"]["clamped"] = True
# sort items
sorted_keys = sorted(
data_items.keys(), key=lambda s: data_items[s]["type"])
sorted_keys = sorted(
sorted_keys, key=lambda s: data_items[s]["has_properties"], reverse=True)
sorted_items = {}
for key in sorted_keys:
sorted_items[key] = data_items[key]
return sorted_items
def validate_data_dict(data):
""" Removes all attributes that aren't valid """
to_delete = []
for key in data.keys():
if not is_valid_attribute(key):
to_delete.append(key)
for key in to_delete:
del data[key]
return data
def data_to_dict(data):
""" Converts the given data to a dictionary with keys for the path section and the data """
data_dict = {}
attributes = dir(data)
if hasattr(data, "keyframe_insert"):
attributes += dir(bpy.types.Struct)
attributes.sort()
for attribute in attributes:
if is_valid_attribute(attribute):
data_dict[attribute] = getattr(data, attribute)
return data_dict
def get_data_item(parent_data, data, path, attribute):
""" Returns a data object for the given data its path and the datas attribute """
try:
data
has_properties = hasattr(
data, "bl_rna") or "bpy_prop_collection" in str(type(data))
if (attribute[0] == "'" and attribute[-1] == "'") or attribute.isdigit():
new_path = f"{path}[{attribute}]"
has_properties = True
else:
new_path = f"{path}.{attribute}"
data_item = {
"name": get_data_name(data, attribute),
"path": new_path,
"required": "",
"type": get_item_type(data),
"data": data,
"data_search": "",
"data_filter": filter_defaults,
"expanded": False,
"has_properties": has_properties,
"properties": {},
"clamped": False,
}
if data_item["type"] == "Function":
data_item["path"] += get_function_parameters(
parent_data, attribute)
data_item["required"] += get_required_function_parameters(
parent_data, attribute)
return data_item
except:
return None
def get_data_name(data, attribute):
""" Returns a name based on the given attribute name """
if (attribute[0] == "'" and attribute[-1] == "'") or attribute.isdigit():
return f"[{attribute}]"
name = attribute.replace("_", " ").title()
if hasattr(data, "bl_rna"):
name = f"{name} ({data.bl_rna.name})"
return name
def get_item_type(data):
""" Returns the item type for the given data """
item_type = str(type(data))
if "bpy_prop_collection" in item_type:
item_type = "Collection"
elif "bpy_types" in item_type:
item_type = "Pointer"
elif "bpy.types" in item_type:
item_type = "Pointer"
elif hasattr(type(data), "bl_rna"):
item_type = "Pointer"
elif "None" in item_type:
item_type = "Pointer"
elif "bpy_func" in item_type or "builtin_function_or_method" in item_type:
item_type = "Function"
elif "set" in item_type:
item_type = "Enum Set"
elif "str" in item_type:
item_type = "String"
elif "bool" in item_type:
item_type = "Boolean"
elif "float" in item_type:
item_type = "Float"
elif "int" in item_type:
item_type = "Integer"
elif "Color" in item_type:
item_type = "Float Vector"
elif "Euler" in item_type:
item_type = "Float Vector"
elif "Quaternion" in item_type:
item_type = "Float Vector"
elif "Matrix" in item_type:
item_type = "List"
elif "list" in item_type:
item_type = "List"
elif ("tuple" in item_type or "Vector" in item_type or "bpy_prop_array" in item_type) and len(data):
if "float" in str(type(data[0])):
item_type = "Float Vector"
elif "int" in str(type(data[0])):
item_type = "Integer Vector"
elif "bool" in str(type(data[0])):
item_type = "Boolean Vector"
else:
item_type = "List"
return item_type
def get_special_function_params(attribute):
""" Returns the special parameters for the given function """
if attribute == "keyframe_insert":
return "data_path: String, index: Integer, frame: Integer, group: String, options: Enum Set['INSERTKEY_NEEDED','INSERTKEY_VISUAL','INSERTKEY_XYZ_TO_RGB','INSERTKEY_REPLACE','INSERTKEY_AVAILABLE','INSERTKEY_CYCLE_AWARE'], "
elif attribute == "keyframe_delete":
return "data_path: String, index: Integer, frame: Integer, group: String, "
elif attribute == "driver_add":
return "path*: String, index*: Integer, "
elif attribute == "driver_remove":
return "path*: String, index*: Integer, "
return ""
def get_special_function_outputs(attribute):
""" Returns the special outputs for the given function """
if attribute == "as_pointer":
return "adress: Integer, "
elif attribute == "keyframe_insert":
return "success: Boolean, "
elif attribute == "keyframe_delete":
return "success: Boolean, "
elif attribute == "driver_add":
return "fcurve: Data, "
elif attribute == "driver_remove":
return "success: Boolean, "
return ""
def get_special_required_parameters(attribute):
""" Returns the special required parameters for the given function """
if attribute == "keyframe_insert":
return "data_path"
elif attribute == "keyframe_delete":
return "data_path"
elif attribute == "driver_add":
return "path*"
elif attribute == "driver_remove":
return "path*"
return ""
def get_function_parameters(parent_data, name):
""" Returns a dictionary with function parameters """
params = ""
outputs = ""
if hasattr(parent_data, "bl_rna") and hasattr(parent_data.bl_rna, "functions"):
if name in parent_data.bl_rna.functions:
for param in parent_data.bl_rna.functions[name].parameters:
param_type = param.type.title()
if param_type == "Str":
param_type = "String"
elif param_type == "Int":
param_type = "Integer"
if getattr(param, "is_array", False):
param_type += " Vector"
if param_type == "Enum":
if param.is_enum_flag:
param_type = "Enum Set"
items = ",".join(
list(map(lambda item: f"'{item.identifier}'", param.enum_items_static)))
param_type += f"[{items}]"
if param.is_output:
outputs += f"{param.identifier}: {param_type}, "
else:
params += f"{param.identifier}: {param_type}, "
else:
params = get_special_function_params(name)
outputs = get_special_function_outputs(name)
if params:
params = params[:-2]
if outputs:
outputs = outputs[:-2]
params = f"({params})"
if outputs:
params += f" = {outputs}"
return params
def get_required_function_parameters(parent_data, name):
""" Returns a string separated by ; with the required function parameters """
required = ""
if hasattr(parent_data, "bl_rna") and hasattr(parent_data.bl_rna, "functions"):
if name in parent_data.bl_rna.functions:
for param in parent_data.bl_rna.functions[name].parameters:
if param.is_required:
required += param.identifier if not required else f";{param.identifier}"
else:
required = get_special_required_parameters(name)
return required
def item_from_path(data, path):
""" Returns the item in the data for the given path. Works for anything above bpy.xyz """
# after bpy.xyz
if len(path.split(".")) > 2:
path_sections = bpy_to_path_sections(path, False)
curr_item = data[path_sections[0]][path_sections[1]]
for key in path_sections[2:]:
curr_item = curr_item["properties"][key]
return curr_item
# bpy
elif len(path.split(".")) == 1:
return bpy
# bpy.xyz
else:
return data[path.split(".")[-1]]
def bpy_to_path_sections(path, keep_brackets=True):
""" Takes a blender python data path and converts it to json path sections """
if len(path) >= 4 and path[:4] == "bpy.":
path = path[4:]
sections = []
curr_section = ""
bracket_level = 0
in_string = False
for char in path:
if in_string:
curr_section += char
if char == "'" or char == '"':
in_string = False
else:
if char == "." and bracket_level == 0:
sections.append(curr_section)
curr_section = ""
elif char == "'" or char == '"':
in_string = True
curr_section += char
elif char == "[":
if bracket_level == 0:
sections.append(curr_section)
curr_section = "[" if keep_brackets else ""
bracket_level = 1
else:
bracket_level += 1
curr_section += "["
elif char == "]":
bracket_level -= 1
if keep_brackets or bracket_level > 0:
curr_section += "]"
else:
curr_section += char
sections.append(curr_section)
sections = list(filter(lambda item: item, sections))
return sections
def bpy_to_indexed_sections(path):
""" Takes a blender python data path and converts it to indexed path sections """
# combine indexed sections
combined = ["bpy"]
for section in bpy_to_path_sections(path):
if (section[0] == "[" and section[-1] == "]") and not combined[-1][-1] == "]":
combined[-1] += section
else:
combined.append(section)
if not "bpy." in path:
combined = combined[1:]
return combined
def join_sections(sections):
""" Returns the given sections joined to a valid data path """
data_path = ""
for i, section in enumerate(sections):
if i == 0 or section[0] == "[":
data_path += section
else:
data_path += f".{section}"
return data_path
@@ -0,0 +1,13 @@
import bpy
import sys
import os
def check_easy_bpy_install():
bpy.context.scene.sn.easy_bpy_path = ""
for path in sys.path:
if os.path.exists(path):
for filename in os.listdir(path):
if filename == "easybpy.py":
bpy.context.scene.sn.easy_bpy_path = os.path.join(path, filename)
@@ -0,0 +1,110 @@
import bpy
import threading
thread = None
data_flat = {}
visited_ids = {}
def is_iterable(data):
if hasattr(data, "keys") and hasattr(data, "values"):
return hasattr(data, "__getitem__")
return False
def is_valid_key(key):
if key.startswith("_"):
return False
if key in ["rna_type", "bl_rna", "depsgraph", "original", "get", "find", "items", "keys", "values", "foreach_get", "foreach_set", "id_data", "tag", "pop", "keyconfigs"]:
return False
return True
def is_valid_path(path, screen):
# NOTE blender bug where it crashes when you access data from another screen
if ".screens[" in path and not screen.name in path:
return False
return True
def get_data_info(data, key):
info = {
"name": key,
"type": type(data).__name__,
}
return info
def get_data(data, path, screen, depth):
global data_flat
global visited_ids
if depth > 10:
return
data_id = data
if data_id in visited_ids and visited_ids[data_id]["depth"] <= depth:
return
visited_ids[data_id] = {
"path": path,
"depth": depth
}
keys = dir(data)
if hasattr(data, "keyframe_insert"):
keys += dir(bpy.types.Struct)
for key in keys:
child_path = f"{path}.{key}"
if is_valid_key(key) and is_valid_path(child_path, screen):
try:
try:
if data.bl_rna.properties[key].type == "ENUM":
if data.bl_rna.properties[key].is_enum_flag:
child_data = set()
else:
child_data = ""
else:
child_data = getattr(data, key)
except Exception as e:
child_data = getattr(data, key)
data_flat[child_path] = get_data_info(child_data, key)
if hasattr(child_data, "bl_rna"):
get_data(child_data, child_path, screen, depth+1)
max_items = 20
if is_iterable(child_data):
# keyed data
if len(child_data.keys()) == len(child_data.values()):
for i, child_key in enumerate(list(child_data.keys())):
get_data(child_data[child_key], f"{child_path}['{child_key}']", screen, depth+1)
# indexed data
else:
for i in range(min(len(child_data.values()), max_items)):
get_data(child_data[i], f"{child_path}[{i}]", screen, depth+1)
except: pass
def timer():
global thread
global data_flat
if not thread.is_alive():
keys = list(data_flat.keys())
keys.sort(key=len)
new_data = {}
for key in keys:
new_data[key] = data_flat[key]
data_flat = new_data
bpy.context.scene.sn.discover_search = bpy.context.scene.sn.discover_search
return None
return 0.1
def start_get_data(context):
global thread
global data_flat
global visited_ids
data_flat = {}
visited_ids = {}
thread = threading.Thread(target=get_data, args=(bpy.data, "bpy.data", context.screen, 1))
thread.start()
bpy.app.timers.register(timer, first_interval=0.1)
@@ -0,0 +1,47 @@
import bpy
import os
def watch_script_changes():
bpy.app.timers.register(timer_script_update)
def unwatch_script_changes():
if bpy.app.timers.is_registered(timer_script_update):
bpy.app.timers.unregister(timer_script_update)
last_updates = {
# '{static_uid;filename}': 'last_save_time'
}
def update_script_nodes(update_blender_always=False):
for ntree in bpy.data.node_groups:
if ntree.bl_idname == "ScriptingNodesTree":
for node in ntree.node_collection("SN_RunScriptNode").nodes:
path = ""
if node.source == "BLENDER":
if node.script and update_blender_always:
node._evaluate(bpy.context)
elif node.script and node.script.filepath:
path = node.script.filepath
elif node.source == "EXTERNAL":
node_path = eval(node.inputs['Script Path'].python_value)
if node_path:
path = bpy.path.abspath(node_path)
if path and os.path.exists(path):
key = f"{node.static_uid};{path}"
time = os.path.getmtime(path)
if not key in last_updates:
last_updates[key] = time
node._evaluate(bpy.context)
else:
if last_updates[key] != time:
last_updates[key] = time
node._evaluate(bpy.context)
def timer_script_update():
update_script_nodes()
return 2
@@ -0,0 +1,135 @@
import bpy
import requests
from random import shuffle
class SN_Addon(bpy.types.PropertyGroup):
name: bpy.props.StringProperty()
description: bpy.props.StringProperty()
price: bpy.props.StringProperty()
category: bpy.props.StringProperty()
author: bpy.props.StringProperty()
blender_version: bpy.props.IntVectorProperty(size=3)
addon_version: bpy.props.IntVectorProperty(size=3)
is_external: bpy.props.BoolProperty()
has_blend: bpy.props.BoolProperty()
addon_url: bpy.props.StringProperty()
blend_url: bpy.props.StringProperty()
serpens_version: bpy.props.IntProperty()
class SN_Package(bpy.types.PropertyGroup):
name: bpy.props.StringProperty()
description: bpy.props.StringProperty()
price: bpy.props.StringProperty()
author: bpy.props.StringProperty()
url: bpy.props.StringProperty()
serpens_version: bpy.props.IntProperty()
class SN_Snippet(bpy.types.PropertyGroup):
name: bpy.props.StringProperty()
description: bpy.props.StringProperty()
price: bpy.props.StringProperty()
author: bpy.props.StringProperty()
url: bpy.props.StringProperty()
blend_url: bpy.props.StringProperty()
serpens_version: bpy.props.IntProperty()
class SN_OT_LoadAddons(bpy.types.Operator):
bl_idname = "sn.load_addons"
bl_label = "Load Addons"
bl_description = "Loads the addons from the marketplace"
bl_options = {"REGISTER","INTERNAL","UNDO"}
def execute(self, context):
url = "https://raw.githubusercontent.com/joshuaKnauber/serpens_addon_market/main/addons.json"
try:
content = requests.get(url).json()
addons = content["addons"]
shuffle(addons)
context.scene.sn.addons.clear()
for addon in addons:
item = context.scene.sn.addons.add()
item.name = addon["name"]
if addon["price"]: item.price = addon["price"]
item.description = addon["description"]
item.category = addon["category"]
item.author = addon["author"]
item.blender_version = addon["blender_version"]
item.addon_version = addon["addon_version"]
item.is_external = addon["external"]
item.has_blend = addon["blend"]
item.addon_url = addon["url"]
item.blend_url = addon["blend_url"]
item.serpens_version = 2 if not "serpens_version" in addon else 3
except:
self.report({"ERROR"}, message="Couldn't load addons!")
return {"FINISHED"}
class SN_OT_LoadPackages(bpy.types.Operator):
bl_idname = "sn.load_packages"
bl_label = "Load Packages"
bl_description = "Loads the packages from the marketplace"
bl_options = {"REGISTER","INTERNAL","UNDO"}
def execute(self, context):
url = "https://raw.githubusercontent.com/joshuaKnauber/serpens_addon_market/main/packages.json"
try:
content = requests.get(url).json()
packages = content["packages"]
shuffle(packages)
context.scene.sn.packages.clear()
for package in packages:
item = context.scene.sn.packages.add()
item.name = package["title"]
item.description = package["description"]
item.price = package["price"]
item.url = package["url"]
item.author = package["author"]
item.serpens_version = 2 if not "version" in package else 3
except:
print("Couldn't load packages!")
return {"FINISHED"}
class SN_OT_LoadSnippets(bpy.types.Operator):
bl_idname = "sn.load_snippets"
bl_label = "Load Snippets"
bl_description = "Loads the snippets from the marketplace"
bl_options = {"REGISTER","INTERNAL","UNDO"}
def execute(self, context):
url = "https://raw.githubusercontent.com/joshuaKnauber/serpens_addon_market/main/snippets.json"
try:
content = requests.get(url).json()
snippets = content["snippets"]
shuffle(snippets)
context.scene.sn.snippets.clear()
for snippet in snippets:
item = context.scene.sn.snippets.add()
item.name = snippet["title"]
item.description = snippet["description"]
item.price = snippet["price"]
item.url = snippet["url"]
item.blend_url = snippet["blend_url"]
item.author = snippet["author"]
item.serpens_version = 2 if not "serpens_version" in snippet else snippet["serpens_version"]
except:
print("Couldn't load snippets!")
return {"FINISHED"}
@@ -0,0 +1,13 @@
import bpy
class PresetData(bpy.types.PropertyGroup):
name: bpy.props.StringProperty(name="Name",
description="Name of this preset node")
idname: bpy.props.StringProperty(name="Idname",
description="The idname of this node")
data: bpy.props.StringProperty(name="Data",
description="The stringified json data used to recreate this node")
@@ -0,0 +1,91 @@
import bpy
import os
import requests
import json
from datetime import date
message = []
class SN_OT_MessageUpdate(bpy.types.Operator):
bl_idname = "sn.update_message"
bl_label = "- - - - - - - - - - - - - - - -"
bl_description = "Update Message"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context):
update_log()
bpy.context.scene.sn.has_update = False
return {"FINISHED"}
def draw(self, context):
global message
layout = self.layout
layout.label(text="Serpens Update available!", icon="FUND")
layout.label(text="A free update is out.")
layout.label(text="You can go and download it right now!")
if message:
layout.label(text="This update includes:")
for m in message:
layout.label(text="" + m)
layout.label(text="- - - - - - - - - - - - - - - -")
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def update_log():
with open(os.path.join(os.path.dirname(__file__), "update_log.json"), "w") as log:
log.write(json.dumps({"last_check": str(date.today())}, indent=4))
def should_update():
should_update = False
addon_prefs = bpy.context.preferences.addons[__name__.partition('.')[ 0]].preferences
if not addon_prefs.check_for_updates:
should_update = False
log_path = os.path.join(os.path.dirname(__file__), "update_log.json")
if os.path.exists(log_path):
with open(log_path, "r") as log:
log = json.loads(log.read())
if not log["last_check"] == str(date.today()):
should_update = True
else:
update_log()
return should_update
def exists_newer_version(version, current):
needs_update = False
if version[0] > current[0]:
needs_update = True
elif version[0] == current[0]:
if version[1] > current[1]:
needs_update = True
elif version[1] == current[1]:
if version[2] > current[2]:
needs_update = True
return needs_update
def check_serpens_updates(current_version):
global message
bpy.context.scene.sn.has_update = False
if should_update():
url = "https://raw.githubusercontent.com/joshuaKnauber/serpens_addon_market/main/version.json"
try:
content = requests.get(url).json()
version = tuple(content["version"])
if exists_newer_version(version, current_version):
message = content["content"]
bpy.context.scene.sn.has_update = True
else:
update_log()
except:
print("Couldn't check for Serpens updates!")