diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 1174048..16771bc 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -363,6 +363,26 @@ class SLICENODE_Infill(SliceNode): layer_rotation=rotation ) +@register_class +class SLICENODE_GetInfillMask(SliceNode): + bl_label = "Get Infill Mask" + + def init(self, context): + self.add_input("SLICESOCK_Paths") + self.add_output("SLICESOCK_Paths") + + def draw_buttons(self, context, layout): + pass + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + if socket_name != "Paths": return SlicePaths() + + paths: SlicePaths = self.resolve("Paths") + + if not paths: return SlicePaths() + + return paths.get_infill_mask() + @register_class class SLICENODE_JoinSlices(SliceNodeJoiner): bl_label = "Boolean Paths" diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index d1fba50..034c056 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -512,6 +512,7 @@ class SLICENODE_MT_Geometry(Menu): layout.operator("node.add_node", text="Rotate Path").type = "SLICENODE_RotatePath" layout.operator("node.add_node", text="Simplify Paths").type = "SLICENODE_SimplifyPaths" layout.operator("node.add_node", text="Infill").type = "SLICENODE_Infill" + layout.operator("node.add_node", text="Get Infill Mask").type = "SLICENODE_GetInfillMask" layout.operator("node.add_node", text="Boolean Paths").type = "SLICENODE_JoinSlices" layout.separator() layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath" diff --git a/core/slice.py b/core/slice.py index a027132..b22f79e 100644 --- a/core/slice.py +++ b/core/slice.py @@ -121,6 +121,11 @@ class SlicePaths(list[np.ndarray]): return new_paths + def get_infill_mask(self) -> SlicePaths: + """Computes the intersection of all paths in each group using shapely.""" + res_paths, res_groups = get_infill_mask_slice(self) + return self.copy_with(res_paths, res_groups) + def infill(self, density: float, rotation_deg: float = 0.0, infill_type: str = 'GRID', layer_rotation: Any = None) -> SlicePaths: """Generates infill for the paths.""" p = self @@ -836,6 +841,60 @@ 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 + @ndarray_lru_cache(maxsize=64) def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', tolerance=.01) -> tuple[list[np.ndarray], list[int]]: diff --git a/doc/NODES.md b/doc/NODES.md index 7e32d73..79c5121 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -86,6 +86,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. - **Boolean Paths**: Performs 2D boolean operations (`UNION`, `INTERSECTION`, `DIFFERENCE`, `XOR`) on multiple slice sets. - **Arrange Slicing Group**: Arranges the slicing collections in a slicing group on the build plate using the `libnest2d` bin-packing library. It nests their 2D bounding boxes to prevent collisions. - **Method**: Switch between **NFP** (No-Fit Polygon placer + First-Fit selection), **Bottom-Left** (Bottom-Left placer + First-Fit selection), and **DJD Heuristic** (No-Fit Polygon placer + Dyckhoff's heuristic). diff --git a/tests/test_slice.py b/tests/test_slice.py index 6866842..8ce68bf 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -208,5 +208,41 @@ class TestSlice(unittest.TestCase): # Check Z coordinate is preserved 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([ + [0.0, 0.0, 3.0], + [0.0, 10.0, 3.0], + [10.0, 10.0, 3.0], + [10.0, 0.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] + ]) + from NodeSlicer.core.slice import SlicePaths + # Both in group 0 + slice_paths = SlicePaths([sq1, sq2], 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) + + mask_path = mask[0] + self.assertTrue(np.allclose(mask_path[0], mask_path[-1])) + + # 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)) + if __name__ == '__main__': unittest.main()