This commit is contained in:
Nicolas Predella 2026-09-25 13:21:10 +03:00
parent bcf9334d78
commit 8d5fb68aaa
9 changed files with 329 additions and 10 deletions

View file

@ -60,7 +60,7 @@ _MATH_OPS: dict[str, Any] = {
'ADD': lambda a, b, t: a + b,
'SUBTRACT': lambda a, b, t: a - b,
'MULTIPLY': lambda a, b, t: a * b,
'DIVIDE': lambda a, b, t: a // b if b != 0 else 0,
'DIVIDE': lambda a, b, t: a / b if b != 0 else 0,
'ABS': lambda a, b, t: abs(a),
'NEGATE': lambda a, b, t: -a,
'POWER': lambda a, b, t: t(a ** b) if not (a == 0 and b < 0) else 0,

View file

@ -67,7 +67,7 @@ class SLICENODE_CollectionObjects(SliceNode):
return
objs = [obj for obj in col.objects if getattr(obj, "type", "") == 'MESH']
expected_names = [obj.name for obj in objs]
expected_names = sorted([obj.name for obj in objs])
# Remove outputs that are no longer in the collection
to_remove = [o for o in self.outputs if o.name not in expected_names]
@ -79,6 +79,13 @@ class SLICENODE_CollectionObjects(SliceNode):
if name not in self.outputs:
self.add_output("NodeSocketObject", name, identifier=name)
# Re-order to match sorted alphabetical order
if hasattr(self.outputs, "find") and hasattr(self.outputs, "move"):
for i, name in enumerate(expected_names):
current_index = self.outputs.find(name)
if current_index != -1 and current_index != i:
self.outputs.move(current_index, i)
def evaluate_output(self, socket_name: str, **kwargs) -> Any:
if self.collection and socket_name in bpy.data.objects:
return bpy.data.objects[socket_name]

View file

@ -41,6 +41,12 @@ class SLICENODE_GetPaths(SliceNode):
class SLICENODE_SliceShells(SliceNode):
bl_label = "Slice Shells"
ignore_holes: bool = NfBoolProperty(
name="Ignore Holes",
description="Ignore interior hole boundaries and generate only outer shells",
default=False
)
def init(self, context):
self.add_input("SLICESOCK_Mesh")
self.add_input("NodeSocketFloat", "Layer Height").value = 0.2
@ -48,6 +54,11 @@ class SLICENODE_SliceShells(SliceNode):
self.add_input("NodeSocketRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
if hasattr(self, "ignore_holes"):
layout.prop(self, "ignore_holes")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
@ -69,6 +80,15 @@ class SLICENODE_SliceShells(SliceNode):
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
ignore_holes: bool = bool(getattr(self, "ignore_holes", False))
if hasattr(self, "inputs") and "Ignore Holes" in self.inputs:
try:
val = self.resolve("Ignore Holes")
if val is not None:
ignore_holes = bool(val)
except Exception:
pass
if not coll: return SlicePaths()
# Handle SlicingGroup flattening
@ -104,7 +124,11 @@ class SLICENODE_SliceShells(SliceNode):
else:
z_values = np.array([])
slices_group: list[list[np.ndarray]] | None = slice_at_range([merged_object.mesh], z_values)
slices_group: list[list[np.ndarray]] | None = slice_at_range(
[merged_object.mesh],
z_values,
ignore_holes=ignore_holes
)
assert slices_group
slices = SlicePaths(slices_group[0])
slices.layer_height = layer_height

View file

@ -610,7 +610,7 @@ class SlicePaths(list[np.ndarray]):
return self.copy_with(new_paths, extrusion_rate=new_ext, feedrate=new_feed, extra_attributes=new_extra)
def slice_at_range(models, z_values) -> list[list[np.ndarray]]:
def slice_at_range(models, z_values, ignore_holes: bool = False) -> list[list[np.ndarray]]:
if not models:
return []
@ -634,7 +634,7 @@ def slice_at_range(models, z_values) -> list[list[np.ndarray]]:
seg3d[..., 2] = zf
# Build geometry for this slice
slice_paths = segments_to_slice(seg3d)
slice_paths = segments_to_slice(seg3d, ignore_holes=ignore_holes)
all_model_paths.extend(slice_paths)
mmps.append(all_model_paths)
@ -746,7 +746,7 @@ def link_segments(segments: np.ndarray, tolerance=1e-6) -> list[np.ndarray]:
return paths
@ndarray_lru_cache(maxsize=128)
def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
def segments_to_slice(segments: np.ndarray, ignore_holes: bool = False) -> list[np.ndarray]:
linked = link_segments(segments)
if not linked: return []
@ -771,6 +771,19 @@ def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
scaled = (path_pts * SCALE).astype(np.int64).tolist()
pc.AddPath(scaled, pyclipper.PT_SUBJECT, closed = True) # pyright: ignore[reportAttributeAccessIssue]
if ignore_holes:
tree = pc.Execute2(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
def collect_outer_contours(node) -> list[list[int]]:
contours = []
if hasattr(node, 'Contour') and node.Contour and not getattr(node, 'IsHole', False):
contours.append(node.Contour)
if hasattr(node, 'Childs') and node.Childs:
for child in node.Childs:
contours.extend(collect_outer_contours(child))
return contours
solution = collect_outer_contours(tree)
else:
# Use EVENODD for robust hole detection.
solution = pc.Execute(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]

View file

@ -26,12 +26,12 @@ This file provides a mapping of modules to their primary responsibilities and ke
## `/core` (Geometry & Slicing)
- `slice.py`:
- `slice_at_range()`: Top-level slicing function that iterates through Z-heights.
- `slice_at_range()`: Top-level slicing function that iterates through Z-heights (supports `ignore_holes` option).
- `slice_at_z()`: Vectorized plane-mesh intersection using NumPy.
- `align_path_seam_sharpest()`: High-performance vectorized heuristic that positions the start vertex (seam) of closed polygon loops at the sharpest concave angle (inner corner), with fallback to sharpest angle overall.
- `SlicePaths.align_seams()`: Method on `SlicePaths` to align path seams to sharpest concave angles while preserving per-vertex attributes and metadata.
- `Pather`: Class that converts toolpaths into G-code strings. Supports per-segment extrusion rates, feedrates, and custom extra attributes.
- `segments_to_slice()`: Utility to convert raw intersection segments into continuous paths (closed polygons are processed via Pyclipper, open paths remain open).
- `segments_to_slice()`: Utility to convert raw intersection segments into continuous paths (closed polygons are processed via Pyclipper, supports `ignore_holes`, open paths remain open).
- `link_segments()`: Logic for joining loose segments into continuous paths.
- `buffer_slice()`: Offset utility using PyclipperOffset. Supports rotated offsetting.
- `infill_slice()`: Generates rotated grid or rectilinear infill patterns, ensuring layer-to-layer consistency.

View file

@ -48,6 +48,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Float**: Single float input.
- **Integer**: Single integer input.
- **String**: Single string/path input.
- **Collection Objects**: Selects a Blender collection and dynamically exposes its mesh objects as output sockets, sorted alphabetically.
- **Settings**: Read-only/write configuration settings dictionary.
- **Printer**: Printer name picker.
- **Printer List**: List of configured printers.
@ -80,6 +81,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Feedrate Attribute**: Mesh attribute to use for per-segment feedrates.
- **Extra Attributes**: Dictionary mapping mesh attribute names to custom G-code prefixes (e.g., `opening_size` -> `S`).
- **Slice Shells**: Slices a `SlicingObject` into 2D `SlicePaths` at a given layer height. Automatically aligns path seams to the sharpest concave angle (inner corner) of each layer. Supports rotation for slicing at an angle.
- **Ignore Holes**: Boolean option to omit interior hole perimeters and generate only the outer shell boundaries.
- **Buffer Shells**: Offsets/buffers 2D `SlicePaths`. Supports rotated offsetting and multiple miter types.
- **Buffer Shells Multiple**: Offsets 2D `SlicePaths` with multiple buffer passes.
- **Buffer Curve**: Buffers open/closed curves using Shapely, allowing open path offsetting.

View file

View file

@ -0,0 +1,178 @@
import unittest
from unittest.mock import MagicMock
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.append(str(PROJECT_ROOT.parent))
class DummyBase:
def _get_data_dict(self):
if not hasattr(self, "_data") or self._data is None:
self._data = {}
return self._data
def __getitem__(self, key):
return self._get_data_dict()[key]
def __setitem__(self, key, value):
self._get_data_dict()[key] = value
def __contains__(self, key):
return key in self._get_data_dict()
def get(self, key, default=None):
return self._get_data_dict().get(key, default)
class DummyObject:
def __init__(self, name="TestObj", obj_type='MESH'):
self.name = name
self.type = obj_type
mock_types = MagicMock()
mock_types.NodeSocket = type("NodeSocket", (DummyBase,), {})
mock_types.Node = DummyBase
mock_types.NodeCustomGroup = type("NodeCustomGroup", (DummyBase,), {})
mock_types.NodeTree = type("NodeTree", (DummyBase,), {})
mock_types.PropertyGroup = type("PropertyGroup", (DummyBase,), {})
mock_types.Operator = type("Operator", (DummyBase,), {})
mock_types.Panel = type("Panel", (DummyBase,), {})
mock_types.Menu = type("Menu", (DummyBase,), {})
mock_types.Object = DummyObject
mock_types.Collection = type("Collection", (DummyBase,), {})
if 'bpy' not in sys.modules:
mock_bpy = MagicMock()
mock_bpy.types = mock_types
sys.modules['bpy'] = mock_bpy
sys.modules['bpy.types'] = mock_types
sys.modules['bpy.props'] = MagicMock()
sys.modules['bpy.utils'] = MagicMock()
sys.modules['bpy.utils.previews'] = MagicMock()
sys.modules['bpy.app'] = MagicMock()
sys.modules['bpy.app.handlers'] = MagicMock()
import bpy
class TestNodeSocket:
def __init__(self, name="", identifier="", node=None):
self.name = name
self.identifier = identifier or name
self.is_linked = False
self.links = []
self.node = node
def __repr__(self):
return f"<TestNodeSocket name={self.name!r} identifier={self.identifier!r}>"
class TestSocketCollection(list):
def __init__(self, node=None):
super().__init__()
self.node = node
def new(self, type_name, name, identifier=""):
sock = TestNodeSocket(name, identifier or name, node=self.node)
self.append(sock)
return sock
def remove(self, sock):
if sock in self:
super().remove(sock)
def get(self, name, default=None):
for item in self:
if item.name == name or item.identifier == name:
return item
return default
def find(self, name):
for i, item in enumerate(self):
if item.name == name or item.identifier == name:
return i
return -1
def __contains__(self, item):
if isinstance(item, str):
return any(sock.name == item or sock.identifier == item for sock in self)
return super().__contains__(item)
def move(self, from_idx, to_idx):
item = self.pop(from_idx)
self.insert(to_idx, item)
class DummyCollection:
def __init__(self, name="TestCollection", objects=None):
self.name = name
self.objects = objects or []
from NodeSlicer.core.nodeslicer.nodes.inputs import SLICENODE_CollectionObjects
class TestCollectionObjectsSorting(unittest.TestCase):
def setUp(self):
self.node = SLICENODE_CollectionObjects()
self.node.inputs = TestSocketCollection(self.node)
self.node.outputs = TestSocketCollection(self.node)
def test_outputs_sorted_alphabetically(self):
# Create collection with objects in non-alphabetical order
obj_z = DummyObject("Zebra", 'MESH')
obj_a = DummyObject("Apple", 'MESH')
obj_m = DummyObject("Mango", 'MESH')
obj_camera = DummyObject("Camera", 'CAMERA') # Should be ignored
collection = DummyCollection("Fruits", [obj_z, obj_a, obj_camera, obj_m])
self.node.collection = collection
self.node._update_outputs()
output_names = [o.name for o in self.node.outputs]
self.assertEqual(output_names, ["Apple", "Mango", "Zebra"])
def test_dynamic_add_and_reorder(self):
# Initial collection with B and D
obj_b = DummyObject("Banana", 'MESH')
obj_d = DummyObject("Date", 'MESH')
collection = DummyCollection("Fruits", [obj_b, obj_d])
self.node.collection = collection
self.node._update_outputs()
self.assertEqual([o.name for o in self.node.outputs], ["Banana", "Date"])
# Add Apple (which should be placed at the beginning) and Cherry (between Banana and Date)
obj_a = DummyObject("Apple", 'MESH')
obj_c = DummyObject("Cherry", 'MESH')
collection.objects.extend([obj_a, obj_c])
self.node._update_outputs()
self.assertEqual([o.name for o in self.node.outputs], ["Apple", "Banana", "Cherry", "Date"])
def test_remove_object(self):
obj_a = DummyObject("Apple", 'MESH')
obj_b = DummyObject("Banana", 'MESH')
obj_c = DummyObject("Cherry", 'MESH')
collection = DummyCollection("Fruits", [obj_a, obj_b, obj_c])
self.node.collection = collection
self.node._update_outputs()
self.assertEqual([o.name for o in self.node.outputs], ["Apple", "Banana", "Cherry"])
# Remove Banana
collection.objects = [obj_a, obj_c]
self.node._update_outputs()
self.assertEqual([o.name for o in self.node.outputs], ["Apple", "Cherry"])
def test_evaluate_output(self):
obj_a = DummyObject("Apple", 'MESH')
bpy.data.objects = {"Apple": obj_a}
collection = DummyCollection("Fruits", [obj_a])
self.node.collection = collection
self.node._update_outputs()
result = self.node.evaluate_output("Apple")
self.assertEqual(result, obj_a)
result_nonexistent = self.node.evaluate_output("NonExistent")
self.assertIsNone(result_nonexistent)
if __name__ == '__main__':
unittest.main()

View file

@ -813,6 +813,101 @@ class TestSlice(unittest.TestCase):
self.assertIsNotNone(res_full_2)
self.assertEqual(res_full_2.objects[0].name, "FullMesh00")
def test_segments_to_slice_ignore_holes(self):
# Outer square: [0,0] to [20,20] at z=10
outer_segments = [
[[0.0, 0.0, 10.0], [20.0, 0.0, 10.0]],
[[20.0, 0.0, 10.0], [20.0, 20.0, 10.0]],
[[20.0, 20.0, 10.0], [0.0, 20.0, 10.0]],
[[0.0, 20.0, 10.0], [0.0, 0.0, 10.0]],
]
# Inner hole square: [5,5] to [15,15] at z=10
hole_segments = [
[[5.0, 5.0, 10.0], [15.0, 5.0, 10.0]],
[[15.0, 5.0, 10.0], [15.0, 15.0, 10.0]],
[[15.0, 15.0, 10.0], [5.0, 15.0, 10.0]],
[[5.0, 15.0, 10.0], [5.0, 5.0, 10.0]],
]
all_segs = np.array(outer_segments + hole_segments, dtype=np.float64)
# Default (ignore_holes=False) -> produces 2 paths (outer + hole)
res_with_holes = segments_to_slice(all_segs, ignore_holes=False)
self.assertEqual(len(res_with_holes), 2)
# ignore_holes=True -> produces 1 path (outer shell only)
res_no_holes = segments_to_slice(all_segs, ignore_holes=True)
self.assertEqual(len(res_no_holes), 1)
# Verify outer shell bounds [0, 20]
self.assertAlmostEqual(res_no_holes[0][:, 0].min(), 0.0)
self.assertAlmostEqual(res_no_holes[0][:, 0].max(), 20.0)
def test_slice_shells_node_ignore_holes(self):
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_SliceShells
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection
# Construct a hollow box mesh
# Outer box: [0, 20] x [0, 20] x [0, 1]
# Inner hole box: [5, 15] x [5, 15] x [0, 1]
def make_quad(v0, v1, v2, v3):
return [
[v0, v1, v2],
[v0, v2, v3]
]
# 4 outer vertical walls + 4 inner vertical walls
z0, z1 = 0.0, 1.0
faces = []
# Outer walls
faces += make_quad([0, 0, z0], [20, 0, z0], [20, 0, z1], [0, 0, z1])
faces += make_quad([20, 0, z0], [20, 20, z0], [20, 20, z1], [20, 0, z1])
faces += make_quad([20, 20, z0], [0, 20, z0], [0, 20, z1], [20, 20, z1])
faces += make_quad([0, 20, z0], [0, 0, z0], [0, 0, z1], [0, 20, z1])
# Inner hole walls
faces += make_quad([5, 5, z0], [15, 5, z0], [15, 5, z1], [5, 5, z1])
faces += make_quad([15, 5, z0], [15, 15, z0], [15, 15, z1], [15, 5, z1])
faces += make_quad([15, 15, z0], [5, 15, z0], [5, 15, z1], [15, 15, z1])
faces += make_quad([5, 15, z0], [5, 5, z0], [5, 5, z1], [5, 15, z1])
triangles = np.zeros((len(faces), 4, 3))
for i, tri in enumerate(faces):
triangles[i, 0] = tri[0]
triangles[i, 1] = tri[1]
triangles[i, 2] = tri[2]
sobj = SlicingObject()
sobj.mesh = triangles
scoll = SlicingCollection([], "test_hollow")
scoll.objects = [sobj]
# 1. Normal evaluation (ignore_holes=False)
node = SLICENODE_SliceShells()
node.ignore_holes = False
node.resolve = lambda name: scoll if name == "Mesh" else (0.5 if name == "Layer Height" else None)
slices_normal = node.evaluate_output("Paths")
self.assertEqual(len(slices_normal), 2) # Outer + hole
# 2. Evaluation with ignore_holes=True
node.ignore_holes = True
slices_ignored = node.evaluate_output("Paths")
self.assertEqual(len(slices_ignored), 1) # Only outer
# 3. Evaluation when node lacks ignore_holes property (simulating missing property / legacy tree)
node_missing = SLICENODE_SliceShells.__new__(SLICENODE_SliceShells)
# Only set resolve and standard dictionary without ignore_holes
node_missing.resolve = lambda name: scoll if name == "Mesh" else (0.5 if name == "Layer Height" else None)
slices_missing = node_missing.evaluate_output("Paths")
self.assertIsNotNone(slices_missing)
self.assertEqual(len(slices_missing), 2)
# 4. draw_buttons with and without ignore_holes does not fail
mock_layout = MagicMock()
node.draw_buttons(None, mock_layout)
mock_layout.prop.assert_called_with(node, "ignore_holes")
mock_layout_missing = MagicMock()
node_missing.draw_buttons(None, mock_layout_missing)
if __name__ == '__main__':
unittest.main()