nodeslicer/core/nodeslicer/nodes.py

1624 lines
59 KiB
Python

from __future__ import annotations
import os
import subprocess
from subprocess import CompletedProcess
import tempfile
import shlex
import shutil
from math import floor
from pathlib import Path
from typing import Any, TypeAlias
import bpy
import numpy as np
from mathutils import Euler
from .bpy_ns_types import 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 import ConfigWriter
from ...infra.prusaslicer_profiles.cache import LocalCache
from ...infra.prusaslicer_profiles.models import Profile
from ...infra.networking.airtable_backend import AirtableRecord
from ...infra._3mf_writer._3mf import prepare_3mf
class SliceIntrinsics:
bed_x: float
bed_y: float
feedrate: float
extrusion_rate: float
def __init__(self, bed_x=250.0, bed_y=250.0, feedrate=1200.0, extrusion_rate=0.017):
self.bed_x = bed_x
self.bed_y = bed_y
self.feedrate = feedrate
self.extrusion_rate = extrusion_rate
# Basic Inputs
class InputNode(SliceNode):
input_type: str = ""
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if "bl_label" not in cls.__dict__: cls.bl_label = cls.input_type
def init(self, context) -> None:
self.add_output(f"SLICESOCK_{self.bl_label}")
def evaluate_output(self, socket_name: str, **_kwargs):
return self.outputs.get(socket_name).value
@register_class
class SLICENODE_InputString(InputNode):
input_type = "String"
@register_class
class SLICENODE_InputFloat(InputNode):
input_type = "Float"
@register_class
class SLICENODE_InputInt(InputNode):
input_type = "Int"
@register_class
class SLICENODE_InputVectorRotation(InputNode):
input_type = "VectorRotation"
@register_class
class SLICENODE_InputObject(InputNode):
input_type = "Object"
bl_icon = "OBJECT_DATA"
@register_class
class SLICENODE_InputOverrides(InputNode):
input_type = "Overrides"
bl_icon = "OBJECT_DATA"
@register_class
class SLICENODE_PrusaslicerConf(SliceNode):
bl_label = "PrusaSlicer Config"
bl_icon = "PROPERTIES"
_cache = None
@classmethod
def get_cache(cls):
if cls._cache is None:
from ...infra.prusaslicer_profiles import LocalCache
cls._cache = LocalCache()
return cls._cache
def ensure_profiles_loaded(self):
cache = self.get_cache()
if not cache.profiles:
# Use a path relative to the addon directory
import os
addon_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
profiles_path = os.path.join(addon_dir, "profiles", "prusaslicer")
if os.path.isdir(profiles_path):
cache.load([profiles_path])
if self.printer_id != 'NONE':
cache.evaluate_compatibility({self.printer_id}, set(cache.vendors))
def update_profiles(self, context):
cache = self.get_cache()
if self.printer_id != 'NONE':
cache.evaluate_compatibility({self.printer_id}, set(cache.vendors))
notify_tree_changed(self, context)
def 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()]
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()]
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='NONE', update=update_profiles, search=search_printers) # pyright: ignore[reportInvalidTypeForm]
filament_id: NfStringProperty(name="Filament", default='NONE', search=search_filaments) # pyright: ignore[reportInvalidTypeForm]
print_id: NfStringProperty(name="Print", default='NONE', 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 != 'NONE':
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 == 'NONE' or self.filament_id == 'NONE' or self.print_id == 'NONE':
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)
for so in sc.objects:
so.assign_modifiers(modifiers)
so.object_type = part_type
so.extruder = extruder
return sc
@register_class
class SLICENODE_GetPaths(SliceNode):
bl_label = "Get Paths"
def init(self, context):
self.add_input("SLICESOCK_Object")
self.add_output("SLICESOCK_Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
obj: Any = resolve_input(self.inputs.get("Object"))
so: list[np.ndarray] = capture_mesh_edges(obj)
return SlicePaths(so)
@register_class
class SLICENODE_SliceShells(SliceNode):
bl_label = "Slice Shells"
def init(self, context):
self.add_input("SLICESOCK_Mesh")
self.add_input("SLICESOCK_Float", "Layer Height").value = 0.2
self.add_output("SLICESOCK_Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
slicing_collection: SlicingCollection | None = resolve_input(self.inputs.get("Mesh"))
layer_height: float = resolve_input(self.inputs.get("Layer Height"))
if not slicing_collection: return SlicePaths()
merged_object = SlicingObject()
coll: SlicingCollection = self.resolve("Mesh")
if coll:
for obj in coll.objects:
merged_object.mesh = np.concatenate([merged_object.mesh, obj.mesh], axis=0)
min = merged_object.mesh[:,0:3,2:3].min()
min = floor(min / layer_height) * layer_height
max = merged_object.mesh[:,0:3,2:3].max()
z_values = np.arange(min, max + layer_height, layer_height)
slices_group: list[list[np.ndarray]] | None = slice_at_range([merged_object.mesh], z_values)
assert slices_group
slices = slices_group[0]
return SlicePaths(slices)
@register_class
class SLICENODE_BufferShells(SliceNode):
bl_label = "Buffer Shells"
shape_option: NfEnumProperty(
name="Miter",
items=[
('round', "Round", ""),
('square', "Square", ""),
('flat', "Flat", "")
]
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Distance").value = 0
self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "shape_option")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = resolve_input(self.inputs.get("Paths"))
distance: float = resolve_input(self.inputs.get("Distance")) or 0.0
rotation: Any = resolve_input(self.inputs.get("Rotation")) or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
if any(rotation):
# De-rotate shells to Z-perpendicular plane
mat = Euler(rotation).to_matrix().inverted()
mat_np = np.array(mat)
prepared_paths = [s @ mat_np.T for s in paths]
else:
prepared_paths = paths
# Buffer
res_paths, res_groups = slice.buffer_slice(prepared_paths, distance, miter=self.shape_option)
if any(rotation):
# Re-rotate shells back
mat_inv = Euler(rotation).to_matrix()
mat_inv_np = np.array(mat_inv)
final_paths = [s @ mat_inv_np.T for s in res_paths]
else:
final_paths = res_paths
return SlicePaths(final_paths, res_groups)
@register_class
class SLICENODE_BufferShellsMultiple(SliceNode):
bl_label = "Buffer Shells Multiple"
shape_option: NfEnumProperty(
name="Miter",
items=[
('round', "Round", ""),
('square', "Square", ""),
('flat', "Flat", "")
]
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Distance").value = 0
self.add_input("SLICESOCK_Float", "Width Step").value = 0.4
self.add_input("SLICESOCK_Int", "Count").value = 2
self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "shape_option")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = resolve_input(self.inputs.get("Paths"))
base_distance: float = resolve_input(self.inputs.get("Distance")) or 0.0
width_step: float = resolve_input(self.inputs.get("Width Step")) or 0.0
count: int = resolve_input(self.inputs.get("Count")) or 1
rotation: Any = resolve_input(self.inputs.get("Rotation")) or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
if any(rotation):
mat = Euler(rotation).to_matrix().inverted()
mat_np = np.array(mat)
prepared_paths = [s @ mat_np.T for s in paths]
else:
prepared_paths = paths
all_res_paths = []
all_res_groups = []
for i in range(count):
dist = base_distance + (i * width_step)
res_paths, res_groups = slice.buffer_slice(prepared_paths, dist, miter=self.shape_option)
all_res_paths.extend(res_paths)
all_res_groups.extend(res_groups)
if any(rotation):
mat_inv = Euler(rotation).to_matrix()
mat_inv_np = np.array(mat_inv)
final_paths = [s @ mat_inv_np.T for s in all_res_paths]
else:
final_paths = all_res_paths
return SlicePaths(final_paths, all_res_groups)
@register_class
class SLICENODE_Infill(SliceNode):
bl_label = "Infill"
infill_type: NfEnumProperty(
name="Type",
items=[
('GRID', "Grid", "Cross-hatch grid"),
('RECTILINEAR', "Rectilinear", "Alternating parallel lines"),
],
default='GRID',
update=notify_tree_changed
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Density").value = 2.0
self.add_input("SLICESOCK_Float", "Infill Rotation").value = 0.0
self.add_input("SLICESOCK_VectorRotation", "Layer Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "infill_type", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = resolve_input(self.inputs.get("Paths"))
density: float = resolve_input(self.inputs.get("Density")) or 2.0
infill_rotation: float = resolve_input(self.inputs.get("Infill Rotation")) or 0.0
rotation: Any = resolve_input(self.inputs.get("Layer Rotation")) or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
p: SlicePaths
if any(rotation):
# De-rotate shells to Z-perpendicular plane
mat = Euler(rotation).to_matrix().inverted()
mat_np = np.array(mat)
p = SlicePaths([s @ mat_np.T for s in paths], paths.groups)
else:
p = paths
res_paths, res_groups = slice.infill_slice(
p,
density,
rotation_deg=infill_rotation,
infill_type=self.infill_type # pyright: ignore[reportOptionalMemberAccess]
)
if any(rotation):
# Re-rotate shells back
mat_inv = Euler(rotation).to_matrix()
mat_inv_np = np.array(mat_inv)
p = SlicePaths([s @ mat_inv_np.T for s in res_paths], res_groups)
else:
p = SlicePaths(res_paths, res_groups)
return p
@register_class
class SLICENODE_PathsToGcode(SliceNode):
bl_label = "Paths to Gcode"
use_header: NfBoolProperty(name="Header", default=True) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Intrinsics")
self.add_output("SLICESOCK_GcodeRo")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "use_header")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
paths: list[np.ndarray] = resolve_input(self.inputs.get("Paths"))
intrinsics: SliceIntrinsics | None = resolve_input(self.inputs.get("Intrinsics"))
pather: Pather = Pather(paths)
if intrinsics:
# Note: Pather currently uses bed_center_x/y as its internal attributes.
# We map the bed size center to these attributes if provided.
pather.bed_center_x = intrinsics.bed_x / 2.0
pather.bed_center_y = intrinsics.bed_y / 2.0
pather.feedrate = intrinsics.feedrate
pather.extrusion_rate = intrinsics.extrusion_rate
gcode: str = pather.to_gcode(include_header=self.use_header)
return gcode
@register_class
class SLICENODE_GcodeBlock(SliceNode):
bl_label = "Gcode Block"
def init(self, context):
self.add_output("SLICESOCK_Gcode", "Gcode")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
return self.outputs['Gcode'].value
@register_class
class SLICENODE_GcodeHelper(SliceNode):
bl_label = "Gcode Helper"
command: NfEnumProperty(
name="Command",
items=[
('G0', "G0: Rapid Move", "Fast movement without extrusion"),
('G1', "G1: Linear Move", "Movement with extrusion"),
('G4', "G4: Dwell", "Wait for time (e.g. P500 for 500ms or S5 for 5s)"),
('G28', "G28: Home All", "Home all axes"),
('G90', "G90: Absolute Pos", "Set to absolute positioning"),
('G91', "G91: Relative Pos", "Set to relative positioning"),
('G92', "G92: Set Position", "Set the current position to specific values (e.g. G92 E0)"),
('M0', "M0: Stop/Wait", "Stop and wait for user button press"),
('M25', "M25: Pause SD", "Pause SD print (Marlin/RepRap)"),
('M104', "M104: Set Temp", "Set hotend temperature"),
('M109', "M109: Wait Temp", "Set hotend temperature and wait"),
('M140', "M140: Set Bed Temp", "Set bed temperature"),
('M190', "M190: Wait Bed Temp", "Set bed temperature and wait"),
('M106', "M106: Fan On", "Turn on cooling fan"),
('M107', "M107: Fan Off", "Turn off cooling fan"),
('M117', "M117: Message", "Display message on printer LCD"),
('M220', "M220: Speed Factor", "Set speed percentage (e.g. S110 for 110%)"),
('M221', "M221: Flow Factor", "Set flow percentage (e.g. S95 for 95%)"),
('M300', "M300: Beep", "Play beep sound"),
('M400', "M400: Wait Finish", "Wait for all current moves to finish"),
('M600', "M600: Color Change", "Filament change / Color change"),
('M82', "M82: Abs Extrusion", "Set extrusion to absolute mode"),
('M83', "M83: Rel Extrusion", "Set extrusion to relative mode"),
('PAUSE', "PAUSE: Klipper Pause", "Pause print (Klipper macro)"),
('T', "T: Change Extruder", "Switch to a different extruder (e.g. T0, T1)"),
],
default='G1'
) # pyright: ignore[reportInvalidTypeForm]
parameters: NfStringProperty(name="Params", default="") # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_output("SLICESOCK_GcodeRo")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "command", text="")
# Commands that don't need parameters
no_params = {'G28', 'G90', 'G91', 'M0', 'M25', 'M107', 'M400', 'M600', 'M82', 'M83', 'PAUSE'}
if self.command not in no_params:
layout.prop(self, "parameters", text="Parameters")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
cmd = self.command
params = self.parameters.strip()
if params:
# T commands usually don't have a space (e.g. T0)
if cmd == 'T':
return f"{cmd}{params}"
return f"{cmd} {params}"
return cmd
### Joiners
class SliceNodeJoiner(SliceNode):
socket_type: str = ""
output_socket_type: str = ""
def init(self, context):
self.add_output(self.output_socket_type or self.socket_type)
self.add_multi_input(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_JoinOverrides(SliceNodeJoiner):
bl_label = "Join Overrides"
socket_type = "SLICESOCK_Overrides"
output_socket_type = "SLICESOCK_OverridesRo"
def evaluate_output(self, socket_name: str, **_kwargs) -> dict[str, str]:
merged: dict[str, str] = {}
for input in self.inputs:
val = resolve_input(input)
if isinstance(val, dict):
merged.update(val)
return merged
@register_class
class SLICENODE_JoinSlices(SliceNodeJoiner):
bl_label = "Join Slices"
socket_type = "SLICESOCK_Paths"
operation: NfEnumProperty(
name="Operation",
items=[
('UNION', "Union", "Union of the slices"),
('INTERSECTION', "Intersection", "Intersection of the slices"),
('DIFFERENCE', "Subtract", "Clipping of the first slice"),
('XOR', "Exclusive or", "Exclusive or of the slices"),
],
default='UNION'
) # pyright: ignore[reportInvalidTypeForm]
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "operation", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
accumulated: SlicePaths = SlicePaths()
current: SlicePaths = SlicePaths()
for input in self.inputs:
current = resolve_input(input)
if not current:
continue
if not accumulated:
accumulated = current
current = SlicePaths()
continue
# Boolean operation loses specific per-segment grouping as it merges polygons.
# We return a new SlicePaths from the result.
res = slice.boolean_slice(accumulated, current, self.operation)
accumulated = SlicePaths(res)
current = SlicePaths()
return accumulated
@register_class
class SLICENODE_JoinMesh(SliceNodeJoiner):
bl_label = "Join Mesh Parts"
socket_type = "SLICESOCK_Mesh"
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection:
coll: SlicingCollection
merged_coll = resolve_input(self.inputs[0])
for input in self.inputs[1:]:
coll = resolve_input(input)
if coll:
merged_coll.objects += coll.objects
return merged_coll
@register_class
class SLICENODE_JoinMeshGroups(SliceNodeJoiner):
bl_label = "Join Mesh Groups"
socket_type = "SLICESOCK_Mesh"
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingGroup:
group: SlicingGroup = SlicingGroup([])
for input in self.inputs:
coll = resolve_input(input)
if coll:
group.collections[coll.objects[0].name] = coll
return group
@register_class
class SLICENODE_JoinPaths(SliceNodeJoiner):
bl_label = "Join Paths"
socket_type = "SLICESOCK_Paths"
mode: NfEnumProperty(
name="Mode",
items=[
('EXTEND', "Extend", "Append paths sequentially"),
('ZIP', "Zip", "Interleave paths from all inputs"),
],
default='EXTEND'
) # pyright: ignore[reportInvalidTypeForm]
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "mode", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
inputs_data: list[SlicePaths] = []
for input in self.inputs:
paths = resolve_input(input)
if paths:
inputs_data.append(paths)
if not inputs_data:
return SlicePaths()
if self.mode == 'EXTEND':
merged = SlicePaths()
for paths in inputs_data:
merged.extend_with_groups(paths)
return merged
elif self.mode == 'ZIP':
# Group-based Zip: sequentially add each group from all inputs.
# E.g. Group 1 (In1), Group 1 (In2), Group 2 (In1), Group 2 (In2)...
all_groups = set()
grouped_inputs = []
for p_list in inputs_data:
d = {}
for i, g in enumerate(p_list.groups):
all_groups.add(g)
d.setdefault(g, []).append(p_list[i])
grouped_inputs.append(d)
sorted_groups = sorted(list(all_groups))
merged = SlicePaths()
for g in sorted_groups:
for d in grouped_inputs:
group_paths = d.get(g, [])
for p in group_paths:
merged.append_with_group(p, g)
return merged
return SlicePaths()
@register_class
class SLICENODE_JoinExecution(SliceNodeJoiner):
bl_label = "Join Execution"
socket_type = "SLICESOCK_ExecutionLink"
def evaluate_output(self, socket_name: str, **_kwargs) -> list[ExecSliceNode]:
nodes: list[ExecSliceNode] = []
for input in self.inputs:
res = resolve_input(input)
if isinstance(res, ExecSliceNode):
nodes.append(res)
elif isinstance(res, list):
nodes.extend(res)
return nodes
### I/O
@register_class
class SLICENODE_LoadGcode(SliceNode):
bl_label = "Load Gcode File"
def init(self, context):
self.add_input("SLICESOCK_FilePath", "Filepath")
self.add_output("SLICESOCK_GcodeRo")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
path = resolve_input(self.inputs.get("Filepath"))
if not path: return ""
abs_path = bpy.path.abspath(path)
if os.path.exists(abs_path):
with open(abs_path, 'r', encoding='utf-8') as f:
return f.read()
return ""
class ExecSliceNode(SliceNode):
button_label: str = 'Execute'
def _init_exec(self):
self.add_output("SLICESOCK_ExecutionLink")
def draw_buttons(self, context, layout):
assert layout
layout.operator("slicenode.execute", text=self.button_label).node_name = self.name
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name == "Execution Link":
return self
return None
@register_class
class SLICENODE_PrinterList(SliceNode):
bl_label = "Printer List"
def _refresh(self):
import json
if not self.printers_json_path:
return
abs_path = bpy.path.abspath(self.printers_json_path)
if not os.path.exists(abs_path):
return
try:
with open(abs_path, 'r', encoding='utf-8') as f:
data = json.load(f)
except Exception:
return
printers = data.get("printers", [])
printer_names = set()
for p_data in printers:
name = p_data.get("name", "Unknown")
printer_names.add(name)
sock = self.outputs.get(name)
if not sock:
sock = self.add_output("SLICESOCK_Printer", name)
# Update properties
sock.label = name
sock.host = str(p_data.get("ip", "localhost"))
sock.port = int(p_data.get("port", 80))
sock.prefix = str(p_data.get("prefix", ""))
sock.username = str(p_data.get("username", ""))
sock.password = str(p_data.get("password", ""))
sock.host_type = str(p_data.get("host_type", "PRUSALINK")).upper()
# Remove stale sockets
for sock in list(self.outputs):
if sock.name not in printer_names:
self.outputs.remove(sock)
def update_config(self, context):
self._refresh()
printers_json_path: NfStringProperty(name="Config File", default="", subtype='FILE_PATH', update=update_config) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
pass
def draw_buttons(self, context, layout):
layout.prop(self, "printers_json_path") # pyright: ignore[reportOptionalMemberAccess]
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
self._refresh()
sock = self.outputs.get(socket_name)
if sock:
return sock.value
return None
@register_class
class SLICENODE_GcodeToFile(ExecSliceNode):
bl_label = "Gcode To File"
button_label = "Generate File"
def init(self, context):
self._init_exec()
self.add_input("SLICESOCK_DirPath", "Folder")
self.add_multi_input("SLICESOCK_Gcode", "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 = resolve_input(self.inputs.get("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 = resolve_input(self.inputs.get("Gcode"))
if not gcode:
return 'ok'
name = self.text_block_name
text_block = bpy.data.texts.get(name)
if text_block is None:
text_block = bpy.data.texts.new(name=name)
text_block.from_string(gcode)
# Attempt to show in an open text editor
assert bpy.context.window_manager
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
if area.type == 'TEXT_EDITOR':
for space in area.spaces:
if space.type == 'TEXT_EDITOR':
space.text = text_block
return 'ok'
@register_class
class SLICENODE_StartPrint(ExecSliceNode):
bl_label = "Start Print"
button_label = "Send to Printer"
def init(self, context):
self._init_exec()
self.add_input("SLICESOCK_Gcode")
self.add_input("SLICESOCK_Printer")
def evaluate(self, operator=None):
from ...infra.networking.printer_backends import (
PrinterConfig,
PrusaLinkBackend,
CrealityBackend,
MoonrakerBackend
)
import tempfile
from pathlib import Path
gcode: str = resolve_input(self.inputs.get("Gcode"))
config: PrinterConfig | None = resolve_input(self.inputs.get("Printer"))
if not gcode:
if operator: operator.report({'ERROR'}, "No Gcode to send")
return 'no gcode'
if not config:
if operator: operator.report({'ERROR'}, "No Printer configuration")
return 'no printer'
# Instantiate correct backend
backend = None
if config.host_type == 'PRUSALINK':
backend = PrusaLinkBackend(config.host, config.port, config.prefix, api_key=config.password)
elif config.host_type == 'CREALITY':
backend = CrealityBackend(config.host, config.port, config.prefix)
elif config.host_type == 'MOONRAKER':
backend = MoonrakerBackend(config.host, config.port, config.prefix)
if not backend:
if operator: operator.report({'ERROR'}, f"Unsupported host type: {config.host_type}")
return 'unsupported'
# Write to temp file to send
with tempfile.NamedTemporaryFile(mode='w', suffix='.gcode', delete=False) as tf:
tf.write(gcode)
temp_path = Path(tf.name)
try:
print_name = f"NodeSlicer_{config.label}.gcode".replace(" ", "_")
backend.start_print(temp_path, print_name)
if operator: operator.report({'INFO'}, f"Print started on {config.label}")
except Exception as e:
if operator: operator.report({'ERROR'}, f"Failed to start print: {str(e)}")
return 'error'
finally:
if temp_path.exists():
temp_path.unlink()
return 'ok'
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_input("SLICESOCK_Gcode", "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:
if operator: operator.report({'ERROR'}, "No Gcode to send")
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)))
# 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})")
return 'ok'
except Exception as e:
if operator: operator.report({'ERROR'}, f"{self._db_name} Error: {str(e)}")
return 'error'
finally:
if temp_path.exists():
temp_path.unlink()
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]
gcode_result: NfStringProperty(name="Gcode Result", default="") # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Mesh", "Mesh")
self.add_input("SLICESOCK_PrusaslicerConf", "Config")
self.add_input("SLICESOCK_Overrides", "Overrides")
self.add_output("SLICESOCK_GcodeRo")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "prusaslicer_executable")
def evaluate_output(self, socket_name: str, **kwargs) -> Any:
if socket_name == "Gcode":
self.evaluate()
return self.gcode_result
return super().evaluate_output(socket_name, **kwargs)
def evaluate(self, operator=None):
self.gcode_result = ""
mesh_data: SlicingCollection | SlicingGroup = resolve_input(self.inputs.get("Mesh"))
conf_data = resolve_input(self.inputs.get("Config"))
overrides = resolve_input(self.inputs.get("Overrides"))
if not mesh_data:
if operator: operator.report({'ERROR'}, "No Mesh data connected")
return 'no object'
if not conf_data:
if operator: operator.report({'ERROR'}, "No Config connected")
return 'no config'
# Extract data from config dict
printer_id = conf_data['printer']
filament_ids = conf_data['filament']
print_id = conf_data['print']
cache = conf_data['cache']
# Generate the SlicingGroup
if isinstance(mesh_data, SlicingGroup):
slicing_group = mesh_data
else:
slicing_group = SlicingGroup([])
slicing_group.collections = {mesh_data.objects[0].name: mesh_data}
# Offset by the bed center
def parse_bed_center(bed_shape_str: str) -> np.ndarray:
try:
points = []
for p in bed_shape_str.split(','):
x, y = map(float, p.split('x'))
points.append((x, y))
points_np = np.array(points)
return (points_np.min(axis=0) + points_np.max(axis=0)) / 2.0
except:
return np.array([125.0, 105.0])
bed_shape = cache.profiles[printer_id].all_conf_dict.get('bed_shape', '0x0,250x0,250x210,0x210')
bed_center = parse_bed_center(bed_shape)
a_z = -slicing_group.min_z
offset = np.array([bed_center[0], bed_center[1], a_z]) - np.array([slicing_group.center_xy[0], slicing_group.center_xy[1], 0])
slicing_group.offset(offset)
# Prepare overrides for generate_conf_writer
# The format expected by generate_conf_writer is overrides={'key': {'value': 'val'}}
writer_overrides = {k: {'value': v} for k, v in overrides.items()}
# Generate the ConfigWriter
conf_writer: ConfigWriter = cache.generate_conf_writer(
printer_id,
filament_ids,
print_id,
overrides=writer_overrides
)
binary_gcode_value = conf_writer.config_dict['binary_gcode']
conf_writer.config_dict['binary_gcode'] = '0'
# Prepare 3mf path
temp_dir = Path(tempfile.gettempdir())
output_path = temp_dir / f"tmp.3mf"
try:
prepare_3mf(output_path, slicing_group, conf_writer, z_gcodes=[])
# Actual slicing
exe_str = self.prusaslicer_executable
if not exe_str:
if operator: operator.report({'ERROR'}, "PrusaSlicer executable path is empty")
return 'error'
# Parse command (handles multi-word commands like flatpak)
cmd_parts = shlex.split(exe_str)
if not cmd_parts:
if operator: operator.report({'ERROR'}, "Invalid PrusaSlicer command")
return 'error'
# Try to resolve the first part as a path if it's not a command in PATH
if not shutil.which(cmd_parts[0]):
abs_path = bpy.path.abspath(cmd_parts[0])
if os.path.exists(abs_path):
cmd_parts[0] = abs_path
# Final check if command exists
if not shutil.which(cmd_parts[0]):
if operator: operator.report({'ERROR'}, f"Command or executable not found: {cmd_parts[0]}")
self.gcode_result = ""
return 'error'
gcode_path = output_path.with_suffix(".gcode")
# Command: <exe> --export-gcode <3mf_path> --output <gcode_path>
cmd = cmd_parts + ["--export-gcode", str(output_path), "--output", str(gcode_path)]
# Run PrusaSlicer
process = subprocess.run(cmd, capture_output=True, text=True)
if process.returncode != 0:
if operator: operator.report({'ERROR'}, f"PrusaSlicer failed: {process.stderr}")
self.gcode_result = ""
return 'error'
# Read G-code
if gcode_path.exists():
with open(gcode_path, 'r', encoding='utf-8') as f:
self.gcode_result = f.read()
self.gcode_result += f"\n; binary_gcode = {binary_gcode_value}\n"
output_path.unlink()
if operator:
operator.report({'INFO'}, f"Slicing complete. G-code generated.")
else:
if operator: operator.report({'ERROR'}, "G-code file was not generated by PrusaSlicer")
self.gcode_result = ""
return 'error'
except Exception as e:
self.gcode_result = ""
if operator: operator.report({'ERROR'}, f"Failed to slice: {str(e)}")
import traceback
traceback.print_exc()
return 'error'
return 'ok'
@register_class
class SLICENODE_GcodeToMesh(ExecSliceNode):
bl_label = "Gcode Preview"
button_label = "Update Preview"
show_e_0: NfBoolProperty(name="Show Zero Extrusions", default=False) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self._init_exec()
self.add_input("SLICESOCK_Gcode", "Gcode")
self.add_input("SLICESOCK_String", "Name")
def draw_buttons(self, context, layout):
super().draw_buttons(context, layout)
layout.prop(self, 'show_e_0')
def evaluate(self, operator=None):
gcode: str = self.resolve("Gcode")
mesh_name: str = self.resolve("Name")
if not gcode:
if mesh_name in bpy.data.objects:
obj = bpy.data.objects[mesh_name]
if obj.type == 'MESH':
obj.data.clear_geometry()
return 'empty'
segments = []
last_x, last_y, last_z = 0.0, 0.0, 0.0
last_e = 0.0
offset_x, offset_y = 0.0, 0.0
for line in gcode.splitlines():
# Parse metadata from comments
if ';' in line:
comment = line.split(';', 1)[1].strip()
if comment.startswith("OFFSET_X="):
try: offset_x = float(comment.split('=')[1])
except ValueError: pass
elif comment.startswith("OFFSET_Y="):
try: offset_y = float(comment.split('=')[1])
except ValueError: pass
line_no_comment = line.split(';')[0].strip()
if not line_no_comment: continue
parts = line_no_comment.split()
cmd = parts[0].upper()
params = {}
for p in parts[1:]:
if len(p) < 2: continue
try:
# Scale coordinates by 0.001 (mm to Blender units)
val = float(p[1:])
if p[0].upper() in 'XYZ':
val *= 0.001
params[p[0].upper()] = val
except ValueError: continue
new_x = params.get('X', last_x)
new_y = params.get('Y', last_y)
new_z = params.get('Z', last_z)
new_e = params.get('E', last_e)
show = False
if self.show_e_0: show = True
if cmd == 'G1' and new_e > 0: show = True
if show:
# Subtract offsets (scaled to Blender units) to return to original space
ox, oy = offset_x * 0.001, offset_y * 0.001
segments.append((
(last_x - ox, last_y - oy, last_z),
(new_x - ox, new_y - oy, new_z)
))
last_x, last_y, last_z = new_x, new_y, new_z
last_e = new_e
if not segments:
return 'no segments'
# Create or find "Debug" mesh object
if mesh_name in bpy.data.objects:
obj = bpy.data.objects[mesh_name]
mesh = obj.data
mesh.clear_geometry()
else:
mesh = bpy.data.meshes.new(mesh_name)
obj = bpy.data.objects.new(mesh_name, mesh)
assert bpy.context.collection
bpy.context.collection.objects.link(obj)
vertices = []
edges = []
for i, (p1, p2) in enumerate(segments):
vertices.extend([p1, p2])
edges.append((2*i, 2*i + 1))
mesh.from_pydata(vertices, edges, [])
mesh.update()
# Assign Geometry Nodes nodegroup for preview
ng_name = "Nonplanar Slicing Preview"
if ng_name in bpy.data.node_groups:
ng = bpy.data.node_groups[ng_name]
# Check if already assigned
has_preview = False
for mod in obj.modifiers:
if mod.type == 'NODES' and mod.node_group == ng: # type: ignore
has_preview = True
break
if not has_preview:
mod = obj.modifiers.new(name="Preview", type='NODES') # type: ignore
mod.node_group = ng
return 'ok'
@register_class
class SLICENODE_Executor(ExecSliceNode, SliceNodeJoiner):
bl_label = "Executor"
button_label = "Execute All"
socket_type = "SLICESOCK_ExecutionLink"
auto_refresh: NfBoolProperty(name="Auto-refresh", default=False) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self._init_exec()
def draw_buttons(self, context, layout):
super().draw_buttons(context, layout)
layout.prop(self, "auto_refresh")
def evaluate(self, operator=None):
from ...infra.blender.blender_slice_nodes_generic import node_execution_feedback
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 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):
_ = resolve_input(self.inputs.get("Gcode"))
return 'ok'