transform

This commit is contained in:
Nicolas Predella 2026-06-30 17:22:54 +03:00
parent 004601b820
commit d556c61ee5
5 changed files with 336 additions and 2 deletions

View file

@ -429,3 +429,64 @@ def _copy_slicing_group(sg: SlicingGroup) -> SlicingGroup:
new_sg.wipe_tower_rotation_deg = sg.wipe_tower_rotation_deg new_sg.wipe_tower_rotation_deg = sg.wipe_tower_rotation_deg
return new_sg 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

View file

@ -496,6 +496,7 @@ class SLICENODE_MT_Geometry(Menu):
layout = self.layout layout = self.layout
assert layout assert layout
layout.operator("node.add_node", text="Get Mesh").type = "SLICENODE_GetMesh" 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").type = "SLICENODE_SeparateMesh"
layout.operator("node.add_node", text="Separate Mesh by Range").type = "SLICENODE_SeparateMeshByRange" 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" layout.operator("node.add_node", text="Arrange Slicing Group").type = "SLICENODE_ArrangeMesh"

View file

@ -53,6 +53,11 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
### Geometry Processing ### Geometry Processing
- **Get Mesh**: Converts a Blender Object into internal `SlicingObject` format. - **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. - **Get Paths**: Extracts existing edges from a mesh as paths.
- **Extrusion Multiplier Attribute**: Mesh attribute to use for per-segment extrusion rates. - **Extrusion Multiplier Attribute**: Mesh attribute to use for per-segment extrusion rates.
- **Feedrate Attribute**: Mesh attribute to use for per-segment feedrates. - **Feedrate Attribute**: Mesh attribute to use for per-segment feedrates.

View file

@ -92,8 +92,58 @@ class SlicingObject():
def offset(self, offset: NDArray): def offset(self, offset: NDArray):
self.mesh[:, :3, :] += offset 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 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]): def assign_modifiers(self, modifiers: dict[str, str]):
self.modifiers = [ self.modifiers = [
@ -151,11 +201,26 @@ class SlicingCollection():
self.objects = [o for o in self.objects if o.mesh.size] self.objects = [o for o in self.objects if o.mesh.size]
self.name = parent 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): def offset(self, offset: NDArray):
for so in self.objects: so.offset(offset) 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) 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 @property
def checksum(self) -> int: def checksum(self) -> int:

202
tests/test_transform.py Normal file
View file

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