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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ a `.cursor-plugin/plugin.json` manifest so the ecosystem drift checker
classifies it as a `cursor-plugin`. This is content the AI loads when the user
asks Blender questions or works on Blender add-ons in Cursor or Claude Code.

The content base is 12 skills, 6 rules, 2 templates, 17 snippets, and 40
The content base is 12 skills, 6 rules, 2 templates, 17 snippets, and 44
examples (counts are CI-enforced against README.md and the manifest). The full
inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy
and authoring rules: copy `examples/bmesh-gear/`; the render look is specified
Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ skills/<skill-name>/SKILL.md - AI workflow definitions, 12 total
rules/<rule-name>.mdc - Anti-pattern rules, 6 total
templates/<template-name>/ - Starter projects, 2 total
snippets/<snippet-name>.py - Standalone code patterns, 17 total
examples/<name>/ - Runnable smoke-gated examples, 40 total (+ gallery.json)
examples/<name>/ - Runnable smoke-gated examples, 44 total (+ gallery.json)
scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only)
scripts/site/ - Vendored landing-page build (Jinja2)
docs/gallery/ - Committed generated gallery pages + hero renders
Expand Down Expand Up @@ -82,11 +82,11 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh

v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`.

## Examples (40)
## Examples (44)

Runnable scripts at `examples/<name>/`, each asserting a real API contract with
deterministic checks (exit non-zero on failure) and optionally rendering a still via
`--output`. All forty run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`;
`--output`. All of them run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`;
their renders ship in the site gallery at `docs/gallery/`. `examples/gallery.json` is the
gallery's source of truth. When authoring a new one, copy the anatomy of
`examples/bmesh-gear/` (script structure, README shape, dark-studio render recipe) and
Expand Down Expand Up @@ -127,7 +127,7 @@ Stage with **explicit paths only** — never `git add -A` or `git add .`. Cursor
- **Asset-sheet gate (asset-type examples — game props/kits):** composite the hero asset rendered alone (neutral three-quarter view, plain studio lighting, no staging tricks, no labels, no comparison props) beside the pinned asset-quality reference set — currently `collision-hull-proxy`, `custom-normals-shade`, `vertex-weight-limit`, `lod-decimate-chain` — rendered the same way; commit under `docs/gallery/asset-sheets/`, link it in the PR body, and report a verdict. The asset ships only if it is not identifiable as the least-designed object in that lineup — a strong scene can carry a weak model; this gate removes the scene. **This list is the canonical home of the reference set** — update it here when a new asset outclasses a member. The measurable floors behind the gate (naming, material variation, edge treatment) live in `examples/gallery_asset_quality.py` — render path only, same call pattern as `gallery_framing`, exit 11 on violation — with the calibration table and dropped-floor evidence in `docs/VISUAL-STYLE.md` § Asset quality.
- **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.
- **Playwright gallery captures:** gallery `<img>` tags lazy-load, so force them first (`document.querySelectorAll('img').forEach(i => i.loading = 'eager')`, then wait). **Scroll the target card into view and take a viewport capture** — `scrollIntoView({block:'center', behavior:'instant'})`, short wait, `browser_take_screenshot` with `fullPage` omitted. A `fullPage` capture is NOT a workaround: on a tall gallery page it renders every card image blank even when the images are verified loaded (`complete === true`, `naturalWidth === 1280`, `opacity === 1`) — measured on the 44-card grid at 1425x4516. Verify load state via `browser_evaluate` rather than trusting the pixels.

## Example-Run Process

Expand Down
10 changes: 10 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo

- ~~Gallery framing recomposition worklist~~ **DRAINED**: all four recomposition cases restaged and gated with `check_framing` — depsgraph-export (0.984x/touch→0.812x; cube+dome recentered, aim-based camera), vertex-weight-limit (touch B→0.867y; more frontal camera, lower aim, base in frame), triangulate-tangents (0.334x→0.833y; 2x-class move-in plus Key/Glint trim that also cleared a pre-existing dome blowout), text-version-stamp (0.663x→0.719x; moved in with aim on the stamp center, dead third gone). The seven marginal cases were camera-nudged in the preceding sweep; deviations documented in #108 (damped-track-aim, wave-displace); sky-texture-sun-elevation is a measurement artifact (world subject — report-only if wired). Remaining: nothing on the framing worklist — future examples inherit the gate at authoring time.

## Asset sheets committed before 2026-07-25 are not comparable

The neutral asset-sheet harness sized its camera from the subject's bounding
box **diagonal**, which cropped tall subjects and shrank wide ones, so panels
in one sheet were at different effective scales. It now solves the required
distance from the real field of view on both axes. Sheets committed before
this fix (`modular-kit-snap`, `lightmap-uv-channel`) show mis-scaled panels
and must not be cited as evidence for or against an asset; re-render them if
that comparison is ever needed.

## Asset-quality survey worklist (measured 2026-07-24, `gallery_asset_quality` floors)

Full-gallery floor survey after the gate landed (`feat/asset-quality-gate`).
Expand Down
37 changes: 34 additions & 3 deletions docs/VISUAL-STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,23 @@ hand-tuned to pass everything is not one either:
assignment, invisible to a per-part share (both measure 1.0 dominant).
Printed as information only.

### Status: what the floors have actually caught

Recorded honestly so nobody mistakes a floor pass for a quality verdict.
**No floor has ever bound on a shipped asset.** Every example that reached
the asset sheet had already cleared naming, material variation and edge
treatment. More pointedly, `socket-attach-points` passed all three on its
first draft (`edge90` 0.000, 10 materials, no default names) — a model that
was then judged bad by eye and rebuilt from scratch. The floors scored the
bevels, not the design.

Treat them as a crude filter for obvious failures — a raw box, an unnamed
`Cube`, one flat slot across a whole prop — and nothing more. The asset
sheet is the gate that decides. Their one confirmed catch to date was
indirect: `measure_edge90` hit a zero-area face from an over-large bevel.
It used to raise `ValueError` there; it now reports `degenerate_faces` in
the line, which is the more useful signal.

### The Goodhart warning

These floors are necessary, not sufficient. Bolting meaningless greebles
Expand Down Expand Up @@ -185,9 +202,23 @@ model, and this gate removes the scene.
## Output

- Render 1280×720 PNG (`taa_render_samples`/`cycles.samples` 32–64).
- Gallery assets: hero webp 1280×720 and preview webp 1200×675, quality 85
(load the PNG in Blender, `save(filepath=..., quality=85)`, `scale()` for
the preview — this preserves pixels without re-applying color management).
- Gallery assets: hero webp 1280×720 and preview webp 1200×675, quality 85.
**Encode with Pillow, not with Blender's `Image.save()`.** The Blender path
keeps the buffer RGBA and ignores the `quality` argument, writing a
near-lossless file: measured 991 KB for a hero that Pillow writes at 19 KB
from the same PNG, and two heroes shipped at 1.15 MB and 984 KB before this
was caught. Reading the rendered PNG and writing WebP does not re-apply
colour management either way, so the pixels are equally faithful:

```python
from PIL import Image
im = Image.open(png).convert("RGB") # drop the unused alpha
im.save(hero_webp, quality=85, method=6)
im.resize((1200, 675), Image.LANCZOS).save(preview_webp, quality=85, method=6)
```

Sanity floor: a 1280×720 hero should land in the tens of kilobytes. Anything
over ~200 KB means the alpha channel survived and the encode went lossless.
- After touching any example: `python scripts/build_gallery.py`, and update
the README gallery-row alt text if the composition changed.

Expand Down
Binary file modified docs/gallery/assets/lightmap-uv-channel-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/modular-kit-snap-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 22 additions & 1 deletion docs/gallery/gltf-export-roundtrip/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ <h2>Source</h2>
NRM_TOL = <span class="n">2e-4</span>
UV_TOL = <span class="n">3e-5</span>
UNIT_TOL = <span class="n">1e-3</span> <span class="c"># |length(n) - 1| for re-imported loop normals</span>
UV_SAMPLE = <span class="n">32</span> <span class="c"># loops sampled for the V-flip check; asserted, not assumed</span>
KEY_DIGITS = <span class="n">4</span> <span class="c"># position-key grid for attribute lookups</span>


Expand Down Expand Up @@ -624,12 +625,32 @@ <h2>Source</h2>
disk_pos = acc_floats(prim0[<span class="s">&quot;POSITION&quot;</span>], <span class="n">3</span>)
disk_uv = acc_floats(prim0[<span class="s">&quot;TEXCOORD_0&quot;</span>], <span class="n">2</span>)
uv_set = set_map(snap[<span class="s">&quot;loops&quot;</span>], snap[<span class="s">&quot;uvs&quot;</span>])
<span class="c"># The comparison below is only evidence if it actually runs on a known</span>
<span class="c"># number of pairs. zip() truncates to the shorter sequence, so without</span>
<span class="c"># these guards a primitive that carried no TEXCOORD_0 at all would</span>
<span class="c"># leave flip_err at its initial 0.0 and pass. Assert the pairing and</span>
<span class="c"># count the comparisons, then require the count.</span>
<span class="k">if</span> len(disk_uv) != len(disk_pos):
print(<span class="s">f&quot;</span><span class="s">ERROR: prim0 TEXCOORD_0 count </span>{len(disk_uv)}<span class="s"> != POSITION </span><span class="s">&quot;</span>
<span class="s">f&quot;</span><span class="s">count </span>{len(disk_pos)}<span class="s"> - the UV attribute does not cover </span><span class="s">&quot;</span>
<span class="s">&quot;the primitive&quot;</span>, file=sys.stderr)
<span class="k">return</span> <span class="n">11</span>
<span class="k">if</span> len(disk_pos) &lt; UV_SAMPLE:
print(<span class="s">f&quot;</span><span class="s">ERROR: prim0 carries only </span>{len(disk_pos)}<span class="s"> loops, fewer than </span><span class="s">&quot;</span>
<span class="s">f&quot;</span><span class="s">the </span>{UV_SAMPLE}<span class="s"> the V-flip check samples</span><span class="s">&quot;</span>, file=sys.stderr)
<span class="k">return</span> <span class="n">11</span>
flip_err = <span class="n">0.0</span>
<span class="k">for</span> pd, (u, v) <span class="k">in</span> zip(disk_pos[:<span class="n">32</span>], disk_uv[:<span class="n">32</span>]):
sampled = <span class="n">0</span>
<span class="k">for</span> pd, (u, v) <span class="k">in</span> zip(disk_pos[:UV_SAMPLE], disk_uv[:UV_SAMPLE]):
pb = (pd[<span class="n">0</span>], -pd[<span class="n">2</span>], pd[<span class="n">1</span>]) <span class="c"># glTF (x, z, -y) -&gt; Blender (x, y, z)</span>
best = min((max(abs(u - e[<span class="n">0</span>]), abs((<span class="n">1.0</span> - v) - e[<span class="n">1</span>]))
<span class="k">for</span> e <span class="k">in</span> candidates(uv_set, pb)), default=<span class="n">1e30</span>)
flip_err = max(flip_err, best)
sampled += <span class="n">1</span>
<span class="k">if</span> sampled != UV_SAMPLE:
print(<span class="s">f&quot;</span><span class="s">ERROR: V-flip check compared </span>{sampled}<span class="s"> loops, expected </span><span class="s">&quot;</span>
<span class="s">f&quot;</span>{UV_SAMPLE}<span class="s">&quot;</span>, file=sys.stderr)
<span class="k">return</span> <span class="n">11</span>
<span class="k">if</span> flip_err &gt; UV_TOL:
print(<span class="s">f&quot;</span><span class="s">ERROR: on-disk UVs deviate </span>{flip_err:<span class="s">.3e</span>}<span class="s"> from the V-flipped </span><span class="s">&quot;</span>
<span class="s">&quot;authored layout (glTF texture origin is top-left)&quot;</span>, file=sys.stderr)
Expand Down
6 changes: 6 additions & 0 deletions docs/gallery/gltf-skin-roundtrip/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,12 @@ <h2>Source</h2>
a = p[<span class="s">&quot;attributes&quot;</span>]
w = acc_f(a[<span class="s">&quot;WEIGHTS_0&quot;</span>], <span class="n">4</span>)
j = acc_u(a[<span class="s">&quot;JOINTS_0&quot;</span>], <span class="n">4</span>, g[<span class="s">&quot;accessors&quot;</span>][a[<span class="s">&quot;JOINTS_0&quot;</span>]][<span class="s">&quot;componentType&quot;</span>])
<span class="c"># JOINTS_0 and WEIGHTS_0 must cover the same vertices; zip() would</span>
<span class="c"># otherwise silently score only the shorter accessor</span>
<span class="k">if</span> len(w) != len(j):
print(<span class="s">f&quot;</span><span class="s">ERROR: WEIGHTS_0 count </span>{len(w)}<span class="s"> != JOINTS_0 count </span><span class="s">&quot;</span>
<span class="s">f&quot;</span>{len(j)}<span class="s"> on a primitive</span><span class="s">&quot;</span>, file=sys.stderr)
<span class="k">return</span> <span class="n">7</span>
disk_verts += len(w)
<span class="k">for</span> ws, js <span class="k">in</span> zip(w, j):
total = sum(x <span class="k">for</span> x, ji <span class="k">in</span> zip(ws, js) <span class="k">if</span> ji &lt; len(BONES))
Expand Down
22 changes: 22 additions & 0 deletions docs/gallery/lightmap-uv-channel/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,28 @@ <h2>Source</h2>
print(<span class="s">f&quot;</span><span class="s">ERROR (</span>{code}<span class="s">): </span>{msg}<span class="s">&quot;</span>, file=sys.stderr)
fails.append(code)

<span class="c"># --- 0. positive control for the overlap detector -----------------------</span>
<span class="c"># &quot;0 overlapping pairs&quot; is only evidence if the detector can find one. A</span>
<span class="c"># blinded detector reports zero and the whole check passes: widening</span>
<span class="c"># SAT_EPS to 1e9 makes every axis test read as separated, and this example</span>
<span class="c"># still exited 0 before this control existed. Prove sensitivity and</span>
<span class="c"># strictness on hand-built triangles before trusting the scan.</span>
_overlapping = (((<span class="n">0.0</span>, <span class="n">0.0</span>), (<span class="n">1.0</span>, <span class="n">0.0</span>), (<span class="n">0.0</span>, <span class="n">1.0</span>)),
((<span class="n">0.2</span>, <span class="n">0.2</span>), (<span class="n">1.2</span>, <span class="n">0.2</span>), (<span class="n">0.2</span>, <span class="n">1.2</span>)))
_disjoint = (((<span class="n">0.0</span>, <span class="n">0.0</span>), (<span class="n">1.0</span>, <span class="n">0.0</span>), (<span class="n">0.0</span>, <span class="n">1.0</span>)),
((<span class="n">5.0</span>, <span class="n">5.0</span>), (<span class="n">6.0</span>, <span class="n">5.0</span>), (<span class="n">5.0</span>, <span class="n">6.0</span>)))
_touching = (((<span class="n">0.0</span>, <span class="n">0.0</span>), (<span class="n">1.0</span>, <span class="n">0.0</span>), (<span class="n">0.0</span>, <span class="n">1.0</span>)),
((<span class="n">1.0</span>, <span class="n">0.0</span>), (<span class="n">2.0</span>, <span class="n">0.0</span>), (<span class="n">1.0</span>, <span class="n">1.0</span>)))
ctl = (_tri_overlap(*_overlapping), _tri_overlap(*_disjoint),
_tri_overlap(*_touching))
print(<span class="s">f&quot;</span><span class="s">sat_control overlapping=</span>{ctl[<span class="n">0</span>]}<span class="s"> disjoint=</span>{ctl[<span class="n">1</span>]}<span class="s"> touching=</span>{ctl[<span class="n">2</span>]}<span class="s"> </span><span class="s">&quot;</span>
<span class="s">f&quot;</span><span class="s">(want True/False/False)</span><span class="s">&quot;</span>)
<span class="k">if</span> ctl != (<span class="k">True</span>, <span class="k">False</span>, <span class="k">False</span>):
fail(<span class="n">7</span>, <span class="s">f&quot;</span><span class="s">SAT self-test </span>{ctl}<span class="s"> != (True, False, False) — the overlap </span><span class="s">&quot;</span>
<span class="s">f&quot;</span><span class="s">detector cannot detect, so a zero-overlap result proves </span><span class="s">&quot;</span>
<span class="s">f&quot;</span><span class="s">nothing</span><span class="s">&quot;</span>)
<span class="k">return</span> fails[<span class="n">0</span>]

nominal_margin = MARGIN_DIV * <span class="n">0.01</span>
total_islands = <span class="n">0</span>
<span class="k">for</span> suffix, me <span class="k">in</span> meshes.items():
Expand Down
8 changes: 8 additions & 0 deletions docs/gallery/modular-kit-snap/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,9 @@ <h2>Source</h2>
NUDGE = <span class="n">2e-3</span> <span class="c"># unsnapped variant: two verts jogged in y (2 mm)</span>
EXPECT_BOUNDARY_VERTS = <span class="n">16</span> <span class="c"># 8 per open end (hollow-rectangle profile)</span>
EXPECT_BOUNDARY_EDGES = <span class="n">16</span> <span class="c"># 8 per open end</span>
EXPECT_PARTS = <span class="n">21</span> <span class="c"># shell + 20 detail parts; asserted before the</span>
<span class="c"># per-part loops below, which assert nothing on</span>
<span class="c"># an empty dict</span>

<span class="c"># Hollow-rectangle profile (y, z): outer shell corners then inner bore corners,</span>
<span class="c"># ordered as one continuous ring so the extrusion&#x27;s side faces come out quads.</span>
Expand Down Expand Up @@ -539,6 +542,11 @@ <h2>Source</h2>
print(<span class="s">f&quot;</span><span class="s">ERROR (</span>{code}<span class="s">): </span>{msg}<span class="s">&quot;</span>, file=sys.stderr)
fails.append(code)

<span class="k">if</span> len(meshes) != EXPECT_PARTS:
fail(<span class="n">11</span>, <span class="s">f&quot;</span><span class="s">kit built </span>{len(meshes)}<span class="s"> parts, expected </span>{EXPECT_PARTS}<span class="s"> - the </span><span class="s">&quot;</span>
<span class="s">f&quot;</span><span class="s">per-part loops below would iterate over the wrong set</span><span class="s">&quot;</span>)
<span class="k">return</span> fails[<span class="n">0</span>]

<span class="c"># --- 1. snap: 16 boundary verts, all on x in {0, TILE} -----------------</span>
on, off = boundary_verts(shell)
print(<span class="s">f&quot;</span><span class="s">snap boundary_verts=</span>{len(on)}<span class="s"> off_plane=</span>{len(off)}<span class="s"> </span><span class="s">&quot;</span>
Expand Down
Loading
Loading