expand lists compatibility

This commit is contained in:
Nicolas Predella 2026-10-14 13:17:23 +03:00
parent 81d93dd124
commit e540c85e59
14 changed files with 1366 additions and 271 deletions

View file

@ -113,17 +113,52 @@ class MathNode(SliceNode):
assert layout
layout.prop(self, "operation", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> math_type:
def update(self) -> None:
super().update()
from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape
if self.inputs:
a_list = is_socket_linked_to_list(self.inputs[0])
b_list = is_socket_linked_to_list(self.inputs[1]) if len(self.inputs) > 1 else False
set_socket_shape(self.inputs[0], "LIST" if a_list else "CIRCLE")
if len(self.inputs) > 1:
set_socket_shape(self.inputs[1], "LIST" if b_list else "CIRCLE")
if self.outputs:
set_socket_shape(self.outputs[0], "LIST" if (a_list or b_list) else "CIRCLE")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
t = self.math_type
a: t = resolve_input(self.inputs[0]) or 0
b: t = resolve_input(self.inputs[1]) or 0
inputs = getattr(self, "inputs", [])
if "resolve" in self.__dict__:
a_raw = self.resolve(inputs[0] if len(inputs) > 0 else 0)
b_raw = self.resolve(inputs[1] if len(inputs) > 1 else 1)
else:
a_raw = resolve_input(inputs[0]) if len(inputs) > 0 else None
b_raw = resolve_input(inputs[1]) if len(inputs) > 1 else None
op_fn = _MATH_OPS.get(self.operation)
if op_fn:
if not op_fn:
return 0
def _calc(val_a: Any, val_b: Any) -> Any:
try:
return op_fn(a, b, t)
a_num = t(val_a if val_a is not None else 0)
b_num = t(val_b if val_b is not None else 0)
return op_fn(a_num, b_num, t)
except Exception:
return 0
return 0
from ....infra.blender.list_utils import is_input_a_list, eval_element_wise_multivalue
a_is_list = is_input_a_list(a_raw)
b_is_list = is_input_a_list(b_raw)
if a_is_list or b_is_list:
if a_is_list:
return eval_element_wise_multivalue(_calc, a_raw, b_raw, input_names=["Value B"])
else:
return eval_element_wise_multivalue(lambda b, a: _calc(a, b), b_raw, a_raw, input_names=["Value A"])
return _calc(a_raw, b_raw)
class SliceNodeJoiner(SliceNode):
socket_type: str = ""

View file

@ -7,7 +7,7 @@ import os
from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfStringProperty
from ....registry import register_class
from .base import SliceNode, SliceIntrinsics, Pather, SliceNodeJoiner, SliceNodeListJoiner
from ....infra.blender.list_utils import eval_element_wise, flatten_list, is_list_data
from ....infra.blender.list_utils import eval_element_wise, eval_element_wise_multivalue, prepare_indexed_inputs, flatten_list, is_list_data
@register_class
class SLICENODE_GcodesToList(SliceNodeListJoiner):
@ -33,12 +33,12 @@ class SLICENODE_PathsToGcode(SliceNode):
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
paths: Any = self.resolve("Paths")
intrinsics: SliceIntrinsics | None = self.resolve("Intrinsics")
intrinsics: Any = self.resolve("Intrinsics")
if not paths:
return ""
def _paths_to_gcode(p: Any) -> str:
def _paths_to_gcode(p: Any, intr: Any) -> str:
if not p:
return ""
pather: Pather = Pather(p)
@ -47,19 +47,24 @@ class SLICENODE_PathsToGcode(SliceNode):
if hasattr(p, "first_layer_height"):
pather.first_layer_height = getattr(p, "first_layer_height")
if intrinsics:
pather.bed_center_x = intrinsics.bed_x / 2.0
pather.bed_center_y = intrinsics.bed_y / 2.0
pather.feedrate = intrinsics.feedrate
pather.travel_feedrate = intrinsics.travel_feedrate
pather.extrusion_rate = intrinsics.extrusion_rate
pather.retraction = intrinsics.retraction
pather.retraction_feedrate = getattr(intrinsics, "retraction_feedrate", 0.0)
pather.lift = intrinsics.lift
if intr:
pather.bed_center_x = intr.bed_x / 2.0
pather.bed_center_y = intr.bed_y / 2.0
pather.feedrate = intr.feedrate
pather.travel_feedrate = intr.travel_feedrate
pather.extrusion_rate = intr.extrusion_rate
pather.retraction = intr.retraction
pather.retraction_feedrate = getattr(intr, "retraction_feedrate", 0.0)
pather.lift = intr.lift
return pather.to_gcode(include_header=self.use_header)
return eval_element_wise(_paths_to_gcode, paths)
return eval_element_wise_multivalue(
_paths_to_gcode,
paths,
intrinsics,
input_names=["Intrinsics"]
)
@register_class
class SLICENODE_GcodeBlock(SliceNode):
@ -74,6 +79,7 @@ class SLICENODE_GcodeBlock(SliceNode):
@register_class
class SLICENODE_AssignMetadata(SliceNode):
bl_label = "Assign Metadata"
list_socket_mapping = [("Name", "Gcode")]
def init(self, context):
self.add_input("NodeSocketString", "Name")
@ -81,19 +87,29 @@ class SLICENODE_AssignMetadata(SliceNode):
self.add_input("NodeSocketString", "Printer Model")
self.add_output("NodeSocketString", "Gcode")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
name = self.resolve("Name")
filament = self.resolve("Filament Type")
printer = self.resolve("Printer Model")
def _meta_one(n: Any, f: Any, p: Any) -> str:
lines = []
if name:
lines.append(f'; objects_info = {{"objects": [{{"name": "{name}", "polygon": []}}]}}')
if filament:
lines.append(f'; filament_type = {filament}')
if printer:
lines.append(f'; printer_model = {printer}')
if n:
lines.append(f'; objects_info = {{"objects": [{{"name": "{n}", "polygon": []}}]}}')
if f:
lines.append(f'; filament_type = {f}')
if p:
lines.append(f'; printer_model = {p}')
return '\n'.join(lines)
return eval_element_wise_multivalue(
_meta_one,
name,
filament,
printer,
input_names=["Filament Type", "Printer Model"]
)
@register_class
class SLICENODE_GcodeRegexReplace(SliceNode):
bl_label = "Gcode Regex Replace"
@ -117,9 +133,11 @@ class SLICENODE_GcodeRegexReplace(SliceNode):
if not find:
return gcode
def _replace_one(gc: Any) -> str:
def _replace_one(gc: Any, f_pattern: Any, r_pattern: Any) -> str:
gc_str = str(gc or "")
if not find or not gc_str:
f_str = str(f_pattern or "")
r_str = str(r_pattern or "")
if not f_str or not gc_str:
return gc_str
try:
# Convert $1, $2, ${name} etc to Python backreferences \g<1>, \g<2>, \g<name>
@ -129,13 +147,19 @@ class SLICENODE_GcodeRegexReplace(SliceNode):
elif match.group(3): return '\\g<' + match.group(3) + '>'
return match.group(0)
rep = re.sub(r'(\$\$)|(?:\$\{([^}]+)\})|(?:\$([a-zA-Z0-9_]+))', subst, replace)
return re.sub(find, rep, gc_str)
rep = re.sub(r'(\$\$)|(?:\$\{([^}]+)\})|(?:\$([a-zA-Z0-9_]+))', subst, r_str)
return re.sub(f_str, rep, gc_str)
except Exception as e:
print(f"Regex error in node {self.name}: {e}")
return gc_str
return eval_element_wise(_replace_one, gcode)
return eval_element_wise_multivalue(
_replace_one,
gcode,
find,
replace,
input_names=["Find", "Replace"]
)
@register_class
class SLICENODE_GcodeHelper(SliceNode):

View file

@ -8,7 +8,7 @@ from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoine
from ....infra.blender.blender_mesh_capture import PdfProperties
from ....core.pdf_exporter.geometry import transform_slice_paths
from ....core.geometry import update_transform
from ....infra.blender.list_utils import eval_element_wise, flatten_list, is_list_data
from ....infra.blender.list_utils import eval_element_wise, eval_element_wise_multivalue, prepare_indexed_inputs, flatten_list, is_list_data, is_input_a_list
@register_class
class SLICENODE_GetMesh(SliceNode):
@ -130,30 +130,30 @@ class SLICENODE_SeparateMesh(SliceNode):
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
coll: Any = self.resolve("Mesh")
attr_name: str = self.resolve("Attribute")
value: float = self.resolve("Value")
attr_name: Any = self.resolve("Attribute")
value: Any = self.resolve("Value")
if not coll: return None
def _split_coll(sc: SlicingCollection) -> tuple[SlicingCollection, SlicingCollection]:
def _split_coll(sc: SlicingCollection, a_name: str, comp_val: float) -> tuple[SlicingCollection, SlicingCollection]:
res_selection = SlicingCollection([], sc.name + "_selection")
res_inverted = SlicingCollection([], sc.name + "_inverted")
for so in sc.objects:
if attr_name not in so.attributes:
if not a_name or a_name not in so.attributes:
res_inverted.objects.append(so)
continue
attr = so.attributes[attr_name]
attr = so.attributes[a_name]
vals = attr["values"]
mask = np.zeros(len(so.mesh), dtype=bool)
if self.comparison == 'EQUAL': mask = vals == value
elif self.comparison == 'NOT_EQUAL': mask = vals != value
elif self.comparison == 'GREATER_THAN': mask = vals > value
elif self.comparison == 'LESS_THAN': mask = vals < value
elif self.comparison == 'GREATER_EQUAL': mask = vals >= value
elif self.comparison == 'LESS_EQUAL': mask = vals <= value
if self.comparison == 'EQUAL': mask = vals == comp_val
elif self.comparison == 'NOT_EQUAL': mask = vals != comp_val
elif self.comparison == 'GREATER_THAN': mask = vals > comp_val
elif self.comparison == 'LESS_THAN': mask = vals < comp_val
elif self.comparison == 'GREATER_EQUAL': mask = vals >= comp_val
elif self.comparison == 'LESS_EQUAL': mask = vals <= comp_val
if np.all(mask):
res_selection.objects.append(so)
@ -191,11 +191,25 @@ class SLICENODE_SeparateMesh(SliceNode):
res_inverted.objects.append(so_inv)
return res_selection, res_inverted
if is_list_data(coll):
results = [_split_coll(c) for c in coll]
is_list, items, (attr_list, val_list) = prepare_indexed_inputs(
coll,
attr_name,
value,
input_names=["Attribute", "Value"]
)
if is_list:
results = [
_split_coll(c, str(a or ""), float(v if v is not None else 0.0))
for c, a, v in zip(items, attr_list, val_list)
]
return [r[0] for r in results] if socket_name == "Selection" else [r[1] for r in results]
res_selection, res_inverted = _split_coll(coll)
res_selection, res_inverted = _split_coll(
items[0],
str(attr_list[0] or ""),
float(val_list[0] if val_list[0] is not None else 0.0)
)
if socket_name == "Selection":
return res_selection
return res_inverted
@ -272,13 +286,13 @@ class SLICENODE_SeparateMeshByRange(SliceNode):
num_digits = max(2, len(str(total_parts)))
i = target_val - start
def _split_coll(sc: SlicingCollection) -> SlicingCollection:
def _split_coll(sc: SlicingCollection, a_name: str) -> SlicingCollection:
res = SlicingCollection([], f"{sc.name}_{target_val}")
for so in sc.objects:
if attr_name not in so.attributes:
if not a_name or a_name not in so.attributes:
continue
attr = so.attributes[attr_name]
attr = so.attributes[a_name]
vals = attr["values"]
# Attribute values might be floats or ints, we check for equality
@ -303,7 +317,7 @@ class SLICENODE_SeparateMeshByRange(SliceNode):
res.objects.append(so_new)
return res
return eval_element_wise(_split_coll, coll)
return eval_element_wise_multivalue(_split_coll, coll, attr_name, input_names=["Attribute"])
@register_class
class SLICENODE_ArrangeMesh(SliceNode):
@ -576,15 +590,72 @@ class SLICENODE_TransformGeometry(SliceNode):
if geom_data is None:
return None
translation = self.resolve("Translation") or (0.0, 0.0, 0.0)
rotation = self.resolve("Rotation") or (0.0, 0.0, 0.0)
scale = self.resolve("Scale") or (1.0, 1.0, 1.0)
translation = self.resolve("Translation")
if translation is None:
translation = (0.0, 0.0, 0.0)
rotation = self.resolve("Rotation")
if rotation is None:
rotation = (0.0, 0.0, 0.0)
scale = self.resolve("Scale")
if scale is None:
scale = (1.0, 1.0, 1.0)
t_arr = np.array(translation, dtype=np.float64)
r_arr = np.array(rotation, dtype=np.float64)
s_arr = np.array(scale, dtype=np.float64)
def _transform_one(g: Any, t: Any, r: Any, s: Any) -> Any:
t_arr = np.array(t, dtype=np.float64)
r_arr = np.array(r, dtype=np.float64)
s_arr = np.array(s, dtype=np.float64)
return _transform_geometry_item(g, t_arr, r_arr, s_arr)
return _transform_geometry_item(geom_data, t_arr, r_arr, s_arr)
return eval_element_wise_multivalue(
_transform_one,
geom_data,
translation,
rotation,
scale,
is_vector=[True, True, True],
input_names=["Translation", "Rotation", "Scale"]
)
@register_class
class SLICENODE_GetTransforms(SliceNode):
bl_label = "Get Transforms"
list_socket_mapping = [("Geometry", ["Translation", "Rotation", "Scale"])]
def init(self, context):
self.add_input("SLICESOCK_Geometry", "Geometry")
self.add_output("NodeSocketVector", "Translation")
self.add_output("NodeSocketRotation", "Rotation")
self.add_output("NodeSocketVector", "Scale")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
geom_data: Any = self.resolve("Geometry")
if geom_data is None:
if socket_name == "Scale":
return (1.0, 1.0, 1.0)
return (0.0, 0.0, 0.0)
from ....core.slice import extract_geometry_transforms
from ....infra.blender.list_utils import is_list_data
if is_list_data(geom_data):
results = [extract_geometry_transforms(g) for g in geom_data]
if socket_name == "Translation":
return [r[0] for r in results]
elif socket_name == "Rotation":
return [r[1] for r in results]
elif socket_name == "Scale":
return [r[2] for r in results]
return (0.0, 0.0, 0.0)
t, r, s = extract_geometry_transforms(geom_data)
if socket_name == "Translation":
return t
elif socket_name == "Rotation":
return r
elif socket_name == "Scale":
return s
return (0.0, 0.0, 0.0)
@register_class
@ -607,11 +678,11 @@ class SLICENODE_DropGeometry(SliceNode):
if z is None:
z = 0.0
def _drop_item(item: Any) -> tuple[Any, tuple[float, float, float]]:
def _drop_item(item: Any, target_z: float) -> tuple[Any, tuple[float, float, float]]:
if not item or getattr(item, "min_z", None) is None:
return item, (0.0, 0.0, 0.0)
min_z = round(item.min_z, 6)
offset_z = float(z - min_z)
offset_z = float(target_z - min_z)
if isinstance(item, SlicingGroup):
copied_data = _copy_slicing_group(item)
else:
@ -629,13 +700,19 @@ class SLICENODE_DropGeometry(SliceNode):
so.transforms = update_transform(getattr(so, "transforms", []), t_arr=drop_vec)
return copied_data, (0.0, 0.0, offset_z)
if is_list_data(mesh_data):
results = [_drop_item(m) for m in mesh_data]
is_list, items, (z_vals,) = prepare_indexed_inputs(
mesh_data,
z,
input_names=["Z"]
)
if is_list:
results = [_drop_item(m, float(z_i if z_i is not None else 0.0)) for m, z_i in zip(items, z_vals)]
if socket_name == "Translation":
return [r[1] for r in results]
return [r[0] for r in results]
dropped, trans = _drop_item(mesh_data)
dropped, trans = _drop_item(items[0], float(z_vals[0] if z_vals[0] is not None else 0.0))
if socket_name == "Translation":
return trans
return dropped
@ -719,12 +796,19 @@ class SLICENODE_RenameMesh(SliceNode):
if geom is None:
return None
name_socket: str | None = self.resolve("Name")
target_name = name_socket
if not target_name:
return geom
target_name = self.resolve("Name")
return _rename_slicing_item(geom, target_name)
def _rename_one(g: Any, name: Any) -> Any:
if not name:
return g
return _rename_slicing_item(g, str(name))
return eval_element_wise_multivalue(
_rename_one,
geom,
target_name,
input_names=["Name"]
)
def _assign_pdf_properties_to_item(geom: Any, line_width: float, line_color: tuple[float, float, float] | None, show_name: bool) -> Any:
@ -789,12 +873,49 @@ class SLICENODE_AssignPdfProperties(SliceNode):
return None
width_socket = self.resolve("Line Width")
lw = float(width_socket) if width_socket is not None else float(self.line_width)
lw = width_socket if width_socket is not None else float(self.line_width)
lc = (float(self.line_color[0]), float(self.line_color[1]), float(self.line_color[2]))
sn = bool(self.show_name)
return _assign_pdf_properties_to_item(geom, lw, lc, sn)
def _assign_one(g: Any, line_w: Any) -> Any:
w = float(line_w) if line_w is not None else float(self.line_width)
return _assign_pdf_properties_to_item(g, w, lc, sn)
return eval_element_wise_multivalue(
_assign_one,
geom,
lw,
input_names=["Line Width"]
)
def _parse_settings_props(raw_settings: Any) -> list[tuple[str, str]]:
props: list[tuple[str, str]] = []
if raw_settings:
if isinstance(raw_settings, dict):
for k, v in raw_settings.items():
props.append((str(k), str(v)))
elif isinstance(raw_settings, (list, tuple)):
for item in raw_settings:
if isinstance(item, tuple) and len(item) == 2:
props.append((str(item[0]), str(item[1])))
elif isinstance(item, dict):
for k, v in item.items():
props.append((str(k), str(v)))
else:
props.append(("", str(item)))
return props
def _is_single_settings_spec(val: Any) -> bool:
if isinstance(val, dict):
return True
if isinstance(val, (list, tuple)):
if len(val) > 0 and all(isinstance(x, tuple) and len(x) == 2 and not isinstance(x[0], (list, dict, tuple)) for x in val):
return True
if len(val) == 0:
return True
return False
return True
def _assign_properties_to_item(geom: Any, props: list[tuple[str, str]]) -> Any:
from ....core.pdf_exporter.metadata import resolve_object_properties
@ -843,21 +964,20 @@ class SLICENODE_AssignPropertyToObject(SliceNode):
return None
raw_settings = self.resolve("Settings")
props: list[tuple[str, str]] = []
if raw_settings:
if isinstance(raw_settings, dict):
for k, v in raw_settings.items():
props.append((str(k), str(v)))
elif isinstance(raw_settings, (list, tuple)):
for item in raw_settings:
if isinstance(item, tuple) and len(item) == 2:
props.append((str(item[0]), str(item[1])))
elif isinstance(item, dict):
for k, v in item.items():
props.append((str(k), str(v)))
if not _is_single_settings_spec(raw_settings):
settings_arg = list(raw_settings)
else:
props.append(("", str(item)))
settings_arg = raw_settings
return _assign_properties_to_item(geom, props)
def _assign_one(g: Any, s: Any) -> Any:
props = _parse_settings_props(s)
return _assign_properties_to_item(g, props)
return eval_element_wise_multivalue(
_assign_one,
geom,
settings_arg,
input_names=["Settings"]
)

View file

@ -52,12 +52,24 @@ class SLICENODE_GcodeToFile(ExecSliceNode):
total_parts: int = len(gcodes)
num_digits: int = max(2, len(str(total_parts)))
is_name_list = isinstance(base_name, (list, tuple))
if is_name_list:
if len(base_name) == 1:
base_name = str(base_name[0])
is_name_list = False
elif len(base_name) != total_parts:
msg = f"Input list length mismatch: Gcode has {total_parts} items, but Name has {len(base_name)} items."
if operator: operator.report({'ERROR'}, msg)
return 'error'
for i, gc in enumerate(gcodes):
if base_name:
if is_name_list:
name = str(base_name[i])
elif base_name:
if total_parts > 1:
name = f"{base_name}{i:0{num_digits}d}"
else:
name = base_name
name = str(base_name)
else:
name = get_gcode_name_from_metadata(gc, default=f'object_{i}')

View file

@ -6,7 +6,7 @@ from ..bpy_ns_types import NfEnumProperty, NfBoolProperty, NfFloatProperty, noti
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, is_list_data, set_socket_shape, is_socket_linked_to_list
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):
@ -37,10 +37,10 @@ class SLICENODE_GetPaths(SliceNode):
from ....infra.blender.blender_mesh_capture import capture_mesh_edges
def _get_single_path(o: Any) -> SlicePaths:
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, e_attr_name, f_attr_name, extra_attrs)
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", []))
@ -50,7 +50,14 @@ class SLICENODE_GetPaths(SliceNode):
sp.objects_info = {"objects": [{"name": name_val, "polygon": []}]}
return sp
return eval_element_wise(_get_single_path, obj)
return eval_element_wise_multivalue(
_get_single_path,
obj,
e_attr_name,
f_attr_name,
extra_attrs,
input_names=["Extrusion Multiplier Attribute", "Feedrate Attribute", "Extra Attributes"]
)
def _slice_single_mesh_item(coll: Any, layer_height: float, fl_height: float | None, rotation: Any, ignore_holes: bool) -> SlicePaths:
if not coll:
@ -216,10 +223,20 @@ class SLICENODE_SliceShells(SliceNode):
if not coll: return SlicePaths()
def _slice_one(item: Any) -> SlicePaths:
return _slice_single_mesh_item(item, layer_height, fl_height, rotation, ignore_holes)
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 eval_element_wise(_slice_one, coll)
return eval_element_wise_multivalue(
_slice_one,
coll,
layer_height,
fl_height,
rotation,
is_vector=[False, False, True],
input_names=["Layer Height", "First Layer Height", "Rotation"]
)
@register_class
class SLICENODE_BufferShells(SliceNode):
@ -255,11 +272,18 @@ class SLICENODE_BufferShells(SliceNode):
if not paths: return SlicePaths()
def _buf(p: Any) -> SlicePaths:
def _buf(p: Any, dist: Any, rot: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.buffer(distance, miter=self.shape_option, rotation=rotation)
return p.buffer(float(dist if dist is not None else 0.0), miter=self.shape_option, rotation=rot)
return eval_element_wise(_buf, paths)
return eval_element_wise_multivalue(
_buf,
paths,
distance,
rotation,
is_vector=[False, True],
input_names=["Distance", "Rotation"]
)
@register_class
class SLICENODE_BufferCurve(SliceNode):
@ -308,22 +332,29 @@ class SLICENODE_BufferCurve(SliceNode):
if socket_name != "Paths": return SlicePaths()
paths = self.resolve("Paths")
distance: float = self.resolve("Distance") or 0.0
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
distance = self.resolve("Distance")
rotation = self.resolve("Rotation")
if not paths: return SlicePaths()
def _buf(p: Any) -> SlicePaths:
def _buf(p: Any, dist: Any, rot: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.buffer_curve(
distance,
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=rotation
rotation=rot
)
return eval_element_wise(_buf, paths)
return eval_element_wise_multivalue(
_buf,
paths,
distance,
rotation,
is_vector=[False, True],
input_names=["Distance", "Rotation"]
)
@register_class
class SLICENODE_OffsetCurve(SliceNode):
@ -354,20 +385,27 @@ class SLICENODE_OffsetCurve(SliceNode):
if socket_name != "Paths": return SlicePaths()
paths = self.resolve("Paths")
distance: float = self.resolve("Distance") or 0.0
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
distance = self.resolve("Distance")
rotation = self.resolve("Rotation")
if not paths: return SlicePaths()
def _off(p: Any) -> SlicePaths:
def _off(p: Any, dist: Any, rot: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.offset_curve(
distance,
float(dist if dist is not None else 0.0),
join_style=cast(Literal['round', 'mitre'], self.join_style),
rotation=rotation
rotation=rot
)
return eval_element_wise(_off, paths)
return eval_element_wise_multivalue(
_off,
paths,
distance,
rotation,
is_vector=[False, True],
input_names=["Distance", "Rotation"]
)
@register_class
class SLICENODE_PathOffset(SliceNode):
@ -381,15 +419,21 @@ class SLICENODE_PathOffset(SliceNode):
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
paths = self.resolve("Paths")
offset: Any = self.resolve("Offset") or (0.0, 0.0, 0.0)
offset = self.resolve("Offset")
if not paths: return SlicePaths()
def _off(p: Any) -> SlicePaths:
def _off(p: Any, off: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.offset(np.array(offset, dtype=np.float64))
return p.offset(np.array(off if off is not None else (0.0, 0.0, 0.0), dtype=np.float64))
return eval_element_wise(_off, paths)
return eval_element_wise_multivalue(
_off,
paths,
offset,
is_vector=[True],
input_names=["Offset"]
)
@register_class
class SLICENODE_RotatePath(SliceNode):
@ -404,18 +448,24 @@ class SLICENODE_RotatePath(SliceNode):
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
paths = self.resolve("Paths")
angle: float = self.resolve("Angle") or 0.0
center: Any = self.resolve("Center")
angle = self.resolve("Angle")
center = self.resolve("Center")
if not paths: return SlicePaths()
center_vec = np.array(center, dtype=np.float64) if center and any(center) else None
def _rot(p: Any) -> SlicePaths:
def _rot(p: Any, ang: Any, cent: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.rotate(angle, center=center_vec)
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 eval_element_wise(_rot, paths)
return eval_element_wise_multivalue(
_rot,
paths,
angle,
center,
is_vector=[False, True],
input_names=["Angle", "Center"]
)
@register_class
class SLICENODE_BufferShellsMultiple(SliceNode):
@ -447,31 +497,40 @@ class SLICENODE_BufferShellsMultiple(SliceNode):
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
paths = self.resolve("Paths")
base_distance_val = self.resolve("Distance")
base_distance: float = float(base_distance_val) if base_distance_val is not None else 0.0
width_step_val = self.resolve("Width Step")
width_step: float = float(width_step_val) if width_step_val is not None else 0.0
count_val = self.resolve("Count")
count: int = int(count_val) if count_val is not None else 1
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
base_distance = self.resolve("Distance")
width_step = self.resolve("Width Step")
count = self.resolve("Count")
rotation = self.resolve("Rotation")
if not paths or count <= 0:
if not paths:
return SlicePaths()
def _buf_multi(p: Any) -> SlicePaths:
if not p or count <= 0:
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(base_distance, base_distance + (count - 1) * width_step) if count > 1 else base_distance
return p.buffer(outer_dist, miter=self.shape_option, rotation=rotation)
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(base_distance, base_distance + (count - 1) * width_step) if count > 1 else base_distance
return p.buffer(inner_dist, miter=self.shape_option, rotation=rotation)
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(base_distance, count, width_step, miter=self.shape_option, rotation=rotation)
return p.buffer_multiple(dist, c, step, miter=self.shape_option, rotation=rot)
return SlicePaths()
return eval_element_wise(_buf_multi, paths)
return eval_element_wise_multivalue(
_buf_multi,
paths,
base_distance,
width_step,
count,
rotation,
is_vector=[False, False, False, True],
input_names=["Distance", "Width Step", "Count", "Rotation"]
)
@register_class
class SLICENODE_Infill(SliceNode):
@ -504,22 +563,30 @@ class SLICENODE_Infill(SliceNode):
if socket_name != "Paths": return SlicePaths()
paths = self.resolve("Paths")
density: float = self.resolve("Density") or 2.0
infill_rotation: float = self.resolve("Infill Rotation") or 0.0
rotation: Any = self.resolve("Layer Rotation") or (0.0, 0.0, 0.0)
density = self.resolve("Density")
infill_rotation = self.resolve("Infill Rotation")
rotation = self.resolve("Layer Rotation")
if not paths: return SlicePaths()
def _inf(p: Any) -> SlicePaths:
def _inf(p: Any, dens: Any, inf_rot: Any, rot: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.infill(
density,
rotation_deg=infill_rotation,
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=rotation
layer_rotation=rot
)
return eval_element_wise(_inf, paths)
return eval_element_wise_multivalue(
_inf,
paths,
density,
infill_rotation,
rotation,
is_vector=[False, False, True],
input_names=["Density", "Infill Rotation", "Layer Rotation"]
)
@register_class
class SLICENODE_OverhangMask(SliceNode):
@ -538,15 +605,15 @@ class SLICENODE_OverhangMask(SliceNode):
if socket_name != "Paths": return SlicePaths()
paths = self.resolve("Paths")
tolerance: float = self.resolve("Tolerance") or 0.05
tolerance = self.resolve("Tolerance")
if not paths: return SlicePaths()
def _mask(p: Any) -> SlicePaths:
def _mask(p: Any, tol: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.get_overhang_mask(tolerance=tolerance)
return p.get_overhang_mask(tolerance=float(tol if tol is not None else 0.05))
return eval_element_wise(_mask, paths)
return eval_element_wise_multivalue(_mask, paths, tolerance, input_names=["Tolerance"])
@register_class
class SLICENODE_PathOperations(SliceNodeJoiner):
@ -574,12 +641,21 @@ class SLICENODE_PathOperations(SliceNodeJoiner):
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")
@ -588,6 +664,7 @@ class SLICENODE_PathOperations(SliceNodeJoiner):
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
@ -595,29 +672,63 @@ class SLICENODE_PathOperations(SliceNodeJoiner):
if not self.inputs:
return SlicePaths()
first_val = resolve_input(self.inputs[0]) if (self.inputs[0].is_linked or getattr(self.inputs[0], 'value', None)) else None
other_paths: list[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 isinstance(val, SlicePaths) and len(val) > 0:
other_paths.append(val)
if _is_valid_paths_input(val):
active_other_inputs.append((getattr(input_socket, "name", "Paths"), val))
def _run_op(primary_path: Any) -> SlicePaths:
inputs_data = [p for p in [primary_path] + other_paths if isinstance(p, SlicePaths) and len(p) > 0]
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
connect_along_boundary=self.connect_along_boundary,
acquire_transforms=acquire_t
)
if is_list_data(first_val):
return [_run_op(item) for item in first_val]
elif isinstance(first_val, SlicePaths) or other_paths:
return _run_op(first_val)
return SlicePaths()
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
@ -645,16 +756,17 @@ class SLICENODE_SimplifyPaths(SliceNode):
return SlicePaths()
paths = self.resolve("Paths")
tolerance: float = self.resolve("Tolerance") or 0.0
if not paths or tolerance <= 0:
return paths
tolerance = self.resolve("Tolerance")
if not paths:
return SlicePaths()
def _simp(p: Any) -> SlicePaths:
if not p or tolerance <= 0:
return p
return p.simplify(tolerance, preserve_topology=self.preserve_topology)
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 eval_element_wise(_simp, paths)
return eval_element_wise_multivalue(_simp, paths, tolerance, input_names=["Tolerance"])
@register_class
@ -705,13 +817,20 @@ class SLICENODE_SeamPlacement(SliceNode):
acquire_t = bool(getattr(self, "acquire_transforms", True))
def _place(p: Any) -> SlicePaths:
def _place(p: Any, m_in: Any, stg: Any) -> SlicePaths:
if not p:
return SlicePaths()
if not mesh_input and abs(stagger_mm) < 1e-6:
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(mesh_input, mode=self.mode.lower(), acquire_transforms=acquire_t, stagger_mm=stagger_mm)
return p.place_seams(m_in, mode=self.mode.lower(), acquire_transforms=acquire_t, stagger_mm=s_val)
return eval_element_wise(_place, paths)
return eval_element_wise_multivalue(
_place,
paths,
mesh_input,
stagger_mm,
input_names=["Mesh", "Seam Staggering"]
)

View file

@ -247,8 +247,20 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
total_parts = len(gcodes)
num_digits = max(2, len(str(total_parts)))
is_name_list = isinstance(base_mesh_name, (list, tuple))
if is_name_list:
if len(base_mesh_name) == 1:
base_mesh_name = str(base_mesh_name[0])
is_name_list = False
elif len(base_mesh_name) != total_parts:
msg = f"Input list length mismatch: Gcode has {total_parts} items, but Name has {len(base_mesh_name)} items."
if operator: operator.report({'ERROR'}, msg)
return 'error'
for i, gcode in enumerate(gcodes):
if total_parts > 1:
if is_name_list:
mesh_name = str(base_mesh_name[i])
elif total_parts > 1:
mesh_name = f"{base_mesh_name}{i:0{num_digits}d}"
else:
mesh_name = base_mesh_name

View file

@ -442,14 +442,6 @@ class SLICETREE_NodeTree(NodeTree):
# Start Print and Printer List reject lists
if to_node.bl_idname in ("SLICENODE_StartPrint", "SLICENODE_PrinterList"):
return False
# Path Operations: only input 0 can accept a list; secondary inputs reject lists
if to_node.bl_idname == "SLICENODE_PathOperations":
if getattr(to_node, "inputs", None) and in_s != to_node.inputs[0]:
return False
# Seam Placement: secondary input (Mesh) rejects lists
if to_node.bl_idname == "SLICENODE_SeamPlacement":
if getattr(in_s, "name", "") == "Mesh":
return False
# Group node: check if input can accept list
if to_node.bl_idname == "SLICENODE_Group":
tree = getattr(to_node, "slicer_tree", None) or getattr(to_node, "node_tree", None)
@ -837,6 +829,7 @@ class SLICENODE_MT_Geometry(Menu):
layout.operator("node.add_node", text="Get Mesh").type = "SLICENODE_GetMesh"
layout.operator("node.add_node", text="Rename Mesh").type = "SLICENODE_RenameMesh"
layout.operator("node.add_node", text="Transform Geometry").type = "SLICENODE_TransformGeometry"
layout.operator("node.add_node", text="Get Transforms").type = "SLICENODE_GetTransforms"
layout.operator("node.add_node", text="Drop Geometry").type = "SLICENODE_DropGeometry"
layout.operator("node.add_node", text="Separate Mesh").type = "SLICENODE_SeparateMesh"
layout.operator("node.add_node", text="Separate Mesh by Range").type = "SLICENODE_SeparateMeshByRange"

View file

@ -423,6 +423,57 @@ def extract_mesh_transforms(mesh_input: Any) -> list[dict[str, Any]]:
return []
def extract_geometry_transforms(
geom: Any
) -> tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]]:
"""
Extracts consolidated (translation, rotation, scale) from a geometry item
(SlicePaths, SlicingObject, SlicingCollection, SlicingGroup, or Blender Object).
Rotation is returned as Euler angles in radians (rx, ry, rz).
"""
if geom is None:
return (0.0, 0.0, 0.0), (0.0, 0.0, 0.0), (1.0, 1.0, 1.0)
transforms = extract_mesh_transforms(geom)
if not transforms and hasattr(geom, "transforms") and isinstance(geom.transforms, list):
transforms = [dict(t) for t in geom.transforms if isinstance(t, dict)]
if not transforms and hasattr(geom, "location") and hasattr(geom, "rotation_euler") and hasattr(geom, "scale"):
t = tuple(float(v) for v in geom.location)
r = tuple(float(v) for v in geom.rotation_euler)
s = tuple(float(v) for v in geom.scale)
return (t[0], t[1], t[2]), (r[0], r[1], r[2]), (s[0], s[1], s[2])
cur_t = [0.0, 0.0, 0.0]
cur_r = [0.0, 0.0, 0.0]
cur_s = [1.0, 1.0, 1.0]
if transforms:
for t in transforms:
if not isinstance(t, dict):
continue
tr = t.get("translation", t.get("offset", (0.0, 0.0, 0.0)))
rot = t.get("rotation", (0.0, 0.0, 0.0))
sc = t.get("scale", (1.0, 1.0, 1.0))
cur_t[0] += float(tr[0])
cur_t[1] += float(tr[1])
cur_t[2] += float(tr[2])
cur_r[0] += float(rot[0])
cur_r[1] += float(rot[1])
cur_r[2] += float(rot[2])
cur_s[0] *= float(sc[0])
cur_s[1] *= float(sc[1])
cur_s[2] *= float(sc[2])
if "angle_deg" in t and not any(float(v) != 0.0 for v in rot):
cur_r[2] += math.radians(float(t["angle_deg"]))
return (
(float(cur_t[0]), float(cur_t[1]), float(cur_t[2])),
(float(cur_r[0]), float(cur_r[1]), float(cur_r[2])),
(float(cur_s[0]), float(cur_s[1]), float(cur_s[2])),
)
def compute_delta_transforms(
path_transforms: list[dict[str, Any]] | None,
mesh_transforms: list[dict[str, Any]] | None
@ -581,6 +632,103 @@ def apply_transforms_to_triangles(
return out_tris
def apply_transforms_to_slice_paths(
paths_obj: Any,
transforms: list[dict[str, Any]] | None
) -> Any:
"""
Applies a list of transform dicts (translation, rotation, scale, angle_deg)
to a SlicePaths object, transforming all coordinates on a copy.
"""
if paths_obj is None or len(paths_obj) == 0 or not transforms:
return paths_obj
new_paths: list[np.ndarray] = []
for p in paths_obj:
if len(p) == 0:
new_paths.append(p.copy())
continue
new_p = p.astype(np.float64, copy=True)
dim = new_p.shape[1] if new_p.ndim > 1 else 0
if dim == 0:
new_paths.append(new_p)
continue
for t in transforms:
if not isinstance(t, dict):
continue
# 1. Scale
if "scale" in t:
sc = t["scale"]
if any(float(v) != 1.0 for v in sc):
s_arr = np.array(sc, dtype=np.float64)
if dim >= 3:
new_p[:, :3] *= s_arr[:3]
else:
new_p[:, :dim] *= s_arr[:dim]
# 2. Rotation
if "angle_deg" in t:
ang_deg = float(t["angle_deg"])
if ang_deg != 0.0:
rad = math.radians(ang_deg)
cos_a = math.cos(rad)
sin_a = math.sin(rad)
center = t.get("center", (0.0, 0.0, 0.0))
c_arr = np.array(center, dtype=np.float64)[:2]
pts_2d = new_p[:, :2] - c_arr
rot_mat = np.array([[cos_a, -sin_a], [sin_a, cos_a]], dtype=np.float64)
new_p[:, :2] = (pts_2d @ rot_mat.T) + c_arr
elif "rotation" in t:
rot = t["rotation"]
if any(float(v) != 0.0 for v in rot):
from .geometry import euler_rotation_matrix
R = euler_rotation_matrix(float(rot[0]), float(rot[1]), float(rot[2]))
center = t.get("center", None)
if center is not None:
c_arr = np.array(center, dtype=np.float64)
if dim >= 3:
new_p[:, :3] = (new_p[:, :3] - c_arr[:3]) @ R.T + c_arr[:3]
elif dim == 2:
new_p[:, :2] = (new_p[:, :2] - c_arr[:2]) @ R[:2, :2].T + c_arr[:2]
else:
if dim >= 3:
new_p[:, :3] = new_p[:, :3] @ R.T
elif dim == 2:
new_p[:, :2] = new_p[:, :2] @ R[:2, :2].T
# 3. Translation / Offset
tr = t.get("translation", t.get("offset", None))
if tr is not None and any(float(v) != 0.0 for v in tr):
t_arr = np.array(tr, dtype=np.float64)
if dim >= 3:
new_p[:, :3] += t_arr[:3]
else:
new_p[:, :dim] += t_arr[:dim]
new_paths.append(new_p)
out_sp = paths_obj.copy_with(new_paths)
res_transforms = [dict(t) for t in getattr(paths_obj, "transforms", [])]
for dt in transforms:
if isinstance(dt, dict):
res_transforms = update_transform(
res_transforms,
t_arr=dt.get("translation", dt.get("offset")),
r_arr=dt.get("rotation"),
s_arr=dt.get("scale")
)
if "angle_deg" in dt and res_transforms:
res_transforms[0]["angle_deg"] = float(dt["angle_deg"])
if "center" in dt:
res_transforms[0]["center"] = list(dt["center"])
out_sp.transforms = res_transforms
return out_sp
def _intersect_segments_2d_py(a: tuple[float, float], b: tuple[float, float], c: tuple[float, float], d: tuple[float, float]) -> float | None:
denom = (b[0] - a[0]) * (d[1] - c[1]) - (b[1] - a[1]) * (d[0] - c[0])
if abs(denom) < 1e-12:
@ -1430,9 +1578,9 @@ class SlicePaths(list[np.ndarray]):
return boolean_slice(self, other, operation, connect_along_boundary=connect_along_boundary)
@classmethod
def path_operations(cls, paths_list: list[SlicePaths], operation: str = 'ZIP', connect_along_boundary: bool = False) -> SlicePaths:
def path_operations(cls, paths_list: list[SlicePaths], operation: str = 'ZIP', connect_along_boundary: bool = False, acquire_transforms: bool = False) -> SlicePaths:
"""Executes Path Operations across multiple SlicePaths inputs."""
return path_operations_slice(paths_list, operation=operation, connect_along_boundary=connect_along_boundary)
return path_operations_slice(paths_list, operation=operation, connect_along_boundary=connect_along_boundary, acquire_transforms=acquire_transforms)
@classmethod
def minimum_coverage(cls, paths_list: list[SlicePaths], connect_along_boundary: bool = False) -> SlicePaths:
@ -2657,12 +2805,27 @@ def xor_slice(
def path_operations_slice(
paths_list: list[SlicePaths],
operation: str = 'ZIP',
connect_along_boundary: bool = False
connect_along_boundary: bool = False,
acquire_transforms: bool = False
) -> SlicePaths:
"""Dispatches Path Operations across multiple SlicePaths inputs."""
if not paths_list:
return SlicePaths()
if acquire_transforms and len(paths_list) > 1:
primary = paths_list[0]
primary_transforms = getattr(primary, "transforms", None)
if primary_transforms:
aligned_list = [primary]
for sec in paths_list[1:]:
sec_transforms = getattr(sec, "transforms", None)
delta = compute_delta_transforms(primary_transforms, sec_transforms)
if delta:
aligned_list.append(apply_transforms_to_slice_paths(sec, delta))
else:
aligned_list.append(sec)
paths_list = aligned_list
op = str(operation).upper()
if op in ('ZIP', 'ZIP_PATHS'):
return SlicePaths.zip_paths(paths_list)

View file

@ -37,7 +37,9 @@ This file provides a mapping of modules to their primary responsibilities and ke
- `buffer_multiple_slice()`: High-performance single-pass multi-shell concentric offset utility for slicing layers.
- `infill_slice()`: Generates rotated grid or rectilinear infill patterns, ensuring layer-to-layer consistency.
- `boolean_slice()`: Performs 2D boolean operations (Union, Intersection, Subtract, XOR) on slice sets with hole support.
- `path_operations_slice()`: Unified dispatcher for multi-input path operations (`ZIP`, `EXTEND`, `MAX_COVERAGE`, `MIN_COVERAGE`, `SUBTRACT`, `XOR`).
- `path_operations_slice()`: Unified dispatcher for multi-input path operations (`ZIP`, `EXTEND`, `MAX_COVERAGE`, `MIN_COVERAGE`, `SUBTRACT`, `XOR`). Supports `acquire_transforms` to align secondary paths with primary transforms.
- `apply_transforms_to_slice_paths()`: Applies a list of transformation dicts (scale, rotation, translation) to a `SlicePaths` object.
- `extract_geometry_transforms()`: Consolidates transformation metadata from `SlicePaths`, `SlicingObject`, `SlicingCollection`, `SlicingGroup`, or Blender objects into translation, rotation (radians), and scale tuples.
- `minimum_coverage_slice()`: Computes layer-by-layer intersection across multiple inputs while preserving single-path/input layers.
- `maximum_coverage_slice()`: Computes layer-by-layer union across multiple inputs.
@ -75,6 +77,10 @@ This file provides a mapping of modules to their primary responsibilities and ke
- `list_utils.py`:
- `is_list_data()`: Type discrimination distinguishing list/tuple collections from atomic objects (such as `SlicePaths`).
- `is_vector_value()`: Distinguishes atomic 3D vectors/rotations from lists of vectors.
- `is_input_a_list()`: Determines if a raw input is a list, accounting for vector socket types.
- `flatten_list()`: Recursive flattener for multi-input joiners and batch sinks.
- `eval_element_wise()`: Vectorized applicator executing operations element-wise on lists or directly on single items.
- `prepare_indexed_inputs()`: Normalizes and matches secondary inputs index-per-index against the primary input, broadcasting single values or validating that list lengths match exactly (raising `ValueError` on mismatch).
- `eval_element_wise_multivalue()`: General element-wise operator executing multi-argument operations with index matching and broadcasting.
- `set_socket_shape()` / `is_socket_linked_to_list()` / `is_output_socket_a_list()` / `sync_node_list_sockets()`: Dynamic socket visual appearance management (`LIST` vs `CIRCLE`).

View file

@ -39,6 +39,13 @@ A standard Python string containing formatted G-code.
- **Extrusion**: Calculated as absolute `E` values based on Euclidean distance and a configurable `EXTRUSION_RATE`.
- **Metadata**: Includes `;OFFSET_X=` and `;OFFSET_Y=` comments to allow reconstruction of original coordinates.
### List Processing Semantics
NodeSlicer supports generalized list processing across geometry, path, G-code, and math nodes:
- **Main Input & Secondary Inputs**: Nodes define a "main" primary input (e.g., `Mesh`, `Geometry`, `Paths`, `Gcode`). When the main input receives a list, the node evaluates element-wise and outputs a corresponding list.
- **Index-per-index Matching**: Secondary input sockets (such as translation vectors, rotations, scale vectors, drop Z coordinates, cutting meshes, rename templates, and parameters) also accept lists. When supplied with a list, secondary inputs are matched index-per-index with the main input list.
- **Broadcasting**: If a secondary input receives a single value (or a list of length 1), that value is automatically broadcast across all elements of the main input list.
- **Length Constraint**: It is mandatory that secondary input lists have either the exact same length as the main input list, or length 1. If lengths mismatch ($M \ne N$ and $M \ne 1$), or if a single main input receives a multi-item secondary list, a `ValueError` is raised with a descriptive error message.
## Nodes
All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** category.
@ -64,6 +71,11 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Translation**: 3D vector specifying the translation offset.
- **Rotation**: 3D vector/Euler rotation values.
- **Scale**: 3D vector specifying scaling factors along the X, Y, and Z axes (defaults to `1.0, 1.0, 1.0`).
- **Get Transforms**: Extracts the accumulated translation, rotation (Euler radians), and scale from input geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`).
- **Geometry**: Generic geometry input socket (accepts `SLICESOCK_Mesh`, `SLICESOCK_Paths`, or `SLICESOCK_Geometry`).
- **Translation**: 3D vector output socket containing translation offset `(X, Y, Z)`.
- **Rotation**: Euler rotation output socket containing rotation `(rx, ry, rz)` in radians.
- **Scale**: 3D vector output socket containing scale factors `(sx, sy, sz)`.
- **Drop Geometry**: Moves a `SlicingCollection` or `SlicingGroup` along Z so that its minimum Z sits at the specified value. Outputs both the dropped geometry and the translation vector.
- **Mesh**: Input mesh data.
- **Z**: Target minimum Z coordinate (defaults to `0.0`).
@ -124,6 +136,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Subtract**: Subtracts subsequent inputs from the first input.
- **Exclusive or**: Computes symmetric difference across all inputs.
- **Connect along boundary**: Boolean option to connect clipped open segments along the boundary of the clipping polygon.
- **Acquire Transforms**: Checkbox to automatically apply upstream transformation metadata from the primary input (`SlicePaths`) to subsequent input paths so that they align as in the viewport.
- **Math**: Float math operations.
- **Integer Math**: Simple integer operations (Add, Multiply, etc.).

View file

@ -7,31 +7,32 @@ Mesh,Existing Joiners,Join Mesh Parts,SLICENODE_JoinMesh,core/nodeslicer/nodes/m
Mesh,Existing Joiners,Join Mesh Groups,SLICENODE_JoinMeshGroups,core/nodeslicer/nodes/mesh.py,Data Joiner (Flattener),Multi-input SLICESOCK_Mesh (Mesh),None,SLICESOCK_Mesh (Mesh),Dynamic (Displays LIST if linked to list),Standard (Group),Bundles multiple SlicingCollections into a SlicingGroup,Flattens incoming lists of collections into a single SlicingGroup.
Gcode,Existing Joiners,Join Gcode,SLICENODE_JoinGcode,core/nodeslicer/nodes/gcode.py,Data Joiner (Flattener),Multi-input NodeSocketString (Gcode),None,NodeSocketString (Gcode),Dynamic (Displays LIST if linked to list),Standard (CIRCLE),Concatenates multiple G-code strings with newline '\n',"Flattens incoming G-code lists and strings, concatenating all sequentially into a single G-code string (CIRCLE)."
Object,Geometry & Mesh,Get Mesh,SLICENODE_GetMesh,core/nodeslicer/nodes/mesh.py,Element-wise Operator,NodeSocketObject (Object),include_children (prop),SLICESOCK_Mesh (Mesh),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Converts single Object into SlicingCollection,"First input is list [Obj1, Obj2, ...]. Converts each element into SlicingCollection. Output is [Mesh1, Mesh2, ...]. Both sockets switch to LIST."
Object,Path Processing,Get Paths,SLICENODE_GetPaths,core/nodeslicer/nodes/path.py,Element-wise Operator,NodeSocketObject (Object),Extrusion/Feedrate/Extra Attributes (cannot be list),SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Extracts mesh edges as SlicePaths,"First input is list [Obj1, Obj2, ...]. Extracts toolpaths for each object using the same attribute settings. Output is [Paths1, Paths2, ...]. Both sockets switch to LIST."
Mesh,Geometry & Mesh,Rename Mesh,SLICENODE_RenameMesh,core/nodeslicer/nodes/mesh.py,Element-wise Operator,SLICESOCK_Mesh (Geometry),NodeSocketString Name (cannot be list),SLICESOCK_Mesh (Geometry),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Renames geometry using name template,"First input is list [Mesh1, Mesh2, ...]. Renames each mesh using the same name template. Output is [Renamed1, Renamed2, ...]. Sockets switch to LIST."
Mesh,Geometry & Mesh,Transform Geometry,SLICENODE_TransformGeometry,core/nodeslicer/nodes/mesh.py,Element-wise Operator,SLICESOCK_Geometry (Geometry),"Translation, Rotation, Scale (cannot be list)",SLICESOCK_Geometry (Geometry),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),"Transforms single Mesh or Paths by T, R, S","First input is list [G1, G2, ...]. Transforms each item using the same T, R, S values. Output is [T(G1), T(G2), ...]. Sockets switch to LIST."
Mesh,Geometry & Mesh,Drop Geometry,SLICENODE_DropGeometry,core/nodeslicer/nodes/mesh.py,Element-wise Operator,SLICESOCK_Mesh (Mesh),NodeSocketFloat Z (cannot be list),"SLICESOCK_Mesh (Mesh), NodeSocketVector (Translation)",Dynamic (Changes to LIST if input is list),Dynamic (Both outputs change to LIST if input is list),"Translates mesh to target minimum Z; outputs dropped mesh and (0,0,dz)","First input is list [Mesh1, Mesh2, ...]. Drops each mesh independently to the same target Z. Outputs are [Dropped1, Dropped2, ...] and [T1, T2, ...]. Sockets switch to LIST."
Mesh,Geometry & Mesh,Separate Mesh,SLICENODE_SeparateMesh,core/nodeslicer/nodes/mesh.py,Element-wise Operator,SLICESOCK_Mesh (Mesh),"Attribute (String), Value (Float) (cannot be list)","Selection (Mesh), Inverted (Mesh)",Dynamic (Changes to LIST if input is list),Dynamic (Both outputs change to LIST if input is list),Splits collection into Selection and Inverted based on attribute condition,"First input is list [Mesh1, Mesh2, ...]. Applies separation to each mesh using same attribute/value. Outputs are Selection: [Sel1, Sel2, ...] and Inverted: [Inv1, Inv2, ...]. Sockets switch to LIST."
Mesh,Geometry & Mesh,Separate Mesh by Range,SLICENODE_SeparateMeshByRange,core/nodeslicer/nodes/mesh.py,Element-wise Demux,SLICESOCK_Mesh (Mesh),"Attribute, Start, End (cannot be list)","Dynamic numeric sockets '0', '1', ...",Dynamic (Changes to LIST if input is list),Dynamic (All outputs change to LIST if input is list),Splits single mesh into multiple output sockets across integer range Start..End,"First input is list [Mesh1, Mesh2, ...]. For each range output socket, delivers list of matching parts across all meshes: e.g. socket '0' gets [Mesh1_0, Mesh2_0, ...]. Outputs switch to LIST."
Object,Path Processing,Get Paths,SLICENODE_GetPaths,core/nodeslicer/nodes/path.py,Element-wise Operator,NodeSocketObject (Object),"Extrusion/Feedrate/Extra Attributes (single or matching list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Extracts mesh edges as SlicePaths,"First input is list [Obj1, Obj2, ...]. Secondary inputs are matched index-per-index or broadcast if single value. Output is [Paths1, Paths2, ...]. Both sockets switch to LIST."
Mesh,Geometry & Mesh,Rename Mesh,SLICENODE_RenameMesh,core/nodeslicer/nodes/mesh.py,Element-wise Operator,SLICESOCK_Mesh (Geometry),NodeSocketString Name (single or matching list),SLICESOCK_Mesh (Geometry),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Renames geometry using name template,"First input is list [Mesh1, Mesh2, ...]. Names are matched index-per-index or broadcast if single value. Output is [Renamed1, Renamed2, ...]. Sockets switch to LIST."
Mesh,Geometry & Mesh,Transform Geometry,SLICENODE_TransformGeometry,core/nodeslicer/nodes/mesh.py,Element-wise Operator,SLICESOCK_Geometry (Geometry),"Translation, Rotation, Scale (single or matching list)",SLICESOCK_Geometry (Geometry),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),"Transforms single Mesh or Paths by T, R, S","First input is list [G1, G2, ...]. Transforms each item using matching T, R, S values index-per-index (or broadcast if single value). Output is [T(G1), T(G2), ...]. Sockets switch to LIST."
Mesh,Geometry & Mesh,Get Transforms,SLICENODE_GetTransforms,core/nodeslicer/nodes/mesh.py,Element-wise Operator,SLICESOCK_Geometry (Geometry),None,"NodeSocketVector (Translation), NodeSocketRotation (Rotation), NodeSocketVector (Scale)",Dynamic (Changes to LIST if input is list),Dynamic (All outputs change to LIST if input is list),"Extracts consolidated Translation, Rotation, and Scale from geometry","Input is list [G1, G2, ...]. Extracts T, R, S for each item. Outputs are [T1, T2, ...], [R1, R2, ...], and [S1, S2, ...]. Sockets switch to LIST."
Mesh,Geometry & Mesh,Drop Geometry,SLICENODE_DropGeometry,core/nodeslicer/nodes/mesh.py,Element-wise Operator,SLICESOCK_Mesh (Mesh),NodeSocketFloat Z (single or matching list),"SLICESOCK_Mesh (Mesh), NodeSocketVector (Translation)",Dynamic (Changes to LIST if input is list),Dynamic (Both outputs change to LIST if input is list),"Translates mesh to target minimum Z; outputs dropped mesh and (0,0,dz)","First input is list [Mesh1, Mesh2, ...]. Target Z values are matched index-per-index (or broadcast if single value). Outputs are [Dropped1, Dropped2, ...] and [T1, T2, ...]. Sockets switch to LIST."
Mesh,Geometry & Mesh,Separate Mesh,SLICENODE_SeparateMesh,core/nodeslicer/nodes/mesh.py,Element-wise Operator,SLICESOCK_Mesh (Mesh),"Attribute (String), Value (Float) (single or matching list)","Selection (Mesh), Inverted (Mesh)",Dynamic (Changes to LIST if input is list),Dynamic (Both outputs change to LIST if input is list),Splits collection into Selection and Inverted based on attribute condition,"First input is list [Mesh1, Mesh2, ...]. Secondary inputs are matched index-per-index or broadcast if single value. Outputs are Selection: [Sel1, ...] and Inverted: [Inv1, ...]. Sockets switch to LIST."
Mesh,Geometry & Mesh,Separate Mesh by Range,SLICENODE_SeparateMeshByRange,core/nodeslicer/nodes/mesh.py,Element-wise Demux,SLICESOCK_Mesh (Mesh),"Attribute, Start, End (single or matching list)","Dynamic numeric sockets '0', '1', ...",Dynamic (Changes to LIST if input is list),Dynamic (All outputs change to LIST if input is list),Splits single mesh into multiple output sockets across integer range Start..End,"First input is list [Mesh1, Mesh2, ...]. Secondary inputs are matched index-per-index or broadcast if single value. For each range output socket, delivers list of matching parts across all meshes: e.g. socket '0' gets [Mesh1_0, Mesh2_0, ...]. Outputs switch to LIST."
Mesh,Geometry & Mesh,Arrange Slicing Group,SLICENODE_ArrangeMesh,core/nodeslicer/nodes/mesh.py,Multi-Entity Packer,SLICESOCK_Mesh (Slicing Group),"Bed X, Bed Y, Spacing, Method, Allow Rotations (cannot be list)",SLICESOCK_Mesh (Slicing Group),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Nests collections in SlicingGroup on build plate using libnest2d,"First input is list [Mesh1, Mesh2, ...]. Packs all meshes in the list simultaneously on the build plate without collisions. Output is [Mesh1_packed, Mesh2_packed, ...]. Sockets switch to LIST."
Mesh,PDF Exporter,Assign PDF Properties,SLICENODE_AssignPdfProperties,core/nodeslicer/nodes/mesh.py,Element-wise Operator,SLICESOCK_Mesh (Geometry),"Line Width, Line Color, Show Name (cannot be list)",SLICESOCK_Mesh (Geometry),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Attaches stroke line width and color metadata to geometry,"First input is list [G1, G2, ...]. Attaches the same stroke properties to each item in the list. Output is [G1', G2', ...]. Sockets switch to LIST."
Mesh,PDF Exporter,Assign PDF Object Properties,SLICENODE_AssignPropertyToObject,core/nodeslicer/nodes/mesh.py,Element-wise Operator,SLICESOCK_Mesh (Geometry),Settings (cannot be list),SLICESOCK_Mesh (Geometry),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Attaches property callouts to geometry for PDF drawing tables,"First input is list [G1, G2, ...]. Attaches property callouts to each item in the list. Output is [G1', G2', ...]. Sockets switch to LIST."
Mesh,Path Processing,Slice Shells,SLICENODE_SliceShells,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Mesh (Mesh),"Layer Height, First Layer Height, Rotation, Ignore Holes (cannot be list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Slices single mesh into 2D SlicePaths polygons,"First input is list [Mesh1, Mesh2, ...]. Slices each mesh independently using the same layer parameters. Output is [Paths1, Paths2, ...]. Sockets switch to LIST."
Mesh,PDF Exporter,Assign PDF Properties,SLICENODE_AssignPdfProperties,core/nodeslicer/nodes/mesh.py,Element-wise Operator,SLICESOCK_Mesh (Geometry),"Line Width, Line Color, Show Name (single or matching list)",SLICESOCK_Mesh (Geometry),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Attaches stroke line width and color metadata to geometry,"First input is list [G1, G2, ...]. Secondary properties are matched index-per-index or broadcast if single value. Output is [G1', G2', ...]. Sockets switch to LIST."
Mesh,PDF Exporter,Assign PDF Object Properties,SLICENODE_AssignPropertyToObject,core/nodeslicer/nodes/mesh.py,Element-wise Operator,SLICESOCK_Mesh (Geometry),Settings (single or matching list),SLICESOCK_Mesh (Geometry),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Attaches property callouts to geometry for PDF drawing tables,"First input is list [G1, G2, ...]. Secondary properties are matched index-per-index or broadcast if single value. Output is [G1', G2', ...]. Sockets switch to LIST."
Mesh,Path Processing,Slice Shells,SLICENODE_SliceShells,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Mesh (Mesh),"Layer Height, First Layer Height, Rotation, Ignore Holes (single or matching list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Slices single mesh into 2D SlicePaths polygons,"First input is list [Mesh1, Mesh2, ...]. Secondary parameters are matched index-per-index or broadcast if single value. Output is [Paths1, Paths2, ...]. Sockets switch to LIST."
Mesh,PDF Exporter,Mesh to PDF,SLICENODE_MeshToPdf,core/nodeslicer/nodes/output.py,Batch Sink,SLICESOCK_Mesh (Geometry) or SLICESOCK_Paths (Paths),"Folder, Settings, Template, Header, Margins (cannot be list)",Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Renders orthographic 1:1 scale vector PDF drawing,"First input is list [Geom1, Geom2, ...]. Renders and exports a separate PDF file for each item using the same settings and naming template."
Path,Path Processing,Buffer Shells,SLICENODE_BufferShells,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),"Distance, Rotation, Miter (cannot be list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Offsets 2D closed polygon boundaries by distance,"First input is list [P1, P2, ...]. Buffers each path block using the same distance. Output is [Buf(P1), Buf(P2), ...]. Sockets switch to LIST."
Path,Path Processing,Buffer Curve,SLICENODE_BufferCurve,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),"Distance, Cap Style, Join Style, Single Sided, Rotation (cannot be list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Dilates open or closed curves by distance,"First input is list [P1, P2, ...]. Buffers each curve using the same parameters. Output is [Buf(P1), Buf(P2), ...]. Sockets switch to LIST."
Path,Path Processing,Offset Curve,SLICENODE_OffsetCurve,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),"Distance, Join Style, Rotation (cannot be list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Generates parallel offset curves using Shapely,"First input is list [P1, P2, ...]. Offsets each curve using the same distance. Output is [Off(P1), Off(P2), ...]. Sockets switch to LIST."
Path,Path Processing,Path Offset,SLICENODE_PathOffset,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),Offset Vector (cannot be list),SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Translates toolpaths by 3D XYZ vector,"First input is list [P1, P2, ...]. Translates each path set by the same vector. Output is [P1 + V, P2 + V, ...]. Sockets switch to LIST."
Path,Path Processing,Rotate Path,SLICENODE_RotatePath,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),"Angle, Center (cannot be list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Rotates toolpaths around Z axis at center point,"First input is list [P1, P2, ...]. Rotates each path set by the same angle around the center. Output is [Rot(P1), Rot(P2), ...]. Sockets switch to LIST."
Path,Path Processing,Buffer Shells Multiple,SLICENODE_BufferShellsMultiple,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),"Distance, Width Step, Count, Rotation, Miter (cannot be list)","Paths, Outermost Path, Innermost Path",Dynamic (Changes to LIST if input is list),Dynamic (All 3 outputs change to LIST if input is list),"Generates multiple concentric perimeter passes, plus outermost and innermost boundaries","First input is list [P1, P2, ...]. Computes concentric passes per item using same distance/step/count. Outputs are [Passes1, Passes2, ...], [Out1, Out2, ...], [In1, In2, ...]. All outputs switch to LIST."
Path,Path Processing,Infill,SLICENODE_Infill,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),"Density, Infill Rotation, Layer Rotation, Infill Type (cannot be list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Generates infill pattern clipped to boundary paths,"First input is list [P1, P2, ...]. Generates infill for each boundary block using the same density and rotation. Output is [Inf(P1), Inf(P2), ...]. Sockets switch to LIST."
Path,Path Processing,Overhang Mask,SLICENODE_OverhangMask,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),Tolerance (cannot be list),SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Calculates downward accumulated overhang mask across layers,"First input is list [P1, P2, ...]. Calculates overhang mask for each path set using the same tolerance. Output is [Mask(P1), Mask(P2), ...]. Sockets switch to LIST."
Path,Path Processing,Simplify Paths,SLICENODE_SimplifyPaths,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),"Tolerance, Preserve Topology (cannot be list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Simplifies toolpath vertices using Douglas-Peucker algorithm,"First input is list [P1, P2, ...]. Simplifies each path set using the same tolerance. Output is [Simp(P1), Simp(P2), ...]. Sockets switch to LIST."
Path,Path Processing,Seam Placement,SLICENODE_SeamPlacement,core/nodeslicer/nodes/path.py,Primary-List Operation,SLICESOCK_Paths (Paths) [Can be a list],"SLICESOCK_Mesh (Cutting Mesh), Seam Staggering, Mode, Acquire Transforms [Cannot be a list]",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Repositions start seams of closed paths to intersection points with cutting mesh,"First input is list [P1, P2, ...]. Other inputs cannot be a list. The seam placement operation is done for each element P_i of the list using the same cutting mesh and parameters. Output is [P1', P2', ...]. Sockets switch to LIST."
Path,Path Processing,Path Operations,SLICENODE_PathOperations,core/nodeslicer/nodes/path.py,Primary-List Operation,Input 0: SLICESOCK_Paths (Paths) [Can be a list],"Inputs 1..N: SLICESOCK_Paths (Paths), connect_along_boundary [Cannot be a list]",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),"Performs Zip, Extend, Max Coverage (Union), Min Coverage, Intersect, Subtract, or XOR across inputs","The first input can be a list [P1, P2, ...], and the other inputs cannot be a list (Q1, Q2...). The operation (Zip, Extend, Union, Subtract, Intersect, etc.) is done for each element of the list using the same other inputs: [op(P1, Q...), op(P2, Q...), ...]. Output is [R1, R2, ...]. Sockets switch to LIST."
Path,G-code Processing,Paths to Gcode,SLICENODE_PathsToGcode,core/nodeslicer/nodes/gcode.py,Element-wise Operator,SLICESOCK_Paths (Paths),"SLICESOCK_Intrinsics (Intrinsics), Header (cannot be list)",NodeSocketString (Gcode),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Converts single SlicePaths into standard G-code string via Pather,"First input is list [P1, P2, ...]. Converts each toolpath block into a G-code string using the same intrinsics. Output is [Gcode1, Gcode2, ...]. Sockets switch to LIST."
Gcode,G-code Processing,Gcode Regex Replace,SLICENODE_GcodeRegexReplace,core/nodeslicer/nodes/gcode.py,Element-wise Operator,NodeSocketString (Gcode),"Find, Replace (cannot be list)",NodeSocketString (Gcode),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Runs regex find & replace on G-code string,"First input is list [G1, G2, ...]. Runs regex search and replace on each G-code string using the same pattern. Output is [G1', G2', ...]. Sockets switch to LIST."
Gcode,Action & I/O,Gcode to File,SLICENODE_GcodeToFile,core/nodeslicer/nodes/output.py,Batch Sink,NodeSocketString (Gcode) [Can be a list or multi-input],"Folder, Name (cannot be list)",Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Saves G-code string to .gcode or .bgcode file on disk,"First input is list [G1, G2, ...]. Saves a separate file for each element using the same folder/name template with zero-padded numbering (Part00.gcode, Part01.gcode) or metadata names."
Path,Path Processing,Buffer Shells,SLICENODE_BufferShells,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),"Distance, Rotation, Miter (single or matching list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Offsets 2D closed polygon boundaries by distance,"First input is list [P1, P2, ...]. Secondary parameters are matched index-per-index or broadcast if single value. Output is [Buf(P1), Buf(P2), ...]. Sockets switch to LIST."
Path,Path Processing,Buffer Curve,SLICENODE_BufferCurve,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),"Distance, Cap Style, Join Style, Single Sided, Rotation (single or matching list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Dilates open or closed curves by distance,"First input is list [P1, P2, ...]. Secondary parameters are matched index-per-index or broadcast if single value. Output is [Buf(P1), Buf(P2), ...]. Sockets switch to LIST."
Path,Path Processing,Offset Curve,SLICENODE_OffsetCurve,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),"Distance, Join Style, Rotation (single or matching list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Generates parallel offset curves using Shapely,"First input is list [P1, P2, ...]. Secondary parameters are matched index-per-index or broadcast if single value. Output is [Off(P1), Off(P2), ...]. Sockets switch to LIST."
Path,Path Processing,Path Offset,SLICENODE_PathOffset,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),Offset Vector (single or matching list),SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Translates toolpaths by 3D XYZ vector,"First input is list [P1, P2, ...]. Offset vectors are matched index-per-index or broadcast if single value. Output is [P1 + V1, P2 + V2, ...]. Sockets switch to LIST."
Path,Path Processing,Rotate Path,SLICENODE_RotatePath,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),"Angle, Center (single or matching list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Rotates toolpaths around Z axis at center point,"First input is list [P1, P2, ...]. Angle/center are matched index-per-index or broadcast if single value. Output is [Rot(P1), Rot(P2), ...]. Sockets switch to LIST."
Path,Path Processing,Buffer Shells Multiple,SLICENODE_BufferShellsMultiple,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),"Distance, Width Step, Count, Rotation, Miter (single or matching list)","Paths, Outermost Path, Innermost Path",Dynamic (Changes to LIST if input is list),Dynamic (All 3 outputs change to LIST if input is list),"Generates multiple concentric perimeter passes, plus outermost and innermost boundaries","First input is list [P1, P2, ...]. Computes concentric passes per item using matching distance/step/count index-per-index (or broadcast if single value). Outputs are [Passes1, ...], [Out1, ...], [In1, ...]. All outputs switch to LIST."
Path,Path Processing,Infill,SLICENODE_Infill,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),"Density, Infill Rotation, Layer Rotation, Infill Type (single or matching list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Generates infill pattern clipped to boundary paths,"First input is list [P1, P2, ...]. Density/rotation parameters are matched index-per-index or broadcast if single value. Output is [Inf(P1), Inf(P2), ...]. Sockets switch to LIST."
Path,Path Processing,Overhang Mask,SLICENODE_OverhangMask,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),Tolerance (single or matching list),SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Calculates downward accumulated overhang mask across layers,"First input is list [P1, P2, ...]. Tolerance values are matched index-per-index or broadcast if single value. Output is [Mask(P1), Mask(P2), ...]. Sockets switch to LIST."
Path,Path Processing,Simplify Paths,SLICENODE_SimplifyPaths,core/nodeslicer/nodes/path.py,Element-wise Operator,SLICESOCK_Paths (Paths),"Tolerance, Preserve Topology (single or matching list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Simplifies toolpath vertices using Douglas-Peucker algorithm,"First input is list [P1, P2, ...]. Tolerance values are matched index-per-index or broadcast if single value. Output is [Simp(P1), Simp(P2), ...]. Sockets switch to LIST."
Path,Path Processing,Seam Placement,SLICENODE_SeamPlacement,core/nodeslicer/nodes/path.py,Primary-List Operation,SLICESOCK_Paths (Paths) [Can be a list],"SLICESOCK_Mesh (Cutting Mesh), Seam Staggering, Mode, Acquire Transforms (single or matching list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Repositions start seams of closed paths to intersection points with cutting mesh,"First input is list [P1, P2, ...]. Cutting meshes and staggering are matched index-per-index (or broadcast if single value). Output is [P1', P2', ...]. Sockets switch to LIST."
Path,Path Processing,Path Operations,SLICENODE_PathOperations,core/nodeslicer/nodes/path.py,Primary-List Operation,Input 0: SLICESOCK_Paths (Paths) [Can be a list],"Inputs 1..N: SLICESOCK_Paths (Paths), connect_along_boundary, Acquire Transforms (single or matching list)",SLICESOCK_Paths (Paths),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),"Performs Zip, Extend, Max Coverage (Union), Min Coverage, Intersect, Subtract, or XOR across inputs","First input is list [P1, P2, ...]. Other inputs can be single values (broadcast) or lists of equal length matched index-per-index: [op(P1, Q1...), op(P2, Q2...), ...]. Output is [R1, R2, ...]. Sockets switch to LIST."
Path,G-code Processing,Paths to Gcode,SLICENODE_PathsToGcode,core/nodeslicer/nodes/gcode.py,Element-wise Operator,SLICESOCK_Paths (Paths),"SLICESOCK_Intrinsics (Intrinsics), Header (single or matching list)",NodeSocketString (Gcode),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Converts single SlicePaths into standard G-code string via Pather,"First input is list [P1, P2, ...]. Intrinsics and header are matched index-per-index or broadcast if single value. Output is [Gcode1, Gcode2, ...]. Sockets switch to LIST."
Path,G-code Processing,Gcode Regex Replace,SLICENODE_GcodeRegexReplace,core/nodeslicer/nodes/gcode.py,Element-wise Operator,NodeSocketString (Gcode),"Find, Replace (single or matching list)",NodeSocketString (Gcode),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Runs regex find & replace on G-code string,"First input is list [G1, G2, ...]. Find/replace patterns are matched index-per-index or broadcast if single value. Output is [G1', G2', ...]. Sockets switch to LIST."
Gcode,Action & I/O,Gcode to File,SLICENODE_GcodeToFile,core/nodeslicer/nodes/output.py,Batch Sink,NodeSocketString (Gcode) [Can be a list or multi-input],"Folder, Name (single or matching list)",Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Saves G-code string to .gcode or .bgcode file on disk,"First input is list [G1, G2, ...]. Names are matched index-per-index (or broadcast if single value/empty). Saves a separate file for each element."
Gcode,Action & I/O,Gcode to Text Block,SLICENODE_GcodeToTextBlock,core/nodeslicer/nodes/output.py,Batch Sink,NodeSocketString (Gcode) [Can be a list],Text Block Name (cannot be list),Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Writes G-code string to an active Blender text editor data-block,"First input is list [G1, G2, ...]. Writes separate numbered Blender text blocks (NodeSlicer_00.gcode, NodeSlicer_01.gcode) using the base name."
Gcode,Action & I/O,Start Print,SLICENODE_StartPrint,core/nodeslicer/nodes/output.py,Single Item Sink,NodeSocketString (Gcode) [Single item only],SLICESOCK_Printer (Single item only),Execution Link,Standard (CIRCLE),Standard Execution Link,Uploads and starts print on network printer,List is not accepted. Sockets reject list connections and evaluate() reports an error if a list is received.
Gcode,Action & I/O,Baserow Submit,SLICENODE_BaserowSubmit,core/nodeslicer/nodes/output.py,Batch Sink,NodeSocketString (Gcode) [Can be a list or multi-input],"Settings, Host, Token, Table ID, Record ID (cannot be list)",Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Uploads G-code attachment and creates/updates row in Baserow database,"First input is list [G1, G2, ...]. Submits each G-code as a separate record and attachment in Baserow using the same database credentials."

1 Type Category Node Name Class Name File Archetype First Input Socket Other Input Sockets Output Socket(s) First Input Shape Output Shape Single Item Behavior List Behavior
7 Mesh Existing Joiners Join Mesh Groups SLICENODE_JoinMeshGroups core/nodeslicer/nodes/mesh.py Data Joiner (Flattener) Multi-input SLICESOCK_Mesh (Mesh) None SLICESOCK_Mesh (Mesh) Dynamic (Displays LIST if linked to list) Standard (Group) Bundles multiple SlicingCollections into a SlicingGroup Flattens incoming lists of collections into a single SlicingGroup.
8 Gcode Existing Joiners Join Gcode SLICENODE_JoinGcode core/nodeslicer/nodes/gcode.py Data Joiner (Flattener) Multi-input NodeSocketString (Gcode) None NodeSocketString (Gcode) Dynamic (Displays LIST if linked to list) Standard (CIRCLE) Concatenates multiple G-code strings with newline '\n' Flattens incoming G-code lists and strings, concatenating all sequentially into a single G-code string (CIRCLE).
9 Object Geometry & Mesh Get Mesh SLICENODE_GetMesh core/nodeslicer/nodes/mesh.py Element-wise Operator NodeSocketObject (Object) include_children (prop) SLICESOCK_Mesh (Mesh) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Converts single Object into SlicingCollection First input is list [Obj1, Obj2, ...]. Converts each element into SlicingCollection. Output is [Mesh1, Mesh2, ...]. Both sockets switch to LIST.
10 Object Path Processing Get Paths SLICENODE_GetPaths core/nodeslicer/nodes/path.py Element-wise Operator NodeSocketObject (Object) Extrusion/Feedrate/Extra Attributes (cannot be list) Extrusion/Feedrate/Extra Attributes (single or matching list) SLICESOCK_Paths (Paths) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Extracts mesh edges as SlicePaths First input is list [Obj1, Obj2, ...]. Extracts toolpaths for each object using the same attribute settings. Output is [Paths1, Paths2, ...]. Both sockets switch to LIST. First input is list [Obj1, Obj2, ...]. Secondary inputs are matched index-per-index or broadcast if single value. Output is [Paths1, Paths2, ...]. Both sockets switch to LIST.
11 Mesh Geometry & Mesh Rename Mesh SLICENODE_RenameMesh core/nodeslicer/nodes/mesh.py Element-wise Operator SLICESOCK_Mesh (Geometry) NodeSocketString Name (cannot be list) NodeSocketString Name (single or matching list) SLICESOCK_Mesh (Geometry) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Renames geometry using name template First input is list [Mesh1, Mesh2, ...]. Renames each mesh using the same name template. Output is [Renamed1, Renamed2, ...]. Sockets switch to LIST. First input is list [Mesh1, Mesh2, ...]. Names are matched index-per-index or broadcast if single value. Output is [Renamed1, Renamed2, ...]. Sockets switch to LIST.
12 Mesh Geometry & Mesh Transform Geometry SLICENODE_TransformGeometry core/nodeslicer/nodes/mesh.py Element-wise Operator SLICESOCK_Geometry (Geometry) Translation, Rotation, Scale (cannot be list) Translation, Rotation, Scale (single or matching list) SLICESOCK_Geometry (Geometry) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Transforms single Mesh or Paths by T, R, S First input is list [G1, G2, ...]. Transforms each item using the same T, R, S values. Output is [T(G1), T(G2), ...]. Sockets switch to LIST. First input is list [G1, G2, ...]. Transforms each item using matching T, R, S values index-per-index (or broadcast if single value). Output is [T(G1), T(G2), ...]. Sockets switch to LIST.
13 Mesh Geometry & Mesh Drop Geometry Get Transforms SLICENODE_DropGeometry SLICENODE_GetTransforms core/nodeslicer/nodes/mesh.py Element-wise Operator SLICESOCK_Mesh (Mesh) SLICESOCK_Geometry (Geometry) NodeSocketFloat Z (cannot be list) None SLICESOCK_Mesh (Mesh), NodeSocketVector (Translation) NodeSocketVector (Translation), NodeSocketRotation (Rotation), NodeSocketVector (Scale) Dynamic (Changes to LIST if input is list) Dynamic (Both outputs change to LIST if input is list) Dynamic (All outputs change to LIST if input is list) Translates mesh to target minimum Z; outputs dropped mesh and (0,0,dz) Extracts consolidated Translation, Rotation, and Scale from geometry First input is list [Mesh1, Mesh2, ...]. Drops each mesh independently to the same target Z. Outputs are [Dropped1, Dropped2, ...] and [T1, T2, ...]. Sockets switch to LIST. Input is list [G1, G2, ...]. Extracts T, R, S for each item. Outputs are [T1, T2, ...], [R1, R2, ...], and [S1, S2, ...]. Sockets switch to LIST.
14 Mesh Geometry & Mesh Separate Mesh Drop Geometry SLICENODE_SeparateMesh SLICENODE_DropGeometry core/nodeslicer/nodes/mesh.py Element-wise Operator SLICESOCK_Mesh (Mesh) Attribute (String), Value (Float) (cannot be list) NodeSocketFloat Z (single or matching list) Selection (Mesh), Inverted (Mesh) SLICESOCK_Mesh (Mesh), NodeSocketVector (Translation) Dynamic (Changes to LIST if input is list) Dynamic (Both outputs change to LIST if input is list) Splits collection into Selection and Inverted based on attribute condition Translates mesh to target minimum Z; outputs dropped mesh and (0,0,dz) First input is list [Mesh1, Mesh2, ...]. Applies separation to each mesh using same attribute/value. Outputs are Selection: [Sel1, Sel2, ...] and Inverted: [Inv1, Inv2, ...]. Sockets switch to LIST. First input is list [Mesh1, Mesh2, ...]. Target Z values are matched index-per-index (or broadcast if single value). Outputs are [Dropped1, Dropped2, ...] and [T1, T2, ...]. Sockets switch to LIST.
15 Mesh Geometry & Mesh Separate Mesh by Range Separate Mesh SLICENODE_SeparateMeshByRange SLICENODE_SeparateMesh core/nodeslicer/nodes/mesh.py Element-wise Demux Element-wise Operator SLICESOCK_Mesh (Mesh) Attribute, Start, End (cannot be list) Attribute (String), Value (Float) (single or matching list) Dynamic numeric sockets '0', '1', ... Selection (Mesh), Inverted (Mesh) Dynamic (Changes to LIST if input is list) Dynamic (All outputs change to LIST if input is list) Dynamic (Both outputs change to LIST if input is list) Splits single mesh into multiple output sockets across integer range Start..End Splits collection into Selection and Inverted based on attribute condition First input is list [Mesh1, Mesh2, ...]. For each range output socket, delivers list of matching parts across all meshes: e.g. socket '0' gets [Mesh1_0, Mesh2_0, ...]. Outputs switch to LIST. First input is list [Mesh1, Mesh2, ...]. Secondary inputs are matched index-per-index or broadcast if single value. Outputs are Selection: [Sel1, ...] and Inverted: [Inv1, ...]. Sockets switch to LIST.
16 Mesh Geometry & Mesh Separate Mesh by Range SLICENODE_SeparateMeshByRange core/nodeslicer/nodes/mesh.py Element-wise Demux SLICESOCK_Mesh (Mesh) Attribute, Start, End (single or matching list) Dynamic numeric sockets '0', '1', ... Dynamic (Changes to LIST if input is list) Dynamic (All outputs change to LIST if input is list) Splits single mesh into multiple output sockets across integer range Start..End First input is list [Mesh1, Mesh2, ...]. Secondary inputs are matched index-per-index or broadcast if single value. For each range output socket, delivers list of matching parts across all meshes: e.g. socket '0' gets [Mesh1_0, Mesh2_0, ...]. Outputs switch to LIST.
17 Mesh Geometry & Mesh Arrange Slicing Group SLICENODE_ArrangeMesh core/nodeslicer/nodes/mesh.py Multi-Entity Packer SLICESOCK_Mesh (Slicing Group) Bed X, Bed Y, Spacing, Method, Allow Rotations (cannot be list) SLICESOCK_Mesh (Slicing Group) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Nests collections in SlicingGroup on build plate using libnest2d First input is list [Mesh1, Mesh2, ...]. Packs all meshes in the list simultaneously on the build plate without collisions. Output is [Mesh1_packed, Mesh2_packed, ...]. Sockets switch to LIST.
18 Mesh PDF Exporter Assign PDF Properties SLICENODE_AssignPdfProperties core/nodeslicer/nodes/mesh.py Element-wise Operator SLICESOCK_Mesh (Geometry) Line Width, Line Color, Show Name (cannot be list) Line Width, Line Color, Show Name (single or matching list) SLICESOCK_Mesh (Geometry) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Attaches stroke line width and color metadata to geometry First input is list [G1, G2, ...]. Attaches the same stroke properties to each item in the list. Output is [G1', G2', ...]. Sockets switch to LIST. First input is list [G1, G2, ...]. Secondary properties are matched index-per-index or broadcast if single value. Output is [G1', G2', ...]. Sockets switch to LIST.
19 Mesh PDF Exporter Assign PDF Object Properties SLICENODE_AssignPropertyToObject core/nodeslicer/nodes/mesh.py Element-wise Operator SLICESOCK_Mesh (Geometry) Settings (cannot be list) Settings (single or matching list) SLICESOCK_Mesh (Geometry) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Attaches property callouts to geometry for PDF drawing tables First input is list [G1, G2, ...]. Attaches property callouts to each item in the list. Output is [G1', G2', ...]. Sockets switch to LIST. First input is list [G1, G2, ...]. Secondary properties are matched index-per-index or broadcast if single value. Output is [G1', G2', ...]. Sockets switch to LIST.
20 Mesh Path Processing Slice Shells SLICENODE_SliceShells core/nodeslicer/nodes/path.py Element-wise Operator SLICESOCK_Mesh (Mesh) Layer Height, First Layer Height, Rotation, Ignore Holes (cannot be list) Layer Height, First Layer Height, Rotation, Ignore Holes (single or matching list) SLICESOCK_Paths (Paths) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Slices single mesh into 2D SlicePaths polygons First input is list [Mesh1, Mesh2, ...]. Slices each mesh independently using the same layer parameters. Output is [Paths1, Paths2, ...]. Sockets switch to LIST. First input is list [Mesh1, Mesh2, ...]. Secondary parameters are matched index-per-index or broadcast if single value. Output is [Paths1, Paths2, ...]. Sockets switch to LIST.
21 Mesh PDF Exporter Mesh to PDF SLICENODE_MeshToPdf core/nodeslicer/nodes/output.py Batch Sink SLICESOCK_Mesh (Geometry) or SLICESOCK_Paths (Paths) Folder, Settings, Template, Header, Margins (cannot be list) Execution Link Dynamic (Displays LIST if input is list) Standard Execution Link Renders orthographic 1:1 scale vector PDF drawing First input is list [Geom1, Geom2, ...]. Renders and exports a separate PDF file for each item using the same settings and naming template.
22 Path Path Processing Buffer Shells SLICENODE_BufferShells core/nodeslicer/nodes/path.py Element-wise Operator SLICESOCK_Paths (Paths) Distance, Rotation, Miter (cannot be list) Distance, Rotation, Miter (single or matching list) SLICESOCK_Paths (Paths) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Offsets 2D closed polygon boundaries by distance First input is list [P1, P2, ...]. Buffers each path block using the same distance. Output is [Buf(P1), Buf(P2), ...]. Sockets switch to LIST. First input is list [P1, P2, ...]. Secondary parameters are matched index-per-index or broadcast if single value. Output is [Buf(P1), Buf(P2), ...]. Sockets switch to LIST.
23 Path Path Processing Buffer Curve SLICENODE_BufferCurve core/nodeslicer/nodes/path.py Element-wise Operator SLICESOCK_Paths (Paths) Distance, Cap Style, Join Style, Single Sided, Rotation (cannot be list) Distance, Cap Style, Join Style, Single Sided, Rotation (single or matching list) SLICESOCK_Paths (Paths) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Dilates open or closed curves by distance First input is list [P1, P2, ...]. Buffers each curve using the same parameters. Output is [Buf(P1), Buf(P2), ...]. Sockets switch to LIST. First input is list [P1, P2, ...]. Secondary parameters are matched index-per-index or broadcast if single value. Output is [Buf(P1), Buf(P2), ...]. Sockets switch to LIST.
24 Path Path Processing Offset Curve SLICENODE_OffsetCurve core/nodeslicer/nodes/path.py Element-wise Operator SLICESOCK_Paths (Paths) Distance, Join Style, Rotation (cannot be list) Distance, Join Style, Rotation (single or matching list) SLICESOCK_Paths (Paths) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Generates parallel offset curves using Shapely First input is list [P1, P2, ...]. Offsets each curve using the same distance. Output is [Off(P1), Off(P2), ...]. Sockets switch to LIST. First input is list [P1, P2, ...]. Secondary parameters are matched index-per-index or broadcast if single value. Output is [Off(P1), Off(P2), ...]. Sockets switch to LIST.
25 Path Path Processing Path Offset SLICENODE_PathOffset core/nodeslicer/nodes/path.py Element-wise Operator SLICESOCK_Paths (Paths) Offset Vector (cannot be list) Offset Vector (single or matching list) SLICESOCK_Paths (Paths) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Translates toolpaths by 3D XYZ vector First input is list [P1, P2, ...]. Translates each path set by the same vector. Output is [P1 + V, P2 + V, ...]. Sockets switch to LIST. First input is list [P1, P2, ...]. Offset vectors are matched index-per-index or broadcast if single value. Output is [P1 + V1, P2 + V2, ...]. Sockets switch to LIST.
26 Path Path Processing Rotate Path SLICENODE_RotatePath core/nodeslicer/nodes/path.py Element-wise Operator SLICESOCK_Paths (Paths) Angle, Center (cannot be list) Angle, Center (single or matching list) SLICESOCK_Paths (Paths) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Rotates toolpaths around Z axis at center point First input is list [P1, P2, ...]. Rotates each path set by the same angle around the center. Output is [Rot(P1), Rot(P2), ...]. Sockets switch to LIST. First input is list [P1, P2, ...]. Angle/center are matched index-per-index or broadcast if single value. Output is [Rot(P1), Rot(P2), ...]. Sockets switch to LIST.
27 Path Path Processing Buffer Shells Multiple SLICENODE_BufferShellsMultiple core/nodeslicer/nodes/path.py Element-wise Operator SLICESOCK_Paths (Paths) Distance, Width Step, Count, Rotation, Miter (cannot be list) Distance, Width Step, Count, Rotation, Miter (single or matching list) Paths, Outermost Path, Innermost Path Dynamic (Changes to LIST if input is list) Dynamic (All 3 outputs change to LIST if input is list) Generates multiple concentric perimeter passes, plus outermost and innermost boundaries First input is list [P1, P2, ...]. Computes concentric passes per item using same distance/step/count. Outputs are [Passes1, Passes2, ...], [Out1, Out2, ...], [In1, In2, ...]. All outputs switch to LIST. First input is list [P1, P2, ...]. Computes concentric passes per item using matching distance/step/count index-per-index (or broadcast if single value). Outputs are [Passes1, ...], [Out1, ...], [In1, ...]. All outputs switch to LIST.
28 Path Path Processing Infill SLICENODE_Infill core/nodeslicer/nodes/path.py Element-wise Operator SLICESOCK_Paths (Paths) Density, Infill Rotation, Layer Rotation, Infill Type (cannot be list) Density, Infill Rotation, Layer Rotation, Infill Type (single or matching list) SLICESOCK_Paths (Paths) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Generates infill pattern clipped to boundary paths First input is list [P1, P2, ...]. Generates infill for each boundary block using the same density and rotation. Output is [Inf(P1), Inf(P2), ...]. Sockets switch to LIST. First input is list [P1, P2, ...]. Density/rotation parameters are matched index-per-index or broadcast if single value. Output is [Inf(P1), Inf(P2), ...]. Sockets switch to LIST.
29 Path Path Processing Overhang Mask SLICENODE_OverhangMask core/nodeslicer/nodes/path.py Element-wise Operator SLICESOCK_Paths (Paths) Tolerance (cannot be list) Tolerance (single or matching list) SLICESOCK_Paths (Paths) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Calculates downward accumulated overhang mask across layers First input is list [P1, P2, ...]. Calculates overhang mask for each path set using the same tolerance. Output is [Mask(P1), Mask(P2), ...]. Sockets switch to LIST. First input is list [P1, P2, ...]. Tolerance values are matched index-per-index or broadcast if single value. Output is [Mask(P1), Mask(P2), ...]. Sockets switch to LIST.
30 Path Path Processing Simplify Paths SLICENODE_SimplifyPaths core/nodeslicer/nodes/path.py Element-wise Operator SLICESOCK_Paths (Paths) Tolerance, Preserve Topology (cannot be list) Tolerance, Preserve Topology (single or matching list) SLICESOCK_Paths (Paths) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Simplifies toolpath vertices using Douglas-Peucker algorithm First input is list [P1, P2, ...]. Simplifies each path set using the same tolerance. Output is [Simp(P1), Simp(P2), ...]. Sockets switch to LIST. First input is list [P1, P2, ...]. Tolerance values are matched index-per-index or broadcast if single value. Output is [Simp(P1), Simp(P2), ...]. Sockets switch to LIST.
31 Path Path Processing Seam Placement SLICENODE_SeamPlacement core/nodeslicer/nodes/path.py Primary-List Operation SLICESOCK_Paths (Paths) [Can be a list] SLICESOCK_Mesh (Cutting Mesh), Seam Staggering, Mode, Acquire Transforms [Cannot be a list] SLICESOCK_Mesh (Cutting Mesh), Seam Staggering, Mode, Acquire Transforms (single or matching list) SLICESOCK_Paths (Paths) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Repositions start seams of closed paths to intersection points with cutting mesh First input is list [P1, P2, ...]. Other inputs cannot be a list. The seam placement operation is done for each element P_i of the list using the same cutting mesh and parameters. Output is [P1', P2', ...]. Sockets switch to LIST. First input is list [P1, P2, ...]. Cutting meshes and staggering are matched index-per-index (or broadcast if single value). Output is [P1', P2', ...]. Sockets switch to LIST.
32 Path Path Processing Path Operations SLICENODE_PathOperations core/nodeslicer/nodes/path.py Primary-List Operation Input 0: SLICESOCK_Paths (Paths) [Can be a list] Inputs 1..N: SLICESOCK_Paths (Paths), connect_along_boundary [Cannot be a list] Inputs 1..N: SLICESOCK_Paths (Paths), connect_along_boundary, Acquire Transforms (single or matching list) SLICESOCK_Paths (Paths) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Performs Zip, Extend, Max Coverage (Union), Min Coverage, Intersect, Subtract, or XOR across inputs The first input can be a list [P1, P2, ...], and the other inputs cannot be a list (Q1, Q2...). The operation (Zip, Extend, Union, Subtract, Intersect, etc.) is done for each element of the list using the same other inputs: [op(P1, Q...), op(P2, Q...), ...]. Output is [R1, R2, ...]. Sockets switch to LIST. First input is list [P1, P2, ...]. Other inputs can be single values (broadcast) or lists of equal length matched index-per-index: [op(P1, Q1...), op(P2, Q2...), ...]. Output is [R1, R2, ...]. Sockets switch to LIST.
33 Path G-code Processing Paths to Gcode SLICENODE_PathsToGcode core/nodeslicer/nodes/gcode.py Element-wise Operator SLICESOCK_Paths (Paths) SLICESOCK_Intrinsics (Intrinsics), Header (cannot be list) SLICESOCK_Intrinsics (Intrinsics), Header (single or matching list) NodeSocketString (Gcode) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Converts single SlicePaths into standard G-code string via Pather First input is list [P1, P2, ...]. Converts each toolpath block into a G-code string using the same intrinsics. Output is [Gcode1, Gcode2, ...]. Sockets switch to LIST. First input is list [P1, P2, ...]. Intrinsics and header are matched index-per-index or broadcast if single value. Output is [Gcode1, Gcode2, ...]. Sockets switch to LIST.
34 Gcode Path G-code Processing Gcode Regex Replace SLICENODE_GcodeRegexReplace core/nodeslicer/nodes/gcode.py Element-wise Operator NodeSocketString (Gcode) Find, Replace (cannot be list) Find, Replace (single or matching list) NodeSocketString (Gcode) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Runs regex find & replace on G-code string First input is list [G1, G2, ...]. Runs regex search and replace on each G-code string using the same pattern. Output is [G1', G2', ...]. Sockets switch to LIST. First input is list [G1, G2, ...]. Find/replace patterns are matched index-per-index or broadcast if single value. Output is [G1', G2', ...]. Sockets switch to LIST.
35 Gcode Action & I/O Gcode to File SLICENODE_GcodeToFile core/nodeslicer/nodes/output.py Batch Sink NodeSocketString (Gcode) [Can be a list or multi-input] Folder, Name (cannot be list) Folder, Name (single or matching list) Execution Link Dynamic (Displays LIST if input is list) Standard Execution Link Saves G-code string to .gcode or .bgcode file on disk First input is list [G1, G2, ...]. Saves a separate file for each element using the same folder/name template with zero-padded numbering (Part00.gcode, Part01.gcode) or metadata names. First input is list [G1, G2, ...]. Names are matched index-per-index (or broadcast if single value/empty). Saves a separate file for each element.
36 Gcode Action & I/O Gcode to Text Block SLICENODE_GcodeToTextBlock core/nodeslicer/nodes/output.py Batch Sink NodeSocketString (Gcode) [Can be a list] Text Block Name (cannot be list) Execution Link Dynamic (Displays LIST if input is list) Standard Execution Link Writes G-code string to an active Blender text editor data-block First input is list [G1, G2, ...]. Writes separate numbered Blender text blocks (NodeSlicer_00.gcode, NodeSlicer_01.gcode) using the base name.
37 Gcode Action & I/O Start Print SLICENODE_StartPrint core/nodeslicer/nodes/output.py Single Item Sink NodeSocketString (Gcode) [Single item only] SLICESOCK_Printer (Single item only) Execution Link Standard (CIRCLE) Standard Execution Link Uploads and starts print on network printer List is not accepted. Sockets reject list connections and evaluate() reports an error if a list is received.
38 Gcode Action & I/O Baserow Submit SLICENODE_BaserowSubmit core/nodeslicer/nodes/output.py Batch Sink NodeSocketString (Gcode) [Can be a list or multi-input] Settings, Host, Token, Table ID, Record ID (cannot be list) Execution Link Dynamic (Displays LIST if input is list) Standard Execution Link Uploads G-code attachment and creates/updates row in Baserow database First input is list [G1, G2, ...]. Submits each G-code as a separate record and attachment in Baserow using the same database credentials.

View file

@ -1,24 +1,59 @@
from __future__ import annotations
from typing import Any, Callable
import numpy as np
LIST_CAPABLE_SOCKET_TYPES = {
"SLICESOCK_Mesh",
"SLICESOCK_Paths",
"SLICESOCK_Object",
"SLICESOCK_Geometry",
"SLICESOCK_Intrinsics",
"SLICESOCK_Settings",
"NodeSocketObject",
"NodeSocketString",
"NodeSocketVector",
"NodeSocketRotation",
"NodeSocketFloat",
"NodeSocketInt",
"NodeSocketColor",
"NodeSocketBool",
}
def is_vector_value(val: Any) -> bool:
"""Checks if val represents a single 3D/4D vector, Euler rotation, or color,
rather than a list of such values."""
if val is None:
return False
val_type = type(val).__name__
if val_type in ("Vector", "Euler", "Quaternion", "Color"):
return True
if isinstance(val, np.ndarray):
if val.ndim == 1 and val.shape[0] in (3, 4):
return True
return False
if isinstance(val, (list, tuple)):
if len(val) in (3, 4) and all(isinstance(x, (int, float, np.number)) and not isinstance(x, bool) for x in val):
return True
return False
def is_input_a_list(val: Any, is_vector: bool = False) -> bool:
"""Determines whether an input value represents a list of items.
If is_vector is True, a single vector/rotation/color is treated as an atomic single value."""
if val is None or isinstance(val, (str, bytes, bool)):
return False
if type(val).__name__ == "SlicePaths" or getattr(val, "is_slice_paths", False):
return False
if is_vector:
if is_vector_value(val):
return False
return isinstance(val, (list, tuple))
return isinstance(val, (list, tuple))
def is_list_data(val: Any) -> bool:
"""Checks if val represents a list of items (Object, Mesh, Path, Gcode).
Note: SlicePaths is a list subclass representing an individual toolpath collection,
not a list of toolpaths, so it is treated as an atomic item (not list data)."""
if val is None or isinstance(val, (str, bytes)):
return False
if type(val).__name__ == "SlicePaths" or getattr(val, "is_slice_paths", False):
return False
return isinstance(val, (list, tuple))
return is_input_a_list(val, is_vector=False)
def flatten_list(items: Any) -> list[Any]:
"""Recursively flattens nested lists/tuples, keeping atomic items intact."""
@ -34,6 +69,94 @@ def flatten_list(items: Any) -> list[Any]:
result.append(item)
return result
def prepare_indexed_inputs(
main_data: Any,
*secondary_inputs: Any,
is_vector: list[bool] | None = None,
input_names: list[str] | None = None,
) -> tuple[bool, list[Any], list[list[Any]]]:
"""Prepares and validates main and secondary inputs according to the generalized list architecture:
- Main input can be a list or a single item.
- Secondary inputs that are lists must have either the same length as the main input, or be a single value (length 1 or non-list).
- If a secondary input is a single value, it is broadcast across all elements of the main input.
- If list lengths mismatch and length != 1, raises ValueError.
Returns:
(is_main_list, main_items, broadcasted_secondary_lists)
"""
main_is_list = is_input_a_list(main_data, is_vector=False)
if main_is_list:
main_items = list(main_data)
n = len(main_items)
else:
main_items = [main_data]
n = 1
vec_flags = is_vector or [False] * len(secondary_inputs)
names = input_names or [f"input_{i+1}" for i in range(len(secondary_inputs))]
broadcasted_secondaries: list[list[Any]] = []
for i, sec_val in enumerate(secondary_inputs):
is_vec = vec_flags[i] if i < len(vec_flags) else False
name = names[i] if i < len(names) else f"input_{i+1}"
sec_is_list = is_input_a_list(sec_val, is_vector=is_vec)
if not sec_is_list:
if main_is_list:
broadcasted_secondaries.append([sec_val] * n)
else:
broadcasted_secondaries.append([sec_val])
else:
m = len(sec_val)
if not main_is_list:
if m == 1:
broadcasted_secondaries.append([sec_val[0]])
else:
raise ValueError(
f"Input list length mismatch: main input is a single item, but secondary input '{name}' has {m} items (must be 1 or a single value)."
)
else:
if m == n:
broadcasted_secondaries.append(list(sec_val))
elif m == 1:
broadcasted_secondaries.append([sec_val[0]] * n)
else:
raise ValueError(
f"Input list length mismatch: main input has {n} items, but secondary input '{name}' has {m} items (must be {n} or 1)."
)
return main_is_list, main_items, broadcasted_secondaries
def eval_element_wise_multivalue(
op_func: Callable[..., Any],
main_data: Any,
*secondary_inputs: Any,
is_vector: list[bool] | None = None,
input_names: list[str] | None = None,
) -> Any:
"""Evaluates op_func element-wise with support for main and secondary list inputs:
- If main_data is a list, returns a list of results.
- If main_data is a single item, returns a single result.
- Secondary inputs that are lists are matched index per index.
- Secondary inputs that are single values (or length-1 lists) are broadcast across all main elements.
- Mismatched secondary list lengths raise ValueError.
"""
is_main_list, main_items, sec_lists = prepare_indexed_inputs(
main_data,
*secondary_inputs,
is_vector=is_vector,
input_names=input_names,
)
if is_main_list:
return [
op_func(item, *(sec[i] for sec in sec_lists))
for i, item in enumerate(main_items)
]
return op_func(main_items[0], *(sec[0] for sec in sec_lists))
def eval_element_wise(op_func: Callable[[Any], Any], data: Any) -> Any:
"""If data is a list, applies op_func to each element and returns a list.
Otherwise applies op_func to data directly."""
@ -110,44 +233,11 @@ def socket_accepts_list(sock: Any, visited: set | None = None) -> bool:
if bl_idname in ("SLICENODE_StartPrint", "SLICENODE_PrinterList"):
return False
# Path Operations: only input 0 can accept a list
if bl_idname == "SLICENODE_PathOperations":
inputs = getattr(node, "inputs", None)
if inputs and sock != inputs[0]:
return False
return True
# Seam Placement: "Mesh" input rejects lists; "Paths" accepts lists
if bl_idname == "SLICENODE_SeamPlacement":
if getattr(sock, "name", "") == "Mesh":
return False
return True
# Socket type check
sock_type = getattr(sock, "bl_idname", "")
if sock_type not in LIST_CAPABLE_SOCKET_TYPES:
return False
# String sockets: only Gcode accepts lists
if sock_type == "NodeSocketString":
sock_name = getattr(sock, "name", "")
if bl_idname == "SLICENODE_JoinGcode":
return True
if bl_idname in ("SLICENODE_WriteGcode", "SLICENODE_ExecuteGcode"):
return sock_name == "Gcode"
if bl_idname == "SLICENODE_GcodeRegexReplace":
return sock_name == "Gcode"
if bl_idname == "SLICENODE_GcodesToList":
return True
if bl_idname == "NodeGroupOutput":
return sock_name.lower().startswith("gcode")
if bl_idname == "SLICENODE_Group":
inner_tree = getattr(node, "slicer_tree", None) or getattr(node, "node_tree", None)
if inner_tree:
return group_input_can_accept_list(inner_tree, sock, visited)
return False
return False
# Reroute node: check downstream targets
if bl_idname == "NodeReroute":
targets = _follow_reroute_downstream(node, visited)
@ -166,23 +256,8 @@ def socket_accepts_list(sock: Any, visited: set | None = None) -> bool:
return group_input_can_accept_list(inner_tree, sock, visited)
return False
# List joiners accept lists
if getattr(node, "is_list_output", False):
return True
# Data joiners accept lists
if bl_idname in ("SLICENODE_JoinMesh", "SLICENODE_JoinMeshGroups", "SLICENODE_JoinGcode"):
return True
# Nodes with list_socket_mapping
mapping = getattr(node, "list_socket_mapping", None)
if mapping:
mapped_in_names = {item[0] for item in mapping}
if getattr(sock, "name", "") in mapped_in_names:
return True
return False
def _get_interface_item(tree: Any, sock_or_idx_or_ident: Any, in_out: str) -> Any:
attr_name = "inputs" if in_out == 'INPUT' else "outputs"
if not hasattr(tree, "interface"):
@ -469,6 +544,9 @@ def is_output_socket_a_list(sock: Any, visited: set | None = None) -> bool:
if getattr(node, "is_list_output", False):
return True
if bl_idname in ("SLICENODE_FloatMath", "SLICENODE_IntMath"):
return any(is_socket_linked_to_list(inp) for inp in getattr(node, "inputs", []))
if bl_idname == "NodeReroute":
inputs = getattr(node, "inputs", None)
if inputs and inputs[0].is_linked and inputs[0].links:
@ -517,10 +595,13 @@ def is_socket_linked_to_list(sock: Any) -> bool:
def sync_node_list_sockets(node: Any, mapping: list[tuple[str, list[str] | str]]) -> None:
"""Updates display_shape on input and output sockets based on mapping.
Also updates display_shape for any secondary input sockets linked to a list.
mapping is a list of tuples: (input_socket_name, output_socket_names)
e.g. [('Paths', ['Paths']), ('Mesh', ['Selection', 'Inverted'])]
"""
mapped_inputs = set()
for in_name, out_names in mapping:
mapped_inputs.add(in_name)
inp = getattr(node, "inputs", None) and node.inputs.get(in_name)
if inp is None:
continue
@ -535,3 +616,8 @@ def sync_node_list_sockets(node: Any, mapping: list[tuple[str, list[str] | str]]
out_sock = getattr(node, "outputs", None) and node.outputs.get(out_name)
if out_sock is not None:
set_socket_shape(out_sock, "LIST" if is_list else "CIRCLE")
for inp in getattr(node, "inputs", []):
if getattr(inp, "name", "") not in mapped_inputs:
is_list = is_socket_linked_to_list(inp)
set_socket_shape(inp, "LIST" if is_list else "CIRCLE")

View file

@ -42,6 +42,10 @@ sys.modules['bpy.app.handlers'] = MagicMock()
from NodeSlicer.infra.blender.list_utils import (
is_list_data,
is_input_a_list,
is_vector_value,
prepare_indexed_inputs,
eval_element_wise_multivalue,
flatten_list,
eval_element_wise,
set_socket_shape,
@ -206,7 +210,7 @@ class TestTreeValidLinkRules(unittest.TestCase):
self.assertFalse(self.tree.valid_link(list_out, pl_in))
def test_valid_link_path_operations_secondary_inputs_reject_list(self):
def test_valid_link_path_operations_secondary_inputs_accept_list(self):
list_out = DummySocket("Paths", "SLICESOCK_Paths", display_shape="LIST")
path_ops = DummyNode("SLICENODE_PathOperations")
p0 = DummySocket("Paths", "SLICESOCK_Paths")
@ -216,10 +220,10 @@ class TestTreeValidLinkRules(unittest.TestCase):
# Primary input p0 accepts list
self.assertTrue(self.tree.valid_link(list_out, p0))
# Secondary input p1 rejects list
self.assertFalse(self.tree.valid_link(list_out, p1))
# Secondary input p1 also accepts list (matched index per index)
self.assertTrue(self.tree.valid_link(list_out, p1))
def test_valid_link_seam_placement_cutting_mesh_rejects_list(self):
def test_valid_link_seam_placement_cutting_mesh_accepts_list(self):
list_mesh_out = DummySocket("Mesh", "SLICESOCK_Mesh", display_shape="LIST")
seam_node = DummyNode("SLICENODE_SeamPlacement")
p_in = DummySocket("Paths", "SLICESOCK_Paths")
@ -227,8 +231,8 @@ class TestTreeValidLinkRules(unittest.TestCase):
seam_node.add_in(p_in)
seam_node.add_in(m_in)
# Secondary cutting mesh rejects list
self.assertFalse(self.tree.valid_link(list_mesh_out, m_in))
# Secondary cutting mesh accepts list (matched index per index)
self.assertTrue(self.tree.valid_link(list_mesh_out, m_in))
class TestNodeEvaluationsWithLists(unittest.TestCase):
@ -739,11 +743,11 @@ class TestImplicitGetMesh(unittest.TestCase):
# Target accepts lists -> allowed
self.assertTrue(self.tree.valid_link(list_obj_out, mesh_in))
# SeamPlacement cutting mesh rejects lists -> rejected
# SeamPlacement cutting mesh accepts lists
seam_mesh_in = DummySocket("Mesh", "SLICESOCK_Mesh")
seam_mesh_in.is_output = False
seam_mesh_in.node = DummyNode("SLICENODE_SeamPlacement")
self.assertFalse(self.tree.valid_link(list_obj_out, seam_mesh_in))
self.assertTrue(self.tree.valid_link(list_obj_out, seam_mesh_in))
def test_resolve_input_implicit_get_mesh_single_object(self):
from NodeSlicer.infra.blender.blender_slice_nodes_generic import resolve_input, execution_cache
@ -929,5 +933,276 @@ class TestImplicitGetMesh(unittest.TestCase):
self.assertEqual(set(family), {parent_obj, c1, c2, gc1})
class TestGeneralizedListArchitecture(unittest.TestCase):
def test_prepare_indexed_inputs_matching_length(self):
main = ["A", "B", "C"]
sec1 = [1, 2, 3]
sec2 = ["x", "y", "z"]
is_list, items, sec_lists = prepare_indexed_inputs(main, sec1, sec2, input_names=["sec1", "sec2"])
self.assertTrue(is_list)
self.assertEqual(items, ["A", "B", "C"])
self.assertEqual(sec_lists[0], [1, 2, 3])
self.assertEqual(sec_lists[1], ["x", "y", "z"])
def test_prepare_indexed_inputs_broadcast_single_value(self):
main = ["A", "B", "C"]
sec1 = 42
sec2 = ["single"]
is_list, items, sec_lists = prepare_indexed_inputs(main, sec1, sec2, input_names=["sec1", "sec2"])
self.assertTrue(is_list)
self.assertEqual(items, ["A", "B", "C"])
self.assertEqual(sec_lists[0], [42, 42, 42])
self.assertEqual(sec_lists[1], ["single", "single", "single"])
def test_prepare_indexed_inputs_mismatched_length_raises(self):
main = ["A", "B", "C"]
sec = [1, 2]
with self.assertRaises(ValueError) as ctx:
prepare_indexed_inputs(main, sec, input_names=["my_sec"])
self.assertIn("my_sec", str(ctx.exception))
self.assertIn("3 items", str(ctx.exception))
self.assertIn("2 items", str(ctx.exception))
def test_prepare_indexed_inputs_single_main_with_single_sec(self):
main = "A"
sec1 = 42
sec2 = [99]
is_list, items, sec_lists = prepare_indexed_inputs(main, sec1, sec2, input_names=["sec1", "sec2"])
self.assertFalse(is_list)
self.assertEqual(items, ["A"])
self.assertEqual(sec_lists[0], [42])
self.assertEqual(sec_lists[1], [99])
def test_prepare_indexed_inputs_single_main_with_multi_sec_raises(self):
main = "A"
sec = [1, 2]
with self.assertRaises(ValueError) as ctx:
prepare_indexed_inputs(main, sec, input_names=["my_sec"])
self.assertIn("single item", str(ctx.exception))
self.assertIn("2 items", str(ctx.exception))
def test_vector_input_distinction(self):
# Single vector is not a list
single_vec = (1.0, 2.0, 3.0)
self.assertTrue(is_vector_value(single_vec))
self.assertFalse(is_input_a_list(single_vec, is_vector=True))
# List of vectors IS a list
vec_list = [(1.0, 0.0, 0.0), (0.0, 1.0, 0.0)]
self.assertFalse(is_vector_value(vec_list))
self.assertTrue(is_input_a_list(vec_list, is_vector=True))
# Broadcasting single vector across main list
main = ["geom1", "geom2"]
is_list, items, (t_list,) = prepare_indexed_inputs(main, single_vec, is_vector=[True], input_names=["Translation"])
self.assertTrue(is_list)
self.assertEqual(t_list, [(1.0, 2.0, 3.0), (1.0, 2.0, 3.0)])
# Matching list of vectors index-per-index
is_list, items, (t_list,) = prepare_indexed_inputs(main, vec_list, is_vector=[True], input_names=["Translation"])
self.assertTrue(is_list)
self.assertEqual(t_list, [(1.0, 0.0, 0.0), (0.0, 1.0, 0.0)])
def test_eval_element_wise_multivalue(self):
op = lambda item, a, b: f"{item}_{a}_{b}"
res = eval_element_wise_multivalue(op, ["x", "y"], [1, 2], "const")
self.assertEqual(res, ["x_1_const", "y_2_const"])
single_res = eval_element_wise_multivalue(op, "x", [1], "const")
self.assertEqual(single_res, "x_1_const")
def test_math_node_list_broadcasting_and_matching(self):
from NodeSlicer.core.nodeslicer.nodes.math import SLICENODE_FloatMath
node = SLICENODE_FloatMath()
in0 = DummySocket("Value", "NodeSocketFloat")
in1 = DummySocket("Value", "NodeSocketFloat")
node.inputs = DummySocketCollection([in0, in1])
node.operation = 'ADD'
# Case 1: A is list, B is scalar
node.resolve = lambda label: [1.0, 2.0, 3.0] if label == node.inputs[0] else 10.0
self.assertEqual(node.evaluate_output("Value"), [11.0, 12.0, 13.0])
# Case 2: A is scalar, B is list
node.resolve = lambda label: 10.0 if label == node.inputs[0] else [1.0, 2.0, 3.0]
self.assertEqual(node.evaluate_output("Value"), [11.0, 12.0, 13.0])
# Case 3: A and B are lists of equal length
node.resolve = lambda label: [1.0, 2.0, 3.0] if label == node.inputs[0] else [10.0, 20.0, 30.0]
self.assertEqual(node.evaluate_output("Value"), [11.0, 22.0, 33.0])
# Case 4: Mismatched list lengths raise ValueError
node.resolve = lambda label: [1.0, 2.0] if label == node.inputs[0] else [10.0, 20.0, 30.0]
with self.assertRaises(ValueError):
node.evaluate_output("Value")
def test_transform_geometry_with_get_transforms_list(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_GetTransforms, SLICENODE_TransformGeometry
# Create two mock slicing collections with transforms
c1 = SlicingCollection([], "Part1")
c1.transforms = [{"type": "INIT", "translation": (10.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}]
c2 = SlicingCollection([], "Part2")
c2.transforms = [{"type": "INIT", "translation": (20.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (2.0, 2.0, 2.0)}]
# Get Transforms on list of geometries
get_t_node = SLICENODE_GetTransforms()
get_t_node.resolve = lambda label: [c1, c2]
t_list = get_t_node.evaluate_output("Translation")
r_list = get_t_node.evaluate_output("Rotation")
s_list = get_t_node.evaluate_output("Scale")
self.assertEqual(len(t_list), 2)
self.assertEqual(len(r_list), 2)
self.assertEqual(len(s_list), 2)
self.assertEqual(t_list[0], (10.0, 0.0, 0.0))
self.assertEqual(t_list[1], (20.0, 0.0, 0.0))
# Pass those into Transform Geometry
tf_node = SLICENODE_TransformGeometry()
tf_node.resolve = lambda label: {
"Geometry": [c1, c2],
"Translation": t_list,
"Rotation": r_list,
"Scale": s_list,
}.get(label)
res = tf_node.evaluate_output("Geometry")
self.assertIsInstance(res, list)
self.assertEqual(len(res), 2)
# Mismatched translation list length raises ValueError
tf_node.resolve = lambda label: {
"Geometry": [c1, c2],
"Translation": [(1.0, 0.0, 0.0)], # length 1 broadcasts
"Rotation": (0.0, 0.0, 0.0),
"Scale": (1.0, 1.0, 1.0),
}.get(label)
res_broadcast = tf_node.evaluate_output("Geometry")
self.assertEqual(len(res_broadcast), 2)
tf_node.resolve = lambda label: {
"Geometry": [c1, c2],
"Translation": [(1.0, 0.0, 0.0), (2.0, 0.0, 0.0), (3.0, 0.0, 0.0)], # length 3 != 2
"Rotation": (0.0, 0.0, 0.0),
"Scale": (1.0, 1.0, 1.0),
}.get(label)
with self.assertRaises(ValueError):
tf_node.evaluate_output("Geometry")
def test_drop_geometry_list_matching(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_DropGeometry
so1 = SlicingObject()
so1.name = "Part1"
so1.mesh = np.zeros((1, 4, 3), dtype=np.float64)
so1.mesh[:, :3, 2] = 5.0
so2 = SlicingObject()
so2.name = "Part2"
so2.mesh = np.zeros((1, 4, 3), dtype=np.float64)
so2.mesh[:, :3, 2] = 10.0
c1 = SlicingCollection([], "Part1")
c1.objects = [so1]
c2 = SlicingCollection([], "Part2")
c2.objects = [so2]
node = SLICENODE_DropGeometry()
# Case 1: list of meshes + list of Z matching 1-to-1
node.resolve = lambda label: [c1, c2] if label == "Mesh" else [0.0, 2.0]
res = node.evaluate_output("Mesh")
self.assertIsInstance(res, list)
self.assertEqual(len(res), 2)
trans = node.evaluate_output("Translation")
self.assertEqual(trans, [(0.0, 0.0, -5.0), (0.0, 0.0, -8.0)])
# Case 2: list of meshes + single Z (broadcast)
node.resolve = lambda label: [c1, c2] if label == "Mesh" else 1.0
trans_broadcast = node.evaluate_output("Translation")
self.assertEqual(trans_broadcast, [(0.0, 0.0, -4.0), (0.0, 0.0, -9.0)])
# Case 3: list of meshes + list of 1 Z (broadcast)
node.resolve = lambda label: [c1, c2] if label == "Mesh" else [1.0]
trans_broadcast_list1 = node.evaluate_output("Translation")
self.assertEqual(trans_broadcast_list1, [(0.0, 0.0, -4.0), (0.0, 0.0, -9.0)])
# Case 4: length mismatch raises ValueError
node.resolve = lambda label: [c1, c2] if label == "Mesh" else [1.0, 2.0, 3.0]
with self.assertRaises(ValueError):
node.evaluate_output("Mesh")
# Case 5: single mesh + list of multiple Z raises ValueError
node.resolve = lambda label: c1 if label == "Mesh" else [1.0, 2.0]
with self.assertRaises(ValueError):
node.evaluate_output("Mesh")
def test_rename_mesh_list_matching(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_RenameMesh
c1 = SlicingCollection([], "Old1")
c2 = SlicingCollection([], "Old2")
node = SLICENODE_RenameMesh()
# Case 1: list of meshes + list of names matching 1-to-1
node.resolve = lambda label: [c1, c2] if label == "Geometry" else ["New1", "New2"]
res = node.evaluate_output("Geometry")
self.assertEqual([r.name for r in res], ["New1", "New2"])
# Case 2: list of meshes + single name (broadcast)
node.resolve = lambda label: [c1, c2] if label == "Geometry" else "SameName"
res = node.evaluate_output("Geometry")
self.assertEqual([r.name for r in res], ["SameName", "SameName"])
# Case 3: length mismatch raises ValueError
node.resolve = lambda label: [c1, c2] if label == "Geometry" else ["A", "B", "C"]
with self.assertRaises(ValueError):
node.evaluate_output("Geometry")
# Case 4: single mesh + multiple names raises ValueError
node.resolve = lambda label: c1 if label == "Geometry" else ["A", "B"]
with self.assertRaises(ValueError):
node.evaluate_output("Geometry")
def test_path_operations_list_matching(self):
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOperations
p1 = SlicePaths([np.array([[0, 0, 0], [10, 0, 0]], dtype=np.int32)])
p2 = SlicePaths([np.array([[0, 10, 0], [10, 10, 0]], dtype=np.int32)])
p3 = SlicePaths([np.array([[0, 20, 0], [10, 20, 0]], dtype=np.int32)])
node = SLICENODE_PathOperations()
in0 = DummySocket("Paths", "SLICESOCK_Paths")
in1 = DummySocket("Paths", "SLICESOCK_Paths")
node.inputs = DummySocketCollection([in0, in1])
in0.is_linked = True
in1.is_linked = True
from unittest.mock import patch
# Case 1: list of Paths A + list of Paths B matching 1-to-1
with patch("NodeSlicer.infra.blender.blender_slice_nodes_generic.resolve_input", side_effect=[[p1, p2], [p3, p3]]):
res = node.evaluate_output("Paths")
self.assertIsInstance(res, list)
self.assertEqual(len(res), 2)
# Case 2: list of Paths A + single Path B (broadcast)
with patch("NodeSlicer.infra.blender.blender_slice_nodes_generic.resolve_input", side_effect=[[p1, p2], p3]):
res = node.evaluate_output("Paths")
self.assertIsInstance(res, list)
self.assertEqual(len(res), 2)
# Case 3: length mismatch raises ValueError
with patch("NodeSlicer.infra.blender.blender_slice_nodes_generic.resolve_input", side_effect=[[p1, p2], [p3, p3, p3]]):
with self.assertRaises(ValueError):
node.evaluate_output("Paths")
# Case 4: single Path A + multiple Paths B raises ValueError
with patch("NodeSlicer.infra.blender.blender_slice_nodes_generic.resolve_input", side_effect=[p1, [p2, p3]]):
with self.assertRaises(ValueError):
node.evaluate_output("Paths")
if __name__ == '__main__':
unittest.main()

View file

@ -641,6 +641,242 @@ class TestTransform(unittest.TestCase):
self.assertEqual(len(so_merged.transforms), 1)
self.assertEqual(so_merged.transforms[0]["translation"], (10.0, 0.0, 0.0))
def test_get_transforms_node(self):
"""Tests SLICENODE_GetTransforms node evaluation with various geometry inputs."""
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_GetTransforms, SLICENODE_TransformGeometry, SLICENODE_DropGeometry
from NodeSlicer.core.slice import SlicePaths
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection, SlicingGroup
node = SLICENODE_GetTransforms()
self.assertEqual(node.list_socket_mapping, [("Geometry", ["Translation", "Rotation", "Scale"])])
# 1. None input
node.resolve = lambda s: None
self.assertEqual(node.evaluate_output("Translation"), (0.0, 0.0, 0.0))
self.assertEqual(node.evaluate_output("Rotation"), (0.0, 0.0, 0.0))
self.assertEqual(node.evaluate_output("Scale"), (1.0, 1.0, 1.0))
# 2. SlicePaths with transforms
p = np.array([[0, 0, 0], [10, 0, 0], [10, 10, 0], [0, 0, 0]], dtype=np.float64)
sp = SlicePaths([p], transforms=[{
"type": "transform",
"translation": (12.0, 34.0, 56.0),
"rotation": (0.1, 0.2, 0.3),
"scale": (2.0, 3.0, 4.0),
}])
node.resolve = lambda s: sp
self.assertEqual(node.evaluate_output("Translation"), (12.0, 34.0, 56.0))
self.assertEqual(node.evaluate_output("Rotation"), (0.1, 0.2, 0.3))
self.assertEqual(node.evaluate_output("Scale"), (2.0, 3.0, 4.0))
# 3. SlicePaths with NO transforms (default output)
sp_plain = SlicePaths([p])
node.resolve = lambda s: sp_plain
self.assertEqual(node.evaluate_output("Translation"), (0.0, 0.0, 0.0))
self.assertEqual(node.evaluate_output("Rotation"), (0.0, 0.0, 0.0))
self.assertEqual(node.evaluate_output("Scale"), (1.0, 1.0, 1.0))
# 4. SlicingObject with transforms
so = SlicingObject()
so.transforms = [{
"type": "transform",
"translation": (1.0, 2.0, 3.0),
"rotation": (0.0, 0.0, np.pi / 4.0),
"scale": (0.5, 0.5, 1.0),
}]
node.resolve = lambda s: so
self.assertEqual(node.evaluate_output("Translation"), (1.0, 2.0, 3.0))
self.assertEqual(node.evaluate_output("Rotation"), (0.0, 0.0, np.pi / 4.0))
self.assertEqual(node.evaluate_output("Scale"), (0.5, 0.5, 1.0))
# 5. SlicingCollection
sc = SlicingCollection([], "test_coll")
sc.transforms = [{
"type": "transform",
"translation": (5.0, -5.0, 10.0),
"rotation": (0.0, np.pi / 2.0, 0.0),
"scale": (1.0, 1.0, 1.0),
}]
node.resolve = lambda s: sc
self.assertEqual(node.evaluate_output("Translation"), (5.0, -5.0, 10.0))
self.assertEqual(node.evaluate_output("Rotation"), (0.0, np.pi / 2.0, 0.0))
self.assertEqual(node.evaluate_output("Scale"), (1.0, 1.0, 1.0))
# 6. SlicingGroup
sg = SlicingGroup([])
sg.transforms = [{
"type": "transform",
"translation": (100.0, 200.0, 300.0),
"rotation": (0.0, 0.0, 0.0),
"scale": (2.0, 2.0, 2.0),
}]
node.resolve = lambda s: sg
self.assertEqual(node.evaluate_output("Translation"), (100.0, 200.0, 300.0))
self.assertEqual(node.evaluate_output("Scale"), (2.0, 2.0, 2.0))
# 7. Chained with Transform Geometry
t_node = SLICENODE_TransformGeometry()
t_node.resolve = lambda f: {
"Geometry": sp_plain,
"Translation": (7.0, 8.0, 9.0),
"Rotation": (0.0, 0.0, 1.5),
"Scale": (1.5, 2.5, 3.5),
}.get(f)
transformed_geom = t_node.evaluate_output("Geometry")
node.resolve = lambda s: transformed_geom
self.assertEqual(node.evaluate_output("Translation"), (7.0, 8.0, 9.0))
self.assertEqual(node.evaluate_output("Rotation"), (0.0, 0.0, 1.5))
self.assertEqual(node.evaluate_output("Scale"), (1.5, 2.5, 3.5))
# 8. List of geometries (element-wise list processing)
list_geoms = [sp, so]
node.resolve = lambda s: list_geoms
self.assertEqual(node.evaluate_output("Translation"), [(12.0, 34.0, 56.0), (1.0, 2.0, 3.0)])
self.assertEqual(node.evaluate_output("Rotation"), [(0.1, 0.2, 0.3), (0.0, 0.0, np.pi / 4.0)])
self.assertEqual(node.evaluate_output("Scale"), [(2.0, 3.0, 4.0), (0.5, 0.5, 1.0)])
# 9. Blender Object fallback
class MockBpyObject:
location = (11.0, 22.0, 33.0)
rotation_euler = (0.4, 0.5, 0.6)
scale = (1.1, 2.2, 3.3)
node.resolve = lambda s: MockBpyObject()
self.assertEqual(node.evaluate_output("Translation"), (11.0, 22.0, 33.0))
self.assertEqual(node.evaluate_output("Rotation"), (0.4, 0.5, 0.6))
self.assertEqual(node.evaluate_output("Scale"), (1.1, 2.2, 3.3))
def test_path_operations_acquire_transforms(self):
"""Tests Acquire Transforms checkbox and behavior on SLICENODE_PathOperations."""
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOperations
from NodeSlicer.core.slice import SlicePaths
node = SLICENODE_PathOperations()
self.assertTrue(node.acquire_transforms)
# Primary square path from (0, 0) to (20, 20) shifted by +50 in X and +30 in Y
sq_coords = np.array([[0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [20.0, 20.0, 0.0], [0.0, 20.0, 0.0], [0.0, 0.0, 0.0]])
shifted_sq = sq_coords + np.array([50.0, 30.0, 0.0])
sp_primary = SlicePaths([shifted_sq], transforms=[{
"type": "transform",
"translation": (50.0, 30.0, 0.0),
"rotation": (0.0, 0.0, 0.0),
"scale": (1.0, 1.0, 1.0)
}])
# Secondary square path from (5, 5) to (15, 15) with NO transforms
cutter_coords = np.array([[5.0, 5.0, 0.0], [15.0, 5.0, 0.0], [15.0, 15.0, 0.0], [5.0, 15.0, 0.0], [5.0, 5.0, 0.0]])
sp_secondary = SlicePaths([cutter_coords])
# Mock sockets
sock0 = MagicMock()
sock0.is_linked = True
sock1 = MagicMock()
sock1.is_linked = True
node.inputs = [sock0, sock1]
def mock_resolve_input(sock):
if sock == sock0:
return sp_primary
elif sock == sock1:
return sp_secondary
return None
import NodeSlicer.infra.blender.blender_slice_nodes_generic as generic_mod
orig_resolve = generic_mod.resolve_input
generic_mod.resolve_input = mock_resolve_input
try:
# 1. INTERSECTION with acquire_transforms=True (default)
node.operation = 'INTERSECTION'
node.acquire_transforms = True
res_inter_acquired = node.evaluate_output("Paths")
self.assertEqual(len(res_inter_acquired), 1)
# Cutter should have shifted to (55, 35) to (65, 45), intersecting properly!
pts = res_inter_acquired[0]
self.assertAlmostEqual(float(np.min(pts[:, 0])), 55.0, places=2)
self.assertAlmostEqual(float(np.max(pts[:, 0])), 65.0, places=2)
self.assertAlmostEqual(float(np.min(pts[:, 1])), 35.0, places=2)
self.assertAlmostEqual(float(np.max(pts[:, 1])), 45.0, places=2)
# 2. INTERSECTION with acquire_transforms=False
node.acquire_transforms = False
res_inter_no_acquire = node.evaluate_output("Paths")
# Without acquiring transforms, cutter remains at (5, 5)-(15, 15), no overlap with (50, 30)-(70, 50)!
self.assertEqual(len(res_inter_no_acquire), 0)
# 3. SUBTRACT with acquire_transforms=True
node.operation = 'SUBTRACT'
node.acquire_transforms = True
res_sub_acquired = node.evaluate_output("Paths")
# Subtracting the cutter from the primary square creates outer boundary + interior hole = 2 paths!
self.assertEqual(len(res_sub_acquired), 2)
# 4. SUBTRACT with acquire_transforms=False
node.operation = 'SUBTRACT'
node.acquire_transforms = False
res_sub_no_acquire = node.evaluate_output("Paths")
# Without acquiring transforms, cutter does not overlap primary square, primary remains untouched (1 path)
self.assertEqual(len(res_sub_no_acquire), 1)
# 5. ZIP with acquire_transforms=True aligns subsequent paths
node.operation = 'ZIP'
node.acquire_transforms = True
res_zip_acquired = node.evaluate_output("Paths")
self.assertEqual(len(res_zip_acquired), 2)
# Second path in zip result should be at (55, 35)-(65, 45)
pts_zip2 = res_zip_acquired[1]
self.assertAlmostEqual(float(np.min(pts_zip2[:, 0])), 55.0, places=2)
self.assertAlmostEqual(float(np.max(pts_zip2[:, 0])), 65.0, places=2)
# 6. Both already have the same transforms -> no double transformation
sp_secondary_transformed = SlicePaths([cutter_coords + np.array([50.0, 30.0, 0.0])], transforms=[{
"type": "transform",
"translation": (50.0, 30.0, 0.0),
"rotation": (0.0, 0.0, 0.0),
"scale": (1.0, 1.0, 1.0)
}])
def mock_resolve_both(sock):
if sock == sock0:
return sp_primary
return sp_secondary_transformed
generic_mod.resolve_input = mock_resolve_both
node.operation = 'INTERSECTION'
node.acquire_transforms = True
res_both = node.evaluate_output("Paths")
self.assertEqual(len(res_both), 1)
pts_both = res_both[0]
self.assertAlmostEqual(float(np.min(pts_both[:, 0])), 55.0, places=2)
self.assertAlmostEqual(float(np.max(pts_both[:, 0])), 65.0, places=2)
# 7. Element-wise list behavior: primary is list [sp_primary, sp_primary2]
sp_primary2 = SlicePaths([sq_coords + np.array([100.0, 100.0, 0.0])], transforms=[{
"type": "transform",
"translation": (100.0, 100.0, 0.0),
"rotation": (0.0, 0.0, 0.0),
"scale": (1.0, 1.0, 1.0)
}])
def mock_resolve_list(sock):
if sock == sock0:
return [sp_primary, sp_primary2]
return sp_secondary
generic_mod.resolve_input = mock_resolve_list
node.operation = 'INTERSECTION'
node.acquire_transforms = True
res_list = node.evaluate_output("Paths")
self.assertIsInstance(res_list, list)
self.assertEqual(len(res_list), 2)
# Item 0 cutter shifted by (50, 30): min X=55
self.assertAlmostEqual(float(np.min(res_list[0][0][:, 0])), 55.0, places=2)
# Item 1 cutter shifted by (100, 100): min X=105
self.assertAlmostEqual(float(np.min(res_list[1][0][:, 0])), 105.0, places=2)
finally:
generic_mod.resolve_input = orig_resolve
if __name__ == '__main__':
unittest.main()