From ac788ef43238aeddc3ddcd25217e63df601956fd Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Fri, 29 May 2026 12:53:55 +0300 Subject: [PATCH] ref --- core/nodeslicer/nodes.py | 1774 ------------------------- core/nodeslicer/nodes/__init__.py | 9 + core/nodeslicer/nodes/base.py | 126 ++ core/nodeslicer/nodes/gcode.py | 196 +++ core/nodeslicer/nodes/inputs.py | 192 +++ core/nodeslicer/nodes/math.py | 15 + core/nodeslicer/nodes/mesh.py | 65 + core/nodeslicer/nodes/output.py | 350 +++++ core/nodeslicer/nodes/path.py | 323 +++++ core/nodeslicer/nodes/preview.py | 182 +++ core/nodeslicer/nodes/system.py | 377 ++++++ infra/blender/blender_mesh_capture.py | 3 +- 12 files changed, 1836 insertions(+), 1776 deletions(-) delete mode 100644 core/nodeslicer/nodes.py create mode 100644 core/nodeslicer/nodes/__init__.py create mode 100644 core/nodeslicer/nodes/base.py create mode 100644 core/nodeslicer/nodes/gcode.py create mode 100644 core/nodeslicer/nodes/inputs.py create mode 100644 core/nodeslicer/nodes/math.py create mode 100644 core/nodeslicer/nodes/mesh.py create mode 100644 core/nodeslicer/nodes/output.py create mode 100644 core/nodeslicer/nodes/path.py create mode 100644 core/nodeslicer/nodes/preview.py create mode 100644 core/nodeslicer/nodes/system.py diff --git a/core/nodeslicer/nodes.py b/core/nodeslicer/nodes.py deleted file mode 100644 index 8ff5204..0000000 --- a/core/nodeslicer/nodes.py +++ /dev/null @@ -1,1774 +0,0 @@ -from __future__ import annotations -import os -import subprocess -import tempfile -import shlex -import shutil -from pathlib import Path -from typing import Any, TypeAlias -import bpy -import numpy as np -from mathutils import Euler - -from .bpy_ns_types import NfIntProperty, NfStringProperty, NfEnumProperty, NfBoolProperty, notify_tree_changed - -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 ...infra.prusaslicer_profiles.cache import LocalCache -from ...infra.prusaslicer_profiles.models import Profile - -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_InputVector(InputNode): - input_type = "Vector" - -@register_class -class SLICENODE_InputObject(InputNode): - input_type = "Object" - bl_icon = "OBJECT_DATA" - -@register_class -class SLICENODE_InputSettings(InputNode): - input_type = "Settings" - 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 != '': - cache.evaluate_compatibility({self.printer_id}, set(cache.vendors)) - - def update_profiles(self, context): - cache = self.get_cache() - if self.printer_id != '': - cache.evaluate_compatibility({self.printer_id}, set(cache.vendors)) - - notify_tree_changed(self, context) - - def search_printers(self, _context, edit_text): - self.ensure_profiles_loaded() - cache: LocalCache = self.get_cache() - items: list[str] = [p.id for k, p in cache.printer_profiles.items() if edit_text.lower() in p.id.lower()] - items.sort() - return items - - def search_conf(self, edit_text: str, conf_type: str) -> list[str]: - if not (printer_id := self.printer_id): return [] - - self.ensure_profiles_loaded() - cache = self.get_cache() - if not (printer := cache.printer_profiles.get("printer:" + printer_id)): return [] - - profiles: list[Profile] = printer.compatible_filament_profiles if conf_type == "filaments" else printer.compatible_print_profiles - - items: list[str] = [p.id for p in profiles if edit_text.lower() in p.id.lower()] - items.sort() - return items - - def search_filaments(self, _context, edit_text) -> list[str]: - return self.search_conf(edit_text, conf_type="filaments") - - def search_prints(self, _context, edit_text) -> list[str]: - return self.search_conf(edit_text, conf_type="prints") - - printer_id: NfStringProperty(name="Printer", default='', update=update_profiles, search=search_printers) # pyright: ignore[reportInvalidTypeForm] - filament_id: NfStringProperty(name="Filament", default='', search=search_filaments) # pyright: ignore[reportInvalidTypeForm] - print_id: NfStringProperty(name="Print", default='', search=search_prints) # 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_id") - if self.printer_id != '': - layout.prop(self, "filament_id") - layout.prop(self, "print_id") - - def evaluate_output(self, socket_name: str, **_kwargs) -> Any: - self.ensure_profiles_loaded() - if self.printer_id == '' or self.filament_id == '' or self.print_id == '': - return None - - cache = self.get_cache() - return { - 'printer': "printer:" + self.printer_id, - 'filament': ["filament:" + self.filament_id], - 'print': "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" - - include_children: NfBoolProperty(name="Include Children", default=True) # pyright: ignore[reportInvalidTypeForm] - - def init(self, context): - self.add_input("SLICESOCK_Object") - self.add_output("SLICESOCK_Mesh") - - def draw_buttons(self, context, layout): - assert layout - layout.prop(self, "include_children") - - 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 - - modifiers = {v.field: v.value_str for k, v in dict(obj.nodeslicer.modifiers).items()} - extruder = obj.nodeslicer.extruder - part_type = obj.nodeslicer.object_type - - family = [obj] + (get_all_children(obj) if self.include_children else []) - - 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_input("SLICESOCK_String", "Extrusion Multiplier Attribute").value = 'e_mult' - self.add_input("SLICESOCK_String", "Feedrate Attribute").value = 'f_rate' - self.add_input("SLICESOCK_Settings", "Extra Attributes") - self.add_output("SLICESOCK_Paths") - - def draw_buttons(self, context, layout): - pass - - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: - obj: Any = self.resolve("Object") - 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, e_attr_name, f_attr_name, extra_attrs) - - return SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data) - -@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 = self.resolve("Mesh") - layer_height: float = self.resolve("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() - from math import floor - 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 = self.resolve("Paths") - distance: float = self.resolve("Distance") or 0.0 - rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) - - if not paths: return SlicePaths() - - return paths.buffer(distance, miter=self.shape_option, rotation=rotation) - -@register_class -class SLICENODE_PathOffset(SliceNode): - bl_label = "Path Offset" - - def init(self, context): - self.add_input("SLICESOCK_Paths", "Paths") - self.add_input("SLICESOCK_Vector", "Offset") - self.add_output("SLICESOCK_Paths", "Paths") - - def evaluate_output(self, socket_name: str, **_kwargs) -> Any: - paths: SlicePaths = self.resolve("Paths") - offset: Any = self.resolve("Offset") or (0.0, 0.0, 0.0) - - if not paths: return SlicePaths() - - return paths.offset(np.array(offset, dtype=np.float32)) - -@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 = self.resolve("Paths") - base_distance: float = self.resolve("Distance") or 0.0 - width_step: float = self.resolve("Width Step") or 0.0 - count: int = self.resolve("Count") or 1 - rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) - - if not paths: return SlicePaths() - - all_buffers: list[SlicePaths] = [] - - for i in range(count): - dist = base_distance + (i * width_step) - all_buffers.append(paths.buffer(dist, miter=self.shape_option, rotation=rotation)) - - # Zip Logic: Interleave paths by group (Z-layer) - all_groups = set() - grouped_inputs = [] - for p_list in all_buffers: - 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 paths.copy_with(merged, merged.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 = self.resolve("Paths") - density: float = self.resolve("Density") or 2.0 - infill_rotation: float = self.resolve("Infill Rotation") or 0.0 - rotation: Any = self.resolve("Layer Rotation") or (0.0, 0.0, 0.0) - - if not paths: return SlicePaths() - - return paths.infill( - density, - rotation_deg=infill_rotation, - infill_type=self.infill_type, # pyright: ignore[reportOptionalMemberAccess] - layer_rotation=rotation - ) - -@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] = self.resolve("Paths") - intrinsics: SliceIntrinsics | None = self.resolve("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_AssignMetadata(SliceNode): - bl_label = "Assign Metadata" - - def init(self, context): - self.add_input("SLICESOCK_String", "Name") - self.add_input("SLICESOCK_String", "Filament Type") - self.add_input("SLICESOCK_String", "Printer Model") - self.add_output("SLICESOCK_GcodeRo", "Gcode") - - def evaluate_output(self, socket_name: str, **_kwargs) -> str: - name = self.resolve("Name") - filament = self.resolve("Filament Type") - printer = self.resolve("Printer Model") - lines = [] - if name: - lines.append(f'; objects_info = {{"objects": [{{"name": "{name}", "polygon": []}}]}}') - if filament: - lines.append(f'; filament_type = {filament}') - if printer: - lines.append(f'; printer_model = {printer}') - return '\n'.join(lines) - -@register_class -class SLICENODE_GcodeRegexReplace(SliceNode): - bl_label = "Gcode Regex Replace" - - def init(self, context): - self.add_input("SLICESOCK_Gcode", "Gcode") - self.add_input("SLICESOCK_String", "Find") - self.add_input("SLICESOCK_String", "Replace") - self.add_output("SLICESOCK_GcodeRo", "Gcode") - - def evaluate_output(self, socket_name: str, **_kwargs) -> str: - import re - gcode = self.resolve("Gcode") or "" - find = self.resolve("Find") or "" - replace = self.resolve("Replace") or "" - - if not find: - return gcode - - try: - # Convert $1, $2, ${name} etc to Python backreferences \g<1>, \g<2>, \g - def subst(match): - if match.group(1): return '$' - elif match.group(2): return '\\g<' + match.group(2) + '>' - elif match.group(3): return '\\g<' + match.group(3) + '>' - return match.group(0) - - replace = re.sub(r'(\$\$)|(?:\$\{([^}]+)\})|(?:\$([a-zA-Z0-9_]+))', subst, replace) - - return re.sub(find, replace, gcode) - except Exception as e: - print(f"Regex error in node {self.name}: {e}") - return gcode - -@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) - self.add_multi_socket_group(self.socket_type) - -@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_JoinSettings(SliceNodeJoiner): - bl_label = "Join Settings" - socket_type = "SLICESOCK_Settings" - output_socket_type = "SLICESOCK_SettingsRo" - - 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 - continue - - # Boolean operation loses specific per-segment grouping as it merges polygons. - # We return a new SlicePaths from the result. - accumulated = accumulated.boolean(current, self.operation) - - 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 = self.resolve("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_DirPath", "Folder") - self.add_multi_socket_group("SLICESOCK_Gcode", name="Gcode") - - def draw_buttons(self, context, layout): - super().draw_buttons(context, layout) - - def evaluate(self, operator=None): - from ...infra.networking.bgcode import convert_to_bgcode - from ...infra.networking.gcode_utils import get_gcode_name_from_metadata - from pathlib import Path - import os - - gcodes: list[str] = [gc for gc in self.resolve_multi_input('Gcode') if gc] - - folder: str = self.resolve("Folder") - if not gcodes: - if operator: operator.report({'ERROR'}, "No Gcode to save") - return 'error' - if not folder: - if operator: operator.report({'ERROR'}, "No Folder provided") - return 'error' - - for i, gc in enumerate(gcodes): - name = get_gcode_name_from_metadata(gc, default=f'object_{i}') - - # User's requested logic: .split('.')[0] - folder_path = Path(bpy.path.abspath(f"//{folder}")) - filepath_abs = folder_path / name - dirname = os.path.dirname(filepath_abs) - basename = os.path.basename(filepath_abs) - name_part = basename.split('.')[0] - os.makedirs(dirname, exist_ok=True) - - # Detect binary trigger in gcode comments - is_binary = "; binary_gcode = 1" in gc - - try: - if is_binary: - filepath = os.path.join(dirname, name_part + ".bgcode") - content = convert_to_bgcode(gc, name_part, True) - with open(filepath, "wb") as f: - f.write(content) - if operator: operator.report({'INFO'}, f"Binary G-code generated at {filepath}") - else: - filepath = os.path.join(dirname, name_part + ".gcode") - with open(filepath, "w", encoding="utf-8", newline="\n") as f: - f.write(gc) - if operator: operator.report({'INFO'}, f"G-code saved to {filepath}") - except Exception as e: - if operator: operator.report({'ERROR'}, str(e)) - return 'error' - - 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 = self.resolve("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 = self.resolve("Gcode") - config: PrinterConfig | None = self.resolve("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' - -class ExecSliceNode_DatabaseSubmit(ExecSliceNode): - record_id: NfStringProperty(name="Record ID", default="") # pyright: ignore[reportInvalidTypeForm] - - _db_name = "Database" - _token_prop_name = "token_path" - - def init(self, context): - self._init_exec() - self.add_multi_socket_group("SLICESOCK_Gcode", name="Gcode") - - def draw_buttons(self, context, layout): - super().draw_buttons(context, layout) - layout.prop(self, "record_id") - - def evaluate(self, operator=None): - from ...infra.networking.bgcode import convert_to_bgcode - from ...infra.networking.gcode_utils import get_gcode_name_from_metadata - import tempfile - import os - from pathlib import Path - - gcodes: str = self.resolve_multi_input("Gcode") - - for gc in gcodes: - import re - import json - - if not gc: - continue - - # Parse Metadata from Gcode - fields = {} - part_name = get_gcode_name_from_metadata(gc) - fields["Part ID / Name"] = part_name - - # 1. Time per print (decimal hours) - time_match = re.search(r"; estimated printing time \(normal mode\) = (.*)", gc) - if time_match: - time_str = time_match.group(1) - h = re.search(r"(\d+)h", time_str) - m = re.search(r"(\d+)m", time_str) - s = re.search(r"(\d+)s", time_str) - total_hours = 0.0 - if h: total_hours += float(h.group(1)) - if m: total_hours += float(m.group(1)) / 60.0 - if s: total_hours += float(s.group(1)) / 3600.0 - fields["Time per print"] = round(total_hours, 2) - - # 2. Parts per print - parts_match = re.search(r"; objects_info = (.*)", gc) - if parts_match: - try: - info = json.loads(parts_match.group(1)) - fields["Parts per print"] = len(info.get("objects", [])) - except Exception: pass - - # 3. Weight per print - weight_match = re.search(r"; total filament used \[g\] = ([\d\.]+)", gc) - if weight_match: - fields["Weight per print"] = int(float(weight_match.group(1))) - - # 4. Profile for reference - profile_match = re.search(r"; printer_settings_id = (.*)", gc) - if profile_match: - fields["Printer Profile"] = profile_match.group(1) - - # Detect binary trigger in gcode comments - is_binary = "; binary_gcode = 1" in gc - - filename = part_name + ".bgcode" if is_binary else ".gcode" - - token_path = getattr(self, self._token_prop_name, "") - token = "" - if token_path: - abs_token_path = bpy.path.abspath(token_path) - if os.path.exists(abs_token_path): - try: - with open(abs_token_path, 'r', encoding='utf-8') as f: - token = f.read().strip() - except Exception as e: - if operator: operator.report({'ERROR'}, f"Failed to read token file: {str(e)}") - return 'error' - - if not self._check_config(token): - if operator: operator.report({'ERROR'}, f"{self._db_name} configuration incomplete") - return 'incomplete config' - - temp_dir = tempfile.TemporaryDirectory() - temp_path = Path(temp_dir.name) / filename - - try: - if is_binary: - content = convert_to_bgcode(gc, part_name, True) - with open(temp_path, "wb") as f: - f.write(content) - else: - with open(temp_path, 'w', encoding='utf-8') as f: - f.write(gc) - - record_id = self._submit_to_db(token, fields, temp_path) - if operator: operator.report({'INFO'}, f"Submitted to {self._db_name} (Record: {record_id})") - except Exception as e: - if operator: operator.report({'ERROR'}, f"{self._db_name} Error: {str(e)}") - finally: - if temp_path.exists(): - temp_path.unlink() - temp_dir.cleanup() - - def _check_config(self, token: str) -> bool: - return False - - def _submit_to_db(self, token: str, fields: dict, temp_path: Path) -> str: - return "" - -@register_class -class SLICENODE_BaserowSubmit(ExecSliceNode_DatabaseSubmit): - bl_label = "Baserow Submit" - button_label = "Submit to Baserow" - - host: NfStringProperty(name="Host", default="https://baserow.io") # pyright: ignore[reportInvalidTypeForm] - token_path: NfStringProperty(name="Token File", subtype='FILE_PATH', default="") # pyright: ignore[reportInvalidTypeForm] - table_id: NfStringProperty(name="Table ID", default="") # pyright: ignore[reportInvalidTypeForm] - - _db_name = "Baserow" - _token_prop_name = "token_path" - - def draw_buttons(self, context, layout): - layout.prop(self, "host") - layout.prop(self, "token_path") - layout.prop(self, "table_id") - super().draw_buttons(context, layout) - - def _check_config(self, token): - return bool(token and self.table_id and self.host) - - def _submit_to_db(self, token, fields, temp_path): - from ...infra.networking.baserow_backend import BaserowBackend - db = BaserowBackend(self.host, token) - - if self.record_id: - try: - rid = int(self.record_id) - res = db.upsert_record(self.table_id, rid, fields) - except ValueError: - raise ValueError("Record ID must be an integer") - else: - res = db.create_record(self.table_id, fields) - - record_id = int(res['id']) - db.upload_attachment(self.table_id, record_id, 'GCODE', temp_path) - return str(record_id) - -@register_class -class SLICENODE_AirtableSubmit(ExecSliceNode_DatabaseSubmit): - bl_label = "Airtable Submit" - button_label = "Submit to Airtable" - - pat_path: NfStringProperty(name="PAT File", subtype='FILE_PATH', default="") # pyright: ignore[reportInvalidTypeForm] - base_id: NfStringProperty(name="Base ID", default="") # pyright: ignore[reportInvalidTypeForm] - table_id: NfStringProperty(name="Table ID", default="") # pyright: ignore[reportInvalidTypeForm] - - _db_name = "Airtable" - _token_prop_name = "pat_path" - - def draw_buttons(self, context, layout): - layout.prop(self, "pat_path") - layout.prop(self, "base_id") - layout.prop(self, "table_id") - super().draw_buttons(context, layout) - - def _check_config(self, token): - return bool(token and self.base_id and self.table_id) - - def _submit_to_db(self, token, fields, temp_path): - from ...infra.networking.airtable_backend import AirtableInterface, AirtableRecord - db = AirtableInterface(token) - - if self.record_id: - record: AirtableRecord = {"id": self.record_id, "fields": fields} - res = db.update_records(self.base_id, self.table_id, [record]) - record_id = res[0]['id'] - else: - res = db.create_record(self.base_id, self.table_id, fields) - record_id = res['id'] - - db.upload_attachment(self.base_id, record_id, 'GCODE', temp_path) - return str(record_id) - -@register_class -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] - - def init(self, context): - self.add_input("SLICESOCK_PrusaslicerConf", "Config") - self.add_input("SLICESOCK_Settings", "Settings") - self.add_multi_socket_group("SLICESOCK_Mesh", "SLICESOCK_GcodeRo", name="Slicing Group", in_name="Mesh", out_name="Gcode") - - def draw_buttons(self, context, layout): - assert layout - layout.prop(self, "prusaslicer_executable") - - 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_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 conf_data: - return "" - - # 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 - writer_overrides = {k: {'value': v} for k, v in overrides.items()} - - # Generate the ConfigWriter - from ...infra.prusaslicer_profiles import ConfigWriter - conf_writer: ConfigWriter = cache.generate_conf_writer( - printer_id, - filament_ids, - print_id, - overrides=writer_overrides - ) - - binary_gcode_value = conf_writer.config_dict.get('binary_gcode', '0') - conf_writer.config_dict['binary_gcode'] = '0' - - # Prepare 3mf path - temp_dir = Path(tempfile.gettempdir()) - 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=[]) - - exe_str = self.prusaslicer_executable - if not exe_str: return "" - - cmd_parts = shlex.split(exe_str) - if not cmd_parts: return "" - - 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 - - if not shutil.which(cmd_parts[0]): - return "" - - gcode_path = output_path.with_suffix(".gcode") - cmd = cmd_parts + ["--export-gcode", str(output_path), "--output", str(gcode_path)] - - process = subprocess.run(cmd, capture_output=True, text=True) - if process.returncode != 0: - return "" - - if gcode_path.exists(): - with open(gcode_path, 'r', encoding='utf-8') as f: - gcode_result = f.read() - gcode_result += f"\n; binary_gcode = {binary_gcode_value}\n" - - output_path.unlink() - if gcode_path.exists(): gcode_path.unlink() - - return gcode_result - else: - return "" - - except Exception as e: - import traceback - traceback.print_exc() - return "" -@register_class -class SLICENODE_PrusaslicerPreview(ExecSliceNode): - bl_label = "PrusaSlicer Preview" - button_label = "Open Preview" - - prusaslicer_executable: NfStringProperty( - name="PrusaSlicer Path", - subtype='FILE_PATH', - default='flatpak run com.prusa3d.PrusaSlicer' - ) # pyright: ignore[reportInvalidTypeForm] - - def init(self, context): - self._init_exec() - self.add_multi_socket_group("SLICESOCK_Gcode", name="Gcode") - - def draw_buttons(self, context, layout): - super().draw_buttons(context, layout) - layout.prop(self, "prusaslicer_executable") - - def evaluate(self, operator=None): - gcodes: list[str] = [g for g in self.resolve_multi_input("Gcode") if g] - if not gcodes: - if operator: operator.report({'ERROR'}, "No Gcode to preview") - return 'no gcode' - - 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' - - # Write to temp files - # We use named temporary files that stay around for a bit. - # PrusaSlicer needs the files to exist when it starts. - try: - for gcode in gcodes: - fd, temp_path = tempfile.mkstemp(suffix='.gcode') - with os.fdopen(fd, 'w', encoding='utf-8') as f: - f.write(gcode) - - cmd = cmd_parts + ["--gcodeviewer", temp_path] - - # Use Popen to not block Blender - subprocess.Popen(cmd) - - if operator: - operator.report({'INFO'}, f"PrusaSlicer Previews launched") - except Exception as e: - if operator: operator.report({'ERROR'}, f"Failed to launch preview: {str(e)}") - 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' - - vertices = [] - edges = [] - e_rates = [] - - last_x, last_y, last_z = 0.0, 0.0, 0.0 - last_e = 0.0 - - offset_x, offset_y = 0.0, 0.0 - custom_default_extrusion_rate = 0.0 - - last_v_idx = -1 - last_p2 = None - - def segment_length_3d(p1, p2) -> float: - return ((p2[0]-p1[0])**2 + (p2[1]-p1[1])**2 + (p2[2]-p1[2])**2)**0.5 - - 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 - elif comment.startswith("DEFAULT_EXTRUSION_RATE="): - try: custom_default_extrusion_rate = 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 - p1 = (last_x - ox, last_y - oy, last_z) - p2 = (new_x - ox, new_y - oy, new_z) - - # 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 custom_default_extrusion_rate > 0: - e_rate = (new_e / seg_len_mm) / custom_default_extrusion_rate - else: - e_rate = 1.0 - - if last_v_idx != -1 and p1 == last_p2: - # Connect to previous segment - v2_idx = len(vertices) - vertices.append(p2) - edges.append((last_v_idx, v2_idx)) - last_v_idx = v2_idx - else: - # Start new path - v1_idx = len(vertices) - v2_idx = v1_idx + 1 - vertices.extend([p1, p2]) - edges.append((v1_idx, v2_idx)) - last_v_idx = v2_idx - - e_rates.append(e_rate) - last_p2 = p2 - else: - # Break path connection on travel or non-shown move - last_v_idx = -1 - last_p2 = None - - last_x, last_y, last_z = new_x, new_y, new_z - last_e = new_e - - if not vertices: - 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) - - mesh.from_pydata(vertices, edges, []) - - # Create e_mult attribute on edges - if "e_mult" not in mesh.attributes: - mesh.attributes.new(name="e_mult", type='FLOAT', domain='EDGE') - - e_mult_attr = mesh.attributes["e_mult"] - e_mult_attr.data.foreach_set("value", e_rates) - - mesh.update() - - # Assign Geometry Nodes nodegroup for preview - ng_name = "Nonplanar Slicing Preview" - if ng_name not in bpy.data.node_groups: - # Try to load it from nodes.blend in the addon root - addon_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - blend_path = os.path.join(addon_dir, "nodes.blend") - if os.path.exists(blend_path): - try: - with bpy.data.libraries.load(blend_path) as (data_from, data_to): - if ng_name in data_from.node_groups: - data_to.node_groups = [ng_name] - except Exception as e: - if operator: operator.report({'WARNING'}, f"Failed to load preview node group: {e}") - - 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() - self.add_multi_socket_group(self.socket_type, name="Links") - - def draw_buttons(self, context, layout): - super().draw_buttons(context, layout) - layout.prop(self, "auto_refresh") - - def evaluate(self, operator=None): - from ...infra.blender.blender_slice_nodes_generic import node_execution_feedback, feedback_disabled, execution_cache - - # If auto_refresh is on, we don't want highlighting/redraws for upstream nodes either - maybe_disabled = feedback_disabled if self.auto_refresh else lambda: execution_cache() - - 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: - with maybe_disabled(): - with node_execution_feedback(upstream_node): - 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): - _ = self.resolve("Gcode") - return 'ok' diff --git a/core/nodeslicer/nodes/__init__.py b/core/nodeslicer/nodes/__init__.py new file mode 100644 index 0000000..d2eba48 --- /dev/null +++ b/core/nodeslicer/nodes/__init__.py @@ -0,0 +1,9 @@ +from .base import * +from .inputs import * +from .math import * +from .mesh import * +from .path import * +from .gcode import * +from .output import * +from .system import * +from .preview import * diff --git a/core/nodeslicer/nodes/base.py b/core/nodeslicer/nodes/base.py new file mode 100644 index 0000000..73a3ae0 --- /dev/null +++ b/core/nodeslicer/nodes/base.py @@ -0,0 +1,126 @@ +from __future__ import annotations +from typing import Any, TypeAlias +import bpy +import numpy as np + +from ..bpy_ns_types import NfIntProperty, NfStringProperty, NfEnumProperty, NfBoolProperty, notify_tree_changed + +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 + +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 + +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 + +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 + +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) + self.add_multi_socket_group(self.socket_type) + +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 diff --git a/core/nodeslicer/nodes/gcode.py b/core/nodeslicer/nodes/gcode.py new file mode 100644 index 0000000..7831000 --- /dev/null +++ b/core/nodeslicer/nodes/gcode.py @@ -0,0 +1,196 @@ +from __future__ import annotations +from typing import Any +import numpy as np +import bpy +import os + +from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfStringProperty +from ....registry import register_class +from .base import SliceNode, SliceIntrinsics, Pather, SliceNodeJoiner, SlicePaths + +@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] = self.resolve("Paths") + intrinsics: SliceIntrinsics | None = self.resolve("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_AssignMetadata(SliceNode): + bl_label = "Assign Metadata" + + def init(self, context): + self.add_input("SLICESOCK_String", "Name") + self.add_input("SLICESOCK_String", "Filament Type") + self.add_input("SLICESOCK_String", "Printer Model") + self.add_output("SLICESOCK_GcodeRo", "Gcode") + + def evaluate_output(self, socket_name: str, **_kwargs) -> str: + name = self.resolve("Name") + filament = self.resolve("Filament Type") + printer = self.resolve("Printer Model") + lines = [] + if name: + lines.append(f'; objects_info = {{"objects": [{{"name": "{name}", "polygon": []}}]}}') + if filament: + lines.append(f'; filament_type = {filament}') + if printer: + lines.append(f'; printer_model = {printer}') + return '\n'.join(lines) + +@register_class +class SLICENODE_GcodeRegexReplace(SliceNode): + bl_label = "Gcode Regex Replace" + + def init(self, context): + self.add_input("SLICESOCK_Gcode", "Gcode") + self.add_input("SLICESOCK_String", "Find") + self.add_input("SLICESOCK_String", "Replace") + self.add_output("SLICESOCK_GcodeRo", "Gcode") + + def evaluate_output(self, socket_name: str, **_kwargs) -> str: + import re + gcode = self.resolve("Gcode") or "" + find = self.resolve("Find") or "" + replace = self.resolve("Replace") or "" + + if not find: + return gcode + + try: + # Convert $1, $2, ${name} etc to Python backreferences \g<1>, \g<2>, \g + def subst(match): + if match.group(1): return '$' + elif match.group(2): return '\\g<' + match.group(2) + '>' + elif match.group(3): return '\\g<' + match.group(3) + '>' + return match.group(0) + + replace = re.sub(r'(\$\$)|(?:\$\{([^}]+)\})|(?:\$([a-zA-Z0-9_]+))', subst, replace) + + return re.sub(find, replace, gcode) + except Exception as e: + print(f"Regex error in node {self.name}: {e}") + return gcode + +@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 + +@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: + from ....infra.blender.blender_slice_nodes_generic import resolve_input + gcode_input: list[str] = [] + for input in self.inputs: + gcode_input += [resolve_input(input)] or [""] + return "\n".join(gcode_input) + +@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 = self.resolve("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 "" diff --git a/core/nodeslicer/nodes/inputs.py b/core/nodeslicer/nodes/inputs.py new file mode 100644 index 0000000..f9e8b72 --- /dev/null +++ b/core/nodeslicer/nodes/inputs.py @@ -0,0 +1,192 @@ +from __future__ import annotations +from typing import Any +import bpy +import os + +from ..bpy_ns_types import NfStringProperty, notify_tree_changed, NfEnumProperty +from ....registry import register_class +from .base import InputNode, SliceNode, SliceIntrinsics + +@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_InputVector(InputNode): + input_type = "Vector" + +@register_class +class SLICENODE_InputObject(InputNode): + input_type = "Object" + bl_icon = "OBJECT_DATA" + +@register_class +class SLICENODE_InputSettings(InputNode): + input_type = "Settings" + 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 + # Note: adjust paths to point to the correct addon root + addon_dir = os.path.dirname(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 != '': + cache.evaluate_compatibility({self.printer_id}, set(cache.vendors)) + + def update_profiles(self, context): + cache = self.get_cache() + if self.printer_id != '': + cache.evaluate_compatibility({self.printer_id}, set(cache.vendors)) + + notify_tree_changed(self, context) + + def search_printers(self, _context, edit_text): + self.ensure_profiles_loaded() + cache = self.get_cache() + items: list[str] = [p.id for k, p in cache.printer_profiles.items() if edit_text.lower() in p.id.lower()] + items.sort() + return items + + def search_conf(self, edit_text: str, conf_type: str) -> list[str]: + if not (printer_id := self.printer_id): return [] + + self.ensure_profiles_loaded() + cache = self.get_cache() + if not (printer := cache.printer_profiles.get("printer:" + printer_id)): return [] + + profiles = printer.compatible_filament_profiles if conf_type == "filaments" else printer.compatible_print_profiles + + items: list[str] = [p.id for p in profiles if edit_text.lower() in p.id.lower()] + items.sort() + return items + + def search_filaments(self, _context, edit_text) -> list[str]: + return self.search_conf(edit_text, conf_type="filaments") + + def search_prints(self, _context, edit_text) -> list[str]: + return self.search_conf(edit_text, conf_type="prints") + + printer_id: NfStringProperty(name="Printer", default='', update=update_profiles, search=search_printers) # pyright: ignore[reportInvalidTypeForm] + filament_id: NfStringProperty(name="Filament", default='', search=search_filaments) # pyright: ignore[reportInvalidTypeForm] + print_id: NfStringProperty(name="Print", default='', search=search_prints) # 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_id") + if self.printer_id != '': + layout.prop(self, "filament_id") + layout.prop(self, "print_id") + + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: + self.ensure_profiles_loaded() + if self.printer_id == '' or self.filament_id == '' or self.print_id == '': + return None + + cache = self.get_cache() + return { + 'printer': "printer:" + self.printer_id, + 'filament': ["filament:" + self.filament_id], + 'print': "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") + ) diff --git a/core/nodeslicer/nodes/math.py b/core/nodeslicer/nodes/math.py new file mode 100644 index 0000000..6b53012 --- /dev/null +++ b/core/nodeslicer/nodes/math.py @@ -0,0 +1,15 @@ +from __future__ import annotations +from ....registry import register_class +from .base import MathNode + +@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" diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py new file mode 100644 index 0000000..ec3f95c --- /dev/null +++ b/core/nodeslicer/nodes/mesh.py @@ -0,0 +1,65 @@ +from __future__ import annotations +from typing import Any +import numpy as np + +from ..bpy_ns_types import NfBoolProperty +from ....registry import register_class +from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SlicingGroup + +@register_class +class SLICENODE_GetMesh(SliceNode): + bl_label = "Get Mesh" + + include_children: NfBoolProperty(name="Include Children", default=True) # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + self.add_input("SLICESOCK_Object") + self.add_output("SLICESOCK_Mesh") + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "include_children") + + 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) if self.include_children else []) + + sc: SlicingCollection = SlicingCollection(family, obj.name) + + return sc + +@register_class +class SLICENODE_JoinMesh(SliceNodeJoiner): + bl_label = "Join Mesh Parts" + socket_type = "SLICESOCK_Mesh" + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection: + from ....infra.blender.blender_slice_nodes_generic import resolve_input + 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: + from ....infra.blender.blender_slice_nodes_generic import resolve_input + group: SlicingGroup = SlicingGroup([]) + + for input in self.inputs: + coll = resolve_input(input) + if coll: + group.collections[coll.objects[0].name] = coll + + return group diff --git a/core/nodeslicer/nodes/output.py b/core/nodeslicer/nodes/output.py new file mode 100644 index 0000000..e1e353f --- /dev/null +++ b/core/nodeslicer/nodes/output.py @@ -0,0 +1,350 @@ +from __future__ import annotations +from typing import Any +from pathlib import Path +import os +import bpy +import tempfile +import re +import json + +from ..bpy_ns_types import NfStringProperty +from ....registry import register_class +from .base import ExecSliceNode, SlicePaths, SliceNode, SlicingCollection, SlicingGroup + +@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_DirPath", "Folder") + self.add_multi_socket_group("SLICESOCK_Gcode", name="Gcode") + + def draw_buttons(self, context, layout): + super().draw_buttons(context, layout) + + def evaluate(self, operator=None): + from ....infra.networking.bgcode import convert_to_bgcode + from ....infra.networking.gcode_utils import get_gcode_name_from_metadata + + gcodes: list[str] = [gc for gc in self.resolve_multi_input('Gcode') if gc] + + folder: str = self.resolve("Folder") + if not gcodes: + if operator: operator.report({'ERROR'}, "No Gcode to save") + return 'error' + if not folder: + if operator: operator.report({'ERROR'}, "No Folder provided") + return 'error' + + for i, gc in enumerate(gcodes): + name = get_gcode_name_from_metadata(gc, default=f'object_{i}') + + # User's requested logic: .split('.')[0] + folder_path = Path(bpy.path.abspath(f"//{folder}")) + filepath_abs = folder_path / name + dirname = os.path.dirname(filepath_abs) + basename = os.path.basename(filepath_abs) + name_part = basename.split('.')[0] + os.makedirs(dirname, exist_ok=True) + + # Detect binary trigger in gcode comments + is_binary = "; binary_gcode = 1" in gc + + try: + if is_binary: + filepath = os.path.join(dirname, name_part + ".bgcode") + content = convert_to_bgcode(gc, name_part, True) + with open(filepath, "wb") as f: + f.write(content) + if operator: operator.report({'INFO'}, f"Binary G-code generated at {filepath}") + else: + filepath = os.path.join(dirname, name_part + ".gcode") + with open(filepath, "w", encoding="utf-8", newline="\n") as f: + f.write(gc) + if operator: operator.report({'INFO'}, f"G-code saved to {filepath}") + except Exception as e: + if operator: operator.report({'ERROR'}, str(e)) + return 'error' + + 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 = self.resolve("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 + ) + + gcode: str = self.resolve("Gcode") + config: PrinterConfig | None = self.resolve("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' + +class ExecSliceNode_DatabaseSubmit(ExecSliceNode): + record_id: NfStringProperty(name="Record ID", default="") # pyright: ignore[reportInvalidTypeForm] + + _db_name = "Database" + _token_prop_name = "token_path" + + def init(self, context): + self._init_exec() + self.add_multi_socket_group("SLICESOCK_Gcode", name="Gcode") + + def draw_buttons(self, context, layout): + super().draw_buttons(context, layout) + layout.prop(self, "record_id") + + def evaluate(self, operator=None): + from ....infra.networking.bgcode import convert_to_bgcode + from ....infra.networking.gcode_utils import get_gcode_name_from_metadata + + gcodes: list[str] = self.resolve_multi_input("Gcode") + + for gc in gcodes: + if not gc: + continue + + # Parse Metadata from Gcode + fields = {} + part_name = get_gcode_name_from_metadata(gc) + fields["Part ID / Name"] = part_name + + # 1. Time per print (decimal hours) + time_match = re.search(r"; estimated printing time \(normal mode\) = (.*)", gc) + if time_match: + time_str = time_match.group(1) + h = re.search(r"(\d+)h", time_str) + m = re.search(r"(\d+)m", time_str) + s = re.search(r"(\d+)s", time_str) + total_hours = 0.0 + if h: total_hours += float(h.group(1)) + if m: total_hours += float(m.group(1)) / 60.0 + if s: total_hours += float(s.group(1)) / 3600.0 + fields["Time per print"] = round(total_hours, 2) + + # 2. Parts per print + parts_match = re.search(r"; objects_info = (.*)", gc) + if parts_match: + try: + info = json.loads(parts_match.group(1)) + fields["Parts per print"] = len(info.get("objects", [])) + except Exception: pass + + # 3. Weight per print + weight_match = re.search(r"; total filament used \[g\] = ([\d\.]+)", gc) + if weight_match: + fields["Weight per print"] = int(float(weight_match.group(1))) + + # 4. Profile for reference + profile_match = re.search(r"; printer_settings_id = (.*)", gc) + if profile_match: + fields["Printer Profile"] = profile_match.group(1) + + # Detect binary trigger in gcode comments + is_binary = "; binary_gcode = 1" in gc + + filename = part_name + (".bgcode" if is_binary else ".gcode") + + token_path = getattr(self, self._token_prop_name, "") + token = "" + if token_path: + abs_token_path = bpy.path.abspath(token_path) + if os.path.exists(abs_token_path): + try: + with open(abs_token_path, 'r', encoding='utf-8') as f: + token = f.read().strip() + except Exception as e: + if operator: operator.report({'ERROR'}, f"Failed to read token file: {str(e)}") + return 'error' + + if not self._check_config(token): + if operator: operator.report({'ERROR'}, f"{self._db_name} configuration incomplete") + return 'incomplete config' + + temp_dir = tempfile.TemporaryDirectory() + temp_path = Path(temp_dir.name) / filename + + try: + if is_binary: + content = convert_to_bgcode(gc, part_name, True) + with open(temp_path, "wb") as f: + f.write(content) + else: + with open(temp_path, 'w', encoding='utf-8') as f: + f.write(gc) + + record_id = self._submit_to_db(token, fields, temp_path) + if operator: operator.report({'INFO'}, f"Submitted to {self._db_name} (Record: {record_id})") + except Exception as e: + if operator: operator.report({'ERROR'}, f"{self._db_name} Error: {str(e)}") + finally: + if temp_path.exists(): + temp_path.unlink() + temp_dir.cleanup() + + def _check_config(self, token: str) -> bool: + return False + + def _submit_to_db(self, token: str, fields: dict, temp_path: Path) -> str: + return "" + +@register_class +class SLICENODE_BaserowSubmit(ExecSliceNode_DatabaseSubmit): + bl_label = "Baserow Submit" + button_label = "Submit to Baserow" + + host: NfStringProperty(name="Host", default="https://baserow.io") # pyright: ignore[reportInvalidTypeForm] + token_path: NfStringProperty(name="Token File", subtype='FILE_PATH', default="") # pyright: ignore[reportInvalidTypeForm] + table_id: NfStringProperty(name="Table ID", default="") # pyright: ignore[reportInvalidTypeForm] + + _db_name = "Baserow" + _token_prop_name = "token_path" + + def draw_buttons(self, context, layout): + layout.prop(self, "host") + layout.prop(self, "token_path") + layout.prop(self, "table_id") + super().draw_buttons(context, layout) + + def _check_config(self, token): + return bool(token and self.table_id and self.host) + + def _submit_to_db(self, token, fields, temp_path): + from ....infra.networking.baserow_backend import BaserowBackend + db = BaserowBackend(self.host, token) + + if self.record_id: + try: + rid = int(self.record_id) + res = db.upsert_record(self.table_id, rid, fields) + except ValueError: + raise ValueError("Record ID must be an integer") + else: + res = db.create_record(self.table_id, fields) + + record_id = int(res['id']) + db.upload_attachment(self.table_id, record_id, 'GCODE', temp_path) + return str(record_id) + +@register_class +class SLICENODE_AirtableSubmit(ExecSliceNode_DatabaseSubmit): + bl_label = "Airtable Submit" + button_label = "Submit to Airtable" + + pat_path: NfStringProperty(name="PAT File", subtype='FILE_PATH', default="") # pyright: ignore[reportInvalidTypeForm] + base_id: NfStringProperty(name="Base ID", default="") # pyright: ignore[reportInvalidTypeForm] + table_id: NfStringProperty(name="Table ID", default="") # pyright: ignore[reportInvalidTypeForm] + + _db_name = "Airtable" + _token_prop_name = "pat_path" + + def draw_buttons(self, context, layout): + layout.prop(self, "pat_path") + layout.prop(self, "base_id") + layout.prop(self, "table_id") + super().draw_buttons(context, layout) + + def _check_config(self, token): + return bool(token and self.base_id and self.table_id) + + def _submit_to_db(self, token, fields, temp_path): + from ....infra.networking.airtable_backend import AirtableInterface, AirtableRecord + db = AirtableInterface(token) + + if self.record_id: + record: AirtableRecord = {"id": self.record_id, "fields": fields} + res = db.update_records(self.base_id, self.table_id, [record]) + record_id = res[0]['id'] + else: + res = db.create_record(self.base_id, self.table_id, fields) + record_id = res['id'] + + db.upload_attachment(self.base_id, record_id, 'GCODE', temp_path) + return str(record_id) diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py new file mode 100644 index 0000000..85e5c8d --- /dev/null +++ b/core/nodeslicer/nodes/path.py @@ -0,0 +1,323 @@ +from __future__ import annotations +from typing import Any +import numpy as np + +from ..bpy_ns_types import NfEnumProperty, notify_tree_changed +from ....registry import register_class +from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner + +@register_class +class SLICENODE_GetPaths(SliceNode): + bl_label = "Get Paths" + + def init(self, context): + self.add_input("SLICESOCK_Object") + self.add_input("SLICESOCK_String", "Extrusion Multiplier Attribute").value = 'e_mult' + self.add_input("SLICESOCK_String", "Feedrate Attribute").value = 'f_rate' + self.add_input("SLICESOCK_Settings", "Extra Attributes") + self.add_output("SLICESOCK_Paths") + + def draw_buttons(self, context, layout): + pass + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + obj: Any = self.resolve("Object") + 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, e_attr_name, f_attr_name, extra_attrs) + + return SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data) + +@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 = self.resolve("Mesh") + layer_height: float = self.resolve("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_z = merged_object.mesh[:,0:3,2:3].min() + from math import floor + min_z = floor(min_z / layer_height) * layer_height + max_z = merged_object.mesh[:,0:3,2:3].max() + + z_values = np.arange(min_z, max_z + 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 = self.resolve("Paths") + distance: float = self.resolve("Distance") or 0.0 + rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) + + if not paths: return SlicePaths() + + return paths.buffer(distance, miter=self.shape_option, rotation=rotation) + +@register_class +class SLICENODE_PathOffset(SliceNode): + bl_label = "Path Offset" + + def init(self, context): + self.add_input("SLICESOCK_Paths", "Paths") + self.add_input("SLICESOCK_Vector", "Offset") + self.add_output("SLICESOCK_Paths", "Paths") + + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: + paths: SlicePaths = self.resolve("Paths") + offset: Any = self.resolve("Offset") or (0.0, 0.0, 0.0) + + if not paths: return SlicePaths() + + return paths.offset(np.array(offset, dtype=np.float32)) + +@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 = self.resolve("Paths") + base_distance: float = self.resolve("Distance") or 0.0 + width_step: float = self.resolve("Width Step") or 0.0 + count: int = self.resolve("Count") or 1 + rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) + + if not paths: return SlicePaths() + + all_buffers: list[SlicePaths] = [] + + for i in range(count): + dist = base_distance + (i * width_step) + all_buffers.append(paths.buffer(dist, miter=self.shape_option, rotation=rotation)) + + # Zip Logic: Interleave paths by group (Z-layer) + all_groups = set() + grouped_inputs = [] + for p_list in all_buffers: + 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 paths.copy_with(merged, merged.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 = self.resolve("Paths") + density: float = self.resolve("Density") or 2.0 + infill_rotation: float = self.resolve("Infill Rotation") or 0.0 + rotation: Any = self.resolve("Layer Rotation") or (0.0, 0.0, 0.0) + + if not paths: return SlicePaths() + + return paths.infill( + density, + rotation_deg=infill_rotation, + infill_type=self.infill_type, # pyright: ignore[reportOptionalMemberAccess] + layer_rotation=rotation + ) + +@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: + from ....infra.blender.blender_slice_nodes_generic import resolve_input + accumulated: SlicePaths = SlicePaths() + current: SlicePaths = SlicePaths() + for input in self.inputs: + current = resolve_input(input) + + if not current: + continue + + if not accumulated: + accumulated = current + continue + + # Boolean operation loses specific per-segment grouping as it merges polygons. + # We return a new SlicePaths from the result. + accumulated = accumulated.boolean(current, self.operation) + + return accumulated + +@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: + from ....infra.blender.blender_slice_nodes_generic import resolve_input + 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() diff --git a/core/nodeslicer/nodes/preview.py b/core/nodeslicer/nodes/preview.py new file mode 100644 index 0000000..4968f00 --- /dev/null +++ b/core/nodeslicer/nodes/preview.py @@ -0,0 +1,182 @@ +from __future__ import annotations +import bpy +import os + +from ..bpy_ns_types import NfBoolProperty +from ....registry import register_class +from .base import ExecSliceNode + +@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' + + vertices = [] + edges = [] + e_rates = [] + + last_x, last_y, last_z = 0.0, 0.0, 0.0 + last_e = 0.0 + + offset_x, offset_y = 0.0, 0.0 + custom_default_extrusion_rate = 0.0 + + last_v_idx = -1 + last_p2 = None + + def segment_length_3d(p1, p2) -> float: + return ((p2[0]-p1[0])**2 + (p2[1]-p1[1])**2 + (p2[2]-p1[2])**2)**0.5 + + 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 + elif comment.startswith("DEFAULT_EXTRUSION_RATE="): + try: custom_default_extrusion_rate = 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() + if not parts: continue + 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 + p1 = (last_x - ox, last_y - oy, last_z) + p2 = (new_x - ox, new_y - oy, new_z) + + # 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 custom_default_extrusion_rate > 0: + e_rate = (new_e / seg_len_mm) / custom_default_extrusion_rate + else: + e_rate = 1.0 + + if last_v_idx != -1 and p1 == last_p2: + # Connect to previous segment + v2_idx = len(vertices) + vertices.append(p2) + edges.append((last_v_idx, v2_idx)) + last_v_idx = v2_idx + else: + # Start new path + v1_idx = len(vertices) + v2_idx = v1_idx + 1 + vertices.extend([p1, p2]) + edges.append((v1_idx, v2_idx)) + last_v_idx = v2_idx + + e_rates.append(e_rate) + last_p2 = p2 + else: + # Break path connection on travel or non-shown move + last_v_idx = -1 + last_p2 = None + + last_x, last_y, last_z = new_x, new_y, new_z + last_e = new_e + + if not vertices: + 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) + + mesh.from_pydata(vertices, edges, []) + + # Create e_mult attribute on edges + if "e_mult" not in mesh.attributes: + mesh.attributes.new(name="e_mult", type='FLOAT', domain='EDGE') + + e_mult_attr = mesh.attributes["e_mult"] + e_mult_attr.data.foreach_set("value", e_rates) + + mesh.update() + + # Assign Geometry Nodes nodegroup for preview + ng_name = "Nonplanar Slicing Preview" + if ng_name not in bpy.data.node_groups: + # Try to load it from nodes.blend in the addon root + addon_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + blend_path = os.path.join(addon_dir, "nodes.blend") + if os.path.exists(blend_path): + try: + with bpy.data.libraries.load(blend_path) as (data_from, data_to): + if ng_name in data_from.node_groups: + data_to.node_groups = [ng_name] + except Exception as e: + if operator: operator.report({'WARNING'}, f"Failed to load preview node group: {e}") + + 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' diff --git a/core/nodeslicer/nodes/system.py b/core/nodeslicer/nodes/system.py new file mode 100644 index 0000000..69dc48b --- /dev/null +++ b/core/nodeslicer/nodes/system.py @@ -0,0 +1,377 @@ +from __future__ import annotations +from typing import Any +from pathlib import Path +import os +import bpy +import tempfile +import shlex +import shutil +import subprocess +import numpy as np + +from ..bpy_ns_types import NfBoolProperty, NfStringProperty +from ....registry import register_class +from .base import ExecSliceNode, SliceNode, SliceNodeJoiner, SlicingCollection, SlicingGroup + +@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_PrusaslicerSlice(SliceNode): + bl_label = "PrusaSlicer Slice" + + prusaslicer_executable: NfStringProperty(name="PrusaSlicer Path", subtype='FILE_PATH', default='flatpak run com.prusa3d.PrusaSlicer') # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + self.add_input("SLICESOCK_PrusaslicerConf", "Config") + self.add_input("SLICESOCK_Settings", "Settings") + self.add_multi_socket_group("SLICESOCK_Mesh", "SLICESOCK_GcodeRo", name="Slicing Group", in_name="Mesh", out_name="Gcode") + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "prusaslicer_executable") + + 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_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 conf_data: + return "" + + # 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 + writer_overrides = {k: {'value': v} for k, v in overrides.items()} + + # Generate the ConfigWriter + from ....infra.prusaslicer_profiles import ConfigWriter + conf_writer: ConfigWriter = cache.generate_conf_writer( + printer_id, + filament_ids, + print_id, + overrides=writer_overrides + ) + + binary_gcode_value = conf_writer.config_dict.get('binary_gcode', '0') + conf_writer.config_dict['binary_gcode'] = '0' + + # Prepare 3mf path + temp_dir = Path(tempfile.gettempdir()) + 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=[]) + + exe_str = self.prusaslicer_executable + if not exe_str: return "" + + cmd_parts = shlex.split(exe_str) + if not cmd_parts: return "" + + 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 + + if not shutil.which(cmd_parts[0]): + return "" + + gcode_path = output_path.with_suffix(".gcode") + cmd = cmd_parts + ["--export-gcode", str(output_path), "--output", str(gcode_path)] + + process = subprocess.run(cmd, capture_output=True, text=True) + if process.returncode != 0: + return "" + + if gcode_path.exists(): + with open(gcode_path, 'r', encoding='utf-8') as f: + gcode_result = f.read() + gcode_result += f"\n; binary_gcode = {binary_gcode_value}\n" + + output_path.unlink() + if gcode_path.exists(): gcode_path.unlink() + + return gcode_result + else: + return "" + + except Exception as e: + import traceback + traceback.print_exc() + return "" + +@register_class +class SLICENODE_PrusaslicerPreview(ExecSliceNode): + bl_label = "PrusaSlicer Preview" + button_label = "Open Preview" + + prusaslicer_executable: NfStringProperty( + name="PrusaSlicer Path", + subtype='FILE_PATH', + default='flatpak run com.prusa3d.PrusaSlicer' + ) # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + self._init_exec() + self.add_multi_socket_group("SLICESOCK_Gcode", name="Gcode") + + def draw_buttons(self, context, layout): + super().draw_buttons(context, layout) + layout.prop(self, "prusaslicer_executable") + + def evaluate(self, operator=None): + from ....infra.blender.blender_slice_nodes_generic import resolve_input + gcodes: list[str] = [g for g in self.resolve_multi_input("Gcode") if g] + if not gcodes: + if operator: operator.report({'ERROR'}, "No Gcode to preview") + return 'no gcode' + + 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' + + # Write to temp files + # We use named temporary files that stay around for a bit. + # PrusaSlicer needs the files to exist when it starts. + try: + for gcode in gcodes: + fd, temp_path = tempfile.mkstemp(suffix='.gcode') + with os.fdopen(fd, 'w', encoding='utf-8') as f: + f.write(gcode) + + cmd = cmd_parts + ["--gcodeviewer", temp_path] + + # Use Popen to not block Blender + subprocess.Popen(cmd) + + if operator: + operator.report({'INFO'}, f"PrusaSlicer Previews launched") + except Exception as e: + if operator: operator.report({'ERROR'}, f"Failed to launch preview: {str(e)}") + return 'error' + + return 'ok' + +@register_class +class SLICENODE_JoinExecution(SliceNodeJoiner): + bl_label = "Join Execution" + socket_type = "SLICESOCK_ExecutionLink" + + def evaluate_output(self, socket_name: str, **_kwargs) -> list[ExecSliceNode]: + from ....infra.blender.blender_slice_nodes_generic import resolve_input + 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 + +@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() + self.add_multi_socket_group(self.socket_type, name="Links") + + def draw_buttons(self, context, layout): + super().draw_buttons(context, layout) + layout.prop(self, "auto_refresh") + + def evaluate(self, operator=None): + from ....infra.blender.blender_slice_nodes_generic import ( + node_execution_feedback, + feedback_disabled, + execution_cache, + resolve_input + ) + + # If auto_refresh is on, we don't want highlighting/redraws for upstream nodes either + maybe_disabled = feedback_disabled if self.auto_refresh else lambda: execution_cache() + + 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: + with maybe_disabled(): + with node_execution_feedback(upstream_node): + 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_JoinSettings(SliceNodeJoiner): + bl_label = "Join Settings" + socket_type = "SLICESOCK_Settings" + output_socket_type = "SLICESOCK_SettingsRo" + + def evaluate_output(self, socket_name: str, **_kwargs) -> dict[str, str]: + from ....infra.blender.blender_slice_nodes_generic import resolve_input + 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_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): + _ = self.resolve("Gcode") + return 'ok' diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index cc89608..55cf94b 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -407,7 +407,6 @@ def objects_to_tris(objects: list[Object], scale) -> NDArray[float64]: def _cached_capture_mesh_edges( obj_name: str, - mesh_name: str, matrix_tuple: tuple[float, ...], scene_scale: float, e_attr_name: str = "e_mult", @@ -496,6 +495,7 @@ def _cached_capture_mesh_edges( if hasattr(obj_eval, "to_mesh_clear"): obj_eval.to_mesh_clear() + 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 [], [], [], {} @@ -510,7 +510,6 @@ def capture_mesh_edges(obj: Object, e_attr_name: str = "e_mult", f_attr_name: st return _cached_capture_mesh_edges( obj.name, - obj.data.name, matrix_tuple, scene_scale, e_attr_name,