This commit is contained in:
Nicolas Predella 2026-05-10 17:09:50 +03:00
parent 1a7bbaf9e5
commit 241b277895
8 changed files with 1067 additions and 881 deletions

View file

@ -1,900 +1,27 @@
from __future__ import annotations
from numpy._typing._array_like import NDArray
from numpy import float64, ndarray
import bpy
from . import registry
from .core.nodeslicer import tree
bl_info = {
"name": "NodeSlicer",
"author": "OpenAI",
"author": "Nicolas Predella",
"version": (0, 1, 0),
"blender": (3, 6, 0),
"location": "Node Editor > Add",
"description": "Very small custom nodes example",
"description": "Prepare GCodes with Nodes",
"category": "Node",
}
from math import floor
from typing import TYPE_CHECKING, Any
import bpy
import os
import numpy as np
from mathutils import Euler, Matrix
from bpy.types import NodeTree, Operator, Menu
from bpy.props import PointerProperty, StringProperty, FloatProperty, IntProperty
from .registry import register_class
from .core import slice
from .core.slice import Pather, slice_at_range, SlicePaths
from .infra.blender_mesh_capture import SlicingObject, capture_mesh_edges
from .infra.blender_slice_nodes_generic import SliceNode, SliceNodeSocket, resolve_input
if TYPE_CHECKING:
from bpy.stub_internal.rna_enums import OperatorReturnItems
class SliceIntrinsics:
bed_x: float
bed_y: float
feedrate: float
extrusion_rate: float
def __init__(self, bed_x=250.0, bed_y=250.0, feedrate=1200.0, extrusion_rate=0.017):
self.bed_x = bed_x
self.bed_y = bed_y
self.feedrate = feedrate
self.extrusion_rate = extrusion_rate
# -----------------------------------------------------------------------------
# Node tree
# -----------------------------------------------------------------------------
@register_class
class SLICETREE_NodeTree(NodeTree):
bl_idname = "SLICETREE_NodeTree"
bl_label = "NodeSlicer Tree"
bl_icon = "NODETREE"
# -----------------------------------------------------------------------------
# Sockets
# -----------------------------------------------------------------------------
class LabelNodeSocket(SliceNodeSocket):
default = ""
def draw(self, context: Any, layout: Any, node: Any, text: str | None):
assert layout
layout.label(text=text or self.name)
class InputNodeSocket(SliceNodeSocket):
def draw(self, context: Any, layout: Any, node: Any, text: str | None):
assert layout
if not self.is_output and self.is_linked:
layout.label(text=text or self.name)
else:
layout.prop(self, "value", text=text or self.name)
def _draw_as_ro(self, context: Any, layout: Any, node: Any, text: str | None):
layout.label(text=text or self.name)
def _draw_as_rw(self, context: Any, layout: Any, node: Any, text: str | None):
layout.prop(self, "value", text=text or self.name)
class ReadonlySocket():
def draw(self, context: Any, layout: Any, node: Any, text: str | None):
return self._draw_as_ro(context, layout, node, text) # pyright: ignore[reportAttributeAccessIssue]
@register_class
class SLICESOCK_Object(InputNodeSocket):
bl_label = "Object"
value: PointerProperty(
name="Object",
type=bpy.types.Object,
) # pyright: ignore[reportInvalidTypeForm]
color = (0.9, 0.7, 0.2, 1.0)
class SLICESOCK_Float_Common(InputNodeSocket):
bl_label = "Float"
value: FloatProperty(name="Float", default=0) # pyright: ignore[reportInvalidTypeForm]
color = (161/255, 161/255, 161/255, 1.0)
@register_class
class SLICESOCK_FloatRo(ReadonlySocket, SLICESOCK_Float_Common): ...
@register_class
class SLICESOCK_Float(SLICESOCK_Float_Common): ...
class SLICESOCK_Int_Common(InputNodeSocket):
bl_label = "Integer"
value: IntProperty(name="Int", default=0) # pyright: ignore[reportInvalidTypeForm]
color = (161/255, 161/255, 161/255, 1.0)
@register_class
class SLICESOCK_Int(SLICESOCK_Int_Common): ...
@register_class
class SLICESOCK_IntRo(ReadonlySocket, SLICESOCK_Int_Common): ...
@register_class
class SLICESOCK_String(InputNodeSocket):
bl_label = "String"
value: StringProperty(name="String", default="") # pyright: ignore[reportInvalidTypeForm]
color = (0.4, 0.7, 1.0, 1.0)
@register_class
class SLICESOCK_VectorRotation_Common(InputNodeSocket):
bl_label = "VectorRotation"
value: bpy.props.FloatVectorProperty(name="Vector", size=3, default=(0.0, 0.0, 0.0), subtype='EULER') # pyright: ignore[reportInvalidTypeForm]
color = (0.39, 0.39, 0.78, 1.0)
@register_class
class SLICESOCK_VectorRotation(SLICESOCK_VectorRotation_Common): ...
@register_class
class SLICESOCK_VectorRotationRo(ReadonlySocket, SLICESOCK_VectorRotation_Common): ...
@register_class
class SLICESOCK_Mesh(LabelNodeSocket):
bl_label = "Mesh"
default = None
color = (0, 214/255, 163/255, 1.0)
@register_class
class SLICESOCK_Paths(LabelNodeSocket):
bl_label = "Paths"
default = SlicePaths()
color = (214/255, 0, 120/255, 1.0)
@register_class
class SLICESOCK_Gcode(LabelNodeSocket):
bl_label = "Gcode"
default = ""
color = (1., 1., 1., 1.)
@register_class
class SLICESOCK_Intrinsics(LabelNodeSocket):
bl_label = "Intrinsics"
default = None
color = (0.2, 0.2, 0.2, 1.0)
# -----------------------------------------------------------------------------
# Nodes
# -----------------------------------------------------------------------------
# Basic Inputs
class InputNode(SliceNode):
input_type: str = ""
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if "bl_label" not in cls.__dict__: cls.bl_label = cls.input_type
def init(self, context) -> None:
self.add_output(f"SLICESOCK_{self.bl_label}")
def evaluate_output(self, socket_name: str, **_kwargs):
return self.outputs.get(socket_name).value
@register_class
class SLICENODE_InputString(InputNode):
input_type = "String"
@register_class
class SLICENODE_InputFloat(InputNode):
input_type = "Float"
@register_class
class SLICENODE_InputInt(InputNode):
input_type = "Int"
@register_class
class SLICENODE_InputVectorRotation(InputNode):
input_type = "VectorRotation"
@register_class
class SLICENODE_InputObject(InputNode):
input_type = "Object"
bl_icon = "OBJECT_DATA"
@register_class
class SLICENODE_InputIntrinsics(SliceNode):
bl_label = "Printer Intrinsics"
bed_x: FloatProperty(name="Bed X", default=250.0) # pyright: ignore[reportInvalidTypeForm]
bed_y: FloatProperty(name="Bed Y", default=250.0) # pyright: ignore[reportInvalidTypeForm]
feedrate: FloatProperty(name="Feedrate", default=1200.0) # pyright: ignore[reportInvalidTypeForm]
extrusion_rate: FloatProperty(name="Extrusion Rate", default=0.017, precision=4) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_output("SLICESOCK_Intrinsics")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "bed_x")
layout.prop(self, "bed_y")
layout.prop(self, "feedrate")
layout.prop(self, "extrusion_rate")
def evaluate_output(self, socket_name: str, **_kwargs) -> SliceIntrinsics:
return SliceIntrinsics(
bed_x=self.bed_x,
bed_y=self.bed_y,
feedrate=self.feedrate,
extrusion_rate=self.extrusion_rate
)
# Math
@register_class
class SLICENODE_IntMath(SliceNode):
bl_label = "Integer Math"
operation: bpy.props.EnumProperty(
name="Operation",
items=[
('ADD', "Add", "Add the two values"),
('SUBTRACT', "Subtract", "Subtract the second value from the first"),
('MULTIPLY', "Multiply", "Multiply the two values"),
('DIVIDE', "Divide", "Integer division"),
('ABS', "Absolute", "Absolute value of the first value"),
('NEGATE', "Negate", "Negate the first value"),
('POWER', "Power", "First value raised to the power of the second"),
('MIN', "Minimum", "The smaller of the two values"),
('MAX', "Maximum", "The larger of the two values"),
('SIGN', "Sign", "The sign of the first value (-1, 0, or 1)"),
('DIVIDE_ROUND', "Divide Round", "Division rounded to the nearest integer"),
('DIVIDE_FLOOR', "Divide Floor", "Division rounded down"),
('DIVIDE_TRUNCATE', "Divide Truncate", "Division rounded towards zero"),
('MODULO_FLOORED', "Floored Modulo", "Modulo using floor division"),
('MODULO_TRUNCATED', "Truncated Modulo", "Modulo using truncated division"),
],
default='ADD'
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_output("SLICESOCK_IntRo", "Value")
self.add_input("SLICESOCK_Int", "Value")
self.add_input("SLICESOCK_Int", "Value")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "operation", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> int:
a: int = resolve_input(self.inputs[0])
b: int = resolve_input(self.inputs[1])
op = self.operation
try:
if op == 'ADD': return a + b
if op == 'SUBTRACT': return a - b
if op == 'MULTIPLY': return a * b
if op == 'DIVIDE': return a // b if b != 0 else 0
if op == 'ABS': return abs(a)
if op == 'NEGATE': return -a
if op == 'POWER': return int(a ** b) if not (a == 0 and b < 0) else 0
if op == 'MIN': return min(a, b)
if op == 'MAX': return max(a, b)
if op == 'SIGN': return (a > 0) - (a < 0)
if op == 'DIVIDE_ROUND': return int(round(a / b)) if b != 0 else 0
if op == 'DIVIDE_FLOOR': return a // b if b != 0 else 0
if op == 'DIVIDE_TRUNCATE': return int(a / b) if b != 0 else 0
if op == 'MODULO_FLOORED': return a % b if b != 0 else 0
if op == 'MODULO_TRUNCATED': return a - (b * int(a / b)) if b != 0 else 0
except Exception:
return 0
return 0
@register_class
class SLICENODE_GetMesh(SliceNode):
bl_label = "Get Mesh"
def init(self, context):
self.add_input("SLICESOCK_Object")
self.add_output("SLICESOCK_Mesh")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingObject | None:
if socket_name != "Mesh": return None
obj: Any = resolve_input(self.inputs.get("Object"))
so = SlicingObject(obj, obj.name)
return so
@register_class
class SLICENODE_GetPaths(SliceNode):
bl_label = "Get Paths"
def init(self, context):
self.add_input("SLICESOCK_Object")
self.add_output("SLICESOCK_Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
obj: Any = resolve_input(self.inputs.get("Object"))
so: list[np.ndarray] = capture_mesh_edges(obj)
return SlicePaths(so)
@register_class
class SLICENODE_SliceShells(SliceNode):
bl_label = "Slice Shells"
def init(self, context):
self.add_input("SLICESOCK_Mesh")
self.add_input("SLICESOCK_Float", "Layer Height").value = 0.2
self.add_output("SLICESOCK_Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
slicingmesh: SlicingObject | None = resolve_input(self.inputs.get("Mesh"))
layer_height: float = resolve_input(self.inputs.get("Layer Height"))
if not slicingmesh: return SlicePaths()
min = slicingmesh.mesh[:,0:3,2:3].min()
min = floor(min / layer_height) * layer_height
max = slicingmesh.mesh[:,0:3,2:3].max()
z_values = np.arange(min, max + layer_height, layer_height)
slices_group: list[list[np.ndarray]] | None = slice_at_range([slicingmesh.mesh], z_values)
assert slices_group
slices = slices_group[0]
return SlicePaths(slices)
@register_class
class SLICENODE_BufferShells(SliceNode):
bl_label = "Buffer Shells"
shape_option: bpy.props.EnumProperty(
name="Miter",
items=[
('round', "Round", ""),
('square', "Square", ""),
('flat', "Flat", "")
]
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Distance").value = 0
self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "shape_option")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = resolve_input(self.inputs.get("Paths"))
distance: float = resolve_input(self.inputs.get("Distance")) or 0.0
rotation: Any = resolve_input(self.inputs.get("Rotation")) or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
if any(rotation):
# De-rotate shells to Z-perpendicular plane
mat = Euler(rotation).to_matrix().inverted()
mat_np = np.array(mat)
prepared_paths = [s @ mat_np.T for s in paths]
else:
prepared_paths = paths
# Buffer
res_paths, res_groups = slice.buffer_slice(prepared_paths, distance, miter=self.shape_option)
if any(rotation):
# Re-rotate shells back
mat_inv = Euler(rotation).to_matrix()
mat_inv_np = np.array(mat_inv)
final_paths = [s @ mat_inv_np.T for s in res_paths]
else:
final_paths = res_paths
return SlicePaths(final_paths, res_groups)
@register_class
class SLICENODE_Infill(SliceNode):
bl_label = "Infill"
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Density").value = 2.0
self.add_input("SLICESOCK_Float", "Infill Rotation").value = 0.0
self.add_input("SLICESOCK_VectorRotation", "Layer Rotation")
self.add_output("SLICESOCK_Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = resolve_input(self.inputs.get("Paths"))
density: float = resolve_input(self.inputs.get("Density")) or 2.0
infill_rotation: float = resolve_input(self.inputs.get("Infill Rotation")) or 0.0
rotation: Any = resolve_input(self.inputs.get("Layer Rotation")) or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
p: SlicePaths
if any(rotation):
# De-rotate shells to Z-perpendicular plane
mat = Euler(rotation).to_matrix().inverted()
mat_np = np.array(mat)
p = SlicePaths([s @ mat_np.T for s in paths], paths.groups)
else:
p = paths
res_paths, res_groups = slice.infill_slice(p, density, rotation_deg=infill_rotation)
if any(rotation):
# Re-rotate shells back
mat_inv = Euler(rotation).to_matrix()
mat_inv_np = np.array(mat_inv)
p = SlicePaths([s @ mat_inv_np.T for s in res_paths], res_groups)
else:
p = SlicePaths(res_paths, res_groups)
return p
@register_class
class SLICENODE_PathsToGcode(SliceNode):
bl_label = "Paths to Gcode"
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Intrinsics")
self.add_output("SLICESOCK_Gcode")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
paths: list[np.ndarray] = resolve_input(self.inputs.get("Paths"))
intrinsics: SliceIntrinsics | None = resolve_input(self.inputs.get("Intrinsics"))
pather: Pather = Pather(paths)
if intrinsics:
# Note: Pather currently uses bed_center_x/y as its internal attributes.
# We map the bed size center to these attributes if provided.
pather.bed_center_x = intrinsics.bed_x / 2.0
pather.bed_center_y = intrinsics.bed_y / 2.0
pather.feedrate = intrinsics.feedrate
pather.extrusion_rate = intrinsics.extrusion_rate
gcode: str = pather.to_gcode()
return gcode
@register_class
class SLICENODE_GcodeBlock(SliceNode):
bl_label = "Gcode Block"
gcode: StringProperty(name="Gcode", default="") # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_output("SLICESOCK_Gcode")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "gcode", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
return self.gcode
### Joiners
class SliceNodeJoiner(SliceNode):
socket_type: str = ""
output_socket_type: str = ""
def init(self, context):
self.add_output(self.output_socket_type or self.socket_type)
def update(self) -> None:
def is_active(input) -> bool:
return input.is_linked or getattr(input, 'value', False)
if len(self.inputs) == 0:
self.add_input(self.socket_type)
return
if is_active(self.inputs[-1]):
self.add_input(self.socket_type)
while True:
if len(self.inputs) < 2: break
if not is_active(self.inputs[-1]) and not is_active(self.inputs[-2]):
self.inputs.remove(self.inputs[-2])
continue
break
@register_class
class SLICENODE_JoinGcode(SliceNodeJoiner):
bl_label = "Join Gcode"
socket_type = "SLICESOCK_Gcode"
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
gcode_input: list[str] = []
for input in self.inputs:
gcode_input += [resolve_input(input)] or [""]
return "\n".join(gcode_input)
@register_class
class SLICENODE_JoinSlices(SliceNodeJoiner):
bl_label = "Join Slices"
socket_type = "SLICESOCK_Paths"
operation: bpy.props.EnumProperty(
name="Operation",
items=[
('UNION', "Union", "Union of the slices"),
('INTERSECTION', "Intersection", "Intersection of the slices"),
('DIFFERENCE', "Subtract", "Clipping of the first slice"),
('XOR', "Exclusive or", "Exclusive or of the slices"),
],
default='UNION'
) # pyright: ignore[reportInvalidTypeForm]
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "operation", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
accumulated: SlicePaths = SlicePaths()
current: SlicePaths = SlicePaths()
for input in self.inputs:
current = resolve_input(input)
if not current:
continue
if not accumulated:
accumulated = current
current = SlicePaths()
continue
# Boolean operation loses specific per-segment grouping as it merges polygons.
# We return a new SlicePaths from the result.
res = slice.boolean_slice(accumulated, current, self.operation)
accumulated = SlicePaths(res)
current = SlicePaths()
return accumulated
@register_class
class SLICENODE_JoinMesh(SliceNodeJoiner):
bl_label = "Join Mesh"
socket_type = "SLICESOCK_Mesh"
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingObject:
merged_mesh = SlicingObject()
for input in self.inputs:
mesh =resolve_input(input)
if mesh:
merged_mesh.mesh = np.concatenate([merged_mesh.mesh, mesh.mesh], axis=0)
return merged_mesh
@register_class
class SLICENODE_JoinPaths(SliceNodeJoiner):
bl_label = "Join Paths"
socket_type = "SLICESOCK_Paths"
mode: bpy.props.EnumProperty(
name="Mode",
items=[
('EXTEND', "Extend", "Append paths sequentially"),
('ZIP', "Zip", "Interleave paths from all inputs"),
],
default='EXTEND'
) # pyright: ignore[reportInvalidTypeForm]
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "mode", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
inputs_data: list[SlicePaths] = []
for input in self.inputs:
paths = resolve_input(input)
if paths:
inputs_data.append(paths)
if not inputs_data:
return SlicePaths()
if self.mode == 'EXTEND':
merged = SlicePaths()
for paths in inputs_data:
merged.extend_with_groups(paths)
return merged
elif self.mode == 'ZIP':
# Group-based Zip: sequentially add each group from all inputs.
# E.g. Group 1 (In1), Group 1 (In2), Group 2 (In1), Group 2 (In2)...
all_groups = set()
grouped_inputs = []
for p_list in inputs_data:
d = {}
for i, g in enumerate(p_list.groups):
all_groups.add(g)
d.setdefault(g, []).append(p_list[i])
grouped_inputs.append(d)
sorted_groups = sorted(list(all_groups))
merged = SlicePaths()
for g in sorted_groups:
for d in grouped_inputs:
group_paths = d.get(g, [])
for p in group_paths:
merged.append_with_group(p, g)
return merged
return SlicePaths()
### I/O
@register_class
class SLICENODE_LoadGcode(SliceNode):
bl_label = "Load Gcode File"
def init(self, context):
self.add_input("SLICESOCK_String", "Filepath")
self.add_output("SLICESOCK_Gcode")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
path = resolve_input(self.inputs.get("Filepath"))
if not path: return ""
abs_path = bpy.path.abspath(path)
if os.path.exists(abs_path):
with open(abs_path, 'r', encoding='utf-8') as f:
return f.read()
return ""
class ExecSliceNode(SliceNode):
button_label: str = 'Execute'
def draw_buttons(self, context, layout):
assert layout
layout.operator("slicenode.execute", text=self.button_label).node_name = self.name
@register_class
class SLICENODE_GcodeToFile(ExecSliceNode):
bl_label = "Gcode To File"
button_label = "Generate File"
def init(self, context):
self.add_input("SLICESOCK_Gcode")
self.add_input("SLICESOCK_String", "Filename")
def evaluate(self):
gcode: str = resolve_input(self.inputs.get("Gcode"))
filename: str = resolve_input(self.inputs.get("Filename"))
filepath = bpy.path.abspath(filename)
os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
with open(filepath, "w", encoding="utf-8", newline="\n") as f:
f.write(gcode)
return 'ok'
@register_class
class SLICENODE_GcodeToMesh(ExecSliceNode):
bl_label = "Gcode to Mesh"
button_label = "Update Debug Mesh"
def init(self, context):
self.add_input("SLICESOCK_Gcode")
def evaluate(self):
gcode: str = resolve_input(self.inputs.get("Gcode"))
if not gcode:
return 'empty'
segments = []
last_x, last_y, last_z = 0.0, 0.0, 0.0
last_e = 0.0
offset_x, offset_y = 0.0, 0.0
for line in gcode.splitlines():
# Parse metadata from comments
if ';' in line:
comment = line.split(';', 1)[1].strip()
if comment.startswith("OFFSET_X="):
try: offset_x = float(comment.split('=')[1])
except ValueError: pass
elif comment.startswith("OFFSET_Y="):
try: offset_y = float(comment.split('=')[1])
except ValueError: pass
line_no_comment = line.split(';')[0].strip()
if not line_no_comment: continue
parts = line_no_comment.split()
cmd = parts[0].upper()
params = {}
for p in parts[1:]:
if len(p) < 2: continue
try:
# Scale coordinates by 0.001 (mm to Blender units)
val = float(p[1:])
if p[0].upper() in 'XYZ':
val *= 0.001
params[p[0].upper()] = val
except ValueError: continue
new_x = params.get('X', last_x)
new_y = params.get('Y', last_y)
new_z = params.get('Z', last_z)
new_e = params.get('E', last_e)
if cmd == 'G1':
is_extruding = new_e > 0
if is_extruding:
# Subtract offsets (scaled to Blender units) to return to original space
ox, oy = offset_x * 0.001, offset_y * 0.001
segments.append((
(last_x - ox, last_y - oy, last_z),
(new_x - ox, new_y - oy, new_z)
))
last_x, last_y, last_z = new_x, new_y, new_z
last_e = new_e
if not segments:
return 'no segments'
# Create or find "Debug" mesh object
mesh_name = "Debug"
if mesh_name in bpy.data.objects:
obj = bpy.data.objects[mesh_name]
mesh = obj.data
mesh.clear_geometry()
else:
mesh = bpy.data.meshes.new(mesh_name)
obj = bpy.data.objects.new(mesh_name, mesh)
assert bpy.context.collection
bpy.context.collection.objects.link(obj)
vertices = []
edges = []
for i, (p1, p2) in enumerate(segments):
vertices.extend([p1, p2])
edges.append((2*i, 2*i + 1))
mesh.from_pydata(vertices, edges, [])
mesh.update()
# Assign Geometry Nodes nodegroup for preview
ng_name = "Nonplanar Slicing Preview"
if ng_name in bpy.data.node_groups:
ng = bpy.data.node_groups[ng_name]
# Check if already assigned
has_preview = False
for mod in obj.modifiers:
if mod.type == 'NODES' and mod.node_group == ng: # type: ignore
has_preview = True
break
if not has_preview:
mod = obj.modifiers.new(name="Preview", type='NODES') # type: ignore
mod.node_group = ng
return 'ok'
@register_class
class SLICENODE_Test(ExecSliceNode):
bl_label = "Test"
button_label = "Test"
def init(self, context):
self.add_input("SLICESOCK_Gcode")
def evaluate(self):
_ = resolve_input(self.inputs.get("Gcode"))
return 'ok'
# -----------------------------------------------------------------------------
# Operator
# -----------------------------------------------------------------------------
@register_class
class SLICENODE_OT_Execute(Operator):
bl_idname = "slicenode.execute"
bl_label = "Execute"
bl_description = "Execute"
node_name: bpy.props.StringProperty() # pyright: ignore[reportInvalidTypeForm]
def execute(self, context) -> set['OperatorReturnItems']:
node_tree = context.space_data.edit_tree # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess]
node = node_tree.nodes[self.node_name]
_ = node.evaluate()
self.report({"INFO"}, f"Execution Successful")
return {"FINISHED"}
# -----------------------------------------------------------------------------
# Menu
# -----------------------------------------------------------------------------
@register_class
class SLICENODE_MT_Menu(Menu):
bl_idname = "SLICENODE_MT_Menu"
bl_label = "SliceNodes"
def draw(self, context):
layout = self.layout
assert layout
layout.operator("node.add_node", text="String").type = "SLICENODE_InputString"
layout.operator("node.add_node", text="Float").type = "SLICENODE_InputFloat"
layout.operator("node.add_node", text="Integer").type = "SLICENODE_InputInt"
layout.operator("node.add_node", text="Rotation").type = "SLICENODE_InputVectorRotation"
layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath"
layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject"
layout.operator("node.add_node", text="Printer Intrinsics").type = "SLICENODE_InputIntrinsics"
layout.operator("node.add_node", text="Test").type = "SLICENODE_Test"
layout.operator("node.add_node", text="Get Mesh").type = "SLICENODE_GetMesh"
layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths"
layout.separator()
layout.operator("node.add_node", text="Slice Shells").type = "SLICENODE_SliceShells"
layout.operator("node.add_node", text="Buffer Shells").type = "SLICENODE_BufferShells"
layout.operator("node.add_node", text="Infill").type = "SLICENODE_Infill"
layout.operator("node.add_node", text="Paths to Gcode").type = "SLICENODE_PathsToGcode"
layout.separator()
layout.operator("node.add_node", text="Gcode Block").type = "SLICENODE_GcodeBlock"
layout.operator("node.add_node", text="Load Gcode").type = "SLICENODE_LoadGcode"
layout.separator()
layout.operator("node.add_node", text="Join Gcode").type = "SLICENODE_JoinGcode"
layout.operator("node.add_node", text="Join Mesh").type = "SLICENODE_JoinMesh"
layout.operator("node.add_node", text="Join Paths").type = "SLICENODE_JoinPaths"
layout.operator("node.add_node", text="Boolean Paths").type = "SLICENODE_JoinSlices"
layout.separator()
layout.operator("node.add_node", text="Gcode to File").type = "SLICENODE_GcodeToFile"
layout.operator("node.add_node", text="Gcode to Mesh").type = "SLICENODE_GcodeToMesh"
def draw_add_menu(self, context):
space = context.space_data
tree = getattr(space, "edit_tree", None)
if tree and tree.bl_idname == "SLICETREE_NodeTree":
self.layout.separator()
self.layout.menu("SLICENODE_MT_Menu")
# -----------------------------------------------------------------------------
# Registration
# -----------------------------------------------------------------------------
### Load collected modules
modules = registry.get()
def register():
registry.blender_register_classes()
bpy.types.NODE_MT_add.append(draw_add_menu)
bpy.types.NODE_MT_add.append(tree.draw_add_menu)
print('Nodeslicer Loaded')
def unregister():
bpy.types.NODE_MT_add.remove(draw_add_menu)
bpy.types.NODE_MT_add.remove(tree.draw_add_menu)
registry.blender_unregister_classes()
print('Nodeslicer Unloaded')
if __name__ == "__main__":
register()
register()

View file

792
core/nodeslicer/nodes.py Normal file
View file

@ -0,0 +1,792 @@
from __future__ import annotations
import os
from math import floor
from typing import Any
import bpy
import numpy as np
from mathutils import Euler
from bpy.props import StringProperty, FloatProperty, IntProperty
from ...registry import register_class
from .. import slice
from ..slice import Pather, slice_at_range, SlicePaths
from ...infra.blender_mesh_capture import SlicingObject, capture_mesh_edges
from ...infra.blender_slice_nodes_generic import SliceNode, resolve_input
class SliceIntrinsics:
bed_x: float
bed_y: float
feedrate: float
extrusion_rate: float
def __init__(self, bed_x=250.0, bed_y=250.0, feedrate=1200.0, extrusion_rate=0.017):
self.bed_x = bed_x
self.bed_y = bed_y
self.feedrate = feedrate
self.extrusion_rate = extrusion_rate
# Basic Inputs
class InputNode(SliceNode):
input_type: str = ""
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if "bl_label" not in cls.__dict__: cls.bl_label = cls.input_type
def init(self, context) -> None:
self.add_output(f"SLICESOCK_{self.bl_label}")
def evaluate_output(self, socket_name: str, **_kwargs):
return self.outputs.get(socket_name).value
@register_class
class SLICENODE_InputString(InputNode):
input_type = "String"
@register_class
class SLICENODE_InputFloat(InputNode):
input_type = "Float"
@register_class
class SLICENODE_InputInt(InputNode):
input_type = "Int"
@register_class
class SLICENODE_InputVectorRotation(InputNode):
input_type = "VectorRotation"
@register_class
class SLICENODE_InputObject(InputNode):
input_type = "Object"
bl_icon = "OBJECT_DATA"
@register_class
class SLICENODE_InputIntrinsics(SliceNode):
bl_label = "Printer Intrinsics"
bed_x: FloatProperty(name="Bed X", default=250.0) # pyright: ignore[reportInvalidTypeForm]
bed_y: FloatProperty(name="Bed Y", default=250.0) # pyright: ignore[reportInvalidTypeForm]
feedrate: FloatProperty(name="Feedrate", default=1200.0) # pyright: ignore[reportInvalidTypeForm]
extrusion_rate: FloatProperty(name="Extrusion Rate", default=0.017, precision=4) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_output("SLICESOCK_Intrinsics")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "bed_x")
layout.prop(self, "bed_y")
layout.prop(self, "feedrate")
layout.prop(self, "extrusion_rate")
def evaluate_output(self, socket_name: str, **_kwargs) -> SliceIntrinsics:
return SliceIntrinsics(
bed_x=self.bed_x,
bed_y=self.bed_y,
feedrate=self.feedrate,
extrusion_rate=self.extrusion_rate
)
# Math
@register_class
class SLICENODE_IntMath(SliceNode):
bl_label = "Integer Math"
operation: bpy.props.EnumProperty(
name="Operation",
items=[
('ADD', "Add", "Add the two values"),
('SUBTRACT', "Subtract", "Subtract the second value from the first"),
('MULTIPLY', "Multiply", "Multiply the two values"),
('DIVIDE', "Divide", "Integer division"),
('ABS', "Absolute", "Absolute value of the first value"),
('NEGATE', "Negate", "Negate the first value"),
('POWER', "Power", "First value raised to the power of the second"),
('MIN', "Minimum", "The smaller of the two values"),
('MAX', "Maximum", "The larger of the two values"),
('SIGN', "Sign", "The sign of the first value (-1, 0, or 1)"),
('DIVIDE_ROUND', "Divide Round", "Division rounded to the nearest integer"),
('DIVIDE_FLOOR', "Divide Floor", "Division rounded down"),
('DIVIDE_TRUNCATE', "Divide Truncate", "Division rounded towards zero"),
('MODULO_FLOORED', "Floored Modulo", "Modulo using floor division"),
('MODULO_TRUNCATED', "Truncated Modulo", "Modulo using truncated division"),
],
default='ADD'
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_output("SLICESOCK_IntRo", "Value")
self.add_input("SLICESOCK_Int", "Value")
self.add_input("SLICESOCK_Int", "Value")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "operation", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> int:
a: int = resolve_input(self.inputs[0])
b: int = resolve_input(self.inputs[1])
op = self.operation
try:
if op == 'ADD': return a + b
if op == 'SUBTRACT': return a - b
if op == 'MULTIPLY': return a * b
if op == 'DIVIDE': return a // b if b != 0 else 0
if op == 'ABS': return abs(a)
if op == 'NEGATE': return -a
if op == 'POWER': return int(a ** b) if not (a == 0 and b < 0) else 0
if op == 'MIN': return min(a, b)
if op == 'MAX': return max(a, b)
if op == 'SIGN': return (a > 0) - (a < 0)
if op == 'DIVIDE_ROUND': return int(round(a / b)) if b != 0 else 0
if op == 'DIVIDE_FLOOR': return a // b if b != 0 else 0
if op == 'DIVIDE_TRUNCATE': return int(a / b) if b != 0 else 0
if op == 'MODULO_FLOORED': return a % b if b != 0 else 0
if op == 'MODULO_TRUNCATED': return a - (b * int(a / b)) if b != 0 else 0
except Exception:
return 0
return 0
@register_class
class SLICENODE_GetMesh(SliceNode):
bl_label = "Get Mesh"
def init(self, context):
self.add_input("SLICESOCK_Object")
self.add_output("SLICESOCK_Mesh")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingObject | None:
if socket_name != "Mesh": return None
obj: Any = resolve_input(self.inputs.get("Object"))
so = SlicingObject(obj, obj.name)
return so
@register_class
class SLICENODE_GetPaths(SliceNode):
bl_label = "Get Paths"
def init(self, context):
self.add_input("SLICESOCK_Object")
self.add_output("SLICESOCK_Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
obj: Any = resolve_input(self.inputs.get("Object"))
so: list[np.ndarray] = capture_mesh_edges(obj)
return SlicePaths(so)
@register_class
class SLICENODE_SliceShells(SliceNode):
bl_label = "Slice Shells"
def init(self, context):
self.add_input("SLICESOCK_Mesh")
self.add_input("SLICESOCK_Float", "Layer Height").value = 0.2
self.add_output("SLICESOCK_Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
slicingmesh: SlicingObject | None = resolve_input(self.inputs.get("Mesh"))
layer_height: float = resolve_input(self.inputs.get("Layer Height"))
if not slicingmesh: return SlicePaths()
min = slicingmesh.mesh[:,0:3,2:3].min()
min = floor(min / layer_height) * layer_height
max = slicingmesh.mesh[:,0:3,2:3].max()
z_values = np.arange(min, max + layer_height, layer_height)
slices_group: list[list[np.ndarray]] | None = slice_at_range([slicingmesh.mesh], z_values)
assert slices_group
slices = slices_group[0]
return SlicePaths(slices)
@register_class
class SLICENODE_BufferShells(SliceNode):
bl_label = "Buffer Shells"
shape_option: bpy.props.EnumProperty(
name="Miter",
items=[
('round', "Round", ""),
('square', "Square", ""),
('flat', "Flat", "")
]
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Distance").value = 0
self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "shape_option")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = resolve_input(self.inputs.get("Paths"))
distance: float = resolve_input(self.inputs.get("Distance")) or 0.0
rotation: Any = resolve_input(self.inputs.get("Rotation")) or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
if any(rotation):
# De-rotate shells to Z-perpendicular plane
mat = Euler(rotation).to_matrix().inverted()
mat_np = np.array(mat)
prepared_paths = [s @ mat_np.T for s in paths]
else:
prepared_paths = paths
# Buffer
res_paths, res_groups = slice.buffer_slice(prepared_paths, distance, miter=self.shape_option)
if any(rotation):
# Re-rotate shells back
mat_inv = Euler(rotation).to_matrix()
mat_inv_np = np.array(mat_inv)
final_paths = [s @ mat_inv_np.T for s in res_paths]
else:
final_paths = res_paths
return SlicePaths(final_paths, res_groups)
@register_class
class SLICENODE_BufferShellsMultiple(SliceNode):
bl_label = "Buffer Shells Multiple"
shape_option: bpy.props.EnumProperty(
name="Miter",
items=[
('round', "Round", ""),
('square', "Square", ""),
('flat', "Flat", "")
]
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Distance").value = 0
self.add_input("SLICESOCK_Float", "Width Step").value = 0.4
self.add_input("SLICESOCK_Int", "Count").value = 2
self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "shape_option")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = resolve_input(self.inputs.get("Paths"))
base_distance: float = resolve_input(self.inputs.get("Distance")) or 0.0
width_step: float = resolve_input(self.inputs.get("Width Step")) or 0.0
count: int = resolve_input(self.inputs.get("Count")) or 1
rotation: Any = resolve_input(self.inputs.get("Rotation")) or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
if any(rotation):
mat = Euler(rotation).to_matrix().inverted()
mat_np = np.array(mat)
prepared_paths = [s @ mat_np.T for s in paths]
else:
prepared_paths = paths
all_res_paths = []
all_res_groups = []
for i in range(count):
dist = base_distance + (i * width_step)
res_paths, res_groups = slice.buffer_slice(prepared_paths, dist, miter=self.shape_option)
all_res_paths.extend(res_paths)
all_res_groups.extend(res_groups)
if any(rotation):
mat_inv = Euler(rotation).to_matrix()
mat_inv_np = np.array(mat_inv)
final_paths = [s @ mat_inv_np.T for s in all_res_paths]
else:
final_paths = all_res_paths
return SlicePaths(final_paths, all_res_groups)
@register_class
class SLICENODE_Infill(SliceNode):
bl_label = "Infill"
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Density").value = 2.0
self.add_input("SLICESOCK_Float", "Infill Rotation").value = 0.0
self.add_input("SLICESOCK_VectorRotation", "Layer Rotation")
self.add_output("SLICESOCK_Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = resolve_input(self.inputs.get("Paths"))
density: float = resolve_input(self.inputs.get("Density")) or 2.0
infill_rotation: float = resolve_input(self.inputs.get("Infill Rotation")) or 0.0
rotation: Any = resolve_input(self.inputs.get("Layer Rotation")) or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
p: SlicePaths
if any(rotation):
# De-rotate shells to Z-perpendicular plane
mat = Euler(rotation).to_matrix().inverted()
mat_np = np.array(mat)
p = SlicePaths([s @ mat_np.T for s in paths], paths.groups)
else:
p = paths
res_paths, res_groups = slice.infill_slice(p, density, rotation_deg=infill_rotation)
if any(rotation):
# Re-rotate shells back
mat_inv = Euler(rotation).to_matrix()
mat_inv_np = np.array(mat_inv)
p = SlicePaths([s @ mat_inv_np.T for s in res_paths], res_groups)
else:
p = SlicePaths(res_paths, res_groups)
return p
@register_class
class SLICENODE_PathsToGcode(SliceNode):
bl_label = "Paths to Gcode"
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Intrinsics")
self.add_output("SLICESOCK_Gcode")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
paths: list[np.ndarray] = resolve_input(self.inputs.get("Paths"))
intrinsics: SliceIntrinsics | None = resolve_input(self.inputs.get("Intrinsics"))
pather: Pather = Pather(paths)
if intrinsics:
# Note: Pather currently uses bed_center_x/y as its internal attributes.
# We map the bed size center to these attributes if provided.
pather.bed_center_x = intrinsics.bed_x / 2.0
pather.bed_center_y = intrinsics.bed_y / 2.0
pather.feedrate = intrinsics.feedrate
pather.extrusion_rate = intrinsics.extrusion_rate
gcode: str = pather.to_gcode()
return gcode
@register_class
class SLICENODE_GcodeBlock(SliceNode):
bl_label = "Gcode Block"
gcode: StringProperty(name="Gcode", default="") # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_output("SLICESOCK_Gcode")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "gcode", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
return self.gcode
@register_class
class SLICENODE_GcodeHelper(SliceNode):
bl_label = "Gcode Helper"
command: bpy.props.EnumProperty(
name="Command",
items=[
('G0', "G0: Rapid Move", "Fast movement without extrusion"),
('G1', "G1: Linear Move", "Movement with extrusion"),
('G4', "G4: Dwell", "Wait for time (e.g. P500 for 500ms or S5 for 5s)"),
('G28', "G28: Home All", "Home all axes"),
('G90', "G90: Absolute Pos", "Set to absolute positioning"),
('G91', "G91: Relative Pos", "Set to relative positioning"),
('G92', "G92: Set Position", "Set the current position to specific values (e.g. G92 E0)"),
('M0', "M0: Stop/Wait", "Stop and wait for user button press"),
('M25', "M25: Pause SD", "Pause SD print (Marlin/RepRap)"),
('M104', "M104: Set Temp", "Set hotend temperature"),
('M109', "M109: Wait Temp", "Set hotend temperature and wait"),
('M140', "M140: Set Bed Temp", "Set bed temperature"),
('M190', "M190: Wait Bed Temp", "Set bed temperature and wait"),
('M106', "M106: Fan On", "Turn on cooling fan"),
('M107', "M107: Fan Off", "Turn off cooling fan"),
('M117', "M117: Message", "Display message on printer LCD"),
('M220', "M220: Speed Factor", "Set speed percentage (e.g. S110 for 110%)"),
('M221', "M221: Flow Factor", "Set flow percentage (e.g. S95 for 95%)"),
('M300', "M300: Beep", "Play beep sound"),
('M400', "M400: Wait Finish", "Wait for all current moves to finish"),
('M600', "M600: Color Change", "Filament change / Color change"),
('M82', "M82: Abs Extrusion", "Set extrusion to absolute mode"),
('M83', "M83: Rel Extrusion", "Set extrusion to relative mode"),
('PAUSE', "PAUSE: Klipper Pause", "Pause print (Klipper macro)"),
('T', "T: Change Extruder", "Switch to a different extruder (e.g. T0, T1)"),
],
default='G1'
) # pyright: ignore[reportInvalidTypeForm]
parameters: StringProperty(name="Params", default="") # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_output("SLICESOCK_Gcode")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "command", text="")
# Commands that don't need parameters
no_params = {'G28', 'G90', 'G91', 'M0', 'M25', 'M107', 'M400', 'M600', 'M82', 'M83', 'PAUSE'}
if self.command not in no_params:
layout.prop(self, "parameters", text="Parameters")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
cmd = self.command
params = self.parameters.strip()
if params:
# T commands usually don't have a space (e.g. T0)
if cmd == 'T':
return f"{cmd}{params}"
return f"{cmd} {params}"
return cmd
### Joiners
class SliceNodeJoiner(SliceNode):
socket_type: str = ""
output_socket_type: str = ""
def init(self, context):
self.add_output(self.output_socket_type or self.socket_type)
def update(self) -> None:
def is_active(input) -> bool:
return input.is_linked or getattr(input, 'value', False)
if len(self.inputs) == 0:
self.add_input(self.socket_type)
return
if is_active(self.inputs[-1]):
self.add_input(self.socket_type)
while True:
if len(self.inputs) < 2: break
if not is_active(self.inputs[-1]) and not is_active(self.inputs[-2]):
self.inputs.remove(self.inputs[-2])
continue
break
@register_class
class SLICENODE_JoinGcode(SliceNodeJoiner):
bl_label = "Join Gcode"
socket_type = "SLICESOCK_Gcode"
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
gcode_input: list[str] = []
for input in self.inputs:
gcode_input += [resolve_input(input)] or [""]
return "\n".join(gcode_input)
@register_class
class SLICENODE_JoinSlices(SliceNodeJoiner):
bl_label = "Join Slices"
socket_type = "SLICESOCK_Paths"
operation: bpy.props.EnumProperty(
name="Operation",
items=[
('UNION', "Union", "Union of the slices"),
('INTERSECTION', "Intersection", "Intersection of the slices"),
('DIFFERENCE', "Subtract", "Clipping of the first slice"),
('XOR', "Exclusive or", "Exclusive or of the slices"),
],
default='UNION'
) # pyright: ignore[reportInvalidTypeForm]
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "operation", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
accumulated: SlicePaths = SlicePaths()
current: SlicePaths = SlicePaths()
for input in self.inputs:
current = resolve_input(input)
if not current:
continue
if not accumulated:
accumulated = current
current = SlicePaths()
continue
# Boolean operation loses specific per-segment grouping as it merges polygons.
# We return a new SlicePaths from the result.
res = slice.boolean_slice(accumulated, current, self.operation)
accumulated = SlicePaths(res)
current = SlicePaths()
return accumulated
@register_class
class SLICENODE_JoinMesh(SliceNodeJoiner):
bl_label = "Join Mesh"
socket_type = "SLICESOCK_Mesh"
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingObject:
merged_mesh = SlicingObject()
for input in self.inputs:
mesh =resolve_input(input)
if mesh:
merged_mesh.mesh = np.concatenate([merged_mesh.mesh, mesh.mesh], axis=0)
return merged_mesh
@register_class
class SLICENODE_JoinPaths(SliceNodeJoiner):
bl_label = "Join Paths"
socket_type = "SLICESOCK_Paths"
mode: bpy.props.EnumProperty(
name="Mode",
items=[
('EXTEND', "Extend", "Append paths sequentially"),
('ZIP', "Zip", "Interleave paths from all inputs"),
],
default='EXTEND'
) # pyright: ignore[reportInvalidTypeForm]
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "mode", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
inputs_data: list[SlicePaths] = []
for input in self.inputs:
paths = resolve_input(input)
if paths:
inputs_data.append(paths)
if not inputs_data:
return SlicePaths()
if self.mode == 'EXTEND':
merged = SlicePaths()
for paths in inputs_data:
merged.extend_with_groups(paths)
return merged
elif self.mode == 'ZIP':
# Group-based Zip: sequentially add each group from all inputs.
# E.g. Group 1 (In1), Group 1 (In2), Group 2 (In1), Group 2 (In2)...
all_groups = set()
grouped_inputs = []
for p_list in inputs_data:
d = {}
for i, g in enumerate(p_list.groups):
all_groups.add(g)
d.setdefault(g, []).append(p_list[i])
grouped_inputs.append(d)
sorted_groups = sorted(list(all_groups))
merged = SlicePaths()
for g in sorted_groups:
for d in grouped_inputs:
group_paths = d.get(g, [])
for p in group_paths:
merged.append_with_group(p, g)
return merged
return SlicePaths()
### I/O
@register_class
class SLICENODE_LoadGcode(SliceNode):
bl_label = "Load Gcode File"
def init(self, context):
self.add_input("SLICESOCK_FilePath", "Filepath")
self.add_output("SLICESOCK_Gcode")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
path = resolve_input(self.inputs.get("Filepath"))
if not path: return ""
abs_path = bpy.path.abspath(path)
if os.path.exists(abs_path):
with open(abs_path, 'r', encoding='utf-8') as f:
return f.read()
return ""
class ExecSliceNode(SliceNode):
button_label: str = 'Execute'
def draw_buttons(self, context, layout):
assert layout
layout.operator("slicenode.execute", text=self.button_label).node_name = self.name
@register_class
class SLICENODE_GcodeToFile(ExecSliceNode):
bl_label = "Gcode To File"
button_label = "Generate File"
def init(self, context):
self.add_input("SLICESOCK_Gcode")
self.add_input("SLICESOCK_FilePath", "Filename")
def evaluate(self):
gcode: str = resolve_input(self.inputs.get("Gcode"))
filename: str = resolve_input(self.inputs.get("Filename"))
filepath = bpy.path.abspath(filename)
os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
with open(filepath, "w", encoding="utf-8", newline="\n") as f:
f.write(gcode)
return 'ok'
@register_class
class SLICENODE_GcodeToMesh(ExecSliceNode):
bl_label = "Gcode to Mesh"
button_label = "Update Debug Mesh"
def init(self, context):
self.add_input("SLICESOCK_Gcode")
def evaluate(self):
gcode: str = resolve_input(self.inputs.get("Gcode"))
if not gcode:
return 'empty'
segments = []
last_x, last_y, last_z = 0.0, 0.0, 0.0
last_e = 0.0
offset_x, offset_y = 0.0, 0.0
for line in gcode.splitlines():
# Parse metadata from comments
if ';' in line:
comment = line.split(';', 1)[1].strip()
if comment.startswith("OFFSET_X="):
try: offset_x = float(comment.split('=')[1])
except ValueError: pass
elif comment.startswith("OFFSET_Y="):
try: offset_y = float(comment.split('=')[1])
except ValueError: pass
line_no_comment = line.split(';')[0].strip()
if not line_no_comment: continue
parts = line_no_comment.split()
cmd = parts[0].upper()
params = {}
for p in parts[1:]:
if len(p) < 2: continue
try:
# Scale coordinates by 0.001 (mm to Blender units)
val = float(p[1:])
if p[0].upper() in 'XYZ':
val *= 0.001
params[p[0].upper()] = val
except ValueError: continue
new_x = params.get('X', last_x)
new_y = params.get('Y', last_y)
new_z = params.get('Z', last_z)
new_e = params.get('E', last_e)
if cmd == 'G1':
is_extruding = new_e > 0
if is_extruding:
# Subtract offsets (scaled to Blender units) to return to original space
ox, oy = offset_x * 0.001, offset_y * 0.001
segments.append((
(last_x - ox, last_y - oy, last_z),
(new_x - ox, new_y - oy, new_z)
))
last_x, last_y, last_z = new_x, new_y, new_z
last_e = new_e
if not segments:
return 'no segments'
# Create or find "Debug" mesh object
mesh_name = "Debug"
if mesh_name in bpy.data.objects:
obj = bpy.data.objects[mesh_name]
mesh = obj.data
mesh.clear_geometry()
else:
mesh = bpy.data.meshes.new(mesh_name)
obj = bpy.data.objects.new(mesh_name, mesh)
assert bpy.context.collection
bpy.context.collection.objects.link(obj)
vertices = []
edges = []
for i, (p1, p2) in enumerate(segments):
vertices.extend([p1, p2])
edges.append((2*i, 2*i + 1))
mesh.from_pydata(vertices, edges, [])
mesh.update()
# Assign Geometry Nodes nodegroup for preview
ng_name = "Nonplanar Slicing Preview"
if ng_name in bpy.data.node_groups:
ng = bpy.data.node_groups[ng_name]
# Check if already assigned
has_preview = False
for mod in obj.modifiers:
if mod.type == 'NODES' and mod.node_group == ng: # type: ignore
has_preview = True
break
if not has_preview:
mod = obj.modifiers.new(name="Preview", type='NODES') # type: ignore
mod.node_group = ng
return 'ok'
@register_class
class SLICENODE_Test(ExecSliceNode):
bl_label = "Test"
button_label = "Test"
def init(self, context):
self.add_input("SLICESOCK_Gcode")
def evaluate(self):
_ = resolve_input(self.inputs.get("Gcode"))
return 'ok'

116
core/nodeslicer/sockets.py Normal file
View file

@ -0,0 +1,116 @@
from __future__ import annotations
from typing import Any
import bpy
from bpy.props import PointerProperty, StringProperty, FloatProperty, IntProperty
from ...registry import register_class
from ...infra.blender_slice_nodes_generic import SliceNodeSocket
from ..slice import SlicePaths
class LabelNodeSocket(SliceNodeSocket):
default = ""
def draw(self, context: Any, layout: Any, node: Any, text: str | None):
assert layout
layout.label(text=text or self.name)
class InputNodeSocket(SliceNodeSocket):
def draw(self, context: Any, layout: Any, node: Any, text: str | None):
assert layout
if not self.is_output and self.is_linked:
layout.label(text=text or self.name)
else:
layout.prop(self, "value", text=text or self.name)
def _draw_as_ro(self, context: Any, layout: Any, node: Any, text: str | None):
layout.label(text=text or self.name)
def _draw_as_rw(self, context: Any, layout: Any, node: Any, text: str | None):
layout.prop(self, "value", text=text or self.name)
class ReadonlySocket():
def draw(self, context: Any, layout: Any, node: Any, text: str | None):
return self._draw_as_ro(context, layout, node, text) # pyright: ignore[reportAttributeAccessIssue]
@register_class
class SLICESOCK_Object(InputNodeSocket):
bl_label = "Object"
value: PointerProperty(
name="Object",
type=bpy.types.Object,
) # pyright: ignore[reportInvalidTypeForm]
color = (0.9, 0.7, 0.2, 1.0)
class SLICESOCK_Float_Common(InputNodeSocket):
bl_label = "Float"
value: FloatProperty(name="Float", default=0) # pyright: ignore[reportInvalidTypeForm]
color = (161/255, 161/255, 161/255, 1.0)
@register_class
class SLICESOCK_FloatRo(ReadonlySocket, SLICESOCK_Float_Common): ...
@register_class
class SLICESOCK_Float(SLICESOCK_Float_Common): ...
class SLICESOCK_Int_Common(InputNodeSocket):
bl_label = "Integer"
value: IntProperty(name="Int", default=0) # pyright: ignore[reportInvalidTypeForm]
color = (161/255, 161/255, 161/255, 1.0)
@register_class
class SLICESOCK_Int(SLICESOCK_Int_Common): ...
@register_class
class SLICESOCK_IntRo(ReadonlySocket, SLICESOCK_Int_Common): ...
@register_class
class SLICESOCK_String(InputNodeSocket):
bl_label = "String"
value: StringProperty(name="String", default="") # pyright: ignore[reportInvalidTypeForm]
color = (0.4, 0.7, 1.0, 1.0)
@register_class
class SLICESOCK_FilePath(InputNodeSocket):
bl_label = "Filepath"
value: StringProperty(name="Filepath", default="", subtype='FILE_PATH') # pyright: ignore[reportInvalidTypeForm]
color = (0.4, 0.7, 1.0, 1.0)
@register_class
class SLICESOCK_VectorRotation_Common(InputNodeSocket):
bl_label = "VectorRotation"
value: bpy.props.FloatVectorProperty(name="Vector", size=3, default=(0.0, 0.0, 0.0), subtype='EULER') # pyright: ignore[reportInvalidTypeForm]
color = (0.39, 0.39, 0.78, 1.0)
@register_class
class SLICESOCK_VectorRotation(SLICESOCK_VectorRotation_Common): ...
@register_class
class SLICESOCK_VectorRotationRo(ReadonlySocket, SLICESOCK_VectorRotation_Common): ...
@register_class
class SLICESOCK_Mesh(LabelNodeSocket):
bl_label = "Mesh"
default = None
color = (0, 214/255, 163/255, 1.0)
@register_class
class SLICESOCK_Paths(LabelNodeSocket):
bl_label = "Paths"
default = SlicePaths()
color = (214/255, 0, 120/255, 1.0)
@register_class
class SLICESOCK_Gcode(LabelNodeSocket):
bl_label = "Gcode"
default = ""
color = (1., 1., 1., 1.)
@register_class
class SLICESOCK_Intrinsics(LabelNodeSocket):
bl_label = "Intrinsics"
default = None
color = (0.2, 0.2, 0.2, 1.0)

75
core/nodeslicer/tree.py Normal file
View file

@ -0,0 +1,75 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import bpy
from bpy.types import NodeTree, Operator, Menu
from ...registry import register_class
if TYPE_CHECKING:
from bpy.stub_internal.rna_enums import OperatorReturnItems
@register_class
class SLICETREE_NodeTree(NodeTree):
bl_idname = "SLICETREE_NodeTree"
bl_label = "NodeSlicer Tree"
bl_icon = "NODETREE"
@register_class
class SLICENODE_OT_Execute(Operator):
bl_idname = "slicenode.execute"
bl_label = "Execute"
bl_description = "Execute"
node_name: bpy.props.StringProperty() # pyright: ignore[reportInvalidTypeForm]
def execute(self, context) -> set['OperatorReturnItems']:
node_tree = context.space_data.edit_tree # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess]
node = node_tree.nodes[self.node_name]
_ = node.evaluate()
self.report({"INFO"}, f"Execution Successful")
return {"FINISHED"}
@register_class
class SLICENODE_MT_Menu(Menu):
bl_idname = "SLICENODE_MT_Menu"
bl_label = "SliceNodes"
def draw(self, context):
layout = self.layout
assert layout
layout.operator("node.add_node", text="String").type = "SLICENODE_InputString"
layout.operator("node.add_node", text="Float").type = "SLICENODE_InputFloat"
layout.operator("node.add_node", text="Integer").type = "SLICENODE_InputInt"
layout.operator("node.add_node", text="Rotation").type = "SLICENODE_InputVectorRotation"
layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath"
layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject"
layout.operator("node.add_node", text="Printer Intrinsics").type = "SLICENODE_InputIntrinsics"
layout.operator("node.add_node", text="Test").type = "SLICENODE_Test"
layout.operator("node.add_node", text="Get Mesh").type = "SLICENODE_GetMesh"
layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths"
layout.separator()
layout.operator("node.add_node", text="Slice Shells").type = "SLICENODE_SliceShells"
layout.operator("node.add_node", text="Buffer Shells").type = "SLICENODE_BufferShells"
layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple"
layout.operator("node.add_node", text="Infill").type = "SLICENODE_Infill"
layout.operator("node.add_node", text="Paths to Gcode").type = "SLICENODE_PathsToGcode"
layout.separator()
layout.operator("node.add_node", text="Gcode Block").type = "SLICENODE_GcodeBlock"
layout.operator("node.add_node", text="Gcode Helper").type = "SLICENODE_GcodeHelper"
layout.operator("node.add_node", text="Load Gcode").type = "SLICENODE_LoadGcode"
layout.separator()
layout.operator("node.add_node", text="Join Gcode").type = "SLICENODE_JoinGcode"
layout.operator("node.add_node", text="Join Mesh").type = "SLICENODE_JoinMesh"
layout.operator("node.add_node", text="Join Paths").type = "SLICENODE_JoinPaths"
layout.operator("node.add_node", text="Boolean Paths").type = "SLICENODE_JoinSlices"
layout.separator()
layout.operator("node.add_node", text="Gcode to File").type = "SLICENODE_GcodeToFile"
layout.operator("node.add_node", text="Gcode to Mesh").type = "SLICENODE_GcodeToMesh"
def draw_add_menu(self, context):
space = context.space_data
tree = getattr(space, "edit_tree", None)
if tree and tree.bl_idname == "SLICETREE_NodeTree":
self.layout.separator()
self.layout.menu("SLICENODE_MT_Menu")

View file

@ -0,0 +1,19 @@
M107
;TYPE:Custom
; Filament-specific end gcode
G1 Z21 F720 ; Move print head up
G1 X0 Y200 F3600 ; park
G1 Z69 F720 ; Move print head further up
G4 ; wait
M221 S100 ; reset flow
M900 K0 ; reset LA
M104 S0 ; turn off temperature
M140 S0 ; turn off heatbed
M107 ; turn off fan
M84 ; disable motors
M73 P100 R0
M73 Q100 S0
M84 ; disable motors

View file

@ -0,0 +1,55 @@
; generated by PrusaSlicer 2.9.4 on 2026-05-10 at 11:51:50 UTC
;
; external perimeters extrusion width = 0.45mm
; perimeters extrusion width = 0.45mm
; infill extrusion width = 0.45mm
; solid infill extrusion width = 0.45mm
; top infill extrusion width = 0.40mm
; first layer extrusion width = 0.42mm
M73 P0 R24
M73 Q0 S24
M201 X1000 Y1000 Z200 E5000 ; sets maximum accelerations, mm/sec^2
M203 X200 Y200 Z12 E120 ; sets maximum feedrates, mm / sec
M204 S1250 T1250 ; sets acceleration (S) and retract acceleration (R), mm/sec^2
M205 X8.00 Y8.00 Z0.40 E4.50 ; sets the jerk limits, mm/sec
M205 S0 T0 ; sets the minimum extruding and travel feed rate, mm/sec
; printing object Cube.016 id:0 copy 0
; stop printing object Cube.016 id:0 copy 0
;TYPE:Custom
M862.3 P "MK3S" ; printer model check
M862.1 P0.4 ; nozzle diameter check
M115 U3.14.1 ; tell printer latest fw version
G90 ; use absolute coordinates
M83 ; extruder relative mode
M104 S215 ; set extruder temp
M140 S60 ; set bed temp
M190 S60 ; wait for bed temp
M109 S215 ; wait for extruder temp
G28 W ; home all without mesh bed level
G80 X112.246 Y92.2458 W25.5083 H25.5083 ; mesh bed levelling
G1 Z0.2 F720
G1 Y-3 F1000 ; go outside print area
G92 E0
G1 X60 E9 F1000 ; intro line
G1 X100 E12.5 F1000 ; intro line
G92 E0
M221 S95
; Don't change E values below. Excessive value can damage the printer.
M907 E538 ; set extruder motor current
G21 ; set units to millimeters
G90 ; use absolute coordinates
M83 ; use relative distances for extrusion
M900 K0.05 ; Filament gcode LA 1.5
M900 K30 ; Filament gcode LA 1.0
M108

View file

@ -0,0 +1,2 @@
G0 Z0.1 F720 ; Move to the desired physical Z clearance height
G92 Z0 ; Redefine this position as Z=0