diff --git a/.clineignore b/.clineignore new file mode 100644 index 0000000..6c6f389 --- /dev/null +++ b/.clineignore @@ -0,0 +1,147 @@ +# YWTA Tools - Cline Ignore Rules +# Clineが処理しない/読み込まないファイルとディレクトリを指定 + +# コンパイル済みファイル +*.mll +*.dll +*.so +*.dylib +*.pyd +*.pyc +*.pyo +__pycache__/ +*.py[cod] +*$py.class + +# ビルド関連 +build/ +build.*/ +maya/cpp/build.2022/ +maya/cpp/build.2024/ +maya/plug-ins/2022/ +maya/plug-ins/2024/ +dist/ +target/ +*.egg-info/ + +# サードパーティライブラリ +maya/cpp/third-party/ +node_modules/ +.eggs/ +lib/ +lib64/ + +# IDE/エディタ設定 +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# ログファイル +*.log +pip-log.txt +pip-delete-this-directory.txt + +# テスト関連 +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ +.pytest_cache/ + +# 一時ファイル +*.tmp +*.temp +*.bak +*.backup +*.orig + +# Maya固有 +*.ma~ +*.mb~ +*.mel~ +*.py~ + +# Blender固有 +*.blend1 +*.blend2 +*.blend~ + +# バイナリファイル +*.exe +*.bin +*.dat + +# 大容量ファイル +*.fbx +*.obj +*.abc +*.usd +*.usda +*.usdc + +# アーカイブファイル +*.zip +*.rar +*.7z +*.tar +*.gz + +# 画像ファイル(アイコン以外) +*.tga +*.exr +*.hdr +*.tiff +*.tif +*.bmp +*.gif +*.jpg +*.jpeg +# *.png # アイコンファイルがあるためコメントアウト + +# 動画ファイル +*.mov +*.mp4 +*.avi +*.mkv + +# 音声ファイル +*.wav +*.mp3 +*.aac + +# ドキュメント(大容量) +*.pdf +*.doc +*.docx + +# 設定ファイル(機密情報含む可能性) +.env +.env.local +config.ini +secrets.json + +# Git関連 +.git/ +.gitmodules + +# パッケージマネージャー +requirements-dev.txt +poetry.lock +Pipfile.lock + +# プロファイリング +*.prof +*.profile + +# その他の開発ツール +.mypy_cache/ +.dmypy.json +dmypy.json diff --git a/.clinerules/current-sprint.md b/.clinerules/current-sprint.md new file mode 100644 index 0000000..1bde0e8 --- /dev/null +++ b/.clinerules/current-sprint.md @@ -0,0 +1,205 @@ +# YWTA Tools - Current Sprint Rules + +## 開発中の機能 + +### 機能名: [プロジェクトの整理] +- **説明**: [プロジェクト内のスクリプトの設計を見直し、整理したい] +- **対象**: Maya + +#### 技術仕様 +- **実装方法**: Python +- **依存関係**: [必要なライブラリやモジュール] +- **APIバージョン**: Maya API 2.0 + +#### ファイル構成 +``` +関連ファイル: +- maya/ywta/[category]/[module_name].py +- maya/icons/[icon_name].png (必要に応じて) +- maya/ywta/test/test_[module_name].py +``` + +#### 実装チェックリスト +- [ ] コードの整理とリファクタリング +- [ ] ドキュメントの更新 + +## 考慮事項 + +### Pyside2とPyside6の併用 +ImportErrorを避けるため、Pyside2とPyside6の両方をサポートするコードを記述します。以下のコードスニペットを使用して、適切なモジュールをインポートしてください。 + +``` +try: + from PySide6.QtCore import QObject, Qt +except ImportError: + from PySide2.QtCore import QObject, Qt +``` + +### ywta.shortcutsの依存をywta.coreに移行 +`ywta.shortcuts`はDeprecatedなので、モジュールの依存関係を`ywta.core`に移行し、汎用ユーティリティ関数を提供するようにします。 + +## 現在の設計の概要 + +### 1. __モジュール構造__ + +- __機能別分類__: rig/, deform/, mesh/, anim/, io/, ui/, utility/ など機能ごとに整理 +- __階層構造__: 各カテゴリ内でさらに細分化(例:rig/controls/, rig/face/) +- __プラグインシステム__: plugins/ディレクトリでC++プラグインとの連携 + +### 2. __初期化システム__ + +- `userSetup.py` → `ywta.initialize()` → `menu.py`の順で初期化 +- `reloadmodules.py`でモジュールの動的リロード機能 + +### 3. __メニューシステム__ + +- `menu.py`で一元的にMayaメニューを管理 +- 機能別にサブメニューを構成(Animation, Mesh, Rigging, Deform, Utility) + +## 設計上の課題と改善点 + +### __課題1: 設定管理の不備__ + +- `settings.py`が非常にシンプル(DOCUMENTATION_ROOT, ENABLE_PLUGINSのみ) +- ハードコードされた設定が各モジュールに散在 + +### __課題2: 依存関係の複雑化__ + +- モジュール間の依存関係が不明確 +- `shortcuts.py`が汎用ユーティリティとして多くのモジュールから参照される可能性 + +### __課題3: エラーハンドリングの一貫性不足__ + +- ログシステムは存在するが、統一的なエラーハンドリング戦略が不明確 + +### __課題4: テスト体系の不完全性__ + +- `test/`ディレクトリは存在するが、各機能モジュールとの対応が不明確 + +### __課題5: プラグイン管理の複雑性__ + +- PythonプラグインとC++プラグインの管理が分離されている + +## 改善提案 + +## 課題1: 設定管理システムの強化 + +### __タスク1-1: 設定クラスの設計__[done] + +- `ywta/config/` ディレクトリの作成 +- `BaseConfig` クラスの実装(型ヒント付き) +- 環境変数、設定ファイル、デフォルト値の優先順位システム +- 設定値の検証機能 + +### __タスク1-2: 設定ファイルの構造化__[done] + +- `config.yaml` または `config.json` の作成 +- 機能別設定セクション(rig, deform, ui, plugins等) +- ユーザー設定とシステム設定の分離 + +### __タスク1-3: 設定管理APIの実装__[done] + +- `get_config()`, `set_config()`, `reset_config()` 関数 +- 設定変更時のコールバック機能 +- 設定の永続化機能 + +## 課題2: 依存関係の明確化 + +### __タスク2-1: 依存関係マップの作成__[done] + +- 各モジュールの `__init__.py` に依存関係を明記 +- 依存関係グラフの可視化スクリプト作成 +- 循環依存の検出ツール + +### __タスク2-2: インターフェース定義__[skipped] + +- `ywta/interfaces/` ディレクトリの作成 +- 抽象基底クラス(ABC)の定義 +- プロトコル(typing.Protocol)の活用 + +### __タスク2-3: モジュール分離の改善__ + +- `shortcuts.py` の機能分割(maya_utils.py, geometry_utils.py等) +- 共通ユーティリティの `ywta/core/` への移動 +- レイヤードアーキテクチャの導入 + +## 課題3: エラーハンドリングの統一 + +### __タスク3-1: 例外クラス階層の設計__ + +- `ywta/exceptions.py` の作成 +- `YWTAError`, `RigError`, `DeformError` 等の例外クラス +- エラーコードシステムの導入 + +### __タスク3-2: ログシステムの強化__ + +- 統一ログ設定(`ywta/logging_config.py`) +- 機能別ロガーの作成 +- ログレベルの動的変更機能 + +### __タスク3-3: ユーザーフレンドリーなエラー表示__ + +- Maya UI用エラーダイアログクラス +- エラーメッセージの多言語対応 +- エラー解決のヒント機能 + +## 課題4: プラグインアーキテクチャの改善 + +### __タスク4-1: プラグイン登録システム__ + +- `ywta/plugin_manager.py` の作成 +- プラグインメタデータ(version, dependencies等) +- プラグイン検索・発見機能 + +### __タスク4-2: 動的ロード機能__ + +- プラグインの有効/無効切り替え +- 依存関係を考慮したロード順序 +- プラグインアンロード時のクリーンアップ + +### __タスク4-3: プラグイン間通信__ + +- イベントシステムの実装 +- プラグイン間のメッセージパッシング +- 共有データストレージ + +## 課題5: テスト体系の整備 + +### __タスク5-1: テスト構造の再編__ + +- 各機能モジュールに対応するテストファイル作成 +- `test_rig/`, `test_deform/` 等のディレクトリ構造 +- テストユーティリティクラスの作成 + +### __タスク5-2: テストカバレッジの向上__ + +- 単体テスト(unittest)の充実 +- 統合テスト(Maya環境での実行) +- パフォーマンステストの追加 + +### __タスク5-3: テスト自動化__ + +- `pytest` への移行検討 +- テストレポート生成機能 +- 継続的テスト実行環境 + +## 実装優先順位の提案 + +### __フェーズ1(基盤整備)__ + +1. タスク1-1, 1-2: 設定システムの基盤 +2. タスク3-1, 3-2: エラーハンドリングの基盤 +3. タスク2-1: 依存関係の可視化 + +### __フェーズ2(アーキテクチャ改善)__ + +1. タスク2-2, 2-3: モジュール構造の改善 +2. タスク4-1, 4-2: プラグインシステムの改善 +3. タスク5-1: テスト構造の整備 + +### __フェーズ3(機能拡張)__ + +1. タスク1-3: 設定管理APIの完成 +2. タスク3-3: ユーザーエクスペリエンスの向上 +3. タスク4-3, 5-2, 5-3: 高度な機能の実装 + diff --git a/.clinerules/project_summery.md b/.clinerules/project_summery.md new file mode 100644 index 0000000..fb1d336 --- /dev/null +++ b/.clinerules/project_summery.md @@ -0,0 +1,91 @@ +# YWTA Tools - Cline Rules +# Maya/Blender用テクニカルアーティストツール開発プロジェクト + +## プロジェクト概要 +- MayaとBlender用のテクニカルアーティストツール集 +- Python/C++によるプラグイン開発 +- リギング、デフォーメーション、アニメーション、メッシュ処理ツール + +## 開発環境とツール +- Maya 2024対応 +- Blender アドオン開発 +- Visual Studio + CMake (C++プラグイン用) +- Python 3.x + +## コーディング規約 + +### 一般規約 +- コードは読みやすく、保守性を重視 +- コメントは適切に記述 +- シンプルかつ必要最低限な実装を意識し、長いコードや複雑なロジックは避ける。 + +### Python +- PEP 8に準拠 +- Maya Python API 2.0を優先使用 +- docstringは必須(Google形式推奨) +- 型ヒントの使用を推奨 +- インデント: 4スペース + +### C++ +- .clang-formatファイルに従う +- Maya C++ APIの使用 +- CMakeによるビルド設定 + +### ファイル構成 +- maya/ywta/: Pythonモジュール +- maya/cpp/: C++プラグインソース +- blender/addons/: Blenderアドオン +- maya/icons/: UIアイコン +- maya/plug-ins/: コンパイル済みプラグイン + +## 開発ルール + +### 新機能追加時 +1. 適切なサブモジュールに配置(rig/, deform/, mesh/, anim/等) +2. menu.pyにメニューエントリを追加 +3. 必要に応じてアイコンを作成 +4. テストコードの作成(maya/ywta/test/) + +### ファイル変更時の注意 +- userSetup.pyの変更は慎重に(Maya起動時に実行される) +- .modファイルの変更時はパス設定を確認 +- C++プラグインの変更時はbuild.batでリビルド必要 + +### UI開発 +- Maya標準UIコンポーネントを使用 +- PySide2/PyQt5の使用も可 +- アイコンは maya/icons/ に配置 +- メニュー構造は既存パターンに従う + +### テスト +- maya_unit_testui.pyを使用したユニットテスト +- 新機能には対応するテストを作成 +- Maya環境でのテスト実行を前提 + +## 依存関係 +- numpy, scipy, pyparsing (requirements.txt参照) (新しいPythonモジュールは追加しないこと +- Maya Python API +- Blender Python API +- Visual Studio (C++開発時) + +## ビルドとデプロイ +- C++プラグイン: maya/cpp/build.bat実行 +- Mayaモジュール: MAYA_MODULE_PATHに配置 +- Blenderアドオン: 標準的なアドオンインストール手順 + +## 禁止事項 +- Maya/Blenderの安定性を損なう可能性のあるコード +- ライセンス違反となるコードの使用 +- 外部依存関係の無断追加 + +## 推奨事項 +- 既存コードパターンの踏襲 +- エラーハンドリングの適切な実装 +- ユーザビリティを考慮したUI設計 +- パフォーマンスを意識した実装 + +## ファイル変更時の確認事項 +- 関連するテストの実行 +- メニュー構造の整合性確認 +- 依存関係の影響範囲確認 +- ドキュメントの更新 diff --git a/README.md b/README.md index 9ebad25..a956d7e 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,47 @@ 個人プロジェクトでよく使うツールや、ツール開発するにあたっての便利なコンポーネントや関数を詰め込んだリポジトリです。 +# Maya用ツール + [chadmv/cmt](https://github.com/chadmv/cmt)をベースにしています。 -# Maya Module Install +## インストール方法 -以下のいずれかの方法でインストールしてください。 -- 環境変数`MAYA_MODULE_PATH`のパスが通った場所(通常`Documents\maya\modules`)にこのリポジトリを置いてください。 -- `Maya.env`の`MAYA_MODULE_PATH`にこのリポジトリのルートディレクトリを追加してください。 -- *[推奨]*`ywtatools.mod`の`./`を解凍先のディレクトリに変更して、`cmt.mod`ファイルを`MAYA_MODULE_PATH`が通ったところにコピーしてください。 +`ywtatools.mod`をテキストファイルで開き`./`を解凍先のディレクトリに変更して、`ywtatools.mod`ファイルを`MAYA_MODULE_PATH`が通ったところにコピーしてください。 -# How to build plugin +## How to build plugin 特定の機能を使う場合は、プラグインのビルドが必要です。プラグインのビルドにはVisual Studioとcmakeが必要です。 `maya/cpp/build.bat`を実行すると、自動的にpluginがビルドされ、所定のフォルダにプラグインがビルドされます。 +## Dependency -# Dependency +Pythonの依存モジュールはRequirements.txtに記載しています。Mayapyへのインストールは自己責任でおねがいします。 +``` +C:\Program Files\Autodesk\Maya2024\bin\mayapy.exe -m pip install -r requirements.txt +``` +# Blender用ツール +Blender用のツールは、Blenderのアドオンとして実装されています。 +## インストール方法 +Blenderのアドオンとしてインストールするには、Preferences > File Paths > Scripts Directoriesに、`path/to/ywtatools/blender`を追加してください。 -Pythonの依存モジュールはRequirements.txtに記載しています。Mayapyへのインストールは自己責任でおねがいします。 +# テストの実行方法 + +YWTAツールには、Maya環境とBlender環境の両方でテストを実行するための包括的なテストフレームワークが含まれています。 + +## Maya用テストの実行 + +### コマンドラインから実行 + +以下のコマンドを実行して、Maya 2024でテストを実行します。 + +```bash +python tests/run_maya_tests.py --pattern "test_*.py" --maya 2024 + ``` -C:\Program Files\Autodesk\Maya2022\bin\mayapy.exe -m pip install -r requirements.txt -``` \ No newline at end of file + +### Maya内から実行 + +Maya内でテストを実行するには、YWTA > Utility > Unit Test Runnerからテスト実行用のUIを開いてください。 diff --git a/maya/icons/cmt_run_all_tests.png b/maya/icons/ywta_run_all_tests.png similarity index 100% rename from maya/icons/cmt_run_all_tests.png rename to maya/icons/ywta_run_all_tests.png diff --git a/maya/icons/cmt_run_failed_tests.png b/maya/icons/ywta_run_failed_tests.png similarity index 100% rename from maya/icons/cmt_run_failed_tests.png rename to maya/icons/ywta_run_failed_tests.png diff --git a/maya/icons/cmt_run_selected_tests.png b/maya/icons/ywta_run_selected_tests.png similarity index 100% rename from maya/icons/cmt_run_selected_tests.png rename to maya/icons/ywta_run_selected_tests.png diff --git a/maya/icons/cmt_test_error.png b/maya/icons/ywta_test_error.png similarity index 100% rename from maya/icons/cmt_test_error.png rename to maya/icons/ywta_test_error.png diff --git a/maya/icons/cmt_test_fail.png b/maya/icons/ywta_test_fail.png similarity index 100% rename from maya/icons/cmt_test_fail.png rename to maya/icons/ywta_test_fail.png diff --git a/maya/icons/cmt_test_skip.png b/maya/icons/ywta_test_skip.png similarity index 100% rename from maya/icons/cmt_test_skip.png rename to maya/icons/ywta_test_skip.png diff --git a/maya/icons/cmt_test_success.png b/maya/icons/ywta_test_success.png similarity index 100% rename from maya/icons/cmt_test_success.png rename to maya/icons/ywta_test_success.png diff --git a/maya/ywta/__init__.py b/maya/ywta/__init__.py index 12f8593..e834760 100644 --- a/maya/ywta/__init__.py +++ b/maya/ywta/__init__.py @@ -1,10 +1,4 @@ -import logging -import sys - -log = logging.getLogger(__name__) - def initialize(): - import ywta.reloadmodules import ywta.menu ywta.menu.create_menu() diff --git a/maya/ywta/anim/__init__.py b/maya/ywta/anim/__init__.py index e69de29..246ac7c 100644 --- a/maya/ywta/anim/__init__.py +++ b/maya/ywta/anim/__init__.py @@ -0,0 +1,6 @@ +# Dependencies: +# - ywta.io.fbx +# - ywta.shortcuts +# - ywta.ui.widgets.accordionwidget +# - ywta.ui.widgets.filepathwidget +# - ywta.ui.widgets.mayanodewidget diff --git a/maya/ywta/config/README.md b/maya/ywta/config/README.md new file mode 100644 index 0000000..2182083 --- /dev/null +++ b/maya/ywta/config/README.md @@ -0,0 +1,202 @@ +# YWTA Tools 設定システム + +このディレクトリには、YWTA Toolsの設定システムが含まれています。設定システムは、デフォルト設定とユーザー設定を統合し、アプリケーション全体で一貫した設定管理を提供します。 + +## 設計概要 + +設定システムは以下のコンポーネントで構成されています: + +1. **BaseConfig**: 設定クラスの基底クラス。設定の読み込み、保存、取得、設定などの基本機能を提供します。 +2. **ConfigValue**: 設定値を表すクラス。型安全性と検証機能を提供します。 +3. **ConfigSchema**: 設定値のスキーマ定義と検証ルールを提供します。 +4. **SettingsManager**: 設定管理クラス。デフォルト設定とユーザー設定を統合し、設定値へのアクセスを提供します。 + +## 設定ファイル + +設定システムは以下の設定ファイルを使用します: + +1. **default_config.json**: デフォルト設定ファイル。アプリケーションに組み込まれており、すべての設定のデフォルト値を定義します。 +2. **user_config.json**: ユーザー設定ファイル。ユーザーが変更した設定のみを含みます。このファイルは、ユーザーのMayaアプリケーションディレクトリ(`{maya_app_dir}/ywta_tools/user_config.json`)に保存されます。 + +## 設定値の優先順位 + +設定値は以下の優先順位で取得されます: + +1. 環境変数(`YWTA_[設定キー]`) +2. ユーザー設定(`user_config.json`) +3. デフォルト設定(`default_config.json`) +4. 指定されたデフォルト値(`get_setting(key, default)`の`default`引数) + +## 設定構造 + +設定は階層構造になっており、ドット表記でアクセスできます。例えば、`rig.default_control_color`は、`rig`セクションの`default_control_color`設定を表します。 + +主な設定セクション: + +- **documentation**: ドキュメント関連の設定 +- **plugins**: プラグイン関連の設定 +- **ui**: UI関連の設定 +- **logging**: ログ関連の設定 +- **rig**: リギング関連の設定 +- **deform**: デフォーメーション関連の設定 +- **mesh**: メッシュ関連の設定 +- **anim**: アニメーション関連の設定 +- **io**: 入出力関連の設定 +- **system**: システム関連の設定 +- **user**: ユーザー関連の設定 + +## 使用方法 + +### 設定値の取得 + +```python +from ywta.config.settings_manager import get_setting + +# 設定値を取得 +control_color = get_setting("rig.default_control_color") +icon_size = get_setting("ui.icon_size") + +# デフォルト値を指定して設定値を取得 +log_dir = get_setting("logging.log_directory", "C:/Temp") +``` + +### 設定値の設定 + +```python +from ywta.config.settings_manager import set_setting + +# 設定値を設定 +set_setting("rig.default_control_color", 13) +set_setting("ui.language", "ja") +``` + +### 設定値のリセット + +```python +from ywta.config.settings_manager import reset_setting + +# 特定の設定値をデフォルトにリセット +reset_setting("rig.default_control_color") + +# すべての設定値をデフォルトにリセット +reset_setting() +``` + +### 設定のエクスポート/インポート + +```python +from ywta.config.settings_manager import export_settings, import_settings + +# 現在の設定をエクスポート +export_settings("C:/Temp/my_settings.json") + +# 設定をインポート +import_settings("C:/Temp/my_settings.json") +``` + +### 設定の保存 + +```python +from ywta.config.settings_manager import save_settings + +# 現在の設定をユーザー設定ファイルに保存 +save_settings() +``` + +### 設定変更時のコールバック + +```python +from ywta.config.settings_manager import add_callback, remove_callback, set_setting + +# コールバック関数を定義 +def on_theme_changed(key, value): + print(f"テーマが変更されました: {key} = {value}") + # UIの更新など必要な処理を実行 + +# コールバックを登録 +add_callback("ui.theme.primary_color", on_theme_changed) + +# 設定を変更(コールバックが呼び出される) +set_setting("ui.theme.primary_color", "#FF0000") +# 出力: テーマが変更されました: ui.theme.primary_color = #FF0000 + +# コールバックを削除 +remove_callback("ui.theme.primary_color", on_theme_changed) + +# 設定を変更(コールバックは呼び出されない) +set_setting("ui.theme.primary_color", "#00FF00") +``` + +### 設定マネージャーの直接使用 + +```python +from ywta.config.settings_manager import get_settings_manager + +# 設定マネージャーのインスタンスを取得 +settings = get_settings_manager() + +# すべての設定値を取得 +all_settings = settings.get_all_settings() + +# 変更された設定値を取得 +modified_settings = settings.get_modified_settings() +``` + +## 新しい設定の追加 + +新しい設定を追加するには、`default_config.json`ファイルに設定を追加します。設定は自動的に読み込まれ、アプリケーション全体で利用可能になります。 + +例えば、新しいリギング設定を追加する場合: + +```json +{ + "rig": { + "new_setting": "value" + } +} +``` + +この設定は、`get_setting("rig.new_setting")`で取得できます。 + +## 設定値の検証 + +設定値の検証が必要な場合は、`ConfigSchema`クラスを使用して検証ルールを定義できます。 + +```python +from ywta.config.config_schema import ConfigSchema +from ywta.config.settings_manager import get_settings_manager + +# スキーマを作成 +schema = ConfigSchema() + +# 検証ルールを追加 +schema.add_range_constraint("rig.control_scale", min_value=0.1, max_value=10.0) +schema.add_choice_constraint("logging.level", ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]) + +# 設定値を検証 +settings = get_settings_manager() +schema.validate("rig.control_scale", settings.get("rig.control_scale")) +``` + +## 環境変数 + +設定値は環境変数でオーバーライドできます。環境変数の名前は`YWTA_`プレフィックスの後に、設定キーを大文字にしてドットをアンダースコアに置き換えたものになります。 + +例: +- `rig.default_control_color` → `YWTA_RIG_DEFAULT_CONTROL_COLOR` +- `ui.icon_size` → `YWTA_UI_ICON_SIZE` + +## 後方互換性 + +既存の設定システムとの後方互換性のために、以下のプロパティが提供されています: + +- `DOCUMENTATION_ROOT`: `documentation.root_url`の別名 +- `ENABLE_PLUGINS`: `plugins.enable_cpp_plugins`の別名 + +これらのプロパティは、`settings.py`モジュールからアクセスできます: + +```python +from ywta.settings import DOCUMENTATION_ROOT, ENABLE_PLUGINS + +print(DOCUMENTATION_ROOT) +print(ENABLE_PLUGINS) diff --git a/maya/ywta/config/__init__.py b/maya/ywta/config/__init__.py new file mode 100644 index 0000000..c5cdd4a --- /dev/null +++ b/maya/ywta/config/__init__.py @@ -0,0 +1,22 @@ +""" +YWTA Tools Configuration System + +このモジュールは、YWTA Toolsの設定管理システムを提供します。 +環境変数、設定ファイル、デフォルト値の優先順位システムを実装し、 +型安全な設定値の検証機能を提供します。 +""" + +from .base_config import BaseConfig, ConfigError, ValidationError +from .settings_manager import SettingsManager +from .config_schema import ConfigSchema + +__all__ = [ + "BaseConfig", + "ConfigError", + "ValidationError", + "SettingsManager", + "ConfigSchema", +] + +# Dependencies: +# - ywta.config.base_config \ No newline at end of file diff --git a/maya/ywta/config/base_config.py b/maya/ywta/config/base_config.py new file mode 100644 index 0000000..a195aba --- /dev/null +++ b/maya/ywta/config/base_config.py @@ -0,0 +1,317 @@ +""" +Base Configuration Classes + +設定システムの基盤となるクラスを定義します。 +""" + +from __future__ import annotations + +import os +import json +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional, Union, Type, TypeVar, Generic +from pathlib import Path +import logging + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +class ConfigError(Exception): + """設定システムの基本例外クラス""" + + pass + + +class ValidationError(ConfigError): + """設定値の検証エラー""" + + pass + + +class ConfigValue(Generic[T]): + """設定値を表すクラス + + 型安全性と検証機能を提供します。 + """ + + def __init__( + self, + key: str, + default: T, + description: str = "", + validator: Optional[callable] = None, + env_var: Optional[str] = None, + ): + self.key = key + self.default = default + self.description = description + self.validator = validator + self.env_var = env_var or f"YWTA_{key.upper().replace('.', '_')}" + self._value: Optional[T] = None + self._is_set = False + + def get(self, config_data: Dict[str, Any] = None) -> T: + """設定値を取得 + + 優先順位: 環境変数 > 設定ファイル > デフォルト値 + """ + if self._is_set: + return self._value + + # 1. 環境変数をチェック + env_value = os.environ.get(self.env_var) + if env_value is not None: + try: + value = self._parse_env_value(env_value) + if self.validator: + self.validator(value) + self._value = value + self._is_set = True + return value + except (ValueError, ValidationError) as e: + logger.warning(f"Invalid environment variable {self.env_var}: {e}") + + # 2. 設定ファイルをチェック + if config_data: + keys = self.key.split(".") + value = config_data + try: + for key in keys: + value = value[key] + if self.validator: + self.validator(value) + self._value = value + self._is_set = True + return value + except (KeyError, TypeError, ValidationError): + pass + + # 3. デフォルト値を使用 + if self.validator: + self.validator(self.default) + self._value = self.default + self._is_set = True + return self.default + + def set(self, value: T) -> None: + """設定値を直接設定""" + if self.validator: + if not self.validator(value): + raise ValidationError(f"Validation failed for {self.key}: {value}") + self._value = value + self._is_set = True + + def reset(self) -> None: + """設定値をリセット""" + self._value = None + self._is_set = False + + def _parse_env_value(self, env_value: str) -> T: + """環境変数の値を適切な型に変換""" + if isinstance(self.default, bool): + return env_value.lower() in ("true", "1", "yes", "on", "t") + elif isinstance(self.default, int): + return int(env_value) + elif isinstance(self.default, float): + return float(env_value) + elif isinstance(self.default, (list, dict)): + return json.loads(env_value) + else: + return env_value + + +class BaseConfig(ABC): + """設定クラスの基底クラス + + すべての設定クラスはこのクラスを継承する必要があります。 + """ + + def __init__(self, config_file: Optional[Union[str, Path]] = None): + self.config_file = Path(config_file) if config_file else None + self._config_data: Dict[str, Any] = {} + self._config_values: Dict[str, ConfigValue] = {} + self._callbacks: Dict[str, list] = {} + + # 設定値を初期化 + self._initialize_config_values() + + # 設定ファイルを読み込み + if self.config_file and self.config_file.exists(): + self.load_config() + + @abstractmethod + def _initialize_config_values(self) -> None: + """設定値を初期化する抽象メソッド + + サブクラスでこのメソッドを実装し、ConfigValueインスタンスを + self._config_valuesに追加してください。 + """ + pass + + def add_config_value(self, config_value: ConfigValue) -> None: + """設定値を追加""" + self._config_values[config_value.key] = config_value + + def get(self, key: str, default: Any = None) -> Any: + """設定値を取得""" + if key in self._config_values: + return self._config_values[key].get(self._config_data) + + # 直接辞書アクセス(後方互換性のため) + keys = key.split(".") + value = self._config_data + try: + for k in keys: + value = value[k] + return value + except (KeyError, TypeError): + return default + + def set(self, key: str, value: Any) -> None: + """設定値を設定""" + if key in self._config_values: + self._config_values[key].set(value) + else: + # 直接辞書に設定(後方互換性のため) + keys = key.split(".") + data = self._config_data + for k in keys[:-1]: + if k not in data: + data[k] = {} + data = data[k] + data[keys[-1]] = value + + # コールバックを実行 + self._execute_callbacks(key, value) + + def reset(self, key: Optional[str] = None) -> None: + """設定値をリセット""" + if key is None: + # すべての設定値をリセット + for config_value in self._config_values.values(): + config_value.reset() + self._config_data.clear() + elif key in self._config_values: + self._config_values[key].reset() + else: + # 直接辞書から削除 + keys = key.split(".") + data = self._config_data + try: + for k in keys[:-1]: + data = data[k] + del data[keys[-1]] + except (KeyError, TypeError): + pass + + def load_config(self, config_file: Optional[Union[str, Path]] = None) -> None: + """設定ファイルを読み込み""" + file_path = Path(config_file) if config_file else self.config_file + if not file_path or not file_path.exists(): + logger.warning(f"Config file not found: {file_path}") + return + + try: + with open(file_path, "r", encoding="utf-8") as f: + if file_path.suffix.lower() == ".json": + self._config_data = json.load(f) + else: + raise ConfigError( + f"Unsupported config file format: {file_path.suffix}, only .json is supported" + ) + + logger.info(f"Config loaded from: {file_path}") + + # すべての設定値をリセットして再読み込み + for config_value in self._config_values.values(): + config_value.reset() + + except Exception as e: + logger.error(f"Failed to load config file {file_path}: {e}") + raise ConfigError(f"Failed to load config file: {e}") + + def save_config(self, config_file: Optional[Union[str, Path]] = None) -> None: + """設定ファイルに保存""" + file_path = Path(config_file) if config_file else self.config_file + if not file_path: + raise ConfigError("No config file specified") + + # 現在の設定値を辞書に変換 + config_data = {} + for key, config_value in self._config_values.items(): + if config_value._is_set: + keys = key.split(".") + data = config_data + for k in keys[:-1]: + if k not in data: + data[k] = {} + data = data[k] + data[keys[-1]] = config_value._value + + # 直接設定された値もマージ + self._merge_dict(config_data, self._config_data) + + try: + file_path.parent.mkdir(parents=True, exist_ok=True) + with open(file_path, "w", encoding="utf-8") as f: + if file_path.suffix.lower() == ".json": + json.dump(config_data, f, indent=2, ensure_ascii=False) + else: + raise ConfigError( + f"Unsupported config file format: {file_path.suffix}, only .json is supported" + ) + + logger.info(f"Config saved to: {file_path}") + + except Exception as e: + logger.error(f"Failed to save config file {file_path}: {e}") + raise ConfigError(f"Failed to save config file: {e}") + + def add_callback(self, key: str, callback: callable) -> None: + """設定値変更時のコールバックを追加""" + if key not in self._callbacks: + self._callbacks[key] = [] + self._callbacks[key].append(callback) + + def remove_callback(self, key: str, callback: callable) -> None: + """コールバックを削除""" + if key in self._callbacks and callback in self._callbacks[key]: + self._callbacks[key].remove(callback) + + def _execute_callbacks(self, key: str, value: Any) -> None: + """コールバックを実行""" + if key in self._callbacks: + for callback in self._callbacks[key]: + try: + callback(key, value) + except Exception as e: + logger.error(f"Callback error for key '{key}': {e}") + + def _merge_dict(self, target: dict, source: dict) -> None: + """辞書を再帰的にマージ""" + for key, value in source.items(): + if ( + key in target + and isinstance(target[key], dict) + and isinstance(value, dict) + ): + self._merge_dict(target[key], value) + else: + target[key] = value + + def get_all_config_values(self) -> Dict[str, Any]: + """すべての設定値を取得""" + result = {} + for key, config_value in self._config_values.items(): + result[key] = config_value.get(self._config_data) + return result + + def validate_all(self) -> None: + """すべての設定値を検証""" + for key, config_value in self._config_values.items(): + try: + config_value.get(self._config_data) + except ValidationError as e: + raise ValidationError(f"Validation failed for '{key}': {e}") diff --git a/maya/ywta/config/config_schema.py b/maya/ywta/config/config_schema.py new file mode 100644 index 0000000..c0e83a5 --- /dev/null +++ b/maya/ywta/config/config_schema.py @@ -0,0 +1,193 @@ +""" +Configuration Schema + +設定値のスキーマ定義と検証ルールを提供します。 +""" + +from typing import Any, Dict, List, Optional, Union, Callable +from .base_config import ValidationError + + +class ConfigSchema: + """設定スキーマクラス + + 設定値の型、制約、検証ルールを定義します。 + """ + + def __init__(self): + self.validators: Dict[str, List[Callable]] = {} + self.constraints: Dict[str, Dict[str, Any]] = {} + + def add_validator( + self, key: str, validator: Callable[[Any], bool], error_message: str = "" + ) -> None: + """バリデーターを追加 + + Args: + key: 設定キー + validator: 検証関数(True/Falseを返す) + error_message: エラーメッセージ + """ + if key not in self.validators: + self.validators[key] = [] + + def wrapped_validator(value): + if not validator(value): + raise ValidationError( + error_message or f"Validation failed for {key}: {value}" + ) + + self.validators[key].append(wrapped_validator) + + def add_range_constraint( + self, + key: str, + min_value: Optional[Union[int, float]] = None, + max_value: Optional[Union[int, float]] = None, + ) -> None: + """数値範囲制約を追加""" + + def range_validator(value): + if not isinstance(value, (int, float)): + raise ValidationError(f"{key} must be a number, got {type(value)}") + if min_value is not None and value < min_value: + raise ValidationError(f"{key} must be >= {min_value}, got {value}") + if max_value is not None and value > max_value: + raise ValidationError(f"{key} must be <= {max_value}, got {value}") + + self.add_validator(key, lambda v: True, "") # ダミー + if key not in self.validators: + self.validators[key] = [] + self.validators[key][-1] = range_validator + + def add_choice_constraint(self, key: str, choices: List[Any]) -> None: + """選択肢制約を追加""" + + def choice_validator(value): + if value not in choices: + raise ValidationError(f"{key} must be one of {choices}, got {value}") + + self.add_validator(key, lambda v: True, "") # ダミー + if key not in self.validators: + self.validators[key] = [] + self.validators[key][-1] = choice_validator + + def add_type_constraint(self, key: str, expected_type: type) -> None: + """型制約を追加""" + + def type_validator(value): + if not isinstance(value, expected_type): + raise ValidationError( + f"{key} must be of type {expected_type.__name__}, got {type(value).__name__}" + ) + + self.add_validator(key, lambda v: True, "") # ダミー + if key not in self.validators: + self.validators[key] = [] + self.validators[key][-1] = type_validator + + def add_path_constraint( + self, + key: str, + must_exist: bool = False, + must_be_file: bool = False, + must_be_dir: bool = False, + ) -> None: + """パス制約を追加""" + import os + + def path_validator(value): + if not isinstance(value, str): + raise ValidationError( + f"{key} must be a string path, got {type(value).__name__}" + ) + + if must_exist and not os.path.exists(value): + raise ValidationError(f"{key} path does not exist: {value}") + + if must_be_file and not os.path.isfile(value): + raise ValidationError(f"{key} must be a file: {value}") + + if must_be_dir and not os.path.isdir(value): + raise ValidationError(f"{key} must be a directory: {value}") + + self.add_validator(key, lambda v: True, "") # ダミー + if key not in self.validators: + self.validators[key] = [] + self.validators[key][-1] = path_validator + + def validate(self, key: str, value: Any) -> None: + """設定値を検証""" + if key in self.validators: + for validator in self.validators[key]: + validator(value) + + def validate_all(self, config_data: Dict[str, Any]) -> None: + """すべての設定値を検証""" + for key in self.validators: + if key in config_data: + self.validate(key, config_data[key]) + + +# 共通バリデーター関数 +def validate_url(value: str) -> bool: + """URL形式の検証""" + import re + + url_pattern = re.compile( + r"^https?://" # http:// or https:// + r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|" # domain... + r"localhost|" # localhost... + r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" # ...or ip + r"(?::\d+)?" # optional port + r"(?:/?|[/?]\S+)$", + re.IGNORECASE, + ) + return url_pattern.match(value) is not None + + +def validate_maya_version(value: str) -> bool: + """Maya バージョン形式の検証 (例: "2024", "2023.5")""" + import re + + version_pattern = re.compile(r"^\d{4}(?:\.\d+)?$") + return version_pattern.match(value) is not None + + +def validate_log_level(value: str) -> bool: + """ログレベルの検証""" + valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + return value.upper() in valid_levels + + +def validate_color_hex(value: str) -> bool: + """16進数カラーコードの検証 (#RRGGBB or #RGB)""" + import re + + hex_pattern = re.compile(r"^#(?:[0-9a-fA-F]{3}){1,2}$") + return hex_pattern.match(value) is not None + + +def validate_positive_number(value: Union[int, float]) -> bool: + """正の数値の検証""" + return isinstance(value, (int, float)) and value > 0 + + +def validate_non_negative_number(value: Union[int, float]) -> bool: + """非負の数値の検証""" + return isinstance(value, (int, float)) and value >= 0 + + +def validate_port_number(value: int) -> bool: + """ポート番号の検証 (1-65535)""" + return isinstance(value, int) and 1 <= value <= 65535 + + +def validate_file_extension(value: str, allowed_extensions: List[str]) -> bool: + """ファイル拡張子の検証""" + import os + + _, ext = os.path.splitext(value.lower()) + return ext in [ + e.lower() if e.startswith(".") else f".{e.lower()}" for e in allowed_extensions + ] diff --git a/maya/ywta/config/default_config.json b/maya/ywta/config/default_config.json new file mode 100644 index 0000000..3af5f26 --- /dev/null +++ b/maya/ywta/config/default_config.json @@ -0,0 +1,118 @@ +{ + "documentation": { + "root_url": "https://github.com/yohawing/ywtatools", + "local_path": "", + "open_in_browser": true + }, + "plugins": { + "enable_cpp_plugins": true, + "auto_load": true, + "search_paths": [] + }, + "ui": { + "icon_size": 24, + "menu_name": "YWTA Tools", + "use_shelf": true, + "shelf_name": "YWTA", + "theme": { + "primary_color": "#3498db", + "secondary_color": "#2ecc71", + "text_color": "#2c3e50" + }, + "language": "en" + }, + "logging": { + "level": "INFO", + "file_logging": true, + "log_directory": "", + "max_log_files": 5, + "max_file_size_mb": 10 + }, + "rig": { + "default_control_color": 13, + "default_control_shape": "circle", + "control_scale": 1.0, + "joint_radius": 1.0, + "auto_orient_joints": true, + "naming": { + "control_suffix": "_ctrl", + "joint_prefix": "jnt_", + "group_suffix": "_grp", + "side_token": { + "left": "L", + "right": "R", + "center": "C" + } + } + }, + "deform": { + "default_skin_method": "dual_quaternion", + "max_influences": 4, + "normalize_weights": true, + "blendshape": { + "default_envelope": 1.0, + "use_in_between": true, + "topology_check": true + }, + "skinio": { + "default_directory": "", + "file_format": "json", + "compress_files": true + } + }, + "mesh": { + "default_subdivision_level": 1, + "cleanup": { + "remove_unused_vertices": true, + "remove_unused_uvs": true, + "fix_non_manifold": true + }, + "export": { + "default_format": "fbx", + "triangulate": true, + "optimize_for_unity": false, + "optimize_for_unreal": false + } + }, + "anim": { + "default_frame_rate": 24, + "time_range": { + "start": 1, + "end": 120 + }, + "auto_key": false, + "tangent_type": "auto" + }, + "io": { + "fbx": { + "export_version": "FBX201900", + "ascii": false, + "embed_media": false, + "preserve_references": true + }, + "alembic": { + "frame_range_mode": "timeline", + "write_uv": true, + "write_color_sets": true, + "data_format": "ogawa" + } + }, + "system": { + "temp_directory": "", + "cache_size_mb": 512, + "threads": 0, + "check_for_updates": true, + "telemetry": false, + "maya_version": "" + }, + "user": { + "name": "", + "email": "", + "preferences": { + "auto_save": true, + "auto_save_interval_min": 15, + "recent_projects": [], + "recent_files": [] + } + } +} diff --git a/maya/ywta/config/settings_manager.py b/maya/ywta/config/settings_manager.py new file mode 100644 index 0000000..1f6dbd3 --- /dev/null +++ b/maya/ywta/config/settings_manager.py @@ -0,0 +1,464 @@ +""" +Settings Manager + +設定管理クラスを提供します。 +デフォルト設定とユーザー設定を統合し、設定値へのアクセスを提供します。 +""" + +from __future__ import annotations + +import os +import json +import logging +import shutil +from pathlib import Path +from typing import Any, Dict, Optional, Union, List, Tuple + +from .base_config import BaseConfig, ConfigValue + +logger = logging.getLogger(__name__) + + +class SettingsManager(BaseConfig): + """YWTA Tools設定管理クラス + + デフォルト設定とユーザー設定を統合し、設定値へのアクセスを提供します。 + 設定値の優先順位: 環境変数 > ユーザー設定 > デフォルト設定 + """ + + _instance: Optional[SettingsManager] = None + + def __init__( + self, + user_config_file: Optional[Union[str, Path]] = None, + default_config_file: Optional[Union[str, Path]] = None, + ): + # ユーザー設定ファイルのパスを決定 + if user_config_file is None: + user_config_file = self._get_user_config_path() + self.user_config_file = Path(user_config_file) + + # デフォルト設定ファイルのパスを決定 + if default_config_file is None: + default_config_file = self._get_default_config_path() + self.default_config_file = Path(default_config_file) + + # ユーザー設定ディレクトリが存在しない場合は作成 + self.user_config_file.parent.mkdir(parents=True, exist_ok=True) + + # デフォルト設定を読み込み + self._default_config_data: Dict[str, Any] = {} + if self.default_config_file.exists(): + self._load_json_file(self.default_config_file, self._default_config_data) + else: + logger.warning( + f"デフォルト設定ファイルが見つかりません: {self.default_config_file}" + ) + + # ユーザー設定ファイルが存在しない場合は作成 + if not self.user_config_file.exists(): + self._create_initial_user_config() + + # BaseConfigの初期化(ユーザー設定ファイルを使用) + super().__init__(self.user_config_file) + + # QSettings互換性 + self._qsettings = None + self._init_qsettings() + + @classmethod + def get_instance( + cls, + user_config_file: Optional[Union[str, Path]] = None, + default_config_file: Optional[Union[str, Path]] = None, + ) -> SettingsManager: + """シングルトンインスタンスを取得""" + if cls._instance is None: + cls._instance = cls(user_config_file, default_config_file) + return cls._instance + + def _get_user_config_path(self) -> Path: + """ユーザー設定ファイルパスを取得""" + try: + import maya.cmds as cmds + + maya_app_dir = Path(cmds.internalVar(userAppDir=True)) + config_dir = maya_app_dir / "ywta_tools" + except ImportError: + config_dir = Path.home() / ".ywta_tools" + + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir / "user_config.json" + + def _get_default_config_path(self) -> Path: + """デフォルト設定ファイルパスを取得""" + # モジュールのディレクトリを取得 + module_dir = Path(__file__).parent + return module_dir / "default_config.json" + + def _create_initial_user_config(self) -> None: + """初期ユーザー設定ファイルを作成""" + # 空の設定ファイルを作成 + empty_config = { + "user": { + "name": "", + "email": "", + "preferences": {"recent_projects": [], "recent_files": []}, + } + } + + with open(self.user_config_file, "w", encoding="utf-8") as f: + json.dump(empty_config, f, indent=2, ensure_ascii=False) + + logger.info(f"初期ユーザー設定ファイルを作成しました: {self.user_config_file}") + + def _load_json_file(self, file_path: Path, target_dict: Dict[str, Any]) -> None: + """JSONファイルを読み込み、辞書に格納""" + try: + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + target_dict.clear() + target_dict.update(data) + except Exception as e: + logger.error(f"設定ファイルの読み込みに失敗しました {file_path}: {e}") + raise + + def _init_qsettings(self): + """QSettings互換性のための初期化""" + try: + try: + from PySide6.QtCore import QSettings + except ImportError: + try: + from PySide2.QtCore import QSettings + except ImportError: + from PySide.QtCore import QSettings + + self._qsettings = QSettings("Chad Vernon", "CMT") + except ImportError: + logger.warning("Qt not available, QSettings compatibility disabled") + self._qsettings = None + + def _initialize_config_values(self) -> None: + """基本的な設定値を初期化 + + デフォルト設定ファイルから設定値を読み込み、ConfigValueインスタンスを作成します。 + """ + # デフォルト設定ファイルから設定値を読み込み + self._register_config_values_from_dict(self._default_config_data) + + # 既存設定との互換性のための明示的な設定 + self.add_config_value( + ConfigValue( + key="documentation.root_url", + default="", + description="ドキュメントのルートURL", + env_var="YWTA_DOCUMENTATION_ROOT", + ) + ) + + self.add_config_value( + ConfigValue( + key="plugins.enable_cpp_plugins", + default=True, + description="C++プラグインを有効にするかどうか", + env_var="YWTA_ENABLE_PLUGINS", + ) + ) + + def _register_config_values_from_dict( + self, config_dict: Dict[str, Any], prefix: str = "" + ) -> None: + """辞書から再帰的に設定値を登録""" + for key, value in config_dict.items(): + full_key = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + # 辞書の場合は再帰的に処理 + self._register_config_values_from_dict(value, full_key) + else: + # 辞書でない場合は設定値として登録 + if full_key not in self._config_values: + self.add_config_value( + ConfigValue( + key=full_key, + default=value, + description=f"設定値: {full_key}", + ) + ) + + def get(self, key: str, default: Any = None) -> Any: + """設定値を取得 + + 優先順位: 環境変数 > ユーザー設定 > デフォルト設定 > 指定されたデフォルト値 + """ + + # 環境変数から取得 + env_value = os.getenv(f"YWTA_{key.upper().replace('.', '_')}", None) + if env_value is not None: + # 環境変数が設定されている場合はそれを使用 + logger.debug(f"環境変数から取得: {key} = {env_value}") + # ConfigValueが定義されていない場合はそのまま返す + if isinstance(env_value, str) and env_value.lower() in ("true", "false"): + return env_value.lower() == "true" + if isinstance(env_value, str) and env_value.isdigit(): + return int(env_value) + return env_value + + # ConfigValueが登録されている場合はそれを使用 + if key in self._config_values: + return self._config_values[key].get(self._config_data) + + # ユーザー設定から取得 + user_value = self._get_from_nested_dict(self._config_data, key) + if user_value is not None: + return user_value + + # デフォルト設定から取得 + default_value = self._get_from_nested_dict(self._default_config_data, key) + if default_value is not None: + return default_value + + # 指定されたデフォルト値を返す + return default + + def _get_from_nested_dict(self, data: Dict[str, Any], key: str) -> Any: + """ネストされた辞書から値を取得""" + keys = key.split(".") + value = data + try: + for k in keys: + value = value[k] + return value + except (KeyError, TypeError): + return None + + def reset_to_default(self, key: str = None) -> None: + """設定値をデフォルトにリセット + + keyが指定されていない場合は、すべての設定値をリセットします。 + """ + if key is None: + # すべての設定値をリセット + self._config_data.clear() + # ConfigValueもリセット + for config_value in self._config_values.values(): + config_value.reset() + self.save_config() + logger.info("すべての設定値をデフォルトにリセットしました") + else: + # 指定された設定値をリセット + if key in self._config_values: + self._config_values[key].reset() + + keys = key.split(".") + if len(keys) == 1: + if key in self._config_data: + del self._config_data[key] + else: + parent_key = ".".join(keys[:-1]) + last_key = keys[-1] + parent = self._get_from_nested_dict(self._config_data, parent_key) + if ( + parent is not None + and isinstance(parent, dict) + and last_key in parent + ): + del parent[last_key] + self.save_config() + logger.info(f"設定値 '{key}' をデフォルトにリセットしました") + + def export_settings(self, file_path: Union[str, Path]) -> None: + """設定をエクスポート + + 現在の設定(デフォルト設定とユーザー設定を統合したもの)をファイルにエクスポートします。 + """ + file_path = Path(file_path) + + # デフォルト設定とユーザー設定を統合 + merged_config = self._default_config_data.copy() + self._merge_dict(merged_config, self._config_data) + + try: + file_path.parent.mkdir(parents=True, exist_ok=True) + with open(file_path, "w", encoding="utf-8") as f: + json.dump(merged_config, f, indent=2, ensure_ascii=False) + logger.info(f"設定をエクスポートしました: {file_path}") + except Exception as e: + logger.error(f"設定のエクスポートに失敗しました: {e}") + raise + + def import_settings(self, file_path: Union[str, Path]) -> None: + """設定をインポート + + ファイルから設定をインポートし、現在のユーザー設定を上書きします。 + """ + file_path = Path(file_path) + if not file_path.exists(): + raise FileNotFoundError(f"設定ファイルが見つかりません: {file_path}") + + try: + with open(file_path, "r", encoding="utf-8") as f: + imported_config = json.load(f) + + # インポートした設定をユーザー設定に適用 + self._config_data.clear() + self._config_data.update(imported_config) + self.save_config() + logger.info(f"設定をインポートしました: {file_path}") + except Exception as e: + logger.error(f"設定のインポートに失敗しました: {e}") + raise + + # QSettings互換性メソッド + def value(self, key: str, default_value: Any = None) -> Any: + """QSettings.value()互換メソッド""" + result = self.get(key, None) + if result is not None: + return result + + if self._qsettings: + result = self._qsettings.value(key, None) + if result is not None: + return result + + return default_value + + def setValue(self, key: str, value: Any) -> None: + """QSettings.setValue()互換メソッド""" + self.set(key, value) + + if self._qsettings: + self._qsettings.setValue(key, value) + + # 後方互換性プロパティ + @property + def DOCUMENTATION_ROOT(self) -> str: + return self.get("documentation.root_url") + + @DOCUMENTATION_ROOT.setter + def DOCUMENTATION_ROOT(self, value: str) -> None: + self.set("documentation.root_url", value) + + @property + def ENABLE_PLUGINS(self) -> bool: + return self.get("plugins.enable_cpp_plugins") + + @ENABLE_PLUGINS.setter + def ENABLE_PLUGINS(self, value: bool) -> None: + self.set("plugins.enable_cpp_plugins", value) + + def get_all_settings(self) -> Dict[str, Any]: + """すべての設定値を取得 + + デフォルト設定とユーザー設定を統合した結果を返します。 + """ + # デフォルト設定をコピー + result = self._default_config_data.copy() + + # ユーザー設定を統合 + self._merge_dict(result, self._config_data) + + return result + + def get_modified_settings(self) -> Dict[str, Tuple[Any, Any]]: + """変更された設定値を取得 + + ユーザーが変更した設定値(デフォルト値と異なる値)を返します。 + 戻り値は {key: (default_value, current_value)} の形式です。 + """ + result = {} + + def compare_dicts(default_dict, user_dict, prefix=""): + for key, default_value in default_dict.items(): + full_key = f"{prefix}.{key}" if prefix else key + + if key in user_dict: + user_value = user_dict[key] + + if isinstance(default_value, dict) and isinstance(user_value, dict): + # 再帰的に辞書を比較 + compare_dicts(default_value, user_value, full_key) + elif default_value != user_value: + # 値が異なる場合は結果に追加 + result[full_key] = (default_value, user_value) + + compare_dicts(self._default_config_data, self._config_data) + return result + + +# グローバルインスタンス +_settings_manager: Optional[SettingsManager] = None + + +def get_settings_manager() -> SettingsManager: + """設定マネージャーのグローバルインスタンスを取得""" + global _settings_manager + if _settings_manager is None: + _settings_manager = SettingsManager.get_instance() + return _settings_manager + + +def get_setting(key: str, default: Any = None) -> Any: + """設定値を取得(便利関数)""" + return get_settings_manager().get(key, default) + + +def set_setting(key: str, value: Any) -> None: + """設定値を設定(便利関数)""" + get_settings_manager().set(key, value) + + +def reset_setting(key: str = None) -> None: + """設定値をデフォルトにリセット(便利関数)""" + get_settings_manager().reset_to_default(key) + + +def export_settings(file_path: Union[str, Path]) -> None: + """設定をエクスポート(便利関数)""" + get_settings_manager().export_settings(file_path) + + +def import_settings(file_path: Union[str, Path]) -> None: + """設定をインポート(便利関数)""" + get_settings_manager().import_settings(file_path) + + +def save_settings() -> None: + """設定を保存(便利関数) + + 現在の設定をユーザー設定ファイルに保存します。 + """ + get_settings_manager().save_config() + + +def add_callback(key: str, callback: callable) -> None: + """設定値変更時のコールバックを追加(便利関数) + + Args: + key: 設定キー + callback: コールバック関数。引数として (key, value) を受け取る必要があります。 + + Example: + ```python + def on_theme_changed(key, value): + print(f"テーマが変更されました: {value}") + # UIの更新など + + # コールバックを登録 + add_callback("ui.theme.primary_color", on_theme_changed) + + # 設定を変更(コールバックが呼び出される) + set_setting("ui.theme.primary_color", "#FF0000") + ``` + """ + get_settings_manager().add_callback(key, callback) + + +def remove_callback(key: str, callback: callable) -> None: + """コールバックを削除(便利関数) + + Args: + key: 設定キー + callback: 削除するコールバック関数 + """ + get_settings_manager().remove_callback(key, callback) diff --git a/maya/ywta/core/README.md b/maya/ywta/core/README.md new file mode 100644 index 0000000..84d119e --- /dev/null +++ b/maya/ywta/core/README.md @@ -0,0 +1,95 @@ +# YWTA Core モジュール + +## 概要 + +`ywta.core`パッケージは、YWTAツールセット全体で使用される共通のユーティリティ関数とクラスを提供します。このパッケージは、以前は`shortcuts.py`に含まれていた機能を機能別に整理し、より明確な責任分担と依存関係を持つモジュール構造に再編成しています。 + +## モジュール構造 + +`core`パッケージは以下のモジュールで構成されています: + +### 1. `maya_utils.py` + +Maya APIを使用するための低レベルユーティリティ関数を提供します。 + +- `get_mobject()` - ノード名からMObjectを取得 +- `get_dag_path()` - ノード名からMDagPathを取得 +- `get_mfnmesh()` - メッシュのMFnMeshを取得 +- `get_points()` - メッシュの頂点位置を取得 +- `set_points()` - メッシュの頂点位置を設定 +- `get_mfnblendshapedeformer()` - ブレンドシェイプのMFnBlendShapeDeformerを取得 +- `get_int_ptr()`, `ptr_to_int()` - MScriptUtil関連のユーティリティ + +### 2. `node_utils.py` + +Mayaのノード操作に関連するユーティリティ関数を提供します。 + +- `get_shape()` - トランスフォームのシェイプノードを取得 +- `get_node_in_namespace_hierarchy()` - 名前空間階層内のノードを検索 + +### 3. `namespace_utils.py` + +名前空間操作に関連するユーティリティ関数を提供します。 + +- `get_namespace_from_name()` - 名前から名前空間を抽出 +- `remove_namespace_from_name()` - 名前から名前空間を削除 + +### 4. `ui_utils.py` + +ユーザーインターフェース関連のクラスと関数を提供します。 + +- `BaseTreeNode` - QAbstractItemModelで使用するための階層機能を持つ基本クラス +- `SingletonWindowMixin` - ウィンドウのインスタンスを1つだけ許可するミックスイン +- `get_icon_path()` - アイコンファイルのパスを取得 + +### 5. `settings_utils.py` + +設定の保存と読み込みに関連するユーティリティ関数を提供します。 + +- `get_setting()`, `set_setting()` - 永続的な設定の取得と保存 +- `get_save_file_name()`, `get_open_file_name()`, `get_directory_name()` - ファイルダイアログ関連の関数 + +### 6. `geometry_utils.py` + +ジオメトリ操作と数学計算に関連するユーティリティ関数を提供します。 + +- `distance()` - 2つのノード間の距離を計算 +- `vector_to()` - 2つのノード間のベクトルを計算 +- `get_bounding_box()` - ノードのバウンディングボックスを取得 +- `get_center_point()` - ノードの中心点を取得 +- `create_vector()` - MVectorを作成 +- `normalize_vector()` - ベクトルを正規化 +- `dot_product()`, `cross_product()` - ベクトル演算 + +## 使用方法 + +### 推奨される使用方法 + +新しいコードでは、必要な機能を持つ特定のモジュールを直接インポートすることをお勧めします: + +```python +# 例:Maya APIユーティリティを使用する場合 +from ywta.core.maya_utils import get_mfnmesh, get_points + +# 例:ジオメトリユーティリティを使用する場合 +from ywta.core.geometry_utils import distance, vector_to +``` + +### 後方互換性 + +既存のコードとの互換性のために、`shortcuts.py`モジュールは引き続き使用できますが、非推奨とマークされています: + +```python +# 非推奨の使用方法(警告が表示されます) +from ywta.shortcuts import get_shape, distance +``` + +## レイヤードアーキテクチャ + +`core`パッケージは、以下のようなレイヤー構造を採用しています: + +1. **基盤レイヤー**:Maya API関連のユーティリティ(`maya_utils.py`) +2. **中間レイヤー**:ノード操作、名前空間、ジオメトリなどの基本機能(`node_utils.py`, `namespace_utils.py`, `geometry_utils.py`) +3. **上位レイヤー**:UI、設定などのユーザー向け機能(`ui_utils.py`, `settings_utils.py`) + +この構造により、依存関係が明確になり、各モジュールの責任範囲が明確になります。上位レイヤーのモジュールは下位レイヤーのモジュールに依存することがありますが、その逆は避けるべきです。 diff --git a/maya/ywta/core/__init__.py b/maya/ywta/core/__init__.py new file mode 100644 index 0000000..366e992 --- /dev/null +++ b/maya/ywta/core/__init__.py @@ -0,0 +1,18 @@ +""" +YWTA Core Module + +このモジュールは、YWTAツールセット全体で使用される共通のユーティリティ関数とクラスを提供します。 +機能別に整理された各サブモジュールは、特定の操作領域に焦点を当てています。 +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +# 各サブモジュールからのインポート +from ywta.core.maya_utils import * +from ywta.core.node_utils import * +from ywta.core.namespace_utils import * +from ywta.core.ui_utils import * +from ywta.core.settings_utils import * +from ywta.core.geometry_utils import * diff --git a/maya/ywta/core/geometry_utils.py b/maya/ywta/core/geometry_utils.py new file mode 100644 index 0000000..426fbbf --- /dev/null +++ b/maya/ywta/core/geometry_utils.py @@ -0,0 +1,137 @@ +""" +Geometry Utilities + +ジオメトリ操作、数学計算、空間変換に関連するユーティリティ関数を提供します。 +これらの関数は、3D空間での計算や変換を簡素化します。 +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import logging + +import maya.cmds as cmds +import maya.api.OpenMaya as OpenMaya2 + +logger = logging.getLogger(__name__) + + +def distance(node1=None, node2=None): + """2つのノード間の距離を計算します + + :param node1: 最初のノード + :param node2: 2番目のノード + :return: 距離 + """ + if node1 is None or node2 is None: + # デフォルトで選択を使用 + selection = cmds.ls(sl=True, type="transform") + if len(selection) != 2: + raise RuntimeError("2つのトランスフォームを選択してください。") + node1, node2 = selection + + pos1 = cmds.xform(node1, query=True, worldSpace=True, translation=True) + pos2 = cmds.xform(node2, query=True, worldSpace=True, translation=True) + + pos1 = OpenMaya2.MPoint(pos1[0], pos1[1], pos1[2]) + pos2 = OpenMaya2.MPoint(pos2[0], pos2[1], pos2[2]) + return pos1.distanceTo(pos2) + + +def vector_to(source=None, target=None): + """2つのノード間のベクトルを計算します + + :param source: 始点ノード + :param target: 終点ノード + :return: MVector (API2) + """ + if source is None or target is None: + # デフォルトで選択を使用 + selection = cmds.ls(sl=True, type="transform") + if len(selection) != 2: + raise RuntimeError("2つのトランスフォームを選択してください。") + source, target = selection + + pos1 = cmds.xform(source, query=True, worldSpace=True, translation=True) + pos2 = cmds.xform(target, query=True, worldSpace=True, translation=True) + + source_point = OpenMaya2.MPoint(pos1[0], pos1[1], pos1[2]) + target_point = OpenMaya2.MPoint(pos2[0], pos2[1], pos2[2]) + return target_point - source_point + + +def get_bounding_box(node): + """ノードのバウンディングボックスを取得します + + :param node: バウンディングボックスを取得するノード + :return: (min_x, min_y, min_z, max_x, max_y, max_z)のタプル + """ + bbox = cmds.exactWorldBoundingBox(node) + return bbox + + +def get_center_point(node): + """ノードの中心点を取得します + + :param node: 中心点を取得するノード + :return: (x, y, z)の中心点座標 + """ + bbox = get_bounding_box(node) + center_x = (bbox[0] + bbox[3]) / 2.0 + center_y = (bbox[1] + bbox[4]) / 2.0 + center_z = (bbox[2] + bbox[5]) / 2.0 + return (center_x, center_y, center_z) + + +def create_vector(x, y, z): + """指定された成分からMVectorを作成します + + :param x: X成分 + :param y: Y成分 + :param z: Z成分 + :return: MVector + """ + return OpenMaya2.MVector(x, y, z) + + +def normalize_vector(vector): + """ベクトルを正規化します + + :param vector: 正規化するベクトル(MVectorまたは3要素のタプル/リスト) + :return: 正規化されたMVector + """ + if isinstance(vector, (tuple, list)): + vector = OpenMaya2.MVector(vector[0], vector[1], vector[2]) + + return vector.normalize() + + +def dot_product(vector1, vector2): + """2つのベクトルのドット積を計算します + + :param vector1: 最初のベクトル(MVectorまたは3要素のタプル/リスト) + :param vector2: 2番目のベクトル(MVectorまたは3要素のタプル/リスト) + :return: ドット積の結果 + """ + if isinstance(vector1, (tuple, list)): + vector1 = OpenMaya2.MVector(vector1[0], vector1[1], vector1[2]) + if isinstance(vector2, (tuple, list)): + vector2 = OpenMaya2.MVector(vector2[0], vector2[1], vector2[2]) + + return vector1 * vector2 + + +def cross_product(vector1, vector2): + """2つのベクトルのクロス積を計算します + + :param vector1: 最初のベクトル(MVectorまたは3要素のタプル/リスト) + :param vector2: 2番目のベクトル(MVectorまたは3要素のタプル/リスト) + :return: クロス積の結果(MVector) + """ + if isinstance(vector1, (tuple, list)): + vector1 = OpenMaya2.MVector(vector1[0], vector1[1], vector1[2]) + if isinstance(vector2, (tuple, list)): + vector2 = OpenMaya2.MVector(vector2[0], vector2[1], vector2[2]) + + return vector1 ^ vector2 diff --git a/maya/ywta/core/maya_utils.py b/maya/ywta/core/maya_utils.py new file mode 100644 index 0000000..4b0af1c --- /dev/null +++ b/maya/ywta/core/maya_utils.py @@ -0,0 +1,113 @@ +""" +Maya API Utilities + +Maya APIを使用するための便利なユーティリティ関数を提供します。 +これらの関数は、Maya APIの複雑さを抽象化し、一般的なタスクを簡素化します。 +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import logging + +import maya.cmds as cmds +import maya.OpenMaya as OpenMaya +import maya.OpenMayaAnim as OpenMayaAnim +import maya.api.OpenMaya as OpenMaya2 + +logger = logging.getLogger(__name__) + + +def get_mobject(node): + """指定されたノードのMObjectを取得します。 + + :param node: ノード名 + :return: MObject + """ + selection_list = OpenMaya2.MSelectionList() + selection_list.add(node) + mobject = selection_list.getDependNode(0) + return mobject + + +def get_dag_path(node): + """指定されたノードのMDagPathを取得します。 + + :param node: ノード名 + :return: MDagPath + """ + selection_list = OpenMaya2.MSelectionList() + selection_list.add(node) + path = selection_list.getDagPath(0) + return path + + +def get_mfnmesh(mesh_name): + """指定されたメッシュのMFnMeshを取得します。 + + :param mesh_name: メッシュ名 + :return: MFnMesh + """ + from ywta.core.node_utils import get_shape + + mesh = get_shape(mesh_name) + path = get_dag_path(mesh) + return OpenMaya2.MFnMesh(path) + + +def get_points(mesh_name, space=OpenMaya2.MSpace.kObject): + """メッシュの頂点位置をMPointArrayとして取得します。 + + :param mesh_name: メッシュ名 + :param space: 座標空間(デフォルトはオブジェクト空間) + :return: MPointArray + """ + fn_mesh = get_mfnmesh(mesh_name) + points = fn_mesh.getPoints(space) + return points + + +def set_points(mesh_name, points): + """メッシュの頂点位置をMPointArrayで設定します。 + + :param mesh_name: メッシュ名 + :param points: MPointArray + """ + from ywta.core.node_utils import get_shape + + mesh = get_shape(mesh_name) + path = get_dag_path(mesh) + fn_mesh = OpenMaya2.MFnMesh(path) + fn_mesh.setPoints(points) + + +def get_mfnblendshapedeformer(blendshape_node_name): + """指定されたブレンドシェイプノードのMFnBlendShapeDeformerを取得します。 + + :param blendshape_node_name: ブレンドシェイプノード名 + :return: MFnBlendShapeDeformer + """ + node = get_mobject(blendshape_node_name) + blendShapeFn = OpenMayaAnim.MFnBlendShapeDeformer(node) + return blendShapeFn + + +# MScriptUtil関連のユーティリティ +def get_int_ptr(): + """整数ポインタを作成します。 + + :return: 整数ポインタ + """ + util = OpenMaya.MScriptUtil() + util.createFromInt(0) + return util.asIntPtr() + + +def ptr_to_int(int_ptr): + """整数ポインタから整数値を取得します。 + + :param int_ptr: 整数ポインタ + :return: 整数値 + """ + return OpenMaya.MScriptUtil.getInt(int_ptr) diff --git a/maya/ywta/core/namespace_utils.py b/maya/ywta/core/namespace_utils.py new file mode 100644 index 0000000..be13a34 --- /dev/null +++ b/maya/ywta/core/namespace_utils.py @@ -0,0 +1,53 @@ +""" +Namespace Utilities + +Mayaの名前空間操作に関連するユーティリティ関数を提供します。 +これらの関数は、名前空間の取得、操作、変換などの一般的なタスクを簡素化します。 +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import logging +import re + +logger = logging.getLogger(__name__) + + +def get_namespace_from_name(name): + """指定された名前から名前空間を取得します。 + + >>> print(get_namespace_from_name('BOB:character')) + BOB: + >>> print(get_namespace_from_name('YEP:BOB:character')) + YEP:BOB: + + :param name: 名前空間を抽出する文字列 + :return: 抽出された名前空間 + """ + namespace = re.match("[_0-9a-zA-Z]+(?=:)(:[_0-9a-zA-Z]+(?=:))*", name) + if namespace: + namespace = "%s:" % str(namespace.group(0)) + else: + namespace = "" + return namespace + + +def remove_namespace_from_name(name): + """指定された名前から名前空間を削除します + + >>> print(remove_namespace_from_name('character')) + character + >>> print(remove_namespace_from_name('BOB:character')) + character + >>> print(remove_namespace_from_name('YEP:BOB:character')) + character + + :param name: 名前空間付きの名前 + :return: 名前空間なしの名前 + """ + namespace = get_namespace_from_name(name) + if namespace: + return re.sub("^{0}".format(namespace), "", name) + return name diff --git a/maya/ywta/core/node_utils.py b/maya/ywta/core/node_utils.py new file mode 100644 index 0000000..dbffeaa --- /dev/null +++ b/maya/ywta/core/node_utils.py @@ -0,0 +1,127 @@ +""" +Node Utilities + +Mayaのノード操作に関連するユーティリティ関数を提供します。 +これらの関数は、ノードの取得、操作、変換などの一般的なタスクを簡素化します。 +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import logging + +import maya.cmds as cmds + +logger = logging.getLogger(__name__) + + +def get_shape(node, intermediate=False): + """トランスフォームのシェイプノードを取得します。 + + これは、ノードがシェイプノードかトランスフォームかを確認する必要がない場合に便利です。 + シェイプノードまたはトランスフォームを渡すと、関数はシェイプノードを返します。 + + :param node: ノード名 + :param intermediate: 中間シェイプを取得する場合はTrue + :return: シェイプノード名 + """ + if cmds.objectType(node, isAType="transform"): + shapes = cmds.listRelatives(node, shapes=True, path=True) + if not shapes: + shapes = [] + for shape in shapes: + is_intermediate = cmds.getAttr("{}.intermediateObject".format(shape)) + if ( + intermediate + and is_intermediate + and cmds.listConnections(shape, source=False) + ): + return shape + elif not intermediate and not is_intermediate: + return shape + if shapes: + return shapes[0] + elif cmds.nodeType(node) in ["mesh", "nurbsCurve", "nurbsSurface"]: + is_intermediate = cmds.getAttr("{}.intermediateObject".format(node)) + if is_intermediate and not intermediate: + node = cmds.listRelatives(node, parent=True, path=True)[0] + return get_shape(node) + else: + return node + return None + + +def get_node_in_namespace_hierarchy(node, namespace=None, shape=False): + """指定された名前空間とすべてのネストされた名前空間から指定されたノードを検索します。 + + :param node: ノード名 + :param namespace: ルート名前空間 + :param shape: シェイプノードを取得する場合はTrue、トランスフォームを取得する場合はFalse + :return: 適切な名前空間内のノード + """ + if shape and node and cmds.objExists(node): + node = get_shape(node) + + if node and cmds.objExists(node): + return node + + if node and namespace: + # 名前空間または子名前空間に存在するかどうかを確認 + namespaces = [namespace.replace(":", "")] + namespaces += cmds.namespaceInfo(namespace, r=True, lon=True) or [] + for namespace in namespaces: + namespaced_node = "{0}:{1}".format(namespace, node) + if shape: + namespaced_node = get_shape(namespaced_node) + if namespaced_node and cmds.objExists(namespaced_node): + return namespaced_node + return None + + +def distance(node1=None, node2=None): + """2つのノード間の距離を計算します + + :param node1: 最初のノード + :param node2: 2番目のノード + :return: 距離 + """ + if node1 is None or node2 is None: + # デフォルトで選択を使用 + selection = cmds.ls(sl=True, type="transform") + if len(selection) != 2: + raise RuntimeError("2つのトランスフォームを選択してください。") + node1, node2 = selection + + pos1 = cmds.xform(node1, query=True, worldSpace=True, translation=True) + pos2 = cmds.xform(node2, query=True, worldSpace=True, translation=True) + + import maya.api.OpenMaya as OpenMaya2 + + pos1 = OpenMaya2.MPoint(pos1[0], pos1[1], pos1[2]) + pos2 = OpenMaya2.MPoint(pos2[0], pos2[1], pos2[2]) + return pos1.distanceTo(pos2) + + +def vector_to(source=None, target=None): + """2つのノード間のベクトルを計算します + + :param source: 始点ノード + :param target: 終点ノード + :return: MVector (API2) + """ + if source is None or target is None: + # デフォルトで選択を使用 + selection = cmds.ls(sl=True, type="transform") + if len(selection) != 2: + raise RuntimeError("2つのトランスフォームを選択してください。") + source, target = selection + + pos1 = cmds.xform(source, query=True, worldSpace=True, translation=True) + pos2 = cmds.xform(target, query=True, worldSpace=True, translation=True) + + import maya.api.OpenMaya as OpenMaya2 + + source = OpenMaya2.MPoint(pos1[0], pos1[1], pos1[2]) + target = OpenMaya2.MPoint(pos2[0], pos2[1], pos2[2]) + return target - source diff --git a/maya/ywta/core/settings_utils.py b/maya/ywta/core/settings_utils.py new file mode 100644 index 0000000..cb06a79 --- /dev/null +++ b/maya/ywta/core/settings_utils.py @@ -0,0 +1,112 @@ +""" +Settings Utilities + +設定の保存、読み込み、管理に関連するユーティリティ関数を提供します。 +これらの関数は、ユーザー設定の永続化と取得を簡素化します。 +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import logging +import os + +import maya.cmds as cmds + +logger = logging.getLogger(__name__) + +# PySide2とPySide6の両方をサポート +try: + from PySide6.QtCore import QSettings +except ImportError: + from PySide2.QtCore import QSettings + + +_settings = None + + +def _get_settings(): + """QSettingsインスタンスを取得します""" + global _settings + if _settings is None: + _settings = QSettings("YWTA", "YWTATools") + return _settings + + +def get_setting(key, default_value=None): + """永続キャッシュから値を取得します。 + + :param key: ハッシュキー + :param default_value: キーが存在しない場合に返す値 + :return: 保存された値 + """ + settings = _get_settings() + return settings.value(key, default_value) + + +def set_setting(key, value): + """永続キャッシュに値を設定します。 + + :param key: ハッシュキー + :param value: 保存する値 + """ + settings = _get_settings() + settings.setValue(key, value) + + +def get_save_file_name(file_filter, key=None): + """保存ダイアログからファイルパスを取得します。 + + :param file_filter: ファイルフィルタ(例:"Maya Files (*.ma *.mb)") + :param key: 永続キャッシュに保存されている開始ディレクトリにアクセスするためのオプションのキー値 + :return: 選択されたファイルパス + """ + return _get_file_path(file_filter, key, 0) + + +def get_open_file_name(file_filter, key=None): + """オープンファイルダイアログからファイルパスを取得します。 + + :param file_filter: ファイルフィルタ(例:"Maya Files (*.ma *.mb)") + :param key: 永続キャッシュに保存されている開始ディレクトリにアクセスするためのオプションのキー値 + :return: 選択されたファイルパス + """ + return _get_file_path(file_filter, key, 1) + + +def get_directory_name(key=None): + """オープンファイルダイアログからファイルパスを取得します。 + + :param key: 永続キャッシュに保存されている開始ディレクトリにアクセスするためのオプションのキー値 + :return: 選択されたファイルパス + """ + return _get_file_path("", key, 3) + + +def _get_file_path(file_filter, key, file_mode): + """ファイルダイアログからファイルパスを取得します。 + + :param file_filter: ファイルフィルタ(例:"Maya Files (*.ma *.mb)") + :param key: 永続キャッシュに保存されている開始ディレクトリにアクセスするためのオプションのキー値 + :param file_mode: 0 存在するかどうかに関わらず、任意のファイル + 1 単一の既存ファイル + 2 ディレクトリの名前。ダイアログにはディレクトリとファイルの両方が表示されます + 3 ディレクトリの名前。ダイアログにはディレクトリのみが表示されます + 4 1つ以上の既存ファイルの名前 + :return: 選択されたファイルパス + """ + start_directory = cmds.workspace(q=True, rd=True) + if key is not None: + start_directory = get_setting(key, start_directory) + + file_path = cmds.fileDialog2( + fileMode=file_mode, startingDirectory=start_directory, fileFilter=file_filter + ) + if key is not None and file_path: + file_path = file_path[0] + directory = ( + file_path if os.path.isdir(file_path) else os.path.dirname(file_path) + ) + set_setting(key, directory) + return file_path diff --git a/maya/ywta/core/ui_utils.py b/maya/ywta/core/ui_utils.py new file mode 100644 index 0000000..a4db8e1 --- /dev/null +++ b/maya/ywta/core/ui_utils.py @@ -0,0 +1,114 @@ +""" +UI Utilities + +Mayaのユーザーインターフェース操作に関連するユーティリティクラスと関数を提供します。 +これらのクラスと関数は、UIの作成、管理、操作などの一般的なタスクを簡素化します。 +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import logging +import os + +import maya.cmds as cmds + +logger = logging.getLogger(__name__) + +# PySide2とPySide6の両方をサポート +try: + from PySide6.QtCore import QSettings +except ImportError: + from PySide2.QtCore import QSettings + + +class BaseTreeNode(object): + """QAbstractItemModelで使用するための階層機能を含む基本ツリーノード""" + + def __init__(self, parent=None): + self.children = [] + self._parent = parent + + if parent is not None: + parent.add_child(self) + + def add_child(self, child): + """ノードに子を追加します。 + + :param child: 追加する子ノード + """ + if child not in self.children: + self.children.append(child) + + def remove(self): + """このノードとそのすべての子をツリーから削除します。""" + if self._parent: + row = self.row() + self._parent.children.pop(row) + self._parent = None + for child in self.children: + child.remove() + + def child(self, row): + """指定されたインデックスの子を取得します。 + + :param row: 子のインデックス + :return: 指定されたインデックスのツリーノード、またはインデックスが範囲外の場合はNone + """ + try: + return self.children[row] + except IndexError: + return None + + def child_count(self): + """ノード内の子の数を取得します""" + return len(self.children) + + def parent(self): + """ノードの親を取得します""" + return self._parent + + def row(self): + """親に対するノードのインデックスを取得します""" + if self._parent is not None: + return self._parent.children.index(self) + return 0 + + def data(self, column): + """テーブル表示データを取得します""" + return "" + + +class SingletonWindowMixin(object): + """ウィンドウのインスタンスを1つだけ許可するQWidgetベースのウィンドウで使用するミックスイン""" + + _window_instance = None + + @classmethod + def show_window(cls): + if not cls._window_instance: + cls._window_instance = cls() + cls._window_instance.show() + cls._window_instance.raise_() + cls._window_instance.activateWindow() + + def closeEvent(self, event): + self._window_instance = None + event.accept() + + +def get_icon_path(name): + """指定されたアイコン名のパスを取得します。 + + :param name: アイコンディレクトリ内のアイコンの名前 + :return: アイコンへのフルパス、または存在しない場合はNone + """ + icon_directory = os.path.join(os.path.dirname(__file__), "..", "..", "..", "icons") + image_extensions = ["png", "svg", "jpg", "jpeg"] + for root, dirs, files in os.walk(icon_directory): + for ext in image_extensions: + full_path = os.path.join(root, "{0}.{1}".format(name, ext)) + if os.path.exists(full_path): + return os.path.normpath(full_path) + return None diff --git a/maya/ywta/deform/__init__.py b/maya/ywta/deform/__init__.py index e69de29..bb348bb 100644 --- a/maya/ywta/deform/__init__.py +++ b/maya/ywta/deform/__init__.py @@ -0,0 +1,5 @@ +# Dependencies: +# - ywta.deform.blendshape +# - ywta.mesh.colorset +# - ywta.shortcuts +# - ywta.ui.optionbox \ No newline at end of file diff --git a/maya/ywta/deform/blendshape.py b/maya/ywta/deform/blendshape.py index 2aca746..8825395 100644 --- a/maya/ywta/deform/blendshape.py +++ b/maya/ywta/deform/blendshape.py @@ -4,17 +4,16 @@ import maya.OpenMaya as OpenMaya import maya.OpenMayaAnim as OpenMayaAnim -from ywta.io.obj import import_obj, export_obj import ywta.shortcuts as shortcuts import ywta.deform.np_mesh as np_mesh import ywta.rig.common as common def get_blendshape_node(geometry): - """Get the first blendshape node upstream from the given geometry. + """指定されたジオメトリの上流にある最初のブレンドシェイプノードを取得します。 - :param geometry: Name of the geometry - :return: The blendShape node name + :param geometry: ジオメトリの名前 + :return: ブレンドシェイプノードの名前 """ geometry = shortcuts.get_shape(geometry) history = cmds.listHistory(geometry, il=2, pdo=False) or [] @@ -31,11 +30,11 @@ def get_blendshape_node(geometry): def get_or_create_blendshape_node(geometry): - """Get the first blendshape node upstream from the given geometry or create one if - one does not exist. + """指定されたジオメトリの上流にある最初のブレンドシェイプノードを取得します。 + 存在しない場合は新しく作成します。 - :param geometry: Name of the geometry - :return: The blendShape node name + :param geometry: ジオメトリの名前 + :return: ブレンドシェイプノードの名前 """ geometry = shortcuts.get_shape(geometry) blendshape = get_blendshape_node(geometry) @@ -105,105 +104,120 @@ def set_target_weights(blendshape, target, weights): ) -def import_obj_directory(directory, base_mesh=None): - if base_mesh: - blendshape = get_or_create_blendshape_node(base_mesh) - for f in os.listdir(directory): - if not f.lower().endswith(".obj") or f.startswith("_"): - continue - full_path = os.path.join(directory, f) - target = import_obj(full_path) - if base_mesh: - add_target(blendshape, target) - cmds.delete(target) - - -def export_blendshape_targets(blendshape, directory): - """Export all targets of a blendshape as objs. - - :param blendshape: Blendshape name - :param directory: Directory path - """ - connections = zero_weights(blendshape) - targets = get_target_list(blendshape) - base = cmds.blendShape(blendshape, q=True, g=True)[0] - for t in targets: - plug = "{}.{}".format(blendshape, t) - cmds.setAttr(plug, 1) - file_path = os.path.join(directory, "{}.obj".format(t)) - export_obj(base, file_path) - cmds.setAttr(plug, 0) - restore_weights(blendshape, connections) - - def zero_weights(blendshape): - """Disconnects all connections to blendshape target weights and zero's - out the weights. + """ブレンドシェイプターゲットのウェイトへのすべての接続を切断し、 + ウェイトをゼロにします。 - :param blendshape: Blendshape node name - :return: Dictionary of connections dict[target] = connection + :param blendshape: ブレンドシェイプノードの名前 + :return: 接続の辞書 dict[target] = [connection, original_value] """ connections = {} targets = get_target_list(blendshape) for t in targets: plug = "{}.{}".format(blendshape, t) + # 現在の値を保存 + original_value = cmds.getAttr(plug) connection = cmds.listConnections(plug, plugs=True, d=False) if connection: - connections[t] = connection[0] + connections[t] = [connection[0], original_value] cmds.disconnectAttr(connection[0], plug) + else: + # 接続がない場合でも元の値を保存 + connections[t] = [None, original_value] cmds.setAttr(plug, 0) return connections def restore_weights(blendshape, connections): - """Restore the weight connections disconnected from zero_weights. + """zero_weightsで切断されたウェイト接続を復元します。 - :param blendshape: Blendshape name - :param connections: Dictionary of connections returned from zero_weights. + :param blendshape: ブレンドシェイプの名前 + :param connections: zero_weightsから返された接続の辞書 """ - for target, connection in connections.items(): - cmds.connectAttr(connection, "{}.{}".format(blendshape, target)) + for target, data in connections.items(): + plug = "{}.{}".format(blendshape, target) + connection, original_value = data + if connection: + cmds.connectAttr(connection, plug) + else: + # 接続がない場合は値を直接設定 + cmds.setAttr(plug, original_value) def transfer_shapes(source, destination, blendshape=None): - """Transfers the shapes on the given blendshape to the destination mesh. + """指定されたブレンドシェイプのシェイプを宛先メッシュに転送します。 - It is assumed the blendshape indirectly deforms the destination mesh. + ブレンドシェイプが間接的に宛先メッシュを変形させることを前提としています。 - :param source: Mesh to transfer shapes from. - :param destination: Mesh to transfer shapes to. - :param blendshape: Optional blendshape node name. If no blendshape is given, the - blendshape on the source mesh will be used. - :return: The new blendshape node name. + :param source: シェイプを転送元のメッシュ + :param destination: シェイプを転送先のメッシュ + :param blendshape: オプションのブレンドシェイプノード名。ブレンドシェイプが指定されない場合、 + ソースメッシュ上のブレンドシェイプが使用されます。 + :return: 新しいブレンドシェイプノードの名前 """ if blendshape is None: blendshape = get_blendshape_node(source) if blendshape is None: - return + return None + connections = zero_weights(blendshape) targets = get_target_list(blendshape) new_targets = [] - for t in targets: - cmds.setAttr("{}.{}".format(blendshape, t), 1) - new_targets.append(cmds.duplicate(destination, name=t)[0]) - cmds.setAttr("{}.{}".format(blendshape, t), 0) + + # 転送先メッシュのヒストリーを削除して新しいシェイプを受け入れる準備をする cmds.delete(destination, ch=True) - new_blendshape = cmds.blendShape(new_targets, destination, foc=True)[0] - cmds.delete(new_targets) - for t in targets: - cmds.connectAttr( - "{}.{}".format(blendshape, t), "{}.{}".format(new_blendshape, t) - ) - restore_weights(blendshape, connections) + + try: + # 各ターゲットについて処理 + for t in targets: + # ソースのブレンドシェイプターゲットをアクティブ化 + cmds.setAttr("{}.{}".format(blendshape, t), 1) + # 転送先メッシュを複製して形状を保存 + target_mesh = cmds.duplicate(destination, name=t)[0] + new_targets.append(target_mesh) + # ソースのブレンドシェイプターゲットを非アクティブ化 + cmds.setAttr("{}.{}".format(blendshape, t), 0) + + # 転送先メッシュに新しいブレンドシェイプを作成 + new_blendshape = cmds.blendShape(new_targets, destination, foc=True)[0] + + # ターゲットごとに元のブレンドシェイプから新しいブレンドシェイプへ接続を作成 + for i, t in enumerate(targets): + try: + # 新しいブレンドシェイプノードのターゲットを名前で確認 + if cmds.attributeQuery(t, node=new_blendshape, exists=True): + cmds.connectAttr( + "{}.{}".format(blendshape, t), "{}.{}".format(new_blendshape, t) + ) + else: + # 名前でターゲットが見つからない場合はインデックスを使用 + cmds.connectAttr( + "{}.{}".format(blendshape, t), + "{}.w[{}]".format(new_blendshape, i), + ) + except RuntimeError as e: + cmds.warning("Failed to connect target '{}': {}".format(t, str(e))) + + # 複製したターゲットメッシュを削除 + cmds.delete(new_targets) + except Exception as e: + # エラーが発生した場合、中間オブジェクトをクリーンアップ + if new_targets: + cmds.delete(new_targets) + raise RuntimeError("Error transferring shapes: {}".format(str(e))) + finally: + # 元のブレンドシェイプの重みを復元 + restore_weights(blendshape, connections) + return new_blendshape def propagate_neutral_update(old_neutral, new_neutral, shapes): - """Propagate neutral update deltas to target shapes + """ニュートラル更新のデルタをターゲットシェイプに伝播します - :param old_neutral: The old neutral mesh - :param new_neutral: The new neutral mesh - :param shapes: The list of shapes to update + :param old_neutral: 古いニュートラルメッシュ + :param new_neutral: 新しいニュートラルメッシュ + :param shapes: 更新するシェイプのリスト """ _old = np_mesh.Mesh.from_maya_mesh(old_neutral) _new = np_mesh.Mesh.from_maya_mesh(new_neutral) @@ -215,14 +229,14 @@ def propagate_neutral_update(old_neutral, new_neutral, shapes): def create_shapes_joint(blendshapes, parent, name="shapes"): - """Create a joint with a weight attribute per each blendshape target. + """各ブレンドシェイプターゲットごとにウェイト属性を持つジョイントを作成します。 - This is used to export blendshape animation with the skeleton. + これはブレンドシェイプアニメーションをスケルトンと一緒にエクスポートするために使用されます。 - :param blendshapes: List of blendshape nodes. - :param parent: Joint to parent the new joint under. - :param name: Name of the new joint. "shapes" by default. - :return: The new joint name + :param blendshapes: ブレンドシェイプノードのリスト + :param parent: 新しいジョイントの親となるジョイント + :param name: 新しいジョイントの名前。デフォルトは "shapes" + :return: 新しいジョイントの名前 """ joint = cmds.createNode("joint", name=name) common.snap_to(joint, parent) @@ -244,10 +258,10 @@ def create_shapes_joint(blendshapes, parent, name="shapes"): def get_targets_at_index(blend_name, index=0): """ - returns targets. - :param blend_name: the name of the blendShape node. - :param index: shape index. - :return: string array of targets at index. + ターゲットを返します。 + :param blend_name: ブレンドシェイプノードの名前 + :param index: シェイプのインデックス + :return: インデックスにあるターゲットの文字列配列 """ blend_fn = shortcuts.get_mfnblendshapedeformer(blend_name) # base_obj = get_base_object(blend_name)[0] @@ -260,9 +274,9 @@ def get_targets_at_index(blend_name, index=0): def get_base_object(blend_name): """ - returns the base object of the blendShape node. - :param blend_name: the name of the blendShape node. - :return: string array of base object. + ブレンドシェイプノードのベースオブジェクトを返します。 + :param blend_name: ブレンドシェイプノードの名前 + :return: ベースオブジェクトの文字列配列 """ blend_fn = shortcuts.get_mfnblendshapedeformer(blend_name) obj_array = OpenMaya.MObjectArray() @@ -271,13 +285,13 @@ def get_base_object(blend_name): def find_replace_target_names(blendshape, find_text, replace_text, case_sensitive=True): - """Find and replace text in blendshape target names. + """ブレンドシェイプターゲット名のテキストを検索して置換します。 - :param blendshape: Name of the blendshape node - :param find_text: Text to find in target names - :param replace_text: Text to replace with - :param case_sensitive: Whether the search should be case sensitive (default: True) - :return: Dictionary with old_name: new_name pairs for renamed targets + :param blendshape: ブレンドシェイプノードの名前 + :param find_text: ターゲット名で検索するテキスト + :param replace_text: 置換するテキスト + :param case_sensitive: 検索が大文字と小文字を区別するかどうか(デフォルト: True) + :return: 名前変更されたターゲットの old_name: new_name ペアを持つ辞書 """ if not cmds.objExists(blendshape): raise RuntimeError("BlendShape node '{}' does not exist".format(blendshape)) @@ -329,12 +343,12 @@ def find_replace_target_names(blendshape, find_text, replace_text, case_sensitiv def find_replace_target_names_regex(blendshape, pattern, replacement): - """Find and replace text in blendshape target names using regular expressions. + """正規表現を使用してブレンドシェイプターゲット名のテキストを検索して置換します。 - :param blendshape: Name of the blendshape node - :param pattern: Regular expression pattern to find - :param replacement: Replacement text (can include regex groups like \\1, \\2) - :return: Dictionary with old_name: new_name pairs for renamed targets + :param blendshape: ブレンドシェイプノードの名前 + :param pattern: 検索する正規表現パターン + :param replacement: 置換テキスト(\\1、\\2などの正規表現グループを含めることができます) + :return: 名前変更されたターゲットの old_name: new_name ペアを持つ辞書 """ import re diff --git a/maya/ywta/deform/shapesui.py b/maya/ywta/deform/shapesui.py deleted file mode 100644 index 32f1143..0000000 --- a/maya/ywta/deform/shapesui.py +++ /dev/null @@ -1,186 +0,0 @@ -"""A UI used to manipulate blendshapes.""" - -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from functools import partial -import logging -import os - -from PySide2.QtCore import * -from PySide2.QtWidgets import * - -from maya.app.general.mayaMixin import MayaQWidgetBaseMixin -import maya.cmds as cmds - -from ywta.ui.widgets.filepathwidget import FilePathWidget -from ywta.ui.stringcache import StringCache -import ywta.deform.blendshape as bs -import ywta.deform.np_mesh as np_mesh - -reload(bs) -import ywta.shortcuts as shortcuts -from ywta.io.obj import import_obj, export_obj - -logger = logging.getLogger(__name__) -_win = None - - -def show(): - """Shows the window.""" - global _win - if _win: - _win.close() - _win = ShapesWindow() - _win.show() - - -class ShapesWindow(MayaQWidgetBaseMixin, QMainWindow): - def __init__(self, parent=None): - super(ShapesWindow, self).__init__(parent) - self.setWindowTitle("Shapes") - self.resize(800, 600) - self.create_actions() - self.create_menu() - main_widget = QWidget() - self.setCentralWidget(main_widget) - main_layout = QVBoxLayout() - main_widget.setLayout(main_layout) - self.file_model = QFileSystemModel(self) - self.root_path = FilePathWidget( - "Root: ", FilePathWidget.directory, name="ywtatools.shapes.rootpath", parent=self - ) - self.root_path.path_changed.connect(self.set_root_path) - main_layout.addWidget(self.root_path) - - self.file_tree_view = QTreeView() - self.file_model.setFilter(QDir.NoDotAndDotDot | QDir.Files | QDir.AllDirs) - self.file_model.setReadOnly(True) - self.file_model.setNameFilters(["*.obj"]) - self.file_model.setNameFilterDisables(False) - self.file_tree_view.setModel(self.file_model) - self.file_tree_view.setColumnHidden(1, True) - self.file_tree_view.setColumnHidden(2, True) - self.file_tree_view.setContextMenuPolicy(Qt.CustomContextMenu) - self.file_tree_view.customContextMenuRequested.connect( - self.on_file_tree_context_menu - ) - self.file_tree_view.doubleClicked.connect(self.on_file_tree_double_clicked) - self.file_tree_view.setSelectionMode(QAbstractItemView.ExtendedSelection) - main_layout.addWidget(self.file_tree_view) - self.set_root_path(self.root_path.path) - - def create_actions(self): - self.propagate_neutral_action = QAction( - "Propagate Neutral Update", - toolTip="Propagate updates to a neutral mesh to the selected targets.", - triggered=self.propagate_neutral_update, - ) - - self.export_selected_action = QAction( - "Export Selected Meshes", - toolTip="Export the selected meshes to the selected directory", - triggered=self.export_selected, - ) - - def create_menu(self): - menubar = self.menuBar() - menu = menubar.addMenu("Shapes") - menu.addAction(self.propagate_neutral_action) - menu.addAction(self.export_selected_action) - - def set_root_path(self, path): - index = self.file_model.setRootPath(path) - self.file_tree_view.setRootIndex(index) - - def on_file_tree_double_clicked(self, index): - path = self.file_model.fileInfo(index).absoluteFilePath() - if not os.path.isfile(path) or not path.lower().endswith(".obj"): - return - self.import_selected_objs() - - def on_file_tree_context_menu(self, pos): - index = self.file_tree_view.indexAt(pos) - - if not index.isValid(): - return - - path = self.file_model.fileInfo(index).absoluteFilePath() - if not os.path.isfile(path) or not path.lower().endswith(".obj"): - return - - sel = cmds.ls(sl=True) - blendshape = bs.get_blendshape_node(sel[0]) if sel else None - - menu = QMenu() - label = "Import as target" if blendshape else "Import" - menu.addAction(QAction(label, self, triggered=self.import_selected_objs)) - if sel and shortcuts.get_shape(sel[0]): - menu.addAction( - QAction( - "Export selected", self, triggered=partial(export_obj, sel[0], path) - ) - ) - menu.exec_(self.file_tree_view.mapToGlobal(pos)) - - def get_selected_paths(self): - indices = self.file_tree_view.selectedIndexes() - if not indices: - return [] - paths = [ - self.file_model.fileInfo(idx).absoluteFilePath() - for idx in indices - if idx.column() == 0 - ] - return paths - - def import_selected_objs(self, add_as_targets=True): - """Import the selected shapes in the tree view. - - If a mesh with a blendshape is selected in the scene, the shapes will be added - as targets - """ - indices = self.file_tree_view.selectedIndexes() - if not indices: - return None - paths = self.get_selected_paths() - - sel = cmds.ls(sl=True) - blendshape = bs.get_blendshape_node(sel[0]) if sel else None - meshes = [import_obj(path) for path in paths] - if blendshape and add_as_targets: - for mesh in meshes: - bs.add_target(blendshape, mesh) - cmds.delete(mesh) - elif meshes: - cmds.select(meshes) - return meshes - - def export_selected(self): - sel = cmds.ls(sl=True) - if not sel: - return - indices = self.file_tree_view.selectedIndexes() - if indices: - path = self.file_model.fileInfo(indices[0]).absoluteFilePath() - directory = os.path.dirname(path) if os.path.isfile(path) else path - else: - directory = self.file_model.rootPath() - - for mesh in sel: - path = os.path.join(directory, "{}.obj".format(mesh)) - export_obj(mesh, path) - - def propagate_neutral_update(self): - sel = cmds.ls(sl=True) - if len(sel) != 2: - QMessageBox.critical( - self, "Error", "Select the old neutral, then the new neutral." - ) - return - old_neutral, new_neutral = sel - meshes = self.import_selected_objs(add_as_targets=False) - if not meshes: - return - bs.propagate_neutral_update(old_neutral, new_neutral, meshes) diff --git a/maya/ywta/deform/skinio.py b/maya/ywta/deform/skinio.py deleted file mode 100644 index f247d9a..0000000 --- a/maya/ywta/deform/skinio.py +++ /dev/null @@ -1,474 +0,0 @@ -"""Exports and imports skin weights. - -Usage: - Select a mesh and run - - # To export - import skinio - skinio.export_skin(file_path='/path/to/data.skin') - - # To import - skinio.import_skin(file_path='/path/to/data.skin') -""" - -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -import json -import logging -import os -import re -from six import string_types -from functools import partial - -from PySide2.QtCore import * -from PySide2.QtGui import * -from PySide2.QtWidgets import * -from maya.app.general.mayaMixin import MayaQWidgetBaseMixin - -import maya.cmds as cmds -import maya.api.OpenMaya as OpenMaya -import maya.api.OpenMayaAnim as OpenMayaAnim - -import ywta.shortcuts as shortcuts - -logger = logging.getLogger(__name__) -EXTENSION = ".skin" - -# Key value for QSettings to save file browser directory -KEY_STORE = "skinio.start_directory" - - -def import_skin( - file_path=None, shape=None, to_selected_shapes=False, enable_remap=True -): - """Creates a skinCluster on the specified shape if one does not already exist - and then import the weight data. - """ - - if file_path is None: - file_path = shortcuts.get_open_file_name( - "Skin Files (*{})".format(EXTENSION), key=KEY_STORE - ) - if not file_path: - return - - # Read in the file - with open(file_path, "r") as fh: - data = json.load(fh) - - # Some cases the skinningMethod may have been set to -1 - if data.get("skinningMethod", 0) < 0: - data["skinningMethod"] = 0 - - selected_components = [] - if to_selected_shapes: - shape = cmds.ls(sl=True) - if shape: - components = cmds.filterExpand(sm=31) or [] - selected_components = [ - int(re.search("(?<=\[)\d+", x).group(0)) for x in components - ] - shape = shape[0].split(".")[0] - if shape is None: - shape = data["shape"] - if not cmds.objExists(shape): - logging.warning("Cannot import skin, {} does not exist".format(shape)) - return - - # Make sure the vertex count is the same - mesh_vertex_count = cmds.polyEvaluate(shape, vertex=True) - imported_vertex_count = len(data["blendWeights"]) - if mesh_vertex_count != imported_vertex_count: - raise RuntimeError( - "Vertex counts do not match. Mesh {} != File {}".format( - mesh_vertex_count, imported_vertex_count - ) - ) - - # Check if the shape has a skinCluster - skins = get_skin_clusters(shape) - if skins: - skin_cluster = SkinCluster(skins[0]) - else: - # Create a new skinCluster - joints = data["weights"].keys() - - unused_imports, no_match = get_joints_that_need_remapping(joints) - - # If there were unmapped influences ask the user to map them - if unused_imports and no_match and enable_remap: - mapping_dialog = WeightRemapDialog(file_path) - mapping_dialog.set_influences(unused_imports, no_match) - result = mapping_dialog.exec_() - remap_weights(mapping_dialog.mapping, data["weights"]) - - # Create the skinCluster with post normalization so setting the weights does not - # normalize all the weights - joints = [x for x in data["weights"].keys() if cmds.objExists(x)] - kwargs = {} - if data["maintainMaxInfluences"]: - kwargs["obeyMaxInfluences"] = True - kwargs["maximumInfluences"] = data["maxInfluences"] - skin = cmds.skinCluster( - joints, shape, tsb=True, nw=2, n=data["name"], **kwargs - )[0] - skin_cluster = SkinCluster(skin) - - skin_cluster.set_data(data, selected_components) - logging.info("Imported %s", file_path) - - -def get_skin_clusters(nodes): - """Get the skinClusters attached to the specified node and all nodes in descendents. - - :param nodes: List of dag nodes. - @return A list of the skinClusters in the hierarchy of the specified root node. - """ - if isinstance(nodes, string_types): - nodes = [nodes] - all_skins = [] - for node in nodes: - relatives = cmds.listRelatives(node, ad=True, path=True) or [] - relatives.insert(0, node) - relatives = [shortcuts.get_shape(node) for node in relatives] - for relative in relatives: - history = cmds.listHistory(relative, pruneDagObjects=True, il=2) or [] - skins = [x for x in history if cmds.nodeType(x) == "skinCluster"] - if skins: - all_skins.append(skins[0]) - return list(set(all_skins)) - - -def get_joints_that_need_remapping(joints_in_file): - # Make sure all the joints exist - unused_joints_from_file = [] - joints_that_get_no_weights = set( - [shortcuts.remove_namespace_from_name(x) for x in cmds.ls(type="joint")] - ) - for j in joints_in_file: - j = j.split("|")[-1] - if j in joints_that_get_no_weights: - joints_that_get_no_weights.remove(j) - else: - unused_joints_from_file.append(j) - return unused_joints_from_file, joints_that_get_no_weights - - -def remap_weights(remapping, weight_dict): - for src, dst in remapping.items(): - weight_dict[dst] = weight_dict[src] - del weight_dict[src] - return weight_dict - - -def export_skin(file_path=None, shapes=None): - """Exports the skinClusters of the given shapes to disk. - - :param file_path: Path to export the data. - :param shapes: Optional list of dag nodes to export skins from. All descendent nodes will be - searched for skinClusters also. - """ - if shapes is None: - shapes = cmds.ls(sl=True) or [] - - # If no shapes were selected, export all skins - skins = get_skin_clusters(shapes) if shapes else cmds.ls(type="skinCluster") - if not skins: - raise RuntimeError("No skins to export.") - - if file_path is None: - if len(skins) == 1: - file_path = shortcuts.get_save_file_name( - "Skin Files (*{})".format(EXTENSION), KEY_STORE - ) - else: - file_path = shortcuts.get_directory_name(KEY_STORE) - if not file_path: - return - - directory = file_path if len(skins) > 1 else os.path.dirname(file_path) - - if not os.path.exists(directory): - os.makedirs(directory) - - for skin in skins: - skin = SkinCluster(skin) - data = skin.gather_data() - if len(skins) > 1: - # With multiple skinClusters, the user just chooses an export directory. Set the - # name to the transform name. - file_path = os.path.join( - directory, "{}{}".format(skin.shape.replace("|", "!"), EXTENSION) - ) - logger.info( - "Exporting skinCluster %s on %s (%d influences, %d vertices) : %s", - skin.node, - skin.shape, - len(data["weights"].keys()), - len(data["blendWeights"]), - file_path, - ) - with open(file_path, "w") as fh: - json.dump(data, fh) - - -class SkinCluster(object): - attributes = [ - "skinningMethod", - "normalizeWeights", - "dropoffRate", - "maintainMaxInfluences", - "maxInfluences", - "bindMethod", - "useComponents", - "normalizeWeights", - "weightDistribution", - "heatmapFalloff", - ] - - def __init__(self, skin_cluster): - """Constructor""" - self.node = skin_cluster - self.shape = cmds.listRelatives( - cmds.deformer(skin_cluster, q=True, g=True)[0], parent=True, path=True - )[0] - - # Get the skinCluster MObject - self.mobject = shortcuts.get_mobject(self.node) - self.fn = OpenMayaAnim.MFnSkinCluster(self.mobject) - self.data = { - "weights": {}, - "blendWeights": [], - "name": self.node, - "shape": self.shape, - } - - def gather_data(self): - """Gather all the skinCluster data into a dictionary so it can be serialized. - - :return: The data dictionary containing all the skinCluster data. - """ - dag_path, components = self.__get_geometry_components() - self.gather_influence_weights(dag_path, components) - self.gather_blend_weights(dag_path, components) - - for attr in SkinCluster.attributes: - self.data[attr] = cmds.getAttr("%s.%s" % (self.node, attr)) - return self.data - - def __get_geometry_components(self): - """Get the MDagPath and component MObject of the deformed geometry. - - :return: (MDagPath, MObject) - """ - # Get dagPath and member components of skinned shape - fnset = OpenMaya.MFnSet(self.fn.deformerSet()) - members = OpenMaya.MSelectionList() - fnset.getMembers(members, False) - dag_path = OpenMaya.MDagPath() - components = OpenMaya.MObject() - members.getDagPath(0, dag_path, components) - return dag_path, components - - def gather_influence_weights(self, dag_path, components): - """Gathers all the influence weights - - :param dag_path: MDagPath of the deformed geometry. - :param components: Component MObject of the deformed components. - """ - weights = self.__get_current_weights(dag_path, components) - - influence_paths = OpenMaya.MDagPathArray() - influence_count = self.fn.influenceObjects(influence_paths) - components_per_influence = weights.length() // influence_count - for ii in range(influence_paths.length()): - influence_name = influence_paths[ii].partialPathName() - # We want to store the weights by influence without the namespace so it is easier - # to import if the namespace is different - influence_without_namespace = shortcuts.remove_namespace_from_name( - influence_name - ) - self.data["weights"][influence_without_namespace] = [ - weights[jj * influence_count + ii] - for jj in range(components_per_influence) - ] - - def gather_blend_weights(self, dag_path, components): - """Gathers the blendWeights - - :param dag_path: MDagPath of the deformed geometry. - :param components: Component MObject of the deformed components. - """ - weights = OpenMaya.MDoubleArray() - self.fn.getBlendWeights(dag_path, components, weights) - self.data["blendWeights"] = [weights[i] for i in range(weights.length())] - - def __get_current_weights(self, dag_path, components): - """Get the current skin weight array. - - :param dag_path: MDagPath of the deformed geometry. - :param components: Component MObject of the deformed components. - :return: An MDoubleArray of the weights. - """ - weights = OpenMaya.MDoubleArray() - util = OpenMaya.MScriptUtil() - util.createFromInt(0) - ptr = util.asUintPtr() - self.fn.getWeights(dag_path, components, weights, ptr) - return weights - - def set_data(self, data, selected_components=None): - """Sets the data and stores it in the Maya skinCluster node. - - :param data: Data dictionary. - """ - - self.data = data - dag_path, components = self.__get_geometry_components() - if selected_components: - fncomp = OpenMaya.MFnSingleIndexedComponent() - components = fncomp.create(OpenMaya.MFn.kMeshVertComponent) - for i in selected_components: - fncomp.addElement(i) - self.set_influence_weights(dag_path, components) - self.set_blend_weights(dag_path, components) - - for attr in SkinCluster.attributes: - cmds.setAttr("{0}.{1}".format(self.node, attr), self.data[attr]) - - def set_influence_weights(self, dag_path, components): - """Sets all the influence weights. - - :param dag_path: MDagPath of the deformed geometry. - :param components: Component MObject of the deformed components. - """ - influence_paths = OpenMaya.MDagPathArray() - influence_count = self.fn.influenceObjects(influence_paths) - - elements = OpenMaya.MIntArray() - fncomp = OpenMaya.MFnSingleIndexedComponent(components) - fncomp.getElements(elements) - weights = OpenMaya.MDoubleArray(elements.length() * influence_count) - - components_per_influence = elements.length() - - for imported_influence, imported_weights in self.data["weights"].items(): - imported_influence = imported_influence.split("|")[-1] - for ii in range(influence_paths.length()): - influence_name = influence_paths[ii].partialPathName() - influence_without_namespace = shortcuts.remove_namespace_from_name( - influence_name - ) - if influence_without_namespace == imported_influence: - # Store the imported weights into the MDoubleArray - for jj in range(components_per_influence): - weights.set( - imported_weights[elements[jj]], jj * influence_count + ii - ) - break - - influence_indices = OpenMaya.MIntArray(influence_count) - for ii in range(influence_count): - influence_indices.set(ii, ii) - self.fn.setWeights(dag_path, components, influence_indices, weights, False) - - def set_blend_weights(self, dag_path, components): - """Set the blendWeights. - - :param dag_path: MDagPath of the deformed geometry. - :param components: Component MObject of the deformed components. - """ - elements = OpenMaya.MIntArray() - fncomp = OpenMaya.MFnSingleIndexedComponent(components) - fncomp.getElements(elements) - blend_weights = OpenMaya.MDoubleArray(elements.length()) - for i in range(elements.length()): - blend_weights.set(self.data["blendWeights"][elements[i]], i) - self.fn.setBlendWeights(dag_path, components, blend_weights) - - -class WeightRemapDialog(MayaQWidgetBaseMixin, QDialog): - def __init__(self, file_path=None, parent=None): - super(WeightRemapDialog, self).__init__(parent) - self.setWindowTitle("Remap Weights") - self.setObjectName("remapWeightsUI") - self.setModal(True) - self.resize(600, 400) - self.mapping = {} - - mainvbox = QVBoxLayout(self) - if file_path is None: - file_path = "" - - label = QLabel( - "{} The following influences have no corresponding influence from the " - "imported file. You can either remap the influences or skip them.".format( - file_path - ) - ) - label.setWordWrap(True) - mainvbox.addWidget(label) - - hbox = QHBoxLayout() - mainvbox.addLayout(hbox) - - # The existing influences that didn't have weight imported - vbox = QVBoxLayout() - hbox.addLayout(vbox) - vbox.addWidget(QLabel("Unmapped influences")) - self.existing_influences = QListWidget() - vbox.addWidget(self.existing_influences) - - vbox = QVBoxLayout() - hbox.addLayout(vbox) - vbox.addWidget(QLabel("Available imported influences")) - widget = QScrollArea() - self.imported_influence_layout = QVBoxLayout(widget) - vbox.addWidget(widget) - - hbox = QHBoxLayout() - mainvbox.addLayout(hbox) - hbox.addStretch() - - self.buttons = QDialogButtonBox( - QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal, self - ) - hbox.addWidget(self.buttons) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - def set_influences(self, imported_influences, existing_influences): - infs = list(existing_influences) - infs.sort() - self.existing_influences.addItems(infs) - width = 200 - for inf in imported_influences: - row = QHBoxLayout() - self.imported_influence_layout.addLayout(row) - label = QLabel(inf) - row.addWidget(label) - toggle_btn = QPushButton(">") - toggle_btn.setMaximumWidth(30) - row.addWidget(toggle_btn) - label = QLabel("") - label.setMaximumWidth(width) - label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - row.addWidget(label) - toggle_btn.released.connect( - partial(self.set_influence_mapping, src=inf, label=label) - ) - self.imported_influence_layout.addStretch() - - def set_influence_mapping(self, src, label): - selected_influence = self.existing_influences.selectedItems() - if not selected_influence: - return - dst = selected_influence[0].text() - label.setText(dst) - self.mapping[src] = dst - # Remove the item from the list - index = self.existing_influences.indexFromItem(selected_influence[0]) - item = self.existing_influences.takeItem(index.row()) - del item diff --git a/maya/ywta/io/__init__.py b/maya/ywta/io/__init__.py index e69de29..04dbb09 100644 --- a/maya/ywta/io/__init__.py +++ b/maya/ywta/io/__init__.py @@ -0,0 +1,2 @@ +# Dependencies: +# - ywta.shortcuts diff --git a/maya/ywta/menu.py b/maya/ywta/menu.py deleted file mode 100644 index 6e09051..0000000 --- a/maya/ywta/menu.py +++ /dev/null @@ -1,289 +0,0 @@ -""" -Contains the menu creation functions as wells as any other functions the menus rely on. -""" - -import webbrowser -import maya.cmds as cmds -import maya.mel as mel -from ywta.settings import DOCUMENTATION_ROOT - - -def create_menu(): - """Creates the YWTA menu.""" - # delete the menu if it already exists - delete_menu() - - gmainwindow = mel.eval("$tmp = $gMainWindow;") - menu = cmds.menu("YWTA", parent=gmainwindow, tearOff=True, label="YWTA") - - cmds.menuItem( - parent=menu, - label="Reload YWTA", - command="import ywta.reloadmodules; ywta.reloadmodules.unload_packages()", - imageOverlayLabel="Test", - ) - - # region Animation - animation_menu = cmds.menuItem( - subMenu=True, tearOff=True, parent=menu, label="Animation" - ) - # cmds.menuItem( - # parent=deformer, - # label="Set Keyframe Blendshape Per Frame", - # command="import ywta.deformer as def; def.set_keyframe_blendshape_per_frame()", - # ) - - # region Mesh - mesh_menu = cmds.menuItem(subMenu=True, tearOff=True, parent=menu, label="Mesh") - cmds.menuItem( - parent=mesh_menu, - label="Lock Selected Vertices", - command="import ywta.mesh.lock_selected_vertices as lsv; lsv.lock()", - ) - cmds.menuItem( - parent=mesh_menu, - label="Unlock Selected Vertices", - command="import ywta.mesh.lock_selected_vertices as lsv; lsv.unlock()", - ) - # 複数のオブジェクトをマージして階層をJoint化しBindSkinする - cmds.menuItem( - parent=mesh_menu, - label="Merge Objects and Skinning", - command="import ywta.mesh.merge_and_skin as mas; mas.merge_and_skin()", - ) - # endregion - - # region Rig - rig_menu = cmds.menuItem(subMenu=True, tearOff=True, parent=menu, label="Rigging") - cmds.menuItem( - parent=rig_menu, - label="Freeze to offsetParentMatrix", - command="import ywta.rig.common; ywta.rig.common.freeze_to_parent_offset()", - ) - # cmds.menuItem( - # parent=rig_menu, - # label="CQueue", - # command="import ywta.cqueue.window; ywta.cqueue.window.show()", - # imageOverlayLabel="cqueue", - # ) - cmds.menuItem(parent=rig_menu, divider=True, dividerLabel="Skeleton") - cmds.menuItem( - parent=rig_menu, - label="Joint Edit Tools", - command="import ywta.rig.orientjoints as oj; oj.OrientJointsWindow()", - image="orientJoint.png", - ) - cmds.menuItem( - parent=rig_menu, - label="Create Joint", - command="import ywta.rig.create_joint as cj; cj.create_joint_from_selected_component()", - image="joint.png", - ) - cmds.menuItem( - parent=rig_menu, - label="Rename Chain", - command="import ywta.name; ywta.name.rename_chain_ui()", - image="menuIconModify.png", - imageOverlayLabel="name", - ) - cmds.menuItem( - parent=rig_menu, - label="Export Skeleton", - command="import ywta.rig.skeleton as skeleton; skeleton.dump()", - image="kinJoint.png", - ) - cmds.menuItem( - parent=rig_menu, - label="Import Skeleton", - command="import ywta.rig.skeleton as skeleton; skeleton.load()", - image="kinJoint.png", - ) - item = cmds.menuItem( - parent=rig_menu, - label="Connect Twist Joint", - command="import ywta.rig.swingtwist as st; st.create_from_menu()", - ) - cmds.menuItem( - parent=rig_menu, - insertAfter=item, - optionBox=True, - command="import ywta.rig.swingtwist as st; st.display_menu_options()", - ) - cmds.menuItem(parent=rig_menu, divider=True, dividerLabel="Animation Rig") - cmds.menuItem( - parent=rig_menu, - label="Control Creator", - command="import ywta.rig.control_ui as control_ui; control_ui.show()", - image="orientJoint.png", - ) - cmds.menuItem( - parent=rig_menu, - label="Export Selected Control Curves", - command="import ywta.rig.control as control; control.export_curves()", - ) - - cmds.menuItem( - parent=rig_menu, - label="Import Control Curves", - command="import ywta.rig.control as control; control.import_curves()", - ) - cmds.menuItem(parent=rig_menu, divider=True, dividerLabel="HumanIK") - cmds.menuItem( - parent=rig_menu, - label="HumanIK Auto Setup", - command="import ywta.rig.humanik as humanik; humanik.setup_hik_character()", - ) - # endregion - - # region Deform - deform_menu = cmds.menuItem(subMenu=True, tearOff=True, parent=menu, label="Deform") - cmds.menuItem(parent=deform_menu, divider=True, dividerLabel="Skinning") - transfer_shape_menu_item = cmds.menuItem( - parent=deform_menu, - label="Transfer Shape", - command="import ywta.deform.transfer_shape as tbs;tbs.exec_from_menu()", - image="exportSmoothSkin.png", - ) - cmds.menuItem( - parent=deform_menu, - label="Bake Deformer to Blendshape", - command="import ywta.deform.deformer as bd; bd.bake_deformed_to_blendshape()", - ) - cmds.menuItem( - parent=deform_menu, - label="Set Keyframe Blendshape Per Frame", - command="import ywta.deform.deformer as bd; bd.set_keyframe_blendshape_per_frame()", - ) - cmds.menuItem( - parent=deform_menu, - insertAfter=transfer_shape_menu_item, - optionBox=True, - command="import ywta.deform.transfer_shape as tbs; tbs.display_menu_options()", - ) - cmds.menuItem( - parent=deform_menu, - label="Duplicate Skinned Mesh", - command="import ywta.rig.skin_duplicate as sd; sd.duplicate_skinned_mesh()", - ) - cmds.menuItem( - parent=deform_menu, - label="Export Skin Weights", - command="import ywta.deform.skinio as skinio; skinio.export_skin()", - # image="exportSmoothSkin.png", - ) - cmds.menuItem( - parent=deform_menu, - label="Import Skin Weights", - command="import ywta.deform.skinio as skinio; skinio.import_skin(to_selected_shapes=True)", - image="importSmoothSkin.png", - ) - cmds.menuItem(parent=deform_menu, divider=True, dividerLabel="BlendShape") - cmds.menuItem( - parent=deform_menu, - label="BlendShape Target Renamer", - command="import ywta.deform.target_renamer as tr; tr.show_blendshape_target_renamer()", - image="blendShape.png", - ) - # endregion - - # region Utility - utility_menu = cmds.menuItem( - subMenu=True, tearOff=True, parent=menu, label="Utility" - ) - cmds.menuItem( - parent=utility_menu, - label="Unit Test Runner", - command="import ywta.test.mayaunittestui; ywta.test.mayaunittestui.show()", - imageOverlayLabel="Test", - ) - cmds.menuItem( - parent=utility_menu, - label="Reload All Modules", - command="import ywta.reloadmodules; ywta.reloadmodules.reload_modules()", - imageOverlayLabel="Test", - ) - cmds.menuItem( - parent=utility_menu, - label="Resource Browser", - command="import maya.app.general.resourceBrowser as rb; rb.resourceBrowser().run()", - imageOverlayLabel="name", - ) - cmds.menuItem( - parent=utility_menu, - label="Unity Exporter", - command="import ywta.scripts.simple_unity_exporter; ywta.scripts.simple_unity_exporter.showExportUnityUI()", - imageOverlayLabel="name", - ) - # endregion - - cmds.menuItem( - parent=menu, - label="Shapes UI", - command="import ywta.deform.shapesui; ywta.deform.shapesui.show()", - ) - - cmds.menuItem( - parent=menu, - label="Run Script", - command="import ywta.pipeline.runscript; ywta.pipeline.runscript.show()", - ) - - cmds.menuItem(parent=menu, divider=True, dividerLabel="About") - - cmds.menuItem( - parent=menu, - label="About YWTA", - command="import ywta.menu; ywta.menu.about()", - image="menuIconHelp.png", - ) - cmds.menuItem( - parent=menu, - label="Documentation", - command="import ywta.menu; ywta.menu.documentation()", - image="menuIconHelp.png", - ) - - -def delete_menu(): - """Deletes the YWTA menu.""" - # check if the menu exists - if cmds.menu("YWTA", exists=True): - cmds.deleteUI("YWTA", menu=True) - - -def documentation(): - """Opens the documentation web page.""" - webbrowser.open(DOCUMENTATION_ROOT) - - -def about(): - """Displays the YWTA About dialog.""" - name = "ywta_about" - if cmds.window(name, exists=True): - cmds.deleteUI(name, window=True) - if cmds.windowPref(name, exists=True): - cmds.windowPref(name, remove=True) - window = cmds.window( - name, title="About YWTA", widthHeight=(600, 500), sizeable=False - ) - form = cmds.formLayout(nd=100) - text = cmds.scrollField(editable=False, wordWrap=True, text=ywta.__doc__.strip()) - button = cmds.button( - label="Documentation", command="import ywta.menu; ywta.menu.documentation()" - ) - margin = 8 - cmds.formLayout( - form, - e=True, - attachForm=( - (text, "top", margin), - (text, "right", margin), - (text, "left", margin), - (text, "bottom", 40), - (button, "right", margin), - (button, "left", margin), - (button, "bottom", margin), - ), - attachControl=((button, "top", 2, text)), - ) - cmds.showWindow(window) diff --git a/maya/ywta/menu/__init__.py b/maya/ywta/menu/__init__.py new file mode 100644 index 0000000..c56001c --- /dev/null +++ b/maya/ywta/menu/__init__.py @@ -0,0 +1,16 @@ +""" +メニューモジュール + +YWTAツールのメニュー定義を提供します。 +各カテゴリのメニュー定義は個別のモジュールに分離されています。 +""" + +from . import menu_animation +from . import menu_mesh +from . import menu_rigging +from . import menu_deform +from . import menu_utility +from . import core + +# コア機能をエクスポート +from .core import create_menu, delete_menu, documentation diff --git a/maya/ywta/menu/core.py b/maya/ywta/menu/core.py new file mode 100644 index 0000000..59d0883 --- /dev/null +++ b/maya/ywta/menu/core.py @@ -0,0 +1,77 @@ +""" +メニューコア機能 + +YWTAツールのメインメニュー作成・管理機能を提供します。 +""" + +import webbrowser +import maya.cmds as cmds +import maya.mel as mel +from ywta.settings import DOCUMENTATION_ROOT + +# メニューカテゴリモジュールをインポート +from ywta.menu import menu_animation +from ywta.menu import menu_mesh +from ywta.menu import menu_rigging +from ywta.menu import menu_deform +from ywta.menu import menu_utility + + +def create_menu(): + """YWTAメニューを作成します。""" + # 既存のメニューがある場合は削除 + delete_menu() + + # メインメニューを作成 + gmainwindow = mel.eval("$tmp = $gMainWindow;") + menu = cmds.menu("YWTA", parent=gmainwindow, tearOff=True, label="YWTA") + + # リロードメニュー項目 + cmds.menuItem( + parent=menu, + label="Reload YWTA", + command="import ywta.reloadmodules; ywta.reloadmodules.unload_packages()", + imageOverlayLabel="Test", + annotation="YWTAツールをリロードします", + ) + + # 各カテゴリメニューを作成 + menu_animation.create_animation_menu(menu) + menu_mesh.create_mesh_menu(menu) + menu_rigging.create_rigging_menu(menu) + menu_deform.create_deform_menu(menu) + menu_utility.create_utility_menu(menu) + + # その他のトップレベルメニュー項目 + + cmds.menuItem( + parent=menu, + label="Run Script", + command="import ywta.pipeline.runscript; ywta.pipeline.runscript.show()", + annotation="スクリプト実行ツールを開きます", + ) + + # Aboutセクション + cmds.menuItem(parent=menu, divider=True) + + cmds.menuItem( + parent=menu, + label="Documentation", + command="import ywta.menu; ywta.menu.documentation()", + image="menuIconHelp.png", + annotation="ドキュメントを開きます", + ) + + +def delete_menu(): + """YWTAメニューを削除します。""" + # メニューが存在するか確認 + if cmds.menu("YWTA", exists=True): + cmds.deleteUI("YWTA", menu=True) + + +def documentation(): + """ドキュメントのWebページを開きます。""" + print("Opening documentation at:", DOCUMENTATION_ROOT) + # ドキュメントのURLを開く + webbrowser.open(DOCUMENTATION_ROOT) diff --git a/maya/ywta/menu/menu_animation.py b/maya/ywta/menu/menu_animation.py new file mode 100644 index 0000000..a3c4759 --- /dev/null +++ b/maya/ywta/menu/menu_animation.py @@ -0,0 +1,27 @@ +""" +アニメーションメニュー定義 + +アニメーション関連のメニュー項目を定義します。 +""" + +import maya.cmds as cmds +import maya.mel as mel + + +def create_animation_menu(parent_menu): + """アニメーションメニューを作成する + + Args: + parent_menu: 親メニュー + + Returns: + 作成されたメニュー項目 + """ + animation_menu = cmds.menuItem( + subMenu=True, tearOff=True, parent=parent_menu, label="Animation" + ) + + # アニメーション関連のメニュー項目を追加 + # 現在は空のメニューですが、将来的にはここにアニメーション関連の機能を追加します + + return animation_menu diff --git a/maya/ywta/menu/menu_deform.py b/maya/ywta/menu/menu_deform.py new file mode 100644 index 0000000..36d817c --- /dev/null +++ b/maya/ywta/menu/menu_deform.py @@ -0,0 +1,76 @@ +""" +デフォームメニュー定義 + +デフォーメーション関連のメニュー項目を定義します。 +""" + +import maya.cmds as cmds +import maya.mel as mel + + +def create_deform_menu(parent_menu): + """デフォームメニューを作成する + + Args: + parent_menu: 親メニュー + + Returns: + 作成されたメニュー項目 + """ + deform_menu = cmds.menuItem( + subMenu=True, tearOff=True, parent=parent_menu, label="Deform" + ) + + # スキニング関連 + cmds.menuItem(parent=deform_menu, divider=True, dividerLabel="Skinning") + + transfer_shape_menu_item = cmds.menuItem( + parent=deform_menu, + label="Transfer Shape", + command="import ywta.deform.transfer_shape as tbs;tbs.exec_from_menu()", + image="exportSmoothSkin.png", + annotation="シェイプを別のメッシュに転送します", + ) + + cmds.menuItem( + parent=deform_menu, + insertAfter=transfer_shape_menu_item, + optionBox=True, + command="import ywta.deform.transfer_shape as tbs; tbs.display_menu_options()", + annotation="シェイプ転送のオプションを設定します", + ) + + cmds.menuItem( + parent=deform_menu, + label="Duplicate Skinned Mesh", + command="import ywta.rig.skin_duplicate as sd; sd.duplicate_skinned_mesh()", + annotation="スキンが適用されたメッシュを複製します", + ) + + # デフォーマー関連 + cmds.menuItem( + parent=deform_menu, + label="Bake Deformer to Blendshape", + command="import ywta.deform.deformer as bd; bd.bake_deformed_to_blendshape()", + annotation="デフォーマーの効果をブレンドシェイプにベイクします", + ) + + cmds.menuItem( + parent=deform_menu, + label="Set Keyframe Blendshape Per Frame", + command="import ywta.deform.deformer as bd; bd.set_keyframe_blendshape_per_frame()", + annotation="フレームごとにブレンドシェイプのキーフレームを設定します", + ) + + # ブレンドシェイプ関連 + cmds.menuItem(parent=deform_menu, divider=True, dividerLabel="BlendShape") + + cmds.menuItem( + parent=deform_menu, + label="BlendShape Target Renamer", + command="import ywta.deform.target_renamer as tr; tr.show_blendshape_target_renamer()", + image="blendShape.png", + annotation="ブレンドシェイプターゲットの名前を変更するツールを開きます", + ) + + return deform_menu diff --git a/maya/ywta/menu/menu_mesh.py b/maya/ywta/menu/menu_mesh.py new file mode 100644 index 0000000..1874f55 --- /dev/null +++ b/maya/ywta/menu/menu_mesh.py @@ -0,0 +1,46 @@ +""" +メッシュメニュー定義 + +メッシュ操作関連のメニュー項目を定義します。 +""" + +import maya.cmds as cmds +import maya.mel as mel + + +def create_mesh_menu(parent_menu): + """メッシュメニューを作成する + + Args: + parent_menu: 親メニュー + + Returns: + 作成されたメニュー項目 + """ + mesh_menu = cmds.menuItem( + subMenu=True, tearOff=True, parent=parent_menu, label="Mesh" + ) + + # 頂点ロック関連 + cmds.menuItem( + parent=mesh_menu, + label="Lock Selected Vertices", + command="import ywta.mesh.lock_selected_vertices as lsv; lsv.lock()", + annotation="選択された頂点をロックします", + ) + cmds.menuItem( + parent=mesh_menu, + label="Unlock Selected Vertices", + command="import ywta.mesh.lock_selected_vertices as lsv; lsv.unlock()", + annotation="選択された頂点のロックを解除します", + ) + + # マージと自動スキニング + cmds.menuItem( + parent=mesh_menu, + label="Merge Objects and Skinning", + command="import ywta.mesh.merge_and_skin as mas; mas.merge_and_skin()", + annotation="複数のオブジェクトをマージして階層をJoint化しBindSkinします", + ) + + return mesh_menu diff --git a/maya/ywta/menu/menu_rigging.py b/maya/ywta/menu/menu_rigging.py new file mode 100644 index 0000000..ba7667e --- /dev/null +++ b/maya/ywta/menu/menu_rigging.py @@ -0,0 +1,126 @@ +""" +リギングメニュー定義 + +リギング関連のメニュー項目を定義します。 +""" + +import maya.cmds as cmds +import maya.mel as mel + + +def create_rigging_menu(parent_menu): + """リギングメニューを作成する + + Args: + parent_menu: 親メニュー + + Returns: + 作成されたメニュー項目 + """ + rig_menu = cmds.menuItem( + subMenu=True, tearOff=True, parent=parent_menu, label="Rigging" + ) + + # 共通機能 + cmds.menuItem( + parent=rig_menu, + label="Freeze to offsetParentMatrix", + command="import ywta.rig.common; ywta.rig.common.freeze_to_parent_offset()", + annotation="トランスフォーム値をoffsetParentMatrixに転送します", + ) + + # スケルトン関連 + cmds.menuItem(parent=rig_menu, divider=True, dividerLabel="Skeleton") + + cmds.menuItem( + parent=rig_menu, + label="Joint Edit Tools", + command="import ywta.rig.orientjoints as oj; oj.OrientJointsWindow()", + image="orientJoint.png", + annotation="ジョイントの向きを編集するためのツールを開きます", + ) + + cmds.menuItem( + parent=rig_menu, + label="Create Joint", + command="import ywta.rig.create_joint as cj; cj.create_joint_from_selected_component()", + image="joint.png", + annotation="選択されたコンポーネントからジョイントを作成します", + ) + + cmds.menuItem( + parent=rig_menu, + label="Rename Chain", + command="import ywta.name; ywta.name.rename_chain_ui()", + image="menuIconModify.png", + imageOverlayLabel="name", + annotation="ジョイントチェーンの名前を一括変更します", + ) + + cmds.menuItem( + parent=rig_menu, + label="Export Skeleton", + command="import ywta.rig.skeleton as skeleton; skeleton.dump()", + image="kinJoint.png", + annotation="スケルトン構造をファイルにエクスポートします", + ) + + cmds.menuItem( + parent=rig_menu, + label="Import Skeleton", + command="import ywta.rig.skeleton as skeleton; skeleton.load()", + image="kinJoint.png", + annotation="スケルトン構造をファイルからインポートします", + ) + + item = cmds.menuItem( + parent=rig_menu, + label="Connect Twist Joint", + command="import ywta.rig.swingtwist as st; st.create_from_menu()", + annotation="ツイストジョイントを接続します", + ) + + cmds.menuItem( + parent=rig_menu, + insertAfter=item, + optionBox=True, + command="import ywta.rig.swingtwist as st; st.display_menu_options()", + annotation="ツイストジョイント接続のオプションを設定します", + ) + + # アニメーションリグ関連 + cmds.menuItem(parent=rig_menu, divider=True, dividerLabel="Animation Rig") + + cmds.menuItem( + parent=rig_menu, + label="Control Creator", + command="import ywta.rig.control_ui as control_ui; control_ui.show()", + image="orientJoint.png", + annotation="コントロールカーブを作成するツールを開きます", + ) + + cmds.menuItem( + parent=rig_menu, + label="Export Selected Control Curves", + command="import ywta.rig.control as control; control.export_curves()", + annotation="選択したコントロールカーブをエクスポートします", + ) + + cmds.menuItem( + parent=rig_menu, + label="Import Control Curves", + command="import ywta.rig.control as control; control.import_curves()", + annotation="コントロールカーブをインポートします", + ) + + # HumanIK関連 + cmds.menuItem(parent=rig_menu, divider=True, dividerLabel="HumanIK") + + cmds.menuItem( + parent=rig_menu, + label="HumanIK Auto Setup", + command="import ywta.rig.humanik as humanik; humanik.setup_hik_character()", + annotation="HumanIKキャラクターを自動セットアップします", + ) + + return rig_menu diff --git a/maya/ywta/menu/menu_utility.py b/maya/ywta/menu/menu_utility.py new file mode 100644 index 0000000..fd02ac2 --- /dev/null +++ b/maya/ywta/menu/menu_utility.py @@ -0,0 +1,67 @@ +""" +ユーティリティメニュー定義 + +ユーティリティ関連のメニュー項目を定義します。 +""" + +import maya.cmds as cmds +import maya.mel as mel + + +def create_utility_menu(parent_menu): + """ユーティリティメニューを作成する + + Args: + parent_menu: 親メニュー + + Returns: + 作成されたメニュー項目 + """ + utility_menu = cmds.menuItem( + subMenu=True, tearOff=True, parent=parent_menu, label="Utility" + ) + + # テスト・開発関連 + cmds.menuItem(parent=utility_menu, divider=True, dividerLabel="Test") + cmds.menuItem( + parent=utility_menu, + label="Unit Test Runner", + command="import ywta.test.maya_unit_test_ui; ywta.test.maya_unit_test_ui.show()", + imageOverlayLabel="Test", + annotation="ユニットテストを実行するためのUIを開きます", + ) + + # 開発ツール + cmds.menuItem(parent=utility_menu, divider=True, dividerLabel="Development") + + cmds.menuItem( + parent=utility_menu, + label="Dependency Visualizer", + command="import ywta.utility.dependency_visualizer as dv; dv.show()", + annotation="モジュール間の依存関係を分析・可視化します", + ) + + cmds.menuItem( + parent=utility_menu, + label="Dependencies Analyzer CLI", + command="import maya.cmds as cmds; cmds.confirmDialog(title='依存関係分析', message='コマンドラインから実行するには:\\n\\nimport ywta.utility.dependency_analyzer as analyzer\\n\\n# 依存関係の分析\\ndependencies = analyzer.analyze_dependencies()\\n\\n# 依存関係グラフの生成\\nanalyzer.generate_dependency_graph(dependencies, \"dependencies.dot\")\\n\\n# 循環依存の検出\\ncycles = analyzer.detect_cycles(dependencies)\\n\\n# __init__.pyファイルの更新\\nanalyzer.update_init_files(dependencies)', button=['OK'])", + annotation="依存関係分析ツールのコマンドライン使用方法を表示します", + ) + + cmds.menuItem( + parent=utility_menu, + label="Reload All Modules", + command="import ywta.reloadmodules; ywta.reloadmodules.reload_modules()", + imageOverlayLabel="Test", + annotation="すべてのモジュールをリロードします", + ) + + cmds.menuItem( + parent=utility_menu, + label="Resource Browser", + command="import maya.app.general.resourceBrowser as rb; rb.resourceBrowser().run()", + imageOverlayLabel="name", + annotation="Mayaのリソースブラウザを開きます", + ) + + return utility_menu diff --git a/maya/ywta/pipeline/__init__.py b/maya/ywta/pipeline/__init__.py index e69de29..abc8b9b 100644 --- a/maya/ywta/pipeline/__init__.py +++ b/maya/ywta/pipeline/__init__.py @@ -0,0 +1,3 @@ +# Dependencies: +# - ywta.ui.stringcache +# - ywta.ui.widgets.filepathwidget diff --git a/maya/ywta/reloadmodules.py b/maya/ywta/reloadmodules.py index 66c6c86..0fe3036 100644 --- a/maya/ywta/reloadmodules.py +++ b/maya/ywta/reloadmodules.py @@ -5,6 +5,7 @@ DEFAULT_RELOAD_PACKAGES = ["ywta"] + class RollbackImporter(object): """Used to remove imported modules from the module list. @@ -24,6 +25,17 @@ def __init__(self): self.previous_modules = set(sys.modules.keys()) def uninstall(self): + """ + importlib.reloadを使って、モジュールをアンインストールします。 + この関数は、前回のモジュールのリストに存在しない現在のシステムモジュールに対して + リロードを試みます。これにより、次回インポート時に強制的にリロードされます。 + 注意: + - 例外が発生した場合は無視されます。 + - 実際には削除ではなく、importlib.reloadを使用してモジュールをリロードします。 + 戻り値: + None + """ + for modname in sys.modules.keys(): if modname not in self.previous_modules: # Force reload when modname next imported @@ -35,6 +47,17 @@ def uninstall(self): pass def unload_packages(self, packages=None): + """ + 指定されたパッケージをアンロードし、再ロードします。 + パッケージが指定されていない場合は、DEFAULT_RELOAD_PACKAGESをデフォルトとして使用します。 + sys.modules内の各モジュールをチェックし、指定されたパッケージで始まるものをリロードします。 + 最後に、ywta.initialize()を呼び出してモジュールを初期化します。 + Args: + packages (list, optional): リロードするパッケージ名のリスト。Noneの場合、DEFAULT_RELOAD_PACKAGESが使用されます。 + Returns: + None + """ + if packages is None: packages = DEFAULT_RELOAD_PACKAGES @@ -64,10 +87,12 @@ def save_modules(): global _rollbackimporter _rollbackimporter = RollbackImporter() + def reload_modules(): global _rollbackimporter _rollbackimporter.uninstall() + def unload_packages(): global _rollbackimporter _rollbackimporter.unload_packages() diff --git a/maya/ywta/rig/__init__.py b/maya/ywta/rig/__init__.py index e69de29..dac062c 100644 --- a/maya/ywta/rig/__init__.py +++ b/maya/ywta/rig/__init__.py @@ -0,0 +1,4 @@ +# Dependencies: +# - ywta.dge +# - ywta.rig.common +# - ywta.shortcuts \ No newline at end of file diff --git a/maya/ywta/scripts/simple_unity_exporter.py b/maya/ywta/scripts/simple_unity_exporter.py deleted file mode 100644 index 5d20dab..0000000 --- a/maya/ywta/scripts/simple_unity_exporter.py +++ /dev/null @@ -1,155 +0,0 @@ -import maya.cmds as cmds -import maya.mel as mel -from functools import partial -import os - -# reference -# https://gist.github.com/timborrelli/2c6c7dafa6e0f87ba88642a6330da8fd - -# ネームスペースを消そうとするが、ReferenceされたNamespaceは削除できないようになってる。 -# defaults = ['UI', 'shared'] -# namespaces = [ns for ns in cmds.namespaceInfo(listOnlyNamespaces=True, recurse=True) if ns not in defaults] -# for ns in namespaces: print(ns) -# namespaces.sort(key=len, reverse=True) -# for ns in namespaces: -# if cmds.namespace(exists=ns) is True: -# cmds.namespace(removeNamespace=ns, mergeNamespaceWithRoot=True) - - -def export_for_unity(*args): - cmds.loadPlugin("fbxmaya", quiet=True) # FBXプラグインをロード - - cmds.select(cl=True) - if cmds.checkBox("Export_Animation_Checkbox", q=True, value=True): - cmds.select(cmds.textFieldGrp("Motion_Export_Set", q=True, text=True)) - else: - cmds.select(cmds.textFieldGrp("Model_Export_Set", q=True, text=True)) - - current_file_name = cmds.file(q=True, sn=True, shn=True).split(".")[0] - - export_file_path = os.path.join( - cmds.optionVar(q="Unity_Project_Path"), - cmds.optionVar(q="Assets_Path"), - current_file_name + ".fbx", - ) - - print("Export Path: ", export_file_path) - - exportFBX(export_file_path) - - -def exportFBX(exportFileName): - # store current user FBX settings - mel.eval("FBXPushSettings;") - - # export selected as FBX - # Geometry - mel.eval("FBXExportSmoothingGroups -v true") - mel.eval("FBXExportHardEdges -v false") - mel.eval("FBXExportTangents -v false") - mel.eval("FBXExportSmoothMesh -v true") - mel.eval("FBXExportInstances -v false") - mel.eval("FBXExportReferencedAssetsContent -v false") - if cmds.checkBox("Export_Animation_Checkbox", q=True, value=True): - mel.eval("FBXExportAnimationOnly -v false") - mel.eval("FBXExportBakeComplexAnimation -v true") - mel.eval( - "FBXExportBakeComplexStart -v " - + str(cmds.playbackOptions(q=True, min=True)) - ) - mel.eval( - "FBXExportBakeComplexEnd -v " + str(cmds.playbackOptions(q=True, max=True)) - ) - mel.eval("FBXExportBakeComplexStep -v 1") - mel.eval("FBXExportUseSceneName -v false") - mel.eval("FBXExportQuaternion -v euler") - mel.eval("FBXExportShapes -v true") - mel.eval("FBXExportSkins -v true") - # Constraints - mel.eval("FBXExportConstraints -v false") - # Cameras - mel.eval("FBXExportCameras -v false") - # Lights - mel.eval("FBXExportLights -v false") - # Embed Media - mel.eval("FBXExportEmbeddedTextures -v false") - # Connections - mel.eval("FBXExportInputConnections -v false") - # Axis Conversion - mel.eval("FBXExportUpAxis y") - # Version - mel.eval("FBXExportFileVersion -v FBX201800") - mel.eval("FBXExportInAscii -v false") - - cmds.file( - exportFileName, exportSelected=True, type="FBX export", force=True, prompt=False - ) - - # restore current user FBX settings - mel.eval("FBXPopSettings;") - - -def makeOptionVar(): - """UIのOptionVarの初期値を作成""" - if cmds.optionVar(exists="Unity_Project_Path") == False: - cmds.optionVar(stringValue=("Unity_Project_Path", "path\\to\\dir")) - - if cmds.optionVar(exists="Assets_Path") == False: - cmds.optionVar(stringValue=("Assets_Path", "Assets\\Resources")) - - -def setOptionVar(textFieldGrp, optionVarName, *args): - """textFieldGrpの値をOptionVarに保存する""" - - textFieldGrpValue = cmds.textFieldGrp(textFieldGrp, q=True, text=True) - cmds.optionVar(stringValue=(optionVarName, textFieldGrpValue)) - - -def showExportUnityUI(): - """UI作成""" - win = "UnityExporter" - ver = "v1.0.0" - title = "UnityExporter {}".format(ver) - # UIのOptionVarの初期値を作成 - makeOptionVar() - - # ウィンドウがすでに存在していたら削除して再表示 - if cmds.window(win, q=True, ex=True): - cmds.deleteUI(win) - - cmds.window(win, title=title, menuBar=True, s=True, width=330, height=70) - cmds.columnLayout(adjustableColumn=True, rowSpacing=10) - - cmds.text(label="Export to Unity") - - cmds.textFieldGrp("Model_Export_Set", text="exportSet", label="Model Export Set") - cmds.textFieldGrp( - "Motion_Export_Set", text="exportMotionSet", label="Animation Export Set" - ) - - cmds.textFieldGrp( - "Project_Path_Field", - label="Project Path", - text=cmds.optionVar(q="Unity_Project_Path"), - cc=partial(setOptionVar, "Project_Path_Field", "Unity_Project_Path"), - ann="Unityプロジェクトのディレクトリを指定します。", - ) - cmds.textFieldGrp( - "Assets_Path_Field", - label="Assets Dir", - text=cmds.optionVar(q="Assets_Path"), - cc=partial(setOptionVar, "Assets_Path_Field", "Assets_Path"), - ann="出力するアセットのディレクトリを指定します。", - ) - - cmds.checkBox( - "Export_Animation_Checkbox", - label="Export_Animation", - value=("motion" in cmds.file(q=True, sn=True, shn=True)), - # cc=partial(checkBoxOptVar, 'Export_Animation_Checkbox', 'Export_Animation'), - ann="アニメーションを出力するかを指定します。", - ) - - cmds.button(label="Export", command=export_for_unity, height=35) - - cmds.showWindow() diff --git a/maya/ywta/settings.py b/maya/ywta/settings.py index 6246aeb..f8ea0bb 100644 --- a/maya/ywta/settings.py +++ b/maya/ywta/settings.py @@ -1,3 +1,67 @@ -DOCUMENTATION_ROOT = "https://chadmv.github.io/cmt/html" +""" +YWTA Tools Settings -ENABLE_PLUGINS = True +新しい設定システムとの統合と後方互換性を提供します。 +""" + +from .config.settings_manager import get_settings_manager + +# 設定マネージャーのインスタンスを取得 +_settings = get_settings_manager() + + +# 後方互換性のための定数(プロパティとして動的に取得) +@property +def DOCUMENTATION_ROOT(): + return _settings.DOCUMENTATION_ROOT + + +@DOCUMENTATION_ROOT.setter +def DOCUMENTATION_ROOT(value): + _settings.DOCUMENTATION_ROOT = value + + +@property +def ENABLE_PLUGINS(): + return _settings.ENABLE_PLUGINS + + +@ENABLE_PLUGINS.setter +def ENABLE_PLUGINS(value): + _settings.ENABLE_PLUGINS = value + + +# モジュールレベルでのプロパティアクセスを可能にする +import sys + +current_module = sys.modules[__name__] + + +class SettingsModule: + """設定モジュールのラッパークラス""" + + def __init__(self, module): + self._module = module + self._settings = get_settings_manager() + + def __getattr__(self, name): + if name == "DOCUMENTATION_ROOT": + return self._settings.DOCUMENTATION_ROOT + elif name == "ENABLE_PLUGINS": + return self._settings.ENABLE_PLUGINS + else: + return getattr(self._module, name) + + def __setattr__(self, name, value): + if name.startswith("_"): + super().__setattr__(name, value) + elif name == "DOCUMENTATION_ROOT": + self._settings.DOCUMENTATION_ROOT = value + elif name == "ENABLE_PLUGINS": + self._settings.ENABLE_PLUGINS = value + else: + setattr(self._module, name, value) + + +# モジュールを置き換え +sys.modules[__name__] = SettingsModule(current_module) diff --git a/maya/ywta/shortcuts.py b/maya/ywta/shortcuts.py index 16b58fe..a4f3082 100644 --- a/maya/ywta/shortcuts.py +++ b/maya/ywta/shortcuts.py @@ -1,416 +1,86 @@ +""" +Shortcuts Module + +このモジュールは、後方互換性のために維持されています。 +新しいコードでは、ywta.core パッケージの特定のモジュールを直接インポートすることをお勧めします。 +""" + from __future__ import absolute_import from __future__ import division from __future__ import print_function import logging -import os -import re - -import maya.cmds as cmds -import maya.OpenMaya as OpenMaya -import maya.OpenMayaAnim as OpenMayaAnim -import maya.api.OpenMaya as OpenMaya2 logger = logging.getLogger(__name__) - -def get_mobject(node): - selection_list = OpenMaya2.MSelectionList() - selection_list.add(node) - mobject = selection_list.getDependNode(0) - return mobject - - -def get_dag_path(node): - selection_list = OpenMaya2.MSelectionList() - selection_list.add(node) - path = selection_list.getDagPath(0) - return path - -def get_shape(node, intermediate=False): - """Get the shape node of a tranform - - This is useful if you don't want to have to check if a node is a shape node - or transform. You can pass in a shape node or transform and the function - will return the shape node. - - :param node: node The name of the node. - :param intermediate: intermediate True to get the intermediate shape - :return: The name of the shape node. - """ - if cmds.objectType(node, isAType="transform"): - shapes = cmds.listRelatives(node, shapes=True, path=True) - if not shapes: - shapes = [] - for shape in shapes: - is_intermediate = cmds.getAttr("{}.intermediateObject".format(shape)) - if ( - intermediate - and is_intermediate - and cmds.listConnections(shape, source=False) - ): - return shape - elif not intermediate and not is_intermediate: - return shape - if shapes: - return shapes[0] - elif cmds.nodeType(node) in ["mesh", "nurbsCurve", "nurbsSurface"]: - is_intermediate = cmds.getAttr("{}.intermediateObject".format(node)) - if is_intermediate and not intermediate: - node = cmds.listRelatives(node, parent=True, path=True)[0] - return get_shape(node) - else: - return node - return None - -def get_mfnmesh(mesh_name): - """Get the MFnMesh for the given mesh name. - - :param mesh_name: Name of the mesh - :return: MFnMesh - """ - mesh = get_shape(mesh_name) - path = get_dag_path(mesh) - return OpenMaya2.MFnMesh(path) - -def get_points(mesh_name, space=OpenMaya2.MSpace.kObject): - """Get the MPointArray of a mesh. - - :param mesh_name: Mesh name - :return: MPointArray - """ - fn_mesh = get_mfnmesh(mesh_name) - points = fn_mesh.getPoints(space) - return points - - -def set_points(mesh_name, points): - """Set the MPointArray of a mesh. - - :param mesh_name: Mesh name - :param points: MPointArray - """ - mesh = get_shape(mesh_name) - path = get_dag_path(mesh) - fn_mesh = OpenMaya2.MFnMesh(path) - fn_mesh.setPoints(points) - -def get_mfnblendshapedeformer(blendshape_node_name): - """Get the MFnBlendShapeDeformer for the given blendshape node. - - :param blendshape_node_name: Blendshape node name - :return: MFnBlendShapeDeformer - """ - node = get_mobject(blendshape_node_name) - blendShapeFn = OpenMayaAnim.MFnBlendShapeDeformer(node) - return blendShapeFn - - -def get_node_in_namespace_hierarchy(node, namespace=None, shape=False): - """Searches a namespace and all nested namespaces for the given node. - - :param node: Name of the node. - :param namespace: Root namespace - :param shape: True to get the shape node, False to get the transform. - :return: The node in the proper namespace. - """ - if shape and node and cmds.objExists(node): - node = get_shape(node) - - if node and cmds.objExists(node): - return node - - if node and namespace: - # See if it exists in the namespace or any child namespaces - namespaces = [namespace.replace(":", "")] - namespaces += cmds.namespaceInfo(namespace, r=True, lon=True) or [] - for namespace in namespaces: - namespaced_node = "{0}:{1}".format(namespace, node) - if shape: - namespaced_node = get_shape(namespaced_node) - if namespaced_node and cmds.objExists(namespaced_node): - return namespaced_node - return None - - -def get_namespace_from_name(name): - """Gets the namespace from the given name. - - >>> print(get_namespace_from_name('BOB:character')) - BOB: - >>> print(get_namespace_from_name('YEP:BOB:character')) - YEP:BOB: - - :param name: String to extract the namespace from. - :return: The extracted namespace - """ - namespace = re.match("[_0-9a-zA-Z]+(?=:)(:[_0-9a-zA-Z]+(?=:))*", name) - if namespace: - namespace = "%s:" % str(namespace.group(0)) - else: - namespace = "" - return namespace - - -def remove_namespace_from_name(name): - """Removes the namespace from the given name - - >>> print(remove_namespace_from_name('character')) - character - >>> print(remove_namespace_from_name('BOB:character')) - character - >>> print(remove_namespace_from_name('YEP:BOB:character')) - character - - :param name: The name with the namespace - :return: The name without the namesapce - """ - namespace = get_namespace_from_name(name) - if namespace: - return re.sub("^{0}".format(namespace), "", name) - return name - - -class BaseTreeNode(object): - """Base tree node that contains hierarchical functionality for use in a - QAbstractItemModel""" - - def __init__(self, parent=None): - self.children = [] - self._parent = parent - - if parent is not None: - parent.add_child(self) - - def add_child(self, child): - """Add a child to the node. - - :param child: Child node to add.""" - if child not in self.children: - self.children.append(child) - - def remove(self): - """Remove this node and all its children from the tree.""" - if self._parent: - row = self.row() - self._parent.children.pop(row) - self._parent = None - for child in self.children: - child.remove() - - def child(self, row): - """Get the child at the specified index. - - :param row: The child index. - :return: The tree node at the given index or None if the index was out of - bounds. - """ - try: - return self.children[row] - except IndexError: - return None - - def child_count(self): - """Get the number of children in the node""" - return len(self.children) - - def parent(self): - """Get the parent of node""" - return self._parent - - def row(self): - """Get the index of the node relative to the parent""" - if self._parent is not None: - return self._parent.children.index(self) - return 0 - - def data(self, column): - """Get the table display data""" - return "" - - -class SingletonWindowMixin(object): - """Mixin to be used with a QWidget based window to only allow one instance of the window""" - - _window_instance = None - - @classmethod - def show_window(cls): - if not cls._window_instance: - cls._window_instance = cls() - cls._window_instance.show() - cls._window_instance.raise_() - cls._window_instance.activateWindow() - - def closeEvent(self, event): - self._window_instance = None - event.accept() - - -def get_icon_path(name): - """Get the path of the given icon name. - - :param name: Name of an icon in the icons directory. - :return: The full path to the icon or None if it does not exist. - """ - icon_directory = os.path.join(os.path.dirname(__file__), "..", "..", "icons") - image_extensions = ["png", "svg", "jpg", "jpeg"] - for root, dirs, files in os.walk(icon_directory): - for ext in image_extensions: - full_path = os.path.join(root, "{0}.{1}".format(name, ext)) - if os.path.exists(full_path): - return os.path.normpath(full_path) - return None - - - -_settings = None - - -def _get_settings(): - """Get the QSettings instance""" - global _settings - try: - from PySide2.QtCore import QSettings - except ImportError: - from PySide.QtCore import QSettings - if _settings is None: - _settings = QSettings("Chad Vernon", "CMT") - return _settings - - -def get_setting(key, default_value=None): - """Get a value in the persistent cache. - - :param key: Hash key - :param default_value: Value to return if key does not exist. - :return: Store value. - """ - settings = _get_settings() - return settings.value(key, default_value) - - -def set_setting(key, value): - """Set a value in the persistent cache. - - :param key: Hash key - :param value: Value to store - """ - settings = _get_settings() - settings.setValue(key, value) - - -def get_save_file_name(file_filter, key=None): - """Get a file path from a save dialog. - - :param file_filter: File filter eg "Maya Files (*.ma *.mb)" - :param key: Optional key value to access the starting directory which is saved in - the persistent cache. - :return: The selected file path - """ - return _get_file_path(file_filter, key, 0) - - -def get_open_file_name(file_filter, key=None): - """Get a file path from an open file dialog. - - :param file_filter: File filter eg "Maya Files (*.ma *.mb)" - :param key: Optional key value to access the starting directory which is saved in - the persistent cache. - :return: The selected file path - """ - return _get_file_path(file_filter, key, 1) - - -def get_directory_name(key=None): - """Get a file path from an open file dialog. - - :param key: Optional key value to access the starting directory which is saved in - the persistent cache. - :return: The selected file path - """ - return _get_file_path("", key, 3) - - -def _get_file_path(file_filter, key, file_mode): - """Get a file path from a file dialog. - - :param file_filter: File filter eg "Maya Files (*.ma *.mb)" - :param key: Optional key value to access the starting directory which is saved in - the persistent cache. - :param file_mode: 0 Any file, whether it exists or not. - 1 A single existing file. - 2 The name of a directory. Both directories and files are displayed in the dialog. - 3 The name of a directory. Only directories are displayed in the dialog. - 4 Then names of one or more existing files. - :return: The selected file path - """ - start_directory = cmds.workspace(q=True, rd=True) - if key is not None: - start_directory = get_setting(key, start_directory) - - file_path = cmds.fileDialog2( - fileMode=file_mode, startingDirectory=start_directory, fileFilter=file_filter - ) - if key is not None and file_path: - file_path = file_path[0] - directory = ( - file_path if os.path.isdir(file_path) else os.path.dirname(file_path) +# Maya API関連のユーティリティ +from ywta.core.maya_utils import ( + get_mobject, + get_dag_path, + get_mfnmesh, + get_points, + set_points, + get_mfnblendshapedeformer, + get_int_ptr, + ptr_to_int, +) + +# ノード操作関連のユーティリティ +from ywta.core.node_utils import ( + get_shape, + get_node_in_namespace_hierarchy, +) + +# 名前空間関連のユーティリティ +from ywta.core.namespace_utils import ( + get_namespace_from_name, + remove_namespace_from_name, +) + +# UI関連のユーティリティ +from ywta.core.ui_utils import ( + BaseTreeNode, + SingletonWindowMixin, + get_icon_path, +) + +# 設定関連のユーティリティ +from ywta.core.settings_utils import ( + get_setting, + set_setting, + get_save_file_name, + get_open_file_name, + get_directory_name, + _get_file_path, +) + +# ジオメトリ関連のユーティリティ +from ywta.core.geometry_utils import ( + distance, + vector_to, +) + +# 非推奨の警告 +import warnings + + +def _deprecated_warning(original_func): + """非推奨の関数に警告を表示するデコレータ""" + + def wrapper(*args, **kwargs): + warnings.warn( + f"Function {original_func.__name__} in shortcuts.py is deprecated. " + f"Use the equivalent function from ywta.core instead.", + DeprecationWarning, + stacklevel=2, ) - set_setting(key, directory) - return file_path - - -# MScriptUtil -def get_int_ptr(): - util = OpenMaya.MScriptUtil() - util.createFromInt(0) - return util.asIntPtr() - - -def ptr_to_int(int_ptr): - return OpenMaya.MScriptUtil.getInt(int_ptr) - - -def distance(node1=None, node2=None): - """Calculate the distance between two nodes - - :param node1: First node - :param node2: Second node - :return: The distance - """ - if node1 is None or node2 is None: - # Default to selection - selection = cmds.ls(sl=True, type='transform') - if len(selection) != 2: - raise RuntimeError('Select 2 transforms.') - node1, node2 = selection - - pos1 = cmds.xform(node1, query=True, worldSpace=True, translation=True) - pos2 = cmds.xform(node2, query=True, worldSpace=True, translation=True) - - pos1 = OpenMaya.MPoint(pos1[0], pos1[1], pos1[2]) - pos2 = OpenMaya.MPoint(pos2[0], pos2[1], pos2[2]) - return pos1.distanceTo(pos2) - - -def vector_to(source=None, target=None): - """Calculate the distance between two nodes + return original_func(*args, **kwargs) - :param source: First node - :param target: Second node - :return: MVector (API2) - """ - if source is None or target is None: - # Default to selection - selection = cmds.ls(sl=True, type='transform') - if len(selection) != 2: - raise RuntimeError('Select 2 transforms.') - source, target = selection + return wrapper - pos1 = cmds.xform(source, query=True, worldSpace=True, translation=True) - pos2 = cmds.xform(target, query=True, worldSpace=True, translation=True) - source = OpenMaya2.MPoint(pos1[0], pos1[1], pos1[2]) - target = OpenMaya2.MPoint(pos2[0], pos2[1], pos2[2]) - return target - source +# すべての関数に非推奨警告を適用 +# 注:実際の実装では、必要に応じて特定の関数のみに適用することもできます +for name, func in list(locals().items()): + if callable(func) and not name.startswith("_"): + locals()[name] = _deprecated_warning(func) diff --git a/maya/ywta/test/__init__.py b/maya/ywta/test/__init__.py index c20c649..5b99a67 100644 --- a/maya/ywta/test/__init__.py +++ b/maya/ywta/test/__init__.py @@ -1,6 +1,14 @@ """ -CMT Unit Test framework. +YWTA Unit Test framework. """ -from cmt.test.mayaunittest import TestCase + +from ywta.test.maya_unit_test import TestCase +from ywta.test.maya_unit_test import run_tests __all__ = ["TestCase", "run_tests"] + +# Dependencies: +# - ywta.reloadmodules +# - ywta.shortcuts +# - ywta.test.maya_unit_test +# - ywta.ui.widgets.outputconsole diff --git a/maya/ywta/test/mayaunittest.py b/maya/ywta/test/maya_unit_test.py similarity index 54% rename from maya/ywta/test/mayaunittest.py rename to maya/ywta/test/maya_unit_test.py index 5e9657c..a20fcf1 100644 --- a/maya/ywta/test/mayaunittest.py +++ b/maya/ywta/test/maya_unit_test.py @@ -1,35 +1,36 @@ """ -Contains functions and classes to aid in the unit testing process within Maya. +Maya内でのユニットテストプロセスを支援する関数とクラスを提供します。 -The main classes are: -TestCase - A derived class of unittest.TestCase which add convenience functionality such as auto plug-in - loading/unloading, and auto temporary file name generation and cleanup. -TestResult - A derived class of unittest.TextTestResult which customizes the test result so we can do things like do a - file new between each test and suppress script editor output. +主要なクラス: +TestCase - unittest.TestCaseを継承し、プラグインの自動ロード/アンロード、一時ファイル名の生成と + クリーンアップなどの便利な機能を追加したクラスです。 +TestResult - unittest.TextTestResultを継承し、各テスト間での新規ファイル作成やスクリプトエディタの + 出力抑制などのカスタマイズを行うクラスです。 -To write tests for this system you need to, - a) Derive from cmt.test.TestCase - b) Write one or more tests that use the unittest module's assert methods to validate the results. +このシステムでテストを作成するにはtestsディレクトリ内に新規のPythonファイルを作成し、以下の手順に従います: + a) YWTA.test.TestCaseを継承したクラスを作成 + b) unittestモジュールのassertメソッドを使用して結果を検証するテストを1つ以上作成 -Example usage: +使用例: # test_sample.py -from cmt.test import TestCase +from ywta.test import TestCase class SampleTests(TestCase): def test_create_sphere(self): sphere = cmds.polySphere(n='mySphere')[0] self.assertEqual('mySphere', sphere) -# To run just this test case in Maya -import cmt.test -cmt.test.run_tests(test='test_sample.SampleTests') +# Mayaで特定のテストケースのみを実行 +import ywta.test +ywta.test.run_tests(test='test_sample.SampleTests') -# To run an individual test in a test case -cmt.test.run_tests(test='test_sample.SampleTests.test_create_sphere') +# テストケース内の特定のテストを実行 +ywta.test.run_tests(test='test_sample.SampleTests.test_create_sphere') -# To run all tests -cmt.test.run_tests() +# すべてのテストを実行 +ywta.test.run_tests() """ + import os import shutil import sys @@ -40,18 +41,17 @@ def test_create_sphere(self): import maya.cmds as cmds # The environment variable that signifies tests are being run with the custom TestResult class. -CMT_TESTING_VAR = "CMT_UNITTEST" +YWTA_TESTING_VAR = "YWTA_UNITTEST" -def run_tests(directories=None, test=None, test_suite=None): - """Run all the tests in the given paths. +def run_tests(test=None, test_suite=None): + """指定されたパスにあるすべてのテストを実行します。 - @param directories: A generator or list of paths containing tests to run. - @param test: Optional name of a specific test to run. - @param test_suite: Optional TestSuite to run. If omitted, a TestSuite will be generated. + @param test: 実行する特定のテストの名前(オプション)。 + @param test_suite: 実行するTestSuite(オプション)。省略された場合、TestSuiteが生成されます。 """ if test_suite is None: - test_suite = get_tests(directories, test) + test_suite = get_tests(test) runner = unittest.TextTestRunner(verbosity=2, resultclass=TestResult) runner.failfast = False @@ -59,18 +59,18 @@ def run_tests(directories=None, test=None, test_suite=None): runner.run(test_suite) -def get_tests(directories=None, test=None, test_suite=None): - """Get a unittest.TestSuite containing all the desired tests. +def get_tests(test=None, test_suite=None): + """必要なすべてのテストを含むunittest.TestSuiteを取得します。 + testsディレクトリを使用します。 - @param directories: Optional list of directories with which to search for tests. If omitted, use all "tests" - directories of the modules found in the MAYA_MODULE_PATH. - @param test: Optional test path to find a specific test such as 'test_mytest.SomeTestCase.test_function'. - @param test_suite: Optional unittest.TestSuite to add the discovered tests to. If omitted a new TestSuite will be - created. - @return: The populated TestSuite. + @param test: 'test_mytest.SomeTestCase.test_function'のような特定のテストを見つけるためのテストパス(オプション)。 + @param test_suite: 発見されたテストを追加するunittest.TestSuite(オプション)。省略された場合、新しいTestSuiteが + 作成されます。 + @return: テストが追加されたTestSuite。 """ - if directories is None: - directories = maya_module_tests() + + # maya/ywta/testsディレクトリから探す + directories = [os.path.join(os.path.dirname(__file__), "../../../tests/maya/unit")] # Populate a TestSuite with all the tests if test_suite is None: @@ -97,18 +97,10 @@ def get_tests(directories=None, test=None, test_suite=None): return test_suite -def maya_module_tests(): - """Generator function to iterate over all the Maya module tests directories.""" - for path in os.environ["MAYA_MODULE_PATH"].split(os.pathsep): - p = "{0}/tests".format(path) - if os.path.exists(p): - yield p - - def run_tests_from_commandline(): - """Runs the tests in Maya standalone mode. + """Mayaスタンドアロンモードでテストを実行します。 - This is called when running cmt/bin/runmayatests.py from the commandline. + コマンドラインからYWTA/bin/runmayatests.pyを実行する際に呼び出されます。 """ import maya.standalone @@ -133,12 +125,12 @@ def run_tests_from_commandline(): class Settings(object): - """Contains options for running tests.""" + """テスト実行のためのオプションを含むクラス。""" # Specifies where files generated during tests should be stored # Use a uuid subdirectory so tests that are running concurrently such as on a build server # do not conflict with each other. - temp_dir = os.path.join(tempfile.gettempdir(), "mayaunittest", str(uuid.uuid4())) + temp_dir = os.path.join(tempfile.gettempdir(), "maya_unit_test", str(uuid.uuid4())) # Controls whether temp files should be deleted after running all tests in the test case delete_files = True @@ -153,9 +145,9 @@ class Settings(object): def set_temp_dir(directory): - """Set where files generated from tests should be stored. + """テストから生成されたファイルを保存する場所を設定します。 - @param directory: A directory path. + @param directory: ディレクトリパス。 """ if os.path.exists(directory): Settings.temp_dir = directory @@ -164,34 +156,34 @@ def set_temp_dir(directory): def set_delete_files(value): - """Set whether temp files should be deleted after running all tests in a test case. + """テストケース内のすべてのテスト実行後に一時ファイルを削除するかどうかを設定します。 - @param value: True to delete files registered with a TestCase. + @param value: TestCaseに登録されたファイルを削除する場合はTrue。 """ Settings.delete_files = value def set_buffer_output(value): - """Set whether the standard output and standard error streams are buffered during the test run. + """テスト実行中に標準出力と標準エラーストリームをバッファリングするかどうかを設定します。 - @param value: True or False + @param value: TrueまたはFalse """ Settings.buffer_output = value def set_file_new(value): - """Set whether a new file should be created after each test. + """各テスト後に新しいファイルを作成するかどうかを設定します。 - @param value: True or False + @param value: TrueまたはFalse """ Settings.file_new = value def add_to_path(path): - """Add the specified path to the system path. + """指定されたパスをシステムパスに追加します。 - @param path: Path to add. - @return True if path was added. Return false if path does not exist or path was already in sys.path + @param path: 追加するパス。 + @return パスが追加された場合はTrue。パスが存在しないか、すでにsys.pathにある場合はFalseを返します。 """ if os.path.exists(path) and path not in sys.path: sys.path.insert(0, path) @@ -200,10 +192,10 @@ def add_to_path(path): class TestCase(unittest.TestCase): - """Base class for unit test cases run in Maya. + """Maya内で実行されるユニットテストケースの基本クラス。 - Tests do not have to inherit from this TestCase but this derived TestCase contains convenience - functions to load/unload plug-ins and clean up temporary files. + テストはこのTestCaseを継承する必要はありませんが、このクラスにはプラグインのロード/アンロードや + 一時ファイルのクリーンアップなどの便利な機能が含まれています。 """ # Keep track of all temporary files that were created so they can be cleaned up after all tests have been run @@ -214,31 +206,31 @@ class TestCase(unittest.TestCase): @classmethod def tearDownClass(cls): - super(TestCase, cls).tearDownClass() + unittest.TestCase.tearDownClass() cls.delete_temp_files() cls.unload_plugins() @classmethod def load_plugin(cls, plugin): - """Load the given plug-in and saves it to be unloaded when the TestCase is finished. + """指定されたプラグインをロードし、TestCase終了時にアンロードするために保存します。 - @param plugin: Plug-in name. + @param plugin: プラグイン名。 """ cmds.loadPlugin(plugin, qt=True) cls.plugins_loaded.add(plugin) @classmethod def unload_plugins(cls): - # Unload any plugins that this test case loaded + # このテストケースでロードされたプラグインをアンロード for plugin in cls.plugins_loaded: cmds.unloadPlugin(plugin) cls.plugins_loaded = [] @classmethod def delete_temp_files(cls): - """Delete the temp files in the cache and clear the cache.""" - # If we don't want to keep temp files around for debugging purposes, delete them when - # all tests in this TestCase have been run + """キャッシュ内の一時ファイルを削除し、キャッシュをクリアします。""" + # デバッグ目的で一時ファイルを保持したくない場合、このTestCaseのすべてのテストが + # 実行された後にファイルを削除します if Settings.delete_files: for f in cls.files_created: if os.path.exists(f): @@ -249,12 +241,11 @@ def delete_temp_files(cls): @classmethod def get_temp_filename(cls, file_name): - """Get a unique filepath name in the testing directory. + """テストディレクトリ内の一意のファイルパス名を取得します。 - The file will not be created, that is up to the caller. This file will be deleted when - the tests are finished. - @param file_name: A partial path ex: 'directory/somefile.txt' - @return The full path to the temporary file. + ファイルは作成されません。作成は呼び出し元の責任です。このファイルはテスト終了時に削除されます。 + @param file_name: 部分的なパス例:'directory/somefile.txt' + @return 一時ファイルへのフルパス。 """ temp_dir = Settings.temp_dir if not os.path.exists(temp_dir): @@ -270,73 +261,71 @@ def get_temp_filename(cls, file_name): return path def assertListAlmostEqual(self, first, second, places=7, msg=None, delta=None): - """Asserts that a list of floating point values is almost equal. + """浮動小数点値のリストがほぼ等しいことをアサートします。 - unittest has assertAlmostEqual and assertListEqual but no assertListAlmostEqual. + unittestにはassertAlmostEqualとassertListEqualはありますが、assertListAlmostEqualはありません。 """ self.assertEqual(len(first), len(second), msg) for a, b in zip(first, second): self.assertAlmostEqual(a, b, places, msg, delta) def tearDown(self): - if Settings.file_new and CMT_TESTING_VAR not in os.environ.keys(): - # If running tests without the custom runner, like with PyCharm, the file new of the TestResult class isn't - # used so call file new here + if Settings.file_new and YWTA_TESTING_VAR not in os.environ.keys(): + # PyCharmなどのカスタムランナーなしでテストを実行する場合、TestResultクラスのfile newは + # 使用されないため、ここでfile newを呼び出します cmds.file(f=True, new=True) class TestResult(unittest.TextTestResult): - """Customize the test result so we can do things like do a file new between each test and suppress script - editor output. - """ + """各テスト間での新規ファイル作成やスクリプトエディタの出力抑制などを行うためにテスト結果をカスタマイズします。""" def __init__(self, stream, descriptions, verbosity): super(TestResult, self).__init__(stream, descriptions, verbosity) self.successes = [] def startTestRun(self): - """Called before any tests are run.""" + """テスト実行前に呼び出されます。""" super(TestResult, self).startTestRun() - # Create an environment variable that specifies tests are being run through the custom runner. - os.environ[CMT_TESTING_VAR] = "1" + # カスタムランナーを通じてテストが実行されていることを指定する環境変数を作成します。 + os.environ[YWTA_TESTING_VAR] = "1" ScriptEditorState.suppress_output() if Settings.buffer_output: - # Disable any logging while running tests. By disabling critical, we are disabling logging - # at all levels below critical as well + # テスト実行中のログを無効にします。criticalを無効にすることで、 + # critical以下のすべてのレベルのログも無効になります logging.disable(logging.CRITICAL) def stopTestRun(self): - """Called after all tests are run.""" + """すべてのテスト実行後に呼び出されます。""" if Settings.buffer_output: - # Restore logging state + # ログ状態を復元 logging.disable(logging.NOTSET) ScriptEditorState.restore_output() if Settings.delete_files and os.path.exists(Settings.temp_dir): shutil.rmtree(Settings.temp_dir) - del os.environ[CMT_TESTING_VAR] + del os.environ[YWTA_TESTING_VAR] super(TestResult, self).stopTestRun() def stopTest(self, test): - """Called after an individual test is run. + """個々のテスト実行後に呼び出されます。 - @param test: TestCase that just ran.""" + @param test: 実行されたばかりのTestCase。""" super(TestResult, self).stopTest(test) if Settings.file_new: cmds.file(f=True, new=True) def addSuccess(self, test): - """Override the base addSuccess method so we can store a list of the successful tests. + """成功したテストのリストを保存できるように、基本のaddSuccessメソッドをオーバーライドします。 - @param test: TestCase that successfully ran.""" + @param test: 正常に実行されたTestCase。""" super(TestResult, self).addSuccess(test) self.successes.append(test) class ScriptEditorState(object): - """Provides methods to suppress and restore script editor output.""" + """スクリプトエディタの出力を抑制および復元するメソッドを提供します。""" # Used to restore logging states in the script editor suppress_results = None @@ -346,7 +335,7 @@ class ScriptEditorState(object): @classmethod def suppress_output(cls): - """Hides all script editor output.""" + """すべてのスクリプトエディタ出力を非表示にします。""" if Settings.buffer_output: cls.suppress_results = cmds.scriptEditorInfo(q=True, suppressResults=True) cls.suppress_errors = cmds.scriptEditorInfo(q=True, suppressErrors=True) @@ -362,7 +351,7 @@ def suppress_output(cls): @classmethod def restore_output(cls): - """Restores the script editor output settings to their original values.""" + """スクリプトエディタの出力設定を元の値に復元します。""" if None not in { cls.suppress_results, cls.suppress_errors, diff --git a/maya/ywta/test/mayaunittestui.py b/maya/ywta/test/maya_unit_test_ui.py similarity index 82% rename from maya/ywta/test/mayaunittestui.py rename to maya/ywta/test/maya_unit_test_ui.py index 1a3600a..2a901b6 100644 --- a/maya/ywta/test/mayaunittestui.py +++ b/maya/ywta/test/maya_unit_test_ui.py @@ -1,13 +1,14 @@ """ -Contains a user interface for the CMT testing framework. +Contains a user interface for the YWTA testing framework. The dialog will display all tests found in MAYA_MODULE_PATH and allow the user to selectively run the tests. The dialog will also automatically get any code updates without any need to reload if the dialog is opened before any other tools have been run. -To open the dialog run the menu item: CMT > Utility > Unit Test Runner. +To open the dialog run the menu item: YWTA > Utility > Unit Test Runner. """ + from __future__ import absolute_import from __future__ import division from __future__ import print_function @@ -17,20 +18,26 @@ import sys import traceback import unittest -import webbrowser +from ywta.reloadmodules import RollbackImporter from maya.app.general.mayaMixin import MayaQWidgetBaseMixin -from PySide2.QtCore import * -from PySide2.QtGui import * -from PySide2.QtWidgets import * -import ywta.test.mayaunittest as mayaunittest -import ywta.shortcuts as shortcuts +try: + from PySide6.QtCore import QObject, Qt + from PySide6.QtGui import QIcon, QPixmap + from PySide6.QtWidgets import * +except ImportError: + from PySide2.QtCore import * + from PySide2.QtGui import * + from PySide2.QtWidgets import * + +import ywta.test.maya_unit_test as maya_unit_test +from ywta.core.ui_utils import BaseTreeNode from ywta.ui.widgets.outputconsole import OutputConsole logger = logging.getLogger(__name__) -ICON_DIR = os.path.join(os.environ["CMT_ROOT_PATH"], "icons") +ICON_DIR = os.path.join(os.environ["YWTA_ROOT_PATH"], "icons") _win = None @@ -44,15 +51,11 @@ def show(): _win.show() -def documentation(): - webbrowser.open("https://github.com/chadmv/cmt/wiki/Unit-Test-Runner-Dialog") - - class MayaTestRunnerDialog(MayaQWidgetBaseMixin, QMainWindow): def __init__(self, *args, **kwargs): super(MayaTestRunnerDialog, self).__init__(*args, **kwargs) self.setAttribute(Qt.WA_DeleteOnClose) - self.setWindowTitle("CMT Unit Test Runner") + self.setWindowTitle("YWTA Unit Test Runner") self.resize(1000, 600) self.rollback_importer = RollbackImporter() @@ -61,33 +64,31 @@ def __init__(self, *args, **kwargs): action = menu.addAction("Buffer Output") action.setToolTip("Only display output during a failed test.") action.setCheckable(True) - action.setChecked(mayaunittest.Settings.buffer_output) - action.toggled.connect(mayaunittest.set_buffer_output) + action.setChecked(maya_unit_test.Settings.buffer_output) + action.toggled.connect(maya_unit_test.set_buffer_output) action = menu.addAction("New Scene Between Test") action.setToolTip("Creates a new scene file after each test.") action.setCheckable(True) - action.setChecked(mayaunittest.Settings.file_new) - action.toggled.connect(mayaunittest.set_file_new) + action.setChecked(maya_unit_test.Settings.file_new) + action.toggled.connect(maya_unit_test.set_file_new) menu = menubar.addMenu("Help") - action = menu.addAction("Documentation") - action.triggered.connect(documentation) toolbar = self.addToolBar("Tools") action = toolbar.addAction("Run All Tests") - action.setIcon(QIcon(QPixmap(os.path.join(ICON_DIR, "cmt_run_all_tests.png")))) + action.setIcon(QIcon(QPixmap(os.path.join(ICON_DIR, "ywta_run_all_tests.png")))) action.triggered.connect(self.run_all_tests) action.setToolTip("Run all tests.") action = toolbar.addAction("Run Selected Tests") action.setIcon( - QIcon(QPixmap(os.path.join(ICON_DIR, "cmt_run_selected_tests.png"))) + QIcon(QPixmap(os.path.join(ICON_DIR, "ywta_run_selected_tests.png"))) ) action.setToolTip("Run all selected tests.") action.triggered.connect(self.run_selected_tests) action = toolbar.addAction("Run Failed Tests") action.setIcon( - QIcon(QPixmap(os.path.join(ICON_DIR, "cmt_run_failed_tests.png"))) + QIcon(QPixmap(os.path.join(ICON_DIR, "ywta_run_failed_tests.png"))) ) action.setToolTip("Run all failed tests.") action.triggered.connect(self.run_failed_tests) @@ -118,7 +119,7 @@ def __init__(self, *args, **kwargs): def refresh_tests(self): self.reset_rollback_importer() - test_suite = mayaunittest.get_tests() + test_suite = maya_unit_test.get_tests() root_node = TestNode(test_suite) self.model = TestTreeModel(root_node, self) self.test_view.setModel(self.model) @@ -137,15 +138,13 @@ def expand_tree(self, root_node): def run_all_tests(self): """Callback method to run all the tests found in MAYA_MODULE_PATH.""" - self.reset_rollback_importer() test_suite = unittest.TestSuite() - mayaunittest.get_tests(test_suite=test_suite) + maya_unit_test.get_tests(test_suite=test_suite) self.output_console.clear() self.model.run_tests(self.output_console, test_suite) def run_selected_tests(self): """Callback method to run the selected tests in the UI.""" - self.reset_rollback_importer() test_suite = unittest.TestSuite() indices = self.test_view.selectedIndexes() @@ -167,21 +166,20 @@ def run_selected_tests(self): # Now get the tests with the pruned paths for path in test_paths: - mayaunittest.get_tests(test=path, test_suite=test_suite) + maya_unit_test.get_tests(test=path, test_suite=test_suite) self.output_console.clear() self.model.run_tests(self.output_console, test_suite) def run_failed_tests(self): """Callback method to run all the tests with fail or error statuses.""" - self.reset_rollback_importer() test_suite = unittest.TestSuite() for node in self.model.node_lookup.values(): if isinstance(node.test, unittest.TestCase) and node.get_status() in { TestStatus.fail, TestStatus.error, }: - mayaunittest.get_tests(test=node.path(), test_suite=test_suite) + maya_unit_test.get_tests(test=node.path(), test_suite=test_suite) self.output_console.clear() self.model.run_tests(self.output_console, test_suite) @@ -211,13 +209,13 @@ class TestStatus: skipped = 4 -class TestNode(shortcuts.BaseTreeNode): +class TestNode(BaseTreeNode): """A node representing a Test, TestCase, or TestSuite for display in a QTreeView.""" - success_icon = QPixmap(os.path.join(ICON_DIR, "cmt_test_success.png")) - fail_icon = QPixmap(os.path.join(ICON_DIR, "cmt_test_fail.png")) - error_icon = QPixmap(os.path.join(ICON_DIR, "cmt_test_error.png")) - skip_icon = QPixmap(os.path.join(ICON_DIR, "cmt_test_skip.png")) + success_icon = QPixmap(os.path.join(ICON_DIR, "ywta_test_success.png")) + fail_icon = QPixmap(os.path.join(ICON_DIR, "ywta_test_fail.png")) + error_icon = QPixmap(os.path.join(ICON_DIR, "ywta_test_error.png")) + skip_icon = QPixmap(os.path.join(ICON_DIR, "ywta_test_skip.png")) def __init__(self, test, parent=None): super(TestNode, self).__init__(parent) @@ -333,7 +331,7 @@ def data(self, index, role): def setData(self, index, value, role=Qt.EditRole): node = index.internalPointer() - data_changed_kwargs = ([index, index, []]) + data_changed_kwargs = [index, index, []] if role == Qt.EditRole: self.dataChanged.emit(*data_changed_kwargs) if role == Qt.DecorationRole: @@ -382,10 +380,10 @@ def run_tests(self, stream, test_suite): :param test_suite: The TestSuite to run. """ runner = unittest.TextTestRunner( - stream=stream, verbosity=2, resultclass=mayaunittest.TestResult + stream=stream, verbosity=2, resultclass=maya_unit_test.TestResult ) runner.failfast = False - runner.buffer = mayaunittest.Settings.buffer_output + runner.buffer = maya_unit_test.Settings.buffer_output result = runner.run(test_suite) self._set_test_result_data(result.failures, TestStatus.fail) @@ -408,28 +406,3 @@ def _set_test_result_data(self, test_list, status): index = self.get_index_of_node(node) self.setData(index, reason, Qt.ToolTipRole) self.setData(index, status, Qt.DecorationRole) - - -class RollbackImporter(object): - """Used to remove imported modules from the module list. - - This allows tests to be rerun after code updates without doing any reloads. - Original idea from: http://pyunit.sourceforge.net/notes/reloading.html - - Usage: - def run_tests(self): - if self.rollback_importer: - self.rollback_importer.uninstall() - self.rollback_importer = RollbackImporter() - self.load_and_execute_tests() - """ - - def __init__(self): - """Creates an instance and installs as the global importer.""" - self.previous_modules = set(sys.modules.keys()) - - def uninstall(self): - for modname in sys.modules.keys(): - if modname not in self.previous_modules: - # Force reload when modname next imported - del (sys.modules[modname]) diff --git a/maya/ywta/ui/widgets/__init__.py b/maya/ywta/ui/widgets/__init__.py index e69de29..e336153 100644 --- a/maya/ywta/ui/widgets/__init__.py +++ b/maya/ywta/ui/widgets/__init__.py @@ -0,0 +1,3 @@ +# Dependencies: +# - ywta.shortcuts +# - ywta.ui.stringcache \ No newline at end of file diff --git a/maya/ywta/ui/widgets/filepathwidget.py b/maya/ywta/ui/widgets/filepathwidget.py index 55eeef8..eb6267c 100644 --- a/maya/ywta/ui/widgets/filepathwidget.py +++ b/maya/ywta/ui/widgets/filepathwidget.py @@ -22,17 +22,12 @@ def func(t): import os -from PySide2.QtCore import Signal -from PySide2.QtWidgets import ( - QWidget, - QHBoxLayout, - QLabel, - QComboBox, - QSizePolicy, - QPushButton, - QDialog, - QFileDialog, -) +try: + from PySide6.QtCore import Signal + from PySide6.QtWidgets import * +except ImportError: + from PySide2.QtCore import Signal + from PySide2.QtWidgets import * from ywta.ui.stringcache import StringCache diff --git a/maya/ywta/utility/__init__.py b/maya/ywta/utility/__init__.py index e69de29..71590a5 100644 --- a/maya/ywta/utility/__init__.py +++ b/maya/ywta/utility/__init__.py @@ -0,0 +1,3 @@ +# Dependencies: +# - ywta.shortcuts +# - ywta.utility.dependency_analyzer diff --git a/maya/ywta/utility/dependency_analyzer.py b/maya/ywta/utility/dependency_analyzer.py new file mode 100644 index 0000000..fbdfe4c --- /dev/null +++ b/maya/ywta/utility/dependency_analyzer.py @@ -0,0 +1,310 @@ +""" +依存関係分析ツール + +このモジュールは、YWTAプロジェクト内のPythonモジュール間の依存関係を分析するための +ツールを提供します。 + +使用方法: + import ywta.utility.dependency_analyzer as analyzer + + # 依存関係の分析 + dependencies = analyzer.analyze_dependencies() + + # 循環依存の検出 + cycles = analyzer.detect_cycles(dependencies) + + # 依存関係情報を各モジュールの__init__.pyに追加 + analyzer.update_init_files(dependencies) +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import os +import re +import sys +import ast +import logging +from collections import defaultdict + +logger = logging.getLogger(__name__) + +# YWTAのルートディレクトリ +YWTA_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + + +def analyze_dependencies(root_dir=None): + """ + 指定されたディレクトリ内のPythonファイルの依存関係を分析します。 + + Args: + root_dir (str, optional): 分析するディレクトリのパス。デフォルトはYWTAのルートディレクトリ。 + + Returns: + dict: モジュールの依存関係を表す辞書。 + {モジュール名: {依存モジュール名のセット}} + """ + if root_dir is None: + root_dir = YWTA_ROOT + + dependencies = defaultdict(set) + + for dirpath, _, filenames in os.walk(root_dir): + for filename in filenames: + if filename.endswith(".py"): + filepath = os.path.join(dirpath, filename) + module_name = get_module_name(filepath, root_dir) + + if module_name: + imports = extract_imports(filepath) + # YWTAモジュールのみをフィルタリング + ywta_imports = [imp for imp in imports if imp.startswith("ywta.")] + if ywta_imports: + dependencies[module_name] = set(ywta_imports) + + return dependencies + + +def get_module_name(filepath, root_dir): + """ + ファイルパスからPythonモジュール名を取得します。 + + Args: + filepath (str): Pythonファイルのパス + root_dir (str): ルートディレクトリのパス + + Returns: + str: モジュール名(例: 'ywta.rig.control') + """ + if not filepath.startswith(root_dir): + return None + + rel_path = os.path.relpath(filepath, os.path.dirname(root_dir)) + module_path = os.path.splitext(rel_path)[0].replace(os.path.sep, ".") + + # __init__.pyの場合はディレクトリ名をモジュール名とする + if module_path.endswith(".__init__"): + module_path = module_path[:-9] + + return module_path + + +def extract_imports(filepath): + """ + Pythonファイルからインポート文を抽出します。 + + Args: + filepath (str): Pythonファイルのパス + + Returns: + list: インポートされたモジュール名のリスト + """ + imports = [] + + try: + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + + tree = ast.parse(content) + + for node in ast.walk(tree): + # 通常のimport文(import X, import X.Y) + if isinstance(node, ast.Import): + for name in node.names: + imports.append(name.name) + + # from X import Y形式 + elif isinstance(node, ast.ImportFrom): + if node.module: + # 相対インポートの処理 + if node.level > 0: + # 相対インポートは現在のモジュールからの相対パスなので、 + # 正確な処理は複雑になるため、ここでは単純化 + module_parts = get_module_name(filepath, YWTA_ROOT).split(".") + parent_module = ".".join(module_parts[: -node.level]) + if node.module != "": + full_module = f"{parent_module}.{node.module}" + else: + full_module = parent_module + else: + full_module = node.module + + for name in node.names: + if name.name == "*": + # import *の場合はモジュール自体を依存関係に追加 + imports.append(full_module) + else: + # 通常のfrom importの場合 + imports.append(full_module) + except Exception as e: + logger.error(f"Error parsing {filepath}: {e}") + + return imports + + +def detect_cycles(dependencies): + """ + 依存関係の循環参照を検出します。 + + Args: + dependencies (dict): モジュールの依存関係を表す辞書 + + Returns: + list: 循環参照のリスト。各循環参照はモジュール名のリスト。 + """ + cycles = [] + visited = set() + path = [] + + def dfs(node): + if node in path: + # 循環参照を検出 + cycle_start = path.index(node) + cycles.append(path[cycle_start:] + [node]) + return + + if node in visited: + return + + visited.add(node) + path.append(node) + + for neighbor in dependencies.get(node, []): + dfs(neighbor) + + path.pop() + + for node in dependencies: + dfs(node) + + return cycles + + +def update_init_files(dependencies): + """ + 依存関係情報を各モジュールの__init__.pyファイルに追加します。 + + Args: + dependencies (dict): モジュールの依存関係を表す辞書 + """ + for module, deps in dependencies.items(): + if not deps: + continue + + # モジュールのディレクトリパスを取得 + parts = module.split(".") + if len(parts) <= 1: + continue + + # ywta.category.module -> ywta/category + module_dir = os.path.join(YWTA_ROOT, *parts[1:-1]) + init_file = os.path.join(module_dir, "__init__.py") + + if not os.path.exists(init_file): + logger.warning(f"{init_file} が存在しません") + continue + + # 既存の内容を読み込む + with open(init_file, "r", encoding="utf-8") as f: + content = f.read() + + # 依存関係コメントがすでに存在するか確認 + dependency_pattern = r"# Dependencies:.*?(?=\n\n|\Z)" + if re.search(dependency_pattern, content, re.DOTALL): + # 既存の依存関係コメントを更新 + updated_content = re.sub( + dependency_pattern, + format_dependency_comment(deps), + content, + flags=re.DOTALL, + ) + else: + # 新しい依存関係コメントを追加 + if content.strip(): + updated_content = ( + content.rstrip() + "\n\n" + format_dependency_comment(deps) + "\n" + ) + else: + updated_content = format_dependency_comment(deps) + "\n" + + # ファイルに書き戻す + with open(init_file, "w", encoding="utf-8") as f: + f.write(updated_content) + + logger.info(f"{init_file} の依存関係情報を更新しました") + + +def format_dependency_comment(deps): + """ + 依存関係コメントをフォーマットします。 + + Args: + deps (set): 依存モジュールのセット + + Returns: + str: フォーマットされた依存関係コメント + """ + deps_list = sorted(list(deps)) + comment = "# Dependencies:\n" + for dep in deps_list: + comment += f"# - {dep}\n" + return comment.rstrip() + + +def main(): + """ + メイン実行関数。コマンドラインから実行された場合に使用されます。 + """ + import argparse + + parser = argparse.ArgumentParser(description="YWTAモジュールの依存関係を分析します") + parser.add_argument( + "--output", + "-o", + default="dependencies.dot", + help="依存関係グラフの出力ファイル(DOT形式)", + ) + parser.add_argument( + "--update-init", + "-u", + action="store_true", + help="__init__.pyファイルに依存関係情報を追加します", + ) + parser.add_argument( + "--detect-cycles", "-c", action="store_true", help="循環依存を検出します" + ) + + args = parser.parse_args() + + # ログ設定 + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + # 依存関係の分析 + logger.info("依存関係の分析を開始します...") + dependencies = analyze_dependencies() + + # 循環依存の検出 + if args.detect_cycles: + logger.info("循環依存の検出を開始します...") + cycles = detect_cycles(dependencies) + if cycles: + logger.warning(f"{len(cycles)}個の循環依存が検出されました:") + for i, cycle in enumerate(cycles, 1): + logger.warning(f"循環{i}: {' -> '.join(cycle)}") + else: + logger.info("循環依存は検出されませんでした") + + # __init__.pyの更新 + if args.update_init: + logger.info("__init__.pyファイルの更新を開始します...") + update_init_files(dependencies) + + logger.info("依存関係分析が完了しました") + + +if __name__ == "__main__": + main() diff --git a/maya/ywta/utility/dependency_visualizer.py b/maya/ywta/utility/dependency_visualizer.py new file mode 100644 index 0000000..677d72a --- /dev/null +++ b/maya/ywta/utility/dependency_visualizer.py @@ -0,0 +1,421 @@ +""" +依存関係分析ツール + +このモジュールは、YWTAプロジェクト内のモジュール間の依存関係を分析するための +ユーザーインターフェースを提供します。 + +使用方法: + # Mayaから実行 + import ywta.utility.dependency_visualizer as visualizer + visualizer.show() +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import os +import sys +import logging +from functools import partial + +try: + from PySide6.QtCore import * + from PySide6.QtGui import * + from PySide6.QtWidgets import * +except ImportError: + from PySide2.QtCore import * + from PySide2.QtGui import * + from PySide2.QtWidgets import * + +from maya.app.general.mayaMixin import MayaQWidgetBaseMixin +import maya.cmds as cmds + +import ywta.utility.dependency_analyzer as analyzer +import ywta.shortcuts as shortcuts + +logger = logging.getLogger(__name__) + + +class DependencyVisualizerWindow(MayaQWidgetBaseMixin, QDialog): + """依存関係分析ツールのメインウィンドウ""" + + _window_instance = None + + @classmethod + def show_window(cls): + if not cls._window_instance: + cls._window_instance = cls() + cls._window_instance.show() + cls._window_instance.raise_() + cls._window_instance.activateWindow() + + def __init__(self, parent=None): + super(DependencyVisualizerWindow, self).__init__(parent) + self.setWindowTitle("YWTA 依存関係分析ツール") + self.setObjectName("ywtaDependencyVisualizerUI") + self.setMinimumWidth(600) + self.setMinimumHeight(400) + + self.dependencies = None + self.cycles = None + + self.create_ui() + + def create_ui(self): + """UIの作成""" + main_layout = QVBoxLayout(self) + + # 分析タブ + analysis_layout = main_layout + + # 分析オプション + options_group = QGroupBox("分析オプション") + options_layout = QVBoxLayout(options_group) + analysis_layout.addWidget(options_group) + + # モジュールフィルタ + filter_layout = QHBoxLayout() + options_layout.addLayout(filter_layout) + filter_layout.addWidget(QLabel("モジュールフィルタ:")) + self.module_filter = QLineEdit() + self.module_filter.setPlaceholderText("例: ywta.rig (空白で全モジュール)") + filter_layout.addWidget(self.module_filter) + + # 分析ボタン + analyze_button = QPushButton("依存関係を分析") + analyze_button.clicked.connect(self.analyze_dependencies) + options_layout.addWidget(analyze_button) + + # 結果表示エリア + results_group = QGroupBox("分析結果") + results_layout = QVBoxLayout(results_group) + analysis_layout.addWidget(results_group) + + # 依存関係ツリー + self.dependency_tree = QTreeWidget() + self.dependency_tree.setHeaderLabels(["モジュール", "依存モジュール数"]) + self.dependency_tree.setAlternatingRowColors(True) + results_layout.addWidget(self.dependency_tree) + + # 循環依存検出結果 + cycles_layout = QHBoxLayout() + results_layout.addLayout(cycles_layout) + cycles_layout.addWidget(QLabel("循環依存:")) + self.cycles_label = QLabel("未検出") + cycles_layout.addWidget(self.cycles_label) + cycles_layout.addStretch() + self.show_cycles_button = QPushButton("詳細を表示") + self.show_cycles_button.setEnabled(False) + self.show_cycles_button.clicked.connect(self.show_cycles_details) + cycles_layout.addWidget(self.show_cycles_button) + + # 更新タブ + update_tab = QWidget() + update_layout = QVBoxLayout(update_tab) + main_layout.addWidget(update_tab) + + # 更新オプション + update_options_group = QGroupBox("更新オプション") + update_options_layout = QVBoxLayout(update_options_group) + update_layout.addWidget(update_options_group) + + # 更新対象選択 + self.update_all_radio = QRadioButton("すべてのモジュールを更新") + self.update_all_radio.setChecked(True) + update_options_layout.addWidget(self.update_all_radio) + + self.update_selected_radio = QRadioButton("選択したモジュールのみ更新") + update_options_layout.addWidget(self.update_selected_radio) + + # モジュール選択リスト + self.module_list = QListWidget() + self.module_list.setSelectionMode(QAbstractItemView.MultiSelection) + self.module_list.setEnabled(False) + update_options_layout.addWidget(self.module_list) + + # ラジオボタンの状態変更時の処理 + self.update_all_radio.toggled.connect( + lambda checked: self.module_list.setEnabled(not checked) + ) + + # 更新ボタン + update_button = QPushButton("__init__.pyファイルを更新") + update_button.clicked.connect(self.update_init_files) + update_options_layout.addWidget(update_button) + + # 更新結果表示 + self.update_result = QTextEdit() + self.update_result.setReadOnly(True) + update_layout.addWidget(self.update_result) + + # ボタンエリア + button_layout = QHBoxLayout() + main_layout.addLayout(button_layout) + + # ヘルプボタン + help_button = QPushButton("ヘルプ") + help_button.clicked.connect(self.show_help) + button_layout.addWidget(help_button) + + button_layout.addStretch() + + # 閉じるボタン + close_button = QPushButton("閉じる") + close_button.clicked.connect(self.close) + button_layout.addWidget(close_button) + + def analyze_dependencies(self): + """依存関係の分析を実行""" + # 進捗ダイアログの表示 + progress_dialog = QProgressDialog( + "依存関係を分析中...", "キャンセル", 0, 100, self + ) + progress_dialog.setWindowTitle("分析中") + progress_dialog.setWindowModality(Qt.WindowModal) + progress_dialog.setValue(10) + QApplication.processEvents() + + try: + # 依存関係の分析 + self.dependencies = analyzer.analyze_dependencies() + progress_dialog.setValue(50) + + # フィルタリング + filter_text = self.module_filter.text().strip() + if filter_text: + filtered_deps = {} + for module, deps in self.dependencies.items(): + if filter_text in module: + filtered_deps[module] = deps + self.dependencies = filtered_deps + + # 循環依存の検出 + self.cycles = analyzer.detect_cycles(self.dependencies) + progress_dialog.setValue(80) + + # 結果の表示 + self.update_dependency_tree() + self.update_cycles_display() + self.update_module_list() + + progress_dialog.setValue(100) + + except Exception as e: + cmds.warning(f"依存関係の分析中にエラーが発生しました: {str(e)}") + logger.error(f"依存関係の分析中にエラーが発生しました: {str(e)}") + finally: + progress_dialog.close() + + def update_dependency_tree(self): + """依存関係ツリーの更新""" + self.dependency_tree.clear() + + if not self.dependencies: + return + + # モジュールをカテゴリごとにグループ化 + categories = {} + for module in sorted(self.dependencies.keys()): + parts = module.split(".") + if len(parts) > 1: + category = parts[1] # ywta.category.module + if category not in categories: + categories[category] = [] + categories[category].append(module) + + # カテゴリごとにツリーアイテムを作成 + for category in sorted(categories.keys()): + category_item = QTreeWidgetItem(self.dependency_tree) + category_item.setText(0, category) + category_item.setExpanded(True) + + # モジュールごとのアイテムを作成 + for module in sorted(categories[category]): + deps = self.dependencies[module] + module_item = QTreeWidgetItem(category_item) + module_item.setText(0, module) + module_item.setText(1, str(len(deps))) + + # 依存モジュールを子アイテムとして追加 + for dep in sorted(deps): + dep_item = QTreeWidgetItem(module_item) + dep_item.setText(0, dep) + + def update_cycles_display(self): + """循環依存の表示を更新""" + if self.cycles: + self.cycles_label.setText(f"{len(self.cycles)}個の循環依存が検出されました") + self.show_cycles_button.setEnabled(True) + else: + self.cycles_label.setText("循環依存は検出されませんでした") + self.show_cycles_button.setEnabled(False) + + def show_cycles_details(self): + """循環依存の詳細を表示""" + if not self.cycles: + return + + dialog = QDialog(self) + dialog.setWindowTitle("循環依存の詳細") + dialog.setMinimumWidth(500) + + layout = QVBoxLayout(dialog) + + # 循環依存リスト + cycles_list = QListWidget() + layout.addWidget(cycles_list) + + for i, cycle in enumerate(self.cycles, 1): + cycles_list.addItem(f"循環{i}: {' -> '.join(cycle)}") + + # 閉じるボタン + button_layout = QHBoxLayout() + layout.addLayout(button_layout) + + button_layout.addStretch() + close_button = QPushButton("閉じる") + close_button.clicked.connect(dialog.close) + button_layout.addWidget(close_button) + + dialog.exec_() + + def update_module_list(self): + """モジュールリストの更新""" + self.module_list.clear() + + if not self.dependencies: + return + + # モジュールをリストに追加 + for module in sorted(self.dependencies.keys()): + self.module_list.addItem(module) + + def update_init_files(self): + """__init__.pyファイルの更新""" + if not self.dependencies: + cmds.warning("依存関係を先に分析してください") + return + + # 進捗ダイアログの表示 + progress_dialog = QProgressDialog( + "__init__.pyファイルを更新中...", "キャンセル", 0, 100, self + ) + progress_dialog.setWindowTitle("更新中") + progress_dialog.setWindowModality(Qt.WindowModal) + progress_dialog.setValue(10) + QApplication.processEvents() + + try: + # 更新対象の選択 + if self.update_all_radio.isChecked(): + # すべてのモジュールを更新 + target_dependencies = self.dependencies + else: + # 選択されたモジュールのみ更新 + selected_modules = [ + item.text() for item in self.module_list.selectedItems() + ] + if not selected_modules: + cmds.warning("更新するモジュールが選択されていません") + return + + target_dependencies = { + module: deps + for module, deps in self.dependencies.items() + if module in selected_modules + } + + progress_dialog.setValue(30) + + # 更新前の状態を保存 + self.update_result.clear() + self.update_result.append("__init__.pyファイルの更新を開始します...\n") + + # 更新の実行 + # 実際の更新処理をキャプチャするためにログハンドラを追加 + log_capture = [] + + class LogHandler(logging.Handler): + def emit(self, record): + log_capture.append(self.format(record)) + + handler = LogHandler() + handler.setFormatter(logging.Formatter("%(message)s")) + logger.addHandler(handler) + + # 更新の実行 + analyzer.update_init_files(target_dependencies) + progress_dialog.setValue(80) + + # ログの表示 + for log in log_capture: + self.update_result.append(log) + + # ログハンドラの削除 + logger.removeHandler(handler) + + self.update_result.append("\n更新が完了しました") + progress_dialog.setValue(100) + + except Exception as e: + cmds.warning(f"__init__.pyファイルの更新中にエラーが発生しました: {str(e)}") + logger.error(f"__init__.pyファイルの更新中にエラーが発生しました: {str(e)}") + finally: + progress_dialog.close() + + def show_help(self): + """ヘルプの表示""" + help_text = """ +

YWTA 依存関係分析ツール

+ +

概要

+

このツールは、YWTAプロジェクト内のPythonモジュール間の依存関係を分析するためのものです。

+ +

使用方法

+
    +
  1. 依存関係分析: モジュールの依存関係を分析します。フィルタを使用して特定のモジュールに絞り込むことができます。
  2. +
  3. __init__.py更新: 分析結果に基づいて、各モジュールの__init__.pyファイルに依存関係情報を追加します。
  4. +
+ +

注意事項

+
    +
  • __init__.pyファイルの更新は、既存のファイルを変更します。重要なファイルは事前にバックアップしてください。
  • +
+""" + + dialog = QDialog(self) + dialog.setWindowTitle("ヘルプ") + dialog.setMinimumSize(600, 400) + + layout = QVBoxLayout(dialog) + + help_browser = QTextEdit() + help_browser.setReadOnly(True) + help_browser.setHtml(help_text) + layout.addWidget(help_browser) + + button_layout = QHBoxLayout() + layout.addLayout(button_layout) + + button_layout.addStretch() + close_button = QPushButton("閉じる") + close_button.clicked.connect(dialog.close) + button_layout.addWidget(close_button) + + dialog.exec_() + + def closeEvent(self, event): + """ウィンドウが閉じられるときの処理""" + # シングルトンインスタンスのクリア + self.__class__._window_instance = None + event.accept() + + +def show(): + """依存関係分析ツールを表示""" + DependencyVisualizerWindow.show_window() + + +if __name__ == "__main__": + show() diff --git a/requirements.txt b/requirements.txt index 5e5c071..53c8f1a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ numpy -scypy -pyparsing \ No newline at end of file +scipy +pyparsing diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..08b5d50 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,188 @@ +# YWTA Tools テストフレームワーク + +このディレクトリには、YWTA Toolsのテストフレームワークが含まれています。 +Maya、Blenderなど異なるプラットフォーム向けのテストを実行するための共通基盤を提供します。 + +## ディレクトリ構造 + +``` +tests/ +├── common/ # 共通のテストユーティリティとベースクラス +├── maya/ # Maya用のテスト +│ ├── unit/ # Mayaの単体テスト +│ ├── integration/ # Mayaの統合テスト +│ └── performance/ # Mayaのパフォーマンステスト +├── blender/ # Blender用のテスト +│ ├── unit/ # Blenderの単体テスト +│ ├── integration/ # Blenderの統合テスト +│ └── performance/ # Blenderのパフォーマンステスト +└── utils/ # テスト実行用のユーティリティ +``` + +## テストの実行方法 + +### 共通のテスト実行 + +すべてのテストを実行するには、以下のコマンドを使用します: + +```bash +python tests/run_tests.py +``` + +### Maya用テストの実行 + +Maya環境でテストを実行するには、以下のいずれかの方法を使用します: + +1. Mayaの内部から実行: + ```python + # Mayaのスクリプトエディタで実行 + import sys + sys.path.append('/path/to/ywtatools') # プロジェクトルートへのパスを指定 + import tests.run_maya_tests + ``` + +2. CLIからMayaのStandalone環境での実行: + ```bash + python tests/run_maya_tests.py --type unit --pattern test_*.py --maya 2024 + ``` + +オプションを指定することもできます: + +```bash +mayapy.exe tests/run_maya_tests.py --type integration +``` + +### Blender用テストの実行 + +Blender環境でテストを実行するには、以下のいずれかの方法を使用します: + +1. Blenderの内部から実行: + ```python + # Blenderのテキストエディタで実行 + import sys + sys.path.append('/path/to/ywtatools') # プロジェクトルートへのパスを指定 + import tests.run_blender_tests + ``` + +2. blenderコマンドラインから実行: + ```bash + blender -b -P tests/run_blender_tests.py + ``` + +オプションを指定することもできます: + +```bash +blender -b -P tests/run_blender_tests.py -- --type integration +``` + +## 新しいテストの作成方法 + +### Maya用テストの作成 + +1. `tests/maya/unit/` ディレクトリに新しいテストファイルを作成します(例:`test_my_feature.py`) +2. `MayaTestCase` クラスを継承したテストクラスを作成します: + +```python +from tests.maya.maya_test_case import MayaTestCase + +class MyFeatureTests(MayaTestCase): + """Mayaの機能テスト""" + + def test_something(self): + """テスト関数""" + # テストコードをここに記述 + self.assertEqual(1 + 1, 2) + + def test_maya_specific(self): + """Maya固有のテスト""" + # Mayaコマンドを使用したテスト + if self.MAYA_AVAILABLE: + sphere = self.create_test_node('polySphere') + self.assertIsNotNone(sphere) +``` + +### Blender用テストの作成 + +1. `tests/blender/unit/` ディレクトリに新しいテストファイルを作成します(例:`test_my_addon.py`) +2. `BlenderTestCase` クラスを継承したテストクラスを作成します: + +```python +from tests.blender.blender_test_case import BlenderTestCase + +class MyAddonTests(BlenderTestCase): + """Blenderアドオンのテスト""" + + @classmethod + def setUpClass(cls): + """テストクラスの初期化""" + super().setUpClass() + # アドオンを有効化 + cls.enable_addon('ywtatools_addon') + + def test_something(self): + """テスト関数""" + # テストコードをここに記述 + self.assertEqual(1 + 1, 2) + + def test_blender_specific(self): + """Blender固有のテスト""" + # Blender APIを使用したテスト + if self.BLENDER_AVAILABLE: + cube = self.create_test_object('MESH', 'TestCube') + self.assertIsNotNone(cube) + self.assertEqual(cube.name, 'TestCube') +``` + +## テスト設定のカスタマイズ + +テスト実行の設定は `tests/common/test_settings.py` で定義されています。 +以下のような設定をカスタマイズできます: + +```python +from tests.common.test_settings import TestSettings + +# 一時ファイルを削除しない +TestSettings.delete_files = False + +# 出力バッファリングを無効化 +TestSettings.buffer_output = False + +# テスト間で新しいシーンを作成しない +TestSettings.new_scene_between_tests = False +``` + +## 高度なテスト機能 + +### 一時ファイルの作成 + +テスト中に一時ファイルを作成する必要がある場合は、`get_temp_filename` メソッドを使用します: + +```python +def test_with_temp_file(self): + # 一時ファイルパスを取得 + temp_file = self.get_temp_filename('test_data.json') + + # ファイルに書き込み + with open(temp_file, 'w') as f: + f.write('{"test": "data"}') + + # テスト終了時に自動的に削除されます +``` + +### 浮動小数点値の比較 + +浮動小数点値を含むリストや辞書を比較するための特殊なアサートメソッドが用意されています: + +```python +def test_float_comparisons(self): + list1 = [1.0001, 2.0002, 3.0003] + list2 = [1.0002, 2.0003, 3.0004] + + # 小数点以下3桁まで一致していればOK + self.assertListAlmostEqual(list1, list2, places=3) + + dict1 = {'a': 1.0001, 'b': 2.0002} + dict2 = {'a': 1.0002, 'b': 2.0003} + + # 小数点以下3桁まで一致していればOK + self.assertDictAlmostEqual(dict1, dict2, places=3) diff --git a/tests/blender/__init__.py b/tests/blender/__init__.py new file mode 100644 index 0000000..9c0df9a --- /dev/null +++ b/tests/blender/__init__.py @@ -0,0 +1,5 @@ +""" +YWTA Tools Blender テストモジュール + +このモジュールは、Blender用のテストフレームワークを提供します。 +""" diff --git a/tests/blender/blender_test_case.py b/tests/blender/blender_test_case.py new file mode 100644 index 0000000..3568409 --- /dev/null +++ b/tests/blender/blender_test_case.py @@ -0,0 +1,226 @@ +""" +Blender テストケースモジュール + +このモジュールは、Blender環境でのテスト実行のための基底クラスを提供します。 +""" + +import os +import sys +import unittest +import tempfile +import uuid +import logging +from pathlib import Path + +try: + import bpy + + BLENDER_AVAILABLE = True +except ImportError: + BLENDER_AVAILABLE = False + +from tests.common.base_test_case import BaseTestCase +from tests.common.test_settings import TestSettings + + +class BlenderTestCase(BaseTestCase): + """Blender環境でのテスト実行のための基底クラス + + このクラスは、Blender固有のテスト機能を提供します。 + Blender APIやアドオン機能を使用するテストは、このクラスを継承して実装します。 + """ + + # 有効化されたアドオンのリスト + addons_enabled = set() + + @classmethod + def setUpClass(cls): + """テストケースクラスの初期化""" + super().setUpClass() + + # Blender環境のセットアップ + TestSettings.setup_environment("blender") + + # Blenderが利用可能かチェック + if not BLENDER_AVAILABLE: + raise unittest.SkipTest("Blender環境が利用できません。") + + @classmethod + def tearDownClass(cls): + """テストケースクラスの終了処理""" + # アドオンの無効化 + cls.disable_addons() + + super().tearDownClass() + + def setUp(self): + """各テスト前の準備""" + super().setUp() + + # 新しいシーンを作成 + if TestSettings.new_scene_between_tests and BLENDER_AVAILABLE: + self.new_scene() + + def tearDown(self): + """各テスト後のクリーンアップ""" + super().tearDown() + + @classmethod + def enable_addon(cls, addon_name): + """アドオンを有効化し、テスト終了時に無効化するために記録 + + Args: + addon_name (str): アドオン名 + + Returns: + bool: 有効化に成功した場合はTrue + """ + if not BLENDER_AVAILABLE: + return False + + try: + # アドオンが既に有効かチェック + if not addon_name in bpy.context.preferences.addons: + bpy.ops.preferences.addon_enable(module=addon_name) + cls.addons_enabled.add(addon_name) + return True + except: + logging.warning(f"アドオン '{addon_name}' の有効化に失敗しました。") + return False + + @classmethod + def disable_addons(cls): + """このテストケースで有効化されたすべてのアドオンを無効化""" + if not BLENDER_AVAILABLE: + return + + for addon in cls.addons_enabled: + try: + if addon in bpy.context.preferences.addons: + bpy.ops.preferences.addon_disable(module=addon) + except: + logging.warning(f"アドオン '{addon}' の無効化に失敗しました。") + + cls.addons_enabled.clear() + + def new_scene(self): + """新しいBlenderシーンを作成""" + if BLENDER_AVAILABLE: + # 既存のオブジェクトをすべて削除 + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete() + + # 新しいシーンを作成 + bpy.ops.scene.new(type="EMPTY") + + def load_scene(self, file_path): + """Blenderシーンをロード + + Args: + file_path (str): シーンファイルのパス + + Returns: + bool: ロードに成功した場合はTrue + """ + if not BLENDER_AVAILABLE: + return False + + try: + bpy.ops.wm.open_mainfile(filepath=file_path) + return True + except: + logging.warning(f"シーンファイル '{file_path}' のロードに失敗しました。") + return False + + def save_scene(self, file_path): + """現在のBlenderシーンを保存 + + Args: + file_path (str): 保存先のパス + + Returns: + bool: 保存に成功した場合はTrue + """ + if not BLENDER_AVAILABLE: + return False + + try: + bpy.ops.wm.save_as_mainfile(filepath=file_path) + return True + except: + logging.warning(f"シーンの保存に失敗しました: '{file_path}'") + return False + + def get_temp_scene(self, scene_name="test_scene.blend"): + """テスト用の一時シーンファイルパスを取得 + + Args: + scene_name (str, optional): シーンファイル名。デフォルトは "test_scene.blend" + + Returns: + str: 一時シーンファイルへのフルパス + """ + return self.get_temp_filename(scene_name) + + def create_test_object(self, obj_type="MESH", name=None): + """テスト用のオブジェクトを作成 + + Args: + obj_type (str, optional): オブジェクトタイプ。デフォルトは 'MESH' + name (str, optional): オブジェクト名 + + Returns: + bpy.types.Object: 作成されたオブジェクト + """ + if not BLENDER_AVAILABLE: + return None + + try: + # オブジェクトタイプに基づいて適切な作成関数を呼び出す + if obj_type == "MESH": + bpy.ops.mesh.primitive_cube_add() + elif obj_type == "CURVE": + bpy.ops.curve.primitive_bezier_curve_add() + elif obj_type == "ARMATURE": + bpy.ops.object.armature_add() + elif obj_type == "EMPTY": + bpy.ops.object.empty_add() + else: + # デフォルトはメッシュ + bpy.ops.mesh.primitive_cube_add() + + # 作成されたオブジェクトを取得 + obj = bpy.context.active_object + + # 名前を設定 + if name: + obj.name = name + + return obj + except: + logging.warning(f"オブジェクト '{obj_type}' の作成に失敗しました。") + return None + + def run_operator(self, operator_path, **kwargs): + """Blenderオペレータを実行 + + Args: + operator_path (str): オペレータのパス (例: "object.select_all") + **kwargs: オペレータに渡す引数 + + Returns: + set: オペレータの実行結果 + """ + if not BLENDER_AVAILABLE: + return {"CANCELLED"} + + try: + # オペレータパスを分解 + category, name = operator_path.split(".") + operator = getattr(getattr(bpy.ops, category), name) + + # オペレータを実行 + return operator(**kwargs) + except: + logging.warning(f"オペレータ '{operator_path}' の実行に失敗しました。") + return {"CANCELLED"} diff --git a/tests/blender/integration/__init__.py b/tests/blender/integration/__init__.py new file mode 100644 index 0000000..b1632eb --- /dev/null +++ b/tests/blender/integration/__init__.py @@ -0,0 +1,5 @@ +""" +YWTA Tools Blender統合テストモジュール + +このモジュールは、Blender環境での統合テストを提供します。 +""" diff --git a/tests/blender/performance/__init__.py b/tests/blender/performance/__init__.py new file mode 100644 index 0000000..c5c0216 --- /dev/null +++ b/tests/blender/performance/__init__.py @@ -0,0 +1,5 @@ +""" +YWTA Tools Blenderパフォーマンステストモジュール + +このモジュールは、Blender環境でのパフォーマンステストを提供します。 +""" diff --git a/tests/blender/unit/__init__.py b/tests/blender/unit/__init__.py new file mode 100644 index 0000000..cef66be --- /dev/null +++ b/tests/blender/unit/__init__.py @@ -0,0 +1,5 @@ +""" +YWTA Tools Blender単体テストモジュール + +このモジュールは、Blender環境での単体テストを提供します。 +""" diff --git a/tests/blender/unit/test_sample.py b/tests/blender/unit/test_sample.py new file mode 100644 index 0000000..56704c4 --- /dev/null +++ b/tests/blender/unit/test_sample.py @@ -0,0 +1,116 @@ +""" +Blender用サンプルテスト + +このファイルは、Blender環境でのテスト作成方法を示すサンプルです。 +""" + +import os +import unittest +from tests.blender.blender_test_case import BlenderTestCase + + +class SampleBlenderTests(BlenderTestCase): + """Blender環境でのサンプルテストケース""" + + @classmethod + def setUpClass(cls): + """テストケースクラスの初期化""" + super().setUpClass() + # 必要に応じてアドオンを有効化 + # cls.enable_addon('ywtatools_addon') + + def setUp(self): + """各テスト前の準備""" + super().setUp() + # テスト固有のセットアップ + + def tearDown(self): + """各テスト後のクリーンアップ""" + # テスト固有のクリーンアップ + super().tearDown() + + def test_basic_functionality(self): + """基本機能のテスト""" + # 基本的なアサーション + self.assertEqual(1 + 1, 2) + self.assertTrue(True) + self.assertFalse(False) + + def test_blender_object_creation(self): + """Blenderオブジェクト作成のテスト""" + if not self.BLENDER_AVAILABLE: + self.skipTest("Blender環境が利用できません") + + # テスト用のオブジェクトを作成 + cube = self.create_test_object("MESH", "TestCube") + self.assertIsNotNone(cube) + + # オブジェクトのプロパティを検証 + self.assertEqual(cube.name, "TestCube") + self.assertEqual(cube.type, "MESH") + + # 一時シーンファイルに保存 + temp_scene = self.get_temp_scene("test_cube.blend") + self.save_scene(temp_scene) + + # 新しいシーンを作成して、保存したシーンをロード + self.new_scene() + self.load_scene(temp_scene) + + # ロードされたシーンを検証 + import bpy + + self.assertIn("TestCube", bpy.data.objects) + + def test_blender_operator(self): + """Blenderオペレータのテスト""" + if not self.BLENDER_AVAILABLE: + self.skipTest("Blender環境が利用できません") + + # オペレータを実行 + result = self.run_operator("mesh.primitive_cube_add") + + # 結果を検証 + self.assertEqual(result, {"FINISHED"}) + + import bpy + + # 新しいキューブが作成されたことを確認 + self.assertEqual(len(bpy.context.selected_objects), 1) + self.assertEqual(bpy.context.active_object.type, "MESH") + + def test_with_temp_files(self): + """一時ファイルを使用したテスト""" + # 一時ファイルパスを取得 + temp_file = self.get_temp_filename("test_data.txt") + + # ファイルに書き込み + with open(temp_file, "w") as f: + f.write("Test data") + + # ファイルが存在することを確認 + self.assertTrue(os.path.exists(temp_file)) + + # ファイルから読み込み + with open(temp_file, "r") as f: + content = f.read() + + # 内容を検証 + self.assertEqual(content, "Test data") + + def test_dict_almost_equal(self): + """浮動小数点辞書比較のテスト""" + dict1 = {"a": 1.0001, "b": 2.0002, "c": {"d": 3.0003}} + dict2 = {"a": 1.0002, "b": 2.0003, "c": {"d": 3.0004}} + + # 小数点以下3桁まで一致していればOK + self.assertDictAlmostEqual(dict1, dict2, places=3) + + # 小数点以下4桁では一致しない + with self.assertRaises(AssertionError): + self.assertDictAlmostEqual(dict1, dict2, places=4) + + @unittest.skip("このテストはスキップされます") + def test_skipped(self): + """スキップされるテスト""" + self.fail("このテストは実行されません") diff --git a/tests/common/__init__.py b/tests/common/__init__.py new file mode 100644 index 0000000..22a8e5a --- /dev/null +++ b/tests/common/__init__.py @@ -0,0 +1,6 @@ +""" +YWTA Tools テストフレームワーク共通モジュール + +このモジュールは、Maya、Blenderなど異なるプラットフォーム間で共有される +テスト用のユーティリティと基本クラスを提供します。 +""" diff --git a/tests/common/base_test_case.py b/tests/common/base_test_case.py new file mode 100644 index 0000000..22e8bf5 --- /dev/null +++ b/tests/common/base_test_case.py @@ -0,0 +1,177 @@ +""" +基本テストケースモジュール + +このモジュールは、Maya、Blenderなど異なるプラットフォーム間で共有される +テストケースの基底クラスを提供します。 +""" + +import os +import sys +import unittest +import shutil +import tempfile +import uuid +import logging +from pathlib import Path + +from tests.common.test_settings import TestSettings + + +class BaseTestCase(unittest.TestCase): + """すべてのテストケースの基底クラス + + このクラスは、プラットフォームに依存しない共通のテスト機能を提供します。 + Maya、Blenderなどの特定のプラットフォーム用のテストケースは、 + このクラスを継承して実装されます。 + """ + + # テスト中に作成された一時ファイルのリスト + files_created = [] + + @classmethod + def setUpClass(cls): + """テストケースクラスの初期化""" + super().setUpClass() + + # 一時ディレクトリが存在することを確認 + if not os.path.exists(TestSettings.temp_dir): + os.makedirs(TestSettings.temp_dir) + + @classmethod + def tearDownClass(cls): + """テストケースクラスの終了処理""" + super().tearDownClass() + + # 一時ファイルの削除 + cls.delete_temp_files() + + def setUp(self): + """各テスト前の準備""" + super().setUp() + + # テスト固有の設定があれば実装 + pass + + def tearDown(self): + """各テスト後のクリーンアップ""" + super().tearDown() + + # テスト固有のクリーンアップがあれば実装 + pass + + @classmethod + def delete_temp_files(cls): + """テスト中に作成された一時ファイルを削除""" + if TestSettings.delete_files: + for file_path in cls.files_created: + if os.path.exists(file_path): + if os.path.isdir(file_path): + shutil.rmtree(file_path) + else: + os.remove(file_path) + + cls.files_created = [] + + # 一時ディレクトリの削除 + if os.path.exists(TestSettings.temp_dir): + shutil.rmtree(TestSettings.temp_dir) + + @classmethod + def get_temp_filename(cls, file_name): + """テストディレクトリ内の一意のファイルパス名を取得 + + Args: + file_name (str): ファイル名または相対パス (例: 'test.txt' または 'subdir/test.txt') + + Returns: + str: 一時ファイルへのフルパス + + Note: + ファイルは作成されません。作成は呼び出し元の責任です。 + このファイルはテスト終了時に削除されます。 + """ + temp_dir = TestSettings.temp_dir + if not os.path.exists(temp_dir): + os.makedirs(temp_dir) + + # サブディレクトリがある場合は作成 + subdir = os.path.dirname(file_name) + if subdir: + full_subdir = os.path.join(temp_dir, subdir) + if not os.path.exists(full_subdir): + os.makedirs(full_subdir) + + base_name, ext = os.path.splitext(os.path.basename(file_name)) + path = os.path.join(temp_dir, subdir, f"{base_name}{ext}") + + # 同名ファイルが存在する場合は連番を付与 + count = 0 + while os.path.exists(path): + count += 1 + path = os.path.join(temp_dir, subdir, f"{base_name}{count}{ext}") + + cls.files_created.append(path) + return path + + @classmethod + def get_temp_dir(cls, dir_name): + """テストディレクトリ内の一意のディレクトリパスを取得して作成 + + Args: + dir_name (str): ディレクトリ名または相対パス + + Returns: + str: 作成された一時ディレクトリへのフルパス + """ + temp_dir = TestSettings.temp_dir + if not os.path.exists(temp_dir): + os.makedirs(temp_dir) + + path = os.path.join(temp_dir, dir_name) + + # 同名ディレクトリが存在する場合は連番を付与 + count = 0 + while os.path.exists(path): + count += 1 + path = os.path.join(temp_dir, f"{dir_name}{count}") + + os.makedirs(path) + cls.files_created.append(path) + return path + + def assertListAlmostEqual(self, first, second, places=7, msg=None, delta=None): + """浮動小数点値のリストがほぼ等しいことをアサート + + Args: + first (list): 最初のリスト + second (list): 2番目のリスト + places (int, optional): 小数点以下の桁数。デフォルトは7。 + msg (str, optional): カスタムエラーメッセージ + delta (float, optional): 許容される最大差 + """ + self.assertEqual(len(first), len(second), msg) + for a, b in zip(first, second): + self.assertAlmostEqual(a, b, places, msg, delta) + + def assertDictAlmostEqual(self, first, second, places=7, msg=None, delta=None): + """浮動小数点値を含む辞書がほぼ等しいことをアサート + + Args: + first (dict): 最初の辞書 + second (dict): 2番目の辞書 + places (int, optional): 小数点以下の桁数。デフォルトは7。 + msg (str, optional): カスタムエラーメッセージ + delta (float, optional): 許容される最大差 + """ + self.assertEqual(set(first.keys()), set(second.keys()), msg) + for key in first: + if isinstance(first[key], (float, int)) and isinstance( + second[key], (float, int) + ): + self.assertAlmostEqual(first[key], second[key], places, msg, delta) + elif isinstance(first[key], list) and isinstance(second[key], list): + self.assertListAlmostEqual(first[key], second[key], places, msg, delta) + elif isinstance(first[key], dict) and isinstance(second[key], dict): + self.assertDictAlmostEqual(first[key], second[key], places, msg, delta) + else: + self.assertEqual(first[key], second[key], msg) diff --git a/tests/common/test_settings.py b/tests/common/test_settings.py new file mode 100644 index 0000000..e93c889 --- /dev/null +++ b/tests/common/test_settings.py @@ -0,0 +1,121 @@ +""" +テスト設定モジュール + +このモジュールは、テスト実行に関する設定と環境変数を管理します。 +""" + +import os +import sys +import tempfile +import uuid +import logging +from pathlib import Path + + +class TestSettings: + """テスト実行のためのグローバル設定を管理するクラス""" + + # テスト中に生成される一時ファイルの保存場所 + # 並行実行時の競合を避けるためにUUIDサブディレクトリを使用 + temp_dir = os.path.join(tempfile.gettempdir(), "ywtatools_tests", str(uuid.uuid4())) + + # テスト終了後に一時ファイルを削除するかどうか + delete_files = True + + # テスト実行中に標準出力と標準エラーをバッファリングするかどうか + # 成功したテストの出力は破棄され、失敗したテストの出力のみが表示される + buffer_output = True + + # テスト間で新しいシーンを作成するかどうか(Maya/Blender固有) + new_scene_between_tests = True + + # テスト実行環境('maya', 'blender', 'standalone') + environment = "standalone" + + # テスト実行モード('unit', 'integration', 'performance') + test_mode = "unit" + + # プロジェクトのルートディレクトリ + @classmethod + def get_project_root(cls): + """プロジェクトのルートディレクトリを取得""" + # このファイルの場所から相対的にプロジェクトルートを特定 + return str(Path(__file__).parent.parent.parent.absolute()) + + @classmethod + def get_maya_scripts_dir(cls): + """Mayaスクリプトディレクトリを取得""" + return os.path.join(cls.get_project_root(), "maya") + + @classmethod + def get_blender_addons_dir(cls): + """Blenderアドオンディレクトリを取得""" + return os.path.join(cls.get_project_root(), "blender", "addons") + + @classmethod + def setup_environment(cls, env_type): + """テスト環境をセットアップ + + Args: + env_type (str): 環境タイプ ('maya', 'blender', 'standalone') + """ + cls.environment = env_type + + # 環境に応じたパス設定 + if env_type == "maya": + # Mayaモジュールパスを追加 + maya_path = cls.get_maya_scripts_dir() + if maya_path not in sys.path: + sys.path.insert(0, maya_path) + + elif env_type == "blender": + # Blenderアドオンパスを追加 + blender_path = cls.get_blender_addons_dir() + if blender_path not in sys.path: + sys.path.insert(0, blender_path) + + @classmethod + def set_temp_dir(cls, directory): + """テストから生成されたファイルを保存する場所を設定 + + Args: + directory (str): ディレクトリパス + """ + if os.path.exists(directory): + cls.temp_dir = directory + else: + raise RuntimeError(f"{directory} が存在しません。") + + @classmethod + def set_delete_files(cls, value): + """テスト終了後に一時ファイルを削除するかどうかを設定 + + Args: + value (bool): 削除する場合はTrue + """ + cls.delete_files = value + + @classmethod + def set_buffer_output(cls, value): + """テスト実行中に出力をバッファリングするかどうかを設定 + + Args: + value (bool): バッファリングする場合はTrue + """ + cls.buffer_output = value + + if value: + # テスト実行中のログを無効化 + logging.disable(logging.CRITICAL) + else: + # ログ状態を復元 + logging.disable(logging.NOTSET) + + @classmethod + def set_new_scene_between_tests(cls, value): + """テスト間で新しいシーンを作成するかどうかを設定 + + Args: + value (bool): 新しいシーンを作成する場合はTrue + """ + cls.new_scene_between_tests = value diff --git a/tests/maya/__init__.py b/tests/maya/__init__.py new file mode 100644 index 0000000..a33bab7 --- /dev/null +++ b/tests/maya/__init__.py @@ -0,0 +1,5 @@ +""" +YWTA Tools Maya テストモジュール + +このモジュールは、Maya用のテストフレームワークを提供します。 +""" diff --git a/tests/maya/integration/__init__.py b/tests/maya/integration/__init__.py new file mode 100644 index 0000000..51be34a --- /dev/null +++ b/tests/maya/integration/__init__.py @@ -0,0 +1,5 @@ +""" +YWTA Tools Maya統合テストモジュール + +このモジュールは、Maya環境での統合テストを提供します。 +""" diff --git a/tests/maya/maya_test_case.py b/tests/maya/maya_test_case.py new file mode 100644 index 0000000..417e978 --- /dev/null +++ b/tests/maya/maya_test_case.py @@ -0,0 +1,214 @@ +""" +Maya テストケースモジュール + +このモジュールは、Maya環境でのテスト実行のための基底クラスを提供します。 +""" + +import os +import sys +import unittest +import tempfile +import uuid +import logging + +try: + import maya.standalone + + # Mayaのスタンドアロンモードを初期化 + maya.standalone.initialize(name="python") + # MayaのコマンドとMELをインポート + import maya.cmds as cmds + import maya.mel as mel + + MAYA_AVAILABLE = True +except ImportError: + MAYA_AVAILABLE = False + +from tests.common.base_test_case import BaseTestCase +from tests.common.test_settings import TestSettings + + +class MayaTestCase(BaseTestCase): + """Maya環境でのテスト実行のための基底クラス + + このクラスは、Maya固有のテスト機能を提供します。 + Maya APIやコマンドを使用するテストは、このクラスを継承して実装します。 + """ + + # ロードされたプラグインのリスト + plugins_loaded = set() + + @classmethod + def setUpClass(cls): + """テストケースクラスの初期化""" + super().setUpClass() + + # Maya環境のセットアップ + TestSettings.setup_environment("maya") + + # Mayaが利用可能かチェック + if not MAYA_AVAILABLE: + raise unittest.SkipTest("Maya環境が利用できません。") + + @classmethod + def tearDownClass(cls): + """テストケースクラスの終了処理""" + # プラグインのアンロード + cls.unload_plugins() + + if float(cmds.about(v=True)) >= 2016.0: + maya.standalone.uninitialize() + + super().tearDownClass() + + def setUp(self): + """各テスト前の準備""" + super().setUp() + + # 新しいシーンを作成 + if TestSettings.new_scene_between_tests: + self.new_scene() + + def tearDown(self): + """各テスト後のクリーンアップ""" + super().tearDown() + + @classmethod + def load_plugin(cls, plugin_name): + """プラグインをロードし、テスト終了時にアンロードするために記録 + + Args: + plugin_name (str): プラグイン名 + + Returns: + bool: ロードに成功した場合はTrue + """ + if not MAYA_AVAILABLE: + return False + + try: + if not cmds.pluginInfo(plugin_name, q=True, loaded=True): + cmds.loadPlugin(plugin_name, quiet=True) + cls.plugins_loaded.add(plugin_name) + return True + except: + logging.warning(f"プラグイン '{plugin_name}' のロードに失敗しました。") + return False + + @classmethod + def unload_plugins(cls): + """このテストケースでロードされたすべてのプラグインをアンロード""" + if not MAYA_AVAILABLE: + return + + for plugin in cls.plugins_loaded: + try: + if cmds.pluginInfo(plugin, q=True, loaded=True): + cmds.unloadPlugin(plugin, force=True) + except: + logging.warning(f"プラグイン '{plugin}' のアンロードに失敗しました。") + + cls.plugins_loaded.clear() + + def new_scene(self): + """新しいMayaシーンを作成""" + if MAYA_AVAILABLE: + cmds.file(new=True, force=True) + + def load_scene(self, file_path): + """Mayaシーンをロード + + Args: + file_path (str): シーンファイルのパス + + Returns: + bool: ロードに成功した場合はTrue + """ + if not MAYA_AVAILABLE: + return False + + try: + cmds.file(file_path, open=True, force=True) + return True + except: + logging.warning(f"シーンファイル '{file_path}' のロードに失敗しました。") + return False + + def save_scene(self, file_path): + """現在のMayaシーンを保存 + + Args: + file_path (str): 保存先のパス + + Returns: + bool: 保存に成功した場合はTrue + """ + if not MAYA_AVAILABLE: + return False + + try: + # ファイル拡張子に基づいてファイル形式を決定 + ext = os.path.splitext(file_path)[1].lower() + if ext == ".mb": + file_type = "mayaBinary" + else: + file_type = "mayaAscii" + + cmds.file(rename=file_path) + cmds.file(save=True, type=file_type) + return True + except: + logging.warning(f"シーンの保存に失敗しました: '{file_path}'") + return False + + def get_temp_scene(self, scene_name="test_scene.ma"): + """テスト用の一時シーンファイルパスを取得 + + Args: + scene_name (str, optional): シーンファイル名。デフォルトは "test_scene.ma" + + Returns: + str: 一時シーンファイルへのフルパス + """ + return self.get_temp_filename(scene_name) + + def create_test_node(self, node_type, name=None): + """テスト用のノードを作成 + + Args: + node_type (str): ノードタイプ + name (str, optional): ノード名 + + Returns: + str: 作成されたノードの名前 + """ + if not MAYA_AVAILABLE: + return None + + kwargs = {} + if name: + kwargs["name"] = name + + try: + return cmds.createNode(node_type, **kwargs) + except: + logging.warning(f"ノード '{node_type}' の作成に失敗しました。") + return None + + def eval_mel(self, mel_script): + """MELスクリプトを評価 + + Args: + mel_script (str): 実行するMELスクリプト + + Returns: + object: MEL評価の結果 + """ + if not MAYA_AVAILABLE: + return None + + try: + return mel.eval(mel_script) + except: + logging.warning(f"MELスクリプトの実行に失敗しました: '{mel_script}'") + return None diff --git a/tests/maya/performance/__init__.py b/tests/maya/performance/__init__.py new file mode 100644 index 0000000..fef5222 --- /dev/null +++ b/tests/maya/performance/__init__.py @@ -0,0 +1,5 @@ +""" +YWTA Tools Mayaパフォーマンステストモジュール + +このモジュールは、Maya環境でのパフォーマンステストを提供します。 +""" diff --git a/tests/maya/unit/__init__.py b/tests/maya/unit/__init__.py new file mode 100644 index 0000000..373fedb --- /dev/null +++ b/tests/maya/unit/__init__.py @@ -0,0 +1,5 @@ +""" +YWTA Tools Maya単体テストモジュール + +このモジュールは、Maya環境での単体テストを提供します。 +""" diff --git a/tests/maya/unit/test_blendshape.py b/tests/maya/unit/test_blendshape.py new file mode 100644 index 0000000..5525e58 --- /dev/null +++ b/tests/maya/unit/test_blendshape.py @@ -0,0 +1,175 @@ +import unittest +import os +import maya.cmds as cmds +import ywta.deform.blendshape as bs + +from ywta.test import TestCase + + +class BlendShapeTests(TestCase): + def test_get_blendshape_on_new_shape(self): + shape = cmds.polyCube()[0] + blendshape = bs.get_or_create_blendshape_node(shape) + self.assertTrue(cmds.objExists(blendshape)) + blendshapes = cmds.ls(type="blendShape") + self.assertEqual(len(blendshapes), 1) + self.assertEqual(blendshapes[0], blendshape) + + blendshape = bs.get_or_create_blendshape_node(shape) + blendshapes = cmds.ls(type="blendShape") + self.assertEqual(len(blendshapes), 1) + self.assertEqual(blendshapes[0], blendshape) + + def test_get_blendshape_on_existing_blendshape(self): + shape = cmds.polyCube()[0] + blendshape = cmds.blendShape(shape)[0] + existing_blendshape = bs.get_or_create_blendshape_node(shape) + self.assertEqual(blendshape, existing_blendshape) + + def test_get_blendshape_node(self): + """Test getting a blendshape node from geometry""" + # Create a mesh with no blendshape + shape = cmds.polyCube()[0] + # Should return None when no blendshape exists + self.assertIsNone(bs.get_blendshape_node(shape)) + + # Create a blendshape + blendshape = cmds.blendShape(shape)[0] + # Should return the blendshape node + self.assertEqual(bs.get_blendshape_node(shape), blendshape) + + def test_add_target_and_get_target_list(self): + """Test adding targets to a blendshape and getting the target list""" + # Create base mesh and blendshape + base_mesh = cmds.polyCube(name="base_mesh")[0] + blendshape = bs.get_or_create_blendshape_node(base_mesh) + + # Create target meshes + target1 = cmds.polyCube(name="target1")[0] + target2 = cmds.polyCube(name="target2")[0] + + # Add targets to blendshape + bs.add_target(blendshape, target1) + bs.add_target(blendshape, target2, new_target_name="renamed_target") + + # Get target list + targets = bs.get_target_list(blendshape) + + # Verify targets were added correctly + self.assertEqual(len(targets), 2) + self.assertIn("target1", targets) + self.assertIn("renamed_target", targets) + self.assertNotIn("target2", targets) # Should be renamed + + def test_get_target_index(self): + """Test getting the index of a target in a blendshape""" + # Create base mesh and blendshape + base_mesh = cmds.polyCube()[0] + blendshape = bs.get_or_create_blendshape_node(base_mesh) + + # Create and add targets + target1 = cmds.polyCube(name="target1")[0] + target2 = cmds.polyCube(name="target2")[0] + + # Add targets with specific indices + index1 = bs.add_target(blendshape, target1) + index2 = bs.add_target(blendshape, target2) + + # Verify indices + self.assertEqual(bs.get_target_index(blendshape, "target1"), index1) + self.assertEqual(bs.get_target_index(blendshape, "target2"), index2) + + # Test non-existent target + with self.assertRaises(RuntimeError): + bs.get_target_index(blendshape, "non_existent_target") + + def test_find_replace_target_names(self): + """Test finding and replacing text in target names""" + # Create base mesh and blendshape + base_mesh = cmds.polyCube()[0] + blendshape = bs.get_or_create_blendshape_node(base_mesh) + + # Create and add targets with specific naming pattern + target1 = cmds.polyCube(name="prefix_name_suffix")[0] + target2 = cmds.polyCube(name="prefix_other_suffix")[0] + target3 = cmds.polyCube(name="different_name")[0] + + bs.add_target(blendshape, target1) + bs.add_target(blendshape, target2) + bs.add_target(blendshape, target3) + + # Test case-sensitive replacement + renamed = bs.find_replace_target_names( + blendshape, "prefix_", "new_", case_sensitive=True + ) + + # Verify renamed targets + targets = bs.get_target_list(blendshape) + self.assertIn("new_name_suffix", targets) + self.assertIn("new_other_suffix", targets) + self.assertIn("different_name", targets) # Should not be renamed + + # Verify returned dictionary + self.assertEqual(len(renamed), 2) + self.assertEqual(renamed["prefix_name_suffix"], "new_name_suffix") + self.assertEqual(renamed["prefix_other_suffix"], "new_other_suffix") + + def test_find_replace_target_names_case_insensitive(self): + """Test finding and replacing text in target names with case insensitivity""" + # Create base mesh and blendshape + base_mesh = cmds.polyCube()[0] + blendshape = bs.get_or_create_blendshape_node(base_mesh) + + # Create and add targets with mixed case + target1 = cmds.polyCube(name="Prefix_name")[0] + target2 = cmds.polyCube(name="prefix_Other")[0] + + bs.add_target(blendshape, target1) + bs.add_target(blendshape, target2) + + # Test case-insensitive replacement + renamed = bs.find_replace_target_names( + blendshape, "prefix", "NEW", case_sensitive=False + ) + + # Verify renamed targets + targets = bs.get_target_list(blendshape) + self.assertIn("NEW_name", targets) + self.assertIn("NEW_Other", targets) + + # Verify returned dictionary + self.assertEqual(len(renamed), 2) + self.assertEqual(renamed["Prefix_name"], "NEW_name") + self.assertEqual(renamed["prefix_Other"], "NEW_Other") + + def test_find_replace_target_names_regex(self): + """Test finding and replacing text in target names using regex""" + # Create base mesh and blendshape + base_mesh = cmds.polyCube()[0] + blendshape = bs.get_or_create_blendshape_node(base_mesh) + + # Create and add targets with specific naming pattern + target1 = cmds.polyCube(name="left_eye_blink")[0] + target2 = cmds.polyCube(name="left_brow_up")[0] + target3 = cmds.polyCube(name="right_eye_blink")[0] + + bs.add_target(blendshape, target1) + bs.add_target(blendshape, target2) + bs.add_target(blendshape, target3) + + # Test regex replacement (swap left/right) + renamed = bs.find_replace_target_names_regex( + blendshape, r"(left|right)_(.+)", r"side_\2_\1" + ) + + # Verify renamed targets + targets = bs.get_target_list(blendshape) + self.assertIn("side_eye_blink_left", targets) + self.assertIn("side_brow_up_left", targets) + self.assertIn("side_eye_blink_right", targets) + + # Verify returned dictionary + self.assertEqual(len(renamed), 3) + self.assertEqual(renamed["left_eye_blink"], "side_eye_blink_left") + self.assertEqual(renamed["left_brow_up"], "side_brow_up_left") + self.assertEqual(renamed["right_eye_blink"], "side_eye_blink_right") diff --git a/tests/maya/unit/test_config.py b/tests/maya/unit/test_config.py new file mode 100644 index 0000000..99187f8 --- /dev/null +++ b/tests/maya/unit/test_config.py @@ -0,0 +1,183 @@ +""" +Configuration System Test + +新しい設定システムの基本的なテストを行います。 +""" + +import os +import json +import maya.cmds as cmds +from pathlib import Path + +from ywta.test import TestCase +from ywta.config.settings_manager import ( + SettingsManager, + get_settings_manager, + get_setting, + set_setting, + reset_setting, + export_settings, + import_settings, + save_settings, + add_callback, + remove_callback, +) +from ywta.config.base_config import ConfigValue, ValidationError + + +class ConfigTests(TestCase): + """設定システムのテストケース""" + + def setUp(self): + """各テスト前の準備""" + # 一時ファイルでデフォルト設定とユーザー設定を作成 + self.temp_default_config = self.get_temp_filename("default_config.json") + self.temp_user_config = self.get_temp_filename("user_config.json") + self.temp_export_config = self.get_temp_filename("export_config.json") + + # デフォルト設定を作成 + default_config = { + "documentation": {"root_url": "https://example.com"}, + "plugins": {"enable_cpp_plugins": True}, + "ui": {"icon_size": 24, "theme": {"primary_color": "#3498db"}}, + "rig": {"default_control_color": 13}, + } + with open(self.temp_default_config, "w", encoding="utf-8") as f: + json.dump(default_config, f) + + # グローバルインスタンスをリセット + SettingsManager._instance = None + + def tearDown(self): + """各テスト後のクリーンアップ""" + # グローバルインスタンスをリセット + SettingsManager._instance = None + + def test_basic_functionality(self): + """基本機能のテスト""" + # 設定マネージャーを作成 + settings = SettingsManager(self.temp_user_config, self.temp_default_config) + + # デフォルト値の確認 + self.assertEqual("", settings.DOCUMENTATION_ROOT) + self.assertEqual(True, settings.ENABLE_PLUGINS) + self.assertEqual(24, settings.get("ui.icon_size")) + self.assertEqual(13, settings.get("rig.default_control_color")) + + # 設定値の変更 + settings.DOCUMENTATION_ROOT = "https://example.com/docs" + settings.ENABLE_PLUGINS = False + settings.set("ui.icon_size", 32) + settings.set("rig.default_control_color", 17) + + # 変更後の値を確認 + self.assertEqual("https://example.com/docs", settings.DOCUMENTATION_ROOT) + self.assertEqual(False, settings.ENABLE_PLUGINS) + self.assertEqual(32, settings.get("ui.icon_size")) + self.assertEqual(17, settings.get("rig.default_control_color")) + + # 新しい設定値の追加 + settings.set("test.value", "test_data") + self.assertEqual("test_data", settings.get("test.value")) + + # 設定ファイルに保存 + settings.save_config() + + # 新しいインスタンスで読み込み確認 + settings2 = SettingsManager(self.temp_user_config, self.temp_default_config) + self.assertEqual("https://example.com/docs", settings2.DOCUMENTATION_ROOT) + self.assertEqual(False, settings2.ENABLE_PLUGINS) + self.assertEqual(32, settings2.get("ui.icon_size")) + self.assertEqual(17, settings2.get("rig.default_control_color")) + self.assertEqual("test_data", settings2.get("test.value")) + + # 変更された設定値の取得 + modified_settings = settings2.get_modified_settings() + self.assertIn("documentation.root_url", modified_settings) + self.assertIn("plugins.enable_cpp_plugins", modified_settings) + self.assertIn("ui.icon_size", modified_settings) + self.assertIn("rig.default_control_color", modified_settings) + + def test_environment_variables(self): + """環境変数テスト""" + # 環境変数を設定 + os.environ["YWTA_DOCUMENTATION_ROOT"] = "https://env.example.com" + os.environ["YWTA_PLUGINS_ENABLE_CPP_PLUGINS"] = "false" + os.environ["YWTA_UI_ICON_SIZE"] = "48" + + try: + # ユーザー設定を作成 + user_config = { + "documentation": {"root_url": "https://user.example.com"}, + "ui": {"icon_size": 32}, + } + with open(self.temp_user_config, "w", encoding="utf-8") as f: + json.dump(user_config, f) + + settings = SettingsManager(self.temp_user_config, self.temp_default_config) + + # 環境変数が優先されることを確認 + self.assertEqual("https://env.example.com", settings.DOCUMENTATION_ROOT) + self.assertEqual(False, settings.ENABLE_PLUGINS) + self.assertEqual(48, settings.get("ui.icon_size")) + + finally: + # 環境変数をクリア + os.environ.pop("YWTA_DOCUMENTATION_ROOT", None) + os.environ.pop("YWTA_PLUGINS_ENABLE_CPP_PLUGINS", None) + os.environ.pop("YWTA_UI_ICON_SIZE", None) + + def test_qsettings_compatibility(self): + """QSettings互換性テスト""" + settings = get_settings_manager() + + # QSettings互換メソッドのテスト + settings.setValue("test.qsettings", "test_value") + value = settings.value("test.qsettings", "default") + + self.assertEqual("test_value", value) + + def test_callbacks(self): + """コールバック機能のテスト""" + # グローバルインスタンスを設定 + global_settings = SettingsManager( + self.temp_user_config, self.temp_default_config + ) + SettingsManager._instance = global_settings + + # コールバック関数 + callback_results = [] + + def on_setting_changed(key, value): + callback_results.append((key, value)) + + # コールバックを登録 + add_callback("ui.theme.primary_color", on_setting_changed) + add_callback("ui.icon_size", on_setting_changed) + + # 設定値を変更 + set_setting("ui.theme.primary_color", "#ff5733") + set_setting("ui.icon_size", 48) + set_setting("documentation.root_url", "https://example.com") # コールバックなし + + # コールバックが呼び出されたことを確認 + self.assertEqual(2, len(callback_results)) + self.assertEqual("ui.theme.primary_color", callback_results[0][0]) + self.assertEqual("#ff5733", callback_results[0][1]) + self.assertEqual("ui.icon_size", callback_results[1][0]) + self.assertEqual(48, callback_results[1][1]) + + # コールバックを削除 + remove_callback("ui.theme.primary_color", on_setting_changed) + + # 設定値を再度変更 + set_setting("ui.theme.primary_color", "#3498db") + set_setting("ui.icon_size", 24) + + # 削除したコールバックは呼び出されないことを確認 + self.assertEqual(3, len(callback_results)) + self.assertEqual("ui.icon_size", callback_results[2][0]) + self.assertEqual(24, callback_results[2][1]) + + # def test_save_settings(self): + # """設定の永続化機能のテスト""" diff --git a/tests/maya/unit/test_skeleton.py b/tests/maya/unit/test_skeleton.py new file mode 100644 index 0000000..1cbedfb --- /dev/null +++ b/tests/maya/unit/test_skeleton.py @@ -0,0 +1,60 @@ +import maya.cmds as cmds +import ywta.rig.skeleton as skeleton +from ywta.test import TestCase + + +class SkeletonTests(TestCase): + def setUp(self): + self.group = cmds.createNode("transform", name="skeleton_grp") + cmds.select(cl=True) + j1 = cmds.joint(p=(0, 10, 0)) + cmds.joint(p=(1, 9, 0)) + cmds.joint(p=(2, 8, 0)) + j = cmds.joint(p=(3, 9, 0)) + cmds.joint(p=(4, 6, 0)) + cmds.joint(p=(5, 5, 0)) + cmds.joint(p=(6, 3, 0)) + self.cube = cmds.polyCube()[0] + cmds.parent(self.cube, j) + cmds.parent(j1, self.group) + + cmds.joint(j1, e=True, oj="xyz", secondaryAxisOrient="yup", ch=True, zso=True) + self.translates = [ + cmds.getAttr("{0}.t".format(x))[0] for x in cmds.ls(type="joint") + ] + self.rotates = [ + cmds.getAttr("{0}.r".format(x))[0] for x in cmds.ls(type="joint") + ] + self.orients = [ + cmds.getAttr("{0}.jo".format(x))[0] for x in cmds.ls(type="joint") + ] + + def test_get_and_rebuild_data(self): + data = skeleton.dumps(self.group) + cmds.file(new=True, f=True) + skeleton.create(data) + self.assert_hierarachies_match() + + def test_export_and_import_data(self): + json_file = self.get_temp_filename("skeleton.json") + skeleton.dump(self.group, json_file) + cmds.file(new=True, f=True) + skeleton.load(json_file) + self.assert_hierarachies_match() + + def assert_hierarachies_match(self): + self.assertEqual(7, len(cmds.ls(type="joint"))) + # Make sure the joint orients are the same + translates = [cmds.getAttr("{0}.t".format(x))[0] for x in cmds.ls(type="joint")] + rotates = [cmds.getAttr("{0}.r".format(x))[0] for x in cmds.ls(type="joint")] + orients = [cmds.getAttr("{0}.jo".format(x))[0] for x in cmds.ls(type="joint")] + for orient, new_orient in zip(self.orients, orients): + self.assertListAlmostEqual(orient, new_orient) + for translate, new_translate in zip(self.translates, translates): + self.assertListAlmostEqual(translate, new_translate) + for rotate, new_rotate in zip(self.rotates, rotates): + self.assertListAlmostEqual(rotate, new_rotate) + # The geometry should not have been exported + self.assertFalse(cmds.objExists(self.cube)) + self.assertTrue(cmds.objExists(self.group)) + self.assertEqual("joint1", cmds.listRelatives(self.group, children=True)[0]) diff --git a/tests/run_blender_tests.py b/tests/run_blender_tests.py new file mode 100644 index 0000000..6b2ab6a --- /dev/null +++ b/tests/run_blender_tests.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python +""" +YWTA Tools Blender テスト実行スクリプト + +このスクリプトは、Blender環境でテストを実行するためのエントリーポイントを提供します。 +Blender内から実行するか、blenderコマンドラインから実行します。 +""" + +import os +import sys +import argparse +from pathlib import Path + +# プロジェクトルートをPythonパスに追加 +project_root = str(Path(__file__).parent.parent.absolute()) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from tests.common.test_settings import TestSettings +from tests.utils.test_runner import run_blender_tests + + +def main(): + """Blender用テスト実行のメイン関数""" + parser = argparse.ArgumentParser(description="YWTA Tools Blender テスト実行ツール") + + # テストタイプの選択 + parser.add_argument( + "--type", + choices=["unit", "integration", "performance"], + default="unit", + help="テストタイプ (デフォルト: unit)", + ) + + # テストディレクトリの指定 + parser.add_argument("--dir", help="テストを検索するディレクトリ") + + # テストファイルパターンの指定 + parser.add_argument( + "--pattern", + default="test_*.py", + help="テストファイルのパターン (デフォルト: test_*.py)", + ) + + # 出力バッファリングの指定 + parser.add_argument( + "--no-buffer", action="store_true", help="出力バッファリングを無効にする" + ) + + args = parser.parse_args() + + # 環境設定 + TestSettings.environment = "blender" + TestSettings.test_mode = args.type + TestSettings.buffer_output = not args.no_buffer + + # テストディレクトリが指定された場合 + if args.dir: + test_dir = args.dir + else: + # デフォルトのテストディレクトリをタイプに基づいて決定 + test_dir = os.path.join( + TestSettings.get_project_root(), "tests", "blender", args.type + ) + + # Blender用のテスト実行 + result = run_blender_tests(test_dir, args.pattern) + + # 終了コードの設定 + sys.exit(0 if result.wasSuccessful() else 1) + + +if __name__ == "__main__": + # Blender環境内で実行されているかチェック + try: + import bpy + + main() + except ImportError: + # Blender環境外で実行された場合は警告を表示 + print("警告: このスクリプトはBlender環境内で実行する必要があります。") + print("例: blender -b -P tests/run_blender_tests.py") + sys.exit(1) diff --git a/tests/run_maya_tests.py b/tests/run_maya_tests.py new file mode 100644 index 0000000..3624b02 --- /dev/null +++ b/tests/run_maya_tests.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python +""" +YWTA Tools Maya テスト実行スクリプト + +このスクリプトは、Maya環境でテストを実行するためのエントリーポイントを提供します。 +Maya内から実行するか、mayapy.exeを使用して実行します。 +""" + +import os +import platform +import subprocess +import sys +import argparse +from pathlib import Path + +# プロジェクトルートをPythonパスに追加 +YWTA_ROOT_DIR = str(Path(__file__).parent.parent.absolute()) +if YWTA_ROOT_DIR not in sys.path: + sys.path.insert(0, YWTA_ROOT_DIR) + +from tests.common.test_settings import TestSettings +from tests.utils.test_runner import run_maya_tests + + +def get_maya_location(maya_version: int) -> Path: + """Mayaがインストールされている場所を取得します。 + + Args: + maya_version: Mayaのバージョン番号 + + Returns: + Mayaがインストールされているパス + + Examples: + >>> get_maya_location(2024) + Path('C:/Program Files/Autodesk/Maya2024') + """ + if "MAYA_LOCATION" in os.environ: + return Path(os.environ["MAYA_LOCATION"]) + + if platform.system() == "Windows": + return Path(f"C:\\Program Files\\Autodesk\\Maya{maya_version}") + elif platform.system() == "Darwin": + return Path(f"/Applications/Autodesk/maya{maya_version}/Maya.app/Contents") + else: + location = f"/usr/autodesk/maya{maya_version}" + if maya_version < 2016: + # 2016以降、デフォルトのインストールディレクトリ名が変更されました + location += "-x64" + return Path(location) + + +def mayapy(maya_version: int) -> Path: + """mayapy実行ファイルのパスを取得します。 + + Args: + maya_version: Mayaのバージョン番号 + + Returns: + mayapy実行ファイルのパス + + Examples: + >>> mayapy(2024) + Path('C:/Program Files/Autodesk/Maya2024/bin/mayapy.exe') + """ + python_exe = get_maya_location(maya_version) / "bin" / "mayapy" + if platform.system() == "Windows": + python_exe = python_exe.with_suffix(".exe") + return python_exe + + +def main(): + """Maya用テスト実行のメイン関数""" + parser = argparse.ArgumentParser(description="YWTA Tools Maya テスト実行ツール") + + # テストファイルパターンの指定 + parser.add_argument( + "--pattern", + default="test_*.py", + help="テストファイルのパターン (デフォルト: test_*.py)", + ) + + # Mayaバージョンの指定 + parser.add_argument( + "-m", + "--maya", + type=int, + default=2024, + help="Mayaのバージョン (デフォルト: 2024)", + ) + + args = parser.parse_args() + + # プロジェクトルートを取 + mayapy_path = mayapy(args.maya) + + maya_unit_test = os.path.join( + YWTA_ROOT_DIR, "maya", "ywta", "test", "maya_unit_test.py" + ) + + # mayapyを使用してテストを実行 + command = [ + str(mayapy_path), + str(maya_unit_test), + "--maya", + str(args.maya), + "--pattern", + args.pattern, + ] + os.environ["MAYA_SCRIPT_PATH"] = "" + os.environ["MAYA_MODULE_PATH"] = YWTA_ROOT_DIR + + try: + subprocess.check_call(command) + except subprocess.CalledProcessError as e: + print(f"テストの実行に失敗しました: {e}") + sys.exit(1) + + print("テストが正常に実行されました。") + + +if __name__ == "__main__": + main() diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..ce9d099 --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +""" +YWTA Tools テスト実行スクリプト + +このスクリプトは、コマンドラインからテストを実行するためのエントリーポイントを提供します。 +""" + +import os +import sys +import argparse +from pathlib import Path + +# プロジェクトルートをPythonパスに追加 +project_root = str(Path(__file__).parent.parent.absolute()) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from tests.run_maya_tests import run_maya_tests +from tests.run_blender_tests import run_blender_tests + + +def main(): + parser = argparse.ArgumentParser(description="YWTA Tools テスト実行スクリプト") + + args = parser.parse_args() + + run_maya_tests( + maya_version=2024, # Mayaのバージョンを指定 + test_dir=os.path.join(project_root, "tests", "maya", "unit"), + pattern="test_*.py", # テストファイルのパターン + ) + # blender_testsはまだ実装されていないため、コメントアウトしています + # run_blender_tests(verbosity=args.verbosity) + + +if __name__ == "__main__": + main() diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..aa6e534 --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,5 @@ +""" +YWTA Tools テストユーティリティモジュール + +このモジュールは、テスト実行のためのユーティリティ関数を提供します。 +""" diff --git a/tests/utils/test_runner.py b/tests/utils/test_runner.py new file mode 100644 index 0000000..453cb89 --- /dev/null +++ b/tests/utils/test_runner.py @@ -0,0 +1,201 @@ +""" +テスト実行ユーティリティモジュール + +このモジュールは、テストの検出と実行のためのユーティリティ関数を提供します。 +""" + +import os +import sys +import unittest +import argparse +import logging +import importlib +from pathlib import Path + +from tests.common.test_settings import TestSettings + + +def discover_tests(test_dir, pattern="test_*.py"): + """指定されたディレクトリからテストを検出 + + Args: + test_dir (str): テストを検索するディレクトリ + pattern (str, optional): テストファイルのパターン。デフォルトは "test_*.py" + + Returns: + unittest.TestSuite: 検出されたテストのTestSuite + """ + loader = unittest.TestLoader() + suite = loader.discover(test_dir, pattern=pattern) + return suite + + +def run_tests(test_suite, verbosity=2, buffer=True): + """テストスイートを実行 + + Args: + test_suite (unittest.TestSuite): 実行するテストスイート + verbosity (int, optional): 詳細レベル。デフォルトは2 + buffer (bool, optional): 出力をバッファリングするかどうか。デフォルトはTrue + + Returns: + unittest.TestResult: テスト実行結果 + """ + runner = unittest.TextTestRunner(verbosity=verbosity, buffer=buffer) + runner.failfast = False # エラーが発生した場合はすぐに停止 + return runner.run(test_suite) + + +def run_specific_test(test_path): + """特定のテストを実行 + + Args: + test_path (str): テストパス (例: "tests.maya.unit.test_config.ConfigTests.test_basic") + + Returns: + unittest.TestResult: テスト実行結果 + """ + loader = unittest.TestLoader() + try: + suite = loader.loadTestsFromName(test_path) + return run_tests(suite) + except (ImportError, AttributeError) as e: + logging.error(f"テスト '{test_path}' のロードに失敗しました: {e}") + return None + + +def run_maya_tests(test_dir=None, pattern="test_*.py"): + """Maya用のテストを実行 + + Args: + test_dir (str, optional): テストディレクトリ。指定されない場合は tests/maya/unit + pattern (str, optional): テストファイルのパターン。デフォルトは "test_*.py" + + Returns: + unittest.TestResult: テスト実行結果 + """ + # Maya環境のセットアップ + TestSettings.setup_environment("maya") + + # テストディレクトリが指定されていない場合はデフォルトを使用 + if test_dir is None: + test_dir = os.path.join( + TestSettings.get_project_root(), "tests", "maya", "unit" + ) + + # テストを検出して実行 + suite = discover_tests(test_dir, pattern) + return run_tests(suite) + + +def run_blender_tests(test_dir=None, pattern="test_*.py"): + """Blender用のテストを実行 + + Args: + test_dir (str, optional): テストディレクトリ。指定されない場合は tests/blender/unit + pattern (str, optional): テストファイルのパターン。デフォルトは "test_*.py" + + Returns: + unittest.TestResult: テスト実行結果 + """ + # Blender環境のセットアップ + TestSettings.setup_environment("blender") + + # テストディレクトリが指定されていない場合はデフォルトを使用 + if test_dir is None: + test_dir = os.path.join( + TestSettings.get_project_root(), "tests", "blender", "unit" + ) + + # テストを検出して実行 + suite = discover_tests(test_dir, pattern) + return run_tests(suite) + + +def main(): + """コマンドラインからのテスト実行のためのメイン関数""" + parser = argparse.ArgumentParser(description="YWTA Tools テスト実行ツール") + + # 環境の選択 + parser.add_argument( + "--env", + choices=["maya", "blender", "standalone"], + default="standalone", + help="テスト実行環境 (デフォルト: standalone)", + ) + + # テストタイプの選択 + parser.add_argument( + "--type", + choices=["unit", "integration", "performance"], + default="unit", + help="テストタイプ (デフォルト: unit)", + ) + + # 特定のテストの実行 + parser.add_argument( + "--test", + help="実行する特定のテスト (例: tests.maya.unit.test_config.ConfigTests.test_basic)", + ) + + # テストディレクトリの指定 + parser.add_argument("--dir", help="テストを検索するディレクトリ") + + # テストファイルパターンの指定 + parser.add_argument( + "--pattern", + default="test_*.py", + help="テストファイルのパターン (デフォルト: test_*.py)", + ) + + # 詳細レベルの指定 + parser.add_argument( + "--verbose", "-v", action="count", default=1, help="詳細レベルを増やす" + ) + + # 出力バッファリングの指定 + parser.add_argument( + "--no-buffer", action="store_true", help="出力バッファリングを無効にする" + ) + + args = parser.parse_args() + + # 環境設定 + TestSettings.environment = args.env + TestSettings.test_mode = args.type + TestSettings.buffer_output = not args.no_buffer + + # 特定のテストが指定された場合 + if args.test: + result = run_specific_test(args.test) + sys.exit(0 if result and result.wasSuccessful() else 1) + + # テストディレクトリが指定された場合 + if args.dir: + test_dir = args.dir + else: + # デフォルトのテストディレクトリを環境とタイプに基づいて決定 + test_dir = os.path.join( + TestSettings.get_project_root(), "tests", args.env, args.type + ) + + # 環境に応じたテスト実行 + if args.env == "maya": + # Mayaのテスト実行はtest_runner.pyではなく、別のスクリプトで行うことをユーザーに知らせる + print( + "Mayaのテストは `python tests/run_maya_tests.py` を使用して実行してください。" + ) + # result = run_maya_tests(test_dir, args.pattern) + elif args.env == "blender": + result = run_blender_tests(test_dir, args.pattern) + else: + # スタンドアロンモードでのテスト実行 + suite = discover_tests(test_dir, args.pattern) + result = run_tests(suite, verbosity=args.verbose, buffer=not args.no_buffer) + + # 終了コードの設定 + sys.exit(0 if result.wasSuccessful() else 1) + + +if __name__ == "__main__": + main()