better open paths support

This commit is contained in:
Nicolas Predella 2026-07-02 15:39:02 +03:00
parent 7d53f29566
commit 0a8910d9cb
5 changed files with 124 additions and 21 deletions

View file

@ -334,21 +334,29 @@ 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)
res = []
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]
z = segments[0, 0, 2]
res = []
for path in solution:
if not path: continue
fpath = np.array(path) / SCALE
@ -356,6 +364,8 @@ def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
# 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]:

View file

@ -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.

View file

@ -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

93
tests/test_slice.py Normal file
View file

@ -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()

View file

@ -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):