1349 lines
46 KiB
Python
1349 lines
46 KiB
Python
from __future__ import annotations
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import shlex
|
|
import shutil
|
|
from math import floor
|
|
from pathlib import Path
|
|
from typing import Any, TypeAlias
|
|
import bpy
|
|
import numpy as np
|
|
from mathutils import Euler
|
|
from bpy.props import StringProperty, FloatProperty, IntProperty, EnumProperty, BoolProperty
|
|
|
|
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, SlicingGroup
|
|
from ...infra.blender_slice_nodes_generic import SliceNode, resolve_input
|
|
from .tree import notify_tree_changed
|
|
from ...infra._3mf_writer._3mf import prepare_3mf
|
|
|
|
class SliceIntrinsics:
|
|
bed_x: float
|
|
bed_y: float
|
|
feedrate: float
|
|
extrusion_rate: float
|
|
|
|
def __init__(self, bed_x=250.0, bed_y=250.0, feedrate=1200.0, extrusion_rate=0.017):
|
|
self.bed_x = bed_x
|
|
self.bed_y = bed_y
|
|
self.feedrate = feedrate
|
|
self.extrusion_rate = extrusion_rate
|
|
|
|
# Basic Inputs
|
|
|
|
class InputNode(SliceNode):
|
|
input_type: str = ""
|
|
|
|
def __init_subclass__(cls, **kwargs):
|
|
super().__init_subclass__(**kwargs)
|
|
if "bl_label" not in cls.__dict__: cls.bl_label = cls.input_type
|
|
|
|
def init(self, context) -> None:
|
|
self.add_output(f"SLICESOCK_{self.bl_label}")
|
|
|
|
def evaluate_output(self, socket_name: str, **_kwargs):
|
|
return self.outputs.get(socket_name).value
|
|
|
|
@register_class
|
|
class SLICENODE_InputString(InputNode):
|
|
input_type = "String"
|
|
|
|
@register_class
|
|
class SLICENODE_InputFloat(InputNode):
|
|
input_type = "Float"
|
|
|
|
@register_class
|
|
class SLICENODE_InputInt(InputNode):
|
|
input_type = "Int"
|
|
|
|
@register_class
|
|
class SLICENODE_InputVectorRotation(InputNode):
|
|
input_type = "VectorRotation"
|
|
|
|
@register_class
|
|
class SLICENODE_InputObject(InputNode):
|
|
input_type = "Object"
|
|
bl_icon = "OBJECT_DATA"
|
|
|
|
@register_class
|
|
class SLICENODE_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 update_profiles(self, context):
|
|
cache = self.get_cache()
|
|
if not cache.profiles and self.config_path:
|
|
abs_path = bpy.path.abspath(self.config_path)
|
|
if os.path.isdir(abs_path):
|
|
cache.load([abs_path])
|
|
|
|
if self.printer_enum != 'NONE':
|
|
cache.evaluate_compatibility({self.printer_enum}, set(cache.vendors))
|
|
|
|
notify_tree_changed(self, context)
|
|
|
|
def get_printers(self, context):
|
|
cache = self.get_cache()
|
|
items = [('NONE', "None", "No printer selected")]
|
|
for k, p in cache.printer_profiles.items():
|
|
items.append((k, p.id, p.path.name))
|
|
return items
|
|
|
|
def get_filaments(self, context):
|
|
cache = self.get_cache()
|
|
items = [('NONE', "None", "No filament selected")]
|
|
|
|
printer_id = self.printer_enum
|
|
if printer_id == 'NONE':
|
|
return items
|
|
|
|
printer = cache.printer_profiles.get(printer_id)
|
|
if not printer:
|
|
return items
|
|
|
|
for k in printer.compatible_profiles:
|
|
if k.startswith("filament:"):
|
|
p = cache.profiles[k]
|
|
items.append((k, p.id, p.path.name))
|
|
return items
|
|
|
|
def get_prints(self, context):
|
|
cache = self.get_cache()
|
|
items = [('NONE', "None", "No print selected")]
|
|
|
|
printer_id = self.printer_enum
|
|
if printer_id == 'NONE':
|
|
return items
|
|
|
|
printer = cache.printer_profiles.get(printer_id)
|
|
if not printer:
|
|
return items
|
|
|
|
for k in printer.compatible_profiles:
|
|
if k.startswith("print:"):
|
|
p = cache.profiles[k]
|
|
items.append((k, p.id, p.path.name))
|
|
return items
|
|
|
|
config_path: StringProperty(
|
|
name="Profiles",
|
|
subtype='DIR_PATH',
|
|
update=update_profiles
|
|
) # pyright: ignore[reportInvalidTypeForm]
|
|
|
|
printer_enum: EnumProperty(
|
|
name="Printer",
|
|
items=get_printers,
|
|
update=update_profiles
|
|
) # pyright: ignore[reportInvalidTypeForm]
|
|
|
|
filament_enum: EnumProperty(
|
|
name="Filament",
|
|
items=get_filaments,
|
|
update=notify_tree_changed
|
|
) # pyright: ignore[reportInvalidTypeForm]
|
|
|
|
print_enum: EnumProperty(
|
|
name="Print",
|
|
items=get_prints,
|
|
update=notify_tree_changed
|
|
) # pyright: ignore[reportInvalidTypeForm]
|
|
|
|
def init(self, context):
|
|
self.add_output("SLICESOCK_PrusaslicerConf", "Config")
|
|
|
|
def draw_buttons(self, context, layout):
|
|
assert layout
|
|
layout.prop(self, "config_path")
|
|
layout.prop(self, "printer_enum")
|
|
if self.printer_enum != 'NONE':
|
|
layout.prop(self, "filament_enum")
|
|
layout.prop(self, "print_enum")
|
|
|
|
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
|
|
if self.printer_enum == 'NONE' or self.filament_enum == 'NONE' or self.print_enum == 'NONE':
|
|
return None
|
|
|
|
cache = self.get_cache()
|
|
return {
|
|
'printer': self.printer_enum,
|
|
'filament': [self.filament_enum],
|
|
'print': self.print_enum,
|
|
'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: StringProperty(name="Label", default="Printer", update=update_label) # pyright: ignore[reportInvalidTypeForm]
|
|
|
|
host_type: EnumProperty(
|
|
name="Type",
|
|
items=[
|
|
('PRUSALINK', "PrusaLink", "PrusaLink API"),
|
|
('CREALITY', "Creality", "Creality Cloud API"),
|
|
('MOONRAKER', "Moonraker", "Moonraker (Klipper) API"),
|
|
],
|
|
default='PRUSALINK',
|
|
update=notify_tree_changed
|
|
) # 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.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: 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", "Division"),
|
|
('ABS', "Absolute", "Absolute value of the first value"),
|
|
('NEGATE', "Negate", "Negate the first value"),
|
|
('POWER', "Power", "First value raised to the power of the second"),
|
|
('MIN', "Minimum", "The smaller of the two values"),
|
|
('MAX', "Maximum", "The larger of the two values"),
|
|
('SIGN', "Sign", "The sign of the first value (-1, 0, or 1)"),
|
|
('DIVIDE_ROUND', "Divide Round", "Division rounded to the nearest integer"),
|
|
('DIVIDE_FLOOR', "Divide Floor", "Division rounded down"),
|
|
('DIVIDE_TRUNCATE', "Divide Truncate", "Division rounded towards zero"),
|
|
('MODULO_FLOORED', "Floored Modulo", "Modulo using floor division"),
|
|
('MODULO_TRUNCATED', "Truncated Modulo", "Modulo using truncated division"),
|
|
],
|
|
default='ADD'
|
|
) # pyright: ignore[reportInvalidTypeForm]
|
|
|
|
math_type: TypeAlias = float
|
|
socket_label: str = "Float"
|
|
|
|
def init(self, context):
|
|
self.add_output(f"SLICESOCK_{self.socket_label}Ro", "Value")
|
|
|
|
self.add_input(f"SLICESOCK_{self.socket_label}", "Value")
|
|
self.add_input(f"SLICESOCK_{self.socket_label}", "Value")
|
|
|
|
def draw_buttons(self, context, layout):
|
|
assert layout
|
|
layout.prop(self, "operation", text="")
|
|
|
|
def evaluate_output(self, socket_name: str, **_kwargs) -> math_type:
|
|
t = self.math_type
|
|
|
|
a: t = resolve_input(self.inputs[0])
|
|
b: t = resolve_input(self.inputs[1])
|
|
|
|
op = self.operation
|
|
try:
|
|
if op == 'ADD': return a + b
|
|
if op == 'SUBTRACT': return a - b
|
|
if op == 'MULTIPLY': return a * b
|
|
if op == 'DIVIDE': return a // b if b != 0 else 0
|
|
if op == 'ABS': return abs(a)
|
|
if op == 'NEGATE': return -a
|
|
if op == 'POWER': return t(a ** b) if not (a == 0 and b < 0) else 0
|
|
if op == 'MIN': return min(a, b)
|
|
if op == 'MAX': return max(a, b)
|
|
if op == 'SIGN': return (a > 0) - (a < 0)
|
|
if op == 'DIVIDE_ROUND': return t(round(a / b)) if b != 0 else 0
|
|
if op == 'DIVIDE_FLOOR': return a // b if b != 0 else 0
|
|
if op == 'DIVIDE_TRUNCATE': return t(a / b) if b != 0 else 0
|
|
if op == 'MODULO_FLOORED': return a % b if b != 0 else 0
|
|
if op == 'MODULO_TRUNCATED': return a - (b * t(a / b)) if b != 0 else 0
|
|
except Exception:
|
|
return 0
|
|
return 0
|
|
|
|
@register_class
|
|
class SLICENODE_FloatMath(MathNode):
|
|
bl_label = "Math"
|
|
math_type = float
|
|
socket_label: str = "Float"
|
|
|
|
@register_class
|
|
class SLICENODE_IntMath(MathNode):
|
|
bl_label = "Integer Math"
|
|
math_type = int
|
|
socket_label: str = "Int"
|
|
|
|
@register_class
|
|
class SLICENODE_GetMesh(SliceNode):
|
|
bl_label = "Get Mesh"
|
|
|
|
def init(self, context):
|
|
self.add_input("SLICESOCK_Object")
|
|
self.add_output("SLICESOCK_Mesh")
|
|
|
|
def evaluate_output(self, socket_name: str, **_kwargs) -> 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"
|
|
|
|
infill_type: EnumProperty(
|
|
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: BoolProperty(name="Header", default=True, update=notify_tree_changed) # 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: 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_GcodeRo")
|
|
|
|
def draw_buttons(self, context, layout):
|
|
assert layout
|
|
layout.prop(self, "command", text="")
|
|
# Commands that don't need parameters
|
|
no_params = {'G28', 'G90', 'G91', 'M0', 'M25', 'M107', 'M400', 'M600', 'M82', 'M83', 'PAUSE'}
|
|
if self.command not in no_params:
|
|
layout.prop(self, "parameters", text="Parameters")
|
|
|
|
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
|
|
cmd = self.command
|
|
params = self.parameters.strip()
|
|
if params:
|
|
# T commands usually don't have a space (e.g. T0)
|
|
if cmd == 'T':
|
|
return f"{cmd}{params}"
|
|
return f"{cmd} {params}"
|
|
return cmd
|
|
|
|
### Joiners
|
|
|
|
class SliceNodeJoiner(SliceNode):
|
|
socket_type: str = ""
|
|
output_socket_type: str = ""
|
|
|
|
def init(self, context):
|
|
self.add_output(self.output_socket_type or self.socket_type)
|
|
|
|
def update(self) -> None:
|
|
def is_active(input) -> bool:
|
|
a: bool = input.is_linked or getattr(input, 'value', False)
|
|
b: bool = getattr(self.inputs[-1], 'value', False)
|
|
return a or b
|
|
|
|
if len(self.inputs) == 0:
|
|
self.add_input(self.socket_type)
|
|
return
|
|
|
|
if is_active(self.inputs[-1]):
|
|
self.add_input(self.socket_type)
|
|
|
|
while True:
|
|
if len(self.inputs) < 2: break
|
|
if not is_active(self.inputs[-1]) and not is_active(self.inputs[-2]):
|
|
self.inputs.remove(self.inputs[-2])
|
|
continue
|
|
break
|
|
|
|
@register_class
|
|
class SLICENODE_JoinGcode(SliceNodeJoiner):
|
|
bl_label = "Join Gcode"
|
|
socket_type = "SLICESOCK_Gcode"
|
|
output_socket_type = "SLICESOCK_GcodeRo"
|
|
|
|
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
|
|
gcode_input: list[str] = []
|
|
for input in self.inputs:
|
|
gcode_input += [resolve_input(input)] or [""]
|
|
return "\n".join(gcode_input)
|
|
|
|
|
|
@register_class
|
|
class SLICENODE_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()
|
|
|
|
@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.config_path:
|
|
return
|
|
|
|
abs_path = bpy.path.abspath(self.config_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()
|
|
|
|
config_path: StringProperty(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, "config_path") # pyright: ignore[reportOptionalMemberAccess]
|
|
|
|
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
|
|
self._refresh()
|
|
sock = self.outputs.get(socket_name)
|
|
if sock:
|
|
return sock.value
|
|
return None
|
|
@register_class
|
|
class SLICENODE_GcodeToFile(ExecSliceNode):
|
|
bl_label = "Gcode To File"
|
|
|
|
button_label = "Generate File"
|
|
|
|
def init(self, context):
|
|
self._init_exec()
|
|
self.add_input("SLICESOCK_Gcode")
|
|
self.add_input("SLICESOCK_FilePath", "Filename")
|
|
|
|
def evaluate(self, operator=None):
|
|
gcode: str = resolve_input(self.inputs.get("Gcode"))
|
|
filename: str = resolve_input(self.inputs.get("Filename"))
|
|
|
|
filepath = bpy.path.abspath(filename)
|
|
os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
|
|
|
|
with open(filepath, "w", encoding="utf-8", newline="\n") as f:
|
|
f.write(gcode)
|
|
|
|
return 'ok'
|
|
|
|
@register_class
|
|
class SLICENODE_GcodeToTextBlock(ExecSliceNode):
|
|
bl_label = "Gcode to Text Block"
|
|
button_label = "Update Text Block"
|
|
|
|
text_block_name: bpy.props.StringProperty(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.printer_backends import (
|
|
PrinterConfig,
|
|
PrusaLinkBackend,
|
|
CrealityBackend,
|
|
MoonrakerBackend
|
|
)
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
gcode: str = resolve_input(self.inputs.get("Gcode"))
|
|
config: PrinterConfig | None = resolve_input(self.inputs.get("Printer"))
|
|
|
|
if not gcode:
|
|
if operator: operator.report({'ERROR'}, "No Gcode to send")
|
|
return 'no gcode'
|
|
if not config:
|
|
if operator: operator.report({'ERROR'}, "No Printer configuration")
|
|
return 'no printer'
|
|
|
|
# Instantiate correct backend
|
|
backend = None
|
|
if config.host_type == 'PRUSALINK':
|
|
backend = PrusaLinkBackend(config.host, config.port, config.prefix, api_key=config.password)
|
|
elif config.host_type == 'CREALITY':
|
|
backend = CrealityBackend(config.host, config.port, config.prefix)
|
|
elif config.host_type == 'MOONRAKER':
|
|
backend = MoonrakerBackend(config.host, config.port, config.prefix)
|
|
|
|
if not backend:
|
|
if operator: operator.report({'ERROR'}, f"Unsupported host type: {config.host_type}")
|
|
return 'unsupported'
|
|
|
|
# Write to temp file to send
|
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.gcode', delete=False) as tf:
|
|
tf.write(gcode)
|
|
temp_path = Path(tf.name)
|
|
|
|
try:
|
|
print_name = f"NodeSlicer_{config.label}.gcode".replace(" ", "_")
|
|
backend.start_print(temp_path, print_name)
|
|
if operator: operator.report({'INFO'}, f"Print started on {config.label}")
|
|
except Exception as e:
|
|
if operator: operator.report({'ERROR'}, f"Failed to start print: {str(e)}")
|
|
return 'error'
|
|
finally:
|
|
if temp_path.exists():
|
|
temp_path.unlink()
|
|
|
|
return 'ok'
|
|
|
|
@register_class
|
|
class SLICENODE_PrusaslicerSlice(ExecSliceNode):
|
|
bl_label = "PrusaSlicer Slice"
|
|
button_label = "Slice with PrusaSlicer"
|
|
|
|
prusaslicer_executable: StringProperty(name="PrusaSlicer Path", subtype='FILE_PATH', update=notify_tree_changed) # pyright: ignore[reportInvalidTypeForm]
|
|
gcode_result: StringProperty(name="Gcode Result", default="") # pyright: ignore[reportInvalidTypeForm]
|
|
|
|
def init(self, context):
|
|
self._init_exec()
|
|
self.add_input("SLICESOCK_Object", "Object")
|
|
self.add_input("SLICESOCK_PrusaslicerConf", "Config")
|
|
self.add_output("SLICESOCK_GcodeRo")
|
|
|
|
def draw_buttons(self, context, layout):
|
|
super().draw_buttons(context, layout)
|
|
layout.prop(self, "prusaslicer_executable")
|
|
|
|
def evaluate_output(self, socket_name: str, **kwargs) -> Any:
|
|
if socket_name == "Gcode":
|
|
return self.gcode_result
|
|
return super().evaluate_output(socket_name, **kwargs)
|
|
|
|
def evaluate(self, operator=None):
|
|
obj = resolve_input(self.inputs.get("Object"))
|
|
conf_data = resolve_input(self.inputs.get("Config"))
|
|
|
|
if not obj:
|
|
if operator: operator.report({'ERROR'}, "No Object 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
|
|
slicing_group = SlicingGroup([obj])
|
|
|
|
# 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)
|
|
|
|
# Generate the ConfigWriter, forcing binary_gcode to 0 to ensure readable ASCII output
|
|
conf_writer = cache.generate_conf_writer(
|
|
printer_id,
|
|
filament_ids,
|
|
print_id,
|
|
overrides={'binary_gcode': {'value': '0'}}
|
|
)
|
|
|
|
# Prepare 3mf path
|
|
temp_dir = Path(tempfile.gettempdir())
|
|
output_path = temp_dir / f"{obj.name}_NodeSlicer.3mf"
|
|
|
|
try:
|
|
prepare_3mf(output_path, slicing_group, conf_writer, z_gcodes=[])
|
|
|
|
# Actual slicing
|
|
exe_str = self.prusaslicer_executable
|
|
if not exe_str:
|
|
if operator: operator.report({'ERROR'}, "PrusaSlicer executable path is empty")
|
|
return 'error'
|
|
|
|
# Parse command (handles multi-word commands like flatpak)
|
|
cmd_parts = shlex.split(exe_str)
|
|
if not cmd_parts:
|
|
if operator: operator.report({'ERROR'}, "Invalid PrusaSlicer command")
|
|
return 'error'
|
|
|
|
# Try to resolve the first part as a path if it's not a command in PATH
|
|
if not shutil.which(cmd_parts[0]):
|
|
abs_path = bpy.path.abspath(cmd_parts[0])
|
|
if os.path.exists(abs_path):
|
|
cmd_parts[0] = abs_path
|
|
|
|
# Final check if command exists
|
|
if not shutil.which(cmd_parts[0]):
|
|
if operator: operator.report({'ERROR'}, f"Command or executable not found: {cmd_parts[0]}")
|
|
return 'error'
|
|
|
|
gcode_path = output_path.with_suffix(".gcode")
|
|
|
|
# Command: <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}")
|
|
return 'error'
|
|
|
|
# Read G-code
|
|
if gcode_path.exists():
|
|
with open(gcode_path, 'r', encoding='utf-8') as f:
|
|
self.gcode_result = f.read()
|
|
|
|
if operator:
|
|
operator.report({'INFO'}, f"Slicing complete. G-code generated.")
|
|
else:
|
|
if operator: operator.report({'ERROR'}, "G-code file was not generated by PrusaSlicer")
|
|
return 'error'
|
|
|
|
except Exception as e:
|
|
if operator: operator.report({'ERROR'}, f"Failed to slice: {str(e)}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return 'error'
|
|
|
|
return 'ok'
|
|
|
|
@register_class
|
|
class SLICENODE_GcodeToMesh(ExecSliceNode):
|
|
bl_label = "Gcode to Mesh"
|
|
|
|
button_label = "Update Debug Mesh"
|
|
|
|
def init(self, context):
|
|
self._init_exec()
|
|
self.add_input("SLICESOCK_Gcode", "Gcode")
|
|
self.add_input("SLICESOCK_String", "Name")
|
|
|
|
def evaluate(self, operator=None):
|
|
gcode: str = self.resolve("Gcode")
|
|
mesh_name: str = self.resolve("Name")
|
|
|
|
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':
|
|
# 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: bpy.props.BoolProperty(name="Auto-refresh", default=False, update=notify_tree_changed) # pyright: ignore[reportInvalidTypeForm]
|
|
|
|
def init(self, context):
|
|
self._init_exec()
|
|
|
|
def draw_buttons(self, context, layout):
|
|
super().draw_buttons(context, layout)
|
|
layout.prop(self, "auto_refresh")
|
|
|
|
def evaluate(self, operator=None):
|
|
for i, job in enumerate(self.inputs):
|
|
# Resolve the input to get the connected node(s)
|
|
res = resolve_input(job)
|
|
if res is None:
|
|
continue
|
|
|
|
# Ensure we have a list of jobs to process (JoinExecution returns a list)
|
|
jobs_to_run = res if isinstance(res, list) else [res]
|
|
|
|
for upstream_node in jobs_to_run:
|
|
if not hasattr(upstream_node, "evaluate"):
|
|
continue
|
|
|
|
if operator:
|
|
operator.report({'INFO'}, f"Executing: {upstream_node.bl_label}")
|
|
|
|
try:
|
|
exec_res = upstream_node.evaluate(operator=operator)
|
|
if exec_res != 'ok' and exec_res is not None:
|
|
if operator: operator.report({'WARNING'}, f"Node {upstream_node.bl_label} returned {exec_res}")
|
|
except Exception as e:
|
|
if operator: operator.report({'ERROR'}, f"Failed executing {upstream_node.bl_label}: {str(e)}")
|
|
return 'error'
|
|
|
|
return 'ok'
|
|
|
|
@register_class
|
|
class SLICENODE_Test(ExecSliceNode):
|
|
bl_label = "Test"
|
|
|
|
button_label = "Test"
|
|
|
|
def init(self, context):
|
|
self._init_exec()
|
|
self.add_input("SLICESOCK_Gcode")
|
|
|
|
def evaluate(self, operator=None):
|
|
_ = resolve_input(self.inputs.get("Gcode"))
|
|
return 'ok'
|