From 4c48483967917a360c003063be2f11870c7e4032 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:12:52 -0400 Subject: [PATCH] fix(skills): correct driver autoexec, handler args, active-vs-selected, bake target and migration claims Several skills taught behavior that live Blender contradicts, so agents following them shipped dead drivers in shared files, AttributeErrors in pre handlers, None guards that miss an unselected active object, and bake setups that cancel on 5.x once a "redundant" tex.select line is removed. - drivers-and-app-handlers: driver_namespace calls are not simple expressions and need Python auto-execution (off by default); pre handlers receive (scene, None); exit_pre receives one bool. - operators + type-annotate-props-and-defend-context rule: active_object survives deselect-all and headless startup; active and selected are independent. - bake-high-to-low + snippet: on 5.0+ the active-texture node must also be selected; 4.5 LTS ignores selection. - bl-info-migration: replace hard-coded add-on names and submodule __name__ with __package__. - depsgraph / mesh-editing: one temporary mesh per evaluated object, not per to_mesh() call. ui-panels: drop the unverified fallback tab name. Examples gain checks with falsifiers: driver-wave (exit 5 is_simple_expression, exit 7 handler arg types), temp-override-join (exit 13 active survives deselect), bake-normal-high-to-low (--unselect-target, exit 4 on 5.0+). Signed-off-by: TMHSDigital Co-Authored-By: Claude Opus 5.5 --- claude/blender-rules.md | 2 +- claude/skills/blender-rules/SKILL.md | 2 +- .../bake-normal-high-to-low/index.html | 7 +- docs/gallery/driver-wave/index.html | 12 ++- docs/gallery/temp-override-join/index.html | 8 +- examples/bake-normal-high-to-low/README.md | 14 +++- .../bake_normal_high_to_low.py | 20 ++++- examples/driver-wave/README.md | 28 ++++++- examples/driver-wave/driver_wave.py | 80 ++++++++++++++++++- examples/index.json | 32 +++++++- examples/temp-override-join/README.md | 15 +++- .../temp-override-join/temp_override_join.py | 42 ++++++++++ ...type-annotate-props-and-defend-context.mdc | 37 ++++++--- skills/bake-high-to-low/SKILL.md | 13 ++- skills/bl-info-migration/SKILL.md | 28 +++++++ skills/depsgraph-and-evaluated-data/SKILL.md | 4 +- skills/drivers-and-app-handlers/SKILL.md | 31 ++++--- skills/mesh-editing-and-bmesh/SKILL.md | 4 +- skills/operators/SKILL.md | 15 +++- skills/ui-panels/SKILL.md | 2 +- snippets/driver-with-custom-function.py | 11 ++- snippets/setup_bake_target_image.py | 9 ++- tests/smoke/catalog.json | 6 +- 23 files changed, 361 insertions(+), 61 deletions(-) diff --git a/claude/blender-rules.md b/claude/blender-rules.md index c061b67b..64785256 100644 --- a/claude/blender-rules.md +++ b/claude/blender-rules.md @@ -37,7 +37,7 @@ Applies to: `**/__init__.py`, `**/blender_manifest.toml`. Full rule: [`rules/tar ## type-annotate-props-and-defend-context -Flag two related anti-patterns. (1) bpy.props defined as class-level assignments instead of type annotations (deprecated since 2.8). (2) Code that touches bpy.context.active_object without guarding for None. +Flag two related anti-patterns. (1) bpy.props defined as class-level assignments instead of type annotations (deprecated since 2.8). (2) Code that touches bpy.context.active_object without guarding for None, or that assumes the active object is selected. Applies to: `**/*.py`. Full rule: [`rules/type-annotate-props-and-defend-context.mdc`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/rules/type-annotate-props-and-defend-context.mdc). diff --git a/claude/skills/blender-rules/SKILL.md b/claude/skills/blender-rules/SKILL.md index d42b3e40..948fd5a7 100644 --- a/claude/skills/blender-rules/SKILL.md +++ b/claude/skills/blender-rules/SKILL.md @@ -42,7 +42,7 @@ Applies to: `**/__init__.py`, `**/blender_manifest.toml`. Full rule: [`rules/tar ## type-annotate-props-and-defend-context -Flag two related anti-patterns. (1) bpy.props defined as class-level assignments instead of type annotations (deprecated since 2.8). (2) Code that touches bpy.context.active_object without guarding for None. +Flag two related anti-patterns. (1) bpy.props defined as class-level assignments instead of type annotations (deprecated since 2.8). (2) Code that touches bpy.context.active_object without guarding for None, or that assumes the active object is selected. Applies to: `**/*.py`. Full rule: [`rules/type-annotate-props-and-defend-context.mdc`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/rules/type-annotate-props-and-defend-context.mdc). diff --git a/docs/gallery/bake-normal-high-to-low/index.html b/docs/gallery/bake-normal-high-to-low/index.html index 4be3b256..506844c5 100644 --- a/docs/gallery/bake-normal-high-to-low/index.html +++ b/docs/gallery/bake-normal-high-to-low/index.html @@ -64,7 +64,7 @@

Bake Normal High To Low

A runnable example that cage-bakes a ribbed bronze hatch plate onto a DECIMATE COLLAPSE LOD and asserts the tangent-space normal map carries measurable surface detail — following bake-high-to-low.

What it witnesses: Cycles selected-to-active normal bake is a statistical process, not a byte-identical one. A high-poly source produces a map that deviates from flat tangent (0.5, 0.5, 1.0); the same bake from an undisplaced source does not.

Byte-identity across 4.5 / 5.1 / 5.2 is not the contract. Tile order and float accumulation differ even at one CPU sample. The gates are fraction of pixels beyond Euclidean 0.04 from flat, mean absolute deviation, and a monotonic gap versus a flat control. Tolerances sit well inside the measured gap (detail frac 0.7211 vs flat 0.0000) so they are not tuned-until-green.

- +

Neighbor of lod-decimate-chain (the LOD is the cage target; collapse keeps UVs) and image-pixels-testcard (save_render, not Image.save(), if you persist the datablock). UV transfer and atlas packing are out of scope.

The still reads left to right as the pipeline: the baked map as an unlit card, the high-poly source it was baked from, and the collapse-decimated LOD wearing it. Source and LOD share one cast-bronze material, so the only difference between them is real geometry versus the map. The floor captions carry live triangle counts from the evaluated meshes (3200 vs 900). A raking key from low on the left makes both the real relief and the baked relief throw light and shade. If the bake were flat, the card would be uniform (128, 128, 255) periwinkle and the right plate would shade like the undisplaced cage while the middle one kept its ribs.

Staging is render-only. Each panel stands in a low display plinth, lifted so its lowest evaluated point sits 35 mm in the slot. Both panels used to balance on an edge, and the leaning plate pierced the floor. The LOD's solidify rim wears plain bronze instead of the baked map. The slight waviness along the plate's border is the bake itself, normal-map shading inside the UV margin, and is left as the map produces it. Bake statistics are unchanged: frac 0.7211, MAD 0.09356 on all three binaries.

@@ -76,12 +76,15 @@

Run # Falsifier: undisplaced high. Must exit non-zero (detail frac gate). blender --background --python bake_normal_high_to_low.py -- --flat-source +# Falsifier: active but deselected target node. Exits 4 on 5.x, 0 on 4.5 LTS. +blender --background --python bake_normal_high_to_low.py -- --unselect-target + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python bake_normal_high_to_low.py -- --output hatch.png blender --background --python bake_normal_high_to_low.py -- --output hatch.png --engine cycles

Exit codes#

Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.

-
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Missing UV layer on the target
4Bake did not FINISHED or image has_data is false
5Detail deviant-pixel fraction below 0.40 (--flat-source lands here)
6Detail MAD below 0.05
7Flat control above 0.05 frac / 0.03 MAD
8Monotonic gap below 0.30
9--output produced no file
10Gallery framing violation
+
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Missing UV layer on the target
4Bake did not FINISHED or image has_data is false (--unselect-target lands here on 5.x)
5Detail deviant-pixel fraction below 0.40 (--flat-source lands here)
6Detail MAD below 0.05
7Flat control above 0.05 frac / 0.03 MAD
8Monotonic gap below 0.30
9--output produced no file
10Gallery framing violation

The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output.

diff --git a/docs/gallery/driver-wave/index.html b/docs/gallery/driver-wave/index.html index f10e0711..6947b574 100644 --- a/docs/gallery/driver-wave/index.html +++ b/docs/gallery/driver-wave/index.html @@ -64,6 +64,8 @@

Driver Wave

A runnable example that drives sixteen organ-pipe heights from a custom function registered in bpy.app.driver_namespace — the pattern from drivers-and-app-handlers. Each column gets a SCRIPTED driver on Z scale whose expression calls wave_scale(i), producing a sine skyline.

What it witnesses: the driver evaluation contract. Driven values appear only after a view-layer update, and they land in two places that must agree: the depsgraph-evaluated copy (evaluated_get(dg).scale) and the original datablock, which the animation system flushes for display. The check asserts both against the closed-form profile.

Note for real add-ons: driver_namespace entries do not persist in .blend files — re-register them from a load_post handler, or every driver that calls them fails on file open. Headless, registering before driver creation (as here) is enough.

+

Two more facts are asserted:

+
  • The custom-function driver is not a simple expression. Every column's driver.is_simple_expression must be False. Only the simple-expression subset runs with Python auto-execution off, which is the default (preferences.filepaths.use_scripts_auto_execute is False on 4.5.11 and 5.2.1). A shared .blend with these drivers goes dead in a GUI session unless the file is trusted or Auto Run Python Scripts is on. --background runs do not hit that block, so this check reads the flag instead of observing a dead driver. --simple-expr writes the same profile inline as 1.4 + sin(i * 0.6). The heights still match, but the drivers are now simple, so the run exits 5.
  • Pre handlers get no depsgraph. frame_change_pre and depsgraph_update_pre are called with (scene, None). frame_change_post and depsgraph_update_post get (scene, Depsgraph). Measured on 4.5.11 and 5.2.1. --swap-handlers hangs each probe on the opposite list, so the run exits 7.

Staging#

The sixteen driven objects are the speaking pipes of a small organ facade. They share one open-tube body mesh of unit height (z 0..1), so the driven Z scale is each pipe's speaking length and the pipe tops trace wave_scale directly; the rim annulus is horizontal and stays crisp under any Z scale. Everything else — the walnut windchest and case back, the side towers with brass finials, a brass foot cone and a mouth under each pipe — is render-only staging built around the driven bodies. The case back sits behind the pipes so their tops read as a wave against wood rather than fading into the stage. The render path gates framing through examples/gallery_framing.py (exit 10) before writing the still.

Run#

@@ -73,13 +75,19 @@

Run # Falsifier: constant 1.0 expression. Must exit non-zero. blender --background --python driver_wave.py -- --flat-expr +# Falsifier: same profile as a simple expression. Must exit 5. +blender --background --python driver_wave.py -- --simple-expr + +# Falsifier: pre-handler probes on the post lists. Must exit 7. +blender --background --python driver_wave.py -- --swap-handlers + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python driver_wave.py -- --output driver.png blender --background --python driver_wave.py -- --output driver.png --engine cycles

Exit codes#

Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.

-
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Evaluated Z scale ≠ wave_scale (--flat-expr lands here)
4Original datablock was not flushed
6--output produced no file
10Gallery framing violation
-

The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output. Its catalog falsifier is --flat-expr (expects exit 3).

+
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Evaluated Z scale ≠ wave_scale (--flat-expr lands here)
4Original datablock was not flushed
5A driver reports is_simple_expression=True (--simple-expr lands here)
7Handler argument types wrong: a _pre handler got a depsgraph or a _post one did not (--swap-handlers lands here)
6--output produced no file
10Gallery framing violation
+

The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output. Its catalog falsifiers are --flat-expr (expects exit 3), --simple-expr (exit 5) and --swap-handlers (exit 7).

Source

diff --git a/docs/gallery/temp-override-join/index.html b/docs/gallery/temp-override-join/index.html index c94c0f83..353c150d 100644 --- a/docs/gallery/temp-override-join/index.html +++ b/docs/gallery/temp-override-join/index.html @@ -64,6 +64,7 @@

Temp Override Join

A runnable example that assembles a hurricane lantern from seven separate part objects with one object.join under bpy.context.temp_override, following the prefer-temp-override-over-context-copy rule and the operators skill: operators that need a fabricated active/selection context run under temp_override(**kwargs), not the deprecated bpy.context.copy() dict-pass form removed in Blender 5.x.

The parts are the way a prop artist blocks the lantern out: a red enamel fount, an amber glass globe, an iron wire guard, the two hurricane side air tubes, the bell cap, the wire bail with its wooden grip, and the brass wick knob and filler cap. Each is its own object with its own mesh, materials and transform. The join produces the single Lantern object an engine wants.

What it witnesses: object.join under temp_override actually consumes the sources and merges their material slots. The check asserts that exactly one mesh remains and it is the target, that all six sources are gone, and that no geometry was lost (verts and faces equal the sum over the parts). The joined mesh must carry exactly the five part materials, once each, and every material must still own exactly the faces its parts brought. So the glass is still glass and the grip is still wood. The local Z span must run from the fount's foot (0) to the top of the grip (closed form 2.303), which proves the part transforms were applied. A no-op override (the 5.x failure mode of the old dict-pass path) leaves seven objects.

+

Why the override passes the selection explicitly. Before the join, the check makes the target active, selects every part, and runs select_all(action='DESELECT'). The target is still context.active_object while selected_objects is empty and target.select_get() is False. Active and selected are independent, so an if obj is None guard passes on a selection that is gone (see the operators skill, Defensive context handling). The scene is then reset to no active object before the join. --clear-active-on-deselect models the wrong belief that deselecting clears the active object, and exits 13.

The render is the joined object. Everything in the still is one mesh object. It shows five materials because the slots merged and the per-face indices were remapped. If they had not been, the lantern would render in the target's red enamel from grip to foot. A shadowless warm point light inside the globe (render-only) stands in for the lit wick.

Run#

# Cheap correctness check (no render) — the CI check:
@@ -72,13 +73,16 @@ 

Run # Falsifier: join without temp_override. Must exit non-zero. blender --background --python temp_override_join.py -- --no-override +# Falsifier: pretend deselect-all clears the active object. Must exit 13. +blender --background --python temp_override_join.py -- --clear-active-on-deselect + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python temp_override_join.py -- --output join.png blender --background --python temp_override_join.py -- --output join.png --engine cycles

Exit codes#

Per-script sequential checks. 9 is a valid check code; there is no rule against it.

-
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Mesh object count after join ≠ 1 (--no-override lands here)
4Joined target is not the sole remaining mesh
5Verts / faces ≠ the sum over the seven parts
6Source objects still present
7Local Z span ≠ [0, 2.303]: a part transform was not applied
8Material slots ≠ the five part materials, once each
9Faces per material ≠ what the parts brought (indices not remapped)
10--output framing gate (gallery_framing)
11--output asset-quality floors (gallery_asset_quality)
12--output produced no file
-

The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output. Its catalog falsifier is --no-override (expects exit 3).

+
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Mesh object count after join ≠ 1 (--no-override lands here)
4Joined target is not the sole remaining mesh
5Verts / faces ≠ the sum over the seven parts
6Source objects still present
7Local Z span ≠ [0, 2.303]: a part transform was not applied
8Material slots ≠ the five part materials, once each
9Faces per material ≠ what the parts brought (indices not remapped)
10--output framing gate (gallery_framing)
11--output asset-quality floors (gallery_asset_quality)
12--output produced no file
13After deselect-all the target is not still active-but-unselected, or something is still selected (--clear-active-on-deselect lands here)
+

The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output. Its catalog falsifiers are --no-override (expects exit 3) and --clear-active-on-deselect (exit 13).

Source

diff --git a/examples/bake-normal-high-to-low/README.md b/examples/bake-normal-high-to-low/README.md index 1a249510..6fa0576f 100644 --- a/examples/bake-normal-high-to-low/README.md +++ b/examples/bake-normal-high-to-low/README.md @@ -31,6 +31,15 @@ tuned-until-green. - **`--flat-source` is the falsifier.** Skips the ribs and still runs the detail gates. Must exit 5. Analogous to `--same-axis` in [`export-preset-axis`](../export-preset-axis/). +- **The target node must be selected (5.0 and later).** The bake writes into + the material's active Image Texture node, and on 5.x only if that node is + also selected. `--unselect-target` keeps `nodes.active = tex` but sets + `tex.select = False`. Measured: 5.0.1, 5.1.2 and 5.2.1 return + `{'CANCELLED'}` with Info "No active and selected image texture node + found" and leave the pixels untouched, so the run exits 4. On 4.5.11 the + bake ignores selection, finishes, and the run **exits 0**. The catalog + falsifier therefore carries `"min_version": "5.0"` and is recorded as SKIP + on 4.5. Neighbor of [`lod-decimate-chain`](../lod-decimate-chain/) (the LOD is the cage target; collapse keeps UVs) and [`image-pixels-testcard`](../image-pixels-testcard/) @@ -68,6 +77,9 @@ blender --background --python bake_normal_high_to_low.py -- # Falsifier: undisplaced high. Must exit non-zero (detail frac gate). blender --background --python bake_normal_high_to_low.py -- --flat-source +# Falsifier: active but deselected target node. Exits 4 on 5.x, 0 on 4.5 LTS. +blender --background --python bake_normal_high_to_low.py -- --unselect-target + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python bake_normal_high_to_low.py -- --output hatch.png blender --background --python bake_normal_high_to_low.py -- --output hatch.png --engine cycles @@ -84,7 +96,7 @@ against it. `10` is the shared framing helper. | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Missing UV layer on the target | -| 4 | Bake did not `FINISHED` or image `has_data` is false | +| 4 | Bake did not `FINISHED` or image `has_data` is false (`--unselect-target` lands here on 5.x) | | 5 | Detail deviant-pixel fraction below 0.40 (`--flat-source` lands here) | | 6 | Detail MAD below 0.05 | | 7 | Flat control above 0.05 frac / 0.03 MAD | diff --git a/examples/bake-normal-high-to-low/bake_normal_high_to_low.py b/examples/bake-normal-high-to-low/bake_normal_high_to_low.py index a03aff51..d583c2ac 100644 --- a/examples/bake-normal-high-to-low/bake_normal_high_to_low.py +++ b/examples/bake-normal-high-to-low/bake_normal_high_to_low.py @@ -9,6 +9,11 @@ 2. The same bake from an undisplaced source does not. 3. ``--flat-source`` skips the ribs and rivets and still runs the *detail* gates, so the assertion fails. That is the falsifier (``--same-axis`` in export-preset-axis). +4. The bake target is the material's active Image Texture node and, on 5.0 and + later, it must also be selected. ``--unselect-target`` clears ``tex.select`` + after setup: 5.x returns ``{'CANCELLED'}`` ("No active and selected image + texture node found") and the run exits 4. 4.5 LTS ignores selection, bakes, + and exits 0, so the catalog falsifier carries ``min_version`` 5.0. Operator RNA is ``type='NORMAL'``, not ``bake_type``. Identifiers match on 4.5.11, 5.1.2, and 5.2.1 — no shim. @@ -18,6 +23,7 @@ blender --background --python bake_normal_high_to_low.py -- blender --background --python bake_normal_high_to_low.py -- --flat-source + blender --background --python bake_normal_high_to_low.py -- --unselect-target blender --background --python bake_normal_high_to_low.py -- --output p.png """ import argparse @@ -256,7 +262,7 @@ def new_image(name, size=BAKE_RES): return img -def check(flat_source): +def check(flat_source, unselect_target=False): base = make_hatch("BakeBase") if not base.data.uv_layers: return fail("base mesh has no UV layer", 3), None, None, None, None @@ -274,10 +280,13 @@ def check(flat_source): img_detail, mat, tex = setup_bake_target(low, "BakeNrmDetail") if img_detail is None: return fail("low mesh has no UV layer", 3), None, None, None, None + if unselect_target: + tex.select = False # still nodes.active; 5.x needs it selected too result = bake_normal(high_detail, low) if result != {"FINISHED"}: - return fail(f"detail bake returned {result}", 4), None, None, None, None + return fail(f"detail bake returned {result} (target node active={mat.node_tree.nodes.active == tex} " + f"selected={tex.select})", 4), None, None, None, None if not img_detail.has_data: return fail("detail bake image has_data is False", 4), None, None, None, None @@ -608,10 +617,15 @@ def main(): action="store_true", help="bake from undisplaced high; detail gates must fail", ) + p.add_argument( + "--unselect-target", + action="store_true", + help="deselect the active bake-target node; 5.x bake must cancel (exit 4)", + ) args = p.parse_args(argv) bpy.ops.wm.read_factory_settings(use_empty=True) - code, high, low, img, mat = check(args.flat_source) + code, high, low, img, mat = check(args.flat_source, args.unselect_target) if code: return code diff --git a/examples/driver-wave/README.md b/examples/driver-wave/README.md index 787ff8fc..602d1ce8 100644 --- a/examples/driver-wave/README.md +++ b/examples/driver-wave/README.md @@ -15,6 +15,24 @@ Note for real add-ons: `driver_namespace` entries do **not** persist in `.blend` re-register them from a `load_post` handler, or every driver that calls them fails on file open. Headless, registering before driver creation (as here) is enough. +Two more facts are asserted: + +- **The custom-function driver is not a simple expression.** Every column's + `driver.is_simple_expression` must be `False`. Only the + [simple-expression subset](https://docs.blender.org/manual/en/latest/animation/drivers/troubleshooting.html) + runs with Python auto-execution off, which is the default + (`preferences.filepaths.use_scripts_auto_execute` is `False` on 4.5.11 and + 5.2.1). A shared `.blend` with these drivers goes dead in a GUI session + unless the file is trusted or Auto Run Python Scripts is on. `--background` + runs do not hit that block, so this check reads the flag instead of + observing a dead driver. `--simple-expr` writes the same profile inline as + `1.4 + sin(i * 0.6)`. The heights still match, but the drivers are now + simple, so the run exits 5. +- **Pre handlers get no depsgraph.** `frame_change_pre` and + `depsgraph_update_pre` are called with `(scene, None)`. `frame_change_post` + and `depsgraph_update_post` get `(scene, Depsgraph)`. Measured on 4.5.11 and + 5.2.1. `--swap-handlers` hangs each probe on the opposite list, so the run exits 7. + ## Staging The sixteen driven objects are the speaking pipes of a small organ facade. They share one @@ -35,6 +53,12 @@ blender --background --python driver_wave.py -- # Falsifier: constant 1.0 expression. Must exit non-zero. blender --background --python driver_wave.py -- --flat-expr +# Falsifier: same profile as a simple expression. Must exit 5. +blender --background --python driver_wave.py -- --simple-expr + +# Falsifier: pre-handler probes on the post lists. Must exit 7. +blender --background --python driver_wave.py -- --swap-handlers + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python driver_wave.py -- --output driver.png blender --background --python driver_wave.py -- --output driver.png --engine cycles @@ -52,10 +76,12 @@ against it. `10` is the shared framing helper. | 2 | argparse / usage | | 3 | Evaluated Z scale ≠ `wave_scale` (`--flat-expr` lands here) | | 4 | Original datablock was not flushed | +| 5 | A driver reports `is_simple_expression=True` (`--simple-expr` lands here) | +| 7 | Handler argument types wrong: a `_pre` handler got a depsgraph or a `_post` one did not (`--swap-handlers` lands here) | | 6 | `--output` produced no file | | 10 | Gallery framing violation | The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). -Smoke does not pass `--output`. Its catalog falsifier is `--flat-expr` (expects exit 3). +Smoke does not pass `--output`. Its catalog falsifiers are `--flat-expr` (expects exit 3), `--simple-expr` (exit 5) and `--swap-handlers` (exit 7). diff --git a/examples/driver-wave/driver_wave.py b/examples/driver-wave/driver_wave.py index 9ee6acec..41a1f21b 100644 --- a/examples/driver-wave/driver_wave.py +++ b/examples/driver-wave/driver_wave.py @@ -11,11 +11,25 @@ ``--flat-expr`` drives Z scale with ``1.0`` and still asserts ``wave_scale``. That is the falsifier (``--same-axis`` in export-preset-axis). +Two more contracts ride along: + +- A driver that calls a ``driver_namespace`` function is NOT a simple + expression (``driver.is_simple_expression`` is False), so it only runs + where Python auto-execution is allowed; in a GUI session with Auto Run + Python Scripts off (the default) it goes dead. ``--simple-expr`` writes the + same profile inline as ``1.4 + sin(i * 0.6)``: values still match, but the + driver is now simple and the check exits 5. +- ``frame_change_pre`` and ``depsgraph_update_pre`` receive ``(scene, None)``; + only the ``_post`` variants get a Depsgraph. ``--swap-handlers`` registers + the pre probe on the post lists (and vice versa) and the check exits 7. + By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python driver_wave.py -- # check only blender --background --python driver_wave.py -- --flat-expr # must fail + blender --background --python driver_wave.py -- --simple-expr # must fail (5) + blender --background --python driver_wave.py -- --swap-handlers # must fail (7) blender --background --python driver_wave.py -- --output d.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -65,7 +79,7 @@ def lathe(bm, profile, segs=32): return rings -def build_columns(flat_expr=False): +def build_columns(flat_expr=False, simple_expr=False): bpy.ops.wm.read_factory_settings(use_empty=True) # driver_namespace entries do not persist in .blend files; real add-ons # re-register them from a load_post handler. Headless, registering before @@ -94,7 +108,12 @@ def build_columns(flat_expr=False): obj.scale = (BASE, BASE, 1.0) fcu = obj.driver_add("scale", 2) fcu.driver.type = 'SCRIPTED' - fcu.driver.expression = "1.0" if flat_expr else f"wave_scale({i})" + if flat_expr: + fcu.driver.expression = "1.0" + elif simple_expr: + fcu.driver.expression = f"1.4 + sin({i} * 0.6)" # same profile, inline + else: + fcu.driver.expression = f"wave_scale({i})" bpy.context.collection.objects.link(obj) objs.append(obj) return objs @@ -115,9 +134,55 @@ def check(objs): print(f"ERROR: col {i} original scale {obj.scale[2]:.4f} not flushed " f"(expected {expect:.4f})", file=sys.stderr) return 4 + # a driver_namespace call is never a "simple expression": it needs Python + # auto-execution, which a GUI session has off by default + simple = [obj.animation_data.drivers[0].driver.is_simple_expression for obj in objs] + if any(simple): + print(f"ERROR: {sum(simple)}/{COUNT} drivers report is_simple_expression=True; " + "the custom-function driver must not be a simple expression", file=sys.stderr) + return 5 lo = min(wave_scale(i) for i in range(COUNT)) hi = max(wave_scale(i) for i in range(COUNT)) - print(f"columns={COUNT} driven_range={lo:.3f}..{hi:.3f} flushed_to_original=True") + print(f"columns={COUNT} driven_range={lo:.3f}..{hi:.3f} flushed_to_original=True " + f"is_simple_expression=False") + return 0 + + +PRE_HANDLERS = ("frame_change_pre", "depsgraph_update_pre") +POST_HANDLERS = ("frame_change_post", "depsgraph_update_post") + + +def check_handler_args(objs, swap=False): + """The pre handlers get (scene, None); the post handlers get (scene, Depsgraph).""" + seen = {} + + def probe(name): + def handler(scene, depsgraph=None): + seen.setdefault(name, (type(scene).__name__, type(depsgraph).__name__)) + return handler + + registered = [] + for name in PRE_HANDLERS + POST_HANDLERS: + # --swap-handlers hangs each probe on its opposite list + target = name.replace("_pre", "_post") if name.endswith("_pre") else name.replace("_post", "_pre") + handler_list = getattr(bpy.app.handlers, target if swap else name) + h = probe(name) + handler_list.append(h) + registered.append((handler_list, h)) + try: + scene = bpy.context.scene + scene.frame_set(scene.frame_current + 1) + objs[0].update_tag() # a real edit, so the depsgraph_update pair fires too + bpy.context.view_layer.update() + finally: + for handler_list, h in registered: + handler_list.remove(h) + want = {n: ("Scene", "NoneType") for n in PRE_HANDLERS} + want.update({n: ("Scene", "Depsgraph") for n in POST_HANDLERS}) + if seen != want: + print(f"ERROR: handler argument types {seen} != {want}", file=sys.stderr) + return 7 + print("handler args: " + ", ".join(f"{n}={seen[n][1]}" for n in PRE_HANDLERS + POST_HANDLERS)) return 0 @@ -366,10 +431,17 @@ def main(): help="render engine for --output (cycles for GPU-less hosts)") p.add_argument("--flat-expr", action="store_true", help="drive Z scale with 1.0 (must fail)") + p.add_argument("--simple-expr", action="store_true", + help="inline the profile as a simple expression (must fail, exit 5)") + p.add_argument("--swap-handlers", action="store_true", + help="register the pre-handler probes on the post lists (must fail, exit 7)") args = p.parse_args(argv) - objs = build_columns(flat_expr=args.flat_expr) + objs = build_columns(flat_expr=args.flat_expr, simple_expr=args.simple_expr) code = check(objs) + if code: + return code + code = check_handler_args(objs, swap=args.swap_handlers) if code: return code diff --git a/examples/index.json b/examples/index.json index 9758dad8..9eb950bf 100644 --- a/examples/index.json +++ b/examples/index.json @@ -83,6 +83,13 @@ "--flat-source" ], "expect_exit": 5 + }, + { + "args": [ + "--unselect-target" + ], + "expect_exit": 4, + "min_version": "5.0" } ], "exit_codes": { @@ -90,7 +97,7 @@ "1": "Uncaught exception (FATAL wrapper)", "2": "argparse / usage", "3": "Missing UV layer on the target", - "4": "Bake did not `FINISHED` or image `has_data` is false", + "4": "Bake did not `FINISHED` or image `has_data` is false (`--unselect-target` lands here on 5.x)", "5": "Detail deviant-pixel fraction below 0.40 (`--flat-source` lands here)", "6": "Detail MAD below 0.05", "7": "Flat control above 0.05 frac / 0.03 MAD", @@ -557,6 +564,18 @@ "--flat-expr" ], "expect_exit": 3 + }, + { + "args": [ + "--simple-expr" + ], + "expect_exit": 5 + }, + { + "args": [ + "--swap-handlers" + ], + "expect_exit": 7 } ], "exit_codes": { @@ -565,6 +584,8 @@ "2": "argparse / usage", "3": "Evaluated Z scale ≠ `wave_scale` (`--flat-expr` lands here)", "4": "Original datablock was not flushed", + "5": "A driver reports `is_simple_expression=True` (`--simple-expr` lands here)", + "7": "Handler argument types wrong: a `_pre` handler got a depsgraph or a `_post` one did not (`--swap-handlers` lands here)", "6": "`--output` produced no file", "10": "Gallery framing violation" } @@ -1851,6 +1872,12 @@ "--no-override" ], "expect_exit": 3 + }, + { + "args": [ + "--clear-active-on-deselect" + ], + "expect_exit": 13 } ], "exit_codes": { @@ -1866,7 +1893,8 @@ "9": "Faces per material ≠ what the parts brought (indices not remapped)", "10": "`--output` framing gate (`gallery_framing`)", "11": "`--output` asset-quality floors (`gallery_asset_quality`)", - "12": "`--output` produced no file" + "12": "`--output` produced no file", + "13": "After deselect-all the target is not still active-but-unselected, or something is still selected (`--clear-active-on-deselect` lands here)" } }, { diff --git a/examples/temp-override-join/README.md b/examples/temp-override-join/README.md index 173f7dd9..f52bbfc2 100644 --- a/examples/temp-override-join/README.md +++ b/examples/temp-override-join/README.md @@ -23,6 +23,15 @@ foot (0) to the top of the grip (closed form 2.303), which proves the part trans applied. A no-op override (the 5.x failure mode of the old dict-pass path) leaves seven objects. +**Why the override passes the selection explicitly.** Before the join, the check makes the +target active, selects every part, and runs `select_all(action='DESELECT')`. The target is +still `context.active_object` while `selected_objects` is empty and +`target.select_get()` is `False`. Active and selected are independent, so an +`if obj is None` guard passes on a selection that is gone (see the +[`operators`](../../skills/operators/SKILL.md) skill, Defensive context handling). The scene +is then reset to no active object before the join. `--clear-active-on-deselect` models the +wrong belief that deselecting clears the active object, and exits 13. + **The render is the joined object.** Everything in the still is one mesh object. It shows five materials because the slots merged and the per-face indices were remapped. If they had not been, the lantern would render in the target's red enamel from grip to foot. A shadowless @@ -37,6 +46,9 @@ blender --background --python temp_override_join.py -- # Falsifier: join without temp_override. Must exit non-zero. blender --background --python temp_override_join.py -- --no-override +# Falsifier: pretend deselect-all clears the active object. Must exit 13. +blender --background --python temp_override_join.py -- --clear-active-on-deselect + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python temp_override_join.py -- --output join.png blender --background --python temp_override_join.py -- --output join.png --engine cycles @@ -62,8 +74,9 @@ against it. | 10 | `--output` framing gate (`gallery_framing`) | | 11 | `--output` asset-quality floors (`gallery_asset_quality`) | | 12 | `--output` produced no file | +| 13 | After deselect-all the target is not still active-but-unselected, or something is still selected (`--clear-active-on-deselect` lands here) | The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). -Smoke does not pass `--output`. Its catalog falsifier is `--no-override` (expects exit 3). +Smoke does not pass `--output`. Its catalog falsifiers are `--no-override` (expects exit 3) and `--clear-active-on-deselect` (exit 13). diff --git a/examples/temp-override-join/temp_override_join.py b/examples/temp-override-join/temp_override_join.py index 38b3cfc6..b3136ddc 100644 --- a/examples/temp-override-join/temp_override_join.py +++ b/examples/temp-override-join/temp_override_join.py @@ -24,11 +24,21 @@ operator raises, that is caught and the existing object-count check still runs. That is the falsifier (``--same-axis`` in export-preset-axis). +Before the join, the check also asserts why the override has to pass the +selection explicitly: active and selected are independent. With the target +made active and every part selected, ``select_all(action='DESELECT')`` +leaves ``context.active_object`` set to the target while +``selected_objects`` is empty, so an ``if obj is None`` guard passes on a +selection that is gone. The scene state is then reset to no active object +and nothing selected, as before. ``--clear-active-on-deselect`` models the +wrong belief (deselect clears the active object) and exits 13. + By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python temp_override_join.py -- # check only blender --background --python temp_override_join.py -- --no-override # must fail + blender --background --python temp_override_join.py -- --clear-active-on-deselect # 13 blender --background --python temp_override_join.py -- --output j.png # + render """ import argparse @@ -362,6 +372,33 @@ def join_with_temp_override(target, sources): return target +def check_active_survives_deselect(objs, clear_active=False): + """Active and selected are independent: deselect-all keeps the active object.""" + view_layer = bpy.context.view_layer + view_layer.objects.active = objs[0] + for ob in objs: + ob.select_set(True) + bpy.ops.object.select_all(action='DESELECT') + if clear_active: + view_layer.objects.active = None # the wrong mental model, made true + active = bpy.context.active_object + if active is None: + active_name = None # what the wrong mental model predicts + else: + active_name = active.name + selected = list(bpy.context.selected_objects) + unselected_active = active is objs[0] and not objs[0].select_get() + # restore the build state: nothing active, nothing selected + view_layer.objects.active = None + if not unselected_active or selected: + print(f"ERROR: after select_all(DESELECT) active={active_name} " + f"selected={[o.name for o in selected]}; expected active={objs[0].name} " + f"(unselected) and no selection", file=sys.stderr) + return 13 + print(f"after deselect-all: active={objs[0].name} select_get=False selected=0") + return 0 + + def check(joined, source_names, expect): mesh_objs = [o for o in bpy.data.objects if o.type == 'MESH'] if len(mesh_objs) != 1: @@ -528,6 +565,8 @@ def main(): help="render engine for --output (cycles for GPU-less hosts)") p.add_argument("--no-override", action="store_true", help="join without temp_override (must fail)") + p.add_argument("--clear-active-on-deselect", action="store_true", + help="clear the active object after deselect-all (must fail, exit 13)") args = p.parse_args(argv) bpy.ops.wm.read_factory_settings(use_empty=True) @@ -539,6 +578,9 @@ def main(): "faces": sum(len(o.data.polygons) for o in objs), "mat_faces": material_faces(objs), } + code = check_active_survives_deselect(objs, args.clear_active_on_deselect) + if code: + return code if args.no_override: try: bpy.ops.object.join() diff --git a/rules/type-annotate-props-and-defend-context.mdc b/rules/type-annotate-props-and-defend-context.mdc index 5f0ff1d8..89996d1a 100644 --- a/rules/type-annotate-props-and-defend-context.mdc +++ b/rules/type-annotate-props-and-defend-context.mdc @@ -1,5 +1,5 @@ --- -description: Flag two related anti-patterns. (1) bpy.props defined as class-level assignments instead of type annotations (deprecated since 2.8). (2) Code that touches bpy.context.active_object without guarding for None. +description: Flag two related anti-patterns. (1) bpy.props defined as class-level assignments instead of type annotations (deprecated since 2.8). (2) Code that touches bpy.context.active_object without guarding for None, or that assumes the active object is selected. alwaysApply: false globs: - "**/*.py" @@ -60,15 +60,29 @@ class MyOperator(bpy.types.Operator): ## Discipline 2: defend against None context -`bpy.context.active_object` returns `None` when: - -- The user has nothing selected. -- The active scene has no objects. -- Blender is running in `--background` mode without an active scene. -- The user is in a context where the active object is hidden or - unavailable (some sub-areas). - -`context.selected_objects` returns `[]` in similar situations. +`bpy.context.active_object` returns `None` when there is no active +object: + +- The scene is empty (for example after + `read_factory_settings(use_empty=True)`). +- The active object was deleted. +- A context override passes `active_object=None`, or the current area + has no object context. + +It is **not** `None` merely because nothing is selected or because +Blender runs in `--background`. Measured on 4.5.11 and 5.2.1 with +`--background --factory-startup`: the startup `Cube` is active, and it +stays active after `select_all(action='DESELECT')` while +`selected_objects` is `[]`. + +**Active and selected are independent.** The `None` guard below is +necessary but not sufficient. An object can be active and unselected, +hidden, or linked from a library. Operators that act on the selection +then do nothing, or act on the wrong objects, without raising (measured: +`bpy.ops.object.delete()` with an active but deselected Cube returns +`{'CANCELLED'}`). When the operation needs selection, check +`obj.select_get()` or iterate `context.selected_objects`. Check +`obj.visible_get()`, and `obj.library is None` before writing to it. ```python # WRONG: AttributeError on None, often surfaces only in headless or @@ -106,6 +120,9 @@ class MyOperator(bpy.types.Operator): if obj is None: self.report({'ERROR'}, "No active object") return {'CANCELLED'} + if not obj.select_get(): + self.report({'ERROR'}, f"{obj.name} is active but not selected") + return {'CANCELLED'} obj.location.z += 1.0 return {'FINISHED'} ``` diff --git a/skills/bake-high-to-low/SKILL.md b/skills/bake-high-to-low/SKILL.md index 33376ad7..49ddcecc 100644 --- a/skills/bake-high-to-low/SKILL.md +++ b/skills/bake-high-to-low/SKILL.md @@ -19,15 +19,19 @@ This skill is the bake step. It composes `ai-mesh-cleanup` (identity scale, appl ## The core misunderstanding -Baking is not a render. EEVEE has no bake path. The operator writes into an **Image Texture node** in the **active** object's material (the active one, `nodes.active`, when the material has several), not into a file and not into "the selected image datablock." The failure modes are not equally loud. Measured on 4.5.11 LTS and 5.2.1 LTS: +Baking is not a render. EEVEE has no bake path. The operator writes into an **Image Texture node** in the **active** object's material, not into a file and not into "the selected image datablock." The node it picks is the material's *active texture* node, and on 5.x that node must also be **selected**. The active-texture node is the Image Texture node most recently made `nodes.active`, or the first one created if none ever was. Making a non-texture node (Principled, Output) active later does not move it. + +The safe pattern sets both: `nodes.active = tex` and `tex.select = True`. The failure modes are not equally loud. Measured on 4.5.11 LTS and 5.2.1 LTS (the selection rows also on 5.0.1 and 5.1.2): | Setup mistake | What `bpy.ops.object.bake` does | | --- | --- | | Target mesh has no UV layer | Raises `RuntimeError: No active UV layer found in the object "Low"` | | Target material has no Image Texture node | Returns `{'CANCELLED'}`, **raises nothing**, writes nothing | -| The only Image Texture node is not `nodes.active` | Bakes into it anyway (`{'FINISHED'}`) | +| Image Texture node is active but `tex.select = False` | **5.0 and later:** `{'CANCELLED'}`, Info `No active and selected image texture node found in material ...`, pixels untouched. **4.5 LTS:** `{'FINISHED'}`, bakes into it anyway | +| Two Image Texture nodes, the one you mean is selected but the *other* is the active texture | **5.0 and later:** `{'CANCELLED'}`, same Info. **4.5 LTS:** bakes into the active texture node, not the selected one | +| The only Image Texture node is selected but `nodes.active` is Principled | Bakes into it (`{'FINISHED'}`); it is still the active texture node | -So a headless job must check the returned set: `{'CANCELLED'}` is the silent one. With several Image Texture nodes, set `nodes.active` to the one you mean. +So a headless job must check the returned set: `{'CANCELLED'}` is the silent one. Do not "clean up" a `tex.select = True` line that looks redundant: on 5.x it is what lets the bake find its target. With several Image Texture nodes, make the one you mean `nodes.active` and selected. The pass type RNA is `type`, not `bake_type`. `bake_type` is not on `bpy.ops.object.bake`. Passing it is a TypeError. @@ -84,7 +88,7 @@ else: obj.data.materials.append(mat) ``` -The Image Texture does **not** need to be linked into Principled for the bake to land. It must be `nodes.active`. After the bake, wire `ShaderNodeNormalMap` for display or export; do not plug Image Texture Color into Principled Normal. +The Image Texture does **not** need to be linked into Principled for the bake to land. It must be the active texture node (`nodes.active = tex`) and, on 5.x, selected (`tex.select = True`). A new node is created selected, but code that deselects nodes or edits the tree afterwards can clear that. After the bake, wire `ShaderNodeNormalMap` for display or export; do not plug Image Texture Color into Principled Normal. Snippet: [`snippets/setup_bake_target_image.py`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/snippets/setup_bake_target_image.py). @@ -150,6 +154,7 @@ Snippet: [`snippets/save_baked_image.py`](https://github.com/TMHSDigital/Blender | GPU / default device in CI | `scene.cycles.device = 'CPU'` | | Low selected, high active | High selected, low **active** | | Image Texture present but not `nodes.active` | Set `nodes.active = tex` | +| Image Texture active but deselected (5.x `CANCELLED`) | Also set `tex.select = True` | | No UV layer | Confirm `mesh.uv_layers` before bake | | `Image.save()` after bake | `Image.save_render(path)` | | Hash / byte-compare maps across versions | Fraction / MAD vs `(0.5, 0.5, 1.0)` plus a flat-source control | diff --git a/skills/bl-info-migration/SKILL.md b/skills/bl-info-migration/SKILL.md index b69e04f1..8db8737a 100644 --- a/skills/bl-info-migration/SKILL.md +++ b/skills/bl-info-migration/SKILL.md @@ -105,6 +105,34 @@ operators = importlib.import_module(f"{__package__}.operators") Both forms are stable across the legacy and Extensions Platform load paths. +### Step 4: Replace hard-coded add-on names with `__package__` + +Imports are not the only place the old module name hides. Any string literal that names the add-on breaks the same way after migration, because the module is now `bl_ext..`: + +```python +# Before: breaks once installed as an extension +prefs = context.preferences.addons["my_addon"].preferences # KeyError + +class MyAddonPrefs(bpy.types.AddonPreferences): + bl_idname = __name__ # wrong when this class lives in a submodule +``` + +```python +# After: __package__ carries the full runtime name +prefs = context.preferences.addons[__package__].preferences + +class MyAddonPrefs(bpy.types.AddonPreferences): + bl_idname = __package__ +``` + +Measured on 5.2.1 LTS with an installed test extension (`bl_ext.user_default.probe_good`): + +- `context.preferences.addons["probe_good"]` raises `KeyError`. The add-on is listed only under `bl_ext.user_default.probe_good`. +- In a submodule `prefs.py`, `__name__` is `bl_ext.user_default.probe_good.prefs` and `__package__` is `bl_ext.user_default.probe_good`. +- An `AddonPreferences` subclass with `bl_idname = __name__` in that submodule still registers without an error, but `addons[pkg].preferences` is `None`. The preferences silently disappear. With `bl_idname = __package__` it returns the class instance. + +`__package__` names the root only one level down. In a deeper module such as `ui/panels.py` it is `bl_ext...ui`. Take the root name from the top-level `__init__.py` (where `__package__ == __name__`) and pass it down, rather than slicing strings. Grep the add-on for its old name in quotes (`"my_addon"`, `'my_addon'`) and for `bl_idname = __name__`. The [`custom-properties`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/skills/custom-properties/SKILL.md) skill shows the `AddonPreferences` pattern with `__package__`. + ## Worked example: before and after ### Before (legacy, single file `__init__.py`) diff --git a/skills/depsgraph-and-evaluated-data/SKILL.md b/skills/depsgraph-and-evaluated-data/SKILL.md index c1ff9176..fb3b901d 100644 --- a/skills/depsgraph-and-evaluated-data/SKILL.md +++ b/skills/depsgraph-and-evaluated-data/SKILL.md @@ -62,7 +62,7 @@ Three steps: ## The lifetime rule (critical) -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: +Every `to_mesh()` must be paired with a `to_mesh_clear()`. One temporary mesh is held per evaluated object until `to_mesh_clear()` or re-evaluation. A second `to_mesh()` on the same object frees the previous temporary mesh and returns a fresh one, so the earlier Python reference raises `ReferenceError` (measured on 4.5.11 and 5.2.1). A missing clear therefore does not multiply per call on one object. Looping over *many objects* without clearing holds one per object. If you skip the clear: - 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) - Code that keeps using the mesh after the next depsgraph update reads freed data; clearing at a known point makes the lifetime explicit @@ -184,7 +184,7 @@ When you build your own exporter on top of `evaluated_depsgraph_get()`, the deps - **Calling `evaluated_get` without the depsgraph argument**. The signature is `obj.evaluated_get(depsgraph)`; passing nothing raises a `TypeError`. - **Treating `obj.data` as identical to `obj_eval.data`**. They are different mesh datablocks. The first is the source; the second is post-evaluation. - **Using the raw object's `matrix_world` after evaluating**. `obj.matrix_world` and `obj_eval.matrix_world` may differ (parent constraints evaluate during depsgraph). Use `obj_eval.matrix_world` for world-space positions. -- **Calling `to_mesh()` inside a tight loop without clearing**. Each iteration leaks a temp mesh. Even with the right intent, this exhausts memory fast. +- **Looping over many objects with `to_mesh()` and no clear**. One temporary mesh is held per evaluated object until `to_mesh_clear()` or re-evaluation, so a scene-wide loop ends up holding an evaluated copy of every mesh at once. Repeated calls on the *same* object do not stack; each frees the previous one. - **USD `evaluation_mode` without `export_subdivision='TESSELLATE'`**. Default `BEST_MATCH` writes the cage plus `subdivisionScheme = catmullClark`, so RENDER and VIEWPORT files match and the mode looks like a no-op. - **`export_apply=True` as "apply object transforms".** RNA is "Apply modifiers (excluding Armatures) to mesh objects". Unapplied non-uniform object scale lands on the glTF node, Y-up permuted `(sx, sz, sy)`; POSITION stays local. Witness: [`examples/unapplied-scale-gltf/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/unapplied-scale-gltf). diff --git a/skills/drivers-and-app-handlers/SKILL.md b/skills/drivers-and-app-handlers/SKILL.md index 53902860..622fa391 100644 --- a/skills/drivers-and-app-handlers/SKILL.md +++ b/skills/drivers-and-app-handlers/SKILL.md @@ -76,13 +76,24 @@ Common variable types: ### The expression security model -Driver expressions run on every depsgraph evaluation. Blender restricts what they can call: +Driver expressions run on every depsgraph evaluation. A SCRIPTED expression is one of two kinds, and they are trusted differently: -- Built-in math is allowed: `+`, `-`, `*`, `/`, `**`, `%`. -- A whitelist of math functions: `sin`, `cos`, `sqrt`, `pi`, `radians`, etc. (see the docs for the full list). -- **Arbitrary Python is blocked.** Function calls to user-defined functions are blocked unless the function is registered in `bpy.app.driver_namespace`. +1. **Simple expressions always run.** Blender evaluates the documented simple subset (arithmetic, comparisons, driver variables, `frame`, and whitelisted math such as `sin`, `cos`, `sqrt`, `pi`, `radians`, `min`, `max`) without Python, so they run even with script auto-execution off. +2. **Everything else needs Python auto-execution.** That includes **every call to a `bpy.app.driver_namespace` function** and any attribute access such as `bpy.context.scene.frame_current`. Blender ships with auto-execution off (`preferences.filepaths.use_scripts_auto_execute` is `False` on a factory 4.5.11 and 5.2.1). A user who opens your `.blend` in a normal GUI session gets dead drivers unless they open it as *Trusted Source*, enable *Preferences > Save & Load > Auto Run Python Scripts*, or launch with `--enable-autoexec` / `-y`. See the manual's [Drivers > Troubleshooting](https://docs.blender.org/manual/en/latest/animation/drivers/troubleshooting.html): a scripted expression outside the simple subset needs Trusted Source or Auto Run Python Scripts. -This is intentional. Without the restriction, opening a malicious .blend would auto-execute Python. +`driver_namespace` does not make a function safe or trusted. It only makes the name resolvable once Python is allowed to run. The gate is auto-execution. + +Check which kind you wrote with `driver.is_simple_expression`. Measured on 4.5.11 and 5.2.1: + +| Expression | `is_simple_expression` | +| --- | --- | +| `1.4 + sin(3 * 0.6)`, `frame / 10`, `max(1, 2)`, `1.0` | `True` | +| `wave_scale(0)` (a `driver_namespace` function) | `False` | +| `bpy.context.scene.frame_current` | `False` | + +**Prefer simple expressions and driver variables when the `.blend` will be shared.** Read data through variables (`SINGLE_PROP`, `TRANSFORMS`) and keep the expression in the simple subset. Reach for a namespace function only when the add-on that registers it will be installed wherever the file is opened, and tell the user the file needs Auto Run. + +Headless tests cannot see this failure. `--background` runs still evaluated a reopened file's namespace driver (so a save-and-reopen check passes). Assert `is_simple_expression` instead of trusting a background evaluation. ### The driver_namespace escape hatch @@ -172,13 +183,15 @@ The handlers live as lists at `bpy.app.handlers.`. To register, append; t | `save_post` | `(filepath: str)` | After the .blend is written. Same single filepath argument. | | `load_pre` | `(filepath: str)` | Before a .blend is loaded. The argument is the file being loaded. | | `load_post` | `(filepath: str)` | After a .blend is loaded. Use to validate or migrate add-on data. | -| `depsgraph_update_pre` | `(scene, depsgraph)` | Before a depsgraph evaluation pass. | +| `depsgraph_update_pre` | `(scene, None)` | Before a depsgraph evaluation pass. The second argument is `None`, not a Depsgraph. | | `depsgraph_update_post` | `(scene, depsgraph)` | After a depsgraph evaluation pass. Fires very frequently; must be O(1) or near-O(1). | -| `frame_change_pre` | `(scene, depsgraph)` | Before frame is set. | +| `frame_change_pre` | `(scene, None)` | Before frame is set. The second argument is `None`; the depsgraph is not available yet. | | `frame_change_post` | `(scene, depsgraph)` | After frame is set. | -| `exit_pre` (new in 5.1) | `(*args)` | Before Blender shuts down. Use for resource cleanup, telemetry flush, etc. The argument is not a Scene; accept `*args`. | +| `exit_pre` (new in 5.1) | `(interactive: bool)` | Before Blender shuts down. Use for resource cleanup, telemetry flush, etc. The argument is `True` for an interactive (GUI) exit and `False` in `--background`. A handler written as `(*args)` receives `(bool, None)`. | + +The save/load handlers (`save_pre`, `save_post`, `load_pre`, `load_post`) all receive the **file path as a string** as their single argument, **not** a Scene. (Verified empirically on 4.5.10 LTS and 5.1.1. The [`bpy.app.handlers`](https://docs.blender.org/api/current/bpy.app.handlers.html) docs type these as `Callable[[str], None]`; `save_pre` is described as "on saving a blend file (before). Accepts one argument: the file being saved, an empty string for the startup-file." — the load handlers use the same wording with "the file being loaded".) Only the `_post` depsgraph/frame-change handlers receive `(scene, depsgraph)`. The `_pre` ones get `(scene, None)`, so a handler that calls `depsgraph.` there raises `AttributeError: 'NoneType' object has no attribute ...`. Measured on 4.5.11 and 5.2.1: `frame_change_pre` and `depsgraph_update_pre` get `['Scene', 'NoneType']`, the `_post` variants `['Scene', 'Depsgraph']`. If a pre handler truly needs evaluated data, call `bpy.context.evaluated_depsgraph_get()`, and not from inside a depsgraph handler, where it can trigger the evaluation the handler is part of. Usually the right fix is to move the work to the `_post` handler. -The save/load handlers (`save_pre`, `save_post`, `load_pre`, `load_post`) all receive the **file path as a string** as their single argument, **not** a Scene. (Verified empirically on 4.5.10 LTS and 5.1.1. The [`bpy.app.handlers`](https://docs.blender.org/api/current/bpy.app.handlers.html) docs type these as `Callable[[str], None]`; `save_pre` is described as "on saving a blend file (before). Accepts one argument: the file being saved, an empty string for the startup-file." — the load handlers use the same wording with "the file being loaded".) Only the depsgraph/frame-change handlers receive `(scene, depsgraph)`. +`exit_pre` was measured on 5.2.1: a `--background` exit passes `False`, a windowed `wm.quit_blender()` passes `True`. The `exit_pre` handler in 5.1 is particularly useful for add-ons that need to release external resources (sockets, log files, child processes) deterministically before the process terminates. diff --git a/skills/mesh-editing-and-bmesh/SKILL.md b/skills/mesh-editing-and-bmesh/SKILL.md index fc6d5330..86dfbbbd 100644 --- a/skills/mesh-editing-and-bmesh/SKILL.md +++ b/skills/mesh-editing-and-bmesh/SKILL.md @@ -174,7 +174,7 @@ finally: eval_obj.to_mesh_clear() ``` -`to_mesh()` returns a temporary mesh datablock. `to_mesh_clear()` releases it. Skipping the cleanup leaks like skipping `bm.free()`. +`to_mesh()` returns a temporary mesh datablock. `to_mesh_clear()` releases it. One temporary mesh is held per evaluated object until `to_mesh_clear()` or re-evaluation. Looping over *many objects* without clearing holds one per object. A repeated call on the same object frees the previous one. See `depsgraph-and-evaluated-data` for the measured lifetime. This is the only way to: - Read the post-subdivision-surface vertex count and positions @@ -249,7 +249,7 @@ After mutating selection, call `bm.select_flush_mode()` if you've changed indivi 2. **Forgetting `bm.free()`**. Crashes Blender after enough runs. Use the `try`/`finally` form unconditionally. -3. **Forgetting `to_mesh_clear()`**. Leaks evaluated meshes. Same `try`/`finally` discipline applies. +3. **Forgetting `to_mesh_clear()`**. Holds one evaluated mesh per object until re-evaluation. Same `try`/`finally` discipline applies. 4. **Reading `obj.data` and expecting modifiers**: diff --git a/skills/operators/SKILL.md b/skills/operators/SKILL.md index c49494a6..c4f8aeaf 100644 --- a/skills/operators/SKILL.md +++ b/skills/operators/SKILL.md @@ -95,11 +95,15 @@ class MESH_OT_offset_along_normals(bpy.types.Operator): ## Defensive context handling -`context.active_object` returns `None` when the scene has no active object, which happens routinely: +`context.active_object` returns `None` only when there is no active object: -- Headless scripts via `blender --background --python script.py` start with no active object. -- Empty selections after a deselect-all. -- Some context overrides set the active object to `None` deliberately. +- An empty scene, such as after `read_factory_settings(use_empty=True)`, or a file whose view layer never had one. +- The active object was deleted. +- A context override sets `active_object=None` deliberately. + +It is **not** `None` just because nothing is selected, and not just because Blender runs headless. Measured on 4.5.11 and 5.2.1 (`--background --factory-startup`): the startup scene's active object is `Cube`, and after `bpy.ops.object.select_all(action='DESELECT')` it is still `Cube`, with `selected_objects == []` and `Cube.select_get() == False`. + +**Active and selected are independent.** That is the real pitfall: an active object that is not selected, hidden, or not editable. A `None` guard does not catch it. Operators that act on the selection then run on nothing, or on the wrong set, without raising. Measured: `bpy.ops.object.delete()` with the Cube active but deselected returns `{'CANCELLED'}` and the Cube survives. When the operation needs selection, check `obj.select_get()` or work from `context.selected_objects`. Check `obj.visible_get()` before acting on what the user sees. Check `obj.library is None` (and `obj.override_library` if you edit overrides) before writing to linked data. **Always** guard before dereferencing: @@ -112,6 +116,9 @@ def execute(self, context): if obj.type != 'MESH': self.report({'ERROR'}, f"{obj.name} is a {obj.type}, expected MESH") return {'CANCELLED'} + if not obj.select_get(): # active is not selected after a deselect-all + self.report({'ERROR'}, f"{obj.name} is active but not selected") + return {'CANCELLED'} # ... ``` diff --git a/skills/ui-panels/SKILL.md b/skills/ui-panels/SKILL.md index cad8c58c..2feecaba 100644 --- a/skills/ui-panels/SKILL.md +++ b/skills/ui-panels/SKILL.md @@ -220,7 +220,7 @@ def unregister(): The label comes from the property definition. Pass `text=` only to override or to suppress with `text=""`. -3. **Mixing `bl_region_type='UI'` with no `bl_category`**. Blender will silently default it, hiding your panel under "View" or similar. Always set `bl_category`. +3. **Mixing `bl_region_type='UI'` with no `bl_category`**. Blender silently files the panel under a fallback sidebar tab you did not choose, mixed in with other add-ons, where users will not look for it. Always set `bl_category`. 4. **Using `bl_region_type='TOOLS'` for new add-ons**. The tools region was deprecated in 2.8 in favor of the UI region for sidebar panels. Stick to `'UI'`. diff --git a/snippets/driver-with-custom-function.py b/snippets/driver-with-custom-function.py index 4c55b7dc..b6427c1b 100644 --- a/snippets/driver-with-custom-function.py +++ b/snippets/driver-with-custom-function.py @@ -1,6 +1,13 @@ # Driver expression calling a custom Python function via driver_namespace. -# Driver expressions block arbitrary Python by default; the namespace is the -# whitelisted escape hatch. +# The namespace makes the name resolvable; it does NOT make the call trusted. +# Any call into driver_namespace is not a simple expression +# (driver.is_simple_expression is False), so it runs only with Python +# auto-execution on. Blender ships with it off: in a GUI session the driver +# is dead unless the file is opened as Trusted Source, Auto Run Python +# Scripts is enabled, or Blender starts with -y / --enable-autoexec. +# --background runs do not show this. For shared .blend files prefer +# driver variables plus a simple expression. +# Manual: docs.blender.org/manual/en/latest/animation/drivers/troubleshooting.html # # driver_namespace is reset on every file load. A driver that evaluates while # its function is missing raises NameError and is disabled (is_valid False), diff --git a/snippets/setup_bake_target_image.py b/snippets/setup_bake_target_image.py index a481d1a7..77dabe27 100644 --- a/snippets/setup_bake_target_image.py +++ b/snippets/setup_bake_target_image.py @@ -1,7 +1,8 @@ # Target setup for bpy.ops.object.bake: generated image, Non-Color, -# a material whose Image Texture node is nodes.active, UV layer present. -# The texture does not need a link into Principled. If it is not active, -# the bake finishes and writes nowhere. +# a material whose Image Texture node is nodes.active AND selected, UV layer +# present. The texture does not need a link into Principled. On 5.0+ an +# active but deselected node makes the bake return {'CANCELLED'} ("No active +# and selected image texture node found"); 4.5 LTS ignores selection. # # Reference: # https://docs.blender.org/api/current/bpy.ops.object.html#bpy.ops.object.bake @@ -23,7 +24,7 @@ def setup_bake_target_image(obj, name="BakeNrm", size=128): tex = nodes.new("ShaderNodeTexImage") tex.image = img nodes.active = tex - tex.select = True + tex.select = True # required on 5.x; do not drop as redundant if obj.data.materials: obj.data.materials[0] = mat else: diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index 7c5cc1eb..ef944d00 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -10,10 +10,10 @@ {"name": "gn-sdf-remesh", "script": "examples/gn-sdf-remesh/gn_sdf_remesh.py", "falsifiers": [{"args": ["--no-sdf"], "expect_exit": 3}]}, {"name": "depsgraph-export", "script": "examples/depsgraph-export/depsgraph_export.py", "falsifiers": [{"args": ["--unevaluated"], "expect_exit": 5}]}, {"name": "wave-displace", "script": "examples/wave-displace/wave_displace.py", "falsifiers": [{"args": ["--flat"], "expect_exit": 4}]}, - {"name": "driver-wave", "script": "examples/driver-wave/driver_wave.py", "falsifiers": [{"args": ["--flat-expr"], "expect_exit": 3}]}, + {"name": "driver-wave", "script": "examples/driver-wave/driver_wave.py", "falsifiers": [{"args": ["--flat-expr"], "expect_exit": 3}, {"args": ["--simple-expr"], "expect_exit": 5}, {"args": ["--swap-handlers"], "expect_exit": 7}]}, {"name": "bmesh-gear", "script": "examples/bmesh-gear/bmesh_gear.py", "falsifiers": [{"args": ["--no-extrude"], "expect_exit": 3}]}, {"name": "shader-node-group", "script": "examples/shader-node-group/shader_node_group.py", "falsifiers": [{"args": ["--same-tint"], "expect_exit": 6}]}, - {"name": "temp-override-join", "script": "examples/temp-override-join/temp_override_join.py", "falsifiers": [{"args": ["--no-override"], "expect_exit": 3}]}, + {"name": "temp-override-join", "script": "examples/temp-override-join/temp_override_join.py", "falsifiers": [{"args": ["--no-override"], "expect_exit": 3}, {"args": ["--clear-active-on-deselect"], "expect_exit": 13}]}, {"name": "gn-instance-grid", "script": "examples/gn-instance-grid/gn_instance_grid.py", "falsifiers": [{"args": ["--one-cell"], "expect_exit": 4}]}, {"name": "gn-modifier-inputs", "script": "examples/gn-modifier-inputs/gn_modifier_inputs.py", "falsifiers": [{"args": ["--same-height"], "expect_exit": 7}, {"args": ["--api", "dict"], "expect_exit": 5, "min_version": "5.2"}]}, {"name": "shape-key-blend", "script": "examples/shape-key-blend/shape_key_blend.py", "falsifiers": [{"args": ["--zero-blend"], "expect_exit": 5}]}, @@ -78,7 +78,7 @@ {"name": "ngon-triangulate", "script": "examples/ngon-triangulate/ngon_triangulate.py", "falsifiers": [{"args": ["--no-dissolve"], "expect_exit": 3}, {"args": ["--skip-triangulate"], "expect_exit": 4}]}, {"name": "unapplied-scale-gltf", "script": "examples/unapplied-scale-gltf/unapplied_scale_gltf.py", "falsifiers": [{"args": ["--identity"], "expect_exit": 3}, {"args": ["--bake"], "expect_exit": 4}]}, {"name": "coincident-vert-weld", "script": "examples/coincident-vert-weld/coincident_vert_weld.py", "falsifiers": [{"args": ["--no-duplicate"], "expect_exit": 3}, {"args": ["--weld"], "expect_exit": 4}]}, - {"name": "bake-normal-high-to-low", "script": "examples/bake-normal-high-to-low/bake_normal_high_to_low.py", "falsifiers": [{"args": ["--flat-source"], "expect_exit": 5}]}, + {"name": "bake-normal-high-to-low", "script": "examples/bake-normal-high-to-low/bake_normal_high_to_low.py", "falsifiers": [{"args": ["--flat-source"], "expect_exit": 5}, {"args": ["--unselect-target"], "expect_exit": 4, "min_version": "5.0"}]}, {"name": "vse-linear-modifiers", "script": "examples/vse-linear-modifiers/vse_linear_modifiers.py", "falsifiers": [{"args": ["--assume-present"], "expect_exit": 4, "min_version": "5.2"}]}, {"name": "gn-socket-rename", "script": "examples/gn-socket-rename/gn_socket_rename.py", "falsifiers": [{"args": ["--legacy-ids"], "expect_exit": 5, "min_version": "5.2"}]}, {"name": "extension-package-lifecycle", "script": "examples/extension-package-lifecycle/extension_package_lifecycle.py", "falsifiers": [{"args": ["--validate-only"], "expect_exit": 4}, {"args": ["--data-next-to-file"], "expect_exit": 7}]},