From cebc1652829c6ea4e8a10135009b9a5cb2a4f6a1 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Tue, 2 Jun 2026 12:23:31 +0300 Subject: [PATCH] node groups --- __init__.py | 45 ++- core/nodeslicer/sockets.py | 7 + core/nodeslicer/tree.py | 306 +++++++++++++++++++ infra/blender/blender_slice_nodes_generic.py | 173 ++++++++++- 4 files changed, 522 insertions(+), 9 deletions(-) diff --git a/__init__.py b/__init__.py index 609bafd..fd1357d 100644 --- a/__init__.py +++ b/__init__.py @@ -16,6 +16,7 @@ bl_info = { } _registered_nodeslicer = False +_keymaps = [] def register(): global _registered_nodeslicer @@ -27,20 +28,52 @@ def register(): bpy.types.Object.nodeslicer = bpy.props.PointerProperty(type=SlicerObjectPropertyGroup, name="nodeslicer") #type: ignore _registered_nodeslicer = True + # Register Keymaps + wm = bpy.context.window_manager + if wm.keyconfigs.addon: + km = wm.keyconfigs.addon.keymaps.new(name='Node Editor', space_type='NODE_EDITOR') + kmi = km.keymap_items.new("slicenode.group_make", 'G', 'PRESS', ctrl=True) + _keymaps.append((km, kmi)) + kmi = km.keymap_items.new("node.group_ungroup", 'G', 'PRESS', ctrl=True, alt=True) + _keymaps.append((km, kmi)) + # Custom Tab toggle for groups + kmi = km.keymap_items.new("slicenode.group_toggle", 'TAB', 'PRESS') + _keymaps.append((km, kmi)) + print('Nodeslicer Loaded') def unregister(): global _registered_nodeslicer - if tree.depsgraph_update_handler in bpy.app.handlers.depsgraph_update_post: - bpy.app.handlers.depsgraph_update_post.remove(tree.depsgraph_update_handler) + try: + if tree.depsgraph_update_handler in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.remove(tree.depsgraph_update_handler) + except Exception as e: + print(f"Error removing handler: {e}") - bpy.types.NODE_MT_add.remove(tree.draw_add_menu) + try: + bpy.types.NODE_MT_add.remove(tree.draw_add_menu) + except Exception as e: + print(f"Error removing menu: {e}") + + # Unregister Keymaps safely + try: + for km, kmi in _keymaps: + km.keymap_items.remove(kmi) + except Exception as e: + print(f"Error removing keymaps: {e}") + finally: + _keymaps.clear() + registry.blender_unregister_classes() if _registered_nodeslicer: - if hasattr(bpy.types.Object, "nodeslicer"): - del bpy.types.Object.nodeslicer #type: ignore - _registered_nodeslicer = False + try: + if hasattr(bpy.types.Object, "nodeslicer"): + del bpy.types.Object.nodeslicer #type: ignore + except Exception as e: + print(f"Error removing property: {e}") + finally: + _registered_nodeslicer = False print('Nodeslicer Unloaded') diff --git a/core/nodeslicer/sockets.py b/core/nodeslicer/sockets.py index c0d12d0..78f9612 100644 --- a/core/nodeslicer/sockets.py +++ b/core/nodeslicer/sockets.py @@ -19,6 +19,13 @@ class LabelNodeSocket(SliceNodeSocket): class InputNodeSocket(SliceNodeSocket): def draw(self, context: Any, layout: Any, node: Any, text: str | None): assert layout + if node and isinstance(node, bpy.types.NodeCustomGroup): + if self.is_output or self.is_linked: + layout.label(text=text or self.name) + else: + self._draw_prop(text, layout) + return + if not self.is_output and self.is_linked: layout.label(text=text or self.name) else: diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 399fd2f..e2e0dca 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -115,11 +115,300 @@ def depsgraph_update_handler(scene, depsgraph): tree_update_handler(scene, depsgraph) object_init_handler(scene, depsgraph) + # Sync SLICENODE_Group sockets if interface changed + for tree in bpy.data.node_groups: + if tree.bl_idname == "SLICETREE_NodeTree": + for node in tree.nodes: + if node.bl_idname == "SLICENODE_Group" and hasattr(node, "sync_sockets"): + try: + node.sync_sockets() + except Exception: + pass + + @register_class class SLICETREE_NodeTree(NodeTree): bl_idname = "SLICETREE_NodeTree" bl_label = "NodeSlicer Tree" bl_icon = "NODETREE" + bl_group_idname = "SLICENODE_Group" + bl_use_group_interface = True + +@register_class +class SLICENODE_OT_GroupToggle(Operator): + bl_idname = "slicenode.group_toggle" + bl_label = "Toggle Group" + bl_description = "Enter or exit the selected node group" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return (context.space_data.type == 'NODE_EDITOR' and + context.space_data.tree_type == "SLICETREE_NodeTree") + + def execute(self, context): + space = context.space_data + path = getattr(space, 'path', None) + if not path: + return {'CANCELLED'} + + # If we are inside a group, popping returns us to the parent + # We want to exit if: 1. No node is selected, OR 2. We press Tab while editing inside a group + active_node = context.active_node + + # If we have an active group node, we try to enter it + if active_node and active_node.bl_idname == "SLICENODE_Group": + group_tree = getattr(active_node, "slicer_tree", None) + if group_tree: + path.append(group_tree, node=active_node) + return {'FINISHED'} + + # Otherwise, if we are nested, we pop out + if len(path) > 1: + path.pop() + return {'FINISHED'} + + return {'CANCELLED'} + +@register_class +class SLICENODE_OT_GroupMake(Operator): + bl_idname = "slicenode.group_make" + bl_label = "Make Group" + bl_description = "Make a group from selected nodes" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return (context.space_data.type == 'NODE_EDITOR' and + context.space_data.edit_tree and + context.space_data.edit_tree.bl_idname == "SLICETREE_NodeTree" and + any(n.select for n in context.space_data.edit_tree.nodes)) + + def execute(self, context): + import bpy + from mathutils import Vector + space = context.space_data + tree = space.edit_tree + + selected_nodes = [n for n in tree.nodes if n.select and n.bl_idname != 'NodeReroute'] + if not selected_nodes: + return {'CANCELLED'} + + # Store selection to restore later or for mapping + original_selection = selected_nodes[:] + + # Create new tree + new_tree = bpy.data.node_groups.new("NodeGroup", "SLICETREE_NodeTree") + + # 1. Map connections + node_map = {} + for old_node in selected_nodes: + new_node = new_tree.nodes.new(old_node.bl_idname) + new_node.location = old_node.location + new_node.name = old_node.name + # Copy node properties (simplified) + for prop in old_node.bl_rna.properties: + if prop.is_readonly or prop.identifier in {'rna_type', 'name', 'location', 'dimensions'}: + continue + try: + setattr(new_node, prop.identifier, getattr(old_node, prop.identifier)) + except: + pass + + # Copy socket properties (value, default_value, etc) + for old_sock, new_sock in zip(old_node.inputs, new_node.inputs): + for prop in old_sock.bl_rna.properties: + if prop.is_readonly or prop.identifier in {'rna_type', 'name', 'identifier', 'is_linked', 'is_output', 'hide'}: + continue + try: + setattr(new_sock, prop.identifier, getattr(old_sock, prop.identifier)) + except: + pass + + for old_sock, new_sock in zip(old_node.outputs, new_node.outputs): + for prop in old_sock.bl_rna.properties: + if prop.is_readonly or prop.identifier in {'rna_type', 'name', 'identifier', 'is_linked', 'is_output', 'hide'}: + continue + try: + setattr(new_sock, prop.identifier, getattr(old_sock, prop.identifier)) + except: + pass + + node_map[old_node] = new_node + + internal_links = [] + external_inputs = [] # link + external_outputs = [] # link + + for link in tree.links: + from_in_selection = link.from_node in node_map + to_in_selection = link.to_node in node_map + + if from_in_selection and to_in_selection: + internal_links.append(link) + elif to_in_selection: + external_inputs.append(link) + elif from_in_selection: + external_outputs.append(link) + + # 2. Create interface and Group Input/Output nodes + has_interface = hasattr(new_tree, "interface") + avg_loc = sum((n.location for n in selected_nodes), Vector((0,0))) / len(selected_nodes) + + print(f"GroupMake: new_tree '{new_tree.name}' interface type: {type(new_tree.interface)}") + + group_input_node = new_tree.nodes.new("NodeGroupInput") + group_input_node.location = (min(n.location.x for n in node_map.values()) - 400, avg_loc.y) + + group_output_node = new_tree.nodes.new("NodeGroupOutput") + group_output_node.location = (max(n.location.x for n in node_map.values()) + 400, avg_loc.y) + + # Map external inputs to new tree inputs + input_mapping = [] # (old_link, interface_item) + print(f"GroupMake: Processing {len(external_inputs)} external inputs") + for link in external_inputs: + # Check if we already have an interface item for this source socket + item = next((m[1] for m in input_mapping if m[0].from_socket == link.from_socket), None) + if not item: + name = link.to_socket.name + if has_interface: + socket_type = link.to_socket.bl_idname + print(f"GroupMake: Creating interface input '{name}' of type '{socket_type}'") + try: + item = new_tree.interface.new_socket(name=name, in_out='INPUT', socket_type=socket_type) + print(f"GroupMake: Successfully created item '{item.name}' (ID: {item.identifier})") + except Exception as e: + print(f"GroupMake: FAILED to create interface input: {e}") + continue + else: + item = new_tree.inputs.new(link.to_socket.bl_idname, name) + input_mapping.append((link, item)) + + # Find the corresponding socket on group_input_node + target_socket = None + if has_interface: + print(f"GroupMake: Looking for GroupInput socket with identifier '{item.identifier}' or name '{item.name}'") + target_socket = group_input_node.outputs.get(item.identifier) or group_input_node.outputs.get(item.name) + else: + target_socket = group_input_node.outputs[new_tree.inputs.find(item.name)] + + if target_socket: + new_tree.links.new(target_socket, node_map[link.to_node].inputs[link.to_socket.identifier]) + else: + print(f"GroupMake: Warning - target socket not found on GroupInput. Available: {[o.name for o in group_input_node.outputs]}") + + # Map external outputs to new tree outputs + output_mapping = [] # (old_link, interface_item) + print(f"GroupMake: Processing {len(external_outputs)} external outputs") + for link in external_outputs: + item = next((m[1] for m in output_mapping if m[0].from_socket == link.from_socket), None) + if not item: + name = link.from_socket.name + if has_interface: + socket_type = link.from_socket.bl_idname + print(f"GroupMake: Creating interface output '{name}' of type '{socket_type}'") + try: + item = new_tree.interface.new_socket(name=name, in_out='OUTPUT', socket_type=socket_type) + print(f"GroupMake: Successfully created item '{item.name}' (ID: {item.identifier})") + except Exception as e: + print(f"GroupMake: FAILED to create interface output: {e}") + continue + else: + item = new_tree.outputs.new(link.from_socket.bl_idname, name) + output_mapping.append((link, item)) + + target_socket = None + if has_interface: + print(f"GroupMake: Looking for GroupOutput socket with identifier '{item.identifier}' or name '{item.name}'") + target_socket = group_output_node.inputs.get(item.identifier) or group_output_node.inputs.get(item.name) + else: + target_socket = group_output_node.inputs[new_tree.outputs.find(item.name)] + + if target_socket: + new_tree.links.new(node_map[link.from_node].outputs[link.from_socket.identifier], target_socket) + else: + print(f"GroupMake: Warning - target socket not found on GroupOutput. Available: {[i.name for i in group_output_node.inputs]}") + + # 3. Re-create internal links + for link in internal_links: + try: + new_tree.links.new(node_map[link.from_node].outputs[link.from_socket.identifier], + node_map[link.to_node].inputs[link.to_socket.identifier]) + except: + pass + + # 4. Create the group node and assign tree + group_node = tree.nodes.new("SLICENODE_Group") + group_node.location = avg_loc + + # Robustly get interface items count + interface_items = [] + if has_interface: + items_attr = getattr(new_tree.interface, "items", None) + if items_attr is not None and not callable(items_attr): + interface_items = items_attr + else: + # Try iterating directly + try: + interface_items = [item for item in new_tree.interface] + except: + pass + + print(f"GroupMake: Assigning new tree to group node. Interface items found: {len(interface_items)}") + for item in interface_items: + print(f" - Item: {getattr(item, 'name', 'unnamed')}, Type: {getattr(item, 'item_type', 'unknown')}, Socket: {getattr(item, 'socket_type', 'unknown')}") + + # Assigning slicer_tree triggers mirroring via update callback + group_node.slicer_tree = new_tree + + # Force an update of the external sockets to be sure + if hasattr(new_tree, "interface_update"): + print("GroupMake: Calling interface_update(bpy.context)") + try: + new_tree.interface_update(bpy.context) + except Exception as e: + print(f"GroupMake: interface_update failed: {e}") + + # Manually force sync + if hasattr(group_node, "sync_sockets"): + group_node.sync_sockets() + + print(f"GroupMake: Post-sync socket count: {len(group_node.inputs)} in, {len(group_node.outputs)} out") + + # Re-link external + # The group_node should have sockets mirrored from new_tree.interface + for link, item in input_mapping: + target_socket = None + if has_interface: + target_socket = group_node.inputs.get(item.name) # Try name first + if not target_socket: + target_socket = group_node.inputs.get(item.identifier) + else: + target_socket = group_node.inputs.get(item.name) + + if target_socket: + tree.links.new(link.from_socket, target_socket) + + for link, item in output_mapping: + target_socket = None + if has_interface: + target_socket = group_node.outputs.get(item.name) + if not target_socket: + target_socket = group_node.outputs.get(item.identifier) + else: + target_socket = group_node.outputs.get(item.name) + + if target_socket: + tree.links.new(target_socket, link.to_socket) + + # 5. Delete old nodes + for node in selected_nodes: + tree.nodes.remove(node) + + # 6. Enter the group + space.path.append(new_tree, node=group_node) # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + + return {'FINISHED'} @register_class class SLICENODE_OT_Execute(Operator): @@ -239,6 +528,21 @@ class SLICENODE_MT_ActionIO(Menu): layout.operator("node.add_node", text="Executor").type = "SLICENODE_Executor" layout.operator("node.add_node", text="Test").type = "SLICENODE_Test" +@register_class +class SLICENODE_MT_Group(Menu): + bl_idname = "SLICENODE_MT_Group" + bl_label = "Group" + + def draw(self, context): + layout = self.layout + assert layout + layout.operator("slicenode.group_make", text="Make Group") + layout.operator("node.group_ungroup", text="Ungroup") + layout.operator("slicenode.group_toggle", text="Edit Group") + layout.separator() + layout.operator("node.add_node", text="Group Input").type = "NodeGroupInput" + layout.operator("node.add_node", text="Group Output").type = "NodeGroupOutput" + @register_class class SLICENODE_MT_Menu(Menu): bl_idname = "SLICENODE_MT_Menu" @@ -252,6 +556,8 @@ class SLICENODE_MT_Menu(Menu): layout.menu("SLICENODE_MT_Gcode") layout.menu("SLICENODE_MT_Joiners") layout.menu("SLICENODE_MT_ActionIO") + layout.separator() + layout.menu("SLICENODE_MT_Group") def draw_add_menu(self, context): diff --git a/infra/blender/blender_slice_nodes_generic.py b/infra/blender/blender_slice_nodes_generic.py index 4040a7d..dc625fc 100644 --- a/infra/blender/blender_slice_nodes_generic.py +++ b/infra/blender/blender_slice_nodes_generic.py @@ -24,7 +24,7 @@ class SliceNodeSocket(NodeSocket, AutoBlIdName): def draw_color(self, context, node) -> tuple[float, float, float, float]: # pyright: ignore[reportIncompatibleMethodOverride] return self.bl_color -from ...registry import get_label_by_name +from ...registry import get_label_by_name, register_class class SliceNode(Node, AutoBlIdName): def update(self) -> None: self._update_multi_groups() @@ -156,6 +156,166 @@ def node_execution_feedback(node: bpy.types.Node): node.color = old_color bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) +@contextmanager +def _group_context(node: bpy.types.Node): + stack = _get_group_stack() + stack.append(node) + try: + yield + finally: + stack.pop() + +def _get_group_stack() -> list[bpy.types.Node]: + cache = _current_execution_cache + if cache is None: return [] + if '_group_stack' not in cache: + cache['_group_stack'] = [] + return cache['_group_stack'] + +def _get_current_group_node() -> bpy.types.Node | None: + stack = _get_group_stack() + return stack[-1] if stack else None + +@register_class +class SLICENODE_Group(bpy.types.NodeCustomGroup, AutoBlIdName): + bl_label = "Group" + + def _tree_updated(self, context): + try: + if not self.name: return + _ = len(self.inputs) + _ = len(self.outputs) + except Exception: + return + + self.sync_sockets() + + slicer_tree: bpy.props.PointerProperty( + type=bpy.types.NodeTree, + name="Node Tree", + update=_tree_updated + ) + + def sync_sockets(self): + try: + if not self.name: return + _ = len(self.inputs) + _ = len(self.outputs) + except Exception: + return + + if not self.slicer_tree: + self.inputs.clear() + self.outputs.clear() + return + + # 1. Collect expected sockets from interface + expected_inputs = [] # (identifier, name, type) + expected_outputs = [] + + has_interface = hasattr(self.slicer_tree, "interface") + if has_interface: + # In Blender 4.0+, the collection of items is items_tree + items_tree = getattr(self.slicer_tree.interface, "items_tree", []) + for item in items_tree: + if getattr(item, "item_type", "") != 'SOCKET': continue + target_list = expected_inputs if item.in_out == 'INPUT' else expected_outputs + target_list.append((item.identifier, item.name, item.socket_type)) + else: + # Fallback for older Blender API without interface + for sock in getattr(self.slicer_tree, "inputs", []): + expected_inputs.append((sock.identifier, sock.name, sock.bl_idname)) + for sock in getattr(self.slicer_tree, "outputs", []): + expected_outputs.append((sock.identifier, sock.name, sock.bl_idname)) + + # 2. Sync Inputs + self._sync_socket_collection(self.inputs, expected_inputs) + + # 3. Sync Outputs + self._sync_socket_collection(self.outputs, expected_outputs) + + def _sync_socket_collection(self, collection, expected): + # Remove sockets that shouldn't be there + expected_ids = {e[0] for e in expected} + to_remove = [s for s in collection if s.identifier not in expected_ids] + + for sock in to_remove: + collection.remove(sock) + + # Add or update sockets + sock_indexes = {s.identifier: i for i, s in enumerate(collection)} + for ident, name, stype in expected: + if ident not in sock_indexes: + try: + # Explicitly pass identifier to keep it perfectly synced + sock = collection.new(stype, name, identifier=ident) + sock_indexes[sock.identifier] = len(collection) - 1 + except Exception as e: + print(f"Failed to create socket {name} ({stype}): {e}") + else: + # Update name if changed + sock = collection[sock_indexes[ident]] + if sock.name != name: + sock.name = name + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "slicer_tree", text="") + + def draw_label(self): + if self.slicer_tree: + return self.slicer_tree.name + return "Group" + + +def _resolve_group_output(socket: NodeSocket, cache: _EvalCache | None, visited: set[tuple[int, str]] | None): + node = socket.node + assert isinstance(node, (bpy.types.NodeGroup, SLICENODE_Group)) + + if isinstance(node, SLICENODE_Group): + tree = node.slicer_tree + else: + tree = node.node_tree + + if not tree: return None + + try: + idx = list(node.outputs).index(socket) + except (ValueError, IndexError): + return None + + # Find the NodeGroupOutput node that has this input linked, or the first one + output_node = None + for n in tree.nodes: + if isinstance(n, bpy.types.NodeGroupOutput): + # NodeGroupOutput inputs usually match the group outputs by index + if idx < len(n.inputs) and n.inputs[idx].is_linked: + output_node = n + break + + if not output_node: + output_node = next((n for n in tree.nodes if isinstance(n, bpy.types.NodeGroupOutput)), None) + + if not output_node or idx >= len(output_node.inputs): return None + + inner_socket = output_node.inputs[idx] + + with _group_context(node): + return resolve_input(typing.cast(Any, inner_socket), cache=cache, visited=visited) + +def _resolve_group_input(socket: NodeSocket, cache: _EvalCache | None, visited: set[tuple[int, str]] | None): + group_node = _get_current_group_node() + if not group_node: return None + + # NodeGroupInput outputs usually match the group inputs by index + try: + idx = list(socket.node.outputs).index(socket) + outer_socket = group_node.inputs[idx] + except (ValueError, IndexError): + return None + + return resolve_input(typing.cast(Any, outer_socket), cache=cache, visited=visited) + def _follow_reroute(socket: NodeSocket) -> NodeSocket: current: NodeSocket = socket seen: set[int] = set() @@ -222,18 +382,25 @@ def _resolve_output( visited = set() if visited is None else visited assert socket.node + # Include group stack in cache key to ensure independent evaluation for multiple instances + group_stack = tuple(n.as_pointer() for n in _get_group_stack()) + # Use identifier instead of name for the cache key, as multiple sockets # (especially in multi-groups) can share the same name but must have unique identifiers. - key = (socket.node.as_pointer(), socket.node.name, socket.identifier) + key = (group_stack, socket.node.as_pointer(), socket.node.name, socket.identifier) if key in cache: return cache[key] - node_key = (socket.node.as_pointer(), socket.node.name) + node_key = (group_stack, socket.node.as_pointer(), socket.node.name) if node_key in visited: return default # cycle guard visited.add(node_key) try: with node_execution_feedback(socket.node): if not isinstance(socket.node, SliceNode): + if isinstance(socket.node, (bpy.types.NodeGroup, SLICENODE_Group)): + return _resolve_group_output(socket, cache=cache, visited=visited) + if isinstance(socket.node, bpy.types.NodeGroupInput): + return _resolve_group_input(socket, cache=cache, visited=visited) return default # Set these temporarily so self.resolve() can find them