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(
|
def _cached_capture_mesh_edges(
|
||||||
obj_name: str,
|
obj_name: str,
|
||||||
mesh_name: str,
|
|
||||||
matrix_tuple: tuple[float, ...],
|
matrix_tuple: tuple[float, ...],
|
||||||
scene_scale: float,
|
scene_scale: float,
|
||||||
e_attr_name: str = "e_mult",
|
e_attr_name: str = "e_mult",
|
||||||
|
|
@ -496,6 +495,7 @@ def _cached_capture_mesh_edges(
|
||||||
if hasattr(obj_eval, "to_mesh_clear"):
|
if hasattr(obj_eval, "to_mesh_clear"):
|
||||||
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]]]]:
|
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 [], [], [], {}
|
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(
|
return _cached_capture_mesh_edges(
|
||||||
obj.name,
|
obj.name,
|
||||||
obj.data.name,
|
|
||||||
matrix_tuple,
|
matrix_tuple,
|
||||||
scene_scale,
|
scene_scale,
|
||||||
e_attr_name,
|
e_attr_name,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue