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 if 'bpy' in sys.modules and isinstance(sys.modules['bpy'], MagicMock): mock_bpy = sys.modules['bpy'] else: mock_bpy = MagicMock() sys.modules['bpy'] = mock_bpy mock_bpy.context.scene = MagicMock() mock_bpy.context.scene.unit_settings.scale_length = 0.001 mock_bpy.types = mock_types 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 == "Geometry": 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("Geometry") 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_transform_node_slice_paths(self): from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry from NodeSlicer.core.slice import SlicePaths path = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64) sp = SlicePaths([path]) node = SLICENODE_TransformGeometry() node.resolve = lambda f: { "Geometry": sp, "Translation": (10.0, 20.0, 30.0), "Rotation": (0.0, 0.0, np.pi / 2.0), "Scale": (2.0, 2.0, 2.0) }.get(f) result_sp = node.evaluate_output("Geometry") self.assertIsNotNone(result_sp) self.assertIsInstance(result_sp, SlicePaths) self.assertIsNot(result_sp, sp) # Point 1: [1, 2, 3] -> scale [2, 4, 6] -> rot Z pi/2 [-4, 2, 6] -> trans [6, 22, 36] np.testing.assert_allclose(result_sp[0][0], [6.0, 22.0, 36.0], atol=1e-7) # Point 2: [4, 5, 6] -> scale [8, 10, 12] -> rot Z pi/2 [-10, 8, 12] -> trans [0, 28, 42] np.testing.assert_allclose(result_sp[0][1], [0.0, 28.0, 42.0], atol=1e-7) # Check transforms metadata self.assertEqual(len(result_sp.transforms), 1) self.assertEqual(result_sp.transforms[0]["type"], "transform") def test_transform_node_list_input(self): from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry from NodeSlicer.core.slice import SlicePaths 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] path = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64) sp = SlicePaths([path]) geom_list = [sc, sp] node = SLICENODE_TransformGeometry() node.resolve = lambda f: { "Geometry": geom_list, "Translation": (10.0, 0.0, 0.0), "Rotation": (0.0, 0.0, 0.0), "Scale": (1.0, 1.0, 1.0) }.get(f) result_list = node.evaluate_output("Geometry") self.assertIsInstance(result_list, list) self.assertEqual(len(result_list), 2) self.assertIsInstance(result_list[0], SlicingCollection) self.assertIsInstance(result_list[1], SlicePaths) np.testing.assert_allclose(result_list[0].objects[0].mesh[0, 0], [11.0, 2.0, 3.0]) np.testing.assert_allclose(result_list[1][0][0], [11.0, 2.0, 3.0]) 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]) def test_gcode_transform_metadata(self): from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry, SLICENODE_DropGeometry from NodeSlicer.core.slice import SlicePaths, Pather so = SlicingObject() so.mesh = np.array([[[0.0, 0.0, 5.0], [10.0, 0.0, 5.0], [5.0, 10.0, 10.0], [0.0, 0.0, 1.0]]], dtype=np.float64) sc = SlicingCollection([], "coll") sc.objects = [so] # 1. Transform Geometry t_node = SLICENODE_TransformGeometry() t_node.resolve = lambda f: { "Geometry": sc, "Translation": (10.0, 20.0, 0.0), "Rotation": (0.0, 0.0, 0.0), "Scale": (1.0, 1.0, 1.0) }.get(f) sc_transformed = t_node.evaluate_output("Geometry") self.assertIsNotNone(sc_transformed) self.assertEqual(len(sc_transformed.transforms), 1) self.assertEqual(sc_transformed.transforms[0]["type"], "transform") self.assertEqual(sc_transformed.transforms[0]["translation"], (10.0, 20.0, 0.0)) # 2. Drop Geometry d_node = SLICENODE_DropGeometry() d_node.resolve = lambda f: { "Mesh": sc_transformed, "Z": 0.0 }.get(f) sc_dropped = d_node.evaluate_output("Mesh") self.assertIsNotNone(sc_dropped) self.assertEqual(len(sc_dropped.transforms), 1) self.assertEqual(sc_dropped.transforms[0]["type"], "transform") self.assertEqual(sc_dropped.transforms[0]["translation"], (10.0, 20.0, -5.0)) # 3. Create SlicePaths from geometry path = np.array([[10.0, 20.0, 0.0], [20.0, 20.0, 0.0], [15.0, 30.0, 0.0]]) sp = SlicePaths([path], transforms=sc_dropped.transforms) # 4. Generate G-code with Pather pather = Pather(sp) gcode = pather.to_gcode(include_header=True) self.assertIn(";PREPARATION_TRANSFORMS=", gcode) self.assertIn(";PREPARATION_TOTAL_TRANSLATION=10.000000,20.000000,-5.000000", gcode) # 5. Process G-code with GcodeToMesh preview node from NodeSlicer.core.nodeslicer.nodes.preview import SLICENODE_GcodeToMesh preview_node = SLICENODE_GcodeToMesh() preview_node.show_e_0 = True mock_mod_dict = {} class MockModifier: type = 'NODES' node_group = MagicMock() def __getitem__(self, k): return mock_mod_dict.get(k) def __setitem__(self, k, v): mock_mod_dict[k] = v def get(self, k, default=None): return mock_mod_dict.get(k, default) mock_obj = MagicMock() mock_obj.type = 'MESH' mock_obj.modifiers = [MockModifier()] import sys sys.modules['bpy'].data.objects = {"PreviewObj": mock_obj} sys.modules['bpy'].data.node_groups = {"Nonplanar Slicing Preview": mock_obj.modifiers[0].node_group} preview_node._process_gcode(gcode, "PreviewObj") # Verify modifier sockets Socket_8, Socket_6, Socket_9 self.assertEqual(mock_mod_dict.get("Socket_8"), (0.01, 0.02, -0.005)) self.assertEqual(mock_mod_dict.get("Socket_6"), (0.0, 0.0, 0.0)) # Test overwriting on an existing mesh object with pre-existing modifier values mock_mod_dict["Socket_8"] = [0.0, 0.0, 0.0] mock_mod_dict["Socket_6"] = [1.0, 1.0, 1.0] mock_mod_dict["Socket_9"] = [2.0, 2.0, 2.0] preview_node._process_gcode(gcode, "PreviewObj") self.assertEqual(tuple(mock_mod_dict.get("Socket_8")), (0.01, 0.02, -0.005)) self.assertEqual(tuple(mock_mod_dict.get("Socket_6")), (0.0, 0.0, 0.0)) self.assertEqual(tuple(mock_mod_dict.get("Socket_9")), (1.0, 1.0, 1.0)) # Test G-code with non-zero 3D rotation and non-uniform scale gcode_rot = ( ";PREPARATION_TOTAL_TRANSLATION=10.000000,20.000000,30.000000\n" ";PREPARATION_TOTAL_ROTATION=0.150000,0.250000,0.350000\n" ";PREPARATION_TOTAL_SCALE=1.500000,2.000000,2.500000\n" "G1 X10 Y20 Z30 E1\n" ) preview_node._process_gcode(gcode_rot, "PreviewObj") self.assertEqual(tuple(mock_mod_dict.get("Socket_8")), (0.01, 0.02, 0.03)) self.assertEqual(tuple(mock_mod_dict.get("Socket_6")), (0.15, 0.25, 0.35)) self.assertEqual(tuple(mock_mod_dict.get("Socket_9")), (1.5, 2.0, 2.5)) def test_gcode_to_mesh_naming_convention(self): from NodeSlicer.core.nodeslicer.nodes.preview import SLICENODE_GcodeToMesh node = SLICENODE_GcodeToMesh() # 1. Single part - no number suffix node.resolve_multi_input = MagicMock(return_value=["GCODE1"]) node.resolve = MagicMock(return_value="Part") node._process_gcode = MagicMock() node.evaluate() node._process_gcode.assert_called_once_with("GCODE1", "Part", None) # 2. Three parts - padded to 2 digits (00, 01, 02) node.resolve_multi_input = MagicMock(return_value=["GC1", "GC2", "GC3"]) node.resolve = MagicMock(return_value="Part") node._process_gcode = MagicMock() node.evaluate() self.assertEqual(node._process_gcode.call_count, 3) node._process_gcode.assert_any_call("GC1", "Part00", None) node._process_gcode.assert_any_call("GC2", "Part01", None) node._process_gcode.assert_any_call("GC3", "Part02", None) # 3. Fifteen parts - padded to 2 digits (00..14) node.resolve_multi_input = MagicMock(return_value=[f"GC{i}" for i in range(1, 16)]) node.resolve = MagicMock(return_value="Part") node._process_gcode = MagicMock() node.evaluate() self.assertEqual(node._process_gcode.call_count, 15) node._process_gcode.assert_any_call("GC1", "Part00", None) node._process_gcode.assert_any_call("GC15", "Part14", None) # 4. Default base name when empty name provided node.resolve_multi_input = MagicMock(return_value=["GC1", "GC2"]) node.resolve = MagicMock(return_value="") node._process_gcode = MagicMock() node.evaluate() node._process_gcode.assert_any_call("GC1", "GcodePreview00", None) node._process_gcode.assert_any_call("GC2", "GcodePreview01", None) def test_gcode_to_file_naming_convention(self): from unittest.mock import patch, mock_open from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_GcodeToFile node = SLICENODE_GcodeToFile() mock_bpy.path.abspath = lambda p: p.replace("//", "/workspace/") # 1. Single part with Name plugged - no number suffix with patch("NodeSlicer.infra.networking.gcode_utils.prepare_gcode_file", return_value=(b"GC", "Part.gcode")) as mock_prep, \ patch("builtins.open", mock_open()), \ patch("os.makedirs"): def resolve_side_effect(name): if name == "Folder": return "output" if name == "Name": return "Part" return "" node.resolve = MagicMock(side_effect=resolve_side_effect) node.resolve_multi_input = MagicMock(return_value=["GCODE1"]) res = node.evaluate() self.assertEqual(res, 'ok') mock_prep.assert_called_once_with("GCODE1", "Part") # 2. Three parts with Name plugged - padded to 2 digits (00, 01, 02) with patch("NodeSlicer.infra.networking.gcode_utils.prepare_gcode_file", return_value=(b"GC", "test.gcode")) as mock_prep, \ patch("builtins.open", mock_open()), \ patch("os.makedirs"): def resolve_side_effect(name): if name == "Folder": return "output" if name == "Name": return "Part" return "" node.resolve = MagicMock(side_effect=resolve_side_effect) node.resolve_multi_input = MagicMock(return_value=["GC1", "GC2", "GC3"]) res = node.evaluate() self.assertEqual(res, 'ok') self.assertEqual(mock_prep.call_count, 3) mock_prep.assert_any_call("GC1", "Part00") mock_prep.assert_any_call("GC2", "Part01") mock_prep.assert_any_call("GC3", "Part02") # 3. Fifteen parts with Name plugged - padded to 2 digits (00..14) with patch("NodeSlicer.infra.networking.gcode_utils.prepare_gcode_file", return_value=(b"GC", "test.gcode")) as mock_prep, \ patch("builtins.open", mock_open()), \ patch("os.makedirs"): def resolve_side_effect(name): if name == "Folder": return "output" if name == "Name": return "Bracket" return "" node.resolve = MagicMock(side_effect=resolve_side_effect) node.resolve_multi_input = MagicMock(return_value=[f"GC{i}" for i in range(1, 16)]) res = node.evaluate() self.assertEqual(res, 'ok') self.assertEqual(mock_prep.call_count, 15) mock_prep.assert_any_call("GC1", "Bracket00") mock_prep.assert_any_call("GC15", "Bracket14") # 4. Not plugged / empty name - uses object names from metadata gc_meta1 = '; objects_info = {"objects": [{"name": "CustomPart"}]}\nG1 X10' gc_meta2 = 'G1 X20' with patch("NodeSlicer.infra.networking.gcode_utils.prepare_gcode_file", return_value=(b"GC", "test.gcode")) as mock_prep, \ patch("builtins.open", mock_open()), \ patch("os.makedirs"): def resolve_side_effect(name): if name == "Folder": return "output" if name == "Name": return "" return "" node.resolve = MagicMock(side_effect=resolve_side_effect) node.resolve_multi_input = MagicMock(return_value=[gc_meta1, gc_meta2]) res = node.evaluate() self.assertEqual(res, 'ok') self.assertEqual(mock_prep.call_count, 2) mock_prep.assert_any_call(gc_meta1, "CustomPart") mock_prep.assert_any_call(gc_meta2, "object_1") def test_drop_geometry_applied_to_main_transform_no_double_accumulation(self): """Verifies that dropping geometry updates the single main transform without accumulating duplicate records.""" from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry, SLICENODE_DropGeometry so = SlicingObject() so.mesh = np.array([[[0.0, 0.0, 10.0], [10.0, 0.0, 10.0], [5.0, 10.0, 15.0], [0.0, 0.0, 1.0]]], dtype=np.float64) sc = SlicingCollection([], "coll") sc.objects = [so] # Step 1: Transform geometry (translate by X=10, Y=20, Z=5, rot Z=90deg) t_node = SLICENODE_TransformGeometry() t_node.resolve = lambda f: { "Geometry": sc, "Translation": (10.0, 20.0, 5.0), "Rotation": (0.0, 0.0, np.pi / 2.0), "Scale": (1.0, 1.0, 1.0) }.get(f) sc_t = t_node.evaluate_output("Geometry") self.assertEqual(len(sc_t.transforms), 1) self.assertEqual(sc_t.transforms[0]["type"], "transform") self.assertEqual(sc_t.transforms[0]["translation"], (10.0, 20.0, 5.0)) self.assertEqual(sc_t.transforms[0]["rotation"], (0.0, 0.0, np.pi / 2.0)) # min_z of sc_t is 10.0 + 5.0 = 15.0 # Step 2: Drop geometry to Z=0.0 (offset_z = -15.0) d_node1 = SLICENODE_DropGeometry() d_node1.resolve = lambda f: { "Mesh": sc_t, "Z": 0.0 }.get(f) sc_d1 = d_node1.evaluate_output("Mesh") self.assertEqual(len(sc_d1.transforms), 1) self.assertEqual(sc_d1.transforms[0]["type"], "transform") # Translation Z becomes 5.0 + (-15.0) = -10.0 self.assertEqual(sc_d1.transforms[0]["translation"], (10.0, 20.0, -10.0)) self.assertEqual(sc_d1.transforms[0]["rotation"], (0.0, 0.0, np.pi / 2.0)) self.assertAlmostEqual(sc_d1.min_z, 0.0) # Step 3: Drop geometry again to Z=2.0 (offset_z = +2.0) d_node2 = SLICENODE_DropGeometry() d_node2.resolve = lambda f: { "Mesh": sc_d1, "Z": 2.0 }.get(f) sc_d2 = d_node2.evaluate_output("Mesh") self.assertEqual(len(sc_d2.transforms), 1) self.assertEqual(sc_d2.transforms[0]["type"], "transform") # Translation Z becomes -10.0 + 2.0 = -8.0 self.assertEqual(sc_d2.transforms[0]["translation"], (10.0, 20.0, -8.0)) self.assertEqual(sc_d2.transforms[0]["rotation"], (0.0, 0.0, np.pi / 2.0)) self.assertAlmostEqual(sc_d2.min_z, 2.0) def test_join_and_boolean_do_not_sum_transforms(self): """Verifies that joining, zipping, or performing boolean operations on distinct geometries does NOT sum their transforms.""" from NodeSlicer.core.slice import SlicePaths, boolean_slice from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject # Object 1 with transform translation (10, 0, 0) p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) sp1 = SlicePaths([p1], groups=[0], transforms=[{"type": "transform", "translation": (10.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}]) # Object 2 with transform translation (20, 0, 0) p2 = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]]) sp2 = SlicePaths([p2], groups=[0], transforms=[{"type": "transform", "translation": (20.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}]) # 1. Zip paths sp_zip = SlicePaths.zip_paths([sp1, sp2]) self.assertEqual(len(sp_zip.transforms), 1) self.assertEqual(sp_zip.transforms[0]["translation"], (10.0, 0.0, 0.0)) # 2. Extend paths sp_ext = SlicePaths.extend_paths([sp1, sp2]) self.assertEqual(len(sp_ext.transforms), 1) self.assertEqual(sp_ext.transforms[0]["translation"], (10.0, 0.0, 0.0)) # 3. Boolean slice (Union) sp_union = boolean_slice(sp1, sp2, operation='UNION') self.assertEqual(len(sp_union.transforms), 1) self.assertEqual(sp_union.transforms[0]["translation"], (10.0, 0.0, 0.0)) # 4. SlicingObject.merge so1 = SlicingObject() so1.name = "Obj1" so1.transforms = [{"type": "transform", "translation": (10.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}] so2 = SlicingObject() so2.name = "Obj2" so2.transforms = [{"type": "transform", "translation": (20.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}] so_merged = SlicingObject.merge([so1, so2]) self.assertEqual(len(so_merged.transforms), 1) self.assertEqual(so_merged.transforms[0]["translation"], (10.0, 0.0, 0.0)) def test_get_transforms_node(self): """Tests SLICENODE_GetTransforms node evaluation with various geometry inputs.""" from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_GetTransforms, SLICENODE_TransformGeometry, SLICENODE_DropGeometry from NodeSlicer.core.slice import SlicePaths from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection, SlicingGroup node = SLICENODE_GetTransforms() self.assertEqual(node.list_socket_mapping, [("Geometry", ["Translation", "Rotation", "Scale"])]) # 1. None input node.resolve = lambda s: None self.assertEqual(node.evaluate_output("Translation"), (0.0, 0.0, 0.0)) self.assertEqual(node.evaluate_output("Rotation"), (0.0, 0.0, 0.0)) self.assertEqual(node.evaluate_output("Scale"), (1.0, 1.0, 1.0)) # 2. SlicePaths with transforms p = np.array([[0, 0, 0], [10, 0, 0], [10, 10, 0], [0, 0, 0]], dtype=np.float64) sp = SlicePaths([p], transforms=[{ "type": "transform", "translation": (12.0, 34.0, 56.0), "rotation": (0.1, 0.2, 0.3), "scale": (2.0, 3.0, 4.0), }]) node.resolve = lambda s: sp self.assertEqual(node.evaluate_output("Translation"), (12.0, 34.0, 56.0)) self.assertEqual(node.evaluate_output("Rotation"), (0.1, 0.2, 0.3)) self.assertEqual(node.evaluate_output("Scale"), (2.0, 3.0, 4.0)) # 3. SlicePaths with NO transforms (default output) sp_plain = SlicePaths([p]) node.resolve = lambda s: sp_plain self.assertEqual(node.evaluate_output("Translation"), (0.0, 0.0, 0.0)) self.assertEqual(node.evaluate_output("Rotation"), (0.0, 0.0, 0.0)) self.assertEqual(node.evaluate_output("Scale"), (1.0, 1.0, 1.0)) # 4. SlicingObject with transforms so = SlicingObject() so.transforms = [{ "type": "transform", "translation": (1.0, 2.0, 3.0), "rotation": (0.0, 0.0, np.pi / 4.0), "scale": (0.5, 0.5, 1.0), }] node.resolve = lambda s: so self.assertEqual(node.evaluate_output("Translation"), (1.0, 2.0, 3.0)) self.assertEqual(node.evaluate_output("Rotation"), (0.0, 0.0, np.pi / 4.0)) self.assertEqual(node.evaluate_output("Scale"), (0.5, 0.5, 1.0)) # 5. SlicingCollection sc = SlicingCollection([], "test_coll") sc.transforms = [{ "type": "transform", "translation": (5.0, -5.0, 10.0), "rotation": (0.0, np.pi / 2.0, 0.0), "scale": (1.0, 1.0, 1.0), }] node.resolve = lambda s: sc self.assertEqual(node.evaluate_output("Translation"), (5.0, -5.0, 10.0)) self.assertEqual(node.evaluate_output("Rotation"), (0.0, np.pi / 2.0, 0.0)) self.assertEqual(node.evaluate_output("Scale"), (1.0, 1.0, 1.0)) # 6. SlicingGroup sg = SlicingGroup([]) sg.transforms = [{ "type": "transform", "translation": (100.0, 200.0, 300.0), "rotation": (0.0, 0.0, 0.0), "scale": (2.0, 2.0, 2.0), }] node.resolve = lambda s: sg self.assertEqual(node.evaluate_output("Translation"), (100.0, 200.0, 300.0)) self.assertEqual(node.evaluate_output("Scale"), (2.0, 2.0, 2.0)) # 7. Chained with Transform Geometry t_node = SLICENODE_TransformGeometry() t_node.resolve = lambda f: { "Geometry": sp_plain, "Translation": (7.0, 8.0, 9.0), "Rotation": (0.0, 0.0, 1.5), "Scale": (1.5, 2.5, 3.5), }.get(f) transformed_geom = t_node.evaluate_output("Geometry") node.resolve = lambda s: transformed_geom self.assertEqual(node.evaluate_output("Translation"), (7.0, 8.0, 9.0)) self.assertEqual(node.evaluate_output("Rotation"), (0.0, 0.0, 1.5)) self.assertEqual(node.evaluate_output("Scale"), (1.5, 2.5, 3.5)) # 8. List of geometries (element-wise list processing) list_geoms = [sp, so] node.resolve = lambda s: list_geoms self.assertEqual(node.evaluate_output("Translation"), [(12.0, 34.0, 56.0), (1.0, 2.0, 3.0)]) self.assertEqual(node.evaluate_output("Rotation"), [(0.1, 0.2, 0.3), (0.0, 0.0, np.pi / 4.0)]) self.assertEqual(node.evaluate_output("Scale"), [(2.0, 3.0, 4.0), (0.5, 0.5, 1.0)]) # 9. Blender Object fallback class MockBpyObject: location = (11.0, 22.0, 33.0) rotation_euler = (0.4, 0.5, 0.6) scale = (1.1, 2.2, 3.3) node.resolve = lambda s: MockBpyObject() self.assertEqual(node.evaluate_output("Translation"), (11.0, 22.0, 33.0)) self.assertEqual(node.evaluate_output("Rotation"), (0.4, 0.5, 0.6)) self.assertEqual(node.evaluate_output("Scale"), (1.1, 2.2, 3.3)) def test_path_operations_acquire_transforms(self): """Tests Acquire Transforms checkbox and behavior on SLICENODE_PathOperations.""" from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOperations from NodeSlicer.core.slice import SlicePaths node = SLICENODE_PathOperations() self.assertTrue(node.acquire_transforms) # Primary square path from (0, 0) to (20, 20) shifted by +50 in X and +30 in Y sq_coords = np.array([[0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [20.0, 20.0, 0.0], [0.0, 20.0, 0.0], [0.0, 0.0, 0.0]]) shifted_sq = sq_coords + np.array([50.0, 30.0, 0.0]) sp_primary = SlicePaths([shifted_sq], transforms=[{ "type": "transform", "translation": (50.0, 30.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0) }]) # Secondary square path from (5, 5) to (15, 15) with NO transforms cutter_coords = np.array([[5.0, 5.0, 0.0], [15.0, 5.0, 0.0], [15.0, 15.0, 0.0], [5.0, 15.0, 0.0], [5.0, 5.0, 0.0]]) sp_secondary = SlicePaths([cutter_coords]) # Mock sockets sock0 = MagicMock() sock0.is_linked = True sock1 = MagicMock() sock1.is_linked = True node.inputs = [sock0, sock1] def mock_resolve_input(sock): if sock == sock0: return sp_primary elif sock == sock1: return sp_secondary return None import NodeSlicer.infra.blender.blender_slice_nodes_generic as generic_mod orig_resolve = generic_mod.resolve_input generic_mod.resolve_input = mock_resolve_input try: # 1. INTERSECTION with acquire_transforms=True (default) node.operation = 'INTERSECTION' node.acquire_transforms = True res_inter_acquired = node.evaluate_output("Paths") self.assertEqual(len(res_inter_acquired), 1) # Cutter should have shifted to (55, 35) to (65, 45), intersecting properly! pts = res_inter_acquired[0] self.assertAlmostEqual(float(np.min(pts[:, 0])), 55.0, places=2) self.assertAlmostEqual(float(np.max(pts[:, 0])), 65.0, places=2) self.assertAlmostEqual(float(np.min(pts[:, 1])), 35.0, places=2) self.assertAlmostEqual(float(np.max(pts[:, 1])), 45.0, places=2) # 2. INTERSECTION with acquire_transforms=False node.acquire_transforms = False res_inter_no_acquire = node.evaluate_output("Paths") # Without acquiring transforms, cutter remains at (5, 5)-(15, 15), no overlap with (50, 30)-(70, 50)! self.assertEqual(len(res_inter_no_acquire), 0) # 3. SUBTRACT with acquire_transforms=True node.operation = 'SUBTRACT' node.acquire_transforms = True res_sub_acquired = node.evaluate_output("Paths") # Subtracting the cutter from the primary square creates outer boundary + interior hole = 2 paths! self.assertEqual(len(res_sub_acquired), 2) # 4. SUBTRACT with acquire_transforms=False node.operation = 'SUBTRACT' node.acquire_transforms = False res_sub_no_acquire = node.evaluate_output("Paths") # Without acquiring transforms, cutter does not overlap primary square, primary remains untouched (1 path) self.assertEqual(len(res_sub_no_acquire), 1) # 5. ZIP with acquire_transforms=True aligns subsequent paths node.operation = 'ZIP' node.acquire_transforms = True res_zip_acquired = node.evaluate_output("Paths") self.assertEqual(len(res_zip_acquired), 2) # Second path in zip result should be at (55, 35)-(65, 45) pts_zip2 = res_zip_acquired[1] self.assertAlmostEqual(float(np.min(pts_zip2[:, 0])), 55.0, places=2) self.assertAlmostEqual(float(np.max(pts_zip2[:, 0])), 65.0, places=2) # 6. Both already have the same transforms -> no double transformation sp_secondary_transformed = SlicePaths([cutter_coords + np.array([50.0, 30.0, 0.0])], transforms=[{ "type": "transform", "translation": (50.0, 30.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0) }]) def mock_resolve_both(sock): if sock == sock0: return sp_primary return sp_secondary_transformed generic_mod.resolve_input = mock_resolve_both node.operation = 'INTERSECTION' node.acquire_transforms = True res_both = node.evaluate_output("Paths") self.assertEqual(len(res_both), 1) pts_both = res_both[0] self.assertAlmostEqual(float(np.min(pts_both[:, 0])), 55.0, places=2) self.assertAlmostEqual(float(np.max(pts_both[:, 0])), 65.0, places=2) # 7. Element-wise list behavior: primary is list [sp_primary, sp_primary2] sp_primary2 = SlicePaths([sq_coords + np.array([100.0, 100.0, 0.0])], transforms=[{ "type": "transform", "translation": (100.0, 100.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0) }]) def mock_resolve_list(sock): if sock == sock0: return [sp_primary, sp_primary2] return sp_secondary generic_mod.resolve_input = mock_resolve_list node.operation = 'INTERSECTION' node.acquire_transforms = True res_list = node.evaluate_output("Paths") self.assertIsInstance(res_list, list) self.assertEqual(len(res_list), 2) # Item 0 cutter shifted by (50, 30): min X=55 self.assertAlmostEqual(float(np.min(res_list[0][0][:, 0])), 55.0, places=2) # Item 1 cutter shifted by (100, 100): min X=105 self.assertAlmostEqual(float(np.min(res_list[1][0][:, 0])), 105.0, places=2) finally: generic_mod.resolve_input = orig_resolve if __name__ == '__main__': unittest.main()