From 5aad206c48054b9f019c4d959ef4e26807a99afe Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Wed, 27 May 2026 17:16:23 +0300 Subject: [PATCH] Refactor multi-socket group system and fix G-code generation metadata synchronization --- core/nodeslicer/nodes.py | 113 ++++++++----------- core/nodeslicer/sockets.py | 1 - core/slice.py | 65 +++++++++-- infra/blender/blender_mesh_capture.py | 10 +- infra/blender/blender_slice_nodes_generic.py | 97 ++++++++++------ 5 files changed, 168 insertions(+), 118 deletions(-) diff --git a/core/nodeslicer/nodes.py b/core/nodeslicer/nodes.py index f8c5faa..b19a31f 100644 --- a/core/nodeslicer/nodes.py +++ b/core/nodeslicer/nodes.py @@ -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: --export-gcode <3mf_path> --output 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) diff --git a/core/nodeslicer/sockets.py b/core/nodeslicer/sockets.py index 3dc531d..c0d12d0 100644 --- a/core/nodeslicer/sockets.py +++ b/core/nodeslicer/sockets.py @@ -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 diff --git a/core/slice.py b/core/slice.py index f9909ba..e7e4d22 100644 --- a/core/slice.py +++ b/core/slice.py @@ -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 diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index 27b4023..cc89608 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -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 ) \ No newline at end of file diff --git a/infra/blender/blender_slice_nodes_generic.py b/infra/blender/blender_slice_nodes_generic.py index c49fde3..4040a7d 100644 --- a/infra/blender/blender_slice_nodes_generic.py +++ b/infra/blender/blender_slice_nodes_generic.py @@ -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: