-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPlanarComponentsAutoSnapToAxis.py
More file actions
171 lines (145 loc) · 5.86 KB
/
PlanarComponentsAutoSnapToAxis.py
File metadata and controls
171 lines (145 loc) · 5.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# -----------------------------------------------------------------------------------
# Planarizes selected components to their best-fit plane.
# Version: 1.2
#
# Changes in v1.2:
# - Only moves vertices that are OFF the plane (selective displacement)
# - Detects vertices already on plane and uses them to define it (preserves majority)
# - Shows in-viewport notification with moved vertex count and plane orientation
# - Distance threshold (1e-3) determines which vertices need alignment
# - Fixed selectType flag error when object is selected
#
# Changes in v1.1:
# - Added snap_normal_to_axis: snaps plane normal to X/Y/Z if within 0.1° threshold
# - Preserves original selection mode (vertex/edge/face/object) after operation
# - Added error handling for selection type detection
# - Improved performance: Vtx3DtoNpArray uses list comprehension instead of np.append
# -----------------------------------------------------------------------------------
import maya.cmds as cmds
import maya.mel as mel
import numpy as np
# -----------------------------------------------------------------------------------
# Linear algebra utilities
# -----------------------------------------------------------------------------------
def Vtx3DtoNpArray(S):
"""Returns vertex coordinates as a numpy array of shape [n, 3]"""
return np.array([cmds.xform(vtx, q=True, ws=True, t=True) for vtx in S])
def normalized(v):
"""Returns the normalized version of a vector"""
norm = np.linalg.norm(v)
if norm == 0:
return v
return v / norm
def fitPlaneEigen(M):
"""Returns the normal of the best-fit plane for a set of points"""
cov = np.cov(M.T)
eigvals, eigvecs = np.linalg.eig(cov)
idx = np.argmin(eigvals)
return eigvecs[:, idx]
def average(M):
"""Returns the average point (centroid) of a set of points"""
return np.mean(M, axis=0)
def snap_normal_to_axis(normal, threshold_deg=0.1):
"""
If the normal is within threshold_deg of a world axis (X/Y/Z),
snaps it to that axis.
"""
threshold_rad = np.deg2rad(threshold_deg)
world_axes = [
np.array([1.0, 0.0, 0.0]),
np.array([0.0, 1.0, 0.0]),
np.array([0.0, 0.0, 1.0])
]
n = normalized(normal)
for axis in world_axes:
dot = np.dot(n, axis)
angle = np.arccos(np.clip(abs(dot), -1.0, 1.0))
if angle < threshold_rad:
return axis * np.sign(dot)
return n
# -----------------------------------------------------------------------------------
# Main Function: Align vertices to a best-fit plane
# -----------------------------------------------------------------------------------
def alignVtxToPlane():
selCom = cmds.ls(sl=True, fl=True)
if not selCom:
cmds.error("Nothing selected.")
return
# Detect original selection type
sel_mode = {
"vertex": bool(cmds.filterExpand(selCom, sm=31)),
"edge": bool(cmds.filterExpand(selCom, sm=32)),
"face": bool(cmds.filterExpand(selCom, sm=34)),
"object": all("." not in s and "Shape" not in s for s in selCom)
}
mesh = selCom[0].split('.')[0]
# Convert to vertices
cmds.select(selCom)
try:
cmds.ConvertSelectionToVertices()
except:
cmds.error("Failed to convert selection to vertices.")
return
selVtx = cmds.ls(sl=True, fl=True)
if len(selVtx) < 3:
cmds.error("Select at least 3 vertices, 2 edges, or 1 face.")
return
# Compute initial best-fit plane
vtxCoor = Vtx3DtoNpArray(selVtx)
avg = average(vtxCoor)
normal = fitPlaneEigen(vtxCoor)
normal = snap_normal_to_axis(normal)
# Find vertices already on the plane
distance_threshold = 1e-3
distances = [abs(np.dot(vtxCoor[i] - avg, normal)) for i in range(len(selVtx))]
on_plane_indices = [i for i, d in enumerate(distances) if d <= distance_threshold]
# If most vertices are already on a plane, use them to define the plane
if len(on_plane_indices) >= 3:
plane_points = vtxCoor[on_plane_indices]
avg = average(plane_points)
normal = fitPlaneEigen(plane_points)
normal = snap_normal_to_axis(normal)
# Move only vertices that are off the plane
moved_count = 0
for i in range(len(selVtx)):
vec = vtxCoor[i] - avg
distance_to_plane = abs(np.dot(vec, normal))
if distance_to_plane > distance_threshold:
proj = np.dot(vec, normal) * normal
new_pos = vtxCoor[i] - proj
cmds.move(new_pos[0], new_pos[1], new_pos[2], selVtx[i], absolute=True)
moved_count += 1
# Restore original selection
cmds.select(cl=True)
cmds.select(selCom)
# Restore original component selection mode
if sel_mode["vertex"]:
mel.eval('doMenuComponentSelectionExt("%s", "vertex", 0);' % mesh)
elif sel_mode["edge"]:
mel.eval('doMenuComponentSelectionExt("%s", "edge", 0);' % mesh)
elif sel_mode["face"]:
mel.eval('doMenuComponentSelectionExt("%s", "facet", 0);' % mesh)
elif sel_mode["object"]:
cmds.selectType(allObjects=True)
# Show success notification
axis_names = {
(1.0, 0.0, 0.0): "X", (-1.0, 0.0, 0.0): "X",
(0.0, 1.0, 0.0): "Y", (0.0, -1.0, 0.0): "Y",
(0.0, 0.0, 1.0): "Z", (0.0, 0.0, -1.0): "Z"
}
normal_tuple = tuple(normal)
axis_info = axis_names.get(normal_tuple, "custom")
message = '<hl>Aligned {count} vertices</hl> to plane (normal: <hl>{axis}</hl>)'.format(
count=moved_count,
axis=axis_info
)
cmds.inViewMessage(
amg=message,
pos='topCenter',
fade=True,
fadeInTime=200,
fadeStayTime=1500,
fadeOutTime=500
)
# Run the function
alignVtxToPlane()