import numpy as np import unittest from unittest.mock import MagicMock import sys from pathlib import Path # Add workspace directory to sys.path to resolve root package name (NodeSlicer) PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.append(str(PROJECT_ROOT.parent)) # Define dummy classes to avoid metaclass conflicts 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.infra.blender.blender_mesh_capture import SlicingCollection, SlicingObject class TestTransform(unittest.TestCase): def test_offset(self): # Create a SlicingObject with a single triangle and a normal pointing up (+Z) so = SlicingObject() so.mesh = np.array([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0], [0.0, 0.0, 1.0]]], dtype=np.float64) # Offset so.offset(np.array([10.0, 20.0, 30.0], dtype=np.float64)) # Normals should remain unchanged, vertices shifted np.testing.assert_allclose(so.mesh[0, 0], [11.0, 22.0, 33.0]) np.testing.assert_allclose(so.mesh[0, 1], [14.0, 25.0, 36.0]) np.testing.assert_allclose(so.mesh[0, 2], [17.0, 28.0, 39.0]) np.testing.assert_allclose(so.mesh[0, 3], [0.0, 0.0, 1.0]) def test_scale_uniform(self): so = SlicingObject() so.mesh = np.array([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0], [0.0, 0.0, 1.0]]], dtype=np.float64) # Uniform Scale so.scale(2.0) np.testing.assert_allclose(so.mesh[0, 0], [2.0, 4.0, 6.0]) np.testing.assert_allclose(so.mesh[0, 3], [0.0, 0.0, 1.0]) def test_scale_non_uniform(self): so = SlicingObject() # Normal pointing at (1, 1, 0) normalized: (0.70710678, 0.70710678, 0.0) n = np.array([1.0, 1.0, 0.0]) / np.sqrt(2.0) so.mesh = np.array([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0], n]], dtype=np.float64) # Scale X by 2, Y by 0.5, Z by 1 so.scale(np.array([2.0, 0.5, 1.0])) # Vertices check np.testing.assert_allclose(so.mesh[0, 0], [2.0, 1.0, 3.0]) # Normals check: non-uniform scale scales normal by inverse transpose, then normalized. # Inverse of scale is [0.5, 2.0, 1.0]. # Rotated normal: (0.7071 / 2, 0.7071 * 2, 0.0) = (0.3535, 1.414, 0.0) # Normalized: (0.3535 / sqrt(0.3535^2 + 1.414^2), ...) expected_n = np.array([n[0] / 2.0, n[1] / 0.5, 0.0]) expected_n /= np.linalg.norm(expected_n) np.testing.assert_allclose(so.mesh[0, 3], expected_n, rtol=1e-6) def test_rotate(self): so = SlicingObject() so.mesh = np.array([[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]]], dtype=np.float64) # Rotate 90 degrees around Z axis (0, 0, pi/2) so.rotate(np.array([0.0, 0.0, np.pi / 2.0])) # Vertex [1, 0, 0] rotated around Z by 90 deg -> [0, 1, 0] np.testing.assert_allclose(so.mesh[0, 0], [0.0, 1.0, 0.0], atol=1e-7) # Vertex [0, 1, 0] rotated around Z by 90 deg -> [-1, 0, 0] np.testing.assert_allclose(so.mesh[0, 1], [-1.0, 0.0, 0.0], atol=1e-7) # Vertex [0, 0, 1] rotated around Z by 90 deg -> [0, 0, 1] np.testing.assert_allclose(so.mesh[0, 2], [0.0, 0.0, 1.0], atol=1e-7) # Normal [0, 0, 1] should remain [0, 0, 1] np.testing.assert_allclose(so.mesh[0, 3], [0.0, 0.0, 1.0], atol=1e-7) def test_collection_cache_invalidation(self): so = SlicingObject() so.mesh = np.array([[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]]], dtype=np.float64) sc = SlicingCollection([], "coll") sc.objects = [so] # Access cached properties to populate the cache all_v = sc.all_verts uniq_v = sc.unique_verts self.assertIn("all_verts", sc.__dict__) self.assertIn("unique_verts", sc.__dict__) # Apply transformation sc.offset(np.array([10, 0, 0])) # Verify cache was cleared self.assertNotIn("all_verts", sc.__dict__) self.assertNotIn("unique_verts", sc.__dict__) # Verify new values are retrieved when accessed again new_all_v = sc.all_verts self.assertEqual(new_all_v[0, 0], 11.0) def test_transform_node(self): from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry # Create a SlicingObject with a single triangle and a normal pointing up (+Z) so = SlicingObject() so.mesh = np.array([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0], [0.0, 0.0, 1.0]]], dtype=np.float64) sc = SlicingCollection([], "coll") sc.objects = [so] # Init node node = SLICENODE_TransformGeometry() # Define mock resolver to return our inputs def mock_resolve(fieldname): if fieldname == "Mesh": return sc elif fieldname == "Translation": return (10.0, 20.0, 30.0) elif fieldname == "Rotation": # Rotate 90 degrees around Z axis: (0, 0, pi / 2) return (0.0, 0.0, np.pi / 2.0) elif fieldname == "Scale": return (2.0, 2.0, 2.0) return None node.resolve = mock_resolve # Evaluate output result_sc = node.evaluate_output("Mesh") self.assertIsNotNone(result_sc) # Ensure it's not the same object (deep copied) self.assertIsNot(result_sc, sc) self.assertIsNot(result_sc.objects[0], so) # Let's check coordinates. # SRT Order: # 1. Scale by 2.0: # v0 becomes [2.0, 4.0, 6.0] # 2. Rotate by pi/2 around Z: # v0 becomes [-4.0, 2.0, 6.0] # 3. Translate by [10, 20, 30]: # v0 becomes [6.0, 22.0, 36.0] np.testing.assert_allclose(result_sc.objects[0].mesh[0, 0], [6.0, 22.0, 36.0], atol=1e-7) def test_drop_geometry_node(self): from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_DropGeometry # Create a SlicingObject with a single triangle having min_z = 3.0 so = SlicingObject() so.mesh = np.array([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0], [0.0, 0.0, 1.0]]], dtype=np.float64) sc = SlicingCollection([], "coll") sc.objects = [so] # Init node node = SLICENODE_DropGeometry() # Test case 1: Drop to Z = 0.0 node.z = 0.0 node.inputs = {} # Mock resolve to return our inputs def mock_resolve_no_socket(fieldname): if fieldname == "Mesh": return sc elif fieldname == "Z": return 0.0 return None node.resolve = mock_resolve_no_socket result_sc_1 = node.evaluate_output("Mesh") self.assertIsNotNone(result_sc_1) self.assertEqual(result_sc_1.min_z, 0.0) np.testing.assert_allclose(result_sc_1.objects[0].mesh[0, 0], [1.0, 2.0, 0.0]) # Test case 2: Drop to Z = 10.0 so2 = SlicingObject() so2.mesh = np.array([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0], [0.0, 0.0, 1.0]]], dtype=np.float64) sc2 = SlicingCollection([], "coll2") sc2.objects = [so2] mock_socket = MagicMock() mock_socket.is_linked = False mock_socket.value = 10.0 node.inputs = {"Z": mock_socket} node.z = 0.0 def mock_resolve_with_socket(fieldname): if fieldname == "Mesh": return sc2 elif fieldname == "Z": return 10.0 return None node.resolve = mock_resolve_with_socket result_sc_2 = node.evaluate_output("Mesh") self.assertIsNotNone(result_sc_2) self.assertEqual(result_sc_2.min_z, 10.0) np.testing.assert_allclose(result_sc_2.objects[0].mesh[0, 0], [1.0, 2.0, 10.0]) if __name__ == '__main__': unittest.main()