diff --git a/CLAUDE.md b/CLAUDE.md index d429f1a..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. @@ -121,10 +122,11 @@ 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). +- **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 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/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 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/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