1645 lines
72 KiB
Python
1645 lines
72 KiB
Python
import numpy as np
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
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()
|
|
|
|
# Import the actual functions to test
|
|
from NodeSlicer.core.slice import segments_to_slice, link_segments
|
|
|
|
class TestSlice(unittest.TestCase):
|
|
def test_link_segments_closed(self):
|
|
# Closed triangle: A(0,0,10) -> B(10,0,10) -> C(0,10,10) -> A(0,0,10)
|
|
segments = np.array([
|
|
[[0.0, 0.0, 10.0], [10.0, 0.0, 10.0]],
|
|
[[10.0, 0.0, 10.0], [0.0, 10.0, 10.0]],
|
|
[[0.0, 10.0, 10.0], [0.0, 0.0, 10.0]]
|
|
])
|
|
linked = link_segments(segments)
|
|
self.assertEqual(len(linked), 1)
|
|
path = linked[0]
|
|
# Should start and end at the same point (A)
|
|
self.assertTrue(np.allclose(path[0], path[-1]))
|
|
|
|
# Verify segments_to_slice closes it
|
|
sliced = segments_to_slice(segments)
|
|
self.assertEqual(len(sliced), 1)
|
|
sliced_path = sliced[0]
|
|
self.assertTrue(np.allclose(sliced_path[0], sliced_path[-1]))
|
|
|
|
def test_link_segments_open(self):
|
|
# Open path: A(0,0,10) -> B(10,0,10) -> C(0,10,10) (no C->A segment)
|
|
segments = np.array([
|
|
[[0.0, 0.0, 10.0], [10.0, 0.0, 10.0]],
|
|
[[10.0, 0.0, 10.0], [0.0, 10.0, 10.0]]
|
|
])
|
|
linked = link_segments(segments)
|
|
self.assertEqual(len(linked), 1)
|
|
path = linked[0]
|
|
# Should NOT start and end at the same point
|
|
self.assertFalse(np.allclose(path[0], path[-1]))
|
|
|
|
# Verify segments_to_slice does NOT close it
|
|
sliced = segments_to_slice(segments)
|
|
self.assertEqual(len(sliced), 1)
|
|
sliced_path = sliced[0]
|
|
self.assertFalse(np.allclose(sliced_path[0], sliced_path[-1]),
|
|
f"Path was incorrectly closed: {sliced_path}")
|
|
|
|
def test_buffer_curve_basic(self):
|
|
# Open path (line segment from (0,0,10) to (10,0,10))
|
|
path = np.array([[0.0, 0.0, 10.0], [10.0, 0.0, 10.0]])
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
slice_paths = SlicePaths([path])
|
|
|
|
# Buffer curve with distance 1.0
|
|
buffered = slice_paths.buffer_curve(1.0, cap_style='flat')
|
|
|
|
# Output should be a SlicePaths instance
|
|
self.assertIsInstance(buffered, SlicePaths)
|
|
self.assertEqual(len(buffered), 1)
|
|
|
|
# Check that the path is now closed (buffered area boundary)
|
|
buffered_path = buffered[0]
|
|
self.assertTrue(np.allclose(buffered_path[0], buffered_path[-1]))
|
|
|
|
# Flat cap with distance 1.0 should result in a rectangle:
|
|
# width = 10 (from 0 to 10), height = 2 (from -1 to 1) -> area = 20
|
|
# Let's compute 2D polygon area of the resulting path
|
|
xs = buffered_path[:, 0]
|
|
ys = buffered_path[:, 1]
|
|
# Shoelace formula for area
|
|
area = 0.5 * np.abs(np.dot(xs, np.roll(ys, 1)) - np.dot(ys, np.roll(xs, 1)))
|
|
self.assertAlmostEqual(area, 20.0, places=3)
|
|
|
|
# Check Z coordinate is preserved
|
|
self.assertTrue(np.allclose(buffered_path[:, 2], 10.0))
|
|
|
|
def test_buffer_curve_cap_styles(self):
|
|
path = np.array([[0.0, 0.0, 5.0], [10.0, 0.0, 5.0]])
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
slice_paths = SlicePaths([path])
|
|
|
|
# Round cap
|
|
buffered_round = slice_paths.buffer_curve(1.0, cap_style='round')
|
|
# Flat cap
|
|
buffered_flat = slice_paths.buffer_curve(1.0, cap_style='flat')
|
|
# Square cap
|
|
buffered_square = slice_paths.buffer_curve(1.0, cap_style='square')
|
|
|
|
# Area comparison: flat (20.0) < round (20.0 + pi) < square (24.0)
|
|
def get_area(p):
|
|
xs, ys = p[:, 0], p[:, 1]
|
|
return 0.5 * np.abs(np.dot(xs, np.roll(ys, 1)) - np.dot(ys, np.roll(xs, 1)))
|
|
|
|
area_flat = get_area(buffered_flat[0])
|
|
area_round = get_area(buffered_round[0])
|
|
area_square = get_area(buffered_square[0])
|
|
|
|
self.assertAlmostEqual(area_flat, 20.0, places=3)
|
|
self.assertAlmostEqual(area_square, 24.0, places=3)
|
|
self.assertGreater(area_round, area_flat)
|
|
self.assertLess(area_round, area_square)
|
|
|
|
def test_buffer_curve_single_sided(self):
|
|
path = np.array([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]])
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
slice_paths = SlicePaths([path])
|
|
|
|
buffered = slice_paths.buffer_curve(1.0, cap_style='flat', single_sided=True)
|
|
# For a single sided flat cap buffer, the rectangle area is distance * length = 1.0 * 10.0 = 10.0
|
|
xs, ys = buffered[0][:, 0], buffered[0][:, 1]
|
|
area = 0.5 * np.abs(np.dot(xs, np.roll(ys, 1)) - np.dot(ys, np.roll(xs, 1)))
|
|
self.assertAlmostEqual(area, 10.0, places=3)
|
|
|
|
def test_offset_curve_basic(self):
|
|
# Open path (line segment from (0,0,10) to (10,0,10))
|
|
path = np.array([[0.0, 0.0, 10.0], [10.0, 0.0, 10.0]])
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
slice_paths = SlicePaths([path])
|
|
|
|
# Offset curve by 1.0 (should shift it to (0, 1, 10) -> (10, 1, 10))
|
|
offset = slice_paths.offset_curve(1.0)
|
|
|
|
self.assertIsInstance(offset, SlicePaths)
|
|
self.assertEqual(len(offset), 1)
|
|
|
|
offset_path = offset[0]
|
|
# Should remain an open path (first and last coordinate not equal)
|
|
self.assertFalse(np.allclose(offset_path[0], offset_path[-1]))
|
|
|
|
# Verify the shifted coordinates
|
|
self.assertTrue(np.allclose(offset_path[:, 1], 1.0))
|
|
self.assertTrue(np.allclose(offset_path[:, 2], 10.0))
|
|
self.assertTrue(np.allclose(offset_path[0, 0], 0.0))
|
|
self.assertTrue(np.allclose(offset_path[1, 0], 10.0))
|
|
|
|
def test_offset_curve_closed_loop(self):
|
|
# Closed square path: (0,0) -> (0,10) -> (10,10) -> (10,0) -> (0,0) at Z=5.0
|
|
path = np.array([
|
|
[0.0, 0.0, 5.0],
|
|
[0.0, 10.0, 5.0],
|
|
[10.0, 10.0, 5.0],
|
|
[10.0, 0.0, 5.0],
|
|
[0.0, 0.0, 5.0]
|
|
])
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
slice_paths = SlicePaths([path])
|
|
|
|
# Offset curve by +1.0
|
|
# Positive distance in a clockwise loop should shift it inward (to a square of size 8.0)
|
|
offset_in = slice_paths.offset_curve(1.0, join_style='mitre')
|
|
|
|
self.assertIsInstance(offset_in, SlicePaths)
|
|
self.assertEqual(len(offset_in), 1)
|
|
|
|
offset_path = offset_in[0]
|
|
# Should remain perfectly closed (first and last coordinate equal)
|
|
self.assertTrue(np.allclose(offset_path[0], offset_path[-1]), f"Offset path was not closed: {offset_path}")
|
|
|
|
# Calculate the area of the offset path
|
|
xs, ys = offset_path[:, 0], offset_path[:, 1]
|
|
area = 0.5 * np.abs(np.dot(xs, np.roll(ys, 1)) - np.dot(ys, np.roll(xs, 1)))
|
|
self.assertAlmostEqual(area, 64.0, places=3)
|
|
|
|
# Check Z coordinate is preserved
|
|
self.assertTrue(np.allclose(offset_path[:, 2], 5.0))
|
|
|
|
def test_get_infill_mask(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
|
|
# Outer square: (0,0) to (10,10) at Z=3.0 (area 100)
|
|
outer = np.array([
|
|
[0.0, 0.0, 3.0],
|
|
[10.0, 0.0, 3.0],
|
|
[10.0, 10.0, 3.0],
|
|
[0.0, 10.0, 3.0],
|
|
[0.0, 0.0, 3.0]
|
|
])
|
|
# Inner hole: (3,3) to (7,7) at Z=3.0 (area 16)
|
|
hole = np.array([
|
|
[3.0, 3.0, 3.0],
|
|
[7.0, 3.0, 3.0],
|
|
[7.0, 7.0, 3.0],
|
|
[3.0, 7.0, 3.0],
|
|
[3.0, 3.0, 3.0]
|
|
])
|
|
|
|
slice_paths = SlicePaths([outer, hole], groups=[0, 0])
|
|
mask = slice_paths.get_infill_mask()
|
|
|
|
self.assertIsInstance(mask, SlicePaths)
|
|
# Should return both outer boundary and inner hole boundary
|
|
self.assertEqual(len(mask), 2)
|
|
|
|
# Verify both loops are closed and at Z=3.0
|
|
for p in mask:
|
|
self.assertTrue(np.allclose(p[0], p[-1]))
|
|
self.assertTrue(np.allclose(p[:, 2], 3.0))
|
|
|
|
# Calculate polygon areas
|
|
areas = []
|
|
for p in mask:
|
|
xs, ys = p[:, 0], p[:, 1]
|
|
a = 0.5 * np.abs(np.dot(xs, np.roll(ys, 1)) - np.dot(ys, np.roll(xs, 1)))
|
|
areas.append(a)
|
|
areas.sort()
|
|
self.assertAlmostEqual(areas[0], 16.0, places=2) # Hole area
|
|
self.assertAlmostEqual(areas[1], 100.0, places=2) # Outer area
|
|
|
|
def test_get_infill_mask_multiple_holes_and_islands(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
|
|
# Island 1 at Z=1.0: (0,0) to (20,20) with 2 holes
|
|
island1_outer = np.array([
|
|
[0.0, 0.0, 1.0], [20.0, 0.0, 1.0], [20.0, 20.0, 1.0], [0.0, 20.0, 1.0], [0.0, 0.0, 1.0]
|
|
])
|
|
h1 = np.array([
|
|
[2.0, 2.0, 1.0], [6.0, 2.0, 1.0], [6.0, 6.0, 1.0], [2.0, 6.0, 1.0], [2.0, 2.0, 1.0]
|
|
])
|
|
h2 = np.array([
|
|
[12.0, 12.0, 1.0], [16.0, 12.0, 1.0], [16.0, 16.0, 1.0], [12.0, 16.0, 1.0], [12.0, 12.0, 1.0]
|
|
])
|
|
|
|
# Island 2 at Z=1.0: (30,30) to (40,40) with 1 hole
|
|
island2_outer = np.array([
|
|
[30.0, 30.0, 1.0], [40.0, 30.0, 1.0], [40.0, 40.0, 1.0], [30.0, 40.0, 1.0], [30.0, 30.0, 1.0]
|
|
])
|
|
h3 = np.array([
|
|
[33.0, 33.0, 1.0], [37.0, 33.0, 1.0], [37.0, 37.0, 1.0], [33.0, 37.0, 1.0], [33.0, 33.0, 1.0]
|
|
])
|
|
|
|
slice_paths = SlicePaths([island1_outer, h1, h2, island2_outer, h3], groups=[0, 0, 0, 0, 0])
|
|
mask = slice_paths.get_infill_mask()
|
|
|
|
# Should produce 5 paths total: 2 outer boundaries + 3 hole boundaries
|
|
self.assertEqual(len(mask), 5)
|
|
for p in mask:
|
|
self.assertTrue(np.allclose(p[0], p[-1]))
|
|
self.assertTrue(np.allclose(p[:, 2], 1.0))
|
|
|
|
def test_get_overhang_mask(self):
|
|
# Layer 2 (Z=2.0): square of size 4 (from -2 to 2)
|
|
sq2 = np.array([
|
|
[-2.0, -2.0, 2.0],
|
|
[-2.0, 2.0, 2.0],
|
|
[ 2.0, 2.0, 2.0],
|
|
[ 2.0, -2.0, 2.0],
|
|
[-2.0, -2.0, 2.0]
|
|
])
|
|
# Layer 1 (Z=1.0): square of size 2 (from -1 to 1)
|
|
sq1 = np.array([
|
|
[-1.0, -1.0, 1.0],
|
|
[-1.0, 1.0, 1.0],
|
|
[ 1.0, 1.0, 1.0],
|
|
[ 1.0, -1.0, 1.0],
|
|
[-1.0, -1.0, 1.0]
|
|
])
|
|
# Layer 0 (Z=0.0): square of size 4 (from -2 to 2)
|
|
sq0 = np.array([
|
|
[-2.0, -2.0, 0.0],
|
|
[-2.0, 2.0, 0.0],
|
|
[ 2.0, 2.0, 0.0],
|
|
[ 2.0, -2.0, 0.0],
|
|
[-2.0, -2.0, 0.0]
|
|
])
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
# Groups sorted by Z height internally: group 0 (Z=0.0), group 1 (Z=1.0), group 2 (Z=2.0)
|
|
slice_paths = SlicePaths([sq0, sq1, sq2], groups=[0, 1, 2])
|
|
|
|
overhang = slice_paths.get_overhang_mask(tolerance=0.0)
|
|
|
|
self.assertIsInstance(overhang, SlicePaths)
|
|
# Top layer Z=2.0 (group 2) and bottom layer Z=0.0 (group 0) should have no overhang paths.
|
|
# Only layer Z=1.0 (group 1) should have overhang paths.
|
|
# The paths on group 1 represent the ring: outer boundary (4x4) and inner boundary (2x2).
|
|
self.assertEqual(len(overhang), 2)
|
|
self.assertTrue(all(g == 1 for g in overhang.groups))
|
|
|
|
# Total area of the two paths on layer 1 should be: outer square (16) - inner square (4) = 12.0
|
|
from shapely.geometry import Polygon
|
|
poly_out = Polygon(overhang[0][:, :2])
|
|
poly_in = Polygon(overhang[1][:, :2])
|
|
area1 = poly_out.area
|
|
area2 = poly_in.area
|
|
self.assertEqual(sorted([area1, area2]), [4.0, 16.0])
|
|
|
|
def test_get_overhang_mask_vertical_walls(self):
|
|
# Two identical squares on adjacent layers Z=0.0 and Z=1.0, representing perfectly vertical walls
|
|
# To simulate slicing noise/discrepancies, we shift one by a tiny amount (0.001)
|
|
sq0 = np.array([
|
|
[0.0, 0.0, 0.0],
|
|
[0.0, 10.0, 0.0],
|
|
[10.0, 10.0, 0.0],
|
|
[10.0, 0.0, 0.0],
|
|
[0.0, 0.0, 0.0]
|
|
])
|
|
sq1 = np.array([
|
|
[0.001, 0.001, 1.0],
|
|
[0.001, 10.001, 1.0],
|
|
[10.001, 10.001, 1.0],
|
|
[10.001, 0.001, 1.0],
|
|
[0.001, 0.001, 1.0]
|
|
])
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
slice_paths = SlicePaths([sq0, sq1], groups=[0, 1])
|
|
|
|
# With default tolerance=0.05, it should completely filter out the vertical wall noise, yielding 0 overhang paths.
|
|
overhang_with_tol = slice_paths.get_overhang_mask(tolerance=0.05)
|
|
self.assertEqual(len(overhang_with_tol), 0)
|
|
|
|
# With tolerance=0.0, it should preserve the tiny difference sliver, yielding non-empty paths.
|
|
overhang_no_tol = slice_paths.get_overhang_mask(tolerance=0.0)
|
|
self.assertGreater(len(overhang_no_tol), 0)
|
|
|
|
def test_get_overhang_mask_propagation_no_gaps(self):
|
|
# 3-layer tapering model (cone-like structure pointing down)
|
|
# Layer 2 (Z=2.0): 4x4 square (from -2 to 2)
|
|
sq2 = np.array([
|
|
[-2.0, -2.0, 2.0],
|
|
[-2.0, 2.0, 2.0],
|
|
[ 2.0, 2.0, 2.0],
|
|
[ 2.0, -2.0, 2.0],
|
|
[-2.0, -2.0, 2.0]
|
|
])
|
|
# Layer 1 (Z=1.0): 3x3 square (from -1.5 to 1.5)
|
|
sq1 = np.array([
|
|
[-1.5, -1.5, 1.0],
|
|
[-1.5, 1.5, 1.0],
|
|
[ 1.5, 1.5, 1.0],
|
|
[ 1.5, -1.5, 1.0],
|
|
[-1.5, -1.5, 1.0]
|
|
])
|
|
# Layer 0 (Z=0.0): 2x2 square (from -1 to 1)
|
|
sq0 = np.array([
|
|
[-1.0, -1.0, 0.0],
|
|
[-1.0, 1.0, 0.0],
|
|
[ 1.0, 1.0, 0.0],
|
|
[ 1.0, -1.0, 0.0],
|
|
[-1.0, -1.0, 0.0]
|
|
])
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
slice_paths = SlicePaths([sq0, sq1, sq2], groups=[0, 1, 2])
|
|
|
|
# tolerance=0.5:
|
|
# At layer 0 (Z=0.0), support should cover:
|
|
# (geom_above (3x3) union S_1 (4x4 \ 3x3)) \ geom_0 = 4x4 \ 2x2
|
|
# Buffered geom_0_sub is 2.5x2.5 (radius 0.5 outward from 2x2).
|
|
# So output_geom = 4x4 \ 2.5x2.5.
|
|
# This is a single ring with area: 16 - 6.25 = 9.75.
|
|
# It must NOT contain gaps or be fragmented into multiple nested rings.
|
|
overhang = slice_paths.get_overhang_mask(tolerance=0.5)
|
|
|
|
# Support should be generated on layer 1 and layer 0.
|
|
# Let's group outputs by Z.
|
|
layer_paths = {}
|
|
for path in overhang:
|
|
z = path[0, 2]
|
|
layer_paths.setdefault(z, []).append(path)
|
|
|
|
self.assertIn(0.0, layer_paths)
|
|
self.assertIn(1.0, layer_paths)
|
|
|
|
# At Z=0.0 (layer 0):
|
|
# We expect exactly 2 paths (exterior of 4x4 and interior hole of 2.5x2.5).
|
|
# If there is fragmentation, there will be more paths.
|
|
paths_z0 = layer_paths[0.0]
|
|
self.assertEqual(len(paths_z0), 2)
|
|
|
|
from shapely.geometry import Polygon
|
|
# The exterior polygon and the interior hole
|
|
p0 = Polygon(paths_z0[0][:, :2])
|
|
p1 = Polygon(paths_z0[1][:, :2])
|
|
areas = sorted([p0.area, p1.area])
|
|
# Outer area is 16, inner hole area is approx 8.784 due to rounded corners of the buffer
|
|
self.assertAlmostEqual(areas[0], 8.784, places=2)
|
|
self.assertAlmostEqual(areas[1], 16.0, places=2)
|
|
|
|
def test_pather_retraction_lift(self):
|
|
from NodeSlicer.core.slice import Pather
|
|
|
|
path1 = np.array([
|
|
[10.0, 10.0, 0.2],
|
|
[20.0, 10.0, 0.2]
|
|
])
|
|
path2 = np.array([
|
|
[30.0, 30.0, 0.2],
|
|
[40.0, 30.0, 0.2]
|
|
])
|
|
|
|
# Test default (no retraction or lift)
|
|
pather = Pather([path1, path2])
|
|
pather.retraction = 0.0
|
|
pather.lift = 0.0
|
|
gcode = pather.to_gcode(include_header=False)
|
|
self.assertNotIn("E-", gcode)
|
|
self.assertNotIn("Z0.700", gcode) # no lift
|
|
|
|
# Test with retraction and lift (default retraction feedrate uses default feedrate 1200)
|
|
pather = Pather([path1, path2])
|
|
pather.retraction = 1.2
|
|
pather.lift = 0.5
|
|
gcode = pather.to_gcode(include_header=False)
|
|
|
|
# Retraction should be present before travel using default feedrate 1200.0
|
|
self.assertIn("G1 E-1.20000 F1200.000", gcode)
|
|
# Lift should be present: 0.2 (last_nozzle_z) + 0.5 (lift) = 0.7
|
|
self.assertIn("G0 Z0.700 F720", gcode)
|
|
# Un-lift back to 0.2 should be present
|
|
self.assertIn("G0 Z0.200 F720", gcode)
|
|
# Un-retraction should be present
|
|
self.assertIn("G1 E1.20000 F1200.000", gcode)
|
|
|
|
# Test with explicit retraction_feedrate
|
|
pather = Pather([path1, path2])
|
|
pather.retraction = 1.2
|
|
pather.retraction_feedrate = 1800.0
|
|
gcode_rf = pather.to_gcode(include_header=False)
|
|
self.assertIn("G1 E-1.20000 F1800.000", gcode_rf)
|
|
self.assertIn("G1 E1.20000 F1800.000", gcode_rf)
|
|
|
|
# Test with lift only (no retraction)
|
|
pather = Pather([path1, path2])
|
|
pather.retraction = 0.0
|
|
pather.lift = 0.5
|
|
gcode = pather.to_gcode(include_header=False)
|
|
self.assertNotIn("E-", gcode)
|
|
# Lift should be present: 0.2 (last_nozzle_z) + 0.5 (lift) = 0.7
|
|
self.assertIn("G0 Z0.700 F720", gcode)
|
|
# Un-lift back to 0.2 should be present
|
|
self.assertIn("G0 Z0.200 F720", gcode)
|
|
|
|
# Test with unparsable retraction and lift fields
|
|
pather = Pather([path1, path2])
|
|
pather.retraction = "invalid"
|
|
pather.lift = None
|
|
gcode = pather.to_gcode(include_header=False)
|
|
self.assertNotIn("E-", gcode)
|
|
self.assertNotIn("Z0.700", gcode)
|
|
|
|
def test_pather_travel_feedrate(self):
|
|
from NodeSlicer.core.slice import Pather
|
|
|
|
path1 = np.array([
|
|
[10.0, 10.0, 0.2],
|
|
[20.0, 10.0, 0.2]
|
|
])
|
|
path2 = np.array([
|
|
[30.0, 30.0, 0.2],
|
|
[40.0, 30.0, 0.2]
|
|
])
|
|
|
|
pather = Pather([path1, path2])
|
|
pather.feedrate = 1200.0
|
|
pather.travel_feedrate = 3000.0
|
|
pather.bed_center_x = 25.0
|
|
pather.bed_center_y = 20.0
|
|
|
|
# Verify travel feedrate is in the header comments
|
|
gcode_with_header = pather.to_gcode(include_header=True)
|
|
self.assertIn(";DEFAULT_TRAVEL_FEEDRATE=3000.0", gcode_with_header)
|
|
self.assertIn(";DEFAULT_FEEDRATE=1200.0", gcode_with_header)
|
|
|
|
# Verify G0 travel moves use the travel_feedrate
|
|
gcode = pather.to_gcode(include_header=False)
|
|
|
|
# The first move should travel to the start of path1 at travel_feedrate
|
|
self.assertIn("G0 X10.000 Y10.000 Z0.200 F3000.0", gcode)
|
|
|
|
# The transition travel move to the start of path2 should also use travel_feedrate
|
|
self.assertIn("G0 X30.000 Y30.000 F3000.0", gcode)
|
|
|
|
def test_pather_layer_metadata(self):
|
|
import math
|
|
from NodeSlicer.core.slice import Pather, SlicePaths
|
|
|
|
path1 = np.array([
|
|
[10.0, 10.0, 0.2],
|
|
[20.0, 10.0, 0.2]
|
|
])
|
|
|
|
slices = SlicePaths([path1])
|
|
slices.layer_height = 0.25
|
|
|
|
pather = Pather(slices)
|
|
pather.layer_height = getattr(slices, "layer_height")
|
|
pather.extrusion_rate = 0.035
|
|
|
|
expected_width = (0.035 * math.pi * (1.75 / 2.0) ** 2) / 0.25
|
|
|
|
gcode = pather.to_gcode(include_header=True)
|
|
self.assertIn(";DEFAULT_LAYER_HEIGHT=0.250000", gcode)
|
|
self.assertIn(f";DEFAULT_LAYER_WIDTH={expected_width:.6f}", gcode)
|
|
|
|
def test_first_layer_height(self):
|
|
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_SliceShells
|
|
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection
|
|
|
|
# Create a simple box mesh [8, 4, 3] from Z=0 to Z=1
|
|
vertices = np.array([
|
|
[0.0, 0.0, 0.0],
|
|
[10.0, 0.0, 0.0],
|
|
[10.0, 10.0, 0.0],
|
|
[0.0, 10.0, 0.0],
|
|
[0.0, 0.0, 1.0],
|
|
[10.0, 0.0, 1.0],
|
|
[10.0, 10.0, 1.0],
|
|
[0.0, 10.0, 1.0],
|
|
])
|
|
faces = [
|
|
[0, 1, 2], [0, 2, 3],
|
|
[4, 6, 5], [4, 7, 6],
|
|
[0, 4, 5], [0, 5, 1],
|
|
[1, 5, 6], [1, 6, 2],
|
|
[2, 6, 7], [2, 7, 3],
|
|
[3, 7, 4], [3, 4, 0]
|
|
]
|
|
triangles = np.zeros((len(faces), 4, 3))
|
|
for i, face in enumerate(faces):
|
|
triangles[i, 0] = vertices[face[0]]
|
|
triangles[i, 1] = vertices[face[1]]
|
|
triangles[i, 2] = vertices[face[2]]
|
|
|
|
sobj = SlicingObject()
|
|
sobj.mesh = triangles
|
|
scoll = SlicingCollection([], "test")
|
|
scoll.objects = [sobj]
|
|
|
|
node = SLICENODE_SliceShells()
|
|
# Test without inputs (simulating missing socket resolution)
|
|
node.resolve = lambda name: scoll if name == "Mesh" else (0.2 if name == "Layer Height" else (0.3 if name == "First Layer Height" else None))
|
|
slices = node.evaluate_output("Paths")
|
|
self.assertTrue(len(slices) > 0)
|
|
# First slice Z should be min_z + fl_height = 0.0 + 0.3 = 0.3
|
|
self.assertAlmostEqual(slices[0][0, 2], 0.3)
|
|
self.assertEqual(getattr(slices, "first_layer_height", None), 0.3)
|
|
# First layer extrusion rates should be multiplied by 0.3 / 0.2 = 1.5
|
|
np.testing.assert_allclose(slices.extrusion_rate[0], 1.5)
|
|
|
|
def test_slice_shells_comprehensive_validation(self):
|
|
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_SliceShells
|
|
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection, SlicingGroup
|
|
|
|
# 10x10x1.0 box mesh
|
|
vertices = np.array([
|
|
[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0], [0.0, 10.0, 0.0],
|
|
[0.0, 0.0, 1.0], [10.0, 0.0, 1.0], [10.0, 10.0, 1.0], [0.0, 10.0, 1.0],
|
|
])
|
|
faces = [
|
|
[0, 1, 2], [0, 2, 3],
|
|
[4, 6, 5], [4, 7, 6],
|
|
[0, 4, 5], [0, 5, 1],
|
|
[1, 5, 6], [1, 6, 2],
|
|
[2, 6, 7], [2, 7, 3],
|
|
[3, 7, 4], [3, 4, 0]
|
|
]
|
|
triangles = np.zeros((len(faces), 4, 3))
|
|
for i, face in enumerate(faces):
|
|
triangles[i, 0] = vertices[face[0]]
|
|
triangles[i, 1] = vertices[face[1]]
|
|
triangles[i, 2] = vertices[face[2]]
|
|
|
|
sobj = SlicingObject()
|
|
sobj.name = "TestBox"
|
|
sobj.mesh = triangles
|
|
|
|
# 1. Direct SlicingObject input support
|
|
node = SLICENODE_SliceShells()
|
|
node.resolve = lambda name: sobj if name == "Mesh" else (0.2 if name == "Layer Height" else (0.35 if name == "First Layer Height" else None))
|
|
slices = node.evaluate_output("Paths")
|
|
|
|
# Layer count: with min_z=0, max_z=1.0, flh=0.35, lh=0.2:
|
|
# Layers: 0.35, 0.55, 0.75, 0.95 -> 4 layers
|
|
self.assertEqual(len(slices), 4)
|
|
self.assertAlmostEqual(slices[0][0, 2], 0.35)
|
|
self.assertAlmostEqual(slices[1][0, 2], 0.55)
|
|
self.assertAlmostEqual(slices[2][0, 2], 0.75)
|
|
self.assertAlmostEqual(slices[3][0, 2], 0.95)
|
|
|
|
# Groups must match layer index
|
|
self.assertEqual(slices.groups, [0, 1, 2, 3])
|
|
|
|
# Extrusion rate for first layer is 0.35 / 0.2 = 1.75
|
|
np.testing.assert_allclose(slices.extrusion_rate[0], 1.75)
|
|
np.testing.assert_allclose(slices.extrusion_rate[1], 1.0)
|
|
self.assertEqual(slices.layer_height, 0.2)
|
|
self.assertEqual(slices.first_layer_height, 0.35)
|
|
|
|
# 2. Equal first layer height (0.2 / 0.2) -> exactly 5 layers: 0.2, 0.4, 0.6, 0.8, 1.0
|
|
node.resolve = lambda name: sobj if name == "Mesh" else (0.2 if name == "Layer Height" else (0.2 if name == "First Layer Height" else None))
|
|
slices_eq = node.evaluate_output("Paths")
|
|
self.assertEqual(len(slices_eq), 5)
|
|
self.assertAlmostEqual(slices_eq[0][0, 2], 0.2)
|
|
self.assertAlmostEqual(slices_eq[-1][0, 2], 1.0)
|
|
np.testing.assert_allclose(slices_eq.extrusion_rate[0], 1.0)
|
|
|
|
# 3. First layer height <= 0 fallback to layer_height
|
|
node.resolve = lambda name: sobj if name == "Mesh" else (0.2 if name == "Layer Height" else (0.0 if name == "First Layer Height" else None))
|
|
slices_zero_fl = node.evaluate_output("Paths")
|
|
self.assertEqual(len(slices_zero_fl), 5)
|
|
self.assertAlmostEqual(slices_zero_fl[0][0, 2], 0.2)
|
|
self.assertIsNone(getattr(slices_zero_fl, "first_layer_height", None))
|
|
|
|
def test_slicepaths_attribute_conservation(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
|
|
path1 = 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]
|
|
])
|
|
|
|
sp = SlicePaths([path1], groups=[0], extrusion_rate=[np.array([1.5, 1.5, 1.5, 1.5, 1.5])])
|
|
sp.layer_height = 0.2
|
|
sp.first_layer_height = 0.3
|
|
sp.custom_meta = "test_value"
|
|
|
|
# Test offset
|
|
off = sp.offset(np.array([1.0, 1.0, 0.0]))
|
|
self.assertEqual(getattr(off, "layer_height", None), 0.2)
|
|
self.assertEqual(getattr(off, "first_layer_height", None), 0.3)
|
|
self.assertEqual(getattr(off, "custom_meta", None), "test_value")
|
|
np.testing.assert_allclose(off.extrusion_rate[0], 1.5)
|
|
|
|
# Test simplify
|
|
simp = sp.simplify(0.05)
|
|
self.assertEqual(getattr(simp, "layer_height", None), 0.2)
|
|
self.assertEqual(getattr(simp, "first_layer_height", None), 0.3)
|
|
self.assertEqual(getattr(simp, "custom_meta", None), "test_value")
|
|
self.assertEqual(len(simp.extrusion_rate[0]), len(simp[0]))
|
|
np.testing.assert_allclose(simp.extrusion_rate[0], 1.5)
|
|
|
|
# Test buffer
|
|
buf = sp.buffer(0.5)
|
|
self.assertEqual(getattr(buf, "layer_height", None), 0.2)
|
|
self.assertEqual(getattr(buf, "first_layer_height", None), 0.3)
|
|
self.assertEqual(getattr(buf, "custom_meta", None), "test_value")
|
|
self.assertTrue(len(buf) > 0)
|
|
np.testing.assert_allclose(buf.extrusion_rate[0], 1.5)
|
|
|
|
# Test buffer_multiple
|
|
buf_mult = sp.buffer_multiple(0.2, count=2, width_step=0.2)
|
|
self.assertEqual(getattr(buf_mult, "layer_height", None), 0.2)
|
|
self.assertEqual(getattr(buf_mult, "first_layer_height", None), 0.3)
|
|
self.assertEqual(getattr(buf_mult, "custom_meta", None), "test_value")
|
|
self.assertTrue(len(buf_mult) >= 2)
|
|
for ext in buf_mult.extrusion_rate:
|
|
np.testing.assert_allclose(ext, 1.5)
|
|
|
|
# Test zip_paths & extend_paths
|
|
sp2 = SlicePaths([path1], groups=[1], extrusion_rate=[np.array([1.0, 1.0, 1.0, 1.0, 1.0])])
|
|
zipped = SlicePaths.zip_paths([sp, sp2])
|
|
self.assertEqual(getattr(zipped, "layer_height", None), 0.2)
|
|
self.assertEqual(getattr(zipped, "first_layer_height", None), 0.3)
|
|
self.assertEqual(getattr(zipped, "custom_meta", None), "test_value")
|
|
|
|
extended = SlicePaths.extend_paths([sp, sp2])
|
|
self.assertEqual(getattr(extended, "layer_height", None), 0.2)
|
|
self.assertEqual(getattr(extended, "first_layer_height", None), 0.3)
|
|
self.assertEqual(getattr(extended, "custom_meta", None), "test_value")
|
|
|
|
def test_align_seam_sharpest_rectangle(self):
|
|
from NodeSlicer.core.slice import align_path_seam_sharpest
|
|
# Rectangle where index 0 is a midpoint on a flat edge (5, 0, 0)
|
|
path = np.array([
|
|
[5.0, 0.0, 0.0],
|
|
[10.0, 0.0, 0.0],
|
|
[10.0, 10.0, 0.0],
|
|
[0.0, 10.0, 0.0],
|
|
[0.0, 0.0, 0.0],
|
|
[5.0, 0.0, 0.0]
|
|
])
|
|
aligned = align_path_seam_sharpest(path)
|
|
self.assertEqual(len(aligned), len(path))
|
|
self.assertTrue(np.allclose(aligned[0], aligned[-1]))
|
|
# Start point should be moved from flat edge (5,0,0) to one of the 90-degree corners
|
|
self.assertFalse(np.allclose(aligned[0, :2], [5.0, 0.0]))
|
|
corners = np.array([[10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]])
|
|
is_corner = any(np.allclose(aligned[0, :2], c) for c in corners)
|
|
self.assertTrue(is_corner)
|
|
|
|
def test_align_seam_sharpest_triangle(self):
|
|
from NodeSlicer.core.slice import align_path_seam_sharpest
|
|
# Right triangle with acute 30-degree vertex at (10, 0)
|
|
# Vertices: 90 deg at (0,0), 60 deg at (0, 5.7735), 30 deg at (10,0)
|
|
# Start at 90 deg vertex (0,0)
|
|
path = np.array([
|
|
[0.0, 0.0, 0.0],
|
|
[0.0, 5.7735, 0.0],
|
|
[10.0, 0.0, 0.0],
|
|
[0.0, 0.0, 0.0]
|
|
])
|
|
aligned = align_path_seam_sharpest(path)
|
|
self.assertTrue(np.allclose(aligned[0], aligned[-1]))
|
|
# Sharpest angle is 30 degrees at (10.0, 0.0)
|
|
np.testing.assert_allclose(aligned[0, :2], [10.0, 0.0], atol=1e-3)
|
|
|
|
def test_slicepaths_align_seams(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
path = np.array([
|
|
[5.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],
|
|
[5.0, 0.0, 0.2]
|
|
])
|
|
ext_rates = np.array([1.0, 1.1, 1.2, 1.3, 1.4, 1.0])
|
|
sp = SlicePaths([path], groups=[0], extrusion_rate=[ext_rates])
|
|
sp.layer_height = 0.2
|
|
|
|
aligned_sp = sp.align_seams(mode="sharpest")
|
|
self.assertEqual(getattr(aligned_sp, "layer_height", None), 0.2)
|
|
self.assertEqual(len(aligned_sp[0]), len(path))
|
|
self.assertTrue(np.allclose(aligned_sp[0][0], aligned_sp[0][-1]))
|
|
self.assertFalse(np.allclose(aligned_sp[0][0, :2], [5.0, 0.0]))
|
|
self.assertEqual(len(aligned_sp.extrusion_rate[0]), len(aligned_sp[0]))
|
|
|
|
def test_align_seam_sharpest_concave_l_shape(self):
|
|
from NodeSlicer.core.slice import align_path_seam_sharpest
|
|
# L-shaped polygon in CCW order:
|
|
# Outer convex corners: (0,0), (10,0), (10,5), (5,10), (0,10)
|
|
# Inner concave corner: (5,5)
|
|
path = np.array([
|
|
[0.0, 0.0, 0.0],
|
|
[10.0, 0.0, 0.0],
|
|
[10.0, 5.0, 0.0],
|
|
[5.0, 5.0, 0.0],
|
|
[5.0, 10.0, 0.0],
|
|
[0.0, 10.0, 0.0],
|
|
[0.0, 0.0, 0.0]
|
|
])
|
|
aligned = align_path_seam_sharpest(path)
|
|
self.assertEqual(len(aligned), len(path))
|
|
self.assertTrue(np.allclose(aligned[0], aligned[-1]))
|
|
# Seam should be placed at the concave inner corner (5.0, 5.0)
|
|
np.testing.assert_allclose(aligned[0, :2], [5.0, 5.0])
|
|
|
|
def test_intrinsics_retraction_feedrate(self):
|
|
from NodeSlicer.core.nodeslicer.nodes.base import SliceIntrinsics
|
|
from NodeSlicer.core.nodeslicer.nodes.inputs import SLICENODE_InputIntrinsics
|
|
|
|
# Test SliceIntrinsics default and custom retraction feedrate
|
|
intr = SliceIntrinsics(retraction_feedrate=1800.0)
|
|
self.assertEqual(intr.retraction_feedrate, 1800.0)
|
|
|
|
intr_default = SliceIntrinsics()
|
|
self.assertEqual(intr_default.retraction_feedrate, 0.0)
|
|
|
|
# Test Node evaluation
|
|
node = SLICENODE_InputIntrinsics()
|
|
values = {
|
|
"Bed X": 200.0,
|
|
"Bed Y": 200.0,
|
|
"Feedrate": 1500.0,
|
|
"Travel Feedrate": 3000.0,
|
|
"Extrusion Rate": 0.02,
|
|
"Retraction": 1.0,
|
|
"Retraction Feedrate": 1800.0,
|
|
"Lift": 0.2
|
|
}
|
|
node.resolve = lambda name: values.get(name, 0.0)
|
|
res_intr = node.evaluate_output("SLICESOCK_Intrinsics")
|
|
self.assertEqual(res_intr.retraction_feedrate, 1800.0)
|
|
|
|
def test_gcode_name_from_metadata_paths_to_gcode(self):
|
|
from NodeSlicer.core.slice import SlicePaths, Pather
|
|
from NodeSlicer.infra.networking.gcode_utils import get_gcode_name_from_metadata
|
|
|
|
# 1. Test SlicePaths with explicit name
|
|
p1 = np.array([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]])
|
|
sp = SlicePaths([p1], name="GearPart")
|
|
pather = Pather(sp)
|
|
gcode = pather.to_gcode(include_header=True)
|
|
self.assertIn("; objects_info =", gcode)
|
|
name = get_gcode_name_from_metadata(gcode)
|
|
self.assertEqual(name, "GearPart")
|
|
|
|
# 2. Test SlicePaths with objects_info (multiple objects)
|
|
sp_multi = SlicePaths(
|
|
[p1],
|
|
objects_info={"objects": [{"name": "PartA", "polygon": []}, {"name": "PartB", "polygon": []}]}
|
|
)
|
|
pather_multi = Pather(sp_multi)
|
|
gcode_multi = pather_multi.to_gcode(include_header=True)
|
|
self.assertIn("; objects_info =", gcode_multi)
|
|
name_multi = get_gcode_name_from_metadata(gcode_multi)
|
|
self.assertEqual(name_multi, "PartA-PartB")
|
|
|
|
# 3. Test get_gcode_name_from_metadata fallback object comments
|
|
gcode_fallback = "; Generated Gcode\n; OBJECT_NAME = CustomBracket\nG1 X10 Y10\n"
|
|
self.assertEqual(get_gcode_name_from_metadata(gcode_fallback), "CustomBracket")
|
|
|
|
def test_separate_mesh_by_range_padding(self):
|
|
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_SeparateMeshByRange
|
|
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingCollection, SlicingObject
|
|
|
|
node = SLICENODE_SeparateMeshByRange()
|
|
node.start_val = 0
|
|
node.end_val = 6
|
|
|
|
so = SlicingObject()
|
|
so.name = "CA"
|
|
so.mesh = np.zeros((4, 4, 3))
|
|
so.attributes = {
|
|
"part_id": {
|
|
"domain": "FACE",
|
|
"type": "INT",
|
|
"values": np.array([1, 1, 2, 2])
|
|
}
|
|
}
|
|
|
|
coll = SlicingCollection.__new__(SlicingCollection)
|
|
coll.objects = [so]
|
|
coll.name = "TestColl"
|
|
coll.transforms = []
|
|
node.resolve = lambda name: coll if name == "Mesh" else ("part_id" if name == "Attribute" else None)
|
|
|
|
# start_val = 0, end_val = 6 -> total_parts = 7 -> num_digits = 2
|
|
# socket "1" -> i = 1 -> CA01
|
|
res_1 = node.evaluate_output("1")
|
|
self.assertIsNotNone(res_1)
|
|
self.assertEqual(res_1.objects[0].name, "CA01")
|
|
|
|
# socket "2" -> i = 2 -> CA02
|
|
res_2 = node.evaluate_output("2")
|
|
self.assertIsNotNone(res_2)
|
|
self.assertEqual(res_2.objects[0].name, "CA02")
|
|
|
|
# Now test start_val = 1, end_val = 6 -> total_parts = 6 -> num_digits = 2
|
|
# Numbering starts from zero, so socket "1" has i = 0 -> CA00, socket "2" has i = 1 -> CA01
|
|
node.start_val = 1
|
|
node.end_val = 6
|
|
|
|
res_start1_out1 = node.evaluate_output("1")
|
|
self.assertIsNotNone(res_start1_out1)
|
|
self.assertEqual(res_start1_out1.objects[0].name, "CA00")
|
|
|
|
res_start1_out2 = node.evaluate_output("2")
|
|
self.assertIsNotNone(res_start1_out2)
|
|
self.assertEqual(res_start1_out2.objects[0].name, "CA01")
|
|
|
|
# Test total_parts >= 100 -> num_digits = 3
|
|
node.start_val = 0
|
|
node.end_val = 100 # total_parts = 101 -> num_digits = 3
|
|
res_large_1 = node.evaluate_output("1")
|
|
self.assertIsNotNone(res_large_1)
|
|
self.assertEqual(res_large_1.objects[0].name, "CA001")
|
|
|
|
# Test np.all(mask) where all faces match the target
|
|
so_full = SlicingObject()
|
|
so_full.name = "FullMesh"
|
|
so_full.mesh = np.zeros((4, 4, 3))
|
|
so_full.attributes = {
|
|
"part_id": {
|
|
"domain": "FACE",
|
|
"type": "INT",
|
|
"values": np.array([2, 2, 2, 2])
|
|
}
|
|
}
|
|
coll_full = SlicingCollection.__new__(SlicingCollection)
|
|
coll_full.objects = [so_full]
|
|
coll_full.name = "FullColl"
|
|
coll_full.transforms = []
|
|
node.start_val = 2
|
|
node.end_val = 5 # total_parts = 4, num_digits = 2
|
|
node.resolve = lambda name: coll_full if name == "Mesh" else ("part_id" if name == "Attribute" else None)
|
|
|
|
res_full_2 = node.evaluate_output("2")
|
|
self.assertIsNotNone(res_full_2)
|
|
self.assertEqual(res_full_2.objects[0].name, "FullMesh00")
|
|
|
|
def test_segments_to_slice_ignore_holes(self):
|
|
# Outer square: [0,0] to [20,20] at z=10
|
|
outer_segments = [
|
|
[[0.0, 0.0, 10.0], [20.0, 0.0, 10.0]],
|
|
[[20.0, 0.0, 10.0], [20.0, 20.0, 10.0]],
|
|
[[20.0, 20.0, 10.0], [0.0, 20.0, 10.0]],
|
|
[[0.0, 20.0, 10.0], [0.0, 0.0, 10.0]],
|
|
]
|
|
# Inner hole square: [5,5] to [15,15] at z=10
|
|
hole_segments = [
|
|
[[5.0, 5.0, 10.0], [15.0, 5.0, 10.0]],
|
|
[[15.0, 5.0, 10.0], [15.0, 15.0, 10.0]],
|
|
[[15.0, 15.0, 10.0], [5.0, 15.0, 10.0]],
|
|
[[5.0, 15.0, 10.0], [5.0, 5.0, 10.0]],
|
|
]
|
|
all_segs = np.array(outer_segments + hole_segments, dtype=np.float64)
|
|
|
|
# Default (ignore_holes=False) -> produces 2 paths (outer + hole)
|
|
res_with_holes = segments_to_slice(all_segs, ignore_holes=False)
|
|
self.assertEqual(len(res_with_holes), 2)
|
|
|
|
# ignore_holes=True -> produces 1 path (outer shell only)
|
|
res_no_holes = segments_to_slice(all_segs, ignore_holes=True)
|
|
self.assertEqual(len(res_no_holes), 1)
|
|
# Verify outer shell bounds [0, 20]
|
|
self.assertAlmostEqual(res_no_holes[0][:, 0].min(), 0.0)
|
|
self.assertAlmostEqual(res_no_holes[0][:, 0].max(), 20.0)
|
|
|
|
def test_slice_shells_node_ignore_holes(self):
|
|
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_SliceShells
|
|
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection
|
|
|
|
# Construct a hollow box mesh
|
|
# Outer box: [0, 20] x [0, 20] x [0, 1]
|
|
# Inner hole box: [5, 15] x [5, 15] x [0, 1]
|
|
def make_quad(v0, v1, v2, v3):
|
|
return [
|
|
[v0, v1, v2],
|
|
[v0, v2, v3]
|
|
]
|
|
|
|
# 4 outer vertical walls + 4 inner vertical walls
|
|
z0, z1 = 0.0, 1.0
|
|
faces = []
|
|
# Outer walls
|
|
faces += make_quad([0, 0, z0], [20, 0, z0], [20, 0, z1], [0, 0, z1])
|
|
faces += make_quad([20, 0, z0], [20, 20, z0], [20, 20, z1], [20, 0, z1])
|
|
faces += make_quad([20, 20, z0], [0, 20, z0], [0, 20, z1], [20, 20, z1])
|
|
faces += make_quad([0, 20, z0], [0, 0, z0], [0, 0, z1], [0, 20, z1])
|
|
# Inner hole walls
|
|
faces += make_quad([5, 5, z0], [15, 5, z0], [15, 5, z1], [5, 5, z1])
|
|
faces += make_quad([15, 5, z0], [15, 15, z0], [15, 15, z1], [15, 5, z1])
|
|
faces += make_quad([15, 15, z0], [5, 15, z0], [5, 15, z1], [15, 15, z1])
|
|
faces += make_quad([5, 15, z0], [5, 5, z0], [5, 5, z1], [5, 15, z1])
|
|
|
|
triangles = np.zeros((len(faces), 4, 3))
|
|
for i, tri in enumerate(faces):
|
|
triangles[i, 0] = tri[0]
|
|
triangles[i, 1] = tri[1]
|
|
triangles[i, 2] = tri[2]
|
|
|
|
sobj = SlicingObject()
|
|
sobj.mesh = triangles
|
|
scoll = SlicingCollection([], "test_hollow")
|
|
scoll.objects = [sobj]
|
|
|
|
# 1. Normal evaluation (ignore_holes=False)
|
|
node = SLICENODE_SliceShells()
|
|
node.ignore_holes = False
|
|
node.resolve = lambda name: scoll if name == "Mesh" else (0.5 if name == "Layer Height" else None)
|
|
slices_normal = node.evaluate_output("Paths")
|
|
self.assertEqual(len(slices_normal), 4) # 2 layers * (Outer + hole) = 4
|
|
|
|
# 2. Evaluation with ignore_holes=True
|
|
node.ignore_holes = True
|
|
slices_ignored = node.evaluate_output("Paths")
|
|
self.assertEqual(len(slices_ignored), 2) # 2 layers * (Only outer) = 2
|
|
|
|
# 3. Evaluation when node lacks ignore_holes property (simulating missing property / legacy tree)
|
|
node_missing = SLICENODE_SliceShells.__new__(SLICENODE_SliceShells)
|
|
# Only set resolve and standard dictionary without ignore_holes
|
|
node_missing.resolve = lambda name: scoll if name == "Mesh" else (0.5 if name == "Layer Height" else None)
|
|
slices_missing = node_missing.evaluate_output("Paths")
|
|
self.assertIsNotNone(slices_missing)
|
|
self.assertEqual(len(slices_missing), 4)
|
|
|
|
# 4. draw_buttons with and without ignore_holes does not fail
|
|
mock_layout = MagicMock()
|
|
node.draw_buttons(None, mock_layout)
|
|
mock_layout.prop.assert_called_with(node, "ignore_holes")
|
|
|
|
mock_layout_missing = MagicMock()
|
|
node_missing.draw_buttons(None, mock_layout_missing)
|
|
|
|
def test_micron_integer_math(self):
|
|
from NodeSlicer.core.slice import SCALE, is_closed_path, align_path_seam_sharpest, SlicePaths
|
|
self.assertEqual(SCALE, 1000)
|
|
|
|
# A square with tiny float noise (< 0.5 micron) at endpoint
|
|
sq = np.array([
|
|
[0.0, 0.0, 1.0],
|
|
[10.0, 0.0, 1.0],
|
|
[10.0, 10.0, 1.0],
|
|
[0.0, 10.0, 1.0],
|
|
[0.0001, -0.0001, 1.0] # 0.1 micron noise
|
|
])
|
|
self.assertTrue(is_closed_path(sq))
|
|
|
|
# Seam alignment with integer math
|
|
aligned = align_path_seam_sharpest(sq)
|
|
self.assertEqual(len(aligned), len(sq))
|
|
|
|
# Test buffer with 1-micron precision
|
|
sp = SlicePaths([sq], groups=[0])
|
|
buffered = sp.buffer(1.0)
|
|
self.assertGreater(len(buffered), 0)
|
|
|
|
def test_micron_segment_linking(self):
|
|
from NodeSlicer.core.slice import link_segments, is_closed_path
|
|
# Segments of a triangle with slight float jitter (0.2 micron) at shared vertices
|
|
z = 2.5
|
|
segments = np.array([
|
|
[[0.0002, 0.0, z], [10.0, 0.0001, z]],
|
|
[[10.0, 0.0, z], [5.0, 8.660254, z]],
|
|
[[5.0, 8.660254, z], [0.0, 0.0, z]],
|
|
])
|
|
paths = link_segments(segments)
|
|
self.assertEqual(len(paths), 1)
|
|
self.assertTrue(is_closed_path(paths[0]))
|
|
self.assertTrue(np.allclose(paths[0][0], paths[0][-1]))
|
|
|
|
def test_path_operations_zip(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
# Input 1: layers at Z=1.0 and Z=2.0
|
|
p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]])
|
|
p2 = np.array([[0, 0, 2.0], [10, 0, 2.0], [10, 10, 2.0], [0, 10, 2.0], [0, 0, 2.0]])
|
|
sp1 = SlicePaths([p1, p2], groups=[0, 1])
|
|
|
|
# Input 2: layers at Z=1.0 and Z=2.0
|
|
q1 = np.array([[5, 5, 1.0], [15, 5, 1.0], [15, 15, 1.0], [5, 15, 1.0], [5, 5, 1.0]])
|
|
q2 = np.array([[5, 5, 2.0], [15, 5, 2.0], [15, 15, 2.0], [5, 15, 2.0], [5, 5, 2.0]])
|
|
sp2 = SlicePaths([q1, q2], groups=[0, 1])
|
|
|
|
zipped = SlicePaths.path_operations([sp1, sp2], operation='ZIP')
|
|
self.assertEqual(len(zipped), 4)
|
|
# Verify layer interleaving: (p1, q1) for group 0, (p2, q2) for group 1
|
|
self.assertEqual(zipped.groups, [0, 0, 1, 1])
|
|
self.assertTrue(np.allclose(zipped[0], p1))
|
|
self.assertTrue(np.allclose(zipped[1], q1))
|
|
self.assertTrue(np.allclose(zipped[2], p2))
|
|
self.assertTrue(np.allclose(zipped[3], q2))
|
|
|
|
def test_path_operations_extend(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]])
|
|
p2 = np.array([[0, 0, 2.0], [10, 0, 2.0], [10, 10, 2.0], [0, 10, 2.0], [0, 0, 2.0]])
|
|
sp1 = SlicePaths([p1], groups=[0])
|
|
sp2 = SlicePaths([p2], groups=[1])
|
|
|
|
extended = SlicePaths.path_operations([sp1, sp2], operation='EXTEND')
|
|
self.assertEqual(len(extended), 2)
|
|
self.assertTrue(np.allclose(extended[0], p1))
|
|
self.assertTrue(np.allclose(extended[1], p2))
|
|
|
|
def test_path_operations_max_coverage(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
# Union of two overlapping squares at Z=1.0: (0,0)-(10,10) and (5,0)-(15,10)
|
|
p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]])
|
|
p2 = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]])
|
|
sp1 = SlicePaths([p1], groups=[0])
|
|
sp2 = SlicePaths([p2], groups=[0])
|
|
|
|
union_res = SlicePaths.path_operations([sp1, sp2], operation='MAX_COVERAGE')
|
|
self.assertEqual(len(union_res), 1)
|
|
# Bounding box should span (0,0) to (15,10)
|
|
self.assertAlmostEqual(union_res[0][:, 0].min(), 0.0)
|
|
self.assertAlmostEqual(union_res[0][:, 0].max(), 15.0)
|
|
|
|
def test_path_operations_min_coverage_and_holes(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
# Input 1 has two layers:
|
|
# Layer 1 (Z=1.0): Donut: Outer (0,0)-(20,20) and Hole (5,5)-(15,15)
|
|
outer1 = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]])
|
|
hole1 = np.array([[5, 5, 1.0], [15, 5, 1.0], [15, 15, 1.0], [5, 15, 1.0], [5, 5, 1.0]])
|
|
# Layer 2 (Z=2.0): Solid square (0,0)-(20,20)
|
|
square2 = np.array([[0, 0, 2.0], [20, 0, 2.0], [20, 20, 2.0], [0, 20, 2.0], [0, 0, 2.0]])
|
|
sp1 = SlicePaths([outer1, hole1, square2], groups=[0, 0, 1])
|
|
|
|
# Input 2 exists ONLY on Layer 1 (Z=1.0): Cutting box (-5,-5) to (10,25)
|
|
cutter = np.array([[-5, -5, 1.0], [10, -5, 1.0], [10, 25, 1.0], [-5, 25, 1.0], [-5, -5, 1.0]])
|
|
sp2 = SlicePaths([cutter], groups=[0])
|
|
|
|
min_cov = SlicePaths.path_operations([sp1, sp2], operation='MIN_COVERAGE')
|
|
|
|
# Layer 1 (Z=1.0): Both inputs active -> intersection of donut with left cutter box -> 1 C-bracket contour
|
|
# Layer 2 (Z=2.0): ONLY Input 1 active -> preserves Input 1 path completely!
|
|
self.assertEqual(len(min_cov), 2)
|
|
|
|
layer1_paths = [p for p in min_cov if np.allclose(p[:, 2], 1.0)]
|
|
layer2_paths = [p for p in min_cov if np.allclose(p[:, 2], 2.0)]
|
|
|
|
self.assertEqual(len(layer1_paths), 1)
|
|
self.assertEqual(len(layer2_paths), 1)
|
|
# Layer 2 is exactly preserved
|
|
self.assertTrue(np.allclose(layer2_paths[0], square2))
|
|
# Layer 1 max X is 10.0 (clipped by cutter)
|
|
self.assertAlmostEqual(layer1_paths[0][:, 0].max(), 10.0)
|
|
|
|
def test_path_operations_subtract_and_xor(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
# Square at Z=1.0: (0,0)-(20,20)
|
|
outer = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]])
|
|
# Hole cutter: (5,5)-(15,15)
|
|
hole_cutter = np.array([[5, 5, 1.0], [15, 5, 1.0], [15, 15, 1.0], [5, 15, 1.0], [5, 5, 1.0]])
|
|
sp_outer = SlicePaths([outer], groups=[0])
|
|
sp_cutter = SlicePaths([hole_cutter], groups=[0])
|
|
|
|
# Test Subtract (creates donut: outer + hole)
|
|
sub_res = SlicePaths.path_operations([sp_outer, sp_cutter], operation='SUBTRACT')
|
|
self.assertEqual(len(sub_res), 2)
|
|
|
|
# Test XOR
|
|
xor_res = SlicePaths.path_operations([sp_outer, sp_cutter], operation='XOR')
|
|
self.assertEqual(len(xor_res), 2)
|
|
|
|
# Test Intersection
|
|
inter_res = SlicePaths.path_operations([sp_outer, sp_cutter], operation='INTERSECTION')
|
|
self.assertEqual(len(inter_res), 1)
|
|
# Should match sp_cutter bounds (5 to 15)
|
|
self.assertAlmostEqual(inter_res[0][:, 0].min(), 5.0)
|
|
self.assertAlmostEqual(inter_res[0][:, 0].max(), 15.0)
|
|
|
|
def test_path_operations_intersection_vs_min_coverage(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
# Input 1 has layer at Z=1.0 and layer at Z=2.0
|
|
p1_l1 = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]])
|
|
p1_l2 = np.array([[0, 0, 2.0], [20, 0, 2.0], [20, 20, 2.0], [0, 20, 2.0], [0, 0, 2.0]])
|
|
sp1 = SlicePaths([p1_l1, p1_l2], groups=[0, 1])
|
|
|
|
# Input 2 has ONLY layer at Z=1.0 (from X=10 to 30)
|
|
p2_l1 = np.array([[10, 0, 1.0], [30, 0, 1.0], [30, 20, 1.0], [10, 20, 1.0], [10, 0, 1.0]])
|
|
sp2 = SlicePaths([p2_l1], groups=[0])
|
|
|
|
# Intersection: Z=1.0 intersects to [10, 20], but Z=2.0 has no counterpart in sp2 so produces nothing
|
|
inter_res = SlicePaths.path_operations([sp1, sp2], operation='INTERSECTION')
|
|
self.assertEqual(len(inter_res), 1)
|
|
self.assertAlmostEqual(inter_res[0][0, 2], 1.0)
|
|
self.assertAlmostEqual(inter_res[0][:, 0].min(), 10.0)
|
|
self.assertAlmostEqual(inter_res[0][:, 0].max(), 20.0)
|
|
|
|
# Minimum coverage: Z=1.0 intersects to [10, 20], AND Z=2.0 is preserved because only 1 input is present
|
|
min_cov = SlicePaths.path_operations([sp1, sp2], operation='MIN_COVERAGE')
|
|
self.assertEqual(len(min_cov), 2)
|
|
zs = [p[0, 2] for p in min_cov]
|
|
self.assertIn(1.0, zs)
|
|
self.assertIn(2.0, zs)
|
|
|
|
def test_path_operations_node_evaluation(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOperations
|
|
p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]])
|
|
p2 = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]])
|
|
sp1 = SlicePaths([p1], groups=[0])
|
|
sp2 = SlicePaths([p2], groups=[0])
|
|
|
|
node = SLICENODE_PathOperations()
|
|
mock_socket1 = MagicMock()
|
|
mock_socket1.is_linked = True
|
|
mock_socket2 = MagicMock()
|
|
mock_socket2.is_linked = True
|
|
node.inputs = [mock_socket1, mock_socket2]
|
|
|
|
# Mock resolve_input to return sp1 and sp2
|
|
with patch("NodeSlicer.infra.blender.blender_slice_nodes_generic.resolve_input", side_effect=[sp1, sp2] * 7):
|
|
node.operation = 'ZIP'
|
|
self.assertEqual(len(node.evaluate_output("Paths")), 2)
|
|
|
|
node.operation = 'EXTEND'
|
|
self.assertEqual(len(node.evaluate_output("Paths")), 2)
|
|
|
|
node.operation = 'MAX_COVERAGE'
|
|
self.assertEqual(len(node.evaluate_output("Paths")), 1)
|
|
|
|
node.operation = 'MIN_COVERAGE'
|
|
self.assertEqual(len(node.evaluate_output("Paths")), 1)
|
|
|
|
node.operation = 'INTERSECTION'
|
|
self.assertEqual(len(node.evaluate_output("Paths")), 1)
|
|
|
|
node.operation = 'SUBTRACT'
|
|
self.assertEqual(len(node.evaluate_output("Paths")), 1)
|
|
|
|
node.operation = 'XOR'
|
|
self.assertEqual(len(node.evaluate_output("Paths")), 2)
|
|
|
|
def test_path_operations_node_ignores_unplugged_and_invalid_sockets(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOperations
|
|
|
|
p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]])
|
|
p2 = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]])
|
|
sp1 = SlicePaths([p1], groups=[0])
|
|
sp2 = SlicePaths([p2], groups=[0])
|
|
|
|
node = SLICENODE_PathOperations()
|
|
|
|
# Case 1: 1 linked socket, 1 unlinked trailing socket (typical Blender node state)
|
|
sock_linked = MagicMock()
|
|
sock_linked.is_linked = True
|
|
sock_unlinked = MagicMock()
|
|
sock_unlinked.is_linked = False
|
|
|
|
node.inputs = [sock_linked, sock_unlinked]
|
|
|
|
with patch("NodeSlicer.infra.blender.blender_slice_nodes_generic.resolve_input", return_value=sp1):
|
|
# When only 1 socket is plugged in, operations should NOT be broken by the unlinked socket
|
|
node.operation = 'MIN_COVERAGE'
|
|
self.assertEqual(len(node.evaluate_output("Paths")), 1)
|
|
|
|
node.operation = 'MAX_COVERAGE'
|
|
self.assertEqual(len(node.evaluate_output("Paths")), 1)
|
|
|
|
node.operation = 'INTERSECTION'
|
|
self.assertEqual(len(node.evaluate_output("Paths")), 1)
|
|
|
|
node.operation = 'SUBTRACT'
|
|
self.assertEqual(len(node.evaluate_output("Paths")), 1)
|
|
|
|
node.operation = 'XOR'
|
|
self.assertEqual(len(node.evaluate_output("Paths")), 1)
|
|
|
|
node.operation = 'ZIP'
|
|
self.assertEqual(len(node.evaluate_output("Paths")), 1)
|
|
|
|
node.operation = 'EXTEND'
|
|
self.assertEqual(len(node.evaluate_output("Paths")), 1)
|
|
|
|
# Case 2: Mixed sockets with unlinked and invalid (None, non-SlicePaths, empty SlicePaths)
|
|
sock1 = MagicMock(is_linked=True)
|
|
sock_unlinked_mid = MagicMock(is_linked=False)
|
|
sock_invalid_type = MagicMock(is_linked=True)
|
|
sock_empty_paths = MagicMock(is_linked=True)
|
|
sock2 = MagicMock(is_linked=True)
|
|
sock_unlinked_end = MagicMock(is_linked=False)
|
|
|
|
node.inputs = [sock1, sock_unlinked_mid, sock_invalid_type, sock_empty_paths, sock2, sock_unlinked_end]
|
|
|
|
def mock_resolve(sock):
|
|
if sock == sock1:
|
|
return sp1
|
|
elif sock == sock_invalid_type:
|
|
return "not_a_slice_path"
|
|
elif sock == sock_empty_paths:
|
|
return SlicePaths()
|
|
elif sock == sock2:
|
|
return sp2
|
|
return None
|
|
|
|
with patch("NodeSlicer.infra.blender.blender_slice_nodes_generic.resolve_input", side_effect=mock_resolve):
|
|
node.operation = 'INTERSECTION'
|
|
res = node.evaluate_output("Paths")
|
|
self.assertEqual(len(res), 1)
|
|
|
|
node.operation = 'ZIP'
|
|
res_zip = node.evaluate_output("Paths")
|
|
self.assertEqual(len(res_zip), 2)
|
|
|
|
# Case 3: All sockets unlinked
|
|
node.inputs = [sock_unlinked, sock_unlinked_end]
|
|
res_empty = node.evaluate_output("Paths")
|
|
self.assertIsInstance(res_empty, SlicePaths)
|
|
self.assertEqual(len(res_empty), 0)
|
|
|
|
def test_path_operations_single_input_min_coverage(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
# Single input with 1 path per layer (Z=1.0 and Z=2.0)
|
|
p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]])
|
|
p2 = np.array([[5, 5, 2.0], [15, 5, 2.0], [15, 15, 2.0], [5, 15, 2.0], [5, 5, 2.0]])
|
|
sp = SlicePaths([p1, p2], groups=[0, 1])
|
|
|
|
res = SlicePaths.path_operations([sp], operation='MIN_COVERAGE')
|
|
self.assertEqual(len(res), 2)
|
|
self.assertTrue(np.allclose(res[0], p1))
|
|
self.assertTrue(np.allclose(res[1], p2))
|
|
|
|
# Single input with 2 overlapping paths on Z=1.0 and 1 path on Z=2.0
|
|
p1_b = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]])
|
|
sp_multi = SlicePaths([p1, p1_b, p2], groups=[0, 0, 1])
|
|
res_multi = SlicePaths.path_operations([sp_multi], operation='MIN_COVERAGE')
|
|
self.assertEqual(len(res_multi), 2)
|
|
# Z=1.0 should be intersection [5, 10] in X
|
|
l1 = [p for p in res_multi if np.allclose(p[:, 2], 1.0)][0]
|
|
self.assertAlmostEqual(l1[:, 0].min(), 5.0)
|
|
self.assertAlmostEqual(l1[:, 0].max(), 10.0)
|
|
# Z=2.0 should be preserved intact
|
|
l2 = [p for p in res_multi if np.allclose(p[:, 2], 2.0)][0]
|
|
self.assertTrue(np.allclose(l2, p2))
|
|
|
|
def test_path_operations_single_input_max_coverage(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
# Single input with 2 overlapping squares on Z=1.0
|
|
p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]])
|
|
p2 = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]])
|
|
sp = SlicePaths([p1, p2], groups=[0, 0])
|
|
|
|
res = SlicePaths.path_operations([sp], operation='MAX_COVERAGE')
|
|
self.assertEqual(len(res), 1)
|
|
self.assertAlmostEqual(res[0][:, 0].min(), 0.0)
|
|
self.assertAlmostEqual(res[0][:, 0].max(), 15.0)
|
|
|
|
def test_path_operations_single_input_donut_hole(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
# Donut: Outer CCW (0,0)-(20,20) and Hole CW (5,5)-(15,15)
|
|
outer = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]])
|
|
hole = np.array([[5, 5, 1.0], [5, 15, 1.0], [15, 15, 1.0], [15, 5, 1.0], [5, 5, 1.0]])
|
|
sp = SlicePaths([outer, hole], groups=[0, 0])
|
|
|
|
# Min coverage on single input with hole preserves both outer and hole
|
|
min_res = SlicePaths.path_operations([sp], operation='MIN_COVERAGE')
|
|
self.assertEqual(len(min_res), 2)
|
|
|
|
# Max coverage on single input with hole preserves both outer and hole
|
|
max_res = SlicePaths.path_operations([sp], operation='MAX_COVERAGE')
|
|
self.assertEqual(len(max_res), 2)
|
|
|
|
def test_path_operations_edge_cases(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
# Empty inputs list
|
|
self.assertEqual(len(SlicePaths.path_operations([], operation='MIN_COVERAGE')), 0)
|
|
self.assertEqual(len(SlicePaths.path_operations([], operation='MAX_COVERAGE')), 0)
|
|
self.assertEqual(len(SlicePaths.path_operations([], operation='INTERSECTION')), 0)
|
|
self.assertEqual(len(SlicePaths.path_operations([], operation='SUBTRACT')), 0)
|
|
self.assertEqual(len(SlicePaths.path_operations([], operation='XOR')), 0)
|
|
self.assertEqual(len(SlicePaths.path_operations([], operation='ZIP')), 0)
|
|
self.assertEqual(len(SlicePaths.path_operations([], operation='EXTEND')), 0)
|
|
|
|
# List of empty SlicePaths
|
|
empty_sp = SlicePaths()
|
|
self.assertEqual(len(SlicePaths.path_operations([empty_sp], operation='MIN_COVERAGE')), 0)
|
|
self.assertEqual(len(SlicePaths.path_operations([empty_sp, empty_sp], operation='MAX_COVERAGE')), 0)
|
|
self.assertEqual(len(SlicePaths.path_operations([empty_sp, empty_sp], operation='INTERSECTION')), 0)
|
|
self.assertEqual(len(SlicePaths.path_operations([empty_sp, empty_sp], operation='SUBTRACT')), 0)
|
|
self.assertEqual(len(SlicePaths.path_operations([empty_sp, empty_sp], operation='XOR')), 0)
|
|
|
|
# Non-empty with empty
|
|
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])
|
|
|
|
# Subtract empty from sp1 should return sp1
|
|
sub_res = SlicePaths.path_operations([sp1, empty_sp], operation='SUBTRACT')
|
|
self.assertEqual(len(sub_res), 1)
|
|
|
|
# Subtract sp1 from empty should return empty
|
|
sub_res_empty = SlicePaths.path_operations([empty_sp, sp1], operation='SUBTRACT')
|
|
self.assertEqual(len(sub_res_empty), 0)
|
|
|
|
# Intersection with empty should return empty
|
|
inter_res = SlicePaths.path_operations([sp1, empty_sp], operation='INTERSECTION')
|
|
self.assertEqual(len(inter_res), 0)
|
|
|
|
# Direct classmethods on SlicePaths
|
|
self.assertEqual(len(SlicePaths.minimum_coverage([sp1])), 1)
|
|
self.assertEqual(len(SlicePaths.maximum_coverage([sp1])), 1)
|
|
self.assertEqual(len(SlicePaths.intersection([sp1])), 1)
|
|
self.assertEqual(len(SlicePaths.subtract([sp1])), 1)
|
|
self.assertEqual(len(SlicePaths.xor([sp1])), 1)
|
|
|
|
def test_intersection_with_holes_open_and_closed(self):
|
|
from NodeSlicer.core.slice import SlicePaths, boolean_slice
|
|
|
|
# Donut: Outer (0,0)-(20,20) and Hole (5,5)-(15,15)
|
|
outer = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]])
|
|
hole = np.array([[5, 5, 1.0], [5, 15, 1.0], [15, 15, 1.0], [15, 5, 1.0], [5, 5, 1.0]])
|
|
donut = SlicePaths([outer, hole], groups=[0, 0])
|
|
|
|
# Test 1: Open line crossing donut
|
|
line = np.array([[-5, 10, 1.0], [25, 10, 1.0]])
|
|
line_sp = SlicePaths([line], groups=[0])
|
|
|
|
# Line as subject, donut as object
|
|
res_line_sub = boolean_slice(line_sp, donut, operation='INTERSECTION', connect_along_boundary=False)
|
|
self.assertEqual(len(res_line_sub), 2)
|
|
# Segments should be X: [0, 5] and [15, 20]
|
|
x_spans = sorted([(p[:, 0].min(), p[:, 0].max()) for p in res_line_sub], key=lambda s: s[0])
|
|
self.assertAlmostEqual(x_spans[0][0], 0.0, places=2)
|
|
self.assertAlmostEqual(x_spans[0][1], 5.0, places=2)
|
|
self.assertAlmostEqual(x_spans[1][0], 15.0, places=2)
|
|
self.assertAlmostEqual(x_spans[1][1], 20.0, places=2)
|
|
|
|
# Donut as subject, line as object (intersection is commutative for geometry)
|
|
res_donut_sub = boolean_slice(donut, line_sp, operation='INTERSECTION', connect_along_boundary=False)
|
|
self.assertEqual(len(res_donut_sub), 2)
|
|
x_spans2 = sorted([(p[:, 0].min(), p[:, 0].max()) for p in res_donut_sub], key=lambda s: s[0])
|
|
self.assertAlmostEqual(x_spans2[0][0], 0.0, places=2)
|
|
self.assertAlmostEqual(x_spans2[0][1], 5.0, places=2)
|
|
self.assertAlmostEqual(x_spans2[1][0], 15.0, places=2)
|
|
self.assertAlmostEqual(x_spans2[1][1], 20.0, places=2)
|
|
|
|
# Test 2: Closed square intersecting donut (cutting it into a C-shape)
|
|
half_square = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]])
|
|
half_sp = SlicePaths([half_square], groups=[0])
|
|
res_c_shape = boolean_slice(donut, half_sp, operation='INTERSECTION')
|
|
self.assertEqual(len(res_c_shape), 1)
|
|
|
|
# Test 3: Enclosing square intersecting donut (preserving the donut and hole)
|
|
enclosing = np.array([[-5, -5, 1.0], [25, -5, 1.0], [25, 25, 1.0], [-5, 25, 1.0], [-5, -5, 1.0]])
|
|
enclosing_sp = SlicePaths([enclosing], groups=[0])
|
|
res_donut_kept = boolean_slice(donut, enclosing_sp, operation='INTERSECTION')
|
|
self.assertEqual(len(res_donut_kept), 2)
|
|
|
|
# Test 4: Path Operations INTERSECTION with donut
|
|
res_path_op = SlicePaths.path_operations([donut, enclosing_sp], operation='INTERSECTION')
|
|
self.assertEqual(len(res_path_op), 2)
|
|
|
|
# Test 5: Open line with connect_along_boundary=True
|
|
res_connected = boolean_slice(line_sp, donut, operation='INTERSECTION', connect_along_boundary=True)
|
|
self.assertEqual(len(res_connected), 1)
|
|
|
|
def test_metadata_and_transforms_preservation(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
|
|
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], name="ObjectA")
|
|
sp1.transforms = [{"type": "initial", "val": 1}]
|
|
|
|
# Rotate
|
|
rotated = sp1.rotate(90.0, center=np.array([5.0, 5.0, 1.0]))
|
|
self.assertEqual(len(rotated.transforms), 2)
|
|
self.assertEqual(rotated.transforms[1]["type"], "rotate")
|
|
self.assertEqual(rotated.transforms[1]["angle_deg"], 90.0)
|
|
self.assertEqual(rotated.name, "ObjectA")
|
|
|
|
# Offset
|
|
offset_sp = rotated.offset(np.array([10.0, 0.0, 0.0]))
|
|
self.assertEqual(len(offset_sp.transforms), 3)
|
|
self.assertEqual(offset_sp.transforms[2]["type"], "offset")
|
|
self.assertEqual(offset_sp.transforms[2]["offset"], [10.0, 0.0, 0.0])
|
|
|
|
# Path operations (Intersection / Union / Zip / Extend) merging metadata
|
|
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], name="ObjectB")
|
|
sp2.transforms = [{"type": "scale", "factor": 2.0}]
|
|
|
|
merged_zip = SlicePaths.zip_paths([sp1, sp2])
|
|
self.assertIn("ObjectA", merged_zip.name)
|
|
self.assertIn("ObjectB", merged_zip.name)
|
|
self.assertEqual(len(merged_zip.transforms), 2)
|
|
|
|
merged_inter = SlicePaths.path_operations([sp1, sp2], operation='INTERSECTION')
|
|
self.assertIn("ObjectA", merged_inter.name)
|
|
self.assertIn("ObjectB", merged_inter.name)
|
|
self.assertEqual(len(merged_inter.transforms), 2)
|
|
|
|
def test_complex_donut_intersection_and_bullseye(self):
|
|
from NodeSlicer.core.slice import SlicePaths, _group_closed_paths_into_shapes, boolean_slice
|
|
|
|
# Bullseye target: Outer (0,0)-(30,30), Hole (5,5)-(25,25), Island (10,10)-(20,20)
|
|
outer = np.array([[0, 0, 1.0], [30, 0, 1.0], [30, 30, 1.0], [0, 30, 1.0], [0, 0, 1.0]])
|
|
hole = np.array([[5, 5, 1.0], [5, 25, 1.0], [25, 25, 1.0], [25, 5, 1.0], [5, 5, 1.0]])
|
|
island = np.array([[10, 10, 1.0], [20, 10, 1.0], [20, 20, 1.0], [10, 20, 1.0], [10, 10, 1.0]])
|
|
|
|
shapes = _group_closed_paths_into_shapes([outer, hole, island])
|
|
self.assertEqual(len(shapes), 2)
|
|
# Shape 1 is [outer, hole], Shape 2 is [island]
|
|
shapes_sorted = sorted(shapes, key=lambda s: len(s), reverse=True)
|
|
self.assertEqual(len(shapes_sorted[0]), 2) # Outer + Hole
|
|
self.assertEqual(len(shapes_sorted[1]), 1) # Island
|
|
|
|
# Intersect bullseye with a vertical slice (0..15 in X)
|
|
cutter = np.array([[0, -5, 1.0], [15, -5, 1.0], [15, 35, 1.0], [0, 35, 1.0], [0, -5, 1.0]])
|
|
bullseye_sp = SlicePaths([outer, hole, island], groups=[0, 0, 0])
|
|
cutter_sp = SlicePaths([cutter], groups=[0])
|
|
|
|
inter_res = boolean_slice(bullseye_sp, cutter_sp, operation='INTERSECTION')
|
|
self.assertGreater(len(inter_res), 0)
|
|
# Verify all resulting points have X <= 15.001
|
|
for p in inter_res:
|
|
self.assertLessEqual(p[:, 0].max(), 15.001)
|
|
|
|
def test_full_metadata_lifecycle(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
|
|
p = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]])
|
|
sp = SlicePaths(
|
|
[p],
|
|
groups=[0],
|
|
extrusion_rate=[np.array([1.5, 1.5, 1.5, 1.5, 1.5])],
|
|
feedrate=[np.array([1200.0, 1200.0, 1200.0, 1200.0, 1200.0])],
|
|
extra_attributes={"fan": [np.array([100.0, 100.0, 100.0, 100.0, 100.0])]},
|
|
layer_height=0.25,
|
|
first_layer_height=0.3,
|
|
name="Part1",
|
|
objects_info={"objects": [{"name": "Part1", "polygon": []}]}
|
|
)
|
|
|
|
# Rotate and check metadata
|
|
rotated = sp.rotate(45.0)
|
|
self.assertEqual(rotated.layer_height, 0.25)
|
|
self.assertEqual(rotated.first_layer_height, 0.3)
|
|
self.assertEqual(rotated.name, "Part1")
|
|
self.assertEqual(len(rotated.transforms), 1)
|
|
self.assertEqual(rotated.transforms[0]["type"], "rotate")
|
|
self.assertAlmostEqual(rotated.transforms[0]["angle_deg"], 45.0)
|
|
|
|
# Buffer and check metadata
|
|
buffered = sp.buffer(1.0)
|
|
self.assertEqual(buffered.layer_height, 0.25)
|
|
self.assertEqual(buffered.first_layer_height, 0.3)
|
|
self.assertEqual(buffered.name, "Part1")
|
|
|
|
# Infill and check metadata
|
|
infilled = sp.infill(10.0, infill_type='RECTILINEAR')
|
|
self.assertEqual(infilled.layer_height, 0.25)
|
|
self.assertEqual(infilled.first_layer_height, 0.3)
|
|
self.assertEqual(infilled.name, "Part1")
|
|
|
|
def test_group_segments_by_z_float_drift(self):
|
|
from NodeSlicer.core.slice import group_segments_by_z
|
|
# Segments with slight float drift on the same layer
|
|
seg1 = np.array([[[0.0, 0.0, 0.20000000000000004], [10.0, 0.0, 0.20000000000000004]]])
|
|
seg2 = np.array([[[10.0, 0.0, 0.20000000000000007], [10.0, 10.0, 0.20000000000000007]]])
|
|
seg3 = np.array([[[0.0, 0.0, 0.40000000000000002], [10.0, 0.0, 0.40000000000000002]]])
|
|
all_segs = np.vstack([seg1, seg2, seg3])
|
|
|
|
groups = group_segments_by_z(all_segs)
|
|
self.assertEqual(len(groups), 2)
|
|
self.assertIn(200, groups)
|
|
self.assertIn(400, groups)
|
|
# Key 200 must have both seg1 and seg2
|
|
self.assertEqual(len(groups[200]), 2)
|
|
self.assertEqual(len(groups[400]), 1)
|
|
|
|
def test_pather_z_layer_change_drift(self):
|
|
from NodeSlicer.core.slice import SlicePaths, Pather
|
|
# 2 paths on layer 1 with tiny drift, 1 path on layer 2
|
|
p1 = np.array([[0, 0, 0.20000000000000004], [10, 0, 0.20000000000000004]])
|
|
p2 = np.array([[10, 0, 0.20000000000000007], [10, 10, 0.20000000000000007]])
|
|
p3 = np.array([[0, 0, 0.4], [10, 0, 0.4]])
|
|
sp = SlicePaths([p1, p2, p3], groups=[0, 0, 1])
|
|
|
|
pather = Pather(sp)
|
|
gcode = pather.to_gcode(include_header=False)
|
|
lines = gcode.splitlines()
|
|
before_layer_changes = [l for l in lines if l == ";BEFORE_LAYER_CHANGE"]
|
|
# Exactly 2 layer changes (one for Z=0.2, one for Z=0.4)
|
|
self.assertEqual(len(before_layer_changes), 2)
|
|
|
|
def test_get_first_layer_midpoint_out_of_order_groups(self):
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
# Top layer first, bottom layer second
|
|
top_p = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]])
|
|
bottom_p = np.array([[10, 10, 0.2], [20, 10, 0.2], [20, 20, 0.2], [10, 20, 0.2], [10, 10, 0.2]])
|
|
sp = SlicePaths([top_p, bottom_p], groups=[99, 10])
|
|
|
|
midpoint = sp.get_first_layer_midpoint()
|
|
# Midpoint of bottom_p is [15.0, 15.0, 0.2]
|
|
self.assertAlmostEqual(midpoint[0], 15.0)
|
|
self.assertAlmostEqual(midpoint[1], 15.0)
|
|
self.assertAlmostEqual(midpoint[2], 0.2)
|
|
|
|
|
|
class TestCodeReviewBugFixes(unittest.TestCase):
|
|
def test_ndarray_lru_cache_empty_list_and_dict(self):
|
|
from NodeSlicer.core.slice import buffer_slice
|
|
# buffer_slice is decorated with @ndarray_lru_cache
|
|
res_paths, res_groups = buffer_slice([], 1.0)
|
|
self.assertEqual(res_paths, [])
|
|
self.assertEqual(res_groups, [])
|
|
|
|
def test_pather_kwargs(self):
|
|
from NodeSlicer.core.slice import Pather
|
|
path = np.array([[0, 0, 0], [10, 0, 0]], dtype=np.float64)
|
|
pather = Pather([path], feedrate=1500.0, retraction=1.2, bed_center_x=120.0)
|
|
self.assertEqual(pather.feedrate, 1500.0)
|
|
self.assertEqual(pather.retraction, 1.2)
|
|
self.assertEqual(pather.bed_center_x, 120.0)
|
|
|
|
def test_join_mesh_immutability(self):
|
|
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_JoinMesh
|
|
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingCollection, SlicingObject
|
|
|
|
so1 = SlicingObject()
|
|
so1.name = "obj1"
|
|
so2 = SlicingObject()
|
|
so2.name = "obj2"
|
|
|
|
coll1 = SlicingCollection([], "coll1")
|
|
coll1.objects = [so1]
|
|
coll2 = SlicingCollection([], "coll2")
|
|
coll2.objects = [so2]
|
|
|
|
node = SLICENODE_JoinMesh()
|
|
node.inputs = [MagicMock(), MagicMock()]
|
|
|
|
with patch("NodeSlicer.infra.blender.blender_slice_nodes_generic.resolve_input", side_effect=[coll1, coll2]):
|
|
res = node.evaluate_output("Mesh")
|
|
self.assertEqual(len(res.objects), 2)
|
|
# Ensure coll1.objects was NOT mutated
|
|
self.assertEqual(len(coll1.objects), 1)
|
|
|
|
def test_join_mesh_groups_empty_collection(self):
|
|
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_JoinMeshGroups
|
|
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingCollection
|
|
|
|
coll_empty = SlicingCollection([], "empty_coll")
|
|
node = SLICENODE_JoinMeshGroups()
|
|
node.inputs = [MagicMock()]
|
|
|
|
with patch("NodeSlicer.infra.blender.blender_slice_nodes_generic.resolve_input", return_value=coll_empty):
|
|
group = node.evaluate_output("Mesh")
|
|
self.assertIn("empty_coll", group.collections)
|
|
|
|
def test_slice_shells_empty_mesh(self):
|
|
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_SliceShells
|
|
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingCollection
|
|
|
|
empty_coll = SlicingCollection([], "empty")
|
|
node = SLICENODE_SliceShells()
|
|
node.inputs = [MagicMock(), MagicMock()]
|
|
|
|
with patch.object(node, "resolve", side_effect=lambda name: empty_coll if name == "Mesh" else 0.2):
|
|
res = node.evaluate_output("Paths")
|
|
self.assertEqual(len(res), 0)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|
|
|
|
|