From a482e16ba71b7d5f2a49614024371cfafe93e180 Mon Sep 17 00:00:00 2001 From: 891458249 Date: Wed, 22 Apr 2026 06:30:28 +0800 Subject: [PATCH 1/3] feat(installer): Maya drag-and-drop installer with module system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to Slice 13 — lets end users install/uninstall the rbfmax plugin by dragging a Python file into the Maya viewport. Files added ----------- - installer/drag_drop_install.py — onMayaDroppedPythonFile hook + install / uninstall via Maya Module System (.mod descriptor written into the user's ~/Documents/maya/modules). Single- version install covers Maya 2022 and 2025 via per-version MAYAVERSION blocks in the .mod file. - installer/package.py — copies built .mll from build-maya-/bin/Release/ into installer/plug-ins//. Strict fail-fast build-missing preflight: exits 1 with a full cmake recipe if either version's .mll is absent. - installer/README.md — Chinese user guide covering both the end- user and developer flows, plus a troubleshooting section. - docs/install.md — repository-level user entry pointing at the installer (so GitHub viewers see the Maya install path without having to download the zip first). Files updated ------------- - README.md — adds a blockquote under Quick Start pointing Maya end users at docs/install.md. - .gitignore — excludes installer/plug-ins/2022/*.mll and installer/plug-ins/2025/*.mll (large build artifacts; users populate them via package.py). Validation ---------- mayapy install + uninstall smoke cycle on Maya 2022 and 2025: - package.py copies .mll : Maya 2022 505.5 KiB / 2025 506.0 KiB - install copies payload + .mod : both versions OK - uninstall deletes .mod + dir : both versions OK - post-uninstall residue check : zero bytes on both versions - build-missing guard : exit 1 with full cmake recipe Design rationale ---------------- - Maya Module System (rather than dropping .mll into the user's plug-ins dir) gives a clean uninstall surface — one .mod file + one directory, both in a user-owned, non-system path. - onMayaDroppedPythonFile is Autodesk's standard drag-drop hook; stable across Maya 2022 / 2024 / 2025 / 2026. - Per-version plug-in subdirectories are selected at Maya runtime via MAYAVERSION blocks in the .mod file — one install script covers all supported versions, no user choice needed. - Scene node purge (deletes mRBFNode + mRBFShape) precedes unloadPlugin during uninstall; Maya refuses unload otherwise. Stacked on top of Slice 13 — must merge after PR #11. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 5 + README.md | 5 + docs/install.md | 89 +++++++ installer/README.md | 150 ++++++++++++ installer/drag_drop_install.py | 416 +++++++++++++++++++++++++++++++++ installer/package.py | 104 +++++++++ 6 files changed, 769 insertions(+) create mode 100644 docs/install.md create mode 100644 installer/README.md create mode 100644 installer/drag_drop_install.py create mode 100644 installer/package.py diff --git a/.gitignore b/.gitignore index 74bdf5b..7df9fcd 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,8 @@ desktop.ini .unpacked_text.txt .remember/ .claude/ + +# Installer payload (built .mll copied in by installer/package.py; +# not checked in because they are large binary build products) +installer/plug-ins/2022/*.mll +installer/plug-ins/2025/*.mll diff --git a/README.md b/README.md index 33d8563..08e639e 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,11 @@ VectorX out = rbf.predict(query); rbf.save("rig.rbf.json"); ``` +> **For Maya end users**: see [`docs/install.md`](docs/install.md) for +> the drag-and-drop installer that packages the compiled plugin + a +> Maya module file into your user preferences — no build step +> required once the .mll has been packaged. + ## Architecture Eight independent modules composed into a single `RBFInterpolator`: diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..d98f989 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,89 @@ +# RBF_MAX — 安装指南(终端用户) + +本文件是给 **Maya 用户** 的安装入口。如果你只想把 RBF_MAX 插件装进 Maya 使用,**不要**从源码构建 —— 按下面的"终端用户"流程即可。 + +如果你要修改源码,参见 [`maya_node/README.md`](../maya_node/README.md)。 + +--- + +## 支持的 Maya 版本 + +- Maya **2022**(Windows x64) +- Maya **2025**(Windows x64) + +macOS / Linux 目前**未验证**;理论上 installer 逻辑跨平台,但需要在对应系统上重新构建 `.mll` / `.so` / `.bundle`。 + +--- + +## 终端用户:两步安装 + +### 方案 A:使用预打包的发行包(推荐) + +1. 从 Release 页下载 `RBF_MAX_installer_.zip` +2. 解压到任意不含中文或空格的本地路径(如 `C:\Tools\RBF_MAX_installer\`) +3. 启动 Maya 2022 或 2025 +4. **把 `drag_drop_install.py` 拖入 Maya 视窗**(3D 视窗,不是 Script Editor) +5. 在弹出的对话框里点 **"安装"** + +安装完成后插件会立即加载并设置为下次 Maya 启动时自动加载。 + +### 方案 B:从仓库源码打包后安装(开发者场景) + +如果你 clone 了本仓库并完成了 CMake 构建,跑: + +```bash +python installer/package.py +``` + +它会把 `build-maya-2022/bin/Release/rbfmax_maya.mll` 和 `build-maya-2025/bin/Release/rbfmax_maya.mll` 复制到 `installer/plug-ins//`。之后和方案 A 一样,把 `installer/drag_drop_install.py` 拖入 Maya 视窗。 + +> **注意**:如果任一版本的 `.mll` 尚未构建,`package.py` 会报错退出并给出 cmake 命令指引。 + +--- + +## 卸载 + +再次把 `drag_drop_install.py` 拖入 Maya 视窗;检测到已安装后会提供 **"重新安装 / 卸载 / 取消"** 三选项。 + +卸载流程: +- 删除场景中所有 `mRBFNode` 和 `mRBFShape` 节点 +- `unloadPlugin rbfmax_maya` +- 关闭自动加载 +- 删除 `%USERPROFILE%/Documents/maya/modules/RBF_MAX/` 目录 +- 删除 `%USERPROFILE%/Documents/maya/modules/RBF_MAX.mod` 文件 + +完全零残留。 + +--- + +## 验证安装 + +Maya Script Editor 的 Python 标签下: + +```python +import maya.cmds as cmds + +rbf = cmds.createNode('mRBFNode') +shp = cmds.createNode('mRBFShape') +cmds.connectAttr(rbf + '.message', shp + '.sourceNode') +# Viewport 2.0 下 mRBFShape 位置应显示 fit-center 的球体 +``` + +--- + +## 更多资料 + +- [`installer/README.md`](../installer/README.md) — 完整的 installer 使用说明(中文) +- [`maya_node/README.md`](../maya_node/README.md) — 插件节点 + 命令用户手册 +- [`README.md`](../README.md) — 项目总览 +- [`DEVLOG.md`](../DEVLOG.md) — 切片级开发日志 + +## 故障排查速查 + +| 症状 | 处置 | +|---|---| +| 拖入文件没反应 | 确认拖到 3D 视窗、Script Editor 底部看错误输出 | +| "找不到 Maya XXXX 对应的插件" | 运行 `python installer/package.py` 先把 .mll 打包 | +| 重启 Maya 后插件没加载 | 检查 `%USERPROFILE%/Documents/maya/modules/RBF_MAX.mod` 是否存在 | + +详细排查请看 [`installer/README.md`](../installer/README.md) 的 "故障排查" 节。 diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 0000000..7a3faf1 --- /dev/null +++ b/installer/README.md @@ -0,0 +1,150 @@ +# RBF_MAX — Maya 安装程序 + +可以**拖入 Maya 视窗**的安装脚本。一键安装 / 卸载 `rbfmax_maya` 插件(包含 `mRBFNode` + `mRBFShape` + `rbfmaxTrainAndSave` 命令)。 + +支持版本:**Maya 2022** 和 **Maya 2025**。 + +--- + +## 给普通用户(最终发布后的使用流程) + +1. 下载 `RBF_MAX_installer.zip` 或克隆本仓库后运行 `python installer/package.py` +2. 解压到任意位置(注意路径不要含中文或空格) +3. 启动 Maya +4. 把 `drag_drop_install.py` **拖入 Maya 视窗** +5. 在弹出的对话框里点 **"安装"** +6. 安装完成后,插件会自动加载,下次启动 Maya 也会自动加载 + +### 卸载 +再次把 `drag_drop_install.py` 拖入 Maya 视窗,会检测到已安装,点 **"卸载"** 即可。卸载过程会: +- 删除场景中所有 `mRBFNode` / `mRBFShape`(防止 unloadPlugin 拒绝) +- `unloadPlugin rbfmax_maya` +- 关闭自动加载(`pluginInfo -autoload false`) +- 删除 `~/Documents/maya/modules/RBF_MAX/` 整个目录 +- 删除 `~/Documents/maya/modules/RBF_MAX.mod` 描述文件 + +卸载后没有任何残留。 + +--- + +## 给开发者(构建 + 打包流程) + +### 一次性准备 + +构建两个版本的插件: +```bash +# Maya 2022 +cmake -S . -B build-maya-2022 -G "Visual Studio 17 2022" -A x64 \ + -DCMAKE_BUILD_TYPE=Release \ + -DRBF_BUILD_MAYA_NODE=ON \ + -DMAYA_VERSION=2022 \ + -DMAYA_DEVKIT_ROOT="C:/SDK/Maya2022/devkitBase" +cmake --build build-maya-2022 --config Release + +# Maya 2025 +cmake -S . -B build-maya-2025 -G "Visual Studio 17 2022" -A x64 \ + -DCMAKE_BUILD_TYPE=Release \ + -DRBF_BUILD_MAYA_NODE=ON \ + -DMAYA_VERSION=2025 \ + -DMAYA_DEVKIT_ROOT="C:/SDK/Maya2025/devkitBase" +cmake --build build-maya-2025 --config Release +``` + +### 打包到 installer/ + +```bash +python installer/package.py +``` + +这个脚本会把两个 `.mll` 从 `build-maya-*/bin/Release/` 复制到 `installer/plug-ins//`,产出如下结构: + +``` +installer/ +├── drag_drop_install.py ← 用户拖这个 +├── README.md +├── package.py +├── plug-ins/ +│ ├── 2022/rbfmax_maya.mll +│ └── 2025/rbfmax_maya.mll +└── scripts/ ← 预留,Phase 2C Qt UI 会放这里 +``` + +之后把整个 `installer/` 目录压缩为 zip 分发即可。 + +--- + +## 安装位置说明 + +模块被安装到 Maya 的**用户模块目录**(不是系统目录,不需要管理员权限): + +| 平台 | 模块目录 | +|--------|-------------------------------------------------------| +| Windows| `%USERPROFILE%/Documents/maya/modules/` | +| macOS | `~/Library/Preferences/Autodesk/maya/modules/` | +| Linux | `~/maya/modules/` | + +安装后该目录下会新增两项: +- `RBF_MAX.mod` — 模块描述文件,告诉 Maya 从哪找插件 +- `RBF_MAX/` — 目录,含 `plug-ins//rbfmax_maya.mll` + `scripts/` + +Maya 下次启动时通过 `.mod` 文件自动识别模块;当前会话中脚本也会主动调用 `loadModule` + `loadPlugin` 立即生效。 + +--- + +## 验证安装 + +安装成功后,在 Maya Script Editor 的 Python 标签下执行: + +```python +import maya.cmds as cmds + +# 创建计算节点 + 可视化节点 + 连接 +rbf = cmds.createNode('mRBFNode') +shp = cmds.createNode('mRBFShape') +cmds.connectAttr(rbf + '.message', shp + '.sourceNode') + +# 加载训练好的 JSON +cmds.setAttr(rbf + '.jsonPath', + 'C:/path/to/rig.json', type='string') +cmds.setAttr(rbf + '.queryPoint', [0.5, 0.5], type='doubleArray') +print(cmds.getAttr(rbf + '.outputValues')) + +# 确认 Viewport 2.0 里 mRBFShape 位置出现白色球体 +``` + +命令用法参见根目录 `maya_node/README.md`。 + +--- + +## 故障排查 + +### 拖入文件没反应 +- 确认你拖的是 **`drag_drop_install.py`**,不是其它文件 +- 确认拖到的是 **Maya 的 3D 视窗**(不是 Script Editor 或 Outliner) +- 查看 Script Editor 底部,拖放错误会打印在那里 + +### 安装时报"找不到 Maya XXXX 对应的插件" +- 确认 `installer/plug-ins//rbfmax_maya.mll` 存在 +- 如果是克隆仓库后直接拖,要先跑 `python installer/package.py` +- 如果你的 Maya 是 2022 / 2025 之外的版本(如 2024),目前不支持 + +### 安装后重启 Maya 插件没自动加载 +- 检查 `%USERPROFILE%/Documents/maya/modules/RBF_MAX.mod` 是否存在 +- 在 Maya 里运行 `cmds.moduleInfo(listModules=True)`,看输出里是否有 `RBF_MAX` +- 手动 `cmds.loadPlugin('rbfmax_maya')`;如果报错消息包含"no such plugin",说明 `.mod` 文件没被识别 + +### 卸载后又出现 mRBFNode 节点 +- 卸载前先打开的场景里有保存的节点;下次打开该场景会提示 "Unknown Nodes" +- 忽略或用 `cmds.delete(cmds.ls(type='mRBFNode'))` 手动清理 + +--- + +## 文件说明 + +| 文件 | 作用 | +|------|------| +| `drag_drop_install.py` | 拖入 Maya 的入口,包含 `onMayaDroppedPythonFile` 和完整安装/卸载逻辑 | +| `package.py` | 从 CMake 构建产物复制 .mll 到 installer/plug-ins// | +| `README.md` | 本文件 | +| `plug-ins//rbfmax_maya.mll` | 编译好的 Maya 插件,由 `package.py` 填充 | +| `scripts/` | 预留目录,Phase 2C Qt UI 脚本未来放这里 | diff --git a/installer/drag_drop_install.py b/installer/drag_drop_install.py new file mode 100644 index 0000000..9e9f5c1 --- /dev/null +++ b/installer/drag_drop_install.py @@ -0,0 +1,416 @@ +# -*- coding: utf-8 -*- +# ============================================================================= +# RBF_MAX — Maya 拖放安装脚本 +# ----------------------------------------------------------------------------- +# 使用方法: +# 1. 把这个 .py 文件直接拖入 Maya 视窗(任意版本 Maya 2022 / 2025) +# 2. 在弹出的对话框里选择 "安装" 或 "卸载" +# +# 技术路径: +# * 基于 Maya Module System(.mod 文件 + modules 目录),不写入 Maya 的 +# 内置 plug-ins 目录,卸载时完全可逆。 +# * 安装目标目录: +# Windows : %USERPROFILE%/Documents/maya/modules/ +# macOS : ~/Library/Preferences/Autodesk/maya/modules/ +# Linux : ~/maya/modules/ +# * Maya 下次启动时会自动识别模块,或当前会话中调用 +# MGlobal::executeCommand('loadModule -load ""') 立即生效。 +# +# 支持的 Maya 版本: 2022, 2025 +# ============================================================================= +from __future__ import print_function + +import os +import shutil +import sys +import traceback + +try: + import maya.cmds as cmds + import maya.mel as mel + _IN_MAYA = True +except ImportError: + _IN_MAYA = False + cmds = None + mel = None + + +# ----------------------------------------------------------------------------- +# 常量 +# ----------------------------------------------------------------------------- +PLUGIN_NAME = "rbfmax_maya" # .mll 文件名(不含扩展) +MODULE_NAME = "RBF_MAX" # Maya module 名 +MODULE_VERSION = "1.1.0" +SUPPORTED_VERSIONS = ("2022", "2025") + +# 随安装包分发的相对路径:plug-ins//rbfmax_maya.mll +_REL_PLUGIN_DIR = "plug-ins" + + +# ----------------------------------------------------------------------------- +# 工具函数 +# ----------------------------------------------------------------------------- +def _installer_dir(): + """返回本脚本所在目录的绝对路径(拖放或 execfile 均可用)。""" + try: + return os.path.abspath(os.path.dirname(__file__)) + except NameError: + # 极少数 Maya 版本下 __file__ 未定义;尝试从栈帧取 + import inspect + frame = inspect.currentframe() + return os.path.abspath(os.path.dirname( + inspect.getsourcefile(frame) or os.getcwd())) + + +def _maya_version(): + """返回字符串 '2022' / '2025' 等。""" + if not _IN_MAYA: + raise RuntimeError("This script must be run inside Maya.") + return str(cmds.about(version=True)).strip().split()[0] + + +def _user_modules_dir(): + """返回用户 Maya modules 目录(创建如不存在)。""" + if sys.platform == "win32": + base = os.path.join(os.path.expanduser("~"), "Documents", "maya") + elif sys.platform == "darwin": + base = os.path.expanduser( + "~/Library/Preferences/Autodesk/maya") + else: + base = os.path.expanduser("~/maya") + modules_dir = os.path.join(base, "modules") + if not os.path.isdir(modules_dir): + os.makedirs(modules_dir) + return modules_dir + + +def _module_target_root(): + """模块被安装到的目标根目录 (/RBF_MAX).""" + return os.path.join(_user_modules_dir(), MODULE_NAME) + + +def _module_file_path(): + """模块描述文件 (/RBF_MAX.mod) 的绝对路径。""" + return os.path.join(_user_modules_dir(), MODULE_NAME + ".mod") + + +def _source_mll(version): + """安装包内的 .mll 源路径。""" + return os.path.join(_installer_dir(), + _REL_PLUGIN_DIR, + version, + PLUGIN_NAME + ".mll") + + +def _message(msg, icon="information"): + """在 Maya 里弹对话框,或 fallback 到 print。""" + if _IN_MAYA: + cmds.confirmDialog( + title="RBF_MAX " + MODULE_VERSION, + message=msg, + button=["确定"], + icon=icon) + else: + print(msg) + + +def _log(msg): + """写入 Maya Script Editor + stdout。""" + line = "[RBF_MAX installer] " + msg + print(line) + if _IN_MAYA: + try: + cmds.inViewMessage(amg=line, pos="midCenter", fade=True) + except Exception: # noqa: BLE001 + pass + + +# ----------------------------------------------------------------------------- +# 安装 +# ----------------------------------------------------------------------------- +def _write_module_file(module_root, version): + """ + 写 RBF_MAX.mod 文件。 + + Maya 模块文件语法: + + MAYAVERSION: + plug-ins: plug-ins/ + scripts: scripts + """ + mod_path = _module_file_path() + lines = [] + for v in SUPPORTED_VERSIONS: + # 每个支持的版本一行;Maya 会只挑对应自己版本的那行生效。 + lines.append("+ MAYAVERSION:{ver} {name} {vn} {root}".format( + ver=v, + name=MODULE_NAME, + vn=MODULE_VERSION, + root=module_root.replace("\\", "/"))) + lines.append("plug-ins: {rel}/{ver}".format( + rel=_REL_PLUGIN_DIR, ver=v)) + lines.append("scripts: scripts") + lines.append("") # 空行分隔版本块 + + with open(mod_path, "w") as f: + f.write("\n".join(lines)) + _log("wrote module file: " + mod_path) + + +def _copy_payload(module_root): + """把 installer 目录里的 plug-ins/ 和 scripts/ 复制到 module_root。""" + src_root = _installer_dir() + for sub in (_REL_PLUGIN_DIR, "scripts"): + src = os.path.join(src_root, sub) + dst = os.path.join(module_root, sub) + if os.path.isdir(dst): + shutil.rmtree(dst) + if os.path.isdir(src): + shutil.copytree(src, dst) + _log("copied {s} -> {d}".format(s=src, d=dst)) + else: + # scripts 可能不存在,跳过 + os.makedirs(dst) + + +def _install(): + version = _maya_version() + if version not in SUPPORTED_VERSIONS: + _message( + "当前 Maya 版本 {v} 未在支持列表内。\n" + "支持的版本:{sup}".format( + v=version, sup=", ".join(SUPPORTED_VERSIONS)), + icon="warning") + return + + src_mll = _source_mll(version) + if not os.path.isfile(src_mll): + _message( + "在安装包中找不到 Maya {v} 对应的插件:\n {p}\n\n" + "请先构建插件(参见 README)。".format( + v=version, p=src_mll), + icon="critical") + return + + module_root = _module_target_root() + mod_file = _module_file_path() + + # 如果已经安装,先卸载再重装(清洁升级路径)。 + if os.path.isfile(mod_file) or os.path.isdir(module_root): + _log("existing installation detected; doing clean reinstall") + _uninstall_core(quiet=True) + + # 1) 建目录 + 拷贝 payload + if not os.path.isdir(module_root): + os.makedirs(module_root) + _copy_payload(module_root) + + # 2) 写 .mod 文件 + _write_module_file(module_root, module_root) + + # 3) 当前会话中立即加载模块 + 插件 + try: + mel.eval('loadModule -load "{0}"'.format( + mod_file.replace("\\", "/"))) + except Exception as e: # noqa: BLE001 + # 某些 Maya 版本 loadModule 不存在或行为差异;不致命。 + _log("loadModule mel eval warning: " + str(e)) + + # 刷新插件搜索路径 + try: + plug_in_dir = os.path.join( + module_root, _REL_PLUGIN_DIR, version) + if os.environ.get("MAYA_PLUG_IN_PATH", ""): + os.environ["MAYA_PLUG_IN_PATH"] = ( + plug_in_dir + os.pathsep + + os.environ["MAYA_PLUG_IN_PATH"]) + else: + os.environ["MAYA_PLUG_IN_PATH"] = plug_in_dir + except Exception as e: # noqa: BLE001 + _log("env update warning: " + str(e)) + + # 4) 加载插件 + 设置自动加载 + try: + cmds.loadPlugin(PLUGIN_NAME, quiet=False) + cmds.pluginInfo(PLUGIN_NAME, edit=True, autoload=True) + except Exception as e: # noqa: BLE001 + _message( + "模块已写入,但加载插件时发生错误:\n{0}\n\n" + "请重启 Maya 后再试。".format(e), + icon="warning") + return + + _message( + "✅ RBF_MAX {ver} 已成功安装到 Maya {mv}\n\n" + "模块位置:\n {root}\n\n" + "插件已加载,下次启动 Maya 会自动加载。\n\n" + "验证:\n import maya.cmds as cmds\n" + " cmds.createNode('mRBFNode')\n" + " cmds.createNode('mRBFShape')".format( + ver=MODULE_VERSION, mv=version, root=module_root)) + + +# ----------------------------------------------------------------------------- +# 卸载 +# ----------------------------------------------------------------------------- +def _uninstall_core(quiet=False): + """核心卸载流程(供卸载入口 + 升级重装共用)。""" + errors = [] + + # 1) 卸载插件 + 清除 autoload + if _IN_MAYA: + try: + if cmds.pluginInfo(PLUGIN_NAME, q=True, loaded=True): + # 先删掉所有 mRBFNode / mRBFShape 场景节点,否则 unload 拒绝 + for ntype in ("mRBFShape", "mRBFNode"): + try: + nodes = cmds.ls(type=ntype) or [] + if nodes: + cmds.delete(nodes) + _log("deleted {0} {1} node(s)".format( + len(nodes), ntype)) + except Exception as e: # noqa: BLE001 + errors.append("delete {0}: {1}".format(ntype, e)) + cmds.flushUndo() + cmds.unloadPlugin(PLUGIN_NAME) + _log("unloaded plugin " + PLUGIN_NAME) + except Exception as e: # noqa: BLE001 + errors.append("unloadPlugin: " + str(e)) + try: + cmds.pluginInfo(PLUGIN_NAME, edit=True, autoload=False) + except Exception: + pass # 未注册过就没 autoload 可清 + + # 2) 删 .mod 文件 + mod_file = _module_file_path() + if os.path.isfile(mod_file): + try: + os.remove(mod_file) + _log("removed mod file: " + mod_file) + except Exception as e: # noqa: BLE001 + errors.append("remove mod: " + str(e)) + + # 3) 删模块根目录 + module_root = _module_target_root() + if os.path.isdir(module_root): + try: + shutil.rmtree(module_root) + _log("removed module dir: " + module_root) + except Exception as e: # noqa: BLE001 + errors.append("remove module dir: " + str(e)) + + if errors and not quiet: + _message( + "卸载完成但有以下警告:\n\n" + "\n".join(errors), + icon="warning") + return errors + + +def _uninstall(): + if not (os.path.isfile(_module_file_path()) + or os.path.isdir(_module_target_root())): + _message("未检测到已安装的 RBF_MAX。", icon="information") + return + _uninstall_core(quiet=False) + _message( + "✅ RBF_MAX 已完全卸载。\n\n" + "被删除:\n 模块描述 {mod}\n 模块目录 {dir}".format( + mod=_module_file_path(), dir=_module_target_root())) + + +# ----------------------------------------------------------------------------- +# 入口:拖放到 Maya 视窗时调用 +# ----------------------------------------------------------------------------- +def _run(): + if not _IN_MAYA: + print("This script must be dragged into a Maya viewport.") + return + + version = _maya_version() + if version not in SUPPORTED_VERSIONS: + _message( + "检测到 Maya {v};本安装包仅支持: {sup}".format( + v=version, sup=", ".join(SUPPORTED_VERSIONS)), + icon="warning") + return + + already = (os.path.isfile(_module_file_path()) + or os.path.isdir(_module_target_root())) + + if already: + # 已安装 — 提供"重新安装 / 卸载 / 取消" + choice = cmds.confirmDialog( + title="RBF_MAX " + MODULE_VERSION, + message=( + "RBF_MAX 已安装。\n\n" + "模块位置:\n {root}\n\n" + "你想要?".format(root=_module_target_root())), + button=["重新安装", "卸载", "取消"], + defaultButton="取消", + cancelButton="取消", + dismissString="取消") + if choice == "重新安装": + _install() + elif choice == "卸载": + _uninstall() + # 取消 — 不动 + else: + # 未安装 — 提供"安装 / 取消" + choice = cmds.confirmDialog( + title="RBF_MAX " + MODULE_VERSION, + message=( + "欢迎使用 RBF_MAX 安装程序\n\n" + "当前 Maya 版本: {v}\n" + "安装目标目录:\n {dir}\n\n" + "是否安装?".format( + v=version, dir=_module_target_root())), + button=["安装", "取消"], + defaultButton="安装", + cancelButton="取消", + dismissString="取消") + if choice == "安装": + _install() + + +def onMayaDroppedPythonFile(obj): # noqa: N802 (Maya API name) + """Maya 把本 .py 文件拖入视窗时调用的入口。""" + try: + _run() + except Exception as exc: # noqa: BLE001 + tb = traceback.format_exc() + print("[RBF_MAX installer] FATAL: {0}\n{1}".format(exc, tb)) + try: + cmds.confirmDialog( + title="RBF_MAX 安装程序错误", + message="发生未预期错误:\n\n{0}\n\n{1}".format(exc, tb), + button=["确定"], + icon="critical") + except Exception: + pass + + +# 允许直接 `mayapy drag_drop_install.py install|uninstall` 用作命令行测试 +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] in ("install", "uninstall"): + # 手动初始化 maya.standalone + try: + import maya.standalone as mstand + mstand.initialize(name="python") + import maya.cmds as cmds # noqa: F811 + import maya.mel as mel # noqa: F811 + globals()["cmds"] = cmds + globals()["mel"] = mel + globals()["_IN_MAYA"] = True + if sys.argv[1] == "install": + _install() + else: + _uninstall() + finally: + try: + import maya.standalone as mstand + mstand.uninitialize() + except Exception: + pass + else: + print("此脚本设计为拖入 Maya 视窗运行。") + print("也可用 mayapy 调用: " + "mayapy drag_drop_install.py [install|uninstall]") diff --git a/installer/package.py b/installer/package.py new file mode 100644 index 0000000..d19006d --- /dev/null +++ b/installer/package.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +""" +RBF_MAX — 打包脚本 + +把 CMake 构建产物 (build-maya-2022 / build-maya-2025 下的 +rbfmax_maya.mll) 复制到 installer/plug-ins// 以形成可分发的 +installer/ 文件夹(用户拖 drag_drop_install.py 到 Maya 就能装)。 + +用法: + python installer/package.py + +要求: + * build-maya-2022/bin/Release/rbfmax_maya.mll 已构建 + * build-maya-2025/bin/Release/rbfmax_maya.mll 已构建 +""" +from __future__ import print_function + +import os +import shutil +import sys + +REPO_ROOT = os.path.abspath( + os.path.join(os.path.dirname(__file__), os.pardir)) +INSTALLER_DIR = os.path.join(REPO_ROOT, "installer") + +VERSIONS = ("2022", "2025") + + +def _source(ver): + return os.path.join(REPO_ROOT, + "build-maya-" + ver, + "bin", "Release", + "rbfmax_maya.mll") + + +def _target(ver): + d = os.path.join(INSTALLER_DIR, "plug-ins", ver) + if not os.path.isdir(d): + os.makedirs(d) + return os.path.join(d, "rbfmax_maya.mll") + + +def main(): + # -- Build-missing preflight (fail-fast, strict) -------------------- + # The installer only makes sense as a fully-packaged artifact; a + # partial zip missing one version's .mll would silently ship to + # users and fail at install time with a cryptic error. Enforce + # "all-or-nothing". + missing = [] + for ver in VERSIONS: + src = _source(ver) + if not os.path.isfile(src): + missing.append((ver, src)) + + if missing: + print("ERROR: Maya plugin build missing for version(s): " + + ", ".join(v for v, _ in missing), file=sys.stderr) + print("", file=sys.stderr) + print("Expected build artifact(s):", file=sys.stderr) + for ver, src in missing: + print(" Maya {v}: {s}".format(v=ver, s=src), + file=sys.stderr) + print("", file=sys.stderr) + print("Please build the plugin first for each missing version:", + file=sys.stderr) + print("", file=sys.stderr) + print(" cmake -S . -B build-maya-" + " -G \"Visual Studio 17 2022\" -A x64 \\", + file=sys.stderr) + print(" -DCMAKE_BUILD_TYPE=Release \\", + file=sys.stderr) + print(" -DRBF_BUILD_MAYA_NODE=ON" + " -DMAYA_VERSION= \\", + file=sys.stderr) + print(" -DMAYA_DEVKIT_ROOT=\"\"", + file=sys.stderr) + print(" cmake --build build-maya- --config Release", + file=sys.stderr) + print("", file=sys.stderr) + print("See maya_node/README.md for full build instructions.", + file=sys.stderr) + return 1 + + # -- Copy payloads -------------------------------------------------- + ok = [] + for ver in VERSIONS: + src = _source(ver) + dst = _target(ver) + shutil.copy2(src, dst) + size_kb = os.path.getsize(dst) / 1024.0 + ok.append((ver, dst, size_kb)) + print("[OK] Maya {v}: {d} ({s:.1f} KiB)".format( + v=ver, d=dst, s=size_kb)) + + print() + print("Packaged {0} plugin version(s) to installer/plug-ins/".format( + len(ok))) + print("End users: drag installer/drag_drop_install.py into " + "Maya viewport to install.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 4c0777f2b1adfc2ec9a6d34b4ae6895a798b2689 Mon Sep 17 00:00:00 2001 From: 891458249 Date: Wed, 22 Apr 2026 08:09:22 +0800 Subject: [PATCH 2/3] fix(installer): resolve Documents folder via Maya internalVar, not HOME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows machines with HOME env var set (Git Bash / MSYS / Cygwin / WSL commonly set it to Documents), os.path.expanduser('~') returns C:/Users//Documents instead of C:/Users/, causing the installer to write .mod file and module tree to C:/Users//Documents/Documents/maya/modules/ — a path Maya never scans. Installer reported success but plugin never loaded; createNode fell back to unknown placeholder nodes. Fix: use cmds.internalVar(userAppDir=True) when inside Maya (Maya uses SHGetFolderPath independent of HOME env), and USERPROFILE as fallback outside Maya on Windows (USERPROFILE is the Windows-standard variable and never modified by Unix tools). Discovered during Slice 13 GUI verification — user's HOME was set to Documents by a prior Git Bash install; mayapy automated smokes did not catch this because mayapy in CI-style setups typically runs with a cleaner env. --- installer/drag_drop_install.py | 46 +++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/installer/drag_drop_install.py b/installer/drag_drop_install.py index 9e9f5c1..8ea524d 100644 --- a/installer/drag_drop_install.py +++ b/installer/drag_drop_install.py @@ -70,15 +70,43 @@ def _maya_version(): def _user_modules_dir(): - """返回用户 Maya modules 目录(创建如不存在)。""" - if sys.platform == "win32": - base = os.path.join(os.path.expanduser("~"), "Documents", "maya") - elif sys.platform == "darwin": - base = os.path.expanduser( - "~/Library/Preferences/Autodesk/maya") - else: - base = os.path.expanduser("~/maya") - modules_dir = os.path.join(base, "modules") + """返回用户 Maya modules 目录(创建如不存在)。 + + FIX (slice-13 installer followup): 原实现用 os.path.expanduser("~") + 解析 Windows 家目录。当用户设了 HOME 环境变量(Git Bash / MSYS / + Cygwin / WSL 等会改它指向 Documents),expanduser("~") 返回 + C:/Users//Documents 而非 C:/Users/,拼接出双 + "Documents/Documents/maya/modules",.mod 文件落到 Maya 扫描不到的 + 位置,用户看到安装成功但插件无法加载。 + + 修复方案: Maya 内优先用 cmds.internalVar(userAppDir=True)(Maya 内部 + 用 SHGetFolderPath 解析,不受 HOME 干扰)。非 Maya fallback 在 + Windows 下强制用 USERPROFILE(Windows 标准、不被 Unix 工具污染)。 + """ + base = None + if _IN_MAYA: + try: + # Maya 的路径解析不受 HOME 干扰 + user_app_dir = cmds.internalVar(userAppDir=True) + if user_app_dir: + # userAppDir 通常带尾斜杠: "C:/Users/.../Documents/maya/" + base = user_app_dir.rstrip("/").rstrip("\\") + except Exception: # noqa: BLE001 + base = None + + if base is None: + if sys.platform == "win32": + # Fallback: 优先 USERPROFILE,不用 expanduser("~") + user_home = (os.environ.get("USERPROFILE") + or os.path.expanduser("~")) + base = os.path.join(user_home, "Documents", "maya") + elif sys.platform == "darwin": + base = os.path.expanduser( + "~/Library/Preferences/Autodesk/maya") + else: + base = os.path.expanduser("~/maya") + + modules_dir = os.path.join(base, "modules").replace("\\", "/") if not os.path.isdir(modules_dir): os.makedirs(modules_dir) return modules_dir From 340283c216e6f92e715c77cb0e7fea0c770229e9 Mon Sep 17 00:00:00 2001 From: 891458249 Date: Wed, 22 Apr 2026 08:33:51 +0800 Subject: [PATCH 3/3] fix(installer): handle Windows .mll file lock during uninstall/reinstall On Windows, even after cmds.unloadPlugin('rbfmax_maya'), the .mll file is held by Maya's DLL loader (FreeLibrary may defer). shutil.rmtree fails with [WinError 5] Permission Denied. Prior to this fix, reinstall flow threw a PermissionError that aborted _install, and uninstall reported 'completed with warnings' while silently leaving the .mll behind (misleading because autoload would still fire next launch if .mod was recreated). Changes: - Add _safe_rmtree with retry+sleep (Windows DLL handle release lag) - Add _force_overwrite_tree as file-level fallback when rmtree fails - After unloadPlugin, gc.collect() + time.sleep(0.5) to encourage FreeLibrary completion - _copy_payload falls back to file-level overwrite instead of aborting - _uninstall dialog now distinguishes clean uninstall vs partial (with explicit 'restart Maya to finish cleanup' instruction) Fundamental Windows limitation remains: fully hot reinstall (same session, new .mll replacing locked .mll) is not achievable and not attempted. Hot uninstall + cold reinstall (after Maya restart) is the supported path. Discovered during Slice 13 GUI verification when user reinstalled in same session. --- installer/drag_drop_install.py | 108 +++++++++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 13 deletions(-) diff --git a/installer/drag_drop_install.py b/installer/drag_drop_install.py index 8ea524d..a5d30df 100644 --- a/installer/drag_drop_install.py +++ b/installer/drag_drop_install.py @@ -24,6 +24,8 @@ import shutil import sys import traceback +import gc +import time try: import maya.cmds as cmds @@ -184,20 +186,77 @@ def _write_module_file(module_root, version): _log("wrote module file: " + mod_path) +def _safe_rmtree(path, retries=5, delay=0.2): + """Windows 下 .mll 句柄可能延迟释放;rmtree 加 retry。 + 成功返回 None;全部失败返回最后捕获的异常(不抛)。""" + if not os.path.isdir(path): + return None + last_err = None + for _ in range(retries): + try: + shutil.rmtree(path) + return None + except (PermissionError, OSError) as e: + last_err = e + time.sleep(delay) + return last_err + + +def _force_overwrite_tree(src, dst): + """文件级覆盖;失败的单文件记录但不中断。返回失败列表。""" + failures = [] + for root, dirs, files in os.walk(src): + rel = os.path.relpath(root, src) + dst_root = os.path.join(dst, rel) if rel != '.' else dst + if not os.path.isdir(dst_root): + try: + os.makedirs(dst_root) + except Exception: # noqa: BLE001 + pass + for f in files: + src_f = os.path.join(root, f) + dst_f = os.path.join(dst_root, f) + try: + if os.path.isfile(dst_f): + os.remove(dst_f) + shutil.copy2(src_f, dst_f) + except Exception as e: # noqa: BLE001 + failures.append((dst_f, str(e))) + return failures + + def _copy_payload(module_root): - """把 installer 目录里的 plug-ins/ 和 scripts/ 复制到 module_root。""" + """把 installer 目录里的 plug-ins/ 和 scripts/ 复制到 module_root。 + + Windows 注意: 若 dst 中的 .mll 当前被 Maya 持有锁,rmtree 会失败。 + 此时退化为文件级覆盖(_force_overwrite_tree):单个 .mll 写不进去 + 时记录而不中断,让 _install 继续完成 .mod 写入;下次启动 Maya + autoload 仍然生效。""" src_root = _installer_dir() for sub in (_REL_PLUGIN_DIR, "scripts"): src = os.path.join(src_root, sub) dst = os.path.join(module_root, sub) if os.path.isdir(dst): - shutil.rmtree(dst) + err = _safe_rmtree(dst) + if err is not None: + _log("rmtree failed ({0}); falling back to file-level " + "overwrite".format(err)) + if os.path.isdir(src): + failures = _force_overwrite_tree(src, dst) + if failures: + _log("file-level overwrite had {0} failure(s); " + "first: {1}".format(len(failures), failures[0])) + else: + _log("file-level overwrite ok: {0} -> {1}".format( + src, dst)) + continue if os.path.isdir(src): shutil.copytree(src, dst) _log("copied {s} -> {d}".format(s=src, d=dst)) else: # scripts 可能不存在,跳过 - os.makedirs(dst) + if not os.path.isdir(dst): + os.makedirs(dst) def _install(): @@ -301,6 +360,10 @@ def _uninstall_core(quiet=False): cmds.flushUndo() cmds.unloadPlugin(PLUGIN_NAME) _log("unloaded plugin " + PLUGIN_NAME) + # Windows: give FreeLibrary a moment to actually release + # the DLL so the subsequent rmtree is not blocked. + gc.collect() + time.sleep(0.5) except Exception as e: # noqa: BLE001 errors.append("unloadPlugin: " + str(e)) try: @@ -317,14 +380,22 @@ def _uninstall_core(quiet=False): except Exception as e: # noqa: BLE001 errors.append("remove mod: " + str(e)) - # 3) 删模块根目录 + # 3) 删模块根目录(Windows 下 .mll 可能仍被 Maya 持有锁) module_root = _module_target_root() if os.path.isdir(module_root): - try: - shutil.rmtree(module_root) + err = _safe_rmtree(module_root) + if err is None: _log("removed module dir: " + module_root) - except Exception as e: # noqa: BLE001 - errors.append("remove module dir: " + str(e)) + else: + # 部分残留 — 列出残留文件方便用户判断 + remaining = [] + for r, d, fs in os.walk(module_root): + for f in fs: + remaining.append(os.path.join(r, f)) + errors.append( + "module dir partially removed ({0} files remain, " + "likely .mll locked by Maya; restart Maya to clean): " + "{1}".format(len(remaining), module_root)) if errors and not quiet: _message( @@ -338,11 +409,22 @@ def _uninstall(): or os.path.isdir(_module_target_root())): _message("未检测到已安装的 RBF_MAX。", icon="information") return - _uninstall_core(quiet=False) - _message( - "✅ RBF_MAX 已完全卸载。\n\n" - "被删除:\n 模块描述 {mod}\n 模块目录 {dir}".format( - mod=_module_file_path(), dir=_module_target_root())) + # quiet=True so we own the dialog flow and can distinguish clean + # vs partial outcomes by inspecting the returned errors list. + errors = _uninstall_core(quiet=True) + if not errors: + _message( + "✅ RBF_MAX 已完全卸载。\n\n" + "被删除:\n 模块描述 {mod}\n 模块目录 {dir}".format( + mod=_module_file_path(), dir=_module_target_root())) + else: + msg = ( + "⚠️ 卸载部分完成\n\n" + "以下问题需要重启 Maya 后手动清理:\n\n" + + "\n".join(" • " + e for e in errors) + + "\n\nautoload 已关闭,下次启动 Maya 不会再加载该插件。\n" + "重启 Maya 后可再次运行本 installer 的卸载来清残留。") + _message(msg, icon="warning") # -----------------------------------------------------------------------------