diff --git a/CLAUDE.md b/CLAUDE.md
index c4dae1f..a8d59c2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -140,7 +140,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. Documented framing deviations (VISUAL-STYLE Layer 1) call `check_framing(..., deviation="reason")` — the helper reports instead of enforcing, and the reason string is required.
+- **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. The helper measures; call sites enforce. Bleed compositions call `measure_framing_deviation` and assert their own cap at the call site — `check_framing` has no `deviation=` flag.
- **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.
- **Asset-sheet gate (asset-type examples — game props/kits):** composite the hero asset rendered alone (neutral three-quarter view, plain studio lighting, no staging tricks, no labels, no comparison props) beside the pinned asset-quality reference set — currently `collision-hull-proxy`, `custom-normals-shade`, `vertex-weight-limit`, `lod-decimate-chain` — rendered the same way; commit under `docs/gallery/asset-sheets/`, link it in the PR body, and report a verdict. The asset ships only if it is not identifiable as the least-designed object in that lineup — a strong scene can carry a weak model; this gate removes the scene. **This list is the canonical home of the reference set** — update it here when a new asset outclasses a member. The measurable floors behind the gate (naming, material variation, edge treatment) live in `examples/gallery_asset_quality.py` — render path only, same call pattern as `gallery_framing`, exit 11 on violation — with the calibration table and dropped-floor evidence in `docs/VISUAL-STYLE.md` § Asset quality.
- **Falsification:** every check must be proven to fail once — break the contract, observe the non-zero exit, restore — with the probe and the measured error reported in the PR body. An assertion that cannot fail witnesses nothing.
diff --git a/docs/VISUAL-STYLE.md b/docs/VISUAL-STYLE.md
index f83ea95..8bce084 100644
--- a/docs/VISUAL-STYLE.md
+++ b/docs/VISUAL-STYLE.md
@@ -68,10 +68,11 @@ the default stage.
world or the atmosphere rather than an object on a stage. A deviation
requires one line in the example README under a `Framing deviation`
heading — what the composition requires and why, exactly as stage
- deviations do. An undocumented deviation is a defect. Under a documented
- deviation the helper reports rather than enforces: call
- `check_framing(..., deviation="reason")`, which prints the numbers with
- the reason; an empty reason raises, so no deviation is taken silently.
+ deviations do. An undocumented deviation is a defect. The helper
+ measures; the call site enforces. `check_framing` always applies the
+ Layer 1 band (0 / 10). Bleed compositions call
+ `measure_framing_deviation`, print the numbers, and assert their own
+ cap with their own exit code. There is no `deviation=` flag.
- 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/modular-kit-snap/index.html b/docs/gallery/modular-kit-snap/index.html
index 142766f..d0851e4 100644
--- a/docs/gallery/modular-kit-snap/index.html
+++ b/docs/gallery/modular-kit-snap/index.html
@@ -267,15 +267,16 @@
modular-kit-snap
What each check catches on failure (probed with an unsnapped variant built by the same code minus the snap pass — 3 mm end-ring skew, 2 mm y-nudge on two verts): boundary verts off the end planes, worst 3.000e-03 m (exit 3); opposing rings displaced 2.000e-03 (exit 4); tiled joint gap/overlap (exit 5); bbox off the declared tile by 3.000e-03 (exit 6); boundary edges torn off the rim (exit 7); non-watertight detail (exit 8); detail escaping the tile (exit 9); unapplied transforms, default names, or wandering origins (exit 11).
Version witness: check output is byte-identical on Blender 4.5.11 LTS and 5.1.2 — same counts, same zero measured deviations.
Render as proof: a four-segment run — the joints vanish. The falsification variant (--falsify) accumulates a 120 mm gap, 50 mm lateral jogs, and 40 mm floor steps at each joint: floor plates split with dark seams and the trim rails visibly break. The check measures the same failure class at 3 mm; the render exaggerates it to read at frame scale. The render path also gates the asset through examples/gallery_asset_quality.py (naming, material variation, edge treatment — exit 11). An earlier revision shipped flat grey wall panels and was remodeled under that gate: each panel is now an assembly (backing plate + inset panel + four bolt heads), the palette shifted teal-slate so the card does not twin with lightmap-uv-channel's warm cart, and mean luminance was brought into the calibration range (82.7 → 66.1, ceiling 77.7) — no luminance deviation needed.
-Framing deviation: the still is an interior corridor run — the envelope surrounds the camera on five sides and the tiling joints are the proof, so the subject reads as extending past the frame (the radiating-architecture class of VISUAL-STYLE Layer 1). check_framing measures and reports (fill 1.000/1.000, all-edge bleed) without enforcing, with the reason string at the call site.
+Framing deviation: the still is an interior corridor run — the envelope surrounds the camera on five sides and the tiling joints are the proof, so the subject reads as extending past the frame. The helper measures; this example enforces a projection-space fill-over cap (FRAME_DEV_MAX = 8.0). Gallery camera scores ~5.06 (pass). --close-camera scores ~30.9 and exits 10. Silhouette saturates at fill 1.0, so the cap is measured with strategy="projection".
Run
blender --background --python modular_kit_snap.py --
blender --background --python modular_kit_snap.py -- --output corridor.png
-blender --background --python modular_kit_snap.py -- --falsify seams.png
+blender --background --python modular_kit_snap.py -- --falsify seams.png
+blender --background --python modular_kit_snap.py -- --output close.png --close-camera
Exit codes
-Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper. 11 is the shared asset-quality helper.
-| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Boundary vert count or end-plane membership | | 4 | End rings do not partition evenly, or opposing loops differ | | 5 | Tiled instances do not share boundary positions | | 6 | Shell or whole-asset bbox off the declared tile | | 7 | Boundary edge count, non-manifold edges, or rim off the end planes | | 8 | Detail part not watertight | | 9 | Detail part reaches a tile boundary; also --output produced no file | | 11 | Kit part count, unapplied scale, namespace, or origin; also gallery asset-quality violation |
-The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --falsify.
+Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is this example's framing-deviation cap (call site, not the shared Layer 1 helper). 11 is the shared asset-quality helper.
+| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Boundary vert count or end-plane membership | | 4 | End rings do not partition evenly, or opposing loops differ | | 5 | Tiled instances do not share boundary positions | | 6 | Shell or whole-asset bbox off the declared tile | | 7 | Boundary edge count, non-manifold edges, or rim off the end planes | | 8 | Detail part not watertight | | 9 | Detail part reaches a tile boundary; also --output produced no file | | 10 | Framing deviation score exceeds cap (--close-camera) | | 11 | Kit part count, unapplied scale, namespace, or origin; also gallery asset-quality violation |
+The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output, --falsify, or --close-camera.
Source
@@ -324,6 +325,7 @@ Source
blender --background --python modular_kit_snap.py -- # check only
blender --background --python modular_kit_snap.py -- --output k.png # + render
blender --background --python modular_kit_snap.py -- --falsify f.png # seam variant
+ blender --background --python modular_kit_snap.py -- --output c.png --close-camera # framing 10
"""
import bpy, bmesh, sys, os, math, argparse
@@ -346,6 +348,12 @@ Source
EXPECT_PARTS = 21 # shell + 20 detail parts; asserted before the
# per-part loops below, which assert nothing on
# an empty dict
+# Projection-space Layer 1 deviation cap. The corridor is supposed to bleed
+# (measured fill_over ~5.06 on the gallery camera). --close-camera pushes
+# fill_over to ~30. Helper measures; this example enforces.
+FRAME_DEV_MAX = 8.0
+CLOSE_CAM_LOC = (-0.15, -0.08, 1.50)
+CLOSE_CAM_LENS = 16.0
# Hollow-rectangle profile (y, z): outer shell corners then inner bore corners,
# ordered as one continuous ring so the extrusion's side faces come out quads.
@@ -832,7 +840,7 @@ Source
return parts
-def render_still(path, engine, falsify=False):
+def render_still(path, engine, falsify=False, close_camera=False):
"""A four-segment run. Snapped: joints vanish. Falsified: each segment
accumulates a 120 mm x gap, alternating 50 mm y jogs, and 40 mm floor
steps, so every joint reads as a seam — the render-scale exaggeration of
@@ -857,9 +865,9 @@ Source
bulkhead = build_bulkhead(sc)
cam_data = bpy.data.cameras.new("Cam")
- cam_data.lens = 25.0
+ cam_data.lens = CLOSE_CAM_LENS if close_camera else 25.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (-0.7, -0.42, 1.58)
+ cam.location = CLOSE_CAM_LOC if close_camera else (-0.7, -0.42, 1.58)
sc.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim", None)
aim.location = (10.0, 0.35, 1.25)
@@ -886,19 +894,25 @@ Source
sc.render.filepath = path
# Standard, always — AgX would lift the stage toward grey (VISUAL-STYLE)
sc.view_settings.view_transform = "Standard"
- # Layer 1 framing gate. The corridor surrounds the camera on five sides
- # and reads as extending past the frame — a documented deviation class
- # (radiating architecture), measured and reported by the helper.
- fcode = gallery_framing.check_framing(
+ # The corridor surrounds the camera — Layer 1 fill/margin will fail, and
+ # that is the design. The helper only measures; this example owns the
+ # cap. Projection so the bleed can grow past 1.0 (silhouette saturates).
+ res, score = gallery_framing.measure_framing_deviation(
sc, cam,
hero=all_parts,
elements=all_parts + bulkhead,
stage=[floor, wall],
- deviation="interior corridor run: the envelope surrounds the camera "
- "and the tiling joints are the proof; edge bleed is the design",
+ strategy="projection",
)
- if fcode:
- return fcode
+ print(res.report())
+ print(f"framing_deviation score={score:.4f} cap={FRAME_DEV_MAX:.4f}")
+ if score > FRAME_DEV_MAX:
+ print(
+ f"ERROR: framing deviation {score:.4f} exceeds cap {FRAME_DEV_MAX:.4f} "
+ "(--close-camera is the designed fail)",
+ file=sys.stderr,
+ )
+ return 10
aqcode = gallery_asset_quality.check_asset_quality(
sc, cam, hero=aq_hero, stage=[floor, wall])
if aqcode:
@@ -916,6 +930,9 @@ Source
p.add_argument("--output", default=None, help="optional: render a still PNG here")
p.add_argument("--falsify", default=None,
help="optional: render the unsnapped-joint seam variant here")
+ p.add_argument("--close-camera", action="store_true",
+ help="falsification: push the camera into the corridor so "
+ "framing deviation exceeds the cap (needs --output)")
p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
help="render engine for --output/--falsify (cycles for GPU-less hosts)")
args = p.parse_args(argv)
@@ -927,7 +944,10 @@ Source
return code
if args.output:
- rcode = render_still(os.path.abspath(args.output), args.engine)
+ rcode = render_still(
+ os.path.abspath(args.output), args.engine,
+ close_camera=args.close_camera,
+ )
if rcode:
return rcode
print(f"rendered still {args.output}")
diff --git a/docs/gallery/shipping-crate/index.html b/docs/gallery/shipping-crate/index.html
index 7ec9f12..41ae7b1 100644
--- a/docs/gallery/shipping-crate/index.html
+++ b/docs/gallery/shipping-crate/index.html
@@ -273,7 +273,7 @@ Run
blender --background --python shipping_crate.py -- --output crate.png
Smoke does not pass --output or --skip-decimate.
Exit codes
-File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path (no deviation=).
+File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path.
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 2 distinct slots | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |
diff --git a/docs/gallery/socket-attach-points/index.html b/docs/gallery/socket-attach-points/index.html
index b7979a8..985902e 100644
--- a/docs/gallery/socket-attach-points/index.html
+++ b/docs/gallery/socket-attach-points/index.html
@@ -278,7 +278,7 @@ Run
blender --background --python socket_attach_points.py -- --output drone.png
blender --background --python socket_attach_points.py -- --falsify adrift.png
blender --background --python socket_attach_points.py -- --probe
-The --output render path additionally gates framing via examples/gallery_framing.py (fill 0.881x, margins 0.066/0.053/0.122/0.106, no edge touched) and the asset floors via examples/gallery_asset_quality.py (32 materials, edge90 0.027, no default names). The --falsify render is a diagnostic, not a gallery hero, so it takes a documented framing deviation — its whole point is that the modules leave the frame.
+The --output render path additionally gates framing via examples/gallery_framing.py (fill 0.881x, margins 0.066/0.053/0.122/0.106, no edge touched) and the asset floors via examples/gallery_asset_quality.py (32 materials, edge90 0.027, no default names). --falsify is a diagnostic, not a gallery hero: it measures and prints framing but does not enforce the Layer 1 band — modules leave the frame on purpose. --falsify and --probe still exit 0.
Exit codes
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper. 11 is the shared asset-quality helper.
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Socket world matrix, orthonormal basis, or determinant | | 4 | Socket pad normal, origin, or up-axis rule | | 5 | Module seating / mount axis / leftover local transform | | 6 | Population, namespace, default datablock name, SKT_ prefix, or skid plane | | 7 | Re-pose: socket or module lost the root | | 8 | Parent-inverse clear, child scale pushdown, or post-apply module drift | | 9 | --output produced no file | | 10 | Gallery framing violation | | 11 | Gallery asset-quality violation |
@@ -1359,17 +1359,19 @@ Source
sc.view_settings.view_transform = "Standard"
bpy.context.view_layer.update()
- # The gallery still is gated; the --falsify diagnostic is not. Its whole
- # point is that the modules fly off the airframe, so measuring it against
- # the Layer 1 band would only ever report the breakage as a framing
- # violation. The numbers are still printed under an explicit reason.
- fcode = gallery_framing.check_framing(
- sc, cam, hero=hero, elements=hero, stage=[floor, wall],
- deviation=("falsification diagnostic: the sockets are deliberately "
- "unparented, so modules leave the frame — this render is "
- "evidence, not a gallery hero") if falsify else None)
- if fcode:
- return fcode
+ # The gallery still is gated by Layer 1. The --falsify diagnostic is not:
+ # modules leave the frame on purpose, so enforcing the band would report
+ # the breakage as a framing violation. Measure and print either way;
+ # enforce only on the gallery path.
+ if falsify:
+ res = gallery_framing.measure_framing(
+ sc, cam, hero=hero, elements=hero, stage=[floor, wall])
+ print(res.report())
+ else:
+ fcode = gallery_framing.check_framing(
+ sc, cam, hero=hero, elements=hero, stage=[floor, wall])
+ if fcode:
+ return fcode
aqcode = gallery_asset_quality.check_asset_quality(sc, cam, hero=hero,
stage=[floor, wall])
if aqcode:
diff --git a/examples/gallery_framing.py b/examples/gallery_framing.py
index 30a4180..2aca716 100644
--- a/examples/gallery_framing.py
+++ b/examples/gallery_framing.py
@@ -54,15 +54,12 @@
runs only on the opt-in render path; check-only semantics (non-zero ==
API-contract drift) are unchanged.
-Deviations: VISUAL-STYLE Layer 1 allows a documented framing deviation
-when the bleed or the scale is the design — radiating subjects that read
-as extending past the frame, edge-to-edge fields where the fill is the
-point, subjects whose contract is the world or the atmosphere. The
-deviation must be visible in the example's source and render log: pass
-``deviation="reason"`` to `check_framing`, which switches enforcement to
-reporting and prints the reason with the numbers. An empty or whitespace
-reason raises ValueError — a deviation cannot be taken silently. The
-README must carry the same one-line note, exactly as stage deviations do.
+The helper measures; call sites enforce. `check_framing` always applies
+the Layer 1 band (0/10). Compositions whose contract *is* the bleed
+(interior corridor, radiating architecture) do not pass a `deviation=`
+flag — that parameter is gone. They call `measure_framing_deviation`,
+which returns the FramingResult plus a scalar score, and assert their
+own cap at the call site with their own exit code.
Scope: the helper measures the (scene, camera) pair it is handed — pass
the scene the still actually renders from. Multi-scene examples (e.g.
@@ -70,8 +67,7 @@
while ``bpy.context.scene`` is the cut-list scene) must pass that scene
and its camera explicitly. Examples whose subject IS the world or the
atmosphere (sky-texture-sun-elevation) have no renderable hero for the
-matte to isolate — the number mismeasures a reference prop, so they take
-a documented deviation and report rather than enforce.
+matte to isolate — they do not call this helper.
"""
import os
import sys
@@ -386,32 +382,44 @@ def measure_framing(scene, camera, hero, elements, stage=(), strategy=DEFAULT_ST
return res
-def check_framing(scene, camera, hero, elements, stage=(), strategy=DEFAULT_STRATEGY,
- deviation=None):
+def deviation_score(res):
+ """How far *res* sits outside the Layer 1 band. 0.0 iff ``res.ok``.
+
+ The score is the max of: fill overshoot past FILL_MAX, fill undershoot
+ below FILL_MIN when neither axis is in band, and the worst margin
+ shortfall below MARGIN_MIN. Call sites that own a bleed composition
+ compare this to their own cap; they do not ask ``check_framing`` to
+ skip enforcement.
+ """
+ fill = max(res.fill_x, res.fill_y)
+ fill_over = max(0.0, fill - FILL_MAX)
+ in_band = any(FILL_MIN <= f <= FILL_MAX for f in (res.fill_x, res.fill_y))
+ fill_under = 0.0 if (in_band or fill > FILL_MAX) else max(0.0, FILL_MIN - fill)
+ margin_short = max(0.0, MARGIN_MIN - min(res.margins.values()))
+ return max(fill_over, fill_under, margin_short)
+
+
+def measure_framing_deviation(scene, camera, hero, elements, stage=(),
+ strategy=DEFAULT_STRATEGY):
+ """Measure framing and return ``(FramingResult, deviation_score)``.
+
+ Additive: ``check_framing`` still returns 0 or EXIT_FRAMING (10) and
+ is unchanged for callers that ignore this function. Bleed compositions
+ call this, print ``result.report()``, and enforce their own cap.
+ """
+ res = measure_framing(scene, camera, hero, elements, stage=stage, strategy=strategy)
+ return res, deviation_score(res)
+
+
+def check_framing(scene, camera, hero, elements, stage=(), strategy=DEFAULT_STRATEGY):
"""Measure, print the numbers, and gate: 0 pass, EXIT_FRAMING (10) on violation.
Render path only — never call this from an example's check-only path.
-
- deviation — None (default) enforces the band. A non-empty reason string
- documents a legitimate framing deviation (VISUAL-STYLE Layer 1): the
- numbers are still measured and printed with the reason, but the gate
- reports instead of enforcing and always returns 0. An empty or
- whitespace-only reason raises ValueError — no silent opt-outs.
+ Always enforces the Layer 1 band. Compositions that must bleed call
+ ``measure_framing_deviation`` and assert at the call site.
"""
- if deviation is not None and not str(deviation).strip():
- raise ValueError(
- "check_framing deviation requires a non-empty reason string; "
- "a framing deviation cannot be taken silently"
- )
res = measure_framing(scene, camera, hero, elements, stage=stage, strategy=strategy)
print(res.report())
- if deviation is not None:
- print(
- f'framing_deviation reason="{str(deviation).strip()}" '
- f"enforcement=report fill_ok={res.fill_ok} "
- f"margins_ok={res.margins_ok} (numbers printed, gate not enforced)"
- )
- return 0
if not res.ok:
print(
"ERROR: framing violation — Layer 1 requires fill "
diff --git a/examples/modular-kit-snap/README.md b/examples/modular-kit-snap/README.md
index 985a3a5..331e516 100644
--- a/examples/modular-kit-snap/README.md
+++ b/examples/modular-kit-snap/README.md
@@ -70,10 +70,11 @@ needed.
**Framing deviation:** the still is an interior corridor run — the envelope
surrounds the camera on five sides and the tiling joints are the proof, so
-the subject reads as extending past the frame (the radiating-architecture
-class of VISUAL-STYLE Layer 1). `check_framing` measures and reports
-(fill 1.000/1.000, all-edge bleed) without enforcing, with the reason
-string at the call site.
+the subject reads as extending past the frame. The helper measures;
+this example enforces a projection-space fill-over cap (`FRAME_DEV_MAX =
+8.0`). Gallery camera scores ~5.06 (pass). `--close-camera` scores ~30.9
+and exits 10. Silhouette saturates at fill 1.0, so the cap is measured
+with `strategy="projection"`.
## Run
@@ -81,13 +82,14 @@ string at the call site.
blender --background --python modular_kit_snap.py --
blender --background --python modular_kit_snap.py -- --output corridor.png
blender --background --python modular_kit_snap.py -- --falsify seams.png
+blender --background --python modular_kit_snap.py -- --output close.png --close-camera
```
## Exit codes
Per-script sequential checks. `9` is a valid check code; there is no rule
-against it. `10` is the shared framing helper. `11` is the shared asset-quality
-helper.
+against it. `10` is this example's framing-deviation cap (call site, not
+the shared Layer 1 helper). `11` is the shared asset-quality helper.
| Code | Meaning |
| --- | --- |
@@ -101,8 +103,9 @@ helper.
| 7 | Boundary edge count, non-manifold edges, or rim off the end planes |
| 8 | Detail part not watertight |
| 9 | Detail part reaches a tile boundary; also `--output` produced no file |
+| 10 | Framing deviation score exceeds cap (`--close-camera`) |
| 11 | Kit part count, unapplied scale, namespace, or origin; also gallery asset-quality violation |
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
-Smoke does not pass `--output` or `--falsify`.
+Smoke does not pass `--output`, `--falsify`, or `--close-camera`.
diff --git a/examples/modular-kit-snap/modular_kit_snap.py b/examples/modular-kit-snap/modular_kit_snap.py
index 8924325..54654d5 100644
--- a/examples/modular-kit-snap/modular_kit_snap.py
+++ b/examples/modular-kit-snap/modular_kit_snap.py
@@ -39,6 +39,7 @@
blender --background --python modular_kit_snap.py -- # check only
blender --background --python modular_kit_snap.py -- --output k.png # + render
blender --background --python modular_kit_snap.py -- --falsify f.png # seam variant
+ blender --background --python modular_kit_snap.py -- --output c.png --close-camera # framing 10
"""
import bpy, bmesh, sys, os, math, argparse
@@ -61,6 +62,12 @@
EXPECT_PARTS = 21 # shell + 20 detail parts; asserted before the
# per-part loops below, which assert nothing on
# an empty dict
+# Projection-space Layer 1 deviation cap. The corridor is supposed to bleed
+# (measured fill_over ~5.06 on the gallery camera). --close-camera pushes
+# fill_over to ~30. Helper measures; this example enforces.
+FRAME_DEV_MAX = 8.0
+CLOSE_CAM_LOC = (-0.15, -0.08, 1.50)
+CLOSE_CAM_LENS = 16.0
# Hollow-rectangle profile (y, z): outer shell corners then inner bore corners,
# ordered as one continuous ring so the extrusion's side faces come out quads.
@@ -547,7 +554,7 @@ def build_bulkhead(sc):
return parts
-def render_still(path, engine, falsify=False):
+def render_still(path, engine, falsify=False, close_camera=False):
"""A four-segment run. Snapped: joints vanish. Falsified: each segment
accumulates a 120 mm x gap, alternating 50 mm y jogs, and 40 mm floor
steps, so every joint reads as a seam — the render-scale exaggeration of
@@ -572,9 +579,9 @@ def render_still(path, engine, falsify=False):
bulkhead = build_bulkhead(sc)
cam_data = bpy.data.cameras.new("Cam")
- cam_data.lens = 25.0
+ cam_data.lens = CLOSE_CAM_LENS if close_camera else 25.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (-0.7, -0.42, 1.58)
+ cam.location = CLOSE_CAM_LOC if close_camera else (-0.7, -0.42, 1.58)
sc.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim", None)
aim.location = (10.0, 0.35, 1.25)
@@ -601,19 +608,25 @@ def render_still(path, engine, falsify=False):
sc.render.filepath = path
# Standard, always — AgX would lift the stage toward grey (VISUAL-STYLE)
sc.view_settings.view_transform = "Standard"
- # Layer 1 framing gate. The corridor surrounds the camera on five sides
- # and reads as extending past the frame — a documented deviation class
- # (radiating architecture), measured and reported by the helper.
- fcode = gallery_framing.check_framing(
+ # The corridor surrounds the camera — Layer 1 fill/margin will fail, and
+ # that is the design. The helper only measures; this example owns the
+ # cap. Projection so the bleed can grow past 1.0 (silhouette saturates).
+ res, score = gallery_framing.measure_framing_deviation(
sc, cam,
hero=all_parts,
elements=all_parts + bulkhead,
stage=[floor, wall],
- deviation="interior corridor run: the envelope surrounds the camera "
- "and the tiling joints are the proof; edge bleed is the design",
+ strategy="projection",
)
- if fcode:
- return fcode
+ print(res.report())
+ print(f"framing_deviation score={score:.4f} cap={FRAME_DEV_MAX:.4f}")
+ if score > FRAME_DEV_MAX:
+ print(
+ f"ERROR: framing deviation {score:.4f} exceeds cap {FRAME_DEV_MAX:.4f} "
+ "(--close-camera is the designed fail)",
+ file=sys.stderr,
+ )
+ return 10
aqcode = gallery_asset_quality.check_asset_quality(
sc, cam, hero=aq_hero, stage=[floor, wall])
if aqcode:
@@ -631,6 +644,9 @@ def main():
p.add_argument("--output", default=None, help="optional: render a still PNG here")
p.add_argument("--falsify", default=None,
help="optional: render the unsnapped-joint seam variant here")
+ p.add_argument("--close-camera", action="store_true",
+ help="falsification: push the camera into the corridor so "
+ "framing deviation exceeds the cap (needs --output)")
p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
help="render engine for --output/--falsify (cycles for GPU-less hosts)")
args = p.parse_args(argv)
@@ -642,7 +658,10 @@ def main():
return code
if args.output:
- rcode = render_still(os.path.abspath(args.output), args.engine)
+ rcode = render_still(
+ os.path.abspath(args.output), args.engine,
+ close_camera=args.close_camera,
+ )
if rcode:
return rcode
print(f"rendered still {args.output}")
diff --git a/examples/socket-attach-points/README.md b/examples/socket-attach-points/README.md
index 64b2982..b449ef6 100644
--- a/examples/socket-attach-points/README.md
+++ b/examples/socket-attach-points/README.md
@@ -127,9 +127,10 @@ The `--output` render path additionally gates framing via
`examples/gallery_framing.py` (fill **0.881x**, margins
**0.066/0.053/0.122/0.106**, no edge touched) and the asset floors via
`examples/gallery_asset_quality.py` (32 materials, `edge90` **0.027**, no
-default names). The `--falsify` render is a diagnostic, not a gallery hero, so
-it takes a documented framing deviation — its whole point is that the modules
-leave the frame.
+default names). `--falsify` is a diagnostic, not a gallery hero: it
+measures and prints framing but does not enforce the Layer 1 band —
+modules leave the frame on purpose. `--falsify` and `--probe` still
+exit 0.
## Exit codes
diff --git a/examples/socket-attach-points/socket_attach_points.py b/examples/socket-attach-points/socket_attach_points.py
index 3c08c80..196d66a 100644
--- a/examples/socket-attach-points/socket_attach_points.py
+++ b/examples/socket-attach-points/socket_attach_points.py
@@ -1067,17 +1067,19 @@ def render_still(path, engine, falsify=False):
sc.view_settings.view_transform = "Standard"
bpy.context.view_layer.update()
- # The gallery still is gated; the --falsify diagnostic is not. Its whole
- # point is that the modules fly off the airframe, so measuring it against
- # the Layer 1 band would only ever report the breakage as a framing
- # violation. The numbers are still printed under an explicit reason.
- fcode = gallery_framing.check_framing(
- sc, cam, hero=hero, elements=hero, stage=[floor, wall],
- deviation=("falsification diagnostic: the sockets are deliberately "
- "unparented, so modules leave the frame — this render is "
- "evidence, not a gallery hero") if falsify else None)
- if fcode:
- return fcode
+ # The gallery still is gated by Layer 1. The --falsify diagnostic is not:
+ # modules leave the frame on purpose, so enforcing the band would report
+ # the breakage as a framing violation. Measure and print either way;
+ # enforce only on the gallery path.
+ if falsify:
+ res = gallery_framing.measure_framing(
+ sc, cam, hero=hero, elements=hero, stage=[floor, wall])
+ print(res.report())
+ else:
+ fcode = gallery_framing.check_framing(
+ sc, cam, hero=hero, elements=hero, stage=[floor, wall])
+ if fcode:
+ return fcode
aqcode = gallery_asset_quality.check_asset_quality(sc, cam, hero=hero,
stage=[floor, wall])
if aqcode:
diff --git a/showcase/README.md b/showcase/README.md
index cf414f6..31c3c96 100644
--- a/showcase/README.md
+++ b/showcase/README.md
@@ -40,8 +40,10 @@ entry in `showcase/gallery.json`, and a rendered still.
- **Rendered still and gallery entry.** Showcase pieces are visual by
definition. The pathology / sidecar exemption does not apply. Call
`examples/gallery_framing.check_framing` on the `--output` path only.
- Do not pass `deviation=`. Do not move or modify `gallery_framing.py` —
- import it by resolving the repo root (see the shipping-crate script).
+ The `deviation=` parameter is gone; bleed compositions call
+ `measure_framing_deviation` and assert at the call site. Do not move
+ or modify `gallery_framing.py` — import it by resolving the repo root
+ (see the shipping-crate script).
- **Composition.** The README names which shipped skills and snippets the
piece composes. Duplicated helpers stay inlined or copied; showcase
scripts do not import snippets as a package.
diff --git a/showcase/shipping-crate/README.md b/showcase/shipping-crate/README.md
index d3304c5..6d7b715 100644
--- a/showcase/shipping-crate/README.md
+++ b/showcase/shipping-crate/README.md
@@ -51,7 +51,7 @@ Smoke does not pass `--output` or `--skip-decimate`.
## Exit codes
File-local. `9` is a valid check code. `10` is reserved for
-`gallery_framing.check_framing` on the `--output` path (no `deviation=`).
+`gallery_framing.check_framing` on the `--output` path.
| Code | Meaning |
| --- | --- |