diff --git a/core/slice.py b/core/slice.py index 684add4..74c64ca 100644 --- a/core/slice.py +++ b/core/slice.py @@ -334,28 +334,38 @@ def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]: linked = link_segments(segments) if not linked: return [] - pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue] + closed_paths = [] + open_paths = [] for p in linked: - # Pyclipper expects open paths (last point != first point) for ET_CLOSEDPOLYGON path_pts = p[:, :2] if len(path_pts) > 2 and np.allclose(path_pts[0], path_pts[-1]): - path_pts = path_pts[:-1] + closed_paths.append(p) + else: + open_paths.append(p) - scaled = (path_pts * SCALE).astype(np.int64).tolist() - pc.AddPath(scaled, pyclipper.PT_SUBJECT, closed = True) # pyright: ignore[reportAttributeAccessIssue] - - # Use EVENODD for robust hole detection. - solution = pc.Execute(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] - - z = segments[0, 0, 2] res = [] - for path in solution: - if not path: continue - fpath = np.array(path) / SCALE - fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) - # Close loop by appending the first point for internal consistency - fpath_3d = np.vstack([fpath_3d, fpath_3d[0]]) - res.append(fpath_3d) + z = segments[0, 0, 2] + + if closed_paths: + pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue] + for p in closed_paths: + path_pts = p[:, :2] + path_pts = path_pts[:-1] + scaled = (path_pts * SCALE).astype(np.int64).tolist() + pc.AddPath(scaled, pyclipper.PT_SUBJECT, closed = True) # pyright: ignore[reportAttributeAccessIssue] + + # Use EVENODD for robust hole detection. + solution = pc.Execute(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] + + for path in solution: + if not path: continue + fpath = np.array(path) / SCALE + fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) + # Close loop by appending the first point for internal consistency + fpath_3d = np.vstack([fpath_3d, fpath_3d[0]]) + res.append(fpath_3d) + + res.extend(open_paths) return res def find_point_on_loop(pt: np.ndarray, loop: np.ndarray, tol: float = 1.0) -> tuple[int, float]: diff --git a/doc/MODULES.md b/doc/MODULES.md index 7857e4f..fe2936e 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -29,7 +29,7 @@ This file provides a mapping of modules to their primary responsibilities and ke - `slice_at_range()`: Top-level slicing function that iterates through Z-heights. - `slice_at_z()`: Vectorized plane-mesh intersection using NumPy. - `Pather`: Class that converts toolpaths into G-code strings. Supports per-segment extrusion rates, feedrates, and custom extra attributes. - - `segments_to_slice()`: Utility to convert raw intersection segments into closed loops using Pyclipper. + - `segments_to_slice()`: Utility to convert raw intersection segments into continuous paths (closed polygons are processed via Pyclipper, open paths remain open). - `link_segments()`: Logic for joining loose segments into continuous paths. - `buffer_slice()`: Offset utility using PyclipperOffset. Supports rotated offsetting. - `infill_slice()`: Generates rotated grid or rectilinear infill patterns, ensuring layer-to-layer consistency. diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 9dcfef0..991420d 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -14,7 +14,7 @@ class TestPrusaSlicerProfiles(unittest.TestCase): @classmethod def setUpClass(cls): cls.cache = LocalCache() - cls.test_dir = PROJECT_ROOT / "tests" + cls.test_dir = PROJECT_ROOT / "profiles" / "prusaslicer" cls.ini_file = cls.test_dir / "2.4.5.ini" if not cls.ini_file.exists(): @@ -47,7 +47,7 @@ class TestPrusaSlicerProfiles(unittest.TestCase): def test_compatibility(self): """Verify that compatibility evaluation runs without errors.""" - printer_keys = set(self.cache.printer_profiles.keys()) + printer_keys = {k.removeprefix("printer:") for k in self.cache.printer_profiles.keys()} vendors = set(self.cache.vendors) # This should not raise any exceptions diff --git a/tests/test_slice.py b/tests/test_slice.py new file mode 100644 index 0000000..ba9799c --- /dev/null +++ b/tests/test_slice.py @@ -0,0 +1,93 @@ +import numpy as np +import unittest +from unittest.mock import MagicMock +import sys +from pathlib import Path + +# Add workspace directory to sys.path to resolve root package name (NodeSlicer) +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.append(str(PROJECT_ROOT.parent)) + +# Define dummy classes to avoid metaclass conflicts +class DummyNodeSocket: pass +class DummyNode: pass +class DummyObject: pass +class DummyMesh: pass +class DummyNodeSocketShader: pass +class DummyNodeCustomGroup: pass +class DummyNodeTree: pass +class DummyOperator: pass +class DummyMenu: pass +class DummyPanel: pass +class DummyPropertyGroup: pass + +mock_types = MagicMock() +mock_types.NodeSocket = DummyNodeSocket +mock_types.Node = DummyNode +mock_types.Object = DummyObject +mock_types.Mesh = DummyMesh +mock_types.NodeSocketShader = DummyNodeSocketShader +mock_types.NodeCustomGroup = DummyNodeCustomGroup +mock_types.NodeTree = DummyNodeTree +mock_types.Operator = DummyOperator +mock_types.Menu = DummyMenu +mock_types.Panel = DummyPanel +mock_types.PropertyGroup = DummyPropertyGroup + +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}") + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_transform.py b/tests/test_transform.py index edf45ad..185a620 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -47,7 +47,7 @@ sys.modules['bpy.utils.previews'] = MagicMock() sys.modules['bpy.app'] = MagicMock() sys.modules['bpy.app.handlers'] = MagicMock() -from ..infra.blender.blender_mesh_capture import SlicingCollection, SlicingObject +from NodeSlicer.infra.blender.blender_mesh_capture import SlicingCollection, SlicingObject class TestTransform(unittest.TestCase): def test_offset(self):