references/api-changes-5x.md
# bpy migration: 4.x → 5.2 (what LLM memory / old tutorials get wrong)
Each row was verified against Blender 5.2.3. Format: **old → new** (version).
## Core / properties
| Old | New |
|---|---|
| `scene["cycles"]`, dict access to `bpy.props` values | attribute access only; ID-props and RNA props are separate (5.0) |
| `bpy.props` `get` without `set` for read-only | `options={'READ_ONLY'}`; `set` without `get` is an error (5.0) |
| `get_transform`/`set_transform` | new prop callbacks that keep default storage (5.0) |
| user site-packages auto-loaded | no longer loaded by default (5.1) |
| Python 3.11 | Python 3.13 (5.1) |
| — | `bpy.data.all_ids` iterates over every ID (5.2) |
| — | `gpu.init()` required for `gpu` in `--background` (5.2) |
| IDProperty nesting unlimited | capped at 1024 levels (5.2) |
| mathutils float64 buffers | float32 buffers; numpy views are non-contiguous (5.0) |
## Mesh / modeling
| Old | New |
|---|---|
| `mesh.use_auto_smooth`, `auto_smooth_angle` | removed (4.1). Use `bpy.ops.object.shade_smooth_by_angle(angle=…)` or the "Smooth by Angle" modifier asset (`shade_auto_smooth`) |
| `mesh.calc_normals_split()`, `loop.normal` | `mesh.corner_normals` (read-only), `mesh.normals_domain` (4.1) |
| custom normals need auto smooth | they work directly: `mesh.normals_split_custom_set(...)` (4.1) |
| face maps | removed. Use a boolean face attribute (4.0) |
| Boolean solver `FAST` | `FLOAT`. Solvers: `EXACT` (default), `FLOAT`, `MANIFOLD` (4.5+) (5.0) |
| per-UV-map selection, `BMLoopUV.select*` | shared `.uv_select_*` attributes; `bm.uv_select_flush()` etc. (5.0) |
| UV pin auto-creates attribute | use the `*_ensure()` functions (5.0) |
| `brush.sculpt_tool` | `brush.sculpt_brush_type` (5.0); brushes are assets (4.3) |
| `tool_settings.unified_paint_settings` | `tool_settings.sculpt.unified_paint_settings` (mode-specific) (5.0) |
| radial symmetry on tool settings | `mesh.radial_symmetry` (5.0) |
| `brush.use_airbrush/use_anchor/use_space/...` | `brush.stroke_method` enum (5.1) |
| `brush.use_automasking_*`, `automasking_*` | `brush.mesh_automasking_settings.*` (5.2) |
## Nodes
| Old | New |
|---|---|
| `ng.inputs.new()` / `ng.outputs.new()` | `ng.interface.new_socket(name, in_out='INPUT', socket_type='NodeSocketFloat')` (4.0) |
| `mod["Socket_2"] = 1.0`, `mod["Socket_2_use_attribute"]` | `mod.properties.inputs.Socket_2.value = 1.0`; `.type = "ATTRIBUTE"`; `.attribute_name` (5.2). Use `getattr(mod.properties.inputs, sock.identifier)` |
| Compare / Random Value socket identifiers | changed (5.2): look up by name, not identifier |
| `scene.node_tree`, `scene.use_nodes = True` | `scene.compositing_node_group = bpy.data.node_groups.new(n, "CompositorNodeTree")` (5.0) |
| `material.use_nodes = True` | deprecated (5.0). New materials already have Principled + Output. Same for `world.use_nodes` |
| File Output `base_path`, `file_slots`, `layer_slots` | `directory`, `file_name`, `file_output_items` (5.0) |
| `CompositorNodeGamma`, `...MixRGB`, etc. | shader-node equivalents (`ShaderNodeGamma`, `ShaderNodeMix`) (5.0) |
| Color node output "RGBA" | "Color" (5.0) |
| Principled `Subsurface`, `Specular`, `Transmission`, `Coat`/`Clearcoat`, `Sheen`, `Emission` | `Subsurface Weight`, `Specular IOR Level`, `Transmission Weight`, `Coat Weight`, `Sheen Weight`, `Emission Color` (4.0) |
| — | Principled `Thin Wall` input (5.2); `Diffuse Roughness`, `Thin Film Thickness/IOR` (4.2–4.3) |
| `SpaceNodeEditor.geometry_nodes_type` | `node_tree_sub_type` (5.0) |
| Point Density texture node | removed (5.0) |
## Materials / rendering
| Old | New |
|---|---|
| engine `BLENDER_EEVEE_NEXT` (4.2–4.5) | `BLENDER_EEVEE` (5.0) |
| `material.blend_method`, `shadow_method` | `material.surface_render_method` = `DITHERED` / `BLENDED`; `shadow_method` removed (4.2). Use `visible_shadow` on the object |
| `scene.eevee.use_gtao`, `gtao_distance` | removed; `view_layer.eevee.ambient_occlusion_distance` (5.0) |
| render pass names `DiffCol`, `Z`, ... | `Diffuse Color`, `Depth`, ... (5.0) |
| `image_settings.file_format = "FFMPEG"` alone | set `image_settings.media_type` (`IMAGE`/`MULTI_LAYER_IMAGE`/`VIDEO`) **before** `file_format` (5.0) |
| Filmic default | AgX default (4.0). Views: Standard, AgX, Khronos PBR Neutral, ACES 1.3/2.0, Filmic, ... |
| — | Cycles texture cache: `scene.render.use_texture_cache`, `use_auto_generate_texture_cache` (.tx) (5.2) |
| `bgl`, `Image.bindcode`, GLSL-source shaders | `gpu` module, `gpu.texture.from_image()` (5.0) |
## Animation / rigging
| Old | New |
|---|---|
| `action.fcurves`, `action.groups`, `action.id_root` | slotted/layered actions. `cb = anim_utils.action_ensure_channelbag_for_slot(action, slot)` → `cb.fcurves.new/ensure/find`, `cb.groups`; `slot.target_id_type` (removed in 5.0; slots since 4.4) |
| `fcurves.new(path, index, action_group)` | `cb.fcurves.new(path, index=0, group_name="")` |
| assigning `animation_data.action` was enough | also set/check `animation_data.action_slot` (auto-picked on assign if suitable) |
| `ANIM_OT_convert_legacy_action` | removed. All actions are layered (5.1) |
| `armature.layers`, `bone.layers`, bone groups | bone collections: `arm.collections.new("FK")`, `bcoll.assign(bone)`, `bone.color.palette`, `pose_bone.color` (4.0) |
| `bone.select`, `select_head/tail` | removed from Bone; use `pose_bone.select` / `edit_bone.select*` (5.0) |
| `context.space_data.action` | removed (5.0) |
## Add-ons / UI / misc
| Old | New |
|---|---|
| `bl_info` dict add-on | extension with `blender_manifest.toml` (4.2). Legacy add-ons still load |
| `UILayout.template_list(columns=)` | no effect (deprecated 5.1) |
| `UIList.layout_type = "GRID"` | removed (5.0) |
| `AssetHandle`, `context.active_file` (asset shelf) | `AssetRepresentation`, `context.asset` (5.0) |
| `GPencilStroke`, `scene.grease_pencil` (annotations) | `AnnotationStroke`, `scene.annotation` (5.0) |
| `GreasePencilv3`, `bpy.data.grease_pencils_v3` | `GreasePencil`, `bpy.data.grease_pencils` (5.0) |
| VSE `frame_final_start/end/duration` | `left_handle`, `right_handle`, `duration` (5.1; old names deprecated) |
| VSE uses `context.scene` | `context.sequencer_scene` (5.0) |
| USD export `export_textures`, `visible_objects_only` | removed (5.0) |
| `PreferencesFilePaths.active_asset_library` index == `asset_libraries` index | offset: list starts with "All Libraries", "Essentials" (5.2) |
| log output format | changed (5.0). Don't regex-parse the render progress |
When something is missing: `dir(obj)`, `T.bl_rna.properties.keys()`, `T.bl_rna.functions.keys()`, or `op.get_rna_type().properties` show the truth faster than docs.
references/bpy-patterns.md
# bpy patterns (Blender 5.2, each block runs standalone; CI executes them)
## Create a mesh from data (fastest, no operators)
```python
import bpy
verts = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)]
me = bpy.data.meshes.new("Quad")
me.from_pydata(verts, [], [(0, 1, 2, 3)])
me.validate(); me.update()
ob = bpy.data.objects.new("Quad", me)
bpy.context.scene.collection.objects.link(ob)
```
## bmesh edit (object mode) and write back
```python
import bpy, bmesh
me = bpy.data.meshes.new("BM"); ob = bpy.data.objects.new("BM", me)
bpy.context.scene.collection.objects.link(ob)
bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1.0)
bmesh.ops.bevel(bm, geom=bm.edges[:], offset=0.05, segments=2, affect="EDGES", profile=0.5)
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=1e-5)
bm.to_mesh(me); bm.free()
```
In Edit Mode use `bm = bmesh.from_edit_mesh(me)` … `bmesh.update_edit_mesh(me)` and don't free it.
## numpy bulk read/write (100x faster than Python loops)
```python
import bpy, numpy as np
me = bpy.data.meshes.new("NP"); me.from_pydata([(0,0,0),(1,0,0),(0,1,0)], [], [(0,1,2)])
co = np.empty(len(me.vertices) * 3, dtype=np.float32)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3); co[:, 2] += 0.5
me.vertices.foreach_set("co", co.ravel()); me.update()
```
## Generic attributes (float/int/bool/color on any domain)
```python
import bpy
me = bpy.data.meshes.new("Attr"); me.from_pydata([(0,0,0),(1,0,0),(0,1,0)], [], [(0,1,2)])
a = me.attributes.get("mask") or me.attributes.new("mask", "FLOAT", "POINT")
a.data.foreach_set("value", [0.0, 0.5, 1.0])
col = me.color_attributes.new("Col", "BYTE_COLOR", "CORNER")
me.color_attributes.active_color = col
```
## Operator with context override (works headless)
```python
import bpy
me = bpy.data.meshes.new("Ov"); me.from_pydata([(0,0,0),(1,0,0),(0,1,0)], [], [(0,1,2)])
ob = bpy.data.objects.new("Ov", me); bpy.context.scene.collection.objects.link(ob)
ob.modifiers.new("Weld", "WELD")
with bpy.context.temp_override(object=ob, active_object=ob, selected_objects=[ob],
selected_editable_objects=[ob]):
assert bpy.ops.object.modifier_apply(modifier="Weld") == {"FINISHED"}
```
## Evaluated mesh (modifiers applied) without changing the object
```python
import bpy
me = bpy.data.meshes.new("Ev"); me.from_pydata([(0,0,0),(1,0,0),(0,1,0)], [], [(0,1,2)])
ob = bpy.data.objects.new("Ev", me); bpy.context.scene.collection.objects.link(ob)
ob.modifiers.new("Sub", "SUBSURF").levels = 2
dg = bpy.context.evaluated_depsgraph_get()
eo = ob.evaluated_get(dg); tmp = eo.to_mesh()
n_faces = len(tmp.polygons)
eo.to_mesh_clear()
baked = bpy.data.meshes.new_from_object(eo) # persistent copy, if you need one
```
## Materials: find nodes by type, link textures
```python
import bpy
mat = bpy.data.materials.new("M") # already has Principled + Output (5.0+)
nt = mat.node_tree
bsdf = next(n for n in nt.nodes if n.type == "BSDF_PRINCIPLED")
tex = nt.nodes.new("ShaderNodeTexImage")
tex.image = bpy.data.images.new("Base", 64, 64)
tex.image.colorspace_settings.name = "sRGB" # data maps: "Non-Color"
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
bsdf.inputs["Roughness"].default_value = 0.6
```
## Geometry Nodes modifier inputs (5.2 RNA API)
```python
import bpy
ng = bpy.data.node_groups.new("GN", "GeometryNodeTree")
ng.interface.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
ng.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
sock = ng.interface.new_socket("Scale", in_out="INPUT", socket_type="NodeSocketFloat")
gi, go = ng.nodes.new("NodeGroupInput"), ng.nodes.new("NodeGroupOutput")
ng.links.new(gi.outputs["Geometry"], go.inputs["Geometry"])
me = bpy.data.meshes.new("G"); ob = bpy.data.objects.new("G", me)
bpy.context.scene.collection.objects.link(ob)
mod = ob.modifiers.new("GN", "NODES"); mod.node_group = ng
getattr(mod.properties.inputs, sock.identifier).value = 2.0
```
## Keyframes with slotted actions (5.0+)
```python
import bpy
from bpy_extras import anim_utils
ob = bpy.data.objects.new("Anim", None); bpy.context.scene.collection.objects.link(ob)
act = bpy.data.actions.new("Anim_Move")
ob.animation_data_create(); ob.animation_data.action = act
slot = ob.animation_data.action_slot or act.slots.new(id_type="OBJECT", name=ob.name)
ob.animation_data.action_slot = slot
cb = anim_utils.action_ensure_channelbag_for_slot(act, slot)
for i in range(3):
fc = cb.fcurves.ensure("location", index=i, group_name="Object Transforms")
fc.keyframe_points.insert(1, 0.0); fc.keyframe_points.insert(24, 2.0 if i == 0 else 0.0)
```
The simple route works too: `ob.keyframe_insert("location", frame=1)` creates the action and slot.
## Drivers
```python
import bpy
ob = bpy.data.objects.new("Drv", None); bpy.context.scene.collection.objects.link(ob)
ctl = bpy.data.objects.new("Ctl", None); bpy.context.scene.collection.objects.link(ctl)
fc = ob.driver_add("location", 2)
d = fc.driver; d.type = "SCRIPTED"
v = d.variables.new(); v.name = "x"; v.type = "TRANSFORMS"
v.targets[0].id = ctl; v.targets[0].transform_type = "LOC_X"; v.targets[0].transform_space = "LOCAL_SPACE"
d.expression = "x * 0.5"
```
Prefer simple expressions (`var`, `x*0.5`, `max(a,b)`), which run without Python auto-exec. Use `d.type="SUM"/"AVERAGE"` when possible.
## Append / link from another .blend
```python
import bpy, os, tempfile
src = os.path.join(tempfile.gettempdir(), "bas_lib.blend")
bpy.data.materials.new("LibMat").use_fake_user = True
bpy.data.libraries.write(src, {bpy.data.materials["LibMat"]}, fake_user=True)
with bpy.data.libraries.load(src, link=False) as (data_from, data_to): # link=True to link
data_to.materials = [n for n in data_from.materials if n == "LibMat"]
```
Linked objects need a library override to be edited: `ob.override_hierarchy_create(scene, view_layer)`.
## Collections and safe naming
```python
import bpy
def ensure_collection(name, parent=None):
col = bpy.data.collections.get(name) or bpy.data.collections.new(name)
parent = parent or bpy.context.scene.collection
if col.name not in parent.children:
parent.children.link(col)
return col
ob = bpy.data.objects.new("SM_Crate", None)
ensure_collection("Export").objects.link(ob)
```
## Apply transforms without selection juggling
```python
import bpy
from mathutils import Matrix
me = bpy.data.meshes.new("T"); me.from_pydata([(0,0,0),(1,0,0),(0,1,0)], [], [(0,1,2)])
ob = bpy.data.objects.new("T", me); bpy.context.scene.collection.objects.link(ob)
ob.scale = (2, 2, 2); ob.rotation_euler = (0, 0, 1.0)
bpy.context.view_layer.update()
loc, rot, sca = ob.matrix_basis.decompose()
ob.data.transform(Matrix.LocRotScale(None, rot, sca)) # bake rot+scale into mesh
ob.matrix_basis = Matrix.Translation(loc)
```
For multi-user mesh data, call `ob.data = ob.data.copy()` first, or every user gets transformed.
## Handlers and timers (live sessions / add-ons)
```python
import bpy
def on_frame(scene, depsgraph=None):
pass
bpy.app.handlers.frame_change_post.append(on_frame)
bpy.app.handlers.frame_change_post.remove(on_frame)
bpy.app.timers.register(lambda: None, first_interval=0.5) # return float to repeat, None to stop
```
## Render a still headless
```python
import bpy, os, tempfile
sc = bpy.context.scene
sc.render.engine = "BLENDER_WORKBENCH"
sc.render.resolution_x = sc.render.resolution_y = 64
sc.render.image_settings.media_type = "IMAGE"; sc.render.image_settings.file_format = "PNG"
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam")); sc.collection.objects.link(cam)
cam.location = (0, -5, 0); cam.rotation_euler = (1.5708, 0, 0); sc.camera = cam
sc.render.filepath = os.path.join(tempfile.gettempdir(), "bas_still.png")
bpy.ops.render.render(write_still=True)
```
scripts/_bpyx.py
"""_bpyx - tiny shared helpers for blender-agent-skills scripts (Blender 5.2+).
Source of truth: shared/_bpyx.py. Copies in skills/*/scripts/ are synced by
tools/sync_shared.py - never edit a copy.
Every skill script follows one contract:
run(**kwargs) -> dict # call from MCP / Python (import the module)
blender -b f.blend -P x.py -- --arg v # CLI: prints one line "@@JSON@@{...}"
"""
import argparse
import json
import sys
import bpy
MARK = "@@JSON@@"
def argv():
"""Arguments after '--' (Blender ignores them)."""
return sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
def _default(o):
if hasattr(o, "__len__") and not isinstance(o, str):
try:
return [round(v, 5) if isinstance(v, float) else v for v in o]
except TypeError:
pass
if isinstance(o, (set, frozenset)):
return sorted(o)
return str(o)
def emit(data):
"""Print compact JSON on one marked line (parsed by run_in_blender.py)."""
print(MARK + json.dumps(data, separators=(",", ":"), default=_default), flush=True)
return data
def parser(doc):
"""ArgumentParser whose help is the script docstring's first line."""
return argparse.ArgumentParser(description=(doc or "").strip().splitlines()[0])
def cli(run, p):
"""CLI entry: parse args after '--', run, emit JSON. Exit 1 on failure."""
args = p.parse_args(argv())
try:
result = run(**vars(args))
save = getattr(args, "save", None)
if save:
bpy.ops.wm.save_as_mainfile(filepath=bpy.path.abspath(save))
result["saved"] = save
emit(result)
except Exception as e: # report, don't dump a traceback into agent context
emit({"ok": False, "error": f"{type(e).__name__}: {e}"})
if bpy.app.background:
sys.exit(1)
def add_common(p, save=True):
"""--objects / --save flags shared by most scripts."""
p.add_argument("--objects", nargs="*", default=None,
help="object names (default: selected, else all meshes)")
if save:
p.add_argument("--save", default=None, help="save .blend to this path after run")
return p
def objects(names=None, types=("MESH",)):
"""Resolve names -> objects. No names: selected of `types`, else all visible of `types`."""
if names:
missing = [n for n in names if n not in bpy.data.objects]
if missing:
raise KeyError(f"objects not found: {missing}")
return [bpy.data.objects[n] for n in names]
pool = [o for o in bpy.context.scene.objects if not types or o.type in types]
sel = [o for o in pool if o.select_get()]
return sel or [o for o in pool if o.visible_get()]
def object_mode():
"""Leave edit/sculpt/pose modes; most data API work needs OBJECT mode."""
ob = bpy.context.view_layer.objects.active
if ob and ob.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
def select_only(objs, active=None):
"""Select exactly `objs` and make `active` (default first) active."""
for o in bpy.context.view_layer.objects:
o.select_set(False)
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = active or (objs[0] if objs else None)
def node(tree, type_):
"""First node of `type_` (e.g. 'BSDF_PRINCIPLED'). Names are localized - never look up by name."""
return next((n for n in tree.nodes if n.type == type_), None)
def enum_ids(owner, prop):
"""Valid identifiers of an enum property (works on types or instances)."""
return [i.identifier for i in owner.bl_rna.properties[prop].enum_items]
def set_enum(owner, prop, value):
"""Assign an enum; on failure raise listing valid values (dynamic enums under-report)."""
try:
setattr(owner, prop, value)
except TypeError as e:
raise ValueError(f"{prop}={value!r} invalid: {e}") from None
def mesh_tris(ob, evaluated=True):
"""Triangle count (evaluated = with modifiers)."""
if evaluated:
eo = ob.evaluated_get(bpy.context.evaluated_depsgraph_get())
me = eo.to_mesh()
try:
me.calc_loop_triangles()
return len(me.loop_triangles)
finally:
eo.to_mesh_clear()
me = ob.data
me.calc_loop_triangles()
return len(me.loop_triangles)
def r(v, n=4):
"""Round a float or a vector for compact JSON."""
if isinstance(v, float):
return round(v, n)
return [round(x, n) for x in v]
scripts/env_report.py
"""Report Blender version, engines, GPU, add-ons, units and scene stats as JSON.
Usage:
blender -b [file.blend] -P env_report.py
MCP: import env_report; print(env_report.run())
"""
import os
import sys
import addon_utils
import bpy
sys.path.insert(0, os.path.dirname(__file__))
import _bpyx as bx # noqa: E402
def _gpu():
try:
prefs = bpy.context.preferences.addons["cycles"].preferences
prefs.refresh_devices()
return {"backend": prefs.compute_device_type,
"devices": sorted({d.name for d in prefs.devices if d.type != "CPU"})}
except Exception as e:
return {"error": str(e)}
def run():
sc = bpy.context.scene
try:
sc.render.engine = "__probe__"
except TypeError as e:
engines = str(e).split("in (")[-1].rstrip(")").replace("'", "").split(", ")
us = sc.unit_settings
kinds = {}
for o in sc.objects:
kinds[o.type] = kinds.get(o.type, 0) + 1
enabled = sorted(bpy.context.preferences.addons.keys())
return {
"ok": True,
"blender": bpy.app.version_string,
"python": sys.version.split()[0],
"background": bpy.app.background,
"file": bpy.data.filepath or None,
"engines": engines,
"engine": sc.render.engine,
"gpu": _gpu(),
"addons_enabled": enabled,
"rigify_available": any(m.__name__ == "rigify" for m in addon_utils.modules()),
"units": {"system": us.system, "scale_length": us.scale_length, "length": us.length_unit},
"color": {"view": sc.view_settings.view_transform, "look": sc.view_settings.look,
"display": sc.display_settings.display_device},
"frame_range": [sc.frame_start, sc.frame_end, sc.render.fps],
"objects": kinds,
"data": {k: len(getattr(bpy.data, k)) for k in
("meshes", "materials", "images", "actions", "node_groups", "collections")},
}
if __name__ == "__main__":
bx.cli(lambda **k: run(), bx.parser(__doc__))
scripts/run_in_blender.py
#!/usr/bin/env python3
"""Run a skill script in headless Blender and print only its JSON result.
Host-side (system Python 3.9+, no bpy). Finds Blender automatically:
$BLENDER_EXE > PATH > standard install dirs (newest version wins).
Usage:
python run_in_blender.py [--blend FILE] [--user-prefs] SCRIPT.py [-- script args]
python run_in_blender.py --expr "import bpy; print(bpy.app.version_string)"
python run_in_blender.py --which # print the Blender path it would use
Exit code: 0 ok, 1 script error, 2 Blender not found.
"""
import glob
import json
import os
import re
import shutil
import subprocess
import sys
MARK = "@@JSON@@"
def _ver(path):
m = re.search(r"(\d+)\.(\d+)", path)
return (int(m.group(1)), int(m.group(2))) if m else (0, 0)
def find_blender():
env = os.environ.get("BLENDER_EXE")
if env and os.path.isfile(env):
return env
hit = shutil.which("blender")
if hit:
return hit
pats = {
"win32": [r"C:\Program Files\Blender Foundation\Blender *\blender.exe",
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Blender Foundation\Blender *\blender.exe"),
r"C:\Program Files (x86)\Steam\steamapps\common\Blender\blender.exe"],
"darwin": ["/Applications/Blender*.app/Contents/MacOS/Blender",
os.path.expanduser("~/Applications/Blender*.app/Contents/MacOS/Blender")],
}.get(sys.platform, ["/usr/bin/blender", "/opt/blender*/blender", "/snap/bin/blender",
os.path.expanduser("~/blender*/blender")])
found = [p for pat in pats for p in glob.glob(pat) if os.path.isfile(p)]
return max(found, key=_ver) if found else None
def main():
a = sys.argv[1:]
rest = []
if "--" in a:
i = a.index("--")
a, rest = a[:i], a[i + 1:]
exe = find_blender()
if not exe:
print(json.dumps({"ok": False, "error": "Blender not found; set BLENDER_EXE"}))
return 2
if "--which" in a:
print(exe)
return 0
cmd = [exe, "-b"]
if "--blend" in a:
cmd.append(a[a.index("--blend") + 1])
if "--user-prefs" not in a:
cmd.append("--factory-startup")
cmd += ["--python-exit-code", "1"]
if "--expr" in a:
cmd += ["--python-expr", a[a.index("--expr") + 1]]
else:
scripts = [x for x in a if x.endswith(".py")]
if not scripts:
print(__doc__)
return 1
cmd += ["-P", scripts[0]]
cmd += ["--"] + rest
p = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
out = [ln[len(MARK):] for ln in p.stdout.splitlines() if ln.startswith(MARK)]
if out:
print("\n".join(out))
elif "--expr" in a:
# free-form expression: show stdout minus Blender banner noise
noise = ("Blender ", "Read prefs", "Read blend", "Blender quit", "Color management")
print("\n".join(ln for ln in p.stdout.splitlines() if ln and not ln.startswith(noise)))
if p.returncode != 0 and not out:
tail = (p.stderr or p.stdout).strip().splitlines()[-15:]
print(json.dumps({"ok": False, "returncode": p.returncode, "log_tail": tail}))
return 0 if p.returncode == 0 else 1
if __name__ == "__main__":
sys.exit(main())
SKILL.md
---
name: blender-core
description: Foundation for any Blender 5.2+ task an agent drives - running bpy via MCP (live) or headless CLI, context overrides, locale-safe node lookup, enum discovery, 5.x API changes, verification. Use first for any Blender, bpy, .blend or 3D-asset request; routes to the specialized blender-* skills.
license: MIT
compatibility: Blender 5.2 LTS or newer. Optional blender-mcp server for live sessions.
metadata:
author: blender-agent-skills
version: "1.2.0"
---
# Blender Core (5.2 LTS+)
## Route to the specialized skill
| Task | Skill |
|---|---|
| Units, collections, naming, linking, asset libraries | `blender-scene-setup` |
| Hard-surface modeling, bevels, booleans, normals | `blender-hardsurface-modeling` |
| Sculpting, multires, remesh | `blender-sculpting` |
| Retopology, UV unwrap, texel density | `blender-retopology-uv` |
| PBR materials, color spaces, texture sets | `blender-pbr-materials` |
| Wear, grime, dust, decals, trim sheets, texture painting | `blender-texturing` |
| Baking high→low maps (normal, AO, curvature, ...) | `blender-texture-baking` |
| Geometry Nodes, procedural modeling | `blender-geometry-nodes` |
| Body rigging, Rigify, weight painting | `blender-character-rigging` |
| Face rig, shape keys, ARKit, visemes | `blender-facial-shapekeys` |
| Keyframes, actions, NLA, mocap retarget, loops, root motion | `blender-animation` |
| Garments, layered clothing, body poke-through | `blender-clothing` |
| Hair, fur, grooming, hair cards | `blender-hair-grooming` |
| Poly budgets, LODs, draw calls, cleanup | `blender-game-optimization` |
| Export: glTF/FBX/USD to Roblox, Unity, Unreal, Godot, web | `blender-export-pipelines` |
| Cycles/EEVEE settings, compositor, batch render | `blender-rendering` |
| Lighting, HDRI, camera, turntable | `blender-lighting-lookdev` |
| Toon/cel/anime shading, outlines, Line Art, Grease Pencil | `blender-stylized-npr` |
| Terrain, biome scatter, modular kits | `blender-environment-kits` |
| Video editing (VSE), motion tracking, camera solve | `blender-video-editing` |
| Time of day, night, sunset, fog, mood (cyberpunk, horror...) | `blender-time-of-day` |
| Rain, snow, wet surfaces, puddles, lightning, dust/embers | `blender-weather-fx` |
| Cloth, rigid body, fluids, hair, GN physics | `blender-simulation` |
| Writing add-ons / extensions | `blender-addon-development` |
| Batch/headless pipelines, file conversion | `blender-pipeline-automation` |
| Checking a mesh/asset before delivery (always) | `blender-asset-qa` |
| End-to-end playbooks (prop, character, Roblox, product shot) | `blender-workflow-recipes` |
## Execute
**Live (blender-mcp `execute_blender_code`)**: work in small steps and print only what you need. To call a skill script, import it:
```python
import sys, importlib; sys.path.insert(0, r"<skills_dir>/blender-asset-qa/scripts")
import qa_check; importlib.reload(qa_check); print(qa_check.run(objects=["Chair"]))
```
Each MCP call runs in a **fresh namespace**: re-`import` in every call. That's cheap because modules stay cached in `sys.modules`. `reload` only after editing a script.
**Headless (any agent with a shell)**: this prints only the script's JSON (`@@JSON@@` lines):
```
python <skills_dir>/blender-core/scripts/run_in_blender.py --blend in.blend <script.py> -- --objects Chair --save out.blend
python <skills_dir>/blender-core/scripts/run_in_blender.py --expr "import bpy; print(len(bpy.data.objects))"
```
Raw form: `blender -b in.blend --factory-startup --python-exit-code 1 -P x.py -- args`. Every script here exposes `run(**kw) -> dict` and the same flags after `--`.
**First call in a session**: `scripts/env_report.py`, which returns the version, engines, GPU, add-ons, units, color management and scene counts.
## Rules
- **Data API over operators**: use `bpy.data`, `bmesh`, `mesh.attributes`, and `foreach_get/set` with numpy. Operators depend on context and selection, and many need a 3D View, which background mode doesn't have.
- If an operator is unavoidable: `with bpy.context.temp_override(object=ob, active_object=ob, selected_objects=[ob], selected_editable_objects=[ob]): bpy.ops...`. Check that the result is `{'FINISHED'}`.
- **Edit mode isolation**: `ob.data` isn't updated while in Edit Mode. Use `bmesh.from_edit_mesh` + `bmesh.update_edit_mesh`, or switch to OBJECT mode before reading.
- **Nodes**: find them by `type` (`n.type == "BSDF_PRINCIPLED"`), never by `name`, because names are localized. Sockets by English name or `identifier` are fine.
- **Enums**: read the valid values with `[i.identifier for i in T.bl_rna.properties[p].enum_items]`. Dynamic enums (`render.engine`, `view_transform`, `look`) under-report, so assign them inside `try/except TypeError` (the message lists the valid values).
- **Engines**: `BLENDER_EEVEE`, `CYCLES`, `BLENDER_WORKBENCH`. Cycles device: `scene.cycles.device = "GPU"` + preferences `compute_device_type`.
- **Evaluated data**: use `ob.evaluated_get(depsgraph)`, then `.to_mesh()`, then always `.to_mesh_clear()`. Call `view_layer.update()` before reading `matrix_world` after a change.
- **Freed data**: applying or removing a modifier, or removing an object or ID, frees it. Collect names first (`[m.name for m in ob.modifiers]`) and never touch the old Python reference afterward, because it returns garbage or crashes.
- **Never trust default names** ("Cube.001"). Keep object references, rename explicitly, and look objects up via `bpy.data.objects[name]`.
- **Scale**: 1 unit = 1 m. Apply rotation/scale (`transform_apply`) before bevels, modifiers with distances, rigging, physics, baking and export.
- **Paths**: `bpy.path.abspath("//textures/x.png")` resolves relative to the .blend. Save explicitly; use `save_as_mainfile(filepath=..., copy=True)` for backups.
- **Live sessions are the user's work**: never `read_factory_settings`, never delete data you didn't create, and save a backup copy before destructive batch edits.
- **GPU module in background**: call `gpu.init()` first (5.2+). `bgl` no longer exists.
- **Big meshes**: no per-vertex `bpy.ops`. Use numpy `foreach_get` (see references/bpy-patterns.md).
## 5.x API changes that break old tutorials/LLM memory
`action.fcurves` → channelbags · `scene.node_tree` → `scene.compositing_node_group` · `material.use_nodes` is deprecated (new materials already have nodes) · `mod["Socket_2"]` → `mod.properties.inputs.Socket_2.value` (5.2) · `BLENDER_EEVEE_NEXT` → `BLENDER_EEVEE` · Boolean `FAST` → `FLOAT` · bone layers → bone collections · `bl_info` add-ons → extensions (`blender_manifest.toml`) · `bpy.props` values are no longer dict-accessible (`scene["cycles"]`). Full table: [references/api-changes-5x.md](references/api-changes-5x.md).
## Verify (required before saying "done")
1. Numbers, not adjectives: report tris, objects, materials, dimensions (`ob.dimensions`) and the output files written.
2. Geometry changed → run `blender-asset-qa` `qa_check.py` (z-fighting, detached parts, non-manifold, flipped normals, scale).
3. Look at it: MCP `look` (`mode="angles"`, shading `material`/`wireframe`). Headless: `blender-asset-qa/scripts/review_shots.py` renders contact sheets.
4. Re-read your own output: confirm that exported files exist and that their size is non-zero.
## References
- [references/api-changes-5x.md](references/api-changes-5x.md): 4.x→5.2 migration table. Load it when code fails with AttributeError/KeyError.
- [references/bpy-patterns.md](references/bpy-patterns.md): verified snippets for bmesh, attributes, numpy, temp_override, depsgraph, drivers, append/link, handlers and timers.