From 241b277895b2851b7b11dfe0f5e9ef1faf7df2b5 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Sun, 10 May 2026 17:09:50 +0300 Subject: [PATCH] up --- __init__.py | 889 +--------------------------- core/nodeslicer/__init__.py | 0 core/nodeslicer/nodes.py | 792 +++++++++++++++++++++++++ core/nodeslicer/sockets.py | 116 ++++ core/nodeslicer/tree.py | 75 +++ gcode_snippets/mk3s_footer.gcode | 19 + gcode_snippets/mk3s_header.gcode | 55 ++ gcode_snippets/redefine_z_0-1.gcode | 2 + 8 files changed, 1067 insertions(+), 881 deletions(-) create mode 100644 core/nodeslicer/__init__.py create mode 100644 core/nodeslicer/nodes.py create mode 100644 core/nodeslicer/sockets.py create mode 100644 core/nodeslicer/tree.py create mode 100644 gcode_snippets/mk3s_footer.gcode create mode 100644 gcode_snippets/mk3s_header.gcode create mode 100644 gcode_snippets/redefine_z_0-1.gcode diff --git a/__init__.py b/__init__.py index d30e15e..dc1a73f 100644 --- a/__init__.py +++ b/__init__.py @@ -1,900 +1,27 @@ from __future__ import annotations -from numpy._typing._array_like import NDArray -from numpy import float64, ndarray +import bpy +from . import registry +from .core.nodeslicer import tree bl_info = { "name": "NodeSlicer", - "author": "OpenAI", + "author": "Nicolas Predella", "version": (0, 1, 0), "blender": (3, 6, 0), "location": "Node Editor > Add", - "description": "Very small custom nodes example", + "description": "Prepare GCodes with Nodes", "category": "Node", } -from math import floor -from typing import TYPE_CHECKING, Any -import bpy -import os -import numpy as np -from mathutils import Euler, Matrix - -from bpy.types import NodeTree, Operator, Menu -from bpy.props import PointerProperty, StringProperty, FloatProperty, IntProperty - -from .registry import register_class -from .core import slice -from .core.slice import Pather, slice_at_range, SlicePaths -from .infra.blender_mesh_capture import SlicingObject, capture_mesh_edges -from .infra.blender_slice_nodes_generic import SliceNode, SliceNodeSocket, resolve_input - -if TYPE_CHECKING: - from bpy.stub_internal.rna_enums import OperatorReturnItems - -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 - - - -# ----------------------------------------------------------------------------- -# Node tree -# ----------------------------------------------------------------------------- - -@register_class -class SLICETREE_NodeTree(NodeTree): - bl_idname = "SLICETREE_NodeTree" - bl_label = "NodeSlicer Tree" - bl_icon = "NODETREE" - -# ----------------------------------------------------------------------------- -# Sockets -# ----------------------------------------------------------------------------- - -class LabelNodeSocket(SliceNodeSocket): - default = "" - def draw(self, context: Any, layout: Any, node: Any, text: str | None): - assert layout - layout.label(text=text or self.name) - -class InputNodeSocket(SliceNodeSocket): - def draw(self, context: Any, layout: Any, node: Any, text: str | None): - assert layout - if not self.is_output and self.is_linked: - layout.label(text=text or self.name) - else: - layout.prop(self, "value", text=text or self.name) - - def _draw_as_ro(self, context: Any, layout: Any, node: Any, text: str | None): - layout.label(text=text or self.name) - - def _draw_as_rw(self, context: Any, layout: Any, node: Any, text: str | None): - layout.prop(self, "value", text=text or self.name) - -class ReadonlySocket(): - def draw(self, context: Any, layout: Any, node: Any, text: str | None): - return self._draw_as_ro(context, layout, node, text) # pyright: ignore[reportAttributeAccessIssue] - -@register_class -class SLICESOCK_Object(InputNodeSocket): - bl_label = "Object" - - value: PointerProperty( - name="Object", - type=bpy.types.Object, - ) # pyright: ignore[reportInvalidTypeForm] - color = (0.9, 0.7, 0.2, 1.0) - - -class SLICESOCK_Float_Common(InputNodeSocket): - bl_label = "Float" - - value: FloatProperty(name="Float", default=0) # pyright: ignore[reportInvalidTypeForm] - color = (161/255, 161/255, 161/255, 1.0) - -@register_class -class SLICESOCK_FloatRo(ReadonlySocket, SLICESOCK_Float_Common): ... -@register_class -class SLICESOCK_Float(SLICESOCK_Float_Common): ... - - -class SLICESOCK_Int_Common(InputNodeSocket): - bl_label = "Integer" - - value: IntProperty(name="Int", default=0) # pyright: ignore[reportInvalidTypeForm] - color = (161/255, 161/255, 161/255, 1.0) - -@register_class -class SLICESOCK_Int(SLICESOCK_Int_Common): ... -@register_class -class SLICESOCK_IntRo(ReadonlySocket, SLICESOCK_Int_Common): ... - - -@register_class -class SLICESOCK_String(InputNodeSocket): - bl_label = "String" - - value: StringProperty(name="String", default="") # pyright: ignore[reportInvalidTypeForm] - color = (0.4, 0.7, 1.0, 1.0) - -@register_class -class SLICESOCK_VectorRotation_Common(InputNodeSocket): - bl_label = "VectorRotation" - - value: bpy.props.FloatVectorProperty(name="Vector", size=3, default=(0.0, 0.0, 0.0), subtype='EULER') # pyright: ignore[reportInvalidTypeForm] - color = (0.39, 0.39, 0.78, 1.0) -@register_class -class SLICESOCK_VectorRotation(SLICESOCK_VectorRotation_Common): ... -@register_class -class SLICESOCK_VectorRotationRo(ReadonlySocket, SLICESOCK_VectorRotation_Common): ... - -@register_class -class SLICESOCK_Mesh(LabelNodeSocket): - bl_label = "Mesh" - default = None - color = (0, 214/255, 163/255, 1.0) - -@register_class -class SLICESOCK_Paths(LabelNodeSocket): - bl_label = "Paths" - default = SlicePaths() - color = (214/255, 0, 120/255, 1.0) - -@register_class -class SLICESOCK_Gcode(LabelNodeSocket): - bl_label = "Gcode" - default = "" - color = (1., 1., 1., 1.) - -@register_class -class SLICESOCK_Intrinsics(LabelNodeSocket): - bl_label = "Intrinsics" - default = None - color = (0.2, 0.2, 0.2, 1.0) - -# ----------------------------------------------------------------------------- -# Nodes -# ----------------------------------------------------------------------------- - -# Basic Inputs - -class InputNode(SliceNode): - input_type: str = "" - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - if "bl_label" not in cls.__dict__: cls.bl_label = cls.input_type - - def init(self, context) -> None: - self.add_output(f"SLICESOCK_{self.bl_label}") - - def evaluate_output(self, socket_name: str, **_kwargs): - return self.outputs.get(socket_name).value - -@register_class -class SLICENODE_InputString(InputNode): - input_type = "String" - -@register_class -class SLICENODE_InputFloat(InputNode): - input_type = "Float" - -@register_class -class SLICENODE_InputInt(InputNode): - input_type = "Int" - -@register_class -class SLICENODE_InputVectorRotation(InputNode): - input_type = "VectorRotation" - -@register_class -class SLICENODE_InputObject(InputNode): - input_type = "Object" - bl_icon = "OBJECT_DATA" - -@register_class -class SLICENODE_InputIntrinsics(SliceNode): - bl_label = "Printer Intrinsics" - - bed_x: FloatProperty(name="Bed X", default=250.0) # pyright: ignore[reportInvalidTypeForm] - bed_y: FloatProperty(name="Bed Y", default=250.0) # pyright: ignore[reportInvalidTypeForm] - feedrate: FloatProperty(name="Feedrate", default=1200.0) # pyright: ignore[reportInvalidTypeForm] - extrusion_rate: FloatProperty(name="Extrusion Rate", default=0.017, precision=4) # pyright: ignore[reportInvalidTypeForm] - - def init(self, context): - self.add_output("SLICESOCK_Intrinsics") - - def draw_buttons(self, context, layout): - assert layout - layout.prop(self, "bed_x") - layout.prop(self, "bed_y") - layout.prop(self, "feedrate") - layout.prop(self, "extrusion_rate") - - def evaluate_output(self, socket_name: str, **_kwargs) -> SliceIntrinsics: - return SliceIntrinsics( - bed_x=self.bed_x, - bed_y=self.bed_y, - feedrate=self.feedrate, - extrusion_rate=self.extrusion_rate - ) - -# Math - -@register_class -class SLICENODE_IntMath(SliceNode): - bl_label = "Integer Math" - - operation: bpy.props.EnumProperty( - 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", "Integer 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] - - def init(self, context): - self.add_output("SLICESOCK_IntRo", "Value") - - self.add_input("SLICESOCK_Int", "Value") - self.add_input("SLICESOCK_Int", "Value") - - def draw_buttons(self, context, layout): - assert layout - layout.prop(self, "operation", text="") - - def evaluate_output(self, socket_name: str, **_kwargs) -> int: - a: int = resolve_input(self.inputs[0]) - b: int = 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 int(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 int(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 int(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 * int(a / b)) if b != 0 else 0 - except Exception: - return 0 - return 0 - -@register_class -class SLICENODE_GetMesh(SliceNode): - bl_label = "Get Mesh" - - def init(self, context): - self.add_input("SLICESOCK_Object") - self.add_output("SLICESOCK_Mesh") - - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingObject | None: - if socket_name != "Mesh": return None - - obj: Any = resolve_input(self.inputs.get("Object")) - - so = SlicingObject(obj, obj.name) - - return so - -@register_class -class SLICENODE_GetPaths(SliceNode): - bl_label = "Get Paths" - - def init(self, context): - self.add_input("SLICESOCK_Object") - self.add_output("SLICESOCK_Paths") - - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: - obj: Any = resolve_input(self.inputs.get("Object")) - - so: list[np.ndarray] = capture_mesh_edges(obj) - - return SlicePaths(so) - -@register_class -class SLICENODE_SliceShells(SliceNode): - bl_label = "Slice Shells" - - def init(self, context): - self.add_input("SLICESOCK_Mesh") - self.add_input("SLICESOCK_Float", "Layer Height").value = 0.2 - self.add_output("SLICESOCK_Paths") - - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: - if socket_name != "Paths": return SlicePaths() - - slicingmesh: SlicingObject | None = resolve_input(self.inputs.get("Mesh")) - layer_height: float = resolve_input(self.inputs.get("Layer Height")) - - if not slicingmesh: return SlicePaths() - - min = slicingmesh.mesh[:,0:3,2:3].min() - min = floor(min / layer_height) * layer_height - max = slicingmesh.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([slicingmesh.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: bpy.props.EnumProperty( - name="Miter", - items=[ - ('round', "Round", ""), - ('square', "Square", ""), - ('flat', "Flat", "") - ] - ) # pyright: ignore[reportInvalidTypeForm] - - def init(self, context): - self.add_input("SLICESOCK_Paths") - self.add_input("SLICESOCK_Float", "Distance").value = 0 - self.add_input("SLICESOCK_VectorRotation", "Rotation") - self.add_output("SLICESOCK_Paths") - - def draw_buttons(self, context, layout): - assert layout - layout.prop(self, "shape_option") - - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: - if socket_name != "Paths": return SlicePaths() - - paths: SlicePaths = resolve_input(self.inputs.get("Paths")) - distance: float = resolve_input(self.inputs.get("Distance")) or 0.0 - rotation: Any = resolve_input(self.inputs.get("Rotation")) or (0.0, 0.0, 0.0) - - if not paths: return SlicePaths() - - if any(rotation): - # De-rotate shells to Z-perpendicular plane - mat = Euler(rotation).to_matrix().inverted() - mat_np = np.array(mat) - prepared_paths = [s @ mat_np.T for s in paths] - else: - prepared_paths = paths - - # Buffer - res_paths, res_groups = slice.buffer_slice(prepared_paths, distance, miter=self.shape_option) - - if any(rotation): - # Re-rotate shells back - mat_inv = Euler(rotation).to_matrix() - mat_inv_np = np.array(mat_inv) - final_paths = [s @ mat_inv_np.T for s in res_paths] - else: - final_paths = res_paths - - return SlicePaths(final_paths, res_groups) - -@register_class -class SLICENODE_Infill(SliceNode): - bl_label = "Infill" - - 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 evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: - if socket_name != "Paths": return SlicePaths() - - paths: SlicePaths = resolve_input(self.inputs.get("Paths")) - density: float = resolve_input(self.inputs.get("Density")) or 2.0 - infill_rotation: float = resolve_input(self.inputs.get("Infill Rotation")) or 0.0 - rotation: Any = resolve_input(self.inputs.get("Layer Rotation")) or (0.0, 0.0, 0.0) - - if not paths: return SlicePaths() - - p: SlicePaths - if any(rotation): - # De-rotate shells to Z-perpendicular plane - mat = Euler(rotation).to_matrix().inverted() - mat_np = np.array(mat) - p = SlicePaths([s @ mat_np.T for s in paths], paths.groups) - else: - p = paths - - res_paths, res_groups = slice.infill_slice(p, density, rotation_deg=infill_rotation) - - if any(rotation): - # Re-rotate shells back - mat_inv = Euler(rotation).to_matrix() - mat_inv_np = np.array(mat_inv) - p = SlicePaths([s @ mat_inv_np.T for s in res_paths], res_groups) - else: - p = SlicePaths(res_paths, res_groups) - - return p - -@register_class -class SLICENODE_PathsToGcode(SliceNode): - bl_label = "Paths to Gcode" - - def init(self, context): - self.add_input("SLICESOCK_Paths") - self.add_input("SLICESOCK_Intrinsics") - self.add_output("SLICESOCK_Gcode") - - def evaluate_output(self, socket_name: str, **_kwargs) -> str: - paths: list[np.ndarray] = resolve_input(self.inputs.get("Paths")) - intrinsics: SliceIntrinsics | None = resolve_input(self.inputs.get("Intrinsics")) - - pather: Pather = Pather(paths) - if intrinsics: - # Note: Pather currently uses bed_center_x/y as its internal attributes. - # We map the bed size center to these attributes if provided. - pather.bed_center_x = intrinsics.bed_x / 2.0 - pather.bed_center_y = intrinsics.bed_y / 2.0 - pather.feedrate = intrinsics.feedrate - pather.extrusion_rate = intrinsics.extrusion_rate - - gcode: str = pather.to_gcode() - return gcode - -@register_class -class SLICENODE_GcodeBlock(SliceNode): - bl_label = "Gcode Block" - - gcode: StringProperty(name="Gcode", default="") # pyright: ignore[reportInvalidTypeForm] - - def init(self, context): - self.add_output("SLICESOCK_Gcode") - - def draw_buttons(self, context, layout): - assert layout - layout.prop(self, "gcode", text="") - - def evaluate_output(self, socket_name: str, **_kwargs) -> str: - return self.gcode - -### Joiners - -class SliceNodeJoiner(SliceNode): - socket_type: str = "" - output_socket_type: str = "" - - def init(self, context): - self.add_output(self.output_socket_type or self.socket_type) - - def update(self) -> None: - def is_active(input) -> bool: - return input.is_linked or getattr(input, 'value', False) - - if len(self.inputs) == 0: - self.add_input(self.socket_type) - return - - if is_active(self.inputs[-1]): - self.add_input(self.socket_type) - - while True: - if len(self.inputs) < 2: break - if not is_active(self.inputs[-1]) and not is_active(self.inputs[-2]): - self.inputs.remove(self.inputs[-2]) - continue - break - -@register_class -class SLICENODE_JoinGcode(SliceNodeJoiner): - bl_label = "Join Gcode" - socket_type = "SLICESOCK_Gcode" - - 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_JoinSlices(SliceNodeJoiner): - bl_label = "Join Slices" - socket_type = "SLICESOCK_Paths" - - operation: bpy.props.EnumProperty( - name="Operation", - items=[ - ('UNION', "Union", "Union of the slices"), - ('INTERSECTION', "Intersection", "Intersection of the slices"), - ('DIFFERENCE', "Subtract", "Clipping of the first slice"), - ('XOR', "Exclusive or", "Exclusive or of the slices"), - ], - default='UNION' - ) # pyright: ignore[reportInvalidTypeForm] - - def draw_buttons(self, context, layout): - assert layout - layout.prop(self, "operation", text="") - - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: - accumulated: SlicePaths = SlicePaths() - current: SlicePaths = SlicePaths() - for input in self.inputs: - current = resolve_input(input) - - if not current: - continue - - if not accumulated: - accumulated = current - current = SlicePaths() - continue - - # Boolean operation loses specific per-segment grouping as it merges polygons. - # We return a new SlicePaths from the result. - res = slice.boolean_slice(accumulated, current, self.operation) - accumulated = SlicePaths(res) - current = SlicePaths() - - return accumulated - -@register_class -class SLICENODE_JoinMesh(SliceNodeJoiner): - bl_label = "Join Mesh" - socket_type = "SLICESOCK_Mesh" - - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingObject: - merged_mesh = SlicingObject() - - for input in self.inputs: - mesh =resolve_input(input) - if mesh: - merged_mesh.mesh = np.concatenate([merged_mesh.mesh, mesh.mesh], axis=0) - - return merged_mesh - -@register_class -class SLICENODE_JoinPaths(SliceNodeJoiner): - bl_label = "Join Paths" - socket_type = "SLICESOCK_Paths" - - mode: bpy.props.EnumProperty( - 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() - -### I/O - -@register_class -class SLICENODE_LoadGcode(SliceNode): - bl_label = "Load Gcode File" - - def init(self, context): - self.add_input("SLICESOCK_String", "Filepath") - self.add_output("SLICESOCK_Gcode") - - def evaluate_output(self, socket_name: str, **_kwargs) -> str: - path = resolve_input(self.inputs.get("Filepath")) - if not path: return "" - abs_path = bpy.path.abspath(path) - if os.path.exists(abs_path): - with open(abs_path, 'r', encoding='utf-8') as f: - return f.read() - return "" - -class ExecSliceNode(SliceNode): - button_label: str = 'Execute' - - def draw_buttons(self, context, layout): - assert layout - layout.operator("slicenode.execute", text=self.button_label).node_name = self.name - -@register_class -class SLICENODE_GcodeToFile(ExecSliceNode): - bl_label = "Gcode To File" - - button_label = "Generate File" - - def init(self, context): - self.add_input("SLICESOCK_Gcode") - self.add_input("SLICESOCK_String", "Filename") - - def evaluate(self): - gcode: str = resolve_input(self.inputs.get("Gcode")) - filename: str = resolve_input(self.inputs.get("Filename")) - - filepath = bpy.path.abspath(filename) - os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True) - - with open(filepath, "w", encoding="utf-8", newline="\n") as f: - f.write(gcode) - - return 'ok' - -@register_class -class SLICENODE_GcodeToMesh(ExecSliceNode): - bl_label = "Gcode to Mesh" - - button_label = "Update Debug Mesh" - - def init(self, context): - self.add_input("SLICESOCK_Gcode") - - def evaluate(self): - gcode: str = resolve_input(self.inputs.get("Gcode")) - if not gcode: - return 'empty' - - segments = [] - last_x, last_y, last_z = 0.0, 0.0, 0.0 - last_e = 0.0 - - offset_x, offset_y = 0.0, 0.0 - - for line in gcode.splitlines(): - # Parse metadata from comments - if ';' in line: - comment = line.split(';', 1)[1].strip() - if comment.startswith("OFFSET_X="): - try: offset_x = float(comment.split('=')[1]) - except ValueError: pass - elif comment.startswith("OFFSET_Y="): - try: offset_y = float(comment.split('=')[1]) - except ValueError: pass - - line_no_comment = line.split(';')[0].strip() - if not line_no_comment: continue - - parts = line_no_comment.split() - cmd = parts[0].upper() - - params = {} - for p in parts[1:]: - if len(p) < 2: continue - try: - # Scale coordinates by 0.001 (mm to Blender units) - val = float(p[1:]) - if p[0].upper() in 'XYZ': - val *= 0.001 - params[p[0].upper()] = val - except ValueError: continue - - new_x = params.get('X', last_x) - new_y = params.get('Y', last_y) - new_z = params.get('Z', last_z) - new_e = params.get('E', last_e) - - if cmd == 'G1': - is_extruding = new_e > 0 - - if is_extruding: - # Subtract offsets (scaled to Blender units) to return to original space - ox, oy = offset_x * 0.001, offset_y * 0.001 - segments.append(( - (last_x - ox, last_y - oy, last_z), - (new_x - ox, new_y - oy, new_z) - )) - - last_x, last_y, last_z = new_x, new_y, new_z - last_e = new_e - - if not segments: - return 'no segments' - - # Create or find "Debug" mesh object - mesh_name = "Debug" - if mesh_name in bpy.data.objects: - obj = bpy.data.objects[mesh_name] - mesh = obj.data - mesh.clear_geometry() - else: - mesh = bpy.data.meshes.new(mesh_name) - obj = bpy.data.objects.new(mesh_name, mesh) - assert bpy.context.collection - bpy.context.collection.objects.link(obj) - - vertices = [] - edges = [] - for i, (p1, p2) in enumerate(segments): - vertices.extend([p1, p2]) - edges.append((2*i, 2*i + 1)) - - mesh.from_pydata(vertices, edges, []) - mesh.update() - - # Assign Geometry Nodes nodegroup for preview - ng_name = "Nonplanar Slicing Preview" - if ng_name in bpy.data.node_groups: - ng = bpy.data.node_groups[ng_name] - # Check if already assigned - has_preview = False - for mod in obj.modifiers: - if mod.type == 'NODES' and mod.node_group == ng: # type: ignore - has_preview = True - break - - if not has_preview: - mod = obj.modifiers.new(name="Preview", type='NODES') # type: ignore - mod.node_group = ng - - return 'ok' - -@register_class -class SLICENODE_Test(ExecSliceNode): - bl_label = "Test" - - button_label = "Test" - - def init(self, context): - self.add_input("SLICESOCK_Gcode") - - def evaluate(self): - _ = resolve_input(self.inputs.get("Gcode")) - return 'ok' - -# ----------------------------------------------------------------------------- -# Operator -# ----------------------------------------------------------------------------- - -@register_class -class SLICENODE_OT_Execute(Operator): - bl_idname = "slicenode.execute" - bl_label = "Execute" - bl_description = "Execute" - - node_name: bpy.props.StringProperty() # pyright: ignore[reportInvalidTypeForm] - - def execute(self, context) -> set['OperatorReturnItems']: - node_tree = context.space_data.edit_tree # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] - node = node_tree.nodes[self.node_name] - _ = node.evaluate() - - self.report({"INFO"}, f"Execution Successful") - return {"FINISHED"} - -# ----------------------------------------------------------------------------- -# Menu -# ----------------------------------------------------------------------------- - -@register_class -class SLICENODE_MT_Menu(Menu): - bl_idname = "SLICENODE_MT_Menu" - bl_label = "SliceNodes" - - def draw(self, context): - layout = self.layout - assert layout - layout.operator("node.add_node", text="String").type = "SLICENODE_InputString" - layout.operator("node.add_node", text="Float").type = "SLICENODE_InputFloat" - layout.operator("node.add_node", text="Integer").type = "SLICENODE_InputInt" - layout.operator("node.add_node", text="Rotation").type = "SLICENODE_InputVectorRotation" - layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath" - layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject" - layout.operator("node.add_node", text="Printer Intrinsics").type = "SLICENODE_InputIntrinsics" - layout.operator("node.add_node", text="Test").type = "SLICENODE_Test" - layout.operator("node.add_node", text="Get Mesh").type = "SLICENODE_GetMesh" - layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths" - layout.separator() - layout.operator("node.add_node", text="Slice Shells").type = "SLICENODE_SliceShells" - layout.operator("node.add_node", text="Buffer Shells").type = "SLICENODE_BufferShells" - layout.operator("node.add_node", text="Infill").type = "SLICENODE_Infill" - layout.operator("node.add_node", text="Paths to Gcode").type = "SLICENODE_PathsToGcode" - layout.separator() - layout.operator("node.add_node", text="Gcode Block").type = "SLICENODE_GcodeBlock" - layout.operator("node.add_node", text="Load Gcode").type = "SLICENODE_LoadGcode" - layout.separator() - layout.operator("node.add_node", text="Join Gcode").type = "SLICENODE_JoinGcode" - layout.operator("node.add_node", text="Join Mesh").type = "SLICENODE_JoinMesh" - layout.operator("node.add_node", text="Join Paths").type = "SLICENODE_JoinPaths" - layout.operator("node.add_node", text="Boolean Paths").type = "SLICENODE_JoinSlices" - layout.separator() - layout.operator("node.add_node", text="Gcode to File").type = "SLICENODE_GcodeToFile" - layout.operator("node.add_node", text="Gcode to Mesh").type = "SLICENODE_GcodeToMesh" - -def draw_add_menu(self, context): - space = context.space_data - tree = getattr(space, "edit_tree", None) - if tree and tree.bl_idname == "SLICETREE_NodeTree": - self.layout.separator() - self.layout.menu("SLICENODE_MT_Menu") - - -# ----------------------------------------------------------------------------- -# Registration -# ----------------------------------------------------------------------------- - -### Load collected modules -modules = registry.get() - def register(): registry.blender_register_classes() - bpy.types.NODE_MT_add.append(draw_add_menu) - + bpy.types.NODE_MT_add.append(tree.draw_add_menu) print('Nodeslicer Loaded') def unregister(): - bpy.types.NODE_MT_add.remove(draw_add_menu) + bpy.types.NODE_MT_add.remove(tree.draw_add_menu) registry.blender_unregister_classes() - print('Nodeslicer Unloaded') if __name__ == "__main__": - register() \ No newline at end of file + register() diff --git a/core/nodeslicer/__init__.py b/core/nodeslicer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/nodeslicer/nodes.py b/core/nodeslicer/nodes.py new file mode 100644 index 0000000..654c0ad --- /dev/null +++ b/core/nodeslicer/nodes.py @@ -0,0 +1,792 @@ +from __future__ import annotations +import os +from math import floor +from typing import Any +import bpy +import numpy as np +from mathutils import Euler +from bpy.props import StringProperty, FloatProperty, IntProperty + +from ...registry import register_class +from .. import slice +from ..slice import Pather, slice_at_range, SlicePaths +from ...infra.blender_mesh_capture import SlicingObject, capture_mesh_edges +from ...infra.blender_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 + +# Basic Inputs + +class InputNode(SliceNode): + input_type: str = "" + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if "bl_label" not in cls.__dict__: cls.bl_label = cls.input_type + + def init(self, context) -> None: + self.add_output(f"SLICESOCK_{self.bl_label}") + + def evaluate_output(self, socket_name: str, **_kwargs): + return self.outputs.get(socket_name).value + +@register_class +class SLICENODE_InputString(InputNode): + input_type = "String" + +@register_class +class SLICENODE_InputFloat(InputNode): + input_type = "Float" + +@register_class +class SLICENODE_InputInt(InputNode): + input_type = "Int" + +@register_class +class SLICENODE_InputVectorRotation(InputNode): + input_type = "VectorRotation" + +@register_class +class SLICENODE_InputObject(InputNode): + input_type = "Object" + bl_icon = "OBJECT_DATA" + +@register_class +class SLICENODE_InputIntrinsics(SliceNode): + bl_label = "Printer Intrinsics" + + bed_x: FloatProperty(name="Bed X", default=250.0) # pyright: ignore[reportInvalidTypeForm] + bed_y: FloatProperty(name="Bed Y", default=250.0) # pyright: ignore[reportInvalidTypeForm] + feedrate: FloatProperty(name="Feedrate", default=1200.0) # pyright: ignore[reportInvalidTypeForm] + extrusion_rate: FloatProperty(name="Extrusion Rate", default=0.017, precision=4) # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + self.add_output("SLICESOCK_Intrinsics") + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "bed_x") + layout.prop(self, "bed_y") + layout.prop(self, "feedrate") + layout.prop(self, "extrusion_rate") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SliceIntrinsics: + return SliceIntrinsics( + bed_x=self.bed_x, + bed_y=self.bed_y, + feedrate=self.feedrate, + extrusion_rate=self.extrusion_rate + ) + +# Math + +@register_class +class SLICENODE_IntMath(SliceNode): + bl_label = "Integer Math" + + operation: bpy.props.EnumProperty( + 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", "Integer 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] + + def init(self, context): + self.add_output("SLICESOCK_IntRo", "Value") + + self.add_input("SLICESOCK_Int", "Value") + self.add_input("SLICESOCK_Int", "Value") + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "operation", text="") + + def evaluate_output(self, socket_name: str, **_kwargs) -> int: + a: int = resolve_input(self.inputs[0]) + b: int = 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 int(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 int(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 int(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 * int(a / b)) if b != 0 else 0 + except Exception: + return 0 + return 0 + +@register_class +class SLICENODE_GetMesh(SliceNode): + bl_label = "Get Mesh" + + def init(self, context): + self.add_input("SLICESOCK_Object") + self.add_output("SLICESOCK_Mesh") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingObject | None: + if socket_name != "Mesh": return None + + obj: Any = resolve_input(self.inputs.get("Object")) + + so = SlicingObject(obj, obj.name) + + return so + +@register_class +class SLICENODE_GetPaths(SliceNode): + bl_label = "Get Paths" + + def init(self, context): + self.add_input("SLICESOCK_Object") + self.add_output("SLICESOCK_Paths") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + obj: Any = resolve_input(self.inputs.get("Object")) + + so: list[np.ndarray] = capture_mesh_edges(obj) + + return SlicePaths(so) + +@register_class +class SLICENODE_SliceShells(SliceNode): + bl_label = "Slice Shells" + + def init(self, context): + self.add_input("SLICESOCK_Mesh") + self.add_input("SLICESOCK_Float", "Layer Height").value = 0.2 + self.add_output("SLICESOCK_Paths") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + if socket_name != "Paths": return SlicePaths() + + slicingmesh: SlicingObject | None = resolve_input(self.inputs.get("Mesh")) + layer_height: float = resolve_input(self.inputs.get("Layer Height")) + + if not slicingmesh: return SlicePaths() + + min = slicingmesh.mesh[:,0:3,2:3].min() + min = floor(min / layer_height) * layer_height + max = slicingmesh.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([slicingmesh.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: bpy.props.EnumProperty( + name="Miter", + items=[ + ('round', "Round", ""), + ('square', "Square", ""), + ('flat', "Flat", "") + ] + ) # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + self.add_input("SLICESOCK_Paths") + self.add_input("SLICESOCK_Float", "Distance").value = 0 + self.add_input("SLICESOCK_VectorRotation", "Rotation") + self.add_output("SLICESOCK_Paths") + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "shape_option") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + if socket_name != "Paths": return SlicePaths() + + paths: SlicePaths = resolve_input(self.inputs.get("Paths")) + distance: float = resolve_input(self.inputs.get("Distance")) or 0.0 + rotation: Any = resolve_input(self.inputs.get("Rotation")) or (0.0, 0.0, 0.0) + + if not paths: return SlicePaths() + + if any(rotation): + # De-rotate shells to Z-perpendicular plane + mat = Euler(rotation).to_matrix().inverted() + mat_np = np.array(mat) + prepared_paths = [s @ mat_np.T for s in paths] + else: + prepared_paths = paths + + # Buffer + res_paths, res_groups = slice.buffer_slice(prepared_paths, distance, miter=self.shape_option) + + if any(rotation): + # Re-rotate shells back + mat_inv = Euler(rotation).to_matrix() + mat_inv_np = np.array(mat_inv) + final_paths = [s @ mat_inv_np.T for s in res_paths] + else: + final_paths = res_paths + + return SlicePaths(final_paths, res_groups) + +@register_class +class SLICENODE_BufferShellsMultiple(SliceNode): + bl_label = "Buffer Shells Multiple" + + shape_option: bpy.props.EnumProperty( + name="Miter", + items=[ + ('round', "Round", ""), + ('square', "Square", ""), + ('flat', "Flat", "") + ] + ) # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + self.add_input("SLICESOCK_Paths") + self.add_input("SLICESOCK_Float", "Distance").value = 0 + self.add_input("SLICESOCK_Float", "Width Step").value = 0.4 + self.add_input("SLICESOCK_Int", "Count").value = 2 + self.add_input("SLICESOCK_VectorRotation", "Rotation") + self.add_output("SLICESOCK_Paths") + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "shape_option") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + if socket_name != "Paths": return SlicePaths() + + paths: SlicePaths = resolve_input(self.inputs.get("Paths")) + base_distance: float = resolve_input(self.inputs.get("Distance")) or 0.0 + width_step: float = resolve_input(self.inputs.get("Width Step")) or 0.0 + count: int = resolve_input(self.inputs.get("Count")) or 1 + rotation: Any = resolve_input(self.inputs.get("Rotation")) or (0.0, 0.0, 0.0) + + if not paths: return SlicePaths() + + if any(rotation): + mat = Euler(rotation).to_matrix().inverted() + mat_np = np.array(mat) + prepared_paths = [s @ mat_np.T for s in paths] + else: + prepared_paths = paths + + all_res_paths = [] + all_res_groups = [] + + for i in range(count): + dist = base_distance + (i * width_step) + res_paths, res_groups = slice.buffer_slice(prepared_paths, dist, miter=self.shape_option) + all_res_paths.extend(res_paths) + all_res_groups.extend(res_groups) + + if any(rotation): + mat_inv = Euler(rotation).to_matrix() + mat_inv_np = np.array(mat_inv) + final_paths = [s @ mat_inv_np.T for s in all_res_paths] + else: + final_paths = all_res_paths + + return SlicePaths(final_paths, all_res_groups) + +@register_class +class SLICENODE_Infill(SliceNode): + bl_label = "Infill" + + 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 evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + if socket_name != "Paths": return SlicePaths() + + paths: SlicePaths = resolve_input(self.inputs.get("Paths")) + density: float = resolve_input(self.inputs.get("Density")) or 2.0 + infill_rotation: float = resolve_input(self.inputs.get("Infill Rotation")) or 0.0 + rotation: Any = resolve_input(self.inputs.get("Layer Rotation")) or (0.0, 0.0, 0.0) + + if not paths: return SlicePaths() + + p: SlicePaths + if any(rotation): + # De-rotate shells to Z-perpendicular plane + mat = Euler(rotation).to_matrix().inverted() + mat_np = np.array(mat) + p = SlicePaths([s @ mat_np.T for s in paths], paths.groups) + else: + p = paths + + res_paths, res_groups = slice.infill_slice(p, density, rotation_deg=infill_rotation) + + if any(rotation): + # Re-rotate shells back + mat_inv = Euler(rotation).to_matrix() + mat_inv_np = np.array(mat_inv) + p = SlicePaths([s @ mat_inv_np.T for s in res_paths], res_groups) + else: + p = SlicePaths(res_paths, res_groups) + + return p + +@register_class +class SLICENODE_PathsToGcode(SliceNode): + bl_label = "Paths to Gcode" + + def init(self, context): + self.add_input("SLICESOCK_Paths") + self.add_input("SLICESOCK_Intrinsics") + self.add_output("SLICESOCK_Gcode") + + def evaluate_output(self, socket_name: str, **_kwargs) -> str: + paths: list[np.ndarray] = resolve_input(self.inputs.get("Paths")) + intrinsics: SliceIntrinsics | None = resolve_input(self.inputs.get("Intrinsics")) + + pather: Pather = Pather(paths) + if intrinsics: + # Note: Pather currently uses bed_center_x/y as its internal attributes. + # We map the bed size center to these attributes if provided. + pather.bed_center_x = intrinsics.bed_x / 2.0 + pather.bed_center_y = intrinsics.bed_y / 2.0 + pather.feedrate = intrinsics.feedrate + pather.extrusion_rate = intrinsics.extrusion_rate + + gcode: str = pather.to_gcode() + return gcode + +@register_class +class SLICENODE_GcodeBlock(SliceNode): + bl_label = "Gcode Block" + + gcode: StringProperty(name="Gcode", default="") # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + self.add_output("SLICESOCK_Gcode") + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "gcode", text="") + + def evaluate_output(self, socket_name: str, **_kwargs) -> str: + return self.gcode + +@register_class +class SLICENODE_GcodeHelper(SliceNode): + bl_label = "Gcode Helper" + + command: bpy.props.EnumProperty( + 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: StringProperty(name="Params", default="") # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + self.add_output("SLICESOCK_Gcode") + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "command", text="") + # Commands that don't need parameters + no_params = {'G28', 'G90', 'G91', 'M0', 'M25', 'M107', 'M400', 'M600', 'M82', 'M83', 'PAUSE'} + if self.command not in no_params: + layout.prop(self, "parameters", text="Parameters") + + def evaluate_output(self, socket_name: str, **_kwargs) -> str: + cmd = self.command + params = self.parameters.strip() + if params: + # T commands usually don't have a space (e.g. T0) + if cmd == 'T': + return f"{cmd}{params}" + return f"{cmd} {params}" + return cmd + +### Joiners + +class SliceNodeJoiner(SliceNode): + socket_type: str = "" + output_socket_type: str = "" + + def init(self, context): + self.add_output(self.output_socket_type or self.socket_type) + + def update(self) -> None: + def is_active(input) -> bool: + return input.is_linked or getattr(input, 'value', False) + + if len(self.inputs) == 0: + self.add_input(self.socket_type) + return + + if is_active(self.inputs[-1]): + self.add_input(self.socket_type) + + while True: + if len(self.inputs) < 2: break + if not is_active(self.inputs[-1]) and not is_active(self.inputs[-2]): + self.inputs.remove(self.inputs[-2]) + continue + break + +@register_class +class SLICENODE_JoinGcode(SliceNodeJoiner): + bl_label = "Join Gcode" + socket_type = "SLICESOCK_Gcode" + + 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_JoinSlices(SliceNodeJoiner): + bl_label = "Join Slices" + socket_type = "SLICESOCK_Paths" + + operation: bpy.props.EnumProperty( + name="Operation", + items=[ + ('UNION', "Union", "Union of the slices"), + ('INTERSECTION', "Intersection", "Intersection of the slices"), + ('DIFFERENCE', "Subtract", "Clipping of the first slice"), + ('XOR', "Exclusive or", "Exclusive or of the slices"), + ], + default='UNION' + ) # pyright: ignore[reportInvalidTypeForm] + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "operation", text="") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + accumulated: SlicePaths = SlicePaths() + current: SlicePaths = SlicePaths() + for input in self.inputs: + current = resolve_input(input) + + if not current: + continue + + if not accumulated: + accumulated = current + current = SlicePaths() + continue + + # Boolean operation loses specific per-segment grouping as it merges polygons. + # We return a new SlicePaths from the result. + res = slice.boolean_slice(accumulated, current, self.operation) + accumulated = SlicePaths(res) + current = SlicePaths() + + return accumulated + +@register_class +class SLICENODE_JoinMesh(SliceNodeJoiner): + bl_label = "Join Mesh" + socket_type = "SLICESOCK_Mesh" + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingObject: + merged_mesh = SlicingObject() + + for input in self.inputs: + mesh =resolve_input(input) + if mesh: + merged_mesh.mesh = np.concatenate([merged_mesh.mesh, mesh.mesh], axis=0) + + return merged_mesh + +@register_class +class SLICENODE_JoinPaths(SliceNodeJoiner): + bl_label = "Join Paths" + socket_type = "SLICESOCK_Paths" + + mode: bpy.props.EnumProperty( + 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() + +### 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_Gcode") + + def evaluate_output(self, socket_name: str, **_kwargs) -> str: + path = resolve_input(self.inputs.get("Filepath")) + if not path: return "" + abs_path = bpy.path.abspath(path) + if os.path.exists(abs_path): + with open(abs_path, 'r', encoding='utf-8') as f: + return f.read() + return "" + +class ExecSliceNode(SliceNode): + button_label: str = 'Execute' + + def draw_buttons(self, context, layout): + assert layout + layout.operator("slicenode.execute", text=self.button_label).node_name = self.name + +@register_class +class SLICENODE_GcodeToFile(ExecSliceNode): + bl_label = "Gcode To File" + + button_label = "Generate File" + + def init(self, context): + self.add_input("SLICESOCK_Gcode") + self.add_input("SLICESOCK_FilePath", "Filename") + + def evaluate(self): + gcode: str = resolve_input(self.inputs.get("Gcode")) + filename: str = resolve_input(self.inputs.get("Filename")) + + filepath = bpy.path.abspath(filename) + os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True) + + with open(filepath, "w", encoding="utf-8", newline="\n") as f: + f.write(gcode) + + return 'ok' + +@register_class +class SLICENODE_GcodeToMesh(ExecSliceNode): + bl_label = "Gcode to Mesh" + + button_label = "Update Debug Mesh" + + def init(self, context): + self.add_input("SLICESOCK_Gcode") + + def evaluate(self): + gcode: str = resolve_input(self.inputs.get("Gcode")) + if not gcode: + return 'empty' + + segments = [] + last_x, last_y, last_z = 0.0, 0.0, 0.0 + last_e = 0.0 + + offset_x, offset_y = 0.0, 0.0 + + for line in gcode.splitlines(): + # Parse metadata from comments + if ';' in line: + comment = line.split(';', 1)[1].strip() + if comment.startswith("OFFSET_X="): + try: offset_x = float(comment.split('=')[1]) + except ValueError: pass + elif comment.startswith("OFFSET_Y="): + try: offset_y = float(comment.split('=')[1]) + except ValueError: pass + + line_no_comment = line.split(';')[0].strip() + if not line_no_comment: continue + + parts = line_no_comment.split() + cmd = parts[0].upper() + + params = {} + for p in parts[1:]: + if len(p) < 2: continue + try: + # Scale coordinates by 0.001 (mm to Blender units) + val = float(p[1:]) + if p[0].upper() in 'XYZ': + val *= 0.001 + params[p[0].upper()] = val + except ValueError: continue + + new_x = params.get('X', last_x) + new_y = params.get('Y', last_y) + new_z = params.get('Z', last_z) + new_e = params.get('E', last_e) + + if cmd == 'G1': + is_extruding = new_e > 0 + + if is_extruding: + # Subtract offsets (scaled to Blender units) to return to original space + ox, oy = offset_x * 0.001, offset_y * 0.001 + segments.append(( + (last_x - ox, last_y - oy, last_z), + (new_x - ox, new_y - oy, new_z) + )) + + last_x, last_y, last_z = new_x, new_y, new_z + last_e = new_e + + if not segments: + return 'no segments' + + # Create or find "Debug" mesh object + mesh_name = "Debug" + if mesh_name in bpy.data.objects: + obj = bpy.data.objects[mesh_name] + mesh = obj.data + mesh.clear_geometry() + else: + mesh = bpy.data.meshes.new(mesh_name) + obj = bpy.data.objects.new(mesh_name, mesh) + assert bpy.context.collection + bpy.context.collection.objects.link(obj) + + vertices = [] + edges = [] + for i, (p1, p2) in enumerate(segments): + vertices.extend([p1, p2]) + edges.append((2*i, 2*i + 1)) + + mesh.from_pydata(vertices, edges, []) + mesh.update() + + # Assign Geometry Nodes nodegroup for preview + ng_name = "Nonplanar Slicing Preview" + if ng_name in bpy.data.node_groups: + ng = bpy.data.node_groups[ng_name] + # Check if already assigned + has_preview = False + for mod in obj.modifiers: + if mod.type == 'NODES' and mod.node_group == ng: # type: ignore + has_preview = True + break + + if not has_preview: + mod = obj.modifiers.new(name="Preview", type='NODES') # type: ignore + mod.node_group = ng + + return 'ok' + +@register_class +class SLICENODE_Test(ExecSliceNode): + bl_label = "Test" + + button_label = "Test" + + def init(self, context): + self.add_input("SLICESOCK_Gcode") + + def evaluate(self): + _ = resolve_input(self.inputs.get("Gcode")) + return 'ok' diff --git a/core/nodeslicer/sockets.py b/core/nodeslicer/sockets.py new file mode 100644 index 0000000..408d8f5 --- /dev/null +++ b/core/nodeslicer/sockets.py @@ -0,0 +1,116 @@ +from __future__ import annotations +from typing import Any +import bpy +from bpy.props import PointerProperty, StringProperty, FloatProperty, IntProperty + +from ...registry import register_class +from ...infra.blender_slice_nodes_generic import SliceNodeSocket +from ..slice import SlicePaths + +class LabelNodeSocket(SliceNodeSocket): + default = "" + def draw(self, context: Any, layout: Any, node: Any, text: str | None): + assert layout + layout.label(text=text or self.name) + +class InputNodeSocket(SliceNodeSocket): + def draw(self, context: Any, layout: Any, node: Any, text: str | None): + assert layout + if not self.is_output and self.is_linked: + layout.label(text=text or self.name) + else: + layout.prop(self, "value", text=text or self.name) + + def _draw_as_ro(self, context: Any, layout: Any, node: Any, text: str | None): + layout.label(text=text or self.name) + + def _draw_as_rw(self, context: Any, layout: Any, node: Any, text: str | None): + layout.prop(self, "value", text=text or self.name) + +class ReadonlySocket(): + def draw(self, context: Any, layout: Any, node: Any, text: str | None): + return self._draw_as_ro(context, layout, node, text) # pyright: ignore[reportAttributeAccessIssue] + +@register_class +class SLICESOCK_Object(InputNodeSocket): + bl_label = "Object" + + value: PointerProperty( + name="Object", + type=bpy.types.Object, + ) # pyright: ignore[reportInvalidTypeForm] + color = (0.9, 0.7, 0.2, 1.0) + + +class SLICESOCK_Float_Common(InputNodeSocket): + bl_label = "Float" + + value: FloatProperty(name="Float", default=0) # pyright: ignore[reportInvalidTypeForm] + color = (161/255, 161/255, 161/255, 1.0) + +@register_class +class SLICESOCK_FloatRo(ReadonlySocket, SLICESOCK_Float_Common): ... +@register_class +class SLICESOCK_Float(SLICESOCK_Float_Common): ... + + +class SLICESOCK_Int_Common(InputNodeSocket): + bl_label = "Integer" + + value: IntProperty(name="Int", default=0) # pyright: ignore[reportInvalidTypeForm] + color = (161/255, 161/255, 161/255, 1.0) + +@register_class +class SLICESOCK_Int(SLICESOCK_Int_Common): ... +@register_class +class SLICESOCK_IntRo(ReadonlySocket, SLICESOCK_Int_Common): ... + + +@register_class +class SLICESOCK_String(InputNodeSocket): + bl_label = "String" + + value: StringProperty(name="String", default="") # pyright: ignore[reportInvalidTypeForm] + color = (0.4, 0.7, 1.0, 1.0) + +@register_class +class SLICESOCK_FilePath(InputNodeSocket): + bl_label = "Filepath" + + value: StringProperty(name="Filepath", default="", subtype='FILE_PATH') # pyright: ignore[reportInvalidTypeForm] + color = (0.4, 0.7, 1.0, 1.0) + +@register_class +class SLICESOCK_VectorRotation_Common(InputNodeSocket): + bl_label = "VectorRotation" + + value: bpy.props.FloatVectorProperty(name="Vector", size=3, default=(0.0, 0.0, 0.0), subtype='EULER') # pyright: ignore[reportInvalidTypeForm] + color = (0.39, 0.39, 0.78, 1.0) +@register_class +class SLICESOCK_VectorRotation(SLICESOCK_VectorRotation_Common): ... +@register_class +class SLICESOCK_VectorRotationRo(ReadonlySocket, SLICESOCK_VectorRotation_Common): ... + +@register_class +class SLICESOCK_Mesh(LabelNodeSocket): + bl_label = "Mesh" + default = None + color = (0, 214/255, 163/255, 1.0) + +@register_class +class SLICESOCK_Paths(LabelNodeSocket): + bl_label = "Paths" + default = SlicePaths() + color = (214/255, 0, 120/255, 1.0) + +@register_class +class SLICESOCK_Gcode(LabelNodeSocket): + bl_label = "Gcode" + default = "" + color = (1., 1., 1., 1.) + +@register_class +class SLICESOCK_Intrinsics(LabelNodeSocket): + bl_label = "Intrinsics" + default = None + color = (0.2, 0.2, 0.2, 1.0) diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py new file mode 100644 index 0000000..2ebee4f --- /dev/null +++ b/core/nodeslicer/tree.py @@ -0,0 +1,75 @@ +from __future__ import annotations +from typing import TYPE_CHECKING +import bpy +from bpy.types import NodeTree, Operator, Menu + +from ...registry import register_class + +if TYPE_CHECKING: + from bpy.stub_internal.rna_enums import OperatorReturnItems + +@register_class +class SLICETREE_NodeTree(NodeTree): + bl_idname = "SLICETREE_NodeTree" + bl_label = "NodeSlicer Tree" + bl_icon = "NODETREE" + +@register_class +class SLICENODE_OT_Execute(Operator): + bl_idname = "slicenode.execute" + bl_label = "Execute" + bl_description = "Execute" + + node_name: bpy.props.StringProperty() # pyright: ignore[reportInvalidTypeForm] + + def execute(self, context) -> set['OperatorReturnItems']: + node_tree = context.space_data.edit_tree # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + node = node_tree.nodes[self.node_name] + _ = node.evaluate() + + self.report({"INFO"}, f"Execution Successful") + return {"FINISHED"} + +@register_class +class SLICENODE_MT_Menu(Menu): + bl_idname = "SLICENODE_MT_Menu" + bl_label = "SliceNodes" + + def draw(self, context): + layout = self.layout + assert layout + layout.operator("node.add_node", text="String").type = "SLICENODE_InputString" + layout.operator("node.add_node", text="Float").type = "SLICENODE_InputFloat" + layout.operator("node.add_node", text="Integer").type = "SLICENODE_InputInt" + layout.operator("node.add_node", text="Rotation").type = "SLICENODE_InputVectorRotation" + layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath" + layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject" + layout.operator("node.add_node", text="Printer Intrinsics").type = "SLICENODE_InputIntrinsics" + layout.operator("node.add_node", text="Test").type = "SLICENODE_Test" + layout.operator("node.add_node", text="Get Mesh").type = "SLICENODE_GetMesh" + layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths" + layout.separator() + layout.operator("node.add_node", text="Slice Shells").type = "SLICENODE_SliceShells" + layout.operator("node.add_node", text="Buffer Shells").type = "SLICENODE_BufferShells" + layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple" + layout.operator("node.add_node", text="Infill").type = "SLICENODE_Infill" + layout.operator("node.add_node", text="Paths to Gcode").type = "SLICENODE_PathsToGcode" + layout.separator() + layout.operator("node.add_node", text="Gcode Block").type = "SLICENODE_GcodeBlock" + layout.operator("node.add_node", text="Gcode Helper").type = "SLICENODE_GcodeHelper" + layout.operator("node.add_node", text="Load Gcode").type = "SLICENODE_LoadGcode" + layout.separator() + layout.operator("node.add_node", text="Join Gcode").type = "SLICENODE_JoinGcode" + layout.operator("node.add_node", text="Join Mesh").type = "SLICENODE_JoinMesh" + layout.operator("node.add_node", text="Join Paths").type = "SLICENODE_JoinPaths" + layout.operator("node.add_node", text="Boolean Paths").type = "SLICENODE_JoinSlices" + layout.separator() + layout.operator("node.add_node", text="Gcode to File").type = "SLICENODE_GcodeToFile" + layout.operator("node.add_node", text="Gcode to Mesh").type = "SLICENODE_GcodeToMesh" + +def draw_add_menu(self, context): + space = context.space_data + tree = getattr(space, "edit_tree", None) + if tree and tree.bl_idname == "SLICETREE_NodeTree": + self.layout.separator() + self.layout.menu("SLICENODE_MT_Menu") diff --git a/gcode_snippets/mk3s_footer.gcode b/gcode_snippets/mk3s_footer.gcode new file mode 100644 index 0000000..c09f323 --- /dev/null +++ b/gcode_snippets/mk3s_footer.gcode @@ -0,0 +1,19 @@ +M107 +;TYPE:Custom +; Filament-specific end gcode +G1 Z21 F720 ; Move print head up +G1 X0 Y200 F3600 ; park +G1 Z69 F720 ; Move print head further up +G4 ; wait +M221 S100 ; reset flow +M900 K0 ; reset LA + +M104 S0 ; turn off temperature +M140 S0 ; turn off heatbed +M107 ; turn off fan +M84 ; disable motors + +M73 P100 R0 +M73 Q100 S0 + +M84 ; disable motors diff --git a/gcode_snippets/mk3s_header.gcode b/gcode_snippets/mk3s_header.gcode new file mode 100644 index 0000000..57d740f --- /dev/null +++ b/gcode_snippets/mk3s_header.gcode @@ -0,0 +1,55 @@ +; generated by PrusaSlicer 2.9.4 on 2026-05-10 at 11:51:50 UTC + +; + +; external perimeters extrusion width = 0.45mm +; perimeters extrusion width = 0.45mm +; infill extrusion width = 0.45mm +; solid infill extrusion width = 0.45mm +; top infill extrusion width = 0.40mm +; first layer extrusion width = 0.42mm + +M73 P0 R24 +M73 Q0 S24 +M201 X1000 Y1000 Z200 E5000 ; sets maximum accelerations, mm/sec^2 +M203 X200 Y200 Z12 E120 ; sets maximum feedrates, mm / sec +M204 S1250 T1250 ; sets acceleration (S) and retract acceleration (R), mm/sec^2 +M205 X8.00 Y8.00 Z0.40 E4.50 ; sets the jerk limits, mm/sec +M205 S0 T0 ; sets the minimum extruding and travel feed rate, mm/sec + +; printing object Cube.016 id:0 copy 0 +; stop printing object Cube.016 id:0 copy 0 + +;TYPE:Custom +M862.3 P "MK3S" ; printer model check +M862.1 P0.4 ; nozzle diameter check +M115 U3.14.1 ; tell printer latest fw version +G90 ; use absolute coordinates +M83 ; extruder relative mode +M104 S215 ; set extruder temp +M140 S60 ; set bed temp +M190 S60 ; wait for bed temp +M109 S215 ; wait for extruder temp +G28 W ; home all without mesh bed level +G80 X112.246 Y92.2458 W25.5083 H25.5083 ; mesh bed levelling + +G1 Z0.2 F720 +G1 Y-3 F1000 ; go outside print area +G92 E0 +G1 X60 E9 F1000 ; intro line +G1 X100 E12.5 F1000 ; intro line + +G92 E0 +M221 S95 + +; Don't change E values below. Excessive value can damage the printer. + +M907 E538 ; set extruder motor current +G21 ; set units to millimeters +G90 ; use absolute coordinates +M83 ; use relative distances for extrusion + +M900 K0.05 ; Filament gcode LA 1.5 +M900 K30 ; Filament gcode LA 1.0 + +M108 diff --git a/gcode_snippets/redefine_z_0-1.gcode b/gcode_snippets/redefine_z_0-1.gcode new file mode 100644 index 0000000..82535ac --- /dev/null +++ b/gcode_snippets/redefine_z_0-1.gcode @@ -0,0 +1,2 @@ +G0 Z0.1 F720 ; Move to the desired physical Z clearance height +G92 Z0 ; Redefine this position as Z=0