nodeslicer/tests/test_transform.py

264 lines
9.6 KiB
Python

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
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.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 == "Mesh":
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("Mesh")
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_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])
if __name__ == '__main__':
unittest.main()