From 8d5fb68aaa0785eecd53ce86691965df506e8db2 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Fri, 25 Sep 2026 13:21:10 +0300 Subject: [PATCH] holes --- core/nodeslicer/nodes/base.py | 2 +- core/nodeslicer/nodes/inputs.py | 9 +- core/nodeslicer/nodes/path.py | 26 ++++- core/slice.py | 23 +++- doc/MODULES.md | 4 +- doc/NODES.md | 2 + test.bgcode | 0 tests/test_collection_objects.py | 178 +++++++++++++++++++++++++++++++ tests/test_slice.py | 95 +++++++++++++++++ 9 files changed, 329 insertions(+), 10 deletions(-) delete mode 100644 test.bgcode create mode 100644 tests/test_collection_objects.py diff --git a/core/nodeslicer/nodes/base.py b/core/nodeslicer/nodes/base.py index 6742f15..e5a287c 100644 --- a/core/nodeslicer/nodes/base.py +++ b/core/nodeslicer/nodes/base.py @@ -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, diff --git a/core/nodeslicer/nodes/inputs.py b/core/nodeslicer/nodes/inputs.py index e50ece7..fc3aa18 100644 --- a/core/nodeslicer/nodes/inputs.py +++ b/core/nodeslicer/nodes/inputs.py @@ -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] diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 4e86a33..2bc8b80 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -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 diff --git a/core/slice.py b/core/slice.py index 41fb74e..95a79f9 100644 --- a/core/slice.py +++ b/core/slice.py @@ -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,8 +771,21 @@ 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] - # Use EVENODD for robust hole detection. - solution = pc.Execute(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # 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] for path in solution: if not path: continue diff --git a/doc/MODULES.md b/doc/MODULES.md index 5f33d96..08acf13 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -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. diff --git a/doc/NODES.md b/doc/NODES.md index 2f94f53..5c2e39b 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -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. diff --git a/test.bgcode b/test.bgcode deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_collection_objects.py b/tests/test_collection_objects.py new file mode 100644 index 0000000..cd7e6dd --- /dev/null +++ b/tests/test_collection_objects.py @@ -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"" + +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() diff --git a/tests/test_slice.py b/tests/test_slice.py index 0762fc9..1c5d3a8 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -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()