From f970be34aecf90c6174c21738703a74312fbde93 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Wed, 22 Jul 2026 17:47:43 -0400 Subject: [PATCH 1/4] feat: add deviation opt-out to the framing helper PR #107's survey put 13 of 22 measured examples outside the band, including a pinned calibration member whose edge bleed is the design, so the band gains the same structure the stage rules have: presumption by default, documented deviation when the bleed or the scale is the point. check_framing takes deviation=: with a non-empty reason string it measures and prints the numbers with the reason but reports instead of enforcing; an empty or whitespace reason raises ValueError so a deviation cannot be taken silently. The docstring also scopes the helper: it measures the (scene, camera) pair it is handed, so multi-scene examples pass the still's scene explicitly, and world/atmosphere-subject examples report rather than enforce because the matte has no renderable hero to isolate. Both current callers keep the enforcing default; verified unchanged on 5.1.2 and 4.5.11 (0.803/0.733 and 0.816/0.678) and falsified both ways (reasoned deviation reports with exit 0; empty reason ValueErrors with exit 1). Signed-off-by: fOuttaMyPaint --- examples/gallery_framing.py | 40 ++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) 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 " From d6711e765b680c30e73ad15d8859107a98bc1999 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Wed, 22 Jul 2026 17:49:09 -0400 Subject: [PATCH 2/4] docs: add the framing deviation clause to VISUAL-STYLE and gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 1 framing now mirrors the stage rules' structure: the 70-90 fill band and the 2 percent margin floor remain the presumption, and a composition may deviate when the bleed or the scale is the design — radiating subjects, edge-to-edge fields, world or atmosphere contracts. A deviation requires a one-line README note under a Framing deviation heading and the helper call switches to check_framing(deviation=reason) so it reports with the reason printed rather than enforcing; an undocumented deviation is still a defect, and an empty reason raises. The CLAUDE.md quality-gates bullet inherits the clause by reference. Signed-off-by: fOuttaMyPaint --- CLAUDE.md | 2 +- docs/VISUAL-STYLE.md | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index d429f1a..30c61a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,7 @@ Stage with **explicit paths only** — never `git add -A` or `git add .`. Cursor ## Quality Gates for Example Runs - `docs/VISUAL-STYLE.md` is the **binding** render standard; deviations are defects. -- **Framing gate:** Layer 1 framing is measured, not eyeballed — the example's `--output` render path calls the shared helper `examples/gallery_framing.py` (`check_framing`, exit 10 on violation) before writing the still: hero fill 0.70–0.90 of frame in at least one axis, every element that matters clearing all four edges by ≥ 0.02. The check-only path never invokes it, so smoke runtimes are unaffected. +- **Framing gate:** Layer 1 framing is measured, not eyeballed — the example's `--output` render path calls the shared helper `examples/gallery_framing.py` (`check_framing`, exit 10 on violation) before writing the still: hero fill 0.70–0.90 of frame in at least one axis, every element that matters clearing all four edges by ≥ 0.02. The check-only path never invokes it, so smoke runtimes are unaffected. Documented framing deviations (VISUAL-STYLE Layer 1) call `check_framing(..., deviation="reason")` — the helper reports instead of enforcing, and the reason string is required. - **Contact-sheet gate:** composite the candidate hero beside the pinned calibration set — currently `armature-bend`, `damped-track-aim`, `bmesh-gear` — commit the composite under `docs/gallery/contact-sheets/`, link it in the PR body, and report per-criterion verdicts (stage darkness, wedge warmth, subject fill, saturation, thumbnail legibility) including mean luminance versus the calibration images. A claim without the committed composite is not acceptable evidence. **This list is the canonical home of the pinned set** — update it here when a new example outclasses a member; `docs/new-example-prompt.md` points here rather than naming members. The longer "calibration references" list in `docs/VISUAL-STYLE.md` is a style reference, not this contact-sheet set. - **Falsification:** every check must be proven to fail once — break the contract, observe the non-zero exit, restore — with the probe and the measured error reported in the PR body. An assertion that cannot fail witnesses nothing. - **After gallery regeneration** (`python scripts/build_gallery.py`), read the **generated HTML** character by character — the `` text and witnesses callouts in `docs/gallery/index.html` and `docs/gallery//index.html` — not just `examples/gallery.json`. Precedent: the `teaches.split(".")[0]` bug truncated 14/21 card alts at dotted API paths like `bmesh.ops` while the source JSON looked fine (fixed in PR #68). diff --git a/docs/VISUAL-STYLE.md b/docs/VISUAL-STYLE.md index a46fa9f..01bacd7 100644 --- a/docs/VISUAL-STYLE.md +++ b/docs/VISUAL-STYLE.md @@ -61,6 +61,17 @@ the default stage. markers — and must clear all four edges by ≥ 2 % of the frame dimension. Default strategy is the silhouette alpha matte; projection is the cheap bbox alternative for boxy subjects (tradeoff in the helper's docstring). +- **Framing deviations.** The band and the margin floor are the presumption. + A composition may deviate 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, and subjects whose contract is the + world or the atmosphere rather than an object on a stage. A deviation + requires one line in the example README under a `Framing deviation` + heading — what the composition requires and why, exactly as stage + deviations do. An undocumented deviation is a defect. Under a documented + deviation the helper reports rather than enforces: call + `check_framing(..., deviation="reason")`, which prints the numbers with + the reason; an empty reason raises, so no deviation is taken silently. - Camera: a chosen angle, not the default — typically a 45–55 mm lens, slightly above subject height, aimed with a `TRACK_TO` constraint at an empty on the subject. Flat subjects present toward the camera (lean or From f15d990a813b02173f0e46d7e6de522b5786f1de Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Wed, 22 Jul 2026 17:50:11 -0400 Subject: [PATCH 3/4] docs: reconcile two runtime facts in CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke workflow floats: it resolves the highest published point release of each series at run time, so CI ran Blender 4.5.12 LTS on PR #107 while local .scratch held 4.5.11 — CLAUDE.md now says the series floats and a local point release may lag, with the exact-reported-version rule unchanged. Also records the Playwright lesson from #107 next to the gallery-capture gate: Chromium scroll-anchoring defeats scrollTo(0, 0) after lazy-load, so captures are full-page, not scroll-then-shoot. Signed-off-by: fOuttaMyPaint --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 30c61a9..47f5aab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,7 @@ cross-example import mechanism (see its docstring). - Local Blender binaries: check `.scratch/` at the repo root **first** — some machines have no system Blender install, and a prior agent run downloads official releases there (e.g. `.scratch/5.1/blender-5.1.x-.../blender[.exe]`). Then check system installs. Do not probe blindly; locate the binary, run it, and state the **exact binary path and the version the binary itself reports** in every report. - **5.1 is the local check version. 4.5 LTS is exercised by CI when unavailable locally.** 4.4 is not a substitute for 4.5 and must never be reported as 4.5. +- **CI floats within each series:** `blender-smoke.yml` resolves the highest published point release at run time (`sort -V | tail -1` on the download listing), so a local 4.5.x may lag CI — its 4.5 job ran 4.5.12 LTS on PR #107 while local `.scratch/` held 4.5.11. Either way, state the exact version the binary reports. - If `.scratch/` lacks a needed version, download an official release from download.blender.org into it. `.scratch` is gitignored. - In scripts, version-branch on the `bpy.app.version` tuple, never on `bpy.app.version_string` — it reads e.g. `"4.5.11 LTS"`, not bare semver. @@ -125,6 +126,7 @@ Stage with **explicit paths only** — never `git add -A` or `git add .`. Cursor - **Contact-sheet gate:** composite the candidate hero beside the pinned calibration set — currently `armature-bend`, `damped-track-aim`, `bmesh-gear` — commit the composite under `docs/gallery/contact-sheets/`, link it in the PR body, and report per-criterion verdicts (stage darkness, wedge warmth, subject fill, saturation, thumbnail legibility) including mean luminance versus the calibration images. A claim without the committed composite is not acceptable evidence. **This list is the canonical home of the pinned set** — update it here when a new example outclasses a member; `docs/new-example-prompt.md` points here rather than naming members. The longer "calibration references" list in `docs/VISUAL-STYLE.md` is a style reference, not this contact-sheet set. - **Falsification:** every check must be proven to fail once — break the contract, observe the non-zero exit, restore — with the probe and the measured error reported in the PR body. An assertion that cannot fail witnesses nothing. - **After gallery regeneration** (`python scripts/build_gallery.py`), read the **generated HTML** character by character — the `` text and witnesses callouts in `docs/gallery/index.html` and `docs/gallery//index.html` — not just `examples/gallery.json`. Precedent: the `teaches.split(".")[0]` bug truncated 14/21 card alts at dotted API paths like `bmesh.ops` while the source JSON looked fine (fixed in PR #68). +- **Playwright gallery captures:** gallery `` tags lazy-load, and Chromium scroll-anchoring defeats `scrollTo(0, 0)` after images load — take full-page captures and crop locally, not scroll-then-shoot. ## Example-Run Process From abc15724e09bcaf9f6dbaa7be477d8bfd43a45a1 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Wed, 22 Jul 2026 17:53:25 -0400 Subject: [PATCH 4/4] docs: triage the thirteen out-of-band heroes and record the worklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-size and card-scale review of each surveyed hero against its README and contract. Two are legitimate deviations and now carry the one-line README Framing deviation note: damped-track-aim (radiating composition — the spikes read as converging from beyond the frame; it is a pinned calibration member and the gallery's reference standard stays) and wave-displace (edge-to-edge field — the fill is the point). sky-texture-sun-elevation is a measurement artifact: the hero is the sky itself, so the matte only saw the cone props; its README now documents report-only. vse-cut-list is in scope — its still renders from the Bay scene with BayCam, and the README records that the (scene, camera) pair must be passed explicitly. The remaining ten are genuine defects (clipped subjects, over-fill, dead space) and land on the ROADMAP candidate pool as a restage worklist with measured numbers. No re-renders in this pass. Signed-off-by: fOuttaMyPaint --- ROADMAP.md | 2 ++ docs/gallery/damped-track-aim/index.html | 2 ++ docs/gallery/sky-texture-sun-elevation/index.html | 2 ++ docs/gallery/vse-cut-list/index.html | 2 ++ docs/gallery/wave-displace/index.html | 2 ++ examples/damped-track-aim/README.md | 8 ++++++++ examples/sky-texture-sun-elevation/README.md | 9 +++++++++ examples/vse-cut-list/README.md | 7 +++++++ examples/wave-displace/README.md | 8 ++++++++ 9 files changed, 42 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 7a57e9c..c6f5ec1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,8 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - UV atlas pack witness — lightmap/unique UV island packing with non-overlap + utilization closed forms (deferred behind hygiene/origin; overlaps `uv-layer-grid` / `triangulate-tangents` / `gltf-export-roundtrip` UV arc) - 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` +- Gallery framing defect worklist (from the #107 survey; restage + re-render into the 0.70–0.90 band with all margins ≥ 0.02, then wire `examples/gallery_framing.py`'s `check_framing`): **depsgraph-export** (fill 0.984x over; left cube clipped by left+bottom edges), **driver-wave** (0.969x over; side margins 0.016), **shader-node-group** (0.969x over; side margins 0.016), **swatch-grid** (0.944y over, marginal), **gp-lineart-contour** (0.978y over; glass base cropped at bottom edge), **png-exr-alpha** (display plinth touches bottom edge, minor), **vertex-weight-limit** (base cropped at bottom edge; x-fill 0.247 with dead space), **text-version-stamp** (max fill 0.663 under; dead lower third), **triangulate-tangents** (0.334x/0.661y under; dead space both sides), **curve-bevel-arc** (right cap margin 0.012 < 0.02, marginal). Triaged as documented deviations instead (README `Framing deviation` notes, no restage): damped-track-aim (radiating composition, calibration member) and wave-displace (edge-to-edge field); sky-texture-sun-elevation is a measurement artifact (world-contract subject — report-only if wired); vse-cut-list is in scope via its `Bay` scene/camera pair. + ## Future (uncommitted) - Asset library and asset browser scripting skill diff --git a/docs/gallery/damped-track-aim/index.html b/docs/gallery/damped-track-aim/index.html index 14c2812..aa8e1e5 100644 --- a/docs/gallery/damped-track-aim/index.html +++ b/docs/gallery/damped-track-aim/index.html @@ -185,6 +185,8 @@

damped-track-aim

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.

+

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

# 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/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