289 lines
12 KiB
Python
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()
|
|
|