From 525191bf20cbd4cd7399ac4c18ebee990b3d56f1 Mon Sep 17 00:00:00 2001
From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Date: Sun, 4 Oct 2026 22:56:24 -0400
Subject: [PATCH] feat(skills): timers-modal-and-threading skill, snippet and
example
Long-running add-on work had no coverage. New skill
timers-modal-and-threading covers:
- bpy.app.timers return values and persistent=True
- modal operators driven by event_timer_add
- worker threads that hand results to the main thread through a
queue.Queue
- why none of this runs in --background
Every claim is measured on 4.5.11, 5.1.2 and 5.2.1:
- In --background a registered timer never runs.
- A None timer runs once; a float timer re-runs; a 0.0 timer keeps
re-running.
- first_interval is keyword-only; register(fn, 0.5) raises TypeError.
- A file load keeps persistent timers and drops plain ones.
- bpy.types.Event has no `timer` attribute, so event.timer in modal()
raises AttributeError.
- A modal operator ticks to FINISHED and removes its timer.
New snippet snippets/thread-queue-timer.py. New check-only example
examples/timers-modal-threading runs a --background child and a windowed
child that quits itself, with falsifiers --return-zero-once (exit 5) and
--non-persistent (exit 8). The windowed child needs a display; smoke
runs under xvfb.
Counts move to 18 skills, 29 snippets and 66 examples (10 check-only);
the example maps to this skill and operators.
Closes #381
Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5
---
.cursor-plugin/plugin.json | 1 +
AGENTS.md | 4 +-
CLAUDE.md | 19 +-
CONTRIBUTING.md | 2 +-
README.md | 18 +-
docs/new-example-prompt.md | 2 +-
examples/index.json | 38 +++
examples/skills.json | 1 +
examples/timers-modal-threading/README.md | 94 +++++++
.../timers_modal_threading.py | 256 ++++++++++++++++++
scripts/build_plugin_dist.py | 2 +-
site.json | 4 +-
skills/operators/SKILL.md | 1 +
skills/timers-modal-and-threading/SKILL.md | 156 +++++++++++
snippets/thread-queue-timer.py | 53 ++++
tests/smoke/catalog.json | 1 +
16 files changed, 627 insertions(+), 25 deletions(-)
create mode 100644 examples/timers-modal-threading/README.md
create mode 100644 examples/timers-modal-threading/timers_modal_threading.py
create mode 100644 skills/timers-modal-and-threading/SKILL.md
create mode 100644 snippets/thread-queue-timer.py
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index 75422531..9e496372 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -24,6 +24,7 @@
"skills/bake-high-to-low/SKILL.md",
"skills/engine-export-presets/SKILL.md",
"skills/extension-runtime-and-packaging/SKILL.md",
+ "skills/timers-modal-and-threading/SKILL.md",
"skills/operators/SKILL.md",
"skills/ui-panels/SKILL.md",
"skills/custom-properties/SKILL.md",
diff --git a/AGENTS.md b/AGENTS.md
index 221fa7a9..31ef2995 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 17 skills, 9 rules, 3 templates, 28 snippets, 65
+The content base is 18 skills, 9 rules, 3 templates, 29 snippets, 66
examples, and 76 showcase pieces (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/`;
@@ -32,7 +32,7 @@ in `docs/VISUAL-STYLE.md`; the canonical run prompt is
```
Blender-Developer-Tools/
- skills//SKILL.md # 17 skill files
+ skills//SKILL.md # 18 skill files
rules/.mdc # 9 rule files
templates// # 3 starter templates
snippets/.py # 27 standalone Python snippets
diff --git a/CLAUDE.md b/CLAUDE.md
index afc58232..c5bbb977 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -17,11 +17,11 @@ The **Blender Developer Tools** repository is at **v0.145.0**. It packages skill
## Repository Architecture
```
-skills//SKILL.md - AI workflow definitions, 17 total
+skills//SKILL.md - AI workflow definitions, 18 total
rules/.mdc - Anti-pattern rules, 9 total
templates// - Starter projects, 3 total
-snippets/.py - Standalone code patterns, 28 total
-examples// - Runnable smoke-gated examples, 65 total (+ gallery.json)
+snippets/.py - Standalone code patterns, 29 total
+examples// - Runnable smoke-gated examples, 66 total (+ gallery.json)
showcase// - Budget-conformance props, 76 pieces, each with a gallery `category` (sibling of examples/; see showcase/README.md § Categories)
scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json
scripts/site/ - Vendored landing-page build (Jinja2); tokens.css is the shared palette
@@ -30,7 +30,7 @@ docs/gallery/ - Committed generated gallery pages + hero render
VERSION - Source of truth for the repo version
```
-## Skills (17)
+## Skills (18)
| Skill | Purpose |
| --- | --- |
@@ -51,6 +51,7 @@ VERSION - Source of truth for the repo version
| bl-info-migration | Three-step migration from legacy `bl_info` to Extensions Platform, dual-format pattern |
| vse-python | VSE timeline from Python: `.strips` vs `.sequences`, `new_effect` kwargs, 5.2 COLOR `width`/`height` bake |
| extension-runtime-and-packaging | Bundled wheels, `extension_path_user` data that survives upgrades, `online_access`, `bl_ext` names, `--command extension` build in CI |
+| timers-modal-and-threading | `bpy.app.timers` return values and `persistent`, modal operators on `event_timer_add`, worker thread + queue + main-thread drain, no timers in `--background` |
## Rules (9)
@@ -96,7 +97,7 @@ matching its `globs` does. Changing a glob changes when the rule fires.
- Unity / Godot / Unreal glTF export via the engine-export-presets contract
- Explicit exit codes matching `headless-batch-script-template` (0, then 2+)
-## Snippets (28)
+## Snippets (29)
Small standalone `.py` files at `snippets/.py`, each 5 to 75 lines.
@@ -106,15 +107,15 @@ v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namesp
AI asset pipeline track: `decimate_to_budget.py`, `convex_hull_collider.py`, `lod_chain.py` (helper duplicated, not imported), `gltf_draco_export.py`, `export_preset_unity.py`, `export_preset_godot.py`, `export_preset_unreal.py`, `setup_bake_target_image.py`, `bake_normal_high_to_low.py`, `save_baked_image.py`.
-Extensions runtime: `extension-user-data.py` (`extension_path_user` settings file and an `online_access` guard).
+Extensions runtime: `extension-user-data.py` (`extension_path_user` settings file and an `online_access` guard). Long-running work: `thread-queue-timer.py` (worker thread, `queue.Queue`, main-thread drain timer).
-## Examples (65)
+## Examples (66)
Runnable scripts at `examples//`, each asserting a real API contract with
deterministic checks (exit non-zero on failure) and optionally rendering a still via
`--output`. All of them run headless on Blender 5.2 LTS and 4.5 LTS in `blender-smoke.yml` (5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch);
-**56 of the 65 ship a render in the site gallery** at `docs/gallery/`. The other
-nine are **check-only**: they carry no `--output` path, no gallery entry, and no
+**56 of the 66 ship a render in the site gallery** at `docs/gallery/`. The other
+ten are **check-only**: they carry no `--output` path, no gallery entry, and no
hero asset. The criterion is whether the contract is expressible in pixels. An
example is check-only when its witness is a data or state fact that no scene
redesign can make visible — a datablock name, an RNA attribute's presence, a
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 71a37642..90509a12 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -276,7 +276,7 @@ The drift-check workflow enforces these on every push and PR.
## Aggregate Counts
-`README.md` declares aggregate counts (e.g. "17 skills, 9 rules, 3 templates, 28 snippets, 65 examples, and 76 showcase pieces"). The `validate-counts` job in `.github/workflows/validate.yml` enforces these substrings against the filesystem on every push and PR. Showcase pieces are counted separately from examples. When you add or remove content, update the README counts in the same commit.
+`README.md` declares aggregate counts (e.g. "18 skills, 9 rules, 3 templates, 29 snippets, 66 examples, and 76 showcase pieces"). The `validate-counts` job in `.github/workflows/validate.yml` enforces these substrings against the filesystem on every push and PR. Showcase pieces are counted separately from examples. When you add or remove content, update the README counts in the same commit.
## Pull Request Process
diff --git a/README.md b/README.md
index d8620b4a..b65017e1 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@
- 17 skills • 9 rules • 3 templates • 28 snippets • 65 examples • 76 showcase pieces
+ 18 skills • 9 rules • 3 templates • 29 snippets • 66 examples • 76 showcase pieces
@@ -44,7 +44,7 @@
## Overview
-This repository ships **17 skills, 9 rules, 3 templates, 28 snippets, 65 examples, and 76 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable.
+This repository ships **18 skills, 9 rules, 3 templates, 29 snippets, 66 examples, and 76 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable.
The content is consumed by AI coding agents reading these files directly from a checkout — **there is no MCP server in this repository, and none is required**. Both agents install it as a plugin (see [Quick start](#quick-start)): Cursor loads the skills and applies `rules/*.mdc` wherever their scope globs match; Claude Code loads the skills and gets the rules through a generated `blender-rules` skill, since it does not read Cursor `.mdc` files. An agent picks a skill up when its description matches the task. Any agent that can read files in a workspace can use it the same way. There is no build step for the content — edit the Markdown and Python files directly.
@@ -63,14 +63,14 @@ The content is consumed by AI coding agents reading these files directly from a
git clone https://github.com/TMHSDigital/Blender-Developer-Tools.git
```
-- **Cursor** — install it as a local plugin from the slim `plugin-dist` branch, then run **Developer: Reload Window**. **Customize** lists the 17 skills and 9 rules; the rules apply by glob scope and the agent loads a skill when its description matches the task.
+- **Cursor** — install it as a local plugin from the slim `plugin-dist` branch, then run **Developer: Reload Window**. **Customize** lists the 18 skills and 9 rules; the rules apply by glob scope and the agent loads a skill when its description matches the task.
```bash
git clone --branch plugin-dist --single-branch https://github.com/TMHSDigital/Blender-Developer-Tools.git ~/.cursor/plugins/local/blender-developer-tools
```
(On Windows the folder is `%USERPROFILE%\.cursor\plugins\local\`. Cursor skips symlinks that point outside that folder, so clone or copy rather than link.) Teams can import the repository as a team marketplace instead; it carries `.cursor-plugin/marketplace.json`. For one project only, copy `skills/*` into the project's `.cursor/skills/` and `rules/*.mdc` into `.cursor/rules/` from a checkout. Copying only the rules gives you no skills.
-- **Claude Code** — install as a plugin, then run `/skills` to see all 17 skills plus `blender-rules`:
+- **Claude Code** — install as a plugin, then run `/skills` to see all 18 skills plus `blender-rules`:
```text
/plugin marketplace add TMHSDigital/Blender-Developer-Tools@plugin-dist
@@ -105,7 +105,7 @@ Releases ship often; [CHANGELOG.md](CHANGELOG.md) lists what each one changed.
## Falsifiers
-Every one of the 65 examples carries a **falsifier**: a flag that changes the
+Every one of the 66 examples carries a **falsifier**: a flag that changes the
input so a real assertion fails. It never disables the assertion, skips the
check, or short-circuits to an error — it feeds the script something the
contract says must not pass, and the same check that guards the happy path
@@ -133,7 +133,7 @@ per-script exit-code model, are in
## Examples and showcase
-**65 examples** in [`examples/`](examples/) are runnable, self-checking scripts: each asserts one API contract, carries a falsifier, and runs headless on Blender 5.2 LTS and 4.5 LTS in the `blender-smoke` workflow (5.1 on the weekly cron or by manual dispatch). Those whose contract is visible also render a still. **76 showcase pieces** in [`showcase/`](showcase/) exist to show the skills working together at production scale: each is a single headless script that models a game prop, then runs it through the same cleanup, bake, LOD, collider and export steps the skills teach, and fails if a measured budget (triangle counts, colliders, materials, real-world size) is missed. They are not API examples. Conventions: [`showcase/README.md`](showcase/README.md).
+**66 examples** in [`examples/`](examples/) are runnable, self-checking scripts: each asserts one API contract, carries a falsifier, and runs headless on Blender 5.2 LTS and 4.5 LTS in the `blender-smoke` workflow (5.1 on the weekly cron or by manual dispatch). Those whose contract is visible also render a still. **76 showcase pieces** in [`showcase/`](showcase/) exist to show the skills working together at production scale: each is a single headless script that models a game prop, then runs it through the same cleanup, bake, LOD, collider and export steps the skills teach, and fails if a measured budget (triangle counts, colliders, materials, real-world size) is missed. They are not API examples. Conventions: [`showcase/README.md`](showcase/README.md).
Browse both, with filters, full-size renders and each script's README, in the **[gallery](https://tmhsdigital.github.io/Blender-Developer-Tools/gallery/)**.
@@ -151,11 +151,11 @@ Browse both, with filters, full-size renders and each script's README, in the **
## How content is organized
```
-skills//SKILL.md - 17 skill files, YAML frontmatter, one canonical pattern each
+skills//SKILL.md - 18 skill files, YAML frontmatter, one canonical pattern each
rules/.mdc - 9 rule files, anti-pattern + correction
templates// - 3 template directories (extension-addon-template, headless-batch-script-template, ai-asset-pipeline-template)
-snippets/.py - 28 standalone Python snippets, 5 to 75 lines each
-examples// - 65 example directories: script, README with exit codes and falsifier
+snippets/.py - 29 standalone Python snippets, 5 to 75 lines each
+examples// - 66 example directories: script, README with exit codes and falsifier
showcase// - 76 showcase pieces: budget-gated game props, script and README
claude/ - generated Claude Code copies of the rules (blender-rules skill + import file)
```
diff --git a/docs/new-example-prompt.md b/docs/new-example-prompt.md
index 128e4276..76ce6408 100644
--- a/docs/new-example-prompt.md
+++ b/docs/new-example-prompt.md
@@ -59,7 +59,7 @@ metadata — so the render would be identical whether the API held or broke. The
"redesign the scene until failure would be visible" instruction above is the test:
attempt it first, and only when it cannot succeed in principle does the example
become check-only. The exception is for contracts that are invisible, not for
-renders that are hard. Nine of the 65 examples currently qualify, and `CLAUDE.md`
+renders that are hard. Ten of the 66 examples currently qualify, and `CLAUDE.md`
carries the same rule. A check-only example is otherwise a full example: it still
asserts a real contract, still exits non-zero on failure, still carries a falsifier,
and still takes a `tests/smoke/catalog.json` row so it runs on every PR.
diff --git a/examples/index.json b/examples/index.json
index 758d8c03..9758dad8 100644
--- a/examples/index.json
+++ b/examples/index.json
@@ -1901,6 +1901,44 @@
"13": "`--output`: bundled `datafiles/fonts/Inter.woff2` not found"
}
},
+ {
+ "name": "timers-modal-threading",
+ "path": "examples/timers-modal-threading/",
+ "script": "examples/timers-modal-threading/timers_modal_threading.py",
+ "skills": [
+ "operators",
+ "timers-modal-and-threading"
+ ],
+ "render": false,
+ "min_version": "4.5",
+ "run": "blender --background --python examples/timers-modal-threading/timers_modal_threading.py",
+ "summary": "Proves the event-loop contracts behind long-running add-on work.",
+ "falsifiers": [
+ {
+ "args": [
+ "--return-zero-once"
+ ],
+ "expect_exit": 5
+ },
+ {
+ "args": [
+ "--non-persistent"
+ ],
+ "expect_exit": 8
+ }
+ ],
+ "exit_codes": {
+ "0": "Success",
+ "1": "Uncaught exception (FATAL wrapper)",
+ "2": "argparse / usage",
+ "3": "`--background` child failed, or its timer ran",
+ "4": "Windowed child wrote no result",
+ "5": "Timer return values not honoured (`--return-zero-once` lands here)",
+ "6": "Worker result not applied once on the main thread",
+ "7": "Modal operator did not finish and remove its timer",
+ "8": "File load did not keep only the persistent timer (`--non-persistent` lands here)"
+ }
+ },
{
"name": "triangulate-tangents",
"path": "examples/triangulate-tangents/",
diff --git a/examples/skills.json b/examples/skills.json
index 33b7009c..23add4ad 100644
--- a/examples/skills.json
+++ b/examples/skills.json
@@ -53,6 +53,7 @@
"solidify-even-thickness": ["depsgraph-and-evaluated-data", "mesh-editing-and-bmesh"],
"swatch-grid": ["procedural-materials-and-shaders"],
"temp-override-join": ["headless-batch-scripting", "operators"],
+ "timers-modal-threading": ["operators", "timers-modal-and-threading"],
"text-version-stamp": [],
"triangulate-tangents": ["mesh-editing-and-bmesh"],
"turntable": ["slotted-actions-animation"],
diff --git a/examples/timers-modal-threading/README.md b/examples/timers-modal-threading/README.md
new file mode 100644
index 00000000..e6ba7ed2
--- /dev/null
+++ b/examples/timers-modal-threading/README.md
@@ -0,0 +1,94 @@
+# Timers, modal operators and threads
+
+Proves the event-loop contracts behind long-running add-on work. Timers and
+modal operators only run where Blender has an event loop, which
+`--background` lacks. So the script runs two child Blenders: one in
+`--background`, one windowed. The windowed child quits itself from a timer and
+reports what happened as JSON. Check-only: the witnesses are call counts,
+thread identity and registration state, which no render shows.
+
+Follows [`timers-modal-and-threading`](../../skills/timers-modal-and-threading/SKILL.md),
+whose snippet is [`thread-queue-timer.py`](../../snippets/thread-queue-timer.py).
+Child-process scaffolding matches
+[`extension-package-lifecycle`](../extension-package-lifecycle/).
+
+**What it witnesses:**
+
+- In a `--background` child a timer registered with `first_interval=0.0`
+ reports `is_registered() == True` and never runs.
+- In the windowed child, a timer returning `None` runs once, and one returning
+ `0.05` until its third call runs three times. Neither is registered
+ afterwards.
+- A worker thread puts its result in a `queue.Queue`; a timer drains it on the
+ main thread (`threading.current_thread() is threading.main_thread()`) and
+ creates a mesh from it.
+- A modal operator started with `INVOKE_DEFAULT` under
+ `temp_override(window=...)` returns `{'RUNNING_MODAL'}`, receives three
+ `TIMER` events from `event_timer_add`, returns `{'FINISHED'}` and removes
+ its timer.
+- `wm.read_factory_settings` run from a timer keeps the timer registered with
+ `persistent=True` and drops the plain one.
+
+**What failure each check would catch:**
+
+- exit 3 — the `--background` child failed, or its timer ran (the no-event-loop premise is gone)
+- exit 4 — the windowed child wrote no result: no display, a crash, or a timeout
+- exit 5 — timer return values not honoured (`--return-zero-once` lands here:
+ the run-once timer returns `0.0`, which re-runs it)
+- exit 6 — the worker's result was not applied exactly once on the main thread
+- exit 7 — the modal operator did not tick to `FINISHED` and remove its timer
+- exit 8 — a file load did not keep only the persistent timer (`--non-persistent`
+ lands here: the keeper is registered without `persistent=True`)
+
+The windowed child needs a display. CI runs smoke under xvfb; on a desktop a
+Blender window opens for a few seconds.
+
+## Re-verified
+
+| Measurement | 4.5.11 | 5.1.2 | 5.2.1 |
+| --- | --- | --- | --- |
+| `--background` timer: registered / ran | yes / no | yes / no | yes / no |
+| run-once / repeat-until-3 call counts | 1 / 3 | 1 / 3 | 1 / 3 |
+| worker result applied on main thread | yes | yes | yes |
+| modal: invoke, `TIMER` ticks, result, timer removed | `RUNNING_MODAL`, 3, `FINISHED`, yes | same | same |
+| after file load: persistent / plain registered | yes / no | yes / no | yes / no |
+| default exit | 0 | 0 | 0 |
+| `--return-zero-once` / `--non-persistent` exit | 5 / 8 | 5 / 8 | 5 / 8 |
+
+Under `--return-zero-once` the run-once timer ran 5 times on 5.2.1 before the
+file load removed it.
+
+## API reference
+
+- [`bpy.app.timers`](https://docs.blender.org/api/current/bpy.app.timers.html)
+ ([4.5 LTS](https://docs.blender.org/api/4.5/bpy.app.timers.html))
+- [`WindowManager.event_timer_add`](https://docs.blender.org/api/current/bpy.types.WindowManager.html#bpy.types.WindowManager.event_timer_add)
+- [Modal operators](https://docs.blender.org/api/current/bpy.types.Operator.html#modal-execution)
+- [Threading gotchas](https://docs.blender.org/api/current/info_gotchas_threading.html)
+
+## Run
+
+```bash
+blender --background --python timers_modal_threading.py --
+blender --background --python timers_modal_threading.py -- --return-zero-once
+blender --background --python timers_modal_threading.py -- --non-persistent
+```
+
+## Exit codes
+
+| Code | Meaning |
+| --- | --- |
+| 0 | Success |
+| 1 | Uncaught exception (FATAL wrapper) |
+| 2 | argparse / usage |
+| 3 | `--background` child failed, or its timer ran |
+| 4 | Windowed child wrote no result |
+| 5 | Timer return values not honoured (`--return-zero-once` lands here) |
+| 6 | Worker result not applied once on the main thread |
+| 7 | Modal operator did not finish and remove its timer |
+| 8 | File load did not keep only the persistent timer (`--non-persistent` 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 passes no extra flags on the happy path. Its catalog falsifiers are
+`--return-zero-once` (expects exit 5) and `--non-persistent` (expects exit 8).
diff --git a/examples/timers-modal-threading/timers_modal_threading.py b/examples/timers-modal-threading/timers_modal_threading.py
new file mode 100644
index 00000000..50c24b92
--- /dev/null
+++ b/examples/timers-modal-threading/timers_modal_threading.py
@@ -0,0 +1,256 @@
+"""Timers, modal operators and worker threads — a runnable example.
+
+Long-running add-on work has three moving parts, and each depends on
+Blender's event loop. This example proves their contracts in child Blender
+processes, because the loop only exists in a windowed Blender:
+
+1. In ``--background`` a registered ``bpy.app.timers`` function never runs:
+ the script ends, Blender exits, and the timer was only ever registered.
+2. In a windowed Blender, a timer that returns ``None`` runs once and
+ unregisters; one that returns a float runs again after that many seconds.
+3. A worker thread hands its result through a ``queue.Queue``; a timer
+ drains it and touches ``bpy`` on the main thread.
+4. A modal operator driven by ``window_manager.event_timer_add`` receives
+ ``TIMER`` events, finishes, and removes its timer.
+5. ``persistent=True`` keeps a timer registered across a file load; a plain
+ timer is dropped.
+
+The windowed child quits itself through a timer, so it needs a display: CI
+runs smoke under xvfb, and on a desktop a Blender window opens briefly.
+Check-only: the witnesses are counts and registration state.
+
+ blender --background --python timers_modal_threading.py --
+ blender --background --python timers_modal_threading.py -- --return-zero-once
+ blender --background --python timers_modal_threading.py -- --non-persistent
+"""
+import argparse
+import json
+import os
+import subprocess
+import sys
+import tempfile
+
+import bpy
+
+TIMEOUT = 180
+REPEATS = 3
+MODAL_TICKS = 3
+
+BACKGROUND_CHILD = r'''
+import bpy, os
+def fire():
+ open(os.environ["BDT_TIMER_FIRED"], "w").write("fired")
+ return None
+bpy.app.timers.register(fire, first_interval=0.0)
+print("RESULT registered", bpy.app.timers.is_registered(fire))
+'''
+
+WINDOWED_CHILD = r'''
+import bpy, json, os, queue, threading, time
+OUT = os.environ["BDT_TIMER_OUT"]
+FLAGS = os.environ.get("BDT_TIMER_FLAGS", "").split()
+T0 = time.time()
+log = {"background": bpy.app.background, "once": 0, "repeat": 0, "applied": [],
+ "ticks": 0, "invoke": None, "modal_result": None, "timer_removed": False}
+
+# 2. Return value decides the next run: None unregisters, a float reschedules.
+def once():
+ log["once"] += 1
+ return 0.0 if "--return-zero-once" in FLAGS else None
+
+def repeat():
+ log["repeat"] += 1
+ return 0.05 if log["repeat"] < %(repeats)d else None
+
+# 3. The worker never touches bpy; the drain timer does, on the main thread.
+results = queue.Queue()
+
+def worker():
+ time.sleep(0.2)
+ results.put([0.0, 1.0, 2.0])
+
+threading.Thread(target=worker, daemon=True).start()
+
+def drain():
+ try:
+ heights = results.get_nowait()
+ except queue.Empty:
+ return 0.05
+ me = bpy.data.meshes.new("FromWorker")
+ log["applied"].append({"main_thread": threading.current_thread() is threading.main_thread(),
+ "values": len(heights), "mesh": me.name})
+ return None
+
+# 4. A modal operator fed by an event timer.
+class BDT_OT_modal_ticks(bpy.types.Operator):
+ bl_idname = "bdt.modal_ticks"
+ bl_label = "Modal ticks"
+ _timer = None
+
+ def invoke(self, context, event):
+ wm = context.window_manager
+ self._timer = wm.event_timer_add(0.05, window=context.window)
+ wm.modal_handler_add(self)
+ return {'RUNNING_MODAL'}
+
+ def modal(self, context, event):
+ if event.type != 'TIMER':
+ return {'PASS_THROUGH'}
+ log["ticks"] += 1
+ if log["ticks"] >= %(ticks)d:
+ self._cleanup(context)
+ log["modal_result"] = "FINISHED"
+ return {'FINISHED'}
+ return {'RUNNING_MODAL'}
+
+ def cancel(self, context):
+ self._cleanup(context)
+
+ def _cleanup(self, context):
+ context.window_manager.event_timer_remove(self._timer)
+ self._timer = None
+ log["timer_removed"] = True
+
+bpy.utils.register_class(BDT_OT_modal_ticks)
+
+def start_modal():
+ with bpy.context.temp_override(window=bpy.context.window_manager.windows[0]):
+ log["invoke"] = sorted(bpy.ops.bdt.modal_ticks('INVOKE_DEFAULT'))
+ return None
+
+# 5. A file load drops plain timers and keeps persistent ones.
+def keeper():
+ return 1.0
+
+def plain():
+ return 1.0
+
+def load_file():
+ if not (log["modal_result"] and log["applied"] and log["repeat"] >= %(repeats)d) and time.time() - T0 < 15:
+ return 0.05 # a file load drops plain timers and ends modal operators; let them finish
+ bpy.app.timers.register(keeper, first_interval=1.0,
+ persistent="--non-persistent" not in FLAGS)
+ bpy.app.timers.register(plain, first_interval=1.0)
+ log["before_load"] = [bpy.app.timers.is_registered(f) for f in (keeper, plain)]
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ log["after_load"] = [bpy.app.timers.is_registered(f) for f in (keeper, plain)]
+ return None
+
+def finish():
+ done = log["repeat"] >= %(repeats)d and log["applied"] and log["modal_result"] and "after_load" in log
+ if not done and time.time() - T0 < 20:
+ return 0.1
+ log["still_registered"] = [bpy.app.timers.is_registered(f) for f in (once, repeat, drain)]
+ with open(OUT, "w") as fh:
+ json.dump(log, fh)
+ bpy.ops.wm.quit_blender()
+ return None
+
+bpy.app.timers.register(once, first_interval=0.1)
+bpy.app.timers.register(repeat, first_interval=0.1)
+bpy.app.timers.register(drain, first_interval=0.05)
+bpy.app.timers.register(start_modal, first_interval=0.2)
+bpy.app.timers.register(load_file, first_interval=0.3)
+bpy.app.timers.register(finish, first_interval=0.5, persistent=True) # must outlive the file load
+''' % {"repeats": REPEATS, "ticks": MODAL_TICKS}
+
+
+def fail(msg, code):
+ print(f"ERROR: {msg}", file=sys.stderr)
+ return code
+
+
+def run_child(source, work, name, env_extra, background):
+ script = os.path.join(work, f"{name}.py")
+ with open(script, "w", encoding="utf-8") as fh:
+ fh.write(source)
+ args = [bpy.app.binary_path, "--factory-startup"]
+ if background:
+ args.append("--background")
+ args += ["--python", script]
+ try:
+ r = subprocess.run(args, capture_output=True, text=True, timeout=TIMEOUT,
+ env=dict(os.environ, **env_extra))
+ except subprocess.TimeoutExpired:
+ return None, ""
+ return r.returncode, r.stdout + r.stderr
+
+
+def check_background(work):
+ fired = os.path.join(work, "fired.txt")
+ code, out = run_child(BACKGROUND_CHILD, work, "background_child", {"BDT_TIMER_FIRED": fired}, True)
+ registered = "RESULT registered True" in out
+ print(f"--background child: exit {code}, registered={registered}, fired={os.path.exists(fired)}")
+ if code != 0 or not registered:
+ print(out[-2000:])
+ return fail("the --background child did not run or did not register its timer", 3)
+ if os.path.exists(fired):
+ return fail("a timer fired in --background; the no-event-loop premise is gone", 3)
+ return 0
+
+
+def check_windowed(work, flags):
+ out_json = os.path.join(work, "windowed.json")
+ code, out = run_child(WINDOWED_CHILD, work, "windowed_child",
+ {"BDT_TIMER_OUT": out_json, "BDT_TIMER_FLAGS": " ".join(flags)}, False)
+ if code is None or not os.path.isfile(out_json):
+ print(out[-3000:])
+ return fail(f"the windowed child produced no result (exit {code}); it needs a display", 4)
+ with open(out_json, encoding="utf-8") as fh:
+ log = json.load(fh)
+ print(f"windowed child: exit {code}, background={log['background']}")
+ print(f" timers: once ran {log['once']}x, repeat ran {log['repeat']}x, "
+ f"still registered (once, repeat, drain)={log['still_registered']}")
+ print(f" worker result applied: {log['applied']}")
+ print(f" modal: invoke={log['invoke']} ticks={log['ticks']} result={log['modal_result']} "
+ f"timer_removed={log['timer_removed']}")
+ print(f" file load: (persistent, plain) registered before={log.get('before_load')} "
+ f"after={log.get('after_load')}")
+
+ if log["background"]:
+ return fail("the windowed child reports bpy.app.background", 4)
+ if log["once"] != 1 or log["repeat"] != REPEATS or any(log["still_registered"]):
+ return fail(f"timer return values were not honoured (once={log['once']}, "
+ f"repeat={log['repeat']}, registered={log['still_registered']})", 5)
+ if len(log["applied"]) != 1 or not log["applied"][0]["main_thread"] or log["applied"][0]["values"] != 3:
+ return fail(f"the worker result was not applied once on the main thread: {log['applied']}", 6)
+ if log["invoke"] != ["RUNNING_MODAL"] or log["ticks"] != MODAL_TICKS \
+ or log["modal_result"] != "FINISHED" or not log["timer_removed"]:
+ return fail("the modal operator did not run its timer ticks to FINISHED and clean up", 7)
+ if log.get("before_load") != [True, True] or log.get("after_load") != [True, False]:
+ return fail(f"file load should keep only the persistent timer: "
+ f"before={log.get('before_load')} after={log.get('after_load')}", 8)
+ return 0
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ p = argparse.ArgumentParser()
+ p.add_argument("--return-zero-once", action="store_true",
+ help="falsification: the run-once timer returns 0.0 instead of None")
+ p.add_argument("--non-persistent", action="store_true",
+ help="falsification: register the keeper timer without persistent=True")
+ args = p.parse_args(argv)
+ flags = [f for f, on in (("--return-zero-once", args.return_zero_once),
+ ("--non-persistent", args.non_persistent)) if on]
+ print(f"blender={bpy.app.version_string} flags={flags}")
+
+ with tempfile.TemporaryDirectory(prefix="bdt_timers_") as work:
+ code = check_background(work)
+ if code:
+ return code
+ code = check_windowed(work, flags)
+ if code:
+ return code
+ print("timers-modal-threading OK")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(main())
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+ print(f"FATAL: {e}", file=sys.stderr)
+ sys.exit(1)
diff --git a/scripts/build_plugin_dist.py b/scripts/build_plugin_dist.py
index 3ed52e57..32743943 100644
--- a/scripts/build_plugin_dist.py
+++ b/scripts/build_plugin_dist.py
@@ -51,7 +51,7 @@
```
Cursor: clone this branch into `~/.cursor/plugins/local/blender-developer-tools`
-and reload the window (Customize then lists the 17 skills and 9 rules).
+and reload the window (Customize then lists the 18 skills and 9 rules).
Skills reference bundled files as `${CLAUDE_PLUGIN_ROOT}/snippets/...`, which
Claude Code expands to this plugin's install directory. In Cursor, read it as
diff --git a/site.json b/site.json
index 7e7aa836..ac0051ed 100644
--- a/site.json
+++ b/site.json
@@ -18,8 +18,8 @@
"github": "https://github.com/TMHSDigital/Blender-Developer-Tools"
},
"installSteps": [
- "Claude Code: /plugin marketplace add TMHSDigital/Blender-Developer-Tools@plugin-dist then /plugin install blender-developer-tools@blender-developer-tools. The 17 skills load when a task matches; the rules come along as the blender-rules skill",
- "Cursor: git clone --branch plugin-dist --single-branch https://github.com/TMHSDigital/Blender-Developer-Tools.git ~/.cursor/plugins/local/blender-developer-tools, then reload the window; Customize lists the 17 skills and 9 rules. This clones only the 0.3 MB plugin build",
+ "Claude Code: /plugin marketplace add TMHSDigital/Blender-Developer-Tools@plugin-dist then /plugin install blender-developer-tools@blender-developer-tools. The 18 skills load when a task matches; the rules come along as the blender-rules skill",
+ "Cursor: git clone --branch plugin-dist --single-branch https://github.com/TMHSDigital/Blender-Developer-Tools.git ~/.cursor/plugins/local/blender-developer-tools, then reload the window; Customize lists the 18 skills and 9 rules. This clones only the 0.3 MB plugin build",
"Other agents (Codex, Copilot, Windsurf): clone the repo and copy skills/* into your agent's skills folder (for example .agents/skills/); point it at claude/blender-rules.md for the rules",
"Grab snippets/ and templates/ as starting points for add-ons and headless batch jobs"
],
diff --git a/skills/operators/SKILL.md b/skills/operators/SKILL.md
index 1f4148f6..c49494a6 100644
--- a/skills/operators/SKILL.md
+++ b/skills/operators/SKILL.md
@@ -219,6 +219,7 @@ Each example runs headless, asserts the contract, and exits non-zero when it bre
- [`prop-origin-transform`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/prop-origin-transform): Street pedestal origin-to-base-center + data-API scale apply + matrix_parent_inverse for a flanged conduit elbow. Falsify: `--skip-mpi` (exit 8).
- [`temp-override-join`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/temp-override-join): Join seven lantern parts into one object under bpy.context.temp_override — the supported replacement for the removed context.copy() dict-pass form. Falsify: `--no-override` (exit 3).
+- [`timers-modal-threading`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/timers-modal-threading): Proves the event-loop contracts behind long-running add-on work. Falsify: `--return-zero-once` (exit 5).
diff --git a/skills/timers-modal-and-threading/SKILL.md b/skills/timers-modal-and-threading/SKILL.md
new file mode 100644
index 00000000..4e68723f
--- /dev/null
+++ b/skills/timers-modal-and-threading/SKILL.md
@@ -0,0 +1,156 @@
+---
+name: timers-modal-and-threading
+description: "Run long or repeated work in a Blender add-on without freezing or crashing it: bpy.app.timers return values and persistent=True, modal operators driven by event_timer_add, and worker threads that hand results to the main thread through a queue. Use when the user polls, animates or downloads from an add-on, calls bpy from a thread, writes a modal operator, sees a timer never fire in --background, or loses a timer after opening a file. Targets 5.2 LTS with 4.5 LTS fallback."
+standards-version: 1.10.0
+---
+
+# Timers, Modal Operators and Threading
+
+## Trigger
+
+Use this skill when the user:
+
+- Wants something to happen later or repeatedly (polling, autosave, a progress readout)
+- Runs slow work (network, file processing, a solver) and the UI freezes
+- Calls `bpy` from a `threading.Thread` and gets crashes or corrupted data
+- Writes a modal operator, or one that never stops
+- Registers a timer in `blender --background` and it never runs
+- Loses a timer after File → Open or `wm.read_homefile`
+
+## Required inputs
+
+- **What runs, how often, and for how long**
+- **Whether it runs headless** (`--background`) or in a windowed Blender
+- **Whether the work must survive file loads** (an add-on service) or belongs to one file
+
+## The event loop is the whole story
+
+Timers, modal operators and `event_timer_add` are all driven by Blender's window-manager event loop. A windowed Blender has one. `blender --background --python script.py` does not: the script runs, Blender exits, and a registered timer never runs (measured on 4.5.11, 5.1.2 and 5.2.1: `is_registered` is `True` and the callback never executes). Headless work must run synchronously in the script; there is no deferred callback to wait for.
+
+## `bpy.app.timers`: the return value schedules the next run
+
+```python
+import bpy
+
+def poll():
+ if done():
+ return None # unregister: this was the last run
+ return 0.5 # run again in 0.5 s
+
+bpy.app.timers.register(poll, first_interval=0.5)
+```
+
+- `None` unregisters; a float re-runs after that many seconds. Measured in a windowed child: a `None` timer ran once, and one returning `0.05` until its third call ran exactly three times. Afterwards `is_registered` was `False` for both.
+- Returning `0.0` does not mean "stop": it re-runs on the next event-loop pass. A timer meant to run once that returns `0.0` ran 5 times before a file load removed it.
+- `first_interval` is keyword-only: `register(poll, 0.5)` raises `TypeError: register() takes exactly 1 positional argument (2 given)` on 4.5.11, 5.1.2 and 5.2.1.
+- **A file load drops every timer not registered with `persistent=True`.** Measured: after `wm.read_factory_settings` inside a timer, the persistent timer was still registered and the plain one was not. Add-on services that must outlive File → Open need `persistent=True`. Per-file work should be plain, so it does not leak into the next file.
+- Unregister in `unregister()`: `if bpy.app.timers.is_registered(poll): bpy.app.timers.unregister(poll)`.
+
+## Threads: compute off the main thread, touch `bpy` on it
+
+`bpy` is not thread-safe. A worker thread may compute, download or read files, but every `bpy` read and write happens on the main thread. Hand results over through a `queue.Queue`, and drain it from a timer:
+
+```python
+import queue
+import threading
+import bpy
+
+results = queue.Queue()
+
+def worker(url):
+ data = download(url) # no bpy here
+ results.put(data)
+
+def drain():
+ try:
+ data = results.get_nowait()
+ except queue.Empty:
+ return 0.1 # nothing yet; look again
+ apply_to_scene(data) # bpy, on the main thread
+ return None
+
+threading.Thread(target=worker, args=(URL,), daemon=True).start()
+bpy.app.timers.register(drain, first_interval=0.1)
+```
+
+Measured: the drain timer ran with `threading.current_thread() is threading.main_thread()` true, and created a mesh from the worker's result. Use `daemon=True` so a hung worker cannot keep Blender from quitting. For network work, check `bpy.app.online_access` first (see `extension-runtime-and-packaging`).
+
+## Modal operators: `event_timer_add` plus `modal()`
+
+```python
+class MYADDON_OT_watch(bpy.types.Operator):
+ bl_idname = "myaddon.watch"
+ bl_label = "Watch"
+
+ _timer = None
+
+ def invoke(self, context, event):
+ wm = context.window_manager
+ self._timer = wm.event_timer_add(0.1, window=context.window)
+ wm.modal_handler_add(self)
+ return {'RUNNING_MODAL'}
+
+ def modal(self, context, event):
+ if event.type == 'ESC':
+ self.cancel(context)
+ return {'CANCELLED'}
+ if event.type != 'TIMER':
+ return {'PASS_THROUGH'} # let the UI keep working
+ if step(context): # True when the job is done
+ self.cancel(context)
+ return {'FINISHED'}
+ return {'RUNNING_MODAL'}
+
+ def cancel(self, context):
+ if self._timer is not None:
+ context.window_manager.event_timer_remove(self._timer)
+ self._timer = None
+```
+
+- Return `{'PASS_THROUGH'}` for events you do not handle, or the operator swallows all input and the UI looks frozen.
+- `bpy.types.Event` has **no `timer` attribute** (absent from its RNA on 4.5.11 and 5.2.1). `event.timer is self._timer` raises `AttributeError` inside `modal()`. Test `event.type == 'TIMER'`; if several timers feed one operator, count or time the ticks yourself.
+- Remove the timer on every exit path: your own `FINISHED` and `CANCELLED` returns, and `cancel()`, which Blender calls when it ends a running modal operator itself.
+- Measured in a windowed child: `invoke` returned `{'RUNNING_MODAL'}`, `modal()` received three `TIMER` events, then returned `{'FINISHED'}` and removed its timer.
+- To start a modal operator from a timer or a script, there is no window in context; supply one with `with bpy.context.temp_override(window=bpy.context.window_manager.windows[0]): bpy.ops.myaddon.watch('INVOKE_DEFAULT')`.
+
+For a progress readout, call `context.window_manager.progress_begin(0, total)`, `progress_update(i)` and `progress_end()` from the main thread: the modal step or the drain timer, never the worker.
+
+## Common AI mistakes
+
+1. **Calling `bpy` from a worker thread** (`bpy.data.objects.new` inside `Thread.run`). Compute in the thread; apply on the main thread from a timer.
+2. **`time.sleep()` or a `while` loop in an operator** to wait for work. It blocks the event loop. Use a timer or a modal operator.
+3. **Expecting timers to fire in `--background`**. There is no event loop; run the work directly in the script.
+4. **A run-once timer that returns `0`**. Zero means "again on the next pass"; return `None` to stop.
+5. **`bpy.app.timers.register(fn, 1.0)`**. `first_interval` is keyword-only on every supported version.
+6. **Add-on timers without `persistent=True`**. They vanish on the first File → Open.
+7. **`event.timer` in `modal()`**. The attribute does not exist; test `event.type == 'TIMER'`.
+8. **Never removing the modal timer**. Remove it in `cancel()` and on every return that ends the operator.
+
+## Compatibility paths
+
+The behaviour above was measured identically on 4.5 LTS, 5.1 and 5.2 LTS. The only visible difference is cosmetic: 5.2's docstring shows `register(function, *, first_interval=0, persistent=False)`, while 4.5 documents the same keyword-only call without the `*`. No version branch is needed.
+
+## Related
+
+- `operators`: operator lifecycle, `bl_options`, `invoke` versus `execute`
+- `drivers-and-app-handlers`: `@persistent` application handlers, the other way to react to file loads
+- `headless-batch-scripting`: what to do instead when there is no event loop
+- `extension-runtime-and-packaging`: `bpy.app.online_access` before network work in a worker
+- Snippet: [`snippets/thread-queue-timer.py`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/snippets/thread-queue-timer.py)
+
+
+## Runnable examples
+
+Each example runs headless, asserts the contract, and exits non-zero when it breaks. Run one with `blender --background --python