From e540c85e59db42dcc14d9cb8b52c2f96e0d7f88b Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Wed, 14 Oct 2026 13:17:23 +0300 Subject: [PATCH] expand lists compatibility --- core/nodeslicer/nodes/base.py | 47 ++++- core/nodeslicer/nodes/gcode.py | 78 ++++++--- core/nodeslicer/nodes/mesh.py | 226 ++++++++++++++++++------ core/nodeslicer/nodes/output.py | 16 +- core/nodeslicer/nodes/path.py | 289 +++++++++++++++++++++--------- core/nodeslicer/nodes/preview.py | 14 +- core/nodeslicer/tree.py | 9 +- core/slice.py | 169 +++++++++++++++++- doc/MODULES.md | 8 +- doc/NODES.md | 13 ++ doc/nodes_list_behavior.csv | 47 ++--- infra/blender/list_utils.py | 194 +++++++++++++++------ tests/test_lists.py | 291 ++++++++++++++++++++++++++++++- tests/test_transform.py | 236 +++++++++++++++++++++++++ 14 files changed, 1366 insertions(+), 271 deletions(-) diff --git a/core/nodeslicer/nodes/base.py b/core/nodeslicer/nodes/base.py index 3b036d5..a7d1373 100644 --- a/core/nodeslicer/nodes/base.py +++ b/core/nodeslicer/nodes/base.py @@ -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 = "" diff --git a/core/nodeslicer/nodes/gcode.py b/core/nodeslicer/nodes/gcode.py index a16b83e..ed5f63c 100644 --- a/core/nodeslicer/nodes/gcode.py +++ b/core/nodeslicer/nodes/gcode.py @@ -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,18 +87,28 @@ 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") - 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}') - return '\n'.join(lines) + + def _meta_one(n: Any, f: Any, p: Any) -> str: + lines = [] + 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): @@ -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 @@ -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): diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index 574b08a..4b0c996 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -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))) - else: - props.append(("", str(item))) + if not _is_single_settings_spec(raw_settings): + settings_arg = list(raw_settings) + else: + 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"] + ) diff --git a/core/nodeslicer/nodes/output.py b/core/nodeslicer/nodes/output.py index 1f65471..3167d2f 100644 --- a/core/nodeslicer/nodes/output.py +++ b/core/nodeslicer/nodes/output.py @@ -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}') diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 505755a..28c3e0e 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -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"] + ) diff --git a/core/nodeslicer/nodes/preview.py b/core/nodeslicer/nodes/preview.py index b578cfa..8facc79 100644 --- a/core/nodeslicer/nodes/preview.py +++ b/core/nodeslicer/nodes/preview.py @@ -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 diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 629ec7f..8a113c3 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -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" diff --git a/core/slice.py b/core/slice.py index 654f8a5..b844bdd 100644 --- a/core/slice.py +++ b/core/slice.py @@ -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) diff --git a/doc/MODULES.md b/doc/MODULES.md index b1abb79..c288e87 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -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`). diff --git a/doc/NODES.md b/doc/NODES.md index ab27ce9..c17beaa 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -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.). diff --git a/doc/nodes_list_behavior.csv b/doc/nodes_list_behavior.csv index 9ab2316..98c569d 100644 --- a/doc/nodes_list_behavior.csv +++ b/doc/nodes_list_behavior.csv @@ -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." diff --git a/infra/blender/list_utils.py b/infra/blender/list_utils.py index 910807b..ce725ad 100644 --- a/infra/blender/list_utils.py +++ b/infra/blender/list_utils.py @@ -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,22 +256,7 @@ 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 + return True 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" @@ -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") diff --git a/tests/test_lists.py b/tests/test_lists.py index 6a0da81..7229985 100644 --- a/tests/test_lists.py +++ b/tests/test_lists.py @@ -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() diff --git a/tests/test_transform.py b/tests/test_transform.py index 7573d32..69e9785 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -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()