import unittest import numpy as np import sys from pathlib import Path from unittest.mock import MagicMock PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.append(str(PROJECT_ROOT.parent)) sys.path.append(str(PROJECT_ROOT)) class DummyNodeSocket: def __init__(self, name="", identifier="", node=None, value=None): self.name = name self.identifier = identifier self.node = node self.value = value self.is_linked = False self.links = [] self.display_shape = "CIRCLE" self.default_value = value 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, anchor_paths from NodeSlicer.core.slice import SlicePaths, is_closed_path, anchor_paths from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_AnchorPaths class DummySocketCollection(list): def get(self, name, default=None): for s in self: if getattr(s, "name", "") == name: return s return default class TestAnchorPaths(unittest.TestCase): def setUp(self): # 10x10 CCW square boundary: # (0,0) -> (10,0) [bottom, East] # (10,0) -> (10,10) [right, North] # (10,10) -> (0,10) [top, West] # (0,10) -> (0,0) [left, South] self.square = np.array([ [0.0, 0.0, 0.2], [10.0, 0.0, 0.2], [10.0, 10.0, 0.2], [0.0, 10.0, 0.2], [0.0, 0.0, 0.2], ], dtype=np.float64) self.anchor_sp = SlicePaths([self.square]) def test_anchor_both_ends_forward(self): # Infill line from (5,0) to (5,10) infill = np.array([[5.0, 0.0, 0.2], [5.0, 10.0, 0.2]], dtype=np.float64) sp = SlicePaths([infill]) res = sp.anchor(self.anchor_sp, distance=2.0, reverse=False) self.assertEqual(len(res), 1) p = res[0] # reverse=False follows anchor in forward (+1) direction: # Start vertex (5,0) touches bottom edge (0->10). # Forward is +X, so nozzle enters from (3,0) to (5,0). # End vertex (5,10) touches top edge (10->0). # Forward is -X, so nozzle exits from (5,10) to (3,10). self.assertEqual(len(p), 4) np.testing.assert_allclose(p[0], [3.0, 0.0, 0.2], atol=1e-5) np.testing.assert_allclose(p[1], [5.0, 0.0, 0.2], atol=1e-5) np.testing.assert_allclose(p[2], [5.0, 10.0, 0.2], atol=1e-5) np.testing.assert_allclose(p[3], [3.0, 10.0, 0.2], atol=1e-5) def test_anchor_both_ends_reverse(self): infill = np.array([[5.0, 0.0, 0.2], [5.0, 10.0, 0.2]], dtype=np.float64) sp = SlicePaths([infill]) res = sp.anchor(self.anchor_sp, distance=2.0, reverse=True) self.assertEqual(len(res), 1) p = res[0] # reverse=True follows anchor in opposite (-1) direction: # Start vertex (5,0): opposite of +X is -X, so nozzle enters from (7,0) to (5,0). # End vertex (5,10): opposite of -X is +X, so nozzle exits from (5,10) to (7,10). self.assertEqual(len(p), 4) np.testing.assert_allclose(p[0], [7.0, 0.0, 0.2], atol=1e-5) np.testing.assert_allclose(p[1], [5.0, 0.0, 0.2], atol=1e-5) np.testing.assert_allclose(p[2], [5.0, 10.0, 0.2], atol=1e-5) np.testing.assert_allclose(p[3], [7.0, 10.0, 0.2], atol=1e-5) def test_anchor_start_only(self): # Infill touching only at start: (5,0) to (5,5) infill = np.array([[5.0, 0.0, 0.2], [5.0, 5.0, 0.2]], dtype=np.float64) sp = SlicePaths([infill]) res = sp.anchor(self.anchor_sp, distance=2.0, reverse=False) self.assertEqual(len(res), 1) p = res[0] # Only start prepended self.assertEqual(len(p), 3) np.testing.assert_allclose(p[0], [3.0, 0.0, 0.2], atol=1e-5) np.testing.assert_allclose(p[1], [5.0, 0.0, 0.2], atol=1e-5) np.testing.assert_allclose(p[2], [5.0, 5.0, 0.2], atol=1e-5) def test_anchor_end_only(self): # Infill touching only at end: (5,5) to (5,10) infill = np.array([[5.0, 5.0, 0.2], [5.0, 10.0, 0.2]], dtype=np.float64) sp = SlicePaths([infill]) res = sp.anchor(self.anchor_sp, distance=2.0, reverse=False) self.assertEqual(len(res), 1) p = res[0] # Only end appended self.assertEqual(len(p), 3) np.testing.assert_allclose(p[0], [5.0, 5.0, 0.2], atol=1e-5) np.testing.assert_allclose(p[1], [5.0, 10.0, 0.2], atol=1e-5) np.testing.assert_allclose(p[2], [3.0, 10.0, 0.2], atol=1e-5) def test_anchor_closed_path_ignored(self): # A closed square inside closed_box = np.array([ [2.0, 2.0, 0.2], [4.0, 2.0, 0.2], [4.0, 4.0, 0.2], [2.0, 4.0, 0.2], [2.0, 2.0, 0.2], ], dtype=np.float64) sp = SlicePaths([closed_box]) res = sp.anchor(self.anchor_sp, distance=2.0) self.assertEqual(len(res), 1) np.testing.assert_allclose(res[0], closed_box) def test_anchor_corner_turn(self): # Infill starts at (1, 0, 0.2) and goes to (1, 10, 0.2) # Distance = 3.0, reverse = False # Bottom edge is (0,0) -> (10,0). # Walking backward (-1) from (1,0) reaches (0,0) after 1.0mm. # Remaining 2.0mm continues onto left edge (0,10) -> (0,0). # Walking backward along left edge means going South from (0,0) towards (0,10), # which reaches (0, 2, 0.2). # Prefix reversed: [(0, 2), (0, 0)] infill = np.array([[1.0, 0.0, 0.2], [1.0, 10.0, 0.2]], dtype=np.float64) sp = SlicePaths([infill]) res = sp.anchor(self.anchor_sp, distance=3.0, reverse=False) p = res[0] np.testing.assert_allclose(p[0], [0.0, 2.0, 0.2], atol=1e-5) np.testing.assert_allclose(p[1], [0.0, 0.0, 0.2], atol=1e-5) np.testing.assert_allclose(p[2], [1.0, 0.0, 0.2], atol=1e-5) def test_anchor_layer_mismatch_ignored(self): # Infill on layer Z = 0.4, anchor on layer Z = 0.2 infill = np.array([[5.0, 0.0, 0.4], [5.0, 10.0, 0.4]], dtype=np.float64) sp = SlicePaths([infill]) res = sp.anchor(self.anchor_sp, distance=2.0) # Untouched because layer heights do not match self.assertEqual(len(res[0]), 2) np.testing.assert_allclose(res[0], infill) def test_anchor_attributes_preservation(self): infill = np.array([[5.0, 0.0, 0.2], [5.0, 10.0, 0.2]], dtype=np.float64) ext = np.array([0.8, 1.2]) feed = np.array([1200.0, 1500.0]) extra = {"S": [np.array([10.0, 20.0])]} sp = SlicePaths([infill], extrusion_rate=[ext], feedrate=[feed], extra_attributes=extra) res = sp.anchor(self.anchor_sp, distance=2.0, reverse=False) self.assertEqual(len(res[0]), 4) self.assertEqual(len(res.extrusion_rate[0]), 4) self.assertEqual(len(res.feedrate[0]), 4) self.assertEqual(len(res.extra_attributes["S"][0]), 4) # Start anchor inherited from start vertex self.assertAlmostEqual(res.extrusion_rate[0][0], 0.8) self.assertAlmostEqual(res.feedrate[0][0], 1200.0) self.assertAlmostEqual(res.extra_attributes["S"][0][0], 10.0) # End anchor inherited from end vertex self.assertAlmostEqual(res.extrusion_rate[0][-1], 1.2) self.assertAlmostEqual(res.feedrate[0][-1], 1500.0) self.assertAlmostEqual(res.extra_attributes["S"][0][-1], 20.0) def test_node_atomic_and_list_eval(self): node = SLICENODE_AnchorPaths() in_paths = DummyNodeSocket("Paths", "SLICESOCK_Paths", node=node) in_anchors = DummyNodeSocket("Anchor Paths", "SLICESOCK_Paths", node=node) in_dist = DummyNodeSocket("Distance", "NodeSocketFloat", node=node, value=2.0) in_rev = DummyNodeSocket("Reverse", "NodeSocketBool", node=node, value=False) out_paths = DummyNodeSocket("Paths", "SLICESOCK_Paths", node=node) node.inputs = DummySocketCollection([in_paths, in_anchors, in_dist, in_rev]) node.outputs = DummySocketCollection([out_paths]) infill1 = np.array([[5.0, 0.0, 0.2], [5.0, 10.0, 0.2]], dtype=np.float64) sp1 = SlicePaths([infill1]) # 1. Atomic eval in_paths.value = sp1 in_anchors.value = self.anchor_sp res_atom = node.evaluate_output("Paths") self.assertIsInstance(res_atom, SlicePaths) self.assertEqual(len(res_atom[0]), 4) # 2. List eval (Paths is a list of 2 SlicePaths, Anchor is atomic) infill2 = np.array([[3.0, 0.0, 0.2], [3.0, 10.0, 0.2]], dtype=np.float64) sp2 = SlicePaths([infill2]) in_paths.value = [sp1, sp2] res_list = node.evaluate_output("Paths") self.assertIsInstance(res_list, list) self.assertEqual(len(res_list), 2) self.assertEqual(len(res_list[0][0]), 4) self.assertEqual(len(res_list[1][0]), 4) # 3. Reverse toggle via property node.reverse = True in_rev.value = True res_rev = node.evaluate_output("Paths") np.testing.assert_allclose(res_rev[0][0][0], [7.0, 0.0, 0.2], atol=1e-5) def test_visual_generation(self): import os from NodeSlicer.tests.visual.visualize_anchor_paths import run_all_anchor_visualizations images = run_all_anchor_visualizations() self.assertEqual(len(images), 4) for img in images: self.assertTrue(os.path.exists(img), f"Missing image {img}") self.assertGreater(os.path.getsize(img), 1000, f"Image {img} too small") if __name__ == '__main__': unittest.main()