From ffa9d30a5974704185a41e147bbfad384866add3 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Sat, 18 Jul 2026 14:48:43 -0400 Subject: [PATCH] feat: add damped-track-aim smoke-gated example Teach Object.constraints.new('DAMPED_TRACK') with evaluated aim checks so headless scripts stop reaching for constraint ops and TRACK_TO. Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 1 + .github/workflows/blender-smoke.yml | 10 + AGENTS.md | 4 +- CLAUDE.md | 6 +- README.md | 19 +- .../gallery/assets/damped-track-aim-hero.webp | Bin 0 -> 13988 bytes docs/gallery/damped-track-aim/index.html | 555 ++++++++++++++++++ docs/gallery/index.html | 12 + examples/damped-track-aim/README.md | 28 + examples/damped-track-aim/damped_track_aim.py | 313 ++++++++++ examples/damped-track-aim/preview.webp | Bin 0 -> 13108 bytes examples/gallery.json | 108 +++- 12 files changed, 1020 insertions(+), 36 deletions(-) create mode 100644 docs/gallery/assets/damped-track-aim-hero.webp create mode 100644 docs/gallery/damped-track-aim/index.html create mode 100644 examples/damped-track-aim/README.md create mode 100644 examples/damped-track-aim/damped_track_aim.py create mode 100644 examples/damped-track-aim/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index b18ab6c..c119868 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -62,6 +62,7 @@ "examples/bmesh-gear", "examples/compositor-glare", "examples/curve-bevel-arc", + "examples/damped-track-aim", "examples/depsgraph-export", "examples/driver-wave", "examples/gn-instance-grid", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 14cb14b..15789c1 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -243,3 +243,13 @@ jobs: # pixels with it off. Exits non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/compositor-glare/compositor_glare.py -- + + - name: Shipped example - damped-track aim (DAMPED_TRACK data API) + run: | + set -euo pipefail + # Frame-independent check only (no render): twelve needles aim at an + # emissive core via Object.constraints.new('DAMPED_TRACK') on TRACK_Z; + # asserts one unmuted constraint per needle and evaluated local +Z + # aligns toward the core (dot ≥ 0.998). Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/damped-track-aim/damped_track_aim.py -- diff --git a/AGENTS.md b/AGENTS.md index e9651ce..0c480bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ The content base (counts are CI-enforced against README.md and the manifest): - 2 templates: `extension-addon-template` for Extensions Platform add-ons, and `headless-batch-script-template` for unattended batch jobs. - 17 snippets covering canonical patterns. -- 13 examples under `examples//`: runnable scripts that assert a real +- 14 examples under `examples//`: runnable scripts that assert a real API contract with deterministic checks, exit non-zero on failure, and optionally render a still via `--output`. Each is executed headless on Blender 4.5 LTS and 5.1 by `blender-smoke.yml`; its render ships in the @@ -41,7 +41,7 @@ Blender-Developer-Tools/ rules/.mdc # 6 rule files templates// # 2 starter templates snippets/.py # 17 standalone Python snippets - examples// # 13 runnable smoke-gated examples (+ gallery.json) + examples// # 14 runnable smoke-gated examples (+ gallery.json) scripts/build_gallery.py # generates docs/gallery/ (stdlib only) scripts/site/ # vendored landing-page build (build_site.py + template) docs/gallery/ # committed generated gallery pages + hero assets diff --git a/CLAUDE.md b/CLAUDE.md index fe835d4..399e031 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ skills//SKILL.md - AI workflow definitions, 12 total rules/.mdc - Anti-pattern rules, 6 total templates// - Starter projects, 2 total snippets/.py - Standalone code patterns, 17 total -examples// - Runnable smoke-gated examples, 13 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 14 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 @@ -80,11 +80,11 @@ 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 (13) +## Examples (14) 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 thirteen run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; +`--output`. All fourteen run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; their renders ship in the site gallery at `docs/gallery/`. `examples/gallery.json` is the gallery's source of truth. When authoring a new one, copy the anatomy of `examples/bmesh-gear/` (script structure, README shape, dark-studio render recipe) and diff --git a/README.md b/README.md index be11ce4..73d7bb3 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,14 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  13 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  14 examples

--- ## Overview -This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 13 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. +This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 14 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. The content is consumed by AI coding agents (Cursor, Claude Code, any MCP-capable client) when working on Blender add-ons, geometry nodes scripts, batch pipelines, or animation tooling. There is no build step. Edit the markdown and Python files directly. @@ -233,6 +233,21 @@ node fed by `Render Layers`, wired via `scene.compositing_node_group` on 5.x and `scene.node_tree` on 4.x. Witnesses with pixels that the halo falls off strictly with the compositor on and is exactly zero with it off — and that EEVEE has no `use_bloom`. + + + + +Damped Track aim: twelve metallic needles in a ring around a cyan emissive core, each tip constrained to point at the core on a dark studio floor + + + +### [damped-track-aim](examples/damped-track-aim/) + +Aim constraints via the data API — `Object.constraints.new('DAMPED_TRACK')` with +`target` and `TRACK_Z`, not `bpy.ops.object.constraint_add` in a headless loop. +Asserts twelve unmuted Damped Track constraints and evaluated local `+Z` alignment +toward the core (dot ≥ 0.998). + diff --git a/docs/gallery/assets/damped-track-aim-hero.webp b/docs/gallery/assets/damped-track-aim-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..b9c06fbc57bba995895ba751f3186b960c1de214 GIT binary patch literal 13988 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

I%TX~a&uC#5ewPV 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 Kx#2OW0000PV>;IW literal 0 HcmV?d00001 diff --git a/docs/gallery/damped-track-aim/index.html b/docs/gallery/damped-track-aim/index.html new file mode 100644 index 0000000..190d3c7 --- /dev/null +++ b/docs/gallery/damped-track-aim/index.html @@ -0,0 +1,555 @@ + + + + + + damped-track-aim — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +

+
+

damped-track-aim

+

Aim constraints via the data API — Object.constraints.new('DAMPED_TRACK') with target and TRACK_Z, not bpy.ops.object.constraint_add in a headless loop.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses Every needle has one unmuted DAMPED_TRACK on the core; evaluated local +Z aligns toward the core (dot ≥ 0.998). TRACK_TO stand-ins and flipped axes fail.
+
+
blender --background --python examples/damped-track-aim/damped_track_aim.py --
+ +
+
+

A runnable example that aims twelve metallic needles at an emissive core with Object.constraints.new('DAMPED_TRACK') — the data-API path, not bpy.ops.object.constraint_add (which needs an active object and fails in headless loops). Damped Track is the twist-stable aim constraint: it points one local axis at a target without the roll fights Track To is known for.

+

What it witnesses: every needle carries exactly one unmuted DAMPED_TRACK bound to the core on TRACK_Z. After a depsgraph update, each evaluated local +Z aligns with the world vector toward the core (dot ≥ 0.998 ≈ 3°). A missing constraint, a muted one, a TRACK_TO stand-in, or a flipped axis fails the check.

+

Run

+
# Cheap correctness check (no render) — the CI check:
+blender --background --python damped_track_aim.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python damped_track_aim.py -- --output aim.png
+blender --background --python damped_track_aim.py -- --output aim.png --engine cycles
+

It exits non-zero on failure (wrong constraint type/target/axis, or evaluated aim outside the angular epsilon). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

+
+
+

Source

+
+ examples/damped-track-aim/damped_track_aim.py + View on GitHub → +
+
"""Damped Track needles aiming at an emissive core — a runnable example.
+
+Witnesses that aim constraints are authored on the data API
+(`Object.constraints.new('DAMPED_TRACK')`, `target`, `track_axis`) — not via
+`bpy.ops.object.constraint_add` (which needs an active object and breaks in
+headless loops). Damped Track is the twist-stable replacement for Track To
+when you only need an axis to point at a target.
+
+The check asserts every needle carries exactly one DAMPED_TRACK aimed at the
+core on TRACK_Z, then samples the evaluated depsgraph: local +Z must align
+with the world vector toward the core within a tight angular epsilon. If the
+constraint is missing, muted, mistyped as TRACK_TO, or the axis is flipped,
+the dot product fails.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python damped_track_aim.py --                 # check only
+    blender --background --python damped_track_aim.py -- --output aim.png
+"""
+import bpy, bmesh, sys, os, math, argparse
+from mathutils import Vector
+
+N_NEEDLES = 12
+ORBIT_RADIUS = 2.05
+CORE_RADIUS = 0.38
+NEEDLE_DEPTH = 1.15
+NEEDLE_RADIUS = 0.09
+# Local +Z must face the core; cos(3°) ≈ 0.9986 — leave a little room for
+# cone tessellation / float noise while still catching a flipped axis.
+MIN_AIM_DOT = 0.998
+
+
+def orbit_positions(n, radius):
+    """Ring in XY plus two polar needles — every tip reads clearly in a 3/4 view."""
+    pts = []
+    ring = n - 2
+    for i in range(ring):
+        a = 2.0 * math.pi * i / ring
+        pts.append(Vector((radius * math.cos(a), radius * math.sin(a), 0.0)))
+    pts.append(Vector((0.0, 0.0, radius)))
+    pts.append(Vector((0.0, 0.0, -radius)))
+    return pts
+
+
+def make_needle_mesh():
+    """Unit cone along +Z (tip at +Z) — TRACK_Z then aims the tip at the core."""
+    me = bpy.data.meshes.new("Needle")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_cone(
+            bm,
+            cap_ends=True,
+            segments=10,
+            radius1=NEEDLE_RADIUS,
+            radius2=0.0,
+            depth=NEEDLE_DEPTH,
+        )
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    return me
+
+
+def make_core_mesh():
+    me = bpy.data.meshes.new("Core")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_uvsphere(bm, u_segments=24, v_segments=16, radius=CORE_RADIUS)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    return me
+
+
+def make_metal(name, color, roughness=0.28, metallic=1.0):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = (*color, 1.0)
+    bsdf.inputs["Roughness"].default_value = roughness
+    bsdf.inputs["Metallic"].default_value = metallic
+    return mat
+
+
+def make_emissive(name, color, strength):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nodes = mat.node_tree.nodes
+    links = mat.node_tree.links
+    nodes.clear()
+    out = nodes.new("ShaderNodeOutputMaterial")
+    emit = nodes.new("ShaderNodeEmission")
+    emit.inputs["Color"].default_value = (*color, 1.0)
+    emit.inputs["Strength"].default_value = strength
+    links.new(emit.outputs["Emission"], out.inputs["Surface"])
+    return mat
+
+
+def build():
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    col = bpy.context.collection
+
+    # Lift the whole constellation so the south polar needle clears the floor.
+    lift = ORBIT_RADIUS + 0.25
+
+    core = bpy.data.objects.new("Core", make_core_mesh())
+    # Modest emission so the cyan reads as colour, not a white clip.
+    core.data.materials.append(make_emissive("CoreEmit", (0.25, 0.75, 1.0), 4.5))
+    core.location = (0.0, 0.0, lift)
+    col.objects.link(core)
+
+    needle_me = make_needle_mesh()
+    metal = make_metal("NeedleMetal", (0.82, 0.78, 0.70), roughness=0.18)
+    needle_me.materials.append(metal)
+
+    needles = []
+    for i, loc in enumerate(orbit_positions(N_NEEDLES, ORBIT_RADIUS)):
+        ob = bpy.data.objects.new(f"Needle_{i:02d}", needle_me)
+        ob.location = (loc.x, loc.y, loc.z + lift)
+        # Deliberate identity rotation — the constraint alone must do the aiming.
+        ob.rotation_euler = (0.0, 0.0, 0.0)
+        col.objects.link(ob)
+        con = ob.constraints.new("DAMPED_TRACK")
+        con.name = "AimCore"
+        con.target = core
+        con.track_axis = "TRACK_Z"
+        needles.append(ob)
+
+    bpy.context.view_layer.update()
+    return core, needles
+
+
+def check(core, needles):
+    if len(needles) != N_NEEDLES:
+        print(f"ERROR: needle count {len(needles)} != {N_NEEDLES}", file=sys.stderr)
+        return 3
+
+    for ob in needles:
+        damped = [c for c in ob.constraints if c.type == "DAMPED_TRACK"]
+        if len(damped) != 1:
+            print(
+                f"ERROR: {ob.name} has {len(damped)} DAMPED_TRACK constraints "
+                f"(total constraints={len(ob.constraints)})",
+                file=sys.stderr,
+            )
+            return 4
+        con = damped[0]
+        if con.target != core:
+            print(f"ERROR: {ob.name} target is {con.target!r}, want Core", file=sys.stderr)
+            return 5
+        if con.track_axis != "TRACK_Z":
+            print(
+                f"ERROR: {ob.name} track_axis={con.track_axis!r}, want TRACK_Z",
+                file=sys.stderr,
+            )
+            return 6
+        if con.mute or con.influence < 0.999:
+            print(
+                f"ERROR: {ob.name} mute={con.mute} influence={con.influence}",
+                file=sys.stderr,
+            )
+            return 7
+        if any(c.type == "TRACK_TO" for c in ob.constraints):
+            print(
+                f"ERROR: {ob.name} still has TRACK_TO — use DAMPED_TRACK for aim",
+                file=sys.stderr,
+            )
+            return 8
+
+    dg = bpy.context.evaluated_depsgraph_get()
+    core_loc = core.evaluated_get(dg).matrix_world.translation
+    worst = 2.0
+    worst_name = needles[0].name
+    for ob in needles:
+        ev = ob.evaluated_get(dg)
+        mw = ev.matrix_world
+        tip_dir = (mw.to_3x3() @ Vector((0.0, 0.0, 1.0))).normalized()
+        to_core = (core_loc - mw.translation).normalized()
+        dot = tip_dir.dot(to_core)
+        if dot < worst:
+            worst = dot
+            worst_name = ob.name
+        if dot < MIN_AIM_DOT:
+            angle = math.degrees(math.acos(max(-1.0, min(1.0, dot))))
+            print(
+                f"ERROR: {ob.name} aim dot={dot:.6f} ({angle:.2f}°) "
+                f"< {MIN_AIM_DOT} — constraint not evaluated or axis flipped",
+                file=sys.stderr,
+            )
+            return 9
+
+    print(
+        f"OK: {N_NEEDLES} DAMPED_TRACK needles → Core on TRACK_Z; "
+        f"worst aim dot={worst:.6f} ({worst_name})"
+    )
+    return 0
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def render_still(core, needles, path, engine):
+    scene = bpy.context.scene
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = make_metal("StudioFloor", (0.045, 0.05, 0.055), roughness=0.42, metallic=0.35)
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 9.0, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.006, 0.007, 0.01, 1.0)
+    scene.world = world
+
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, ORBIT_RADIUS + 0.25)
+    scene.collection.objects.link(aim)
+
+    def light(name, loc, energy, size, col):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        scene.collection.objects.link(ob)
+        lc = ob.constraints.new("TRACK_TO")
+        lc.target = aim
+        lc.track_axis = "TRACK_NEGATIVE_Z"
+        lc.up_axis = "UP_Y"
+
+    light("Key", (-4.0, -4.5, 6.5), 900.0, 6.0, (1.0, 0.97, 0.92))
+    light("Fill", (5.0, -3.0, 3.8), 220.0, 8.0, (0.75, 0.85, 1.0))
+    light("Rim", (1.2, 5.0, 3.2), 380.0, 4.0, (1.0, 0.7, 0.4))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (5.2, -5.6, 4.4)
+    scene.collection.objects.link(cam)
+    scene.camera = cam
+    track = cam.constraints.new("TRACK_TO")
+    track.target = aim
+    track.track_axis = "TRACK_NEGATIVE_Z"
+    track.up_axis = "UP_Y"
+
+    # Keep references live for the renderer (needles share one mesh).
+    _ = (core, needles)
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 48
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = "PNG"
+    scene.render.filepath = path
+    bpy.ops.render.render(write_still=True)
+    return os.path.exists(path) and os.path.getsize(path) > 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None, help="optional: render a still PNG here")
+    p.add_argument(
+        "--engine",
+        default="eevee",
+        choices=("eevee", "cycles"),
+        help="render engine when --output is set",
+    )
+    args = p.parse_args(argv)
+
+    core, needles = build()
+    code = check(core, needles)
+    if code != 0:
+        return code
+
+    if args.output:
+        ok = render_still(core, needles, args.output, args.engine)
+        if not ok:
+            print(f"ERROR: render failed to write {args.output}", file=sys.stderr)
+            return 10
+        print(f"Wrote {args.output}")
+    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)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 510d018..9a5c2b2 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -178,6 +178,7 @@

Examples Gallery

+ @@ -338,6 +339,17 @@

compositor-glare

View example +
+ + damped-track-aim — Aim constraints via the data API — Object + +
+

damped-track-aim

+

Aim constraints via the data API — Object.constraints.new('DAMPED_TRACK') with target and TRACK_Z, not bpy.ops.object.constraint_add in a headless loop.

+

witnesses Every needle has one unmuted DAMPED_TRACK on the core; evaluated local +Z aligns toward the core (dot ≥ 0.998). TRACK_TO stand-ins and flipped axes fail.

+ View example +
+