Skip to content

Commit 7d90367

Browse files
feat: enforce framing deviation at the call site (#149)
check_framing no longer accepts deviation=. measure_framing_deviation returns the score so bleed compositions fail at the call site. modular-kit-snap asserts a projection fill-over cap; socket-attach-points drops the flag and leaves --falsify unenforced. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f1d0aa2 commit 7d90367

12 files changed

Lines changed: 160 additions & 102 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ Stage with **explicit paths only** — never `git add -A` or `git add .`. Cursor
140140
## Quality Gates for Example Runs
141141

142142
- `docs/VISUAL-STYLE.md` is the **binding** render standard; deviations are defects.
143-
- **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.
143+
- **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.
144144
- **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.
145145
- **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.
146146
- **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.

‎docs/VISUAL-STYLE.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,11 @@ the default stage.
6868
world or the atmosphere rather than an object on a stage. A deviation
6969
requires one line in the example README under a `Framing deviation`
7070
heading — what the composition requires and why, exactly as stage
71-
deviations do. An undocumented deviation is a defect. Under a documented
72-
deviation the helper reports rather than enforces: call
73-
`check_framing(..., deviation="reason")`, which prints the numbers with
74-
the reason; an empty reason raises, so no deviation is taken silently.
71+
deviations do. An undocumented deviation is a defect. The helper
72+
measures; the call site enforces. `check_framing` always applies the
73+
Layer 1 band (0 / 10). Bleed compositions call
74+
`measure_framing_deviation`, print the numbers, and assert their own
75+
cap with their own exit code. There is no `deviation=` flag.
7576
- Camera: a chosen angle, not the default — typically a 45–55 mm lens,
7677
slightly above subject height, aimed with a `TRACK_TO` constraint at an
7778
empty on the subject. Flat subjects present toward the camera (lean or

‎docs/gallery/modular-kit-snap/index.html‎

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -267,15 +267,16 @@ <h1>modular-kit-snap</h1>
267267
<p><strong>What each check catches on failure</strong> (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 <strong>3.000e-03 m</strong> (exit 3); opposing rings displaced <strong>2.000e-03</strong> (exit 4); tiled joint gap/overlap (exit 5); bbox off the declared tile by <strong>3.000e-03</strong> (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).</p>
268268
<p><strong>Version witness:</strong> check output is byte-identical on Blender 4.5.11 LTS and 5.1.2 — same counts, same zero measured deviations.</p>
269269
<p><strong>Render as proof:</strong> a four-segment run — the joints vanish. The falsification variant (<code>--falsify</code>) 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 <code>examples/gallery_asset_quality.py</code> (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 <code>lightmap-uv-channel</code>&#x27;s warm cart, and mean luminance was brought into the calibration range (82.7 → 66.1, ceiling 77.7) — no luminance deviation needed.</p>
270-
<p><strong>Framing deviation:</strong> 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). <code>check_framing</code> measures and reports (fill 1.000/1.000, all-edge bleed) without enforcing, with the reason string at the call site.</p>
270+
<p><strong>Framing deviation:</strong> 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 (<code>FRAME_DEV_MAX = 8.0</code>). Gallery camera scores ~5.06 (pass). <code>--close-camera</code> scores ~30.9 and exits 10. Silhouette saturates at fill 1.0, so the cap is measured with <code>strategy=&quot;projection&quot;</code>.</p>
271271
<h2>Run</h2>
272272
<pre><code>blender --background --python modular_kit_snap.py --
273273
blender --background --python modular_kit_snap.py -- --output corridor.png
274-
blender --background --python modular_kit_snap.py -- --falsify seams.png</code></pre>
274+
blender --background --python modular_kit_snap.py -- --falsify seams.png
275+
blender --background --python modular_kit_snap.py -- --output close.png --close-camera</code></pre>
275276
<h2>Exit codes</h2>
276-
<p>Per-script sequential checks. <code>9</code> is a valid check code; there is no rule against it. <code>10</code> is the shared framing helper. <code>11</code> is the shared asset-quality helper.</p>
277-
<p>| 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 <code>--output</code> produced no file | | 11 | Kit part count, unapplied scale, namespace, or origin; also gallery asset-quality violation |</p>
278-
<p>The <code>blender-smoke</code> workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the <code>needs-5.1</code> PR label, or manual dispatch). Smoke does not pass <code>--output</code> or <code>--falsify</code>.</p>
277+
<p>Per-script sequential checks. <code>9</code> is a valid check code; there is no rule against it. <code>10</code> is this example&#x27;s framing-deviation cap (call site, not the shared Layer 1 helper). <code>11</code> is the shared asset-quality helper.</p>
278+
<p>| 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 <code>--output</code> produced no file | | 10 | Framing deviation score exceeds cap (<code>--close-camera</code>) | | 11 | Kit part count, unapplied scale, namespace, or origin; also gallery asset-quality violation |</p>
279+
<p>The <code>blender-smoke</code> workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the <code>needs-5.1</code> PR label, or manual dispatch). Smoke does not pass <code>--output</code>, <code>--falsify</code>, or <code>--close-camera</code>.</p>
279280
</section>
280281
<section class="detail-section src">
281282
<h2>Source</h2>
@@ -324,6 +325,7 @@ <h2>Source</h2>
324325
blender --background --python modular_kit_snap.py -- # check only
325326
blender --background --python modular_kit_snap.py -- --output k.png # + render
326327
blender --background --python modular_kit_snap.py -- --falsify f.png # seam variant
328+
blender --background --python modular_kit_snap.py -- --output c.png --close-camera # framing 10
327329
&quot;&quot;&quot;</span>
328330
<span class="k">import</span> bpy, bmesh, sys, os, math, argparse
329331

@@ -346,6 +348,12 @@ <h2>Source</h2>
346348
EXPECT_PARTS = <span class="n">21</span> <span class="c"># shell + 20 detail parts; asserted before the</span>
347349
<span class="c"># per-part loops below, which assert nothing on</span>
348350
<span class="c"># an empty dict</span>
351+
<span class="c"># Projection-space Layer 1 deviation cap. The corridor is supposed to bleed</span>
352+
<span class="c"># (measured fill_over ~5.06 on the gallery camera). --close-camera pushes</span>
353+
<span class="c"># fill_over to ~30. Helper measures; this example enforces.</span>
354+
FRAME_DEV_MAX = <span class="n">8.0</span>
355+
CLOSE_CAM_LOC = (-<span class="n">0.15</span>, -<span class="n">0.08</span>, <span class="n">1.50</span>)
356+
CLOSE_CAM_LENS = <span class="n">16.0</span>
349357

350358
<span class="c"># Hollow-rectangle profile (y, z): outer shell corners then inner bore corners,</span>
351359
<span class="c"># ordered as one continuous ring so the extrusion&#x27;s side faces come out quads.</span>
@@ -832,7 +840,7 @@ <h2>Source</h2>
832840
<span class="k">return</span> parts
833841

834842

835-
<span class="k">def</span> render_still(path, engine, falsify=<span class="k">False</span>):
843+
<span class="k">def</span> render_still(path, engine, falsify=<span class="k">False</span>, close_camera=<span class="k">False</span>):
836844
<span class="s">&quot;&quot;&quot;A four-segment run. Snapped: joints vanish. Falsified: each segment
837845
accumulates a 120 mm x gap, alternating 50 mm y jogs, and 40 mm floor
838846
steps, so every joint reads as a seam — the render-scale exaggeration of
@@ -857,9 +865,9 @@ <h2>Source</h2>
857865
bulkhead = build_bulkhead(sc)
858866

859867
cam_data = bpy.data.cameras.new(<span class="s">&quot;Cam&quot;</span>)
860-
cam_data.lens = <span class="n">25.0</span>
868+
cam_data.lens = CLOSE_CAM_LENS <span class="k">if</span> close_camera <span class="k">else</span> <span class="n">25.0</span>
861869
cam = bpy.data.objects.new(<span class="s">&quot;Cam&quot;</span>, cam_data)
862-
cam.location = (-<span class="n">0.7</span>, -<span class="n">0.42</span>, <span class="n">1.58</span>)
870+
cam.location = CLOSE_CAM_LOC <span class="k">if</span> close_camera <span class="k">else</span> (-<span class="n">0.7</span>, -<span class="n">0.42</span>, <span class="n">1.58</span>)
863871
sc.collection.objects.link(cam)
864872
aim = bpy.data.objects.new(<span class="s">&quot;Aim&quot;</span>, <span class="k">None</span>)
865873
aim.location = (<span class="n">10.0</span>, <span class="n">0.35</span>, <span class="n">1.25</span>)
@@ -886,19 +894,25 @@ <h2>Source</h2>
886894
sc.render.filepath = path
887895
<span class="c"># Standard, always — AgX would lift the stage toward grey (VISUAL-STYLE)</span>
888896
sc.view_settings.view_transform = <span class="s">&quot;Standard&quot;</span>
889-
<span class="c"># Layer 1 framing gate. The corridor surrounds the camera on five sides</span>
890-
<span class="c"># and reads as extending past the frame — a documented deviation class</span>
891-
<span class="c"># (radiating architecture), measured and reported by the helper.</span>
892-
fcode = gallery_framing.check_framing(
897+
<span class="c"># The corridor surrounds the camera — Layer 1 fill/margin will fail, and</span>
898+
<span class="c"># that is the design. The helper only measures; this example owns the</span>
899+
<span class="c"># cap. Projection so the bleed can grow past 1.0 (silhouette saturates).</span>
900+
res, score = gallery_framing.measure_framing_deviation(
893901
sc, cam,
894902
hero=all_parts,
895903
elements=all_parts + bulkhead,
896904
stage=[floor, wall],
897-
deviation=<span class="s">&quot;interior corridor run: the envelope surrounds the camera &quot;</span>
898-
<span class="s">&quot;and the tiling joints are the proof; edge bleed is the design&quot;</span>,
905+
strategy=<span class="s">&quot;projection&quot;</span>,
899906
)
900-
<span class="k">if</span> fcode:
901-
<span class="k">return</span> fcode
907+
print(res.report())
908+
print(<span class="s">f&quot;</span><span class="s">framing_deviation score=</span>{score:<span class="s">.4f</span>}<span class="s"> cap=</span>{FRAME_DEV_MAX:<span class="s">.4f</span>}<span class="s">&quot;</span>)
909+
<span class="k">if</span> score &gt; FRAME_DEV_MAX:
910+
print(
911+
<span class="s">f&quot;</span><span class="s">ERROR: framing deviation </span>{score:<span class="s">.4f</span>}<span class="s"> exceeds cap </span>{FRAME_DEV_MAX:<span class="s">.4f</span>}<span class="s"> </span><span class="s">&quot;</span>
912+
<span class="s">&quot;(--close-camera is the designed fail)&quot;</span>,
913+
file=sys.stderr,
914+
)
915+
<span class="k">return</span> <span class="n">10</span>
902916
aqcode = gallery_asset_quality.check_asset_quality(
903917
sc, cam, hero=aq_hero, stage=[floor, wall])
904918
<span class="k">if</span> aqcode:
@@ -916,6 +930,9 @@ <h2>Source</h2>
916930
p.add_argument(<span class="s">&quot;--output&quot;</span>, default=<span class="k">None</span>, help=<span class="s">&quot;optional: render a still PNG here&quot;</span>)
917931
p.add_argument(<span class="s">&quot;--falsify&quot;</span>, default=<span class="k">None</span>,
918932
help=<span class="s">&quot;optional: render the unsnapped-joint seam variant here&quot;</span>)
933+
p.add_argument(<span class="s">&quot;--close-camera&quot;</span>, action=<span class="s">&quot;store_true&quot;</span>,
934+
help=<span class="s">&quot;falsification: push the camera into the corridor so &quot;</span>
935+
<span class="s">&quot;framing deviation exceeds the cap (needs --output)&quot;</span>)
919936
p.add_argument(<span class="s">&quot;--engine&quot;</span>, default=<span class="s">&quot;eevee&quot;</span>, choices=(<span class="s">&quot;eevee&quot;</span>, <span class="s">&quot;cycles&quot;</span>),
920937
help=<span class="s">&quot;render engine for --output/--falsify (cycles for GPU-less hosts)&quot;</span>)
921938
args = p.parse_args(argv)
@@ -927,7 +944,10 @@ <h2>Source</h2>
927944
<span class="k">return</span> code
928945

929946
<span class="k">if</span> args.output:
930-
rcode = render_still(os.path.abspath(args.output), args.engine)
947+
rcode = render_still(
948+
os.path.abspath(args.output), args.engine,
949+
close_camera=args.close_camera,
950+
)
931951
<span class="k">if</span> rcode:
932952
<span class="k">return</span> rcode
933953
print(<span class="s">f&quot;</span><span class="s">rendered still </span>{args.output}<span class="s">&quot;</span>)

‎docs/gallery/shipping-crate/index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ <h2>Run</h2>
273273
blender --background --python shipping_crate.py -- --output crate.png</code></pre>
274274
<p>Smoke does not pass <code>--output</code> or <code>--skip-decimate</code>.</p>
275275
<h2>Exit codes</h2>
276-
<p>File-local. <code>9</code> is a valid check code. <code>10</code> is reserved for <code>gallery_framing.check_framing</code> on the <code>--output</code> path (no <code>deviation=</code>).</p>
276+
<p>File-local. <code>9</code> is a valid check code. <code>10</code> is reserved for <code>gallery_framing.check_framing</code> on the <code>--output</code> path.</p>
277277
<p>| 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 (<code>--skip-decimate</code> 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 | <code>--output</code> produced no file |</p>
278278
</section>
279279
<section class="detail-section src">

0 commit comments

Comments
 (0)