From 0e6b6f4b920f5f529e9b3c0d1fbddf0bb94091be Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Wed, 6 May 2026 14:15:39 +0300 Subject: [PATCH] obj to path redo --- __init__.py | 73 ++++++++++-------------- core/slice.py | 39 ++++--------- infra/blender_mesh_capture.py | 84 +++++++++++++++------------- infra/blender_slice_nodes_generic.py | 28 ++++++++-- 4 files changed, 108 insertions(+), 116 deletions(-) diff --git a/__init__.py b/__init__.py index 531fea1..9734de1 100644 --- a/__init__.py +++ b/__init__.py @@ -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' # ----------------------------------------------------------------------------- diff --git a/core/slice.py b/core/slice.py index d686d7d..2205b8f 100644 --- a/core/slice.py +++ b/core/slice.py @@ -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) \ No newline at end of file diff --git a/infra/blender_mesh_capture.py b/infra/blender_mesh_capture.py index b9f5761..6074957 100644 --- a/infra/blender_mesh_capture.py +++ b/infra/blender_mesh_capture.py @@ -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 diff --git a/infra/blender_slice_nodes_generic.py b/infra/blender_slice_nodes_generic.py index ecc3205..bafc8c9 100644 --- a/infra/blender_slice_nodes_generic.py +++ b/infra/blender_slice_nodes_generic.py @@ -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