offset paths, execution highlighting only when manual exec

This commit is contained in:
Nicolas Predella 2026-05-22 15:43:34 +03:00
parent 64eff798ac
commit 7e642f4c32
5 changed files with 168 additions and 70 deletions

View file

@ -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'

View file

@ -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"

View file

@ -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"

View file

@ -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)

View file

@ -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)