feat: add Overhang Mask node with support column propagation

This commit is contained in:
Nicolas Predella 2026-07-13 13:31:15 +03:00
parent 4537389458
commit 2eaafd0c97
5 changed files with 269 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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