infill mask

This commit is contained in:
Nicolas Predella 2026-09-27 13:15:22 +03:00
parent 767fd08060
commit 7e9990b173
3 changed files with 129 additions and 93 deletions

View file

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

View file

@ -104,7 +104,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.

View file

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