node groups

This commit is contained in:
Nicolas Predella 2026-06-02 12:23:31 +03:00
parent f7f74a4cc1
commit cebc165282
4 changed files with 522 additions and 9 deletions

View file

@ -16,6 +16,7 @@ bl_info = {
} }
_registered_nodeslicer = False _registered_nodeslicer = False
_keymaps = []
def register(): def register():
global _registered_nodeslicer global _registered_nodeslicer
@ -27,19 +28,51 @@ def register():
bpy.types.Object.nodeslicer = bpy.props.PointerProperty(type=SlicerObjectPropertyGroup, name="nodeslicer") #type: ignore bpy.types.Object.nodeslicer = bpy.props.PointerProperty(type=SlicerObjectPropertyGroup, name="nodeslicer") #type: ignore
_registered_nodeslicer = True _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') print('Nodeslicer Loaded')
def unregister(): def unregister():
global _registered_nodeslicer global _registered_nodeslicer
try:
if tree.depsgraph_update_handler in bpy.app.handlers.depsgraph_update_post: if tree.depsgraph_update_handler in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.remove(tree.depsgraph_update_handler) bpy.app.handlers.depsgraph_update_post.remove(tree.depsgraph_update_handler)
except Exception as e:
print(f"Error removing handler: {e}")
try:
bpy.types.NODE_MT_add.remove(tree.draw_add_menu) 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() registry.blender_unregister_classes()
if _registered_nodeslicer: if _registered_nodeslicer:
try:
if hasattr(bpy.types.Object, "nodeslicer"): if hasattr(bpy.types.Object, "nodeslicer"):
del bpy.types.Object.nodeslicer #type: ignore del bpy.types.Object.nodeslicer #type: ignore
except Exception as e:
print(f"Error removing property: {e}")
finally:
_registered_nodeslicer = False _registered_nodeslicer = False
print('Nodeslicer Unloaded') print('Nodeslicer Unloaded')

View file

@ -19,6 +19,13 @@ class LabelNodeSocket(SliceNodeSocket):
class InputNodeSocket(SliceNodeSocket): class InputNodeSocket(SliceNodeSocket):
def draw(self, context: Any, layout: Any, node: Any, text: str | None): def draw(self, context: Any, layout: Any, node: Any, text: str | None):
assert layout 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: if not self.is_output and self.is_linked:
layout.label(text=text or self.name) layout.label(text=text or self.name)
else: else:

View file

@ -115,11 +115,300 @@ def depsgraph_update_handler(scene, depsgraph):
tree_update_handler(scene, depsgraph) tree_update_handler(scene, depsgraph)
object_init_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 @register_class
class SLICETREE_NodeTree(NodeTree): class SLICETREE_NodeTree(NodeTree):
bl_idname = "SLICETREE_NodeTree" bl_idname = "SLICETREE_NodeTree"
bl_label = "NodeSlicer Tree" bl_label = "NodeSlicer Tree"
bl_icon = "NODETREE" 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 @register_class
class SLICENODE_OT_Execute(Operator): 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="Executor").type = "SLICENODE_Executor"
layout.operator("node.add_node", text="Test").type = "SLICENODE_Test" 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 @register_class
class SLICENODE_MT_Menu(Menu): class SLICENODE_MT_Menu(Menu):
bl_idname = "SLICENODE_MT_Menu" bl_idname = "SLICENODE_MT_Menu"
@ -252,6 +556,8 @@ class SLICENODE_MT_Menu(Menu):
layout.menu("SLICENODE_MT_Gcode") layout.menu("SLICENODE_MT_Gcode")
layout.menu("SLICENODE_MT_Joiners") layout.menu("SLICENODE_MT_Joiners")
layout.menu("SLICENODE_MT_ActionIO") layout.menu("SLICENODE_MT_ActionIO")
layout.separator()
layout.menu("SLICENODE_MT_Group")
def draw_add_menu(self, context): def draw_add_menu(self, context):

View file

@ -24,7 +24,7 @@ class SliceNodeSocket(NodeSocket, AutoBlIdName):
def draw_color(self, context, node) -> tuple[float, float, float, float]: # pyright: ignore[reportIncompatibleMethodOverride] def draw_color(self, context, node) -> tuple[float, float, float, float]: # pyright: ignore[reportIncompatibleMethodOverride]
return self.bl_color return self.bl_color
from ...registry import get_label_by_name from ...registry import get_label_by_name, register_class
class SliceNode(Node, AutoBlIdName): class SliceNode(Node, AutoBlIdName):
def update(self) -> None: def update(self) -> None:
self._update_multi_groups() self._update_multi_groups()
@ -156,6 +156,166 @@ def node_execution_feedback(node: bpy.types.Node):
node.color = old_color node.color = old_color
bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) 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: def _follow_reroute(socket: NodeSocket) -> NodeSocket:
current: NodeSocket = socket current: NodeSocket = socket
seen: set[int] = set() seen: set[int] = set()
@ -222,18 +382,25 @@ def _resolve_output(
visited = set() if visited is None else visited visited = set() if visited is None else visited
assert socket.node 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 # 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. # (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] 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 if node_key in visited: return default # cycle guard
visited.add(node_key) visited.add(node_key)
try: try:
with node_execution_feedback(socket.node): with node_execution_feedback(socket.node):
if not isinstance(socket.node, SliceNode): 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 return default
# Set these temporarily so self.resolve() can find them # Set these temporarily so self.resolve() can find them