[maya] melでリファレンスをオブジェクトとして読み込み

[Maya] Loading a reference as an object using MEL

Reference_to_Object.mel

global proc PrefixHierarchyNames2(string $filename)
{
    //string $roots[] = `ls -sl -dag -long`;
    string $roots[] = `ls -sl -dag`;
    print("-----------------$roots--------------------\n");
    print($roots);
    print("-----------------$roots--------------------\n");
    if (size($roots) == 0)
    {
        warning "対象のルートノードを選択してください。";
        return;
    }
    
    string $nodes[];
    
    // 選択されたルート
    for ($root in $roots)
    {
        $nodes[size($nodes)] = $root;

        // 子孫を取得
        string $children[] = `listRelatives
            -allDescendents
            -fullPath
            -type "transform"
            $root`;

        for ($child in $children)
            $nodes[size($nodes)] = $child;
    }

    // 深い階層から処理  //for ($node in $roots)
    for ($node in $nodes)
    {//////////////////////////////////////////////////////////
    
        if (!`objExists $node`)
            continue;
    
    
        string $NodeNameArr[];
        $NodeNameArr = stringToStringArray($node, ":");
    
        //string $NewName = $NodeNameArr[1];
        string $NewName = substituteAllString($node, $NodeNameArr[0]+":", "");
        print("$node= "+$node+" >>> $NewName= "+$NewName+"\n");
        print("Hit $NodeNameArr[0]= "+$NodeNameArr[0]+" === $filename="+$filename+"\n");
        if($NodeNameArr[0] == $filename||"|"+$NodeNameArr[0] == $filename){
            
            //lockNode -l off $node;
            rename $node $NewName;
            
        }
    
    }//////////////////////////////////////////////////////////
}


proc Reference_to_Object(){
    string $refs[] = `file -q -list`;
    string $scenePath = `file -q -sceneName`;
    print($scenePath);
    for ($refFile in $refs)
    {
        string $ext = fileExtension($refFile);

        if($ext == "mb" ||$ext == "ma"){
            print("$refFile = "+$refFile+"\n");
            print("$ext = "+$ext+"\n");
            print("$refFile= "+$refFile+"\n");
            //string $filename = `basename $refFile ""`;
            string $dotExt = "."+$ext;
            string $filename = basename($refFile, $dotExt);
            print("$filename= "+$filename+"\n");
            if($scenePath==$refFile){
                print("シーンのファイル名と同じなので却下"+"\n");
            }else{
                //refNodeの取得
                string $refNode = `referenceQuery -referenceNode $refFile`;
                print("$refNode= "+$refNode+"\n");
                //file -reference -referenceNode "pc001aRN" $refFile;
                //file -importReference $refNode;
                //リファレンスをオブジェクトとして読み込み
                file -importReference $refFile;
 
                {//////////////////////////////////////////////////////////
                    string $topNodes[] = `ls -assemblies`;
                    print($topNodes);
                    select -cl ;
                    for ($nodeName in $topNodes){
                        if (gmatch($nodeName, "*:*") == 0) {
                            print ("ヒット(コロンなし): " + $nodeName + "\n");
                        }else{
                            print ("ヒット(コロンあり): " + $nodeName + "\n");
                            select -r $nodeName ;
                            PrefixHierarchyNames2($filename);
                        }
                    }
                    
                }//////////////////////////////////////////////////////////
             //  
            }//else end/////////////////////////////////////////////////

        }
    }
}
Reference_to_Object();

[python] Autodesk Python FBXSDKでFBX MotionViewer作ってみた

Autodesk Python FBXSDKは以下

https://aps.autodesk.com/developer/overview/fbx-sdk

Autodesk Python FBXSDK をFBX SDK 2020.3.11 Python (exe – 2800KB)を上記からダウンロード

exeに沿ってインストールする。

で早速ソースコード

motion_viewer.py

"""FBX SDK (Python) で読み込んだモーションを PyVista + Qt で再生するシンプルなビューア

使い方:
    python motion_viewer.py [motion.fbx | motions_dir]
    ウィンドウに FBX ファイル / フォルダーをドラッグ&ドロップするとリストに追加される

操作:
    リスト     : クリック / Up/Down でアニメーション切り替え
    Space      : 再生 / 一時停止
    Left/Right : 1 フレーム戻る / 進む
    M          : メッシュ表示 ON / OFF
    スライダー : フレーム移動
"""
import os
import sys
import time
from pathlib import Path

os.environ.setdefault("QT_API", "pyside6")  # Qt は LGPL の PySide6 を使う (PyQt は GPL)

import fbx
import numpy as np
import pyvista as pv
from pyvistaqt import QtInteractor
from qtpy import QtCore, QtGui, QtWidgets


def load_scene(path):
    manager = fbx.FbxManager.Create()
    manager.SetIOSettings(fbx.FbxIOSettings.Create(manager, fbx.IOSROOT))
    importer = fbx.FbxImporter.Create(manager, "")
    if not importer.Initialize(str(path), -1, manager.GetIOSettings()):
        error = importer.GetStatus().GetErrorString()
        manager.Destroy()
        raise RuntimeError(error)
    scene = fbx.FbxScene.Create(manager, "")
    importer.Import(scene)
    importer.Destroy()
    # UE と同じ Z-up / センチメートルに揃える (UE の Y 軸はこの座標の -Y)
    fbx.FbxAxisSystem.MayaZUp.ConvertScene(scene)
    fbx.FbxSystemUnit.cm.ConvertScene(scene)
    fbx.FbxGeometryConverter(manager).Triangulate(scene, True)
    return manager, scene


def to_np(m):
    """FbxAMatrix -> numpy 4x4 (列ベクトル形式: M @ v)"""
    return np.array([[m.Get(r, c) for c in range(4)] for r in range(4)]).T


def collect(node, bones, meshes, parent=None):
    """スケルトンを (ノード, 親ボーンの番号 or None)、メッシュをノードのリストに集める"""
    if node.GetSkeleton():
        bones.append((node, parent))
        parent = len(bones) - 1
    if node.GetMesh():
        meshes.append(node)
    for i in range(node.GetChildCount()):
        collect(node.GetChild(i), bones, meshes, parent)


def read_mesh(node):
    """頂点・三角形と、スキンの影響ノード / オフセット行列 / ウェイトを読む"""
    mesh = node.GetMesh()
    verts = np.array([[p[0], p[1], p[2], 1.0] for p in mesh.GetControlPoints()])
    faces = np.array([[3] + [mesh.GetPolygonVertex(i, k) for k in range(3)]
                      for i in range(mesh.GetPolygonCount())]).ravel()

    links, offsets, weights = [], [], []
    skin = mesh.GetDeformer(0) if mesh.GetDeformerCount() else None
    if skin and hasattr(skin, "GetClusterCount"):
        for i in range(skin.GetClusterCount()):
            cluster = skin.GetCluster(i)
            if not cluster.GetLink():
                continue
            bind, bind_link = fbx.FbxAMatrix(), fbx.FbxAMatrix()
            cluster.GetTransformMatrix(bind)
            cluster.GetTransformLinkMatrix(bind_link)
            w = np.zeros(len(verts))
            w[list(cluster.GetControlPointIndices())] = cluster.GetControlPointWeights()
            links.append(cluster.GetLink())
            offsets.append(np.linalg.inv(to_np(bind_link)) @ to_np(bind))
            weights.append(w)
    if not links:  # スキンなし: ノード自身に追従
        links, offsets, weights = [node], [np.eye(4)], [np.ones(len(verts))]

    weights = np.array(weights).T
    weights /= np.maximum(weights.sum(axis=1, keepdims=True), 1e-8)
    return verts, faces, links, np.array(offsets), weights


def bake_matrices(scene, nodes):
    """全フレームのノードのグローバル行列を計算する -> mats[f, i] = 4x4"""
    stack = scene.GetSrcObject(fbx.FbxCriteria.ObjectType(fbx.FbxAnimStack.ClassId), 0)
    mode = scene.GetGlobalSettings().GetTimeMode()
    fps = fbx.FbxTime.GetFrameRate(mode)
    first = last = 0
    if stack:  # アニメーションなしなら 1 フレームだけ
        scene.SetCurrentAnimationStack(stack)
        span = stack.GetLocalTimeSpan()
        first = span.GetStart().GetFrameCount(mode)
        last = span.GetStop().GetFrameCount(mode)

    mats = []
    t = fbx.FbxTime()
    for f in range(first, last + 1):
        t.SetFrame(f, mode)
        mats.append([to_np(node.EvaluateGlobalTransform(t)) for node in nodes])
    return np.array(mats).reshape(-1, len(nodes), 4, 4), fps


class Motion:
    """FBX 1 ファイル分のデータ (FBX SDK のオブジェクトは持たない)"""

    def __init__(self, path):
        manager, scene = load_scene(path)
        bones, mesh_nodes = [], []
        collect(scene.GetRootNode(), bones, mesh_nodes)

        # 評価するノードをまとめ、各メッシュの影響ノードを番号で持つ
        nodes = [node for node, _ in bones]
        self.meshes = []
        for mesh_node in mesh_nodes:
            verts, faces, links, offsets, weights = read_mesh(mesh_node)
            index = list(range(len(nodes), len(nodes) + len(links)))
            nodes += links
            self.meshes.append((verts, faces, index, offsets, weights))
        self.links = [(i, p) for i, (_, p) in enumerate(bones) if p is not None]
        self.n_bones = len(bones)

        self.mats, self.fps = bake_matrices(scene, nodes)
        manager.Destroy()
        self.n_frames = len(self.mats)

    def skin(self, f, mesh):
        verts, _, index, offsets, weights = mesh
        m = np.einsum("vb,bij->vij", weights, self.mats[f, index] @ offsets)
        return np.einsum("vij,vj->vi", m, verts)[:, :3]

    def bone_points(self, f):
        return self.mats[f, :self.n_bones, :3, 3]


def find_fbx(path):
    if path.is_dir():
        return sorted(p for p in path.rglob("*") if p.suffix.lower() == ".fbx")
    return [path] if path.suffix.lower() == ".fbx" else []


def make_grid(size=500, step=50):
    """床 (Z=0) のグリッド線と中心線 (単位 cm)"""
    def lines(values):
        pts = [p for v in values for p in ([v, -size, 0], [v, size, 0], [-size, v, 0], [size, v, 0])]
        cells = np.array([[2, i, i + 1] for i in range(0, len(pts), 2)]).ravel()
        return pv.PolyData(np.array(pts, float), lines=cells)
    return lines([v for v in range(-size, size + 1, step) if v]), lines([0])


def make_ue_axes():
    """UE と同じ左手系の軸表示 (X 前 / Y 右 / Z 上) -> Y 矢印を反転"""
    flip_y = np.diag([1.0, -1.0, 1.0, 1.0])
    axes = pv.AxesActor()
    axes.SetUserMatrix(pv.vtkmatrix_from_array(flip_y))
    return axes


ABOUT_TEXT = """\
<h3>FBX Motion Viewer</h3>
<p>FBX のスケルトン / スキンメッシュのアニメーションを再生するビューア<br>
Copyright (c) 2026 furcraea / MIT License</p>
<p><b>Autodesk FBX SDK</b><br>
This software contains Autodesk® FBX® code developed by Autodesk, Inc. Copyright 2019
Autodesk, Inc. All rights, reserved. Such code is provided "as is" and Autodesk, Inc. disclaims any
and all warranties, whether express or implied, including without limitation the implied warranties
of merchantability, fitness for a particular purpose or non-infringement of third party rights. In no
event shall Autodesk, Inc. be liable for any direct, indirect, incidental, special, exemplary, or
consequential damages (including, but not limited to, procurement of substitute goods or services;
loss of use, data, or profits; or business interruption) however caused and on any theory of
liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising
in any way out of such code.</p>
<p><b>Qt for Python (PySide6)</b><br>
GNU LGPL v3 の下で、改変せずに利用しています。</p>
<p>そのほかのライブラリのライセンスは <a href="{licenses}">THIRD_PARTY_LICENSES.md</a>
と LICENSES フォルダーを参照してください。</p>
"""


class Viewer(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("FBX Motion Viewer")
        self.resize(1280, 800)
        self.setAcceptDrops(True)

        self.motion = None
        self.polys, self.mesh_actors, self.bone_actors, self.skeleton = [], [], [], None
        self.pos, self.playing, self.last = 0.0, True, time.perf_counter()

        # 左: アニメーションリスト / 右: 3D ビュー + タイムバー
        self.list = QtWidgets.QListWidget()
        self.list.currentRowChanged.connect(self.open_motion)
        self.plotter = QtInteractor(self)
        self.plotter.set_background("black")
        self.plotter.add_orientation_widget(make_ue_axes())
        grid, center = make_grid()
        self.plotter.add_mesh(grid, color="#3a3a3a")
        self.plotter.add_mesh(center, color="#707070", line_width=2)
        self.play_button = QtWidgets.QPushButton("Pause")
        self.play_button.clicked.connect(self.toggle_play)
        self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
        self.slider.valueChanged.connect(self.on_slider)
        self.label = QtWidgets.QLabel("FBX / フォルダーをドロップ")
        about_button = QtWidgets.QPushButton("About")
        about_button.clicked.connect(self.show_about)
        for w in (self.play_button, self.slider, about_button):
            w.setFocusPolicy(QtCore.Qt.NoFocus)

        bar = QtWidgets.QHBoxLayout()
        bar.addWidget(self.play_button)
        bar.addWidget(self.slider, 1)
        bar.addWidget(self.label)
        bar.addWidget(about_button)
        right = QtWidgets.QWidget()
        layout = QtWidgets.QVBoxLayout(right)
        layout.addWidget(self.plotter.interactor, 1)
        layout.addLayout(bar)
        splitter = QtWidgets.QSplitter()
        splitter.addWidget(self.list)
        splitter.addWidget(right)
        splitter.setSizes([300, 980])
        self.setCentralWidget(splitter)

        for key, func in (("Space", self.toggle_play), ("Left", lambda: self.step(-1)),
                          ("Right", lambda: self.step(1)), ("M", self.toggle_mesh)):
            QtWidgets.QShortcut(key, self, func)

        self.timer = QtCore.QTimer(self)
        self.timer.timeout.connect(self.tick)
        self.timer.start(16)

    # --- ドラッグ&ドロップ ---
    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.acceptProposedAction()

    def dropEvent(self, event):
        self.set_paths([Path(url.toLocalFile()) for url in event.mimeData().urls()])

    def set_paths(self, paths):
        self.list.clear()
        for path in paths:
            for f in find_fbx(path):
                name = str(f.relative_to(path.parent)) if path.is_dir() else f.name
                item = QtWidgets.QListWidgetItem(name)
                item.setData(QtCore.Qt.UserRole, str(f))
                item.setToolTip(str(f))
                self.list.addItem(item)
        if self.list.count():
            self.list.setCurrentRow(0)
            self.list.setFocus()

    # --- 読み込みと描画 ---
    def open_motion(self, row):
        if row < 0:
            return
        path = Path(self.list.item(row).data(QtCore.Qt.UserRole))
        self.plotter.remove_actor(self.mesh_actors + self.bone_actors)
        self.polys, self.mesh_actors, self.bone_actors, self.skeleton = [], [], [], None
        first = self.motion is None
        QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor)
        try:
            self.motion = Motion(path)
        except Exception as e:
            self.motion = None
            self.label.setText(f"読み込み失敗: {e}")
            return
        finally:
            QtWidgets.QApplication.restoreOverrideCursor()
        motion = self.motion
        print(f"{path}  bones: {motion.n_bones}  frames: {motion.n_frames}  fps: {motion.fps:.2f}")

        for mesh in motion.meshes:
            poly = pv.PolyData(motion.skin(0, mesh), mesh[1])
            self.polys.append(poly)
            self.mesh_actors.append(self.plotter.add_mesh(poly, color="lightgray"))
        if motion.links:
            lines = np.array([[2, c, p] for c, p in motion.links]).ravel()
            self.skeleton = pv.PolyData(motion.bone_points(0), lines=lines)
            self.bone_actors.append(self.plotter.add_mesh(self.skeleton, color="red", line_width=3))

        self.setWindowTitle(f"FBX Motion Viewer - {path.name}")
        self.slider.setRange(0, motion.n_frames - 1)
        self.pos = 0.0
        self.draw()
        if first:  # カメラは最初の 1 回だけキャラクターの正面 (UE の +Y 側) に合わせる
            pts = np.vstack([p.points for p in self.polys] + [motion.bone_points(0)])
            bounds = np.ravel([pts.min(axis=0), pts.max(axis=0)], order="F")
            self.plotter.view_vector((0.5, -1, 0.3), viewup=(0, 0, 1), bounds=bounds)

    def frame(self):
        return int(self.pos) % self.motion.n_frames

    def draw(self):
        motion, f = self.motion, self.frame()
        for poly, mesh in zip(self.polys, motion.meshes):
            poly.points = motion.skin(f, mesh)
        if self.skeleton is not None:
            self.skeleton.points = motion.bone_points(f)
        self.slider.blockSignals(True)
        self.slider.setValue(f)
        self.slider.blockSignals(False)
        self.label.setText(f"{f + 1} / {motion.n_frames}")
        self.plotter.render()

    # --- 再生操作 ---
    def tick(self):
        now = time.perf_counter()
        dt, self.last = now - self.last, now
        if self.motion and self.playing and not self.slider.isSliderDown():
            self.pos += dt * self.motion.fps
            self.draw()

    def on_slider(self, value):
        if self.motion:
            self.pos = value
            self.draw()

    def step(self, delta):
        if self.motion:
            self.set_playing(False)
            self.pos = self.frame() + delta
            self.draw()

    def set_playing(self, playing):
        self.playing = playing
        self.play_button.setText("Pause" if playing else "Play")

    def toggle_play(self):
        self.set_playing(not self.playing)

    def show_about(self):
        licenses = Path(__file__).with_name("THIRD_PARTY_LICENSES.md")
        text = ABOUT_TEXT.format(licenses=QtCore.QUrl.fromLocalFile(str(licenses)).toString())
        QtWidgets.QMessageBox.about(self, "About FBX Motion Viewer", text)

    def toggle_mesh(self):
        for actor in self.mesh_actors:
            actor.SetVisibility(not actor.GetVisibility())
        self.plotter.render()

    def closeEvent(self, event):
        self.timer.stop()
        self.plotter.close()
        super().closeEvent(event)


def set_dark_theme(app):
    """Qt ウィジェット全体をダークテーマにする"""
    app.setStyle("Fusion")
    palette = QtGui.QPalette()
    for role, color in (("Window", "#2b2b2b"), ("WindowText", "#dddddd"), ("Base", "#1e1e1e"),
                        ("AlternateBase", "#2b2b2b"), ("Text", "#dddddd"), ("Button", "#3a3a3a"),
                        ("ButtonText", "#dddddd"), ("Highlight", "#2f65ca"),
                        ("HighlightedText", "#ffffff"), ("ToolTipBase", "#1e1e1e"),
                        ("ToolTipText", "#dddddd")):
        palette.setColor(getattr(QtGui.QPalette, role), QtGui.QColor(color))
    app.setPalette(palette)


def set_dark_title_bar(widget):
    """Windows 10/11 のタイトルバーを暗くする"""
    if sys.platform == "win32":
        import ctypes
        value = ctypes.c_int(1)  # DWMWA_USE_IMMERSIVE_DARK_MODE = 20
        ctypes.windll.dwmapi.DwmSetWindowAttribute(
            int(widget.winId()), 20, ctypes.byref(value), ctypes.sizeof(value))


def main():
    app = QtWidgets.QApplication(sys.argv)
    set_dark_theme(app)
    viewer = Viewer()
    set_dark_title_bar(viewer)
    viewer.show()
    if len(sys.argv) > 1:
        viewer.set_paths([Path(a) for a in sys.argv[1:]])
    app.exec_()


if __name__ == "__main__":
    main()

FBX SDK を入れたときに実際に置かれた本体は、C:\python3.11.0\Lib\site-packages\ にある fbx.cp311-win_amd64.pyd(約 6 MB)の1ファイルだけです。

方法1:.pyd だけをコピーする
次の条件がそろえば、このファイルをコピーするだけで動きます。

  • 別の PC の Python が 3.11 の 64bit 版であること(ファイル名の cp311-win_amd64 は「この環境専用」という意味です)
  • fbx.cp311-win_amd64.pyd を、その Python の site-packages か、motion_viewer.py と同じフォルダーに置くこと
  • Microsoft Visual C++ ランタイムが入っていること。たいていの Windows PC には入っています。import fbx で DLL のエラーが出たときは「Microsoft Visual C++ 再頒布可能パッケージ」を入れてください。

fbx(FBX SDK の wheel)FBX の読み込み(import fbx)
pyvista3D 表示
pyvistaqtPyVista の 3D 表示を Qt のウィンドウに埋め込む
PySide6ウィンドウ、リスト、スライダーなどの画面部品
matplotlibpyvista が必要としているので、アンインストールはできません。

これで入ります。
python -m pip install fbx-2020.3.11-cp311-none-win_amd64.whl pyvista pyvistaqt PySide6

実行用バッチ
run_viewer.bat

@echo off
setlocal

rem Python for FBX SDK (e.g. C:\Python310\python.exe)
set PYTHON=C:\python3.11.0\python.exe

rem Drop FBX files or folders onto the viewer window
"%PYTHON%" "%~dp0motion_viewer.py" %*
if errorlevel 1 pause

run_viewer.bat の set PYTHON= の行を、別の PC の Python の場所に書き換えてください。

[Maya]FacialのBlendShapeのExport/Import

maya-exporting-importing-facial-blendshapes

BlendShapeってメッシュとか沢山Rootに並んでて作業しずらい。

出力してさっぱりしちゃおってことで

この状態で
CLO_Face_Mesh_bs_miya75
CLO_Eyebrows_Mesh_bs_miya75
を出力する場合

blendshape_io_use_output.py

import sys
d = r"D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode"
if d not in sys.path:
    sys.path.insert(0, d)

import importlib
import blendshape_io as bio
importlib.reload(bio)   # 既に読み込み済みなら最新版に更新

# 書き出しーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー 


# フェイスの書き出し
out = r"D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson"
bio.export_node("CLO_Face_Mesh_bs_miya75", out + "/CLO_Face_Mesh_bs_miya75.json")
bio.export_all(out + "/bs_export")

# まゆげの書き出し
out = r"D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson"
bio.export_node("CLO_Eyebrows_Mesh_bs_miya75", out + "/CLO_Eyebrows_Mesh_bs_miya75.json")
bio.export_all(out + "/bs_export")


出力するとこんな感じのログ

[   0.0s] blendshape_io 開始  (maya-blendshape-io/1)
[   0.0s] export: CLO_Face_Mesh_bs_miya75
[   0.1s]   geo[0] Face_MeshShape : 107 ターゲット, 4911 頂点
[   0.4s] 書き出し完了: D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/CLO_Face_Mesh_bs_miya75.json  (107 ターゲット, 2686.6 KB)
[   0.4s] 完了
[   0.0s] blendshape_io 開始  (maya-blendshape-io/1)
[   0.0s] blendShape ノード 12 件: ['CLO_Eyebrows_Mesh_bs_miya75', 'CLO_Face_Mesh_bs_miya75', 'T3:Body_Mesh_BlendShape', 'T3:DressShirt_Mesh_BlendShape', 'T3:EyebrowsUP_Mesh_BlendShape', 'T3:Eyebrows_Mesh_BlendShape', 'T3:Face_Mesh_BlendShape', 'T3:Shadow_Mesh_BlendShape', 'T3:Shirt_Mesh_BlendShape', 'T3:Soocks_Mesh_BlendShape', 'T3:Tie_Mesh_BlendShape', 'T3:UnderPants_Mesh_BlendShape']
[   0.1s] export: CLO_Eyebrows_Mesh_bs_miya75
[   0.1s]   geo[0] Eyebrows_MeshShape : 24 ターゲット, 80 頂点
[   0.1s] 書き出し完了: D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export\CLO_Eyebrows_Mesh_bs_miya75.json  (24 ターゲット, 40.8 KB)
[   0.1s] export: CLO_Face_Mesh_bs_miya75
[   0.3s]   geo[0] Face_MeshShape : 107 ターゲット, 4911 頂点
[   0.5s] 書き出し完了: D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export\CLO_Face_Mesh_bs_miya75.json  (107 ターゲット, 2686.6 KB)
[   0.6s] export: T3:Body_Mesh_BlendShape
[   0.6s]   geo[0] Body_MeshShape : 6 ターゲット, 18673 頂点
[   0.7s] 書き出し完了: D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export\T3_Body_Mesh_BlendShape.json  (6 ターゲット, 521.8 KB)
[   0.7s] export: T3:DressShirt_Mesh_BlendShape
[   0.7s]   geo[0] DressShirt_MeshShape : 2 ターゲット, 2328 頂点
[   0.7s] 書き出し完了: D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export\T3_DressShirt_Mesh_BlendShape.json  (2 ターゲット, 21.0 KB)
[   0.7s] export: T3:EyebrowsUP_Mesh_BlendShape
[   0.7s]   geo[0] EyebrowsUP_MeshShape : 25 ターゲット, 80 頂点
[   0.7s] 書き出し完了: D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export\T3_EyebrowsUP_Mesh_BlendShape.json  (25 ターゲット, 36.0 KB)
[   0.7s] export: T3:Eyebrows_Mesh_BlendShape
[   0.7s]   geo[0] Eyebrows_MeshShape : 24 ターゲット, 80 頂点
[   0.7s] 書き出し完了: D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export\T3_Eyebrows_Mesh_BlendShape.json  (24 ターゲット, 38.9 KB)
[   0.7s] export: T3:Face_Mesh_BlendShape
[   0.8s]   geo[0] Face_MeshShape : 106 ターゲット, 4911 頂点
[   1.0s] 書き出し完了: D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export\T3_Face_Mesh_BlendShape.json  (106 ターゲット, 1314.4 KB)
[   1.0s] export: T3:Shadow_Mesh_BlendShape
[   1.0s]   geo[0] Shadow_MeshShape : 16 ターゲット, 466 頂点
[   1.0s] 書き出し完了: D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export\T3_Shadow_Mesh_BlendShape.json  (16 ターゲット, 57.8 KB)
[   1.0s] export: T3:Shirt_Mesh_BlendShape
[   1.0s]   geo[0] Shirt_MeshShape : 1 ターゲット, 1010 頂点
[   1.0s] 書き出し完了: D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export\T3_Shirt_Mesh_BlendShape.json  (1 ターゲット, 34.0 KB)
[   1.1s] export: T3:Soocks_Mesh_BlendShape
[   1.1s]   geo[0] Soocks_MeshShape : 1 ターゲット, 1678 頂点
[   1.1s] 書き出し完了: D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export\T3_Soocks_Mesh_BlendShape.json  (1 ターゲット, 28.9 KB)
[   1.2s] export: T3:Tie_Mesh_BlendShape
[   1.2s]   geo[0] Tie_MeshShape : 1 ターゲット, 236 頂点
[   1.2s] 書き出し完了: D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export\T3_Tie_Mesh_BlendShape.json  (1 ターゲット, 2.0 KB)
[   1.2s] export: T3:UnderPants_Mesh_BlendShape
[   1.2s]   geo[0] UnderPants_MeshShape : 1 ターゲット, 1023 頂点
[   1.2s] 書き出し完了: D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export\T3_UnderPants_Mesh_BlendShape.json  (1 ターゲット, 33.7 KB)
[   1.3s] 全 12 件書き出し -> D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export
[   1.3s] 完了

出力されたフォルダ

空の状態で

Import /読み込む場合

blendshape_io_use_import.py

import sys
d = r"D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode"
if d not in sys.path:
    sys.path.insert(0, d)

import importlib
import blendshape_io as bio
importlib.reload(bio)   # 既に読み込み済みなら最新版に更新

# 読み込み-------------------Face
SRC  = r"D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export"
ROOT = "Tsubame_v1_0_VRChat4_miya7_openFinger3|geo_gp|head_gp"

bio.import_node(SRC + "/CLO_Face_Mesh_bs_miya75.json",
                mesh=ROOT + "|Face_Mesh",
                node_name="CLO_Face_Mesh_bs_miya77")


# 読み込み-----------------EyeBrow
SRC  = r"D:/Github/ENRAI_CLO3D_UnityGit/cMayaBlendShapeCode/BlendShapeJson/bs_export"
ROOT = "Tsubame_v1_0_VRChat4_miya7_openFinger3|geo_gp|head_gp"

bio.import_node(SRC + "/CLO_Eyebrows_Mesh_bs_miya75.json",
                mesh=ROOT + "|Eyebrows_Mesh",

読み込めた!

メインのソース/blendshape_io.py

blendshape_io.py

# -*- coding: utf-8 -*-
"""
blendshape_io.py

Maya の blendShape ノードを「外部データ (JSON)」として書き出し / 読み戻す。

ターゲットメッシュ本体は不要。blendShape ノードのプラグ
    inputTarget[g].inputTargetGroup[w].inputTargetItem[i].inputPointsTarget
    inputTarget[g].inputTargetGroup[w].inputTargetItem[i].inputComponentsTarget
に入っている「疎な delta(動いた頂点だけの相対オフセット, ローカル空間)」を
そのまま JSON 化する。これは Maya が内部で持っている表現そのものなので、
往復(export→import)で完全一致する。

  - alias(表情名)、envelope、origin、inbetween(中間ターゲット)も保存
  - baseWeights / targetWeights(ペイントウェイト)は非デフォルト分だけ疎に保存
  - import 時はターゲットメッシュに新しい blendShape ノードを作り、プラグへ直接流し込む
    → CLO_*_bs のような「焼き込み済み」ノードを別シーン / 別リグへ移植できる

使い方 (Maya Script Editor / Python):

    # ★再現性のため import + reload ではなく exec 推奨(reload は編集中だと壊れる)
    p = r"D:/Github/ENRAI_CLO3D_UnityGit/claudeMayaBlendShapeCode/blendshape_io.py"
    g = {}; exec(open(p, encoding="utf-8").read(), g); bio = type("m", (), g)

    # --- 書き出し(元シーンで)---
    bio.export_all(r"D:/tmp/bs_export")          # シーン内の全 blendShape を個別 JSON に

    # --- 読み戻し(移植先シーンで): フォルダ一括・冪等・結果表つき ---
    bio.import_dir(r"D:/tmp/bs_export",
                   root="Tsubame_v1_0_VRChat4_miya7_openFinger3|geo_gp|head_gp",
                   suffix="_miya73")
    #   root 直下の同名メッシュへ全 JSON を import。既存は置換。
    #   メッシュが無い JSON は SKIP(エラーで止まらない)。最後に結果表。

    # --- 単発でやりたいとき ---
    bio.export_node("CLO_Face_Mesh_bs", r"D:/tmp/CLO_Face_Mesh_bs.json")
    bio.import_node(r"D:/tmp/CLO_Face_Mesh_bs.json",
                    mesh="...|head_gp|Face_Mesh", replace=True)

    # --- 検証(ライブノード vs JSON の delta 差分)---
    bio.verify(r"D:/tmp/CLO_Face_Mesh_bs.json", "CLO_Face_Mesh_bs")

    # --- import の掃除(import_node で作ったノードだけ消す)---
    bio.list_imported()                              # 一覧
    bio.remove_imported(dry_run=True)                # 消える対象を確認
    bio.remove_imported()                            # 全部削除
    bio.remove_imported(mesh="...|Face_Mesh")        # そのメッシュの分だけ
    bio.remove_imported(name="CLO_*_miya7")          # 名前で絞り込み
    bio.remove_by_name("CLO_Face_Mesh_bs")           # 印の無い旧 import 分を強制削除

コマンドポート経由 (4700 / mel) の場合:
    python("import blendshape_io as bio; bio.export_all(r'D:/tmp/bs_export')")
進捗は out ディレクトリ(export)またはシーン同階層(import)の
blendshape_io_log.txt に flush 都度書かれるので tail で追える。

FORMAT: "maya-blendshape-io/1"
"""

import os
import json
import time
import fnmatch
import maya.cmds as cmds
import maya.api.OpenMaya as om

FORMAT = "maya-blendshape-io/1"

# import_node が作ったノードに付ける印(掃除で使う)
_TAG = "blendShapeIoImported"

# blendShape プラグの短縮名
_IT = "inputTarget"
_ITG = "inputTargetGroup"
_ITI = "inputTargetItem"
_IPT = "inputPointsTarget"
_ICT = "inputComponentsTarget"


# --------------------------------------------------------------------------- log
class _Logger(object):
    def __init__(self, path):
        self.path = path
        self.t0 = time.time()
        try:
            self.f = open(path, "w", encoding="utf-8")
        except Exception:
            self.f = None
        self.write(u"blendshape_io 開始  (%s)" % FORMAT)

    def write(self, msg):
        line = u"[%6.1fs] %s" % (time.time() - self.t0, msg)
        print(line)
        if self.f:
            try:
                self.f.write(line + u"\n")
                self.f.flush()
                os.fsync(self.f.fileno())
            except Exception:
                pass

    def close(self, ok=True):
        self.write(u"完了" if ok else u"中断(エラー)")
        if self.f:
            try:
                self.f.close()
            except Exception:
                pass


def _log_path(hint_dir=None):
    d = hint_dir
    if not d:
        sp = cmds.file(q=True, sn=True)
        d = os.path.dirname(sp) if sp else cmds.workspace(q=True, directory=True)
    return os.path.join(d, "blendshape_io_log.txt")


# ----------------------------------------------------------------------- helpers
def _resolve(name):
    hits = cmds.ls(name, long=True) or []
    if not hits:
        raise ValueError(u"見つかりません: %s" % name)
    return sorted(hits, key=lambda p: ("OLD" in p, p.count("|")))[0]


def _shape_of(node):
    """transform ならデフォームされる可視シェイプ、shape ならそのまま返す。"""
    node = _resolve(node)
    if cmds.nodeType(node) == "mesh":
        return node
    shapes = cmds.listRelatives(node, s=True, f=True, ni=False) or []
    vis = [s for s in shapes if not cmds.getAttr(s + ".intermediateObject")]
    if not vis:
        raise ValueError(u"可視メッシュシェイプが見つかりません: %s" % node)
    return vis[0]


def _num_vertices(shape):
    try:
        return cmds.polyEvaluate(shape, v=True)
    except Exception:
        return None


def _weight_indices(bs):
    return sorted(cmds.getAttr(bs + ".weight", mi=True) or [])


def _geo_indices(bs):
    idx = cmds.getAttr("%s.%s" % (bs, _IT), mi=True)
    return sorted(idx) if idx else [0]


def _alias_of(bs, wi):
    a = cmds.aliasAttr("%s.weight[%d]" % (bs, wi), q=True)
    return a if a else None


def _item_indices(bs, gi, wi):
    plug = "%s.%s[%d].%s[%d].%s" % (bs, _IT, gi, _ITG, wi, _ITI)
    idx = cmds.getAttr(plug, mi=True)
    return sorted(idx) if idx else []


def _item_plug(bs, gi, wi, ii):
    return "%s.%s[%d].%s[%d].%s[%d]" % (bs, _IT, gi, _ITG, wi, _ITI, ii)


def _iti_to_weight(ii):
    # 6000 -> 1.0, 5000 -> 0.0, 5750 -> 0.75, 4000 -> -1.0
    return (ii - 5000) / 1000.0


def _weight_to_iti(w):
    return int(round(w * 1000.0)) + 5000


def _read_target_item(bs, gi, wi, ii):
    plug = _item_plug(bs, gi, wi, ii)
    pts = cmds.getAttr(plug + "." + _IPT) or []
    comps = cmds.getAttr(plug + "." + _ICT) or []
    pts = [[round(float(p[0]), 7), round(float(p[1]), 7), round(float(p[2]), 7)]
           for p in pts]
    comps = [str(c) for c in comps]
    return comps, pts


def _read_paint_weights(bs, gi, wi=None):
    """baseWeights (wi=None) / targetWeights を疎な {index: value} で返す。
    デフォルト (1.0) の要素は落とす。全てデフォルトなら None。"""
    if wi is None:
        plug = "%s.%s[%d].baseWeights" % (bs, _IT, gi)
    else:
        plug = "%s.%s[%d].%s[%d].targetWeights" % (bs, _IT, gi, _ITG, wi)
    idx = cmds.getAttr(plug, mi=True)
    if not idx:
        return None
    out = {}
    for i in idx:
        try:
            v = cmds.getAttr("%s[%d]" % (plug, i))
        except Exception:
            continue
        if abs(v - 1.0) > 1e-6:
            out[str(i)] = round(float(v), 6)
    return out or None


def _inbetween_name(bs, wi, ii):
    """named inbetween があれば名前を返す(無ければ None)。"""
    for attr in ("%s.inbetweenInfoGroup[%d].inbetweenInfo[%d].inbetweenTargetName" % (bs, wi, ii),):
        try:
            if cmds.objExists(bs) and cmds.attributeQuery("inbetweenInfoGroup", node=bs, exists=True):
                v = cmds.getAttr(attr)
                if v:
                    return v
        except Exception:
            pass
    return None


# ----------------------------------------------------------------------- export
def export_node(node, out_path, log=None, include_paint_weights=True):
    own_log = log is None
    if own_log:
        log = _Logger(_log_path(os.path.dirname(os.path.abspath(out_path))))
    try:
        bs = _resolve(node)
        if cmds.nodeType(bs) != "blendShape":
            # transform / mesh を渡された場合、履歴から blendShape を拾う
            hist = cmds.listHistory(bs) or []
            cand = [h for h in hist if cmds.nodeType(h) == "blendShape"]
            if not cand:
                raise ValueError(u"blendShape ノードが見つかりません: %s" % node)
            bs = cand[0]
        log.write(u"export: %s" % bs)

        geo = cmds.blendShape(bs, q=True, geometry=True) or []
        geo_long = [_resolve(g) for g in geo]
        data = {
            "format": FORMAT,
            "node": bs.split("|")[-1],
            "envelope": round(cmds.getAttr(bs + ".envelope"), 6),
            "origin": cmds.getAttr(bs + ".origin", asString=True),
            "supportNegativeWeights": _safe_getattr(bs, "supportNegativeWeights"),
            "maya": cmds.about(version=True),
            "scene": cmds.file(q=True, sn=True) or "",
            "geometry": [],
        }

        for gi in _geo_indices(bs):
            shp = geo_long[gi] if gi < len(geo_long) else None
            tr = None
            if shp:
                p = cmds.listRelatives(shp, p=True, f=True)
                tr = p[0] if p else None
            nv = _num_vertices(shp) if shp else None
            g = {
                "index": gi,
                "shape": shp.split("|")[-1] if shp else None,
                "transform": tr.split("|")[-1] if tr else None,
                "transformPath": tr if tr else None,
                "numVertices": nv,
                "baseWeights": _read_paint_weights(bs, gi) if include_paint_weights else None,
                "targets": [],
            }
            for wi in _weight_indices(bs):
                items = []
                for ii in _item_indices(bs, gi, wi):
                    comps, pts = _read_target_item(bs, gi, wi, ii)
                    if not comps and not pts:
                        continue
                    items.append({
                        "itemIndex": ii,
                        "inbetweenWeight": round(_iti_to_weight(ii), 4),
                        "inbetweenName": _inbetween_name(bs, wi, ii),
                        "components": comps,
                        "points": pts,
                    })
                if not items:
                    continue
                g["targets"].append({
                    "weightIndex": wi,
                    "alias": _alias_of(bs, wi),
                    "weight": round(_safe_getattr(bs, "weight[%d]" % wi, 0.0), 6),
                    "targetWeights": _read_paint_weights(bs, gi, wi) if include_paint_weights else None,
                    "items": items,
                })
            log.write(u"  geo[%d] %s : %d ターゲット, %s 頂点"
                      % (gi, g["shape"], len(g["targets"]), nv))
            data["geometry"].append(g)

        _write_json(out_path, data)
        n_t = sum(len(g["targets"]) for g in data["geometry"])
        sz = os.path.getsize(out_path)
        log.write(u"書き出し完了: %s  (%d ターゲット, %.1f KB)"
                  % (out_path, n_t, sz / 1024.0))
        return out_path
    finally:
        if own_log:
            import sys as _sys
            log.close(ok=_sys.exc_info()[0] is None)


def export_all(out_dir, include_paint_weights=True, include_referenced=False):
    if not os.path.isdir(out_dir):
        os.makedirs(out_dir)
    log = _Logger(_log_path(out_dir))
    try:
        nodes = cmds.ls(type="blendShape") or []
        if not include_referenced:
            keep = []
            for bs in nodes:
                try:
                    ref = cmds.referenceQuery(bs, isNodeReferenced=True)
                except Exception:
                    ref = False
                if ref:
                    log.write(u"  スキップ(参照ノード): %s" % bs)
                else:
                    keep.append(bs)
            nodes = keep
        log.write(u"blendShape ノード %d 件: %s" % (len(nodes), nodes))
        paths = []
        for bs in nodes:
            safe = bs.split("|")[-1].replace(":", "_")
            out = os.path.join(out_dir, safe + ".json")
            try:
                export_node(bs, out, log=log,
                            include_paint_weights=include_paint_weights)
                paths.append(out)
            except Exception as e:
                log.write(u"  失敗 %s: %s" % (bs, e))
        log.write(u"全 %d 件書き出し -> %s" % (len(paths), out_dir))
        return paths
    finally:
        import sys as _sys
        log.close(ok=_sys.exc_info()[0] is None)


# ----------------------------------------------------------------------- import
def import_node(in_path, mesh=None, node_name=None, front_of_chain=True,
                geo_index=0, apply_paint_weights=True, replace=False):
    """
    JSON から blendShape ノードを再構築して mesh に適用する。

      mesh            : ターゲット transform / shape。省略時は JSON の transform 名で検索。
      node_name       : 生成する blendShape 名。省略時は JSON の "node"。
      front_of_chain  : True で skinCluster 等より上流に挿入(CLO_*_bs と同じ)。
      geo_index       : JSON 内のどの geometry ブロックを使うか(通常 0)。
      replace         : 同名ノードが既にあれば削除してから作り直す。
    """
    data = _read_json(in_path)
    if data.get("format") != FORMAT:
        raise ValueError(u"未知のフォーマット: %s" % data.get("format"))

    geos = data.get("geometry") or []
    g = next((x for x in geos if x.get("index") == geo_index), geos[0] if geos else None)
    if g is None:
        raise ValueError(u"geometry ブロックがありません")

    node_name = node_name or data.get("node") or "imported_bs"
    log = _Logger(_log_path())
    try:
        # --- ターゲットメッシュ解決 ---
        if mesh is None:
            for key in ("transformPath", "transform", "shape"):
                nm = g.get(key)
                if nm and (cmds.ls(nm) or cmds.ls("*|" + nm)):
                    mesh = nm
                    break
            if mesh is None:
                raise ValueError(
                    u"mesh 未指定で、JSON の '%s' も見つかりません。mesh= で指定してください。"
                    % g.get("transform"))
        shp = _shape_of(mesh)
        tr = cmds.listRelatives(shp, p=True, f=True)[0]
        log.write(u"import 先: %s" % tr)

        nv_json = g.get("numVertices")
        nv_live = _num_vertices(shp)
        if nv_json and nv_live and nv_json != nv_live:
            raise ValueError(u"頂点数不一致: JSON %d ≠ メッシュ %d(トポロジ違い)"
                             % (nv_json, nv_live))
        log.write(u"頂点数 OK: %s" % nv_live)

        # --- 既存ノード処理 ---
        if cmds.objExists(node_name):
            if replace:
                log.write(u"既存 %s を削除" % node_name)
                cmds.delete(node_name)
            else:
                raise ValueError(u"%s は既に存在します。replace=True か node_name= を指定。"
                                 % node_name)

        # --- blendShape 生成(0 ターゲット)---
        kw = {"name": node_name, "frontOfChain": front_of_chain}
        origin = data.get("origin")
        if origin in ("local", "world"):
            kw["origin"] = origin
        bs = cmds.blendShape(tr, **kw)[0]
        _tag_imported(bs, in_path, tr)
        log.write(u"blendShape 生成: %s  (frontOfChain=%s, origin=%s)"
                  % (bs, front_of_chain, origin))

        gi = geo_index
        targets = g.get("targets") or []
        for n, t in enumerate(targets):
            wi = t["weightIndex"]
            grp = "%s.%s[%d].%s[%d]" % (bs, _IT, gi, _ITG, wi)
            for item in t.get("items") or []:
                ii = item["itemIndex"]
                comps = item.get("components") or []
                pts = [tuple(map(float, p)) for p in (item.get("points") or [])]
                plug = "%s.%s[%d]" % (grp, _ITI, ii)
                if comps:
                    cmds.setAttr(plug + "." + _ICT, len(comps), *comps,
                                 type="componentList")
                if pts:
                    cmds.setAttr(plug + "." + _IPT, len(pts), *pts,
                                 type="pointArray")
            # weight プラグ + alias
            wplug = "%s.weight[%d]" % (bs, wi)
            try:
                cmds.setAttr(wplug, float(t.get("weight") or 0.0))
            except Exception:
                pass
            alias = t.get("alias")
            if alias:
                try:
                    cmds.aliasAttr(alias, wplug)
                except Exception as e:
                    log.write(u"  alias 設定失敗 %s: %s" % (alias, e))
            # targetWeights(ペイント)
            if apply_paint_weights and t.get("targetWeights"):
                for si, sv in t["targetWeights"].items():
                    try:
                        cmds.setAttr("%s.targetWeights[%s]" % (grp, si), float(sv))
                    except Exception:
                        pass
            if (n + 1) % 20 == 0:
                log.write(u"  %d / %d ターゲット" % (n + 1, len(targets)))

        # baseWeights(ペイント)
        if apply_paint_weights and g.get("baseWeights"):
            bplug = "%s.%s[%d].baseWeights" % (bs, _IT, gi)
            for si, sv in g["baseWeights"].items():
                try:
                    cmds.setAttr("%s[%s]" % (bplug, si), float(sv))
                except Exception:
                    pass

        if "envelope" in data:
            cmds.setAttr(bs + ".envelope", float(data["envelope"]))

        got = cmds.listAttr(bs + ".weight", multi=True) or []
        log.write(u"完了: %s  ターゲット %d 件  (alias: %s ...)"
                  % (bs, len(got), ", ".join(got[:6])))
        return bs
    finally:
        import sys as _sys
        log.close(ok=_sys.exc_info()[0] is None)


# ----------------------------------------------------------------------- verify
def verify(in_path, node, tol=1e-5, geo_index=0):
    """JSON の delta とライブ blendShape ノードの delta を突き合わせる。"""
    data = _read_json(in_path)
    bs = _resolve(node)
    g = next((x for x in (data.get("geometry") or []) if x.get("index") == geo_index), None)
    if g is None:
        print(u"geometry[%d] が JSON にありません" % geo_index)
        return False
    ok = True
    for t in g.get("targets") or []:
        wi = t["weightIndex"]
        for item in t.get("items") or []:
            ii = item["itemIndex"]
            comps, pts = _read_target_item(bs, geo_index, wi, ii)
            jp = item.get("points") or []
            jc = item.get("components") or []
            if comps != jc:
                print(u"[%s iti=%d] components 不一致 (live %d / json %d)"
                      % (t.get("alias") or wi, ii, len(comps), len(jc)))
                ok = False
                continue
            md = 0.0
            for a, b in zip(pts, jp):
                d = max(abs(a[0] - b[0]), abs(a[1] - b[1]), abs(a[2] - b[2]))
                md = max(md, d)
            flag = u"OK" if md <= tol else u"差分あり"
            if md > tol:
                ok = False
            print(u"[%-24s iti=%d] 点 %4d  最大差 %.2e  %s"
                  % (t.get("alias") or wi, ii, len(pts), md, flag))
    print(u"=== %s ===" % (u"一致" if ok else u"不一致あり"))
    return ok


def _verify_quiet(in_path, node, tol=1e-4, geo_index=0):
    """verify の静か版。(ok, 最大差, ターゲット数) を返す。表示なし。"""
    try:
        data = _read_json(in_path)
        bs = _resolve(node)
    except Exception:
        return (False, None, 0)
    g = next((x for x in (data.get("geometry") or [])
              if x.get("index") == geo_index), None)
    if g is None:
        return (False, None, 0)
    ok, gmax, n = True, 0.0, 0
    for t in g.get("targets") or []:
        wi = t["weightIndex"]
        for item in t.get("items") or []:
            ii = item["itemIndex"]
            comps, pts = _read_target_item(bs, geo_index, wi, ii)
            jc = item.get("components") or []
            jp = item.get("points") or []
            n += 1
            if comps != jc:
                ok = False
                continue
            for a, b in zip(pts, jp):
                d = max(abs(a[0] - b[0]), abs(a[1] - b[1]), abs(a[2] - b[2]))
                gmax = max(gmax, d)
    if gmax > tol:
        ok = False
    return (ok, gmax, n)


# ------------------------------------------------------------- batch import (1発)
def import_dir(json_dir, root, suffix="", front_of_chain=True,
               replace=True, do_verify=True):
    """
    フォルダ内の *.json を、root 直下の同名メッシュへ順に import する冪等バッチ。

      json_dir : export_all で作った JSON フォルダ
      root     : ターゲット階層のルート(例
                 "Tsubame_v1_0_VRChat4_miya7_openFinger3|geo_gp|head_gp")
                 JSON の transform 名を root 直下から探す。見つからなければ
                 root 配下を再帰検索、それでも無ければ SKIP(エラーにしない)。
      suffix   : 生成ノード名の接尾辞(例 "_miya73" → CLO_Face_Mesh_bs_miya73)
      replace  : True で既存同名ノードを削除して作り直す(冪等)

    最後に 1 行 = 1 メッシュの結果表を出す。途中で 1 件失敗しても続行する。
    """
    log = _Logger(_log_path(json_dir))
    rows = []
    try:
        files = sorted(f for f in os.listdir(json_dir)
                       if f.lower().endswith(".json"))
        log.write(u"root = %s" % root)
        log.write(u"JSON %d 件: %s" % (len(files), files))
        for fn in files:
            path = os.path.join(json_dir, fn)
            try:
                data = _read_json(path)
            except Exception as e:
                rows.append((fn, "-", "-", u"SKIP 壊れたJSON (%s)" % e))
                continue
            if data.get("format") != FORMAT:
                rows.append((fn, "-", "-", u"SKIP フォーマット違い"))
                continue
            geos = data.get("geometry") or []
            g = geos[0] if geos else {}
            mesh_name = g.get("transform")
            node_name = (data.get("node") or fn[:-5]) + suffix

            # --- メッシュ解決 ---
            mesh = None
            for cand in (root + "|" + (mesh_name or ""),):
                if mesh_name and cmds.ls(cand):
                    mesh = cand
                    break
            if mesh is None and mesh_name:
                deep = [m for m in (cmds.ls(root + "|*|" + mesh_name, long=True)
                                    or []) + (cmds.ls("*|" + mesh_name, long=True)
                                    or []) if m.startswith("|" + root.split("|")[0])
                        or root.split("|")[0] in m]
                deep = deep or (cmds.ls("*|" + mesh_name, long=True) or [])
                if len(deep) == 1:
                    mesh = deep[0]
                elif len(deep) > 1:
                    rows.append((fn, mesh_name, node_name,
                                 u"SKIP 候補が%d件 曖昧: %s" % (len(deep), deep)))
                    continue
            if mesh is None:
                rows.append((fn, mesh_name, node_name, u"SKIP メッシュ無し"))
                continue

            # --- 頂点数プリチェック ---
            shp = _shape_of(mesh)
            nvj, nvl = g.get("numVertices"), _num_vertices(shp)
            if nvj and nvl and nvj != nvl:
                rows.append((fn, mesh_name, node_name,
                             u"SKIP 頂点数 %d≠%d" % (nvj, nvl)))
                continue

            # --- import ---
            try:
                bs = import_node(path, mesh=mesh, node_name=node_name,
                                 front_of_chain=front_of_chain, replace=replace)
            except Exception as e:
                rows.append((fn, mesh_name, node_name, u"FAIL %s" % e))
                continue

            ntg = len(cmds.listAttr(bs + ".weight", multi=True) or [])
            note = u"OK  (%d target)" % ntg
            if do_verify:
                vok, vmax, _ = _verify_quiet(path, bs)
                note = u"%s  verify=%s (最大差 %.1e)" % (
                    u"OK" if vok else u"要確認", u"一致" if vok else u"NG",
                    vmax if vmax is not None else -1)
            rows.append((fn, mesh_name, bs, note))
            log.write(u"  %s -> %s : %s" % (fn, bs, note))

        # --- 結果表 ---
        log.write(u"---- 結果 ----")
        for fn, mesh_name, node, note in rows:
            log.write(u"  %-30s %-16s %-28s %s"
                      % (fn, mesh_name or "-", node, note))
        bad = [r for r in rows if not r[3].startswith("OK")]
        log.write(u"完了: %d 件中 OK %d / 要対応 %d"
                  % (len(rows), len(rows) - len(bad), len(bad)))
        return rows
    finally:
        import sys as _sys
        log.close(ok=_sys.exc_info()[0] is None)


# --------------------------------------------------------------------- cleanup
def _tag_imported(bs, src, tr):
    """import_node が作ったノードに印を付ける(掃除で識別するため)。"""
    try:
        if not cmds.attributeQuery(_TAG, node=bs, exists=True):
            cmds.addAttr(bs, ln=_TAG, dt="string")
        cmds.setAttr(bs + "." + _TAG, json.dumps({
            "source": src,
            "mesh": tr.split("|")[-1],
            "when": time.strftime("%Y-%m-%d %H:%M:%S"),
            "format": FORMAT,
        }), type="string")
    except Exception:
        pass


def list_imported(verbose=True):
    """blendshape_io.import_node で作った blendShape ノードを列挙。"""
    out = []
    for bs in cmds.ls(type="blendShape", long=True) or []:
        if not cmds.attributeQuery(_TAG, node=bs, exists=True):
            continue
        try:
            info = json.loads(cmds.getAttr(bs + "." + _TAG) or "{}")
        except Exception:
            info = {}
        geo = cmds.blendShape(bs, q=True, geometry=True) or []
        out.append({"node": bs, "geometry": geo, "info": info})
        if verbose:
            print(u"%-40s  mesh=%s  when=%s"
                  % (bs.split("|")[-1], info.get("mesh"), info.get("when")))
    if verbose and not out:
        print(u"blendshape_io で作った blendShape ノードはありません")
    return out


def remove_imported(mesh=None, name=None, dry_run=False):
    """
    import_node で作った blendShape ノードだけを削除する。

      mesh    : この transform / shape に付いているものだけ(省略で全部)
      name    : ノード名のワイルドカード絞り込み(例 "CLO_*_miya7")
      dry_run : True で対象一覧だけ表示して削除しない

    印 (_TAG 属性) が付いていないノードは絶対に触らない。
    印の無い旧 import 分は name= で個別指定して cmds.delete するか、
    force_names=[...] を使うこと。
    """
    log = _Logger(_log_path())
    try:
        want_shape = _shape_of(mesh) if mesh else None
        targets = []
        for bs in cmds.ls(type="blendShape", long=True) or []:
            if not cmds.attributeQuery(_TAG, node=bs, exists=True):
                continue
            if name and not fnmatch.fnmatch(bs.split("|")[-1], name):
                continue
            if want_shape:
                geo = [_resolve(g) for g in
                       (cmds.blendShape(bs, q=True, geometry=True) or [])]
                if want_shape not in geo:
                    continue
            targets.append(bs)

        log.write(u"削除対象 %d 件%s: %s"
                  % (len(targets), u"(dry run)" if dry_run else u"",
                     [t.split("|")[-1] for t in targets]))
        if dry_run or not targets:
            return targets

        for bs in targets:
            try:
                cmds.lockNode(bs, lock=False)
            except Exception:
                pass
            try:
                cmds.delete(bs)
                log.write(u"  削除: %s" % bs.split("|")[-1])
            except Exception as e:
                log.write(u"  削除失敗 %s: %s" % (bs.split("|")[-1], e))
        log.write(u"削除完了")
        return targets
    finally:
        import sys as _sys
        log.close(ok=_sys.exc_info()[0] is None)


def remove_by_name(names, dry_run=False):
    """印の有無に関係なく、指定した名前の blendShape ノードを削除する。
    印の無い旧 import 分を消したいとき用(names は文字列 or リスト、ワイルドカード可)。"""
    if isinstance(names, str):
        names = [names]
    log = _Logger(_log_path())
    try:
        hit = []
        for pat in names:
            for bs in cmds.ls(pat, type="blendShape", long=True) or []:
                if bs not in hit:
                    hit.append(bs)
        log.write(u"対象 %d 件%s: %s"
                  % (len(hit), u"(dry run)" if dry_run else u"",
                     [h.split("|")[-1] for h in hit]))
        if dry_run:
            return hit
        for bs in hit:
            try:
                cmds.lockNode(bs, lock=False)
            except Exception:
                pass
            try:
                cmds.delete(bs)
                log.write(u"  削除: %s" % bs.split("|")[-1])
            except Exception as e:
                log.write(u"  削除失敗 %s: %s" % (bs.split("|")[-1], e))
        return hit
    finally:
        import sys as _sys
        log.close(ok=_sys.exc_info()[0] is None)


# -------------------------------------------------------------------------- misc
def _safe_getattr(node, attr, default=None):
    try:
        return cmds.getAttr("%s.%s" % (node, attr))
    except Exception:
        return default


def _write_json(path, data):
    d = os.path.dirname(os.path.abspath(path))
    if d and not os.path.isdir(d):
        os.makedirs(d)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, separators=(",", ":"))


def _read_json(path):
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


if __name__ == "__main__":
    sel = cmds.ls(sl=True) or []
    if sel and cmds.nodeType(sel[0]) == "blendShape":
        out = _resolve(sel[0]).split("|")[-1] + ".json"
        export_node(sel[0], os.path.join(cmds.workspace(q=True, rd=True), out))
    else:
        print(u"blendShape ノードを選択して実行するか、bio.export_all(dir) を使ってください")

[maya][hair][nurbs]nurbsLoftでの束感のあるヘアーモデリングの方法

[Maya][Hair][NURBS] Method for modeling hair with a “clumped” look using NURBS Loft

根元からNurbsCircleを置いて行って

ctrl+d複製し拡大縮小しながら次の円も書いていきます。

ctrl+d複製し縮小しながら移動して先端の部分を用意します

nurbsを全部選択し、サーフェス>作成>ロフトで、loftedSurface1ができます。

loftedSurface1を選択して、修正>変換>NURBSをポリゴンに 

でポリゴンに変換できます。

[maya][python]接合したメッシュの法線をいい感じに丸く滑らかにするツール furcraeaSmoothNormalTool.pyとfurcraeaSeamNormalTool.py

できます。まずは 選択したメッシュ、フェース、頂点の法線を滑らかにする Maya Python ツールとして作るのが扱いやすいです。

下のコードは、次の機能を持っています。

  • 選択範囲だけ法線をソフト化
  • メッシュ全体をソフト化
  • 角度を指定
  • 法線ロック解除
  • 法線の平均化
  • ハードエッジ化
  • 法線履歴の削除

furcraeaSmoothNormalTool.py

# -*- coding: utf-8 -*-
import maya.cmds as cmds


WINDOW_NAME = "furcraeaSmoothNormalTool"


def get_selected_mesh_components():
    """
    現在の選択を取得します。
    オブジェクト選択の場合は、そのメッシュ全体を返します。
    コンポーネント選択の場合は、そのまま返します。
    """
    selection = cmds.ls(selection=True, long=True, flatten=True) or []

    if not selection:
        cmds.warning("メッシュ、フェース、エッジ、頂点を選択してください。")
        return []

    result = []

    for item in selection:
        # コンポーネント選択
        if "." in item:
            result.append(item)
            continue

        # TransformからShapeを取得
        if cmds.nodeType(item) == "transform":
            shapes = cmds.listRelatives(
                item,
                shapes=True,
                noIntermediate=True,
                fullPath=True
            ) or []

            mesh_shapes = [
                shape for shape in shapes
                if cmds.nodeType(shape) == "mesh"
            ]

            if mesh_shapes:
                result.append(item)

        # Shapeが直接選択されている場合
        elif cmds.nodeType(item) == "mesh":
            parents = cmds.listRelatives(
                item,
                parent=True,
                fullPath=True
            ) or []

            if parents:
                result.append(parents[0])

    if not result:
        cmds.warning("選択内にポリゴンメッシュがありません。")

    return result


def unlock_normals():
    """選択したメッシュまたはコンポーネントの法線ロックを解除します。"""
    targets = get_selected_mesh_components()
    if not targets:
        return

    cmds.undoInfo(openChunk=True)

    try:
        for target in targets:
            try:
                cmds.polyNormalPerVertex(
                    target,
                    unFreezeNormal=True
                )
            except RuntimeError:
                pass

        cmds.inViewMessage(
            amg="<hl>法線ロックを解除しました</hl>",
            pos="midCenter",
            fade=True
        )

    finally:
        cmds.undoInfo(closeChunk=True)


def soften_normals(angle=None):
    """
    指定角度以下のエッジをソフト化します。

    angle:
        180の場合、基本的にすべてのエッジを滑らかにします。
        30~60程度なら、鋭い角を残しやすくなります。
    """
    targets = get_selected_mesh_components()
    if not targets:
        return

    if angle is None:
        angle = cmds.floatSliderGrp(
            "smoothNormalAngleSlider",
            query=True,
            value=True
        )

    cmds.undoInfo(openChunk=True)

    try:
        for target in targets:
            # ロックされた法線がある場合に備えて解除
            try:
                cmds.polyNormalPerVertex(
                    target,
                    unFreezeNormal=True
                )
            except RuntimeError:
                pass

            cmds.polySoftEdge(
                target,
                angle=angle,
                constructionHistory=True
            )

        cmds.inViewMessage(
            amg="<hl>法線を滑らかにしました:</hl> {}°".format(angle),
            pos="midCenter",
            fade=True
        )

    finally:
        cmds.undoInfo(closeChunk=True)


def soften_all():
    """選択したメッシュまたはコンポーネントを完全にソフト化します。"""
    soften_normals(180.0)


def harden_normals():
    """選択した範囲をハードエッジ化します。"""
    targets = get_selected_mesh_components()
    if not targets:
        return

    cmds.undoInfo(openChunk=True)

    try:
        for target in targets:
            try:
                cmds.polyNormalPerVertex(
                    target,
                    unFreezeNormal=True
                )
            except RuntimeError:
                pass

            cmds.polySoftEdge(
                target,
                angle=0,
                constructionHistory=True
            )

        cmds.inViewMessage(
            amg="<hl>ハードエッジ化しました</hl>",
            pos="midCenter",
            fade=True
        )

    finally:
        cmds.undoInfo(closeChunk=True)


def average_vertex_normals():
    """
    選択頂点の法線を平均化します。
    頂点以外を選択した場合は頂点へ変換します。
    """
    selection = cmds.ls(selection=True, long=True, flatten=True) or []

    if not selection:
        cmds.warning("平均化するメッシュまたはコンポーネントを選択してください。")
        return

    vertices = cmds.polyListComponentConversion(
        selection,
        toVertex=True
    )

    vertices = cmds.ls(
        vertices,
        flatten=True,
        long=True
    ) or []

    if not vertices:
        cmds.warning("頂点を取得できませんでした。")
        return

    cmds.undoInfo(openChunk=True)

    try:
        try:
            cmds.polyNormalPerVertex(
                vertices,
                unFreezeNormal=True
            )
        except RuntimeError:
            pass

        cmds.polyAverageNormal(
            vertices,
            distance=0.0,
            replaceNormalXYZ=(0, 0, 0)
        )

        cmds.inViewMessage(
            amg="<hl>頂点法線を平均化しました</hl>",
            pos="midCenter",
            fade=True
        )

    finally:
        cmds.undoInfo(closeChunk=True)


def conform_normals():
    """
    法線の向きを統一します。
    裏返ったフェースが混在している場合に使用します。
    """
    targets = get_selected_mesh_components()
    if not targets:
        return

    cmds.undoInfo(openChunk=True)

    try:
        for target in targets:
            cmds.polyNormal(
                target,
                normalMode=2,
                userNormalMode=0,
                constructionHistory=True
            )

        cmds.inViewMessage(
            amg="<hl>法線方向を統一しました</hl>",
            pos="midCenter",
            fade=True
        )

    finally:
        cmds.undoInfo(closeChunk=True)


def reverse_normals():
    """選択したフェースまたはメッシュの法線を反転します。"""
    targets = get_selected_mesh_components()
    if not targets:
        return

    cmds.undoInfo(openChunk=True)

    try:
        for target in targets:
            cmds.polyNormal(
                target,
                normalMode=0,
                userNormalMode=0,
                constructionHistory=True
            )

        cmds.inViewMessage(
            amg="<hl>法線を反転しました</hl>",
            pos="midCenter",
            fade=True
        )

    finally:
        cmds.undoInfo(closeChunk=True)


def delete_normal_history():
    """
    選択メッシュの履歴を削除します。
    法線処理以外の履歴も削除されるため注意してください。
    """
    targets = get_selected_mesh_components()
    if not targets:
        return

    transforms = set()

    for target in targets:
        object_name = target.split(".")[0]

        if cmds.nodeType(object_name) == "mesh":
            parents = cmds.listRelatives(
                object_name,
                parent=True,
                fullPath=True
            ) or []

            if parents:
                transforms.add(parents[0])
        else:
            transforms.add(object_name)

    if transforms:
        cmds.delete(list(transforms), constructionHistory=True)

        cmds.inViewMessage(
            amg="<hl>ヒストリを削除しました</hl>",
            pos="midCenter",
            fade=True
        )


def show_smooth_normal_tool():
    """UIを表示します。"""
    if cmds.window(WINDOW_NAME, exists=True):
        cmds.deleteUI(WINDOW_NAME)

    window = cmds.window(
        WINDOW_NAME,
        title="Smooth Normal Tool",
        sizeable=False,
        widthHeight=(340, 390)
    )

    cmds.columnLayout(
        adjustableColumn=True,
        rowSpacing=8
    )

    cmds.separator(height=8, style="none")

    cmds.text(
        label="メッシュ・フェース・エッジ・頂点に対応",
        align="center"
    )

    cmds.separator(height=8, style="in")

    cmds.floatSliderGrp(
        "smoothNormalAngleSlider",
        label="Soft Angle",
        field=True,
        minValue=0.0,
        maxValue=180.0,
        fieldMinValue=0.0,
        fieldMaxValue=180.0,
        value=60.0,
        columnWidth3=(80, 60, 180)
    )

    cmds.button(
        label="指定角度で滑らかにする",
        height=36,
        command=lambda *_: soften_normals()
    )

    cmds.button(
        label="すべて滑らかにする  180°",
        height=32,
        command=lambda *_: soften_all()
    )

    cmds.button(
        label="ハードエッジにする  0°",
        height=32,
        command=lambda *_: harden_normals()
    )

    cmds.separator(height=10, style="in")

    cmds.button(
        label="頂点法線を平均化",
        height=32,
        command=lambda *_: average_vertex_normals()
    )

    cmds.button(
        label="法線ロックを解除",
        height=32,
        command=lambda *_: unlock_normals()
    )

    cmds.separator(height=10, style="in")

    cmds.rowLayout(
        numberOfColumns=2,
        adjustableColumn=1,
        columnWidth2=(165, 165)
    )

    cmds.button(
        label="法線方向を統一",
        height=30,
        command=lambda *_: conform_normals()
    )

    cmds.button(
        label="法線を反転",
        height=30,
        command=lambda *_: reverse_normals()
    )

    cmds.setParent("..")

    cmds.separator(height=10, style="in")

    cmds.button(
        label="ヒストリを削除",
        height=30,
        command=lambda *_: delete_normal_history()
    )

    cmds.separator(height=8, style="none")

    cmds.showWindow(window)


show_smooth_normal_tool()

メッシュの接合部で法線がめちゃくちゃになってるパターンをどうにか滑らかにしたい

その場合は polySoftEdge だけでは直らない可能性が高いです。

接合部で法線が乱れる原因は主に次の2種類です。

  1. 頂点は接合されているが、法線がロック・分割されている
  2. 別メッシュ同士で、同じ位置にある頂点の法線が一致していない

特に髪・顔・首・胴体など、別メッシュの境界を滑らかにつなぎたい場合は、接合部の近接頂点を探して、両方の法線を平均化する必要があります。

以下は、選択した複数メッシュ間で、同じ位置にある頂点の法線を平均化するPythonツールです。

furcraeaSeamNormalTool.py

# -*- coding: utf-8 -*-

import maya.cmds as cmds
import maya.api.OpenMaya as om


WINDOW_NAME = "furcraeaSeamNormalTool"


def get_selected_mesh_dag_paths():
    """
    選択されたTransformまたはMesh Shapeから、
    MeshのMDagPathを取得する。
    """
    selection = om.MGlobal.getActiveSelectionList()
    mesh_paths = []

    for index in range(selection.length()):
        try:
            dag_path = selection.getDagPath(index)
        except RuntimeError:
            continue

        if dag_path.node().hasFn(om.MFn.kTransform):
            try:
                dag_path.extendToShape()
            except RuntimeError:
                continue

        if dag_path.node().hasFn(om.MFn.kMesh):
            mesh_paths.append(dag_path)

    return mesh_paths


def unlock_mesh_normals(dag_path):
    """
    メッシュ全体の頂点法線ロックを解除する。
    """
    mesh_name = dag_path.fullPathName()

    try:
        cmds.polyNormalPerVertex(
            mesh_name,
            unFreezeNormal=True
        )
    except RuntimeError:
        pass


def get_vertex_data(dag_path):
    """
    各頂点について以下を取得する。

    - 頂点番号
    - ワールド座標
    - 頂点法線
    """
    mesh_fn = om.MFnMesh(dag_path)

    points = mesh_fn.getPoints(om.MSpace.kWorld)
    normals = mesh_fn.getVertexNormals(
        True,
        om.MSpace.kWorld
    )

    result = []

    for vertex_id, point in enumerate(points):
        normal = normals[vertex_id]

        result.append({
            "dag_path": dag_path,
            "mesh_fn": mesh_fn,
            "vertex_id": vertex_id,
            "position": om.MVector(point.x, point.y, point.z),
            "normal": om.MVector(normal.x, normal.y, normal.z)
        })

    return result


def build_spatial_hash(vertex_data, tolerance):
    """
    頂点座標をグリッド化して、近接頂点を高速に検索する。
    """
    spatial_hash = {}

    for data in vertex_data:
        position = data["position"]

        key = (
            int(round(position.x / tolerance)),
            int(round(position.y / tolerance)),
            int(round(position.z / tolerance))
        )

        spatial_hash.setdefault(key, []).append(data)

    return spatial_hash


def get_neighbor_keys(position, tolerance):
    """
    誤差境界をまたいだ頂点も検索できるように、
    周囲27セルを返す。
    """
    center_x = int(round(position.x / tolerance))
    center_y = int(round(position.y / tolerance))
    center_z = int(round(position.z / tolerance))

    for offset_x in (-1, 0, 1):
        for offset_y in (-1, 0, 1):
            for offset_z in (-1, 0, 1):
                yield (
                    center_x + offset_x,
                    center_y + offset_y,
                    center_z + offset_z
                )


def average_seam_normals(tolerance=0.001, border_only=True):
    """
    選択した複数メッシュ間で、
    同じ位置にある頂点法線を平均化する。

    tolerance:
        同じ位置と判定する距離。

    border_only:
        Trueの場合は境界頂点のみを対象にする。
    """
    dag_paths = get_selected_mesh_dag_paths()

    if len(dag_paths) < 2:
        cmds.warning(
            "接合部を修正する2つ以上のメッシュを選択してください。"
        )
        return

    cmds.undoInfo(openChunk=True)

    try:
        all_vertex_data = []

        for dag_path in dag_paths:
            unlock_mesh_normals(dag_path)

            mesh_vertex_data = get_vertex_data(dag_path)

            if border_only:
                border_ids = get_border_vertex_ids(dag_path)

                mesh_vertex_data = [
                    data
                    for data in mesh_vertex_data
                    if data["vertex_id"] in border_ids
                ]

            all_vertex_data.extend(mesh_vertex_data)

        spatial_hash = build_spatial_hash(
            all_vertex_data,
            tolerance
        )

        processed = set()
        matched_groups = []

        for source_data in all_vertex_data:
            source_key = (
                source_data["dag_path"].fullPathName(),
                source_data["vertex_id"]
            )

            if source_key in processed:
                continue

            matching_vertices = []

            for neighbor_key in get_neighbor_keys(
                source_data["position"],
                tolerance
            ):
                candidates = spatial_hash.get(
                    neighbor_key,
                    []
                )

                for candidate in candidates:
                    candidate_key = (
                        candidate["dag_path"].fullPathName(),
                        candidate["vertex_id"]
                    )

                    if candidate_key in processed:
                        continue

                    # 同一メッシュ内の近接頂点は対象外
                    if (
                        candidate["dag_path"].fullPathName()
                        == source_data["dag_path"].fullPathName()
                    ):
                        continue

                    distance = (
                        candidate["position"]
                        - source_data["position"]
                    ).length()

                    if distance <= tolerance:
                        matching_vertices.append(candidate)

            if not matching_vertices:
                continue

            group = [source_data] + matching_vertices

            # 同一頂点の重複を除去
            unique_group = {}
            for data in group:
                key = (
                    data["dag_path"].fullPathName(),
                    data["vertex_id"]
                )
                unique_group[key] = data

            group = list(unique_group.values())

            if len(group) < 2:
                continue

            matched_groups.append(group)

            for data in group:
                processed.add((
                    data["dag_path"].fullPathName(),
                    data["vertex_id"]
                ))

        if not matched_groups:
            cmds.warning(
                "指定距離内に一致する接合頂点が見つかりませんでした。"
            )
            return

        apply_average_normals(matched_groups)

        cmds.inViewMessage(
            amg=(
                "<hl>接合部法線を平均化しました</hl>"
                "<br/>一致グループ数: {}"
            ).format(len(matched_groups)),
            pos="midCenter",
            fade=True
        )

    finally:
        cmds.undoInfo(closeChunk=True)


def get_border_vertex_ids(dag_path):
    """
    メッシュの境界エッジに含まれる頂点番号を取得する。
    """
    border_vertex_ids = set()

    edge_iterator = om.MItMeshEdge(dag_path)

    while not edge_iterator.isDone():
        if edge_iterator.onBoundary():
            border_vertex_ids.add(
                edge_iterator.vertexId(0)
            )
            border_vertex_ids.add(
                edge_iterator.vertexId(1)
            )

        edge_iterator.next()

    return border_vertex_ids


def apply_average_normals(matched_groups):
    """
    法線グループごとに平均法線を計算し、
    各頂点へ適用する。
    """
    mesh_updates = {}

    for group in matched_groups:
        average_normal = om.MVector()

        for data in group:
            average_normal += data["normal"]

        if average_normal.length() < 0.000001:
            continue

        average_normal.normalize()

        for data in group:
            mesh_name = data["dag_path"].fullPathName()

            if mesh_name not in mesh_updates:
                mesh_updates[mesh_name] = {
                    "mesh_fn": data["mesh_fn"],
                    "vertex_ids": [],
                    "normals": []
                }

            mesh_updates[mesh_name]["vertex_ids"].append(
                data["vertex_id"]
            )

            mesh_updates[mesh_name]["normals"].append(
                om.MVector(
                    average_normal.x,
                    average_normal.y,
                    average_normal.z
                )
            )

    for update_data in mesh_updates.values():
        mesh_fn = update_data["mesh_fn"]

        mesh_fn.setVertexNormals(
            update_data["normals"],
            update_data["vertex_ids"],
            om.MSpace.kWorld
        )


def run_from_ui(*args):
    tolerance = cmds.floatField(
        "seamNormalToleranceField",
        query=True,
        value=True
    )

    border_only = cmds.checkBox(
        "seamNormalBorderOnlyCheckBox",
        query=True,
        value=True
    )

    if tolerance <= 0:
        cmds.warning("許容距離は0より大きくしてください。")
        return

    average_seam_normals(
        tolerance=tolerance,
        border_only=border_only
    )


def show_seam_normal_tool():
    if cmds.window(WINDOW_NAME, exists=True):
        cmds.deleteUI(WINDOW_NAME)

    window = cmds.window(
        WINDOW_NAME,
        title="Seam Normal Smoother",
        sizeable=False,
        widthHeight=(360, 190)
    )

    cmds.columnLayout(
        adjustableColumn=True,
        rowSpacing=8
    )

    cmds.separator(height=8, style="none")

    cmds.text(
        label="接合する2つ以上のメッシュを選択",
        align="center"
    )

    cmds.text(
        label="同じ位置にある境界頂点の法線を平均化します",
        align="center"
    )

    cmds.separator(height=8, style="in")

    cmds.floatFieldGrp(
        "seamNormalToleranceField",
        numberOfFields=1,
        label="許容距離",
        value1=0.001,
        precision=6,
        columnWidth2=(100, 220)
    )

    cmds.checkBox(
        "seamNormalBorderOnlyCheckBox",
        label="境界頂点のみを処理",
        value=True
    )

    cmds.separator(height=8, style="in")

    cmds.button(
        label="接合部の法線を滑らかにする",
        height=38,
        command=run_from_ui
    )

    cmds.separator(height=8, style="none")

    cmds.showWindow(window)


show_seam_normal_tool()

#UE4 #UE4Study Character #HairCloth #HairShadingModel #AdvancedLocomotionSystem ファサっと天使の輪と束感でUEのヘアーの使い方

服と髪をなびかせるHair Clothはこれをやった
https://docs.unrealengine.com/4.27/ja/InteractiveExperiences/Physics/Cloth/Overview/
1, [Section Selection (セクション選択)]
2,左クリックを使って、クロスとして使用したいメッシュの一部を選択します。次に右クリックして、コンテキスト メニューを開いて Cloth アセットを作成します。
3,コンテキスト メニューから、[Create Cloth Asset from Selection (選択したものから Cloth アセットを作成)] を選びます。

 A,Asset Name – 後で簡単に見つけられるようにアセットに名前を付けてください。

 B,Remove from Mesh – クロスとして関連付けたい別個のジオメトリのメッシュの構成要素があれば、このオプションを有効にします。そうでなければ、チェックを入れずにこのままにします。

 C,Physics Asset – Cloth アセットがキャラクター用ならば、その Physics アセットを使ってクロス シミュレーションで適切なコリジョンが生じるようにします。

4,[Create] ボタンをクリックします。
5,セクションを再度右クリックして、コンテキスト メニューを表示させて、[Apply Clothing Asset] にマウスをかざし、利用可能なクロス アセットから適用するものを選択します。これで作成した任意のクロス アセットが選択したセクションに関連付けられます。

・ペイント – マウスの左ボタン
・消去 – Shift キー + マウスの左ボタン
・クロスのプレビュー – H キー

[Window] を選択し、リストから [Clothing] を選択します。
[Clothing Data] リストから選択します。

[Activate Cloth Paint] ボタンをクリックして、選択した Cloth アセットをペイントするために使用可能なプロパティを有効にします。

・ブラシの半径を5にして
・強度を0.2
・フォールオフ0.5
でヘアをクリックすると塗れる。ピンクが塗ってない場所

走ってみると

髪が前に来ちゃう

ので

物理ボディを修正した

全骨コリジョン作ったほうが調整しやすかった。(指は消した)

Spine5がこういった設定 Radius 10以下ぐらい

以下は昔の設定

で設定することが重要

でやっといい感じ

HairShadingModel 基本はこれをやった。

つまりこれで髪の天使の輪みたいなものができる。 (まわしてみないと分からない)

でも髪の束感は出したかったのでOpacityMaskもありにするとこうなる。

束感のために使ってるテクスチャはこれで

https://drive.google.com/file/d/11JcBV0dAtrexQew0fbKUZcLRfbYn7FuZ/view?usp=sharing

こんな感じにスキャッター、スペキュラ、ラフネスにMultyplyしたらいい感じになった。

使っているMIでの値はこんな感じ

最後にまたこれをやって

やっとこうなった

元のモデルはここ

https://www.deviantart.com/xcrofty/art/NieR-Automata-A2-678696387

[UE5.8.0][Maya][Retarget] TposeのキャラクターモデルをAposeのMannequinのモーションにリターゲットする方法

MM_T_Pose をエクスポート

Export Optionはこう

MayaにFBXインポートした状態で保存する SKM_Quinn_Simple_Tpose.ma

Tposeのキャラクターモデル(ここではfanModelのサイトからダウンロードしたものを用意した)

https://www.deviantart.com/d-presso/art/Nier-Re-in-carnation-Levannia-XPS-DL-880627377

を開いて


先ほどのTposeのFBXをインポートして

Root ( SKM_Quinn_Simple_Tpose:root_Quinn_T_pose ) のトップ のjointをスケールしてキャラクターにあわせる。

複製してフリーズする

以下ツールでスキンウェイトをコピー

SkinWeightCopy_First_Select_MeshGroup_to_Second Select_MeshGroup.mel

// SkinWeightCopy First Select MeshGroup to Second Select MeshGroup 
string $selectedArrFUllPath[] = `ls -long -sl`;
print($selectedArrFUllPath);

string $FirstSelect=$selectedArrFUllPath[0];

string $SecondSelect=$selectedArrFUllPath[1];

string $rejectArr[];

//string $inputNodes[] = `ls -type transform -long -dag $FirstSelect`;
//string $destNodes[] = `ls -type transform -long -dag $SecondSelect`;

string $inputNodes_mesh[] = `ls -type mesh -long -dag $FirstSelect`;
string $destNodes_mesh[] = `ls -type mesh -long -dag $SecondSelect`;

$inputNodes_meshlong=size($inputNodes_mesh);
$destNodes_meshlong=size($destNodes_mesh);

string $inputNodes[];
string $destNodes[];
clear $inputNodes;
clear $destNodes;
string $mesh;
string $parent;
for($d0 = 0; $d0 <$inputNodes_meshlong;$d0++){
    $mesh=$inputNodes_mesh[$d0];
    string $parentS[] = `listRelatives -parent -path -type transform $mesh`;
    $parent=$parentS[0];
    $inputNodes[size($inputNodes)] = $parent;
}
for($d0 = 0; $d0 <$destNodes_meshlong;$d0++){
    $mesh=$destNodes_mesh[$d0];
    string $parentS[] = `listRelatives -parent -path -type transform $mesh`;
    $parent=$parentS[0];
    $destNodes[size($destNodes)] = $parent;
}

print("$inputNodes= ------------------------------------------------ \n");
print($inputNodes);

print("$destNodes= ------------------------------------------------ \n");
print($destNodes);



$inputNodeslong=size($inputNodes);
$destNodeslong=size($destNodes);

string $buffer[];
string $buffer_i[];
$finded = 0;
for($d = 0; $d <$destNodeslong;$d++){
    string $destNode_name=$destNodes[$d];
    
    if($destNode_name==$SecondSelect){
            
    }else{
        print("$destNode_name= "+$destNode_name+"\n");
        
        $numTokens = `tokenize $destNode_name "|" $buffer`;
        $bufferlong=size($buffer);
        string $destNode_name_Short = $buffer[$bufferlong-1]+"";
        print("$destNode_name_Short= "+$destNode_name_Short+"\n");
        $finded = 0;
        for($i = 0; $i <$inputNodeslong;$i++){
            string $inputNode_name=$inputNodes[$i];
            $numTokens_i = `tokenize $inputNode_name "|" $buffer_i`;
            $bufferlong_i=size($buffer_i);
            string $inputNode_name_Short = $buffer_i[$bufferlong_i-1]+"";
            print("$inputNode_name_Short= "+$inputNode_name_Short+"\n");
                
            //$index=`gmatch $inputNode_name $destNode_name_Short`;
            //if($index > 0){
            if($inputNode_name_Short==$destNode_name_Short){
                //$blendShapeName = $blendshapeRet_name;
                print("$destNode_name= "+$destNode_name+" isHit!! "+"\n");
                select -r $inputNode_name;
                select -add $destNode_name;
                copySkinWeights  -noMirror -surfaceAssociation closestPoint -influenceAssociation closestJoint;
                //copySkinWeights -noMirror -surfaceAssociation closestPoint -influenceAssociation closestJoint -sourceSkin $inputNode_name -destinationSkin $destNode_name;
                $finded = 1;
            }//if
        }//for
        if($finded==0){
            $rejectArr[size($rejectArr)] = $destNode_name;
        }//if
    }//if
}//for
print("----rejected_list---start"+"\n");
print($rejectArr);
print("----rejected_list---end"+"\n");
select -r $FirstSelect ;
select -add $SecondSelect ;

適当にスキンウェイトを確認

rootとMesh 選択してFBX出力

UEへインポート

IKRig作成

IKRig設定

マネキンのほうはこう

IK Retargeter 作成

SounceにCreate>Import From Animation Sequence

MM_T_Pose選択

MM_T_Pose.FBX

https://drive.google.com/file/d/1j1O7pulCJ2Gbc2G1R-8TK3AjUmB98Lky/view?usp=sharing

ポーズがそろう

Op Stack >Add Default Ops 押す とそろった

あとは必要なアニメーションを選択してエクスポート

追加で調整した作業 RootMotionを切ってエクスポートしないとだめだった

そのあとは以下を参考にしてください。

TexturePath Replace Python Script for Maya

Select HyperShade Textures And Do Script
import maya.cmds as cmds
import pymel.core as pm
import os

def remap_selected_texture(z_path):
    
    # figure out what's selected
    selected = pm.ls(sl=True)
    workspace_path = cmds.workspace(query=True, rootDirectory=True)
    print("workspace_path= "+workspace_path)
    for item in selected:
        test_path = pm.getAttr(item+".ftn")
        fileName = test_path.split('/')[-1]
        fileName = fileName.replace('//', '')
        fileName = fileName.replace('/', '')
        print("fileName2= "+fileName)
        #if ':' not in test_path:
        if(fileName=="GraceYong_ArmsColorD1001.jpg"):
            fileName="GraceYong_ArmsColorD_1001.jpg"
        if (1==1):   
            
            #print("fileName1= "+fileName)
            
            
            #new_path = os.path.join(z_path, 'cartoon_room', second)
            new_path = os.path.join(workspace_path,z_path, fileName)
            new_path = new_path.replace('\\', '/')
            new_path = new_path.replace('//', '/')
            relative_path = os.path.join(z_path, fileName)
            relative_path = relative_path.replace('\\', '/')
            new_path = new_path.replace('//', '/')
            if os.path.exists(new_path):
                if(fileName==""):
                    print("item= "+str(item)+" is No fileName= "+ fileName)
                    cmds.select( str(item), r=True )
                    cmds.delete( str(item) )
                else:
                    print("new_path= "+new_path+' is exists! OK')
                    pm.setAttr(item+'.ftn', new_path)
                #pm.setAttr(item+'.ftn', relative_path)
            else:
                print("new_path= "+new_path+ ' not exists NG' )
        if os.path.exists(test_path):
            pass
        else:
            PersonalIndex=test_path.find("Personal")
            if(PersonalIndex==-1):
                pass
            else:
                print("test_path= "+test_path+ ' not exists NG' )
                pm.setAttr(item+'.ftn', "")
                cmds.select( str(item), r=True )
                cmds.delete( str(item) )
         
    cmds.select(selected)
    print("--------------remap_selected_texture----------END")
    
remap_selected_texture("GraceYong.images")

モデルフォルダの自動認識と出力フォルダ引数のパターン

#remap_selected_filenode_texture_path
import maya.cmds as cmds
import pymel.core as pm
import os

def remap_selected_texture(z_path):
    
    # figure out what's selected
    selected = pm.ls(sl=True)
    # workspace_project_path
    workspace_path = cmds.workspace(query=True, rootDirectory=True)
    print("workspace_path= "+workspace_path)
    # ma_file_path
    ma_file_path=cmds.file(q=True, sn=True)
    print("ma_file_path= "+ma_file_path)
    ma_file_path_arr = ma_file_path.split('/')
    ma_Folder=ma_file_path_arr[-2]
    
    print("ma_Folder= "+ma_Folder)
    for item in selected:
        item_path = pm.getAttr(item+".ftn")
        print("item_path= "+item_path)
        item_path_arr = item_path.split('/')
        texFolderName = item_path_arr[-2]
        print("texFolderName= "+texFolderName)
        fileName = item_path_arr[-1]
        print("fileName= "+fileName)
        fileName = fileName.replace('//', '')
        fileName = fileName.replace('/', '')
        print("fileName2= "+fileName)
        
        
        
        #if ':' not in test_path:

        if (1==1):   
            
            #print("fileName1= "+fileName)
            
            
            #new_path = os.path.join(z_path, 'cartoon_room', second)
            new_path = os.path.join(workspace_path,z_path, fileName)
            new_path = new_path.replace('\\', '/')
            new_path = new_path.replace('//', '/')
            #relative_path = os.path.join(z_path, fileName)
            #====================================================
            relative_path = ma_Folder+"/"+z_path+"/"+fileName
            #====================================================
            print("relative_path= "+relative_path)
            #relative_path = relative_path.replace('\\', '/')
            abs_path = os.path.abspath(workspace_path+"/"+relative_path)
            abs_path = abs_path.replace('//', '/')
            print(" abs_path= "+abs_path)
            new_path= abs_path
            print(" new_path= "+new_path)
            print("----------------------------end--------------------------")
            
            if os.path.exists(new_path):
                if(fileName==""):
                    print("item= "+str(item)+" is No fileName= "+ fileName)
                    cmds.select( str(item), r=True )
                    cmds.delete( str(item) )
                else:
                    print("new_path= "+new_path+' is exists! OK')
                    pm.setAttr(item+'.ftn', new_path)
                
            else:
                print("new_path= "+new_path+ ' not exists NG' )

    cmds.select(selected)
    print("--------------remap_selected_texture----------END")
    
remap_selected_texture("texture")

Scene押してシーンディレクトリ取得、押してWalkでテクスチャファイルをwalkで自動検索

#remap_selected_filenode_texture_path
import maya.cmds as cmds
import pymel.core as pm
import os
   
def dir_walk(walkDir,TextureFileName):
    resultPath=""
    for curDir, dirs, files in os.walk(walkDir):
        print('===================')
        print("現在のディレクトリ: " + curDir)
        curDir=curDir.replace("\\","/")
        curDir_arr=curDir.split("/")
        
        curDir_endDirName = curDir_arr[-1]
        print("curDir_endDirName= "+curDir_endDirName)
        if(curDir_endDirName==".mayaSwatches"):
            print("処理をスキップします。。。")
            pass
        else:
            
            print("内包するディレクトリ:" + str(dirs))
            print("内包するファイル: " + str(files))
            for fileName in files: 
                if(fileName==TextureFileName):
                    print("Hit : fileName= "+fileName+ "== TextureFileName= "+TextureFileName)
                    resultPath=curDir+"/"+fileName
        print('===================')
    return resultPath
     
def remap_selected_texture2(sceme_path):
    
    # figure out what's selected
    selected = cmds.ls(sl=True)
    # workspace_project_path
    workspace_path = cmds.workspace(query=True, rootDirectory=True)
    print("workspace_path= "+workspace_path)
    # ma_file_path
    ma_file_path=cmds.file(q=True, sn=True)
    print("ma_file_path= "+ma_file_path)
    ma_file_path_arr = ma_file_path.split('/')
    ma_Folder=ma_file_path_arr[-2]
    
    print("ma_Folder= "+ma_Folder)
    for item in selected:
        item_path = cmds.getAttr(item+".ftn")
        print("item_path= "+item_path)
        item_path_arr = item_path.split('/')
        texFolderName = item_path_arr[-2]
        print("texFolderName= "+texFolderName)
        fileName = item_path_arr[-1]
        print("fileName= "+fileName)
        fileName = fileName.replace('//', '')
        fileName = fileName.replace('/', '')
        print("fileName2= "+fileName)
        
        fixTexturePath=dir_walk(sceme_path,fileName)
        print("fixTexturePath= "+fixTexturePath)
        #if ':' not in test_path:

        if (1==1):   
            
            #print("fileName1= "+fileName)
            
            
            #new_path = os.path.join(z_path, 'cartoon_room', second)
            #new_path = os.path.join(workspace_path,z_path, fileName)
            new_path = fixTexturePath
            new_path = new_path.replace('\\', '/')
            new_path = new_path.replace('//', '/')
            #relative_path = os.path.join(z_path, fileName)
            #====================================================
            #relative_path = ma_Folder+"/"+z_path+"/"+fileName
            #====================================================
            #print("relative_path= "+relative_path)
            #relative_path = relative_path.replace('\\', '/')
            #abs_path = os.path.abspath(workspace_path+"/"+relative_path)
            #abs_path = abs_path.replace('//', '/')
            #print(" abs_path= "+abs_path)
            #new_path= abs_path
            print(" new_path= "+new_path)
            print("----------------------------end--------------------------")
            
            if os.path.exists(new_path):
                if(fileName==""):
                    print("item= "+str(item)+" is No fileName= "+ fileName)
                    cmds.select( str(item), r=True )
                    cmds.delete( str(item) )
                else:
                    print("new_path= "+new_path+' is exists! OK')
                    #cmds.setAttr(item+'.ftn', new_path)
                    cmds.setAttr(item+'.fileTextureName', new_path,type='string')
            else:
                print("new_path= "+new_path+ ' not exists NG' )

    cmds.select(selected)
    print("--------------remap_selected_texture----------END")
    

def remap_fileNode_texture(self):
    text_Field_id="remap_selected_filenode_texture_path_Window|USD_layout|pathTxtFld"
    textField_outputFolder = cmds.textField(text_Field_id, q=True, text=True)
    remap_selected_texture2(textField_outputFolder)

def get_scenePath():
    scenefilePath = cmds.file(q=1, sceneName=1)
    mayaPath,mayaFile = os.path.split(scenefilePath)
    #mayaPath = mayaPath + "/Usd/"
    #mayaPath = mayaPath + "/"
    mayaPath=os.path.abspath(mayaPath)
    mayaPath=mayaPath.replace('\\', '/')
    print("mayaPath= "+mayaPath)
    mayaPath_len=len(mayaPath)
    last_str=mayaPath[mayaPath_len-1:]
    print("mayaPath= "+mayaPath+ " last_str= "+last_str)
    if(last_str=="/"):
        pass
    else:
        mayaPath=mayaPath+"/"
    
    return mayaPath

def btn_scene(self):
    scenePath = get_scenePath()
    
    #textureFolder= cmds.textField('textField_outputFolder', q=True, text=True)
    
    
    
    #lastExportDirPath=scenePath+textureFolder+"/"
    
    
    
    set_lastExportDirPath(scenePath)


def set_lastExportDirPath(lastExportDirPath):
    
    selectList=cmds.ls(sl=True)
    if(str(selectList)== "[]"):
        print("なにも選択されていません。0 set_lastExportDirPath")

            
    text_Field_id="remap_selected_filenode_texture_path_Window|USD_layout|pathTxtFld"
    cmds.textField(text_Field_id, edit=True, text=lastExportDirPath)
    cmds.select(selectList)



def createWindow():
    scenefilePath = cmds.file(q=1, sceneName=1)

    USD_window = cmds.window("remap_selected_filenode_texture_path_Window", widthHeight=(400, 200))
    USD_layout = cmds.columnLayout("USD_layout",adjustableColumn=True, parent=USD_window)
    cmds.text (label="選択したハイパーシェードのテクスチャのパスを置換するツールです。", align='left', parent=USD_layout)
    cmds.text (label="シーンのパス以下のフォルダーから検索して自動的に置換します。", align='left', parent=USD_layout)
    cmds.separator(parent=USD_layout)
    cmds.text (label="", align='left', parent=USD_layout)
    
    cmds.text (label="1、Sceneを押してください。", align='left', parent=USD_layout)
    cmds.button(label="Scene", command=btn_scene, parent=USD_layout)
    text_field = cmds.textField("pathTxtFld", parent="USD_layout",text="")
    #cmds.text (label="2、テクスチャフォルダ名を指定してください。", align='left', parent=USD_layout)
    #text_field = cmds.textField("textField_outputFolder", parent="USD_layout",text="texture")
    cmds.separator(parent=USD_layout)
    cmds.text (label="", align='left', parent=USD_layout)

    
    
    
    cmds.text (label="2、HyperShadeのテクスチャTabで(例:file1,file2,複数可)を選択して、ボタンを押してください。", align='left', parent=USD_layout)
    #CheckBox_Absolute_Path_Bool = cmds.checkBox('CheckBox_Absolute_Path', q=True, value=True)
    cmds.button(label="Remap FileNode Texture ", command=remap_fileNode_texture, parent=USD_layout)
    
    #cmds.separator(parent=USD_layout)

    #cmds.button(label="Add USD Referernce/Payload... ", command=add_prim_xform_for_stage_layer, parent=USD_layout)

    cmds.showWindow(USD_window)
    return None

    
def remap_selected_filenode_texture_path():
    if cmds.window("remap_selected_filenode_texture_path_Window",exists=True):
        cmds.deleteUI("remap_selected_filenode_texture_path_Window")
    createWindow()



remap_selected_filenode_texture_path()

fileノードのパスのバックスラッシュに対応しました。

#remap_selected_filenode_texture_path
import maya.cmds as cmds
import pymel.core as pm
import os
   
def dir_walk(walkDir,TextureFileName):
    resultPath=""
    for curDir, dirs, files in os.walk(walkDir):
        print('===================')
        print("現在のディレクトリ: " + curDir)
        curDir=curDir.replace("\\","/")
        curDir_arr=curDir.split("/")
        
        curDir_endDirName = curDir_arr[-1]
        print("curDir_endDirName= "+curDir_endDirName)
        if(curDir_endDirName==".mayaSwatches"):
            print("処理をスキップします。。。")
            pass
        else:
            
            print("内包するディレクトリ:" + str(dirs))
            print("内包するファイル: " + str(files))
            for fileName in files: 
                if(fileName==TextureFileName):
                    print("Hit : fileName= "+fileName+ "== TextureFileName= "+TextureFileName)
                    resultPath=curDir+"/"+fileName
        print('===================')
    return resultPath
     
def remap_selected_texture2(sceme_path):
    
    # figure out what's selected
    selected = cmds.ls(sl=True)
    # workspace_project_path
    workspace_path = cmds.workspace(query=True, rootDirectory=True)
    print("workspace_path= "+workspace_path)
    # ma_file_path
    ma_file_path=cmds.file(q=True, sn=True)
    print("ma_file_path= "+ma_file_path)
    ma_file_path_arr = ma_file_path.split('/')
    ma_Folder=ma_file_path_arr[-2]
    
    print("ma_Folder= "+ma_Folder)
    for item in selected:
        item_path = cmds.getAttr(item+".ftn")
        print("item_path= "+item_path)
        safe_path = item_path.replace("\\", "/")
        print("safe_path= "+safe_path)
        item_path_arr = safe_path.split('/')
        print("item_path_arr[0]= "+item_path_arr[0])
        
        print("item_path_arr[-1]= "+item_path_arr[-1])
        print("item_path_arr[-2]= "+item_path_arr[-2])
        texFolderName = item_path_arr[-2]
        print("texFolderName= "+texFolderName)
        fileName = item_path_arr[-1]
        print("fileName= "+fileName)
        fileName = fileName.replace('//', '')
        fileName = fileName.replace('/', '')
        print("fileName2= "+fileName)
        
        fixTexturePath=dir_walk(sceme_path,fileName)
        print("fixTexturePath= "+fixTexturePath)
        #if ':' not in test_path:

        if (1==1):   
            
            #print("fileName1= "+fileName)
            
            
            #new_path = os.path.join(z_path, 'cartoon_room', second)
            #new_path = os.path.join(workspace_path,z_path, fileName)
            new_path = fixTexturePath
            new_path = new_path.replace('\\', '/')
            new_path = new_path.replace('//', '/')
            #relative_path = os.path.join(z_path, fileName)
            #====================================================
            #relative_path = ma_Folder+"/"+z_path+"/"+fileName
            #====================================================
            #print("relative_path= "+relative_path)
            #relative_path = relative_path.replace('\\', '/')
            #abs_path = os.path.abspath(workspace_path+"/"+relative_path)
            #abs_path = abs_path.replace('//', '/')
            #print(" abs_path= "+abs_path)
            #new_path= abs_path
            print(" new_path= "+new_path)
            print("----------------------------end--------------------------")
            
            if os.path.exists(new_path):
                if(fileName==""):
                    print("item= "+str(item)+" is No fileName= "+ fileName)
                    cmds.select( str(item), r=True )
                    cmds.delete( str(item) )
                else:
                    print("new_path= "+new_path+' is exists! OK')
                    #cmds.setAttr(item+'.ftn', new_path)
                    cmds.setAttr(item+'.fileTextureName', new_path,type='string')
            else:
                print("new_path= "+new_path+ ' not exists NG' )

    cmds.select(selected)
    print("--------------remap_selected_texture----------END")
    

def remap_fileNode_texture(self):
    text_Field_id="remap_selected_filenode_texture_path_Window|USD_layout|pathTxtFld"
    textField_outputFolder = cmds.textField(text_Field_id, q=True, text=True)
    remap_selected_texture2(textField_outputFolder)

def get_scenePath():
    scenefilePath = cmds.file(q=1, sceneName=1)
    mayaPath,mayaFile = os.path.split(scenefilePath)
    #mayaPath = mayaPath + "/Usd/"
    #mayaPath = mayaPath + "/"
    mayaPath=os.path.abspath(mayaPath)
    mayaPath=mayaPath.replace('\\', '/')
    print("mayaPath= "+mayaPath)
    mayaPath_len=len(mayaPath)
    last_str=mayaPath[mayaPath_len-1:]
    print("mayaPath= "+mayaPath+ " last_str= "+last_str)
    if(last_str=="/"):
        pass
    else:
        mayaPath=mayaPath+"/"
    
    return mayaPath

def btn_scene(self):
    scenePath = get_scenePath()
    
    #textureFolder= cmds.textField('textField_outputFolder', q=True, text=True)
    
    
    
    #lastExportDirPath=scenePath+textureFolder+"/"
    
    
    
    set_lastExportDirPath(scenePath)


def set_lastExportDirPath(lastExportDirPath):
    
    selectList=cmds.ls(sl=True)
    if(str(selectList)== "[]"):
        print("なにも選択されていません。0 set_lastExportDirPath")

            
    text_Field_id="remap_selected_filenode_texture_path_Window|USD_layout|pathTxtFld"
    cmds.textField(text_Field_id, edit=True, text=lastExportDirPath)
    cmds.select(selectList)



def createWindow():
    scenefilePath = cmds.file(q=1, sceneName=1)

    USD_window = cmds.window("remap_selected_filenode_texture_path_Window", widthHeight=(400, 200))
    USD_layout = cmds.columnLayout("USD_layout",adjustableColumn=True, parent=USD_window)
    cmds.text (label="選択したハイパーシェードのテクスチャのパスを置換するツールです。", align='left', parent=USD_layout)
    cmds.text (label="シーンのパス以下のフォルダーから検索して自動的に置換します。", align='left', parent=USD_layout)
    cmds.separator(parent=USD_layout)
    cmds.text (label="", align='left', parent=USD_layout)
    
    cmds.text (label="1、Sceneを押してください。", align='left', parent=USD_layout)
    cmds.button(label="Scene", command=btn_scene, parent=USD_layout)
    text_field = cmds.textField("pathTxtFld", parent="USD_layout",text="")
    #cmds.text (label="2、テクスチャフォルダ名を指定してください。", align='left', parent=USD_layout)
    #text_field = cmds.textField("textField_outputFolder", parent="USD_layout",text="texture")
    cmds.separator(parent=USD_layout)
    cmds.text (label="", align='left', parent=USD_layout)

    
    
    
    cmds.text (label="2、HyperShadeのテクスチャTabで(例:file1,file2,複数可)を選択して、ボタンを押してください。", align='left', parent=USD_layout)
    #CheckBox_Absolute_Path_Bool = cmds.checkBox('CheckBox_Absolute_Path', q=True, value=True)
    cmds.button(label="Remap FileNode Texture ", command=remap_fileNode_texture, parent=USD_layout)
    
    #cmds.separator(parent=USD_layout)

    #cmds.button(label="Add USD Referernce/Payload... ", command=add_prim_xform_for_stage_layer, parent=USD_layout)

    cmds.showWindow(USD_window)
    return None

    
def remap_selected_filenode_texture_path():
    if cmds.window("remap_selected_filenode_texture_path_Window",exists=True):
        cmds.deleteUI("remap_selected_filenode_texture_path_Window")
    createWindow()



remap_selected_filenode_texture_path()

もうちょっと汎用的なテクスチャパスだけを検索置換できるツールが欲しくなり作った

# -*- coding: utf-8 -*-
"""
TexturePath Replace (generic) for Maya

シーン内の「ファイルパスを持つアトリビュート」を自動検出し、
テクスチャパスだけを検索・置換するツール。

- file / aiImage / psdFileTex / Redshift / V-Ray など、ノードタイプを問わず
  usedAsFilename 属性を持つアトリビュートを自動で拾う
- 通常文字列 / 正規表現、大文字小文字の区別、 / と \\ の同一視
- 置換前にプレビュー(置換後のファイルが実在するかもチェック、UDIM等のトークン対応)
- 行ごとのチェックで適用対象を選べる、1回の Undo で元に戻せる

使い方 (Maya スクリプトエディタ / Python):
    import texturepath_replace
    texturepath_replace.show()

Maya 2020 - 2026 (PySide2 / PySide6) 対応。
"""
from __future__ import print_function

import glob
import os
import re

try:
    from maya import cmds
    from maya import OpenMayaUI as omui
except ImportError:  # Maya 外 (テスト用)
    cmds = None
    omui = None

try:
    from PySide6 import QtCore, QtGui, QtWidgets
    from shiboken6 import wrapInstance
except ImportError:
    try:
        from PySide2 import QtCore, QtGui, QtWidgets
        from shiboken2 import wrapInstance
    except ImportError:
        QtCore = QtGui = QtWidgets = wrapInstance = None


TOOL_TITLE = "TexturePath Replace"
WINDOW_OBJECT_NAME = "texturePathReplaceWindow"

# classification に "texture" を含まないが、テクスチャパスを持つ既知ノード
EXTRA_TEXTURE_TYPES = {
    "file",
    "psdFileTex",
    "movie",
    "aiImage",
    "RedshiftNormalMap",
    "RedshiftSprite",
    "RedshiftDomeLight",
    "VRayLightDomeShape",
    "VRayLightRectShape",
}

# テクスチャ系フィルタを外しても対象にしないノード (タイプ指定時は対象にできる)
EXCLUDE_TYPES = {
    "colorManagementGlobals",
}

# glob 用にトークンをワイルドカードへ変換 (<UDIM>, <UVTILE>, <f>, u<U>_v<V>, ####, _MAPID_ ...)
_TOKEN_RE = re.compile(r"<[^<>]+>|_MAPID_|#+|\$\{?F\d*\}?")


# ---------------------------------------------------------------------------
# Core (Maya 非依存)
# ---------------------------------------------------------------------------
def build_replacer(search, replace, use_regex=False, case_sensitive=False,
                   ignore_slash=True):
    """path -> (new_path, count) を返す関数を作る。不正な正規表現は re.error を送出。"""
    if not search:
        return lambda path: (path, 0)

    flags = 0 if case_sensitive else re.IGNORECASE
    if use_regex:
        pattern = re.compile(search, flags)
        repl = replace  # \1 や \g<name> を使える
    else:
        parts = []
        for ch in search:
            if ignore_slash and ch in "/\\":
                parts.append(r"[\\/]")
            else:
                parts.append(re.escape(ch))
        pattern = re.compile("".join(parts), flags)
        # Windows パスのバックスラッシュが re のエスケープとして解釈されないよう関数で返す
        repl = lambda m: replace  # noqa: E731

    def _apply(path):
        return pattern.subn(repl, path)

    return _apply


def normalize_slashes(path):
    """区切りを / に統一し、重複スラッシュをまとめる (UNC の先頭 // は保持)。"""
    p = path.replace("\\", "/")
    head = "//" if p.startswith("//") else ""
    body = re.sub(r"/{2,}", "/", p[len(head):])
    return head + body


def resolve_path(path, root_dir=None):
    """環境変数とプロジェクト相対パスを展開した絶対パスを返す。"""
    p = os.path.expandvars(os.path.expanduser(path))
    if root_dir and not os.path.isabs(p):
        p = os.path.join(root_dir, p)
    return os.path.normpath(p)


def path_exists(path, root_dir=None):
    """ファイルの存在確認。UDIM / フレーム番号などのトークンは glob で判定。"""
    if not path:
        return False
    p = resolve_path(path, root_dir)
    if _TOKEN_RE.search(p):
        return bool(glob.glob(_TOKEN_RE.sub("*", glob.escape(p) if hasattr(glob, "escape") else p)))
    return os.path.isfile(p)


# ---------------------------------------------------------------------------
# Maya scene access
# ---------------------------------------------------------------------------
class PathEntry(object):
    __slots__ = ("node", "node_type", "attr", "path", "new_path", "count",
                 "locked", "exists_old", "exists_new", "enabled")

    def __init__(self, node, node_type, attr, path, locked=False):
        self.node = node
        self.node_type = node_type
        self.attr = attr
        self.path = path
        self.new_path = path
        self.count = 0
        self.locked = locked
        self.exists_old = None
        self.exists_new = None
        self.enabled = True

    @property
    def plug(self):
        return "{}.{}".format(self.node, self.attr)

    @property
    def changed(self):
        return self.count > 0 and self.new_path != self.path


def _is_texture_type(node_type, cache):
    if node_type in cache:
        return cache[node_type]
    result = node_type in EXTRA_TEXTURE_TYPES
    if not result:
        try:
            for c in cmds.getClassification(node_type) or []:
                if "texture" in c.lower():
                    result = True
                    break
        except RuntimeError:
            pass
    cache[node_type] = result
    return result


def _selection_nodes():
    """選択ノード + そのシェーダーネットワーク (ヒストリー) を返す。"""
    sel = cmds.ls(sl=True, long=True) or []
    if not sel:
        return []
    roots = set(sel)
    shapes = cmds.listRelatives(sel, allDescendents=True, shapes=True,
                                fullPath=True) or []
    roots.update(shapes)
    sgs = cmds.listConnections(list(roots), type="shadingEngine") or []
    roots.update(sgs)
    hist = cmds.listHistory(list(roots)) or []
    roots.update(hist)
    return cmds.ls(list(roots), long=True) or []


def scan_scene(selection_only=False, texture_only=True, type_filter=""):
    """シーンからファイルパス属性を収集して PathEntry のリストを返す。"""
    nodes = _selection_nodes() if selection_only else (cmds.ls(long=True) or [])
    if not nodes:
        return []

    type_patterns = [t.strip() for t in re.split(r"[,\s]+", type_filter) if t.strip()]
    type_re = None
    if type_patterns:
        type_re = re.compile("|".join(
            "^" + re.escape(t).replace(r"\*", ".*") + "$" for t in type_patterns),
            re.IGNORECASE)

    # ノードタイプごとにグループ化
    by_type = {}
    for n in nodes:
        try:
            t = cmds.nodeType(n)
        except RuntimeError:
            continue
        by_type.setdefault(t, []).append(n)

    tex_cache = {}
    entries = []
    for t in sorted(by_type):
        if type_re and not type_re.match(t):
            continue
        if not type_re:
            if t in EXCLUDE_TYPES:
                continue
            if texture_only and not _is_texture_type(t, tex_cache):
                continue
        type_nodes = by_type[t]
        # 静的属性はタイプ単位で同じなのでサンプル1つで判定 → 無ければスキップ
        sample_attrs = cmds.listAttr(type_nodes[0], usedAsFilename=True) or []
        if not sample_attrs:
            continue
        for n in type_nodes:
            attrs = cmds.listAttr(n, usedAsFilename=True) or []
            short = cmds.ls(n)[0] if cmds.ls(n) else n
            for a in attrs:
                plug = "{}.{}".format(n, a)
                try:
                    val = cmds.getAttr(plug)
                except (RuntimeError, ValueError):
                    continue
                if not isinstance(val, str):
                    try:
                        val = val if isinstance(val, unicode) else None  # noqa: F821 (py2)
                    except NameError:
                        val = None
                if not val:
                    continue
                try:
                    locked = bool(cmds.getAttr(plug, lock=True))
                except RuntimeError:
                    locked = False
                e = PathEntry(short, t, a, val, locked)
                e.node = n  # long name で保持
                entries.append(e)
    return entries


def apply_entries(entries):
    """チェックされた変更を適用。(成功数, 失敗リスト) を返す。1 回の Undo で戻せる。"""
    ok, failed = 0, []
    cmds.undoInfo(openChunk=True, chunkName="TexturePathReplace")
    try:
        for e in entries:
            if not (e.enabled and e.changed):
                continue
            if e.locked:
                failed.append((e.plug, "locked"))
                continue
            try:
                cmds.setAttr(e.plug, e.new_path, type="string")
                ok += 1
            except RuntimeError as ex:
                failed.append((e.plug, str(ex).strip()))
    finally:
        cmds.undoInfo(closeChunk=True)
    return ok, failed


# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------
def _maya_main_window():
    if omui is None or wrapInstance is None:
        return None
    ptr = omui.MQtUtil.mainWindow()
    if ptr is None:
        return None
    try:
        return wrapInstance(int(ptr), QtWidgets.QWidget)
    except TypeError:
        return wrapInstance(long(ptr), QtWidgets.QWidget)  # noqa: F821 (py2)


if QtWidgets is not None:

    COL_CHECK, COL_NODE, COL_TYPE, COL_ATTR, COL_OLD, COL_NEW, COL_EXISTS = range(7)
    HEADERS = ["", "Node", "Type", "Attr", "現在のパス", "置換後のパス", "存在"]

    class TexturePathReplaceWindow(QtWidgets.QDialog):

        def __init__(self, parent=None):
            super(TexturePathReplaceWindow, self).__init__(parent or _maya_main_window())
            self.setObjectName(WINDOW_OBJECT_NAME)
            self.setWindowTitle(TOOL_TITLE)
            self.setWindowFlags(self.windowFlags() | QtCore.Qt.Window)
            self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
            self.resize(1100, 600)

            self.entries = []
            self._root_dir = ""
            self._error = ""
            self._preview_timer = QtCore.QTimer(self)
            self._preview_timer.setSingleShot(True)
            self._preview_timer.setInterval(200)
            self._preview_timer.timeout.connect(self.update_preview)

            self._build_ui()
            self.rescan()

        # ---- build -------------------------------------------------------
        def _build_ui(self):
            main = QtWidgets.QVBoxLayout(self)

            # 検索 / 置換
            form = QtWidgets.QGridLayout()
            self.search_edit = QtWidgets.QLineEdit()
            self.search_edit.setPlaceholderText("例) D:/old_project/sourceimages   (正規表現: ^.*/textures/)")
            self.replace_edit = QtWidgets.QLineEdit()
            self.replace_edit.setPlaceholderText("例) ${PROJECT_ROOT}/sourceimages   (正規表現: \\1 などのグループ参照可)")
            swap_btn = QtWidgets.QPushButton(u"\u21c5")
            swap_btn.setToolTip("検索と置換を入れ替え")
            swap_btn.setFixedWidth(28)
            swap_btn.clicked.connect(self._swap_fields)
            form.addWidget(QtWidgets.QLabel("検索:"), 0, 0)
            form.addWidget(self.search_edit, 0, 1)
            form.addWidget(swap_btn, 0, 2, 2, 1)
            form.addWidget(QtWidgets.QLabel("置換:"), 1, 0)
            form.addWidget(self.replace_edit, 1, 1)
            main.addLayout(form)

            # オプション
            opt = QtWidgets.QHBoxLayout()
            self.regex_cb = QtWidgets.QCheckBox("正規表現")
            self.case_cb = QtWidgets.QCheckBox("大文字小文字を区別")
            self.slash_cb = QtWidgets.QCheckBox("/ と \\ を同一視")
            self.slash_cb.setChecked(True)
            self.slash_cb.setToolTip("通常検索時、/ と \\ のどちらでもマッチします")
            self.normalize_cb = QtWidgets.QCheckBox("置換結果の区切りを / に統一")
            self.normalize_cb.setChecked(True)
            for w in (self.regex_cb, self.case_cb, self.slash_cb, self.normalize_cb):
                opt.addWidget(w)
                w.toggled.connect(self._schedule_preview)
            opt.addStretch()
            main.addLayout(opt)

            # スキャン範囲
            scope = QtWidgets.QHBoxLayout()
            self.scope_all = QtWidgets.QRadioButton("シーン全体")
            self.scope_sel = QtWidgets.QRadioButton("選択 (シェーダーネットワーク含む)")
            self.scope_all.setChecked(True)
            self.texture_only_cb = QtWidgets.QCheckBox("テクスチャ系ノードのみ")
            self.texture_only_cb.setChecked(True)
            self.texture_only_cb.setToolTip(
                "OFF にすると gpuCache / Alembic / StandIn / imagePlane などのパスも対象になります")
            self.type_filter_edit = QtWidgets.QLineEdit()
            self.type_filter_edit.setPlaceholderText("ノードタイプ指定 (例: file, aiImage, Redshift*)")
            self.type_filter_edit.setMaximumWidth(260)
            rescan_btn = QtWidgets.QPushButton("再スキャン")
            rescan_btn.clicked.connect(self.rescan)
            for w in (self.scope_all, self.scope_sel, self.texture_only_cb):
                scope.addWidget(w)
            scope.addWidget(self.type_filter_edit)
            scope.addStretch()
            scope.addWidget(rescan_btn)
            self.scope_all.toggled.connect(self.rescan)
            self.texture_only_cb.toggled.connect(self.rescan)
            self.type_filter_edit.returnPressed.connect(self.rescan)
            main.addLayout(scope)

            # 表示フィルタ
            view = QtWidgets.QHBoxLayout()
            self.changed_only_cb = QtWidgets.QCheckBox("変更される行のみ表示")
            self.missing_only_cb = QtWidgets.QCheckBox("ファイルが見つからない行のみ表示")
            self.changed_only_cb.toggled.connect(self._refresh_table)
            self.missing_only_cb.toggled.connect(self._refresh_table)
            check_all = QtWidgets.QPushButton("全チェック")
            uncheck_all = QtWidgets.QPushButton("全解除")
            check_all.clicked.connect(lambda: self._set_all_checked(True))
            uncheck_all.clicked.connect(lambda: self._set_all_checked(False))
            view.addWidget(self.changed_only_cb)
            view.addWidget(self.missing_only_cb)
            view.addStretch()
            view.addWidget(check_all)
            view.addWidget(uncheck_all)
            main.addLayout(view)

            # テーブル
            self.table = QtWidgets.QTableWidget(0, len(HEADERS))
            self.table.setHorizontalHeaderLabels(HEADERS)
            self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
            self.table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
            self.table.setAlternatingRowColors(True)
            self.table.setSortingEnabled(False)
            self.table.verticalHeader().setVisible(False)
            hdr = self.table.horizontalHeader()
            hdr.setSectionResizeMode(QtWidgets.QHeaderView.Interactive)
            hdr.setSectionResizeMode(COL_CHECK, QtWidgets.QHeaderView.ResizeToContents)
            hdr.setSectionResizeMode(COL_EXISTS, QtWidgets.QHeaderView.ResizeToContents)
            hdr.setStretchLastSection(False)
            hdr.setSectionResizeMode(COL_OLD, QtWidgets.QHeaderView.Stretch)
            hdr.setSectionResizeMode(COL_NEW, QtWidgets.QHeaderView.Stretch)
            for col, w in ((COL_NODE, 130), (COL_TYPE, 80), (COL_ATTR, 110)):
                self.table.setColumnWidth(col, w)
            self.table.itemChanged.connect(self._on_item_changed)
            self.table.cellDoubleClicked.connect(self._on_double_click)
            self.table.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
            self.table.customContextMenuRequested.connect(self._context_menu)
            main.addWidget(self.table, 1)

            # 下部
            bottom = QtWidgets.QHBoxLayout()
            self.status_label = QtWidgets.QLabel("")
            apply_btn = QtWidgets.QPushButton("置換を実行")
            apply_btn.setMinimumWidth(140)
            apply_btn.setDefault(False)
            apply_btn.setAutoDefault(False)
            apply_btn.clicked.connect(self.apply)
            bottom.addWidget(self.status_label, 1)
            bottom.addWidget(apply_btn)
            main.addLayout(bottom)

            hint = QtWidgets.QLabel(
                "ヒント: パス列をダブルクリックで検索欄へコピー / Node 列をダブルクリックでノード選択 / 右クリックでメニュー")
            hint.setStyleSheet("color: gray;")
            main.addWidget(hint)

            self.search_edit.textChanged.connect(self._schedule_preview)
            self.replace_edit.textChanged.connect(self._schedule_preview)

        # ---- logic -------------------------------------------------------
        def rescan(self, *args):
            if cmds is None:
                return
            try:
                self._root_dir = cmds.workspace(q=True, rootDirectory=True) or ""
            except RuntimeError:
                self._root_dir = ""
            self.entries = scan_scene(
                selection_only=self.scope_sel.isChecked(),
                texture_only=self.texture_only_cb.isChecked(),
                type_filter=self.type_filter_edit.text(),
            )
            for e in self.entries:
                e.exists_old = path_exists(e.path, self._root_dir)
            self.update_preview()

        def _schedule_preview(self, *args):
            self._preview_timer.start()

        def update_preview(self):
            self.search_edit.setStyleSheet("")
            self._error = ""
            try:
                replacer = build_replacer(
                    self.search_edit.text(),
                    self.replace_edit.text(),
                    use_regex=self.regex_cb.isChecked(),
                    case_sensitive=self.case_cb.isChecked(),
                    ignore_slash=self.slash_cb.isChecked(),
                )
            except re.error as ex:
                self.search_edit.setStyleSheet("background-color: #6b2b2b;")
                self._error = u"正規表現エラー: {}".format(ex)
                replacer = lambda p: (p, 0)  # noqa: E731

            normalize = self.normalize_cb.isChecked()
            for e in self.entries:
                try:
                    new, count = replacer(e.path)
                except (re.error, IndexError) as ex:  # 不正なグループ参照など
                    self._error = u"置換文字列エラー: {}".format(ex)
                    new, count = e.path, 0
                if count and normalize:
                    new = normalize_slashes(new)
                e.new_path, e.count = new, count
                e.exists_new = path_exists(new, self._root_dir) if e.changed else e.exists_old
            self._refresh_table()

        def _visible_entries(self):
            result = []
            for e in self.entries:
                if self.changed_only_cb.isChecked() and not e.changed:
                    continue
                if self.missing_only_cb.isChecked() and e.exists_new:
                    continue
                result.append(e)
            return result

        def _refresh_table(self, *args):
            visible = self._visible_entries()
            self.table.blockSignals(True)
            self.table.setRowCount(len(visible))

            changed_bg = QtGui.QColor(60, 90, 60)
            missing_fg = QtGui.QColor(230, 110, 110)
            ok_fg = QtGui.QColor(120, 200, 120)

            for row, e in enumerate(visible):
                chk = QtWidgets.QTableWidgetItem()
                chk.setFlags(QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled)
                chk.setCheckState(QtCore.Qt.Checked if e.enabled else QtCore.Qt.Unchecked)
                chk.setData(QtCore.Qt.UserRole, self.entries.index(e))
                self.table.setItem(row, COL_CHECK, chk)

                node_item = QtWidgets.QTableWidgetItem(e.node.split("|")[-1])
                node_item.setToolTip(e.node)
                if e.locked:
                    node_item.setText(node_item.text() + " (locked)")
                self.table.setItem(row, COL_NODE, node_item)
                self.table.setItem(row, COL_TYPE, QtWidgets.QTableWidgetItem(e.node_type))
                self.table.setItem(row, COL_ATTR, QtWidgets.QTableWidgetItem(e.attr))

                old_item = QtWidgets.QTableWidgetItem(e.path)
                old_item.setToolTip(e.path)
                if not e.exists_old:
                    old_item.setForeground(missing_fg)
                self.table.setItem(row, COL_OLD, old_item)

                new_item = QtWidgets.QTableWidgetItem(e.new_path if e.changed else "")
                new_item.setToolTip(e.new_path)
                if e.changed:
                    new_item.setBackground(changed_bg)
                self.table.setItem(row, COL_NEW, new_item)

                ex_item = QtWidgets.QTableWidgetItem(u"\u2714" if e.exists_new else u"\u2718")
                ex_item.setTextAlignment(QtCore.Qt.AlignCenter)
                ex_item.setForeground(ok_fg if e.exists_new else missing_fg)
                self.table.setItem(row, COL_EXISTS, ex_item)

            self.table.blockSignals(False)
            self._update_status()

        def _update_status(self):
            total = len(self.entries)
            changed = sum(1 for e in self.entries if e.changed)
            targets = sum(1 for e in self.entries if e.changed and e.enabled)
            missing = sum(1 for e in self.entries if e.changed and e.enabled and not e.exists_new)
            text = u"パス {} 件 / マッチ {} 件 / 適用対象 {} 件".format(total, changed, targets)
            if missing:
                text += u"  (うち置換後にファイルが見つからない: {} 件)".format(missing)
            if self._error:
                text = u"<span style='color:#e66e6e'>{}</span> {}".format(self._error, text)
            self.status_label.setText(text)

        def _entry_at(self, row):
            item = self.table.item(row, COL_CHECK)
            if item is None:
                return None
            return self.entries[item.data(QtCore.Qt.UserRole)]

        def _on_item_changed(self, item):
            if item.column() != COL_CHECK:
                return
            e = self.entries[item.data(QtCore.Qt.UserRole)]
            e.enabled = item.checkState() == QtCore.Qt.Checked
            self._update_status()

        def _set_all_checked(self, state):
            for e in self._visible_entries():
                e.enabled = state
            self._refresh_table()

        def _on_double_click(self, row, col):
            e = self._entry_at(row)
            if e is None:
                return
            if col in (COL_OLD, COL_NEW):
                self.search_edit.setText(e.path if col == COL_OLD else e.new_path)
                self.search_edit.setFocus()
            else:
                cmds.select(e.node, replace=True)

        def _context_menu(self, pos):
            rows = sorted({i.row() for i in self.table.selectedIndexes()})
            entries = [x for x in (self._entry_at(r) for r in rows) if x]
            if not entries:
                return
            menu = QtWidgets.QMenu(self)
            menu.addAction("ノードを選択", lambda: cmds.select([x.node for x in entries], replace=True))
            menu.addAction("チェックを入れる", lambda: self._set_checked(entries, True))
            menu.addAction("チェックを外す", lambda: self._set_checked(entries, False))
            menu.addSeparator()
            menu.addAction("現在のパスをコピー",
                           lambda: QtWidgets.QApplication.clipboard().setText(
                               "\n".join(x.path for x in entries)))
            menu.addAction("フォルダ部分を検索欄へ",
                           lambda: self.search_edit.setText(
                               os.path.dirname(entries[0].path.replace("\\", "/"))))
            menu.exec_(self.table.viewport().mapToGlobal(pos))

        def _set_checked(self, entries, state):
            for e in entries:
                e.enabled = state
            self._refresh_table()

        def _swap_fields(self):
            s, r = self.search_edit.text(), self.replace_edit.text()
            self.search_edit.setText(r)
            self.replace_edit.setText(s)

        def apply(self):
            targets = [e for e in self.entries if e.enabled and e.changed]
            if not targets:
                QtWidgets.QMessageBox.information(self, TOOL_TITLE, "置換対象がありません。")
                return
            missing = sum(1 for e in targets if not e.exists_new)
            msg = u"{} 件のパスを置換します。".format(len(targets))
            if missing:
                msg += u"\n\n置換後にファイルが見つからないパスが {} 件あります。".format(missing)
            msg += u"\n\n実行しますか? (Ctrl+Z で一括 Undo 可能)"
            if QtWidgets.QMessageBox.question(self, TOOL_TITLE, msg) != QtWidgets.QMessageBox.Yes:
                return

            ok, failed = apply_entries(targets)
            print(u"[{}] {} 件置換しました。".format(TOOL_TITLE, ok))
            for plug, reason in failed:
                print(u"[{}] 失敗: {} ({})".format(TOOL_TITLE, plug, reason))
            if failed:
                QtWidgets.QMessageBox.warning(
                    self, TOOL_TITLE,
                    u"{} 件置換、{} 件失敗しました。詳細はスクリプトエディタを参照してください。".format(
                        ok, len(failed)))
            self.rescan()
            self.status_label.setText(u"{} 件置換しました。  ".format(ok) + self.status_label.text())


def show():
    """ウィンドウを表示 (既に開いていれば閉じて開き直す)。"""
    if QtWidgets is None or cmds is None:
        raise RuntimeError("Maya (PySide2/PySide6) 上で実行してください。")
    app = QtWidgets.QApplication.instance()
    for w in app.topLevelWidgets():
        if w.objectName() == WINDOW_OBJECT_NAME:
            w.close()
            w.deleteLater()
    win = TexturePathReplaceWindow()
    win.show()
    return win


if __name__ == "__main__" and cmds is not None:
    show()

[maya][UE5.7.3]USD workflow pipline tool :レイヤー持ち背景のインポート

[Maya][UE5.7.3] USD workflow pipeline tool: Importing backgrounds with layers

https://github.com/nobolu-ootsuka-unrealengine/furcraeaMayaTool

kind を設定版を作ってUE で毎回オプションを触らなくて済むようにします。

USD Export Selection

Window > USD Stage Windowを開き


Openから S_Stg_Furcraea_Gate_Com_Geom.usda を選択


USD Stage Editor >option>collapsing>Use prim kinds for collapsingをoffにしたらStaticMeshが分かれた

Import

インポートフォルダ選択

インポートオプションにも Kind To Collapseがあるがこの設定にした。

一番上のGeom以下の全部選択して>Level>Create Packed Actor

Level Instance

BPP

BPP

Level Instance

レイヤー持ち背景のインポートできあがり。

[maya][mel]選択したグループに入った大量のメッシュのUVを自動配置するselect_Mesh_Group_To_replace_UV.mel

[maya][mel] select_Mesh_Group_To_replace_UV.mel – Automatically places UVs on a large number of meshes in a group.

ひさしぶりに楽しい楽しいmel script codingした。

Nurvs モデリングしたあとUVスケールが1×1になって全メッシュのUVが重なってる状態から

スクリプト実行で

自動配置できる。


select_Mesh_Group_To_replace_UV.mel

//実行
float $widthU = 0.05; //幅
float $heightV = 0.05; //高さ
float $ScaleU_ = 0.0625; //スケールX
float $ScaleV_ = 0.0625; //スケールY
float $newLineU_ =0.4; //改行位置
select_Mesh_Group_To_replace_UV($widthU,$heightV,$ScaleU_,$ScaleV_,$newLineU_);

//select Mesh Group To replace UV
global proc select_Mesh_Group_To_replace_UV(float $width,float $height,float $ScaleU,float $ScaleV ,float $newLineU)
{
    string $selectedArrFUllPath[] = `ls -long -sl`;
    print($selectedArrFUllPath);
    
    string $FirstSelect=$selectedArrFUllPath[0];
    string $inputNodes_mesh[] = `ls -type mesh -long -dag $FirstSelect`;
    $inputNodes_meshlong=size($inputNodes_mesh);
    string $inputNodes[];
    clear $inputNodes;
    string $mesh;
    for($d0 = 0; $d0 <$inputNodes_meshlong;$d0++){
        $mesh=$inputNodes_mesh[$d0];
        //string $parentS[] = `listRelatives -parent -path -type transform $mesh`;
        //$parent=$parentS[0];
        //$inputNodes[size($inputNodes)] = $parent;
        $bool=`gmatch $mesh "*Orig"`;
        if($bool==0){
            $inputNodes[size($inputNodes)] = $mesh;
        }
    }
    
    
    
    print("$inputNodes= ------------------------------------------------ \n");
    print($inputNodes);
    print("------------------------------------------------------------- \n");
    
    $inputNodeslong=size($inputNodes);
    
    float $buildX=0;
    float $buildY=0;
    //float $width=0.05;
    //float $height=0.05;
    for($f = 0; $f <$inputNodeslong;$f++){
        $mesh=$inputNodes[$f];
        $face=$mesh+".f[0:]";
        
        select -r $mesh;
        polyAutoProjection -lm 0 -pb 0 -ibd 1 -cm 0 -l 2 -sc 1 -o 1 -p 6 -ps 0.2 -ws 0 $face;
        select -r $face;
        
        $uvPivot=$mesh+".uvPivot";
        //setAttr $uvPivot -type double2 0.5 0.5 ;
        
        $U=$buildX*1-0.0;
        $V=$buildY*1-0.0;
        print("U:"+$U+" V:"+$V+"\n");
        //polyEditUV -relative false -u $buildX -v $buildY;
        $PivotU=$U+$width/2;
        $PivotV=$V+$height/2;
        polyEditUV -relative false -pu $PivotU -pv $PivotV -su $ScaleU -sv $ScaleU -u $buildX -v $buildY;
        //polyEditUV -relative false -pu $PivotU -pv $PivotV -su 0.8 -sv 0.8 ;
        //polyEditUV -relative false -pu $U -pv $V -su 0.2 -sv 0.2 ;
        if($buildX>$newLineU){
            print("new y----------Line\n");
            $buildY = $buildY+$width;
            $buildX = -$width;
        }
        $buildX = $buildX + $width;
    }
}
float $widthU = 0.05; //幅
float $heightV = 0.05; //高さ
float $ScaleU_ = 0.0625; //スケールX
float $ScaleV_ = 0.0625; //スケールY
float $newLineU_ =0.4; //改行位置
select_Mesh_Group_To_replace_UV($widthU,$heightV,$ScaleU_,$ScaleV_,$newLineU_);
/*
参考スクリプト
select -r group24_rearBoost_Up2.f[0:1601] group4_rear_Body1.f[0:7323] ;
setAttr "group24_rearBoost_UpShape2.uvPivot" -type double2 0.5 2.504208 ;
polyEditUV -u 0 -v 2.004208 ;
polyEditUV -pu 0.5 -pv 2.504208 -su 0.25 -sv 0.25 ;
*/

わかった事
polyEditUV コマンドは一発で6パラメータ入れないとだめ、

polyEditUV -relative false -pu $PivotU -pv $PivotV -su $ScaleU -sv $ScaleU -u $buildX -v $buildY;

polyEditUVコマンドは絶対スケール値を指定しても相対スケールが入る

polyEditUV -relative false -pu $PivotU -pv $PivotV -su $ScaleU -sv $ScaleU -u $buildX -v $buildY;