obj to path redo

This commit is contained in:
Nicolas Predella 2026-05-06 14:15:39 +03:00
parent cf7749b9b7
commit 0e6b6f4b92
4 changed files with 108 additions and 116 deletions

View file

@ -1,4 +1,6 @@
from __future__ import annotations from __future__ import annotations
from numpy._typing._array_like import NDArray
from numpy import float64
bl_info = { bl_info = {
"name": "NodeSlicer", "name": "NodeSlicer",
@ -16,14 +18,14 @@ import bpy
import os import os
import numpy as np import numpy as np
from bpy.types import NodeTree, Node, NodeSocket, Operator, Menu from bpy.types import NodeTree, Operator, Menu
from bpy.props import PointerProperty, StringProperty, FloatProperty, IntProperty from bpy.props import PointerProperty, StringProperty, FloatProperty, IntProperty
from .registry import register_class from .registry import register_class
from .core import slice from .core import slice
from .core.slice import Pather, slice_at_range from .core.slice import Pather, slice_at_range
from .infra.blender_mesh_capture import SlicingObject, capture_mesh_edges from .infra.blender_mesh_capture import SlicingObject, capture_mesh_edges
from .infra.blender_slice_nodes_generic import SliceNodeSocket from .infra.blender_slice_nodes_generic import SliceNode, SliceNodeSocket, resolve_input
if TYPE_CHECKING: if TYPE_CHECKING:
from bpy.stub_internal.rna_enums import OperatorReturnItems from bpy.stub_internal.rna_enums import OperatorReturnItems
@ -136,21 +138,6 @@ class SLICESOCK_Gcode(LabelNodeSocket):
# Nodes # Nodes
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class SliceNode(Node, AutoBlIdName):
def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ...
def add_output(self, bl_idname: str, name: str | None = None):
from .registry import get_label_by_name
return self.outputs.new(bl_idname, name or get_label_by_name(bl_idname))
def add_input(self, bl_idname: str, name: str | None = None, multi: bool = False):
from .registry import get_label_by_name
sock = self.inputs.new(bl_idname, name or get_label_by_name(bl_idname))
if multi:
sock.link_limit = 0
sock.display_shape = 'LIST'
return sock
# Basic Inputs # Basic Inputs
class InputNode(SliceNode): class InputNode(SliceNode):
@ -178,6 +165,11 @@ class SLICENODE_InputFloat(InputNode):
class SLICENODE_InputInt(InputNode): class SLICENODE_InputInt(InputNode):
input_type = "Int" input_type = "Int"
@register_class
class SLICENODE_InputObject(InputNode):
input_type = "Object"
bl_icon = "OBJECT_DATA"
# Math # Math
@register_class @register_class
@ -217,8 +209,8 @@ class SLICENODE_IntMath(SliceNode):
layout.prop(self, "operation", text="") layout.prop(self, "operation", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> int: def evaluate_output(self, socket_name: str, **_kwargs) -> int:
a: int = _resolve_input(self.inputs[0]) a: int = resolve_input(self.inputs[0])
b: int = _resolve_input(self.inputs[1]) b: int = resolve_input(self.inputs[1])
op = self.operation op = self.operation
try: try:
@ -241,11 +233,6 @@ class SLICENODE_IntMath(SliceNode):
return 0 return 0
return 0 return 0
@register_class
class SLICENODE_InputObject(InputNode):
bl_label = "Object"
bl_icon = "OBJECT_DATA"
@register_class @register_class
class SLICENODE_GetMesh(SliceNode): class SLICENODE_GetMesh(SliceNode):
bl_label = "Get Mesh" bl_label = "Get Mesh"
@ -257,7 +244,7 @@ class SLICENODE_GetMesh(SliceNode):
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingObject | None: def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingObject | None:
if socket_name != "Mesh": return None if socket_name != "Mesh": return None
obj: Any = _resolve_input(self.inputs.get("Object")) obj: Any = resolve_input(self.inputs.get("Object"))
so = SlicingObject(obj, obj.name) so = SlicingObject(obj, obj.name)
@ -272,9 +259,9 @@ class SLICENODE_GetPaths(SliceNode):
self.add_output("SLICESOCK_Paths") self.add_output("SLICESOCK_Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]: def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]:
obj: Any = _resolve_input(self.inputs.get("Object")) obj: Any = resolve_input(self.inputs.get("Object"))
so = capture_mesh_edges(obj) so: list[NDArray[float64]] = capture_mesh_edges(obj)
return so return so
@ -290,8 +277,8 @@ class SLICENODE_SliceShells(SliceNode):
def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]: def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]:
if socket_name != "Slices": return [] if socket_name != "Slices": return []
slicingmesh: SlicingObject | None = _resolve_input(self.inputs.get("Mesh")) slicingmesh: SlicingObject | None = resolve_input(self.inputs.get("Mesh"))
layer_height: float = _resolve_input(self.inputs.get("Layer Height")) layer_height: float = resolve_input(self.inputs.get("Layer Height"))
if not slicingmesh: return [] if not slicingmesh: return []
@ -333,8 +320,8 @@ class SLICENODE_BufferShells(SliceNode):
def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]: def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]:
if socket_name != "Slices": return [] if socket_name != "Slices": return []
slices: list[np.ndarray] = _resolve_input(self.inputs.get("Slices")) slices: list[np.ndarray] = resolve_input(self.inputs.get("Slices"))
distance: float = _resolve_input(self.inputs.get("Distance")) distance: float = resolve_input(self.inputs.get("Distance"))
buffered_slices = slice.buffer_slice(slices, distance, miter=self.shape_option) buffered_slices = slice.buffer_slice(slices, distance, miter=self.shape_option)
@ -349,7 +336,7 @@ class SLICENODE_SlicesToPaths(SliceNode):
self.add_output("SLICESOCK_Paths") self.add_output("SLICESOCK_Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]: def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]:
slices: list[np.ndarray] = _resolve_input(self.inputs.get("Slices")) slices: list[np.ndarray] = resolve_input(self.inputs.get("Slices"))
return slices return slices
@register_class @register_class
@ -361,7 +348,7 @@ class SLICENODE_PathsToGcode(SliceNode):
self.add_output("SLICESOCK_Gcode") self.add_output("SLICESOCK_Gcode")
def evaluate_output(self, socket_name: str, **_kwargs) -> str: def evaluate_output(self, socket_name: str, **_kwargs) -> str:
paths: list[np.ndarray] = _resolve_input(self.inputs.get("Paths")) paths: list[np.ndarray] = resolve_input(self.inputs.get("Paths"))
pather: Pather = Pather(paths) pather: Pather = Pather(paths)
gcode: str = pather.to_gcode() gcode: str = pather.to_gcode()
return gcode return gcode
@ -417,7 +404,7 @@ class SLICENODE_JoinGcode(SliceNodeJoiner):
def evaluate_output(self, socket_name: str, **_kwargs) -> str: def evaluate_output(self, socket_name: str, **_kwargs) -> str:
gcode_input: list[str] = [] gcode_input: list[str] = []
for input in self.inputs: for input in self.inputs:
gcode_input += [_resolve_input(input)] or [""] gcode_input += [resolve_input(input)] or [""]
return "\n".join(gcode_input) return "\n".join(gcode_input)
@ -445,7 +432,7 @@ class SLICENODE_JoinSlices(SliceNodeJoiner):
accumulated: list[np.ndarray] = [] accumulated: list[np.ndarray] = []
current: list[np.ndarray] = [] current: list[np.ndarray] = []
for input in self.inputs: for input in self.inputs:
current =_resolve_input(input) current =resolve_input(input)
if not current: if not current:
continue continue
@ -469,7 +456,7 @@ class SLICENODE_JoinMesh(SliceNodeJoiner):
merged_mesh = SlicingObject() merged_mesh = SlicingObject()
for input in self.inputs: for input in self.inputs:
mesh =_resolve_input(input) mesh =resolve_input(input)
if mesh: if mesh:
merged_mesh.mesh = np.concatenate([merged_mesh.mesh, mesh.mesh], axis=0) merged_mesh.mesh = np.concatenate([merged_mesh.mesh, mesh.mesh], axis=0)
@ -484,7 +471,7 @@ class SLICENODE_JoinPaths(SliceNodeJoiner):
merged_paths = [] merged_paths = []
for input in self.inputs: for input in self.inputs:
paths = _resolve_input(input) paths = resolve_input(input)
if paths: if paths:
merged_paths.extend(paths) merged_paths.extend(paths)
@ -501,7 +488,7 @@ class SLICENODE_LoadGcode(SliceNode):
self.add_output("SLICESOCK_Gcode") self.add_output("SLICESOCK_Gcode")
def evaluate_output(self, socket_name: str, **_kwargs) -> str: def evaluate_output(self, socket_name: str, **_kwargs) -> str:
path = _resolve_input(self.inputs.get("Filepath")) path = resolve_input(self.inputs.get("Filepath"))
if not path: return "" if not path: return ""
abs_path = bpy.path.abspath(path) abs_path = bpy.path.abspath(path)
if os.path.exists(abs_path): if os.path.exists(abs_path):
@ -527,8 +514,8 @@ class SLICENODE_GcodeToFile(ExecSliceNode):
self.add_input("SLICESOCK_String", "Filename") self.add_input("SLICESOCK_String", "Filename")
def evaluate(self): def evaluate(self):
gcode: str = _resolve_input(self.inputs.get("Gcode")) gcode: str = resolve_input(self.inputs.get("Gcode"))
filename: str = _resolve_input(self.inputs.get("Filename")) filename: str = resolve_input(self.inputs.get("Filename"))
filepath = bpy.path.abspath(filename) filepath = bpy.path.abspath(filename)
os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True) os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
@ -548,7 +535,7 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
self.add_input("SLICESOCK_Gcode") self.add_input("SLICESOCK_Gcode")
def evaluate(self): def evaluate(self):
gcode: str = _resolve_input(self.inputs.get("Gcode")) gcode: str = resolve_input(self.inputs.get("Gcode"))
if not gcode: if not gcode:
return 'empty' return 'empty'
@ -628,7 +615,7 @@ class SLICENODE_Test(ExecSliceNode):
self.add_input("SLICESOCK_Gcode") self.add_input("SLICESOCK_Gcode")
def evaluate(self): def evaluate(self):
_ = _resolve_input(self.inputs.get("Gcode")) _ = resolve_input(self.inputs.get("Gcode"))
return 'ok' return 'ok'
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View file

@ -301,75 +301,60 @@ def group_segments_by_z(segments: np.ndarray) -> dict[int, np.ndarray]:
############################################################ ############################################################
class Pather(object): class Pather(object):
FEEDRATE: float = 1200. # mm/min
EXTRUSION_RATE: float = 0.017 # E per mm of path
def __init__(self, paths: list[np.ndarray], **kwargs) -> None: def __init__(self, paths: list[np.ndarray], **kwargs) -> None:
self.paths = paths self.paths = paths
self.bed_center_x: float = 250. self.bed_center_x: float = 250.
self.bed_center_y: float = 250. self.bed_center_y: float = 250.
self.feedrate: float = 1200.
self.extrusion_rate: float = 0.017
def to_gcode(self) -> str: def to_gcode(self) -> str:
"""
Convert a list of paths (3D numpy arrays) into G-code.
"""
if not self.paths: if not self.paths:
return "" return ""
def z_key(path) -> float:
return path[0, 2]
def segment_length_3d(p1, p2) -> float: def segment_length_3d(p1, p2) -> float:
dx = p2[0] - p1[0] dx = p2[0] - p1[0]
dy = p2[1] - p1[1] dy = p2[1] - p1[1]
dz = p2[2] - p1[2] dz = p2[2] - p1[2]
return (dx * dx + dy * dy + dz * dz) ** 0.5 return (dx * dx + dy * dy + dz * dz) ** 0.5
# Sort paths by Z height
lines = sorted(self.paths, key=z_key)
gcode: list[str] = [ gcode: list[str] = [
"; Generated from NodeSlicer Paths (via Pyclipper)", "; Generated from NodeSlicer",
f"; FEEDRATE={self.FEEDRATE}", f";TYPE:Custom",
f"; EXTRUSION_RATE={self.EXTRUSION_RATE}", f";FEEDRATE={self.feedrate}",
f";EXTRUSION_RATE={self.extrusion_rate}",
"G21 ; set units to mm", "G21 ; set units to mm",
"G90 ; absolute XYZ positioning", "G90 ; absolute XYZ positioning",
"M82 ; absolute extrusion mode", "M83 ; relative extrusion mode",
"G92 E0", "G92 E0",
] ]
e = 0.0
last_z = -1.0 last_z = -1.0
for line in lines: for line in self.paths:
coords = line.tolist() coords = line.tolist()
if len(coords) < 2: if len(coords) < 2:
continue continue
x0, y0, z0 = coords[0] x0, y0, z0 = coords[0]
# Layer change markers for PrusaSlicer
if abs(z0 - last_z) > 0.001: if abs(z0 - last_z) > 0.001:
gcode.append(f";LAYER_CHANGE")
gcode.append(f";Z:{z0:.3f}")
gcode.append(f";BEFORE_LAYER_CHANGE") gcode.append(f";BEFORE_LAYER_CHANGE")
gcode.append(f"G92 E0") # Reset E on layer change
e = 0.0
gcode.append(f"G1 Z{z0:.3f} F720") gcode.append(f"G1 Z{z0:.3f} F720")
gcode.append(f";AFTER_LAYER_CHANGE") gcode.append(f";AFTER_LAYER_CHANGE")
last_z = z0 last_z = z0
# Travel move to start of this path
gcode.append(f"G0 X{x0:.3f} Y{y0:.3f} Z{z0:.3f}") gcode.append(f"G0 X{x0:.3f} Y{y0:.3f} Z{z0:.3f}")
gcode.append(";TYPE:External perimeter") gcode.append(";TYPE:External perimeter")
# Extrude along the line
for p1, p2 in zip(coords, coords[1:]): for p1, p2 in zip(coords, coords[1:]):
seg_len: float = segment_length_3d(p1, p2) seg_len: float = segment_length_3d(p1, p2)
e += seg_len * self.EXTRUSION_RATE # Calculate extrusion for this segment only
e_segment = seg_len * self.extrusion_rate
x, y, z = p2 x, y, z = p2
gcode.append( gcode.append(
f"G1 X{x:.3f} Y{y:.3f} Z{z:.3f} E{e:.5f} F{self.FEEDRATE}" f"G1 X{x:.3f} Y{y:.3f} Z{z:.3f} E{e_segment:.5f} F{self.feedrate}"
) )
return "\n".join(gcode) return "\n".join(gcode)

View file

@ -350,7 +350,9 @@ def objects_to_tris(objects: list[Object], scale) -> (NDArray[tuple[int, int, in
def capture_mesh_edges(obj: Object) -> list[NDArray[float64]]: def capture_mesh_edges(obj: Object) -> list[NDArray[float64]]:
try: try:
depsgraph = bpy.context.evaluated_depsgraph_get() depsgraph = bpy.context.evaluated_depsgraph_get()
mesh: Mesh = obj.to_mesh(depsgraph=depsgraph, preserve_all_data_layers=True) obj_eval = obj.evaluated_get(depsgraph)
mesh = obj_eval.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
except Exception: except Exception:
return [] return []
@ -358,47 +360,49 @@ def capture_mesh_edges(obj: Object) -> list[NDArray[float64]]:
return [] return []
try: try:
assert bpy.context.scene if not mesh.vertices:
assert bpy.context.scene.unit_settings return []
scene_scale: float = bpy.context.scene.unit_settings.scale_length
scale = 1000.0 * scene_scale
# Get vertices # 1. Scaling & Matrix Setup
scale = 1000.0 * bpy.context.scene.unit_settings.scale_length
world_mat = np.array(obj.matrix_world)
# 2. Extract and Transform Coordinates
num_verts = len(mesh.vertices) num_verts = len(mesh.vertices)
verts_flat = np.empty(num_verts * 3, dtype=np.float64) verts = np.empty((num_verts, 3), dtype=np.float64)
mesh.vertices.foreach_get("co", verts_flat) mesh.vertices.foreach_get("co", verts.ravel())
verts = verts_flat.reshape(-1, 3)
# Apply world matrix transformation (consistent with objects_to_tris) # World transform: (V * R.T) + T
mat = np.array(obj.matrix_world.transposed()) tx_verts = (verts @ world_mat[:3, :3].T + world_mat[:3, 3]) * scale
homogeneous_verts = np.hstack((verts, np.ones((num_verts, 1))))
tx_verts = (homogeneous_verts @ mat)[:, :3] * scale
# Identify connectivity between consecutive indices # 3. Build connectivity lookup
num_edges = len(mesh.edges) # Only need edges where v2 = v1 + 1 or loop-back
connected = np.zeros(num_verts - 1, dtype=bool) edges_set = {tuple(sorted(e.vertices)) for e in mesh.edges}
if num_edges > 0: # Identify gaps in sequential connectivity
edges_flat = np.empty(num_edges * 2, dtype=np.int32) # connected[i] is True if vertex i is connected to i+1
mesh.edges.foreach_get("vertices", edges_flat) connected = np.array([(i, i + 1) in edges_set for i in range(num_verts - 1)])
edges = edges_flat.reshape(-1, 2)
v_min = np.min(edges, axis=1) # 4. Split into segments (Maintains original index order)
v_max = np.max(edges, axis=1)
# Check for edges connecting vertex i and i+1
mask = (v_max == v_min + 1)
consecutive_v_min = v_min[mask]
# Ensure indices are within bounds for the 'connected' array
valid_mask = (consecutive_v_min >= 0) & (consecutive_v_min < num_verts - 1)
connected[consecutive_v_min[valid_mask]] = True
# Split vertices into paths where connectivity is interrupted
split_at = np.where(~connected)[0] + 1 split_at = np.where(~connected)[0] + 1
paths = np.split(tx_verts, split_at) raw_paths = np.split(tx_verts, split_at)
indices_split = np.split(np.arange(num_verts), split_at)
final_paths = []
for i, path_coords in enumerate(raw_paths):
if path_coords.shape[0] == 0:
continue
# 5. Handle Loop Closure
# Only check if the segment could potentially be a loop (indices > 2)
idx = indices_split[i]
if len(idx) > 2 and tuple(sorted((idx[0], idx[-1]))) in edges_set:
path_coords = np.vstack((path_coords, path_coords[0]))
final_paths.append(path_coords)
return final_paths
return [p for p in paths if p.size > 0]
finally: finally:
# Compatibility with Blender 2.8+ to avoid memory leaks # Compatibility with Blender 2.8+ to avoid memory leaks
# We check if the attribute exists as it's not used elsewhere in this file # We check if the attribute exists as it's not used elsewhere in this file

View file

@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
import typing import typing
from bpy.types import NodeSocket from typing import Any
from bpy.types import NodeSocket, Node
class NodeException(Exception): ... class NodeException(Exception): ...
_EvalCache = dict[tuple[int, str], typing.Any] _EvalCache = dict[tuple[int, str], Any]
class AutoBlIdName: class AutoBlIdName:
def __init_subclass__(cls, **kwargs): def __init_subclass__(cls, **kwargs):
@ -14,11 +15,26 @@ class AutoBlIdName:
cls.bl_idname = cls.__name__ cls.bl_idname = cls.__name__
class SliceNodeSocket(NodeSocket, AutoBlIdName): class SliceNodeSocket(NodeSocket, AutoBlIdName):
default: typing.Any = None default: Any = None
color = (1., 1., 1., 1.) color = (1., 1., 1., 1.)
def draw_color(self, context, node) -> tuple[float, float, float, float]: return self.color # pyright: ignore[reportIncompatibleMethodOverride] def draw_color(self, context, node) -> tuple[float, float, float, float]: return self.color # pyright: ignore[reportIncompatibleMethodOverride]
class SliceNode(Node, AutoBlIdName):
def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ...
def add_output(self, bl_idname: str, name: str | None = None):
from ..registry import get_label_by_name
return self.outputs.new(bl_idname, name or get_label_by_name(bl_idname))
def add_input(self, bl_idname: str, name: str | None = None, multi: bool = False):
from ..registry import get_label_by_name
sock = self.inputs.new(bl_idname, name or get_label_by_name(bl_idname))
if multi:
sock.link_limit = 0
sock.display_shape = 'LIST'
return sock
## Evaluation helpers ## Evaluation helpers
def _follow_reroute(socket: NodeSocket) -> NodeSocket: def _follow_reroute(socket: NodeSocket) -> NodeSocket:
@ -40,7 +56,7 @@ def _follow_reroute(socket: NodeSocket) -> NodeSocket:
return current return current
def _resolve_input( def resolve_input(
socket: SliceNodeSocket | None, socket: SliceNodeSocket | None,
*, *,
cache: _EvalCache | None = None, cache: _EvalCache | None = None,
@ -69,7 +85,7 @@ def _resolve_input(
def _resolve_output( def _resolve_output(
socket: NodeSocket, socket: NodeSocket,
*, *,
default: typing.Any = None, default: Any = None,
cache: _EvalCache | None = None, cache: _EvalCache | None = None,
visited: set[tuple[int, str]] | None = None, visited: set[tuple[int, str]] | None = None,
): ):
@ -97,7 +113,7 @@ def _resolve_output(
break break
if input_to_bypass: if input_to_bypass:
value = _resolve_input(typing.cast(Any, input_to_bypass), cache=cache, visited=visited) value = resolve_input(typing.cast(Any, input_to_bypass), cache=cache, visited=visited)
cache[key] = value cache[key] = value
return value return value
return default return default