Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:18:51 +08:00
commit d80558b1cf
52 changed files with 12920 additions and 0 deletions

601
skills/SKILL.md Normal file
View File

@@ -0,0 +1,601 @@
---
name: blender-toolkit
description: |
Blender automation with geometry creation, materials, modifiers, and Mixamo animation retargeting.
Core Features: WebSocket-based real-time control, automatic bone mapping with UI review, two-phase confirmation workflow, quality assessment, multi-project support, comprehensive CLI commands.
Use Cases: Create 3D primitives (cube, sphere, cylinder, etc.), manipulate objects (transform, duplicate, delete), manage materials and modifiers, retarget Mixamo animations to custom rigs with fuzzy bone matching.
allowed-tools: Bash, Read, Write, Glob
---
## ⚠️ Installation Check (READ THIS FIRST)
**IMPORTANT**: Before using this skill, check Blender addon installation status.
**Config location**: Check the shared config file for your installation status:
```
~/.claude/plugins/marketplaces/dev-gom-plugins/blender-config.json
```
**Always run scripts with `--help` first** to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window.
**Required actions based on config**:
### 1. If Blender Not Detected (`blenderExecutable: null`)
Blender was not found during initialization. Please:
1. **Install Blender 4.0+** from https://www.blender.org
2. **Restart Claude Code session** to trigger auto-detection
3. Check logs: `.blender-toolkit/init-log.txt`
### 2. If Multiple Versions Detected (`detectedBlenderVersions` array)
The system detected multiple Blender installations. If you want to use a different version:
1. **Open config file** (path shown above)
2. **Edit `blenderExecutable`** field to your preferred version path
3. **Restart Claude Code session**
Example:
```json
{
"detectedBlenderVersions": [
{"version": "4.2.0", "path": "C:\\Program Files\\Blender Foundation\\Blender 4.2\\blender.exe"},
{"version": "4.1.0", "path": "C:\\Program Files\\Blender Foundation\\Blender 4.1\\blender.exe"}
],
"blenderExecutable": "C:\\Program Files\\Blender Foundation\\Blender 4.2\\blender.exe"
}
```
### 3. If Addon Not Installed (`addonInstalled: false`)
The addon needs to be installed manually. Follow these steps:
**Manual Installation Steps**:
**Method 1: Install from ZIP (Recommended)**
```bash
# 1. Open Blender 4.0+
# 2. Edit > Preferences > Add-ons > Install
# 3. Select: .blender-toolkit/blender-toolkit-addon-v*.zip
# 4. Enable "Blender Toolkit WebSocket Server"
```
**Method 2: Install from Source**
```bash
# 1. Open Blender 4.0+
# 2. Edit > Preferences > Add-ons > Install
# 3. Select: plugins/blender-toolkit/skills/addon/__init__.py
# 4. Enable "Blender Toolkit WebSocket Server"
```
**Start WebSocket Server**:
1. Open 3D View → Sidebar (press N key)
2. Find "Blender Toolkit" tab
3. Click "Start Server" button
4. Default port: 9400 (auto-assigned per project)
**Update Config**:
- Open config file (path shown above)
- Set `"addonInstalled": true`
- Save file
**Verify Connection**:
- Try a simple command: `node .blender-toolkit/bt.js list-objects`
- If successful, you'll see a list of objects in your scene
**Troubleshooting**:
- If Blender path is incorrect: Update `blenderExecutable` in config
- If port is in use: System will auto-assign next available port (9401-9500)
- Check logs: `.blender-toolkit/init-log.txt`
- Check Blender console for error messages
### 4. If Everything is Ready (`addonInstalled: true`)
✅ You're all set! You can use all Blender Toolkit commands.
---
# blender-toolkit
Automate Blender workflows with WebSocket-based real-time control. Create geometry, manage materials and modifiers, and retarget Mixamo animations to custom rigs with intelligent bone mapping.
## Purpose
Provide comprehensive Blender automation through:
- 🎨 **Geometry Creation** - Primitives (cube, sphere, cylinder, plane, cone, torus)
- 🎭 **Material Management** - Create, assign, and configure materials
- 🔧 **Modifier Control** - Add, apply, and manage modifiers
- 🎬 **Animation Retargeting** - Mixamo to custom rigs with automatic bone mapping
## When to Use
Use this skill when:
- **Creating 3D Geometry:** User wants to create primitives or manipulate meshes
- **Managing Materials:** User needs to create or assign materials with PBR properties
- **Adding Modifiers:** User wants subdivision, mirror, array, or other modifiers
- **Retargeting Animations:** User needs to apply Mixamo animations to custom characters
- **Batch Operations:** User wants to process multiple objects or animations
**Note:** Mixamo does not provide an official API. Users must manually download FBX files from Mixamo.com.
## Quick Start
### Prerequisites Checklist
Before starting, ensure:
- [ ] Blender 4.0+ installed
- [ ] Blender Toolkit addon installed and enabled
- [ ] WebSocket server started in Blender (default port: 9400)
- [ ] Character rig loaded (for animation retargeting)
**Install Addon:**
```
1. Open Blender → Edit → Preferences → Add-ons
2. Click "Install" → Select plugins/blender-toolkit/skills/addon/__init__.py
3. Enable "Blender Toolkit WebSocket Server"
4. Start server: View3D → Sidebar (N) → "Blender Toolkit" → "Start Server"
```
### Common Operations
**Create Geometry:**
```bash
# Create cube at origin
blender-toolkit create-cube --size 2.0
# Create sphere with custom settings
blender-toolkit create-sphere --radius 1.5 --segments 64
# Subdivide mesh
blender-toolkit subdivide --name "Cube" --cuts 2
```
**Manage Objects:**
```bash
# List all objects
blender-toolkit list-objects
# Transform object
blender-toolkit transform --name "Cube" --loc-x 5 --loc-y 0 --scale-x 2
# Duplicate object
blender-toolkit duplicate --name "Cube" --new-name "Cube.001" --x 3
```
**Materials:**
```bash
# Create material
blender-toolkit material create --name "RedMaterial"
# Assign to object
blender-toolkit material assign --object "Cube" --material "RedMaterial"
# Set color
blender-toolkit material set-color --material "RedMaterial" --r 1.0 --g 0.0 --b 0.0
```
**Retarget Animation:**
```bash
# Basic retargeting with UI confirmation
blender-toolkit retarget \
--target "HeroRig" \
--file "./Walking.fbx" \
--name "Walking"
# Rigify preset (skip confirmation)
blender-toolkit retarget \
--target "MyRigifyCharacter" \
--file "./Walking.fbx" \
--mapping mixamo_to_rigify \
--skip-confirmation
# Show Mixamo download instructions
blender-toolkit mixamo-help Walking
```
## Architecture
**WebSocket-Based Design:**
```
┌──────────────┐ ┌─────────────┐ WebSocket ┌──────────────┐
│ Claude Code │ IPC │ TypeScript │◄──────────────►│ Blender │
│ (Skill) │────────►│ Client │ Port 9400+ │ (Addon) │
└──────────────┘ └─────────────┘ └──────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌────────────────────┐
│ - Geometry │ │ - WebSocket │
│ - Material │ │ Server │
│ - Modifier │ │ - Command │
│ - Retargeting │ │ Handlers │
│ - Bone Mapping │ │ - Bone Mapping UI │
└─────────────────┘ └────────────────────┘
```
**Key Components:**
- **WebSocket Server:** Python addon in Blender (ports 9400-9500)
- **TypeScript Client:** Sends commands via JSON-RPC
- **Bone Mapping System:** Fuzzy matching with UI confirmation
- **Two-Phase Workflow:** Generate → Review → Apply
## Core Workflows
### 1. Geometry Creation Workflow
**Extract Requirements:**
- Primitive type (cube, sphere, cylinder, etc.)
- Position (x, y, z coordinates)
- Size parameters (radius, depth, segments)
- Optional object name
**Execute:**
```typescript
import { BlenderClient } from 'blender-toolkit';
const client = new BlenderClient();
await client.connect(9400);
// Create sphere
const result = await client.sendCommand('Geometry.createSphere', {
location: [0, 0, 2],
radius: 1.5,
segments: 64,
name: 'MySphere'
});
console.log(`✅ Created ${result.name} with ${result.vertices} vertices`);
```
### 2. Material Assignment Workflow
**Steps:**
1. Create material
2. Assign to object
3. Configure properties (color, metallic, roughness)
**Execute:**
```bash
# Create and configure material
blender-toolkit material create --name "Metal"
blender-toolkit material set-color --material "Metal" --r 0.8 --g 0.8 --b 0.8
blender-toolkit material set-metallic --material "Metal" --value 1.0
blender-toolkit material set-roughness --material "Metal" --value 0.2
# Assign to object
blender-toolkit material assign --object "Sphere" --material "Metal"
```
### 3. Animation Retargeting Workflow ⭐
**Most Common Use Case**
**Phase 1: Setup & Generate Mapping**
```
1. User provides:
- Target character armature name
- Animation FBX file path
- (Optional) Animation name for NLA track
2. System executes:
- Connects to Blender WebSocket
- Imports FBX file
- Analyzes bone structure
- Auto-generates bone mapping (fuzzy matching)
- Displays mapping in Blender UI for review
3. Quality Assessment:
- Excellent (8-9 critical bones) → Safe to auto-apply
- Good (6-7 critical bones) → Quick review recommended
- Fair (4-5 critical bones) → Thorough review required
- Poor (< 4 critical bones) → Manual mapping needed
```
**Phase 2: User Confirmation**
```
1. User reviews mapping in Blender:
- View3D → Sidebar (N) → "Blender Toolkit" → "Bone Mapping Review"
- Check source → target correspondence
- Edit incorrect mappings using dropdowns
- Use "Auto Re-map" button to regenerate if needed
2. User confirms:
- Click "Apply Retargeting" button in Blender
3. System completes:
- Creates constraint-based retargeting
- Bakes animation to keyframes
- Adds to NLA track
- Cleans up temporary objects
```
**Example:**
```typescript
import { AnimationRetargetingWorkflow } from 'blender-toolkit';
const workflow = new AnimationRetargetingWorkflow();
// If user doesn't have FBX yet
console.log(workflow.getManualDownloadInstructions('Walking'));
// After user downloads FBX
await workflow.run({
targetCharacterArmature: 'HeroRig',
animationFilePath: './Walking.fbx',
animationName: 'Walking',
boneMapping: 'auto', // Auto-generate with fuzzy matching
skipConfirmation: false // Enable UI review workflow
});
```
**Skip Confirmation (For Known-Good Mappings):**
```bash
# Rigify preset - instant application
blender-toolkit retarget \
--target "RigifyCharacter" \
--file "./Walking.fbx" \
--mapping mixamo_to_rigify \
--skip-confirmation
# Excellent quality - trusted auto-mapping
blender-toolkit retarget \
--target "MyCharacter" \
--file "./Walking.fbx" \
--skip-confirmation
```
## Key Features
### Auto Bone Mapping with UI Review 🌟
**Recommended Workflow** for unknown or custom rigs:
**How It Works:**
1. **Fuzzy Matching Algorithm**
- Normalizes bone names (handles various conventions)
- Calculates similarity scores (0.0-1.0)
- Applies bonuses for:
- Substring matches (+0.15)
- Common prefixes: left, right (+0.1)
- Common suffixes: .L, .R, _l, _r (+0.1)
- Number matching: Spine1, Spine2 (+0.1)
- Anatomical keywords: arm, leg, hand (+0.05)
2. **Quality Assessment**
- Tracks 9 critical bones (Hips, Spine, Head, Arms, Legs, Hands)
- Provides quality rating (Excellent/Good/Fair/Poor)
- Recommends action based on quality
3. **UI Confirmation Panel**
- Shows complete mapping table
- Editable dropdowns for each mapping
- "Auto Re-map" button (regenerate)
- "Apply Retargeting" button (proceed)
**Benefits:**
- Works with any rig structure
- No manual configuration needed
- User verifies before application
- Prevents animation errors
### Three Bone Mapping Modes
**1. Auto Mode (Recommended)**
```bash
# Default: Auto-generate with UI confirmation
blender-toolkit retarget --target "Hero" --file "./Walk.fbx"
```
- Fuzzy matching algorithm
- UI review workflow
- Best for unknown rigs
**2. Rigify Mode**
```bash
# Preset for Rigify control rigs
blender-toolkit retarget --target "Hero" --file "./Walk.fbx" --mapping mixamo_to_rigify
```
- Predefined Mixamo → Rigify mapping
- Instant application
- Highest accuracy for Rigify
**3. Custom Mode**
```typescript
// Explicit bone mapping
const customMapping = {
"Hips": "root_bone",
"Spine": "torso_01",
"LeftArm": "l_upper_arm",
// ... complete mapping
};
await workflow.run({
boneMapping: customMapping,
skipConfirmation: true
});
```
- Full control
- Reusable across animations
- For non-standard rigs
### Multi-Project Support
**Automatic Port Management:**
- Projects automatically assigned unique ports (9400-9500)
- Configuration persists across sessions
- Multiple Blender instances can run simultaneously
**Configuration Storage:**
```json
// ~/.claude/plugins/.../blender-config.json
{
"projects": {
"/path/to/project-a": { "port": 9400 },
"/path/to/project-b": { "port": 9401 }
}
}
```
## Important Guidelines
### When to Ask User
Use `AskUserQuestion` tool if:
- Character armature name is unclear
- Multiple rigs exist (ambiguous target)
- Animation FBX path not provided
- Blender WebSocket connection fails
- User needs Mixamo download guidance
**DO NOT** guess:
- Character names
- File paths
- Rig structures
### Mixamo Download Process
Since Mixamo has no API, users must manually download:
**Provide Instructions:**
```typescript
// Show download help
const workflow = new AnimationRetargetingWorkflow();
console.log(workflow.getManualDownloadInstructions('Walking'));
console.log(workflow.getRecommendedSettings());
```
**Wait for User:**
- Guide user through Mixamo.com download
- Get file path after download completes
- Then proceed with retargeting
## Troubleshooting
### "Blender is not running"
```bash
# Check connection
blender-toolkit daemon-status
# If failed:
1. Verify Blender is open
2. Check addon is enabled
3. Start server: Blender → N → "Blender Toolkit""Start Server"
```
### "Target armature not found"
- Verify exact armature name (case-sensitive)
- Check character is in current scene
- Use `list-objects --type ARMATURE` to see available armatures
### "Poor quality" bone mapping
1. Review bone names in Blender (Edit Mode)
2. Create custom mapping for critical bones
3. Lower similarity threshold (default: 0.6)
4. Check rig has proper hierarchy
### "Twisted or inverted limbs"
- Check left/right bone mapping
- Verify bone roll in Edit Mode
- Review constraint axes
- Test with simple animation first
## Best Practices
1. **🌟 Use Auto Mode with UI Confirmation**
- Most reliable for unknown rigs
- Always review critical bones (Hips, Spine, Arms, Legs)
- Edit incorrect mappings before applying
2. **Test Simple Animations First**
- Start with Idle or Walking
- Verify bone mapping works correctly
- Check root motion (Hips bone)
- Then proceed to complex animations
3. **Download Correct Format from Mixamo**
- Format: FBX (.fbx)
- Skin: Without Skin
- FPS: 30 fps
- Keyframe Reduction: None
4. **Check Quality Before Auto-Apply**
- Excellent (8-9 critical) → Safe to skip confirmation
- Good (6-7 critical) → Quick review
- Fair (4-5 critical) → Thorough review
- Poor (< 4 critical) → Use custom mapping
5. **Save Custom Mappings for Reuse**
- Document successful mappings
- Reuse for same character's animations
- Share with team members
6. **Let System Manage Ports**
- Don't manually configure ports
- System handles multi-project conflicts
- Configuration persists automatically
## References
Detailed documentation in `references/` folder:
- **[commands-reference.md](references/commands-reference.md)** - Complete CLI command reference
- All geometry, object, material, modifier commands
- Detailed options and examples
- Port management and tips
- **[bone-mapping-guide.md](references/bone-mapping-guide.md)** - Bone matching system details
- Fuzzy matching algorithm explained
- Quality assessment metrics
- Common mapping patterns (Rigify, UE4, Unity)
- Troubleshooting mapping issues
- **[workflow-guide.md](references/workflow-guide.md)** - Complete workflow documentation
- Step-by-step retargeting workflow
- Mixamo download process
- Two-phase confirmation details
- Batch processing workflows
- Multi-project workflows
- **[addon-api-reference.md](references/addon-api-reference.md)** - WebSocket API documentation
- JSON-RPC protocol details
- All API methods and parameters
- Error handling
- Security and performance tips
**When to Load References:**
- User needs detailed command options
- Troubleshooting complex issues
- Understanding bone mapping algorithm
- Setting up advanced workflows
- API integration requirements
## Output Structure
```
.blender-toolkit/
├── skills/scripts/ # Local TypeScript scripts (auto-initialized)
│ ├── src/ # Source code
│ ├── dist/ # Compiled JavaScript
│ └── node_modules/ # Dependencies
├── bt.js # CLI wrapper
├── logs/ # Log files
│ ├── typescript.log
│ ├── blender-addon.log
│ └── error.log
└── .gitignore
Shared config:
~/.claude/plugins/.../blender-config.json
```
## Notes
- **Port range:** 9400-9500 (Browser Pilot uses 9222-9322)
- **File formats:** FBX recommended, Collada (.dae) supported
- **Blender version:** 4.0+ required (2023+)
- **Auto-initialization:** SessionStart hook installs and builds scripts
- **No manual daemon management:** System handles everything
- **WebSocket protocol:** JSON-RPC 2.0

409
skills/addon/.pylintrc Normal file
View File

@@ -0,0 +1,409 @@
[MASTER]
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=
# Specify a score threshold to be exceeded before program exits with error code.
fail-under=10.0
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=CVS
# Add files or directories matching the regex patterns to the blacklist. The
# regex matches against base names and not paths.
ignore-patterns=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use.
jobs=1
# Control the amount of potential inferences PyLint can do when analyzing open
# files. Increasing this value might help you get proper information for your
# scripts, but can also result in longer computation time. This is a trade-off
# you can make as you see fit. It defaults to 0.
limit-inference-results=100
# List of plugins (as comma separated). Plugins should always be named after
# their package or modules names, not the filename in the plugins directory.
load-plugins=
# Minimum Python version to target. Used for version dependent checks and
# annotations parsing.
py-version=3.9
# Allow optimization of some simple Pylint rules at the cost of some lost
# message locations accuracy.
unsafe-load-any-extension=no
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
confidence=
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then reenable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W"
disable=
# Blender addon specific disables
wrong-import-position, # C0413: Blender requires bl_info before imports
too-many-lines, # C0302: Large addon files are acceptable
invalid-name, # C0103: Blender naming conventions (e.g., ADDON_OT_OperatorName)
too-few-public-methods, # R0903: Blender Operators have required structure
unused-argument, # W0613: Blender callbacks have required signatures
import-error, # E0401: bpy module not available in linting environment
import-outside-toplevel, # C0415: Lazy imports common in Blender addons
no-else-return, # R1705: elif after return (acceptable pattern)
too-many-return-statements,# R0911: Command routers need multiple returns
# Additional useful disables for development
fixme, # W0511: TODOs and FIXMEs in code
duplicate-code, # R0801: Similar lines in multiple locations
[REPORTS]
# Python expression which should return a score less than or equal to 10 (10 is
# the highest value). You have access to the variables 'fatal', 'error',
# 'warning', 'refactor', 'convention', and 'info' which represent the number of
# messages in each category, as well as 'statement' which represents the total
# number of statements analyzed. This score is used by the global evaluation
# report (RP0004). Evaluation of this score is skipped if it's value is "-1".
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
# Set the output format. Available formats are text, parseable, colorized, json
# and msvs (visual studio). You can also give a reporter class e.g. mypackage.
# mymodule.MyReporterClass --output-format=parseable
output-format=text
# Tells whether to display a full report or only the messages
reports=no
# Tells whether to display a full report or only the messages
score=yes
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit,argparse.ArgumentParser.error
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,bar,baz,toto,tutu,tata
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style. If left empty, class attribute names will be checked
# with the set naming style.
class-attribute-rgx=
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style. If left empty, function names will be checked with the set
# naming style.
function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,j,k,ex,Run,_,x,y,z,dx,dy,dz,bpy
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles. This is useful for enforcing naming
# consistency across properties, getters, and setters. Each named set should
# contain RegEx rule names to its own set. The RegEx rules in the same set must
# have compatible naming style. This is done in order to make sure they can
# interchangeably be used.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
variable-rgx=
[FORMAT]
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=100
# Maximum number of lines in a module.
max-module-lines=1000
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[LOGGING]
# The type of string formatting that logging methods do. `old` for %
# formatting, `new` for {} formatting and `fstring` for f-strings.
logging-format-style=old
# Format template used to check logging format string.
logging-modules=logging
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,XXX,TODO
# Regular expression of note tags to take in consideration.
notes-rgx=
[SIMILARITIES]
# Comments are removed from the similarity computation
ignore-comments=yes
# Docstrings are removed from the similarity computation
ignore-docstrings=yes
# Imports are removed from the similarity computation
ignore-imports=no
# Signatures are removed from the similarity computation
ignore-signatures=no
# Minimum lines number d a similarity.
min-similarity-lines=4
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes.
max-spelling-suggestions=4
# Path to a dictionary that some tests and fixers may parse and use.
spelling-dict=
# Tells whether to spell check word list when using the quiet mode
spelling-ignore-words=
# A path to a file with private dictionary; one word per line.
spelling-private-dict-file=
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of names allowed to shadow builtins
allowed-redefined-builtins=
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|^ignored_|^unused_
# Argument names that match this expression will be ignored.
ignored-argument-names=_.*
# Tells whether we should check for unused import in __init__ files.
init-import=no
[CLASSES]
# Validate membership accesses on modules/namespaces based on the public API
# advertised by a module's all definition.
check-protected-access-in-special-methods=no
# List of method names used to declare an abstract method. The naming doesn't
# matter, as long as the method has the property decorated with
# "abstractmethod" anything else will be ignored.
defining-attr-methods=__init__,__new__,setUp,__post_init__
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=cls
[DESIGN]
# Maximum number of arguments for function / method
max-args=5
# Maximum number of attributes for a class
max-attributes=7
# Maximum number of boolean expressions in an if statement
max-bool-expr=5
# Maximum number of branch for function / method body
max-branches=12
# Maximum number of locals for function / method body
max-locals=15
# Maximum number of parents for a class (see R0901)
max-parents=7
# Maximum number of public methods for a class
max-public-methods=20
# Maximum number of return / yield for function / method body
max-returns=6
# Maximum number of statements in function / method body
max-statements=50
# Minimum number of public methods for a class
min-public-methods=2
[IMPORTS]
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means you may have duplicated imports (same
# imports in try except blocks). By default it set to False.
analyse-fallback-blocks=no
# Create a graph of external dependencies in the given file (report RP0402 must
# not be disabled)
ext-import-graph=
# Create a graph of every (i.e. non external) dependencies in the given file
# (report RP0402 must not be disabled)
import-graph=
# Create a graph of those files that have a dependency loop.
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
[EXCEPTIONS]
# Exceptions that will emit a warning when being caught. Defaults to
# "BaseException, Exception".
overgeneral-exceptions=builtins.BaseException,builtins.Exception

134
skills/addon/__init__.py Normal file
View File

@@ -0,0 +1,134 @@
"""
Blender Toolkit WebSocket Server
Claude Code와 통신하기 위한 WebSocket 서버 애드온
설치 방법:
1. Blender > Edit > Preferences > Add-ons > Install
2. 이 파일 선택
3. "Blender Toolkit WebSocket Server" 활성화
"""
# flake8: noqa: E402
# Blender addon requires bl_info at top of file
bl_info = { # type: ignore[misc]
"name": "Blender Toolkit WebSocket Server",
"author": "Dev GOM",
"version": (1, 0, 0),
"blender": (4, 0, 0),
"location": "View3D > Sidebar > Blender Toolkit",
"description": (
"WebSocket server for Claude Code integration "
"with animation retargeting"
),
"category": "Animation",
}
# Add bundled dependencies to sys.path
import sys
import os
_addon_dir = os.path.dirname(os.path.realpath(__file__))
_libs_dir = os.path.join(_addon_dir, 'libs')
if os.path.exists(_libs_dir) and _libs_dir not in sys.path:
sys.path.insert(0, _libs_dir)
import bpy
# Logging utilities
from .utils.logger import get_logger
# WebSocket Server
from .websocket_server import BlenderWebSocketServer
# UI Classes
from .ui import (
BoneMappingItem,
BLENDERTOOLKIT_PT_Panel,
BLENDERTOOLKIT_PT_BoneMappingPanel,
BLENDERTOOLKIT_OT_StartServer,
BLENDERTOOLKIT_OT_StopServer,
BLENDERTOOLKIT_OT_AutoRemap,
BLENDERTOOLKIT_OT_ApplyRetargeting,
)
# 모듈 로거 초기화
logger = get_logger('addon')
# ============================================================================
# 등록/해제
# ============================================================================
def register():
"""Blender 애드온 클래스 및 속성 등록."""
# Register property groups first
bpy.utils.register_class(BoneMappingItem)
# Register UI panels
bpy.utils.register_class(BLENDERTOOLKIT_PT_Panel)
bpy.utils.register_class(BLENDERTOOLKIT_PT_BoneMappingPanel)
# Register operators
bpy.utils.register_class(BLENDERTOOLKIT_OT_StartServer)
bpy.utils.register_class(BLENDERTOOLKIT_OT_StopServer)
bpy.utils.register_class(BLENDERTOOLKIT_OT_AutoRemap)
bpy.utils.register_class(BLENDERTOOLKIT_OT_ApplyRetargeting)
# 포트 설정 속성
bpy.types.Scene.blender_toolkit_port = bpy.props.IntProperty(
name="Port",
description="WebSocket server port",
default=9400,
min=1024,
max=65535
)
# 본 매핑 속성
bpy.types.Scene.bone_mapping_items = bpy.props.CollectionProperty(
type=BoneMappingItem,
name="Bone Mapping Items"
)
bpy.types.Scene.bone_mapping_source_armature = bpy.props.StringProperty(
name="Source Armature",
description="Source armature name"
)
bpy.types.Scene.bone_mapping_target_armature = bpy.props.StringProperty(
name="Target Armature",
description="Target armature name"
)
bpy.types.Scene.bone_mapping_status = bpy.props.StringProperty(
name="Bone Mapping Status",
description="Current status of bone mapping operation",
default=""
)
print("✅ Blender Toolkit WebSocket Server registered")
def unregister():
"""Blender 애드온 클래스 및 속성 등록 해제."""
# Unregister operators
bpy.utils.unregister_class(BLENDERTOOLKIT_OT_ApplyRetargeting)
bpy.utils.unregister_class(BLENDERTOOLKIT_OT_AutoRemap)
bpy.utils.unregister_class(BLENDERTOOLKIT_OT_StopServer)
bpy.utils.unregister_class(BLENDERTOOLKIT_OT_StartServer)
# Unregister UI panels
bpy.utils.unregister_class(BLENDERTOOLKIT_PT_BoneMappingPanel)
bpy.utils.unregister_class(BLENDERTOOLKIT_PT_Panel)
# Unregister property groups
bpy.utils.unregister_class(BoneMappingItem)
# Delete properties
del bpy.types.Scene.bone_mapping_status
del bpy.types.Scene.bone_mapping_target_armature
del bpy.types.Scene.bone_mapping_source_armature
del bpy.types.Scene.bone_mapping_items
del bpy.types.Scene.blender_toolkit_port
print("🔌 Blender Toolkit WebSocket Server unregistered")
if __name__ == "__main__":
register()

View File

@@ -0,0 +1,91 @@
"""
Command Handlers
WebSocket 명령 핸들러 모듈
"""
from .armature import list_armatures, get_bones
from .retargeting import auto_map_bones, retarget_animation, get_preset_bone_mapping
from .animation import list_animations, play_animation, stop_animation, add_to_nla
from .import_ import import_fbx, import_dae
from .bone_mapping import store_bone_mapping, load_bone_mapping
from .geometry import (
# Primitive creation
create_cube, create_sphere, create_cylinder, create_plane,
create_cone, create_torus,
# Object operations
delete_object, transform_object, duplicate_object, list_objects,
# Vertex operations
get_vertices, move_vertex, subdivide_mesh, extrude_face
)
from .modifier import (
# Modifier operations
add_modifier, apply_modifier, list_modifiers, remove_modifier,
toggle_modifier, modify_modifier_properties, get_modifier_info, reorder_modifier
)
from .material import (
# Material creation
create_material, list_materials, delete_material,
# Material assignment
assign_material, list_object_materials,
# Material properties
set_material_base_color, set_material_metallic, set_material_roughness,
set_material_emission, get_material_properties
)
__all__ = [
# Armature commands
'list_armatures',
'get_bones',
# Retargeting commands
'auto_map_bones',
'retarget_animation',
'get_preset_bone_mapping',
# Animation commands
'list_animations',
'play_animation',
'stop_animation',
'add_to_nla',
# Import commands
'import_fbx',
'import_dae',
# Bone mapping commands
'store_bone_mapping',
'load_bone_mapping',
# Geometry - Primitive creation
'create_cube',
'create_sphere',
'create_cylinder',
'create_plane',
'create_cone',
'create_torus',
# Geometry - Object operations
'delete_object',
'transform_object',
'duplicate_object',
'list_objects',
# Geometry - Vertex operations
'get_vertices',
'move_vertex',
'subdivide_mesh',
'extrude_face',
# Modifier operations
'add_modifier',
'apply_modifier',
'list_modifiers',
'remove_modifier',
'toggle_modifier',
'modify_modifier_properties',
'get_modifier_info',
'reorder_modifier',
# Material operations
'create_material',
'list_materials',
'delete_material',
'assign_material',
'list_object_materials',
'set_material_base_color',
'set_material_metallic',
'set_material_roughness',
'set_material_emission',
'get_material_properties',
]

View File

@@ -0,0 +1,136 @@
"""
Animation 관련 명령 핸들러
애니메이션 재생, NLA 트랙 관리
"""
import bpy
from typing import List
from ..utils.logger import get_logger
logger = get_logger(__name__)
def list_animations(armature_name: str) -> List[str]:
"""
아마추어의 애니메이션 액션 목록
Args:
armature_name: 아마추어 이름
Returns:
액션 이름 리스트
Raises:
ValueError: 아마추어를 찾을 수 없는 경우
"""
logger.debug(f"Listing animations for armature: {armature_name}")
armature = bpy.data.objects.get(armature_name)
if not armature:
logger.error(f"Armature '{armature_name}' not found")
raise ValueError(f"Armature '{armature_name}' not found")
actions = []
if armature.animation_data:
for action in bpy.data.actions:
if action.id_root == 'OBJECT':
actions.append(action.name)
logger.info(f"Found {len(actions)} animations for {armature_name}")
return actions
def play_animation(armature_name: str, action_name: str, loop: bool = True) -> str:
"""
애니메이션 재생
Args:
armature_name: 아마추어 이름
action_name: 액션 이름
loop: 루프 재생 여부
Returns:
결과 메시지
Raises:
ValueError: 아마추어 또는 액션을 찾을 수 없는 경우
"""
logger.info(f"Playing animation: {action_name} on {armature_name}")
armature = bpy.data.objects.get(armature_name)
if not armature:
logger.error(f"Armature '{armature_name}' not found")
raise ValueError(f"Armature '{armature_name}' not found")
action = bpy.data.actions.get(action_name)
if not action:
logger.error(f"Action '{action_name}' not found")
raise ValueError(f"Action '{action_name}' not found")
if not armature.animation_data:
armature.animation_data_create()
armature.animation_data.action = action
bpy.context.scene.frame_set(int(action.frame_range[0]))
bpy.ops.screen.animation_play()
logger.info(f"Started playing {action_name}")
return f"Playing {action_name}"
def stop_animation() -> str:
"""
애니메이션 중지
Returns:
결과 메시지
"""
logger.info("Stopping animation playback")
bpy.ops.screen.animation_cancel()
return "Animation stopped"
def add_to_nla(armature_name: str, action_name: str, track_name: str) -> str:
"""
NLA 트랙에 애니메이션 추가
Args:
armature_name: 아마추어 이름
action_name: 액션 이름
track_name: 트랙 이름
Returns:
결과 메시지
Raises:
ValueError: 아마추어 또는 액션을 찾을 수 없는 경우
"""
logger.info(f"Adding {action_name} to NLA track {track_name} on {armature_name}")
armature = bpy.data.objects.get(armature_name)
if not armature:
logger.error(f"Armature '{armature_name}' not found")
raise ValueError(f"Armature '{armature_name}' not found")
action = bpy.data.actions.get(action_name)
if not action:
logger.error(f"Action '{action_name}' not found")
raise ValueError(f"Action '{action_name}' not found")
if not armature.animation_data:
armature.animation_data_create()
# NLA 트랙 생성 또는 찾기
nla_tracks = armature.animation_data.nla_tracks
track = nla_tracks.get(track_name)
if not track:
track = nla_tracks.new()
track.name = track_name
logger.debug(f"Created new NLA track: {track_name}")
# 액션을 스트립으로 추가
strip = track.strips.new(action.name, int(action.frame_range[0]), action)
logger.info(f"Added strip {strip.name} to track {track_name}")
return f"Added {action_name} to NLA track {track_name}"

View File

@@ -0,0 +1,55 @@
"""
Armature 관련 명령 핸들러
아마추어 정보 조회 및 본 구조 분석
"""
import bpy
from typing import List, Dict
from ..utils.logger import get_logger
logger = get_logger(__name__)
def list_armatures() -> List[str]:
"""
모든 아마추어 오브젝트 목록 반환
Returns:
아마추어 이름 리스트
"""
logger.debug("Listing all armatures")
armatures = [obj.name for obj in bpy.data.objects if obj.type == 'ARMATURE']
logger.info(f"Found {len(armatures)} armatures")
return armatures
def get_bones(armature_name: str) -> List[Dict]:
"""
아마추어의 본 정보 가져오기
Args:
armature_name: 아마추어 이름
Returns:
본 정보 리스트 (name, parent, children)
Raises:
ValueError: 아마추어를 찾을 수 없거나 타입이 잘못된 경우
"""
logger.debug(f"Getting bones for armature: {armature_name}")
armature = bpy.data.objects.get(armature_name)
if not armature or armature.type != 'ARMATURE':
logger.error(f"Armature '{armature_name}' not found or invalid type")
raise ValueError(f"Armature '{armature_name}' not found")
bones = []
for bone in armature.data.bones:
bones.append({
"name": bone.name,
"parent": bone.parent.name if bone.parent else None,
"children": [child.name for child in bone.children]
})
logger.info(f"Retrieved {len(bones)} bones from {armature_name}")
return bones

View File

@@ -0,0 +1,90 @@
"""
Bone Mapping 관련 명령 핸들러
본 매핑 저장/로드, UI 표시
"""
import bpy
from typing import Dict
from ..utils.logger import get_logger
logger = get_logger(__name__)
def store_bone_mapping(source_armature: str, target_armature: str, bone_mapping: Dict[str, str]) -> str:
"""
본 매핑을 Scene 속성에 저장
Args:
source_armature: 소스 아마추어 이름
target_armature: 타겟 아마추어 이름
bone_mapping: 본 매핑 딕셔너리
Returns:
결과 메시지
"""
logger.info(f"Storing bone mapping: {source_armature} -> {target_armature} ({len(bone_mapping)} bones)")
scene = bpy.context.scene
# 기존 매핑 클리어
scene.bone_mapping_items.clear()
# 새 매핑 저장
for source_bone, target_bone in bone_mapping.items():
item = scene.bone_mapping_items.add()
item.source_bone = source_bone
item.target_bone = target_bone
# 아마추어 정보 저장
scene.bone_mapping_source_armature = source_armature
scene.bone_mapping_target_armature = target_armature
logger.info(f"Stored {len(bone_mapping)} bone mappings")
print(f"✅ Stored bone mapping: {len(bone_mapping)} bones")
return f"Bone mapping stored ({len(bone_mapping)} bones)"
def load_bone_mapping(source_armature: str, target_armature: str) -> Dict[str, str]:
"""
Scene 속성에서 본 매핑 로드
Args:
source_armature: 소스 아마추어 이름
target_armature: 타겟 아마추어 이름
Returns:
본 매핑 딕셔너리
Raises:
ValueError: 저장된 매핑이 없거나 불일치하는 경우
"""
logger.info(f"Loading bone mapping: {source_armature} -> {target_armature}")
scene = bpy.context.scene
# 아마추어 검증
if not scene.bone_mapping_source_armature:
logger.error("No bone mapping stored")
raise ValueError("No bone mapping stored. Please generate mapping first using BoneMapping.show command.")
if (scene.bone_mapping_source_armature != source_armature or
scene.bone_mapping_target_armature != target_armature):
logger.error("Stored mapping doesn't match requested armatures")
raise ValueError(
f"Stored mapping for ({scene.bone_mapping_source_armature}"
f"{scene.bone_mapping_target_armature}) doesn't match requested "
f"({source_armature}{target_armature})"
)
# 매핑 로드
bone_mapping = {}
for item in scene.bone_mapping_items:
bone_mapping[item.source_bone] = item.target_bone
if not bone_mapping:
logger.error("Bone mapping is empty")
raise ValueError("Bone mapping is empty. Please generate mapping first.")
logger.info(f"Loaded {len(bone_mapping)} bone mappings")
print(f"✅ Loaded bone mapping: {len(bone_mapping)} bones")
return bone_mapping

View File

@@ -0,0 +1,88 @@
"""
Collection Operations
컬렉션 관리 명령 핸들러
"""
import bpy
from typing import Dict, List, Any
from ..utils.logger import get_logger
logger = get_logger(__name__)
def create_collection(name: str) -> Dict[str, Any]:
"""컬렉션 생성"""
logger.info(f"Creating collection: {name}")
if name in bpy.data.collections:
logger.warn(f"Collection '{name}' already exists")
coll = bpy.data.collections[name]
else:
coll = bpy.data.collections.new(name)
bpy.context.scene.collection.children.link(coll)
return {'name': coll.name, 'objects': len(coll.objects)}
def list_collections() -> List[Dict[str, Any]]:
"""모든 컬렉션 목록 조회"""
logger.info("Listing all collections")
collections = []
for coll in bpy.data.collections:
collections.append({
'name': coll.name,
'objects': len(coll.objects),
'children': len(coll.children)
})
return collections
def add_to_collection(object_name: str, collection_name: str) -> Dict[str, str]:
"""오브젝트를 컬렉션에 추가"""
logger.info(f"Adding '{object_name}' to collection '{collection_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
coll = bpy.data.collections.get(collection_name)
if not coll:
raise ValueError(f"Collection '{collection_name}' not found")
if obj.name not in coll.objects:
coll.objects.link(obj)
return {'status': 'success', 'message': f"Added '{object_name}' to '{collection_name}'"}
def remove_from_collection(object_name: str, collection_name: str) -> Dict[str, str]:
"""오브젝트를 컬렉션에서 제거"""
logger.info(f"Removing '{object_name}' from collection '{collection_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
coll = bpy.data.collections.get(collection_name)
if not coll:
raise ValueError(f"Collection '{collection_name}' not found")
if obj.name in coll.objects:
coll.objects.unlink(obj)
return {'status': 'success', 'message': f"Removed '{object_name}' from '{collection_name}'"}
def delete_collection(name: str) -> Dict[str, str]:
"""컬렉션 삭제"""
logger.info(f"Deleting collection: {name}")
coll = bpy.data.collections.get(name)
if not coll:
raise ValueError(f"Collection '{name}' not found")
bpy.data.collections.remove(coll)
return {'status': 'success', 'message': f"Collection '{name}' deleted"}

View File

@@ -0,0 +1,552 @@
"""
Geometry Operations
도형 생성, 수정, 삭제 등 기하학적 작업을 처리하는 명령 핸들러
"""
import bpy
import bmesh
from typing import Dict, List, Tuple, Optional, Any
from ..utils.logger import get_logger
logger = get_logger(__name__)
# ============================================================================
# Primitive Creation (기본 도형 생성)
# ============================================================================
def create_cube(
location: Tuple[float, float, float] = (0, 0, 0),
size: float = 2.0,
name: Optional[str] = None
) -> Dict[str, Any]:
"""
큐브 생성
Args:
location: 위치 (x, y, z)
size: 크기
name: 오브젝트 이름 (None이면 자동 생성)
Returns:
생성된 오브젝트 정보
"""
logger.info(f"Creating cube at {location} with size {size}")
bpy.ops.mesh.primitive_cube_add(size=size, location=location)
obj = bpy.context.active_object
if name:
obj.name = name
return {
'name': obj.name,
'type': obj.type,
'location': list(obj.location),
'vertices': len(obj.data.vertices),
'faces': len(obj.data.polygons)
}
def create_sphere(
location: Tuple[float, float, float] = (0, 0, 0),
radius: float = 1.0,
segments: int = 32,
ring_count: int = 16,
name: Optional[str] = None
) -> Dict[str, Any]:
"""
구(Sphere) 생성
Args:
location: 위치 (x, y, z)
radius: 반지름
segments: 세그먼트 수 (수평)
ring_count: 링 수 (수직)
name: 오브젝트 이름
Returns:
생성된 오브젝트 정보
"""
logger.info(f"Creating sphere at {location} with radius {radius}")
bpy.ops.mesh.primitive_uv_sphere_add(
radius=radius,
segments=segments,
ring_count=ring_count,
location=location
)
obj = bpy.context.active_object
if name:
obj.name = name
return {
'name': obj.name,
'type': obj.type,
'location': list(obj.location),
'vertices': len(obj.data.vertices),
'faces': len(obj.data.polygons)
}
def create_cylinder(
location: Tuple[float, float, float] = (0, 0, 0),
radius: float = 1.0,
depth: float = 2.0,
vertices: int = 32,
name: Optional[str] = None
) -> Dict[str, Any]:
"""
실린더 생성
Args:
location: 위치 (x, y, z)
radius: 반지름
depth: 높이
vertices: 버텍스 수
name: 오브젝트 이름
Returns:
생성된 오브젝트 정보
"""
logger.info(f"Creating cylinder at {location}")
bpy.ops.mesh.primitive_cylinder_add(
radius=radius,
depth=depth,
vertices=vertices,
location=location
)
obj = bpy.context.active_object
if name:
obj.name = name
return {
'name': obj.name,
'type': obj.type,
'location': list(obj.location),
'vertices': len(obj.data.vertices),
'faces': len(obj.data.polygons)
}
def create_plane(
location: Tuple[float, float, float] = (0, 0, 0),
size: float = 2.0,
name: Optional[str] = None
) -> Dict[str, Any]:
"""
평면(Plane) 생성
Args:
location: 위치 (x, y, z)
size: 크기
name: 오브젝트 이름
Returns:
생성된 오브젝트 정보
"""
logger.info(f"Creating plane at {location}")
bpy.ops.mesh.primitive_plane_add(size=size, location=location)
obj = bpy.context.active_object
if name:
obj.name = name
return {
'name': obj.name,
'type': obj.type,
'location': list(obj.location),
'vertices': len(obj.data.vertices),
'faces': len(obj.data.polygons)
}
def create_cone(
location: Tuple[float, float, float] = (0, 0, 0),
radius1: float = 1.0,
depth: float = 2.0,
vertices: int = 32,
name: Optional[str] = None
) -> Dict[str, Any]:
"""
원뿔(Cone) 생성
Args:
location: 위치 (x, y, z)
radius1: 아래 반지름
depth: 높이
vertices: 버텍스 수
name: 오브젝트 이름
Returns:
생성된 오브젝트 정보
"""
logger.info(f"Creating cone at {location}")
bpy.ops.mesh.primitive_cone_add(
radius1=radius1,
depth=depth,
vertices=vertices,
location=location
)
obj = bpy.context.active_object
if name:
obj.name = name
return {
'name': obj.name,
'type': obj.type,
'location': list(obj.location),
'vertices': len(obj.data.vertices),
'faces': len(obj.data.polygons)
}
def create_torus(
location: Tuple[float, float, float] = (0, 0, 0),
major_radius: float = 1.0,
minor_radius: float = 0.25,
major_segments: int = 48,
minor_segments: int = 12,
name: Optional[str] = None
) -> Dict[str, Any]:
"""
토러스(Torus) 생성
Args:
location: 위치 (x, y, z)
major_radius: 주 반지름
minor_radius: 부 반지름
major_segments: 주 세그먼트 수
minor_segments: 부 세그먼트 수
name: 오브젝트 이름
Returns:
생성된 오브젝트 정보
"""
logger.info(f"Creating torus at {location}")
bpy.ops.mesh.primitive_torus_add(
major_radius=major_radius,
minor_radius=minor_radius,
major_segments=major_segments,
minor_segments=minor_segments,
location=location
)
obj = bpy.context.active_object
if name:
obj.name = name
return {
'name': obj.name,
'type': obj.type,
'location': list(obj.location),
'vertices': len(obj.data.vertices),
'faces': len(obj.data.polygons)
}
# ============================================================================
# Object Operations (오브젝트 작업)
# ============================================================================
def delete_object(name: str) -> Dict[str, str]:
"""
오브젝트 삭제
Args:
name: 오브젝트 이름
Returns:
삭제 결과
"""
logger.info(f"Deleting object: {name}")
obj = bpy.data.objects.get(name)
if not obj:
raise ValueError(f"Object '{name}' not found")
bpy.data.objects.remove(obj, do_unlink=True)
return {'status': 'success', 'message': f"Object '{name}' deleted"}
def transform_object(
name: str,
location: Optional[Tuple[float, float, float]] = None,
rotation: Optional[Tuple[float, float, float]] = None,
scale: Optional[Tuple[float, float, float]] = None
) -> Dict[str, Any]:
"""
오브젝트 변형 (이동, 회전, 스케일)
Args:
name: 오브젝트 이름
location: 위치 (x, y, z)
rotation: 회전 (x, y, z) in radians
scale: 스케일 (x, y, z)
Returns:
변형된 오브젝트 정보
"""
logger.info(f"Transforming object: {name}")
obj = bpy.data.objects.get(name)
if not obj:
raise ValueError(f"Object '{name}' not found")
if location:
obj.location = location
if rotation:
obj.rotation_euler = rotation
if scale:
obj.scale = scale
return {
'name': obj.name,
'location': list(obj.location),
'rotation': list(obj.rotation_euler),
'scale': list(obj.scale)
}
def duplicate_object(
name: str,
new_name: Optional[str] = None,
location: Optional[Tuple[float, float, float]] = None
) -> Dict[str, Any]:
"""
오브젝트 복제
Args:
name: 원본 오브젝트 이름
new_name: 새 오브젝트 이름 (None이면 자동 생성)
location: 새 위치 (None이면 원본 위치)
Returns:
복제된 오브젝트 정보
"""
logger.info(f"Duplicating object: {name}")
obj = bpy.data.objects.get(name)
if not obj:
raise ValueError(f"Object '{name}' not found")
# 복제
new_obj = obj.copy()
new_obj.data = obj.data.copy()
if new_name:
new_obj.name = new_name
if location:
new_obj.location = location
# 씬에 추가
bpy.context.collection.objects.link(new_obj)
return {
'name': new_obj.name,
'type': new_obj.type,
'location': list(new_obj.location)
}
def list_objects(object_type: Optional[str] = None) -> List[Dict[str, Any]]:
"""
씬의 오브젝트 목록 조회
Args:
object_type: 오브젝트 타입 필터 (None이면 전체)
예: 'MESH', 'ARMATURE', 'CAMERA', 'LIGHT'
Returns:
오브젝트 목록
"""
logger.info(f"Listing objects (type: {object_type or 'ALL'})")
objects = []
for obj in bpy.data.objects:
if object_type and obj.type != object_type:
continue
objects.append({
'name': obj.name,
'type': obj.type,
'location': list(obj.location),
'rotation': list(obj.rotation_euler),
'scale': list(obj.scale)
})
return objects
# ============================================================================
# Vertex Operations (버텍스 작업)
# ============================================================================
def get_vertices(name: str) -> List[Dict[str, Any]]:
"""
오브젝트의 버텍스 정보 조회
Args:
name: 오브젝트 이름
Returns:
버텍스 목록
"""
logger.info(f"Getting vertices for object: {name}")
obj = bpy.data.objects.get(name)
if not obj or obj.type != 'MESH':
raise ValueError(f"Mesh object '{name}' not found")
vertices = []
for i, vert in enumerate(obj.data.vertices):
vertices.append({
'index': i,
'co': list(vert.co),
'normal': list(vert.normal)
})
return vertices
def move_vertex(
object_name: str,
vertex_index: int,
new_position: Tuple[float, float, float]
) -> Dict[str, Any]:
"""
버텍스 이동
Args:
object_name: 오브젝트 이름
vertex_index: 버텍스 인덱스
new_position: 새 위치 (x, y, z)
Returns:
수정된 버텍스 정보
"""
logger.info(f"Moving vertex {vertex_index} in object {object_name}")
obj = bpy.data.objects.get(object_name)
if not obj or obj.type != 'MESH':
raise ValueError(f"Mesh object '{object_name}' not found")
mesh = obj.data
if vertex_index >= len(mesh.vertices):
raise ValueError(f"Vertex index {vertex_index} out of range")
mesh.vertices[vertex_index].co = new_position
mesh.update()
return {
'object': object_name,
'vertex_index': vertex_index,
'position': list(mesh.vertices[vertex_index].co)
}
def subdivide_mesh(
name: str,
cuts: int = 1
) -> Dict[str, Any]:
"""
메쉬 세분화 (Subdivide)
Args:
name: 오브젝트 이름
cuts: 세분화 횟수
Returns:
세분화된 메쉬 정보
"""
logger.info(f"Subdividing mesh: {name} (cuts: {cuts})")
obj = bpy.data.objects.get(name)
if not obj or obj.type != 'MESH':
raise ValueError(f"Mesh object '{name}' not found")
# Edit 모드로 전환
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='EDIT')
# 모든 에지 선택
bpy.ops.mesh.select_all(action='SELECT')
# 세분화
bpy.ops.mesh.subdivide(number_cuts=cuts)
# Object 모드로 복귀
bpy.ops.object.mode_set(mode='OBJECT')
return {
'name': obj.name,
'vertices': len(obj.data.vertices),
'edges': len(obj.data.edges),
'faces': len(obj.data.polygons)
}
def extrude_face(
object_name: str,
face_index: int,
offset: float = 1.0
) -> Dict[str, Any]:
"""
페이스 돌출 (Extrude)
Args:
object_name: 오브젝트 이름
face_index: 페이스 인덱스
offset: 돌출 거리
Returns:
돌출 결과 정보
"""
logger.info(f"Extruding face {face_index} in object {object_name}")
obj = bpy.data.objects.get(object_name)
if not obj or obj.type != 'MESH':
raise ValueError(f"Mesh object '{object_name}' not found")
# BMesh를 사용한 extrude
mesh = obj.data
bm = bmesh.new()
bm.from_mesh(mesh)
# 페이스 선택
if face_index >= len(bm.faces):
bm.free()
raise ValueError(f"Face index {face_index} out of range")
face = bm.faces[face_index]
# Extrude
ret = bmesh.ops.extrude_face_region(bm, geom=[face])
extruded_verts = [v for v in ret['geom'] if isinstance(v, bmesh.types.BMVert)]
# 오프셋 적용
for v in extruded_verts:
v.co += face.normal * offset
# 메쉬 업데이트
bm.to_mesh(mesh)
bm.free()
mesh.update()
return {
'object': object_name,
'face_index': face_index,
'vertices': len(mesh.vertices),
'faces': len(mesh.polygons)
}

View File

@@ -0,0 +1,79 @@
"""
Import 관련 명령 핸들러
FBX, DAE 파일 임포트
"""
import bpy
import os
from ..utils.logger import get_logger
from ..utils.security import validate_file_path
logger = get_logger(__name__)
def import_fbx(filepath: str) -> str:
"""
FBX 파일 임포트
Args:
filepath: FBX 파일 경로
Returns:
결과 메시지
Raises:
RuntimeError: 임포트 실패
ValueError: 잘못된 파일 경로
"""
logger.info(f"Importing FBX file: {filepath}")
# 경로 보안 검증 (path traversal 방지)
try:
# 사용자 홈 디렉토리 또는 현재 작업 디렉토리 내로 제한
allowed_root = os.path.expanduser("~")
validated_path = validate_file_path(filepath, allowed_root)
except ValueError as e:
logger.error(f"Invalid file path: {e}")
raise ValueError(f"Invalid file path: {e}")
try:
bpy.ops.import_scene.fbx(filepath=validated_path)
logger.info(f"FBX import successful: {validated_path}")
return f"Imported {validated_path}"
except Exception as e:
logger.error(f"FBX import failed: {e}", exc_info=True)
raise RuntimeError(f"Failed to import FBX: {str(e)}")
def import_dae(filepath: str) -> str:
"""
DAE (Collada) 파일 임포트
Args:
filepath: DAE 파일 경로
Returns:
결과 메시지
Raises:
RuntimeError: 임포트 실패
ValueError: 잘못된 파일 경로
"""
logger.info(f"Importing DAE file: {filepath}")
# 경로 보안 검증 (path traversal 방지)
try:
# 사용자 홈 디렉토리 또는 현재 작업 디렉토리 내로 제한
allowed_root = os.path.expanduser("~")
validated_path = validate_file_path(filepath, allowed_root)
except ValueError as e:
logger.error(f"Invalid file path: {e}")
raise ValueError(f"Invalid file path: {e}")
try:
bpy.ops.wm.collada_import(filepath=validated_path)
logger.info(f"DAE import successful: {validated_path}")
return f"Imported {validated_path}"
except Exception as e:
logger.error(f"DAE import failed: {e}", exc_info=True)
raise RuntimeError(f"Failed to import DAE: {str(e)}")

View File

@@ -0,0 +1,361 @@
"""
Material Operations
머티리얼 및 셰이더 관련 작업을 처리하는 명령 핸들러
"""
import bpy
from typing import Dict, List, Tuple, Optional, Any
from ..utils.logger import get_logger
logger = get_logger(__name__)
# ============================================================================
# Material Creation (머티리얼 생성)
# ============================================================================
def create_material(
name: str,
use_nodes: bool = True
) -> Dict[str, Any]:
"""
머티리얼 생성
Args:
name: 머티리얼 이름
use_nodes: 노드 시스템 사용 여부 (기본값: True)
Returns:
생성된 머티리얼 정보
"""
logger.info(f"Creating material: {name}")
# 기존 머티리얼 확인
if name in bpy.data.materials:
logger.warn(f"Material '{name}' already exists, returning existing")
mat = bpy.data.materials[name]
else:
mat = bpy.data.materials.new(name=name)
mat.use_nodes = use_nodes
return {
'name': mat.name,
'use_nodes': mat.use_nodes
}
def list_materials() -> List[Dict[str, Any]]:
"""
모든 머티리얼 목록 조회
Returns:
머티리얼 목록
"""
logger.info("Listing all materials")
materials = []
for mat in bpy.data.materials:
materials.append({
'name': mat.name,
'use_nodes': mat.use_nodes,
'users': mat.users # 사용 중인 오브젝트 수
})
return materials
def delete_material(name: str) -> Dict[str, str]:
"""
머티리얼 삭제
Args:
name: 머티리얼 이름
Returns:
삭제 결과
"""
logger.info(f"Deleting material: {name}")
mat = bpy.data.materials.get(name)
if not mat:
raise ValueError(f"Material '{name}' not found")
bpy.data.materials.remove(mat)
return {'status': 'success', 'message': f"Material '{name}' deleted"}
# ============================================================================
# Material Assignment (머티리얼 할당)
# ============================================================================
def assign_material(
object_name: str,
material_name: str,
slot_index: int = 0
) -> Dict[str, Any]:
"""
오브젝트에 머티리얼 할당
Args:
object_name: 오브젝트 이름
material_name: 머티리얼 이름
slot_index: 머티리얼 슬롯 인덱스 (기본값: 0)
Returns:
할당 결과
"""
logger.info(f"Assigning material '{material_name}' to object '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mat = bpy.data.materials.get(material_name)
if not mat:
raise ValueError(f"Material '{material_name}' not found")
# 머티리얼 슬롯이 없으면 생성
if len(obj.data.materials) == 0:
obj.data.materials.append(mat)
else:
# 기존 슬롯에 할당
if slot_index < len(obj.data.materials):
obj.data.materials[slot_index] = mat
else:
obj.data.materials.append(mat)
return {
'object': object_name,
'material': material_name,
'slot_index': slot_index
}
def list_object_materials(object_name: str) -> List[Dict[str, Any]]:
"""
오브젝트의 머티리얼 슬롯 목록 조회
Args:
object_name: 오브젝트 이름
Returns:
머티리얼 슬롯 목록
"""
logger.info(f"Listing materials for object: {object_name}")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
materials = []
for i, mat_slot in enumerate(obj.material_slots):
materials.append({
'slot_index': i,
'material': mat_slot.material.name if mat_slot.material else None
})
return materials
# ============================================================================
# Material Properties (머티리얼 속성)
# ============================================================================
def set_material_base_color(
material_name: str,
color: Tuple[float, float, float, float]
) -> Dict[str, Any]:
"""
머티리얼 기본 색상 설정 (Principled BSDF)
Args:
material_name: 머티리얼 이름
color: RGBA 색상 (0.0 ~ 1.0)
Returns:
설정 결과
"""
logger.info(f"Setting base color for material: {material_name}")
mat = bpy.data.materials.get(material_name)
if not mat:
raise ValueError(f"Material '{material_name}' not found")
if not mat.use_nodes:
raise ValueError(f"Material '{material_name}' does not use nodes")
# Principled BSDF 노드 찾기
principled = None
for node in mat.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
principled = node
break
if not principled:
raise ValueError(f"Principled BSDF node not found in material '{material_name}'")
# Base Color 설정
principled.inputs['Base Color'].default_value = color
return {
'material': material_name,
'base_color': list(color)
}
def set_material_metallic(
material_name: str,
metallic: float
) -> Dict[str, Any]:
"""
머티리얼 Metallic 값 설정
Args:
material_name: 머티리얼 이름
metallic: Metallic 값 (0.0 ~ 1.0)
Returns:
설정 결과
"""
logger.info(f"Setting metallic for material: {material_name}")
mat = bpy.data.materials.get(material_name)
if not mat or not mat.use_nodes:
raise ValueError(f"Material '{material_name}' not found or does not use nodes")
# Principled BSDF 노드 찾기
principled = None
for node in mat.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
principled = node
break
if not principled:
raise ValueError(f"Principled BSDF node not found")
principled.inputs['Metallic'].default_value = metallic
return {
'material': material_name,
'metallic': metallic
}
def set_material_roughness(
material_name: str,
roughness: float
) -> Dict[str, Any]:
"""
머티리얼 Roughness 값 설정
Args:
material_name: 머티리얼 이름
roughness: Roughness 값 (0.0 ~ 1.0)
Returns:
설정 결과
"""
logger.info(f"Setting roughness for material: {material_name}")
mat = bpy.data.materials.get(material_name)
if not mat or not mat.use_nodes:
raise ValueError(f"Material '{material_name}' not found or does not use nodes")
# Principled BSDF 노드 찾기
principled = None
for node in mat.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
principled = node
break
if not principled:
raise ValueError(f"Principled BSDF node not found")
principled.inputs['Roughness'].default_value = roughness
return {
'material': material_name,
'roughness': roughness
}
def set_material_emission(
material_name: str,
color: Tuple[float, float, float, float],
strength: float = 1.0
) -> Dict[str, Any]:
"""
머티리얼 Emission 설정
Args:
material_name: 머티리얼 이름
color: Emission 색상 RGBA (0.0 ~ 1.0)
strength: Emission 강도 (기본값: 1.0)
Returns:
설정 결과
"""
logger.info(f"Setting emission for material: {material_name}")
mat = bpy.data.materials.get(material_name)
if not mat or not mat.use_nodes:
raise ValueError(f"Material '{material_name}' not found or does not use nodes")
# Principled BSDF 노드 찾기
principled = None
for node in mat.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
principled = node
break
if not principled:
raise ValueError(f"Principled BSDF node not found")
principled.inputs['Emission'].default_value = color
principled.inputs['Emission Strength'].default_value = strength
return {
'material': material_name,
'emission_color': list(color),
'emission_strength': strength
}
def get_material_properties(material_name: str) -> Dict[str, Any]:
"""
머티리얼 속성 조회
Args:
material_name: 머티리얼 이름
Returns:
머티리얼 속성
"""
logger.info(f"Getting properties for material: {material_name}")
mat = bpy.data.materials.get(material_name)
if not mat:
raise ValueError(f"Material '{material_name}' not found")
props = {
'name': mat.name,
'use_nodes': mat.use_nodes
}
if mat.use_nodes:
# Principled BSDF 속성 가져오기
principled = None
for node in mat.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
principled = node
break
if principled:
props['base_color'] = list(principled.inputs['Base Color'].default_value)
props['metallic'] = principled.inputs['Metallic'].default_value
props['roughness'] = principled.inputs['Roughness'].default_value
props['emission'] = list(principled.inputs['Emission'].default_value)
props['emission_strength'] = principled.inputs['Emission Strength'].default_value
return props

View File

@@ -0,0 +1,388 @@
"""
Modifier Operations
모디파이어 관리 명령 핸들러
"""
import bpy
from typing import Dict, List, Any, Optional
from ..utils.logger import get_logger
logger = get_logger(__name__)
def add_modifier(object_name: str, modifier_type: str, name: Optional[str] = None) -> Dict[str, Any]:
"""오브젝트에 모디파이어 추가
Args:
object_name: 대상 오브젝트 이름
modifier_type: 모디파이어 타입 (SUBSURF, MIRROR, ARRAY, BEVEL, etc.)
name: 모디파이어 이름 (optional)
Supported modifier types:
- SUBSURF: Subdivision Surface
- MIRROR: Mirror
- ARRAY: Array
- BEVEL: Bevel
- BOOLEAN: Boolean
- SOLIDIFY: Solidify
- WIREFRAME: Wireframe
- SKIN: Skin
- ARMATURE: Armature
- LATTICE: Lattice
- CURVE: Curve
- SIMPLE_DEFORM: Simple Deform
- CAST: Cast
- DISPLACE: Displace
- HOOK: Hook
- LAPLACIANDEFORM: Laplacian Deform
- MESH_DEFORM: Mesh Deform
- SHRINKWRAP: Shrinkwrap
- WAVE: Wave
- OCEAN: Ocean
- PARTICLE_SYSTEM: Particle System
- CLOTH: Cloth
- COLLISION: Collision
- DYNAMIC_PAINT: Dynamic Paint
- EXPLODE: Explode
- FLUID: Fluid
- SOFT_BODY: Soft Body
"""
logger.info(f"Adding modifier '{modifier_type}' to '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mod = obj.modifiers.new(name or modifier_type, modifier_type)
return {
'name': mod.name,
'type': mod.type,
'show_viewport': mod.show_viewport,
'show_render': mod.show_render
}
def apply_modifier(object_name: str, modifier_name: str) -> Dict[str, str]:
"""모디파이어 적용"""
logger.info(f"Applying modifier '{modifier_name}' on '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mod = obj.modifiers.get(modifier_name)
if not mod:
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
# 모디파이어 적용은 Edit 모드에서는 할 수 없음
if bpy.context.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
# 오브젝트 선택 및 활성화
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
# 모디파이어 적용
bpy.ops.object.modifier_apply(modifier=modifier_name)
return {'status': 'success', 'message': f"Applied modifier '{modifier_name}' to '{object_name}'"}
def list_modifiers(object_name: str) -> List[Dict[str, Any]]:
"""오브젝트의 모디파이어 목록 조회"""
logger.info(f"Listing modifiers for '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
modifiers = []
for mod in obj.modifiers:
mod_info = {
'name': mod.name,
'type': mod.type,
'show_viewport': mod.show_viewport,
'show_render': mod.show_render,
}
# 타입별 특화 속성 추가
if mod.type == 'SUBSURF':
mod_info['levels'] = mod.levels
mod_info['render_levels'] = mod.render_levels
elif mod.type == 'MIRROR':
mod_info['use_axis'] = [mod.use_axis[0], mod.use_axis[1], mod.use_axis[2]]
mod_info['use_bisect_axis'] = [mod.use_bisect_axis[0], mod.use_bisect_axis[1], mod.use_bisect_axis[2]]
elif mod.type == 'ARRAY':
mod_info['count'] = mod.count
mod_info['use_relative_offset'] = mod.use_relative_offset
mod_info['relative_offset_displace'] = list(mod.relative_offset_displace)
elif mod.type == 'BEVEL':
mod_info['width'] = mod.width
mod_info['segments'] = mod.segments
mod_info['limit_method'] = mod.limit_method
elif mod.type == 'BOOLEAN':
mod_info['operation'] = mod.operation
mod_info['object'] = mod.object.name if mod.object else None
elif mod.type == 'SOLIDIFY':
mod_info['thickness'] = mod.thickness
mod_info['offset'] = mod.offset
elif mod.type == 'ARMATURE':
mod_info['object'] = mod.object.name if mod.object else None
mod_info['use_vertex_groups'] = mod.use_vertex_groups
elif mod.type == 'LATTICE':
mod_info['object'] = mod.object.name if mod.object else None
elif mod.type == 'CURVE':
mod_info['object'] = mod.object.name if mod.object else None
elif mod.type == 'SIMPLE_DEFORM':
mod_info['deform_method'] = mod.deform_method
mod_info['factor'] = mod.factor
elif mod.type == 'CAST':
mod_info['cast_type'] = mod.cast_type
mod_info['factor'] = mod.factor
elif mod.type == 'DISPLACE':
mod_info['strength'] = mod.strength
mod_info['direction'] = mod.direction
elif mod.type == 'WAVE':
mod_info['time_offset'] = mod.time_offset
mod_info['height'] = mod.height
modifiers.append(mod_info)
return modifiers
def remove_modifier(object_name: str, modifier_name: str) -> Dict[str, str]:
"""모디파이어 제거"""
logger.info(f"Removing modifier '{modifier_name}' from '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mod = obj.modifiers.get(modifier_name)
if not mod:
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
obj.modifiers.remove(mod)
return {'status': 'success', 'message': f"Removed modifier '{modifier_name}' from '{object_name}'"}
def toggle_modifier(object_name: str, modifier_name: str,
viewport: Optional[bool] = None,
render: Optional[bool] = None) -> Dict[str, Any]:
"""모디파이어 활성화/비활성화
Args:
object_name: 대상 오브젝트 이름
modifier_name: 모디파이어 이름
viewport: 뷰포트 표시 on/off (None이면 토글)
render: 렌더 표시 on/off (None이면 토글)
"""
logger.info(f"Toggling modifier '{modifier_name}' on '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mod = obj.modifiers.get(modifier_name)
if not mod:
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
if viewport is not None:
mod.show_viewport = viewport
else:
mod.show_viewport = not mod.show_viewport
if render is not None:
mod.show_render = render
else:
mod.show_render = not mod.show_render
return {
'name': mod.name,
'show_viewport': mod.show_viewport,
'show_render': mod.show_render
}
def modify_modifier_properties(object_name: str, modifier_name: str, **properties) -> Dict[str, Any]:
"""모디파이어 속성 수정
Args:
object_name: 대상 오브젝트 이름
modifier_name: 모디파이어 이름
**properties: 수정할 속성들 (key=value 형태)
Example properties by modifier type:
SUBSURF: levels, render_levels
MIRROR: use_axis, use_bisect_axis, mirror_object
ARRAY: count, relative_offset_displace
BEVEL: width, segments, limit_method
BOOLEAN: operation, object
SOLIDIFY: thickness, offset
ARMATURE: object, use_vertex_groups
SIMPLE_DEFORM: deform_method, factor, angle
CAST: cast_type, factor, radius
DISPLACE: strength, direction
"""
logger.info(f"Modifying properties of '{modifier_name}' on '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mod = obj.modifiers.get(modifier_name)
if not mod:
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
updated_properties = {}
for key, value in properties.items():
if hasattr(mod, key):
# 특수 처리가 필요한 속성들
if key in ['use_axis', 'use_bisect_axis'] and isinstance(value, list):
# Mirror 모디파이어의 axis는 boolean 배열
for i, v in enumerate(value):
if i < 3:
getattr(mod, key)[i] = v
elif key == 'relative_offset_displace' and isinstance(value, list):
# Array 모디파이어의 offset은 Vector
for i, v in enumerate(value):
if i < 3:
mod.relative_offset_displace[i] = v
elif key == 'object' and isinstance(value, str):
# 오브젝트 참조는 문자열로 받아서 변환
target_obj = bpy.data.objects.get(value)
if target_obj:
setattr(mod, key, target_obj)
else:
logger.warn(f"Target object '{value}' not found for property '{key}'")
continue
else:
# 일반 속성
setattr(mod, key, value)
updated_properties[key] = value
else:
logger.warn(f"Property '{key}' not found on modifier '{modifier_name}'")
return {
'name': mod.name,
'type': mod.type,
'updated_properties': updated_properties
}
def get_modifier_info(object_name: str, modifier_name: str) -> Dict[str, Any]:
"""특정 모디파이어의 상세 정보 조회"""
logger.info(f"Getting info for modifier '{modifier_name}' on '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mod = obj.modifiers.get(modifier_name)
if not mod:
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
# 모든 읽기 가능한 속성을 추출
info = {
'name': mod.name,
'type': mod.type,
'show_viewport': mod.show_viewport,
'show_render': mod.show_render,
}
# 타입별 모든 관련 속성 추가
if mod.type == 'SUBSURF':
info.update({
'levels': mod.levels,
'render_levels': mod.render_levels,
'subdivision_type': mod.subdivision_type,
'use_limit_surface': mod.use_limit_surface
})
elif mod.type == 'MIRROR':
info.update({
'use_axis': [mod.use_axis[0], mod.use_axis[1], mod.use_axis[2]],
'use_bisect_axis': [mod.use_bisect_axis[0], mod.use_bisect_axis[1], mod.use_bisect_axis[2]],
'use_bisect_flip_axis': [mod.use_bisect_flip_axis[0], mod.use_bisect_flip_axis[1], mod.use_bisect_flip_axis[2]],
'mirror_object': mod.mirror_object.name if mod.mirror_object else None,
'use_clip': mod.use_clip,
'use_mirror_merge': mod.use_mirror_merge,
'merge_threshold': mod.merge_threshold
})
elif mod.type == 'ARRAY':
info.update({
'count': mod.count,
'use_constant_offset': mod.use_constant_offset,
'use_relative_offset': mod.use_relative_offset,
'use_object_offset': mod.use_object_offset,
'constant_offset_displace': list(mod.constant_offset_displace),
'relative_offset_displace': list(mod.relative_offset_displace),
'offset_object': mod.offset_object.name if mod.offset_object else None
})
elif mod.type == 'BEVEL':
info.update({
'width': mod.width,
'segments': mod.segments,
'limit_method': mod.limit_method,
'offset_type': mod.offset_type,
'profile': mod.profile,
'material': mod.material
})
elif mod.type == 'BOOLEAN':
info.update({
'operation': mod.operation,
'object': mod.object.name if mod.object else None,
'solver': mod.solver
})
elif mod.type == 'SOLIDIFY':
info.update({
'thickness': mod.thickness,
'offset': mod.offset,
'use_rim': mod.use_rim,
'use_even_offset': mod.use_even_offset,
'material_offset': mod.material_offset
})
return info
def reorder_modifier(object_name: str, modifier_name: str, direction: str) -> Dict[str, Any]:
"""모디파이어 순서 변경
Args:
object_name: 대상 오브젝트 이름
modifier_name: 모디파이어 이름
direction: 'UP' 또는 'DOWN'
"""
logger.info(f"Moving modifier '{modifier_name}' {direction} on '{object_name}'")
obj = bpy.data.objects.get(object_name)
if not obj:
raise ValueError(f"Object '{object_name}' not found")
mod = obj.modifiers.get(modifier_name)
if not mod:
raise ValueError(f"Modifier '{modifier_name}' not found on '{object_name}'")
# 오브젝트 선택 및 활성화
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if direction.upper() == 'UP':
bpy.ops.object.modifier_move_up(modifier=modifier_name)
elif direction.upper() == 'DOWN':
bpy.ops.object.modifier_move_down(modifier=modifier_name)
else:
raise ValueError(f"Invalid direction '{direction}'. Use 'UP' or 'DOWN'")
# 현재 순서 반환
modifiers = [m.name for m in obj.modifiers]
return {
'status': 'success',
'modifier': modifier_name,
'new_order': modifiers
}

View File

@@ -0,0 +1,260 @@
"""
Animation Retargeting 관련 명령 핸들러
본 매핑, 애니메이션 리타게팅 실행
"""
import bpy
from typing import Dict
from ..utils.logger import get_logger
from ..utils.bone_matching import fuzzy_match_bones, get_match_quality_report
logger = get_logger(__name__)
def auto_map_bones(source_armature: str, target_armature: str) -> Dict[str, str]:
"""
자동 본 매핑 (Mixamo -> 사용자 캐릭터)
Fuzzy matching 알고리즘 사용으로 정확도 개선
Args:
source_armature: 소스 아마추어 이름 (Mixamo)
target_armature: 타겟 아마추어 이름 (사용자 캐릭터)
Returns:
본 매핑 딕셔너리 {source_bone: target_bone}
Raises:
ValueError: 아마추어를 찾을 수 없는 경우
"""
logger.info(f"Auto-mapping bones: {source_armature} -> {target_armature}")
source = bpy.data.objects.get(source_armature)
target = bpy.data.objects.get(target_armature)
if not source or not target:
logger.error("Source or target armature not found")
raise ValueError("Source or target armature not found")
# Mixamo 표준 본 이름과 알려진 별칭 (확장: 손가락, 발가락 포함)
mixamo_bone_aliases = {
# 몸통 (6개)
"Hips": ["hips", "pelvis", "root"],
"Spine": ["spine", "spine1"],
"Spine1": ["spine1", "spine2"],
"Spine2": ["spine2", "spine3", "chest"],
"Neck": ["neck"],
"Head": ["head"],
# 왼쪽 팔 (4개)
"LeftShoulder": ["shoulder.l", "clavicle.l", "leftshoulder"],
"LeftArm": ["upper_arm.l", "leftarm", "upperarm.l"],
"LeftForeArm": ["forearm.l", "leftforearm", "lowerarm.l"],
"LeftHand": ["hand.l", "lefthand"],
# 오른쪽 팔 (4개)
"RightShoulder": ["shoulder.r", "clavicle.r", "rightshoulder"],
"RightArm": ["upper_arm.r", "rightarm", "upperarm.r"],
"RightForeArm": ["forearm.r", "rightforearm", "lowerarm.r"],
"RightHand": ["hand.r", "righthand"],
# 왼쪽 다리 (4개)
"LeftUpLeg": ["thigh.l", "leftupleg", "upperleg.l"],
"LeftLeg": ["shin.l", "leftleg", "lowerleg.l"],
"LeftFoot": ["foot.l", "leftfoot"],
"LeftToeBase": ["toe.l", "lefttoebase", "foot.l.001"],
# 오른쪽 다리 (4개)
"RightUpLeg": ["thigh.r", "rightupleg", "upperleg.r"],
"RightLeg": ["shin.r", "rightleg", "lowerleg.r"],
"RightFoot": ["foot.r", "rightfoot"],
"RightToeBase": ["toe.r", "righttoebase", "foot.r.001"],
# 왼쪽 손가락 (15개)
"LeftHandThumb1": ["thumb.01.l", "lefthandthumb1", "thumb_01.l"],
"LeftHandThumb2": ["thumb.02.l", "lefthandthumb2", "thumb_02.l"],
"LeftHandThumb3": ["thumb.03.l", "lefthandthumb3", "thumb_03.l"],
"LeftHandIndex1": ["f_index.01.l", "lefthandindex1", "index_01.l"],
"LeftHandIndex2": ["f_index.02.l", "lefthandindex2", "index_02.l"],
"LeftHandIndex3": ["f_index.03.l", "lefthandindex3", "index_03.l"],
"LeftHandMiddle1": ["f_middle.01.l", "lefthandmiddle1", "middle_01.l"],
"LeftHandMiddle2": ["f_middle.02.l", "lefthandmiddle2", "middle_02.l"],
"LeftHandMiddle3": ["f_middle.03.l", "lefthandmiddle3", "middle_03.l"],
"LeftHandRing1": ["f_ring.01.l", "lefthandring1", "ring_01.l"],
"LeftHandRing2": ["f_ring.02.l", "lefthandring2", "ring_02.l"],
"LeftHandRing3": ["f_ring.03.l", "lefthandring3", "ring_03.l"],
"LeftHandPinky1": ["f_pinky.01.l", "lefthandpinky1", "pinky_01.l"],
"LeftHandPinky2": ["f_pinky.02.l", "lefthandpinky2", "pinky_02.l"],
"LeftHandPinky3": ["f_pinky.03.l", "lefthandpinky3", "pinky_03.l"],
# 오른쪽 손가락 (15개)
"RightHandThumb1": ["thumb.01.r", "righthandthumb1", "thumb_01.r"],
"RightHandThumb2": ["thumb.02.r", "righthandthumb2", "thumb_02.r"],
"RightHandThumb3": ["thumb.03.r", "righthandthumb3", "thumb_03.r"],
"RightHandIndex1": ["f_index.01.r", "righthandindex1", "index_01.r"],
"RightHandIndex2": ["f_index.02.r", "righthandindex2", "index_02.r"],
"RightHandIndex3": ["f_index.03.r", "righthandindex3", "index_03.r"],
"RightHandMiddle1": ["f_middle.01.r", "righthandmiddle1", "middle_01.r"],
"RightHandMiddle2": ["f_middle.02.r", "righthandmiddle2", "middle_02.r"],
"RightHandMiddle3": ["f_middle.03.r", "righthandmiddle3", "middle_03.r"],
"RightHandRing1": ["f_ring.01.r", "righthandring1", "ring_01.r"],
"RightHandRing2": ["f_ring.02.r", "righthandring2", "ring_02.r"],
"RightHandRing3": ["f_ring.03.r", "righthandring3", "ring_03.r"],
"RightHandPinky1": ["f_pinky.01.r", "righthandpinky1", "pinky_01.r"],
"RightHandPinky2": ["f_pinky.02.r", "righthandpinky2", "pinky_02.r"],
"RightHandPinky3": ["f_pinky.03.r", "righthandpinky3", "pinky_03.r"],
}
# 소스 본 리스트 (실제로 존재하는 본만)
source_bones = [bone.name for bone in source.data.bones
if bone.name in mixamo_bone_aliases]
# 타겟 본 리스트
target_bones = [bone.name for bone in target.data.bones]
# Fuzzy matching 실행 (정확한 매칭 우선, 그 다음 유사도 매칭)
logger.info("Running fuzzy bone matching algorithm...")
logger.debug(f"Source bones: {len(source_bones)}, Target bones: {len(target_bones)}")
bone_map = fuzzy_match_bones(
source_bones=source_bones,
target_bones=target_bones,
known_aliases=mixamo_bone_aliases,
threshold=0.6, # 60% 이상 유사도
prefer_exact=True # 정확한 매칭 우선
)
# 매칭 품질 보고서
quality_report = get_match_quality_report(bone_map)
logger.info(f"Auto-mapped {quality_report['total_mappings']} bones")
logger.info(f"Quality: {quality_report['quality'].upper()}")
logger.info(f"Critical bones: {quality_report['critical_bones_mapped']}")
logger.debug(f"Bone mapping: {bone_map}")
# 콘솔 출력 (사용자에게 피드백)
print(f"✅ Auto-mapped {quality_report['total_mappings']} bones")
print(f" Quality: {quality_report['quality'].upper()}")
print(f" Critical bones: {quality_report['critical_bones_mapped']}")
return bone_map
def retarget_animation(
source_armature: str,
target_armature: str,
bone_map: Dict[str, str],
preserve_rotation: bool = True,
preserve_location: bool = False
) -> str:
"""
애니메이션 리타게팅 실행
Args:
source_armature: 소스 아마추어 이름
target_armature: 타겟 아마추어 이름
bone_map: 본 매핑 딕셔너리
preserve_rotation: 회전 보존 여부
preserve_location: 위치 보존 여부 (보통 루트 본만)
Returns:
결과 메시지
Raises:
ValueError: 아마추어를 찾을 수 없거나 애니메이션이 없는 경우
"""
logger.info(f"Retargeting animation: {source_armature} -> {target_armature}")
logger.debug(f"Bone mappings: {len(bone_map)}, Rotation: {preserve_rotation}, Location: {preserve_location}")
source = bpy.data.objects.get(source_armature)
target = bpy.data.objects.get(target_armature)
if not source or not target:
logger.error("Source or target armature not found")
raise ValueError("Source or target armature not found")
if not source.animation_data or not source.animation_data.action:
logger.error("Source armature has no animation")
raise ValueError("Source armature has no animation")
# 타겟 아마추어 선택
bpy.context.view_layer.objects.active = target
target.select_set(True)
# Pose 모드로 전환
bpy.ops.object.mode_set(mode='POSE')
# 각 본에 대해 컨스트레인트 생성
constraints_added = 0
for source_bone_name, target_bone_name in bone_map.items():
if source_bone_name not in source.pose.bones:
logger.debug(f"Source bone not found: {source_bone_name}")
continue
if target_bone_name not in target.pose.bones:
logger.debug(f"Target bone not found: {target_bone_name}")
continue
target_bone = target.pose.bones[target_bone_name]
# Rotation constraint
if preserve_rotation:
constraint = target_bone.constraints.new('COPY_ROTATION')
constraint.target = source
constraint.subtarget = source_bone_name
constraints_added += 1
# Location constraint (일반적으로 루트 본만)
if preserve_location and source_bone_name == "Hips":
constraint = target_bone.constraints.new('COPY_LOCATION')
constraint.target = source
constraint.subtarget = source_bone_name
constraints_added += 1
logger.info(f"Added {constraints_added} constraints")
# 컨스트레인트를 키프레임으로 베이크
logger.info("Baking constraints to keyframes...")
bpy.ops.nla.bake(
frame_start=bpy.context.scene.frame_start,
frame_end=bpy.context.scene.frame_end,
only_selected=False,
visual_keying=True,
clear_constraints=True,
bake_types={'POSE'}
)
bpy.ops.object.mode_set(mode='OBJECT')
logger.info("Animation retargeting completed successfully")
return f"Animation retargeted to {target_armature}"
def get_preset_bone_mapping(preset: str) -> Dict[str, str]:
"""
미리 정의된 본 매핑 프리셋
Args:
preset: 프리셋 이름 (예: "mixamo_to_rigify")
Returns:
본 매핑 딕셔너리
"""
logger.debug(f"Getting bone mapping preset: {preset}")
presets = {
"mixamo_to_rigify": {
"Hips": "torso",
"Spine": "spine",
"Spine1": "spine.001",
"Spine2": "spine.002",
"Neck": "neck",
"Head": "head",
"LeftShoulder": "shoulder.L",
"LeftArm": "upper_arm.L",
"LeftForeArm": "forearm.L",
"LeftHand": "hand.L",
# ... 더 많은 매핑 추가 가능
}
}
bone_map = presets.get(preset, {})
logger.info(f"Preset '{preset}' loaded with {len(bone_map)} mappings")
return bone_map

View File

@@ -0,0 +1,22 @@
{
"include": [
"."
],
"exclude": [
"**/__pycache__",
".venv",
"commands"
],
"extraPaths": [
"D:/Blender/Blender_Launcher_v1.15.1_Windows_x64/stable/blender-4.5.4-lts.b3efe983cc58/4.5/scripts/modules",
"D:/Blender/Blender_Launcher_v1.15.1_Windows_x64/stable/blender-4.5.4-lts.b3efe983cc58/4.5/python/lib/site-packages"
],
"typeCheckingMode": "basic",
"reportMissingImports": false,
"reportMissingModuleSource": false,
"reportUnknownParameterType": "warning",
"reportUnknownMemberType": "warning",
"reportMissingParameterType": "warning",
"pythonVersion": "3.11",
"pythonPlatform": "Windows"
}

View File

@@ -0,0 +1 @@
aiohttp>=3.8,<4.0

532
skills/addon/retargeting.py Normal file
View File

@@ -0,0 +1,532 @@
"""
Animation Retargeting Module
Blender 애니메이션 리타게팅 관련 함수들
주요 기능:
- 아마추어 및 본 정보 조회
- 자동 본 매핑 (Fuzzy matching)
- 애니메이션 리타게팅
- 애니메이션 재생 및 NLA 트랙 관리
- FBX/DAE 임포트
- 본 매핑 저장/로드
"""
import os
from typing import Dict, List, Optional
import bpy
# Fuzzy bone matching utilities
from .utils.bone_matching import (
fuzzy_match_bones,
get_match_quality_report,
)
# Logging utilities
from .utils.logger import get_logger
# 모듈 로거 초기화
logger = get_logger('retargeting')
# ============================================================================
# Armature & Bone Query Functions
# ============================================================================
def list_armatures() -> List[str]:
"""
모든 아마추어 오브젝트 목록 반환
Returns:
아마추어 오브젝트 이름 리스트
"""
return [obj.name for obj in bpy.data.objects if obj.type == 'ARMATURE']
def get_bones(armature_name: str) -> List[Dict[str, Optional[str]]]:
"""
아마추어의 본 정보 가져오기
Args:
armature_name: 아마추어 오브젝트 이름
Returns:
본 정보 리스트 (name, parent, children)
Raises:
ValueError: 아마추어를 찾을 수 없는 경우
"""
armature = bpy.data.objects.get(armature_name)
if not armature or armature.type != 'ARMATURE':
raise ValueError(f"Armature '{armature_name}' not found")
bones = []
for bone in armature.data.bones:
bones.append({
"name": bone.name,
"parent": bone.parent.name if bone.parent else None,
"children": [child.name for child in bone.children]
})
return bones
# ============================================================================
# Bone Mapping Functions
# ============================================================================
def auto_map_bones(source_armature: str, target_armature: str) -> Dict[str, str]:
"""
자동 본 매핑 (Mixamo -> 사용자 캐릭터)
Fuzzy matching 알고리즘 사용으로 정확도 개선
Args:
source_armature: 소스 아마추어 이름 (예: Mixamo)
target_armature: 타겟 아마추어 이름 (사용자 캐릭터)
Returns:
본 매핑 딕셔너리 {소스 본: 타겟 본}
Raises:
ValueError: 아마추어를 찾을 수 없는 경우
"""
source = bpy.data.objects.get(source_armature)
target = bpy.data.objects.get(target_armature)
if not source or not target:
raise ValueError("Source or target armature not found")
# Mixamo 표준 본 이름과 알려진 별칭 (확장: 손가락, 발가락 포함)
mixamo_bone_aliases = {
# 몸통 (6개)
"Hips": ["hips", "pelvis", "root"],
"Spine": ["spine", "spine1"],
"Spine1": ["spine1", "spine2"],
"Spine2": ["spine2", "spine3", "chest"],
"Neck": ["neck"],
"Head": ["head"],
# 왼쪽 팔 (4개)
"LeftShoulder": ["shoulder.l", "clavicle.l", "leftshoulder"],
"LeftArm": ["upper_arm.l", "leftarm", "upperarm.l"],
"LeftForeArm": ["forearm.l", "leftforearm", "lowerarm.l"],
"LeftHand": ["hand.l", "lefthand"],
# 오른쪽 팔 (4개)
"RightShoulder": ["shoulder.r", "clavicle.r", "rightshoulder"],
"RightArm": ["upper_arm.r", "rightarm", "upperarm.r"],
"RightForeArm": ["forearm.r", "rightforearm", "lowerarm.r"],
"RightHand": ["hand.r", "righthand"],
# 왼쪽 다리 (4개)
"LeftUpLeg": ["thigh.l", "leftupleg", "upperleg.l"],
"LeftLeg": ["shin.l", "leftleg", "lowerleg.l"],
"LeftFoot": ["foot.l", "leftfoot"],
"LeftToeBase": ["toe.l", "lefttoebase", "foot.l.001"],
# 오른쪽 다리 (4개)
"RightUpLeg": ["thigh.r", "rightupleg", "upperleg.r"],
"RightLeg": ["shin.r", "rightleg", "lowerleg.r"],
"RightFoot": ["foot.r", "rightfoot"],
"RightToeBase": ["toe.r", "righttoebase", "foot.r.001"],
# 왼쪽 손가락 (15개)
"LeftHandThumb1": ["thumb.01.l", "lefthandthumb1", "thumb_01.l"],
"LeftHandThumb2": ["thumb.02.l", "lefthandthumb2", "thumb_02.l"],
"LeftHandThumb3": ["thumb.03.l", "lefthandthumb3", "thumb_03.l"],
"LeftHandIndex1": ["f_index.01.l", "lefthandindex1", "index_01.l"],
"LeftHandIndex2": ["f_index.02.l", "lefthandindex2", "index_02.l"],
"LeftHandIndex3": ["f_index.03.l", "lefthandindex3", "index_03.l"],
"LeftHandMiddle1": ["f_middle.01.l", "lefthandmiddle1", "middle_01.l"],
"LeftHandMiddle2": ["f_middle.02.l", "lefthandmiddle2", "middle_02.l"],
"LeftHandMiddle3": ["f_middle.03.l", "lefthandmiddle3", "middle_03.l"],
"LeftHandRing1": ["f_ring.01.l", "lefthandring1", "ring_01.l"],
"LeftHandRing2": ["f_ring.02.l", "lefthandring2", "ring_02.l"],
"LeftHandRing3": ["f_ring.03.l", "lefthandring3", "ring_03.l"],
"LeftHandPinky1": ["f_pinky.01.l", "lefthandpinky1", "pinky_01.l"],
"LeftHandPinky2": ["f_pinky.02.l", "lefthandpinky2", "pinky_02.l"],
"LeftHandPinky3": ["f_pinky.03.l", "lefthandpinky3", "pinky_03.l"],
# 오른쪽 손가락 (15개)
"RightHandThumb1": ["thumb.01.r", "righthandthumb1", "thumb_01.r"],
"RightHandThumb2": ["thumb.02.r", "righthandthumb2", "thumb_02.r"],
"RightHandThumb3": ["thumb.03.r", "righthandthumb3", "thumb_03.r"],
"RightHandIndex1": ["f_index.01.r", "righthandindex1", "index_01.r"],
"RightHandIndex2": ["f_index.02.r", "righthandindex2", "index_02.r"],
"RightHandIndex3": ["f_index.03.r", "righthandindex3", "index_03.r"],
"RightHandMiddle1": ["f_middle.01.r", "righthandmiddle1", "middle_01.r"],
"RightHandMiddle2": ["f_middle.02.r", "righthandmiddle2", "middle_02.r"],
"RightHandMiddle3": ["f_middle.03.r", "righthandmiddle3", "middle_03.r"],
"RightHandRing1": ["f_ring.01.r", "righthandring1", "ring_01.r"],
"RightHandRing2": ["f_ring.02.r", "righthandring2", "ring_02.r"],
"RightHandRing3": ["f_ring.03.r", "righthandring3", "ring_03.r"],
"RightHandPinky1": ["f_pinky.01.r", "righthandpinky1", "pinky_01.r"],
"RightHandPinky2": ["f_pinky.02.r", "righthandpinky2", "pinky_02.r"],
"RightHandPinky3": ["f_pinky.03.r", "righthandpinky3", "pinky_03.r"],
}
# 소스 본 리스트 (실제로 존재하는 본만)
source_bones = [bone.name for bone in source.data.bones
if bone.name in mixamo_bone_aliases]
# 타겟 본 리스트
target_bones = [bone.name for bone in target.data.bones]
# Fuzzy matching 실행 (정확한 매칭 우선, 그 다음 유사도 매칭)
logger.info("Running fuzzy bone matching algorithm...")
logger.debug("Source bones: %d, Target bones: %d", len(source_bones), len(target_bones))
bone_map = fuzzy_match_bones(
source_bones=source_bones,
target_bones=target_bones,
known_aliases=mixamo_bone_aliases,
threshold=0.6, # 60% 이상 유사도
prefer_exact=True # 정확한 매칭 우선
)
# 매칭 품질 보고서
quality_report = get_match_quality_report(bone_map)
logger.info("Auto-mapped %d bones", quality_report['total_mappings'])
logger.info("Quality: %s", quality_report['quality'].upper())
logger.info("Critical bones: %s", quality_report['critical_bones_mapped'])
logger.debug("Bone mapping: %s", bone_map)
# 콘솔 출력 (사용자에게 피드백)
print(f"✅ Auto-mapped {quality_report['total_mappings']} bones")
print(f" Quality: {quality_report['quality'].upper()}")
print(f" Critical bones: {quality_report['critical_bones_mapped']}")
return bone_map
def get_preset_bone_mapping(preset: str) -> Dict[str, str]:
"""
미리 정의된 본 매핑 프리셋 반환
Args:
preset: 프리셋 이름 (예: "mixamo_to_rigify")
Returns:
본 매핑 딕셔너리
"""
presets = {
"mixamo_to_rigify": {
"Hips": "torso",
"Spine": "spine",
"Spine1": "spine.001",
"Spine2": "spine.002",
"Neck": "neck",
"Head": "head",
"LeftShoulder": "shoulder.L",
"LeftArm": "upper_arm.L",
"LeftForeArm": "forearm.L",
"LeftHand": "hand.L",
# ... 더 많은 매핑
}
}
return presets.get(preset, {})
def store_bone_mapping(
source_armature: str, target_armature: str, bone_mapping: Dict[str, str]
) -> str:
"""
본 매핑을 Scene 속성에 저장
Args:
source_armature: 소스 아마추어 이름
target_armature: 타겟 아마추어 이름
bone_mapping: 본 매핑 딕셔너리
Returns:
작업 결과 메시지
"""
scene = bpy.context.scene
# 기존 매핑 클리어
scene.bone_mapping_items.clear()
# 새 매핑 저장
for source_bone, target_bone in bone_mapping.items():
item = scene.bone_mapping_items.add()
item.source_bone = source_bone
item.target_bone = target_bone
# 아마추어 정보 저장
scene.bone_mapping_source_armature = source_armature
scene.bone_mapping_target_armature = target_armature
print(f"✅ Stored bone mapping: {len(bone_mapping)} bones")
return f"Bone mapping stored ({len(bone_mapping)} bones)"
def load_bone_mapping(source_armature: str, target_armature: str) -> Dict[str, str]:
"""
Scene 속성에서 본 매핑 로드
Args:
source_armature: 소스 아마추어 이름
target_armature: 타겟 아마추어 이름
Returns:
본 매핑 딕셔너리
Raises:
ValueError: 저장된 매핑이 없거나 아마추어가 일치하지 않는 경우
"""
scene = bpy.context.scene
# 아마추어 검증
if not scene.bone_mapping_source_armature:
raise ValueError(
"No bone mapping stored. Please generate mapping first using "
"BoneMapping.show command."
)
if (scene.bone_mapping_source_armature != source_armature or
scene.bone_mapping_target_armature != target_armature):
raise ValueError(
f"Stored mapping for ({scene.bone_mapping_source_armature}"
f"{scene.bone_mapping_target_armature}) doesn't match requested "
f"({source_armature}{target_armature})"
)
# 매핑 로드
bone_mapping = {}
for item in scene.bone_mapping_items:
bone_mapping[item.source_bone] = item.target_bone
if not bone_mapping:
raise ValueError("Bone mapping is empty. Please generate mapping first.")
print(f"✅ Loaded bone mapping: {len(bone_mapping)} bones")
return bone_mapping
# ============================================================================
# Animation Retargeting Functions
# ============================================================================
def retarget_animation(
source_armature: str,
target_armature: str,
bone_map: Dict[str, str],
preserve_rotation: bool = True,
preserve_location: bool = False
) -> str:
"""
애니메이션 리타게팅 실행
Args:
source_armature: 소스 아마추어 이름
target_armature: 타겟 아마추어 이름
bone_map: 본 매핑 딕셔너리
preserve_rotation: 회전 보존 여부
preserve_location: 위치 보존 여부
Returns:
작업 결과 메시지
Raises:
ValueError: 아마추어를 찾을 수 없거나 애니메이션이 없는 경우
"""
source = bpy.data.objects.get(source_armature)
target = bpy.data.objects.get(target_armature)
if not source or not target:
raise ValueError("Source or target armature not found")
if not source.animation_data or not source.animation_data.action:
raise ValueError("Source armature has no animation")
# 타겟 아마추어 선택
bpy.context.view_layer.objects.active = target
target.select_set(True)
# Pose 모드로 전환
bpy.ops.object.mode_set(mode='POSE')
# 각 본에 대해 컨스트레인트 생성
for source_bone_name, target_bone_name in bone_map.items():
if source_bone_name not in source.pose.bones:
continue
if target_bone_name not in target.pose.bones:
continue
target_bone = target.pose.bones[target_bone_name]
# Rotation constraint
if preserve_rotation:
constraint = target_bone.constraints.new('COPY_ROTATION')
constraint.target = source
constraint.subtarget = source_bone_name
# Location constraint (일반적으로 루트 본만)
if preserve_location and source_bone_name == "Hips":
constraint = target_bone.constraints.new('COPY_LOCATION')
constraint.target = source
constraint.subtarget = source_bone_name
# 컨스트레인트를 키프레임으로 베이크
bpy.ops.nla.bake(
frame_start=bpy.context.scene.frame_start,
frame_end=bpy.context.scene.frame_end,
only_selected=False,
visual_keying=True,
clear_constraints=True,
bake_types={'POSE'}
)
bpy.ops.object.mode_set(mode='OBJECT')
return f"Animation retargeted to {target_armature}"
# ============================================================================
# Animation Playback Functions
# ============================================================================
def list_animations(armature_name: str) -> List[str]:
"""
아마추어의 애니메이션 액션 목록 반환
Args:
armature_name: 아마추어 이름
Returns:
액션 이름 리스트
Raises:
ValueError: 아마추어를 찾을 수 없는 경우
"""
armature = bpy.data.objects.get(armature_name)
if not armature:
raise ValueError(f"Armature '{armature_name}' not found")
actions = []
if armature.animation_data:
for action in bpy.data.actions:
if action.id_root == 'OBJECT':
actions.append(action.name)
return actions
def play_animation(armature_name: str, action_name: str, loop: bool = True) -> str:
"""
애니메이션 재생
Args:
armature_name: 아마추어 이름
action_name: 액션 이름
loop: 루프 재생 여부
Returns:
작업 결과 메시지
Raises:
ValueError: 아마추어 또는 액션을 찾을 수 없는 경우
"""
armature = bpy.data.objects.get(armature_name)
if not armature:
raise ValueError(f"Armature '{armature_name}' not found")
action = bpy.data.actions.get(action_name)
if not action:
raise ValueError(f"Action '{action_name}' not found")
if not armature.animation_data:
armature.animation_data_create()
armature.animation_data.action = action
bpy.context.scene.frame_set(int(action.frame_range[0]))
bpy.ops.screen.animation_play()
return f"Playing {action_name}"
def stop_animation() -> str:
"""
애니메이션 중지
Returns:
작업 결과 메시지
"""
bpy.ops.screen.animation_cancel()
return "Animation stopped"
def add_to_nla(armature_name: str, action_name: str, track_name: str) -> str:
"""
NLA 트랙에 애니메이션 추가
Args:
armature_name: 아마추어 이름
action_name: 액션 이름
track_name: NLA 트랙 이름
Returns:
작업 결과 메시지
Raises:
ValueError: 아마추어 또는 액션을 찾을 수 없는 경우
"""
armature = bpy.data.objects.get(armature_name)
action = bpy.data.actions.get(action_name)
if not armature or not action:
raise ValueError("Armature or action not found")
if not armature.animation_data:
armature.animation_data_create()
nla_track = armature.animation_data.nla_tracks.new()
nla_track.name = track_name
nla_track.strips.new(action.name, int(action.frame_range[0]), action)
return f"Added {action_name} to NLA track {track_name}"
# ============================================================================
# Import Functions
# ============================================================================
def import_fbx(filepath: str) -> str:
"""
FBX 파일 임포트
Args:
filepath: FBX 파일 경로
Returns:
작업 결과 메시지
Raises:
ValueError: 파일을 찾을 수 없는 경우
"""
if not os.path.exists(filepath):
raise ValueError(f"File not found: {filepath}")
bpy.ops.import_scene.fbx(filepath=filepath)
return f"Imported {filepath}"
def import_dae(filepath: str) -> str:
"""
Collada (.dae) 파일 임포트
Args:
filepath: DAE 파일 경로
Returns:
작업 결과 메시지
Raises:
ValueError: 파일을 찾을 수 없는 경우
"""
if not os.path.exists(filepath):
raise ValueError(f"File not found: {filepath}")
bpy.ops.wm.collada_import(filepath=filepath)
return f"Imported {filepath}"

350
skills/addon/ui.py Normal file
View File

@@ -0,0 +1,350 @@
"""
Blender Toolkit UI Components
UI 패널, 오퍼레이터, 속성 그룹 정의
"""
import asyncio
import threading
from typing import Any
import bpy
from .retargeting import auto_map_bones, retarget_animation
# ============================================================================
# Blender UI Panel
# ============================================================================
class BLENDERTOOLKIT_PT_Panel(bpy.types.Panel):
"""Blender Toolkit 사이드바 패널"""
bl_label = "Blender Toolkit"
bl_idname = "BLENDERTOOLKIT_PT_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Blender Toolkit'
def draw(self, context):
"""UI 패널 그리기."""
layout = self.layout
# 서버 상태 표시
layout.label(text="WebSocket Server", icon='NETWORK_DRIVE')
# 서버 시작/중지 버튼
row = layout.row()
row.operator("blendertoolkit.start_server", text="Start Server", icon='PLAY')
row.operator("blendertoolkit.stop_server", text="Stop Server", icon='PAUSE')
layout.separator()
# 포트 설정
layout.prop(context.scene, "blender_toolkit_port", text="Port")
class BLENDERTOOLKIT_OT_StartServer(bpy.types.Operator):
"""서버 시작 오퍼레이터"""
bl_idname = "blendertoolkit.start_server"
bl_label = "Start WebSocket Server"
def execute(self, context):
"""WebSocket 서버를 시작하는 오퍼레이터 실행."""
# Import here to avoid circular dependency
from . import BlenderWebSocketServer
port = context.scene.blender_toolkit_port
# Check if server is already running
if hasattr(bpy.types.Scene, '_blender_toolkit_server_thread'):
server_thread = bpy.types.Scene._blender_toolkit_server_thread
if server_thread and server_thread.is_alive():
self.report({'INFO'}, f"WebSocket server already running on port {port}")
return {'CANCELLED'}
# Start server in background thread
def run_server():
"""서버를 별도 스레드에서 실행"""
try:
server = BlenderWebSocketServer(port)
# Store server instance globally
bpy.types.Scene._blender_toolkit_server = server
# Create new event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Store loop reference for graceful shutdown
bpy.types.Scene._blender_toolkit_event_loop = loop
# Run server until stopped
loop.run_until_complete(server.start())
# Keep loop running until stop() is called
loop.run_forever()
except (RuntimeError, OSError, ValueError) as e:
print(f"Blender Toolkit Server Error: {e}")
finally:
# Cleanup
loop.close()
# Start server thread
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Store thread reference
bpy.types.Scene._blender_toolkit_server_thread = server_thread
bpy.types.Scene._blender_toolkit_server_port = port
self.report({'INFO'}, f"✓ WebSocket server started on port {port}")
print(f"Blender Toolkit: WebSocket server started on ws://127.0.0.1:{port}")
return {'FINISHED'}
class BLENDERTOOLKIT_OT_StopServer(bpy.types.Operator):
"""서버 중지 오퍼레이터"""
bl_idname = "blendertoolkit.stop_server"
bl_label = "Stop WebSocket Server"
def execute(self, context):
"""WebSocket 서버를 중지하는 오퍼레이터 실행."""
# Check if server is running
if not hasattr(bpy.types.Scene, '_blender_toolkit_server_thread'):
self.report({'WARNING'}, "WebSocket server is not running")
return {'CANCELLED'}
server_thread = bpy.types.Scene._blender_toolkit_server_thread
if not server_thread or not server_thread.is_alive():
self.report({'WARNING'}, "WebSocket server is not running")
# Clean up stale references
self._cleanup_references()
return {'CANCELLED'}
# Get server instance and event loop
server = getattr(bpy.types.Scene, '_blender_toolkit_server', None)
loop = getattr(bpy.types.Scene, '_blender_toolkit_event_loop', None)
port = getattr(bpy.types.Scene, '_blender_toolkit_server_port', 'unknown')
if not server or not loop:
self.report({'WARNING'}, "Server instance not found. Please restart Blender to fully stop the server.")
self._cleanup_references()
return {'CANCELLED'}
try:
# Schedule server stop in the event loop thread
def stop_server():
"""이벤트 루프에서 서버를 안전하게 종료합니다."""
async def _shutdown():
try:
# 서버의 비동기 중지 메서드를 호출하고 완료될 때까지 기다립니다.
await server.stop()
except (RuntimeError, ValueError) as e:
print(f"Error stopping server: {e}")
finally:
# 서버가 완전히 중지된 후에 이벤트 루프를 멈춥니다.
loop.stop()
# 스레드 안전하게 비동기 종료 시퀀스를 스케줄링합니다.
asyncio.ensure_future(_shutdown())
# Call stop_server() in the event loop thread (thread-safe)
loop.call_soon_threadsafe(stop_server)
self.report({'INFO'}, f"✓ WebSocket server on port {port} stopped successfully")
print(f"Blender Toolkit: WebSocket server on port {port} stopped")
except (RuntimeError, ValueError, AttributeError) as e:
self.report({'ERROR'}, f"Failed to stop server: {str(e)}")
print(f"Blender Toolkit: Error stopping server: {e}")
return {'CANCELLED'}
finally:
# Clean up all references
self._cleanup_references()
return {'FINISHED'}
def _cleanup_references(self):
"""Clean up all server references"""
if hasattr(bpy.types.Scene, '_blender_toolkit_server'):
delattr(bpy.types.Scene, '_blender_toolkit_server')
if hasattr(bpy.types.Scene, '_blender_toolkit_event_loop'):
delattr(bpy.types.Scene, '_blender_toolkit_event_loop')
if hasattr(bpy.types.Scene, '_blender_toolkit_server_thread'):
delattr(bpy.types.Scene, '_blender_toolkit_server_thread')
if hasattr(bpy.types.Scene, '_blender_toolkit_server_port'):
delattr(bpy.types.Scene, '_blender_toolkit_server_port')
class BLENDERTOOLKIT_PT_BoneMappingPanel(bpy.types.Panel):
"""본 매핑 리뷰 패널"""
bl_label = "Bone Mapping Review"
bl_idname = "BLENDERTOOLKIT_PT_bone_mapping"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Blender Toolkit'
bl_options = {'DEFAULT_CLOSED'}
def draw(self, context):
"""본 매핑 리뷰 패널 그리기."""
layout = self.layout
scene = context.scene
# Show armature info
if scene.bone_mapping_source_armature and scene.bone_mapping_target_armature:
layout.label(text=f"Source: {scene.bone_mapping_source_armature}", icon='ARMATURE_DATA')
layout.label(text=f"Target: {scene.bone_mapping_target_armature}", icon='ARMATURE_DATA')
layout.separator()
# Bone mapping table
if len(scene.bone_mapping_items) > 0:
mapping_count = len(scene.bone_mapping_items)
layout.label(
text=f"Bone Mappings ({mapping_count}):", icon='BONE_DATA'
)
# Header
box = layout.box()
row = box.row()
row.label(text="Source Bone")
row.label(text="")
row.label(text="Target Bone")
# Mapping items (scrollable)
for _, item in enumerate(scene.bone_mapping_items):
row = layout.row()
row.label(text=item.source_bone)
row.label(text="")
# Editable target bone dropdown
target_armature = bpy.data.objects.get(scene.bone_mapping_target_armature)
if target_armature and target_armature.type == 'ARMATURE':
row.prop_search(item, "target_bone", target_armature.data, "bones", text="")
else:
row.prop(item, "target_bone", text="")
layout.separator()
# Auto re-map button
layout.operator(
"blendertoolkit.auto_remap", text="Auto Re-map",
icon='FILE_REFRESH'
)
# Apply retargeting button
layout.separator()
# Show status
if hasattr(scene, 'bone_mapping_status'):
if scene.bone_mapping_status == "APPLYING":
layout.label(text="⏳ Applying retargeting...", icon='TIME')
elif scene.bone_mapping_status == "COMPLETED":
layout.label(text="✓ Retargeting completed!", icon='CHECKMARK')
elif scene.bone_mapping_status == "FAILED":
layout.label(text="✗ Retargeting failed", icon='ERROR')
layout.operator(
"blendertoolkit.apply_retargeting",
text="Apply Retargeting", icon='PLAY'
)
else:
layout.label(text="No bone mapping data", icon='INFO')
else:
layout.label(text="No bone mapping loaded", icon='INFO')
layout.label(text="Waiting for Claude Code...", icon='TIME')
class BLENDERTOOLKIT_OT_AutoRemap(bpy.types.Operator):
"""자동 재매핑 오퍼레이터"""
bl_idname = "blendertoolkit.auto_remap"
bl_label = "Auto Re-map Bones"
bl_description = "Re-generate bone mapping automatically"
def execute(self, context):
"""자동 본 재매핑 오퍼레이터 실행."""
scene = context.scene
source_armature = scene.bone_mapping_source_armature
target_armature = scene.bone_mapping_target_armature
if not source_armature or not target_armature:
self.report({'ERROR'}, "Source or target armature not set")
return {'CANCELLED'}
try:
# Auto-map bones
bone_map = auto_map_bones(source_armature, target_armature)
# Update scene properties
scene.bone_mapping_items.clear()
for source_bone, target_bone in bone_map.items():
item = scene.bone_mapping_items.add()
item.source_bone = source_bone
item.target_bone = target_bone
self.report({'INFO'}, f"Re-mapped {len(bone_map)} bones")
except (ValueError, KeyError, AttributeError) as e:
self.report({'ERROR'}, f"Auto re-mapping failed: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
class BLENDERTOOLKIT_OT_ApplyRetargeting(bpy.types.Operator):
"""리타게팅 적용 오퍼레이터"""
bl_idname = "blendertoolkit.apply_retargeting"
bl_label = "Apply Retargeting"
bl_description = "Apply retargeting with current bone mapping"
def execute(self, context):
"""애니메이션 리타게팅을 적용하는 오퍼레이터 실행."""
scene = context.scene
source_armature = scene.bone_mapping_source_armature
target_armature = scene.bone_mapping_target_armature
if not source_armature or not target_armature:
self.report({'ERROR'}, "Source or target armature not set")
return {'CANCELLED'}
# Build bone map from items
bone_map = {}
for item in scene.bone_mapping_items:
if item.target_bone: # Skip empty mappings
bone_map[item.source_bone] = item.target_bone
if not bone_map:
self.report({'ERROR'}, "No bone mappings defined")
return {'CANCELLED'}
try:
# Show progress in UI
self.report({'INFO'}, f"Applying retargeting to {len(bone_map)} bones...")
# Set status flag
scene.bone_mapping_status = "APPLYING"
result = retarget_animation(
source_armature,
target_armature,
bone_map,
preserve_rotation=True,
preserve_location=True
)
# Update status
scene.bone_mapping_status = "COMPLETED"
self.report({'INFO'}, result)
except (ValueError, KeyError, AttributeError, RuntimeError) as e:
scene.bone_mapping_status = "FAILED"
self.report({'ERROR'}, f"Retargeting failed: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
# ============================================================================
# Property Groups
# ============================================================================
class BoneMappingItem(bpy.types.PropertyGroup):
"""본 매핑 아이템"""
source_bone: bpy.props.StringProperty(name="Source Bone")
target_bone: bpy.props.StringProperty(name="Target Bone")

View File

@@ -0,0 +1,33 @@
"""
Blender Toolkit Utilities
유틸리티 모듈
"""
from .bone_matching import (
normalize_bone_name,
calculate_similarity,
find_best_match,
fuzzy_match_bones,
get_match_quality_report,
)
from .logger import (
get_logger,
setup_logging,
log_function_call,
log_error,
)
__all__ = [
# Bone matching
'normalize_bone_name',
'calculate_similarity',
'find_best_match',
'fuzzy_match_bones',
'get_match_quality_report',
# Logging
'get_logger',
'setup_logging',
'log_function_call',
'log_error',
]

View File

@@ -0,0 +1,262 @@
"""
Fuzzy Bone Matching Utilities
본 이름 유사도 기반 자동 매칭 알고리즘
"""
import re
from difflib import SequenceMatcher
from typing import Any, Dict, List, Optional, Tuple, Union
def normalize_bone_name(name: str) -> str:
"""
본 이름 정규화
- 소문자 변환
- 특수문자를 언더스코어로 변환
- 연속된 언더스코어 제거
- 양쪽 공백 제거
Examples:
"Left_Arm" -> "left_arm"
"left-arm" -> "left_arm"
"LeftArm" -> "leftarm"
"Left Arm" -> "left_arm"
"""
# 소문자 변환
normalized = name.lower()
# 특수문자를 언더스코어로 변환 (알파벳, 숫자, 점만 유지)
normalized = re.sub(r'[^a-z0-9.]', '_', normalized)
# 연속된 언더스코어를 하나로
normalized = re.sub(r'_+', '_', normalized)
# 양쪽 언더스코어 제거
normalized = normalized.strip('_')
return normalized
def calculate_similarity(name1: str, name2: str) -> float:
"""
두 본 이름 간 유사도 계산 (0.0 ~ 1.0)
알고리즘:
1. 정규화된 이름으로 SequenceMatcher 사용 (기본 점수)
2. 부분 문자열 매칭 보너스
3. 접두사/접미사 매칭 보너스
4. 단어 포함 보너스
Args:
name1: 첫 번째 본 이름
name2: 두 번째 본 이름
Returns:
유사도 점수 (0.0 = 전혀 다름, 1.0 = 완전 일치)
"""
# 정규화
norm1 = normalize_bone_name(name1)
norm2 = normalize_bone_name(name2)
# 완전 일치
if norm1 == norm2:
return 1.0
# SequenceMatcher로 기본 유사도 계산
base_score = SequenceMatcher(None, norm1, norm2).ratio()
# 보너스 점수 계산
bonus = 0.0
# 1. 부분 문자열 매칭 보너스 (한쪽이 다른 쪽에 포함)
if norm1 in norm2 or norm2 in norm1:
bonus += 0.15
# 2. 접두사 매칭 보너스 (left, right 등)
common_prefixes = ['left', 'right', 'up', 'low', 'upper', 'lower']
for prefix in common_prefixes:
if norm1.startswith(prefix) and norm2.startswith(prefix):
bonus += 0.1
break
# 3. 접미사 매칭 보너스 (l, r 등)
underscore_l_match = norm1.endswith('_l') and norm2.endswith('_l')
underscore_r_match = norm1.endswith('_r') and norm2.endswith('_r')
dot_l_match = norm1.endswith('.l') and norm2.endswith('.l')
dot_r_match = norm1.endswith('.r') and norm2.endswith('.r')
if underscore_l_match or underscore_r_match or dot_l_match or dot_r_match:
bonus += 0.1
# 4. 숫자 매칭 보너스 (Spine1, Spine2 등)
digits1 = re.findall(r'\d+', norm1)
digits2 = re.findall(r'\d+', norm2)
if digits1 and digits2 and digits1 == digits2:
bonus += 0.1
# 5. 단어 포함 보너스 (arm, hand, leg, foot 등)
keywords = ['arm', 'hand', 'leg', 'foot', 'finger', 'thumb', 'index',
'middle', 'ring', 'pinky', 'shoulder', 'elbow', 'wrist',
'hip', 'knee', 'ankle', 'toe', 'spine', 'neck', 'head']
for keyword in keywords:
if keyword in norm1 and keyword in norm2:
bonus += 0.05
break
# 최종 점수 (최대 1.0)
final_score = min(base_score + bonus, 1.0)
return final_score
def find_best_match(
source_bone: str,
target_bones: List[str],
threshold: float = 0.6,
return_score: bool = False
) -> Union[Optional[str], Tuple[Optional[str], float]]:
"""
타겟 본 리스트에서 가장 유사한 본 찾기
Args:
source_bone: 매칭할 소스 본 이름
target_bones: 타겟 본 이름 리스트
threshold: 최소 유사도 임계값 (0.0 ~ 1.0)
return_score: True면 (본_이름, 점수) 튜플 반환
Returns:
가장 유사한 타겟 본 이름, 또는 None (임계값 미만)
return_score=True면 (본_이름, 점수) 튜플
"""
best_match = None
best_score = 0.0
for target_bone in target_bones:
score = calculate_similarity(source_bone, target_bone)
if score > best_score and score >= threshold:
best_score = score
best_match = target_bone
if return_score:
return (best_match, best_score)
return best_match
def fuzzy_match_bones(
source_bones: List[str],
target_bones: List[str],
known_aliases: Dict[str, List[str]] = None,
threshold: float = 0.6,
prefer_exact: bool = True
) -> Dict[str, str]:
"""
Fuzzy matching을 사용한 전체 본 매핑
알고리즘:
1. 정확한 매칭 우선 (known_aliases 사용)
2. Fuzzy matching으로 나머지 매칭
3. 임계값 이상인 것만 포함
Args:
source_bones: 소스 본 이름 리스트
target_bones: 타겟 본 이름 리스트
known_aliases: 알려진 별칭 딕셔너리 {source: [target_alias1, ...]}
threshold: 최소 유사도 임계값
prefer_exact: True면 정확한 매칭 우선
Returns:
본 매핑 딕셔너리 {source_bone: target_bone}
"""
bone_map = {}
target_bone_names_lower = [b.lower() for b in target_bones]
matched_targets = set() # 중복 매칭 방지
# 1단계: 정확한 매칭 (known_aliases 사용)
if prefer_exact and known_aliases:
for source_bone in source_bones:
if source_bone not in known_aliases:
continue
# 별칭 리스트에서 타겟에 있는 것 찾기
for alias in known_aliases[source_bone]:
alias_lower = alias.lower()
if alias_lower in target_bone_names_lower:
idx = target_bone_names_lower.index(alias_lower)
actual_name = target_bones[idx]
# 이미 매칭된 타겟이 아니면 추가
if actual_name not in matched_targets:
bone_map[source_bone] = actual_name
matched_targets.add(actual_name)
break
# 2단계: Fuzzy matching으로 나머지 매칭
for source_bone in source_bones:
# 이미 매칭된 소스 본은 건너뛰기
if source_bone in bone_map:
continue
# 아직 매칭되지 않은 타겟 본들만 대상으로
available_targets = [t for t in target_bones if t not in matched_targets]
if not available_targets:
continue
# 가장 유사한 타겟 찾기
best_match, _ = find_best_match(
source_bone,
available_targets,
threshold=threshold,
return_score=True
)
if best_match:
bone_map[source_bone] = best_match
matched_targets.add(best_match)
return bone_map
def get_match_quality_report(bone_map: Dict[str, str]) -> Dict[str, Any]:
"""
본 매핑 품질 보고서 생성
Args:
bone_map: 본 매핑 딕셔너리
Returns:
품질 보고서 딕셔너리
"""
if not bone_map:
return {
'total_mappings': 0,
'quality': 'none',
'summary': 'No bone mappings found'
}
total = len(bone_map)
# 주요 본 체크 (최소한 있어야 하는 본들)
critical_bones = ['Hips', 'Spine', 'Head', 'LeftArm', 'RightArm',
'LeftLeg', 'RightLeg', 'LeftHand', 'RightHand']
critical_mapped = sum(1 for bone in critical_bones if bone in bone_map)
# 품질 평가
if critical_mapped >= 8:
quality = 'excellent'
elif critical_mapped >= 6:
quality = 'good'
elif critical_mapped >= 4:
quality = 'fair'
else:
quality = 'poor'
return {
'total_mappings': total,
'critical_bones_mapped': f'{critical_mapped}/{len(critical_bones)}',
'quality': quality,
'summary': f'{total} bones mapped, {critical_mapped}/{len(critical_bones)} critical bones'
}

View File

@@ -0,0 +1,165 @@
"""
Python Logging Configuration
Blender addon용 로깅 시스템
"""
import logging
import os
from pathlib import Path
from datetime import datetime
# 로그 디렉토리 경로
def get_log_dir() -> Path:
"""로그 디렉토리 경로 가져오기"""
# CLAUDE_PROJECT_DIR 환경변수 또는 현재 작업 디렉토리 사용
project_dir = os.environ.get('CLAUDE_PROJECT_DIR', os.getcwd())
log_dir = Path(project_dir) / '.blender-toolkit' / 'logs'
# 디렉토리 생성
log_dir.mkdir(parents=True, exist_ok=True)
return log_dir
# 로그 포맷 정의
LOG_FORMAT = '[%(asctime)s] [%(levelname)-8s] [%(name)s] %(message)s'
DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
# 전역 로거 설정 완료 여부
_logger_initialized = False
def setup_logging(level: int = logging.INFO) -> None:
"""
전역 로깅 설정 초기화
Args:
level: 로그 레벨 (logging.DEBUG, INFO, WARNING, ERROR)
"""
global _logger_initialized
if _logger_initialized:
return
# 로그 디렉토리
log_dir = get_log_dir()
# 루트 로거 설정
root_logger = logging.getLogger('blender_toolkit')
root_logger.setLevel(level)
# 기존 핸들러 제거 (중복 방지)
root_logger.handlers.clear()
# 파일 핸들러 (모든 로그)
file_handler = logging.FileHandler(
log_dir / 'blender-addon.log',
mode='a',
encoding='utf-8'
)
file_handler.setLevel(level)
file_handler.setFormatter(logging.Formatter(LOG_FORMAT, DATE_FORMAT))
# 파일 핸들러 (에러만)
error_handler = logging.FileHandler(
log_dir / 'error.log',
mode='a',
encoding='utf-8'
)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(logging.Formatter(LOG_FORMAT, DATE_FORMAT))
# 콘솔 핸들러 (개발 모드)
if os.environ.get('DEBUG'):
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_handler.setFormatter(
logging.Formatter('[%(levelname)-8s] %(message)s')
)
root_logger.addHandler(console_handler)
# 핸들러 추가
root_logger.addHandler(file_handler)
root_logger.addHandler(error_handler)
_logger_initialized = True
# 초기화 메시지
root_logger.info('=' * 70)
root_logger.info(f'Blender Toolkit Logger initialized')
root_logger.info(f'Log directory: {log_dir}')
root_logger.info(f'Log level: {logging.getLevelName(level)}')
root_logger.info('=' * 70)
def get_logger(name: str = None) -> logging.Logger:
"""
모듈별 로거 가져오기
Args:
name: 로거 이름 (보통 __name__ 사용)
Returns:
Logger 인스턴스
Example:
```python
from .utils.logger import get_logger
logger = get_logger(__name__)
logger.info("Hello, world!")
logger.error("An error occurred", exc_info=True)
```
"""
# 로깅 시스템이 초기화되지 않았으면 초기화
if not _logger_initialized:
# DEBUG 환경변수가 있으면 DEBUG 레벨 사용
level = logging.DEBUG if os.environ.get('DEBUG') else logging.INFO
setup_logging(level)
# 모듈별 로거 반환
logger_name = f'blender_toolkit.{name}' if name else 'blender_toolkit'
return logging.getLogger(logger_name)
# 편의 함수들
def log_function_call(logger: logging.Logger):
"""
함수 호출 로깅 데코레이터
Example:
```python
@log_function_call(logger)
def my_function(arg1, arg2):
return arg1 + arg2
```
"""
def decorator(func):
def wrapper(*args, **kwargs):
logger.debug(f'Calling {func.__name__}() with args={args}, kwargs={kwargs}')
try:
result = func(*args, **kwargs)
logger.debug(f'{func.__name__}() returned: {result}')
return result
except Exception as e:
logger.error(f'{func.__name__}() raised {type(e).__name__}: {e}', exc_info=True)
raise
return wrapper
return decorator
def log_error(logger: logging.Logger, error: Exception, context: str = None):
"""
에러 로깅 헬퍼
Args:
logger: Logger 인스턴스
error: Exception 객체
context: 에러 발생 컨텍스트 설명
"""
message = f'{type(error).__name__}: {error}'
if context:
message = f'{context} - {message}'
logger.error(message, exc_info=True)

View File

@@ -0,0 +1,129 @@
"""
Security utilities for Blender Toolkit addon
"""
import os
from pathlib import Path
from typing import Optional
def validate_file_path(file_path: str, allowed_root: Optional[str] = None) -> str:
"""
Validate file path to prevent path traversal attacks.
Args:
file_path: Path to validate
allowed_root: Optional allowed root directory. If None, only checks for dangerous patterns.
Returns:
Validated absolute path
Raises:
ValueError: If path is invalid or outside allowed directory
"""
if not file_path:
raise ValueError("File path cannot be empty")
# Resolve to absolute path
try:
abs_path = os.path.abspath(os.path.expanduser(file_path))
except Exception as e:
raise ValueError(f"Invalid file path: {e}")
# Check for null bytes (security risk)
if '\0' in file_path:
raise ValueError("File path contains null bytes")
# If allowed_root is specified, ensure path is within it
if allowed_root:
allowed_abs = os.path.abspath(os.path.expanduser(allowed_root))
# Resolve symlinks to prevent bypass
try:
real_path = os.path.realpath(abs_path)
real_root = os.path.realpath(allowed_abs)
except Exception:
# If realpath fails, use absolute paths
real_path = abs_path
real_root = allowed_abs
# Check if path is within allowed root
try:
Path(real_path).relative_to(real_root)
except ValueError:
raise ValueError(f"Path outside allowed directory: {file_path}")
return abs_path
def validate_port(port: int) -> int:
"""
Validate WebSocket port number.
Args:
port: Port number to validate
Returns:
Validated port number
Raises:
ValueError: If port is invalid
"""
if not isinstance(port, int):
raise ValueError("Port must be an integer")
if port < 1024 or port > 65535:
raise ValueError("Port must be between 1024 and 65535")
return port
# Whitelist for safe object attributes
ALLOWED_OBJECT_ATTRIBUTES = {
'location',
'rotation_euler',
'rotation_quaternion',
'rotation_axis_angle',
'scale',
'name',
'hide',
'hide_viewport',
'hide_render',
'hide_select',
}
# Whitelist for safe armature bone attributes
ALLOWED_BONE_ATTRIBUTES = {
'name',
'head',
'tail',
'roll',
'use_connect',
'use_deform',
'use_inherit_rotation',
'use_inherit_scale',
'use_local_location',
}
def validate_attribute_name(attr_name: str, allowed_attributes: set) -> str:
"""
Validate attribute name against whitelist.
Args:
attr_name: Attribute name to validate
allowed_attributes: Set of allowed attribute names
Returns:
Validated attribute name
Raises:
ValueError: If attribute is not allowed
"""
if not attr_name:
raise ValueError("Attribute name cannot be empty")
if attr_name not in allowed_attributes:
raise ValueError(f"Attribute '{attr_name}' is not allowed")
return attr_name

View File

@@ -0,0 +1,485 @@
"""
WebSocket Server for Blender Toolkit
Claude Code와 통신하기 위한 WebSocket 서버
이 모듈은 Blender 내부에서 WebSocket 서버를 실행하여
외부 클라이언트(Claude Code)와 JSON-RPC 스타일 통신을 제공합니다.
"""
import asyncio
import json
from typing import Any, Dict, Union
import bpy
from aiohttp import web
from aiohttp.web import Request, WebSocketResponse
from .utils.logger import get_logger
from .utils.security import validate_port
# 모듈 로거 초기화
logger = get_logger('websocket_server')
# 보안 상수
MAX_CONNECTIONS = 5 # 최대 동시 연결 수 (로컬 환경)
class BlenderWebSocketServer:
"""WebSocket 서버 메인 클래스"""
def __init__(self, port: int = 9400):
self.port = validate_port(port)
self.app = None
self.runner = None
self.site = None
self.clients = []
async def handle_command(
self, request: Request
) -> Union[WebSocketResponse, web.Response]:
"""WebSocket 연결 핸들러"""
# 로컬호스트만 허용 (보안)
peername = request.transport.get_extra_info('peername')
if peername:
host = peername[0]
if host not in ('127.0.0.1', '::1', 'localhost'):
logger.warning(
"Rejected connection from non-localhost: %s", host
)
return web.Response(
status=403, text="Only localhost connections allowed"
)
# 최대 연결 수 제한 (DoS 방지)
if len(self.clients) >= MAX_CONNECTIONS:
logger.warning(
"Connection limit reached (%d)", MAX_CONNECTIONS
)
return web.Response(status=503, text="Too many connections")
ws = web.WebSocketResponse()
await ws.prepare(request)
self.clients.append(ws)
logger.info("Client connected (total: %d)", len(self.clients))
print(f"✅ Client connected (total: {len(self.clients)})")
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
logger.debug("Received message: %s", data)
response = await self.process_command(data)
await ws.send_json(response)
except (json.JSONDecodeError, KeyError, ValueError) as e:
logger.error("Error handling message: %s", e, exc_info=True)
await ws.send_json({
"id": data.get("id") if 'data' in locals() else None,
"error": {
"code": -1,
"message": str(e)
}
})
elif msg.type == web.WSMsgType.ERROR:
logger.error('WebSocket error: %s', ws.exception())
print(f'❌ WebSocket error: {ws.exception()}')
self.clients.remove(ws)
logger.info("Client disconnected (total: %d)", len(self.clients))
print(f"🔌 Client disconnected (total: {len(self.clients)})")
return ws
async def process_command(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""명령 처리"""
method = data.get("method")
params = data.get("params", {})
msg_id = data.get("id")
logger.info("Processing command: %s", method)
logger.debug("Command params: %s", params)
print(f"📨 Received command: {method}")
try:
# 메서드 라우팅
if method.startswith("Armature."):
result = await self.handle_armature_command(method, params)
elif method.startswith("Retargeting."):
result = await self.handle_retargeting_command(method, params)
elif method.startswith("BoneMapping."):
result = await self.handle_bonemapping_command(method, params)
elif method.startswith("Animation."):
result = await self.handle_animation_command(method, params)
elif method.startswith("Import."):
result = await self.handle_import_command(method, params)
elif method.startswith("Geometry."):
result = await self.handle_geometry_command(method, params)
elif method.startswith("Object."):
result = await self.handle_object_command(method, params)
elif method.startswith("Modifier."):
result = await self.handle_modifier_command(method, params)
elif method.startswith("Material."):
result = await self.handle_material_command(method, params)
elif method.startswith("Collection."):
result = await self.handle_collection_command(method, params)
else:
raise ValueError(f"Unknown method: {method}")
logger.info("Command %s completed successfully", method)
return {"id": msg_id, "result": result}
except (ValueError, KeyError, AttributeError, RuntimeError) as e:
logger.error("Error processing %s: %s", method, str(e), exc_info=True)
print(f"❌ Error processing {method}: {str(e)}")
return {
"id": msg_id,
"error": {"code": -1, "message": str(e)}
}
async def handle_armature_command(self, method: str, params: Dict) -> Any:
"""아마추어 관련 명령 처리"""
from .retargeting import get_bones, list_armatures
if method == "Armature.getBones":
armature_name = params.get("armatureName")
return get_bones(armature_name)
elif method == "Armature.list":
return list_armatures()
else:
raise ValueError(f"Unknown armature method: {method}")
async def handle_retargeting_command(self, method: str, params: Dict) -> Any:
"""리타게팅 명령 처리"""
from .retargeting import auto_map_bones, retarget_animation, get_preset_bone_mapping
if method == "Retargeting.autoMapBones":
return auto_map_bones(
params.get("sourceArmature"),
params.get("targetArmature")
)
elif method == "Retargeting.retargetAnimation":
return retarget_animation(
params.get("sourceArmature"),
params.get("targetArmature"),
params.get("boneMap"),
params.get("preserveRotation", True),
params.get("preserveLocation", False)
)
elif method == "Retargeting.getPresetMapping":
preset = params.get("preset")
return get_preset_bone_mapping(preset)
else:
raise ValueError(f"Unknown retargeting method: {method}")
async def handle_bonemapping_command(self, method: str, params: Dict) -> Any:
"""본 매핑 명령 처리"""
from .retargeting import store_bone_mapping, load_bone_mapping
if method == "BoneMapping.show":
return store_bone_mapping(
params.get("sourceArmature"),
params.get("targetArmature"),
params.get("boneMapping")
)
elif method == "BoneMapping.get":
return load_bone_mapping(
params.get("sourceArmature"),
params.get("targetArmature")
)
else:
raise ValueError(f"Unknown bone mapping method: {method}")
async def handle_animation_command(self, method: str, params: Dict) -> Any:
"""애니메이션 명령 처리"""
from .retargeting import list_animations, play_animation, stop_animation, add_to_nla
if method == "Animation.list":
armature_name = params.get("armatureName")
return list_animations(armature_name)
elif method == "Animation.play":
return play_animation(
params.get("armatureName"),
params.get("actionName"),
params.get("loop", True)
)
elif method == "Animation.stop":
return stop_animation()
elif method == "Animation.addToNLA":
return add_to_nla(
params.get("armatureName"),
params.get("actionName"),
params.get("trackName")
)
else:
raise ValueError(f"Unknown animation method: {method}")
async def handle_import_command(self, method: str, params: Dict) -> Any:
"""임포트 명령 처리"""
from .retargeting import import_fbx, import_dae
if method == "Import.fbx":
return import_fbx(params.get("filepath"))
elif method == "Import.dae":
return import_dae(params.get("filepath"))
else:
raise ValueError(f"Unknown import method: {method}")
async def handle_geometry_command(self, method: str, params: Dict) -> Any:
"""도형 생성 명령 처리"""
from .commands.geometry import (
create_cube, create_sphere, create_cylinder,
create_plane, create_cone, create_torus,
get_vertices, move_vertex, subdivide_mesh, extrude_face
)
if method == "Geometry.createCube":
return create_cube(
location=tuple(params.get("location", [0, 0, 0])),
size=params.get("size", 2.0),
name=params.get("name")
)
elif method == "Geometry.createSphere":
return create_sphere(
location=tuple(params.get("location", [0, 0, 0])),
radius=params.get("radius", 1.0),
segments=params.get("segments", 32),
ring_count=params.get("ringCount", 16),
name=params.get("name")
)
elif method == "Geometry.createCylinder":
return create_cylinder(
location=tuple(params.get("location", [0, 0, 0])),
radius=params.get("radius", 1.0),
depth=params.get("depth", 2.0),
vertices=params.get("vertices", 32),
name=params.get("name")
)
elif method == "Geometry.createPlane":
return create_plane(
location=tuple(params.get("location", [0, 0, 0])),
size=params.get("size", 2.0),
name=params.get("name")
)
elif method == "Geometry.createCone":
return create_cone(
location=tuple(params.get("location", [0, 0, 0])),
radius1=params.get("radius1", 1.0),
depth=params.get("depth", 2.0),
vertices=params.get("vertices", 32),
name=params.get("name")
)
elif method == "Geometry.createTorus":
return create_torus(
location=tuple(params.get("location", [0, 0, 0])),
major_radius=params.get("majorRadius", 1.0),
minor_radius=params.get("minorRadius", 0.25),
major_segments=params.get("majorSegments", 48),
minor_segments=params.get("minorSegments", 12),
name=params.get("name")
)
elif method == "Geometry.getVertices":
return get_vertices(params.get("name"))
elif method == "Geometry.moveVertex":
return move_vertex(
object_name=params.get("objectName"),
vertex_index=params.get("vertexIndex"),
new_position=tuple(params.get("newPosition"))
)
elif method == "Geometry.subdivideMesh":
return subdivide_mesh(
name=params.get("name"),
cuts=params.get("cuts", 1)
)
elif method == "Geometry.extrudeFace":
return extrude_face(
object_name=params.get("objectName"),
face_index=params.get("faceIndex"),
offset=params.get("offset", 1.0)
)
else:
raise ValueError(f"Unknown geometry method: {method}")
async def handle_object_command(self, method: str, params: Dict) -> Any:
"""오브젝트 명령 처리"""
from .commands.geometry import (
delete_object, transform_object, duplicate_object, list_objects
)
if method == "Object.delete":
return delete_object(params.get("name"))
elif method == "Object.transform":
location = params.get("location")
rotation = params.get("rotation")
scale = params.get("scale")
return transform_object(
name=params.get("name"),
location=tuple(location) if location else None,
rotation=tuple(rotation) if rotation else None,
scale=tuple(scale) if scale else None
)
elif method == "Object.duplicate":
location = params.get("location")
return duplicate_object(
name=params.get("name"),
new_name=params.get("newName"),
location=tuple(location) if location else None
)
elif method == "Object.list":
return list_objects(params.get("type"))
else:
raise ValueError(f"Unknown object method: {method}")
async def handle_modifier_command(self, method: str, params: Dict) -> Any:
"""모디파이어 명령 처리"""
from .commands.modifier import (
add_modifier, apply_modifier, list_modifiers, remove_modifier,
toggle_modifier, modify_modifier_properties, get_modifier_info, reorder_modifier
)
if method == "Modifier.add":
properties = params.get("properties", {})
return add_modifier(
object_name=params.get("objectName"),
modifier_type=params.get("modifierType"),
name=params.get("name")
)
elif method == "Modifier.apply":
return apply_modifier(
object_name=params.get("objectName"),
modifier_name=params.get("modifierName")
)
elif method == "Modifier.list":
return list_modifiers(
object_name=params.get("objectName")
)
elif method == "Modifier.remove":
return remove_modifier(
object_name=params.get("objectName"),
modifier_name=params.get("modifierName")
)
elif method == "Modifier.toggle":
return toggle_modifier(
object_name=params.get("objectName"),
modifier_name=params.get("modifierName"),
viewport=params.get("viewport"),
render=params.get("render")
)
elif method == "Modifier.modify":
properties = params.get("properties", {})
return modify_modifier_properties(
object_name=params.get("objectName"),
modifier_name=params.get("modifierName"),
**properties
)
elif method == "Modifier.getInfo":
return get_modifier_info(
object_name=params.get("objectName"),
modifier_name=params.get("modifierName")
)
elif method == "Modifier.reorder":
return reorder_modifier(
object_name=params.get("objectName"),
modifier_name=params.get("modifierName"),
direction=params.get("direction")
)
else:
raise ValueError(f"Unknown modifier method: {method}")
async def handle_material_command(self, method: str, params: Dict) -> Any:
"""머티리얼 명령 처리"""
from .commands.material import (
create_material, list_materials, delete_material,
assign_material, list_object_materials,
set_material_base_color, set_material_metallic, set_material_roughness,
set_material_emission, get_material_properties
)
if method == "Material.create":
return create_material(
name=params.get("name"),
use_nodes=params.get("useNodes", True)
)
elif method == "Material.list":
return list_materials()
elif method == "Material.delete":
return delete_material(name=params.get("name"))
elif method == "Material.assign":
return assign_material(
object_name=params.get("objectName"),
material_name=params.get("materialName"),
slot_index=params.get("slotIndex", 0)
)
elif method == "Material.listObjectMaterials":
return list_object_materials(object_name=params.get("objectName"))
elif method == "Material.setBaseColor":
color = params.get("color")
return set_material_base_color(
material_name=params.get("materialName"),
color=tuple(color) if isinstance(color, list) else color
)
elif method == "Material.setMetallic":
return set_material_metallic(
material_name=params.get("materialName"),
metallic=params.get("metallic")
)
elif method == "Material.setRoughness":
return set_material_roughness(
material_name=params.get("materialName"),
roughness=params.get("roughness")
)
elif method == "Material.setEmission":
color = params.get("color")
return set_material_emission(
material_name=params.get("materialName"),
color=tuple(color) if isinstance(color, list) else color,
strength=params.get("strength", 1.0)
)
elif method == "Material.getProperties":
return get_material_properties(material_name=params.get("materialName"))
else:
raise ValueError(f"Unknown material method: {method}")
async def handle_collection_command(self, method: str, params: Dict) -> Any:
"""컬렉션 명령 처리"""
from .commands.collection import (
create_collection, list_collections, add_to_collection,
remove_from_collection, delete_collection
)
if method == "Collection.create":
return create_collection(name=params.get("name"))
elif method == "Collection.list":
return list_collections()
elif method == "Collection.addObject":
return add_to_collection(
object_name=params.get("objectName"),
collection_name=params.get("collectionName")
)
elif method == "Collection.removeObject":
return remove_from_collection(
object_name=params.get("objectName"),
collection_name=params.get("collectionName")
)
elif method == "Collection.delete":
return delete_collection(name=params.get("name"))
else:
raise ValueError(f"Unknown collection method: {method}")
async def start(self):
"""서버 시작"""
self.app = web.Application()
self.app.router.add_get('/ws', self.handle_command)
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(self.runner, '127.0.0.1', self.port)
await self.site.start()
print(f"✅ Blender WebSocket Server started on port {self.port}")
async def stop(self):
"""서버 중지"""
if self.site:
await self.site.stop()
if self.runner:
await self.runner.cleanup()
print("🛑 Blender WebSocket Server stopped")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,653 @@
# Bone Mapping Guide
Complete guide to the automatic bone matching system for animation retargeting.
## Table of Contents
- [Overview](#overview)
- [Bone Mapping Modes](#bone-mapping-modes)
- [Auto Bone Matching Algorithm](#auto-bone-matching-algorithm)
- [Two-Phase Workflow](#two-phase-workflow)
- [Quality Assessment](#quality-assessment)
- [Blender UI Panel](#blender-ui-panel)
- [Common Mapping Patterns](#common-mapping-patterns)
- [Troubleshooting](#troubleshooting)
- [Best Practices](#best-practices)
---
## Overview
Bone mapping is the process of establishing correspondence between bones in the Mixamo animation skeleton and bones in your custom character rig. Accurate bone mapping is essential for successful animation retargeting.
**Why Bone Mapping Matters:**
- Mixamo uses standardized bone names (e.g., "mixamorig:Hips", "mixamorig:LeftArm")
- Custom rigs use various naming conventions (e.g., "Hips", "LeftArm", "left_arm", "arm.L")
- Without proper mapping, animations won't transfer correctly
- Incorrect mapping can result in twisted limbs, inverted rotations, or broken animations
**Core Features:**
-**Automatic Fuzzy Matching** - Intelligently matches bones by name similarity
-**UI Confirmation Workflow** - Review and edit mappings in Blender before applying
-**Quality Assessment** - Automatic evaluation of mapping quality
-**Rigify Presets** - Built-in support for Rigify rigs
-**Custom Mappings** - Support for non-standard rigs
---
## Bone Mapping Modes
Three modes are available for bone mapping:
### 1. Auto Mode (Recommended) ⭐
**When to Use:** Unknown or non-standard rigs
```bash
blender-toolkit retarget --target "Hero" --file "./Walking.fbx" --mapping auto
```
**How It Works:**
1. Analyzes both source (Mixamo) and target (your character) bone names
2. Uses fuzzy matching algorithm to find best matches
3. Generates mapping with similarity scores
4. Displays mapping in Blender UI for user review
5. User confirms or edits before application
**Advantages:**
- Works with any rig structure
- No manual configuration required
- Intelligent name matching handles various conventions
- User confirmation ensures accuracy
**Similarity Algorithm:**
- Base matching using SequenceMatcher
- Bonuses for substring matches
- Bonuses for common prefixes (left, right, upper, lower)
- Bonuses for common suffixes (.L, .R, _l, _r)
- Bonuses for number matching (Spine1, Spine2)
- Bonuses for anatomical keywords (arm, leg, hand, foot)
### 2. Rigify Mode
**When to Use:** Standard Rigify rigs
```bash
blender-toolkit retarget --target "Hero" --file "./Walking.fbx" --mapping mixamo_to_rigify
```
**How It Works:**
- Uses predefined Mixamo → Rigify bone mapping
- Optimized for standard Rigify control rig structure
- Instant mapping with high confidence
**Advantages:**
- Zero configuration for Rigify users
- Highest accuracy for Rigify rigs
- Immediate application (no UI review needed)
**Rigify Bone Naming:**
```
Mixamo Rigify
-------- ------
Hips hips
Spine spine_fk
Spine1 spine_fk.001
Spine2 spine_fk.002
Neck neck
Head head
LeftShoulder shoulder.L
LeftArm upper_arm_fk.L
LeftForeArm forearm_fk.L
LeftHand hand_fk.L
```
### 3. Custom Mode
**When to Use:** Unique rig structures with known mappings
```typescript
// In your workflow code
const customMapping = {
"Hips": "Root",
"Spine": "Torso_01",
"Spine1": "Torso_02",
"LeftArm": "L_UpperArm",
"RightArm": "R_UpperArm"
};
await workflow.run({
targetCharacterArmature: 'MyCharacter',
animationFilePath: './Walking.fbx',
boneMapping: customMapping
});
```
**Advantages:**
- Full control over mapping
- Reusable across multiple animations
- No UI confirmation needed if mapping is trusted
---
## Auto Bone Matching Algorithm
The fuzzy matching algorithm intelligently pairs bones from Mixamo skeleton to your character rig.
### Phase 1: Normalization
All bone names are normalized before comparison:
```python
# Input variations
"Left_Arm" "left_arm"
"left-arm" "left_arm"
"LeftArm" "leftarm"
"Left Arm" "left_arm"
"left.arm" "left_arm"
```
**Normalization Steps:**
1. Convert to lowercase
2. Replace special characters with underscore
3. Remove consecutive underscores
4. Strip leading/trailing underscores
### Phase 2: Similarity Calculation
Calculates similarity score (0.0 - 1.0) between bone names:
```python
def calculate_similarity(name1: str, name2: str) -> float:
# Base score from SequenceMatcher
base_score = SequenceMatcher(None, norm1, norm2).ratio()
# Bonus factors
bonus = 0.0
# Substring match: +0.15
if norm1 in norm2 or norm2 in norm1:
bonus += 0.15
# Prefix match (left, right, etc): +0.1
# Suffix match (.L, .R, etc): +0.1
# Number match (Spine1, Spine2): +0.1
# Keyword match (arm, leg, etc): +0.05
return min(base_score + bonus, 1.0)
```
**Example Scores:**
```
"LeftArm" ↔ "left_arm" = 0.95 (substring + prefix)
"LeftArm" ↔ "L_Arm" = 0.78 (keyword + suffix)
"LeftArm" ↔ "RightArm" = 0.65 (keyword only)
"LeftArm" ↔ "LeftLeg" = 0.42 (prefix only)
"LeftArm" ↔ "Head" = 0.15 (no match)
```
### Phase 3: Best Match Selection
Selects the best match for each source bone:
```python
def find_best_match(source_bone, target_bones, threshold=0.6):
best_match = None
best_score = 0.0
for target_bone in target_bones:
score = calculate_similarity(source_bone, target_bone)
if score > best_score and score >= threshold:
best_score = score
best_match = target_bone
return best_match
```
**Key Points:**
- Only matches above threshold (default: 0.6) are considered
- Each target bone can only be matched once (prevents double mapping)
- Returns `None` if no suitable match found
### Phase 4: Quality Assessment
Evaluates overall mapping quality based on critical bones:
```python
critical_bones = [
'Hips', # Root motion
'Spine', # Torso
'Head', # Head orientation
'LeftArm', # Upper body
'RightArm',
'LeftLeg', # Lower body
'RightLeg',
'LeftHand', # Extremities
'RightHand'
]
if critical_mapped >= 8:
quality = 'excellent' # Safe to auto-apply
elif critical_mapped >= 6:
quality = 'good' # Quick review recommended
elif critical_mapped >= 4:
quality = 'fair' # Thorough review required
else:
quality = 'poor' # Manual mapping needed
```
---
## Two-Phase Workflow
Blender Toolkit uses a two-phase workflow to ensure mapping accuracy.
### Phase 1: Generate & Display
**What Happens:**
1. Import animation FBX into Blender
2. Auto-generate bone mapping using fuzzy matching
3. Calculate quality score
4. Display mapping in Blender UI panel
**Blender UI Shows:**
- Complete bone mapping table
- Source bone → Target bone correspondence
- Editable dropdowns for each mapping
- Quality assessment score
- "Auto Re-map" button (regenerate)
- "Apply Retargeting" button (proceed to Phase 2)
**User Actions:**
- Review each bone correspondence
- Fix incorrect mappings using dropdowns
- Use "Auto Re-map" to regenerate if needed
- Click "Apply Retargeting" when satisfied
### Phase 2: Apply & Bake
**What Happens:**
1. User clicks "Apply Retargeting" in Blender
2. Creates constraint-based retargeting setup
3. Bakes animation to keyframes
4. Adds animation to NLA track
5. Cleans up temporary objects
**Result:**
- Fully retargeted animation on your character
- Animation stored in NLA track
- Original character rig unchanged
- Ready for further editing or export
---
## Quality Assessment
The system automatically evaluates mapping quality.
### Quality Metrics
**Total Mappings:**
- Number of bones successfully mapped
- Higher is better
**Critical Bones Mapped:**
- 9 essential bones for quality animation
- Shows as ratio: "7/9 critical bones"
**Quality Rating:**
| Rating | Critical Bones | Recommendation |
|--------|----------------|----------------|
| **Excellent** | 8-9 | Safe to auto-apply with skip-confirmation |
| **Good** | 6-7 | Quick review recommended |
| **Fair** | 4-5 | Thorough review required |
| **Poor** | 0-3 | Manual mapping required |
### Quality Report Example
```json
{
"total_mappings": 52,
"critical_bones_mapped": "8/9",
"quality": "excellent",
"summary": "52 bones mapped, 8/9 critical bones"
}
```
### When to Review Mappings
**Always Review If:**
- Quality is "Fair" or "Poor"
- Character uses non-standard rig
- Animation has unusual requirements
- First time using a new character rig
**Quick Review If:**
- Quality is "Good"
- Character is standard Rigify
- Similar mappings worked before
**Auto-Apply If:**
- Quality is "Excellent"
- Using trusted custom mapping
- Repeated animations on same character
---
## Blender UI Panel
The bone mapping UI panel appears in Blender's View3D sidebar.
### Location
**Path:** View3D → Sidebar (N key) → "Blender Toolkit" tab → "Bone Mapping Review"
### Panel Components
**1. Mapping Table**
```
┌─────────────────────────────────┐
│ Bone Mapping Review │
├─────────────────────────────────┤
│ Source Bone → Target Bone │
│ ─────────────────────────────── │
│ Hips → [Dropdown: Hips]│
│ Spine → [Dropdown: Spine]│
│ LeftArm → [Dropdown: LeftArm]│
│ ... │
└─────────────────────────────────┘
```
**2. Quality Info**
```
Quality: Excellent
Total: 52 mappings
Critical: 8/9 bones
```
**3. Action Buttons**
- **Auto Re-map** - Regenerate mapping
- **Apply Retargeting** - Proceed to apply
### Using the Panel
**Step 1: Open Panel**
```
1. Press N key in 3D View
2. Click "Blender Toolkit" tab
3. Find "Bone Mapping Review" panel
```
**Step 2: Review Mappings**
```
1. Scroll through mapping table
2. Check each source → target correspondence
3. Pay special attention to critical bones:
- Hips (root motion)
- Spine chain (posture)
- Arms and legs (animation transfer)
```
**Step 3: Edit Mappings**
```
1. Click dropdown next to incorrect mapping
2. Select correct target bone from list
3. Repeat for all incorrect mappings
```
**Step 4: Apply**
```
1. Click "Apply Retargeting" button
2. Wait for processing (progress shown in console)
3. Animation will be applied and baked
```
---
## Common Mapping Patterns
### Rigify Rigs
**Standard Rigify Control Rig:**
```
Mixamo Rigify
-------- ------
Hips hips
Spine spine_fk
Spine1 spine_fk.001
Spine2 spine_fk.002
Neck neck
Head head
LeftShoulder shoulder.L
LeftArm upper_arm_fk.L
LeftForeArm forearm_fk.L
LeftHand hand_fk.L
RightShoulder shoulder.R
RightArm upper_arm_fk.R
RightForeArm forearm_fk.R
RightHand hand_fk.R
LeftUpLeg thigh_fk.L
LeftLeg shin_fk.L
LeftFoot foot_fk.L
RightUpLeg thigh_fk.R
RightLeg shin_fk.R
RightFoot foot_fk.R
```
### Unreal Engine (UE4/UE5)
**UE4 Mannequin Skeleton:**
```
Mixamo UE4/UE5
-------- -------
Hips pelvis
Spine spine_01
Spine1 spine_02
Spine2 spine_03
Neck neck_01
Head head
LeftShoulder clavicle_l
LeftArm upperarm_l
LeftForeArm lowerarm_l
LeftHand hand_l
RightShoulder clavicle_r
RightArm upperarm_r
RightForeArm lowerarm_r
RightHand hand_r
```
### Unity Humanoid
**Unity Mecanim Humanoid:**
```
Mixamo Unity
-------- -----
Hips Hips
Spine Spine
Spine1 Chest
Spine2 UpperChest
Neck Neck
Head Head
LeftShoulder LeftShoulder
LeftArm LeftUpperArm
LeftForeArm LeftLowerArm
LeftHand LeftHand
```
---
## Troubleshooting
### "Poor Quality" Mapping
**Symptoms:**
- Quality assessment shows "Poor"
- Less than 4 critical bones mapped
**Solutions:**
1. **Check Rig Structure**
- Verify character has proper armature
- Ensure bones follow hierarchical structure
- Check for missing bones
2. **Use Custom Mapping**
- Create explicit bone mapping dictionary
- Test with known-good mapping first
3. **Review Bone Names**
- Check for unusual naming conventions
- Look for typos or special characters
### Incorrect Left/Right Mapping
**Symptoms:**
- Left arm mapped to right arm
- Crossed animations
**Solutions:**
1. **Check Suffix Convention**
- Ensure consistent use of .L/.R or _l/_r
- Verify suffix matches throughout rig
2. **Manual Correction**
- Use Blender UI to swap mappings
- Fix all left/right pairs
### Missing Critical Bones
**Symptoms:**
- Key bones not mapped (Hips, Spine, etc.)
- Animation doesn't transfer properly
**Solutions:**
1. **Lower Threshold**
```python
# In custom workflow
bone_map = fuzzy_match_bones(
source_bones,
target_bones,
threshold=0.5 # Lower from default 0.6
)
```
2. **Check Bone Names**
- Print all bone names in Blender console
- Verify expected bones exist
3. **Use Explicit Mapping**
- Map critical bones manually
- Let auto-match handle fingers/toes
### Twisted or Inverted Limbs
**Symptoms:**
- Arms twist incorrectly
- Legs bend backwards
**Causes:**
- Bone roll differences
- Constraint axis misalignment
**Solutions:**
1. **Check Bone Roll**
- Compare source and target bone rolls
- Adjust in Edit Mode if needed
2. **Post-Process Animation**
- Use constraint influence
- Add corrective keyframes
---
## Best Practices
### 1. Start Simple
**First Animation:**
- Use simple animation (Idle, Walking)
- Verify mapping quality
- Test full body movement
- Check for issues before complex animations
### 2. Review Critical Bones First
**Priority Order:**
1. **Hips** - Root motion and posture
2. **Spine Chain** - Torso movement
3. **Shoulders** - Upper body orientation
4. **Arms/Legs** - Limb movement
5. **Hands/Feet** - Extremity position
6. **Fingers/Toes** - Fine detail (optional)
### 3. Save Custom Mappings
**For Reuse:**
```typescript
// Save successful mapping
const myCharacterMapping = {
"Hips": "root_bone",
"Spine": "torso_01",
// ... complete mapping
};
// Reuse for all animations
await workflow.run({
boneMapping: myCharacterMapping,
skipConfirmation: true // Safe with known mapping
});
```
### 4. Use Quality Threshold
**Decide Confirmation Strategy:**
```typescript
// Auto-apply only for excellent quality
if (quality === 'excellent') {
skipConfirmation = true;
} else {
skipConfirmation = false; // Review in UI
}
```
### 5. Document Your Rigs
**Create Mapping Reference:**
```markdown
# Character: Hero
Rig Type: Custom
Created: 2024-01-15
## Bone Mapping
Mixamo → Hero
- Hips → root
- Spine → spine_01
- ...
## Notes
- Uses custom spine chain (4 bones)
- Left/Right suffix: _L / _R
- Tested with: Walking, Running, Jumping
```
### 6. Test Before Batch Processing
**Workflow:**
1. Test mapping with one animation
2. Verify quality and appearance
3. Save mapping configuration
4. Batch process remaining animations
### 7. Handle Edge Cases
**Preparation:**
- Create fallback mappings for unusual rigs
- Document special handling requirements
- Test with varied animation types

View File

@@ -0,0 +1,879 @@
# Commands Reference
Complete command-line interface reference for Blender Toolkit CLI.
## Table of Contents
- [Geometry Commands](#geometry-commands)
- [Object Commands](#object-commands)
- [Modifier Commands](#modifier-commands)
- [Material Commands](#material-commands)
- [Collection Commands](#collection-commands)
- [Retargeting Commands](#retargeting-commands)
- [Daemon Commands](#daemon-commands)
- [Global Options](#global-options)
---
## Geometry Commands
Create and manipulate geometric primitives and meshes.
### create-cube
Create a cube primitive.
```bash
blender-toolkit create-cube [options]
```
**Options:**
- `-x, --x <number>` - X position (default: 0)
- `-y, --y <number>` - Y position (default: 0)
- `-z, --z <number>` - Z position (default: 0)
- `-s, --size <number>` - Cube size (default: 2.0)
- `-n, --name <string>` - Object name
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit create-cube --x 0 --y 0 --z 2 --size 1.5 --name "MyCube"
```
### create-sphere
Create a sphere primitive.
```bash
blender-toolkit create-sphere [options]
```
**Options:**
- `-x, --x <number>` - X position (default: 0)
- `-y, --y <number>` - Y position (default: 0)
- `-z, --z <number>` - Z position (default: 0)
- `-r, --radius <number>` - Sphere radius (default: 1.0)
- `--segments <number>` - Number of segments (default: 32)
- `--rings <number>` - Number of rings (default: 16)
- `-n, --name <string>` - Object name
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit create-sphere --radius 2 --segments 64 --rings 32
```
### create-cylinder
Create a cylinder primitive.
```bash
blender-toolkit create-cylinder [options]
```
**Options:**
- `-x, --x <number>` - X position (default: 0)
- `-y, --y <number>` - Y position (default: 0)
- `-z, --z <number>` - Z position (default: 0)
- `-r, --radius <number>` - Cylinder radius (default: 1.0)
- `-d, --depth <number>` - Cylinder height/depth (default: 2.0)
- `--vertices <number>` - Number of vertices (default: 32)
- `-n, --name <string>` - Object name
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit create-cylinder --radius 1.5 --depth 3 --vertices 64
```
### create-plane
Create a plane primitive.
```bash
blender-toolkit create-plane [options]
```
**Options:**
- `-x, --x <number>` - X position (default: 0)
- `-y, --y <number>` - Y position (default: 0)
- `-z, --z <number>` - Z position (default: 0)
- `-s, --size <number>` - Plane size (default: 2.0)
- `-n, --name <string>` - Object name
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit create-plane --size 10 --name "Ground"
```
### create-cone
Create a cone primitive.
```bash
blender-toolkit create-cone [options]
```
**Options:**
- `-x, --x <number>` - X position (default: 0)
- `-y, --y <number>` - Y position (default: 0)
- `-z, --z <number>` - Z position (default: 0)
- `-r, --radius <number>` - Cone base radius (default: 1.0)
- `-d, --depth <number>` - Cone height/depth (default: 2.0)
- `--vertices <number>` - Number of vertices (default: 32)
- `-n, --name <string>` - Object name
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit create-cone --radius 2 --depth 4
```
### create-torus
Create a torus primitive.
```bash
blender-toolkit create-torus [options]
```
**Options:**
- `-x, --x <number>` - X position (default: 0)
- `-y, --y <number>` - Y position (default: 0)
- `-z, --z <number>` - Z position (default: 0)
- `--major-radius <number>` - Major radius (default: 1.0)
- `--minor-radius <number>` - Minor radius/tube thickness (default: 0.25)
- `--major-segments <number>` - Major segments (default: 48)
- `--minor-segments <number>` - Minor segments (default: 12)
- `-n, --name <string>` - Object name
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit create-torus --major-radius 3 --minor-radius 0.5
```
### subdivide
Subdivide a mesh object to add more geometry detail.
```bash
blender-toolkit subdivide [options]
```
**Options:**
- `-n, --name <string>` - Object name **(required)**
- `-c, --cuts <number>` - Number of subdivision cuts (default: 1)
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit subdivide --name "Cube" --cuts 2
```
### get-vertices
Get vertices information of an object.
```bash
blender-toolkit get-vertices [options]
```
**Options:**
- `-n, --name <string>` - Object name **(required)**
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit get-vertices --name "Sphere"
```
### move-vertex
Move a specific vertex to a new position.
```bash
blender-toolkit move-vertex [options]
```
**Options:**
- `-n, --name <string>` - Object name **(required)**
- `-i, --index <number>` - Vertex index **(required)**
- `-x, --x <number>` - New X position **(required)**
- `-y, --y <number>` - New Y position **(required)**
- `-z, --z <number>` - New Z position **(required)**
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit move-vertex --name "Cube" --index 0 --x 1.5 --y 0 --z 0
```
---
## Object Commands
Manage and manipulate Blender objects.
### list-objects
List all objects in the scene.
```bash
blender-toolkit list-objects [options]
```
**Options:**
- `-t, --type <string>` - Filter by object type (MESH, ARMATURE, CAMERA, LIGHT)
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit list-objects --type MESH
```
### transform
Transform an object (move, rotate, scale).
```bash
blender-toolkit transform [options]
```
**Options:**
- `-n, --name <string>` - Object name **(required)**
- `--loc-x <number>` - X location
- `--loc-y <number>` - Y location
- `--loc-z <number>` - Z location
- `--rot-x <number>` - X rotation (radians)
- `--rot-y <number>` - Y rotation (radians)
- `--rot-z <number>` - Z rotation (radians)
- `--scale-x <number>` - X scale
- `--scale-y <number>` - Y scale
- `--scale-z <number>` - Z scale
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit transform --name "Cube" --loc-x 5 --loc-y 0 --loc-z 2 --scale-x 2
```
### duplicate
Duplicate an object.
```bash
blender-toolkit duplicate [options]
```
**Options:**
- `-n, --name <string>` - Source object name **(required)**
- `--new-name <string>` - New object name
- `-x, --x <number>` - X position for duplicate
- `-y, --y <number>` - Y position for duplicate
- `-z, --z <number>` - Z position for duplicate
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit duplicate --name "Cube" --new-name "Cube.001" --x 3
```
### delete
Delete an object.
```bash
blender-toolkit delete [options]
```
**Options:**
- `-n, --name <string>` - Object name **(required)**
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit delete --name "Cube.001"
```
---
## Modifier Commands
Add and manage modifiers on objects.
### add-modifier
Add a modifier to an object.
```bash
blender-toolkit add-modifier [options]
```
**Options:**
- `-n, --name <string>` - Object name **(required)**
- `-t, --type <string>` - Modifier type (SUBSURF, MIRROR, ARRAY, BEVEL, etc.) **(required)**
- `--mod-name <string>` - Modifier name
- `--levels <number>` - Subdivision levels (for SUBSURF)
- `--render-levels <number>` - Render levels (for SUBSURF)
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Common Modifier Types:**
- `SUBSURF` - Subdivision Surface
- `MIRROR` - Mirror
- `ARRAY` - Array
- `BEVEL` - Bevel
- `SOLIDIFY` - Solidify
- `BOOLEAN` - Boolean
**Example:**
```bash
blender-toolkit add-modifier --name "Cube" --type SUBSURF --levels 2
```
### apply-modifier
Apply a modifier to an object.
```bash
blender-toolkit apply-modifier [options]
```
**Options:**
- `-n, --name <string>` - Object name **(required)**
- `-m, --modifier <string>` - Modifier name **(required)**
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit apply-modifier --name "Cube" --modifier "Subdivision"
```
### list-modifiers
List all modifiers on an object.
```bash
blender-toolkit list-modifiers [options]
```
**Options:**
- `-n, --name <string>` - Object name **(required)**
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit list-modifiers --name "Cube"
```
### remove-modifier
Remove a modifier from an object.
```bash
blender-toolkit remove-modifier [options]
```
**Options:**
- `-n, --name <string>` - Object name **(required)**
- `-m, --modifier <string>` - Modifier name **(required)**
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit remove-modifier --name "Cube" --modifier "Subdivision"
```
### toggle-modifier
Toggle modifier visibility.
```bash
blender-toolkit toggle-modifier [options]
```
**Options:**
- `-n, --name <string>` - Object name **(required)**
- `-m, --modifier <string>` - Modifier name **(required)**
- `--viewport <boolean>` - Viewport visibility (true/false)
- `--render <boolean>` - Render visibility (true/false)
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit toggle-modifier --name "Cube" --modifier "Subdivision" --viewport false
```
### modify-modifier
Modify modifier properties.
```bash
blender-toolkit modify-modifier [options]
```
**Options:**
- `-n, --name <string>` - Object name **(required)**
- `-m, --modifier <string>` - Modifier name **(required)**
- `--levels <number>` - Subdivision levels
- `--render-levels <number>` - Render levels
- `--width <number>` - Bevel width
- `--segments <number>` - Bevel segments
- `--count <number>` - Array count
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit modify-modifier --name "Cube" --modifier "Subdivision" --levels 3
```
### get-modifier-info
Get detailed modifier information.
```bash
blender-toolkit get-modifier-info [options]
```
**Options:**
- `-n, --name <string>` - Object name **(required)**
- `-m, --modifier <string>` - Modifier name **(required)**
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit get-modifier-info --name "Cube" --modifier "Subdivision"
```
### reorder-modifier
Reorder modifier in the modifier stack.
```bash
blender-toolkit reorder-modifier [options]
```
**Options:**
- `-n, --name <string>` - Object name **(required)**
- `-m, --modifier <string>` - Modifier name **(required)**
- `-d, --direction <string>` - Direction (UP or DOWN) **(required)**
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
**Example:**
```bash
blender-toolkit reorder-modifier --name "Cube" --modifier "Subdivision" --direction UP
```
---
## Material Commands
Create and manage materials.
### material create
Create a new material.
```bash
blender-toolkit material create [options]
```
**Options:**
- `--name <name>` - Material name **(required)**
- `--no-nodes` - Disable node-based material (default: enabled)
**Example:**
```bash
blender-toolkit material create --name "RedMaterial"
```
### material list
List all materials in the scene.
```bash
blender-toolkit material list
```
**Example:**
```bash
blender-toolkit material list
```
### material delete
Delete a material.
```bash
blender-toolkit material delete [options]
```
**Options:**
- `--name <name>` - Material name **(required)**
**Example:**
```bash
blender-toolkit material delete --name "RedMaterial"
```
### material assign
Assign a material to an object.
```bash
blender-toolkit material assign [options]
```
**Options:**
- `--object <name>` - Object name **(required)**
- `--material <name>` - Material name **(required)**
- `--slot <index>` - Material slot index (default: 0)
**Example:**
```bash
blender-toolkit material assign --object "Cube" --material "RedMaterial"
```
### material list-object
List materials assigned to an object.
```bash
blender-toolkit material list-object [options]
```
**Options:**
- `--object <name>` - Object name **(required)**
**Example:**
```bash
blender-toolkit material list-object --object "Cube"
```
### material set-color
Set material base color.
```bash
blender-toolkit material set-color [options]
```
**Options:**
- `--material <name>` - Material name **(required)**
- `--r <value>` - Red (0-1) **(required)**
- `--g <value>` - Green (0-1) **(required)**
- `--b <value>` - Blue (0-1) **(required)**
- `--a <value>` - Alpha (0-1) (default: 1.0)
**Example:**
```bash
blender-toolkit material set-color --material "RedMaterial" --r 1.0 --g 0.0 --b 0.0
```
### material set-metallic
Set material metallic value.
```bash
blender-toolkit material set-metallic [options]
```
**Options:**
- `--material <name>` - Material name **(required)**
- `--value <value>` - Metallic value (0-1) **(required)**
**Example:**
```bash
blender-toolkit material set-metallic --material "MetalMaterial" --value 1.0
```
### material set-roughness
Set material roughness value.
```bash
blender-toolkit material set-roughness [options]
```
**Options:**
- `--material <name>` - Material name **(required)**
- `--value <value>` - Roughness value (0-1) **(required)**
**Example:**
```bash
blender-toolkit material set-roughness --material "MetalMaterial" --value 0.2
```
### material set-emission
Set material emission.
```bash
blender-toolkit material set-emission [options]
```
**Options:**
- `--material <name>` - Material name **(required)**
- `--r <value>` - Red (0-1) **(required)**
- `--g <value>` - Green (0-1) **(required)**
- `--b <value>` - Blue (0-1) **(required)**
- `--strength <value>` - Emission strength (default: 1.0)
**Example:**
```bash
blender-toolkit material set-emission --material "GlowMaterial" --r 0 --g 1 --b 0 --strength 5
```
### material get-properties
Get material properties.
```bash
blender-toolkit material get-properties [options]
```
**Options:**
- `--material <name>` - Material name **(required)**
**Example:**
```bash
blender-toolkit material get-properties --material "RedMaterial"
```
---
## Collection Commands
Organize objects into collections.
### collection create
Create a new collection.
```bash
blender-toolkit collection create [options]
```
**Options:**
- `--name <name>` - Collection name **(required)**
**Example:**
```bash
blender-toolkit collection create --name "Props"
```
### collection list
List all collections.
```bash
blender-toolkit collection list
```
**Example:**
```bash
blender-toolkit collection list
```
### collection add-object
Add an object to a collection.
```bash
blender-toolkit collection add-object [options]
```
**Options:**
- `--object <name>` - Object name **(required)**
- `--collection <name>` - Collection name **(required)**
**Example:**
```bash
blender-toolkit collection add-object --object "Cube" --collection "Props"
```
### collection remove-object
Remove an object from a collection.
```bash
blender-toolkit collection remove-object [options]
```
**Options:**
- `--object <name>` - Object name **(required)**
- `--collection <name>` - Collection name **(required)**
**Example:**
```bash
blender-toolkit collection remove-object --object "Cube" --collection "Props"
```
### collection delete
Delete a collection.
```bash
blender-toolkit collection delete [options]
```
**Options:**
- `--name <name>` - Collection name **(required)**
**Example:**
```bash
blender-toolkit collection delete --name "Props"
```
---
## Retargeting Commands
Animation retargeting from Mixamo to custom rigs.
### retarget
Retarget animation from Mixamo to your character.
```bash
blender-toolkit retarget [options]
```
**Options:**
- `-t, --target <string>` - Target character armature name **(required)**
- `-f, --file <string>` - Animation file path (FBX or DAE) **(required)**
- `-n, --name <string>` - Animation name for NLA track
- `-m, --mapping <string>` - Bone mapping mode (auto, mixamo_to_rigify, custom) (default: auto)
- `--skip-confirmation` - Skip bone mapping confirmation (default: false)
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
- `-o, --output <string>` - Output directory
**Example:**
```bash
blender-toolkit retarget --target "Hero" --file "./Walking.fbx" --name "Walking"
```
**With Auto Confirmation:**
```bash
blender-toolkit retarget --target "Hero" --file "./Walking.fbx" --skip-confirmation
```
### mixamo-help
Show Mixamo download instructions and popular animations.
```bash
blender-toolkit mixamo-help [animation-name]
```
**Arguments:**
- `[animation-name]` - Optional: Get specific animation instructions
**Example:**
```bash
# Show all popular animations and general instructions
blender-toolkit mixamo-help
# Show instructions for specific animation
blender-toolkit mixamo-help Walking
```
---
## Daemon Commands
Manage Blender WebSocket server daemon.
### daemon-start
Start the Blender WebSocket server.
```bash
blender-toolkit daemon-start [options]
```
**Options:**
- `-p, --port <number>` - Port number (default: 9400)
**Example:**
```bash
blender-toolkit daemon-start --port 9400
```
### daemon-stop
Stop the Blender WebSocket server.
```bash
blender-toolkit daemon-stop [options]
```
**Options:**
- `-p, --port <number>` - Port number (default: 9400)
**Example:**
```bash
blender-toolkit daemon-stop
```
### daemon-status
Check Blender WebSocket server status.
```bash
blender-toolkit daemon-status [options]
```
**Options:**
- `-p, --port <number>` - Port number (default: 9400)
**Example:**
```bash
blender-toolkit daemon-status
```
---
## Global Options
Options available for all commands:
- `-p, --port <number>` - Blender WebSocket port (default: 9400)
- `-h, --help` - Display help for command
- `-V, --version` - Output the version number
---
## Port Range
Blender Toolkit uses port range **9400-9500** for WebSocket connections.
- Default port: **9400**
- Browser Pilot uses: **9222-9322** (no conflict)
- Multiple projects can run simultaneously with different ports
- Ports are auto-assigned and persisted in project configuration
---
## Tips
1. **Use `--help` for Detailed Options:**
```bash
blender-toolkit <command> --help
```
2. **Port Conflicts:**
- If default port 9400 is in use, specify a different port
- Configuration persists across sessions
3. **Object Names are Case-Sensitive:**
- Use exact names as they appear in Blender
4. **WebSocket Connection:**
- Ensure Blender addon is enabled and server is started
- Check port number matches between CLI and addon
5. **Batch Operations:**
- Use shell scripts to combine multiple commands
- Example: Create multiple objects with different positions

View File

@@ -0,0 +1,817 @@
# Workflow Guide
Detailed guide for animation retargeting workflows using Blender Toolkit.
## Table of Contents
- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [Complete Retargeting Workflow](#complete-retargeting-workflow)
- [Mixamo Download Workflow](#mixamo-download-workflow)
- [Two-Phase Confirmation Workflow](#two-phase-confirmation-workflow)
- [Batch Processing Workflow](#batch-processing-workflow)
- [Multi-Project Workflow](#multi-project-workflow)
- [Advanced Workflows](#advanced-workflows)
- [Common Scenarios](#common-scenarios)
- [Troubleshooting](#troubleshooting)
---
## Overview
Blender Toolkit provides a complete workflow for retargeting Mixamo animations to custom character rigs in Blender.
**Core Workflow Steps:**
1. Prepare character rig in Blender
2. Download animation from Mixamo
3. Connect to Blender via WebSocket
4. Import and auto-map bones
5. Review mapping in Blender UI
6. Confirm and apply retargeting
7. Animation baked to NLA track
**Key Features:**
- WebSocket-based real-time control
- Two-phase confirmation workflow
- Automatic bone mapping with UI review
- Quality assessment
- Multi-project support
- Session hooks for auto-initialization
---
## Prerequisites
### 1. Blender Setup
**Install and Configure:**
```
1. Install Blender 4.0 or higher (2023+)
2. Install Python addon:
Method 1 (Recommended): Install from ZIP
- Edit → Preferences → Add-ons → Install
- Select: .blender-toolkit/blender-toolkit-addon-v*.zip
- Enable "Blender Toolkit WebSocket Server"
Method 2: Install from Source
- Edit → Preferences → Add-ons → Install
- Select: plugins/blender-toolkit/skills/addon/__init__.py
- Enable "Blender Toolkit WebSocket Server"
3. Start WebSocket server:
- View3D → Sidebar (N key) → "Blender Toolkit" tab
- Click "Start Server"
- Default port: 9400
```
### 2. Character Rig Requirements
**Your Character Must Have:**
- ✅ Armature with properly set up bones
- ✅ Standard or Rigify-compatible bone naming (recommended)
- ✅ Proper parent-child bone hierarchy
- ✅ Character loaded in current Blender scene
**Supported Rig Types:**
- Rigify control rigs ⭐ (best support)
- Custom rigs with standard naming
- Game engine rigs (UE4/UE5, Unity)
- Any armature with clear bone hierarchy
### 3. Local Scripts
**Auto-Initialized by SessionStart Hook:**
- TypeScript source copied to `.blender-toolkit/skills/scripts/`
- Dependencies installed (`npm install`)
- Scripts built (`npm run build`)
- CLI wrapper created (`.blender-toolkit/bt.js`)
**Manual Check (if needed):**
```bash
# Verify scripts are built
ls .blender-toolkit/skills/scripts/dist
# Rebuild if necessary
cd .blender-toolkit/skills/scripts
npm install
npm run build
```
---
## Complete Retargeting Workflow
### Step 1: Prepare Character
**In Blender:**
```
1. Open your character model
2. Verify armature exists and is rigged
3. Note the exact armature name (case-sensitive)
4. Check bone structure (Edit Mode):
- Proper hierarchy (Hips → Spine → etc.)
- Standard naming (preferred)
5. Leave Blender open with character visible
```
**Tips:**
- Use descriptive armature name: "HeroRig", "PlayerModel"
- Avoid generic names: "Armature", "Armature.001"
- Ensure character is in rest pose
### Step 2: Download Mixamo Animation
**Option A: User Has FBX File**
- User provides path to downloaded FBX
- Skip to Step 3
**Option B: User Needs to Download**
```bash
# Show download instructions
blender-toolkit mixamo-help Walking
```
**Download Steps:**
1. Go to Mixamo.com
2. Search for animation (e.g., "Walking")
3. Configure settings:
- Format: FBX (.fbx)
- Skin: Without Skin
- FPS: 30
- Keyframe Reduction: None
4. Click "Download"
5. Note download path
**Recommended Settings:**
```
Format: FBX (.fbx)
Skin: Without Skin
Frame Rate: 30 fps
Keyframe Reduction: None
```
### Step 3: Verify Blender Connection
**Check WebSocket Server:**
```bash
blender-toolkit daemon-status
```
**If Not Running:**
```
1. Open Blender
2. Press N key in 3D View
3. Click "Blender Toolkit" tab
4. Click "Start Server"
```
**Expected Output:**
```
✅ Blender WebSocket server is running on port 9400
```
### Step 4: Execute Retargeting
**Basic Command:**
```bash
blender-toolkit retarget \
--target "HeroRig" \
--file "./downloads/Walking.fbx" \
--name "Walking"
```
**What Happens:**
```
🎬 Starting animation retargeting workflow...
[1/6] Connecting to Blender...
✅ Connected to Blender on port 9400
[2/6] Importing animation FBX...
✅ Animation imported: 30 frames
[3/6] Analyzing bone structure...
✅ Source bones: 65 (Mixamo)
✅ Target bones: 52 (HeroRig)
[4/6] Auto-generating bone mapping...
✅ Mapped 48 bones
✅ Quality: Excellent (8/9 critical bones)
[5/6] Displaying mapping in Blender UI...
✅ Mapping displayed in "Bone Mapping Review" panel
⏸ Workflow paused for user review
👉 Please review the bone mapping in Blender
👉 Edit any incorrect mappings
👉 Click "Apply Retargeting" when ready
```
### Step 5: Review Mapping in Blender
**Open Mapping Panel:**
```
1. Press N key in 3D View
2. Go to "Blender Toolkit" tab
3. Find "Bone Mapping Review" panel
```
**Review Checklist:**
- [ ] Hips mapped correctly (root motion)
- [ ] Spine chain mapped in order
- [ ] Left/Right arms not swapped
- [ ] Left/Right legs not swapped
- [ ] Hands and feet mapped
- [ ] Head and neck mapped
**Edit If Needed:**
- Click dropdown next to incorrect mapping
- Select correct bone from list
- Repeat for all issues
### Step 6: Apply Retargeting
**In Blender:**
```
1. After reviewing mappings
2. Click "Apply Retargeting" button
3. Wait for processing
```
**Processing Steps:**
```
[6/6] Applying retargeting...
- Creating constraint setup...
- Baking animation to keyframes...
- Adding to NLA track...
- Cleaning up temporary objects...
✅ Animation retargeting completed successfully!
```
**Result:**
- Animation applied to your character
- Stored in NLA track named "Walking"
- Original rig unchanged
- Ready for editing or export
---
## Mixamo Download Workflow
Step-by-step guide for downloading animations from Mixamo.
### Get Download Instructions
**Show Popular Animations:**
```bash
blender-toolkit mixamo-help
```
**Output:**
```
📚 Popular Mixamo Animations:
Locomotion:
• Walking
• Running
• Jogging
• Sprinting
• Crouching
Combat:
• Punching
• Kicking
• Sword Slash
• Rifle Aim
• Pistol Fire
Idle:
• Idle
• Breathing Idle
• Standing Idle
```
**Get Specific Instructions:**
```bash
blender-toolkit mixamo-help Walking
```
**Output:**
```
📥 Mixamo Download Instructions for "Walking"
1. Go to https://www.mixamo.com
2. Sign in or create account (free)
3. Search for "Walking" in the search bar
4. Select the animation you want
5. Click "Download" button
6. Configure download settings:
✅ Format: FBX (.fbx)
✅ Skin: Without Skin
✅ Frame Rate: 30 fps
✅ Keyframe Reduction: None
7. Click "Download"
8. Note the downloaded file path
⚙️ Recommended Settings:
Format: FBX (.fbx)
Skin: Without Skin
Frame Rate: 30 fps
Keyframe Reduction: None
```
### Why "Without Skin"
**Reasons:**
- We only need animation data, not mesh
- Reduces file size significantly
- Faster import into Blender
- Cleaner workflow (no extra objects to delete)
**What It Means:**
- FBX contains only skeleton and keyframes
- No mesh/geometry included
- Perfect for retargeting to existing characters
---
## Two-Phase Confirmation Workflow
The workflow pauses after mapping generation for user review.
### Phase 1: Generate and Display
**Automatic Steps:**
```
1. Import FBX [Auto]
2. Extract bone structure [Auto]
3. Generate mapping [Auto]
4. Display in UI [Auto]
5. Pause for review [Manual]
```
**User Actions:**
- Review mapping quality
- Check critical bones
- Edit incorrect mappings
- Confirm readiness
### Phase 2: Apply and Bake
**Triggered by User:**
- User clicks "Apply Retargeting" in Blender
**Automatic Steps:**
```
6. Create constraints [Auto]
7. Bake to keyframes [Auto]
8. Add to NLA track [Auto]
9. Cleanup [Auto]
10. Complete [Auto]
```
### Skipping Confirmation
**For Trusted Mappings:**
```bash
blender-toolkit retarget \
--target "HeroRig" \
--file "./Walking.fbx" \
--skip-confirmation
```
**When to Skip:**
- Excellent quality mapping (8-9 critical bones)
- Repeated animations on same character
- Using proven custom mapping
- Batch processing with known-good setup
**When NOT to Skip:**
- First animation on new character
- Unknown rig structure
- Fair or Poor quality mapping
- Complex or unusual animations
---
## Batch Processing Workflow
Process multiple animations efficiently.
### Step 1: Test Single Animation
**Verify Setup:**
```bash
# Test with one animation first
blender-toolkit retarget \
--target "HeroRig" \
--file "./Walking.fbx" \
--name "Walking"
```
**Check Results:**
- Animation looks correct
- No twisted limbs
- Left/Right not swapped
- Quality is excellent
### Step 2: Extract Mapping
**Save Successful Mapping:**
```typescript
// After successful test, save the mapping
// Check Blender console or logs for generated mapping
const heroRigMapping = {
"Hips": "root",
"Spine": "spine_01",
"Spine1": "spine_02",
// ... complete mapping
};
// Save to file for reuse
fs.writeFileSync('./hero-mapping.json', JSON.stringify(heroRigMapping));
```
### Step 3: Batch Process
**Shell Script Example:**
```bash
#!/bin/bash
# batch-retarget.sh
ANIMATIONS=(
"Walking"
"Running"
"Jumping"
"Idle"
"Punching"
)
for anim in "${ANIMATIONS[@]}"; do
echo "Processing ${anim}..."
blender-toolkit retarget \
--target "HeroRig" \
--file "./animations/${anim}.fbx" \
--name "${anim}" \
--skip-confirmation
echo "${anim} completed"
done
echo "🎉 All animations processed!"
```
**TypeScript Example:**
```typescript
// batch-retarget.ts
const animations = [
'Walking', 'Running', 'Jumping',
'Idle', 'Punching'
];
const workflow = new AnimationRetargetingWorkflow();
for (const anim of animations) {
await workflow.run({
targetCharacterArmature: 'HeroRig',
animationFilePath: `./animations/${anim}.fbx`,
animationName: anim,
boneMapping: heroRigMapping, // Reuse saved mapping
skipConfirmation: true
});
console.log(`${anim} completed`);
}
```
---
## Multi-Project Workflow
Work with multiple Blender projects simultaneously.
### Port Management
**Default Behavior:**
- First project: Port 9400
- Second project: Port 9401
- Third project: Port 9402
- Auto-increments for each project
**Configuration:**
```json
// ~/.claude/plugins/.../blender-config.json
{
"projects": {
"/path/to/project-a": {
"port": 9400,
"lastUsed": "2024-01-15T10:30:00Z"
},
"/path/to/project-b": {
"port": 9401,
"lastUsed": "2024-01-15T11:00:00Z"
}
}
}
```
### Workflow
**Project A:**
```bash
cd /path/to/project-a
# Start Blender with port 9400
# Run retargeting
blender-toolkit retarget \
--target "CharacterA" \
--file "./Walking.fbx" \
--port 9400
```
**Project B (Simultaneously):**
```bash
cd /path/to/project-b
# Start Blender with port 9401
# Run retargeting
blender-toolkit retarget \
--target "CharacterB" \
--file "./Running.fbx" \
--port 9401
```
**Benefits:**
- No port conflicts
- Simultaneous processing
- Independent configurations
- Separate log files
---
## Advanced Workflows
### Custom Bone Mapping Workflow
**For Non-Standard Rigs:**
**Step 1: Analyze Bones**
```bash
# List all bones in target rig
blender-toolkit list-objects --type ARMATURE
blender-toolkit get-bones --armature "MyRig"
```
**Step 2: Create Mapping**
```typescript
// custom-mapping.ts
export const myRigMapping = {
// Core
"Hips": "pelvis",
"Spine": "spine_01",
"Spine1": "spine_02",
"Spine2": "chest",
"Neck": "neck_01",
"Head": "head",
// Left Arm
"LeftShoulder": "clavicle_L",
"LeftArm": "upperarm_L",
"LeftForeArm": "forearm_L",
"LeftHand": "hand_L",
// Right Arm
"RightShoulder": "clavicle_R",
"RightArm": "upperarm_R",
"RightForeArm": "forearm_R",
"RightHand": "hand_R",
// Add remaining bones...
};
```
**Step 3: Use Custom Mapping**
```typescript
import { myRigMapping } from './custom-mapping';
await workflow.run({
targetCharacterArmature: 'MyRig',
animationFilePath: './Walking.fbx',
boneMapping: myRigMapping,
skipConfirmation: true
});
```
### Animation Library Workflow
**Organize Animation Library:**
**Directory Structure:**
```
animations/
├── locomotion/
│ ├── walking.fbx
│ ├── running.fbx
│ └── jumping.fbx
├── combat/
│ ├── punch.fbx
│ ├── kick.fbx
│ └── block.fbx
└── idle/
├── idle.fbx
└── breathing.fbx
```
**Batch Import Script:**
```typescript
// import-library.ts
const library = {
locomotion: ['walking', 'running', 'jumping'],
combat: ['punch', 'kick', 'block'],
idle: ['idle', 'breathing']
};
for (const [category, animations] of Object.entries(library)) {
for (const anim of animations) {
await workflow.run({
targetCharacterArmature: 'Hero',
animationFilePath: `./animations/${category}/${anim}.fbx`,
animationName: `${category}_${anim}`,
boneMapping: 'auto',
skipConfirmation: false // Review each category first
});
}
}
```
---
## Common Scenarios
### Scenario 1: First-Time User
**Goal:** Retarget first Mixamo animation to custom character
**Steps:**
1. Download animation from Mixamo
2. Start Blender with character
3. Enable and start WebSocket addon
4. Run retarget command
5. Review mapping in UI
6. Apply retargeting
**Commands:**
```bash
# Get download instructions
blender-toolkit mixamo-help Walking
# After downloading...
blender-toolkit retarget \
--target "MyCharacter" \
--file "./Walking.fbx"
```
### Scenario 2: Rigify User
**Goal:** Fast workflow for standard Rigify rig
**Steps:**
1. Download animation
2. Run with Rigify preset
3. Auto-apply (skip confirmation)
**Commands:**
```bash
blender-toolkit retarget \
--target "MyRigifyCharacter" \
--file "./Walking.fbx" \
--mapping mixamo_to_rigify \
--skip-confirmation
```
### Scenario 3: Game Developer
**Goal:** Import 50 animations for game character
**Steps:**
1. Test one animation
2. Save mapping configuration
3. Batch process all animations
4. Export to game engine
**Commands:**
```bash
# Test first
blender-toolkit retarget \
--target "GameCharacter" \
--file "./test.fbx"
# Batch process
./batch-import.sh
```
### Scenario 4: Studio Pipeline
**Goal:** Integrate into production pipeline
**Setup:**
- Custom wrapper scripts
- CI/CD integration
- Automated testing
- Quality validation
**Pipeline:**
```yaml
# .github/workflows/animation-pipeline.yml
jobs:
retarget:
runs-on: ubuntu-latest
steps:
- name: Setup Blender
run: install-blender
- name: Start WebSocket
run: start-blender-daemon
- name: Retarget Animations
run: |
for fbx in animations/*.fbx; do
blender-toolkit retarget \
--target "$CHARACTER" \
--file "$fbx" \
--skip-confirmation
done
```
---
## Troubleshooting
### Connection Issues
**Problem:** "Failed to connect to Blender"
**Solutions:**
```bash
# 1. Check if Blender is running
ps aux | grep -i blender
# 2. Verify addon is enabled
# In Blender: Edit → Preferences → Add-ons → Search "Blender Toolkit"
# 3. Check server status
blender-toolkit daemon-status
# 4. Restart server
# In Blender: Click "Stop Server", then "Start Server"
# 5. Try different port
blender-toolkit retarget --port 9401 ...
```
### Import Issues
**Problem:** "Failed to import FBX file"
**Solutions:**
- Verify file path is correct
- Check FBX format (should be Binary, not ASCII)
- Ensure file is not corrupted
- Try re-downloading from Mixamo
### Mapping Issues
**Problem:** "Poor quality mapping"
**Solutions:**
1. Lower threshold:
```typescript
// Custom workflow
threshold: 0.5 // Default is 0.6
```
2. Use custom mapping for critical bones
3. Review bone names in Blender:
- Edit Mode → Show bone names
- Check for typos or unusual names
### Animation Issues
**Problem:** "Animation looks wrong"
**Solutions:**
- Check bone roll in Edit Mode
- Verify constraint influence
- Review mapping (especially left/right)
- Test with simple animation first
### Performance Issues
**Problem:** "Retargeting is slow"
**Solutions:**
- Close other Blender instances
- Reduce FBX complexity (remove unnecessary bones)
- Use SSD for faster file I/O
- Process in batches during off-hours

View File

@@ -0,0 +1,43 @@
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
export default [
{
ignores: [
'node_modules/**',
'dist/**',
'*.backup/**'
]
},
{
files: ['src/**/*.ts'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: './tsconfig.json'
}
},
plugins: {
'@typescript-eslint': typescriptEslint
},
rules: {
// TypeScript 규칙
'@typescript-eslint/no-explicit-any': 'error', // any 사용 금지 - 타입 가드 또는 명시적 타입 사용
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_'
}],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'warn',
// 일반 규칙
'no-console': 'off', // CLI 도구이므로 console 사용 허용
'prefer-const': 'error',
'no-var': 'error'
}
}
];

71
skills/scripts/install-addon.py Executable file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""
Blender Toolkit Addon Auto-Installer
Blender를 백그라운드에서 실행하여 애드온을 자동으로 설치/활성화합니다.
"""
import bpy
import sys
import os
from pathlib import Path
def install_addon():
"""애드온 설치 및 활성화"""
# 애드온 경로 (이 스크립트의 부모 디렉토리)
script_dir = Path(__file__).parent.absolute()
addon_dir = script_dir.parent / "addon"
addon_init = addon_dir / "__init__.py"
if not addon_init.exists():
print(f"❌ Error: Addon not found at {addon_init}")
sys.exit(1)
print(f"📦 Installing Blender Toolkit addon from: {addon_dir}")
try:
# 애드온이 이미 설치되어 있으면 먼저 제거
addon_name = "blender_toolkit_websocket"
if addon_name in bpy.context.preferences.addons:
print(f"🔄 Removing existing addon: {addon_name}")
bpy.ops.preferences.addon_disable(module=addon_name)
bpy.ops.preferences.addon_remove(module=addon_name)
# 애드온 디렉토리를 Blender scripts path에 추가
scripts_path = bpy.utils.user_resource('SCRIPTS', path="addons")
# 심볼릭 링크 또는 복사 방식으로 설치
import shutil
target_path = Path(scripts_path) / "blender_toolkit_websocket"
if target_path.exists():
print(f"🔄 Removing existing installation at {target_path}")
shutil.rmtree(target_path)
print(f"📋 Copying addon to: {target_path}")
shutil.copytree(addon_dir, target_path)
# 애드온 활성화
print(f"✅ Enabling addon: {addon_name}")
bpy.ops.preferences.addon_enable(module=addon_name)
# User preferences 저장
bpy.ops.wm.save_userpref()
print("✅ Addon installed and enabled successfully!")
print("\n📝 Next steps:")
print(" 1. Start Blender normally")
print(" 2. The WebSocket server will auto-start on port 9400")
print(" 3. Use CLI: node dist/cli/cli.js <command>")
return 0
except Exception as e:
print(f"❌ Error installing addon: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
exit_code = install_addon()
sys.exit(exit_code)

View File

@@ -0,0 +1,39 @@
{
"name": "blender-toolkit-cli",
"version": "1.4.4",
"description": "Blender automation CLI with geometry, materials, modifiers, collections, animation retargeting, and WebSocket-based control",
"main": "dist/index.js",
"bin": {
"blender-toolkit": "./dist/cli/cli.js"
},
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"clean": "rm -rf dist"
},
"keywords": [
"blender",
"animation",
"retargeting",
"mixamo",
"websocket",
"geometry",
"materials",
"modifiers",
"collections",
"3d",
"automation"
],
"author": "Dev GOM",
"license": "Apache-2.0",
"dependencies": {
"commander": "^14.0.2",
"ws": "^8.14.0",
"winston": "^3.11.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/ws": "^8.5.0",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,198 @@
/**
* Blender WebSocket Client
* Blender Python 애드온과 통신하기 위한 WebSocket 클라이언트
*/
import WebSocket from 'ws';
import { EventEmitter } from 'events';
import { BLENDER } from '../constants';
import { log } from '../utils/logger';
export interface BlenderMessage {
id: number;
method: string;
params?: unknown;
}
export interface BlenderResponse {
id: number;
result?: unknown;
error?: {
code: number;
message: string;
};
}
export interface BlenderEvent {
method: string;
params?: unknown;
}
export class BlenderClient extends EventEmitter {
private ws: WebSocket | null = null;
private messageId = 0;
private wsUrl: string;
private port: number;
constructor(port: number = BLENDER.DEFAULT_PORT) {
super();
this.port = port;
this.wsUrl = `ws://${BLENDER.LOCALHOST}:${port}`;
}
/**
* Blender에 WebSocket으로 연결
*/
async connect(port?: number): Promise<void> {
// port가 제공되면 업데이트
if (port !== undefined) {
this.port = port;
this.wsUrl = `ws://${BLENDER.LOCALHOST}:${port}`;
}
log.info(`Connecting to Blender WebSocket: ${this.wsUrl}`);
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.wsUrl);
const timeout = setTimeout(() => {
if (this.ws) {
this.ws.terminate();
}
const errorMsg = `Connection timeout (${BLENDER.WS_TIMEOUT}ms)`;
log.error(errorMsg);
reject(new Error(errorMsg));
}, BLENDER.WS_TIMEOUT);
this.ws.on('open', () => {
clearTimeout(timeout);
log.info('WebSocket connection established');
// 전역 메시지 핸들러 설정 (이벤트 수신용)
if (this.ws) {
this.ws.on('message', (data: WebSocket.Data) => {
try {
const message = JSON.parse(data.toString());
// 이벤트는 id가 없고 method만 있음
if (!message.id && message.method) {
this.emit('event', message as BlenderEvent);
this.emit(message.method, message.params);
}
} catch (error) {
// JSON 파싱 에러는 무시하되 디버그 모드에서는 로깅
if (process.env.DEBUG) {
console.debug('[BlenderClient] Event JSON parse error:', error);
}
}
});
}
resolve();
});
this.ws.on('error', (error) => {
clearTimeout(timeout);
log.error(`WebSocket error: ${error.message}`);
reject(error);
});
this.ws.on('close', () => {
log.info('WebSocket connection closed');
this.emit('disconnected');
});
});
}
/**
* Blender에 명령 전송 및 응답 대기
*/
async sendCommand<T = any>(
method: string,
params?: unknown
): Promise<T> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
const errorMsg = 'Not connected to Blender';
log.error(errorMsg);
throw new Error(errorMsg);
}
// Capture ws reference for use in callbacks
const ws = this.ws;
const id = ++this.messageId;
const message: BlenderMessage = { id, method, params };
log.debug(`Sending command: ${method}`, params);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ws.off('message', messageHandler);
reject(new Error(`Command timeout: ${method}`));
}, BLENDER.WS_TIMEOUT);
// 응답 대기
const messageHandler = (data: WebSocket.Data) => {
try {
const response = JSON.parse(data.toString()) as BlenderResponse;
if (response.id === id) {
clearTimeout(timeout);
ws.off('message', messageHandler);
if (response.error) {
log.error(`Command ${method} failed: ${response.error.message}`);
reject(new Error(response.error.message));
} else {
log.debug(`Command ${method} completed successfully`);
resolve(response.result as T);
}
}
} catch (error) {
// JSON 파싱 에러는 무시 (다른 메시지일 수 있음)
// 디버그 모드에서만 로깅
if (process.env.DEBUG) {
console.debug('[BlenderClient] JSON parse error:', error);
}
}
};
ws.on('message', messageHandler);
// 메시지 전송
ws.send(JSON.stringify(message), (error) => {
if (error) {
clearTimeout(timeout);
ws.off('message', messageHandler);
reject(error);
}
});
});
}
/**
* WebSocket 연결 종료
*/
async disconnect(): Promise<void> {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
/**
* 연결 종료 (disconnect의 alias)
*/
close(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
/**
* 연결 상태 확인
*/
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
}

View File

@@ -0,0 +1,352 @@
/**
* Configuration management for Blender WebSocket port and state
* Browser-Pilot의 config 시스템을 참고한 프로젝트별 설정 관리
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, statSync } from 'fs';
import { join, basename } from 'path';
import { tmpdir } from 'os';
import { createServer } from 'net';
import { BLENDER, FS } from '../constants';
export interface ProjectConfig {
rootPath: string;
port: number;
outputDir: string;
lastUsed: string | null;
autoCleanup: boolean;
}
export interface SharedBlenderConfig {
projects: {
[projectName: string]: ProjectConfig;
};
}
/**
* 로컬 타임스탬프 문자열 생성
* Format: YYYY-MM-DD HH:MM:SS.mmm
*/
function getLocalTimestamp(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
}
/**
* 공유 설정 파일 경로 가져오기
* Browser Pilot 패턴: CLAUDE_PLUGIN_ROOT 환경 변수 사용 (fallback 없음)
* 위치: $CLAUDE_PLUGIN_ROOT/skills/blender-config.json
*/
function getSharedConfigPath(): string {
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
if (!pluginRoot) {
console.error('Error: CLAUDE_PLUGIN_ROOT environment variable not set');
console.error('This tool must be run from Claude Code environment');
process.exit(1);
}
return join(pluginRoot, 'skills', 'blender-config.json');
}
/**
* 프로젝트 루트 찾기
* Browser Pilot 패턴: 환경 변수 검증 후 fallback
*/
export function findProjectRoot(): string {
const projectDir = process.env.CLAUDE_PROJECT_DIR;
if (projectDir) {
// 경로 존재 여부 확인
if (!existsSync(projectDir)) {
console.warn(`Warning: CLAUDE_PROJECT_DIR points to non-existent path: ${projectDir}`);
console.warn('Falling back to current working directory');
return process.cwd();
}
// 디렉토리인지 확인
try {
const stats = statSync(projectDir);
if (!stats.isDirectory()) {
console.error(`Error: CLAUDE_PROJECT_DIR is not a directory: ${projectDir}`);
process.exit(1);
}
return projectDir;
} catch (error) {
console.warn(`Warning: Cannot access CLAUDE_PROJECT_DIR: ${projectDir}`);
console.warn('Falling back to current working directory');
return process.cwd();
}
}
// 환경 변수 없으면 현재 작업 디렉토리 사용
return process.cwd();
}
/**
* 프로젝트 이름 가져오기 (폴더 이름)
*/
function getProjectName(projectRoot: string): string {
return basename(projectRoot);
}
/**
* 프로젝트 출력 디렉토리 가져오기
*/
export function getOutputDir(): string {
const projectRoot = findProjectRoot();
const outputDir = join(projectRoot, FS.OUTPUT_DIR);
// .blender-toolkit 디렉토리 생성
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
// .gitignore 생성
const gitignorePath = join(outputDir, '.gitignore');
if (!existsSync(gitignorePath)) {
writeFileSync(gitignorePath, FS.GITIGNORE_CONTENT, 'utf-8');
}
return outputDir;
}
/**
* 공유 설정 로드
*/
export function loadSharedConfig(): SharedBlenderConfig {
const configPath = getSharedConfigPath();
// 설정 파일 디렉토리 생성
const configDir = join(configPath, '..');
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
if (!existsSync(configPath)) {
// 기본 설정 생성
const defaultConfig: SharedBlenderConfig = {
projects: {}
};
saveSharedConfig(defaultConfig);
return defaultConfig;
}
try {
const data = readFileSync(configPath, 'utf-8');
return JSON.parse(data);
} catch (error) {
console.error('Failed to load shared config:', error);
console.warn('Returning empty config - existing settings may be lost');
console.warn(`Config path: ${configPath}`);
return {
projects: {}
};
}
}
/**
* 공유 설정 저장 (원자적 쓰기)
* Browser Pilot 패턴: 임시 파일에 쓴 후 rename으로 원자적 교체
*/
export function saveSharedConfig(config: SharedBlenderConfig): void {
const configPath = getSharedConfigPath();
const tempPath = join(tmpdir(), `blender-config-${Date.now()}-${process.pid}.tmp`);
try {
// 1. Write to temporary file first
writeFileSync(tempPath, JSON.stringify(config, null, 2), 'utf-8');
// 2. Atomic rename (replaces existing file)
renameSync(tempPath, configPath);
} catch (error) {
// Clean up temporary file if it exists
if (existsSync(tempPath)) {
try {
unlinkSync(tempPath);
} catch (cleanupError) {
// Ignore cleanup errors
}
}
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Failed to save shared config:', errorMessage);
console.warn(`Config path: ${configPath}`);
throw new Error(`Configuration save failed: ${errorMessage}`);
}
}
/**
* 현재 프로젝트의 설정 가져오기
* 없으면 사용 가능한 포트로 자동 생성
*/
export async function getProjectConfig(): Promise<ProjectConfig> {
const projectRoot = findProjectRoot();
const projectName = getProjectName(projectRoot);
const sharedConfig = loadSharedConfig();
// rootPath로 기존 설정 찾기 (이름이 바뀐 경우 대비)
const existingEntry = Object.entries(sharedConfig.projects).find(
([_, config]) => config.rootPath === projectRoot
);
if (existingEntry) {
const [existingName, config] = existingEntry;
// 이름이 바뀐 경우 업데이트
if (existingName !== projectName) {
delete sharedConfig.projects[existingName];
sharedConfig.projects[projectName] = config;
saveSharedConfig(sharedConfig);
console.log(`📝 Updated project name: ${existingName}${projectName}`);
}
return config;
}
// 같은 이름이 다른 경로에 있는지 확인
if (sharedConfig.projects[projectName]) {
console.warn(`⚠️ Project name "${projectName}" already exists with different path`);
console.warn(` Existing: ${sharedConfig.projects[projectName].rootPath}`);
console.warn(` Current: ${projectRoot}`);
throw new Error(`Project name conflict: "${projectName}"`);
}
// 새 프로젝트 설정 생성
const basePort = parseInt(process.env.BLENDER_WS_PORT || String(BLENDER.DEFAULT_PORT));
// 사용 중인 포트 목록
const usedPorts = Object.values(sharedConfig.projects).map(p => p.port);
let port = basePort;
// 사용 가능한 포트 찾기
while (usedPorts.includes(port) || !(await isPortAvailable(port))) {
port++;
if (port > basePort + BLENDER.PORT_RANGE_MAX) {
throw new Error(
`No available port found in range ${basePort}-${basePort + BLENDER.PORT_RANGE_MAX}`
);
}
}
const projectConfig: ProjectConfig = {
rootPath: projectRoot,
port,
outputDir: FS.OUTPUT_DIR,
lastUsed: getLocalTimestamp(),
autoCleanup: false // 안전을 위해 기본값 false
};
// 설정 저장
sharedConfig.projects[projectName] = projectConfig;
saveSharedConfig(sharedConfig);
console.log(`📝 Created config for project: ${projectName}`);
console.log(` Path: ${projectRoot}`);
console.log(` Port: ${port}`);
return projectConfig;
}
/**
* 마지막 사용 시간 업데이트
*/
export function updateProjectLastUsed(): void {
const projectRoot = findProjectRoot();
const projectName = getProjectName(projectRoot);
const sharedConfig = loadSharedConfig();
if (sharedConfig.projects[projectName]) {
sharedConfig.projects[projectName].lastUsed = getLocalTimestamp();
saveSharedConfig(sharedConfig);
}
}
/**
* 프로젝트 포트 가져오기
*/
export async function getProjectPort(): Promise<number> {
const config = await getProjectConfig();
return config.port;
}
/**
* 모든 프로젝트 목록
*/
export function listProjects(): void {
const sharedConfig = loadSharedConfig();
const projects = Object.entries(sharedConfig.projects);
if (projects.length === 0) {
console.log('No projects configured yet.');
return;
}
console.log(`\n📋 Configured Projects (${projects.length}):\n`);
projects.forEach(([name, config]) => {
console.log(` ${name}`);
console.log(` ├─ Path: ${config.rootPath}`);
console.log(` ├─ Port: ${config.port}`);
console.log(` ├─ Output: ${config.outputDir}`);
console.log(` └─ Last Used: ${config.lastUsed || 'Never'}\n`);
});
}
/**
* 프로젝트 설정 초기화
*/
export function resetProjectConfig(): void {
const projectRoot = findProjectRoot();
const projectName = getProjectName(projectRoot);
const sharedConfig = loadSharedConfig();
delete sharedConfig.projects[projectName];
saveSharedConfig(sharedConfig);
console.log(`🗑️ Removed config for project: ${projectName}`);
}
/**
* 포트 사용 가능 여부 확인
*/
export async function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = createServer();
server.once('error', () => {
resolve(false);
});
server.once('listening', () => {
server.close();
resolve(true);
});
server.listen(port, BLENDER.LOCALHOST);
});
}
/**
* 사용 가능한 포트 찾기
*/
export async function findAvailablePort(
startPort = BLENDER.DEFAULT_PORT,
maxAttempts = BLENDER.PORT_RANGE_MAX
): Promise<number> {
for (let port = startPort; port < startPort + maxAttempts; port++) {
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(
`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`
);
}

View File

@@ -0,0 +1,65 @@
/**
* Mixamo Integration - Manual Download Support
* Mixamo does not provide an official API, so users must download animations manually
*/
/**
* Provides manual download instructions and popular animation suggestions
*/
export class MixamoHelper {
/**
* Get manual download instructions for a specific animation
*/
getManualDownloadInstructions(animationName: string): string {
return `
📝 Manual Download Instructions for "${animationName}":
1. Visit https://www.mixamo.com
2. Login with your Adobe account
3. Search for "${animationName}"
4. Select the animation
5. Click "Download" button
6. Choose settings:
- Format: FBX (.fbx)
- Skin: Without Skin (recommended for retargeting)
- FPS: 30
7. Save to your project's animations folder
8. Return here and provide the file path
Alternative: You can also drag & drop the FBX file into Blender manually.
`.trim();
}
/**
* Get list of popular Mixamo animations
*/
getPopularAnimations(): Array<{ name: string; category: string }> {
return [
{ name: 'Walking', category: 'Locomotion' },
{ name: 'Running', category: 'Locomotion' },
{ name: 'Idle', category: 'Idle' },
{ name: 'Jump', category: 'Action' },
{ name: 'Dancing', category: 'Dance' },
{ name: 'Sitting', category: 'Sitting' },
{ name: 'Standing', category: 'Standing' },
{ name: 'Fighting', category: 'Combat' },
{ name: 'Waving', category: 'Gesture' },
{ name: 'Talking', category: 'Gesture' },
];
}
/**
* Get download settings recommendation
*/
getRecommendedSettings(): {
format: string;
skin: string;
fps: number;
} {
return {
format: 'FBX (.fbx)',
skin: 'Without Skin',
fps: 30,
};
}
}

View File

@@ -0,0 +1,169 @@
/**
* Animation Retargeting Controller
* Mixamo 애니메이션을 사용자 캐릭터에 리타게팅
*/
import { BlenderClient } from './client';
import { RETARGETING, TIMING } from '../constants';
export interface RetargetOptions {
sourceArmature: string; // Mixamo 아마추어 이름
targetArmature: string; // 사용자 캐릭터 아마추어 이름
boneMapping?: 'auto' | 'mixamo_to_rigify' | 'custom';
customBoneMap?: Record<string, string>;
preserveRotation?: boolean;
preserveLocation?: boolean;
}
export interface BoneInfo {
name: string;
parent: string | null;
children: string[];
}
export class RetargetingController {
private client: BlenderClient;
constructor(client: BlenderClient) {
this.client = client;
}
/**
* 아마추어의 본 목록 가져오기
*/
async getBones(armatureName: string): Promise<BoneInfo[]> {
return await this.client.sendCommand<BoneInfo[]>('Armature.getBones', {
armatureName,
});
}
/**
* 자동 본 매핑 생성
* Mixamo 본 이름과 사용자 캐릭터 본 이름을 매칭
*/
async autoMapBones(
sourceArmature: string,
targetArmature: string
): Promise<Record<string, string>> {
return await this.client.sendCommand<Record<string, string>>(
'Retargeting.autoMapBones',
{
sourceArmature,
targetArmature,
}
);
}
/**
* 애니메이션 리타게팅 실행
*/
async retarget(options: RetargetOptions): Promise<void> {
const {
sourceArmature,
targetArmature,
boneMapping = 'auto',
customBoneMap,
preserveRotation = true,
preserveLocation = false,
} = options;
// 본 매핑 생성
let boneMap: Record<string, string>;
if (boneMapping === 'custom' && customBoneMap) {
boneMap = customBoneMap;
} else if (boneMapping === 'auto') {
console.log('🔍 Auto-detecting bone mapping...');
boneMap = await this.autoMapBones(sourceArmature, targetArmature);
console.log(`✅ Mapped ${Object.keys(boneMap).length} bones`);
} else {
// 미리 정의된 프리셋 사용
boneMap = await this.client.sendCommand<Record<string, string>>(
'Retargeting.getPresetMapping',
{
preset: boneMapping,
}
);
}
// 본 매핑 검증
if (!boneMap || Object.keys(boneMap).length === 0) {
throw new Error('Bone mapping is empty. Cannot proceed with retargeting.');
}
// 리타게팅 실행
console.log('🎬 Starting animation retargeting...');
console.log(` Mapping ${Object.keys(boneMap).length} bones...`);
await this.client.sendCommand(
'Retargeting.retargetAnimation',
{
sourceArmature,
targetArmature,
boneMap,
preserveRotation,
preserveLocation,
},
TIMING.RETARGET_TIMEOUT
);
console.log('✅ Animation retargeted successfully');
}
/**
* NLA(Non-Linear Animation) 트랙에 애니메이션 추가
*/
async addToNLA(
armatureName: string,
actionName: string,
trackName?: string
): Promise<void> {
await this.client.sendCommand('Animation.addToNLA', {
armatureName,
actionName,
trackName: trackName || `Mixamo_${Date.now()}`,
});
}
/**
* 애니메이션 클립 목록 가져오기
*/
async getAnimations(armatureName: string): Promise<string[]> {
return await this.client.sendCommand<string[]>('Animation.list', {
armatureName,
});
}
/**
* 애니메이션 미리보기 재생
*/
async playAnimation(
armatureName: string,
actionName: string,
loop: boolean = true
): Promise<void> {
await this.client.sendCommand('Animation.play', {
armatureName,
actionName,
loop,
});
}
/**
* 애니메이션 정지
*/
async stopAnimation(): Promise<void> {
await this.client.sendCommand('Animation.stop');
}
}
// BlenderClient에 timeout 파라미터 추가를 위한 타입 확장
declare module './client' {
interface BlenderClient {
sendCommand<T = Record<string, unknown>>(
method: string,
params?: unknown,
timeout?: number
): Promise<T>;
}
}

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env node
/**
* Blender Toolkit CLI - Blender automation command-line interface
* Provides geometry creation, object manipulation, and animation retargeting
*/
import { Command } from 'commander';
import { registerGeometryCommands } from './commands/geometry';
import { registerObjectCommands } from './commands/object';
import { registerModifierCommands } from './commands/modifier';
import { registerRetargetingCommands } from './commands/retargeting';
import { registerMaterialCommands } from './commands/material';
import { registerCollectionCommands } from './commands/collection';
import { registerDaemonCommands } from './commands/daemon';
const program = new Command();
program
.name('blender-toolkit')
.description('Blender automation CLI with geometry creation, materials, modifiers, collections, and animation retargeting')
.version('1.3.0')
.addHelpText('after', '\nTip: Use "<command> --help" to see detailed options for each command.\nExample: blender-toolkit material create --help');
// Register all command groups
registerGeometryCommands(program);
registerObjectCommands(program);
registerModifierCommands(program);
registerMaterialCommands(program);
registerCollectionCommands(program);
registerRetargetingCommands(program);
registerDaemonCommands(program);
// Parse command line arguments
program.parse();

View File

@@ -0,0 +1,119 @@
/**
* Collection CLI Commands
* 컬렉션 생성, 오브젝트 추가/제거 등의 CLI 명령
*/
import { Command } from 'commander';
import { BlenderClient } from '../../blender/client';
export function registerCollectionCommands(program: Command): void {
const collectionGroup = program
.command('collection')
.description('Collection management commands');
// Create collection
collectionGroup
.command('create')
.description('Create a new collection')
.requiredOption('--name <name>', 'Collection name')
.action(async (options) => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Collection.create', {
name: options.name
});
console.log('✅ Collection created:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
// List collections
collectionGroup
.command('list')
.description('List all collections')
.action(async () => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Collection.list', {});
console.log('📋 Collections:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
// Add object to collection
collectionGroup
.command('add-object')
.description('Add object to collection')
.requiredOption('--object <name>', 'Object name')
.requiredOption('--collection <name>', 'Collection name')
.action(async (options) => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Collection.addObject', {
objectName: options.object,
collectionName: options.collection
});
console.log('✅ Object added to collection:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
// Remove object from collection
collectionGroup
.command('remove-object')
.description('Remove object from collection')
.requiredOption('--object <name>', 'Object name')
.requiredOption('--collection <name>', 'Collection name')
.action(async (options) => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Collection.removeObject', {
objectName: options.object,
collectionName: options.collection
});
console.log('✅ Object removed from collection:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
// Delete collection
collectionGroup
.command('delete')
.description('Delete a collection')
.requiredOption('--name <name>', 'Collection name')
.action(async (options) => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Collection.delete', {
name: options.name
});
console.log('✅ Collection deleted:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
}

View File

@@ -0,0 +1,196 @@
/**
* Daemon management commands
*/
import { Command } from 'commander';
import { DaemonManager } from '../../daemon/manager';
import { spawn } from 'child_process';
import { join } from 'path';
import { existsSync } from 'fs';
export function registerDaemonCommands(program: Command) {
// Start daemon
program
.command('daemon-start')
.description('Start Blender Toolkit daemon (persistent background service)')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
await manager.start({ verbose: !options.quiet });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Stop daemon
program
.command('daemon-stop')
.description('Stop Blender Toolkit daemon')
.option('-q, --quiet', 'Suppress output')
.option('-f, --force', 'Force kill the daemon')
.action(async (options) => {
const manager = new DaemonManager();
try {
await manager.stop({ verbose: !options.quiet, force: options.force });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Restart daemon
program
.command('daemon-restart')
.description('Restart Blender Toolkit daemon')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
await manager.restart({ verbose: !options.quiet });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Daemon status
program
.command('daemon-status')
.description('Check daemon status and Blender connection info')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
const state = await manager.getStatus({ verbose: !options.quiet });
process.exit(state ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Addon install
program
.command('addon-install')
.description('Install Blender Toolkit addon automatically')
.option('-b, --blender <path>', 'Blender executable path', 'blender')
.action(async (options) => {
try {
console.log('🔧 Installing Blender Toolkit addon...\n');
// Install script path
const scriptDir = join(__dirname, '..', '..', '..');
const installScript = join(scriptDir, 'install-addon.py');
if (!existsSync(installScript)) {
console.error(`❌ Error: Install script not found at ${installScript}`);
process.exit(1);
}
console.log(`📍 Script: ${installScript}`);
console.log(`📍 Blender: ${options.blender}\n`);
// Run Blender in background with install script
const blender = spawn(options.blender, [
'--background',
'--python', installScript
], {
stdio: 'inherit'
});
blender.on('exit', (code) => {
if (code === 0) {
console.log('\n✅ Addon installation completed!');
console.log('\n📝 Next steps:');
console.log(' 1. Start Blender normally');
console.log(' 2. The WebSocket server will auto-start on port 9400');
console.log(' 3. Start daemon: blender-toolkit daemon-start');
console.log(' 4. Use CLI commands: blender-toolkit <command>');
} else {
console.error(`\n❌ Installation failed with code ${code}`);
}
process.exit(code || 0);
});
blender.on('error', (error) => {
console.error(`\n❌ Failed to run Blender: ${error.message}`);
console.error('\nTips:');
console.error(' - Make sure Blender is installed');
console.error(' - Use --blender flag to specify path: --blender /path/to/blender');
process.exit(1);
});
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Addon build
program
.command('addon-build')
.description('Build Blender addon ZIP package for distribution')
.option('-o, --output-dir <path>', 'Output directory for ZIP file')
.option('-f, --force', 'Force rebuild even if ZIP already exists')
.action(async (options) => {
try {
console.log('📦 Building Blender addon ZIP...\n');
// Build script path (plugins/blender-toolkit/scripts/build-addon.js)
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
if (!pluginRoot) {
console.error('❌ Error: CLAUDE_PLUGIN_ROOT environment variable not set');
process.exit(1);
}
const buildScript = join(pluginRoot, 'scripts', 'build-addon.js');
if (!existsSync(buildScript)) {
console.error(`❌ Error: Build script not found at ${buildScript}`);
process.exit(1);
}
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
console.log(`📍 Project: ${projectRoot}`);
console.log(`📍 Script: ${buildScript}\n`);
// Prepare arguments
const args = ['--project-root', projectRoot];
if (options.outputDir) {
args.push('--output-dir', options.outputDir);
}
if (options.force) {
args.push('--force');
}
// Run build script
const buildProcess = spawn('node', [buildScript, ...args], {
stdio: 'inherit'
});
buildProcess.on('exit', (code) => {
if (code === 0) {
console.log('\n📝 Next steps:');
console.log(' 1. Open Blender 4.0+');
console.log(' 2. Edit > Preferences > Add-ons > Install');
console.log(' 3. Select: .blender-toolkit/blender-toolkit-addon-v*.zip');
console.log(' 4. Enable "Blender Toolkit WebSocket Server"');
}
process.exit(code || 0);
});
buildProcess.on('error', (error) => {
console.error(`\n❌ Failed to run build script: ${error.message}`);
process.exit(1);
});
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,338 @@
/**
* Geometry Commands
* Blender 도형 생성 및 메쉬 편집 명령
*/
import { Command } from 'commander';
import { BlenderClient } from '../../blender/client';
import { logger } from '../../utils/logger';
const client = new BlenderClient();
export function registerGeometryCommands(program: Command) {
// Create Cube
program
.command('create-cube')
.description('Create a cube primitive')
.option('-x, --x <number>', 'X position', parseFloat, 0)
.option('-y, --y <number>', 'Y position', parseFloat, 0)
.option('-z, --z <number>', 'Z position', parseFloat, 0)
.option('-s, --size <number>', 'Cube size', parseFloat, 2.0)
.option('-n, --name <string>', 'Object name')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const result: any = await client.sendCommand('Geometry.createCube', {
location: [options.x, options.y, options.z],
size: options.size,
name: options.name
});
console.log('✅ Cube created successfully:');
console.log(` Name: ${result.name}`);
console.log(` Location: [${result.location.join(', ')}]`);
console.log(` Vertices: ${result.vertices}`);
console.log(` Faces: ${result.faces}`);
await client.disconnect();
} catch (error) {
logger.error('Failed to create cube:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Create Sphere
program
.command('create-sphere')
.description('Create a sphere primitive')
.option('-x, --x <number>', 'X position', parseFloat, 0)
.option('-y, --y <number>', 'Y position', parseFloat, 0)
.option('-z, --z <number>', 'Z position', parseFloat, 0)
.option('-r, --radius <number>', 'Sphere radius', parseFloat, 1.0)
.option('--segments <number>', 'Number of segments', parseInt, 32)
.option('--rings <number>', 'Number of rings', parseInt, 16)
.option('-n, --name <string>', 'Object name')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const result: any = await client.sendCommand('Geometry.createSphere', {
location: [options.x, options.y, options.z],
radius: options.radius,
segments: options.segments,
ringCount: options.rings,
name: options.name
});
console.log('✅ Sphere created successfully:');
console.log(` Name: ${result.name}`);
console.log(` Location: [${result.location.join(', ')}]`);
console.log(` Vertices: ${result.vertices}`);
console.log(` Faces: ${result.faces}`);
await client.disconnect();
} catch (error) {
logger.error('Failed to create sphere:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Create Cylinder
program
.command('create-cylinder')
.description('Create a cylinder primitive')
.option('-x, --x <number>', 'X position', parseFloat, 0)
.option('-y, --y <number>', 'Y position', parseFloat, 0)
.option('-z, --z <number>', 'Z position', parseFloat, 0)
.option('-r, --radius <number>', 'Cylinder radius', parseFloat, 1.0)
.option('-d, --depth <number>', 'Cylinder height/depth', parseFloat, 2.0)
.option('--vertices <number>', 'Number of vertices', parseInt, 32)
.option('-n, --name <string>', 'Object name')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const result: any = await client.sendCommand('Geometry.createCylinder', {
location: [options.x, options.y, options.z],
radius: options.radius,
depth: options.depth,
vertices: options.vertices,
name: options.name
});
console.log('✅ Cylinder created successfully:');
console.log(` Name: ${result.name}`);
console.log(` Location: [${result.location.join(', ')}]`);
console.log(` Vertices: ${result.vertices}`);
console.log(` Faces: ${result.faces}`);
await client.disconnect();
} catch (error) {
logger.error('Failed to create cylinder:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Create Plane
program
.command('create-plane')
.description('Create a plane primitive')
.option('-x, --x <number>', 'X position', parseFloat, 0)
.option('-y, --y <number>', 'Y position', parseFloat, 0)
.option('-z, --z <number>', 'Z position', parseFloat, 0)
.option('-s, --size <number>', 'Plane size', parseFloat, 2.0)
.option('-n, --name <string>', 'Object name')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const result: any = await client.sendCommand('Geometry.createPlane', {
location: [options.x, options.y, options.z],
size: options.size,
name: options.name
});
console.log('✅ Plane created successfully:');
console.log(` Name: ${result.name}`);
console.log(` Location: [${result.location.join(', ')}]`);
console.log(` Vertices: ${result.vertices}`);
console.log(` Faces: ${result.faces}`);
await client.disconnect();
} catch (error) {
logger.error('Failed to create plane:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Create Cone
program
.command('create-cone')
.description('Create a cone primitive')
.option('-x, --x <number>', 'X position', parseFloat, 0)
.option('-y, --y <number>', 'Y position', parseFloat, 0)
.option('-z, --z <number>', 'Z position', parseFloat, 0)
.option('-r, --radius <number>', 'Cone base radius', parseFloat, 1.0)
.option('-d, --depth <number>', 'Cone height/depth', parseFloat, 2.0)
.option('--vertices <number>', 'Number of vertices', parseInt, 32)
.option('-n, --name <string>', 'Object name')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const result: any = await client.sendCommand('Geometry.createCone', {
location: [options.x, options.y, options.z],
radius1: options.radius,
depth: options.depth,
vertices: options.vertices,
name: options.name
});
console.log('✅ Cone created successfully:');
console.log(` Name: ${result.name}`);
console.log(` Location: [${result.location.join(', ')}]`);
console.log(` Vertices: ${result.vertices}`);
console.log(` Faces: ${result.faces}`);
await client.disconnect();
} catch (error) {
logger.error('Failed to create cone:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Create Torus
program
.command('create-torus')
.description('Create a torus primitive')
.option('-x, --x <number>', 'X position', parseFloat, 0)
.option('-y, --y <number>', 'Y position', parseFloat, 0)
.option('-z, --z <number>', 'Z position', parseFloat, 0)
.option('--major-radius <number>', 'Major radius', parseFloat, 1.0)
.option('--minor-radius <number>', 'Minor radius (tube thickness)', parseFloat, 0.25)
.option('--major-segments <number>', 'Major segments', parseInt, 48)
.option('--minor-segments <number>', 'Minor segments', parseInt, 12)
.option('-n, --name <string>', 'Object name')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const result: any = await client.sendCommand('Geometry.createTorus', {
location: [options.x, options.y, options.z],
majorRadius: options.majorRadius,
minorRadius: options.minorRadius,
majorSegments: options.majorSegments,
minorSegments: options.minorSegments,
name: options.name
});
console.log('✅ Torus created successfully:');
console.log(` Name: ${result.name}`);
console.log(` Location: [${result.location.join(', ')}]`);
console.log(` Vertices: ${result.vertices}`);
console.log(` Faces: ${result.faces}`);
await client.disconnect();
} catch (error) {
logger.error('Failed to create torus:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Subdivide Mesh
program
.command('subdivide')
.description('Subdivide a mesh object')
.requiredOption('-n, --name <string>', 'Object name')
.option('-c, --cuts <number>', 'Number of subdivision cuts', parseInt, 1)
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const result: any = await client.sendCommand('Geometry.subdivideMesh', {
name: options.name,
cuts: options.cuts
});
console.log('✅ Mesh subdivided successfully:');
console.log(` Name: ${result.name}`);
console.log(` Vertices: ${result.vertices}`);
console.log(` Edges: ${result.edges}`);
console.log(` Faces: ${result.faces}`);
await client.disconnect();
} catch (error) {
logger.error('Failed to subdivide mesh:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Get Vertices
program
.command('get-vertices')
.description('Get vertices information of an object')
.requiredOption('-n, --name <string>', 'Object name')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const vertices: any = await client.sendCommand('Geometry.getVertices', {
name: options.name
});
console.log(`✅ Found ${vertices.length} vertices in "${options.name}":`);
if (vertices.length <= 10) {
// Show all vertices if 10 or less
vertices.forEach((v: any) => {
console.log(` Vertex ${v.index}: [${v.co.map((n: number) => n.toFixed(3)).join(', ')}]`);
});
} else {
// Show first 5 and last 5 if more than 10
for (let i = 0; i < 5; i++) {
const v = vertices[i];
console.log(` Vertex ${v.index}: [${v.co.map((n: number) => n.toFixed(3)).join(', ')}]`);
}
console.log(` ... (${vertices.length - 10} more vertices)`);
for (let i = vertices.length - 5; i < vertices.length; i++) {
const v = vertices[i];
console.log(` Vertex ${v.index}: [${v.co.map((n: number) => n.toFixed(3)).join(', ')}]`);
}
}
await client.disconnect();
} catch (error) {
logger.error('Failed to get vertices:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Move Vertex
program
.command('move-vertex')
.description('Move a specific vertex to a new position')
.requiredOption('-n, --name <string>', 'Object name')
.requiredOption('-i, --index <number>', 'Vertex index', parseInt)
.requiredOption('-x, --x <number>', 'New X position', parseFloat)
.requiredOption('-y, --y <number>', 'New Y position', parseFloat)
.requiredOption('-z, --z <number>', 'New Z position', parseFloat)
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const result: any = await client.sendCommand('Geometry.moveVertex', {
objectName: options.name,
vertexIndex: options.index,
newPosition: [options.x, options.y, options.z]
});
console.log('✅ Vertex moved successfully:');
console.log(` Object: ${result.object}`);
console.log(` Vertex ${result.vertex_index}: [${result.position.map((n: number) => n.toFixed(3)).join(', ')}]`);
await client.disconnect();
} catch (error) {
logger.error('Failed to move vertex:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,241 @@
/**
* Material CLI Commands
* 머티리얼 생성, 할당, 속성 설정 등의 CLI 명령
*/
import { Command } from 'commander';
import { BlenderClient } from '../../blender/client';
export function registerMaterialCommands(program: Command): void {
const materialGroup = program
.command('material')
.description('Material creation and management commands');
// Create material
materialGroup
.command('create')
.description('Create a new material')
.requiredOption('--name <name>', 'Material name')
.option('--no-nodes', 'Disable node-based material (default: enabled)')
.action(async (options) => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Material.create', {
name: options.name,
useNodes: options.nodes
});
console.log('✅ Material created:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
// List materials
materialGroup
.command('list')
.description('List all materials')
.action(async () => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Material.list', {});
console.log('📋 Materials:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
// Delete material
materialGroup
.command('delete')
.description('Delete a material')
.requiredOption('--name <name>', 'Material name')
.action(async (options) => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Material.delete', {
name: options.name
});
console.log('✅ Material deleted:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
// Assign material to object
materialGroup
.command('assign')
.description('Assign material to object')
.requiredOption('--object <name>', 'Object name')
.requiredOption('--material <name>', 'Material name')
.option('--slot <index>', 'Material slot index', '0')
.action(async (options) => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Material.assign', {
objectName: options.object,
materialName: options.material,
slotIndex: parseInt(options.slot)
});
console.log('✅ Material assigned:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
// List object materials
materialGroup
.command('list-object')
.description('List materials of an object')
.requiredOption('--object <name>', 'Object name')
.action(async (options) => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Material.listObjectMaterials', {
objectName: options.object
});
console.log('📋 Object materials:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
// Set base color
materialGroup
.command('set-color')
.description('Set material base color')
.requiredOption('--material <name>', 'Material name')
.requiredOption('--r <value>', 'Red (0-1)', parseFloat)
.requiredOption('--g <value>', 'Green (0-1)', parseFloat)
.requiredOption('--b <value>', 'Blue (0-1)', parseFloat)
.option('--a <value>', 'Alpha (0-1)', parseFloat, 1.0)
.action(async (options) => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Material.setBaseColor', {
materialName: options.material,
color: [options.r, options.g, options.b, options.a]
});
console.log('✅ Base color set:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
// Set metallic
materialGroup
.command('set-metallic')
.description('Set material metallic value')
.requiredOption('--material <name>', 'Material name')
.requiredOption('--value <value>', 'Metallic value (0-1)', parseFloat)
.action(async (options) => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Material.setMetallic', {
materialName: options.material,
metallic: options.value
});
console.log('✅ Metallic set:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
// Set roughness
materialGroup
.command('set-roughness')
.description('Set material roughness value')
.requiredOption('--material <name>', 'Material name')
.requiredOption('--value <value>', 'Roughness value (0-1)', parseFloat)
.action(async (options) => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Material.setRoughness', {
materialName: options.material,
roughness: options.value
});
console.log('✅ Roughness set:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
// Set emission
materialGroup
.command('set-emission')
.description('Set material emission')
.requiredOption('--material <name>', 'Material name')
.requiredOption('--r <value>', 'Red (0-1)', parseFloat)
.requiredOption('--g <value>', 'Green (0-1)', parseFloat)
.requiredOption('--b <value>', 'Blue (0-1)', parseFloat)
.option('--strength <value>', 'Emission strength', parseFloat, 1.0)
.action(async (options) => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Material.setEmission', {
materialName: options.material,
color: [options.r, options.g, options.b, 1.0],
strength: options.strength
});
console.log('✅ Emission set:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
// Get material properties
materialGroup
.command('get-properties')
.description('Get material properties')
.requiredOption('--material <name>', 'Material name')
.action(async (options) => {
const client = new BlenderClient();
try {
await client.connect();
const result = await client.sendCommand('Material.getProperties', {
materialName: options.material
});
console.log('📋 Material properties:', JSON.stringify(result, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
} finally {
client.close();
}
});
}

View File

@@ -0,0 +1,278 @@
/**
* Modifier Commands
* Blender 모디파이어 명령
*/
import { Command } from 'commander';
import { BlenderClient } from '../../blender/client';
import { logger } from '../../utils/logger';
const client = new BlenderClient();
export function registerModifierCommands(program: Command) {
// Add Modifier
program
.command('add-modifier')
.description('Add a modifier to an object')
.requiredOption('-n, --name <string>', 'Object name')
.requiredOption('-t, --type <string>', 'Modifier type (SUBSURF, MIRROR, ARRAY, BEVEL, etc.)')
.option('--mod-name <string>', 'Modifier name')
.option('--levels <number>', 'Subdivision levels (for SUBSURF)', parseInt)
.option('--render-levels <number>', 'Render levels (for SUBSURF)', parseInt)
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const properties: any = {};
if (options.levels !== undefined) {
properties.levels = options.levels;
}
if (options.renderLevels !== undefined) {
properties.render_levels = options.renderLevels;
}
const result: any = await client.sendCommand('Modifier.add', {
objectName: options.name,
modifierType: options.type,
name: options.modName,
properties
});
console.log('✅ Modifier added successfully:');
console.log(` Object: ${result.object}`);
console.log(` Modifier: ${result.modifier} (${result.type})`);
await client.disconnect();
} catch (error) {
logger.error('Failed to add modifier:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Apply Modifier
program
.command('apply-modifier')
.description('Apply a modifier to an object')
.requiredOption('-n, --name <string>', 'Object name')
.requiredOption('-m, --modifier <string>', 'Modifier name')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const result: any = await client.sendCommand('Modifier.apply', {
objectName: options.name,
modifierName: options.modifier
});
console.log(`${result.message}`);
await client.disconnect();
} catch (error) {
logger.error('Failed to apply modifier:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// List Modifiers
program
.command('list-modifiers')
.description('List all modifiers on an object')
.requiredOption('-n, --name <string>', 'Object name')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const result: any = await client.sendCommand('Modifier.list', {
objectName: options.name
});
console.log('📋 Modifiers:');
if (result.length === 0) {
console.log(' No modifiers found');
} else {
result.forEach((mod: any) => {
console.log(` - ${mod.name} (${mod.type})`);
console.log(` Viewport: ${mod.show_viewport}, Render: ${mod.show_render}`);
if (mod.levels !== undefined) {
console.log(` Levels: ${mod.levels}, Render Levels: ${mod.render_levels}`);
}
});
}
await client.disconnect();
} catch (error) {
logger.error('Failed to list modifiers:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Remove Modifier
program
.command('remove-modifier')
.description('Remove a modifier from an object')
.requiredOption('-n, --name <string>', 'Object name')
.requiredOption('-m, --modifier <string>', 'Modifier name')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const result: any = await client.sendCommand('Modifier.remove', {
objectName: options.name,
modifierName: options.modifier
});
console.log(`${result.message}`);
await client.disconnect();
} catch (error) {
logger.error('Failed to remove modifier:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Toggle Modifier
program
.command('toggle-modifier')
.description('Toggle modifier visibility')
.requiredOption('-n, --name <string>', 'Object name')
.requiredOption('-m, --modifier <string>', 'Modifier name')
.option('--viewport <boolean>', 'Viewport visibility (true/false)')
.option('--render <boolean>', 'Render visibility (true/false)')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const params: any = {
objectName: options.name,
modifierName: options.modifier
};
if (options.viewport !== undefined) {
params.viewport = options.viewport === 'true';
}
if (options.render !== undefined) {
params.render = options.render === 'true';
}
const result: any = await client.sendCommand('Modifier.toggle', params);
console.log('✅ Modifier toggled:');
console.log(` Viewport: ${result.show_viewport}`);
console.log(` Render: ${result.show_render}`);
await client.disconnect();
} catch (error) {
logger.error('Failed to toggle modifier:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Modify Modifier Properties
program
.command('modify-modifier')
.description('Modify modifier properties')
.requiredOption('-n, --name <string>', 'Object name')
.requiredOption('-m, --modifier <string>', 'Modifier name')
.option('--levels <number>', 'Subdivision levels', parseInt)
.option('--render-levels <number>', 'Render levels', parseInt)
.option('--width <number>', 'Bevel width', parseFloat)
.option('--segments <number>', 'Bevel segments', parseInt)
.option('--count <number>', 'Array count', parseInt)
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const properties: any = {};
if (options.levels !== undefined) properties.levels = options.levels;
if (options.renderLevels !== undefined) properties.render_levels = options.renderLevels;
if (options.width !== undefined) properties.width = options.width;
if (options.segments !== undefined) properties.segments = options.segments;
if (options.count !== undefined) properties.count = options.count;
const result: any = await client.sendCommand('Modifier.modify', {
objectName: options.name,
modifierName: options.modifier,
properties
});
console.log('✅ Modifier properties updated:');
console.log(` Updated properties: ${result.updated_properties ? Object.keys(result.updated_properties).join(', ') : 'none'}`);
await client.disconnect();
} catch (error) {
logger.error('Failed to modify modifier properties:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Get Modifier Info
program
.command('get-modifier-info')
.description('Get detailed modifier information')
.requiredOption('-n, --name <string>', 'Object name')
.requiredOption('-m, --modifier <string>', 'Modifier name')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const result: any = await client.sendCommand('Modifier.getInfo', {
objectName: options.name,
modifierName: options.modifier
});
console.log('📋 Modifier Info:');
console.log(JSON.stringify(result, null, 2));
await client.disconnect();
} catch (error) {
logger.error('Failed to get modifier info:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Reorder Modifier
program
.command('reorder-modifier')
.description('Reorder modifier in stack')
.requiredOption('-n, --name <string>', 'Object name')
.requiredOption('-m, --modifier <string>', 'Modifier name')
.requiredOption('-d, --direction <string>', 'Direction (UP or DOWN)')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const result: any = await client.sendCommand('Modifier.reorder', {
objectName: options.name,
modifierName: options.modifier,
direction: options.direction.toUpperCase()
});
console.log(`✅ Modifier reordered`);
console.log(` New order: ${result.new_order.join(' > ')}`);
await client.disconnect();
} catch (error) {
logger.error('Failed to reorder modifier:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,176 @@
/**
* Object Commands
* Blender 오브젝트 조작 명령
*/
import { Command } from 'commander';
import { BlenderClient } from '../../blender/client';
import { logger } from '../../utils/logger';
const client = new BlenderClient();
export function registerObjectCommands(program: Command) {
// List Objects
program
.command('list-objects')
.description('List all objects in the scene')
.option('-t, --type <string>', 'Filter by object type (MESH, ARMATURE, CAMERA, LIGHT)')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const objects: any = await client.sendCommand('Object.list', {
type: options.type
});
if (objects.length === 0) {
console.log('No objects found in the scene.');
} else {
console.log(`✅ Found ${objects.length} object(s):\n`);
objects.forEach((obj: any) => {
console.log(`📦 ${obj.name} (${obj.type})`);
console.log(` Location: [${obj.location.map((n: number) => n.toFixed(2)).join(', ')}]`);
console.log(` Rotation: [${obj.rotation.map((n: number) => n.toFixed(2)).join(', ')}]`);
console.log(` Scale: [${obj.scale.map((n: number) => n.toFixed(2)).join(', ')}]`);
console.log('');
});
}
await client.disconnect();
} catch (error) {
logger.error('Failed to list objects:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Transform Object
program
.command('transform')
.description('Transform an object (move, rotate, scale)')
.requiredOption('-n, --name <string>', 'Object name')
.option('--loc-x <number>', 'X location', parseFloat)
.option('--loc-y <number>', 'Y location', parseFloat)
.option('--loc-z <number>', 'Z location', parseFloat)
.option('--rot-x <number>', 'X rotation (radians)', parseFloat)
.option('--rot-y <number>', 'Y rotation (radians)', parseFloat)
.option('--rot-z <number>', 'Z rotation (radians)', parseFloat)
.option('--scale-x <number>', 'X scale', parseFloat)
.option('--scale-y <number>', 'Y scale', parseFloat)
.option('--scale-z <number>', 'Z scale', parseFloat)
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const params: any = { name: options.name };
if (options.locX !== undefined || options.locY !== undefined || options.locZ !== undefined) {
params.location = [
options.locX ?? 0,
options.locY ?? 0,
options.locZ ?? 0
];
}
if (options.rotX !== undefined || options.rotY !== undefined || options.rotZ !== undefined) {
params.rotation = [
options.rotX ?? 0,
options.rotY ?? 0,
options.rotZ ?? 0
];
}
if (options.scaleX !== undefined || options.scaleY !== undefined || options.scaleZ !== undefined) {
params.scale = [
options.scaleX ?? 1,
options.scaleY ?? 1,
options.scaleZ ?? 1
];
}
const result: any = await client.sendCommand('Object.transform', params);
console.log('✅ Object transformed successfully:');
console.log(` Name: ${result.name}`);
console.log(` Location: [${result.location.map((n: number) => n.toFixed(3)).join(', ')}]`);
console.log(` Rotation: [${result.rotation.map((n: number) => n.toFixed(3)).join(', ')}]`);
console.log(` Scale: [${result.scale.map((n: number) => n.toFixed(3)).join(', ')}]`);
await client.disconnect();
} catch (error) {
logger.error('Failed to transform object:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Duplicate Object
program
.command('duplicate')
.description('Duplicate an object')
.requiredOption('-n, --name <string>', 'Source object name')
.option('--new-name <string>', 'New object name')
.option('-x, --x <number>', 'X position for duplicate', parseFloat)
.option('-y, --y <number>', 'Y position for duplicate', parseFloat)
.option('-z, --z <number>', 'Z position for duplicate', parseFloat)
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const params: any = { name: options.name };
if (options.newName) {
params.newName = options.newName;
}
if (options.x !== undefined || options.y !== undefined || options.z !== undefined) {
params.location = [
options.x ?? 0,
options.y ?? 0,
options.z ?? 0
];
}
const result: any = await client.sendCommand('Object.duplicate', params);
console.log('✅ Object duplicated successfully:');
console.log(` New Name: ${result.name}`);
console.log(` Type: ${result.type}`);
console.log(` Location: [${result.location.map((n: number) => n.toFixed(3)).join(', ')}]`);
await client.disconnect();
} catch (error) {
logger.error('Failed to duplicate object:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Delete Object
program
.command('delete')
.description('Delete an object')
.requiredOption('-n, --name <string>', 'Object name')
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.action(async (options) => {
try {
await client.connect(options.port);
const result: any = await client.sendCommand('Object.delete', {
name: options.name
});
console.log(`${result.message}`);
await client.disconnect();
} catch (error) {
logger.error('Failed to delete object:', error);
console.error('❌ Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,77 @@
/**
* Retargeting Commands
* Blender 애니메이션 리타게팅 명령
*/
import { Command } from 'commander';
import { AnimationRetargetingWorkflow } from '../../index';
import { logger } from '../../utils/logger';
export function registerRetargetingCommands(program: Command) {
// Retarget Animation
program
.command('retarget')
.description('Retarget animation from Mixamo to your character')
.requiredOption('-t, --target <string>', 'Target character armature name')
.requiredOption('-f, --file <string>', 'Animation file path (FBX or DAE)')
.option('-n, --name <string>', 'Animation name for NLA track')
.option('-m, --mapping <string>', 'Bone mapping mode (auto, mixamo_to_rigify, custom)', 'auto')
.option('--skip-confirmation', 'Skip bone mapping confirmation', false)
.option('-p, --port <number>', 'Blender WebSocket port', parseInt, 9400)
.option('-o, --output <string>', 'Output directory')
.action(async (options) => {
try {
const workflow = new AnimationRetargetingWorkflow();
console.log('🎬 Starting animation retargeting workflow...\n');
await workflow.run({
blenderPort: options.port,
targetCharacterArmature: options.target,
animationFilePath: options.file,
animationName: options.name,
boneMapping: options.mapping,
skipConfirmation: options.skipConfirmation,
outputDir: options.output
});
console.log('\n✅ Animation retargeting completed successfully!');
} catch (error) {
logger.error('Retargeting failed:', error);
console.error('\n❌ Retargeting failed:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Show Mixamo download instructions
program
.command('mixamo-help')
.description('Show Mixamo download instructions and popular animations')
.argument('[animation-name]', 'Animation name (optional)')
.action((animationName) => {
const workflow = new AnimationRetargetingWorkflow();
if (animationName) {
console.log(workflow.getManualDownloadInstructions(animationName));
} else {
console.log('📚 Popular Mixamo Animations:\n');
const popularAnimations = workflow.getPopularAnimations();
Object.entries(popularAnimations).forEach(([category, animations]) => {
console.log(`\n${category}:`);
(animations as unknown as string[]).forEach((anim) => {
console.log(`${anim}`);
});
});
console.log('\n\n📥 Download Instructions:\n');
console.log(workflow.getManualDownloadInstructions('Walking'));
}
console.log('\n⚙ Recommended Settings:\n');
const settings = workflow.getRecommendedSettings();
Object.entries(settings).forEach(([key, value]) => {
console.log(` ${key}: ${value}`);
});
});
}

View File

@@ -0,0 +1,135 @@
/**
* Blender Toolkit Constants
* 모든 매직 넘버, 포트, 타이밍 등을 중앙에서 관리
*/
/**
* Blender WebSocket 관련 상수
* @property DEFAULT_PORT - 기본 WebSocket 포트 (9400, Browser-Pilot과 충돌 방지)
* @property PORT_RANGE_MAX - 포트 검색 범위 (100)
* @property LOCALHOST - 로컬 호스트 주소
* @property WS_TIMEOUT - WebSocket 연결 타임아웃 (30초)
*/
export const BLENDER = {
DEFAULT_PORT: 9400,
PORT_RANGE_MAX: 100,
LOCALHOST: '127.0.0.1',
WS_TIMEOUT: 30000, // 30 seconds
} as const;
/**
* 파일 시스템 관련 상수
* @property OUTPUT_DIR - 출력 디렉토리 (.blender-toolkit)
* @property ANIMATIONS_DIR - 애니메이션 다운로드 디렉토리
* @property CONFIG_FILE - 설정 파일명
* @property DAEMON_PID_FILE - 데몬 PID 파일명
* @property GITIGNORE_CONTENT - .gitignore 기본 내용
*/
export const FS = {
OUTPUT_DIR: '.blender-toolkit',
ANIMATIONS_DIR: 'animations',
MODELS_DIR: 'models',
CONFIG_FILE: 'blender-config.json',
DAEMON_PID_FILE: 'daemon.pid',
GITIGNORE_CONTENT: `# Blender Toolkit generated files
*
`,
} as const;
/**
* Mixamo 관련 상수
* Note: Mixamo does not provide an official API. Users must manually download files from Mixamo.com
* @property WEBSITE_URL - Mixamo 웹사이트 URL
* @property SUPPORTED_FORMATS - 지원 파일 포맷
* @property RECOMMENDED_FORMAT - 권장 다운로드 포맷
* @property RECOMMENDED_SKIN - 권장 스킨 설정 (리타게팅용)
* @property RECOMMENDED_FPS - 권장 FPS
*/
export const MIXAMO = {
WEBSITE_URL: 'https://www.mixamo.com',
SUPPORTED_FORMATS: ['fbx', 'dae'] as const,
RECOMMENDED_FORMAT: 'fbx' as const,
RECOMMENDED_SKIN: 'Without Skin', // Better for retargeting
RECOMMENDED_FPS: 30,
} as const;
/**
* 리타게팅 관련 상수
*/
export const RETARGETING = {
BONE_MAPPING_PRESETS: {
MIXAMO_TO_RIGIFY: 'mixamo_to_rigify',
MIXAMO_TO_CUSTOM: 'mixamo_to_custom',
AUTO_DETECT: 'auto_detect',
},
CONSTRAINT_TYPES: ['COPY_ROTATION', 'COPY_LOCATION'] as const,
} as const;
/**
* 타이밍 관련 상수 (모든 시간 단위는 밀리초)
*/
export const TIMING = {
DEFAULT_TIMEOUT: 30000, // 30 seconds
IMPORT_TIMEOUT: 60000, // 1 minute
RETARGET_TIMEOUT: 120000, // 2 minutes
RENDER_TIMEOUT: 300000, // 5 minutes
POLLING_INTERVAL: 1000, // 1 second
DAEMON_IDLE_TIMEOUT: 1800000, // 30 minutes
DAEMON_PING_INTERVAL: 5000, // 5 seconds
HOOK_INPUT_TIMEOUT: 100, // 100ms for reading stdin
ACTION_DELAY_SHORT: 50, // 50ms
ACTION_DELAY_MEDIUM: 100, // 100ms
ACTION_DELAY_LONG: 500, // 500ms
POLLING_INTERVAL_FAST: 100, // 100ms
POLLING_INTERVAL_STANDARD: 500, // 500ms
POLLING_INTERVAL_SLOW: 1000, // 1s
WAIT_FOR_BLENDER: 5000, // 5s - wait for Blender connection
} as const;
/**
* Daemon 관련 상수
*/
export const DAEMON = {
IPC_TIMEOUT: 5000, // 5 seconds
MAX_RETRIES: 3,
RETRY_DELAY: 1000, // 1 second
IDLE_CHECK_INTERVAL: 60000, // 1 minute
MAX_MESSAGE_SIZE: 10 * 1024 * 1024, // 10MB - Browser Pilot 패턴
CONNECT_TIMEOUT: 5000, // 5 seconds
SHUTDOWN_TIMEOUT: 5000, // 5 seconds for graceful shutdown
} as const;
/**
* 환경 변수 이름 상수
*/
export const ENV = {
BLENDER_WS_PORT: 'BLENDER_WS_PORT',
BLENDER_EXECUTABLE: 'BLENDER_EXECUTABLE',
CLAUDE_PROJECT_DIR: 'CLAUDE_PROJECT_DIR',
} as const;
/**
* 에러 메시지
*/
export const ERROR_MESSAGES = {
BLENDER_NOT_RUNNING: 'Blender is not running or WebSocket server is not started',
CONNECTION_FAILED: 'Failed to connect to Blender',
TIMEOUT: 'Operation timed out',
IMPORT_FAILED: 'Failed to import animation',
RETARGET_FAILED: 'Failed to retarget animation',
NO_CHARACTER_SELECTED: 'No character selected',
ANIMATION_FILE_NOT_FOUND: 'Animation file not found. Please download from Mixamo.com first',
INVALID_BONE_MAPPING: 'Invalid bone mapping',
BONE_MAPPING_CONFIRMATION_FAILED: 'Bone mapping confirmation failed',
} as const;
/**
* 성공 메시지
*/
export const SUCCESS_MESSAGES = {
CONNECTED: 'Connected to Blender',
ANIMATION_IMPORTED: 'Animation imported successfully',
BONE_MAPPING_GENERATED: 'Bone mapping generated successfully',
BONE_MAPPING_SENT_TO_UI: 'Bone mapping sent to Blender UI for review',
RETARGETING_COMPLETE: 'Animation retargeted successfully',
} as const;

View File

@@ -0,0 +1,209 @@
/**
* IPC Client for Blender Toolkit Daemon
* Used by CLI commands to communicate with the daemon
*/
import { Socket, connect } from 'net';
import { join } from 'path';
import { existsSync } from 'fs';
import { randomUUID } from 'crypto';
import { getOutputDir } from '../blender/config';
import {
IPCRequest,
IPCResponse,
SOCKET_PATH_PREFIX,
getProjectSocketName
} from './protocol';
import { DAEMON } from '../constants';
import { logger } from '../utils/logger';
export class IPCClient {
private socket: Socket | null = null;
private socketPath: string;
private pendingRequests: Map<string, {
resolve: (response: IPCResponse) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}> = new Map();
private buffer: string = '';
constructor() {
const outputDir = getOutputDir();
this.socketPath = this.getSocketPath(outputDir);
}
/**
* Get socket path (platform-specific, project-unique)
*/
private getSocketPath(outputDir: string): string {
if (process.platform === 'win32') {
// Windows: project-specific named pipe
const socketName = getProjectSocketName();
return `\\\\.\\pipe\\${socketName}`;
} else {
// Unix domain socket (already project-specific via outputDir)
return join(outputDir, `${SOCKET_PATH_PREFIX}.sock`);
}
}
/**
* Connect to daemon
*/
async connect(): Promise<void> {
if (this.socket && !this.socket.destroyed) {
return; // Already connected
}
// Check if socket file exists (Unix only)
if (process.platform !== 'win32' && !existsSync(this.socketPath)) {
throw new Error('Daemon not running (socket file not found)');
}
return new Promise((resolve, reject) => {
// Browser Pilot 패턴: 연결 타임아웃
const timeout = setTimeout(() => {
this.socket?.destroy();
reject(new Error(`Connection timeout after ${DAEMON.CONNECT_TIMEOUT}ms`));
}, DAEMON.CONNECT_TIMEOUT);
this.socket = connect(this.socketPath);
this.socket.on('connect', () => {
clearTimeout(timeout);
this.setupSocket();
resolve();
});
this.socket.on('error', (error) => {
clearTimeout(timeout);
reject(new Error(`Connection failed: ${error.message}`));
});
});
}
/**
* Setup socket event handlers
*/
private setupSocket(): void {
if (!this.socket) return;
this.socket.on('data', (data) => {
this.buffer += data.toString();
// Browser Pilot 패턴: 메시지 크기 제한 (DoS 방지)
if (this.buffer.length > DAEMON.MAX_MESSAGE_SIZE) {
logger.error(`Message size exceeded limit: ${this.buffer.length} bytes`);
this.socket?.destroy();
this.rejectAllPending(new Error('Message size exceeded limit'));
return;
}
// Process complete JSON messages (delimited by newline)
const messages = this.buffer.split('\n');
this.buffer = messages.pop() || ''; // Keep incomplete message in buffer
for (const message of messages) {
if (!message.trim()) continue;
try {
const response: IPCResponse = JSON.parse(message);
this.handleResponse(response);
} catch (error) {
logger.error('Failed to parse response', error);
}
}
});
this.socket.on('error', (error) => {
logger.error('Socket error', error);
this.rejectAllPending(new Error(`Socket error: ${error.message}`));
// Browser Pilot 패턴: 리소스 정리
this.buffer = '';
this.socket = null;
});
this.socket.on('close', () => {
// Browser Pilot 패턴: 리소스 정리
this.buffer = '';
this.socket = null;
this.rejectAllPending(new Error('Connection closed'));
});
}
/**
* Handle response from daemon
*/
private handleResponse(response: IPCResponse): void {
const pending = this.pendingRequests.get(response.id);
if (!pending) {
logger.warn(`Received response for unknown request: ${response.id}`);
return;
}
clearTimeout(pending.timeout);
this.pendingRequests.delete(response.id);
if (response.success) {
pending.resolve(response);
} else {
pending.reject(new Error(response.error || 'Command failed'));
}
}
/**
* Reject all pending requests
*/
private rejectAllPending(error: Error): void {
for (const [_id, pending] of this.pendingRequests.entries()) {
clearTimeout(pending.timeout);
pending.reject(error);
}
this.pendingRequests.clear();
}
/**
* Send request to daemon
*/
async sendRequest(command: string, params: Record<string, unknown> = {}, timeout: number = DAEMON.IPC_TIMEOUT): Promise<IPCResponse> {
await this.connect();
if (!this.socket) {
throw new Error('Not connected to daemon');
}
const request: IPCRequest = {
id: randomUUID(),
command,
params,
timeout
};
return new Promise((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
this.pendingRequests.delete(request.id);
reject(new Error(`Request timeout after ${timeout}ms`));
}, timeout);
this.pendingRequests.set(request.id, {
resolve,
reject,
timeout: timeoutHandle
});
// Send request (newline-delimited JSON)
this.socket!.write(JSON.stringify(request) + '\n');
});
}
/**
* Close connection
*/
close(): void {
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
this.rejectAllPending(new Error('Client closed'));
}
}

View File

@@ -0,0 +1,275 @@
/**
* Daemon Process Manager
* Handles starting, stopping, and checking status of the Blender Toolkit Daemon
*/
import { spawn } from 'child_process';
import { join } from 'path';
import { existsSync, readFileSync, unlinkSync } from 'fs';
import { getOutputDir } from '../blender/config';
import { IPCClient } from './client';
import { PID_FILENAME, DaemonState, DAEMON_COMMANDS } from './protocol';
import { DAEMON, TIMING } from '../constants';
import { logger } from '../utils/logger';
export class DaemonManager {
private outputDir: string;
private pidPath: string;
constructor() {
this.outputDir = getOutputDir();
this.pidPath = join(this.outputDir, PID_FILENAME);
}
/**
* Start daemon process
*/
async start(options: { verbose?: boolean } = {}): Promise<void> {
const { verbose = true } = options;
// Check if already running
if (await this.isRunning()) {
if (verbose) {
console.log(' Daemon is already running');
}
return;
}
if (verbose) {
console.log('=<3D> Starting Blender Toolkit Daemon...');
}
// Get path to server.js (compiled output)
const serverPath = join(__dirname, 'server.js');
if (!existsSync(serverPath)) {
throw new Error(`Daemon server not found at ${serverPath}. Did you run 'npm run build'?`);
}
// Spawn daemon as detached process
const daemon = spawn(process.execPath, [serverPath], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
env: process.env
});
// Detach the process so it continues running when parent exits
daemon.unref();
// Wait for daemon to start
await this.waitForDaemon();
if (verbose) {
console.log(' Daemon started successfully');
}
}
/**
* Wait for daemon to be ready
*/
private async waitForDaemon(): Promise<void> {
const maxAttempts = 10;
const delay = 500; // 500ms
for (let i = 0; i < maxAttempts; i++) {
await new Promise(resolve => setTimeout(resolve, delay));
if (await this.isRunning()) {
return;
}
}
throw new Error('Daemon failed to start');
}
/**
* Stop daemon process
*/
async stop(options: { verbose?: boolean; force?: boolean } = {}): Promise<void> {
const { verbose = true, force = false } = options;
if (!(await this.isRunning())) {
if (verbose) {
console.log('Daemon is not running');
}
return;
}
if (verbose) {
console.log('=<3D> Stopping Blender Toolkit Daemon...');
}
if (force) {
// Force kill via PID
await this.forceKill();
} else {
// Graceful shutdown via IPC
try {
const client = new IPCClient();
await client.sendRequest(DAEMON_COMMANDS.SHUTDOWN, {});
client.close();
// Wait for shutdown
await this.waitForShutdown();
} catch (error) {
if (verbose) {
console.log('<27> Graceful shutdown failed, force killing...');
}
await this.forceKill();
}
}
if (verbose) {
console.log(' Daemon stopped');
}
}
/**
* Force kill daemon process
*/
private async forceKill(): Promise<void> {
if (!existsSync(this.pidPath)) {
return;
}
try {
const pidStr = readFileSync(this.pidPath, 'utf-8').trim();
const pid = parseInt(pidStr, 10);
if (isNaN(pid) || pid <= 0) {
logger.warn(`Invalid PID in ${this.pidPath}: ${pidStr}`);
unlinkSync(this.pidPath);
return;
}
// Kill process
try {
process.kill(pid, 'SIGTERM');
await new Promise(resolve => setTimeout(resolve, 1000));
// If still running, force kill
if (this.isProcessRunning(pid)) {
process.kill(pid, 'SIGKILL');
}
} catch (error) {
// Process might already be dead
}
// Remove PID file
if (existsSync(this.pidPath)) {
unlinkSync(this.pidPath);
}
} catch (error) {
logger.error('Force kill failed:', error);
}
}
/**
* Wait for daemon to shutdown
*/
private async waitForShutdown(): Promise<void> {
const maxAttempts = 10;
const delay = 500; // 500ms
for (let i = 0; i < maxAttempts; i++) {
await new Promise(resolve => setTimeout(resolve, delay));
if (!(await this.isRunning())) {
return;
}
}
throw new Error('Daemon failed to shutdown gracefully');
}
/**
* Restart daemon
*/
async restart(options: { verbose?: boolean } = {}): Promise<void> {
const { verbose = true } = options;
if (verbose) {
console.log('= Restarting Blender Toolkit Daemon...');
}
await this.stop({ verbose: false });
await this.start({ verbose: false });
if (verbose) {
console.log(' Daemon restarted');
}
}
/**
* Get daemon status
*/
async getStatus(options: { verbose?: boolean } = {}): Promise<DaemonState | null> {
const { verbose = true } = options;
if (!(await this.isRunning())) {
if (verbose) {
console.log('Daemon is not running');
}
return null;
}
try {
const client = new IPCClient();
const response = await client.sendRequest(DAEMON_COMMANDS.GET_STATUS, {});
client.close();
const state = response.data as DaemonState;
if (verbose) {
console.log('Daemon Status:');
console.log(` Connected to Blender: ${state.connected ? 'Yes' : 'No'}`);
console.log(` Blender Port: ${state.port}`);
console.log(` Uptime: ${Math.floor(state.uptime / 1000)}s`);
console.log(` Last Activity: ${Math.floor((Date.now() - state.lastActivity) / 1000)}s ago`);
}
return state;
} catch (error) {
if (verbose) {
console.error('Failed to get status:', error);
}
return null;
}
}
/**
* Check if daemon is running
*/
async isRunning(): Promise<boolean> {
if (!existsSync(this.pidPath)) {
return false;
}
try {
const pidStr = readFileSync(this.pidPath, 'utf-8').trim();
const pid = parseInt(pidStr, 10);
if (isNaN(pid) || pid <= 0) {
return false;
}
return this.isProcessRunning(pid);
} catch (error) {
return false;
}
}
/**
* Check if process is running by PID
*/
private isProcessRunning(pid: number): boolean {
try {
// Signal 0 checks if process exists without killing it
process.kill(pid, 0);
return true;
} catch (error) {
return false;
}
}
}

View File

@@ -0,0 +1,78 @@
/**
* IPC Protocol definitions for Blender Toolkit Daemon
*/
import { createHash } from 'crypto';
import { basename } from 'path';
/**
* IPC Request from CLI to Daemon
*/
export interface IPCRequest {
id: string;
command: string;
params: Record<string, unknown>;
timeout?: number;
}
/**
* IPC Response from Daemon to CLI
*/
export interface IPCResponse {
id: string;
success: boolean;
data?: unknown;
error?: string;
}
/**
* Daemon state information
*/
export interface DaemonState {
connected: boolean;
port: number | null;
host: string;
uptime: number;
lastActivity: number;
blenderVersion?: string;
}
/**
* File names and paths
*/
export const PID_FILENAME = 'daemon.pid';
export const SOCKET_PATH_PREFIX = 'daemon';
/**
* Get project-specific socket name for daemon IPC
* Same logic as browser-pilot
*/
export function getProjectSocketName(projectRoot?: string): string {
const root = projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
const projectName = basename(root)
.replace(/[^a-zA-Z0-9_-]/g, '-')
.toLowerCase();
// Add hash of full path to prevent collision
const hash = createHash('sha256')
.update(root)
.digest('hex')
.substring(0, 8);
return `${SOCKET_PATH_PREFIX}-${projectName}-${hash}`;
}
/**
* Daemon commands
*/
export const DAEMON_COMMANDS = {
// Status commands
PING: 'ping',
GET_STATUS: 'get-status',
SHUTDOWN: 'shutdown',
// Blender commands (pass-through to Blender WebSocket)
BLENDER_COMMAND: 'blender-command',
} as const;
export type DaemonCommand = typeof DAEMON_COMMANDS[keyof typeof DAEMON_COMMANDS];

View File

@@ -0,0 +1,353 @@
/**
* Blender Toolkit Daemon Server
* Detached background process that maintains connection to Blender WebSocket
* and provides IPC interface for CLI commands
*/
import { Server as NetServer, Socket as NetSocket, createServer } from 'net';
import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { BlenderClient } from '../blender/client';
import { getOutputDir, getProjectConfig } from '../blender/config';
import {
IPCRequest,
IPCResponse,
DaemonState,
DAEMON_COMMANDS,
PID_FILENAME,
SOCKET_PATH_PREFIX,
getProjectSocketName
} from './protocol';
import { DAEMON } from '../constants';
import { logger } from '../utils/logger';
class DaemonServer {
private ipcServer: NetServer | null = null;
private blenderClient: BlenderClient;
private socketPath: string;
private pidPath: string;
private startTime: number;
private lastActivity: number;
private blenderPort: number = 9400;
private shutdownRequested: boolean = false;
// Browser Pilot 패턴: 활성 연결 추적
private activeSockets: Set<NetSocket> = new Set();
// Browser Pilot 패턴: shutdown Promise (race condition 방지)
private shutdownPromise: Promise<void> | null = null;
constructor() {
const outputDir = getOutputDir();
this.socketPath = this.getSocketPath(outputDir);
this.pidPath = join(outputDir, PID_FILENAME);
this.blenderClient = new BlenderClient();
this.startTime = Date.now();
this.lastActivity = Date.now();
}
/**
* Get socket path (platform-specific)
*/
private getSocketPath(outputDir: string): string {
if (process.platform === 'win32') {
const socketName = getProjectSocketName();
return `\\\\.\\pipe\\${socketName}`;
} else {
return join(outputDir, `${SOCKET_PATH_PREFIX}.sock`);
}
}
/**
* Start daemon server
*/
async start(): Promise<void> {
try {
// Get project config for Blender port
const config = await getProjectConfig();
this.blenderPort = config.port;
logger.info(`Starting Blender Toolkit Daemon on port ${this.blenderPort}`);
// Write PID file
writeFileSync(this.pidPath, String(process.pid), 'utf-8');
logger.info(`PID file written: ${this.pidPath}`);
// Start IPC server
await this.startIPCServer();
// Setup shutdown handlers
this.setupShutdownHandlers();
logger.info(' Daemon started successfully');
console.log(`Blender Toolkit Daemon started (PID: ${process.pid})`);
} catch (error) {
logger.error('Failed to start daemon:', error);
process.exit(1);
}
}
/**
* Start IPC server for CLI communication
*/
private async startIPCServer(): Promise<void> {
return new Promise((resolve, reject) => {
// Remove existing socket file (Unix only)
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
unlinkSync(this.socketPath);
}
this.ipcServer = createServer((socket: NetSocket) => {
this.handleIPCConnection(socket);
});
this.ipcServer.on('error', (error) => {
logger.error('IPC server error:', error);
reject(error);
});
this.ipcServer.listen(this.socketPath, () => {
logger.info(`IPC server listening on ${this.socketPath}`);
resolve();
});
});
}
/**
* Handle IPC connection from CLI
*/
private handleIPCConnection(socket: NetSocket): void {
logger.info('CLI client connected');
// Browser Pilot 패턴: 활성 소켓 추적
this.activeSockets.add(socket);
let buffer = '';
socket.on('data', async (data) => {
buffer += data.toString();
// Browser Pilot 패턴: 메시지 크기 제한 (DoS 방지)
if (buffer.length > DAEMON.MAX_MESSAGE_SIZE) {
logger.error(`Message size exceeded limit: ${buffer.length} bytes`);
socket.destroy();
return;
}
// Process newline-delimited JSON
const messages = buffer.split('\n');
buffer = messages.pop() || '';
for (const message of messages) {
if (!message.trim()) continue;
try {
const request: IPCRequest = JSON.parse(message);
const response = await this.handleIPCRequest(request);
socket.write(JSON.stringify(response) + '\n');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Failed to handle IPC request:', errorMessage);
}
}
});
socket.on('error', (error) => {
logger.warn('IPC socket error:', error);
// Browser Pilot 패턴: 활성 소켓에서 제거
this.activeSockets.delete(socket);
});
socket.on('close', () => {
logger.info('CLI client disconnected');
// Browser Pilot 패턴: 활성 소켓에서 제거
this.activeSockets.delete(socket);
});
}
/**
* Handle IPC request from CLI
*/
private async handleIPCRequest(request: IPCRequest): Promise<IPCResponse> {
this.lastActivity = Date.now();
try {
logger.info(`Handling command: ${request.command}`);
switch (request.command) {
case DAEMON_COMMANDS.PING:
return { id: request.id, success: true, data: { status: 'alive' } };
case DAEMON_COMMANDS.GET_STATUS:
return { id: request.id, success: true, data: this.getStatus() };
case DAEMON_COMMANDS.SHUTDOWN:
this.shutdown();
return { id: request.id, success: true, data: { message: 'Shutting down' } };
case DAEMON_COMMANDS.BLENDER_COMMAND:
// Forward command to Blender WebSocket
const result = await this.forwardToBlender(request.params);
return { id: request.id, success: true, data: result };
default:
return {
id: request.id,
success: false,
error: `Unknown command: ${request.command}`
};
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Command failed: ${errorMessage}`);
return {
id: request.id,
success: false,
error: errorMessage
};
}
}
/**
* Forward command to Blender WebSocket
*/
private async forwardToBlender(params: Record<string, unknown>): Promise<unknown> {
try {
// Connect to Blender if not connected
if (!this.blenderClient.isConnected()) {
await this.blenderClient.connect(this.blenderPort);
logger.info(`Connected to Blender on port ${this.blenderPort}`);
}
// Extract command method and params
const method = params.method as string;
const commandParams = params.params as Record<string, unknown>;
// Send command to Blender
const result = await this.blenderClient.sendCommand(method, commandParams);
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Blender command failed: ${errorMessage}`);
throw error;
}
}
/**
* Get daemon status
*/
private getStatus(): DaemonState {
const uptime = Date.now() - this.startTime;
return {
connected: this.blenderClient.isConnected(),
port: this.blenderPort,
host: '127.0.0.1',
uptime,
lastActivity: this.lastActivity
};
}
/**
* Setup shutdown handlers
*/
private setupShutdownHandlers(): void {
const shutdown = (signal: string) => {
logger.info(`Received ${signal}, shutting down...`);
void this.shutdown();
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
if (process.platform !== 'win32') {
process.on('SIGHUP', () => shutdown('SIGHUP'));
}
}
/**
* Shutdown daemon
* Browser Pilot 패턴: Race condition 방지
*/
private shutdown(): Promise<void> {
// Race condition 방지: 이미 shutdown 중이면 기존 Promise 반환
if (this.shutdownPromise) {
return this.shutdownPromise;
}
this.shutdownRequested = true;
this.shutdownPromise = this.performShutdown();
return this.shutdownPromise;
}
/**
* 실제 shutdown 수행 (내부 메서드)
* Browser Pilot 패턴: Promise 기반 안전한 종료
*/
private async performShutdown(): Promise<void> {
logger.info('Shutting down daemon...');
try {
// 1. Close all active client connections
logger.info(`Closing ${this.activeSockets.size} active connections...`);
for (const socket of this.activeSockets) {
try {
socket.destroy();
} catch (error) {
// Ignore individual socket errors
}
}
this.activeSockets.clear();
// 2. Close Blender connection
if (this.blenderClient.isConnected()) {
this.blenderClient.disconnect();
logger.info('Disconnected from Blender');
}
// 3. Close IPC server with timeout
if (this.ipcServer) {
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
logger.warn('IPC server close timeout, forcing...');
resolve();
}, DAEMON.SHUTDOWN_TIMEOUT);
this.ipcServer!.close(() => {
clearTimeout(timeout);
logger.info('IPC server closed');
resolve();
});
});
}
// 4. Remove socket file (Unix only)
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
unlinkSync(this.socketPath);
logger.info('Socket file removed');
}
// 5. Remove PID file
if (existsSync(this.pidPath)) {
unlinkSync(this.pidPath);
logger.info('PID file removed');
}
logger.info('✓ Daemon shutdown complete');
} catch (error) {
logger.error('Error during shutdown:', error);
} finally {
process.exit(0);
}
}
}
// Main entry point
if (require.main === module) {
const server = new DaemonServer();
server.start().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
}
export default DaemonServer;

308
skills/scripts/src/index.ts Normal file
View File

@@ -0,0 +1,308 @@
/**
* Blender Animation Retargeting Workflow
* Mixamo 애니메이션을 사용자 캐릭터에 리타게팅하는 전체 워크플로우
*/
import { BlenderClient } from './blender/client';
import { RetargetingController } from './blender/retargeting';
import { MixamoHelper } from './blender/mixamo';
import { BLENDER, FS, ERROR_MESSAGES, SUCCESS_MESSAGES } from './constants';
import { existsSync, mkdirSync } from 'fs';
import { join } from 'path';
export interface RetargetWorkflowOptions {
// Blender 설정
blenderPort?: number;
// 캐릭터 설정
targetCharacterArmature: string;
// 애니메이션 파일 설정
animationFilePath: string; // FBX or DAE file path (manual download required)
animationName?: string; // Optional animation name for NLA track
// 리타게팅 설정
boneMapping?: 'auto' | 'mixamo_to_rigify' | 'custom';
customBoneMap?: Record<string, string>;
// Confirmation workflow
skipConfirmation?: boolean; // Skip bone mapping confirmation (use auto-mapping directly)
// 출력 설정
outputDir?: string;
}
export class AnimationRetargetingWorkflow {
private blenderClient: BlenderClient;
private retargetingController: RetargetingController;
private mixamoHelper: MixamoHelper;
private outputDir: string;
constructor() {
this.blenderClient = new BlenderClient();
this.retargetingController = new RetargetingController(this.blenderClient);
this.mixamoHelper = new MixamoHelper();
this.outputDir = join(process.cwd(), FS.OUTPUT_DIR);
}
/**
* 전체 리타게팅 워크플로우 실행
*
* Workflow with user confirmation:
* 1. Import animation FBX
* 2. Auto-generate bone mapping
* 3. Send mapping to Blender UI for review
* 4. Wait for user confirmation (via AskUserQuestion)
* 5. Retrieve edited mapping from Blender
* 6. Apply retargeting with confirmed mapping
*/
async run(options: RetargetWorkflowOptions): Promise<void> {
const {
blenderPort = BLENDER.DEFAULT_PORT,
targetCharacterArmature,
animationFilePath,
animationName,
boneMapping = 'auto',
customBoneMap,
skipConfirmation = false,
outputDir,
} = options;
if (outputDir) {
this.outputDir = outputDir;
}
// 출력 디렉토리 생성
this.ensureOutputDirectory();
// Validate animation file
if (!existsSync(animationFilePath)) {
throw new Error(`Animation file not found: ${animationFilePath}`);
}
try {
// Step 1: Blender에 연결
console.log('🔌 Connecting to Blender...');
await this.blenderClient.connect();
console.log(SUCCESS_MESSAGES.CONNECTED);
// Step 2: 타겟 캐릭터 확인
console.log('🔍 Checking target character...');
const armatures = await this.getArmatures();
if (!armatures.includes(targetCharacterArmature)) {
throw new Error(
`Target armature "${targetCharacterArmature}" not found. Available: ${armatures.join(', ')}`
);
}
// Step 3: 애니메이션 파일 임포트
console.log(`📦 Importing animation from: ${animationFilePath}`);
await this.importAnimation(animationFilePath);
console.log(SUCCESS_MESSAGES.ANIMATION_IMPORTED);
// Step 4: Mixamo 아마추어 찾기 (방금 임포트된 것)
const updatedArmatures = await this.getArmatures();
const mixamoArmature = updatedArmatures.find(
(name) => !armatures.includes(name)
);
if (!mixamoArmature) {
throw new Error('Failed to find imported animation armature');
}
console.log(`✅ Found animation armature: ${mixamoArmature}`);
// Step 5: Auto-generate bone mapping
console.log('🔍 Auto-generating bone mapping...');
let finalBoneMap: Record<string, string>;
if (boneMapping === 'custom' && customBoneMap) {
finalBoneMap = customBoneMap;
} else {
finalBoneMap = await this.retargetingController.autoMapBones(
mixamoArmature,
targetCharacterArmature
);
}
console.log(`✅ Generated bone mapping (${Object.keys(finalBoneMap).length} bones)`);
// Step 6: Bone mapping confirmation workflow
if (!skipConfirmation) {
console.log('\n📋 Bone Mapping Preview:');
console.log('─'.repeat(60));
Object.entries(finalBoneMap).forEach(([source, target]) => {
console.log(` ${source.padEnd(25)}${target}`);
});
console.log('─'.repeat(60));
// Send bone mapping to Blender UI
console.log('\n📤 Sending bone mapping to Blender UI...');
await this.blenderClient.sendCommand('BoneMapping.show', {
sourceArmature: mixamoArmature,
targetArmature: targetCharacterArmature,
boneMapping: finalBoneMap,
});
console.log('✅ Bone mapping displayed in Blender');
console.log('\n⏸ Please review the bone mapping in Blender:');
console.log(' 1. Check the "Blender Toolkit" panel in the 3D View sidebar (N key)');
console.log(' 2. Review the bone mapping table');
console.log(' 3. Edit any incorrect mappings if needed');
console.log(' 4. Click "Apply Retargeting" when ready');
console.log('\nWaiting for user confirmation...\n');
// Note: In actual implementation with Claude Code, this would use AskUserQuestion
// For now, we'll retrieve the mapping after a pause
// TODO: Integrate with Claude Code's AskUserQuestion tool
// Retrieve edited bone mapping from Blender (with error recovery)
console.log('📥 Retrieving bone mapping from Blender...');
try {
const retrievedMapping = await this.blenderClient.sendCommand<Record<string, string>>(
'BoneMapping.get',
{
sourceArmature: mixamoArmature,
targetArmature: targetCharacterArmature,
}
);
if (retrievedMapping && Object.keys(retrievedMapping).length > 0) {
finalBoneMap = retrievedMapping;
console.log(`✅ Using edited bone mapping (${Object.keys(finalBoneMap).length} bones)`);
} else {
console.log('⚠️ No edited mapping found, using auto-generated mapping');
}
} catch (error) {
console.warn('⚠️ Failed to retrieve edited mapping, using auto-generated mapping');
console.warn(` Error: ${error}`);
// finalBoneMap already contains the auto-generated mapping, so no action needed
}
}
// Step 7: 리타게팅 실행
console.log('\n🎬 Starting animation retargeting...');
await this.retargetingController.retarget({
sourceArmature: mixamoArmature,
targetArmature: targetCharacterArmature,
boneMapping: 'custom',
customBoneMap: finalBoneMap,
preserveRotation: true,
preserveLocation: true,
});
console.log(SUCCESS_MESSAGES.RETARGETING_COMPLETE);
// Step 8: NLA에 추가 (선택사항)
const animations = await this.retargetingController.getAnimations(
targetCharacterArmature
);
if (animations.length > 0) {
const latestAnimation = animations[animations.length - 1];
const nlaTrackName = animationName || `Retargeted_${Date.now()}`;
console.log(`📋 Adding animation to NLA track: ${nlaTrackName}`);
await this.retargetingController.addToNLA(
targetCharacterArmature,
latestAnimation,
nlaTrackName
);
}
console.log('\n✅ Animation retargeting completed successfully!\n');
console.log('Next steps:');
console.log(' 1. Review the retargeted animation in Blender');
console.log(' 2. Adjust keyframes if needed');
console.log(' 3. Export or save your scene');
} catch (error) {
console.error('❌ Retargeting workflow failed:', error);
throw error;
} finally {
// 연결 종료
await this.blenderClient.disconnect();
}
}
/**
* 애니메이션 파일 임포트
*/
private async importAnimation(filepath: string): Promise<void> {
const ext = filepath.split('.').pop()?.toLowerCase();
if (ext === 'fbx') {
await this.blenderClient.sendCommand('Import.fbx', { filepath });
} else if (ext === 'dae') {
await this.blenderClient.sendCommand('Import.dae', { filepath });
} else {
throw new Error(`Unsupported file format: ${ext}`);
}
}
/**
* 아마추어 목록 가져오기
*/
private async getArmatures(): Promise<string[]> {
return await this.blenderClient.sendCommand<string[]>('Armature.list');
}
/**
* 출력 디렉토리 생성
*/
private ensureOutputDirectory(): void {
if (!existsSync(this.outputDir)) {
mkdirSync(this.outputDir, { recursive: true });
}
const animationsDir = join(this.outputDir, FS.ANIMATIONS_DIR);
if (!existsSync(animationsDir)) {
mkdirSync(animationsDir, { recursive: true });
}
// .gitignore 생성
const gitignorePath = join(this.outputDir, '.gitignore');
if (!existsSync(gitignorePath)) {
const fs = require('fs');
fs.writeFileSync(gitignorePath, FS.GITIGNORE_CONTENT);
}
}
/**
* Get manual download instructions for Mixamo
*/
getManualDownloadInstructions(animationName: string): string {
return this.mixamoHelper.getManualDownloadInstructions(animationName);
}
/**
* Get list of popular Mixamo animations
*/
getPopularAnimations() {
return this.mixamoHelper.getPopularAnimations();
}
/**
* Get recommended Mixamo download settings
*/
getRecommendedSettings() {
return this.mixamoHelper.getRecommendedSettings();
}
}
// CLI 사용 예시
export async function runRetargetingFromCLI() {
const workflow = new AnimationRetargetingWorkflow();
// Show manual download instructions
console.log(workflow.getManualDownloadInstructions('Walking'));
console.log('\nRecommended settings:', workflow.getRecommendedSettings());
// After manual download, run retargeting
await workflow.run({
targetCharacterArmature: 'MyCharacter', // User's character name
animationFilePath: './animations/Walking.fbx', // Downloaded FBX path
animationName: 'Walking', // Animation name for NLA track
boneMapping: 'auto', // Auto bone mapping
skipConfirmation: false, // Enable confirmation workflow
});
}

View File

@@ -0,0 +1,92 @@
/**
* Winston Logger Configuration
* TypeScript 애플리케이션용 로깅 시스템
*/
import winston from 'winston';
import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
// 로그 디렉토리 경로
const LOG_DIR = join(process.cwd(), '.blender-toolkit', 'logs');
// 로그 디렉토리 생성
if (!existsSync(LOG_DIR)) {
mkdirSync(LOG_DIR, { recursive: true });
}
// 로그 포맷 정의
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
winston.format.errors({ stack: true }),
winston.format.printf(({ timestamp, level, message, stack }) => {
const logMessage = `[${timestamp}] [${level.toUpperCase().padEnd(5)}] ${message}`;
return stack ? `${logMessage}\n${stack}` : logMessage;
})
);
// 콘솔용 컬러 포맷
const consoleFormat = winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({ format: 'HH:mm:ss' }),
winston.format.printf(({ timestamp, level, message }) => {
return `[${timestamp}] ${level}: ${message}`;
})
);
// Winston 로거 생성
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: logFormat,
transports: [
// 파일 트랜스포트: 모든 로그
new winston.transports.File({
filename: join(LOG_DIR, 'typescript.log'),
maxsize: 5242880, // 5MB
maxFiles: 5,
}),
// 파일 트랜스포트: 에러만
new winston.transports.File({
filename: join(LOG_DIR, 'error.log'),
level: 'error',
maxsize: 5242880, // 5MB
maxFiles: 5,
}),
],
});
// 개발 모드에서는 콘솔에도 출력
if (process.env.NODE_ENV !== 'production') {
logger.add(
new winston.transports.Console({
format: consoleFormat,
})
);
}
// 디버그 모드 활성화
if (process.env.DEBUG) {
logger.level = 'debug';
}
// 로거 래퍼 함수들 (사용 편의성)
export const log = {
debug: (message: string, ...meta: any[]) => logger.debug(message, ...meta),
info: (message: string, ...meta: any[]) => logger.info(message, ...meta),
warn: (message: string, ...meta: any[]) => logger.warn(message, ...meta),
error: (message: string, ...meta: any[]) => logger.error(message, ...meta),
};
// Named export (코드베이스 호환성)
export { logger };
// 기본 export
export default logger;
// 로거 초기화 메시지
logger.info('Logger initialized', {
logDir: LOG_DIR,
level: logger.level,
nodeEnv: process.env.NODE_ENV || 'development',
});

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "commonjs",
"lib": ["ES2023"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}