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 numpy._typing._array_like import NDArray
from numpy import float64
bl_info = {
"name": "NodeSlicer",
@ -16,14 +18,14 @@ import bpy
import os
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 .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 SliceNodeSocket
from .infra.blender_slice_nodes_generic import SliceNode, SliceNodeSocket, resolve_input
if TYPE_CHECKING:
from bpy.stub_internal.rna_enums import OperatorReturnItems
@ -136,21 +138,6 @@ class SLICESOCK_Gcode(LabelNodeSocket):
# 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
class InputNode(SliceNode):
@ -162,7 +149,7 @@ class InputNode(SliceNode):
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
@ -178,6 +165,11 @@ class SLICENODE_InputFloat(InputNode):
class SLICENODE_InputInt(InputNode):
input_type = "Int"
@register_class
class SLICENODE_InputObject(InputNode):
input_type = "Object"
bl_icon = "OBJECT_DATA"
# Math
@register_class
@ -217,8 +209,8 @@ class SLICENODE_IntMath(SliceNode):
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])
a: int = resolve_input(self.inputs[0])
b: int = resolve_input(self.inputs[1])
op = self.operation
try:
@ -241,11 +233,6 @@ class SLICENODE_IntMath(SliceNode):
return 0
return 0
@register_class
class SLICENODE_InputObject(InputNode):
bl_label = "Object"
bl_icon = "OBJECT_DATA"
@register_class
class SLICENODE_GetMesh(SliceNode):
bl_label = "Get Mesh"
@ -257,7 +244,7 @@ class SLICENODE_GetMesh(SliceNode):
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingObject | 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)
@ -272,9 +259,9 @@ class SLICENODE_GetPaths(SliceNode):
self.add_output("SLICESOCK_Paths")
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
@ -290,8 +277,8 @@ class SLICENODE_SliceShells(SliceNode):
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"))
slicingmesh: SlicingObject | None = resolve_input(self.inputs.get("Mesh"))
layer_height: float = resolve_input(self.inputs.get("Layer Height"))
if not slicingmesh: return []
@ -333,8 +320,8 @@ class SLICENODE_BufferShells(SliceNode):
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"))
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)
@ -349,7 +336,7 @@ class SLICENODE_SlicesToPaths(SliceNode):
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"))
slices: list[np.ndarray] = resolve_input(self.inputs.get("Slices"))
return slices
@register_class
@ -361,7 +348,7 @@ class SLICENODE_PathsToGcode(SliceNode):
self.add_output("SLICESOCK_Gcode")
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)
gcode: str = pather.to_gcode()
return gcode
@ -417,7 +404,7 @@ class SLICENODE_JoinGcode(SliceNodeJoiner):
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
gcode_input: list[str] = []
for input in self.inputs:
gcode_input += [_resolve_input(input)] or [""]
gcode_input += [resolve_input(input)] or [""]
return "\n".join(gcode_input)
@ -445,7 +432,7 @@ class SLICENODE_JoinSlices(SliceNodeJoiner):
accumulated: list[np.ndarray] = []
current: list[np.ndarray] = []
for input in self.inputs:
current =_resolve_input(input)
current =resolve_input(input)
if not current:
continue
@ -469,7 +456,7 @@ class SLICENODE_JoinMesh(SliceNodeJoiner):
merged_mesh = SlicingObject()
for input in self.inputs:
mesh =_resolve_input(input)
mesh =resolve_input(input)
if mesh:
merged_mesh.mesh = np.concatenate([merged_mesh.mesh, mesh.mesh], axis=0)
@ -484,7 +471,7 @@ class SLICENODE_JoinPaths(SliceNodeJoiner):
merged_paths = []
for input in self.inputs:
paths = _resolve_input(input)
paths = resolve_input(input)
if paths:
merged_paths.extend(paths)
@ -501,7 +488,7 @@ class SLICENODE_LoadGcode(SliceNode):
self.add_output("SLICESOCK_Gcode")
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 ""
abs_path = bpy.path.abspath(path)
if os.path.exists(abs_path):
@ -527,8 +514,8 @@ class SLICENODE_GcodeToFile(ExecSliceNode):
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"))
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)
@ -548,7 +535,7 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
self.add_input("SLICESOCK_Gcode")
def evaluate(self):
gcode: str = _resolve_input(self.inputs.get("Gcode"))
gcode: str = resolve_input(self.inputs.get("Gcode"))
if not gcode:
return 'empty'
@ -628,7 +615,7 @@ class SLICENODE_Test(ExecSliceNode):
self.add_input("SLICESOCK_Gcode")
def evaluate(self):
_ = _resolve_input(self.inputs.get("Gcode"))
_ = resolve_input(self.inputs.get("Gcode"))
return 'ok'
# -----------------------------------------------------------------------------

View file

@ -301,75 +301,60 @@ def group_segments_by_z(segments: np.ndarray) -> dict[int, np.ndarray]:
############################################################
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:
self.paths = paths
self.bed_center_x: float = 250.
self.bed_center_y: float = 250.
self.feedrate: float = 1200.
self.extrusion_rate: float = 0.017
def to_gcode(self) -> str:
"""
Convert a list of paths (3D numpy arrays) into G-code.
"""
if not self.paths:
return ""
def z_key(path) -> float:
return path[0, 2]
def segment_length_3d(p1, p2) -> float:
dx = p2[0] - p1[0]
dy = p2[1] - p1[1]
dz = p2[2] - p1[2]
return (dx * dx + dy * dy + dz * dz) ** 0.5
# Sort paths by Z height
lines = sorted(self.paths, key=z_key)
gcode: list[str] = [
"; Generated from NodeSlicer Paths (via Pyclipper)",
f"; FEEDRATE={self.FEEDRATE}",
f"; EXTRUSION_RATE={self.EXTRUSION_RATE}",
"; Generated from NodeSlicer",
f";TYPE:Custom",
f";FEEDRATE={self.feedrate}",
f";EXTRUSION_RATE={self.extrusion_rate}",
"G21 ; set units to mm",
"G90 ; absolute XYZ positioning",
"M82 ; absolute extrusion mode",
"M83 ; relative extrusion mode",
"G92 E0",
]
e = 0.0
last_z = -1.0
for line in lines:
for line in self.paths:
coords = line.tolist()
if len(coords) < 2:
continue
x0, y0, z0 = coords[0]
# Layer change markers for PrusaSlicer
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"G92 E0") # Reset E on layer change
e = 0.0
gcode.append(f"G1 Z{z0:.3f} F720")
gcode.append(f";AFTER_LAYER_CHANGE")
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(";TYPE:External perimeter")
# Extrude along the line
for p1, p2 in zip(coords, coords[1:]):
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
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,55 +350,59 @@ def objects_to_tris(objects: list[Object], scale) -> (NDArray[tuple[int, int, in
def capture_mesh_edges(obj: Object) -> list[NDArray[float64]]:
try:
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:
return []
if not mesh or not mesh.vertices:
if not mesh or not mesh.vertices:
return []
try:
assert bpy.context.scene
assert bpy.context.scene.unit_settings
scene_scale: float = bpy.context.scene.unit_settings.scale_length
scale = 1000.0 * scene_scale
if not mesh.vertices:
return []
# 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)
verts_flat = np.empty(num_verts * 3, dtype=np.float64)
mesh.vertices.foreach_get("co", verts_flat)
verts = verts_flat.reshape(-1, 3)
# Apply world matrix transformation (consistent with objects_to_tris)
mat = np.array(obj.matrix_world.transposed())
homogeneous_verts = np.hstack((verts, np.ones((num_verts, 1))))
tx_verts = (homogeneous_verts @ mat)[:, :3] * scale
# Identify connectivity between consecutive indices
num_edges = len(mesh.edges)
connected = np.zeros(num_verts - 1, dtype=bool)
if num_edges > 0:
edges_flat = np.empty(num_edges * 2, dtype=np.int32)
mesh.edges.foreach_get("vertices", edges_flat)
edges = edges_flat.reshape(-1, 2)
v_min = np.min(edges, axis=1)
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
paths = np.split(tx_verts, split_at)
verts = np.empty((num_verts, 3), dtype=np.float64)
mesh.vertices.foreach_get("co", verts.ravel())
# World transform: (V * R.T) + T
tx_verts = (verts @ world_mat[:3, :3].T + world_mat[:3, 3]) * scale
# 3. Build connectivity lookup
# Only need edges where v2 = v1 + 1 or loop-back
edges_set = {tuple(sorted(e.vertices)) for e in mesh.edges}
# Identify gaps in sequential connectivity
# connected[i] is True if vertex i is connected to i+1
connected = np.array([(i, i + 1) in edges_set for i in range(num_verts - 1)])
# 4. Split into segments (Maintains original index order)
split_at = np.where(~connected)[0] + 1
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:
# Compatibility with Blender 2.8+ to avoid memory leaks
# 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
import typing
from bpy.types import NodeSocket
from typing import Any
from bpy.types import NodeSocket, Node
class NodeException(Exception): ...
_EvalCache = dict[tuple[int, str], typing.Any]
_EvalCache = dict[tuple[int, str], Any]
class AutoBlIdName:
def __init_subclass__(cls, **kwargs):
@ -14,11 +15,26 @@ class AutoBlIdName:
cls.bl_idname = cls.__name__
class SliceNodeSocket(NodeSocket, AutoBlIdName):
default: typing.Any = None
default: Any = None
color = (1., 1., 1., 1.)
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
def _follow_reroute(socket: NodeSocket) -> NodeSocket:
@ -40,7 +56,7 @@ def _follow_reroute(socket: NodeSocket) -> NodeSocket:
return current
def _resolve_input(
def resolve_input(
socket: SliceNodeSocket | None,
*,
cache: _EvalCache | None = None,
@ -69,7 +85,7 @@ def _resolve_input(
def _resolve_output(
socket: NodeSocket,
*,
default: typing.Any = None,
default: Any = None,
cache: _EvalCache | None = None,
visited: set[tuple[int, str]] | None = None,
):
@ -97,7 +113,7 @@ def _resolve_output(
break
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
return value
return default