nodeslicer/tests/test_transform.py
2026-10-14 16:39:49 +03:00

901 lines
39 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
if 'bpy' in sys.modules and isinstance(sys.modules['bpy'], MagicMock):
mock_bpy = sys.modules['bpy']
else:
mock_bpy = MagicMock()
sys.modules['bpy'] = mock_bpy
mock_bpy.context.scene = MagicMock()
mock_bpy.context.scene.unit_settings.scale_length = 0.001
mock_bpy.types = mock_types
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 == "Geometry":
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("Geometry")
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_transform_node_slice_paths(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry
from NodeSlicer.core.slice import SlicePaths
path = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64)
sp = SlicePaths([path])
node = SLICENODE_TransformGeometry()
node.resolve = lambda f: {
"Geometry": sp,
"Translation": (10.0, 20.0, 30.0),
"Rotation": (0.0, 0.0, np.pi / 2.0),
"Scale": (2.0, 2.0, 2.0)
}.get(f)
result_sp = node.evaluate_output("Geometry")
self.assertIsNotNone(result_sp)
self.assertIsInstance(result_sp, SlicePaths)
self.assertIsNot(result_sp, sp)
# Point 1: [1, 2, 3] -> scale [2, 4, 6] -> rot Z pi/2 [-4, 2, 6] -> trans [6, 22, 36]
np.testing.assert_allclose(result_sp[0][0], [6.0, 22.0, 36.0], atol=1e-7)
# Point 2: [4, 5, 6] -> scale [8, 10, 12] -> rot Z pi/2 [-10, 8, 12] -> trans [0, 28, 42]
np.testing.assert_allclose(result_sp[0][1], [0.0, 28.0, 42.0], atol=1e-7)
# Check transforms metadata
self.assertEqual(len(result_sp.transforms), 1)
self.assertEqual(result_sp.transforms[0]["type"], "transform")
def test_transform_node_list_input(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry
from NodeSlicer.core.slice import SlicePaths
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]
path = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64)
sp = SlicePaths([path])
geom_list = [sc, sp]
node = SLICENODE_TransformGeometry()
node.resolve = lambda f: {
"Geometry": geom_list,
"Translation": (10.0, 0.0, 0.0),
"Rotation": (0.0, 0.0, 0.0),
"Scale": (1.0, 1.0, 1.0)
}.get(f)
result_list = node.evaluate_output("Geometry")
self.assertIsInstance(result_list, list)
self.assertEqual(len(result_list), 2)
self.assertIsInstance(result_list[0], SlicingCollection)
self.assertIsInstance(result_list[1], SlicePaths)
np.testing.assert_allclose(result_list[0].objects[0].mesh[0, 0], [11.0, 2.0, 3.0])
np.testing.assert_allclose(result_list[1][0][0], [11.0, 2.0, 3.0])
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])
def test_gcode_transform_metadata(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry, SLICENODE_DropGeometry
from NodeSlicer.core.slice import SlicePaths, Pather
so = SlicingObject()
so.mesh = np.array([[[0.0, 0.0, 5.0],
[10.0, 0.0, 5.0],
[5.0, 10.0, 10.0],
[0.0, 0.0, 1.0]]], dtype=np.float64)
sc = SlicingCollection([], "coll")
sc.objects = [so]
# 1. Transform Geometry
t_node = SLICENODE_TransformGeometry()
t_node.resolve = lambda f: {
"Geometry": sc,
"Translation": (10.0, 20.0, 0.0),
"Rotation": (0.0, 0.0, 0.0),
"Scale": (1.0, 1.0, 1.0)
}.get(f)
sc_transformed = t_node.evaluate_output("Geometry")
self.assertIsNotNone(sc_transformed)
self.assertEqual(len(sc_transformed.transforms), 1)
self.assertEqual(sc_transformed.transforms[0]["type"], "transform")
self.assertEqual(sc_transformed.transforms[0]["translation"], (10.0, 20.0, 0.0))
# 2. Drop Geometry
d_node = SLICENODE_DropGeometry()
d_node.resolve = lambda f: {
"Mesh": sc_transformed,
"Z": 0.0
}.get(f)
sc_dropped = d_node.evaluate_output("Mesh")
self.assertIsNotNone(sc_dropped)
self.assertEqual(len(sc_dropped.transforms), 1)
self.assertEqual(sc_dropped.transforms[0]["type"], "transform")
self.assertEqual(sc_dropped.transforms[0]["translation"], (10.0, 20.0, -5.0))
# 3. Create SlicePaths from geometry
path = np.array([[10.0, 20.0, 0.0], [20.0, 20.0, 0.0], [15.0, 30.0, 0.0]])
sp = SlicePaths([path], transforms=sc_dropped.transforms)
# 4. Generate G-code with Pather
pather = Pather(sp)
gcode = pather.to_gcode(include_header=True)
self.assertIn(";PREPARATION_TRANSFORMS=", gcode)
self.assertIn(";PREPARATION_TOTAL_TRANSLATION=10.000000,20.000000,-5.000000", gcode)
# 5. Process G-code with GcodeToMesh preview node
from NodeSlicer.core.nodeslicer.nodes.preview import SLICENODE_GcodeToMesh
preview_node = SLICENODE_GcodeToMesh()
preview_node.show_e_0 = True
mock_mod_dict = {}
class MockModifier:
type = 'NODES'
node_group = MagicMock()
def __getitem__(self, k): return mock_mod_dict.get(k)
def __setitem__(self, k, v): mock_mod_dict[k] = v
def get(self, k, default=None): return mock_mod_dict.get(k, default)
mock_obj = MagicMock()
mock_obj.type = 'MESH'
mock_obj.modifiers = [MockModifier()]
import sys
sys.modules['bpy'].data.objects = {"PreviewObj": mock_obj}
sys.modules['bpy'].data.node_groups = {"Nonplanar Slicing Preview": mock_obj.modifiers[0].node_group}
preview_node._process_gcode(gcode, "PreviewObj")
# Verify modifier sockets Socket_8, Socket_6, Socket_9
self.assertEqual(mock_mod_dict.get("Socket_8"), (0.01, 0.02, -0.005))
self.assertEqual(mock_mod_dict.get("Socket_6"), (0.0, 0.0, 0.0))
# Test overwriting on an existing mesh object with pre-existing modifier values
mock_mod_dict["Socket_8"] = [0.0, 0.0, 0.0]
mock_mod_dict["Socket_6"] = [1.0, 1.0, 1.0]
mock_mod_dict["Socket_9"] = [2.0, 2.0, 2.0]
preview_node._process_gcode(gcode, "PreviewObj")
self.assertEqual(tuple(mock_mod_dict.get("Socket_8")), (0.01, 0.02, -0.005))
self.assertEqual(tuple(mock_mod_dict.get("Socket_6")), (0.0, 0.0, 0.0))
self.assertEqual(tuple(mock_mod_dict.get("Socket_9")), (1.0, 1.0, 1.0))
# Test G-code with non-zero 3D rotation and non-uniform scale
gcode_rot = (
";PREPARATION_TOTAL_TRANSLATION=10.000000,20.000000,30.000000\n"
";PREPARATION_TOTAL_ROTATION=0.150000,0.250000,0.350000\n"
";PREPARATION_TOTAL_SCALE=1.500000,2.000000,2.500000\n"
"G1 X10 Y20 Z30 E1\n"
)
preview_node._process_gcode(gcode_rot, "PreviewObj")
self.assertEqual(tuple(mock_mod_dict.get("Socket_8")), (0.01, 0.02, 0.03))
self.assertEqual(tuple(mock_mod_dict.get("Socket_6")), (0.15, 0.25, 0.35))
self.assertEqual(tuple(mock_mod_dict.get("Socket_9")), (1.5, 2.0, 2.5))
def test_gcode_to_mesh_naming_convention(self):
from NodeSlicer.core.nodeslicer.nodes.preview import SLICENODE_GcodeToMesh
node = SLICENODE_GcodeToMesh()
# 1. Single part - no number suffix
node.resolve_multi_input = MagicMock(return_value=["GCODE1"])
node.resolve = MagicMock(return_value="Part")
node._process_gcode = MagicMock()
node.evaluate()
node._process_gcode.assert_called_once_with("GCODE1", "Part", None)
# 2. Three parts - padded to 2 digits (00, 01, 02)
node.resolve_multi_input = MagicMock(return_value=["GC1", "GC2", "GC3"])
node.resolve = MagicMock(return_value="Part")
node._process_gcode = MagicMock()
node.evaluate()
self.assertEqual(node._process_gcode.call_count, 3)
node._process_gcode.assert_any_call("GC1", "Part00", None)
node._process_gcode.assert_any_call("GC2", "Part01", None)
node._process_gcode.assert_any_call("GC3", "Part02", None)
# 3. Fifteen parts - padded to 2 digits (00..14)
node.resolve_multi_input = MagicMock(return_value=[f"GC{i}" for i in range(1, 16)])
node.resolve = MagicMock(return_value="Part")
node._process_gcode = MagicMock()
node.evaluate()
self.assertEqual(node._process_gcode.call_count, 15)
node._process_gcode.assert_any_call("GC1", "Part00", None)
node._process_gcode.assert_any_call("GC15", "Part14", None)
# 4. Default base name when empty name provided
node.resolve_multi_input = MagicMock(return_value=["GC1", "GC2"])
node.resolve = MagicMock(return_value="")
node._process_gcode = MagicMock()
node.evaluate()
node._process_gcode.assert_any_call("GC1", "GcodePreview00", None)
node._process_gcode.assert_any_call("GC2", "GcodePreview01", None)
def test_gcode_to_file_naming_convention(self):
from unittest.mock import patch, mock_open
from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_GcodeToFile
node = SLICENODE_GcodeToFile()
mock_bpy.path.abspath = lambda p: p.replace("//", "/workspace/")
# 1. Single part with Name plugged - no number suffix
with patch("NodeSlicer.infra.networking.gcode_utils.prepare_gcode_file", return_value=(b"GC", "Part.gcode")) as mock_prep, \
patch("builtins.open", mock_open()), \
patch("os.makedirs"):
def resolve_side_effect(name):
if name == "Folder": return "output"
if name == "Name": return "Part"
return ""
node.resolve = MagicMock(side_effect=resolve_side_effect)
node.resolve_multi_input = MagicMock(return_value=["GCODE1"])
res = node.evaluate()
self.assertEqual(res, 'ok')
mock_prep.assert_called_once_with("GCODE1", "Part")
# 2. Three parts with Name plugged - padded to 2 digits (00, 01, 02)
with patch("NodeSlicer.infra.networking.gcode_utils.prepare_gcode_file", return_value=(b"GC", "test.gcode")) as mock_prep, \
patch("builtins.open", mock_open()), \
patch("os.makedirs"):
def resolve_side_effect(name):
if name == "Folder": return "output"
if name == "Name": return "Part"
return ""
node.resolve = MagicMock(side_effect=resolve_side_effect)
node.resolve_multi_input = MagicMock(return_value=["GC1", "GC2", "GC3"])
res = node.evaluate()
self.assertEqual(res, 'ok')
self.assertEqual(mock_prep.call_count, 3)
mock_prep.assert_any_call("GC1", "Part00")
mock_prep.assert_any_call("GC2", "Part01")
mock_prep.assert_any_call("GC3", "Part02")
# 3. Fifteen parts with Name plugged - padded to 2 digits (00..14)
with patch("NodeSlicer.infra.networking.gcode_utils.prepare_gcode_file", return_value=(b"GC", "test.gcode")) as mock_prep, \
patch("builtins.open", mock_open()), \
patch("os.makedirs"):
def resolve_side_effect(name):
if name == "Folder": return "output"
if name == "Name": return "Bracket"
return ""
node.resolve = MagicMock(side_effect=resolve_side_effect)
node.resolve_multi_input = MagicMock(return_value=[f"GC{i}" for i in range(1, 16)])
res = node.evaluate()
self.assertEqual(res, 'ok')
self.assertEqual(mock_prep.call_count, 15)
mock_prep.assert_any_call("GC1", "Bracket00")
mock_prep.assert_any_call("GC15", "Bracket14")
# 4. Not plugged / empty name - uses object names from metadata
gc_meta1 = '; objects_info = {"objects": [{"name": "CustomPart"}]}\nG1 X10'
gc_meta2 = 'G1 X20'
with patch("NodeSlicer.infra.networking.gcode_utils.prepare_gcode_file", return_value=(b"GC", "test.gcode")) as mock_prep, \
patch("builtins.open", mock_open()), \
patch("os.makedirs"):
def resolve_side_effect(name):
if name == "Folder": return "output"
if name == "Name": return ""
return ""
node.resolve = MagicMock(side_effect=resolve_side_effect)
node.resolve_multi_input = MagicMock(return_value=[gc_meta1, gc_meta2])
res = node.evaluate()
self.assertEqual(res, 'ok')
self.assertEqual(mock_prep.call_count, 2)
mock_prep.assert_any_call(gc_meta1, "CustomPart")
mock_prep.assert_any_call(gc_meta2, "object_1")
def test_drop_geometry_applied_to_main_transform_no_double_accumulation(self):
"""Verifies that dropping geometry updates the single main transform without accumulating duplicate records."""
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry, SLICENODE_DropGeometry
so = SlicingObject()
so.mesh = np.array([[[0.0, 0.0, 10.0],
[10.0, 0.0, 10.0],
[5.0, 10.0, 15.0],
[0.0, 0.0, 1.0]]], dtype=np.float64)
sc = SlicingCollection([], "coll")
sc.objects = [so]
# Step 1: Transform geometry (translate by X=10, Y=20, Z=5, rot Z=90deg)
t_node = SLICENODE_TransformGeometry()
t_node.resolve = lambda f: {
"Geometry": sc,
"Translation": (10.0, 20.0, 5.0),
"Rotation": (0.0, 0.0, np.pi / 2.0),
"Scale": (1.0, 1.0, 1.0)
}.get(f)
sc_t = t_node.evaluate_output("Geometry")
self.assertEqual(len(sc_t.transforms), 1)
self.assertEqual(sc_t.transforms[0]["type"], "transform")
self.assertEqual(sc_t.transforms[0]["translation"], (10.0, 20.0, 5.0))
self.assertEqual(sc_t.transforms[0]["rotation"], (0.0, 0.0, np.pi / 2.0))
# min_z of sc_t is 10.0 + 5.0 = 15.0
# Step 2: Drop geometry to Z=0.0 (offset_z = -15.0)
d_node1 = SLICENODE_DropGeometry()
d_node1.resolve = lambda f: {
"Mesh": sc_t,
"Z": 0.0
}.get(f)
sc_d1 = d_node1.evaluate_output("Mesh")
self.assertEqual(len(sc_d1.transforms), 1)
self.assertEqual(sc_d1.transforms[0]["type"], "transform")
# Translation Z becomes 5.0 + (-15.0) = -10.0
self.assertEqual(sc_d1.transforms[0]["translation"], (10.0, 20.0, -10.0))
self.assertEqual(sc_d1.transforms[0]["rotation"], (0.0, 0.0, np.pi / 2.0))
self.assertAlmostEqual(sc_d1.min_z, 0.0)
# Step 3: Drop geometry again to Z=2.0 (offset_z = +2.0)
d_node2 = SLICENODE_DropGeometry()
d_node2.resolve = lambda f: {
"Mesh": sc_d1,
"Z": 2.0
}.get(f)
sc_d2 = d_node2.evaluate_output("Mesh")
self.assertEqual(len(sc_d2.transforms), 1)
self.assertEqual(sc_d2.transforms[0]["type"], "transform")
# Translation Z becomes -10.0 + 2.0 = -8.0
self.assertEqual(sc_d2.transforms[0]["translation"], (10.0, 20.0, -8.0))
self.assertEqual(sc_d2.transforms[0]["rotation"], (0.0, 0.0, np.pi / 2.0))
self.assertAlmostEqual(sc_d2.min_z, 2.0)
def test_join_and_boolean_do_not_sum_transforms(self):
"""Verifies that joining, zipping, or performing boolean operations on distinct geometries does NOT sum their transforms."""
from NodeSlicer.core.slice import SlicePaths, boolean_slice
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject
# Object 1 with transform translation (10, 0, 0)
p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]])
sp1 = SlicePaths([p1], groups=[0], transforms=[{"type": "transform", "translation": (10.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}])
# Object 2 with transform translation (20, 0, 0)
p2 = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]])
sp2 = SlicePaths([p2], groups=[0], transforms=[{"type": "transform", "translation": (20.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}])
# 1. Zip paths
sp_zip = SlicePaths.zip_paths([sp1, sp2])
self.assertEqual(len(sp_zip.transforms), 1)
self.assertEqual(sp_zip.transforms[0]["translation"], (10.0, 0.0, 0.0))
# 2. Extend paths
sp_ext = SlicePaths.extend_paths([sp1, sp2])
self.assertEqual(len(sp_ext.transforms), 1)
self.assertEqual(sp_ext.transforms[0]["translation"], (10.0, 0.0, 0.0))
# 3. Boolean slice (Union)
sp_union = boolean_slice(sp1, sp2, operation='UNION')
self.assertEqual(len(sp_union.transforms), 1)
self.assertEqual(sp_union.transforms[0]["translation"], (10.0, 0.0, 0.0))
# 4. SlicingObject.merge
so1 = SlicingObject()
so1.name = "Obj1"
so1.transforms = [{"type": "transform", "translation": (10.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}]
so2 = SlicingObject()
so2.name = "Obj2"
so2.transforms = [{"type": "transform", "translation": (20.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}]
so_merged = SlicingObject.merge([so1, so2])
self.assertEqual(len(so_merged.transforms), 1)
self.assertEqual(so_merged.transforms[0]["translation"], (10.0, 0.0, 0.0))
def test_get_transforms_node(self):
"""Tests SLICENODE_GetTransforms node evaluation with various geometry inputs."""
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_GetTransforms, SLICENODE_TransformGeometry, SLICENODE_DropGeometry
from NodeSlicer.core.slice import SlicePaths
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection, SlicingGroup
node = SLICENODE_GetTransforms()
self.assertEqual(node.list_socket_mapping, [("Geometry", ["Translation", "Rotation", "Scale"])])
# 1. None input
node.resolve = lambda s: None
self.assertEqual(node.evaluate_output("Translation"), (0.0, 0.0, 0.0))
self.assertEqual(node.evaluate_output("Rotation"), (0.0, 0.0, 0.0))
self.assertEqual(node.evaluate_output("Scale"), (1.0, 1.0, 1.0))
# 2. SlicePaths with transforms
p = np.array([[0, 0, 0], [10, 0, 0], [10, 10, 0], [0, 0, 0]], dtype=np.float64)
sp = SlicePaths([p], transforms=[{
"type": "transform",
"translation": (12.0, 34.0, 56.0),
"rotation": (0.1, 0.2, 0.3),
"scale": (2.0, 3.0, 4.0),
}])
node.resolve = lambda s: sp
self.assertEqual(node.evaluate_output("Translation"), (12.0, 34.0, 56.0))
self.assertEqual(node.evaluate_output("Rotation"), (0.1, 0.2, 0.3))
self.assertEqual(node.evaluate_output("Scale"), (2.0, 3.0, 4.0))
# 3. SlicePaths with NO transforms (default output)
sp_plain = SlicePaths([p])
node.resolve = lambda s: sp_plain
self.assertEqual(node.evaluate_output("Translation"), (0.0, 0.0, 0.0))
self.assertEqual(node.evaluate_output("Rotation"), (0.0, 0.0, 0.0))
self.assertEqual(node.evaluate_output("Scale"), (1.0, 1.0, 1.0))
# 4. SlicingObject with transforms
so = SlicingObject()
so.transforms = [{
"type": "transform",
"translation": (1.0, 2.0, 3.0),
"rotation": (0.0, 0.0, np.pi / 4.0),
"scale": (0.5, 0.5, 1.0),
}]
node.resolve = lambda s: so
self.assertEqual(node.evaluate_output("Translation"), (1.0, 2.0, 3.0))
self.assertEqual(node.evaluate_output("Rotation"), (0.0, 0.0, np.pi / 4.0))
self.assertEqual(node.evaluate_output("Scale"), (0.5, 0.5, 1.0))
# 5. SlicingCollection
sc = SlicingCollection([], "test_coll")
sc.transforms = [{
"type": "transform",
"translation": (5.0, -5.0, 10.0),
"rotation": (0.0, np.pi / 2.0, 0.0),
"scale": (1.0, 1.0, 1.0),
}]
node.resolve = lambda s: sc
self.assertEqual(node.evaluate_output("Translation"), (5.0, -5.0, 10.0))
self.assertEqual(node.evaluate_output("Rotation"), (0.0, np.pi / 2.0, 0.0))
self.assertEqual(node.evaluate_output("Scale"), (1.0, 1.0, 1.0))
# 6. SlicingGroup
sg = SlicingGroup([])
sg.transforms = [{
"type": "transform",
"translation": (100.0, 200.0, 300.0),
"rotation": (0.0, 0.0, 0.0),
"scale": (2.0, 2.0, 2.0),
}]
node.resolve = lambda s: sg
self.assertEqual(node.evaluate_output("Translation"), (100.0, 200.0, 300.0))
self.assertEqual(node.evaluate_output("Scale"), (2.0, 2.0, 2.0))
# 7. Chained with Transform Geometry
t_node = SLICENODE_TransformGeometry()
t_node.resolve = lambda f: {
"Geometry": sp_plain,
"Translation": (7.0, 8.0, 9.0),
"Rotation": (0.0, 0.0, 1.5),
"Scale": (1.5, 2.5, 3.5),
}.get(f)
transformed_geom = t_node.evaluate_output("Geometry")
node.resolve = lambda s: transformed_geom
self.assertEqual(node.evaluate_output("Translation"), (7.0, 8.0, 9.0))
self.assertEqual(node.evaluate_output("Rotation"), (0.0, 0.0, 1.5))
self.assertEqual(node.evaluate_output("Scale"), (1.5, 2.5, 3.5))
# 8. List of geometries (element-wise list processing)
list_geoms = [sp, so]
node.resolve = lambda s: list_geoms
self.assertEqual(node.evaluate_output("Translation"), [(12.0, 34.0, 56.0), (1.0, 2.0, 3.0)])
self.assertEqual(node.evaluate_output("Rotation"), [(0.1, 0.2, 0.3), (0.0, 0.0, np.pi / 4.0)])
self.assertEqual(node.evaluate_output("Scale"), [(2.0, 3.0, 4.0), (0.5, 0.5, 1.0)])
# 9. Blender Object fallback
class MockBpyObject:
def __init__(self, loc=(11.0, 22.0, 33.0), rot=(0.4, 0.5, 0.6), sc=(1.1, 2.2, 3.3)):
self.location = loc
self.rotation_euler = rot
self.scale = sc
node.resolve = lambda s: MockBpyObject()
self.assertEqual(node.evaluate_output("Translation"), (11.0, 22.0, 33.0))
self.assertEqual(node.evaluate_output("Rotation"), (0.4, 0.5, 0.6))
self.assertEqual(node.evaluate_output("Scale"), (1.1, 2.2, 3.3))
# 10. List of Blender Objects
obj1 = MockBpyObject((1.0, 2.0, 3.0), (0.0, 0.0, 0.0), (1.0, 1.0, 1.0))
obj2 = MockBpyObject((4.0, 5.0, 6.0), (0.1, 0.2, 0.3), (2.0, 2.0, 2.0))
node.resolve = lambda s: [obj1, obj2]
self.assertEqual(node.evaluate_output("Translation"), [(1.0, 2.0, 3.0), (4.0, 5.0, 6.0)])
self.assertEqual(node.evaluate_output("Rotation"), [(0.0, 0.0, 0.0), (0.1, 0.2, 0.3)])
self.assertEqual(node.evaluate_output("Scale"), [(1.0, 1.0, 1.0), (2.0, 2.0, 2.0)])
# 11. Empty list
node.resolve = lambda s: []
self.assertEqual(node.evaluate_output("Translation"), [])
self.assertEqual(node.evaluate_output("Rotation"), [])
self.assertEqual(node.evaluate_output("Scale"), [])
def test_path_operations_acquire_transforms(self):
"""Tests Acquire Transforms checkbox and behavior on SLICENODE_PathOperations."""
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOperations
from NodeSlicer.core.slice import SlicePaths
node = SLICENODE_PathOperations()
self.assertTrue(node.acquire_transforms)
# Primary square path from (0, 0) to (20, 20) shifted by +50 in X and +30 in Y
sq_coords = np.array([[0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [20.0, 20.0, 0.0], [0.0, 20.0, 0.0], [0.0, 0.0, 0.0]])
shifted_sq = sq_coords + np.array([50.0, 30.0, 0.0])
sp_primary = SlicePaths([shifted_sq], transforms=[{
"type": "transform",
"translation": (50.0, 30.0, 0.0),
"rotation": (0.0, 0.0, 0.0),
"scale": (1.0, 1.0, 1.0)
}])
# Secondary square path from (5, 5) to (15, 15) with NO transforms
cutter_coords = np.array([[5.0, 5.0, 0.0], [15.0, 5.0, 0.0], [15.0, 15.0, 0.0], [5.0, 15.0, 0.0], [5.0, 5.0, 0.0]])
sp_secondary = SlicePaths([cutter_coords])
# Mock sockets
sock0 = MagicMock()
sock0.is_linked = True
sock1 = MagicMock()
sock1.is_linked = True
node.inputs = [sock0, sock1]
def mock_resolve_input(sock):
if sock == sock0:
return sp_primary
elif sock == sock1:
return sp_secondary
return None
import NodeSlicer.infra.blender.blender_slice_nodes_generic as generic_mod
orig_resolve = generic_mod.resolve_input
generic_mod.resolve_input = mock_resolve_input
try:
# 1. INTERSECTION with acquire_transforms=True (default)
node.operation = 'INTERSECTION'
node.acquire_transforms = True
res_inter_acquired = node.evaluate_output("Paths")
self.assertEqual(len(res_inter_acquired), 1)
# Cutter should have shifted to (55, 35) to (65, 45), intersecting properly!
pts = res_inter_acquired[0]
self.assertAlmostEqual(float(np.min(pts[:, 0])), 55.0, places=2)
self.assertAlmostEqual(float(np.max(pts[:, 0])), 65.0, places=2)
self.assertAlmostEqual(float(np.min(pts[:, 1])), 35.0, places=2)
self.assertAlmostEqual(float(np.max(pts[:, 1])), 45.0, places=2)
# 2. INTERSECTION with acquire_transforms=False
node.acquire_transforms = False
res_inter_no_acquire = node.evaluate_output("Paths")
# Without acquiring transforms, cutter remains at (5, 5)-(15, 15), no overlap with (50, 30)-(70, 50)!
self.assertEqual(len(res_inter_no_acquire), 0)
# 3. SUBTRACT with acquire_transforms=True
node.operation = 'SUBTRACT'
node.acquire_transforms = True
res_sub_acquired = node.evaluate_output("Paths")
# Subtracting the cutter from the primary square creates outer boundary + interior hole = 2 paths!
self.assertEqual(len(res_sub_acquired), 2)
# 4. SUBTRACT with acquire_transforms=False
node.operation = 'SUBTRACT'
node.acquire_transforms = False
res_sub_no_acquire = node.evaluate_output("Paths")
# Without acquiring transforms, cutter does not overlap primary square, primary remains untouched (1 path)
self.assertEqual(len(res_sub_no_acquire), 1)
# 5. ZIP with acquire_transforms=True aligns subsequent paths
node.operation = 'ZIP'
node.acquire_transforms = True
res_zip_acquired = node.evaluate_output("Paths")
self.assertEqual(len(res_zip_acquired), 2)
# Second path in zip result should be at (55, 35)-(65, 45)
pts_zip2 = res_zip_acquired[1]
self.assertAlmostEqual(float(np.min(pts_zip2[:, 0])), 55.0, places=2)
self.assertAlmostEqual(float(np.max(pts_zip2[:, 0])), 65.0, places=2)
# 6. Both already have the same transforms -> no double transformation
sp_secondary_transformed = SlicePaths([cutter_coords + 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)
}])
def mock_resolve_both(sock):
if sock == sock0:
return sp_primary
return sp_secondary_transformed
generic_mod.resolve_input = mock_resolve_both
node.operation = 'INTERSECTION'
node.acquire_transforms = True
res_both = node.evaluate_output("Paths")
self.assertEqual(len(res_both), 1)
pts_both = res_both[0]
self.assertAlmostEqual(float(np.min(pts_both[:, 0])), 55.0, places=2)
self.assertAlmostEqual(float(np.max(pts_both[:, 0])), 65.0, places=2)
# 7. Element-wise list behavior: primary is list [sp_primary, sp_primary2]
sp_primary2 = SlicePaths([sq_coords + np.array([100.0, 100.0, 0.0])], transforms=[{
"type": "transform",
"translation": (100.0, 100.0, 0.0),
"rotation": (0.0, 0.0, 0.0),
"scale": (1.0, 1.0, 1.0)
}])
def mock_resolve_list(sock):
if sock == sock0:
return [sp_primary, sp_primary2]
return sp_secondary
generic_mod.resolve_input = mock_resolve_list
node.operation = 'INTERSECTION'
node.acquire_transforms = True
res_list = node.evaluate_output("Paths")
self.assertIsInstance(res_list, list)
self.assertEqual(len(res_list), 2)
# Item 0 cutter shifted by (50, 30): min X=55
self.assertAlmostEqual(float(np.min(res_list[0][0][:, 0])), 55.0, places=2)
# Item 1 cutter shifted by (100, 100): min X=105
self.assertAlmostEqual(float(np.min(res_list[1][0][:, 0])), 105.0, places=2)
finally:
generic_mod.resolve_input = orig_resolve
if __name__ == '__main__':
unittest.main()