diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index 554d1a0..dc8f712 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -465,7 +465,7 @@ class SLICENODE_TransformGeometry(SliceNode): if any(r != 0.0 for r in r_arr): col.rotate(r_arr) if any(t != 0.0 for t in t_arr): - col.translate(t_arr) + col.offset(t_arr) # Also offset wipe tower location if any(t != 0.0 for t in t_arr): copied_data.wipe_tower_xy = copied_data.wipe_tower_xy + t_arr[0:2] @@ -485,8 +485,32 @@ class SLICENODE_TransformGeometry(SliceNode): if any(r != 0.0 for r in r_arr): copied_data.rotate(r_arr) if any(t != 0.0 for t in t_arr): - copied_data.translate(t_arr) + copied_data.offset(t_arr) return copied_data +@register_class +class SLICENODE_DropGeometry(SliceNode): + bl_label = "Drop Geometry" + + def init(self, context): + self.add_input("SLICESOCK_Mesh", "Mesh") + self.add_input("SLICESOCK_Float", "Z") + self.add_output("SLICESOCK_Mesh", "Mesh") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | SlicingGroup | None: + + z=self.resolve('Z') + if socket_name != "Mesh": + return None + + mesh_data: SlicingCollection | SlicingGroup | None = self.resolve("Mesh") + if not mesh_data: + return None + + assert mesh_data.min_z is not None + min_z = round(mesh_data.min_z , 6) + mesh_data.offset(np.array([0.0, 0.0, z-min_z], dtype=np.float64)) + + return mesh_data diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index eb5c9e5..719247d 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -71,7 +71,11 @@ class SLICENODE_SliceShells(SliceNode): min_z = floor(min_z / layer_height) * layer_height max_z = merged_object.mesh[:,0:3,2:3].max() - z_values = np.arange(min_z, max_z + layer_height, layer_height) + z_values = np.arange( + min_z + layer_height/2, + max_z, + layer_height + ) slices_group: list[list[np.ndarray]] | None = slice_at_range([merged_object.mesh], z_values) assert slices_group @@ -331,10 +335,12 @@ class SLICENODE_JoinPaths(SliceNodeJoiner): def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: from ....infra.blender.blender_slice_nodes_generic import resolve_input + inputs_data: list[SlicePaths] = [] for input in self.inputs: - paths = resolve_input(input) + paths: SlicePaths = resolve_input(input) if paths: + # paths.normalize_groups() inputs_data.append(paths) if not inputs_data: @@ -344,6 +350,7 @@ class SLICENODE_JoinPaths(SliceNodeJoiner): merged = SlicePaths() for paths in inputs_data: merged.extend_with_groups(paths) + return merged elif self.mode == 'ZIP': @@ -358,7 +365,7 @@ class SLICENODE_JoinPaths(SliceNodeJoiner): d.setdefault(g, []).append(p_list[i]) grouped_inputs.append(d) - sorted_groups = sorted(list(all_groups)) + sorted_groups = sorted(all_groups) merged = SlicePaths() for g in sorted_groups: @@ -366,6 +373,7 @@ class SLICENODE_JoinPaths(SliceNodeJoiner): group_paths = d.get(g, []) for p in group_paths: merged.append_with_group(p, g) + return merged return SlicePaths() diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 7389a32..9aaed42 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -497,6 +497,7 @@ class SLICENODE_MT_Geometry(Menu): assert layout layout.operator("node.add_node", text="Get Mesh").type = "SLICENODE_GetMesh" layout.operator("node.add_node", text="Transform Geometry").type = "SLICENODE_TransformGeometry" + layout.operator("node.add_node", text="Drop Geometry").type = "SLICENODE_DropGeometry" layout.operator("node.add_node", text="Separate Mesh").type = "SLICENODE_SeparateMesh" layout.operator("node.add_node", text="Separate Mesh by Range").type = "SLICENODE_SeparateMeshByRange" layout.operator("node.add_node", text="Arrange Slicing Group").type = "SLICENODE_ArrangeMesh" diff --git a/core/slice.py b/core/slice.py index 0acc8ea..684add4 100644 --- a/core/slice.py +++ b/core/slice.py @@ -190,6 +190,9 @@ class SlicePaths(list[np.ndarray]): if k not in extra_attributes: self.extra_attributes[k].append(np.zeros(len(path))) + def normalize_groups(self) -> None: + self.groups = [m.setdefault(x, len(m)) for m in [{}] for x in self.groups] + def slice_at_range(models, z_values) -> list[list[np.ndarray]]: if not models: return [] @@ -484,26 +487,26 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI if not connect_along_boundary or is_closed: pc.Clear() if is_closed: path_pts = path_pts[:-1] - pc.AddPath((path_pts * SCALE).astype(np.int64).tolist(), pyclipper.PT_SUBJECT, is_closed) + pc.AddPath((path_pts * SCALE).astype(np.int64).tolist(), pyclipper.PT_SUBJECT, is_closed) # pyright: ignore[reportAttributeAccessIssue] for c in clips_scaled: - pc.AddPath(c, pyclipper.PT_CLIP, True) + pc.AddPath(c, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] - solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) - for path in pyclipper.ClosedPathsFromPolyTree(solution): + solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] + for path in pyclipper.ClosedPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue] fpath = np.array(path) / SCALE fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) layer_results.append(np.vstack([fpath_3d, fpath_3d[0]])) - for path in pyclipper.OpenPathsFromPolyTree(solution): + for path in pyclipper.OpenPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue] fpath = np.array(path) / SCALE layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)])) else: pc.Clear() - pc.AddPath((path_pts * SCALE).astype(np.int64).tolist(), pyclipper.PT_SUBJECT, False) + pc.AddPath((path_pts * SCALE).astype(np.int64).tolist(), pyclipper.PT_SUBJECT, False) # pyright: ignore[reportAttributeAccessIssue] for c in clips_scaled: - pc.AddPath(c, pyclipper.PT_CLIP, True) + pc.AddPath(c, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] - solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) - open_results = pyclipper.OpenPathsFromPolyTree(solution) + solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] + open_results = pyclipper.OpenPathsFromPolyTree(solution) # pyright: ignore[reportAttributeAccessIssue] if not open_results: continue def get_p(pt): @@ -555,24 +558,24 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI if operation == 'INTERSECTION': obj_closed_pts = [s[:, :2] for s in object_slices if len(s) > 2 and np.allclose(s[0], s[-1])] obj_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in obj_closed_pts] - process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_INTERSECTION) + process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_INTERSECTION) # pyright: ignore[reportAttributeAccessIssue] # Pass 2 for B_open ∩ A_closed b_open = [s for s in object_slices if not (len(s) > 2 and np.allclose(s[0], s[-1]))] if b_open: sub_closed_pts = [s[:, :2] for s in subject_slices if len(s) > 2 and np.allclose(s[0], s[-1])] sub_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in sub_closed_pts] - process_op(b_open, sub_closed_scaled, sub_closed_pts, pyclipper.CT_INTERSECTION) + process_op(b_open, sub_closed_scaled, sub_closed_pts, pyclipper.CT_INTERSECTION) # pyright: ignore[reportAttributeAccessIssue] elif operation == 'DIFFERENCE': obj_closed_pts = [s[:, :2] for s in object_slices if len(s) > 2 and np.allclose(s[0], s[-1])] obj_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in obj_closed_pts] - process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_DIFFERENCE) + process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_DIFFERENCE) # pyright: ignore[reportAttributeAccessIssue] # DIFFERENCE is not symmetric for open paths in the same way, usually A - B means A is the main focus. else: # UNION, XOR - add_paths_to_clipper(subject_slices, pyclipper.PT_SUBJECT) + add_paths_to_clipper(subject_slices, pyclipper.PT_SUBJECT) # pyright: ignore[reportAttributeAccessIssue] obj_closed = [] obj_open = [] for s in object_slices: @@ -580,23 +583,23 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI else: obj_open.append(s) if operation in ['UNION', 'XOR']: - add_paths_to_clipper(obj_open, pyclipper.PT_SUBJECT) + add_paths_to_clipper(obj_open, pyclipper.PT_SUBJECT) # pyright: ignore[reportAttributeAccessIssue] for s in obj_closed: path_pts = s[:, :2] scaled = (path_pts[:-1] * SCALE).astype(np.int64).tolist() - pc.AddPath(scaled, pyclipper.PT_CLIP, True) + pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] - op_type = pyclipper.CT_UNION - if operation == 'UNION': op_type = pyclipper.CT_UNION - elif operation == 'XOR': op_type = pyclipper.CT_XOR + op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue] + if operation == 'UNION': op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue] + elif operation == 'XOR': op_type = pyclipper.CT_XOR # pyright: ignore[reportAttributeAccessIssue] - solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) - for path in pyclipper.ClosedPathsFromPolyTree(solution): + solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] + for path in pyclipper.ClosedPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue] fpath = np.array(path) / SCALE fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) layer_results.append(np.vstack([fpath_3d, fpath_3d[0]])) - for path in pyclipper.OpenPathsFromPolyTree(solution): + for path in pyclipper.OpenPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue] fpath = np.array(path) / SCALE layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)])) diff --git a/doc/NODES.md b/doc/NODES.md index 3cd6a12..1b2849d 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -58,6 +58,9 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Translation**: 3D vector specifying the translation offset. - **Rotation**: 3D vector/Euler rotation values. - **Scale**: 3D vector specifying scaling factors along the X, Y, and Z axes (defaults to `1.0, 1.0, 1.0`). +- **Drop Geometry**: Moves a `SlicingCollection` or `SlicingGroup` along Z so that its minimum Z sits at the specified value. + - **Mesh**: Input mesh data. + - **Z**: Target minimum Z coordinate (defaults to `0.0`). - **Get Paths**: Extracts existing edges from a mesh as paths. - **Extrusion Multiplier Attribute**: Mesh attribute to use for per-segment extrusion rates. - **Feedrate Attribute**: Mesh attribute to use for per-segment feedrates. diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index a0ba4e8..f50d446 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -92,9 +92,6 @@ class SlicingObject(): def offset(self, offset: NDArray): self.mesh[:, :3, :] += offset - def translate(self, translation: NDArray): - self.offset(translation) - def scale(self, factor: float | NDArray): self.mesh[:, :3, :] *= factor if isinstance(factor, (list, tuple, np.ndarray)): @@ -210,10 +207,6 @@ class SlicingCollection(): for so in self.objects: so.offset(offset) self._clear_cache() - def translate(self, translation: NDArray): - for so in self.objects: so.translate(translation) - self._clear_cache() - def scale(self, factor: float | NDArray): for so in self.objects: so.scale(factor) self._clear_cache() diff --git a/tests/test_transform.py b/tests/test_transform.py index 27ce0f4..edf45ad 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -47,10 +47,10 @@ sys.modules['bpy.utils.previews'] = MagicMock() sys.modules['bpy.app'] = MagicMock() sys.modules['bpy.app.handlers'] = MagicMock() -from NodeSlicer.infra.blender.blender_mesh_capture import SlicingCollection, SlicingObject +from ..infra.blender.blender_mesh_capture import SlicingCollection, SlicingObject class TestTransform(unittest.TestCase): - def test_translate(self): + def test_offset(self): # Create a SlicingObject with a single triangle and a normal pointing up (+Z) so = SlicingObject() so.mesh = np.array([[[1.0, 2.0, 3.0], @@ -58,8 +58,8 @@ class TestTransform(unittest.TestCase): [7.0, 8.0, 9.0], [0.0, 0.0, 1.0]]], dtype=np.float64) - # Translate - so.translate(np.array([10.0, 20.0, 30.0], dtype=np.float64)) + # Offset + so.offset(np.array([10.0, 20.0, 30.0], dtype=np.float64)) # Normals should remain unchanged, vertices shifted np.testing.assert_allclose(so.mesh[0, 0], [11.0, 22.0, 33.0]) @@ -138,7 +138,7 @@ class TestTransform(unittest.TestCase): self.assertIn("unique_verts", sc.__dict__) # Apply transformation - sc.translate(np.array([10, 0, 0])) + sc.offset(np.array([10, 0, 0])) # Verify cache was cleared self.assertNotIn("all_verts", sc.__dict__) @@ -197,6 +197,68 @@ class TestTransform(unittest.TestCase): # v0 becomes [6.0, 22.0, 36.0] np.testing.assert_allclose(result_sc.objects[0].mesh[0, 0], [6.0, 22.0, 36.0], atol=1e-7) + def test_drop_geometry_node(self): + from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_DropGeometry + + # Create a SlicingObject with a single triangle having min_z = 3.0 + so = SlicingObject() + so.mesh = np.array([[[1.0, 2.0, 3.0], + [4.0, 5.0, 6.0], + [7.0, 8.0, 9.0], + [0.0, 0.0, 1.0]]], dtype=np.float64) + + sc = SlicingCollection([], "coll") + sc.objects = [so] + + # Init node + node = SLICENODE_DropGeometry() + + # Test case 1: Drop to Z = 0.0 + node.z = 0.0 + node.inputs = {} + # Mock resolve to return our inputs + def mock_resolve_no_socket(fieldname): + if fieldname == "Mesh": + return sc + elif fieldname == "Z": + return 0.0 + return None + node.resolve = mock_resolve_no_socket + + result_sc_1 = node.evaluate_output("Mesh") + self.assertIsNotNone(result_sc_1) + self.assertEqual(result_sc_1.min_z, 0.0) + np.testing.assert_allclose(result_sc_1.objects[0].mesh[0, 0], [1.0, 2.0, 0.0]) + + # Test case 2: Drop to Z = 10.0 + so2 = SlicingObject() + so2.mesh = np.array([[[1.0, 2.0, 3.0], + [4.0, 5.0, 6.0], + [7.0, 8.0, 9.0], + [0.0, 0.0, 1.0]]], dtype=np.float64) + sc2 = SlicingCollection([], "coll2") + sc2.objects = [so2] + + mock_socket = MagicMock() + mock_socket.is_linked = False + mock_socket.value = 10.0 + node.inputs = {"Z": mock_socket} + node.z = 0.0 + + def mock_resolve_with_socket(fieldname): + if fieldname == "Mesh": + return sc2 + elif fieldname == "Z": + return 10.0 + return None + node.resolve = mock_resolve_with_socket + + result_sc_2 = node.evaluate_output("Mesh") + self.assertIsNotNone(result_sc_2) + self.assertEqual(result_sc_2.min_z, 10.0) + np.testing.assert_allclose(result_sc_2.objects[0].mesh[0, 0], [1.0, 2.0, 10.0]) + if __name__ == '__main__': unittest.main() +