diff --git a/core/nodeslicer/nodes.py b/core/nodeslicer/nodes.py index ee4c708..521b470 100644 --- a/core/nodeslicer/nodes.py +++ b/core/nodeslicer/nodes.py @@ -1,6 +1,11 @@ from __future__ import annotations import os +import subprocess +import tempfile +import shlex +import shutil from math import floor +from pathlib import Path from typing import Any, TypeAlias import bpy import numpy as np @@ -10,8 +15,9 @@ from bpy.props import StringProperty, FloatProperty, IntProperty, EnumProperty, from ...registry import register_class from .. import slice from ..slice import Pather, slice_at_range, SlicePaths -from ...infra.blender_mesh_capture import SlicingObject, capture_mesh_edges +from ...infra.blender_mesh_capture import SlicingObject, capture_mesh_edges, SlicingGroup from ...infra.blender_slice_nodes_generic import SliceNode, resolve_input +from ...infra._3mf_writer._3mf import prepare_3mf class SliceIntrinsics: bed_x: float @@ -1045,19 +1051,27 @@ class SLICENODE_StartPrint(ExecSliceNode): @register_class class SLICENODE_PrusaslicerSlice(ExecSliceNode): bl_label = "PrusaSlicer Slice" - button_label = "Generate 3MF" + button_label = "Slice with PrusaSlicer" + + prusaslicer_executable: StringProperty(name="PrusaSlicer Path", subtype='FILE_PATH') # pyright: ignore[reportInvalidTypeForm] + gcode_result: StringProperty(name="Gcode Result", default="") # pyright: ignore[reportInvalidTypeForm] def init(self, context): self._init_exec() self.add_input("SLICESOCK_Object", "Object") self.add_input("SLICESOCK_PrusaslicerConf", "Config") + self.add_output("SLICESOCK_Gcode") + + def draw_buttons(self, context, layout): + super().draw_buttons(context, layout) + layout.prop(self, "prusaslicer_executable") + + def evaluate_output(self, socket_name: str, **kwargs) -> Any: + if socket_name == "Gcode": + return self.gcode_result + return super().evaluate_output(socket_name, **kwargs) def evaluate(self, operator=None): - from ...infra.blender_mesh_capture import SlicingGroup - from ...infra._3mf_writer._3mf import prepare_3mf - import tempfile - from pathlib import Path - obj = resolve_input(self.inputs.get("Object")) conf_data = resolve_input(self.inputs.get("Config")) @@ -1096,20 +1110,68 @@ class SLICENODE_PrusaslicerSlice(ExecSliceNode): 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) - # Generate the ConfigWriter with no overrides - conf_writer = cache.generate_conf_writer(printer_id, filament_ids, print_id, overrides={}) + # Generate the ConfigWriter, forcing binary_gcode to 0 to ensure readable ASCII output + conf_writer = cache.generate_conf_writer( + printer_id, + filament_ids, + print_id, + overrides={'binary_gcode': {'value': '0'}} + ) - # Prepare 3MF path + # Prepare 3mf path temp_dir = Path(tempfile.gettempdir()) output_path = temp_dir / f"{obj.name}_NodeSlicer.3mf" try: prepare_3mf(output_path, slicing_group, conf_writer, z_gcodes=[]) - if operator: - operator.report({'INFO'}, f"3MF generated at {output_path}") + + # Actual slicing + exe_str = self.prusaslicer_executable + if not exe_str: + 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) + if not cmd_parts: + 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]): + 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]}") + return 'error' + + 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}") + return 'error' + + # Read G-code + if gcode_path.exists(): + with open(gcode_path, 'r', encoding='utf-8') as f: + self.gcode_result = f.read() + + if operator: + operator.report({'INFO'}, f"Slicing complete. G-code generated.") + else: + if operator: operator.report({'ERROR'}, "G-code file was not generated by PrusaSlicer") + return 'error' except Exception as e: - if operator: operator.report({'ERROR'}, f"Failed to generate 3MF: {str(e)}") + if operator: operator.report({'ERROR'}, f"Failed to slice: {str(e)}") import traceback traceback.print_exc() return 'error' diff --git a/doc/NODES.md b/doc/NODES.md index fa900cc..7110b21 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -71,6 +71,11 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Header**: Toggles the generation of standard NodeSlicer G-code headers and units setup. - **Load Gcode**: Reads a G-code file from disk. - **Gcode to File**: Writes G-code string to a file on disk. +- **PrusaSlicer Slice**: Exports the current mesh and configuration to a 3MF file and calls PrusaSlicer to generate G-code. + - **Executable**: Path to the PrusaSlicer executable. + - **Object**: The Blender object to slice. + - **Config**: PrusaSlicer configuration (from the **PrusaSlicer Config** node). + - **Gcode**: Output socket providing the generated G-code string. - **Gcode to Text Block**: Creates a new Blender text block containing the G-code and opens it in any active text editors. - **Gcode to Mesh**: Decodes G-code and creates a Blender mesh ("Debug" object) for visualization. - **Test**: Triggers evaluation for debugging purposes.