Refactor multi-socket group system and fix G-code generation metadata synchronization

This commit is contained in:
Nicolas Predella 2026-05-27 17:16:23 +03:00
parent 5f49097f80
commit 5aad206c48
5 changed files with 168 additions and 118 deletions

View file

@ -357,12 +357,12 @@ class SLICENODE_GetPaths(SliceNode):
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
obj: Any = self.resolve("Object") obj: Any = self.resolve("Object")
attr_name: str = self.resolve("Extrusion Multiplier Attribute") e_attr_name: str = self.resolve("Extrusion Multiplier Attribute")
f_attr_name: str = self.resolve("Feedrate Attribute") f_attr_name: str = self.resolve("Feedrate Attribute")
extra_attrs: dict[str, str] = self.resolve("Extra Attributes") extra_attrs: dict[str, str] = self.resolve("Extra Attributes")
from ...infra.blender.blender_mesh_capture import capture_mesh_edges from ...infra.blender.blender_mesh_capture import capture_mesh_edges
so, e_rates, f_rates, extra_attrs_data = capture_mesh_edges(obj, attr_name=attr_name, f_attr_name=f_attr_name, extra_attrs=extra_attrs) so, e_rates, f_rates, extra_attrs_data = capture_mesh_edges(obj, e_attr_name, f_attr_name, extra_attrs)
return SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data) return SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data)
@ -723,7 +723,7 @@ class SliceNodeJoiner(SliceNode):
def init(self, context): def init(self, context):
self.add_output(self.output_socket_type or self.socket_type) self.add_output(self.output_socket_type or self.socket_type)
self.add_multi_input(self.socket_type) self.add_multi_socket_group(self.socket_type)
@register_class @register_class
class SLICENODE_JoinGcode(SliceNodeJoiner): class SLICENODE_JoinGcode(SliceNodeJoiner):
@ -997,7 +997,7 @@ class SLICENODE_GcodeToFile(ExecSliceNode):
def init(self, context): def init(self, context):
self._init_exec() self._init_exec()
self.add_input("SLICESOCK_DirPath", "Folder") self.add_input("SLICESOCK_DirPath", "Folder")
self.add_multi_input("SLICESOCK_Gcode", "Gcode") self.add_multi_socket_group("SLICESOCK_Gcode", name="Gcode")
def draw_buttons(self, context, layout): def draw_buttons(self, context, layout):
super().draw_buttons(context, layout) super().draw_buttons(context, layout)
@ -1156,7 +1156,7 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
def init(self, context): def init(self, context):
self._init_exec() self._init_exec()
self.add_multi_input("SLICESOCK_Gcode", "Gcode") self.add_multi_socket_group("SLICESOCK_Gcode", name="Gcode")
def draw_buttons(self, context, layout): def draw_buttons(self, context, layout):
super().draw_buttons(context, layout) super().draw_buttons(context, layout)
@ -1176,7 +1176,6 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
import json import json
if not gc: if not gc:
if operator: operator.report({'ERROR'}, "No Gcode to send")
continue continue
# Parse Metadata from Gcode # Parse Metadata from Gcode
@ -1250,10 +1249,8 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
record_id = self._submit_to_db(token, fields, temp_path) record_id = self._submit_to_db(token, fields, temp_path)
if operator: operator.report({'INFO'}, f"Submitted to {self._db_name} (Record: {record_id})") if operator: operator.report({'INFO'}, f"Submitted to {self._db_name} (Record: {record_id})")
return 'ok'
except Exception as e: except Exception as e:
if operator: operator.report({'ERROR'}, f"{self._db_name} Error: {str(e)}") if operator: operator.report({'ERROR'}, f"{self._db_name} Error: {str(e)}")
return 'error'
finally: finally:
if temp_path.exists(): if temp_path.exists():
temp_path.unlink() temp_path.unlink()
@ -1344,37 +1341,39 @@ class SLICENODE_PrusaslicerSlice(SliceNode):
bl_label = "PrusaSlicer Slice" bl_label = "PrusaSlicer Slice"
prusaslicer_executable: NfStringProperty(name="PrusaSlicer Path", subtype='FILE_PATH', default='flatpak run com.prusa3d.PrusaSlicer') # pyright: ignore[reportInvalidTypeForm] prusaslicer_executable: NfStringProperty(name="PrusaSlicer Path", subtype='FILE_PATH', default='flatpak run com.prusa3d.PrusaSlicer') # pyright: ignore[reportInvalidTypeForm]
gcode_result: NfStringProperty(name="Gcode Result", default="") # pyright: ignore[reportInvalidTypeForm]
def init(self, context): def init(self, context):
self.add_input("SLICESOCK_Mesh", "Mesh")
self.add_input("SLICESOCK_PrusaslicerConf", "Config") self.add_input("SLICESOCK_PrusaslicerConf", "Config")
self.add_input("SLICESOCK_Settings", "Settings") self.add_input("SLICESOCK_Settings", "Settings")
self.add_output("SLICESOCK_GcodeRo") self.add_multi_socket_group("SLICESOCK_Mesh", "SLICESOCK_GcodeRo", name="Slicing Group")
def draw_buttons(self, context, layout): def draw_buttons(self, context, layout):
assert layout assert layout
layout.prop(self, "prusaslicer_executable") layout.prop(self, "prusaslicer_executable")
def evaluate_output(self, socket_name: str, **kwargs) -> Any: def evaluate_output(self, socket_name: str, identifier: str = "", **kwargs) -> Any:
if socket_name == "Gcode": if identifier.startswith("Slicing Group_ms_out_"):
self.evaluate() try:
return self.gcode_result idx = int(identifier.split("_")[-1])
return self.evaluate_for_index(idx)
except (ValueError, IndexError):
return ""
return super().evaluate_output(socket_name, **kwargs) return super().evaluate_output(socket_name, **kwargs)
def evaluate(self, operator=None): def evaluate_for_index(self, index: int, operator=None) -> str:
self.gcode_result = "" mesh_groups: list[SlicingCollection | SlicingGroup] = self.resolve_multi_input("Slicing Group")
mesh_data: SlicingCollection | SlicingGroup = self.resolve("Mesh") if index >= len(mesh_groups):
return ""
mesh_data = mesh_groups[index]
if not mesh_data:
return ""
conf_data = self.resolve("Config") conf_data = self.resolve("Config")
overrides = self.resolve("Settings") overrides = self.resolve("Settings")
if not mesh_data:
if operator: operator.report({'ERROR'}, "No Mesh data connected")
return 'no object'
if not conf_data: if not conf_data:
if operator: operator.report({'ERROR'}, "No Config connected") return ""
return 'no config'
# Extract data from config dict # Extract data from config dict
printer_id = conf_data['printer'] printer_id = conf_data['printer']
@ -1409,7 +1408,6 @@ class SLICENODE_PrusaslicerSlice(SliceNode):
slicing_group.offset(offset) slicing_group.offset(offset)
# Prepare overrides for generate_conf_writer # Prepare overrides for generate_conf_writer
# The format expected by generate_conf_writer is overrides={'key': {'value': 'val'}}
writer_overrides = {k: {'value': v} for k, v in overrides.items()} writer_overrides = {k: {'value': v} for k, v in overrides.items()}
# Generate the ConfigWriter # Generate the ConfigWriter
@ -1426,72 +1424,49 @@ class SLICENODE_PrusaslicerSlice(SliceNode):
# Prepare 3mf path # Prepare 3mf path
temp_dir = Path(tempfile.gettempdir()) temp_dir = Path(tempfile.gettempdir())
output_path = temp_dir / f"tmp.3mf" output_path = temp_dir / f"tmp_{index}.3mf"
try: try:
from ...infra._3mf_writer._3mf import prepare_3mf from ...infra._3mf_writer._3mf import prepare_3mf
prepare_3mf(output_path, slicing_group, conf_writer, z_gcodes=[]) prepare_3mf(output_path, slicing_group, conf_writer, z_gcodes=[])
# Actual slicing
exe_str = self.prusaslicer_executable exe_str = self.prusaslicer_executable
if not exe_str: if not exe_str: return ""
if operator: operator.report({'ERROR'}, "PrusaSlicer executable path is empty")
return 'error'
# Parse command (handles multi-word commands like flatpak)
cmd_parts = shlex.split(exe_str) cmd_parts = shlex.split(exe_str)
if not cmd_parts: if not cmd_parts: return ""
if operator: operator.report({'ERROR'}, "Invalid PrusaSlicer command")
return 'error'
# Try to resolve the first part as a path if it's not a command in PATH
if not shutil.which(cmd_parts[0]): if not shutil.which(cmd_parts[0]):
abs_path = bpy.path.abspath(cmd_parts[0]) abs_path = bpy.path.abspath(cmd_parts[0])
if os.path.exists(abs_path): if os.path.exists(abs_path):
cmd_parts[0] = abs_path cmd_parts[0] = abs_path
# Final check if command exists
if not shutil.which(cmd_parts[0]): if not shutil.which(cmd_parts[0]):
if operator: operator.report({'ERROR'}, f"Command or executable not found: {cmd_parts[0]}") return ""
self.gcode_result = ""
return 'error'
gcode_path = output_path.with_suffix(".gcode") gcode_path = output_path.with_suffix(".gcode")
# Command: <exe> --export-gcode <3mf_path> --output <gcode_path>
cmd = cmd_parts + ["--export-gcode", str(output_path), "--output", str(gcode_path)] cmd = cmd_parts + ["--export-gcode", str(output_path), "--output", str(gcode_path)]
# Run PrusaSlicer
process = subprocess.run(cmd, capture_output=True, text=True) process = subprocess.run(cmd, capture_output=True, text=True)
if process.returncode != 0: if process.returncode != 0:
if operator: operator.report({'ERROR'}, f"PrusaSlicer failed: {process.stderr}") return ""
self.gcode_result = ""
return 'error'
# Read G-code
if gcode_path.exists(): if gcode_path.exists():
with open(gcode_path, 'r', encoding='utf-8') as f: with open(gcode_path, 'r', encoding='utf-8') as f:
self.gcode_result = f.read() gcode_result = f.read()
self.gcode_result += f"\n; binary_gcode = {binary_gcode_value}\n" gcode_result += f"\n; binary_gcode = {binary_gcode_value}\n"
output_path.unlink() output_path.unlink()
if gcode_path.exists(): gcode_path.unlink()
if operator: return gcode_result
operator.report({'INFO'}, f"Slicing complete. G-code generated.")
else: else:
if operator: operator.report({'ERROR'}, "G-code file was not generated by PrusaSlicer") return ""
self.gcode_result = ""
return 'error'
except Exception as e: except Exception as e:
self.gcode_result = ""
if operator: operator.report({'ERROR'}, f"Failed to slice: {str(e)}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return 'error' return ""
return 'ok'
@register_class @register_class
class SLICENODE_PrusaslicerPreview(ExecSliceNode): class SLICENODE_PrusaslicerPreview(ExecSliceNode):
bl_label = "PrusaSlicer Preview" bl_label = "PrusaSlicer Preview"
@ -1595,7 +1570,7 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
last_e = 0.0 last_e = 0.0
offset_x, offset_y = 0.0, 0.0 offset_x, offset_y = 0.0, 0.0
default_extrusion_rate = 0.017 # Default fallback custom_default_extrusion_rate = 0.0
last_v_idx = -1 last_v_idx = -1
last_p2 = None last_p2 = None
@ -1614,7 +1589,7 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
try: offset_y = float(comment.split('=')[1]) try: offset_y = float(comment.split('=')[1])
except ValueError: pass except ValueError: pass
elif comment.startswith("DEFAULT_EXTRUSION_RATE="): elif comment.startswith("DEFAULT_EXTRUSION_RATE="):
try: default_extrusion_rate = float(comment.split('=')[1]) try: custom_default_extrusion_rate = float(comment.split('=')[1])
except ValueError: pass except ValueError: pass
line_no_comment = line.split(';')[0].strip() line_no_comment = line.split(';')[0].strip()
@ -1651,8 +1626,8 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
# Calculate e_rate (proportion between E and default_extrusion_rate) # Calculate e_rate (proportion between E and default_extrusion_rate)
seg_len_mm = segment_length_3d(p1, p2) * 1000.0 seg_len_mm = segment_length_3d(p1, p2) * 1000.0
if seg_len_mm > 0 and default_extrusion_rate > 0: if seg_len_mm > 0 and custom_default_extrusion_rate > 0:
e_rate = (new_e / seg_len_mm) / default_extrusion_rate e_rate = (new_e / seg_len_mm) / custom_default_extrusion_rate
else: else:
e_rate = 1.0 e_rate = 1.0
@ -1744,7 +1719,7 @@ class SLICENODE_Executor(ExecSliceNode, SliceNodeJoiner):
def init(self, context): def init(self, context):
self._init_exec() self._init_exec()
self.add_multi_input(self.socket_type, "Links") self.add_multi_socket_group(self.socket_type, name="Links")
def draw_buttons(self, context, layout): def draw_buttons(self, context, layout):
super().draw_buttons(context, layout) super().draw_buttons(context, layout)

View file

@ -2,7 +2,6 @@ from __future__ import annotations
from typing import Any from typing import Any
import bpy import bpy
from bpy.props import CollectionProperty from bpy.props import CollectionProperty
from bpy.types import Property, PropertyGroup, bpy_struct
from .bpy_ns_types import NfStringProperty, NfFloatProperty, NfIntProperty, NfEnumProperty, NfPointerProperty, NfFloatVectorProperty from .bpy_ns_types import NfStringProperty, NfFloatProperty, NfIntProperty, NfEnumProperty, NfPointerProperty, NfFloatVectorProperty
from ...registry import register_class from ...registry import register_class

View file

@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from numpy import float64
import numpy as np import numpy as np
import pyclipper import pyclipper
from typing import Any from typing import Any, Literal
from .geometry import ndarray_lru_cache from .geometry import ndarray_lru_cache
@ -111,13 +112,42 @@ class SlicePaths(list[np.ndarray]):
if isinstance(other, SlicePaths): if isinstance(other, SlicePaths):
self.extend(other) self.extend(other)
self.groups.extend(other.groups) self.groups.extend(other.groups)
self.extrusion_rate.extend(other.extrusion_rate)
self.feedrate.extend(other.feedrate)
for k, v in other.extra_attributes.items():
if k in self.extra_attributes:
self.extra_attributes[k].extend(v)
else:
# Pad with zeros for existing paths if we didn't have this attribute
self.extra_attributes[k] = [np.zeros(len(p)) for p in self[:-len(other)]] + list(v)
# Pad existing attributes if 'other' didn't have them
for k in self.extra_attributes:
if k not in other.extra_attributes:
self.extra_attributes[k].extend([np.zeros(len(p)) for p in other])
else: else:
self.extend(other) self.extend(other)
self.groups.extend(list(range(0,len(other)))) self.groups.extend(list(range(0,len(other))))
self.extrusion_rate.extend([np.ones(len(p)) for p in other])
self.feedrate.extend([np.zeros(len(p)) for p in other])
for k in self.extra_attributes:
self.extra_attributes[k].extend([np.zeros(len(p)) for p in other])
def append_with_group(self, path: np.ndarray, group: int = 0): def append_with_group(self, path: np.ndarray, group: int = 0, extrusion_rate: np.ndarray | None = None, feedrate: np.ndarray | None = None, extra_attributes: dict[str, np.ndarray] | None = None):
self.append(path) self.append(path)
self.groups.append(group) self.groups.append(group)
self.extrusion_rate.append(extrusion_rate if extrusion_rate is not None else np.ones(len(path)))
self.feedrate.append(feedrate if feedrate is not None else np.zeros(len(path)))
extra_attributes = extra_attributes or {}
for k, v in extra_attributes.items():
if k in self.extra_attributes:
self.extra_attributes[k].append(v)
else:
self.extra_attributes[k] = [np.zeros(len(p)) for p in self[:-1]] + [v]
for k in self.extra_attributes:
if k not in extra_attributes:
self.extra_attributes[k].append(np.zeros(len(path)))
def slice_at_range(models, z_values) -> list[list[np.ndarray]]: def slice_at_range(models, z_values) -> list[list[np.ndarray]]:
if not models: if not models:
@ -539,9 +569,26 @@ class Pather(object):
def __init__(self, paths: list[np.ndarray], **kwargs) -> None: def __init__(self, paths: list[np.ndarray], **kwargs) -> None:
self.paths = paths self.paths = paths
self.extrusion_rates = getattr(paths, "extrusion_rate", [np.ones(len(p)) for p in paths])
self.feedrates = getattr(paths, "feedrate", [np.zeros(len(p)) for p in paths]) # Robust initialization: ensure metadata lists match paths length
self.extra_attributes = getattr(paths, "extra_attributes", {}) e_rate = getattr(paths, "extrusion_rate", None)
if e_rate is not None and len(e_rate) == len(paths):
self.extrusion_rates = e_rate
else:
self.extrusion_rates = [np.ones(len(p)) for p in paths]
f_rate = getattr(paths, "feedrate", None)
if f_rate is not None and len(f_rate) == len(paths):
self.feedrates = f_rate
else:
self.feedrates = [np.zeros(len(p)) for p in paths]
extra = getattr(paths, "extra_attributes", None)
if extra is not None and all(len(v) == len(paths) for v in extra.values()):
self.extra_attributes = extra
else:
self.extra_attributes = {}
self.bed_center_x: float = 250. self.bed_center_x: float = 250.
self.bed_center_y: float = 250. self.bed_center_y: float = 250.
self.feedrate: float = 1200. self.feedrate: float = 1200.
@ -599,8 +646,8 @@ class Pather(object):
if len(coords) < 2: if len(coords) < 2:
continue continue
e_rates = self.extrusion_rates[i] e_rate = self.extrusion_rates[i]
f_rates = self.feedrates[i] f_rate = self.feedrates[i]
extras = {prefix: vals[i] for prefix, vals in self.extra_attributes.items()} extras = {prefix: vals[i] for prefix, vals in self.extra_attributes.items()}
x0, y0, z0 = coords[0] x0, y0, z0 = coords[0]
@ -618,10 +665,10 @@ class Pather(object):
old_extras = {prefix: None for prefix in extras} old_extras = {prefix: None for prefix in extras}
for j, (p1, p2) in enumerate(zip(coords, coords[1:])): for j, (p1, p2) in enumerate(zip(coords, coords[1:])):
seg_len: float = segment_length_3d(p1, p2) seg_len: float = segment_length_3d(p1, p2)
e_segment = seg_len * self.extrusion_rate * e_rates[j] e_segment = seg_len * self.extrusion_rate * e_rate[j]
# Per-segment feedrate value (not multiplier) # Per-segment feedrate value (not multiplier)
fr = f_rates[j] if f_rates[j] > 0 else self.feedrate fr = f_rate[j] if f_rate[j] > 0 else self.feedrate
old_x, old_y, old_z = p1 old_x, old_y, old_z = p1
x, y, z = p2 x, y, z = p2

View file

@ -410,7 +410,7 @@ def _cached_capture_mesh_edges(
mesh_name: str, mesh_name: str,
matrix_tuple: tuple[float, ...], matrix_tuple: tuple[float, ...],
scene_scale: float, scene_scale: float,
attr_name: str = "e_mult", e_attr_name: str = "e_mult",
f_attr_name: str = "f_rate", f_attr_name: str = "f_rate",
extra_attrs: dict[str, str] | None = None extra_attrs: dict[str, str] | None = None
) -> tuple[list[NDArray[float64]], list[NDArray[float64]], list[NDArray[float64]], dict[str, list[NDArray[float64]]]]: ) -> tuple[list[NDArray[float64]], list[NDArray[float64]], list[NDArray[float64]], dict[str, list[NDArray[float64]]]]:
@ -438,8 +438,8 @@ def _cached_capture_mesh_edges(
mesh.vertices.foreach_get("co", verts.ravel()) mesh.vertices.foreach_get("co", verts.ravel())
e_mults = np.ones(num_verts, dtype=np.float64) e_mults = np.ones(num_verts, dtype=np.float64)
if attr_name in mesh.attributes: if e_attr_name in mesh.attributes:
mesh.attributes[attr_name].data.foreach_get("value", e_mults) mesh.attributes[e_attr_name].data.foreach_get("value", e_mults)
f_rates = np.zeros(num_verts, dtype=np.float64) f_rates = np.zeros(num_verts, dtype=np.float64)
if f_attr_name in mesh.attributes: if f_attr_name in mesh.attributes:
@ -496,7 +496,7 @@ def _cached_capture_mesh_edges(
if hasattr(obj_eval, "to_mesh_clear"): if hasattr(obj_eval, "to_mesh_clear"):
obj_eval.to_mesh_clear() obj_eval.to_mesh_clear()
def capture_mesh_edges(obj: Object, attr_name: str = "e_mult", f_attr_name: str = "f_rate", extra_attrs: dict[str, str] | None = None) -> tuple[list[NDArray[float64]], list[NDArray[float64]], list[NDArray[float64]], dict[str, list[NDArray[float64]]]]: def capture_mesh_edges(obj: Object, e_attr_name: str = "e_mult", f_attr_name: str = "f_rate", extra_attrs: dict[str, str] | None = None) -> tuple[list[NDArray[float64]], list[NDArray[float64]], list[NDArray[float64]], dict[str, list[NDArray[float64]]]]:
if not obj or not obj.data: return [], [], [], {} if not obj or not obj.data: return [], [], [], {}
if obj.mode == 'EDIT': if obj.mode == 'EDIT':
@ -513,7 +513,7 @@ def capture_mesh_edges(obj: Object, attr_name: str = "e_mult", f_attr_name: str
obj.data.name, obj.data.name,
matrix_tuple, matrix_tuple,
scene_scale, scene_scale,
attr_name, e_attr_name,
f_attr_name, f_attr_name,
extra_attrs extra_attrs
) )

View file

@ -27,7 +27,7 @@ class SliceNodeSocket(NodeSocket, AutoBlIdName):
from ...registry import get_label_by_name from ...registry import get_label_by_name
class SliceNode(Node, AutoBlIdName): class SliceNode(Node, AutoBlIdName):
def update(self) -> None: def update(self) -> None:
self._update_multi_inputs() self._update_multi_groups()
def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ... def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ...
@ -36,50 +36,71 @@ class SliceNode(Node, AutoBlIdName):
visited = getattr(self, "_current_visited", None) visited = getattr(self, "_current_visited", None)
return resolve_input(self.inputs.get(fieldname), cache=cache, visited=visited) return resolve_input(self.inputs.get(fieldname), cache=cache, visited=visited)
def add_output(self, bl_idname: str, name: str = "", identifier: str = ""): def add_output(self, bl_idname: str, name: str = "", identifier: str = "", hide: bool = False):
eval_name = name or get_label_by_name(bl_idname) eval_name = name or get_label_by_name(bl_idname)
return self.outputs.new(bl_idname, eval_name, identifier=identifier or eval_name) sock = self.outputs.new(bl_idname, eval_name, identifier=identifier or eval_name)
sock.hide = hide
return sock
def add_input(self, bl_idname: str, name: str = "", identifier: str = ""): def add_input(self, bl_idname: str, name: str = "", identifier: str = "", hide: bool = False):
eval_name = name or get_label_by_name(bl_idname) eval_name = name or get_label_by_name(bl_idname)
return self.inputs.new(bl_idname, eval_name, identifier=identifier or eval_name) sock = self.inputs.new(bl_idname, eval_name, identifier=identifier or eval_name)
sock.hide = hide
return sock
def add_multi_input(self, bl_idname: str, name: str = "") -> None: def add_multi_socket_group(self, in_type: str, out_type: str = "", name: str = "", in_name: str = "", out_name: str = ""):
if not self.get('multi_sockets'): self['multi_sockets'] = {} if not self.get('multi_groups'): self['multi_groups'] = {}
if not self['multi_sockets'].get(name): self['multi_sockets'][name] = {'bl_idname': bl_idname, 'name': name or get_label_by_name(bl_idname)} group_id = name or in_name or out_name or in_type
self._update_multi_inputs() self['multi_groups'][group_id] = {
'in_type': in_type,
'out_type': out_type,
'in_name': in_name or name or get_label_by_name(in_type),
'out_name': out_name or name or (get_label_by_name(out_type) if out_type else ""),
}
self._update_multi_groups()
def resolve_multi_input(self, fieldname) -> Any: def resolve_multi_input(self, fieldname) -> Any:
cache = getattr(self, "_current_cache", None) cache = getattr(self, "_current_cache", None)
visited = getattr(self, "_current_visited", None) visited = getattr(self, "_current_visited", None)
inputs = [i for i in list(self.inputs) if i.name == fieldname] # Search by name or identifier prefix for multi-groups
inputs = [i for i in list(self.inputs) if i.name == fieldname or i.identifier.startswith(f"{fieldname}_ms_in_")]
return [resolve_input(r, cache=cache, visited=visited) for r in inputs] return [resolve_input(r, cache=cache, visited=visited) for r in inputs]
def _update_multi_inputs(self): def _update_multi_groups(self):
if not self.get('multi_sockets'): return if not self.get('multi_groups'): return
for k, ms in self['multi_sockets'].items():
def is_active(input) -> bool:
a: bool = input.is_linked or getattr(input, 'value', False)
b: bool = getattr(inputs()[-1], 'value', False)
return a or b
def inputs(): for group_id, cfg in self['multi_groups'].items():
return [i for i in list(self.inputs) if i.name == ms['name']] in_type = cfg['in_type']
out_type = cfg.get('out_type', '')
in_name = cfg['in_name']
out_name = cfg['out_name']
socket_type = ms['bl_idname'] def get_ins(): return [i for i in self.inputs if i.identifier.startswith(f"{group_id}_ms_in_")]
name = ms['name'] def get_outs(): return [o for o in self.outputs if o.identifier.startswith(f"{group_id}_ms_out_")]
def is_active(s): return s.is_linked or bool(getattr(s, 'value', False))
if len(inputs()) == 0: ins = get_ins()
self.add_input(socket_type, name, name+str(len(inputs())))
return
if is_active(inputs()[-1]): if not ins:
self.add_input(socket_type, name, name+str(len(inputs()))) self.add_input(in_type, in_name, identifier=f"{group_id}_ms_in_0")
if out_type:
self.add_output(out_type, out_name, identifier=f"{group_id}_ms_out_0")
ins = get_ins()
while True: if is_active(ins[-1]):
if len(inputs()) < 2: break idx = len(ins)
if not is_active(inputs()[-1]) and not is_active(inputs()[-2]): self.add_input(in_type, in_name, identifier=f"{group_id}_ms_in_{idx}")
self.inputs.remove(inputs()[-1]) if out_type:
self.add_output(out_type, out_name, identifier=f"{group_id}_ms_out_{idx}")
ins = get_ins()
while len(ins) > 1:
if not is_active(ins[-1]) and not is_active(ins[-2]):
self.inputs.remove(ins[-1])
outs = get_outs()
if out_type and len(outs) >= len(ins):
self.outputs.remove(outs[-1])
ins.pop()
continue continue
break break
@ -201,7 +222,9 @@ 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
key = (socket.node.as_pointer(), socket.node.name, socket.name) # 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)
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 = (socket.node.as_pointer(), socket.node.name)
@ -234,7 +257,13 @@ def _resolve_output(
return default return default
assert socket.node.evaluate_output assert socket.node.evaluate_output
value = socket.node.evaluate_output(socket.name, cache=cache, visited=visited) value = socket.node.evaluate_output(
socket.name,
identifier=socket.identifier,
socket=socket,
cache=cache,
visited=visited
)
cache[key] = value cache[key] = value
return value return value
finally: finally: