Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 `<img alt>` text and witnesses callouts in `docs/gallery/index.html` and `docs/gallery/<name>/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 `<img>` 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

Expand Down
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/VISUAL-STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/gallery/damped-track-aim/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ <h1>damped-track-aim</h1>
<section class="detail-section md">
<p>A runnable example that aims twelve brass spikes at an ember core with <code>Object.constraints.new(&#x27;DAMPED_TRACK&#x27;)</code> — the data-API path, not <code>bpy.ops.object.constraint_add</code> (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.</p>
<p><strong>What it witnesses:</strong> every spike carries exactly one unmuted <code>DAMPED_TRACK</code> bound to the core on <code>TRACK_Z</code>. After a depsgraph update, each evaluated local <code>+Z</code> aligns with the world vector toward the core (dot ≥ 0.998 ≈ 3.6°). A missing constraint, a muted one, a <code>TRACK_TO</code> stand-in, or a flipped axis fails the check.</p>
<h2>Framing deviation</h2>
<p>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 <code>examples/gallery_framing.py</code>, call it with <code>deviation=&quot;radiating composition; spikes read as extending past frame&quot;</code>.</p>
<h2>Run</h2>
<pre><code># Cheap correctness check (no render) — the CI check:
blender --background --python damped_track_aim.py --
Expand Down
2 changes: 2 additions & 0 deletions docs/gallery/sky-texture-sun-elevation/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ <h1>sky-texture-sun-elevation</h1>
<p><strong>Version witness:</strong> <code>sky_type</code> and the dust/aerosol rename are the divergence; <code>sun_elevation</code> itself is stable. Zenith rise differs by model (Nishita vs multiple scattering) but clears the same gate on both binaries.</p>
<h2>Stage deviation</h2>
<p>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: <code>view_transform=&#x27;Standard&#x27;</code>, designed terracotta materials, chosen camera, no helpers in frame.</p>
<h2>Framing deviation</h2>
<p>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 <code>examples/gallery_framing.py</code>, this example reports with <code>deviation=&quot;world-contract subject; matte sees only the cone props&quot;</code> rather than enforcing.</p>
<h2>Run</h2>
<pre><code># Zenith-luminance correctness check (tiny Cycles CPU EXR probes) — the CI check:
blender --background --python sky_texture_sun_elevation.py --
Expand Down
2 changes: 2 additions & 0 deletions docs/gallery/vse-cut-list/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ <h1>vse-cut-list</h1>
<ul><li>A GAMMA_CROSS asked to outlast its inputs&#x27; overlap is <strong>silently clamped</strong> to the overlap — request length 9 over a (25, 33) overlap, get (25, 33).</li><li>A scene strip pointing at its <strong>own</strong> scene is a feedback loop and renders transparent (alpha 0) — the &quot;stage&quot; is silently absent. Source a separate scene.</li><li>Effect strips <strong>consume</strong> their inputs: input strips never composite on their own channel, and the effect&#x27;s transform applies on top of the inputs&#x27; transforms. Consumption requires the effect on a channel <strong>above</strong> its inputs; below, they keep painting independently.</li><li>An empty <code>bpy_prop_collection</code> is falsy — <code>se.strips or se.sequences</code> silently falls through to the legacy accessor on an empty timeline. Always branch on <code>hasattr</code>.</li></ul>
<p><strong>Version divergence:</strong> the whole example is the divergence — gated on the <code>bpy.app.version</code> tuple (<code>&gt;= (5, 0, 0)</code>), never on <code>version_string</code> (<code>&quot;4.5.11 LTS&quot;</code> is not bare semver). Each side asserts its own canonical contract plus the other side&#x27;s removal/bridge state. Measured values are identical on 4.5.11 and 5.1.1, including the pixel witness.</p>
<p><strong>Render:</strong> 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 <code>--check-pixels</code> need no GPU (Cycles CPU).</p>
<h2>Framing measurement</h2>
<p>The gallery still renders from the dedicated <code>Bay</code> scene (camera <code>BayCam</code>), not <code>bpy.context.scene</code> — a framing measurement with <code>examples/gallery_framing.py</code> must be handed that (scene, camera) pair explicitly.</p>
<h2>Run</h2>
<pre><code># Cheap correctness check (no render) — the CI check:
blender --background --python vse_cut_list.py --
Expand Down
2 changes: 2 additions & 0 deletions docs/gallery/wave-displace/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ <h1>wave-displace</h1>
<section class="detail-section md">
<p>A runnable example that displaces a 96×96 grid (9,409 vertices) into a standing wave using <strong>one <code>foreach_get</code> and one <code>foreach_set</code></strong> — the bulk-IO pattern from <a href="https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/rules/use-foreach-set-for-bulk-data.mdc"><code>use-foreach-set-for-bulk-data</code></a> and the <a href="https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/skills/mesh-editing-and-bmesh/SKILL.md"><code>mesh-editing-and-bmesh</code></a> skill — instead of 9,409 individual <code>mesh.vertices[i].co</code> accesses.</p>
<p><strong>What it witnesses:</strong> 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 <strong>every</strong> vertex matches the closed-form wave, so a stride or interleave bug in the flat buffer cannot hide behind a lucky probe.</p>
<h2>Framing deviation</h2>
<p>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 <code>examples/gallery_framing.py</code>, call it with <code>deviation=&quot;edge-to-edge wave field; the fill is the point&quot;</code>.</p>
<h2>Run</h2>
<pre><code># Cheap correctness check (no render) — the CI check:
blender --background --python wave_displace.py --
Expand Down
8 changes: 8 additions & 0 deletions examples/damped-track-aim/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 39 additions & 1 deletion examples/gallery_framing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 "
Expand Down
9 changes: 9 additions & 0 deletions examples/sky-texture-sun-elevation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions examples/vse-cut-list/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions examples/wave-displace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading