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") # 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 islands on Z=1.0 and 1 path on Z=2.0 -> all preserved intact p1_b = np.array([[50, 0, 1.0], [60, 0, 1.0], [60, 10, 1.0], [50, 10, 1.0], [50, 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), 3) def test_min_coverage_ignores_empty_layers_across_objects(self): from NodeSlicer.core.slice import SlicePaths # Object A exists on Z=1.0 and Z=2.0 # Object B exists on Z=2.0 and Z=3.0 pA1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) pA2 = np.array([[0, 0, 2.0], [10, 0, 2.0], [10, 10, 2.0], [0, 10, 2.0], [0, 0, 2.0]]) spA = SlicePaths([pA1, pA2], groups=[0, 1]) pB2 = np.array([[5, 0, 2.0], [15, 0, 2.0], [15, 10, 2.0], [5, 10, 2.0], [5, 0, 2.0]]) pB3 = np.array([[5, 5, 3.0], [15, 5, 3.0], [15, 15, 3.0], [5, 15, 3.0], [5, 5, 3.0]]) spB = SlicePaths([pB2, pB3], groups=[0, 1]) # Min Coverage across spA and spB: # Z=1.0: only spA -> spA1 preserved intact # Z=2.0: spA2 and spB2 -> intersection [5, 10] in X # Z=3.0: only spB -> spB3 preserved intact res = SlicePaths.path_operations([spA, spB], operation='MIN_COVERAGE') self.assertEqual(len(res), 3) z1 = [p for p in res if np.allclose(p[:, 2], 1.0)][0] self.assertAlmostEqual(z1[:, 0].min(), 0.0) self.assertAlmostEqual(z1[:, 0].max(), 10.0) z2 = [p for p in res if np.allclose(p[:, 2], 2.0)][0] self.assertAlmostEqual(z2[:, 0].min(), 5.0) self.assertAlmostEqual(z2[:, 0].max(), 10.0) z3 = [p for p in res if np.allclose(p[:, 2], 3.0)][0] self.assertAlmostEqual(z3[:, 0].min(), 5.0) self.assertAlmostEqual(z3[:, 0].max(), 15.0) 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 -> boolean unioned into 1 path 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_concentric_perimeters_two_inputs(self): from NodeSlicer.core.slice import SlicePaths # Input 1: Outer perimeter (0,0)-(20,20) # Input 2: Inner perimeter (2,2)-(18,18) p_out = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]]) p_in = np.array([[2, 2, 1.0], [18, 2, 1.0], [18, 18, 1.0], [2, 18, 1.0], [2, 2, 1.0]]) sp_out = SlicePaths([p_out], groups=[0]) sp_in = SlicePaths([p_in], groups=[0]) max_cov = SlicePaths.path_operations([sp_out, sp_in], operation='MAX_COVERAGE') self.assertEqual(len(max_cov), 1) self.assertAlmostEqual(max_cov[0][:, 0].min(), 0.0) self.assertAlmostEqual(max_cov[0][:, 0].max(), 20.0) min_cov = SlicePaths.path_operations([sp_out, sp_in], operation='MIN_COVERAGE') self.assertEqual(len(min_cov), 1) self.assertAlmostEqual(min_cov[0][:, 0].min(), 2.0) self.assertAlmostEqual(min_cov[0][:, 0].max(), 18.0) def test_path_operations_concentric_perimeters_single_input(self): from NodeSlicer.core.slice import SlicePaths # Single input with 2 concentric perimeters on Z=1.0 p_out = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]]) p_in = np.array([[2, 2, 1.0], [18, 2, 1.0], [18, 18, 1.0], [2, 18, 1.0], [2, 2, 1.0]]) sp_conc = SlicePaths([p_out, p_in], groups=[0, 0]) max_cov = SlicePaths.path_operations([sp_conc], operation='MAX_COVERAGE') self.assertEqual(len(max_cov), 1) self.assertAlmostEqual(max_cov[0][:, 0].min(), 0.0) self.assertAlmostEqual(max_cov[0][:, 0].max(), 20.0) min_cov = SlicePaths.path_operations([sp_conc], operation='MIN_COVERAGE') self.assertEqual(len(min_cov), 1) self.assertAlmostEqual(min_cov[0][:, 0].min(), 2.0) self.assertAlmostEqual(min_cov[0][:, 0].max(), 18.0) def test_path_operations_donut_plus_box_max_and_min_coverage(self): from NodeSlicer.core.slice import SlicePaths # Donut: Outer (0,0)-(30,30) and Hole (8,8)-(22,22) outer_donut = np.array([[0, 0, 1.0], [30, 0, 1.0], [30, 30, 1.0], [0, 30, 1.0], [0, 0, 1.0]]) hole_donut = np.array([[8, 8, 1.0], [8, 22, 1.0], [22, 22, 1.0], [22, 8, 1.0], [8, 8, 1.0]]) donut_sp = SlicePaths([outer_donut, hole_donut], groups=[0, 0]) # Solid Box (15,15)-(40,40) box = np.array([[15, 15, 1.0], [40, 15, 1.0], [40, 40, 1.0], [15, 40, 1.0], [15, 15, 1.0]]) box_sp = SlicePaths([box], groups=[0]) # Max coverage (union): outer boundary [0, 40] and remaining hole [8, 22] (2 paths) max_res = SlicePaths.path_operations([donut_sp, box_sp], operation='MAX_COVERAGE') self.assertEqual(len(max_res), 2) outer_p = [p for p in max_res if p[:, 0].min() == 0.0][0] self.assertAlmostEqual(outer_p[:, 0].max(), 40.0) hole_p = [p for p in max_res if p[:, 0].min() >= 8.0][0] self.assertAlmostEqual(hole_p[:, 0].min(), 8.0) self.assertAlmostEqual(hole_p[:, 0].max(), 22.0) # Min coverage (intersection): [15, 30] x [15, 30] minus hole (1 path) min_res = SlicePaths.path_operations([donut_sp, box_sp], operation='MIN_COVERAGE') self.assertEqual(len(min_res), 1) self.assertAlmostEqual(min_res[0][:, 0].min(), 15.0) self.assertAlmostEqual(min_res[0][:, 0].max(), 30.0) 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": "transform", "translation": (0.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}] # Rotate rotated = sp1.rotate(90.0, center=np.array([5.0, 5.0, 1.0])) self.assertEqual(len(rotated.transforms), 1) self.assertEqual(rotated.transforms[0]["type"], "transform") self.assertAlmostEqual(rotated.transforms[0]["rotation"][2], np.pi / 2.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), 1) self.assertEqual(offset_sp.transforms[0]["type"], "transform") self.assertEqual(offset_sp.transforms[0]["translation"], (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": "transform", "translation": (0.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (2.0, 2.0, 2.0)}] merged_zip = SlicePaths.zip_paths([sp1, sp2]) self.assertEqual(merged_zip.name, "ObjectA") self.assertEqual(len(merged_zip.transforms), 1) merged_inter = SlicePaths.path_operations([sp1, sp2], operation='INTERSECTION') self.assertEqual(merged_inter.name, "ObjectA") self.assertEqual(len(merged_inter.transforms), 1) 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"], "transform") 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) def test_buffer_shells_multiple_node(self): from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_BufferShellsMultiple from NodeSlicer.core.slice import SlicePaths # Square from (0,0) to (20,20) sq = np.array([[0, 0, 0.2], [20, 0, 0.2], [20, 20, 0.2], [0, 20, 0.2], [0, 0, 0.2]], dtype=np.float64) sp = SlicePaths([sq], groups=[0]) node = SLICENODE_BufferShellsMultiple() node.shape_option = 'square' # Test with inward offset: distance = 0.0, width_step = -1.0, count = 3 # Buffers will be at dist: 0.0, -1.0, -2.0 # Outermost should be dist 0.0 (area 400) # Innermost should be dist -2.0 (area (20-4)^2 = 256) resolve_map = { "Paths": sp, "Distance": 0.0, "Width Step": -1.0, "Count": 3, "Rotation": (0.0, 0.0, 0.0) } with patch.object(node, "resolve", side_effect=lambda name: resolve_map.get(name)): # Paths output all_paths = node.evaluate_output("Paths") self.assertEqual(len(all_paths), 3) # Outermost Path output (dist = 0.0) outermost = node.evaluate_output("Outermost Path") self.assertEqual(len(outermost), 1) # Check bounding box of outermost (0 to 20) self.assertAlmostEqual(np.min(outermost[0][:, 0]), 0.0, places=2) self.assertAlmostEqual(np.max(outermost[0][:, 0]), 20.0, places=2) # Innermost Path output (dist = -2.0) innermost = node.evaluate_output("Innermost Path") self.assertEqual(len(innermost), 1) # Check bounding box of innermost (2 to 18) self.assertAlmostEqual(np.min(innermost[0][:, 0]), 2.0, places=2) self.assertAlmostEqual(np.max(innermost[0][:, 0]), 18.0, places=2) # Test with outward offset: distance = 0.0, width_step = 1.0, count = 3 # Buffers will be at dist: 0.0, 1.0, 2.0 # Outermost should be dist 2.0 (box -2 to 22) # Innermost should be dist 0.0 (box 0 to 20) resolve_map_out = { "Paths": sp, "Distance": 0.0, "Width Step": 1.0, "Count": 3, "Rotation": (0.0, 0.0, 0.0) } with patch.object(node, "resolve", side_effect=lambda name: resolve_map_out.get(name)): outermost_out = node.evaluate_output("Outermost Path") self.assertEqual(len(outermost_out), 1) self.assertAlmostEqual(np.min(outermost_out[0][:, 0]), -2.0, places=2) self.assertAlmostEqual(np.max(outermost_out[0][:, 0]), 22.0, places=2) innermost_out = node.evaluate_output("Innermost Path") self.assertEqual(len(innermost_out), 1) self.assertAlmostEqual(np.min(innermost_out[0][:, 0]), 0.0, places=2) self.assertAlmostEqual(np.max(innermost_out[0][:, 0]), 20.0, places=2) # Test count = 1 resolve_map_single = { "Paths": sp, "Distance": -0.5, "Width Step": -1.0, "Count": 1, "Rotation": (0.0, 0.0, 0.0) } with patch.object(node, "resolve", side_effect=lambda name: resolve_map_single.get(name)): outermost_single = node.evaluate_output("Outermost Path") innermost_single = node.evaluate_output("Innermost Path") self.assertEqual(len(outermost_single), 1) self.assertEqual(len(innermost_single), 1) self.assertTrue(np.allclose(outermost_single[0], innermost_single[0])) # Test count <= 0 or empty paths resolve_map_zero = { "Paths": sp, "Distance": 0.0, "Width Step": 0.4, "Count": 0, "Rotation": (0.0, 0.0, 0.0) } with patch.object(node, "resolve", side_effect=lambda name: resolve_map_zero.get(name)): self.assertEqual(len(node.evaluate_output("Paths")), 0) self.assertEqual(len(node.evaluate_output("Outermost Path")), 0) self.assertEqual(len(node.evaluate_output("Innermost Path")), 0) # Test unknown socket name with patch.object(node, "resolve", side_effect=lambda name: resolve_map.get(name)): self.assertEqual(len(node.evaluate_output("UnknownSocket")), 0) def test_buffer_shells_node(self): from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_BufferShells from NodeSlicer.core.slice import SlicePaths sq = np.array([[0, 0, 0.2], [20, 0, 0.2], [20, 20, 0.2], [0, 20, 0.2], [0, 0, 0.2]], dtype=np.float64) sp = SlicePaths([sq], groups=[0]) node = SLICENODE_BufferShells() node.shape_option = 'square' resolve_map = { "Paths": sp, "Distance": 1.0, "Rotation": (0.0, 0.0, 0.0) } with patch.object(node, "resolve", side_effect=lambda name: resolve_map.get(name)): res = node.evaluate_output("Paths") self.assertEqual(len(res), 1) self.assertAlmostEqual(np.min(res[0][:, 0]), -1.0, places=2) self.assertAlmostEqual(np.max(res[0][:, 0]), 21.0, places=2) def test_name_preservation_across_all_nodes_and_operations(self): from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_SliceShells from NodeSlicer.core.slice import SlicePaths, path_operations_slice from NodeSlicer.infra.blender.blender_mesh_capture import SlicingCollection, SlicingObject from NodeSlicer.infra.networking.gcode_utils import get_gcode_name_from_metadata # 1. SlicingObject.merge so1 = SlicingObject() so1.name = "FirstMesh" so1.mesh = np.zeros((1, 4, 3)) so2 = SlicingObject() so2.name = "SecondMesh" so2.mesh = np.zeros((1, 4, 3)) so_merged = SlicingObject.merge([so1, so2]) self.assertEqual(so_merged.name, "FirstMesh") # 2. SLICENODE_SliceShells coll = SlicingCollection([], "MyCollection") coll.objects = [so1, so2] node = SLICENODE_SliceShells() resolve_map = { "Mesh": coll, "Layer Height": 0.2, "First Layer Height": 0.2, "Rotation": (0.0, 0.0, 0.0) } with patch.object(node, "resolve", side_effect=lambda name: resolve_map.get(name)): with patch("NodeSlicer.core.nodeslicer.nodes.path.slice_at_range", return_value=[[np.array([[0, 0, 0.2], [10, 0, 0.2], [10, 10, 0.2], [0, 10, 0.2], [0, 0, 0.2]])]]): slices = node.evaluate_output("Paths") self.assertEqual(slices.name, "FirstMesh") # 3. Path Operations (ZIP, EXTEND, MAX_COVERAGE, MIN_COVERAGE, INTERSECTION, SUBTRACT, XOR) p1 = np.array([[0, 0, 0.2], [10, 0, 0.2], [10, 10, 0.2], [0, 10, 0.2], [0, 0, 0.2]]) p2 = np.array([[5, 0, 0.2], [15, 0, 0.2], [15, 10, 0.2], [5, 10, 0.2], [5, 0, 0.2]]) sp_a = SlicePaths([p1], groups=[0], name="Alpha") sp_b = SlicePaths([p2], groups=[0], name="Beta") for op in ['ZIP', 'EXTEND', 'MAX_COVERAGE', 'MIN_COVERAGE', 'INTERSECTION', 'SUBTRACT', 'XOR']: res = path_operations_slice([sp_a, sp_b], operation=op) self.assertEqual(res.name, "Alpha", f"Failed for operation {op}") # 4. G-code name from metadata with multiple objects in objects_info gc = '; objects_info = {"objects": [{"name": "BracketA", "polygon": []}, {"name": "BracketB", "polygon": []}]}\n; filament_type = PLA\n; printer_model = MK4\n' gname = get_gcode_name_from_metadata(gc) self.assertEqual(gname, "BracketA-PLA-MK4") def test_robust_mesh_slicing_coplanar_cube(self): from NodeSlicer.core.slice import slice_at_range, is_closed_path # 10x10x10 cube verts = np.array([ [0, 0, 0], [10, 0, 0], [10, 10, 0], [0, 10, 0], [0, 0, 10], [10, 0, 10], [10, 10, 10], [0, 10, 10] ], dtype=float) faces = [ [0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7] ] triangles = np.zeros((len(faces), 4, 3)) for i, f in enumerate(faces): triangles[i, 0] = verts[f[0]] triangles[i, 1] = verts[f[1]] triangles[i, 2] = verts[f[2]] # Slice at flat bottom (z=0.0), middle (z=5.0), and flat top (z=10.0) res = slice_at_range([triangles], [0.0, 5.0, 10.0]) self.assertEqual(len(res), 1) paths = res[0] self.assertEqual(len(paths), 3) for p in paths: self.assertTrue(is_closed_path(p)) self.assertTrue(len(p) >= 4) def test_robust_mesh_slicing_octahedron_coplanar_vertices_and_edges(self): from NodeSlicer.core.slice import slice_at_range, is_closed_path # Octahedron centered at origin with 4 vertices lying exactly at z=0.0 verts = np.array([ [5, 0, 0], [-5, 0, 0], [0, 5, 0], [0, -5, 0], [0, 0, 5], [0, 0, -5] ], dtype=float) faces = [ [0, 2, 4], [2, 1, 4], [1, 3, 4], [3, 0, 4], [2, 0, 5], [1, 2, 5], [3, 1, 5], [0, 3, 5], ] triangles = np.zeros((len(faces), 4, 3)) for i, f in enumerate(faces): triangles[i, 0] = verts[f[0]] triangles[i, 1] = verts[f[1]] triangles[i, 2] = verts[f[2]] res = slice_at_range([triangles], [0.0]) self.assertEqual(len(res), 1) paths = res[0] self.assertEqual(len(paths), 1) self.assertTrue(is_closed_path(paths[0])) def test_robust_mesh_slicing_staircase_step(self): from NodeSlicer.core.slice import slice_at_range, is_closed_path # Stepped mesh with a plateau at z=5.0 verts = np.array([ [0, 0, 0], [10, 0, 0], [10, 10, 0], [0, 10, 0], [20, 0, 0], [20, 10, 0], [10, 0, 5], [20, 0, 5], [20, 10, 5], [10, 10, 5], [0, 0, 10], [10, 0, 10], [10, 10, 10], [0, 10, 10] ], dtype=float) faces = [ [0, 2, 1], [0, 3, 2], [1, 5, 4], [1, 2, 5], [10, 11, 12], [10, 12, 13], [6, 7, 8], [6, 8, 9], [0, 1, 6], [0, 6, 10], [10, 6, 11], [1, 4, 7], [1, 7, 6], [4, 5, 8], [4, 8, 7], [5, 2, 9], [5, 9, 8], [2, 3, 13], [2, 13, 9], [9, 13, 12], [3, 0, 10], [3, 10, 13], [11, 6, 9], [11, 9, 12] ] triangles = np.zeros((len(faces), 4, 3)) for i, f in enumerate(faces): triangles[i, 0] = verts[f[0]] triangles[i, 1] = verts[f[1]] triangles[i, 2] = verts[f[2]] res = slice_at_range([triangles], [0.0, 2.5, 5.0, 7.5]) self.assertEqual(len(res), 1) paths = res[0] self.assertEqual(len(paths), 4) for p in paths: self.assertTrue(is_closed_path(p)) def test_robust_mesh_slicing_open_sheet(self): from NodeSlicer.core.slice import slice_at_range # Open triangle in 3D open_tri = np.zeros((1, 4, 3)) open_tri[0, 0] = [0, 0, 0] open_tri[0, 1] = [10, 0, 10] open_tri[0, 2] = [0, 10, 5] res = slice_at_range([open_tri], [3.0]) self.assertEqual(len(res), 1) self.assertEqual(len(res[0]), 1) self.assertEqual(len(res[0][0]), 2) # 1 line segment with 2 endpoints def test_buffer_multiple_multilayer(self): from NodeSlicer.core.slice import SlicePaths, buffer_multiple_slice # Two layers: z=0.2 (square 10x10) and z=0.4 (square 10x10) p1 = np.array([[0, 0, 0.2], [10, 0, 0.2], [10, 10, 0.2], [0, 10, 0.2], [0, 0, 0.2]]) p2 = np.array([[0, 0, 0.4], [10, 0, 0.4], [10, 10, 0.4], [0, 10, 0.4], [0, 0, 0.4]]) sp = SlicePaths([p1, p2], groups=[0, 1]) # 3 shells with base -1.0, step -1.0 buffered = sp.buffer_multiple(-1.0, count=3, width_step=-1.0) self.assertEqual(len(buffered), 6) # 3 shells on layer 0, 3 shells on layer 1 self.assertEqual(buffered.groups, [0, 0, 0, 1, 1, 1]) # Check Z height consistency for i in range(3): self.assertAlmostEqual(buffered[i][0, 2], 0.2) for i in range(3, 6): self.assertAlmostEqual(buffered[i][0, 2], 0.4) # Check buffer_multiple_slice directly raw_paths, raw_groups = buffer_multiple_slice([p1, p2], -1.0, 3, -1.0) self.assertEqual(len(raw_paths), 6) self.assertEqual(raw_groups, [0, 0, 0, 1, 1, 1]) if __name__ == '__main__': unittest.main()