nodeslicer/tests/test_anchor_paths.py
2026-10-15 11:01:23 +03:00

289 lines
12 KiB
Python

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 clockwise for both endpoints:
# Start vertex (5,0) touches bottom edge (0->10).
# Clockwise is -X (towards 0,0), so extension goes to (3,0).
# End vertex (5,10) touches top edge (10->0).
# Clockwise is +X (towards 10,10), so extension goes to (7,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], [7.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 counter-clockwise for both endpoints:
# Start vertex (5,0): counter-clockwise is +X (towards 10,0) -> (7,0).
# End vertex (5,10): counter-clockwise is -X (towards 0,10) -> (3,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], [3.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 (clockwise on bottom edge -> (3,0))
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 (clockwise on top edge -> (7,10))
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], [7.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).
# Clockwise is -X: from (1,0) reaches (0,0) after 1.0mm.
# Remaining 2.0mm continues onto left edge towards (0,10) (North, +Y), reaching (0, 2, 0.2).
# Top edge is (10,10) -> (0,10).
# Clockwise is +X: from (1,10) reaches (4,10) after 3.0mm.
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]
self.assertEqual(len(p), 5)
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)
np.testing.assert_allclose(p[3], [1.0, 10.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p[4], [4.0, 10.0, 0.2], atol=1e-5)
def test_anchor_open_path_direction_invariant(self):
# Verifies that both A->B and B->A extend in the exact same clockwise direction
line_fwd = np.array([[5.0, 0.0, 0.2], [5.0, 10.0, 0.2]], dtype=np.float64)
line_rev = np.array([[5.0, 10.0, 0.2], [5.0, 0.0, 0.2]], dtype=np.float64)
res_fwd = SlicePaths([line_fwd]).anchor(self.anchor_sp, distance=2.0, reverse=False)
res_rev = SlicePaths([line_rev]).anchor(self.anchor_sp, distance=2.0, reverse=False)
# For fwd: start at (5,0) extends to (3,0); end at (5,10) extends to (7,10)
p_fwd = res_fwd[0]
np.testing.assert_allclose(p_fwd[0], [3.0, 0.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p_fwd[-1], [7.0, 10.0, 0.2], atol=1e-5)
# For rev: start at (5,10) extends to (7,10); end at (5,0) extends to (3,0)
p_rev = res_rev[0]
np.testing.assert_allclose(p_rev[0], [7.0, 10.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p_rev[-1], [3.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()