Compare commits
32 Commits
dee7c5e6de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 93661e0987 | |||
| a570949416 | |||
| 968425e023 | |||
| 48f0065859 | |||
| 50d3f05dd9 | |||
| 53927a6880 | |||
| b7a83667d6 | |||
| 0299cc5aef | |||
| b681d02462 | |||
| 1de7ec5752 | |||
| 7e738fce11 | |||
| 440b206116 | |||
| 32a292477a | |||
| 38355fcfc1 | |||
| f069289890 | |||
| 20f81d0e1e | |||
| 8798c249d7 | |||
| 7b49d207d7 | |||
| fb0bae51f3 | |||
| 6d9fb0f74d | |||
| 1f28932792 | |||
| ae38056b1c | |||
| 55afba30a8 | |||
| a3868e126d | |||
| 4dee5cbe01 | |||
| 4b6a351242 | |||
| 4d5706ddf0 | |||
| 7642ff5d56 | |||
| bc01755c1e | |||
| 48e0648d4b | |||
| 0b7ecf500d | |||
| a8f7e5cd7d |
@@ -0,0 +1,30 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
gitea-act-runner:
|
||||
image: gitea/act_runner:latest
|
||||
container_name: gitea-act-runner-dlm
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Update this path to your TrueNAS dataset (or local runner data path)
|
||||
- /mnt/pool/gitea-runner:/data
|
||||
# Docker socket - required for runner to create job containers
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
# Update with your Gitea instance URL (accessible from this container)
|
||||
- GITEA_INSTANCE_URL=http://10.1.10.3:30008/
|
||||
# Get this from: Repository → Settings → Actions → Runners → New Runner
|
||||
- GITEA_RUNNER_REGISTRATION_TOKEN=<your-runner-token>
|
||||
# Name your runner (can be anything)
|
||||
- GITEA_RUNNER_NAME=dlm-runner
|
||||
# Labels: format is "label:docker-image"
|
||||
# These labels will be used in your workflow's runs-on field
|
||||
- GITEA_RUNNER_LABELS=ubuntu:docker://node:20-bullseye,linux:docker://node:20-bullseye
|
||||
networks:
|
||||
# Update to match your Gitea container's network
|
||||
- gitea-network
|
||||
|
||||
networks:
|
||||
gitea-network:
|
||||
external: true # Change to false if you want to create a new network
|
||||
# name: gitea # Uncomment and set if using external network
|
||||
@@ -0,0 +1,95 @@
|
||||
name: Create Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Release tag (e.g., v0.3.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get Version
|
||||
id: get_version
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
data = tomllib.loads(Path("blender_manifest.toml").read_text(encoding="utf-8"))
|
||||
version = data["version"]
|
||||
|
||||
# Gitea Actions uses GITHUB_OUTPUT (compatible with GitHub Actions)
|
||||
output_file = os.environ.get("GITHUB_OUTPUT", os.environ.get("GITEA_OUTPUT", "/dev/stdout"))
|
||||
with open(output_file, "a", encoding="utf-8") as fh:
|
||||
fh.write(f"version={version}\n")
|
||||
fh.write(f"zip_name=dynamiclinkmanager_{version}.zip\n")
|
||||
PY
|
||||
|
||||
- name: Create ZIP
|
||||
run: |
|
||||
ADDON_DIR=dynamiclinkmanager
|
||||
mkdir -p "$ADDON_DIR"
|
||||
|
||||
# Copy top-level files
|
||||
for file in __init__.py blender_manifest.toml; do
|
||||
if [ -f "$file" ]; then
|
||||
cp "$file" "$ADDON_DIR"/
|
||||
else
|
||||
echo "Skipping missing file: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy directories
|
||||
cp -r ops "$ADDON_DIR"/
|
||||
cp -r ui "$ADDON_DIR"/
|
||||
if [ -d "utils" ] && [ "$(ls -A utils)" ]; then
|
||||
cp -r utils "$ADDON_DIR"/
|
||||
fi
|
||||
|
||||
# Create the zip from the temporary directory
|
||||
zip -r "${{ steps.get_version.outputs.zip_name }}" "$ADDON_DIR"/*
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
# Gitea Actions uses github.repository context (compatible with GitHub Actions)
|
||||
API_BASE="${GITHUB_SERVER_URL}/api/v1"
|
||||
curl -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\": \"${{ inputs.release_tag }}\",
|
||||
\"name\": \"${{ inputs.release_tag }}\",
|
||||
\"body\": \"Release ${{ steps.get_version.outputs.version }}\",
|
||||
\"draft\": true
|
||||
}" \
|
||||
"${API_BASE}/repos/${{ github.repository }}/releases" || true
|
||||
|
||||
- name: Upload Release Asset
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
API_BASE="${GITHUB_SERVER_URL}/api/v1"
|
||||
# Get the release ID
|
||||
RELEASE_ID=$(curl -s \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"${API_BASE}/repos/${{ github.repository }}/releases/tags/${{ inputs.release_tag }}" \
|
||||
| grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
curl -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary "@${{ steps.get_version.outputs.zip_name }}" \
|
||||
"${API_BASE}/repos/${{ github.repository }}/releases/$RELEASE_ID/assets?name=${{ steps.get_version.outputs.zip_name }}"
|
||||
fi
|
||||
@@ -1,2 +1,4 @@
|
||||
# SpecStory explanation file
|
||||
/.what-is-this.md
|
||||
# SpecStory project identity file
|
||||
/.project.json
|
||||
|
||||
+2947
-1031
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,544 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session 3f042eb5-f457-4e06-9b56-ee14ac7afd90 (2026-02-19 18:53Z) -->
|
||||
|
||||
# Blendfile local datablocks analysis (2026-02-19 18:53Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
Use blender mcp to view my scene. What is taking up space in the blendfile? Only count local datablocks and local override data, linked libraries are not stored in the blendfile itself.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model default, mode Agent)**_
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
|
||||
The user wants me to use the Blender MCP to view their scene and find out what's taking up space in the blend file. They specifically want to count only local datablocks and local override data - not linked libraries since those are stored elsewhere.
|
||||
|
||||
Let me first check what MCP tools are available from the user-blender server.
|
||||
</details></think>
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="generic" data-tool-name="glob_file_search">
|
||||
<details>
|
||||
<summary>Tool use: **glob_file_search** • Searched codebase "undefined" • **0** result in **1** directory</summary>
|
||||
|
||||
Directory: **T:\Dynamic-Link-Manager\mcps\user-blender** (0 file)
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
|
||||
Let me try listing the directory with list_dir or read the mcps folder structure.
|
||||
</details></think>
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
File not found
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="generic" data-tool-name="glob_file_search">
|
||||
<details>
|
||||
<summary>Tool use: **glob_file_search** • Searched codebase "undefined" • **0** result in **1** directory</summary>
|
||||
|
||||
Directory: **T:\Dynamic-Link-Manager\mcps\user-blender** (0 file)
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
|
||||
The mcps folder might be in a different structure. Let me check the workspace for MCP-related files and also look for how to call Blender MCP tools.
|
||||
</details></think>
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="unknown" data-tool-name="ripgrep_raw_search">
|
||||
<details>
|
||||
<summary>Tool use: **ripgrep_raw_search**</summary>
|
||||
|
||||
|
||||
Parameters:
|
||||
|
||||
```json
|
||||
{
|
||||
"pattern": "user-blender|blender",
|
||||
"path": "T:\\Dynamic-Link-Manager",
|
||||
"glob": "*.json",
|
||||
"caseInsensitive": false
|
||||
}
|
||||
```
|
||||
Additional data:
|
||||
|
||||
```json
|
||||
{
|
||||
"isPruned": true,
|
||||
"pattern": "user-blender|blender",
|
||||
"path": "T:\\Dynamic-Link-Manager",
|
||||
"outputMode": "content",
|
||||
"totalFiles": 0,
|
||||
"totalMatches": 0,
|
||||
"topFiles": []
|
||||
}
|
||||
```
|
||||
Status: **completed**
|
||||
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
File not found
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
|
||||
The user said "Use blender mcp" - the MCP servers were listed as user-blender and cursor-ide-browser. The instructions said "Browse the MCP tool descriptors in the file system" at C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager/mcps/<server>/tools/. Let me try with the path that was in the system message - maybe the folder is there but empty or has a different structure. Let me list what's in mcps.
|
||||
</details></think>
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="unknown" data-tool-name="ripgrep_raw_search">
|
||||
<details>
|
||||
<summary>Tool use: **ripgrep_raw_search**</summary>
|
||||
|
||||
|
||||
Parameters:
|
||||
|
||||
```json
|
||||
{
|
||||
"pattern": "call_mcp_tool|mcp",
|
||||
"path": "T:\\Dynamic-Link-Manager",
|
||||
"outputMode": "files_with_matches",
|
||||
"caseInsensitive": false
|
||||
}
|
||||
```
|
||||
Additional data:
|
||||
|
||||
```json
|
||||
{
|
||||
"isPruned": true,
|
||||
"pattern": "call_mcp_tool|mcp",
|
||||
"path": "T:\\Dynamic-Link-Manager",
|
||||
"outputMode": "files_with_matches",
|
||||
"totalFiles": 1,
|
||||
"totalMatches": 1,
|
||||
"topFiles": [
|
||||
{
|
||||
"uri": ".specstory\\history\\2026-02-18_23-39Z-character-migrator-workflow-integration.md",
|
||||
"matchCount": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Status: **completed**
|
||||
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="search" data-tool-name="web_search">
|
||||
<details>
|
||||
<summary>Tool use: **web_search** • Searched web `undefined` • **1** results</summary>
|
||||
|
||||
| Title | URL | Chunk |
|
||||
|-------|-----|-------|
|
||||
| Web search results for query: Blender MCP server tools list datablocks scene | | Links:<br/>1. [Blender MCP Server: Headless Script Management](https://mcpmarket.com/server/blender)<br/>2. [Blender Script Manager – README \| MCP Marketplace](https://ubos.tech/mcp/blender-mcp-server/)<br/>3. [poly-mcp/Blender-MCP-Server](https://github.com/llm-use/Blender-MCP-Server)<br/>4. [Blender MCP Server - poly-mcp/blender-mcp-server](https://playbooks.com/mcp/llm-use-blender)<br/>5. [cwahlfeldt/blender-mcp](https://github.com/cwahlfeldt/blender-mcp)<br/><br/># Blender MCP Server Tools Overview<br/><br/>There are two main Blender MCP server implementations with different capability levels:<br/><br/>## Basic Script Manager (cwahlfeldt/blender-mcp)[2][5]<br/><br/>**Tools:**<br/>- `add_script(name, content)` - Add new scripts<br/>- `edit_script(name, content)` - Edit existing scripts<br/>- `execute_script(name, blend_file=None)` - Execute scripts in headless Blender<br/>- `remove_script(name)` - Remove scripts<br/><br/>**Resources:**<br/>- `scripts://list` - List available scripts<br/>- `script://\{name\}` - Access specific script content<br/>- `result://\{name\}` - Get execution results<br/><br/>## Advanced Complete Server (poly-mcp/Blender-MCP-Server)[3][4]<br/><br/>**50+ Tools across multiple categories:**<br/>- Object creation, manipulation, and transformation<br/>- Material and shader system management<br/>- Animation and keyframe control<br/>- Camera and lighting setup<br/>- Modifiers and constraints<br/>- Physics simulations (rigid body, cloth, fluid)<br/>- Geometry nodes and procedural generation<br/>- File import/export operations<br/>- Scene optimization and batch operations<br/><br/>**Additional Features:**[3][4]<br/>- Thread-safe execution queue for concurrent operations<br/>- Auto-dependency installation<br/>- Real-time monitoring and statistics<br/>- Production-ready error handling and logging<br/>- Simple UI panel in Blender's N-panel for server control<br/><br/>Both servers support working with datablocks and scene data through Blender's Python API (`bpy.data` objects).[2][5] |
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
|
||||
The MCP tool descriptors were said to be in the file system at C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager/mcps/<server>/tools/. The server name is "user-blender". So the path would be C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender. The glob returned 0 files - maybe the path uses forward slashes or the folder doesn't have the tool JSON files. Let me try to list resources and call a generic tool to see what's available. I'll use list_dir on the workspace to see the mcps folder.
|
||||
</details></think>
|
||||
|
||||
---
|
||||
|
||||
|
||||
Checking the MCP server structure and available tools:
|
||||
|
||||
|
||||
---
|
||||
|
||||
File not found
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="generic" data-tool-name="glob_file_search">
|
||||
<details>
|
||||
<summary>Tool use: **glob_file_search** • Searched codebase "undefined" • **59** results in **1** directory</summary>
|
||||
|
||||
Directory: **C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps** (59 files)
|
||||
|
||||
| File |
|
||||
|------|
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\INSTRUCTIONS.md` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\SERVER_METADATA.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_profile_start.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_profile_stop.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_unlock.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_lock.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_search.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_tabs.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_take_screenshot.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\SERVER_METADATA.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_console_messages.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_network_requests.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\prompts\asset_creation_strategy.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_handle_dialog.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\import_generated_asset_hunyuan.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_resize.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\generate_hunyuan3d_model.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_drag.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_highlight.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\poll_hunyuan_job_status.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_hunyuan3d_status.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_get_bounding_box.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_is_checked.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_is_enabled.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\import_generated_asset.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\poll_rodin_job_status.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\generate_hyper3d_model_via_images.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_is_visible.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\generate_hyper3d_model_via_text.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_get_attribute.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_get_input_value.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\download_sketchfab_model.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_sketchfab_model_preview.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\search_sketchfab_models.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_reload.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_scroll.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_sketchfab_status.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_navigate_back.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_navigate_forward.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_hyper3d_status.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_polyhaven_status.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_press_key.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_wait_for.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\download_polyhaven_asset.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_hover.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\set_texture.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_select_option.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_fill.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_fill_form.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_polyhaven_categories.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\search_polyhaven_assets.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_click.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\execute_blender_code.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_viewport_screenshot.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_type.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_object_info.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_scene_info.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_navigate.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\cursor-ide-browser\tools\browser_snapshot.json` |
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
|
||||
The user-blender MCP has tools like import_generated_asset, generate_hunyuan3d_model, etc. - these seem to be asset-generation focused. I need to find if there's a tool to run Python in Blender or get scene/datablock info. Let me list all user-blender tools and check the server metadata.
|
||||
</details></think>
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="generic" data-tool-name="glob_file_search">
|
||||
<details>
|
||||
<summary>Tool use: **glob_file_search** • Searched codebase "undefined" • **22** results in **1** directory</summary>
|
||||
|
||||
Directory: **C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools** (22 files)
|
||||
|
||||
| File |
|
||||
|------|
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\import_generated_asset_hunyuan.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\generate_hunyuan3d_model.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\poll_hunyuan_job_status.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_hunyuan3d_status.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\poll_rodin_job_status.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\import_generated_asset.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\generate_hyper3d_model_via_images.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\generate_hyper3d_model_via_text.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\download_sketchfab_model.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\search_sketchfab_models.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_sketchfab_model_preview.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_sketchfab_status.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_polyhaven_status.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_hyper3d_status.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\download_polyhaven_asset.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\set_texture.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_polyhaven_categories.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\search_polyhaven_assets.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\execute_blender_code.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_viewport_screenshot.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_object_info.json` |
|
||||
| `../C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_scene_info.json` |
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="read" data-tool-name="read_file_v2">
|
||||
<details><summary>Tool use: **read_file_v2** • Read file: C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\SERVER_METADATA.json</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
|
||||
There's `execute_blender_code.json`, `get_scene_info.json`, and `get_object_info.json`. Let me read those to see if I can get datablock counts and size info. get_scene_info might give scene overview; execute_blender_code would let me run custom Python to count local datablocks and override data.
|
||||
</details></think>
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reading the schema for the tools that can inspect the scene and run code:
|
||||
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="read" data-tool-name="read_file_v2">
|
||||
<details><summary>Tool use: **read_file_v2** • Read file: C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_scene_info.json</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="read" data-tool-name="read_file_v2">
|
||||
<details><summary>Tool use: **read_file_v2** • Read file: C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\execute_blender_code.json</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="read" data-tool-name="read_file_v2">
|
||||
<details><summary>Tool use: **read_file_v2** • Read file: C:\Users\Nathan\.cursor\projects\t-Dynamic-Link-Manager\mcps\user-blender\tools\get_object_info.json</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
|
||||
I'll use get_scene_info first to get an overview, then execute_blender_code to run Python that:
|
||||
1. Iterates bpy.data and counts only local datablocks (where .library is None)
|
||||
2. Counts local override data (overrides that are stored in this file)
|
||||
3. Optionally reports approximate size or counts by type
|
||||
|
||||
In Blender:
|
||||
- A datablock is "local" if it's not from a library: `id_data.library is None`
|
||||
- Override data: `bpy.data.libraries` contains linked libraries; overrides are stored in the current file. I need to count override properties - in Blender 4.0+ we have `bpy.types.ID.override_create` and overrides live in the file. Actually "local override data" might mean the override hierarchy / override properties stored in the blend file for linked data. So:
|
||||
- Local datablocks: all id_data where .library is None
|
||||
- Local override data: could mean the override system's storage. In Blender, when you have a linked object and you override properties, that override info is stored locally. So we need datablocks that are in the override hierarchy (they might be virtual/local overrides).
|
||||
|
||||
Let me first get scene info to see what we're working with, then run code to count:
|
||||
1. Per-type counts of datablocks where block.library is None (or getattr(block, 'library', None) is None for types that don't have library)
|
||||
2. Override: in Blender, overrides are typically ID types that have .override_library set. So we could count override data as IDs that have override_library and are stored in this file (their override is local). Actually the user said "local override data" - meaning the override data that is stored in the blend file. So we want:
|
||||
- All IDs that are local (library is None)
|
||||
- Among those, some might be "override" type (they override a linked datablock) - the override hierarchy and property overrides are stored locally
|
||||
|
||||
I'll write Python that:
|
||||
1. For each bpy.data collection (objects, meshes, materials, etc.), count items where .library is None
|
||||
2. For override: count IDs that have override_library (they represent local override data)
|
||||
|
||||
Let me get scene info first, then run the counting code.
|
||||
</details></think>
|
||||
|
||||
---
|
||||
|
||||
|
||||
Fetching scene info, then running Blender Python to count only local datablocks and local override data.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="unknown" data-tool-name="mcp-blender-user-blender-get_scene_info">
|
||||
<details>
|
||||
<summary>Tool use: **mcp-blender-user-blender-get_scene_info**</summary>
|
||||
|
||||
|
||||
Parameters:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_scene_info",
|
||||
"parameters": "{\"user_prompt\":\"View scene and report what is taking up space in the blend file; only local datablocks and local override data.\"}",
|
||||
"serverName": "blender"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Additional data:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success"
|
||||
}
|
||||
```
|
||||
Result:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "{\"content\":[{\"type\":\"text\",\"text\":\"{\\n \\\"name\\\": \\\"Scene\\\",\\n \\\"object_count\\\": 3104,\\n \\\"objects\\\": [\\n {\\n \\\"name\\\": \\\"Package\\\",\\n \\\"type\\\": \\\"MESH\\\",\\n \\\"location\\\": [\\n -0.1,\\n 0.55,\\n 0.24\\n ]\\n },\\n {\\n \\\"name\\\": \\\"Package.002\\\",\\n \\\"type\\\": \\\"MESH\\\",\\n \\\"location\\\": [\\n -0.02,\\n 0.0,\\n 0.26\\n ]\\n },\\n {\\n \\\"name\\\": \\\"Package.001\\\",\\n \\\"type\\\": \\\"MESH\\\",\\n \\\"location\\\": [\\n -0.26,\\n 0.11,\\n 0.77\\n ]\\n },\\n {\\n \\\"name\\\": \\\"Damaged-Package\\\",\\n \\\"type\\\": \\\"MESH\\\",\\n \\\"location\\\": [\\n -0.2,\\n -0.4,\\n 0.24\\n ]\\n },\\n {\\n \\\"name\\\": \\\"Damaged-Package.001\\\",\\n \\\"type\\\": \\\"MESH\\\",\\n \\\"location\\\": [\\n -0.1,\\n -0.31,\\n 0.76\\n ]\\n },\\n {\\n \\\"name\\\": \\\"Problem-solve_Rack\\\",\\n \\\"type\\\": \\\"MESH\\\",\\n \\\"location\\\": [\\n -0.21,\\n -0.08,\\n -0.0\\n ]\\n },\\n {\\n \\\"name\\\": \\\"staging tape\\\",\\n \\\"type\\\": \\\"MESH\\\",\\n \\\"location\\\": [\\n 0.12,\\n 0.02,\\n 0.0\\n ]\\n },\\n {\\n \\\"name\\\": \\\"Problem-Sovle_label\\\",\\n \\\"type\\\": \\\"MESH\\\",\\n \\\"location\\\": [\\n 0.31,\\n 0.02,\\n 0.01\\n ]\\n },\\n {\\n \\\"name\\\": \\\"problem-solve_QR\\\",\\n \\\"type\\\": \\\"MESH\\\",\\n \\\"location\\\": [\\n 0.32,\\n 0.66,\\n 0.01\\n ]\\n },\\n {\\n \\\"name\\\": \\\"Problem-Sovle_label.001\\\",\\n \\\"type\\\": \\\"MESH\\\",\\n \\\"location\\\": [\\n 0.11,\\n 0.04,\\n 1.85\\n ]\\n }\\n ],\\n \\\"materials_count\\\": 145\\n}\"}]}"
|
||||
}
|
||||
```
|
||||
Status: **completed**
|
||||
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
|
||||
Scene has 3104 objects and 145 materials. Now I need to run execute_blender_code to count local vs linked datablocks and get override counts. I'll write Python that:
|
||||
1. Iterates all bpy.data.* collections
|
||||
2. For each datablock, check if it's local (library is None)
|
||||
3. Count overrides (id_data.override_library is not None means it's an override - but overrides can be on linked data too; the override *data* is stored locally). So: local datablocks = library is None. For "local override data" - in Blender, when you override a linked ID, the override is stored in the current file. So we want IDs that have override_library - those are the ones that have local override data (the override hierarchy). Let me count both.
|
||||
</details></think>
|
||||
|
||||
---
|
||||
|
||||
|
||||
Running Python in Blender to count local datablocks and local override data:
|
||||
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="unknown" data-tool-name="mcp-blender-user-blender-execute_blender_code">
|
||||
<details>
|
||||
<summary>Tool use: **mcp-blender-user-blender-execute_blender_code**</summary>
|
||||
|
||||
|
||||
Parameters:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"name": "execute_blender_code",
|
||||
"parameters": "{\"user_prompt\":\"Count local datablocks and override data in blend file\",\"code\":\"import bpy\\nimport collections\\n\\n# Count local datablocks only (library is None)\\nlocal_counts = collections.defaultdict(int)\\noverride_count = 0\\noverride_by_type = collections.defaultdict(int)\\n\\nfor attr in dir(bpy.data):\\n if attr.startswith('_'):\\n continue\\n coll = getattr(bpy.data, attr, None)\\n if not hasattr(coll, 'values'):\\n continue\\n try:\\n for block in coll:\\n if getattr(block, 'library', None) is not None:\\n continue # skip linked\\n local_counts[attr] += 1\\n if getattr(block, 'override_library', None) and block.override_library:\\n override_count += 1\\n override_by_type[attr] += 1\\n except (TypeError, AttributeError):\\n pass\\n\\nlines = ['=== Local datablocks (library is None) ===']\\nfor k in sorted(local_counts.keys()):\\n lines.append(f'{k}: {local_counts[k]}')\\nlines.append('')\\nlines.append(f'Total local datablocks: {sum(local_counts.values())}')\\nlines.append('')\\nlines.append('=== Local override data (IDs with override_library) ===')\\nfor k in sorted(override_by_type.keys()):\\n lines.append(f'{k}: {override_by_type[k]}')\\nlines.append(f'Total with overrides: {override_count}')\\nprint('\\\\n'.join(lines))\"}",
|
||||
"serverName": "blender"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Additional data:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success"
|
||||
}
|
||||
```
|
||||
Result:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "{\"content\":[{\"type\":\"text\",\"text\":\"Code executed successfully: === Local datablocks (library is None) ===\\nactions: 4\\narmatures: 3\\nbrushes: 44\\ncameras: 1\\ncollections: 33\\nfonts: 1\\nimages: 9\\nlibraries: 6\\nlights: 108\\nlinestyles: 1\\nmaterials: 21\\nmeshes: 19\\nnode_groups: 8\\nobjects: 3092\\nscenes: 1\\nscreens: 18\\nshape_keys: 6\\nwindow_managers: 1\\nworkspaces: 12\\nworlds: 1\\n\\nTotal local datablocks: 3389\\n\\n=== Local override data (IDs with override_library) ===\\narmatures: 3\\ncollections: 23\\nlights: 104\\nmaterials: 1\\nmeshes: 7\\nnode_groups: 1\\nobjects: 3072\\nTotal with overrides: 3211\\n\"}]}"
|
||||
}
|
||||
```
|
||||
Status: **completed**
|
||||
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
|
||||
Good. So we have:
|
||||
- 3389 total local datablocks
|
||||
- 3211 of those (or overlapping) have override_library - meaning they're overriding linked data, so the override data is stored locally
|
||||
|
||||
The biggest contributors to local storage:
|
||||
- objects: 3092 local (and 3072 have overrides)
|
||||
- lights: 108 local (104 with overrides)
|
||||
- brushes: 44
|
||||
- collections: 33 (23 with overrides)
|
||||
- materials: 21
|
||||
- meshes: 19
|
||||
- screens: 18
|
||||
- workspaces: 12
|
||||
- images: 9
|
||||
- node_groups: 8
|
||||
- etc.
|
||||
|
||||
Let me also get a sense of what might be taking the most *space* - e.g. mesh data (verts/faces), image size, etc. I'll run another snippet to summarize size-heavy items.
|
||||
</details></think>
|
||||
|
||||
---
|
||||
|
||||
|
||||
Gathering what's using the most space (mesh geometry, images, etc.):
|
||||
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="unknown" data-tool-name="mcp--">
|
||||
<details>
|
||||
<summary>Tool use: **mcp--**</summary>
|
||||
|
||||
Status: **loading**
|
||||
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session 986c4b32-627a-4d70-9edb-fcaf30e33ee2 (2026-03-10 16:04Z) -->
|
||||
|
||||
# Blender addon file structure exploration (2026-03-10 16:04Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
I need to understand the file structure of a Blender addon at T:\Dynamic-Link-Manager. Please list all Python files (.py) in this directory and its subdirectories. For each file, show its full path and roughly how many lines it contains.
|
||||
|
||||
The shell seems to be returning cached output, so please use the file tools (Glob, Read with limited lines) to explore the directory structure.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants to understand the file structure of a Blender addon. They want me to:
|
||||
1. List all Python files (.py) in the directory and subdirectories
|
||||
2. Show the full path and line count for each file
|
||||
|
||||
They mention that shell returns cached output, so I should use Glob and Read tools instead. Let me start by finding all Python files using Glob.</details></think>
|
||||
|
||||
---
|
||||
|
||||
I'll explore the directory structure using the file tools to find all Python files and their line counts.
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="generic" data-tool-name="glob_file_search">
|
||||
<details>
|
||||
<summary>Tool use: **glob_file_search** • Searched codebase "undefined" • **12** results in **1** directory</summary>
|
||||
|
||||
Directory: **T:\Dynamic-Link-Manager** (12 files)
|
||||
|
||||
| File |
|
||||
|------|
|
||||
| `../ui/properties.py` |
|
||||
| `../ui/__init__.py` |
|
||||
| `../ui/panels.py` |
|
||||
| `../ops\migrator.py` |
|
||||
| `../__init__.py` |
|
||||
| `../ops/fk_rotations.py` |
|
||||
| `../utils/__init__.py` |
|
||||
| `../ops/library.py` |
|
||||
| `../ui/operators.py` |
|
||||
| `../ops/__init__.py` |
|
||||
| `../ops/tweak_tools.py` |
|
||||
| `../ui/preferences.py` |
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
I found 12 Python files. Now I need to read each file to determine the line count. I can use the Read tool with a limit to quickly check the line count for each file. Let me read all 12 files in parallel.</details></think>
|
||||
|
||||
---
|
||||
|
||||
Now let me read each file to determine their line counts.
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="read" data-tool-name="read_file_v2">
|
||||
<details><summary>Tool use: **read_file_v2** • Read file: T:\Dynamic-Link-Manager\__init__.py</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="read" data-tool-name="read_file_v2">
|
||||
<details><summary>Tool use: **read_file_v2** • Read file: T:\Dynamic-Link-Manager\ui\properties.py</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="read" data-tool-name="read_file_v2">
|
||||
<details><summary>Tool use: **read_file_v2** • Read file: T:\Dynamic-Link-Manager\ui\__init__.py</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="unknown" data-tool-name="read_file_v2">
|
||||
<details>
|
||||
<summary>Tool use: **read_file_v2**</summary>
|
||||
|
||||
Status: **loading**
|
||||
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="unknown" data-tool-name="read_file_v2">
|
||||
<details>
|
||||
<summary>Tool use: **read_file_v2**</summary>
|
||||
|
||||
Status: **loading**
|
||||
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="unknown" data-tool-name="read_file_v2">
|
||||
<details>
|
||||
<summary>Tool use: **read_file_v2**</summary>
|
||||
|
||||
Status: **loading**
|
||||
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="unknown" data-tool-name="read_file_v2">
|
||||
<details>
|
||||
<summary>Tool use: **read_file_v2**</summary>
|
||||
|
||||
Status: **loading**
|
||||
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="unknown" data-tool-name="read_file_v2">
|
||||
<details>
|
||||
<summary>Tool use: **read_file_v2**</summary>
|
||||
|
||||
Status: **loading**
|
||||
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="unknown" data-tool-name="read_file_v2">
|
||||
<details>
|
||||
<summary>Tool use: **read_file_v2**</summary>
|
||||
|
||||
Status: **loading**
|
||||
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="unknown" data-tool-name="read_file_v2">
|
||||
<details>
|
||||
<summary>Tool use: **read_file_v2**</summary>
|
||||
|
||||
Status: **loading**
|
||||
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="unknown" data-tool-name="read_file_v2">
|
||||
<details>
|
||||
<summary>Tool use: **read_file_v2**</summary>
|
||||
|
||||
Status: **loading**
|
||||
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="unknown" data-tool-name="read_file_v2">
|
||||
<details>
|
||||
<summary>Tool use: **read_file_v2**</summary>
|
||||
|
||||
Status: **loading**
|
||||
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session a6a5c4fa-5957-4577-9323-13723784a540 (2026-03-10 16:05Z) -->
|
||||
|
||||
# Restoring corrupted Python files in git (2026-03-10 16:05Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
I need to restore corrupted Python files in a git repository at T:\Dynamic-Link-Manager from git. The files have been corrupted with content from another file.
|
||||
|
||||
Please run the following git commands to restore these files:
|
||||
1. `git -C "T:\Dynamic-Link-Manager" checkout ui/operators.py`
|
||||
2. `git -C "T:\Dynamic-Link-Manager" checkout ops/migrator.py`
|
||||
3. `git -C "T:\Dynamic-Link-Manager" checkout __init__.py`
|
||||
4. `git -C "T:\Dynamic-Link-Manager" checkout ui/__init__.py`
|
||||
|
||||
You can run these commands using the Shell tool with `powershell -Command` or directly. Make sure to use the full path T:\Dynamic-Link-Manager for the git commands.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants to restore corrupted Python files in a git repository using git checkout commands. They want me to run 4 specific git checkout commands to restore files from git.
|
||||
|
||||
</details></think>
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session be47994a-f179-482a-9af2-4d208a01d324 (2026-03-10 16:07Z) -->
|
||||
|
||||
# Listing Python files in directory (2026-03-10 16:07Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
List all Python files in the directory T:\Dynamic-Link-Manager and its subdirectories. Use the Get-ChildItem PowerShell command to find all .py files recursively. Return the full paths of all Python files found.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants me to list all Python files in the T:\Dynamic-Link-Manager directory and its subdirectories using PowerShell's Get-ChildItem command. I'll execute this command to find all .py files recursively.</details></think>
|
||||
|
||||
---
|
||||
|
||||
I'll run the PowerShell command to find all Python files recursively in that directory.
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: Get-ChildItem -Path "T:\Dynamic-Link-Manager" -Recurse -Filter "*.py" | Select-Object -ExpandProperty FullName</summary>
|
||||
|
||||
```bash
|
||||
Get-ChildItem -Path "T:\Dynamic-Link-Manager" -Recurse -Filter "*.py" | Select-Object -ExpandProperty FullName
|
||||
```
|
||||
|
||||
```
|
||||
T:\Dynamic-Link-Manager\__init__.py
|
||||
T:\Dynamic-Link-Manager\ops\__init__.py
|
||||
T:\Dynamic-Link-Manager\ops\fk_rotations.py
|
||||
T:\Dynamic-Link-Manager\ops\library.py
|
||||
T:\Dynamic-Link-Manager\ops\migrator.py
|
||||
T:\Dynamic-Link-Manager\ops\tweak_tools.py
|
||||
T:\Dynamic-Link-Manager\ui\__init__.py
|
||||
T:\Dynamic-Link-Manager\ui\operators.py
|
||||
T:\Dynamic-Link-Manager\ui\panels.py
|
||||
T:\Dynamic-Link-Manager\ui\preferences.py
|
||||
T:\Dynamic-Link-Manager\ui\properties.py
|
||||
T:\Dynamic-Link-Manager\utils\__init__.py
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The command executed successfully and found 11 Python files in the T:\Dynamic-Link-Manager directory and its subdirectories. I'll present these results to the user in a clear format.</details></think>
|
||||
|
||||
---
|
||||
|
||||
Found **11 Python files** in `T:\Dynamic-Link-Manager`:
|
||||
|
||||
```
|
||||
T:\Dynamic-Link-Manager\__init__.py
|
||||
T:\Dynamic-Link-Manager\ops\__init__.py
|
||||
T:\Dynamic-Link-Manager\ops\fk_rotations.py
|
||||
T:\Dynamic-Link-Manager\ops\library.py
|
||||
T:\Dynamic-Link-Manager\ops\migrator.py
|
||||
T:\Dynamic-Link-Manager\ops\tweak_tools.py
|
||||
T:\Dynamic-Link-Manager\ui\__init__.py
|
||||
T:\Dynamic-Link-Manager\ui\operators.py
|
||||
T:\Dynamic-Link-Manager\ui\panels.py
|
||||
T:\Dynamic-Link-Manager\ui\preferences.py
|
||||
T:\Dynamic-Link-Manager\ui\properties.py
|
||||
T:\Dynamic-Link-Manager\utils\__init__.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session 15ca8ebd-f444-47c7-8df3-efe4c9e8386f (2026-03-10 16:07Z) -->
|
||||
|
||||
# Reading content from migrator.py (2026-03-10 16:07Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
Read the first 100 lines of the file T:\Dynamic-Link-Manager\ops\migrator.py and return the actual content. Use Python to read the file.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants me to read the first 100 lines of a specific file using Python. Let me use the Shell tool to execute a Python command that reads the file.</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: python -c "
|
||||
with open(r'T:\Dynamic-Link-Manager\ops\migrator.py', 'r') as f:
|
||||
lines = f.readlines()[:100]
|
||||
print(''.join(lines))
|
||||
"</summary>
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
with open(r'T:\Dynamic-Link-Manager\ops\migrator.py', 'r') as f:
|
||||
lines = f.readlines()[:100]
|
||||
print(''.join(lines))
|
||||
"
|
||||
```
|
||||
|
||||
```
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
"""Character migrator: migrate animation, constraints, relations from original to replacement armature."""
|
||||
|
||||
import bpy
|
||||
|
||||
from ..utils import descendants, collection_containing_armature
|
||||
|
||||
|
||||
def get_pair_manual(context):
|
||||
"""Return (orig_armature, rep_armature) from scene props, or (None, None)."""
|
||||
props = getattr(context.scene, "dynamic_link_manager", None)
|
||||
if not props:
|
||||
return None, None
|
||||
orig = getattr(props, "original_character", None)
|
||||
rep = getattr(props, "replacement_character", None)
|
||||
if orig and orig.type == "ARMATURE" and rep and rep.type == "ARMATURE":
|
||||
return orig, rep
|
||||
return None, None
|
||||
|
||||
|
||||
def get_pair_automatic(context):
|
||||
"""Discover one pair by convention: Name_Rigify and Name_Rigify.001. Returns (orig, rep) or (None, None)."""
|
||||
pairs = []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type != "ARMATURE":
|
||||
continue
|
||||
name = obj.name
|
||||
if name.endswith("_Rigify.001"):
|
||||
base = name[:-len("_Rigify.001")]
|
||||
orig = bpy.data.objects.get(f"{base}_Rigify")
|
||||
if orig and orig.type == "ARMATURE" and orig != obj:
|
||||
pairs.append((orig, obj))
|
||||
return pairs[0] if pairs else (None, None)
|
||||
|
||||
|
||||
def run_copy_attr(orig, rep):
|
||||
"""Copy armature object attributes: location, rotation, scale (CopyAttr)."""
|
||||
rep.location = orig.location.copy()
|
||||
if orig.rotation_mode == "QUATERNION":
|
||||
rep.rotation_quaternion = orig.rotation_quaternion.copy()
|
||||
else:
|
||||
rep.rotation_euler = orig.rotation_euler.copy()
|
||||
rep.scale = orig.scale.copy()
|
||||
|
||||
|
||||
def _has_als_anywhere(orig):
|
||||
"""Return True if orig has Animation Layers (addon uses Object.als RNA PropertyGroup, obj.als.turn_on)."""
|
||||
# Animation Layers addon: bpy.types.Object.als (RNA), not id props
|
||||
if getattr(orig, "als", None) is not None:
|
||||
return True
|
||||
key = "als.turn_on"
|
||||
if key in orig:
|
||||
return True
|
||||
if getattr(orig, "data", None) and hasattr(orig.data, "keys") and key in orig.data:
|
||||
return True
|
||||
try:
|
||||
als = orig.get("als")
|
||||
if als is not None and callable(getattr(als, "keys", None)) and "turn_on" in als.keys():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
for pb in orig.pose.bones:
|
||||
if key in pb:
|
||||
return True
|
||||
try:
|
||||
als = pb.get("als")
|
||||
if als is not None and callable(getattr(als, "keys", None)) and "turn_on" in als.keys():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _debug_als_lookup(orig):
|
||||
"""Print full debug for AnimLayers: RNA obj.als and every id_prop on orig."""
|
||||
key = "als.turn_on"
|
||||
print("[DLM MigNLA] === AnimLayers debug ===")
|
||||
als_rna = getattr(orig, "als", None)
|
||||
print(f"[DLM MigNLA] orig.als (RNA): {als_rna!r}, turn_on={getattr(als_rna, 'turn_on', 'N/A') if als_rna else 'N/A'}")
|
||||
print(f"[DLM MigNLA] 'als.turn_on' in orig (object): {key in orig}")
|
||||
if getattr(orig, "data", None):
|
||||
has_data_keys = hasattr(orig.data, "keys")
|
||||
print(f"[DLM MigNLA] orig.data has keys(): {has_data_keys}")
|
||||
if has_data_keys:
|
||||
print(f"[DLM MigNLA] 'als.turn_on' in orig.data: {key in orig.data}")
|
||||
print(f"[DLM MigNLA] orig.data keys: {list(orig.data.keys())}")
|
||||
try:
|
||||
als = orig.get("als")
|
||||
has_als = als is not None and callable(getattr(als, "keys", None))
|
||||
print(f"[DLM MigNLA] orig.get('als') is group: {has_als}")
|
||||
if has_als:
|
||||
print(f"[DLM MigNLA] orig['als'] keys: {list(als.keys())}")
|
||||
print(f"[DLM MigNLA] 'turn_on' in orig['als']: {'turn_on' in als.keys()}")
|
||||
except Exception as e:
|
||||
print(f"[DLM MigNLA] orig.get('als') error: {e}")
|
||||
print(f"[DLM MigNLA] orig (object) all keys: {list(orig.keys())}")
|
||||
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The command executed successfully and returned the first 100 lines of the file. I should present this content to the user clearly.</details></think>
|
||||
|
||||
---
|
||||
|
||||
Here are the first 100 lines of `T:\Dynamic-Link-Manager\ops\migrator.py`:
|
||||
|
||||
```python
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
"""Character migrator: migrate animation, constraints, relations from original to replacement armature."""
|
||||
|
||||
import bpy
|
||||
|
||||
from ..utils import descendants, collection_containing_armature
|
||||
|
||||
|
||||
def get_pair_manual(context):
|
||||
"""Return (orig_armature, rep_armature) from scene props, or (None, None)."""
|
||||
props = getattr(context.scene, "dynamic_link_manager", None)
|
||||
if not props:
|
||||
return None, None
|
||||
orig = getattr(props, "original_character", None)
|
||||
rep = getattr(props, "replacement_character", None)
|
||||
if orig and orig.type == "ARMATURE" and rep and rep.type == "ARMATURE":
|
||||
return orig, rep
|
||||
return None, None
|
||||
|
||||
|
||||
def get_pair_automatic(context):
|
||||
"""Discover one pair by convention: Name_Rigify and Name_Rigify.001. Returns (orig, rep) or (None, None)."""
|
||||
pairs = []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type != "ARMATURE":
|
||||
continue
|
||||
name = obj.name
|
||||
if name.endswith("_Rigify.001"):
|
||||
base = name[:-len("_Rigify.001")]
|
||||
orig = bpy.data.objects.get(f"{base}_Rigify")
|
||||
if orig and orig.type == "ARMATURE" and orig != obj:
|
||||
pairs.append((orig, obj))
|
||||
return pairs[0] if pairs else (None, None)
|
||||
|
||||
|
||||
def run_copy_attr(orig, rep):
|
||||
"""Copy armature object attributes: location, rotation, scale (CopyAttr)."""
|
||||
rep.location = orig.location.copy()
|
||||
if orig.rotation_mode == "QUATERNION":
|
||||
rep.rotation_quaternion = orig.rotation_quaternion.copy()
|
||||
else:
|
||||
rep.rotation_euler = orig.rotation_euler.copy()
|
||||
rep.scale = orig.scale.copy()
|
||||
|
||||
|
||||
def _has_als_anywhere(orig):
|
||||
"""Return True if orig has Animation Layers (addon uses Object.als RNA PropertyGroup, obj.als.turn_on)."""
|
||||
# Animation Layers addon: bpy.types.Object.als (RNA), not id props
|
||||
if getattr(orig, "als", None) is not None:
|
||||
return True
|
||||
key = "als.turn_on"
|
||||
if key in orig:
|
||||
return True
|
||||
if getattr(orig, "data", None) and hasattr(orig.data, "keys") and key in orig.data:
|
||||
return True
|
||||
try:
|
||||
als = orig.get("als")
|
||||
if als is not None and callable(getattr(als, "keys", None)) and "turn_on" in als.keys():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
for pb in orig.pose.bones:
|
||||
if key in pb:
|
||||
return True
|
||||
try:
|
||||
als = pb.get("als")
|
||||
if als is not None and callable(getattr(als, "keys", None)) and "turn_on" in als.keys():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _debug_als_lookup(orig):
|
||||
"""Print full debug for AnimLayers: RNA obj.als and every id_prop on orig."""
|
||||
key = "als.turn_on"
|
||||
print("[DLM MigNLA] === AnimLayers debug ===")
|
||||
als_rna = getattr(orig, "als", None)
|
||||
print(f"[DLM MigNLA] orig.als (RNA): {als_rna!r}, turn_on={getattr(als_rna, 'turn_on', 'N/A') if als_rna else 'N/A'}")
|
||||
print(f"[DLM MigNLA] 'als.turn_on' in orig (object): {key in orig}")
|
||||
if getattr(orig, "data", None):
|
||||
has_data_keys = hasattr(orig.data, "keys")
|
||||
print(f"[DLM MigNLA] orig.data has keys(): {has_data_keys}")
|
||||
if has_data_keys:
|
||||
print(f"[DLM MigNLA] 'als.turn_on' in orig.data: {key in orig.data}")
|
||||
print(f"[DLM MigNLA] orig.data keys: {list(orig.data.keys())}")
|
||||
try:
|
||||
als = orig.get("als")
|
||||
has_als = als is not None and callable(getattr(als, "keys", None))
|
||||
print(f"[DLM MigNLA] orig.get('als') is group: {has_als}")
|
||||
if has_als:
|
||||
print(f"[DLM MigNLA] orig['als'] keys: {list(als.keys())}")
|
||||
print(f"[DLM MigNLA] 'turn_on' in orig['als']: {'turn_on' in als.keys()}")
|
||||
except Exception as e:
|
||||
print(f"[DLM MigNLA] orig.get('als') error: {e}")
|
||||
print(f"[DLM MigNLA] orig (object) all keys: {list(orig.keys())}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session f1bb25c5-e5d4-4aad-88a3-f562ab877040 (2026-03-10 16:08Z) -->
|
||||
|
||||
# File content reading in Python (2026-03-10 16:08Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
Read the full content of the file T:\Dynamic-Link-Manager\ui\panels.py and return it. Use Python to read and print the file.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants me to read the full content of a file at `T:\Dynamic-Link-Manager\ui\panels.py` using Python to read and print the file.
|
||||
|
||||
Let me use the Shell tool to execute a Python command that reads and prints the file content.</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: python -c "with open('T:\\Dynamic-Link-Manager\\ui\\panels.py', 'r') as f: print(f.read())"</summary>
|
||||
|
||||
```bash
|
||||
python -c "with open('T:\\Dynamic-Link-Manager\\ui\\panels.py', 'r') as f: print(f.read())"
|
||||
```
|
||||
|
||||
```
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
import bpy
|
||||
from bpy.types import Panel, UIList
|
||||
from . import properties
|
||||
|
||||
|
||||
class DLM_UL_library_list(UIList):
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||
if self.layout_type in {"DEFAULT", "COMPACT"}:
|
||||
layout.scale_x = 0.4
|
||||
layout.label(text=item.name)
|
||||
layout.scale_x = 0.3
|
||||
if item.is_missing:
|
||||
layout.label(text="MISSING", icon="ERROR")
|
||||
elif item.is_indirect:
|
||||
layout.label(text="INDIRECT", icon="INFO")
|
||||
else:
|
||||
layout.label(text="OK", icon="FILE_BLEND")
|
||||
layout.scale_x = 0.3
|
||||
path_text = item.filepath or ""
|
||||
if path_text.startswith("\\\\"):
|
||||
parts = path_text.split("\\")
|
||||
short_path = f"\\\\{parts[2]}\\" if len(parts) >= 3 else "\\\\ (network)"
|
||||
elif len(path_text) >= 2 and path_text[1] == ":":
|
||||
short_path = f"{path_text[:2]}\\"
|
||||
elif path_text.startswith("//"):
|
||||
short_path = "// (relative)"
|
||||
else:
|
||||
short_path = path_text[:15] + "..." if len(path_text) > 15 else path_text
|
||||
layout.label(text=short_path, icon="FILE_FOLDER")
|
||||
elif self.layout_type == "GRID":
|
||||
layout.alignment = "CENTER"
|
||||
layout.label(text="", icon="FILE_BLEND")
|
||||
|
||||
|
||||
def _get_short_path(filepath):
|
||||
if not filepath:
|
||||
return "Unknown"
|
||||
if filepath.startswith("//"):
|
||||
return "// (relative)"
|
||||
if filepath.startswith("\\\\"):
|
||||
parts = filepath.split("\\")
|
||||
return f"\\\\{parts[2]}\\" if len(parts) >= 3 else "\\\\ (network)"
|
||||
if len(filepath) >= 2 and filepath[1] == ":":
|
||||
return f"{filepath[:2]}\\"
|
||||
if filepath.startswith("/"):
|
||||
parts = filepath.split("/")
|
||||
return f"/{parts[1]}/" if len(parts) >= 2 else "/ (root)"
|
||||
return "Unknown"
|
||||
|
||||
|
||||
class DLM_PT_main_panel(Panel):
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "UI"
|
||||
bl_category = "Dynamic Link Manager"
|
||||
bl_label = "Dynamic Link Manager"
|
||||
|
||||
def draw_header(self, context):
|
||||
layout = self.layout
|
||||
layout.operator("preferences.addon_show", text="", icon="PREFERENCES").module = __package__.rsplit(".", 1)[0]
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
props = context.scene.dynamic_link_manager
|
||||
|
||||
# Path management
|
||||
row = layout.row()
|
||||
row.operator("dlm.make_paths_relative", text="Make Paths Relative", icon="FILE_PARENT")
|
||||
row.operator("dlm.make_paths_absolute", text="Make Paths Absolute", icon="FILE_FOLDER")
|
||||
|
||||
# CharMig section (placeholder; full UI in implementation step 5)
|
||||
box = layout.box()
|
||||
box.label(text="Character Migrator")
|
||||
row = box.row()
|
||||
row.prop(props, "migrator_mode", text="Automatic pair discovery")
|
||||
row = box.row()
|
||||
row.prop(props, "original_character", text="Original")
|
||||
row.operator("dlm.picker_original_character", text="", icon="EYEDROPPER")
|
||||
row = box.row()
|
||||
row.prop(props, "replacement_character", text="Replacement")
|
||||
row.operator("dlm.picker_replacement_character", text="", icon="EYEDROPPER")
|
||||
row = box.row()
|
||||
row.operator("dlm.run_character_migration", text="Run migration", icon="ARMATURE_DATA")
|
||||
row = box.row(align=True)
|
||||
row.operator("dlm.migrator_copy_attributes", text="CopyAttr", icon="COPY_ID")
|
||||
row.operator("dlm.migrator_migrate_nla", text="MigNLA", icon="NLA")
|
||||
row.operator("dlm.migrator_custom_properties", text="MigCustProps", icon="PROPERTIES")
|
||||
row = box.row(align=True)
|
||||
row.operator("dlm.migrator_bone_constraints", text="MigBoneConst", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.migrator_retarget_relations", text="RetargRelatives", icon="ORIENTATION_PARENT")
|
||||
|
||||
# Situational
|
||||
situational_box = layout.box()
|
||||
situational_box.label(text="Situational Fixes", icon="QUESTION")
|
||||
row = situational_box.row(align=True)
|
||||
row.operator("dlm.migrator_basebody_shapekeys", text="MigBBodyShapeKeys", icon="SHAPEKEY_DATA")
|
||||
row = situational_box.row(align=True)
|
||||
row.operator("dlm.migrator_fk_rotations", text="MigFKRot", icon="BONE_DATA")
|
||||
row.operator("dlm.migrator_fk_rotations_remove", text="Remove", icon="X")
|
||||
row.operator("dlm.migrator_fk_rotations_bake", text="Bake", icon="KEYFRAME")
|
||||
|
||||
# Tweak Tools: header row (always), main box only when expanded
|
||||
section_icon = "DISCLOSURE_TRI_DOWN" if props.tweak_tools_section_expanded else "DISCLOSURE_TRI_RIGHT"
|
||||
row = layout.row(align=True)
|
||||
row.prop(props, "tweak_tools_section_expanded", text="", icon=section_icon, icon_only=True)
|
||||
row.label(text="Tweak Tools", icon="CONSTRAINT")
|
||||
if props.tweak_tools_section_expanded:
|
||||
tweak_box = layout.box()
|
||||
row = tweak_box.row(align=True)
|
||||
row.operator("dlm.tweak_add_arm", text="Add Arm", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.tweak_remove_arm", text="Remove Arm", icon="X")
|
||||
row.operator("dlm.tweak_bake_arm", text="Bake Arm", icon="KEYFRAME")
|
||||
row = tweak_box.row(align=True)
|
||||
row.operator("dlm.tweak_add_leg", text="Add Leg", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.tweak_remove_leg", text="Remove Leg", icon="X")
|
||||
row.operator("dlm.tweak_bake_leg", text="Bake Leg", icon="KEYFRAME")
|
||||
row = tweak_box.row(align=True)
|
||||
row.operator("dlm.tweak_add_both", text="Add Both", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.tweak_remove_both", text="Remove Both", icon="X")
|
||||
row.operator("dlm.tweak_bake_both", text="Bake Both", icon="KEYFRAME")
|
||||
row = tweak_box.row()
|
||||
row.prop(props, "tweak_nla_track_name", text="NLA track")
|
||||
row = tweak_box.row()
|
||||
row.prop(props, "tweak_bake_post_clean", text="Post-clean after bake")
|
||||
|
||||
# Linked Libraries: header row (always), main box only when expanded
|
||||
missing_count = sum(1 for lib in props.linked_libraries if lib.is_missing)
|
||||
section_icon = "DISCLOSURE_TRI_DOWN" if props.linked_libraries_section_expanded else "DISCLOSURE_TRI_RIGHT"
|
||||
row = layout.row(align=True)
|
||||
row.prop(props, "linked_libraries_section_expanded", text="", icon=section_icon, icon_only=True)
|
||||
row.label(text="Linked Libraries Analysis")
|
||||
if missing_count > 0:
|
||||
row.label(text=f"({props.linked_assets_count} libs, {missing_count} missing)", icon="ERROR")
|
||||
else:
|
||||
row.label(text=f"({props.linked_assets_count} libraries)")
|
||||
if props.linked_libraries_section_expanded:
|
||||
box = layout.box()
|
||||
row = box.row()
|
||||
row.operator("dlm.scan_linked_assets", text="Scan Linked Assets", icon="FILE_REFRESH")
|
||||
if props.linked_assets_count > 0:
|
||||
row = box.row()
|
||||
row.template_list("DLM_UL_library_list", "", props, "linked_libraries", props, "linked_libraries_index")
|
||||
row = box.row()
|
||||
row.operator("dlm.reload_libraries", text="Reload Libraries", icon="FILE_REFRESH")
|
||||
prefs = context.preferences.addons.get(__package__.rsplit(".", 1)[0])
|
||||
if prefs and prefs.preferences.search_paths:
|
||||
for i, path_item in enumerate(prefs.preferences.search_paths):
|
||||
row = box.row()
|
||||
row.prop(path_item, "path", text=f"Search path {i+1}")
|
||||
row.operator("dlm.browse_search_path", text="", icon="FILE_FOLDER").index = i
|
||||
row.operator("dlm.remove_search_path", text="", icon="REMOVE").index = i
|
||||
row = box.row()
|
||||
row.alignment = "RIGHT"
|
||||
row.operator("dlm.add_search_path", text="Add search path", icon="ADD")
|
||||
if missing_count > 0:
|
||||
row = box.row()
|
||||
row.operator("dlm.find_libraries_in_folders", text="Find libraries in these folders", icon="VIEWZOOM")
|
||||
if props.linked_libraries and 0 <= props.linked_libraries_index < len(props.linked_libraries):
|
||||
selected_lib = props.linked_libraries[props.linked_libraries_index]
|
||||
info_box = box.box()
|
||||
if selected_lib.is_missing:
|
||||
info_box.alert = True
|
||||
info_box.label(text=f"Selected: {selected_lib.name}")
|
||||
if selected_lib.is_missing:
|
||||
info_box.label(text="Status: MISSING", icon="ERROR")
|
||||
elif selected_lib.is_indirect:
|
||||
info_box.label(text="Status: INDIRECT", icon="INFO")
|
||||
else:
|
||||
info_box.label(text="Status: OK", icon="FILE_BLEND")
|
||||
info_box.label(text=f"Path: {selected_lib.filepath}", icon="FILE_FOLDER")
|
||||
info_box.operator("dlm.open_linked_file", text="Open Blend", icon="FILE_BLEND").filepath = selected_lib.filepath
|
||||
op = info_box.operator("dlm.relocate_single_library", text="Relocate Library", icon="FILE_FOLDER")
|
||||
op.target_filepath = selected_lib.filepath
|
||||
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The command executed successfully and returned the full content of the file. Let me present this to the user clearly.</details></think>
|
||||
|
||||
---
|
||||
|
||||
Here is the full content of `T:\Dynamic-Link-Manager\ui\panels.py`:
|
||||
|
||||
```python
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
import bpy
|
||||
from bpy.types import Panel, UIList
|
||||
from . import properties
|
||||
|
||||
|
||||
class DLM_UL_library_list(UIList):
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||
if self.layout_type in {"DEFAULT", "COMPACT"}:
|
||||
layout.scale_x = 0.4
|
||||
layout.label(text=item.name)
|
||||
layout.scale_x = 0.3
|
||||
if item.is_missing:
|
||||
layout.label(text="MISSING", icon="ERROR")
|
||||
elif item.is_indirect:
|
||||
layout.label(text="INDIRECT", icon="INFO")
|
||||
else:
|
||||
layout.label(text="OK", icon="FILE_BLEND")
|
||||
layout.scale_x = 0.3
|
||||
path_text = item.filepath or ""
|
||||
if path_text.startswith("\\\\"):
|
||||
parts = path_text.split("\\")
|
||||
short_path = f"\\\\{parts[2]}\\" if len(parts) >= 3 else "\\\\ (network)"
|
||||
elif len(path_text) >= 2 and path_text[1] == ":":
|
||||
short_path = f"{path_text[:2]}\\"
|
||||
elif path_text.startswith("//"):
|
||||
short_path = "// (relative)"
|
||||
else:
|
||||
short_path = path_text[:15] + "..." if len(path_text) > 15 else path_text
|
||||
layout.label(text=short_path, icon="FILE_FOLDER")
|
||||
elif self.layout_type == "GRID":
|
||||
layout.alignment = "CENTER"
|
||||
layout.label(text="", icon="FILE_BLEND")
|
||||
|
||||
|
||||
def _get_short_path(filepath):
|
||||
if not filepath:
|
||||
return "Unknown"
|
||||
if filepath.startswith("//"):
|
||||
return "// (relative)"
|
||||
if filepath.startswith("\\\\"):
|
||||
parts = filepath.split("\\")
|
||||
return f"\\\\{parts[2]}\\" if len(parts) >= 3 else "\\\\ (network)"
|
||||
if len(filepath) >= 2 and filepath[1] == ":":
|
||||
return f"{filepath[:2]}\\"
|
||||
if filepath.startswith("/"):
|
||||
parts = filepath.split("/")
|
||||
return f"/{parts[1]}/" if len(parts) >= 2 else "/ (root)"
|
||||
return "Unknown"
|
||||
|
||||
|
||||
class DLM_PT_main_panel(Panel):
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "UI"
|
||||
bl_category = "Dynamic Link Manager"
|
||||
bl_label = "Dynamic Link Manager"
|
||||
|
||||
def draw_header(self, context):
|
||||
layout = self.layout
|
||||
layout.operator("preferences.addon_show", text="", icon="PREFERENCES").module = __package__.rsplit(".", 1)[0]
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
props = context.scene.dynamic_link_manager
|
||||
|
||||
# Path management
|
||||
row = layout.row()
|
||||
row.operator("dlm.make_paths_relative", text="Make Paths Relative", icon="FILE_PARENT")
|
||||
row.operator("dlm.make_paths_absolute", text="Make Paths Absolute", icon="FILE_FOLDER")
|
||||
|
||||
# CharMig section (placeholder; full UI in implementation step 5)
|
||||
box = layout.box()
|
||||
box.label(text="Character Migrator
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session 49972a6d-d81d-4647-b6b5-9c92f8599474 (2026-03-10 16:09Z) -->
|
||||
|
||||
# Operator line modification in panels.py (2026-03-10 16:09Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
Update the file T:\Dynamic-Link-Manager\ui\panels.py by adding a new operator line.
|
||||
|
||||
Find this line (around line 87):
|
||||
```
|
||||
row.operator("dlm.run_character_migration", text="Run migration", icon="ARMATURE_DATA")
|
||||
```
|
||||
|
||||
And replace it with these two lines:
|
||||
```
|
||||
row.operator("dlm.migrator_remove_original", text="Remove Original", icon="TRASH")
|
||||
row.operator("dlm.run_character_migration", text="Run migration", icon="ARMATURE_DATA")
|
||||
```
|
||||
|
||||
Use Python to read the file, make the replacement, and write it back.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants me to update a specific file by replacing one line with two lines. I need to use Python to read the file, find the line around line 87, and make the replacement.
|
||||
|
||||
Let me first read the file to see its contents and understand the structure.</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: python -c "
|
||||
with open(r'T:\Dynamic-Link-Manager\ui\panels.py', 'r') as f:
|
||||
lines = f.readlines()
|
||||
for i, line in enumerate(lines[85:92], start=86):
|
||||
print(f'{i}|{line}', end='')
|
||||
"</summary>
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
with open(r'T:\Dynamic-Link-Manager\ui\panels.py', 'r') as f:
|
||||
lines = f.readlines()
|
||||
for i, line in enumerate(lines[85:92], start=86):
|
||||
print(f'{i}|{line}', end='')
|
||||
"
|
||||
```
|
||||
|
||||
```
|
||||
86| row = box.row()
|
||||
87| row.operator("dlm.run_character_migration", text="Run migration", icon="ARMATURE_DATA")
|
||||
88| row = box.row(align=True)
|
||||
89| row.operator("dlm.migrator_copy_attributes", text="CopyAttr", icon="COPY_ID")
|
||||
90| row.operator("dlm.migrator_migrate_nla", text="MigNLA", icon="NLA")
|
||||
91| row.operator("dlm.migrator_custom_properties", text="MigCustProps", icon="PROPERTIES")
|
||||
92| row = box.row(align=True)
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
I can see the file content. Line 87 contains the line I need to replace. Now I'll write Python code to:
|
||||
1. Read the file
|
||||
2. Replace line 87 with the two new lines
|
||||
3. Write the file back</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: python -c "
|
||||
filepath = r'T:\Dynamic-Link-Manager\ui\panels.py'
|
||||
|
||||
with open(filepath, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Line 87 is at index 86
|
||||
new_lines = [
|
||||
' row.operator(\"dlm.migrator_remove_original\", text=\"Remove Original\", icon=\"TRASH\")\n',
|
||||
' row.operator(\"dlm.run_character_migration\", text=\"Run migration\", icon=\"ARMATURE_DATA\")\n'
|
||||
]
|
||||
|
||||
# Replace line at index 86 with the two new lines
|
||||
lines[86:87] = new_lines
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
print('File updated successfully.')
|
||||
"</summary>
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
filepath = r'T:\Dynamic-Link-Manager\ui\panels.py'
|
||||
|
||||
with open(filepath, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Line 87 is at index 86
|
||||
new_lines = [
|
||||
' row.operator(\"dlm.migrator_remove_original\", text=\"Remove Original\", icon=\"TRASH\")\n',
|
||||
' row.operator(\"dlm.run_character_migration\", text=\"Run migration\", icon=\"ARMATURE_DATA\")\n'
|
||||
]
|
||||
|
||||
# Replace line at index 86 with the two new lines
|
||||
lines[86:87] = new_lines
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
print('File updated successfully.')
|
||||
"
|
||||
```
|
||||
|
||||
```
|
||||
File "<string>", line 9
|
||||
' row.operator(\
|
||||
^
|
||||
SyntaxError: unterminated string literal (detected at line 9)
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session b450a89e-cc97-4ef3-a775-fe26c81c7d0b (2026-03-10 16:09Z) -->
|
||||
|
||||
# Reading lines with whitespace representation (2026-03-10 16:09Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
Read lines 82-92 of T:\Dynamic-Link-Manager\ui\panels.py and print them with exact whitespace representation (use repr() on each line).
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants me to read lines 82-92 of a specific file and print them with exact whitespace representation using repr(). I need to use the Shell tool to execute a Python command that will read these specific lines and print them with repr() to show exact whitespace.
|
||||
|
||||
Let me execute a Python command to do this.</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: python -c "
|
||||
with open(r'T:\Dynamic-Link-Manager\ui\panels.py', 'r') as f:
|
||||
lines = f.readlines()
|
||||
for i in range(81, 92): # lines 82-92 (0-indexed: 81-91)
|
||||
if i < len(lines):
|
||||
print(f'{i+1}: {repr(lines[i])}')
|
||||
"</summary>
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
with open(r'T:\Dynamic-Link-Manager\ui\panels.py', 'r') as f:
|
||||
lines = f.readlines()
|
||||
for i in range(81, 92): # lines 82-92 (0-indexed: 81-91)
|
||||
if i < len(lines):
|
||||
print(f'{i+1}: {repr(lines[i])}')
|
||||
"
|
||||
```
|
||||
|
||||
```
|
||||
82: ' row.operator("dlm.picker_original_character", text="", icon="EYEDROPPER")\n'
|
||||
83: ' row = box.row()\n'
|
||||
84: ' row.prop(props, "replacement_character", text="Replacement")\n'
|
||||
85: ' row.operator("dlm.picker_replacement_character", text="", icon="EYEDROPPER")\n'
|
||||
86: ' row = box.row()\n'
|
||||
87: ' row.operator("dlm.run_character_migration", text="Run migration", icon="ARMATURE_DATA")\n'
|
||||
88: ' row = box.row(align=True)\n'
|
||||
89: ' row.operator("dlm.migrator_copy_attributes", text="CopyAttr", icon="COPY_ID")\n'
|
||||
90: ' row.operator("dlm.migrator_migrate_nla", text="MigNLA", icon="NLA")\n'
|
||||
91: ' row.operator("dlm.migrator_custom_properties", text="MigCustProps", icon="PROPERTIES")\n'
|
||||
92: ' row = box.row(align=True)\n'
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The command executed successfully and returned the lines 82-92 with exact whitespace representation using repr(). I can see each line with its line number and the exact representation including newline characters and any whitespace.</details></think>
|
||||
|
||||
---
|
||||
|
||||
Here are lines 82-92 of `T:\Dynamic-Link-Manager\ui\panels.py` with exact whitespace representation:
|
||||
|
||||
```
|
||||
82: ' row.operator("dlm.picker_original_character", text="", icon="EYEDROPPER")\n'
|
||||
83: ' row = box.row()\n'
|
||||
84: ' row.prop(props, "replacement_character", text="Replacement")\n'
|
||||
85: ' row.operator("dlm.picker_replacement_character", text="", icon="EYEDROPPER")\n'
|
||||
86: ' row = box.row()\n'
|
||||
87: ' row.operator("dlm.run_character_migration", text="Run migration", icon="ARMATURE_DATA")\n'
|
||||
88: ' row = box.row(align=True)\n'
|
||||
89: ' row.operator("dlm.migrator_copy_attributes", text="CopyAttr", icon="COPY_ID")\n'
|
||||
90: ' row.operator("dlm.migrator_migrate_nla", text="MigNLA", icon="NLA")\n'
|
||||
91: ' row.operator("dlm.migrator_custom_properties", text="MigCustProps", icon="PROPERTIES")\n'
|
||||
92: ' row = box.row(align=True)\n'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session cb944af7-6783-4830-acd6-af18544ff67e (2026-03-10 16:10Z) -->
|
||||
|
||||
# Verification of changes in panels.py (2026-03-10 16:10Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
Read lines 82-95 of T:\Dynamic-Link-Manager\ui\panels.py to verify the changes were applied correctly.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants me to read specific lines (82-95) from a file at T:\Dynamic-Link-Manager\ui\panels.py to verify changes were applied correctly. I need to use a file reading tool to get this content.
|
||||
|
||||
Since I don't see a specific Read tool in my available tools, I'll use the Shell tool to read the file with appropriate commands.</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: sed -n '82,95p' "T:\Dynamic-Link-Manager\ui\panels.py"</summary>
|
||||
|
||||
```bash
|
||||
sed -n '82,95p' "T:\Dynamic-Link-Manager\ui\panels.py"
|
||||
```
|
||||
|
||||
```
|
||||
sed: C:\Users\Nathan\AppData\Local\Temp\ps-script-3ec7d68b-574e-4559-a5c7-d770b9bf96d5.ps1:7
|
||||
Line |
|
||||
7 | sed -n '82,95p' "T:\Dynamic-Link-Manager\ui\panels.py"
|
||||
| ~~~
|
||||
| The term 'sed' is not recognized as a name of a cmdlet, function, script file, or executable program. Check the
|
||||
| spelling of the name, or if a path was included, verify that the path is correct and try again.
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session 3d77131c-052c-4be7-b2e4-2326abcd6957 (2026-03-10 16:15Z) -->
|
||||
|
||||
# File integrity check and code modification (2026-03-10 16:15Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
The file at t:/Dynamic-Link-Manager/ui/panels.py may be corrupted.
|
||||
|
||||
1. First, check if the file is corrupted by reading its first 100 characters. If it contains terminal output (like "head:" or PowerShell errors) instead of Python code, then it's corrupted.
|
||||
|
||||
2. If corrupted, restore it from git using: git checkout ui/panels.py
|
||||
|
||||
3. Then re-apply this change: Find the line with `row.operator("dlm.run_character_migration", text="Run migration", icon="ARMATURE_DATA")` and add BEFORE it: `row.operator("dlm.migrator_remove_original", text="Remove Original", icon="TRASH")`
|
||||
|
||||
Report back what you found and what actions you took.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants me to:
|
||||
1. Check if t:/Dynamic-Link-Manager/ui/panels.py is corrupted by reading first 100 characters
|
||||
2. If corrupted, restore from git
|
||||
3. Re-apply a specific change
|
||||
|
||||
Let me start by reading the first 100 characters of the file.</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: cd "t:/Dynamic-Link-Manager" && head -c 100 ui/panels.py</summary>
|
||||
|
||||
```bash
|
||||
cd "t:/Dynamic-Link-Manager" && head -c 100 ui/panels.py
|
||||
```
|
||||
|
||||
```
|
||||
head: C:\Users\Nathan\AppData\Local\Temp\ps-script-91b9ed9c-9402-4b22-8274-cfd7ac52481b.ps1:7
|
||||
Line |
|
||||
7 | cd "t:/Dynamic-Link-Manager" && head -c 100 ui/panels.py
|
||||
| ~~~~
|
||||
| The term 'head' is not recognized as a name of a cmdlet, function, script file, or executable program. Check the
|
||||
| spelling of the name, or if a path was included, verify that the path is correct and try again.
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
`head` is not available. Let me try using PowerShell's `Get-Content` to read the first 100 characters of the file.</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: cd "t:/Dynamic-Link-Manager" && powershell -Command "Get-Content ui/panels.py -TotalCount 1 | ForEach-Object { $_.Substring(0, [Math]::Min(100, $_.Length)) }"</summary>
|
||||
|
||||
```bash
|
||||
cd "t:/Dynamic-Link-Manager" && powershell -Command "Get-Content ui/panels.py -TotalCount 1 | ForEach-Object { $_.Substring(0, [Math]::Min(100, $_.Length)) }"
|
||||
```
|
||||
|
||||
```
|
||||
At line:1 char:89
|
||||
+ ... otalCount 1 | ForEach-Object { .Substring(0, [Math]::Min(100, .Length ...
|
||||
+ ~
|
||||
Missing expression after ','.
|
||||
At line:1 char:90
|
||||
+ ... Count 1 | ForEach-Object { .Substring(0, [Math]::Min(100, .Length)) }
|
||||
+ ~~~~~~~
|
||||
Unexpected token '.Length' in expression or statement.
|
||||
At line:1 char:89
|
||||
+ ... otalCount 1 | ForEach-Object { .Substring(0, [Math]::Min(100, .Length ...
|
||||
+ ~
|
||||
Missing closing ')' in expression.
|
||||
At line:1 char:57
|
||||
+ Get-Content ui/panels.py -TotalCount 1 | ForEach-Object { .Substring( ...
|
||||
+ ~
|
||||
Missing closing '}' in statement block or type definition.
|
||||
At line:1 char:97
|
||||
+ ... Count 1 | ForEach-Object { .Substring(0, [Math]::Min(100, .Length)) }
|
||||
+ ~
|
||||
Unexpected token ')' in expression or statement.
|
||||
At line:1 char:98
|
||||
+ ... Count 1 | ForEach-Object { .Substring(0, [Math]::Min(100, .Length)) }
|
||||
+ ~
|
||||
Unexpected token ')' in expression or statement.
|
||||
At line:1 char:100
|
||||
+ ... Count 1 | ForEach-Object { .Substring(0, [Math]::Min(100, .Length)) }
|
||||
+ ~
|
||||
Unexpected token '}' in expression or statement.
|
||||
+ CategoryInfo : ParserError: (:) [], ParentContainsErrorRecordException
|
||||
+ FullyQualifiedErrorId : MissingExpressionAfterToken
|
||||
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The PowerShell command syntax is causing issues. Let me just read the file using Python which will be more reliable.</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: cd "t:/Dynamic-Link-Manager" && python -c "with open('ui/panels.py', 'r', encoding='utf-8') as f: print(repr(f.read(100)))"</summary>
|
||||
|
||||
```bash
|
||||
cd "t:/Dynamic-Link-Manager" && python -c "with open('ui/panels.py', 'r', encoding='utf-8') as f: print(repr(f.read(100)))"
|
||||
```
|
||||
|
||||
```
|
||||
'# This program is free software; you can redistribute it and/or modify\n# it under the terms of the G'
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session 73bda206-5380-40cc-afb8-b5fc426fc0dc (2026-03-10 16:17Z) -->
|
||||
|
||||
# Class definition and inclusion verification (2026-03-10 16:17Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
Check the file T:\Dynamic-Link-Manager\ui\operators.py to verify:
|
||||
1. The DLM_OT_migrator_remove_original class is defined with correct bl_idname "dlm.migrator_remove_original"
|
||||
2. The class is included in the OPERATOR_CLASSES list at the bottom of the file
|
||||
|
||||
Print the relevant sections of the file to verify.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants me to check a specific file to verify certain code exists. Let me read the file and look for the specific class and the OPERATOR_CLASSES list.</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: rg "class DLM_OT_migrator_remove_original|bl_idname.*=.*\"dlm.migrator_remove_original\"|^OPERATOR_CLASSES|DLM_OT_migrator_remove_original" "T:\Dynamic-Link-Manager\ui\operators.py" --line-number --context 3</summary>
|
||||
|
||||
```bash
|
||||
rg "class DLM_OT_migrator_remove_original|bl_idname.*=.*\"dlm.migrator_remove_original\"|^OPERATOR_CLASSES|DLM_OT_migrator_remove_original" "T:\Dynamic-Link-Manager\ui\operators.py" --line-number --context 3
|
||||
```
|
||||
|
||||
```
|
||||
rg: C:\Users\Nathan\AppData\Local\Temp\ps-script-da8b7975-d4d1-4c9c-b469-9c0979b7c2cf.ps1:7
|
||||
Line |
|
||||
7 | rg "class DLM_OT_migrator_remove_original|bl_idname.*=.*\"dlm.migrato .
|
||||
| ~~
|
||||
| The term 'rg' is not recognized as a name of a cmdlet, function, script file, or executable program. Check the
|
||||
| spelling of the name, or if a path was included, verify that the path is correct and try again.
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
rg is not available. Let me use PowerShell's Select-String instead.</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: Select-String -Path "T:\Dynamic-Link-Manager\ui\operators.py" -Pattern "class DLM_OT_migrator_remove_original|bl_idname.*dlm\.migrator_remove_original|OPERATOR_CLASSES" -Context 2,2</summary>
|
||||
|
||||
```bash
|
||||
Select-String -Path "T:\Dynamic-Link-Manager\ui\operators.py" -Pattern "class DLM_OT_migrator_remove_original|bl_idname.*dlm\.migrator_remove_original|OPERATOR_CLASSES" -Context 2,2
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
ui\operators.py:496:
|
||||
ui\operators.py:497:
|
||||
> ui\operators.py:498:class DLM_OT_migrator_remove_original(Operator):
|
||||
> ui\operators.py:499: bl_idname = "dlm.migrator_remove_original"
|
||||
ui\operators.py:500: bl_label = "Remove Original"
|
||||
ui\operators.py:501: bl_description = "Delete the original character armature and its data from the scene"
|
||||
ui\operators.py:771:
|
||||
ui\operators.py:772:
|
||||
> ui\operators.py:773:OPERATOR_CLASSES = [
|
||||
ui\operators.py:774: DLM_OT_replace_linked_asset,
|
||||
ui\operators.py:775: DLM_OT_scan_linked_assets,
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session d613f220-0255-4158-8a5d-4f93d50aa47b (2026-03-10 16:17Z) -->
|
||||
|
||||
# Operator classes update in operators.py (2026-03-10 16:17Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
Update T:\Dynamic-Link-Manager\ui\operators.py to add DLM_OT_migrator_remove_original to the OPERATOR_CLASSES list.
|
||||
|
||||
The class should be added right before DLM_OT_run_character_migration (since that's where it appears in the UI).
|
||||
|
||||
Find the OPERATOR_CLASSES list and change:
|
||||
```
|
||||
DLM_OT_relocate_single_library,
|
||||
DLM_OT_run_character_migration,
|
||||
```
|
||||
|
||||
To:
|
||||
```
|
||||
DLM_OT_relocate_single_library,
|
||||
DLM_OT_migrator_remove_original,
|
||||
DLM_OT_run_character_migration,
|
||||
```
|
||||
|
||||
Use Python to make this edit.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants me to update the `ui/operators.py` file to add `DLM_OT_migrator_remove_original` to the `OPERATOR_CLASSES` list, specifically right before `DLM_OT_run_character_migration`.
|
||||
|
||||
Let me first read the file to find the OPERATOR_CLASSES list and then make the edit using Python.</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: cd T:\Dynamic-Link-Manager && python -c "
|
||||
import re
|
||||
|
||||
with open('ui/operators.py', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find and replace the specific pattern
|
||||
old = ''' DLM_OT_relocate_single_library,
|
||||
DLM_OT_run_character_migration,'''
|
||||
|
||||
new = ''' DLM_OT_relocate_single_library,
|
||||
DLM_OT_migrator_remove_original,
|
||||
DLM_OT_run_character_migration,'''
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
with open('ui/operators.py', 'w') as f:
|
||||
f.write(content)
|
||||
print('Successfully updated OPERATOR_CLASSES')
|
||||
else:
|
||||
print('Pattern not found - checking for alternative patterns')
|
||||
# Show context around OPERATOR_CLASSES
|
||||
match = re.search(r'OPERATOR_CLASSES.*?(?=\n\n|\Z)', content, re.DOTALL)
|
||||
if match:
|
||||
print('Found OPERATOR_CLASSES at:', match.start())
|
||||
print('Content:', match.group()[:500])
|
||||
"
|
||||
</summary>
|
||||
|
||||
```bash
|
||||
cd T:\Dynamic-Link-Manager && python -c "
|
||||
import re
|
||||
|
||||
with open('ui/operators.py', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find and replace the specific pattern
|
||||
old = ''' DLM_OT_relocate_single_library,
|
||||
DLM_OT_run_character_migration,'''
|
||||
|
||||
new = ''' DLM_OT_relocate_single_library,
|
||||
DLM_OT_migrator_remove_original,
|
||||
DLM_OT_run_character_migration,'''
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
with open('ui/operators.py', 'w') as f:
|
||||
f.write(content)
|
||||
print('Successfully updated OPERATOR_CLASSES')
|
||||
else:
|
||||
print('Pattern not found - checking for alternative patterns')
|
||||
# Show context around OPERATOR_CLASSES
|
||||
match = re.search(r'OPERATOR_CLASSES.*?(?=\n\n|\Z)', content, re.DOTALL)
|
||||
if match:
|
||||
print('Found OPERATOR_CLASSES at:', match.start())
|
||||
print('Content:', match.group()[:500])
|
||||
"
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
Successfully updated OPERATOR_CLASSES
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1339
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,905 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session 7ac38efd-6dd0-42da-9a01-f7d5062046e9 (2026-03-10 16:23Z) -->
|
||||
|
||||
# DLM_OT_migrator_remove_original class update (2026-03-10 16:23Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
Update the DLM_OT_migrator_remove_original class in T:\Dynamic-Link-Manager\ui\operators.py to delete the collection containing the original character instead of just deleting the armature object.
|
||||
|
||||
Current implementation:
|
||||
```python
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig:
|
||||
self.report({"WARNING"}, "No original character selected")
|
||||
return {"CANCELLED"}
|
||||
if orig == rep:
|
||||
self.report({"ERROR"}, "Original and replacement cannot be the same object")
|
||||
return {"CANCELLED"}
|
||||
|
||||
name = orig.name
|
||||
try:
|
||||
# Remove from scene
|
||||
bpy.data.objects.remove(orig, do_unlink=True)
|
||||
# Clear the property
|
||||
context.scene.dynamic_link_manager.original_character = None
|
||||
self.report({"INFO"}, f"Removed original character: {name}")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to remove original: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
```
|
||||
|
||||
Change it to:
|
||||
1. Import and use `collection_containing_armature` from `..utils` to find the collection
|
||||
2. Delete the collection (which removes all objects in it)
|
||||
3. Clear the original_character property
|
||||
4. Report which collection was deleted
|
||||
|
||||
If no collection is found, fall back to just deleting the armature object.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants me to update a class in a file. Let me first read the file to see the current implementation and understand the context.</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: cat "T:\Dynamic-Link-Manager\ui\operators.py"</summary>
|
||||
|
||||
```bash
|
||||
cat "T:\Dynamic-Link-Manager\ui\operators.py"
|
||||
```
|
||||
|
||||
```
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
import bpy
|
||||
import os
|
||||
from bpy.types import Operator
|
||||
from bpy.props import StringProperty, BoolProperty
|
||||
from bpy.props import StringProperty, IntProperty
|
||||
|
||||
ADDON_NAME = __package__.rsplit(".", 1)[0] if "." in __package__ else __package__
|
||||
|
||||
|
||||
def _prefs(context):
|
||||
return context.preferences.addons.get(ADDON_NAME)
|
||||
|
||||
|
||||
class DLM_OT_replace_linked_asset(Operator):
|
||||
bl_idname = "dlm.replace_linked_asset"
|
||||
bl_label = "Replace Linked Asset"
|
||||
bl_description = "Open file browser to replace the linked asset with another file"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
filepath: StringProperty(name="File Path", description="Path to the new asset file", subtype="FILE_PATH")
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
self.report({"ERROR"}, "No object selected")
|
||||
return {"CANCELLED"}
|
||||
if getattr(obj, "library", None):
|
||||
self.report({"INFO"}, f"Object '{obj.name}' is linked from: {obj.library.filepath}")
|
||||
return {"FINISHED"}
|
||||
if obj.data and getattr(obj.data, "library", None) and obj.data.library:
|
||||
self.report({"INFO"}, f"Object '{obj.name}' has linked data from: {obj.data.library.filepath}")
|
||||
return {"FINISHED"}
|
||||
if obj.type == "ARMATURE" and obj.data and obj.data.name in bpy.data.armatures:
|
||||
ad = bpy.data.armatures[obj.data.name]
|
||||
if getattr(ad, "library", None) and ad.library:
|
||||
self.report({"INFO"}, f"Armature '{obj.name}' data is linked from: {ad.library.filepath}")
|
||||
return {"FINISHED"}
|
||||
self.report({"ERROR"}, "Selected object is not a linked asset")
|
||||
return {"CANCELLED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
class DLM_OT_scan_linked_assets(Operator):
|
||||
bl_idname = "dlm.scan_linked_assets"
|
||||
bl_label = "Scan Linked Libraries"
|
||||
bl_description = "Scan the current file for linked libraries and list their status"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
from ..ops import library
|
||||
|
||||
return library.scan_linked_assets(context, self.report)
|
||||
|
||||
|
||||
class DLM_OT_find_libraries_in_folders(Operator):
|
||||
bl_idname = "dlm.find_libraries_in_folders"
|
||||
bl_label = "Find Libraries in Folders"
|
||||
bl_description = "Search addon search paths for missing library blend files"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
from ..ops import library
|
||||
|
||||
return library.find_libraries_in_folders(context, self.report, ADDON_NAME)
|
||||
|
||||
|
||||
class DLM_OT_open_linked_file(Operator):
|
||||
bl_idname = "dlm.open_linked_file"
|
||||
bl_label = "Open Linked File"
|
||||
bl_description = "Open the selected linked blend file in a new Blender instance"
|
||||
bl_options = {"REGISTER"}
|
||||
filepath: StringProperty(name="File Path", default="")
|
||||
|
||||
def execute(self, context):
|
||||
if not self.filepath:
|
||||
self.report({"ERROR"}, "No file path specified")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
bpy.ops.wm.path_open(filepath=self.filepath)
|
||||
self.report({"INFO"}, f"Opening linked file: {self.filepath}")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to open linked file: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_add_search_path(Operator):
|
||||
bl_idname = "dlm.add_search_path"
|
||||
bl_label = "Add Search Path"
|
||||
bl_description = "Add a new folder to the list of search paths for finding libraries"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
prefs = _prefs(context)
|
||||
if prefs:
|
||||
p = prefs.preferences.search_paths.add()
|
||||
p.path = "//"
|
||||
self.report({"INFO"}, f"Added search path: {p.path}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_remove_search_path(Operator):
|
||||
bl_idname = "dlm.remove_search_path"
|
||||
bl_label = "Remove Search Path"
|
||||
bl_description = "Remove the selected search path from the list"
|
||||
bl_options = {"REGISTER"}
|
||||
index: IntProperty(name="Index", default=0)
|
||||
|
||||
def execute(self, context):
|
||||
prefs = _prefs(context)
|
||||
if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
|
||||
prefs.preferences.search_paths.remove(self.index)
|
||||
self.report({"INFO"}, f"Removed search path at index {self.index}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_attempt_relink(Operator):
|
||||
bl_idname = "dlm.attempt_relink"
|
||||
bl_label = "Attempt Relink"
|
||||
bl_description = "Try to relink missing libraries using the configured search paths"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
from ..ops import library
|
||||
|
||||
return library.attempt_relink(context, self.report, ADDON_NAME)
|
||||
|
||||
|
||||
class DLM_OT_browse_search_path(Operator):
|
||||
bl_idname = "dlm.browse_search_path"
|
||||
bl_label = "Browse Search Path"
|
||||
bl_description = "Browse to set the folder for the selected search path"
|
||||
bl_options = {"REGISTER"}
|
||||
index: IntProperty(name="Index", default=0)
|
||||
filepath: StringProperty(name="Search Path", subtype="DIR_PATH")
|
||||
|
||||
def execute(self, context):
|
||||
prefs = _prefs(context)
|
||||
if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
|
||||
prefs.preferences.search_paths[self.index].path = self.filepath
|
||||
self.report({"INFO"}, f"Updated search path {self.index + 1}: {self.filepath}")
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
prefs = _prefs(context)
|
||||
if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
|
||||
self.filepath = prefs.preferences.search_paths[self.index].path
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
class DLM_OT_reload_libraries(Operator):
|
||||
bl_idname = "dlm.reload_libraries"
|
||||
bl_label = "Reload Libraries"
|
||||
bl_description = "Reload all linked libraries (or fallback manual reload)"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
bpy.ops.outliner.lib_operation(type="RELOAD")
|
||||
self.report({"INFO"}, "Library reload operation completed")
|
||||
except Exception:
|
||||
try:
|
||||
for lib in bpy.data.libraries:
|
||||
if lib.filepath and os.path.exists(bpy.path.abspath(lib.filepath)):
|
||||
lib.reload()
|
||||
self.report({"INFO"}, "Libraries reloaded manually")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to reload libraries: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_make_paths_relative(Operator):
|
||||
bl_idname = "dlm.make_paths_relative"
|
||||
bl_label = "Make Paths Relative"
|
||||
bl_description = "Convert all internal file paths to relative"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
bpy.ops.file.make_paths_relative()
|
||||
self.report({"INFO"}, "All file paths made relative")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to make paths relative: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_make_paths_absolute(Operator):
|
||||
bl_idname = "dlm.make_paths_absolute"
|
||||
bl_label = "Make Paths Absolute"
|
||||
bl_description = "Convert all internal file paths to absolute"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
bpy.ops.file.make_paths_absolute()
|
||||
self.report({"INFO"}, "All file paths made absolute")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to make paths absolute: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_relocate_single_library(Operator):
|
||||
bl_idname = "dlm.relocate_single_library"
|
||||
bl_label = "Relocate Library"
|
||||
bl_description = "Point the selected library to a new blend file and reload"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
target_filepath: StringProperty(name="Current Library Path", default="")
|
||||
filepath: StringProperty(name="New Library File", subtype="FILE_PATH", default="")
|
||||
|
||||
def execute(self, context):
|
||||
if not self.target_filepath or not self.filepath:
|
||||
self.report({"ERROR"}, "No target or new file specified")
|
||||
return {"CANCELLED"}
|
||||
abs_match = bpy.path.abspath(self.target_filepath) if self.target_filepath else ""
|
||||
library = None
|
||||
for lib in bpy.data.libraries:
|
||||
try:
|
||||
if lib.filepath and bpy.path.abspath(lib.filepath) == abs_match:
|
||||
library = lib
|
||||
break
|
||||
except Exception:
|
||||
if lib.filepath == self.target_filepath:
|
||||
library = lib
|
||||
break
|
||||
if not library:
|
||||
self.report({"ERROR"}, "Could not resolve the selected library")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
library.filepath = self.filepath
|
||||
library.reload()
|
||||
self.report({"INFO"}, f"Relocated to: {self.filepath}")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to relocate: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
if self.target_filepath:
|
||||
try:
|
||||
self.filepath = bpy.path.abspath(self.target_filepath)
|
||||
except Exception:
|
||||
self.filepath = self.target_filepath
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
def _get_migrator_pair(context):
|
||||
"""Return (orig, rep) from scene props (manual or automatic). (None, None) if invalid."""
|
||||
from ..ops.migrator import get_pair_manual, get_pair_automatic
|
||||
|
||||
props = getattr(context.scene, "dynamic_link_manager", None)
|
||||
if not props:
|
||||
return None, None
|
||||
use_auto = getattr(props, "migrator_mode", False)
|
||||
orig, rep = get_pair_automatic(context) if use_auto else get_pair_manual(context)
|
||||
return orig, rep
|
||||
|
||||
|
||||
class DLM_OT_migrator_copy_attributes(Operator):
|
||||
bl_idname = "dlm.migrator_copy_attributes"
|
||||
bl_label = "CopyAttr"
|
||||
bl_description = "Copy object and armature attributes from original to replacement character"
|
||||
bl_icon = "COPY_ID"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair (set Original/Replacement or enable Automatic).")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_copy_attr
|
||||
run_copy_attr(orig, rep)
|
||||
self.report({"INFO"}, "Copy attributes done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_migrate_nla(Operator):
|
||||
bl_idname = "dlm.migrator_migrate_nla"
|
||||
bl_label = "MigNLA"
|
||||
bl_description = "Migrate NLA tracks and strips from original to replacement character"
|
||||
bl_icon = "NLA"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_mig_nla
|
||||
run_mig_nla(orig, rep, report=self.report)
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_custom_properties(Operator):
|
||||
bl_idname = "dlm.migrator_custom_properties"
|
||||
bl_label = "MigCustProps"
|
||||
bl_description = "Copy custom properties from original to replacement character"
|
||||
bl_icon = "PROPERTIES"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_mig_cust_props
|
||||
run_mig_cust_props(orig, rep)
|
||||
self.report({"INFO"}, "Custom properties done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_bone_constraints(Operator):
|
||||
bl_idname = "dlm.migrator_bone_constraints"
|
||||
bl_label = "MigBoneConst"
|
||||
bl_description = "Migrate bone constraints from original to replacement armature"
|
||||
bl_icon = "CONSTRAINT_BONE"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_mig_bone_const
|
||||
orig_to_rep = {orig: rep}
|
||||
run_mig_bone_const(orig, rep, orig_to_rep)
|
||||
self.report({"INFO"}, "Bone constraints done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_retarget_relations(Operator):
|
||||
bl_idname = "dlm.migrator_retarget_relations"
|
||||
bl_label = "RetargRelatives"
|
||||
bl_description = "Retarget parent/child and other relations to the replacement character"
|
||||
bl_icon = "ORIENTATION_PARENT"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_retarg_relatives
|
||||
from ..utils import descendants
|
||||
rep_descendants = descendants(rep)
|
||||
orig_to_rep = {orig: rep}
|
||||
run_retarg_relatives(orig, rep, rep_descendants, orig_to_rep)
|
||||
self.report({"INFO"}, "Retarget relations done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_basebody_shapekeys(Operator):
|
||||
bl_idname = "dlm.migrator_basebody_shapekeys"
|
||||
bl_label = "MigBBodyShapeKeys"
|
||||
bl_description = "Migrate base body mesh shape key values from original to replacement"
|
||||
bl_icon = "SHAPEKEY_DATA"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_mig_bbody_shapekeys
|
||||
from ..utils import descendants
|
||||
rep_descendants = descendants(rep)
|
||||
run_mig_bbody_shapekeys(orig, rep, rep_descendants, context)
|
||||
self.report({"INFO"}, "Migrate BaseBody shapekeys done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_fk_rotations(Operator):
|
||||
bl_idname = "dlm.migrator_fk_rotations"
|
||||
bl_label = "MigFKRot"
|
||||
bl_description = "Copy FK arm and finger rotations from original to replacement (uses constraints)"
|
||||
bl_icon = "BONE_DATA"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.fk_rotations import copy_fk_rotations
|
||||
ok, msg = copy_fk_rotations(context, orig, rep)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_fk_rotations_bake(Operator):
|
||||
bl_idname = "dlm.migrator_fk_rotations_bake"
|
||||
bl_label = "Bake MigFKRot"
|
||||
bl_description = "Bake FK rotations to keyframes using nla.bake (similar to tweak tools)"
|
||||
bl_icon = "KEYFRAME"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
track_name: StringProperty(name="NLA Track", description="Optional NLA track name for frame range", default="")
|
||||
post_clean: BoolProperty(name="Post-clean", description="Clean curves after bake", default=False)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.fk_rotations import bake_fk_rotations
|
||||
ok, msg = bake_fk_rotations(context, orig, rep, track_name=self.track_name or None, post_clean=self.post_clean)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_fk_rotations_remove(Operator):
|
||||
bl_idname = "dlm.migrator_fk_rotations_remove"
|
||||
bl_label = "Remove MigFKRot"
|
||||
bl_description = "Remove FK rotation COPY_TRANSFORMS constraints (similar to tweak_remove_arm)"
|
||||
bl_icon = "X"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.fk_rotations import remove_fk_rotations
|
||||
ok, msg = remove_fk_rotations(context, rep)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
|
||||
|
||||
class DLM_OT_migrator_remove_original(Operator):
|
||||
bl_idname = "dlm.migrator_remove_original"
|
||||
bl_label = "Remove Original"
|
||||
bl_description = "Delete the original character armature and its data from the scene"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig:
|
||||
self.report({"WARNING"}, "No original character selected")
|
||||
return {"CANCELLED"}
|
||||
if orig == rep:
|
||||
self.report({"ERROR"}, "Original and replacement cannot be the same object")
|
||||
return {"CANCELLED"}
|
||||
|
||||
name = orig.name
|
||||
try:
|
||||
# Remove from scene
|
||||
bpy.data.objects.remove(orig, do_unlink=True)
|
||||
# Clear the property
|
||||
context.scene.dynamic_link_manager.original_character = None
|
||||
self.report({"INFO"}, f"Removed original character: {name}")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to remove original: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
|
||||
class DLM_OT_picker_original_character(Operator):
|
||||
bl_idname = "dlm.picker_original_character"
|
||||
bl_label = "Pick Original"
|
||||
bl_description = "Set the original character armature from the active object"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
if not obj or obj.type != "ARMATURE":
|
||||
self.report({"WARNING"}, "Select an armature")
|
||||
return {"CANCELLED"}
|
||||
context.scene.dynamic_link_manager.original_character = obj
|
||||
self.report({"INFO"}, f"Original: {obj.name}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_picker_replacement_character(Operator):
|
||||
bl_idname = "dlm.picker_replacement_character"
|
||||
bl_label = "Pick Replacement"
|
||||
bl_description = "Set the replacement character armature from the active object"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
if not obj or obj.type != "ARMATURE":
|
||||
self.report({"WARNING"}, "Select an armature")
|
||||
return {"CANCELLED"}
|
||||
context.scene.dynamic_link_manager.replacement_character = obj
|
||||
self.report({"INFO"}, f"Replacement: {obj.name}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def _tweak_poll(context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
return orig is not None and rep is not None
|
||||
|
||||
|
||||
class DLM_OT_tweak_add_arm(Operator):
|
||||
bl_idname = "dlm.tweak_add_arm"
|
||||
bl_label = "Add Arm Tweaks"
|
||||
bl_description = "Add tweak bone constraints to arm bones on the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
tweak_tools.add_tweak_constraints(orig, rep, "arm")
|
||||
self.report({"INFO"}, "Arm tweak constraints added.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_remove_arm(Operator):
|
||||
bl_idname = "dlm.tweak_remove_arm"
|
||||
bl_label = "Remove Arm Tweaks"
|
||||
bl_description = "Remove arm tweak constraints from the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
n = tweak_tools.remove_tweak_constraints(orig, rep, "arm")
|
||||
self.report({"INFO"}, f"Removed {n} arm tweak constraints.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_bake_arm(Operator):
|
||||
bl_idname = "dlm.tweak_bake_arm"
|
||||
bl_label = "Bake Arm Tweaks"
|
||||
bl_description = "Bake arm tweak constraints to keyframes and optionally remove constraints"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
props = context.scene.dynamic_link_manager
|
||||
from ..ops import tweak_tools
|
||||
ok, msg = tweak_tools.bake_tweak_constraints(
|
||||
context, orig, rep, "arm",
|
||||
getattr(props, "tweak_nla_track_name", "") or "",
|
||||
getattr(props, "tweak_bake_post_clean", False),
|
||||
)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_add_leg(Operator):
|
||||
bl_idname = "dlm.tweak_add_leg"
|
||||
bl_label = "Add Leg Tweaks"
|
||||
bl_description = "Add tweak bone constraints to leg bones on the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
tweak_tools.add_tweak_constraints(orig, rep, "leg")
|
||||
self.report({"INFO"}, "Leg tweak constraints added.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_remove_leg(Operator):
|
||||
bl_idname = "dlm.tweak_remove_leg"
|
||||
bl_label = "Remove Leg Tweaks"
|
||||
bl_description = "Remove leg tweak constraints from the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
n = tweak_tools.remove_tweak_constraints(orig, rep, "leg")
|
||||
self.report({"INFO"}, f"Removed {n} leg tweak constraints.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_bake_leg(Operator):
|
||||
bl_idname = "dlm.tweak_bake_leg"
|
||||
bl_label = "Bake Leg Tweaks"
|
||||
bl_description = "Bake leg tweak constraints to keyframes and optionally remove constraints"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
props = context.scene.dynamic_link_manager
|
||||
from ..ops import tweak_tools
|
||||
ok, msg = tweak_tools.bake_tweak_constraints(
|
||||
context, orig, rep, "leg",
|
||||
getattr(props, "tweak_nla_track_name", "") or "",
|
||||
getattr(props, "tweak_bake_post_clean", False),
|
||||
)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_add_both(Operator):
|
||||
bl_idname = "dlm.tweak_add_both"
|
||||
bl_label = "Add Arm & Leg Tweaks"
|
||||
bl_description = "Add tweak bone constraints to both arm and leg bones"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
tweak_tools.add_tweak_constraints(orig, rep, "both")
|
||||
self.report({"INFO"}, "Arm & leg tweak constraints added.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_remove_both(Operator):
|
||||
bl_idname = "dlm.tweak_remove_both"
|
||||
bl_label = "Remove Arm & Leg Tweaks"
|
||||
bl_description = "Remove all arm and leg tweak constraints from the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
n = tweak_tools.remove_tweak_constraints(orig, rep, "both")
|
||||
self.report({"INFO"}, f"Removed {n} tweak constraints.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_bake_both(Operator):
|
||||
bl_idname = "dlm.tweak_bake_both"
|
||||
bl_label = "Bake Arm & Leg Tweaks"
|
||||
bl_description = "Bake all arm and leg tweak constraints to keyframes and optionally remove constraints"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
props = context.scene.dynamic_link_manager
|
||||
from ..ops import tweak_tools
|
||||
ok, msg = tweak_tools.bake_tweak_constraints(
|
||||
context, orig, rep, "both",
|
||||
getattr(props, "tweak_nla_track_name", "") or "",
|
||||
getattr(props, "tweak_bake_post_clean", False),
|
||||
)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
OPERATOR_CLASSES = [
|
||||
DLM_OT_replace_linked_asset,
|
||||
DLM_OT_scan_linked_assets,
|
||||
DLM_OT_find_libraries_in_folders,
|
||||
DLM_OT_open_linked_file,
|
||||
DLM_OT_add_search_path,
|
||||
DLM_OT_remove_search_path,
|
||||
DLM_OT_browse_search_path,
|
||||
DLM_OT_attempt_relink,
|
||||
DLM_OT_reload_libraries,
|
||||
DLM_OT_make_paths_relative,
|
||||
DLM_OT_make_paths_absolute,
|
||||
DLM_OT_relocate_single_library,
|
||||
DLM_OT_migrator_remove_original,
|
||||
DLM_OT_picker_original_character,
|
||||
DLM_OT_picker_replacement_character,
|
||||
DLM_OT_migrator_copy_attributes,
|
||||
DLM_OT_migrator_migrate_nla,
|
||||
DLM_OT_migrator_custom_properties,
|
||||
DLM_OT_migrator_bone_constraints,
|
||||
DLM_OT_migrator_retarget_relations,
|
||||
DLM_OT_migrator_basebody_shapekeys,
|
||||
DLM_OT_tweak_add_arm,
|
||||
DLM_OT_tweak_remove_arm,
|
||||
DLM_OT_tweak_bake_arm,
|
||||
DLM_OT_tweak_add_leg,
|
||||
DLM_OT_tweak_remove_leg,
|
||||
DLM_OT_tweak_bake_leg,
|
||||
DLM_OT_tweak_add_both,
|
||||
DLM_OT_tweak_remove_both,
|
||||
DLM_OT_tweak_bake_both,
|
||||
DLM_OT_migrator_fk_rotations,
|
||||
DLM_OT_migrator_fk_rotations_bake,
|
||||
DLM_OT_migrator_fk_rotations_remove,
|
||||
]
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: cat "T:\Dynamic-Link-Manager\utils\__init__.py"</summary>
|
||||
|
||||
```bash
|
||||
cat "T:\Dynamic-Link-Manager\utils\__init__.py"
|
||||
```
|
||||
|
||||
```
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
"""Shared helpers for Dynamic Link Manager."""
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def descendants(armature):
|
||||
"""Return a set of objects whose parent chain leads to the given armature."""
|
||||
out = set()
|
||||
for ob in bpy.data.objects:
|
||||
p = ob.parent
|
||||
while p:
|
||||
if p == armature:
|
||||
out.add(ob)
|
||||
break
|
||||
p = p.parent
|
||||
return out
|
||||
|
||||
|
||||
def collection_containing_armature(armature):
|
||||
"""
|
||||
Return a collection that contains the armature (for linked character context).
|
||||
Prefers a collection whose name matches the character base (e.g. "Steve" for Steve_Rigify).
|
||||
"""
|
||||
if not armature or armature.name not in bpy.data.objects:
|
||||
return None
|
||||
colls = getattr(armature, "users_collection", []) or []
|
||||
if not colls:
|
||||
return None
|
||||
name = armature.name
|
||||
base = name.replace("_Rigify", "").replace(".001", "").rstrip("0123456789.")
|
||||
for c in colls:
|
||||
if c.name == base or base in c.name or c.name in name:
|
||||
return c
|
||||
return colls[0]
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
I found the `collection_containing_armature` function in the utils module. Now I need to find the exact line numbers for the `DLM_OT</details></think>
|
||||
|
||||
---
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+83
@@ -0,0 +1,83 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session daaa74ba-72d7-4686-a95b-ae6379d17ee9 (2026-03-10 16:25Z) -->
|
||||
|
||||
# DLM_OT_migrator_remove_original operator error handling (2026-03-10 16:25Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
The DLM_OT_migrator_remove_original operator in T:\Dynamic-Link-Manager\ui\operators.py is failing with "StructRNA of type Collection has been removed" error.
|
||||
|
||||
This likely happens because:
|
||||
1. The collection might be linked or an override that can't be removed directly
|
||||
2. Other addons (like Animation Layers) might be holding references
|
||||
|
||||
Update the operator to:
|
||||
1. First check if the collection is local (not linked) before trying to remove it
|
||||
2. If collection removal fails, catch the specific error and fall back to removing just the objects in the collection
|
||||
3. Handle the case where the collection is already being processed
|
||||
|
||||
Here's the improved logic:
|
||||
```python
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig:
|
||||
self.report({"WARNING"}, "No original character selected")
|
||||
return {"CANCELLED"}
|
||||
if orig == rep:
|
||||
self.report({"ERROR"}, "Original and replacement cannot be the same object")
|
||||
return {"CANCELLED"}
|
||||
|
||||
name = orig.name
|
||||
try:
|
||||
# Try to find the collection containing the original character
|
||||
coll = collection_containing_armature(orig)
|
||||
if coll and not coll.library: # Only remove local collections
|
||||
# Get all objects in the collection before removing it
|
||||
objects_to_remove = list(coll.objects)
|
||||
# Remove the collection first
|
||||
bpy.data.collections.remove(coll)
|
||||
# Then remove the objects
|
||||
for obj in objects_to_remove:
|
||||
if obj and obj.name in bpy.data.objects:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
context.scene.dynamic_link_manager.original_character = None
|
||||
self.report({"INFO"}, f"Removed character collection: {coll.name}")
|
||||
else:
|
||||
# Fallback: just delete the armature object
|
||||
if orig.name in bpy.data.objects:
|
||||
bpy.data.objects.remove(orig, do_unlink=True)
|
||||
context.scene.dynamic_link_manager.original_character = None
|
||||
if coll:
|
||||
self.report({"INFO"}, f"Removed original (linked collection cannot be deleted): {name}")
|
||||
else:
|
||||
self.report({"INFO"}, f"Removed original character: {name}")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to remove original: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
```
|
||||
|
||||
Make this change to the file.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants me to update the `DLM_OT_migrator_remove_original` operator in `T:\Dynamic-Link-Manager\ui\operators.py`. Let me first read the file to see the current implementation and then make the requested changes.</details></think>
|
||||
|
||||
---
|
||||
|
||||
I'll read the current file to see the existing implementation of the operator.
|
||||
|
||||
---
|
||||
|
||||
Cancelled
|
||||
|
||||
---
|
||||
|
||||
+926
@@ -0,0 +1,926 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session 7d4946a5-6831-4e4c-90f7-8e9a96fa62d5 (2026-03-10 17:03Z) -->
|
||||
|
||||
# Operator update for DLM_OT_migrator_remove_original (2026-03-10 17:03Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
Update the DLM_OT_migrator_remove_original operator in T:\Dynamic-Link-Manager\ui\operators.py to:
|
||||
|
||||
1. **Purge actions from the original character**: Before removing the collection, find all actions associated with the original character (through animation_data, NLA strips, etc.) and remove them from bpy.data.actions
|
||||
|
||||
2. **Remove .rep suffix from replacement actions**: After the original is removed, find all actions on the replacement character that have ".rep" in their name and rename them to remove that suffix.
|
||||
|
||||
The current execute method structure:
|
||||
```python
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig:
|
||||
self.report({"WARNING"}, "No original character selected")
|
||||
return {"CANCELLED"}
|
||||
if orig == rep:
|
||||
self.report({"ERROR"}, "Original and replacement cannot be the same object")
|
||||
return {"CANCELLED"}
|
||||
|
||||
name = orig.name
|
||||
try:
|
||||
# Try to find and delete the collection containing the original character
|
||||
coll = collection_containing_armature(orig)
|
||||
if coll:
|
||||
coll_name = coll.name
|
||||
context.scene.dynamic_link_manager.original_character = None
|
||||
try:
|
||||
bpy.data.collections.remove(coll)
|
||||
self.report({"INFO"}, f"Removed collection: {coll_name}")
|
||||
except Exception as remove_err:
|
||||
self.report({"WARNING"}, f"Collection {coll_name} removal issue: {remove_err}")
|
||||
else:
|
||||
# Fallback: just delete the armature object
|
||||
bpy.data.objects.remove(orig, do_unlink=True)
|
||||
context.scene.dynamic_link_manager.original_character = None
|
||||
self.report({"INFO"}, f"Removed original character: {name}")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to remove original: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
```
|
||||
|
||||
Add logic to:
|
||||
1. Before removing the collection, iterate through the original's animation_data (if any) and collect all actions from NLA strips and the active action
|
||||
2. Remove those actions from bpy.data.actions
|
||||
3. After removing the original, iterate through the replacement's animation_data and find actions with ".rep" suffix
|
||||
4. Rename those actions to remove the ".rep" suffix
|
||||
|
||||
Make sure to handle cases where animation_data might be None.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants me to update an operator in a Blender addon. Let me first read the current file to understand the context and find the `DLM_OT_migrator_remove_original` operator.</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: cat "T:\Dynamic-Link-Manager\ui\operators.py"</summary>
|
||||
|
||||
```bash
|
||||
cat "T:\Dynamic-Link-Manager\ui\operators.py"
|
||||
```
|
||||
|
||||
```
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
import bpy
|
||||
import os
|
||||
from bpy.types import Operator
|
||||
from bpy.props import StringProperty, BoolProperty
|
||||
from bpy.props import StringProperty, IntProperty
|
||||
|
||||
from ..utils import collection_containing_armature
|
||||
|
||||
ADDON_NAME = __package__.rsplit(".", 1)[0] if "." in __package__ else __package__
|
||||
|
||||
|
||||
def _prefs(context):
|
||||
return context.preferences.addons.get(ADDON_NAME)
|
||||
|
||||
|
||||
class DLM_OT_replace_linked_asset(Operator):
|
||||
bl_idname = "dlm.replace_linked_asset"
|
||||
bl_label = "Replace Linked Asset"
|
||||
bl_description = "Open file browser to replace the linked asset with another file"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
filepath: StringProperty(name="File Path", description="Path to the new asset file", subtype="FILE_PATH")
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
self.report({"ERROR"}, "No object selected")
|
||||
return {"CANCELLED"}
|
||||
if getattr(obj, "library", None):
|
||||
self.report({"INFO"}, f"Object '{obj.name}' is linked from: {obj.library.filepath}")
|
||||
return {"FINISHED"}
|
||||
if obj.data and getattr(obj.data, "library", None) and obj.data.library:
|
||||
self.report({"INFO"}, f"Object '{obj.name}' has linked data from: {obj.data.library.filepath}")
|
||||
return {"FINISHED"}
|
||||
if obj.type == "ARMATURE" and obj.data and obj.data.name in bpy.data.armatures:
|
||||
ad = bpy.data.armatures[obj.data.name]
|
||||
if getattr(ad, "library", None) and ad.library:
|
||||
self.report({"INFO"}, f"Armature '{obj.name}' data is linked from: {ad.library.filepath}")
|
||||
return {"FINISHED"}
|
||||
self.report({"ERROR"}, "Selected object is not a linked asset")
|
||||
return {"CANCELLED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
class DLM_OT_scan_linked_assets(Operator):
|
||||
bl_idname = "dlm.scan_linked_assets"
|
||||
bl_label = "Scan Linked Libraries"
|
||||
bl_description = "Scan the current file for linked libraries and list their status"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
from ..ops import library
|
||||
|
||||
return library.scan_linked_assets(context, self.report)
|
||||
|
||||
|
||||
class DLM_OT_find_libraries_in_folders(Operator):
|
||||
bl_idname = "dlm.find_libraries_in_folders"
|
||||
bl_label = "Find Libraries in Folders"
|
||||
bl_description = "Search addon search paths for missing library blend files"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
from ..ops import library
|
||||
|
||||
return library.find_libraries_in_folders(context, self.report, ADDON_NAME)
|
||||
|
||||
|
||||
class DLM_OT_open_linked_file(Operator):
|
||||
bl_idname = "dlm.open_linked_file"
|
||||
bl_label = "Open Linked File"
|
||||
bl_description = "Open the selected linked blend file in a new Blender instance"
|
||||
bl_options = {"REGISTER"}
|
||||
filepath: StringProperty(name="File Path", default="")
|
||||
|
||||
def execute(self, context):
|
||||
if not self.filepath:
|
||||
self.report({"ERROR"}, "No file path specified")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
bpy.ops.wm.path_open(filepath=self.filepath)
|
||||
self.report({"INFO"}, f"Opening linked file: {self.filepath}")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to open linked file: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_add_search_path(Operator):
|
||||
bl_idname = "dlm.add_search_path"
|
||||
bl_label = "Add Search Path"
|
||||
bl_description = "Add a new folder to the list of search paths for finding libraries"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
prefs = _prefs(context)
|
||||
if prefs:
|
||||
p = prefs.preferences.search_paths.add()
|
||||
p.path = "//"
|
||||
self.report({"INFO"}, f"Added search path: {p.path}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_remove_search_path(Operator):
|
||||
bl_idname = "dlm.remove_search_path"
|
||||
bl_label = "Remove Search Path"
|
||||
bl_description = "Remove the selected search path from the list"
|
||||
bl_options = {"REGISTER"}
|
||||
index: IntProperty(name="Index", default=0)
|
||||
|
||||
def execute(self, context):
|
||||
prefs = _prefs(context)
|
||||
if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
|
||||
prefs.preferences.search_paths.remove(self.index)
|
||||
self.report({"INFO"}, f"Removed search path at index {self.index}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_attempt_relink(Operator):
|
||||
bl_idname = "dlm.attempt_relink"
|
||||
bl_label = "Attempt Relink"
|
||||
bl_description = "Try to relink missing libraries using the configured search paths"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
from ..ops import library
|
||||
|
||||
return library.attempt_relink(context, self.report, ADDON_NAME)
|
||||
|
||||
|
||||
class DLM_OT_browse_search_path(Operator):
|
||||
bl_idname = "dlm.browse_search_path"
|
||||
bl_label = "Browse Search Path"
|
||||
bl_description = "Browse to set the folder for the selected search path"
|
||||
bl_options = {"REGISTER"}
|
||||
index: IntProperty(name="Index", default=0)
|
||||
filepath: StringProperty(name="Search Path", subtype="DIR_PATH")
|
||||
|
||||
def execute(self, context):
|
||||
prefs = _prefs(context)
|
||||
if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
|
||||
prefs.preferences.search_paths[self.index].path = self.filepath
|
||||
self.report({"INFO"}, f"Updated search path {self.index + 1}: {self.filepath}")
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
prefs = _prefs(context)
|
||||
if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
|
||||
self.filepath = prefs.preferences.search_paths[self.index].path
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
class DLM_OT_reload_libraries(Operator):
|
||||
bl_idname = "dlm.reload_libraries"
|
||||
bl_label = "Reload Libraries"
|
||||
bl_description = "Reload all linked libraries (or fallback manual reload)"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
bpy.ops.outliner.lib_operation(type="RELOAD")
|
||||
self.report({"INFO"}, "Library reload operation completed")
|
||||
except Exception:
|
||||
try:
|
||||
for lib in bpy.data.libraries:
|
||||
if lib.filepath and os.path.exists(bpy.path.abspath(lib.filepath)):
|
||||
lib.reload()
|
||||
self.report({"INFO"}, "Libraries reloaded manually")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to reload libraries: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_make_paths_relative(Operator):
|
||||
bl_idname = "dlm.make_paths_relative"
|
||||
bl_label = "Make Paths Relative"
|
||||
bl_description = "Convert all internal file paths to relative"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
bpy.ops.file.make_paths_relative()
|
||||
self.report({"INFO"}, "All file paths made relative")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to make paths relative: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_make_paths_absolute(Operator):
|
||||
bl_idname = "dlm.make_paths_absolute"
|
||||
bl_label = "Make Paths Absolute"
|
||||
bl_description = "Convert all internal file paths to absolute"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
bpy.ops.file.make_paths_absolute()
|
||||
self.report({"INFO"}, "All file paths made absolute")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to make paths absolute: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_relocate_single_library(Operator):
|
||||
bl_idname = "dlm.relocate_single_library"
|
||||
bl_label = "Relocate Library"
|
||||
bl_description = "Point the selected library to a new blend file and reload"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
target_filepath: StringProperty(name="Current Library Path", default="")
|
||||
filepath: StringProperty(name="New Library File", subtype="FILE_PATH", default="")
|
||||
|
||||
def execute(self, context):
|
||||
if not self.target_filepath or not self.filepath:
|
||||
self.report({"ERROR"}, "No target or new file specified")
|
||||
return {"CANCELLED"}
|
||||
abs_match = bpy.path.abspath(self.target_filepath) if self.target_filepath else ""
|
||||
library = None
|
||||
for lib in bpy.data.libraries:
|
||||
try:
|
||||
if lib.filepath and bpy.path.abspath(lib.filepath) == abs_match:
|
||||
library = lib
|
||||
break
|
||||
except Exception:
|
||||
if lib.filepath == self.target_filepath:
|
||||
library = lib
|
||||
break
|
||||
if not library:
|
||||
self.report({"ERROR"}, "Could not resolve the selected library")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
library.filepath = self.filepath
|
||||
library.reload()
|
||||
self.report({"INFO"}, f"Relocated to: {self.filepath}")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to relocate: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
if self.target_filepath:
|
||||
try:
|
||||
self.filepath = bpy.path.abspath(self.target_filepath)
|
||||
except Exception:
|
||||
self.filepath = self.target_filepath
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
def _get_migrator_pair(context):
|
||||
"""Return (orig, rep) from scene props (manual or automatic). (None, None) if invalid."""
|
||||
from ..ops.migrator import get_pair_manual, get_pair_automatic
|
||||
|
||||
props = getattr(context.scene, "dynamic_link_manager", None)
|
||||
if not props:
|
||||
return None, None
|
||||
use_auto = getattr(props, "migrator_mode", False)
|
||||
orig, rep = get_pair_automatic(context) if use_auto else get_pair_manual(context)
|
||||
return orig, rep
|
||||
|
||||
|
||||
class DLM_OT_migrator_copy_attributes(Operator):
|
||||
bl_idname = "dlm.migrator_copy_attributes"
|
||||
bl_label = "CopyAttr"
|
||||
bl_description = "Copy object and armature attributes from original to replacement character"
|
||||
bl_icon = "COPY_ID"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair (set Original/Replacement or enable Automatic).")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_copy_attr
|
||||
run_copy_attr(orig, rep)
|
||||
self.report({"INFO"}, "Copy attributes done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_migrate_nla(Operator):
|
||||
bl_idname = "dlm.migrator_migrate_nla"
|
||||
bl_label = "MigNLA"
|
||||
bl_description = "Migrate NLA tracks and strips from original to replacement character"
|
||||
bl_icon = "NLA"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_mig_nla
|
||||
run_mig_nla(orig, rep, report=self.report)
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_custom_properties(Operator):
|
||||
bl_idname = "dlm.migrator_custom_properties"
|
||||
bl_label = "MigCustProps"
|
||||
bl_description = "Copy custom properties from original to replacement character"
|
||||
bl_icon = "PROPERTIES"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_mig_cust_props
|
||||
run_mig_cust_props(orig, rep)
|
||||
self.report({"INFO"}, "Custom properties done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_bone_constraints(Operator):
|
||||
bl_idname = "dlm.migrator_bone_constraints"
|
||||
bl_label = "MigBoneConst"
|
||||
bl_description = "Migrate bone constraints from original to replacement armature"
|
||||
bl_icon = "CONSTRAINT_BONE"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_mig_bone_const
|
||||
orig_to_rep = {orig: rep}
|
||||
run_mig_bone_const(orig, rep, orig_to_rep)
|
||||
self.report({"INFO"}, "Bone constraints done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_retarget_relations(Operator):
|
||||
bl_idname = "dlm.migrator_retarget_relations"
|
||||
bl_label = "RetargRelatives"
|
||||
bl_description = "Retarget parent/child and other relations to the replacement character"
|
||||
bl_icon = "ORIENTATION_PARENT"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_retarg_relatives
|
||||
from ..utils import descendants
|
||||
rep_descendants = descendants(rep)
|
||||
orig_to_rep = {orig: rep}
|
||||
run_retarg_relatives(orig, rep, rep_descendants, orig_to_rep)
|
||||
self.report({"INFO"}, "Retarget relations done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_basebody_shapekeys(Operator):
|
||||
bl_idname = "dlm.migrator_basebody_shapekeys"
|
||||
bl_label = "MigBBodyShapeKeys"
|
||||
bl_description = "Migrate base body mesh shape key values from original to replacement"
|
||||
bl_icon = "SHAPEKEY_DATA"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_mig_bbody_shapekeys
|
||||
from ..utils import descendants
|
||||
rep_descendants = descendants(rep)
|
||||
run_mig_bbody_shapekeys(orig, rep, rep_descendants, context)
|
||||
self.report({"INFO"}, "Migrate BaseBody shapekeys done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_fk_rotations(Operator):
|
||||
bl_idname = "dlm.migrator_fk_rotations"
|
||||
bl_label = "MigFKRot"
|
||||
bl_description = "Copy FK arm and finger rotations from original to replacement (uses constraints)"
|
||||
bl_icon = "BONE_DATA"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.fk_rotations import copy_fk_rotations
|
||||
ok, msg = copy_fk_rotations(context, orig, rep)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_fk_rotations_bake(Operator):
|
||||
bl_idname = "dlm.migrator_fk_rotations_bake"
|
||||
bl_label = "Bake MigFKRot"
|
||||
bl_description = "Bake FK rotations to keyframes using nla.bake (similar to tweak tools)"
|
||||
bl_icon = "KEYFRAME"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
track_name: StringProperty(name="NLA Track", description="Optional NLA track name for frame range", default="")
|
||||
post_clean: BoolProperty(name="Post-clean", description="Clean curves after bake", default=False)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.fk_rotations import bake_fk_rotations
|
||||
ok, msg = bake_fk_rotations(context, orig, rep, track_name=self.track_name or None, post_clean=self.post_clean)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_fk_rotations_remove(Operator):
|
||||
bl_idname = "dlm.migrator_fk_rotations_remove"
|
||||
bl_label = "Remove MigFKRot"
|
||||
bl_description = "Remove FK rotation COPY_TRANSFORMS constraints (similar to tweak_remove_arm)"
|
||||
bl_icon = "X"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.fk_rotations import remove_fk_rotations
|
||||
ok, msg = remove_fk_rotations(context, rep)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
|
||||
|
||||
class DLM_OT_migrator_remove_original(Operator):
|
||||
bl_idname = "dlm.migrator_remove_original"
|
||||
bl_label = "Remove Original"
|
||||
bl_description = "Delete the original character armature and its data from the scene"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig:
|
||||
self.report({"WARNING"}, "No original character selected")
|
||||
return {"CANCELLED"}
|
||||
if orig == rep:
|
||||
self.report({"ERROR"}, "Original and replacement cannot be the same object")
|
||||
return {"CANCELLED"}
|
||||
|
||||
name = orig.name
|
||||
try:
|
||||
# Try to find and delete the collection containing the original character
|
||||
coll = collection_containing_armature(orig)
|
||||
if coll:
|
||||
coll_name = coll.name # Store name BEFORE removal (RNA invalidates after remove)
|
||||
context.scene.dynamic_link_manager.original_character = None
|
||||
try:
|
||||
bpy.data.collections.remove(coll)
|
||||
self.report({"INFO"}, f"Removed collection: {coll_name}")
|
||||
except Exception as remove_err:
|
||||
# Collection may have already been removed by another process
|
||||
self.report({"WARNING"}, f"Collection {coll_name} removal issue: {remove_err}")
|
||||
else:
|
||||
# Fallback: just delete the armature object
|
||||
bpy.data.objects.remove(orig, do_unlink=True)
|
||||
context.scene.dynamic_link_manager.original_character = None
|
||||
self.report({"INFO"}, f"Removed original character: {name}")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to remove original: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
|
||||
class DLM_OT_picker_original_character(Operator):
|
||||
bl_idname = "dlm.picker_original_character"
|
||||
bl_label = "Pick Original"
|
||||
bl_description = "Set the original character armature from the active object"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
if not obj or obj.type != "ARMATURE":
|
||||
self.report({"WARNING"}, "Select an armature")
|
||||
return {"CANCELLED"}
|
||||
context.scene.dynamic_link_manager.original_character = obj
|
||||
self.report({"INFO"}, f"Original: {obj.name}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_picker_replacement_character(Operator):
|
||||
bl_idname = "dlm.picker_replacement_character"
|
||||
bl_label = "Pick Replacement"
|
||||
bl_description = "Set the replacement character armature from the active object"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
if not obj or obj.type != "ARMATURE":
|
||||
self.report({"WARNING"}, "Select an armature")
|
||||
return {"CANCELLED"}
|
||||
context.scene.dynamic_link_manager.replacement_character = obj
|
||||
self.report({"INFO"}, f"Replacement: {obj.name}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def _tweak_poll(context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
return orig is not None and rep is not None
|
||||
|
||||
|
||||
class DLM_OT_tweak_add_arm(Operator):
|
||||
bl_idname = "dlm.tweak_add_arm"
|
||||
bl_label = "Add Arm Tweaks"
|
||||
bl_description = "Add tweak bone constraints to arm bones on the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
tweak_tools.add_tweak_constraints(orig, rep, "arm")
|
||||
self.report({"INFO"}, "Arm tweak constraints added.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_remove_arm(Operator):
|
||||
bl_idname = "dlm.tweak_remove_arm"
|
||||
bl_label = "Remove Arm Tweaks"
|
||||
bl_description = "Remove arm tweak constraints from the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
n = tweak_tools.remove_tweak_constraints(orig, rep, "arm")
|
||||
self.report({"INFO"}, f"Removed {n} arm tweak constraints.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_bake_arm(Operator):
|
||||
bl_idname = "dlm.tweak_bake_arm"
|
||||
bl_label = "Bake Arm Tweaks"
|
||||
bl_description = "Bake arm tweak constraints to keyframes and optionally remove constraints"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
props = context.scene.dynamic_link_manager
|
||||
from ..ops import tweak_tools
|
||||
ok, msg = tweak_tools.bake_tweak_constraints(
|
||||
context, orig, rep, "arm",
|
||||
getattr(props, "tweak_nla_track_name", "") or "",
|
||||
getattr(props, "tweak_bake_post_clean", False),
|
||||
)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_add_leg(Operator):
|
||||
bl_idname = "dlm.tweak_add_leg"
|
||||
bl_label = "Add Leg Tweaks"
|
||||
bl_description = "Add tweak bone constraints to leg bones on the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
tweak_tools.add_tweak_constraints(orig, rep, "leg")
|
||||
self.report({"INFO"}, "Leg tweak constraints added.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_remove_leg(Operator):
|
||||
bl_idname = "dlm.tweak_remove_leg"
|
||||
bl_label = "Remove Leg Tweaks"
|
||||
bl_description = "Remove leg tweak constraints from the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
n = tweak_tools.remove_tweak_constraints(orig, rep, "leg")
|
||||
self.report({"INFO"}, f"Removed {n} leg tweak constraints.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_bake_leg(Operator):
|
||||
bl_idname = "dlm.tweak_bake_leg"
|
||||
bl_label = "Bake Leg Tweaks"
|
||||
bl_description = "Bake leg tweak constraints to keyframes and optionally remove constraints"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
props = context.scene.dynamic_link_manager
|
||||
from ..ops import tweak_tools
|
||||
ok, msg = tweak_tools.bake_tweak_constraints(
|
||||
context, orig, rep, "leg",
|
||||
getattr(props, "tweak_nla_track_name", "") or "",
|
||||
getattr(props, "tweak_bake_post_clean", False),
|
||||
)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_add_both(Operator):
|
||||
bl_idname = "dlm.tweak_add_both"
|
||||
bl_label = "Add Arm & Leg Tweaks"
|
||||
bl_description = "Add tweak bone constraints to both arm and leg bones"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
tweak_tools.add_tweak_constraints(orig, rep, "both")
|
||||
self.report({"INFO"}, "Arm & leg tweak constraints added.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_remove_both(Operator):
|
||||
bl_idname = "dlm.tweak_remove_both"
|
||||
bl_label = "Remove Arm & Leg Tweaks"
|
||||
bl_description = "Remove all arm and leg tweak constraints from the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
n = tweak_tools.remove_tweak_constraints(orig, rep, "both")
|
||||
self.report({"INFO"}, f"Removed {n} tweak constraints.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_bake_both(Operator):
|
||||
bl_idname = "dlm.tweak_bake_both"
|
||||
bl_label = "Bake Arm & Leg Tweaks"
|
||||
bl_description = "Bake all arm and leg tweak constraints to keyframes and optionally remove constraints"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
props = context.scene.dynamic_link_manager
|
||||
from ..ops import tweak_tools
|
||||
ok, msg = tweak_tools.bake_tweak_constraints(
|
||||
context, orig, rep, "both",
|
||||
getattr(props, "tweak_nla_track_name", "") or "",
|
||||
getattr(props, "tweak_bake_post_clean", False),
|
||||
)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
OPERATOR_CLASSES = [
|
||||
DLM_OT_replace_linked_asset,
|
||||
DLM_OT_scan_linked_assets,
|
||||
DLM_OT_find_libraries_in_folders,
|
||||
DLM_OT_open_linked_file,
|
||||
DLM_OT_add_search_path,
|
||||
DLM_OT_remove_search_path,
|
||||
DLM_OT_browse_search_path,
|
||||
DLM_OT_attempt_relink,
|
||||
DLM_OT_reload_libraries,
|
||||
DLM_OT_make_paths_relative,
|
||||
DLM_OT_make_paths_absolute,
|
||||
DLM_OT_relocate_single_library,
|
||||
DLM_OT_migrator_remove_original,
|
||||
DLM_OT_picker_original_character,
|
||||
DLM_OT_picker_replacement_character,
|
||||
DLM_OT_migrator_copy_attributes,
|
||||
DLM_OT_migrator_migrate_nla,
|
||||
DLM_OT_migrator_custom_properties,
|
||||
DLM_OT_migrator_bone_constraints,
|
||||
DLM_OT_migrator_retarget_relations,
|
||||
DLM_OT_migrator_basebody_shapekeys,
|
||||
DLM_OT_tweak_add_arm,
|
||||
DLM_OT_tweak_remove_arm,
|
||||
DLM_OT_tweak_bake_arm,
|
||||
DLM_OT_tweak_add_leg,
|
||||
DLM_OT_tweak_remove_leg,
|
||||
DLM_OT_tweak_bake_leg,
|
||||
DLM_OT_tweak_add_both,
|
||||
DLM_OT_tweak_remove_both,
|
||||
DLM_OT_tweak_bake_both,
|
||||
DLM_OT_migrator_fk_rotations,
|
||||
DLM_OT_migrator_fk_rotations_bake,
|
||||
DLM_OT_migrator_fk_rotations_remove,
|
||||
]
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
Let me search for the `DLM_OT_migrator_remove_original` class to find its exact location in the file.</details></think>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2**</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2**</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2**</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2**</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session 9d47ad7e-a8c1-4b72-b7cb-d27d2232bf50 (2026-03-10 17:24Z) -->
|
||||
|
||||
# Version and commit history check (2026-03-10 17:24Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
1. First, check if there's a version file or manifest in T:\Dynamic-Link-Manager. Look for:
|
||||
- blender_manifest.toml
|
||||
- __init__.py with version/bl_info
|
||||
- Any version.txt or similar files
|
||||
|
||||
2. Check if there's a CHANGELOG.md or similar file
|
||||
|
||||
3. Run `git log --oneline -11` in the T:\Dynamic-Link-Manager directory to get the last 11 commits
|
||||
|
||||
Report back what you find for the current version and the commit history.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants me to:
|
||||
1. Check for version files in T:\Dynamic-Link-Manager
|
||||
2. Check for a CHANGELOG.md file
|
||||
3. Run git log --oneline -11 in that directory
|
||||
|
||||
Let me start by listing the files in the directory to see what exists, and then read the relevant files.</details></think>
|
||||
|
||||
---
|
||||
|
||||
I'll check for version files, changelog, and commit history in the repository.
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: ls -la T:\Dynamic-Link-Manager</summary>
|
||||
|
||||
```bash
|
||||
ls -la T:\Dynamic-Link-Manager
|
||||
```
|
||||
|
||||
```
|
||||
Get-ChildItem: C:\Users\Nathan\AppData\Local\Temp\ps-script-64c6ec1b-14fb-4817-ae61-5c5dd0a3abc6.ps1:7
|
||||
Line |
|
||||
7 | ls -la T:\Dynamic-Link-Manager
|
||||
| ~~~
|
||||
| A parameter cannot be found that matches parameter name 'la'.
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: git log --oneline -11</summary>
|
||||
|
||||
```bash
|
||||
git log --oneline -11
|
||||
```
|
||||
|
||||
```
|
||||
a570949 Purge orig actions and cleanup rep actions
|
||||
968425e make rm original work as intended
|
||||
48f0065 rm run migration
|
||||
50d3f05 add remove original button
|
||||
53927a6 make tweak tools collapsible
|
||||
b7a8366 separate functions into situational fixes
|
||||
0299cc5 FKrot bake finally working
|
||||
b681d02 tweak FKRot baking and removal
|
||||
1de7ec5 fix error print on migfkrot
|
||||
7e738fc add FKRotBake, armature parent functionality in RetargRelatives
|
||||
440b206 migfkrot working for 1 frame
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The ls -la command failed because it's using PowerShell, not bash. Let me try a PowerShell-compatible command to list the directory contents.</details></think>
|
||||
|
||||
---
|
||||
|
||||
Let me list the files using the correct PowerShell syntax.
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: Get-ChildItem -Path T:\Dynamic-Link-Manager | Select-Object Name</summary>
|
||||
|
||||
```bash
|
||||
Get-ChildItem -Path T:\Dynamic-Link-Manager | Select-Object Name
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: Get-ChildItem -Path T:\Dynamic-Link-Manager -Filter "*.md"</summary>
|
||||
|
||||
```bash
|
||||
Get-ChildItem -Path T:\Dynamic-Link-Manager -Filter "*.md"
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
Directory: T:\Dynamic-Link-Manager
|
||||
|
||||
Mode LastWriteTime Length Name
|
||||
---- ------------- ------ ----
|
||||
-a--- 3/9/2026 2:28 PM 2424 CHANGELOG.md
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
Good, I found a CHANGELOG.md. Now let me read the key files to check the version:
|
||||
1. blender_manifest.toml (already seen as open in the editor)
|
||||
2. __init__.py
|
||||
3. CHANGELOG.md
|
||||
|
||||
Let me read these files using the shell.</details></think>
|
||||
|
||||
---
|
||||
|
||||
Now let me read the version files and CHANGELOG.
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: Get-Content T:\Dynamic-Link-Manager\blender_manifest.toml</summary>
|
||||
|
||||
```bash
|
||||
Get-Content T:\Dynamic-Link-Manager\blender_manifest.toml
|
||||
```
|
||||
|
||||
```
|
||||
schema_version = "1.0.0"
|
||||
|
||||
id = "dynamiclinkmanager"
|
||||
name = "Dynamic Link Manager"
|
||||
tagline = "Character migrator and linked library tools"
|
||||
version = "0.1.2"
|
||||
type = "add-on"
|
||||
|
||||
# Optional: Semantic Versioning
|
||||
maintainer = "Nathan Lindsay <nathanlindsay9@gmail.com>"
|
||||
license = ["GPL-3.0-or-later"]
|
||||
blender_version_min = "4.5.0"
|
||||
|
||||
website = "http://10.1.10.3:30008/Nathan/Dynamic-Link-Manager"
|
||||
|
||||
tags = ["3D View"]
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: Get-Content T:\Dynamic-Link-Manager\CHANGELOG.md</summary>
|
||||
|
||||
```bash
|
||||
Get-Content T:\Dynamic-Link-Manager\CHANGELOG.md
|
||||
```
|
||||
|
||||
```
|
||||
# Changelog
|
||||
|
||||
## [0.1.2] - 2026-02-19
|
||||
|
||||
### Added
|
||||
- MigFKRot operator: copy FK arm and finger rotations from original to replacement using pose matrix copy.
|
||||
|
||||
### Changed
|
||||
- MigNLA: duplicate actions when copying to repchar so editing on repchar doesn't affect origchar.
|
||||
- MigFKRot: expanded bone name pattern matching for various rig styles (Rigify and alternatives).
|
||||
- MigBBody shapekeys: also duplicate shape key actions for independence.
|
||||
|
||||
### Fixed
|
||||
- MigFKRot: add debug logging to show which bones are found and copied.
|
||||
|
||||
|
||||
## [0.1.1] - 2026-02-19
|
||||
|
||||
### Fixed
|
||||
- MigBBody shapekeys: action slot not applying; now copy action and slot props (last_slot_identifier, action_slot, blend/extrapolation/influence) from original base body.
|
||||
- RetargRelations: skip objects in Original Character's hierarchy (linked collection); only retarget relations outside orig's hierarchy.
|
||||
- MigBoneConst: copy all constraint properties and targets (RNA POINTER/COLLECTION with origrep retarget), not just type/name/mute/influence.
|
||||
- AnimLayers: detect/mirror via RNA (obj.als.turn_on) so "Animation Layer attributes migrated" reports correctly.
|
||||
- MigCustProps: recursive id-property copy for nested groups; debug logging for armature/bone keys.
|
||||
|
||||
|
||||
## [0.1.0] - 2026-02-19
|
||||
|
||||
### Added
|
||||
- Gitea release workflow and runner docker-compose.
|
||||
- Tweak tools subsection: add/remove/bake constraints for arms, legs, and both; track name and post-clean options.
|
||||
- MigNLA: when no NLA, copy active action and action slot (incl. last_slot_identifier, blend/extrapolation/influence); debug logging for slot migration.
|
||||
- NLAMig: AnimLayers support (mirror `als.turn_on`), strip timing and properties; active-action-only path when no NLA tracks.
|
||||
- Operator icons (CopyAttr, MigNLA, CustProps, BoneConst, RetargRelatives, MigBBodyShapeKeys, pickers, tweak ops).
|
||||
- BaseBody shapekeys step: prefer original base body's shape-key action slot; library override + editable; copy shape key values.
|
||||
|
||||
### Changed
|
||||
- Operator labels refactored to canonical short names (CopyAttr, MigNLA, MigCustProps, etc.) with `bl_description` on all UI operators.
|
||||
- Migrate BaseBody shapekeys redefined: find base body in hierarchy, override mesh/key data when linked, then apply shape-key action.
|
||||
- Button labels truncated (e.g. "NLA", "BaseBody ShapeKeys").
|
||||
|
||||
### Fixed
|
||||
- BaseBody shapekeys: lib override creation and value copy; correct original-base-body lookup and slot assignment.
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: if (Test-Path T:\Dynamic-Link-Manager\__init__.py) { Get-Content T:\Dynamic-Link-Manager\__init__.py } else { "No __init__.py file found" }</summary>
|
||||
|
||||
```bash
|
||||
if (Test-Path T:\Dynamic-Link-Manager\__init__.py) { Get-Content T:\Dynamic-Link-Manager\__init__.py } else { "No __init__.py file found" }
|
||||
```
|
||||
|
||||
```
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTIBILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import bpy
|
||||
|
||||
from .ui import CLASSES
|
||||
from .ui.properties import DynamicLinkManagerProperties
|
||||
from .ui.preferences import DynamicLinkManagerPreferences
|
||||
|
||||
|
||||
def ensure_default_search_path():
|
||||
addon = bpy.context.preferences.addons.get(__name__)
|
||||
if addon and len(addon.preferences.search_paths) == 0:
|
||||
addon.preferences.search_paths.add().path = "//"
|
||||
|
||||
|
||||
def register():
|
||||
DynamicLinkManagerPreferences.bl_idname = __name__
|
||||
for cls in CLASSES:
|
||||
bpy.utils.register_class(cls)
|
||||
bpy.types.Scene.dynamic_link_manager = bpy.props.PointerProperty(type=DynamicLinkManagerProperties)
|
||||
bpy.app.handlers.load_post.append(ensure_default_search_path)
|
||||
|
||||
|
||||
def unregister():
|
||||
if ensure_default_search_path in bpy.app.handlers.load_post:
|
||||
bpy.app.handlers.load_post.remove(ensure_default_search_path)
|
||||
del bpy.types.Scene.dynamic_link_manager
|
||||
for cls in reversed(CLASSES):
|
||||
try:
|
||||
bpy.utils.unregister_class(cls)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
I now have all the information the user requested. Let me summarize:
|
||||
|
||||
1. **Version file**: `blender_manifest.toml` exists with `version = "0.1.2"`
|
||||
2. **CHANGELOG.md**: Exists with detailed changelog
|
||||
3. **__init__.py**: Exists but does NOT have a `bl_info` dict with version info - this appears to be a modern Blender 4.x extension format that uses `blender_manifest.toml` instead
|
||||
4. **Git log**: 11 commits provided
|
||||
|
||||
Let me report back the findings.</details></think>
|
||||
|
||||
---
|
||||
|
||||
Here's what I found:
|
||||
|
||||
## Version Information
|
||||
|
||||
**Current Version: `0.1.2`**
|
||||
|
||||
The version is defined in `blender_manifest.toml`:
|
||||
|
||||
```toml
|
||||
schema_version = "1.0.0"
|
||||
id = "dynamiclinkmanager"
|
||||
name = "Dynamic Link Manager"
|
||||
tagline = "Character migrator and linked library tools"
|
||||
version = "0.1.2"
|
||||
type = "add-on"
|
||||
maintainer = "Nathan Lindsay <nathanlindsay9@gmail.com>"
|
||||
license = ["GPL-3.0-or-later"]
|
||||
blender_version_min = "4.5.0"
|
||||
```
|
||||
|
||||
The `__init__.py` file exists but **does not contain a `bl_info` version dict** - this project uses the newer Blender 4.x extension manifest format (`blender_manifest.toml`) instead of the legacy add-on format.
|
||||
|
||||
## CHANGELOG.md
|
||||
|
||||
Found `CHANGELOG.md` with the following recent entries:
|
||||
|
||||
| Version | Date | Highlights |
|
||||
|---------|------|------------|
|
||||
| 0.1.2 | 2026-02-19 | MigFKRot operator for FK arm/finger rotations; expanded bone pattern matching |
|
||||
| 0.1.1 | 2026-02-19 | Fixed action slots, constraint copying, AnimLayers detection, custom properties |
|
||||
| 0.1.0 | 2026-02-19 | Initial release with Gitea workflow, tweak tools, NLA migration, AnimLayers support |
|
||||
|
||||
## Git Commit History (Last 11)
|
||||
|
||||
```
|
||||
a570949 Purge orig actions and cleanup rep actions
|
||||
968425e make rm original work as intended
|
||||
48f0065 rm run migration
|
||||
50d3f05 add remove original button
|
||||
53927a6 make tweak tools collapsible
|
||||
b7a8366 separate functions into situational fixes
|
||||
0299cc5 FKrot bake finally working
|
||||
b681d02 tweak FKRot baking and removal
|
||||
1de7ec5 fix error print on migfkrot
|
||||
7e738fc add FKRotBake, armature parent functionality in RetargRelatives
|
||||
440b206 migfkrot working for 1 frame
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.1.0 -->
|
||||
|
||||
<!-- cursor Session dd526f99-b20d-401d-abac-ea6770d3a424 (2026-03-10 17:25Z) -->
|
||||
|
||||
# Dynamic-Link-Manager version and changelog update (2026-03-10 17:25Z)
|
||||
|
||||
_**User**_
|
||||
|
||||
Update the version and changelog for the Dynamic-Link-Manager addon:
|
||||
|
||||
1. **Update blender_manifest.toml**: Change version from "0.1.2" to "0.2.0"
|
||||
|
||||
2. **Update CHANGELOG.md**: Add a new entry at the top for version 0.2.0 with today's date (2026-03-10) and a concise summary of the changes from the last 11 commits:
|
||||
|
||||
The commits were:
|
||||
- Purge orig actions and cleanup rep actions
|
||||
- make rm original work as intended
|
||||
- rm run migration
|
||||
- add remove original button
|
||||
- make tweak tools collapsible
|
||||
- separate functions into situational fixes
|
||||
- FKrot bake finally working
|
||||
- tweak FKRot baking and removal
|
||||
- fix error print on migfkrot
|
||||
- add FKRotBake, armature parent functionality in RetargRelatives
|
||||
- migfkrot working for 1 frame
|
||||
|
||||
Summarize these concisely into a few bullet points covering:
|
||||
- New "Remove Original" operator with collection deletion and action management
|
||||
- UI improvements (collapsible Tweak Tools, Situational section)
|
||||
- FK rotation migration improvements (baking, single-frame copy, error fixes)
|
||||
- Removal of "Run Migration" button
|
||||
- Armature parent replication in RetargRelatives
|
||||
|
||||
Add the new entry at the TOP of the changelog table (before 0.1.2).
|
||||
|
||||
Use Python to make both edits.
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model accounts/fireworks/models/kimi-k2p5, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
The user wants me to update version and changelog for a Blender addon. Let me first read the existing files to understand their structure, then use Python to make the edits.
|
||||
|
||||
Let me start by reading the files.</details></think>
|
||||
|
||||
---
|
||||
|
||||
I'll first read the existing files to understand their structure, then use Python to make the edits.
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2** • Run command: cat "t:\Dynamic-Link-Manager\blender_manifest.toml"</summary>
|
||||
|
||||
```bash
|
||||
cat "t:\Dynamic-Link-Manager\blender_manifest.toml"
|
||||
```
|
||||
|
||||
```
|
||||
schema_version = "1.0.0"
|
||||
|
||||
id = "dynamiclinkmanager"
|
||||
name = "Dynamic Link Manager"
|
||||
tagline = "Character migrator and linked library tools"
|
||||
version = "0.1.2"
|
||||
type = "add-on"
|
||||
|
||||
# Optional: Semantic Versioning
|
||||
maintainer = "Nathan Lindsay <nathanlindsay9@gmail.com>"
|
||||
license = ["GPL-3.0-or-later"]
|
||||
blender_version_min = "4.5.0"
|
||||
|
||||
website = "http://10.1.10.3:30008/Nathan/Dynamic-Link-Manager"
|
||||
|
||||
tags = ["3D View"]
|
||||
|
||||
```
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2**</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2**</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2**</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<tool-use data-tool-type="shell" data-tool-name="run_terminal_command_v2">
|
||||
<details><summary>Tool use: **run_terminal_command_v2**</summary>
|
||||
|
||||
</details>
|
||||
</tool-use>
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## [0.2.0] - 2026-03-10
|
||||
|
||||
### Added
|
||||
- New "Remove Original" operator with collection deletion and action management (purge/cleanup).
|
||||
- FK rotation migration improvements: baking support, single-frame copy, armature parent replication in RetargRelatives.
|
||||
|
||||
### Changed
|
||||
- UI improvements: Tweak Tools section is now collapsible; reorganized into Situational Fixes section.
|
||||
- Removed "Run Migration" button (functionality merged into Remove Original workflow).
|
||||
|
||||
### Fixed
|
||||
- MigFKRot: fixed error printing and improved bone matching reliability.
|
||||
|
||||
## [0.1.2] - 2026-02-19
|
||||
|
||||
### Added
|
||||
- MigFKRot operator: copy FK arm and finger rotations from original to replacement using pose matrix copy.
|
||||
|
||||
### Changed
|
||||
- MigNLA: duplicate actions when copying to repchar so editing on repchar doesn't affect origchar.
|
||||
- MigFKRot: expanded bone name pattern matching for various rig styles (Rigify and alternatives).
|
||||
- MigBBody shapekeys: also duplicate shape key actions for independence.
|
||||
|
||||
### Fixed
|
||||
- MigFKRot: add debug logging to show which bones are found and copied.
|
||||
|
||||
|
||||
## [0.1.1] - 2026-02-19
|
||||
|
||||
### Fixed
|
||||
- MigBBody shapekeys: action slot not applying; now copy action and slot props (last_slot_identifier, action_slot, blend/extrapolation/influence) from original base body.
|
||||
- RetargRelations: skip objects in Original Character's hierarchy (linked collection); only retarget relations outside orig's hierarchy.
|
||||
- MigBoneConst: copy all constraint properties and targets (RNA POINTER/COLLECTION with orig→rep retarget), not just type/name/mute/influence.
|
||||
- AnimLayers: detect/mirror via RNA (obj.als.turn_on) so "Animation Layer attributes migrated" reports correctly.
|
||||
- MigCustProps: recursive id-property copy for nested groups; debug logging for armature/bone keys.
|
||||
|
||||
|
||||
## [0.1.0] - 2026-02-19
|
||||
|
||||
### Added
|
||||
- Gitea release workflow and runner docker-compose.
|
||||
- Tweak tools subsection: add/remove/bake constraints for arms, legs, and both; track name and post-clean options.
|
||||
- MigNLA: when no NLA, copy active action and action slot (incl. last_slot_identifier, blend/extrapolation/influence); debug logging for slot migration.
|
||||
- NLAMig: AnimLayers support (mirror `als.turn_on`), strip timing and properties; active-action-only path when no NLA tracks.
|
||||
- Operator icons (CopyAttr, MigNLA, CustProps, BoneConst, RetargRelatives, MigBBodyShapeKeys, pickers, tweak ops).
|
||||
- BaseBody shapekeys step: prefer original base body's shape-key action slot; library override + editable; copy shape key values.
|
||||
|
||||
### Changed
|
||||
- Operator labels refactored to canonical short names (CopyAttr, MigNLA, MigCustProps, etc.) with `bl_description` on all UI operators.
|
||||
- Migrate BaseBody shapekeys redefined: find base body in hierarchy, override mesh/key data when linked, then apply shape-key action.
|
||||
- Button labels truncated (e.g. "NLA", "BaseBody ShapeKeys").
|
||||
|
||||
### Fixed
|
||||
- BaseBody shapekeys: lib override creation and value copy; correct original-base-body lookup and slot assignment.
|
||||
+20
-16
@@ -12,32 +12,36 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import bpy
|
||||
from bpy.props import StringProperty, BoolProperty, EnumProperty
|
||||
from bpy.types import Panel, Operator, PropertyGroup
|
||||
|
||||
# Import local modules
|
||||
from . import operators
|
||||
from . import ui
|
||||
from .ui import CLASSES
|
||||
from .ui.properties import DynamicLinkManagerProperties
|
||||
from .ui.preferences import DynamicLinkManagerPreferences
|
||||
|
||||
|
||||
def ensure_default_search_path():
|
||||
"""Ensure there's always at least one search path"""
|
||||
prefs = bpy.context.preferences.addons.get(__name__)
|
||||
if prefs and len(prefs.preferences.search_paths) == 0:
|
||||
new_path = prefs.preferences.search_paths.add()
|
||||
new_path.path = "//" # Default to relative path
|
||||
addon = bpy.context.preferences.addons.get(__name__)
|
||||
if addon and len(addon.preferences.search_paths) == 0:
|
||||
addon.preferences.search_paths.add().path = "//"
|
||||
|
||||
|
||||
def register():
|
||||
operators.register()
|
||||
ui.register()
|
||||
# Ensure default search path exists
|
||||
DynamicLinkManagerPreferences.bl_idname = __name__
|
||||
for cls in CLASSES:
|
||||
bpy.utils.register_class(cls)
|
||||
bpy.types.Scene.dynamic_link_manager = bpy.props.PointerProperty(type=DynamicLinkManagerProperties)
|
||||
bpy.app.handlers.load_post.append(ensure_default_search_path)
|
||||
|
||||
|
||||
def unregister():
|
||||
ui.unregister()
|
||||
operators.unregister()
|
||||
# Remove the handler
|
||||
if ensure_default_search_path in bpy.app.handlers.load_post:
|
||||
bpy.app.handlers.load_post.remove(ensure_default_search_path)
|
||||
del bpy.types.Scene.dynamic_link_manager
|
||||
for cls in reversed(CLASSES):
|
||||
try:
|
||||
bpy.utils.unregister_class(cls)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||
|
||||
@@ -2,8 +2,8 @@ schema_version = "1.0.0"
|
||||
|
||||
id = "dynamiclinkmanager"
|
||||
name = "Dynamic Link Manager"
|
||||
tagline = "Relink characters and library blends with ease"
|
||||
version = "0.0.1"
|
||||
tagline = "Character migrator and linked library tools"
|
||||
version = "0.2.0"
|
||||
type = "add-on"
|
||||
|
||||
# Optional: Semantic Versioning
|
||||
|
||||
-728
@@ -1,728 +0,0 @@
|
||||
import bpy
|
||||
import os
|
||||
from bpy.types import Operator
|
||||
from bpy.props import StringProperty, BoolProperty, EnumProperty, IntProperty
|
||||
|
||||
class DLM_OT_replace_linked_asset(Operator):
|
||||
"""Replace a linked asset with a new file"""
|
||||
bl_idname = "dlm.replace_linked_asset"
|
||||
bl_label = "Replace Linked Asset"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
filepath: StringProperty(
|
||||
name="File Path",
|
||||
description="Path to the new asset file",
|
||||
subtype='FILE_PATH'
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
self.report({'ERROR'}, "No object selected")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Comprehensive debug info
|
||||
debug_info = f"Object: {obj.name}, Type: {obj.type}"
|
||||
|
||||
# Check object library
|
||||
if hasattr(obj, 'library'):
|
||||
debug_info += f", Object has library attr: {obj.library is not None}"
|
||||
if obj.library:
|
||||
debug_info += f", Object library: {obj.library.filepath}"
|
||||
|
||||
# Check object data
|
||||
if obj.data:
|
||||
debug_info += f", Has data: {type(obj.data).__name__}, Name: {obj.data.name}"
|
||||
|
||||
# Check data library attribute
|
||||
if hasattr(obj.data, 'library'):
|
||||
debug_info += f", Data.library exists: {obj.data.library is not None}"
|
||||
if obj.data.library:
|
||||
debug_info += f", Data.library.filepath: {obj.data.library.filepath}"
|
||||
|
||||
# Check if data is in bpy.data collections
|
||||
if obj.type == 'ARMATURE' and obj.data.name in bpy.data.armatures:
|
||||
armature_data = bpy.data.armatures[obj.data.name]
|
||||
debug_info += f", Found in bpy.data.armatures"
|
||||
if hasattr(armature_data, 'library'):
|
||||
debug_info += f", bpy.data library: {armature_data.library is not None}"
|
||||
if armature_data.library:
|
||||
debug_info += f", bpy.data library path: {armature_data.library.filepath}"
|
||||
|
||||
# Check if data is in bpy.data.objects
|
||||
if obj.data.name in bpy.data.objects:
|
||||
debug_info += f", Data also in bpy.data.objects"
|
||||
|
||||
# Check if object itself is linked
|
||||
if hasattr(obj, 'library') and obj.library:
|
||||
self.report({'INFO'}, f"Object '{obj.name}' is linked from: {obj.library.filepath}")
|
||||
return {'FINISHED'}
|
||||
|
||||
# Check if object's data is linked
|
||||
if obj.data and hasattr(obj.data, 'library') and obj.data.library:
|
||||
self.report({'INFO'}, f"Object '{obj.name}' has linked data from: {obj.data.library.filepath}")
|
||||
return {'FINISHED'}
|
||||
|
||||
# Check if armature data is linked through bpy.data system
|
||||
if obj.type == 'ARMATURE' and obj.data and obj.data.name in bpy.data.armatures:
|
||||
armature_data = bpy.data.armatures[obj.data.name]
|
||||
if hasattr(armature_data, 'library') and armature_data.library:
|
||||
self.report({'INFO'}, f"Armature '{obj.name}' data is linked from: {armature_data.library.filepath}")
|
||||
return {'FINISHED'}
|
||||
|
||||
# If we get here, show debug info
|
||||
self.report({'WARNING'}, debug_info)
|
||||
self.report({'ERROR'}, "Selected object is not a linked asset")
|
||||
return {'CANCELLED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
class DLM_OT_scan_linked_assets(Operator):
|
||||
"""Scan scene for directly linked libraries and their status"""
|
||||
bl_idname = "dlm.scan_linked_assets"
|
||||
bl_label = "Scan Linked Libraries"
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
def execute(self, context):
|
||||
# Clear previous results
|
||||
context.scene.dynamic_link_manager.linked_libraries.clear()
|
||||
|
||||
# Function to check if file exists
|
||||
def is_file_missing(filepath):
|
||||
if not filepath:
|
||||
return True
|
||||
try:
|
||||
abs_path = bpy.path.abspath(filepath)
|
||||
except Exception:
|
||||
abs_path = filepath
|
||||
return not os.path.isfile(abs_path)
|
||||
|
||||
# Function to get library name from path
|
||||
def get_library_name(filepath):
|
||||
if not filepath:
|
||||
return "Unknown"
|
||||
return os.path.basename(filepath)
|
||||
|
||||
# Helper: naive scan of a .blend file to find referenced library paths
|
||||
def scan_blend_for_missing_indirects(blend_path: str) -> bool:
|
||||
"""Return True if the .blend likely references at least one missing
|
||||
library (.blend) path. This uses a conservative byte-string scan that
|
||||
never loads data into Blender and is thus context-free and safe.
|
||||
|
||||
Strategy: look for substrings ending in .blend inside the file bytes
|
||||
and expand left/right to the nearest NUL byte or line break to
|
||||
reconstruct a plausible filesystem path. If any such path does not
|
||||
exist on disk, we consider it an indirect missing dependency.
|
||||
"""
|
||||
try:
|
||||
if not blend_path or not os.path.exists(blend_path):
|
||||
return False
|
||||
with open(blend_path, 'rb') as f:
|
||||
data = f.read()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
import re
|
||||
base_dir = os.path.dirname(blend_path)
|
||||
# Consider absolute Windows paths and Blender // relative paths
|
||||
patterns = [
|
||||
rb"[A-Za-z]:[\\/][^\r\n\0]*?\.blend",
|
||||
rb"\\\\[^\r\n\0]*?\.blend",
|
||||
rb"//[^\r\n\0]*?\.blend",
|
||||
]
|
||||
for pat in patterns:
|
||||
for m in re.finditer(pat, data):
|
||||
try:
|
||||
s = m.group(0).decode('utf-8', errors='ignore').strip().strip('"\'')
|
||||
if s.startswith('//'):
|
||||
rel = s[2:].replace('/', os.sep).replace('\\', os.sep)
|
||||
candidate = os.path.normpath(os.path.join(base_dir, rel))
|
||||
else:
|
||||
candidate = s
|
||||
if not os.path.isfile(candidate):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
# Reload all libraries up front so Blender populates parent links
|
||||
for lib in bpy.data.libraries:
|
||||
try:
|
||||
if lib.filepath:
|
||||
lib.reload()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Use Blender's library graph: direct libraries have no parent; indirect
|
||||
# libraries have parent != None. We display only direct libraries.
|
||||
direct_libs = set()
|
||||
for lib in bpy.data.libraries:
|
||||
try:
|
||||
if lib.parent is None and lib.filepath:
|
||||
direct_libs.add(lib.filepath)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Only show directly used libraries in the main list
|
||||
all_libraries = set(direct_libs)
|
||||
|
||||
# Store results in scene properties
|
||||
context.scene.dynamic_link_manager.linked_assets_count = len(all_libraries)
|
||||
|
||||
# Build set of direct-parent libraries that have at least one missing
|
||||
# indirect child, using the library.parent chain.
|
||||
missing_indirect_libs = set()
|
||||
for lib in bpy.data.libraries:
|
||||
try:
|
||||
if lib.parent is not None and lib.filepath:
|
||||
try:
|
||||
abs_child = bpy.path.abspath(lib.filepath)
|
||||
except Exception:
|
||||
abs_child = lib.filepath
|
||||
if not os.path.isfile(abs_child):
|
||||
# climb up to the root direct parent
|
||||
root = lib.parent
|
||||
while getattr(root, 'parent', None) is not None:
|
||||
root = root.parent
|
||||
if root and root.filepath:
|
||||
missing_indirect_libs.add(root.filepath)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Additionally, mark any direct library as INDIRECT if any of its
|
||||
# linked ID users are flagged as missing by Blender (e.g., the source
|
||||
# .blend no longer contains the datablock). This catches cases where
|
||||
# the library file exists but its contents are missing.
|
||||
missing_ids_by_library = set()
|
||||
def check_missing_on(ids):
|
||||
for idb in ids:
|
||||
try:
|
||||
lib = getattr(idb, 'library', None)
|
||||
if lib and lib.filepath and getattr(idb, 'is_library_missing', False):
|
||||
missing_ids_by_library.add(lib.filepath)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
check_missing_on(bpy.data.objects)
|
||||
check_missing_on(bpy.data.meshes)
|
||||
check_missing_on(bpy.data.armatures)
|
||||
check_missing_on(bpy.data.materials)
|
||||
check_missing_on(bpy.data.node_groups)
|
||||
check_missing_on(bpy.data.images)
|
||||
check_missing_on(bpy.data.texts)
|
||||
check_missing_on(bpy.data.collections)
|
||||
check_missing_on(bpy.data.cameras)
|
||||
check_missing_on(bpy.data.lights)
|
||||
|
||||
# Create library items for the UI based on definitive indirect set
|
||||
library_items = []
|
||||
for filepath in sorted(all_libraries):
|
||||
if not filepath:
|
||||
continue
|
||||
lib_item = context.scene.dynamic_link_manager.linked_libraries.add()
|
||||
lib_item.filepath = filepath
|
||||
lib_item.name = get_library_name(filepath)
|
||||
lib_item.is_missing = is_file_missing(filepath)
|
||||
|
||||
# INDIRECT if it has a missing indirect child OR any linked IDs are missing
|
||||
lib_item.is_indirect = (filepath in missing_indirect_libs) or (filepath in missing_ids_by_library)
|
||||
|
||||
# Store for sorting
|
||||
library_items.append((lib_item, filepath))
|
||||
|
||||
# Sort libraries: missing first, then by name
|
||||
library_items.sort(key=lambda x: (not x[0].is_missing, get_library_name(x[1]).lower()))
|
||||
|
||||
# Clear and re-add in sorted order
|
||||
context.scene.dynamic_link_manager.linked_libraries.clear()
|
||||
for lib_item, filepath in library_items:
|
||||
new_item = context.scene.dynamic_link_manager.linked_libraries.add()
|
||||
new_item.filepath = filepath
|
||||
new_item.name = get_library_name(filepath) # Use the function directly
|
||||
new_item.is_missing = lib_item.is_missing
|
||||
new_item.is_indirect = lib_item.is_indirect
|
||||
|
||||
# Show detailed info
|
||||
self.report({'INFO'}, f"Found {len(all_libraries)} unique linked library files")
|
||||
if all_libraries:
|
||||
for lib in sorted(all_libraries):
|
||||
self.report({'INFO'}, f"Library: {lib}")
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
class DLM_OT_find_libraries_in_folders(Operator):
|
||||
"""Find missing libraries in search folders and attempt to relocate them"""
|
||||
bl_idname = "dlm.find_libraries_in_folders"
|
||||
bl_label = "Find Libraries in Folders"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
prefs = context.preferences.addons.get(__package__)
|
||||
if not prefs or not prefs.preferences.search_paths:
|
||||
self.report({'ERROR'}, "No search paths configured. Add search paths in addon preferences.")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Get missing libraries
|
||||
missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
|
||||
if not missing_libs:
|
||||
self.report({'INFO'}, "No missing libraries to find")
|
||||
return {'FINISHED'}
|
||||
|
||||
self.report({'INFO'}, f"Searching for {len(missing_libs)} missing libraries in search paths...")
|
||||
|
||||
# Recursive directory scanning (searches all subfolders)
|
||||
files_dir_list = []
|
||||
total_dirs_scanned = 0
|
||||
|
||||
try:
|
||||
for search_path in prefs.preferences.search_paths:
|
||||
if search_path.path:
|
||||
# Handle both relative and absolute paths
|
||||
if search_path.path.startswith("//"):
|
||||
# Relative path - convert to absolute
|
||||
abs_path = bpy.path.abspath(search_path.path)
|
||||
else:
|
||||
# Absolute path - use as is
|
||||
abs_path = search_path.path
|
||||
|
||||
self.report({'INFO'}, f"Scanning search path: {abs_path}")
|
||||
|
||||
# Check if path exists and is accessible
|
||||
if not os.path.exists(abs_path):
|
||||
self.report({'WARNING'}, f"Search path does not exist or is not accessible: {abs_path}")
|
||||
continue
|
||||
|
||||
if not os.path.isdir(abs_path):
|
||||
self.report({'WARNING'}, f"Search path is not a directory: {abs_path}")
|
||||
continue
|
||||
|
||||
# Use os.walk to recursively scan all subdirectories
|
||||
for dirpath, dirnames, filenames in os.walk(abs_path):
|
||||
files_dir_list.append([dirpath, filenames])
|
||||
total_dirs_scanned += 1
|
||||
|
||||
# Debug: Show what we're finding
|
||||
if len(filenames) > 0:
|
||||
blend_files = [f for f in filenames if f.endswith('.blend')]
|
||||
if blend_files:
|
||||
self.report({'INFO'}, f" Found {len(blend_files)} .blend files in: {dirpath}")
|
||||
|
||||
# Limit to prevent excessive scanning (safety measure)
|
||||
if total_dirs_scanned > 1000:
|
||||
self.report({'WARNING'}, "Reached scan limit of 1000 directories. Some subfolders may not be scanned.")
|
||||
break
|
||||
|
||||
self.report({'INFO'}, f"Scanned {total_dirs_scanned} directories from search path: {abs_path}")
|
||||
|
||||
except FileNotFoundError as e:
|
||||
self.report({'ERROR'}, f"Error - Bad file path in search paths: {e}")
|
||||
return {'CANCELLED'}
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, f"Error scanning search paths: {e}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
self.report({'INFO'}, f"Total directories scanned: {total_dirs_scanned}")
|
||||
|
||||
# Phase 1: Library finding with recursive search
|
||||
found_libraries = {} # Dictionary to store filename -> full path mapping
|
||||
found_count = 0
|
||||
|
||||
self.report({'INFO'}, "=== PHASE 1: SEARCHING FOR LIBRARIES ===")
|
||||
|
||||
for lib_item in missing_libs:
|
||||
lib_filename = os.path.basename(lib_item.filepath)
|
||||
self.report({'INFO'}, f"Looking for: {lib_filename}")
|
||||
|
||||
for dir_info in files_dir_list:
|
||||
dirpath, filenames = dir_info
|
||||
|
||||
# Exact filename match
|
||||
if lib_filename in filenames:
|
||||
new_path = os.path.join(dirpath, lib_filename)
|
||||
found_libraries[lib_filename] = new_path
|
||||
self.report({'INFO'}, f"✓ Found {lib_filename} at: {new_path}")
|
||||
found_count += 1
|
||||
break
|
||||
|
||||
self.report({'INFO'}, f"=== SEARCH COMPLETE: Found {found_count} out of {len(missing_libs)} missing libraries ===")
|
||||
|
||||
# Phase 2: Relinking found libraries
|
||||
if found_count > 0:
|
||||
self.report({'INFO'}, "=== PHASE 2: RELINKING LIBRARIES ===")
|
||||
|
||||
# Manual, deterministic relink of exact filename matches.
|
||||
relinked_count = 0
|
||||
for lib in bpy.data.libraries:
|
||||
try:
|
||||
if not lib.filepath:
|
||||
continue
|
||||
lib_filename = os.path.basename(lib.filepath)
|
||||
if lib_filename in found_libraries:
|
||||
new_path = found_libraries[lib_filename]
|
||||
# Only update if currently missing and different
|
||||
current_abs = bpy.path.abspath(lib.filepath)
|
||||
if (not os.path.isfile(current_abs)) or (current_abs != new_path):
|
||||
lib.filepath = new_path
|
||||
try:
|
||||
lib.reload()
|
||||
except Exception:
|
||||
pass
|
||||
relinked_count += 1
|
||||
self.report({'INFO'}, f"Relinked {lib_filename} -> {new_path}")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if relinked_count == 0:
|
||||
self.report({'WARNING'}, "No libraries were relinked; ensure filenames match exactly in search paths.")
|
||||
else:
|
||||
self.report({'INFO'}, f"✓ Manually relinked {relinked_count} libraries")
|
||||
else:
|
||||
self.report({'WARNING'}, "No libraries found in search paths - nothing to relink")
|
||||
|
||||
# Trigger a rescan so UI reflects current status immediately
|
||||
try:
|
||||
bpy.ops.dlm.scan_linked_assets()
|
||||
except Exception:
|
||||
pass
|
||||
self.report({'INFO'}, "=== OPERATION COMPLETE ===")
|
||||
return {'FINISHED'}
|
||||
|
||||
def register():
|
||||
bpy.utils.register_class(DLM_OT_replace_linked_asset)
|
||||
bpy.utils.register_class(DLM_OT_scan_linked_assets)
|
||||
|
||||
class DLM_OT_open_linked_file(Operator):
|
||||
"""Open the linked file in a new Blender instance"""
|
||||
bl_idname = "dlm.open_linked_file"
|
||||
bl_label = "Open Linked File"
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
filepath: StringProperty(
|
||||
name="File Path",
|
||||
description="Path to the linked file",
|
||||
default=""
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
if not self.filepath:
|
||||
self.report({'ERROR'}, "No file path specified")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Try to open the linked file in a new Blender instance
|
||||
try:
|
||||
# Use Blender's built-in file browser to open the file
|
||||
bpy.ops.wm.path_open(filepath=self.filepath)
|
||||
self.report({'INFO'}, f"Opening linked file: {self.filepath}")
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, f"Failed to open linked file: {e}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
class DLM_OT_add_search_path(Operator):
|
||||
"""Add a new search path for missing libraries"""
|
||||
bl_idname = "dlm.add_search_path"
|
||||
bl_label = "Add Search Path"
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
def execute(self, context):
|
||||
prefs = context.preferences.addons.get(__package__)
|
||||
if prefs:
|
||||
new_path = prefs.preferences.search_paths.add()
|
||||
new_path.path = "//" # Default to relative path
|
||||
self.report({'INFO'}, f"Added search path: {new_path.path}")
|
||||
return {'FINISHED'}
|
||||
|
||||
class DLM_OT_remove_search_path(Operator):
|
||||
"""Remove a search path"""
|
||||
bl_idname = "dlm.remove_search_path"
|
||||
bl_label = "Remove Search Path"
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
index: IntProperty(
|
||||
name="Index",
|
||||
description="Index of the search path to remove",
|
||||
default=0
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
prefs = context.preferences.addons.get(__package__)
|
||||
if prefs and prefs.preferences.search_paths:
|
||||
if 0 <= self.index < len(prefs.preferences.search_paths):
|
||||
prefs.preferences.search_paths.remove(self.index)
|
||||
self.report({'INFO'}, f"Removed search path at index {self.index}")
|
||||
else:
|
||||
self.report({'ERROR'}, f"Invalid index: {self.index}")
|
||||
return {'FINISHED'}
|
||||
|
||||
class DLM_OT_attempt_relink(Operator):
|
||||
"""Attempt to relink missing libraries using search paths"""
|
||||
bl_idname = "dlm.attempt_relink"
|
||||
bl_label = "Attempt Relink"
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
def execute(self, context):
|
||||
prefs = context.preferences.addons.get(__package__)
|
||||
if not prefs or not prefs.preferences.search_paths:
|
||||
self.report({'ERROR'}, "No search paths configured. Add search paths in addon preferences.")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Get missing libraries
|
||||
missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
|
||||
if not missing_libs:
|
||||
self.report({'INFO'}, "No missing libraries to relink")
|
||||
return {'FINISHED'}
|
||||
|
||||
self.report({'INFO'}, f"Attempting to relink {len(missing_libs)} missing libraries...")
|
||||
|
||||
# Scan search paths for missing libraries (FMT-inspired approach)
|
||||
files_dir_list = []
|
||||
try:
|
||||
for search_path in prefs.preferences.search_paths:
|
||||
if search_path.path:
|
||||
for dirpath, dirnames, filenames in os.walk(bpy.path.abspath(search_path.path)):
|
||||
files_dir_list.append([dirpath, filenames])
|
||||
except FileNotFoundError:
|
||||
self.report({'ERROR'}, f"Error - Bad file path in search paths")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Try to find and relink each missing library
|
||||
relinked_count = 0
|
||||
indirect_errors = []
|
||||
|
||||
for lib_item in missing_libs:
|
||||
lib_filename = os.path.basename(lib_item.filepath)
|
||||
found = False
|
||||
|
||||
# Search through all directories
|
||||
for dir_info in files_dir_list:
|
||||
dirpath, filenames = dir_info
|
||||
|
||||
# Look for exact filename match
|
||||
if lib_filename in filenames:
|
||||
new_path = os.path.join(dirpath, lib_filename)
|
||||
try:
|
||||
# Try to relink using Blender's system
|
||||
# This will naturally detect indirect links if they exist
|
||||
bpy.ops.file.find_missing_files()
|
||||
found = True
|
||||
relinked_count += 1
|
||||
break
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "unable to relocate indirectly linked library" in error_msg:
|
||||
indirect_errors.append(lib_item.filepath)
|
||||
print(f"Indirect link detected for: {lib_item.filepath}")
|
||||
else:
|
||||
print(f"Error relinking {lib_item.filepath}: {e}")
|
||||
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
print(f"Could not find {lib_filename} in search paths")
|
||||
|
||||
# Update the UI to show indirect links
|
||||
if indirect_errors:
|
||||
self.report({'WARNING'}, f"Found {len(indirect_errors)} indirectly linked libraries")
|
||||
# Mark these as having indirect missing
|
||||
for lib_item in context.scene.dynamic_link_manager.linked_libraries:
|
||||
if lib_item.filepath in indirect_errors:
|
||||
lib_item.has_indirect_missing = True
|
||||
lib_item.indirect_missing_count = 1
|
||||
|
||||
self.report({'INFO'}, f"Relink attempt complete. Relinked: {relinked_count}, Indirect: {len(indirect_errors)}")
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
|
||||
class DLM_OT_browse_search_path(Operator):
|
||||
"""Browse for a search path directory"""
|
||||
bl_idname = "dlm.browse_search_path"
|
||||
bl_label = "Browse Search Path"
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
index: IntProperty(
|
||||
name="Index",
|
||||
description="Index of the search path to browse for",
|
||||
default=0
|
||||
)
|
||||
|
||||
filepath: StringProperty(
|
||||
name="Search Path",
|
||||
description="Path to search for missing linked libraries",
|
||||
subtype='DIR_PATH'
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
prefs = context.preferences.addons.get(__package__)
|
||||
if prefs and prefs.preferences.search_paths:
|
||||
if 0 <= self.index < len(prefs.preferences.search_paths):
|
||||
prefs.preferences.search_paths[self.index].path = self.filepath
|
||||
self.report({'INFO'}, f"Updated search path {self.index + 1}: {self.filepath}")
|
||||
else:
|
||||
self.report({'ERROR'}, f"Invalid index: {self.index}")
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
prefs = context.preferences.addons.get(__package__)
|
||||
if prefs and prefs.preferences.search_paths:
|
||||
if 0 <= self.index < len(prefs.preferences.search_paths):
|
||||
# Set the current path as default
|
||||
self.filepath = prefs.preferences.search_paths[self.index].path
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
|
||||
|
||||
class DLM_OT_reload_libraries(Operator):
|
||||
"""Reload all libraries using Blender's built-in reload operation"""
|
||||
bl_idname = "dlm.reload_libraries"
|
||||
bl_label = "Reload Libraries"
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
# Try the outliner operation first
|
||||
bpy.ops.outliner.lib_operation(type='RELOAD')
|
||||
self.report({'INFO'}, "Library reload operation completed")
|
||||
except Exception as e:
|
||||
# Fallback: manually reload libraries
|
||||
try:
|
||||
for library in bpy.data.libraries:
|
||||
if library.filepath and os.path.exists(bpy.path.abspath(library.filepath)):
|
||||
library.reload()
|
||||
self.report({'INFO'}, "Libraries reloaded manually")
|
||||
except Exception as e2:
|
||||
self.report({'ERROR'}, f"Failed to reload libraries: {e2}")
|
||||
return {'CANCELLED'}
|
||||
return {'FINISHED'}
|
||||
|
||||
class DLM_OT_make_paths_relative(Operator):
|
||||
"""Make all file paths in the scene relative"""
|
||||
bl_idname = "dlm.make_paths_relative"
|
||||
bl_label = "Make Paths Relative"
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
# Use Blender's built-in operator to make paths relative
|
||||
bpy.ops.file.make_paths_relative()
|
||||
self.report({'INFO'}, "All file paths made relative")
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, f"Failed to make paths relative: {e}")
|
||||
return {'CANCELLED'}
|
||||
return {'FINISHED'}
|
||||
|
||||
class DLM_OT_make_paths_absolute(Operator):
|
||||
"""Make all file paths in the scene absolute"""
|
||||
bl_idname = "dlm.make_paths_absolute"
|
||||
bl_label = "Make Paths Absolute"
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
# Use Blender's built-in operator to make paths absolute
|
||||
bpy.ops.file.make_paths_absolute()
|
||||
self.report({'INFO'}, "All file paths made absolute")
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, f"Failed to make paths absolute: {e}")
|
||||
return {'CANCELLED'}
|
||||
return {'FINISHED'}
|
||||
|
||||
class DLM_OT_relocate_single_library(Operator):
|
||||
"""Relocate a single library by choosing a new .blend and reloading it (context-free)"""
|
||||
bl_idname = "dlm.relocate_single_library"
|
||||
bl_label = "Relocate Library"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
# The library we want to relocate (current path as stored on the item)
|
||||
target_filepath: StringProperty(
|
||||
name="Current Library Path",
|
||||
description="Current path of the library to relocate",
|
||||
default=""
|
||||
)
|
||||
|
||||
# New file chosen by the user via file selector
|
||||
filepath: StringProperty(
|
||||
name="New Library File",
|
||||
description="Choose the new .blend file for this library",
|
||||
subtype='FILE_PATH',
|
||||
default=""
|
||||
)
|
||||
|
||||
def _find_library_by_path(self, path_to_match: str):
|
||||
abs_match = bpy.path.abspath(path_to_match) if path_to_match else ""
|
||||
for lib in bpy.data.libraries:
|
||||
try:
|
||||
if bpy.path.abspath(lib.filepath) == abs_match:
|
||||
return lib
|
||||
except Exception:
|
||||
# In case abspath fails for odd paths
|
||||
if lib.filepath == path_to_match:
|
||||
return lib
|
||||
return None
|
||||
|
||||
def execute(self, context):
|
||||
if not self.target_filepath:
|
||||
self.report({'ERROR'}, "No target library specified")
|
||||
return {'CANCELLED'}
|
||||
if not self.filepath:
|
||||
self.report({'ERROR'}, "No new file selected")
|
||||
return {'CANCELLED'}
|
||||
|
||||
library = self._find_library_by_path(self.target_filepath)
|
||||
if not library:
|
||||
self.report({'ERROR'}, "Could not resolve the selected library in bpy.data.libraries")
|
||||
return {'CANCELLED'}
|
||||
|
||||
try:
|
||||
# Assign the new path and reload the library datablocks
|
||||
library.filepath = self.filepath
|
||||
library.reload()
|
||||
self.report({'INFO'}, f"Relocated to: {self.filepath}")
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, f"Failed to relocate: {e}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
# Pre-populate the selector with the current path when possible
|
||||
if self.target_filepath:
|
||||
try:
|
||||
self.filepath = bpy.path.abspath(self.target_filepath)
|
||||
except Exception:
|
||||
self.filepath = self.target_filepath
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def register():
|
||||
bpy.utils.register_class(DLM_OT_replace_linked_asset)
|
||||
bpy.utils.register_class(DLM_OT_scan_linked_assets)
|
||||
bpy.utils.register_class(DLM_OT_open_linked_file)
|
||||
bpy.utils.register_class(DLM_OT_add_search_path)
|
||||
bpy.utils.register_class(DLM_OT_remove_search_path)
|
||||
bpy.utils.register_class(DLM_OT_browse_search_path)
|
||||
bpy.utils.register_class(DLM_OT_attempt_relink)
|
||||
bpy.utils.register_class(DLM_OT_find_libraries_in_folders)
|
||||
bpy.utils.register_class(DLM_OT_reload_libraries)
|
||||
bpy.utils.register_class(DLM_OT_make_paths_relative)
|
||||
bpy.utils.register_class(DLM_OT_make_paths_absolute)
|
||||
bpy.utils.register_class(DLM_OT_relocate_single_library)
|
||||
|
||||
def unregister():
|
||||
bpy.utils.unregister_class(DLM_OT_find_libraries_in_folders)
|
||||
bpy.utils.unregister_class(DLM_OT_attempt_relink)
|
||||
bpy.utils.unregister_class(DLM_OT_browse_search_path)
|
||||
bpy.utils.unregister_class(DLM_OT_remove_search_path)
|
||||
bpy.utils.unregister_class(DLM_OT_add_search_path)
|
||||
bpy.utils.unregister_class(DLM_OT_open_linked_file)
|
||||
bpy.utils.unregister_class(DLM_OT_scan_linked_assets)
|
||||
bpy.utils.unregister_class(DLM_OT_replace_linked_asset)
|
||||
bpy.utils.unregister_class(DLM_OT_reload_libraries)
|
||||
bpy.utils.unregister_class(DLM_OT_make_paths_relative)
|
||||
bpy.utils.unregister_class(DLM_OT_make_paths_absolute)
|
||||
bpy.utils.unregister_class(DLM_OT_relocate_single_library)
|
||||
@@ -0,0 +1,6 @@
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
"""Feature logic (migrator, library) for Dynamic Link Manager."""
|
||||
@@ -0,0 +1,403 @@
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
"""FK rotations: copy visible rotations from original to replacement using constraints."""
|
||||
|
||||
import bpy
|
||||
|
||||
# Arm FK bone name patterns to check (Rigify and common alternatives)
|
||||
ARM_FK_PATTERNS = (
|
||||
# Rigify style
|
||||
("upper_arm_fk.L", "upper_arm_fk.R"),
|
||||
("forearm_fk.L", "forearm_fk.R"),
|
||||
("hand_fk.L", "hand_fk.R"),
|
||||
# Common alternatives
|
||||
("upper_arm.L", "upper_arm.R"),
|
||||
("forearm.L", "forearm.R"),
|
||||
("hand.L", "hand.R"),
|
||||
("arm_fk.L", "arm_fk.R"),
|
||||
("lower_arm_fk.L", "lower_arm_fk.R"),
|
||||
# Short forms
|
||||
("arm.L", "arm.R"),
|
||||
("elbow.L", "elbow.R"),
|
||||
)
|
||||
|
||||
# Finger bone name patterns (will match with .01, .02, .03, etc.)
|
||||
FINGER_PREFIXES = (
|
||||
"thumb", "f_index", "f_middle", "f_ring", "f_pinky",
|
||||
"finger1", "finger2", "finger3", "finger4", "finger5",
|
||||
)
|
||||
|
||||
|
||||
def _get_matching_arm_fk_bones(armature):
|
||||
"""Return list of arm FK bone names that exist on armature."""
|
||||
if not armature or armature.type != "ARMATURE" or not armature.pose:
|
||||
return []
|
||||
bones = armature.pose.bones
|
||||
found = []
|
||||
for pattern in ARM_FK_PATTERNS:
|
||||
for name in pattern:
|
||||
if name in bones:
|
||||
found.append(name)
|
||||
return found
|
||||
|
||||
|
||||
def _get_finger_fk_bones(armature):
|
||||
"""Return list of FK finger bone names that exist on armature. Only control bones (no ORG-/DEF-/MCH- prefix)."""
|
||||
if not armature or armature.type != "ARMATURE" or not armature.pose:
|
||||
return []
|
||||
bones = armature.pose.bones
|
||||
finger_bones = []
|
||||
for bone_name in bones.keys():
|
||||
if bone_name.startswith("ORG-") or bone_name.startswith("DEF-") or bone_name.startswith("MCH-"):
|
||||
continue
|
||||
lower_name = bone_name.lower()
|
||||
for prefix in FINGER_PREFIXES:
|
||||
if prefix in lower_name and ("_fk." in bone_name or bone_name.endswith(".L") or bone_name.endswith(".R")):
|
||||
if "." in bone_name and any(d in bone_name for d in "0123456789"):
|
||||
finger_bones.append(bone_name)
|
||||
break
|
||||
return finger_bones
|
||||
|
||||
|
||||
def _get_fk_bones(armature):
|
||||
"""Return list of all FK arm and finger bone names that exist on armature."""
|
||||
arm_bones = _get_matching_arm_fk_bones(armature)
|
||||
finger_bones = _get_finger_fk_bones(armature)
|
||||
return list(dict.fromkeys(arm_bones + finger_bones))
|
||||
|
||||
|
||||
def copy_fk_rotations(context, orig, rep):
|
||||
"""
|
||||
Copy visual rotations from orig to rep using temporary COPY_TRANSFORMS constraints.
|
||||
This properly handles all coordinate space conversions.
|
||||
Returns (True, message) or (False, error_message).
|
||||
"""
|
||||
fk_names = _get_fk_bones(rep)
|
||||
|
||||
print(f"[DLM MigFKRot] Found {len(fk_names)} FK bones on {rep.name}")
|
||||
|
||||
if not fk_names:
|
||||
return False, "No FK arm or finger bones found on replacement armature"
|
||||
|
||||
# Filter to bones that exist on both
|
||||
common_bones = [n for n in fk_names if n in orig.pose.bones and n in rep.pose.bones]
|
||||
if not common_bones:
|
||||
return False, "No matching FK bones found on both armatures"
|
||||
|
||||
print(f"[DLM MigFKRot] Will copy {len(common_bones)} bones using constraints")
|
||||
|
||||
original_active = context.view_layer.objects.active
|
||||
constraints_added = []
|
||||
|
||||
try:
|
||||
# Ensure rep is active and in pose mode
|
||||
bpy.context.view_layer.objects.active = rep
|
||||
if rep.mode != 'POSE':
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
|
||||
# Step 1: Add COPY_TRANSFORMS constraints to each rep bone
|
||||
for bone_name in common_bones:
|
||||
rep_bone = rep.pose.bones[bone_name]
|
||||
|
||||
# Check if bone already has this constraint
|
||||
existing = [c for c in rep_bone.constraints if c.type == 'COPY_TRANSFORMS' and getattr(c, 'target', None) == orig]
|
||||
if existing:
|
||||
continue
|
||||
|
||||
# Add constraint
|
||||
c = rep_bone.constraints.new(type='COPY_TRANSFORMS')
|
||||
c.name = "MigFKRot_Temp"
|
||||
c.target = orig
|
||||
c.subtarget = bone_name
|
||||
c.target_space = 'POSE'
|
||||
c.owner_space = 'POSE'
|
||||
constraints_added.append((rep_bone, c))
|
||||
|
||||
# Step 2: Update scene to evaluate constraints
|
||||
context.view_layer.update()
|
||||
|
||||
# Step 3: Apply visual transform (bake constraint result into pose)
|
||||
try:
|
||||
bpy.ops.pose.select_all(action='DESELECT')
|
||||
for rep_bone, _ in constraints_added:
|
||||
rep_bone.bone.select = True
|
||||
# Apply visual transform - this bakes the constraint result
|
||||
bpy.ops.pose.visual_transform_apply()
|
||||
except (RuntimeError, AttributeError):
|
||||
# visual_transform_apply requires bones to be selectable
|
||||
# AttributeError: 'bone.select' may not exist in Blender 5.0
|
||||
# If selection fails, the constraint result is still applied
|
||||
pass # Silently ignore - constraints still drove the pose
|
||||
|
||||
# Constraints remain active for bake step
|
||||
print(f"[DLM MigFKRot] Copied {len(constraints_added)} bones (constraints active)")
|
||||
return True, f"Copied FK rotations for {len(constraints_added)} bones (constraints active - run Bake to finalize)"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DLM MigFKRot] Error: {e}")
|
||||
# Cleanup constraints on error
|
||||
for rep_bone, c in constraints_added:
|
||||
try:
|
||||
if c in rep_bone.constraints:
|
||||
rep_bone.constraints.remove(c)
|
||||
except:
|
||||
pass
|
||||
return False, str(e)
|
||||
|
||||
finally:
|
||||
# Only restore active object, don't remove constraints
|
||||
if original_active:
|
||||
context.view_layer.objects.active = original_active
|
||||
|
||||
|
||||
def _get_action_frame_range(action):
|
||||
"""Get the full frame range from action keyframes (not just strip in/out)."""
|
||||
if not action:
|
||||
return None
|
||||
|
||||
frames = set()
|
||||
|
||||
# Try Blender 5.0+ channelbags first
|
||||
if hasattr(action, 'channelbags'):
|
||||
for cb in action.channelbags:
|
||||
if hasattr(cb, 'fcurves'):
|
||||
for fc in cb.fcurves:
|
||||
for kp in fc.keyframe_points:
|
||||
frames.add(int(kp.co.x))
|
||||
|
||||
# Fallback to fcurves
|
||||
if hasattr(action, 'fcurves') and not frames:
|
||||
for fc in action.fcurves:
|
||||
for kp in fc.keyframe_points:
|
||||
frames.add(int(kp.co.x))
|
||||
|
||||
if frames:
|
||||
return (min(frames), max(frames))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_bone_name_from_data_path(data_path):
|
||||
"""Extract bone name from fcurve data_path like 'pose.bones["bone.name"].rotation_euler'."""
|
||||
if not data_path:
|
||||
return None
|
||||
if 'pose.bones["' in data_path:
|
||||
start = data_path.find('["') + 2
|
||||
end = data_path.find('"]', start)
|
||||
if start > 1 and end > start:
|
||||
return data_path[start:end]
|
||||
elif "pose.bones['" in data_path:
|
||||
start = data_path.find("['") + 2
|
||||
end = data_path.find("']", start)
|
||||
if start > 1 and end > start:
|
||||
return data_path[start:end]
|
||||
return None
|
||||
|
||||
|
||||
def bake_fk_rotations(context, orig, rep, track_name=None, post_clean=False):
|
||||
"""
|
||||
Bake FK arm/finger rotations to a new NLA track with replace mode.
|
||||
Returns (True, message) or (False, error_message).
|
||||
"""
|
||||
print(f"[DLM MigFKRot Bake] START: orig={orig.name}, rep={rep.name}")
|
||||
|
||||
fk_names = _get_fk_bones(rep)
|
||||
print(f"[DLM MigFKRot Bake] Found {len(fk_names)} FK bones: {fk_names[:5]}...")
|
||||
|
||||
if not fk_names:
|
||||
return False, f"No FK bones found on {rep.name}"
|
||||
|
||||
# Filter to bones that exist on both
|
||||
common_bones = [n for n in fk_names if n in orig.pose.bones and n in rep.pose.bones]
|
||||
print(f"[DLM MigFKRot Bake] {len(common_bones)} common bones between orig and rep")
|
||||
|
||||
if not common_bones:
|
||||
return False, "No matching FK bones found on both armatures"
|
||||
|
||||
# Get source action for frame range (from keyframes, not strip bounds)
|
||||
source_action = None
|
||||
if rep.animation_data:
|
||||
if rep.animation_data.action:
|
||||
source_action = rep.animation_data.action
|
||||
elif rep.animation_data.nla_tracks:
|
||||
for track in rep.animation_data.nla_tracks:
|
||||
if track.strips:
|
||||
for strip in track.strips:
|
||||
if strip.action:
|
||||
source_action = strip.action
|
||||
break
|
||||
if source_action:
|
||||
break
|
||||
|
||||
# Get frame range from source action keyframes
|
||||
frame_range = _get_action_frame_range(source_action) if source_action else None
|
||||
|
||||
if not frame_range:
|
||||
# Fallback to scene range
|
||||
frame_range = (context.scene.frame_start, context.scene.frame_end)
|
||||
|
||||
frame_start, frame_end = frame_range
|
||||
|
||||
# Ensure rep is active and in pose mode
|
||||
if context.view_layer.objects.active != rep:
|
||||
context.view_layer.objects.active = rep
|
||||
if rep.mode != "POSE":
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
|
||||
# Step 1: Check for existing COPY_TRANSFORMS constraints
|
||||
constraints_added = []
|
||||
for bone_name in common_bones:
|
||||
rep_bone = rep.pose.bones[bone_name]
|
||||
existing = [c for c in rep_bone.constraints if c.type == 'COPY_TRANSFORMS' and getattr(c, 'target', None) == orig]
|
||||
if existing:
|
||||
constraints_added.append((rep_bone, existing[0]))
|
||||
|
||||
print(f"[DLM MigFKRot Bake] Found {len(constraints_added)} constraints from {orig.name} to {rep.name}")
|
||||
print(f"[DLM MigFKRot Bake] Frame range: {frame_start}-{frame_end}")
|
||||
|
||||
# Step 2: Create new action and bake with nla.bake
|
||||
# We use the current action (not a new one) because nla.bake needs use_current_action=True
|
||||
# After baking, we'll clean up non-FK fcurves from the baked action
|
||||
|
||||
# First, ensure there's an action to bake to
|
||||
if rep.animation_data is None:
|
||||
rep.animation_data_create()
|
||||
|
||||
# If there's no current action, create one
|
||||
if not rep.animation_data.action:
|
||||
action_name = f"{rep.name}_FK_Bake_{frame_start}-{frame_end}"
|
||||
new_action = bpy.data.actions.new(name=action_name)
|
||||
if len(new_action.slots) == 0:
|
||||
new_action.slots.new(name=rep.name, id_type='OBJECT')
|
||||
slot = new_action.slots[0]
|
||||
try:
|
||||
rep.animation_data.action_slot = slot
|
||||
except Exception as e:
|
||||
print(f"[DLM MigFKRot Bake] Warning: Could not set slot: {e}")
|
||||
|
||||
# Store the action we're baking to
|
||||
baked_action = rep.animation_data.action
|
||||
print(f"[DLM MigFKRot Bake] Baking to action: {baked_action.name if baked_action else 'None'}")
|
||||
|
||||
# Step 3: Select only FK bones and bake with only_selected=True
|
||||
print(f"[DLM MigFKRot Bake] Selecting {len(common_bones)} FK bones...")
|
||||
try:
|
||||
# Deselect all bones first
|
||||
bpy.ops.pose.select_all(action='DESELECT')
|
||||
|
||||
# Select only our FK bones
|
||||
for bone_name in common_bones:
|
||||
if bone_name in rep.pose.bones:
|
||||
rep_bone = rep.pose.bones[bone_name]
|
||||
# Try different ways to select the bone (Blender 5.0 compatibility)
|
||||
try:
|
||||
# Method 1: Direct selection on pose bone
|
||||
rep_bone.select = True
|
||||
except (AttributeError, TypeError):
|
||||
try:
|
||||
# Method 2: Selection on the bone property
|
||||
if hasattr(rep_bone, 'bone'):
|
||||
rep_bone.bone.select = True
|
||||
except (AttributeError, TypeError):
|
||||
pass # Selection failed, continue anyway
|
||||
|
||||
print(f"[DLM MigFKRot Bake] Running nla.bake with only_selected=True...")
|
||||
bpy.ops.nla.bake(
|
||||
frame_start=frame_start,
|
||||
frame_end=frame_end,
|
||||
step=1,
|
||||
only_selected=True,
|
||||
visual_keying=True,
|
||||
clear_constraints=False,
|
||||
clear_parents=False,
|
||||
use_current_action=True,
|
||||
clean_curves=False,
|
||||
bake_types={"POSE"},
|
||||
channel_types={"ROTATION"},
|
||||
)
|
||||
print(f"[DLM MigFKRot Bake] nla.bake completed successfully")
|
||||
except Exception as e:
|
||||
# Clean up constraints on failure
|
||||
for rep_bone, c in constraints_added:
|
||||
try:
|
||||
if c in rep_bone.constraints:
|
||||
rep_bone.constraints.remove(c)
|
||||
except:
|
||||
pass
|
||||
return False, f"nla.bake failed: {e}"
|
||||
|
||||
# Step 4: Remove constraints
|
||||
removed_count = 0
|
||||
for rep_bone, c in constraints_added:
|
||||
try:
|
||||
if c in rep_bone.constraints:
|
||||
rep_bone.constraints.remove(c)
|
||||
removed_count += 1
|
||||
except:
|
||||
pass
|
||||
print(f"[DLM MigFKRot Bake] Removed {removed_count} constraints")
|
||||
|
||||
# Step 5: Create new NLA track at TOP with replace mode
|
||||
# Use the baked action (which now has only FK fcurves)
|
||||
if rep.animation_data and baked_action:
|
||||
nla_track = rep.animation_data.nla_tracks.new(prev=None)
|
||||
nla_track.name = f"FK_Bake_{frame_start}-{frame_end}"
|
||||
|
||||
strip = nla_track.strips.new(name=baked_action.name, start=frame_start, action=baked_action)
|
||||
strip.frame_end = frame_end
|
||||
strip.blend_type = 'REPLACE'
|
||||
strip.use_auto_blend = False
|
||||
|
||||
print(f"[DLM MigFKRot Bake] Created NLA track '{nla_track.name}' (REPLACE mode)")
|
||||
|
||||
if not post_clean:
|
||||
return True, f"Baked {len(common_bones)} FK bones to NLA track ({frame_start}-{frame_end})."
|
||||
|
||||
# Post-clean
|
||||
win = context.window
|
||||
for area in win.screen.areas:
|
||||
if area.type == "DOPESHEET_EDITOR":
|
||||
with context.temp_override(window=win, area=area):
|
||||
try:
|
||||
bpy.ops.action.clean_keyframes()
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
for area in win.screen.areas:
|
||||
if area.type == "GRAPH_EDITOR":
|
||||
with context.temp_override(window=win, area=area):
|
||||
try:
|
||||
bpy.ops.graph.decimate(mode="ERROR", error=0.001)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
return True, f"Baked and cleaned {len(common_bones)} FK bones to NLA track ({frame_start}-{frame_end})."
|
||||
|
||||
|
||||
def remove_fk_rotations(context, rep):
|
||||
"""
|
||||
Remove COPY_TRANSFORMS constraints that were added by copy_fk_rotations.
|
||||
Similar to tweak_tools.remove_tweak_constraints.
|
||||
Returns (True, message) or (False, error_message).
|
||||
"""
|
||||
fk_names = _get_fk_bones(rep)
|
||||
if not fk_names:
|
||||
return False, f"No FK bones found on {rep.name}"
|
||||
|
||||
removed = 0
|
||||
for bone_name in fk_names:
|
||||
if bone_name not in rep.pose.bones:
|
||||
continue
|
||||
rep_bone = rep.pose.bones[bone_name]
|
||||
to_remove = [
|
||||
c for c in rep_bone.constraints
|
||||
if c.type == 'COPY_TRANSFORMS' and c.name == "MigFKRot_Temp"
|
||||
]
|
||||
for c in to_remove:
|
||||
rep_bone.constraints.remove(c)
|
||||
removed += 1
|
||||
|
||||
return True, f"Removed {removed} FK rotation constraints."
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
import os
|
||||
import re
|
||||
import bpy
|
||||
|
||||
|
||||
def _is_file_missing(filepath):
|
||||
if not filepath:
|
||||
return True
|
||||
try:
|
||||
abs_path = bpy.path.abspath(filepath)
|
||||
except Exception:
|
||||
abs_path = filepath
|
||||
return not os.path.isfile(abs_path)
|
||||
|
||||
|
||||
def _get_library_name(filepath):
|
||||
return os.path.basename(filepath) if filepath else "Unknown"
|
||||
|
||||
|
||||
def scan_linked_assets(context, report):
|
||||
props = context.scene.dynamic_link_manager
|
||||
props.linked_libraries.clear()
|
||||
|
||||
for lib in bpy.data.libraries:
|
||||
try:
|
||||
if lib.filepath:
|
||||
lib.reload()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
direct_libs = set()
|
||||
for lib in bpy.data.libraries:
|
||||
try:
|
||||
if getattr(lib, "parent", None) is None and lib.filepath:
|
||||
direct_libs.add(lib.filepath)
|
||||
except Exception:
|
||||
continue
|
||||
all_libraries = set(direct_libs)
|
||||
props.linked_assets_count = len(all_libraries)
|
||||
|
||||
missing_indirect_libs = set()
|
||||
for lib in bpy.data.libraries:
|
||||
try:
|
||||
if getattr(lib, "parent", None) is not None and lib.filepath:
|
||||
try:
|
||||
abs_child = bpy.path.abspath(lib.filepath)
|
||||
except Exception:
|
||||
abs_child = lib.filepath
|
||||
if not os.path.isfile(abs_child):
|
||||
root = lib.parent
|
||||
while getattr(root, "parent", None) is not None:
|
||||
root = root.parent
|
||||
if root and root.filepath:
|
||||
missing_indirect_libs.add(root.filepath)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
missing_ids_by_library = set()
|
||||
for idb in list(bpy.data.objects) + list(bpy.data.meshes) + list(bpy.data.armatures) + list(bpy.data.materials) + list(bpy.data.node_groups) + list(bpy.data.images) + list(bpy.data.texts) + list(bpy.data.collections) + list(bpy.data.cameras) + list(bpy.data.lights):
|
||||
try:
|
||||
lib = getattr(idb, "library", None)
|
||||
if lib and lib.filepath and getattr(idb, "is_library_missing", False):
|
||||
missing_ids_by_library.add(lib.filepath)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
library_items = []
|
||||
for filepath in sorted(all_libraries):
|
||||
if not filepath:
|
||||
continue
|
||||
lib_item = props.linked_libraries.add()
|
||||
lib_item.filepath = filepath
|
||||
lib_item.name = _get_library_name(filepath)
|
||||
lib_item.is_missing = _is_file_missing(filepath)
|
||||
lib_item.is_indirect = (filepath in missing_indirect_libs) or (filepath in missing_ids_by_library)
|
||||
library_items.append((lib_item, filepath))
|
||||
|
||||
library_items.sort(key=lambda x: (not x[0].is_missing, _get_library_name(x[1]).lower()))
|
||||
props.linked_libraries.clear()
|
||||
for lib_item, filepath in library_items:
|
||||
new_item = props.linked_libraries.add()
|
||||
new_item.filepath = filepath
|
||||
new_item.name = _get_library_name(filepath)
|
||||
new_item.is_missing = lib_item.is_missing
|
||||
new_item.is_indirect = lib_item.is_indirect
|
||||
|
||||
report({"INFO"}, f"Found {len(all_libraries)} unique linked library files")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def find_libraries_in_folders(context, report, addon_name=None):
|
||||
if addon_name is None:
|
||||
addon_name = __package__.rsplit(".", 1)[0] if "." in __package__ else __package__
|
||||
prefs = context.preferences.addons.get(addon_name)
|
||||
if not prefs or not prefs.preferences.search_paths:
|
||||
report({"ERROR"}, "No search paths configured. Add search paths in addon preferences.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
|
||||
if not missing_libs:
|
||||
report({"INFO"}, "No missing libraries to find")
|
||||
return {"FINISHED"}
|
||||
|
||||
report({"INFO"}, f"Searching for {len(missing_libs)} missing libraries in search paths...")
|
||||
files_dir_list = []
|
||||
total_dirs_scanned = 0
|
||||
try:
|
||||
for search_path in prefs.preferences.search_paths:
|
||||
if not search_path.path:
|
||||
continue
|
||||
abs_path = bpy.path.abspath(search_path.path) if search_path.path.startswith("//") else search_path.path
|
||||
report({"INFO"}, f"Scanning search path: {abs_path}")
|
||||
if not os.path.exists(abs_path):
|
||||
report({"WARNING"}, f"Search path does not exist: {abs_path}")
|
||||
continue
|
||||
if not os.path.isdir(abs_path):
|
||||
report({"WARNING"}, f"Search path is not a directory: {abs_path}")
|
||||
continue
|
||||
for dirpath, dirnames, filenames in os.walk(abs_path):
|
||||
files_dir_list.append([dirpath, filenames])
|
||||
total_dirs_scanned += 1
|
||||
if total_dirs_scanned > 1000:
|
||||
report({"WARNING"}, "Reached scan limit of 1000 directories.")
|
||||
break
|
||||
except Exception as e:
|
||||
report({"ERROR"}, f"Error scanning search paths: {e}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
found_libraries = {}
|
||||
for lib_item in missing_libs:
|
||||
lib_filename = os.path.basename(lib_item.filepath)
|
||||
for dirpath, filenames in files_dir_list:
|
||||
if lib_filename in filenames:
|
||||
found_libraries[lib_filename] = os.path.join(dirpath, lib_filename)
|
||||
report({"INFO"}, f"Found {lib_filename} at: {os.path.join(dirpath, lib_filename)}")
|
||||
break
|
||||
|
||||
if found_libraries:
|
||||
relinked_count = 0
|
||||
for lib in bpy.data.libraries:
|
||||
try:
|
||||
if not lib.filepath:
|
||||
continue
|
||||
lib_filename = os.path.basename(lib.filepath)
|
||||
if lib_filename in found_libraries:
|
||||
new_path = found_libraries[lib_filename]
|
||||
current_abs = bpy.path.abspath(lib.filepath)
|
||||
if not os.path.isfile(current_abs) or current_abs != new_path:
|
||||
lib.filepath = new_path
|
||||
try:
|
||||
lib.reload()
|
||||
except Exception:
|
||||
pass
|
||||
relinked_count += 1
|
||||
report({"INFO"}, f"Relinked {lib_filename} -> {new_path}")
|
||||
except Exception:
|
||||
continue
|
||||
report({"INFO"}, f"Manually relinked {relinked_count} libraries")
|
||||
else:
|
||||
report({"WARNING"}, "No libraries found in search paths")
|
||||
try:
|
||||
bpy.ops.dlm.scan_linked_assets()
|
||||
except Exception:
|
||||
pass
|
||||
report({"INFO"}, "Operation complete.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def attempt_relink(context, report, addon_name=None):
|
||||
if addon_name is None:
|
||||
addon_name = __package__.rsplit(".", 1)[0] if "." in __package__ else __package__
|
||||
prefs = context.preferences.addons.get(addon_name)
|
||||
if not prefs or not prefs.preferences.search_paths:
|
||||
report({"ERROR"}, "No search paths configured.")
|
||||
return {"CANCELLED"}
|
||||
missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
|
||||
if not missing_libs:
|
||||
report({"INFO"}, "No missing libraries to relink")
|
||||
return {"FINISHED"}
|
||||
report({"INFO"}, f"Attempting to relink {len(missing_libs)} missing libraries...")
|
||||
files_dir_list = []
|
||||
try:
|
||||
for search_path in prefs.preferences.search_paths:
|
||||
if search_path.path:
|
||||
for dirpath, dirnames, filenames in os.walk(bpy.path.abspath(search_path.path)):
|
||||
files_dir_list.append([dirpath, filenames])
|
||||
except FileNotFoundError:
|
||||
report({"ERROR"}, "Bad file path in search paths")
|
||||
return {"CANCELLED"}
|
||||
relinked_count = 0
|
||||
for lib_item in missing_libs:
|
||||
lib_filename = os.path.basename(lib_item.filepath)
|
||||
for dirpath, filenames in files_dir_list:
|
||||
if lib_filename in filenames:
|
||||
try:
|
||||
bpy.ops.file.find_missing_files()
|
||||
relinked_count += 1
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
report({"INFO"}, f"Relink attempt complete. Relinked: {relinked_count}")
|
||||
return {"FINISHED"}
|
||||
+691
@@ -0,0 +1,691 @@
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
"""Character migrator: migrate animation, constraints, relations from original to replacement armature."""
|
||||
|
||||
import bpy
|
||||
|
||||
from ..utils import descendants, collection_containing_armature
|
||||
|
||||
|
||||
def get_pair_manual(context):
|
||||
"""Return (orig_armature, rep_armature) from scene props, or (None, None)."""
|
||||
props = getattr(context.scene, "dynamic_link_manager", None)
|
||||
if not props:
|
||||
return None, None
|
||||
orig = getattr(props, "original_character", None)
|
||||
rep = getattr(props, "replacement_character", None)
|
||||
if orig and orig.type == "ARMATURE" and rep and rep.type == "ARMATURE":
|
||||
return orig, rep
|
||||
return None, None
|
||||
|
||||
|
||||
def get_pair_automatic(context):
|
||||
"""Discover one pair by convention: Name_Rigify and Name_Rigify.001. Returns (orig, rep) or (None, None)."""
|
||||
pairs = []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type != "ARMATURE":
|
||||
continue
|
||||
name = obj.name
|
||||
if name.endswith("_Rigify.001"):
|
||||
base = name[:-len("_Rigify.001")]
|
||||
orig = bpy.data.objects.get(f"{base}_Rigify")
|
||||
if orig and orig.type == "ARMATURE" and orig != obj:
|
||||
pairs.append((orig, obj))
|
||||
return pairs[0] if pairs else (None, None)
|
||||
|
||||
|
||||
def run_copy_attr(orig, rep):
|
||||
"""Copy armature object attributes: location, rotation, scale (CopyAttr)."""
|
||||
rep.location = orig.location.copy()
|
||||
if orig.rotation_mode == "QUATERNION":
|
||||
rep.rotation_quaternion = orig.rotation_quaternion.copy()
|
||||
else:
|
||||
rep.rotation_euler = orig.rotation_euler.copy()
|
||||
rep.scale = orig.scale.copy()
|
||||
|
||||
|
||||
def _has_als_anywhere(orig):
|
||||
"""Return True if orig has Animation Layers (addon uses Object.als RNA PropertyGroup, obj.als.turn_on)."""
|
||||
# Animation Layers addon: bpy.types.Object.als (RNA), not id props
|
||||
if getattr(orig, "als", None) is not None:
|
||||
return True
|
||||
key = "als.turn_on"
|
||||
if key in orig:
|
||||
return True
|
||||
if getattr(orig, "data", None) and hasattr(orig.data, "keys") and key in orig.data:
|
||||
return True
|
||||
try:
|
||||
als = orig.get("als")
|
||||
if als is not None and callable(getattr(als, "keys", None)) and "turn_on" in als.keys():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
for pb in orig.pose.bones:
|
||||
if key in pb:
|
||||
return True
|
||||
try:
|
||||
als = pb.get("als")
|
||||
if als is not None and callable(getattr(als, "keys", None)) and "turn_on" in als.keys():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _debug_als_lookup(orig):
|
||||
"""Print full debug for AnimLayers: RNA obj.als and every id_prop on orig."""
|
||||
key = "als.turn_on"
|
||||
print("[DLM MigNLA] === AnimLayers debug ===")
|
||||
als_rna = getattr(orig, "als", None)
|
||||
print(f"[DLM MigNLA] orig.als (RNA): {als_rna!r}, turn_on={getattr(als_rna, 'turn_on', 'N/A') if als_rna else 'N/A'}")
|
||||
print(f"[DLM MigNLA] 'als.turn_on' in orig (object): {key in orig}")
|
||||
if getattr(orig, "data", None):
|
||||
has_data_keys = hasattr(orig.data, "keys")
|
||||
print(f"[DLM MigNLA] orig.data has keys(): {has_data_keys}")
|
||||
if has_data_keys:
|
||||
print(f"[DLM MigNLA] 'als.turn_on' in orig.data: {key in orig.data}")
|
||||
print(f"[DLM MigNLA] orig.data keys: {list(orig.data.keys())}")
|
||||
try:
|
||||
als = orig.get("als")
|
||||
has_als = als is not None and callable(getattr(als, "keys", None))
|
||||
print(f"[DLM MigNLA] orig.get('als') is group: {has_als}")
|
||||
if has_als:
|
||||
print(f"[DLM MigNLA] orig['als'] keys: {list(als.keys())}")
|
||||
print(f"[DLM MigNLA] 'turn_on' in orig['als']: {'turn_on' in als.keys()}")
|
||||
except Exception as e:
|
||||
print(f"[DLM MigNLA] orig.get('als') error: {e}")
|
||||
print(f"[DLM MigNLA] orig (object) all keys: {list(orig.keys())}")
|
||||
for k in list(orig.keys()):
|
||||
try:
|
||||
v = orig[k]
|
||||
if callable(getattr(v, "keys", None)):
|
||||
print(f"[DLM MigNLA] orig[{k!r}] (group) keys: {list(v.keys())}")
|
||||
else:
|
||||
print(f"[DLM MigNLA] orig[{k!r}] = {v!r}")
|
||||
except Exception as e:
|
||||
print(f"[DLM MigNLA] orig[{k!r}] error: {e}")
|
||||
# RNA props that might be animation-layer related
|
||||
try:
|
||||
rna_props = list(orig.bl_rna.properties.keys())
|
||||
layer_like = [p for p in rna_props if "layer" in p.lower() or "als" in p.lower() or "turn" in p.lower() or "anim" in p.lower()]
|
||||
print(f"[DLM MigNLA] orig RNA props (layer/als/turn/anim): {layer_like}")
|
||||
except Exception as e:
|
||||
print(f"[DLM MigNLA] orig bl_rna.properties error: {e}")
|
||||
# Every bone that has keys
|
||||
bones_with_keys = []
|
||||
for pb in orig.pose.bones:
|
||||
if pb.keys():
|
||||
bones_with_keys.append((pb.name, list(pb.keys())))
|
||||
print(f"[DLM MigNLA] bones with id_props ({len(bones_with_keys)}): {bones_with_keys[:20]}{'...' if len(bones_with_keys) > 20 else ''}")
|
||||
for bname, bkeys in bones_with_keys[:10]:
|
||||
pb = orig.pose.bones[bname]
|
||||
print(f"[DLM MigNLA] bone {bname!r}: keys={bkeys}")
|
||||
for k in bkeys:
|
||||
try:
|
||||
v = pb[k]
|
||||
if callable(getattr(v, "keys", None)):
|
||||
print(f"[DLM MigNLA] [{k!r}] (group) keys: {list(v.keys())}")
|
||||
else:
|
||||
print(f"[DLM MigNLA] [{k!r}] = {v!r}")
|
||||
except Exception as e:
|
||||
print(f"[DLM MigNLA] [{k!r}] error: {e}")
|
||||
print("[DLM MigNLA] === end AnimLayers debug ===")
|
||||
|
||||
|
||||
def _mirror_als_turn_on(orig, rep):
|
||||
"""Mirror Animation Layers state: obj.als.turn_on (RNA) and id-property fallbacks."""
|
||||
# Animation Layers addon: Object.als is RNA PropertyGroup
|
||||
orig_als = getattr(orig, "als", None)
|
||||
rep_als = getattr(rep, "als", None)
|
||||
if orig_als is not None and rep_als is not None:
|
||||
try:
|
||||
rep_als.turn_on = orig_als.turn_on
|
||||
except Exception:
|
||||
pass
|
||||
key = "als.turn_on"
|
||||
if key in orig:
|
||||
try:
|
||||
rep[key] = orig[key]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
als = orig.get("als")
|
||||
if als is not None and callable(getattr(als, "keys", None)) and "turn_on" in als.keys():
|
||||
if "als" not in rep:
|
||||
rep["als"] = {}
|
||||
rep["als"]["turn_on"] = als["turn_on"]
|
||||
except Exception:
|
||||
pass
|
||||
if getattr(orig, "data", None) and hasattr(orig.data, "keys") and key in orig.data:
|
||||
try:
|
||||
if rep.data is not None and hasattr(rep.data, "keys"):
|
||||
rep.data[key] = orig.data[key]
|
||||
except Exception:
|
||||
pass
|
||||
for pbone in orig.pose.bones:
|
||||
if pbone.name not in rep.pose.bones:
|
||||
continue
|
||||
rbone = rep.pose.bones[pbone.name]
|
||||
if key in pbone:
|
||||
try:
|
||||
rbone[key] = pbone[key]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
als = pbone.get("als")
|
||||
if als is not None and callable(getattr(als, "keys", None)) and "turn_on" in als.keys():
|
||||
if "als" not in rbone:
|
||||
rbone["als"] = {}
|
||||
rbone["als"]["turn_on"] = als["turn_on"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _duplicate_action(src_action, suffix=".rep"):
|
||||
"""Duplicate an action, returning the new action with a unique name."""
|
||||
if src_action is None:
|
||||
return None
|
||||
new_name = src_action.name
|
||||
if not new_name.endswith(suffix):
|
||||
new_name = f"{new_name}{suffix}"
|
||||
# Ensure unique name
|
||||
base_name = new_name
|
||||
n = 1
|
||||
while new_name in bpy.data.actions:
|
||||
new_name = f"{base_name}.{n:03d}"
|
||||
n += 1
|
||||
new_action = src_action.copy()
|
||||
new_action.name = new_name
|
||||
return new_action
|
||||
|
||||
|
||||
def run_mig_nla(orig, rep, report=None):
|
||||
"""Migrate NLA: copy tracks and strips to replacement; or mirror action slot when no NLA (MigNLA).
|
||||
Actions are duplicated so repchar has independent copies."""
|
||||
if not orig.animation_data:
|
||||
return
|
||||
ad = orig.animation_data
|
||||
has_nla = ad.nla_tracks and len(ad.nla_tracks) > 0
|
||||
active_action = getattr(ad, "action", None)
|
||||
if not has_nla:
|
||||
if rep.animation_data is None:
|
||||
rep.animation_data_create()
|
||||
rad = rep.animation_data
|
||||
# Debug: Orig action slot state (Blender 4.4+ slotted actions).
|
||||
def _slot_debug(label, animdata):
|
||||
if animdata is None:
|
||||
print(f"[DLM MigNLA] {label}: no animation_data")
|
||||
return
|
||||
a = getattr(animdata, "action", None)
|
||||
print(f"[DLM MigNLA] {label} action={a.name if a else None}")
|
||||
for p in ("action_slot", "action_slot_handle", "last_slot_identifier",
|
||||
"action_blend_type", "action_extrapolation", "action_influence"):
|
||||
if hasattr(animdata, p):
|
||||
v = getattr(animdata, p, None)
|
||||
if hasattr(v, "identifier"):
|
||||
v = getattr(v, "identifier", v)
|
||||
print(f"[DLM MigNLA] {p}={v!r}")
|
||||
_slot_debug("Orig (before)", ad)
|
||||
_slot_debug("Rep (before)", rad)
|
||||
# Duplicate the active action for repchar
|
||||
dup_action = _duplicate_action(active_action, suffix=".rep")
|
||||
# Copy last_slot_identifier before action so slot is resolved when assigning (4.4+).
|
||||
if hasattr(ad, "last_slot_identifier") and hasattr(rad, "last_slot_identifier") and ad.last_slot_identifier:
|
||||
rad.last_slot_identifier = ad.last_slot_identifier
|
||||
print(f"[DLM MigNLA] set rep last_slot_identifier={ad.last_slot_identifier!r}")
|
||||
rad.action = dup_action
|
||||
# Copy Action Slot and related props (Blender 4.4+ slotted actions).
|
||||
if getattr(ad, "action_slot", None) and getattr(rad, "action_slot", None):
|
||||
try:
|
||||
rad.action_slot = ad.action_slot
|
||||
print(f"[DLM MigNLA] set rep action_slot={getattr(ad.action_slot, 'identifier', ad.action_slot)!r}")
|
||||
except Exception as e:
|
||||
print(f"[DLM MigNLA] rad.action_slot assign failed: {e}")
|
||||
for prop in ("action_blend_type", "action_extrapolation", "action_influence"):
|
||||
if hasattr(ad, prop) and hasattr(rad, prop):
|
||||
setattr(rad, prop, getattr(ad, prop))
|
||||
print(f"[DLM MigNLA] set rep {prop}={getattr(ad, prop)!r}")
|
||||
_slot_debug("Rep (after)", rad)
|
||||
_mirror_als_turn_on(orig, rep)
|
||||
if report:
|
||||
report({"INFO"}, "No NLA detected, active action (duplicated) and slot copied to Replacement Armature.")
|
||||
return
|
||||
if rep.animation_data is None:
|
||||
rep.animation_data_create()
|
||||
rep_tracks = rep.animation_data.nla_tracks
|
||||
existing_names = {t.name for t in rep_tracks}
|
||||
prev_track = rep_tracks[-1] if rep_tracks else None
|
||||
for track in ad.nla_tracks:
|
||||
new_track = rep_tracks.new(prev=prev_track)
|
||||
name = track.name
|
||||
if name in existing_names:
|
||||
base, n = name, 1
|
||||
while f"{base}.{n:03d}" in existing_names:
|
||||
n += 1
|
||||
name = f"{base}.{n:03d}"
|
||||
new_track.name = name
|
||||
existing_names.add(name)
|
||||
new_track.mute = track.mute
|
||||
new_track.is_solo = track.is_solo
|
||||
new_track.lock = track.lock
|
||||
for strip in track.strips:
|
||||
if strip.type != "CLIP" or not strip.action:
|
||||
continue
|
||||
dup_action = _duplicate_action(strip.action, suffix=".rep")
|
||||
new_strip = new_track.strips.new(
|
||||
strip.name, int(strip.frame_start), dup_action
|
||||
)
|
||||
new_strip.blend_type = strip.blend_type
|
||||
new_strip.extrapolation = strip.extrapolation
|
||||
new_strip.frame_end = strip.frame_end
|
||||
new_strip.blend_in = strip.blend_in
|
||||
new_strip.blend_out = strip.blend_out
|
||||
new_strip.repeat = strip.repeat
|
||||
new_strip.action_frame_start = strip.action_frame_start
|
||||
new_strip.action_frame_end = strip.action_frame_end
|
||||
new_strip.influence = strip.influence
|
||||
new_strip.mute = strip.mute
|
||||
new_strip.scale = strip.scale
|
||||
new_strip.use_auto_blend = strip.use_auto_blend
|
||||
new_strip.use_reverse = strip.use_reverse
|
||||
new_strip.use_animated_influence = strip.use_animated_influence
|
||||
new_strip.use_animated_time = strip.use_animated_time
|
||||
new_strip.use_animated_time_cyclic = strip.use_animated_time_cyclic
|
||||
new_strip.use_sync_length = strip.use_sync_length
|
||||
prev_track = new_track
|
||||
_mirror_als_turn_on(orig, rep)
|
||||
if report:
|
||||
_debug_als_lookup(orig)
|
||||
has_als = _has_als_anywhere(orig)
|
||||
print(f"[DLM MigNLA] AnimLayers check: has_als={has_als}")
|
||||
if has_als:
|
||||
report({"INFO"}, "NLA layers detected, Animation Layer attributes migrated to Replacement Armature.")
|
||||
else:
|
||||
report({"INFO"}, "NLA layers detected and migrated. No Animation Layers found.")
|
||||
|
||||
|
||||
EXCLUDE_PROPS = {"_RNA_UI", "rigify_type", "rigify_parameters"}
|
||||
|
||||
|
||||
def _is_id_prop_group(val):
|
||||
"""True if val is an ID property group (nested dict-like), not a leaf or string."""
|
||||
if val is None or isinstance(val, (str, bytes)):
|
||||
return False
|
||||
return callable(getattr(val, "keys", None))
|
||||
|
||||
|
||||
def _copy_id_prop_recursive(orig_container, rep_container, key, debug_path="", debug=False):
|
||||
"""Copy one id property from orig_container[key] into rep_container[key] (recursive for groups)."""
|
||||
if key not in orig_container:
|
||||
return
|
||||
orig_val = orig_container[key]
|
||||
try:
|
||||
if _is_id_prop_group(orig_val):
|
||||
if key not in rep_container:
|
||||
rep_container[key] = {}
|
||||
rep_group = rep_container[key]
|
||||
for k in list(orig_val.keys()):
|
||||
_copy_id_prop_recursive(orig_val, rep_group, k, f"{debug_path}.{key}", debug)
|
||||
if debug:
|
||||
print(f"[DLM MigCustProps] group {debug_path}.{key!r}: copied {len(orig_val.keys())} sub-keys")
|
||||
else:
|
||||
rep_container[key] = orig_val
|
||||
if debug:
|
||||
print(f"[DLM MigCustProps] leaf {debug_path}.{key!r} = {orig_val!r}")
|
||||
except Exception as e:
|
||||
print(f"[DLM MigCustProps] FAILED {debug_path}.{key!r}: {e}")
|
||||
|
||||
|
||||
def _copy_custom_props_from(orig_obj, rep_obj, debug_label="", debug=False):
|
||||
"""Copy all custom props from orig_obj to rep_obj (object or pose bone), including nested groups."""
|
||||
keys = [k for k in orig_obj.keys() if k not in EXCLUDE_PROPS]
|
||||
if debug and keys:
|
||||
print(f"[DLM MigCustProps] {debug_label} keys: {keys}")
|
||||
for key in keys:
|
||||
_copy_id_prop_recursive(orig_obj, rep_obj, key, debug_label, debug)
|
||||
|
||||
|
||||
def run_mig_cust_props(orig, rep):
|
||||
"""Custom properties: copy overridden settings (ID props only, incl. nested e.g. Settings/Devices) from orig to rep."""
|
||||
debug = True
|
||||
print(f"[DLM MigCustProps] orig={orig.name!r} rep={rep.name!r}")
|
||||
# Armature object
|
||||
o_keys = list(orig.keys())
|
||||
print(f"[DLM MigCustProps] armature orig keys (all): {o_keys}")
|
||||
_copy_custom_props_from(orig, rep, f"obj:{orig.name}", debug)
|
||||
# Bones with any id props
|
||||
bones_with_keys = [(pb.name, list(pb.keys())) for pb in orig.pose.bones if pb.keys()]
|
||||
print(f"[DLM MigCustProps] bones with id_props: {bones_with_keys}")
|
||||
for pbone in orig.pose.bones:
|
||||
if pbone.name not in rep.pose.bones:
|
||||
continue
|
||||
rbone = rep.pose.bones[pbone.name]
|
||||
_copy_custom_props_from(pbone, rbone, f"bone:{pbone.name}", debug)
|
||||
# After: rep armature and Settings bone if present
|
||||
print(f"[DLM MigCustProps] rep armature keys after: {list(rep.keys())}")
|
||||
if "Settings" in rep.pose.bones:
|
||||
sb = rep.pose.bones["Settings"]
|
||||
print(f"[DLM MigCustProps] rep bone Settings keys after: {list(sb.keys())}")
|
||||
if sb.keys():
|
||||
for k in sb.keys():
|
||||
v = sb[k]
|
||||
if _is_id_prop_group(v):
|
||||
print(f"[DLM MigCustProps] Settings[{k!r}] (group) keys: {list(v.keys())}")
|
||||
else:
|
||||
print(f"[DLM MigCustProps] Settings[{k!r}] = {v!r}")
|
||||
|
||||
|
||||
def _retarget_id(ob, orig, rep, orig_to_rep):
|
||||
"""Return rep, orig_to_rep[ob], or ob so constraint targets point to replacement when appropriate."""
|
||||
if ob is None:
|
||||
return None
|
||||
if ob == orig:
|
||||
return rep
|
||||
return orig_to_rep.get(ob, ob)
|
||||
|
||||
|
||||
def _copy_constraint_props(c, nc, orig, rep, orig_to_rep):
|
||||
"""Copy all copyable RNA properties from c to nc, retargeting object/armature pointers."""
|
||||
for rna_prop in c.bl_rna.properties:
|
||||
if rna_prop.is_readonly or rna_prop.identifier in ("name", "type"):
|
||||
continue
|
||||
if not hasattr(nc, rna_prop.identifier):
|
||||
continue
|
||||
try:
|
||||
val = getattr(c, rna_prop.identifier)
|
||||
except Exception:
|
||||
continue
|
||||
rna_type = getattr(rna_prop, "type", None)
|
||||
if rna_type == "POINTER":
|
||||
setattr(nc, rna_prop.identifier, _retarget_id(val, orig, rep, orig_to_rep))
|
||||
elif rna_type == "COLLECTION":
|
||||
# e.g. ArmatureConstraint.targets: ensure count then copy item props (target, subtarget, weight)
|
||||
try:
|
||||
dst_coll = getattr(nc, rna_prop.identifier)
|
||||
src_coll = getattr(c, rna_prop.identifier)
|
||||
add_fn = getattr(dst_coll, "add", None) or getattr(dst_coll, "new", None)
|
||||
for i in range(len(src_coll)):
|
||||
if i >= len(dst_coll) and add_fn:
|
||||
add_fn()
|
||||
for i, src_item in enumerate(src_coll):
|
||||
if i >= len(dst_coll):
|
||||
break
|
||||
dst_item = dst_coll[i]
|
||||
for p in dst_item.bl_rna.properties:
|
||||
if p.is_readonly or p.identifier == "name":
|
||||
continue
|
||||
if not hasattr(dst_item, p.identifier):
|
||||
continue
|
||||
try:
|
||||
v = getattr(src_item, p.identifier)
|
||||
if getattr(p, "type", None) == "POINTER":
|
||||
v = _retarget_id(v, orig, rep, orig_to_rep)
|
||||
setattr(dst_item, p.identifier, v)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
setattr(nc, rna_prop.identifier, val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_mig_bone_const(orig, rep, orig_to_rep):
|
||||
"""Bone constraints: remove stale on rep, copy from orig with full props (targets, etc.) and retarget, trim duplicates."""
|
||||
other_originals = [o for o in orig_to_rep if o != orig]
|
||||
for pb in rep.pose.bones:
|
||||
to_remove = [c for c in pb.constraints if getattr(c, "target", None) in other_originals]
|
||||
for c in to_remove:
|
||||
pb.constraints.remove(c)
|
||||
for pbone in orig.pose.bones:
|
||||
if pbone.name not in rep.pose.bones:
|
||||
continue
|
||||
rbone = rep.pose.bones[pbone.name]
|
||||
for c in pbone.constraints:
|
||||
nc = rbone.constraints.new(type=c.type)
|
||||
nc.name = c.name
|
||||
_copy_constraint_props(c, nc, orig, rep, orig_to_rep)
|
||||
for pb in orig.pose.bones:
|
||||
if pb.name not in rep.pose.bones:
|
||||
continue
|
||||
ro, rr = pb.constraints, rep.pose.bones[pb.name].constraints
|
||||
while len(rr) > len(ro):
|
||||
rr.remove(rr[-1])
|
||||
|
||||
|
||||
def run_retarg_relatives(orig, rep, rep_descendants, orig_to_rep):
|
||||
"""Retarget relations: parents, constraint targets, Armature modifiers to rep. Skip objects in orig's hierarchy (linked collection)."""
|
||||
# Replicate orig's parent on rep if it exists (keep world transform)
|
||||
if orig.parent is not None:
|
||||
# Store world matrix before reparenting
|
||||
world_matrix = rep.matrix_world.copy()
|
||||
rep.parent = orig.parent
|
||||
rep.parent_type = orig.parent_type
|
||||
rep.parent_bone = orig.parent_bone
|
||||
# Restore world matrix
|
||||
rep.matrix_world = world_matrix
|
||||
|
||||
orig_hierarchy = {orig} | descendants(orig)
|
||||
candidates = set(rep_descendants)
|
||||
for ob in bpy.data.objects:
|
||||
if getattr(ob.parent, "name", None) == orig.name:
|
||||
candidates.add(ob)
|
||||
for c in getattr(ob, "constraints", []):
|
||||
if getattr(c, "target", None) == orig:
|
||||
candidates.add(ob)
|
||||
candidates -= orig_hierarchy
|
||||
for ob in candidates:
|
||||
if ob.parent == orig:
|
||||
ob.parent = rep
|
||||
for c in getattr(ob, "constraints", []):
|
||||
if getattr(c, "target", None) == orig:
|
||||
c.target = rep
|
||||
if ob.type == "MESH" and ob.modifiers:
|
||||
for m in ob.modifiers:
|
||||
if getattr(m, "object", None) == orig:
|
||||
m.object = rep
|
||||
|
||||
|
||||
def _base_body_name_match(ob):
|
||||
"""True if object looks like the base body mesh (MESH, name has body+base)."""
|
||||
if ob.type != "MESH":
|
||||
return False
|
||||
name_lower = (ob.name + " " + (ob.data.name if ob.data else "")).lower()
|
||||
return "body" in name_lower and "base" in name_lower
|
||||
|
||||
|
||||
def _objects_in_collection_recursive(coll):
|
||||
"""Yield all objects in collection and nested collections."""
|
||||
for ob in coll.objects:
|
||||
yield ob
|
||||
for child in coll.children:
|
||||
yield from _objects_in_collection_recursive(child)
|
||||
|
||||
|
||||
def _find_base_body(armature, descendants_iter, rep_base_name=None):
|
||||
"""Return the base body mesh: in descendants (armature mod), or in armature's collection(s), matched by name."""
|
||||
def gather_candidates(ob_iter):
|
||||
candidates = []
|
||||
for ob in ob_iter:
|
||||
if not _base_body_name_match(ob):
|
||||
continue
|
||||
if ob.modifiers:
|
||||
for m in ob.modifiers:
|
||||
if m.type == "ARMATURE" and m.object == armature:
|
||||
return ob, candidates
|
||||
candidates.append(ob)
|
||||
return None, candidates
|
||||
|
||||
found, candidates = gather_candidates(descendants_iter)
|
||||
if found:
|
||||
return found
|
||||
# Fallback: base body may be in same collection as armature but not parented to it (e.g. linked).
|
||||
if not candidates:
|
||||
for coll in [collection_containing_armature(armature)] + list(getattr(armature, "users_collection", []) or []):
|
||||
if not coll:
|
||||
continue
|
||||
found, candidates = gather_candidates(_objects_in_collection_recursive(coll))
|
||||
if found:
|
||||
return found
|
||||
if candidates:
|
||||
break
|
||||
if not candidates:
|
||||
return None
|
||||
if rep_base_name:
|
||||
base = rep_base_name.rsplit(".", 1)[0] if "." in rep_base_name else rep_base_name
|
||||
for ob in candidates:
|
||||
if ob.name == base or ob.name.startswith(base + ".") or (ob.data and ob.data.name == base):
|
||||
return ob
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def run_mig_bbody_shapekeys(orig, rep, rep_descendants, context=None):
|
||||
"""Replacement base body: library override (fully editable when context given), copy shapekey values, then shape-key action."""
|
||||
orig_descendants = list(descendants(orig))
|
||||
for ob in list(rep_descendants):
|
||||
if not _base_body_name_match(ob):
|
||||
continue
|
||||
if ob.modifiers:
|
||||
for m in ob.modifiers:
|
||||
if m.type == "ARMATURE" and m.object == rep:
|
||||
break
|
||||
else:
|
||||
continue
|
||||
orig_base = _find_base_body(orig, orig_descendants, rep_base_name=ob.name)
|
||||
# Debug: base body mesh state before override handling.
|
||||
_lib = getattr(ob.data, "library", None)
|
||||
_ol = getattr(ob.data, "override_library", None)
|
||||
_sys = getattr(_ol, "is_system_override", None) if _ol else None
|
||||
print(f"[DLM step6] {ob.name} data: linked={_lib is not None}, override={_ol is not None}, is_system_override={_sys}")
|
||||
# Library override: use hierarchy create (fully editable) when context available, else single-id override.
|
||||
if getattr(ob, "library", None):
|
||||
if context:
|
||||
try:
|
||||
ob = ob.override_hierarchy_create(
|
||||
context.scene, context.view_layer, do_fully_editable=True
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
ob.override_create()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
ob.override_create()
|
||||
except Exception:
|
||||
pass
|
||||
if getattr(ob.data, "library", None):
|
||||
try:
|
||||
ob.data.override_create(remap_local_usages=True)
|
||||
# Make override user-editable (same as shift-click in data tab).
|
||||
ol = getattr(ob.data, "override_library", None)
|
||||
if ol is not None and getattr(ol, "is_system_override", None) is not None:
|
||||
try:
|
||||
ol.is_system_override = False
|
||||
except Exception as e:
|
||||
print(f"[DLM step6] {ob.name} set is_system_override=False: {e}")
|
||||
except Exception as e:
|
||||
print(f"[DLM step6] {ob.name} ob.data.override_create: {e}")
|
||||
elif getattr(ob.data, "override_library", None):
|
||||
ol = ob.data.override_library
|
||||
if getattr(ol, "is_system_override", False):
|
||||
try:
|
||||
ol.is_system_override = False
|
||||
except Exception as e:
|
||||
print(f"[DLM step6] {ob.name} set is_system_override=False: {e}")
|
||||
# Debug: state after override handling.
|
||||
_ol2 = getattr(ob.data, "override_library", None)
|
||||
_sys2 = getattr(_ol2, "is_system_override", None) if _ol2 else None
|
||||
print(f"[DLM step6] {ob.name} after: override={_ol2 is not None}, is_system_override={_sys2} (False=editable)")
|
||||
if ob.data.shape_keys:
|
||||
# Ensure we can write shape key values: override the Key block if it is linked.
|
||||
sk = ob.data.shape_keys
|
||||
if getattr(sk, "library", None):
|
||||
try:
|
||||
sk.override_create(remap_local_usages=True)
|
||||
except Exception as e:
|
||||
print(f"[DLM step6] {ob.name} shape_keys.override_create: {e}")
|
||||
# Copy shape key values from original base body to replacement (by matching key name).
|
||||
if orig_base and orig_base.data.shape_keys:
|
||||
rep_blocks = ob.data.shape_keys.key_blocks
|
||||
orig_blocks = orig_base.data.shape_keys.key_blocks
|
||||
n_copied = 0
|
||||
for orig_key in orig_blocks:
|
||||
rep_key = rep_blocks.get(orig_key.name)
|
||||
if rep_key is not None:
|
||||
rep_key.value = orig_key.value
|
||||
n_copied += 1
|
||||
print(f"[DLM step6] {ob.name} shapekey values: copied {n_copied}/{len(orig_blocks)} from {orig_base.name}")
|
||||
else:
|
||||
if not orig_base:
|
||||
print(f"[DLM step6] {ob.name} no orig base body found for armature {orig.name}")
|
||||
elif not orig_base.data.shape_keys:
|
||||
print(f"[DLM step6] {ob.name} orig base body has no shape_keys")
|
||||
if not ob.data.shape_keys.animation_data:
|
||||
ob.data.shape_keys.animation_data_create()
|
||||
sk_ad = ob.data.shape_keys.animation_data
|
||||
# Prefer action (and slot) from original base body; fallback to name lookup.
|
||||
orig_sk_ad = None
|
||||
if orig_base and orig_base.data.shape_keys:
|
||||
orig_sk_ad = orig_base.data.shape_keys.animation_data
|
||||
action = None
|
||||
if orig_sk_ad and getattr(orig_sk_ad, "action", None):
|
||||
action = orig_sk_ad.action
|
||||
if action is None:
|
||||
body_name = ob.name
|
||||
action = (
|
||||
bpy.data.actions.get(body_name + "Action")
|
||||
or bpy.data.actions.get(ob.data.name + "Action")
|
||||
or bpy.data.actions.get(body_name + "Action.001")
|
||||
)
|
||||
if action:
|
||||
# Duplicate action so repchar has independent copy
|
||||
dup_action = _duplicate_action(action, suffix=".rep")
|
||||
# Copy slot-related props before action so slot is applied (Blender 4.4+).
|
||||
if orig_sk_ad and hasattr(sk_ad, "last_slot_identifier") and hasattr(orig_sk_ad, "last_slot_identifier") and orig_sk_ad.last_slot_identifier:
|
||||
sk_ad.last_slot_identifier = orig_sk_ad.last_slot_identifier
|
||||
sk_ad.action = dup_action
|
||||
if orig_sk_ad and getattr(orig_sk_ad, "action_slot", None) and getattr(sk_ad, "action_slot", None):
|
||||
try:
|
||||
sk_ad.action_slot = orig_sk_ad.action_slot
|
||||
except Exception:
|
||||
pass
|
||||
for prop in ("action_blend_type", "action_extrapolation", "action_influence"):
|
||||
if orig_sk_ad and hasattr(orig_sk_ad, prop) and hasattr(sk_ad, prop):
|
||||
try:
|
||||
setattr(sk_ad, prop, getattr(orig_sk_ad, prop))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_full_migration(context):
|
||||
"""
|
||||
Run the full 7-step character migration for the single pair (manual or automatic).
|
||||
Returns (True, message) on success, (False, error_message) on failure.
|
||||
"""
|
||||
props = getattr(context.scene, "dynamic_link_manager", None)
|
||||
use_auto = props and getattr(props, "migrator_mode", False)
|
||||
orig, rep = (get_pair_automatic(context) if use_auto else get_pair_manual(context))
|
||||
if not orig or not rep:
|
||||
return False, "No character pair (set Original/Replacement or enable Automatic)."
|
||||
if orig == rep:
|
||||
return False, "Original and replacement must be different armatures."
|
||||
|
||||
orig_to_rep = {orig: rep}
|
||||
rep_descendants = descendants(rep)
|
||||
|
||||
try:
|
||||
run_copy_attr(orig, rep)
|
||||
run_mig_nla(orig, rep)
|
||||
run_mig_cust_props(orig, rep)
|
||||
run_mig_bone_const(orig, rep, orig_to_rep)
|
||||
run_retarg_relatives(orig, rep, rep_descendants, orig_to_rep)
|
||||
run_mig_bbody_shapekeys(orig, rep, rep_descendants, context)
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
return True, f"Migrated {orig.name} → {rep.name}"
|
||||
@@ -0,0 +1,146 @@
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
"""Tweak tools: add/remove/bake COPY_TRANSFORMS on Rigify arm/leg tweak bones."""
|
||||
|
||||
import bpy
|
||||
|
||||
# Rigify-style tweak bone names (only those present on armature are used)
|
||||
ARM_TWEAK_BONES = (
|
||||
"upper_arm_tweak.L", "upper_arm_tweak.R",
|
||||
"forearm_tweak.L", "forearm_tweak.R",
|
||||
"hand_tweak.L", "hand_tweak.R",
|
||||
)
|
||||
LEG_TWEAK_BONES = (
|
||||
"thigh_tweak.L", "thigh_tweak.R",
|
||||
"shin_tweak.L", "shin_tweak.R",
|
||||
"foot_tweak.L", "foot_tweak.R",
|
||||
)
|
||||
TWEAK_CONSTRAINT_NAME = "Copy from Original"
|
||||
|
||||
|
||||
def get_tweak_bones(armature, limb):
|
||||
"""Return list of tweak bone names that exist on armature. limb in 'arm', 'leg', 'both'."""
|
||||
if not armature or armature.type != "ARMATURE" or not armature.pose:
|
||||
return []
|
||||
bones = armature.pose.bones
|
||||
if limb == "arm":
|
||||
names = ARM_TWEAK_BONES
|
||||
elif limb == "leg":
|
||||
names = LEG_TWEAK_BONES
|
||||
elif limb == "both":
|
||||
names = ARM_TWEAK_BONES + LEG_TWEAK_BONES
|
||||
else:
|
||||
return []
|
||||
return [n for n in names if n in bones]
|
||||
|
||||
|
||||
def add_tweak_constraints(orig, rep, limb):
|
||||
"""On rep, add COPY_TRANSFORMS on each tweak bone targeting same bone on orig."""
|
||||
names = get_tweak_bones(rep, limb)
|
||||
for name in names:
|
||||
pb = rep.pose.bones[name]
|
||||
c = pb.constraints.new(type="COPY_TRANSFORMS")
|
||||
c.name = TWEAK_CONSTRAINT_NAME
|
||||
c.target = orig
|
||||
c.subtarget = name
|
||||
c.mix_mode = "REPLACE"
|
||||
|
||||
|
||||
def remove_tweak_constraints(orig, rep, limb):
|
||||
"""On rep, remove COPY_TRANSFORMS that target orig from tweak bones."""
|
||||
names = get_tweak_bones(rep, limb)
|
||||
removed = 0
|
||||
for name in names:
|
||||
pb = rep.pose.bones[name]
|
||||
to_remove = [
|
||||
c for c in pb.constraints
|
||||
if c.type == "COPY_TRANSFORMS" and getattr(c, "target", None) == orig
|
||||
]
|
||||
for c in to_remove:
|
||||
pb.constraints.remove(c)
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def _frame_range_from_track(rep, track_name):
|
||||
"""Return (frame_start, frame_end) from rep's NLA track named track_name, or None."""
|
||||
if not track_name or not rep.animation_data or not rep.animation_data.nla_tracks:
|
||||
return None
|
||||
track = rep.animation_data.nla_tracks.get(track_name)
|
||||
if not track or not track.strips:
|
||||
return None
|
||||
start = min(s.frame_start for s in track.strips)
|
||||
end = max(s.frame_end for s in track.strips)
|
||||
return (int(start), int(end))
|
||||
|
||||
|
||||
def bake_tweak_constraints(context, orig, rep, limb, track_name, post_clean):
|
||||
"""
|
||||
Select rep's tweak bones, run nla.bake, optionally run clean + decimate.
|
||||
Returns (True, message) or (False, error_message).
|
||||
"""
|
||||
names = get_tweak_bones(rep, limb)
|
||||
if not names:
|
||||
return False, f"No tweak bones found for {limb} on {rep.name}"
|
||||
|
||||
scene = context.scene
|
||||
frame_range = _frame_range_from_track(rep, track_name) if track_name else None
|
||||
if not frame_range:
|
||||
frame_range = (scene.frame_start, scene.frame_end)
|
||||
frame_start, frame_end = frame_range
|
||||
|
||||
# Ensure rep is active and in pose mode
|
||||
if context.view_layer.objects.active != rep:
|
||||
context.view_layer.objects.active = rep
|
||||
if rep.mode != "POSE":
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
|
||||
# Select only tweak bones on rep
|
||||
bpy.ops.pose.select_all(action="DESELECT")
|
||||
for name in names:
|
||||
rep.pose.bones[name].bone.select = True
|
||||
|
||||
# Bake
|
||||
try:
|
||||
bpy.ops.nla.bake(
|
||||
frame_start=frame_start,
|
||||
frame_end=frame_end,
|
||||
step=1,
|
||||
only_selected=True,
|
||||
visual_keying=True,
|
||||
clear_constraints=True,
|
||||
clear_parents=True,
|
||||
use_current_action=True,
|
||||
clean_curves=False,
|
||||
bake_types={"POSE"},
|
||||
channel_types={"LOCATION", "ROTATION"},
|
||||
)
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
if not post_clean:
|
||||
return True, f"Baked {len(names)} tweak bones ({frame_start}-{frame_end})."
|
||||
|
||||
# Post-clean: find an area we can use for action/graph ops
|
||||
win = context.window
|
||||
for area in win.screen.areas:
|
||||
if area.type == "DOPESHEET_EDITOR":
|
||||
with context.temp_override(window=win, area=area):
|
||||
try:
|
||||
bpy.ops.action.clean_keyframes()
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
for area in win.screen.areas:
|
||||
if area.type == "GRAPH_EDITOR":
|
||||
with context.temp_override(window=win, area=area):
|
||||
try:
|
||||
bpy.ops.graph.decimate(mode="ERROR", error=0.001)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
return True, f"Baked and cleaned {len(names)} tweak bones ({frame_start}-{frame_end})."
|
||||
@@ -1,294 +0,0 @@
|
||||
import bpy
|
||||
from bpy.types import Panel, PropertyGroup, AddonPreferences, UIList
|
||||
from bpy.props import IntProperty, StringProperty, BoolProperty, CollectionProperty
|
||||
|
||||
# Properties for search paths
|
||||
class SearchPathItem(PropertyGroup):
|
||||
path: StringProperty(
|
||||
name="Search Path",
|
||||
description="Path to search for missing linked libraries",
|
||||
subtype='DIR_PATH'
|
||||
)
|
||||
|
||||
# Properties for individual linked datablocks
|
||||
class LinkedDatablockItem(PropertyGroup):
|
||||
name: StringProperty(name="Name", description="Name of the linked datablock")
|
||||
type: StringProperty(name="Type", description="Type of the linked datablock")
|
||||
|
||||
# UIList for linked libraries
|
||||
class DLM_UL_library_list(UIList):
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||
custom_icon = 'FILE_BLEND'
|
||||
|
||||
if self.layout_type in {'DEFAULT', 'COMPACT'}:
|
||||
# Library name and status
|
||||
layout.scale_x = 0.4
|
||||
layout.label(text=item.name)
|
||||
|
||||
# Status indicator
|
||||
layout.scale_x = 0.3
|
||||
if item.is_missing:
|
||||
layout.label(text="MISSING", icon='ERROR')
|
||||
elif item.is_indirect:
|
||||
layout.label(text="INDIRECT", icon='INFO')
|
||||
else:
|
||||
layout.label(text="OK", icon='FILE_BLEND')
|
||||
|
||||
# File path (abbreviated)
|
||||
layout.scale_x = 0.3
|
||||
path_text = item.filepath
|
||||
if path_text.startswith("\\\\"):
|
||||
# Network path: show server name
|
||||
parts = path_text.split("\\")
|
||||
if len(parts) >= 3:
|
||||
short_path = f"\\\\{parts[2]}\\"
|
||||
else:
|
||||
short_path = "\\\\ (network)"
|
||||
elif len(path_text) >= 2 and path_text[1] == ':':
|
||||
# Drive path: show drive letter
|
||||
short_path = f"{path_text[:2]}\\"
|
||||
elif path_text.startswith("//"):
|
||||
# Relative path
|
||||
short_path = "// (relative)"
|
||||
else:
|
||||
short_path = path_text[:15] + "..." if len(path_text) > 15 else path_text
|
||||
layout.label(text=short_path, icon='FILE_FOLDER')
|
||||
|
||||
elif self.layout_type in {'GRID'}:
|
||||
layout.alignment = 'CENTER'
|
||||
layout.label(text="", icon=custom_icon)
|
||||
|
||||
# Properties for a single linked library file
|
||||
class LinkedLibraryItem(PropertyGroup):
|
||||
filepath: StringProperty(name="File Path", description="Path to the linked .blend file")
|
||||
name: StringProperty(name="Name", description="Name of the linked .blend file")
|
||||
is_missing: BoolProperty(name="Missing", description="True if the linked file is not found")
|
||||
is_indirect: BoolProperty(name="Is Indirect", description="True if this is an indirectly linked library")
|
||||
is_expanded: BoolProperty(name="Expanded", description="Whether this library item is expanded in the UI", default=True)
|
||||
linked_datablocks: CollectionProperty(type=LinkedDatablockItem, name="Linked Datablocks")
|
||||
|
||||
class DynamicLinkManagerProperties(PropertyGroup):
|
||||
linked_libraries: CollectionProperty(type=LinkedLibraryItem, name="Linked Libraries")
|
||||
linked_libraries_index: IntProperty(
|
||||
name="Linked Libraries Index",
|
||||
description="Index of the selected linked library",
|
||||
default=0
|
||||
)
|
||||
linked_assets_count: IntProperty(
|
||||
name="Linked Assets Count",
|
||||
description="Number of linked assets found in scene",
|
||||
default=0
|
||||
)
|
||||
|
||||
linked_libraries_expanded: BoolProperty(
|
||||
name="Linked Libraries Expanded",
|
||||
description="Whether the linked libraries section is expanded",
|
||||
default=True
|
||||
)
|
||||
|
||||
selected_asset_path: StringProperty(
|
||||
name="Selected Asset Path",
|
||||
description="Path to the currently selected asset",
|
||||
default=""
|
||||
)
|
||||
|
||||
# Addon preferences for search paths
|
||||
class DynamicLinkManagerPreferences(AddonPreferences):
|
||||
bl_idname = __package__
|
||||
|
||||
search_paths: CollectionProperty(
|
||||
type=SearchPathItem,
|
||||
name="Search Paths",
|
||||
description="Paths to search for missing linked libraries"
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
# Search paths section
|
||||
box = layout.box()
|
||||
box.label(text="Default Search Paths for Missing Libraries")
|
||||
|
||||
# Add button - right-justified
|
||||
row = box.row()
|
||||
row.alignment = 'RIGHT'
|
||||
row.operator("dlm.add_search_path", text="Add search path", icon='ADD')
|
||||
|
||||
# List search paths
|
||||
for i, path_item in enumerate(self.search_paths):
|
||||
row = box.row()
|
||||
row.prop(path_item, "path", text=f"Search path {i+1}")
|
||||
# Folder icon for browsing
|
||||
row.operator("dlm.browse_search_path", text="", icon='FILE_FOLDER').index = i
|
||||
# Remove button
|
||||
row.operator("dlm.remove_search_path", text="", icon='REMOVE').index = i
|
||||
|
||||
class DLM_PT_main_panel(Panel):
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Dynamic Link Manager'
|
||||
bl_label = "Dynamic Link Manager"
|
||||
|
||||
def get_short_path(self, filepath):
|
||||
"""Extract a short, readable path from a full filepath"""
|
||||
if not filepath:
|
||||
return "Unknown"
|
||||
|
||||
# Handle relative paths
|
||||
if filepath.startswith("//"):
|
||||
return "// (relative)"
|
||||
|
||||
# Handle Windows network paths
|
||||
if filepath.startswith("\\\\"):
|
||||
# Extract server name (e.g., \\NAS\ or \\NEXUS\)
|
||||
parts = filepath.split("\\")
|
||||
if len(parts) >= 3:
|
||||
return f"\\\\{parts[2]}\\"
|
||||
return "\\\\ (network)"
|
||||
|
||||
# Handle Windows drive paths
|
||||
if len(filepath) >= 2 and filepath[1] == ':':
|
||||
return f"{filepath[:2]}\\"
|
||||
|
||||
# Handle Unix-style paths
|
||||
if filepath.startswith("/"):
|
||||
parts = filepath.split("/")
|
||||
if len(parts) >= 2:
|
||||
return f"/{parts[1]}/"
|
||||
return "/ (root)"
|
||||
|
||||
# Fallback
|
||||
return "Unknown"
|
||||
|
||||
def draw_header(self, context):
|
||||
layout = self.layout
|
||||
layout.operator("preferences.addon_show", text="", icon='PREFERENCES').module = __package__
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
props = context.scene.dynamic_link_manager
|
||||
|
||||
# Path management buttons
|
||||
row = layout.row()
|
||||
row.operator("dlm.make_paths_relative", text="Make Paths Relative", icon='FILE_PARENT')
|
||||
row.operator("dlm.make_paths_absolute", text="Make Paths Absolute", icon='FILE_FOLDER')
|
||||
|
||||
# Main scan section
|
||||
box = layout.box()
|
||||
box.label(text="Linked Libraries Analysis")
|
||||
row = box.row()
|
||||
row.operator("dlm.scan_linked_assets", text="Scan Linked Assets", icon='FILE_REFRESH')
|
||||
|
||||
# Show total count and missing count
|
||||
missing_count = sum(1 for lib in props.linked_libraries if lib.is_missing)
|
||||
if missing_count > 0:
|
||||
row.label(text=f"({props.linked_assets_count} libraries, {missing_count} missing)", icon='ERROR')
|
||||
else:
|
||||
row.label(text=f"({props.linked_assets_count} libraries)")
|
||||
|
||||
# Show more detailed info if we have results
|
||||
if props.linked_assets_count > 0:
|
||||
# Linked Libraries section with single dropdown
|
||||
row = box.row(align=True)
|
||||
|
||||
# Dropdown arrow for the entire section
|
||||
icon = 'DISCLOSURE_TRI_DOWN' if props.linked_libraries_expanded else 'DISCLOSURE_TRI_RIGHT'
|
||||
row.prop(props, "linked_libraries_expanded", text="", icon=icon, icon_only=True)
|
||||
|
||||
# Section header
|
||||
row.label(text="Linked Libraries:")
|
||||
row.label(text=f"({props.linked_assets_count} libraries)")
|
||||
|
||||
# Only show library details if section is expanded
|
||||
if props.linked_libraries_expanded:
|
||||
# Compact list view
|
||||
row = box.row()
|
||||
row.template_list("DLM_UL_library_list", "", props, "linked_libraries", props, "linked_libraries_index")
|
||||
|
||||
# Action buttons below the list
|
||||
row = box.row()
|
||||
row.operator("dlm.reload_libraries", text="Reload Libraries", icon='FILE_REFRESH')
|
||||
|
||||
# Search Paths Management - integrated into Linked Libraries Analysis
|
||||
if props.linked_assets_count > 0:
|
||||
# Get preferences for search paths
|
||||
prefs = context.preferences.addons.get(__package__)
|
||||
|
||||
# Search paths list - Each path gets its own row with folder icon
|
||||
if prefs and prefs.preferences.search_paths:
|
||||
for i, path_item in enumerate(prefs.preferences.search_paths):
|
||||
row = box.row()
|
||||
row.prop(path_item, "path", text=f"Search path {i+1}")
|
||||
# Folder icon for browsing
|
||||
row.operator("dlm.browse_search_path", text="", icon='FILE_FOLDER').index = i
|
||||
# Remove button
|
||||
row.operator("dlm.remove_search_path", text="", icon='REMOVE').index = i
|
||||
|
||||
# Add button - Just the + button, right-justified
|
||||
row = box.row()
|
||||
row.alignment = 'RIGHT'
|
||||
row.operator("dlm.add_search_path", text="Add search path", icon='ADD')
|
||||
|
||||
# Main action button
|
||||
if missing_count > 0:
|
||||
row = box.row()
|
||||
row.operator("dlm.find_libraries_in_folders", text="Find libraries in these folders", icon='VIEWZOOM')
|
||||
|
||||
# Show details of selected item at the bottom
|
||||
if props.linked_libraries and 0 <= props.linked_libraries_index < len(props.linked_libraries):
|
||||
selected_lib = props.linked_libraries[props.linked_libraries_index]
|
||||
info_box = box.box()
|
||||
|
||||
# Make entire box red if library is missing
|
||||
if selected_lib.is_missing:
|
||||
info_box.alert = True
|
||||
|
||||
info_box.label(text=f"Selected: {selected_lib.name}")
|
||||
|
||||
if selected_lib.is_missing:
|
||||
row = info_box.row()
|
||||
row.label(text="Status: MISSING", icon='ERROR')
|
||||
elif selected_lib.is_indirect:
|
||||
row = info_box.row()
|
||||
row.label(text="Status: INDIRECT", icon='INFO')
|
||||
else:
|
||||
row = info_box.row()
|
||||
row.label(text="Status: OK", icon='FILE_BLEND')
|
||||
|
||||
# Show full path and Open Blend button
|
||||
row = info_box.row()
|
||||
row.label(text=f"Path: {selected_lib.filepath}", icon='FILE_FOLDER')
|
||||
|
||||
row = info_box.row()
|
||||
row.operator("dlm.open_linked_file", text="Open Blend", icon='FILE_BLEND').filepath = selected_lib.filepath
|
||||
|
||||
# Relocate (context-free): lets the user pick a new .blend and we reload the library
|
||||
row = info_box.row()
|
||||
op = row.operator("dlm.relocate_single_library", text="Relocate Library", icon='FILE_FOLDER')
|
||||
op.target_filepath = selected_lib.filepath
|
||||
|
||||
|
||||
|
||||
def register():
|
||||
bpy.utils.register_class(SearchPathItem)
|
||||
bpy.utils.register_class(LinkedDatablockItem)
|
||||
bpy.utils.register_class(DLM_UL_library_list)
|
||||
bpy.utils.register_class(LinkedLibraryItem)
|
||||
bpy.utils.register_class(DynamicLinkManagerProperties)
|
||||
bpy.utils.register_class(DynamicLinkManagerPreferences)
|
||||
bpy.utils.register_class(DLM_PT_main_panel)
|
||||
|
||||
# Register properties to scene
|
||||
bpy.types.Scene.dynamic_link_manager = bpy.props.PointerProperty(type=DynamicLinkManagerProperties)
|
||||
|
||||
def unregister():
|
||||
# Unregister properties from scene
|
||||
del bpy.types.Scene.dynamic_link_manager
|
||||
|
||||
bpy.utils.unregister_class(DLM_PT_main_panel)
|
||||
bpy.utils.unregister_class(DynamicLinkManagerPreferences)
|
||||
bpy.utils.unregister_class(DynamicLinkManagerProperties)
|
||||
bpy.utils.unregister_class(LinkedLibraryItem)
|
||||
bpy.utils.unregister_class(DLM_UL_library_list)
|
||||
bpy.utils.unregister_class(LinkedDatablockItem)
|
||||
bpy.utils.unregister_class(SearchPathItem)
|
||||
@@ -0,0 +1,24 @@
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
from .operators import OPERATOR_CLASSES
|
||||
from .panels import DLM_PT_main_panel, DLM_UL_library_list
|
||||
from .preferences import DynamicLinkManagerPreferences
|
||||
from . import properties
|
||||
|
||||
PANEL_CLASSES = [DLM_PT_main_panel, DLM_UL_library_list]
|
||||
|
||||
CLASSES = (
|
||||
properties.SearchPathItem,
|
||||
properties.LinkedDatablockItem,
|
||||
properties.LinkedLibraryItem,
|
||||
properties.DynamicLinkManagerProperties,
|
||||
DynamicLinkManagerPreferences,
|
||||
DLM_UL_library_list,
|
||||
DLM_PT_main_panel,
|
||||
*OPERATOR_CLASSES,
|
||||
)
|
||||
|
||||
__all__ = ["CLASSES", "OPERATOR_CLASSES", "PANEL_CLASSES", "DynamicLinkManagerPreferences", "properties"]
|
||||
+834
@@ -0,0 +1,834 @@
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
import bpy
|
||||
import os
|
||||
from bpy.types import Operator
|
||||
from bpy.props import StringProperty, BoolProperty
|
||||
from bpy.props import StringProperty, IntProperty
|
||||
|
||||
from ..utils import collection_containing_armature
|
||||
|
||||
ADDON_NAME = __package__.rsplit(".", 1)[0] if "." in __package__ else __package__
|
||||
|
||||
|
||||
def _prefs(context):
|
||||
return context.preferences.addons.get(ADDON_NAME)
|
||||
|
||||
|
||||
class DLM_OT_replace_linked_asset(Operator):
|
||||
bl_idname = "dlm.replace_linked_asset"
|
||||
bl_label = "Replace Linked Asset"
|
||||
bl_description = "Open file browser to replace the linked asset with another file"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
filepath: StringProperty(name="File Path", description="Path to the new asset file", subtype="FILE_PATH")
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
self.report({"ERROR"}, "No object selected")
|
||||
return {"CANCELLED"}
|
||||
if getattr(obj, "library", None):
|
||||
self.report({"INFO"}, f"Object '{obj.name}' is linked from: {obj.library.filepath}")
|
||||
return {"FINISHED"}
|
||||
if obj.data and getattr(obj.data, "library", None) and obj.data.library:
|
||||
self.report({"INFO"}, f"Object '{obj.name}' has linked data from: {obj.data.library.filepath}")
|
||||
return {"FINISHED"}
|
||||
if obj.type == "ARMATURE" and obj.data and obj.data.name in bpy.data.armatures:
|
||||
ad = bpy.data.armatures[obj.data.name]
|
||||
if getattr(ad, "library", None) and ad.library:
|
||||
self.report({"INFO"}, f"Armature '{obj.name}' data is linked from: {ad.library.filepath}")
|
||||
return {"FINISHED"}
|
||||
self.report({"ERROR"}, "Selected object is not a linked asset")
|
||||
return {"CANCELLED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
class DLM_OT_scan_linked_assets(Operator):
|
||||
bl_idname = "dlm.scan_linked_assets"
|
||||
bl_label = "Scan Linked Libraries"
|
||||
bl_description = "Scan the current file for linked libraries and list their status"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
from ..ops import library
|
||||
|
||||
return library.scan_linked_assets(context, self.report)
|
||||
|
||||
|
||||
class DLM_OT_find_libraries_in_folders(Operator):
|
||||
bl_idname = "dlm.find_libraries_in_folders"
|
||||
bl_label = "Find Libraries in Folders"
|
||||
bl_description = "Search addon search paths for missing library blend files"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
from ..ops import library
|
||||
|
||||
return library.find_libraries_in_folders(context, self.report, ADDON_NAME)
|
||||
|
||||
|
||||
class DLM_OT_open_linked_file(Operator):
|
||||
bl_idname = "dlm.open_linked_file"
|
||||
bl_label = "Open Linked File"
|
||||
bl_description = "Open the selected linked blend file in a new Blender instance"
|
||||
bl_options = {"REGISTER"}
|
||||
filepath: StringProperty(name="File Path", default="")
|
||||
|
||||
def execute(self, context):
|
||||
if not self.filepath:
|
||||
self.report({"ERROR"}, "No file path specified")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
bpy.ops.wm.path_open(filepath=self.filepath)
|
||||
self.report({"INFO"}, f"Opening linked file: {self.filepath}")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to open linked file: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_add_search_path(Operator):
|
||||
bl_idname = "dlm.add_search_path"
|
||||
bl_label = "Add Search Path"
|
||||
bl_description = "Add a new folder to the list of search paths for finding libraries"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
prefs = _prefs(context)
|
||||
if prefs:
|
||||
p = prefs.preferences.search_paths.add()
|
||||
p.path = "//"
|
||||
self.report({"INFO"}, f"Added search path: {p.path}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_remove_search_path(Operator):
|
||||
bl_idname = "dlm.remove_search_path"
|
||||
bl_label = "Remove Search Path"
|
||||
bl_description = "Remove the selected search path from the list"
|
||||
bl_options = {"REGISTER"}
|
||||
index: IntProperty(name="Index", default=0)
|
||||
|
||||
def execute(self, context):
|
||||
prefs = _prefs(context)
|
||||
if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
|
||||
prefs.preferences.search_paths.remove(self.index)
|
||||
self.report({"INFO"}, f"Removed search path at index {self.index}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_attempt_relink(Operator):
|
||||
bl_idname = "dlm.attempt_relink"
|
||||
bl_label = "Attempt Relink"
|
||||
bl_description = "Try to relink missing libraries using the configured search paths"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
from ..ops import library
|
||||
|
||||
return library.attempt_relink(context, self.report, ADDON_NAME)
|
||||
|
||||
|
||||
class DLM_OT_browse_search_path(Operator):
|
||||
bl_idname = "dlm.browse_search_path"
|
||||
bl_label = "Browse Search Path"
|
||||
bl_description = "Browse to set the folder for the selected search path"
|
||||
bl_options = {"REGISTER"}
|
||||
index: IntProperty(name="Index", default=0)
|
||||
filepath: StringProperty(name="Search Path", subtype="DIR_PATH")
|
||||
|
||||
def execute(self, context):
|
||||
prefs = _prefs(context)
|
||||
if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
|
||||
prefs.preferences.search_paths[self.index].path = self.filepath
|
||||
self.report({"INFO"}, f"Updated search path {self.index + 1}: {self.filepath}")
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
prefs = _prefs(context)
|
||||
if prefs and prefs.preferences.search_paths and 0 <= self.index < len(prefs.preferences.search_paths):
|
||||
self.filepath = prefs.preferences.search_paths[self.index].path
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
class DLM_OT_reload_libraries(Operator):
|
||||
bl_idname = "dlm.reload_libraries"
|
||||
bl_label = "Reload Libraries"
|
||||
bl_description = "Reload all linked libraries (or fallback manual reload)"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
bpy.ops.outliner.lib_operation(type="RELOAD")
|
||||
self.report({"INFO"}, "Library reload operation completed")
|
||||
except Exception:
|
||||
try:
|
||||
for lib in bpy.data.libraries:
|
||||
if lib.filepath and os.path.exists(bpy.path.abspath(lib.filepath)):
|
||||
lib.reload()
|
||||
self.report({"INFO"}, "Libraries reloaded manually")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to reload libraries: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_make_paths_relative(Operator):
|
||||
bl_idname = "dlm.make_paths_relative"
|
||||
bl_label = "Make Paths Relative"
|
||||
bl_description = "Convert all internal file paths to relative"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
bpy.ops.file.make_paths_relative()
|
||||
self.report({"INFO"}, "All file paths made relative")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to make paths relative: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_make_paths_absolute(Operator):
|
||||
bl_idname = "dlm.make_paths_absolute"
|
||||
bl_label = "Make Paths Absolute"
|
||||
bl_description = "Convert all internal file paths to absolute"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
bpy.ops.file.make_paths_absolute()
|
||||
self.report({"INFO"}, "All file paths made absolute")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to make paths absolute: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_relocate_single_library(Operator):
|
||||
bl_idname = "dlm.relocate_single_library"
|
||||
bl_label = "Relocate Library"
|
||||
bl_description = "Point the selected library to a new blend file and reload"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
target_filepath: StringProperty(name="Current Library Path", default="")
|
||||
filepath: StringProperty(name="New Library File", subtype="FILE_PATH", default="")
|
||||
|
||||
def execute(self, context):
|
||||
if not self.target_filepath or not self.filepath:
|
||||
self.report({"ERROR"}, "No target or new file specified")
|
||||
return {"CANCELLED"}
|
||||
abs_match = bpy.path.abspath(self.target_filepath) if self.target_filepath else ""
|
||||
library = None
|
||||
for lib in bpy.data.libraries:
|
||||
try:
|
||||
if lib.filepath and bpy.path.abspath(lib.filepath) == abs_match:
|
||||
library = lib
|
||||
break
|
||||
except Exception:
|
||||
if lib.filepath == self.target_filepath:
|
||||
library = lib
|
||||
break
|
||||
if not library:
|
||||
self.report({"ERROR"}, "Could not resolve the selected library")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
library.filepath = self.filepath
|
||||
library.reload()
|
||||
self.report({"INFO"}, f"Relocated to: {self.filepath}")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to relocate: {e}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
if self.target_filepath:
|
||||
try:
|
||||
self.filepath = bpy.path.abspath(self.target_filepath)
|
||||
except Exception:
|
||||
self.filepath = self.target_filepath
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
def _get_migrator_pair(context):
|
||||
"""Return (orig, rep) from scene props (manual or automatic). (None, None) if invalid."""
|
||||
from ..ops.migrator import get_pair_manual, get_pair_automatic
|
||||
|
||||
props = getattr(context.scene, "dynamic_link_manager", None)
|
||||
if not props:
|
||||
return None, None
|
||||
use_auto = getattr(props, "migrator_mode", False)
|
||||
orig, rep = get_pair_automatic(context) if use_auto else get_pair_manual(context)
|
||||
return orig, rep
|
||||
|
||||
|
||||
class DLM_OT_migrator_copy_attributes(Operator):
|
||||
bl_idname = "dlm.migrator_copy_attributes"
|
||||
bl_label = "CopyAttr"
|
||||
bl_description = "Copy object and armature attributes from original to replacement character"
|
||||
bl_icon = "COPY_ID"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair (set Original/Replacement or enable Automatic).")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_copy_attr
|
||||
run_copy_attr(orig, rep)
|
||||
self.report({"INFO"}, "Copy attributes done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_migrate_nla(Operator):
|
||||
bl_idname = "dlm.migrator_migrate_nla"
|
||||
bl_label = "MigNLA"
|
||||
bl_description = "Migrate NLA tracks and strips from original to replacement character"
|
||||
bl_icon = "NLA"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_mig_nla
|
||||
run_mig_nla(orig, rep, report=self.report)
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_custom_properties(Operator):
|
||||
bl_idname = "dlm.migrator_custom_properties"
|
||||
bl_label = "MigCustProps"
|
||||
bl_description = "Copy custom properties from original to replacement character"
|
||||
bl_icon = "PROPERTIES"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_mig_cust_props
|
||||
run_mig_cust_props(orig, rep)
|
||||
self.report({"INFO"}, "Custom properties done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_bone_constraints(Operator):
|
||||
bl_idname = "dlm.migrator_bone_constraints"
|
||||
bl_label = "MigBoneConst"
|
||||
bl_description = "Migrate bone constraints from original to replacement armature"
|
||||
bl_icon = "CONSTRAINT_BONE"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_mig_bone_const
|
||||
orig_to_rep = {orig: rep}
|
||||
run_mig_bone_const(orig, rep, orig_to_rep)
|
||||
self.report({"INFO"}, "Bone constraints done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_retarget_relations(Operator):
|
||||
bl_idname = "dlm.migrator_retarget_relations"
|
||||
bl_label = "RetargRelatives"
|
||||
bl_description = "Retarget parent/child and other relations to the replacement character"
|
||||
bl_icon = "ORIENTATION_PARENT"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_retarg_relatives
|
||||
from ..utils import descendants
|
||||
rep_descendants = descendants(rep)
|
||||
orig_to_rep = {orig: rep}
|
||||
run_retarg_relatives(orig, rep, rep_descendants, orig_to_rep)
|
||||
self.report({"INFO"}, "Retarget relations done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_basebody_shapekeys(Operator):
|
||||
bl_idname = "dlm.migrator_basebody_shapekeys"
|
||||
bl_label = "MigBBodyShapeKeys"
|
||||
bl_description = "Migrate base body mesh shape key values from original to replacement"
|
||||
bl_icon = "SHAPEKEY_DATA"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.migrator import run_mig_bbody_shapekeys
|
||||
from ..utils import descendants
|
||||
rep_descendants = descendants(rep)
|
||||
run_mig_bbody_shapekeys(orig, rep, rep_descendants, context)
|
||||
self.report({"INFO"}, "Migrate BaseBody shapekeys done.")
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_fk_rotations(Operator):
|
||||
bl_idname = "dlm.migrator_fk_rotations"
|
||||
bl_label = "MigFKRot"
|
||||
bl_description = "Copy FK arm and finger rotations from original to replacement (uses constraints)"
|
||||
bl_icon = "BONE_DATA"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.fk_rotations import copy_fk_rotations
|
||||
ok, msg = copy_fk_rotations(context, orig, rep)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_fk_rotations_bake(Operator):
|
||||
bl_idname = "dlm.migrator_fk_rotations_bake"
|
||||
bl_label = "Bake MigFKRot"
|
||||
bl_description = "Bake FK rotations to keyframes using nla.bake (similar to tweak tools)"
|
||||
bl_icon = "KEYFRAME"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
track_name: StringProperty(name="NLA Track", description="Optional NLA track name for frame range", default="")
|
||||
post_clean: BoolProperty(name="Post-clean", description="Clean curves after bake", default=False)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.fk_rotations import bake_fk_rotations
|
||||
ok, msg = bake_fk_rotations(context, orig, rep, track_name=self.track_name or None, post_clean=self.post_clean)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_migrator_fk_rotations_remove(Operator):
|
||||
bl_idname = "dlm.migrator_fk_rotations_remove"
|
||||
bl_label = "Remove MigFKRot"
|
||||
bl_description = "Remove FK rotation COPY_TRANSFORMS constraints (similar to tweak_remove_arm)"
|
||||
bl_icon = "X"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig or not rep or orig == rep:
|
||||
self.report({"ERROR"}, "No valid character pair.")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
from ..ops.fk_rotations import remove_fk_rotations
|
||||
ok, msg = remove_fk_rotations(context, rep)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
|
||||
|
||||
class DLM_OT_migrator_remove_original(Operator):
|
||||
bl_idname = "dlm.migrator_remove_original"
|
||||
bl_label = "Remove Original"
|
||||
bl_description = "Delete the original character armature and its data from the scene"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
if not orig:
|
||||
self.report({"WARNING"}, "No original character selected")
|
||||
return {"CANCELLED"}
|
||||
if orig == rep:
|
||||
self.report({"ERROR"}, "Original and replacement cannot be the same object")
|
||||
return {"CANCELLED"}
|
||||
|
||||
name = orig.name
|
||||
|
||||
# Collect actions from original character before removal
|
||||
actions_to_remove = set()
|
||||
if orig.animation_data:
|
||||
# Active action
|
||||
if orig.animation_data.action:
|
||||
actions_to_remove.add(orig.animation_data.action)
|
||||
# NLA strips
|
||||
for track in orig.animation_data.nla_tracks:
|
||||
for strip in track.strips:
|
||||
if strip.action:
|
||||
actions_to_remove.add(strip.action)
|
||||
|
||||
# Remove collected actions from bpy.data.actions
|
||||
removed_actions = []
|
||||
for action in actions_to_remove:
|
||||
action_name = action.name
|
||||
try:
|
||||
bpy.data.actions.remove(action)
|
||||
removed_actions.append(action_name)
|
||||
except Exception as e:
|
||||
self.report({"WARNING"}, f"Could not remove action {action_name}: {e}")
|
||||
|
||||
if removed_actions:
|
||||
self.report({"INFO"}, f"Removed {len(removed_actions)} action(s) from original")
|
||||
|
||||
try:
|
||||
# Try to find and delete the collection containing the original character
|
||||
coll = collection_containing_armature(orig)
|
||||
if coll:
|
||||
coll_name = coll.name # Store name BEFORE removal (RNA invalidates after remove)
|
||||
context.scene.dynamic_link_manager.original_character = None
|
||||
try:
|
||||
bpy.data.collections.remove(coll)
|
||||
self.report({"INFO"}, f"Removed collection: {coll_name}")
|
||||
except Exception as remove_err:
|
||||
# Collection may have already been removed by another process
|
||||
self.report({"WARNING"}, f"Collection {coll_name} removal issue: {remove_err}")
|
||||
else:
|
||||
# Fallback: just delete the armature object
|
||||
bpy.data.objects.remove(orig, do_unlink=True)
|
||||
context.scene.dynamic_link_manager.original_character = None
|
||||
self.report({"INFO"}, f"Removed original character: {name}")
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to remove original: {e}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Rename replacement actions with ".rep" suffix
|
||||
if rep and rep.animation_data:
|
||||
renamed_actions = []
|
||||
# Active action
|
||||
if rep.animation_data.action and ".rep" in rep.animation_data.action.name:
|
||||
old_name = rep.animation_data.action.name
|
||||
new_name = old_name.replace(".rep", "")
|
||||
rep.animation_data.action.name = new_name
|
||||
renamed_actions.append(f"{old_name} -> {new_name}")
|
||||
# NLA strips
|
||||
for track in rep.animation_data.nla_tracks:
|
||||
for strip in track.strips:
|
||||
if strip.action and ".rep" in strip.action.name:
|
||||
old_name = strip.action.name
|
||||
new_name = old_name.replace(".rep", "")
|
||||
strip.action.name = new_name
|
||||
renamed_actions.append(f"{old_name} -> {new_name}")
|
||||
if renamed_actions:
|
||||
self.report({"INFO"}, f"Renamed {len(renamed_actions)} replacement action(s)")
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
class DLM_OT_picker_original_character(Operator):
|
||||
bl_idname = "dlm.picker_original_character"
|
||||
bl_label = "Pick Original"
|
||||
bl_description = "Set the original character armature from the active object"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
if not obj or obj.type != "ARMATURE":
|
||||
self.report({"WARNING"}, "Select an armature")
|
||||
return {"CANCELLED"}
|
||||
context.scene.dynamic_link_manager.original_character = obj
|
||||
self.report({"INFO"}, f"Original: {obj.name}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_picker_replacement_character(Operator):
|
||||
bl_idname = "dlm.picker_replacement_character"
|
||||
bl_label = "Pick Replacement"
|
||||
bl_description = "Set the replacement character armature from the active object"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
if not obj or obj.type != "ARMATURE":
|
||||
self.report({"WARNING"}, "Select an armature")
|
||||
return {"CANCELLED"}
|
||||
context.scene.dynamic_link_manager.replacement_character = obj
|
||||
self.report({"INFO"}, f"Replacement: {obj.name}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def _tweak_poll(context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
return orig is not None and rep is not None
|
||||
|
||||
|
||||
class DLM_OT_tweak_add_arm(Operator):
|
||||
bl_idname = "dlm.tweak_add_arm"
|
||||
bl_label = "Add Arm Tweaks"
|
||||
bl_description = "Add tweak bone constraints to arm bones on the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
tweak_tools.add_tweak_constraints(orig, rep, "arm")
|
||||
self.report({"INFO"}, "Arm tweak constraints added.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_remove_arm(Operator):
|
||||
bl_idname = "dlm.tweak_remove_arm"
|
||||
bl_label = "Remove Arm Tweaks"
|
||||
bl_description = "Remove arm tweak constraints from the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
n = tweak_tools.remove_tweak_constraints(orig, rep, "arm")
|
||||
self.report({"INFO"}, f"Removed {n} arm tweak constraints.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_bake_arm(Operator):
|
||||
bl_idname = "dlm.tweak_bake_arm"
|
||||
bl_label = "Bake Arm Tweaks"
|
||||
bl_description = "Bake arm tweak constraints to keyframes and optionally remove constraints"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
props = context.scene.dynamic_link_manager
|
||||
from ..ops import tweak_tools
|
||||
ok, msg = tweak_tools.bake_tweak_constraints(
|
||||
context, orig, rep, "arm",
|
||||
getattr(props, "tweak_nla_track_name", "") or "",
|
||||
getattr(props, "tweak_bake_post_clean", False),
|
||||
)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_add_leg(Operator):
|
||||
bl_idname = "dlm.tweak_add_leg"
|
||||
bl_label = "Add Leg Tweaks"
|
||||
bl_description = "Add tweak bone constraints to leg bones on the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
tweak_tools.add_tweak_constraints(orig, rep, "leg")
|
||||
self.report({"INFO"}, "Leg tweak constraints added.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_remove_leg(Operator):
|
||||
bl_idname = "dlm.tweak_remove_leg"
|
||||
bl_label = "Remove Leg Tweaks"
|
||||
bl_description = "Remove leg tweak constraints from the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
n = tweak_tools.remove_tweak_constraints(orig, rep, "leg")
|
||||
self.report({"INFO"}, f"Removed {n} leg tweak constraints.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_bake_leg(Operator):
|
||||
bl_idname = "dlm.tweak_bake_leg"
|
||||
bl_label = "Bake Leg Tweaks"
|
||||
bl_description = "Bake leg tweak constraints to keyframes and optionally remove constraints"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
props = context.scene.dynamic_link_manager
|
||||
from ..ops import tweak_tools
|
||||
ok, msg = tweak_tools.bake_tweak_constraints(
|
||||
context, orig, rep, "leg",
|
||||
getattr(props, "tweak_nla_track_name", "") or "",
|
||||
getattr(props, "tweak_bake_post_clean", False),
|
||||
)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_add_both(Operator):
|
||||
bl_idname = "dlm.tweak_add_both"
|
||||
bl_label = "Add Arm & Leg Tweaks"
|
||||
bl_description = "Add tweak bone constraints to both arm and leg bones"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
tweak_tools.add_tweak_constraints(orig, rep, "both")
|
||||
self.report({"INFO"}, "Arm & leg tweak constraints added.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_remove_both(Operator):
|
||||
bl_idname = "dlm.tweak_remove_both"
|
||||
bl_label = "Remove Arm & Leg Tweaks"
|
||||
bl_description = "Remove all arm and leg tweak constraints from the replacement character"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
from ..ops import tweak_tools
|
||||
n = tweak_tools.remove_tweak_constraints(orig, rep, "both")
|
||||
self.report({"INFO"}, f"Removed {n} tweak constraints.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DLM_OT_tweak_bake_both(Operator):
|
||||
bl_idname = "dlm.tweak_bake_both"
|
||||
bl_label = "Bake Arm & Leg Tweaks"
|
||||
bl_description = "Bake all arm and leg tweak constraints to keyframes and optionally remove constraints"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return _tweak_poll(context)
|
||||
|
||||
def execute(self, context):
|
||||
orig, rep = _get_migrator_pair(context)
|
||||
props = context.scene.dynamic_link_manager
|
||||
from ..ops import tweak_tools
|
||||
ok, msg = tweak_tools.bake_tweak_constraints(
|
||||
context, orig, rep, "both",
|
||||
getattr(props, "tweak_nla_track_name", "") or "",
|
||||
getattr(props, "tweak_bake_post_clean", False),
|
||||
)
|
||||
if ok:
|
||||
self.report({"INFO"}, msg)
|
||||
return {"FINISHED"}
|
||||
self.report({"ERROR"}, msg)
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
OPERATOR_CLASSES = [
|
||||
DLM_OT_replace_linked_asset,
|
||||
DLM_OT_scan_linked_assets,
|
||||
DLM_OT_find_libraries_in_folders,
|
||||
DLM_OT_open_linked_file,
|
||||
DLM_OT_add_search_path,
|
||||
DLM_OT_remove_search_path,
|
||||
DLM_OT_browse_search_path,
|
||||
DLM_OT_attempt_relink,
|
||||
DLM_OT_reload_libraries,
|
||||
DLM_OT_make_paths_relative,
|
||||
DLM_OT_make_paths_absolute,
|
||||
DLM_OT_relocate_single_library,
|
||||
DLM_OT_migrator_remove_original,
|
||||
DLM_OT_picker_original_character,
|
||||
DLM_OT_picker_replacement_character,
|
||||
DLM_OT_migrator_copy_attributes,
|
||||
DLM_OT_migrator_migrate_nla,
|
||||
DLM_OT_migrator_custom_properties,
|
||||
DLM_OT_migrator_bone_constraints,
|
||||
DLM_OT_migrator_retarget_relations,
|
||||
DLM_OT_migrator_basebody_shapekeys,
|
||||
DLM_OT_tweak_add_arm,
|
||||
DLM_OT_tweak_remove_arm,
|
||||
DLM_OT_tweak_bake_arm,
|
||||
DLM_OT_tweak_add_leg,
|
||||
DLM_OT_tweak_remove_leg,
|
||||
DLM_OT_tweak_bake_leg,
|
||||
DLM_OT_tweak_add_both,
|
||||
DLM_OT_tweak_remove_both,
|
||||
DLM_OT_tweak_bake_both,
|
||||
DLM_OT_migrator_fk_rotations,
|
||||
DLM_OT_migrator_fk_rotations_bake,
|
||||
DLM_OT_migrator_fk_rotations_remove,
|
||||
]
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
import bpy
|
||||
from bpy.types import Panel, UIList
|
||||
from . import properties
|
||||
|
||||
|
||||
class DLM_UL_library_list(UIList):
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||
if self.layout_type in {"DEFAULT", "COMPACT"}:
|
||||
layout.scale_x = 0.4
|
||||
layout.label(text=item.name)
|
||||
layout.scale_x = 0.3
|
||||
if item.is_missing:
|
||||
layout.label(text="MISSING", icon="ERROR")
|
||||
elif item.is_indirect:
|
||||
layout.label(text="INDIRECT", icon="INFO")
|
||||
else:
|
||||
layout.label(text="OK", icon="FILE_BLEND")
|
||||
layout.scale_x = 0.3
|
||||
path_text = item.filepath or ""
|
||||
if path_text.startswith("\\\\"):
|
||||
parts = path_text.split("\\")
|
||||
short_path = f"\\\\{parts[2]}\\" if len(parts) >= 3 else "\\\\ (network)"
|
||||
elif len(path_text) >= 2 and path_text[1] == ":":
|
||||
short_path = f"{path_text[:2]}\\"
|
||||
elif path_text.startswith("//"):
|
||||
short_path = "// (relative)"
|
||||
else:
|
||||
short_path = path_text[:15] + "..." if len(path_text) > 15 else path_text
|
||||
layout.label(text=short_path, icon="FILE_FOLDER")
|
||||
elif self.layout_type == "GRID":
|
||||
layout.alignment = "CENTER"
|
||||
layout.label(text="", icon="FILE_BLEND")
|
||||
|
||||
|
||||
def _get_short_path(filepath):
|
||||
if not filepath:
|
||||
return "Unknown"
|
||||
if filepath.startswith("//"):
|
||||
return "// (relative)"
|
||||
if filepath.startswith("\\\\"):
|
||||
parts = filepath.split("\\")
|
||||
return f"\\\\{parts[2]}\\" if len(parts) >= 3 else "\\\\ (network)"
|
||||
if len(filepath) >= 2 and filepath[1] == ":":
|
||||
return f"{filepath[:2]}\\"
|
||||
if filepath.startswith("/"):
|
||||
parts = filepath.split("/")
|
||||
return f"/{parts[1]}/" if len(parts) >= 2 else "/ (root)"
|
||||
return "Unknown"
|
||||
|
||||
|
||||
class DLM_PT_main_panel(Panel):
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "UI"
|
||||
bl_category = "Dynamic Link Manager"
|
||||
bl_label = "Dynamic Link Manager"
|
||||
|
||||
def draw_header(self, context):
|
||||
layout = self.layout
|
||||
layout.operator("preferences.addon_show", text="", icon="PREFERENCES").module = __package__.rsplit(".", 1)[0]
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
props = context.scene.dynamic_link_manager
|
||||
|
||||
# Path management
|
||||
row = layout.row()
|
||||
row.operator("dlm.make_paths_relative", text="Make Paths Relative", icon="FILE_PARENT")
|
||||
row.operator("dlm.make_paths_absolute", text="Make Paths Absolute", icon="FILE_FOLDER")
|
||||
|
||||
# CharMig section (placeholder; full UI in implementation step 5)
|
||||
box = layout.box()
|
||||
box.label(text="Character Migrator")
|
||||
row = box.row()
|
||||
row.prop(props, "migrator_mode", text="Automatic pair discovery")
|
||||
row = box.row()
|
||||
row.prop(props, "original_character", text="Original")
|
||||
row.operator("dlm.picker_original_character", text="", icon="EYEDROPPER")
|
||||
row = box.row()
|
||||
row.prop(props, "replacement_character", text="Replacement")
|
||||
row.operator("dlm.picker_replacement_character", text="", icon="EYEDROPPER")
|
||||
row = box.row()
|
||||
row.operator("dlm.migrator_remove_original", text="Remove Original", icon="TRASH")
|
||||
row = box.row(align=True)
|
||||
row.operator("dlm.migrator_copy_attributes", text="CopyAttr", icon="COPY_ID")
|
||||
row.operator("dlm.migrator_migrate_nla", text="MigNLA", icon="NLA")
|
||||
row.operator("dlm.migrator_custom_properties", text="MigCustProps", icon="PROPERTIES")
|
||||
row = box.row(align=True)
|
||||
row.operator("dlm.migrator_bone_constraints", text="MigBoneConst", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.migrator_retarget_relations", text="RetargRelatives", icon="ORIENTATION_PARENT")
|
||||
|
||||
# Situational
|
||||
situational_box = layout.box()
|
||||
situational_box.label(text="Situational Fixes", icon="QUESTION")
|
||||
row = situational_box.row(align=True)
|
||||
row.operator("dlm.migrator_basebody_shapekeys", text="MigBBodyShapeKeys", icon="SHAPEKEY_DATA")
|
||||
row = situational_box.row(align=True)
|
||||
row.operator("dlm.migrator_fk_rotations", text="MigFKRot", icon="BONE_DATA")
|
||||
row.operator("dlm.migrator_fk_rotations_remove", text="Remove", icon="X")
|
||||
row.operator("dlm.migrator_fk_rotations_bake", text="Bake", icon="KEYFRAME")
|
||||
|
||||
# Tweak Tools: header row (always), main box only when expanded
|
||||
section_icon = "DISCLOSURE_TRI_DOWN" if props.tweak_tools_section_expanded else "DISCLOSURE_TRI_RIGHT"
|
||||
row = layout.row(align=True)
|
||||
row.prop(props, "tweak_tools_section_expanded", text="", icon=section_icon, icon_only=True)
|
||||
row.label(text="Tweak Tools", icon="CONSTRAINT")
|
||||
if props.tweak_tools_section_expanded:
|
||||
tweak_box = layout.box()
|
||||
row = tweak_box.row(align=True)
|
||||
row.operator("dlm.tweak_add_arm", text="Add Arm", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.tweak_remove_arm", text="Remove Arm", icon="X")
|
||||
row.operator("dlm.tweak_bake_arm", text="Bake Arm", icon="KEYFRAME")
|
||||
row = tweak_box.row(align=True)
|
||||
row.operator("dlm.tweak_add_leg", text="Add Leg", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.tweak_remove_leg", text="Remove Leg", icon="X")
|
||||
row.operator("dlm.tweak_bake_leg", text="Bake Leg", icon="KEYFRAME")
|
||||
row = tweak_box.row(align=True)
|
||||
row.operator("dlm.tweak_add_both", text="Add Both", icon="CONSTRAINT_BONE")
|
||||
row.operator("dlm.tweak_remove_both", text="Remove Both", icon="X")
|
||||
row.operator("dlm.tweak_bake_both", text="Bake Both", icon="KEYFRAME")
|
||||
row = tweak_box.row()
|
||||
row.prop(props, "tweak_nla_track_name", text="NLA track")
|
||||
row = tweak_box.row()
|
||||
row.prop(props, "tweak_bake_post_clean", text="Post-clean after bake")
|
||||
|
||||
# Linked Libraries: header row (always), main box only when expanded
|
||||
missing_count = sum(1 for lib in props.linked_libraries if lib.is_missing)
|
||||
section_icon = "DISCLOSURE_TRI_DOWN" if props.linked_libraries_section_expanded else "DISCLOSURE_TRI_RIGHT"
|
||||
row = layout.row(align=True)
|
||||
row.prop(props, "linked_libraries_section_expanded", text="", icon=section_icon, icon_only=True)
|
||||
row.label(text="Linked Libraries Analysis")
|
||||
if missing_count > 0:
|
||||
row.label(text=f"({props.linked_assets_count} libs, {missing_count} missing)", icon="ERROR")
|
||||
else:
|
||||
row.label(text=f"({props.linked_assets_count} libraries)")
|
||||
if props.linked_libraries_section_expanded:
|
||||
box = layout.box()
|
||||
row = box.row()
|
||||
row.operator("dlm.scan_linked_assets", text="Scan Linked Assets", icon="FILE_REFRESH")
|
||||
if props.linked_assets_count > 0:
|
||||
row = box.row()
|
||||
row.template_list("DLM_UL_library_list", "", props, "linked_libraries", props, "linked_libraries_index")
|
||||
row = box.row()
|
||||
row.operator("dlm.reload_libraries", text="Reload Libraries", icon="FILE_REFRESH")
|
||||
prefs = context.preferences.addons.get(__package__.rsplit(".", 1)[0])
|
||||
if prefs and prefs.preferences.search_paths:
|
||||
for i, path_item in enumerate(prefs.preferences.search_paths):
|
||||
row = box.row()
|
||||
row.prop(path_item, "path", text=f"Search path {i+1}")
|
||||
row.operator("dlm.browse_search_path", text="", icon="FILE_FOLDER").index = i
|
||||
row.operator("dlm.remove_search_path", text="", icon="REMOVE").index = i
|
||||
row = box.row()
|
||||
row.alignment = "RIGHT"
|
||||
row.operator("dlm.add_search_path", text="Add search path", icon="ADD")
|
||||
if missing_count > 0:
|
||||
row = box.row()
|
||||
row.operator("dlm.find_libraries_in_folders", text="Find libraries in these folders", icon="VIEWZOOM")
|
||||
if props.linked_libraries and 0 <= props.linked_libraries_index < len(props.linked_libraries):
|
||||
selected_lib = props.linked_libraries[props.linked_libraries_index]
|
||||
info_box = box.box()
|
||||
if selected_lib.is_missing:
|
||||
info_box.alert = True
|
||||
info_box.label(text=f"Selected: {selected_lib.name}")
|
||||
if selected_lib.is_missing:
|
||||
info_box.label(text="Status: MISSING", icon="ERROR")
|
||||
elif selected_lib.is_indirect:
|
||||
info_box.label(text="Status: INDIRECT", icon="INFO")
|
||||
else:
|
||||
info_box.label(text="Status: OK", icon="FILE_BLEND")
|
||||
info_box.label(text=f"Path: {selected_lib.filepath}", icon="FILE_FOLDER")
|
||||
info_box.operator("dlm.open_linked_file", text="Open Blend", icon="FILE_BLEND").filepath = selected_lib.filepath
|
||||
op = info_box.operator("dlm.relocate_single_library", text="Relocate Library", icon="FILE_FOLDER")
|
||||
op.target_filepath = selected_lib.filepath
|
||||
@@ -0,0 +1,32 @@
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
import bpy
|
||||
from bpy.types import AddonPreferences
|
||||
from bpy.props import StringProperty, CollectionProperty
|
||||
from . import properties
|
||||
|
||||
|
||||
class DynamicLinkManagerPreferences(AddonPreferences):
|
||||
bl_idname = __package__.rsplit(".", 1)[0]
|
||||
|
||||
search_paths: CollectionProperty(
|
||||
type=properties.SearchPathItem,
|
||||
name="Search Paths",
|
||||
description="Paths to search for missing linked libraries",
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
box = layout.box()
|
||||
box.label(text="Default Search Paths for Missing Libraries")
|
||||
row = box.row()
|
||||
row.alignment = "RIGHT"
|
||||
row.operator("dlm.add_search_path", text="Add search path", icon="ADD")
|
||||
for i, path_item in enumerate(self.search_paths):
|
||||
row = box.row()
|
||||
row.prop(path_item, "path", text=f"Search path {i+1}")
|
||||
row.operator("dlm.browse_search_path", text="", icon="FILE_FOLDER").index = i
|
||||
row.operator("dlm.remove_search_path", text="", icon="REMOVE").index = i
|
||||
@@ -0,0 +1,78 @@
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
import bpy
|
||||
from bpy.types import PropertyGroup
|
||||
from bpy.props import IntProperty, StringProperty, BoolProperty, CollectionProperty, PointerProperty
|
||||
|
||||
|
||||
class SearchPathItem(PropertyGroup):
|
||||
path: StringProperty(
|
||||
name="Search Path",
|
||||
description="Path to search for missing linked libraries",
|
||||
subtype="DIR_PATH",
|
||||
)
|
||||
|
||||
|
||||
class LinkedDatablockItem(PropertyGroup):
|
||||
name: StringProperty(name="Name", description="Name of the linked datablock")
|
||||
type: StringProperty(name="Type", description="Type of the linked datablock")
|
||||
|
||||
|
||||
class LinkedLibraryItem(PropertyGroup):
|
||||
filepath: StringProperty(name="File Path", description="Path to the linked .blend file")
|
||||
name: StringProperty(name="Name", description="Name of the linked .blend file")
|
||||
is_missing: BoolProperty(name="Missing", description="True if the linked file is not found")
|
||||
is_indirect: BoolProperty(name="Is Indirect", description="True if this is an indirectly linked library")
|
||||
is_expanded: BoolProperty(name="Expanded", default=True)
|
||||
linked_datablocks: CollectionProperty(type=LinkedDatablockItem, name="Linked Datablocks")
|
||||
|
||||
|
||||
class DynamicLinkManagerProperties(PropertyGroup):
|
||||
linked_libraries: CollectionProperty(type=LinkedLibraryItem, name="Linked Libraries")
|
||||
linked_libraries_index: IntProperty(name="Linked Libraries Index", default=0)
|
||||
linked_assets_count: IntProperty(name="Linked Assets Count", default=0)
|
||||
linked_libraries_section_expanded: BoolProperty(
|
||||
name="Linked Libraries Analysis Expanded",
|
||||
description="Show or hide the Linked Libraries Analysis section",
|
||||
default=False,
|
||||
)
|
||||
selected_asset_path: StringProperty(name="Selected Asset Path", default="")
|
||||
|
||||
# Character migrator (manual mode)
|
||||
migrator_mode: BoolProperty(
|
||||
name="Automatic",
|
||||
description="Automatic: discover pair by Name_Rigify / Name_Rigify.001. Manual: use fields below",
|
||||
default=False,
|
||||
)
|
||||
original_character: PointerProperty(
|
||||
name="Original Character",
|
||||
description="Armature to migrate from",
|
||||
type=bpy.types.Object,
|
||||
poll=lambda self, obj: obj and obj.type == "ARMATURE",
|
||||
)
|
||||
replacement_character: PointerProperty(
|
||||
name="Replacement Character",
|
||||
description="Armature to migrate to",
|
||||
type=bpy.types.Object,
|
||||
poll=lambda self, obj: obj and obj.type == "ARMATURE",
|
||||
)
|
||||
|
||||
# Tweak tools (collapsible section)
|
||||
tweak_tools_section_expanded: BoolProperty(
|
||||
name="Tweak Tools Expanded",
|
||||
description="Show or hide the Tweak Tools section",
|
||||
default=False,
|
||||
)
|
||||
tweak_nla_track_name: StringProperty(
|
||||
name="NLA Track (bake range)",
|
||||
description="If set, bake uses this NLA track on the replacement armature for frame range; else scene range",
|
||||
default="",
|
||||
)
|
||||
tweak_bake_post_clean: BoolProperty(
|
||||
name="Post-clean after bake",
|
||||
description="Run action clean keyframes and graph decimate (error 0.001) after baking",
|
||||
default=False,
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
"""Shared helpers for Dynamic Link Manager."""
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def descendants(armature):
|
||||
"""Return a set of objects whose parent chain leads to the given armature."""
|
||||
out = set()
|
||||
for ob in bpy.data.objects:
|
||||
p = ob.parent
|
||||
while p:
|
||||
if p == armature:
|
||||
out.add(ob)
|
||||
break
|
||||
p = p.parent
|
||||
return out
|
||||
|
||||
|
||||
def collection_containing_armature(armature):
|
||||
"""
|
||||
Return a collection that contains the armature (for linked character context).
|
||||
Prefers a collection whose name matches the character base (e.g. "Steve" for Steve_Rigify).
|
||||
"""
|
||||
if not armature or armature.name not in bpy.data.objects:
|
||||
return None
|
||||
colls = getattr(armature, "users_collection", []) or []
|
||||
if not colls:
|
||||
return None
|
||||
name = armature.name
|
||||
base = name.replace("_Rigify", "").replace(".001", "").rstrip("0123456789.")
|
||||
for c in colls:
|
||||
if c.name == base or base in c.name or c.name in name:
|
||||
return c
|
||||
return colls[0]
|
||||
Reference in New Issue
Block a user