Skip to content

Commit d6fa235

Browse files
authored
feat: measurable framing gate + restage hygiene/origin heroes into the band (#107)
* feat: add shared framing-measurement helper for gallery renders Layer 1 framing was the only mandatory render rule with no number attached, so agents eyeballed it and overshot in both directions. examples/gallery_framing.py measures hero fill (0.70-0.90 of frame in at least one axis) and all-elements edge margins (>= 0.02) on a staged scene, defaulting to a silhouette alpha matte (exact for round or angled subjects) with a bbox-projection strategy documented as the cheap alternative for boxy subjects. check_framing prints the measured values in the house log style and exits 10 on violation, render path only, so check-only smoke runtimes are untouched. It also establishes the repo's cross-example sharing mechanism: a __file__-relative sys.path shim that keeps every example standalone-runnable from the repository root. Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com> * fix: restage hygiene and origin heroes into the framing band Both renders sat outside the Layer 1 band in opposite directions, the failure mode the new helper exists to catch. mesh-hygiene-audit was at 0.391 fill (two small valves in a large empty stage); the camera moves from 38mm at (0,-7.8,1.9) to 50mm at (0,-5.0,1.5), landing fill at 0.803x0.733 with all margins >= 0.05. The backlit through-hole and loose-vert bead staging is untouched and no light shape re-enters the frame. prop-origin-transform was at 0.906 fill with the shipped asset cropped at both side edges and the tops; the camera pulls back from (1.15,-5.35,1.45) to (1.15,-5.9,1.55) only until margins clear, landing fill at 0.816x0.678 with margins >= 0.072. Both render paths now call gallery_framing.check_framing before the beauty render (exit 10, no artifact on violation); check code and closed forms are unchanged, and check-only output is byte-identical to main on 5.1.2 and 4.5.11. Contact sheets recomposited against the pinned calibration set. Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com> * docs: codify the framing band in VISUAL-STYLE and CLAUDE gates Layer 1 framing now carries its numbers where every run reads them: the 0.70-0.90 fill band in at least one axis, the 2 percent minimum edge margin, the hero-fill versus all-elements-margin distinction, and the instruction to measure with examples/gallery_framing.py rather than eyeball. CLAUDE.md's quality gates gain the matching framing-gate bullet so the standard example prompt inherits the rule by reference, plus one line naming the cross-example import shim. A read-only survey of every shipped hero with the helper found 13 examples outside the band (worklist for a future pass, not fixed here). Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com> * docs: note the shared framing helper in AGENTS.md structure Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com> --------- Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com>
1 parent 14ed3ed commit d6fa235

17 files changed

Lines changed: 497 additions & 52 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Blender-Developer-Tools/
3636
templates/<template-name>/ # 2 starter templates
3737
snippets/<snippet-name>.py # 17 standalone Python snippets
3838
examples/<name>/ # 23 runnable smoke-gated examples (+ gallery.json)
39+
examples/gallery_framing.py # shared Layer 1 framing measurement (render path only)
3940
scripts/build_gallery.py # generates docs/gallery/ (stdlib only)
4041
scripts/site/ # vendored landing-page build (build_site.py + template)
4142
docs/gallery/ # committed generated gallery pages + hero assets

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ wire all of: gallery.json entry, `.cursor-plugin/plugin.json` examples array (CI
9494
a `blender-smoke.yml` step, a README gallery row, hero webp (1280×720) in
9595
`docs/gallery/assets/` + preview webp (1200×675), then run `python scripts/build_gallery.py`.
9696
Renders must conform to the gallery look spec at `docs/VISUAL-STYLE.md`.
97+
Render paths gate framing through the shared helper `examples/gallery_framing.py`
98+
imported via a `__file__`-relative `sys.path` shim, the repo's only
99+
cross-example import mechanism (see its docstring).
97100

98101
## Blender Runtime Discovery
99102

@@ -118,6 +121,7 @@ Stage with **explicit paths only** — never `git add -A` or `git add .`. Cursor
118121
## Quality Gates for Example Runs
119122

120123
- `docs/VISUAL-STYLE.md` is the **binding** render standard; deviations are defects.
124+
- **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.
121125
- **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.
122126
- **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.
123127
- **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).

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,7 @@ with the Smooth-by-Angle modifier on 5.1.
668668
</tr>
669669
<tr>
670670
<td width="46%" valign="middle">
671-
<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>
671+
<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>
672672
</td>
673673
<td valign="middle">
674674

@@ -686,7 +686,7 @@ still: DIRTY through-hole + loose-vert bead vs CLEAN manifold (same brass).
686686
</tr>
687687
<tr>
688688
<td width="46%" valign="middle">
689-
<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>
689+
<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>
690690
</td>
691691
<td valign="middle">
692692

docs/VISUAL-STYLE.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,18 @@ the default stage.
4949

5050
## Framing and camera
5151

52-
- The subject fills roughly 70–90 % of the frame in at least one axis.
53-
Nothing that matters may touch or cross the frame edge.
52+
- The subject fills 70–90 % of the frame in at least one axis, measured —
53+
never eyeballed. Nothing that matters may touch or cross the frame edge.
54+
- Measure with the shared helper `examples/gallery_framing.py`, called on the
55+
render path only (import shim and full contract in its docstring):
56+
`gallery_framing.check_framing(sc, cam, hero=..., elements=..., stage=...)`
57+
prints the numbers and exits 10 on violation. **Fill** is measured on the
58+
hero subject only and must land in the 0.70–0.90 band in at least one axis,
59+
neither axis above 0.90. **Margin** is measured on the union of every
60+
element that matters — hero, placards, labels, comparison props, overlay
61+
markers — and must clear all four edges by ≥ 2 % of the frame dimension.
62+
Default strategy is the silhouette alpha matte; projection is the cheap
63+
bbox alternative for boxy subjects (tradeoff in the helper's docstring).
5464
- Camera: a chosen angle, not the default — typically a 45–55 mm lens,
5565
slightly above subject height, aimed with a `TRACK_TO` constraint at an
5666
empty on the subject. Flat subjects present toward the camera (lean or
382 Bytes
Loading
-7.55 KB
Loading
20.3 KB
Loading
17.4 KB
Loading

docs/gallery/mesh-hygiene-audit/index.html

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ <h2>Run</h2>
196196
<pre><code>blender --background --python mesh_hygiene_audit.py --
197197
blender --background --python mesh_hygiene_audit.py -- --output hygiene.png
198198
blender --background --python mesh_hygiene_audit.py -- --output hygiene.png --engine cycles</code></pre>
199-
<p>Exits non-zero on failure. The <code>blender-smoke</code> workflow runs the check on Blender 4.5 LTS and 5.1.</p>
199+
<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>
200200
</section>
201201
<section class="detail-section src">
202202
<h2>Source</h2>
@@ -226,6 +226,11 @@ <h2>Source</h2>
226226
<span class="k">import</span> bpy, bmesh, sys, os, math, argparse
227227
<span class="k">from</span> mathutils <span class="k">import</span> Matrix, Vector
228228

229+
<span class="c"># Shared Layer 1 framing measurement (render path only) — see gallery_framing.py</span>
230+
sys.path.insert(<span class="n">0</span>, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
231+
sys.dont_write_bytecode = <span class="k">True</span> <span class="c"># keep examples/__pycache__ out of the repo tree</span>
232+
<span class="k">import</span> gallery_framing
233+
229234
AREA_EPS = <span class="n">1e-10</span>
230235
VOL_EPS = <span class="n">1e-8</span>
231236

@@ -531,6 +536,7 @@ <h2>Source</h2>
531536
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>))
532537
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>))
533538
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>))
539+
<span class="k">return</span> floor, wall
534540

535541

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

697-
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>)
698-
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>)
703+
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>)
704+
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>)
699705

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

720726
cam_data = bpy.data.cameras.new(<span class="s">&quot;Cam&quot;</span>)
721-
cam_data.lens = <span class="n">38.0</span>
727+
cam_data.lens = <span class="n">50.0</span>
722728
cam = bpy.data.objects.new(<span class="s">&quot;Cam&quot;</span>, cam_data)
723-
cam.location = (<span class="n">0.0</span>, -<span class="n">7.8</span>, <span class="n">1.9</span>)
729+
cam.location = (<span class="n">0.0</span>, -<span class="n">5.0</span>, <span class="n">1.5</span>)
724730
sc.collection.objects.link(cam)
725731
aim = bpy.data.objects.new(<span class="s">&quot;Aim&quot;</span>, <span class="k">None</span>)
726-
aim.location = (<span class="n">0.0</span>, <span class="n">0.0</span>, <span class="n">0.48</span>)
732+
aim.location = (<span class="n">0.0</span>, <span class="n">0.0</span>, <span class="n">0.55</span>)
727733
sc.collection.objects.link(aim)
728734
tr = cam.constraints.new(<span class="s">&quot;TRACK_TO&quot;</span>)
729735
tr.target = aim
@@ -746,8 +752,22 @@ <h2>Source</h2>
746752
sc.render.image_settings.file_format = <span class="s">&quot;PNG&quot;</span>
747753
sc.render.filepath = path
748754
sc.view_settings.view_transform = <span class="s">&quot;Standard&quot;</span>
755+
<span class="c"># Layer 1 framing gate (silhouette matte) — exit 10 on violation, before</span>
756+
<span class="c"># the beauty render so a defective composition ships no artifact.</span>
757+
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>))]
758+
fcode = gallery_framing.check_framing(
759+
sc, cam,
760+
hero=[left, right],
761+
elements=[left, right, p_dirty, p_clean] + overlays,
762+
stage=[floor, wall],
763+
)
764+
<span class="k">if</span> fcode:
765+
<span class="k">return</span> fcode
749766
bpy.ops.render.render(write_still=<span class="k">True</span>)
750-
<span class="k">return</span> os.path.exists(path) <span class="k">and</span> os.path.getsize(path) &gt; <span class="n">0</span>
767+
<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>):
768+
print(<span class="s">&quot;ERROR: render produced no file&quot;</span>, file=sys.stderr)
769+
<span class="k">return</span> <span class="n">9</span>
770+
<span class="k">return</span> <span class="n">0</span>
751771

752772

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

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

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

0 commit comments

Comments
 (0)