From 277d8b01284fba5fe07c759483963859bf35c781 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 4 May 2026 15:06:48 +0100 Subject: [PATCH] Port node tree conversion operator --- __init__.py | 9 ++ operators/__init__.py | 1 + operators/convert_tree.py | 316 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 operators/__init__.py create mode 100644 operators/convert_tree.py diff --git a/__init__.py b/__init__.py index 9e428e7..df8432a 100644 --- a/__init__.py +++ b/__init__.py @@ -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: diff --git a/operators/__init__.py b/operators/__init__.py new file mode 100644 index 0000000..ea7f7d1 --- /dev/null +++ b/operators/__init__.py @@ -0,0 +1 @@ +"""Geometry Script Blender operators.""" diff --git a/operators/convert_tree.py b/operators/convert_tree.py new file mode 100644 index 0000000..8485ea2 --- /dev/null +++ b/operators/convert_tree.py @@ -0,0 +1,316 @@ +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 + 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"}