Files
PozzettiAndrea-ComfyUI-Geom…/nodes/main/transforms/transform.py
T

428 lines
14 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
"""
Unified Transform - Apply various transformations to meshes.
Supports:
- translate: Move mesh by offset
- rotate: Rotate around axes (degrees)
- scale: Scale uniformly or per-axis
- mirror: Mirror/reflect across axis
- center: Center mesh at origin
- align_to_axes: Align principal axes to world axes
- apply_matrix: Apply custom 4x4 transformation matrix
"""
import numpy as np
import trimesh as trimesh_module
class TransformMeshNode:
"""
Unified Transform - Apply various transformations to meshes.
Supports:
- translate: Move mesh by offset
- rotate: Rotate around axes (degrees)
- scale: Scale uniformly or per-axis
- mirror: Mirror/reflect across axis
- center: Center mesh at origin
- align_to_axes: Align principal axes to world axes
- apply_matrix: Apply custom 4x4 transformation matrix
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"trimesh": ("TRIMESH",),
"operation": ([
"translate",
"rotate",
"scale",
"mirror",
"center",
"align_to_axes",
"apply_matrix"
], {"default": "center"}),
},
"optional": {
# Translate
"translate_x": ("FLOAT", {
"default": 0.0,
"min": -1000.0,
"max": 1000.0,
"step": 0.1,
"visible_when": {"operation": ["translate"]},
}),
"translate_y": ("FLOAT", {
"default": 0.0,
"min": -1000.0,
"max": 1000.0,
"step": 0.1,
"visible_when": {"operation": ["translate"]},
}),
"translate_z": ("FLOAT", {
"default": 0.0,
"min": -1000.0,
"max": 1000.0,
"step": 0.1,
"visible_when": {"operation": ["translate"]},
}),
# Rotate (degrees)
"rotate_x": ("FLOAT", {
"default": 0.0,
"min": -360.0,
"max": 360.0,
"step": 1.0,
"visible_when": {"operation": ["rotate"]},
}),
"rotate_y": ("FLOAT", {
"default": 0.0,
"min": -360.0,
"max": 360.0,
"step": 1.0,
"visible_when": {"operation": ["rotate"]},
}),
"rotate_z": ("FLOAT", {
"default": 0.0,
"min": -360.0,
"max": 360.0,
"step": 1.0,
"visible_when": {"operation": ["rotate"]},
}),
# Scale
"scale_uniform": ("FLOAT", {
"default": 1.0,
"min": 0.001,
"max": 1000.0,
"step": 0.1,
"visible_when": {"operation": ["scale"]},
}),
"scale_x": ("FLOAT", {
"default": 1.0,
"min": 0.001,
"max": 1000.0,
"step": 0.1,
"visible_when": {"operation": ["scale"]},
}),
"scale_y": ("FLOAT", {
"default": 1.0,
"min": 0.001,
"max": 1000.0,
"step": 0.1,
"visible_when": {"operation": ["scale"]},
}),
"scale_z": ("FLOAT", {
"default": 1.0,
"min": 0.001,
"max": 1000.0,
"step": 0.1,
"visible_when": {"operation": ["scale"]},
}),
# Mirror
"mirror_axis": (["x", "y", "z"], {
"default": "x",
"visible_when": {"operation": ["mirror"]},
}),
# Center options
"center_x": (["true", "false"], {
"default": "true",
"visible_when": {"operation": ["center"]},
}),
"center_y": (["true", "false"], {
"default": "true",
"visible_when": {"operation": ["center"]},
}),
"center_z": (["true", "false"], {
"default": "true",
"visible_when": {"operation": ["center"]},
}),
# Matrix (16 comma-separated floats, row-major)
"matrix_string": ("STRING", {
"default": "1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1",
"multiline": False,
"visible_when": {"operation": ["apply_matrix"]},
}),
}
}
RETURN_TYPES = ("TRIMESH", "STRING")
RETURN_NAMES = ("transformed_mesh", "info")
FUNCTION = "transform"
CATEGORY = "geompack/transforms"
OUTPUT_NODE = True
def transform(self, trimesh, operation,
translate_x=0.0, translate_y=0.0, translate_z=0.0,
rotate_x=0.0, rotate_y=0.0, rotate_z=0.0,
scale_uniform=1.0, scale_x=1.0, scale_y=1.0, scale_z=1.0,
mirror_axis="x",
center_x="true", center_y="true", center_z="true",
matrix_string="1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1"):
"""
Apply transformation to mesh.
Args:
trimesh: Input trimesh.Trimesh object
operation: Type of transformation to apply
[other params]: Operation-specific parameters
Returns:
tuple: (transformed_mesh, info_string)
"""
print(f"[Transform] Input: {len(trimesh.vertices)} vertices, {len(trimesh.faces)} faces")
print(f"[Transform] Operation: {operation}")
# Create copy to avoid modifying original
result = trimesh.copy()
if operation == "translate":
result, info = self._translate(result, translate_x, translate_y, translate_z)
elif operation == "rotate":
result, info = self._rotate(result, rotate_x, rotate_y, rotate_z)
elif operation == "scale":
result, info = self._scale(result, scale_uniform, scale_x, scale_y, scale_z)
elif operation == "mirror":
result, info = self._mirror(result, mirror_axis)
elif operation == "center":
result, info = self._center(result, center_x, center_y, center_z)
elif operation == "align_to_axes":
result, info = self._align_to_axes(result)
elif operation == "apply_matrix":
result, info = self._apply_matrix(result, matrix_string)
else:
raise ValueError(f"Unknown operation: {operation}")
# Preserve metadata
result.metadata = trimesh.metadata.copy()
result.metadata['transform'] = {
'operation': operation,
'original_bounds': trimesh.bounds.tolist(),
'new_bounds': result.bounds.tolist()
}
print(f"[Transform] Complete")
return {"ui": {"text": [info]}, "result": (result, info)}
def _translate(self, mesh, tx, ty, tz):
"""Translate mesh by offset."""
translation = np.array([tx, ty, tz])
mesh.apply_translation(translation)
info = f"""Transform Results (Translate):
Translation Vector: [{tx:.3f}, {ty:.3f}, {tz:.3f}]
New Bounds:
Min: [{mesh.bounds[0][0]:.3f}, {mesh.bounds[0][1]:.3f}, {mesh.bounds[0][2]:.3f}]
Max: [{mesh.bounds[1][0]:.3f}, {mesh.bounds[1][1]:.3f}, {mesh.bounds[1][2]:.3f}]
"""
return mesh, info
def _rotate(self, mesh, rx, ry, rz):
"""Rotate mesh around axes (degrees)."""
# Convert to radians
rx_rad = np.radians(rx)
ry_rad = np.radians(ry)
rz_rad = np.radians(rz)
# Create rotation matrices (X, Y, Z order)
# Rotation around X
Rx = np.array([
[1, 0, 0, 0],
[0, np.cos(rx_rad), -np.sin(rx_rad), 0],
[0, np.sin(rx_rad), np.cos(rx_rad), 0],
[0, 0, 0, 1]
])
# Rotation around Y
Ry = np.array([
[np.cos(ry_rad), 0, np.sin(ry_rad), 0],
[0, 1, 0, 0],
[-np.sin(ry_rad), 0, np.cos(ry_rad), 0],
[0, 0, 0, 1]
])
# Rotation around Z
Rz = np.array([
[np.cos(rz_rad), -np.sin(rz_rad), 0, 0],
[np.sin(rz_rad), np.cos(rz_rad), 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]
])
# Combined rotation: Z * Y * X (applied in reverse order)
rotation_matrix = Rz @ Ry @ Rx
mesh.apply_transform(rotation_matrix)
info = f"""Transform Results (Rotate):
Rotation (degrees):
X: {rx:.1f}°
Y: {ry:.1f}°
Z: {rz:.1f}°
Order: X -> Y -> Z (Euler angles)
New Bounds:
Min: [{mesh.bounds[0][0]:.3f}, {mesh.bounds[0][1]:.3f}, {mesh.bounds[0][2]:.3f}]
Max: [{mesh.bounds[1][0]:.3f}, {mesh.bounds[1][1]:.3f}, {mesh.bounds[1][2]:.3f}]
"""
return mesh, info
def _scale(self, mesh, uniform, sx, sy, sz):
"""Scale mesh uniformly or per-axis."""
# If uniform scale is not 1.0, use it; otherwise use per-axis
if abs(uniform - 1.0) > 1e-6:
scale_factors = [uniform, uniform, uniform]
else:
scale_factors = [sx, sy, sz]
mesh.apply_scale(scale_factors)
info = f"""Transform Results (Scale):
Scale Factors:
X: {scale_factors[0]:.3f}
Y: {scale_factors[1]:.3f}
Z: {scale_factors[2]:.3f}
New Bounds:
Min: [{mesh.bounds[0][0]:.3f}, {mesh.bounds[0][1]:.3f}, {mesh.bounds[0][2]:.3f}]
Max: [{mesh.bounds[1][0]:.3f}, {mesh.bounds[1][1]:.3f}, {mesh.bounds[1][2]:.3f}]
New Extents: [{mesh.extents[0]:.3f}, {mesh.extents[1]:.3f}, {mesh.extents[2]:.3f}]
"""
return mesh, info
def _mirror(self, mesh, axis):
"""Mirror mesh across axis."""
# Mirror by negating the appropriate coordinate
axis_idx = {"x": 0, "y": 1, "z": 2}[axis]
# Negate vertices along axis
mesh.vertices[:, axis_idx] *= -1
# Flip face winding to maintain correct normals
mesh.faces = mesh.faces[:, ::-1]
# Reset normals cache
mesh._cache.clear()
info = f"""Transform Results (Mirror):
Mirror Axis: {axis.upper()}
Vertices mirrored and face winding corrected.
New Bounds:
Min: [{mesh.bounds[0][0]:.3f}, {mesh.bounds[0][1]:.3f}, {mesh.bounds[0][2]:.3f}]
Max: [{mesh.bounds[1][0]:.3f}, {mesh.bounds[1][1]:.3f}, {mesh.bounds[1][2]:.3f}]
"""
return mesh, info
def _center(self, mesh, cx, cy, cz):
"""Center mesh at origin (selective axes)."""
bounds_center = (mesh.bounds[0] + mesh.bounds[1]) / 2.0
translation = np.array([0.0, 0.0, 0.0])
if cx == "true":
translation[0] = -bounds_center[0]
if cy == "true":
translation[1] = -bounds_center[1]
if cz == "true":
translation[2] = -bounds_center[2]
mesh.apply_translation(translation)
new_center = (mesh.bounds[0] + mesh.bounds[1]) / 2.0
info = f"""Transform Results (Center):
Centered Axes: X={cx}, Y={cy}, Z={cz}
Original Center: [{bounds_center[0]:.3f}, {bounds_center[1]:.3f}, {bounds_center[2]:.3f}]
New Center: [{new_center[0]:.3f}, {new_center[1]:.3f}, {new_center[2]:.3f}]
Translation Applied: [{translation[0]:.3f}, {translation[1]:.3f}, {translation[2]:.3f}]
"""
return mesh, info
def _align_to_axes(self, mesh):
"""Align mesh principal axes to world axes."""
# Get principal axes using PCA
centered_vertices = mesh.vertices - mesh.vertices.mean(axis=0)
covariance = np.cov(centered_vertices.T)
eigenvalues, eigenvectors = np.linalg.eigh(covariance)
# Sort by eigenvalue (largest first)
idx = eigenvalues.argsort()[::-1]
eigenvectors = eigenvectors[:, idx]
# Ensure right-handed coordinate system
if np.linalg.det(eigenvectors) < 0:
eigenvectors[:, 2] *= -1
# Create rotation matrix to align with world axes
rotation = np.eye(4)
rotation[:3, :3] = eigenvectors.T
# Center first, then rotate
center = mesh.vertices.mean(axis=0)
mesh.apply_translation(-center)
mesh.apply_transform(rotation)
info = f"""Transform Results (Align to Axes):
Principal axes aligned to world X, Y, Z axes.
Mesh centered and rotated using PCA.
New Bounds:
Min: [{mesh.bounds[0][0]:.3f}, {mesh.bounds[0][1]:.3f}, {mesh.bounds[0][2]:.3f}]
Max: [{mesh.bounds[1][0]:.3f}, {mesh.bounds[1][1]:.3f}, {mesh.bounds[1][2]:.3f}]
New Extents: [{mesh.extents[0]:.3f}, {mesh.extents[1]:.3f}, {mesh.extents[2]:.3f}]
"""
return mesh, info
def _apply_matrix(self, mesh, matrix_string):
"""Apply custom 4x4 transformation matrix."""
try:
# Parse comma-separated values
values = [float(x.strip()) for x in matrix_string.split(',')]
if len(values) != 16:
raise ValueError(f"Expected 16 values, got {len(values)}")
# Reshape to 4x4 (row-major)
matrix = np.array(values).reshape(4, 4)
mesh.apply_transform(matrix)
info = f"""Transform Results (Apply Matrix):
Matrix (4x4):
[{matrix[0, 0]:.3f}, {matrix[0, 1]:.3f}, {matrix[0, 2]:.3f}, {matrix[0, 3]:.3f}]
[{matrix[1, 0]:.3f}, {matrix[1, 1]:.3f}, {matrix[1, 2]:.3f}, {matrix[1, 3]:.3f}]
[{matrix[2, 0]:.3f}, {matrix[2, 1]:.3f}, {matrix[2, 2]:.3f}, {matrix[2, 3]:.3f}]
[{matrix[3, 0]:.3f}, {matrix[3, 1]:.3f}, {matrix[3, 2]:.3f}, {matrix[3, 3]:.3f}]
New Bounds:
Min: [{mesh.bounds[0][0]:.3f}, {mesh.bounds[0][1]:.3f}, {mesh.bounds[0][2]:.3f}]
Max: [{mesh.bounds[1][0]:.3f}, {mesh.bounds[1][1]:.3f}, {mesh.bounds[1][2]:.3f}]
"""
except Exception as e:
raise ValueError(f"Invalid matrix string: {e}")
return mesh, info
NODE_CLASS_MAPPINGS = {
"GeomPackTransformMesh": TransformMeshNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"GeomPackTransformMesh": "Transform Mesh",
}