feat: add Overhang Mask node with support column propagation
This commit is contained in:
parent
4537389458
commit
2eaafd0c97
5 changed files with 269 additions and 0 deletions
|
|
@ -383,6 +383,28 @@ class SLICENODE_GetInfillMask(SliceNode):
|
||||||
|
|
||||||
return paths.get_infill_mask()
|
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
|
@register_class
|
||||||
class SLICENODE_JoinSlices(SliceNodeJoiner):
|
class SLICENODE_JoinSlices(SliceNodeJoiner):
|
||||||
bl_label = "Boolean Paths"
|
bl_label = "Boolean Paths"
|
||||||
|
|
|
||||||
|
|
@ -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="Simplify Paths").type = "SLICENODE_SimplifyPaths"
|
||||||
layout.operator("node.add_node", text="Infill").type = "SLICENODE_Infill"
|
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="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.operator("node.add_node", text="Boolean Paths").type = "SLICENODE_JoinSlices"
|
||||||
layout.separator()
|
layout.separator()
|
||||||
layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath"
|
layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath"
|
||||||
|
|
|
||||||
110
core/slice.py
110
core/slice.py
|
|
@ -126,6 +126,11 @@ class SlicePaths(list[np.ndarray]):
|
||||||
res_paths, res_groups = get_infill_mask_slice(self)
|
res_paths, res_groups = get_infill_mask_slice(self)
|
||||||
return self.copy_with(res_paths, res_groups)
|
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:
|
def infill(self, density: float, rotation_deg: float = 0.0, infill_type: str = 'GRID', layer_rotation: Any = None) -> SlicePaths:
|
||||||
"""Generates infill for the paths."""
|
"""Generates infill for the paths."""
|
||||||
p = self
|
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
|
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)
|
@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]]:
|
def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', tolerance=.01) -> tuple[list[np.ndarray], list[int]]:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **Infill Rotation**: Base rotation of the infill pattern.
|
||||||
- **Layer Rotation**: Rotation of the entire slice to align infill with specific planes.
|
- **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 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.
|
- **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.
|
- **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).
|
- **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).
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,142 @@ class TestSlice(unittest.TestCase):
|
||||||
self.assertAlmostEqual(area, 25.0, places=3)
|
self.assertAlmostEqual(area, 25.0, places=3)
|
||||||
self.assertTrue(np.allclose(mask_path[:, 2], 3.0))
|
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):
|
def test_pather_retraction_lift(self):
|
||||||
from NodeSlicer.core.slice import Pather
|
from NodeSlicer.core.slice import Pather
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue