commit d80558b1cf47dd33f93b12162cdf02cd1c1a4d3a Author: Zhongwei Li Date: Sat Nov 29 18:18:51 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..47faa74 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -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" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dad483e --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# blender-toolkit + +Blender automation toolkit with CLI for geometry, materials, modifiers, collections, animation retargeting, and WebSocket-based real-time control diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..f9ec409 --- /dev/null +++ b/plugin.lock.json @@ -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": [] + } +} \ No newline at end of file diff --git a/skills/SKILL.md b/skills/SKILL.md new file mode 100644 index 0000000..1697ebe --- /dev/null +++ b/skills/SKILL.md @@ -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 diff --git a/skills/addon/.pylintrc b/skills/addon/.pylintrc new file mode 100644 index 0000000..46e4399 --- /dev/null +++ b/skills/addon/.pylintrc @@ -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*(# )??$ + +# 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 diff --git a/skills/addon/__init__.py b/skills/addon/__init__.py new file mode 100644 index 0000000..b755036 --- /dev/null +++ b/skills/addon/__init__.py @@ -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() diff --git a/skills/addon/commands/__init__.py b/skills/addon/commands/__init__.py new file mode 100644 index 0000000..9df78d2 --- /dev/null +++ b/skills/addon/commands/__init__.py @@ -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', +] diff --git a/skills/addon/commands/animation.py b/skills/addon/commands/animation.py new file mode 100644 index 0000000..26c0006 --- /dev/null +++ b/skills/addon/commands/animation.py @@ -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}" diff --git a/skills/addon/commands/armature.py b/skills/addon/commands/armature.py new file mode 100644 index 0000000..a938d89 --- /dev/null +++ b/skills/addon/commands/armature.py @@ -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 diff --git a/skills/addon/commands/bone_mapping.py b/skills/addon/commands/bone_mapping.py new file mode 100644 index 0000000..5ab8300 --- /dev/null +++ b/skills/addon/commands/bone_mapping.py @@ -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 diff --git a/skills/addon/commands/collection.py b/skills/addon/commands/collection.py new file mode 100644 index 0000000..a127933 --- /dev/null +++ b/skills/addon/commands/collection.py @@ -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"} diff --git a/skills/addon/commands/geometry.py b/skills/addon/commands/geometry.py new file mode 100644 index 0000000..8fc033d --- /dev/null +++ b/skills/addon/commands/geometry.py @@ -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) + } diff --git a/skills/addon/commands/import_.py b/skills/addon/commands/import_.py new file mode 100644 index 0000000..2d51d2b --- /dev/null +++ b/skills/addon/commands/import_.py @@ -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)}") diff --git a/skills/addon/commands/material.py b/skills/addon/commands/material.py new file mode 100644 index 0000000..4d5f6b6 --- /dev/null +++ b/skills/addon/commands/material.py @@ -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 diff --git a/skills/addon/commands/modifier.py b/skills/addon/commands/modifier.py new file mode 100644 index 0000000..968c299 --- /dev/null +++ b/skills/addon/commands/modifier.py @@ -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 + } diff --git a/skills/addon/commands/retargeting.py b/skills/addon/commands/retargeting.py new file mode 100644 index 0000000..ba6a09e --- /dev/null +++ b/skills/addon/commands/retargeting.py @@ -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 diff --git a/skills/addon/pyrightconfig.json b/skills/addon/pyrightconfig.json new file mode 100644 index 0000000..f49e4b8 --- /dev/null +++ b/skills/addon/pyrightconfig.json @@ -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" +} diff --git a/skills/addon/requirements.txt b/skills/addon/requirements.txt new file mode 100644 index 0000000..9e11346 --- /dev/null +++ b/skills/addon/requirements.txt @@ -0,0 +1 @@ +aiohttp>=3.8,<4.0 diff --git a/skills/addon/retargeting.py b/skills/addon/retargeting.py new file mode 100644 index 0000000..2fe4ada --- /dev/null +++ b/skills/addon/retargeting.py @@ -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}" diff --git a/skills/addon/ui.py b/skills/addon/ui.py new file mode 100644 index 0000000..1041041 --- /dev/null +++ b/skills/addon/ui.py @@ -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") diff --git a/skills/addon/utils/__init__.py b/skills/addon/utils/__init__.py new file mode 100644 index 0000000..e772ecc --- /dev/null +++ b/skills/addon/utils/__init__.py @@ -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', +] diff --git a/skills/addon/utils/bone_matching.py b/skills/addon/utils/bone_matching.py new file mode 100644 index 0000000..7c0eba3 --- /dev/null +++ b/skills/addon/utils/bone_matching.py @@ -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' + } diff --git a/skills/addon/utils/logger.py b/skills/addon/utils/logger.py new file mode 100644 index 0000000..b0d99cf --- /dev/null +++ b/skills/addon/utils/logger.py @@ -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) diff --git a/skills/addon/utils/security.py b/skills/addon/utils/security.py new file mode 100644 index 0000000..47cbb67 --- /dev/null +++ b/skills/addon/utils/security.py @@ -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 diff --git a/skills/addon/websocket_server.py b/skills/addon/websocket_server.py new file mode 100644 index 0000000..aac7f4f --- /dev/null +++ b/skills/addon/websocket_server.py @@ -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") diff --git a/skills/references/addon-api-reference.md b/skills/references/addon-api-reference.md new file mode 100644 index 0000000..419e020 --- /dev/null +++ b/skills/references/addon-api-reference.md @@ -0,0 +1,1229 @@ +# Addon API Reference + +Complete reference for Blender Toolkit Python Addon WebSocket API. + +## Table of Contents + +- [Overview](#overview) +- [WebSocket Protocol](#websocket-protocol) +- [Command Categories](#command-categories) +- [Geometry API](#geometry-api) +- [Object API](#object-api) +- [Material API](#material-api) +- [Collection API](#collection-api) +- [Armature API](#armature-api) +- [Animation API](#animation-api) +- [Bone Mapping API](#bone-mapping-api) +- [Retargeting API](#retargeting-api) +- [Modifier API](#modifier-api) +- [Import API](#import-api) +- [Error Handling](#error-handling) + +--- + +## Overview + +The Blender Toolkit addon provides a WebSocket-based JSON-RPC API for controlling Blender programmatically. + +**Key Features:** +- Real-time Blender control via WebSocket +- JSON-RPC 2.0 protocol +- Comprehensive API coverage +- Type-safe commands +- Error reporting +- Security validation + +**Architecture:** +``` +Client (TypeScript) WebSocket Blender Addon (Python) +───────────────────── ───────── ────────────────────── +BlenderClient.sendCommand() ──────► Command Handler + { β”‚ + method: "Geometry.createCube", β”‚ + params: {...} β–Ό + } Execute Command + β”‚ +Response β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + { + result: {...}, + error: null + } +``` + +--- + +## WebSocket Protocol + +### Connection + +**Endpoint:** +``` +ws://localhost:9400/ +``` + +**Port Range:** 9400-9500 + +**Connection Example:** +```typescript +import { BlenderClient } from './blender/client'; + +const client = new BlenderClient(); +await client.connect(9400); + +// Send command +const result = await client.sendCommand('Geometry.createCube', { + location: [0, 0, 0], + size: 2.0 +}); + +await client.disconnect(); +``` + +### Message Format + +**Request (JSON-RPC 2.0):** +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "Geometry.createCube", + "params": { + "location": [0, 0, 0], + "size": 2.0, + "name": "MyCube" + } +} +``` + +**Response (Success):** +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "name": "Cube", + "location": [0.0, 0.0, 0.0], + "vertices": 8, + "faces": 6 + }, + "error": null +} +``` + +**Response (Error):** +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": null, + "error": { + "code": -32602, + "message": "Invalid params", + "data": "Object 'MyCube' not found" + } +} +``` + +--- + +## Command Categories + +| Category | Description | Commands | +|----------|-------------|----------| +| **Geometry** | Create and modify meshes | createCube, createSphere, subdivide, etc. | +| **Object** | Object manipulation | list, transform, duplicate, delete | +| **Material** | Material management | create, assign, setColor, etc. | +| **Collection** | Collection organization | create, addObject, removeObject | +| **Armature** | Armature operations | getBones, getBoneInfo | +| **Animation** | Animation control | import, bake, addToNLA | +| **BoneMapping** | Bone correspondence | generate, display, apply | +| **Retargeting** | Animation retargeting | Full workflow commands | +| **Modifier** | Modifier management | add, apply, toggle, modify | +| **Import** | File import | importFBX, importDAE | + +--- + +## Geometry API + +### Geometry.createCube + +Create a cube primitive. + +**Method:** `Geometry.createCube` + +**Parameters:** +```typescript +{ + location?: [number, number, number]; // Default: [0, 0, 0] + size?: number; // Default: 2.0 + name?: string; // Optional custom name +} +``` + +**Returns:** +```typescript +{ + name: string; + location: [number, number, number]; + vertices: number; + faces: number; +} +``` + +**Example:** +```python +# Blender Python equivalent +import bpy +bpy.ops.mesh.primitive_cube_add( + size=2.0, + location=(0, 0, 0) +) +``` + +### Geometry.createSphere + +Create a sphere primitive. + +**Method:** `Geometry.createSphere` + +**Parameters:** +```typescript +{ + location?: [number, number, number]; // Default: [0, 0, 0] + radius?: number; // Default: 1.0 + segments?: number; // Default: 32 + ringCount?: number; // Default: 16 + name?: string; +} +``` + +**Returns:** +```typescript +{ + name: string; + location: [number, number, number]; + vertices: number; + faces: number; +} +``` + +### Geometry.createCylinder + +Create a cylinder primitive. + +**Method:** `Geometry.createCylinder` + +**Parameters:** +```typescript +{ + location?: [number, number, number]; + radius?: number; // Default: 1.0 + depth?: number; // Height, default: 2.0 + vertices?: number; // Default: 32 + name?: string; +} +``` + +### Geometry.createPlane + +Create a plane primitive. + +**Method:** `Geometry.createPlane` + +**Parameters:** +```typescript +{ + location?: [number, number, number]; + size?: number; // Default: 2.0 + name?: string; +} +``` + +### Geometry.createCone + +Create a cone primitive. + +**Method:** `Geometry.createCone` + +**Parameters:** +```typescript +{ + location?: [number, number, number]; + radius1?: number; // Base radius + depth?: number; // Height + vertices?: number; + name?: string; +} +``` + +### Geometry.createTorus + +Create a torus primitive. + +**Method:** `Geometry.createTorus` + +**Parameters:** +```typescript +{ + location?: [number, number, number]; + majorRadius?: number; // Default: 1.0 + minorRadius?: number; // Tube thickness, default: 0.25 + majorSegments?: number; // Default: 48 + minorSegments?: number; // Default: 12 + name?: string; +} +``` + +### Geometry.subdivideMesh + +Subdivide a mesh to add detail. + +**Method:** `Geometry.subdivideMesh` + +**Parameters:** +```typescript +{ + name: string; // Object name (required) + cuts?: number; // Subdivision cuts, default: 1 +} +``` + +**Returns:** +```typescript +{ + name: string; + vertices: number; + edges: number; + faces: number; +} +``` + +### Geometry.getVertices + +Get all vertices of a mesh. + +**Method:** `Geometry.getVertices` + +**Parameters:** +```typescript +{ + name: string; // Object name (required) +} +``` + +**Returns:** +```typescript +Array<{ + index: number; + co: [number, number, number]; // Coordinate +}> +``` + +### Geometry.moveVertex + +Move a specific vertex. + +**Method:** `Geometry.moveVertex` + +**Parameters:** +```typescript +{ + objectName: string; + vertexIndex: number; + newPosition: [number, number, number]; +} +``` + +**Returns:** +```typescript +{ + object: string; + vertex_index: number; + position: [number, number, number]; +} +``` + +--- + +## Object API + +### Object.list + +List all objects in the scene. + +**Method:** `Object.list` + +**Parameters:** +```typescript +{ + type?: string; // Filter: MESH, ARMATURE, CAMERA, LIGHT +} +``` + +**Returns:** +```typescript +Array<{ + name: string; + type: string; + location: [number, number, number]; + rotation: [number, number, number]; + scale: [number, number, number]; +}> +``` + +### Object.transform + +Transform an object (move, rotate, scale). + +**Method:** `Object.transform` + +**Parameters:** +```typescript +{ + name: string; // Object name (required) + location?: [number, number, number]; + rotation?: [number, number, number]; // Radians + scale?: [number, number, number]; +} +``` + +**Returns:** +```typescript +{ + name: string; + location: [number, number, number]; + rotation: [number, number, number]; + scale: [number, number, number]; +} +``` + +### Object.duplicate + +Duplicate an object. + +**Method:** `Object.duplicate` + +**Parameters:** +```typescript +{ + name: string; // Source object (required) + newName?: string; + location?: [number, number, number]; +} +``` + +**Returns:** +```typescript +{ + name: string; // New object name + type: string; + location: [number, number, number]; +} +``` + +### Object.delete + +Delete an object. + +**Method:** `Object.delete` + +**Parameters:** +```typescript +{ + name: string; // Object name (required) +} +``` + +**Returns:** +```typescript +{ + message: string; // "Object 'X' deleted successfully" +} +``` + +--- + +## Material API + +### Material.create + +Create a new material. + +**Method:** `Material.create` + +**Parameters:** +```typescript +{ + name: string; // Material name (required) + useNodes?: boolean; // Default: true +} +``` + +**Returns:** +```typescript +{ + name: string; + use_nodes: boolean; +} +``` + +### Material.list + +List all materials. + +**Method:** `Material.list` + +**Parameters:** `{}` + +**Returns:** +```typescript +Array<{ + name: string; + use_nodes: boolean; +}> +``` + +### Material.delete + +Delete a material. + +**Method:** `Material.delete` + +**Parameters:** +```typescript +{ + name: string; // Material name (required) +} +``` + +### Material.assign + +Assign material to object. + +**Method:** `Material.assign` + +**Parameters:** +```typescript +{ + objectName: string; // Object name (required) + materialName: string; // Material name (required) + slotIndex?: number; // Default: 0 +} +``` + +**Returns:** +```typescript +{ + object: string; + material: string; + slot: number; +} +``` + +### Material.listObjectMaterials + +List materials on an object. + +**Method:** `Material.listObjectMaterials` + +**Parameters:** +```typescript +{ + objectName: string; // Object name (required) +} +``` + +**Returns:** +```typescript +Array<{ + slot: number; + material: string | null; +}> +``` + +### Material.setBaseColor + +Set material base color. + +**Method:** `Material.setBaseColor` + +**Parameters:** +```typescript +{ + materialName: string; // Material name (required) + color: [number, number, number, number]; // RGBA (0-1) +} +``` + +**Returns:** +```typescript +{ + material: string; + base_color: [number, number, number, number]; +} +``` + +### Material.setMetallic + +Set metallic value. + +**Method:** `Material.setMetallic` + +**Parameters:** +```typescript +{ + materialName: string; + metallic: number; // 0.0 - 1.0 +} +``` + +### Material.setRoughness + +Set roughness value. + +**Method:** `Material.setRoughness` + +**Parameters:** +```typescript +{ + materialName: string; + roughness: number; // 0.0 - 1.0 +} +``` + +### Material.setEmission + +Set emission color and strength. + +**Method:** `Material.setEmission` + +**Parameters:** +```typescript +{ + materialName: string; + color: [number, number, number, number]; + strength?: number; // Default: 1.0 +} +``` + +### Material.getProperties + +Get all material properties. + +**Method:** `Material.getProperties` + +**Parameters:** +```typescript +{ + materialName: string; +} +``` + +**Returns:** +```typescript +{ + name: string; + use_nodes: boolean; + base_color?: [number, number, number, number]; + metallic?: number; + roughness?: number; + // ... other properties +} +``` + +--- + +## Collection API + +### Collection.create + +Create a new collection. + +**Method:** `Collection.create` + +**Parameters:** +```typescript +{ + name: string; // Collection name (required) +} +``` + +### Collection.list + +List all collections. + +**Method:** `Collection.list` + +**Parameters:** `{}` + +**Returns:** +```typescript +Array<{ + name: string; + objects: number; // Count of objects +}> +``` + +### Collection.addObject + +Add object to collection. + +**Method:** `Collection.addObject` + +**Parameters:** +```typescript +{ + objectName: string; + collectionName: string; +} +``` + +### Collection.removeObject + +Remove object from collection. + +**Method:** `Collection.removeObject` + +**Parameters:** +```typescript +{ + objectName: string; + collectionName: string; +} +``` + +### Collection.delete + +Delete a collection. + +**Method:** `Collection.delete` + +**Parameters:** +```typescript +{ + name: string; +} +``` + +--- + +## Armature API + +### Armature.getBones + +Get all bones in an armature. + +**Method:** `Armature.getBones` + +**Parameters:** +```typescript +{ + armatureName: string; +} +``` + +**Returns:** +```typescript +Array // Bone names +``` + +### Armature.getBoneInfo + +Get detailed bone information. + +**Method:** `Armature.getBoneInfo` + +**Parameters:** +```typescript +{ + armatureName: string; + boneName: string; +} +``` + +**Returns:** +```typescript +{ + name: string; + head: [number, number, number]; + tail: [number, number, number]; + parent: string | null; + children: string[]; +} +``` + +--- + +## Animation API + +### Animation.import + +Import animation from FBX. + +**Method:** `Animation.import` + +**Parameters:** +```typescript +{ + filePath: string; + removeNamespace?: boolean; // Default: true +} +``` + +**Returns:** +```typescript +{ + imported: string; // Armature name + frames: number; +} +``` + +### Animation.bake + +Bake animation to keyframes. + +**Method:** `Animation.bake` + +**Parameters:** +```typescript +{ + armatureName: string; + startFrame: number; + endFrame: number; +} +``` + +### Animation.addToNLA + +Add animation to NLA track. + +**Method:** `Animation.addToNLA` + +**Parameters:** +```typescript +{ + armatureName: string; + trackName: string; + actionName: string; +} +``` + +--- + +## Bone Mapping API + +### BoneMapping.generate + +Generate automatic bone mapping. + +**Method:** `BoneMapping.generate` + +**Parameters:** +```typescript +{ + sourceArmature: string; + targetArmature: string; + threshold?: number; // Default: 0.6 +} +``` + +**Returns:** +```typescript +{ + mapping: Record; // source -> target + quality: { + total_mappings: number; + critical_bones_mapped: string; // "8/9" + quality: string; // excellent/good/fair/poor + summary: string; + }; +} +``` + +### BoneMapping.display + +Display mapping in Blender UI. + +**Method:** `BoneMapping.display` + +**Parameters:** +```typescript +{ + sourceArmature: string; + targetArmature: string; + mapping: Record; + quality: object; +} +``` + +**Effect:** +- Creates UI panel in View3D sidebar +- Shows mapping table +- Allows user editing +- Provides "Apply" button + +### BoneMapping.getUserConfirmation + +Wait for user confirmation. + +**Method:** `BoneMapping.getUserConfirmation` + +**Parameters:** `{}` + +**Returns:** +```typescript +{ + confirmed: boolean; + mapping: Record; // Updated mapping +} +``` + +**Behavior:** +- Blocks until user clicks "Apply Retargeting" +- Returns updated mapping (after user edits) + +--- + +## Retargeting API + +### Retargeting.apply + +Apply retargeting with constraints. + +**Method:** `Retargeting.apply` + +**Parameters:** +```typescript +{ + sourceArmature: string; + targetArmature: string; + mapping: Record; + startFrame: number; + endFrame: number; +} +``` + +**Process:** +1. Creates Copy Rotation constraints +2. Sets constraint targets +3. Bakes animation to keyframes +4. Removes constraints +5. Cleans up + +**Returns:** +```typescript +{ + success: boolean; + frames_baked: number; +} +``` + +### Retargeting.cleanup + +Clean up temporary objects. + +**Method:** `Retargeting.cleanup` + +**Parameters:** +```typescript +{ + sourceArmature: string; +} +``` + +**Effect:** +- Removes imported Mixamo armature +- Cleans up temporary data + +--- + +## Modifier API + +### Modifier.add + +Add a modifier to object. + +**Method:** `Modifier.add` + +**Parameters:** +```typescript +{ + objectName: string; + modifierType: string; // SUBSURF, MIRROR, ARRAY, etc. + name?: string; + properties?: Record; +} +``` + +**Modifier Types:** +- `SUBSURF` - Subdivision Surface +- `MIRROR` - Mirror +- `ARRAY` - Array +- `BEVEL` - Bevel +- `SOLIDIFY` - Solidify +- `BOOLEAN` - Boolean +- And many more... + +### Modifier.apply + +Apply a modifier. + +**Method:** `Modifier.apply` + +**Parameters:** +```typescript +{ + objectName: string; + modifierName: string; +} +``` + +### Modifier.list + +List modifiers on object. + +**Method:** `Modifier.list` + +**Parameters:** +```typescript +{ + objectName: string; +} +``` + +**Returns:** +```typescript +Array<{ + name: string; + type: string; + show_viewport: boolean; + show_render: boolean; + // Type-specific properties +}> +``` + +### Modifier.remove + +Remove a modifier. + +**Method:** `Modifier.remove` + +**Parameters:** +```typescript +{ + objectName: string; + modifierName: string; +} +``` + +### Modifier.toggle + +Toggle modifier visibility. + +**Method:** `Modifier.toggle` + +**Parameters:** +```typescript +{ + objectName: string; + modifierName: string; + viewport?: boolean; + render?: boolean; +} +``` + +### Modifier.modify + +Modify modifier properties. + +**Method:** `Modifier.modify` + +**Parameters:** +```typescript +{ + objectName: string; + modifierName: string; + properties: Record; +} +``` + +**Example Properties:** +```typescript +// Subdivision Surface +{ + levels: 2, + render_levels: 3 +} + +// Bevel +{ + width: 0.1, + segments: 4 +} + +// Array +{ + count: 5, + relative_offset_displace: [2, 0, 0] +} +``` + +### Modifier.getInfo + +Get detailed modifier info. + +**Method:** `Modifier.getInfo` + +**Parameters:** +```typescript +{ + objectName: string; + modifierName: string; +} +``` + +### Modifier.reorder + +Reorder modifier in stack. + +**Method:** `Modifier.reorder` + +**Parameters:** +```typescript +{ + objectName: string; + modifierName: string; + direction: 'UP' | 'DOWN'; +} +``` + +--- + +## Import API + +### Import.importFBX + +Import FBX file. + +**Method:** `Import.importFBX` + +**Parameters:** +```typescript +{ + filePath: string; + useImageSearch?: boolean; + globalScale?: number; + applyTransform?: boolean; + removeNamespace?: boolean; +} +``` + +### Import.importDAE + +Import Collada (DAE) file. + +**Method:** `Import.importDAE` + +**Parameters:** +```typescript +{ + filePath: string; + fixOrientation?: boolean; + importUnits?: boolean; +} +``` + +--- + +## Error Handling + +### Error Codes + +Standard JSON-RPC error codes: + +| Code | Name | Description | +|------|------|-------------| +| `-32700` | Parse error | Invalid JSON | +| `-32600` | Invalid Request | Invalid JSON-RPC | +| `-32601` | Method not found | Unknown method | +| `-32602` | Invalid params | Invalid parameters | +| `-32603` | Internal error | Server error | + +### Custom Error Codes + +| Code | Name | Description | +|------|------|-------------| +| `1001` | Object not found | Specified object doesn't exist | +| `1002` | Invalid operation | Operation not allowed | +| `1003` | File error | File not found or unreadable | +| `1004` | Validation error | Parameter validation failed | + +### Error Response Format + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": null, + "error": { + "code": 1001, + "message": "Object not found", + "data": { + "object_name": "NonExistent", + "details": "No object named 'NonExistent' in scene" + } + } +} +``` + +### Error Handling in Client + +```typescript +try { + const result = await client.sendCommand('Object.transform', { + name: 'NonExistent' + }); +} catch (error) { + if (error.code === 1001) { + console.error('Object not found:', error.data.object_name); + } else { + console.error('Error:', error.message); + } +} +``` + +--- + +## Security + +### Input Validation + +All commands validate inputs: +- Type checking +- Range validation +- Path sanitization +- Object existence verification + +### Path Security + +File paths are validated to prevent: +- Directory traversal (`../../../`) +- Absolute path exploits +- Symbolic link attacks + +### Command Whitelist + +Only registered commands are allowed: +- No arbitrary Python code execution +- No system commands +- No file system write access (except designated dirs) + +--- + +## Performance Tips + +1. **Batch Commands:** Group related operations +2. **Reuse Connections:** Don't reconnect for each command +3. **Use Appropriate Timeouts:** Long operations need longer timeouts +4. **Cache Data:** Store repeated query results +5. **Minimize Data Transfer:** Only request needed data diff --git a/skills/references/bone-mapping-guide.md b/skills/references/bone-mapping-guide.md new file mode 100644 index 0000000..7f4c567 --- /dev/null +++ b/skills/references/bone-mapping-guide.md @@ -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 diff --git a/skills/references/commands-reference.md b/skills/references/commands-reference.md new file mode 100644 index 0000000..e4da1dd --- /dev/null +++ b/skills/references/commands-reference.md @@ -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 ` - X position (default: 0) +- `-y, --y ` - Y position (default: 0) +- `-z, --z ` - Z position (default: 0) +- `-s, --size ` - Cube size (default: 2.0) +- `-n, --name ` - Object name +- `-p, --port ` - 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 ` - X position (default: 0) +- `-y, --y ` - Y position (default: 0) +- `-z, --z ` - Z position (default: 0) +- `-r, --radius ` - Sphere radius (default: 1.0) +- `--segments ` - Number of segments (default: 32) +- `--rings ` - Number of rings (default: 16) +- `-n, --name ` - Object name +- `-p, --port ` - 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 ` - X position (default: 0) +- `-y, --y ` - Y position (default: 0) +- `-z, --z ` - Z position (default: 0) +- `-r, --radius ` - Cylinder radius (default: 1.0) +- `-d, --depth ` - Cylinder height/depth (default: 2.0) +- `--vertices ` - Number of vertices (default: 32) +- `-n, --name ` - Object name +- `-p, --port ` - 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 ` - X position (default: 0) +- `-y, --y ` - Y position (default: 0) +- `-z, --z ` - Z position (default: 0) +- `-s, --size ` - Plane size (default: 2.0) +- `-n, --name ` - Object name +- `-p, --port ` - 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 ` - X position (default: 0) +- `-y, --y ` - Y position (default: 0) +- `-z, --z ` - Z position (default: 0) +- `-r, --radius ` - Cone base radius (default: 1.0) +- `-d, --depth ` - Cone height/depth (default: 2.0) +- `--vertices ` - Number of vertices (default: 32) +- `-n, --name ` - Object name +- `-p, --port ` - 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 ` - X position (default: 0) +- `-y, --y ` - Y position (default: 0) +- `-z, --z ` - Z position (default: 0) +- `--major-radius ` - Major radius (default: 1.0) +- `--minor-radius ` - Minor radius/tube thickness (default: 0.25) +- `--major-segments ` - Major segments (default: 48) +- `--minor-segments ` - Minor segments (default: 12) +- `-n, --name ` - Object name +- `-p, --port ` - 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 ` - Object name **(required)** +- `-c, --cuts ` - Number of subdivision cuts (default: 1) +- `-p, --port ` - 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 ` - Object name **(required)** +- `-p, --port ` - 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 ` - Object name **(required)** +- `-i, --index ` - Vertex index **(required)** +- `-x, --x ` - New X position **(required)** +- `-y, --y ` - New Y position **(required)** +- `-z, --z ` - New Z position **(required)** +- `-p, --port ` - 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 ` - Filter by object type (MESH, ARMATURE, CAMERA, LIGHT) +- `-p, --port ` - 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 ` - Object name **(required)** +- `--loc-x ` - X location +- `--loc-y ` - Y location +- `--loc-z ` - Z location +- `--rot-x ` - X rotation (radians) +- `--rot-y ` - Y rotation (radians) +- `--rot-z ` - Z rotation (radians) +- `--scale-x ` - X scale +- `--scale-y ` - Y scale +- `--scale-z ` - Z scale +- `-p, --port ` - 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 ` - Source object name **(required)** +- `--new-name ` - New object name +- `-x, --x ` - X position for duplicate +- `-y, --y ` - Y position for duplicate +- `-z, --z ` - Z position for duplicate +- `-p, --port ` - 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 ` - Object name **(required)** +- `-p, --port ` - 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 ` - Object name **(required)** +- `-t, --type ` - Modifier type (SUBSURF, MIRROR, ARRAY, BEVEL, etc.) **(required)** +- `--mod-name ` - Modifier name +- `--levels ` - Subdivision levels (for SUBSURF) +- `--render-levels ` - Render levels (for SUBSURF) +- `-p, --port ` - 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 ` - Object name **(required)** +- `-m, --modifier ` - Modifier name **(required)** +- `-p, --port ` - 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 ` - Object name **(required)** +- `-p, --port ` - 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 ` - Object name **(required)** +- `-m, --modifier ` - Modifier name **(required)** +- `-p, --port ` - 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 ` - Object name **(required)** +- `-m, --modifier ` - Modifier name **(required)** +- `--viewport ` - Viewport visibility (true/false) +- `--render ` - Render visibility (true/false) +- `-p, --port ` - 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 ` - Object name **(required)** +- `-m, --modifier ` - Modifier name **(required)** +- `--levels ` - Subdivision levels +- `--render-levels ` - Render levels +- `--width ` - Bevel width +- `--segments ` - Bevel segments +- `--count ` - Array count +- `-p, --port ` - 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 ` - Object name **(required)** +- `-m, --modifier ` - Modifier name **(required)** +- `-p, --port ` - 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 ` - Object name **(required)** +- `-m, --modifier ` - Modifier name **(required)** +- `-d, --direction ` - Direction (UP or DOWN) **(required)** +- `-p, --port ` - 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 ` - 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 ` - 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 ` - Object name **(required)** +- `--material ` - Material name **(required)** +- `--slot ` - 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 ` - 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 ` - Material name **(required)** +- `--r ` - Red (0-1) **(required)** +- `--g ` - Green (0-1) **(required)** +- `--b ` - Blue (0-1) **(required)** +- `--a ` - 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 ` - Material name **(required)** +- `--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 ` - Material name **(required)** +- `--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 ` - Material name **(required)** +- `--r ` - Red (0-1) **(required)** +- `--g ` - Green (0-1) **(required)** +- `--b ` - Blue (0-1) **(required)** +- `--strength ` - 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 ` - 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 ` - 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 ` - Object name **(required)** +- `--collection ` - 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 ` - Object name **(required)** +- `--collection ` - 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 ` - 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 ` - Target character armature name **(required)** +- `-f, --file ` - Animation file path (FBX or DAE) **(required)** +- `-n, --name ` - Animation name for NLA track +- `-m, --mapping ` - Bone mapping mode (auto, mixamo_to_rigify, custom) (default: auto) +- `--skip-confirmation` - Skip bone mapping confirmation (default: false) +- `-p, --port ` - Blender WebSocket port (default: 9400) +- `-o, --output ` - 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 ` - 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 ` - 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 ` - Port number (default: 9400) + +**Example:** +```bash +blender-toolkit daemon-status +``` + +--- + +## Global Options + +Options available for all commands: + +- `-p, --port ` - 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 --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 diff --git a/skills/references/workflow-guide.md b/skills/references/workflow-guide.md new file mode 100644 index 0000000..edd1592 --- /dev/null +++ b/skills/references/workflow-guide.md @@ -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 diff --git a/skills/scripts/eslint.config.mjs b/skills/scripts/eslint.config.mjs new file mode 100644 index 0000000..9120835 --- /dev/null +++ b/skills/scripts/eslint.config.mjs @@ -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' + } + } +]; diff --git a/skills/scripts/install-addon.py b/skills/scripts/install-addon.py new file mode 100755 index 0000000..0e0be4a --- /dev/null +++ b/skills/scripts/install-addon.py @@ -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 ") + + 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) diff --git a/skills/scripts/package.json b/skills/scripts/package.json new file mode 100644 index 0000000..409f8a2 --- /dev/null +++ b/skills/scripts/package.json @@ -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" + } +} diff --git a/skills/scripts/src/blender/client.ts b/skills/scripts/src/blender/client.ts new file mode 100644 index 0000000..ef03834 --- /dev/null +++ b/skills/scripts/src/blender/client.ts @@ -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 { + // 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( + method: string, + params?: unknown + ): Promise { + 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 { + 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; + } +} diff --git a/skills/scripts/src/blender/config.ts b/skills/scripts/src/blender/config.ts new file mode 100644 index 0000000..022240d --- /dev/null +++ b/skills/scripts/src/blender/config.ts @@ -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 { + 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 { + 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 { + 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 { + 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}` + ); +} diff --git a/skills/scripts/src/blender/mixamo.ts b/skills/scripts/src/blender/mixamo.ts new file mode 100644 index 0000000..e58d376 --- /dev/null +++ b/skills/scripts/src/blender/mixamo.ts @@ -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, + }; + } +} diff --git a/skills/scripts/src/blender/retargeting.ts b/skills/scripts/src/blender/retargeting.ts new file mode 100644 index 0000000..8f37066 --- /dev/null +++ b/skills/scripts/src/blender/retargeting.ts @@ -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; + 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 { + return await this.client.sendCommand('Armature.getBones', { + armatureName, + }); + } + + /** + * μžλ™ λ³Έ λ§€ν•‘ 생성 + * Mixamo λ³Έ 이름과 μ‚¬μš©μž 캐릭터 λ³Έ 이름을 λ§€μΉ­ + */ + async autoMapBones( + sourceArmature: string, + targetArmature: string + ): Promise> { + return await this.client.sendCommand>( + 'Retargeting.autoMapBones', + { + sourceArmature, + targetArmature, + } + ); + } + + /** + * μ• λ‹ˆλ©”μ΄μ…˜ λ¦¬νƒ€κ²ŒνŒ… μ‹€ν–‰ + */ + async retarget(options: RetargetOptions): Promise { + const { + sourceArmature, + targetArmature, + boneMapping = 'auto', + customBoneMap, + preserveRotation = true, + preserveLocation = false, + } = options; + + // λ³Έ λ§€ν•‘ 생성 + let boneMap: Record; + + 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>( + '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 { + await this.client.sendCommand('Animation.addToNLA', { + armatureName, + actionName, + trackName: trackName || `Mixamo_${Date.now()}`, + }); + } + + /** + * μ• λ‹ˆλ©”μ΄μ…˜ 클립 λͺ©λ‘ κ°€μ Έμ˜€κΈ° + */ + async getAnimations(armatureName: string): Promise { + return await this.client.sendCommand('Animation.list', { + armatureName, + }); + } + + /** + * μ• λ‹ˆλ©”μ΄μ…˜ 미리보기 μž¬μƒ + */ + async playAnimation( + armatureName: string, + actionName: string, + loop: boolean = true + ): Promise { + await this.client.sendCommand('Animation.play', { + armatureName, + actionName, + loop, + }); + } + + /** + * μ• λ‹ˆλ©”μ΄μ…˜ μ •μ§€ + */ + async stopAnimation(): Promise { + await this.client.sendCommand('Animation.stop'); + } +} + +// BlenderClient에 timeout νŒŒλΌλ―Έν„° μΆ”κ°€λ₯Ό μœ„ν•œ νƒ€μž… ν™•μž₯ +declare module './client' { + interface BlenderClient { + sendCommand>( + method: string, + params?: unknown, + timeout?: number + ): Promise; + } +} diff --git a/skills/scripts/src/cli/cli.ts b/skills/scripts/src/cli/cli.ts new file mode 100644 index 0000000..abdcf18 --- /dev/null +++ b/skills/scripts/src/cli/cli.ts @@ -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 " --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(); diff --git a/skills/scripts/src/cli/commands/collection.ts b/skills/scripts/src/cli/commands/collection.ts new file mode 100644 index 0000000..ebb0c6a --- /dev/null +++ b/skills/scripts/src/cli/commands/collection.ts @@ -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 ', '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 ', 'Object name') + .requiredOption('--collection ', '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 ', 'Object name') + .requiredOption('--collection ', '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 ', '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(); + } + }); +} diff --git a/skills/scripts/src/cli/commands/daemon.ts b/skills/scripts/src/cli/commands/daemon.ts new file mode 100644 index 0000000..929deb5 --- /dev/null +++ b/skills/scripts/src/cli/commands/daemon.ts @@ -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 ', '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 '); + } 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 ', '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); + } + }); +} diff --git a/skills/scripts/src/cli/commands/geometry.ts b/skills/scripts/src/cli/commands/geometry.ts new file mode 100644 index 0000000..3307550 --- /dev/null +++ b/skills/scripts/src/cli/commands/geometry.ts @@ -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 ', 'X position', parseFloat, 0) + .option('-y, --y ', 'Y position', parseFloat, 0) + .option('-z, --z ', 'Z position', parseFloat, 0) + .option('-s, --size ', 'Cube size', parseFloat, 2.0) + .option('-n, --name ', 'Object name') + .option('-p, --port ', '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 ', 'X position', parseFloat, 0) + .option('-y, --y ', 'Y position', parseFloat, 0) + .option('-z, --z ', 'Z position', parseFloat, 0) + .option('-r, --radius ', 'Sphere radius', parseFloat, 1.0) + .option('--segments ', 'Number of segments', parseInt, 32) + .option('--rings ', 'Number of rings', parseInt, 16) + .option('-n, --name ', 'Object name') + .option('-p, --port ', '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 ', 'X position', parseFloat, 0) + .option('-y, --y ', 'Y position', parseFloat, 0) + .option('-z, --z ', 'Z position', parseFloat, 0) + .option('-r, --radius ', 'Cylinder radius', parseFloat, 1.0) + .option('-d, --depth ', 'Cylinder height/depth', parseFloat, 2.0) + .option('--vertices ', 'Number of vertices', parseInt, 32) + .option('-n, --name ', 'Object name') + .option('-p, --port ', '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 ', 'X position', parseFloat, 0) + .option('-y, --y ', 'Y position', parseFloat, 0) + .option('-z, --z ', 'Z position', parseFloat, 0) + .option('-s, --size ', 'Plane size', parseFloat, 2.0) + .option('-n, --name ', 'Object name') + .option('-p, --port ', '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 ', 'X position', parseFloat, 0) + .option('-y, --y ', 'Y position', parseFloat, 0) + .option('-z, --z ', 'Z position', parseFloat, 0) + .option('-r, --radius ', 'Cone base radius', parseFloat, 1.0) + .option('-d, --depth ', 'Cone height/depth', parseFloat, 2.0) + .option('--vertices ', 'Number of vertices', parseInt, 32) + .option('-n, --name ', 'Object name') + .option('-p, --port ', '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 ', 'X position', parseFloat, 0) + .option('-y, --y ', 'Y position', parseFloat, 0) + .option('-z, --z ', 'Z position', parseFloat, 0) + .option('--major-radius ', 'Major radius', parseFloat, 1.0) + .option('--minor-radius ', 'Minor radius (tube thickness)', parseFloat, 0.25) + .option('--major-segments ', 'Major segments', parseInt, 48) + .option('--minor-segments ', 'Minor segments', parseInt, 12) + .option('-n, --name ', 'Object name') + .option('-p, --port ', '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 ', 'Object name') + .option('-c, --cuts ', 'Number of subdivision cuts', parseInt, 1) + .option('-p, --port ', '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 ', 'Object name') + .option('-p, --port ', '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 ', 'Object name') + .requiredOption('-i, --index ', 'Vertex index', parseInt) + .requiredOption('-x, --x ', 'New X position', parseFloat) + .requiredOption('-y, --y ', 'New Y position', parseFloat) + .requiredOption('-z, --z ', 'New Z position', parseFloat) + .option('-p, --port ', '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); + } + }); +} diff --git a/skills/scripts/src/cli/commands/material.ts b/skills/scripts/src/cli/commands/material.ts new file mode 100644 index 0000000..37790b9 --- /dev/null +++ b/skills/scripts/src/cli/commands/material.ts @@ -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 ', '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 ', '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 ', 'Object name') + .requiredOption('--material ', 'Material name') + .option('--slot ', '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 ', '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 ', 'Material name') + .requiredOption('--r ', 'Red (0-1)', parseFloat) + .requiredOption('--g ', 'Green (0-1)', parseFloat) + .requiredOption('--b ', 'Blue (0-1)', parseFloat) + .option('--a ', '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 ', 'Material name') + .requiredOption('--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 ', 'Material name') + .requiredOption('--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 ', 'Material name') + .requiredOption('--r ', 'Red (0-1)', parseFloat) + .requiredOption('--g ', 'Green (0-1)', parseFloat) + .requiredOption('--b ', 'Blue (0-1)', parseFloat) + .option('--strength ', '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 ', '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(); + } + }); +} diff --git a/skills/scripts/src/cli/commands/modifier.ts b/skills/scripts/src/cli/commands/modifier.ts new file mode 100644 index 0000000..447fb9f --- /dev/null +++ b/skills/scripts/src/cli/commands/modifier.ts @@ -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 ', 'Object name') + .requiredOption('-t, --type ', 'Modifier type (SUBSURF, MIRROR, ARRAY, BEVEL, etc.)') + .option('--mod-name ', 'Modifier name') + .option('--levels ', 'Subdivision levels (for SUBSURF)', parseInt) + .option('--render-levels ', 'Render levels (for SUBSURF)', parseInt) + .option('-p, --port ', '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 ', 'Object name') + .requiredOption('-m, --modifier ', 'Modifier name') + .option('-p, --port ', '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 ', 'Object name') + .option('-p, --port ', '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 ', 'Object name') + .requiredOption('-m, --modifier ', 'Modifier name') + .option('-p, --port ', '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 ', 'Object name') + .requiredOption('-m, --modifier ', 'Modifier name') + .option('--viewport ', 'Viewport visibility (true/false)') + .option('--render ', 'Render visibility (true/false)') + .option('-p, --port ', '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 ', 'Object name') + .requiredOption('-m, --modifier ', 'Modifier name') + .option('--levels ', 'Subdivision levels', parseInt) + .option('--render-levels ', 'Render levels', parseInt) + .option('--width ', 'Bevel width', parseFloat) + .option('--segments ', 'Bevel segments', parseInt) + .option('--count ', 'Array count', parseInt) + .option('-p, --port ', '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 ', 'Object name') + .requiredOption('-m, --modifier ', 'Modifier name') + .option('-p, --port ', '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 ', 'Object name') + .requiredOption('-m, --modifier ', 'Modifier name') + .requiredOption('-d, --direction ', 'Direction (UP or DOWN)') + .option('-p, --port ', '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); + } + }); +} diff --git a/skills/scripts/src/cli/commands/object.ts b/skills/scripts/src/cli/commands/object.ts new file mode 100644 index 0000000..baafe75 --- /dev/null +++ b/skills/scripts/src/cli/commands/object.ts @@ -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 ', 'Filter by object type (MESH, ARMATURE, CAMERA, LIGHT)') + .option('-p, --port ', '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 ', 'Object name') + .option('--loc-x ', 'X location', parseFloat) + .option('--loc-y ', 'Y location', parseFloat) + .option('--loc-z ', 'Z location', parseFloat) + .option('--rot-x ', 'X rotation (radians)', parseFloat) + .option('--rot-y ', 'Y rotation (radians)', parseFloat) + .option('--rot-z ', 'Z rotation (radians)', parseFloat) + .option('--scale-x ', 'X scale', parseFloat) + .option('--scale-y ', 'Y scale', parseFloat) + .option('--scale-z ', 'Z scale', parseFloat) + .option('-p, --port ', '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 ', 'Source object name') + .option('--new-name ', 'New object name') + .option('-x, --x ', 'X position for duplicate', parseFloat) + .option('-y, --y ', 'Y position for duplicate', parseFloat) + .option('-z, --z ', 'Z position for duplicate', parseFloat) + .option('-p, --port ', '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 ', 'Object name') + .option('-p, --port ', '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); + } + }); +} diff --git a/skills/scripts/src/cli/commands/retargeting.ts b/skills/scripts/src/cli/commands/retargeting.ts new file mode 100644 index 0000000..8fc5b5d --- /dev/null +++ b/skills/scripts/src/cli/commands/retargeting.ts @@ -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 ', 'Target character armature name') + .requiredOption('-f, --file ', 'Animation file path (FBX or DAE)') + .option('-n, --name ', 'Animation name for NLA track') + .option('-m, --mapping ', 'Bone mapping mode (auto, mixamo_to_rigify, custom)', 'auto') + .option('--skip-confirmation', 'Skip bone mapping confirmation', false) + .option('-p, --port ', 'Blender WebSocket port', parseInt, 9400) + .option('-o, --output ', '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}`); + }); + }); +} diff --git a/skills/scripts/src/constants/index.ts b/skills/scripts/src/constants/index.ts new file mode 100644 index 0000000..c1477a7 --- /dev/null +++ b/skills/scripts/src/constants/index.ts @@ -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; diff --git a/skills/scripts/src/daemon/client.ts b/skills/scripts/src/daemon/client.ts new file mode 100644 index 0000000..fb2f523 --- /dev/null +++ b/skills/scripts/src/daemon/client.ts @@ -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 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 { + 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 = {}, timeout: number = DAEMON.IPC_TIMEOUT): Promise { + 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')); + } +} diff --git a/skills/scripts/src/daemon/manager.ts b/skills/scripts/src/daemon/manager.ts new file mode 100644 index 0000000..a351890 --- /dev/null +++ b/skills/scripts/src/daemon/manager.ts @@ -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 { + 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('=€ 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 { + 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 { + const { verbose = true, force = false } = options; + + if (!(await this.isRunning())) { + if (verbose) { + console.log('Daemon is not running'); + } + return; + } + + if (verbose) { + console.log('=Ρ 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('  Graceful shutdown failed, force killing...'); + } + await this.forceKill(); + } + } + + if (verbose) { + console.log(' Daemon stopped'); + } + } + + /** + * Force kill daemon process + */ + private async forceKill(): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/skills/scripts/src/daemon/protocol.ts b/skills/scripts/src/daemon/protocol.ts new file mode 100644 index 0000000..1d9e664 --- /dev/null +++ b/skills/scripts/src/daemon/protocol.ts @@ -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; + 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]; diff --git a/skills/scripts/src/daemon/server.ts b/skills/scripts/src/daemon/server.ts new file mode 100644 index 0000000..171883e --- /dev/null +++ b/skills/scripts/src/daemon/server.ts @@ -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 = new Set(); + // Browser Pilot νŒ¨ν„΄: shutdown Promise (race condition λ°©μ§€) + private shutdownPromise: Promise | 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 { + 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 { + 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 { + 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): Promise { + 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; + + // 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 { + // 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 { + 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((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; diff --git a/skills/scripts/src/index.ts b/skills/scripts/src/index.ts new file mode 100644 index 0000000..752af40 --- /dev/null +++ b/skills/scripts/src/index.ts @@ -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; + + // 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 { + 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; + + 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>( + '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 { + 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 { + return await this.blenderClient.sendCommand('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 + }); +} diff --git a/skills/scripts/src/utils/logger.ts b/skills/scripts/src/utils/logger.ts new file mode 100644 index 0000000..25abd5c --- /dev/null +++ b/skills/scripts/src/utils/logger.ts @@ -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', +}); diff --git a/skills/scripts/tsconfig.json b/skills/scripts/tsconfig.json new file mode 100644 index 0000000..bbe9f1a --- /dev/null +++ b/skills/scripts/tsconfig.json @@ -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"] +}