refactor zip + drop geometry

This commit is contained in:
Nicolas Predella 2026-07-02 12:53:55 +03:00
parent d556c61ee5
commit 7d53f29566
7 changed files with 132 additions and 38 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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