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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Blender-Developer-Tools/
templates/<template-name>/ # 2 starter templates
snippets/<snippet-name>.py # 17 standalone Python snippets
examples/<name>/ # 23 runnable smoke-gated examples (+ gallery.json)
examples/gallery_framing.py # shared Layer 1 framing measurement (render path only)
scripts/build_gallery.py # generates docs/gallery/ (stdlib only)
scripts/site/ # vendored landing-page build (build_site.py + template)
docs/gallery/ # committed generated gallery pages + hero assets
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ wire all of: gallery.json entry, `.cursor-plugin/plugin.json` examples array (CI
a `blender-smoke.yml` step, a README gallery row, hero webp (1280×720) in
`docs/gallery/assets/` + preview webp (1200×675), then run `python scripts/build_gallery.py`.
Renders must conform to the gallery look spec at `docs/VISUAL-STYLE.md`.
Render paths gate framing through the shared helper `examples/gallery_framing.py` —
imported via a `__file__`-relative `sys.path` shim, the repo's only
cross-example import mechanism (see its docstring).

## Blender Runtime Discovery

Expand All @@ -118,6 +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.
- **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).
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,7 @@ with the Smooth-by-Angle modifier on 5.1.
</tr>
<tr>
<td width="46%" valign="middle">
<a href="examples/mesh-hygiene-audit/"><img src="examples/mesh-hygiene-audit/preview.webp" alt="Mesh hygiene audit: dual-panel dirty vs clean street electrical pedestal — left orange emissive wireframe with loose-vert bead and DIRTY placard, right teal CLEAN manifold — proving the engine-ingest topology checklist" /></a>
<a href="examples/mesh-hygiene-audit/"><img src="examples/mesh-hygiene-audit/preview.webp" alt="Mesh hygiene audit: dual-panel dirty vs clean brass street valves filling the frame — left with backlit through-hole, emissive boundary tubes and loose-vert bead over a DIRTY placard, right intact CLEAN manifold — proving the engine-ingest topology checklist" /></a>
</td>
<td valign="middle">

Expand All @@ -686,7 +686,7 @@ still: DIRTY through-hole + loose-vert bead vs CLEAN manifold (same brass).
</tr>
<tr>
<td width="46%" valign="middle">
<a href="examples/prop-origin-transform/"><img src="examples/prop-origin-transform/preview.webp" alt="Prop origin transform: dual-panel TRAP vs MPI KEEP street pedestals — left bare-parent accessory teleports with expected-seat ghost, right accessory stays put, emissive origin markers at base — proving scale apply, base origin, and matrix_parent_inverse" /></a>
<a href="examples/prop-origin-transform/"><img src="examples/prop-origin-transform/preview.webp" alt="Prop origin transform: dual-panel TRAP vs MPI KEEP street pedestals with margins all around — left bare-parent conduit accessory teleports off its dark mount well, right accessory stays seated — proving scale apply, base origin, and matrix_parent_inverse" /></a>
</td>
<td valign="middle">

Expand Down
14 changes: 12 additions & 2 deletions docs/VISUAL-STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,18 @@ the default stage.

## Framing and camera

- The subject fills roughly 70–90 % of the frame in at least one axis.
Nothing that matters may touch or cross the frame edge.
- The subject fills 70–90 % of the frame in at least one axis, measured —
never eyeballed. Nothing that matters may touch or cross the frame edge.
- Measure with the shared helper `examples/gallery_framing.py`, called on the
render path only (import shim and full contract in its docstring):
`gallery_framing.check_framing(sc, cam, hero=..., elements=..., stage=...)`
prints the numbers and exits 10 on violation. **Fill** is measured on the
hero subject only and must land in the 0.70–0.90 band in at least one axis,
neither axis above 0.90. **Margin** is measured on the union of every
element that matters — hero, placards, labels, comparison props, overlay
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).
- 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
Binary file modified docs/gallery/assets/mesh-hygiene-audit-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/prop-origin-transform-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 31 additions & 11 deletions docs/gallery/mesh-hygiene-audit/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ <h2>Run</h2>
<pre><code>blender --background --python mesh_hygiene_audit.py --
blender --background --python mesh_hygiene_audit.py -- --output hygiene.png
blender --background --python mesh_hygiene_audit.py -- --output hygiene.png --engine cycles</code></pre>
<p>Exits non-zero on failure. The <code>blender-smoke</code> workflow runs the check on Blender 4.5 LTS and 5.1.</p>
<p>Exits non-zero on failure. The <code>blender-smoke</code> workflow runs the check on Blender 4.5 LTS and 5.1. The <code>--output</code> render path additionally measures framing against the Layer 1 band via <code>examples/gallery_framing.py</code> (exit 10 on violation) before writing the still.</p>
</section>
<section class="detail-section src">
<h2>Source</h2>
Expand Down Expand Up @@ -226,6 +226,11 @@ <h2>Source</h2>
<span class="k">import</span> bpy, bmesh, sys, os, math, argparse
<span class="k">from</span> mathutils <span class="k">import</span> Matrix, Vector

<span class="c"># Shared Layer 1 framing measurement (render path only) — see gallery_framing.py</span>
sys.path.insert(<span class="n">0</span>, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
sys.dont_write_bytecode = <span class="k">True</span> <span class="c"># keep examples/__pycache__ out of the repo tree</span>
<span class="k">import</span> gallery_framing

AREA_EPS = <span class="n">1e-10</span>
VOL_EPS = <span class="n">1e-8</span>

Expand Down Expand Up @@ -531,6 +536,7 @@ <h2>Source</h2>
light(<span class="s">&quot;Rim&quot;</span>, (<span class="n">1.5</span>, <span class="n">4.5</span>, <span class="n">3.5</span>), <span class="n">280.0</span>, <span class="n">3.0</span>, (<span class="n">0.6</span>, <span class="n">0.78</span>, <span class="n">1.0</span>), (-<span class="n">55</span>, <span class="n">0</span>, <span class="n">170</span>))
light(<span class="s">&quot;Wedge&quot;</span>, (<span class="n">2.5</span>, <span class="n">5.5</span>, <span class="n">4.0</span>), <span class="n">420.0</span>, <span class="n">6.0</span>, (<span class="n">1.0</span>, <span class="n">0.72</span>, <span class="n">0.42</span>), (-<span class="n">68</span>, <span class="n">0</span>, <span class="n">190</span>))
light(<span class="s">&quot;Glint&quot;</span>, (-<span class="n">2.0</span>, -<span class="n">4.0</span>, <span class="n">3.2</span>), <span class="n">220.0</span>, <span class="n">2.0</span>, (<span class="n">1.0</span>, <span class="n">0.9</span>, <span class="n">0.75</span>), (<span class="n">55</span>, <span class="n">0</span>, -<span class="n">20</span>))
<span class="k">return</span> floor, wall


<span class="k">def</span> _duplicate_mesh_obj(sc, src, name, loc):
Expand Down Expand Up @@ -694,10 +700,10 @@ <h2>Source</h2>
<span class="k">for</span> p <span class="k">in</span> right.data.polygons:
p.use_smooth = abs(p.normal.z) &lt; <span class="n">0.85</span>

placard(sc, <span class="s">&quot;DIRTY&quot;</span>, (-<span class="n">0.88</span>, -<span class="n">1.15</span>, <span class="n">0.02</span>), size=<span class="n">0.11</span>)
placard(sc, <span class="s">&quot;CLEAN&quot;</span>, (<span class="n">0.88</span>, -<span class="n">1.15</span>, <span class="n">0.02</span>), size=<span class="n">0.11</span>)
p_dirty = placard(sc, <span class="s">&quot;DIRTY&quot;</span>, (-<span class="n">0.88</span>, -<span class="n">1.15</span>, <span class="n">0.02</span>), size=<span class="n">0.11</span>)
p_clean = placard(sc, <span class="s">&quot;CLEAN&quot;</span>, (<span class="n">0.88</span>, -<span class="n">1.15</span>, <span class="n">0.02</span>), size=<span class="n">0.11</span>)

build_studio(sc)
floor, wall = build_studio(sc)
<span class="c"># Out-of-frame POINT behind DIRTY — aperture glow without a rectangular AREA</span>
<span class="c"># footprint on the floor (Layer 1: no visible light shapes).</span>
hole_ld = bpy.data.lights.new(<span class="s">&quot;HoleBack&quot;</span>, <span class="s">&quot;POINT&quot;</span>)
Expand All @@ -718,12 +724,12 @@ <h2>Source</h2>
ob.data.energy = <span class="n">140.0</span>

cam_data = bpy.data.cameras.new(<span class="s">&quot;Cam&quot;</span>)
cam_data.lens = <span class="n">38.0</span>
cam_data.lens = <span class="n">50.0</span>
cam = bpy.data.objects.new(<span class="s">&quot;Cam&quot;</span>, cam_data)
cam.location = (<span class="n">0.0</span>, -<span class="n">7.8</span>, <span class="n">1.9</span>)
cam.location = (<span class="n">0.0</span>, -<span class="n">5.0</span>, <span class="n">1.5</span>)
sc.collection.objects.link(cam)
aim = bpy.data.objects.new(<span class="s">&quot;Aim&quot;</span>, <span class="k">None</span>)
aim.location = (<span class="n">0.0</span>, <span class="n">0.0</span>, <span class="n">0.48</span>)
aim.location = (<span class="n">0.0</span>, <span class="n">0.0</span>, <span class="n">0.55</span>)
sc.collection.objects.link(aim)
tr = cam.constraints.new(<span class="s">&quot;TRACK_TO&quot;</span>)
tr.target = aim
Expand All @@ -746,8 +752,22 @@ <h2>Source</h2>
sc.render.image_settings.file_format = <span class="s">&quot;PNG&quot;</span>
sc.render.filepath = path
sc.view_settings.view_transform = <span class="s">&quot;Standard&quot;</span>
<span class="c"># Layer 1 framing gate (silhouette matte) — exit 10 on violation, before</span>
<span class="c"># the beauty render so a defective composition ships no artifact.</span>
overlays = [o <span class="k">for</span> o <span class="k">in</span> sc.objects <span class="k">if</span> o.name.startswith((<span class="s">&quot;LooseBead&quot;</span>, <span class="s">&quot;Bnd&quot;</span>))]
fcode = gallery_framing.check_framing(
sc, cam,
hero=[left, right],
elements=[left, right, p_dirty, p_clean] + overlays,
stage=[floor, wall],
)
<span class="k">if</span> fcode:
<span class="k">return</span> fcode
bpy.ops.render.render(write_still=<span class="k">True</span>)
<span class="k">return</span> os.path.exists(path) <span class="k">and</span> os.path.getsize(path) &gt; <span class="n">0</span>
<span class="k">if</span> <span class="k">not</span> (os.path.exists(path) <span class="k">and</span> os.path.getsize(path) &gt; <span class="n">0</span>):
print(<span class="s">&quot;ERROR: render produced no file&quot;</span>, file=sys.stderr)
<span class="k">return</span> <span class="n">9</span>
<span class="k">return</span> <span class="n">0</span>


<span class="k">def</span> build_scene():
Expand All @@ -772,9 +792,9 @@ <h2>Source</h2>
<span class="k">return</span> code

<span class="k">if</span> args.output:
<span class="k">if</span> <span class="k">not</span> render_still(ob, os.path.abspath(args.output), args.engine):
print(<span class="s">&quot;ERROR: render produced no file&quot;</span>, file=sys.stderr)
<span class="k">return</span> <span class="n">9</span>
rcode = render_still(ob, os.path.abspath(args.output), args.engine)
<span class="k">if</span> rcode:
<span class="k">return</span> rcode
print(<span class="s">f&quot;</span><span class="s">rendered still </span>{args.output}<span class="s">&quot;</span>)

print(<span class="s">&quot;mesh-hygiene-audit OK&quot;</span>)
Expand Down
Loading
Loading