Compare commits
2 commits
28a4618b40
...
da83351d98
| Author | SHA1 | Date | |
|---|---|---|---|
| da83351d98 | |||
| 7e9990b173 |
3 changed files with 129 additions and 93 deletions
130
core/slice.py
130
core/slice.py
|
|
@ -385,7 +385,7 @@ class SlicePaths(list[np.ndarray]):
|
|||
return merged
|
||||
|
||||
def get_infill_mask(self) -> SlicePaths:
|
||||
"""Computes the intersection of all paths in each group using shapely."""
|
||||
"""Computes the 2D solid infill mask for each layer, resolving exterior boundaries and interior holes."""
|
||||
res_paths, res_groups = get_infill_mask_slice(self)
|
||||
return self.copy_with(res_paths, res_groups)
|
||||
|
||||
|
|
@ -1241,60 +1241,6 @@ def offset_curve_slice(
|
|||
|
||||
return paths_list, groups_list
|
||||
|
||||
@ndarray_lru_cache(maxsize=64)
|
||||
def get_infill_mask_slice(slice_paths: list[np.ndarray]) -> tuple[list[np.ndarray], list[int]]:
|
||||
if not slice_paths:
|
||||
return [], []
|
||||
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
# Group paths by their Z height
|
||||
groups: dict[int, list[np.ndarray]] = {}
|
||||
for p in slice_paths:
|
||||
z = p[0, 2]
|
||||
z_key = int(round(z * 1000))
|
||||
groups.setdefault(z_key, []).append(p)
|
||||
|
||||
paths_list: list[np.ndarray] = []
|
||||
groups_list: list[int] = []
|
||||
|
||||
group_id = 0
|
||||
for z_key in sorted(groups.keys()):
|
||||
layer_paths = groups[z_key]
|
||||
if not layer_paths:
|
||||
continue
|
||||
|
||||
z = layer_paths[0][0, 2]
|
||||
|
||||
# Convert each closed path to a Shapely Polygon
|
||||
polygons = []
|
||||
for p in layer_paths:
|
||||
if len(p) < 3:
|
||||
continue
|
||||
pts_2d = p[:, :2]
|
||||
poly = Polygon(pts_2d)
|
||||
if poly.is_valid and not poly.is_empty:
|
||||
polygons.append(poly)
|
||||
|
||||
if not polygons:
|
||||
continue
|
||||
|
||||
# Compute the intersection of all polygons in the layer
|
||||
intersection_poly = polygons[0]
|
||||
for poly in polygons[1:]:
|
||||
intersection_poly = intersection_poly.intersection(poly)
|
||||
|
||||
# Convert resulting polygon back to 3D paths
|
||||
result_paths = geometry_to_paths(intersection_poly, z)
|
||||
|
||||
for path in result_paths:
|
||||
paths_list.append(path)
|
||||
groups_list.append(group_id)
|
||||
|
||||
group_id += 1
|
||||
|
||||
return paths_list, groups_list
|
||||
|
||||
def paths_to_shapely_polygon(paths: list[np.ndarray]) -> BaseGeometry:
|
||||
"""Converts a list of paths on a layer to a single Shapely geometry using pyclipper to resolve holes."""
|
||||
if not paths:
|
||||
|
|
@ -1318,20 +1264,25 @@ def paths_to_shapely_polygon(paths: list[np.ndarray]) -> BaseGeometry:
|
|||
def polytree_to_shapely(node) -> list[Polygon]:
|
||||
polygons = []
|
||||
if hasattr(node, 'Contour') and node.Contour and not node.IsHole:
|
||||
exterior = np.array(node.Contour) / SCALE
|
||||
interiors = []
|
||||
child_islands = []
|
||||
for child in node.Childs:
|
||||
if child.IsHole:
|
||||
if child.Contour:
|
||||
interiors.append(np.array(child.Contour) / SCALE)
|
||||
for grandchild in child.Childs:
|
||||
child_islands.append(grandchild)
|
||||
else:
|
||||
child_islands.append(child)
|
||||
polygons.append(Polygon(exterior, interiors))
|
||||
for island in child_islands:
|
||||
polygons.extend(polytree_to_shapely(island))
|
||||
if len(node.Contour) >= 3:
|
||||
exterior = np.array(node.Contour) / SCALE
|
||||
interiors = []
|
||||
child_islands = []
|
||||
for child in node.Childs:
|
||||
if child.IsHole:
|
||||
if child.Contour and len(child.Contour) >= 3:
|
||||
interiors.append(np.array(child.Contour) / SCALE)
|
||||
for grandchild in child.Childs:
|
||||
child_islands.append(grandchild)
|
||||
else:
|
||||
child_islands.append(child)
|
||||
poly = Polygon(exterior, interiors)
|
||||
if not poly.is_valid:
|
||||
poly = poly.buffer(0)
|
||||
if not poly.is_empty:
|
||||
polygons.append(poly)
|
||||
for island in child_islands:
|
||||
polygons.extend(polytree_to_shapely(island))
|
||||
else:
|
||||
for child in node.Childs:
|
||||
polygons.extend(polytree_to_shapely(child))
|
||||
|
|
@ -1340,6 +1291,47 @@ def paths_to_shapely_polygon(paths: list[np.ndarray]) -> BaseGeometry:
|
|||
polys = polytree_to_shapely(tree)
|
||||
return unary_union(polys)
|
||||
|
||||
@ndarray_lru_cache(maxsize=64)
|
||||
def get_infill_mask_slice(slice_paths: list[np.ndarray]) -> tuple[list[np.ndarray], list[int]]:
|
||||
if not slice_paths:
|
||||
return [], []
|
||||
|
||||
# Group paths by their Z height
|
||||
groups: dict[int, list[np.ndarray]] = {}
|
||||
for p in slice_paths:
|
||||
if len(p) == 0:
|
||||
continue
|
||||
z = p[0, 2]
|
||||
z_key = int(round(z * 1000))
|
||||
groups.setdefault(z_key, []).append(p)
|
||||
|
||||
paths_list: list[np.ndarray] = []
|
||||
groups_list: list[int] = []
|
||||
|
||||
group_id = 0
|
||||
for z_key in sorted(groups.keys()):
|
||||
layer_paths = groups[z_key]
|
||||
if not layer_paths:
|
||||
continue
|
||||
|
||||
z = layer_paths[0][0, 2]
|
||||
|
||||
# Convert layer paths to a single Shapely geometry (handling holes and multiple islands)
|
||||
geom = paths_to_shapely_polygon(layer_paths)
|
||||
if geom.is_empty:
|
||||
continue
|
||||
|
||||
# Convert resulting polygon back to 3D paths
|
||||
result_paths = geometry_to_paths(geom, z)
|
||||
|
||||
for path in result_paths:
|
||||
paths_list.append(path)
|
||||
groups_list.append(group_id)
|
||||
|
||||
group_id += 1
|
||||
|
||||
return paths_list, groups_list
|
||||
|
||||
@ndarray_lru_cache(maxsize=32)
|
||||
def get_overhang_mask_slice(slice_paths: list[np.ndarray], groups: tuple[int, ...], tolerance: float = 0.05) -> tuple[list[np.ndarray], list[int]]:
|
||||
if not slice_paths or not groups:
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
|
|||
- **Density**: Distance between lines in units.
|
||||
- **Infill Rotation**: Base rotation of the infill pattern.
|
||||
- **Layer Rotation**: Rotation of the entire slice to align infill with specific planes.
|
||||
- **Get Infill Mask**: Computes the intersection of all closed paths in each group (layer) using Shapely.
|
||||
- **Get Infill Mask**: Computes the solid 2D infill mask for each layer, resolving exterior boundaries and interior holes into closed boundary loops.
|
||||
- **Overhang Mask**: Computes the accumulated overhang mask for each layer, propagating support requirements from top to bottom.
|
||||
- **Boolean Paths**: Performs 2D boolean operations (`UNION`, `INTERSECTION`, `DIFFERENCE`, `XOR`) on multiple slice sets.
|
||||
- **Math**: Float math operations.
|
||||
|
|
|
|||
|
|
@ -209,40 +209,84 @@ class TestSlice(unittest.TestCase):
|
|||
self.assertTrue(np.allclose(offset_path[:, 2], 5.0))
|
||||
|
||||
def test_get_infill_mask(self):
|
||||
# Two overlapping closed squares on the same layer Z=3.0
|
||||
# Square 1: (0,0) to (10,10)
|
||||
sq1 = np.array([
|
||||
from NodeSlicer.core.slice import SlicePaths
|
||||
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_GetInfillMask
|
||||
|
||||
# Outer square: (0,0) to (10,10) at Z=3.0 (area 100)
|
||||
outer = np.array([
|
||||
[0.0, 0.0, 3.0],
|
||||
[0.0, 10.0, 3.0],
|
||||
[10.0, 10.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]
|
||||
])
|
||||
# Square 2: (5,5) to (15,15) - overlaps from (5,5) to (10,10)
|
||||
sq2 = np.array([
|
||||
[5.0, 5.0, 3.0],
|
||||
[5.0, 15.0, 3.0],
|
||||
[15.0, 15.0, 3.0],
|
||||
[15.0, 5.0, 3.0],
|
||||
[5.0, 5.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]
|
||||
])
|
||||
from NodeSlicer.core.slice import SlicePaths
|
||||
# Both in group 0
|
||||
slice_paths = SlicePaths([sq1, sq2], groups=[0, 0])
|
||||
|
||||
slice_paths = SlicePaths([outer, hole], groups=[0, 0])
|
||||
mask = slice_paths.get_infill_mask()
|
||||
|
||||
self.assertIsInstance(mask, SlicePaths)
|
||||
self.assertEqual(len(mask), 1) # Intersection of two squares should be one square (5,5) to (10,10)
|
||||
# Should return both outer boundary and inner hole boundary
|
||||
self.assertEqual(len(mask), 2)
|
||||
|
||||
mask_path = mask[0]
|
||||
self.assertTrue(np.allclose(mask_path[0], mask_path[-1]))
|
||||
# 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))
|
||||
|
||||
# Area of intersection should be 5 * 5 = 25
|
||||
xs, ys = mask_path[:, 0], mask_path[:, 1]
|
||||
area = 0.5 * np.abs(np.dot(xs, np.roll(ys, 1)) - np.dot(ys, np.roll(xs, 1)))
|
||||
self.assertAlmostEqual(area, 25.0, places=3)
|
||||
self.assertTrue(np.allclose(mask_path[:, 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
|
||||
|
||||
# Test with node evaluation
|
||||
node = SLICENODE_GetInfillMask()
|
||||
node.resolve = lambda key: slice_paths if key == "Paths" else None
|
||||
node_mask = node.evaluate_output("Paths")
|
||||
self.assertEqual(len(node_mask), 2)
|
||||
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue