Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"examples/degenerate-bevel-weld",
"examples/depsgraph-export",
"examples/driver-wave",
"examples/exit-pre-sidecar",
"examples/gltf-export-roundtrip",
"examples/gltf-skin-roundtrip",
"examples/gn-bundle-roundtrip",
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ a `.cursor-plugin/plugin.json` manifest so the ecosystem drift checker
classifies it as a `cursor-plugin`. This is content the AI loads when the user
asks Blender questions or works on Blender add-ons in Cursor or Claude Code.

The content base is 13 skills, 6 rules, 2 templates, 17 snippets, and 49
The content base is 13 skills, 6 rules, 2 templates, 17 snippets, and 50
examples (counts are CI-enforced against README.md and the manifest). The full
inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy
and authoring rules: copy `examples/bmesh-gear/`; the render look is specified
Expand All @@ -35,7 +35,7 @@ Blender-Developer-Tools/
rules/<rule-name>.mdc # 6 rule files
templates/<template-name>/ # 2 starter templates
snippets/<snippet-name>.py # 17 standalone Python snippets
examples/<name>/ # 49 runnable smoke-gated examples (+ gallery.json)
examples/<name>/ # 50 runnable smoke-gated examples (+ gallery.json)
examples/gallery_framing.py # shared Layer 1 framing measurement (render path only)
scripts/build_gallery.py # generates docs/gallery/ (stdlib only)
scripts/site/ # vendored landing-page build (build_site.py + template)
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ skills/<skill-name>/SKILL.md - AI workflow definitions, 13 total
rules/<rule-name>.mdc - Anti-pattern rules, 6 total
templates/<template-name>/ - Starter projects, 2 total
snippets/<snippet-name>.py - Standalone code patterns, 17 total
examples/<name>/ - Runnable smoke-gated examples, 49 total (+ gallery.json)
examples/<name>/ - Runnable smoke-gated examples, 50 total (+ gallery.json)
scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only)
scripts/site/ - Vendored landing-page build (Jinja2)
docs/gallery/ - Committed generated gallery pages + hero renders
Expand Down Expand Up @@ -83,7 +83,7 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh

v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`.

## Examples (49)
## Examples (50)

Runnable scripts at `examples/<name>/`, each asserting a real API contract with
deterministic checks (exit non-zero on failure) and optionally rendering a still via
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
</p>

<p align="center">
<strong>13 skills</strong> &nbsp;&bull;&nbsp; <strong>6 rules</strong> &nbsp;&bull;&nbsp; <strong>2 templates</strong> &nbsp;&bull;&nbsp; <strong>17 snippets</strong> &nbsp;&bull;&nbsp; <strong>49 examples</strong>
<strong>13 skills</strong> &nbsp;&bull;&nbsp; <strong>6 rules</strong> &nbsp;&bull;&nbsp; <strong>2 templates</strong> &nbsp;&bull;&nbsp; <strong>17 snippets</strong> &nbsp;&bull;&nbsp; <strong>50 examples</strong>
</p>

<p align="center">
Expand Down Expand Up @@ -459,7 +459,7 @@ fails creating the 5.x RNA.
</details>

<details>
<summary><strong>Animation, rigging &amp; constraints</strong> — 5 examples</summary>
<summary><strong>Animation, rigging &amp; constraints</strong> — 6 examples</summary>

<table>
<tr>
Expand Down Expand Up @@ -488,6 +488,21 @@ A `driver_namespace` function driving sixteen column heights through SCRIPTED dr
Witnesses the evaluation contract: driven values appear after a view-layer update on the
evaluated copy **and** the flushed-back original, and both must match the closed form.

</td>
</tr>
<tr>
<td width="46%" valign="middle">
check-only, no gallery still — no geometry
</td>
<td valign="middle">

### [exit-pre-sidecar](examples/exit-pre-sidecar/)

`bpy.app.handlers.exit_pre` writes `$BDT_SMOKE_SIDECAR` as Blender dies; the
harness asserts `exit_pre-ok` after the process exits. Skips 4.5
(`min_version` 5.1). `--silent-handler` / `--no-handler` miss the file;
`--wrong-text` / `--write-in-main` fail the content check.

</td>
</tr>
<tr>
Expand Down
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~Repeat Zone / For Each Element pairing witness~~ **SHIPPED** as `examples/gn-zone-iterate/` — `pair_with_output` is load-bearing (unpaired evaluates 0 verts); Repeat `8×(1+N)` with X-centers at `k×STEP`; For Each `8×P` with Z-centers at `i×STEP`; For Each main Geometry is a passthrough (6 verts); count-only is insufficient (`--no-offset` still 32 verts, one X-center)
- ~~Geometry Nodes bundle round-trip~~ **SHIPPED** as `examples/gn-bundle-roundtrip/` — 5.x RNA `NodeCombineBundle` (`GeometryNodeCombineBundle` undefined on 5.2); closed form 8/6 + x `[0.5, 2.5]` + `bundle_mark=0.314159`; 4.5 SKIP via catalog `min_version` 5.0 (experimental flag off evaluates empty; `--force-run` hits 5.x RNA undefined)
- Tighten inverted smoke canaries: assert the expected `[FAIL]` marker text (`skipped on ... should run` / `missing post-exit sidecar`) alongside wrapper exit 1, so a canary that dies for the wrong reason does not satisfy the gate
- Falsification flags on shipped examples (Phases 6–8 `--skip-delete` / `--unpair-*` / `--bypass`, Phase 9 `--silent-handler` / `--wrong-text`) were proven local-Windows only — chat and `.scratch`, not CI history. Decide later whether a cron should exercise them or whether flags in the scripts are sufficient record.
- ~~exit_pre sidecar witness~~ **SHIPPED** as `examples/exit-pre-sidecar/` — 5.1+ `handlers.exit_pre` writes `$BDT_SMOKE_SIDECAR`; harness `--expect-sidecar` + `sidecar_contains=exit_pre-ok` after process exit; 4.5 SKIP `min_version` 5.1; `--force-run` is AttributeError; `--silent-handler` / `--no-handler` missing sidecar; `--wrong-text` / `--write-in-main` / `--atexit-instead` wrong contents
- ~~Fleet Pages facelift + examples support~~ **RESOLVED differently (2026-07-03)**: the meta-repo migration was dropped — the fleet template only scaffolds new tools, and each tool's site evolves independently after that. This repo vendored the site build into `scripts/site/`, redesigned landing + gallery as the Blender-viewport system (see `docs/gallery/DESIGN_NOTES.md`), added the examples grid, nav link, and full hero stats locally. `scripts/build_gallery.py` and `examples/gallery.json` are now permanent, not a prototype awaiting lift-and-shift.
- `modal-operators` skill -- `invoke` returning `RUNNING_MODAL`, the `modal()` event handler, modal cancellation patterns
- `usd-pipelines` skill -- USD export options, `evaluation_mode`, instancing, the USD vs glTF tradeoffs
Expand Down
35 changes: 35 additions & 0 deletions examples/exit-pre-sidecar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# exit_pre sidecar

`bpy.app.handlers.exit_pre` writes `$BDT_SMOKE_SIDECAR` as Blender dies.
Witnesses [`drivers-and-app-handlers`](../../skills/drivers-and-app-handlers/SKILL.md).
`main` does not write the file. The host runner asserts it after the
process exits (`tests/smoke/run_example.py --expect-sidecar`).

5.1+. 4.5 LTS: `AttributeError` — no `exit_pre`. Skip
(`SMOKE_SKIP: exit_pre requires Blender 5.1+`, exit 77, catalog
`min_version` 5.1). `--force-run` bypasses the skip so 4.5 fails
accessing the handler list.

No gallery still. There is no geometry.

**What failure each check would catch:**

- exit 77 — Blender &lt; 5.1 and not `--force-run`
- exit 2 — `--force-run` on 4.5 (`exit_pre` missing)
- harness FAIL missing sidecar — `--silent-handler` / `--no-handler`
- harness FAIL wrong contents — `--wrong-text` (`nope`), `--write-in-main`
(`from-main`), `--atexit-instead` (`atexit-ok`)

The harness checks **contents** (`sidecar_contains=exit_pre-ok`), not
existence only. A file written from `main` or `atexit` is red.

## Run

Via the harness (sets `$BDT_SMOKE_SIDECAR`):

```bash
python tests/smoke/run_example.py --name exit-pre-sidecar \
--blender blender --script examples/exit-pre-sidecar/exit_pre_sidecar.py \
--series 5.2 --min-version 5.1 \
--expect-sidecar /tmp/exit-pre.sidecar --sidecar-contains exit_pre-ok
```
167 changes: 167 additions & 0 deletions examples/exit-pre-sidecar/exit_pre_sidecar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""exit_pre post-exit sidecar — a runnable example.

Witnesses ``bpy.app.handlers.exit_pre`` from
``skills/drivers-and-app-handlers``. The callback fires as Blender dies.
This script does **not** write ``$BDT_SMOKE_SIDECAR`` in ``main``; the
handler writes it. The host runner (``tests/smoke/run_example.py``)
asserts the file after the process exits.

5.1+. 4.5 LTS has no ``exit_pre`` (AttributeError). Catalog
``min_version`` 5.0 is wrong — the floor is 5.1. Skip: ``SMOKE_SKIP``.
``--force-run`` bypasses the skip so 4.5 fails accessing ``exit_pre``.

No gallery still. There is no geometry.

blender --background --python exit_pre_sidecar.py --
"""
import argparse
import atexit
import os
import sys

import bpy
from bpy.app.handlers import persistent

SKIP_REASON = "exit_pre requires Blender 5.1+"
MARKER = "exit_pre-ok"
MAIN_MARKER = "from-main"
WRONG_MARKER = "nope"
ATEXIT_MARKER = "atexit-ok"


def sidecar_path():
path = os.environ.get("BDT_SMOKE_SIDECAR")
if not path:
print("ERROR: BDT_SMOKE_SIDECAR unset", file=sys.stderr)
return None
return path


def write_sidecar(text):
path = sidecar_path()
if not path:
return False
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(text)
if not text.endswith("\n"):
fh.write("\n")
return True


@persistent
def on_exit_write(*args):
write_sidecar(MARKER)


@persistent
def on_exit_silent(*args):
return


@persistent
def on_exit_wrong(*args):
write_sidecar(WRONG_MARKER)


def on_atexit():
write_sidecar(ATEXIT_MARKER)


def maybe_skip(force_run):
if bpy.app.version >= (5, 1, 0):
return 0
if force_run:
return 0
print(f"SMOKE_SKIP: {SKIP_REASON}", flush=True)
return 77


def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
p = argparse.ArgumentParser()
p.add_argument(
"--force-run",
action="store_true",
help="bypass the 5.1 skip; 4.5 fails accessing handlers.exit_pre",
)
p.add_argument(
"--silent-handler",
action="store_true",
help="falsification: register exit_pre that writes nothing",
)
p.add_argument(
"--wrong-text",
action="store_true",
help="falsification: handler writes nope, not exit_pre-ok",
)
p.add_argument(
"--no-handler",
action="store_true",
help="falsification: do not register exit_pre, do not write in main",
)
p.add_argument(
"--write-in-main",
action="store_true",
help="falsification: write from-main in main without exit_pre",
)
p.add_argument(
"--atexit-instead",
action="store_true",
help="falsification: atexit writes atexit-ok, not exit_pre",
)
args = p.parse_args(argv)

skipped = maybe_skip(args.force_run)
if skipped:
return skipped

if args.force_run and bpy.app.version < (5, 1, 0):
try:
bpy.app.handlers.exit_pre.append(on_exit_write)
except AttributeError as exc:
print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
return 2
print("ERROR: exit_pre existed on this Blender; 4.5 should AttributeError", file=sys.stderr)
return 2

if sidecar_path() is None:
return 1

if args.write_in_main:
write_sidecar(MAIN_MARKER)
print("wrote sidecar from main (no exit_pre)", flush=True)
return 0

if args.no_handler:
print("no exit_pre registered", flush=True)
return 0

if args.atexit_instead:
atexit.register(on_atexit)
print("registered atexit, not exit_pre", flush=True)
return 0

if args.silent_handler:
bpy.app.handlers.exit_pre.append(on_exit_silent)
print("registered silent exit_pre", flush=True)
return 0

if args.wrong_text:
bpy.app.handlers.exit_pre.append(on_exit_wrong)
print("registered exit_pre writing nope", flush=True)
return 0

bpy.app.handlers.exit_pre.append(on_exit_write)
print("registered exit_pre", flush=True)
return 0


if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc:
print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
sys.exit(1)
2 changes: 2 additions & 0 deletions skills/drivers-and-app-handlers/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ The `exit_pre` handler list is new in Blender 5.1. On 4.5 LTS, fall back to OS-l
- **Recursively modifying the scene from a depsgraph handler.** The modification triggers another depsgraph evaluation, which calls the handler, which modifies the scene. Infinite loop, often manifesting as a hang.
- **Asymmetric register/unregister.** The handler is appended on register but not removed on unregister. Disabling the add-on leaves the callback in place. After enable/disable cycles, the callback runs N times per event.
- **Treating the `save_pre` argument as a Scene.** The save/load handlers receive the **file path string** (empty for the startup file), not a Scene. Name the parameter `filepath` (or take `*args`), and reach scenes via `bpy.context.scene` / `bpy.data.scenes`. A membership test like `'key' in arg0` against the path string is silently wrong, and `del arg0['key']` raises `TypeError`.
- **Doing exit cleanup in the script body instead of `exit_pre`.** A `--background --python` script can `sys.exit(0)` without the handler firing if you never registered it. The witness is a sidecar written from `exit_pre`, asserted after the process dies.

## Version correctness

Expand All @@ -279,6 +280,7 @@ The `exit_pre` handler list is new in Blender 5.1. On 4.5 LTS, fall back to OS-l

- Snippet `driver-with-custom-function.py` for the driver_namespace pattern.
- Snippet `app-handler-registration.py` for save_pre with proper unregister.
- Example `exit-pre-sidecar` for `exit_pre` writing a post-exit sidecar (5.1+; skip 4.5).
- Skill `custom-properties` for the data the driver might be reading.

## References
Expand Down
9 changes: 8 additions & 1 deletion tests/smoke/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,12 @@
{"name": "cross-version-property-delete", "script": "examples/cross-version-property-delete/cross_version_property_delete.py"},
{"name": "usd-export-evaluation-mode", "script": "examples/usd-export-evaluation-mode/usd_export_evaluation_mode.py"},
{"name": "gn-zone-iterate", "script": "examples/gn-zone-iterate/gn_zone_iterate.py"},
{"name": "gn-bundle-roundtrip", "script": "examples/gn-bundle-roundtrip/gn_bundle_roundtrip.py", "min_version": "5.0"}
{"name": "gn-bundle-roundtrip", "script": "examples/gn-bundle-roundtrip/gn_bundle_roundtrip.py", "min_version": "5.0"},
{
"name": "exit-pre-sidecar",
"script": "examples/exit-pre-sidecar/exit_pre_sidecar.py",
"min_version": "5.1",
"expect_sidecar": "$OUT/exit-pre-sidecar.sidecar",
"sidecar_contains": "exit_pre-ok"
}
]
7 changes: 7 additions & 0 deletions tests/smoke/run_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ def main(argv=None):
cmd.append("--xvfb")
if item.get("min_version"):
cmd.extend(["--min-version", item["min_version"]])
if item.get("expect_sidecar"):
cmd.extend([
"--expect-sidecar",
item["expect_sidecar"].replace("$OUT", args.out),
])
if item.get("sidecar_contains"):
cmd.extend(["--sidecar-contains", item["sidecar_contains"]])
if extra:
cmd.append("--")
cmd.extend(extra)
Expand Down
12 changes: 12 additions & 0 deletions tests/smoke/test_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""
from __future__ import annotations

import json
import os
import sys
import tempfile
Expand Down Expand Up @@ -159,5 +160,16 @@ def test_empty_is_not_green(self):
self.assertEqual(code, 2)


class CatalogWiring(unittest.TestCase):
def test_exit_pre_row_has_sidecar_and_floor(self):
here = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(here, "catalog.json"), encoding="utf-8") as fh:
catalog = json.load(fh)
row = next(i for i in catalog if i["name"] == "exit-pre-sidecar")
self.assertEqual(row["min_version"], "5.1")
self.assertIn("$OUT", row["expect_sidecar"])
self.assertEqual(row["sidecar_contains"], "exit_pre-ok")


if __name__ == "__main__":
unittest.main()
Loading