Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "blender-toolkit",
|
||||||
|
"description": "Blender automation toolkit with CLI for geometry, materials, modifiers, collections, animation retargeting, and WebSocket-based real-time control",
|
||||||
|
"version": "1.4.4",
|
||||||
|
"author": {
|
||||||
|
"name": "Dev GOM",
|
||||||
|
"url": "https://github.com/Dev-GOM/claude-code-marketplace"
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
"./skills"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# blender-toolkit
|
||||||
|
|
||||||
|
Blender automation toolkit with CLI for geometry, materials, modifiers, collections, animation retargeting, and WebSocket-based real-time control
|
||||||
237
plugin.lock.json
Normal file
237
plugin.lock.json
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
{
|
||||||
|
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||||
|
"pluginId": "gh:Dev-GOM/claude-code-marketplace:plugins/blender-toolkit",
|
||||||
|
"normalized": {
|
||||||
|
"repo": null,
|
||||||
|
"ref": "refs/tags/v20251128.0",
|
||||||
|
"commit": "13ae58926de007e413e78ac97e7d2db698e7e883",
|
||||||
|
"treeHash": "0d090326e440207dc8a576a519772a98a5cb70ad16263ecd7218d18b4858df91",
|
||||||
|
"generatedAt": "2025-11-28T10:10:17.871897Z",
|
||||||
|
"toolVersion": "publish_plugins.py@0.2.0"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||||
|
"branch": "master",
|
||||||
|
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||||
|
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"name": "blender-toolkit",
|
||||||
|
"description": "Blender automation toolkit with CLI for geometry, materials, modifiers, collections, animation retargeting, and WebSocket-based real-time control",
|
||||||
|
"version": "1.4.4"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "25f3bd7f0ada0525394ac72c0d6deb8d1076068bacc6b80dcaa7e583cec8ec94"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".claude-plugin/plugin.json",
|
||||||
|
"sha256": "6b9f5e6a9a659b33ec0bccfa1619b1c50a99d68abcb68f0b3a4c04b6a0b3c012"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/SKILL.md",
|
||||||
|
"sha256": "cdd81cb0235df864c2189264c99db1969c117188b1483ab730122ac99ad95c87"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/references/commands-reference.md",
|
||||||
|
"sha256": "c053b41ade2e418b72e97224b8d1004ee83f2fcacd3cd113abef83899a6283bd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/references/workflow-guide.md",
|
||||||
|
"sha256": "cfa77cf1298441a0a2e455f6e88c390388b7126c53225a0050970768fbb29c99"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/references/addon-api-reference.md",
|
||||||
|
"sha256": "3d99cfc1bbce22074c5ab2086a23d3bfeb89d1790f5ac3a3a630e15266737897"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/references/bone-mapping-guide.md",
|
||||||
|
"sha256": "7932c578589f061db361f1f9902c11b49dd27f441b0d747eeaaa7061a04076dd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/package.json",
|
||||||
|
"sha256": "2f6895f0fe7f4131ec3f890fe9bd344bc01e707c977aebe5b47e3b33dc27a0c0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/install-addon.py",
|
||||||
|
"sha256": "dbfe984cda2b5ba0703bfdf50542b21a04d2858f71260a028d121fe15ecd7e9e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/tsconfig.json",
|
||||||
|
"sha256": "56b5d80d998cb968f4df092e19bca68db5a3d6a22136b5f619d7666238b0a734"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/eslint.config.mjs",
|
||||||
|
"sha256": "1147567cc49454b578398e51774ef908ac51bc069611bd9aaf66ed6b9293818f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/index.ts",
|
||||||
|
"sha256": "bc8000bcf41e932afc96f0609416b11652f3575c90e8b95c5c84b003ae8c5d9f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/constants/index.ts",
|
||||||
|
"sha256": "3d3acc3ad4e2d3f141b041c7aaadd10aac77eb5dcc344a448179fa15b12d571b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/utils/logger.ts",
|
||||||
|
"sha256": "0a838c621338c9003581b75bde16997dc889e4183177e1327ac0ba1508697ab9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/cli/cli.ts",
|
||||||
|
"sha256": "b658ffb233b4e758e1d2b0e7f5a81a89eda140dc01c3db262740a3cd27fe87e2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/cli/commands/daemon.ts",
|
||||||
|
"sha256": "beb3f7f65a5359567c5ffdde5fa25e6f7bda7c92c0ed2684ae488fd5d7407be3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/cli/commands/retargeting.ts",
|
||||||
|
"sha256": "e037f9e0b85e7dbc76d8f727d39e3b3e91d6886cf3fd22e8c987d987178a253f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/cli/commands/collection.ts",
|
||||||
|
"sha256": "c597825e989c8a7554a5df6b746bac176672430bb56f53784399a1c98066bcd8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/cli/commands/geometry.ts",
|
||||||
|
"sha256": "bb4a6f0b8b1bf3b5bd40b712e58d3e9cff06fb5765bd0a34074494f2f2c9b4fb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/cli/commands/modifier.ts",
|
||||||
|
"sha256": "4695049c52b78e861c929c65adeff67b6208fae56d56991cdee75e8750723708"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/cli/commands/object.ts",
|
||||||
|
"sha256": "3091144ccd11b08bc5431999e534285c98febc17c86902c77a4e96f218eb7244"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/cli/commands/material.ts",
|
||||||
|
"sha256": "7a2ce60a84e6ecf0d7235c821c68ae193ac2d7089d3f5da63378e728e5444496"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/daemon/manager.ts",
|
||||||
|
"sha256": "06c4bbd473a1c6db7232308119326235df9f7afb405d085ead3517c1658896ba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/daemon/client.ts",
|
||||||
|
"sha256": "8e38f744a26c19dfa20eae128e471e5ec5d735de203b31cb8cdd04824d11a1ab"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/daemon/protocol.ts",
|
||||||
|
"sha256": "4e676ecd4ccb602df36369b7e45d32d82d23676967ba9b5b5c424ef981e759c6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/daemon/server.ts",
|
||||||
|
"sha256": "d27445b742139ed2ab3441a91e20c135e19ef02ff932440cf17b8fb00bffab98"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/blender/retargeting.ts",
|
||||||
|
"sha256": "8b7dc947a5170a670656727c3ad00c1bdc6d4217cc04a8a056e49c27a1d0d22b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/blender/mixamo.ts",
|
||||||
|
"sha256": "c2d07e709eef9c5e7ab0640e05b2c75296fef28368f55ef4fd9fe85f3c467d27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/blender/client.ts",
|
||||||
|
"sha256": "5920064e3752511cd15837d603bbe27de94433332df5a3f43f34ddf3b98da2e1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/scripts/src/blender/config.ts",
|
||||||
|
"sha256": "bd089f699a571b547582dd9ec9f1906f600bb3c59c8ff199aa91c67ba52fa996"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/.pylintrc",
|
||||||
|
"sha256": "8ff0f0af2d634b492518cc310b6ed41edd04f66d268531090fb55359a67be2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/pyrightconfig.json",
|
||||||
|
"sha256": "cce91ba40bcdcd6c13da986292ce3ea182d2e6ac2dd90c12733e07b6ba1ac17d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/requirements.txt",
|
||||||
|
"sha256": "2eeae60a737da312f1509b3dd2af292ff2adbb8d80b4ef967fe91e444fcea1ee"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/ui.py",
|
||||||
|
"sha256": "65d2e1e530f44fd5d4c941af9a101d4c87552b03ec98a77e651f2dfd4e3745a9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/__init__.py",
|
||||||
|
"sha256": "38d29c7e7711d6f97722f0cc57a99e89e62c98b7a96c19d5d230174702b9df06"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/websocket_server.py",
|
||||||
|
"sha256": "a3ec6ab60763661d1d1024642fb77e86f2581c20415aa387d2158231611ae3a2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/retargeting.py",
|
||||||
|
"sha256": "d4702feb8f5aeea92fc1a079596cfb52bb0402ed951c4127f077e0c11d6ebfd8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/utils/bone_matching.py",
|
||||||
|
"sha256": "f95ced13986529f254e0b9de6fbb8d6bf8e22290739a0b59628408da5ba1f5fd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/utils/security.py",
|
||||||
|
"sha256": "d0fc236f89704f8b7afac707daf9371f39b59f936a89628e3bb718410da06ebd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/utils/__init__.py",
|
||||||
|
"sha256": "cd3ad4f4577be47ea8e96ca56d8e1a77f820d862467c4e3baa9c87c787ba4220"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/utils/logger.py",
|
||||||
|
"sha256": "c9c4903b0fc6ba4e4e4cd2be0365be4c1232e57e71279d7903586aff4eed1de1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/commands/material.py",
|
||||||
|
"sha256": "62ebd756c871e207990d993fc2cbef9986fafbf72e4cd125980662c4ceec3482"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/commands/bone_mapping.py",
|
||||||
|
"sha256": "3c4cbda02c66ecd12aff472d8922e5238f204d8b13e7e82c6f6222c99d78658e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/commands/modifier.py",
|
||||||
|
"sha256": "98b2c31fa6107b297b35f3402a3b6ad22131feb0eeb01628bf61b2203ff6ab35"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/commands/__init__.py",
|
||||||
|
"sha256": "444d6731e45dd1be16f2079727aac18c1d35061902eaa9af2e3a76630e01c82e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/commands/animation.py",
|
||||||
|
"sha256": "c1e137cbad9c8c2a935fef19e7e3908e29d0376d49161428f76cd73fc3c79793"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/commands/armature.py",
|
||||||
|
"sha256": "a88e0d6e555cddba778a733b3e7f176d8526048f238bae9d42ba1e30a6e84465"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/commands/geometry.py",
|
||||||
|
"sha256": "a02e8c931ab1143348ac675c787a3fc689061eb495a9a32f830b31d83b3b926f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/commands/collection.py",
|
||||||
|
"sha256": "790b73914f34212e7ec294738fadd2b5ca090af508ae283ee7a9c1d9184e9df5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/commands/retargeting.py",
|
||||||
|
"sha256": "42272a91d293a3ad1bd854ef3841f3790f5796a047a6dce60d263bb092e1defd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/addon/commands/import_.py",
|
||||||
|
"sha256": "01f7877f4a7c07f971168824b8ee4d558a547818fd341ca257e4defdd77b4941"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dirSha256": "0d090326e440207dc8a576a519772a98a5cb70ad16263ecd7218d18b4858df91"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"scannedAt": null,
|
||||||
|
"scannerVersion": null,
|
||||||
|
"flags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
601
skills/SKILL.md
Normal file
601
skills/SKILL.md
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
---
|
||||||
|
name: blender-toolkit
|
||||||
|
description: |
|
||||||
|
Blender automation with geometry creation, materials, modifiers, and Mixamo animation retargeting.
|
||||||
|
|
||||||
|
Core Features: WebSocket-based real-time control, automatic bone mapping with UI review, two-phase confirmation workflow, quality assessment, multi-project support, comprehensive CLI commands.
|
||||||
|
|
||||||
|
Use Cases: Create 3D primitives (cube, sphere, cylinder, etc.), manipulate objects (transform, duplicate, delete), manage materials and modifiers, retarget Mixamo animations to custom rigs with fuzzy bone matching.
|
||||||
|
|
||||||
|
allowed-tools: Bash, Read, Write, Glob
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Installation Check (READ THIS FIRST)
|
||||||
|
|
||||||
|
**IMPORTANT**: Before using this skill, check Blender addon installation status.
|
||||||
|
|
||||||
|
**Config location**: Check the shared config file for your installation status:
|
||||||
|
```
|
||||||
|
~/.claude/plugins/marketplaces/dev-gom-plugins/blender-config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always run scripts with `--help` first** to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window.
|
||||||
|
|
||||||
|
**Required actions based on config**:
|
||||||
|
|
||||||
|
### 1. If Blender Not Detected (`blenderExecutable: null`)
|
||||||
|
|
||||||
|
Blender was not found during initialization. Please:
|
||||||
|
|
||||||
|
1. **Install Blender 4.0+** from https://www.blender.org
|
||||||
|
2. **Restart Claude Code session** to trigger auto-detection
|
||||||
|
3. Check logs: `.blender-toolkit/init-log.txt`
|
||||||
|
|
||||||
|
### 2. If Multiple Versions Detected (`detectedBlenderVersions` array)
|
||||||
|
|
||||||
|
The system detected multiple Blender installations. If you want to use a different version:
|
||||||
|
|
||||||
|
1. **Open config file** (path shown above)
|
||||||
|
2. **Edit `blenderExecutable`** field to your preferred version path
|
||||||
|
3. **Restart Claude Code session**
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detectedBlenderVersions": [
|
||||||
|
{"version": "4.2.0", "path": "C:\\Program Files\\Blender Foundation\\Blender 4.2\\blender.exe"},
|
||||||
|
{"version": "4.1.0", "path": "C:\\Program Files\\Blender Foundation\\Blender 4.1\\blender.exe"}
|
||||||
|
],
|
||||||
|
"blenderExecutable": "C:\\Program Files\\Blender Foundation\\Blender 4.2\\blender.exe"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. If Addon Not Installed (`addonInstalled: false`)
|
||||||
|
|
||||||
|
The addon needs to be installed manually. Follow these steps:
|
||||||
|
|
||||||
|
**Manual Installation Steps**:
|
||||||
|
|
||||||
|
**Method 1: Install from ZIP (Recommended)**
|
||||||
|
```bash
|
||||||
|
# 1. Open Blender 4.0+
|
||||||
|
# 2. Edit > Preferences > Add-ons > Install
|
||||||
|
# 3. Select: .blender-toolkit/blender-toolkit-addon-v*.zip
|
||||||
|
# 4. Enable "Blender Toolkit WebSocket Server"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Method 2: Install from Source**
|
||||||
|
```bash
|
||||||
|
# 1. Open Blender 4.0+
|
||||||
|
# 2. Edit > Preferences > Add-ons > Install
|
||||||
|
# 3. Select: plugins/blender-toolkit/skills/addon/__init__.py
|
||||||
|
# 4. Enable "Blender Toolkit WebSocket Server"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Start WebSocket Server**:
|
||||||
|
1. Open 3D View → Sidebar (press N key)
|
||||||
|
2. Find "Blender Toolkit" tab
|
||||||
|
3. Click "Start Server" button
|
||||||
|
4. Default port: 9400 (auto-assigned per project)
|
||||||
|
|
||||||
|
**Update Config**:
|
||||||
|
- Open config file (path shown above)
|
||||||
|
- Set `"addonInstalled": true`
|
||||||
|
- Save file
|
||||||
|
|
||||||
|
**Verify Connection**:
|
||||||
|
- Try a simple command: `node .blender-toolkit/bt.js list-objects`
|
||||||
|
- If successful, you'll see a list of objects in your scene
|
||||||
|
|
||||||
|
**Troubleshooting**:
|
||||||
|
- If Blender path is incorrect: Update `blenderExecutable` in config
|
||||||
|
- If port is in use: System will auto-assign next available port (9401-9500)
|
||||||
|
- Check logs: `.blender-toolkit/init-log.txt`
|
||||||
|
- Check Blender console for error messages
|
||||||
|
|
||||||
|
### 4. If Everything is Ready (`addonInstalled: true`)
|
||||||
|
|
||||||
|
✅ You're all set! You can use all Blender Toolkit commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# blender-toolkit
|
||||||
|
|
||||||
|
Automate Blender workflows with WebSocket-based real-time control. Create geometry, manage materials and modifiers, and retarget Mixamo animations to custom rigs with intelligent bone mapping.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Provide comprehensive Blender automation through:
|
||||||
|
- 🎨 **Geometry Creation** - Primitives (cube, sphere, cylinder, plane, cone, torus)
|
||||||
|
- 🎭 **Material Management** - Create, assign, and configure materials
|
||||||
|
- 🔧 **Modifier Control** - Add, apply, and manage modifiers
|
||||||
|
- 🎬 **Animation Retargeting** - Mixamo to custom rigs with automatic bone mapping
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use this skill when:
|
||||||
|
- **Creating 3D Geometry:** User wants to create primitives or manipulate meshes
|
||||||
|
- **Managing Materials:** User needs to create or assign materials with PBR properties
|
||||||
|
- **Adding Modifiers:** User wants subdivision, mirror, array, or other modifiers
|
||||||
|
- **Retargeting Animations:** User needs to apply Mixamo animations to custom characters
|
||||||
|
- **Batch Operations:** User wants to process multiple objects or animations
|
||||||
|
|
||||||
|
**Note:** Mixamo does not provide an official API. Users must manually download FBX files from Mixamo.com.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites Checklist
|
||||||
|
|
||||||
|
Before starting, ensure:
|
||||||
|
- [ ] Blender 4.0+ installed
|
||||||
|
- [ ] Blender Toolkit addon installed and enabled
|
||||||
|
- [ ] WebSocket server started in Blender (default port: 9400)
|
||||||
|
- [ ] Character rig loaded (for animation retargeting)
|
||||||
|
|
||||||
|
**Install Addon:**
|
||||||
|
```
|
||||||
|
1. Open Blender → Edit → Preferences → Add-ons
|
||||||
|
2. Click "Install" → Select plugins/blender-toolkit/skills/addon/__init__.py
|
||||||
|
3. Enable "Blender Toolkit WebSocket Server"
|
||||||
|
4. Start server: View3D → Sidebar (N) → "Blender Toolkit" → "Start Server"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Operations
|
||||||
|
|
||||||
|
**Create Geometry:**
|
||||||
|
```bash
|
||||||
|
# Create cube at origin
|
||||||
|
blender-toolkit create-cube --size 2.0
|
||||||
|
|
||||||
|
# Create sphere with custom settings
|
||||||
|
blender-toolkit create-sphere --radius 1.5 --segments 64
|
||||||
|
|
||||||
|
# Subdivide mesh
|
||||||
|
blender-toolkit subdivide --name "Cube" --cuts 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manage Objects:**
|
||||||
|
```bash
|
||||||
|
# List all objects
|
||||||
|
blender-toolkit list-objects
|
||||||
|
|
||||||
|
# Transform object
|
||||||
|
blender-toolkit transform --name "Cube" --loc-x 5 --loc-y 0 --scale-x 2
|
||||||
|
|
||||||
|
# Duplicate object
|
||||||
|
blender-toolkit duplicate --name "Cube" --new-name "Cube.001" --x 3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Materials:**
|
||||||
|
```bash
|
||||||
|
# Create material
|
||||||
|
blender-toolkit material create --name "RedMaterial"
|
||||||
|
|
||||||
|
# Assign to object
|
||||||
|
blender-toolkit material assign --object "Cube" --material "RedMaterial"
|
||||||
|
|
||||||
|
# Set color
|
||||||
|
blender-toolkit material set-color --material "RedMaterial" --r 1.0 --g 0.0 --b 0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Retarget Animation:**
|
||||||
|
```bash
|
||||||
|
# Basic retargeting with UI confirmation
|
||||||
|
blender-toolkit retarget \
|
||||||
|
--target "HeroRig" \
|
||||||
|
--file "./Walking.fbx" \
|
||||||
|
--name "Walking"
|
||||||
|
|
||||||
|
# Rigify preset (skip confirmation)
|
||||||
|
blender-toolkit retarget \
|
||||||
|
--target "MyRigifyCharacter" \
|
||||||
|
--file "./Walking.fbx" \
|
||||||
|
--mapping mixamo_to_rigify \
|
||||||
|
--skip-confirmation
|
||||||
|
|
||||||
|
# Show Mixamo download instructions
|
||||||
|
blender-toolkit mixamo-help Walking
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**WebSocket-Based Design:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌─────────────┐ WebSocket ┌──────────────┐
|
||||||
|
│ Claude Code │ IPC │ TypeScript │◄──────────────►│ Blender │
|
||||||
|
│ (Skill) │────────►│ Client │ Port 9400+ │ (Addon) │
|
||||||
|
└──────────────┘ └─────────────┘ └──────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌────────────────────┐
|
||||||
|
│ - Geometry │ │ - WebSocket │
|
||||||
|
│ - Material │ │ Server │
|
||||||
|
│ - Modifier │ │ - Command │
|
||||||
|
│ - Retargeting │ │ Handlers │
|
||||||
|
│ - Bone Mapping │ │ - Bone Mapping UI │
|
||||||
|
└─────────────────┘ └────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Components:**
|
||||||
|
- **WebSocket Server:** Python addon in Blender (ports 9400-9500)
|
||||||
|
- **TypeScript Client:** Sends commands via JSON-RPC
|
||||||
|
- **Bone Mapping System:** Fuzzy matching with UI confirmation
|
||||||
|
- **Two-Phase Workflow:** Generate → Review → Apply
|
||||||
|
|
||||||
|
## Core Workflows
|
||||||
|
|
||||||
|
### 1. Geometry Creation Workflow
|
||||||
|
|
||||||
|
**Extract Requirements:**
|
||||||
|
- Primitive type (cube, sphere, cylinder, etc.)
|
||||||
|
- Position (x, y, z coordinates)
|
||||||
|
- Size parameters (radius, depth, segments)
|
||||||
|
- Optional object name
|
||||||
|
|
||||||
|
**Execute:**
|
||||||
|
```typescript
|
||||||
|
import { BlenderClient } from 'blender-toolkit';
|
||||||
|
|
||||||
|
const client = new BlenderClient();
|
||||||
|
await client.connect(9400);
|
||||||
|
|
||||||
|
// Create sphere
|
||||||
|
const result = await client.sendCommand('Geometry.createSphere', {
|
||||||
|
location: [0, 0, 2],
|
||||||
|
radius: 1.5,
|
||||||
|
segments: 64,
|
||||||
|
name: 'MySphere'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Created ${result.name} with ${result.vertices} vertices`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Material Assignment Workflow
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Create material
|
||||||
|
2. Assign to object
|
||||||
|
3. Configure properties (color, metallic, roughness)
|
||||||
|
|
||||||
|
**Execute:**
|
||||||
|
```bash
|
||||||
|
# Create and configure material
|
||||||
|
blender-toolkit material create --name "Metal"
|
||||||
|
blender-toolkit material set-color --material "Metal" --r 0.8 --g 0.8 --b 0.8
|
||||||
|
blender-toolkit material set-metallic --material "Metal" --value 1.0
|
||||||
|
blender-toolkit material set-roughness --material "Metal" --value 0.2
|
||||||
|
|
||||||
|
# Assign to object
|
||||||
|
blender-toolkit material assign --object "Sphere" --material "Metal"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Animation Retargeting Workflow ⭐
|
||||||
|
|
||||||
|
**Most Common Use Case**
|
||||||
|
|
||||||
|
**Phase 1: Setup & Generate Mapping**
|
||||||
|
```
|
||||||
|
1. User provides:
|
||||||
|
- Target character armature name
|
||||||
|
- Animation FBX file path
|
||||||
|
- (Optional) Animation name for NLA track
|
||||||
|
|
||||||
|
2. System executes:
|
||||||
|
- Connects to Blender WebSocket
|
||||||
|
- Imports FBX file
|
||||||
|
- Analyzes bone structure
|
||||||
|
- Auto-generates bone mapping (fuzzy matching)
|
||||||
|
- Displays mapping in Blender UI for review
|
||||||
|
|
||||||
|
3. Quality Assessment:
|
||||||
|
- Excellent (8-9 critical bones) → Safe to auto-apply
|
||||||
|
- Good (6-7 critical bones) → Quick review recommended
|
||||||
|
- Fair (4-5 critical bones) → Thorough review required
|
||||||
|
- Poor (< 4 critical bones) → Manual mapping needed
|
||||||
|
```
|
||||||
|
|
||||||
|
**Phase 2: User Confirmation**
|
||||||
|
```
|
||||||
|
1. User reviews mapping in Blender:
|
||||||
|
- View3D → Sidebar (N) → "Blender Toolkit" → "Bone Mapping Review"
|
||||||
|
- Check source → target correspondence
|
||||||
|
- Edit incorrect mappings using dropdowns
|
||||||
|
- Use "Auto Re-map" button to regenerate if needed
|
||||||
|
|
||||||
|
2. User confirms:
|
||||||
|
- Click "Apply Retargeting" button in Blender
|
||||||
|
|
||||||
|
3. System completes:
|
||||||
|
- Creates constraint-based retargeting
|
||||||
|
- Bakes animation to keyframes
|
||||||
|
- Adds to NLA track
|
||||||
|
- Cleans up temporary objects
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
import { AnimationRetargetingWorkflow } from 'blender-toolkit';
|
||||||
|
|
||||||
|
const workflow = new AnimationRetargetingWorkflow();
|
||||||
|
|
||||||
|
// If user doesn't have FBX yet
|
||||||
|
console.log(workflow.getManualDownloadInstructions('Walking'));
|
||||||
|
|
||||||
|
// After user downloads FBX
|
||||||
|
await workflow.run({
|
||||||
|
targetCharacterArmature: 'HeroRig',
|
||||||
|
animationFilePath: './Walking.fbx',
|
||||||
|
animationName: 'Walking',
|
||||||
|
boneMapping: 'auto', // Auto-generate with fuzzy matching
|
||||||
|
skipConfirmation: false // Enable UI review workflow
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Skip Confirmation (For Known-Good Mappings):**
|
||||||
|
```bash
|
||||||
|
# Rigify preset - instant application
|
||||||
|
blender-toolkit retarget \
|
||||||
|
--target "RigifyCharacter" \
|
||||||
|
--file "./Walking.fbx" \
|
||||||
|
--mapping mixamo_to_rigify \
|
||||||
|
--skip-confirmation
|
||||||
|
|
||||||
|
# Excellent quality - trusted auto-mapping
|
||||||
|
blender-toolkit retarget \
|
||||||
|
--target "MyCharacter" \
|
||||||
|
--file "./Walking.fbx" \
|
||||||
|
--skip-confirmation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Auto Bone Mapping with UI Review 🌟
|
||||||
|
|
||||||
|
**Recommended Workflow** for unknown or custom rigs:
|
||||||
|
|
||||||
|
**How It Works:**
|
||||||
|
1. **Fuzzy Matching Algorithm**
|
||||||
|
- Normalizes bone names (handles various conventions)
|
||||||
|
- Calculates similarity scores (0.0-1.0)
|
||||||
|
- Applies bonuses for:
|
||||||
|
- Substring matches (+0.15)
|
||||||
|
- Common prefixes: left, right (+0.1)
|
||||||
|
- Common suffixes: .L, .R, _l, _r (+0.1)
|
||||||
|
- Number matching: Spine1, Spine2 (+0.1)
|
||||||
|
- Anatomical keywords: arm, leg, hand (+0.05)
|
||||||
|
|
||||||
|
2. **Quality Assessment**
|
||||||
|
- Tracks 9 critical bones (Hips, Spine, Head, Arms, Legs, Hands)
|
||||||
|
- Provides quality rating (Excellent/Good/Fair/Poor)
|
||||||
|
- Recommends action based on quality
|
||||||
|
|
||||||
|
3. **UI Confirmation Panel**
|
||||||
|
- Shows complete mapping table
|
||||||
|
- Editable dropdowns for each mapping
|
||||||
|
- "Auto Re-map" button (regenerate)
|
||||||
|
- "Apply Retargeting" button (proceed)
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Works with any rig structure
|
||||||
|
- No manual configuration needed
|
||||||
|
- User verifies before application
|
||||||
|
- Prevents animation errors
|
||||||
|
|
||||||
|
### Three Bone Mapping Modes
|
||||||
|
|
||||||
|
**1. Auto Mode (Recommended)** ⭐
|
||||||
|
```bash
|
||||||
|
# Default: Auto-generate with UI confirmation
|
||||||
|
blender-toolkit retarget --target "Hero" --file "./Walk.fbx"
|
||||||
|
```
|
||||||
|
- Fuzzy matching algorithm
|
||||||
|
- UI review workflow
|
||||||
|
- Best for unknown rigs
|
||||||
|
|
||||||
|
**2. Rigify Mode**
|
||||||
|
```bash
|
||||||
|
# Preset for Rigify control rigs
|
||||||
|
blender-toolkit retarget --target "Hero" --file "./Walk.fbx" --mapping mixamo_to_rigify
|
||||||
|
```
|
||||||
|
- Predefined Mixamo → Rigify mapping
|
||||||
|
- Instant application
|
||||||
|
- Highest accuracy for Rigify
|
||||||
|
|
||||||
|
**3. Custom Mode**
|
||||||
|
```typescript
|
||||||
|
// Explicit bone mapping
|
||||||
|
const customMapping = {
|
||||||
|
"Hips": "root_bone",
|
||||||
|
"Spine": "torso_01",
|
||||||
|
"LeftArm": "l_upper_arm",
|
||||||
|
// ... complete mapping
|
||||||
|
};
|
||||||
|
|
||||||
|
await workflow.run({
|
||||||
|
boneMapping: customMapping,
|
||||||
|
skipConfirmation: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
- Full control
|
||||||
|
- Reusable across animations
|
||||||
|
- For non-standard rigs
|
||||||
|
|
||||||
|
### Multi-Project Support
|
||||||
|
|
||||||
|
**Automatic Port Management:**
|
||||||
|
- Projects automatically assigned unique ports (9400-9500)
|
||||||
|
- Configuration persists across sessions
|
||||||
|
- Multiple Blender instances can run simultaneously
|
||||||
|
|
||||||
|
**Configuration Storage:**
|
||||||
|
```json
|
||||||
|
// ~/.claude/plugins/.../blender-config.json
|
||||||
|
{
|
||||||
|
"projects": {
|
||||||
|
"/path/to/project-a": { "port": 9400 },
|
||||||
|
"/path/to/project-b": { "port": 9401 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Guidelines
|
||||||
|
|
||||||
|
### When to Ask User
|
||||||
|
|
||||||
|
Use `AskUserQuestion` tool if:
|
||||||
|
- Character armature name is unclear
|
||||||
|
- Multiple rigs exist (ambiguous target)
|
||||||
|
- Animation FBX path not provided
|
||||||
|
- Blender WebSocket connection fails
|
||||||
|
- User needs Mixamo download guidance
|
||||||
|
|
||||||
|
**DO NOT** guess:
|
||||||
|
- Character names
|
||||||
|
- File paths
|
||||||
|
- Rig structures
|
||||||
|
|
||||||
|
### Mixamo Download Process
|
||||||
|
|
||||||
|
Since Mixamo has no API, users must manually download:
|
||||||
|
|
||||||
|
**Provide Instructions:**
|
||||||
|
```typescript
|
||||||
|
// Show download help
|
||||||
|
const workflow = new AnimationRetargetingWorkflow();
|
||||||
|
console.log(workflow.getManualDownloadInstructions('Walking'));
|
||||||
|
console.log(workflow.getRecommendedSettings());
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wait for User:**
|
||||||
|
- Guide user through Mixamo.com download
|
||||||
|
- Get file path after download completes
|
||||||
|
- Then proceed with retargeting
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Blender is not running"
|
||||||
|
```bash
|
||||||
|
# Check connection
|
||||||
|
blender-toolkit daemon-status
|
||||||
|
|
||||||
|
# If failed:
|
||||||
|
1. Verify Blender is open
|
||||||
|
2. Check addon is enabled
|
||||||
|
3. Start server: Blender → N → "Blender Toolkit" → "Start Server"
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Target armature not found"
|
||||||
|
- Verify exact armature name (case-sensitive)
|
||||||
|
- Check character is in current scene
|
||||||
|
- Use `list-objects --type ARMATURE` to see available armatures
|
||||||
|
|
||||||
|
### "Poor quality" bone mapping
|
||||||
|
1. Review bone names in Blender (Edit Mode)
|
||||||
|
2. Create custom mapping for critical bones
|
||||||
|
3. Lower similarity threshold (default: 0.6)
|
||||||
|
4. Check rig has proper hierarchy
|
||||||
|
|
||||||
|
### "Twisted or inverted limbs"
|
||||||
|
- Check left/right bone mapping
|
||||||
|
- Verify bone roll in Edit Mode
|
||||||
|
- Review constraint axes
|
||||||
|
- Test with simple animation first
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **🌟 Use Auto Mode with UI Confirmation**
|
||||||
|
- Most reliable for unknown rigs
|
||||||
|
- Always review critical bones (Hips, Spine, Arms, Legs)
|
||||||
|
- Edit incorrect mappings before applying
|
||||||
|
|
||||||
|
2. **Test Simple Animations First**
|
||||||
|
- Start with Idle or Walking
|
||||||
|
- Verify bone mapping works correctly
|
||||||
|
- Check root motion (Hips bone)
|
||||||
|
- Then proceed to complex animations
|
||||||
|
|
||||||
|
3. **Download Correct Format from Mixamo**
|
||||||
|
- Format: FBX (.fbx)
|
||||||
|
- Skin: Without Skin
|
||||||
|
- FPS: 30 fps
|
||||||
|
- Keyframe Reduction: None
|
||||||
|
|
||||||
|
4. **Check Quality Before Auto-Apply**
|
||||||
|
- Excellent (8-9 critical) → Safe to skip confirmation
|
||||||
|
- Good (6-7 critical) → Quick review
|
||||||
|
- Fair (4-5 critical) → Thorough review
|
||||||
|
- Poor (< 4 critical) → Use custom mapping
|
||||||
|
|
||||||
|
5. **Save Custom Mappings for Reuse**
|
||||||
|
- Document successful mappings
|
||||||
|
- Reuse for same character's animations
|
||||||
|
- Share with team members
|
||||||
|
|
||||||
|
6. **Let System Manage Ports**
|
||||||
|
- Don't manually configure ports
|
||||||
|
- System handles multi-project conflicts
|
||||||
|
- Configuration persists automatically
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
Detailed documentation in `references/` folder:
|
||||||
|
|
||||||
|
- **[commands-reference.md](references/commands-reference.md)** - Complete CLI command reference
|
||||||
|
- All geometry, object, material, modifier commands
|
||||||
|
- Detailed options and examples
|
||||||
|
- Port management and tips
|
||||||
|
|
||||||
|
- **[bone-mapping-guide.md](references/bone-mapping-guide.md)** - Bone matching system details
|
||||||
|
- Fuzzy matching algorithm explained
|
||||||
|
- Quality assessment metrics
|
||||||
|
- Common mapping patterns (Rigify, UE4, Unity)
|
||||||
|
- Troubleshooting mapping issues
|
||||||
|
|
||||||
|
- **[workflow-guide.md](references/workflow-guide.md)** - Complete workflow documentation
|
||||||
|
- Step-by-step retargeting workflow
|
||||||
|
- Mixamo download process
|
||||||
|
- Two-phase confirmation details
|
||||||
|
- Batch processing workflows
|
||||||
|
- Multi-project workflows
|
||||||
|
|
||||||
|
- **[addon-api-reference.md](references/addon-api-reference.md)** - WebSocket API documentation
|
||||||
|
- JSON-RPC protocol details
|
||||||
|
- All API methods and parameters
|
||||||
|
- Error handling
|
||||||
|
- Security and performance tips
|
||||||
|
|
||||||
|
**When to Load References:**
|
||||||
|
- User needs detailed command options
|
||||||
|
- Troubleshooting complex issues
|
||||||
|
- Understanding bone mapping algorithm
|
||||||
|
- Setting up advanced workflows
|
||||||
|
- API integration requirements
|
||||||
|
|
||||||
|
## Output Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.blender-toolkit/
|
||||||
|
├── skills/scripts/ # Local TypeScript scripts (auto-initialized)
|
||||||
|
│ ├── src/ # Source code
|
||||||
|
│ ├── dist/ # Compiled JavaScript
|
||||||
|
│ └── node_modules/ # Dependencies
|
||||||
|
├── bt.js # CLI wrapper
|
||||||
|
├── logs/ # Log files
|
||||||
|
│ ├── typescript.log
|
||||||
|
│ ├── blender-addon.log
|
||||||
|
│ └── error.log
|
||||||
|
└── .gitignore
|
||||||
|
|
||||||
|
Shared config:
|
||||||
|
~/.claude/plugins/.../blender-config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Port range:** 9400-9500 (Browser Pilot uses 9222-9322)
|
||||||
|
- **File formats:** FBX recommended, Collada (.dae) supported
|
||||||
|
- **Blender version:** 4.0+ required (2023+)
|
||||||
|
- **Auto-initialization:** SessionStart hook installs and builds scripts
|
||||||
|
- **No manual daemon management:** System handles everything
|
||||||
|
- **WebSocket protocol:** JSON-RPC 2.0
|
||||||
409
skills/addon/.pylintrc
Normal file
409
skills/addon/.pylintrc
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
[MASTER]
|
||||||
|
# A comma-separated list of package or module names from where C extensions may
|
||||||
|
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||||
|
# run arbitrary code.
|
||||||
|
extension-pkg-allow-list=
|
||||||
|
|
||||||
|
# Specify a score threshold to be exceeded before program exits with error code.
|
||||||
|
fail-under=10.0
|
||||||
|
|
||||||
|
# Add files or directories to the blacklist. They should be base names, not
|
||||||
|
# paths.
|
||||||
|
ignore=CVS
|
||||||
|
|
||||||
|
# Add files or directories matching the regex patterns to the blacklist. The
|
||||||
|
# regex matches against base names and not paths.
|
||||||
|
ignore-patterns=
|
||||||
|
|
||||||
|
# Python code to execute, usually for sys.path manipulation such as
|
||||||
|
# pygtk.require().
|
||||||
|
#init-hook=
|
||||||
|
|
||||||
|
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
|
||||||
|
# number of processors available to use.
|
||||||
|
jobs=1
|
||||||
|
|
||||||
|
# Control the amount of potential inferences PyLint can do when analyzing open
|
||||||
|
# files. Increasing this value might help you get proper information for your
|
||||||
|
# scripts, but can also result in longer computation time. This is a trade-off
|
||||||
|
# you can make as you see fit. It defaults to 0.
|
||||||
|
limit-inference-results=100
|
||||||
|
|
||||||
|
# List of plugins (as comma separated). Plugins should always be named after
|
||||||
|
# their package or modules names, not the filename in the plugins directory.
|
||||||
|
load-plugins=
|
||||||
|
|
||||||
|
# Minimum Python version to target. Used for version dependent checks and
|
||||||
|
# annotations parsing.
|
||||||
|
py-version=3.9
|
||||||
|
|
||||||
|
# Allow optimization of some simple Pylint rules at the cost of some lost
|
||||||
|
# message locations accuracy.
|
||||||
|
unsafe-load-any-extension=no
|
||||||
|
|
||||||
|
|
||||||
|
[MESSAGES CONTROL]
|
||||||
|
|
||||||
|
# Only show warnings with the listed confidence levels. Leave empty to show
|
||||||
|
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
|
||||||
|
confidence=
|
||||||
|
|
||||||
|
# Disable the message, report, category or checker with the given id(s). You
|
||||||
|
# can either give multiple identifiers separated by comma (,) or put this
|
||||||
|
# option multiple times (only on the command line, not in the configuration
|
||||||
|
# file where it should appear only once). You can also use "--disable=all" to
|
||||||
|
# disable everything first and then reenable specific checks. For example, if
|
||||||
|
# you want to run only the similarities checker, you can use "--disable=all
|
||||||
|
# --enable=similarities". If you want to run only the classes checker, but have
|
||||||
|
# no Warning level messages displayed, use "--disable=all --enable=classes
|
||||||
|
# --disable=W"
|
||||||
|
disable=
|
||||||
|
# Blender addon specific disables
|
||||||
|
wrong-import-position, # C0413: Blender requires bl_info before imports
|
||||||
|
too-many-lines, # C0302: Large addon files are acceptable
|
||||||
|
invalid-name, # C0103: Blender naming conventions (e.g., ADDON_OT_OperatorName)
|
||||||
|
too-few-public-methods, # R0903: Blender Operators have required structure
|
||||||
|
unused-argument, # W0613: Blender callbacks have required signatures
|
||||||
|
import-error, # E0401: bpy module not available in linting environment
|
||||||
|
import-outside-toplevel, # C0415: Lazy imports common in Blender addons
|
||||||
|
no-else-return, # R1705: elif after return (acceptable pattern)
|
||||||
|
too-many-return-statements,# R0911: Command routers need multiple returns
|
||||||
|
|
||||||
|
# Additional useful disables for development
|
||||||
|
fixme, # W0511: TODOs and FIXMEs in code
|
||||||
|
duplicate-code, # R0801: Similar lines in multiple locations
|
||||||
|
|
||||||
|
|
||||||
|
[REPORTS]
|
||||||
|
|
||||||
|
# Python expression which should return a score less than or equal to 10 (10 is
|
||||||
|
# the highest value). You have access to the variables 'fatal', 'error',
|
||||||
|
# 'warning', 'refactor', 'convention', and 'info' which represent the number of
|
||||||
|
# messages in each category, as well as 'statement' which represents the total
|
||||||
|
# number of statements analyzed. This score is used by the global evaluation
|
||||||
|
# report (RP0004). Evaluation of this score is skipped if it's value is "-1".
|
||||||
|
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
|
||||||
|
|
||||||
|
# Set the output format. Available formats are text, parseable, colorized, json
|
||||||
|
# and msvs (visual studio). You can also give a reporter class e.g. mypackage.
|
||||||
|
# mymodule.MyReporterClass --output-format=parseable
|
||||||
|
output-format=text
|
||||||
|
|
||||||
|
# Tells whether to display a full report or only the messages
|
||||||
|
reports=no
|
||||||
|
|
||||||
|
# Tells whether to display a full report or only the messages
|
||||||
|
score=yes
|
||||||
|
|
||||||
|
|
||||||
|
[REFACTORING]
|
||||||
|
|
||||||
|
# Maximum number of nested blocks for function / method body
|
||||||
|
max-nested-blocks=5
|
||||||
|
|
||||||
|
# Complete name of functions that never returns. When checking for
|
||||||
|
# inconsistent-return-statements if a never returning function is called then
|
||||||
|
# it will be considered as an explicit return statement and no message will be
|
||||||
|
# printed.
|
||||||
|
never-returning-functions=sys.exit,argparse.ArgumentParser.error
|
||||||
|
|
||||||
|
|
||||||
|
[BASIC]
|
||||||
|
|
||||||
|
# Naming style matching correct argument names.
|
||||||
|
argument-naming-style=snake_case
|
||||||
|
|
||||||
|
# Regular expression matching correct argument names. Overrides argument-
|
||||||
|
# naming-style. If left empty, argument names will be checked with the set
|
||||||
|
# naming style.
|
||||||
|
argument-rgx=
|
||||||
|
|
||||||
|
# Naming style matching correct attribute names.
|
||||||
|
attr-naming-style=snake_case
|
||||||
|
|
||||||
|
# Regular expression matching correct attribute names. Overrides attr-naming-
|
||||||
|
# style. If left empty, attribute names will be checked with the set naming
|
||||||
|
# style.
|
||||||
|
attr-rgx=
|
||||||
|
|
||||||
|
# Bad variable names which should always be refused, separated by a comma.
|
||||||
|
bad-names=foo,bar,baz,toto,tutu,tata
|
||||||
|
|
||||||
|
# Naming style matching correct class attribute names.
|
||||||
|
class-attribute-naming-style=any
|
||||||
|
|
||||||
|
# Regular expression matching correct class attribute names. Overrides class-
|
||||||
|
# attribute-naming-style. If left empty, class attribute names will be checked
|
||||||
|
# with the set naming style.
|
||||||
|
class-attribute-rgx=
|
||||||
|
|
||||||
|
# Naming style matching correct class constant names.
|
||||||
|
class-const-naming-style=UPPER_CASE
|
||||||
|
|
||||||
|
# Regular expression matching correct class constant names. Overrides class-
|
||||||
|
# const-naming-style. If left empty, class constant names will be checked with
|
||||||
|
# the set naming style.
|
||||||
|
class-const-rgx=
|
||||||
|
|
||||||
|
# Naming style matching correct class names.
|
||||||
|
class-naming-style=PascalCase
|
||||||
|
|
||||||
|
# Regular expression matching correct class names. Overrides class-naming-
|
||||||
|
# style. If left empty, class names will be checked with the set naming style.
|
||||||
|
class-rgx=
|
||||||
|
|
||||||
|
# Naming style matching correct constant names.
|
||||||
|
const-naming-style=UPPER_CASE
|
||||||
|
|
||||||
|
# Regular expression matching correct constant names. Overrides const-naming-
|
||||||
|
# style. If left empty, constant names will be checked with the set naming
|
||||||
|
# style.
|
||||||
|
const-rgx=
|
||||||
|
|
||||||
|
# Minimum line length for functions/classes that require docstrings, shorter
|
||||||
|
# ones are exempt.
|
||||||
|
docstring-min-length=-1
|
||||||
|
|
||||||
|
# Naming style matching correct function names.
|
||||||
|
function-naming-style=snake_case
|
||||||
|
|
||||||
|
# Regular expression matching correct function names. Overrides function-
|
||||||
|
# naming-style. If left empty, function names will be checked with the set
|
||||||
|
# naming style.
|
||||||
|
function-rgx=
|
||||||
|
|
||||||
|
# Good variable names which should always be accepted, separated by a comma.
|
||||||
|
good-names=i,j,k,ex,Run,_,x,y,z,dx,dy,dz,bpy
|
||||||
|
|
||||||
|
# Naming style matching correct method names.
|
||||||
|
method-naming-style=snake_case
|
||||||
|
|
||||||
|
# Regular expression matching correct method names. Overrides method-naming-
|
||||||
|
# style. If left empty, method names will be checked with the set naming style.
|
||||||
|
method-rgx=
|
||||||
|
|
||||||
|
# Naming style matching correct module names.
|
||||||
|
module-naming-style=snake_case
|
||||||
|
|
||||||
|
# Regular expression matching correct module names. Overrides module-naming-
|
||||||
|
# style. If left empty, module names will be checked with the set naming style.
|
||||||
|
module-rgx=
|
||||||
|
|
||||||
|
# Colon-delimited sets of names that determine each other's naming style when
|
||||||
|
# the name regexes allow several styles. This is useful for enforcing naming
|
||||||
|
# consistency across properties, getters, and setters. Each named set should
|
||||||
|
# contain RegEx rule names to its own set. The RegEx rules in the same set must
|
||||||
|
# have compatible naming style. This is done in order to make sure they can
|
||||||
|
# interchangeably be used.
|
||||||
|
name-group=
|
||||||
|
|
||||||
|
# Regular expression which should only match function or class names that do
|
||||||
|
# not require a docstring.
|
||||||
|
no-docstring-rgx=^_
|
||||||
|
|
||||||
|
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
||||||
|
# to this list to register other decorators that produce valid properties.
|
||||||
|
# These decorators are taken in consideration only for invalid-name.
|
||||||
|
property-classes=abc.abstractproperty
|
||||||
|
|
||||||
|
# Naming style matching correct variable names.
|
||||||
|
variable-naming-style=snake_case
|
||||||
|
|
||||||
|
# Regular expression matching correct variable names. Overrides variable-
|
||||||
|
# naming-style. If left empty, variable names will be checked with the set
|
||||||
|
# naming style.
|
||||||
|
variable-rgx=
|
||||||
|
|
||||||
|
|
||||||
|
[FORMAT]
|
||||||
|
|
||||||
|
# Regexp for a line that is allowed to be longer than the limit.
|
||||||
|
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
||||||
|
|
||||||
|
# Number of spaces of indent required inside a hanging or continued line.
|
||||||
|
indent-after-paren=4
|
||||||
|
|
||||||
|
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
||||||
|
# tab).
|
||||||
|
indent-string=' '
|
||||||
|
|
||||||
|
# Maximum number of characters on a single line.
|
||||||
|
max-line-length=100
|
||||||
|
|
||||||
|
# Maximum number of lines in a module.
|
||||||
|
max-module-lines=1000
|
||||||
|
|
||||||
|
# Allow the body of a class to be on the same line as the declaration if body
|
||||||
|
# contains single statement.
|
||||||
|
single-line-class-stmt=no
|
||||||
|
|
||||||
|
# Allow the body of an if to be on the same line as the test if there is no
|
||||||
|
# else.
|
||||||
|
single-line-if-stmt=no
|
||||||
|
|
||||||
|
|
||||||
|
[LOGGING]
|
||||||
|
|
||||||
|
# The type of string formatting that logging methods do. `old` for %
|
||||||
|
# formatting, `new` for {} formatting and `fstring` for f-strings.
|
||||||
|
logging-format-style=old
|
||||||
|
|
||||||
|
# Format template used to check logging format string.
|
||||||
|
logging-modules=logging
|
||||||
|
|
||||||
|
|
||||||
|
[MISCELLANEOUS]
|
||||||
|
|
||||||
|
# List of note tags to take in consideration, separated by a comma.
|
||||||
|
notes=FIXME,XXX,TODO
|
||||||
|
|
||||||
|
# Regular expression of note tags to take in consideration.
|
||||||
|
notes-rgx=
|
||||||
|
|
||||||
|
|
||||||
|
[SIMILARITIES]
|
||||||
|
|
||||||
|
# Comments are removed from the similarity computation
|
||||||
|
ignore-comments=yes
|
||||||
|
|
||||||
|
# Docstrings are removed from the similarity computation
|
||||||
|
ignore-docstrings=yes
|
||||||
|
|
||||||
|
# Imports are removed from the similarity computation
|
||||||
|
ignore-imports=no
|
||||||
|
|
||||||
|
# Signatures are removed from the similarity computation
|
||||||
|
ignore-signatures=no
|
||||||
|
|
||||||
|
# Minimum lines number d a similarity.
|
||||||
|
min-similarity-lines=4
|
||||||
|
|
||||||
|
|
||||||
|
[SPELLING]
|
||||||
|
|
||||||
|
# Limits count of emitted suggestions for spelling mistakes.
|
||||||
|
max-spelling-suggestions=4
|
||||||
|
|
||||||
|
# Path to a dictionary that some tests and fixers may parse and use.
|
||||||
|
spelling-dict=
|
||||||
|
|
||||||
|
# Tells whether to spell check word list when using the quiet mode
|
||||||
|
spelling-ignore-words=
|
||||||
|
|
||||||
|
# A path to a file with private dictionary; one word per line.
|
||||||
|
spelling-private-dict-file=
|
||||||
|
|
||||||
|
|
||||||
|
[VARIABLES]
|
||||||
|
|
||||||
|
# List of additional names supposed to be defined in builtins. Remember that
|
||||||
|
# you should avoid defining new builtins when possible.
|
||||||
|
additional-builtins=
|
||||||
|
|
||||||
|
# Tells whether unused global variables should be treated as a violation.
|
||||||
|
allow-global-unused-variables=yes
|
||||||
|
|
||||||
|
# List of names allowed to shadow builtins
|
||||||
|
allowed-redefined-builtins=
|
||||||
|
|
||||||
|
# List of strings which can identify a callback function by name. A callback
|
||||||
|
# name must start or end with one of those strings.
|
||||||
|
callbacks=cb_,_cb
|
||||||
|
|
||||||
|
# A regular expression matching the name of dummy variables (i.e. expected to
|
||||||
|
# not be used).
|
||||||
|
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|^ignored_|^unused_
|
||||||
|
|
||||||
|
# Argument names that match this expression will be ignored.
|
||||||
|
ignored-argument-names=_.*
|
||||||
|
|
||||||
|
# Tells whether we should check for unused import in __init__ files.
|
||||||
|
init-import=no
|
||||||
|
|
||||||
|
|
||||||
|
[CLASSES]
|
||||||
|
|
||||||
|
# Validate membership accesses on modules/namespaces based on the public API
|
||||||
|
# advertised by a module's all definition.
|
||||||
|
check-protected-access-in-special-methods=no
|
||||||
|
|
||||||
|
# List of method names used to declare an abstract method. The naming doesn't
|
||||||
|
# matter, as long as the method has the property decorated with
|
||||||
|
# "abstractmethod" anything else will be ignored.
|
||||||
|
defining-attr-methods=__init__,__new__,setUp,__post_init__
|
||||||
|
|
||||||
|
# List of member names, which should be excluded from the protected access
|
||||||
|
# warning.
|
||||||
|
exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
|
||||||
|
|
||||||
|
# List of valid names for the first argument in a class method.
|
||||||
|
valid-classmethod-first-arg=cls
|
||||||
|
|
||||||
|
# List of valid names for the first argument in a metaclass class method.
|
||||||
|
valid-metaclass-classmethod-first-arg=cls
|
||||||
|
|
||||||
|
|
||||||
|
[DESIGN]
|
||||||
|
|
||||||
|
# Maximum number of arguments for function / method
|
||||||
|
max-args=5
|
||||||
|
|
||||||
|
# Maximum number of attributes for a class
|
||||||
|
max-attributes=7
|
||||||
|
|
||||||
|
# Maximum number of boolean expressions in an if statement
|
||||||
|
max-bool-expr=5
|
||||||
|
|
||||||
|
# Maximum number of branch for function / method body
|
||||||
|
max-branches=12
|
||||||
|
|
||||||
|
# Maximum number of locals for function / method body
|
||||||
|
max-locals=15
|
||||||
|
|
||||||
|
# Maximum number of parents for a class (see R0901)
|
||||||
|
max-parents=7
|
||||||
|
|
||||||
|
# Maximum number of public methods for a class
|
||||||
|
max-public-methods=20
|
||||||
|
|
||||||
|
# Maximum number of return / yield for function / method body
|
||||||
|
max-returns=6
|
||||||
|
|
||||||
|
# Maximum number of statements in function / method body
|
||||||
|
max-statements=50
|
||||||
|
|
||||||
|
# Minimum number of public methods for a class
|
||||||
|
min-public-methods=2
|
||||||
|
|
||||||
|
|
||||||
|
[IMPORTS]
|
||||||
|
|
||||||
|
# Analyse import fallback blocks. This can be used to support both Python 2 and
|
||||||
|
# 3 compatible code, which means you may have duplicated imports (same
|
||||||
|
# imports in try except blocks). By default it set to False.
|
||||||
|
analyse-fallback-blocks=no
|
||||||
|
|
||||||
|
# Create a graph of external dependencies in the given file (report RP0402 must
|
||||||
|
# not be disabled)
|
||||||
|
ext-import-graph=
|
||||||
|
|
||||||
|
# Create a graph of every (i.e. non external) dependencies in the given file
|
||||||
|
# (report RP0402 must not be disabled)
|
||||||
|
import-graph=
|
||||||
|
|
||||||
|
# Create a graph of those files that have a dependency loop.
|
||||||
|
int-import-graph=
|
||||||
|
|
||||||
|
# Force import order to recognize a module as part of the standard
|
||||||
|
# compatibility libraries.
|
||||||
|
known-standard-library=
|
||||||
|
|
||||||
|
# Force import order to recognize a module as part of a third party library.
|
||||||
|
known-third-party=enchant
|
||||||
|
|
||||||
|
|
||||||
|
[EXCEPTIONS]
|
||||||
|
|
||||||
|
# Exceptions that will emit a warning when being caught. Defaults to
|
||||||
|
# "BaseException, Exception".
|
||||||
|
overgeneral-exceptions=builtins.BaseException,builtins.Exception
|
||||||
134
skills/addon/__init__.py
Normal file
134
skills/addon/__init__.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
Blender Toolkit WebSocket Server
|
||||||
|
Claude Code와 통신하기 위한 WebSocket 서버 애드온
|
||||||
|
|
||||||
|
설치 방법:
|
||||||
|
1. Blender > Edit > Preferences > Add-ons > Install
|
||||||
|
2. 이 파일 선택
|
||||||
|
3. "Blender Toolkit WebSocket Server" 활성화
|
||||||
|
"""
|
||||||
|
|
||||||
|
# flake8: noqa: E402
|
||||||
|
# Blender addon requires bl_info at top of file
|
||||||
|
|
||||||
|
bl_info = { # type: ignore[misc]
|
||||||
|
"name": "Blender Toolkit WebSocket Server",
|
||||||
|
"author": "Dev GOM",
|
||||||
|
"version": (1, 0, 0),
|
||||||
|
"blender": (4, 0, 0),
|
||||||
|
"location": "View3D > Sidebar > Blender Toolkit",
|
||||||
|
"description": (
|
||||||
|
"WebSocket server for Claude Code integration "
|
||||||
|
"with animation retargeting"
|
||||||
|
),
|
||||||
|
"category": "Animation",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add bundled dependencies to sys.path
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
_addon_dir = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
_libs_dir = os.path.join(_addon_dir, 'libs')
|
||||||
|
if os.path.exists(_libs_dir) and _libs_dir not in sys.path:
|
||||||
|
sys.path.insert(0, _libs_dir)
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
# Logging utilities
|
||||||
|
from .utils.logger import get_logger
|
||||||
|
|
||||||
|
# WebSocket Server
|
||||||
|
from .websocket_server import BlenderWebSocketServer
|
||||||
|
|
||||||
|
# UI Classes
|
||||||
|
from .ui import (
|
||||||
|
BoneMappingItem,
|
||||||
|
BLENDERTOOLKIT_PT_Panel,
|
||||||
|
BLENDERTOOLKIT_PT_BoneMappingPanel,
|
||||||
|
BLENDERTOOLKIT_OT_StartServer,
|
||||||
|
BLENDERTOOLKIT_OT_StopServer,
|
||||||
|
BLENDERTOOLKIT_OT_AutoRemap,
|
||||||
|
BLENDERTOOLKIT_OT_ApplyRetargeting,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 모듈 로거 초기화
|
||||||
|
logger = get_logger('addon')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 등록/해제
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def register():
|
||||||
|
"""Blender 애드온 클래스 및 속성 등록."""
|
||||||
|
# Register property groups first
|
||||||
|
bpy.utils.register_class(BoneMappingItem)
|
||||||
|
|
||||||
|
# Register UI panels
|
||||||
|
bpy.utils.register_class(BLENDERTOOLKIT_PT_Panel)
|
||||||
|
bpy.utils.register_class(BLENDERTOOLKIT_PT_BoneMappingPanel)
|
||||||
|
|
||||||
|
# Register operators
|
||||||
|
bpy.utils.register_class(BLENDERTOOLKIT_OT_StartServer)
|
||||||
|
bpy.utils.register_class(BLENDERTOOLKIT_OT_StopServer)
|
||||||
|
bpy.utils.register_class(BLENDERTOOLKIT_OT_AutoRemap)
|
||||||
|
bpy.utils.register_class(BLENDERTOOLKIT_OT_ApplyRetargeting)
|
||||||
|
|
||||||
|
# 포트 설정 속성
|
||||||
|
bpy.types.Scene.blender_toolkit_port = bpy.props.IntProperty(
|
||||||
|
name="Port",
|
||||||
|
description="WebSocket server port",
|
||||||
|
default=9400,
|
||||||
|
min=1024,
|
||||||
|
max=65535
|
||||||
|
)
|
||||||
|
|
||||||
|
# 본 매핑 속성
|
||||||
|
bpy.types.Scene.bone_mapping_items = bpy.props.CollectionProperty(
|
||||||
|
type=BoneMappingItem,
|
||||||
|
name="Bone Mapping Items"
|
||||||
|
)
|
||||||
|
bpy.types.Scene.bone_mapping_source_armature = bpy.props.StringProperty(
|
||||||
|
name="Source Armature",
|
||||||
|
description="Source armature name"
|
||||||
|
)
|
||||||
|
bpy.types.Scene.bone_mapping_target_armature = bpy.props.StringProperty(
|
||||||
|
name="Target Armature",
|
||||||
|
description="Target armature name"
|
||||||
|
)
|
||||||
|
bpy.types.Scene.bone_mapping_status = bpy.props.StringProperty(
|
||||||
|
name="Bone Mapping Status",
|
||||||
|
description="Current status of bone mapping operation",
|
||||||
|
default=""
|
||||||
|
)
|
||||||
|
|
||||||
|
print("✅ Blender Toolkit WebSocket Server registered")
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
"""Blender 애드온 클래스 및 속성 등록 해제."""
|
||||||
|
# Unregister operators
|
||||||
|
bpy.utils.unregister_class(BLENDERTOOLKIT_OT_ApplyRetargeting)
|
||||||
|
bpy.utils.unregister_class(BLENDERTOOLKIT_OT_AutoRemap)
|
||||||
|
bpy.utils.unregister_class(BLENDERTOOLKIT_OT_StopServer)
|
||||||
|
bpy.utils.unregister_class(BLENDERTOOLKIT_OT_StartServer)
|
||||||
|
|
||||||
|
# Unregister UI panels
|
||||||
|
bpy.utils.unregister_class(BLENDERTOOLKIT_PT_BoneMappingPanel)
|
||||||
|
bpy.utils.unregister_class(BLENDERTOOLKIT_PT_Panel)
|
||||||
|
|
||||||
|
# Unregister property groups
|
||||||
|
bpy.utils.unregister_class(BoneMappingItem)
|
||||||
|
|
||||||
|
# Delete properties
|
||||||
|
del bpy.types.Scene.bone_mapping_status
|
||||||
|
del bpy.types.Scene.bone_mapping_target_armature
|
||||||
|
del bpy.types.Scene.bone_mapping_source_armature
|
||||||
|
del bpy.types.Scene.bone_mapping_items
|
||||||
|
del bpy.types.Scene.blender_toolkit_port
|
||||||
|
|
||||||
|
print("🔌 Blender Toolkit WebSocket Server unregistered")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
register()
|
||||||
91
skills/addon/commands/__init__.py
Normal file
91
skills/addon/commands/__init__.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
Command Handlers
|
||||||
|
WebSocket 명령 핸들러 모듈
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .armature import list_armatures, get_bones
|
||||||
|
from .retargeting import auto_map_bones, retarget_animation, get_preset_bone_mapping
|
||||||
|
from .animation import list_animations, play_animation, stop_animation, add_to_nla
|
||||||
|
from .import_ import import_fbx, import_dae
|
||||||
|
from .bone_mapping import store_bone_mapping, load_bone_mapping
|
||||||
|
from .geometry import (
|
||||||
|
# Primitive creation
|
||||||
|
create_cube, create_sphere, create_cylinder, create_plane,
|
||||||
|
create_cone, create_torus,
|
||||||
|
# Object operations
|
||||||
|
delete_object, transform_object, duplicate_object, list_objects,
|
||||||
|
# Vertex operations
|
||||||
|
get_vertices, move_vertex, subdivide_mesh, extrude_face
|
||||||
|
)
|
||||||
|
from .modifier import (
|
||||||
|
# Modifier operations
|
||||||
|
add_modifier, apply_modifier, list_modifiers, remove_modifier,
|
||||||
|
toggle_modifier, modify_modifier_properties, get_modifier_info, reorder_modifier
|
||||||
|
)
|
||||||
|
from .material import (
|
||||||
|
# Material creation
|
||||||
|
create_material, list_materials, delete_material,
|
||||||
|
# Material assignment
|
||||||
|
assign_material, list_object_materials,
|
||||||
|
# Material properties
|
||||||
|
set_material_base_color, set_material_metallic, set_material_roughness,
|
||||||
|
set_material_emission, get_material_properties
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Armature commands
|
||||||
|
'list_armatures',
|
||||||
|
'get_bones',
|
||||||
|
# Retargeting commands
|
||||||
|
'auto_map_bones',
|
||||||
|
'retarget_animation',
|
||||||
|
'get_preset_bone_mapping',
|
||||||
|
# Animation commands
|
||||||
|
'list_animations',
|
||||||
|
'play_animation',
|
||||||
|
'stop_animation',
|
||||||
|
'add_to_nla',
|
||||||
|
# Import commands
|
||||||
|
'import_fbx',
|
||||||
|
'import_dae',
|
||||||
|
# Bone mapping commands
|
||||||
|
'store_bone_mapping',
|
||||||
|
'load_bone_mapping',
|
||||||
|
# Geometry - Primitive creation
|
||||||
|
'create_cube',
|
||||||
|
'create_sphere',
|
||||||
|
'create_cylinder',
|
||||||
|
'create_plane',
|
||||||
|
'create_cone',
|
||||||
|
'create_torus',
|
||||||
|
# Geometry - Object operations
|
||||||
|
'delete_object',
|
||||||
|
'transform_object',
|
||||||
|
'duplicate_object',
|
||||||
|
'list_objects',
|
||||||
|
# Geometry - Vertex operations
|
||||||
|
'get_vertices',
|
||||||
|
'move_vertex',
|
||||||
|
'subdivide_mesh',
|
||||||
|
'extrude_face',
|
||||||
|
# Modifier operations
|
||||||
|
'add_modifier',
|
||||||
|
'apply_modifier',
|
||||||
|
'list_modifiers',
|
||||||
|
'remove_modifier',
|
||||||
|
'toggle_modifier',
|
||||||
|
'modify_modifier_properties',
|
||||||
|
'get_modifier_info',
|
||||||
|
'reorder_modifier',
|
||||||
|
# Material operations
|
||||||
|
'create_material',
|
||||||
|
'list_materials',
|
||||||
|
'delete_material',
|
||||||
|
'assign_material',
|
||||||
|
'list_object_materials',
|
||||||
|
'set_material_base_color',
|
||||||
|
'set_material_metallic',
|
||||||
|
'set_material_roughness',
|
||||||
|
'set_material_emission',
|
||||||
|
'get_material_properties',
|
||||||
|
]
|
||||||
136
skills/addon/commands/animation.py
Normal file
136
skills/addon/commands/animation.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
Animation 관련 명령 핸들러
|
||||||
|
애니메이션 재생, NLA 트랙 관리
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from typing import List
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def list_animations(armature_name: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
아마추어의 애니메이션 액션 목록
|
||||||
|
|
||||||
|
Args:
|
||||||
|
armature_name: 아마추어 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
액션 이름 리스트
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 아마추어를 찾을 수 없는 경우
|
||||||
|
"""
|
||||||
|
logger.debug(f"Listing animations for armature: {armature_name}")
|
||||||
|
|
||||||
|
armature = bpy.data.objects.get(armature_name)
|
||||||
|
if not armature:
|
||||||
|
logger.error(f"Armature '{armature_name}' not found")
|
||||||
|
raise ValueError(f"Armature '{armature_name}' not found")
|
||||||
|
|
||||||
|
actions = []
|
||||||
|
if armature.animation_data:
|
||||||
|
for action in bpy.data.actions:
|
||||||
|
if action.id_root == 'OBJECT':
|
||||||
|
actions.append(action.name)
|
||||||
|
|
||||||
|
logger.info(f"Found {len(actions)} animations for {armature_name}")
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def play_animation(armature_name: str, action_name: str, loop: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
애니메이션 재생
|
||||||
|
|
||||||
|
Args:
|
||||||
|
armature_name: 아마추어 이름
|
||||||
|
action_name: 액션 이름
|
||||||
|
loop: 루프 재생 여부
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
결과 메시지
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 아마추어 또는 액션을 찾을 수 없는 경우
|
||||||
|
"""
|
||||||
|
logger.info(f"Playing animation: {action_name} on {armature_name}")
|
||||||
|
|
||||||
|
armature = bpy.data.objects.get(armature_name)
|
||||||
|
if not armature:
|
||||||
|
logger.error(f"Armature '{armature_name}' not found")
|
||||||
|
raise ValueError(f"Armature '{armature_name}' not found")
|
||||||
|
|
||||||
|
action = bpy.data.actions.get(action_name)
|
||||||
|
if not action:
|
||||||
|
logger.error(f"Action '{action_name}' not found")
|
||||||
|
raise ValueError(f"Action '{action_name}' not found")
|
||||||
|
|
||||||
|
if not armature.animation_data:
|
||||||
|
armature.animation_data_create()
|
||||||
|
|
||||||
|
armature.animation_data.action = action
|
||||||
|
bpy.context.scene.frame_set(int(action.frame_range[0]))
|
||||||
|
bpy.ops.screen.animation_play()
|
||||||
|
|
||||||
|
logger.info(f"Started playing {action_name}")
|
||||||
|
return f"Playing {action_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def stop_animation() -> str:
|
||||||
|
"""
|
||||||
|
애니메이션 중지
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
결과 메시지
|
||||||
|
"""
|
||||||
|
logger.info("Stopping animation playback")
|
||||||
|
bpy.ops.screen.animation_cancel()
|
||||||
|
return "Animation stopped"
|
||||||
|
|
||||||
|
|
||||||
|
def add_to_nla(armature_name: str, action_name: str, track_name: str) -> str:
|
||||||
|
"""
|
||||||
|
NLA 트랙에 애니메이션 추가
|
||||||
|
|
||||||
|
Args:
|
||||||
|
armature_name: 아마추어 이름
|
||||||
|
action_name: 액션 이름
|
||||||
|
track_name: 트랙 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
결과 메시지
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 아마추어 또는 액션을 찾을 수 없는 경우
|
||||||
|
"""
|
||||||
|
logger.info(f"Adding {action_name} to NLA track {track_name} on {armature_name}")
|
||||||
|
|
||||||
|
armature = bpy.data.objects.get(armature_name)
|
||||||
|
if not armature:
|
||||||
|
logger.error(f"Armature '{armature_name}' not found")
|
||||||
|
raise ValueError(f"Armature '{armature_name}' not found")
|
||||||
|
|
||||||
|
action = bpy.data.actions.get(action_name)
|
||||||
|
if not action:
|
||||||
|
logger.error(f"Action '{action_name}' not found")
|
||||||
|
raise ValueError(f"Action '{action_name}' not found")
|
||||||
|
|
||||||
|
if not armature.animation_data:
|
||||||
|
armature.animation_data_create()
|
||||||
|
|
||||||
|
# NLA 트랙 생성 또는 찾기
|
||||||
|
nla_tracks = armature.animation_data.nla_tracks
|
||||||
|
track = nla_tracks.get(track_name)
|
||||||
|
|
||||||
|
if not track:
|
||||||
|
track = nla_tracks.new()
|
||||||
|
track.name = track_name
|
||||||
|
logger.debug(f"Created new NLA track: {track_name}")
|
||||||
|
|
||||||
|
# 액션을 스트립으로 추가
|
||||||
|
strip = track.strips.new(action.name, int(action.frame_range[0]), action)
|
||||||
|
logger.info(f"Added strip {strip.name} to track {track_name}")
|
||||||
|
|
||||||
|
return f"Added {action_name} to NLA track {track_name}"
|
||||||
55
skills/addon/commands/armature.py
Normal file
55
skills/addon/commands/armature.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
Armature 관련 명령 핸들러
|
||||||
|
아마추어 정보 조회 및 본 구조 분석
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from typing import List, Dict
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def list_armatures() -> List[str]:
|
||||||
|
"""
|
||||||
|
모든 아마추어 오브젝트 목록 반환
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
아마추어 이름 리스트
|
||||||
|
"""
|
||||||
|
logger.debug("Listing all armatures")
|
||||||
|
armatures = [obj.name for obj in bpy.data.objects if obj.type == 'ARMATURE']
|
||||||
|
logger.info(f"Found {len(armatures)} armatures")
|
||||||
|
return armatures
|
||||||
|
|
||||||
|
|
||||||
|
def get_bones(armature_name: str) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
아마추어의 본 정보 가져오기
|
||||||
|
|
||||||
|
Args:
|
||||||
|
armature_name: 아마추어 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
본 정보 리스트 (name, parent, children)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 아마추어를 찾을 수 없거나 타입이 잘못된 경우
|
||||||
|
"""
|
||||||
|
logger.debug(f"Getting bones for armature: {armature_name}")
|
||||||
|
|
||||||
|
armature = bpy.data.objects.get(armature_name)
|
||||||
|
if not armature or armature.type != 'ARMATURE':
|
||||||
|
logger.error(f"Armature '{armature_name}' not found or invalid type")
|
||||||
|
raise ValueError(f"Armature '{armature_name}' not found")
|
||||||
|
|
||||||
|
bones = []
|
||||||
|
for bone in armature.data.bones:
|
||||||
|
bones.append({
|
||||||
|
"name": bone.name,
|
||||||
|
"parent": bone.parent.name if bone.parent else None,
|
||||||
|
"children": [child.name for child in bone.children]
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Retrieved {len(bones)} bones from {armature_name}")
|
||||||
|
return bones
|
||||||
90
skills/addon/commands/bone_mapping.py
Normal file
90
skills/addon/commands/bone_mapping.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
Bone Mapping 관련 명령 핸들러
|
||||||
|
본 매핑 저장/로드, UI 표시
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from typing import Dict
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def store_bone_mapping(source_armature: str, target_armature: str, bone_mapping: Dict[str, str]) -> str:
|
||||||
|
"""
|
||||||
|
본 매핑을 Scene 속성에 저장
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_armature: 소스 아마추어 이름
|
||||||
|
target_armature: 타겟 아마추어 이름
|
||||||
|
bone_mapping: 본 매핑 딕셔너리
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
결과 메시지
|
||||||
|
"""
|
||||||
|
logger.info(f"Storing bone mapping: {source_armature} -> {target_armature} ({len(bone_mapping)} bones)")
|
||||||
|
|
||||||
|
scene = bpy.context.scene
|
||||||
|
|
||||||
|
# 기존 매핑 클리어
|
||||||
|
scene.bone_mapping_items.clear()
|
||||||
|
|
||||||
|
# 새 매핑 저장
|
||||||
|
for source_bone, target_bone in bone_mapping.items():
|
||||||
|
item = scene.bone_mapping_items.add()
|
||||||
|
item.source_bone = source_bone
|
||||||
|
item.target_bone = target_bone
|
||||||
|
|
||||||
|
# 아마추어 정보 저장
|
||||||
|
scene.bone_mapping_source_armature = source_armature
|
||||||
|
scene.bone_mapping_target_armature = target_armature
|
||||||
|
|
||||||
|
logger.info(f"Stored {len(bone_mapping)} bone mappings")
|
||||||
|
print(f"✅ Stored bone mapping: {len(bone_mapping)} bones")
|
||||||
|
return f"Bone mapping stored ({len(bone_mapping)} bones)"
|
||||||
|
|
||||||
|
|
||||||
|
def load_bone_mapping(source_armature: str, target_armature: str) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Scene 속성에서 본 매핑 로드
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_armature: 소스 아마추어 이름
|
||||||
|
target_armature: 타겟 아마추어 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
본 매핑 딕셔너리
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 저장된 매핑이 없거나 불일치하는 경우
|
||||||
|
"""
|
||||||
|
logger.info(f"Loading bone mapping: {source_armature} -> {target_armature}")
|
||||||
|
|
||||||
|
scene = bpy.context.scene
|
||||||
|
|
||||||
|
# 아마추어 검증
|
||||||
|
if not scene.bone_mapping_source_armature:
|
||||||
|
logger.error("No bone mapping stored")
|
||||||
|
raise ValueError("No bone mapping stored. Please generate mapping first using BoneMapping.show command.")
|
||||||
|
|
||||||
|
if (scene.bone_mapping_source_armature != source_armature or
|
||||||
|
scene.bone_mapping_target_armature != target_armature):
|
||||||
|
logger.error("Stored mapping doesn't match requested armatures")
|
||||||
|
raise ValueError(
|
||||||
|
f"Stored mapping for ({scene.bone_mapping_source_armature} → "
|
||||||
|
f"{scene.bone_mapping_target_armature}) doesn't match requested "
|
||||||
|
f"({source_armature} → {target_armature})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 매핑 로드
|
||||||
|
bone_mapping = {}
|
||||||
|
for item in scene.bone_mapping_items:
|
||||||
|
bone_mapping[item.source_bone] = item.target_bone
|
||||||
|
|
||||||
|
if not bone_mapping:
|
||||||
|
logger.error("Bone mapping is empty")
|
||||||
|
raise ValueError("Bone mapping is empty. Please generate mapping first.")
|
||||||
|
|
||||||
|
logger.info(f"Loaded {len(bone_mapping)} bone mappings")
|
||||||
|
print(f"✅ Loaded bone mapping: {len(bone_mapping)} bones")
|
||||||
|
return bone_mapping
|
||||||
88
skills/addon/commands/collection.py
Normal file
88
skills/addon/commands/collection.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Collection Operations
|
||||||
|
컬렉션 관리 명령 핸들러
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from typing import Dict, List, Any
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_collection(name: str) -> Dict[str, Any]:
|
||||||
|
"""컬렉션 생성"""
|
||||||
|
logger.info(f"Creating collection: {name}")
|
||||||
|
|
||||||
|
if name in bpy.data.collections:
|
||||||
|
logger.warn(f"Collection '{name}' already exists")
|
||||||
|
coll = bpy.data.collections[name]
|
||||||
|
else:
|
||||||
|
coll = bpy.data.collections.new(name)
|
||||||
|
bpy.context.scene.collection.children.link(coll)
|
||||||
|
|
||||||
|
return {'name': coll.name, 'objects': len(coll.objects)}
|
||||||
|
|
||||||
|
|
||||||
|
def list_collections() -> List[Dict[str, Any]]:
|
||||||
|
"""모든 컬렉션 목록 조회"""
|
||||||
|
logger.info("Listing all collections")
|
||||||
|
|
||||||
|
collections = []
|
||||||
|
for coll in bpy.data.collections:
|
||||||
|
collections.append({
|
||||||
|
'name': coll.name,
|
||||||
|
'objects': len(coll.objects),
|
||||||
|
'children': len(coll.children)
|
||||||
|
})
|
||||||
|
|
||||||
|
return collections
|
||||||
|
|
||||||
|
|
||||||
|
def add_to_collection(object_name: str, collection_name: str) -> Dict[str, str]:
|
||||||
|
"""오브젝트를 컬렉션에 추가"""
|
||||||
|
logger.info(f"Adding '{object_name}' to collection '{collection_name}'")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(object_name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{object_name}' not found")
|
||||||
|
|
||||||
|
coll = bpy.data.collections.get(collection_name)
|
||||||
|
if not coll:
|
||||||
|
raise ValueError(f"Collection '{collection_name}' not found")
|
||||||
|
|
||||||
|
if obj.name not in coll.objects:
|
||||||
|
coll.objects.link(obj)
|
||||||
|
|
||||||
|
return {'status': 'success', 'message': f"Added '{object_name}' to '{collection_name}'"}
|
||||||
|
|
||||||
|
|
||||||
|
def remove_from_collection(object_name: str, collection_name: str) -> Dict[str, str]:
|
||||||
|
"""오브젝트를 컬렉션에서 제거"""
|
||||||
|
logger.info(f"Removing '{object_name}' from collection '{collection_name}'")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(object_name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{object_name}' not found")
|
||||||
|
|
||||||
|
coll = bpy.data.collections.get(collection_name)
|
||||||
|
if not coll:
|
||||||
|
raise ValueError(f"Collection '{collection_name}' not found")
|
||||||
|
|
||||||
|
if obj.name in coll.objects:
|
||||||
|
coll.objects.unlink(obj)
|
||||||
|
|
||||||
|
return {'status': 'success', 'message': f"Removed '{object_name}' from '{collection_name}'"}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_collection(name: str) -> Dict[str, str]:
|
||||||
|
"""컬렉션 삭제"""
|
||||||
|
logger.info(f"Deleting collection: {name}")
|
||||||
|
|
||||||
|
coll = bpy.data.collections.get(name)
|
||||||
|
if not coll:
|
||||||
|
raise ValueError(f"Collection '{name}' not found")
|
||||||
|
|
||||||
|
bpy.data.collections.remove(coll)
|
||||||
|
|
||||||
|
return {'status': 'success', 'message': f"Collection '{name}' deleted"}
|
||||||
552
skills/addon/commands/geometry.py
Normal file
552
skills/addon/commands/geometry.py
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
"""
|
||||||
|
Geometry Operations
|
||||||
|
도형 생성, 수정, 삭제 등 기하학적 작업을 처리하는 명령 핸들러
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
import bmesh
|
||||||
|
from typing import Dict, List, Tuple, Optional, Any
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Primitive Creation (기본 도형 생성)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def create_cube(
|
||||||
|
location: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
size: float = 2.0,
|
||||||
|
name: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
큐브 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location: 위치 (x, y, z)
|
||||||
|
size: 크기
|
||||||
|
name: 오브젝트 이름 (None이면 자동 생성)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
생성된 오브젝트 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"Creating cube at {location} with size {size}")
|
||||||
|
|
||||||
|
bpy.ops.mesh.primitive_cube_add(size=size, location=location)
|
||||||
|
obj = bpy.context.active_object
|
||||||
|
|
||||||
|
if name:
|
||||||
|
obj.name = name
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': obj.name,
|
||||||
|
'type': obj.type,
|
||||||
|
'location': list(obj.location),
|
||||||
|
'vertices': len(obj.data.vertices),
|
||||||
|
'faces': len(obj.data.polygons)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_sphere(
|
||||||
|
location: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
radius: float = 1.0,
|
||||||
|
segments: int = 32,
|
||||||
|
ring_count: int = 16,
|
||||||
|
name: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
구(Sphere) 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location: 위치 (x, y, z)
|
||||||
|
radius: 반지름
|
||||||
|
segments: 세그먼트 수 (수평)
|
||||||
|
ring_count: 링 수 (수직)
|
||||||
|
name: 오브젝트 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
생성된 오브젝트 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"Creating sphere at {location} with radius {radius}")
|
||||||
|
|
||||||
|
bpy.ops.mesh.primitive_uv_sphere_add(
|
||||||
|
radius=radius,
|
||||||
|
segments=segments,
|
||||||
|
ring_count=ring_count,
|
||||||
|
location=location
|
||||||
|
)
|
||||||
|
obj = bpy.context.active_object
|
||||||
|
|
||||||
|
if name:
|
||||||
|
obj.name = name
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': obj.name,
|
||||||
|
'type': obj.type,
|
||||||
|
'location': list(obj.location),
|
||||||
|
'vertices': len(obj.data.vertices),
|
||||||
|
'faces': len(obj.data.polygons)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_cylinder(
|
||||||
|
location: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
radius: float = 1.0,
|
||||||
|
depth: float = 2.0,
|
||||||
|
vertices: int = 32,
|
||||||
|
name: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
실린더 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location: 위치 (x, y, z)
|
||||||
|
radius: 반지름
|
||||||
|
depth: 높이
|
||||||
|
vertices: 버텍스 수
|
||||||
|
name: 오브젝트 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
생성된 오브젝트 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"Creating cylinder at {location}")
|
||||||
|
|
||||||
|
bpy.ops.mesh.primitive_cylinder_add(
|
||||||
|
radius=radius,
|
||||||
|
depth=depth,
|
||||||
|
vertices=vertices,
|
||||||
|
location=location
|
||||||
|
)
|
||||||
|
obj = bpy.context.active_object
|
||||||
|
|
||||||
|
if name:
|
||||||
|
obj.name = name
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': obj.name,
|
||||||
|
'type': obj.type,
|
||||||
|
'location': list(obj.location),
|
||||||
|
'vertices': len(obj.data.vertices),
|
||||||
|
'faces': len(obj.data.polygons)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_plane(
|
||||||
|
location: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
size: float = 2.0,
|
||||||
|
name: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
평면(Plane) 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location: 위치 (x, y, z)
|
||||||
|
size: 크기
|
||||||
|
name: 오브젝트 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
생성된 오브젝트 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"Creating plane at {location}")
|
||||||
|
|
||||||
|
bpy.ops.mesh.primitive_plane_add(size=size, location=location)
|
||||||
|
obj = bpy.context.active_object
|
||||||
|
|
||||||
|
if name:
|
||||||
|
obj.name = name
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': obj.name,
|
||||||
|
'type': obj.type,
|
||||||
|
'location': list(obj.location),
|
||||||
|
'vertices': len(obj.data.vertices),
|
||||||
|
'faces': len(obj.data.polygons)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_cone(
|
||||||
|
location: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
radius1: float = 1.0,
|
||||||
|
depth: float = 2.0,
|
||||||
|
vertices: int = 32,
|
||||||
|
name: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
원뿔(Cone) 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location: 위치 (x, y, z)
|
||||||
|
radius1: 아래 반지름
|
||||||
|
depth: 높이
|
||||||
|
vertices: 버텍스 수
|
||||||
|
name: 오브젝트 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
생성된 오브젝트 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"Creating cone at {location}")
|
||||||
|
|
||||||
|
bpy.ops.mesh.primitive_cone_add(
|
||||||
|
radius1=radius1,
|
||||||
|
depth=depth,
|
||||||
|
vertices=vertices,
|
||||||
|
location=location
|
||||||
|
)
|
||||||
|
obj = bpy.context.active_object
|
||||||
|
|
||||||
|
if name:
|
||||||
|
obj.name = name
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': obj.name,
|
||||||
|
'type': obj.type,
|
||||||
|
'location': list(obj.location),
|
||||||
|
'vertices': len(obj.data.vertices),
|
||||||
|
'faces': len(obj.data.polygons)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_torus(
|
||||||
|
location: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
major_radius: float = 1.0,
|
||||||
|
minor_radius: float = 0.25,
|
||||||
|
major_segments: int = 48,
|
||||||
|
minor_segments: int = 12,
|
||||||
|
name: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
토러스(Torus) 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location: 위치 (x, y, z)
|
||||||
|
major_radius: 주 반지름
|
||||||
|
minor_radius: 부 반지름
|
||||||
|
major_segments: 주 세그먼트 수
|
||||||
|
minor_segments: 부 세그먼트 수
|
||||||
|
name: 오브젝트 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
생성된 오브젝트 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"Creating torus at {location}")
|
||||||
|
|
||||||
|
bpy.ops.mesh.primitive_torus_add(
|
||||||
|
major_radius=major_radius,
|
||||||
|
minor_radius=minor_radius,
|
||||||
|
major_segments=major_segments,
|
||||||
|
minor_segments=minor_segments,
|
||||||
|
location=location
|
||||||
|
)
|
||||||
|
obj = bpy.context.active_object
|
||||||
|
|
||||||
|
if name:
|
||||||
|
obj.name = name
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': obj.name,
|
||||||
|
'type': obj.type,
|
||||||
|
'location': list(obj.location),
|
||||||
|
'vertices': len(obj.data.vertices),
|
||||||
|
'faces': len(obj.data.polygons)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Object Operations (오브젝트 작업)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def delete_object(name: str) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
오브젝트 삭제
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 오브젝트 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
삭제 결과
|
||||||
|
"""
|
||||||
|
logger.info(f"Deleting object: {name}")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{name}' not found")
|
||||||
|
|
||||||
|
bpy.data.objects.remove(obj, do_unlink=True)
|
||||||
|
|
||||||
|
return {'status': 'success', 'message': f"Object '{name}' deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
def transform_object(
|
||||||
|
name: str,
|
||||||
|
location: Optional[Tuple[float, float, float]] = None,
|
||||||
|
rotation: Optional[Tuple[float, float, float]] = None,
|
||||||
|
scale: Optional[Tuple[float, float, float]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
오브젝트 변형 (이동, 회전, 스케일)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 오브젝트 이름
|
||||||
|
location: 위치 (x, y, z)
|
||||||
|
rotation: 회전 (x, y, z) in radians
|
||||||
|
scale: 스케일 (x, y, z)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
변형된 오브젝트 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"Transforming object: {name}")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{name}' not found")
|
||||||
|
|
||||||
|
if location:
|
||||||
|
obj.location = location
|
||||||
|
|
||||||
|
if rotation:
|
||||||
|
obj.rotation_euler = rotation
|
||||||
|
|
||||||
|
if scale:
|
||||||
|
obj.scale = scale
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': obj.name,
|
||||||
|
'location': list(obj.location),
|
||||||
|
'rotation': list(obj.rotation_euler),
|
||||||
|
'scale': list(obj.scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def duplicate_object(
|
||||||
|
name: str,
|
||||||
|
new_name: Optional[str] = None,
|
||||||
|
location: Optional[Tuple[float, float, float]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
오브젝트 복제
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 원본 오브젝트 이름
|
||||||
|
new_name: 새 오브젝트 이름 (None이면 자동 생성)
|
||||||
|
location: 새 위치 (None이면 원본 위치)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
복제된 오브젝트 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"Duplicating object: {name}")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{name}' not found")
|
||||||
|
|
||||||
|
# 복제
|
||||||
|
new_obj = obj.copy()
|
||||||
|
new_obj.data = obj.data.copy()
|
||||||
|
|
||||||
|
if new_name:
|
||||||
|
new_obj.name = new_name
|
||||||
|
|
||||||
|
if location:
|
||||||
|
new_obj.location = location
|
||||||
|
|
||||||
|
# 씬에 추가
|
||||||
|
bpy.context.collection.objects.link(new_obj)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': new_obj.name,
|
||||||
|
'type': new_obj.type,
|
||||||
|
'location': list(new_obj.location)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_objects(object_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
씬의 오브젝트 목록 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_type: 오브젝트 타입 필터 (None이면 전체)
|
||||||
|
예: 'MESH', 'ARMATURE', 'CAMERA', 'LIGHT'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
오브젝트 목록
|
||||||
|
"""
|
||||||
|
logger.info(f"Listing objects (type: {object_type or 'ALL'})")
|
||||||
|
|
||||||
|
objects = []
|
||||||
|
for obj in bpy.data.objects:
|
||||||
|
if object_type and obj.type != object_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
objects.append({
|
||||||
|
'name': obj.name,
|
||||||
|
'type': obj.type,
|
||||||
|
'location': list(obj.location),
|
||||||
|
'rotation': list(obj.rotation_euler),
|
||||||
|
'scale': list(obj.scale)
|
||||||
|
})
|
||||||
|
|
||||||
|
return objects
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Vertex Operations (버텍스 작업)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def get_vertices(name: str) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
오브젝트의 버텍스 정보 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 오브젝트 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
버텍스 목록
|
||||||
|
"""
|
||||||
|
logger.info(f"Getting vertices for object: {name}")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(name)
|
||||||
|
if not obj or obj.type != 'MESH':
|
||||||
|
raise ValueError(f"Mesh object '{name}' not found")
|
||||||
|
|
||||||
|
vertices = []
|
||||||
|
for i, vert in enumerate(obj.data.vertices):
|
||||||
|
vertices.append({
|
||||||
|
'index': i,
|
||||||
|
'co': list(vert.co),
|
||||||
|
'normal': list(vert.normal)
|
||||||
|
})
|
||||||
|
|
||||||
|
return vertices
|
||||||
|
|
||||||
|
|
||||||
|
def move_vertex(
|
||||||
|
object_name: str,
|
||||||
|
vertex_index: int,
|
||||||
|
new_position: Tuple[float, float, float]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
버텍스 이동
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_name: 오브젝트 이름
|
||||||
|
vertex_index: 버텍스 인덱스
|
||||||
|
new_position: 새 위치 (x, y, z)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
수정된 버텍스 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"Moving vertex {vertex_index} in object {object_name}")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(object_name)
|
||||||
|
if not obj or obj.type != 'MESH':
|
||||||
|
raise ValueError(f"Mesh object '{object_name}' not found")
|
||||||
|
|
||||||
|
mesh = obj.data
|
||||||
|
if vertex_index >= len(mesh.vertices):
|
||||||
|
raise ValueError(f"Vertex index {vertex_index} out of range")
|
||||||
|
|
||||||
|
mesh.vertices[vertex_index].co = new_position
|
||||||
|
mesh.update()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'object': object_name,
|
||||||
|
'vertex_index': vertex_index,
|
||||||
|
'position': list(mesh.vertices[vertex_index].co)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def subdivide_mesh(
|
||||||
|
name: str,
|
||||||
|
cuts: int = 1
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
메쉬 세분화 (Subdivide)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 오브젝트 이름
|
||||||
|
cuts: 세분화 횟수
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
세분화된 메쉬 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"Subdividing mesh: {name} (cuts: {cuts})")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(name)
|
||||||
|
if not obj or obj.type != 'MESH':
|
||||||
|
raise ValueError(f"Mesh object '{name}' not found")
|
||||||
|
|
||||||
|
# Edit 모드로 전환
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
bpy.ops.object.mode_set(mode='EDIT')
|
||||||
|
|
||||||
|
# 모든 에지 선택
|
||||||
|
bpy.ops.mesh.select_all(action='SELECT')
|
||||||
|
|
||||||
|
# 세분화
|
||||||
|
bpy.ops.mesh.subdivide(number_cuts=cuts)
|
||||||
|
|
||||||
|
# Object 모드로 복귀
|
||||||
|
bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': obj.name,
|
||||||
|
'vertices': len(obj.data.vertices),
|
||||||
|
'edges': len(obj.data.edges),
|
||||||
|
'faces': len(obj.data.polygons)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extrude_face(
|
||||||
|
object_name: str,
|
||||||
|
face_index: int,
|
||||||
|
offset: float = 1.0
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
페이스 돌출 (Extrude)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_name: 오브젝트 이름
|
||||||
|
face_index: 페이스 인덱스
|
||||||
|
offset: 돌출 거리
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
돌출 결과 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"Extruding face {face_index} in object {object_name}")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(object_name)
|
||||||
|
if not obj or obj.type != 'MESH':
|
||||||
|
raise ValueError(f"Mesh object '{object_name}' not found")
|
||||||
|
|
||||||
|
# BMesh를 사용한 extrude
|
||||||
|
mesh = obj.data
|
||||||
|
bm = bmesh.new()
|
||||||
|
bm.from_mesh(mesh)
|
||||||
|
|
||||||
|
# 페이스 선택
|
||||||
|
if face_index >= len(bm.faces):
|
||||||
|
bm.free()
|
||||||
|
raise ValueError(f"Face index {face_index} out of range")
|
||||||
|
|
||||||
|
face = bm.faces[face_index]
|
||||||
|
|
||||||
|
# Extrude
|
||||||
|
ret = bmesh.ops.extrude_face_region(bm, geom=[face])
|
||||||
|
extruded_verts = [v for v in ret['geom'] if isinstance(v, bmesh.types.BMVert)]
|
||||||
|
|
||||||
|
# 오프셋 적용
|
||||||
|
for v in extruded_verts:
|
||||||
|
v.co += face.normal * offset
|
||||||
|
|
||||||
|
# 메쉬 업데이트
|
||||||
|
bm.to_mesh(mesh)
|
||||||
|
bm.free()
|
||||||
|
mesh.update()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'object': object_name,
|
||||||
|
'face_index': face_index,
|
||||||
|
'vertices': len(mesh.vertices),
|
||||||
|
'faces': len(mesh.polygons)
|
||||||
|
}
|
||||||
79
skills/addon/commands/import_.py
Normal file
79
skills/addon/commands/import_.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Import 관련 명령 핸들러
|
||||||
|
FBX, DAE 파일 임포트
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
import os
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
from ..utils.security import validate_file_path
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def import_fbx(filepath: str) -> str:
|
||||||
|
"""
|
||||||
|
FBX 파일 임포트
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: FBX 파일 경로
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
결과 메시지
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: 임포트 실패
|
||||||
|
ValueError: 잘못된 파일 경로
|
||||||
|
"""
|
||||||
|
logger.info(f"Importing FBX file: {filepath}")
|
||||||
|
|
||||||
|
# 경로 보안 검증 (path traversal 방지)
|
||||||
|
try:
|
||||||
|
# 사용자 홈 디렉토리 또는 현재 작업 디렉토리 내로 제한
|
||||||
|
allowed_root = os.path.expanduser("~")
|
||||||
|
validated_path = validate_file_path(filepath, allowed_root)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Invalid file path: {e}")
|
||||||
|
raise ValueError(f"Invalid file path: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
bpy.ops.import_scene.fbx(filepath=validated_path)
|
||||||
|
logger.info(f"FBX import successful: {validated_path}")
|
||||||
|
return f"Imported {validated_path}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"FBX import failed: {e}", exc_info=True)
|
||||||
|
raise RuntimeError(f"Failed to import FBX: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def import_dae(filepath: str) -> str:
|
||||||
|
"""
|
||||||
|
DAE (Collada) 파일 임포트
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: DAE 파일 경로
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
결과 메시지
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: 임포트 실패
|
||||||
|
ValueError: 잘못된 파일 경로
|
||||||
|
"""
|
||||||
|
logger.info(f"Importing DAE file: {filepath}")
|
||||||
|
|
||||||
|
# 경로 보안 검증 (path traversal 방지)
|
||||||
|
try:
|
||||||
|
# 사용자 홈 디렉토리 또는 현재 작업 디렉토리 내로 제한
|
||||||
|
allowed_root = os.path.expanduser("~")
|
||||||
|
validated_path = validate_file_path(filepath, allowed_root)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Invalid file path: {e}")
|
||||||
|
raise ValueError(f"Invalid file path: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
bpy.ops.wm.collada_import(filepath=validated_path)
|
||||||
|
logger.info(f"DAE import successful: {validated_path}")
|
||||||
|
return f"Imported {validated_path}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DAE import failed: {e}", exc_info=True)
|
||||||
|
raise RuntimeError(f"Failed to import DAE: {str(e)}")
|
||||||
361
skills/addon/commands/material.py
Normal file
361
skills/addon/commands/material.py
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
"""
|
||||||
|
Material Operations
|
||||||
|
머티리얼 및 셰이더 관련 작업을 처리하는 명령 핸들러
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from typing import Dict, List, Tuple, Optional, Any
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Material Creation (머티리얼 생성)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def create_material(
|
||||||
|
name: str,
|
||||||
|
use_nodes: bool = True
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
머티리얼 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 머티리얼 이름
|
||||||
|
use_nodes: 노드 시스템 사용 여부 (기본값: True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
생성된 머티리얼 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"Creating material: {name}")
|
||||||
|
|
||||||
|
# 기존 머티리얼 확인
|
||||||
|
if name in bpy.data.materials:
|
||||||
|
logger.warn(f"Material '{name}' already exists, returning existing")
|
||||||
|
mat = bpy.data.materials[name]
|
||||||
|
else:
|
||||||
|
mat = bpy.data.materials.new(name=name)
|
||||||
|
mat.use_nodes = use_nodes
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': mat.name,
|
||||||
|
'use_nodes': mat.use_nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_materials() -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
모든 머티리얼 목록 조회
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
머티리얼 목록
|
||||||
|
"""
|
||||||
|
logger.info("Listing all materials")
|
||||||
|
|
||||||
|
materials = []
|
||||||
|
for mat in bpy.data.materials:
|
||||||
|
materials.append({
|
||||||
|
'name': mat.name,
|
||||||
|
'use_nodes': mat.use_nodes,
|
||||||
|
'users': mat.users # 사용 중인 오브젝트 수
|
||||||
|
})
|
||||||
|
|
||||||
|
return materials
|
||||||
|
|
||||||
|
|
||||||
|
def delete_material(name: str) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
머티리얼 삭제
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 머티리얼 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
삭제 결과
|
||||||
|
"""
|
||||||
|
logger.info(f"Deleting material: {name}")
|
||||||
|
|
||||||
|
mat = bpy.data.materials.get(name)
|
||||||
|
if not mat:
|
||||||
|
raise ValueError(f"Material '{name}' not found")
|
||||||
|
|
||||||
|
bpy.data.materials.remove(mat)
|
||||||
|
|
||||||
|
return {'status': 'success', 'message': f"Material '{name}' deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Material Assignment (머티리얼 할당)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def assign_material(
|
||||||
|
object_name: str,
|
||||||
|
material_name: str,
|
||||||
|
slot_index: int = 0
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
오브젝트에 머티리얼 할당
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_name: 오브젝트 이름
|
||||||
|
material_name: 머티리얼 이름
|
||||||
|
slot_index: 머티리얼 슬롯 인덱스 (기본값: 0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
할당 결과
|
||||||
|
"""
|
||||||
|
logger.info(f"Assigning material '{material_name}' to object '{object_name}'")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(object_name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{object_name}' not found")
|
||||||
|
|
||||||
|
mat = bpy.data.materials.get(material_name)
|
||||||
|
if not mat:
|
||||||
|
raise ValueError(f"Material '{material_name}' not found")
|
||||||
|
|
||||||
|
# 머티리얼 슬롯이 없으면 생성
|
||||||
|
if len(obj.data.materials) == 0:
|
||||||
|
obj.data.materials.append(mat)
|
||||||
|
else:
|
||||||
|
# 기존 슬롯에 할당
|
||||||
|
if slot_index < len(obj.data.materials):
|
||||||
|
obj.data.materials[slot_index] = mat
|
||||||
|
else:
|
||||||
|
obj.data.materials.append(mat)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'object': object_name,
|
||||||
|
'material': material_name,
|
||||||
|
'slot_index': slot_index
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_object_materials(object_name: str) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
오브젝트의 머티리얼 슬롯 목록 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_name: 오브젝트 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
머티리얼 슬롯 목록
|
||||||
|
"""
|
||||||
|
logger.info(f"Listing materials for object: {object_name}")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(object_name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{object_name}' not found")
|
||||||
|
|
||||||
|
materials = []
|
||||||
|
for i, mat_slot in enumerate(obj.material_slots):
|
||||||
|
materials.append({
|
||||||
|
'slot_index': i,
|
||||||
|
'material': mat_slot.material.name if mat_slot.material else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return materials
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Material Properties (머티리얼 속성)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def set_material_base_color(
|
||||||
|
material_name: str,
|
||||||
|
color: Tuple[float, float, float, float]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
머티리얼 기본 색상 설정 (Principled BSDF)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
material_name: 머티리얼 이름
|
||||||
|
color: RGBA 색상 (0.0 ~ 1.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
설정 결과
|
||||||
|
"""
|
||||||
|
logger.info(f"Setting base color for material: {material_name}")
|
||||||
|
|
||||||
|
mat = bpy.data.materials.get(material_name)
|
||||||
|
if not mat:
|
||||||
|
raise ValueError(f"Material '{material_name}' not found")
|
||||||
|
|
||||||
|
if not mat.use_nodes:
|
||||||
|
raise ValueError(f"Material '{material_name}' does not use nodes")
|
||||||
|
|
||||||
|
# Principled BSDF 노드 찾기
|
||||||
|
principled = None
|
||||||
|
for node in mat.node_tree.nodes:
|
||||||
|
if node.type == 'BSDF_PRINCIPLED':
|
||||||
|
principled = node
|
||||||
|
break
|
||||||
|
|
||||||
|
if not principled:
|
||||||
|
raise ValueError(f"Principled BSDF node not found in material '{material_name}'")
|
||||||
|
|
||||||
|
# Base Color 설정
|
||||||
|
principled.inputs['Base Color'].default_value = color
|
||||||
|
|
||||||
|
return {
|
||||||
|
'material': material_name,
|
||||||
|
'base_color': list(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_material_metallic(
|
||||||
|
material_name: str,
|
||||||
|
metallic: float
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
머티리얼 Metallic 값 설정
|
||||||
|
|
||||||
|
Args:
|
||||||
|
material_name: 머티리얼 이름
|
||||||
|
metallic: Metallic 값 (0.0 ~ 1.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
설정 결과
|
||||||
|
"""
|
||||||
|
logger.info(f"Setting metallic for material: {material_name}")
|
||||||
|
|
||||||
|
mat = bpy.data.materials.get(material_name)
|
||||||
|
if not mat or not mat.use_nodes:
|
||||||
|
raise ValueError(f"Material '{material_name}' not found or does not use nodes")
|
||||||
|
|
||||||
|
# Principled BSDF 노드 찾기
|
||||||
|
principled = None
|
||||||
|
for node in mat.node_tree.nodes:
|
||||||
|
if node.type == 'BSDF_PRINCIPLED':
|
||||||
|
principled = node
|
||||||
|
break
|
||||||
|
|
||||||
|
if not principled:
|
||||||
|
raise ValueError(f"Principled BSDF node not found")
|
||||||
|
|
||||||
|
principled.inputs['Metallic'].default_value = metallic
|
||||||
|
|
||||||
|
return {
|
||||||
|
'material': material_name,
|
||||||
|
'metallic': metallic
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_material_roughness(
|
||||||
|
material_name: str,
|
||||||
|
roughness: float
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
머티리얼 Roughness 값 설정
|
||||||
|
|
||||||
|
Args:
|
||||||
|
material_name: 머티리얼 이름
|
||||||
|
roughness: Roughness 값 (0.0 ~ 1.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
설정 결과
|
||||||
|
"""
|
||||||
|
logger.info(f"Setting roughness for material: {material_name}")
|
||||||
|
|
||||||
|
mat = bpy.data.materials.get(material_name)
|
||||||
|
if not mat or not mat.use_nodes:
|
||||||
|
raise ValueError(f"Material '{material_name}' not found or does not use nodes")
|
||||||
|
|
||||||
|
# Principled BSDF 노드 찾기
|
||||||
|
principled = None
|
||||||
|
for node in mat.node_tree.nodes:
|
||||||
|
if node.type == 'BSDF_PRINCIPLED':
|
||||||
|
principled = node
|
||||||
|
break
|
||||||
|
|
||||||
|
if not principled:
|
||||||
|
raise ValueError(f"Principled BSDF node not found")
|
||||||
|
|
||||||
|
principled.inputs['Roughness'].default_value = roughness
|
||||||
|
|
||||||
|
return {
|
||||||
|
'material': material_name,
|
||||||
|
'roughness': roughness
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_material_emission(
|
||||||
|
material_name: str,
|
||||||
|
color: Tuple[float, float, float, float],
|
||||||
|
strength: float = 1.0
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
머티리얼 Emission 설정
|
||||||
|
|
||||||
|
Args:
|
||||||
|
material_name: 머티리얼 이름
|
||||||
|
color: Emission 색상 RGBA (0.0 ~ 1.0)
|
||||||
|
strength: Emission 강도 (기본값: 1.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
설정 결과
|
||||||
|
"""
|
||||||
|
logger.info(f"Setting emission for material: {material_name}")
|
||||||
|
|
||||||
|
mat = bpy.data.materials.get(material_name)
|
||||||
|
if not mat or not mat.use_nodes:
|
||||||
|
raise ValueError(f"Material '{material_name}' not found or does not use nodes")
|
||||||
|
|
||||||
|
# Principled BSDF 노드 찾기
|
||||||
|
principled = None
|
||||||
|
for node in mat.node_tree.nodes:
|
||||||
|
if node.type == 'BSDF_PRINCIPLED':
|
||||||
|
principled = node
|
||||||
|
break
|
||||||
|
|
||||||
|
if not principled:
|
||||||
|
raise ValueError(f"Principled BSDF node not found")
|
||||||
|
|
||||||
|
principled.inputs['Emission'].default_value = color
|
||||||
|
principled.inputs['Emission Strength'].default_value = strength
|
||||||
|
|
||||||
|
return {
|
||||||
|
'material': material_name,
|
||||||
|
'emission_color': list(color),
|
||||||
|
'emission_strength': strength
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_material_properties(material_name: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
머티리얼 속성 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
material_name: 머티리얼 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
머티리얼 속성
|
||||||
|
"""
|
||||||
|
logger.info(f"Getting properties for material: {material_name}")
|
||||||
|
|
||||||
|
mat = bpy.data.materials.get(material_name)
|
||||||
|
if not mat:
|
||||||
|
raise ValueError(f"Material '{material_name}' not found")
|
||||||
|
|
||||||
|
props = {
|
||||||
|
'name': mat.name,
|
||||||
|
'use_nodes': mat.use_nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
if mat.use_nodes:
|
||||||
|
# Principled BSDF 속성 가져오기
|
||||||
|
principled = None
|
||||||
|
for node in mat.node_tree.nodes:
|
||||||
|
if node.type == 'BSDF_PRINCIPLED':
|
||||||
|
principled = node
|
||||||
|
break
|
||||||
|
|
||||||
|
if principled:
|
||||||
|
props['base_color'] = list(principled.inputs['Base Color'].default_value)
|
||||||
|
props['metallic'] = principled.inputs['Metallic'].default_value
|
||||||
|
props['roughness'] = principled.inputs['Roughness'].default_value
|
||||||
|
props['emission'] = list(principled.inputs['Emission'].default_value)
|
||||||
|
props['emission_strength'] = principled.inputs['Emission Strength'].default_value
|
||||||
|
|
||||||
|
return props
|
||||||
388
skills/addon/commands/modifier.py
Normal file
388
skills/addon/commands/modifier.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
"""
|
||||||
|
Modifier Operations
|
||||||
|
모디파이어 관리 명령 핸들러
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from typing import Dict, List, Any, Optional
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def add_modifier(object_name: str, modifier_type: str, name: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""오브젝트에 모디파이어 추가
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_name: 대상 오브젝트 이름
|
||||||
|
modifier_type: 모디파이어 타입 (SUBSURF, MIRROR, ARRAY, BEVEL, etc.)
|
||||||
|
name: 모디파이어 이름 (optional)
|
||||||
|
|
||||||
|
Supported modifier types:
|
||||||
|
- SUBSURF: Subdivision Surface
|
||||||
|
- MIRROR: Mirror
|
||||||
|
- ARRAY: Array
|
||||||
|
- BEVEL: Bevel
|
||||||
|
- BOOLEAN: Boolean
|
||||||
|
- SOLIDIFY: Solidify
|
||||||
|
- WIREFRAME: Wireframe
|
||||||
|
- SKIN: Skin
|
||||||
|
- ARMATURE: Armature
|
||||||
|
- LATTICE: Lattice
|
||||||
|
- CURVE: Curve
|
||||||
|
- SIMPLE_DEFORM: Simple Deform
|
||||||
|
- CAST: Cast
|
||||||
|
- DISPLACE: Displace
|
||||||
|
- HOOK: Hook
|
||||||
|
- LAPLACIANDEFORM: Laplacian Deform
|
||||||
|
- MESH_DEFORM: Mesh Deform
|
||||||
|
- SHRINKWRAP: Shrinkwrap
|
||||||
|
- WAVE: Wave
|
||||||
|
- OCEAN: Ocean
|
||||||
|
- PARTICLE_SYSTEM: Particle System
|
||||||
|
- CLOTH: Cloth
|
||||||
|
- COLLISION: Collision
|
||||||
|
- DYNAMIC_PAINT: Dynamic Paint
|
||||||
|
- EXPLODE: Explode
|
||||||
|
- FLUID: Fluid
|
||||||
|
- SOFT_BODY: Soft Body
|
||||||
|
"""
|
||||||
|
logger.info(f"Adding modifier '{modifier_type}' to '{object_name}'")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(object_name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{object_name}' not found")
|
||||||
|
|
||||||
|
mod = obj.modifiers.new(name or modifier_type, modifier_type)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': mod.name,
|
||||||
|
'type': mod.type,
|
||||||
|
'show_viewport': mod.show_viewport,
|
||||||
|
'show_render': mod.show_render
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_modifier(object_name: str, modifier_name: str) -> Dict[str, str]:
|
||||||
|
"""모디파이어 적용"""
|
||||||
|
logger.info(f"Applying modifier '{modifier_name}' on '{object_name}'")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(object_name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{object_name}' not found")
|
||||||
|
|
||||||
|
mod = obj.modifiers.get(modifier_name)
|
||||||
|
if not mod:
|
||||||
|
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
|
||||||
|
|
||||||
|
# 모디파이어 적용은 Edit 모드에서는 할 수 없음
|
||||||
|
if bpy.context.mode != 'OBJECT':
|
||||||
|
bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
|
||||||
|
# 오브젝트 선택 및 활성화
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
obj.select_set(True)
|
||||||
|
|
||||||
|
# 모디파이어 적용
|
||||||
|
bpy.ops.object.modifier_apply(modifier=modifier_name)
|
||||||
|
|
||||||
|
return {'status': 'success', 'message': f"Applied modifier '{modifier_name}' to '{object_name}'"}
|
||||||
|
|
||||||
|
|
||||||
|
def list_modifiers(object_name: str) -> List[Dict[str, Any]]:
|
||||||
|
"""오브젝트의 모디파이어 목록 조회"""
|
||||||
|
logger.info(f"Listing modifiers for '{object_name}'")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(object_name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{object_name}' not found")
|
||||||
|
|
||||||
|
modifiers = []
|
||||||
|
for mod in obj.modifiers:
|
||||||
|
mod_info = {
|
||||||
|
'name': mod.name,
|
||||||
|
'type': mod.type,
|
||||||
|
'show_viewport': mod.show_viewport,
|
||||||
|
'show_render': mod.show_render,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 타입별 특화 속성 추가
|
||||||
|
if mod.type == 'SUBSURF':
|
||||||
|
mod_info['levels'] = mod.levels
|
||||||
|
mod_info['render_levels'] = mod.render_levels
|
||||||
|
elif mod.type == 'MIRROR':
|
||||||
|
mod_info['use_axis'] = [mod.use_axis[0], mod.use_axis[1], mod.use_axis[2]]
|
||||||
|
mod_info['use_bisect_axis'] = [mod.use_bisect_axis[0], mod.use_bisect_axis[1], mod.use_bisect_axis[2]]
|
||||||
|
elif mod.type == 'ARRAY':
|
||||||
|
mod_info['count'] = mod.count
|
||||||
|
mod_info['use_relative_offset'] = mod.use_relative_offset
|
||||||
|
mod_info['relative_offset_displace'] = list(mod.relative_offset_displace)
|
||||||
|
elif mod.type == 'BEVEL':
|
||||||
|
mod_info['width'] = mod.width
|
||||||
|
mod_info['segments'] = mod.segments
|
||||||
|
mod_info['limit_method'] = mod.limit_method
|
||||||
|
elif mod.type == 'BOOLEAN':
|
||||||
|
mod_info['operation'] = mod.operation
|
||||||
|
mod_info['object'] = mod.object.name if mod.object else None
|
||||||
|
elif mod.type == 'SOLIDIFY':
|
||||||
|
mod_info['thickness'] = mod.thickness
|
||||||
|
mod_info['offset'] = mod.offset
|
||||||
|
elif mod.type == 'ARMATURE':
|
||||||
|
mod_info['object'] = mod.object.name if mod.object else None
|
||||||
|
mod_info['use_vertex_groups'] = mod.use_vertex_groups
|
||||||
|
elif mod.type == 'LATTICE':
|
||||||
|
mod_info['object'] = mod.object.name if mod.object else None
|
||||||
|
elif mod.type == 'CURVE':
|
||||||
|
mod_info['object'] = mod.object.name if mod.object else None
|
||||||
|
elif mod.type == 'SIMPLE_DEFORM':
|
||||||
|
mod_info['deform_method'] = mod.deform_method
|
||||||
|
mod_info['factor'] = mod.factor
|
||||||
|
elif mod.type == 'CAST':
|
||||||
|
mod_info['cast_type'] = mod.cast_type
|
||||||
|
mod_info['factor'] = mod.factor
|
||||||
|
elif mod.type == 'DISPLACE':
|
||||||
|
mod_info['strength'] = mod.strength
|
||||||
|
mod_info['direction'] = mod.direction
|
||||||
|
elif mod.type == 'WAVE':
|
||||||
|
mod_info['time_offset'] = mod.time_offset
|
||||||
|
mod_info['height'] = mod.height
|
||||||
|
|
||||||
|
modifiers.append(mod_info)
|
||||||
|
|
||||||
|
return modifiers
|
||||||
|
|
||||||
|
|
||||||
|
def remove_modifier(object_name: str, modifier_name: str) -> Dict[str, str]:
|
||||||
|
"""모디파이어 제거"""
|
||||||
|
logger.info(f"Removing modifier '{modifier_name}' from '{object_name}'")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(object_name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{object_name}' not found")
|
||||||
|
|
||||||
|
mod = obj.modifiers.get(modifier_name)
|
||||||
|
if not mod:
|
||||||
|
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
|
||||||
|
|
||||||
|
obj.modifiers.remove(mod)
|
||||||
|
|
||||||
|
return {'status': 'success', 'message': f"Removed modifier '{modifier_name}' from '{object_name}'"}
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_modifier(object_name: str, modifier_name: str,
|
||||||
|
viewport: Optional[bool] = None,
|
||||||
|
render: Optional[bool] = None) -> Dict[str, Any]:
|
||||||
|
"""모디파이어 활성화/비활성화
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_name: 대상 오브젝트 이름
|
||||||
|
modifier_name: 모디파이어 이름
|
||||||
|
viewport: 뷰포트 표시 on/off (None이면 토글)
|
||||||
|
render: 렌더 표시 on/off (None이면 토글)
|
||||||
|
"""
|
||||||
|
logger.info(f"Toggling modifier '{modifier_name}' on '{object_name}'")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(object_name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{object_name}' not found")
|
||||||
|
|
||||||
|
mod = obj.modifiers.get(modifier_name)
|
||||||
|
if not mod:
|
||||||
|
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
|
||||||
|
|
||||||
|
if viewport is not None:
|
||||||
|
mod.show_viewport = viewport
|
||||||
|
else:
|
||||||
|
mod.show_viewport = not mod.show_viewport
|
||||||
|
|
||||||
|
if render is not None:
|
||||||
|
mod.show_render = render
|
||||||
|
else:
|
||||||
|
mod.show_render = not mod.show_render
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': mod.name,
|
||||||
|
'show_viewport': mod.show_viewport,
|
||||||
|
'show_render': mod.show_render
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def modify_modifier_properties(object_name: str, modifier_name: str, **properties) -> Dict[str, Any]:
|
||||||
|
"""모디파이어 속성 수정
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_name: 대상 오브젝트 이름
|
||||||
|
modifier_name: 모디파이어 이름
|
||||||
|
**properties: 수정할 속성들 (key=value 형태)
|
||||||
|
|
||||||
|
Example properties by modifier type:
|
||||||
|
SUBSURF: levels, render_levels
|
||||||
|
MIRROR: use_axis, use_bisect_axis, mirror_object
|
||||||
|
ARRAY: count, relative_offset_displace
|
||||||
|
BEVEL: width, segments, limit_method
|
||||||
|
BOOLEAN: operation, object
|
||||||
|
SOLIDIFY: thickness, offset
|
||||||
|
ARMATURE: object, use_vertex_groups
|
||||||
|
SIMPLE_DEFORM: deform_method, factor, angle
|
||||||
|
CAST: cast_type, factor, radius
|
||||||
|
DISPLACE: strength, direction
|
||||||
|
"""
|
||||||
|
logger.info(f"Modifying properties of '{modifier_name}' on '{object_name}'")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(object_name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{object_name}' not found")
|
||||||
|
|
||||||
|
mod = obj.modifiers.get(modifier_name)
|
||||||
|
if not mod:
|
||||||
|
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
|
||||||
|
|
||||||
|
updated_properties = {}
|
||||||
|
for key, value in properties.items():
|
||||||
|
if hasattr(mod, key):
|
||||||
|
# 특수 처리가 필요한 속성들
|
||||||
|
if key in ['use_axis', 'use_bisect_axis'] and isinstance(value, list):
|
||||||
|
# Mirror 모디파이어의 axis는 boolean 배열
|
||||||
|
for i, v in enumerate(value):
|
||||||
|
if i < 3:
|
||||||
|
getattr(mod, key)[i] = v
|
||||||
|
elif key == 'relative_offset_displace' and isinstance(value, list):
|
||||||
|
# Array 모디파이어의 offset은 Vector
|
||||||
|
for i, v in enumerate(value):
|
||||||
|
if i < 3:
|
||||||
|
mod.relative_offset_displace[i] = v
|
||||||
|
elif key == 'object' and isinstance(value, str):
|
||||||
|
# 오브젝트 참조는 문자열로 받아서 변환
|
||||||
|
target_obj = bpy.data.objects.get(value)
|
||||||
|
if target_obj:
|
||||||
|
setattr(mod, key, target_obj)
|
||||||
|
else:
|
||||||
|
logger.warn(f"Target object '{value}' not found for property '{key}'")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# 일반 속성
|
||||||
|
setattr(mod, key, value)
|
||||||
|
|
||||||
|
updated_properties[key] = value
|
||||||
|
else:
|
||||||
|
logger.warn(f"Property '{key}' not found on modifier '{modifier_name}'")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': mod.name,
|
||||||
|
'type': mod.type,
|
||||||
|
'updated_properties': updated_properties
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_modifier_info(object_name: str, modifier_name: str) -> Dict[str, Any]:
|
||||||
|
"""특정 모디파이어의 상세 정보 조회"""
|
||||||
|
logger.info(f"Getting info for modifier '{modifier_name}' on '{object_name}'")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(object_name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{object_name}' not found")
|
||||||
|
|
||||||
|
mod = obj.modifiers.get(modifier_name)
|
||||||
|
if not mod:
|
||||||
|
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
|
||||||
|
|
||||||
|
# 모든 읽기 가능한 속성을 추출
|
||||||
|
info = {
|
||||||
|
'name': mod.name,
|
||||||
|
'type': mod.type,
|
||||||
|
'show_viewport': mod.show_viewport,
|
||||||
|
'show_render': mod.show_render,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 타입별 모든 관련 속성 추가
|
||||||
|
if mod.type == 'SUBSURF':
|
||||||
|
info.update({
|
||||||
|
'levels': mod.levels,
|
||||||
|
'render_levels': mod.render_levels,
|
||||||
|
'subdivision_type': mod.subdivision_type,
|
||||||
|
'use_limit_surface': mod.use_limit_surface
|
||||||
|
})
|
||||||
|
elif mod.type == 'MIRROR':
|
||||||
|
info.update({
|
||||||
|
'use_axis': [mod.use_axis[0], mod.use_axis[1], mod.use_axis[2]],
|
||||||
|
'use_bisect_axis': [mod.use_bisect_axis[0], mod.use_bisect_axis[1], mod.use_bisect_axis[2]],
|
||||||
|
'use_bisect_flip_axis': [mod.use_bisect_flip_axis[0], mod.use_bisect_flip_axis[1], mod.use_bisect_flip_axis[2]],
|
||||||
|
'mirror_object': mod.mirror_object.name if mod.mirror_object else None,
|
||||||
|
'use_clip': mod.use_clip,
|
||||||
|
'use_mirror_merge': mod.use_mirror_merge,
|
||||||
|
'merge_threshold': mod.merge_threshold
|
||||||
|
})
|
||||||
|
elif mod.type == 'ARRAY':
|
||||||
|
info.update({
|
||||||
|
'count': mod.count,
|
||||||
|
'use_constant_offset': mod.use_constant_offset,
|
||||||
|
'use_relative_offset': mod.use_relative_offset,
|
||||||
|
'use_object_offset': mod.use_object_offset,
|
||||||
|
'constant_offset_displace': list(mod.constant_offset_displace),
|
||||||
|
'relative_offset_displace': list(mod.relative_offset_displace),
|
||||||
|
'offset_object': mod.offset_object.name if mod.offset_object else None
|
||||||
|
})
|
||||||
|
elif mod.type == 'BEVEL':
|
||||||
|
info.update({
|
||||||
|
'width': mod.width,
|
||||||
|
'segments': mod.segments,
|
||||||
|
'limit_method': mod.limit_method,
|
||||||
|
'offset_type': mod.offset_type,
|
||||||
|
'profile': mod.profile,
|
||||||
|
'material': mod.material
|
||||||
|
})
|
||||||
|
elif mod.type == 'BOOLEAN':
|
||||||
|
info.update({
|
||||||
|
'operation': mod.operation,
|
||||||
|
'object': mod.object.name if mod.object else None,
|
||||||
|
'solver': mod.solver
|
||||||
|
})
|
||||||
|
elif mod.type == 'SOLIDIFY':
|
||||||
|
info.update({
|
||||||
|
'thickness': mod.thickness,
|
||||||
|
'offset': mod.offset,
|
||||||
|
'use_rim': mod.use_rim,
|
||||||
|
'use_even_offset': mod.use_even_offset,
|
||||||
|
'material_offset': mod.material_offset
|
||||||
|
})
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_modifier(object_name: str, modifier_name: str, direction: str) -> Dict[str, Any]:
|
||||||
|
"""모디파이어 순서 변경
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_name: 대상 오브젝트 이름
|
||||||
|
modifier_name: 모디파이어 이름
|
||||||
|
direction: 'UP' 또는 'DOWN'
|
||||||
|
"""
|
||||||
|
logger.info(f"Moving modifier '{modifier_name}' {direction} on '{object_name}'")
|
||||||
|
|
||||||
|
obj = bpy.data.objects.get(object_name)
|
||||||
|
if not obj:
|
||||||
|
raise ValueError(f"Object '{object_name}' not found")
|
||||||
|
|
||||||
|
mod = obj.modifiers.get(modifier_name)
|
||||||
|
if not mod:
|
||||||
|
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
|
||||||
|
|
||||||
|
# 오브젝트 선택 및 활성화
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
obj.select_set(True)
|
||||||
|
|
||||||
|
if direction.upper() == 'UP':
|
||||||
|
bpy.ops.object.modifier_move_up(modifier=modifier_name)
|
||||||
|
elif direction.upper() == 'DOWN':
|
||||||
|
bpy.ops.object.modifier_move_down(modifier=modifier_name)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid direction '{direction}'. Use 'UP' or 'DOWN'")
|
||||||
|
|
||||||
|
# 현재 순서 반환
|
||||||
|
modifiers = [m.name for m in obj.modifiers]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'modifier': modifier_name,
|
||||||
|
'new_order': modifiers
|
||||||
|
}
|
||||||
260
skills/addon/commands/retargeting.py
Normal file
260
skills/addon/commands/retargeting.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"""
|
||||||
|
Animation Retargeting 관련 명령 핸들러
|
||||||
|
본 매핑, 애니메이션 리타게팅 실행
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from typing import Dict
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
from ..utils.bone_matching import fuzzy_match_bones, get_match_quality_report
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def auto_map_bones(source_armature: str, target_armature: str) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
자동 본 매핑 (Mixamo -> 사용자 캐릭터)
|
||||||
|
Fuzzy matching 알고리즘 사용으로 정확도 개선
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_armature: 소스 아마추어 이름 (Mixamo)
|
||||||
|
target_armature: 타겟 아마추어 이름 (사용자 캐릭터)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
본 매핑 딕셔너리 {source_bone: target_bone}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 아마추어를 찾을 수 없는 경우
|
||||||
|
"""
|
||||||
|
logger.info(f"Auto-mapping bones: {source_armature} -> {target_armature}")
|
||||||
|
|
||||||
|
source = bpy.data.objects.get(source_armature)
|
||||||
|
target = bpy.data.objects.get(target_armature)
|
||||||
|
|
||||||
|
if not source or not target:
|
||||||
|
logger.error("Source or target armature not found")
|
||||||
|
raise ValueError("Source or target armature not found")
|
||||||
|
|
||||||
|
# Mixamo 표준 본 이름과 알려진 별칭 (확장: 손가락, 발가락 포함)
|
||||||
|
mixamo_bone_aliases = {
|
||||||
|
# 몸통 (6개)
|
||||||
|
"Hips": ["hips", "pelvis", "root"],
|
||||||
|
"Spine": ["spine", "spine1"],
|
||||||
|
"Spine1": ["spine1", "spine2"],
|
||||||
|
"Spine2": ["spine2", "spine3", "chest"],
|
||||||
|
"Neck": ["neck"],
|
||||||
|
"Head": ["head"],
|
||||||
|
|
||||||
|
# 왼쪽 팔 (4개)
|
||||||
|
"LeftShoulder": ["shoulder.l", "clavicle.l", "leftshoulder"],
|
||||||
|
"LeftArm": ["upper_arm.l", "leftarm", "upperarm.l"],
|
||||||
|
"LeftForeArm": ["forearm.l", "leftforearm", "lowerarm.l"],
|
||||||
|
"LeftHand": ["hand.l", "lefthand"],
|
||||||
|
|
||||||
|
# 오른쪽 팔 (4개)
|
||||||
|
"RightShoulder": ["shoulder.r", "clavicle.r", "rightshoulder"],
|
||||||
|
"RightArm": ["upper_arm.r", "rightarm", "upperarm.r"],
|
||||||
|
"RightForeArm": ["forearm.r", "rightforearm", "lowerarm.r"],
|
||||||
|
"RightHand": ["hand.r", "righthand"],
|
||||||
|
|
||||||
|
# 왼쪽 다리 (4개)
|
||||||
|
"LeftUpLeg": ["thigh.l", "leftupleg", "upperleg.l"],
|
||||||
|
"LeftLeg": ["shin.l", "leftleg", "lowerleg.l"],
|
||||||
|
"LeftFoot": ["foot.l", "leftfoot"],
|
||||||
|
"LeftToeBase": ["toe.l", "lefttoebase", "foot.l.001"],
|
||||||
|
|
||||||
|
# 오른쪽 다리 (4개)
|
||||||
|
"RightUpLeg": ["thigh.r", "rightupleg", "upperleg.r"],
|
||||||
|
"RightLeg": ["shin.r", "rightleg", "lowerleg.r"],
|
||||||
|
"RightFoot": ["foot.r", "rightfoot"],
|
||||||
|
"RightToeBase": ["toe.r", "righttoebase", "foot.r.001"],
|
||||||
|
|
||||||
|
# 왼쪽 손가락 (15개)
|
||||||
|
"LeftHandThumb1": ["thumb.01.l", "lefthandthumb1", "thumb_01.l"],
|
||||||
|
"LeftHandThumb2": ["thumb.02.l", "lefthandthumb2", "thumb_02.l"],
|
||||||
|
"LeftHandThumb3": ["thumb.03.l", "lefthandthumb3", "thumb_03.l"],
|
||||||
|
"LeftHandIndex1": ["f_index.01.l", "lefthandindex1", "index_01.l"],
|
||||||
|
"LeftHandIndex2": ["f_index.02.l", "lefthandindex2", "index_02.l"],
|
||||||
|
"LeftHandIndex3": ["f_index.03.l", "lefthandindex3", "index_03.l"],
|
||||||
|
"LeftHandMiddle1": ["f_middle.01.l", "lefthandmiddle1", "middle_01.l"],
|
||||||
|
"LeftHandMiddle2": ["f_middle.02.l", "lefthandmiddle2", "middle_02.l"],
|
||||||
|
"LeftHandMiddle3": ["f_middle.03.l", "lefthandmiddle3", "middle_03.l"],
|
||||||
|
"LeftHandRing1": ["f_ring.01.l", "lefthandring1", "ring_01.l"],
|
||||||
|
"LeftHandRing2": ["f_ring.02.l", "lefthandring2", "ring_02.l"],
|
||||||
|
"LeftHandRing3": ["f_ring.03.l", "lefthandring3", "ring_03.l"],
|
||||||
|
"LeftHandPinky1": ["f_pinky.01.l", "lefthandpinky1", "pinky_01.l"],
|
||||||
|
"LeftHandPinky2": ["f_pinky.02.l", "lefthandpinky2", "pinky_02.l"],
|
||||||
|
"LeftHandPinky3": ["f_pinky.03.l", "lefthandpinky3", "pinky_03.l"],
|
||||||
|
|
||||||
|
# 오른쪽 손가락 (15개)
|
||||||
|
"RightHandThumb1": ["thumb.01.r", "righthandthumb1", "thumb_01.r"],
|
||||||
|
"RightHandThumb2": ["thumb.02.r", "righthandthumb2", "thumb_02.r"],
|
||||||
|
"RightHandThumb3": ["thumb.03.r", "righthandthumb3", "thumb_03.r"],
|
||||||
|
"RightHandIndex1": ["f_index.01.r", "righthandindex1", "index_01.r"],
|
||||||
|
"RightHandIndex2": ["f_index.02.r", "righthandindex2", "index_02.r"],
|
||||||
|
"RightHandIndex3": ["f_index.03.r", "righthandindex3", "index_03.r"],
|
||||||
|
"RightHandMiddle1": ["f_middle.01.r", "righthandmiddle1", "middle_01.r"],
|
||||||
|
"RightHandMiddle2": ["f_middle.02.r", "righthandmiddle2", "middle_02.r"],
|
||||||
|
"RightHandMiddle3": ["f_middle.03.r", "righthandmiddle3", "middle_03.r"],
|
||||||
|
"RightHandRing1": ["f_ring.01.r", "righthandring1", "ring_01.r"],
|
||||||
|
"RightHandRing2": ["f_ring.02.r", "righthandring2", "ring_02.r"],
|
||||||
|
"RightHandRing3": ["f_ring.03.r", "righthandring3", "ring_03.r"],
|
||||||
|
"RightHandPinky1": ["f_pinky.01.r", "righthandpinky1", "pinky_01.r"],
|
||||||
|
"RightHandPinky2": ["f_pinky.02.r", "righthandpinky2", "pinky_02.r"],
|
||||||
|
"RightHandPinky3": ["f_pinky.03.r", "righthandpinky3", "pinky_03.r"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 소스 본 리스트 (실제로 존재하는 본만)
|
||||||
|
source_bones = [bone.name for bone in source.data.bones
|
||||||
|
if bone.name in mixamo_bone_aliases]
|
||||||
|
|
||||||
|
# 타겟 본 리스트
|
||||||
|
target_bones = [bone.name for bone in target.data.bones]
|
||||||
|
|
||||||
|
# Fuzzy matching 실행 (정확한 매칭 우선, 그 다음 유사도 매칭)
|
||||||
|
logger.info("Running fuzzy bone matching algorithm...")
|
||||||
|
logger.debug(f"Source bones: {len(source_bones)}, Target bones: {len(target_bones)}")
|
||||||
|
|
||||||
|
bone_map = fuzzy_match_bones(
|
||||||
|
source_bones=source_bones,
|
||||||
|
target_bones=target_bones,
|
||||||
|
known_aliases=mixamo_bone_aliases,
|
||||||
|
threshold=0.6, # 60% 이상 유사도
|
||||||
|
prefer_exact=True # 정확한 매칭 우선
|
||||||
|
)
|
||||||
|
|
||||||
|
# 매칭 품질 보고서
|
||||||
|
quality_report = get_match_quality_report(bone_map)
|
||||||
|
logger.info(f"Auto-mapped {quality_report['total_mappings']} bones")
|
||||||
|
logger.info(f"Quality: {quality_report['quality'].upper()}")
|
||||||
|
logger.info(f"Critical bones: {quality_report['critical_bones_mapped']}")
|
||||||
|
logger.debug(f"Bone mapping: {bone_map}")
|
||||||
|
|
||||||
|
# 콘솔 출력 (사용자에게 피드백)
|
||||||
|
print(f"✅ Auto-mapped {quality_report['total_mappings']} bones")
|
||||||
|
print(f" Quality: {quality_report['quality'].upper()}")
|
||||||
|
print(f" Critical bones: {quality_report['critical_bones_mapped']}")
|
||||||
|
|
||||||
|
return bone_map
|
||||||
|
|
||||||
|
|
||||||
|
def retarget_animation(
|
||||||
|
source_armature: str,
|
||||||
|
target_armature: str,
|
||||||
|
bone_map: Dict[str, str],
|
||||||
|
preserve_rotation: bool = True,
|
||||||
|
preserve_location: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
애니메이션 리타게팅 실행
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_armature: 소스 아마추어 이름
|
||||||
|
target_armature: 타겟 아마추어 이름
|
||||||
|
bone_map: 본 매핑 딕셔너리
|
||||||
|
preserve_rotation: 회전 보존 여부
|
||||||
|
preserve_location: 위치 보존 여부 (보통 루트 본만)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
결과 메시지
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 아마추어를 찾을 수 없거나 애니메이션이 없는 경우
|
||||||
|
"""
|
||||||
|
logger.info(f"Retargeting animation: {source_armature} -> {target_armature}")
|
||||||
|
logger.debug(f"Bone mappings: {len(bone_map)}, Rotation: {preserve_rotation}, Location: {preserve_location}")
|
||||||
|
|
||||||
|
source = bpy.data.objects.get(source_armature)
|
||||||
|
target = bpy.data.objects.get(target_armature)
|
||||||
|
|
||||||
|
if not source or not target:
|
||||||
|
logger.error("Source or target armature not found")
|
||||||
|
raise ValueError("Source or target armature not found")
|
||||||
|
|
||||||
|
if not source.animation_data or not source.animation_data.action:
|
||||||
|
logger.error("Source armature has no animation")
|
||||||
|
raise ValueError("Source armature has no animation")
|
||||||
|
|
||||||
|
# 타겟 아마추어 선택
|
||||||
|
bpy.context.view_layer.objects.active = target
|
||||||
|
target.select_set(True)
|
||||||
|
|
||||||
|
# Pose 모드로 전환
|
||||||
|
bpy.ops.object.mode_set(mode='POSE')
|
||||||
|
|
||||||
|
# 각 본에 대해 컨스트레인트 생성
|
||||||
|
constraints_added = 0
|
||||||
|
for source_bone_name, target_bone_name in bone_map.items():
|
||||||
|
if source_bone_name not in source.pose.bones:
|
||||||
|
logger.debug(f"Source bone not found: {source_bone_name}")
|
||||||
|
continue
|
||||||
|
if target_bone_name not in target.pose.bones:
|
||||||
|
logger.debug(f"Target bone not found: {target_bone_name}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
target_bone = target.pose.bones[target_bone_name]
|
||||||
|
|
||||||
|
# Rotation constraint
|
||||||
|
if preserve_rotation:
|
||||||
|
constraint = target_bone.constraints.new('COPY_ROTATION')
|
||||||
|
constraint.target = source
|
||||||
|
constraint.subtarget = source_bone_name
|
||||||
|
constraints_added += 1
|
||||||
|
|
||||||
|
# Location constraint (일반적으로 루트 본만)
|
||||||
|
if preserve_location and source_bone_name == "Hips":
|
||||||
|
constraint = target_bone.constraints.new('COPY_LOCATION')
|
||||||
|
constraint.target = source
|
||||||
|
constraint.subtarget = source_bone_name
|
||||||
|
constraints_added += 1
|
||||||
|
|
||||||
|
logger.info(f"Added {constraints_added} constraints")
|
||||||
|
|
||||||
|
# 컨스트레인트를 키프레임으로 베이크
|
||||||
|
logger.info("Baking constraints to keyframes...")
|
||||||
|
bpy.ops.nla.bake(
|
||||||
|
frame_start=bpy.context.scene.frame_start,
|
||||||
|
frame_end=bpy.context.scene.frame_end,
|
||||||
|
only_selected=False,
|
||||||
|
visual_keying=True,
|
||||||
|
clear_constraints=True,
|
||||||
|
bake_types={'POSE'}
|
||||||
|
)
|
||||||
|
|
||||||
|
bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
|
||||||
|
logger.info("Animation retargeting completed successfully")
|
||||||
|
return f"Animation retargeted to {target_armature}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset_bone_mapping(preset: str) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
미리 정의된 본 매핑 프리셋
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset: 프리셋 이름 (예: "mixamo_to_rigify")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
본 매핑 딕셔너리
|
||||||
|
"""
|
||||||
|
logger.debug(f"Getting bone mapping preset: {preset}")
|
||||||
|
|
||||||
|
presets = {
|
||||||
|
"mixamo_to_rigify": {
|
||||||
|
"Hips": "torso",
|
||||||
|
"Spine": "spine",
|
||||||
|
"Spine1": "spine.001",
|
||||||
|
"Spine2": "spine.002",
|
||||||
|
"Neck": "neck",
|
||||||
|
"Head": "head",
|
||||||
|
"LeftShoulder": "shoulder.L",
|
||||||
|
"LeftArm": "upper_arm.L",
|
||||||
|
"LeftForeArm": "forearm.L",
|
||||||
|
"LeftHand": "hand.L",
|
||||||
|
# ... 더 많은 매핑 추가 가능
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bone_map = presets.get(preset, {})
|
||||||
|
logger.info(f"Preset '{preset}' loaded with {len(bone_map)} mappings")
|
||||||
|
return bone_map
|
||||||
22
skills/addon/pyrightconfig.json
Normal file
22
skills/addon/pyrightconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"."
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"**/__pycache__",
|
||||||
|
".venv",
|
||||||
|
"commands"
|
||||||
|
],
|
||||||
|
"extraPaths": [
|
||||||
|
"D:/Blender/Blender_Launcher_v1.15.1_Windows_x64/stable/blender-4.5.4-lts.b3efe983cc58/4.5/scripts/modules",
|
||||||
|
"D:/Blender/Blender_Launcher_v1.15.1_Windows_x64/stable/blender-4.5.4-lts.b3efe983cc58/4.5/python/lib/site-packages"
|
||||||
|
],
|
||||||
|
"typeCheckingMode": "basic",
|
||||||
|
"reportMissingImports": false,
|
||||||
|
"reportMissingModuleSource": false,
|
||||||
|
"reportUnknownParameterType": "warning",
|
||||||
|
"reportUnknownMemberType": "warning",
|
||||||
|
"reportMissingParameterType": "warning",
|
||||||
|
"pythonVersion": "3.11",
|
||||||
|
"pythonPlatform": "Windows"
|
||||||
|
}
|
||||||
1
skills/addon/requirements.txt
Normal file
1
skills/addon/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
aiohttp>=3.8,<4.0
|
||||||
532
skills/addon/retargeting.py
Normal file
532
skills/addon/retargeting.py
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
"""
|
||||||
|
Animation Retargeting Module
|
||||||
|
Blender 애니메이션 리타게팅 관련 함수들
|
||||||
|
|
||||||
|
주요 기능:
|
||||||
|
- 아마추어 및 본 정보 조회
|
||||||
|
- 자동 본 매핑 (Fuzzy matching)
|
||||||
|
- 애니메이션 리타게팅
|
||||||
|
- 애니메이션 재생 및 NLA 트랙 관리
|
||||||
|
- FBX/DAE 임포트
|
||||||
|
- 본 매핑 저장/로드
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
# Fuzzy bone matching utilities
|
||||||
|
from .utils.bone_matching import (
|
||||||
|
fuzzy_match_bones,
|
||||||
|
get_match_quality_report,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Logging utilities
|
||||||
|
from .utils.logger import get_logger
|
||||||
|
|
||||||
|
# 모듈 로거 초기화
|
||||||
|
logger = get_logger('retargeting')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Armature & Bone Query Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def list_armatures() -> List[str]:
|
||||||
|
"""
|
||||||
|
모든 아마추어 오브젝트 목록 반환
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
아마추어 오브젝트 이름 리스트
|
||||||
|
"""
|
||||||
|
return [obj.name for obj in bpy.data.objects if obj.type == 'ARMATURE']
|
||||||
|
|
||||||
|
|
||||||
|
def get_bones(armature_name: str) -> List[Dict[str, Optional[str]]]:
|
||||||
|
"""
|
||||||
|
아마추어의 본 정보 가져오기
|
||||||
|
|
||||||
|
Args:
|
||||||
|
armature_name: 아마추어 오브젝트 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
본 정보 리스트 (name, parent, children)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 아마추어를 찾을 수 없는 경우
|
||||||
|
"""
|
||||||
|
armature = bpy.data.objects.get(armature_name)
|
||||||
|
if not armature or armature.type != 'ARMATURE':
|
||||||
|
raise ValueError(f"Armature '{armature_name}' not found")
|
||||||
|
|
||||||
|
bones = []
|
||||||
|
for bone in armature.data.bones:
|
||||||
|
bones.append({
|
||||||
|
"name": bone.name,
|
||||||
|
"parent": bone.parent.name if bone.parent else None,
|
||||||
|
"children": [child.name for child in bone.children]
|
||||||
|
})
|
||||||
|
|
||||||
|
return bones
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Bone Mapping Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def auto_map_bones(source_armature: str, target_armature: str) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
자동 본 매핑 (Mixamo -> 사용자 캐릭터)
|
||||||
|
Fuzzy matching 알고리즘 사용으로 정확도 개선
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_armature: 소스 아마추어 이름 (예: Mixamo)
|
||||||
|
target_armature: 타겟 아마추어 이름 (사용자 캐릭터)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
본 매핑 딕셔너리 {소스 본: 타겟 본}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 아마추어를 찾을 수 없는 경우
|
||||||
|
"""
|
||||||
|
source = bpy.data.objects.get(source_armature)
|
||||||
|
target = bpy.data.objects.get(target_armature)
|
||||||
|
|
||||||
|
if not source or not target:
|
||||||
|
raise ValueError("Source or target armature not found")
|
||||||
|
|
||||||
|
# Mixamo 표준 본 이름과 알려진 별칭 (확장: 손가락, 발가락 포함)
|
||||||
|
mixamo_bone_aliases = {
|
||||||
|
# 몸통 (6개)
|
||||||
|
"Hips": ["hips", "pelvis", "root"],
|
||||||
|
"Spine": ["spine", "spine1"],
|
||||||
|
"Spine1": ["spine1", "spine2"],
|
||||||
|
"Spine2": ["spine2", "spine3", "chest"],
|
||||||
|
"Neck": ["neck"],
|
||||||
|
"Head": ["head"],
|
||||||
|
|
||||||
|
# 왼쪽 팔 (4개)
|
||||||
|
"LeftShoulder": ["shoulder.l", "clavicle.l", "leftshoulder"],
|
||||||
|
"LeftArm": ["upper_arm.l", "leftarm", "upperarm.l"],
|
||||||
|
"LeftForeArm": ["forearm.l", "leftforearm", "lowerarm.l"],
|
||||||
|
"LeftHand": ["hand.l", "lefthand"],
|
||||||
|
|
||||||
|
# 오른쪽 팔 (4개)
|
||||||
|
"RightShoulder": ["shoulder.r", "clavicle.r", "rightshoulder"],
|
||||||
|
"RightArm": ["upper_arm.r", "rightarm", "upperarm.r"],
|
||||||
|
"RightForeArm": ["forearm.r", "rightforearm", "lowerarm.r"],
|
||||||
|
"RightHand": ["hand.r", "righthand"],
|
||||||
|
|
||||||
|
# 왼쪽 다리 (4개)
|
||||||
|
"LeftUpLeg": ["thigh.l", "leftupleg", "upperleg.l"],
|
||||||
|
"LeftLeg": ["shin.l", "leftleg", "lowerleg.l"],
|
||||||
|
"LeftFoot": ["foot.l", "leftfoot"],
|
||||||
|
"LeftToeBase": ["toe.l", "lefttoebase", "foot.l.001"],
|
||||||
|
|
||||||
|
# 오른쪽 다리 (4개)
|
||||||
|
"RightUpLeg": ["thigh.r", "rightupleg", "upperleg.r"],
|
||||||
|
"RightLeg": ["shin.r", "rightleg", "lowerleg.r"],
|
||||||
|
"RightFoot": ["foot.r", "rightfoot"],
|
||||||
|
"RightToeBase": ["toe.r", "righttoebase", "foot.r.001"],
|
||||||
|
|
||||||
|
# 왼쪽 손가락 (15개)
|
||||||
|
"LeftHandThumb1": ["thumb.01.l", "lefthandthumb1", "thumb_01.l"],
|
||||||
|
"LeftHandThumb2": ["thumb.02.l", "lefthandthumb2", "thumb_02.l"],
|
||||||
|
"LeftHandThumb3": ["thumb.03.l", "lefthandthumb3", "thumb_03.l"],
|
||||||
|
"LeftHandIndex1": ["f_index.01.l", "lefthandindex1", "index_01.l"],
|
||||||
|
"LeftHandIndex2": ["f_index.02.l", "lefthandindex2", "index_02.l"],
|
||||||
|
"LeftHandIndex3": ["f_index.03.l", "lefthandindex3", "index_03.l"],
|
||||||
|
"LeftHandMiddle1": ["f_middle.01.l", "lefthandmiddle1", "middle_01.l"],
|
||||||
|
"LeftHandMiddle2": ["f_middle.02.l", "lefthandmiddle2", "middle_02.l"],
|
||||||
|
"LeftHandMiddle3": ["f_middle.03.l", "lefthandmiddle3", "middle_03.l"],
|
||||||
|
"LeftHandRing1": ["f_ring.01.l", "lefthandring1", "ring_01.l"],
|
||||||
|
"LeftHandRing2": ["f_ring.02.l", "lefthandring2", "ring_02.l"],
|
||||||
|
"LeftHandRing3": ["f_ring.03.l", "lefthandring3", "ring_03.l"],
|
||||||
|
"LeftHandPinky1": ["f_pinky.01.l", "lefthandpinky1", "pinky_01.l"],
|
||||||
|
"LeftHandPinky2": ["f_pinky.02.l", "lefthandpinky2", "pinky_02.l"],
|
||||||
|
"LeftHandPinky3": ["f_pinky.03.l", "lefthandpinky3", "pinky_03.l"],
|
||||||
|
|
||||||
|
# 오른쪽 손가락 (15개)
|
||||||
|
"RightHandThumb1": ["thumb.01.r", "righthandthumb1", "thumb_01.r"],
|
||||||
|
"RightHandThumb2": ["thumb.02.r", "righthandthumb2", "thumb_02.r"],
|
||||||
|
"RightHandThumb3": ["thumb.03.r", "righthandthumb3", "thumb_03.r"],
|
||||||
|
"RightHandIndex1": ["f_index.01.r", "righthandindex1", "index_01.r"],
|
||||||
|
"RightHandIndex2": ["f_index.02.r", "righthandindex2", "index_02.r"],
|
||||||
|
"RightHandIndex3": ["f_index.03.r", "righthandindex3", "index_03.r"],
|
||||||
|
"RightHandMiddle1": ["f_middle.01.r", "righthandmiddle1", "middle_01.r"],
|
||||||
|
"RightHandMiddle2": ["f_middle.02.r", "righthandmiddle2", "middle_02.r"],
|
||||||
|
"RightHandMiddle3": ["f_middle.03.r", "righthandmiddle3", "middle_03.r"],
|
||||||
|
"RightHandRing1": ["f_ring.01.r", "righthandring1", "ring_01.r"],
|
||||||
|
"RightHandRing2": ["f_ring.02.r", "righthandring2", "ring_02.r"],
|
||||||
|
"RightHandRing3": ["f_ring.03.r", "righthandring3", "ring_03.r"],
|
||||||
|
"RightHandPinky1": ["f_pinky.01.r", "righthandpinky1", "pinky_01.r"],
|
||||||
|
"RightHandPinky2": ["f_pinky.02.r", "righthandpinky2", "pinky_02.r"],
|
||||||
|
"RightHandPinky3": ["f_pinky.03.r", "righthandpinky3", "pinky_03.r"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 소스 본 리스트 (실제로 존재하는 본만)
|
||||||
|
source_bones = [bone.name for bone in source.data.bones
|
||||||
|
if bone.name in mixamo_bone_aliases]
|
||||||
|
|
||||||
|
# 타겟 본 리스트
|
||||||
|
target_bones = [bone.name for bone in target.data.bones]
|
||||||
|
|
||||||
|
# Fuzzy matching 실행 (정확한 매칭 우선, 그 다음 유사도 매칭)
|
||||||
|
logger.info("Running fuzzy bone matching algorithm...")
|
||||||
|
logger.debug("Source bones: %d, Target bones: %d", len(source_bones), len(target_bones))
|
||||||
|
|
||||||
|
bone_map = fuzzy_match_bones(
|
||||||
|
source_bones=source_bones,
|
||||||
|
target_bones=target_bones,
|
||||||
|
known_aliases=mixamo_bone_aliases,
|
||||||
|
threshold=0.6, # 60% 이상 유사도
|
||||||
|
prefer_exact=True # 정확한 매칭 우선
|
||||||
|
)
|
||||||
|
|
||||||
|
# 매칭 품질 보고서
|
||||||
|
quality_report = get_match_quality_report(bone_map)
|
||||||
|
logger.info("Auto-mapped %d bones", quality_report['total_mappings'])
|
||||||
|
logger.info("Quality: %s", quality_report['quality'].upper())
|
||||||
|
logger.info("Critical bones: %s", quality_report['critical_bones_mapped'])
|
||||||
|
logger.debug("Bone mapping: %s", bone_map)
|
||||||
|
|
||||||
|
# 콘솔 출력 (사용자에게 피드백)
|
||||||
|
print(f"✅ Auto-mapped {quality_report['total_mappings']} bones")
|
||||||
|
print(f" Quality: {quality_report['quality'].upper()}")
|
||||||
|
print(f" Critical bones: {quality_report['critical_bones_mapped']}")
|
||||||
|
|
||||||
|
return bone_map
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset_bone_mapping(preset: str) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
미리 정의된 본 매핑 프리셋 반환
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset: 프리셋 이름 (예: "mixamo_to_rigify")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
본 매핑 딕셔너리
|
||||||
|
"""
|
||||||
|
presets = {
|
||||||
|
"mixamo_to_rigify": {
|
||||||
|
"Hips": "torso",
|
||||||
|
"Spine": "spine",
|
||||||
|
"Spine1": "spine.001",
|
||||||
|
"Spine2": "spine.002",
|
||||||
|
"Neck": "neck",
|
||||||
|
"Head": "head",
|
||||||
|
"LeftShoulder": "shoulder.L",
|
||||||
|
"LeftArm": "upper_arm.L",
|
||||||
|
"LeftForeArm": "forearm.L",
|
||||||
|
"LeftHand": "hand.L",
|
||||||
|
# ... 더 많은 매핑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return presets.get(preset, {})
|
||||||
|
|
||||||
|
|
||||||
|
def store_bone_mapping(
|
||||||
|
source_armature: str, target_armature: str, bone_mapping: Dict[str, str]
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
본 매핑을 Scene 속성에 저장
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_armature: 소스 아마추어 이름
|
||||||
|
target_armature: 타겟 아마추어 이름
|
||||||
|
bone_mapping: 본 매핑 딕셔너리
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
작업 결과 메시지
|
||||||
|
"""
|
||||||
|
scene = bpy.context.scene
|
||||||
|
|
||||||
|
# 기존 매핑 클리어
|
||||||
|
scene.bone_mapping_items.clear()
|
||||||
|
|
||||||
|
# 새 매핑 저장
|
||||||
|
for source_bone, target_bone in bone_mapping.items():
|
||||||
|
item = scene.bone_mapping_items.add()
|
||||||
|
item.source_bone = source_bone
|
||||||
|
item.target_bone = target_bone
|
||||||
|
|
||||||
|
# 아마추어 정보 저장
|
||||||
|
scene.bone_mapping_source_armature = source_armature
|
||||||
|
scene.bone_mapping_target_armature = target_armature
|
||||||
|
|
||||||
|
print(f"✅ Stored bone mapping: {len(bone_mapping)} bones")
|
||||||
|
return f"Bone mapping stored ({len(bone_mapping)} bones)"
|
||||||
|
|
||||||
|
|
||||||
|
def load_bone_mapping(source_armature: str, target_armature: str) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Scene 속성에서 본 매핑 로드
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_armature: 소스 아마추어 이름
|
||||||
|
target_armature: 타겟 아마추어 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
본 매핑 딕셔너리
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 저장된 매핑이 없거나 아마추어가 일치하지 않는 경우
|
||||||
|
"""
|
||||||
|
scene = bpy.context.scene
|
||||||
|
|
||||||
|
# 아마추어 검증
|
||||||
|
if not scene.bone_mapping_source_armature:
|
||||||
|
raise ValueError(
|
||||||
|
"No bone mapping stored. Please generate mapping first using "
|
||||||
|
"BoneMapping.show command."
|
||||||
|
)
|
||||||
|
|
||||||
|
if (scene.bone_mapping_source_armature != source_armature or
|
||||||
|
scene.bone_mapping_target_armature != target_armature):
|
||||||
|
raise ValueError(
|
||||||
|
f"Stored mapping for ({scene.bone_mapping_source_armature} → "
|
||||||
|
f"{scene.bone_mapping_target_armature}) doesn't match requested "
|
||||||
|
f"({source_armature} → {target_armature})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 매핑 로드
|
||||||
|
bone_mapping = {}
|
||||||
|
for item in scene.bone_mapping_items:
|
||||||
|
bone_mapping[item.source_bone] = item.target_bone
|
||||||
|
|
||||||
|
if not bone_mapping:
|
||||||
|
raise ValueError("Bone mapping is empty. Please generate mapping first.")
|
||||||
|
|
||||||
|
print(f"✅ Loaded bone mapping: {len(bone_mapping)} bones")
|
||||||
|
return bone_mapping
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Animation Retargeting Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def retarget_animation(
|
||||||
|
source_armature: str,
|
||||||
|
target_armature: str,
|
||||||
|
bone_map: Dict[str, str],
|
||||||
|
preserve_rotation: bool = True,
|
||||||
|
preserve_location: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
애니메이션 리타게팅 실행
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_armature: 소스 아마추어 이름
|
||||||
|
target_armature: 타겟 아마추어 이름
|
||||||
|
bone_map: 본 매핑 딕셔너리
|
||||||
|
preserve_rotation: 회전 보존 여부
|
||||||
|
preserve_location: 위치 보존 여부
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
작업 결과 메시지
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 아마추어를 찾을 수 없거나 애니메이션이 없는 경우
|
||||||
|
"""
|
||||||
|
source = bpy.data.objects.get(source_armature)
|
||||||
|
target = bpy.data.objects.get(target_armature)
|
||||||
|
|
||||||
|
if not source or not target:
|
||||||
|
raise ValueError("Source or target armature not found")
|
||||||
|
|
||||||
|
if not source.animation_data or not source.animation_data.action:
|
||||||
|
raise ValueError("Source armature has no animation")
|
||||||
|
|
||||||
|
# 타겟 아마추어 선택
|
||||||
|
bpy.context.view_layer.objects.active = target
|
||||||
|
target.select_set(True)
|
||||||
|
|
||||||
|
# Pose 모드로 전환
|
||||||
|
bpy.ops.object.mode_set(mode='POSE')
|
||||||
|
|
||||||
|
# 각 본에 대해 컨스트레인트 생성
|
||||||
|
for source_bone_name, target_bone_name in bone_map.items():
|
||||||
|
if source_bone_name not in source.pose.bones:
|
||||||
|
continue
|
||||||
|
if target_bone_name not in target.pose.bones:
|
||||||
|
continue
|
||||||
|
|
||||||
|
target_bone = target.pose.bones[target_bone_name]
|
||||||
|
|
||||||
|
# Rotation constraint
|
||||||
|
if preserve_rotation:
|
||||||
|
constraint = target_bone.constraints.new('COPY_ROTATION')
|
||||||
|
constraint.target = source
|
||||||
|
constraint.subtarget = source_bone_name
|
||||||
|
|
||||||
|
# Location constraint (일반적으로 루트 본만)
|
||||||
|
if preserve_location and source_bone_name == "Hips":
|
||||||
|
constraint = target_bone.constraints.new('COPY_LOCATION')
|
||||||
|
constraint.target = source
|
||||||
|
constraint.subtarget = source_bone_name
|
||||||
|
|
||||||
|
# 컨스트레인트를 키프레임으로 베이크
|
||||||
|
bpy.ops.nla.bake(
|
||||||
|
frame_start=bpy.context.scene.frame_start,
|
||||||
|
frame_end=bpy.context.scene.frame_end,
|
||||||
|
only_selected=False,
|
||||||
|
visual_keying=True,
|
||||||
|
clear_constraints=True,
|
||||||
|
bake_types={'POSE'}
|
||||||
|
)
|
||||||
|
|
||||||
|
bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
|
||||||
|
return f"Animation retargeted to {target_armature}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Animation Playback Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def list_animations(armature_name: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
아마추어의 애니메이션 액션 목록 반환
|
||||||
|
|
||||||
|
Args:
|
||||||
|
armature_name: 아마추어 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
액션 이름 리스트
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 아마추어를 찾을 수 없는 경우
|
||||||
|
"""
|
||||||
|
armature = bpy.data.objects.get(armature_name)
|
||||||
|
if not armature:
|
||||||
|
raise ValueError(f"Armature '{armature_name}' not found")
|
||||||
|
|
||||||
|
actions = []
|
||||||
|
if armature.animation_data:
|
||||||
|
for action in bpy.data.actions:
|
||||||
|
if action.id_root == 'OBJECT':
|
||||||
|
actions.append(action.name)
|
||||||
|
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def play_animation(armature_name: str, action_name: str, loop: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
애니메이션 재생
|
||||||
|
|
||||||
|
Args:
|
||||||
|
armature_name: 아마추어 이름
|
||||||
|
action_name: 액션 이름
|
||||||
|
loop: 루프 재생 여부
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
작업 결과 메시지
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 아마추어 또는 액션을 찾을 수 없는 경우
|
||||||
|
"""
|
||||||
|
armature = bpy.data.objects.get(armature_name)
|
||||||
|
if not armature:
|
||||||
|
raise ValueError(f"Armature '{armature_name}' not found")
|
||||||
|
|
||||||
|
action = bpy.data.actions.get(action_name)
|
||||||
|
if not action:
|
||||||
|
raise ValueError(f"Action '{action_name}' not found")
|
||||||
|
|
||||||
|
if not armature.animation_data:
|
||||||
|
armature.animation_data_create()
|
||||||
|
|
||||||
|
armature.animation_data.action = action
|
||||||
|
bpy.context.scene.frame_set(int(action.frame_range[0]))
|
||||||
|
bpy.ops.screen.animation_play()
|
||||||
|
|
||||||
|
return f"Playing {action_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def stop_animation() -> str:
|
||||||
|
"""
|
||||||
|
애니메이션 중지
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
작업 결과 메시지
|
||||||
|
"""
|
||||||
|
bpy.ops.screen.animation_cancel()
|
||||||
|
return "Animation stopped"
|
||||||
|
|
||||||
|
|
||||||
|
def add_to_nla(armature_name: str, action_name: str, track_name: str) -> str:
|
||||||
|
"""
|
||||||
|
NLA 트랙에 애니메이션 추가
|
||||||
|
|
||||||
|
Args:
|
||||||
|
armature_name: 아마추어 이름
|
||||||
|
action_name: 액션 이름
|
||||||
|
track_name: NLA 트랙 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
작업 결과 메시지
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 아마추어 또는 액션을 찾을 수 없는 경우
|
||||||
|
"""
|
||||||
|
armature = bpy.data.objects.get(armature_name)
|
||||||
|
action = bpy.data.actions.get(action_name)
|
||||||
|
|
||||||
|
if not armature or not action:
|
||||||
|
raise ValueError("Armature or action not found")
|
||||||
|
|
||||||
|
if not armature.animation_data:
|
||||||
|
armature.animation_data_create()
|
||||||
|
|
||||||
|
nla_track = armature.animation_data.nla_tracks.new()
|
||||||
|
nla_track.name = track_name
|
||||||
|
nla_track.strips.new(action.name, int(action.frame_range[0]), action)
|
||||||
|
|
||||||
|
return f"Added {action_name} to NLA track {track_name}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Import Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def import_fbx(filepath: str) -> str:
|
||||||
|
"""
|
||||||
|
FBX 파일 임포트
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: FBX 파일 경로
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
작업 결과 메시지
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 파일을 찾을 수 없는 경우
|
||||||
|
"""
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
raise ValueError(f"File not found: {filepath}")
|
||||||
|
|
||||||
|
bpy.ops.import_scene.fbx(filepath=filepath)
|
||||||
|
return f"Imported {filepath}"
|
||||||
|
|
||||||
|
|
||||||
|
def import_dae(filepath: str) -> str:
|
||||||
|
"""
|
||||||
|
Collada (.dae) 파일 임포트
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: DAE 파일 경로
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
작업 결과 메시지
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 파일을 찾을 수 없는 경우
|
||||||
|
"""
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
raise ValueError(f"File not found: {filepath}")
|
||||||
|
|
||||||
|
bpy.ops.wm.collada_import(filepath=filepath)
|
||||||
|
return f"Imported {filepath}"
|
||||||
350
skills/addon/ui.py
Normal file
350
skills/addon/ui.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
"""
|
||||||
|
Blender Toolkit UI Components
|
||||||
|
UI 패널, 오퍼레이터, 속성 그룹 정의
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
from .retargeting import auto_map_bones, retarget_animation
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Blender UI Panel
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class BLENDERTOOLKIT_PT_Panel(bpy.types.Panel):
|
||||||
|
"""Blender Toolkit 사이드바 패널"""
|
||||||
|
bl_label = "Blender Toolkit"
|
||||||
|
bl_idname = "BLENDERTOOLKIT_PT_panel"
|
||||||
|
bl_space_type = 'VIEW_3D'
|
||||||
|
bl_region_type = 'UI'
|
||||||
|
bl_category = 'Blender Toolkit'
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
"""UI 패널 그리기."""
|
||||||
|
layout = self.layout
|
||||||
|
|
||||||
|
# 서버 상태 표시
|
||||||
|
layout.label(text="WebSocket Server", icon='NETWORK_DRIVE')
|
||||||
|
|
||||||
|
# 서버 시작/중지 버튼
|
||||||
|
row = layout.row()
|
||||||
|
row.operator("blendertoolkit.start_server", text="Start Server", icon='PLAY')
|
||||||
|
row.operator("blendertoolkit.stop_server", text="Stop Server", icon='PAUSE')
|
||||||
|
|
||||||
|
layout.separator()
|
||||||
|
|
||||||
|
# 포트 설정
|
||||||
|
layout.prop(context.scene, "blender_toolkit_port", text="Port")
|
||||||
|
|
||||||
|
|
||||||
|
class BLENDERTOOLKIT_OT_StartServer(bpy.types.Operator):
|
||||||
|
"""서버 시작 오퍼레이터"""
|
||||||
|
bl_idname = "blendertoolkit.start_server"
|
||||||
|
bl_label = "Start WebSocket Server"
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
"""WebSocket 서버를 시작하는 오퍼레이터 실행."""
|
||||||
|
# Import here to avoid circular dependency
|
||||||
|
from . import BlenderWebSocketServer
|
||||||
|
|
||||||
|
port = context.scene.blender_toolkit_port
|
||||||
|
|
||||||
|
# Check if server is already running
|
||||||
|
if hasattr(bpy.types.Scene, '_blender_toolkit_server_thread'):
|
||||||
|
server_thread = bpy.types.Scene._blender_toolkit_server_thread
|
||||||
|
if server_thread and server_thread.is_alive():
|
||||||
|
self.report({'INFO'}, f"WebSocket server already running on port {port}")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
# Start server in background thread
|
||||||
|
|
||||||
|
def run_server():
|
||||||
|
"""서버를 별도 스레드에서 실행"""
|
||||||
|
try:
|
||||||
|
server = BlenderWebSocketServer(port)
|
||||||
|
# Store server instance globally
|
||||||
|
bpy.types.Scene._blender_toolkit_server = server
|
||||||
|
|
||||||
|
# Create new event loop for this thread
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
# Store loop reference for graceful shutdown
|
||||||
|
bpy.types.Scene._blender_toolkit_event_loop = loop
|
||||||
|
|
||||||
|
# Run server until stopped
|
||||||
|
loop.run_until_complete(server.start())
|
||||||
|
|
||||||
|
# Keep loop running until stop() is called
|
||||||
|
loop.run_forever()
|
||||||
|
except (RuntimeError, OSError, ValueError) as e:
|
||||||
|
print(f"Blender Toolkit Server Error: {e}")
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
# Start server thread
|
||||||
|
server_thread = threading.Thread(target=run_server, daemon=True)
|
||||||
|
server_thread.start()
|
||||||
|
|
||||||
|
# Store thread reference
|
||||||
|
bpy.types.Scene._blender_toolkit_server_thread = server_thread
|
||||||
|
bpy.types.Scene._blender_toolkit_server_port = port
|
||||||
|
|
||||||
|
self.report({'INFO'}, f"✓ WebSocket server started on port {port}")
|
||||||
|
print(f"Blender Toolkit: WebSocket server started on ws://127.0.0.1:{port}")
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
class BLENDERTOOLKIT_OT_StopServer(bpy.types.Operator):
|
||||||
|
"""서버 중지 오퍼레이터"""
|
||||||
|
bl_idname = "blendertoolkit.stop_server"
|
||||||
|
bl_label = "Stop WebSocket Server"
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
"""WebSocket 서버를 중지하는 오퍼레이터 실행."""
|
||||||
|
# Check if server is running
|
||||||
|
if not hasattr(bpy.types.Scene, '_blender_toolkit_server_thread'):
|
||||||
|
self.report({'WARNING'}, "WebSocket server is not running")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
server_thread = bpy.types.Scene._blender_toolkit_server_thread
|
||||||
|
if not server_thread or not server_thread.is_alive():
|
||||||
|
self.report({'WARNING'}, "WebSocket server is not running")
|
||||||
|
# Clean up stale references
|
||||||
|
self._cleanup_references()
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
# Get server instance and event loop
|
||||||
|
server = getattr(bpy.types.Scene, '_blender_toolkit_server', None)
|
||||||
|
loop = getattr(bpy.types.Scene, '_blender_toolkit_event_loop', None)
|
||||||
|
port = getattr(bpy.types.Scene, '_blender_toolkit_server_port', 'unknown')
|
||||||
|
|
||||||
|
if not server or not loop:
|
||||||
|
self.report({'WARNING'}, "Server instance not found. Please restart Blender to fully stop the server.")
|
||||||
|
self._cleanup_references()
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Schedule server stop in the event loop thread
|
||||||
|
def stop_server():
|
||||||
|
"""이벤트 루프에서 서버를 안전하게 종료합니다."""
|
||||||
|
async def _shutdown():
|
||||||
|
try:
|
||||||
|
# 서버의 비동기 중지 메서드를 호출하고 완료될 때까지 기다립니다.
|
||||||
|
await server.stop()
|
||||||
|
except (RuntimeError, ValueError) as e:
|
||||||
|
print(f"Error stopping server: {e}")
|
||||||
|
finally:
|
||||||
|
# 서버가 완전히 중지된 후에 이벤트 루프를 멈춥니다.
|
||||||
|
loop.stop()
|
||||||
|
|
||||||
|
# 스레드 안전하게 비동기 종료 시퀀스를 스케줄링합니다.
|
||||||
|
asyncio.ensure_future(_shutdown())
|
||||||
|
|
||||||
|
# Call stop_server() in the event loop thread (thread-safe)
|
||||||
|
loop.call_soon_threadsafe(stop_server)
|
||||||
|
|
||||||
|
self.report({'INFO'}, f"✓ WebSocket server on port {port} stopped successfully")
|
||||||
|
print(f"Blender Toolkit: WebSocket server on port {port} stopped")
|
||||||
|
except (RuntimeError, ValueError, AttributeError) as e:
|
||||||
|
self.report({'ERROR'}, f"Failed to stop server: {str(e)}")
|
||||||
|
print(f"Blender Toolkit: Error stopping server: {e}")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
finally:
|
||||||
|
# Clean up all references
|
||||||
|
self._cleanup_references()
|
||||||
|
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
def _cleanup_references(self):
|
||||||
|
"""Clean up all server references"""
|
||||||
|
if hasattr(bpy.types.Scene, '_blender_toolkit_server'):
|
||||||
|
delattr(bpy.types.Scene, '_blender_toolkit_server')
|
||||||
|
if hasattr(bpy.types.Scene, '_blender_toolkit_event_loop'):
|
||||||
|
delattr(bpy.types.Scene, '_blender_toolkit_event_loop')
|
||||||
|
if hasattr(bpy.types.Scene, '_blender_toolkit_server_thread'):
|
||||||
|
delattr(bpy.types.Scene, '_blender_toolkit_server_thread')
|
||||||
|
if hasattr(bpy.types.Scene, '_blender_toolkit_server_port'):
|
||||||
|
delattr(bpy.types.Scene, '_blender_toolkit_server_port')
|
||||||
|
|
||||||
|
|
||||||
|
class BLENDERTOOLKIT_PT_BoneMappingPanel(bpy.types.Panel):
|
||||||
|
"""본 매핑 리뷰 패널"""
|
||||||
|
bl_label = "Bone Mapping Review"
|
||||||
|
bl_idname = "BLENDERTOOLKIT_PT_bone_mapping"
|
||||||
|
bl_space_type = 'VIEW_3D'
|
||||||
|
bl_region_type = 'UI'
|
||||||
|
bl_category = 'Blender Toolkit'
|
||||||
|
bl_options = {'DEFAULT_CLOSED'}
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
"""본 매핑 리뷰 패널 그리기."""
|
||||||
|
layout = self.layout
|
||||||
|
scene = context.scene
|
||||||
|
|
||||||
|
# Show armature info
|
||||||
|
if scene.bone_mapping_source_armature and scene.bone_mapping_target_armature:
|
||||||
|
layout.label(text=f"Source: {scene.bone_mapping_source_armature}", icon='ARMATURE_DATA')
|
||||||
|
layout.label(text=f"Target: {scene.bone_mapping_target_armature}", icon='ARMATURE_DATA')
|
||||||
|
layout.separator()
|
||||||
|
|
||||||
|
# Bone mapping table
|
||||||
|
if len(scene.bone_mapping_items) > 0:
|
||||||
|
mapping_count = len(scene.bone_mapping_items)
|
||||||
|
layout.label(
|
||||||
|
text=f"Bone Mappings ({mapping_count}):", icon='BONE_DATA'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
box = layout.box()
|
||||||
|
row = box.row()
|
||||||
|
row.label(text="Source Bone")
|
||||||
|
row.label(text="→")
|
||||||
|
row.label(text="Target Bone")
|
||||||
|
|
||||||
|
# Mapping items (scrollable)
|
||||||
|
for _, item in enumerate(scene.bone_mapping_items):
|
||||||
|
row = layout.row()
|
||||||
|
row.label(text=item.source_bone)
|
||||||
|
row.label(text="→")
|
||||||
|
|
||||||
|
# Editable target bone dropdown
|
||||||
|
target_armature = bpy.data.objects.get(scene.bone_mapping_target_armature)
|
||||||
|
if target_armature and target_armature.type == 'ARMATURE':
|
||||||
|
row.prop_search(item, "target_bone", target_armature.data, "bones", text="")
|
||||||
|
else:
|
||||||
|
row.prop(item, "target_bone", text="")
|
||||||
|
|
||||||
|
layout.separator()
|
||||||
|
|
||||||
|
# Auto re-map button
|
||||||
|
layout.operator(
|
||||||
|
"blendertoolkit.auto_remap", text="Auto Re-map",
|
||||||
|
icon='FILE_REFRESH'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply retargeting button
|
||||||
|
layout.separator()
|
||||||
|
|
||||||
|
# Show status
|
||||||
|
if hasattr(scene, 'bone_mapping_status'):
|
||||||
|
if scene.bone_mapping_status == "APPLYING":
|
||||||
|
layout.label(text="⏳ Applying retargeting...", icon='TIME')
|
||||||
|
elif scene.bone_mapping_status == "COMPLETED":
|
||||||
|
layout.label(text="✓ Retargeting completed!", icon='CHECKMARK')
|
||||||
|
elif scene.bone_mapping_status == "FAILED":
|
||||||
|
layout.label(text="✗ Retargeting failed", icon='ERROR')
|
||||||
|
|
||||||
|
layout.operator(
|
||||||
|
"blendertoolkit.apply_retargeting",
|
||||||
|
text="Apply Retargeting", icon='PLAY'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
layout.label(text="No bone mapping data", icon='INFO')
|
||||||
|
else:
|
||||||
|
layout.label(text="No bone mapping loaded", icon='INFO')
|
||||||
|
layout.label(text="Waiting for Claude Code...", icon='TIME')
|
||||||
|
|
||||||
|
|
||||||
|
class BLENDERTOOLKIT_OT_AutoRemap(bpy.types.Operator):
|
||||||
|
"""자동 재매핑 오퍼레이터"""
|
||||||
|
bl_idname = "blendertoolkit.auto_remap"
|
||||||
|
bl_label = "Auto Re-map Bones"
|
||||||
|
bl_description = "Re-generate bone mapping automatically"
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
"""자동 본 재매핑 오퍼레이터 실행."""
|
||||||
|
scene = context.scene
|
||||||
|
source_armature = scene.bone_mapping_source_armature
|
||||||
|
target_armature = scene.bone_mapping_target_armature
|
||||||
|
|
||||||
|
if not source_armature or not target_armature:
|
||||||
|
self.report({'ERROR'}, "Source or target armature not set")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Auto-map bones
|
||||||
|
bone_map = auto_map_bones(source_armature, target_armature)
|
||||||
|
|
||||||
|
# Update scene properties
|
||||||
|
scene.bone_mapping_items.clear()
|
||||||
|
for source_bone, target_bone in bone_map.items():
|
||||||
|
item = scene.bone_mapping_items.add()
|
||||||
|
item.source_bone = source_bone
|
||||||
|
item.target_bone = target_bone
|
||||||
|
|
||||||
|
self.report({'INFO'}, f"Re-mapped {len(bone_map)} bones")
|
||||||
|
except (ValueError, KeyError, AttributeError) as e:
|
||||||
|
self.report({'ERROR'}, f"Auto re-mapping failed: {str(e)}")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
class BLENDERTOOLKIT_OT_ApplyRetargeting(bpy.types.Operator):
|
||||||
|
"""리타게팅 적용 오퍼레이터"""
|
||||||
|
bl_idname = "blendertoolkit.apply_retargeting"
|
||||||
|
bl_label = "Apply Retargeting"
|
||||||
|
bl_description = "Apply retargeting with current bone mapping"
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
"""애니메이션 리타게팅을 적용하는 오퍼레이터 실행."""
|
||||||
|
scene = context.scene
|
||||||
|
source_armature = scene.bone_mapping_source_armature
|
||||||
|
target_armature = scene.bone_mapping_target_armature
|
||||||
|
|
||||||
|
if not source_armature or not target_armature:
|
||||||
|
self.report({'ERROR'}, "Source or target armature not set")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
# Build bone map from items
|
||||||
|
bone_map = {}
|
||||||
|
for item in scene.bone_mapping_items:
|
||||||
|
if item.target_bone: # Skip empty mappings
|
||||||
|
bone_map[item.source_bone] = item.target_bone
|
||||||
|
|
||||||
|
if not bone_map:
|
||||||
|
self.report({'ERROR'}, "No bone mappings defined")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Show progress in UI
|
||||||
|
self.report({'INFO'}, f"Applying retargeting to {len(bone_map)} bones...")
|
||||||
|
|
||||||
|
# Set status flag
|
||||||
|
scene.bone_mapping_status = "APPLYING"
|
||||||
|
|
||||||
|
result = retarget_animation(
|
||||||
|
source_armature,
|
||||||
|
target_armature,
|
||||||
|
bone_map,
|
||||||
|
preserve_rotation=True,
|
||||||
|
preserve_location=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update status
|
||||||
|
scene.bone_mapping_status = "COMPLETED"
|
||||||
|
|
||||||
|
self.report({'INFO'}, result)
|
||||||
|
except (ValueError, KeyError, AttributeError, RuntimeError) as e:
|
||||||
|
scene.bone_mapping_status = "FAILED"
|
||||||
|
self.report({'ERROR'}, f"Retargeting failed: {str(e)}")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Property Groups
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class BoneMappingItem(bpy.types.PropertyGroup):
|
||||||
|
"""본 매핑 아이템"""
|
||||||
|
source_bone: bpy.props.StringProperty(name="Source Bone")
|
||||||
|
target_bone: bpy.props.StringProperty(name="Target Bone")
|
||||||
33
skills/addon/utils/__init__.py
Normal file
33
skills/addon/utils/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
Blender Toolkit Utilities
|
||||||
|
유틸리티 모듈
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .bone_matching import (
|
||||||
|
normalize_bone_name,
|
||||||
|
calculate_similarity,
|
||||||
|
find_best_match,
|
||||||
|
fuzzy_match_bones,
|
||||||
|
get_match_quality_report,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .logger import (
|
||||||
|
get_logger,
|
||||||
|
setup_logging,
|
||||||
|
log_function_call,
|
||||||
|
log_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Bone matching
|
||||||
|
'normalize_bone_name',
|
||||||
|
'calculate_similarity',
|
||||||
|
'find_best_match',
|
||||||
|
'fuzzy_match_bones',
|
||||||
|
'get_match_quality_report',
|
||||||
|
# Logging
|
||||||
|
'get_logger',
|
||||||
|
'setup_logging',
|
||||||
|
'log_function_call',
|
||||||
|
'log_error',
|
||||||
|
]
|
||||||
262
skills/addon/utils/bone_matching.py
Normal file
262
skills/addon/utils/bone_matching.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
"""
|
||||||
|
Fuzzy Bone Matching Utilities
|
||||||
|
본 이름 유사도 기반 자동 매칭 알고리즘
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_bone_name(name: str) -> str:
|
||||||
|
"""
|
||||||
|
본 이름 정규화
|
||||||
|
- 소문자 변환
|
||||||
|
- 특수문자를 언더스코어로 변환
|
||||||
|
- 연속된 언더스코어 제거
|
||||||
|
- 양쪽 공백 제거
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
"Left_Arm" -> "left_arm"
|
||||||
|
"left-arm" -> "left_arm"
|
||||||
|
"LeftArm" -> "leftarm"
|
||||||
|
"Left Arm" -> "left_arm"
|
||||||
|
"""
|
||||||
|
# 소문자 변환
|
||||||
|
normalized = name.lower()
|
||||||
|
|
||||||
|
# 특수문자를 언더스코어로 변환 (알파벳, 숫자, 점만 유지)
|
||||||
|
normalized = re.sub(r'[^a-z0-9.]', '_', normalized)
|
||||||
|
|
||||||
|
# 연속된 언더스코어를 하나로
|
||||||
|
normalized = re.sub(r'_+', '_', normalized)
|
||||||
|
|
||||||
|
# 양쪽 언더스코어 제거
|
||||||
|
normalized = normalized.strip('_')
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_similarity(name1: str, name2: str) -> float:
|
||||||
|
"""
|
||||||
|
두 본 이름 간 유사도 계산 (0.0 ~ 1.0)
|
||||||
|
|
||||||
|
알고리즘:
|
||||||
|
1. 정규화된 이름으로 SequenceMatcher 사용 (기본 점수)
|
||||||
|
2. 부분 문자열 매칭 보너스
|
||||||
|
3. 접두사/접미사 매칭 보너스
|
||||||
|
4. 단어 포함 보너스
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name1: 첫 번째 본 이름
|
||||||
|
name2: 두 번째 본 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
유사도 점수 (0.0 = 전혀 다름, 1.0 = 완전 일치)
|
||||||
|
"""
|
||||||
|
# 정규화
|
||||||
|
norm1 = normalize_bone_name(name1)
|
||||||
|
norm2 = normalize_bone_name(name2)
|
||||||
|
|
||||||
|
# 완전 일치
|
||||||
|
if norm1 == norm2:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
# SequenceMatcher로 기본 유사도 계산
|
||||||
|
base_score = SequenceMatcher(None, norm1, norm2).ratio()
|
||||||
|
|
||||||
|
# 보너스 점수 계산
|
||||||
|
bonus = 0.0
|
||||||
|
|
||||||
|
# 1. 부분 문자열 매칭 보너스 (한쪽이 다른 쪽에 포함)
|
||||||
|
if norm1 in norm2 or norm2 in norm1:
|
||||||
|
bonus += 0.15
|
||||||
|
|
||||||
|
# 2. 접두사 매칭 보너스 (left, right 등)
|
||||||
|
common_prefixes = ['left', 'right', 'up', 'low', 'upper', 'lower']
|
||||||
|
for prefix in common_prefixes:
|
||||||
|
if norm1.startswith(prefix) and norm2.startswith(prefix):
|
||||||
|
bonus += 0.1
|
||||||
|
break
|
||||||
|
|
||||||
|
# 3. 접미사 매칭 보너스 (l, r 등)
|
||||||
|
underscore_l_match = norm1.endswith('_l') and norm2.endswith('_l')
|
||||||
|
underscore_r_match = norm1.endswith('_r') and norm2.endswith('_r')
|
||||||
|
dot_l_match = norm1.endswith('.l') and norm2.endswith('.l')
|
||||||
|
dot_r_match = norm1.endswith('.r') and norm2.endswith('.r')
|
||||||
|
|
||||||
|
if underscore_l_match or underscore_r_match or dot_l_match or dot_r_match:
|
||||||
|
bonus += 0.1
|
||||||
|
|
||||||
|
# 4. 숫자 매칭 보너스 (Spine1, Spine2 등)
|
||||||
|
digits1 = re.findall(r'\d+', norm1)
|
||||||
|
digits2 = re.findall(r'\d+', norm2)
|
||||||
|
if digits1 and digits2 and digits1 == digits2:
|
||||||
|
bonus += 0.1
|
||||||
|
|
||||||
|
# 5. 단어 포함 보너스 (arm, hand, leg, foot 등)
|
||||||
|
keywords = ['arm', 'hand', 'leg', 'foot', 'finger', 'thumb', 'index',
|
||||||
|
'middle', 'ring', 'pinky', 'shoulder', 'elbow', 'wrist',
|
||||||
|
'hip', 'knee', 'ankle', 'toe', 'spine', 'neck', 'head']
|
||||||
|
|
||||||
|
for keyword in keywords:
|
||||||
|
if keyword in norm1 and keyword in norm2:
|
||||||
|
bonus += 0.05
|
||||||
|
break
|
||||||
|
|
||||||
|
# 최종 점수 (최대 1.0)
|
||||||
|
final_score = min(base_score + bonus, 1.0)
|
||||||
|
|
||||||
|
return final_score
|
||||||
|
|
||||||
|
|
||||||
|
def find_best_match(
|
||||||
|
source_bone: str,
|
||||||
|
target_bones: List[str],
|
||||||
|
threshold: float = 0.6,
|
||||||
|
return_score: bool = False
|
||||||
|
) -> Union[Optional[str], Tuple[Optional[str], float]]:
|
||||||
|
"""
|
||||||
|
타겟 본 리스트에서 가장 유사한 본 찾기
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_bone: 매칭할 소스 본 이름
|
||||||
|
target_bones: 타겟 본 이름 리스트
|
||||||
|
threshold: 최소 유사도 임계값 (0.0 ~ 1.0)
|
||||||
|
return_score: True면 (본_이름, 점수) 튜플 반환
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
가장 유사한 타겟 본 이름, 또는 None (임계값 미만)
|
||||||
|
return_score=True면 (본_이름, 점수) 튜플
|
||||||
|
"""
|
||||||
|
best_match = None
|
||||||
|
best_score = 0.0
|
||||||
|
|
||||||
|
for target_bone in target_bones:
|
||||||
|
score = calculate_similarity(source_bone, target_bone)
|
||||||
|
|
||||||
|
if score > best_score and score >= threshold:
|
||||||
|
best_score = score
|
||||||
|
best_match = target_bone
|
||||||
|
|
||||||
|
if return_score:
|
||||||
|
return (best_match, best_score)
|
||||||
|
|
||||||
|
return best_match
|
||||||
|
|
||||||
|
|
||||||
|
def fuzzy_match_bones(
|
||||||
|
source_bones: List[str],
|
||||||
|
target_bones: List[str],
|
||||||
|
known_aliases: Dict[str, List[str]] = None,
|
||||||
|
threshold: float = 0.6,
|
||||||
|
prefer_exact: bool = True
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Fuzzy matching을 사용한 전체 본 매핑
|
||||||
|
|
||||||
|
알고리즘:
|
||||||
|
1. 정확한 매칭 우선 (known_aliases 사용)
|
||||||
|
2. Fuzzy matching으로 나머지 매칭
|
||||||
|
3. 임계값 이상인 것만 포함
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_bones: 소스 본 이름 리스트
|
||||||
|
target_bones: 타겟 본 이름 리스트
|
||||||
|
known_aliases: 알려진 별칭 딕셔너리 {source: [target_alias1, ...]}
|
||||||
|
threshold: 최소 유사도 임계값
|
||||||
|
prefer_exact: True면 정확한 매칭 우선
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
본 매핑 딕셔너리 {source_bone: target_bone}
|
||||||
|
"""
|
||||||
|
bone_map = {}
|
||||||
|
target_bone_names_lower = [b.lower() for b in target_bones]
|
||||||
|
matched_targets = set() # 중복 매칭 방지
|
||||||
|
|
||||||
|
# 1단계: 정확한 매칭 (known_aliases 사용)
|
||||||
|
if prefer_exact and known_aliases:
|
||||||
|
for source_bone in source_bones:
|
||||||
|
if source_bone not in known_aliases:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 별칭 리스트에서 타겟에 있는 것 찾기
|
||||||
|
for alias in known_aliases[source_bone]:
|
||||||
|
alias_lower = alias.lower()
|
||||||
|
if alias_lower in target_bone_names_lower:
|
||||||
|
idx = target_bone_names_lower.index(alias_lower)
|
||||||
|
actual_name = target_bones[idx]
|
||||||
|
|
||||||
|
# 이미 매칭된 타겟이 아니면 추가
|
||||||
|
if actual_name not in matched_targets:
|
||||||
|
bone_map[source_bone] = actual_name
|
||||||
|
matched_targets.add(actual_name)
|
||||||
|
break
|
||||||
|
|
||||||
|
# 2단계: Fuzzy matching으로 나머지 매칭
|
||||||
|
for source_bone in source_bones:
|
||||||
|
# 이미 매칭된 소스 본은 건너뛰기
|
||||||
|
if source_bone in bone_map:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 아직 매칭되지 않은 타겟 본들만 대상으로
|
||||||
|
available_targets = [t for t in target_bones if t not in matched_targets]
|
||||||
|
|
||||||
|
if not available_targets:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 가장 유사한 타겟 찾기
|
||||||
|
best_match, _ = find_best_match(
|
||||||
|
source_bone,
|
||||||
|
available_targets,
|
||||||
|
threshold=threshold,
|
||||||
|
return_score=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if best_match:
|
||||||
|
bone_map[source_bone] = best_match
|
||||||
|
matched_targets.add(best_match)
|
||||||
|
|
||||||
|
return bone_map
|
||||||
|
|
||||||
|
|
||||||
|
def get_match_quality_report(bone_map: Dict[str, str]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
본 매핑 품질 보고서 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bone_map: 본 매핑 딕셔너리
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
품질 보고서 딕셔너리
|
||||||
|
"""
|
||||||
|
if not bone_map:
|
||||||
|
return {
|
||||||
|
'total_mappings': 0,
|
||||||
|
'quality': 'none',
|
||||||
|
'summary': 'No bone mappings found'
|
||||||
|
}
|
||||||
|
|
||||||
|
total = len(bone_map)
|
||||||
|
|
||||||
|
# 주요 본 체크 (최소한 있어야 하는 본들)
|
||||||
|
critical_bones = ['Hips', 'Spine', 'Head', 'LeftArm', 'RightArm',
|
||||||
|
'LeftLeg', 'RightLeg', 'LeftHand', 'RightHand']
|
||||||
|
critical_mapped = sum(1 for bone in critical_bones if bone in bone_map)
|
||||||
|
|
||||||
|
# 품질 평가
|
||||||
|
if critical_mapped >= 8:
|
||||||
|
quality = 'excellent'
|
||||||
|
elif critical_mapped >= 6:
|
||||||
|
quality = 'good'
|
||||||
|
elif critical_mapped >= 4:
|
||||||
|
quality = 'fair'
|
||||||
|
else:
|
||||||
|
quality = 'poor'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_mappings': total,
|
||||||
|
'critical_bones_mapped': f'{critical_mapped}/{len(critical_bones)}',
|
||||||
|
'quality': quality,
|
||||||
|
'summary': f'{total} bones mapped, {critical_mapped}/{len(critical_bones)} critical bones'
|
||||||
|
}
|
||||||
165
skills/addon/utils/logger.py
Normal file
165
skills/addon/utils/logger.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
Python Logging Configuration
|
||||||
|
Blender addon용 로깅 시스템
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# 로그 디렉토리 경로
|
||||||
|
def get_log_dir() -> Path:
|
||||||
|
"""로그 디렉토리 경로 가져오기"""
|
||||||
|
# CLAUDE_PROJECT_DIR 환경변수 또는 현재 작업 디렉토리 사용
|
||||||
|
project_dir = os.environ.get('CLAUDE_PROJECT_DIR', os.getcwd())
|
||||||
|
log_dir = Path(project_dir) / '.blender-toolkit' / 'logs'
|
||||||
|
|
||||||
|
# 디렉토리 생성
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return log_dir
|
||||||
|
|
||||||
|
|
||||||
|
# 로그 포맷 정의
|
||||||
|
LOG_FORMAT = '[%(asctime)s] [%(levelname)-8s] [%(name)s] %(message)s'
|
||||||
|
DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
|
||||||
|
|
||||||
|
# 전역 로거 설정 완료 여부
|
||||||
|
_logger_initialized = False
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(level: int = logging.INFO) -> None:
|
||||||
|
"""
|
||||||
|
전역 로깅 설정 초기화
|
||||||
|
|
||||||
|
Args:
|
||||||
|
level: 로그 레벨 (logging.DEBUG, INFO, WARNING, ERROR)
|
||||||
|
"""
|
||||||
|
global _logger_initialized
|
||||||
|
|
||||||
|
if _logger_initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 로그 디렉토리
|
||||||
|
log_dir = get_log_dir()
|
||||||
|
|
||||||
|
# 루트 로거 설정
|
||||||
|
root_logger = logging.getLogger('blender_toolkit')
|
||||||
|
root_logger.setLevel(level)
|
||||||
|
|
||||||
|
# 기존 핸들러 제거 (중복 방지)
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
|
||||||
|
# 파일 핸들러 (모든 로그)
|
||||||
|
file_handler = logging.FileHandler(
|
||||||
|
log_dir / 'blender-addon.log',
|
||||||
|
mode='a',
|
||||||
|
encoding='utf-8'
|
||||||
|
)
|
||||||
|
file_handler.setLevel(level)
|
||||||
|
file_handler.setFormatter(logging.Formatter(LOG_FORMAT, DATE_FORMAT))
|
||||||
|
|
||||||
|
# 파일 핸들러 (에러만)
|
||||||
|
error_handler = logging.FileHandler(
|
||||||
|
log_dir / 'error.log',
|
||||||
|
mode='a',
|
||||||
|
encoding='utf-8'
|
||||||
|
)
|
||||||
|
error_handler.setLevel(logging.ERROR)
|
||||||
|
error_handler.setFormatter(logging.Formatter(LOG_FORMAT, DATE_FORMAT))
|
||||||
|
|
||||||
|
# 콘솔 핸들러 (개발 모드)
|
||||||
|
if os.environ.get('DEBUG'):
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(logging.DEBUG)
|
||||||
|
console_handler.setFormatter(
|
||||||
|
logging.Formatter('[%(levelname)-8s] %(message)s')
|
||||||
|
)
|
||||||
|
root_logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# 핸들러 추가
|
||||||
|
root_logger.addHandler(file_handler)
|
||||||
|
root_logger.addHandler(error_handler)
|
||||||
|
|
||||||
|
_logger_initialized = True
|
||||||
|
|
||||||
|
# 초기화 메시지
|
||||||
|
root_logger.info('=' * 70)
|
||||||
|
root_logger.info(f'Blender Toolkit Logger initialized')
|
||||||
|
root_logger.info(f'Log directory: {log_dir}')
|
||||||
|
root_logger.info(f'Log level: {logging.getLevelName(level)}')
|
||||||
|
root_logger.info('=' * 70)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str = None) -> logging.Logger:
|
||||||
|
"""
|
||||||
|
모듈별 로거 가져오기
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 로거 이름 (보통 __name__ 사용)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Logger 인스턴스
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from .utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.info("Hello, world!")
|
||||||
|
logger.error("An error occurred", exc_info=True)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
# 로깅 시스템이 초기화되지 않았으면 초기화
|
||||||
|
if not _logger_initialized:
|
||||||
|
# DEBUG 환경변수가 있으면 DEBUG 레벨 사용
|
||||||
|
level = logging.DEBUG if os.environ.get('DEBUG') else logging.INFO
|
||||||
|
setup_logging(level)
|
||||||
|
|
||||||
|
# 모듈별 로거 반환
|
||||||
|
logger_name = f'blender_toolkit.{name}' if name else 'blender_toolkit'
|
||||||
|
return logging.getLogger(logger_name)
|
||||||
|
|
||||||
|
|
||||||
|
# 편의 함수들
|
||||||
|
def log_function_call(logger: logging.Logger):
|
||||||
|
"""
|
||||||
|
함수 호출 로깅 데코레이터
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@log_function_call(logger)
|
||||||
|
def my_function(arg1, arg2):
|
||||||
|
return arg1 + arg2
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
def decorator(func):
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
logger.debug(f'Calling {func.__name__}() with args={args}, kwargs={kwargs}')
|
||||||
|
try:
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
logger.debug(f'{func.__name__}() returned: {result}')
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'{func.__name__}() raised {type(e).__name__}: {e}', exc_info=True)
|
||||||
|
raise
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def log_error(logger: logging.Logger, error: Exception, context: str = None):
|
||||||
|
"""
|
||||||
|
에러 로깅 헬퍼
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logger: Logger 인스턴스
|
||||||
|
error: Exception 객체
|
||||||
|
context: 에러 발생 컨텍스트 설명
|
||||||
|
"""
|
||||||
|
message = f'{type(error).__name__}: {error}'
|
||||||
|
if context:
|
||||||
|
message = f'{context} - {message}'
|
||||||
|
|
||||||
|
logger.error(message, exc_info=True)
|
||||||
129
skills/addon/utils/security.py
Normal file
129
skills/addon/utils/security.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
Security utilities for Blender Toolkit addon
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def validate_file_path(file_path: str, allowed_root: Optional[str] = None) -> str:
|
||||||
|
"""
|
||||||
|
Validate file path to prevent path traversal attacks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to validate
|
||||||
|
allowed_root: Optional allowed root directory. If None, only checks for dangerous patterns.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated absolute path
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path is invalid or outside allowed directory
|
||||||
|
"""
|
||||||
|
if not file_path:
|
||||||
|
raise ValueError("File path cannot be empty")
|
||||||
|
|
||||||
|
# Resolve to absolute path
|
||||||
|
try:
|
||||||
|
abs_path = os.path.abspath(os.path.expanduser(file_path))
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Invalid file path: {e}")
|
||||||
|
|
||||||
|
# Check for null bytes (security risk)
|
||||||
|
if '\0' in file_path:
|
||||||
|
raise ValueError("File path contains null bytes")
|
||||||
|
|
||||||
|
# If allowed_root is specified, ensure path is within it
|
||||||
|
if allowed_root:
|
||||||
|
allowed_abs = os.path.abspath(os.path.expanduser(allowed_root))
|
||||||
|
|
||||||
|
# Resolve symlinks to prevent bypass
|
||||||
|
try:
|
||||||
|
real_path = os.path.realpath(abs_path)
|
||||||
|
real_root = os.path.realpath(allowed_abs)
|
||||||
|
except Exception:
|
||||||
|
# If realpath fails, use absolute paths
|
||||||
|
real_path = abs_path
|
||||||
|
real_root = allowed_abs
|
||||||
|
|
||||||
|
# Check if path is within allowed root
|
||||||
|
try:
|
||||||
|
Path(real_path).relative_to(real_root)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Path outside allowed directory: {file_path}")
|
||||||
|
|
||||||
|
return abs_path
|
||||||
|
|
||||||
|
|
||||||
|
def validate_port(port: int) -> int:
|
||||||
|
"""
|
||||||
|
Validate WebSocket port number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: Port number to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated port number
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If port is invalid
|
||||||
|
"""
|
||||||
|
if not isinstance(port, int):
|
||||||
|
raise ValueError("Port must be an integer")
|
||||||
|
|
||||||
|
if port < 1024 or port > 65535:
|
||||||
|
raise ValueError("Port must be between 1024 and 65535")
|
||||||
|
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
# Whitelist for safe object attributes
|
||||||
|
ALLOWED_OBJECT_ATTRIBUTES = {
|
||||||
|
'location',
|
||||||
|
'rotation_euler',
|
||||||
|
'rotation_quaternion',
|
||||||
|
'rotation_axis_angle',
|
||||||
|
'scale',
|
||||||
|
'name',
|
||||||
|
'hide',
|
||||||
|
'hide_viewport',
|
||||||
|
'hide_render',
|
||||||
|
'hide_select',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Whitelist for safe armature bone attributes
|
||||||
|
ALLOWED_BONE_ATTRIBUTES = {
|
||||||
|
'name',
|
||||||
|
'head',
|
||||||
|
'tail',
|
||||||
|
'roll',
|
||||||
|
'use_connect',
|
||||||
|
'use_deform',
|
||||||
|
'use_inherit_rotation',
|
||||||
|
'use_inherit_scale',
|
||||||
|
'use_local_location',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_attribute_name(attr_name: str, allowed_attributes: set) -> str:
|
||||||
|
"""
|
||||||
|
Validate attribute name against whitelist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attr_name: Attribute name to validate
|
||||||
|
allowed_attributes: Set of allowed attribute names
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated attribute name
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If attribute is not allowed
|
||||||
|
"""
|
||||||
|
if not attr_name:
|
||||||
|
raise ValueError("Attribute name cannot be empty")
|
||||||
|
|
||||||
|
if attr_name not in allowed_attributes:
|
||||||
|
raise ValueError(f"Attribute '{attr_name}' is not allowed")
|
||||||
|
|
||||||
|
return attr_name
|
||||||
485
skills/addon/websocket_server.py
Normal file
485
skills/addon/websocket_server.py
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
"""
|
||||||
|
WebSocket Server for Blender Toolkit
|
||||||
|
Claude Code와 통신하기 위한 WebSocket 서버
|
||||||
|
|
||||||
|
이 모듈은 Blender 내부에서 WebSocket 서버를 실행하여
|
||||||
|
외부 클라이언트(Claude Code)와 JSON-RPC 스타일 통신을 제공합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, Union
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.web import Request, WebSocketResponse
|
||||||
|
|
||||||
|
from .utils.logger import get_logger
|
||||||
|
from .utils.security import validate_port
|
||||||
|
|
||||||
|
# 모듈 로거 초기화
|
||||||
|
logger = get_logger('websocket_server')
|
||||||
|
|
||||||
|
# 보안 상수
|
||||||
|
MAX_CONNECTIONS = 5 # 최대 동시 연결 수 (로컬 환경)
|
||||||
|
|
||||||
|
|
||||||
|
class BlenderWebSocketServer:
|
||||||
|
"""WebSocket 서버 메인 클래스"""
|
||||||
|
|
||||||
|
def __init__(self, port: int = 9400):
|
||||||
|
self.port = validate_port(port)
|
||||||
|
self.app = None
|
||||||
|
self.runner = None
|
||||||
|
self.site = None
|
||||||
|
self.clients = []
|
||||||
|
|
||||||
|
async def handle_command(
|
||||||
|
self, request: Request
|
||||||
|
) -> Union[WebSocketResponse, web.Response]:
|
||||||
|
"""WebSocket 연결 핸들러"""
|
||||||
|
# 로컬호스트만 허용 (보안)
|
||||||
|
peername = request.transport.get_extra_info('peername')
|
||||||
|
if peername:
|
||||||
|
host = peername[0]
|
||||||
|
if host not in ('127.0.0.1', '::1', 'localhost'):
|
||||||
|
logger.warning(
|
||||||
|
"Rejected connection from non-localhost: %s", host
|
||||||
|
)
|
||||||
|
return web.Response(
|
||||||
|
status=403, text="Only localhost connections allowed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 최대 연결 수 제한 (DoS 방지)
|
||||||
|
if len(self.clients) >= MAX_CONNECTIONS:
|
||||||
|
logger.warning(
|
||||||
|
"Connection limit reached (%d)", MAX_CONNECTIONS
|
||||||
|
)
|
||||||
|
return web.Response(status=503, text="Too many connections")
|
||||||
|
|
||||||
|
ws = web.WebSocketResponse()
|
||||||
|
await ws.prepare(request)
|
||||||
|
|
||||||
|
self.clients.append(ws)
|
||||||
|
logger.info("Client connected (total: %d)", len(self.clients))
|
||||||
|
print(f"✅ Client connected (total: {len(self.clients)})")
|
||||||
|
|
||||||
|
async for msg in ws:
|
||||||
|
if msg.type == web.WSMsgType.TEXT:
|
||||||
|
try:
|
||||||
|
data = json.loads(msg.data)
|
||||||
|
logger.debug("Received message: %s", data)
|
||||||
|
response = await self.process_command(data)
|
||||||
|
await ws.send_json(response)
|
||||||
|
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||||
|
logger.error("Error handling message: %s", e, exc_info=True)
|
||||||
|
await ws.send_json({
|
||||||
|
"id": data.get("id") if 'data' in locals() else None,
|
||||||
|
"error": {
|
||||||
|
"code": -1,
|
||||||
|
"message": str(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
elif msg.type == web.WSMsgType.ERROR:
|
||||||
|
logger.error('WebSocket error: %s', ws.exception())
|
||||||
|
print(f'❌ WebSocket error: {ws.exception()}')
|
||||||
|
|
||||||
|
self.clients.remove(ws)
|
||||||
|
logger.info("Client disconnected (total: %d)", len(self.clients))
|
||||||
|
print(f"🔌 Client disconnected (total: {len(self.clients)})")
|
||||||
|
return ws
|
||||||
|
|
||||||
|
async def process_command(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""명령 처리"""
|
||||||
|
method = data.get("method")
|
||||||
|
params = data.get("params", {})
|
||||||
|
msg_id = data.get("id")
|
||||||
|
|
||||||
|
logger.info("Processing command: %s", method)
|
||||||
|
logger.debug("Command params: %s", params)
|
||||||
|
print(f"📨 Received command: {method}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 메서드 라우팅
|
||||||
|
if method.startswith("Armature."):
|
||||||
|
result = await self.handle_armature_command(method, params)
|
||||||
|
elif method.startswith("Retargeting."):
|
||||||
|
result = await self.handle_retargeting_command(method, params)
|
||||||
|
elif method.startswith("BoneMapping."):
|
||||||
|
result = await self.handle_bonemapping_command(method, params)
|
||||||
|
elif method.startswith("Animation."):
|
||||||
|
result = await self.handle_animation_command(method, params)
|
||||||
|
elif method.startswith("Import."):
|
||||||
|
result = await self.handle_import_command(method, params)
|
||||||
|
elif method.startswith("Geometry."):
|
||||||
|
result = await self.handle_geometry_command(method, params)
|
||||||
|
elif method.startswith("Object."):
|
||||||
|
result = await self.handle_object_command(method, params)
|
||||||
|
elif method.startswith("Modifier."):
|
||||||
|
result = await self.handle_modifier_command(method, params)
|
||||||
|
elif method.startswith("Material."):
|
||||||
|
result = await self.handle_material_command(method, params)
|
||||||
|
elif method.startswith("Collection."):
|
||||||
|
result = await self.handle_collection_command(method, params)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown method: {method}")
|
||||||
|
|
||||||
|
logger.info("Command %s completed successfully", method)
|
||||||
|
return {"id": msg_id, "result": result}
|
||||||
|
except (ValueError, KeyError, AttributeError, RuntimeError) as e:
|
||||||
|
logger.error("Error processing %s: %s", method, str(e), exc_info=True)
|
||||||
|
print(f"❌ Error processing {method}: {str(e)}")
|
||||||
|
return {
|
||||||
|
"id": msg_id,
|
||||||
|
"error": {"code": -1, "message": str(e)}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def handle_armature_command(self, method: str, params: Dict) -> Any:
|
||||||
|
"""아마추어 관련 명령 처리"""
|
||||||
|
from .retargeting import get_bones, list_armatures
|
||||||
|
|
||||||
|
if method == "Armature.getBones":
|
||||||
|
armature_name = params.get("armatureName")
|
||||||
|
return get_bones(armature_name)
|
||||||
|
elif method == "Armature.list":
|
||||||
|
return list_armatures()
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown armature method: {method}")
|
||||||
|
|
||||||
|
async def handle_retargeting_command(self, method: str, params: Dict) -> Any:
|
||||||
|
"""리타게팅 명령 처리"""
|
||||||
|
from .retargeting import auto_map_bones, retarget_animation, get_preset_bone_mapping
|
||||||
|
|
||||||
|
if method == "Retargeting.autoMapBones":
|
||||||
|
return auto_map_bones(
|
||||||
|
params.get("sourceArmature"),
|
||||||
|
params.get("targetArmature")
|
||||||
|
)
|
||||||
|
elif method == "Retargeting.retargetAnimation":
|
||||||
|
return retarget_animation(
|
||||||
|
params.get("sourceArmature"),
|
||||||
|
params.get("targetArmature"),
|
||||||
|
params.get("boneMap"),
|
||||||
|
params.get("preserveRotation", True),
|
||||||
|
params.get("preserveLocation", False)
|
||||||
|
)
|
||||||
|
elif method == "Retargeting.getPresetMapping":
|
||||||
|
preset = params.get("preset")
|
||||||
|
return get_preset_bone_mapping(preset)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown retargeting method: {method}")
|
||||||
|
|
||||||
|
async def handle_bonemapping_command(self, method: str, params: Dict) -> Any:
|
||||||
|
"""본 매핑 명령 처리"""
|
||||||
|
from .retargeting import store_bone_mapping, load_bone_mapping
|
||||||
|
|
||||||
|
if method == "BoneMapping.show":
|
||||||
|
return store_bone_mapping(
|
||||||
|
params.get("sourceArmature"),
|
||||||
|
params.get("targetArmature"),
|
||||||
|
params.get("boneMapping")
|
||||||
|
)
|
||||||
|
elif method == "BoneMapping.get":
|
||||||
|
return load_bone_mapping(
|
||||||
|
params.get("sourceArmature"),
|
||||||
|
params.get("targetArmature")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown bone mapping method: {method}")
|
||||||
|
|
||||||
|
async def handle_animation_command(self, method: str, params: Dict) -> Any:
|
||||||
|
"""애니메이션 명령 처리"""
|
||||||
|
from .retargeting import list_animations, play_animation, stop_animation, add_to_nla
|
||||||
|
|
||||||
|
if method == "Animation.list":
|
||||||
|
armature_name = params.get("armatureName")
|
||||||
|
return list_animations(armature_name)
|
||||||
|
elif method == "Animation.play":
|
||||||
|
return play_animation(
|
||||||
|
params.get("armatureName"),
|
||||||
|
params.get("actionName"),
|
||||||
|
params.get("loop", True)
|
||||||
|
)
|
||||||
|
elif method == "Animation.stop":
|
||||||
|
return stop_animation()
|
||||||
|
elif method == "Animation.addToNLA":
|
||||||
|
return add_to_nla(
|
||||||
|
params.get("armatureName"),
|
||||||
|
params.get("actionName"),
|
||||||
|
params.get("trackName")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown animation method: {method}")
|
||||||
|
|
||||||
|
async def handle_import_command(self, method: str, params: Dict) -> Any:
|
||||||
|
"""임포트 명령 처리"""
|
||||||
|
from .retargeting import import_fbx, import_dae
|
||||||
|
|
||||||
|
if method == "Import.fbx":
|
||||||
|
return import_fbx(params.get("filepath"))
|
||||||
|
elif method == "Import.dae":
|
||||||
|
return import_dae(params.get("filepath"))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown import method: {method}")
|
||||||
|
|
||||||
|
async def handle_geometry_command(self, method: str, params: Dict) -> Any:
|
||||||
|
"""도형 생성 명령 처리"""
|
||||||
|
from .commands.geometry import (
|
||||||
|
create_cube, create_sphere, create_cylinder,
|
||||||
|
create_plane, create_cone, create_torus,
|
||||||
|
get_vertices, move_vertex, subdivide_mesh, extrude_face
|
||||||
|
)
|
||||||
|
|
||||||
|
if method == "Geometry.createCube":
|
||||||
|
return create_cube(
|
||||||
|
location=tuple(params.get("location", [0, 0, 0])),
|
||||||
|
size=params.get("size", 2.0),
|
||||||
|
name=params.get("name")
|
||||||
|
)
|
||||||
|
elif method == "Geometry.createSphere":
|
||||||
|
return create_sphere(
|
||||||
|
location=tuple(params.get("location", [0, 0, 0])),
|
||||||
|
radius=params.get("radius", 1.0),
|
||||||
|
segments=params.get("segments", 32),
|
||||||
|
ring_count=params.get("ringCount", 16),
|
||||||
|
name=params.get("name")
|
||||||
|
)
|
||||||
|
elif method == "Geometry.createCylinder":
|
||||||
|
return create_cylinder(
|
||||||
|
location=tuple(params.get("location", [0, 0, 0])),
|
||||||
|
radius=params.get("radius", 1.0),
|
||||||
|
depth=params.get("depth", 2.0),
|
||||||
|
vertices=params.get("vertices", 32),
|
||||||
|
name=params.get("name")
|
||||||
|
)
|
||||||
|
elif method == "Geometry.createPlane":
|
||||||
|
return create_plane(
|
||||||
|
location=tuple(params.get("location", [0, 0, 0])),
|
||||||
|
size=params.get("size", 2.0),
|
||||||
|
name=params.get("name")
|
||||||
|
)
|
||||||
|
elif method == "Geometry.createCone":
|
||||||
|
return create_cone(
|
||||||
|
location=tuple(params.get("location", [0, 0, 0])),
|
||||||
|
radius1=params.get("radius1", 1.0),
|
||||||
|
depth=params.get("depth", 2.0),
|
||||||
|
vertices=params.get("vertices", 32),
|
||||||
|
name=params.get("name")
|
||||||
|
)
|
||||||
|
elif method == "Geometry.createTorus":
|
||||||
|
return create_torus(
|
||||||
|
location=tuple(params.get("location", [0, 0, 0])),
|
||||||
|
major_radius=params.get("majorRadius", 1.0),
|
||||||
|
minor_radius=params.get("minorRadius", 0.25),
|
||||||
|
major_segments=params.get("majorSegments", 48),
|
||||||
|
minor_segments=params.get("minorSegments", 12),
|
||||||
|
name=params.get("name")
|
||||||
|
)
|
||||||
|
elif method == "Geometry.getVertices":
|
||||||
|
return get_vertices(params.get("name"))
|
||||||
|
elif method == "Geometry.moveVertex":
|
||||||
|
return move_vertex(
|
||||||
|
object_name=params.get("objectName"),
|
||||||
|
vertex_index=params.get("vertexIndex"),
|
||||||
|
new_position=tuple(params.get("newPosition"))
|
||||||
|
)
|
||||||
|
elif method == "Geometry.subdivideMesh":
|
||||||
|
return subdivide_mesh(
|
||||||
|
name=params.get("name"),
|
||||||
|
cuts=params.get("cuts", 1)
|
||||||
|
)
|
||||||
|
elif method == "Geometry.extrudeFace":
|
||||||
|
return extrude_face(
|
||||||
|
object_name=params.get("objectName"),
|
||||||
|
face_index=params.get("faceIndex"),
|
||||||
|
offset=params.get("offset", 1.0)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown geometry method: {method}")
|
||||||
|
|
||||||
|
async def handle_object_command(self, method: str, params: Dict) -> Any:
|
||||||
|
"""오브젝트 명령 처리"""
|
||||||
|
from .commands.geometry import (
|
||||||
|
delete_object, transform_object, duplicate_object, list_objects
|
||||||
|
)
|
||||||
|
|
||||||
|
if method == "Object.delete":
|
||||||
|
return delete_object(params.get("name"))
|
||||||
|
elif method == "Object.transform":
|
||||||
|
location = params.get("location")
|
||||||
|
rotation = params.get("rotation")
|
||||||
|
scale = params.get("scale")
|
||||||
|
return transform_object(
|
||||||
|
name=params.get("name"),
|
||||||
|
location=tuple(location) if location else None,
|
||||||
|
rotation=tuple(rotation) if rotation else None,
|
||||||
|
scale=tuple(scale) if scale else None
|
||||||
|
)
|
||||||
|
elif method == "Object.duplicate":
|
||||||
|
location = params.get("location")
|
||||||
|
return duplicate_object(
|
||||||
|
name=params.get("name"),
|
||||||
|
new_name=params.get("newName"),
|
||||||
|
location=tuple(location) if location else None
|
||||||
|
)
|
||||||
|
elif method == "Object.list":
|
||||||
|
return list_objects(params.get("type"))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown object method: {method}")
|
||||||
|
|
||||||
|
async def handle_modifier_command(self, method: str, params: Dict) -> Any:
|
||||||
|
"""모디파이어 명령 처리"""
|
||||||
|
from .commands.modifier import (
|
||||||
|
add_modifier, apply_modifier, list_modifiers, remove_modifier,
|
||||||
|
toggle_modifier, modify_modifier_properties, get_modifier_info, reorder_modifier
|
||||||
|
)
|
||||||
|
|
||||||
|
if method == "Modifier.add":
|
||||||
|
properties = params.get("properties", {})
|
||||||
|
return add_modifier(
|
||||||
|
object_name=params.get("objectName"),
|
||||||
|
modifier_type=params.get("modifierType"),
|
||||||
|
name=params.get("name")
|
||||||
|
)
|
||||||
|
elif method == "Modifier.apply":
|
||||||
|
return apply_modifier(
|
||||||
|
object_name=params.get("objectName"),
|
||||||
|
modifier_name=params.get("modifierName")
|
||||||
|
)
|
||||||
|
elif method == "Modifier.list":
|
||||||
|
return list_modifiers(
|
||||||
|
object_name=params.get("objectName")
|
||||||
|
)
|
||||||
|
elif method == "Modifier.remove":
|
||||||
|
return remove_modifier(
|
||||||
|
object_name=params.get("objectName"),
|
||||||
|
modifier_name=params.get("modifierName")
|
||||||
|
)
|
||||||
|
elif method == "Modifier.toggle":
|
||||||
|
return toggle_modifier(
|
||||||
|
object_name=params.get("objectName"),
|
||||||
|
modifier_name=params.get("modifierName"),
|
||||||
|
viewport=params.get("viewport"),
|
||||||
|
render=params.get("render")
|
||||||
|
)
|
||||||
|
elif method == "Modifier.modify":
|
||||||
|
properties = params.get("properties", {})
|
||||||
|
return modify_modifier_properties(
|
||||||
|
object_name=params.get("objectName"),
|
||||||
|
modifier_name=params.get("modifierName"),
|
||||||
|
**properties
|
||||||
|
)
|
||||||
|
elif method == "Modifier.getInfo":
|
||||||
|
return get_modifier_info(
|
||||||
|
object_name=params.get("objectName"),
|
||||||
|
modifier_name=params.get("modifierName")
|
||||||
|
)
|
||||||
|
elif method == "Modifier.reorder":
|
||||||
|
return reorder_modifier(
|
||||||
|
object_name=params.get("objectName"),
|
||||||
|
modifier_name=params.get("modifierName"),
|
||||||
|
direction=params.get("direction")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown modifier method: {method}")
|
||||||
|
|
||||||
|
async def handle_material_command(self, method: str, params: Dict) -> Any:
|
||||||
|
"""머티리얼 명령 처리"""
|
||||||
|
from .commands.material import (
|
||||||
|
create_material, list_materials, delete_material,
|
||||||
|
assign_material, list_object_materials,
|
||||||
|
set_material_base_color, set_material_metallic, set_material_roughness,
|
||||||
|
set_material_emission, get_material_properties
|
||||||
|
)
|
||||||
|
|
||||||
|
if method == "Material.create":
|
||||||
|
return create_material(
|
||||||
|
name=params.get("name"),
|
||||||
|
use_nodes=params.get("useNodes", True)
|
||||||
|
)
|
||||||
|
elif method == "Material.list":
|
||||||
|
return list_materials()
|
||||||
|
elif method == "Material.delete":
|
||||||
|
return delete_material(name=params.get("name"))
|
||||||
|
elif method == "Material.assign":
|
||||||
|
return assign_material(
|
||||||
|
object_name=params.get("objectName"),
|
||||||
|
material_name=params.get("materialName"),
|
||||||
|
slot_index=params.get("slotIndex", 0)
|
||||||
|
)
|
||||||
|
elif method == "Material.listObjectMaterials":
|
||||||
|
return list_object_materials(object_name=params.get("objectName"))
|
||||||
|
elif method == "Material.setBaseColor":
|
||||||
|
color = params.get("color")
|
||||||
|
return set_material_base_color(
|
||||||
|
material_name=params.get("materialName"),
|
||||||
|
color=tuple(color) if isinstance(color, list) else color
|
||||||
|
)
|
||||||
|
elif method == "Material.setMetallic":
|
||||||
|
return set_material_metallic(
|
||||||
|
material_name=params.get("materialName"),
|
||||||
|
metallic=params.get("metallic")
|
||||||
|
)
|
||||||
|
elif method == "Material.setRoughness":
|
||||||
|
return set_material_roughness(
|
||||||
|
material_name=params.get("materialName"),
|
||||||
|
roughness=params.get("roughness")
|
||||||
|
)
|
||||||
|
elif method == "Material.setEmission":
|
||||||
|
color = params.get("color")
|
||||||
|
return set_material_emission(
|
||||||
|
material_name=params.get("materialName"),
|
||||||
|
color=tuple(color) if isinstance(color, list) else color,
|
||||||
|
strength=params.get("strength", 1.0)
|
||||||
|
)
|
||||||
|
elif method == "Material.getProperties":
|
||||||
|
return get_material_properties(material_name=params.get("materialName"))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown material method: {method}")
|
||||||
|
|
||||||
|
async def handle_collection_command(self, method: str, params: Dict) -> Any:
|
||||||
|
"""컬렉션 명령 처리"""
|
||||||
|
from .commands.collection import (
|
||||||
|
create_collection, list_collections, add_to_collection,
|
||||||
|
remove_from_collection, delete_collection
|
||||||
|
)
|
||||||
|
|
||||||
|
if method == "Collection.create":
|
||||||
|
return create_collection(name=params.get("name"))
|
||||||
|
elif method == "Collection.list":
|
||||||
|
return list_collections()
|
||||||
|
elif method == "Collection.addObject":
|
||||||
|
return add_to_collection(
|
||||||
|
object_name=params.get("objectName"),
|
||||||
|
collection_name=params.get("collectionName")
|
||||||
|
)
|
||||||
|
elif method == "Collection.removeObject":
|
||||||
|
return remove_from_collection(
|
||||||
|
object_name=params.get("objectName"),
|
||||||
|
collection_name=params.get("collectionName")
|
||||||
|
)
|
||||||
|
elif method == "Collection.delete":
|
||||||
|
return delete_collection(name=params.get("name"))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown collection method: {method}")
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""서버 시작"""
|
||||||
|
self.app = web.Application()
|
||||||
|
self.app.router.add_get('/ws', self.handle_command)
|
||||||
|
|
||||||
|
self.runner = web.AppRunner(self.app)
|
||||||
|
await self.runner.setup()
|
||||||
|
|
||||||
|
self.site = web.TCPSite(self.runner, '127.0.0.1', self.port)
|
||||||
|
await self.site.start()
|
||||||
|
|
||||||
|
print(f"✅ Blender WebSocket Server started on port {self.port}")
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""서버 중지"""
|
||||||
|
if self.site:
|
||||||
|
await self.site.stop()
|
||||||
|
if self.runner:
|
||||||
|
await self.runner.cleanup()
|
||||||
|
print("🛑 Blender WebSocket Server stopped")
|
||||||
1229
skills/references/addon-api-reference.md
Normal file
1229
skills/references/addon-api-reference.md
Normal file
File diff suppressed because it is too large
Load Diff
653
skills/references/bone-mapping-guide.md
Normal file
653
skills/references/bone-mapping-guide.md
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
# Bone Mapping Guide
|
||||||
|
|
||||||
|
Complete guide to the automatic bone matching system for animation retargeting.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Bone Mapping Modes](#bone-mapping-modes)
|
||||||
|
- [Auto Bone Matching Algorithm](#auto-bone-matching-algorithm)
|
||||||
|
- [Two-Phase Workflow](#two-phase-workflow)
|
||||||
|
- [Quality Assessment](#quality-assessment)
|
||||||
|
- [Blender UI Panel](#blender-ui-panel)
|
||||||
|
- [Common Mapping Patterns](#common-mapping-patterns)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
- [Best Practices](#best-practices)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Bone mapping is the process of establishing correspondence between bones in the Mixamo animation skeleton and bones in your custom character rig. Accurate bone mapping is essential for successful animation retargeting.
|
||||||
|
|
||||||
|
**Why Bone Mapping Matters:**
|
||||||
|
- Mixamo uses standardized bone names (e.g., "mixamorig:Hips", "mixamorig:LeftArm")
|
||||||
|
- Custom rigs use various naming conventions (e.g., "Hips", "LeftArm", "left_arm", "arm.L")
|
||||||
|
- Without proper mapping, animations won't transfer correctly
|
||||||
|
- Incorrect mapping can result in twisted limbs, inverted rotations, or broken animations
|
||||||
|
|
||||||
|
**Core Features:**
|
||||||
|
- ✅ **Automatic Fuzzy Matching** - Intelligently matches bones by name similarity
|
||||||
|
- ✅ **UI Confirmation Workflow** - Review and edit mappings in Blender before applying
|
||||||
|
- ✅ **Quality Assessment** - Automatic evaluation of mapping quality
|
||||||
|
- ✅ **Rigify Presets** - Built-in support for Rigify rigs
|
||||||
|
- ✅ **Custom Mappings** - Support for non-standard rigs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bone Mapping Modes
|
||||||
|
|
||||||
|
Three modes are available for bone mapping:
|
||||||
|
|
||||||
|
### 1. Auto Mode (Recommended) ⭐
|
||||||
|
|
||||||
|
**When to Use:** Unknown or non-standard rigs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit retarget --target "Hero" --file "./Walking.fbx" --mapping auto
|
||||||
|
```
|
||||||
|
|
||||||
|
**How It Works:**
|
||||||
|
1. Analyzes both source (Mixamo) and target (your character) bone names
|
||||||
|
2. Uses fuzzy matching algorithm to find best matches
|
||||||
|
3. Generates mapping with similarity scores
|
||||||
|
4. Displays mapping in Blender UI for user review
|
||||||
|
5. User confirms or edits before application
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- Works with any rig structure
|
||||||
|
- No manual configuration required
|
||||||
|
- Intelligent name matching handles various conventions
|
||||||
|
- User confirmation ensures accuracy
|
||||||
|
|
||||||
|
**Similarity Algorithm:**
|
||||||
|
- Base matching using SequenceMatcher
|
||||||
|
- Bonuses for substring matches
|
||||||
|
- Bonuses for common prefixes (left, right, upper, lower)
|
||||||
|
- Bonuses for common suffixes (.L, .R, _l, _r)
|
||||||
|
- Bonuses for number matching (Spine1, Spine2)
|
||||||
|
- Bonuses for anatomical keywords (arm, leg, hand, foot)
|
||||||
|
|
||||||
|
### 2. Rigify Mode
|
||||||
|
|
||||||
|
**When to Use:** Standard Rigify rigs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit retarget --target "Hero" --file "./Walking.fbx" --mapping mixamo_to_rigify
|
||||||
|
```
|
||||||
|
|
||||||
|
**How It Works:**
|
||||||
|
- Uses predefined Mixamo → Rigify bone mapping
|
||||||
|
- Optimized for standard Rigify control rig structure
|
||||||
|
- Instant mapping with high confidence
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- Zero configuration for Rigify users
|
||||||
|
- Highest accuracy for Rigify rigs
|
||||||
|
- Immediate application (no UI review needed)
|
||||||
|
|
||||||
|
**Rigify Bone Naming:**
|
||||||
|
```
|
||||||
|
Mixamo Rigify
|
||||||
|
-------- ------
|
||||||
|
Hips hips
|
||||||
|
Spine spine_fk
|
||||||
|
Spine1 spine_fk.001
|
||||||
|
Spine2 spine_fk.002
|
||||||
|
Neck neck
|
||||||
|
Head head
|
||||||
|
LeftShoulder shoulder.L
|
||||||
|
LeftArm upper_arm_fk.L
|
||||||
|
LeftForeArm forearm_fk.L
|
||||||
|
LeftHand hand_fk.L
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Custom Mode
|
||||||
|
|
||||||
|
**When to Use:** Unique rig structures with known mappings
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In your workflow code
|
||||||
|
const customMapping = {
|
||||||
|
"Hips": "Root",
|
||||||
|
"Spine": "Torso_01",
|
||||||
|
"Spine1": "Torso_02",
|
||||||
|
"LeftArm": "L_UpperArm",
|
||||||
|
"RightArm": "R_UpperArm"
|
||||||
|
};
|
||||||
|
|
||||||
|
await workflow.run({
|
||||||
|
targetCharacterArmature: 'MyCharacter',
|
||||||
|
animationFilePath: './Walking.fbx',
|
||||||
|
boneMapping: customMapping
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- Full control over mapping
|
||||||
|
- Reusable across multiple animations
|
||||||
|
- No UI confirmation needed if mapping is trusted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto Bone Matching Algorithm
|
||||||
|
|
||||||
|
The fuzzy matching algorithm intelligently pairs bones from Mixamo skeleton to your character rig.
|
||||||
|
|
||||||
|
### Phase 1: Normalization
|
||||||
|
|
||||||
|
All bone names are normalized before comparison:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Input variations
|
||||||
|
"Left_Arm" → "left_arm"
|
||||||
|
"left-arm" → "left_arm"
|
||||||
|
"LeftArm" → "leftarm"
|
||||||
|
"Left Arm" → "left_arm"
|
||||||
|
"left.arm" → "left_arm"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Normalization Steps:**
|
||||||
|
1. Convert to lowercase
|
||||||
|
2. Replace special characters with underscore
|
||||||
|
3. Remove consecutive underscores
|
||||||
|
4. Strip leading/trailing underscores
|
||||||
|
|
||||||
|
### Phase 2: Similarity Calculation
|
||||||
|
|
||||||
|
Calculates similarity score (0.0 - 1.0) between bone names:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def calculate_similarity(name1: str, name2: str) -> float:
|
||||||
|
# Base score from SequenceMatcher
|
||||||
|
base_score = SequenceMatcher(None, norm1, norm2).ratio()
|
||||||
|
|
||||||
|
# Bonus factors
|
||||||
|
bonus = 0.0
|
||||||
|
|
||||||
|
# Substring match: +0.15
|
||||||
|
if norm1 in norm2 or norm2 in norm1:
|
||||||
|
bonus += 0.15
|
||||||
|
|
||||||
|
# Prefix match (left, right, etc): +0.1
|
||||||
|
# Suffix match (.L, .R, etc): +0.1
|
||||||
|
# Number match (Spine1, Spine2): +0.1
|
||||||
|
# Keyword match (arm, leg, etc): +0.05
|
||||||
|
|
||||||
|
return min(base_score + bonus, 1.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Scores:**
|
||||||
|
```
|
||||||
|
"LeftArm" ↔ "left_arm" = 0.95 (substring + prefix)
|
||||||
|
"LeftArm" ↔ "L_Arm" = 0.78 (keyword + suffix)
|
||||||
|
"LeftArm" ↔ "RightArm" = 0.65 (keyword only)
|
||||||
|
"LeftArm" ↔ "LeftLeg" = 0.42 (prefix only)
|
||||||
|
"LeftArm" ↔ "Head" = 0.15 (no match)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Best Match Selection
|
||||||
|
|
||||||
|
Selects the best match for each source bone:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def find_best_match(source_bone, target_bones, threshold=0.6):
|
||||||
|
best_match = None
|
||||||
|
best_score = 0.0
|
||||||
|
|
||||||
|
for target_bone in target_bones:
|
||||||
|
score = calculate_similarity(source_bone, target_bone)
|
||||||
|
|
||||||
|
if score > best_score and score >= threshold:
|
||||||
|
best_score = score
|
||||||
|
best_match = target_bone
|
||||||
|
|
||||||
|
return best_match
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Points:**
|
||||||
|
- Only matches above threshold (default: 0.6) are considered
|
||||||
|
- Each target bone can only be matched once (prevents double mapping)
|
||||||
|
- Returns `None` if no suitable match found
|
||||||
|
|
||||||
|
### Phase 4: Quality Assessment
|
||||||
|
|
||||||
|
Evaluates overall mapping quality based on critical bones:
|
||||||
|
|
||||||
|
```python
|
||||||
|
critical_bones = [
|
||||||
|
'Hips', # Root motion
|
||||||
|
'Spine', # Torso
|
||||||
|
'Head', # Head orientation
|
||||||
|
'LeftArm', # Upper body
|
||||||
|
'RightArm',
|
||||||
|
'LeftLeg', # Lower body
|
||||||
|
'RightLeg',
|
||||||
|
'LeftHand', # Extremities
|
||||||
|
'RightHand'
|
||||||
|
]
|
||||||
|
|
||||||
|
if critical_mapped >= 8:
|
||||||
|
quality = 'excellent' # Safe to auto-apply
|
||||||
|
elif critical_mapped >= 6:
|
||||||
|
quality = 'good' # Quick review recommended
|
||||||
|
elif critical_mapped >= 4:
|
||||||
|
quality = 'fair' # Thorough review required
|
||||||
|
else:
|
||||||
|
quality = 'poor' # Manual mapping needed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two-Phase Workflow
|
||||||
|
|
||||||
|
Blender Toolkit uses a two-phase workflow to ensure mapping accuracy.
|
||||||
|
|
||||||
|
### Phase 1: Generate & Display
|
||||||
|
|
||||||
|
**What Happens:**
|
||||||
|
1. Import animation FBX into Blender
|
||||||
|
2. Auto-generate bone mapping using fuzzy matching
|
||||||
|
3. Calculate quality score
|
||||||
|
4. Display mapping in Blender UI panel
|
||||||
|
|
||||||
|
**Blender UI Shows:**
|
||||||
|
- Complete bone mapping table
|
||||||
|
- Source bone → Target bone correspondence
|
||||||
|
- Editable dropdowns for each mapping
|
||||||
|
- Quality assessment score
|
||||||
|
- "Auto Re-map" button (regenerate)
|
||||||
|
- "Apply Retargeting" button (proceed to Phase 2)
|
||||||
|
|
||||||
|
**User Actions:**
|
||||||
|
- Review each bone correspondence
|
||||||
|
- Fix incorrect mappings using dropdowns
|
||||||
|
- Use "Auto Re-map" to regenerate if needed
|
||||||
|
- Click "Apply Retargeting" when satisfied
|
||||||
|
|
||||||
|
### Phase 2: Apply & Bake
|
||||||
|
|
||||||
|
**What Happens:**
|
||||||
|
1. User clicks "Apply Retargeting" in Blender
|
||||||
|
2. Creates constraint-based retargeting setup
|
||||||
|
3. Bakes animation to keyframes
|
||||||
|
4. Adds animation to NLA track
|
||||||
|
5. Cleans up temporary objects
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
- Fully retargeted animation on your character
|
||||||
|
- Animation stored in NLA track
|
||||||
|
- Original character rig unchanged
|
||||||
|
- Ready for further editing or export
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quality Assessment
|
||||||
|
|
||||||
|
The system automatically evaluates mapping quality.
|
||||||
|
|
||||||
|
### Quality Metrics
|
||||||
|
|
||||||
|
**Total Mappings:**
|
||||||
|
- Number of bones successfully mapped
|
||||||
|
- Higher is better
|
||||||
|
|
||||||
|
**Critical Bones Mapped:**
|
||||||
|
- 9 essential bones for quality animation
|
||||||
|
- Shows as ratio: "7/9 critical bones"
|
||||||
|
|
||||||
|
**Quality Rating:**
|
||||||
|
| Rating | Critical Bones | Recommendation |
|
||||||
|
|--------|----------------|----------------|
|
||||||
|
| **Excellent** | 8-9 | Safe to auto-apply with skip-confirmation |
|
||||||
|
| **Good** | 6-7 | Quick review recommended |
|
||||||
|
| **Fair** | 4-5 | Thorough review required |
|
||||||
|
| **Poor** | 0-3 | Manual mapping required |
|
||||||
|
|
||||||
|
### Quality Report Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_mappings": 52,
|
||||||
|
"critical_bones_mapped": "8/9",
|
||||||
|
"quality": "excellent",
|
||||||
|
"summary": "52 bones mapped, 8/9 critical bones"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### When to Review Mappings
|
||||||
|
|
||||||
|
**Always Review If:**
|
||||||
|
- Quality is "Fair" or "Poor"
|
||||||
|
- Character uses non-standard rig
|
||||||
|
- Animation has unusual requirements
|
||||||
|
- First time using a new character rig
|
||||||
|
|
||||||
|
**Quick Review If:**
|
||||||
|
- Quality is "Good"
|
||||||
|
- Character is standard Rigify
|
||||||
|
- Similar mappings worked before
|
||||||
|
|
||||||
|
**Auto-Apply If:**
|
||||||
|
- Quality is "Excellent"
|
||||||
|
- Using trusted custom mapping
|
||||||
|
- Repeated animations on same character
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Blender UI Panel
|
||||||
|
|
||||||
|
The bone mapping UI panel appears in Blender's View3D sidebar.
|
||||||
|
|
||||||
|
### Location
|
||||||
|
|
||||||
|
**Path:** View3D → Sidebar (N key) → "Blender Toolkit" tab → "Bone Mapping Review"
|
||||||
|
|
||||||
|
### Panel Components
|
||||||
|
|
||||||
|
**1. Mapping Table**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ Bone Mapping Review │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ Source Bone → Target Bone │
|
||||||
|
│ ─────────────────────────────── │
|
||||||
|
│ Hips → [Dropdown: Hips]│
|
||||||
|
│ Spine → [Dropdown: Spine]│
|
||||||
|
│ LeftArm → [Dropdown: LeftArm]│
|
||||||
|
│ ... │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Quality Info**
|
||||||
|
```
|
||||||
|
Quality: Excellent
|
||||||
|
Total: 52 mappings
|
||||||
|
Critical: 8/9 bones
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Action Buttons**
|
||||||
|
- **Auto Re-map** - Regenerate mapping
|
||||||
|
- **Apply Retargeting** - Proceed to apply
|
||||||
|
|
||||||
|
### Using the Panel
|
||||||
|
|
||||||
|
**Step 1: Open Panel**
|
||||||
|
```
|
||||||
|
1. Press N key in 3D View
|
||||||
|
2. Click "Blender Toolkit" tab
|
||||||
|
3. Find "Bone Mapping Review" panel
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Review Mappings**
|
||||||
|
```
|
||||||
|
1. Scroll through mapping table
|
||||||
|
2. Check each source → target correspondence
|
||||||
|
3. Pay special attention to critical bones:
|
||||||
|
- Hips (root motion)
|
||||||
|
- Spine chain (posture)
|
||||||
|
- Arms and legs (animation transfer)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Edit Mappings**
|
||||||
|
```
|
||||||
|
1. Click dropdown next to incorrect mapping
|
||||||
|
2. Select correct target bone from list
|
||||||
|
3. Repeat for all incorrect mappings
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Apply**
|
||||||
|
```
|
||||||
|
1. Click "Apply Retargeting" button
|
||||||
|
2. Wait for processing (progress shown in console)
|
||||||
|
3. Animation will be applied and baked
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Mapping Patterns
|
||||||
|
|
||||||
|
### Rigify Rigs
|
||||||
|
|
||||||
|
**Standard Rigify Control Rig:**
|
||||||
|
```
|
||||||
|
Mixamo Rigify
|
||||||
|
-------- ------
|
||||||
|
Hips hips
|
||||||
|
Spine spine_fk
|
||||||
|
Spine1 spine_fk.001
|
||||||
|
Spine2 spine_fk.002
|
||||||
|
Neck neck
|
||||||
|
Head head
|
||||||
|
|
||||||
|
LeftShoulder shoulder.L
|
||||||
|
LeftArm upper_arm_fk.L
|
||||||
|
LeftForeArm forearm_fk.L
|
||||||
|
LeftHand hand_fk.L
|
||||||
|
|
||||||
|
RightShoulder shoulder.R
|
||||||
|
RightArm upper_arm_fk.R
|
||||||
|
RightForeArm forearm_fk.R
|
||||||
|
RightHand hand_fk.R
|
||||||
|
|
||||||
|
LeftUpLeg thigh_fk.L
|
||||||
|
LeftLeg shin_fk.L
|
||||||
|
LeftFoot foot_fk.L
|
||||||
|
|
||||||
|
RightUpLeg thigh_fk.R
|
||||||
|
RightLeg shin_fk.R
|
||||||
|
RightFoot foot_fk.R
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unreal Engine (UE4/UE5)
|
||||||
|
|
||||||
|
**UE4 Mannequin Skeleton:**
|
||||||
|
```
|
||||||
|
Mixamo UE4/UE5
|
||||||
|
-------- -------
|
||||||
|
Hips pelvis
|
||||||
|
Spine spine_01
|
||||||
|
Spine1 spine_02
|
||||||
|
Spine2 spine_03
|
||||||
|
Neck neck_01
|
||||||
|
Head head
|
||||||
|
|
||||||
|
LeftShoulder clavicle_l
|
||||||
|
LeftArm upperarm_l
|
||||||
|
LeftForeArm lowerarm_l
|
||||||
|
LeftHand hand_l
|
||||||
|
|
||||||
|
RightShoulder clavicle_r
|
||||||
|
RightArm upperarm_r
|
||||||
|
RightForeArm lowerarm_r
|
||||||
|
RightHand hand_r
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unity Humanoid
|
||||||
|
|
||||||
|
**Unity Mecanim Humanoid:**
|
||||||
|
```
|
||||||
|
Mixamo Unity
|
||||||
|
-------- -----
|
||||||
|
Hips Hips
|
||||||
|
Spine Spine
|
||||||
|
Spine1 Chest
|
||||||
|
Spine2 UpperChest
|
||||||
|
Neck Neck
|
||||||
|
Head Head
|
||||||
|
|
||||||
|
LeftShoulder LeftShoulder
|
||||||
|
LeftArm LeftUpperArm
|
||||||
|
LeftForeArm LeftLowerArm
|
||||||
|
LeftHand LeftHand
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Poor Quality" Mapping
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- Quality assessment shows "Poor"
|
||||||
|
- Less than 4 critical bones mapped
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. **Check Rig Structure**
|
||||||
|
- Verify character has proper armature
|
||||||
|
- Ensure bones follow hierarchical structure
|
||||||
|
- Check for missing bones
|
||||||
|
|
||||||
|
2. **Use Custom Mapping**
|
||||||
|
- Create explicit bone mapping dictionary
|
||||||
|
- Test with known-good mapping first
|
||||||
|
|
||||||
|
3. **Review Bone Names**
|
||||||
|
- Check for unusual naming conventions
|
||||||
|
- Look for typos or special characters
|
||||||
|
|
||||||
|
### Incorrect Left/Right Mapping
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- Left arm mapped to right arm
|
||||||
|
- Crossed animations
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. **Check Suffix Convention**
|
||||||
|
- Ensure consistent use of .L/.R or _l/_r
|
||||||
|
- Verify suffix matches throughout rig
|
||||||
|
|
||||||
|
2. **Manual Correction**
|
||||||
|
- Use Blender UI to swap mappings
|
||||||
|
- Fix all left/right pairs
|
||||||
|
|
||||||
|
### Missing Critical Bones
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- Key bones not mapped (Hips, Spine, etc.)
|
||||||
|
- Animation doesn't transfer properly
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. **Lower Threshold**
|
||||||
|
```python
|
||||||
|
# In custom workflow
|
||||||
|
bone_map = fuzzy_match_bones(
|
||||||
|
source_bones,
|
||||||
|
target_bones,
|
||||||
|
threshold=0.5 # Lower from default 0.6
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check Bone Names**
|
||||||
|
- Print all bone names in Blender console
|
||||||
|
- Verify expected bones exist
|
||||||
|
|
||||||
|
3. **Use Explicit Mapping**
|
||||||
|
- Map critical bones manually
|
||||||
|
- Let auto-match handle fingers/toes
|
||||||
|
|
||||||
|
### Twisted or Inverted Limbs
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- Arms twist incorrectly
|
||||||
|
- Legs bend backwards
|
||||||
|
|
||||||
|
**Causes:**
|
||||||
|
- Bone roll differences
|
||||||
|
- Constraint axis misalignment
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. **Check Bone Roll**
|
||||||
|
- Compare source and target bone rolls
|
||||||
|
- Adjust in Edit Mode if needed
|
||||||
|
|
||||||
|
2. **Post-Process Animation**
|
||||||
|
- Use constraint influence
|
||||||
|
- Add corrective keyframes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Start Simple
|
||||||
|
|
||||||
|
**First Animation:**
|
||||||
|
- Use simple animation (Idle, Walking)
|
||||||
|
- Verify mapping quality
|
||||||
|
- Test full body movement
|
||||||
|
- Check for issues before complex animations
|
||||||
|
|
||||||
|
### 2. Review Critical Bones First
|
||||||
|
|
||||||
|
**Priority Order:**
|
||||||
|
1. **Hips** - Root motion and posture
|
||||||
|
2. **Spine Chain** - Torso movement
|
||||||
|
3. **Shoulders** - Upper body orientation
|
||||||
|
4. **Arms/Legs** - Limb movement
|
||||||
|
5. **Hands/Feet** - Extremity position
|
||||||
|
6. **Fingers/Toes** - Fine detail (optional)
|
||||||
|
|
||||||
|
### 3. Save Custom Mappings
|
||||||
|
|
||||||
|
**For Reuse:**
|
||||||
|
```typescript
|
||||||
|
// Save successful mapping
|
||||||
|
const myCharacterMapping = {
|
||||||
|
"Hips": "root_bone",
|
||||||
|
"Spine": "torso_01",
|
||||||
|
// ... complete mapping
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reuse for all animations
|
||||||
|
await workflow.run({
|
||||||
|
boneMapping: myCharacterMapping,
|
||||||
|
skipConfirmation: true // Safe with known mapping
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use Quality Threshold
|
||||||
|
|
||||||
|
**Decide Confirmation Strategy:**
|
||||||
|
```typescript
|
||||||
|
// Auto-apply only for excellent quality
|
||||||
|
if (quality === 'excellent') {
|
||||||
|
skipConfirmation = true;
|
||||||
|
} else {
|
||||||
|
skipConfirmation = false; // Review in UI
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Document Your Rigs
|
||||||
|
|
||||||
|
**Create Mapping Reference:**
|
||||||
|
```markdown
|
||||||
|
# Character: Hero
|
||||||
|
Rig Type: Custom
|
||||||
|
Created: 2024-01-15
|
||||||
|
|
||||||
|
## Bone Mapping
|
||||||
|
Mixamo → Hero
|
||||||
|
- Hips → root
|
||||||
|
- Spine → spine_01
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Uses custom spine chain (4 bones)
|
||||||
|
- Left/Right suffix: _L / _R
|
||||||
|
- Tested with: Walking, Running, Jumping
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Test Before Batch Processing
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
1. Test mapping with one animation
|
||||||
|
2. Verify quality and appearance
|
||||||
|
3. Save mapping configuration
|
||||||
|
4. Batch process remaining animations
|
||||||
|
|
||||||
|
### 7. Handle Edge Cases
|
||||||
|
|
||||||
|
**Preparation:**
|
||||||
|
- Create fallback mappings for unusual rigs
|
||||||
|
- Document special handling requirements
|
||||||
|
- Test with varied animation types
|
||||||
879
skills/references/commands-reference.md
Normal file
879
skills/references/commands-reference.md
Normal file
@@ -0,0 +1,879 @@
|
|||||||
|
# Commands Reference
|
||||||
|
|
||||||
|
Complete command-line interface reference for Blender Toolkit CLI.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Geometry Commands](#geometry-commands)
|
||||||
|
- [Object Commands](#object-commands)
|
||||||
|
- [Modifier Commands](#modifier-commands)
|
||||||
|
- [Material Commands](#material-commands)
|
||||||
|
- [Collection Commands](#collection-commands)
|
||||||
|
- [Retargeting Commands](#retargeting-commands)
|
||||||
|
- [Daemon Commands](#daemon-commands)
|
||||||
|
- [Global Options](#global-options)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Geometry Commands
|
||||||
|
|
||||||
|
Create and manipulate geometric primitives and meshes.
|
||||||
|
|
||||||
|
### create-cube
|
||||||
|
|
||||||
|
Create a cube primitive.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit create-cube [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-x, --x <number>` - X position (default: 0)
|
||||||
|
- `-y, --y <number>` - Y position (default: 0)
|
||||||
|
- `-z, --z <number>` - Z position (default: 0)
|
||||||
|
- `-s, --size <number>` - Cube size (default: 2.0)
|
||||||
|
- `-n, --name <string>` - Object name
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit create-cube --x 0 --y 0 --z 2 --size 1.5 --name "MyCube"
|
||||||
|
```
|
||||||
|
|
||||||
|
### create-sphere
|
||||||
|
|
||||||
|
Create a sphere primitive.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit create-sphere [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-x, --x <number>` - X position (default: 0)
|
||||||
|
- `-y, --y <number>` - Y position (default: 0)
|
||||||
|
- `-z, --z <number>` - Z position (default: 0)
|
||||||
|
- `-r, --radius <number>` - Sphere radius (default: 1.0)
|
||||||
|
- `--segments <number>` - Number of segments (default: 32)
|
||||||
|
- `--rings <number>` - Number of rings (default: 16)
|
||||||
|
- `-n, --name <string>` - Object name
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit create-sphere --radius 2 --segments 64 --rings 32
|
||||||
|
```
|
||||||
|
|
||||||
|
### create-cylinder
|
||||||
|
|
||||||
|
Create a cylinder primitive.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit create-cylinder [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-x, --x <number>` - X position (default: 0)
|
||||||
|
- `-y, --y <number>` - Y position (default: 0)
|
||||||
|
- `-z, --z <number>` - Z position (default: 0)
|
||||||
|
- `-r, --radius <number>` - Cylinder radius (default: 1.0)
|
||||||
|
- `-d, --depth <number>` - Cylinder height/depth (default: 2.0)
|
||||||
|
- `--vertices <number>` - Number of vertices (default: 32)
|
||||||
|
- `-n, --name <string>` - Object name
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit create-cylinder --radius 1.5 --depth 3 --vertices 64
|
||||||
|
```
|
||||||
|
|
||||||
|
### create-plane
|
||||||
|
|
||||||
|
Create a plane primitive.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit create-plane [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-x, --x <number>` - X position (default: 0)
|
||||||
|
- `-y, --y <number>` - Y position (default: 0)
|
||||||
|
- `-z, --z <number>` - Z position (default: 0)
|
||||||
|
- `-s, --size <number>` - Plane size (default: 2.0)
|
||||||
|
- `-n, --name <string>` - Object name
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit create-plane --size 10 --name "Ground"
|
||||||
|
```
|
||||||
|
|
||||||
|
### create-cone
|
||||||
|
|
||||||
|
Create a cone primitive.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit create-cone [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-x, --x <number>` - X position (default: 0)
|
||||||
|
- `-y, --y <number>` - Y position (default: 0)
|
||||||
|
- `-z, --z <number>` - Z position (default: 0)
|
||||||
|
- `-r, --radius <number>` - Cone base radius (default: 1.0)
|
||||||
|
- `-d, --depth <number>` - Cone height/depth (default: 2.0)
|
||||||
|
- `--vertices <number>` - Number of vertices (default: 32)
|
||||||
|
- `-n, --name <string>` - Object name
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit create-cone --radius 2 --depth 4
|
||||||
|
```
|
||||||
|
|
||||||
|
### create-torus
|
||||||
|
|
||||||
|
Create a torus primitive.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit create-torus [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-x, --x <number>` - X position (default: 0)
|
||||||
|
- `-y, --y <number>` - Y position (default: 0)
|
||||||
|
- `-z, --z <number>` - Z position (default: 0)
|
||||||
|
- `--major-radius <number>` - Major radius (default: 1.0)
|
||||||
|
- `--minor-radius <number>` - Minor radius/tube thickness (default: 0.25)
|
||||||
|
- `--major-segments <number>` - Major segments (default: 48)
|
||||||
|
- `--minor-segments <number>` - Minor segments (default: 12)
|
||||||
|
- `-n, --name <string>` - Object name
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit create-torus --major-radius 3 --minor-radius 0.5
|
||||||
|
```
|
||||||
|
|
||||||
|
### subdivide
|
||||||
|
|
||||||
|
Subdivide a mesh object to add more geometry detail.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit subdivide [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-n, --name <string>` - Object name **(required)**
|
||||||
|
- `-c, --cuts <number>` - Number of subdivision cuts (default: 1)
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit subdivide --name "Cube" --cuts 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### get-vertices
|
||||||
|
|
||||||
|
Get vertices information of an object.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit get-vertices [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-n, --name <string>` - Object name **(required)**
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit get-vertices --name "Sphere"
|
||||||
|
```
|
||||||
|
|
||||||
|
### move-vertex
|
||||||
|
|
||||||
|
Move a specific vertex to a new position.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit move-vertex [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-n, --name <string>` - Object name **(required)**
|
||||||
|
- `-i, --index <number>` - Vertex index **(required)**
|
||||||
|
- `-x, --x <number>` - New X position **(required)**
|
||||||
|
- `-y, --y <number>` - New Y position **(required)**
|
||||||
|
- `-z, --z <number>` - New Z position **(required)**
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit move-vertex --name "Cube" --index 0 --x 1.5 --y 0 --z 0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Object Commands
|
||||||
|
|
||||||
|
Manage and manipulate Blender objects.
|
||||||
|
|
||||||
|
### list-objects
|
||||||
|
|
||||||
|
List all objects in the scene.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit list-objects [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-t, --type <string>` - Filter by object type (MESH, ARMATURE, CAMERA, LIGHT)
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit list-objects --type MESH
|
||||||
|
```
|
||||||
|
|
||||||
|
### transform
|
||||||
|
|
||||||
|
Transform an object (move, rotate, scale).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit transform [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-n, --name <string>` - Object name **(required)**
|
||||||
|
- `--loc-x <number>` - X location
|
||||||
|
- `--loc-y <number>` - Y location
|
||||||
|
- `--loc-z <number>` - Z location
|
||||||
|
- `--rot-x <number>` - X rotation (radians)
|
||||||
|
- `--rot-y <number>` - Y rotation (radians)
|
||||||
|
- `--rot-z <number>` - Z rotation (radians)
|
||||||
|
- `--scale-x <number>` - X scale
|
||||||
|
- `--scale-y <number>` - Y scale
|
||||||
|
- `--scale-z <number>` - Z scale
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit transform --name "Cube" --loc-x 5 --loc-y 0 --loc-z 2 --scale-x 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### duplicate
|
||||||
|
|
||||||
|
Duplicate an object.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit duplicate [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-n, --name <string>` - Source object name **(required)**
|
||||||
|
- `--new-name <string>` - New object name
|
||||||
|
- `-x, --x <number>` - X position for duplicate
|
||||||
|
- `-y, --y <number>` - Y position for duplicate
|
||||||
|
- `-z, --z <number>` - Z position for duplicate
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit duplicate --name "Cube" --new-name "Cube.001" --x 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### delete
|
||||||
|
|
||||||
|
Delete an object.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit delete [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-n, --name <string>` - Object name **(required)**
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit delete --name "Cube.001"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modifier Commands
|
||||||
|
|
||||||
|
Add and manage modifiers on objects.
|
||||||
|
|
||||||
|
### add-modifier
|
||||||
|
|
||||||
|
Add a modifier to an object.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit add-modifier [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-n, --name <string>` - Object name **(required)**
|
||||||
|
- `-t, --type <string>` - Modifier type (SUBSURF, MIRROR, ARRAY, BEVEL, etc.) **(required)**
|
||||||
|
- `--mod-name <string>` - Modifier name
|
||||||
|
- `--levels <number>` - Subdivision levels (for SUBSURF)
|
||||||
|
- `--render-levels <number>` - Render levels (for SUBSURF)
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Common Modifier Types:**
|
||||||
|
- `SUBSURF` - Subdivision Surface
|
||||||
|
- `MIRROR` - Mirror
|
||||||
|
- `ARRAY` - Array
|
||||||
|
- `BEVEL` - Bevel
|
||||||
|
- `SOLIDIFY` - Solidify
|
||||||
|
- `BOOLEAN` - Boolean
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit add-modifier --name "Cube" --type SUBSURF --levels 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### apply-modifier
|
||||||
|
|
||||||
|
Apply a modifier to an object.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit apply-modifier [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-n, --name <string>` - Object name **(required)**
|
||||||
|
- `-m, --modifier <string>` - Modifier name **(required)**
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit apply-modifier --name "Cube" --modifier "Subdivision"
|
||||||
|
```
|
||||||
|
|
||||||
|
### list-modifiers
|
||||||
|
|
||||||
|
List all modifiers on an object.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit list-modifiers [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-n, --name <string>` - Object name **(required)**
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit list-modifiers --name "Cube"
|
||||||
|
```
|
||||||
|
|
||||||
|
### remove-modifier
|
||||||
|
|
||||||
|
Remove a modifier from an object.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit remove-modifier [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-n, --name <string>` - Object name **(required)**
|
||||||
|
- `-m, --modifier <string>` - Modifier name **(required)**
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit remove-modifier --name "Cube" --modifier "Subdivision"
|
||||||
|
```
|
||||||
|
|
||||||
|
### toggle-modifier
|
||||||
|
|
||||||
|
Toggle modifier visibility.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit toggle-modifier [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-n, --name <string>` - Object name **(required)**
|
||||||
|
- `-m, --modifier <string>` - Modifier name **(required)**
|
||||||
|
- `--viewport <boolean>` - Viewport visibility (true/false)
|
||||||
|
- `--render <boolean>` - Render visibility (true/false)
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit toggle-modifier --name "Cube" --modifier "Subdivision" --viewport false
|
||||||
|
```
|
||||||
|
|
||||||
|
### modify-modifier
|
||||||
|
|
||||||
|
Modify modifier properties.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit modify-modifier [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-n, --name <string>` - Object name **(required)**
|
||||||
|
- `-m, --modifier <string>` - Modifier name **(required)**
|
||||||
|
- `--levels <number>` - Subdivision levels
|
||||||
|
- `--render-levels <number>` - Render levels
|
||||||
|
- `--width <number>` - Bevel width
|
||||||
|
- `--segments <number>` - Bevel segments
|
||||||
|
- `--count <number>` - Array count
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit modify-modifier --name "Cube" --modifier "Subdivision" --levels 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### get-modifier-info
|
||||||
|
|
||||||
|
Get detailed modifier information.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit get-modifier-info [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-n, --name <string>` - Object name **(required)**
|
||||||
|
- `-m, --modifier <string>` - Modifier name **(required)**
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit get-modifier-info --name "Cube" --modifier "Subdivision"
|
||||||
|
```
|
||||||
|
|
||||||
|
### reorder-modifier
|
||||||
|
|
||||||
|
Reorder modifier in the modifier stack.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit reorder-modifier [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-n, --name <string>` - Object name **(required)**
|
||||||
|
- `-m, --modifier <string>` - Modifier name **(required)**
|
||||||
|
- `-d, --direction <string>` - Direction (UP or DOWN) **(required)**
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit reorder-modifier --name "Cube" --modifier "Subdivision" --direction UP
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Material Commands
|
||||||
|
|
||||||
|
Create and manage materials.
|
||||||
|
|
||||||
|
### material create
|
||||||
|
|
||||||
|
Create a new material.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit material create [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--name <name>` - Material name **(required)**
|
||||||
|
- `--no-nodes` - Disable node-based material (default: enabled)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit material create --name "RedMaterial"
|
||||||
|
```
|
||||||
|
|
||||||
|
### material list
|
||||||
|
|
||||||
|
List all materials in the scene.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit material list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit material list
|
||||||
|
```
|
||||||
|
|
||||||
|
### material delete
|
||||||
|
|
||||||
|
Delete a material.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit material delete [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--name <name>` - Material name **(required)**
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit material delete --name "RedMaterial"
|
||||||
|
```
|
||||||
|
|
||||||
|
### material assign
|
||||||
|
|
||||||
|
Assign a material to an object.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit material assign [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--object <name>` - Object name **(required)**
|
||||||
|
- `--material <name>` - Material name **(required)**
|
||||||
|
- `--slot <index>` - Material slot index (default: 0)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit material assign --object "Cube" --material "RedMaterial"
|
||||||
|
```
|
||||||
|
|
||||||
|
### material list-object
|
||||||
|
|
||||||
|
List materials assigned to an object.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit material list-object [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--object <name>` - Object name **(required)**
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit material list-object --object "Cube"
|
||||||
|
```
|
||||||
|
|
||||||
|
### material set-color
|
||||||
|
|
||||||
|
Set material base color.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit material set-color [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--material <name>` - Material name **(required)**
|
||||||
|
- `--r <value>` - Red (0-1) **(required)**
|
||||||
|
- `--g <value>` - Green (0-1) **(required)**
|
||||||
|
- `--b <value>` - Blue (0-1) **(required)**
|
||||||
|
- `--a <value>` - Alpha (0-1) (default: 1.0)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit material set-color --material "RedMaterial" --r 1.0 --g 0.0 --b 0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### material set-metallic
|
||||||
|
|
||||||
|
Set material metallic value.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit material set-metallic [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--material <name>` - Material name **(required)**
|
||||||
|
- `--value <value>` - Metallic value (0-1) **(required)**
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit material set-metallic --material "MetalMaterial" --value 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### material set-roughness
|
||||||
|
|
||||||
|
Set material roughness value.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit material set-roughness [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--material <name>` - Material name **(required)**
|
||||||
|
- `--value <value>` - Roughness value (0-1) **(required)**
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit material set-roughness --material "MetalMaterial" --value 0.2
|
||||||
|
```
|
||||||
|
|
||||||
|
### material set-emission
|
||||||
|
|
||||||
|
Set material emission.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit material set-emission [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--material <name>` - Material name **(required)**
|
||||||
|
- `--r <value>` - Red (0-1) **(required)**
|
||||||
|
- `--g <value>` - Green (0-1) **(required)**
|
||||||
|
- `--b <value>` - Blue (0-1) **(required)**
|
||||||
|
- `--strength <value>` - Emission strength (default: 1.0)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit material set-emission --material "GlowMaterial" --r 0 --g 1 --b 0 --strength 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### material get-properties
|
||||||
|
|
||||||
|
Get material properties.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit material get-properties [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--material <name>` - Material name **(required)**
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit material get-properties --material "RedMaterial"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collection Commands
|
||||||
|
|
||||||
|
Organize objects into collections.
|
||||||
|
|
||||||
|
### collection create
|
||||||
|
|
||||||
|
Create a new collection.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit collection create [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--name <name>` - Collection name **(required)**
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit collection create --name "Props"
|
||||||
|
```
|
||||||
|
|
||||||
|
### collection list
|
||||||
|
|
||||||
|
List all collections.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit collection list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit collection list
|
||||||
|
```
|
||||||
|
|
||||||
|
### collection add-object
|
||||||
|
|
||||||
|
Add an object to a collection.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit collection add-object [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--object <name>` - Object name **(required)**
|
||||||
|
- `--collection <name>` - Collection name **(required)**
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit collection add-object --object "Cube" --collection "Props"
|
||||||
|
```
|
||||||
|
|
||||||
|
### collection remove-object
|
||||||
|
|
||||||
|
Remove an object from a collection.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit collection remove-object [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--object <name>` - Object name **(required)**
|
||||||
|
- `--collection <name>` - Collection name **(required)**
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit collection remove-object --object "Cube" --collection "Props"
|
||||||
|
```
|
||||||
|
|
||||||
|
### collection delete
|
||||||
|
|
||||||
|
Delete a collection.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit collection delete [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--name <name>` - Collection name **(required)**
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit collection delete --name "Props"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retargeting Commands
|
||||||
|
|
||||||
|
Animation retargeting from Mixamo to custom rigs.
|
||||||
|
|
||||||
|
### retarget
|
||||||
|
|
||||||
|
Retarget animation from Mixamo to your character.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit retarget [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-t, --target <string>` - Target character armature name **(required)**
|
||||||
|
- `-f, --file <string>` - Animation file path (FBX or DAE) **(required)**
|
||||||
|
- `-n, --name <string>` - Animation name for NLA track
|
||||||
|
- `-m, --mapping <string>` - Bone mapping mode (auto, mixamo_to_rigify, custom) (default: auto)
|
||||||
|
- `--skip-confirmation` - Skip bone mapping confirmation (default: false)
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
- `-o, --output <string>` - Output directory
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit retarget --target "Hero" --file "./Walking.fbx" --name "Walking"
|
||||||
|
```
|
||||||
|
|
||||||
|
**With Auto Confirmation:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit retarget --target "Hero" --file "./Walking.fbx" --skip-confirmation
|
||||||
|
```
|
||||||
|
|
||||||
|
### mixamo-help
|
||||||
|
|
||||||
|
Show Mixamo download instructions and popular animations.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit mixamo-help [animation-name]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Arguments:**
|
||||||
|
- `[animation-name]` - Optional: Get specific animation instructions
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
# Show all popular animations and general instructions
|
||||||
|
blender-toolkit mixamo-help
|
||||||
|
|
||||||
|
# Show instructions for specific animation
|
||||||
|
blender-toolkit mixamo-help Walking
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Daemon Commands
|
||||||
|
|
||||||
|
Manage Blender WebSocket server daemon.
|
||||||
|
|
||||||
|
### daemon-start
|
||||||
|
|
||||||
|
Start the Blender WebSocket server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit daemon-start [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-p, --port <number>` - Port number (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit daemon-start --port 9400
|
||||||
|
```
|
||||||
|
|
||||||
|
### daemon-stop
|
||||||
|
|
||||||
|
Stop the Blender WebSocket server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit daemon-stop [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-p, --port <number>` - Port number (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit daemon-stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### daemon-status
|
||||||
|
|
||||||
|
Check Blender WebSocket server status.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender-toolkit daemon-status [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-p, --port <number>` - Port number (default: 9400)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit daemon-status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Global Options
|
||||||
|
|
||||||
|
Options available for all commands:
|
||||||
|
|
||||||
|
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
|
||||||
|
- `-h, --help` - Display help for command
|
||||||
|
- `-V, --version` - Output the version number
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Port Range
|
||||||
|
|
||||||
|
Blender Toolkit uses port range **9400-9500** for WebSocket connections.
|
||||||
|
|
||||||
|
- Default port: **9400**
|
||||||
|
- Browser Pilot uses: **9222-9322** (no conflict)
|
||||||
|
- Multiple projects can run simultaneously with different ports
|
||||||
|
- Ports are auto-assigned and persisted in project configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
1. **Use `--help` for Detailed Options:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit <command> --help
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Port Conflicts:**
|
||||||
|
- If default port 9400 is in use, specify a different port
|
||||||
|
- Configuration persists across sessions
|
||||||
|
|
||||||
|
3. **Object Names are Case-Sensitive:**
|
||||||
|
- Use exact names as they appear in Blender
|
||||||
|
|
||||||
|
4. **WebSocket Connection:**
|
||||||
|
- Ensure Blender addon is enabled and server is started
|
||||||
|
- Check port number matches between CLI and addon
|
||||||
|
|
||||||
|
5. **Batch Operations:**
|
||||||
|
- Use shell scripts to combine multiple commands
|
||||||
|
- Example: Create multiple objects with different positions
|
||||||
817
skills/references/workflow-guide.md
Normal file
817
skills/references/workflow-guide.md
Normal file
@@ -0,0 +1,817 @@
|
|||||||
|
# Workflow Guide
|
||||||
|
|
||||||
|
Detailed guide for animation retargeting workflows using Blender Toolkit.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [Complete Retargeting Workflow](#complete-retargeting-workflow)
|
||||||
|
- [Mixamo Download Workflow](#mixamo-download-workflow)
|
||||||
|
- [Two-Phase Confirmation Workflow](#two-phase-confirmation-workflow)
|
||||||
|
- [Batch Processing Workflow](#batch-processing-workflow)
|
||||||
|
- [Multi-Project Workflow](#multi-project-workflow)
|
||||||
|
- [Advanced Workflows](#advanced-workflows)
|
||||||
|
- [Common Scenarios](#common-scenarios)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Blender Toolkit provides a complete workflow for retargeting Mixamo animations to custom character rigs in Blender.
|
||||||
|
|
||||||
|
**Core Workflow Steps:**
|
||||||
|
1. Prepare character rig in Blender
|
||||||
|
2. Download animation from Mixamo
|
||||||
|
3. Connect to Blender via WebSocket
|
||||||
|
4. Import and auto-map bones
|
||||||
|
5. Review mapping in Blender UI
|
||||||
|
6. Confirm and apply retargeting
|
||||||
|
7. Animation baked to NLA track
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- WebSocket-based real-time control
|
||||||
|
- Two-phase confirmation workflow
|
||||||
|
- Automatic bone mapping with UI review
|
||||||
|
- Quality assessment
|
||||||
|
- Multi-project support
|
||||||
|
- Session hooks for auto-initialization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### 1. Blender Setup
|
||||||
|
|
||||||
|
**Install and Configure:**
|
||||||
|
```
|
||||||
|
1. Install Blender 4.0 or higher (2023+)
|
||||||
|
2. Install Python addon:
|
||||||
|
Method 1 (Recommended): Install from ZIP
|
||||||
|
- Edit → Preferences → Add-ons → Install
|
||||||
|
- Select: .blender-toolkit/blender-toolkit-addon-v*.zip
|
||||||
|
- Enable "Blender Toolkit WebSocket Server"
|
||||||
|
|
||||||
|
Method 2: Install from Source
|
||||||
|
- Edit → Preferences → Add-ons → Install
|
||||||
|
- Select: plugins/blender-toolkit/skills/addon/__init__.py
|
||||||
|
- Enable "Blender Toolkit WebSocket Server"
|
||||||
|
3. Start WebSocket server:
|
||||||
|
- View3D → Sidebar (N key) → "Blender Toolkit" tab
|
||||||
|
- Click "Start Server"
|
||||||
|
- Default port: 9400
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Character Rig Requirements
|
||||||
|
|
||||||
|
**Your Character Must Have:**
|
||||||
|
- ✅ Armature with properly set up bones
|
||||||
|
- ✅ Standard or Rigify-compatible bone naming (recommended)
|
||||||
|
- ✅ Proper parent-child bone hierarchy
|
||||||
|
- ✅ Character loaded in current Blender scene
|
||||||
|
|
||||||
|
**Supported Rig Types:**
|
||||||
|
- Rigify control rigs ⭐ (best support)
|
||||||
|
- Custom rigs with standard naming
|
||||||
|
- Game engine rigs (UE4/UE5, Unity)
|
||||||
|
- Any armature with clear bone hierarchy
|
||||||
|
|
||||||
|
### 3. Local Scripts
|
||||||
|
|
||||||
|
**Auto-Initialized by SessionStart Hook:**
|
||||||
|
- TypeScript source copied to `.blender-toolkit/skills/scripts/`
|
||||||
|
- Dependencies installed (`npm install`)
|
||||||
|
- Scripts built (`npm run build`)
|
||||||
|
- CLI wrapper created (`.blender-toolkit/bt.js`)
|
||||||
|
|
||||||
|
**Manual Check (if needed):**
|
||||||
|
```bash
|
||||||
|
# Verify scripts are built
|
||||||
|
ls .blender-toolkit/skills/scripts/dist
|
||||||
|
|
||||||
|
# Rebuild if necessary
|
||||||
|
cd .blender-toolkit/skills/scripts
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Retargeting Workflow
|
||||||
|
|
||||||
|
### Step 1: Prepare Character
|
||||||
|
|
||||||
|
**In Blender:**
|
||||||
|
```
|
||||||
|
1. Open your character model
|
||||||
|
2. Verify armature exists and is rigged
|
||||||
|
3. Note the exact armature name (case-sensitive)
|
||||||
|
4. Check bone structure (Edit Mode):
|
||||||
|
- Proper hierarchy (Hips → Spine → etc.)
|
||||||
|
- Standard naming (preferred)
|
||||||
|
5. Leave Blender open with character visible
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tips:**
|
||||||
|
- Use descriptive armature name: "HeroRig", "PlayerModel"
|
||||||
|
- Avoid generic names: "Armature", "Armature.001"
|
||||||
|
- Ensure character is in rest pose
|
||||||
|
|
||||||
|
### Step 2: Download Mixamo Animation
|
||||||
|
|
||||||
|
**Option A: User Has FBX File**
|
||||||
|
- User provides path to downloaded FBX
|
||||||
|
- Skip to Step 3
|
||||||
|
|
||||||
|
**Option B: User Needs to Download**
|
||||||
|
```bash
|
||||||
|
# Show download instructions
|
||||||
|
blender-toolkit mixamo-help Walking
|
||||||
|
```
|
||||||
|
|
||||||
|
**Download Steps:**
|
||||||
|
1. Go to Mixamo.com
|
||||||
|
2. Search for animation (e.g., "Walking")
|
||||||
|
3. Configure settings:
|
||||||
|
- Format: FBX (.fbx)
|
||||||
|
- Skin: Without Skin
|
||||||
|
- FPS: 30
|
||||||
|
- Keyframe Reduction: None
|
||||||
|
4. Click "Download"
|
||||||
|
5. Note download path
|
||||||
|
|
||||||
|
**Recommended Settings:**
|
||||||
|
```
|
||||||
|
Format: FBX (.fbx)
|
||||||
|
Skin: Without Skin
|
||||||
|
Frame Rate: 30 fps
|
||||||
|
Keyframe Reduction: None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Verify Blender Connection
|
||||||
|
|
||||||
|
**Check WebSocket Server:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit daemon-status
|
||||||
|
```
|
||||||
|
|
||||||
|
**If Not Running:**
|
||||||
|
```
|
||||||
|
1. Open Blender
|
||||||
|
2. Press N key in 3D View
|
||||||
|
3. Click "Blender Toolkit" tab
|
||||||
|
4. Click "Start Server"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Output:**
|
||||||
|
```
|
||||||
|
✅ Blender WebSocket server is running on port 9400
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Execute Retargeting
|
||||||
|
|
||||||
|
**Basic Command:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit retarget \
|
||||||
|
--target "HeroRig" \
|
||||||
|
--file "./downloads/Walking.fbx" \
|
||||||
|
--name "Walking"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What Happens:**
|
||||||
|
```
|
||||||
|
🎬 Starting animation retargeting workflow...
|
||||||
|
|
||||||
|
[1/6] Connecting to Blender...
|
||||||
|
✅ Connected to Blender on port 9400
|
||||||
|
|
||||||
|
[2/6] Importing animation FBX...
|
||||||
|
✅ Animation imported: 30 frames
|
||||||
|
|
||||||
|
[3/6] Analyzing bone structure...
|
||||||
|
✅ Source bones: 65 (Mixamo)
|
||||||
|
✅ Target bones: 52 (HeroRig)
|
||||||
|
|
||||||
|
[4/6] Auto-generating bone mapping...
|
||||||
|
✅ Mapped 48 bones
|
||||||
|
✅ Quality: Excellent (8/9 critical bones)
|
||||||
|
|
||||||
|
[5/6] Displaying mapping in Blender UI...
|
||||||
|
✅ Mapping displayed in "Bone Mapping Review" panel
|
||||||
|
|
||||||
|
⏸ Workflow paused for user review
|
||||||
|
👉 Please review the bone mapping in Blender
|
||||||
|
👉 Edit any incorrect mappings
|
||||||
|
👉 Click "Apply Retargeting" when ready
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Review Mapping in Blender
|
||||||
|
|
||||||
|
**Open Mapping Panel:**
|
||||||
|
```
|
||||||
|
1. Press N key in 3D View
|
||||||
|
2. Go to "Blender Toolkit" tab
|
||||||
|
3. Find "Bone Mapping Review" panel
|
||||||
|
```
|
||||||
|
|
||||||
|
**Review Checklist:**
|
||||||
|
- [ ] Hips mapped correctly (root motion)
|
||||||
|
- [ ] Spine chain mapped in order
|
||||||
|
- [ ] Left/Right arms not swapped
|
||||||
|
- [ ] Left/Right legs not swapped
|
||||||
|
- [ ] Hands and feet mapped
|
||||||
|
- [ ] Head and neck mapped
|
||||||
|
|
||||||
|
**Edit If Needed:**
|
||||||
|
- Click dropdown next to incorrect mapping
|
||||||
|
- Select correct bone from list
|
||||||
|
- Repeat for all issues
|
||||||
|
|
||||||
|
### Step 6: Apply Retargeting
|
||||||
|
|
||||||
|
**In Blender:**
|
||||||
|
```
|
||||||
|
1. After reviewing mappings
|
||||||
|
2. Click "Apply Retargeting" button
|
||||||
|
3. Wait for processing
|
||||||
|
```
|
||||||
|
|
||||||
|
**Processing Steps:**
|
||||||
|
```
|
||||||
|
[6/6] Applying retargeting...
|
||||||
|
- Creating constraint setup...
|
||||||
|
- Baking animation to keyframes...
|
||||||
|
- Adding to NLA track...
|
||||||
|
- Cleaning up temporary objects...
|
||||||
|
|
||||||
|
✅ Animation retargeting completed successfully!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
- Animation applied to your character
|
||||||
|
- Stored in NLA track named "Walking"
|
||||||
|
- Original rig unchanged
|
||||||
|
- Ready for editing or export
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mixamo Download Workflow
|
||||||
|
|
||||||
|
Step-by-step guide for downloading animations from Mixamo.
|
||||||
|
|
||||||
|
### Get Download Instructions
|
||||||
|
|
||||||
|
**Show Popular Animations:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit mixamo-help
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```
|
||||||
|
📚 Popular Mixamo Animations:
|
||||||
|
|
||||||
|
Locomotion:
|
||||||
|
• Walking
|
||||||
|
• Running
|
||||||
|
• Jogging
|
||||||
|
• Sprinting
|
||||||
|
• Crouching
|
||||||
|
|
||||||
|
Combat:
|
||||||
|
• Punching
|
||||||
|
• Kicking
|
||||||
|
• Sword Slash
|
||||||
|
• Rifle Aim
|
||||||
|
• Pistol Fire
|
||||||
|
|
||||||
|
Idle:
|
||||||
|
• Idle
|
||||||
|
• Breathing Idle
|
||||||
|
• Standing Idle
|
||||||
|
```
|
||||||
|
|
||||||
|
**Get Specific Instructions:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit mixamo-help Walking
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```
|
||||||
|
📥 Mixamo Download Instructions for "Walking"
|
||||||
|
|
||||||
|
1. Go to https://www.mixamo.com
|
||||||
|
2. Sign in or create account (free)
|
||||||
|
3. Search for "Walking" in the search bar
|
||||||
|
4. Select the animation you want
|
||||||
|
5. Click "Download" button
|
||||||
|
6. Configure download settings:
|
||||||
|
✅ Format: FBX (.fbx)
|
||||||
|
✅ Skin: Without Skin
|
||||||
|
✅ Frame Rate: 30 fps
|
||||||
|
✅ Keyframe Reduction: None
|
||||||
|
7. Click "Download"
|
||||||
|
8. Note the downloaded file path
|
||||||
|
|
||||||
|
⚙️ Recommended Settings:
|
||||||
|
|
||||||
|
Format: FBX (.fbx)
|
||||||
|
Skin: Without Skin
|
||||||
|
Frame Rate: 30 fps
|
||||||
|
Keyframe Reduction: None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why "Without Skin"
|
||||||
|
|
||||||
|
**Reasons:**
|
||||||
|
- We only need animation data, not mesh
|
||||||
|
- Reduces file size significantly
|
||||||
|
- Faster import into Blender
|
||||||
|
- Cleaner workflow (no extra objects to delete)
|
||||||
|
|
||||||
|
**What It Means:**
|
||||||
|
- FBX contains only skeleton and keyframes
|
||||||
|
- No mesh/geometry included
|
||||||
|
- Perfect for retargeting to existing characters
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two-Phase Confirmation Workflow
|
||||||
|
|
||||||
|
The workflow pauses after mapping generation for user review.
|
||||||
|
|
||||||
|
### Phase 1: Generate and Display
|
||||||
|
|
||||||
|
**Automatic Steps:**
|
||||||
|
```
|
||||||
|
1. Import FBX [Auto]
|
||||||
|
2. Extract bone structure [Auto]
|
||||||
|
3. Generate mapping [Auto]
|
||||||
|
4. Display in UI [Auto]
|
||||||
|
5. Pause for review [Manual]
|
||||||
|
```
|
||||||
|
|
||||||
|
**User Actions:**
|
||||||
|
- Review mapping quality
|
||||||
|
- Check critical bones
|
||||||
|
- Edit incorrect mappings
|
||||||
|
- Confirm readiness
|
||||||
|
|
||||||
|
### Phase 2: Apply and Bake
|
||||||
|
|
||||||
|
**Triggered by User:**
|
||||||
|
- User clicks "Apply Retargeting" in Blender
|
||||||
|
|
||||||
|
**Automatic Steps:**
|
||||||
|
```
|
||||||
|
6. Create constraints [Auto]
|
||||||
|
7. Bake to keyframes [Auto]
|
||||||
|
8. Add to NLA track [Auto]
|
||||||
|
9. Cleanup [Auto]
|
||||||
|
10. Complete [Auto]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skipping Confirmation
|
||||||
|
|
||||||
|
**For Trusted Mappings:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit retarget \
|
||||||
|
--target "HeroRig" \
|
||||||
|
--file "./Walking.fbx" \
|
||||||
|
--skip-confirmation
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to Skip:**
|
||||||
|
- Excellent quality mapping (8-9 critical bones)
|
||||||
|
- Repeated animations on same character
|
||||||
|
- Using proven custom mapping
|
||||||
|
- Batch processing with known-good setup
|
||||||
|
|
||||||
|
**When NOT to Skip:**
|
||||||
|
- First animation on new character
|
||||||
|
- Unknown rig structure
|
||||||
|
- Fair or Poor quality mapping
|
||||||
|
- Complex or unusual animations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Batch Processing Workflow
|
||||||
|
|
||||||
|
Process multiple animations efficiently.
|
||||||
|
|
||||||
|
### Step 1: Test Single Animation
|
||||||
|
|
||||||
|
**Verify Setup:**
|
||||||
|
```bash
|
||||||
|
# Test with one animation first
|
||||||
|
blender-toolkit retarget \
|
||||||
|
--target "HeroRig" \
|
||||||
|
--file "./Walking.fbx" \
|
||||||
|
--name "Walking"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check Results:**
|
||||||
|
- Animation looks correct
|
||||||
|
- No twisted limbs
|
||||||
|
- Left/Right not swapped
|
||||||
|
- Quality is excellent
|
||||||
|
|
||||||
|
### Step 2: Extract Mapping
|
||||||
|
|
||||||
|
**Save Successful Mapping:**
|
||||||
|
```typescript
|
||||||
|
// After successful test, save the mapping
|
||||||
|
// Check Blender console or logs for generated mapping
|
||||||
|
const heroRigMapping = {
|
||||||
|
"Hips": "root",
|
||||||
|
"Spine": "spine_01",
|
||||||
|
"Spine1": "spine_02",
|
||||||
|
// ... complete mapping
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to file for reuse
|
||||||
|
fs.writeFileSync('./hero-mapping.json', JSON.stringify(heroRigMapping));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Batch Process
|
||||||
|
|
||||||
|
**Shell Script Example:**
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# batch-retarget.sh
|
||||||
|
|
||||||
|
ANIMATIONS=(
|
||||||
|
"Walking"
|
||||||
|
"Running"
|
||||||
|
"Jumping"
|
||||||
|
"Idle"
|
||||||
|
"Punching"
|
||||||
|
)
|
||||||
|
|
||||||
|
for anim in "${ANIMATIONS[@]}"; do
|
||||||
|
echo "Processing ${anim}..."
|
||||||
|
blender-toolkit retarget \
|
||||||
|
--target "HeroRig" \
|
||||||
|
--file "./animations/${anim}.fbx" \
|
||||||
|
--name "${anim}" \
|
||||||
|
--skip-confirmation
|
||||||
|
echo "✅ ${anim} completed"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "🎉 All animations processed!"
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript Example:**
|
||||||
|
```typescript
|
||||||
|
// batch-retarget.ts
|
||||||
|
const animations = [
|
||||||
|
'Walking', 'Running', 'Jumping',
|
||||||
|
'Idle', 'Punching'
|
||||||
|
];
|
||||||
|
|
||||||
|
const workflow = new AnimationRetargetingWorkflow();
|
||||||
|
|
||||||
|
for (const anim of animations) {
|
||||||
|
await workflow.run({
|
||||||
|
targetCharacterArmature: 'HeroRig',
|
||||||
|
animationFilePath: `./animations/${anim}.fbx`,
|
||||||
|
animationName: anim,
|
||||||
|
boneMapping: heroRigMapping, // Reuse saved mapping
|
||||||
|
skipConfirmation: true
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ ${anim} completed`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-Project Workflow
|
||||||
|
|
||||||
|
Work with multiple Blender projects simultaneously.
|
||||||
|
|
||||||
|
### Port Management
|
||||||
|
|
||||||
|
**Default Behavior:**
|
||||||
|
- First project: Port 9400
|
||||||
|
- Second project: Port 9401
|
||||||
|
- Third project: Port 9402
|
||||||
|
- Auto-increments for each project
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```json
|
||||||
|
// ~/.claude/plugins/.../blender-config.json
|
||||||
|
{
|
||||||
|
"projects": {
|
||||||
|
"/path/to/project-a": {
|
||||||
|
"port": 9400,
|
||||||
|
"lastUsed": "2024-01-15T10:30:00Z"
|
||||||
|
},
|
||||||
|
"/path/to/project-b": {
|
||||||
|
"port": 9401,
|
||||||
|
"lastUsed": "2024-01-15T11:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
**Project A:**
|
||||||
|
```bash
|
||||||
|
cd /path/to/project-a
|
||||||
|
|
||||||
|
# Start Blender with port 9400
|
||||||
|
# Run retargeting
|
||||||
|
blender-toolkit retarget \
|
||||||
|
--target "CharacterA" \
|
||||||
|
--file "./Walking.fbx" \
|
||||||
|
--port 9400
|
||||||
|
```
|
||||||
|
|
||||||
|
**Project B (Simultaneously):**
|
||||||
|
```bash
|
||||||
|
cd /path/to/project-b
|
||||||
|
|
||||||
|
# Start Blender with port 9401
|
||||||
|
# Run retargeting
|
||||||
|
blender-toolkit retarget \
|
||||||
|
--target "CharacterB" \
|
||||||
|
--file "./Running.fbx" \
|
||||||
|
--port 9401
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- No port conflicts
|
||||||
|
- Simultaneous processing
|
||||||
|
- Independent configurations
|
||||||
|
- Separate log files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Workflows
|
||||||
|
|
||||||
|
### Custom Bone Mapping Workflow
|
||||||
|
|
||||||
|
**For Non-Standard Rigs:**
|
||||||
|
|
||||||
|
**Step 1: Analyze Bones**
|
||||||
|
```bash
|
||||||
|
# List all bones in target rig
|
||||||
|
blender-toolkit list-objects --type ARMATURE
|
||||||
|
blender-toolkit get-bones --armature "MyRig"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Create Mapping**
|
||||||
|
```typescript
|
||||||
|
// custom-mapping.ts
|
||||||
|
export const myRigMapping = {
|
||||||
|
// Core
|
||||||
|
"Hips": "pelvis",
|
||||||
|
"Spine": "spine_01",
|
||||||
|
"Spine1": "spine_02",
|
||||||
|
"Spine2": "chest",
|
||||||
|
"Neck": "neck_01",
|
||||||
|
"Head": "head",
|
||||||
|
|
||||||
|
// Left Arm
|
||||||
|
"LeftShoulder": "clavicle_L",
|
||||||
|
"LeftArm": "upperarm_L",
|
||||||
|
"LeftForeArm": "forearm_L",
|
||||||
|
"LeftHand": "hand_L",
|
||||||
|
|
||||||
|
// Right Arm
|
||||||
|
"RightShoulder": "clavicle_R",
|
||||||
|
"RightArm": "upperarm_R",
|
||||||
|
"RightForeArm": "forearm_R",
|
||||||
|
"RightHand": "hand_R",
|
||||||
|
|
||||||
|
// Add remaining bones...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Use Custom Mapping**
|
||||||
|
```typescript
|
||||||
|
import { myRigMapping } from './custom-mapping';
|
||||||
|
|
||||||
|
await workflow.run({
|
||||||
|
targetCharacterArmature: 'MyRig',
|
||||||
|
animationFilePath: './Walking.fbx',
|
||||||
|
boneMapping: myRigMapping,
|
||||||
|
skipConfirmation: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animation Library Workflow
|
||||||
|
|
||||||
|
**Organize Animation Library:**
|
||||||
|
|
||||||
|
**Directory Structure:**
|
||||||
|
```
|
||||||
|
animations/
|
||||||
|
├── locomotion/
|
||||||
|
│ ├── walking.fbx
|
||||||
|
│ ├── running.fbx
|
||||||
|
│ └── jumping.fbx
|
||||||
|
├── combat/
|
||||||
|
│ ├── punch.fbx
|
||||||
|
│ ├── kick.fbx
|
||||||
|
│ └── block.fbx
|
||||||
|
└── idle/
|
||||||
|
├── idle.fbx
|
||||||
|
└── breathing.fbx
|
||||||
|
```
|
||||||
|
|
||||||
|
**Batch Import Script:**
|
||||||
|
```typescript
|
||||||
|
// import-library.ts
|
||||||
|
const library = {
|
||||||
|
locomotion: ['walking', 'running', 'jumping'],
|
||||||
|
combat: ['punch', 'kick', 'block'],
|
||||||
|
idle: ['idle', 'breathing']
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [category, animations] of Object.entries(library)) {
|
||||||
|
for (const anim of animations) {
|
||||||
|
await workflow.run({
|
||||||
|
targetCharacterArmature: 'Hero',
|
||||||
|
animationFilePath: `./animations/${category}/${anim}.fbx`,
|
||||||
|
animationName: `${category}_${anim}`,
|
||||||
|
boneMapping: 'auto',
|
||||||
|
skipConfirmation: false // Review each category first
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: First-Time User
|
||||||
|
|
||||||
|
**Goal:** Retarget first Mixamo animation to custom character
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Download animation from Mixamo
|
||||||
|
2. Start Blender with character
|
||||||
|
3. Enable and start WebSocket addon
|
||||||
|
4. Run retarget command
|
||||||
|
5. Review mapping in UI
|
||||||
|
6. Apply retargeting
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
```bash
|
||||||
|
# Get download instructions
|
||||||
|
blender-toolkit mixamo-help Walking
|
||||||
|
|
||||||
|
# After downloading...
|
||||||
|
blender-toolkit retarget \
|
||||||
|
--target "MyCharacter" \
|
||||||
|
--file "./Walking.fbx"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2: Rigify User
|
||||||
|
|
||||||
|
**Goal:** Fast workflow for standard Rigify rig
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Download animation
|
||||||
|
2. Run with Rigify preset
|
||||||
|
3. Auto-apply (skip confirmation)
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
```bash
|
||||||
|
blender-toolkit retarget \
|
||||||
|
--target "MyRigifyCharacter" \
|
||||||
|
--file "./Walking.fbx" \
|
||||||
|
--mapping mixamo_to_rigify \
|
||||||
|
--skip-confirmation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 3: Game Developer
|
||||||
|
|
||||||
|
**Goal:** Import 50 animations for game character
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Test one animation
|
||||||
|
2. Save mapping configuration
|
||||||
|
3. Batch process all animations
|
||||||
|
4. Export to game engine
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
```bash
|
||||||
|
# Test first
|
||||||
|
blender-toolkit retarget \
|
||||||
|
--target "GameCharacter" \
|
||||||
|
--file "./test.fbx"
|
||||||
|
|
||||||
|
# Batch process
|
||||||
|
./batch-import.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 4: Studio Pipeline
|
||||||
|
|
||||||
|
**Goal:** Integrate into production pipeline
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
- Custom wrapper scripts
|
||||||
|
- CI/CD integration
|
||||||
|
- Automated testing
|
||||||
|
- Quality validation
|
||||||
|
|
||||||
|
**Pipeline:**
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/animation-pipeline.yml
|
||||||
|
jobs:
|
||||||
|
retarget:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Setup Blender
|
||||||
|
run: install-blender
|
||||||
|
|
||||||
|
- name: Start WebSocket
|
||||||
|
run: start-blender-daemon
|
||||||
|
|
||||||
|
- name: Retarget Animations
|
||||||
|
run: |
|
||||||
|
for fbx in animations/*.fbx; do
|
||||||
|
blender-toolkit retarget \
|
||||||
|
--target "$CHARACTER" \
|
||||||
|
--file "$fbx" \
|
||||||
|
--skip-confirmation
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Connection Issues
|
||||||
|
|
||||||
|
**Problem:** "Failed to connect to Blender"
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
```bash
|
||||||
|
# 1. Check if Blender is running
|
||||||
|
ps aux | grep -i blender
|
||||||
|
|
||||||
|
# 2. Verify addon is enabled
|
||||||
|
# In Blender: Edit → Preferences → Add-ons → Search "Blender Toolkit"
|
||||||
|
|
||||||
|
# 3. Check server status
|
||||||
|
blender-toolkit daemon-status
|
||||||
|
|
||||||
|
# 4. Restart server
|
||||||
|
# In Blender: Click "Stop Server", then "Start Server"
|
||||||
|
|
||||||
|
# 5. Try different port
|
||||||
|
blender-toolkit retarget --port 9401 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Issues
|
||||||
|
|
||||||
|
**Problem:** "Failed to import FBX file"
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Verify file path is correct
|
||||||
|
- Check FBX format (should be Binary, not ASCII)
|
||||||
|
- Ensure file is not corrupted
|
||||||
|
- Try re-downloading from Mixamo
|
||||||
|
|
||||||
|
### Mapping Issues
|
||||||
|
|
||||||
|
**Problem:** "Poor quality mapping"
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Lower threshold:
|
||||||
|
```typescript
|
||||||
|
// Custom workflow
|
||||||
|
threshold: 0.5 // Default is 0.6
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Use custom mapping for critical bones
|
||||||
|
|
||||||
|
3. Review bone names in Blender:
|
||||||
|
- Edit Mode → Show bone names
|
||||||
|
- Check for typos or unusual names
|
||||||
|
|
||||||
|
### Animation Issues
|
||||||
|
|
||||||
|
**Problem:** "Animation looks wrong"
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Check bone roll in Edit Mode
|
||||||
|
- Verify constraint influence
|
||||||
|
- Review mapping (especially left/right)
|
||||||
|
- Test with simple animation first
|
||||||
|
|
||||||
|
### Performance Issues
|
||||||
|
|
||||||
|
**Problem:** "Retargeting is slow"
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Close other Blender instances
|
||||||
|
- Reduce FBX complexity (remove unnecessary bones)
|
||||||
|
- Use SSD for faster file I/O
|
||||||
|
- Process in batches during off-hours
|
||||||
43
skills/scripts/eslint.config.mjs
Normal file
43
skills/scripts/eslint.config.mjs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||||
|
import typescriptParser from '@typescript-eslint/parser';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'node_modules/**',
|
||||||
|
'dist/**',
|
||||||
|
'*.backup/**'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: typescriptParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
project: './tsconfig.json'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': typescriptEslint
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// TypeScript 규칙
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error', // any 사용 금지 - 타입 가드 또는 명시적 타입 사용
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', {
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_'
|
||||||
|
}],
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||||
|
|
||||||
|
// 일반 규칙
|
||||||
|
'no-console': 'off', // CLI 도구이므로 console 사용 허용
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'no-var': 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
71
skills/scripts/install-addon.py
Executable file
71
skills/scripts/install-addon.py
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Blender Toolkit Addon Auto-Installer
|
||||||
|
Blender를 백그라운드에서 실행하여 애드온을 자동으로 설치/활성화합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def install_addon():
|
||||||
|
"""애드온 설치 및 활성화"""
|
||||||
|
|
||||||
|
# 애드온 경로 (이 스크립트의 부모 디렉토리)
|
||||||
|
script_dir = Path(__file__).parent.absolute()
|
||||||
|
addon_dir = script_dir.parent / "addon"
|
||||||
|
addon_init = addon_dir / "__init__.py"
|
||||||
|
|
||||||
|
if not addon_init.exists():
|
||||||
|
print(f"❌ Error: Addon not found at {addon_init}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"📦 Installing Blender Toolkit addon from: {addon_dir}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 애드온이 이미 설치되어 있으면 먼저 제거
|
||||||
|
addon_name = "blender_toolkit_websocket"
|
||||||
|
if addon_name in bpy.context.preferences.addons:
|
||||||
|
print(f"🔄 Removing existing addon: {addon_name}")
|
||||||
|
bpy.ops.preferences.addon_disable(module=addon_name)
|
||||||
|
bpy.ops.preferences.addon_remove(module=addon_name)
|
||||||
|
|
||||||
|
# 애드온 디렉토리를 Blender scripts path에 추가
|
||||||
|
scripts_path = bpy.utils.user_resource('SCRIPTS', path="addons")
|
||||||
|
|
||||||
|
# 심볼릭 링크 또는 복사 방식으로 설치
|
||||||
|
import shutil
|
||||||
|
target_path = Path(scripts_path) / "blender_toolkit_websocket"
|
||||||
|
|
||||||
|
if target_path.exists():
|
||||||
|
print(f"🔄 Removing existing installation at {target_path}")
|
||||||
|
shutil.rmtree(target_path)
|
||||||
|
|
||||||
|
print(f"📋 Copying addon to: {target_path}")
|
||||||
|
shutil.copytree(addon_dir, target_path)
|
||||||
|
|
||||||
|
# 애드온 활성화
|
||||||
|
print(f"✅ Enabling addon: {addon_name}")
|
||||||
|
bpy.ops.preferences.addon_enable(module=addon_name)
|
||||||
|
|
||||||
|
# User preferences 저장
|
||||||
|
bpy.ops.wm.save_userpref()
|
||||||
|
|
||||||
|
print("✅ Addon installed and enabled successfully!")
|
||||||
|
print("\n📝 Next steps:")
|
||||||
|
print(" 1. Start Blender normally")
|
||||||
|
print(" 2. The WebSocket server will auto-start on port 9400")
|
||||||
|
print(" 3. Use CLI: node dist/cli/cli.js <command>")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error installing addon: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit_code = install_addon()
|
||||||
|
sys.exit(exit_code)
|
||||||
39
skills/scripts/package.json
Normal file
39
skills/scripts/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "blender-toolkit-cli",
|
||||||
|
"version": "1.4.4",
|
||||||
|
"description": "Blender automation CLI with geometry, materials, modifiers, collections, animation retargeting, and WebSocket-based control",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"bin": {
|
||||||
|
"blender-toolkit": "./dist/cli/cli.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"watch": "tsc --watch",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"blender",
|
||||||
|
"animation",
|
||||||
|
"retargeting",
|
||||||
|
"mixamo",
|
||||||
|
"websocket",
|
||||||
|
"geometry",
|
||||||
|
"materials",
|
||||||
|
"modifiers",
|
||||||
|
"collections",
|
||||||
|
"3d",
|
||||||
|
"automation"
|
||||||
|
],
|
||||||
|
"author": "Dev GOM",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^14.0.2",
|
||||||
|
"ws": "^8.14.0",
|
||||||
|
"winston": "^3.11.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/ws": "^8.5.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
198
skills/scripts/src/blender/client.ts
Normal file
198
skills/scripts/src/blender/client.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* Blender WebSocket Client
|
||||||
|
* Blender Python 애드온과 통신하기 위한 WebSocket 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { BLENDER } from '../constants';
|
||||||
|
import { log } from '../utils/logger';
|
||||||
|
|
||||||
|
export interface BlenderMessage {
|
||||||
|
id: number;
|
||||||
|
method: string;
|
||||||
|
params?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlenderResponse {
|
||||||
|
id: number;
|
||||||
|
result?: unknown;
|
||||||
|
error?: {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlenderEvent {
|
||||||
|
method: string;
|
||||||
|
params?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BlenderClient extends EventEmitter {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private messageId = 0;
|
||||||
|
private wsUrl: string;
|
||||||
|
private port: number;
|
||||||
|
|
||||||
|
constructor(port: number = BLENDER.DEFAULT_PORT) {
|
||||||
|
super();
|
||||||
|
this.port = port;
|
||||||
|
this.wsUrl = `ws://${BLENDER.LOCALHOST}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blender에 WebSocket으로 연결
|
||||||
|
*/
|
||||||
|
async connect(port?: number): Promise<void> {
|
||||||
|
// port가 제공되면 업데이트
|
||||||
|
if (port !== undefined) {
|
||||||
|
this.port = port;
|
||||||
|
this.wsUrl = `ws://${BLENDER.LOCALHOST}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Connecting to Blender WebSocket: ${this.wsUrl}`);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.terminate();
|
||||||
|
}
|
||||||
|
const errorMsg = `Connection timeout (${BLENDER.WS_TIMEOUT}ms)`;
|
||||||
|
log.error(errorMsg);
|
||||||
|
reject(new Error(errorMsg));
|
||||||
|
}, BLENDER.WS_TIMEOUT);
|
||||||
|
|
||||||
|
this.ws.on('open', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
log.info('WebSocket connection established');
|
||||||
|
|
||||||
|
// 전역 메시지 핸들러 설정 (이벤트 수신용)
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.on('message', (data: WebSocket.Data) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data.toString());
|
||||||
|
|
||||||
|
// 이벤트는 id가 없고 method만 있음
|
||||||
|
if (!message.id && message.method) {
|
||||||
|
this.emit('event', message as BlenderEvent);
|
||||||
|
this.emit(message.method, message.params);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// JSON 파싱 에러는 무시하되 디버그 모드에서는 로깅
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.debug('[BlenderClient] Event JSON parse error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('error', (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
log.error(`WebSocket error: ${error.message}`);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('close', () => {
|
||||||
|
log.info('WebSocket connection closed');
|
||||||
|
this.emit('disconnected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blender에 명령 전송 및 응답 대기
|
||||||
|
*/
|
||||||
|
async sendCommand<T = any>(
|
||||||
|
method: string,
|
||||||
|
params?: unknown
|
||||||
|
): Promise<T> {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
const errorMsg = 'Not connected to Blender';
|
||||||
|
log.error(errorMsg);
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture ws reference for use in callbacks
|
||||||
|
const ws = this.ws;
|
||||||
|
const id = ++this.messageId;
|
||||||
|
const message: BlenderMessage = { id, method, params };
|
||||||
|
|
||||||
|
log.debug(`Sending command: ${method}`, params);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
ws.off('message', messageHandler);
|
||||||
|
reject(new Error(`Command timeout: ${method}`));
|
||||||
|
}, BLENDER.WS_TIMEOUT);
|
||||||
|
|
||||||
|
// 응답 대기
|
||||||
|
const messageHandler = (data: WebSocket.Data) => {
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(data.toString()) as BlenderResponse;
|
||||||
|
|
||||||
|
if (response.id === id) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.off('message', messageHandler);
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
log.error(`Command ${method} failed: ${response.error.message}`);
|
||||||
|
reject(new Error(response.error.message));
|
||||||
|
} else {
|
||||||
|
log.debug(`Command ${method} completed successfully`);
|
||||||
|
resolve(response.result as T);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// JSON 파싱 에러는 무시 (다른 메시지일 수 있음)
|
||||||
|
// 디버그 모드에서만 로깅
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.debug('[BlenderClient] JSON parse error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.on('message', messageHandler);
|
||||||
|
|
||||||
|
// 메시지 전송
|
||||||
|
ws.send(JSON.stringify(message), (error) => {
|
||||||
|
if (error) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.off('message', messageHandler);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket 연결 종료
|
||||||
|
*/
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결 종료 (disconnect의 alias)
|
||||||
|
*/
|
||||||
|
close(): void {
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결 상태 확인
|
||||||
|
*/
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
}
|
||||||
352
skills/scripts/src/blender/config.ts
Normal file
352
skills/scripts/src/blender/config.ts
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
/**
|
||||||
|
* Configuration management for Blender WebSocket port and state
|
||||||
|
* Browser-Pilot의 config 시스템을 참고한 프로젝트별 설정 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, statSync } from 'fs';
|
||||||
|
import { join, basename } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { createServer } from 'net';
|
||||||
|
import { BLENDER, FS } from '../constants';
|
||||||
|
|
||||||
|
export interface ProjectConfig {
|
||||||
|
rootPath: string;
|
||||||
|
port: number;
|
||||||
|
outputDir: string;
|
||||||
|
lastUsed: string | null;
|
||||||
|
autoCleanup: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedBlenderConfig {
|
||||||
|
projects: {
|
||||||
|
[projectName: string]: ProjectConfig;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로컬 타임스탬프 문자열 생성
|
||||||
|
* Format: YYYY-MM-DD HH:MM:SS.mmm
|
||||||
|
*/
|
||||||
|
function getLocalTimestamp(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(now.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||||
|
const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 설정 파일 경로 가져오기
|
||||||
|
* Browser Pilot 패턴: CLAUDE_PLUGIN_ROOT 환경 변수 사용 (fallback 없음)
|
||||||
|
* 위치: $CLAUDE_PLUGIN_ROOT/skills/blender-config.json
|
||||||
|
*/
|
||||||
|
function getSharedConfigPath(): string {
|
||||||
|
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
||||||
|
|
||||||
|
if (!pluginRoot) {
|
||||||
|
console.error('Error: CLAUDE_PLUGIN_ROOT environment variable not set');
|
||||||
|
console.error('This tool must be run from Claude Code environment');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return join(pluginRoot, 'skills', 'blender-config.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 루트 찾기
|
||||||
|
* Browser Pilot 패턴: 환경 변수 검증 후 fallback
|
||||||
|
*/
|
||||||
|
export function findProjectRoot(): string {
|
||||||
|
const projectDir = process.env.CLAUDE_PROJECT_DIR;
|
||||||
|
|
||||||
|
if (projectDir) {
|
||||||
|
// 경로 존재 여부 확인
|
||||||
|
if (!existsSync(projectDir)) {
|
||||||
|
console.warn(`Warning: CLAUDE_PROJECT_DIR points to non-existent path: ${projectDir}`);
|
||||||
|
console.warn('Falling back to current working directory');
|
||||||
|
return process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디렉토리인지 확인
|
||||||
|
try {
|
||||||
|
const stats = statSync(projectDir);
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
console.error(`Error: CLAUDE_PROJECT_DIR is not a directory: ${projectDir}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return projectDir;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Warning: Cannot access CLAUDE_PROJECT_DIR: ${projectDir}`);
|
||||||
|
console.warn('Falling back to current working directory');
|
||||||
|
return process.cwd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 환경 변수 없으면 현재 작업 디렉토리 사용
|
||||||
|
return process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 이름 가져오기 (폴더 이름)
|
||||||
|
*/
|
||||||
|
function getProjectName(projectRoot: string): string {
|
||||||
|
return basename(projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 출력 디렉토리 가져오기
|
||||||
|
*/
|
||||||
|
export function getOutputDir(): string {
|
||||||
|
const projectRoot = findProjectRoot();
|
||||||
|
const outputDir = join(projectRoot, FS.OUTPUT_DIR);
|
||||||
|
|
||||||
|
// .blender-toolkit 디렉토리 생성
|
||||||
|
if (!existsSync(outputDir)) {
|
||||||
|
mkdirSync(outputDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// .gitignore 생성
|
||||||
|
const gitignorePath = join(outputDir, '.gitignore');
|
||||||
|
if (!existsSync(gitignorePath)) {
|
||||||
|
writeFileSync(gitignorePath, FS.GITIGNORE_CONTENT, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 설정 로드
|
||||||
|
*/
|
||||||
|
export function loadSharedConfig(): SharedBlenderConfig {
|
||||||
|
const configPath = getSharedConfigPath();
|
||||||
|
|
||||||
|
// 설정 파일 디렉토리 생성
|
||||||
|
const configDir = join(configPath, '..');
|
||||||
|
if (!existsSync(configDir)) {
|
||||||
|
mkdirSync(configDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
// 기본 설정 생성
|
||||||
|
const defaultConfig: SharedBlenderConfig = {
|
||||||
|
projects: {}
|
||||||
|
};
|
||||||
|
saveSharedConfig(defaultConfig);
|
||||||
|
return defaultConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = readFileSync(configPath, 'utf-8');
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load shared config:', error);
|
||||||
|
console.warn('Returning empty config - existing settings may be lost');
|
||||||
|
console.warn(`Config path: ${configPath}`);
|
||||||
|
return {
|
||||||
|
projects: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 설정 저장 (원자적 쓰기)
|
||||||
|
* Browser Pilot 패턴: 임시 파일에 쓴 후 rename으로 원자적 교체
|
||||||
|
*/
|
||||||
|
export function saveSharedConfig(config: SharedBlenderConfig): void {
|
||||||
|
const configPath = getSharedConfigPath();
|
||||||
|
const tempPath = join(tmpdir(), `blender-config-${Date.now()}-${process.pid}.tmp`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Write to temporary file first
|
||||||
|
writeFileSync(tempPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
// 2. Atomic rename (replaces existing file)
|
||||||
|
renameSync(tempPath, configPath);
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up temporary file if it exists
|
||||||
|
if (existsSync(tempPath)) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempPath);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('Failed to save shared config:', errorMessage);
|
||||||
|
console.warn(`Config path: ${configPath}`);
|
||||||
|
throw new Error(`Configuration save failed: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 프로젝트의 설정 가져오기
|
||||||
|
* 없으면 사용 가능한 포트로 자동 생성
|
||||||
|
*/
|
||||||
|
export async function getProjectConfig(): Promise<ProjectConfig> {
|
||||||
|
const projectRoot = findProjectRoot();
|
||||||
|
const projectName = getProjectName(projectRoot);
|
||||||
|
const sharedConfig = loadSharedConfig();
|
||||||
|
|
||||||
|
// rootPath로 기존 설정 찾기 (이름이 바뀐 경우 대비)
|
||||||
|
const existingEntry = Object.entries(sharedConfig.projects).find(
|
||||||
|
([_, config]) => config.rootPath === projectRoot
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry) {
|
||||||
|
const [existingName, config] = existingEntry;
|
||||||
|
|
||||||
|
// 이름이 바뀐 경우 업데이트
|
||||||
|
if (existingName !== projectName) {
|
||||||
|
delete sharedConfig.projects[existingName];
|
||||||
|
sharedConfig.projects[projectName] = config;
|
||||||
|
saveSharedConfig(sharedConfig);
|
||||||
|
console.log(`📝 Updated project name: ${existingName} → ${projectName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 같은 이름이 다른 경로에 있는지 확인
|
||||||
|
if (sharedConfig.projects[projectName]) {
|
||||||
|
console.warn(`⚠️ Project name "${projectName}" already exists with different path`);
|
||||||
|
console.warn(` Existing: ${sharedConfig.projects[projectName].rootPath}`);
|
||||||
|
console.warn(` Current: ${projectRoot}`);
|
||||||
|
throw new Error(`Project name conflict: "${projectName}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 프로젝트 설정 생성
|
||||||
|
const basePort = parseInt(process.env.BLENDER_WS_PORT || String(BLENDER.DEFAULT_PORT));
|
||||||
|
|
||||||
|
// 사용 중인 포트 목록
|
||||||
|
const usedPorts = Object.values(sharedConfig.projects).map(p => p.port);
|
||||||
|
let port = basePort;
|
||||||
|
|
||||||
|
// 사용 가능한 포트 찾기
|
||||||
|
while (usedPorts.includes(port) || !(await isPortAvailable(port))) {
|
||||||
|
port++;
|
||||||
|
if (port > basePort + BLENDER.PORT_RANGE_MAX) {
|
||||||
|
throw new Error(
|
||||||
|
`No available port found in range ${basePort}-${basePort + BLENDER.PORT_RANGE_MAX}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectConfig: ProjectConfig = {
|
||||||
|
rootPath: projectRoot,
|
||||||
|
port,
|
||||||
|
outputDir: FS.OUTPUT_DIR,
|
||||||
|
lastUsed: getLocalTimestamp(),
|
||||||
|
autoCleanup: false // 안전을 위해 기본값 false
|
||||||
|
};
|
||||||
|
|
||||||
|
// 설정 저장
|
||||||
|
sharedConfig.projects[projectName] = projectConfig;
|
||||||
|
saveSharedConfig(sharedConfig);
|
||||||
|
|
||||||
|
console.log(`📝 Created config for project: ${projectName}`);
|
||||||
|
console.log(` Path: ${projectRoot}`);
|
||||||
|
console.log(` Port: ${port}`);
|
||||||
|
|
||||||
|
return projectConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마지막 사용 시간 업데이트
|
||||||
|
*/
|
||||||
|
export function updateProjectLastUsed(): void {
|
||||||
|
const projectRoot = findProjectRoot();
|
||||||
|
const projectName = getProjectName(projectRoot);
|
||||||
|
const sharedConfig = loadSharedConfig();
|
||||||
|
|
||||||
|
if (sharedConfig.projects[projectName]) {
|
||||||
|
sharedConfig.projects[projectName].lastUsed = getLocalTimestamp();
|
||||||
|
saveSharedConfig(sharedConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 포트 가져오기
|
||||||
|
*/
|
||||||
|
export async function getProjectPort(): Promise<number> {
|
||||||
|
const config = await getProjectConfig();
|
||||||
|
return config.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 프로젝트 목록
|
||||||
|
*/
|
||||||
|
export function listProjects(): void {
|
||||||
|
const sharedConfig = loadSharedConfig();
|
||||||
|
const projects = Object.entries(sharedConfig.projects);
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
console.log('No projects configured yet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n📋 Configured Projects (${projects.length}):\n`);
|
||||||
|
projects.forEach(([name, config]) => {
|
||||||
|
console.log(` ${name}`);
|
||||||
|
console.log(` ├─ Path: ${config.rootPath}`);
|
||||||
|
console.log(` ├─ Port: ${config.port}`);
|
||||||
|
console.log(` ├─ Output: ${config.outputDir}`);
|
||||||
|
console.log(` └─ Last Used: ${config.lastUsed || 'Never'}\n`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 설정 초기화
|
||||||
|
*/
|
||||||
|
export function resetProjectConfig(): void {
|
||||||
|
const projectRoot = findProjectRoot();
|
||||||
|
const projectName = getProjectName(projectRoot);
|
||||||
|
const sharedConfig = loadSharedConfig();
|
||||||
|
|
||||||
|
delete sharedConfig.projects[projectName];
|
||||||
|
saveSharedConfig(sharedConfig);
|
||||||
|
|
||||||
|
console.log(`🗑️ Removed config for project: ${projectName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포트 사용 가능 여부 확인
|
||||||
|
*/
|
||||||
|
export async function isPortAvailable(port: number): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = createServer();
|
||||||
|
|
||||||
|
server.once('error', () => {
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.once('listening', () => {
|
||||||
|
server.close();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, BLENDER.LOCALHOST);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용 가능한 포트 찾기
|
||||||
|
*/
|
||||||
|
export async function findAvailablePort(
|
||||||
|
startPort = BLENDER.DEFAULT_PORT,
|
||||||
|
maxAttempts = BLENDER.PORT_RANGE_MAX
|
||||||
|
): Promise<number> {
|
||||||
|
for (let port = startPort; port < startPort + maxAttempts; port++) {
|
||||||
|
if (await isPortAvailable(port)) {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`
|
||||||
|
);
|
||||||
|
}
|
||||||
65
skills/scripts/src/blender/mixamo.ts
Normal file
65
skills/scripts/src/blender/mixamo.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Mixamo Integration - Manual Download Support
|
||||||
|
* Mixamo does not provide an official API, so users must download animations manually
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides manual download instructions and popular animation suggestions
|
||||||
|
*/
|
||||||
|
export class MixamoHelper {
|
||||||
|
/**
|
||||||
|
* Get manual download instructions for a specific animation
|
||||||
|
*/
|
||||||
|
getManualDownloadInstructions(animationName: string): string {
|
||||||
|
return `
|
||||||
|
📝 Manual Download Instructions for "${animationName}":
|
||||||
|
|
||||||
|
1. Visit https://www.mixamo.com
|
||||||
|
2. Login with your Adobe account
|
||||||
|
3. Search for "${animationName}"
|
||||||
|
4. Select the animation
|
||||||
|
5. Click "Download" button
|
||||||
|
6. Choose settings:
|
||||||
|
- Format: FBX (.fbx)
|
||||||
|
- Skin: Without Skin (recommended for retargeting)
|
||||||
|
- FPS: 30
|
||||||
|
7. Save to your project's animations folder
|
||||||
|
8. Return here and provide the file path
|
||||||
|
|
||||||
|
Alternative: You can also drag & drop the FBX file into Blender manually.
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of popular Mixamo animations
|
||||||
|
*/
|
||||||
|
getPopularAnimations(): Array<{ name: string; category: string }> {
|
||||||
|
return [
|
||||||
|
{ name: 'Walking', category: 'Locomotion' },
|
||||||
|
{ name: 'Running', category: 'Locomotion' },
|
||||||
|
{ name: 'Idle', category: 'Idle' },
|
||||||
|
{ name: 'Jump', category: 'Action' },
|
||||||
|
{ name: 'Dancing', category: 'Dance' },
|
||||||
|
{ name: 'Sitting', category: 'Sitting' },
|
||||||
|
{ name: 'Standing', category: 'Standing' },
|
||||||
|
{ name: 'Fighting', category: 'Combat' },
|
||||||
|
{ name: 'Waving', category: 'Gesture' },
|
||||||
|
{ name: 'Talking', category: 'Gesture' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get download settings recommendation
|
||||||
|
*/
|
||||||
|
getRecommendedSettings(): {
|
||||||
|
format: string;
|
||||||
|
skin: string;
|
||||||
|
fps: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
format: 'FBX (.fbx)',
|
||||||
|
skin: 'Without Skin',
|
||||||
|
fps: 30,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
169
skills/scripts/src/blender/retargeting.ts
Normal file
169
skills/scripts/src/blender/retargeting.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Animation Retargeting Controller
|
||||||
|
* Mixamo 애니메이션을 사용자 캐릭터에 리타게팅
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BlenderClient } from './client';
|
||||||
|
import { RETARGETING, TIMING } from '../constants';
|
||||||
|
|
||||||
|
export interface RetargetOptions {
|
||||||
|
sourceArmature: string; // Mixamo 아마추어 이름
|
||||||
|
targetArmature: string; // 사용자 캐릭터 아마추어 이름
|
||||||
|
boneMapping?: 'auto' | 'mixamo_to_rigify' | 'custom';
|
||||||
|
customBoneMap?: Record<string, string>;
|
||||||
|
preserveRotation?: boolean;
|
||||||
|
preserveLocation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoneInfo {
|
||||||
|
name: string;
|
||||||
|
parent: string | null;
|
||||||
|
children: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RetargetingController {
|
||||||
|
private client: BlenderClient;
|
||||||
|
|
||||||
|
constructor(client: BlenderClient) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아마추어의 본 목록 가져오기
|
||||||
|
*/
|
||||||
|
async getBones(armatureName: string): Promise<BoneInfo[]> {
|
||||||
|
return await this.client.sendCommand<BoneInfo[]>('Armature.getBones', {
|
||||||
|
armatureName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 본 매핑 생성
|
||||||
|
* Mixamo 본 이름과 사용자 캐릭터 본 이름을 매칭
|
||||||
|
*/
|
||||||
|
async autoMapBones(
|
||||||
|
sourceArmature: string,
|
||||||
|
targetArmature: string
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
return await this.client.sendCommand<Record<string, string>>(
|
||||||
|
'Retargeting.autoMapBones',
|
||||||
|
{
|
||||||
|
sourceArmature,
|
||||||
|
targetArmature,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 애니메이션 리타게팅 실행
|
||||||
|
*/
|
||||||
|
async retarget(options: RetargetOptions): Promise<void> {
|
||||||
|
const {
|
||||||
|
sourceArmature,
|
||||||
|
targetArmature,
|
||||||
|
boneMapping = 'auto',
|
||||||
|
customBoneMap,
|
||||||
|
preserveRotation = true,
|
||||||
|
preserveLocation = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 본 매핑 생성
|
||||||
|
let boneMap: Record<string, string>;
|
||||||
|
|
||||||
|
if (boneMapping === 'custom' && customBoneMap) {
|
||||||
|
boneMap = customBoneMap;
|
||||||
|
} else if (boneMapping === 'auto') {
|
||||||
|
console.log('🔍 Auto-detecting bone mapping...');
|
||||||
|
boneMap = await this.autoMapBones(sourceArmature, targetArmature);
|
||||||
|
console.log(`✅ Mapped ${Object.keys(boneMap).length} bones`);
|
||||||
|
} else {
|
||||||
|
// 미리 정의된 프리셋 사용
|
||||||
|
boneMap = await this.client.sendCommand<Record<string, string>>(
|
||||||
|
'Retargeting.getPresetMapping',
|
||||||
|
{
|
||||||
|
preset: boneMapping,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 본 매핑 검증
|
||||||
|
if (!boneMap || Object.keys(boneMap).length === 0) {
|
||||||
|
throw new Error('Bone mapping is empty. Cannot proceed with retargeting.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리타게팅 실행
|
||||||
|
console.log('🎬 Starting animation retargeting...');
|
||||||
|
console.log(` Mapping ${Object.keys(boneMap).length} bones...`);
|
||||||
|
|
||||||
|
await this.client.sendCommand(
|
||||||
|
'Retargeting.retargetAnimation',
|
||||||
|
{
|
||||||
|
sourceArmature,
|
||||||
|
targetArmature,
|
||||||
|
boneMap,
|
||||||
|
preserveRotation,
|
||||||
|
preserveLocation,
|
||||||
|
},
|
||||||
|
TIMING.RETARGET_TIMEOUT
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✅ Animation retargeted successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NLA(Non-Linear Animation) 트랙에 애니메이션 추가
|
||||||
|
*/
|
||||||
|
async addToNLA(
|
||||||
|
armatureName: string,
|
||||||
|
actionName: string,
|
||||||
|
trackName?: string
|
||||||
|
): Promise<void> {
|
||||||
|
await this.client.sendCommand('Animation.addToNLA', {
|
||||||
|
armatureName,
|
||||||
|
actionName,
|
||||||
|
trackName: trackName || `Mixamo_${Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 애니메이션 클립 목록 가져오기
|
||||||
|
*/
|
||||||
|
async getAnimations(armatureName: string): Promise<string[]> {
|
||||||
|
return await this.client.sendCommand<string[]>('Animation.list', {
|
||||||
|
armatureName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 애니메이션 미리보기 재생
|
||||||
|
*/
|
||||||
|
async playAnimation(
|
||||||
|
armatureName: string,
|
||||||
|
actionName: string,
|
||||||
|
loop: boolean = true
|
||||||
|
): Promise<void> {
|
||||||
|
await this.client.sendCommand('Animation.play', {
|
||||||
|
armatureName,
|
||||||
|
actionName,
|
||||||
|
loop,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 애니메이션 정지
|
||||||
|
*/
|
||||||
|
async stopAnimation(): Promise<void> {
|
||||||
|
await this.client.sendCommand('Animation.stop');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlenderClient에 timeout 파라미터 추가를 위한 타입 확장
|
||||||
|
declare module './client' {
|
||||||
|
interface BlenderClient {
|
||||||
|
sendCommand<T = Record<string, unknown>>(
|
||||||
|
method: string,
|
||||||
|
params?: unknown,
|
||||||
|
timeout?: number
|
||||||
|
): Promise<T>;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
skills/scripts/src/cli/cli.ts
Normal file
35
skills/scripts/src/cli/cli.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blender Toolkit CLI - Blender automation command-line interface
|
||||||
|
* Provides geometry creation, object manipulation, and animation retargeting
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { registerGeometryCommands } from './commands/geometry';
|
||||||
|
import { registerObjectCommands } from './commands/object';
|
||||||
|
import { registerModifierCommands } from './commands/modifier';
|
||||||
|
import { registerRetargetingCommands } from './commands/retargeting';
|
||||||
|
import { registerMaterialCommands } from './commands/material';
|
||||||
|
import { registerCollectionCommands } from './commands/collection';
|
||||||
|
import { registerDaemonCommands } from './commands/daemon';
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program
|
||||||
|
.name('blender-toolkit')
|
||||||
|
.description('Blender automation CLI with geometry creation, materials, modifiers, collections, and animation retargeting')
|
||||||
|
.version('1.3.0')
|
||||||
|
.addHelpText('after', '\nTip: Use "<command> --help" to see detailed options for each command.\nExample: blender-toolkit material create --help');
|
||||||
|
|
||||||
|
// Register all command groups
|
||||||
|
registerGeometryCommands(program);
|
||||||
|
registerObjectCommands(program);
|
||||||
|
registerModifierCommands(program);
|
||||||
|
registerMaterialCommands(program);
|
||||||
|
registerCollectionCommands(program);
|
||||||
|
registerRetargetingCommands(program);
|
||||||
|
registerDaemonCommands(program);
|
||||||
|
|
||||||
|
// Parse command line arguments
|
||||||
|
program.parse();
|
||||||
119
skills/scripts/src/cli/commands/collection.ts
Normal file
119
skills/scripts/src/cli/commands/collection.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Collection CLI Commands
|
||||||
|
* 컬렉션 생성, 오브젝트 추가/제거 등의 CLI 명령
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { BlenderClient } from '../../blender/client';
|
||||||
|
|
||||||
|
export function registerCollectionCommands(program: Command): void {
|
||||||
|
const collectionGroup = program
|
||||||
|
.command('collection')
|
||||||
|
.description('Collection management commands');
|
||||||
|
|
||||||
|
// Create collection
|
||||||
|
collectionGroup
|
||||||
|
.command('create')
|
||||||
|
.description('Create a new collection')
|
||||||
|
.requiredOption('--name <name>', 'Collection name')
|
||||||
|
.action(async (options) => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Collection.create', {
|
||||||
|
name: options.name
|
||||||
|
});
|
||||||
|
console.log('✅ Collection created:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List collections
|
||||||
|
collectionGroup
|
||||||
|
.command('list')
|
||||||
|
.description('List all collections')
|
||||||
|
.action(async () => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Collection.list', {});
|
||||||
|
console.log('📋 Collections:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add object to collection
|
||||||
|
collectionGroup
|
||||||
|
.command('add-object')
|
||||||
|
.description('Add object to collection')
|
||||||
|
.requiredOption('--object <name>', 'Object name')
|
||||||
|
.requiredOption('--collection <name>', 'Collection name')
|
||||||
|
.action(async (options) => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Collection.addObject', {
|
||||||
|
objectName: options.object,
|
||||||
|
collectionName: options.collection
|
||||||
|
});
|
||||||
|
console.log('✅ Object added to collection:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove object from collection
|
||||||
|
collectionGroup
|
||||||
|
.command('remove-object')
|
||||||
|
.description('Remove object from collection')
|
||||||
|
.requiredOption('--object <name>', 'Object name')
|
||||||
|
.requiredOption('--collection <name>', 'Collection name')
|
||||||
|
.action(async (options) => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Collection.removeObject', {
|
||||||
|
objectName: options.object,
|
||||||
|
collectionName: options.collection
|
||||||
|
});
|
||||||
|
console.log('✅ Object removed from collection:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete collection
|
||||||
|
collectionGroup
|
||||||
|
.command('delete')
|
||||||
|
.description('Delete a collection')
|
||||||
|
.requiredOption('--name <name>', 'Collection name')
|
||||||
|
.action(async (options) => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Collection.delete', {
|
||||||
|
name: options.name
|
||||||
|
});
|
||||||
|
console.log('✅ Collection deleted:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
196
skills/scripts/src/cli/commands/daemon.ts
Normal file
196
skills/scripts/src/cli/commands/daemon.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Daemon management commands
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { DaemonManager } from '../../daemon/manager';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
export function registerDaemonCommands(program: Command) {
|
||||||
|
// Start daemon
|
||||||
|
program
|
||||||
|
.command('daemon-start')
|
||||||
|
.description('Start Blender Toolkit daemon (persistent background service)')
|
||||||
|
.option('-q, --quiet', 'Suppress output')
|
||||||
|
.action(async (options) => {
|
||||||
|
const manager = new DaemonManager();
|
||||||
|
try {
|
||||||
|
await manager.start({ verbose: !options.quiet });
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop daemon
|
||||||
|
program
|
||||||
|
.command('daemon-stop')
|
||||||
|
.description('Stop Blender Toolkit daemon')
|
||||||
|
.option('-q, --quiet', 'Suppress output')
|
||||||
|
.option('-f, --force', 'Force kill the daemon')
|
||||||
|
.action(async (options) => {
|
||||||
|
const manager = new DaemonManager();
|
||||||
|
try {
|
||||||
|
await manager.stop({ verbose: !options.quiet, force: options.force });
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restart daemon
|
||||||
|
program
|
||||||
|
.command('daemon-restart')
|
||||||
|
.description('Restart Blender Toolkit daemon')
|
||||||
|
.option('-q, --quiet', 'Suppress output')
|
||||||
|
.action(async (options) => {
|
||||||
|
const manager = new DaemonManager();
|
||||||
|
try {
|
||||||
|
await manager.restart({ verbose: !options.quiet });
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Daemon status
|
||||||
|
program
|
||||||
|
.command('daemon-status')
|
||||||
|
.description('Check daemon status and Blender connection info')
|
||||||
|
.option('-q, --quiet', 'Suppress output')
|
||||||
|
.action(async (options) => {
|
||||||
|
const manager = new DaemonManager();
|
||||||
|
try {
|
||||||
|
const state = await manager.getStatus({ verbose: !options.quiet });
|
||||||
|
process.exit(state ? 0 : 1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Addon install
|
||||||
|
program
|
||||||
|
.command('addon-install')
|
||||||
|
.description('Install Blender Toolkit addon automatically')
|
||||||
|
.option('-b, --blender <path>', 'Blender executable path', 'blender')
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
console.log('🔧 Installing Blender Toolkit addon...\n');
|
||||||
|
|
||||||
|
// Install script path
|
||||||
|
const scriptDir = join(__dirname, '..', '..', '..');
|
||||||
|
const installScript = join(scriptDir, 'install-addon.py');
|
||||||
|
|
||||||
|
if (!existsSync(installScript)) {
|
||||||
|
console.error(`❌ Error: Install script not found at ${installScript}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📍 Script: ${installScript}`);
|
||||||
|
console.log(`📍 Blender: ${options.blender}\n`);
|
||||||
|
|
||||||
|
// Run Blender in background with install script
|
||||||
|
const blender = spawn(options.blender, [
|
||||||
|
'--background',
|
||||||
|
'--python', installScript
|
||||||
|
], {
|
||||||
|
stdio: 'inherit'
|
||||||
|
});
|
||||||
|
|
||||||
|
blender.on('exit', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
console.log('\n✅ Addon installation completed!');
|
||||||
|
console.log('\n📝 Next steps:');
|
||||||
|
console.log(' 1. Start Blender normally');
|
||||||
|
console.log(' 2. The WebSocket server will auto-start on port 9400');
|
||||||
|
console.log(' 3. Start daemon: blender-toolkit daemon-start');
|
||||||
|
console.log(' 4. Use CLI commands: blender-toolkit <command>');
|
||||||
|
} else {
|
||||||
|
console.error(`\n❌ Installation failed with code ${code}`);
|
||||||
|
}
|
||||||
|
process.exit(code || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
blender.on('error', (error) => {
|
||||||
|
console.error(`\n❌ Failed to run Blender: ${error.message}`);
|
||||||
|
console.error('\nTips:');
|
||||||
|
console.error(' - Make sure Blender is installed');
|
||||||
|
console.error(' - Use --blender flag to specify path: --blender /path/to/blender');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Addon build
|
||||||
|
program
|
||||||
|
.command('addon-build')
|
||||||
|
.description('Build Blender addon ZIP package for distribution')
|
||||||
|
.option('-o, --output-dir <path>', 'Output directory for ZIP file')
|
||||||
|
.option('-f, --force', 'Force rebuild even if ZIP already exists')
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
console.log('📦 Building Blender addon ZIP...\n');
|
||||||
|
|
||||||
|
// Build script path (plugins/blender-toolkit/scripts/build-addon.js)
|
||||||
|
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
||||||
|
if (!pluginRoot) {
|
||||||
|
console.error('❌ Error: CLAUDE_PLUGIN_ROOT environment variable not set');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildScript = join(pluginRoot, 'scripts', 'build-addon.js');
|
||||||
|
if (!existsSync(buildScript)) {
|
||||||
|
console.error(`❌ Error: Build script not found at ${buildScript}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
||||||
|
console.log(`📍 Project: ${projectRoot}`);
|
||||||
|
console.log(`📍 Script: ${buildScript}\n`);
|
||||||
|
|
||||||
|
// Prepare arguments
|
||||||
|
const args = ['--project-root', projectRoot];
|
||||||
|
if (options.outputDir) {
|
||||||
|
args.push('--output-dir', options.outputDir);
|
||||||
|
}
|
||||||
|
if (options.force) {
|
||||||
|
args.push('--force');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run build script
|
||||||
|
const buildProcess = spawn('node', [buildScript, ...args], {
|
||||||
|
stdio: 'inherit'
|
||||||
|
});
|
||||||
|
|
||||||
|
buildProcess.on('exit', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
console.log('\n📝 Next steps:');
|
||||||
|
console.log(' 1. Open Blender 4.0+');
|
||||||
|
console.log(' 2. Edit > Preferences > Add-ons > Install');
|
||||||
|
console.log(' 3. Select: .blender-toolkit/blender-toolkit-addon-v*.zip');
|
||||||
|
console.log(' 4. Enable "Blender Toolkit WebSocket Server"');
|
||||||
|
}
|
||||||
|
process.exit(code || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
buildProcess.on('error', (error) => {
|
||||||
|
console.error(`\n❌ Failed to run build script: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
338
skills/scripts/src/cli/commands/geometry.ts
Normal file
338
skills/scripts/src/cli/commands/geometry.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* Geometry Commands
|
||||||
|
* Blender 도형 생성 및 메쉬 편집 명령
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { BlenderClient } from '../../blender/client';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
|
const client = new BlenderClient();
|
||||||
|
|
||||||
|
export function registerGeometryCommands(program: Command) {
|
||||||
|
// Create Cube
|
||||||
|
program
|
||||||
|
.command('create-cube')
|
||||||
|
.description('Create a cube primitive')
|
||||||
|
.option('-x, --x <number>', 'X position', parseFloat, 0)
|
||||||
|
.option('-y, --y <number>', 'Y position', parseFloat, 0)
|
||||||
|
.option('-z, --z <number>', 'Z position', parseFloat, 0)
|
||||||
|
.option('-s, --size <number>', 'Cube size', parseFloat, 2.0)
|
||||||
|
.option('-n, --name <string>', 'Object name')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Geometry.createCube', {
|
||||||
|
location: [options.x, options.y, options.z],
|
||||||
|
size: options.size,
|
||||||
|
name: options.name
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Cube created successfully:');
|
||||||
|
console.log(` Name: ${result.name}`);
|
||||||
|
console.log(` Location: [${result.location.join(', ')}]`);
|
||||||
|
console.log(` Vertices: ${result.vertices}`);
|
||||||
|
console.log(` Faces: ${result.faces}`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create cube:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Sphere
|
||||||
|
program
|
||||||
|
.command('create-sphere')
|
||||||
|
.description('Create a sphere primitive')
|
||||||
|
.option('-x, --x <number>', 'X position', parseFloat, 0)
|
||||||
|
.option('-y, --y <number>', 'Y position', parseFloat, 0)
|
||||||
|
.option('-z, --z <number>', 'Z position', parseFloat, 0)
|
||||||
|
.option('-r, --radius <number>', 'Sphere radius', parseFloat, 1.0)
|
||||||
|
.option('--segments <number>', 'Number of segments', parseInt, 32)
|
||||||
|
.option('--rings <number>', 'Number of rings', parseInt, 16)
|
||||||
|
.option('-n, --name <string>', 'Object name')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Geometry.createSphere', {
|
||||||
|
location: [options.x, options.y, options.z],
|
||||||
|
radius: options.radius,
|
||||||
|
segments: options.segments,
|
||||||
|
ringCount: options.rings,
|
||||||
|
name: options.name
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Sphere created successfully:');
|
||||||
|
console.log(` Name: ${result.name}`);
|
||||||
|
console.log(` Location: [${result.location.join(', ')}]`);
|
||||||
|
console.log(` Vertices: ${result.vertices}`);
|
||||||
|
console.log(` Faces: ${result.faces}`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create sphere:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Cylinder
|
||||||
|
program
|
||||||
|
.command('create-cylinder')
|
||||||
|
.description('Create a cylinder primitive')
|
||||||
|
.option('-x, --x <number>', 'X position', parseFloat, 0)
|
||||||
|
.option('-y, --y <number>', 'Y position', parseFloat, 0)
|
||||||
|
.option('-z, --z <number>', 'Z position', parseFloat, 0)
|
||||||
|
.option('-r, --radius <number>', 'Cylinder radius', parseFloat, 1.0)
|
||||||
|
.option('-d, --depth <number>', 'Cylinder height/depth', parseFloat, 2.0)
|
||||||
|
.option('--vertices <number>', 'Number of vertices', parseInt, 32)
|
||||||
|
.option('-n, --name <string>', 'Object name')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Geometry.createCylinder', {
|
||||||
|
location: [options.x, options.y, options.z],
|
||||||
|
radius: options.radius,
|
||||||
|
depth: options.depth,
|
||||||
|
vertices: options.vertices,
|
||||||
|
name: options.name
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Cylinder created successfully:');
|
||||||
|
console.log(` Name: ${result.name}`);
|
||||||
|
console.log(` Location: [${result.location.join(', ')}]`);
|
||||||
|
console.log(` Vertices: ${result.vertices}`);
|
||||||
|
console.log(` Faces: ${result.faces}`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create cylinder:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Plane
|
||||||
|
program
|
||||||
|
.command('create-plane')
|
||||||
|
.description('Create a plane primitive')
|
||||||
|
.option('-x, --x <number>', 'X position', parseFloat, 0)
|
||||||
|
.option('-y, --y <number>', 'Y position', parseFloat, 0)
|
||||||
|
.option('-z, --z <number>', 'Z position', parseFloat, 0)
|
||||||
|
.option('-s, --size <number>', 'Plane size', parseFloat, 2.0)
|
||||||
|
.option('-n, --name <string>', 'Object name')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Geometry.createPlane', {
|
||||||
|
location: [options.x, options.y, options.z],
|
||||||
|
size: options.size,
|
||||||
|
name: options.name
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Plane created successfully:');
|
||||||
|
console.log(` Name: ${result.name}`);
|
||||||
|
console.log(` Location: [${result.location.join(', ')}]`);
|
||||||
|
console.log(` Vertices: ${result.vertices}`);
|
||||||
|
console.log(` Faces: ${result.faces}`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create plane:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Cone
|
||||||
|
program
|
||||||
|
.command('create-cone')
|
||||||
|
.description('Create a cone primitive')
|
||||||
|
.option('-x, --x <number>', 'X position', parseFloat, 0)
|
||||||
|
.option('-y, --y <number>', 'Y position', parseFloat, 0)
|
||||||
|
.option('-z, --z <number>', 'Z position', parseFloat, 0)
|
||||||
|
.option('-r, --radius <number>', 'Cone base radius', parseFloat, 1.0)
|
||||||
|
.option('-d, --depth <number>', 'Cone height/depth', parseFloat, 2.0)
|
||||||
|
.option('--vertices <number>', 'Number of vertices', parseInt, 32)
|
||||||
|
.option('-n, --name <string>', 'Object name')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Geometry.createCone', {
|
||||||
|
location: [options.x, options.y, options.z],
|
||||||
|
radius1: options.radius,
|
||||||
|
depth: options.depth,
|
||||||
|
vertices: options.vertices,
|
||||||
|
name: options.name
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Cone created successfully:');
|
||||||
|
console.log(` Name: ${result.name}`);
|
||||||
|
console.log(` Location: [${result.location.join(', ')}]`);
|
||||||
|
console.log(` Vertices: ${result.vertices}`);
|
||||||
|
console.log(` Faces: ${result.faces}`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create cone:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Torus
|
||||||
|
program
|
||||||
|
.command('create-torus')
|
||||||
|
.description('Create a torus primitive')
|
||||||
|
.option('-x, --x <number>', 'X position', parseFloat, 0)
|
||||||
|
.option('-y, --y <number>', 'Y position', parseFloat, 0)
|
||||||
|
.option('-z, --z <number>', 'Z position', parseFloat, 0)
|
||||||
|
.option('--major-radius <number>', 'Major radius', parseFloat, 1.0)
|
||||||
|
.option('--minor-radius <number>', 'Minor radius (tube thickness)', parseFloat, 0.25)
|
||||||
|
.option('--major-segments <number>', 'Major segments', parseInt, 48)
|
||||||
|
.option('--minor-segments <number>', 'Minor segments', parseInt, 12)
|
||||||
|
.option('-n, --name <string>', 'Object name')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Geometry.createTorus', {
|
||||||
|
location: [options.x, options.y, options.z],
|
||||||
|
majorRadius: options.majorRadius,
|
||||||
|
minorRadius: options.minorRadius,
|
||||||
|
majorSegments: options.majorSegments,
|
||||||
|
minorSegments: options.minorSegments,
|
||||||
|
name: options.name
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Torus created successfully:');
|
||||||
|
console.log(` Name: ${result.name}`);
|
||||||
|
console.log(` Location: [${result.location.join(', ')}]`);
|
||||||
|
console.log(` Vertices: ${result.vertices}`);
|
||||||
|
console.log(` Faces: ${result.faces}`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create torus:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subdivide Mesh
|
||||||
|
program
|
||||||
|
.command('subdivide')
|
||||||
|
.description('Subdivide a mesh object')
|
||||||
|
.requiredOption('-n, --name <string>', 'Object name')
|
||||||
|
.option('-c, --cuts <number>', 'Number of subdivision cuts', parseInt, 1)
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Geometry.subdivideMesh', {
|
||||||
|
name: options.name,
|
||||||
|
cuts: options.cuts
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Mesh subdivided successfully:');
|
||||||
|
console.log(` Name: ${result.name}`);
|
||||||
|
console.log(` Vertices: ${result.vertices}`);
|
||||||
|
console.log(` Edges: ${result.edges}`);
|
||||||
|
console.log(` Faces: ${result.faces}`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to subdivide mesh:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Vertices
|
||||||
|
program
|
||||||
|
.command('get-vertices')
|
||||||
|
.description('Get vertices information of an object')
|
||||||
|
.requiredOption('-n, --name <string>', 'Object name')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const vertices: any = await client.sendCommand('Geometry.getVertices', {
|
||||||
|
name: options.name
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Found ${vertices.length} vertices in "${options.name}":`);
|
||||||
|
|
||||||
|
if (vertices.length <= 10) {
|
||||||
|
// Show all vertices if 10 or less
|
||||||
|
vertices.forEach((v: any) => {
|
||||||
|
console.log(` Vertex ${v.index}: [${v.co.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Show first 5 and last 5 if more than 10
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const v = vertices[i];
|
||||||
|
console.log(` Vertex ${v.index}: [${v.co.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||||
|
}
|
||||||
|
console.log(` ... (${vertices.length - 10} more vertices)`);
|
||||||
|
for (let i = vertices.length - 5; i < vertices.length; i++) {
|
||||||
|
const v = vertices[i];
|
||||||
|
console.log(` Vertex ${v.index}: [${v.co.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get vertices:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move Vertex
|
||||||
|
program
|
||||||
|
.command('move-vertex')
|
||||||
|
.description('Move a specific vertex to a new position')
|
||||||
|
.requiredOption('-n, --name <string>', 'Object name')
|
||||||
|
.requiredOption('-i, --index <number>', 'Vertex index', parseInt)
|
||||||
|
.requiredOption('-x, --x <number>', 'New X position', parseFloat)
|
||||||
|
.requiredOption('-y, --y <number>', 'New Y position', parseFloat)
|
||||||
|
.requiredOption('-z, --z <number>', 'New Z position', parseFloat)
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Geometry.moveVertex', {
|
||||||
|
objectName: options.name,
|
||||||
|
vertexIndex: options.index,
|
||||||
|
newPosition: [options.x, options.y, options.z]
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Vertex moved successfully:');
|
||||||
|
console.log(` Object: ${result.object}`);
|
||||||
|
console.log(` Vertex ${result.vertex_index}: [${result.position.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to move vertex:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
241
skills/scripts/src/cli/commands/material.ts
Normal file
241
skills/scripts/src/cli/commands/material.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Material CLI Commands
|
||||||
|
* 머티리얼 생성, 할당, 속성 설정 등의 CLI 명령
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { BlenderClient } from '../../blender/client';
|
||||||
|
|
||||||
|
export function registerMaterialCommands(program: Command): void {
|
||||||
|
const materialGroup = program
|
||||||
|
.command('material')
|
||||||
|
.description('Material creation and management commands');
|
||||||
|
|
||||||
|
// Create material
|
||||||
|
materialGroup
|
||||||
|
.command('create')
|
||||||
|
.description('Create a new material')
|
||||||
|
.requiredOption('--name <name>', 'Material name')
|
||||||
|
.option('--no-nodes', 'Disable node-based material (default: enabled)')
|
||||||
|
.action(async (options) => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Material.create', {
|
||||||
|
name: options.name,
|
||||||
|
useNodes: options.nodes
|
||||||
|
});
|
||||||
|
console.log('✅ Material created:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List materials
|
||||||
|
materialGroup
|
||||||
|
.command('list')
|
||||||
|
.description('List all materials')
|
||||||
|
.action(async () => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Material.list', {});
|
||||||
|
console.log('📋 Materials:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete material
|
||||||
|
materialGroup
|
||||||
|
.command('delete')
|
||||||
|
.description('Delete a material')
|
||||||
|
.requiredOption('--name <name>', 'Material name')
|
||||||
|
.action(async (options) => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Material.delete', {
|
||||||
|
name: options.name
|
||||||
|
});
|
||||||
|
console.log('✅ Material deleted:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign material to object
|
||||||
|
materialGroup
|
||||||
|
.command('assign')
|
||||||
|
.description('Assign material to object')
|
||||||
|
.requiredOption('--object <name>', 'Object name')
|
||||||
|
.requiredOption('--material <name>', 'Material name')
|
||||||
|
.option('--slot <index>', 'Material slot index', '0')
|
||||||
|
.action(async (options) => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Material.assign', {
|
||||||
|
objectName: options.object,
|
||||||
|
materialName: options.material,
|
||||||
|
slotIndex: parseInt(options.slot)
|
||||||
|
});
|
||||||
|
console.log('✅ Material assigned:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List object materials
|
||||||
|
materialGroup
|
||||||
|
.command('list-object')
|
||||||
|
.description('List materials of an object')
|
||||||
|
.requiredOption('--object <name>', 'Object name')
|
||||||
|
.action(async (options) => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Material.listObjectMaterials', {
|
||||||
|
objectName: options.object
|
||||||
|
});
|
||||||
|
console.log('📋 Object materials:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set base color
|
||||||
|
materialGroup
|
||||||
|
.command('set-color')
|
||||||
|
.description('Set material base color')
|
||||||
|
.requiredOption('--material <name>', 'Material name')
|
||||||
|
.requiredOption('--r <value>', 'Red (0-1)', parseFloat)
|
||||||
|
.requiredOption('--g <value>', 'Green (0-1)', parseFloat)
|
||||||
|
.requiredOption('--b <value>', 'Blue (0-1)', parseFloat)
|
||||||
|
.option('--a <value>', 'Alpha (0-1)', parseFloat, 1.0)
|
||||||
|
.action(async (options) => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Material.setBaseColor', {
|
||||||
|
materialName: options.material,
|
||||||
|
color: [options.r, options.g, options.b, options.a]
|
||||||
|
});
|
||||||
|
console.log('✅ Base color set:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set metallic
|
||||||
|
materialGroup
|
||||||
|
.command('set-metallic')
|
||||||
|
.description('Set material metallic value')
|
||||||
|
.requiredOption('--material <name>', 'Material name')
|
||||||
|
.requiredOption('--value <value>', 'Metallic value (0-1)', parseFloat)
|
||||||
|
.action(async (options) => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Material.setMetallic', {
|
||||||
|
materialName: options.material,
|
||||||
|
metallic: options.value
|
||||||
|
});
|
||||||
|
console.log('✅ Metallic set:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set roughness
|
||||||
|
materialGroup
|
||||||
|
.command('set-roughness')
|
||||||
|
.description('Set material roughness value')
|
||||||
|
.requiredOption('--material <name>', 'Material name')
|
||||||
|
.requiredOption('--value <value>', 'Roughness value (0-1)', parseFloat)
|
||||||
|
.action(async (options) => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Material.setRoughness', {
|
||||||
|
materialName: options.material,
|
||||||
|
roughness: options.value
|
||||||
|
});
|
||||||
|
console.log('✅ Roughness set:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set emission
|
||||||
|
materialGroup
|
||||||
|
.command('set-emission')
|
||||||
|
.description('Set material emission')
|
||||||
|
.requiredOption('--material <name>', 'Material name')
|
||||||
|
.requiredOption('--r <value>', 'Red (0-1)', parseFloat)
|
||||||
|
.requiredOption('--g <value>', 'Green (0-1)', parseFloat)
|
||||||
|
.requiredOption('--b <value>', 'Blue (0-1)', parseFloat)
|
||||||
|
.option('--strength <value>', 'Emission strength', parseFloat, 1.0)
|
||||||
|
.action(async (options) => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Material.setEmission', {
|
||||||
|
materialName: options.material,
|
||||||
|
color: [options.r, options.g, options.b, 1.0],
|
||||||
|
strength: options.strength
|
||||||
|
});
|
||||||
|
console.log('✅ Emission set:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get material properties
|
||||||
|
materialGroup
|
||||||
|
.command('get-properties')
|
||||||
|
.description('Get material properties')
|
||||||
|
.requiredOption('--material <name>', 'Material name')
|
||||||
|
.action(async (options) => {
|
||||||
|
const client = new BlenderClient();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.sendCommand('Material.getProperties', {
|
||||||
|
materialName: options.material
|
||||||
|
});
|
||||||
|
console.log('📋 Material properties:', JSON.stringify(result, null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
278
skills/scripts/src/cli/commands/modifier.ts
Normal file
278
skills/scripts/src/cli/commands/modifier.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Modifier Commands
|
||||||
|
* Blender 모디파이어 명령
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { BlenderClient } from '../../blender/client';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
|
const client = new BlenderClient();
|
||||||
|
|
||||||
|
export function registerModifierCommands(program: Command) {
|
||||||
|
// Add Modifier
|
||||||
|
program
|
||||||
|
.command('add-modifier')
|
||||||
|
.description('Add a modifier to an object')
|
||||||
|
.requiredOption('-n, --name <string>', 'Object name')
|
||||||
|
.requiredOption('-t, --type <string>', 'Modifier type (SUBSURF, MIRROR, ARRAY, BEVEL, etc.)')
|
||||||
|
.option('--mod-name <string>', 'Modifier name')
|
||||||
|
.option('--levels <number>', 'Subdivision levels (for SUBSURF)', parseInt)
|
||||||
|
.option('--render-levels <number>', 'Render levels (for SUBSURF)', parseInt)
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const properties: any = {};
|
||||||
|
|
||||||
|
if (options.levels !== undefined) {
|
||||||
|
properties.levels = options.levels;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.renderLevels !== undefined) {
|
||||||
|
properties.render_levels = options.renderLevels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Modifier.add', {
|
||||||
|
objectName: options.name,
|
||||||
|
modifierType: options.type,
|
||||||
|
name: options.modName,
|
||||||
|
properties
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Modifier added successfully:');
|
||||||
|
console.log(` Object: ${result.object}`);
|
||||||
|
console.log(` Modifier: ${result.modifier} (${result.type})`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to add modifier:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply Modifier
|
||||||
|
program
|
||||||
|
.command('apply-modifier')
|
||||||
|
.description('Apply a modifier to an object')
|
||||||
|
.requiredOption('-n, --name <string>', 'Object name')
|
||||||
|
.requiredOption('-m, --modifier <string>', 'Modifier name')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Modifier.apply', {
|
||||||
|
objectName: options.name,
|
||||||
|
modifierName: options.modifier
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ ${result.message}`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to apply modifier:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List Modifiers
|
||||||
|
program
|
||||||
|
.command('list-modifiers')
|
||||||
|
.description('List all modifiers on an object')
|
||||||
|
.requiredOption('-n, --name <string>', 'Object name')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Modifier.list', {
|
||||||
|
objectName: options.name
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('📋 Modifiers:');
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.log(' No modifiers found');
|
||||||
|
} else {
|
||||||
|
result.forEach((mod: any) => {
|
||||||
|
console.log(` - ${mod.name} (${mod.type})`);
|
||||||
|
console.log(` Viewport: ${mod.show_viewport}, Render: ${mod.show_render}`);
|
||||||
|
if (mod.levels !== undefined) {
|
||||||
|
console.log(` Levels: ${mod.levels}, Render Levels: ${mod.render_levels}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to list modifiers:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove Modifier
|
||||||
|
program
|
||||||
|
.command('remove-modifier')
|
||||||
|
.description('Remove a modifier from an object')
|
||||||
|
.requiredOption('-n, --name <string>', 'Object name')
|
||||||
|
.requiredOption('-m, --modifier <string>', 'Modifier name')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Modifier.remove', {
|
||||||
|
objectName: options.name,
|
||||||
|
modifierName: options.modifier
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ ${result.message}`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to remove modifier:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle Modifier
|
||||||
|
program
|
||||||
|
.command('toggle-modifier')
|
||||||
|
.description('Toggle modifier visibility')
|
||||||
|
.requiredOption('-n, --name <string>', 'Object name')
|
||||||
|
.requiredOption('-m, --modifier <string>', 'Modifier name')
|
||||||
|
.option('--viewport <boolean>', 'Viewport visibility (true/false)')
|
||||||
|
.option('--render <boolean>', 'Render visibility (true/false)')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const params: any = {
|
||||||
|
objectName: options.name,
|
||||||
|
modifierName: options.modifier
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.viewport !== undefined) {
|
||||||
|
params.viewport = options.viewport === 'true';
|
||||||
|
}
|
||||||
|
if (options.render !== undefined) {
|
||||||
|
params.render = options.render === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Modifier.toggle', params);
|
||||||
|
|
||||||
|
console.log('✅ Modifier toggled:');
|
||||||
|
console.log(` Viewport: ${result.show_viewport}`);
|
||||||
|
console.log(` Render: ${result.show_render}`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to toggle modifier:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modify Modifier Properties
|
||||||
|
program
|
||||||
|
.command('modify-modifier')
|
||||||
|
.description('Modify modifier properties')
|
||||||
|
.requiredOption('-n, --name <string>', 'Object name')
|
||||||
|
.requiredOption('-m, --modifier <string>', 'Modifier name')
|
||||||
|
.option('--levels <number>', 'Subdivision levels', parseInt)
|
||||||
|
.option('--render-levels <number>', 'Render levels', parseInt)
|
||||||
|
.option('--width <number>', 'Bevel width', parseFloat)
|
||||||
|
.option('--segments <number>', 'Bevel segments', parseInt)
|
||||||
|
.option('--count <number>', 'Array count', parseInt)
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const properties: any = {};
|
||||||
|
|
||||||
|
if (options.levels !== undefined) properties.levels = options.levels;
|
||||||
|
if (options.renderLevels !== undefined) properties.render_levels = options.renderLevels;
|
||||||
|
if (options.width !== undefined) properties.width = options.width;
|
||||||
|
if (options.segments !== undefined) properties.segments = options.segments;
|
||||||
|
if (options.count !== undefined) properties.count = options.count;
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Modifier.modify', {
|
||||||
|
objectName: options.name,
|
||||||
|
modifierName: options.modifier,
|
||||||
|
properties
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Modifier properties updated:');
|
||||||
|
console.log(` Updated properties: ${result.updated_properties ? Object.keys(result.updated_properties).join(', ') : 'none'}`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to modify modifier properties:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Modifier Info
|
||||||
|
program
|
||||||
|
.command('get-modifier-info')
|
||||||
|
.description('Get detailed modifier information')
|
||||||
|
.requiredOption('-n, --name <string>', 'Object name')
|
||||||
|
.requiredOption('-m, --modifier <string>', 'Modifier name')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Modifier.getInfo', {
|
||||||
|
objectName: options.name,
|
||||||
|
modifierName: options.modifier
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('📋 Modifier Info:');
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get modifier info:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reorder Modifier
|
||||||
|
program
|
||||||
|
.command('reorder-modifier')
|
||||||
|
.description('Reorder modifier in stack')
|
||||||
|
.requiredOption('-n, --name <string>', 'Object name')
|
||||||
|
.requiredOption('-m, --modifier <string>', 'Modifier name')
|
||||||
|
.requiredOption('-d, --direction <string>', 'Direction (UP or DOWN)')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Modifier.reorder', {
|
||||||
|
objectName: options.name,
|
||||||
|
modifierName: options.modifier,
|
||||||
|
direction: options.direction.toUpperCase()
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Modifier reordered`);
|
||||||
|
console.log(` New order: ${result.new_order.join(' > ')}`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to reorder modifier:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
176
skills/scripts/src/cli/commands/object.ts
Normal file
176
skills/scripts/src/cli/commands/object.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Object Commands
|
||||||
|
* Blender 오브젝트 조작 명령
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { BlenderClient } from '../../blender/client';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
|
const client = new BlenderClient();
|
||||||
|
|
||||||
|
export function registerObjectCommands(program: Command) {
|
||||||
|
// List Objects
|
||||||
|
program
|
||||||
|
.command('list-objects')
|
||||||
|
.description('List all objects in the scene')
|
||||||
|
.option('-t, --type <string>', 'Filter by object type (MESH, ARMATURE, CAMERA, LIGHT)')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const objects: any = await client.sendCommand('Object.list', {
|
||||||
|
type: options.type
|
||||||
|
});
|
||||||
|
|
||||||
|
if (objects.length === 0) {
|
||||||
|
console.log('No objects found in the scene.');
|
||||||
|
} else {
|
||||||
|
console.log(`✅ Found ${objects.length} object(s):\n`);
|
||||||
|
|
||||||
|
objects.forEach((obj: any) => {
|
||||||
|
console.log(`📦 ${obj.name} (${obj.type})`);
|
||||||
|
console.log(` Location: [${obj.location.map((n: number) => n.toFixed(2)).join(', ')}]`);
|
||||||
|
console.log(` Rotation: [${obj.rotation.map((n: number) => n.toFixed(2)).join(', ')}]`);
|
||||||
|
console.log(` Scale: [${obj.scale.map((n: number) => n.toFixed(2)).join(', ')}]`);
|
||||||
|
console.log('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to list objects:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform Object
|
||||||
|
program
|
||||||
|
.command('transform')
|
||||||
|
.description('Transform an object (move, rotate, scale)')
|
||||||
|
.requiredOption('-n, --name <string>', 'Object name')
|
||||||
|
.option('--loc-x <number>', 'X location', parseFloat)
|
||||||
|
.option('--loc-y <number>', 'Y location', parseFloat)
|
||||||
|
.option('--loc-z <number>', 'Z location', parseFloat)
|
||||||
|
.option('--rot-x <number>', 'X rotation (radians)', parseFloat)
|
||||||
|
.option('--rot-y <number>', 'Y rotation (radians)', parseFloat)
|
||||||
|
.option('--rot-z <number>', 'Z rotation (radians)', parseFloat)
|
||||||
|
.option('--scale-x <number>', 'X scale', parseFloat)
|
||||||
|
.option('--scale-y <number>', 'Y scale', parseFloat)
|
||||||
|
.option('--scale-z <number>', 'Z scale', parseFloat)
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const params: any = { name: options.name };
|
||||||
|
|
||||||
|
if (options.locX !== undefined || options.locY !== undefined || options.locZ !== undefined) {
|
||||||
|
params.location = [
|
||||||
|
options.locX ?? 0,
|
||||||
|
options.locY ?? 0,
|
||||||
|
options.locZ ?? 0
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.rotX !== undefined || options.rotY !== undefined || options.rotZ !== undefined) {
|
||||||
|
params.rotation = [
|
||||||
|
options.rotX ?? 0,
|
||||||
|
options.rotY ?? 0,
|
||||||
|
options.rotZ ?? 0
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.scaleX !== undefined || options.scaleY !== undefined || options.scaleZ !== undefined) {
|
||||||
|
params.scale = [
|
||||||
|
options.scaleX ?? 1,
|
||||||
|
options.scaleY ?? 1,
|
||||||
|
options.scaleZ ?? 1
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Object.transform', params);
|
||||||
|
|
||||||
|
console.log('✅ Object transformed successfully:');
|
||||||
|
console.log(` Name: ${result.name}`);
|
||||||
|
console.log(` Location: [${result.location.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||||
|
console.log(` Rotation: [${result.rotation.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||||
|
console.log(` Scale: [${result.scale.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to transform object:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Duplicate Object
|
||||||
|
program
|
||||||
|
.command('duplicate')
|
||||||
|
.description('Duplicate an object')
|
||||||
|
.requiredOption('-n, --name <string>', 'Source object name')
|
||||||
|
.option('--new-name <string>', 'New object name')
|
||||||
|
.option('-x, --x <number>', 'X position for duplicate', parseFloat)
|
||||||
|
.option('-y, --y <number>', 'Y position for duplicate', parseFloat)
|
||||||
|
.option('-z, --z <number>', 'Z position for duplicate', parseFloat)
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const params: any = { name: options.name };
|
||||||
|
|
||||||
|
if (options.newName) {
|
||||||
|
params.newName = options.newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.x !== undefined || options.y !== undefined || options.z !== undefined) {
|
||||||
|
params.location = [
|
||||||
|
options.x ?? 0,
|
||||||
|
options.y ?? 0,
|
||||||
|
options.z ?? 0
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Object.duplicate', params);
|
||||||
|
|
||||||
|
console.log('✅ Object duplicated successfully:');
|
||||||
|
console.log(` New Name: ${result.name}`);
|
||||||
|
console.log(` Type: ${result.type}`);
|
||||||
|
console.log(` Location: [${result.location.map((n: number) => n.toFixed(3)).join(', ')}]`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to duplicate object:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete Object
|
||||||
|
program
|
||||||
|
.command('delete')
|
||||||
|
.description('Delete an object')
|
||||||
|
.requiredOption('-n, --name <string>', 'Object name')
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
await client.connect(options.port);
|
||||||
|
|
||||||
|
const result: any = await client.sendCommand('Object.delete', {
|
||||||
|
name: options.name
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ ${result.message}`);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete object:', error);
|
||||||
|
console.error('❌ Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
77
skills/scripts/src/cli/commands/retargeting.ts
Normal file
77
skills/scripts/src/cli/commands/retargeting.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Retargeting Commands
|
||||||
|
* Blender 애니메이션 리타게팅 명령
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { AnimationRetargetingWorkflow } from '../../index';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
|
export function registerRetargetingCommands(program: Command) {
|
||||||
|
// Retarget Animation
|
||||||
|
program
|
||||||
|
.command('retarget')
|
||||||
|
.description('Retarget animation from Mixamo to your character')
|
||||||
|
.requiredOption('-t, --target <string>', 'Target character armature name')
|
||||||
|
.requiredOption('-f, --file <string>', 'Animation file path (FBX or DAE)')
|
||||||
|
.option('-n, --name <string>', 'Animation name for NLA track')
|
||||||
|
.option('-m, --mapping <string>', 'Bone mapping mode (auto, mixamo_to_rigify, custom)', 'auto')
|
||||||
|
.option('--skip-confirmation', 'Skip bone mapping confirmation', false)
|
||||||
|
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
|
||||||
|
.option('-o, --output <string>', 'Output directory')
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
const workflow = new AnimationRetargetingWorkflow();
|
||||||
|
|
||||||
|
console.log('🎬 Starting animation retargeting workflow...\n');
|
||||||
|
|
||||||
|
await workflow.run({
|
||||||
|
blenderPort: options.port,
|
||||||
|
targetCharacterArmature: options.target,
|
||||||
|
animationFilePath: options.file,
|
||||||
|
animationName: options.name,
|
||||||
|
boneMapping: options.mapping,
|
||||||
|
skipConfirmation: options.skipConfirmation,
|
||||||
|
outputDir: options.output
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n✅ Animation retargeting completed successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Retargeting failed:', error);
|
||||||
|
console.error('\n❌ Retargeting failed:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show Mixamo download instructions
|
||||||
|
program
|
||||||
|
.command('mixamo-help')
|
||||||
|
.description('Show Mixamo download instructions and popular animations')
|
||||||
|
.argument('[animation-name]', 'Animation name (optional)')
|
||||||
|
.action((animationName) => {
|
||||||
|
const workflow = new AnimationRetargetingWorkflow();
|
||||||
|
|
||||||
|
if (animationName) {
|
||||||
|
console.log(workflow.getManualDownloadInstructions(animationName));
|
||||||
|
} else {
|
||||||
|
console.log('📚 Popular Mixamo Animations:\n');
|
||||||
|
|
||||||
|
const popularAnimations = workflow.getPopularAnimations();
|
||||||
|
Object.entries(popularAnimations).forEach(([category, animations]) => {
|
||||||
|
console.log(`\n${category}:`);
|
||||||
|
(animations as unknown as string[]).forEach((anim) => {
|
||||||
|
console.log(` • ${anim}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n\n📥 Download Instructions:\n');
|
||||||
|
console.log(workflow.getManualDownloadInstructions('Walking'));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n⚙️ Recommended Settings:\n');
|
||||||
|
const settings = workflow.getRecommendedSettings();
|
||||||
|
Object.entries(settings).forEach(([key, value]) => {
|
||||||
|
console.log(` ${key}: ${value}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
135
skills/scripts/src/constants/index.ts
Normal file
135
skills/scripts/src/constants/index.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Blender Toolkit Constants
|
||||||
|
* 모든 매직 넘버, 포트, 타이밍 등을 중앙에서 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blender WebSocket 관련 상수
|
||||||
|
* @property DEFAULT_PORT - 기본 WebSocket 포트 (9400, Browser-Pilot과 충돌 방지)
|
||||||
|
* @property PORT_RANGE_MAX - 포트 검색 범위 (100)
|
||||||
|
* @property LOCALHOST - 로컬 호스트 주소
|
||||||
|
* @property WS_TIMEOUT - WebSocket 연결 타임아웃 (30초)
|
||||||
|
*/
|
||||||
|
export const BLENDER = {
|
||||||
|
DEFAULT_PORT: 9400,
|
||||||
|
PORT_RANGE_MAX: 100,
|
||||||
|
LOCALHOST: '127.0.0.1',
|
||||||
|
WS_TIMEOUT: 30000, // 30 seconds
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 시스템 관련 상수
|
||||||
|
* @property OUTPUT_DIR - 출력 디렉토리 (.blender-toolkit)
|
||||||
|
* @property ANIMATIONS_DIR - 애니메이션 다운로드 디렉토리
|
||||||
|
* @property CONFIG_FILE - 설정 파일명
|
||||||
|
* @property DAEMON_PID_FILE - 데몬 PID 파일명
|
||||||
|
* @property GITIGNORE_CONTENT - .gitignore 기본 내용
|
||||||
|
*/
|
||||||
|
export const FS = {
|
||||||
|
OUTPUT_DIR: '.blender-toolkit',
|
||||||
|
ANIMATIONS_DIR: 'animations',
|
||||||
|
MODELS_DIR: 'models',
|
||||||
|
CONFIG_FILE: 'blender-config.json',
|
||||||
|
DAEMON_PID_FILE: 'daemon.pid',
|
||||||
|
GITIGNORE_CONTENT: `# Blender Toolkit generated files
|
||||||
|
*
|
||||||
|
`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mixamo 관련 상수
|
||||||
|
* Note: Mixamo does not provide an official API. Users must manually download files from Mixamo.com
|
||||||
|
* @property WEBSITE_URL - Mixamo 웹사이트 URL
|
||||||
|
* @property SUPPORTED_FORMATS - 지원 파일 포맷
|
||||||
|
* @property RECOMMENDED_FORMAT - 권장 다운로드 포맷
|
||||||
|
* @property RECOMMENDED_SKIN - 권장 스킨 설정 (리타게팅용)
|
||||||
|
* @property RECOMMENDED_FPS - 권장 FPS
|
||||||
|
*/
|
||||||
|
export const MIXAMO = {
|
||||||
|
WEBSITE_URL: 'https://www.mixamo.com',
|
||||||
|
SUPPORTED_FORMATS: ['fbx', 'dae'] as const,
|
||||||
|
RECOMMENDED_FORMAT: 'fbx' as const,
|
||||||
|
RECOMMENDED_SKIN: 'Without Skin', // Better for retargeting
|
||||||
|
RECOMMENDED_FPS: 30,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리타게팅 관련 상수
|
||||||
|
*/
|
||||||
|
export const RETARGETING = {
|
||||||
|
BONE_MAPPING_PRESETS: {
|
||||||
|
MIXAMO_TO_RIGIFY: 'mixamo_to_rigify',
|
||||||
|
MIXAMO_TO_CUSTOM: 'mixamo_to_custom',
|
||||||
|
AUTO_DETECT: 'auto_detect',
|
||||||
|
},
|
||||||
|
CONSTRAINT_TYPES: ['COPY_ROTATION', 'COPY_LOCATION'] as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타이밍 관련 상수 (모든 시간 단위는 밀리초)
|
||||||
|
*/
|
||||||
|
export const TIMING = {
|
||||||
|
DEFAULT_TIMEOUT: 30000, // 30 seconds
|
||||||
|
IMPORT_TIMEOUT: 60000, // 1 minute
|
||||||
|
RETARGET_TIMEOUT: 120000, // 2 minutes
|
||||||
|
RENDER_TIMEOUT: 300000, // 5 minutes
|
||||||
|
POLLING_INTERVAL: 1000, // 1 second
|
||||||
|
DAEMON_IDLE_TIMEOUT: 1800000, // 30 minutes
|
||||||
|
DAEMON_PING_INTERVAL: 5000, // 5 seconds
|
||||||
|
HOOK_INPUT_TIMEOUT: 100, // 100ms for reading stdin
|
||||||
|
ACTION_DELAY_SHORT: 50, // 50ms
|
||||||
|
ACTION_DELAY_MEDIUM: 100, // 100ms
|
||||||
|
ACTION_DELAY_LONG: 500, // 500ms
|
||||||
|
POLLING_INTERVAL_FAST: 100, // 100ms
|
||||||
|
POLLING_INTERVAL_STANDARD: 500, // 500ms
|
||||||
|
POLLING_INTERVAL_SLOW: 1000, // 1s
|
||||||
|
WAIT_FOR_BLENDER: 5000, // 5s - wait for Blender connection
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daemon 관련 상수
|
||||||
|
*/
|
||||||
|
export const DAEMON = {
|
||||||
|
IPC_TIMEOUT: 5000, // 5 seconds
|
||||||
|
MAX_RETRIES: 3,
|
||||||
|
RETRY_DELAY: 1000, // 1 second
|
||||||
|
IDLE_CHECK_INTERVAL: 60000, // 1 minute
|
||||||
|
MAX_MESSAGE_SIZE: 10 * 1024 * 1024, // 10MB - Browser Pilot 패턴
|
||||||
|
CONNECT_TIMEOUT: 5000, // 5 seconds
|
||||||
|
SHUTDOWN_TIMEOUT: 5000, // 5 seconds for graceful shutdown
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 환경 변수 이름 상수
|
||||||
|
*/
|
||||||
|
export const ENV = {
|
||||||
|
BLENDER_WS_PORT: 'BLENDER_WS_PORT',
|
||||||
|
BLENDER_EXECUTABLE: 'BLENDER_EXECUTABLE',
|
||||||
|
CLAUDE_PROJECT_DIR: 'CLAUDE_PROJECT_DIR',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에러 메시지
|
||||||
|
*/
|
||||||
|
export const ERROR_MESSAGES = {
|
||||||
|
BLENDER_NOT_RUNNING: 'Blender is not running or WebSocket server is not started',
|
||||||
|
CONNECTION_FAILED: 'Failed to connect to Blender',
|
||||||
|
TIMEOUT: 'Operation timed out',
|
||||||
|
IMPORT_FAILED: 'Failed to import animation',
|
||||||
|
RETARGET_FAILED: 'Failed to retarget animation',
|
||||||
|
NO_CHARACTER_SELECTED: 'No character selected',
|
||||||
|
ANIMATION_FILE_NOT_FOUND: 'Animation file not found. Please download from Mixamo.com first',
|
||||||
|
INVALID_BONE_MAPPING: 'Invalid bone mapping',
|
||||||
|
BONE_MAPPING_CONFIRMATION_FAILED: 'Bone mapping confirmation failed',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공 메시지
|
||||||
|
*/
|
||||||
|
export const SUCCESS_MESSAGES = {
|
||||||
|
CONNECTED: 'Connected to Blender',
|
||||||
|
ANIMATION_IMPORTED: 'Animation imported successfully',
|
||||||
|
BONE_MAPPING_GENERATED: 'Bone mapping generated successfully',
|
||||||
|
BONE_MAPPING_SENT_TO_UI: 'Bone mapping sent to Blender UI for review',
|
||||||
|
RETARGETING_COMPLETE: 'Animation retargeted successfully',
|
||||||
|
} as const;
|
||||||
209
skills/scripts/src/daemon/client.ts
Normal file
209
skills/scripts/src/daemon/client.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* IPC Client for Blender Toolkit Daemon
|
||||||
|
* Used by CLI commands to communicate with the daemon
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Socket, connect } from 'net';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { getOutputDir } from '../blender/config';
|
||||||
|
import {
|
||||||
|
IPCRequest,
|
||||||
|
IPCResponse,
|
||||||
|
SOCKET_PATH_PREFIX,
|
||||||
|
getProjectSocketName
|
||||||
|
} from './protocol';
|
||||||
|
import { DAEMON } from '../constants';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
export class IPCClient {
|
||||||
|
private socket: Socket | null = null;
|
||||||
|
private socketPath: string;
|
||||||
|
private pendingRequests: Map<string, {
|
||||||
|
resolve: (response: IPCResponse) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
}> = new Map();
|
||||||
|
private buffer: string = '';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const outputDir = getOutputDir();
|
||||||
|
this.socketPath = this.getSocketPath(outputDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get socket path (platform-specific, project-unique)
|
||||||
|
*/
|
||||||
|
private getSocketPath(outputDir: string): string {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// Windows: project-specific named pipe
|
||||||
|
const socketName = getProjectSocketName();
|
||||||
|
return `\\\\.\\pipe\\${socketName}`;
|
||||||
|
} else {
|
||||||
|
// Unix domain socket (already project-specific via outputDir)
|
||||||
|
return join(outputDir, `${SOCKET_PATH_PREFIX}.sock`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to daemon
|
||||||
|
*/
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this.socket && !this.socket.destroyed) {
|
||||||
|
return; // Already connected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if socket file exists (Unix only)
|
||||||
|
if (process.platform !== 'win32' && !existsSync(this.socketPath)) {
|
||||||
|
throw new Error('Daemon not running (socket file not found)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Browser Pilot 패턴: 연결 타임아웃
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.socket?.destroy();
|
||||||
|
reject(new Error(`Connection timeout after ${DAEMON.CONNECT_TIMEOUT}ms`));
|
||||||
|
}, DAEMON.CONNECT_TIMEOUT);
|
||||||
|
|
||||||
|
this.socket = connect(this.socketPath);
|
||||||
|
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
this.setupSocket();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('error', (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error(`Connection failed: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup socket event handlers
|
||||||
|
*/
|
||||||
|
private setupSocket(): void {
|
||||||
|
if (!this.socket) return;
|
||||||
|
|
||||||
|
this.socket.on('data', (data) => {
|
||||||
|
this.buffer += data.toString();
|
||||||
|
|
||||||
|
// Browser Pilot 패턴: 메시지 크기 제한 (DoS 방지)
|
||||||
|
if (this.buffer.length > DAEMON.MAX_MESSAGE_SIZE) {
|
||||||
|
logger.error(`Message size exceeded limit: ${this.buffer.length} bytes`);
|
||||||
|
this.socket?.destroy();
|
||||||
|
this.rejectAllPending(new Error('Message size exceeded limit'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process complete JSON messages (delimited by newline)
|
||||||
|
const messages = this.buffer.split('\n');
|
||||||
|
this.buffer = messages.pop() || ''; // Keep incomplete message in buffer
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (!message.trim()) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response: IPCResponse = JSON.parse(message);
|
||||||
|
this.handleResponse(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to parse response', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('error', (error) => {
|
||||||
|
logger.error('Socket error', error);
|
||||||
|
this.rejectAllPending(new Error(`Socket error: ${error.message}`));
|
||||||
|
// Browser Pilot 패턴: 리소스 정리
|
||||||
|
this.buffer = '';
|
||||||
|
this.socket = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('close', () => {
|
||||||
|
// Browser Pilot 패턴: 리소스 정리
|
||||||
|
this.buffer = '';
|
||||||
|
this.socket = null;
|
||||||
|
this.rejectAllPending(new Error('Connection closed'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle response from daemon
|
||||||
|
*/
|
||||||
|
private handleResponse(response: IPCResponse): void {
|
||||||
|
const pending = this.pendingRequests.get(response.id);
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
logger.warn(`Received response for unknown request: ${response.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
this.pendingRequests.delete(response.id);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
pending.resolve(response);
|
||||||
|
} else {
|
||||||
|
pending.reject(new Error(response.error || 'Command failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject all pending requests
|
||||||
|
*/
|
||||||
|
private rejectAllPending(error: Error): void {
|
||||||
|
for (const [_id, pending] of this.pendingRequests.entries()) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
pending.reject(error);
|
||||||
|
}
|
||||||
|
this.pendingRequests.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send request to daemon
|
||||||
|
*/
|
||||||
|
async sendRequest(command: string, params: Record<string, unknown> = {}, timeout: number = DAEMON.IPC_TIMEOUT): Promise<IPCResponse> {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
if (!this.socket) {
|
||||||
|
throw new Error('Not connected to daemon');
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: IPCRequest = {
|
||||||
|
id: randomUUID(),
|
||||||
|
command,
|
||||||
|
params,
|
||||||
|
timeout
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeoutHandle = setTimeout(() => {
|
||||||
|
this.pendingRequests.delete(request.id);
|
||||||
|
reject(new Error(`Request timeout after ${timeout}ms`));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
this.pendingRequests.set(request.id, {
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timeout: timeoutHandle
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send request (newline-delimited JSON)
|
||||||
|
this.socket!.write(JSON.stringify(request) + '\n');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close connection
|
||||||
|
*/
|
||||||
|
close(): void {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.destroy();
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
this.rejectAllPending(new Error('Client closed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
275
skills/scripts/src/daemon/manager.ts
Normal file
275
skills/scripts/src/daemon/manager.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* Daemon Process Manager
|
||||||
|
* Handles starting, stopping, and checking status of the Blender Toolkit Daemon
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync, readFileSync, unlinkSync } from 'fs';
|
||||||
|
import { getOutputDir } from '../blender/config';
|
||||||
|
import { IPCClient } from './client';
|
||||||
|
import { PID_FILENAME, DaemonState, DAEMON_COMMANDS } from './protocol';
|
||||||
|
import { DAEMON, TIMING } from '../constants';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
export class DaemonManager {
|
||||||
|
private outputDir: string;
|
||||||
|
private pidPath: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.outputDir = getOutputDir();
|
||||||
|
this.pidPath = join(this.outputDir, PID_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start daemon process
|
||||||
|
*/
|
||||||
|
async start(options: { verbose?: boolean } = {}): Promise<void> {
|
||||||
|
const { verbose = true } = options;
|
||||||
|
|
||||||
|
// Check if already running
|
||||||
|
if (await this.isRunning()) {
|
||||||
|
if (verbose) {
|
||||||
|
console.log(' Daemon is already running');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log('=<3D> Starting Blender Toolkit Daemon...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get path to server.js (compiled output)
|
||||||
|
const serverPath = join(__dirname, 'server.js');
|
||||||
|
|
||||||
|
if (!existsSync(serverPath)) {
|
||||||
|
throw new Error(`Daemon server not found at ${serverPath}. Did you run 'npm run build'?`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn daemon as detached process
|
||||||
|
const daemon = spawn(process.execPath, [serverPath], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: process.env
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detach the process so it continues running when parent exits
|
||||||
|
daemon.unref();
|
||||||
|
|
||||||
|
// Wait for daemon to start
|
||||||
|
await this.waitForDaemon();
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(' Daemon started successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for daemon to be ready
|
||||||
|
*/
|
||||||
|
private async waitForDaemon(): Promise<void> {
|
||||||
|
const maxAttempts = 10;
|
||||||
|
const delay = 500; // 500ms
|
||||||
|
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
|
||||||
|
if (await this.isRunning()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Daemon failed to start');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop daemon process
|
||||||
|
*/
|
||||||
|
async stop(options: { verbose?: boolean; force?: boolean } = {}): Promise<void> {
|
||||||
|
const { verbose = true, force = false } = options;
|
||||||
|
|
||||||
|
if (!(await this.isRunning())) {
|
||||||
|
if (verbose) {
|
||||||
|
console.log('Daemon is not running');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log('=<3D> Stopping Blender Toolkit Daemon...');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (force) {
|
||||||
|
// Force kill via PID
|
||||||
|
await this.forceKill();
|
||||||
|
} else {
|
||||||
|
// Graceful shutdown via IPC
|
||||||
|
try {
|
||||||
|
const client = new IPCClient();
|
||||||
|
await client.sendRequest(DAEMON_COMMANDS.SHUTDOWN, {});
|
||||||
|
client.close();
|
||||||
|
|
||||||
|
// Wait for shutdown
|
||||||
|
await this.waitForShutdown();
|
||||||
|
} catch (error) {
|
||||||
|
if (verbose) {
|
||||||
|
console.log('<27> Graceful shutdown failed, force killing...');
|
||||||
|
}
|
||||||
|
await this.forceKill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(' Daemon stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force kill daemon process
|
||||||
|
*/
|
||||||
|
private async forceKill(): Promise<void> {
|
||||||
|
if (!existsSync(this.pidPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pidStr = readFileSync(this.pidPath, 'utf-8').trim();
|
||||||
|
const pid = parseInt(pidStr, 10);
|
||||||
|
|
||||||
|
if (isNaN(pid) || pid <= 0) {
|
||||||
|
logger.warn(`Invalid PID in ${this.pidPath}: ${pidStr}`);
|
||||||
|
unlinkSync(this.pidPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill process
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGTERM');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// If still running, force kill
|
||||||
|
if (this.isProcessRunning(pid)) {
|
||||||
|
process.kill(pid, 'SIGKILL');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Process might already be dead
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove PID file
|
||||||
|
if (existsSync(this.pidPath)) {
|
||||||
|
unlinkSync(this.pidPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Force kill failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for daemon to shutdown
|
||||||
|
*/
|
||||||
|
private async waitForShutdown(): Promise<void> {
|
||||||
|
const maxAttempts = 10;
|
||||||
|
const delay = 500; // 500ms
|
||||||
|
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
|
||||||
|
if (!(await this.isRunning())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Daemon failed to shutdown gracefully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart daemon
|
||||||
|
*/
|
||||||
|
async restart(options: { verbose?: boolean } = {}): Promise<void> {
|
||||||
|
const { verbose = true } = options;
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log('= Restarting Blender Toolkit Daemon...');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.stop({ verbose: false });
|
||||||
|
await this.start({ verbose: false });
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(' Daemon restarted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get daemon status
|
||||||
|
*/
|
||||||
|
async getStatus(options: { verbose?: boolean } = {}): Promise<DaemonState | null> {
|
||||||
|
const { verbose = true } = options;
|
||||||
|
|
||||||
|
if (!(await this.isRunning())) {
|
||||||
|
if (verbose) {
|
||||||
|
console.log('Daemon is not running');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new IPCClient();
|
||||||
|
const response = await client.sendRequest(DAEMON_COMMANDS.GET_STATUS, {});
|
||||||
|
client.close();
|
||||||
|
|
||||||
|
const state = response.data as DaemonState;
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log('Daemon Status:');
|
||||||
|
console.log(` Connected to Blender: ${state.connected ? 'Yes' : 'No'}`);
|
||||||
|
console.log(` Blender Port: ${state.port}`);
|
||||||
|
console.log(` Uptime: ${Math.floor(state.uptime / 1000)}s`);
|
||||||
|
console.log(` Last Activity: ${Math.floor((Date.now() - state.lastActivity) / 1000)}s ago`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
} catch (error) {
|
||||||
|
if (verbose) {
|
||||||
|
console.error('Failed to get status:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if daemon is running
|
||||||
|
*/
|
||||||
|
async isRunning(): Promise<boolean> {
|
||||||
|
if (!existsSync(this.pidPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pidStr = readFileSync(this.pidPath, 'utf-8').trim();
|
||||||
|
const pid = parseInt(pidStr, 10);
|
||||||
|
|
||||||
|
if (isNaN(pid) || pid <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.isProcessRunning(pid);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if process is running by PID
|
||||||
|
*/
|
||||||
|
private isProcessRunning(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
// Signal 0 checks if process exists without killing it
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
skills/scripts/src/daemon/protocol.ts
Normal file
78
skills/scripts/src/daemon/protocol.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* IPC Protocol definitions for Blender Toolkit Daemon
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { basename } from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPC Request from CLI to Daemon
|
||||||
|
*/
|
||||||
|
export interface IPCRequest {
|
||||||
|
id: string;
|
||||||
|
command: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPC Response from Daemon to CLI
|
||||||
|
*/
|
||||||
|
export interface IPCResponse {
|
||||||
|
id: string;
|
||||||
|
success: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daemon state information
|
||||||
|
*/
|
||||||
|
export interface DaemonState {
|
||||||
|
connected: boolean;
|
||||||
|
port: number | null;
|
||||||
|
host: string;
|
||||||
|
uptime: number;
|
||||||
|
lastActivity: number;
|
||||||
|
blenderVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File names and paths
|
||||||
|
*/
|
||||||
|
export const PID_FILENAME = 'daemon.pid';
|
||||||
|
export const SOCKET_PATH_PREFIX = 'daemon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project-specific socket name for daemon IPC
|
||||||
|
* Same logic as browser-pilot
|
||||||
|
*/
|
||||||
|
export function getProjectSocketName(projectRoot?: string): string {
|
||||||
|
const root = projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
||||||
|
const projectName = basename(root)
|
||||||
|
.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
// Add hash of full path to prevent collision
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
.update(root)
|
||||||
|
.digest('hex')
|
||||||
|
.substring(0, 8);
|
||||||
|
|
||||||
|
return `${SOCKET_PATH_PREFIX}-${projectName}-${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daemon commands
|
||||||
|
*/
|
||||||
|
export const DAEMON_COMMANDS = {
|
||||||
|
// Status commands
|
||||||
|
PING: 'ping',
|
||||||
|
GET_STATUS: 'get-status',
|
||||||
|
SHUTDOWN: 'shutdown',
|
||||||
|
|
||||||
|
// Blender commands (pass-through to Blender WebSocket)
|
||||||
|
BLENDER_COMMAND: 'blender-command',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type DaemonCommand = typeof DAEMON_COMMANDS[keyof typeof DAEMON_COMMANDS];
|
||||||
353
skills/scripts/src/daemon/server.ts
Normal file
353
skills/scripts/src/daemon/server.ts
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
/**
|
||||||
|
* Blender Toolkit Daemon Server
|
||||||
|
* Detached background process that maintains connection to Blender WebSocket
|
||||||
|
* and provides IPC interface for CLI commands
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Server as NetServer, Socket as NetSocket, createServer } from 'net';
|
||||||
|
import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { BlenderClient } from '../blender/client';
|
||||||
|
import { getOutputDir, getProjectConfig } from '../blender/config';
|
||||||
|
import {
|
||||||
|
IPCRequest,
|
||||||
|
IPCResponse,
|
||||||
|
DaemonState,
|
||||||
|
DAEMON_COMMANDS,
|
||||||
|
PID_FILENAME,
|
||||||
|
SOCKET_PATH_PREFIX,
|
||||||
|
getProjectSocketName
|
||||||
|
} from './protocol';
|
||||||
|
import { DAEMON } from '../constants';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
class DaemonServer {
|
||||||
|
private ipcServer: NetServer | null = null;
|
||||||
|
private blenderClient: BlenderClient;
|
||||||
|
private socketPath: string;
|
||||||
|
private pidPath: string;
|
||||||
|
private startTime: number;
|
||||||
|
private lastActivity: number;
|
||||||
|
private blenderPort: number = 9400;
|
||||||
|
private shutdownRequested: boolean = false;
|
||||||
|
// Browser Pilot 패턴: 활성 연결 추적
|
||||||
|
private activeSockets: Set<NetSocket> = new Set();
|
||||||
|
// Browser Pilot 패턴: shutdown Promise (race condition 방지)
|
||||||
|
private shutdownPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const outputDir = getOutputDir();
|
||||||
|
this.socketPath = this.getSocketPath(outputDir);
|
||||||
|
this.pidPath = join(outputDir, PID_FILENAME);
|
||||||
|
this.blenderClient = new BlenderClient();
|
||||||
|
this.startTime = Date.now();
|
||||||
|
this.lastActivity = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get socket path (platform-specific)
|
||||||
|
*/
|
||||||
|
private getSocketPath(outputDir: string): string {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const socketName = getProjectSocketName();
|
||||||
|
return `\\\\.\\pipe\\${socketName}`;
|
||||||
|
} else {
|
||||||
|
return join(outputDir, `${SOCKET_PATH_PREFIX}.sock`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start daemon server
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Get project config for Blender port
|
||||||
|
const config = await getProjectConfig();
|
||||||
|
this.blenderPort = config.port;
|
||||||
|
|
||||||
|
logger.info(`Starting Blender Toolkit Daemon on port ${this.blenderPort}`);
|
||||||
|
|
||||||
|
// Write PID file
|
||||||
|
writeFileSync(this.pidPath, String(process.pid), 'utf-8');
|
||||||
|
logger.info(`PID file written: ${this.pidPath}`);
|
||||||
|
|
||||||
|
// Start IPC server
|
||||||
|
await this.startIPCServer();
|
||||||
|
|
||||||
|
// Setup shutdown handlers
|
||||||
|
this.setupShutdownHandlers();
|
||||||
|
|
||||||
|
logger.info(' Daemon started successfully');
|
||||||
|
console.log(`Blender Toolkit Daemon started (PID: ${process.pid})`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to start daemon:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start IPC server for CLI communication
|
||||||
|
*/
|
||||||
|
private async startIPCServer(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Remove existing socket file (Unix only)
|
||||||
|
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
|
||||||
|
unlinkSync(this.socketPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ipcServer = createServer((socket: NetSocket) => {
|
||||||
|
this.handleIPCConnection(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ipcServer.on('error', (error) => {
|
||||||
|
logger.error('IPC server error:', error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ipcServer.listen(this.socketPath, () => {
|
||||||
|
logger.info(`IPC server listening on ${this.socketPath}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle IPC connection from CLI
|
||||||
|
*/
|
||||||
|
private handleIPCConnection(socket: NetSocket): void {
|
||||||
|
logger.info('CLI client connected');
|
||||||
|
|
||||||
|
// Browser Pilot 패턴: 활성 소켓 추적
|
||||||
|
this.activeSockets.add(socket);
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
socket.on('data', async (data) => {
|
||||||
|
buffer += data.toString();
|
||||||
|
|
||||||
|
// Browser Pilot 패턴: 메시지 크기 제한 (DoS 방지)
|
||||||
|
if (buffer.length > DAEMON.MAX_MESSAGE_SIZE) {
|
||||||
|
logger.error(`Message size exceeded limit: ${buffer.length} bytes`);
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process newline-delimited JSON
|
||||||
|
const messages = buffer.split('\n');
|
||||||
|
buffer = messages.pop() || '';
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (!message.trim()) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request: IPCRequest = JSON.parse(message);
|
||||||
|
const response = await this.handleIPCRequest(request);
|
||||||
|
socket.write(JSON.stringify(response) + '\n');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('Failed to handle IPC request:', errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
logger.warn('IPC socket error:', error);
|
||||||
|
// Browser Pilot 패턴: 활성 소켓에서 제거
|
||||||
|
this.activeSockets.delete(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
logger.info('CLI client disconnected');
|
||||||
|
// Browser Pilot 패턴: 활성 소켓에서 제거
|
||||||
|
this.activeSockets.delete(socket);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle IPC request from CLI
|
||||||
|
*/
|
||||||
|
private async handleIPCRequest(request: IPCRequest): Promise<IPCResponse> {
|
||||||
|
this.lastActivity = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`Handling command: ${request.command}`);
|
||||||
|
|
||||||
|
switch (request.command) {
|
||||||
|
case DAEMON_COMMANDS.PING:
|
||||||
|
return { id: request.id, success: true, data: { status: 'alive' } };
|
||||||
|
|
||||||
|
case DAEMON_COMMANDS.GET_STATUS:
|
||||||
|
return { id: request.id, success: true, data: this.getStatus() };
|
||||||
|
|
||||||
|
case DAEMON_COMMANDS.SHUTDOWN:
|
||||||
|
this.shutdown();
|
||||||
|
return { id: request.id, success: true, data: { message: 'Shutting down' } };
|
||||||
|
|
||||||
|
case DAEMON_COMMANDS.BLENDER_COMMAND:
|
||||||
|
// Forward command to Blender WebSocket
|
||||||
|
const result = await this.forwardToBlender(request.params);
|
||||||
|
return { id: request.id, success: true, data: result };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: false,
|
||||||
|
error: `Unknown command: ${request.command}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(`Command failed: ${errorMessage}`);
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: false,
|
||||||
|
error: errorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward command to Blender WebSocket
|
||||||
|
*/
|
||||||
|
private async forwardToBlender(params: Record<string, unknown>): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
// Connect to Blender if not connected
|
||||||
|
if (!this.blenderClient.isConnected()) {
|
||||||
|
await this.blenderClient.connect(this.blenderPort);
|
||||||
|
logger.info(`Connected to Blender on port ${this.blenderPort}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract command method and params
|
||||||
|
const method = params.method as string;
|
||||||
|
const commandParams = params.params as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Send command to Blender
|
||||||
|
const result = await this.blenderClient.sendCommand(method, commandParams);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(`Blender command failed: ${errorMessage}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get daemon status
|
||||||
|
*/
|
||||||
|
private getStatus(): DaemonState {
|
||||||
|
const uptime = Date.now() - this.startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected: this.blenderClient.isConnected(),
|
||||||
|
port: this.blenderPort,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
uptime,
|
||||||
|
lastActivity: this.lastActivity
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup shutdown handlers
|
||||||
|
*/
|
||||||
|
private setupShutdownHandlers(): void {
|
||||||
|
const shutdown = (signal: string) => {
|
||||||
|
logger.info(`Received ${signal}, shutting down...`);
|
||||||
|
void this.shutdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
process.on('SIGHUP', () => shutdown('SIGHUP'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown daemon
|
||||||
|
* Browser Pilot 패턴: Race condition 방지
|
||||||
|
*/
|
||||||
|
private shutdown(): Promise<void> {
|
||||||
|
// Race condition 방지: 이미 shutdown 중이면 기존 Promise 반환
|
||||||
|
if (this.shutdownPromise) {
|
||||||
|
return this.shutdownPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shutdownRequested = true;
|
||||||
|
this.shutdownPromise = this.performShutdown();
|
||||||
|
return this.shutdownPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실제 shutdown 수행 (내부 메서드)
|
||||||
|
* Browser Pilot 패턴: Promise 기반 안전한 종료
|
||||||
|
*/
|
||||||
|
private async performShutdown(): Promise<void> {
|
||||||
|
logger.info('Shutting down daemon...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Close all active client connections
|
||||||
|
logger.info(`Closing ${this.activeSockets.size} active connections...`);
|
||||||
|
for (const socket of this.activeSockets) {
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore individual socket errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.activeSockets.clear();
|
||||||
|
|
||||||
|
// 2. Close Blender connection
|
||||||
|
if (this.blenderClient.isConnected()) {
|
||||||
|
this.blenderClient.disconnect();
|
||||||
|
logger.info('Disconnected from Blender');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Close IPC server with timeout
|
||||||
|
if (this.ipcServer) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
logger.warn('IPC server close timeout, forcing...');
|
||||||
|
resolve();
|
||||||
|
}, DAEMON.SHUTDOWN_TIMEOUT);
|
||||||
|
|
||||||
|
this.ipcServer!.close(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
logger.info('IPC server closed');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Remove socket file (Unix only)
|
||||||
|
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
|
||||||
|
unlinkSync(this.socketPath);
|
||||||
|
logger.info('Socket file removed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Remove PID file
|
||||||
|
if (existsSync(this.pidPath)) {
|
||||||
|
unlinkSync(this.pidPath);
|
||||||
|
logger.info('PID file removed');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('✓ Daemon shutdown complete');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during shutdown:', error);
|
||||||
|
} finally {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Main entry point
|
||||||
|
if (require.main === module) {
|
||||||
|
const server = new DaemonServer();
|
||||||
|
server.start().catch((error) => {
|
||||||
|
console.error('Fatal error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DaemonServer;
|
||||||
308
skills/scripts/src/index.ts
Normal file
308
skills/scripts/src/index.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* Blender Animation Retargeting Workflow
|
||||||
|
* Mixamo 애니메이션을 사용자 캐릭터에 리타게팅하는 전체 워크플로우
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BlenderClient } from './blender/client';
|
||||||
|
import { RetargetingController } from './blender/retargeting';
|
||||||
|
import { MixamoHelper } from './blender/mixamo';
|
||||||
|
import { BLENDER, FS, ERROR_MESSAGES, SUCCESS_MESSAGES } from './constants';
|
||||||
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export interface RetargetWorkflowOptions {
|
||||||
|
// Blender 설정
|
||||||
|
blenderPort?: number;
|
||||||
|
|
||||||
|
// 캐릭터 설정
|
||||||
|
targetCharacterArmature: string;
|
||||||
|
|
||||||
|
// 애니메이션 파일 설정
|
||||||
|
animationFilePath: string; // FBX or DAE file path (manual download required)
|
||||||
|
animationName?: string; // Optional animation name for NLA track
|
||||||
|
|
||||||
|
// 리타게팅 설정
|
||||||
|
boneMapping?: 'auto' | 'mixamo_to_rigify' | 'custom';
|
||||||
|
customBoneMap?: Record<string, string>;
|
||||||
|
|
||||||
|
// Confirmation workflow
|
||||||
|
skipConfirmation?: boolean; // Skip bone mapping confirmation (use auto-mapping directly)
|
||||||
|
|
||||||
|
// 출력 설정
|
||||||
|
outputDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnimationRetargetingWorkflow {
|
||||||
|
private blenderClient: BlenderClient;
|
||||||
|
private retargetingController: RetargetingController;
|
||||||
|
private mixamoHelper: MixamoHelper;
|
||||||
|
private outputDir: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.blenderClient = new BlenderClient();
|
||||||
|
this.retargetingController = new RetargetingController(this.blenderClient);
|
||||||
|
this.mixamoHelper = new MixamoHelper();
|
||||||
|
this.outputDir = join(process.cwd(), FS.OUTPUT_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 리타게팅 워크플로우 실행
|
||||||
|
*
|
||||||
|
* Workflow with user confirmation:
|
||||||
|
* 1. Import animation FBX
|
||||||
|
* 2. Auto-generate bone mapping
|
||||||
|
* 3. Send mapping to Blender UI for review
|
||||||
|
* 4. Wait for user confirmation (via AskUserQuestion)
|
||||||
|
* 5. Retrieve edited mapping from Blender
|
||||||
|
* 6. Apply retargeting with confirmed mapping
|
||||||
|
*/
|
||||||
|
async run(options: RetargetWorkflowOptions): Promise<void> {
|
||||||
|
const {
|
||||||
|
blenderPort = BLENDER.DEFAULT_PORT,
|
||||||
|
targetCharacterArmature,
|
||||||
|
animationFilePath,
|
||||||
|
animationName,
|
||||||
|
boneMapping = 'auto',
|
||||||
|
customBoneMap,
|
||||||
|
skipConfirmation = false,
|
||||||
|
outputDir,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (outputDir) {
|
||||||
|
this.outputDir = outputDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 출력 디렉토리 생성
|
||||||
|
this.ensureOutputDirectory();
|
||||||
|
|
||||||
|
// Validate animation file
|
||||||
|
if (!existsSync(animationFilePath)) {
|
||||||
|
throw new Error(`Animation file not found: ${animationFilePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Blender에 연결
|
||||||
|
console.log('🔌 Connecting to Blender...');
|
||||||
|
await this.blenderClient.connect();
|
||||||
|
console.log(SUCCESS_MESSAGES.CONNECTED);
|
||||||
|
|
||||||
|
// Step 2: 타겟 캐릭터 확인
|
||||||
|
console.log('🔍 Checking target character...');
|
||||||
|
const armatures = await this.getArmatures();
|
||||||
|
if (!armatures.includes(targetCharacterArmature)) {
|
||||||
|
throw new Error(
|
||||||
|
`Target armature "${targetCharacterArmature}" not found. Available: ${armatures.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: 애니메이션 파일 임포트
|
||||||
|
console.log(`📦 Importing animation from: ${animationFilePath}`);
|
||||||
|
await this.importAnimation(animationFilePath);
|
||||||
|
console.log(SUCCESS_MESSAGES.ANIMATION_IMPORTED);
|
||||||
|
|
||||||
|
// Step 4: Mixamo 아마추어 찾기 (방금 임포트된 것)
|
||||||
|
const updatedArmatures = await this.getArmatures();
|
||||||
|
const mixamoArmature = updatedArmatures.find(
|
||||||
|
(name) => !armatures.includes(name)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mixamoArmature) {
|
||||||
|
throw new Error('Failed to find imported animation armature');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Found animation armature: ${mixamoArmature}`);
|
||||||
|
|
||||||
|
// Step 5: Auto-generate bone mapping
|
||||||
|
console.log('🔍 Auto-generating bone mapping...');
|
||||||
|
let finalBoneMap: Record<string, string>;
|
||||||
|
|
||||||
|
if (boneMapping === 'custom' && customBoneMap) {
|
||||||
|
finalBoneMap = customBoneMap;
|
||||||
|
} else {
|
||||||
|
finalBoneMap = await this.retargetingController.autoMapBones(
|
||||||
|
mixamoArmature,
|
||||||
|
targetCharacterArmature
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Generated bone mapping (${Object.keys(finalBoneMap).length} bones)`);
|
||||||
|
|
||||||
|
// Step 6: Bone mapping confirmation workflow
|
||||||
|
if (!skipConfirmation) {
|
||||||
|
console.log('\n📋 Bone Mapping Preview:');
|
||||||
|
console.log('─'.repeat(60));
|
||||||
|
Object.entries(finalBoneMap).forEach(([source, target]) => {
|
||||||
|
console.log(` ${source.padEnd(25)} → ${target}`);
|
||||||
|
});
|
||||||
|
console.log('─'.repeat(60));
|
||||||
|
|
||||||
|
// Send bone mapping to Blender UI
|
||||||
|
console.log('\n📤 Sending bone mapping to Blender UI...');
|
||||||
|
await this.blenderClient.sendCommand('BoneMapping.show', {
|
||||||
|
sourceArmature: mixamoArmature,
|
||||||
|
targetArmature: targetCharacterArmature,
|
||||||
|
boneMapping: finalBoneMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Bone mapping displayed in Blender');
|
||||||
|
console.log('\n⏸️ Please review the bone mapping in Blender:');
|
||||||
|
console.log(' 1. Check the "Blender Toolkit" panel in the 3D View sidebar (N key)');
|
||||||
|
console.log(' 2. Review the bone mapping table');
|
||||||
|
console.log(' 3. Edit any incorrect mappings if needed');
|
||||||
|
console.log(' 4. Click "Apply Retargeting" when ready');
|
||||||
|
console.log('\nWaiting for user confirmation...\n');
|
||||||
|
|
||||||
|
// Note: In actual implementation with Claude Code, this would use AskUserQuestion
|
||||||
|
// For now, we'll retrieve the mapping after a pause
|
||||||
|
// TODO: Integrate with Claude Code's AskUserQuestion tool
|
||||||
|
|
||||||
|
// Retrieve edited bone mapping from Blender (with error recovery)
|
||||||
|
console.log('📥 Retrieving bone mapping from Blender...');
|
||||||
|
try {
|
||||||
|
const retrievedMapping = await this.blenderClient.sendCommand<Record<string, string>>(
|
||||||
|
'BoneMapping.get',
|
||||||
|
{
|
||||||
|
sourceArmature: mixamoArmature,
|
||||||
|
targetArmature: targetCharacterArmature,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (retrievedMapping && Object.keys(retrievedMapping).length > 0) {
|
||||||
|
finalBoneMap = retrievedMapping;
|
||||||
|
console.log(`✅ Using edited bone mapping (${Object.keys(finalBoneMap).length} bones)`);
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ No edited mapping found, using auto-generated mapping');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Failed to retrieve edited mapping, using auto-generated mapping');
|
||||||
|
console.warn(` Error: ${error}`);
|
||||||
|
// finalBoneMap already contains the auto-generated mapping, so no action needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: 리타게팅 실행
|
||||||
|
console.log('\n🎬 Starting animation retargeting...');
|
||||||
|
await this.retargetingController.retarget({
|
||||||
|
sourceArmature: mixamoArmature,
|
||||||
|
targetArmature: targetCharacterArmature,
|
||||||
|
boneMapping: 'custom',
|
||||||
|
customBoneMap: finalBoneMap,
|
||||||
|
preserveRotation: true,
|
||||||
|
preserveLocation: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(SUCCESS_MESSAGES.RETARGETING_COMPLETE);
|
||||||
|
|
||||||
|
// Step 8: NLA에 추가 (선택사항)
|
||||||
|
const animations = await this.retargetingController.getAnimations(
|
||||||
|
targetCharacterArmature
|
||||||
|
);
|
||||||
|
|
||||||
|
if (animations.length > 0) {
|
||||||
|
const latestAnimation = animations[animations.length - 1];
|
||||||
|
const nlaTrackName = animationName || `Retargeted_${Date.now()}`;
|
||||||
|
console.log(`📋 Adding animation to NLA track: ${nlaTrackName}`);
|
||||||
|
await this.retargetingController.addToNLA(
|
||||||
|
targetCharacterArmature,
|
||||||
|
latestAnimation,
|
||||||
|
nlaTrackName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ Animation retargeting completed successfully!\n');
|
||||||
|
console.log('Next steps:');
|
||||||
|
console.log(' 1. Review the retargeted animation in Blender');
|
||||||
|
console.log(' 2. Adjust keyframes if needed');
|
||||||
|
console.log(' 3. Export or save your scene');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Retargeting workflow failed:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// 연결 종료
|
||||||
|
await this.blenderClient.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 애니메이션 파일 임포트
|
||||||
|
*/
|
||||||
|
private async importAnimation(filepath: string): Promise<void> {
|
||||||
|
const ext = filepath.split('.').pop()?.toLowerCase();
|
||||||
|
|
||||||
|
if (ext === 'fbx') {
|
||||||
|
await this.blenderClient.sendCommand('Import.fbx', { filepath });
|
||||||
|
} else if (ext === 'dae') {
|
||||||
|
await this.blenderClient.sendCommand('Import.dae', { filepath });
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported file format: ${ext}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아마추어 목록 가져오기
|
||||||
|
*/
|
||||||
|
private async getArmatures(): Promise<string[]> {
|
||||||
|
return await this.blenderClient.sendCommand<string[]>('Armature.list');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 출력 디렉토리 생성
|
||||||
|
*/
|
||||||
|
private ensureOutputDirectory(): void {
|
||||||
|
if (!existsSync(this.outputDir)) {
|
||||||
|
mkdirSync(this.outputDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const animationsDir = join(this.outputDir, FS.ANIMATIONS_DIR);
|
||||||
|
if (!existsSync(animationsDir)) {
|
||||||
|
mkdirSync(animationsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// .gitignore 생성
|
||||||
|
const gitignorePath = join(this.outputDir, '.gitignore');
|
||||||
|
if (!existsSync(gitignorePath)) {
|
||||||
|
const fs = require('fs');
|
||||||
|
fs.writeFileSync(gitignorePath, FS.GITIGNORE_CONTENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get manual download instructions for Mixamo
|
||||||
|
*/
|
||||||
|
getManualDownloadInstructions(animationName: string): string {
|
||||||
|
return this.mixamoHelper.getManualDownloadInstructions(animationName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of popular Mixamo animations
|
||||||
|
*/
|
||||||
|
getPopularAnimations() {
|
||||||
|
return this.mixamoHelper.getPopularAnimations();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recommended Mixamo download settings
|
||||||
|
*/
|
||||||
|
getRecommendedSettings() {
|
||||||
|
return this.mixamoHelper.getRecommendedSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI 사용 예시
|
||||||
|
export async function runRetargetingFromCLI() {
|
||||||
|
const workflow = new AnimationRetargetingWorkflow();
|
||||||
|
|
||||||
|
// Show manual download instructions
|
||||||
|
console.log(workflow.getManualDownloadInstructions('Walking'));
|
||||||
|
console.log('\nRecommended settings:', workflow.getRecommendedSettings());
|
||||||
|
|
||||||
|
// After manual download, run retargeting
|
||||||
|
await workflow.run({
|
||||||
|
targetCharacterArmature: 'MyCharacter', // User's character name
|
||||||
|
animationFilePath: './animations/Walking.fbx', // Downloaded FBX path
|
||||||
|
animationName: 'Walking', // Animation name for NLA track
|
||||||
|
boneMapping: 'auto', // Auto bone mapping
|
||||||
|
skipConfirmation: false, // Enable confirmation workflow
|
||||||
|
});
|
||||||
|
}
|
||||||
92
skills/scripts/src/utils/logger.ts
Normal file
92
skills/scripts/src/utils/logger.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Winston Logger Configuration
|
||||||
|
* TypeScript 애플리케이션용 로깅 시스템
|
||||||
|
*/
|
||||||
|
|
||||||
|
import winston from 'winston';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
|
|
||||||
|
// 로그 디렉토리 경로
|
||||||
|
const LOG_DIR = join(process.cwd(), '.blender-toolkit', 'logs');
|
||||||
|
|
||||||
|
// 로그 디렉토리 생성
|
||||||
|
if (!existsSync(LOG_DIR)) {
|
||||||
|
mkdirSync(LOG_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그 포맷 정의
|
||||||
|
const logFormat = winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.printf(({ timestamp, level, message, stack }) => {
|
||||||
|
const logMessage = `[${timestamp}] [${level.toUpperCase().padEnd(5)}] ${message}`;
|
||||||
|
return stack ? `${logMessage}\n${stack}` : logMessage;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 콘솔용 컬러 포맷
|
||||||
|
const consoleFormat = winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.timestamp({ format: 'HH:mm:ss' }),
|
||||||
|
winston.format.printf(({ timestamp, level, message }) => {
|
||||||
|
return `[${timestamp}] ${level}: ${message}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Winston 로거 생성
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
format: logFormat,
|
||||||
|
transports: [
|
||||||
|
// 파일 트랜스포트: 모든 로그
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: join(LOG_DIR, 'typescript.log'),
|
||||||
|
maxsize: 5242880, // 5MB
|
||||||
|
maxFiles: 5,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 파일 트랜스포트: 에러만
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: join(LOG_DIR, 'error.log'),
|
||||||
|
level: 'error',
|
||||||
|
maxsize: 5242880, // 5MB
|
||||||
|
maxFiles: 5,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 개발 모드에서는 콘솔에도 출력
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
logger.add(
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: consoleFormat,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디버그 모드 활성화
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
logger.level = 'debug';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로거 래퍼 함수들 (사용 편의성)
|
||||||
|
export const log = {
|
||||||
|
debug: (message: string, ...meta: any[]) => logger.debug(message, ...meta),
|
||||||
|
info: (message: string, ...meta: any[]) => logger.info(message, ...meta),
|
||||||
|
warn: (message: string, ...meta: any[]) => logger.warn(message, ...meta),
|
||||||
|
error: (message: string, ...meta: any[]) => logger.error(message, ...meta),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Named export (코드베이스 호환성)
|
||||||
|
export { logger };
|
||||||
|
|
||||||
|
// 기본 export
|
||||||
|
export default logger;
|
||||||
|
|
||||||
|
// 로거 초기화 메시지
|
||||||
|
logger.info('Logger initialized', {
|
||||||
|
logDir: LOG_DIR,
|
||||||
|
level: logger.level,
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
});
|
||||||
20
skills/scripts/tsconfig.json
Normal file
20
skills/scripts/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user