ref
This commit is contained in:
parent
efc3c65982
commit
ac788ef432
12 changed files with 1836 additions and 1776 deletions
File diff suppressed because it is too large
Load diff
9
core/nodeslicer/nodes/__init__.py
Normal file
9
core/nodeslicer/nodes/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from .base import *
|
||||
from .inputs import *
|
||||
from .math import *
|
||||
from .mesh import *
|
||||
from .path import *
|
||||
from .gcode import *
|
||||
from .output import *
|
||||
from .system import *
|
||||
from .preview import *
|
||||
126
core/nodeslicer/nodes/base.py
Normal file
126
core/nodeslicer/nodes/base.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
from __future__ import annotations
|
||||
from typing import Any, TypeAlias
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
from ..bpy_ns_types import NfIntProperty, NfStringProperty, NfEnumProperty, NfBoolProperty, notify_tree_changed
|
||||
|
||||
from ....registry import register_class
|
||||
from ... import slice
|
||||
from ...slice import Pather, slice_at_range, SlicePaths
|
||||
from ....infra.blender.blender_mesh_capture import SlicingCollection, SlicingObject, capture_mesh_edges, SlicingGroup
|
||||
from ....infra.blender.blender_bridge import get_all_children
|
||||
from ....infra.blender.blender_slice_nodes_generic import SliceNode, resolve_input
|
||||
|
||||
class SliceIntrinsics:
|
||||
bed_x: float
|
||||
bed_y: float
|
||||
feedrate: float
|
||||
extrusion_rate: float
|
||||
|
||||
def __init__(self, bed_x=250.0, bed_y=250.0, feedrate=1200.0, extrusion_rate=0.017):
|
||||
self.bed_x = bed_x
|
||||
self.bed_y = bed_y
|
||||
self.feedrate = feedrate
|
||||
self.extrusion_rate = extrusion_rate
|
||||
|
||||
class InputNode(SliceNode):
|
||||
input_type: str = ""
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
if "bl_label" not in cls.__dict__: cls.bl_label = cls.input_type
|
||||
|
||||
def init(self, context) -> None:
|
||||
self.add_output(f"SLICESOCK_{self.bl_label}")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs):
|
||||
return self.outputs.get(socket_name).value
|
||||
|
||||
class MathNode(SliceNode):
|
||||
bl_label = "Float Math"
|
||||
|
||||
operation: NfEnumProperty(
|
||||
name="Operation",
|
||||
items=[
|
||||
('ADD', "Add", "Add the two values"),
|
||||
('SUBTRACT', "Subtract", "Subtract the second value from the first"),
|
||||
('MULTIPLY', "Multiply", "Multiply the two values"),
|
||||
('DIVIDE', "Divide", "Division"),
|
||||
('ABS', "Absolute", "Absolute value of the first value"),
|
||||
('NEGATE', "Negate", "Negate the first value"),
|
||||
('POWER', "Power", "First value raised to the power of the second"),
|
||||
('MIN', "Minimum", "The smaller of the two values"),
|
||||
('MAX', "Maximum", "The larger of the two values"),
|
||||
('SIGN', "Sign", "The sign of the first value (-1, 0, or 1)"),
|
||||
('DIVIDE_ROUND', "Divide Round", "Division rounded to the nearest integer"),
|
||||
('DIVIDE_FLOOR', "Divide Floor", "Division rounded down"),
|
||||
('DIVIDE_TRUNCATE', "Divide Truncate", "Division rounded towards zero"),
|
||||
('MODULO_FLOORED', "Floored Modulo", "Modulo using floor division"),
|
||||
('MODULO_TRUNCATED', "Truncated Modulo", "Modulo using truncated division"),
|
||||
],
|
||||
default='ADD'
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
math_type: TypeAlias = float
|
||||
socket_label: str = "Float"
|
||||
|
||||
def init(self, context):
|
||||
self.add_output(f"SLICESOCK_{self.socket_label}Ro", "Value")
|
||||
|
||||
self.add_input(f"SLICESOCK_{self.socket_label}", "Value")
|
||||
self.add_input(f"SLICESOCK_{self.socket_label}", "Value")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
assert layout
|
||||
layout.prop(self, "operation", text="")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> math_type:
|
||||
t = self.math_type
|
||||
|
||||
a: t = resolve_input(self.inputs[0])
|
||||
b: t = resolve_input(self.inputs[1])
|
||||
|
||||
op = self.operation
|
||||
try:
|
||||
if op == 'ADD': return a + b
|
||||
if op == 'SUBTRACT': return a - b
|
||||
if op == 'MULTIPLY': return a * b
|
||||
if op == 'DIVIDE': return a // b if b != 0 else 0
|
||||
if op == 'ABS': return abs(a)
|
||||
if op == 'NEGATE': return -a
|
||||
if op == 'POWER': return t(a ** b) if not (a == 0 and b < 0) else 0
|
||||
if op == 'MIN': return min(a, b)
|
||||
if op == 'MAX': return max(a, b)
|
||||
if op == 'SIGN': return (a > 0) - (a < 0)
|
||||
if op == 'DIVIDE_ROUND': return t(round(a / b)) if b != 0 else 0
|
||||
if op == 'DIVIDE_FLOOR': return a // b if b != 0 else 0
|
||||
if op == 'DIVIDE_TRUNCATE': return t(a / b) if b != 0 else 0
|
||||
if op == 'MODULO_FLOORED': return a % b if b != 0 else 0
|
||||
if op == 'MODULO_TRUNCATED': return a - (b * t(a / b)) if b != 0 else 0
|
||||
except Exception:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
class SliceNodeJoiner(SliceNode):
|
||||
socket_type: str = ""
|
||||
output_socket_type: str = ""
|
||||
|
||||
def init(self, context):
|
||||
self.add_output(self.output_socket_type or self.socket_type)
|
||||
self.add_multi_socket_group(self.socket_type)
|
||||
|
||||
class ExecSliceNode(SliceNode):
|
||||
button_label: str = 'Execute'
|
||||
|
||||
def _init_exec(self):
|
||||
self.add_output("SLICESOCK_ExecutionLink")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
assert layout
|
||||
layout.operator("slicenode.execute", text=self.button_label).node_name = self.name
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
|
||||
if socket_name == "Execution Link":
|
||||
return self
|
||||
return None
|
||||
196
core/nodeslicer/nodes/gcode.py
Normal file
196
core/nodeslicer/nodes/gcode.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
from __future__ import annotations
|
||||
from typing import Any
|
||||
import numpy as np
|
||||
import bpy
|
||||
import os
|
||||
|
||||
from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfStringProperty
|
||||
from ....registry import register_class
|
||||
from .base import SliceNode, SliceIntrinsics, Pather, SliceNodeJoiner, SlicePaths
|
||||
|
||||
@register_class
|
||||
class SLICENODE_PathsToGcode(SliceNode):
|
||||
bl_label = "Paths to Gcode"
|
||||
|
||||
use_header: NfBoolProperty(name="Header", default=True) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
self.add_input("SLICESOCK_Paths")
|
||||
self.add_input("SLICESOCK_Intrinsics")
|
||||
self.add_output("SLICESOCK_GcodeRo")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
assert layout
|
||||
layout.prop(self, "use_header")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
|
||||
paths: list[np.ndarray] = self.resolve("Paths")
|
||||
intrinsics: SliceIntrinsics | None = self.resolve("Intrinsics")
|
||||
|
||||
pather: Pather = Pather(paths)
|
||||
if intrinsics:
|
||||
# Note: Pather currently uses bed_center_x/y as its internal attributes.
|
||||
# We map the bed size center to these attributes if provided.
|
||||
pather.bed_center_x = intrinsics.bed_x / 2.0
|
||||
pather.bed_center_y = intrinsics.bed_y / 2.0
|
||||
pather.feedrate = intrinsics.feedrate
|
||||
pather.extrusion_rate = intrinsics.extrusion_rate
|
||||
|
||||
gcode: str = pather.to_gcode(include_header=self.use_header)
|
||||
return gcode
|
||||
|
||||
@register_class
|
||||
class SLICENODE_GcodeBlock(SliceNode):
|
||||
bl_label = "Gcode Block"
|
||||
|
||||
def init(self, context):
|
||||
self.add_output("SLICESOCK_Gcode", "Gcode")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
|
||||
return self.outputs['Gcode'].value
|
||||
|
||||
@register_class
|
||||
class SLICENODE_AssignMetadata(SliceNode):
|
||||
bl_label = "Assign Metadata"
|
||||
|
||||
def init(self, context):
|
||||
self.add_input("SLICESOCK_String", "Name")
|
||||
self.add_input("SLICESOCK_String", "Filament Type")
|
||||
self.add_input("SLICESOCK_String", "Printer Model")
|
||||
self.add_output("SLICESOCK_GcodeRo", "Gcode")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
|
||||
name = self.resolve("Name")
|
||||
filament = self.resolve("Filament Type")
|
||||
printer = self.resolve("Printer Model")
|
||||
lines = []
|
||||
if name:
|
||||
lines.append(f'; objects_info = {{"objects": [{{"name": "{name}", "polygon": []}}]}}')
|
||||
if filament:
|
||||
lines.append(f'; filament_type = {filament}')
|
||||
if printer:
|
||||
lines.append(f'; printer_model = {printer}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
@register_class
|
||||
class SLICENODE_GcodeRegexReplace(SliceNode):
|
||||
bl_label = "Gcode Regex Replace"
|
||||
|
||||
def init(self, context):
|
||||
self.add_input("SLICESOCK_Gcode", "Gcode")
|
||||
self.add_input("SLICESOCK_String", "Find")
|
||||
self.add_input("SLICESOCK_String", "Replace")
|
||||
self.add_output("SLICESOCK_GcodeRo", "Gcode")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
|
||||
import re
|
||||
gcode = self.resolve("Gcode") or ""
|
||||
find = self.resolve("Find") or ""
|
||||
replace = self.resolve("Replace") or ""
|
||||
|
||||
if not find:
|
||||
return gcode
|
||||
|
||||
try:
|
||||
# Convert $1, $2, ${name} etc to Python backreferences \g<1>, \g<2>, \g<name>
|
||||
def subst(match):
|
||||
if match.group(1): return '$'
|
||||
elif match.group(2): return '\\g<' + match.group(2) + '>'
|
||||
elif match.group(3): return '\\g<' + match.group(3) + '>'
|
||||
return match.group(0)
|
||||
|
||||
replace = re.sub(r'(\$\$)|(?:\$\{([^}]+)\})|(?:\$([a-zA-Z0-9_]+))', subst, replace)
|
||||
|
||||
return re.sub(find, replace, gcode)
|
||||
except Exception as e:
|
||||
print(f"Regex error in node {self.name}: {e}")
|
||||
return gcode
|
||||
|
||||
@register_class
|
||||
class SLICENODE_GcodeHelper(SliceNode):
|
||||
bl_label = "Gcode Helper"
|
||||
|
||||
command: NfEnumProperty(
|
||||
name="Command",
|
||||
items=[
|
||||
('G0', "G0: Rapid Move", "Fast movement without extrusion"),
|
||||
('G1', "G1: Linear Move", "Movement with extrusion"),
|
||||
('G4', "G4: Dwell", "Wait for time (e.g. P500 for 500ms or S5 for 5s)"),
|
||||
('G28', "G28: Home All", "Home all axes"),
|
||||
('G90', "G90: Absolute Pos", "Set to absolute positioning"),
|
||||
('G91', "G91: Relative Pos", "Set to relative positioning"),
|
||||
('G92', "G92: Set Position", "Set the current position to specific values (e.g. G92 E0)"),
|
||||
('M0', "M0: Stop/Wait", "Stop and wait for user button press"),
|
||||
('M25', "M25: Pause SD", "Pause SD print (Marlin/RepRap)"),
|
||||
('M104', "M104: Set Temp", "Set hotend temperature"),
|
||||
('M109', "M109: Wait Temp", "Set hotend temperature and wait"),
|
||||
('M140', "M140: Set Bed Temp", "Set bed temperature"),
|
||||
('M190', "M190: Wait Bed Temp", "Set bed temperature and wait"),
|
||||
('M106', "M106: Fan On", "Turn on cooling fan"),
|
||||
('M107', "M107: Fan Off", "Turn off cooling fan"),
|
||||
('M117', "M117: Message", "Display message on printer LCD"),
|
||||
('M220', "M220: Speed Factor", "Set speed percentage (e.g. S110 for 110%)"),
|
||||
('M221', "M221: Flow Factor", "Set flow percentage (e.g. S95 for 95%)"),
|
||||
('M300', "M300: Beep", "Play beep sound"),
|
||||
('M400', "M400: Wait Finish", "Wait for all current moves to finish"),
|
||||
('M600', "M600: Color Change", "Filament change / Color change"),
|
||||
('M82', "M82: Abs Extrusion", "Set extrusion to absolute mode"),
|
||||
('M83', "M83: Rel Extrusion", "Set extrusion to relative mode"),
|
||||
('PAUSE', "PAUSE: Klipper Pause", "Pause print (Klipper macro)"),
|
||||
('T', "T: Change Extruder", "Switch to a different extruder (e.g. T0, T1)"),
|
||||
],
|
||||
default='G1'
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
parameters: NfStringProperty(name="Params", default="") # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
self.add_output("SLICESOCK_GcodeRo")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
assert layout
|
||||
layout.prop(self, "command", text="")
|
||||
# Commands that don't need parameters
|
||||
no_params = {'G28', 'G90', 'G91', 'M0', 'M25', 'M107', 'M400', 'M600', 'M82', 'M83', 'PAUSE'}
|
||||
if self.command not in no_params:
|
||||
layout.prop(self, "parameters", text="Parameters")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
|
||||
cmd = self.command
|
||||
params = self.parameters.strip()
|
||||
if params:
|
||||
# T commands usually don't have a space (e.g. T0)
|
||||
if cmd == 'T':
|
||||
return f"{cmd}{params}"
|
||||
return f"{cmd} {params}"
|
||||
return cmd
|
||||
|
||||
@register_class
|
||||
class SLICENODE_JoinGcode(SliceNodeJoiner):
|
||||
bl_label = "Join Gcode"
|
||||
socket_type = "SLICESOCK_Gcode"
|
||||
output_socket_type = "SLICESOCK_GcodeRo"
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
|
||||
from ....infra.blender.blender_slice_nodes_generic import resolve_input
|
||||
gcode_input: list[str] = []
|
||||
for input in self.inputs:
|
||||
gcode_input += [resolve_input(input)] or [""]
|
||||
return "\n".join(gcode_input)
|
||||
|
||||
@register_class
|
||||
class SLICENODE_LoadGcode(SliceNode):
|
||||
bl_label = "Load Gcode File"
|
||||
|
||||
def init(self, context):
|
||||
self.add_input("SLICESOCK_FilePath", "Filepath")
|
||||
self.add_output("SLICESOCK_GcodeRo")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
|
||||
path = self.resolve("Filepath")
|
||||
if not path: return ""
|
||||
abs_path = bpy.path.abspath(path)
|
||||
if os.path.exists(abs_path):
|
||||
with open(abs_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
return ""
|
||||
192
core/nodeslicer/nodes/inputs.py
Normal file
192
core/nodeslicer/nodes/inputs.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
from __future__ import annotations
|
||||
from typing import Any
|
||||
import bpy
|
||||
import os
|
||||
|
||||
from ..bpy_ns_types import NfStringProperty, notify_tree_changed, NfEnumProperty
|
||||
from ....registry import register_class
|
||||
from .base import InputNode, SliceNode, SliceIntrinsics
|
||||
|
||||
@register_class
|
||||
class SLICENODE_InputString(InputNode):
|
||||
input_type = "String"
|
||||
|
||||
@register_class
|
||||
class SLICENODE_InputFloat(InputNode):
|
||||
input_type = "Float"
|
||||
|
||||
@register_class
|
||||
class SLICENODE_InputInt(InputNode):
|
||||
input_type = "Int"
|
||||
|
||||
@register_class
|
||||
class SLICENODE_InputVectorRotation(InputNode):
|
||||
input_type = "VectorRotation"
|
||||
|
||||
@register_class
|
||||
class SLICENODE_InputVector(InputNode):
|
||||
input_type = "Vector"
|
||||
|
||||
@register_class
|
||||
class SLICENODE_InputObject(InputNode):
|
||||
input_type = "Object"
|
||||
bl_icon = "OBJECT_DATA"
|
||||
|
||||
@register_class
|
||||
class SLICENODE_InputSettings(InputNode):
|
||||
input_type = "Settings"
|
||||
bl_icon = "OBJECT_DATA"
|
||||
|
||||
@register_class
|
||||
class SLICENODE_PrusaslicerConf(SliceNode):
|
||||
bl_label = "PrusaSlicer Config"
|
||||
bl_icon = "PROPERTIES"
|
||||
|
||||
_cache = None
|
||||
|
||||
@classmethod
|
||||
def get_cache(cls):
|
||||
if cls._cache is None:
|
||||
from ....infra.prusaslicer_profiles import LocalCache
|
||||
cls._cache = LocalCache()
|
||||
return cls._cache
|
||||
|
||||
def ensure_profiles_loaded(self):
|
||||
cache = self.get_cache()
|
||||
if not cache.profiles:
|
||||
# Use a path relative to the addon directory
|
||||
# Note: adjust paths to point to the correct addon root
|
||||
addon_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
profiles_path = os.path.join(addon_dir, "profiles", "prusaslicer")
|
||||
if os.path.isdir(profiles_path):
|
||||
cache.load([profiles_path])
|
||||
|
||||
if self.printer_id != '':
|
||||
cache.evaluate_compatibility({self.printer_id}, set(cache.vendors))
|
||||
|
||||
def update_profiles(self, context):
|
||||
cache = self.get_cache()
|
||||
if self.printer_id != '':
|
||||
cache.evaluate_compatibility({self.printer_id}, set(cache.vendors))
|
||||
|
||||
notify_tree_changed(self, context)
|
||||
|
||||
def search_printers(self, _context, edit_text):
|
||||
self.ensure_profiles_loaded()
|
||||
cache = self.get_cache()
|
||||
items: list[str] = [p.id for k, p in cache.printer_profiles.items() if edit_text.lower() in p.id.lower()]
|
||||
items.sort()
|
||||
return items
|
||||
|
||||
def search_conf(self, edit_text: str, conf_type: str) -> list[str]:
|
||||
if not (printer_id := self.printer_id): return []
|
||||
|
||||
self.ensure_profiles_loaded()
|
||||
cache = self.get_cache()
|
||||
if not (printer := cache.printer_profiles.get("printer:" + printer_id)): return []
|
||||
|
||||
profiles = printer.compatible_filament_profiles if conf_type == "filaments" else printer.compatible_print_profiles
|
||||
|
||||
items: list[str] = [p.id for p in profiles if edit_text.lower() in p.id.lower()]
|
||||
items.sort()
|
||||
return items
|
||||
|
||||
def search_filaments(self, _context, edit_text) -> list[str]:
|
||||
return self.search_conf(edit_text, conf_type="filaments")
|
||||
|
||||
def search_prints(self, _context, edit_text) -> list[str]:
|
||||
return self.search_conf(edit_text, conf_type="prints")
|
||||
|
||||
printer_id: NfStringProperty(name="Printer", default='', update=update_profiles, search=search_printers) # pyright: ignore[reportInvalidTypeForm]
|
||||
filament_id: NfStringProperty(name="Filament", default='', search=search_filaments) # pyright: ignore[reportInvalidTypeForm]
|
||||
print_id: NfStringProperty(name="Print", default='', search=search_prints) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
self.add_output("SLICESOCK_PrusaslicerConf", "Config")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
assert layout
|
||||
self.ensure_profiles_loaded()
|
||||
layout.prop(self, "printer_id")
|
||||
if self.printer_id != '':
|
||||
layout.prop(self, "filament_id")
|
||||
layout.prop(self, "print_id")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
|
||||
self.ensure_profiles_loaded()
|
||||
if self.printer_id == '' or self.filament_id == '' or self.print_id == '':
|
||||
return None
|
||||
|
||||
cache = self.get_cache()
|
||||
return {
|
||||
'printer': "printer:" + self.printer_id,
|
||||
'filament': ["filament:" + self.filament_id],
|
||||
'print': "print:" + self.print_id,
|
||||
'cache': cache
|
||||
}
|
||||
|
||||
@register_class
|
||||
class SLICENODE_InputPrinter(SliceNode):
|
||||
bl_label = "Printer"
|
||||
bl_icon = "OUTPUT"
|
||||
|
||||
def update_label(self, context):
|
||||
self.label = self.printer_label
|
||||
notify_tree_changed(self, context)
|
||||
|
||||
printer_label: NfStringProperty(name="Label", default="Printer", update=update_label) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
host_type: NfEnumProperty(
|
||||
name="Type",
|
||||
items=[
|
||||
('PRUSALINK', "PrusaLink", "PrusaLink API"),
|
||||
('CREALITY', "Creality", "Creality Cloud API"),
|
||||
('MOONRAKER', "Moonraker", "Moonraker (Klipper) API"),
|
||||
],
|
||||
default='PRUSALINK'
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
self.add_output("SLICESOCK_Printer")
|
||||
self.add_input("SLICESOCK_String", "Host").value = "localhost"
|
||||
self.add_input("SLICESOCK_Int", "Port").value = 80
|
||||
self.add_input("SLICESOCK_String", "Prefix")
|
||||
self.add_input("SLICESOCK_String", "Username")
|
||||
self.add_input("SLICESOCK_String", "Password")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
layout.prop(self, "printer_label") # pyright: ignore[reportOptionalMemberAccess]
|
||||
layout.prop(self, "host_type") # pyright: ignore[reportOptionalMemberAccess]
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
|
||||
from ....infra.networking.printer_backends import PrinterConfig
|
||||
return PrinterConfig(
|
||||
host=self.resolve("Host"),
|
||||
port=self.resolve("Port"),
|
||||
prefix=self.resolve("Prefix"),
|
||||
label=self.printer_label,
|
||||
username=self.resolve("Username"),
|
||||
password=self.resolve("Password"),
|
||||
host_type=self.host_type
|
||||
)
|
||||
|
||||
|
||||
@register_class
|
||||
class SLICENODE_InputIntrinsics(SliceNode):
|
||||
|
||||
bl_label = "Printer Intrinsics"
|
||||
|
||||
def init(self, context):
|
||||
self.add_output("SLICESOCK_Intrinsics")
|
||||
self.add_input("SLICESOCK_Float", "Bed X").value = 250.0
|
||||
self.add_input("SLICESOCK_Float", "Bed Y").value = 210.0
|
||||
self.add_input("SLICESOCK_Float", "Feedrate").value = 1200.0
|
||||
self.add_input("SLICESOCK_Float", "Extrusion Rate").value = 0.035
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> SliceIntrinsics:
|
||||
return SliceIntrinsics(
|
||||
bed_x=self.resolve("Bed X"),
|
||||
bed_y=self.resolve("Bed Y"),
|
||||
feedrate=self.resolve("Feedrate"),
|
||||
extrusion_rate=self.resolve("Extrusion Rate")
|
||||
)
|
||||
15
core/nodeslicer/nodes/math.py
Normal file
15
core/nodeslicer/nodes/math.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from __future__ import annotations
|
||||
from ....registry import register_class
|
||||
from .base import MathNode
|
||||
|
||||
@register_class
|
||||
class SLICENODE_FloatMath(MathNode):
|
||||
bl_label = "Math"
|
||||
math_type = float
|
||||
socket_label: str = "Float"
|
||||
|
||||
@register_class
|
||||
class SLICENODE_IntMath(MathNode):
|
||||
bl_label = "Integer Math"
|
||||
math_type = int
|
||||
socket_label: str = "Int"
|
||||
65
core/nodeslicer/nodes/mesh.py
Normal file
65
core/nodeslicer/nodes/mesh.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from __future__ import annotations
|
||||
from typing import Any
|
||||
import numpy as np
|
||||
|
||||
from ..bpy_ns_types import NfBoolProperty
|
||||
from ....registry import register_class
|
||||
from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SlicingGroup
|
||||
|
||||
@register_class
|
||||
class SLICENODE_GetMesh(SliceNode):
|
||||
bl_label = "Get Mesh"
|
||||
|
||||
include_children: NfBoolProperty(name="Include Children", default=True) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
self.add_input("SLICESOCK_Object")
|
||||
self.add_output("SLICESOCK_Mesh")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
assert layout
|
||||
layout.prop(self, "include_children")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | None:
|
||||
if socket_name != "Mesh": return None
|
||||
|
||||
obj: Any = self.resolve("Object")
|
||||
if not obj: return None
|
||||
|
||||
family = [obj] + (get_all_children(obj) if self.include_children else [])
|
||||
|
||||
sc: SlicingCollection = SlicingCollection(family, obj.name)
|
||||
|
||||
return sc
|
||||
|
||||
@register_class
|
||||
class SLICENODE_JoinMesh(SliceNodeJoiner):
|
||||
bl_label = "Join Mesh Parts"
|
||||
socket_type = "SLICESOCK_Mesh"
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection:
|
||||
from ....infra.blender.blender_slice_nodes_generic import resolve_input
|
||||
merged_coll = resolve_input(self.inputs[0])
|
||||
|
||||
for input in self.inputs[1:]:
|
||||
coll = resolve_input(input)
|
||||
if coll:
|
||||
merged_coll.objects += coll.objects
|
||||
|
||||
return merged_coll
|
||||
|
||||
@register_class
|
||||
class SLICENODE_JoinMeshGroups(SliceNodeJoiner):
|
||||
bl_label = "Join Mesh Groups"
|
||||
socket_type = "SLICESOCK_Mesh"
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingGroup:
|
||||
from ....infra.blender.blender_slice_nodes_generic import resolve_input
|
||||
group: SlicingGroup = SlicingGroup([])
|
||||
|
||||
for input in self.inputs:
|
||||
coll = resolve_input(input)
|
||||
if coll:
|
||||
group.collections[coll.objects[0].name] = coll
|
||||
|
||||
return group
|
||||
350
core/nodeslicer/nodes/output.py
Normal file
350
core/nodeslicer/nodes/output.py
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
import os
|
||||
import bpy
|
||||
import tempfile
|
||||
import re
|
||||
import json
|
||||
|
||||
from ..bpy_ns_types import NfStringProperty
|
||||
from ....registry import register_class
|
||||
from .base import ExecSliceNode, SlicePaths, SliceNode, SlicingCollection, SlicingGroup
|
||||
|
||||
@register_class
|
||||
class SLICENODE_GcodeToFile(ExecSliceNode):
|
||||
bl_label = "Gcode To File"
|
||||
button_label = "Generate File"
|
||||
|
||||
def init(self, context):
|
||||
self._init_exec()
|
||||
self.add_input("SLICESOCK_DirPath", "Folder")
|
||||
self.add_multi_socket_group("SLICESOCK_Gcode", name="Gcode")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
super().draw_buttons(context, layout)
|
||||
|
||||
def evaluate(self, operator=None):
|
||||
from ....infra.networking.bgcode import convert_to_bgcode
|
||||
from ....infra.networking.gcode_utils import get_gcode_name_from_metadata
|
||||
|
||||
gcodes: list[str] = [gc for gc in self.resolve_multi_input('Gcode') if gc]
|
||||
|
||||
folder: str = self.resolve("Folder")
|
||||
if not gcodes:
|
||||
if operator: operator.report({'ERROR'}, "No Gcode to save")
|
||||
return 'error'
|
||||
if not folder:
|
||||
if operator: operator.report({'ERROR'}, "No Folder provided")
|
||||
return 'error'
|
||||
|
||||
for i, gc in enumerate(gcodes):
|
||||
name = get_gcode_name_from_metadata(gc, default=f'object_{i}')
|
||||
|
||||
# User's requested logic: .split('.')[0]
|
||||
folder_path = Path(bpy.path.abspath(f"//{folder}"))
|
||||
filepath_abs = folder_path / name
|
||||
dirname = os.path.dirname(filepath_abs)
|
||||
basename = os.path.basename(filepath_abs)
|
||||
name_part = basename.split('.')[0]
|
||||
os.makedirs(dirname, exist_ok=True)
|
||||
|
||||
# Detect binary trigger in gcode comments
|
||||
is_binary = "; binary_gcode = 1" in gc
|
||||
|
||||
try:
|
||||
if is_binary:
|
||||
filepath = os.path.join(dirname, name_part + ".bgcode")
|
||||
content = convert_to_bgcode(gc, name_part, True)
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(content)
|
||||
if operator: operator.report({'INFO'}, f"Binary G-code generated at {filepath}")
|
||||
else:
|
||||
filepath = os.path.join(dirname, name_part + ".gcode")
|
||||
with open(filepath, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(gc)
|
||||
if operator: operator.report({'INFO'}, f"G-code saved to {filepath}")
|
||||
except Exception as e:
|
||||
if operator: operator.report({'ERROR'}, str(e))
|
||||
return 'error'
|
||||
|
||||
return 'ok'
|
||||
|
||||
@register_class
|
||||
class SLICENODE_GcodeToTextBlock(ExecSliceNode):
|
||||
bl_label = "Gcode to Text Block"
|
||||
button_label = "Update Text Block"
|
||||
|
||||
text_block_name: NfStringProperty(name="Text Block Name", default="NodeSlicer_Gcode.gcode") # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
self._init_exec()
|
||||
self.add_input("SLICESOCK_Gcode")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
super().draw_buttons(context, layout)
|
||||
layout.prop(self, "text_block_name")
|
||||
|
||||
def evaluate(self, operator=None):
|
||||
gcode: str = self.resolve("Gcode")
|
||||
if not gcode:
|
||||
return 'ok'
|
||||
|
||||
name = self.text_block_name
|
||||
text_block = bpy.data.texts.get(name)
|
||||
if text_block is None:
|
||||
text_block = bpy.data.texts.new(name=name)
|
||||
|
||||
text_block.from_string(gcode)
|
||||
|
||||
# Attempt to show in an open text editor
|
||||
assert bpy.context.window_manager
|
||||
for window in bpy.context.window_manager.windows:
|
||||
for area in window.screen.areas:
|
||||
if area.type == 'TEXT_EDITOR':
|
||||
for space in area.spaces:
|
||||
if space.type == 'TEXT_EDITOR':
|
||||
space.text = text_block
|
||||
|
||||
return 'ok'
|
||||
|
||||
@register_class
|
||||
class SLICENODE_StartPrint(ExecSliceNode):
|
||||
bl_label = "Start Print"
|
||||
button_label = "Send to Printer"
|
||||
|
||||
def init(self, context):
|
||||
self._init_exec()
|
||||
self.add_input("SLICESOCK_Gcode")
|
||||
self.add_input("SLICESOCK_Printer")
|
||||
|
||||
def evaluate(self, operator=None):
|
||||
from ....infra.networking.printer_backends import (
|
||||
PrinterConfig,
|
||||
PrusaLinkBackend,
|
||||
CrealityBackend,
|
||||
MoonrakerBackend
|
||||
)
|
||||
|
||||
gcode: str = self.resolve("Gcode")
|
||||
config: PrinterConfig | None = self.resolve("Printer")
|
||||
|
||||
if not gcode:
|
||||
if operator: operator.report({'ERROR'}, "No Gcode to send")
|
||||
return 'no gcode'
|
||||
if not config:
|
||||
if operator: operator.report({'ERROR'}, "No Printer configuration")
|
||||
return 'no printer'
|
||||
|
||||
# Instantiate correct backend
|
||||
backend = None
|
||||
if config.host_type == 'PRUSALINK':
|
||||
backend = PrusaLinkBackend(config.host, config.port, config.prefix, api_key=config.password)
|
||||
elif config.host_type == 'CREALITY':
|
||||
backend = CrealityBackend(config.host, config.port, config.prefix)
|
||||
elif config.host_type == 'MOONRAKER':
|
||||
backend = MoonrakerBackend(config.host, config.port, config.prefix)
|
||||
|
||||
if not backend:
|
||||
if operator: operator.report({'ERROR'}, f"Unsupported host type: {config.host_type}")
|
||||
return 'unsupported'
|
||||
|
||||
# Write to temp file to send
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.gcode', delete=False) as tf:
|
||||
tf.write(gcode)
|
||||
temp_path = Path(tf.name)
|
||||
|
||||
try:
|
||||
print_name = f"NodeSlicer_{config.label}.gcode".replace(" ", "_")
|
||||
backend.start_print(temp_path, print_name)
|
||||
if operator: operator.report({'INFO'}, f"Print started on {config.label}")
|
||||
except Exception as e:
|
||||
if operator: operator.report({'ERROR'}, f"Failed to start print: {str(e)}")
|
||||
return 'error'
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
return 'ok'
|
||||
|
||||
class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
|
||||
record_id: NfStringProperty(name="Record ID", default="") # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
_db_name = "Database"
|
||||
_token_prop_name = "token_path"
|
||||
|
||||
def init(self, context):
|
||||
self._init_exec()
|
||||
self.add_multi_socket_group("SLICESOCK_Gcode", name="Gcode")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
super().draw_buttons(context, layout)
|
||||
layout.prop(self, "record_id")
|
||||
|
||||
def evaluate(self, operator=None):
|
||||
from ....infra.networking.bgcode import convert_to_bgcode
|
||||
from ....infra.networking.gcode_utils import get_gcode_name_from_metadata
|
||||
|
||||
gcodes: list[str] = self.resolve_multi_input("Gcode")
|
||||
|
||||
for gc in gcodes:
|
||||
if not gc:
|
||||
continue
|
||||
|
||||
# Parse Metadata from Gcode
|
||||
fields = {}
|
||||
part_name = get_gcode_name_from_metadata(gc)
|
||||
fields["Part ID / Name"] = part_name
|
||||
|
||||
# 1. Time per print (decimal hours)
|
||||
time_match = re.search(r"; estimated printing time \(normal mode\) = (.*)", gc)
|
||||
if time_match:
|
||||
time_str = time_match.group(1)
|
||||
h = re.search(r"(\d+)h", time_str)
|
||||
m = re.search(r"(\d+)m", time_str)
|
||||
s = re.search(r"(\d+)s", time_str)
|
||||
total_hours = 0.0
|
||||
if h: total_hours += float(h.group(1))
|
||||
if m: total_hours += float(m.group(1)) / 60.0
|
||||
if s: total_hours += float(s.group(1)) / 3600.0
|
||||
fields["Time per print"] = round(total_hours, 2)
|
||||
|
||||
# 2. Parts per print
|
||||
parts_match = re.search(r"; objects_info = (.*)", gc)
|
||||
if parts_match:
|
||||
try:
|
||||
info = json.loads(parts_match.group(1))
|
||||
fields["Parts per print"] = len(info.get("objects", []))
|
||||
except Exception: pass
|
||||
|
||||
# 3. Weight per print
|
||||
weight_match = re.search(r"; total filament used \[g\] = ([\d\.]+)", gc)
|
||||
if weight_match:
|
||||
fields["Weight per print"] = int(float(weight_match.group(1)))
|
||||
|
||||
# 4. Profile for reference
|
||||
profile_match = re.search(r"; printer_settings_id = (.*)", gc)
|
||||
if profile_match:
|
||||
fields["Printer Profile"] = profile_match.group(1)
|
||||
|
||||
# Detect binary trigger in gcode comments
|
||||
is_binary = "; binary_gcode = 1" in gc
|
||||
|
||||
filename = part_name + (".bgcode" if is_binary else ".gcode")
|
||||
|
||||
token_path = getattr(self, self._token_prop_name, "")
|
||||
token = ""
|
||||
if token_path:
|
||||
abs_token_path = bpy.path.abspath(token_path)
|
||||
if os.path.exists(abs_token_path):
|
||||
try:
|
||||
with open(abs_token_path, 'r', encoding='utf-8') as f:
|
||||
token = f.read().strip()
|
||||
except Exception as e:
|
||||
if operator: operator.report({'ERROR'}, f"Failed to read token file: {str(e)}")
|
||||
return 'error'
|
||||
|
||||
if not self._check_config(token):
|
||||
if operator: operator.report({'ERROR'}, f"{self._db_name} configuration incomplete")
|
||||
return 'incomplete config'
|
||||
|
||||
temp_dir = tempfile.TemporaryDirectory()
|
||||
temp_path = Path(temp_dir.name) / filename
|
||||
|
||||
try:
|
||||
if is_binary:
|
||||
content = convert_to_bgcode(gc, part_name, True)
|
||||
with open(temp_path, "wb") as f:
|
||||
f.write(content)
|
||||
else:
|
||||
with open(temp_path, 'w', encoding='utf-8') as f:
|
||||
f.write(gc)
|
||||
|
||||
record_id = self._submit_to_db(token, fields, temp_path)
|
||||
if operator: operator.report({'INFO'}, f"Submitted to {self._db_name} (Record: {record_id})")
|
||||
except Exception as e:
|
||||
if operator: operator.report({'ERROR'}, f"{self._db_name} Error: {str(e)}")
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
temp_dir.cleanup()
|
||||
|
||||
def _check_config(self, token: str) -> bool:
|
||||
return False
|
||||
|
||||
def _submit_to_db(self, token: str, fields: dict, temp_path: Path) -> str:
|
||||
return ""
|
||||
|
||||
@register_class
|
||||
class SLICENODE_BaserowSubmit(ExecSliceNode_DatabaseSubmit):
|
||||
bl_label = "Baserow Submit"
|
||||
button_label = "Submit to Baserow"
|
||||
|
||||
host: NfStringProperty(name="Host", default="https://baserow.io") # pyright: ignore[reportInvalidTypeForm]
|
||||
token_path: NfStringProperty(name="Token File", subtype='FILE_PATH', default="") # pyright: ignore[reportInvalidTypeForm]
|
||||
table_id: NfStringProperty(name="Table ID", default="") # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
_db_name = "Baserow"
|
||||
_token_prop_name = "token_path"
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
layout.prop(self, "host")
|
||||
layout.prop(self, "token_path")
|
||||
layout.prop(self, "table_id")
|
||||
super().draw_buttons(context, layout)
|
||||
|
||||
def _check_config(self, token):
|
||||
return bool(token and self.table_id and self.host)
|
||||
|
||||
def _submit_to_db(self, token, fields, temp_path):
|
||||
from ....infra.networking.baserow_backend import BaserowBackend
|
||||
db = BaserowBackend(self.host, token)
|
||||
|
||||
if self.record_id:
|
||||
try:
|
||||
rid = int(self.record_id)
|
||||
res = db.upsert_record(self.table_id, rid, fields)
|
||||
except ValueError:
|
||||
raise ValueError("Record ID must be an integer")
|
||||
else:
|
||||
res = db.create_record(self.table_id, fields)
|
||||
|
||||
record_id = int(res['id'])
|
||||
db.upload_attachment(self.table_id, record_id, 'GCODE', temp_path)
|
||||
return str(record_id)
|
||||
|
||||
@register_class
|
||||
class SLICENODE_AirtableSubmit(ExecSliceNode_DatabaseSubmit):
|
||||
bl_label = "Airtable Submit"
|
||||
button_label = "Submit to Airtable"
|
||||
|
||||
pat_path: NfStringProperty(name="PAT File", subtype='FILE_PATH', default="") # pyright: ignore[reportInvalidTypeForm]
|
||||
base_id: NfStringProperty(name="Base ID", default="") # pyright: ignore[reportInvalidTypeForm]
|
||||
table_id: NfStringProperty(name="Table ID", default="") # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
_db_name = "Airtable"
|
||||
_token_prop_name = "pat_path"
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
layout.prop(self, "pat_path")
|
||||
layout.prop(self, "base_id")
|
||||
layout.prop(self, "table_id")
|
||||
super().draw_buttons(context, layout)
|
||||
|
||||
def _check_config(self, token):
|
||||
return bool(token and self.base_id and self.table_id)
|
||||
|
||||
def _submit_to_db(self, token, fields, temp_path):
|
||||
from ....infra.networking.airtable_backend import AirtableInterface, AirtableRecord
|
||||
db = AirtableInterface(token)
|
||||
|
||||
if self.record_id:
|
||||
record: AirtableRecord = {"id": self.record_id, "fields": fields}
|
||||
res = db.update_records(self.base_id, self.table_id, [record])
|
||||
record_id = res[0]['id']
|
||||
else:
|
||||
res = db.create_record(self.base_id, self.table_id, fields)
|
||||
record_id = res['id']
|
||||
|
||||
db.upload_attachment(self.base_id, record_id, 'GCODE', temp_path)
|
||||
return str(record_id)
|
||||
323
core/nodeslicer/nodes/path.py
Normal file
323
core/nodeslicer/nodes/path.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
from __future__ import annotations
|
||||
from typing import Any
|
||||
import numpy as np
|
||||
|
||||
from ..bpy_ns_types import NfEnumProperty, notify_tree_changed
|
||||
from ....registry import register_class
|
||||
from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner
|
||||
|
||||
@register_class
|
||||
class SLICENODE_GetPaths(SliceNode):
|
||||
bl_label = "Get Paths"
|
||||
|
||||
def init(self, context):
|
||||
self.add_input("SLICESOCK_Object")
|
||||
self.add_input("SLICESOCK_String", "Extrusion Multiplier Attribute").value = 'e_mult'
|
||||
self.add_input("SLICESOCK_String", "Feedrate Attribute").value = 'f_rate'
|
||||
self.add_input("SLICESOCK_Settings", "Extra Attributes")
|
||||
self.add_output("SLICESOCK_Paths")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
pass
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
|
||||
obj: Any = self.resolve("Object")
|
||||
e_attr_name: str = self.resolve("Extrusion Multiplier Attribute")
|
||||
f_attr_name: str = self.resolve("Feedrate Attribute")
|
||||
extra_attrs: dict[str, str] = self.resolve("Extra Attributes")
|
||||
|
||||
from ....infra.blender.blender_mesh_capture import capture_mesh_edges
|
||||
so, e_rates, f_rates, extra_attrs_data = capture_mesh_edges(obj, e_attr_name, f_attr_name, extra_attrs)
|
||||
|
||||
return SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data)
|
||||
|
||||
@register_class
|
||||
class SLICENODE_SliceShells(SliceNode):
|
||||
bl_label = "Slice Shells"
|
||||
|
||||
def init(self, context):
|
||||
self.add_input("SLICESOCK_Mesh")
|
||||
self.add_input("SLICESOCK_Float", "Layer Height").value = 0.2
|
||||
self.add_output("SLICESOCK_Paths")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
|
||||
if socket_name != "Paths": return SlicePaths()
|
||||
|
||||
slicing_collection: SlicingCollection | None = self.resolve("Mesh")
|
||||
layer_height: float = self.resolve("Layer Height")
|
||||
|
||||
if not slicing_collection: return SlicePaths()
|
||||
|
||||
merged_object = SlicingObject()
|
||||
coll: SlicingCollection = self.resolve("Mesh")
|
||||
if coll:
|
||||
for obj in coll.objects:
|
||||
merged_object.mesh = np.concatenate([merged_object.mesh, obj.mesh], axis=0)
|
||||
|
||||
min_z = merged_object.mesh[:,0:3,2:3].min()
|
||||
from math import floor
|
||||
min_z = floor(min_z / layer_height) * layer_height
|
||||
max_z = merged_object.mesh[:,0:3,2:3].max()
|
||||
|
||||
z_values = np.arange(min_z, max_z + layer_height, layer_height)
|
||||
|
||||
slices_group: list[list[np.ndarray]] | None = slice_at_range([merged_object.mesh], z_values)
|
||||
assert slices_group
|
||||
slices = slices_group[0]
|
||||
|
||||
return SlicePaths(slices)
|
||||
|
||||
@register_class
|
||||
class SLICENODE_BufferShells(SliceNode):
|
||||
bl_label = "Buffer Shells"
|
||||
|
||||
shape_option: NfEnumProperty(
|
||||
name="Miter",
|
||||
items=[
|
||||
('round', "Round", ""),
|
||||
('square', "Square", ""),
|
||||
('flat', "Flat", "")
|
||||
]
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
self.add_input("SLICESOCK_Paths")
|
||||
self.add_input("SLICESOCK_Float", "Distance").value = 0
|
||||
self.add_input("SLICESOCK_VectorRotation", "Rotation")
|
||||
self.add_output("SLICESOCK_Paths")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
assert layout
|
||||
layout.prop(self, "shape_option")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
|
||||
if socket_name != "Paths": return SlicePaths()
|
||||
|
||||
paths: SlicePaths = self.resolve("Paths")
|
||||
distance: float = self.resolve("Distance") or 0.0
|
||||
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
|
||||
|
||||
if not paths: return SlicePaths()
|
||||
|
||||
return paths.buffer(distance, miter=self.shape_option, rotation=rotation)
|
||||
|
||||
@register_class
|
||||
class SLICENODE_PathOffset(SliceNode):
|
||||
bl_label = "Path Offset"
|
||||
|
||||
def init(self, context):
|
||||
self.add_input("SLICESOCK_Paths", "Paths")
|
||||
self.add_input("SLICESOCK_Vector", "Offset")
|
||||
self.add_output("SLICESOCK_Paths", "Paths")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
|
||||
paths: SlicePaths = self.resolve("Paths")
|
||||
offset: Any = self.resolve("Offset") or (0.0, 0.0, 0.0)
|
||||
|
||||
if not paths: return SlicePaths()
|
||||
|
||||
return paths.offset(np.array(offset, dtype=np.float32))
|
||||
|
||||
@register_class
|
||||
class SLICENODE_BufferShellsMultiple(SliceNode):
|
||||
bl_label = "Buffer Shells Multiple"
|
||||
|
||||
shape_option: NfEnumProperty(
|
||||
name="Miter",
|
||||
items=[
|
||||
('round', "Round", ""),
|
||||
('square', "Square", ""),
|
||||
('flat', "Flat", "")
|
||||
]
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
self.add_input("SLICESOCK_Paths")
|
||||
self.add_input("SLICESOCK_Float", "Distance").value = 0
|
||||
self.add_input("SLICESOCK_Float", "Width Step").value = 0.4
|
||||
self.add_input("SLICESOCK_Int", "Count").value = 2
|
||||
self.add_input("SLICESOCK_VectorRotation", "Rotation")
|
||||
self.add_output("SLICESOCK_Paths")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
assert layout
|
||||
layout.prop(self, "shape_option")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
|
||||
if socket_name != "Paths": return SlicePaths()
|
||||
|
||||
paths: SlicePaths = self.resolve("Paths")
|
||||
base_distance: float = self.resolve("Distance") or 0.0
|
||||
width_step: float = self.resolve("Width Step") or 0.0
|
||||
count: int = self.resolve("Count") or 1
|
||||
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
|
||||
|
||||
if not paths: return SlicePaths()
|
||||
|
||||
all_buffers: list[SlicePaths] = []
|
||||
|
||||
for i in range(count):
|
||||
dist = base_distance + (i * width_step)
|
||||
all_buffers.append(paths.buffer(dist, miter=self.shape_option, rotation=rotation))
|
||||
|
||||
# Zip Logic: Interleave paths by group (Z-layer)
|
||||
all_groups = set()
|
||||
grouped_inputs = []
|
||||
for p_list in all_buffers:
|
||||
d = {}
|
||||
for i, g in enumerate(p_list.groups):
|
||||
all_groups.add(g)
|
||||
d.setdefault(g, []).append(p_list[i])
|
||||
grouped_inputs.append(d)
|
||||
|
||||
sorted_groups = sorted(list(all_groups))
|
||||
|
||||
merged = SlicePaths()
|
||||
for g in sorted_groups:
|
||||
for d in grouped_inputs:
|
||||
group_paths = d.get(g, [])
|
||||
for p in group_paths:
|
||||
merged.append_with_group(p, g)
|
||||
|
||||
return paths.copy_with(merged, merged.groups)
|
||||
|
||||
@register_class
|
||||
class SLICENODE_Infill(SliceNode):
|
||||
bl_label = "Infill"
|
||||
|
||||
infill_type: NfEnumProperty(
|
||||
name="Type",
|
||||
items=[
|
||||
('GRID', "Grid", "Cross-hatch grid"),
|
||||
('RECTILINEAR', "Rectilinear", "Alternating parallel lines"),
|
||||
],
|
||||
default='GRID',
|
||||
update=notify_tree_changed
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
self.add_input("SLICESOCK_Paths")
|
||||
self.add_input("SLICESOCK_Float", "Density").value = 2.0
|
||||
self.add_input("SLICESOCK_Float", "Infill Rotation").value = 0.0
|
||||
self.add_input("SLICESOCK_VectorRotation", "Layer Rotation")
|
||||
self.add_output("SLICESOCK_Paths")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
assert layout
|
||||
layout.prop(self, "infill_type", text="")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
|
||||
if socket_name != "Paths": return SlicePaths()
|
||||
|
||||
paths: SlicePaths = self.resolve("Paths")
|
||||
density: float = self.resolve("Density") or 2.0
|
||||
infill_rotation: float = self.resolve("Infill Rotation") or 0.0
|
||||
rotation: Any = self.resolve("Layer Rotation") or (0.0, 0.0, 0.0)
|
||||
|
||||
if not paths: return SlicePaths()
|
||||
|
||||
return paths.infill(
|
||||
density,
|
||||
rotation_deg=infill_rotation,
|
||||
infill_type=self.infill_type, # pyright: ignore[reportOptionalMemberAccess]
|
||||
layer_rotation=rotation
|
||||
)
|
||||
|
||||
@register_class
|
||||
class SLICENODE_JoinSlices(SliceNodeJoiner):
|
||||
bl_label = "Join Slices"
|
||||
socket_type = "SLICESOCK_Paths"
|
||||
|
||||
operation: NfEnumProperty(
|
||||
name="Operation",
|
||||
items=[
|
||||
('UNION', "Union", "Union of the slices"),
|
||||
('INTERSECTION', "Intersection", "Intersection of the slices"),
|
||||
('DIFFERENCE', "Subtract", "Clipping of the first slice"),
|
||||
('XOR', "Exclusive or", "Exclusive or of the slices"),
|
||||
],
|
||||
default='UNION'
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
assert layout
|
||||
layout.prop(self, "operation", text="")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
|
||||
from ....infra.blender.blender_slice_nodes_generic import resolve_input
|
||||
accumulated: SlicePaths = SlicePaths()
|
||||
current: SlicePaths = SlicePaths()
|
||||
for input in self.inputs:
|
||||
current = resolve_input(input)
|
||||
|
||||
if not current:
|
||||
continue
|
||||
|
||||
if not accumulated:
|
||||
accumulated = current
|
||||
continue
|
||||
|
||||
# Boolean operation loses specific per-segment grouping as it merges polygons.
|
||||
# We return a new SlicePaths from the result.
|
||||
accumulated = accumulated.boolean(current, self.operation)
|
||||
|
||||
return accumulated
|
||||
|
||||
@register_class
|
||||
class SLICENODE_JoinPaths(SliceNodeJoiner):
|
||||
bl_label = "Join Paths"
|
||||
socket_type = "SLICESOCK_Paths"
|
||||
|
||||
mode: NfEnumProperty(
|
||||
name="Mode",
|
||||
items=[
|
||||
('EXTEND', "Extend", "Append paths sequentially"),
|
||||
('ZIP', "Zip", "Interleave paths from all inputs"),
|
||||
],
|
||||
default='EXTEND'
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
assert layout
|
||||
layout.prop(self, "mode", text="")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
|
||||
from ....infra.blender.blender_slice_nodes_generic import resolve_input
|
||||
inputs_data: list[SlicePaths] = []
|
||||
for input in self.inputs:
|
||||
paths = resolve_input(input)
|
||||
if paths:
|
||||
inputs_data.append(paths)
|
||||
|
||||
if not inputs_data:
|
||||
return SlicePaths()
|
||||
|
||||
if self.mode == 'EXTEND':
|
||||
merged = SlicePaths()
|
||||
for paths in inputs_data:
|
||||
merged.extend_with_groups(paths)
|
||||
return merged
|
||||
|
||||
elif self.mode == 'ZIP':
|
||||
# Group-based Zip: sequentially add each group from all inputs.
|
||||
# E.g. Group 1 (In1), Group 1 (In2), Group 2 (In1), Group 2 (In2)...
|
||||
all_groups = set()
|
||||
grouped_inputs = []
|
||||
for p_list in inputs_data:
|
||||
d = {}
|
||||
for i, g in enumerate(p_list.groups):
|
||||
all_groups.add(g)
|
||||
d.setdefault(g, []).append(p_list[i])
|
||||
grouped_inputs.append(d)
|
||||
|
||||
sorted_groups = sorted(list(all_groups))
|
||||
|
||||
merged = SlicePaths()
|
||||
for g in sorted_groups:
|
||||
for d in grouped_inputs:
|
||||
group_paths = d.get(g, [])
|
||||
for p in group_paths:
|
||||
merged.append_with_group(p, g)
|
||||
return merged
|
||||
|
||||
return SlicePaths()
|
||||
182
core/nodeslicer/nodes/preview.py
Normal file
182
core/nodeslicer/nodes/preview.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
from __future__ import annotations
|
||||
import bpy
|
||||
import os
|
||||
|
||||
from ..bpy_ns_types import NfBoolProperty
|
||||
from ....registry import register_class
|
||||
from .base import ExecSliceNode
|
||||
|
||||
@register_class
|
||||
class SLICENODE_GcodeToMesh(ExecSliceNode):
|
||||
bl_label = "Gcode Preview"
|
||||
|
||||
button_label = "Update Preview"
|
||||
show_e_0: NfBoolProperty(name="Show Zero Extrusions", default=False) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
self._init_exec()
|
||||
self.add_input("SLICESOCK_Gcode", "Gcode")
|
||||
self.add_input("SLICESOCK_String", "Name")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
super().draw_buttons(context, layout)
|
||||
layout.prop(self, 'show_e_0')
|
||||
|
||||
def evaluate(self, operator=None):
|
||||
gcode: str = self.resolve("Gcode")
|
||||
mesh_name: str = self.resolve("Name")
|
||||
|
||||
if not gcode:
|
||||
if mesh_name in bpy.data.objects:
|
||||
obj = bpy.data.objects[mesh_name]
|
||||
if obj.type == 'MESH':
|
||||
obj.data.clear_geometry()
|
||||
return 'empty'
|
||||
|
||||
vertices = []
|
||||
edges = []
|
||||
e_rates = []
|
||||
|
||||
last_x, last_y, last_z = 0.0, 0.0, 0.0
|
||||
last_e = 0.0
|
||||
|
||||
offset_x, offset_y = 0.0, 0.0
|
||||
custom_default_extrusion_rate = 0.0
|
||||
|
||||
last_v_idx = -1
|
||||
last_p2 = None
|
||||
|
||||
def segment_length_3d(p1, p2) -> float:
|
||||
return ((p2[0]-p1[0])**2 + (p2[1]-p1[1])**2 + (p2[2]-p1[2])**2)**0.5
|
||||
|
||||
for line in gcode.splitlines():
|
||||
# Parse metadata from comments
|
||||
if ';' in line:
|
||||
comment = line.split(';', 1)[1].strip()
|
||||
if comment.startswith("OFFSET_X="):
|
||||
try: offset_x = float(comment.split('=')[1])
|
||||
except ValueError: pass
|
||||
elif comment.startswith("OFFSET_Y="):
|
||||
try: offset_y = float(comment.split('=')[1])
|
||||
except ValueError: pass
|
||||
elif comment.startswith("DEFAULT_EXTRUSION_RATE="):
|
||||
try: custom_default_extrusion_rate = float(comment.split('=')[1])
|
||||
except ValueError: pass
|
||||
|
||||
line_no_comment = line.split(';')[0].strip()
|
||||
if not line_no_comment: continue
|
||||
|
||||
parts = line_no_comment.split()
|
||||
if not parts: continue
|
||||
cmd = parts[0].upper()
|
||||
|
||||
params = {}
|
||||
for p in parts[1:]:
|
||||
if len(p) < 2: continue
|
||||
try:
|
||||
# Scale coordinates by 0.001 (mm to Blender units)
|
||||
val = float(p[1:])
|
||||
if p[0].upper() in 'XYZ':
|
||||
val *= 0.001
|
||||
params[p[0].upper()] = val
|
||||
except ValueError: continue
|
||||
|
||||
new_x = params.get('X', last_x)
|
||||
new_y = params.get('Y', last_y)
|
||||
new_z = params.get('Z', last_z)
|
||||
new_e = params.get('E', last_e)
|
||||
|
||||
show = False
|
||||
if self.show_e_0: show = True
|
||||
if cmd == 'G1' and new_e > 0: show = True
|
||||
|
||||
if show:
|
||||
# Subtract offsets (scaled to Blender units) to return to original space
|
||||
ox, oy = offset_x * 0.001, offset_y * 0.001
|
||||
p1 = (last_x - ox, last_y - oy, last_z)
|
||||
p2 = (new_x - ox, new_y - oy, new_z)
|
||||
|
||||
# Calculate e_rate (proportion between E and default_extrusion_rate)
|
||||
seg_len_mm = segment_length_3d(p1, p2) * 1000.0
|
||||
if seg_len_mm > 0 and custom_default_extrusion_rate > 0:
|
||||
e_rate = (new_e / seg_len_mm) / custom_default_extrusion_rate
|
||||
else:
|
||||
e_rate = 1.0
|
||||
|
||||
if last_v_idx != -1 and p1 == last_p2:
|
||||
# Connect to previous segment
|
||||
v2_idx = len(vertices)
|
||||
vertices.append(p2)
|
||||
edges.append((last_v_idx, v2_idx))
|
||||
last_v_idx = v2_idx
|
||||
else:
|
||||
# Start new path
|
||||
v1_idx = len(vertices)
|
||||
v2_idx = v1_idx + 1
|
||||
vertices.extend([p1, p2])
|
||||
edges.append((v1_idx, v2_idx))
|
||||
last_v_idx = v2_idx
|
||||
|
||||
e_rates.append(e_rate)
|
||||
last_p2 = p2
|
||||
else:
|
||||
# Break path connection on travel or non-shown move
|
||||
last_v_idx = -1
|
||||
last_p2 = None
|
||||
|
||||
last_x, last_y, last_z = new_x, new_y, new_z
|
||||
last_e = new_e
|
||||
|
||||
if not vertices:
|
||||
return 'no segments'
|
||||
|
||||
# Create or find "Debug" mesh object
|
||||
if mesh_name in bpy.data.objects:
|
||||
obj = bpy.data.objects[mesh_name]
|
||||
mesh = obj.data
|
||||
mesh.clear_geometry()
|
||||
else:
|
||||
mesh = bpy.data.meshes.new(mesh_name)
|
||||
obj = bpy.data.objects.new(mesh_name, mesh)
|
||||
assert bpy.context.collection
|
||||
bpy.context.collection.objects.link(obj)
|
||||
|
||||
mesh.from_pydata(vertices, edges, [])
|
||||
|
||||
# Create e_mult attribute on edges
|
||||
if "e_mult" not in mesh.attributes:
|
||||
mesh.attributes.new(name="e_mult", type='FLOAT', domain='EDGE')
|
||||
|
||||
e_mult_attr = mesh.attributes["e_mult"]
|
||||
e_mult_attr.data.foreach_set("value", e_rates)
|
||||
|
||||
mesh.update()
|
||||
|
||||
# Assign Geometry Nodes nodegroup for preview
|
||||
ng_name = "Nonplanar Slicing Preview"
|
||||
if ng_name not in bpy.data.node_groups:
|
||||
# Try to load it from nodes.blend in the addon root
|
||||
addon_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
blend_path = os.path.join(addon_dir, "nodes.blend")
|
||||
if os.path.exists(blend_path):
|
||||
try:
|
||||
with bpy.data.libraries.load(blend_path) as (data_from, data_to):
|
||||
if ng_name in data_from.node_groups:
|
||||
data_to.node_groups = [ng_name]
|
||||
except Exception as e:
|
||||
if operator: operator.report({'WARNING'}, f"Failed to load preview node group: {e}")
|
||||
|
||||
if ng_name in bpy.data.node_groups:
|
||||
ng = bpy.data.node_groups[ng_name]
|
||||
# Check if already assigned
|
||||
has_preview = False
|
||||
for mod in obj.modifiers:
|
||||
if mod.type == 'NODES' and mod.node_group == ng: # type: ignore
|
||||
has_preview = True
|
||||
break
|
||||
|
||||
if not has_preview:
|
||||
mod = obj.modifiers.new(name="Preview", type='NODES') # type: ignore
|
||||
mod.node_group = ng
|
||||
|
||||
return 'ok'
|
||||
377
core/nodeslicer/nodes/system.py
Normal file
377
core/nodeslicer/nodes/system.py
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
import os
|
||||
import bpy
|
||||
import tempfile
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import numpy as np
|
||||
|
||||
from ..bpy_ns_types import NfBoolProperty, NfStringProperty
|
||||
from ....registry import register_class
|
||||
from .base import ExecSliceNode, SliceNode, SliceNodeJoiner, SlicingCollection, SlicingGroup
|
||||
|
||||
@register_class
|
||||
class SLICENODE_PrinterList(SliceNode):
|
||||
bl_label = "Printer List"
|
||||
|
||||
def _refresh(self):
|
||||
import json
|
||||
if not self.printers_json_path:
|
||||
return
|
||||
|
||||
abs_path = bpy.path.abspath(self.printers_json_path)
|
||||
if not os.path.exists(abs_path):
|
||||
return
|
||||
|
||||
try:
|
||||
with open(abs_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
printers = data.get("printers", [])
|
||||
printer_names = set()
|
||||
|
||||
for p_data in printers:
|
||||
name = p_data.get("name", "Unknown")
|
||||
printer_names.add(name)
|
||||
sock = self.outputs.get(name)
|
||||
if not sock:
|
||||
sock = self.add_output("SLICESOCK_Printer", name)
|
||||
|
||||
# Update properties
|
||||
sock.label = name
|
||||
sock.host = str(p_data.get("ip", "localhost"))
|
||||
sock.port = int(p_data.get("port", 80))
|
||||
sock.prefix = str(p_data.get("prefix", ""))
|
||||
sock.username = str(p_data.get("username", ""))
|
||||
sock.password = str(p_data.get("password", ""))
|
||||
sock.host_type = str(p_data.get("host_type", "PRUSALINK")).upper()
|
||||
|
||||
# Remove stale sockets
|
||||
for sock in list(self.outputs):
|
||||
if sock.name not in printer_names:
|
||||
self.outputs.remove(sock)
|
||||
|
||||
def update_config(self, context):
|
||||
self._refresh()
|
||||
|
||||
printers_json_path: NfStringProperty(name="Config File", default="", subtype='FILE_PATH', update=update_config) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
pass
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
layout.prop(self, "printers_json_path") # pyright: ignore[reportOptionalMemberAccess]
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
|
||||
self._refresh()
|
||||
sock = self.outputs.get(socket_name)
|
||||
if sock:
|
||||
return sock.value
|
||||
return None
|
||||
|
||||
@register_class
|
||||
class SLICENODE_PrusaslicerSlice(SliceNode):
|
||||
bl_label = "PrusaSlicer Slice"
|
||||
|
||||
prusaslicer_executable: NfStringProperty(name="PrusaSlicer Path", subtype='FILE_PATH', default='flatpak run com.prusa3d.PrusaSlicer') # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
self.add_input("SLICESOCK_PrusaslicerConf", "Config")
|
||||
self.add_input("SLICESOCK_Settings", "Settings")
|
||||
self.add_multi_socket_group("SLICESOCK_Mesh", "SLICESOCK_GcodeRo", name="Slicing Group", in_name="Mesh", out_name="Gcode")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
assert layout
|
||||
layout.prop(self, "prusaslicer_executable")
|
||||
|
||||
def evaluate_output(self, socket_name: str, identifier: str = "", **kwargs) -> Any:
|
||||
if identifier.startswith("Slicing Group_ms_out_"):
|
||||
try:
|
||||
idx = int(identifier.split("_")[-1])
|
||||
return self.evaluate_for_index(idx)
|
||||
except (ValueError, IndexError):
|
||||
return ""
|
||||
return super().evaluate_output(socket_name, **kwargs)
|
||||
|
||||
def evaluate_for_index(self, index: int, operator=None) -> str:
|
||||
mesh_groups: list[SlicingCollection | SlicingGroup] = self.resolve_multi_input("Slicing Group")
|
||||
if index >= len(mesh_groups):
|
||||
return ""
|
||||
|
||||
mesh_data = mesh_groups[index]
|
||||
if not mesh_data:
|
||||
return ""
|
||||
|
||||
conf_data = self.resolve("Config")
|
||||
overrides = self.resolve("Settings")
|
||||
|
||||
if not conf_data:
|
||||
return ""
|
||||
|
||||
# Extract data from config dict
|
||||
printer_id = conf_data['printer']
|
||||
filament_ids = conf_data['filament']
|
||||
print_id = conf_data['print']
|
||||
cache = conf_data['cache']
|
||||
|
||||
# Generate the SlicingGroup
|
||||
if isinstance(mesh_data, SlicingGroup):
|
||||
slicing_group = mesh_data
|
||||
else:
|
||||
slicing_group = SlicingGroup([])
|
||||
slicing_group.collections = {mesh_data.objects[0].name: mesh_data}
|
||||
|
||||
# Offset by the bed center
|
||||
def parse_bed_center(bed_shape_str: str) -> np.ndarray:
|
||||
try:
|
||||
points = []
|
||||
for p in bed_shape_str.split(','):
|
||||
x, y = map(float, p.split('x'))
|
||||
points.append((x, y))
|
||||
points_np = np.array(points)
|
||||
return (points_np.min(axis=0) + points_np.max(axis=0)) / 2.0
|
||||
except:
|
||||
return np.array([125.0, 105.0])
|
||||
|
||||
bed_shape = cache.profiles[printer_id].all_conf_dict.get('bed_shape', '0x0,250x0,250x210,0x210')
|
||||
bed_center = parse_bed_center(bed_shape)
|
||||
|
||||
a_z = -slicing_group.min_z
|
||||
offset = np.array([bed_center[0], bed_center[1], a_z]) - np.array([slicing_group.center_xy[0], slicing_group.center_xy[1], 0])
|
||||
slicing_group.offset(offset)
|
||||
|
||||
# Prepare overrides for generate_conf_writer
|
||||
writer_overrides = {k: {'value': v} for k, v in overrides.items()}
|
||||
|
||||
# Generate the ConfigWriter
|
||||
from ....infra.prusaslicer_profiles import ConfigWriter
|
||||
conf_writer: ConfigWriter = cache.generate_conf_writer(
|
||||
printer_id,
|
||||
filament_ids,
|
||||
print_id,
|
||||
overrides=writer_overrides
|
||||
)
|
||||
|
||||
binary_gcode_value = conf_writer.config_dict.get('binary_gcode', '0')
|
||||
conf_writer.config_dict['binary_gcode'] = '0'
|
||||
|
||||
# Prepare 3mf path
|
||||
temp_dir = Path(tempfile.gettempdir())
|
||||
output_path = temp_dir / f"tmp_{index}.3mf"
|
||||
|
||||
try:
|
||||
from ....infra._3mf_writer._3mf import prepare_3mf
|
||||
prepare_3mf(output_path, slicing_group, conf_writer, z_gcodes=[])
|
||||
|
||||
exe_str = self.prusaslicer_executable
|
||||
if not exe_str: return ""
|
||||
|
||||
cmd_parts = shlex.split(exe_str)
|
||||
if not cmd_parts: return ""
|
||||
|
||||
if not shutil.which(cmd_parts[0]):
|
||||
abs_path = bpy.path.abspath(cmd_parts[0])
|
||||
if os.path.exists(abs_path):
|
||||
cmd_parts[0] = abs_path
|
||||
|
||||
if not shutil.which(cmd_parts[0]):
|
||||
return ""
|
||||
|
||||
gcode_path = output_path.with_suffix(".gcode")
|
||||
cmd = cmd_parts + ["--export-gcode", str(output_path), "--output", str(gcode_path)]
|
||||
|
||||
process = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if process.returncode != 0:
|
||||
return ""
|
||||
|
||||
if gcode_path.exists():
|
||||
with open(gcode_path, 'r', encoding='utf-8') as f:
|
||||
gcode_result = f.read()
|
||||
gcode_result += f"\n; binary_gcode = {binary_gcode_value}\n"
|
||||
|
||||
output_path.unlink()
|
||||
if gcode_path.exists(): gcode_path.unlink()
|
||||
|
||||
return gcode_result
|
||||
else:
|
||||
return ""
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return ""
|
||||
|
||||
@register_class
|
||||
class SLICENODE_PrusaslicerPreview(ExecSliceNode):
|
||||
bl_label = "PrusaSlicer Preview"
|
||||
button_label = "Open Preview"
|
||||
|
||||
prusaslicer_executable: NfStringProperty(
|
||||
name="PrusaSlicer Path",
|
||||
subtype='FILE_PATH',
|
||||
default='flatpak run com.prusa3d.PrusaSlicer'
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
self._init_exec()
|
||||
self.add_multi_socket_group("SLICESOCK_Gcode", name="Gcode")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
super().draw_buttons(context, layout)
|
||||
layout.prop(self, "prusaslicer_executable")
|
||||
|
||||
def evaluate(self, operator=None):
|
||||
from ....infra.blender.blender_slice_nodes_generic import resolve_input
|
||||
gcodes: list[str] = [g for g in self.resolve_multi_input("Gcode") if g]
|
||||
if not gcodes:
|
||||
if operator: operator.report({'ERROR'}, "No Gcode to preview")
|
||||
return 'no gcode'
|
||||
|
||||
exe_str = self.prusaslicer_executable
|
||||
if not exe_str:
|
||||
if operator: operator.report({'ERROR'}, "PrusaSlicer executable path is empty")
|
||||
return 'error'
|
||||
|
||||
# Parse command (handles multi-word commands like flatpak)
|
||||
cmd_parts = shlex.split(exe_str)
|
||||
if not cmd_parts:
|
||||
if operator: operator.report({'ERROR'}, "Invalid PrusaSlicer command")
|
||||
return 'error'
|
||||
|
||||
# Try to resolve the first part as a path if it's not a command in PATH
|
||||
if not shutil.which(cmd_parts[0]):
|
||||
abs_path = bpy.path.abspath(cmd_parts[0])
|
||||
if os.path.exists(abs_path):
|
||||
cmd_parts[0] = abs_path
|
||||
|
||||
# Final check if command exists
|
||||
if not shutil.which(cmd_parts[0]):
|
||||
if operator: operator.report({'ERROR'}, f"Command or executable not found: {cmd_parts[0]}")
|
||||
return 'error'
|
||||
|
||||
# Write to temp files
|
||||
# We use named temporary files that stay around for a bit.
|
||||
# PrusaSlicer needs the files to exist when it starts.
|
||||
try:
|
||||
for gcode in gcodes:
|
||||
fd, temp_path = tempfile.mkstemp(suffix='.gcode')
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
f.write(gcode)
|
||||
|
||||
cmd = cmd_parts + ["--gcodeviewer", temp_path]
|
||||
|
||||
# Use Popen to not block Blender
|
||||
subprocess.Popen(cmd)
|
||||
|
||||
if operator:
|
||||
operator.report({'INFO'}, f"PrusaSlicer Previews launched")
|
||||
except Exception as e:
|
||||
if operator: operator.report({'ERROR'}, f"Failed to launch preview: {str(e)}")
|
||||
return 'error'
|
||||
|
||||
return 'ok'
|
||||
|
||||
@register_class
|
||||
class SLICENODE_JoinExecution(SliceNodeJoiner):
|
||||
bl_label = "Join Execution"
|
||||
socket_type = "SLICESOCK_ExecutionLink"
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> list[ExecSliceNode]:
|
||||
from ....infra.blender.blender_slice_nodes_generic import resolve_input
|
||||
nodes: list[ExecSliceNode] = []
|
||||
for input in self.inputs:
|
||||
res = resolve_input(input)
|
||||
if isinstance(res, ExecSliceNode):
|
||||
nodes.append(res)
|
||||
elif isinstance(res, list):
|
||||
nodes.extend(res)
|
||||
return nodes
|
||||
|
||||
@register_class
|
||||
class SLICENODE_Executor(ExecSliceNode, SliceNodeJoiner):
|
||||
bl_label = "Executor"
|
||||
button_label = "Execute All"
|
||||
socket_type = "SLICESOCK_ExecutionLink"
|
||||
|
||||
auto_refresh: NfBoolProperty(name="Auto-refresh", default=False) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
self._init_exec()
|
||||
self.add_multi_socket_group(self.socket_type, name="Links")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
super().draw_buttons(context, layout)
|
||||
layout.prop(self, "auto_refresh")
|
||||
|
||||
def evaluate(self, operator=None):
|
||||
from ....infra.blender.blender_slice_nodes_generic import (
|
||||
node_execution_feedback,
|
||||
feedback_disabled,
|
||||
execution_cache,
|
||||
resolve_input
|
||||
)
|
||||
|
||||
# If auto_refresh is on, we don't want highlighting/redraws for upstream nodes either
|
||||
maybe_disabled = feedback_disabled if self.auto_refresh else lambda: execution_cache()
|
||||
|
||||
for i, job in enumerate(self.inputs):
|
||||
# Resolve the input to get the connected node(s)
|
||||
res = resolve_input(job)
|
||||
if res is None:
|
||||
continue
|
||||
|
||||
# Ensure we have a list of jobs to process (JoinExecution returns a list)
|
||||
jobs_to_run = res if isinstance(res, list) else [res]
|
||||
|
||||
for upstream_node in jobs_to_run:
|
||||
if not hasattr(upstream_node, "evaluate"):
|
||||
continue
|
||||
|
||||
if operator:
|
||||
operator.report({'INFO'}, f"Executing: {upstream_node.bl_label}")
|
||||
|
||||
try:
|
||||
with maybe_disabled():
|
||||
with node_execution_feedback(upstream_node):
|
||||
exec_res = upstream_node.evaluate(operator=operator)
|
||||
if exec_res != 'ok' and exec_res is not None:
|
||||
if operator: operator.report({'WARNING'}, f"Node {upstream_node.bl_label} returned {exec_res}")
|
||||
except Exception as e:
|
||||
if operator: operator.report({'ERROR'}, f"Failed executing {upstream_node.bl_label}: {str(e)}")
|
||||
return 'error'
|
||||
|
||||
return 'ok'
|
||||
|
||||
@register_class
|
||||
class SLICENODE_JoinSettings(SliceNodeJoiner):
|
||||
bl_label = "Join Settings"
|
||||
socket_type = "SLICESOCK_Settings"
|
||||
output_socket_type = "SLICESOCK_SettingsRo"
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> dict[str, str]:
|
||||
from ....infra.blender.blender_slice_nodes_generic import resolve_input
|
||||
merged: dict[str, str] = {}
|
||||
for input in self.inputs:
|
||||
val = resolve_input(input)
|
||||
if isinstance(val, dict):
|
||||
merged.update(val)
|
||||
return merged
|
||||
|
||||
@register_class
|
||||
class SLICENODE_Test(ExecSliceNode):
|
||||
bl_label = "Test"
|
||||
|
||||
button_label = "Test"
|
||||
|
||||
def init(self, context):
|
||||
self._init_exec()
|
||||
self.add_input("SLICESOCK_Gcode")
|
||||
|
||||
def evaluate(self, operator=None):
|
||||
_ = self.resolve("Gcode")
|
||||
return 'ok'
|
||||
|
|
@ -407,7 +407,6 @@ def objects_to_tris(objects: list[Object], scale) -> NDArray[float64]:
|
|||
|
||||
def _cached_capture_mesh_edges(
|
||||
obj_name: str,
|
||||
mesh_name: str,
|
||||
matrix_tuple: tuple[float, ...],
|
||||
scene_scale: float,
|
||||
e_attr_name: str = "e_mult",
|
||||
|
|
@ -496,6 +495,7 @@ def _cached_capture_mesh_edges(
|
|||
if hasattr(obj_eval, "to_mesh_clear"):
|
||||
obj_eval.to_mesh_clear()
|
||||
|
||||
|
||||
def capture_mesh_edges(obj: Object, e_attr_name: str = "e_mult", f_attr_name: str = "f_rate", extra_attrs: dict[str, str] | None = None) -> tuple[list[NDArray[float64]], list[NDArray[float64]], list[NDArray[float64]], dict[str, list[NDArray[float64]]]]:
|
||||
if not obj or not obj.data: return [], [], [], {}
|
||||
|
||||
|
|
@ -510,7 +510,6 @@ def capture_mesh_edges(obj: Object, e_attr_name: str = "e_mult", f_attr_name: st
|
|||
|
||||
return _cached_capture_mesh_edges(
|
||||
obj.name,
|
||||
obj.data.name,
|
||||
matrix_tuple,
|
||||
scene_scale,
|
||||
e_attr_name,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue