implicit conversion

This commit is contained in:
Nicolas Predella 2026-10-13 18:19:54 +03:00
parent c5c8df0973
commit 8dd61f5858
8 changed files with 337 additions and 49 deletions

View file

@ -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):

View file

@ -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"

View file

@ -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

View file

@ -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))

View file

@ -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(

View file

@ -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)

View file

@ -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()

View file

@ -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):