nodeslicer/tests/test_seam_placement.py
2026-10-09 16:49:22 +03:00

619 lines
22 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
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.append(str(PROJECT_ROOT.parent))
# Define dummy classes to avoid metaclass conflicts with bpy
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.core.slice import (
SlicePaths,
is_closed_path,
find_path_mesh_intersection,
reposition_path_seam,
extract_mesh_triangles,
)
from NodeSlicer.infra.blender.blender_mesh_capture import (
SlicingObject,
SlicingCollection,
SlicingGroup,
)
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_SeamPlacement
def create_triangle_mesh(v0, v1, v2) -> np.ndarray:
"""Helper to create a single triangle mesh with shape (1, 4, 3)."""
e1 = np.array(v1) - np.array(v0)
e2 = np.array(v2) - np.array(v0)
n = np.cross(e1, e2)
norm = np.linalg.norm(n)
if norm > 0:
n = n / norm
tri = np.zeros((1, 4, 3), dtype=np.float64)
tri[0, 0] = v0
tri[0, 1] = v1
tri[0, 2] = v2
tri[0, 3] = n
return tri
def create_quad_mesh(p0, p1, p2, p3) -> np.ndarray:
"""Helper to create a quad mesh (2 triangles) with shape (2, 4, 3)."""
t1 = create_triangle_mesh(p0, p1, p2)
t2 = create_triangle_mesh(p0, p2, p3)
return np.concatenate([t1, t2], axis=0)
class TestSeamPlacement(unittest.TestCase):
def setUp(self):
# Base square path at Z=0: from (-10,-10) -> (10,-10) -> (10,10) -> (-10,10) -> (-10,-10)
self.square_path = np.array([
[-10.0, -10.0, 0.0],
[10.0, -10.0, 0.0],
[10.0, 10.0, 0.0],
[-10.0, 10.0, 0.0],
[-10.0, -10.0, 0.0]
], dtype=np.float64)
def test_single_intersection_edge_midpoint(self):
# Vertical cutting plane through top edge at X=0, Y=10
# Triangle cutting only the top edge: X in [-0.5, 0.5], Y in [9, 11], Z in [-5, 5]
cutting_plane = create_quad_mesh(
[0.0, 5.0, -5.0],
[0.0, 15.0, -5.0],
[0.0, 15.0, 5.0],
[0.0, 5.0, 5.0]
)
sp = SlicePaths([self.square_path])
res = sp.place_seams(cutting_plane)
self.assertEqual(len(res), 1)
mod_p = res[0]
# Should be a valid closed loop
self.assertTrue(is_closed_path(mod_p))
# Seam should now be at (0, 10, 0)
np.testing.assert_allclose(mod_p[0], [0.0, 10.0, 0.0], atol=1e-4)
np.testing.assert_allclose(mod_p[-1], [0.0, 10.0, 0.0], atol=1e-4)
# Total points should have increased by 1 (inserted vertex)
self.assertEqual(len(mod_p), 6)
def test_intersection_snap_to_existing_vertex(self):
# Cutting plane passing right through corner vertex (10, 10, 0)
cutting_plane = create_quad_mesh(
[9.0, 9.0, -5.0],
[11.0, 11.0, -5.0],
[11.0, 11.0, 5.0],
[9.0, 9.0, 5.0]
)
sp = SlicePaths([self.square_path])
res = sp.place_seams(cutting_plane)
self.assertEqual(len(res), 1)
mod_p = res[0]
self.assertTrue(is_closed_path(mod_p))
np.testing.assert_allclose(mod_p[0], [10.0, 10.0, 0.0], atol=1e-4)
np.testing.assert_allclose(mod_p[-1], [10.0, 10.0, 0.0], atol=1e-4)
# Should snap to existing vertex without increasing point count
self.assertEqual(len(mod_p), 5)
def test_no_intersection_preserves_path(self):
# Guide mesh far away at Z=100
mesh_far = create_quad_mesh(
[0.0, 5.0, 95.0],
[0.0, 15.0, 95.0],
[0.0, 15.0, 105.0],
[0.0, 5.0, 105.0]
)
sp = SlicePaths([self.square_path])
res = sp.place_seams(mesh_far)
self.assertEqual(len(res), 1)
np.testing.assert_allclose(res[0], self.square_path, atol=1e-7)
def test_open_path_untouched(self):
# Open path (3 vertices, not closed)
open_p = np.array([
[-10.0, -10.0, 0.0],
[0.0, 0.0, 0.0],
[10.0, 10.0, 0.0]
], dtype=np.float64)
cutting_plane = create_quad_mesh(
[0.0, -5.0, -5.0],
[0.0, 5.0, -5.0],
[0.0, 5.0, 5.0],
[0.0, -5.0, 5.0]
)
sp = SlicePaths([open_p])
res = sp.place_seams(cutting_plane)
self.assertEqual(len(res), 1)
np.testing.assert_allclose(res[0], open_p, atol=1e-7)
def test_holes_handling(self):
# Outer boundary square: 40x40 from (-20,-20) to (20,20)
outer_p = np.array([
[-20.0, -20.0, 0.0],
[20.0, -20.0, 0.0],
[20.0, 20.0, 0.0],
[-20.0, 20.0, 0.0],
[-20.0, -20.0, 0.0]
], dtype=np.float64)
# Hole 1 in top-left: square 6x6 from (-15, 5) to (-9, 11) (clockwise)
hole1_p = np.array([
[-15.0, 11.0, 0.0],
[-9.0, 11.0, 0.0],
[-9.0, 5.0, 0.0],
[-15.0, 5.0, 0.0],
[-15.0, 11.0, 0.0]
], dtype=np.float64)
# Hole 2 in bottom-right: square 6x6 from (9, -15) to (15, -9)
hole2_p = np.array([
[9.0, -9.0, 0.0],
[15.0, -9.0, 0.0],
[15.0, -15.0, 0.0],
[9.0, -15.0, 0.0],
[9.0, -9.0, 0.0]
], dtype=np.float64)
# Cutting plane cutting through outer top edge (at X=-12, Y=20) and Hole 1 (at X=-12, Y=11 and Y=5)
# Plane is at X = -12, spanning Y in [0, 25], Z in [-5, 5]
cutting_plane = create_quad_mesh(
[-12.0, 0.0, -5.0],
[-12.0, 25.0, -5.0],
[-12.0, 25.0, 5.0],
[-12.0, 0.0, 5.0]
)
sp = SlicePaths([outer_p, hole1_p, hole2_p])
res = sp.place_seams(cutting_plane)
self.assertEqual(len(res), 3)
# 1. Outer boundary seam should be moved to (-12, 20, 0)
np.testing.assert_allclose(res[0][0], [-12.0, 20.0, 0.0], atol=1e-4)
np.testing.assert_allclose(res[0][-1], [-12.0, 20.0, 0.0], atol=1e-4)
self.assertTrue(is_closed_path(res[0]))
# 2. Hole 1 seam should be moved to (-12, 11, 0) (or intersection on hole1)
self.assertTrue(is_closed_path(res[1]))
np.testing.assert_allclose(res[1][0, 0], -12.0, atol=1e-4)
np.testing.assert_allclose(res[1][-1, 0], -12.0, atol=1e-4)
# 3. Hole 2 does NOT intersect the plane at X=-12, so its seam stays unchanged!
np.testing.assert_allclose(res[2], hole2_p, atol=1e-7)
def test_mesh_input_types_agnostic(self):
# Test SlicingObject, SlicingCollection, SlicingGroup, and list
tri_data = create_quad_mesh(
[0.0, 5.0, -5.0],
[0.0, 15.0, -5.0],
[0.0, 15.0, 5.0],
[0.0, 5.0, 5.0]
)
so = SlicingObject()
so.mesh = tri_data
sc = SlicingCollection([], "test_coll")
sc.objects = [so]
sg = SlicingGroup([])
sg.collections["default"] = sc
sp = SlicePaths([self.square_path])
# 1. SlicingObject
res_so = sp.place_seams(so)
np.testing.assert_allclose(res_so[0][0], [0.0, 10.0, 0.0], atol=1e-4)
# 2. SlicingCollection
res_sc = sp.place_seams(sc)
np.testing.assert_allclose(res_sc[0][0], [0.0, 10.0, 0.0], atol=1e-4)
# 3. SlicingGroup
res_sg = sp.place_seams(sg)
np.testing.assert_allclose(res_sg[0][0], [0.0, 10.0, 0.0], atol=1e-4)
# 4. List of objects
res_list = sp.place_seams([so, sc])
np.testing.assert_allclose(res_list[0][0], [0.0, 10.0, 0.0], atol=1e-4)
# 5. None / Empty
res_none = sp.place_seams(None)
np.testing.assert_allclose(res_none[0], self.square_path, atol=1e-7)
def test_attribute_rotation_and_interpolation(self):
# Square path with non-uniform extrusion rate and feedrate
ext = np.array([1.0, 2.0, 3.0, 4.0, 1.0], dtype=np.float64)
feed = np.array([100.0, 200.0, 300.0, 400.0, 100.0], dtype=np.float64)
extra = {"temp": [np.array([210.0, 215.0, 220.0, 225.0, 210.0], dtype=np.float64)]}
sp = SlicePaths([self.square_path], extrusion_rate=[ext], feedrate=[feed], extra_attributes=extra)
# Plane cutting segment 2 (between (10, 10) and (-10, 10)) at midpoint X=0, Y=10 (t=0.5)
cutting_plane = create_quad_mesh(
[0.0, 5.0, -5.0],
[0.0, 15.0, -5.0],
[0.0, 15.0, 5.0],
[0.0, 5.0, 5.0]
)
res = sp.place_seams(cutting_plane)
self.assertEqual(len(res), 1)
# Interpolated value at t=0.5 on segment 2:
# segment 2 is between vertex 2 (ext=3.0) and vertex 3 (ext=4.0)
# Expected interpolated ext at new seam: 3.5
expected_seam_ext = 3.5
self.assertAlmostEqual(res.extrusion_rate[0][0], expected_seam_ext, places=3)
self.assertAlmostEqual(res.extrusion_rate[0][-1], expected_seam_ext, places=3)
# Feedrate at t=0.5: between 300.0 and 400.0 -> 350.0
self.assertAlmostEqual(res.feedrate[0][0], 350.0, places=3)
self.assertAlmostEqual(res.feedrate[0][-1], 350.0, places=3)
# Extra attribute at t=0.5: between 220.0 and 225.0 -> 222.5
self.assertAlmostEqual(res.extra_attributes["temp"][0][0], 222.5, places=3)
self.assertAlmostEqual(res.extra_attributes["temp"][0][-1], 222.5, places=3)
def test_seam_placement_modes(self):
# Plane that cuts square path at two places:
# 1. Top edge at (0, 10, 0)
# 2. Bottom edge at (0, -10, 0)
full_plane = create_quad_mesh(
[0.0, -15.0, -5.0],
[0.0, 15.0, -5.0],
[0.0, 15.0, 5.0],
[0.0, -15.0, 5.0]
)
# Original square starts at (-10, -10, 0) (closer to (0, -10, 0))
sp = SlicePaths([self.square_path])
# Mode: CLOSEST -> should pick (0, -10, 0) because original seam is at (-10, -10, 0)
res_closest = sp.place_seams(full_plane, mode="closest")
np.testing.assert_allclose(res_closest[0][0], [0.0, -10.0, 0.0], atol=1e-4)
# Mode: FIRST -> segment 0 goes from (-10, -10) to (10, -10), so first intersection is (0, -10, 0)
res_first = sp.place_seams(full_plane, mode="first")
np.testing.assert_allclose(res_first[0][0], [0.0, -10.0, 0.0], atol=1e-4)
def test_seam_placement_node_execution(self):
# Test SLICENODE_SeamPlacement evaluate_output method
node = SLICENODE_SeamPlacement()
node.mode = 'CLOSEST'
cutting_plane = create_quad_mesh(
[0.0, 5.0, -5.0],
[0.0, 15.0, -5.0],
[0.0, 15.0, 5.0],
[0.0, 5.0, 5.0]
)
sp = SlicePaths([self.square_path])
so = SlicingObject()
so.mesh = cutting_plane
# Mock resolve method
def mock_resolve(socket_name):
if socket_name == "Paths":
return sp
elif socket_name == "Mesh":
return so
return None
node.resolve = mock_resolve
out = node.evaluate_output("Paths")
self.assertIsInstance(out, SlicePaths)
self.assertEqual(len(out), 1)
np.testing.assert_allclose(out[0][0], [0.0, 10.0, 0.0], atol=1e-4)
def test_acquire_transforms(self):
# Shifted square path by +50 in X and +30 in Y
shifted_square = self.square_path + 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)
}]
sp = SlicePaths([shifted_square], transforms=transforms)
# Cutting plane at original untransformed location at X=0, Y=10
cutting_plane = create_quad_mesh(
[0.0, 5.0, -5.0],
[0.0, 15.0, -5.0],
[0.0, 15.0, 5.0],
[0.0, 5.0, 5.0]
)
# 1. With acquire_transforms=True (default):
# The cutting plane should be transformed by +50 X and +30 Y, intersecting at (50, 40, 0)
res_acquired = sp.place_seams(cutting_plane, acquire_transforms=True)
self.assertEqual(len(res_acquired), 1)
np.testing.assert_allclose(res_acquired[0][0], [50.0, 40.0, 0.0], atol=1e-4)
np.testing.assert_allclose(res_acquired[0][-1], [50.0, 40.0, 0.0], atol=1e-4)
# 2. With acquire_transforms=False:
# The cutting plane remains at (0, 10, 0) and does not intersect the path at (50, 30, 0)
res_no_acquire = sp.place_seams(cutting_plane, acquire_transforms=False)
self.assertEqual(len(res_no_acquire), 1)
np.testing.assert_allclose(res_no_acquire[0], shifted_square, atol=1e-7)
def test_node_acquire_transforms_checkbox(self):
node = SLICENODE_SeamPlacement()
self.assertTrue(node.acquire_transforms)
shifted_square = self.square_path + 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)
}]
sp = SlicePaths([shifted_square], transforms=transforms)
cutting_plane = create_quad_mesh(
[0.0, 5.0, -5.0],
[0.0, 15.0, -5.0],
[0.0, 15.0, 5.0],
[0.0, 5.0, 5.0]
)
so = SlicingObject()
so.mesh = cutting_plane
def mock_resolve(socket_name):
if socket_name == "Paths":
return sp
elif socket_name == "Mesh":
return so
return None
node.resolve = mock_resolve
# When checkbox is True
node.acquire_transforms = True
out_true = node.evaluate_output("Paths")
np.testing.assert_allclose(out_true[0][0], [50.0, 40.0, 0.0], atol=1e-4)
# When checkbox is False
node.acquire_transforms = False
out_false = node.evaluate_output("Paths")
np.testing.assert_allclose(out_false[0], shifted_square, atol=1e-7)
def test_large_dataset_performance(self):
# 500 layers of complex circular paths (100 vertices per circle)
num_layers = 500
angles = np.linspace(0, 2 * np.pi, 100)
circle_2d = np.stack([10.0 * np.cos(angles), 10.0 * np.sin(angles)], axis=-1)
paths = []
for i in range(num_layers):
z = i * 0.2
p = np.empty((len(circle_2d), 3), dtype=np.float64)
p[:, :2] = circle_2d
p[:, 2] = z
paths.append(p)
sp = SlicePaths(paths)
# Mesh with 1,000 triangles spanning Z in [0, 100]
# Cutting quad at X=0, Y in [5, 15], Z in [-5, 105]
cutting_plane = create_quad_mesh(
[0.0, 5.0, -5.0],
[0.0, 15.0, -5.0],
[0.0, 15.0, 105.0],
[0.0, 5.0, 105.0]
)
import time
start_time = time.perf_counter()
res = sp.place_seams(cutting_plane)
duration = time.perf_counter() - start_time
self.assertEqual(len(res), num_layers)
for i in range(num_layers):
self.assertTrue(is_closed_path(res[i]))
# Should have seam on positive Y axis at X=0
np.testing.assert_allclose(res[i][0, 0], 0.0, atol=1e-3)
self.assertGreater(res[i][0, 1], 9.0)
# 500 layers must complete in well under 1 second (typically < 50ms)
self.assertLess(duration, 1.0, f"Execution took too long: {duration:.3f}s")
def test_complex_high_poly_mesh_performance(self):
# 500 layers, each with an outer contour and an inner hole (1000 paths total)
num_layers = 500
angles = np.linspace(0, 2 * np.pi, 80)
outer_2d = np.stack([20.0 * np.cos(angles), 20.0 * np.sin(angles)], axis=-1)
inner_2d = np.stack([10.0 * np.cos(angles[::-1]), 10.0 * np.sin(angles[::-1])], axis=-1)
paths = []
for i in range(num_layers):
z = i * 0.2
po = np.empty((len(outer_2d), 3), dtype=np.float64)
po[:, :2] = outer_2d
po[:, 2] = z
paths.append(po)
pi = np.empty((len(inner_2d), 3), dtype=np.float64)
pi[:, :2] = inner_2d
pi[:, 2] = z
paths.append(pi)
sp = SlicePaths(paths)
# Generate a high-poly mesh of 25,000 quads (50,000 triangles)
# spanning Z in [-10, 110]
n_quads = 25000
z_grid = np.linspace(-10, 110, n_quads + 1)
tris = np.zeros((n_quads * 2, 4, 3), dtype=np.float64)
for q in range(n_quads):
z0 = z_grid[q]
z1 = z_grid[q + 1]
# Quad cutting at X=0, Y in [5, 25]
p0 = np.array([0.0, 5.0, z0])
p1 = np.array([0.0, 25.0, z0])
p2 = np.array([0.0, 25.0, z1])
p3 = np.array([0.0, 5.0, z1])
tris[q * 2, 0] = p0
tris[q * 2, 1] = p1
tris[q * 2, 2] = p2
tris[q * 2 + 1, 0] = p0
tris[q * 2 + 1, 1] = p2
tris[q * 2 + 1, 2] = p3
import time
start_time = time.perf_counter()
res = sp.place_seams(tris)
duration = time.perf_counter() - start_time
self.assertEqual(len(res), num_layers * 2)
# Even with 50,000 triangles and 1,000 paths across 500 layers,
# layer batching must finish in under 0.5s
self.assertLess(duration, 0.5, f"High-poly seam placement took {duration:.3f}s (must be < 0.5s)")
def test_acquire_transforms_with_rotate_path_center(self):
# Square centered at (100, 100): from (90, 90) to (110, 110)
center = np.array([100.0, 100.0, 0.0])
square_offset = np.array([
[90.0, 90.0, 0.0],
[110.0, 90.0, 0.0],
[110.0, 110.0, 0.0],
[90.0, 110.0, 0.0],
[90.0, 90.0, 0.0]
], dtype=np.float64)
sp = SlicePaths([square_offset])
# Cutting plane cutting top edge at (100, 110, 0)
cutting_plane = create_quad_mesh(
[100.0, 105.0, -5.0],
[100.0, 115.0, -5.0],
[100.0, 115.0, 5.0],
[100.0, 105.0, 5.0]
)
# Rotate path by 90 degrees around center (100, 100, 0)
# Top edge (100, 110) -> rotates by +90 deg (CCW) around (100, 100) to (90, 100)
rotated_sp = sp.rotate(90.0, center=center)
# When acquire_transforms=True, the cutting plane must rotate around the SAME center (100, 100)
# So the cutting plane at (100, 110) rotates to (90, 100) and cuts the rotated path at (90, 100, 0)
res = rotated_sp.place_seams(cutting_plane, acquire_transforms=True)
self.assertEqual(len(res), 1)
self.assertTrue(is_closed_path(res[0]))
np.testing.assert_allclose(res[0][0], [90.0, 100.0, 0.0], atol=1e-4)
np.testing.assert_allclose(res[0][-1], [90.0, 100.0, 0.0], atol=1e-4)
def test_acquire_transforms_when_mesh_already_transformed(self):
# Transformed path and transformed SlicingCollection
# Both already have transforms: translation +142.93 in Z, rotation -90 deg around Y
trans_record = [{
"type": "transform",
"translation": (0.0, 0.0, 142.93),
"rotation": (0.0, -np.pi / 2.0, 0.0),
"scale": (1.0, 1.0, 1.0)
}]
# Create original path and cutting plane
orig_p = np.array([
[-10.0, -10.0, 0.0],
[10.0, -10.0, 0.0],
[10.0, 10.0, 0.0],
[-10.0, 10.0, 0.0],
[-10.0, -10.0, 0.0]
], dtype=np.float64)
cutting_plane = create_quad_mesh(
[0.0, 5.0, -5.0],
[0.0, 15.0, -5.0],
[0.0, 15.0, 5.0],
[0.0, 5.0, 5.0]
)
from NodeSlicer.core.pdf_exporter.geometry import transform_slice_paths
from NodeSlicer.core.slice import apply_transforms_to_triangles
# Transform both the paths and the cutting mesh
sp = transform_slice_paths(SlicePaths([orig_p]), (0.0, 0.0, 142.93), (0.0, -np.pi / 2.0, 0.0), (1.0, 1.0, 1.0))
sp.transforms = [dict(t) for t in trans_record]
transformed_tris = apply_transforms_to_triangles(cutting_plane, trans_record)
so = SlicingObject()
so.mesh = transformed_tris
so.transforms = [dict(t) for t in trans_record]
# When acquire_transforms=True is passed with a mesh that is ALREADY transformed,
# it must NOT double-transform the mesh!
res = sp.place_seams(so, acquire_transforms=True)
self.assertEqual(len(res), 1)
self.assertTrue(is_closed_path(res[0]))
# The seam must be placed at the intersection (not left unchanged)
# Transformed seam should NOT equal the unmodified transformed start point
self.assertFalse(np.allclose(res[0][0], sp[0][0]))
if __name__ == '__main__':
unittest.main()