diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index 94e7064..554d1a0 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -429,3 +429,64 @@ def _copy_slicing_group(sg: SlicingGroup) -> SlicingGroup: new_sg.wipe_tower_rotation_deg = sg.wipe_tower_rotation_deg return new_sg + +@register_class +class SLICENODE_TransformGeometry(SliceNode): + bl_label = "Transform Geometry" + + def init(self, context): + self.add_input("SLICESOCK_Mesh", "Mesh") + self.add_input("SLICESOCK_Vector", "Translation") + self.add_input("SLICESOCK_VectorRotation", "Rotation") + self.add_input("SLICESOCK_Vector", "Scale").value = (1.0, 1.0, 1.0) + self.add_output("SLICESOCK_Mesh", "Mesh") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | SlicingGroup | None: + if socket_name != "Mesh": + return None + + mesh_data: SlicingCollection | SlicingGroup | None = self.resolve("Mesh") + if not mesh_data: + return None + + translation = self.resolve("Translation") or (0.0, 0.0, 0.0) + rotation = self.resolve("Rotation") or (0.0, 0.0, 0.0) + scale = self.resolve("Scale") or (1.0, 1.0, 1.0) + + t_arr = np.array(translation, dtype=np.float64) + r_arr = np.array(rotation, dtype=np.float64) + s_arr = np.array(scale, dtype=np.float64) + + if isinstance(mesh_data, SlicingGroup): + copied_data = _copy_slicing_group(mesh_data) + for col in copied_data.collections.values(): + if any(s != 1.0 for s in s_arr): + col.scale(s_arr) + 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) + # 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] + if any(r != 0.0 for r in r_arr): + rz = r_arr[2] + cz, sz = np.cos(rz), np.sin(rz) + rot_mat_2d = np.array([[cz, -sz], [sz, cz]]) + copied_data.wipe_tower_xy = copied_data.wipe_tower_xy @ rot_mat_2d.T + import math + copied_data.wipe_tower_rotation_deg = round(copied_data.wipe_tower_rotation_deg + rz * 180 / math.pi, 5) + if any(s != 1.0 for s in s_arr): + copied_data.wipe_tower_xy *= s_arr[0:2] + else: + copied_data = _copy_slicing_collection(mesh_data) + if any(s != 1.0 for s in s_arr): + copied_data.scale(s_arr) + 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) + + return copied_data + + diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index f1e2487..7389a32 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -496,6 +496,7 @@ class SLICENODE_MT_Geometry(Menu): layout = self.layout 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="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/doc/NODES.md b/doc/NODES.md index a06c072..3cd6a12 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -53,6 +53,11 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ ### Geometry Processing - **Get Mesh**: Converts a Blender Object into internal `SlicingObject` format. +- **Transform Geometry**: Moves, rotates, and scales a `SlicingCollection` or `SlicingGroup` relative to the origin. + - **Mesh**: Input mesh data. + - **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`). - **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 2bde37b..a0ba4e8 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -92,8 +92,58 @@ class SlicingObject(): def offset(self, offset: NDArray): self.mesh[:, :3, :] += offset - def scale(self, factor: float): + 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)): + factor_arr = np.array(factor, dtype=np.float64) + if factor_arr.shape == (3,): + # For non-uniform scaling, normals transform by the inverse transpose. + safe_factor = np.where(factor_arr == 0, 1.0, factor_arr) + self.mesh[:, 3, :] /= safe_factor + norms = self.mesh[:, 3, :] + norms_len = np.linalg.norm(norms, axis=-1, keepdims=True) + norms_len = np.where(norms_len == 0, 1.0, norms_len) + self.mesh[:, 3, :] = norms / norms_len + + def rotate(self, angles: NDArray): + # angles: array-like of 3 floats (rx, ry, rz) in radians + angles_arr = np.array(angles, dtype=np.float64) + rx, ry, rz = angles_arr + cx, sx = np.cos(rx), np.sin(rx) + cy, sy = np.cos(ry), np.sin(ry) + cz, sz = np.cos(rz), np.sin(rz) + + Rx = np.array([ + [1.0, 0.0, 0.0], + [0.0, cx, -sx], + [0.0, sx, cx] + ], dtype=np.float64) + Ry = np.array([ + [cy, 0.0, sy], + [0.0, 1.0, 0.0], + [-sy, 0.0, cy] + ], dtype=np.float64) + Rz = np.array([ + [cz, -sz, 0.0], + [sz, cz, 0.0], + [0.0, 0.0, 1.0] + ], dtype=np.float64) + + R = Rz @ (Ry @ Rx) + + # Apply to vertices: shape (N, 3, 3) + self.mesh[:, :3, :] = self.mesh[:, :3, :] @ R.T + + # Apply to normals: shape (N, 3) + self.mesh[:, 3, :] = self.mesh[:, 3, :] @ R.T + # Normalize the normals + norms = self.mesh[:, 3, :] + norms_len = np.linalg.norm(norms, axis=-1, keepdims=True) + norms_len = np.where(norms_len == 0, 1.0, norms_len) + self.mesh[:, 3, :] = norms / norms_len def assign_modifiers(self, modifiers: dict[str, str]): self.modifiers = [ @@ -151,11 +201,26 @@ class SlicingCollection(): self.objects = [o for o in self.objects if o.mesh.size] self.name = parent + def _clear_cache(self): + for prop in ["all_verts", "unique_verts"]: + if prop in self.__dict__: + del self.__dict__[prop] + def offset(self, offset: NDArray): for so in self.objects: so.offset(offset) + self._clear_cache() - def scale(self, factor: float): + 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() + + def rotate(self, angles: NDArray): + for so in self.objects: so.rotate(angles) + self._clear_cache() @property def checksum(self) -> int: diff --git a/tests/test_transform.py b/tests/test_transform.py new file mode 100644 index 0000000..27ce0f4 --- /dev/null +++ b/tests/test_transform.py @@ -0,0 +1,202 @@ +import numpy as np +import unittest +from unittest.mock import MagicMock +import sys +from pathlib import Path + +# Add workspace directory to sys.path to resolve root package name (NodeSlicer) +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.append(str(PROJECT_ROOT.parent)) + +# Define dummy classes to avoid metaclass conflicts +class DummyNodeSocket: pass +class DummyNode: pass +class DummyObject: pass +class DummyMesh: pass +class DummyNodeSocketShader: pass +class DummyNodeCustomGroup: pass +class DummyNodeTree: pass +class DummyOperator: pass +class DummyMenu: pass +class DummyPanel: pass +class DummyPropertyGroup: pass + +mock_types = MagicMock() +mock_types.NodeSocket = DummyNodeSocket +mock_types.Node = DummyNode +mock_types.Object = DummyObject +mock_types.Mesh = DummyMesh +mock_types.NodeSocketShader = DummyNodeSocketShader +mock_types.NodeCustomGroup = DummyNodeCustomGroup +mock_types.NodeTree = DummyNodeTree +mock_types.Operator = DummyOperator +mock_types.Menu = DummyMenu +mock_types.Panel = DummyPanel +mock_types.PropertyGroup = DummyPropertyGroup + +mock_bpy = MagicMock() +mock_bpy.context.scene = MagicMock() +mock_bpy.context.scene.unit_settings.scale_length = 0.001 +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() + +from NodeSlicer.infra.blender.blender_mesh_capture import SlicingCollection, SlicingObject + +class TestTransform(unittest.TestCase): + def test_translate(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], + [4.0, 5.0, 6.0], + [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)) + + # Normals should remain unchanged, vertices shifted + np.testing.assert_allclose(so.mesh[0, 0], [11.0, 22.0, 33.0]) + np.testing.assert_allclose(so.mesh[0, 1], [14.0, 25.0, 36.0]) + np.testing.assert_allclose(so.mesh[0, 2], [17.0, 28.0, 39.0]) + np.testing.assert_allclose(so.mesh[0, 3], [0.0, 0.0, 1.0]) + + def test_scale_uniform(self): + 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) + + # Uniform Scale + so.scale(2.0) + np.testing.assert_allclose(so.mesh[0, 0], [2.0, 4.0, 6.0]) + np.testing.assert_allclose(so.mesh[0, 3], [0.0, 0.0, 1.0]) + + def test_scale_non_uniform(self): + so = SlicingObject() + # Normal pointing at (1, 1, 0) normalized: (0.70710678, 0.70710678, 0.0) + n = np.array([1.0, 1.0, 0.0]) / np.sqrt(2.0) + so.mesh = np.array([[[1.0, 2.0, 3.0], + [4.0, 5.0, 6.0], + [7.0, 8.0, 9.0], + n]], dtype=np.float64) + + # Scale X by 2, Y by 0.5, Z by 1 + so.scale(np.array([2.0, 0.5, 1.0])) + + # Vertices check + np.testing.assert_allclose(so.mesh[0, 0], [2.0, 1.0, 3.0]) + + # Normals check: non-uniform scale scales normal by inverse transpose, then normalized. + # Inverse of scale is [0.5, 2.0, 1.0]. + # Rotated normal: (0.7071 / 2, 0.7071 * 2, 0.0) = (0.3535, 1.414, 0.0) + # Normalized: (0.3535 / sqrt(0.3535^2 + 1.414^2), ...) + expected_n = np.array([n[0] / 2.0, n[1] / 0.5, 0.0]) + expected_n /= np.linalg.norm(expected_n) + np.testing.assert_allclose(so.mesh[0, 3], expected_n, rtol=1e-6) + + def test_rotate(self): + so = SlicingObject() + so.mesh = np.array([[[1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 1.0]]], dtype=np.float64) + + # Rotate 90 degrees around Z axis (0, 0, pi/2) + so.rotate(np.array([0.0, 0.0, np.pi / 2.0])) + + # Vertex [1, 0, 0] rotated around Z by 90 deg -> [0, 1, 0] + np.testing.assert_allclose(so.mesh[0, 0], [0.0, 1.0, 0.0], atol=1e-7) + # Vertex [0, 1, 0] rotated around Z by 90 deg -> [-1, 0, 0] + np.testing.assert_allclose(so.mesh[0, 1], [-1.0, 0.0, 0.0], atol=1e-7) + # Vertex [0, 0, 1] rotated around Z by 90 deg -> [0, 0, 1] + np.testing.assert_allclose(so.mesh[0, 2], [0.0, 0.0, 1.0], atol=1e-7) + # Normal [0, 0, 1] should remain [0, 0, 1] + np.testing.assert_allclose(so.mesh[0, 3], [0.0, 0.0, 1.0], atol=1e-7) + + def test_collection_cache_invalidation(self): + so = SlicingObject() + so.mesh = np.array([[[1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 1.0]]], dtype=np.float64) + + sc = SlicingCollection([], "coll") + sc.objects = [so] + + # Access cached properties to populate the cache + all_v = sc.all_verts + uniq_v = sc.unique_verts + self.assertIn("all_verts", sc.__dict__) + self.assertIn("unique_verts", sc.__dict__) + + # Apply transformation + sc.translate(np.array([10, 0, 0])) + + # Verify cache was cleared + self.assertNotIn("all_verts", sc.__dict__) + self.assertNotIn("unique_verts", sc.__dict__) + + # Verify new values are retrieved when accessed again + new_all_v = sc.all_verts + self.assertEqual(new_all_v[0, 0], 11.0) + + def test_transform_node(self): + from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry + + # 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], + [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_TransformGeometry() + + # Define mock resolver to return our inputs + def mock_resolve(fieldname): + if fieldname == "Mesh": + return sc + elif fieldname == "Translation": + return (10.0, 20.0, 30.0) + elif fieldname == "Rotation": + # Rotate 90 degrees around Z axis: (0, 0, pi / 2) + return (0.0, 0.0, np.pi / 2.0) + elif fieldname == "Scale": + return (2.0, 2.0, 2.0) + return None + + node.resolve = mock_resolve + + # Evaluate output + result_sc = node.evaluate_output("Mesh") + self.assertIsNotNone(result_sc) + + # Ensure it's not the same object (deep copied) + self.assertIsNot(result_sc, sc) + self.assertIsNot(result_sc.objects[0], so) + + # Let's check coordinates. + # SRT Order: + # 1. Scale by 2.0: + # v0 becomes [2.0, 4.0, 6.0] + # 2. Rotate by pi/2 around Z: + # v0 becomes [-4.0, 2.0, 6.0] + # 3. Translate by [10, 20, 30]: + # 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) + +if __name__ == '__main__': + unittest.main() +