diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 16771bc..f0886c7 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -383,6 +383,28 @@ class SLICENODE_GetInfillMask(SliceNode): return paths.get_infill_mask() +@register_class +class SLICENODE_OverhangMask(SliceNode): + bl_label = "Overhang Mask" + + def init(self, context): + self.add_input("SLICESOCK_Paths") + self.add_input("SLICESOCK_Float", "Tolerance").value = 0.05 + 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") + tolerance: float = self.resolve("Tolerance") or 0.05 + + if not paths: return SlicePaths() + + return paths.get_overhang_mask(tolerance=tolerance) + @register_class class SLICENODE_JoinSlices(SliceNodeJoiner): bl_label = "Boolean Paths" diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 034c056..dabd455 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -513,6 +513,7 @@ class SLICENODE_MT_Geometry(Menu): 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="Overhang Mask").type = "SLICENODE_OverhangMask" 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 3ac80bc..0e8cf5c 100644 --- a/core/slice.py +++ b/core/slice.py @@ -126,6 +126,11 @@ class SlicePaths(list[np.ndarray]): res_paths, res_groups = get_infill_mask_slice(self) return self.copy_with(res_paths, res_groups) + def get_overhang_mask(self, tolerance: float = 0.05) -> SlicePaths: + """Computes the overhang mask for each layer, propagating from top to bottom.""" + res_paths, res_groups = get_overhang_mask_slice(self, tuple(self.groups), tolerance=tolerance) + 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 @@ -895,6 +900,111 @@ def get_infill_mask_slice(slice_paths: list[np.ndarray]) -> tuple[list[np.ndarra 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: + return Polygon() + + import pyclipper + from shapely.geometry import Polygon + from shapely.ops import unary_union + + pc = pyclipper.Pyclipper() + for p in paths: + if len(p) < 3: + continue + pts_2d = p[:, :2] + if np.allclose(pts_2d[0], pts_2d[-1]): + pts_2d = pts_2d[:-1] + scaled = (pts_2d * SCALE).astype(np.int64).tolist() + pc.AddPath(scaled, pyclipper.PT_SUBJECT, True) + + tree = pc.Execute2(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) + + 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)) + else: + for child in node.Childs: + polygons.extend(polytree_to_shapely(child)) + return polygons + + polys = polytree_to_shapely(tree) + return unary_union(polys) + +@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: + return [], [] + + from shapely.geometry import Polygon + + # Group paths by their group index + group_map: dict[int, list[np.ndarray]] = {} + for p, g in zip(slice_paths, groups): + group_map.setdefault(g, []).append(p) + + sorted_groups = sorted(group_map.keys()) + if len(sorted_groups) < 2: + # If there's only 1 layer, there are no overhangs under any layer + return [], [] + + # Pre-build solid geometry for each group + layer_geoms = {g: paths_to_shapely_polygon(group_map[g]) for g in sorted_groups} + + paths_list: list[np.ndarray] = [] + groups_list: list[int] = [] + + accumulated = Polygon() + + # Iterate from top group down to bottom group + for i in range(len(sorted_groups) - 1, -1, -1): + g = sorted_groups[i] + z = group_map[g][0][0, 2] if group_map[g] and len(group_map[g][0]) > 0 else 0.0 + geom_i = layer_geoms[g] + + if i == len(sorted_groups) - 1: + # Top layer has no overhang + continue + + g_above = sorted_groups[i+1] + geom_above = layer_geoms[g_above] + + # Propagate support region downwards: S_i = (S_{i+1} u L_{i+1}) \ L_i + # This propagates the raw support footprint without tolerance shrinkage, avoiding fragmentation. + accumulated = accumulated.union(geom_above).difference(geom_i) + + if not accumulated.is_empty: + # Apply tolerance buffering to ensure XY clearance only for the output of the current layer. + geom_i_sub = geom_i.buffer(tolerance) if tolerance > 0 else geom_i + output_geom = accumulated.difference(geom_i_sub) + if not output_geom.is_empty: + result_paths = geometry_to_paths(output_geom, z) + for path in result_paths: + paths_list.append(path) + groups_list.append(g) + + # Sort output by group index so that paths are ordered bottom-to-top + sorted_indices = np.argsort(groups_list) + paths_list = [paths_list[idx] for idx in sorted_indices] + groups_list = [int(groups_list[idx]) for idx in sorted_indices] + + 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 f4b8b71..ddad46d 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -87,6 +87,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **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. +- **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. - **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 75472c3..4a03558 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -244,7 +244,142 @@ class TestSlice(unittest.TestCase): self.assertAlmostEqual(area, 25.0, places=3) self.assertTrue(np.allclose(mask_path[:, 2], 3.0)) + def test_get_overhang_mask(self): + # Layer 2 (Z=2.0): square of size 4 (from -2 to 2) + sq2 = np.array([ + [-2.0, -2.0, 2.0], + [-2.0, 2.0, 2.0], + [ 2.0, 2.0, 2.0], + [ 2.0, -2.0, 2.0], + [-2.0, -2.0, 2.0] + ]) + # Layer 1 (Z=1.0): square of size 2 (from -1 to 1) + sq1 = np.array([ + [-1.0, -1.0, 1.0], + [-1.0, 1.0, 1.0], + [ 1.0, 1.0, 1.0], + [ 1.0, -1.0, 1.0], + [-1.0, -1.0, 1.0] + ]) + # Layer 0 (Z=0.0): square of size 4 (from -2 to 2) + sq0 = np.array([ + [-2.0, -2.0, 0.0], + [-2.0, 2.0, 0.0], + [ 2.0, 2.0, 0.0], + [ 2.0, -2.0, 0.0], + [-2.0, -2.0, 0.0] + ]) + from NodeSlicer.core.slice import SlicePaths + # Groups sorted by Z height internally: group 0 (Z=0.0), group 1 (Z=1.0), group 2 (Z=2.0) + slice_paths = SlicePaths([sq0, sq1, sq2], groups=[0, 1, 2]) + + overhang = slice_paths.get_overhang_mask(tolerance=0.0) + + self.assertIsInstance(overhang, SlicePaths) + # Top layer Z=2.0 (group 2) and bottom layer Z=0.0 (group 0) should have no overhang paths. + # Only layer Z=1.0 (group 1) should have overhang paths. + # The paths on group 1 represent the ring: outer boundary (4x4) and inner boundary (2x2). + self.assertEqual(len(overhang), 2) + self.assertTrue(all(g == 1 for g in overhang.groups)) + + # Total area of the two paths on layer 1 should be: outer square (16) - inner square (4) = 12.0 + from shapely.geometry import Polygon + poly_out = Polygon(overhang[0][:, :2]) + poly_in = Polygon(overhang[1][:, :2]) + area1 = poly_out.area + area2 = poly_in.area + self.assertEqual(sorted([area1, area2]), [4.0, 16.0]) + def test_get_overhang_mask_vertical_walls(self): + # Two identical squares on adjacent layers Z=0.0 and Z=1.0, representing perfectly vertical walls + # To simulate slicing noise/discrepancies, we shift one by a tiny amount (0.001) + sq0 = np.array([ + [0.0, 0.0, 0.0], + [0.0, 10.0, 0.0], + [10.0, 10.0, 0.0], + [10.0, 0.0, 0.0], + [0.0, 0.0, 0.0] + ]) + sq1 = np.array([ + [0.001, 0.001, 1.0], + [0.001, 10.001, 1.0], + [10.001, 10.001, 1.0], + [10.001, 0.001, 1.0], + [0.001, 0.001, 1.0] + ]) + from NodeSlicer.core.slice import SlicePaths + slice_paths = SlicePaths([sq0, sq1], groups=[0, 1]) + + # With default tolerance=0.05, it should completely filter out the vertical wall noise, yielding 0 overhang paths. + overhang_with_tol = slice_paths.get_overhang_mask(tolerance=0.05) + self.assertEqual(len(overhang_with_tol), 0) + + # With tolerance=0.0, it should preserve the tiny difference sliver, yielding non-empty paths. + overhang_no_tol = slice_paths.get_overhang_mask(tolerance=0.0) + self.assertGreater(len(overhang_no_tol), 0) + + def test_get_overhang_mask_propagation_no_gaps(self): + # 3-layer tapering model (cone-like structure pointing down) + # Layer 2 (Z=2.0): 4x4 square (from -2 to 2) + sq2 = np.array([ + [-2.0, -2.0, 2.0], + [-2.0, 2.0, 2.0], + [ 2.0, 2.0, 2.0], + [ 2.0, -2.0, 2.0], + [-2.0, -2.0, 2.0] + ]) + # Layer 1 (Z=1.0): 3x3 square (from -1.5 to 1.5) + sq1 = np.array([ + [-1.5, -1.5, 1.0], + [-1.5, 1.5, 1.0], + [ 1.5, 1.5, 1.0], + [ 1.5, -1.5, 1.0], + [-1.5, -1.5, 1.0] + ]) + # Layer 0 (Z=0.0): 2x2 square (from -1 to 1) + sq0 = np.array([ + [-1.0, -1.0, 0.0], + [-1.0, 1.0, 0.0], + [ 1.0, 1.0, 0.0], + [ 1.0, -1.0, 0.0], + [-1.0, -1.0, 0.0] + ]) + from NodeSlicer.core.slice import SlicePaths + slice_paths = SlicePaths([sq0, sq1, sq2], groups=[0, 1, 2]) + + # tolerance=0.5: + # At layer 0 (Z=0.0), support should cover: + # (geom_above (3x3) union S_1 (4x4 \ 3x3)) \ geom_0 = 4x4 \ 2x2 + # Buffered geom_0_sub is 2.5x2.5 (radius 0.5 outward from 2x2). + # So output_geom = 4x4 \ 2.5x2.5. + # This is a single ring with area: 16 - 6.25 = 9.75. + # It must NOT contain gaps or be fragmented into multiple nested rings. + overhang = slice_paths.get_overhang_mask(tolerance=0.5) + + # Support should be generated on layer 1 and layer 0. + # Let's group outputs by Z. + layer_paths = {} + for path in overhang: + z = path[0, 2] + layer_paths.setdefault(z, []).append(path) + + self.assertIn(0.0, layer_paths) + self.assertIn(1.0, layer_paths) + + # At Z=0.0 (layer 0): + # We expect exactly 2 paths (exterior of 4x4 and interior hole of 2.5x2.5). + # If there is fragmentation, there will be more paths. + paths_z0 = layer_paths[0.0] + self.assertEqual(len(paths_z0), 2) + + from shapely.geometry import Polygon + # The exterior polygon and the interior hole + p0 = Polygon(paths_z0[0][:, :2]) + p1 = Polygon(paths_z0[1][:, :2]) + areas = sorted([p0.area, p1.area]) + # Outer area is 16, inner hole area is approx 8.784 due to rounded corners of the buffer + self.assertAlmostEqual(areas[0], 8.784, places=2) + self.assertAlmostEqual(areas[1], 16.0, places=2) def test_pather_retraction_lift(self): from NodeSlicer.core.slice import Pather