better open paths support
This commit is contained in:
parent
7d53f29566
commit
0a8910d9cb
5 changed files with 124 additions and 21 deletions
|
|
@ -334,21 +334,29 @@ def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
|
||||||
linked = link_segments(segments)
|
linked = link_segments(segments)
|
||||||
if not linked: return []
|
if not linked: return []
|
||||||
|
|
||||||
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
|
closed_paths = []
|
||||||
|
open_paths = []
|
||||||
for p in linked:
|
for p in linked:
|
||||||
# Pyclipper expects open paths (last point != first point) for ET_CLOSEDPOLYGON
|
|
||||||
path_pts = p[:, :2]
|
path_pts = p[:, :2]
|
||||||
if len(path_pts) > 2 and np.allclose(path_pts[0], path_pts[-1]):
|
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()
|
scaled = (path_pts * SCALE).astype(np.int64).tolist()
|
||||||
pc.AddPath(scaled, pyclipper.PT_SUBJECT, closed = True) # pyright: ignore[reportAttributeAccessIssue]
|
pc.AddPath(scaled, pyclipper.PT_SUBJECT, closed = True) # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
# Use EVENODD for robust hole detection.
|
# Use EVENODD for robust hole detection.
|
||||||
solution = pc.Execute(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
|
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:
|
for path in solution:
|
||||||
if not path: continue
|
if not path: continue
|
||||||
fpath = np.array(path) / SCALE
|
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
|
# Close loop by appending the first point for internal consistency
|
||||||
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
|
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
|
||||||
res.append(fpath_3d)
|
res.append(fpath_3d)
|
||||||
|
|
||||||
|
res.extend(open_paths)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def find_point_on_loop(pt: np.ndarray, loop: np.ndarray, tol: float = 1.0) -> tuple[int, float]:
|
def find_point_on_loop(pt: np.ndarray, loop: np.ndarray, tol: float = 1.0) -> tuple[int, float]:
|
||||||
|
|
|
||||||
|
|
@ -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_range()`: Top-level slicing function that iterates through Z-heights.
|
||||||
- `slice_at_z()`: Vectorized plane-mesh intersection using NumPy.
|
- `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.
|
- `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.
|
- `link_segments()`: Logic for joining loose segments into continuous paths.
|
||||||
- `buffer_slice()`: Offset utility using PyclipperOffset. Supports rotated offsetting.
|
- `buffer_slice()`: Offset utility using PyclipperOffset. Supports rotated offsetting.
|
||||||
- `infill_slice()`: Generates rotated grid or rectilinear infill patterns, ensuring layer-to-layer consistency.
|
- `infill_slice()`: Generates rotated grid or rectilinear infill patterns, ensuring layer-to-layer consistency.
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ class TestPrusaSlicerProfiles(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
cls.cache = LocalCache()
|
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"
|
cls.ini_file = cls.test_dir / "2.4.5.ini"
|
||||||
|
|
||||||
if not cls.ini_file.exists():
|
if not cls.ini_file.exists():
|
||||||
|
|
@ -47,7 +47,7 @@ class TestPrusaSlicerProfiles(unittest.TestCase):
|
||||||
|
|
||||||
def test_compatibility(self):
|
def test_compatibility(self):
|
||||||
"""Verify that compatibility evaluation runs without errors."""
|
"""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)
|
vendors = set(self.cache.vendors)
|
||||||
|
|
||||||
# This should not raise any exceptions
|
# This should not raise any exceptions
|
||||||
|
|
|
||||||
93
tests/test_slice.py
Normal file
93
tests/test_slice.py
Normal 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()
|
||||||
|
|
@ -47,7 +47,7 @@ sys.modules['bpy.utils.previews'] = MagicMock()
|
||||||
sys.modules['bpy.app'] = MagicMock()
|
sys.modules['bpy.app'] = MagicMock()
|
||||||
sys.modules['bpy.app.handlers'] = 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):
|
class TestTransform(unittest.TestCase):
|
||||||
def test_offset(self):
|
def test_offset(self):
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue