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 from mathutils import Euler from .bpy_ns_types import NfStringProperty, NfEnumProperty, NfBoolProperty from ...registry import register_class from .. import slice from ..slice import Pather, slice_at_range, SlicePaths from ...infra.blender.blender_mesh_capture import SlicingCollection, SlicingObject, capture_mesh_edges, SlicingGroup from ...infra.blender.blender_bridge import get_all_children from ...infra.blender.blender_slice_nodes_generic import SliceNode, resolve_input from .bpy_ns_types import notify_tree_changed from ...infra._3mf_writer._3mf import prepare_3mf class SliceIntrinsics: bed_x: float bed_y: float feedrate: float extrusion_rate: float def __init__(self, bed_x=250.0, bed_y=250.0, feedrate=1200.0, extrusion_rate=0.017): self.bed_x = bed_x self.bed_y = bed_y self.feedrate = feedrate self.extrusion_rate = extrusion_rate # Basic Inputs class InputNode(SliceNode): input_type: str = "" def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if "bl_label" not in cls.__dict__: cls.bl_label = cls.input_type def init(self, context) -> None: self.add_output(f"SLICESOCK_{self.bl_label}") def evaluate_output(self, socket_name: str, **_kwargs): return self.outputs.get(socket_name).value @register_class class SLICENODE_InputString(InputNode): input_type = "String" @register_class class SLICENODE_InputFloat(InputNode): input_type = "Float" @register_class class SLICENODE_InputInt(InputNode): input_type = "Int" @register_class class SLICENODE_InputVectorRotation(InputNode): input_type = "VectorRotation" @register_class class SLICENODE_InputObject(InputNode): input_type = "Object" bl_icon = "OBJECT_DATA" @register_class class SLICENODE_InputOverrides(InputNode): input_type = "Overrides" bl_icon = "OBJECT_DATA" @register_class class SLICENODE_PrusaslicerConf(SliceNode): bl_label = "PrusaSlicer Config" bl_icon = "PROPERTIES" _cache = None @classmethod def get_cache(cls): if cls._cache is None: from ...infra.prusaslicer_profiles import LocalCache cls._cache = LocalCache() return cls._cache def ensure_profiles_loaded(self): cache = self.get_cache() if not cache.profiles: # Use a path relative to the addon directory import os addon_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) profiles_path = os.path.join(addon_dir, "profiles", "prusaslicer") if os.path.isdir(profiles_path): cache.load([profiles_path]) if self.printer_id != 'NONE': cache.evaluate_compatibility({self.printer_id}, set(cache.vendors)) def update_profiles(self, context): cache = self.get_cache() if self.printer_id != 'NONE': cache.evaluate_compatibility({self.printer_id}, set(cache.vendors)) notify_tree_changed(self, context) def get_printers(self, context): self.ensure_profiles_loaded() cache = self.get_cache() items = [('NONE', "None", "No printer selected", 'NONE', 0)] for i, (k, p) in enumerate(cache.printer_profiles.items(), 1): items.append((k, p.id, p.path.name, 'PRINTER', i)) return items def get_filaments(self, context): self.ensure_profiles_loaded() cache = self.get_cache() items = [('NONE', "None", "No filament selected", 'NONE', 0)] printer_id = self.printer_id if printer_id == 'NONE': return items printer = cache.printer_profiles.get(printer_id) if not printer: return items idx = 1 for k in printer.compatible_profiles: if k.startswith("filament:"): p = cache.profiles[k] items.append((k, p.id, p.path.name, 'FILAMENT', idx)) idx += 1 return items def get_prints(self, context): self.ensure_profiles_loaded() cache = self.get_cache() items = [('NONE', "None", "No print selected", 'NONE', 0)] printer_id = self.printer_id if printer_id == 'NONE': return items printer = cache.printer_profiles.get(printer_id) if not printer: return items idx = 1 for k in printer.compatible_profiles: if k.startswith("print:"): p = cache.profiles[k] items.append((k, p.id, p.path.name, 'PRINT', idx)) idx += 1 return items printer_id: NfStringProperty(default='NONE', update=update_profiles) # pyright: ignore[reportInvalidTypeForm] filament_id: NfStringProperty(default='NONE') # pyright: ignore[reportInvalidTypeForm] print_id: NfStringProperty(default='NONE') # pyright: ignore[reportInvalidTypeForm] def _get_enum_idx(self, items, value): for item in items: if item[0] == value: return item[4] return 0 def _set_enum_idx(self, items, index, prop_name): for item in items: if item[4] == index: setattr(self, prop_name, item[0]) break def get_printer_enum(self): return self._get_enum_idx(self.get_printers(None), self.printer_id) def set_printer_enum(self, value): self._set_enum_idx(self.get_printers(None), value, 'printer_id') def get_filament_enum(self): return self._get_enum_idx(self.get_filaments(None), self.filament_id) def set_filament_enum(self, value): self._set_enum_idx(self.get_filaments(None), value, 'filament_id') def get_print_enum(self): return self._get_enum_idx(self.get_prints(None), self.print_id) def set_print_enum(self, value): self._set_enum_idx(self.get_prints(None), value, 'print_id') printer_enum: NfEnumProperty( name="Printer", items=get_printers, get=get_printer_enum, set=set_printer_enum ) # pyright: ignore[reportInvalidTypeForm] filament_enum: NfEnumProperty( name="Filament", items=get_filaments, get=get_filament_enum, set=set_filament_enum ) # pyright: ignore[reportInvalidTypeForm] print_enum: NfEnumProperty( name="Print", items=get_prints, get=get_print_enum, set=set_print_enum ) # pyright: ignore[reportInvalidTypeForm] def init(self, context): self.add_output("SLICESOCK_PrusaslicerConf", "Config") def draw_buttons(self, context, layout): assert layout self.ensure_profiles_loaded() layout.prop(self, "printer_enum") if self.printer_id != 'NONE': layout.prop(self, "filament_enum") layout.prop(self, "print_enum") def evaluate_output(self, socket_name: str, **_kwargs) -> Any: self.ensure_profiles_loaded() if self.printer_id == 'NONE' or self.filament_id == 'NONE' or self.print_id == 'NONE': return None cache = self.get_cache() return { 'printer': self.printer_id, 'filament': [self.filament_id], 'print': self.print_id, 'cache': cache } @register_class class SLICENODE_InputPrinter(SliceNode): bl_label = "Printer" bl_icon = "OUTPUT" def update_label(self, context): self.label = self.printer_label notify_tree_changed(self, context) printer_label: NfStringProperty(name="Label", default="Printer", update=update_label) # pyright: ignore[reportInvalidTypeForm] host_type: NfEnumProperty( name="Type", items=[ ('PRUSALINK', "PrusaLink", "PrusaLink API"), ('CREALITY', "Creality", "Creality Cloud API"), ('MOONRAKER', "Moonraker", "Moonraker (Klipper) API"), ], default='PRUSALINK' ) # pyright: ignore[reportInvalidTypeForm] def init(self, context): self.add_output("SLICESOCK_Printer") self.add_input("SLICESOCK_String", "Host").value = "localhost" self.add_input("SLICESOCK_Int", "Port").value = 80 self.add_input("SLICESOCK_String", "Prefix") self.add_input("SLICESOCK_String", "Username") self.add_input("SLICESOCK_String", "Password") def draw_buttons(self, context, layout): layout.prop(self, "printer_label") # pyright: ignore[reportOptionalMemberAccess] layout.prop(self, "host_type") # pyright: ignore[reportOptionalMemberAccess] def evaluate_output(self, socket_name: str, **_kwargs) -> Any: from ...infra.networking.printer_backends import PrinterConfig return PrinterConfig( host=self.resolve("Host"), port=self.resolve("Port"), prefix=self.resolve("Prefix"), label=self.printer_label, username=self.resolve("Username"), password=self.resolve("Password"), host_type=self.host_type ) @register_class class SLICENODE_InputIntrinsics(SliceNode): bl_label = "Printer Intrinsics" def init(self, context): self.add_output("SLICESOCK_Intrinsics") self.add_input("SLICESOCK_Float", "Bed X").value = 250.0 self.add_input("SLICESOCK_Float", "Bed Y").value = 210.0 self.add_input("SLICESOCK_Float", "Feedrate").value = 1200.0 self.add_input("SLICESOCK_Float", "Extrusion Rate").value = 0.035 def evaluate_output(self, socket_name: str, **_kwargs) -> SliceIntrinsics: return SliceIntrinsics( bed_x=self.resolve("Bed X"), bed_y=self.resolve("Bed Y"), feedrate=self.resolve("Feedrate"), extrusion_rate=self.resolve("Extrusion Rate") ) # Math class MathNode(SliceNode): bl_label = "Float Math" operation: NfEnumProperty( name="Operation", items=[ ('ADD', "Add", "Add the two values"), ('SUBTRACT', "Subtract", "Subtract the second value from the first"), ('MULTIPLY', "Multiply", "Multiply the two values"), ('DIVIDE', "Divide", "Division"), ('ABS', "Absolute", "Absolute value of the first value"), ('NEGATE', "Negate", "Negate the first value"), ('POWER', "Power", "First value raised to the power of the second"), ('MIN', "Minimum", "The smaller of the two values"), ('MAX', "Maximum", "The larger of the two values"), ('SIGN', "Sign", "The sign of the first value (-1, 0, or 1)"), ('DIVIDE_ROUND', "Divide Round", "Division rounded to the nearest integer"), ('DIVIDE_FLOOR', "Divide Floor", "Division rounded down"), ('DIVIDE_TRUNCATE', "Divide Truncate", "Division rounded towards zero"), ('MODULO_FLOORED', "Floored Modulo", "Modulo using floor division"), ('MODULO_TRUNCATED', "Truncated Modulo", "Modulo using truncated division"), ], default='ADD' ) # pyright: ignore[reportInvalidTypeForm] math_type: TypeAlias = float socket_label: str = "Float" def init(self, context): self.add_output(f"SLICESOCK_{self.socket_label}Ro", "Value") self.add_input(f"SLICESOCK_{self.socket_label}", "Value") self.add_input(f"SLICESOCK_{self.socket_label}", "Value") def draw_buttons(self, context, layout): assert layout layout.prop(self, "operation", text="") def evaluate_output(self, socket_name: str, **_kwargs) -> math_type: t = self.math_type a: t = resolve_input(self.inputs[0]) b: t = resolve_input(self.inputs[1]) op = self.operation try: if op == 'ADD': return a + b if op == 'SUBTRACT': return a - b if op == 'MULTIPLY': return a * b if op == 'DIVIDE': return a // b if b != 0 else 0 if op == 'ABS': return abs(a) if op == 'NEGATE': return -a if op == 'POWER': return t(a ** b) if not (a == 0 and b < 0) else 0 if op == 'MIN': return min(a, b) if op == 'MAX': return max(a, b) if op == 'SIGN': return (a > 0) - (a < 0) if op == 'DIVIDE_ROUND': return t(round(a / b)) if b != 0 else 0 if op == 'DIVIDE_FLOOR': return a // b if b != 0 else 0 if op == 'DIVIDE_TRUNCATE': return t(a / b) if b != 0 else 0 if op == 'MODULO_FLOORED': return a % b if b != 0 else 0 if op == 'MODULO_TRUNCATED': return a - (b * t(a / b)) if b != 0 else 0 except Exception: return 0 return 0 @register_class class SLICENODE_FloatMath(MathNode): bl_label = "Math" math_type = float socket_label: str = "Float" @register_class class SLICENODE_IntMath(MathNode): bl_label = "Integer Math" math_type = int socket_label: str = "Int" @register_class class SLICENODE_GetMesh(SliceNode): bl_label = "Get Mesh" def init(self, context): self.add_input("SLICESOCK_Object") self.add_output("SLICESOCK_Mesh") def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | None: if socket_name != "Mesh": return None obj: Any = self.resolve("Object") if not obj: return None family = [obj] + get_all_children(obj) sc: SlicingCollection = SlicingCollection(family, obj.name) return sc @register_class class SLICENODE_GetPaths(SliceNode): bl_label = "Get Paths" def init(self, context): self.add_input("SLICESOCK_Object") self.add_output("SLICESOCK_Paths") def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: obj: Any = resolve_input(self.inputs.get("Object")) so: list[np.ndarray] = capture_mesh_edges(obj) return SlicePaths(so) @register_class class SLICENODE_SliceShells(SliceNode): bl_label = "Slice Shells" def init(self, context): self.add_input("SLICESOCK_Mesh") self.add_input("SLICESOCK_Float", "Layer Height").value = 0.2 self.add_output("SLICESOCK_Paths") def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: if socket_name != "Paths": return SlicePaths() slicing_collection: SlicingCollection | None = resolve_input(self.inputs.get("Mesh")) layer_height: float = resolve_input(self.inputs.get("Layer Height")) if not slicing_collection: return SlicePaths() merged_object = SlicingObject() coll: SlicingCollection = self.resolve("Mesh") if coll: for obj in coll.objects: merged_object.mesh = np.concatenate([merged_object.mesh, obj.mesh], axis=0) min = merged_object.mesh[:,0:3,2:3].min() min = floor(min / layer_height) * layer_height max = merged_object.mesh[:,0:3,2:3].max() z_values = np.arange(min, max + layer_height, layer_height) slices_group: list[list[np.ndarray]] | None = slice_at_range([merged_object.mesh], z_values) assert slices_group slices = slices_group[0] return SlicePaths(slices) @register_class class SLICENODE_BufferShells(SliceNode): bl_label = "Buffer Shells" shape_option: NfEnumProperty( name="Miter", items=[ ('round', "Round", ""), ('square', "Square", ""), ('flat', "Flat", "") ] ) # pyright: ignore[reportInvalidTypeForm] def init(self, context): self.add_input("SLICESOCK_Paths") self.add_input("SLICESOCK_Float", "Distance").value = 0 self.add_input("SLICESOCK_VectorRotation", "Rotation") self.add_output("SLICESOCK_Paths") def draw_buttons(self, context, layout): assert layout layout.prop(self, "shape_option") def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: if socket_name != "Paths": return SlicePaths() paths: SlicePaths = resolve_input(self.inputs.get("Paths")) distance: float = resolve_input(self.inputs.get("Distance")) or 0.0 rotation: Any = resolve_input(self.inputs.get("Rotation")) or (0.0, 0.0, 0.0) if not paths: return SlicePaths() if any(rotation): # De-rotate shells to Z-perpendicular plane mat = Euler(rotation).to_matrix().inverted() mat_np = np.array(mat) prepared_paths = [s @ mat_np.T for s in paths] else: prepared_paths = paths # Buffer res_paths, res_groups = slice.buffer_slice(prepared_paths, distance, miter=self.shape_option) if any(rotation): # Re-rotate shells back mat_inv = Euler(rotation).to_matrix() mat_inv_np = np.array(mat_inv) final_paths = [s @ mat_inv_np.T for s in res_paths] else: final_paths = res_paths return SlicePaths(final_paths, res_groups) @register_class class SLICENODE_BufferShellsMultiple(SliceNode): bl_label = "Buffer Shells Multiple" shape_option: NfEnumProperty( name="Miter", items=[ ('round', "Round", ""), ('square', "Square", ""), ('flat', "Flat", "") ] ) # pyright: ignore[reportInvalidTypeForm] def init(self, context): self.add_input("SLICESOCK_Paths") self.add_input("SLICESOCK_Float", "Distance").value = 0 self.add_input("SLICESOCK_Float", "Width Step").value = 0.4 self.add_input("SLICESOCK_Int", "Count").value = 2 self.add_input("SLICESOCK_VectorRotation", "Rotation") self.add_output("SLICESOCK_Paths") def draw_buttons(self, context, layout): assert layout layout.prop(self, "shape_option") def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: if socket_name != "Paths": return SlicePaths() paths: SlicePaths = resolve_input(self.inputs.get("Paths")) base_distance: float = resolve_input(self.inputs.get("Distance")) or 0.0 width_step: float = resolve_input(self.inputs.get("Width Step")) or 0.0 count: int = resolve_input(self.inputs.get("Count")) or 1 rotation: Any = resolve_input(self.inputs.get("Rotation")) or (0.0, 0.0, 0.0) if not paths: return SlicePaths() if any(rotation): mat = Euler(rotation).to_matrix().inverted() mat_np = np.array(mat) prepared_paths = [s @ mat_np.T for s in paths] else: prepared_paths = paths all_res_paths = [] all_res_groups = [] for i in range(count): dist = base_distance + (i * width_step) res_paths, res_groups = slice.buffer_slice(prepared_paths, dist, miter=self.shape_option) all_res_paths.extend(res_paths) all_res_groups.extend(res_groups) if any(rotation): mat_inv = Euler(rotation).to_matrix() mat_inv_np = np.array(mat_inv) final_paths = [s @ mat_inv_np.T for s in all_res_paths] else: final_paths = all_res_paths return SlicePaths(final_paths, all_res_groups) @register_class class SLICENODE_Infill(SliceNode): bl_label = "Infill" infill_type: NfEnumProperty( name="Type", items=[ ('GRID', "Grid", "Cross-hatch grid"), ('RECTILINEAR', "Rectilinear", "Alternating parallel lines"), ], default='GRID', update=notify_tree_changed ) # pyright: ignore[reportInvalidTypeForm] def init(self, context): self.add_input("SLICESOCK_Paths") self.add_input("SLICESOCK_Float", "Density").value = 2.0 self.add_input("SLICESOCK_Float", "Infill Rotation").value = 0.0 self.add_input("SLICESOCK_VectorRotation", "Layer Rotation") self.add_output("SLICESOCK_Paths") def draw_buttons(self, context, layout): assert layout layout.prop(self, "infill_type", text="") def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: if socket_name != "Paths": return SlicePaths() paths: SlicePaths = resolve_input(self.inputs.get("Paths")) density: float = resolve_input(self.inputs.get("Density")) or 2.0 infill_rotation: float = resolve_input(self.inputs.get("Infill Rotation")) or 0.0 rotation: Any = resolve_input(self.inputs.get("Layer Rotation")) or (0.0, 0.0, 0.0) if not paths: return SlicePaths() p: SlicePaths if any(rotation): # De-rotate shells to Z-perpendicular plane mat = Euler(rotation).to_matrix().inverted() mat_np = np.array(mat) p = SlicePaths([s @ mat_np.T for s in paths], paths.groups) else: p = paths res_paths, res_groups = slice.infill_slice( p, density, rotation_deg=infill_rotation, infill_type=self.infill_type # pyright: ignore[reportOptionalMemberAccess] ) if any(rotation): # Re-rotate shells back mat_inv = Euler(rotation).to_matrix() mat_inv_np = np.array(mat_inv) p = SlicePaths([s @ mat_inv_np.T for s in res_paths], res_groups) else: p = SlicePaths(res_paths, res_groups) return p @register_class class SLICENODE_PathsToGcode(SliceNode): bl_label = "Paths to Gcode" use_header: NfBoolProperty(name="Header", default=True) # pyright: ignore[reportInvalidTypeForm] def init(self, context): self.add_input("SLICESOCK_Paths") self.add_input("SLICESOCK_Intrinsics") self.add_output("SLICESOCK_GcodeRo") def draw_buttons(self, context, layout): assert layout layout.prop(self, "use_header") def evaluate_output(self, socket_name: str, **_kwargs) -> str: paths: list[np.ndarray] = resolve_input(self.inputs.get("Paths")) intrinsics: SliceIntrinsics | None = resolve_input(self.inputs.get("Intrinsics")) pather: Pather = Pather(paths) if intrinsics: # Note: Pather currently uses bed_center_x/y as its internal attributes. # We map the bed size center to these attributes if provided. pather.bed_center_x = intrinsics.bed_x / 2.0 pather.bed_center_y = intrinsics.bed_y / 2.0 pather.feedrate = intrinsics.feedrate pather.extrusion_rate = intrinsics.extrusion_rate gcode: str = pather.to_gcode(include_header=self.use_header) return gcode @register_class class SLICENODE_GcodeBlock(SliceNode): bl_label = "Gcode Block" def init(self, context): self.add_output("SLICESOCK_Gcode", "Gcode") def evaluate_output(self, socket_name: str, **_kwargs) -> str: return self.outputs['Gcode'].value @register_class class SLICENODE_GcodeHelper(SliceNode): bl_label = "Gcode Helper" command: NfEnumProperty( name="Command", items=[ ('G0', "G0: Rapid Move", "Fast movement without extrusion"), ('G1', "G1: Linear Move", "Movement with extrusion"), ('G4', "G4: Dwell", "Wait for time (e.g. P500 for 500ms or S5 for 5s)"), ('G28', "G28: Home All", "Home all axes"), ('G90', "G90: Absolute Pos", "Set to absolute positioning"), ('G91', "G91: Relative Pos", "Set to relative positioning"), ('G92', "G92: Set Position", "Set the current position to specific values (e.g. G92 E0)"), ('M0', "M0: Stop/Wait", "Stop and wait for user button press"), ('M25', "M25: Pause SD", "Pause SD print (Marlin/RepRap)"), ('M104', "M104: Set Temp", "Set hotend temperature"), ('M109', "M109: Wait Temp", "Set hotend temperature and wait"), ('M140', "M140: Set Bed Temp", "Set bed temperature"), ('M190', "M190: Wait Bed Temp", "Set bed temperature and wait"), ('M106', "M106: Fan On", "Turn on cooling fan"), ('M107', "M107: Fan Off", "Turn off cooling fan"), ('M117', "M117: Message", "Display message on printer LCD"), ('M220', "M220: Speed Factor", "Set speed percentage (e.g. S110 for 110%)"), ('M221', "M221: Flow Factor", "Set flow percentage (e.g. S95 for 95%)"), ('M300', "M300: Beep", "Play beep sound"), ('M400', "M400: Wait Finish", "Wait for all current moves to finish"), ('M600', "M600: Color Change", "Filament change / Color change"), ('M82', "M82: Abs Extrusion", "Set extrusion to absolute mode"), ('M83', "M83: Rel Extrusion", "Set extrusion to relative mode"), ('PAUSE', "PAUSE: Klipper Pause", "Pause print (Klipper macro)"), ('T', "T: Change Extruder", "Switch to a different extruder (e.g. T0, T1)"), ], default='G1' ) # pyright: ignore[reportInvalidTypeForm] parameters: NfStringProperty(name="Params", default="") # pyright: ignore[reportInvalidTypeForm] def init(self, context): self.add_output("SLICESOCK_GcodeRo") def draw_buttons(self, context, layout): assert layout layout.prop(self, "command", text="") # Commands that don't need parameters no_params = {'G28', 'G90', 'G91', 'M0', 'M25', 'M107', 'M400', 'M600', 'M82', 'M83', 'PAUSE'} if self.command not in no_params: layout.prop(self, "parameters", text="Parameters") def evaluate_output(self, socket_name: str, **_kwargs) -> str: cmd = self.command params = self.parameters.strip() if params: # T commands usually don't have a space (e.g. T0) if cmd == 'T': return f"{cmd}{params}" return f"{cmd} {params}" return cmd ### Joiners class SliceNodeJoiner(SliceNode): socket_type: str = "" output_socket_type: str = "" def init(self, context): self.add_output(self.output_socket_type or self.socket_type) def update(self) -> None: def is_active(input) -> bool: a: bool = input.is_linked or getattr(input, 'value', False) b: bool = getattr(self.inputs[-1], 'value', False) return a or b if len(self.inputs) == 0: self.add_input(self.socket_type) return if is_active(self.inputs[-1]): self.add_input(self.socket_type) while True: if len(self.inputs) < 2: break if not is_active(self.inputs[-1]) and not is_active(self.inputs[-2]): self.inputs.remove(self.inputs[-2]) continue break @register_class class SLICENODE_JoinGcode(SliceNodeJoiner): bl_label = "Join Gcode" socket_type = "SLICESOCK_Gcode" output_socket_type = "SLICESOCK_GcodeRo" def evaluate_output(self, socket_name: str, **_kwargs) -> str: gcode_input: list[str] = [] for input in self.inputs: gcode_input += [resolve_input(input)] or [""] return "\n".join(gcode_input) @register_class class SLICENODE_JoinOverrides(SliceNodeJoiner): bl_label = "Join Overrides" socket_type = "SLICESOCK_Overrides" output_socket_type = "SLICESOCK_OverridesRo" def evaluate_output(self, socket_name: str, **_kwargs) -> dict[str, str]: merged: dict[str, str] = {} for input in self.inputs: val = resolve_input(input) if isinstance(val, dict): merged.update(val) return merged @register_class class SLICENODE_JoinSlices(SliceNodeJoiner): bl_label = "Join Slices" socket_type = "SLICESOCK_Paths" operation: NfEnumProperty( name="Operation", items=[ ('UNION', "Union", "Union of the slices"), ('INTERSECTION', "Intersection", "Intersection of the slices"), ('DIFFERENCE', "Subtract", "Clipping of the first slice"), ('XOR', "Exclusive or", "Exclusive or of the slices"), ], default='UNION' ) # pyright: ignore[reportInvalidTypeForm] def draw_buttons(self, context, layout): assert layout layout.prop(self, "operation", text="") def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: accumulated: SlicePaths = SlicePaths() current: SlicePaths = SlicePaths() for input in self.inputs: current = resolve_input(input) if not current: continue if not accumulated: accumulated = current current = SlicePaths() continue # Boolean operation loses specific per-segment grouping as it merges polygons. # We return a new SlicePaths from the result. res = slice.boolean_slice(accumulated, current, self.operation) accumulated = SlicePaths(res) current = SlicePaths() return accumulated @register_class class SLICENODE_JoinMesh(SliceNodeJoiner): bl_label = "Join Mesh Parts" socket_type = "SLICESOCK_Mesh" def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection: coll: SlicingCollection merged_coll = resolve_input(self.inputs[0]) for input in self.inputs[1:]: coll = resolve_input(input) if coll: merged_coll.objects += coll.objects return merged_coll @register_class class SLICENODE_JoinMeshGroups(SliceNodeJoiner): bl_label = "Join Mesh Groups" socket_type = "SLICESOCK_Mesh" def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingGroup: group: SlicingGroup = SlicingGroup([]) for input in self.inputs: coll = resolve_input(input) if coll: group.collections[coll.objects[0].name] = coll return group @register_class class SLICENODE_JoinPaths(SliceNodeJoiner): bl_label = "Join Paths" socket_type = "SLICESOCK_Paths" mode: NfEnumProperty( name="Mode", items=[ ('EXTEND', "Extend", "Append paths sequentially"), ('ZIP', "Zip", "Interleave paths from all inputs"), ], default='EXTEND' ) # pyright: ignore[reportInvalidTypeForm] def draw_buttons(self, context, layout): assert layout layout.prop(self, "mode", text="") def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: inputs_data: list[SlicePaths] = [] for input in self.inputs: paths = resolve_input(input) if paths: inputs_data.append(paths) if not inputs_data: return SlicePaths() if self.mode == 'EXTEND': merged = SlicePaths() for paths in inputs_data: merged.extend_with_groups(paths) return merged elif self.mode == 'ZIP': # Group-based Zip: sequentially add each group from all inputs. # E.g. Group 1 (In1), Group 1 (In2), Group 2 (In1), Group 2 (In2)... all_groups = set() grouped_inputs = [] for p_list in inputs_data: d = {} for i, g in enumerate(p_list.groups): all_groups.add(g) d.setdefault(g, []).append(p_list[i]) grouped_inputs.append(d) sorted_groups = sorted(list(all_groups)) merged = SlicePaths() for g in sorted_groups: for d in grouped_inputs: group_paths = d.get(g, []) for p in group_paths: merged.append_with_group(p, g) return merged return SlicePaths() @register_class class SLICENODE_JoinExecution(SliceNodeJoiner): bl_label = "Join Execution" socket_type = "SLICESOCK_ExecutionLink" def evaluate_output(self, socket_name: str, **_kwargs) -> list[ExecSliceNode]: nodes: list[ExecSliceNode] = [] for input in self.inputs: res = resolve_input(input) if isinstance(res, ExecSliceNode): nodes.append(res) elif isinstance(res, list): nodes.extend(res) return nodes ### I/O @register_class class SLICENODE_LoadGcode(SliceNode): bl_label = "Load Gcode File" def init(self, context): self.add_input("SLICESOCK_FilePath", "Filepath") self.add_output("SLICESOCK_GcodeRo") def evaluate_output(self, socket_name: str, **_kwargs) -> str: path = resolve_input(self.inputs.get("Filepath")) if not path: return "" abs_path = bpy.path.abspath(path) if os.path.exists(abs_path): with open(abs_path, 'r', encoding='utf-8') as f: return f.read() return "" class ExecSliceNode(SliceNode): button_label: str = 'Execute' def _init_exec(self): self.add_output("SLICESOCK_ExecutionLink") def draw_buttons(self, context, layout): assert layout layout.operator("slicenode.execute", text=self.button_label).node_name = self.name def evaluate_output(self, socket_name: str, **_kwargs) -> Any: if socket_name == "Execution Link": return self return None @register_class class SLICENODE_PrinterList(SliceNode): bl_label = "Printer List" def _refresh(self): import json if not self.printers_json_path: return abs_path = bpy.path.abspath(self.printers_json_path) if not os.path.exists(abs_path): return try: with open(abs_path, 'r', encoding='utf-8') as f: data = json.load(f) except Exception: return printers = data.get("printers", []) printer_names = set() for p_data in printers: name = p_data.get("name", "Unknown") printer_names.add(name) sock = self.outputs.get(name) if not sock: sock = self.add_output("SLICESOCK_Printer", name) # Update properties sock.label = name sock.host = str(p_data.get("ip", "localhost")) sock.port = int(p_data.get("port", 80)) sock.prefix = str(p_data.get("prefix", "")) sock.username = str(p_data.get("username", "")) sock.password = str(p_data.get("password", "")) sock.host_type = str(p_data.get("host_type", "PRUSALINK")).upper() # Remove stale sockets for sock in list(self.outputs): if sock.name not in printer_names: self.outputs.remove(sock) def update_config(self, context): self._refresh() printers_json_path: NfStringProperty(name="Config File", default="", subtype='FILE_PATH', update=update_config) # pyright: ignore[reportInvalidTypeForm] def init(self, context): pass def draw_buttons(self, context, layout): layout.prop(self, "printers_json_path") # pyright: ignore[reportOptionalMemberAccess] def evaluate_output(self, socket_name: str, **_kwargs) -> Any: self._refresh() sock = self.outputs.get(socket_name) if sock: return sock.value return None @register_class class SLICENODE_GcodeToFile(ExecSliceNode): bl_label = "Gcode To File" button_label = "Generate File" def init(self, context): self._init_exec() self.add_input("SLICESOCK_Gcode") self.add_input("SLICESOCK_FilePath", "Filename") def evaluate(self, operator=None): gcode: str = resolve_input(self.inputs.get("Gcode")) filename: str = resolve_input(self.inputs.get("Filename")) filepath = bpy.path.abspath(filename) os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True) with open(filepath, "w", encoding="utf-8", newline="\n") as f: f.write(gcode) return 'ok' @register_class class SLICENODE_GcodeToTextBlock(ExecSliceNode): bl_label = "Gcode to Text Block" button_label = "Update Text Block" text_block_name: NfStringProperty(name="Text Block Name", default="NodeSlicer_Gcode.gcode") # pyright: ignore[reportInvalidTypeForm] def init(self, context): self._init_exec() self.add_input("SLICESOCK_Gcode") def draw_buttons(self, context, layout): super().draw_buttons(context, layout) layout.prop(self, "text_block_name") def evaluate(self, operator=None): gcode: str = resolve_input(self.inputs.get("Gcode")) if not gcode: return 'ok' name = self.text_block_name text_block = bpy.data.texts.get(name) if text_block is None: text_block = bpy.data.texts.new(name=name) text_block.from_string(gcode) # Attempt to show in an open text editor assert bpy.context.window_manager for window in bpy.context.window_manager.windows: for area in window.screen.areas: if area.type == 'TEXT_EDITOR': for space in area.spaces: if space.type == 'TEXT_EDITOR': space.text = text_block return 'ok' @register_class class SLICENODE_StartPrint(ExecSliceNode): bl_label = "Start Print" button_label = "Send to Printer" def init(self, context): self._init_exec() self.add_input("SLICESOCK_Gcode") self.add_input("SLICESOCK_Printer") def evaluate(self, operator=None): from ...infra.networking.printer_backends import ( PrinterConfig, PrusaLinkBackend, CrealityBackend, MoonrakerBackend ) import tempfile from pathlib import Path gcode: str = resolve_input(self.inputs.get("Gcode")) config: PrinterConfig | None = resolve_input(self.inputs.get("Printer")) if not gcode: if operator: operator.report({'ERROR'}, "No Gcode to send") return 'no gcode' if not config: if operator: operator.report({'ERROR'}, "No Printer configuration") return 'no printer' # Instantiate correct backend backend = None if config.host_type == 'PRUSALINK': backend = PrusaLinkBackend(config.host, config.port, config.prefix, api_key=config.password) elif config.host_type == 'CREALITY': backend = CrealityBackend(config.host, config.port, config.prefix) elif config.host_type == 'MOONRAKER': backend = MoonrakerBackend(config.host, config.port, config.prefix) if not backend: if operator: operator.report({'ERROR'}, f"Unsupported host type: {config.host_type}") return 'unsupported' # Write to temp file to send with tempfile.NamedTemporaryFile(mode='w', suffix='.gcode', delete=False) as tf: tf.write(gcode) temp_path = Path(tf.name) try: print_name = f"NodeSlicer_{config.label}.gcode".replace(" ", "_") backend.start_print(temp_path, print_name) if operator: operator.report({'INFO'}, f"Print started on {config.label}") except Exception as e: if operator: operator.report({'ERROR'}, f"Failed to start print: {str(e)}") return 'error' finally: if temp_path.exists(): temp_path.unlink() return 'ok' @register_class class SLICENODE_PrusaslicerSlice(ExecSliceNode): bl_label = "PrusaSlicer Slice" button_label = "Slice with PrusaSlicer" 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._init_exec() self.add_input("SLICESOCK_Mesh", "Mesh") self.add_input("SLICESOCK_PrusaslicerConf", "Config") self.add_input("SLICESOCK_Overrides", "Overrides") self.add_output("SLICESOCK_GcodeRo") 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): self.gcode_result = "" mesh_data: SlicingCollection | SlicingGroup = resolve_input(self.inputs.get("Mesh")) conf_data = resolve_input(self.inputs.get("Config")) overrides = resolve_input(self.inputs.get("Overrides")) 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' # Extract data from config dict printer_id = conf_data['printer'] filament_ids = conf_data['filament'] print_id = conf_data['print'] cache = conf_data['cache'] # Generate the SlicingGroup if isinstance(mesh_data, SlicingGroup): slicing_group = mesh_data 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: points = [] for p in bed_shape_str.split(','): x, y = map(float, p.split('x')) points.append((x, y)) points_np = np.array(points) return (points_np.min(axis=0) + points_np.max(axis=0)) / 2.0 except: return np.array([125.0, 105.0]) 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()} writer_overrides['binary_gcode'] = {'value': '0'} # Generate the ConfigWriter conf_writer = cache.generate_conf_writer( printer_id, filament_ids, print_id, overrides=writer_overrides ) # Prepare 3mf path temp_dir = Path(tempfile.gettempdir()) output_path = temp_dir / f"tmp.3mf" try: 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' # 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]}") self.gcode_result = "" 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}") self.gcode_result = "" return 'error' # Read G-code if gcode_path.exists(): with open(gcode_path, 'r', encoding='utf-8') as f: self.gcode_result = f.read() output_path.unlink() 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") self.gcode_result = "" return 'error' 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' @register_class class SLICENODE_GcodeToMesh(ExecSliceNode): bl_label = "Gcode Preview" button_label = "Update Preview" show_e_0: NfBoolProperty(name="Show Zero Extrusions", default=False) # pyright: ignore[reportInvalidTypeForm] def init(self, context): self._init_exec() self.add_input("SLICESOCK_Gcode", "Gcode") self.add_input("SLICESOCK_String", "Name") def draw_buttons(self, context, layout): super().draw_buttons(context, layout) layout.prop(self, 'show_e_0') def evaluate(self, operator=None): gcode: str = self.resolve("Gcode") mesh_name: str = self.resolve("Name") if not gcode: if mesh_name in bpy.data.objects: obj = bpy.data.objects[mesh_name] if obj.type == 'MESH': obj.data.clear_geometry() return 'empty' segments = [] last_x, last_y, last_z = 0.0, 0.0, 0.0 last_e = 0.0 offset_x, offset_y = 0.0, 0.0 for line in gcode.splitlines(): # Parse metadata from comments if ';' in line: comment = line.split(';', 1)[1].strip() if comment.startswith("OFFSET_X="): try: offset_x = float(comment.split('=')[1]) except ValueError: pass elif comment.startswith("OFFSET_Y="): try: offset_y = float(comment.split('=')[1]) except ValueError: pass line_no_comment = line.split(';')[0].strip() if not line_no_comment: continue parts = line_no_comment.split() cmd = parts[0].upper() params = {} for p in parts[1:]: if len(p) < 2: continue try: # Scale coordinates by 0.001 (mm to Blender units) val = float(p[1:]) if p[0].upper() in 'XYZ': val *= 0.001 params[p[0].upper()] = val except ValueError: continue new_x = params.get('X', last_x) new_y = params.get('Y', last_y) new_z = params.get('Z', last_z) new_e = params.get('E', last_e) show = False if self.show_e_0: show = True if cmd == 'G1' and new_e > 0: show = True if show: # Subtract offsets (scaled to Blender units) to return to original space ox, oy = offset_x * 0.001, offset_y * 0.001 segments.append(( (last_x - ox, last_y - oy, last_z), (new_x - ox, new_y - oy, new_z) )) last_x, last_y, last_z = new_x, new_y, new_z last_e = new_e if not segments: return 'no segments' # Create or find "Debug" mesh object if mesh_name in bpy.data.objects: obj = bpy.data.objects[mesh_name] mesh = obj.data mesh.clear_geometry() else: mesh = bpy.data.meshes.new(mesh_name) obj = bpy.data.objects.new(mesh_name, mesh) assert bpy.context.collection bpy.context.collection.objects.link(obj) vertices = [] edges = [] for i, (p1, p2) in enumerate(segments): vertices.extend([p1, p2]) edges.append((2*i, 2*i + 1)) mesh.from_pydata(vertices, edges, []) mesh.update() # Assign Geometry Nodes nodegroup for preview ng_name = "Nonplanar Slicing Preview" if ng_name in bpy.data.node_groups: ng = bpy.data.node_groups[ng_name] # Check if already assigned has_preview = False for mod in obj.modifiers: if mod.type == 'NODES' and mod.node_group == ng: # type: ignore has_preview = True break if not has_preview: mod = obj.modifiers.new(name="Preview", type='NODES') # type: ignore mod.node_group = ng return 'ok' @register_class class SLICENODE_Executor(ExecSliceNode, SliceNodeJoiner): bl_label = "Executor" button_label = "Execute All" socket_type = "SLICESOCK_ExecutionLink" auto_refresh: NfBoolProperty(name="Auto-refresh", default=False) # pyright: ignore[reportInvalidTypeForm] def init(self, context): self._init_exec() def draw_buttons(self, context, layout): super().draw_buttons(context, layout) layout.prop(self, "auto_refresh") def evaluate(self, operator=None): for i, job in enumerate(self.inputs): # Resolve the input to get the connected node(s) res = resolve_input(job) if res is None: continue # Ensure we have a list of jobs to process (JoinExecution returns a list) jobs_to_run = res if isinstance(res, list) else [res] for upstream_node in jobs_to_run: if not hasattr(upstream_node, "evaluate"): continue if operator: operator.report({'INFO'}, f"Executing: {upstream_node.bl_label}") try: exec_res = upstream_node.evaluate(operator=operator) if exec_res != 'ok' and exec_res is not None: if operator: operator.report({'WARNING'}, f"Node {upstream_node.bl_label} returned {exec_res}") except Exception as e: if operator: operator.report({'ERROR'}, f"Failed executing {upstream_node.bl_label}: {str(e)}") return 'error' return 'ok' @register_class class SLICENODE_Test(ExecSliceNode): bl_label = "Test" button_label = "Test" def init(self, context): self._init_exec() self.add_input("SLICESOCK_Gcode") def evaluate(self, operator=None): _ = resolve_input(self.inputs.get("Gcode")) return 'ok'