nodeslicer/core/nodeslicer/nodes/path.py
2026-10-14 18:59:00 +03:00

997 lines
36 KiB
Python

import time
from typing import Any, cast, Literal
import numpy as np
from ..bpy_ns_types import NfEnumProperty, NfBoolProperty, NfFloatProperty, notify_tree_changed
from ....registry import register_class
from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner, SliceNodeListJoiner, SlicingGroup
from ...slice import align_path_seam_sharpest, is_closed_path
from ....infra.blender.list_utils import eval_element_wise, eval_element_wise_multivalue, prepare_indexed_inputs, is_list_data, is_input_a_list, set_socket_shape, is_socket_linked_to_list, flatten_list
@register_class
class SLICENODE_PathsToList(SliceNodeListJoiner):
bl_label = "Paths to List"
socket_type = "SLICESOCK_Paths"
socket_label = "Paths"
@register_class
class SLICENODE_FlattenPathList(SliceNode):
bl_label = "Flatten Path List"
always_list_inputs = ["Paths"]
mode: str = NfEnumProperty(
name="Mode",
items=[
('ZIP', "Zip", "Interleave paths layer by layer across all list items"),
('EXTEND', "Extend", "Append paths sequentially from all list items"),
],
default='ZIP',
update=notify_tree_changed,
)
def init(self, context):
self.add_input("SLICESOCK_Paths", "Paths", display_shape='LIST')
self.add_output("SLICESOCK_Paths", "Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "mode", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
val = self.resolve("Paths")
if val is None:
return SlicePaths()
if not is_list_data(val):
if isinstance(val, SlicePaths) or getattr(val, "is_slice_paths", False):
return val
return SlicePaths()
raw_items = flatten_list(val)
paths_list: list[SlicePaths] = [
item for item in raw_items
if isinstance(item, SlicePaths) or getattr(item, "is_slice_paths", False)
]
if not paths_list:
return SlicePaths()
mode = str(getattr(self, "mode", "ZIP")).upper()
if mode == "EXTEND":
return SlicePaths.extend_paths(paths_list)
else:
return SlicePaths.zip_paths(paths_list)
@register_class
class SLICENODE_GetPaths(SliceNode):
bl_label = "Get Paths"
list_socket_mapping = [("Object", "Paths")]
def init(self, context):
self.add_input("NodeSocketObject")
self.add_input("NodeSocketString", "Extrusion Multiplier Attribute").value = 'e_mult'
self.add_input("NodeSocketString", "Feedrate Attribute").value = 'f_rate'
self.add_input("SLICESOCK_Settings", "Extra Attributes")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
pass
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
from ....infra.blender.blender_mesh_capture import capture_mesh_edges
def _get_single_path(o: Any, e_name: Any, f_name: Any, extra: Any) -> SlicePaths:
if not o:
return SlicePaths()
so, e_rates, f_rates, extra_attrs_data = capture_mesh_edges(o, str(e_name or ""), str(f_name or ""), extra)
sp = SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data)
if hasattr(o, "transforms"):
sp.transforms = list(getattr(o, "transforms", []))
if hasattr(o, "name") and getattr(o, "name"):
name_val = str(getattr(o, "name"))
sp.name = name_val
sp.objects_info = {"objects": [{"name": name_val, "polygon": []}]}
return sp
return self.eval_multivalue(
_get_single_path,
main_socket="Object",
secondary_sockets=["Extrusion Multiplier Attribute", "Feedrate Attribute", "Extra Attributes"],
socket_name=socket_name,
default=SlicePaths(),
)
def _slice_single_mesh_item(coll: Any, layer_height: float, fl_height: float | None, rotation: Any, ignore_holes: bool) -> SlicePaths:
if not coll:
return SlicePaths()
# Handle SlicingGroup, SlicingCollection, SlicingObject
if isinstance(coll, SlicingGroup):
objects = []
for c in coll.collections.values():
objects.extend(c.objects)
elif isinstance(coll, SlicingCollection):
objects = list(coll.objects)
elif isinstance(coll, SlicingObject):
objects = [coll]
else:
objects = getattr(coll, "objects", [coll] if hasattr(coll, "mesh") else [])
merged_object = SlicingObject.merge(objects)
if merged_object.mesh is None or merged_object.mesh.size == 0:
return SlicePaths()
has_rot = bool(rotation and any(rotation))
if has_rot:
from mathutils import Euler
mat = np.array(Euler(rotation).to_matrix().inverted())
merged_object.mesh[:, :3, :] = merged_object.mesh[:, :3, :] @ mat.T
merged_object.mesh[:, 3:4, :] = merged_object.mesh[:, 3:4, :] @ mat.T
min_z = float(merged_object.mesh[:, :3, 2].min())
if abs(min_z) < 1e-6:
min_z = 0.0
max_z = float(merged_object.mesh[:, :3, 2].max())
h_first = fl_height if fl_height is not None else layer_height
min_z_um = int(round(min_z * 1000))
lh_um = int(round(layer_height * 1000))
flh_um = int(round(h_first * 1000))
max_z_um = int(round(max_z * 1000))
z_first_um = min_z_um + flh_um
z_rest_start_um = z_first_um + lh_um
if z_first_um <= max_z_um:
if z_rest_start_um <= max_z_um and lh_um > 0:
z_um_values = np.arange(z_rest_start_um, max_z_um + 1, lh_um, dtype=np.int64)
z_values = np.concatenate(([z_first_um], z_um_values)) / 1000.0
else:
z_values = np.array([z_first_um], dtype=np.float64) / 1000.0
else:
z_values = np.array([], dtype=np.float64)
return _finish_slice_paths(merged_object, coll, objects, z_values, rotation, ignore_holes, fl_height, layer_height)
def _finish_slice_paths(
merged_object: Any,
coll: Any,
objects: list[Any],
z_values: np.ndarray,
rotation: Any,
ignore_holes: bool,
fl_height: float | None,
layer_height: float,
) -> SlicePaths:
slices_group: list[list[np.ndarray]] | None = slice_at_range(
[merged_object.mesh],
z_values,
ignore_holes=ignore_holes
)
if not slices_group or not slices_group[0]:
return SlicePaths()
paths = slices_group[0]
z_to_group: dict[int, int] = {}
groups = []
for p in paths:
if len(p) > 0:
z_k = int(round(p[0, 2] * 1000))
if z_k not in z_to_group:
z_to_group[z_k] = len(z_to_group)
groups.append(z_to_group[z_k])
else:
groups.append(0)
slices = SlicePaths(paths, groups=groups)
slices.layer_height = layer_height
if fl_height is not None:
slices.first_layer_height = fl_height
slices.transforms = list(getattr(coll, "transforms", [])) or list(getattr(merged_object, "transforms", []))
names = []
if objects:
for o in objects:
n = getattr(o, "name", "")
if n:
if n.endswith("_merged"):
n = n[:-7]
if n and n not in names:
names.append(n)
if not names and hasattr(coll, "name") and getattr(coll, "name"):
names.append(str(getattr(coll, "name")))
if names:
slices.name = names[0]
slices.objects_info = {"objects": [{"name": n, "polygon": []} for n in names]}
if len(z_values) > 0 and fl_height is not None and layer_height > 0:
mult = fl_height / layer_height
z_first_um = int(round(z_values[0] * 1000))
for i, p in enumerate(slices):
if len(p) > 0 and int(round(p[0, 2] * 1000)) == z_first_um:
slices.extrusion_rate[i] = slices.extrusion_rate[i] * mult
else:
break
has_rot = bool(rotation and any(rotation))
if has_rot:
from mathutils import Euler
mat_inv = np.array(Euler(rotation).to_matrix())
slices = slices.transform(mat_inv)
slices = slices.align_seams(mode="sharpest")
return slices
def _slice_single_mesh_item_with_layers(coll: Any, layers: Any, rotation: Any, ignore_holes: bool) -> SlicePaths:
if not coll or layers is None:
return SlicePaths()
# Handle SlicingGroup, SlicingCollection, SlicingObject
if isinstance(coll, SlicingGroup):
objects = []
for c in coll.collections.values():
objects.extend(c.objects)
elif isinstance(coll, SlicingCollection):
objects = list(coll.objects)
elif isinstance(coll, SlicingObject):
objects = [coll]
else:
objects = getattr(coll, "objects", [coll] if hasattr(coll, "mesh") else [])
merged_object = SlicingObject.merge(objects)
if merged_object.mesh is None or merged_object.mesh.size == 0:
return SlicePaths()
has_rot = bool(rotation and any(rotation))
if has_rot:
from mathutils import Euler
mat = np.array(Euler(rotation).to_matrix().inverted())
merged_object.mesh[:, :3, :] = merged_object.mesh[:, :3, :] @ mat.T
merged_object.mesh[:, 3:4, :] = merged_object.mesh[:, 3:4, :] @ mat.T
min_z = float(merged_object.mesh[:, :3, 2].min())
if abs(min_z) < 1e-6:
min_z = 0.0
max_z = float(merged_object.mesh[:, :3, 2].max())
min_z_um = int(round(min_z * 1000))
max_z_um = int(round(max_z * 1000))
raw_layers = layers if isinstance(layers, (list, tuple, np.ndarray)) else [layers]
valid_z: list[float] = []
for z in raw_layers:
try:
zf = float(z)
z_um = int(round(zf * 1000))
if min_z_um <= z_um <= max_z_um:
valid_z.append(zf)
except (ValueError, TypeError):
continue
if not valid_z:
return SlicePaths()
z_values = np.array(valid_z, dtype=np.float64)
fl_height = float(z_values[0]) if len(z_values) > 0 else None
layer_height = float(z_values[1] - z_values[0]) if len(z_values) > 1 else (fl_height or 0.2)
return _finish_slice_paths(merged_object, coll, objects, z_values, rotation, ignore_holes, fl_height, layer_height)
@register_class
class SLICENODE_Layers(SliceNode):
bl_label = "Layers"
is_list_output = True
always_list_outputs = ["Layers"]
list_socket_mapping = [("Layer Height", "Layers")]
def init(self, context):
self.add_input("NodeSocketFloat", "First Layer Height").value = 0.2
self.add_input("NodeSocketFloat", "Layer Height").value = 0.2
self.add_input("NodeSocketFloat", "Total Height").value = 250.0
self.add_output("NodeSocketFloat", "Layers", display_shape='LIST')
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
def _calc_layers(lh_val: Any, flh_val: Any, tot_val: Any) -> list[float]:
flh = float(flh_val) if flh_val is not None and float(flh_val) > 0 else 0.2
lh = float(lh_val) if lh_val is not None and float(lh_val) > 0 else 0.2
tot = float(tot_val) if tot_val is not None and float(tot_val) > 0 else 250.0
flh_um = max(1, int(round(flh * 1000)))
lh_um = max(1, int(round(lh * 1000)))
tot_um = int(round(tot * 1000))
layers: list[float] = []
z_um = flh_um
while z_um <= tot_um:
layers.append(z_um / 1000.0)
z_um += lh_um
return layers
return self.eval_multivalue(
_calc_layers,
main_socket="Layer Height",
secondary_sockets=["First Layer Height", "Total Height"],
socket_name=socket_name,
default=[],
)
@register_class
class SLICENODE_SliceShells(SliceNode):
bl_label = "Slice Shells"
list_socket_mapping = [("Mesh", "Paths")]
ignore_holes: bool = NfBoolProperty(
name="Ignore Holes",
description="Ignore interior hole boundaries and generate only outer shells",
default=False
)
def init(self, context):
self.add_input("SLICESOCK_Mesh")
self.add_input("NodeSocketFloat", "Layer Height").value = 0.2
self.add_input("NodeSocketFloat", "First Layer Height").value = 0.2
self.add_input("NodeSocketRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
if hasattr(self, "ignore_holes"):
layout.prop(self, "ignore_holes")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
ignore_holes: bool = bool(getattr(self, "ignore_holes", False))
if hasattr(self, "inputs") and "Ignore Holes" in self.inputs:
try:
val = self.resolve("Ignore Holes")
if val is not None:
ignore_holes = bool(val)
except Exception:
pass
def _slice_one(item: Any, lh_val: Any, flh_val: Any, rot_val: Any) -> SlicePaths:
lh = float(lh_val) if lh_val is not None and float(lh_val) > 0 else 0.2
flh = float(flh_val) if flh_val is not None and float(flh_val) > 0 else None
return _slice_single_mesh_item(item, lh, flh, rot_val, ignore_holes)
return self.eval_multivalue(
_slice_one,
main_socket="Mesh",
secondary_sockets=["Layer Height", "First Layer Height", "Rotation"],
is_vector=[False, False, True],
socket_name=socket_name,
default=SlicePaths(),
)
@register_class
class SLICENODE_SliceShellsLayers(SliceNode):
bl_label = "Slice Shells (Layers)"
always_list_inputs = ["Layers"]
list_socket_mapping = [("Mesh", "Paths")]
ignore_holes: bool = NfBoolProperty(
name="Ignore Holes",
description="Ignore interior hole boundaries and generate only outer shells",
default=False
)
def init(self, context):
self.add_input("SLICESOCK_Mesh")
self.add_input("NodeSocketFloat", "Layers", display_shape='LIST')
self.add_input("NodeSocketRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
if hasattr(self, "ignore_holes"):
layout.prop(self, "ignore_holes")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
ignore_holes: bool = bool(getattr(self, "ignore_holes", False))
if hasattr(self, "inputs") and "Ignore Holes" in self.inputs:
try:
val = self.resolve("Ignore Holes")
if val is not None:
ignore_holes = bool(val)
except Exception:
pass
def _slice_one(item: Any, layers_val: Any, rot_val: Any) -> SlicePaths:
return _slice_single_mesh_item_with_layers(item, layers_val, rot_val, ignore_holes)
return self.eval_multivalue(
_slice_one,
main_socket="Mesh",
secondary_sockets=["Layers", "Rotation"],
is_vector=[False, True],
is_atomic=[True, False],
socket_name=socket_name,
default=SlicePaths(),
)
@register_class
class SLICENODE_BufferShells(SliceNode):
bl_label = "Buffer Shells"
list_socket_mapping = [("Paths", "Paths")]
shape_option: str = NfEnumProperty(
name="Miter",
items=[
('round', "Round", ""),
('square', "Square", ""),
('flat', "Flat", "")
]
)
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("NodeSocketFloat", "Distance").value = 0
self.add_input("NodeSocketRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "shape_option")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
def _buf(p: Any, dist: Any, rot: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.buffer(float(dist if dist is not None else 0.0), miter=self.shape_option, rotation=rot)
return self.eval_multivalue(
_buf,
main_socket="Paths",
secondary_sockets=["Distance", "Rotation"],
is_vector=[False, True],
socket_name=socket_name,
default=SlicePaths(),
)
@register_class
class SLICENODE_BufferCurve(SliceNode):
bl_label = "Buffer Curve"
list_socket_mapping = [("Paths", "Paths")]
cap_style: str = NfEnumProperty(
name="Cap Style",
items=[
('round', "Round", ""),
('flat', "Flat", ""),
('square', "Square", "")
],
default='round'
)
join_style: str = NfEnumProperty(
name="Join Style",
items=[
('round', "Round", ""),
('mitre', "Mitre", ""),
('bevel', "Bevel", "")
],
default='round'
)
single_sided: bool = NfBoolProperty(
name="Single Sided",
description="Buffer only on one side of the curve",
default=False
)
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("NodeSocketFloat", "Distance").value = 0.0
self.add_input("NodeSocketRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "cap_style")
layout.prop(self, "join_style")
layout.prop(self, "single_sided")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
def _buf(p: Any, dist: Any, rot: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.buffer_curve(
float(dist if dist is not None else 0.0),
cap_style=self.cap_style,
join_style=self.join_style,
single_sided=self.single_sided,
rotation=rot
)
return self.eval_multivalue(
_buf,
main_socket="Paths",
secondary_sockets=["Distance", "Rotation"],
is_vector=[False, True],
socket_name=socket_name,
default=SlicePaths(),
)
@register_class
class SLICENODE_OffsetCurve(SliceNode):
bl_label = "Offset Curve"
list_socket_mapping = [("Paths", "Paths")]
join_style: str = NfEnumProperty(
name="Join Style",
items=[
('round', "Round", ""),
('mitre', "Mitre", ""),
('bevel', "Bevel", "")
],
default='round'
)
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("NodeSocketFloat", "Distance").value = 0.0
self.add_input("NodeSocketRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "join_style")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
def _off(p: Any, dist: Any, rot: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.offset_curve(
float(dist if dist is not None else 0.0),
join_style=cast(Literal['round', 'mitre'], self.join_style),
rotation=rot
)
return self.eval_multivalue(
_off,
main_socket="Paths",
secondary_sockets=["Distance", "Rotation"],
is_vector=[False, True],
socket_name=socket_name,
default=SlicePaths(),
)
@register_class
class SLICENODE_PathOffset(SliceNode):
bl_label = "Path Offset"
list_socket_mapping = [("Paths", "Paths")]
def init(self, context):
self.add_input("SLICESOCK_Paths", "Paths")
self.add_input("NodeSocketVector", "Offset")
self.add_output("SLICESOCK_Paths", "Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
def _off(p: Any, off: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.offset(np.array(off if off is not None else (0.0, 0.0, 0.0), dtype=np.float64))
return self.eval_multivalue(
_off,
main_socket="Paths",
secondary_sockets=["Offset"],
is_vector=[True],
socket_name=socket_name,
default=SlicePaths(),
)
@register_class
class SLICENODE_RotatePath(SliceNode):
bl_label = "Rotate Path"
list_socket_mapping = [("Paths", "Paths")]
def init(self, context):
self.add_input("SLICESOCK_Paths", "Paths")
self.add_input("NodeSocketFloat", "Angle").value = 0.0
self.add_input("NodeSocketVector", "Center")
self.add_output("SLICESOCK_Paths", "Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
def _rot(p: Any, ang: Any, cent: Any) -> SlicePaths:
if not p: return SlicePaths()
center_vec = np.array(cent, dtype=np.float64) if cent and any(cent) else None
return p.rotate(float(ang if ang is not None else 0.0), center=center_vec)
return self.eval_multivalue(
_rot,
main_socket="Paths",
secondary_sockets=["Angle", "Center"],
is_vector=[False, True],
socket_name=socket_name,
default=SlicePaths(),
)
@register_class
class SLICENODE_BufferShellsMultiple(SliceNode):
bl_label = "Buffer Shells Multiple"
list_socket_mapping = [("Paths", ["Paths", "Outermost Path", "Innermost Path"])]
shape_option: str = NfEnumProperty(
name="Miter",
items=[
('round', "Round", ""),
('square', "Square", ""),
('flat', "Flat", "")
]
)
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("NodeSocketFloat", "Distance").value = 0
self.add_input("NodeSocketFloat", "Width Step").value = 0.4
self.add_input("NodeSocketInt", "Count").value = 2
self.add_input("NodeSocketRotation", "Rotation")
self.add_output("SLICESOCK_Paths", "Paths")
self.add_output("SLICESOCK_Paths", "Outermost Path")
self.add_output("SLICESOCK_Paths", "Innermost Path")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "shape_option")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
def _buf_multi(p: Any, b_dist: Any, w_step: Any, cnt: Any, rot: Any) -> SlicePaths:
c = int(cnt if cnt is not None else 1)
if not p or c <= 0:
return SlicePaths()
dist = float(b_dist if b_dist is not None else 0.0)
step = float(w_step if w_step is not None else 0.4)
if socket_name == "Outermost Path":
outer_dist = max(dist, dist + (c - 1) * step) if c > 1 else dist
return p.buffer(outer_dist, miter=self.shape_option, rotation=rot)
elif socket_name == "Innermost Path":
inner_dist = min(dist, dist + (c - 1) * step) if c > 1 else dist
return p.buffer(inner_dist, miter=self.shape_option, rotation=rot)
elif socket_name == "Paths":
return p.buffer_multiple(dist, c, step, miter=self.shape_option, rotation=rot)
return SlicePaths()
return self.eval_multivalue(
_buf_multi,
main_socket="Paths",
secondary_sockets=["Distance", "Width Step", "Count", "Rotation"],
is_vector=[False, False, False, True],
socket_name=socket_name,
default=SlicePaths(),
)
@register_class
class SLICENODE_Infill(SliceNode):
bl_label = "Infill"
list_socket_mapping = [("Paths", "Paths")]
infill_type: str = NfEnumProperty(
name="Type",
items=[
('GRID', "Grid", "Cross-hatch grid"),
('RECTILINEAR', "Rectilinear", "Alternating parallel lines"),
('LINES', "Lines", "Parallel lines in one direction"),
],
default='GRID',
update=notify_tree_changed
)
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("NodeSocketFloat", "Density").value = 2.0
self.add_input("NodeSocketFloat", "Infill Rotation").value = 0.0
self.add_input("NodeSocketRotation", "Layer Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "infill_type", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
def _inf(p: Any, dens: Any, inf_rot: Any, rot: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.infill(
float(dens if dens is not None else 2.0),
rotation_deg=float(inf_rot if inf_rot is not None else 0.0),
infill_type=self.infill_type,
layer_rotation=rot
)
return self.eval_multivalue(
_inf,
main_socket="Paths",
secondary_sockets=["Density", "Infill Rotation", "Layer Rotation"],
is_vector=[False, False, True],
socket_name=socket_name,
default=SlicePaths(),
)
@register_class
class SLICENODE_OverhangMask(SliceNode):
bl_label = "Overhang Mask"
list_socket_mapping = [("Paths", "Paths")]
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("NodeSocketFloat", "Tolerance").value = 0.05
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
pass
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
def _mask(p: Any, tol: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.get_overhang_mask(tolerance=float(tol if tol is not None else 0.05))
return self.eval_multivalue(
_mask,
main_socket="Paths",
secondary_sockets=["Tolerance"],
socket_name=socket_name,
default=SlicePaths(),
)
@register_class
class SLICENODE_PathOperations(SliceNodeJoiner):
bl_label = "Path Operations"
socket_type = "SLICESOCK_Paths"
operation: str = NfEnumProperty(
name="Operation",
items=[
('ZIP', "Zip paths", "Interleave paths from all inputs layer by layer"),
('EXTEND', "Extend paths", "Append paths sequentially"),
('MAX_COVERAGE', "Maximum coverage", "Union of all paths"),
('MIN_COVERAGE', "Minimum coverage", "Intersection of paths per layer (single paths preserved)"),
('INTERSECTION', "Intersection", "Intersection of all paths"),
('SUBTRACT', "Subtract", "Subtract subsequent paths from first input"),
('XOR', "Exclusive or", "Exclusive or of all paths"),
],
default='ZIP',
update=notify_tree_changed
)
connect_along_boundary: bool = NfBoolProperty(
name="Connect along boundary",
description="Connect clipped segments along the boundary of the clipping polygon",
default=False
)
acquire_transforms: bool = NfBoolProperty(
name="Acquire Transforms",
description="Apply transforms from primary path metadata to subsequent paths so they line up as in the viewport",
default=True,
update=notify_tree_changed
)
def update(self) -> None:
super().update()
if self.inputs:
first_inp = self.inputs[0]
is_list = is_socket_linked_to_list(first_inp)
set_socket_shape(first_inp, "LIST" if is_list else "CIRCLE")
for inp in self.inputs[1:]:
set_socket_shape(inp, "LIST" if is_socket_linked_to_list(inp) else "CIRCLE")
if self.outputs:
set_socket_shape(self.outputs[0], "LIST" if is_list else "CIRCLE")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "operation", text="")
if self.operation in ['MIN_COVERAGE', 'INTERSECTION', 'SUBTRACT']:
layout.prop(self, "connect_along_boundary")
layout.prop(self, "acquire_transforms")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
from ....infra.blender.blender_slice_nodes_generic import resolve_input
if not self.inputs:
return SlicePaths()
def _is_valid_paths_input(val: Any) -> bool:
if val is None:
return False
if isinstance(val, SlicePaths):
return len(val) > 0
if is_list_data(val):
flat = flatten_list(val)
return any(isinstance(x, SlicePaths) and len(x) > 0 for x in flat)
return False
first_val = resolve_input(self.inputs[0]) if getattr(self.inputs[0], "is_linked", False) else None
active_other_inputs: list[tuple[str, Any]] = []
for input_socket in self.inputs[1:]:
if getattr(input_socket, "is_linked", False):
val = resolve_input(input_socket)
if _is_valid_paths_input(val):
active_other_inputs.append((getattr(input_socket, "name", "Paths"), val))
acquire_t = bool(getattr(self, "acquire_transforms", True))
if not active_other_inputs:
def _single_op(p: Any) -> Any:
if isinstance(p, SlicePaths):
return p
return SlicePaths()
if first_val is None:
return SlicePaths()
return eval_element_wise(_single_op, first_val)
other_names = [item[0] for item in active_other_inputs]
other_vals = [item[1] for item in active_other_inputs]
is_list, p0_items, other_lists = prepare_indexed_inputs(
first_val,
*other_vals,
input_names=other_names
)
def _run_op_at_index(p0: Any, *others: Any) -> SlicePaths:
valid_others = [p for p in others if isinstance(p, SlicePaths) and len(p) > 0]
inputs_data = [p for p in [p0] + valid_others if isinstance(p, SlicePaths) and len(p) > 0]
if not inputs_data:
return SlicePaths()
return SlicePaths.path_operations(
inputs_data,
operation=self.operation,
connect_along_boundary=self.connect_along_boundary,
acquire_transforms=acquire_t
)
if is_list:
return [
_run_op_at_index(p0_items[i], *(other_lists[k][i] for k in range(len(other_lists))))
for i in range(len(p0_items))
]
return _run_op_at_index(p0_items[0], *(other_lists[k][0] for k in range(len(other_lists))))
@register_class
class SLICENODE_SimplifyPaths(SliceNode):
bl_label = "Simplify Paths"
list_socket_mapping = [("Paths", "Paths")]
preserve_topology: bool = NfBoolProperty(
name="Preserve Topology",
description="Prevents swapping of contained/containing geometry during simplification",
default=True
)
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("NodeSocketFloat", "Tolerance").value = 0.1
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "preserve_topology")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
def _simp(p: Any, tol: Any) -> SlicePaths:
t_val = float(tol if tol is not None else 0.0)
if not p or t_val <= 0:
return p if isinstance(p, SlicePaths) else SlicePaths()
return p.simplify(t_val, preserve_topology=self.preserve_topology)
return self.eval_multivalue(
_simp,
main_socket="Paths",
secondary_sockets=["Tolerance"],
socket_name=socket_name,
default=SlicePaths(),
)
@register_class
class SLICENODE_SeamPlacement(SliceNode):
bl_label = "Seam Placement"
list_socket_mapping = [("Paths", "Paths")]
mode: str = NfEnumProperty(
name="Mode",
items=[
('CLOSEST', "Closest to Seam", "Reposition seam to intersection nearest to existing seam"),
('FIRST', "First Intersection", "Reposition seam to first intersection along path traversal"),
('CENTROID', "Mesh Centroid", "Reposition seam to intersection nearest to mesh center"),
],
default='CLOSEST',
update=notify_tree_changed
)
acquire_transforms: bool = NfBoolProperty(
name="Acquire Transforms",
description="Apply transforms from paths metadata to the cutting mesh so they line up as in the viewport",
default=True,
update=notify_tree_changed
)
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Mesh")
self.add_input("NodeSocketFloat", "Seam Staggering").value = 0.0
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "mode", text="")
layout.prop(self, "acquire_transforms")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
acquire_t = bool(getattr(self, "acquire_transforms", True))
def _place(p: Any, m_in: Any, stg: Any) -> SlicePaths:
if not p:
return SlicePaths()
s_val = float(stg if stg is not None else 0.0)
if not m_in and abs(s_val) < 1e-6:
return p
return p.place_seams(m_in, mode=self.mode.lower(), acquire_transforms=acquire_t, stagger_mm=s_val)
return self.eval_multivalue(
_place,
main_socket="Paths",
secondary_sockets=["Mesh", "Seam Staggering"],
socket_name=socket_name,
default=SlicePaths(),
)
@register_class
class SLICENODE_AnchorPaths(SliceNode):
bl_label = "Anchor Paths"
list_socket_mapping = [("Paths", "Paths")]
reverse: bool = NfBoolProperty(
name="Reverse",
description="Follow anchor paths in the opposite direction",
default=False,
update=notify_tree_changed,
)
distance: float = NfFloatProperty(
name="Distance",
description="Distance to follow along anchor paths",
default=2.0,
min=0.0,
update=notify_tree_changed,
)
def init(self, context):
self.add_input("SLICESOCK_Paths", "Paths")
self.add_input("SLICESOCK_Paths", "Anchor Paths")
self.add_input("NodeSocketFloat", "Distance").value = 2.0
self.add_input("NodeSocketBool", "Reverse").value = False
self.add_output("SLICESOCK_Paths", "Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "reverse")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
rev_prop = bool(getattr(self, "reverse", False))
def _anchor_one(paths: Any, anchor_paths: Any, dist_val: Any, rev_val: Any) -> SlicePaths:
if not paths:
return SlicePaths()
if not isinstance(paths, SlicePaths) and not getattr(paths, "is_slice_paths", False):
return SlicePaths()
if not anchor_paths:
return paths
d = float(dist_val if dist_val is not None else getattr(self, "distance", 2.0))
is_rev = bool(rev_val) if rev_val is not None else rev_prop
if hasattr(self, "inputs"):
rev_sock = self.inputs.get("Reverse")
if rev_sock is not None and not getattr(rev_sock, "is_linked", False):
is_rev = bool(getattr(rev_sock, "value", rev_prop)) or rev_prop
return paths.anchor(anchor_paths, distance=d, reverse=is_rev)
return self.eval_multivalue(
_anchor_one,
main_socket="Paths",
secondary_sockets=["Anchor Paths", "Distance", "Reverse"],
socket_name=socket_name,
default=SlicePaths(),
)