nodeslicer/tests/test_layers.py
2026-10-14 16:39:49 +03:00

379 lines
14 KiB
Python

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