650 lines
27 KiB
Python
650 lines
27 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))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|
|
|
|
|
|
|
|
|