transform
This commit is contained in:
parent
004601b820
commit
d556c61ee5
5 changed files with 336 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
202
tests/test_transform.py
Normal 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()
|
||||||
|
|
||||||
Loading…
Add table
Reference in a new issue