Compare commits

...
This repository has been archived on 2026-10-29. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
Author SHA1 Message Date
Meta Vens
ebac332fe7 Avoid duplicate converter keywords for socket-backed properties 2026-05-04 20:04:50 +01:00
Meta Vens
6fbebb8ff2 Tolerate Blender menu socket defaults 2026-05-04 15:51:38 +01:00
Meta Vens
277d8b0128 Port node tree conversion operator 2026-05-04 15:06:48 +01:00
Meta Vens
55085d0a4b Fix nested tree group references on Blender 4+ 2026-05-04 13:41:12 +01:00
4 changed files with 350 additions and 6 deletions

View file

@ -22,6 +22,7 @@ import webbrowser
from .api.tree import *
from .preferences import GeometryScriptPreferences
from .absolute_path import absolute_path
from .operators.convert_tree import ConvertTree
bl_info = {
"name" : "Geometry Script",
@ -74,6 +75,10 @@ def templates_menu_draw(self, context):
def editor_header_draw(self, context):
self.layout.menu(GeometryScriptMenu.bl_idname)
def node_header_draw(self, context):
if context.space_data.tree_type == 'GeometryNodeTree':
self.layout.operator(ConvertTree.bl_idname)
def auto_resolve():
if bpy.context.scene.geometry_script_settings.auto_resolve:
try:
@ -97,8 +102,10 @@ def register():
bpy.utils.register_class(GeometryScriptPreferences)
bpy.utils.register_class(OpenDocumentation)
bpy.utils.register_class(GeometryScriptMenu)
bpy.utils.register_class(ConvertTree)
bpy.types.TEXT_HT_header.append(editor_header_draw)
bpy.types.NODE_HT_header.append(node_header_draw)
bpy.types.Scene.geometry_script_settings = bpy.props.PointerProperty(type=GeometryScriptSettings)
@ -111,7 +118,9 @@ def unregister():
bpy.utils.unregister_class(GeometryScriptPreferences)
bpy.utils.unregister_class(OpenDocumentation)
bpy.utils.unregister_class(GeometryScriptMenu)
bpy.utils.unregister_class(ConvertTree)
bpy.types.TEXT_HT_header.remove(editor_header_draw)
bpy.types.NODE_HT_header.remove(node_header_draw)
try:
bpy.app.timers.unregister(auto_resolve)
except:

View file

@ -115,8 +115,12 @@ def tree(name):
node_input = node_group.interface.new_socket(socket_type=arg[1][0].socket_type, name=input_name, in_out='INPUT')
else:
node_input = node_group.inputs.new(arg[1][0].socket_type, input_name)
if arg[1][1] != inspect.Parameter.empty:
node_input.default_value = arg[1][1]
if arg[1][1] != inspect.Parameter.empty and arg[1][1] is not None:
try:
node_input.default_value = arg[1][1]
except TypeError:
if getattr(node_input, "bl_socket_idname", "") != "NodeSocketMenu":
raise
if arg[1][2] is not None:
if arg[1][2] not in builder_inputs:
builder_inputs[arg[1][2]] = signature.parameters[arg[1][2]].annotation()
@ -163,12 +167,21 @@ def tree(name):
# This lets @trees be used in other @trees via simple function calls.
def group_reference(*args, **kwargs):
if IS_BLENDER_4:
result = geometrynodegroup(node_tree=node_group, *args, **kwargs)
result = State.current_node_tree.nodes.new('GeometryNodeGroup')
result.node_tree = node_group
enabled_inputs = [node_input for node_input in result.inputs if node_input.enabled]
for value, node_input in zip(args, enabled_inputs):
set_or_create_link(value, node_input)
for node_input in enabled_inputs[len(args):]:
argname = node_input.name.lower().replace(' ', '_')
if argname in kwargs:
set_or_create_link(kwargs[argname], node_input)
else:
result = group(node_tree=node_group, *args, **kwargs)
group_outputs = []
for group_output in result._socket.node.outputs:
group_outputs.append(Type(group_output))
for group_output in result.outputs:
if group_output.enabled:
group_outputs.append(Type(group_output))
if len(group_outputs) == 1:
return group_outputs[0]
else:
@ -178,4 +191,4 @@ def tree(name):
return build_tree
else:
tree_name = name.__name__
return build_tree(name)
return build_tree(name)

1
operators/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Geometry Script Blender operators."""

321
operators/convert_tree.py Normal file
View file

@ -0,0 +1,321 @@
import collections.abc
import keyword
import re
import bpy
import mathutils
_SOCKET_TYPE_NAMES = {
"BOOLEAN": "Bool",
"COLLECTION": "Collection",
"GEOMETRY": "Geometry",
"IMAGE": "Image",
"INT": "Int",
"MATERIAL": "Material",
"MENU": "Menu",
"OBJECT": "Object",
"RGBA": "Color",
"ROTATION": "Rotation",
"STRING": "String",
"TEXTURE": "Texture",
"VALUE": "Float",
"VECTOR": "Vector",
}
_PROP_DENYLIST = {
"bl_idname",
"bl_label",
"bl_static_type",
"color",
"dimensions",
"height",
"hide",
"inputs",
"internal_links",
"label",
"location",
"mute",
"name",
"outputs",
"parent",
"rna_type",
"select",
"show_options",
"show_preview",
"show_texture",
"type",
"use_custom_color",
"warning_propagation",
"width",
}
def _snake_case(value):
value = re.sub(r"[^0-9A-Za-z_]+", "_", value.strip())
value = re.sub(r"_+", "_", value).strip("_").lower()
if not value:
value = "value"
if value[0].isdigit():
value = "_" + value
if keyword.iskeyword(value):
value += "_"
return value
def _node_function_name(node):
if node.bl_idname == "GeometryNodeGroup" and getattr(node, "node_tree", None):
return _snake_case(node.node_tree.name)
return _snake_case(node.bl_rna.name)
def _socket_name(socket):
return _snake_case(socket.name or socket.identifier)
def _enabled_sockets(sockets):
return [socket for socket in sockets if socket.enabled and socket.type != "CUSTOM"]
def _convert_value(value):
if isinstance(value, mathutils.Vector):
return tuple(value)
if isinstance(value, mathutils.Euler):
return tuple(value)
if isinstance(value, mathutils.Color):
return tuple(value)
if isinstance(value, bpy.types.bpy_prop_array):
return tuple(value)
if isinstance(value, collections.abc.Iterable) and not isinstance(value, (str, bytes)):
try:
return tuple(value)
except TypeError:
return value
return value
def _literal(value):
value = _convert_value(value)
if isinstance(value, bpy.types.ID):
return "None"
return repr(value)
def _socket_type_annotation(socket):
return _SOCKET_TYPE_NAMES.get(socket.type, "Type")
class _Assignment:
def __init__(self, name, node):
self.name = name
self.node = node
self.props = {}
self.arguments = {}
self.argument_outputs = {}
self.argument_is_multi_input = {}
def flattened_arguments(self):
result = []
for value in self.arguments.values():
if isinstance(value, _Assignment):
result.append(value)
elif isinstance(value, list):
result.extend(item for item in value if isinstance(item, _Assignment))
return result
def _value_expr(self, key, value, index=None):
if isinstance(value, list):
values = [self._value_expr(key, item, index=i) for i, item in enumerate(value)]
if self.argument_is_multi_input.get(key, False):
return "[" + ", ".join(values) + "]"
if len(values) == 1:
return values[0]
return "(" + ", ".join(values) + ")"
if not isinstance(value, _Assignment):
return _literal(value)
if value.node.type == "GROUP_INPUT":
output_name = self.argument_outputs[key] if index is None else self.argument_outputs[key][index]
return output_name
output_name = self.argument_outputs[key] if index is None else self.argument_outputs[key][index]
enabled_outputs = _enabled_sockets(value.node.outputs)
if len(enabled_outputs) > 1:
return f"{value.name}.{output_name}"
return value.name
def _argument_expr(self, key, value):
return f"{key}={self._value_expr(key, value)}"
def result_targets(self):
return self.name
def to_script(self):
args = []
for key, value in self.props.items():
args.append(f"{key}={_literal(value)}")
for key, value in self.arguments.items():
args.append(self._argument_expr(key, value))
return f"{self.result_targets()} = {_node_function_name(self.node)}({', '.join(args)})"
def _collect_node_props(node):
props = {}
parent_props = set()
if getattr(node.bl_rna, "base", None) is not None:
parent_props.update(prop.identifier for prop in node.bl_rna.base.bl_rna.properties)
for prop in node.bl_rna.properties:
if prop.identifier in parent_props or prop.identifier in _PROP_DENYLIST:
continue
if prop.is_readonly and prop.type not in {"ENUM", "BOOLEAN", "INT", "FLOAT", "STRING"}:
continue
try:
value = getattr(node, prop.identifier)
except Exception:
continue
if prop.type == "POINTER":
continue
if prop.type == "COLLECTION":
continue
props[_snake_case(prop.identifier)] = _convert_value(value)
return props
def _collect_input_defaults(node):
props = {}
for socket in _enabled_sockets(node.inputs):
if not hasattr(socket, "default_value") or socket.hide_value:
continue
props[_socket_name(socket)] = _convert_value(socket.default_value)
return props
def _topological_sort(root):
seen = set()
order = []
def visit(assignment):
if assignment in seen:
return
seen.add(assignment)
for dependency in assignment.flattened_arguments():
visit(dependency)
order.append(assignment)
visit(root)
return order
def _function_arguments(group_input):
if group_input is None:
return []
arguments = []
for output in _enabled_sockets(group_input.outputs):
arguments.append(f"{_socket_name(output)}: {_socket_type_annotation(output)}")
return arguments
def _return_entries(group_output, output_assignment):
entries = []
for socket in _enabled_sockets(group_output.node.inputs):
key = _socket_name(socket)
if key not in group_output.arguments:
continue
value = group_output.arguments[key]
expr = group_output._argument_expr(key, value).split("=", 1)[1]
entries.append(f'"{socket.name}": {expr}')
if entries:
return "{ " + ", ".join(entries) + " }"
return output_assignment.name
def convert_node_tree(tree):
"""Return a best-effort Geometry Script draft for a Blender node tree."""
assignments = []
node_to_assignment = {}
node_type_counts = {}
incoming_links = {}
for node in tree.nodes:
prefix = _snake_case(node.type[:1] or "node")
count = node_type_counts.get(prefix, 0) + 1
node_type_counts[prefix] = count
assignment = _Assignment(f"{prefix}{count}", node)
assignment.props.update(_collect_node_props(node))
assignments.append(assignment)
node_to_assignment[node] = assignment
for link in tree.links:
incoming_links.setdefault(link.to_socket, []).append((node_to_assignment[link.from_node], _socket_name(link.from_socket)))
for assignment in assignments:
grouped_inputs = {}
for socket in _enabled_sockets(assignment.node.inputs):
key = _socket_name(socket)
grouped_inputs.setdefault(key, []).append(socket)
for key, sockets in grouped_inputs.items():
values = []
output_names = {}
is_multi_input = any(socket.is_multi_input for socket in sockets)
for socket in sockets:
links = incoming_links.get(socket, [])
if links:
for from_assignment, output_name in links:
output_names[len(values)] = output_name
values.append(from_assignment)
elif hasattr(socket, "default_value") and not socket.hide_value:
values.append(_convert_value(socket.default_value))
if not values:
continue
# Some Blender nodes expose the same conceptual control as both an
# RNA enum property and an input socket. Geometry Script maps that
# collapsed control to one Python keyword, so prefer the socket
# value/link and drop the duplicate property keyword.
assignment.props.pop(key, None)
if len(values) == 1:
assignment.arguments[key] = values[0]
if output_names:
assignment.argument_outputs[key] = output_names[0]
else:
assignment.arguments[key] = values
assignment.argument_outputs[key] = output_names
assignment.argument_is_multi_input[key] = is_multi_input
group_output = next((assignment for assignment in assignments if assignment.node.type == "GROUP_OUTPUT"), None)
if group_output is None:
raise ValueError(f"Node tree {tree.name!r} has no Group Output node")
group_input = next((node for node in tree.nodes if node.type == "GROUP_INPUT"), None)
sorted_assignments = [
assignment
for assignment in _topological_sort(group_output)
if assignment.node.type not in {"GROUP_INPUT", "GROUP_OUTPUT"}
]
body = "\n ".join(assignment.to_script() for assignment in sorted_assignments)
if not body:
body = "pass"
arguments = ", ".join(_function_arguments(group_input))
function_name = _snake_case(tree.name)
return_expr = _return_entries(group_output, sorted_assignments[-1] if sorted_assignments else group_output)
return (
"from geometry_script import *\n\n"
f'@tree("{tree.name}")\n'
f"def {function_name}({arguments}):\n"
f" {body}\n"
f" return {return_expr}\n"
)
class ConvertTree(bpy.types.Operator):
bl_idname = "geometry_script.convert_tree"
bl_label = "Convert to Geometry Script"
@classmethod
def poll(cls, context):
return bool(getattr(getattr(context, "space_data", None), "node_tree", None))
def execute(self, context):
script = convert_node_tree(context.space_data.node_tree)
script_datablock = bpy.data.texts.new(context.space_data.node_tree.name + ".py")
script_datablock.write(script)
return {"FINISHED"}