diff --git a/.specstory/history/2026-02-18_23-39Z-character-migrator-workflow-integration.md b/.specstory/history/2026-02-18_23-39Z-character-migrator-workflow-integration.md index 6cdcb37..38737b4 100644 --- a/.specstory/history/2026-02-18_23-39Z-character-migrator-workflow-integration.md +++ b/.specstory/history/2026-02-18_23-39Z-character-migrator-workflow-integration.md @@ -13884,3 +13884,572 @@ _**User**_ --- +_**Agent (model default, mode Agent)**_ + +
Thought Process + +The user says AnimLayers is still being detected improperly. They're suggesting I grep how the animation layers addon works. The addon might be installed in Blender's addons folder - I should search for it. Common locations would be: +- C:\Program Files\Blender Foundation\Blender 5.0\5.0\scripts\addons +- Or a user addon path +- The terminal mentioned "animation_layers_updater addon" - so there might be a folder like "animation_layers" or "animation_layers_updater" + +Let me search the user's system for the animation layers addon so we can see how it stores the "turn on" / layer state. I'll search in common Blender addon paths and also in the workspace. +
+ +--- + + +Searching for the Animation Layers addon to see how it stores its state: + + +--- + + +
+Tool use: **glob_file_search** • Searched codebase "undefined" • **16** results in **1** directory + +Directory: **C:\Program Files\Blender Foundation\Blender 5.0** (16 files) + +| File | +|------| +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\animation_layers_updater\Animation_Layers_updater_status.json` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\multikey.py` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\subscriptions.py` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\anim_layers.py` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\bake_ops.py` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\addon_updater_ops.py` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\addon_updater.py` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\__init__.py` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\animation_layers_updater\backup\animation_layers_updater\Animation_Layers_updater_status.json` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\animation_layers_updater\backup\subscriptions.py` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\animation_layers_updater\backup\__init__.py` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\animation_layers_updater\backup\anim_layers.py` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\animation_layers_updater\backup\bake_ops.py` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\animation_layers_updater\backup\multikey.py` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\animation_layers_updater\backup\addon_updater.py` | +| `../C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\animation_layers_updater\backup\addon_updater_ops.py` | + +
+
+ +--- + + +
+Tool use: **ripgrep_raw_search** + + +Parameters: + +```json +{ + "pattern": "turn_on|als\\.|animation.layer", + "path": "C:\\Program Files\\Blender Foundation\\Blender 5.0", + "glob": "*.py", + "outputMode": "content", + "contextBefore": 0, + "contextAfter": 0, + "caseInsensitive": false +} +``` +Additional data: + +```json +{ + "isPruned": true, + "pattern": "turn_on|als\\.|animation.layer", + "path": "C:\\Program Files\\Blender Foundation\\Blender 5.0", + "outputMode": "content", + "totalFiles": 457, + "totalMatches": 2148, + "topFiles": [ + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\typing_extensions.py", + "matchCount": 8 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\regex\\test_regex.py", + "matchCount": 1 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\regex\\regex.py", + "matchCount": 1 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\regex\\_regex_core.py", + "matchCount": 3 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\rdflib\\xsd_datetime.py", + "matchCount": 1 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\rdflib\\term.py", + "matchCount": 2 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\rdflib\\plugins\\parsers\\notation3.py", + "matchCount": 1 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\rdflib\\namespace\\_TIME.py", + "matchCount": 2 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\rdflib\\namespace\\_SH.py", + "matchCount": 3 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\rdflib\\namespace\\_SDO.py", + "matchCount": 8 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\rdflib\\namespace\\_PROV.py", + "matchCount": 1 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\rdflib\\namespace\\_OWL.py", + "matchCount": 5 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\dateutil\\rrule.py", + "matchCount": 5 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\jsonschema\\_keywords.py", + "matchCount": 1 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\joblib\\test\\test_parallel.py", + "matchCount": 2 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\joblib\\test\\test_module.py", + "matchCount": 1 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\joblib\\test\\test_memmapping.py", + "matchCount": 2 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\joblib\\externals\\loky\\backend\\resource_tracker.py", + "matchCount": 1 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\joblib\\externals\\loky\\backend\\reduction.py", + "matchCount": 1 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\extensions\\.local\\lib\\python3.11\\site-packages\\joblib\\externals\\loky\\backend\\popen_loky_win32.py", + "matchCount": 1 + } + ] +} +``` +Status: **completed** + + +
+
+ +--- + +
Thought Process + +The grep hit a lot of unrelated files. Let me search specifically inside the Animation_Layers addon folder for turn_on, als, nla, layer state, etc. +
+ +--- + + + + + +--- + + +
+Tool use: **ripgrep_raw_search** + + +Parameters: + +```json +{ + "pattern": "turn_on|als\\.|\\[.als|animation_data|nla_tracks", + "path": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers", + "glob": "*.py", + "caseInsensitive": false +} +``` +Additional data: + +```json +{ + "isPruned": true, + "pattern": "turn_on|als\\.|\\[.als|animation_data|nla_tracks", + "path": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers", + "outputMode": "content", + "totalFiles": 10, + "totalMatches": 997, + "topFiles": [ + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\subscriptions.py", + "matchCount": 70 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\multikey.py", + "matchCount": 13 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\bake_ops.py", + "matchCount": 105 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\anim_layers.py", + "matchCount": 310 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\__init__.py", + "matchCount": 1 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\animation_layers_updater\\backup\\__init__.py", + "matchCount": 1 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\animation_layers_updater\\backup\\subscriptions.py", + "matchCount": 70 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\animation_layers_updater\\backup\\multikey.py", + "matchCount": 13 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\animation_layers_updater\\backup\\bake_ops.py", + "matchCount": 105 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\animation_layers_updater\\backup\\anim_layers.py", + "matchCount": 309 + } + ] +} +``` +Status: **completed** + + +
+
+ +--- + + +
Tool use: **read_file_v2** • Read file: C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\anim_layers.py + +
+
+ +--- + +
Thought Process + +I found it. The addon uses **obj.als.turn_on** - so `als` is an **RNA property** (a PropertyGroup on the object), not an id property. So we're checking `key in orig` (id props) but the addon stores it as **orig.als** which is a PropertyGroup with a **turn_on** attribute. So we need to check `getattr(orig, 'als', None)` and then `getattr(orig.als, 'turn_on', None)` or similar. Let me look at how they register the property. +
+ +--- + + +Checking how the addon registers `als` and `Anim_Layers`: + + +--- + + +
+Tool use: **ripgrep_raw_search** + + +Parameters: + +```json +{ + "pattern": "als\\.|Anim_Layers|PropertyGroup|BoolProperty.*turn_on", + "path": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers", + "glob": "*.py", + "outputMode": "content", + "contextBefore": 0, + "contextAfter": 0, + "caseInsensitive": false +} +``` +Additional data: + +```json +{ + "isPruned": true, + "pattern": "als\\.|Anim_Layers|PropertyGroup|BoolProperty.*turn_on", + "path": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers", + "outputMode": "content", + "totalFiles": 3, + "totalMatches": 699, + "topFiles": [ + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\subscriptions.py", + "matchCount": 54 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\multikey.py", + "matchCount": 2 + }, + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\anim_layers.py", + "matchCount": 24 + } + ] +} +``` +Status: **completed** + + +
+
+ +--- + + +
Tool use: **read_file_v2** • Read file: C:\Program Files\Blender Foundation\Blender 5.0\portable\scripts\addons\Animation_Layers\__init__.py + +
+
+ +--- + +
Thought Process + +Found it. In __init__.py: +- **AnimLayersSettings** is a PropertyGroup with **turn_on: bpy.props.BoolProperty** +- It's registered on the **Object** type as **als** (need to find where they register it) + +So the addon uses **obj.als** (RNA PropertyGroup on Object) with **obj.als.turn_on**. So we must check **getattr(orig, 'als', None)** and then **getattr(orig.als, 'turn_on', None)**. It's not in orig.keys() at all - it's an RNA property. Let me find where they register it on Object. +
+ +--- + + + + + +--- + + +
+Tool use: **ripgrep_raw_search** + + +Parameters: + +```json +{ + "pattern": "als|Anim_Layers|register_class|Object\\.|bpy\\.types\\.Object", + "path": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\__init__.py", + "caseInsensitive": false +} +``` +Additional data: + +```json +{ + "isPruned": true, + "pattern": "als|Anim_Layers|register_class|Object\\.|bpy\\.types\\.Object", + "path": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\__init__.py", + "outputMode": "content", + "totalFiles": 1, + "totalMatches": 39, + "topFiles": [ + { + "uri": "C:\\Program Files\\Blender Foundation\\Blender 5.0\\portable\\scripts\\addons\\Animation_Layers\\__init__.py", + "matchCount": 39 + } + ] +} +``` +Status: **completed** + + +
+
+ +--- + +
Thought Process + +Perfect. So: +- **bpy.types.Object.als** = PointerProperty(type=AnimLayersSettings) +- So **obj.als** is an RNA property (PropertyGroup) on the **Object**, and **obj.als.turn_on** is the boolean. + +So we need to: +1. Detect Animation Layers by checking **getattr(orig, 'als', None)** and then **getattr(orig.als, 'turn_on', None)** - if the addon is loaded, every Object has .als; we consider "has Animation Layers" when orig has NLA tracks (we already have has_nla) OR when we want to report that we migrated the toggle. Actually the message is "Animation Layer attributes migrated" when we find als - so we need has_als to be True when **orig.als exists and we copied turn_on**. So detection should be: has_als = getattr(orig, 'als', None) is not None (and optionally orig.als.turn_on is True or we have nla - but actually we should consider "Animation Layers found" when the Object has the als property group, which it will if the addon is loaded). So simply: has_als = getattr(orig, 'als', None) is not None. + +2. Mirror: we need to copy **orig.als.turn_on** to **rep.als.turn_on** (and possibly other writable properties of the als group like layer_index). So in _mirror_als_turn_on we do: + - If getattr(orig, 'als', None) and getattr(rep, 'als', None): + rep.als.turn_on = orig.als.turn_on + And for detection: has_als = getattr(orig, 'als', None) is not None (and maybe rep has als too). +
+ +--- + + +Identifying the addon: it registers `bpy.types.Object.als` (RNA PropertyGroup), so the toggle is `obj.als.turn_on`, not an id prop. Updating detection and mirroring to use `orig.als` / `rep.als`. + + +--- + +The string to replace was not found in the file. + +--- + +
Thought Process + +I had a typo in the replace - "except Exception:\n pass" vs "except Exception:\n pass" in the bone loop I wrote "except Exception:\n pass" but with wrong indentation (only 4 spaces before pass in one place). Let me do the replace in two smaller chunks: first _has_als_anywhere, then _mirror_als_turn_on. +
+ +--- + + + + + +--- + + + +
Tool use: **edit_file_v2** • Edit file: T:\Dynamic-Link-Manager\ops\migrator.py + +
+ +
+ +--- + + + +
Tool use: **edit_file_v2** • Edit file: T:\Dynamic-Link-Manager\ops\migrator.py + +
+ +
+ +--- + + + +--- + +
Thought Process + +Both edits applied. Adding RNA check to _debug_als_lookup so the debug output shows that obj.als exists when the addon is loaded. +
+ +--- + + +Adding an RNA check to the debug output so we log when `obj.als` exists: + + +--- + + + +
Tool use: **edit_file_v2** • Edit file: T:\Dynamic-Link-Manager\ops\migrator.py + +
+ +
+ +--- + +
Thought Process + +Good. Let me verify there are no linter errors. +
+ +--- + + + + + +--- + + +
+Tool use: **read_lints** + + +Parameters: + +```json +{ + "paths": [ + "T:\\Dynamic-Link-Manager\\ops\\migrator.py" + ] +} +``` +Result: + +```json +{ + "linterErrorsByFile": [ + { + "relativeWorkspacePath": "T:\\Dynamic-Link-Manager\\ops\\migrator.py" + } + ] +} +``` +Status: **completed** + + +
+
+ +--- + +
Thought Process + +All good. Summarize for the user. +
+ +--- + + +Summary of what changed: + +**Cause:** The Animation Layers addon uses **RNA**, not id properties: it registers `bpy.types.Object.als` (a `PointerProperty` to `AnimLayersSettings`) and stores the toggle as **`obj.als.turn_on`**. The migrator was only checking id props (`"als.turn_on"` in `orig.keys()`, etc.), so it never saw that. + +**Updates in `ops/migrator.py`:** + +1. **`_has_als_anywhere(orig)`** + - First checks **`getattr(orig, "als", None) is not None`** (RNA). + - If that’s true, it returns True so “Animation Layer attributes migrated” is reported. + - Id-property checks are kept as a fallback. + +2. **`_mirror_als_turn_on(orig, rep)`** + - First copies via RNA: if both `orig.als` and `rep.als` exist, sets **`rep.als.turn_on = orig.als.turn_on`**. + - Then runs the existing id-property mirroring as fallback. + +3. **`_debug_als_lookup(orig)`** + - Now prints **`orig.als` (RNA)** and **`orig.als.turn_on`** so you can confirm the addon’s state in the console. + +With the Animation Layers addon enabled, any armature object has the `als` RNA property. So when the original has Animation Layers turned on (`orig.als.turn_on`), detection should succeed and the report should say “Animation Layer attributes migrated” after Mig NLA. Reload the addon and run Mig NLA again to confirm. + +--- + +_**User**_ + +Good. + +MigBoneConst is copying the constraints, but it's not copying their properties, specifically the targets. I like to run bpy.ops.pose.copy_pose_con(), but that's context-dependent on pose mode. + +--- + diff --git a/ops/migrator.py b/ops/migrator.py index 5fc9d01..d79d4e5 100644 --- a/ops/migrator.py +++ b/ops/migrator.py @@ -356,8 +356,65 @@ def run_mig_cust_props(orig, rep): 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 retarget, trim duplicates.""" + """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] @@ -368,25 +425,9 @@ def run_mig_bone_const(orig, rep, orig_to_rep): continue rbone = rep.pose.bones[pbone.name] for c in pbone.constraints: - if getattr(c, "target", None) == orig: - continue nc = rbone.constraints.new(type=c.type) nc.name = c.name - nc.mute = c.mute - nc.influence = c.influence - t = getattr(c, "target", None) - if t is not None and t in orig_to_rep: - nc.target = orig_to_rep[t] - elif getattr(nc, "target", None) is not None and t: - nc.target = t - if hasattr(c, "subtarget") and c.subtarget: - nc.subtarget = c.subtarget - for prop in ("head_tail", "use_bone_object", "invert_x", "invert_y", "invert_z"): - if hasattr(c, prop): - try: - setattr(nc, prop, getattr(c, prop)) - except Exception: - pass + _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