import numpy as np import unittest from unittest.mock import MagicMock import sys from pathlib import Path # Add workspace directory to sys.path PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.append(str(PROJECT_ROOT.parent)) # Define dummy classes to avoid metaclass conflicts with bpy 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, is_closed_path, find_path_mesh_intersection, reposition_path_seam, extract_mesh_triangles, ) from NodeSlicer.infra.blender.blender_mesh_capture import ( SlicingObject, SlicingCollection, SlicingGroup, ) from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_SeamPlacement def create_triangle_mesh(v0, v1, v2) -> np.ndarray: """Helper to create a single triangle mesh with shape (1, 4, 3).""" e1 = np.array(v1) - np.array(v0) e2 = np.array(v2) - np.array(v0) n = np.cross(e1, e2) norm = np.linalg.norm(n) if norm > 0: n = n / norm tri = np.zeros((1, 4, 3), dtype=np.float64) tri[0, 0] = v0 tri[0, 1] = v1 tri[0, 2] = v2 tri[0, 3] = n return tri def create_quad_mesh(p0, p1, p2, p3) -> np.ndarray: """Helper to create a quad mesh (2 triangles) with shape (2, 4, 3).""" t1 = create_triangle_mesh(p0, p1, p2) t2 = create_triangle_mesh(p0, p2, p3) return np.concatenate([t1, t2], axis=0) class TestSeamPlacement(unittest.TestCase): def setUp(self): # Base square path at Z=0: from (-10,-10) -> (10,-10) -> (10,10) -> (-10,10) -> (-10,-10) self.square_path = np.array([ [-10.0, -10.0, 0.0], [10.0, -10.0, 0.0], [10.0, 10.0, 0.0], [-10.0, 10.0, 0.0], [-10.0, -10.0, 0.0] ], dtype=np.float64) def test_single_intersection_edge_midpoint(self): # Vertical cutting plane through top edge at X=0, Y=10 # Triangle cutting only the top edge: X in [-0.5, 0.5], Y in [9, 11], Z in [-5, 5] cutting_plane = create_quad_mesh( [0.0, 5.0, -5.0], [0.0, 15.0, -5.0], [0.0, 15.0, 5.0], [0.0, 5.0, 5.0] ) sp = SlicePaths([self.square_path]) res = sp.place_seams(cutting_plane) self.assertEqual(len(res), 1) mod_p = res[0] # Should be a valid closed loop self.assertTrue(is_closed_path(mod_p)) # Seam should now be at (0, 10, 0) np.testing.assert_allclose(mod_p[0], [0.0, 10.0, 0.0], atol=1e-4) np.testing.assert_allclose(mod_p[-1], [0.0, 10.0, 0.0], atol=1e-4) # Total points should have increased by 1 (inserted vertex) self.assertEqual(len(mod_p), 6) def test_intersection_snap_to_existing_vertex(self): # Cutting plane passing right through corner vertex (10, 10, 0) cutting_plane = create_quad_mesh( [9.0, 9.0, -5.0], [11.0, 11.0, -5.0], [11.0, 11.0, 5.0], [9.0, 9.0, 5.0] ) sp = SlicePaths([self.square_path]) res = sp.place_seams(cutting_plane) self.assertEqual(len(res), 1) mod_p = res[0] self.assertTrue(is_closed_path(mod_p)) np.testing.assert_allclose(mod_p[0], [10.0, 10.0, 0.0], atol=1e-4) np.testing.assert_allclose(mod_p[-1], [10.0, 10.0, 0.0], atol=1e-4) # Should snap to existing vertex without increasing point count self.assertEqual(len(mod_p), 5) def test_no_intersection_preserves_path(self): # Guide mesh far away at Z=100 mesh_far = create_quad_mesh( [0.0, 5.0, 95.0], [0.0, 15.0, 95.0], [0.0, 15.0, 105.0], [0.0, 5.0, 105.0] ) sp = SlicePaths([self.square_path]) res = sp.place_seams(mesh_far) self.assertEqual(len(res), 1) np.testing.assert_allclose(res[0], self.square_path, atol=1e-7) def test_open_path_untouched(self): # Open path (3 vertices, not closed) open_p = np.array([ [-10.0, -10.0, 0.0], [0.0, 0.0, 0.0], [10.0, 10.0, 0.0] ], dtype=np.float64) cutting_plane = create_quad_mesh( [0.0, -5.0, -5.0], [0.0, 5.0, -5.0], [0.0, 5.0, 5.0], [0.0, -5.0, 5.0] ) sp = SlicePaths([open_p]) res = sp.place_seams(cutting_plane) self.assertEqual(len(res), 1) np.testing.assert_allclose(res[0], open_p, atol=1e-7) def test_holes_handling(self): # Outer boundary square: 40x40 from (-20,-20) to (20,20) outer_p = np.array([ [-20.0, -20.0, 0.0], [20.0, -20.0, 0.0], [20.0, 20.0, 0.0], [-20.0, 20.0, 0.0], [-20.0, -20.0, 0.0] ], dtype=np.float64) # Hole 1 in top-left: square 6x6 from (-15, 5) to (-9, 11) (clockwise) hole1_p = np.array([ [-15.0, 11.0, 0.0], [-9.0, 11.0, 0.0], [-9.0, 5.0, 0.0], [-15.0, 5.0, 0.0], [-15.0, 11.0, 0.0] ], dtype=np.float64) # Hole 2 in bottom-right: square 6x6 from (9, -15) to (15, -9) hole2_p = np.array([ [9.0, -9.0, 0.0], [15.0, -9.0, 0.0], [15.0, -15.0, 0.0], [9.0, -15.0, 0.0], [9.0, -9.0, 0.0] ], dtype=np.float64) # Cutting plane cutting through outer top edge (at X=-12, Y=20) and Hole 1 (at X=-12, Y=11 and Y=5) # Plane is at X = -12, spanning Y in [0, 25], Z in [-5, 5] cutting_plane = create_quad_mesh( [-12.0, 0.0, -5.0], [-12.0, 25.0, -5.0], [-12.0, 25.0, 5.0], [-12.0, 0.0, 5.0] ) sp = SlicePaths([outer_p, hole1_p, hole2_p]) res = sp.place_seams(cutting_plane) self.assertEqual(len(res), 3) # 1. Outer boundary seam should be moved to (-12, 20, 0) np.testing.assert_allclose(res[0][0], [-12.0, 20.0, 0.0], atol=1e-4) np.testing.assert_allclose(res[0][-1], [-12.0, 20.0, 0.0], atol=1e-4) self.assertTrue(is_closed_path(res[0])) # 2. Hole 1 seam should be moved to (-12, 11, 0) (or intersection on hole1) self.assertTrue(is_closed_path(res[1])) np.testing.assert_allclose(res[1][0, 0], -12.0, atol=1e-4) np.testing.assert_allclose(res[1][-1, 0], -12.0, atol=1e-4) # 3. Hole 2 does NOT intersect the plane at X=-12, so its seam stays unchanged! np.testing.assert_allclose(res[2], hole2_p, atol=1e-7) def test_mesh_input_types_agnostic(self): # Test SlicingObject, SlicingCollection, SlicingGroup, and list tri_data = create_quad_mesh( [0.0, 5.0, -5.0], [0.0, 15.0, -5.0], [0.0, 15.0, 5.0], [0.0, 5.0, 5.0] ) so = SlicingObject() so.mesh = tri_data sc = SlicingCollection([], "test_coll") sc.objects = [so] sg = SlicingGroup([]) sg.collections["default"] = sc sp = SlicePaths([self.square_path]) # 1. SlicingObject res_so = sp.place_seams(so) np.testing.assert_allclose(res_so[0][0], [0.0, 10.0, 0.0], atol=1e-4) # 2. SlicingCollection res_sc = sp.place_seams(sc) np.testing.assert_allclose(res_sc[0][0], [0.0, 10.0, 0.0], atol=1e-4) # 3. SlicingGroup res_sg = sp.place_seams(sg) np.testing.assert_allclose(res_sg[0][0], [0.0, 10.0, 0.0], atol=1e-4) # 4. List of objects res_list = sp.place_seams([so, sc]) np.testing.assert_allclose(res_list[0][0], [0.0, 10.0, 0.0], atol=1e-4) # 5. None / Empty res_none = sp.place_seams(None) np.testing.assert_allclose(res_none[0], self.square_path, atol=1e-7) def test_attribute_rotation_and_interpolation(self): # Square path with non-uniform extrusion rate and feedrate ext = np.array([1.0, 2.0, 3.0, 4.0, 1.0], dtype=np.float64) feed = np.array([100.0, 200.0, 300.0, 400.0, 100.0], dtype=np.float64) extra = {"temp": [np.array([210.0, 215.0, 220.0, 225.0, 210.0], dtype=np.float64)]} sp = SlicePaths([self.square_path], extrusion_rate=[ext], feedrate=[feed], extra_attributes=extra) # Plane cutting segment 2 (between (10, 10) and (-10, 10)) at midpoint X=0, Y=10 (t=0.5) cutting_plane = create_quad_mesh( [0.0, 5.0, -5.0], [0.0, 15.0, -5.0], [0.0, 15.0, 5.0], [0.0, 5.0, 5.0] ) res = sp.place_seams(cutting_plane) self.assertEqual(len(res), 1) # Interpolated value at t=0.5 on segment 2: # segment 2 is between vertex 2 (ext=3.0) and vertex 3 (ext=4.0) # Expected interpolated ext at new seam: 3.5 expected_seam_ext = 3.5 self.assertAlmostEqual(res.extrusion_rate[0][0], expected_seam_ext, places=3) self.assertAlmostEqual(res.extrusion_rate[0][-1], expected_seam_ext, places=3) # Feedrate at t=0.5: between 300.0 and 400.0 -> 350.0 self.assertAlmostEqual(res.feedrate[0][0], 350.0, places=3) self.assertAlmostEqual(res.feedrate[0][-1], 350.0, places=3) # Extra attribute at t=0.5: between 220.0 and 225.0 -> 222.5 self.assertAlmostEqual(res.extra_attributes["temp"][0][0], 222.5, places=3) self.assertAlmostEqual(res.extra_attributes["temp"][0][-1], 222.5, places=3) def test_seam_placement_modes(self): # Plane that cuts square path at two places: # 1. Top edge at (0, 10, 0) # 2. Bottom edge at (0, -10, 0) full_plane = create_quad_mesh( [0.0, -15.0, -5.0], [0.0, 15.0, -5.0], [0.0, 15.0, 5.0], [0.0, -15.0, 5.0] ) # Original square starts at (-10, -10, 0) (closer to (0, -10, 0)) sp = SlicePaths([self.square_path]) # Mode: CLOSEST -> should pick (0, -10, 0) because original seam is at (-10, -10, 0) res_closest = sp.place_seams(full_plane, mode="closest") np.testing.assert_allclose(res_closest[0][0], [0.0, -10.0, 0.0], atol=1e-4) # Mode: FIRST -> segment 0 goes from (-10, -10) to (10, -10), so first intersection is (0, -10, 0) res_first = sp.place_seams(full_plane, mode="first") np.testing.assert_allclose(res_first[0][0], [0.0, -10.0, 0.0], atol=1e-4) def test_seam_placement_node_execution(self): # Test SLICENODE_SeamPlacement evaluate_output method node = SLICENODE_SeamPlacement() node.mode = 'CLOSEST' cutting_plane = create_quad_mesh( [0.0, 5.0, -5.0], [0.0, 15.0, -5.0], [0.0, 15.0, 5.0], [0.0, 5.0, 5.0] ) sp = SlicePaths([self.square_path]) so = SlicingObject() so.mesh = cutting_plane # Mock resolve method def mock_resolve(socket_name): if socket_name == "Paths": return sp elif socket_name == "Mesh": return so return None node.resolve = mock_resolve out = node.evaluate_output("Paths") self.assertIsInstance(out, SlicePaths) self.assertEqual(len(out), 1) np.testing.assert_allclose(out[0][0], [0.0, 10.0, 0.0], atol=1e-4) def test_acquire_transforms(self): # Shifted square path by +50 in X and +30 in Y shifted_square = self.square_path + 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) }] sp = SlicePaths([shifted_square], transforms=transforms) # Cutting plane at original untransformed location at X=0, Y=10 cutting_plane = create_quad_mesh( [0.0, 5.0, -5.0], [0.0, 15.0, -5.0], [0.0, 15.0, 5.0], [0.0, 5.0, 5.0] ) # 1. With acquire_transforms=True (default): # The cutting plane should be transformed by +50 X and +30 Y, intersecting at (50, 40, 0) res_acquired = sp.place_seams(cutting_plane, acquire_transforms=True) self.assertEqual(len(res_acquired), 1) np.testing.assert_allclose(res_acquired[0][0], [50.0, 40.0, 0.0], atol=1e-4) np.testing.assert_allclose(res_acquired[0][-1], [50.0, 40.0, 0.0], atol=1e-4) # 2. With acquire_transforms=False: # The cutting plane remains at (0, 10, 0) and does not intersect the path at (50, 30, 0) res_no_acquire = sp.place_seams(cutting_plane, acquire_transforms=False) self.assertEqual(len(res_no_acquire), 1) np.testing.assert_allclose(res_no_acquire[0], shifted_square, atol=1e-7) def test_node_acquire_transforms_checkbox(self): node = SLICENODE_SeamPlacement() self.assertTrue(node.acquire_transforms) shifted_square = self.square_path + 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) }] sp = SlicePaths([shifted_square], transforms=transforms) cutting_plane = create_quad_mesh( [0.0, 5.0, -5.0], [0.0, 15.0, -5.0], [0.0, 15.0, 5.0], [0.0, 5.0, 5.0] ) so = SlicingObject() so.mesh = cutting_plane def mock_resolve(socket_name): if socket_name == "Paths": return sp elif socket_name == "Mesh": return so return None node.resolve = mock_resolve # When checkbox is True node.acquire_transforms = True out_true = node.evaluate_output("Paths") np.testing.assert_allclose(out_true[0][0], [50.0, 40.0, 0.0], atol=1e-4) # When checkbox is False node.acquire_transforms = False out_false = node.evaluate_output("Paths") np.testing.assert_allclose(out_false[0], shifted_square, atol=1e-7) def test_large_dataset_performance(self): # 500 layers of complex circular paths (100 vertices per circle) num_layers = 500 angles = np.linspace(0, 2 * np.pi, 100) circle_2d = np.stack([10.0 * np.cos(angles), 10.0 * np.sin(angles)], axis=-1) paths = [] for i in range(num_layers): z = i * 0.2 p = np.empty((len(circle_2d), 3), dtype=np.float64) p[:, :2] = circle_2d p[:, 2] = z paths.append(p) sp = SlicePaths(paths) # Mesh with 1,000 triangles spanning Z in [0, 100] # Cutting quad at X=0, Y in [5, 15], Z in [-5, 105] cutting_plane = create_quad_mesh( [0.0, 5.0, -5.0], [0.0, 15.0, -5.0], [0.0, 15.0, 105.0], [0.0, 5.0, 105.0] ) import time start_time = time.perf_counter() res = sp.place_seams(cutting_plane) duration = time.perf_counter() - start_time self.assertEqual(len(res), num_layers) for i in range(num_layers): self.assertTrue(is_closed_path(res[i])) # Should have seam on positive Y axis at X=0 np.testing.assert_allclose(res[i][0, 0], 0.0, atol=1e-3) self.assertGreater(res[i][0, 1], 9.0) # 500 layers must complete in well under 1 second (typically < 50ms) self.assertLess(duration, 1.0, f"Execution took too long: {duration:.3f}s") def test_complex_high_poly_mesh_performance(self): # 500 layers, each with an outer contour and an inner hole (1000 paths total) num_layers = 500 angles = np.linspace(0, 2 * np.pi, 80) outer_2d = np.stack([20.0 * np.cos(angles), 20.0 * np.sin(angles)], axis=-1) inner_2d = np.stack([10.0 * np.cos(angles[::-1]), 10.0 * np.sin(angles[::-1])], axis=-1) paths = [] for i in range(num_layers): z = i * 0.2 po = np.empty((len(outer_2d), 3), dtype=np.float64) po[:, :2] = outer_2d po[:, 2] = z paths.append(po) pi = np.empty((len(inner_2d), 3), dtype=np.float64) pi[:, :2] = inner_2d pi[:, 2] = z paths.append(pi) sp = SlicePaths(paths) # Generate a high-poly mesh of 25,000 quads (50,000 triangles) # spanning Z in [-10, 110] n_quads = 25000 z_grid = np.linspace(-10, 110, n_quads + 1) tris = np.zeros((n_quads * 2, 4, 3), dtype=np.float64) for q in range(n_quads): z0 = z_grid[q] z1 = z_grid[q + 1] # Quad cutting at X=0, Y in [5, 25] p0 = np.array([0.0, 5.0, z0]) p1 = np.array([0.0, 25.0, z0]) p2 = np.array([0.0, 25.0, z1]) p3 = np.array([0.0, 5.0, z1]) tris[q * 2, 0] = p0 tris[q * 2, 1] = p1 tris[q * 2, 2] = p2 tris[q * 2 + 1, 0] = p0 tris[q * 2 + 1, 1] = p2 tris[q * 2 + 1, 2] = p3 import time start_time = time.perf_counter() res = sp.place_seams(tris) duration = time.perf_counter() - start_time self.assertEqual(len(res), num_layers * 2) # Even with 50,000 triangles and 1,000 paths across 500 layers, # layer batching must finish in under 0.5s self.assertLess(duration, 0.5, f"High-poly seam placement took {duration:.3f}s (must be < 0.5s)") def test_acquire_transforms_with_rotate_path_center(self): # Square centered at (100, 100): from (90, 90) to (110, 110) center = np.array([100.0, 100.0, 0.0]) square_offset = np.array([ [90.0, 90.0, 0.0], [110.0, 90.0, 0.0], [110.0, 110.0, 0.0], [90.0, 110.0, 0.0], [90.0, 90.0, 0.0] ], dtype=np.float64) sp = SlicePaths([square_offset]) # Cutting plane cutting top edge at (100, 110, 0) cutting_plane = create_quad_mesh( [100.0, 105.0, -5.0], [100.0, 115.0, -5.0], [100.0, 115.0, 5.0], [100.0, 105.0, 5.0] ) # Rotate path by 90 degrees around center (100, 100, 0) # Top edge (100, 110) -> rotates by +90 deg (CCW) around (100, 100) to (90, 100) rotated_sp = sp.rotate(90.0, center=center) # When acquire_transforms=True, the cutting plane must rotate around the SAME center (100, 100) # So the cutting plane at (100, 110) rotates to (90, 100) and cuts the rotated path at (90, 100, 0) res = rotated_sp.place_seams(cutting_plane, acquire_transforms=True) self.assertEqual(len(res), 1) self.assertTrue(is_closed_path(res[0])) np.testing.assert_allclose(res[0][0], [90.0, 100.0, 0.0], atol=1e-4) np.testing.assert_allclose(res[0][-1], [90.0, 100.0, 0.0], atol=1e-4) def test_acquire_transforms_when_mesh_already_transformed(self): # Transformed path and transformed SlicingCollection # Both already have transforms: translation +142.93 in Z, rotation -90 deg around Y trans_record = [{ "type": "transform", "translation": (0.0, 0.0, 142.93), "rotation": (0.0, -np.pi / 2.0, 0.0), "scale": (1.0, 1.0, 1.0) }] # Create original path and cutting plane orig_p = np.array([ [-10.0, -10.0, 0.0], [10.0, -10.0, 0.0], [10.0, 10.0, 0.0], [-10.0, 10.0, 0.0], [-10.0, -10.0, 0.0] ], dtype=np.float64) cutting_plane = create_quad_mesh( [0.0, 5.0, -5.0], [0.0, 15.0, -5.0], [0.0, 15.0, 5.0], [0.0, 5.0, 5.0] ) from NodeSlicer.core.pdf_exporter.geometry import transform_slice_paths from NodeSlicer.core.slice import apply_transforms_to_triangles # Transform both the paths and the cutting mesh sp = transform_slice_paths(SlicePaths([orig_p]), (0.0, 0.0, 142.93), (0.0, -np.pi / 2.0, 0.0), (1.0, 1.0, 1.0)) sp.transforms = [dict(t) for t in trans_record] transformed_tris = apply_transforms_to_triangles(cutting_plane, trans_record) so = SlicingObject() so.mesh = transformed_tris so.transforms = [dict(t) for t in trans_record] # When acquire_transforms=True is passed with a mesh that is ALREADY transformed, # it must NOT double-transform the mesh! res = sp.place_seams(so, acquire_transforms=True) self.assertEqual(len(res), 1) self.assertTrue(is_closed_path(res[0])) # The seam must be placed at the intersection (not left unchanged) # Transformed seam should NOT equal the unmodified transformed start point self.assertFalse(np.allclose(res[0][0], sp[0][0])) def test_seam_staggering_alternating_layers_with_mesh(self): # Create 3 layers of square paths: Z=0.0, Z=0.2, Z=0.4 # Path vertices: (-10,-10) -> (10,-10) -> (10,10) -> (-10,10) -> (-10,-10) # Cutting plane at top edge (X=0, Y=10) p0 = self.square_path.copy() p0[:, 2] = 0.0 p1 = self.square_path.copy() p1[:, 2] = 0.2 p2 = self.square_path.copy() p2[:, 2] = 0.4 sp = SlicePaths([p0, p1, p2]) cutting_plane = create_quad_mesh( [0.0, 5.0, -5.0], [0.0, 15.0, -5.0], [0.0, 15.0, 5.0], [0.0, 5.0, 5.0] ) stagger_dist = 2.0 res = sp.place_seams(cutting_plane, stagger_mm=stagger_dist) self.assertEqual(len(res), 3) for p in res: self.assertTrue(is_closed_path(p)) # Intersection is at (0, 10, Z). # On top edge from (10, 10) to (-10, 10), moving forward along path goes in -X direction. # Layer 0 (even): +2.0 mm forward along path -> (0 - 2.0, 10, 0.0) = (-2.0, 10.0, 0.0) np.testing.assert_allclose(res[0][0], [-2.0, 10.0, 0.0], atol=1e-4) np.testing.assert_allclose(res[0][-1], [-2.0, 10.0, 0.0], atol=1e-4) # Layer 1 (odd): -2.0 mm backward along path -> (0 + 2.0, 10, 0.2) = (+2.0, 10.0, 0.2) np.testing.assert_allclose(res[1][0], [2.0, 10.0, 0.2], atol=1e-4) np.testing.assert_allclose(res[1][-1], [2.0, 10.0, 0.2], atol=1e-4) # Layer 2 (even): +2.0 mm forward along path -> (-2.0, 10.0, 0.4) np.testing.assert_allclose(res[2][0], [-2.0, 10.0, 0.4], atol=1e-4) np.testing.assert_allclose(res[2][-1], [-2.0, 10.0, 0.4], atol=1e-4) def test_seam_staggering_without_mesh(self): # Stagger seams directly without any cutting mesh p0 = self.square_path.copy() p0[:, 2] = 0.0 p1 = self.square_path.copy() p1[:, 2] = 0.2 sp = SlicePaths([p0, p1]) stagger_dist = 3.0 res = sp.stagger_seams(stagger_dist) self.assertEqual(len(res), 2) self.assertTrue(is_closed_path(res[0])) self.assertTrue(is_closed_path(res[1])) # Original start was (-10, -10). # First segment is from (-10, -10) to (10, -10) along +X. # Layer 0 (even): +3.0 mm forward -> (-7.0, -10.0, 0.0) np.testing.assert_allclose(res[0][0], [-7.0, -10.0, 0.0], atol=1e-4) np.testing.assert_allclose(res[0][-1], [-7.0, -10.0, 0.0], atol=1e-4) # Layer 1 (odd): -3.0 mm backward -> wraps to previous segment from (-10, 10) to (-10, -10) at (-10.0, -7.0, 0.2) np.testing.assert_allclose(res[1][0], [-10.0, -7.0, 0.2], atol=1e-4) np.testing.assert_allclose(res[1][-1], [-10.0, -7.0, 0.2], atol=1e-4) def test_seam_staggering_preserves_attributes(self): p0 = self.square_path.copy() p0[:, 2] = 0.0 p1 = self.square_path.copy() p1[:, 2] = 0.2 ext = [np.array([1.0, 2.0, 3.0, 4.0, 1.0]), np.array([5.0, 6.0, 7.0, 8.0, 5.0])] feed = [np.array([10.0, 20.0, 30.0, 40.0, 10.0]), np.array([50.0, 60.0, 70.0, 80.0, 50.0])] extra = {"temp": [np.array([200.0, 210.0, 220.0, 230.0, 200.0]), np.array([205.0, 215.0, 225.0, 235.0, 205.0])]} sp = SlicePaths([p0, p1], extrusion_rate=ext, feedrate=feed, extra_attributes=extra) res = sp.stagger_seams(2.0) self.assertEqual(len(res), 2) self.assertEqual(len(res.extrusion_rate), 2) self.assertEqual(len(res.feedrate), 2) self.assertIn("temp", res.extra_attributes) self.assertEqual(len(res.extra_attributes["temp"]), 2) for i in range(2): self.assertEqual(len(res[i]), len(res.extrusion_rate[i])) self.assertEqual(len(res[i]), len(res.feedrate[i])) self.assertEqual(len(res[i]), len(res.extra_attributes["temp"][i])) # Extrusion rate at start and end must match for closed loop self.assertAlmostEqual(res.extrusion_rate[i][0], res.extrusion_rate[i][-1]) self.assertAlmostEqual(res.feedrate[i][0], res.feedrate[i][-1]) self.assertAlmostEqual(res.extra_attributes["temp"][i][0], res.extra_attributes["temp"][i][-1]) def test_seam_placement_node_socket_and_execution(self): node = SLICENODE_SeamPlacement() # Mock inputs inputs_dict = {} class MockSocket: def __init__(self, name): self.name = name self.value = 0.0 def mock_add_input(type_name, name=None): s_name = name or ("Paths" if "Paths" in type_name else "Mesh") sock = MockSocket(s_name) inputs_dict[s_name] = sock return sock node.add_input = mock_add_input node.add_output = MagicMock() node.init(None) self.assertIn("Seam Staggering", inputs_dict) self.assertEqual(inputs_dict["Seam Staggering"].value, 0.0) # Mock node.resolve p0 = self.square_path.copy() p0[:, 2] = 0.0 p1 = self.square_path.copy() p1[:, 2] = 0.2 sp = SlicePaths([p0, p1]) def mock_resolve(name): if name == "Paths": return sp if name == "Mesh": return None if name == "Seam Staggering": return 2.5 return None node.resolve = mock_resolve res = node.evaluate_output("Paths") self.assertEqual(len(res), 2) self.assertTrue(is_closed_path(res[0])) self.assertTrue(is_closed_path(res[1])) # Check layer 0 staggered forward by 2.5mm np.testing.assert_allclose(res[0][0], [-7.5, -10.0, 0.0], atol=1e-4) if __name__ == '__main__': unittest.main()