Skip to content

Commit a827da0

Browse files
authored
fix: close checks that could pass without asserting anything, plus gallery-infrastructure debt (#120)
* fix: close checks that could pass without asserting anything A falsification probe on vertex-color-ao exited 0 when it should have failed: the depsgraph comparison used zip(src.data, eva.data), which truncates to the shorter sequence, so a resampled attribute read as a clean 0.0 deviation. That is a direct hit on this repository's central claim, so all 44 examples were audited for the same class of defect. Two checks could not fail at all. gltf-export-roundtrip initialised flip_err to 0.0 and derived it from a zip over two accessors whose lengths nothing asserted: deleting the entire TEXCOORD_0 attribute still exited 0. And lightmap-uv-channel asserted "zero overlapping islands" without ever establishing that its SAT detector can find one - widening SAT_EPS blinds the detector, and a blind detector reports zero. The rest are hardening. Pairings whose lengths are part of the claim now assert those lengths instead of letting zip() quietly shorten the comparison (gltf-skin-roundtrip, vertex-weight-limit, vse-cut-list). Checks built entirely from per-element loops now pin their population first, because a loop over an empty collection reports success (modular-kit-snap, socket-attach-points, vertex-color-ao). socket-attach-points additionally asserts that both branches of its up-axis rule are exercised: that assertion compared the socket basis against a re-derivation using the same threshold, so on its own it could not notice the threshold being wrong. Every guard added here was proven to fire, and all 44 checks pass unchanged on 4.5.11 LTS and 5.1.2. Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com> * fix: re-encode gallery WebP that shipped near-lossless Blender's Image.save() ignores its quality argument and keeps the buffer RGBA, so the documented recipe was writing near-lossless files. Four assets shipped that way, totalling 3.95 MB; encoded from the same pixels with Pillow they total 85 KB. Whole-gallery image weight drops from 5.36 MB to 1.50 MB. Mean absolute deviation from the committed originals is about 1/255 with under 0.3% of pixels differing by more than 8/255 - ordinary q85 loss, including on the lightmap atlas board, which is the worst case here (thin saturated island strips on black). No example was re-rendered; this is an encoding change only. Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com> * feat: framing gate reports how far off, not just that it failed The gate printed FAIL without a magnitude, so converging into the band cost a render per guess - nine of twenty-seven renders in the last example run went on that. On-screen extent is very nearly inversely proportional to camera distance, so the measured-to-target fill ratio is a usable one-step correction, and margin shortfalls can be named per edge. Enforcement is unchanged; this is diagnostic output. The suggestion is labelled as a lower bound when the silhouette leaves the frame, because fill saturates at 1.000 there and the true extent is unknowable from the measurement. Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com> * fix: report zero-area faces instead of raising on them measure_edge90 called Vector.angle on face normals without checking length, and a zero-area face has a zero-length normal, so an asset with one lost every other measurement to a ValueError. A degenerate face is a real defect worth hearing about; the count is now reported in the aq_edge90 line and those edges are skipped rather than crashing the gate. Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com> * docs: correct the WebP recipe, Playwright guidance, and stale counts The WebP recipe told authors to use a Blender call that silently ignores quality; it now specifies Pillow and gives a size sanity floor. The Playwright note recommended full-page capture as the workaround for lazy-loaded gallery images. That does not work: on the 44-card grid a fullPage capture renders every card blank even with images verified loaded. Replaced with the method that does work - force eager, scroll into view, viewport capture - and the failing advice is called out rather than quietly dropped. The asset floors get an honest status note: no floor has ever bound on a shipped asset, and one first draft passed all three and was then judged bad by eye and rebuilt. They are a filter for obvious failures; the asset sheet decides. Example counts in CLAUDE.md and AGENTS.md said 40 against a real 44, taken from the manifest. ROADMAP records that asset sheets committed before the neutral-rig camera fix used mis-scaled panels and are not comparable. Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com> --------- Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com>
1 parent fc7ae87 commit a827da0

26 files changed

Lines changed: 336 additions & 17 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ a `.cursor-plugin/plugin.json` manifest so the ecosystem drift checker
2020
classifies it as a `cursor-plugin`. This is content the AI loads when the user
2121
asks Blender questions or works on Blender add-ons in Cursor or Claude Code.
2222

23-
The content base is 12 skills, 6 rules, 2 templates, 17 snippets, and 40
23+
The content base is 12 skills, 6 rules, 2 templates, 17 snippets, and 44
2424
examples (counts are CI-enforced against README.md and the manifest). The full
2525
inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy
2626
and authoring rules: copy `examples/bmesh-gear/`; the render look is specified

‎CLAUDE.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ skills/<skill-name>/SKILL.md - AI workflow definitions, 12 total
2121
rules/<rule-name>.mdc - Anti-pattern rules, 6 total
2222
templates/<template-name>/ - Starter projects, 2 total
2323
snippets/<snippet-name>.py - Standalone code patterns, 17 total
24-
examples/<name>/ - Runnable smoke-gated examples, 40 total (+ gallery.json)
24+
examples/<name>/ - Runnable smoke-gated examples, 44 total (+ gallery.json)
2525
scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only)
2626
scripts/site/ - Vendored landing-page build (Jinja2)
2727
docs/gallery/ - Committed generated gallery pages + hero renders
@@ -82,11 +82,11 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh
8282

8383
v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`.
8484

85-
## Examples (40)
85+
## Examples (44)
8686

8787
Runnable scripts at `examples/<name>/`, each asserting a real API contract with
8888
deterministic checks (exit non-zero on failure) and optionally rendering a still via
89-
`--output`. All forty run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`;
89+
`--output`. All of them run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`;
9090
their renders ship in the site gallery at `docs/gallery/`. `examples/gallery.json` is the
9191
gallery's source of truth. When authoring a new one, copy the anatomy of
9292
`examples/bmesh-gear/` (script structure, README shape, dark-studio render recipe) and
@@ -127,7 +127,7 @@ Stage with **explicit paths only** — never `git add -A` or `git add .`. Cursor
127127
- **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.
128128
- **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.
129129
- **After gallery regeneration** (`python scripts/build_gallery.py`), read the **generated HTML** character by character — the `<img alt>` text and witnesses callouts in `docs/gallery/index.html` and `docs/gallery/<name>/index.html` — not just `examples/gallery.json`. Precedent: the `teaches.split(".")[0]` bug truncated 14/21 card alts at dotted API paths like `bmesh.ops` while the source JSON looked fine (fixed in PR #68).
130-
- **Playwright gallery captures:** gallery `<img>` tags lazy-load, and Chromium scroll-anchoring defeats `scrollTo(0, 0)` after images load — take full-page captures and crop locally, not scroll-then-shoot.
130+
- **Playwright gallery captures:** gallery `<img>` tags lazy-load, so force them first (`document.querySelectorAll('img').forEach(i => i.loading = 'eager')`, then wait). **Scroll the target card into view and take a viewport capture** — `scrollIntoView({block:'center', behavior:'instant'})`, short wait, `browser_take_screenshot` with `fullPage` omitted. A `fullPage` capture is NOT a workaround: on a tall gallery page it renders every card image blank even when the images are verified loaded (`complete === true`, `naturalWidth === 1280`, `opacity === 1`) — measured on the 44-card grid at 1425x4516. Verify load state via `browser_evaluate` rather than trusting the pixels.
131131

132132
## Example-Run Process
133133

‎ROADMAP.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,16 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
133133

134134
- ~~Gallery framing recomposition worklist~~ **DRAINED**: all four recomposition cases restaged and gated with `check_framing` — depsgraph-export (0.984x/touch→0.812x; cube+dome recentered, aim-based camera), vertex-weight-limit (touch B→0.867y; more frontal camera, lower aim, base in frame), triangulate-tangents (0.334x→0.833y; 2x-class move-in plus Key/Glint trim that also cleared a pre-existing dome blowout), text-version-stamp (0.663x→0.719x; moved in with aim on the stamp center, dead third gone). The seven marginal cases were camera-nudged in the preceding sweep; deviations documented in #108 (damped-track-aim, wave-displace); sky-texture-sun-elevation is a measurement artifact (world subject — report-only if wired). Remaining: nothing on the framing worklist — future examples inherit the gate at authoring time.
135135

136+
## Asset sheets committed before 2026-07-25 are not comparable
137+
138+
The neutral asset-sheet harness sized its camera from the subject's bounding
139+
box **diagonal**, which cropped tall subjects and shrank wide ones, so panels
140+
in one sheet were at different effective scales. It now solves the required
141+
distance from the real field of view on both axes. Sheets committed before
142+
this fix (`modular-kit-snap`, `lightmap-uv-channel`) show mis-scaled panels
143+
and must not be cited as evidence for or against an asset; re-render them if
144+
that comparison is ever needed.
145+
136146
## Asset-quality survey worklist (measured 2026-07-24, `gallery_asset_quality` floors)
137147

138148
Full-gallery floor survey after the gate landed (`feat/asset-quality-gate`).

‎docs/VISUAL-STYLE.md‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,23 @@ hand-tuned to pass everything is not one either:
157157
assignment, invisible to a per-part share (both measure 1.0 dominant).
158158
Printed as information only.
159159

160+
### Status: what the floors have actually caught
161+
162+
Recorded honestly so nobody mistakes a floor pass for a quality verdict.
163+
**No floor has ever bound on a shipped asset.** Every example that reached
164+
the asset sheet had already cleared naming, material variation and edge
165+
treatment. More pointedly, `socket-attach-points` passed all three on its
166+
first draft (`edge90` 0.000, 10 materials, no default names) — a model that
167+
was then judged bad by eye and rebuilt from scratch. The floors scored the
168+
bevels, not the design.
169+
170+
Treat them as a crude filter for obvious failures — a raw box, an unnamed
171+
`Cube`, one flat slot across a whole prop — and nothing more. The asset
172+
sheet is the gate that decides. Their one confirmed catch to date was
173+
indirect: `measure_edge90` hit a zero-area face from an over-large bevel.
174+
It used to raise `ValueError` there; it now reports `degenerate_faces` in
175+
the line, which is the more useful signal.
176+
160177
### The Goodhart warning
161178

162179
These floors are necessary, not sufficient. Bolting meaningless greebles
@@ -185,9 +202,23 @@ model, and this gate removes the scene.
185202
## Output
186203

187204
- Render 1280×720 PNG (`taa_render_samples`/`cycles.samples` 32–64).
188-
- Gallery assets: hero webp 1280×720 and preview webp 1200×675, quality 85
189-
(load the PNG in Blender, `save(filepath=..., quality=85)`, `scale()` for
190-
the preview — this preserves pixels without re-applying color management).
205+
- Gallery assets: hero webp 1280×720 and preview webp 1200×675, quality 85.
206+
**Encode with Pillow, not with Blender's `Image.save()`.** The Blender path
207+
keeps the buffer RGBA and ignores the `quality` argument, writing a
208+
near-lossless file: measured 991 KB for a hero that Pillow writes at 19 KB
209+
from the same PNG, and two heroes shipped at 1.15 MB and 984 KB before this
210+
was caught. Reading the rendered PNG and writing WebP does not re-apply
211+
colour management either way, so the pixels are equally faithful:
212+
213+
```python
214+
from PIL import Image
215+
im = Image.open(png).convert("RGB") # drop the unused alpha
216+
im.save(hero_webp, quality=85, method=6)
217+
im.resize((1200, 675), Image.LANCZOS).save(preview_webp, quality=85, method=6)
218+
```
219+
220+
Sanity floor: a 1280×720 hero should land in the tens of kilobytes. Anything
221+
over ~200 KB means the alpha channel survived and the encode went lossless.
191222
- After touching any example: `python scripts/build_gallery.py`, and update
192223
the README gallery-row alt text if the composition changed.
193224

-942 KB
Loading
-1.07 MB
Loading

‎docs/gallery/gltf-export-roundtrip/index.html‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ <h2>Source</h2>
379379
NRM_TOL = <span class="n">2e-4</span>
380380
UV_TOL = <span class="n">3e-5</span>
381381
UNIT_TOL = <span class="n">1e-3</span> <span class="c"># |length(n) - 1| for re-imported loop normals</span>
382+
UV_SAMPLE = <span class="n">32</span> <span class="c"># loops sampled for the V-flip check; asserted, not assumed</span>
382383
KEY_DIGITS = <span class="n">4</span> <span class="c"># position-key grid for attribute lookups</span>
383384

384385

@@ -624,12 +625,32 @@ <h2>Source</h2>
624625
disk_pos = acc_floats(prim0[<span class="s">&quot;POSITION&quot;</span>], <span class="n">3</span>)
625626
disk_uv = acc_floats(prim0[<span class="s">&quot;TEXCOORD_0&quot;</span>], <span class="n">2</span>)
626627
uv_set = set_map(snap[<span class="s">&quot;loops&quot;</span>], snap[<span class="s">&quot;uvs&quot;</span>])
628+
<span class="c"># The comparison below is only evidence if it actually runs on a known</span>
629+
<span class="c"># number of pairs. zip() truncates to the shorter sequence, so without</span>
630+
<span class="c"># these guards a primitive that carried no TEXCOORD_0 at all would</span>
631+
<span class="c"># leave flip_err at its initial 0.0 and pass. Assert the pairing and</span>
632+
<span class="c"># count the comparisons, then require the count.</span>
633+
<span class="k">if</span> len(disk_uv) != len(disk_pos):
634+
print(<span class="s">f&quot;</span><span class="s">ERROR: prim0 TEXCOORD_0 count </span>{len(disk_uv)}<span class="s"> != POSITION </span><span class="s">&quot;</span>
635+
<span class="s">f&quot;</span><span class="s">count </span>{len(disk_pos)}<span class="s"> - the UV attribute does not cover </span><span class="s">&quot;</span>
636+
<span class="s">&quot;the primitive&quot;</span>, file=sys.stderr)
637+
<span class="k">return</span> <span class="n">11</span>
638+
<span class="k">if</span> len(disk_pos) &lt; UV_SAMPLE:
639+
print(<span class="s">f&quot;</span><span class="s">ERROR: prim0 carries only </span>{len(disk_pos)}<span class="s"> loops, fewer than </span><span class="s">&quot;</span>
640+
<span class="s">f&quot;</span><span class="s">the </span>{UV_SAMPLE}<span class="s"> the V-flip check samples</span><span class="s">&quot;</span>, file=sys.stderr)
641+
<span class="k">return</span> <span class="n">11</span>
627642
flip_err = <span class="n">0.0</span>
628-
<span class="k">for</span> pd, (u, v) <span class="k">in</span> zip(disk_pos[:<span class="n">32</span>], disk_uv[:<span class="n">32</span>]):
643+
sampled = <span class="n">0</span>
644+
<span class="k">for</span> pd, (u, v) <span class="k">in</span> zip(disk_pos[:UV_SAMPLE], disk_uv[:UV_SAMPLE]):
629645
pb = (pd[<span class="n">0</span>], -pd[<span class="n">2</span>], pd[<span class="n">1</span>]) <span class="c"># glTF (x, z, -y) -&gt; Blender (x, y, z)</span>
630646
best = min((max(abs(u - e[<span class="n">0</span>]), abs((<span class="n">1.0</span> - v) - e[<span class="n">1</span>]))
631647
<span class="k">for</span> e <span class="k">in</span> candidates(uv_set, pb)), default=<span class="n">1e30</span>)
632648
flip_err = max(flip_err, best)
649+
sampled += <span class="n">1</span>
650+
<span class="k">if</span> sampled != UV_SAMPLE:
651+
print(<span class="s">f&quot;</span><span class="s">ERROR: V-flip check compared </span>{sampled}<span class="s"> loops, expected </span><span class="s">&quot;</span>
652+
<span class="s">f&quot;</span>{UV_SAMPLE}<span class="s">&quot;</span>, file=sys.stderr)
653+
<span class="k">return</span> <span class="n">11</span>
633654
<span class="k">if</span> flip_err &gt; UV_TOL:
634655
print(<span class="s">f&quot;</span><span class="s">ERROR: on-disk UVs deviate </span>{flip_err:<span class="s">.3e</span>}<span class="s"> from the V-flipped </span><span class="s">&quot;</span>
635656
<span class="s">&quot;authored layout (glTF texture origin is top-left)&quot;</span>, file=sys.stderr)

‎docs/gallery/gltf-skin-roundtrip/index.html‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,12 @@ <h2>Source</h2>
599599
a = p[<span class="s">&quot;attributes&quot;</span>]
600600
w = acc_f(a[<span class="s">&quot;WEIGHTS_0&quot;</span>], <span class="n">4</span>)
601601
j = acc_u(a[<span class="s">&quot;JOINTS_0&quot;</span>], <span class="n">4</span>, g[<span class="s">&quot;accessors&quot;</span>][a[<span class="s">&quot;JOINTS_0&quot;</span>]][<span class="s">&quot;componentType&quot;</span>])
602+
<span class="c"># JOINTS_0 and WEIGHTS_0 must cover the same vertices; zip() would</span>
603+
<span class="c"># otherwise silently score only the shorter accessor</span>
604+
<span class="k">if</span> len(w) != len(j):
605+
print(<span class="s">f&quot;</span><span class="s">ERROR: WEIGHTS_0 count </span>{len(w)}<span class="s"> != JOINTS_0 count </span><span class="s">&quot;</span>
606+
<span class="s">f&quot;</span>{len(j)}<span class="s"> on a primitive</span><span class="s">&quot;</span>, file=sys.stderr)
607+
<span class="k">return</span> <span class="n">7</span>
602608
disk_verts += len(w)
603609
<span class="k">for</span> ws, js <span class="k">in</span> zip(w, j):
604610
total = sum(x <span class="k">for</span> x, ji <span class="k">in</span> zip(ws, js) <span class="k">if</span> ji &lt; len(BONES))

‎docs/gallery/lightmap-uv-channel/index.html‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,28 @@ <h2>Source</h2>
781781
print(<span class="s">f&quot;</span><span class="s">ERROR (</span>{code}<span class="s">): </span>{msg}<span class="s">&quot;</span>, file=sys.stderr)
782782
fails.append(code)
783783

784+
<span class="c"># --- 0. positive control for the overlap detector -----------------------</span>
785+
<span class="c"># &quot;0 overlapping pairs&quot; is only evidence if the detector can find one. A</span>
786+
<span class="c"># blinded detector reports zero and the whole check passes: widening</span>
787+
<span class="c"># SAT_EPS to 1e9 makes every axis test read as separated, and this example</span>
788+
<span class="c"># still exited 0 before this control existed. Prove sensitivity and</span>
789+
<span class="c"># strictness on hand-built triangles before trusting the scan.</span>
790+
_overlapping = (((<span class="n">0.0</span>, <span class="n">0.0</span>), (<span class="n">1.0</span>, <span class="n">0.0</span>), (<span class="n">0.0</span>, <span class="n">1.0</span>)),
791+
((<span class="n">0.2</span>, <span class="n">0.2</span>), (<span class="n">1.2</span>, <span class="n">0.2</span>), (<span class="n">0.2</span>, <span class="n">1.2</span>)))
792+
_disjoint = (((<span class="n">0.0</span>, <span class="n">0.0</span>), (<span class="n">1.0</span>, <span class="n">0.0</span>), (<span class="n">0.0</span>, <span class="n">1.0</span>)),
793+
((<span class="n">5.0</span>, <span class="n">5.0</span>), (<span class="n">6.0</span>, <span class="n">5.0</span>), (<span class="n">5.0</span>, <span class="n">6.0</span>)))
794+
_touching = (((<span class="n">0.0</span>, <span class="n">0.0</span>), (<span class="n">1.0</span>, <span class="n">0.0</span>), (<span class="n">0.0</span>, <span class="n">1.0</span>)),
795+
((<span class="n">1.0</span>, <span class="n">0.0</span>), (<span class="n">2.0</span>, <span class="n">0.0</span>), (<span class="n">1.0</span>, <span class="n">1.0</span>)))
796+
ctl = (_tri_overlap(*_overlapping), _tri_overlap(*_disjoint),
797+
_tri_overlap(*_touching))
798+
print(<span class="s">f&quot;</span><span class="s">sat_control overlapping=</span>{ctl[<span class="n">0</span>]}<span class="s"> disjoint=</span>{ctl[<span class="n">1</span>]}<span class="s"> touching=</span>{ctl[<span class="n">2</span>]}<span class="s"> </span><span class="s">&quot;</span>
799+
<span class="s">f&quot;</span><span class="s">(want True/False/False)</span><span class="s">&quot;</span>)
800+
<span class="k">if</span> ctl != (<span class="k">True</span>, <span class="k">False</span>, <span class="k">False</span>):
801+
fail(<span class="n">7</span>, <span class="s">f&quot;</span><span class="s">SAT self-test </span>{ctl}<span class="s"> != (True, False, False) — the overlap </span><span class="s">&quot;</span>
802+
<span class="s">f&quot;</span><span class="s">detector cannot detect, so a zero-overlap result proves </span><span class="s">&quot;</span>
803+
<span class="s">f&quot;</span><span class="s">nothing</span><span class="s">&quot;</span>)
804+
<span class="k">return</span> fails[<span class="n">0</span>]
805+
784806
nominal_margin = MARGIN_DIV * <span class="n">0.01</span>
785807
total_islands = <span class="n">0</span>
786808
<span class="k">for</span> suffix, me <span class="k">in</span> meshes.items():

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,9 @@ <h2>Source</h2>
340340
NUDGE = <span class="n">2e-3</span> <span class="c"># unsnapped variant: two verts jogged in y (2 mm)</span>
341341
EXPECT_BOUNDARY_VERTS = <span class="n">16</span> <span class="c"># 8 per open end (hollow-rectangle profile)</span>
342342
EXPECT_BOUNDARY_EDGES = <span class="n">16</span> <span class="c"># 8 per open end</span>
343+
EXPECT_PARTS = <span class="n">21</span> <span class="c"># shell + 20 detail parts; asserted before the</span>
344+
<span class="c"># per-part loops below, which assert nothing on</span>
345+
<span class="c"># an empty dict</span>
343346

344347
<span class="c"># Hollow-rectangle profile (y, z): outer shell corners then inner bore corners,</span>
345348
<span class="c"># ordered as one continuous ring so the extrusion&#x27;s side faces come out quads.</span>
@@ -539,6 +542,11 @@ <h2>Source</h2>
539542
print(<span class="s">f&quot;</span><span class="s">ERROR (</span>{code}<span class="s">): </span>{msg}<span class="s">&quot;</span>, file=sys.stderr)
540543
fails.append(code)
541544

545+
<span class="k">if</span> len(meshes) != EXPECT_PARTS:
546+
fail(<span class="n">11</span>, <span class="s">f&quot;</span><span class="s">kit built </span>{len(meshes)}<span class="s"> parts, expected </span>{EXPECT_PARTS}<span class="s"> - the </span><span class="s">&quot;</span>
547+
<span class="s">f&quot;</span><span class="s">per-part loops below would iterate over the wrong set</span><span class="s">&quot;</span>)
548+
<span class="k">return</span> fails[<span class="n">0</span>]
549+
542550
<span class="c"># --- 1. snap: 16 boundary verts, all on x in {0, TILE} -----------------</span>
543551
on, off = boundary_verts(shell)
544552
print(<span class="s">f&quot;</span><span class="s">snap boundary_verts=</span>{len(on)}<span class="s"> off_plane=</span>{len(off)}<span class="s"> </span><span class="s">&quot;</span>

0 commit comments

Comments
 (0)