【Maya × Claude】自动化轮胎旋转(Car Wheel Rig Tool 详解)
【Maya × Claude】タイヤの回転を自動化する(Car Wheel Rig Tool 解説)
乗用車のタイヤを、移動に合わせて自動で回転させるリグとそのためのツールを、Claudeで作成しました。タイヤにキーを打つ必要はなく、走る速さやタイミングを後から変えても、回転は自動で追従します。
タイヤの自動回転そのものは、よく知られた手法です。ただ、半径やカーブの長さを測って、式に入れて、リグが変わるたびに直す作業は手間がかかります。そこで、測定とエクスプレッションの作成をまとめた GUI ツール「Car Wheel Rig Tool」を作りました。
動作確認は、Maya 2026で行っています。
タイヤ回転の仕組み
タイヤの回転角は、進んだ距離が、タイヤの円周(1周分の長さ)の何倍かで決まります。
回転角の求め方
1.円周 = 半径 × 2 × 3.14
2.回転数 = 進んだ距離 ÷ 円周
3.回転角(度) = 回転数 × 360
例:半径が30のタイヤの場合、円周は 30 × 2 × 3.14 = 188.4 です。
回転角 = 進んだ距離 ÷ (半径 × 2 × 3.14) × 360
進んだ距離の求め方
進んだ距離は、車の動かし方で変わります。
| 動かし方 | 進んだ距離 |
|---|---|
| Transform(直線的に動かす) | 動かした量そのまま(例:translateZ) |
| Motion Path(カーブに沿って動かす) | uValue × カーブの長さ |
Motion Path の uValue は、Parametric Length がONのとき、0〜1の値で、カーブ全体に対する進行の割合です。距離そのものではないため、カーブの長さを掛けて距離に直します。例えば、カーブの長さが1000で uValue が0.25なら、進んだ距離は250です。
エクスプレッションにすると
上の計算をエクスプレッションで書くと、次の形になります。
半径 と カーブの長さ には、測定した値が入ります。
Transform で動かす場合:
FrontWheel_L_r.rotateX = 360 * (Car_GP.translateZ / (2 * 3.14159265 * 半径));
Motion Path で動かす場合:
FrontWheel_L_r.rotateX = 360 * ((motionPath1.uValue * カーブの長さ) / (2 * 3.14159265 * 半径));
リグの全体構成
ステアリング用の _h の内側に、転がり用の _r を入れ子にするのが、このリグの要です。
後輪は、ハンドルで向きを変えない(操舵しない)ので、左右の2輪を1つのメッシュにまとめて、_h のない BackWheel_r だけで回します。
後輪の扱いは、一般的な乗用車の場合です。後輪も操舵する車や、フォークリフトのように後輪で操舵する車両では、後輪にも _h を付けてください。
タイヤごとにロケーターを作り、車軸の中心に置きます(Rotate 0 / Scale 1)。最上位のグループと車体のメッシュは、Car_GP と Car のように、別の名前にしてください。
Car_GP のピボット位置は、後輪の中心にしています。Motion Path がカーブに沿って動かすのは、ピボットの位置です。そのため、後輪がカーブをなぞるようになり、後輪の滑りを軽減できます。
事前準備
MotionPathで動かす場合は、ツールを使う前に、Motion Pathを付けて、Car_GP を走らせる状態にしておきます。
Parametric Length の状態を確認する:Attribute Editor の motionPath タブで、U Value の下の「Parametric Length」を見ます。
Parametric Length の状態で、uValue の意味が変わります。ツールの「uValue の換算」は、この状態に合わせて選びます。
| Parametric Length | uValueの意味 | ツールの「uValue の換算」 |
|---|---|---|
| ON(Fraction Mode がON) | カーブの長さに対する割合(0〜1) | 割合(0〜1)のまま使う |
| OFF | カーブのパラメーター値(範囲はカーブごとに違い、例えば0〜10) | 対応表で正確に換算する(推奨) |
どちらの状態でも、ツールは、uValue のキー(アニメーション)を変えずに計算できます。
①測定を押すと、状態に合わせて、「uValue の換算」が自動で切り替わります。
すでに uValue にキーを打っている場合は、Parametric Length のチェックを、あとから切り替えないでください。キーの値の意味が変わって、車の動きが変わってしまいます。切り替えずに、ツールの「uValue の換算」を、今の状態に合わせてください。
Car Wheel Rig Tool の使い方
「測定 → 確認 → 作成」の3段階で、タイヤ回転のエクスプレッションを作ります。測定と確認ではシーンを変更せず、シーンが変わるのは最後の「作成」だけです。
起動する
ツールは、シーンにエクスプレッションなどのノードを作ります。使う前に、シーンを別名で保存しておくと安心です。
ツールのスクリプト(wheel_rig_tool.py)の内容を Script Editor の Python タブに貼り付けて実行すると、ウィンドウが開きます。
wheel_rig_tool.py
wheel_rig_tool.py
# -*- coding: utf-8 -*-
"""
wheel_rig_tool.py
------------------------------------------------------
タイヤ回転用エクスプレッションの 測定 → 確認 → 作成 を行う GUI ツール (Maya 2026)
使い方
1. このファイルの内容を Script Editor (Python タブ) に貼り付けて実行 → ウィンドウが開く
2. 「① 測定」を押す
タイヤの半径・直径、カーブの長さが入力欄に入り、エクスプレッションが生成される
3. 数値やエクスプレッションの内容を確認し、必要なら直接編集する
(数値だけ変えた場合は「② 式を再生成」を押す)
4. 「③ Expression を作成/更新」を押すと、シーンにエクスプレッションが作成される
(何度押しても、既存の CarWheel_rollExp を置き換えるだけ)
「コピー」を押すと、エクスプレッションをクリップボードにコピーできる。
(Expression Editor に自分で貼り付けたい場合に使う)
① と ② はシーンを変更しません。シーンが変わるのは ③ だけです(Undo 可能)。
------------------------------------------------------
"""
import math
import re
import maya.cmds as cmds
PI = 3.14159265
WIN_NAME = 'carWheelRigToolWin'
EXP_NAME = 'CarWheel_rollExp'
LOOKUP_NAME = 'CarWheel_distCurve' # uValue → 進んだ距離 の対応表
# 今回のリグ構成に合わせた初期値(画面上で書き換え可能)
DEFAULT_CAR = 'Car_GP'
DEFAULT_WHEELS = [
('FrontWheel_L', 'FrontWheel_L_r'),
('FrontWheel_R', 'FrontWheel_R_r'),
('BackWheel', 'BackWheel_r'),
]
# ======================================================
# 測定・検出まわり(読み取りのみ)
# ======================================================
def measure_wheel_radius(mesh, axis='x'):
"""
車軸中心から最も遠い頂点までの距離を半径として返す。
溝があっても、路面に接する外周(トレッド外側)が半径になる。
"""
idx = {'x': 0, 'y': 1, 'z': 2}[axis.lower()]
others = [i for i in range(3) if i != idx]
pts = cmds.xform(mesh + '.vtx[*]', query=True, worldSpace=True, translation=True)
verts = [pts[i:i + 3] for i in range(0, len(pts), 3)]
center = {}
for i in others:
lo = min(v[i] for v in verts)
hi = max(v[i] for v in verts)
center[i] = (lo + hi) / 2.0
return max(
math.hypot(v[others[0]] - center[others[0]], v[others[1]] - center[others[1]])
for v in verts
)
def _curve_shape(node):
if cmds.nodeType(node) == 'nurbsCurve':
return node
shapes = cmds.listRelatives(node, shapes=True, type='nurbsCurve', fullPath=True)
if not shapes:
raise RuntimeError('カーブのシェイプが見つかりません: {}'.format(node))
return shapes[0]
def _find_motion_path(car_group):
if car_group and cmds.objExists(car_group):
linked = cmds.listConnections(car_group, source=True, destination=False,
type='motionPath') or []
if linked:
return linked[0]
all_mp = cmds.ls(type='motionPath') or []
return all_mp[0] if len(all_mp) == 1 else None
def _find_curve(motion_path):
src = cmds.listConnections(motion_path + '.geometryPath',
source=True, destination=False) or []
return src[0] if src else None
def _curve_length(curve):
"""(長さ, 警告文 or None) を返す"""
shape = _curve_shape(curve)
length = cmds.arclen(shape, constructionHistory=False)
warn = None
parent = cmds.listRelatives(shape, parent=True)
if parent:
sc = [cmds.getAttr('{}.scale{}'.format(parent[0], a)) for a in 'XYZ']
if any(abs(s - 1.0) > 1e-4 for s in sc):
warn = ('カーブ {} にスケール {} がかかっています。curveInfo の値(ワールド空間)と'
'ズレる場合は Freeze Transformations してください。'.format(parent[0], sc))
return length, warn
def _curve_info_name(curve_shape):
"""接続済みの curveInfo があればその名前、なければ作成予定の名前"""
existing = cmds.listConnections(curve_shape + '.worldSpace[0]',
destination=True, source=False,
type='curveInfo') or []
if existing:
return existing[0]
return curve_shape.split('|')[-1] + '_info'
def _get_or_create_curve_info(curve_shape):
existing = cmds.listConnections(curve_shape + '.worldSpace[0]',
destination=True, source=False,
type='curveInfo') or []
if existing:
return existing[0]
info = cmds.createNode('curveInfo', name=curve_shape.split('|')[-1] + '_info')
cmds.connectAttr(curve_shape + '.worldSpace[0]', info + '.inputCurve', force=True)
return info
def _safe(name):
return re.sub(r'\W', '_', name)
# シーンの単位 → 1cm が何単位にあたるか(内部の単位は cm)
_UNITS_PER_CM = {'mm': 10.0, 'cm': 1.0, 'm': 0.01,
'in': 1.0 / 2.54, 'ft': 1.0 / 30.48, 'yd': 1.0 / 91.44}
def _ui_units_per_cm():
"""
シーンの単位(Working Units の Linear)で、内部の 1(= 1cm)が何になるかを返す。
uValue は「距離」型の属性なので、エクスプレッションで読むと、この倍率が掛かる
(mm のシーンでは、割合の 1 が 10 として読まれる)。cm のシーンでは 1.0。
"""
unit = cmds.currentUnit(query=True, linear=True)
return _UNITS_PER_CM.get(unit, 1.0)
def _param_range(curve):
"""カーブのパラメーター範囲(Parametric Length が OFF のときの uValue の範囲)"""
shape = _curve_shape(curve)
return cmds.getAttr(shape + '.minValue'), cmds.getAttr(shape + '.maxValue')
def _apply_expression(text):
"""
エクスプレッションを作る。すでにあれば、ノードを削除せずに、中身だけを書き換える。
(以前の版では、削除した瞬間に、uValue のキーが消える現象が起きたため)
戻り値: 'created'(新規作成) または 'updated'(書き換え)
"""
if cmds.objExists(EXP_NAME):
cmds.expression(EXP_NAME, edit=True, string=text, alwaysEvaluate=True,
unitConversion='all')
return 'updated'
cmds.expression(string=text, name=EXP_NAME, alwaysEvaluate=True, unitConversion='all')
return 'created'
def _remove_lookup(name):
"""
対応表を削除する。uValue からの接続を先に外してから、ノードを削除する。
(uValue の側のデータに影響が出ないようにするため)
"""
for src in cmds.listConnections(name + '.input', source=True, destination=False,
plugs=True) or []:
node = src.split('.')[0]
if cmds.nodeType(node) == 'unitConversion':
cmds.delete(node) # 自動で挿入された変換ノードごと、接続を外す
else:
cmds.disconnectAttr(src, name + '.input')
cmds.delete(name)
def build_distance_lookup(motion_path, curve, name=LOOKUP_NAME, samples=200, trace=None):
"""
「uValue → 進んだ距離」の対応表(animCurveUU)を作り、uValue につなぐ。
Parametric Length が OFF のとき、uValue はカーブのパラメーター値(例:0〜10)で、
長さとは比例しない。カーブの形から、各パラメーター値までの長さを求めて表にする。
uValue のキー(アニメーション)は一切変更しない。
trace : 各段階のあとに呼ばれる関数 trace('段階の名前')(キーの変化の追跡用)
"""
import maya.api.OpenMaya as om2
mark = trace or (lambda label: None)
shape = _curve_shape(curve)
sel = om2.MSelectionList()
sel.add(shape)
fn = om2.MFnNurbsCurve(sel.getDagPath(0))
pmin, pmax = _param_range(curve)
scale = _ui_units_per_cm() # エクスプレッションが読む uValue は、シーンの単位に変換される
params = [pmin + (pmax - pmin) * i / float(samples - 1) for i in range(samples)]
positions = [p * scale for p in params]
values = [fn.findLengthFromParam(p) for p in params]
# すでに同じ形の対応表があれば、削除せずに使い回す。
# (uValue につながった対応表を削除すると、uValue のキーが消える現象が起きたため)
if cmds.objExists(name):
old_pos = cmds.keyframe(name, query=True, floatChange=True) or []
old_val = cmds.keyframe(name, query=True, valueChange=True) or []
if len(old_pos) == samples and \
all(abs(a - b) < 1e-4 for a, b in zip(old_pos, positions)):
changed = [i for i in range(samples)
if abs(old_val[i] - values[i]) > 1e-3 * max(1.0, abs(values[i]))]
for i in changed:
cmds.setKeyframe(name, float=positions[i], value=values[i],
inTangentType='linear', outTangentType='linear')
mark('対応表の値の更新({} 個)'.format(len(changed)))
if not cmds.listConnections(name + '.input', source=True, destination=False):
cmds.connectAttr(motion_path + '.uValue', name + '.input', force=True)
mark('uValue から対応表への接続')
return name
# 形が違う(カーブのパラメーター範囲が変わった等)ときだけ、作り直す
_remove_lookup(name)
mark('古い対応表の削除')
anim = cmds.createNode('animCurveUU', name=name)
mark('対応表ノードの作成')
for pos, val in zip(positions, values):
cmds.setKeyframe(anim, float=pos, value=val,
inTangentType='linear', outTangentType='linear')
mark('対応表のキーの設定')
cmds.connectAttr(motion_path + '.uValue', anim + '.input', force=True)
mark('uValue から対応表への接続')
return anim
def _uvalue_keys(motion_path):
"""uValue のキーの (時間, 値) の一覧を返す。キーのカーブがなければ (None, [])"""
anims = cmds.listConnections(motion_path + '.uValue', source=True, destination=False,
type='animCurve') or []
if not anims:
return None, []
times = cmds.keyframe(anims[0], query=True, timeChange=True) or []
values = cmds.keyframe(anims[0], query=True, valueChange=True) or []
return anims[0], list(zip(times, values))
def _format_keys(keys):
"""キーの一覧を、ログ用の短い文字列にする"""
if not keys:
return 'なし'
items = ['{:g}F={:g}'.format(t, v) for t, v in keys[:6]]
more = ' …(計 {} 個)'.format(len(keys)) if len(keys) > 6 else ''
return '{} 個 [{}]{}'.format(len(keys), ', '.join(items), more)
def _foreign_sources(plug, own_exp):
"""
plug につながっている接続元のうち、自作のエクスプレッション(own_exp)以外を返す。
エクスプレッションは、単位変換ノード(unitConversion)を間に挟んでつながる。
変換ノードの先が自作のエクスプレッションなら、自作の接続とみなす。
"""
foreign = []
for n in cmds.listConnections(plug, source=True, destination=False) or []:
if n == own_exp:
continue
if cmds.nodeType(n) == 'unitConversion':
ups = cmds.listConnections(n + '.input', source=True, destination=False) or []
if not ups or all(u == own_exp for u in ups):
continue
foreign.extend(u for u in ups if u != own_exp)
continue
foreign.append(n)
return foreign
def _delete_orphan_conversions(plug):
"""元のエクスプレッションを消したあとに残った、入力のない単位変換ノードを削除する"""
for n in cmds.listConnections(plug, source=True, destination=False) or []:
if cmds.nodeType(n) == 'unitConversion' and \
not cmds.listConnections(n + '.input', source=True, destination=False):
cmds.delete(n)
# ======================================================
# エクスプレッション文の生成(純粋な文字列処理)
# ======================================================
def build_expression(wheels, mode, car, motion_path, dist_attr,
curve_len, curve_info, use_dir, comments, invert_all=False,
uvalue_scale=1.0, u_range=None, lookup=None):
"""
wheels : [{'mesh','roll','axis','radius','invert'(任意)}, ...]
'invert' が True のタイヤは、回転方向を反転する
invert_all : True なら全タイヤの回転方向を反転する($dir にマイナスを付ける)
uvalue_scale : シーンの単位による uValue の倍率(mm のシーンなら 10)。1.0 以外なら、
uValue をこの値で割って、元の割合(0〜1)に戻す
u_range : (最小, 最大)。指定すると、uValue をこの範囲で 0〜1 に換算して使う(簡易)
lookup : 対応表(animCurveUU)のノード名。指定すると、uValue から距離を、表で求める
mode : 'motion' or 'transform'
curve_info : curveInfo ノード名(指定時は arcLength を参照)。None なら curve_len の数値を埋め込む
"""
def c(text):
return (' // ' + text) if comments else ''
lines = []
if comments:
lines.append('// ===== 入力値(ここを編集) =====')
if mode == 'motion' and not lookup:
if curve_info:
lines.append('float $curveLen = {}.arcLength;'.format(curve_info)
+ c('カーブの長さ(curveInfo から自動取得)'))
else:
lines.append('float $curveLen = {:.4f};'.format(curve_len) + c('カーブの長さ'))
if u_range:
lines.append('float $uMin = {:.4f};'.format(u_range[0]) + c('uValue の最小(パスの始点)'))
lines.append('float $uMax = {:.4f};'.format(u_range[1]) + c('uValue の最大(パスの終点)'))
for w in wheels:
lines.append('float $r_{s} = {r:.4f};'.format(s=_safe(w['mesh']), r=w['radius'])
+ c('{} の半径'.format(w['mesh'])))
lines.append('')
if comments:
lines.append('// ===== 計算 =====')
if mode == 'motion':
if lookup:
lines.append('float $dist = {}.output;'.format(lookup)
+ c('進んだ距離 = uValue から距離への対応表(カーブの形から作成)'))
elif u_range:
lines.append('float $dist = ({mp}.uValue - $uMin) / ($uMax - $uMin) * $curveLen;'.format(
mp=motion_path)
+ c('進んだ距離 = (uValue を 0〜1 に換算) × カーブの長さ'))
elif abs(uvalue_scale - 1.0) > 1e-9:
lines.append('float $dist = {mp}.uValue / {s:g} * $curveLen;'.format(
mp=motion_path, s=uvalue_scale)
+ c('進んだ距離 = 進行割合 × カーブの長さ(uValue は距離型で、シーンの単位に変換されて'
'読まれるため、{s:g} で割って割合に戻す)'.format(s=uvalue_scale)))
else:
lines.append('float $dist = {}.uValue * $curveLen;'.format(motion_path)
+ c('進んだ距離 = 進行割合 × カーブの長さ'))
else:
lines.append('float $dist = {};'.format(dist_attr) + c('進んだ距離 = 親グループの移動量'))
dir_src = '{}.wheelDir'.format(car) if use_dir else '1'
if invert_all:
dir_src = '-' + dir_src
dir_note = '前進=1 / 向き切り替え後=-1' + (' ※全タイヤの回転方向を反転中' if invert_all else '')
lines.append('float $dir = {};'.format(dir_src) + c(dir_note))
for w in wheels:
inv = bool(w.get('invert'))
lines.append('{roll}.rotate{ax} = {sg}$dir * 360 * ($dist / (2 * {pi} * $r_{s}));'.format(
roll=w['roll'], ax=w['axis'].upper(), sg='-' if inv else '',
pi=PI, s=_safe(w['mesh']))
+ c('距離 ÷ 円周 × 360' + (' ※このタイヤは回転方向を反転' if inv else '')))
return '\n'.join(lines)
# ======================================================
# GUI
# ======================================================
class WheelRigTool(object):
def __init__(self):
self.f = {} # 主要な UI 部品
self.rows = [] # タイヤ行
self.wheel_col = None
# ---------- ウィンドウ構築 ----------
def show(self):
if cmds.window(WIN_NAME, exists=True):
cmds.deleteUI(WIN_NAME)
cmds.window(WIN_NAME, title='Car Wheel Rig Tool', widthHeight=(680, 860))
cmds.scrollLayout(childResizable=True)
cmds.columnLayout(adjustableColumn=True, rowSpacing=6)
self._build_basic()
self._build_wheels()
self._build_actions()
self._build_result()
cmds.showWindow(WIN_NAME)
self._on_mode_changed()
self._log('①「測定」を押すと、半径・カーブ長・エクスプレッションが入ります。')
def _text_row(self, label, default='', pick=True, extra=None):
cmds.rowLayout(numberOfColumns=3, adjustableColumn=2,
columnWidth=[(1, 112), (3, 74)])
cmds.text(label=label, align='right', width=106)
tf = cmds.textField(text=default)
if extra:
cmds.button(label=extra[0], width=70, command=extra[1])
elif pick:
cmds.button(label='◀ 選択', width=70, command=lambda *_: self._pick(tf))
else:
cmds.text(label='')
cmds.setParent('..')
return tf
def _build_basic(self):
cmds.frameLayout(label='1. 基本設定', collapsable=True, marginWidth=8, marginHeight=6)
cmds.columnLayout(adjustableColumn=True, rowSpacing=4)
self.f['car'] = self._text_row('車のグループ', DEFAULT_CAR)
cmds.rowLayout(numberOfColumns=2, columnWidth=[(1, 112)])
cmds.text(label='車軸(転がり軸)', align='right', width=106)
self.f['axis'] = cmds.optionMenu()
for a in ('x', 'y', 'z'):
cmds.menuItem(label=a)
cmds.setParent('..')
self.f['mode'] = cmds.radioButtonGrp(
numberOfRadioButtons=2,
labelArray2=['Motion Path (uValue × カーブの長さ)', 'Transform (移動量をそのまま距離に)'],
select=1, columnWidth2=(300, 300),
onCommand1=self._on_mode_changed, onCommand2=self._on_mode_changed)
# Motion Path 用
self.f['motion_box'] = cmds.columnLayout(adjustableColumn=True, rowSpacing=4)
self.f['mp'] = self._text_row('motionPath', '', pick=False,
extra=('自動検出', self._on_auto_detect))
self.f['curve'] = self._text_row('カーブ', '')
self.f['umode'] = cmds.radioButtonGrp(
numberOfRadioButtons=3,
vertical=True,
label='uValue の換算',
labelArray3=['割合(0〜1)のまま使う ※Parametric Length が ON のとき',
'範囲で割って 0〜1 にする(簡易) ※Parametric Length が OFF のとき',
'対応表で正確に換算する ※Parametric Length が OFF のとき(推奨)'],
select=1, columnWidth=[(1, 100), (2, 480)])
self.f['live'] = cmds.checkBox(
label='カーブの長さを curveInfo で自動追従させる(curveInfo ノードを作成します)',
value=False)
cmds.setParent('..')
# Transform 用
self.f['tr_box'] = cmds.columnLayout(adjustableColumn=True, rowSpacing=4)
self.f['dist_attr'] = self._text_row('距離に使う属性', DEFAULT_CAR + '.translateZ', pick=False)
cmds.setParent('..')
self.f['add_dir'] = cmds.checkBox(
label='wheelDir 属性(前進=1 / 切り替え後=-1)を車のグループに追加する', value=True)
self.f['invert_all'] = cmds.checkBox(
label='回転方向を反転する(全タイヤ) ※前進でタイヤが逆に回る場合にチェック', value=False)
self.f['comments'] = cmds.checkBox(
label='エクスプレッションにコメントを付ける', value=True)
cmds.setParent('..')
cmds.setParent('..')
def _build_wheels(self):
cmds.frameLayout(label='2. タイヤ', collapsable=True, marginWidth=8, marginHeight=6)
cmds.columnLayout(adjustableColumn=True, rowSpacing=2)
widths = [(1, 30), (2, 130), (3, 40), (4, 130), (5, 40), (6, 80), (7, 90), (8, 50)]
cmds.rowLayout(numberOfColumns=8, columnWidth=widths)
for t in ('使用', 'メッシュ(測定対象)', '', '回転ロケーター(_r)', '', '半径', '直径', '反転'):
cmds.text(label=t, align='left')
cmds.setParent('..')
self.wheel_col = cmds.columnLayout(adjustableColumn=True, rowSpacing=2)
for mesh, roll in DEFAULT_WHEELS:
self._add_row(mesh, roll)
cmds.setParent('..')
cmds.button(label='+ タイヤ行を追加', command=lambda *_: self._add_row())
cmds.setParent('..')
cmds.setParent('..')
def _add_row(self, mesh_name='', roll_name=''):
widths = [(1, 30), (2, 130), (3, 40), (4, 130), (5, 40), (6, 80), (7, 90), (8, 50)]
cmds.rowLayout(numberOfColumns=8, columnWidth=widths, parent=self.wheel_col)
use = cmds.checkBox(label='', value=True)
mesh = cmds.textField(text=mesh_name)
cmds.button(label='◀', command=lambda *_: self._pick(mesh))
roll = cmds.textField(text=roll_name)
cmds.button(label='◀', command=lambda *_: self._pick(roll))
rad = cmds.floatField(value=0.0, precision=4,
changeCommand=lambda *_: self._refresh_dia(rad, dia))
dia = cmds.text(label='-', align='left')
inv = cmds.checkBox(label='', value=False)
cmds.setParent('..')
self.rows.append({'use': use, 'mesh': mesh, 'roll': roll, 'rad': rad, 'dia': dia,
'inv': inv})
def _build_actions(self):
cmds.frameLayout(label='3. 操作', collapsable=False, marginWidth=8, marginHeight=6)
cmds.columnLayout(adjustableColumn=True, rowSpacing=6)
cmds.rowColumnLayout(numberOfColumns=2, columnWidth=[(1, 322), (2, 322)],
columnSpacing=[(2, 6)], rowSpacing=[(2, 6)])
cmds.button(label='① 測定', height=32, backgroundColor=(0.35, 0.5, 0.65),
command=self.on_measure)
cmds.button(label='② 式を再生成', height=32, command=self.on_generate)
cmds.button(label='③ Expression を作成/更新', height=32,
backgroundColor=(0.4, 0.6, 0.4), command=self.on_create)
cmds.button(label='コピー', height=32, command=self.on_copy)
cmds.setParent('..')
cmds.setParent('..')
cmds.setParent('..')
def _build_result(self):
cmds.frameLayout(label='4. 結果', collapsable=False, marginWidth=8, marginHeight=6)
cmds.columnLayout(adjustableColumn=True, rowSpacing=4)
cmds.rowLayout(numberOfColumns=3, columnWidth=[(1, 112), (2, 130)])
cmds.text(label='カーブの長さ', align='right', width=106)
self.f['curveLen'] = cmds.floatField(value=0.0, precision=4, width=120)
cmds.text(label=' (直接編集できます。編集後は ② を押す)', align='left')
cmds.setParent('..')
cmds.text(label='ログ', align='left')
self.f['log'] = cmds.scrollField(editable=False, wordWrap=True, height=150)
cmds.text(label='エクスプレッション(直接編集できます。③ でこの内容が作成されます)', align='left')
self.f['expr'] = cmds.scrollField(editable=True, wordWrap=False, height=260,
font='fixedWidthFont')
cmds.setParent('..')
cmds.setParent('..')
# ---------- 小さな補助 ----------
def _pick(self, field):
sel = cmds.ls(selection=True)
if sel:
cmds.textField(field, edit=True, text=sel[0])
else:
cmds.warning('オブジェクトが選択されていません。')
def _refresh_dia(self, rad_field, dia_label):
r = cmds.floatField(rad_field, query=True, value=True)
cmds.text(dia_label, edit=True, label='{:.4f}'.format(r * 2.0) if r > 0 else '-')
def _on_mode_changed(self, *_):
motion = cmds.radioButtonGrp(self.f['mode'], query=True, select=True) == 1
cmds.columnLayout(self.f['motion_box'], edit=True, enable=motion)
cmds.columnLayout(self.f['tr_box'], edit=True, enable=not motion)
def _on_auto_detect(self, *_):
car = cmds.textField(self.f['car'], query=True, text=True).strip()
mp = _find_motion_path(car)
if not mp:
self._log('motionPath を自動検出できませんでした。手動で入力してください。')
return
cmds.textField(self.f['mp'], edit=True, text=mp)
curve = _find_curve(mp)
if curve:
cmds.textField(self.f['curve'], edit=True, text=curve)
self._log('検出: motionPath={} / curve={}'.format(mp, curve))
def _log(self, msg, clear=False):
cur = '' if clear else cmds.scrollField(self.f['log'], query=True, text=True)
text = (cur + '\n' if cur else '') + msg
cmds.scrollField(self.f['log'], edit=True, text=text)
def _settings(self):
f = self.f
return {
'car': cmds.textField(f['car'], query=True, text=True).strip(),
'axis': cmds.optionMenu(f['axis'], query=True, value=True),
'mode': 'motion' if cmds.radioButtonGrp(f['mode'], query=True, select=True) == 1
else 'transform',
'mp': cmds.textField(f['mp'], query=True, text=True).strip(),
'curve': cmds.textField(f['curve'], query=True, text=True).strip(),
'live': cmds.checkBox(f['live'], query=True, value=True),
'umode': cmds.radioButtonGrp(f['umode'], query=True, select=True),
'dist_attr': cmds.textField(f['dist_attr'], query=True, text=True).strip(),
'add_dir': cmds.checkBox(f['add_dir'], query=True, value=True),
'comments': cmds.checkBox(f['comments'], query=True, value=True),
'invert_all': cmds.checkBox(f['invert_all'], query=True, value=True),
'curve_len': cmds.floatField(f['curveLen'], query=True, value=True),
}
def _rows_data(self):
out = []
for r in self.rows:
if not cmds.checkBox(r['use'], query=True, value=True):
continue
out.append({
'mesh': cmds.textField(r['mesh'], query=True, text=True).strip(),
'roll': cmds.textField(r['roll'], query=True, text=True).strip(),
'radius': cmds.floatField(r['rad'], query=True, value=True),
'invert': cmds.checkBox(r['inv'], query=True, value=True),
'rad_field': r['rad'],
'dia_label': r['dia'],
})
return out
# ---------- ① 測定 ----------
def on_measure(self, *_):
self._log('', clear=True)
s = self._settings()
rows = self._rows_data()
if not rows:
self._log('使用するタイヤがありません。')
return
# 車のグループ
if not s['car'] or not cmds.objExists(s['car']):
self._log('[警告] 車のグループが見つかりません: {}'.format(s['car']))
elif len(cmds.ls(s['car'])) > 1:
self._log('[警告] "{}" と同名のオブジェクトが複数あります。'
'エクスプレッションがエラーになるので名前を変更してください。'.format(s['car']))
# タイヤの測定
measured = []
self._log('【タイヤの測定結果】(車軸中心から最も遠い頂点までを半径とする)')
for r in rows:
if not r['mesh'] or not cmds.objExists(r['mesh']):
self._log(' [エラー] メッシュが見つかりません: "{}"'.format(r['mesh']))
continue
rot = [cmds.getAttr('{}.rotate{}'.format(r['mesh'], a)) for a in 'XYZ']
if any(abs(v) > 1e-4 for v in rot):
self._log(' [警告] {} の Rotate が 0 ではありません {}。測定がずれる可能性があります。'
.format(r['mesh'], rot))
radius = measure_wheel_radius(r['mesh'], s['axis'])
cmds.floatField(r['rad_field'], edit=True, value=radius)
self._refresh_dia(r['rad_field'], r['dia_label'])
measured.append((r['mesh'], radius))
self._log(' {}: 直径 {:.4f} / 半径 {:.4f} / 円周 {:.4f}'.format(
r['mesh'], radius * 2.0, radius, 2.0 * PI * radius))
# Motion Path / カーブ
mp, curve = s['mp'], s['curve']
if s['mode'] == 'motion':
if not mp:
mp = _find_motion_path(s['car'])
if mp:
cmds.textField(self.f['mp'], edit=True, text=mp)
if mp and not curve:
curve = _find_curve(mp)
if curve:
cmds.textField(self.f['curve'], edit=True, text=curve)
self._log('')
self._log('【カーブの長さ】')
curve_len = None
if curve and cmds.objExists(curve):
curve_len, warn = _curve_length(curve)
cmds.floatField(self.f['curveLen'], edit=True, value=curve_len)
self._log(' {} : {:.4f}'.format(curve, curve_len))
if warn:
self._log(' [警告] ' + warn)
for mesh, radius in measured:
self._log(' → {} は、カーブ全長を走ると約 {:.2f} 回転'.format(
mesh, curve_len / (2.0 * PI * radius)))
else:
self._log(' カーブを取得できませんでした。motionPath / カーブ欄を確認してください。')
if s['mode'] == 'motion' and mp and cmds.objExists(mp):
for line in self._fraction_report(mp):
self._log(line)
fraction_on = cmds.getAttr(mp + '.fractionMode')
if not fraction_on and s['umode'] == 1:
cmds.radioButtonGrp(self.f['umode'], edit=True, select=3)
self._log(' → Parametric Length が OFF のため、「uValue の換算」を'
'「対応表で正確に換算する」に切り替えました。')
elif fraction_on and s['umode'] != 1:
cmds.radioButtonGrp(self.f['umode'], edit=True, select=1)
self._log(' → Parametric Length が ON のため、「uValue の換算」を'
'「割合(0〜1)のまま使う」に切り替えました。')
self._log('')
self.on_generate()
def _fraction_report(self, mp):
"""
Parametric Length(Fraction Mode)の状態と、uValue のキーの範囲を調べ、表示用の行を返す。
OFF のままだと uValue がカーブの長さの割合にならず、タイヤが何倍もの速さで回る。
"""
lines = []
on = cmds.getAttr(mp + '.fractionMode')
lines.append(' motionPath: {} / Parametric Length(Fraction Mode): {}'.format(
mp, 'ON' if on else 'OFF'))
# uValue は距離型のため、画面やキーの値は、シーンの単位に変換されている
scale = _ui_units_per_cm()
unit = cmds.currentUnit(query=True, linear=True)
if abs(scale - 1.0) > 1e-9:
lines.append(' [注意] シーンの単位が {} です(cm ではありません)。'
'uValue は画面上で {:g} 倍の値で表示されます。'.format(unit, scale))
lines.append(' 式では {:g} で割って、元の割合(0〜1)に戻しています。'.format(scale))
_, key_list = _uvalue_keys(mp)
lines.append(' uValue のキー: {}'.format(_format_keys(key_list)))
keys = [k / scale for k in
(cmds.keyframe(mp + '.uValue', query=True, valueChange=True) or [])]
if keys:
lines.append(' uValue のキーの範囲(割合に直した値): {:.3f} 〜 {:.3f}'.format(
min(keys), max(keys)))
if not on:
lines.append(' [注意] Parametric Length が OFF です(uValue はカーブのパラメーター値)。')
lines.append(' 「uValue の換算」を「対応表」にすると、キー(アニメーション)を'
'変えずに、正確に計算できます。')
elif keys and max(keys) > 1.0 + 1e-6:
lines.append(' [警告] uValue のキーが 1 を超えています。Fraction Mode では、'
'0〜1 がカーブの全長です。')
lines.append(' 車は 1 でパスの終点に着いて止まり、タイヤだけが回り続けます。')
lines.append(' キーがパラメーター値で打たれている場合は、Parametric Length を OFF に戻し、'
'「uValue の換算」を「対応表」にしてください。')
return lines
# ---------- ② 式を生成 ----------
def _collect_for_generate(self):
"""入力を検証して (設定, タイヤ, エラー一覧) を返す"""
s = self._settings()
rows = self._rows_data()
errors = []
if not rows:
errors.append('使用するタイヤがありません。')
for r in rows:
if not r['roll'] or not cmds.objExists(r['roll']):
errors.append('回転ロケーターが見つかりません: "{}"'.format(r['roll']))
if r['radius'] <= 0:
errors.append('{} の半径が 0 です。① 測定を実行するか、直接入力してください。'
.format(r['mesh']))
if s['mode'] == 'motion':
if not s['mp'] or not cmds.objExists(s['mp']):
errors.append('motionPath が見つかりません: "{}"'.format(s['mp']))
elif s['umode'] in (2, 3) and cmds.getAttr(s['mp'] + '.fractionMode'):
errors.append('「uValue の換算」の2・3は、Parametric Length が OFF のとき用です。'
'ON のときは「割合(0〜1)のまま使う」を選んでください。')
if s['umode'] in (2, 3) or s['live']:
if not s['curve'] or not cmds.objExists(s['curve']):
errors.append('カーブが見つかりません: "{}"'.format(s['curve']))
if s['umode'] != 3 and not s['live'] and s['curve_len'] <= 0:
errors.append('カーブの長さが 0 です。① 測定を実行するか、直接入力してください。')
else:
if not s['dist_attr'] or not cmds.objExists(s['dist_attr']):
errors.append('距離に使う属性が見つかりません: "{}"'.format(s['dist_attr']))
else:
owner = s['dist_attr'].split('.')[0]
history = cmds.listHistory(owner, pruneDagObjects=True) or []
if any(cmds.nodeType(n) == 'motionPath' for n in history):
errors.append('{} は Motion Path で動かされているため、Transform 用は使えません。'
'「距離の求め方」を Motion Path にしてください。'.format(owner))
wheels = [{'mesh': r['mesh'], 'roll': r['roll'], 'axis': s['axis'],
'radius': r['radius'], 'invert': r['invert']} for r in rows]
return s, wheels, errors
def on_generate(self, *_):
s, wheels, errors = self._collect_for_generate()
if errors:
for e in errors:
self._log('[エラー] ' + e)
return False
curve_info = None
if s['mode'] == 'motion' and s['live'] and s['umode'] != 3:
curve_info = _curve_info_name(_curve_shape(s['curve']))
u_range, lookup = None, None
if s['mode'] == 'motion' and s['umode'] == 2:
pmin, pmax = _param_range(s['curve'])
k = _ui_units_per_cm() # エクスプレッションが読む uValue は、シーンの単位に変換される
u_range = (pmin * k, pmax * k)
elif s['mode'] == 'motion' and s['umode'] == 3:
lookup = LOOKUP_NAME
use_dir = s['add_dir'] or (
cmds.objExists(s['car']) and cmds.attributeQuery('wheelDir', node=s['car'], exists=True))
text = build_expression(
wheels, s['mode'], s['car'], s['mp'], s['dist_attr'],
s['curve_len'], curve_info, use_dir, s['comments'], s['invert_all'],
uvalue_scale=_ui_units_per_cm(), u_range=u_range, lookup=lookup)
cmds.scrollField(self.f['expr'], edit=True, text=text)
self._log('エクスプレッションを生成しました。内容を確認してください。')
return True
# ---------- ③ 作成/更新 ----------
def on_create(self, *_):
text = cmds.scrollField(self.f['expr'], query=True, text=True).strip()
if not text:
self._log('[エラー] エクスプレッションが空です。先に ① 測定 か ② 式を再生成 を押してください。')
return
s, wheels, errors = self._collect_for_generate()
if errors:
for e in errors:
self._log('[エラー] ' + e)
return
# uValue のキーの見張り(作成の前後で、キーが変わっていないか確認する)
keys_before = None
if s['mp'] and cmds.objExists(s['mp']):
_, keys_before = _uvalue_keys(s['mp'])
self._log('uValue のキー(作成前): {}'.format(_format_keys(keys_before)))
last = [keys_before]
def mark(label):
"""各処理のあとで uValue のキーを調べ、変わっていたらログに出す(原因の追跡用)"""
if keys_before is None:
return
_, now = _uvalue_keys(s['mp'])
if now != last[0]:
self._log('[追跡] 「{}」のあとで、uValue のキーが変わりました: {} → {}'.format(
label, _format_keys(last[0]), _format_keys(now)))
last[0] = now
cmds.undoInfo(openChunk=True)
try:
# 回転属性に、既存のエクスプレッション以外の接続やキーがないか確認
for w in wheels:
plug = '{}.rotate{}'.format(w['roll'], w['axis'].upper())
srcs = _foreign_sources(plug, EXP_NAME)
if srcs:
raise RuntimeError('{} には既に接続やキーがあります ({})。'
'先に削除してください。'.format(plug, ', '.join(srcs)))
mark('接続の確認')
# wheelDir 属性
if s['add_dir'] and cmds.objExists(s['car']) and \
not cmds.attributeQuery('wheelDir', node=s['car'], exists=True):
cmds.addAttr(s['car'], longName='wheelDir', attributeType='long',
minValue=-1, maxValue=1, defaultValue=1, keyable=True)
self._log('{} に wheelDir 属性を追加しました。'.format(s['car']))
# 対応表を使う設定なのに、式が対応表を参照していない場合は止める
if s['mode'] == 'motion' and s['umode'] == 3 and LOOKUP_NAME not in text:
raise RuntimeError('式が対応表を使っていません。先に ② 式を再生成 を押してください。')
# curveInfo
if s['mode'] == 'motion' and s['live'] and s['umode'] != 3:
shape = _curve_shape(s['curve'])
planned = _curve_info_name(shape)
actual = _get_or_create_curve_info(shape)
if actual != planned:
text = text.replace(planned, actual)
cmds.scrollField(self.f['expr'], edit=True, text=text)
self._log('curveInfo: {}'.format(actual))
# 残っている単位変換ノードの片付け(エクスプレッション自体は削除しない)
for w in wheels:
_delete_orphan_conversions('{}.rotate{}'.format(w['roll'], w['axis'].upper()))
mark('残った変換ノードの削除')
# 対応表を使わない設定でも、以前に作った対応表は削除しない
# (削除すると uValue のキーが消える現象が起きたため。残っていても、害はありません)
if not (s['mode'] == 'motion' and s['umode'] == 3) and cmds.objExists(LOOKUP_NAME):
self._log('使わない対応表 {} は、そのまま残しています(式では使われません)。'.format(
LOOKUP_NAME))
# uValue → 進んだ距離 の対応表(uValue のキーは変更しない)
if s['mode'] == 'motion' and s['umode'] == 3:
anim = build_distance_lookup(s['mp'], s['curve'], trace=mark)
self._log('対応表: {}(uValue → 進んだ距離。uValue のキーは変更していません)'.format(anim))
result = _apply_expression(text)
mark('エクスプレッションの書き換え' if result == 'updated' else 'エクスプレッションの作成')
self._log('エクスプレッション "{}" を{}しました。'.format(
EXP_NAME, '書き換え' if result == 'updated' else '作成'))
self._log('確認: Windows > Animation Editors > Expression Editor')
if keys_before is not None:
self._check_uvalue_keys(s['mp'], keys_before)
if s['mode'] == 'motion':
report = self._fraction_report(s['mp'])
if any('[警告]' in line for line in report):
for line in report:
self._log(line)
except Exception as e:
self._log('[エラー] ' + str(e))
cmds.warning(str(e))
finally:
cmds.undoInfo(closeChunk=True)
def _check_uvalue_keys(self, mp, keys_before):
"""作成の前後で uValue のキーが変わっていないか確認し、失われたキーは元に戻す"""
_, keys_after = _uvalue_keys(mp)
self._log('uValue のキー(作成後): {}'.format(_format_keys(keys_after)))
def has(keys, key):
return any(abs(key[0] - k[0]) < 1e-6 and abs(key[1] - k[1]) < 1e-6 for k in keys)
lost = [k for k in keys_before if not has(keys_after, k)]
if not lost:
return
self._log('[警告] uValue のキーが失われました: {}'.format(
', '.join('{:g}F={:g}'.format(t, v) for t, v in lost)))
try:
for t, v in lost:
cmds.setKeyframe(mp, attribute='uValue', time=t, value=v)
self._log(' 失われたキーを元に戻しました(タンジェントは標準の設定になります)。')
self._log(' どの操作でキーが消えたかを、教えてください。')
except Exception as e:
self._log(' 元に戻せませんでした: {}'.format(e))
# ---------- コピー ----------
def on_copy(self, *_):
text = cmds.scrollField(self.f['expr'], query=True, text=True)
if not text.strip():
self._log('[エラー] コピーするエクスプレッションがありません。')
return
qt = None
try:
from PySide6 import QtWidgets as qt
except ImportError:
try:
from PySide2 import QtWidgets as qt
except ImportError:
qt = None
if qt:
qt.QApplication.clipboard().setText(text)
self._log('エクスプレッションをクリップボードにコピーしました。')
else:
self._log('[警告] クリップボードを使えませんでした。'
'エクスプレッション欄を全選択(Ctrl+A)してコピーしてください。')
def show():
WheelRigTool().show()
# Script Editor で実行するとウィンドウが開く
if __name__ == '__main__':
show()
画面の項目
| 項目 | 設定内容 |
|---|---|
| 車のグループ | Motion Path が付いているグループ(Car_GP) |
| 車軸(転がり軸) | タイヤが転がる軸。通常は x |
| 距離の求め方 | Motion Path(uValue × カーブの長さ)か、Transform(移動量をそのまま距離に使う) |
| motionPath / カーブ | 「自動検出」で入ります |
| curveInfo で自動追従 | ONにすると、カーブの長さを curveInfo から取得します |
| wheelDir 属性を追加 | ONにすると、Car_GP に前進・後退の切り替え用の属性を追加します |
| 回転方向を反転(全タイヤ) | 前進でタイヤが逆に回る場合にONにします |
| タイヤ表 | メッシュ、回転ロケーター(_r)、半径、直径、タイヤごとの反転 |
| uValue の換算 | Motion Path のとき、Parametric Length の状態に合わせて選びます。①測定で自動で切り替わります。 |
操作の流れ
1.①測定:タイヤの半径と直径、カーブの長さが入力欄に入り、エクスプレッションが生成されます。
2.確認:ログと入力欄の数値を確認します。数値は直接編集できます。ログには、カーブ全長を走ったときの回転数も出るので、数値が妥当かの目安になります。
3.②式を再生成:数値やチェックを変えたときに押します。手で編集した式は、作り直されて消えます。
4.③Expression を作成/更新:エクスプレッション CarWheel_rollExp が作られます。何度押しても、既存のエクスプレッションの中身を書き換えるだけです。
タイヤの半径の測り方
半径は、車軸の中心から一番遠い頂点までの距離です。タイヤに溝があっても、路面に接するトレッドの外側が半径になるので、転がりの計算に使いたい値と一致します。後輪のように2輪をまとめたメッシュでも、車軸方向を除いた2方向で測るので、問題なく測れます。
Transform で動かす場合
Motion Path を使わず、Car_GP の移動(translateZ など)で車を動かす場合は、ツールの「距離の求め方」を Transform にします。「距離に使う属性」に、進む方向の属性(Car_GP.translateZ など)を指定すると、移動量がそのまま距離になります。

ただし、この方法は、車が曲がりながら進む場合には使えません。移動量と実際に走った距離が一致しなくなるためです。直線に近い動きで使ってください。
また、Car_GP がMotion Pathで動いている場合は、translateZ がパス上の位置になるため、Transform用は使えません。ツールは、この場合、エラーを出して止めます。Motion Pathのシーンでは、「距離の求め方」をMotion Pathにしてください。
アニメーションが確定したら
アニメーションが確定したら、Bake してください。エクスプレッションで動いているタイヤの回転を、キーに変換します。対象は、_r ロケーターの回転です。
ハマりどころとTips
実際に試して引っかかった点と、その対処をまとめます。
Motion Path で、タイヤが何倍もの速さで回る:uValue のキーが、0〜10のように、1を超えていないか確認します。Parametric Length がOFFなのに、「uValue の換算」が「割合(0〜1)のまま使う」になっていると、距離が何倍にも計算されます。OFFのときは、「対応表」にします。
回転が滑って見える:半径がずれています。リグにスケールがかかっている場合は、見た目ではなく実際の寸法で半径を測り直してください。ポリゴン数が少ない場合は、半径を数%下げます。
最後に
今回は、Claudeを活用して、Maya用のタイヤ自動回転ツールを作成しました。実現したい機能をテキストで伝え、対話を重ねながら形にできるため、ツール作成にも気軽に取り組めるようになりました。
社内でもClaudeの活用を進めており、日々の制作にAIを取り入れています。今後も、作成したツールの動作を確認しながら実務に活用し、作業の効率化や表現の幅を広げることにつなげていきたいと思います。
Go to list of users who liked
0
Register as a new user and use Qiita more conveniently
- You get articles that match your needs
- You can efficiently read back useful information
- You can use dark theme
来源:Qiita · Unity 开发社区 · qiita.com



