!+qy_ECuq9MteI~L?8A&I2KR_57^%^qUvcc5H+
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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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+
+ 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 --
+
Copy
+
+
+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
+
+ """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 )
+
+
+
+
+
+
+
+
+
+
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
bevel
bmesh
compositor
+ constraints
context
curves
depsgraph
@@ -338,6 +339,17 @@
View example →
+
+
+
+
+
+
+
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 →
+
+
diff --git a/examples/damped-track-aim/README.md b/examples/damped-track-aim/README.md
new file mode 100644
index 0000000..49ad5d6
--- /dev/null
+++ b/examples/damped-track-aim/README.md
@@ -0,0 +1,28 @@
+# Damped Track Aim
+
+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
+
+```bash
+# 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.
diff --git a/examples/damped-track-aim/damped_track_aim.py b/examples/damped-track-aim/damped_track_aim.py
new file mode 100644
index 0000000..b4421e0
--- /dev/null
+++ b/examples/damped-track-aim/damped_track_aim.py
@@ -0,0 +1,313 @@
+"""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)
diff --git a/examples/damped-track-aim/preview.webp b/examples/damped-track-aim/preview.webp
new file mode 100644
index 0000000000000000000000000000000000000000..1262e8a6bf34a96ca1291b6ad85ffbb0e639debe
GIT binary patch
literal 13108
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
OP3P6_15bB=0002RABp$?
literal 0
HcmV?d00001
diff --git a/examples/gallery.json b/examples/gallery.json
index 2e228ae..9699fe7 100644
--- a/examples/gallery.json
+++ b/examples/gallery.json
@@ -1,18 +1,21 @@
{
"_comment": "FORWARD-COMPATIBLE SOURCE OF TRUTH for the examples gallery. The local page at docs/gallery/index.html is GENERATED from this file by scripts/build_gallery.py -- do not hand-edit the HTML. When the fleet template (Developer-Tools-Directory: site-template/build_site.py + template.html.j2) gains examples support (see ROADMAP: 'Fleet Pages examples support'), it reads this same file and the local page is retired. That migration is a lift-and-shift, not a rewrite: keep this schema stable. Per-entry schema: {name, dir, teaches, witnessesFix, hero, preview, tags?}; hero/preview/dir are repo-root-relative; tags is an optional additive list driving the gallery filter chips. build_gallery.py also emits a detail page per example at docs/gallery//.",
"title": "Examples Gallery",
- "description": "Runnable, smoke-gated Blender Python examples — each executed headless on Blender 4.5 LTS and 5.1, so every render reflects code that actually runs.",
+ "description": "Runnable, smoke-gated Blender Python examples \u2014 each executed headless on Blender 4.5 LTS and 5.1, so every render reflects code that actually runs.",
"repoBaseUrl": "https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main",
"siteBaseUrl": "https://tmhsdigital.github.io/Blender-Developer-Tools",
"examples": [
{
"name": "swatch-grid",
"dir": "examples/swatch-grid",
- "teaches": "Procedural Principled materials — metal and dielectric, the emission pattern, and the cross-version set_specular shim.",
- "witnessesFix": "EEVEE engine-id mapping: BLENDER_EEVEE on 5.x, BLENDER_EEVEE_NEXT on 4.2–4.5.",
+ "teaches": "Procedural Principled materials \u2014 metal and dielectric, the emission pattern, and the cross-version set_specular shim.",
+ "witnessesFix": "EEVEE engine-id mapping: BLENDER_EEVEE on 5.x, BLENDER_EEVEE_NEXT on 4.2\u20134.5.",
"hero": "docs/gallery/assets/swatch-grid-hero.webp",
"preview": "examples/swatch-grid/preview.webp",
- "tags": ["materials", "rendering"]
+ "tags": [
+ "materials",
+ "rendering"
+ ]
},
{
"name": "turntable",
@@ -21,106 +24,153 @@
"witnessesFix": "Slotted-actions boundary: ensure-helper channelbag on 5.x, strip.channelbag on 4.4/4.5.",
"hero": "docs/gallery/assets/turntable-hero.webp",
"preview": "examples/turntable/preview.webp",
- "tags": ["animation"]
+ "tags": [
+ "animation"
+ ]
},
{
"name": "gn-sdf-remesh",
"dir": "examples/gn-sdf-remesh",
- "teaches": "A Geometry Nodes SDF remesh (MeshToSDFGrid → GridToMesh at the SDF zero-level), with a Set Material node carrying the material through the remesh.",
+ "teaches": "A Geometry Nodes SDF remesh (MeshToSDFGrid \u2192 GridToMesh at the SDF zero-level), with a Set Material node carrying the material through the remesh.",
"witnessesFix": "An SDF grid is meshed with Grid to Mesh, not Volume to Mesh; GN geometry needs Set Material or it renders untextured.",
"hero": "docs/gallery/assets/gn-sdf-remesh-hero.webp",
"preview": "examples/gn-sdf-remesh/preview.webp",
- "tags": ["geometry-nodes", "materials"]
+ "tags": [
+ "geometry-nodes",
+ "materials"
+ ]
},
{
"name": "depsgraph-export",
"dir": "examples/depsgraph-export",
- "teaches": "The depsgraph lifetime contract — evaluated_get().to_mesh() paired with to_mesh_clear() — measured against an OBJ export of the same object.",
+ "teaches": "The depsgraph lifetime contract \u2014 evaluated_get().to_mesh() paired with to_mesh_clear() \u2014 measured against an OBJ export of the same object.",
"witnessesFix": "Exports ship evaluated geometry: the exported vertex count equals the subsurf-applied count and is strictly greater than the base mesh.",
"hero": "docs/gallery/assets/depsgraph-export-hero.webp",
"preview": "examples/depsgraph-export/preview.webp",
- "tags": ["depsgraph", "export"]
+ "tags": [
+ "depsgraph",
+ "export"
+ ]
},
{
"name": "wave-displace",
"dir": "examples/wave-displace",
- "teaches": "Bulk vertex IO at real scale — 9,409 vertices displaced into a standing wave with one foreach_get and one foreach_set, no per-vertex access.",
+ "teaches": "Bulk vertex IO at real scale \u2014 9,409 vertices displaced into a standing wave with one foreach_get and one foreach_set, no per-vertex access.",
"witnessesFix": "The bulk path is correct, not just fast: vertex count unchanged, Z span matches the wave amplitude, probe vertex matches the closed form exactly.",
"hero": "docs/gallery/assets/wave-displace-hero.webp",
"preview": "examples/wave-displace/preview.webp",
- "tags": ["mesh", "performance"]
+ "tags": [
+ "mesh",
+ "performance"
+ ]
},
{
"name": "driver-wave",
"dir": "examples/driver-wave",
- "teaches": "A driver_namespace function driving sixteen column heights through SCRIPTED drivers — the sine skyline is entirely driver-evaluated.",
+ "teaches": "A driver_namespace function driving sixteen column heights through SCRIPTED drivers \u2014 the sine skyline is entirely driver-evaluated.",
"witnessesFix": "Driven values appear after a view-layer update in two places that must agree: the evaluated copy and the original datablock the animation system flushes for display.",
"hero": "docs/gallery/assets/driver-wave-hero.webp",
"preview": "examples/driver-wave/preview.webp",
- "tags": ["drivers", "animation"]
+ "tags": [
+ "drivers",
+ "animation"
+ ]
},
{
"name": "bmesh-gear",
"dir": "examples/bmesh-gear",
- "teaches": "A 14-tooth gear built entirely with bmesh — profile ring, face, extrude — with bm.free() in a try/finally, exactly as the ownership contract demands.",
+ "teaches": "A 14-tooth gear built entirely with bmesh \u2014 profile ring, face, extrude \u2014 with bm.free() in a try/finally, exactly as the ownership contract demands.",
"witnessesFix": "Parametric bmesh topology is exactly predictable: verts, edges, and faces match their closed forms, and every edge borders exactly two faces (watertight).",
"hero": "docs/gallery/assets/bmesh-gear-hero.webp",
"preview": "examples/bmesh-gear/preview.webp",
- "tags": ["mesh", "bmesh"]
+ "tags": [
+ "mesh",
+ "bmesh"
+ ]
},
{
"name": "shader-node-group",
"dir": "examples/shader-node-group",
- "teaches": "One reusable shader group declared via tree.interface.new_socket, instanced in two materials with different Tint values — two spheres, one group, two colors.",
+ "teaches": "One reusable shader group declared via tree.interface.new_socket, instanced in two materials with different Tint values \u2014 two spheres, one group, two colors.",
"witnessesFix": "Grouping contract: interface sockets appear on every instance, both materials share one group datablock (users == 2), and per-material parameters live on the group node, not inside the tree.",
"hero": "docs/gallery/assets/shader-node-group-hero.webp",
"preview": "examples/shader-node-group/preview.webp",
- "tags": ["materials", "node-groups"]
+ "tags": [
+ "materials",
+ "node-groups"
+ ]
},
{
"name": "temp-override-join",
"dir": "examples/temp-override-join",
- "teaches": "Join three unit cubes into a staircase under bpy.context.temp_override — the supported replacement for the removed context.copy() dict-pass form.",
- "witnessesFix": "temp_override actually applies: join consumes the sources, exactly one mesh remains, topology is verts = 8 × steps, and local Z spans all three steps.",
+ "teaches": "Join three unit cubes into a staircase under bpy.context.temp_override \u2014 the supported replacement for the removed context.copy() dict-pass form.",
+ "witnessesFix": "temp_override actually applies: join consumes the sources, exactly one mesh remains, topology is verts = 8 \u00d7 steps, and local Z spans all three steps.",
"hero": "docs/gallery/assets/temp-override-join-hero.webp",
"preview": "examples/temp-override-join/preview.webp",
- "tags": ["operators", "context"]
+ "tags": [
+ "operators",
+ "context"
+ ]
},
{
"name": "gn-instance-grid",
"dir": "examples/gn-instance-grid",
- "teaches": "A generative Geometry Nodes tree — Mesh Grid → Instance on Points → Realize Instances → Set Shade Smooth — attached as a NODES modifier with no Group Input geometry.",
+ "teaches": "A generative Geometry Nodes tree \u2014 Mesh Grid \u2192 Instance on Points \u2192 Realize Instances \u2192 Set Shade Smooth \u2014 attached as a NODES modifier with no Group Input geometry.",
"witnessesFix": "Realized instances produce closed-form topology (72 verts, 54 faces), Set Material carries Lime, and the corner instance center sits at its closed-form grid coordinate.",
"hero": "docs/gallery/assets/gn-instance-grid-hero.webp",
"preview": "examples/gn-instance-grid/preview.webp",
- "tags": ["geometry-nodes", "instancing"]
+ "tags": [
+ "geometry-nodes",
+ "instancing"
+ ]
},
{
"name": "shape-key-blend",
"dir": "examples/shape-key-blend",
- "teaches": "A relative Tall shape key that lifts and flares the top face — authored via shape_key_add / key_blocks / .value — read back from the depsgraph-evaluated mesh.",
- "witnessesFix": "Shape keys do not rewrite mesh.vertices: every evaluated vert matches basis + value × (key − basis), including the flared top half-extent.",
+ "teaches": "A relative Tall shape key that lifts and flares the top face \u2014 authored via shape_key_add / key_blocks / .value \u2014 read back from the depsgraph-evaluated mesh.",
+ "witnessesFix": "Shape keys do not rewrite mesh.vertices: every evaluated vert matches basis + value \u00d7 (key \u2212 basis), including the flared top half-extent.",
"hero": "docs/gallery/assets/shape-key-blend-hero.webp",
"preview": "examples/shape-key-blend/preview.webp",
- "tags": ["mesh", "shape-keys"]
+ "tags": [
+ "mesh",
+ "shape-keys"
+ ]
},
{
"name": "curve-bevel-arc",
"dir": "examples/curve-bevel-arc",
- "teaches": "A beveled Bezier semicircle authored on bpy.types.Curve — splines.new('BEZIER'), bezier_points, bevel_depth, use_fill_caps — so the curve renders as a solid tube without a prior mesh conversion.",
+ "teaches": "A beveled Bezier semicircle authored on bpy.types.Curve \u2014 splines.new('BEZIER'), bezier_points, bevel_depth, use_fill_caps \u2014 so the curve renders as a solid tube without a prior mesh conversion.",
"witnessesFix": "Curve tubes are curve datablocks: eight Bezier points, bevel_depth == 0.15, filled caps, and the evaluated mesh has deterministic topology (1044 verts, 1028 faces) resting on the floor.",
"hero": "docs/gallery/assets/curve-bevel-arc-hero.webp",
"preview": "examples/curve-bevel-arc/preview.webp",
- "tags": ["curves", "bevel"]
+ "tags": [
+ "curves",
+ "bevel"
+ ]
},
{
"name": "compositor-glare",
"dir": "examples/compositor-glare",
- "teaches": "Bloom where it actually lives — a compositor Glare (Fog Glow) node fed by Render Layers, wired via scene.compositing_node_group on 5.x and scene.node_tree on 4.x, with the Glare node's legacy properties vs 5.x menu sockets.",
+ "teaches": "Bloom where it actually lives \u2014 a compositor Glare (Fog Glow) node fed by Render Layers, wired via scene.compositing_node_group on 5.x and scene.node_tree on 4.x, with the Glare node's legacy properties vs 5.x menu sockets.",
"witnessesFix": "scene.node_tree is gone in 5.x and EEVEE has no use_bloom on either side: the halo beyond the ring silhouette falls off strictly with the compositor on and is exactly zero with it off.",
"hero": "docs/gallery/assets/compositor-glare-hero.webp",
"preview": "examples/compositor-glare/preview.webp",
- "tags": ["compositor", "rendering"]
+ "tags": [
+ "compositor",
+ "rendering"
+ ]
+ },
+ {
+ "name": "damped-track-aim",
+ "dir": "examples/damped-track-aim",
+ "teaches": "Aim constraints via the data API \u2014 Object.constraints.new('DAMPED_TRACK') with target and TRACK_Z, not bpy.ops.object.constraint_add in a headless loop.",
+ "witnessesFix": "Every needle has one unmuted DAMPED_TRACK on the core; evaluated local +Z aligns toward the core (dot \u2265 0.998). TRACK_TO stand-ins and flipped axes fail.",
+ "hero": "docs/gallery/assets/damped-track-aim-hero.webp",
+ "preview": "examples/damped-track-aim/preview.webp",
+ "tags": [
+ "constraints",
+ "animation"
+ ]
}
]
}