379 lines
14 KiB
Python
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()
|