import sys from pathlib import Path import unittest from unittest.mock import MagicMock import numpy as np # Add workspace directory to sys.path to resolve root package name (NodeSlicer) PROJECT_ROOT = Path(__file__).resolve().parent.parent if str(PROJECT_ROOT.parent) not in sys.path: sys.path.append(str(PROJECT_ROOT.parent)) class DummyNodeSocket: pass class DummyNode: pass class DummyObject: pass class DummyMesh: pass class DummyNodeSocketShader: pass class DummyNodeCustomGroup: pass class DummyNodeTree: pass class DummyOperator: pass class DummyMenu: pass class DummyPanel: pass class DummyPropertyGroup: pass mock_types = MagicMock() mock_types.NodeSocket = DummyNodeSocket mock_types.Node = DummyNode mock_types.Object = DummyObject mock_types.Mesh = DummyMesh mock_types.NodeSocketShader = DummyNodeSocketShader mock_types.NodeCustomGroup = DummyNodeCustomGroup mock_types.NodeTree = DummyNodeTree mock_types.Operator = DummyOperator mock_types.Menu = DummyMenu mock_types.Panel = DummyPanel mock_types.PropertyGroup = DummyPropertyGroup mock_bpy = MagicMock() mock_bpy.context.scene = MagicMock() mock_bpy.context.scene.unit_settings.scale_length = 0.001 mock_bpy.types = mock_types sys.modules['bpy'] = mock_bpy sys.modules['bpy.types'] = mock_types sys.modules['bpy.props'] = MagicMock() sys.modules['bpy.utils'] = MagicMock() sys.modules['bpy.utils.previews'] = MagicMock() sys.modules['bpy.app'] = MagicMock() sys.modules['bpy.app.handlers'] = MagicMock() from NodeSlicer.core.slice import SlicePaths from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_Layers, SLICENODE_SliceShellsLayers def _make_box_mesh(z_min: float = 0.0, z_max: float = 1.0) -> SlicingObject: """Helper to create a box mesh between z_min and z_max.""" vertices = np.array([ [0.0, 0.0, z_min], [10.0, 0.0, z_min], [10.0, 10.0, z_min], [0.0, 10.0, z_min], [0.0, 0.0, z_max], [10.0, 0.0, z_max], [10.0, 10.0, z_max], [0.0, 10.0, z_max], ], dtype=np.float64) faces = [ [0, 1, 2], [0, 2, 3], # Bottom [4, 6, 5], [4, 7, 6], # Top [0, 4, 5], [0, 5, 1], # Front [2, 6, 7], [2, 7, 3], # Back [0, 3, 7], [0, 7, 4], # Left [1, 5, 6], [1, 6, 2], # Right ] triangles = np.zeros((len(faces), 3, 3), dtype=np.float64) for i, face in enumerate(faces): triangles[i, 0] = vertices[face[0]] triangles[i, 1] = vertices[face[1]] triangles[i, 2] = vertices[face[2]] sobj = SlicingObject() sobj.name = "TestBox" sobj.mesh = triangles return sobj class TestLayersNode(unittest.TestCase): def test_layers_generation_single(self): node = SLICENODE_Layers() node.resolve = lambda name: 0.2 if name == "First Layer Height" else (0.2 if name == "Layer Height" else (1.0 if name == "Total Height" else None)) res = node.evaluate_output("Layers") self.assertEqual(res, [0.2, 0.4, 0.6, 0.8, 1.0]) def test_layers_different_flh(self): node = SLICENODE_Layers() node.resolve = lambda name: 0.3 if name == "First Layer Height" else (0.2 if name == "Layer Height" else (0.8 if name == "Total Height" else None)) res = node.evaluate_output("Layers") self.assertEqual(res, [0.3, 0.5, 0.7]) def test_layers_list_layer_height(self): node = SLICENODE_Layers() # Layer Height is a list of two values node.resolve = lambda name: 0.2 if name == "First Layer Height" else ([0.2, 0.5] if name == "Layer Height" else (1.0 if name == "Total Height" else None)) res = node.evaluate_output("Layers") self.assertIsInstance(res, list) self.assertEqual(len(res), 2) self.assertEqual(res[0], [0.2, 0.4, 0.6, 0.8, 1.0]) self.assertEqual(res[1], [0.2, 0.7]) class TestSliceShellsLayersNode(unittest.TestCase): def test_slice_shells_layers_single_mesh(self): box = _make_box_mesh(z_min=0.0, z_max=1.0) node = SLICENODE_SliceShellsLayers() layers = [0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4] # Has layers above box node.resolve = lambda name: box if name == "Mesh" else (layers if name == "Layers" else None) slices = node.evaluate_output("Paths") self.assertIsInstance(slices, SlicePaths) # Should only slice in box range 0.0 .. 1.0 self.assertEqual(len(slices), 5) np.testing.assert_allclose([s[0, 2] for s in slices], [0.2, 0.4, 0.6, 0.8, 1.0]) def test_slice_shells_layers_multi_mesh(self): box1 = _make_box_mesh(z_min=0.0, z_max=0.5) box2 = _make_box_mesh(z_min=0.0, z_max=1.0) node = SLICENODE_SliceShellsLayers() layers = [0.2, 0.4, 0.6, 0.8, 1.0] # Multi-mesh with single layer plan node.resolve = lambda name: [box1, box2] if name == "Mesh" else (layers if name == "Layers" else None) slices_list = node.evaluate_output("Paths") self.assertIsInstance(slices_list, list) self.assertEqual(len(slices_list), 2) # Box1 has layers up to 0.5 -> 0.2, 0.4 (2 layers) self.assertEqual(len(slices_list[0]), 2) np.testing.assert_allclose([s[0, 2] for s in slices_list[0]], [0.2, 0.4]) # Box2 has layers up to 1.0 -> 0.2, 0.4, 0.6, 0.8, 1.0 (5 layers) self.assertEqual(len(slices_list[1]), 5) np.testing.assert_allclose([s[0, 2] for s in slices_list[1]], [0.2, 0.4, 0.6, 0.8, 1.0]) class DummySock: def __init__(self, name="Socket", bl_idname="NodeSocketFloat", display_shape="CIRCLE"): self.name = name self.identifier = name self.bl_idname = bl_idname self.display_shape = display_shape self.is_linked = False self.links = [] self.node = None self.is_output = False class DummyLink: def __init__(self, from_sock, to_sock): self.from_socket = from_sock self.to_socket = to_sock self.from_node = getattr(from_sock, "node", None) self.to_node = getattr(to_sock, "node", None) class DummySocketCollection(list): def get(self, name, default=None): for s in self: if getattr(s, "name", None) == name or getattr(s, "identifier", None) == name: return s return default class DummyNodeContainer: def __init__(self, bl_idname="SliceNode"): self.bl_idname = bl_idname self.name = bl_idname self.inputs = DummySocketCollection() self.outputs = DummySocketCollection() self.list_socket_mapping = [] self.always_list_inputs = [] self.always_list_outputs = [] self.is_list_output = False def add_in(self, sock): sock.node = self sock.is_output = False self.inputs.append(sock) def add_out(self, sock): sock.node = self sock.is_output = True self.outputs.append(sock) class TestConsistentSocketAppearance(unittest.TestCase): def test_layers_node_output_always_list(self): from NodeSlicer.infra.blender.list_utils import sync_node_list_sockets, is_output_socket_a_list node = DummyNodeContainer("SLICENODE_Layers") node.is_list_output = True node.always_list_outputs = ["Layers"] node.list_socket_mapping = [("Layer Height", "Layers")] in_flh = DummySock("First Layer Height", "NodeSocketFloat", "CIRCLE") in_lh = DummySock("Layer Height", "NodeSocketFloat", "CIRCLE") in_tot = DummySock("Total Height", "NodeSocketFloat", "CIRCLE") out_layers = DummySock("Layers", "NodeSocketFloat", "LIST") node.add_in(in_flh) node.add_in(in_lh) node.add_in(in_tot) node.add_out(out_layers) # Sync with unlinked inputs sync_node_list_sockets(node) self.assertEqual(out_layers.display_shape, "LIST") self.assertTrue(is_output_socket_a_list(out_layers)) self.assertEqual(in_lh.display_shape, "CIRCLE") # When Layer Height is linked to a list upstream_list = DummySock("ListOut", "NodeSocketFloat", "LIST") link = DummyLink(upstream_list, in_lh) in_lh.is_linked = True in_lh.links = [link] sync_node_list_sockets(node) self.assertEqual(in_lh.display_shape, "LIST") self.assertEqual(out_layers.display_shape, "LIST") def test_slice_shells_layers_socket_shapes(self): from NodeSlicer.infra.blender.list_utils import sync_node_list_sockets node = DummyNodeContainer("SLICENODE_SliceShellsLayers") node.always_list_inputs = ["Layers"] node.list_socket_mapping = [("Mesh", "Paths")] in_mesh = DummySock("Mesh", "SLICESOCK_Mesh", "CIRCLE") in_layers = DummySock("Layers", "NodeSocketFloat", "LIST") in_rot = DummySock("Rotation", "NodeSocketRotation", "CIRCLE") out_paths = DummySock("Paths", "SLICESOCK_Paths", "CIRCLE") node.add_in(in_mesh) node.add_in(in_layers) node.add_in(in_rot) node.add_out(out_paths) # Before linking: Layers is LIST, Mesh/Paths/Rotation are CIRCLE sync_node_list_sockets(node) self.assertEqual(in_layers.display_shape, "LIST") self.assertEqual(in_mesh.display_shape, "CIRCLE") self.assertEqual(out_paths.display_shape, "CIRCLE") self.assertEqual(in_rot.display_shape, "CIRCLE") # Link Mesh to a list output mesh_list_out = DummySock("Meshes", "SLICESOCK_Mesh", "LIST") link_mesh = DummyLink(mesh_list_out, in_mesh) in_mesh.is_linked = True in_mesh.links = [link_mesh] sync_node_list_sockets(node) self.assertEqual(in_layers.display_shape, "LIST") self.assertEqual(in_mesh.display_shape, "LIST") self.assertEqual(out_paths.display_shape, "LIST") self.assertEqual(in_rot.display_shape, "CIRCLE") # Link Rotation to a list output rot_list_out = DummySock("Rotations", "NodeSocketRotation", "LIST") link_rot = DummyLink(rot_list_out, in_rot) in_rot.is_linked = True in_rot.links = [link_rot] sync_node_list_sockets(node) self.assertEqual(in_rot.display_shape, "LIST") # Disconnect Mesh list: should revert Mesh and Paths back to CIRCLE, Layers stays LIST in_mesh.is_linked = False in_mesh.links = [] sync_node_list_sockets(node) self.assertEqual(in_layers.display_shape, "LIST") self.assertEqual(in_mesh.display_shape, "CIRCLE") self.assertEqual(out_paths.display_shape, "CIRCLE") def test_get_transforms_socket_shapes(self): from NodeSlicer.infra.blender.list_utils import sync_node_list_sockets node = DummyNodeContainer("SLICENODE_GetTransforms") node.list_socket_mapping = [("Geometry", ["Translation", "Rotation", "Scale"])] in_geom = DummySock("Geometry", "SLICESOCK_Geometry", "CIRCLE") out_t = DummySock("Translation", "NodeSocketVector", "CIRCLE") out_r = DummySock("Rotation", "NodeSocketRotation", "CIRCLE") out_s = DummySock("Scale", "NodeSocketVector", "CIRCLE") node.add_in(in_geom) node.add_out(out_t) node.add_out(out_r) node.add_out(out_s) # Unlinked -> all CIRCLE sync_node_list_sockets(node) self.assertEqual(in_geom.display_shape, "CIRCLE") self.assertEqual(out_t.display_shape, "CIRCLE") self.assertEqual(out_r.display_shape, "CIRCLE") self.assertEqual(out_s.display_shape, "CIRCLE") # Linked to list -> all 3 outputs become LIST geom_list_out = DummySock("Geometry", "SLICESOCK_Geometry", "LIST") link = DummyLink(geom_list_out, in_geom) in_geom.is_linked = True in_geom.links = [link] sync_node_list_sockets(node) self.assertEqual(in_geom.display_shape, "LIST") self.assertEqual(out_t.display_shape, "LIST") self.assertEqual(out_r.display_shape, "LIST") self.assertEqual(out_s.display_shape, "LIST") def test_sync_tree_socket_shapes_propagation(self): from NodeSlicer.infra.blender.list_utils import sync_tree_socket_shapes # Chain: NodeA (List Output) -> NodeB (GetMesh) -> NodeC (SliceShells) node_a = DummyNodeContainer("SLICENODE_ObjectsToList") node_a.is_list_output = True node_a.always_list_outputs = ["Objects"] out_a = DummySock("Objects", "NodeSocketObject", "LIST") node_a.add_out(out_a) node_b = DummyNodeContainer("SLICENODE_GetMesh") node_b.list_socket_mapping = [("Object", "Mesh")] in_b = DummySock("Object", "NodeSocketObject", "CIRCLE") out_b = DummySock("Mesh", "SLICESOCK_Mesh", "CIRCLE") node_b.add_in(in_b) node_b.add_out(out_b) node_c = DummyNodeContainer("SLICENODE_SliceShells") node_c.list_socket_mapping = [("Mesh", "Paths")] in_c = DummySock("Mesh", "SLICESOCK_Mesh", "CIRCLE") out_c = DummySock("Paths", "SLICESOCK_Paths", "CIRCLE") node_c.add_in(in_c) node_c.add_out(out_c) # Link A -> B and B -> C link_ab = DummyLink(out_a, in_b) in_b.is_linked = True in_b.links = [link_ab] link_bc = DummyLink(out_b, in_c) in_c.is_linked = True in_c.links = [link_bc] tree = type("DummyTree", (), {})() tree.nodes = [node_a, node_b, node_c] sync_tree_socket_shapes(tree) # Both B and C should have LIST shapes self.assertEqual(out_a.display_shape, "LIST") self.assertEqual(in_b.display_shape, "LIST") self.assertEqual(out_b.display_shape, "LIST") self.assertEqual(in_c.display_shape, "LIST") self.assertEqual(out_c.display_shape, "LIST") # Disconnect A -> B: everything down the chain should revert to CIRCLE in_b.is_linked = False in_b.links = [] sync_tree_socket_shapes(tree) self.assertEqual(out_a.display_shape, "LIST") self.assertEqual(in_b.display_shape, "CIRCLE") self.assertEqual(out_b.display_shape, "CIRCLE") self.assertEqual(in_c.display_shape, "CIRCLE") self.assertEqual(out_c.display_shape, "CIRCLE") if __name__ == "__main__": unittest.main()