From 8dd61f5858947fab974d81001869c258b8bb0d9e Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Tue, 13 Oct 2026 18:19:54 +0300 Subject: [PATCH] implicit conversion --- core/nodeslicer/nodes/mesh.py | 8 +- core/nodeslicer/sockets.py | 23 -- core/nodeslicer/tree.py | 20 +- infra/blender/blender_bridge.py | 2 +- infra/blender/blender_mesh_capture.py | 3 +- infra/blender/blender_slice_nodes_generic.py | 82 ++++++- tests/test_lists.py | 233 ++++++++++++++++++- tests/test_pdf_exporter.py | 15 +- 8 files changed, 337 insertions(+), 49 deletions(-) diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index 9115dce..574b08a 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -31,12 +31,8 @@ class SLICENODE_GetMesh(SliceNode): obj: Any = self.resolve("Object") if not obj: return None - def _to_collection(o: Any) -> SlicingCollection | None: - if not o: return None - family = [o] + (get_all_children(o) if self.include_children else []) - return SlicingCollection(family, getattr(o, "name", "Mesh")) - - return eval_element_wise(_to_collection, obj) + from ....infra.blender.blender_slice_nodes_generic import object_to_mesh + return object_to_mesh(obj, include_children=self.include_children) @register_class class SLICENODE_MeshesToList(SliceNodeListJoiner): diff --git a/core/nodeslicer/sockets.py b/core/nodeslicer/sockets.py index f596c07..eb6c6f3 100644 --- a/core/nodeslicer/sockets.py +++ b/core/nodeslicer/sockets.py @@ -45,29 +45,6 @@ class SLICESOCK_Geometry(LabelNodeSocket): default = None bl_color = (0.0, 0.75, 0.75, 1.0) - def draw_color(self, context, node) -> tuple[float, float, float, float]: - def _get_upstream_color(sock: Any) -> tuple[float, float, float, float]: - if not sock or not getattr(sock, "is_linked", False) or not getattr(sock, "links", None): - return self.bl_color - from_sock = sock.links[0].from_socket - if hasattr(from_sock, "draw_color"): - try: - from_node = getattr(from_sock, "node", None) - return from_sock.draw_color(context, from_node) - except Exception: - pass - return getattr(from_sock, "bl_color", self.bl_color) - - if getattr(self, "is_output", False): - if node and hasattr(node, "inputs"): - sock_name = getattr(self, "name", "") - matching_in = (node.inputs.get(sock_name) if sock_name else None) or node.inputs.get("Geometry") - if matching_in: - return _get_upstream_color(matching_in) - return self.bl_color - else: - return _get_upstream_color(self) - @register_class class SLICESOCK_Mesh(LabelNodeSocket): bl_label = "Mesh" diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 01a9929..6c25ef8 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -160,32 +160,42 @@ class SLICETREE_NodeTree(NodeTree): def valid_link(self, from_socket: Any, to_socket: Any) -> bool: geom_sockets = {"SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Geometry"} + out_s, in_s = from_socket, to_socket + if getattr(from_socket, "is_output", None) is False or getattr(to_socket, "is_output", None) is True: + out_s, in_s = to_socket, from_socket + + is_obj_to_mesh = ( + getattr(out_s, "bl_idname", "") in ("NodeSocketObject", "SLICESOCK_Object") + and getattr(in_s, "bl_idname", "") == "SLICESOCK_Mesh" + ) + compatible = ( (from_socket.bl_idname in geom_sockets and to_socket.bl_idname in geom_sockets) or from_socket.bl_idname == to_socket.bl_idname + or is_obj_to_mesh ) if not compatible: return False from ...infra.blender.list_utils import is_output_socket_a_list, group_input_can_accept_list - if is_output_socket_a_list(from_socket): - to_node = getattr(to_socket, "node", None) + if is_output_socket_a_list(out_s): + to_node = getattr(in_s, "node", None) if to_node is not None: # 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 to_socket != to_node.inputs[0]: + 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(to_socket, "name", "") == "Mesh": + 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) - if tree and not group_input_can_accept_list(tree, to_socket): + if tree and not group_input_can_accept_list(tree, in_s): return False return True diff --git a/infra/blender/blender_bridge.py b/infra/blender/blender_bridge.py index e16a952..cba7afa 100644 --- a/infra/blender/blender_bridge.py +++ b/infra/blender/blender_bridge.py @@ -2,7 +2,7 @@ from __future__ import annotations def get_all_children(obj): children = [] - for child in obj.children: + for child in getattr(obj, "children", []): children += [child] + get_all_children(child) children = list(set(children)) diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index aba752b..2c23952 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -592,7 +592,8 @@ def _cached_objects_to_tris(obj_names: tuple[str, ...], matrices: tuple[tuple[fl def objects_to_tris(objects: list[Object], scale: float) -> tuple[NDArray[float64], dict[str, dict[str, Any]]]: obj_names = tuple(o.name for o in objects) - matrices = tuple(tuple(val for row in o.matrix_world for val in row) for o in objects) + identity_mat = ((1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0, 0, 0, 1)) + matrices = tuple(tuple(val for row in getattr(o, "matrix_world", identity_mat) for val in row) for o in objects) return _cached_objects_to_tris(obj_names, matrices, scale) def _cached_capture_mesh_edges( diff --git a/infra/blender/blender_slice_nodes_generic.py b/infra/blender/blender_slice_nodes_generic.py index d2ae756..966d45f 100644 --- a/infra/blender/blender_slice_nodes_generic.py +++ b/infra/blender/blender_slice_nodes_generic.py @@ -657,6 +657,56 @@ def _extract_caller_context() -> tuple[Any, str | None]: return None, None +def object_to_mesh(data: Any, include_children: bool = True) -> Any: + """Converts an Object (or list of Objects) into SlicingCollection, matching SLICENODE_GetMesh.""" + from .blender_mesh_capture import SlicingCollection, SlicingGroup, SlicingObject + from .blender_bridge import get_all_children + from .list_utils import is_list_data + + def _to_collection(o: Any) -> Any: + if not o: + return None + if isinstance(o, (SlicingCollection, SlicingGroup, SlicingObject)): + return o + family = [o] + (get_all_children(o) if include_children else []) + return SlicingCollection(family, getattr(o, "name", "Mesh")) + + def _eval(d: Any) -> Any: + if is_list_data(d): + return [_eval(item) for item in d] + return _to_collection(d) + + return _eval(data) + +def _is_already_mesh(val: Any) -> bool: + if val is None: + return False + from .blender_mesh_capture import SlicingCollection, SlicingGroup, SlicingObject + if isinstance(val, (SlicingCollection, SlicingGroup, SlicingObject)): + return True + if is_list_data(val): + flat = flatten_list(val) + if flat and all(isinstance(x, (SlicingCollection, SlicingGroup, SlicingObject)) for x in flat): + return True + return False + +def _is_object_data(val: Any) -> bool: + if val is None: + return False + if _is_already_mesh(val): + return False + if isinstance(val, (str, int, float, bool, bytes, dict)): + return False + if is_list_data(val): + flat = flatten_list(val) + if not flat: + return False + return any(_is_object_data(x) for x in flat) + obj_type = getattr(bpy.types, "Object", None) + if isinstance(obj_type, type) and isinstance(val, obj_type): + return True + return hasattr(val, "name") or hasattr(val, "type") or hasattr(val, "matrix_world") + def resolve_input( socket: SliceNodeSocket | None, *, @@ -709,13 +759,37 @@ def resolve_input( assert socket.links from_socket: NodeSocket = _follow_reroute(socket.links[0].from_socket) - return _resolve_output( + val = _resolve_output( from_socket, default=None, cache=cache, visited=visited, ) + is_mesh_socket = ( + getattr(socket, "bl_idname", "") == "SLICESOCK_Mesh" + or type(socket).__name__ == "SLICESOCK_Mesh" + ) + if is_mesh_socket and val is not None: + is_obj_source = getattr(from_socket, "bl_idname", "") in ("NodeSocketObject", "SLICESOCK_Object") + if (is_obj_source or _is_object_data(val)) and not _is_already_mesh(val): + group_stack = tuple(n.as_pointer() for n in _get_group_stack(cache)) + from_node = getattr(from_socket, "node", None) + from_ptr = from_node.as_pointer() if (from_node and hasattr(from_node, "as_pointer")) else id(from_node) + from_name = getattr(from_node, "name", "") if from_node else "" + from_ident = getattr(from_socket, "identifier", getattr(from_socket, "name", "")) + conv_cache_key = ("implicit_get_mesh", group_stack, from_ptr, from_name, from_ident) + + if cache is not None and conv_cache_key in cache: + return cache[conv_cache_key] + + mesh_val = object_to_mesh(val, include_children=True) + if cache is not None: + cache[conv_cache_key] = mesh_val + return mesh_val + + return val + def _resolve_output( socket: NodeSocket, *, @@ -735,10 +809,12 @@ def _resolve_output( # Use identifier instead of name for the cache key, as multiple sockets # (especially in multi-groups) can share the same name but must have unique identifiers. - key = (group_stack, socket.node.as_pointer(), socket.node.name, socket.identifier) + node_ptr = socket.node.as_pointer() if hasattr(socket.node, "as_pointer") else id(socket.node) + node_name = getattr(socket.node, "name", "") + key = (group_stack, node_ptr, node_name, socket.identifier) if key in cache: return cache[key] - node_key = (group_stack, socket.node.as_pointer(), socket.node.name) + node_key = (group_stack, node_ptr, node_name) if node_key in visited: return default # cycle guard visited.add(node_key) diff --git a/tests/test_lists.py b/tests/test_lists.py index 0373e31..6a0da81 100644 --- a/tests/test_lists.py +++ b/tests/test_lists.py @@ -115,13 +115,19 @@ class DummySocketCollection(list): return s return default -class DummyNode: - def __init__(self, bl_idname="DummyNode"): +from NodeSlicer.infra.blender.blender_slice_nodes_generic import SliceNode + +class DummyNode(SliceNode): + def __init__(self, bl_idname="DummyNode", name="DummyNode"): self.bl_idname = bl_idname + self.name = name self.inputs = DummySocketCollection() self.outputs = DummySocketCollection() self.is_list_output = False + def as_pointer(self): + return id(self) + def add_in(self, sock): sock.node = self self.inputs.append(sock) @@ -700,5 +706,228 @@ class TestGroupNodeListShapes(unittest.TestCase): self.assertFalse(validator.valid_link(list_out, grp_in_float)) +class TestImplicitGetMesh(unittest.TestCase): + def setUp(self): + self.tree = SLICETREE_NodeTree() + + def test_valid_link_object_to_mesh(self): + obj_out = DummySocket("Object", "NodeSocketObject") + obj_out.is_output = True + mesh_in = DummySocket("Mesh", "SLICESOCK_Mesh") + mesh_in.is_output = False + mesh_in.node = DummyNode("SLICENODE_SliceShells") + mesh_in.node.list_socket_mapping = [("Mesh", "Paths")] + + # Object output to Mesh input is valid + self.assertTrue(self.tree.valid_link(obj_out, mesh_in)) + + # Reverse: Mesh output to Object input is NOT valid + mesh_out = DummySocket("Mesh", "SLICESOCK_Mesh") + mesh_out.is_output = True + obj_in = DummySocket("Object", "NodeSocketObject") + obj_in.is_output = False + self.assertFalse(self.tree.valid_link(mesh_out, obj_in)) + + def test_valid_link_object_list_to_mesh(self): + list_obj_out = DummySocket("Objects", "NodeSocketObject", display_shape="LIST") + list_obj_out.is_output = True + mesh_in = DummySocket("Mesh", "SLICESOCK_Mesh") + mesh_in.is_output = False + mesh_in.node = DummyNode("SLICENODE_SliceShells") + mesh_in.node.list_socket_mapping = [("Mesh", "Paths")] + + # Target accepts lists -> allowed + self.assertTrue(self.tree.valid_link(list_obj_out, mesh_in)) + + # SeamPlacement cutting mesh rejects lists -> rejected + 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)) + + def test_resolve_input_implicit_get_mesh_single_object(self): + from NodeSlicer.infra.blender.blender_slice_nodes_generic import resolve_input, execution_cache + + parent_obj = DummyObject("ParentCube") + child_obj1 = DummyObject("Child1") + child_obj2 = DummyObject("Child2") + parent_obj.children = [child_obj1, child_obj2] + + # Source node outputting parent_obj + src_node = DummyNode("SLICENODE_InputObject") + src_node.name = "InputObject" + src_out = DummySocket("Object", "NodeSocketObject") + src_out.node = src_node + src_node.outputs = DummySocketCollection([src_out]) + src_node.evaluate_output = lambda socket_name, **kw: parent_obj + + # Target node with SLICESOCK_Mesh input + dst_node = DummyNode("SLICENODE_SliceShells") + dst_in = DummySocket("Mesh", "SLICESOCK_Mesh") + dst_in.node = dst_node + dst_node.inputs = DummySocketCollection([dst_in]) + + # Link them + dst_in.is_linked = True + link = DummyLink(src_out, dst_in) + dst_in.links = [link] + + with execution_cache() as cache: + result = resolve_input(dst_in, cache=cache) + + self.assertIsInstance(result, SlicingCollection) + self.assertEqual(result.name, "ParentCube") + + def test_resolve_input_implicit_get_mesh_object_list(self): + from NodeSlicer.infra.blender.blender_slice_nodes_generic import resolve_input, execution_cache + + obj1 = DummyObject("Obj1") + obj2 = DummyObject("Obj2") + + src_node = DummyNode("SLICENODE_ObjectsToList") + src_node.name = "ObjectsToList" + src_out = DummySocket("Objects", "NodeSocketObject", display_shape="LIST") + src_out.node = src_node + src_node.outputs = DummySocketCollection([src_out]) + src_node.evaluate_output = lambda socket_name, **kw: [obj1, obj2] + + dst_node = DummyNode("SLICENODE_SliceShells") + dst_in = DummySocket("Mesh", "SLICESOCK_Mesh", display_shape="LIST") + dst_in.node = dst_node + dst_node.inputs = DummySocketCollection([dst_in]) + + dst_in.is_linked = True + dst_in.links = [DummyLink(src_out, dst_in)] + + with execution_cache() as cache: + result = resolve_input(dst_in, cache=cache) + + self.assertIsInstance(result, list) + self.assertEqual(len(result), 2) + self.assertIsInstance(result[0], SlicingCollection) + self.assertIsInstance(result[1], SlicingCollection) + self.assertEqual(result[0].name, "Obj1") + self.assertEqual(result[1].name, "Obj2") + + def test_resolve_input_already_mesh_is_not_reconverted(self): + from NodeSlicer.infra.blender.blender_slice_nodes_generic import resolve_input, execution_cache + + sc = SlicingCollection([], "ExistingMesh") + + src_node = DummyNode("SLICENODE_GetMesh") + src_node.name = "GetMesh" + src_out = DummySocket("Mesh", "SLICESOCK_Mesh") + src_out.node = src_node + src_node.outputs = DummySocketCollection([src_out]) + src_node.evaluate_output = lambda socket_name, **kw: sc + + dst_node = DummyNode("SLICENODE_SliceShells") + dst_in = DummySocket("Mesh", "SLICESOCK_Mesh") + dst_in.node = dst_node + dst_node.inputs = DummySocketCollection([dst_in]) + + dst_in.is_linked = True + dst_in.links = [DummyLink(src_out, dst_in)] + + with execution_cache() as cache: + result = resolve_input(dst_in, cache=cache) + + self.assertIs(result, sc) + + def test_resolve_input_object_to_object_socket_not_converted(self): + from NodeSlicer.infra.blender.blender_slice_nodes_generic import resolve_input, execution_cache + + obj = DummyObject("MyObj") + + src_node = DummyNode("SLICENODE_InputObject") + src_node.name = "InputObject" + src_out = DummySocket("Object", "NodeSocketObject") + src_out.node = src_node + src_node.outputs = DummySocketCollection([src_out]) + src_node.evaluate_output = lambda socket_name, **kw: obj + + dst_node = DummyNode("SLICENODE_ObjectsToList") + dst_in = DummySocket("Object", "NodeSocketObject") + dst_in.node = dst_node + dst_node.inputs = DummySocketCollection([dst_in]) + + dst_in.is_linked = True + dst_in.links = [DummyLink(src_out, dst_in)] + + with execution_cache() as cache: + result = resolve_input(dst_in, cache=cache) + + self.assertIs(result, obj) + + def test_implicit_get_mesh_caching(self): + from NodeSlicer.infra.blender.blender_slice_nodes_generic import resolve_input, execution_cache + + obj = DummyObject("SharedObj") + + src_node = DummyNode("SLICENODE_InputObject") + src_node.name = "InputObject" + src_out = DummySocket("Object", "NodeSocketObject") + src_out.node = src_node + src_node.outputs = DummySocketCollection([src_out]) + src_node.evaluate_output = lambda socket_name, **kw: obj + + dst_node1 = DummyNode("SLICENODE_SliceShells") + dst_in1 = DummySocket("Mesh", "SLICESOCK_Mesh") + dst_in1.node = dst_node1 + dst_node1.inputs = DummySocketCollection([dst_in1]) + dst_in1.is_linked = True + dst_in1.links = [DummyLink(src_out, dst_in1)] + + dst_node2 = DummyNode("SLICENODE_JoinMesh") + dst_in2 = DummySocket("Mesh", "SLICESOCK_Mesh") + dst_in2.node = dst_node2 + dst_node2.inputs = DummySocketCollection([dst_in2]) + dst_in2.is_linked = True + dst_in2.links = [DummyLink(src_out, dst_in2)] + + with execution_cache() as cache: + res1 = resolve_input(dst_in1, cache=cache) + res2 = resolve_input(dst_in2, cache=cache) + + self.assertIs(res1, res2) + + def test_explicit_get_mesh_include_children(self): + from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_GetMesh + + parent_obj = DummyObject("Parent") + child_obj = DummyObject("Child") + parent_obj.children = [child_obj] + + node_with = SLICENODE_GetMesh() + node_with.include_children = True + node_with.resolve = lambda label: parent_obj + res_with = node_with.evaluate_output("Mesh") + self.assertIsInstance(res_with, SlicingCollection) + + node_without = SLICENODE_GetMesh() + node_without.include_children = False + node_without.resolve = lambda label: parent_obj + res_without = node_without.evaluate_output("Mesh") + self.assertIsInstance(res_without, SlicingCollection) + + def test_implicit_get_mesh_passes_all_children_to_slicing_collection(self): + from unittest.mock import patch + from NodeSlicer.infra.blender.blender_slice_nodes_generic import object_to_mesh + + parent_obj = DummyObject("Parent") + c1 = DummyObject("C1") + c2 = DummyObject("C2") + gc1 = DummyObject("GC1") + c2.children = [gc1] + parent_obj.children = [c1, c2] + + with patch.object(SlicingCollection, "__init__", return_value=None) as mock_init: + object_to_mesh(parent_obj, include_children=True) + self.assertTrue(mock_init.called) + family, name = mock_init.call_args[0] + self.assertEqual(name, "Parent") + self.assertEqual(set(family), {parent_obj, c1, c2, gc1}) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index 59a8f2d..0721827 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -1127,7 +1127,7 @@ class TestPdfExporter(unittest.TestCase): updated_sp = _assign_properties_to_item(sp, props) self.assertEqual(updated_sp.pdf_object_properties, props) - # Verify dynamic socket color logic + # Verify socket colors remain static and do not adapt to upstream sockets from NodeSlicer.core.nodeslicer.sockets import SLICESOCK_Geometry, SLICESOCK_Paths, SLICESOCK_Mesh class DummyLink: def __init__(self, from_sock): @@ -1150,16 +1150,15 @@ class TestPdfExporter(unittest.TestCase): out_geom = SLICESOCK_Geometry() out_geom.is_output = True - # Input socket draws Paths color (pink) - self.assertEqual(in_geom.draw_color(None, dummy_node), paths_out.bl_color) - # Output socket on same node inherits Paths color (pink) from input socket - self.assertEqual(out_geom.draw_color(None, dummy_node), paths_out.bl_color) + # Sockets retain their native bl_color, not upstream Paths color (pink) + self.assertEqual(in_geom.draw_color(None, dummy_node), in_geom.bl_color) + self.assertEqual(out_geom.draw_color(None, dummy_node), out_geom.bl_color) - # Switch upstream to Mesh (cyan) + # Switch upstream to Mesh (teal) - sockets still retain their native bl_color mesh_out = SLICESOCK_Mesh() in_geom.links = [DummyLink(mesh_out)] - self.assertEqual(in_geom.draw_color(None, dummy_node), mesh_out.bl_color) - self.assertEqual(out_geom.draw_color(None, dummy_node), mesh_out.bl_color) + self.assertEqual(in_geom.draw_color(None, dummy_node), in_geom.bl_color) + self.assertEqual(out_geom.draw_color(None, dummy_node), out_geom.bl_color) def test_export_pdf_object_properties_table(self):