Claude Skill

scenario-blender-expert

Use when doing any 3D work in Blender through Python or an MCP bridge, headless or in a live session: modeling, sculpting a character or head, retopology, UV unwrapping, baking, texturing, rigging, animating, previs, geometry nodes, lighting and rendering, grease pencil, or finis

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download scenario-labs-skills-skills_dcc_blender_scenario-blender-expert-dc592af.zip · 54 KB
Part of scenario-labs/skills — 97 skills

Install

skills CLI npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/blender/scenario-blender-expert
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
Git git clone https://github.com/scenario-labs/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Blender expert (router and agent protocol)

Expert-level Blender work is a loop, not a script: build big to small, and at every stage turn the model, measure it and fix it before adding detail. This skill is the protocol every Blender task follows and the map to the domain skills distilled from Blender Studio artists, Blender Conference talks and top instructors (sources in each skill). If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.

1. Execution channel

Channel Use for Rules
Live GUI Blender (user's session over the MCP bridge) anything interactive: real sculpt/paint brushes, weight painting, viewport screenshots One short, idempotent script per call returning a compact result dict. Get-or-create by name. Never touch objects you did not create. Save a stage backup bpy.ops.wm.save_as_mainfile(filepath=..., copy=True) after each stage.
Headless helper blender -b [file] --factory-startup --python-exit-code 1 -P x.py -- args renders, audits, batch ops, anything when no bridge is up Always --factory-startup: without it the user's add-ons load (on this machine one of them makes network API calls). Without --python-exit-code a failing script still exits 0. Print a final machine-readable result line.

Never open your own GUI Blender window to test things on the user's machine: windows steal focus and pile up. Live work goes through the user's already-running Blender; everything else runs headless, one Blender process at a time per agent (other sessions may be rendering on the same machine).

Check bpy.app.version first; this skill set targets 5.2 LTS. Many tutorials the experts recorded use 2.8x to 4.2 UI: read references/blender-5.2-deltas.md before trusting any remembered operator, brush name or API.

Verified capability matrix (Blender 5.2.1):

Action Headless GUI session
Voxel remesh, QuadriFlow, multires subdivide, modifiers, bmesh, UV ops with edit-mode context, Cycles/EEVEE/Workbench renders, baking yes yes
bpy.ops.brush.asset_activate, dyntopo toggle yes yes
Brush strokes (sculpt.brush_stroke, paint.image_paint, paint.weight_paint) no (poll fails) yes, via scripts/bx_gui.py
paint.mask_flood_fill, sculpt.mesh_filter CRASHES Blender yes, via bx_gui.run()
Programmatic sculpting on mesh data yes, scenario-blender-sculpting/scripts/bx_sculpt.py yes

2. The expert loop (every domain)

  1. Establish the brief in numbers: purpose (film, game engine, still, animation), budgets (tris, texture size, frame range), style (stylized or realistic), references. When the brief is silent, choose a documented default from the domain skill and write it down.
  2. Plan the stages the domain skill defines. Never start with detail.
  3. Build one stage with the smallest scripts that do it.
  4. Gate the stage: run the measurable checks (scripts/bx_audit.py for meshes, the domain's own checks) AND render a review sheet (scripts/bx_review.py: silhouette, matcap, wire from front/side/three-quarter/low) and open it with the image reader. Experts orbit constantly; the review sheet is the agent's orbit. Judge with the domain's references/critique.md.
  5. Fix before advancing. A problem found at blockout costs one script; found after texturing it costs the stage.
  6. Deliver with evidence: final renders, audit numbers, and what was not verified.

Never report a model, rig, animation or render as done without having looked at a render of it.

3. Persona pipelines

Persona / brief Skill chain Deliverable
Character artist, stylized or realistic scenario-blender-sculpting, scenario-blender-retopology, scenario-blender-uv-baking, scenario-blender-texturing-shading, scenario-blender-hair sculpt, animation-ready low poly, UVs, baked maps, materials
AI-mesh finisher (Scenario 3D output to production) audit the generated mesh, scenario-blender-retopology (or cleanup + remesh), scenario-blender-uv-baking (bake FROM the generated high), scenario-blender-texturing-shading; generation itself: scenario-3d clean, UV'd, baked asset at a stated budget
Game asset artist scenario-blender-hard-surface or scenario-blender-sculpting, scenario-blender-retopology (game budget), scenario-blender-uv-baking, scenario-blender-texturing-shading low poly + PBR set, exported glTF/FBX
Rigger / character TD scenario-blender-rigging rig with controls, weights, deformation tests
Animator scenario-blender-previs-storyboard (shot planning), scenario-blender-animation blocked, splined, polished shot + playblast
Story / previs artist scenario-blender-previs-storyboard, scenario-blender-grease-pencil shots, cameras, animatic
Look-dev and lighting artist scenario-blender-texturing-shading, scenario-blender-lighting-rendering lit, color-managed, composited renders
Technical artist / procedural scenario-blender-geometry-nodes node-group assets, procedural systems
2D / 2.5D animator scenario-blender-grease-pencil GP animation

Related skills outside this set: scenario-3d (generate meshes to finish here), scenario-textures (AI textures), text-image-to-blender-blockout (camera-locked blockouts for render-to-real video).

4. Shared toolkit (scripts/, all tested on 5.2.1)

  • bx_audit.py: audit(obj, high=None) returns quads %, n-gons, poles (valence 3 / 5+ / 6+), non-manifold, loose, duplicates, flipped faces, self-intersections, boundary loops, symmetry %, edge-length CV, and fidelity to a high poly; verdict(report) lists problems. CLI: blender -b f.blend --python bx_audit.py -- --object Name [--high Sculpt] [--json out.json].
  • bx_review.py: review(objs, out_dir, views, modes) writes one contact sheet (rows = modes, cols = views) in a temporary scene, user settings untouched; playblast(path); turntable(obj, path).
  • bx_gui.py: real brushes in a live session: set_view, activate_brush(mode, name), stroke(mode, world_points, size, strength), run(op, **kw), set_paint_color, screenshot.

Import pattern: import sys; sys.path.append("<this skill>/scripts"); import bx_audit, bx_review.

5. Version traps that break most remembered code

  • Brushes are assets (4.3): bpy.ops.brush.asset_activate(asset_library_type='ESSENTIALS', asset_library_identifier="", relative_asset_identifier="brushes/essentials_brushes-mesh_sculpt.blend/Brush/Clay Strips"); brush size is a diameter (5.0); Flatten/Fill/Scrape merged into the Plane type.
  • Auto Smooth removed (4.1): mesh.shade_smooth() / Smooth by Angle modifier; voxel remesh output is flat-shaded until you call shade_smooth().
  • Principled BSDF v2 (4.0): address sockets by name; Emission strength defaults to 0.
  • BLENDER_EEVEE is the EEVEE Next identifier in 5.x (BLENDER_EEVEE_NEXT fails).
  • Compositor is a node group (5.0): scene.compositing_node_group, not scene.node_tree.
  • Video output: set image_settings.media_type = 'VIDEO' before file_format = 'FFMPEG' (5.0).
  • Slotted actions (4.4), action.fcurves removed (5.0): use bpy_extras.anim_utils.action_get_channelbag_for_slot(action, slot).
  • Bone collections replace armature layers (4.0); Grease Pencil v3 is GREASEPENCIL with bpy.ops.grease_pencil.* (4.3).
  • Positional context dicts for operators are gone: with bpy.context.temp_override(...).
  • obj.matrix_world is stale right after setting location/scale until view_layer.update().

References

  • references/bpy-reliability.md: checklist and tested procedures for reliable bpy (operators vs data API, stale references, evaluated vs original data, idempotent headless jobs, numpy bulk access, node trees, persistence). Load before writing any non-trivial script.
  • references/blender-5.2-deltas.md: full, cited list of what changed from 2.8x to 5.2 per domain (load before writing code from memory or following an old tutorial).
Files (skills)
  • references
    • blender-5.2-deltas.md 115.8 KB
      # Blender version deltas: what makes 2.8x to 4.x tutorials misleading in 5.2.1 LTS
      
      Reference for condensing expert Blender tutorials (recorded on 2.80 to 4.2) into agent skills that drive **Blender 5.2.1 LTS** headless through `bpy`. Organized by domain. Each point reads "old tutorial says X -> in 5.2 do Y (bpy: ...)".
      
      ## Legend and provenance
      
      - **[verified in 5.2.1]**: checked empirically on 2026-09-24 in `/opt/homebrew/bin/blender` = Blender 5.2.1 LTS (build 2026-08-25), Python 3.13.13, run as `blender --background --factory-startup --python ...`. Probe scripts: `archive/tests/version-deltas/probe1.py` to `probe9b.py`, `probe5_keymap.py`, `probe6_keymapdata.py`, `probe7_defaults.py`, `probe8_bake.py`.
      - **[release notes]**: stated in the official release notes (link given), not re-tested.
      - **[inferred]**: my reading of an API or UI detail, not confirmed. Treat with care.
      - Release-note Markdown for 2.80 to 5.3 was pulled from the `blender-developer-docs` repo and cached in `archive/tests/version-deltas/rn/` for grepping. All links below point to the public pages `https://developer.blender.org/docs/release_notes/<version>/<page>/` (URL pattern checked, HTTP 200).
      - Scripting gotcha found while verifying: `hasattr(bpy.types.Mesh, "color_attributes")` returns False for RNA _properties_ (it only sees functions). Test with `"prop" in bpy.types.X.bl_rna.properties` or on an instance. [verified in 5.2.1]
      - Keymaps are **empty in `--background`** (`keyconfigs.default.keymaps[...].keymap_items` has 0 items). Shortcut facts below were read from the bundled keymap data (`scripts/presets/keyconfig/keymap_data/blender_default.py`) through `generate_keymaps()`. [verified in 5.2.1]
      
      ---
      
      ## 1. Version timeline (what each release broke for old tutorials)
      
      | Version               | Headline changes that make older tutorials wrong                                                                                                                                                                                                                                                                                                         | Main source                                                                                                                                                                                                                                                                                                                                                                                                            |
      | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | 2.81 to 2.83          | Voxel and QuadriFlow remesh, Pose/Elastic/Draw Sharp brushes, mask expand (2.81); Face Sets, Cloth and Clay Thumb brushes, brush hardness (2.83)                                                                                                                                                                                                         | [2.81/sculpt](https://developer.blender.org/docs/release_notes/2.81/sculpt/), [2.83/sculpt](https://developer.blender.org/docs/release_notes/2.83/sculpt/)                                                                                                                                                                                                                                                             |
      | 2.90 to 2.93          | Multires rebuild/unsubdivide (2.90); per-mesh XYZ symmetry, trim/line gestures, cloth sculpting (2.91); first attribute-based Geometry Nodes (2.92/2.93, obsolete since 3.0)                                                                                                                                                                             | [2.90/sculpt](https://developer.blender.org/docs/release_notes/2.90/sculpt/), [2.91/sculpt](https://developer.blender.org/docs/release_notes/2.91/sculpt/)                                                                                                                                                                                                                                                             |
      | 3.0 to 3.6            | Cycles X (tiles, branched path and OpenCL gone) and Geometry Nodes fields (3.0); vertex colors become Color Attributes + painting in Sculpt Mode (3.2); new Curves hair object (3.3); path guiding, UV sculpt/packing work, face sets opt-in (3.4); light tree default, VDM brushes (3.5); simulation zone, Retopology overlay, UV packing options (3.6) | [3.0/cycles](https://developer.blender.org/docs/release_notes/3.0/cycles/), [3.0/nodes_physics](https://developer.blender.org/docs/release_notes/3.0/nodes_physics/), [3.2/sculpt](https://developer.blender.org/docs/release_notes/3.2/sculpt/), [3.5/cycles](https://developer.blender.org/docs/release_notes/3.5/cycles/), [3.6/nodes_physics](https://developer.blender.org/docs/release_notes/3.6/nodes_physics/) |
      | 4.0                   | Principled BSDF v2 (socket renames), AgX default, bone collections replace layers/bone groups, node group `interface` API, sculpt/paint keymap overhaul, repeat zone, node tools, bevel weights/creases as attributes, `bpy.ops` context dict removed                                                                                                    | [4.0/python_api](https://developer.blender.org/docs/release_notes/4.0/python_api/), [4.0/keymap](https://developer.blender.org/docs/release_notes/4.0/keymap/)                                                                                                                                                                                                                                                         |
      | 4.1                   | Auto Smooth removed (modifier/operator), I/K keying change, light probe type renames, Musgrave merged into Noise, hierarchical bone collections, sculpt mask as attribute                                                                                                                                                                                | [4.1/python_api](https://developer.blender.org/docs/release_notes/4.1/python_api/), [4.1/modeling](https://developer.blender.org/docs/release_notes/4.1/modeling/)                                                                                                                                                                                                                                                     |
      | 4.2 LTS (16 Jul 2024) | EEVEE Next rewrite (Blend Mode -> Render Method, bloom removed, SSR/GTAO toggles gone), most bundled add-ons moved to the Extensions platform, GPU/CPU compositor rewrite, Line/Polyline sculpt tools                                                                                                                                                    | [4.2/eevee_migration](https://developer.blender.org/docs/release_notes/4.2/eevee_migration/), [4.2 index](https://developer.blender.org/docs/release_notes/4.2/)                                                                                                                                                                                                                                                       |
      | 4.3                   | Brush assets + asset shelf (brush toolbar gone), Grease Pencil v3 rewrite, For Each Element zone, Minimum Stretch (SLIM) unwrap, Metallic BSDF, sculpt performance rewrite                                                                                                                                                                               | [4.3/sculpt](https://developer.blender.org/docs/release_notes/4.3/sculpt/), [4.3/grease_pencil_migration](https://developer.blender.org/docs/release_notes/4.3/grease_pencil_migration/)                                                                                                                                                                                                                               |
      | 4.4                   | Slotted Actions, `Sequence` -> `Strip`, Plane brush (Flatten/Fill/Scrape merged), CPU compositor rewrite, `paint.brush` read-only                                                                                                                                                                                                                        | [4.4/python_api](https://developer.blender.org/docs/release_notes/4.4/python_api/), [4.4/upgrading/slotted_actions](https://developer.blender.org/docs/release_notes/4.4/upgrading/slotted_actions/)                                                                                                                                                                                                                   |
      | 4.5 LTS               | Manifold boolean solver, compositor node options become inputs (many nodes deprecated), GN import nodes, free custom normals, bump distance default 1 mm                                                                                                                                                                                                 | [4.5/compositor](https://developer.blender.org/docs/release_notes/4.5/compositor/), [4.5/modeling](https://developer.blender.org/docs/release_notes/4.5/modeling/)                                                                                                                                                                                                                                                     |
      | 5.0                   | EEVEE id back to `BLENDER_EEVEE`; legacy Action API removed; `use_nodes` deprecated; compositor tree becomes a node group; `media_type` before `file_format`; working color space; bundles/closures/volume grids; brush property renames; Collada removed; bone select/hide on pose bones                                                                | [5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/), [5.0/migration/compositor_migration](https://developer.blender.org/docs/release_notes/5.0/migration/compositor_migration/)                                                                                                                                                                                                         |
      | 5.1                   | Python 3.13, `brush.stroke_method` enum, Grease Pencil fill workflow (`fill_id`), `sculpt.sample_color` removed, strip time property renames                                                                                                                                                                                                             | [5.1/python_api](https://developer.blender.org/docs/release_notes/5.1/python_api/)                                                                                                                                                                                                                                                                                                                                     |
      | 5.2 LTS (14 Jul 2026) | GN modifier `properties.inputs` API, automasking struct, GN lists/geometry bundles/Mesh Bevel node, experimental XPBD physics, Principled Thin Wall, Scene Project brush, Online Essentials assets                                                                                                                                                       | [5.2/python_api](https://developer.blender.org/docs/release_notes/5.2/python_api/), [5.2/geometry_nodes](https://developer.blender.org/docs/release_notes/5.2/geometry_nodes/)                                                                                                                                                                                                                                         |
      
      ---
      
      ## 2. Headless scripting basics (read first)
      
      - **Old:** `bpy.ops.object.delete({"selected_objects": objs})` -> **5.2:** `with bpy.context.temp_override(selected_objects=objs): bpy.ops.object.delete()`. Positional context dicts raise `ValueError: 1-2 args execution context is supported`. [verified in 5.2.1] ([4.0/python_api](https://developer.blender.org/docs/release_notes/4.0/python_api/)). 5.0 adds `override.logging_set(True)` to log which context members an operator reads ([5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/)).
      - Engine identifiers: `'BLENDER_EEVEE'`, `'CYCLES'`, `'BLENDER_WORKBENCH'`. Setting `'BLENDER_EEVEE_NEXT'` fails in 5.2.1. Note `RenderSettings.bl_rna.properties["engine"].enum_items` statically lists only `BLENDER_EEVEE`, so do not validate engines from that list. [verified in 5.2.1]
      - Factory defaults: engine `BLENDER_EEVEE`, 1920x1080 at 24 fps, view transform `AgX`, look `None`, display `sRGB`, EEVEE `taa_render_samples=64`, `use_raytracing=False`; Cycles `samples=4096`, denoising on (`OPENIMAGEDENOISE`), light tree on, device `CPU`. [verified in 5.2.1]
      - Python is 3.13 (5.1); the user site-packages directory is no longer loaded by default, pass `--python-use-user-env` to restore it. [verified 3.13.13 in 5.2.1] ([5.1/python_api](https://developer.blender.org/docs/release_notes/5.1/python_api/), [5.1/core](https://developer.blender.org/docs/release_notes/5.1/core/))
      - `import bgl` fails (module removed in 5.0); `Image.bindcode` removed, use `gpu.texture.from_image()`. [verified in 5.2.1] ([5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/))
      - 5.2 adds `gpu.init()` to initialize the GPU backend in `--background`, `bpy.data.all_ids`, `Window.screenshot`, and expanded `bpy.data.file_path_foreach` options. [release notes] ([5.2/python_api](https://developer.blender.org/docs/release_notes/5.2/python_api/))
      - `--background` disables audio by default (4.2). Log output format changed in 5.0: `--debug-cycles` -> `--log cycles`, `--debug-ffmpeg` -> `--log video`, `--verbose` -> `--log-level info|debug|trace`; render-farm log parsers need updating. [release notes] ([4.2/core](https://developer.blender.org/docs/release_notes/4.2/core/), [5.0/core](https://developer.blender.org/docs/release_notes/5.0/core/))
      - `bpy.props`-defined properties are no longer reachable as dict items (`scene["cycles"]` no longer gives Cycles settings); use the RNA attributes. Blend-file compression is on by default. Data-block names can be 255 bytes. [release notes] ([5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/), [5.0/core](https://developer.blender.org/docs/release_notes/5.0/core/))
      - `mathutils` types now expose float32 buffers (numpy conversions change). Python subclasses of Blender types that define `__init__` must call `super().__init__(*args, **kwargs)` (4.4). IDProperties behind `bpy.props` and GN modifier inputs are statically typed (4.2). [release notes] ([5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/), [4.4/python_api](https://developer.blender.org/docs/release_notes/4.4/python_api/), [4.2/python_api](https://developer.blender.org/docs/release_notes/4.2/python_api/))
      - `bpy.ops.render.render()` accepts `frame_start`/`frame_end` (5.0). [release notes] ([5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/))
      
      ---
      
      ## 3. Sculpting
      
      ### 3.1 Brush selection and the brush asset system (4.3)
      
      - **Old:** pick brushes from the toolbar (one tool per brush), or `bpy.ops.paint.brush_select(...)` / `paint.brush_set` -> **5.2:** brushes are **assets** in the _Essentials_ library, picked from the asset shelf at the bottom of the viewport or the popup (`Shift+Space` with the default spacebar action "Play", plain `Space` if the keymap uses "Tools"). `paint.brush_set` does not exist. [verified in 5.2.1] ([4.3/sculpt](https://developer.blender.org/docs/release_notes/4.3/sculpt/))
      - bpy (works headless once the object is in Sculpt Mode; 4.5 made it a blocking load in background):
        ```python
        bpy.ops.object.mode_set(mode='SCULPT')
        bpy.ops.brush.asset_activate(asset_library_type='ESSENTIALS', asset_library_identifier="",
            relative_asset_identifier="brushes/essentials_brushes-mesh_sculpt.blend/Brush/Clay Strips")
        b = bpy.context.scene.tool_settings.sculpt.brush   # 'Clay Strips', CLAY_STRIPS, size 70, strength 0.5
        ```
        [verified in 5.2.1] ([4.3/sculpt](https://developer.blender.org/docs/release_notes/4.3/sculpt/), [4.5/python_api](https://developer.blender.org/docs/release_notes/4.5/python_api/)). Assigning `tool_settings.sculpt.brush = ...` is not the way: `paint.brush` is read-only since 4.4 ([4.4/python_api](https://developer.blender.org/docs/release_notes/4.4/python_api/)).
      - Essentials brush files live in `/Applications/Blender.app/Contents/Resources/5.2/datafiles/assets/brushes/`: `essentials_brushes-mesh_sculpt.blend` (64 brushes), `-mesh_texture` (15), `-mesh_vertex` (8), `-mesh_weight` (4), `-curve_sculpt` (11), `-gp_draw` (13), `-gp_sculpt` (9), `-gp_vertex` (5), `-gp_weight` (4). [verified in 5.2.1]
      - Custom brushes: duplicate an essentials brush, then _Save As Asset_ (`bpy.ops.brush.asset_save_as`) into a user library; each brush is its own `.asset.blend`. Old files' local brushes need _Mark as Asset_ then _Save As Asset_. [verified op exists in 5.2.1] ([4.3/sculpt](https://developer.blender.org/docs/release_notes/4.3/sculpt/))
      - Brush thumbnails, custom icons: `brush.use_custom_icon` / `icon_filepath` removed (5.0), use the asset preview. [verified absent in 5.2.1] ([5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/))
      
      ### 3.2 Brush names and merges (tutorial name -> 5.2 asset name -> `sculpt_brush_type`)
      
      All mapped by loading every brush in `essentials_brushes-mesh_sculpt.blend`. [verified in 5.2.1]
      
      | Tutorial (2.8x to 4.2) says                                        | 5.2 Essentials asset                                                                                                                                                                                               | `sculpt_brush_type`                                                                                                     |
      | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
      | Draw / Draw Sharp / Clay / Clay Strips / Clay Thumb / Layer / Blob | same names                                                                                                                                                                                                         | `DRAW`, `DRAW_SHARP`, `CLAY`, `CLAY_STRIPS`, `CLAY_THUMB`, `LAYER`, `BLOB`                                              |
      | Inflate                                                            | **Inflate/Deflate**                                                                                                                                                                                                | `INFLATE`                                                                                                               |
      | Crease                                                             | **Crease Polish** (renamed 4.3) + new **Crease Sharp**                                                                                                                                                             | `CREASE`                                                                                                                |
      | Pinch                                                              | **Pinch/Magnify**                                                                                                                                                                                                  | `PINCH`                                                                                                                 |
      | Flatten, Fill, Scrape (separate tools)                             | **Flatten/Contrast**, **Fill/Deepen**, **Scrape/Fill**, plus **Plateau**, **Trim**                                                                                                                                 | all `PLANE` (4.4 merged Flatten/Fill/Scrape into the Plane type; `FLATTEN`/`FILL`/`SCRAPE` no longer exist in the enum) |
      | Multi-plane Scrape                                                 | **Scrape Multiplane**                                                                                                                                                                                              | `MULTIPLANE_SCRAPE`                                                                                                     |
      | Elastic Deform                                                     | **Elastic Grab** (+ **Elastic Snake Hook**)                                                                                                                                                                        | `ELASTIC_DEFORM`, `SNAKE_HOOK`                                                                                          |
      | Grab / Snake Hook / Thumb / Nudge / Pose / Boundary                | same, plus **Grab 2D**, **Grab Silhouette**, **Pull**                                                                                                                                                              | `GRAB`, `SNAKE_HOOK`, `THUMB`, `NUDGE`, `POSE`, `BOUNDARY`                                                              |
      | Rotate                                                             | **Twist**                                                                                                                                                                                                          | `ROTATE`                                                                                                                |
      | Slide Relax                                                        | **Relax Slide**, **Relax Pinch**                                                                                                                                                                                   | `TOPOLOGY`                                                                                                              |
      | Simplify (dyntopo brush)                                           | **Density**                                                                                                                                                                                                        | `SIMPLIFY`                                                                                                              |
      | Draw Face Sets                                                     | **Face Set Paint**                                                                                                                                                                                                 | `DRAW_FACE_SETS`                                                                                                        |
      | Multires Displacement Eraser / Smear                               | **Erase Multires Displacement**, **Smear Multires Displacement**                                                                                                                                                   | `DISPLACEMENT_ERASER`, `DISPLACEMENT_SMEAR`                                                                             |
      | Cloth (one brush with modes)                                       | 9 dedicated cloth brushes (Drag, Push, Grab, Grab Planar, Grab Random, Inflate, Expand/Contract, Pinch Folds, Pinch Point Cloth) + Bend/Twist, Stretch/Move Cloth (`POSE`), Bend/Twist Boundary Cloth (`BOUNDARY`) | `CLOTH` etc.                                                                                                            |
      | Paint / Smear (3.2 sculpt painting)                                | Paint Hard/Soft/Square/Blend (+Pressure variants), Airbrush, Blend Hard/Soft/Square; Smear, **Sharpen**                                                                                                            | `PAINT`, `SMEAR`                                                                                                        |
      | (new)                                                              | **Blur** (5.1), **Scene Project** (5.2, shrinkwrap-like projection onto other objects)                                                                                                                             | `BLUR`, `SCENE_PROJECT`                                                                                                 |
      
      Sources: brush list and renames [4.3/sculpt](https://developer.blender.org/docs/release_notes/4.3/sculpt/), Plane brush [4.4/sculpt](https://developer.blender.org/docs/release_notes/4.4/sculpt/), Blur [5.1/sculpt](https://developer.blender.org/docs/release_notes/5.1/sculpt/), Scene Project [5.2/sculpt](https://developer.blender.org/docs/release_notes/5.2/sculpt/).
      
      ### 3.3 Brush settings and API renames
      
      - **Brush size is now a diameter** (5.0). A tutorial's "Radius 50 px" is `size = 100` in 5.x. RNA description of `Brush.size`: "Diameter of the brush in pixels". [verified in 5.2.1] ([5.0/sculpt](https://developer.blender.org/docs/release_notes/5.0/sculpt/))
      - `brush.sculpt_tool` -> `brush.sculpt_brush_type`; likewise `image_brush_type`, `vertex_brush_type`, `weight_brush_type`, `gpencil_brush_type`, `curves_sculpt_brush_type` (5.0). [verified in 5.2.1] ([5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/))
      - `brush.curve` / `curve_preset` -> `curve_distance_falloff` / `curve_distance_falloff_preset` (5.0); pen-pressure curves for size/strength/jitter (5.0). [verified rename in 5.2.1] ([5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/), [5.0/sculpt](https://developer.blender.org/docs/release_notes/5.0/sculpt/))
      - `use_airbrush`, `use_anchor`, `use_space`, `use_line`, `use_curve`, `use_restore_mesh` -> one enum `brush.stroke_method` in `{'DOTS','DRAG_DOT','SPACE','AIRBRUSH','ANCHORED','LINE','CURVE'}` (5.1). [verified in 5.2.1] ([5.1/python_api](https://developer.blender.org/docs/release_notes/5.1/python_api/))
      - Unified size/strength/color are per mode: `scene.tool_settings.unified_paint_settings` -> `scene.tool_settings.sculpt.unified_paint_settings` (5.0). [verified in 5.2.1] ([5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/))
      - Radial symmetry moved from tool settings to the mesh: `mesh.radial_symmetry` (5.0). XYZ mirror has been per mesh since 2.91: `mesh.use_mirror_x/y/z`. [verified in 5.2.1] ([5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/), [2.91/sculpt](https://developer.blender.org/docs/release_notes/2.91/sculpt/))
      - Automasking moved into a struct (5.2): `sculpt.use_automasking_topology` -> `tool_settings.sculpt.mesh_automasking_settings.use_automasking_topology` (same on `brush.mesh_automasking_settings`); `automasking_cavity_factor` -> `mesh_automasking_settings.cavity_factor`, etc. [verified in 5.2.1] ([5.2/python_api](https://developer.blender.org/docs/release_notes/5.2/python_api/))
      - Brush and palette colors are stored in scene linear (5.0); sRGB hex values from tutorials need conversion or use the color picker's Perceptual sliders. [release notes] ([5.0/sculpt](https://developer.blender.org/docs/release_notes/5.0/sculpt/), [5.0/color_management](https://developer.blender.org/docs/release_notes/5.0/color_management/))
      - Grab, Snake Hook, Elastic, Pose, Boundary, Thumb, Rotate no longer show pressure toggles (5.0). Tilt support for Draw, Draw Sharp, Plane, Clay Strips (4.5). [release notes] ([5.0/sculpt](https://developer.blender.org/docs/release_notes/5.0/sculpt/), [4.5/sculpt](https://developer.blender.org/docs/release_notes/4.5/sculpt/))
      - Clay Strips was "overhauled" in 4.3, cloth brushes default _Persistent_ off (4.4): old strength values may not transfer. [release notes] ([4.3/sculpt](https://developer.blender.org/docs/release_notes/4.3/sculpt/), [4.4/sculpt](https://developer.blender.org/docs/release_notes/4.4/sculpt/))
      
      ### 3.4 Dyntopo, remesh, multires
      
      - **Dyntopo.** Old: `Ctrl+D` toggles, "Smooth Shading" option in the Dyntopo panel -> 5.2: no default shortcut (removed 4.0), "Smooth Shading" removed (4.0), no confirmation dialog when enabling (5.2). bpy: `bpy.ops.sculpt.dynamic_topology_toggle()` works headless; `object.use_dynamic_topology_sculpting` reports state; defaults `detail_type_method='RELATIVE'`, `detail_size=12`, `detail_refine_method='SUBDIVIDE_COLLAPSE'`. `R` = detail size edit and `Ctrl+R` = detail flood fill when dyntopo is on. [verified in 5.2.1] ([4.0/keymap](https://developer.blender.org/docs/release_notes/4.0/keymap/), [4.0/sculpt](https://developer.blender.org/docs/release_notes/4.0/sculpt/), [5.2/sculpt](https://developer.blender.org/docs/release_notes/5.2/sculpt/))
      - **Voxel remesh.** `mesh.remesh_voxel_size = 0.02; bpy.ops.object.voxel_remesh()` (Object or Sculpt Mode). Keys: `R` voxel size edit, `Ctrl+R` remesh. Voxel Size edit works in relative mode by default (4.3). Remesh propagates all attributes (4.1) and interpolates vertex/corner attributes such as colors instead of nearest sampling (5.2). [verified op and keys in 5.2.1] ([4.3/sculpt](https://developer.blender.org/docs/release_notes/4.3/sculpt/), [4.1/sculpt](https://developer.blender.org/docs/release_notes/4.1/sculpt/), [5.2/sculpt](https://developer.blender.org/docs/release_notes/5.2/sculpt/))
      - **Quad remesh.** QuadriFlow is still built in: `bpy.ops.object.quadriflow_remesh(target_faces=500)` ran headless (523 faces). Its `Ctrl+Alt+R` shortcut was removed in 4.0. "Quad Remesher" in tutorials is the paid third-party add-on, not bundled. [verified in 5.2.1] ([4.0/keymap](https://developer.blender.org/docs/release_notes/4.0/keymap/))
      - **Multires.** `object.multires_subdivide`, `multires_unsubdivide`, `multires_rebuild_subdiv` (2.90), `multires_base_apply` exist. In Sculpt Mode `Ctrl+0..5` runs `object.subdivision_set(level=N, ensure_modifier=True)` which adds a Multiresolution modifier; `Alt+1`/`Alt+2` step levels down/up (`Page Up/Down` now grow/shrink visibility). 5.0 added "Conform Base" beside "Apply Base": there is no separate operator in 5.2.1; `multires_base_apply` has a bool `apply_heuristic` (default True, "slightly alter base positions for a new subdivision modifier"), so Conform Base is presumably `apply_heuristic=False` [inferred]. [verified ops/keys in 5.2.1] ([2.90/sculpt](https://developer.blender.org/docs/release_notes/2.90/sculpt/), [4.0/keymap](https://developer.blender.org/docs/release_notes/4.0/keymap/), [5.0/sculpt](https://developer.blender.org/docs/release_notes/5.0/sculpt/))
      - Bake from Multires supports n-gons, matches Subdivision Surface interpolation, and vector displacement (5.0). [release notes] ([5.0/rendering](https://developer.blender.org/docs/release_notes/5.0/rendering/))
      
      ### 3.5 Face sets, masks, hiding, trimming
      
      - Face sets are opt-in since 3.4 (primitives start without them). Ops: `sculpt.face_sets_create`, `sculpt.face_set_edit`, `sculpt.face_set_change_visibility` (5.1 adds `active_face_set` param for scripts). Keys: `H` hide active face set, `Shift+H` toggle, `Alt+H` show all, `Ctrl+W`/`Ctrl+Alt+W` grow/shrink, `Shift+W` expand face set, `Alt+W` face set pie, `Page Up/Down` grow/shrink visibility. [verified keys/ops in 5.2.1] ([3.4/sculpt](https://developer.blender.org/docs/release_notes/3.4/sculpt/), [5.1/sculpt](https://developer.blender.org/docs/release_notes/5.1/sculpt/))
      - Mask is a generic float attribute `.sculpt_mask` (4.1); `mesh.vertex_paint_mask` returns the attribute, `vertex_paint_mask_ensure()` creates it. Keys: `M` Mask brush (toggle), `A` mask pie, `Alt+M` clear, `Ctrl+I` invert, `B` box mask, `Shift+A` expand mask, `Shift+Alt+A` expand by normals, `Ctrl+Shift+LMB` lasso mask. 5.1: `Alt+LMB` temporarily switches to Mask, `Ctrl+Alt+LMB` erases mask. Mask from Boundary (4.3), Mask by Color in the Mask menu (4.5). [verified keys in 5.2.1] ([4.1/python_api](https://developer.blender.org/docs/release_notes/4.1/python_api/), [5.1/sculpt](https://developer.blender.org/docs/release_notes/5.1/sculpt/), [4.3/sculpt](https://developer.blender.org/docs/release_notes/4.3/sculpt/))
      - Hide/Trim/Mask/Face Set tools gained Line and Polyline variants (4.2); trim solvers are now **Float** (old "Fast"), Exact, **Manifold** (4.5, needs a manifold mesh). Op example: `sculpt.trim_polyline_gesture`. [verified ops in 5.2.1] ([4.2/sculpt](https://developer.blender.org/docs/release_notes/4.2/sculpt/), [4.5/sculpt](https://developer.blender.org/docs/release_notes/4.5/sculpt/))
      - 5.2 adds Add Primitive tools inside Sculpt Mode. [release notes] ([5.2/sculpt](https://developer.blender.org/docs/release_notes/5.2/sculpt/))
      
      ### 3.6 Sculpt-mode painting and color attributes
      
      - Painting in Sculpt Mode writes **Color Attributes** (3.2), not images. Color sampling: `sculpt.sample_color` removed in 5.1 -> `paint.sample_color` (`Shift+X`, `Ctrl+Shift+X` merged). `X` flips colors; `Ctrl+X` fills the current color with any tool (5.2, `sculpt.color_filter(type='FILL')`). [verified in 5.2.1] ([3.2/sculpt](https://developer.blender.org/docs/release_notes/3.2/sculpt/), [5.1/python_api](https://developer.blender.org/docs/release_notes/5.1/python_api/), [5.2/sculpt](https://developer.blender.org/docs/release_notes/5.2/sculpt/))
      - HSV color jitter per stroke (4.5). [release notes] ([4.5/sculpt](https://developer.blender.org/docs/release_notes/4.5/sculpt/))
      
      ### 3.7 Current Sculpt Mode shortcuts (5.2.1 default keymap)
      
      `V` Draw, `S` Smooth, `P` Pinch/Magnify, `I` Inflate/Deflate, `G` Grab, `Shift+T` Scrape/Fill, `C` Clay Strips, `Shift+C` Crease Polish, `K` Snake Hook, `M` Mask (toggle); `F`/`Shift+F` size/strength, `[`/`]` scale size, `Shift+RMB` set pivot, `Alt+E` stroke method menu, `Shift+S` toggle smooth stroke, `Alt+A` automasking pie. Removed by 4.0: `X` Draw (now `V`), `Shift+S` Smooth (now `S`), `L` Layer, `Ctrl+M` mask overlay, `Ctrl+D` dyntopo, `Ctrl+Alt+R` QuadriFlow. [verified in 5.2.1] ([4.0/keymap](https://developer.blender.org/docs/release_notes/4.0/keymap/))
      
      ---
      
      ## 4. Texture painting, vertex and weight paint
      
      - **Brushes** are assets here too (4.3). Texture set (`essentials_brushes-mesh_texture.blend`): Paint Hard/Soft (+Pressure), Paint Pixel Art, Erase Hard/Soft (+Pressure), Erase Pixel Art, Airbrush, Blur (`SOFTEN`), Smear, Clone (`CLONE`), Fill (`FILL`), Mask (`MASK`). Activate with `relative_asset_identifier="brushes/essentials_brushes-mesh_texture.blend/Brush/Paint Hard"`. Type prop is `brush.image_brush_type` (was `image_tool`). Old "TexDraw"/"Draw" brush names are gone; "Soften" is "Blur". [verified in 5.2.1] ([4.3/sculpt](https://developer.blender.org/docs/release_notes/4.3/sculpt/), [5.0/sculpt](https://developer.blender.org/docs/release_notes/5.0/sculpt/))
      - **Image vs color attributes.** Texture Paint mode paints **images** through `tool_settings.image_paint.mode` (`'MATERIAL'` slots or `'IMAGE'` with `image_paint.canvas`); create slots with `bpy.ops.paint.add_texture_paint_slot`. Sculpt and Vertex Paint paint **color attributes**. [verified props/ops in 5.2.1] ([3.2/sculpt](https://developer.blender.org/docs/release_notes/3.2/sculpt/))
      - **Vertex colors -> Color Attributes** (3.2). Old: Object Data > Vertex Colors, `mesh.vertex_colors.new()`, `bpy.ops.mesh.vertex_color_add()` -> 5.2: `mesh.color_attributes.new(name, type='FLOAT_COLOR'|'BYTE_COLOR', domain='POINT'|'CORNER')` or `bpy.ops.geometry.color_attribute_add()`. `mesh.vertex_colors` still exists as a legacy accessor; `mesh.vertex_color_add` is gone; `sculpt_vertex_colors` removed (4.0). [verified in 5.2.1] ([3.2/sculpt](https://developer.blender.org/docs/release_notes/3.2/sculpt/), [4.0/python_api](https://developer.blender.org/docs/release_notes/4.0/python_api/))
      - Clone settings left the brush: `brush.clone_image/clone_alpha/clone_offset` removed (4.4). Painting operator strokes no longer take per-element `pen_flip` (4.4). [release notes] ([4.4/python_api](https://developer.blender.org/docs/release_notes/4.4/python_api/))
      - 5.1: `Esc` cancels a texture paint stroke, property changes create undo steps; stroke-type toggles SMOOTH/ERASE/MASK moved from the operator `mode` to `brush_toggle`. 5.2: autosave covers texture paint, saving a blend now asks to save modified images. [release notes] ([5.1/sculpt](https://developer.blender.org/docs/release_notes/5.1/sculpt/), [5.1/python_api](https://developer.blender.org/docs/release_notes/5.1/python_api/), [5.2/sculpt](https://developer.blender.org/docs/release_notes/5.2/sculpt/), [5.2/user_interface](https://developer.blender.org/docs/release_notes/5.2/user_interface/))
      - Keys (texture/vertex/weight): `X` flip colors, `Shift+X` sample, `Ctrl+Shift+X` sample merged (4.4), `Ctrl+X` set vertex color / set weight, `F`/`Shift+F` size/strength, `Ctrl+F` weight radial control, `Shift+A`/`Shift+Alt+A` weight gradients, `K` vertex-group lock pie, `1`/`2`/`3` face/vertex/bone selection mask (bone selection made explicit in 4.1). `Alt+LMB` selects faces/verts while a brush is active with left-click select (4.0). [verified in 5.2.1] ([4.0/keymap](https://developer.blender.org/docs/release_notes/4.0/keymap/), [4.1/animation_rigging](https://developer.blender.org/docs/release_notes/4.1/animation_rigging/), [4.4/sculpt](https://developer.blender.org/docs/release_notes/4.4/sculpt/))
      - Weight paint: weights below 0.0001 round to 0; Zero Weights overlay defaults to Active (5.0). [release notes] ([5.0/sculpt](https://developer.blender.org/docs/release_notes/5.0/sculpt/))
      
      ---
      
      ## 5. UV editing
      
      - Unwrap methods: `'ANGLE_BASED'`, `'CONFORMAL'`, `'MINIMUM_STRETCH'` (SLIM, 4.3, also in Live Unwrap). The RNA default of `bpy.ops.uv.unwrap(method=...)` is `'CONFORMAL'` in 5.2.1 although the 4.3 notes called Angle Based the default, so **always pass `method=` explicitly**. Other params: `margin_method`, `no_flip`, `iterations`, `use_original_bounds` (5.2 "Original bounding box"). [verified in 5.2.1] ([4.3/modeling](https://developer.blender.org/docs/release_notes/4.3/modeling/), [5.2/modeling](https://developer.blender.org/docs/release_notes/5.2/modeling/))
      - **UV Sync Selection is on by default** (5.0) and can now select individual UVs; selection is shared across all UV maps; undo stores UV select settings. Old tutorials that start with "enable sync select" or assume per-UV-map selection behave differently. [verified `use_uv_select_sync=True` in 5.2.1] ([5.0/modeling](https://developer.blender.org/docs/release_notes/5.0/modeling/))
      - UV selection API (5.0): `MeshUVLoopLayer.vertex_selection/edge_selection` and `BMLoopUV.select/select_edge` removed -> `BMLoop.uv_select_vert`, `BMLoop.uv_select_edge`, `BMFace.uv_select`, `BMesh.uv_select_sync_*`; attributes `.uv_select_vert/.uv_select_edge/.uv_select_face`; pin attribute needs its `_ensure()` function. [release notes] ([5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/))
      - Packing: `uv.pack_islands(shape_method=..., merge_overlap=..., margin_method=..., pin=..., rotate_method=..., udim_source=...)` (options from 3.4/3.6; pack to custom region 5.0). New ops: `uv.arrange_islands` (5.0), `uv.copy`/`uv.paste` (`Ctrl+C/V`, 4.4), Select Tile (5.1), select linked delimiters, Select by Winding, snap points (5.2). Edge/Vert Slide and snap base `B` in the UV editor (4.2). [verified ops/props in 5.2.1] ([3.6/modeling](https://developer.blender.org/docs/release_notes/3.6/modeling/), [5.0/modeling](https://developer.blender.org/docs/release_notes/5.0/modeling/), [4.4/user_interface](https://developer.blender.org/docs/release_notes/4.4/user_interface/), [4.2/modeling](https://developer.blender.org/docs/release_notes/4.2/modeling/))
      - "UV Snap To Pixels" was renamed "UV Round To Pixels" (3.4); UV Sculpt `strength_curve` -> `curve_distance_falloff` (5.0). [release notes] ([3.4/modeling](https://developer.blender.org/docs/release_notes/3.4/modeling/), [5.0/modeling](https://developer.blender.org/docs/release_notes/5.0/modeling/))
      - Seams unchanged: `MeshEdge.use_seam` still exists. Export UV Layout add-on still ships enabled. [verified in 5.2.1]
      
      ---
      
      ## 6. Modeling
      
      - **Auto Smooth (removed 4.1).** Old: Object Data > Normals > Auto Smooth 30 deg, `mesh.use_auto_smooth = True; mesh.auto_smooth_angle = ...` -> 5.2: _Object > Shade Auto Smooth_ adds a **"Smooth by Angle"** geometry-nodes modifier pinned last, or _Shade Smooth by Angle_ writes the `sharp_edge` attribute once.
        ```python
        bpy.ops.object.shade_auto_smooth(angle=math.radians(30))   # modifier 'Smooth by Angle', type NODES, use_pin_to_last=True
        bpy.ops.object.shade_smooth_by_angle(angle=math.radians(30), keep_sharp_edges=True)  # writes 'sharp_edge'
        bpy.ops.object.shade_smooth()          # keep_sharp_edges defaults to True
        bpy.ops.mesh.set_sharpness_by_angle()  # edit mode
        ```
        `use_auto_smooth`, `auto_smooth_angle`, `calc_normals_split`, `create/free_normals_split` are gone; read normals from `mesh.corner_normals`; `mesh.normals_domain` tells which domain Blender uses; custom normals are always used when present (so Weighted Normal/Normal Edit no longer need Auto Smooth). [verified in 5.2.1] ([4.1/modeling](https://developer.blender.org/docs/release_notes/4.1/modeling/), [4.1/python_api](https://developer.blender.org/docs/release_notes/4.1/python_api/), [4.2/modeling](https://developer.blender.org/docs/release_notes/4.2/modeling/))
      - **Bevel weights and creases are attributes** (4.0): `bevel_weight_edge`, `bevel_weight_vert`, `crease_edge`, `crease_vert`; `MeshEdge.bevel_weight`/`crease` removed; `mesh.edge_creases_ensure()`. Face Maps removed (converted to an int attribute). Freestyle marks became attributes `freestyle_edge`/`freestyle_face` (5.0). [verified absences in 5.2.1] ([4.0/python_api](https://developer.blender.org/docs/release_notes/4.0/python_api/), [5.0/modeling](https://developer.blender.org/docs/release_notes/5.0/modeling/))
      - **Boolean solver** enum is `'FLOAT'` (old `'FAST'`, renamed 5.0), `'EXACT'`, `'MANIFOLD'` (4.5, fast and robust but needs manifold input). [verified in 5.2.1] ([5.0/modeling](https://developer.blender.org/docs/release_notes/5.0/modeling/), [4.5/modeling](https://developer.blender.org/docs/release_notes/4.5/modeling/))
      - **Add Modifier menu** is a categorized standard menu that also lists node-group assets; `Shift+A` over the modifier stack opens it (4.0); modifiers can be pinned to the bottom (4.2); Normal Edit/Weighted Normal sit in a Normals submenu (4.2). 5.0 adds GN-based **Array** (the legacy `ARRAY` modifier still exists), **Scatter on Surface**, **Instance on Elements**, **Randomize Instances**, **Curve to Tube**, **Geometry Input** from `geometry_nodes_essentials.blend`. [verified keymap/assets in 5.2.1] ([4.0/modeling](https://developer.blender.org/docs/release_notes/4.0/modeling/), [4.2/modeling](https://developer.blender.org/docs/release_notes/4.2/modeling/), [5.0/modeling](https://developer.blender.org/docs/release_notes/5.0/modeling/))
      - **Snapping**: press `B` during a transform to set the snap base, hold `Alt` to navigate mid-transform (4.0); "Absolute Grid Snap" -> snap element `'GRID'` (4.2; `use_snap_grid_absolute` still in RNA); "Project Individual Elements" -> `'FACE_PROJECT'` (4.0); face-center snapping `'FACE_MIDPOINT'` (5.1). Full `snap_elements` enum: `INCREMENT, GRID, VERTEX, EDGE, FACE, VOLUME, EDGE_MIDPOINT, EDGE_PERPENDICULAR, FACE_MIDPOINT, FACE_PROJECT, FACE_NEAREST`. [verified in 5.2.1] ([4.0/modeling](https://developer.blender.org/docs/release_notes/4.0/modeling/), [4.2/modeling](https://developer.blender.org/docs/release_notes/4.2/modeling/), [5.1/modeling](https://developer.blender.org/docs/release_notes/5.1/modeling/))
      - **LoopTools** is no longer bundled (4.2 Extensions move) but 5.2 builds in three of its tools: Circle -> `bpy.ops.mesh.circularize` ("To Circle"), Space -> `bpy.ops.mesh.space_edge_loops_evenly`, Flatten -> `bpy.ops.mesh.flatten`. [verified op ids in 5.2.1] ([5.2/modeling](https://developer.blender.org/docs/release_notes/5.2/modeling/))
      - Other tool changes: Select Poles via Select by Trait, Join Triangles topology influence, safer dissolve (4.4); Grid Fill can re-grid existing faces, Dissolve Edges angle threshold (4.5); Merge by Distance deterministic + Centroid option on by default, Auto Merge in Symmetrize/Bevel, Lattice Deform Selected (`object.lattice_add_to_selected`) (5.0); loop/ring select delimiters, bevel snapping, Apply Scale flips normals for negative scale (5.1); selection through back-face-culled faces (5.2). [release notes; lattice op verified] ([4.4/modeling](https://developer.blender.org/docs/release_notes/4.4/modeling/), [4.5/modeling](https://developer.blender.org/docs/release_notes/4.5/modeling/), [5.0/modeling](https://developer.blender.org/docs/release_notes/5.0/modeling/), [5.1/modeling](https://developer.blender.org/docs/release_notes/5.1/modeling/), [5.2/modeling](https://developer.blender.org/docs/release_notes/5.2/modeling/))
      - Import/export: `import_scene.obj`/`export_scene.obj` and Python PLY removed (4.0) -> `wm.obj_import`, `wm.ply_import`; C++ FBX importer is default (5.0) -> `wm.fbx_import` (legacy `import_scene.fbx` still present); **Collada removed** (5.0); "Import Images as Planes" is built in as `image.import_as_mesh_planes` (old `import_image.to_plane` gone). [verified in 5.2.1] ([4.0/python_api](https://developer.blender.org/docs/release_notes/4.0/python_api/), [5.0/pipeline_io](https://developer.blender.org/docs/release_notes/5.0/pipeline_io/))
      - **Add-ons**: most former bundled add-ons (LoopTools, Bool Tool, F2, Extra Objects, etc.) moved to extensions.blender.org in 4.2. Bundled in 5.2.1: `cycles`, `io_scene_fbx`, `io_scene_gltf2`, `io_anim_bvh`, `io_curve_svg`, `io_mesh_uv_layout`, `pose_library`, `node_wrangler`, `rigify`, `hydra_storm`, `viewport_vr_preview`, `ui_translate`, `bl_pkg`. Enabled on factory settings: bl_pkg, cycles, io_anim_bvh, io_curve_svg, io_mesh_uv_layout, io_scene_fbx, io_scene_gltf2, pose_library. **Node Wrangler and Rigify must be enabled** (`bpy.ops.preferences.addon_enable(module="node_wrangler")`). [verified in 5.2.1] ([4.2 index](https://developer.blender.org/docs/release_notes/4.2/))
      
      ---
      
      ## 7. Retopology helpers
      
      - **Old:** "enable Hidden Wire / X-ray and In Front" -> **5.2:** the **Retopology** overlay (3.6, replaced Hidden Wire): `overlay.show_retopology = True`, `overlay.retopology_offset` (default 0.01 m since 4.5, was 0.2 m). [verified in 5.2.1] ([3.6/eevee](https://developer.blender.org/docs/release_notes/3.6/eevee/), [4.5/modeling](https://developer.blender.org/docs/release_notes/4.5/modeling/))
      - **Old:** Snap to Face + "Project Individual Elements" -> **5.2:** `tool_settings.snap_elements = {'FACE_PROJECT'}` (or `'FACE_NEAREST'`), with `use_snap_self`, `use_snap_backface_culling`, `snap_target`, `snap_elements_individual`, `use_snap_align_rotation`. [verified props in 5.2.1] ([4.0/modeling](https://developer.blender.org/docs/release_notes/4.0/modeling/))
      - Poly Build tool (2.81) is unchanged; Shrinkwrap modifier unchanged. QuadriFlow remesh is the only built-in quad remesher (see 3.4). Select Poles (4.4) and selection through culled faces (5.2) help topology checks. [release notes] ([2.81/sculpt](https://developer.blender.org/docs/release_notes/2.81/sculpt/), [4.4/modeling](https://developer.blender.org/docs/release_notes/4.4/modeling/))
      
      ---
      
      ## 8. Baking
      
      - Texture baking is **Cycles only**: under EEVEE `bpy.ops.object.bake()` fails with "Current render engine does not support baking". Headless bake works: set `scene.render.engine='CYCLES'`, make the target Image Texture node the active node, `bpy.ops.object.bake(type='DIFFUSE')`. [verified in 5.2.1]
      - Settings: `scene.render.bake` (BakeSettings: `use_pass_direct/indirect/color`, `use_selected_to_active`, `cage_extrusion`, `max_ray_distance`, `margin`, `margin_type`, `target` IMAGE_TEXTURES/VERTEX_COLORS, `use_multires`, `normal_space`); bake type also on `scene.cycles.bake_type`. Types: `COMBINED, AO, SHADOW, POSITION, NORMAL, UV, ROUGHNESS, EMIT, ENVIRONMENT, DIFFUSE, GLOSSY, TRANSMISSION`. [verified in 5.2.1]
      - 5.0: bakes go only to **selected (and active)** image nodes instead of every active image; multires bake supports n-gons and vector displacement. 4.4: faster Selected to Active. [release notes] ([5.0/rendering](https://developer.blender.org/docs/release_notes/5.0/rendering/), [4.4/cycles](https://developer.blender.org/docs/release_notes/4.4/cycles/))
      - EEVEE "Bake Indirect Lighting" (legacy irradiance cache) is gone: 4.2 bakes Volume light probes per object (Scene > Light Probes > Bake All Light Probe Volumes). [release notes] ([4.2/eevee_migration](https://developer.blender.org/docs/release_notes/4.2/eevee_migration/))
      - Geometry-node bakes: Bake node (4.1), packed into the .blend by default (4.3). [release notes] ([4.1/nodes_physics](https://developer.blender.org/docs/release_notes/4.1/nodes_physics/), [4.3/geometry_nodes](https://developer.blender.org/docs/release_notes/4.3/geometry_nodes/))
      
      ---
      
      ## 9. Materials and shading
      
      ### 9.1 Principled BSDF v2 (4.0)
      
      | 3.x tutorial socket                                                             | 5.2 socket                                                                                | Note                                                                                             |
      | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
      | Subsurface                                                                      | **Subsurface Weight**                                                                     |                                                                                                  |
      | Subsurface Color                                                                | removed                                                                                   | uses Base Color; new **Subsurface Scale** (5.2.1 default 0.005), Subsurface Radius (1, 0.2, 0.1) |
      | Specular                                                                        | **Specular IOR Level**                                                                    | default 0.5; reflection mainly driven by **IOR** (default 1.5)                                   |
      | Specular Tint (float)                                                           | **Specular Tint** (color)                                                                 | also tints metal edges (F82)                                                                     |
      | Transmission                                                                    | **Transmission Weight**                                                                   |                                                                                                  |
      | Clearcoat / Clearcoat Roughness / Clearcoat Normal                              | **Coat Weight / Coat Roughness / Coat Normal**                                            | plus Coat IOR, Coat Tint                                                                         |
      | Sheen                                                                           | **Sheen Weight**                                                                          | new microfiber model, plus Sheen Roughness                                                       |
      | Emission (color; 3.x default black with Strength 1 [inferred, not re-verified]) | **Emission Color** default **white**, **Emission Strength default 0** [verified in 5.2.1] | an old "set emission color" step does nothing until Strength > 0                                 |
      
      Current inputs in order: Base Color, Metallic, Roughness, IOR, Alpha, Thin Wall (5.2), Normal, Weight, Diffuse Roughness (4.3), Subsurface Weight/Radius/Scale/IOR/Anisotropy, Specular IOR Level, Specular Tint, Anisotropic, Anisotropic Rotation, Tangent, Transmission Weight, Coat Weight/Roughness/IOR/Tint/Normal, Sheen Weight/Roughness/Tint, Emission Color, Emission Strength, Thin Film Thickness, Thin Film IOR (4.2). `distribution` default `MULTI_GGX`; `subsurface_method` in `BURLEY, RANDOM_WALK, RANDOM_WALK_SKIN, RANDOM_WALK_LEGACY` (5.2 converts old files to Legacy). **Access sockets by name, never by index** (indices shifted). [verified in 5.2.1] ([4.0/python_api](https://developer.blender.org/docs/release_notes/4.0/python_api/), [4.0/shading](https://developer.blender.org/docs/release_notes/4.0/shading/), [5.2/rendering](https://developer.blender.org/docs/release_notes/5.2/rendering/))
      
      ### 9.2 Node and material API
      
      - **`material.use_nodes` is deprecated** (5.0): always True, setting False has no effect; `bpy.data.materials.new()` already contains Principled BSDF + Material Output. Same for `world.use_nodes` (new worlds come with Background + World Output) and lights (5.1). Old `mat.use_nodes = True` lines are harmless no-ops. [verified in 5.2.1] ([5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/), [5.1/user_interface](https://developer.blender.org/docs/release_notes/5.1/user_interface/))
      - Removed/merged nodes: Glossy + Anisotropic merged, `ShaderNodeBsdfGlossy` is an alias that creates `ShaderNodeBsdfAnisotropic` (4.0); Velvet -> **Sheen BSDF** (`ShaderNodeBsdfVelvet` fails) (4.0); **Musgrave Texture removed**, use Noise Texture's fractal types (`Roughness = Lacunarity^-Dimension`, Detail minus 1) (4.1); **Point Density removed** (5.0); **Separate/Combine RGB and HSV removed**, use Separate/Combine Color (5.0). Legacy `ShaderNodeMixRGB` still creatable but the Add menu uses `ShaderNodeMix` (3.4). [verified in 5.2.1] ([4.0/python_api](https://developer.blender.org/docs/release_notes/4.0/python_api/), [4.1/rendering](https://developer.blender.org/docs/release_notes/4.1/rendering/), [5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/))
      - New nodes: Metallic BSDF, Gabor Texture (4.3); Volume Coefficients (4.5); Radial Tiling, repeat zones, closures and bundles in shader trees (5.0); Raycast (5.1); Time (5.2). In shader trees these use shared ids: repeat zone `GeometryNodeRepeatInput/Output`, Time `GeometryNodeInputSceneTime`, Menu Switch `GeometryNodeMenuSwitch`, closures `NodeClosureInput/Output`, `NodeEvaluateClosure`, bundles `NodeCombineBundle` (there is no `ShaderNodeTime`). [verified in 5.2.1] ([4.3/rendering](https://developer.blender.org/docs/release_notes/4.3/rendering/), [5.0/rendering](https://developer.blender.org/docs/release_notes/5.0/rendering/), [5.1/rendering](https://developer.blender.org/docs/release_notes/5.1/rendering/), [5.2/rendering](https://developer.blender.org/docs/release_notes/5.2/rendering/))
      - Sky Texture: `sun_direction`, `turbidity`, `ground_albedo` inputs removed (5.0); `sky_type` is `SINGLE_SCATTERING, MULTIPLE_SCATTERING, PREETHAM, HOSEK_WILKIE` (old `'NISHITA'` is not in the enum). [verified enum in 5.2.1] ([5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/), [5.0/rendering](https://developer.blender.org/docs/release_notes/5.0/rendering/))
      - Bump node `Distance` default 0.001 (was 1.0) and new `Filter Width` 0.1 (4.5): importers/tutorial values may need `Distance=1.0`. Normal Map node gained `convention` (OpenGL/DirectX, 5.1) and `base`. Voronoi hashing changed (5.0) so patterns differ from old renders. [verified defaults/props in 5.2.1] ([4.5/rendering](https://developer.blender.org/docs/release_notes/4.5/rendering/), [5.1/rendering](https://developer.blender.org/docs/release_notes/5.1/rendering/), [5.0/rendering](https://developer.blender.org/docs/release_notes/5.0/rendering/))
      - Material output nodes inside a group no longer take priority (5.0). `displacement_method` moved from `material.cycles` to `material` (4.1). [release notes] ([5.0/rendering](https://developer.blender.org/docs/release_notes/5.0/rendering/), [4.1/python_api](https://developer.blender.org/docs/release_notes/4.1/python_api/))
      - Lights: point/spot became sphere lights (4.0), `use_soft_falloff` (4.1, on for new lights) restores the pre-4.0 look; `exposure`, `use_temperature`/`temperature`, `normalize` (4.5); W unit dropped from the UI (4.5); cast shadow unified as `light.use_shadow` (4.2, was `light.cycles.cast_shadow`). Legacy EEVEE light props (contact shadows, falloff, shadow buffer) removed. [verified props in 5.2.1] ([4.0/shading](https://developer.blender.org/docs/release_notes/4.0/shading/), [4.1/rendering](https://developer.blender.org/docs/release_notes/4.1/rendering/), [4.5/rendering](https://developer.blender.org/docs/release_notes/4.5/rendering/), [4.2/python_api](https://developer.blender.org/docs/release_notes/4.2/python_api/))
      
      ---
      
      ## 10. EEVEE (legacy 2.8 to 4.1) vs EEVEE Next (4.2+)
      
      - **Engine id history:** `BLENDER_EEVEE` (up to 4.1, legacy) -> `BLENDER_EEVEE_NEXT` (4.2 to 4.5) -> `BLENDER_EEVEE` (5.0+). Scripts written for 4.2 to 4.5 break on 5.2. [verified in 5.2.1] ([4.2/python_api](https://developer.blender.org/docs/release_notes/4.2/python_api/), [5.0/python_api](https://developer.blender.org/docs/release_notes/5.0/python_api/))
      - **Material settings.** Old: Blend Mode Opaque/Alpha Clip/Alpha Hashed/Alpha Blend, Shadow Mode, Screen Space Refraction -> 5.2: `material.surface_render_method` in `{'DITHERED','BLENDED'}` (Dithered = old Hashed; Alpha Clip = add Math _Greater Than_ on alpha); shadows via **object** ray visibility `obj.visible_shadow` and `material.use_transparent_shadow`; `use_raytrace_refraction` (old `use_screen_refraction`), `use_transparency_overlap` (old `show_transparent_back`), `thickness_mode`, `use_thickness_from_shadow`. `blend_method` still exists as a deprecated alias; `shadow_method` removed (4.3). [verified in 5.2.1] ([4.2/eevee](https://developer.blender.org/docs/release_notes/4.2/eevee/), [4.2/eevee_migration](https://developer.blender.org/docs/release_notes/4.2/eevee_migration/), [4.3/python_api](https://developer.blender.org/docs/release_notes/4.3/python_api/))
      - **Render settings.** Old toggles: Ambient Occlusion, Bloom, Screen Space Reflections, Soft Shadows, Shadow cube/cascade size, Motion Blur on `scene.eevee` -> 5.2: `scene.eevee.use_raytracing`, `ray_tracing_method`, `ray_tracing_options`, `use_fast_gi`/`fast_gi_method` (AO-like fallback), `use_shadows`, `shadow
    • bpy-reliability.md 24.1 KB
      # bpy reliability for agents (Blender 5.2)
      
      Distilled from Sybren Stuvel (Blender core developer: BCON25 "import bpy", Scripting for Artists 6 and 8), CG Python (Victor: geometry nodes, materials, BMesh, command line), Mike Shah (BCON23), Vincent Dorenkamp (BCON24) and the official API gotchas pages, with every item re-tested headless in Blender 5.2.1 (test scripts: `archive/tests/python_bpy/` in the build project). Load this before writing any non-trivial bpy script, and run the checklist before and after.
      
      ## bpy reliability checklist (compact)
      
      Run through this before and after every generated script. Items are verified in 5.2.1 unless marked.
      
      **Launch**
      
      - [ ] `blender -b [file.blend] --factory-startup --python-exit-code 1 -P script.py -- <args>`; parse `sys.argv` after `--`.
      - [ ] Render flags ordered: file, `-P`, `-E`, `-o`, `-F`, then `-f N` / `-a` last.
      - [ ] Wrap the body in a `main()`; let exceptions propagate (exit code 1), print a final machine-readable `RESULT ...` line.
      
      **Context and operators**
      
      - [ ] Prefer data API; before an unavoidable operator, `bpy.ops.cat.op.poll()`; if False, `with bpy.context.temp_override(object=ob, active_object=ob, selected_objects=[ob], selected_editable_objects=[ob]):`; debug with `.logging_set(True)`.
      - [ ] Never assign `bpy.context.*`; set `view_layer.objects.active = ob`, `ob.select_set(True)`.
      - [ ] No `view3d.*`, timers, modal operators or undo in `-b` runs.
      - [ ] Functions that receive `context` use it, not `bpy.context`.
      
      **References and names**
      
      - [ ] Keep objects returned by `new()`; never re-fetch by requested name; check `.name` if you need it.
      - [ ] Re-fetch references after `mode_set`, after bulk adds to a collection or mesh, after undo, after `remove()`.
      - [ ] Iterate over `[:]` copies whenever the loop renames, links, unlinks, removes or toggles `hide_*`.
      - [ ] Tag owned data (`ob["agent_task"] = 1` or one owned collection) so reruns find it.
      
      **Mesh and BMesh**
      
      - [ ] Build meshes with `from_pydata(verts, [], faces)` then `validate()`; or bmesh then `to_mesh()` + `free()`.
      - [ ] BMesh: `ensure_lookup_table()` before indexing, `index_update()` after adding, `normal_update()` after manual moves; Edit Mode uses `from_edit_mesh` / `update_edit_mesh`.
      - [ ] Reading mesh data while the object is in Edit Mode: `obj.update_from_editmode()` first.
      - [ ] Guard every topology walk with a step cap.
      
      **Evaluation and transforms**
      
      - [ ] After changing transforms, parents, constraints or drivers: `view_layer.update()` before reading `matrix_world`.
      - [ ] Measure final geometry via `evaluated_get(evaluated_depsgraph_get())`; always `to_mesh_clear()`.
      - [ ] Copy mathutils values you want to keep (`.copy()`).
      - [ ] World-space bounds: `[ob.matrix_world @ Vector(c) for c in ob_eval.bound_box]`.
      
      **Materials and nodes**
      
      - [ ] `materials.get(name) or materials.new(name)`; do not set `use_nodes`.
      - [ ] 4.0+ Principled names (`Specular IOR Level`, `Emission Color` + `Emission Strength` > 0, `Coat Weight`, `Transmission Weight`, `Subsurface Weight`).
      - [ ] Data images `Non-Color` (set before writing pixels); never `Linear` or `Raw`.
      - [ ] Node trees from `node_groups.new(...)` with `interface.new_socket`; check `all(l.is_valid for l in tree.links)`.
      - [ ] GN inputs: `mod.properties.inputs.<identifier>.value`, identifier found by name.
      
      **Collections and persistence**
      
      - [ ] New collection linked with `parent.children.link(c)` (guard against double link); objects linked into a collection that is in the view layer.
      - [ ] Every datablock that must persist has users or `use_fake_user = True`.
      - [ ] Save with an absolute path; reopen and assert counts when the output matters.
      - [ ] After a rerun: `bpy.data.orphans_purge(do_recursive=True)` removes nothing new, and no owned name has a `.001` twin.
      
      **Performance**
      
      - [ ] No `bpy.ops` inside loops over many items; `foreach_get/set` with float32 numpy on `mesh.attributes[...]`.
      - [ ] Share meshes (`objects.new(name, mesh)`) when instances are acceptable; `mesh.copy()` only when geometry differs.
      
      **5.x traps**
      
      - [ ] No context dicts to operators; no `action.fcurves` (channelbags via `bpy_extras.anim_utils`); engine id `BLENDER_EEVEE`; `scene.compositing_node_group`; `image_settings.media_type` before `file_format`; brush assets; `bpy.props` not readable via `ob[...]`.
      
      ## Why each rule exists (ranked, with sources)
      
      1. **Operators take no data arguments, return only a status and fail on poll; the data API does not.** Put work in functions, use operators only where no data API exists. Sybren `GP53gDHGiIQ [00:41:49]`; docs `info_gotchas_operators`. Measured: 600 `primitive_monkey_add` = 4.16 s vs 600 `objects.new` + link = 0.015 s; each add op costs about 7 ms in a 600-object scene [verified, t2/t3].
      2. **Python references to Blender data die when their container changes, on mode switches and on undo.** Re-fetch after `mode_set`, after adding many items to a collection property or mesh array, after any undo; store names or indices across steps, not objects. docs `info_gotchas_crashes` (Edit-Mode memory access, array re-allocation, undo invalidates all IDs); Sybren `GP53gDHGiIQ [00:26:34]`.
      3. **Renaming inside `for o in bpy.data.objects` re-sorts the collection:** some items visited twice, some skipped. In 5.2.1 a "z" prefix loop never finished on its own (capped at 31 visits for 5 objects); iterate `bpy.data.objects[:]`. Sybren `GP53gDHGiIQ [00:27:47]` [verified, t3].
      4. **The name you request is not the name you get** (`demo` exists: you get `demo.001`; names cap at 255 bytes; library data can duplicate names). Keep the returned object; use `bpy.data.objects[name, None]` to force local data. Sybren `opZy2OJp8co [00:05:51]`, `GP53gDHGiIQ [00:29:28]`; docs internal data [verified, t2].
      5. **Name lookups of auto-created data break on rerun:** `bpy.data.node_groups["Geometry Nodes"]` returns the first group while `new_geometry_nodes_modifier()` just made `Geometry Nodes.001`. Take `obj.modifiers[-1].node_group` or create trees with `bpy.data.node_groups.new`. Victor `Is8Qu7onvzM [00:02:50]` [verified, t3].
      6. **`matrix_world` and every dependent value are stale until `view_layer.update()`** (child of a moved parent read x = 0.0, then 5.0 after update; a new object reads identity). docs internal data "Stale Data" [verified, t1].
      7. **Edit Mode keeps its own copy of the mesh:** `obj.data` is out of sync until leaving Edit Mode or `obj.update_from_editmode()` (8 vs 26 vertices in the test). docs `info_gotchas_meshes`; Mike's brittle selection `wWTAQP7-ZUQ [00:47:30]` [verified, t1].
      8. **`obj.data` ignores modifiers;** measure what renders through `obj.evaluated_get(context.evaluated_depsgraph_get())` and `to_mesh()` / `to_mesh_clear()`, or `new_from_object` for a persistent copy (0 users, link or it is lost). `evaluated_depsgraph_get()` re-evaluated after a modifier change without extra calls. Mike's vertex-based bounding box `wWTAQP7-ZUQ [00:30:27]` [verified, t1].
      9. **Bulk data goes through `foreach_get/foreach_set` with float32 numpy arrays, preferably on the attribute API.** 491,401 vertices: Python loop 105 ms, `vertices.foreach_get("co")` 7.2 ms, `attributes["position"].data.foreach_get("vector")` 0.1 ms; float64 arrays cost about 20 ms (conversion); wrong array size raises `RuntimeError`. Sybren's lookup-cost remark `GP53gDHGiIQ [00:14:02]`; docs best practice [verified, t3].
      10. **BMesh loops are face corners:** per-corner data (UVs, corner colors) is read and written through `loop[layer]`; walk topology with `link_loop_next` (same face) and `link_loop_radial_next` (across the edge); per-vertex values on corner storage need a cache by `loop.vert.index`. Victor `aSkgwf0SX_k [00:00:32]`, `[00:08:12]`, `[00:25:51]` [verified loop facts, t2].
      11. **BMesh housekeeping:** `ensure_lookup_table()` before any `seq[i]` (even right after `bmesh.ops.create_cube`), `index_update()` after adding (new elements have index -1), `normal_update()` after moving vertices by hand, `update_edit_mesh` in Edit Mode vs `to_mesh` + `free` in Object Mode, guard topology walks with a step cap. Victor shows `ensure_lookup_table` and the unguarded walk (`aSkgwf0SX_k [00:01:37]`, `[00:09:21]`); `index_update`, `normal_update` and the step cap are additions found in testing [verified, t2/t5b].
      12. **Background mode has a window but no area and no event loop:** `view3d.*` operators fail poll, `bpy.app.timers` never fire, modal operators cannot run, `ed.undo` fails poll. Sybren's timer/modal advice (slides "Warning") applies to live sessions only [verified, t1].
      13. **Discover which context an operator needs with `temp_override(...).logging_set(True)`** (5.0): `object.modifier_apply` logged reads of `object`, `scene`, `window`, `screen`, `area`, `region`, `edit_object`, so overriding `object` was enough. Old positional context dicts raise `ValueError: 1-2 args execution context is supported`. deltas section 2 [verified, t1]. Check first with `bpy.ops.x.y.poll()` (docs quickstart).
      14. **Command-line arguments execute left to right:** `-o`/`-F`/`-E` must precede `-f`/`-a` (a later `-o` is ignored for that render); a `--python-expr` after `-f` runs after the render; script args go after `--`. Victor `JJ54OfiWlf8 [00:04:26]`, `[00:05:32]` [verified, cli].
      15. **A failing script still exits 0** unless `--python-exit-code N`; `argparse` errors exit Blender with code 2 (the docs warn `sys.exit` looks like a crash). Always pass `--factory-startup` (user add-ons loaded on this machine and made network calls). Victor `JJ54OfiWlf8 [00:23:24]` [verified, cli].
      16. **Datablocks with 0 users are not saved** (a new mesh or collection never linked vanished after save and reload; a fake-user material survived). New collections have 0 users until `children.link`. Victor's orphan discussion `TdBYf8orLA4 [00:07:13]`; Sybren `opZy2OJp8co [00:06:25]` [verified, t3/t4].
      17. **Objects must be in a collection of the active view layer** to be selected, made active or rendered: `select_set` on an unlinked object raises `RuntimeError ... not in View Layer`; `layer_collection.exclude = True` removes objects from `view_layer.objects`. Sybren `opZy2OJp8co [00:07:33]` [verified, t3].
      18. **Three visibility levels:** `collection.hide_viewport/hide_render/hide_select` (global), `view_layer.layer_collection...exclude` and `.hide_viewport` (per view layer), `obj.hide_set()` (per view layer, what `hide_get()` reads) vs `obj.hide_viewport` (global). `visible_get()` is the combined answer. Sybren `opZy2OJp8co [00:14:50]`, `GP53gDHGiIQ [00:17:30]` [verified, t2/t3].
      19. **The Info log records how the UI did it, not what you want:** deltas instead of values, every default argument, UI-context operators. Use it to find API names, then rewrite as assignments. Sybren `GP53gDHGiIQ [00:09:33]`. In a headless run `bpy.app.debug_wm = True` prints each operator call (docs tips) [verified].
      20. **Socket keys match the UI name or the identifier; on multi-type nodes use identifiers.** Noise "Factor" has identifier "Fac" (both work); `ShaderNodeMix` has four sockets named "A" (`A_Float`, `A_Vector`, `A_Color`, `A_Rotation`). GN modifier inputs in 5.2: `mod.properties.inputs.<identifier>.value`, identifiers looked up by name, never hard-coded; `mod["Socket_2"] = x` raises `TypeError`. Victor `Is8Qu7onvzM [00:19:35]` [verified, t3/t5].
      21. **Operator design for live tools:** function + thin operator, `bl_options = {'REGISTER', 'UNDO'}` (redo = undo + re-execute), hard `min` vs `soft_max`, distinct defaults per axis, `poll()` with `poll_message_set()`; the redo panel only exists in editors that have one (3D Viewport, not Properties). Sybren `xscQ9tcN4GI [00:08:18]`, `[00:12:16]`, `[00:15:08]`; `GP53gDHGiIQ [00:43:32]`, `[01:00:41]`. From Python, `soft_max` is not enforced and `min` clamps silently [verified, t5].
      22. **Custom vs RNA properties, 5.0 split:** `bpy.props` properties are attribute-only; `ob.get("my_weight")` returns `None` in 5.2.1. Custom props accept only int, float, string, arrays and string-keyed dicts (docs quickstart, 1024 nesting limit). Sybren `GP53gDHGiIQ [00:56:48]` [verified, t2].
      23. **mathutils values are live references:** `start = obj.location` changes when the object moves; use `.copy()`. Assigning `matrix_world` updates `location` immediately. docs quickstart "Mathutils Types" [verified, t5].
      24. **Bake Python results into vanilla data** (attributes, float EXR images) that nodes read natively, so the file works without the script. When saving a float data image, set `colorspace_settings.name = "Non-Color"` before writing pixels: otherwise `img.save()` wrote sRGB-encoded values (a stored -1.0 read back as -12.92 under Non-Color). Vincent `BOwnejd1LSY [00:16:34]`, `[00:19:23]` [verified, t6/t6c].
      25. **No `bpy` from Python threads;** threads must finish before the script returns. For parallel or heavy work run separate processes (Blender's bundled Python or more `blender -b`). Sybren `GP53gDHGiIQ [01:20:59]`; docs `info_gotchas_threading`.
      26. **Relative `//` paths need a saved blend and a library argument:** in an unsaved file `bpy.path.abspath("//tex.png")` returned `tex.png`; for linked data use `bpy.path.abspath(path, library=id.library)`; blend strings must be UTF-8. docs `info_gotchas_file_paths_and_encoding` [verified abspath, unsaved case].
      27. **Module reload in a live session:** `import` does not re-read a changed file; use `importlib.reload(mod)` (and reload submodules explicitly, reassigning the name). Sybren `GP53gDHGiIQ [01:08:40]`, `[01:12:02]`; docs tips "Executing Modules".
      28. **Check your algorithm against Blender's own answer** (bounds, normals, convex hull) before trusting it. Mike `wWTAQP7-ZUQ [00:04:48]`, `[00:25:34]`.
      
      ## Procedures (all ran in 5.2.1 unless marked)
      
      All code below ran in 5.2.1 unless marked [verify].
      
      **P1. Headless job skeleton (idempotent)** [verified, `t7_idempotent_skeleton.py`, three runs on the same file]
      
      1. Parse args after `--`; `scene = bpy.context.scene`.
      2. `coll = bpy.data.collections.get(NAME) or bpy.data.collections.new(NAME)`; `if coll.name not in scene.collection.children: scene.collection.children.link(coll)`.
      3. Remove previously owned objects: `objs = [o for o in coll.all_objects if o.get(TAG)]`; `bpy.data.batch_remove(objs)`; then remove their now 0-user meshes with `batch_remove`.
      4. Get-or-create shared resources (`materials.get(name) or materials.new(name)`).
      5. Create geometry with bmesh or `from_pydata`; create objects with `bpy.data.objects.new(name, mesh)`, tag `o[TAG] = 1`, link to `coll`.
      6. `bpy.context.view_layer.update()`; run code checks (asserts); `bpy.ops.wm.save_as_mainfile(filepath=abs_path)`.
      
      **P2. Call an operator that has no data equivalent**
      
      1. `op = bpy.ops.object.modifier_apply`; if `op.poll()` is False, open `with bpy.context.temp_override(object=ob, active_object=ob, selected_objects=[ob], selected_editable_objects=[ob]) as o:`; optionally `o.logging_set(True)` to see members read; call `op(modifier=name)` [verified].
      2. Mode-dependent operators: `view_layer.objects.active = ob`, `bpy.ops.object.mode_set(mode='EDIT')`, run, `mode_set(mode='OBJECT')`, then re-fetch `ob.data` sub-references [verified].
      3. Anything needing `area.type == 'VIEW_3D'` cannot run headless: find the data API or bmesh equivalent, or run in a GUI session [verified poll failure].
      
      **P3. Mesh from numpy, fast edit**
      
      1. `me = bpy.data.meshes.new(name); me.from_pydata(verts, [], faces); me.validate()` [verified].
      2. Edit positions: `co = np.empty(n*3, np.float32); me.attributes["position"].data.foreach_get("vector", co)`; modify; `foreach_set("vector", co)`; `me.update()` (the depsgraph picked up the change even without `update()` in 5.2.1, but keep it after structural edits) [verified].
      3. Custom per-point data: `me.attributes.new("name", 'FLOAT_VECTOR', 'POINT').data.foreach_set("vector", arr)` [verified].
      
      **P4. BMesh edit**
      
      1. Object Mode: `bm = bmesh.new(); bm.from_mesh(me)`; edit; `bm.normal_update()`; `bm.to_mesh(me); bm.free()`.
      2. Edit Mode: `bm = bmesh.from_edit_mesh(me)`; edit; `bmesh.update_edit_mesh(me)`; do not free.
      3. Before `bm.verts[i]`: `bm.verts.ensure_lookup_table()`; after adding: `index_update()` [verified].
      4. Corner data: `uv = bm.loops.layers.uv.active` (or `.verify()`), `col = bm.loops.layers.color.new(name)` (BYTE_COLOR, CORNER) [verified]; per-vertex color: `bm.verts.layers.float_color.new(name)` (FLOAT_COLOR, POINT) [verified].
      
      **P5. Material builder**
      
      1. `mat = bpy.data.materials.get(n) or bpy.data.materials.new(n)`; `nt = mat.node_tree`; `bsdf = next(x for x in nt.nodes if x.bl_idname == "ShaderNodeBsdfPrincipled")` [verified].
      2. Set inputs by name; add nodes with `nt.nodes.new("ShaderNodeTexNoise")` etc.; `nt.links.new(a.outputs["Fac"], b.inputs["Fac"])` [verified].
      3. Image textures: `img.colorspace_settings.name = "Non-Color"` for data [verified].
      4. Assign: `ob.data.materials.append(mat)` (shared by linked duplicates) or per-object slot link [verified append].
      
      **P6. Geometry-nodes builder** [verified, t3]
      
      1. `ng = bpy.data.node_groups.new(name, "GeometryNodeTree")`; `ng.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")` and the output; extra inputs with `default_value`.
      2. `NodeGroupInput`, `NodeGroupOutput`, then `GeometryNode*` nodes; link by socket name; set `location` 250 to 300 units apart.
      3. `mod = ob.modifiers.new("GN", "NODES"); mod.node_group = ng`; set inputs through `mod.properties.inputs.<identifier>.value`.
      4. Verify: links valid, evaluated face count as expected.
      
      **P7. Evaluate and measure**
      
      1. `dg = bpy.context.evaluated_depsgraph_get(); ev = ob.evaluated_get(dg); m = ev.to_mesh()`; read with `foreach_get`; `ev.to_mesh_clear()` [verified].
      2. Animated: loop `scene.frame_set(f)` then step 1 [verified in t6].
      3. Persistent copy: `bpy.data.meshes.new_from_object(ev)` then link it to an object [verified 0 users until used].
      
      **P8. Batch CLI render**
      `blender -b scene.blend --factory-startup --python-exit-code 1 -P setup.py -E CYCLES -o /abs/out/frame_#### -F PNG -f 10` (or `-s 1 -e 100 -a`) [ordering verified with Workbench].
      
      **P9. Bake to vanilla data (EXR)** [verified round trip, t6/t6c]
      
      1. `img = bpy.data.images.new(name, width=w, height=h, alpha=True, float_buffer=True)`; `img.colorspace_settings.name = "Non-Color"` first.
      2. `img.pixels.foreach_set(rgba_float32.ravel())`; `img.filepath_raw = abs_path; img.file_format = 'OPEN_EXR'; img.save()`.
      3. Reload with `bpy.data.images.load(abs_path, check_existing=False)`, set `Non-Color`, compare with numpy.
      
      ## GUI habits and their agent substitutes
      
      | GUI technique (video)                                     | Substitute for an agent                                                                                                                                                                                                         |
      | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | Hover tooltips, Copy Data Path (`GP53gDHGiIQ [00:03:25]`) | `bpy.types.X.bl_rna.properties[...]` (name, description, enum items); API reference pages [verified]                                                                                                                            |
      | Ctrl+C on a menu item, Tab for parameters (`[00:07:51]`)  | `repr(bpy.ops.mesh.primitive_ico_sphere_add)` prints the call with defaults; `op.get_rna_type().properties` lists parameters [verified]                                                                                         |
      | Info editor log (`[00:09:33]`, `Is8Qu7onvzM [00:01:57]`)  | `bpy.app.debug_wm = True` prints each operator call to stdout, also in background [verified]                                                                                                                                    |
      | Edit Source on a UI element (`[00:57:57]`)                | Read `/Applications/Blender.app/Contents/Resources/5.2/scripts/startup/bl_ui/*.py` and `bl_operators/*.py` directly                                                                                                             |
      | Redo panel tweaks (`xscQ9tcN4GI [00:08:18]`)              | Rerun the idempotent script with new arguments (no undo in background)                                                                                                                                                          |
      | F3 search for operators                                   | `hasattr(bpy.ops.object, "monkey_grid")`, `dir(bpy.ops.mesh)`                                                                                                                                                                   |
      | VS Code breakpoints (`GP53gDHGiIQ [00:50:10]`)            | Prints, asserts, `faulthandler.enable()` for crash lines (docs crashes); `code.interact` only in an interactive terminal                                                                                                        |
      | Alt+click edge loop (`aSkgwf0SX_k [00:03:17]`)            | `bpy.ops.mesh.select_edge_loop_multi()` (ring: `select_edge_ring_multi`) in Edit Mode on selected edges, works headless; the old name `loop_multi_select` does not exist in 5.2.1 [verified]; or the bmesh walk with a step cap |
      | Visual check in Outliner / viewport                       | Assert on `coll.objects`, `users_collection`, `visible_get()`, evaluated counts; render a still for appearance                                                                                                                  |
      | Vertex Paint view of colors (`aSkgwf0SX_k [00:24:46]`)    | Read `mesh.color_attributes[name].data.foreach_get("color", arr)`; render with the attribute as emission                                                                                                                        |
      | Window > Toggle System Console                            | Run from a terminal; capture stdout/stderr of the `blender -b` process                                                                                                                                                          |
      
      ## Quality gates for generated scripts
      
      ### Measurable in code
      
      - [ ] Script exit code 0 and a final result line; no `Traceback` in the log.
      - [ ] Owned objects: expected count, names without unexpected `.00N` suffixes, all in `view_layer.objects`, all with the ownership tag.
      - [ ] Owned datablocks: `users > 0` or fake user; `bpy.data.orphans_purge()` after a rerun returns 0 (or the same number as a clean run).
      - [ ] Generated meshes: `mesh.validate(verbose=False)` returns False (nothing to fix); no zero-area faces if relevant; expected vertex/face counts.
      - [ ] Evaluated geometry: counts and bounds (from `evaluated_get`) within expected ranges; modifiers present in the intended order.
      - [ ] Transforms: `view_layer.update()` then `matrix_world` positions match intent within 1e-5.
      - [ ] Node trees: all links valid, required nodes present by `bl_idname`, output connected, GN modifier inputs hold the intended values.
      - [ ] Materials: required sockets exist by name, values as intended (e.g., `Emission Strength > 0` when emission is wanted), slot link (`DATA`/`OBJECT`) as intended.
      - [ ] Images: data maps `colorspace_settings.is_data == True`; baked textures reload with max error below tolerance.
      - [ ] Idempotence: run the script twice on the same file; object, mesh, material and node-group counts identical after both runs (the skeleton `t7_idempotent_skeleton.py` passes this).
      - [ ] Performance: wall time per stage printed; flag any loop calling `bpy.ops` more than ~50 times.
      - [ ] Saved file reopens (`wm.open_mainfile`) and the checks above still pass.
      
      ### Needs a render and visual judgment
      
      - [ ] Materials read as intended under known light (metals need an environment; a black metal render is usually missing lighting, not a bad material).
      - [ ] Geometry-node results look plausible (shape, density, no z-fighting between split faces); compare with a reference image.
      - [ ] Baked vertex-animation or attribute-driven motion plays correctly (render 3 frames at start, middle, end).
      - [ ] UV and corner-color writes: render with a checker or the color attribute as emission to see seams or faceting.
      - [ ] Generated node trees intended for humans are legible (needs a GUI session; background mode cannot screenshot editors).
      
  • scripts
    • bx_audit.py 9.6 KB
      """
      bx_audit: objective mesh checks an agent runs before calling a model done.
      
      Works inside any Blender 5.x session (headless or GUI). Two ways to use it:
      
        # inside a running Blender (MCP bridge / Python console)
        import sys; sys.path.append("<skill>/scripts"); import bx_audit
        report = bx_audit.audit(bpy.data.objects["Head_retopo"])
        report = bx_audit.audit(low, high=bpy.data.objects["Head_sculpt"])  # adds fidelity
      
        # headless
        blender --background scene.blend --python bx_audit.py -- --object Head_retopo \
                [--high Head_sculpt] [--json out.json]
      
      What the numbers mean. Reference values measured with this script on Blender Studio's
      own production retopology (CC-BY retopo_examples.blend, Julien Kaspar): Rex head 3,890
      faces, 99.9% quads, 38 e3 + 31 e5 poles, 3 valence-6; Snow body 9,402 faces, 99.96%
      quads, 250 e3 + 258 e5; Rain body 16,526 faces, 99.98% quads, 241 e3 + 247 e5, 2 v6.
        quads_pct          deforming character mesh: studio work sits at 99.9+; below 98 = look why
        ngons              0 on anything that gets subdivided, deformed or exported to a game engine
        poles_e3 / poles_e5plus   valence-3 / valence-5+ interior vertices; fine in flat,
                           non-deforming areas, never on lip/lid rims or inside a fold
        valence_6plus      almost always a mistake (pinching, shading artifacts)
        non_manifold_edges / loose_verts / loose_edges    0 unless intentional
        boundary_loops     list of open borders (eye holes, neck cut); unexpected ones = holes
        zero_area_faces, duplicate_verts                 0
        flipped_faces      faces whose normal disagrees with neighbours (inconsistent winding)
        self_intersections face pairs that cross each other (ignores neighbours)
        edge_len_cv        coefficient of variation of edge length. NOT a quality score: studio
                           character retopo measures 0.6 to 0.8 because density is deliberately
                           higher around eyes, mouth and joints. Uniform remeshes read ~0.2 to 0.35.
                           Use it to tell an auto-remesh (low) from a hand-planned layout (high).
        pole density       (poles_e3 + poles_e5plus) / faces: ~2% on a studio head, ~5% on a body
        symmetry_pct       share of vertices with an X-mirror partner (characters: ~100)
        fidelity_*         distance from low-poly verts to the high-poly surface, in scene units
      """
      
      __version__ = "0.1"  # Blender Expert Skills v0.1 (2026-09-24)
      import json
      import math
      import sys
      
      import bmesh
      import bpy
      from mathutils.bvhtree import BVHTree
      
      
      def _bm_from(obj, evaluated=False):
          bm = bmesh.new()
          if evaluated:
              dg = bpy.context.evaluated_depsgraph_get()
              eo = obj.evaluated_get(dg)
              me = eo.to_mesh()
              bm.from_mesh(me)
              eo.to_mesh_clear()
          else:
              bm.from_mesh(obj.data)
          bm.transform(obj.matrix_world)
          bm.verts.ensure_lookup_table()
          bm.faces.ensure_lookup_table()
          bm.normal_update()
          return bm
      
      
      def _boundary_loops(bm):
          edges = {e for e in bm.edges if e.is_boundary}
          loops = []
          while edges:
              start = edges.pop()
              count, stack = 1, [start]
              while stack:
                  e = stack.pop()
                  for v in e.verts:
                      for n in v.link_edges:
                          if n in edges:
                              edges.remove(n)
                              stack.append(n)
                              count += 1
              loops.append(count)
          return sorted(loops, reverse=True)
      
      
      def _flipped(bm):
          bad = 0
          for e in bm.edges:
              if len(e.link_faces) != 2:
                  continue
              f1, f2 = e.link_faces
              # consistent winding: the shared edge runs in opposite directions in the two faces
              l1 = next(l for l in e.link_loops if l.face == f1)
              l2 = next(l for l in e.link_loops if l.face == f2)
              if l1.vert == l2.vert:
                  bad += 1
          return bad
      
      
      def _self_intersections(bm, limit=200000):
          if len(bm.faces) > limit:
              return None  # too heavy, skip on dense sculpts
          tree = BVHTree.FromBMesh(bm)
          pairs = tree.overlap(tree)
          faces = bm.faces
          n = 0
          for a, b in pairs:
              if a >= b:
                  continue
              va = {v.index for v in faces[a].verts}
              if va & {v.index for v in faces[b].verts}:
                  continue
              n += 1
          return n
      
      
      def _symmetry(bm, axis=0, tol=None):
          co = [v.co for v in bm.verts]
          if not co:
              return 0.0
          size = max((max(c[i] for c in co) - min(c[i] for c in co)) for i in range(3)) or 1.0
          tol = tol or size * 1e-3
          from mathutils.kdtree import KDTree
          kd = KDTree(len(co))
          for i, c in enumerate(co):
              kd.insert(c, i)
          kd.balance()
          hit = 0
          for c in co:
              m = c.copy()
              m[axis] = -m[axis]
              _, _, d = kd.find(m)
              if d is not None and d <= tol:
                  hit += 1
          return 100.0 * hit / len(co)
      
      
      def audit(obj, high=None, evaluated=False, symmetry_axis=0, max_listed=25):
          """Return a dict of topology/geometry metrics for a mesh object (world space)."""
          bm = _bm_from(obj, evaluated)
          faces = bm.faces
          tris = sum(1 for f in faces if len(f.verts) == 3)
          quads = sum(1 for f in faces if len(f.verts) == 4)
          ngons = [f.index for f in faces if len(f.verts) > 4]
          val = {}
          poles3, poles5 = [], []
          for v in bm.verts:
              if v.is_boundary or not v.link_faces:
                  continue
              k = len(v.link_edges)
              val[k] = val.get(k, 0) + 1
              if k == 3:
                  poles3.append(v)
              elif k >= 5:
                  poles5.append(v)
          lengths = [e.calc_length() for e in bm.edges]
          mean_len = sum(lengths) / len(lengths) if lengths else 0.0
          cv = (math.sqrt(sum((l - mean_len) ** 2 for l in lengths) / len(lengths)) / mean_len) if mean_len else 0.0
          dup = 0
          if bm.verts:
              from mathutils.kdtree import KDTree
              kd = KDTree(len(bm.verts))
              for v in bm.verts:
                  kd.insert(v.co, v.index)
              kd.balance()
              eps = max(mean_len * 1e-3, 1e-6)
              for v in bm.verts:
                  if len(kd.find_range(v.co, eps)) > 1:
                      dup += 1
          r = {
              "object": obj.name,
              "verts": len(bm.verts), "edges": len(bm.edges), "faces": len(faces),
              "tris_equivalent": sum(len(f.verts) - 2 for f in faces),
              "tris": tris, "quads": quads, "ngons": len(ngons),
              "quads_pct": round(100.0 * quads / len(faces), 2) if faces else 0.0,
              "valence_hist_interior": dict(sorted(val.items())),
              "poles_e3": len(poles3), "poles_e5plus": len(poles5),
              "valence_6plus": sum(c for k, c in val.items() if k >= 6),
              "non_manifold_edges": sum(1 for e in bm.edges if not e.is_manifold and not e.is_boundary),
              "boundary_loops": _boundary_loops(bm),
              "loose_verts": sum(1 for v in bm.verts if not v.link_edges),
              "loose_edges": sum(1 for e in bm.edges if not e.link_faces),
              "zero_area_faces": sum(1 for f in faces if f.calc_area() < 1e-12),
              "duplicate_verts": dup,
              "flipped_faces": _flipped(bm),
              "self_intersections": _self_intersections(bm),
              "edge_len_mean": round(mean_len, 6), "edge_len_cv": round(cv, 3),
              "symmetry_pct": round(_symmetry(bm, symmetry_axis), 2),
              "dimensions": [round(d, 4) for d in obj.dimensions],
              "ngon_faces_sample": ngons[:max_listed],
              "pole_e5_sample": [[round(c, 4) for c in v.co] for v in poles5[:max_listed]],
              "pole_e3_sample": [[round(c, 4) for c in v.co] for v in poles3[:max_listed]],
          }
          if high is not None:
              r.update(fidelity(bm, high))
          bm.free()
          return r
      
      
      def fidelity(low_bm_or_obj, high):
          """Distance from each low-poly vertex to the high-poly surface (world space)."""
          own = not isinstance(low_bm_or_obj, bmesh.types.BMesh)
          bm = _bm_from(low_bm_or_obj) if own else low_bm_or_obj
          hb = _bm_from(high, evaluated=True)
          tree = BVHTree.FromBMesh(hb)
          d = []
          for v in bm.verts:
              hit = tree.find_nearest(v.co)
              if hit[0] is not None:
                  d.append(hit[3])
          hb.free()
          if own:
              bm.free()
          d.sort()
          if not d:
              return {"fidelity_mean": None}
          size = max(high.dimensions) or 1.0
          return {
              "fidelity_mean": round(sum(d) / len(d), 6),
              "fidelity_p95": round(d[int(0.95 * (len(d) - 1))], 6),
              "fidelity_max": round(d[-1], 6),
              "fidelity_max_pct_of_size": round(100.0 * d[-1] / size, 3),
          }
      
      
      def verdict(r, deforming=True):
          """Short list of human-readable problems, empty when the mesh is clean."""
          out = []
          if r["ngons"]:
              out.append(f"{r['ngons']} n-gons")
          if deforming and r["quads_pct"] < 95:
              out.append(f"only {r['quads_pct']}% quads")
          if r["valence_6plus"]:
              out.append(f"{r['valence_6plus']} vertices with 6+ edges")
          for k in ("non_manifold_edges", "loose_verts", "loose_edges", "zero_area_faces",
                    "duplicate_verts", "flipped_faces"):
              if r[k]:
                  out.append(f"{r[k]} {k.replace('_', ' ')}")
          if r.get("self_intersections"):
              out.append(f"{r['self_intersections']} self-intersecting face pairs")
          if r.get("fidelity_max_pct_of_size", 0) > 1.0:
              out.append(f"max deviation from high poly {r['fidelity_max_pct_of_size']}% of size")
          return out
      
      
      def _cli():
          argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
          args = dict(zip(argv[::2], argv[1::2]))
          obj = bpy.data.objects[args["--object"]]
          high = bpy.data.objects[args["--high"]] if "--high" in args else None
          r = audit(obj, high=high)
          r["problems"] = verdict(r)
          text = json.dumps(r, indent=1)
          if "--json" in args:
              open(args["--json"], "w").write(text)
          print(text)
      
      
      if __name__ == "__main__":
          _cli()
      
    • bx_gui.py 8.7 KB
      """
      bx_gui: drive Blender's REAL brushes from Python in a live GUI session.
      
      Only for a Blender that has a window (the MCP bridge / user's running Blender).
      Headless (`--background`) there is no 3D view region: brush strokes fail their poll
      and some sculpt operators (paint.mask_flood_fill, sculpt.mesh_filter) crash Blender.
      Headless, use scenario-blender-sculpting/scripts/bx_sculpt.py instead.
      
      Verified on Blender 5.2.1 GUI (2026-09-24): Clay Strips sculpt stroke, stroke with
      dyntopo on, mask flood fill, weight paint stroke, texture paint stroke.
      NOT drivable: hair-curve sculpting (`sculpt_curves.brush_stroke` has no exec and returns
      PASS_THROUGH; event simulation does not help either). For hair, write guide curves as
      data and use the hair node-group assets (see scenario-blender-hair).
      
        import sys; sys.path.append("<skill>/scripts"); import bx_gui as G
        G.set_view("FRONT", frame=obj)                       # orient the view the stroke is drawn in
        G.activate_brush("SCULPT", "Clay Strips")            # 5.x brush assets (Essentials library)
        G.stroke("SCULPT", world_points, size=80, strength=0.5)
        G.run("paint.mask_flood_fill", mode="VALUE", value=0.0)   # any op that needs the 3D view
      
      Rules learned the hard way:
        - Stroke points must be visible from the current view: set_view() first, pick a view
          that looks at the surface you are working on (points behind the surface miss).
        - `size` is the brush DIAMETER in pixels (5.0 change). Pixel size depends on zoom:
          frame the object first so the same size means the same thing every time.
        - Paint colour defaults to black in a factory 5.2 session and is a unified per-mode
          setting: set_paint_color() sets both the brush and the unified colour.
        - Stroke element keys in 5.2: name, location, mouse, mouse_event, pressure, size,
          x_tilt, y_tilt, time, is_start (no 'pen_flip').
        - The exec path reads `location` in OBJECT space (mouse is region space). stroke()
          converts world points for you; passing world coordinates for an object away from
          the origin makes dabs land off the mesh (nothing moves) or in the wrong place.
        - set_view() frames in Object Mode with Smooth View off and forces a redraw, then
          restores the mode: view_selected from Sculpt Mode does not frame, and region
          matrices only refresh on redraw.
      """
      
      __version__ = "0.1"  # Blender Expert Skills v0.1 (2026-09-24)
      import bpy
      from bpy_extras import view3d_utils
      from mathutils import Vector
      
      BRUSH_FILES = {
          "SCULPT": "essentials_brushes-mesh_sculpt.blend",
          "TEXTURE_PAINT": "essentials_brushes-mesh_texture.blend",
          "VERTEX_PAINT": "essentials_brushes-mesh_vertex.blend",
          "WEIGHT_PAINT": "essentials_brushes-mesh_weight.blend",
          "SCULPT_CURVES": "essentials_brushes-curve_sculpt.blend",
      }
      STROKE_OPS = {
          "SCULPT": "sculpt.brush_stroke",
          "TEXTURE_PAINT": "paint.image_paint",
          "VERTEX_PAINT": "paint.vertex_paint",
          "WEIGHT_PAINT": "paint.weight_paint",
          "SCULPT_CURVES": "sculpt_curves.brush_stroke",
      }
      PAINT_SETTINGS = {"SCULPT": "sculpt", "TEXTURE_PAINT": "image_paint",
                        "VERTEX_PAINT": "vertex_paint", "WEIGHT_PAINT": "weight_paint"}
      
      
      def has_window():
          return not bpy.app.background and bool(bpy.context.window_manager.windows)
      
      
      def view3d():
          """(window, area, region, region_3d) of the first 3D viewport."""
          for win in bpy.context.window_manager.windows:
              for area in win.screen.areas:
                  if area.type == "VIEW_3D":
                      region = next(r for r in area.regions if r.type == "WINDOW")
                      return win, area, region, area.spaces.active.region_3d
          raise RuntimeError("no 3D viewport: bx_gui needs a GUI session (use bx_sculpt headless)")
      
      
      def run(op_path, **kwargs):
          """Call any operator with a 3D-view context, e.g. run('sculpt.face_sets_init', mode='LOOSE_PARTS')."""
          win, area, region, _ = view3d()
          mod, name = op_path.split(".")
          with bpy.context.temp_override(window=win, area=area, region=region):
              return getattr(getattr(bpy.ops, mod), name)(**kwargs)
      
      
      def set_view(axis="FRONT", frame=None):
          """axis: FRONT BACK LEFT RIGHT TOP BOTTOM. frame=object to zoom on (selected alone).
          view3d.view_selected does not frame the object from Sculpt/paint modes (found
          2026-09-24: a 30 px stroke then covered the whole head and flattened it), so the
          framing happens in Object Mode and the previous mode is restored afterwards."""
          if frame is not None:
              mode = frame.mode
              if mode != "OBJECT":
                  bpy.ops.object.mode_set(mode="OBJECT")
              for o in bpy.context.selected_objects:
                  o.select_set(False)
              frame.select_set(True)
              bpy.context.view_layer.objects.active = frame
          # Smooth View animates the camera over ~200 ms; strokes projected before it ends
          # land in the old view and miss. Disable it while framing.
          prefs = bpy.context.preferences.view
          smooth = prefs.smooth_view
          prefs.smooth_view = 0
          try:
              run("view3d.view_axis", type=axis)
              if frame is not None:
                  run("view3d.view_selected")
          finally:
              prefs.smooth_view = smooth
          if frame is not None and mode != "OBJECT":
              bpy.ops.object.mode_set(mode=mode)
          redraw()
      
      
      def redraw():
          """Force a viewport redraw: region_3d projection matrices (used by strokes and by
          location_3d_to_region_2d) only refresh when the region draws."""
          win, area, region, _ = view3d()
          with bpy.context.temp_override(window=win, area=area, region=region):
              bpy.ops.wm.redraw_timer(type="DRAW_WIN_SWAP", iterations=1)
      
      
      def activate_brush(mode, name):
          """Activate an Essentials brush asset for a paint mode (object must be in that mode)."""
          return bpy.ops.brush.asset_activate(
              asset_library_type="ESSENTIALS", asset_library_identifier="",
              relative_asset_identifier=f"brushes/{BRUSH_FILES[mode]}/Brush/{name}")
      
      
      def brush(mode):
          return getattr(bpy.context.tool_settings, PAINT_SETTINGS[mode]).brush
      
      
      def set_paint_color(mode, rgb):
          ps = getattr(bpy.context.tool_settings, PAINT_SETTINGS[mode])
          if ps.brush is not None:
              ps.brush.color = rgb
          ps.unified_paint_settings.color = rgb
      
      
      def stroke(mode, world_points, size=60, pressure=1.0, strength=None, invert=False,
                 snap=True, **op_kwargs):
          """Run one brush stroke through world-space points (object must be in `mode`).
          snap=True casts a ray from the view through each point onto the active object, as a
          mouse would, and uses the hit as the dab location (the exec path uses `location`
          literally, so a point 5 cm off the surface affects nothing). Points whose ray misses
          are skipped and returned. Returns (operator result, missed points)."""
          redraw()   # region matrices and paint canvases refresh on draw (texture paint needs it)
          win, area, region, rv3d = view3d()
          ob = bpy.context.active_object
          to_local = ob.matrix_world.inverted() if ob is not None else None
          b = brush(mode) if mode in PAINT_SETTINGS else None
          if b is not None and strength is not None:
              b.strength = strength
          elems, missed = [], []
          for i, p in enumerate(world_points):
              xy = view3d_utils.location_3d_to_region_2d(region, rv3d, Vector(p))
              if xy is None:
                  missed.append(p)
                  continue
              loc_world = Vector(p)
              if snap and ob is not None and ob.type == "MESH":
                  origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, xy)
                  direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, xy)
                  o_l = to_local @ origin
                  d_l = (to_local.to_3x3() @ direction).normalized()
                  hit, loc_l, _n, _idx = ob.ray_cast(o_l, d_l)
                  if not hit:
                      missed.append(p)
                      continue
                  loc_world = ob.matrix_world @ loc_l
              # 'location' is read in OBJECT space by the exec path; 'mouse' is region space
              loc = tuple(to_local @ loc_world) if to_local is not None else tuple(loc_world)
              elems.append({"name": "", "location": loc, "mouse": (xy.x, xy.y),
                            "mouse_event": (xy.x, xy.y), "pressure": pressure, "size": size,
                            "time": float(i), "x_tilt": 0.0, "y_tilt": 0.0, "is_start": not elems})
          if not elems:
              return {"CANCELLED"}, missed
          if mode == "SCULPT":
              op_kwargs.setdefault("mode", "INVERT" if invert else "NORMAL")
          mod, name = STROKE_OPS[mode].split(".")
          with bpy.context.temp_override(window=win, area=area, region=region):
              r = getattr(getattr(bpy.ops, mod), name)(stroke=elems, **op_kwargs)
          return r, missed
      
      
      def screenshot(path):
          """Viewport screenshot of the whole window (what a human would see)."""
          win, area, region, _ = view3d()
          with bpy.context.temp_override(window=win, area=area, region=region):
              bpy.ops.screen.screenshot_area(filepath=path)
          return path
      
    • bx_review.py 14.6 KB
      """
      bx_review: the renders an agent looks at instead of orbiting a viewport.
      
      Experts judge a model by turning it: silhouette first, then big planes under a
      matcap, then edge flow in wireframe. This module renders exactly that, headless-safe
      (Workbench engine), into one labelled contact sheet the agent opens with its image
      reader. It never touches the user's scene settings: objects are linked into a
      temporary scene that is removed afterwards.
      
        import sys; sys.path.append("<skill>/scripts"); import bx_review
        sheet = bx_review.review([obj], "/abs/out/dir")                       # all defaults
        sheet = bx_review.review([obj], out, views=("front", "side"), modes=("silhouette",))
        bx_review.playblast("/abs/out/shot.mp4")        # scene camera, frame range, Workbench
        bx_review.turntable(obj, "/abs/out/turn.mp4", frames=72)
      
      headless:
        blender --background scene.blend --python bx_review.py -- --objects Head,Eyes --out /abs/dir
      
      Modes:  silhouette (black on white: reads shape and gesture only)
              matcap     (clay_studio matcap: reads planes and forms)
              wire       (clay + dark wireframe overlay: reads topology; skipped above 250k faces)
              normals    (check_normal+y matcap: reveals shading breaks and flipped normals)
      Views:  front, back, left, right, top, threequarter (perspective, 50 mm), and
              low (three-quarter from below, catches jaw/chin and under-plane problems)
      """
      
      __version__ = "0.1"  # Blender Expert Skills v0.1 (2026-09-24)
      import math
      import os
      import sys
      
      import bpy
      from mathutils import Matrix, Vector
      
      VIEW_DIRS = {
          "front": Vector((0, -1, 0)),
          "back": Vector((0, 1, 0)),
          "right": Vector((1, 0, 0)),
          "left": Vector((-1, 0, 0)),
          "top": Vector((0, 0, 1)),
          "threequarter": Vector((-0.7, -1.0, 0.35)).normalized(),
          "low": Vector((0.8, -1.0, -0.45)).normalized(),
      }
      PERSPECTIVE = {"threequarter", "low"}
      WIRE_FACE_LIMIT = 250_000
      
      
      def _world_bbox(objs):
          dg = bpy.context.evaluated_depsgraph_get()
          pts = []
          for o in objs:
              eo = o.evaluated_get(dg)
              pts += [eo.matrix_world @ Vector(c) for c in eo.bound_box]
          lo = Vector([min(p[i] for p in pts) for i in range(3)])
          hi = Vector([max(p[i] for p in pts) for i in range(3)])
          return lo, hi
      
      
      def _look_at(cam, target, direction, dist):
          cam.location = target + direction * dist
          fwd = (target - cam.location).normalized()
          up = Vector((0, 0, 1)) if abs(fwd.z) < 0.99 else Vector((0, 1, 0))
          right = fwd.cross(up).normalized()
          up = right.cross(fwd)
          rot = Matrix((right, up, -fwd)).transposed()
          cam.rotation_euler = rot.to_euler()
      
      
      def _extent(corners, center, direction):
          """Half-size of the bbox seen from `direction` (square frame) and its half-depth."""
          fwd = -direction
          up = Vector((0, 0, 1)) if abs(fwd.z) < 0.99 else Vector((0, 1, 0))
          right = fwd.cross(up).normalized()
          up = right.cross(fwd)
          rel = [c - center for c in corners]
          half = max(max(abs(r.dot(right)) for r in rel), max(abs(r.dot(up)) for r in rel))
          depth = max(abs(r.dot(fwd)) for r in rel)
          return max(half, 1e-4), depth
      
      
      def _sample_points(objs, limit=20000):
          """World-space vertex positions of the evaluated objects (subsampled): framing from
          real geometry, because bbox corners overshoot badly in diagonal views."""
          dg = bpy.context.evaluated_depsgraph_get()
          pts = []
          for o in objs:
              if o.type != "MESH":
                  continue
              eo = o.evaluated_get(dg)
              me = eo.to_mesh()
              n = len(me.vertices)
              step = max(1, n // max(1, limit // max(1, len(objs))))
              mw = eo.matrix_world
              pts += [mw @ me.vertices[i].co for i in range(0, n, step)]
              eo.to_mesh_clear()
          return pts
      
      
      def _persp_distance(points, center, direction, tan_half):
          """Smallest camera distance along `direction` at which every point fits the frame."""
          fwd = -direction
          up = Vector((0, 0, 1)) if abs(fwd.z) < 0.99 else Vector((0, 1, 0))
          right = fwd.cross(up).normalized()
          up = right.cross(fwd)
          need = 0.0
          for p in points:
              r = p - center
              toward_cam = r.dot(direction)
              need = max(need, abs(r.dot(right)) / tan_half + toward_cam,
                         abs(r.dot(up)) / tan_half + toward_cam)
          return max(need, 1e-3)
      
      
      def _face_count(o):
          return len(o.data.polygons) if o.type == "MESH" else 0
      
      
      def _setup_scene(objs, res):
          sc = bpy.data.scenes.new("BX_Review")
          for o in objs:
              sc.collection.objects.link(o)
          sc.render.engine = "BLENDER_WORKBENCH"
          sc.render.resolution_x = sc.render.resolution_y = res
          sc.render.resolution_percentage = 100
          sc.render.image_settings.media_type = "IMAGE"
          sc.render.image_settings.file_format = "PNG"
          sc.view_settings.view_transform = "Standard"
          sc.display.shading.show_cavity = False
          world = bpy.data.worlds.new("BX_Review_World")
          sc.world = world
          cam_data = bpy.data.cameras.new("BX_Review_Cam")
          cam = bpy.data.objects.new("BX_Review_Cam", cam_data)
          sc.collection.objects.link(cam)
          sc.camera = cam
          return sc, cam, world
      
      
      def _cleanup(sc, cam, world):
          cam_data = cam.data
          bpy.data.objects.remove(cam)
          if cam_data.users == 0:
              bpy.data.cameras.remove(cam_data)
          bpy.data.scenes.remove(sc)
          bpy.data.worlds.remove(world)
      
      
      def _set_mode(sc, world, mode, objs, wires):
          sh = sc.display.shading
          for w in wires:
              w.hide_render = mode != "wire"
          if mode == "silhouette":
              sh.light, sh.color_type = "FLAT", "SINGLE"
              sh.single_color = (0.0, 0.0, 0.0)
              world.color = (1.0, 1.0, 1.0)
          elif mode in ("matcap", "normals"):
              sh.light, sh.color_type = "MATCAP", "SINGLE"
              sh.studio_light = "clay_studio.exr" if mode == "matcap" else "check_normal+y.exr"
              sh.single_color = (0.8, 0.8, 0.8)
              world.color = (0.18, 0.18, 0.18)
          elif mode == "wire":
              sh.light, sh.color_type = "STUDIO", "OBJECT"
              sh.studio_light = "Default"
              for o in objs:
                  o.color = (0.75, 0.75, 0.75, 1.0)
              for w in wires:
                  w.color = (0.02, 0.02, 0.02, 1.0)
              world.color = (0.25, 0.25, 0.25)
      
      
      def _make_wires(sc, objs):
          wires = []
          for o in objs:
              if o.type != "MESH" or _face_count(o) > WIRE_FACE_LIMIT or not o.data.edges:
                  continue
              w = bpy.data.objects.new(o.name + "_BXwire", o.data)
              w.matrix_world = o.matrix_world
              # copy deforming/generating modifiers so the cage matches what is rendered
              for m in o.modifiers:
                  if m.type in {"MIRROR", "ARMATURE", "SHRINKWRAP"}:
                      nm = w.modifiers.new(m.name, m.type)
                      for prop in m.bl_rna.properties:
                          if not prop.is_readonly and prop.identifier not in {"name", "type"}:
                              try:
                                  setattr(nm, prop.identifier, getattr(m, prop.identifier))
                              except Exception:
                                  pass
              verts, edges = o.data.vertices, o.data.edges
              n = min(len(edges), 5000)
              mean = sum((verts[e.vertices[0]].co - verts[e.vertices[1]].co).length
                         for e in list(edges)[:n]) / n * max(o.matrix_world.to_scale())
              wm = w.modifiers.new("BXwire", "WIREFRAME")
              wm.thickness = mean * 0.08
              wm.use_replace = True
              wm.use_even_offset = False
              sc.collection.objects.link(w)
              wires.append(w)
          return wires
      
      
      def _tile(paths, rows, cols, out, labels):
          """Compose PNGs into one sheet with numpy (Blender ships numpy; no PIL needed)."""
          import numpy as np
          imgs = []
          for p in paths:
              im = bpy.data.images.load(p, check_existing=False)
              w, h = im.size
              a = np.array(im.pixels[:], dtype=np.float32).reshape(h, w, 4)
              imgs.append(a)
              bpy.data.images.remove(im)
          h, w = imgs[0].shape[:2]
          sheet = np.ones((rows * h, cols * w, 4), dtype=np.float32)
          for i, a in enumerate(imgs):
              r, c = divmod(i, cols)
              # images are stored bottom-up; row 0 goes at the top of the sheet
              y0 = (rows - 1 - r) * h
              sheet[y0:y0 + h, c * w:(c + 1) * w] = a
          out_img = bpy.data.images.new("BX_sheet", cols * w, rows * h, alpha=True)
          out_img.pixels = sheet.ravel()
          out_img.filepath_raw = out
          out_img.file_format = "PNG"
          out_img.save()
          bpy.data.images.remove(out_img)
          with open(os.path.splitext(out)[0] + "_legend.txt", "w") as f:
              f.write("rows x cols (top-left first):\n" + "\n".join(labels))
          return out
      
      
      def review(objs, out_dir, views=("front", "right", "threequarter", "back"),
                 modes=("silhouette", "matcap", "wire"), res=800, margin=1.12, focus=None):
          """Render modes x views and return the contact sheet path (rows = modes, cols = views).
          focus: close-up framing instead of the whole object: ((x, y, z), radius) in world
          space, e.g. focus=(eye_center, 0.06) to inspect eye loops. The rest of the mesh stays
          in the render (no cropping, no temporary copies); the camera just frames that sphere."""
          objs = [o for o in objs if o is not None]
          os.makedirs(out_dir, exist_ok=True)
          sc, cam, world = _setup_scene(objs, res)
          wires = _make_wires(sc, objs) if "wire" in modes else []
          lo, hi = _world_bbox(objs)
          center = (lo + hi) / 2
          corners = _sample_points(objs) or [Vector((x, y, z)) for x in (lo.x, hi.x)
                                             for y in (lo.y, hi.y) for z in (lo.z, hi.z)]
          if focus is not None:
              fc, fr = Vector(focus[0]), float(focus[1])
              center = fc
              corners = [fc + Vector(d) * fr for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),
                                                        (0, -1, 0), (0, 0, 1), (0, 0, -1))]
          paths, labels = [], []
          try:
              for mode in modes:
                  if mode == "wire" and not wires:
                      continue
                  _set_mode(sc, world, mode, objs, wires)
                  for view in views:
                      d = VIEW_DIRS[view]
                      half, depth = _extent(corners, center, d)
                      if view in PERSPECTIVE:
                          cam.data.type, cam.data.lens = "PERSP", 50
                          fov = 2 * math.atan(cam.data.sensor_width / (2 * cam.data.lens))
                          dist = _persp_distance(corners, center, d, math.tan(fov / 2) / margin)
                      else:
                          cam.data.type = "ORTHO"
                          cam.data.ortho_scale = 2 * half * margin
                          dist = depth * 4 + 1.0
                      cam.data.clip_start, cam.data.clip_end = dist * 0.01, dist * 10
                      _look_at(cam, center, d, dist)
                      p = os.path.join(out_dir, f"{mode}_{view}.png")
                      sc.render.filepath = p
                      bpy.ops.render.render(write_still=True, scene=sc.name)
                      paths.append(p)
                      labels.append(f"{mode} / {view}")
              rows = len(paths) // len(views)
              sheet = _tile(paths, rows, len(views), os.path.join(out_dir, "review_sheet.png"), labels)
          finally:
              for w in wires:
                  bpy.data.objects.remove(w)
              _cleanup(sc, cam, world)
          return sheet
      
      
      def playblast(out_path, scene=None, res_pct=50, color_type="MATERIAL"):
          """Workbench movie of the scene camera over the frame range (works headless)."""
          sc = scene or bpy.context.scene
          r = sc.render
          saved = (r.engine, r.filepath, r.resolution_percentage, r.image_settings.media_type,
                   sc.display.shading.color_type, sc.view_settings.view_transform)
          try:
              r.engine = "BLENDER_WORKBENCH"
              sc.display.shading.color_type = color_type
              sc.view_settings.view_transform = "Standard"
              r.resolution_percentage = res_pct
              r.image_settings.media_type = "VIDEO"      # 5.x: must precede FFMPEG
              r.image_settings.file_format = "FFMPEG"
              r.ffmpeg.format, r.ffmpeg.codec = "MPEG4", "H264"
              r.filepath = out_path
              bpy.ops.render.render(animation=True, scene=sc.name)
          finally:
              (r.engine, r.filepath, r.resolution_percentage, media,
               sc.display.shading.color_type, sc.view_settings.view_transform) = saved
              r.image_settings.media_type = media
          return out_path
      
      
      def turntable(obj, out_path, frames=72, res=720, mode="matcap"):
          """360-degree matcap turntable of one object, rendered in a temporary scene."""
          sc, cam, world = _setup_scene([obj], res)
          _set_mode(sc, world, mode, [obj], [])
          lo, hi = _world_bbox([obj])
          center, radius = (lo + hi) / 2, max((hi - lo).length / 2, 1e-3)
          pivot = bpy.data.objects.new("BX_pivot", None)
          sc.collection.objects.link(pivot)
          pivot.location = center
          cam.parent = pivot
          cam.data.type, cam.data.lens = "PERSP", 50
          fov = 2 * math.atan(cam.data.sensor_width / (2 * cam.data.lens))
          dist = radius * 1.2 / math.sin(fov / 2)
          _look_at(cam, Vector((0, 0, 0)), Vector((0, -1, 0.2)).normalized(), dist)
          sc.frame_start, sc.frame_end = 1, frames
          pivot.rotation_euler = (0, 0, 0)
          pivot.keyframe_insert("rotation_euler", index=2, frame=1)
          pivot.rotation_euler = (0, 0, 2 * math.pi * (frames - 1) / frames)
          pivot.keyframe_insert("rotation_euler", index=2, frame=frames)
          for fc in _fcurves(pivot):
              for k in fc.keyframe_points:
                  k.interpolation = "LINEAR"
          try:
              sc.render.image_settings.media_type = "VIDEO"
              sc.render.image_settings.file_format = "FFMPEG"
              sc.render.ffmpeg.format, sc.render.ffmpeg.codec = "MPEG4", "H264"
              sc.render.filepath = out_path
              bpy.ops.render.render(animation=True, scene=sc.name)
          finally:
              act = pivot.animation_data.action if pivot.animation_data else None
              bpy.data.objects.remove(pivot)
              if act:
                  bpy.data.actions.remove(act)
              _cleanup(sc, cam, world)
          return out_path
      
      
      def _fcurves(obj):
          """F-curves of an object's action on Blender 5.x (slotted actions) and 4.x."""
          ad = obj.animation_data
          if not ad or not ad.action:
              return []
          act = ad.action
          if hasattr(ad, "action_slot") and ad.action_slot is not None and hasattr(act, "layers"):
              from bpy_extras import anim_utils
              cb = anim_utils.action_get_channelbag_for_slot(act, ad.action_slot)
              return list(cb.fcurves) if cb else []
          return list(getattr(act, "fcurves", []))
      
      
      def _cli():
          argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
          args = dict(zip(argv[::2], argv[1::2]))
          objs = [bpy.data.objects[n] for n in args["--objects"].split(",")]
          views = tuple(args.get("--views", "front,right,threequarter,back").split(","))
          modes = tuple(args.get("--modes", "silhouette,matcap,wire").split(","))
          print("SHEET", review(objs, args["--out"], views=views, modes=modes, res=int(args.get("--res", 800))))
      
      
      if __name__ == "__main__":
          _cli()
      
  • SKILL.md 12.5 KB
    ---
    name: scenario-blender-expert
    description: "Use when doing any 3D work in Blender through Python or an MCP bridge, headless or in a live session: modeling, sculpting a character or head, retopology, UV unwrapping, baking, texturing, rigging, animating, previs, geometry nodes, lighting and rendering, grease pencil, or finishing an AI-generated mesh into a production asset. Also when a bpy script fails on Blender 5.x API changes, a brush or sculpt operator fails or crashes headless, or a result has to be judged like a professional 3D artist would."
    license: MIT
    ---
    
    # Blender expert (router and agent protocol)
    
    Expert-level Blender work is a loop, not a script: build big to small, and at every stage turn the model, measure it and fix it before adding detail. This skill is the protocol every Blender task follows and the map to the domain skills distilled from Blender Studio artists, Blender Conference talks and top instructors (sources in each skill). If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap.
    
    ## 1. Execution channel
    
    | Channel                                                                                    | Use for                                                                                | Rules                                                                                                                                                                                                                              |
    | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | Live GUI Blender (user's session over the MCP bridge)                                      | anything interactive: real sculpt/paint brushes, weight painting, viewport screenshots | One short, idempotent script per call returning a compact result dict. Get-or-create by name. Never touch objects you did not create. Save a stage backup `bpy.ops.wm.save_as_mainfile(filepath=..., copy=True)` after each stage. |
    | Headless helper `blender -b [file] --factory-startup --python-exit-code 1 -P x.py -- args` | renders, audits, batch ops, anything when no bridge is up                              | Always `--factory-startup`: without it the user's add-ons load (on this machine one of them makes network API calls). Without `--python-exit-code` a failing script still exits 0. Print a final machine-readable result line.     |
    
    Never open your own GUI Blender window to test things on the user's machine: windows steal focus and pile up. Live work goes through the user's already-running Blender; everything else runs headless, one Blender process at a time per agent (other sessions may be rendering on the same machine).
    
    Check `bpy.app.version` first; this skill set targets 5.2 LTS. Many tutorials the experts recorded use 2.8x to 4.2 UI: read [`references/blender-5.2-deltas.md`](references/blender-5.2-deltas.md) before trusting any remembered operator, brush name or API.
    
    **Verified capability matrix (Blender 5.2.1):**
    
    | Action                                                                                                                                | Headless                                               | GUI session                                       |
    | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------- |
    | Voxel remesh, QuadriFlow, multires subdivide, modifiers, bmesh, UV ops with edit-mode context, Cycles/EEVEE/Workbench renders, baking | yes                                                    | yes                                               |
    | `bpy.ops.brush.asset_activate`, dyntopo toggle                                                                                        | yes                                                    | yes                                               |
    | Brush strokes (`sculpt.brush_stroke`, `paint.image_paint`, `paint.weight_paint`)                                                      | no (poll fails)                                        | yes, via [`scripts/bx_gui.py`](scripts/bx_gui.py) |
    | `paint.mask_flood_fill`, `sculpt.mesh_filter`                                                                                         | CRASHES Blender                                        | yes, via `bx_gui.run()`                           |
    | Programmatic sculpting on mesh data                                                                                                   | yes, `scenario-blender-sculpting/scripts/bx_sculpt.py` | yes                                               |
    
    ## 2. The expert loop (every domain)
    
    1. **Establish the brief** in numbers: purpose (film, game engine, still, animation), budgets (tris, texture size, frame range), style (stylized or realistic), references. When the brief is silent, choose a documented default from the domain skill and write it down.
    2. **Plan the stages** the domain skill defines. Never start with detail.
    3. **Build one stage** with the smallest scripts that do it.
    4. **Gate the stage:** run the measurable checks ([`scripts/bx_audit.py`](scripts/bx_audit.py) for meshes, the domain's own checks) AND render a review sheet ([`scripts/bx_review.py`](scripts/bx_review.py): silhouette, matcap, wire from front/side/three-quarter/low) and open it with the image reader. Experts orbit constantly; the review sheet is the agent's orbit. Judge with the domain's `references/critique.md`.
    5. **Fix before advancing.** A problem found at blockout costs one script; found after texturing it costs the stage.
    6. **Deliver with evidence:** final renders, audit numbers, and what was not verified.
    
    Never report a model, rig, animation or render as done without having looked at a render of it.
    
    ## 3. Persona pipelines
    
    | Persona / brief                                     | Skill chain                                                                                                                                                                                                | Deliverable                                                  |
    | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
    | Character artist, stylized or realistic             | scenario-blender-sculpting, scenario-blender-retopology, scenario-blender-uv-baking, scenario-blender-texturing-shading, scenario-blender-hair                                                             | sculpt, animation-ready low poly, UVs, baked maps, materials |
    | AI-mesh finisher (Scenario 3D output to production) | audit the generated mesh, scenario-blender-retopology (or cleanup + remesh), scenario-blender-uv-baking (bake FROM the generated high), scenario-blender-texturing-shading; generation itself: scenario-3d | clean, UV'd, baked asset at a stated budget                  |
    | Game asset artist                                   | scenario-blender-hard-surface or scenario-blender-sculpting, scenario-blender-retopology (game budget), scenario-blender-uv-baking, scenario-blender-texturing-shading                                     | low poly + PBR set, exported glTF/FBX                        |
    | Rigger / character TD                               | scenario-blender-rigging                                                                                                                                                                                   | rig with controls, weights, deformation tests                |
    | Animator                                            | scenario-blender-previs-storyboard (shot planning), scenario-blender-animation                                                                                                                             | blocked, splined, polished shot + playblast                  |
    | Story / previs artist                               | scenario-blender-previs-storyboard, scenario-blender-grease-pencil                                                                                                                                         | shots, cameras, animatic                                     |
    | Look-dev and lighting artist                        | scenario-blender-texturing-shading, scenario-blender-lighting-rendering                                                                                                                                    | lit, color-managed, composited renders                       |
    | Technical artist / procedural                       | scenario-blender-geometry-nodes                                                                                                                                                                            | node-group assets, procedural systems                        |
    | 2D / 2.5D animator                                  | scenario-blender-grease-pencil                                                                                                                                                                             | GP animation                                                 |
    
    Related skills outside this set: scenario-3d (generate meshes to finish here), scenario-textures (AI textures), text-image-to-blender-blockout (camera-locked blockouts for render-to-real video).
    
    ## 4. Shared toolkit (`scripts/`, all tested on 5.2.1)
    
    - `bx_audit.py`: `audit(obj, high=None)` returns quads %, n-gons, poles (valence 3 / 5+ / 6+), non-manifold, loose, duplicates, flipped faces, self-intersections, boundary loops, symmetry %, edge-length CV, and fidelity to a high poly; `verdict(report)` lists problems. CLI: `blender -b f.blend --python bx_audit.py -- --object Name [--high Sculpt] [--json out.json]`.
    - `bx_review.py`: `review(objs, out_dir, views, modes)` writes one contact sheet (rows = modes, cols = views) in a temporary scene, user settings untouched; `playblast(path)`; `turntable(obj, path)`.
    - `bx_gui.py`: real brushes in a live session: `set_view`, `activate_brush(mode, name)`, `stroke(mode, world_points, size, strength)`, `run(op, **kw)`, `set_paint_color`, `screenshot`.
    
    Import pattern: `import sys; sys.path.append("<this skill>/scripts"); import bx_audit, bx_review`.
    
    ## 5. Version traps that break most remembered code
    
    - Brushes are assets (4.3): `bpy.ops.brush.asset_activate(asset_library_type='ESSENTIALS', asset_library_identifier="", relative_asset_identifier="brushes/essentials_brushes-mesh_sculpt.blend/Brush/Clay Strips")`; brush size is a diameter (5.0); Flatten/Fill/Scrape merged into the Plane type.
    - Auto Smooth removed (4.1): `mesh.shade_smooth()` / Smooth by Angle modifier; voxel remesh output is flat-shaded until you call `shade_smooth()`.
    - Principled BSDF v2 (4.0): address sockets by name; Emission strength defaults to 0.
    - `BLENDER_EEVEE` is the EEVEE Next identifier in 5.x (`BLENDER_EEVEE_NEXT` fails).
    - Compositor is a node group (5.0): `scene.compositing_node_group`, not `scene.node_tree`.
    - Video output: set `image_settings.media_type = 'VIDEO'` before `file_format = 'FFMPEG'` (5.0).
    - Slotted actions (4.4), `action.fcurves` removed (5.0): use `bpy_extras.anim_utils.action_get_channelbag_for_slot(action, slot)`.
    - Bone collections replace armature layers (4.0); Grease Pencil v3 is `GREASEPENCIL` with `bpy.ops.grease_pencil.*` (4.3).
    - Positional context dicts for operators are gone: `with bpy.context.temp_override(...)`.
    - `obj.matrix_world` is stale right after setting location/scale until `view_layer.update()`.
    
    ## References
    
    - [`references/bpy-reliability.md`](references/bpy-reliability.md): checklist and tested procedures for reliable bpy (operators vs data API, stale references, evaluated vs original data, idempotent headless jobs, numpy bulk access, node trees, persistence). Load before writing any non-trivial script.
    - `references/blender-5.2-deltas.md`: full, cited list of what changed from 2.8x to 5.2 per domain (load before writing code from memory or following an old tutorial).
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related