diff --git a/ROADMAP.md b/ROADMAP.md
index dfb14fa..45e8ebd 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -129,7 +129,7 @@ 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.
+- Gallery framing recomposition worklist (corrected survey after the survey scene-capture fix; these need real reframing, not a camera nudge — the seven marginal cases were swept mechanically and wired to `check_framing`): **depsgraph-export** (fill 0.984x over; left cube amputated by left+bottom edges — unclipping it changes the composition substantially), **vertex-weight-limit** (fill 0.247x/0.861y; base cropped at bottom edge, off-center with dead space left), **text-version-stamp** (max fill 0.663 under; dead lower third — needs vertical rebalance), **triangulate-tangents** (fill 0.334x/0.661y under; buckler small in a large stage, needs a 2x-class move-in). Already handled: the seven marginal cases (curve-bevel-arc, png-exr-alpha, swatch-grid, driver-wave, shader-node-group, gp-lineart-contour, vse-cut-list) were camera-nudged into the band and gated; deviations documented in #108 (damped-track-aim, wave-displace); sky-texture-sun-elevation is a measurement artifact (world subject — report-only if wired).
## Future (uncommitted)
diff --git a/docs/gallery/assets/curve-bevel-arc-hero.webp b/docs/gallery/assets/curve-bevel-arc-hero.webp
index c41e2a7..ee2ac31 100644
Binary files a/docs/gallery/assets/curve-bevel-arc-hero.webp and b/docs/gallery/assets/curve-bevel-arc-hero.webp differ
diff --git a/docs/gallery/assets/driver-wave-hero.webp b/docs/gallery/assets/driver-wave-hero.webp
index 70bf5f6..13f8e77 100644
Binary files a/docs/gallery/assets/driver-wave-hero.webp and b/docs/gallery/assets/driver-wave-hero.webp differ
diff --git a/docs/gallery/assets/gp-lineart-contour-hero.webp b/docs/gallery/assets/gp-lineart-contour-hero.webp
index 92a46d6..efde3f8 100644
Binary files a/docs/gallery/assets/gp-lineart-contour-hero.webp and b/docs/gallery/assets/gp-lineart-contour-hero.webp differ
diff --git a/docs/gallery/assets/png-exr-alpha-hero.webp b/docs/gallery/assets/png-exr-alpha-hero.webp
index 349208d..e439f8b 100644
Binary files a/docs/gallery/assets/png-exr-alpha-hero.webp and b/docs/gallery/assets/png-exr-alpha-hero.webp differ
diff --git a/docs/gallery/assets/shader-node-group-hero.webp b/docs/gallery/assets/shader-node-group-hero.webp
index c6b802c..4fb7dad 100644
Binary files a/docs/gallery/assets/shader-node-group-hero.webp and b/docs/gallery/assets/shader-node-group-hero.webp differ
diff --git a/docs/gallery/assets/swatch-grid-hero.webp b/docs/gallery/assets/swatch-grid-hero.webp
index 785e11e..7e0f7ad 100644
Binary files a/docs/gallery/assets/swatch-grid-hero.webp and b/docs/gallery/assets/swatch-grid-hero.webp differ
diff --git a/docs/gallery/assets/vse-cut-list-hero.webp b/docs/gallery/assets/vse-cut-list-hero.webp
index 5577bab..a49bbdc 100644
Binary files a/docs/gallery/assets/vse-cut-list-hero.webp and b/docs/gallery/assets/vse-cut-list-hero.webp differ
diff --git a/docs/gallery/contact-sheets/curve-bevel-arc-contact-sheet.webp b/docs/gallery/contact-sheets/curve-bevel-arc-contact-sheet.webp
new file mode 100644
index 0000000..1feada6
Binary files /dev/null and b/docs/gallery/contact-sheets/curve-bevel-arc-contact-sheet.webp differ
diff --git a/docs/gallery/contact-sheets/driver-wave-contact-sheet.webp b/docs/gallery/contact-sheets/driver-wave-contact-sheet.webp
new file mode 100644
index 0000000..f8f3aa1
Binary files /dev/null and b/docs/gallery/contact-sheets/driver-wave-contact-sheet.webp differ
diff --git a/docs/gallery/contact-sheets/gp-lineart-contour-contact-sheet.webp b/docs/gallery/contact-sheets/gp-lineart-contour-contact-sheet.webp
index 16cf01e..ec50717 100644
Binary files a/docs/gallery/contact-sheets/gp-lineart-contour-contact-sheet.webp and b/docs/gallery/contact-sheets/gp-lineart-contour-contact-sheet.webp differ
diff --git a/docs/gallery/contact-sheets/png-exr-alpha-contact-sheet.webp b/docs/gallery/contact-sheets/png-exr-alpha-contact-sheet.webp
index 05f2f8d..5621b46 100644
Binary files a/docs/gallery/contact-sheets/png-exr-alpha-contact-sheet.webp and b/docs/gallery/contact-sheets/png-exr-alpha-contact-sheet.webp differ
diff --git a/docs/gallery/contact-sheets/shader-node-group-contact-sheet.webp b/docs/gallery/contact-sheets/shader-node-group-contact-sheet.webp
new file mode 100644
index 0000000..c23adfa
Binary files /dev/null and b/docs/gallery/contact-sheets/shader-node-group-contact-sheet.webp differ
diff --git a/docs/gallery/contact-sheets/swatch-grid-contact-sheet.webp b/docs/gallery/contact-sheets/swatch-grid-contact-sheet.webp
new file mode 100644
index 0000000..cde317c
Binary files /dev/null and b/docs/gallery/contact-sheets/swatch-grid-contact-sheet.webp differ
diff --git a/docs/gallery/contact-sheets/vse-cut-list-contact-sheet.webp b/docs/gallery/contact-sheets/vse-cut-list-contact-sheet.webp
index fb9a836..55d8d11 100644
Binary files a/docs/gallery/contact-sheets/vse-cut-list-contact-sheet.webp and b/docs/gallery/contact-sheets/vse-cut-list-contact-sheet.webp differ
diff --git a/docs/gallery/curve-bevel-arc/index.html b/docs/gallery/curve-bevel-arc/index.html
index a43758c..4f9641c 100644
--- a/docs/gallery/curve-bevel-arc/index.html
+++ b/docs/gallery/curve-bevel-arc/index.html
@@ -193,6 +193,7 @@
Run
blender --background --python curve_bevel_arc.py -- --output arc.png
blender --background --python curve_bevel_arc.py -- --output arc.png --engine cycles
It exits non-zero on failure (wrong point count, bevel, caps, topology, or span). 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
@@ -219,6 +220,11 @@ Source
"""
import bpy, bmesh, sys, os, math, argparse
+# 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
+
N_POINTS = 8
RADIUS = 1.5
BEVEL = 0.15
@@ -383,7 +389,7 @@ Source
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (2.4, -3.4, 1.7)
+ cam.location = (2.55, -3.62, 1.78)
scene.collection.objects.link(cam)
scene.camera = cam
track = cam.constraints.new('TRACK_TO')
@@ -405,8 +411,21 @@ Source
scene.render.filepath = path
# AgX would wash the rose tube toward salmon (docs/VISUAL-STYLE.md)
scene.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.
+ fcode = gallery_framing.check_framing(
+ scene, cam,
+ hero=[obj],
+ elements=[obj],
+ 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 11
+ return 0
def main():
@@ -423,9 +442,9 @@ Source
return code
if args.output:
- if not render_still(obj, os.path.abspath(args.output), args.engine):
- print("ERROR: render produced no file", file=sys.stderr)
- return 11
+ rcode = render_still(obj, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
print(f"rendered still {args.output}")
print("curve-bevel-arc OK")
diff --git a/docs/gallery/driver-wave/index.html b/docs/gallery/driver-wave/index.html
index 5f16cf9..666fd38 100644
--- a/docs/gallery/driver-wave/index.html
+++ b/docs/gallery/driver-wave/index.html
@@ -194,6 +194,7 @@ Run
blender --background --python driver_wave.py -- --output driver.png
blender --background --python driver_wave.py -- --output driver.png --engine cycles
It exits non-zero on failure (driven value wrong, or the flush-back disagreed). 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
@@ -219,6 +220,11 @@ Source
"""
import bpy, bmesh, sys, os, math, argparse
+# 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
+
COUNT = 16
SPACING = 0.72
BASE = 0.28
@@ -347,7 +353,7 @@ Source
# the frame with a small margin -- the skyline must not clip
cam_data = bpy.data.cameras.new("Cam"); cam_data.lens = 42.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (0.0, -13.5, 2.0)
+ cam.location = (0.0, -15.0, 2.0)
cam.rotation_euler = (math.radians(86), 0.0, 0.0)
scene.collection.objects.link(cam)
scene.camera = cam
@@ -366,8 +372,21 @@ Source
scene.render.filepath = path
# AgX would wash the orange columns toward tan (docs/VISUAL-STYLE.md)
scene.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.
+ fcode = gallery_framing.check_framing(
+ scene, cam,
+ hero=list(objs),
+ elements=list(objs),
+ 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 6
+ return 0
def main():
@@ -384,9 +403,9 @@ Source
return code
if args.output:
- if not render_still(objs, os.path.abspath(args.output), args.engine):
- print("ERROR: render produced no file", file=sys.stderr)
- return 6
+ rcode = render_still(objs, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
print(f"rendered still {args.output}")
print("driver-wave OK")
diff --git a/docs/gallery/gp-lineart-contour/index.html b/docs/gallery/gp-lineart-contour/index.html
index 5588aa5..cb1f03e 100644
--- a/docs/gallery/gp-lineart-contour/index.html
+++ b/docs/gallery/gp-lineart-contour/index.html
@@ -195,6 +195,7 @@ Run
# Also render the gallery still:
blender --background --python gp_lineart_contour.py -- --output lineart.png
It 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
@@ -227,6 +228,11 @@ Source
blender --background --python gp_lineart_contour.py -- --output l.png
"""
import bpy, bmesh, sys, os, math, argparse
+
+# 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
from mathutils import Matrix
# Faceted crystal (octahedron-ish) — enough silhouette edges to read at thumbnail
@@ -353,7 +359,7 @@ Source
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (3.4, -4.2, 2.4)
+ cam.location = (3.95, -4.9, 2.62)
sc.collection.objects.link(cam)
sc.camera = cam
aim = bpy.data.objects.new("Aim", None)
@@ -571,8 +577,27 @@ 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. The two
+ # renderable subjects are the crystal source mesh and the GP stroke object.
+ stage = [o for o in sc.objects if o.name in {"Floor", "Wall"}]
+ hero = [
+ o for o in sc.objects
+ if o.type in {"MESH", "GREASEPENCIL"} and o not in stage
+ ]
+ fcode = gallery_framing.check_framing(
+ sc, sc.camera,
+ hero=hero,
+ elements=hero,
+ stage=stage,
+ )
+ 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 8
+ return 0
def build_scene():
@@ -604,9 +629,9 @@ Source
return code
if args.output:
- if not render_still(sc, os.path.abspath(args.output), args.engine):
- print("ERROR: render produced no file", file=sys.stderr)
- return 8
+ rcode = render_still(sc, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
print(f"rendered still {args.output}")
print("gp-lineart-contour OK")
diff --git a/docs/gallery/png-exr-alpha/index.html b/docs/gallery/png-exr-alpha/index.html
index b9fc644..47d5c47 100644
--- a/docs/gallery/png-exr-alpha/index.html
+++ b/docs/gallery/png-exr-alpha/index.html
@@ -199,6 +199,7 @@ Run
blender --background --python png_exr_alpha.py -- --output alpha.png
blender --background --python png_exr_alpha.py -- --output alpha.png --engine cycles
It exits non-zero on failure and prints every measured error and tolerance on success, so CI logs carry the numbers. 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
@@ -231,6 +232,11 @@ Source
import os
import math
import argparse
+
+# 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
import tempfile
import struct
@@ -792,11 +798,13 @@ Source
(0.0, 9.0, 2.6))
aim = bpy.data.objects.new("Aim", None)
- aim.location = (-0.10, 0.2, 1.65)
+ aim.location = (-0.10, 0.2, 1.38)
scene.collection.objects.link(aim)
# Gentle 3/4 from the right: both faces stay fully readable while the
- # bezels, struts, and plinth show their thickness.
+ # bezels, struts, and plinth show their thickness. Aim dropped 0.23 from
+ # the original 1.65 so the plinth clears the bottom edge (framing gate)
+ # without changing the distance or the composition.
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
@@ -822,8 +830,26 @@ Source
scene.render.filepath = path
# AgX desaturates the card toward pastel — Standard keeps the clamp visible.
scene.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 = [
+ o for o in scene.objects
+ if o.name.startswith(("PngDisplay", "ExrDisplay")) and o.type in {"MESH", "FONT"}
+ ]
+ plinth = scene.objects.get("Plinth")
+ fcode = gallery_framing.check_framing(
+ scene, cam,
+ hero=hero,
+ elements=hero + ([plinth] if plinth else []),
+ 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 13
+ return 0
def main():
@@ -841,8 +867,11 @@ Source
return code
if args.output:
bpy.ops.wm.read_factory_settings(use_empty=True)
- if not render_still(os.path.abspath(args.output), args.engine):
+ rcode = render_still(os.path.abspath(args.output), args.engine)
+ if rcode == 13:
return fail(f"render produced no file at {args.output}", 13)
+ if rcode:
+ return rcode
print(f"wrote {args.output}")
return 0
diff --git a/docs/gallery/shader-node-group/index.html b/docs/gallery/shader-node-group/index.html
index 5bdb17b..b6f8a01 100644
--- a/docs/gallery/shader-node-group/index.html
+++ b/docs/gallery/shader-node-group/index.html
@@ -193,6 +193,7 @@ Run
blender --background --python shader_node_group.py -- --output spheres.png
blender --background --python shader_node_group.py -- --output spheres.png --engine cycles
It exits non-zero on failure (missing interface sockets, unshared group, or identical instance parameters). 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
@@ -218,6 +219,11 @@ Source
"""
import bpy, bmesh, sys, os, math, argparse
+# 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
+
TINTS = {
"SphereA": (0.012, 0.32, 0.30, 1.0), # teal
"SphereB": (0.42, 0.02, 0.20, 1.0), # magenta
@@ -356,7 +362,7 @@ Source
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 58.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (0.0, -7.8, 2.6)
+ cam.location = (0.0, -8.7, 2.6)
cam.rotation_euler = (math.radians(78), 0.0, 0.0)
scene.collection.objects.link(cam)
scene.camera = cam
@@ -373,8 +379,21 @@ Source
scene.render.resolution_y = 720
scene.render.image_settings.file_format = 'PNG'
scene.render.filepath = path
+ # Layer 1 framing gate (silhouette matte) — exit 10 on violation, before
+ # the beauty render so a defective composition ships no artifact.
+ fcode = gallery_framing.check_framing(
+ scene, cam,
+ hero=list(objs),
+ elements=list(objs),
+ 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 7
+ return 0
def main():
@@ -391,9 +410,9 @@ Source
return code
if args.output:
- if not render_still(objs, os.path.abspath(args.output), args.engine):
- print("ERROR: render produced no file", file=sys.stderr)
- return 7
+ rcode = render_still(objs, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
print(f"rendered still {args.output}")
print("shader-node-group OK")
diff --git a/docs/gallery/swatch-grid/index.html b/docs/gallery/swatch-grid/index.html
index 9b3eae9..21ed800 100644
--- a/docs/gallery/swatch-grid/index.html
+++ b/docs/gallery/swatch-grid/index.html
@@ -198,7 +198,7 @@ Run
blender --background --python swatch_grid.py -- --output swatch.png --engine cycles --samples 16 --width 960
The script is deterministic and dependency-light (fixed camera and layout, no HDRI, no network). It exits non-zero on any failure, including a render that comes out uniformly black or without the expected six distinct swatch regions — the same honest check the CI smoke gate runs on both Blender 4.5 LTS and 5.1.
Verified
-Runs headless on Blender 4.5.10 LTS and 5.1.1; exercised on both by the blender-smoke workflow on every PR and weekly schedule.
+Runs headless on Blender 4.5.10 LTS and 5.1.1; exercised on both by the blender-smoke workflow on every PR and weekly schedule. 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
@@ -228,6 +228,12 @@ Source
"""
import bpy
import bmesh
+
+# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
+import os as _os, sys as _sys
+_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
import sys
import os
import math
@@ -329,11 +335,12 @@ Source
i += 1
# ortho camera framed on the grid cells: verify_png() samples the image at
# each cell center, so every sphere must stay inside its third/half of the
- # frame. ortho height = ortho_scale * 9/16 = 4.39 units for a grid that
- # spans 4.14 -- nothing clips, nothing drifts out of its cell.
+ # frame. ortho height = ortho_scale * 9/16 = 4.73 units for a grid that
+ # spans 4.14 -- nothing clips, nothing drifts out of its cell, and the
+ # grid fills 0.876 of the frame height (inside the Layer 1 band).
cam_d = bpy.data.cameras.new("cam")
cam_d.type = 'ORTHO'
- cam_d.ortho_scale = 7.8
+ cam_d.ortho_scale = 8.4
cam = bpy.data.objects.new("cam", cam_d)
cam.location = (0.0, -10.0, 0.0)
cam.rotation_euler = (math.radians(90), 0, 0)
@@ -453,6 +460,18 @@ Source
# AgX would desaturate the swatches toward pastel -- exactly the material
# colors this example exists to show (docs/VISUAL-STYLE.md)
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.
+ spheres = [o for o in sc.objects if o.type == "MESH" and o.name.startswith("S")]
+ stage = [o for o in sc.objects if o.name == "Wall"]
+ fcode = gallery_framing.check_framing(
+ sc, sc.camera,
+ hero=spheres,
+ elements=spheres,
+ stage=stage,
+ )
+ if fcode:
+ return fcode
os.makedirs(os.path.dirname(os.path.abspath(args.output)) or ".", exist_ok=True)
bpy.ops.render.render(write_still=True)
if not (os.path.exists(args.output) and os.path.getsize(args.output) > 0):
diff --git a/docs/gallery/vse-cut-list/index.html b/docs/gallery/vse-cut-list/index.html
index d657c5a..a093374 100644
--- a/docs/gallery/vse-cut-list/index.html
+++ b/docs/gallery/vse-cut-list/index.html
@@ -204,6 +204,7 @@ Run
# Also render the still (EEVEE on a GPU host; --engine cycles on GPU-less):
blender --background --python vse_cut_list.py -- --output vse.png
It exits non-zero on failure and prints every measured value and tolerance on success, so CI logs carry the numbers. The blender-smoke workflow runs the check and the pixel witness 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
@@ -259,6 +260,11 @@ Source
import bpy, sys, os, math, argparse, tempfile, warnings, shutil
import mathutils
+# 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
+
IS_5X = bpy.app.version >= (5, 0, 0)
# Closed-form cut list, end-exclusive [start, end) spans on the timeline.
@@ -724,10 +730,12 @@ Source
cam_data = bpy.data.cameras.new("BayCam")
cam_data.lens = 52.0
cam = bpy.data.objects.new("BayCam", cam_data)
- cam.location = (2.0, -5.2, 1.72)
+ # Pulled back from (2.0, -5.2, 1.72) so the bezel top and desk base
+ # clear the frame edges (framing gate on the Bay scene).
+ cam.location = (3.05, -7.9, 2.0)
sc.collection.objects.link(cam)
target = bpy.data.objects.new("BayAim", None)
- target.location = (0.0, 0.0, 1.28)
+ target.location = (0.0, 0.0, 1.20)
sc.collection.objects.link(target)
con = cam.constraints.new("TRACK_TO")
con.target = target
@@ -749,8 +757,27 @@ Source
sc.render.filepath = path
# Standard keeps both the stage saturated and the on-screen frame true
sc.view_settings.view_transform = "Standard"
+ # Layer 1 framing gate (silhouette matte) on the BAY scene — exit 10 on
+ # violation, before the beauty render. Renderables here are the desk,
+ # stand, and monitor assembly; floor/wall are the stage.
+ stage = [o for o in sc.objects if any(k in o.name.lower() for k in ("floor", "wall"))]
+ hero = [
+ o for o in sc.objects
+ if o.type in gallery_framing._RENDER_TYPES and not o.hide_render and o not in stage
+ ]
+ fcode = gallery_framing.check_framing(
+ sc, cam,
+ hero=hero,
+ elements=hero,
+ stage=stage,
+ )
+ if fcode:
+ return fcode
bpy.ops.render.render(write_still=True, scene=sc.name)
- 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 11
+ return 0
def render_still(path, engine):
@@ -763,7 +790,7 @@ Source
try:
frame_path = os.path.join(tmp, "program.png")
if not render_frame(sc, frame_path, engine, RENDER_W, RENDER_H):
- return False
+ return 11
bay = bpy.data.scenes.new("Bay")
build_stage(bay) # the house dark studio, lights included
build_bay(bay, frame_path)
@@ -860,8 +887,11 @@ Source
if args.output:
# The round-trip reloaded a .blend; start clean for the still.
bpy.ops.wm.read_factory_settings(use_empty=True)
- if not render_still(os.path.abspath(args.output), args.engine):
+ rcode = render_still(os.path.abspath(args.output), args.engine)
+ if rcode == 11:
return fail(f"render produced no file at {args.output}", 11)
+ if rcode:
+ return rcode
print(f"wrote {args.output}")
return 0
diff --git a/examples/curve-bevel-arc/README.md b/examples/curve-bevel-arc/README.md
index 3dd2cb9..dcea5b6 100644
--- a/examples/curve-bevel-arc/README.md
+++ b/examples/curve-bevel-arc/README.md
@@ -24,3 +24,5 @@ blender --background --python curve_bevel_arc.py -- --output arc.png --engine cy
It exits non-zero on failure (wrong point count, bevel, caps, topology, or span).
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.
diff --git a/examples/curve-bevel-arc/curve_bevel_arc.py b/examples/curve-bevel-arc/curve_bevel_arc.py
index 7ffe57a..2046059 100644
--- a/examples/curve-bevel-arc/curve_bevel_arc.py
+++ b/examples/curve-bevel-arc/curve_bevel_arc.py
@@ -17,6 +17,11 @@
"""
import bpy, bmesh, sys, os, math, argparse
+# 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
+
N_POINTS = 8
RADIUS = 1.5
BEVEL = 0.15
@@ -181,7 +186,7 @@ def light(name, loc, energy, size, col):
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (2.4, -3.4, 1.7)
+ cam.location = (2.55, -3.62, 1.78)
scene.collection.objects.link(cam)
scene.camera = cam
track = cam.constraints.new('TRACK_TO')
@@ -203,8 +208,21 @@ def light(name, loc, energy, size, col):
scene.render.filepath = path
# AgX would wash the rose tube toward salmon (docs/VISUAL-STYLE.md)
scene.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.
+ fcode = gallery_framing.check_framing(
+ scene, cam,
+ hero=[obj],
+ elements=[obj],
+ 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 11
+ return 0
def main():
@@ -221,9 +239,9 @@ def main():
return code
if args.output:
- if not render_still(obj, os.path.abspath(args.output), args.engine):
- print("ERROR: render produced no file", file=sys.stderr)
- return 11
+ rcode = render_still(obj, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
print(f"rendered still {args.output}")
print("curve-bevel-arc OK")
diff --git a/examples/curve-bevel-arc/preview.webp b/examples/curve-bevel-arc/preview.webp
index d4c7e37..8e9d03c 100644
Binary files a/examples/curve-bevel-arc/preview.webp and b/examples/curve-bevel-arc/preview.webp differ
diff --git a/examples/driver-wave/README.md b/examples/driver-wave/README.md
index e8caaee..76444d1 100644
--- a/examples/driver-wave/README.md
+++ b/examples/driver-wave/README.md
@@ -28,3 +28,5 @@ blender --background --python driver_wave.py -- --output driver.png --engine cyc
It exits non-zero on failure (driven value wrong, or the flush-back disagreed). 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.
diff --git a/examples/driver-wave/driver_wave.py b/examples/driver-wave/driver_wave.py
index f7a4f0f..f1f0b7a 100644
--- a/examples/driver-wave/driver_wave.py
+++ b/examples/driver-wave/driver_wave.py
@@ -16,6 +16,11 @@
"""
import bpy, bmesh, sys, os, math, argparse
+# 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
+
COUNT = 16
SPACING = 0.72
BASE = 0.28
@@ -144,7 +149,7 @@ def render_still(objs, path, engine):
# the frame with a small margin -- the skyline must not clip
cam_data = bpy.data.cameras.new("Cam"); cam_data.lens = 42.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (0.0, -13.5, 2.0)
+ cam.location = (0.0, -15.0, 2.0)
cam.rotation_euler = (math.radians(86), 0.0, 0.0)
scene.collection.objects.link(cam)
scene.camera = cam
@@ -163,8 +168,21 @@ def render_still(objs, path, engine):
scene.render.filepath = path
# AgX would wash the orange columns toward tan (docs/VISUAL-STYLE.md)
scene.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.
+ fcode = gallery_framing.check_framing(
+ scene, cam,
+ hero=list(objs),
+ elements=list(objs),
+ 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 6
+ return 0
def main():
@@ -181,9 +199,9 @@ def main():
return code
if args.output:
- if not render_still(objs, os.path.abspath(args.output), args.engine):
- print("ERROR: render produced no file", file=sys.stderr)
- return 6
+ rcode = render_still(objs, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
print(f"rendered still {args.output}")
print("driver-wave OK")
diff --git a/examples/driver-wave/preview.webp b/examples/driver-wave/preview.webp
index 6922999..81f7a3d 100644
Binary files a/examples/driver-wave/preview.webp and b/examples/driver-wave/preview.webp differ
diff --git a/examples/gallery_framing.py b/examples/gallery_framing.py
index b39bb47..3049662 100644
--- a/examples/gallery_framing.py
+++ b/examples/gallery_framing.py
@@ -223,7 +223,9 @@ def _matte_render(scene, camera, path, width, height):
scene.camera = camera
if saved["samples"] is not None:
scene.eevee.taa_render_samples = 8
- bpy.ops.render.render(write_still=True)
+ # Render the passed scene, not whatever happens to be active —
+ # examples may still from a non-context scene (vse-cut-list's Bay).
+ bpy.ops.render.render(write_still=True, scene=scene.name)
finally:
rd.engine = saved["engine"]
rd.resolution_x, rd.resolution_y, rd.resolution_percentage = saved["res"]
diff --git a/examples/gp-lineart-contour/README.md b/examples/gp-lineart-contour/README.md
index 4728765..b7c2e99 100644
--- a/examples/gp-lineart-contour/README.md
+++ b/examples/gp-lineart-contour/README.md
@@ -42,3 +42,5 @@ blender --background --python gp_lineart_contour.py -- --output lineart.png
It 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.
diff --git a/examples/gp-lineart-contour/gp_lineart_contour.py b/examples/gp-lineart-contour/gp_lineart_contour.py
index 9497285..f155cad 100644
--- a/examples/gp-lineart-contour/gp_lineart_contour.py
+++ b/examples/gp-lineart-contour/gp_lineart_contour.py
@@ -23,6 +23,11 @@
blender --background --python gp_lineart_contour.py -- --output l.png
"""
import bpy, bmesh, sys, os, math, argparse
+
+# 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
from mathutils import Matrix
# Faceted crystal (octahedron-ish) — enough silhouette edges to read at thumbnail
@@ -149,7 +154,7 @@ def setup_camera(sc):
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (3.4, -4.2, 2.4)
+ cam.location = (3.95, -4.9, 2.62)
sc.collection.objects.link(cam)
sc.camera = cam
aim = bpy.data.objects.new("Aim", None)
@@ -367,8 +372,27 @@ def render_still(sc, path, engine):
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. The two
+ # renderable subjects are the crystal source mesh and the GP stroke object.
+ stage = [o for o in sc.objects if o.name in {"Floor", "Wall"}]
+ hero = [
+ o for o in sc.objects
+ if o.type in {"MESH", "GREASEPENCIL"} and o not in stage
+ ]
+ fcode = gallery_framing.check_framing(
+ sc, sc.camera,
+ hero=hero,
+ elements=hero,
+ stage=stage,
+ )
+ 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 8
+ return 0
def build_scene():
@@ -400,9 +424,9 @@ def main():
return code
if args.output:
- if not render_still(sc, os.path.abspath(args.output), args.engine):
- print("ERROR: render produced no file", file=sys.stderr)
- return 8
+ rcode = render_still(sc, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
print(f"rendered still {args.output}")
print("gp-lineart-contour OK")
diff --git a/examples/gp-lineart-contour/preview.webp b/examples/gp-lineart-contour/preview.webp
index e2cea13..192f676 100644
Binary files a/examples/gp-lineart-contour/preview.webp and b/examples/gp-lineart-contour/preview.webp differ
diff --git a/examples/png-exr-alpha/README.md b/examples/png-exr-alpha/README.md
index 0fcc875..3b5bf28 100644
--- a/examples/png-exr-alpha/README.md
+++ b/examples/png-exr-alpha/README.md
@@ -71,3 +71,5 @@ blender --background --python png_exr_alpha.py -- --output alpha.png --engine cy
It exits non-zero on failure and prints every measured error and tolerance on
success, so CI logs carry the numbers. 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.
diff --git a/examples/png-exr-alpha/png_exr_alpha.py b/examples/png-exr-alpha/png_exr_alpha.py
index baa1a1a..f9b961d 100644
--- a/examples/png-exr-alpha/png_exr_alpha.py
+++ b/examples/png-exr-alpha/png_exr_alpha.py
@@ -23,6 +23,11 @@
import os
import math
import argparse
+
+# 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
import tempfile
import struct
@@ -584,11 +589,13 @@ def light(name, loc, energy, size, col, target):
(0.0, 9.0, 2.6))
aim = bpy.data.objects.new("Aim", None)
- aim.location = (-0.10, 0.2, 1.65)
+ aim.location = (-0.10, 0.2, 1.38)
scene.collection.objects.link(aim)
# Gentle 3/4 from the right: both faces stay fully readable while the
- # bezels, struts, and plinth show their thickness.
+ # bezels, struts, and plinth show their thickness. Aim dropped 0.23 from
+ # the original 1.65 so the plinth clears the bottom edge (framing gate)
+ # without changing the distance or the composition.
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
@@ -614,8 +621,26 @@ def light(name, loc, energy, size, col, target):
scene.render.filepath = path
# AgX desaturates the card toward pastel — Standard keeps the clamp visible.
scene.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 = [
+ o for o in scene.objects
+ if o.name.startswith(("PngDisplay", "ExrDisplay")) and o.type in {"MESH", "FONT"}
+ ]
+ plinth = scene.objects.get("Plinth")
+ fcode = gallery_framing.check_framing(
+ scene, cam,
+ hero=hero,
+ elements=hero + ([plinth] if plinth else []),
+ 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 13
+ return 0
def main():
@@ -633,8 +658,11 @@ def main():
return code
if args.output:
bpy.ops.wm.read_factory_settings(use_empty=True)
- if not render_still(os.path.abspath(args.output), args.engine):
+ rcode = render_still(os.path.abspath(args.output), args.engine)
+ if rcode == 13:
return fail(f"render produced no file at {args.output}", 13)
+ if rcode:
+ return rcode
print(f"wrote {args.output}")
return 0
diff --git a/examples/png-exr-alpha/preview.webp b/examples/png-exr-alpha/preview.webp
index ddfbe10..d93f2b5 100644
Binary files a/examples/png-exr-alpha/preview.webp and b/examples/png-exr-alpha/preview.webp differ
diff --git a/examples/shader-node-group/README.md b/examples/shader-node-group/README.md
index f4ee7a9..38c604f 100644
--- a/examples/shader-node-group/README.md
+++ b/examples/shader-node-group/README.md
@@ -25,3 +25,5 @@ blender --background --python shader_node_group.py -- --output spheres.png --eng
It exits non-zero on failure (missing interface sockets, unshared group, or identical
instance parameters). 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.
diff --git a/examples/shader-node-group/preview.webp b/examples/shader-node-group/preview.webp
index 4e100ab..93a11ef 100644
Binary files a/examples/shader-node-group/preview.webp and b/examples/shader-node-group/preview.webp differ
diff --git a/examples/shader-node-group/shader_node_group.py b/examples/shader-node-group/shader_node_group.py
index bf040b6..8374780 100644
--- a/examples/shader-node-group/shader_node_group.py
+++ b/examples/shader-node-group/shader_node_group.py
@@ -16,6 +16,11 @@
"""
import bpy, bmesh, sys, os, math, argparse
+# 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
+
TINTS = {
"SphereA": (0.012, 0.32, 0.30, 1.0), # teal
"SphereB": (0.42, 0.02, 0.20, 1.0), # magenta
@@ -154,7 +159,7 @@ def light(name, loc, energy, size, col, rot):
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 58.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (0.0, -7.8, 2.6)
+ cam.location = (0.0, -8.7, 2.6)
cam.rotation_euler = (math.radians(78), 0.0, 0.0)
scene.collection.objects.link(cam)
scene.camera = cam
@@ -171,8 +176,21 @@ def light(name, loc, energy, size, col, rot):
scene.render.resolution_y = 720
scene.render.image_settings.file_format = 'PNG'
scene.render.filepath = path
+ # Layer 1 framing gate (silhouette matte) — exit 10 on violation, before
+ # the beauty render so a defective composition ships no artifact.
+ fcode = gallery_framing.check_framing(
+ scene, cam,
+ hero=list(objs),
+ elements=list(objs),
+ 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 7
+ return 0
def main():
@@ -189,9 +207,9 @@ def main():
return code
if args.output:
- if not render_still(objs, os.path.abspath(args.output), args.engine):
- print("ERROR: render produced no file", file=sys.stderr)
- return 7
+ rcode = render_still(objs, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
print(f"rendered still {args.output}")
print("shader-node-group OK")
diff --git a/examples/swatch-grid/README.md b/examples/swatch-grid/README.md
index 7d4bbe8..65121bd 100644
--- a/examples/swatch-grid/README.md
+++ b/examples/swatch-grid/README.md
@@ -40,3 +40,4 @@ smoke gate runs on both Blender 4.5 LTS and 5.1.
Runs headless on **Blender 4.5.10 LTS** and **5.1.1**; exercised on both by the
`blender-smoke` workflow on every PR and weekly schedule.
+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.
diff --git a/examples/swatch-grid/preview.webp b/examples/swatch-grid/preview.webp
index 45e49d6..f9da58d 100644
Binary files a/examples/swatch-grid/preview.webp and b/examples/swatch-grid/preview.webp differ
diff --git a/examples/swatch-grid/swatch_grid.py b/examples/swatch-grid/swatch_grid.py
index f9c115b..a1997d5 100644
--- a/examples/swatch-grid/swatch_grid.py
+++ b/examples/swatch-grid/swatch_grid.py
@@ -20,6 +20,12 @@
"""
import bpy
import bmesh
+
+# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
+import os as _os, sys as _sys
+_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
import sys
import os
import math
@@ -121,11 +127,12 @@ def build_scene(mats):
i += 1
# ortho camera framed on the grid cells: verify_png() samples the image at
# each cell center, so every sphere must stay inside its third/half of the
- # frame. ortho height = ortho_scale * 9/16 = 4.39 units for a grid that
- # spans 4.14 -- nothing clips, nothing drifts out of its cell.
+ # frame. ortho height = ortho_scale * 9/16 = 4.73 units for a grid that
+ # spans 4.14 -- nothing clips, nothing drifts out of its cell, and the
+ # grid fills 0.876 of the frame height (inside the Layer 1 band).
cam_d = bpy.data.cameras.new("cam")
cam_d.type = 'ORTHO'
- cam_d.ortho_scale = 7.8
+ cam_d.ortho_scale = 8.4
cam = bpy.data.objects.new("cam", cam_d)
cam.location = (0.0, -10.0, 0.0)
cam.rotation_euler = (math.radians(90), 0, 0)
@@ -245,6 +252,18 @@ def main():
# AgX would desaturate the swatches toward pastel -- exactly the material
# colors this example exists to show (docs/VISUAL-STYLE.md)
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.
+ spheres = [o for o in sc.objects if o.type == "MESH" and o.name.startswith("S")]
+ stage = [o for o in sc.objects if o.name == "Wall"]
+ fcode = gallery_framing.check_framing(
+ sc, sc.camera,
+ hero=spheres,
+ elements=spheres,
+ stage=stage,
+ )
+ if fcode:
+ return fcode
os.makedirs(os.path.dirname(os.path.abspath(args.output)) or ".", exist_ok=True)
bpy.ops.render.render(write_still=True)
if not (os.path.exists(args.output) and os.path.getsize(args.output) > 0):
diff --git a/examples/vse-cut-list/README.md b/examples/vse-cut-list/README.md
index a54a09d..87076d4 100644
--- a/examples/vse-cut-list/README.md
+++ b/examples/vse-cut-list/README.md
@@ -116,3 +116,5 @@ blender --background --python vse_cut_list.py -- --output vse.png
It exits non-zero on failure and prints every measured value and tolerance on
success, so CI logs carry the numbers. The `blender-smoke` workflow runs the
check and the pixel witness 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.
diff --git a/examples/vse-cut-list/preview.webp b/examples/vse-cut-list/preview.webp
index e03381b..3458671 100644
Binary files a/examples/vse-cut-list/preview.webp and b/examples/vse-cut-list/preview.webp differ
diff --git a/examples/vse-cut-list/vse_cut_list.py b/examples/vse-cut-list/vse_cut_list.py
index 6c72c07..93de80f 100644
--- a/examples/vse-cut-list/vse_cut_list.py
+++ b/examples/vse-cut-list/vse_cut_list.py
@@ -46,6 +46,11 @@
import bpy, sys, os, math, argparse, tempfile, warnings, shutil
import mathutils
+# 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
+
IS_5X = bpy.app.version >= (5, 0, 0)
# Closed-form cut list, end-exclusive [start, end) spans on the timeline.
@@ -511,10 +516,12 @@ def render_bay(sc, path, engine, w, h):
cam_data = bpy.data.cameras.new("BayCam")
cam_data.lens = 52.0
cam = bpy.data.objects.new("BayCam", cam_data)
- cam.location = (2.0, -5.2, 1.72)
+ # Pulled back from (2.0, -5.2, 1.72) so the bezel top and desk base
+ # clear the frame edges (framing gate on the Bay scene).
+ cam.location = (3.05, -7.9, 2.0)
sc.collection.objects.link(cam)
target = bpy.data.objects.new("BayAim", None)
- target.location = (0.0, 0.0, 1.28)
+ target.location = (0.0, 0.0, 1.20)
sc.collection.objects.link(target)
con = cam.constraints.new("TRACK_TO")
con.target = target
@@ -536,8 +543,27 @@ def render_bay(sc, path, engine, w, h):
sc.render.filepath = path
# Standard keeps both the stage saturated and the on-screen frame true
sc.view_settings.view_transform = "Standard"
+ # Layer 1 framing gate (silhouette matte) on the BAY scene — exit 10 on
+ # violation, before the beauty render. Renderables here are the desk,
+ # stand, and monitor assembly; floor/wall are the stage.
+ stage = [o for o in sc.objects if any(k in o.name.lower() for k in ("floor", "wall"))]
+ hero = [
+ o for o in sc.objects
+ if o.type in gallery_framing._RENDER_TYPES and not o.hide_render and o not in stage
+ ]
+ fcode = gallery_framing.check_framing(
+ sc, cam,
+ hero=hero,
+ elements=hero,
+ stage=stage,
+ )
+ if fcode:
+ return fcode
bpy.ops.render.render(write_still=True, scene=sc.name)
- 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 11
+ return 0
def render_still(path, engine):
@@ -550,7 +576,7 @@ def render_still(path, engine):
try:
frame_path = os.path.join(tmp, "program.png")
if not render_frame(sc, frame_path, engine, RENDER_W, RENDER_H):
- return False
+ return 11
bay = bpy.data.scenes.new("Bay")
build_stage(bay) # the house dark studio, lights included
build_bay(bay, frame_path)
@@ -647,8 +673,11 @@ def main():
if args.output:
# The round-trip reloaded a .blend; start clean for the still.
bpy.ops.wm.read_factory_settings(use_empty=True)
- if not render_still(os.path.abspath(args.output), args.engine):
+ rcode = render_still(os.path.abspath(args.output), args.engine)
+ if rcode == 11:
return fail(f"render produced no file at {args.output}", 11)
+ if rcode:
+ return rcode
print(f"wrote {args.output}")
return 0