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:
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")
extra_attrs: dict[str, str] = self.resolve("Extra Attributes")
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)
@ -723,7 +723,7 @@ class SliceNodeJoiner(SliceNode):
def init(self, context):
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
class SLICENODE_JoinGcode(SliceNodeJoiner):
@ -997,7 +997,7 @@ class SLICENODE_GcodeToFile(ExecSliceNode):
def init(self, context):
self._init_exec()
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):
super().draw_buttons(context, layout)
@ -1156,7 +1156,7 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
def init(self, context):
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):
super().draw_buttons(context, layout)
@ -1176,7 +1176,6 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
import json
if not gc:
if operator: operator.report({'ERROR'}, "No Gcode to send")
continue
# Parse Metadata from Gcode
@ -1250,10 +1249,8 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
record_id = self._submit_to_db(token, fields, temp_path)
if operator: operator.report({'INFO'}, f"Submitted to {self._db_name} (Record: {record_id})")
return 'ok'
except Exception as e:
if operator: operator.report({'ERROR'}, f"{self._db_name} Error: {str(e)}")
return 'error'
finally:
if temp_path.exists():
temp_path.unlink()
@ -1344,37 +1341,39 @@ class SLICENODE_PrusaslicerSlice(SliceNode):
bl_label = "PrusaSlicer Slice"
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):
self.add_input("SLICESOCK_Mesh", "Mesh")
self.add_input("SLICESOCK_PrusaslicerConf", "Config")
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):
assert layout
layout.prop(self, "prusaslicer_executable")
def evaluate_output(self, socket_name: str, **kwargs) -> Any:
if socket_name == "Gcode":
self.evaluate()
return self.gcode_result
def evaluate_output(self, socket_name: str, identifier: str = "", **kwargs) -> Any:
if identifier.startswith("Slicing Group_ms_out_"):
try:
idx = int(identifier.split("_")[-1])
return self.evaluate_for_index(idx)
except (ValueError, IndexError):
return ""
return super().evaluate_output(socket_name, **kwargs)
def evaluate(self, operator=None):
self.gcode_result = ""
mesh_data: SlicingCollection | SlicingGroup = self.resolve("Mesh")
def evaluate_for_index(self, index: int, operator=None) -> str:
mesh_groups: list[SlicingCollection | SlicingGroup] = self.resolve_multi_input("Slicing Group")
if index >= len(mesh_groups):
return ""
mesh_data = mesh_groups[index]
if not mesh_data:
return ""
conf_data = self.resolve("Config")
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 operator: operator.report({'ERROR'}, "No Config connected")
return 'no config'
return ""
# Extract data from config dict
printer_id = conf_data['printer']
@ -1388,7 +1387,7 @@ class SLICENODE_PrusaslicerSlice(SliceNode):
else:
slicing_group = SlicingGroup([])
slicing_group.collections = {mesh_data.objects[0].name: mesh_data}
# Offset by the bed center
def parse_bed_center(bed_shape_str: str) -> np.ndarray:
try:
@ -1403,13 +1402,12 @@ class SLICENODE_PrusaslicerSlice(SliceNode):
bed_shape = cache.profiles[printer_id].all_conf_dict.get('bed_shape', '0x0,250x0,250x210,0x210')
bed_center = parse_bed_center(bed_shape)
a_z = -slicing_group.min_z
offset = np.array([bed_center[0], bed_center[1], a_z]) - np.array([slicing_group.center_xy[0], slicing_group.center_xy[1], 0])
slicing_group.offset(offset)
# 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()}
# Generate the ConfigWriter
@ -1426,72 +1424,49 @@ class SLICENODE_PrusaslicerSlice(SliceNode):
# Prepare 3mf path
temp_dir = Path(tempfile.gettempdir())
output_path = temp_dir / f"tmp.3mf"
output_path = temp_dir / f"tmp_{index}.3mf"
try:
from ...infra._3mf_writer._3mf import prepare_3mf
prepare_3mf(output_path, slicing_group, conf_writer, z_gcodes=[])
# Actual slicing
exe_str = self.prusaslicer_executable
if not exe_str:
if operator: operator.report({'ERROR'}, "PrusaSlicer executable path is empty")
return 'error'
if not exe_str: return ""
# Parse command (handles multi-word commands like flatpak)
cmd_parts = shlex.split(exe_str)
if not cmd_parts:
if operator: operator.report({'ERROR'}, "Invalid PrusaSlicer command")
return 'error'
if not cmd_parts: return ""
# Try to resolve the first part as a path if it's not a command in PATH
if not shutil.which(cmd_parts[0]):
abs_path = bpy.path.abspath(cmd_parts[0])
if os.path.exists(abs_path):
cmd_parts[0] = abs_path
# Final check if command exists
if not shutil.which(cmd_parts[0]):
if operator: operator.report({'ERROR'}, f"Command or executable not found: {cmd_parts[0]}")
self.gcode_result = ""
return 'error'
return ""
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)]
# Run PrusaSlicer
process = subprocess.run(cmd, capture_output=True, text=True)
if process.returncode != 0:
if operator: operator.report({'ERROR'}, f"PrusaSlicer failed: {process.stderr}")
self.gcode_result = ""
return 'error'
return ""
# Read G-code
if gcode_path.exists():
with open(gcode_path, 'r', encoding='utf-8') as f:
self.gcode_result = f.read()
self.gcode_result += f"\n; binary_gcode = {binary_gcode_value}\n"
gcode_result = f.read()
gcode_result += f"\n; binary_gcode = {binary_gcode_value}\n"
output_path.unlink()
if operator:
operator.report({'INFO'}, f"Slicing complete. G-code generated.")
if gcode_path.exists(): gcode_path.unlink()
return gcode_result
else:
if operator: operator.report({'ERROR'}, "G-code file was not generated by PrusaSlicer")
self.gcode_result = ""
return 'error'
return ""
except Exception as e:
self.gcode_result = ""
if operator: operator.report({'ERROR'}, f"Failed to slice: {str(e)}")
import traceback
traceback.print_exc()
return 'error'
return 'ok'
return ""
@register_class
class SLICENODE_PrusaslicerPreview(ExecSliceNode):
bl_label = "PrusaSlicer Preview"
@ -1595,7 +1570,7 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
last_e = 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_p2 = None
@ -1614,7 +1589,7 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
try: offset_y = float(comment.split('=')[1])
except ValueError: pass
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
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)
seg_len_mm = segment_length_3d(p1, p2) * 1000.0
if seg_len_mm > 0 and default_extrusion_rate > 0:
e_rate = (new_e / seg_len_mm) / default_extrusion_rate
if seg_len_mm > 0 and custom_default_extrusion_rate > 0:
e_rate = (new_e / seg_len_mm) / custom_default_extrusion_rate
else:
e_rate = 1.0
@ -1744,7 +1719,7 @@ class SLICENODE_Executor(ExecSliceNode, SliceNodeJoiner):
def init(self, context):
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):
super().draw_buttons(context, layout)

View file

@ -2,7 +2,6 @@ from __future__ import annotations
from typing import Any
import bpy
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 ...registry import register_class

View file

@ -1,7 +1,8 @@
from __future__ import annotations
from numpy import float64
import numpy as np
import pyclipper
from typing import Any
from typing import Any, Literal
from .geometry import ndarray_lru_cache
@ -111,13 +112,42 @@ class SlicePaths(list[np.ndarray]):
if isinstance(other, SlicePaths):
self.extend(other)
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:
self.extend(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.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]]:
if not models:
@ -539,9 +569,26 @@ class Pather(object):
def __init__(self, paths: list[np.ndarray], **kwargs) -> None:
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])
self.extra_attributes = getattr(paths, "extra_attributes", {})
# Robust initialization: ensure metadata lists match paths length
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_y: float = 250.
self.feedrate: float = 1200.
@ -599,8 +646,8 @@ class Pather(object):
if len(coords) < 2:
continue
e_rates = self.extrusion_rates[i]
f_rates = self.feedrates[i]
e_rate = self.extrusion_rates[i]
f_rate = self.feedrates[i]
extras = {prefix: vals[i] for prefix, vals in self.extra_attributes.items()}
x0, y0, z0 = coords[0]
@ -618,10 +665,10 @@ class Pather(object):
old_extras = {prefix: None for prefix in extras}
for j, (p1, p2) in enumerate(zip(coords, coords[1:])):
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)
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
x, y, z = p2

View file

@ -410,7 +410,7 @@ def _cached_capture_mesh_edges(
mesh_name: str,
matrix_tuple: tuple[float, ...],
scene_scale: float,
attr_name: str = "e_mult",
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]]]]:
@ -438,8 +438,8 @@ def _cached_capture_mesh_edges(
mesh.vertices.foreach_get("co", verts.ravel())
e_mults = np.ones(num_verts, dtype=np.float64)
if attr_name in mesh.attributes:
mesh.attributes[attr_name].data.foreach_get("value", e_mults)
if e_attr_name in mesh.attributes:
mesh.attributes[e_attr_name].data.foreach_get("value", e_mults)
f_rates = np.zeros(num_verts, dtype=np.float64)
if f_attr_name in mesh.attributes:
@ -496,7 +496,7 @@ def _cached_capture_mesh_edges(
if hasattr(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 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,
matrix_tuple,
scene_scale,
attr_name,
e_attr_name,
f_attr_name,
extra_attrs
)

View file

@ -27,7 +27,7 @@ class SliceNodeSocket(NodeSocket, AutoBlIdName):
from ...registry import get_label_by_name
class SliceNode(Node, AutoBlIdName):
def update(self) -> None:
self._update_multi_inputs()
self._update_multi_groups()
def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ...
@ -36,50 +36,71 @@ class SliceNode(Node, AutoBlIdName):
visited = getattr(self, "_current_visited", None)
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)
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)
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:
if not self.get('multi_sockets'): self['multi_sockets'] = {}
if not self['multi_sockets'].get(name): self['multi_sockets'][name] = {'bl_idname': bl_idname, 'name': name or get_label_by_name(bl_idname)}
self._update_multi_inputs()
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_groups'): self['multi_groups'] = {}
group_id = name or in_name or out_name or in_type
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:
cache = getattr(self, "_current_cache", 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]
def _update_multi_inputs(self):
if not self.get('multi_sockets'): 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 _update_multi_groups(self):
if not self.get('multi_groups'): return
for group_id, cfg in self['multi_groups'].items():
in_type = cfg['in_type']
out_type = cfg.get('out_type', '')
in_name = cfg['in_name']
out_name = cfg['out_name']
def get_ins(): return [i for i in self.inputs if i.identifier.startswith(f"{group_id}_ms_in_")]
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))
def inputs():
return [i for i in list(self.inputs) if i.name == ms['name']]
ins = get_ins()
if not ins:
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()
socket_type = ms['bl_idname']
name = ms['name']
if is_active(ins[-1]):
idx = len(ins)
self.add_input(in_type, in_name, identifier=f"{group_id}_ms_in_{idx}")
if out_type:
self.add_output(out_type, out_name, identifier=f"{group_id}_ms_out_{idx}")
ins = get_ins()
if len(inputs()) == 0:
self.add_input(socket_type, name, name+str(len(inputs())))
return
if is_active(inputs()[-1]):
self.add_input(socket_type, name, name+str(len(inputs())))
while True:
if len(inputs()) < 2: break
if not is_active(inputs()[-1]) and not is_active(inputs()[-2]):
self.inputs.remove(inputs()[-1])
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
break
@ -201,7 +222,9 @@ def _resolve_output(
visited = set() if visited is None else visited
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]
node_key = (socket.node.as_pointer(), socket.node.name)
@ -234,7 +257,13 @@ def _resolve_output(
return default
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
return value
finally: