Skip to content

Commit 040e994

Browse files
TMHSDigitalclaude
andauthored
fix(skills): correct measured-false claims across eight skills and two rules (#414)
Each statement below was checked by a headless probe on 4.5.11 LTS and 5.2.1 LTS or against the API docs, and rewritten to match: - always-free-bmesh (rule, snippet, mesh-editing skill): BMesh storage is freed when its wrapper is collected; the reason for try/finally free() is deterministic release and fail-fast ReferenceError, not memory the GC can never reclaim. Edit-mode free() is an ownership rule, not a double-free crash. - mesh-editing: float64 into foreach_set converts correctly (~1e-7) but is slower; objects.remove() already defaults to do_unlink=True. - depsgraph: an evaluated object owns one temp mesh (a second to_mesh() returns the same one); the unsourced crash claim is gone. - headless: sys.argv is the full command line; document --factory-startup and --python-exit-code; export_scene.obj was removed in 4.0, not blocked by a file dialog; render.render is not special in --background. - addon-scaffolding / bl-info-migration: a manifest alone covers 4.2+, so bl_info matters only for 4.1 and earlier (not "4.0 or earlier"). - ai-mesh-cleanup / decimate_to_budget: Mesh.loop_triangles is lazy; calc_loop_triangles() is optional. - operators: REGISTER is Info + redo panel, not undo; INTERNAL hides from search; BLOCKING blocks the cursor. - ui-panels: a missing _PT_ only warns. procedural-materials: a second link to Surface replaces the first. extension template README: Install from Disk takes a zip; build it with `extension build` first. Closes #358 Closes #391 Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d447317 commit 040e994

15 files changed

Lines changed: 68 additions & 45 deletions

File tree

‎claude/blender-rules.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ full version with Wrong/Right examples.
77

88
## always-free-bmesh
99

10-
Flag bmesh.new() calls without a paired bm.free() in a try/finally block. BMesh allocates C-side memory that Python's garbage collector cannot reclaim; missing free() leaks and eventually crashes Blender.
10+
Flag bmesh.new() calls without a paired bm.free() in a try/finally block. Blender frees an unreferenced BMesh only when its Python wrapper dies, which a traceback, global, closure or modal operator can delay for the whole session; an explicit free() in finally releases it deterministically.
1111

1212
Applies to: `**/*.py`. Full rule: [`rules/always-free-bmesh.mdc`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/rules/always-free-bmesh.mdc).
1313

‎claude/skills/blender-rules/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ full version with Wrong/Right examples.
1212

1313
## always-free-bmesh
1414

15-
Flag bmesh.new() calls without a paired bm.free() in a try/finally block. BMesh allocates C-side memory that Python's garbage collector cannot reclaim; missing free() leaks and eventually crashes Blender.
15+
Flag bmesh.new() calls without a paired bm.free() in a try/finally block. Blender frees an unreferenced BMesh only when its Python wrapper dies, which a traceback, global, closure or modal operator can delay for the whole session; an explicit free() in finally releases it deterministically.
1616

1717
Applies to: `**/*.py`. Full rule: [`rules/always-free-bmesh.mdc`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/rules/always-free-bmesh.mdc).
1818

‎rules/always-free-bmesh.mdc‎

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
description: Flag bmesh.new() calls without a paired bm.free() in a try/finally block. BMesh allocates C-side memory that Python's garbage collector cannot reclaim; missing free() leaks and eventually crashes Blender.
2+
description: Flag bmesh.new() calls without a paired bm.free() in a try/finally block. Blender frees an unreferenced BMesh only when its Python wrapper dies, which a traceback, global, closure or modal operator can delay for the whole session; an explicit free() in finally releases it deterministically.
33
alwaysApply: false
44
globs:
55
- "**/*.py"
@@ -10,13 +10,21 @@ standards-version: 1.10.0
1010

1111
`bmesh.new()` returns a Python wrapper around a C-side BMesh structure.
1212
The structure holds the vertex, edge, face, loop, and attribute layer
13-
storage that the bmesh module operates on. Python's garbage collector
14-
sees the wrapper but **cannot free the underlying C memory**. That has
15-
to be done explicitly with `bm.free()`.
16-
17-
Forgetting `bm.free()` leaks. Long-running scripts and modal operators
18-
that loop without freeing eventually exhaust memory and crash Blender,
19-
often after the user has done significant unsaved work.
13+
storage that the bmesh module operates on. Blender frees it when the
14+
wrapper is garbage-collected ("typically when the script finishes
15+
executing", per the `BMesh.free()` docs), so the problem is not that the
16+
memory can never be reclaimed. It is **when**:
17+
18+
- An exception's traceback holds the frame, and with it `bm`, alive
19+
(`sys.last_traceback` keeps the last one for the whole session).
20+
- A global, a closure, a class attribute or a modal operator's `self`
21+
keeps the wrapper reachable for as long as the add-on runs.
22+
- A loop that builds a BMesh per iteration without freeing can hold many
23+
full mesh copies at once before collection catches up.
24+
25+
`bm.free()` in a `finally` releases the storage at a point you choose,
26+
on every path, and makes any later use of `bm` fail fast with
27+
`ReferenceError` instead of quietly reading stale data.
2028

2129
## What this rule flags
2230

@@ -77,8 +85,8 @@ and `bm.free()` raises. Without it, a `KeyError` reading
7785
remaining session.
7886

7987
For the edit-mode case where the bmesh comes from `bmesh.from_edit_mesh()`,
80-
**do not call `bm.free()`**. Edit-mode bmeshes are owned by Blender and
81-
freeing them double-frees and crashes:
88+
**do not call `bm.free()`**. Blender owns the edit-mode BMesh for as long
89+
as the mesh is in Edit Mode; your wrapper only borrows it:
8290

8391
```python
8492
# Edit-mode: Blender owns the bmesh, do not free it
@@ -124,9 +132,9 @@ RAM and survives until they restart Blender. For users on machines with
124132
16GB or less, even modest leaks (a few hundred MB per modal session)
125133
make Blender unusable after an hour.
126134

127-
The crash itself is hard to diagnose: the OOM may not happen during
135+
The symptom is hard to diagnose: memory pressure may not surface during
128136
your code's execution at all, but during a later modifier evaluation
129-
that allocates the last piece of available RAM.
137+
that needs the RAM a lingering BMesh still holds.
130138

131139
## Related
132140

‎skills/addon-scaffolding/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ def unregister():
199199

200200
## Compatibility paths
201201

202-
For libraries you want to be installable on both 4.5 LTS and 5.2 LTS, also keep `bl_info` for 4.5 fallback even though new submissions to the extensions platform require the manifest. Blender prefers the manifest when both are present.
202+
`blender_manifest.toml` alone covers 4.2 and later, including 4.5 LTS and 5.2 LTS: with `blender_version_min = "4.5.0"`, a `bl_info` dict adds nothing. Keep `bl_info` alongside the manifest only if the add-on must also install on 4.1 or earlier, or through the legacy add-on folder / "Install legacy Add-on" path. Blender prefers the manifest when both are present.
203203

204204
```python
205205
bl_info = {

‎skills/ai-mesh-cleanup/SKILL.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ Origin at the lowest Z of the mesh (sit-on-ground) via `foreach_get` / `foreach_
141141

142142
### 6. Measure evaluated triangle count
143143

144-
`obj.data` is the authored mesh. A DECIMATE modifier does not change `obj.data`. Count triangles on the evaluated mesh, and call `calc_loop_triangles()` first. Tessellation is not implicit on 4.5 or 5.x.
144+
`obj.data` is the authored mesh. A DECIMATE modifier does not change `obj.data`. Count triangles on the evaluated mesh. On 4.5 and 5.x `Mesh.loop_triangles` is computed lazily from the current topology, so `calc_loop_triangles()` is optional (harmless; it matters only on much older builds).
145145

146146
```python
147147
def evaluated_triangle_count(obj):
@@ -176,7 +176,7 @@ Draco, selected-only, explicit `export_yup`, and `export_apply=True` so the deci
176176
## Common AI mistakes
177177

178178
1. **Decimate ratio against `len(obj.data.polygons)`**. That ignores modifiers already on the stack and counts n-gons as one. Use evaluated `loop_triangles`.
179-
2. **Skipping `calc_loop_triangles()`**. `loop_triangles` can be empty or stale. Required on 4.5 LTS and 5.x.
179+
2. **Counting triangles on `obj.data`** instead of the evaluated mesh. A DECIMATE modifier does not change `obj.data`, so the count ignores the budget you just applied.
180180
3. **`export_apply=True` as "apply object transforms".** It applies modifiers excluding armatures. Apply object scale first.
181181
4. **Hulling the dense render mesh.** Over budget. Hull a coarse cage.
182182
5. **`bm.normal_update()` for flipped faces.** Use `recalc_face_normals`.
@@ -187,7 +187,7 @@ Draco, selected-only, explicit `export_yup`, and `export_apply=True` so the deci
187187

188188
The cleanup sequence is the same on 4.5 LTS and 5.x:
189189

190-
- `Mesh.calc_loop_triangles()` is still required before `mesh.loop_triangles` on 4.5 LTS, 5.1, and 5.2. Not implicit. Verified: https://docs.blender.org/api/5.1/bpy.types.Mesh.html#bpy.types.Mesh.calc_loop_triangles and https://docs.blender.org/api/4.5/bpy.types.Mesh.html#bpy.types.Mesh.calc_loop_triangles. The 5.1 bmesh module still says tessellation "needs to be called explicitly": https://docs.blender.org/api/5.1/bmesh.html
190+
- `Mesh.loop_triangles` is lazily computed on 4.5 LTS and 5.x: measured on 4.5.11 and 5.2.1, a fresh cube reads 12 without `calc_loop_triangles()`, as do a `to_mesh()` result and a mesh rewritten by bmesh. The "needs to be called explicitly" note in the bmesh docs is about `BMesh.calc_loop_triangles()`, the bmesh-side tessellation, not `Mesh.loop_triangles`.
191191
- `Mesh.calc_normals()` is gone since 4.0. `bmesh.ops.recalc_face_normals` on both LTS lines.
192192
- glTF import has no `global_scale` on either line. FBX does.
193193
- `DecimateModifier.decimate_type='COLLAPSE'` and `ratio` are stable across 4.5 LTS and 5.x.

‎skills/bl-info-migration/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ This produces a `.zip` in `dist\` that the user installs via Edit > Preferences
228228
| `extension build` command | Available | Available |
229229
| Schema version | `1.0.0` | `1.0.0` |
230230

231-
The Extensions Platform shipped in Blender 4.2. Migrations done now will work on 4.2+ and 5.x without further changes. Code targeting 4.0 or earlier still needs the `bl_info`-only path.
231+
The Extensions Platform shipped in Blender 4.2. Migrations done now will work on 4.2+ and 5.x without further changes. Code targeting 4.1 or earlier still needs the `bl_info` path.
232232

233233
## See also
234234

‎skills/depsgraph-and-evaluated-data/SKILL.md‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,10 @@ Three steps:
6262

6363
## The lifetime rule (critical)
6464

65-
Every `to_mesh()` must be paired with a `to_mesh_clear()`. If you skip the clear:
65+
Every `to_mesh()` must be paired with a `to_mesh_clear()`. The evaluated object owns **one** temporary mesh: a second `to_mesh()` on the same object returns that same mesh (measured on 4.5.11 and 5.2.1), so a missing clear does not multiply per call. If you skip the clear:
6666

67-
- The temporary mesh leaks for the lifetime of the depsgraph (typically the session)
68-
- Repeated calls accumulate memory
69-
- In some 5.x builds the next depsgraph evaluation crashes if too many temp meshes are outstanding
67+
- That temporary mesh stays allocated until the object is re-evaluated or freed, once per evaluated object you touched (a whole-scene exporter holds a full copy of every mesh)
68+
- Code that keeps using the mesh after the next depsgraph update reads freed data; clearing at a known point makes the lifetime explicit
7069

7170
```python
7271
obj_eval, mesh_eval = get_evaluated_mesh(obj)
@@ -180,7 +179,7 @@ When you build your own exporter on top of `evaluated_depsgraph_get()`, the deps
180179
## Common AI mistakes
181180

182181
- **Reading `obj.data.vertices` and calling it "what the user sees"**. It is not. This is the single most common AI exporter bug.
183-
- **Forgetting `to_mesh_clear`**. The temporary mesh leaks. Cumulative leaks degrade Blender's memory headroom and can crash long-running batch jobs.
182+
- **Forgetting `to_mesh_clear`**. Each evaluated object keeps its temporary mesh until it is re-evaluated or freed. Across a whole-scene export that is a second copy of every mesh held at once.
184183
- **Storing `mesh_eval` past the cleanup point**. Once `to_mesh_clear` runs, the mesh datablock is freed. Any references become dangling.
185184
- **Calling `evaluated_get` without the depsgraph argument**. The signature is `obj.evaluated_get(depsgraph)`; passing nothing raises a `TypeError`.
186185
- **Treating `obj.data` as identical to `obj_eval.data`**. They are different mesh datablocks. The first is the source; the second is post-evaluation.

‎skills/headless-batch-scripting/SKILL.md‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ Or with arguments after `--`:
3333
blender --background scene.blend --python my_script.py -- --output out.png --frames 1,5,10
3434
```
3535

36-
`--background` (or `-b`) tells Blender not to launch a UI window. `--python` (or `-P`) runs a Python script. The script receives `sys.argv` containing everything after the `--` separator.
36+
`--background` (or `-b`) tells Blender not to launch a UI window. `--python` (or `-P`) runs a Python script. The script's `sys.argv` is Blender's **full** command line (`blender`, `--background`, `--python`, ...); slice off everything up to and including `--` to get your own arguments, as the parser below does.
37+
38+
Two flags matter for unattended runs:
39+
40+
- `--factory-startup` ignores the user's startup file and preferences (add-ons, themes, auto-run settings), so the job behaves the same on every machine.
41+
- `--python-exit-code N` makes an uncaught exception in the script exit Blender with code `N`. Without it, a traceback still exits **0**, and CI reports success.
3742

3843
## What changes without a UI
3944

@@ -68,8 +73,8 @@ for obj in list(bpy.data.objects):
6873
```
6974

7075
```python
71-
# WRONG: requires UI for the file dialog.
72-
bpy.ops.export_scene.obj(filepath="/tmp/out.obj") # may also fail because the OBJ exporter is now under wm.obj_export
76+
# WRONG: export_scene.obj was the legacy Python OBJ add-on, removed in 4.0.
77+
bpy.ops.export_scene.obj(filepath="/tmp/out.obj") # AttributeError on 4.x / 5.x
7378

7479
# RIGHT in 5.x: the new exporter operates on bpy.data, not the active object selection.
7580
bpy.ops.wm.obj_export(filepath="/tmp/out.obj", export_selected_objects=False)
@@ -201,7 +206,7 @@ if __name__ == "__main__":
201206

202207
Notes:
203208

204-
- `bpy.ops.render.render(write_still=True)` is one of the few `bpy.ops` calls that work in `--background`.
209+
- `bpy.ops.render.render(write_still=True)` works in `--background`, like other operators that do not need a window or editor area.
205210
- EEVEE's engine identifier is `BLENDER_EEVEE` on Blender 5.0+ and `BLENDER_EEVEE_NEXT` on 4.2-4.5 LTS (the id was reclaimed in 5.0 after legacy EEVEE was removed in 4.2). Detect via `bpy.app.version`.
206211

207212
## Detecting Blender version in scripts

‎skills/mesh-editing-and-bmesh/SKILL.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ Use this layer when:
6969
- You're doing structural edits (split edges, merge verts, dissolve faces).
7070
- You need iteration order to be stable mid-edit.
7171

72-
The `try`/`finally` with `bm.free()` is **mandatory**. `bmesh.new()` allocates memory in the C side that Python's garbage collector cannot reclaim. Forgetting `bm.free()` leaks BMesh structures and eventually crashes Blender. See the rule `always-free-bmesh`.
72+
The `try`/`finally` with `bm.free()` is **mandatory**. `bmesh.new()` allocates C-side storage that is released only when the wrapper is collected, and a traceback, global, closure or modal operator can keep it alive for the whole session. `free()` in `finally` releases it on every path and makes any later use of `bm` raise `ReferenceError`. See the rule `always-free-bmesh`.
7373

7474
### Layer 3: `bpy.ops.mesh.*` operators
7575

@@ -127,7 +127,7 @@ obj = bpy.data.objects["Cube"]
127127
bpy.data.objects.remove(obj, do_unlink=True)
128128
```
129129

130-
`do_unlink=True` removes the object from any collections it was linked into before deleting it. Skip it and Blender will refuse to delete an object still referenced.
130+
`do_unlink=True` removes the object from any collections it was linked into before deleting it. It is already the default (`remove(object, *, do_unlink=True, ...)`); passing it explicitly documents intent. With `do_unlink=False`, Blender refuses to delete an object that is still referenced.
131131

132132
## `foreach_set` for bulk vertex injection
133133

@@ -264,7 +264,7 @@ After mutating selection, call `bm.select_flush_mode()` if you've changed indivi
264264

265265
6. **Skipping `mesh.update()`** after `foreach_set` or vertex coordinate edits. The mesh stays out of date until the next depsgraph cycle.
266266

267-
7. **Wrong dtype in `foreach_set`** (`float64` instead of `float32`). Silently writes garbage on some platforms.
267+
7. **Mismatched dtype in `foreach_set`** (a `float64` buffer for a float32 attribute). The values are converted correctly (round-trip error about 1e-7, float32 precision, on 4.5.11 and 5.2.1), but slower: 38 ms vs 21 ms for 361k vertices on 5.2.1 (34 vs 24 ms on 4.5.11). Match the attribute's type (`float32` for `co`) for bulk speed.
268268

269269
8. **Trusting glTF as an n-gon witness.** The exporter always triangulates. A cube and a hexagon-from-dissolved-edge both ship 12 tris. Hygiene (`len(poly.vertices) <= 4`) and `Mesh.calc_tangents` (aborts on n-gons) must run on the Blender mesh. Witness: [`examples/ngon-triangulate/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/ngon-triangulate).
270270

‎skills/operators/SKILL.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,10 @@ class MESH_OT_offset_along_normals(bpy.types.Operator):
8787

8888
### `bl_options` you almost always want
8989

90-
- `'REGISTER'`: makes the operator show up in the redo panel and undo history. Without this, the operator is invisible to the user after running and properties cannot be tweaked.
90+
- `'REGISTER'`: reports the operator in the Info editor and enables the Adjust Last Operation (redo) panel. Without it, properties cannot be tweaked after running. It does not add an undo step; that is `'UNDO'`.
9191
- `'UNDO'`: hooks into the undo stack. Without this, your operator's effect cannot be undone with Ctrl-Z.
92-
- `'INTERNAL'`: hides the operator from search and help. Use for operators called only by other operators or panels.
93-
- `'BLOCKING'`: pauses other UI updates while running. Reserve for modal operators that need exclusive input.
92+
- `'INTERNAL'`: hides the operator from operator search. Use for operators called only by other operators or panels. It still runs from Python and buttons.
93+
- `'BLOCKING'`: blocks anything else from using the cursor while the operator runs. Reserve for modal operators that need exclusive mouse input.
9494
- `'GRAB_CURSOR'` and `'GRAB_CURSOR_X'` / `'GRAB_CURSOR_Y'`: useful in modal operators to capture mouse motion.
9595

9696
## Defensive context handling

0 commit comments

Comments
 (0)