diff --git a/AGENTS.md b/AGENTS.md index 6a952d1..046c12a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ Blender-Developer-Tools/ templates// # 2 starter templates snippets/.py # 17 standalone Python snippets examples// # 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 diff --git a/CLAUDE.md b/CLAUDE.md index a24b55a..7b07970 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 `` 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). diff --git a/README.md b/README.md index 55d8a34..2a49cd9 100644 --- a/README.md +++ b/README.md @@ -668,7 +668,7 @@ with the Smooth-by-Angle modifier on 5.1. -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 +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 @@ -686,7 +686,7 @@ still: DIRTY through-hole + loose-vert bead vs CLEAN manifold (same brass). -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 +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 diff --git a/docs/VISUAL-STYLE.md b/docs/VISUAL-STYLE.md index 46a522f..a46fa9f 100644 --- a/docs/VISUAL-STYLE.md +++ b/docs/VISUAL-STYLE.md @@ -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 diff --git a/docs/gallery/assets/mesh-hygiene-audit-hero.webp b/docs/gallery/assets/mesh-hygiene-audit-hero.webp index 9a8db17..7026a24 100644 Binary files a/docs/gallery/assets/mesh-hygiene-audit-hero.webp and b/docs/gallery/assets/mesh-hygiene-audit-hero.webp differ diff --git a/docs/gallery/assets/prop-origin-transform-hero.webp b/docs/gallery/assets/prop-origin-transform-hero.webp index 83724fb..9be7146 100644 Binary files a/docs/gallery/assets/prop-origin-transform-hero.webp and b/docs/gallery/assets/prop-origin-transform-hero.webp differ diff --git a/docs/gallery/contact-sheets/mesh-hygiene-audit-contact-sheet.webp b/docs/gallery/contact-sheets/mesh-hygiene-audit-contact-sheet.webp index b96fff3..79f0f61 100644 Binary files a/docs/gallery/contact-sheets/mesh-hygiene-audit-contact-sheet.webp and b/docs/gallery/contact-sheets/mesh-hygiene-audit-contact-sheet.webp differ diff --git a/docs/gallery/contact-sheets/prop-origin-transform-contact-sheet.webp b/docs/gallery/contact-sheets/prop-origin-transform-contact-sheet.webp index 237b0d6..7775796 100644 Binary files a/docs/gallery/contact-sheets/prop-origin-transform-contact-sheet.webp and b/docs/gallery/contact-sheets/prop-origin-transform-contact-sheet.webp differ diff --git a/docs/gallery/mesh-hygiene-audit/index.html b/docs/gallery/mesh-hygiene-audit/index.html index f1755d0..2c0c44d 100644 --- a/docs/gallery/mesh-hygiene-audit/index.html +++ b/docs/gallery/mesh-hygiene-audit/index.html @@ -196,7 +196,7 @@

Run

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
-

Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

+

Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1. The --output render path additionally measures framing against the Layer 1 band via examples/gallery_framing.py (exit 10 on violation) before writing the still.

Source

@@ -226,6 +226,11 @@

Source

import bpy, bmesh, sys, os, math, argparse from mathutils import Matrix, Vector +# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) +sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree +import gallery_framing + AREA_EPS = 1e-10 VOL_EPS = 1e-8 @@ -531,6 +536,7 @@

Source

light("Rim", (1.5, 4.5, 3.5), 280.0, 3.0, (0.6, 0.78, 1.0), (-55, 0, 170)) light("Wedge", (2.5, 5.5, 4.0), 420.0, 6.0, (1.0, 0.72, 0.42), (-68, 0, 190)) light("Glint", (-2.0, -4.0, 3.2), 220.0, 2.0, (1.0, 0.9, 0.75), (55, 0, -20)) + return floor, wall def _duplicate_mesh_obj(sc, src, name, loc): @@ -694,10 +700,10 @@

Source

for p in right.data.polygons: p.use_smooth = abs(p.normal.z) < 0.85 - placard(sc, "DIRTY", (-0.88, -1.15, 0.02), size=0.11) - placard(sc, "CLEAN", (0.88, -1.15, 0.02), size=0.11) + p_dirty = placard(sc, "DIRTY", (-0.88, -1.15, 0.02), size=0.11) + p_clean = placard(sc, "CLEAN", (0.88, -1.15, 0.02), size=0.11) - build_studio(sc) + floor, wall = build_studio(sc) # Out-of-frame POINT behind DIRTY — aperture glow without a rectangular AREA # footprint on the floor (Layer 1: no visible light shapes). hole_ld = bpy.data.lights.new("HoleBack", "POINT") @@ -718,12 +724,12 @@

Source

ob.data.energy = 140.0 cam_data = bpy.data.cameras.new("Cam") - cam_data.lens = 38.0 + cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (0.0, -7.8, 1.9) + cam.location = (0.0, -5.0, 1.5) sc.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.48) + aim.location = (0.0, 0.0, 0.55) sc.collection.objects.link(aim) tr = cam.constraints.new("TRACK_TO") tr.target = aim @@ -746,8 +752,22 @@

Source

sc.render.image_settings.file_format = "PNG" sc.render.filepath = path sc.view_settings.view_transform = "Standard" + # Layer 1 framing gate (silhouette matte) — exit 10 on violation, before + # the beauty render so a defective composition ships no artifact. + overlays = [o for o in sc.objects if o.name.startswith(("LooseBead", "Bnd"))] + fcode = gallery_framing.check_framing( + sc, cam, + hero=[left, right], + elements=[left, right, p_dirty, p_clean] + overlays, + stage=[floor, wall], + ) + if fcode: + return fcode bpy.ops.render.render(write_still=True) - return os.path.exists(path) and os.path.getsize(path) > 0 + if not (os.path.exists(path) and os.path.getsize(path) > 0): + print("ERROR: render produced no file", file=sys.stderr) + return 9 + return 0 def build_scene(): @@ -772,9 +792,9 @@

Source

return code if args.output: - if not render_still(ob, os.path.abspath(args.output), args.engine): - print("ERROR: render produced no file", file=sys.stderr) - return 9 + rcode = render_still(ob, os.path.abspath(args.output), args.engine) + if rcode: + return rcode print(f"rendered still {args.output}") print("mesh-hygiene-audit OK") diff --git a/docs/gallery/prop-origin-transform/index.html b/docs/gallery/prop-origin-transform/index.html index c2e0e75..0b29239 100644 --- a/docs/gallery/prop-origin-transform/index.html +++ b/docs/gallery/prop-origin-transform/index.html @@ -195,7 +195,7 @@

Run

blender --background --python prop_origin_transform.py --
 blender --background --python prop_origin_transform.py -- --output origin.png
 blender --background --python prop_origin_transform.py -- --output origin.png --engine cycles
-

Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

+

Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1. The --output render path additionally measures framing against the Layer 1 band via examples/gallery_framing.py (exit 10 on violation) before writing the still.

Source

@@ -221,6 +221,11 @@

Source

import bpy, bmesh, sys, os, math, argparse from mathutils import Matrix, Vector +# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) +sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree +import gallery_framing + BBOX_EPS = 1e-6 WORLD_EPS = 1e-5 MPI_EPS = 1e-6 @@ -654,6 +659,7 @@

Source

light("Fill", (5.0, -3.5, 2.5), 120.0, 9.0, (0.75, 0.85, 1.0), (65, 0, 50)) light("Rim", (1.5, 4.5, 3.5), 280.0, 3.0, (0.6, 0.78, 1.0), (-55, 0, 170)) light("Wedge", (2.5, 5.5, 4.0), 400.0, 6.0, (1.0, 0.72, 0.42), (-68, 0, 190)) + return floor, wall def render_still(path, engine): @@ -717,8 +723,8 @@

Source

left_front = left.matrix_world @ Vector((0.0, -0.52, 0.78)) right_front = right.matrix_world @ Vector((0.0, -0.52, 0.78)) - make_socket("TrapSocket", left_front, sock_empty_m, parent=left) - make_socket("KeepSocket", right_front, sock_m, parent=right) + trap_sock = make_socket("TrapSocket", left_front, sock_empty_m, parent=left) + keep_sock = make_socket("KeepSocket", right_front, sock_m, parent=right) trap_acc = make_acc("TrapAcc", left_front + Vector((0.0, -0.05, 0.0)), acc_m) keep_acc = make_acc("KeepAcc", right_front + Vector((0.0, -0.05, 0.0)), acc_m) @@ -748,19 +754,19 @@

Source

origin_marker(sc, Vector(left.matrix_world.translation)) origin_marker(sc, Vector(right.matrix_world.translation)) - placard(sc, "TRAP", (0.15, -1.25, 0.02), size=0.11) - placard(sc, "MPI KEEP", (2.1, -1.25, 0.02), size=0.11) + p_trap = placard(sc, "TRAP", (0.15, -1.25, 0.02), size=0.11) + p_keep = placard(sc, "MPI KEEP", (2.1, -1.25, 0.02), size=0.11) - build_studio(sc) + floor, wall = build_studio(sc) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 48.0 cam = bpy.data.objects.new("Cam", cam_data) - # Closer for fill; placards still clear the bottom - cam.location = (1.15, -5.35, 1.45) + # Pulled back until the framing gate's margins clear — not further. + cam.location = (1.15, -5.9, 1.55) sc.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (1.1, 0.0, 0.70) + aim.location = (1.1, 0.0, 0.72) sc.collection.objects.link(aim) tr = cam.constraints.new("TRACK_TO") tr.target = aim @@ -783,8 +789,23 @@

Source

sc.render.image_settings.file_format = "PNG" sc.render.filepath = path sc.view_settings.view_transform = "Standard" + # Layer 1 framing gate (silhouette matte) — exit 10 on violation, before + # the beauty render so a defective composition ships no artifact. + hero = [left, right, trap_acc, keep_acc, trap_sock, keep_sock] + markers = [o for o in sc.objects if o.name.startswith(("OriginBead", "Axis"))] + fcode = gallery_framing.check_framing( + sc, cam, + hero=hero, + elements=hero + [p_trap, p_keep] + markers, + stage=[floor, wall], + ) + if fcode: + return fcode bpy.ops.render.render(write_still=True) - return os.path.exists(path) and os.path.getsize(path) > 0 + if not (os.path.exists(path) and os.path.getsize(path) > 0): + print("ERROR: render produced no file", file=sys.stderr) + return 9 + return 0 def main(): @@ -801,9 +822,9 @@

Source

return code if args.output: - if not render_still(os.path.abspath(args.output), args.engine): - print("ERROR: render produced no file", file=sys.stderr) - return 9 + rcode = render_still(os.path.abspath(args.output), args.engine) + if rcode: + return rcode print(f"rendered still {args.output}") print("prop-origin-transform OK") diff --git a/examples/gallery_framing.py b/examples/gallery_framing.py new file mode 100644 index 0000000..9e59831 --- /dev/null +++ b/examples/gallery_framing.py @@ -0,0 +1,344 @@ +"""Gallery framing measurement — the numeric form of VISUAL-STYLE Layer 1 framing. + +Layer 1 mandates: the hero subject fills 70–90 % of the frame in at least +one axis, and nothing that matters touches or crosses a frame edge. This +module turns that prose into measured numbers so renders are gated by +measurement instead of eyeballing. + +Sharing mechanism (the only cross-example import in this repository): +examples stay standalone-runnable — +`blender --background --python examples//