A runnable example that aims twelve brass spikes at an ember 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 spike 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.6°). A missing constraint, a muted one, a TRACK_TO stand-in, or a flipped axis fails the check.
Radiating composition — the twelve spikes read as converging on the core from beyond the frame, so their tails bleed past the left, right, and bottom edges by design (measured fill 1.000x/0.917y with edge touch on three sides). If wired to examples/gallery_framing.py, call it with deviation="radiating composition; spikes read as extending past frame".
# Cheap correctness check (no render) — the CI check:
blender --background --python damped_track_aim.py --
diff --git a/docs/gallery/sky-texture-sun-elevation/index.html b/docs/gallery/sky-texture-sun-elevation/index.html
index f538e08..ec1d953 100644
--- a/docs/gallery/sky-texture-sun-elevation/index.html
+++ b/docs/gallery/sky-texture-sun-elevation/index.html
@@ -190,6 +190,8 @@ sky-texture-sun-elevation
Version witness: sky_type and the dust/aerosol rename are the divergence; sun_elevation itself is stable. Zenith rise differs by model (Nishita vs multiple scattering) but clears the same gate on both binaries.
Stage deviation
World carries a Nishita / multiple-scattering Sky Texture instead of the default near-black Background — the contract *is* the sky. Gallery still is a dual-elevation diptych (8° | 55°) so failure (identical panels) is visible at thumbnail scale. Layer 1 still holds: view_transform='Standard', designed terracotta materials, chosen camera, no helpers in frame.
+Framing deviation
+The hero is the sky itself — a world contract, not an object on the stage. The silhouette matte can only measure the cone reference props (0.369x / 0.489y), so the out-of-band number is a measurement artifact, not the composition; if ever wired to examples/gallery_framing.py, this example reports with deviation="world-contract subject; matte sees only the cone props" rather than enforcing.
Run
# Zenith-luminance correctness check (tiny Cycles CPU EXR probes) — the CI check:
blender --background --python sky_texture_sun_elevation.py --
diff --git a/docs/gallery/vse-cut-list/index.html b/docs/gallery/vse-cut-list/index.html
index 18804b4..d657c5a 100644
--- a/docs/gallery/vse-cut-list/index.html
+++ b/docs/gallery/vse-cut-list/index.html
@@ -192,6 +192,8 @@ vse-cut-list
- A GAMMA_CROSS asked to outlast its inputs' overlap is silently clamped to the overlap — request length 9 over a (25, 33) overlap, get (25, 33).
- A scene strip pointing at its own scene is a feedback loop and renders transparent (alpha 0) — the "stage" is silently absent. Source a separate scene.
- Effect strips consume their inputs: input strips never composite on their own channel, and the effect's transform applies on top of the inputs' transforms. Consumption requires the effect on a channel above its inputs; below, they keep painting independently.
- An empty
bpy_prop_collection is falsy — se.strips or se.sequences silently falls through to the legacy accessor on an empty timeline. Always branch on hasattr.
Version divergence: the whole example is the divergence — gated on the bpy.app.version tuple (>= (5, 0, 0)), never on version_string ("4.5.11 LTS" is not bare semver). Each side asserts its own canonical contract plus the other side's removal/bridge state. Measured values are identical on 4.5.11 and 5.1.1, including the pixel witness.
Render: the program wall *is* the sequencer output at frame 29 (mid cross) — crimson A, teal B, amber long-runner C, and the 50/50 cross blend, over the Stage scene strip showing the dark studio. An off-by-one in end-exclusive span math drops its cell to the dark stage; the caption strip carries the closed form. The hero presents that authentic frame on a reference monitor in a dark-studio editing bay — the pixels on the screen are the genuine sequencer output (evidence); only the bay around them is staged (presentation). Rendered locally with EEVEE (GPU host); the checks and --check-pixels need no GPU (Cycles CPU).
+Framing measurement
+The gallery still renders from the dedicated Bay scene (camera BayCam), not bpy.context.scene — a framing measurement with examples/gallery_framing.py must be handed that (scene, camera) pair explicitly.
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python vse_cut_list.py --
diff --git a/docs/gallery/wave-displace/index.html b/docs/gallery/wave-displace/index.html
index 6f4fecb..e0993a2 100644
--- a/docs/gallery/wave-displace/index.html
+++ b/docs/gallery/wave-displace/index.html
@@ -185,6 +185,8 @@ wave-displace
A runnable example that displaces a 96×96 grid (9,409 vertices) into a standing wave using one foreach_get and one foreach_set — the bulk-IO pattern from use-foreach-set-for-bulk-data and the mesh-editing-and-bmesh skill — instead of 9,409 individual mesh.vertices[i].co accesses.
What it witnesses: the bulk path is not just faster, it is *correct* — the check asserts the flat grid gained the expected Z span (the write actually landed) and that every vertex matches the closed-form wave, so a stride or interleave bug in the flat buffer cannot hide behind a lucky probe.
+Framing deviation
+Edge-to-edge field — the displaced sheet fills the frame and crops at the left, right, and bottom edges so the wave reads as a surface, not an object on a stage (measured fill 1.000x/0.817y with edge touch on three sides). If wired to examples/gallery_framing.py, call it with deviation="edge-to-edge wave field; the fill is the point".
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python wave_displace.py --
diff --git a/examples/damped-track-aim/README.md b/examples/damped-track-aim/README.md
index a9e07b2..c477458 100644
--- a/examples/damped-track-aim/README.md
+++ b/examples/damped-track-aim/README.md
@@ -12,6 +12,14 @@ bound to the core on `TRACK_Z`. After a depsgraph update, each evaluated local
constraint, a muted one, a `TRACK_TO` stand-in, or a flipped axis fails the
check.
+## Framing deviation
+
+Radiating composition — the twelve spikes read as converging on the core from
+beyond the frame, so their tails bleed past the left, right, and bottom edges
+by design (measured fill 1.000x/0.917y with edge touch on three sides). If
+wired to `examples/gallery_framing.py`, call it with
+`deviation="radiating composition; spikes read as extending past frame"`.
+
## Run
```bash
diff --git a/examples/gallery_framing.py b/examples/gallery_framing.py
index 9e59831..b39bb47 100644
--- a/examples/gallery_framing.py
+++ b/examples/gallery_framing.py
@@ -53,6 +53,25 @@
render exactly like a failed contract check fails the witness. The gate
runs only on the opt-in render path; check-only semantics (non-zero ==
API-contract drift) are unchanged.
+
+Deviations: VISUAL-STYLE Layer 1 allows a documented framing deviation
+when the bleed or the scale is the design — radiating subjects that read
+as extending past the frame, edge-to-edge fields where the fill is the
+point, subjects whose contract is the world or the atmosphere. The
+deviation must be visible in the example's source and render log: pass
+``deviation="reason"`` to `check_framing`, which switches enforcement to
+reporting and prints the reason with the numbers. An empty or whitespace
+reason raises ValueError — a deviation cannot be taken silently. The
+README must carry the same one-line note, exactly as stage deviations do.
+
+Scope: the helper measures the (scene, camera) pair it is handed — pass
+the scene the still actually renders from. Multi-scene examples (e.g.
+vse-cut-list, whose gallery still renders from a dedicated ``Bay`` scene
+while ``bpy.context.scene`` is the cut-list scene) must pass that scene
+and its camera explicitly. Examples whose subject IS the world or the
+atmosphere (sky-texture-sun-elevation) have no renderable hero for the
+matte to isolate — the number mismeasures a reference prop, so they take
+a documented deviation and report rather than enforce.
"""
import os
import sys
@@ -325,13 +344,32 @@ def measure_framing(scene, camera, hero, elements, stage=(), strategy=DEFAULT_ST
return res
-def check_framing(scene, camera, hero, elements, stage=(), strategy=DEFAULT_STRATEGY):
+def check_framing(scene, camera, hero, elements, stage=(), strategy=DEFAULT_STRATEGY,
+ deviation=None):
"""Measure, print the numbers, and gate: 0 pass, EXIT_FRAMING (10) on violation.
Render path only — never call this from an example's check-only path.
+
+ deviation — None (default) enforces the band. A non-empty reason string
+ documents a legitimate framing deviation (VISUAL-STYLE Layer 1): the
+ numbers are still measured and printed with the reason, but the gate
+ reports instead of enforcing and always returns 0. An empty or
+ whitespace-only reason raises ValueError — no silent opt-outs.
"""
+ if deviation is not None and not str(deviation).strip():
+ raise ValueError(
+ "check_framing deviation requires a non-empty reason string; "
+ "a framing deviation cannot be taken silently"
+ )
res = measure_framing(scene, camera, hero, elements, stage=stage, strategy=strategy)
print(res.report())
+ if deviation is not None:
+ print(
+ f'framing_deviation reason="{str(deviation).strip()}" '
+ f"enforcement=report fill_ok={res.fill_ok} "
+ f"margins_ok={res.margins_ok} (numbers printed, gate not enforced)"
+ )
+ return 0
if not res.ok:
print(
"ERROR: framing violation — Layer 1 requires fill "
diff --git a/examples/sky-texture-sun-elevation/README.md b/examples/sky-texture-sun-elevation/README.md
index 5db1885..21519a2 100644
--- a/examples/sky-texture-sun-elevation/README.md
+++ b/examples/sky-texture-sun-elevation/README.md
@@ -39,6 +39,15 @@ dual-elevation diptych (8° | 55°) so failure (identical panels) is visible at
thumbnail scale. Layer 1 still holds: `view_transform='Standard'`, designed
terracotta materials, chosen camera, no helpers in frame.
+## Framing deviation
+
+The hero is the sky itself — a world contract, not an object on the stage.
+The silhouette matte can only measure the cone reference props (0.369x /
+0.489y), so the out-of-band number is a measurement artifact, not the
+composition; if ever wired to `examples/gallery_framing.py`, this example
+reports with `deviation="world-contract subject; matte sees only the cone
+props"` rather than enforcing.
+
## Run
```bash
diff --git a/examples/vse-cut-list/README.md b/examples/vse-cut-list/README.md
index 547bdd1..a54a09d 100644
--- a/examples/vse-cut-list/README.md
+++ b/examples/vse-cut-list/README.md
@@ -93,6 +93,13 @@ are the genuine sequencer output (evidence); only the bay around them is
staged (presentation). Rendered locally with EEVEE (GPU host); the checks
and `--check-pixels` need no GPU (Cycles CPU).
+## Framing measurement
+
+The gallery still renders from the dedicated `Bay` scene (camera `BayCam`),
+not `bpy.context.scene` — a framing measurement with
+`examples/gallery_framing.py` must be handed that (scene, camera) pair
+explicitly.
+
## Run
```bash
diff --git a/examples/wave-displace/README.md b/examples/wave-displace/README.md
index f45e480..c150961 100644
--- a/examples/wave-displace/README.md
+++ b/examples/wave-displace/README.md
@@ -11,6 +11,14 @@ the flat grid gained the expected Z span (the write actually landed) and that **
vertex matches the closed-form wave, so a stride or interleave bug in the flat buffer
cannot hide behind a lucky probe.
+## Framing deviation
+
+Edge-to-edge field — the displaced sheet fills the frame and crops at the left,
+right, and bottom edges so the wave reads as a surface, not an object on a
+stage (measured fill 1.000x/0.817y with edge touch on three sides). If wired
+to `examples/gallery_framing.py`, call it with
+`deviation="edge-to-edge wave field; the fill is the point"`.
+
## Run
```bash