From 7e9990b1736ff8954409a224b5fb884e0e411b4d Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Sun, 27 Sep 2026 13:15:22 +0300 Subject: [PATCH] infill mask --- core/slice.py | 130 +++++++++++++++++++++----------------------- doc/NODES.md | 2 +- tests/test_slice.py | 90 ++++++++++++++++++++++-------- 3 files changed, 129 insertions(+), 93 deletions(-) diff --git a/core/slice.py b/core/slice.py index 95a79f9..36411f5 100644 --- a/core/slice.py +++ b/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: diff --git a/doc/NODES.md b/doc/NODES.md index 7dc72b2..a7068a7 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -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. diff --git a/tests/test_slice.py b/tests/test_slice.py index 1c5d3a8..9d6891c 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -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)