diff --git a/core/nodeslicer/nodes.py b/core/nodeslicer/nodes.py index d382c3c..8582808 100644 --- a/core/nodeslicer/nodes.py +++ b/core/nodeslicer/nodes.py @@ -64,6 +64,10 @@ class SLICENODE_InputInt(InputNode): class SLICENODE_InputVectorRotation(InputNode): input_type = "VectorRotation" +@register_class +class SLICENODE_InputVector(InputNode): + input_type = "Vector" + @register_class class SLICENODE_InputObject(InputNode): input_type = "Object" @@ -421,26 +425,24 @@ class SLICENODE_BufferShells(SliceNode): 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 + return paths.buffer(distance, miter=self.shape_option, rotation=rotation) - # Buffer - res_paths, res_groups = slice.buffer_slice(prepared_paths, distance, miter=self.shape_option) +@register_class +class SLICENODE_PathOffset(SliceNode): + bl_label = "Path Offset" - 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 + def init(self, context): + self.add_input("SLICESOCK_Paths", "Paths") + self.add_input("SLICESOCK_Vector", "Offset") + self.add_output("SLICESOCK_Paths", "Paths") - return SlicePaths(final_paths, res_groups) + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: + paths: SlicePaths = self.resolve("Paths") + offset: Any = self.resolve("Offset") or (0.0, 0.0, 0.0) + + if not paths: return SlicePaths() + + return paths.offset(np.array(offset, dtype=np.float32)) @register_class class SLICENODE_BufferShellsMultiple(SliceNode): @@ -478,19 +480,11 @@ class SLICENODE_BufferShellsMultiple(SliceNode): 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_buffers: list[SlicePaths] = [] 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_buffers.append(SlicePaths(res_paths, res_groups)) + all_buffers.append(paths.buffer(dist, miter=self.shape_option, rotation=rotation)) # Zip Logic: Interleave paths by group (Z-layer) all_groups = set() @@ -511,13 +505,7 @@ class SLICENODE_BufferShellsMultiple(SliceNode): for p in group_paths: merged.append_with_group(p, g) - 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 merged] - return SlicePaths(final_paths, merged.groups) - - return merged + return paths.copy_with(merged, merged.groups) @register_class class SLICENODE_Infill(SliceNode): @@ -554,32 +542,13 @@ class SLICENODE_Infill(SliceNode): 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, + return paths.infill( density, rotation_deg=infill_rotation, - infill_type=self.infill_type # pyright: ignore[reportOptionalMemberAccess] + infill_type=self.infill_type, # pyright: ignore[reportOptionalMemberAccess] + layer_rotation=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" @@ -644,6 +613,31 @@ class SLICENODE_AssignMetadata(SliceNode): lines.append(f'; printer_model = {printer}') return '\n'.join(lines) +@register_class +class SLICENODE_GcodeRegexReplace(SliceNode): + bl_label = "Gcode Regex Replace" + + def init(self, context): + self.add_input("SLICESOCK_Gcode", "Gcode") + self.add_input("SLICESOCK_String", "Find") + self.add_input("SLICESOCK_String", "Replace") + self.add_output("SLICESOCK_GcodeRo", "Gcode") + + def evaluate_output(self, socket_name: str, **_kwargs) -> str: + import re + gcode = self.resolve("Gcode") or "" + find = self.resolve("Find") or "" + replace = self.resolve("Replace") or "" + + if not find: + return gcode + + try: + return re.sub(find, replace, gcode) + except Exception as e: + print(f"Regex error in node {self.name}: {e}") + return gcode + @register_class class SLICENODE_GcodeHelper(SliceNode): bl_label = "Gcode Helper" @@ -771,14 +765,11 @@ class SLICENODE_JoinSlices(SliceNodeJoiner): 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() + accumulated = accumulated.boolean(current, self.operation) return accumulated @@ -1696,7 +1687,11 @@ class SLICENODE_Executor(ExecSliceNode, SliceNodeJoiner): layout.prop(self, "auto_refresh") def evaluate(self, operator=None): - from ...infra.blender.blender_slice_nodes_generic import node_execution_feedback + from ...infra.blender.blender_slice_nodes_generic import node_execution_feedback, feedback_disabled, execution_cache + + # If auto_refresh is on, we don't want highlighting/redraws for upstream nodes either + maybe_disabled = feedback_disabled if self.auto_refresh else lambda: execution_cache() + for i, job in enumerate(self.inputs): # Resolve the input to get the connected node(s) res = resolve_input(job) @@ -1714,10 +1709,11 @@ class SLICENODE_Executor(ExecSliceNode, SliceNodeJoiner): operator.report({'INFO'}, f"Executing: {upstream_node.bl_label}") try: - with node_execution_feedback(upstream_node): - exec_res = upstream_node.evaluate(operator=operator) - if exec_res != 'ok' and exec_res is not None: - if operator: operator.report({'WARNING'}, f"Node {upstream_node.bl_label} returned {exec_res}") + with maybe_disabled(): + with node_execution_feedback(upstream_node): + exec_res = upstream_node.evaluate(operator=operator) + if exec_res != 'ok' and exec_res is not None: + if operator: operator.report({'WARNING'}, f"Node {upstream_node.bl_label} returned {exec_res}") except Exception as e: if operator: operator.report({'ERROR'}, f"Failed executing {upstream_node.bl_label}: {str(e)}") return 'error' diff --git a/core/nodeslicer/sockets.py b/core/nodeslicer/sockets.py index dde5343..c45fc26 100644 --- a/core/nodeslicer/sockets.py +++ b/core/nodeslicer/sockets.py @@ -100,13 +100,25 @@ 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] - bl_color = (0.39, 0.39, 0.78, 1.0) + bl_color = (166/255, 99/255, 199/255, 1.0) @register_class class SLICESOCK_VectorRotation(SLICESOCK_VectorRotation_Common): ... @register_class class SLICESOCK_VectorRotationRo(ReadonlySocket, SLICESOCK_VectorRotation_Common): ... +@register_class +class SLICESOCK_Vector_Common(InputNodeSocket): + bl_label = "Vector" + + value: bpy.props.FloatVectorProperty(name="Vector", size=3, default=(0.0, 0.0, 0.0)) # pyright: ignore[reportInvalidTypeForm] + bl_color = (0.39, 0.39, 0.78, 1.0) + +@register_class +class SLICESOCK_Vector(SLICESOCK_Vector_Common): ... +@register_class +class SLICESOCK_VectorRo(ReadonlySocket, SLICESOCK_Vector_Common): ... + @register_class class SLICESOCK_Mesh(LabelNodeSocket): bl_label = "Mesh" diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index a895df1..808ae3a 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -85,8 +85,10 @@ def tree_update_handler(scene, depsgraph): if should_refresh: _is_updating = True try: - with node_execution_feedback(node): - node.evaluate() + from ...infra.blender.blender_slice_nodes_generic import feedback_disabled + with feedback_disabled(): + with node_execution_feedback(node): + node.evaluate() except Exception as e: print(f"Auto-refresh failed: {e}") import traceback @@ -155,6 +157,7 @@ class SLICENODE_MT_DataInput(Menu): 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="Vector").type = "SLICENODE_InputVector" layout.separator() layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject" layout.operator("node.add_node", text="Overrides").type = "SLICENODE_InputOverrides" @@ -178,6 +181,7 @@ class SLICENODE_MT_Geometry(Menu): 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="Path Offset").type = "SLICENODE_PathOffset" layout.operator("node.add_node", text="Infill").type = "SLICENODE_Infill" layout.operator("node.add_node", text="Boolean Paths").type = "SLICENODE_JoinSlices" layout.separator() @@ -194,6 +198,7 @@ class SLICENODE_MT_Gcode(Menu): assert layout layout.operator("node.add_node", text="Paths to Gcode").type = "SLICENODE_PathsToGcode" layout.operator("node.add_node", text="Gcode Block").type = "SLICENODE_GcodeBlock" + layout.operator("node.add_node", text="Gcode Regex Replace").type = "SLICENODE_GcodeRegexReplace" layout.operator("node.add_node", text="Assign Metadata").type = "SLICENODE_AssignMetadata" layout.operator("node.add_node", text="Gcode Helper").type = "SLICENODE_GcodeHelper" layout.operator("node.add_node", text="Load Gcode").type = "SLICENODE_LoadGcode" diff --git a/core/slice.py b/core/slice.py index bb0b5ca..d761507 100644 --- a/core/slice.py +++ b/core/slice.py @@ -17,6 +17,75 @@ class SlicePaths(list[np.ndarray]): if groups: self.groups = list(groups) else: self.groups = list(range(0,len(paths))) + def copy_with(self, paths: list[np.ndarray], groups: list[int] | None = None) -> SlicePaths: + """Returns a new SlicePaths object with new paths/groups but preserving all other metadata.""" + new_obj = SlicePaths(paths, groups if groups is not None else self.groups) + # Copy any other attributes that might have been added + for k, v in self.__dict__.items(): + if k != "groups": + setattr(new_obj, k, v) + return new_obj + + def offset(self, offset_vec: np.ndarray) -> SlicePaths: + """Returns a new SlicePaths object shifted by the given vector.""" + return self.copy_with([p + offset_vec for p in self]) + + def transform(self, matrix: np.ndarray) -> SlicePaths: + """Applies a transformation matrix to all paths.""" + return self.copy_with([p @ matrix.T for p in self]) + + def buffer(self, distance: float, miter: str = 'round', rotation: Any = None) -> SlicePaths: + """Buffers (offsets) the paths. Handles optional rotation for non-planar buffering.""" + p = self + if rotation and any(rotation): + from mathutils import Euler + mat = Euler(rotation).to_matrix().inverted() + p = self.transform(np.array(mat)) + + res_paths, res_groups = buffer_slice(p, distance, miter=miter) + new_paths = SlicePaths(res_paths, res_groups) + + # Copy other metadata from self + for k, v in self.__dict__.items(): + if k != "groups": + setattr(new_paths, k, v) + + if rotation and any(rotation): + from mathutils import Euler + mat_inv = Euler(rotation).to_matrix() + new_paths = new_paths.transform(np.array(mat_inv)) + + return new_paths + + def infill(self, density: float, rotation_deg: float = 0.0, infill_type: str = 'GRID', layer_rotation: Any = None) -> SlicePaths: + """Generates infill for the paths.""" + p = self + if layer_rotation and any(layer_rotation): + from mathutils import Euler + mat = Euler(layer_rotation).to_matrix().inverted() + p = self.transform(np.array(mat)) + + res_paths, res_groups = infill_slice(p, density, rotation_deg=rotation_deg, infill_type=infill_type) + new_paths = SlicePaths(res_paths, res_groups) + + # Copy other metadata from self + for k, v in self.__dict__.items(): + if k != "groups": + setattr(new_paths, k, v) + + if layer_rotation and any(layer_rotation): + from mathutils import Euler + mat_inv = Euler(layer_rotation).to_matrix() + new_paths = new_paths.transform(np.array(mat_inv)) + + return new_paths + + def boolean(self, other: SlicePaths, operation: str = 'UNION') -> SlicePaths: + """Performs a 2D boolean operation with another set of paths.""" + res = boolean_slice(self, other, operation) + # Boolean operations merge paths across layers, so we don't preserve original grouping as precisely. + return self.copy_with(res) + def extend_with_groups(self, other: SlicePaths | list[np.ndarray]): if isinstance(other, SlicePaths): self.extend(other) diff --git a/infra/blender/blender_slice_nodes_generic.py b/infra/blender/blender_slice_nodes_generic.py index 11440f5..c49fde3 100644 --- a/infra/blender/blender_slice_nodes_generic.py +++ b/infra/blender/blender_slice_nodes_generic.py @@ -86,6 +86,7 @@ class SliceNode(Node, AutoBlIdName): ## Evaluation helpers _current_execution_cache: _EvalCache | None = None +_feedback_enabled: bool = True @contextmanager def execution_cache(): @@ -100,8 +101,23 @@ def execution_cache(): finally: _current_execution_cache = None +@contextmanager +def feedback_disabled(): + global _feedback_enabled + old = _feedback_enabled + _feedback_enabled = False + try: + yield + finally: + _feedback_enabled = old + @contextmanager def node_execution_feedback(node: bpy.types.Node): + if not _feedback_enabled: + with execution_cache(): + yield + return + # Save original color old_use_color = node.use_custom_color old_color = node.color[:] @@ -150,7 +166,7 @@ def resolve_input( assert socket.node if cache is None: - cache = getattr(socket.node, "_current_cache", _current_execution_cache) + cache = getattr(socket.node, "_current_cache", None) or _current_execution_cache if visited is None: visited = getattr(socket.node, "_current_visited", None) @@ -222,7 +238,7 @@ def _resolve_output( cache[key] = value return value finally: - if hasattr(socket.node, "_current_cache"): del socket.node._current_cache - if hasattr(socket.node, "_current_visited"): del socket.node._current_visited + socket.node._current_cache = None # type: ignore + socket.node._current_visited = None # type: ignore finally: visited.remove(node_key)