fix transforms issues

This commit is contained in:
Nicolas Predella 2026-10-14 17:20:33 +03:00
parent 657660ab2e
commit 6520e2b6f9
4 changed files with 237 additions and 27 deletions

View file

@ -575,6 +575,7 @@ class SLICENODE_TransformGeometry(SliceNode):
_transform_one, _transform_one,
main_socket="Geometry", main_socket="Geometry",
secondary_sockets=["Translation", "Rotation", "Scale"], secondary_sockets=["Translation", "Rotation", "Scale"],
is_vector=[True, True, True],
socket_name=socket_name, socket_name=socket_name,
) )

View file

@ -311,6 +311,7 @@ class SLICENODE_SliceShells(SliceNode):
_slice_one, _slice_one,
main_socket="Mesh", main_socket="Mesh",
secondary_sockets=["Layer Height", "First Layer Height", "Rotation"], secondary_sockets=["Layer Height", "First Layer Height", "Rotation"],
is_vector=[False, False, True],
socket_name=socket_name, socket_name=socket_name,
default=SlicePaths(), default=SlicePaths(),
) )
@ -394,6 +395,7 @@ class SLICENODE_BufferShells(SliceNode):
_buf, _buf,
main_socket="Paths", main_socket="Paths",
secondary_sockets=["Distance", "Rotation"], secondary_sockets=["Distance", "Rotation"],
is_vector=[False, True],
socket_name=socket_name, socket_name=socket_name,
default=SlicePaths(), default=SlicePaths(),
) )
@ -456,6 +458,7 @@ class SLICENODE_BufferCurve(SliceNode):
_buf, _buf,
main_socket="Paths", main_socket="Paths",
secondary_sockets=["Distance", "Rotation"], secondary_sockets=["Distance", "Rotation"],
is_vector=[False, True],
socket_name=socket_name, socket_name=socket_name,
default=SlicePaths(), default=SlicePaths(),
) )
@ -498,6 +501,7 @@ class SLICENODE_OffsetCurve(SliceNode):
_off, _off,
main_socket="Paths", main_socket="Paths",
secondary_sockets=["Distance", "Rotation"], secondary_sockets=["Distance", "Rotation"],
is_vector=[False, True],
socket_name=socket_name, socket_name=socket_name,
default=SlicePaths(), default=SlicePaths(),
) )
@ -521,6 +525,7 @@ class SLICENODE_PathOffset(SliceNode):
_off, _off,
main_socket="Paths", main_socket="Paths",
secondary_sockets=["Offset"], secondary_sockets=["Offset"],
is_vector=[True],
socket_name=socket_name, socket_name=socket_name,
default=SlicePaths(), default=SlicePaths(),
) )
@ -546,6 +551,7 @@ class SLICENODE_RotatePath(SliceNode):
_rot, _rot,
main_socket="Paths", main_socket="Paths",
secondary_sockets=["Angle", "Center"], secondary_sockets=["Angle", "Center"],
is_vector=[False, True],
socket_name=socket_name, socket_name=socket_name,
default=SlicePaths(), default=SlicePaths(),
) )
@ -599,6 +605,7 @@ class SLICENODE_BufferShellsMultiple(SliceNode):
_buf_multi, _buf_multi,
main_socket="Paths", main_socket="Paths",
secondary_sockets=["Distance", "Width Step", "Count", "Rotation"], secondary_sockets=["Distance", "Width Step", "Count", "Rotation"],
is_vector=[False, False, False, True],
socket_name=socket_name, socket_name=socket_name,
default=SlicePaths(), default=SlicePaths(),
) )
@ -644,6 +651,7 @@ class SLICENODE_Infill(SliceNode):
_inf, _inf,
main_socket="Paths", main_socket="Paths",
secondary_sockets=["Density", "Infill Rotation", "Layer Rotation"], secondary_sockets=["Density", "Infill Rotation", "Layer Rotation"],
is_vector=[False, False, True],
socket_name=socket_name, socket_name=socket_name,
default=SlicePaths(), default=SlicePaths(),
) )

View file

@ -20,27 +20,36 @@ LIST_CAPABLE_SOCKET_TYPES = {
} }
def is_vector_value(val: Any) -> bool: 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.""" rather than a list of such values."""
if val is None: if val is None:
return False return False
val_type = type(val).__name__ 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 return True
if isinstance(val, np.ndarray): 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 True
return False return False
if isinstance(val, (list, tuple)): if hasattr(val, "__len__") and not isinstance(val, (str, bytes, dict, set)):
if len(val) in (3, 4) and all(isinstance(x, (int, float, np.number)) and not isinstance(x, bool) for x in val): 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 return True
except Exception:
pass
return False return False
def _is_sequence_collection(val: Any) -> bool: def _is_sequence_collection(val: Any) -> bool:
if isinstance(val, (list, tuple)): if isinstance(val, (list, tuple)):
return True 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)): if hasattr(val, "__iter__") and not hasattr(val, "shape") and not isinstance(val, (str, bytes, dict, set)):
cls_name = type(val).__name__ 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"): if "bpy_prop" in cls_name or "Collection" in cls_name or hasattr(val, "foreach_get"):
return True return True
import collections.abc import collections.abc
@ -55,6 +64,9 @@ def is_input_a_list(val: Any, is_vector: bool = False) -> bool:
return False return False
if type(val).__name__ == "SlicePaths" or getattr(val, "is_slice_paths", False): if type(val).__name__ == "SlicePaths" or getattr(val, "is_slice_paths", False):
return 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:
if is_vector_value(val): if is_vector_value(val):
return False return False
@ -87,6 +99,7 @@ def prepare_indexed_inputs(
is_vector: list[bool] | None = None, is_vector: list[bool] | None = None,
input_names: list[str] | None = None, input_names: list[str] | None = None,
is_atomic: list[bool] | None = None, is_atomic: list[bool] | None = None,
main_is_vector: bool = False,
) -> tuple[bool, list[Any], list[list[Any]]]: ) -> tuple[bool, list[Any], list[list[Any]]]:
"""Prepares and validates main and secondary inputs according to the generalized list architecture: """Prepares and validates main and secondary inputs according to the generalized list architecture:
- Main input can be a list or a single item. - Main input can be a list or a single item.
@ -99,7 +112,7 @@ def prepare_indexed_inputs(
Returns: Returns:
(is_main_list, main_items, broadcasted_secondary_lists) (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] main_items = list(main_data) if main_is_list else [main_data]
n = len(main_items) n = len(main_items)
@ -113,6 +126,13 @@ def prepare_indexed_inputs(
is_atom = atom_flags[i] if i < len(atom_flags) else False is_atom = atom_flags[i] if i < len(atom_flags) else False
name = names[i] if i < len(names) else f"input_{i+1}" 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: if is_atom:
is_nested_list = ( is_nested_list = (
isinstance(sec_val, (list, tuple)) isinstance(sec_val, (list, tuple))
@ -160,6 +180,7 @@ def eval_element_wise_multivalue(
input_names: list[str] | None = None, input_names: list[str] | None = None,
output_index: int | None = None, output_index: int | None = None,
is_atomic: list[bool] | None = None, is_atomic: list[bool] | None = None,
main_is_vector: bool = False,
) -> Any: ) -> Any:
"""Evaluates op_func element-wise with support for main and secondary list inputs: """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 list, returns a list of results.
@ -175,6 +196,7 @@ def eval_element_wise_multivalue(
is_vector=is_vector, is_vector=is_vector,
input_names=input_names, input_names=input_names,
is_atomic=is_atomic, is_atomic=is_atomic,
main_is_vector=main_is_vector,
) )
def _call(item: Any, i: int) -> Any: 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: def is_socket_vector_type(node: Any, socket_name: str) -> bool:
"""Checks whether the socket on the node is a vector/rotation/color socket.""" """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) inputs = getattr(node, "inputs", None)
if inputs: if inputs:
sock = None sock = None
if hasattr(inputs, "get"): if hasattr(inputs, "get"):
sock = inputs.get(socket_name) sock = inputs.get(socket_name)
elif isinstance(inputs, (list, tuple)): if sock is None:
try:
for s in inputs: for s in inputs:
if getattr(s, "name", "") == socket_name: if getattr(s, "name", "").lower() == name_lower:
sock = s sock = s
break break
except Exception:
pass
if sock is not None: 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", "") 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 return True
if bl_id in ("NodeSocketFloat", "NodeSocketInt", "NodeSocketString", "NodeSocketBool", "SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Settings"): if bl_id in ("NodeSocketFloat", "NodeSocketInt", "NodeSocketString", "NodeSocketBool", "SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Settings"):
return False return False
name_lower = socket_name.lower() return False
return name_lower in ("translation", "rotation", "scale", "offset", "center", "layer rotation", "color")
def eval_multivalue( def eval_multivalue(
node: Any, node: Any,
@ -273,7 +303,13 @@ def eval_multivalue(
if is_vector is None: if is_vector is None:
vec_flags = [is_socket_vector_type(node, sname) for sname in secondary_sockets] vec_flags = [is_socket_vector_type(node, sname) for sname in secondary_sockets]
else: 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: try:
return eval_element_wise_multivalue( return eval_element_wise_multivalue(
@ -284,6 +320,7 @@ def eval_multivalue(
input_names=secondary_sockets, input_names=secondary_sockets,
output_index=output_index, output_index=output_index,
is_atomic=is_atomic, is_atomic=is_atomic,
main_is_vector=main_is_vec,
) )
except Exception as e: except Exception as e:
from .blender_slice_nodes_generic import attach_node_failure_info from .blender_slice_nodes_generic import attach_node_failure_info

View file

@ -852,27 +852,191 @@ class TestGeneralizedListArchitecture(unittest.TestCase):
self.assertIn("2 items", str(ctx.exception)) self.assertIn("2 items", str(ctx.exception))
def test_vector_input_distinction(self): def test_vector_input_distinction(self):
# Single vector is not a list class MockBpyPropArray:
single_vec = (1.0, 2.0, 3.0) def __init__(self, items):
self.assertTrue(is_vector_value(single_vec)) self._items = list(items)
self.assertFalse(is_input_a_list(single_vec, is_vector=True)) 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 # List of vectors IS a list
vec_list = [(1.0, 0.0, 0.0), (0.0, 1.0, 0.0)] list_of_vecs = [(1.0, 0.0, 0.0), (0.0, 1.0, 0.0)]
self.assertFalse(is_vector_value(vec_list)) self.assertFalse(is_vector_value(list_of_vecs))
self.assertTrue(is_input_a_list(vec_list, is_vector=True)) self.assertTrue(is_input_a_list(list_of_vecs, is_vector=True))
# Broadcasting single vector across main list # Broadcasting single vector across main list
main = ["geom1", "geom2"] main = ["geom1", "geom2"]
is_list, items, (t_list,) = prepare_indexed_inputs(main, single_vec, is_vector=[True], input_names=["Translation"]) 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.assertTrue(is_list)
self.assertEqual(t_list, [(1.0, 2.0, 3.0), (1.0, 2.0, 3.0)]) 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 # 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.assertTrue(is_list)
self.assertEqual(t_list, [(1.0, 0.0, 0.0), (0.0, 1.0, 0.0)]) 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): def test_eval_element_wise_multivalue(self):
op = lambda item, a, b: f"{item}_{a}_{b}" op = lambda item, a, b: f"{item}_{a}_{b}"
res = eval_element_wise_multivalue(op, ["x", "y"], [1, 2], "const") res = eval_element_wise_multivalue(op, ["x", "y"], [1, 2], "const")