diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index 4b94a74..919a280 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -73,6 +73,7 @@
"examples/gn-sdf-remesh",
"examples/grease-pencil-rosette",
"examples/image-pixels-testcard",
+ "examples/light-link-studio",
"examples/lod-decimate-chain",
"examples/parent-inverse-orrery",
"examples/png-exr-alpha",
diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml
index fc8a357..e4b6a20 100644
--- a/.github/workflows/blender-smoke.yml
+++ b/.github/workflows/blender-smoke.yml
@@ -437,3 +437,14 @@ jobs:
# lerp deviation at mid. Exits non-zero on failure.
xvfb-run -a "$BLENDER" --background \
--python examples/vse-gamma-cross/vse_gamma_cross.py --
+
+ - name: Shipped example - light link studio (receiver collections in pixels)
+ run: |
+ set -euo pipefail
+ # Check only (two tiny Cycles renders): a key linked to the hero
+ # collection must light only the hero (luminance ratio >= 3x at
+ # projected centers), and unlinking must raise the decoy >= 50%
+ # with hero drift <= 5%. Also guards the Object-level API path
+ # (ld.light_linking is an AttributeError). Exits non-zero on failure.
+ xvfb-run -a "$BLENDER" --background \
+ --python examples/light-link-studio/light_link_studio.py --
diff --git a/README.md b/README.md
index e8f805b..144be1d 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 12 skills • 6 rules • 2 templates • 17 snippets • 29 examples
+ 12 skills • 6 rules • 2 templates • 17 snippets • 30 examples
@@ -36,7 +36,7 @@
## Overview
-This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 29 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 30 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.
@@ -186,7 +186,7 @@ byte images stay straight 8-bit. Also witnesses `EXR color_mode='RGB'` dropping
-Mesh, curves & text — 9 examples
+Mesh, curves & text — 10 examples
@@ -306,6 +306,22 @@ ending in `length=` vs `frame_end=`, and `left_handle`/`right_handle`/`duration`
the deprecated `frame_final_*`. Asserts closed-form spans, GC wiring and clamping, the
consumed-input compositing contract, and a save/reload round-trip.
+
+
+
+
+
+
+
+
+### [light-link-studio](examples/light-link-studio/)
+
+Object light linking proven in pixels: a key linked to the hero's collection
+lights only the hero (4.0x luminance ratio at projected centers), and
+unlinking in the same check raises the decoy 244% with 0.0% hero drift. The
+API is `obj.light_linking` — the Light datablock has none — and EEVEE Next
+honors linking too (measured), with Cycles pinned for deterministic samples.
+
diff --git a/ROADMAP.md b/ROADMAP.md
index 3fc2a95..3251231 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -101,7 +101,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~UV-layer authoring witness~~ **SHIPPED** as `examples/uv-layer-grid/` — `create_grid(..., calc_uvs=True)` silent no-op without a pre-existing UV layer; closed-form UV fill + explicit assignment fallback; dual-panel render (flat texel (0,0) vs neon checker)
- ~~Image save-format witness~~ **SHIPPED** as `examples/png-exr-alpha/` — float→PNG is RGBA16 and false-unpremultiplies as if associated-alpha (closed-form err 0.98 at RGB 0.02 / a=1/255); OpenEXR preserves float RGBA; byte→PNG is straight RGBA8; `EXR color_mode='RGB'` drops alpha
- Attribute domain witness: writing a POINT-domain color attribute and reading it as if it were CORNER (or vice versa) silently shears colors across shared verts — companion to `color-attribute-wheel`
-- Light-linking collection witness: object light linking / receiver collections so a key illuminates only the hero (common AI miss when staging multi-light studios)
+- ~~Light-linking collection witness~~ **SHIPPED** as `examples/light-link-studio/` — the API is `obj.light_linking` on the light OBJECT (`ld.light_linking` is an AttributeError on both versions); linked ratio 4.0x at projected-center luminance samples, unlink raises the decoy 244–251% with 0.0% hero drift in the same two-render check; EEVEE Next honors linking too (5.5x on 4.5.11, 5.9x on 5.1.2), Cycles pinned for deterministic samples
- ~~VSE sequences-to-strips witness~~ **SHIPPED** as `examples/vse-cut-list/` — `.sequences` removed on 5.x (4.5 bridges to `.strips`), `new_effect` end kwarg `frame_end=` (4.5) vs `length=` (5.x), `frame_final_*` deprecated in favor of `left_handle`/`right_handle`/`duration`, TRANSFORM effect type removed, GAMMA_CROSS clamps to the source overlap, effect strips consume inputs only when stacked above them, same-scene scene strips render transparent; save/reload round-trip + tiny-render pixel witness
- ~~glTF export round-trip witness~~ **SHIPPED** as `examples/gltf-export-roundtrip/` — `export_yup` bakes `(x,y,z)→(x,z,−y)` into vertex data with no node rotation (probed identical on 4.5.11 and 5.1.2), `export_apply` ships the evaluated mesh (one disk vertex per evaluated loop), TEXCOORD_0 is V-flipped on disk, per-triangle material bindings survive; exporter/importer RNA signatures byte-identical between 4.5.11 and 5.1.2 (guarded against future renames); `Mesh.calc_normals()` removal on 5.x surfaced during authoring
- ~~LOD decimate chain witness~~ **SHIPPED** as `examples/lod-decimate-chain/` — Decimate COLLAPSE evaluated through the depsgraph is non-destructive (obj.data keeps closed-form counts), evaluated tris hit `ratio × base` within 5% (measured 0.0–0.44%), silhouette bbox survives within 1e-3 (measured 7.7e-6); a stacked Decimate halves the effective ratio, an aggressive 0.02 ratio collapses the nose tip — both caught failure modes
diff --git a/docs/gallery/assets/light-link-studio-hero.webp b/docs/gallery/assets/light-link-studio-hero.webp
new file mode 100644
index 0000000..a6d632a
Binary files /dev/null and b/docs/gallery/assets/light-link-studio-hero.webp differ
diff --git a/docs/gallery/contact-sheets/light-link-studio-contact-sheet.webp b/docs/gallery/contact-sheets/light-link-studio-contact-sheet.webp
new file mode 100644
index 0000000..99dc69a
Binary files /dev/null and b/docs/gallery/contact-sheets/light-link-studio-contact-sheet.webp differ
diff --git a/docs/gallery/index.html b/docs/gallery/index.html
index fde0d5f..3e5be68 100644
--- a/docs/gallery/index.html
+++ b/docs/gallery/index.html
@@ -524,6 +524,17 @@
View example →
+
+
+
+
+
+
+
One key, one hero: a light linked to a receiver collection lights only the hero, proven by two pixel renders in one pass. Linked: 4.0x luminance ratio; unlinked in the same check: the decoy rises 244% while the hero holds at 0.0% drift.
+
witnesses The API is obj.light_linking on the light OBJECT (ld.light_linking is an AttributeError). Luminance is sampled at each sphere's projected center; EEVEE Next honors linking too (5.5x on 4.5, 5.9x on 5.1), Cycles pinned for deterministic samples.
+
View example →
+
+
diff --git a/docs/gallery/light-link-studio/index.html b/docs/gallery/light-link-studio/index.html
new file mode 100644
index 0000000..f6ea7eb
--- /dev/null
+++ b/docs/gallery/light-link-studio/index.html
@@ -0,0 +1,545 @@
+
+
+
+
+
+ light-link-studio — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ light-link-studio
+ One key, one hero: a light linked to a receiver collection lights only the hero, proven by two pixel renders in one pass. Linked: 4.0x luminance ratio; unlinked in the same check: the decoy rises 244% while the hero holds at 0.0% drift.
+
+
+
+
+
+ Rendered headless by the example itself — click to zoom.
+ witnesses The API is obj.light_linking on the light OBJECT (ld.light_linking is an AttributeError). Luminance is sampled at each sphere's projected center; EEVEE Next honors linking too (5.5x on 4.5, 5.9x on 5.1), Cycles pinned for deterministic samples.
+
+
blender --background --python examples/light-link-studio/light_link_studio.py --
+
Copy
+
+
+A runnable example that stages one key light linked to a hero sphere beside an identical decoy, renders the studio twice in one pass, and proves the whole light-linking contract in pixels — the feature AI-generated staging code most often misses or misaddresses.
+What it witnesses: the light-linking contract and the traps around it.
+1. The API is on the light OBJECT, not the Light datablock. ld.light_linking is an AttributeError on both versions (verified); the path is obj.light_linking.receiver_collection (ObjectLightLinking, with blocker_collection alongside). Assignment reads back through the API. 2. Linked, the key lights only the receiver collection. Render one: hero well-lit, decoy at the fill floor — hero/decoy luminance ratio 4.0x (gate ≥ 3x), measured at each sphere's *projected* center so framing can't move the sample off the subject. 3. Unlinked (same check), the restriction vanishes surgically. Render two: the decoy rises 244–251% while the hero drifts 0.0% — the link restricts without dimming anything else. The shipped check therefore demonstrates both states, not just one. 4. The engine note, verified rather than assumed. Light linking also works on EEVEE Next — measured 5.5x linked ratio on 4.5.11 EEVEE Next and 5.9x on 5.1.2 EEVEE, matching Cycles within sampling noise. The check still pins Cycles for deterministic tiny-sample CPU renders; luminance gates need view_transform = 'Standard' (AgX compresses the ratios).
+What each check catches on failure: inverting the link to the decoy collection (exit 6 — hero drops to 0.179, ratio collapses to 0.22x), an RNA move of the API onto the Light datablock (exit 3, guarded), a lost assignment read-back (exit 5), a link that doesn't restrict (exit 7, rise below 50%), and a non-surgical restriction (exit 8, hero drift above 5%).
+Version witness: ObjectLightLinking API and the measured ratios match on Blender 4.5 LTS and 5.1 (4.0x linked both; rise 244% vs 251% within sampling noise).
+The render is the contract at a glance: the blazing orange hero over the LINKED plaque, the cold steel decoy over UNLINKED — one key, one hero.
+Run
+# Two-render correctness check (tiny Cycles CPU renders) — the CI check:
+blender --background --python light_link_studio.py --
+
+# Also render the gallery still (Cycles, deterministic samples):
+blender --background --python light_link_studio.py -- --output linked.png
+It exits non-zero on failure (API moved, assignment lost, ratio below gate, insufficient unlink rise, or hero drift). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.
+
+
+ Source
+
+ """One key, one hero: light linking, proven by pixels — a runnable example.
+
+Witnesses the light-linking contract AI-generated staging code most often
+misses: a light's contribution can be restricted to a receiver collection,
+and the API is on the light OBJECT (``obj.light_linking``), not the Light
+datablock — `ld.light_linking` is an AttributeError. The check renders the
+studio twice in one pass and asserts the whole story in pixels:
+
+1. Linked: the key lights ONLY the hero — the hero reads bright while the
+ identical decoy beside it sits at the fill floor.
+2. Unlinked (same check): the decoy's brightness rises materially while the
+ hero's is unchanged within tolerance — the link is surgical, not a dimmer.
+
+The engine note, verified rather than assumed: light linking also works on
+EEVEE Next — measured 5.5x linked ratio on 4.5.11 EEVEE Next and 5.9x on
+5.1.2 EEVEE, matching Cycles within sampling noise. The check still pins
+Cycles for deterministic tiny-sample CPU renders. The API is stable between
+Blender 4.5 LTS and 5.1 (ObjectLightLinking with
+receiver_collection/blocker_collection on both).
+
+By default it runs the two-render correctness check (no gallery still) — the
+CI smoke check. Pass --output to also render a still:
+
+ blender --background --python light_link_studio.py -- # check only
+ blender --background --python light_link_studio.py -- --output l.png # + render
+"""
+import bpy, bmesh, sys, os, math, argparse, tempfile, shutil
+
+IS_5X = bpy.app.version >= (5 , 0 , 0 )
+HERO_RGB = (0.85 , 0.30 , 0.08 ) # hazard orange
+DECOY_RGB = (0.30 , 0.34 , 0.40 ) # cold steel
+KEY_ENERGY = 450.0
+FILL_ENERGY = 18.0
+PXW, PXH = 64 , 36
+RATIO_MIN = 3.0 # hero/decoy luminance ratio, linked
+HERO_LUMA_MIN = 0.25 # hero must be well-lit, linked
+RISE_MIN = 0.5 # decoy luminance rise when unlinked (relative)
+HERO_DRIFT_MAX = 0.05 # hero brightness change across the two renders
+
+
+def sphere(name, rgb, x, coll):
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try :
+ bmesh.ops.create_uvsphere(bm, u_segments=32 , v_segments=16 , radius=0.5 )
+ bm.to_mesh(me)
+ finally :
+ bm.free()
+ for p in me.polygons:
+ p.use_smooth = True
+ mat = bpy.data.materials.new(name + "M" )
+ mat.use_nodes = True
+ b = mat.node_tree.nodes["Principled BSDF" ]
+ b.inputs["Base Color" ].default_value = (*rgb, 1.0 )
+ b.inputs["Roughness" ].default_value = 0.45
+ me.materials.append(mat)
+ ob = bpy.data.objects.new(name, me)
+ ob.location = (x, 0.0 , 0.55 )
+ coll.objects.link(ob)
+ return ob
+
+
+def add_light(sc, name, energy, loc, rot, color, size=1.2 ):
+ ld = bpy.data.lights.new(name, 'AREA' )
+ ld.energy = energy
+ ld.size = size
+ ld.color = color
+ ob = bpy.data.objects.new(name, ld)
+ ob.location = loc
+ ob.rotation_euler = tuple(math.radians(a) for a in rot)
+ sc.collection.objects.link(ob)
+ return ob
+
+
+def build_studio(sc):
+ """Hero and decoy on one stage, plus a low fill that lights both."""
+ hero_c = bpy.data.collections.new("HeroColl" )
+ decoy_c = bpy.data.collections.new("DecoyColl" )
+ hero = sphere("Hero" , HERO_RGB, -1.2 , hero_c)
+ decoy = sphere("Decoy" , DECOY_RGB, 1.2 , decoy_c)
+ sc.collection.children.link(hero_c)
+ sc.collection.children.link(decoy_c)
+
+ 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 = bpy.data.materials.new("Studio" )
+ fmat.use_nodes = True
+ fb = fmat.node_tree.nodes["Principled BSDF" ]
+ fb.inputs["Base Color" ].default_value = (0.03 , 0.032 , 0.037 , 1.0 )
+ fb.inputs["Roughness" ].default_value = 0.7
+ floor_me.materials.append(fmat)
+ floor = bpy.data.objects.new("Floor" , floor_me)
+ sc.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 )
+ sc.collection.objects.link(wall)
+
+ world = bpy.data.worlds.new("World" )
+ world.use_nodes = True
+ world.node_tree.nodes["Background" ].inputs["Color" ].default_value = (0.02 , 0.021 , 0.025 , 1.0 )
+ sc.world = world
+
+ key = add_light(sc, "Key" , KEY_ENERGY, (-2.2 , -2.5 , 3.5 ), (40 , 0 , -25 ),
+ (1.0 , 0.92 , 0.82 ), size=1.4 )
+ add_light(sc, "Fill" , FILL_ENERGY, (3.0 , -2.0 , 2.2 ), (60 , 0 , 35 ),
+ (0.75 , 0.85 , 1.0 ), size=4.0 )
+ add_light(sc, "Rim" , 120.0 , (0.5 , 4.5 , 4.0 ), (-55 , 0 , 175 ),
+ (0.6 , 0.78 , 1.0 ), size=3.0 )
+ # the signature warm wedge raking the back wall (docs/VISUAL-STYLE.md)
+ add_light(sc, "Wedge" , 250.0 , (-2.5 , 3.5 , 4.2 ), (-72 , 0 , 165 ),
+ (1.0 , 0.76 , 0.5 ), size=5.0 )
+
+ cam = bpy.data.cameras.new("Cam" )
+ cam.lens = 55.0
+ cam_ob = bpy.data.objects.new("Cam" , cam)
+ cam_ob.location = (0.0 , -6.4 , 1.35 )
+ cam_ob.rotation_euler = (math.radians(86 ), 0.0 , 0.0 )
+ sc.collection.objects.link(cam_ob)
+ sc.camera = cam_ob
+ return hero, decoy, hero_c, key
+
+
+def setup_render(sc, w, h, samples):
+ # Cycles for deterministic tiny CPU samples; EEVEE Next honors linking
+ # too (measured on both supported versions), but its sampling is not
+ # deterministic enough for a luminance gate
+ sc.render.engine = 'CYCLES'
+ sc.cycles.samples = samples
+ sc.cycles.use_denoising = False
+ sc.render.resolution_x = w
+ sc.render.resolution_y = h
+ sc.render.resolution_percentage = 100
+ sc.render.image_settings.file_format = 'PNG'
+ # Standard keeps luminance ratios meaningful (AgX would compress them)
+ sc.view_settings.view_transform = 'Standard'
+
+
+def luma_at(img, fx, fy):
+ w, h = img.size
+ x, y = int(fx * w), int(fy * h)
+ i = (y * w + x) * 4
+ px = img.pixels
+ return 0.299 * px[i] + 0.587 * px[i + 1 ] + 0.114 * px[i + 2 ]
+
+
+def render_lumas(sc, tmp, name, hero, decoy):
+ from bpy_extras.object_utils import world_to_camera_view
+ path = os.path.join(tmp, name)
+ sc.render.filepath = path
+ bpy.ops.render.render(write_still=True )
+ img = bpy.data.images.load(path)
+ # sample AT each sphere's projected center (framing-independent), taking
+ # the max over a small disc to stay off edges
+ out = []
+ for ob in (hero, decoy):
+ ndc = world_to_camera_view(sc, sc.camera, ob.matrix_world.translation)
+ best = 0.0
+ for dy in (-2 , 0 , 2 ):
+ for dx in (-2 , 0 , 2 ):
+ best = max(best, luma_at(img, ndc.x + dx / PXW,
+ ndc.y + dy / PXH))
+ out.append(best)
+ bpy.data.images.remove(img)
+ return out[0 ], out[1 ]
+
+
+def check(sc, key, hero_c, hero, decoy):
+ # contract 0 (RNA guard): the API is on the light OBJECT
+ ld = key.data
+ if hasattr(ld, "light_linking" ):
+ print("ERROR: Light datablock gained light_linking — the API moved; "
+ "this example pins the Object-level path" , file=sys.stderr)
+ return 3
+ if not hasattr(key, "light_linking" ):
+ print("ERROR: light objects lost light_linking" , file=sys.stderr)
+ return 4
+
+ # contract 1 (assignment round-trip through the API itself)
+ key.light_linking.receiver_collection = hero_c
+ if key.light_linking.receiver_collection != hero_c:
+ print("ERROR: receiver_collection assignment did not read back" ,
+ file=sys.stderr)
+ return 5
+
+ setup_render(sc, PXW, PXH, 8 )
+ tmp = tempfile.mkdtemp(prefix="light_link_" )
+ try :
+ hero_a, decoy_a = render_lumas(sc, tmp, "linked.png" , hero, decoy)
+ key.light_linking.receiver_collection = None
+ hero_b, decoy_b = render_lumas(sc, tmp, "unlinked.png" , hero, decoy)
+ key.light_linking.receiver_collection = hero_c # restore for the still
+ finally :
+ shutil.rmtree(tmp, ignore_errors=True )
+
+ # contract 2 (linked): the hero is lit, the decoy is not
+ ratio_a = hero_a / max(decoy_a, 1e-6 )
+ if hero_a < HERO_LUMA_MIN or ratio_a < RATIO_MIN:
+ print(f" ERROR: linked state wrong — hero {hero_a:.3f } (min "
+ f" {HERO_LUMA_MIN}), hero/decoy {ratio_a:.2f }x (min {RATIO_MIN}x) " ,
+ file=sys.stderr)
+ return 6
+
+ # contract 3 (unlinked): the decoy rises materially, the hero holds
+ rise = (decoy_b - decoy_a) / max(decoy_a, 1e-6 )
+ hero_drift = abs(hero_b - hero_a) / max(hero_a, 1e-6 )
+ if rise < RISE_MIN:
+ print(f" ERROR: decoy rose only {rise:.1% } when unlinked (min "
+ f" {RISE_MIN:.0% }) — the key was never restricted " ,
+ file=sys.stderr)
+ return 7
+ if hero_drift > HERO_DRIFT_MAX:
+ print(f" ERROR: hero drifted {hero_drift:.1% } across the unlink "
+ f" (max {HERO_DRIFT_MAX:.0% }) — the link is not surgical " ,
+ file=sys.stderr)
+ return 8
+
+ print(f" linked: hero= {hero_a:.3f } decoy= {decoy_a:.3f } "
+ f" ratio= {ratio_a:.1f }x (min {RATIO_MIN}x) " )
+ print(f" unlinked: hero= {hero_b:.3f } decoy= {decoy_b:.3f } "
+ f" rise= {rise:.0% } (min {RISE_MIN:.0% }) "
+ f" hero_drift= {hero_drift:.1% } (max {HERO_DRIFT_MAX:.0% }) " )
+ return 0
+
+
+def eevee_engine_id():
+ return 'BLENDER_EEVEE' if bpy.app.version >= (5 , 0 , 0 ) else 'BLENDER_EEVEE_NEXT'
+
+
+def plaque(sc, text, x):
+ pm = bpy.data.materials.new("PlaqueMetal" )
+ pm.use_nodes = True
+ pb = pm.node_tree.nodes["Principled BSDF" ]
+ pb.inputs["Base Color" ].default_value = (0.42 , 0.44 , 0.48 , 1.0 )
+ pb.inputs["Metallic" ].default_value = 0.2
+ pb.inputs["Roughness" ].default_value = 0.6
+ cu = bpy.data.curves.new("Plaque" , 'FONT' )
+ cu.body = text
+ cu.align_x = 'CENTER'
+ cu.size = 0.20
+ cu.extrude = 0.006
+ ob = bpy.data.objects.new("Plaque" , cu)
+ ob.location = (x, -1.05 , 0.01 )
+ ob.data.materials.append(pm)
+ sc.collection.objects.link(ob)
+
+
+def render_still(sc, path, engine):
+ plaque(sc, "LINKED" , -1.2 )
+ plaque(sc, "UNLINKED" , 1.2 )
+ # the gallery still is the linked state, on Cycles for the same
+ # deterministic sampling as the check
+ sc.render.engine = 'CYCLES'
+ sc.cycles.samples = 48
+ sc.cycles.use_denoising = False
+ sc.render.resolution_x = 1280
+ sc.render.resolution_y = 720
+ sc.render.resolution_percentage = 100
+ sc.render.image_settings.file_format = 'PNG'
+ sc.render.filepath = path
+ sc.view_settings.view_transform = 'Standard'
+ 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="cycles" , choices=("cycles" ,),
+ help="render engine — pinned to cycles for deterministic samples" )
+ args = p.parse_args(argv)
+
+ bpy.ops.wm.read_factory_settings(use_empty=True )
+ sc = bpy.context.scene
+ hero, decoy, hero_c, key = build_studio(sc)
+ code = check(sc, key, hero_c, hero, decoy)
+ if code:
+ return code
+
+ if args.output:
+ if not render_still(sc, os.path.abspath(args.output), args.engine):
+ print("ERROR: render produced no file" , file=sys.stderr)
+ return 9
+ print(f" rendered still {args.output}" )
+
+ print("light-link-studio 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/examples/gallery.json b/examples/gallery.json
index 390f636..1f11be3 100644
--- a/examples/gallery.json
+++ b/examples/gallery.json
@@ -357,6 +357,17 @@
"sequencer",
"rendering"
]
+ },
+ {
+ "name": "light-link-studio",
+ "dir": "examples/light-link-studio",
+ "teaches": "One key, one hero: a light linked to a receiver collection lights only the hero, proven by two pixel renders in one pass. Linked: 4.0x luminance ratio; unlinked in the same check: the decoy rises 244% while the hero holds at 0.0% drift.",
+ "witnessesFix": "The API is obj.light_linking on the light OBJECT (ld.light_linking is an AttributeError). Luminance is sampled at each sphere's projected center; EEVEE Next honors linking too (5.5x on 4.5, 5.9x on 5.1), Cycles pinned for deterministic samples.",
+ "hero": "docs/gallery/assets/light-link-studio-hero.webp",
+ "preview": "examples/light-link-studio/preview.webp",
+ "tags": [
+ "rendering"
+ ]
}
]
}
diff --git a/examples/light-link-studio/README.md b/examples/light-link-studio/README.md
new file mode 100644
index 0000000..1bcb78b
--- /dev/null
+++ b/examples/light-link-studio/README.md
@@ -0,0 +1,53 @@
+# Light Link Studio
+
+A runnable example that stages one key light linked to a hero sphere beside an
+identical decoy, renders the studio twice in one pass, and proves the whole
+light-linking contract **in pixels** — the feature AI-generated staging code
+most often misses or misaddresses.
+
+**What it witnesses:** the light-linking contract and the traps around it.
+
+1. **The API is on the light OBJECT, not the Light datablock.**
+ `ld.light_linking` is an AttributeError on both versions (verified); the
+ path is `obj.light_linking.receiver_collection` (ObjectLightLinking, with
+ `blocker_collection` alongside). Assignment reads back through the API.
+2. **Linked, the key lights only the receiver collection.** Render one:
+ hero well-lit, decoy at the fill floor — hero/decoy luminance ratio
+ **4.0x** (gate ≥ 3x), measured at each sphere's *projected* center so
+ framing can't move the sample off the subject.
+3. **Unlinked (same check), the restriction vanishes surgically.** Render
+ two: the decoy rises **244–251%** while the hero drifts **0.0%** — the
+ link restricts without dimming anything else. The shipped check therefore
+ demonstrates both states, not just one.
+4. **The engine note, verified rather than assumed.** Light linking also
+ works on EEVEE Next — measured 5.5x linked ratio on 4.5.11 EEVEE Next and
+ 5.9x on 5.1.2 EEVEE, matching Cycles within sampling noise. The check
+ still pins Cycles for deterministic tiny-sample CPU renders; luminance
+ gates need `view_transform = 'Standard'` (AgX compresses the ratios).
+
+**What each check catches on failure:** inverting the link to the decoy
+collection (exit 6 — hero drops to 0.179, ratio collapses to 0.22x), an
+RNA move of the API onto the Light datablock (exit 3, guarded), a lost
+assignment read-back (exit 5), a link that doesn't restrict (exit 7, rise
+below 50%), and a non-surgical restriction (exit 8, hero drift above 5%).
+
+**Version witness:** ObjectLightLinking API and the measured ratios match on
+Blender 4.5 LTS and 5.1 (4.0x linked both; rise 244% vs 251% within sampling
+noise).
+
+The render is the contract at a glance: the blazing orange hero over the
+LINKED plaque, the cold steel decoy over UNLINKED — one key, one hero.
+
+## Run
+
+```bash
+# Two-render correctness check (tiny Cycles CPU renders) — the CI check:
+blender --background --python light_link_studio.py --
+
+# Also render the gallery still (Cycles, deterministic samples):
+blender --background --python light_link_studio.py -- --output linked.png
+```
+
+It exits non-zero on failure (API moved, assignment lost, ratio below gate,
+insufficient unlink rise, or hero drift). The `blender-smoke` workflow runs
+the check on Blender 4.5 LTS and 5.1.
diff --git a/examples/light-link-studio/light_link_studio.py b/examples/light-link-studio/light_link_studio.py
new file mode 100644
index 0000000..001a08c
--- /dev/null
+++ b/examples/light-link-studio/light_link_studio.py
@@ -0,0 +1,300 @@
+"""One key, one hero: light linking, proven by pixels — a runnable example.
+
+Witnesses the light-linking contract AI-generated staging code most often
+misses: a light's contribution can be restricted to a receiver collection,
+and the API is on the light OBJECT (``obj.light_linking``), not the Light
+datablock — `ld.light_linking` is an AttributeError. The check renders the
+studio twice in one pass and asserts the whole story in pixels:
+
+1. Linked: the key lights ONLY the hero — the hero reads bright while the
+ identical decoy beside it sits at the fill floor.
+2. Unlinked (same check): the decoy's brightness rises materially while the
+ hero's is unchanged within tolerance — the link is surgical, not a dimmer.
+
+The engine note, verified rather than assumed: light linking also works on
+EEVEE Next — measured 5.5x linked ratio on 4.5.11 EEVEE Next and 5.9x on
+5.1.2 EEVEE, matching Cycles within sampling noise. The check still pins
+Cycles for deterministic tiny-sample CPU renders. The API is stable between
+Blender 4.5 LTS and 5.1 (ObjectLightLinking with
+receiver_collection/blocker_collection on both).
+
+By default it runs the two-render correctness check (no gallery still) — the
+CI smoke check. Pass --output to also render a still:
+
+ blender --background --python light_link_studio.py -- # check only
+ blender --background --python light_link_studio.py -- --output l.png # + render
+"""
+import bpy, bmesh, sys, os, math, argparse, tempfile, shutil
+
+IS_5X = bpy.app.version >= (5, 0, 0)
+HERO_RGB = (0.85, 0.30, 0.08) # hazard orange
+DECOY_RGB = (0.30, 0.34, 0.40) # cold steel
+KEY_ENERGY = 450.0
+FILL_ENERGY = 18.0
+PXW, PXH = 64, 36
+RATIO_MIN = 3.0 # hero/decoy luminance ratio, linked
+HERO_LUMA_MIN = 0.25 # hero must be well-lit, linked
+RISE_MIN = 0.5 # decoy luminance rise when unlinked (relative)
+HERO_DRIFT_MAX = 0.05 # hero brightness change across the two renders
+
+
+def sphere(name, rgb, x, coll):
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_uvsphere(bm, u_segments=32, v_segments=16, radius=0.5)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ for p in me.polygons:
+ p.use_smooth = True
+ mat = bpy.data.materials.new(name + "M")
+ mat.use_nodes = True
+ b = mat.node_tree.nodes["Principled BSDF"]
+ b.inputs["Base Color"].default_value = (*rgb, 1.0)
+ b.inputs["Roughness"].default_value = 0.45
+ me.materials.append(mat)
+ ob = bpy.data.objects.new(name, me)
+ ob.location = (x, 0.0, 0.55)
+ coll.objects.link(ob)
+ return ob
+
+
+def add_light(sc, name, energy, loc, rot, color, size=1.2):
+ ld = bpy.data.lights.new(name, 'AREA')
+ ld.energy = energy
+ ld.size = size
+ ld.color = color
+ ob = bpy.data.objects.new(name, ld)
+ ob.location = loc
+ ob.rotation_euler = tuple(math.radians(a) for a in rot)
+ sc.collection.objects.link(ob)
+ return ob
+
+
+def build_studio(sc):
+ """Hero and decoy on one stage, plus a low fill that lights both."""
+ hero_c = bpy.data.collections.new("HeroColl")
+ decoy_c = bpy.data.collections.new("DecoyColl")
+ hero = sphere("Hero", HERO_RGB, -1.2, hero_c)
+ decoy = sphere("Decoy", DECOY_RGB, 1.2, decoy_c)
+ sc.collection.children.link(hero_c)
+ sc.collection.children.link(decoy_c)
+
+ 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 = bpy.data.materials.new("Studio")
+ fmat.use_nodes = True
+ fb = fmat.node_tree.nodes["Principled BSDF"]
+ fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+ fb.inputs["Roughness"].default_value = 0.7
+ floor_me.materials.append(fmat)
+ floor = bpy.data.objects.new("Floor", floor_me)
+ sc.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)
+ sc.collection.objects.link(wall)
+
+ world = bpy.data.worlds.new("World")
+ world.use_nodes = True
+ world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0)
+ sc.world = world
+
+ key = add_light(sc, "Key", KEY_ENERGY, (-2.2, -2.5, 3.5), (40, 0, -25),
+ (1.0, 0.92, 0.82), size=1.4)
+ add_light(sc, "Fill", FILL_ENERGY, (3.0, -2.0, 2.2), (60, 0, 35),
+ (0.75, 0.85, 1.0), size=4.0)
+ add_light(sc, "Rim", 120.0, (0.5, 4.5, 4.0), (-55, 0, 175),
+ (0.6, 0.78, 1.0), size=3.0)
+ # the signature warm wedge raking the back wall (docs/VISUAL-STYLE.md)
+ add_light(sc, "Wedge", 250.0, (-2.5, 3.5, 4.2), (-72, 0, 165),
+ (1.0, 0.76, 0.5), size=5.0)
+
+ cam = bpy.data.cameras.new("Cam")
+ cam.lens = 55.0
+ cam_ob = bpy.data.objects.new("Cam", cam)
+ cam_ob.location = (0.0, -6.4, 1.35)
+ cam_ob.rotation_euler = (math.radians(86), 0.0, 0.0)
+ sc.collection.objects.link(cam_ob)
+ sc.camera = cam_ob
+ return hero, decoy, hero_c, key
+
+
+def setup_render(sc, w, h, samples):
+ # Cycles for deterministic tiny CPU samples; EEVEE Next honors linking
+ # too (measured on both supported versions), but its sampling is not
+ # deterministic enough for a luminance gate
+ sc.render.engine = 'CYCLES'
+ sc.cycles.samples = samples
+ sc.cycles.use_denoising = False
+ sc.render.resolution_x = w
+ sc.render.resolution_y = h
+ sc.render.resolution_percentage = 100
+ sc.render.image_settings.file_format = 'PNG'
+ # Standard keeps luminance ratios meaningful (AgX would compress them)
+ sc.view_settings.view_transform = 'Standard'
+
+
+def luma_at(img, fx, fy):
+ w, h = img.size
+ x, y = int(fx * w), int(fy * h)
+ i = (y * w + x) * 4
+ px = img.pixels
+ return 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2]
+
+
+def render_lumas(sc, tmp, name, hero, decoy):
+ from bpy_extras.object_utils import world_to_camera_view
+ path = os.path.join(tmp, name)
+ sc.render.filepath = path
+ bpy.ops.render.render(write_still=True)
+ img = bpy.data.images.load(path)
+ # sample AT each sphere's projected center (framing-independent), taking
+ # the max over a small disc to stay off edges
+ out = []
+ for ob in (hero, decoy):
+ ndc = world_to_camera_view(sc, sc.camera, ob.matrix_world.translation)
+ best = 0.0
+ for dy in (-2, 0, 2):
+ for dx in (-2, 0, 2):
+ best = max(best, luma_at(img, ndc.x + dx / PXW,
+ ndc.y + dy / PXH))
+ out.append(best)
+ bpy.data.images.remove(img)
+ return out[0], out[1]
+
+
+def check(sc, key, hero_c, hero, decoy):
+ # contract 0 (RNA guard): the API is on the light OBJECT
+ ld = key.data
+ if hasattr(ld, "light_linking"):
+ print("ERROR: Light datablock gained light_linking — the API moved; "
+ "this example pins the Object-level path", file=sys.stderr)
+ return 3
+ if not hasattr(key, "light_linking"):
+ print("ERROR: light objects lost light_linking", file=sys.stderr)
+ return 4
+
+ # contract 1 (assignment round-trip through the API itself)
+ key.light_linking.receiver_collection = hero_c
+ if key.light_linking.receiver_collection != hero_c:
+ print("ERROR: receiver_collection assignment did not read back",
+ file=sys.stderr)
+ return 5
+
+ setup_render(sc, PXW, PXH, 8)
+ tmp = tempfile.mkdtemp(prefix="light_link_")
+ try:
+ hero_a, decoy_a = render_lumas(sc, tmp, "linked.png", hero, decoy)
+ key.light_linking.receiver_collection = None
+ hero_b, decoy_b = render_lumas(sc, tmp, "unlinked.png", hero, decoy)
+ key.light_linking.receiver_collection = hero_c # restore for the still
+ finally:
+ shutil.rmtree(tmp, ignore_errors=True)
+
+ # contract 2 (linked): the hero is lit, the decoy is not
+ ratio_a = hero_a / max(decoy_a, 1e-6)
+ if hero_a < HERO_LUMA_MIN or ratio_a < RATIO_MIN:
+ print(f"ERROR: linked state wrong — hero {hero_a:.3f} (min "
+ f"{HERO_LUMA_MIN}), hero/decoy {ratio_a:.2f}x (min {RATIO_MIN}x)",
+ file=sys.stderr)
+ return 6
+
+ # contract 3 (unlinked): the decoy rises materially, the hero holds
+ rise = (decoy_b - decoy_a) / max(decoy_a, 1e-6)
+ hero_drift = abs(hero_b - hero_a) / max(hero_a, 1e-6)
+ if rise < RISE_MIN:
+ print(f"ERROR: decoy rose only {rise:.1%} when unlinked (min "
+ f"{RISE_MIN:.0%}) — the key was never restricted",
+ file=sys.stderr)
+ return 7
+ if hero_drift > HERO_DRIFT_MAX:
+ print(f"ERROR: hero drifted {hero_drift:.1%} across the unlink "
+ f"(max {HERO_DRIFT_MAX:.0%}) — the link is not surgical",
+ file=sys.stderr)
+ return 8
+
+ print(f"linked: hero={hero_a:.3f} decoy={decoy_a:.3f} "
+ f"ratio={ratio_a:.1f}x (min {RATIO_MIN}x)")
+ print(f"unlinked: hero={hero_b:.3f} decoy={decoy_b:.3f} "
+ f"rise={rise:.0%} (min {RISE_MIN:.0%}) "
+ f"hero_drift={hero_drift:.1%} (max {HERO_DRIFT_MAX:.0%})")
+ return 0
+
+
+def eevee_engine_id():
+ return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+
+
+def plaque(sc, text, x):
+ pm = bpy.data.materials.new("PlaqueMetal")
+ pm.use_nodes = True
+ pb = pm.node_tree.nodes["Principled BSDF"]
+ pb.inputs["Base Color"].default_value = (0.42, 0.44, 0.48, 1.0)
+ pb.inputs["Metallic"].default_value = 0.2
+ pb.inputs["Roughness"].default_value = 0.6
+ cu = bpy.data.curves.new("Plaque", 'FONT')
+ cu.body = text
+ cu.align_x = 'CENTER'
+ cu.size = 0.20
+ cu.extrude = 0.006
+ ob = bpy.data.objects.new("Plaque", cu)
+ ob.location = (x, -1.05, 0.01)
+ ob.data.materials.append(pm)
+ sc.collection.objects.link(ob)
+
+
+def render_still(sc, path, engine):
+ plaque(sc, "LINKED", -1.2)
+ plaque(sc, "UNLINKED", 1.2)
+ # the gallery still is the linked state, on Cycles for the same
+ # deterministic sampling as the check
+ sc.render.engine = 'CYCLES'
+ sc.cycles.samples = 48
+ sc.cycles.use_denoising = False
+ sc.render.resolution_x = 1280
+ sc.render.resolution_y = 720
+ sc.render.resolution_percentage = 100
+ sc.render.image_settings.file_format = 'PNG'
+ sc.render.filepath = path
+ sc.view_settings.view_transform = 'Standard'
+ 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="cycles", choices=("cycles",),
+ help="render engine — pinned to cycles for deterministic samples")
+ args = p.parse_args(argv)
+
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ sc = bpy.context.scene
+ hero, decoy, hero_c, key = build_studio(sc)
+ code = check(sc, key, hero_c, hero, decoy)
+ if code:
+ return code
+
+ if args.output:
+ if not render_still(sc, os.path.abspath(args.output), args.engine):
+ print("ERROR: render produced no file", file=sys.stderr)
+ return 9
+ print(f"rendered still {args.output}")
+
+ print("light-link-studio 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/examples/light-link-studio/preview.webp b/examples/light-link-studio/preview.webp
new file mode 100644
index 0000000..5c12da8
Binary files /dev/null and b/examples/light-link-studio/preview.webp differ