nodeslicer/__init__.py

706 lines
No EOL
23 KiB
Python

from __future__ import annotations
from numpy._typing._array_like import NDArray
from numpy import float64
bl_info = {
"name": "NodeSlicer",
"author": "OpenAI",
"version": (0, 1, 0),
"blender": (3, 6, 0),
"location": "Node Editor > Add",
"description": "Very small custom nodes example",
"category": "Node",
}
from math import floor
from typing import TYPE_CHECKING, Any
import bpy
import os
import numpy as np
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
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
# -----------------------------------------------------------------------------
# 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_Mesh(LabelNodeSocket):
bl_label = "Mesh"
default = None
color = (0, 214/255, 163/255, 1.0)
@register_class
class SLICESOCK_Slices(LabelNodeSocket):
bl_label = "Slices"
default = []
color = (0, 214/255, 96/255, 1.0)
@register_class
class SLICESOCK_Paths(LabelNodeSocket):
bl_label = "Paths"
default = []
color = (214/255, 0, 120/255, 1.0)
@register_class
class SLICESOCK_Gcode(LabelNodeSocket):
bl_label = "Gcode"
default = ""
color = (1., 1., 1., 1.)
# -----------------------------------------------------------------------------
# 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_InputObject(InputNode):
input_type = "Object"
bl_icon = "OBJECT_DATA"
# 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) -> list[np.ndarray]:
obj: Any = resolve_input(self.inputs.get("Object"))
so: list[NDArray[float64]] = capture_mesh_edges(obj)
return 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_Slices")
def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]:
if socket_name != "Slices": return []
slicingmesh: SlicingObject | None = resolve_input(self.inputs.get("Mesh"))
layer_height: float = resolve_input(self.inputs.get("Layer Height"))
if not slicingmesh: return []
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 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_Slices")
self.add_input("SLICESOCK_Float", "Distance").value = 0
self.add_output("SLICESOCK_Slices")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "shape_option")
def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]:
if socket_name != "Slices": return []
slices: list[np.ndarray] = resolve_input(self.inputs.get("Slices"))
distance: float = resolve_input(self.inputs.get("Distance"))
buffered_slices = slice.buffer_slice(slices, distance, miter=self.shape_option)
return buffered_slices
@register_class
class SLICENODE_SlicesToPaths(SliceNode):
bl_label = "Slices to Paths"
def init(self, context):
self.add_input("SLICESOCK_Slices")
self.add_output("SLICESOCK_Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]:
slices: list[np.ndarray] = resolve_input(self.inputs.get("Slices"))
return slices
@register_class
class SLICENODE_PathsToGcode(SliceNode):
bl_label = "Paths to Gcode"
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_output("SLICESOCK_Gcode")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
paths: list[np.ndarray] = resolve_input(self.inputs.get("Paths"))
pather: Pather = Pather(paths)
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_Slices"
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) -> list[np.ndarray]:
accumulated: list[np.ndarray] = []
current: list[np.ndarray] = []
for input in self.inputs:
current =resolve_input(input)
if not current:
continue
if not accumulated:
accumulated = current
current = []
continue
accumulated = slice.boolean_slice(accumulated, current, self.operation)
current = []
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"
def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]:
merged_paths = []
for input in self.inputs:
paths = resolve_input(input)
if paths:
merged_paths.extend(paths)
return merged_paths
### 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
for line in gcode.splitlines():
line = line.split(';')[0].strip()
if not line: continue
parts = line.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':
# Check if it's an extrusion move
# In absolute mode (typical), E must increase.
# In relative mode, E must be > 0.
# We'll assume absolute mode as per Pather.to_gcode() M82
is_extruding = new_e > last_e
if is_extruding:
segments.append(((last_x, last_y, last_z), (new_x, new_y, 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()
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="Integer Math").type = "SLICENODE_IntMath"
layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject"
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="Slices to Paths").type = "SLICENODE_SlicesToPaths"
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="Join Slices").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)
print('Nodeslicer Loaded')
def unregister():
bpy.types.NODE_MT_add.remove(draw_add_menu)
registry.blender_unregister_classes()
print('Nodeslicer Unloaded')
if __name__ == "__main__":
register()