diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index ce339bf..0f76eae 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -575,6 +575,7 @@ class SLICENODE_TransformGeometry(SliceNode): _transform_one, main_socket="Geometry", secondary_sockets=["Translation", "Rotation", "Scale"], + is_vector=[True, True, True], socket_name=socket_name, ) diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 26fc9d8..a78904c 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -311,6 +311,7 @@ class SLICENODE_SliceShells(SliceNode): _slice_one, main_socket="Mesh", secondary_sockets=["Layer Height", "First Layer Height", "Rotation"], + is_vector=[False, False, True], socket_name=socket_name, default=SlicePaths(), ) @@ -394,6 +395,7 @@ class SLICENODE_BufferShells(SliceNode): _buf, main_socket="Paths", secondary_sockets=["Distance", "Rotation"], + is_vector=[False, True], socket_name=socket_name, default=SlicePaths(), ) @@ -456,6 +458,7 @@ class SLICENODE_BufferCurve(SliceNode): _buf, main_socket="Paths", secondary_sockets=["Distance", "Rotation"], + is_vector=[False, True], socket_name=socket_name, default=SlicePaths(), ) @@ -498,6 +501,7 @@ class SLICENODE_OffsetCurve(SliceNode): _off, main_socket="Paths", secondary_sockets=["Distance", "Rotation"], + is_vector=[False, True], socket_name=socket_name, default=SlicePaths(), ) @@ -521,6 +525,7 @@ class SLICENODE_PathOffset(SliceNode): _off, main_socket="Paths", secondary_sockets=["Offset"], + is_vector=[True], socket_name=socket_name, default=SlicePaths(), ) @@ -546,6 +551,7 @@ class SLICENODE_RotatePath(SliceNode): _rot, main_socket="Paths", secondary_sockets=["Angle", "Center"], + is_vector=[False, True], socket_name=socket_name, default=SlicePaths(), ) @@ -599,6 +605,7 @@ class SLICENODE_BufferShellsMultiple(SliceNode): _buf_multi, main_socket="Paths", secondary_sockets=["Distance", "Width Step", "Count", "Rotation"], + is_vector=[False, False, False, True], socket_name=socket_name, default=SlicePaths(), ) @@ -644,6 +651,7 @@ class SLICENODE_Infill(SliceNode): _inf, main_socket="Paths", secondary_sockets=["Density", "Infill Rotation", "Layer Rotation"], + is_vector=[False, False, True], socket_name=socket_name, default=SlicePaths(), ) diff --git a/infra/blender/list_utils.py b/infra/blender/list_utils.py index be9266a..04b7242 100644 --- a/infra/blender/list_utils.py +++ b/infra/blender/list_utils.py @@ -20,27 +20,36 @@ LIST_CAPABLE_SOCKET_TYPES = { } def is_vector_value(val: Any) -> bool: - """Checks if val represents a single 3D/4D vector, Euler rotation, or color, + """Checks if val represents a single 2D/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"): + if val_type in ("Vector", "Euler", "Quaternion", "Color", "Matrix") or "bpy_prop_array" in val_type: return True if isinstance(val, np.ndarray): - if val.ndim == 1 and val.shape[0] in (3, 4): + if val.ndim == 1 and val.shape[0] in (2, 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 + if hasattr(val, "__len__") and not isinstance(val, (str, bytes, dict, set)): + try: + length = len(val) + if length in (2, 3, 4): + if all(isinstance(x, (int, float, np.number)) and not isinstance(x, bool) for x in val): + return True + except Exception: + pass return False def _is_sequence_collection(val: Any) -> bool: if isinstance(val, (list, tuple)): return True + if isinstance(val, np.ndarray): + return val.ndim >= 2 if hasattr(val, "__iter__") and not hasattr(val, "shape") and not isinstance(val, (str, bytes, dict, set)): cls_name = type(val).__name__ + if "bpy_prop_array" in cls_name: + return False if "bpy_prop" in cls_name or "Collection" in cls_name or hasattr(val, "foreach_get"): return True import collections.abc @@ -55,6 +64,9 @@ def is_input_a_list(val: Any, is_vector: bool = False) -> bool: return False if type(val).__name__ == "SlicePaths" or getattr(val, "is_slice_paths", False): return False + val_type = type(val).__name__ + if val_type in ("Vector", "Euler", "Quaternion", "Color", "Matrix") or "bpy_prop_array" in val_type: + return False if is_vector: if is_vector_value(val): return False @@ -87,6 +99,7 @@ def prepare_indexed_inputs( is_vector: list[bool] | None = None, input_names: list[str] | None = None, is_atomic: list[bool] | None = None, + main_is_vector: bool = False, ) -> 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. @@ -99,7 +112,7 @@ def prepare_indexed_inputs( Returns: (is_main_list, main_items, broadcasted_secondary_lists) """ - main_is_list = is_input_a_list(main_data, is_vector=False) + main_is_list = is_input_a_list(main_data, is_vector=main_is_vector) main_items = list(main_data) if main_is_list else [main_data] n = len(main_items) @@ -113,6 +126,13 @@ def prepare_indexed_inputs( is_atom = atom_flags[i] if i < len(atom_flags) else False name = names[i] if i < len(names) else f"input_{i+1}" + if not is_vec and name.lower() in ("translation", "rotation", "scale", "offset", "center", "layer rotation", "color"): + is_vec = True + + if is_vec and is_vector_value(sec_val): + broadcasted_secondaries.append([sec_val] * n) + continue + if is_atom: is_nested_list = ( isinstance(sec_val, (list, tuple)) @@ -160,6 +180,7 @@ def eval_element_wise_multivalue( input_names: list[str] | None = None, output_index: int | None = None, is_atomic: list[bool] | None = None, + main_is_vector: bool = False, ) -> 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. @@ -175,6 +196,7 @@ def eval_element_wise_multivalue( is_vector=is_vector, input_names=input_names, is_atomic=is_atomic, + main_is_vector=main_is_vector, ) def _call(item: Any, i: int) -> Any: @@ -192,24 +214,32 @@ def eval_element_wise_multivalue( def is_socket_vector_type(node: Any, socket_name: str) -> bool: """Checks whether the socket on the node is a vector/rotation/color socket.""" + name_lower = socket_name.lower() + if name_lower in ("translation", "rotation", "scale", "offset", "center", "layer rotation", "color"): + return True inputs = getattr(node, "inputs", None) if inputs: sock = None if hasattr(inputs, "get"): sock = inputs.get(socket_name) - elif isinstance(inputs, (list, tuple)): - for s in inputs: - if getattr(s, "name", "") == socket_name: - sock = s - break + if sock is None: + try: + for s in inputs: + if getattr(s, "name", "").lower() == name_lower: + sock = s + break + except Exception: + pass if sock is not None: + sock_type = getattr(sock, "type", "") + if sock_type in ("VECTOR", "ROTATION", "RGBA"): + return True bl_id = getattr(sock, "bl_idname", "") - if bl_id in ("NodeSocketVector", "NodeSocketRotation", "NodeSocketColor", "NodeSocketVectorXYZ", "NodeSocketVectorEuler"): + if any(term in bl_id for term in ("Vector", "Rotation", "Color", "Euler")): return True if bl_id in ("NodeSocketFloat", "NodeSocketInt", "NodeSocketString", "NodeSocketBool", "SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Settings"): return False - name_lower = socket_name.lower() - return name_lower in ("translation", "rotation", "scale", "offset", "center", "layer rotation", "color") + return False def eval_multivalue( node: Any, @@ -273,7 +303,13 @@ def eval_multivalue( if is_vector is None: vec_flags = [is_socket_vector_type(node, sname) for sname in secondary_sockets] else: - vec_flags = is_vector + vec_flags = list(is_vector) + for idx, sname in enumerate(secondary_sockets): + if idx < len(vec_flags) and not vec_flags[idx]: + if is_socket_vector_type(node, sname): + vec_flags[idx] = True + + main_is_vec = is_socket_vector_type(node, main_socket) if main_socket else False try: return eval_element_wise_multivalue( @@ -284,6 +320,7 @@ def eval_multivalue( input_names=secondary_sockets, output_index=output_index, is_atomic=is_atomic, + main_is_vector=main_is_vec, ) except Exception as e: from .blender_slice_nodes_generic import attach_node_failure_info diff --git a/tests/test_lists.py b/tests/test_lists.py index 789c121..4596bfa 100644 --- a/tests/test_lists.py +++ b/tests/test_lists.py @@ -852,27 +852,191 @@ class TestGeneralizedListArchitecture(unittest.TestCase): 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)) + class MockBpyPropArray: + def __init__(self, items): + self._items = list(items) + def __iter__(self): + return iter(self._items) + def __len__(self): + return len(self._items) + def __getitem__(self, idx): + return self._items[idx] + MockBpyPropArray.__name__ = "bpy_prop_array" + + # Single vector variants (tuple, list, np.ndarray, MockBpyPropArray) + vec_tuple = (1.0, 2.0, 3.0) + vec_list = [1.0, 2.0, 3.0] + vec_np = np.array([1.0, 2.0, 3.0]) + vec_bpy = MockBpyPropArray([1.0, 2.0, 3.0]) + + for v in (vec_tuple, vec_list, vec_np, vec_bpy): + self.assertTrue(is_vector_value(v), f"Failed for {type(v).__name__}") + self.assertFalse(is_input_a_list(v, is_vector=True), f"Failed for {type(v).__name__}") # 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)) + list_of_vecs = [(1.0, 0.0, 0.0), (0.0, 1.0, 0.0)] + self.assertFalse(is_vector_value(list_of_vecs)) + self.assertTrue(is_input_a_list(list_of_vecs, 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)]) + for v in (vec_tuple, vec_list, vec_np, vec_bpy): + is_list, items, (t_list,) = prepare_indexed_inputs(main, v, is_vector=[True], input_names=["Translation"]) + self.assertTrue(is_list) + self.assertEqual(len(t_list), 2) + self.assertEqual(list(t_list[0]), [1.0, 2.0, 3.0]) + self.assertEqual(list(t_list[1]), [1.0, 2.0, 3.0]) + + # Single main geometry with single atomic vector must NOT be treated as a list of 3 items + single_main = "geom1" + for v in (vec_tuple, vec_list, vec_np, vec_bpy): + is_list, items, (t_list,) = prepare_indexed_inputs(single_main, v, is_vector=[True], input_names=["Translation"]) + self.assertFalse(is_list) + self.assertEqual(len(t_list), 1) + self.assertEqual(list(t_list[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"]) + is_list, items, (t_list,) = prepare_indexed_inputs(main, list_of_vecs, 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_transform_geometry_atomic_vector_inputs(self): + from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry + + class MockBpyPropArray: + def __init__(self, items): + self._items = list(items) + def __iter__(self): + return iter(self._items) + def __len__(self): + return len(self._items) + def __getitem__(self, idx): + return self._items[idx] + MockBpyPropArray.__name__ = "bpy_prop_array" + + so1 = SlicingObject() + so1.name = "Part1" + so1.mesh = np.zeros((1, 4, 3), dtype=np.float64) + c1 = SlicingCollection([so1], "Col1") + + so2 = SlicingObject() + so2.name = "Part2" + so2.mesh = np.zeros((1, 4, 3), dtype=np.float64) + c2 = SlicingCollection([so2], "Col2") + + tf_node = SLICENODE_TransformGeometry() + + # 1. Single geometry + atomic tuple translation + tf_node.resolve = lambda label: { + "Geometry": c1, + "Translation": (10.0, 20.0, 30.0), + "Rotation": (0.0, 0.0, 0.0), + "Scale": (1.0, 1.0, 1.0), + }.get(label) + res = tf_node.evaluate_output("Geometry") + self.assertNotIsInstance(res, list) + self.assertIsInstance(res, SlicingCollection) + self.assertEqual(res.transforms[-1]["translation"], (10.0, 20.0, 30.0)) + + # 2. Single geometry + atomic list translation [10, 20, 30] (NOT a list of 3 items!) + tf_node.resolve = lambda label: { + "Geometry": c1, + "Translation": [10.0, 20.0, 30.0], + "Rotation": [0.0, 0.0, 0.0], + "Scale": [1.0, 1.0, 1.0], + }.get(label) + res = tf_node.evaluate_output("Geometry") + self.assertNotIsInstance(res, list) + self.assertIsInstance(res, SlicingCollection) + self.assertEqual(tuple(res.transforms[-1]["translation"]), (10.0, 20.0, 30.0)) + + # 3. Single geometry + mock bpy_prop_array (unlinked socket default value in Blender) + tf_node.resolve = lambda label: { + "Geometry": c1, + "Translation": MockBpyPropArray([10.0, 20.0, 30.0]), + "Rotation": MockBpyPropArray([0.0, 0.0, 0.0]), + "Scale": MockBpyPropArray([1.0, 1.0, 1.0]), + }.get(label) + res = tf_node.evaluate_output("Geometry") + self.assertNotIsInstance(res, list) + self.assertIsInstance(res, SlicingCollection) + self.assertEqual(tuple(res.transforms[-1]["translation"]), (10.0, 20.0, 30.0)) + + # 4. Multiple geometries + atomic translation (broadcasted) + tf_node.resolve = lambda label: { + "Geometry": [c1, c2], + "Translation": (10.0, 20.0, 30.0), + "Rotation": (0.0, 0.0, 0.0), + "Scale": (1.0, 1.0, 1.0), + }.get(label) + res_multi = tf_node.evaluate_output("Geometry") + self.assertIsInstance(res_multi, list) + self.assertEqual(len(res_multi), 2) + self.assertEqual(res_multi[0].transforms[-1]["translation"], (10.0, 20.0, 30.0)) + self.assertEqual(res_multi[1].transforms[-1]["translation"], (10.0, 20.0, 30.0)) + + # 5. Multiple geometries + list of 2 translations + tf_node.resolve = lambda label: { + "Geometry": [c1, c2], + "Translation": [(10.0, 0.0, 0.0), (20.0, 0.0, 0.0)], + "Rotation": (0.0, 0.0, 0.0), + "Scale": (1.0, 1.0, 1.0), + }.get(label) + res_matched = tf_node.evaluate_output("Geometry") + self.assertIsInstance(res_matched, list) + self.assertEqual(len(res_matched), 2) + self.assertEqual(res_matched[0].transforms[-1]["translation"], (10.0, 0.0, 0.0)) + self.assertEqual(res_matched[1].transforms[-1]["translation"], (20.0, 0.0, 0.0)) + + # 6. Single geometry + list of 2 translations -> raises ValueError + tf_node.resolve = lambda label: { + "Geometry": c1, + "Translation": [(10.0, 0.0, 0.0), (20.0, 0.0, 0.0)], + "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_path_nodes_atomic_vector_inputs(self): + from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOffset, SLICENODE_RotatePath + + class MockBpyPropArray: + def __init__(self, items): + self._items = list(items) + def __iter__(self): + return iter(self._items) + def __len__(self): + return len(self._items) + def __getitem__(self, idx): + return self._items[idx] + MockBpyPropArray.__name__ = "bpy_prop_array" + + p1 = SlicePaths([np.array([[0, 0, 0], [10, 0, 0]], dtype=np.int32)]) + + # Path Offset with atomic (10, 0, 0) as tuple, list, and bpy_prop_array + node_offset = SLICENODE_PathOffset() + for off in ((10.0, 0.0, 0.0), [10.0, 0.0, 0.0], MockBpyPropArray([10.0, 0.0, 0.0])): + node_offset.resolve = lambda label, o=off: { + "Paths": p1, + "Offset": o, + }.get(label) + res = node_offset.evaluate_output("Paths") + self.assertIsInstance(res, SlicePaths) + self.assertEqual(len(res), 1) + + # Rotate Path with atomic center + node_rot = SLICENODE_RotatePath() + for cent in ((5.0, 5.0, 0.0), [5.0, 5.0, 0.0], MockBpyPropArray([5.0, 5.0, 0.0])): + node_rot.resolve = lambda label, c=cent: { + "Paths": p1, + "Angle": 45.0, + "Center": c, + }.get(label) + res = node_rot.evaluate_output("Paths") + self.assertIsInstance(res, SlicePaths) + self.assertEqual(len(res), 1) + 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")