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 @@
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.
frac >= 0.40 and MAD >= 0.05 (measured 0.7211 / 0.09356 on 4.5.11, 5.1.2, and 5.2.1). Catches an inactive Image Texture node, reversed selection, or EEVEE/GPU mis-setup that writes a blank map. MAD floor is half the measured hatch value, still ~30× a flat bake.frac <= 0.05 and MAD <= 0.03 (measured 0.0000 / 0.00277). Catches a noisy or wrongly-typed bake that would also satisfy the detail gates.detail_frac - flat_frac >= 0.30. The two maps must separate; a tolerance wide enough to pass both would have no discriminating power.--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.frac >= 0.40 and MAD >= 0.05 (measured 0.7211 / 0.09356 on 4.5.11, 5.1.2, and 5.2.1). Catches an inactive Image Texture node, reversed selection, or EEVEE/GPU mis-setup that writes a blank map. MAD floor is half the measured hatch value, still ~30× a flat bake.frac <= 0.05 and MAD <= 0.03 (measured 0.0000 / 0.00277). Catches a noisy or wrongly-typed bake that would also satisfy the detail gates.detail_frac - flat_frac >= 0.30. The two maps must separate; a tolerance wide enough to pass both would have no discriminating power.--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.--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 (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 @@Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
| Code | Meaning |
|---|---|
| 0 | Success |
| 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 |
| 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 |
| 8 | Monotonic gap below 0.30 |
| 9 | --output produced no file |
| 10 | Gallery framing violation |
| Code | Meaning |
|---|---|
| 0 | Success |
| 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 (--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 |
| 8 | Monotonic gap below 0.30 |
| 9 | --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.
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:
+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.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.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.
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Uncaught exception (FATAL wrapper) |
| 2 | argparse / usage |
| 3 | Evaluated Z scale ≠ wave_scale (--flat-expr lands here) |
| 4 | Original datablock was not flushed |
| 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).
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Uncaught exception (FATAL wrapper) |
| 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 falsifiers are --flat-expr (expects exit 3), --simple-expr (exit 5) and --swap-handlers (exit 7).
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.
# 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
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Uncaught exception (FATAL wrapper) |
| 2 | argparse / usage |
| 3 | Mesh object count after join ≠ 1 (--no-override lands here) |
| 4 | Joined target is not the sole remaining mesh |
| 5 | Verts / faces ≠ the sum over the seven parts |
| 6 | Source objects still present |
| 7 | Local Z span ≠ [0, 2.303]: a part transform was not applied |
| 8 | Material slots ≠ the five part materials, once each |
| 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 |
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).
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Uncaught exception (FATAL wrapper) |
| 2 | argparse / usage |
| 3 | Mesh object count after join ≠ 1 (--no-override lands here) |
| 4 | Joined target is not the sole remaining mesh |
| 5 | Verts / faces ≠ the sum over the seven parts |
| 6 | Source objects still present |
| 7 | Local Z span ≠ [0, 2.303]: a part transform was not applied |
| 8 | Material slots ≠ the five part materials, once each |
| 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 |
| 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 falsifiers are --no-override (expects exit 3) and --clear-active-on-deselect (exit 13).