From eb03556cc3aecb0382abbac3a2755e492ac795c6 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Sat, 25 Jul 2026 10:08:33 -0400 Subject: [PATCH] feat: add vertex-color-ao example (baked occlusion in a colour attribute) Most bakes can only be regression-tested against a previous run. This one cannot drift silently, because the hemisphere visibility integral has closed forms: an unoccluded surface integrates to exactly 1, and a point a distance d from an infinitely wide wall of height H integrates to 1 - 0.5(1 - 1/sqrt(1+k^2)) with k = H/d. The check holds a deterministic cosine-weighted integrator to that formula across two decades of k, then runs the same integrator over the asset that ships, so what is in the attribute is the thing that was measured. The storage half is the trap worth shipping. BYTE_COLOR is not linear 8-bit, it is sRGB-encoded 8-bit: a linear 0.735 reads back 0.7379107. The check reproduces the readback with an independent encode/quantise/decode model rather than asserting a captured constant, so an exporter handing raw bytes to an engine expecting linear occlusion is caught by construction. Two authoring hazards are recorded with the geometry that provoked them: bmesh.ops.bevel offsets along cached face normals and flips outward once a transform leaves them more than 90 degrees stale, and point-domain AO needs subdivision or the crevice gradient never reaches the attribute at all. The depsgraph check originally compared zip(src.data, eva.data), which stops at the shorter sequence; a Subdivision modifier resampling the attribute reported a clean 0.0 deviation and the falsification probe exited 0. The length guard exists because the probe failed to fail. Signed-off-by: fOuttaMyPaint Signed-off-by: fOuttaMyPaint --- .cursor-plugin/plugin.json | 1 + .github/workflows/blender-smoke.yml | 15 + README.md | 25 +- ROADMAP.md | 1 + .../gallery/asset-sheets/vertex-color-ao.webp | Bin 0 -> 33736 bytes docs/gallery/assets/vertex-color-ao-hero.webp | Bin 0 -> 18212 bytes .../vertex-color-ao-contact-sheet.webp | Bin 0 -> 46720 bytes docs/gallery/index.html | 13 +- docs/gallery/vertex-color-ao/index.html | 1269 +++++++++++++++++ examples/gallery.json | 14 + examples/vertex-color-ao/README.md | 124 ++ examples/vertex-color-ao/preview.webp | Bin 0 -> 16794 bytes examples/vertex-color-ao/vertex_color_ao.py | 942 ++++++++++++ 13 files changed, 2401 insertions(+), 3 deletions(-) create mode 100644 docs/gallery/asset-sheets/vertex-color-ao.webp create mode 100644 docs/gallery/assets/vertex-color-ao-hero.webp create mode 100644 docs/gallery/contact-sheets/vertex-color-ao-contact-sheet.webp create mode 100644 docs/gallery/vertex-color-ao/index.html create mode 100644 examples/vertex-color-ao/README.md create mode 100644 examples/vertex-color-ao/preview.webp create mode 100644 examples/vertex-color-ao/vertex_color_ao.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 14e55ac..32eefa0 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -98,6 +98,7 @@ "examples/triangulate-tangents", "examples/turntable", "examples/uv-layer-grid", + "examples/vertex-color-ao", "examples/vertex-weight-limit", "examples/vse-cut-list", "examples/vse-gamma-cross", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index e26c63a..f698d17 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -609,3 +609,18 @@ jobs: # root scale down into the children. Exits non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/socket-attach-points/socket_attach_points.py -- + + - name: Shipped example - vertex colour AO (baked occlusion attribute) + run: | + set -euo pipefail + # Check only (no render): a deterministic cosine-weighted hemisphere + # AO integrator validated against the closed form + # 1 - 0.5(1 - 1/sqrt(1+k^2)) at six probe distances, an unoccluded + # plate baking to exactly 1.0, strict monotonicity with distance, + # then the same integrator run over an 11-part stone well: values in + # [0,1] with real spread, FLOAT_COLOR exact round-trip vs BYTE_COLOR + # matching an independent sRGB quantisation model, both attributes + # surviving depsgraph evaluation at deviation 0 with element counts + # intact, and reuse hygiene. Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/vertex-color-ao/vertex_color_ao.py -- diff --git a/README.md b/README.md index a30d389..ed7be3c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  43 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  44 examples

@@ -36,7 +36,7 @@ ## Overview -This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 43 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. +This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 44 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. The content is consumed by AI coding agents (Cursor, Claude Code, any MCP-capable client) when working on Blender add-ons, geometry nodes scripts, batch pipelines, or animation tooling. There is no build step. Edit the markdown and Python files directly. @@ -843,6 +843,27 @@ Companion to [`prop-origin-transform`](examples/prop-origin-transform/) [`parent-inverse-orrery`](examples/parent-inverse-orrery/) (parent-inverse under animation). + + + + +Vertex colour AO: a stone village well on a dark studio stage, its masonry joints, shaft mouth and coping undersides darkened by ambient occlusion baked into a colour attribute rather than by the lights + + + +### [vertex-color-ao](examples/vertex-color-ao/) + +Baked occlusion in a colour attribute, checked against a **formula** instead +of a captured value: the cosine-weighted hemisphere integral for a wall of +height `H` at distance `d` is `1 - ½(1 - 1/√(1+k²))`, `k = H/d`, and the +integrator matches it to **6.760e-04** across two decades of `k`. An +unoccluded plate bakes to exactly **1.0**. `FLOAT_COLOR` round-trips exactly +while **`BYTE_COLOR` is sRGB-encoded 8-bit** — 0.735 reads back +**0.7379107**, matching an independent encode/quantise/decode model to +**3.189e-07**. Both survive depsgraph evaluation at deviation **0.0**. +Companion to [`color-attribute-wheel`](examples/color-attribute-wheel/) and +[`attribute-domain-shear`](examples/attribute-domain-shear/). + diff --git a/ROADMAP.md b/ROADMAP.md index c4c9664..54dab5d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -112,6 +112,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Modular kit snap witness~~ **SHIPPED** as `examples/modular-kit-snap/` — tiling corridor segment: 16 boundary verts on `x ∈ {0, TILE}` within 1e-6 m, end rings coincident under the tile offset (nearest-key match), linked-duplicate joint deviation 0.0, shell bbox == declared tile (dev 0.0), manifold except the 16 open-end edges, 20 detail parts contained inside the tile; unsnapped probe (3 mm skew, 2 mm nudge) fails at exits 3/4 with measured errors; sorted-zip ring matching mispairs displaced verts — match by nearest key (authoring hazard, fixed); byte-identical on 4.5.11 and 5.1.2 - ~~Lightmap UV channel witness~~ **SHIPPED** as `examples/lightmap-uv-channel/` — two-channel UV contract on a market cart: per-part `UVMap`/`UVLight` with `active`/`active_render` re-asserted after the ops (edit-mode UV ops clear both flags; `uv_layers.new()` never moves them), UV0 drift 0.0 (clobber trap measured 2.068), every UV1 loop in [0,1], 0 overlapping islands via independent binned strict-SAT (15 hits when falsified), min island distance 0.00401 vs nominal margin 0.002 (`MARGIN_DIV × 0.01` semantics measured live); held `MeshUVLoopLayer.data` after edit-mode UV ops segfaults 4.5.11 (5/5, EXCEPTION_ACCESS_VIOLATION), silent no-op pack without prior unwrap; byte-identical on 4.5.11 and 5.1.2 (3680 islands) - ~~Socket attach-point witness~~ **SHIPPED** as `examples/socket-attach-points/` — named `SKT_` empties as the spawn contract on a survey drone: 7 socket world matrices within 1.788e-07 of the authored transform with orthonormal right-handed bases (Gram 3.576e-07, det err 2.980e-07), socket +Z == the mount pad's Newell normal (1.794e-07) by a quaternion-swing construction independent of the Gram-Schmidt basis, origin on the mount-face centroid (6.687e-08), documented up-axis fallback for the +Z/−Z mounts, module seating offset exactly 0.0 with strictly identity local matrices, rigid invariance under a re-pose (2.384e-07); `transform_apply` on an **Empty** root preserves world matrices (2.235e-07) but clears **7/7** `matrix_parent_inverse` and pushes the root scale down into every child (0.35 for an applied 1.35) — both halves asserted; a child left selected during the apply drifts sockets **2.335 m**; `bm.normal_update()` does not fix inward winding (`recalc_face_normals` does — the lofted fuselage rendered as a flat white panel); no-parent-inverse probe jumps 0.690128 m; byte-identical on 4.5.11 and 5.1.2 +- ~~Vertex-colour AO witness~~ **SHIPPED** as `examples/vertex-color-ao/` — baked occlusion in a colour attribute on a stone village well, checked against a closed form rather than a captured value: the cosine-weighted hemisphere integral for an infinitely wide wall of height H at distance d is `AO = 1 − ½(1 − 1/√(1+k²))`, `k = H/d`, matched to **6.760e-04** across k = 60.0…0.60 (gate 2.5e-03, QMC noise ~1/√n at 4096 samples); unoccluded plate bakes to **exactly 1.0**; strictly monotone 0.508301 → 0.928467; asset values in [0,1] with spread 1.0; **`BYTE_COLOR` is sRGB-encoded 8-bit, not linear** (0.735 → 0.7379107, peak round-trip error 3.782e-03, matching an independent encode/quantise/decode model to 3.189e-07) while `FLOAT_COLOR` is exact; both survive depsgraph evaluation at deviation 0.0; `bmesh.ops.bevel` offsets along **cached** face normals and flips outward past 90° of staleness (12 mm below ground on a ring of boxes, fixed by `normal_update()`); point-domain AO needs subdivision or the crevice gradient never reaches the attribute; `color_attributes` enumeration order differs between 4.5.11 and 5.1.2 — look up by name; byte-identical on 4.5.11 and 5.1.2 (6966 verts) - UV atlas **utilization** witness — coverage/wasted-texel closed forms for a packed lightmap atlas (the non-overlap, unit-square, margin, and active/active_render contracts shipped in `lightmap-uv-channel`; utilization is the remaining unbuilt slice of the old "UV atlas pack" candidate) - ~~GAMMA_CROSS blend-curve witness~~ **SHIPPED** as `examples/vse-gamma-cross/` — the cross blends in a gamma-0.5 space: `((1-t)·√A + t·√B)²` with `t = (frame − start)/duration`, never 1 inside the effect; mid-cross dips 0.115 below the sRGB lerp from crimson/teal (closed form (0.341, 0.349, 0.463) confirmed per frame); AgX-default sampling poisons the fit (0.146 red-channel error, `view_transform='Standard'` mandatory); deleting a consumed input orphans-and-deletes the effect — follow-up to `vse-cut-list` - Falsy `bpy_prop_collection` trap snippet: an empty collection is falsy, so `editor.strips or editor.sequences` silently falls through to the legacy accessor on an empty timeline — always branch on `hasattr`; likely generalizes across the API (found authoring `vse-cut-list`) diff --git a/docs/gallery/asset-sheets/vertex-color-ao.webp b/docs/gallery/asset-sheets/vertex-color-ao.webp new file mode 100644 index 0000000000000000000000000000000000000000..c912b82249c42d616300db7c08f6ad6bda283bb7 GIT binary patch literal 33736 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 bP}Y*19ev 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 z zoIeN|kbJk8nhOK(Xh!oryNX`+jKP+NL9rK7Pe7*`9GxC%BwIUl%7xPMWcy9i@bn#; z+V)aD3`%^2FSIALTPiKK6NLbF;0ECvQ-$c7&aTcL%0JWt`6bbIyl^`cVw&thC6>T? z`<)C)ljpSiT?5ekWe-C)a-||QX znTrU8X+a^2(&8RRbno2a#u~q)Kex{z zITK+zhM4(V-F}Ah1~rkx1%$j~AxY_Ei^G8F|6x=Zb6Yt&_%Q8tosi7_Vc-$2T_#FQ zmFLmb>mBNc3L@VZWslR04tKaTfpN5T2mw^bcSJQrv`A64F$i>@_GIhNNjZZj3F?VB z1j7`rGb7_@9Ir};mmHf2w9}u2(@4YM`7_l%^(q!F(J!bUDP{)XwH%Ric$WZkc$83C z{rVdX)@x+Lk#0lIX<5d7{Vk|qG)Iih}Vw!BK947V< zO2P#V zIuO!$B!--r^2C_V9=1|Vxid+l27fbZ;IQSVtuw=ewIEsIVa=k@M3HG5(^iG3UliNc zO^iCklIw!U31URPH~u1UK5vc%2nWL3762`g2X_GF4Qv&LdYK%wIt%Okf`3Ru3elEP z2)%vfuT~5=1G84b>|-$zrOC`(KIffcE3Lv=#-mC=ihQ?b{Bb|6PS>+e1uO&Vqtb22mKFk*VA0~(r7ukS} zcc3ZBxhd9k`AUD&P7}%GHzu*UijPx4-XJ9fjYP?$T@{xKUq9)G)_l2hB7;?kBgKF& zU%UDWbJ<~GD2Cm_TG5=o9OD~1gX{exkYRPE{eCUxDCtlR*nCSVkI%$5&GwvQvY*MW zpMCkRUuN|>4Ir4QAW=I(sR{NVx0aM-ThdkZuX-0_t@E^p$317mV%>uRK}7hya?`hS zRwAb_j#={BfCSBJR{E~7&Ztv_?LcJ2JsXB!R+xvaans$`XJAi0nG&BVFkHrCm$j{5qwjVd7JUqaGr*`+5P|S;j;V!^nI!Y z8u2kvGc+Gokt-#uf`7zjf~Z9qhnWQFREaj_CprJ)eaL~wT=ORHWB1@pvlq}A{li)i zJ}zXv+OZBz!&I40<_9BNh-yR!=Z*0FhLXz(_v@*?V zbv2xk^`IONJ&aI?16ru)(MI=D)g{#0JyGm_{XKM6QFMLK86so${eY3{#+Fqt2QKX3 z=03S|j!#7Zg-7iE8E6P!yyorH;^-9_t9)a=v#YI0-9A`)(_z2AGPUMIGYsw7*8Jyl zxrpS?2SsOVWf?`eO-}3gOiqbDwY5~j*o-mtdz?_k7;THuwIJ3tBazQd>2AG^XXgM6 zdO$M)hl90PJ2zoZB`CL;xY9s^Ceb8{f%LDb8pEN3t!bBx2|sG4n0YF_M-s9Q+aJD@ zB38l=MP&1KuE?T}CIp?7T&S1Ri=*R2kF%54v`*;+W~dyyk)1TBLb%V>yJ0Me7HQ@f z5y=ri)M&1ZdYRV$r8^<7*njS{LA6Q*(rqEW%GewjrBg>9j{Lx}Vz4_d+F3Gzct`Cg z2~0it!;S^G`QSRKU0&b=Vc!U^u)H)6hH|<=X)o>~v}dnGiWC(?0na~BJp+$|@=q+P ze6555wUdQU0Zj&VB=cFajd1D*CdXVSw9(}5R^{BGVSIm6fkaIDZZMwg0KzxR;`{s` zwW~hM{+v#{v6dP0;5CY+D*@d1;!zxZY+B+7I@TcXLaW@H0M!Ym2@_Nj&zL5WA1FfZ za0xur#wq=&iW;lS!Dc23XXNg6<&eFXJFy)c_E3HFnA-@QD~On!86Gyc19S2(6%Jl& z6CFe7f1}5%HVqqAE8+}>T52GRm1t z^LwR*vhcUOKoCQ}ppN_S*acA0Bo~caQnlR=AVNr>%BVbg-PMkskhCm1EpmoJM9~OO zet|Phqv&LBmG5CkA}0%x>u}|8YOUj50V6Z8bm>Z^zp^+gmg`T-HsjI)VYIwV9xHZT z)G&l}E!Gsdm(ctuavWLtsAbDwo_tkFccP8g)Ai6Xvx&8Mg9zc2V;sbTs(d8*W+EEd zh{9!lT~99F4bu@fUdtfB`d{KypB&5l-h}>w; zFyL50^EDuaS%8*7wY~bHwy^Lev=~~OeDsllT2%7V;3Kv3R(6{ZB>0c4 zUxwA24z2jPpOScX)%haW)D~m5%Vwi+=R`T8nqFAOa117Yf0X*VLR_wd8;*fD2*MS6 z%^_M?v%BbyQj-msKh5L5kx4w!MWC7lnu`Z3I1zUVW}JBhzlg$U6e1AW01hl+QWwXj zf}0%ACkn)F(C92aan6j!PQ-SDMXlZX7BHH%FUhku8Qn-QE}+$0hk|A1KHMoJe2mP& zpkQ9zpn}u2s2HO2!C`g%oEpF~yXs|dXANop)hXy$Rqc4ZqY|j@>9W<*by8(j!Ew5M zB;)vl;sy*CLo6+4zSm!&+wg9RVngYZJ-yz^L&6@tMti+*hqmPBw(zyW{Yc(|JxP=i zdH*<0XQ;mJn(;=(b>W3epj)*afZUGE)ogv_V-OS(_gmW|U=LDnip}QdJi8msj;`-A z+3$J^c_CAK#>He;u_LS>OMk(dhi4|+yq7E~xkf*YTjxZ(<<9<5EQ7_;ZcHCc8NKaL z--tY@Au|o57n-)fKBg;+n&_XhbZ0TSVS4uFfdpzSnNOOQm1KZQb|IwK;pSrDuyRT4oD=^ITCd zlMC*CEU#!ec_7^VaF^~^LDA#bO&v&Az`=`(1eW;ryUpdtGFvfY;Y$XYSK~==n#xU2 zcW`Sa_lY9`_~NnJILSU2?6QAC;YMa%8p`@9BU2~*cYV3V!!YG&5ZP$L%9lk7Y9ONJ zN6B)bXNjHNO-Gd3A3ZzZOBFXfT6AqkE*dNn!;}o|b6`N?$iJJI!H-8RV zcu|Mbv;n*B4_b;)yO(^n+};%Z;(YL`brerns=LS&xDsF0v;nvcjrI078kzAf>aXOd zI8s-RA#wIF%RnEHB5WEb6iyW;$eVzK0$(d(Y zHnj_5X;mdOm-AGG=kX>P*nzRA*on?IXX)u`qEe)|u7S8sJaHDZVW%Y&0ugN=x0&Zy zBbYc!7XM)TtRvAs??J=XMcD8nyG4+GHpU$S619qu)eWOQ=i}kixjXX+-i_B8XI_lj zF_wO7zJ~aVL%Fm-_tKkcgC%mjD`-$}ztUjuG%^4-p54(nx@^k7S%Cz>N^bUgYGL&} zb0$%Hdf1JW`Wu29%Tm=+B=CS6>y(iys=z9bC2WYS<_HCTVFj!*YsroiC1y z0G!a#4TfJ!8otY=BNA%yg}K7@m%)L-p}>t+^RS%m6*cx3C*n&Tcvqf+=vVQ~9CEw^ zo8jp<9PJEm*h3A2V)^oY704+(NpyqW<@;$xNS{wmmYK=}joLheTpB>AO2c zs`eFiFkkpv4=L6_Y4?~v>n@BYhi6+IDJhH6G5^<4sMJkyf8`ocF{S!8nP-<<` z%R`JQNn$?E91EvI`P-V!P{NuH27=?IdOS^!HfMWuLIfa80pxG+H{10aeu$*Nt?17U zk^Ijjq6XA^?@&JV@p+X0xg16V!EG_Yo5X2l?qR;ZA~ews3LtH7N4r8L^4K$LAQogl zZmt>L?ytk0yIlksy|B#tXN^r>(N8tV7%4f#Z_85F-Be4EAna?MpmzMz3XWdN-rPoVI;7%QRL^Tph`mi2rO`-$AIC|!= zL1N(j1xYta@n+I729&&auku`fr9hWYx#8OtD5{3r5I(Civ*&LvQ{y0yvUan`!79|d7XagXcj6dLB&HMo3l5iAsxTRI|C|cb6N??k7gR+9}0(F0926=pJYX_XovDC z!?jPu9p}c*1TG{WSII2sUddJ}PtDOu0LK0QG|2UywP3OBby6;x_;ZEN3zNiraj+hjPX#wT~&(8cF(an7ym$Kk)(Y)91{#E{usjSk~E+p@#kfd3GH%RrSWZbpY5fnT!fG*>KX4ITws)JEwn;XN^0hBmea zD;V1Y)W*vu)N{#yLKNnny)Dw1Y?m%Gln4zjE&oPz(2$^ynD!fF^n343X#_rhx#emj z{H)E-M7Yp*6whHlNDI;p%6k_}OE_}|naS0`6)A$emKIy}U5Mu? z5qb6)i;&DgI7C1w3rO>D{E36Zo=CYZ&Qw}oM3l9;$F@-?HbWW9_#nV~Q4p*ewwWxX zqrr0k6qlG!G~D3N5qWCj>JyB@DB7?kA+9j zVwk2+#{`vuhl*W=17DCX?!p`0u0@Hz)mBo6Y~IB}hU!r*$Y5ODsmat$wUbK#y=>>} zjjn=-Q{FSq?4Ye0-Zc<1Rm5oMuwWs%lQ=q2Bx_9BEq|>lYjjW2_*Cz#hY~vCBDgRJ zs0TdG1MJtre*UedECxP)07RgX;ctCnhR2x64S+tyJ^LbZGtLbi5L(4@)}XPqOm-;l z#z|f{hfb&?6j7~Y~$#1W)4MG@TVD=}Z4gaxxb32dH?lxs134UcUIsd@6*-xt& zOp5MA*V0tQvsO;T*jn~jHReWw5>0sT{j9bq<}^;?JmH|m9N6HXXDe${pGogSmj=!t zlIan%zhA04A5%p>cHhR-s(N((j;e~w{5BbbD_Q#H^Wc$a>3M_<6jOKgk`>g2-M2{i zj~+s!I)(L47KZ#pI#N=c)g{2}$WD%jQ_6a8xJ%mBE-&Kw7O=GvI+xfG!9Tjd5VQo? zq2aprFDU!#j76ABs-7!G;c%Q%56^zd$m6;&m66vpFXj&8=uIr?dWS?jYI&@rXtO2% z3xQz5G(}?0c^hV-V@=Q{L_?7QCBniC)lBYx9lE9Gn@P%?izM0Ji$J-=Ya-dF-x(6J<5}CtxU&vZL!n<0OcDZb>WUF*0c3R z5v0vUqEUc8x7zq|dE^KVBreAznvoX%Xynz#6M4Pf_Gm0P?gRpHUE3V8a(ODS(HMf? z@lvBo&ZqtkGJYY-w_mK^;2>NpxhcIjY(tS1uwk)iE{%2+7evQ<$Eh|z*8|U4Lrs>i zN8u-7vZG?vbT(%6lq~R4oP^s;gGAsEQE)Ivy5d@1byS2oc#_+sWD=kAXEPWRLlh(& zU9Lxfpn?8YXqA>}E&pwE(pr;6fABl~*a%Ac*CKHuJFbSvvfPdaDD)5bkrunZmOO1D zB&^&}S>d)?*NdFbCx2b3s((Vcido%moVmXY@!26MRD zCd}3oA=k_P6W8ogfBKN>MHMMtTe@>T+%H}5BKRC}tz5UWw?9>Wu_ScrT*bZPl_U81 z`QD}Bn}jsVb(EUPQ>@hW(l!A2rY(R8&C5Xtm`$?tXbRU=xaGsCHEhb3ny)q1?{O`D zQ97CMM!*Df%Vp%o?9HX*ZHW%9;EKa;ku)!g-k*#u@XR=BDEH{VBM(GP0Tg8Z;cp>O zuj(st9p&2A%9-atl2q_rFCCiY_9)Kk8t_-_D0Gs_ayfd*C#Hr5D_>C5ht{5Is?fm?!APsuSLr?ioMF)gco6lsay9y(=$eAq1raeC&J7 zd6-b{C2gu*kHDTd8ou<-c*iYu{h9vm&OF%#RL+#aIc z#a!`kf%H00@4N%&Rv5xPjF}in>M>BKm-@SkZB2<#Xs^@duqf;704nNYDc=`+WUnOi z6w+!5w1@dk4RWF~{0I^gi8t@0?c@27Q?`(sMs82)k7-ExuqXzM@#I?7mV`<3Phg^MK(jebBnh`?A!Qg$LfsnhWs;t z6d1E&P@h>X(t&O7oV1CT99teGO>)8aB*<;(FdHXD5XdWy9th-9FcDI-p*;?e#914rTI`R!}fQZ3N2zA8=y_4{PTzILd(yoC1nvk)kWAf6x&Rn(? zL)Y`S`ozW!)gdR4Mvb+U90Mn_#>Pl5<&6l2k!M15dn5hpBm2*5*&6bz zS}*g%%Yw+7d@m^_whU`@{#(Q;>(3gX)aEJj{>?GP^eS-;C!ccBYcHDhxzmO)BoB3` z$!7j~p$W5s0SBbf;`aIf0;~VoNN5Bj^_}VgJGPo_b((R8;q%*oP?nJXgl?gOuI$`p z(2fkS=WwfwYEr|pBtbno{(eSVE&Tts&38P_e6{hCe9Qy@A{kzP>au$C3Y7)?NwCcq z0(fIQ;gBEPJycRHX!`*Bte!E2XfbG}!XXOKmWbg_VA1g&$rIvN;mj$ySUxsrM{~>9 zQ6>FjKM%WafhbD^)P>U=sk&BV4Eh=Ggxe*BI5Lk-0cAc>y3Ni%O*P2}D!yc>lvJQ= zQ(C%d$(rQ$*i1`7e08B<=DO~zuwcrGxdkG(vv|u4C$ImP2LuUlc!ND%>zWka} zT4V`iB*q=GSf4f)%a0@KOfhwWm zwv`S8_RfSklCZ@qX9y8-E(x($3ZegjoBL7M%EihPrfLs8pUJFcMa)u#mikj3y|xznc#s_WeVjB z_P>FapB*d$TR~xfyb@I;IdB%hh9Epja_`Y9o#tEDE(8`rY{qn3dP}#-sHJh83SlUk zO7Xw5avFOtUx+80#Yv&@-xb+pd#n9TET6QuXZq+xK$yx;;EV4=MnxNoWlZEJ~unsK8< zJf`&p>1EpA_>oQ8__;F|&olHn2mIdb@}5zyJca&^D0xadZVS1_ug-D%TF;>7hMWJ% zizOQ*%%W@f2$M|vBA<2j*ZB1WM}irlcV=WMVo*EJ%Dq?F_sPn+*hc0c-kvi_`_N z*`FyFqkZtq-=jBrXfkAnv2W8rv%`)K>vUm=chxOuMJ7Xne7RB|QNnPoB)^PJCE@(|5~4gh{xyymwGu z%2g|sO+55BhKUx?!bYU6bzGaV9*ZjWqy&8v+rCXH;t_^IG?o?|;pvXhXqel0dv-{>dD_AT^s0akaA2Ywl;8?8k zNMN^UMcsXnr@WK*NmoRgZ6|}7e8%i(myj}P@+Ffj(kueuv<>!v#}PC;0yS(v8uOJe zthq)Y8N3&zb4r*ca8If>TOUR<=n)4rPhC>l>}`TSYdn7>>ij{M{XfW@#(L&~v?Vhg zLlg*S4nDj}O11=~OuvgOA(_S(qWz`OGAiK=*JP(k=)e>N?>qc@_0IMCo;XK86FOM` zIu1_1Ei~l>3+x7qqE3zyk4JUJ7Hy7xcXT}UNMKK?OhEjeo8sFJ#6`FkO|JRC@Jnzy zc+gIgBqCPiSIVIX9Fzz=Y zWq9gQJl{W1F;;Vr!unQc^{gYdv|jLCPT5TIkQNP2ws;4S>Ed^KzXRR zq(l`zc34@xJB#=`vi%i7Zm0wKpMs+bhLGKz`s4_?4=VnB-aQB*HYnE5?M3xyxet>b zAOa`8loljd12>k7LjjJir#c6H?~y;t)ipT?-VB->C9aUfEbcKcg8HflH)#@rX&rAK zU<7H|bw1_?CSQsKFW;k8_9BeaCUi0d5UA^Xd|I<8GT|k;2otkJ&0jF!)krg*pO}c;CI?g<=S%^#OCxo9lf!9~LcSn*4DbeJS2vAP;JSLk zApp(U5lve`9>29nh?^MY&N%Mx{mxk+0EIm$Z(n0w1g9Z>v`psJSTTk2d;8 zrDrIx+Tb+cl}M>pt>g?WOFPz3BB7T~F$HH)(hgG#!v9j^r32792v2C8SdE>&s7Trh z+4u7l;aK$;b@}%K(YyZ$9Vl}&c#So4zN~sdg4L@kC+2RI7y<_tEujg%VcfiR_gh>H zm|p$pp;O#%b#?p11&A|hhq+sumq)~6L6KOx=0wR5>++WP*@JNY$-BA#-9N-t4%=al ztV(}{?cEz*3Cr0Ps*etIVC z7ilfkYsXSY$UNXO=>X6q-)GWzEc&{nZbF&%Z8Fo0Y4l}7$Wx=vSk+8M=Oxsl5ga}2 zw3OyBA`}Id{@&OG!*PTkoH+IO+_p*h=*HKGO7z~w!tjQCWyV<({WCUqK}A{`3*G=W zH3Fv{E`h)kwO$NtH00=iqPg%k%77DL#BXQ!_G>E5`r@wO&iRK+u(=R9 zi{2!7@6WgXOqG}|2PMlwmCcgaSD7(~=(4%^$$IzH? zQJ9ej3f07d%=zQ)BfRts0jTn2{$hq~WCM6IT`Uk#9`|wy{GMle3#UOZt)amYNh>Ll z%0@GphT}mh@@n60uvV8%Imw~u_$GwLDDwe>SoLg-xg&a1H44#uKt}WmIS6kO*ISjv zV3JJg6sSOL5|~JR48Qe6$YBWZi@57^XTVf5oo4#PMM$wG$*b(p2SZ`a&n!%d1Aw%G zm>9<{QvLv4TvH=t2oZk2z>F4qnH5<2zGm$8wdQB@SyTSg_f>o@jT)f>Hb#lBx)FtA z14^~ETpX{tlLf=@rFaf2<)6hdF7z=iL#-B)X4)Xac`rf}!;zWRp-M zCb6h4isDBmJJGHKIfP?>X4kosm0Em$S5dXM$!6E$Vw%Ak*OiXCj#a?~+U)vD&jc#W zVsO+p!%1~O_8gvAf|?Nv z{mJCvA@*6MD+}`FK4xj71H2>lFy9|jw{4n2IMbIITA}pK0=~?KTW*uSoBbr^0p^DO zlS4bq&BErZ= zjI4*hkwL6Pay5QP+zgADpZTf~lN`t8^4irvRp${Y=P3mGvUGKh`wgS#qEUwEhOY2- z64nqCFBE*(*4T*qotYN@B5w8cP7pjzH@VR)*$@K}zrR4?NBe~f`QTUO9&|x1X4(!U zF5JKFdx)kZjnd)5dH>2ld~vZ#xO+9H(GznfUlgR}=>IY1OA9h%{-lfh7!5F{r@sY3 z={Kf-3tWf?cyIs<5_r))U#~Ac*|pBtzw(8t?#FkIP4*s=yWgq90+{_aGZX_O(D&~p z(3t%|QMyO;J01K>9PBPAoRew2mFweD&z+Mf*|G?U9VxyPkA4i;-j>q8irbmfuVRFzkDDRZ^ zIE$6bQo$Go7h?F50Mq>dcr?8SNnXEfnrZ0R_9&x|SPB4I>XEld+Ez7Hc8y?#Q;>&x zD6;3dezph8q40>1H(eH64&xRS#cj1GtJ}Jr=j)M`?~BE>3qMKJJ0If3gs^mq;)+U6 satuPM8~A2LCPmba5+R+>jz$NHE&u=k015}G$OF-hx@2_!@Bsh-09YZ5DF6Tf literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index b8c3390..0947929 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -272,7 +272,7 @@

Examples Gallery

autocomplete="off" spellcheck="false" aria-label="Search examples" /> - 43 examples + 44 examples
@@ -795,6 +795,17 @@

socket-attach-points

View example
+
+ + vertex-color-ao — A stone well carrying baked ambient occlusion in a colour attribute, with the bake held to the closed-form hemisphere integral rather than to a captured value. + +
+

vertex-color-ao

+

A stone well carrying baked ambient occlusion in a colour attribute, with the bake held to the closed-form hemisphere integral rather than to a captured value.

+

witnesses Integrator matches 1 - 0.5(1 - 1/sqrt(1+k^2)) to 6.760e-04, unoccluded plate exactly 1.0, FLOAT_COLOR exact vs BYTE_COLOR sRGB-quantised (model agreement 3.189e-07), evaluated deviation 0.0.

+ View example +
+
diff --git a/docs/gallery/vertex-color-ao/index.html b/docs/gallery/vertex-color-ao/index.html new file mode 100644 index 0000000..a9605c5 --- /dev/null +++ b/docs/gallery/vertex-color-ao/index.html @@ -0,0 +1,1269 @@ + + + + + + vertex-color-ao — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

vertex-color-ao

+

A stone well carrying baked ambient occlusion in a colour attribute, with the bake held to the closed-form hemisphere integral rather than to a captured value.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses Integrator matches 1 - 0.5(1 - 1/sqrt(1+k^2)) to 6.760e-04, unoccluded plate exactly 1.0, FLOAT_COLOR exact vs BYTE_COLOR sRGB-quantised (model agreement 3.189e-07), evaluated deviation 0.0.
+
+
blender --background --python examples/vertex-color-ao/vertex_color_ao.py --
+ +
+
+

A runnable example baking ambient occlusion into a colour attribute — the cheap contact shadows an engine gets for free if it reads vertex colour. Unlike most bakes this one can be checked against a *formula* rather than against a previous run's numbers: the hemisphere visibility integral has closed forms.

+

For a point on the floor a distance d from an infinitely wide wall of height H, integrating the cosine-weighted hemisphere over the directions the wall blocks gives (the φ integral collapses to π / √(1+k²)):

+
A(k) = ½ (1 − 1/√(1+k²)),   k = H/d        AO = 1 − A(k)
+

AO depends only on the ratio H/d and increases strictly with d — which is exactly "a concave corner darkens monotonically with depth", as an equation. For an unoccluded flat surface the integral is exactly 1.

+

The asset, for reuse: Well.Stone, a 2.5 m stone village well — three courses of 15 masonry blocks each on a 16-slab paved apron, a 15-slab coping ring, a lined bore, two braced timber posts with a crossbeam, an iron-banded windlass with a crank, and a hanging bucket. 11 parts, 11 materials, 6966 vertices. Origin at the ground contact centre (z == 0 is where it rests), identity transforms, datablocks under Well.Stone.*. Every part ships with the AO baked into two colour attributes, and default_color_name points at the linear one so an engine reads the right channel.

+

Pipeline arc neighbours: attribute domains in attribute-domain-shear and color-attribute-wheel, the second UV set for *texture*-baked lighting in lightmap-uv-channel, topology gates in mesh-hygiene-audit.

+

What it witnesses (all closed form or independently re-derived):

+
  • Analytic AO. The integrator matches 1 − A(H/d) at six probe distances spanning two decades of H/d (60.0 down to 0.60); worst error 6.760e-04 against a 2.5e-03 gate.
  • Unoccluded is exactly 1. An isolated flat plate bakes to 1.000000000 — not approximately, exactly, because no ray can self-hit.
  • Monotone with depth. AO rises strictly across the calibration ruler, 0.508301 → 0.928467.
  • Range and spread. Every baked value on the asset lies in [0, 1] and the bake actually uses the range (measured spread 1.000000); a silently constant bake would pass a bare range check and fails this one.
  • Storage. FLOAT_COLOR round-trips the linear value exactly (0.000e+00). BYTE_COLOR does not — see below.
  • Depsgraph survival. Both attributes read back off the evaluated mesh at deviation exactly 0.0, keeping data_type and domain.
  • Reuse hygiene. Identity scales, Well.Stone.* names, no default datablocks, min z == 0.00e+00, render colour attribute pinned to AO.
+

Hazards found while authoring

+
  • BYTE_COLOR is sRGB-encoded 8-bit, not linear 8-bit. Writing linear 0.735 reads back 0.7379107. The round-trip error peaks at 3.782e-03, and the readback matches an independent encode → quantise → decode model to 3.189e-07 — so the encoding is confirmed, not guessed. Darks get more precision than a linear ramp would give and midtones get less. An exporter that hands the raw bytes to an engine expecting linear occlusion ships visibly wrong shadows. Store AO in FLOAT_COLOR unless the target genuinely wants sRGB.
  • bmesh.ops.bevel offsets along *cached* face normals. Moving a vertex does not refresh them. While the stale normal is within 90° of the true one the bevel merely skews; past 90° it flips sign and grows the solid outward. Measured while authoring, on a ring of bevelled boxes placed around a circle: the boxes at 0/36/72° bevelled correctly and the one at 108° grew by exactly one offset (12 mm) in both z directions, putting geometry below the ground plane. t.normal_update() before every bevel is the fix, and every _box/_prism here calls it; the hygiene check (exit 8) is what caught it.
  • Point-domain AO needs vertices to vary across. An 8-vertex slab bakes to one nearly-constant value per face, so the crevice gradient never reaches the attribute. The first draft rendered as flat pale stone with a correct bake underneath. Parts are subdivided before the bake for this reason.
  • color_attributes enumeration order is not portable — measured BYTE_COLOR first on 4.5.11 and FLOAT_COLOR first on 5.1.2 for the same mesh. Look attributes up by name, never by index.
+

What each check catches on failure (every one probed, with the measured error): an integrator sampling the full sphere rather than the hemisphere — AO 0.752686 against a closed form of 0.508332, error 2.444e-01 (exit 3); uniform-solid-angle weighting instead of cosine — error 1.202e-02 (exit 3); rays cast with no normal offset so they self-hit the surface they start on — unoccluded plate bakes to 0.160644531 instead of 1.0 (exit 4); a bake collapsed to a constant — spread 0.000000 (exit 5); the sRGB storage model swapped for naive linear 8-bit quantisation — 4.577e-03 disagreement with the real readback (exit 6); a Subdivision modifier resampling the attribute — 3270 evaluated elements against 840 authored (exit 7); a part sunk below the ground plane — -1.200e-02 (exit 8); a default Cube datablock name (exit 8).

+

The exit-7 probe earned its keep twice: the depsgraph check originally compared zip(src.data, eva.data), which stops at the shorter sequence, so a resampled attribute reported a clean 0.0 deviation and the probe exited 0. The length guard was added because the falsification pass failed to fail.

+

Version witness: check output is byte-identical on Blender 4.5.11 LTS and 5.1.2 — same 6966 vertices, same closed-form errors to every printed digit, same storage deviations.

+

Render as proof: the well on the dark stage, lit only by the studio rig, with the baked AO multiplied into base colour — the masonry joints, the shaft mouth, the coping undersides and the apron contact all darken from the attribute, not from the lights. The falsification variant (--falsify) writes the same bake inverted: sky-facing coping and apron tops go grimy while the recesses and post bases glow, occlusion turned inside out.

+

Run

+
blender --background --python vertex_color_ao.py --
+blender --background --python vertex_color_ao.py -- --output well.png
+blender --background --python vertex_color_ao.py -- --falsify inverted.png
+

Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1 (the calibration rig gets 4096 samples over six points, the asset a cheap 64, so the whole check is ~1 s). The --output render path additionally gates framing via examples/gallery_framing.py (fill 0.839y, margins 0.241/0.238/0.072/0.089, no edge touched) and the asset floors via examples/gallery_asset_quality.py (11 materials, edge90 0.152, no default names).

+
+
+

Source

+
+ examples/vertex-color-ao/vertex_color_ao.py + View on GitHub → +
+
"""Vertex-colour AO — cheap baked occlusion in a colour attribute.
+
+Engines that read vertex colour get their contact shadows for free if the
+asset ships with ambient occlusion baked into a colour attribute. The bake is
+a hemisphere visibility integral, and unlike most bakes it has *analytic*
+answers: for an unoccluded flat surface the integral is exactly 1, and for a
+point on the floor a distance ``d`` from an infinitely wide wall of height
+``H`` the cosine-weighted occluded fraction is
+
+    A(k) = 0.5 * (1 - 1 / sqrt(1 + k^2)),    k = H / d
+    AO   = 1 - A(k)
+
+derived by integrating the cosine-weighted hemisphere measure over the
+directions the wall blocks (the phi integral collapses to
+``pi / sqrt(1 + k^2)``). Note AO(k) depends only on the *ratio* H/d, and
+darkens monotonically as the point approaches the wall — so the bake can be
+checked against a formula rather than against a previous run's numbers.
+
+The asset is a stone village well. The same integrator that is validated
+against the closed form above is run over its geometry, so what ships in the
+attribute is the thing the check measured.
+
+Two storage traps are asserted rather than described:
+
+* ``FLOAT_COLOR`` round-trips a linear value exactly (1.4e-08 here).
+* ``BYTE_COLOR`` does NOT store linear 8-bit — it stores **sRGB-encoded**
+  8-bit. Writing 0.735 reads back 0.7379107, and the round-trip error peaks
+  at 2.9e-03, an order of magnitude worse than a naive 1/255 = 3.9e-03
+  uniform-quantisation model would predict *in the shadows* and much better
+  in the darks. The check reproduces the readback with an independent
+  sRGB encode/quantise/decode model. An exporter that hands raw bytes to an
+  engine expecting linear occlusion ships wrong shadows.
+
+Check (all closed form or independently re-derived, nothing captured):
+
+1. Analytic AO (exit 3): the integrator matches ``1 - A(H/d)`` at six probe
+   distances spanning two decades of H/d.
+2. Unoccluded and monotone (exit 4): an isolated flat plate bakes to exactly
+   1.0 everywhere; along the calibration ruler AO increases strictly with
+   distance from the wall.
+3. Range (exit 5): every baked value on the asset lies in [0, 1], and the
+   asset actually uses its range (crevices darker than exposed faces by a
+   measured margin — a bake that silently produced a constant would pass a
+   bare range check).
+4. Storage round-trip (exit 6): FLOAT_COLOR exact, BYTE_COLOR matching the
+   independent sRGB quantisation model to within 1e-6.
+5. Depsgraph survival (exit 7): both attributes read back off the evaluated
+   mesh with deviation exactly 0, keeping data_type and domain.
+6. Reuse hygiene (exit 8): identity scales, ``Well.Stone.*`` names, no
+   default datablocks, the asset resting on z == 0.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python vertex_color_ao.py --                   # check
+    blender --background --python vertex_color_ao.py -- --output well.png # + render
+    blender --background --python vertex_color_ao.py -- --falsify flat.png
+"""
+import bpy, bmesh, sys, os, math, argparse
+from mathutils import Vector, Matrix
+from mathutils.bvhtree import BVHTree
+
+# 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 gallery_asset_quality
+
+AO_ATTR = "AO"             # FLOAT_COLOR / POINT — the linear, exact channel
+AO_BYTE_ATTR = "AOByte"    # BYTE_COLOR / POINT — the sRGB-quantised channel
+
+# Bake sample counts. The calibration rig gets a heavy count because it is
+# only six points and it is being held to a formula; the asset gets a cheap
+# count because what is asserted there is range, spread and survival.
+CAL_SAMPLES = 4096
+ASSET_SAMPLES = 64
+
+# Quasi-Monte-Carlo convergence on the calibration rig is ~1/sqrt(n): the
+# measured worst error at 4096 samples is 6.8e-04, so 2.5e-03 is roughly a
+# 3.5x margin. Tightening it below ~1e-3 would make the gate a sampling-noise
+# detector rather than a correctness check.
+CAL_TOL = 2.5e-03
+WALL_H = 3.0               # calibration wall height, metres
+WALL_HALF_W = 400.0        # half width: wide enough that truncation < 1e-4
+CAL_DISTANCES = (0.05, 0.2, 0.5, 1.0, 2.0, 5.0)
+RAY_EPS = 1e-5             # offset along the normal so a ray cannot self-hit
+RAY_FAR = 1.0e4
+
+FLOAT_TOL = 1e-6           # FLOAT_COLOR round-trip (measured 1.43e-08)
+BYTE_MODEL_TOL = 1e-6      # agreement with the independent sRGB model
+SPREAD_MIN = 0.25          # the asset must actually use its AO range
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+# ---------------------------------------------------------------------------
+# The AO integrator
+# ---------------------------------------------------------------------------
+
+def hemisphere_dirs(n):
+    """Deterministic cosine-weighted hemisphere directions about +Z.
+
+    A Fibonacci lattice on the unit disc lifted to the hemisphere: sampling
+    the disc uniformly in area and projecting up gives exactly the cosine
+    weighting the AO integral needs, with no RNG — the same n directions
+    every run, on every machine, on both Blender versions.
+    """
+    golden = math.pi * (3.0 - math.sqrt(5.0))
+    dirs = []
+    for i in range(n):
+        r = math.sqrt((i + 0.5) / n)
+        phi = i * golden
+        x, y = r * math.cos(phi), r * math.sin(phi)
+        dirs.append(Vector((x, y, math.sqrt(max(0.0, 1.0 - r * r)))))
+    return dirs
+
+
+def frame_from_normal(nz):
+    """An orthonormal frame with +Z along nz. Any tangent will do — the
+    cosine-weighted set is rotationally symmetric about the normal."""
+    nz = Vector(nz).normalized()
+    ref = Vector((1.0, 0.0, 0.0)) if abs(nz.x) < 0.9 else Vector((0.0, 1.0, 0.0))
+    nx = (ref - nz * ref.dot(nz)).normalized()
+    ny = nz.cross(nx)
+    return Matrix((nx, ny, nz)).transposed()
+
+
+def analytic_ao(height, dist):
+    """Closed form for an infinitely wide wall of `height` at `dist`."""
+    k = height / dist
+    return 1.0 - 0.5 * (1.0 - 1.0 / math.sqrt(1.0 + k * k))
+
+
+def bake_points(bvh, points, normals, n_samples):
+    """AO at each (point, normal) against `bvh`. Returns a list of floats."""
+    dirs = hemisphere_dirs(n_samples)
+    inv_n = 1.0 / n_samples
+    out = []
+    for p, nrm in zip(points, normals):
+        basis = frame_from_normal(nrm)
+        origin = Vector(p) + Vector(nrm).normalized() * RAY_EPS
+        blocked = 0
+        for d in dirs:
+            if bvh.ray_cast(origin, basis @ d, RAY_FAR)[0] is not None:
+                blocked += 1
+        out.append(1.0 - blocked * inv_n)
+    return out
+
+
+def world_bvh(objects):
+    """One BVH over every object's world-space triangles."""
+    bm = bmesh.new()
+    try:
+        for ob in objects:
+            tmp = bm.__class__() if False else None  # keep one bmesh, transform in
+            me = ob.data
+            offset = len(bm.verts)
+            bm.verts.ensure_lookup_table()
+            new_verts = [bm.verts.new(ob.matrix_world @ v.co) for v in me.vertices]
+            for poly in me.polygons:
+                try:
+                    bm.faces.new([new_verts[i] for i in poly.vertices])
+                except ValueError:
+                    pass          # duplicate face across overlapping parts
+        bm.verts.ensure_lookup_table()
+        bmesh.ops.triangulate(bm, faces=bm.faces[:])
+        return BVHTree.FromBMesh(bm)
+    finally:
+        bm.free()
+
+
+def bake_object_ao(bvh, ob, n_samples):
+    """AO per vertex of `ob`, evaluated in world space against `bvh`."""
+    me = ob.data
+    mw = ob.matrix_world
+    nrm3 = mw.to_3x3().inverted().transposed()
+    pts = [mw @ v.co for v in me.vertices]
+    nrms = [(nrm3 @ Vector(me.vertex_normals[i].vector)).normalized()
+            for i in range(len(me.vertices))]
+    return bake_points(bvh, pts, nrms, n_samples)
+
+
+def write_ao_attributes(me, values, invert=False):
+    """Write AO into a FLOAT_COLOR and a BYTE_COLOR attribute on POINT.
+
+    HAZARD: look attributes up by NAME. The enumeration order of
+    `color_attributes` differs between 4.5.11 and 5.1.2 for the same mesh
+    (measured: BYTE first on 4.5, FLOAT first on 5.1), so indexing into the
+    collection is not portable.
+    """
+    for name, dtype in ((AO_ATTR, "FLOAT_COLOR"), (AO_BYTE_ATTR, "BYTE_COLOR")):
+        if name in me.color_attributes:
+            me.color_attributes.remove(me.color_attributes[name])
+        attr = me.color_attributes.new(name=name, type=dtype, domain="POINT")
+        for i, a in enumerate(values):
+            v = (1.0 - a) if invert else a
+            attr.data[i].color = (v, v, v, 1.0)
+    me.color_attributes.active_color = me.color_attributes[AO_ATTR]
+    me.attributes.default_color_name = AO_ATTR
+    me.attributes.active_color_name = AO_ATTR
+
+
+# --- the independent sRGB storage model, for check 4 ------------------------
+
+def lin_to_srgb(c):
+    return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1.0 / 2.4)) - 0.055
+
+
+def srgb_to_lin(c):
+    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
+
+
+def byte_color_model(v):
+    """What a BYTE_COLOR attribute should hand back for a linear write of v."""
+    return srgb_to_lin(round(lin_to_srgb(v) * 255.0) / 255.0)
+
+
+# ---------------------------------------------------------------------------
+# Geometry helpers
+# ---------------------------------------------------------------------------
+
+class Part:
+    """A bmesh under construction with named material slots."""
+
+    def __init__(self):
+        self.bm = bmesh.new()
+        self.slots = []
+
+    def group(self, slot_name, fn):
+        before = set(self.bm.faces)
+        fn(self.bm)
+        if slot_name not in self.slots:
+            self.slots.append(slot_name)
+        idx = self.slots.index(slot_name)
+        for f in self.bm.faces:
+            if f not in before:
+                f.material_index = idx
+        return self
+
+    def finish(self, name):
+        me = bpy.data.meshes.new(name)
+        try:
+            # normal_update() recomputes from the existing winding; it does not
+            # repair a face built the wrong way round, and an inward-facing
+            # shell would swallow every AO ray it should have blocked.
+            bmesh.ops.recalc_face_normals(self.bm, faces=list(self.bm.faces))
+            self.bm.normal_update()
+            self.bm.to_mesh(me)
+        finally:
+            self.bm.free()
+        me["slots"] = self.slots
+        return me
+
+
+# _bevel_normals_note
+# HAZARD: bmesh.ops.bevel offsets along the CACHED face normals, and moving a
+# vertex does not refresh them. Build a cube, transform its verts, bevel: while
+# the stale normal is still within 90 degrees of the true one the offset merely
+# skews, but past 90 degrees it flips sign and the bevel grows the solid
+# outward instead of chamfering it inward. Measured while authoring this
+# asset, on a ring of bevelled boxes placed around a circle: the boxes at
+# 0/36/72 degrees bevelled correctly and the box at 108 degrees grew by
+# exactly one offset (12 mm) in both z directions, putting geometry below the
+# ground plane. The hygiene check (exit 8) is what caught it.
+# t.normal_update() before every bevel is the fix, and it is why every _box
+# and _prism here calls it.
+
+
+def _emit(bm, build, cuts=0):
+    """Build one sub-solid in a private bmesh, then copy it into `bm`.
+
+    HAZARD, and the reason this indirection exists. The obvious way to add a
+    bevelled primitive to a shared bmesh is to snapshot ``set(bm.verts)``,
+    create the primitive, diff to find the new verts, and bevel only the edges
+    whose verts are all new. That works for the first few primitives and then
+    silently corrupts: ``bmesh.ops.bevel`` reallocates the vertex table, so
+    Python BMVert wrappers captured in the snapshot can alias geometry created
+    afterwards. Measured on this asset's ground apron, the fault appeared at
+    the **4th** box — edges belonging to an already-finished neighbour were
+    selected and bevelled, dropping vertices exactly one bevel offset (12 mm)
+    below the ground plane. The hygiene check is what caught it.
+
+    Building each primitive in its own bmesh removes the coupling: no op ever
+    runs on the shared mesh while a snapshot of it is being held.
+    """
+    tmp = bmesh.new()
+    try:
+        build(tmp)
+        if cuts:
+            # Point-domain AO can only vary where there are vertices. An
+            # 8-vertex slab bakes to one nearly-constant value per face and
+            # the crevice gradient the bake exists to capture never appears
+            # in the attribute (draft 3 rendered as flat pale stone).
+            bmesh.ops.subdivide_edges(tmp, edges=list(tmp.edges), cuts=cuts,
+                                      use_grid_fill=True)
+        vmap = {v: bm.verts.new(v.co) for v in tmp.verts}
+        for f in tmp.faces:
+            try:
+                bm.faces.new([vmap[v] for v in f.verts])
+            except ValueError:
+                pass                      # coincident face across sub-solids
+    finally:
+        tmp.free()
+
+
+def _box(bm, dims, centre, bevel=0.0, segments=1, rot=None, cuts=0):
+    def build(t):
+        bmesh.ops.create_cube(t, size=1.0)
+        for v in t.verts:
+            c = Vector((v.co.x * dims[0], v.co.y * dims[1], v.co.z * dims[2]))
+            if rot is not None:
+                c = rot @ c
+            v.co = c + Vector(centre)
+        if bevel > 0.0:
+            t.normal_update()      # see _bevel_normals_note
+            bmesh.ops.bevel(t, geom=list(t.edges), offset=bevel,
+                            segments=segments, profile=0.5, affect="EDGES",
+                            clamp_overlap=True)
+    _emit(bm, build, cuts=cuts)
+
+
+def _prism(bm, sides, radius, half_h, centre, bevel=0.0, rot=None, taper=1.0,
+           phase=0.0, cuts=0):
+    def build(t):
+        ring = []
+        for i in range(sides):
+            a = 2.0 * math.pi * i / sides + phase
+            ring.append((math.cos(a) * radius, math.sin(a) * radius))
+        top = [t.verts.new((x * taper, y * taper, half_h)) for x, y in ring]
+        bot = [t.verts.new((x, y, -half_h)) for x, y in ring]
+        t.faces.new(top)
+        t.faces.new(list(reversed(bot)))
+        for i in range(sides):
+            j = (i + 1) % sides
+            t.faces.new((bot[i], bot[j], top[j], top[i]))
+        for v in t.verts:
+            c = Vector(v.co)
+            if rot is not None:
+                c = rot @ c
+            v.co = c + Vector(centre)
+        if bevel > 0.0:
+            t.normal_update()      # see _bevel_normals_note
+            bmesh.ops.bevel(t, geom=list(t.edges), offset=bevel, segments=1,
+                            profile=0.6, affect="EDGES", clamp_overlap=True)
+    _emit(bm, build, cuts=cuts)
+
+
+def _plate(bm, verts_xy, z0, z1, cuts=2):
+    """A prism from an explicit polygon footprint, subdivided for the bake."""
+    def build(t):
+        top = [t.verts.new((x, y, z1)) for x, y in verts_xy]
+        bot = [t.verts.new((x, y, z0)) for x, y in verts_xy]
+        t.faces.new(top)
+        t.faces.new(list(reversed(bot)))
+        n = len(verts_xy)
+        for i in range(n):
+            j = (i + 1) % n
+            t.faces.new((bot[i], bot[j], top[j], top[i]))
+    _emit(bm, build, cuts=cuts)
+
+
+# ---------------------------------------------------------------------------
+# The asset: a stone village well
+# ---------------------------------------------------------------------------
+
+# Proportions: a squat, wide wellhead rather than a spindly one. Draft 4 gave
+# the masonry a third of the frame and the timber frame the rest, so the joint
+# occlusion the bake exists to show was sub-pixel. Widening the ring and
+# dropping the frame puts the stonework where the eye lands.
+SHAFT_R = 0.545           # inner bore radius
+COURSES = (               # (z0, z1, outer radius, stone count, phase offset)
+    (0.000, 0.200, 0.845, 15, 0.00),
+    (0.200, 0.385, 0.822, 15, 0.50),
+    (0.385, 0.560, 0.805, 15, 0.00),
+)
+COPING_Z0, COPING_Z1 = 0.560, 0.646
+COPING_R = 0.925
+POST_Y = 0.790
+POST_TOP = 1.36
+DRUM_Z = 1.030
+
+
+def _course_stone(bm, z0, z1, r_out, idx, count, phase, jog):
+    """One masonry block: a trapezoid footprint spanning an arc of the ring.
+
+    Blocks are inset from each other by a small joint so the bake has real
+    crevices to find — the mortar lines are geometry, not a texture.
+    """
+    joint = 0.078 / r_out                      # angular half-gap
+    a0 = 2.0 * math.pi * (idx + phase) / count + joint
+    a1 = 2.0 * math.pi * (idx + 1 + phase) / count - joint
+    ro = r_out + jog
+    ri = SHAFT_R
+    pts = []
+    for a in (a0, a1):
+        pts.append((math.cos(a) * ro, math.sin(a) * ro))
+    for a in (a1, a0):
+        pts.append((math.cos(a) * ri, math.sin(a) * ri))
+    _plate(bm, pts, z0 + 0.012, z1 - 0.012)
+
+
+def build_well_meshes():
+    meshes = {}
+    # deterministic per-stone jog so the courses are not machine-perfect
+    def jog(i, c):
+        return 0.014 * math.sin(i * 2.399963 + c * 1.107)
+
+    for ci, (z0, z1, r_out, count, phase) in enumerate(COURSES):
+        part = Part()
+        part.group("Stone", lambda bm, z0=z0, z1=z1, r=r_out, c=count,
+                   ph=phase, ci=ci: [
+                       _course_stone(bm, z0, z1, r, i, c, ph, jog(i, ci))
+                       for i in range(c)])
+        meshes[f"Course.{ci}"] = part.finish(f"Well.Stone.Course.{ci}")
+
+    # coping: 11 flat slabs, a wider ring that overhangs and casts the
+    # strongest contact darkening onto the top course
+    cop = Part()
+    def slabs(bm):
+        n = 15
+        for i in range(n):
+            joint = 0.028 / COPING_R
+            a0 = 2.0 * math.pi * i / n + joint
+            a1 = 2.0 * math.pi * (i + 1) / n - joint
+            pts = [(math.cos(a) * COPING_R, math.sin(a) * COPING_R)
+                   for a in (a0, a1)]
+            pts += [(math.cos(a) * (SHAFT_R - 0.02),
+                     math.sin(a) * (SHAFT_R - 0.02)) for a in (a1, a0)]
+            _plate(bm, pts, COPING_Z0, COPING_Z1 + 0.006 * math.sin(i * 1.7))
+    cop.group("Coping", slabs)
+    meshes["Coping"] = cop.finish("Well.Stone.Coping")
+
+    # inner bore sleeve: darkens to near-zero down the shaft
+    bore = Part()
+    # the sleeve stops at z == 0 so the asset rests on the ground plane; an
+    # earlier revision ran it to -0.02 and the hygiene check caught it
+    bore.group("Bore", lambda bm: _prism(bm, 26, SHAFT_R - 0.028, 0.298,
+                                         (0.0, 0.0, 0.298), cuts=3))
+    meshes["Bore"] = bore.finish("Well.Stone.Bore")
+
+    # ground apron: flagstones the well sits on, so the base has a floor to
+    # occlude against
+    ap = Part()
+    def apron(bm):
+        # a paved collar butted against the base course: the tighter the ring
+        # sits, the deeper the contact darkening the bake has to find
+        n = 16
+        for i in range(n):
+            joint = 0.032 / 1.05
+            a0 = 2.0 * math.pi * i / n + joint
+            a1 = 2.0 * math.pi * (i + 1) / n - joint
+            r0, r1 = 0.870, 1.235 + 0.045 * math.sin(i * 2.1)
+            pts = [(math.cos(a) * r1, math.sin(a) * r1) for a in (a0, a1)]
+            pts += [(math.cos(a) * r0, math.sin(a) * r0) for a in (a1, a0)]
+            _plate(bm, pts, 0.0, 0.048 + 0.008 * math.sin(i * 1.3), cuts=2)
+    ap.group("Flag", apron)
+    meshes["Apron"] = ap.finish("Well.Stone.Apron")
+
+    # timber frame
+    for side, sy in (("L", 1.0), ("R", -1.0)):
+        fr = Part()
+        fr.group("Timber", lambda bm, s=sy: _box(
+            bm, (0.12, 0.12, POST_TOP - 0.30),
+            (0.0, s * POST_Y, 0.30 + (POST_TOP - 0.30) / 2), bevel=0.012, cuts=3))
+        fr.group("Iron", lambda bm, s=sy: _box(
+            bm, (0.16, 0.16, 0.040), (0.0, s * POST_Y, 0.70), bevel=0.006))
+        fr.group("Brace", lambda bm, s=sy: _box(
+            bm, (0.08, 0.08, 0.42), (0.0, s * (POST_Y - 0.15), 0.90), bevel=0.010,
+            rot=Matrix.Rotation(math.radians(22.0) * s, 3, "X")))
+        meshes[f"Post.{side}"] = fr.finish(f"Well.Stone.Post.{side}")
+
+    beam = Part()
+    beam.group("Timber", lambda bm: _box(bm, (0.12, 2 * POST_Y + 0.22, 0.13),
+                                         (0.0, 0.0, POST_TOP - 0.065), bevel=0.014,
+                                         cuts=3))
+    def caps(bm):
+        # Post caps, not a roof. Two revisions of a gable were tried: fanned
+        # boards read as detached planks, and solid slopes big enough to look
+        # like a roof covered the masonry that carries the AO story. A
+        # roofless windlass well is the more honest prop and the better
+        # subject for this bake.
+        for s in (1.0, -1.0):
+            _box(bm, (0.18, 0.18, 0.05), (0.0, s * POST_Y, POST_TOP + 0.025),
+                 bevel=0.010, cuts=1)
+        _box(bm, (0.16, 2 * POST_Y - 0.30, 0.05),
+             (0.0, 0.0, POST_TOP - 0.155), bevel=0.010, cuts=2)
+    beam.group("Shingle", caps)
+    meshes["Beam"] = beam.finish("Well.Stone.Beam")
+
+    # windlass: drum along Y with a crank, plus the rope and bucket
+    wl = Part()
+    rotY = Matrix.Rotation(math.radians(90.0), 3, "X")
+    wl.group("Drum", lambda bm: _prism(bm, 14, 0.085, POST_Y - 0.03,
+                                       (0.0, 0.0, DRUM_Z), rot=rotY, cuts=2))
+    def collars(bm):
+        for s in (1.0, -1.0):
+            _prism(bm, 14, 0.098, 0.022, (0.0, s * (POST_Y - 0.045), DRUM_Z),
+                   rot=rotY)
+    wl.group("Iron", collars)
+    def crank(bm):
+        _prism(bm, 10, 0.020, 0.10, (0.0, POST_Y + 0.06, DRUM_Z), rot=rotY)
+        _box(bm, (0.030, 0.030, 0.20), (0.0, POST_Y + 0.10, DRUM_Z - 0.09),
+             bevel=0.006, rot=Matrix.Rotation(math.radians(90.0), 3, "Y"))
+        _prism(bm, 10, 0.026, 0.062, (0.14, POST_Y + 0.10, DRUM_Z - 0.09),
+               rot=Matrix.Rotation(math.radians(90.0), 3, "X"))
+    wl.group("Iron", crank)
+    meshes["Windlass"] = wl.finish("Well.Stone.Windlass")
+
+    bk = Part()
+    bk.group("Rope", lambda bm: _prism(bm, 7, 0.020, 0.105,
+                                       (0.10, -0.20, DRUM_Z - 0.020), cuts=2))
+    bk.group("Stave", lambda bm: _prism(bm, 16, 0.150, 0.130,
+                                        (0.10, -0.20, 0.845), taper=0.86, cuts=2))
+    def bands(bm):
+        for z in (0.740, 0.953):
+            _prism(bm, 16, 0.158, 0.016, (0.10, -0.20, z))
+    bk.group("Iron", bands)
+    bk.group("Iron", lambda bm: _box(bm, (0.30, 0.022, 0.022),
+                                     (0.10, -0.20, 0.983), bevel=0.005))
+    meshes["Bucket"] = bk.finish("Well.Stone.Bucket")
+    return meshes
+
+
+def build_asset(sc):
+    """Link the well into `sc`. Returns (root, parts)."""
+    root = bpy.data.objects.new("Well.Stone", None)
+    root.empty_display_type = "PLAIN_AXES"
+    sc.collection.objects.link(root)
+    parts = []
+    for suffix, me in build_well_meshes().items():
+        ob = bpy.data.objects.new(me.name, me)
+        sc.collection.objects.link(ob)
+        ob.parent = root
+        parts.append(ob)
+    bpy.context.view_layer.update()
+    return root, parts
+
+
+def bake_asset(parts, n_samples=ASSET_SAMPLES, invert=False):
+    """Bake AO for every part against the whole assembly. Returns all values."""
+    bvh = world_bvh(parts)
+    every = []
+    for ob in parts:
+        vals = bake_object_ao(bvh, ob, n_samples)
+        write_ao_attributes(ob.data, vals, invert=invert)
+        every.extend(vals)
+    return every
+
+
+# ---------------------------------------------------------------------------
+# Calibration rig
+# ---------------------------------------------------------------------------
+
+def build_calibration(sc):
+    """A wide wall plus a floor strip whose vertices sit at CAL_DISTANCES.
+
+    Returns (wall_ob, floor_ob, probe_indices) — the floor vertices whose AO
+    is held to the closed form.
+    """
+    wm = bpy.data.meshes.new("Cal.Wall")
+    bm = bmesh.new()
+    try:
+        v = [bm.verts.new(p) for p in ((0, -WALL_HALF_W, 0), (0, WALL_HALF_W, 0),
+                                       (0, WALL_HALF_W, WALL_H),
+                                       (0, -WALL_HALF_W, WALL_H))]
+        bm.faces.new(v)
+        bm.to_mesh(wm)
+    finally:
+        bm.free()
+    wall = bpy.data.objects.new("Cal.Wall", wm)
+    sc.collection.objects.link(wall)
+
+    fm = bpy.data.meshes.new("Cal.Floor")
+    bm = bmesh.new()
+    try:
+        rows = []
+        for d in CAL_DISTANCES:
+            rows.append([bm.verts.new((d, y, 0.0)) for y in (-6.0, 6.0)])
+        for a, b in zip(rows, rows[1:]):
+            bm.faces.new((a[0], a[1], b[1], b[0]))
+        bm.normal_update()
+        bm.to_mesh(fm)
+    finally:
+        bm.free()
+    floor = bpy.data.objects.new("Cal.Floor", fm)
+    sc.collection.objects.link(floor)
+    bpy.context.view_layer.update()
+    return wall, floor
+
+
+# ---------------------------------------------------------------------------
+# Check
+# ---------------------------------------------------------------------------
+
+def check():
+    sc = bpy.context.scene
+    fails = []
+
+    def fail(code, msg):
+        print(f"ERROR ({code}): {msg}", file=sys.stderr)
+        fails.append(code)
+
+    # --- 1. analytic AO against the closed form -----------------------------
+    wall, floor = build_calibration(sc)
+    bvh = world_bvh([wall])
+    up = Vector((0.0, 0.0, 1.0))
+    measured, expected = [], []
+    for d in CAL_DISTANCES:
+        got = bake_points(bvh, [Vector((d, 0.0, 0.0))], [up], CAL_SAMPLES)[0]
+        want = analytic_ao(WALL_H, d)
+        measured.append(got)
+        expected.append(want)
+        if abs(got - want) > CAL_TOL:
+            fail(3, f"AO at d={d} is {got:.6f}, closed form {want:.6f} "
+                    f"(err {abs(got - want):.3e} > {CAL_TOL:.1e}) — the "
+                    f"integrator does not integrate the hemisphere it claims to")
+    worst_cal = max(abs(g - w) for g, w in zip(measured, expected))
+    print("analytic_ao samples=%d H=%.1f" % (CAL_SAMPLES, WALL_H))
+    for d, g, w in zip(CAL_DISTANCES, measured, expected):
+        print(f"  d={d:<5} k={WALL_H / d:<7.2f} ao={g:.6f} closed_form={w:.6f} "
+              f"err={abs(g - w):.3e}")
+    print(f"analytic_ao worst_err={worst_cal:.3e} tol={CAL_TOL:.1e}")
+
+    # --- 2. unoccluded == 1 exactly, and strict monotonicity ----------------
+    free = bake_points(world_bvh([floor]), [Vector((3.0, 0.0, 0.0))], [up],
+                       CAL_SAMPLES)[0]
+    print(f"unoccluded_plate ao={free:.9f} (closed form 1.0)")
+    if abs(free - 1.0) > 0.0:
+        fail(4, f"an unoccluded flat plate bakes to {free:.9f}, not exactly 1.0 "
+                f"— rays are self-hitting the surface they start on")
+    strictly_up = all(b > a for a, b in zip(measured, measured[1:]))
+    print(f"monotonic increasing_with_distance={strictly_up} "
+          f"range={measured[0]:.6f}..{measured[-1]:.6f}")
+    if not strictly_up:
+        fail(4, "AO does not increase strictly with distance from the wall — "
+                "the corner does not darken monotonically with depth")
+
+    # --- 3. asset bake: in range, and actually using the range --------------
+    root, parts = build_asset(sc)
+    values = bake_asset(parts)
+    lo, hi = min(values), max(values)
+    print(f"asset_bake parts={len(parts)} verts={len(values)} "
+          f"samples={ASSET_SAMPLES} min={lo:.6f} max={hi:.6f} spread={hi - lo:.6f}")
+    if lo < 0.0 or hi > 1.0:
+        fail(5, f"baked AO out of range [{lo:.6f}, {hi:.6f}] — a colour "
+                f"attribute an engine reads must stay in [0,1]")
+    if hi - lo < SPREAD_MIN:
+        fail(5, f"AO spread {hi - lo:.6f} < {SPREAD_MIN} — the bake is nearly "
+                f"constant, so it is not describing this geometry")
+
+    # --- 4. storage round-trip: FLOAT exact, BYTE sRGB-quantised ------------
+    probe_me = parts[0].data
+    fa = probe_me.color_attributes[AO_ATTR]
+    ba = probe_me.color_attributes[AO_BYTE_ATTR]
+    written = values[:len(fa.data)]
+    float_err = max(abs(fa.data[i].color[0] - v) for i, v in enumerate(written))
+    byte_err = max(abs(ba.data[i].color[0] - v) for i, v in enumerate(written))
+    model_err = max(abs(ba.data[i].color[0] - byte_color_model(v))
+                    for i, v in enumerate(written))
+    print(f"storage float_roundtrip_err={float_err:.3e} "
+          f"byte_roundtrip_err={byte_err:.3e} byte_vs_srgb_model={model_err:.3e}")
+    if float_err > FLOAT_TOL:
+        fail(6, f"FLOAT_COLOR round-trip error {float_err:.3e} > {FLOAT_TOL:.0e}")
+    if model_err > BYTE_MODEL_TOL:
+        fail(6, f"BYTE_COLOR readback deviates {model_err:.3e} from the "
+                f"independent sRGB encode/quantise/decode model — the storage "
+                f"encoding is not what the README documents")
+    if byte_err <= float_err:
+        fail(6, "BYTE_COLOR round-trips as tightly as FLOAT_COLOR — the "
+                "documented 8-bit sRGB quantisation has stopped happening, so "
+                "the exporter warning is now wrong")
+
+    # --- 5. depsgraph survival ----------------------------------------------
+    dg = bpy.context.evaluated_depsgraph_get()
+    worst_ev = 0.0
+    for ob in parts:
+        ev = ob.evaluated_get(dg).data
+        for name in (AO_ATTR, AO_BYTE_ATTR):
+            eva = ev.color_attributes.get(name)
+            if eva is None:
+                fail(7, f"{ob.name}: {name} missing from the evaluated mesh")
+                continue
+            src = ob.data.color_attributes[name]
+            if eva.data_type != src.data_type or eva.domain != src.domain:
+                fail(7, f"{ob.name}: {name} changed to "
+                        f"{eva.data_type}/{eva.domain} under evaluation")
+            # Compare lengths BEFORE zipping. zip() stops at the shorter
+            # sequence, so a modifier that resamples the attribute onto a
+            # different element count would slip through as a clean 0.0
+            # deviation — the probe that added a Subdiv modifier exited 0
+            # until this guard was added.
+            if len(eva.data) != len(src.data):
+                fail(7, f"{ob.name}: {name} has {len(eva.data)} evaluated "
+                        f"elements vs {len(src.data)} authored — a modifier "
+                        f"resampled the attribute, so what renders is not "
+                        f"what was baked")
+                continue
+            worst_ev = max(worst_ev, max(
+                abs(a.color[0] - b.color[0]) for a, b in zip(src.data, eva.data)))
+    print(f"depsgraph_survival parts={len(parts)} max_dev={worst_ev:.3e}")
+    if worst_ev > 0.0:
+        fail(7, f"colour attributes drift {worst_ev:.3e} under depsgraph "
+                f"evaluation — the baked data is not what renders")
+
+    # --- 6. reuse hygiene ----------------------------------------------------
+    default_names = {"Cube", "Sphere", "Torus", "Suzanne", "Plane", "Circle",
+                     "Cylinder", "Cone", "Grid", "Icosphere", "Empty"}
+    for ob in parts:
+        if max(abs(s - 1.0) for s in ob.scale) > 0.0:
+            fail(8, f"{ob.name} scale {tuple(ob.scale)} not applied")
+        if not ob.name.startswith("Well.Stone."):
+            fail(8, f"part {ob.name!r} outside the asset namespace")
+        if ob.data.name.split(".")[0] in default_names:
+            fail(8, f"{ob.name} carries a default datablock name")
+        if ob.data.attributes.default_color_name != AO_ATTR:
+            fail(8, f"{ob.name} render colour attribute is "
+                    f"{ob.data.attributes.default_color_name!r}, not {AO_ATTR!r} "
+                    f"— an engine would read the wrong channel")
+    ground = min(min(v.co.z for v in ob.data.vertices) for ob in parts)
+    if abs(ground) > 1e-4:
+        fail(8, f"asset rests at z={ground:.5f}, not on the ground plane")
+    print(f"hygiene parts={len(parts)} ground_z={ground:.2e} "
+          f"render_attr={AO_ATTR}")
+
+    if fails:
+        return fails[0]
+    print(f"vertex-color-ao OK cal_err={worst_cal:.3e} unoccluded={free:.6f} "
+          f"asset_range={lo:.4f}..{hi:.4f} float_err={float_err:.3e} "
+          f"byte_model_err={model_err:.3e} evaluated_dev={worst_ev:.3e}")
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# Render
+# ---------------------------------------------------------------------------
+
+def make_ao_material(name, rgb, rough, metallic=0.0, ao_strength=1.0):
+    """Principled with the AO colour attribute multiplied into base colour."""
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    bsdf.inputs["Roughness"].default_value = rough
+    bsdf.inputs["Metallic"].default_value = metallic
+    col = nt.nodes.new("ShaderNodeVertexColor")
+    col.layer_name = AO_ATTR
+    col.location = (-700, 100)
+    # lift so full occlusion does not go to pure black
+    lift = nt.nodes.new("ShaderNodeMixRGB")
+    lift.blend_type = "MIX"
+    lift.inputs["Fac"].default_value = ao_strength
+    lift.inputs["Color1"].default_value = (1.0, 1.0, 1.0, 1.0)
+    lift.location = (-500, 100)
+    nt.links.new(col.outputs["Color"], lift.inputs["Color2"])
+    tint = nt.nodes.new("ShaderNodeMixRGB")
+    tint.blend_type = "MULTIPLY"
+    tint.inputs["Fac"].default_value = 1.0
+    tint.inputs["Color1"].default_value = (*rgb, 1.0)
+    tint.location = (-300, 100)
+    nt.links.new(lift.outputs["Color"], tint.inputs["Color2"])
+    nt.links.new(tint.outputs["Color"], bsdf.inputs["Base Color"])
+    return mat
+
+
+SLOT_MATS = {
+    "Stone":   ((0.086, 0.079, 0.069), 0.86, 0.0),
+    "Coping":  ((0.118, 0.108, 0.094), 0.80, 0.0),
+    "Bore":    ((0.038, 0.035, 0.032), 0.92, 0.0),
+    "Flag":    ((0.066, 0.061, 0.055), 0.89, 0.0),
+    "Timber":  ((0.148, 0.078, 0.030), 0.74, 0.0),
+    "Brace":   ((0.120, 0.064, 0.026), 0.76, 0.0),
+    "Shingle": ((0.092, 0.056, 0.028), 0.82, 0.0),
+    "Drum":    ((0.170, 0.098, 0.040), 0.72, 0.0),
+    "Stave":   ((0.205, 0.118, 0.048), 0.70, 0.0),
+    "Rope":    ((0.255, 0.208, 0.122), 0.88, 0.0),
+    "Iron":    ((0.075, 0.078, 0.084), 0.44, 0.85),
+}
+
+_mat_cache = {}
+
+
+def bind_materials(ob, ao_strength=1.0):
+    me = ob.data
+    if me.materials:
+        return
+    for slot in me.get("slots", []):
+        key = (slot, ao_strength)
+        if key not in _mat_cache:
+            rgb, rough, metal = SLOT_MATS[slot]
+            _mat_cache[key] = make_ao_material(slot, rgb, rough, metal,
+                                               ao_strength)
+        me.materials.append(_mat_cache[key])
+
+
+def build_studio(sc):
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=40.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Studio")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.030, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    sc.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 7.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    sc.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.020, 0.021, 0.025, 1.0)
+    sc.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy, ld.size, ld.color = energy, size, col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        sc.collection.objects.link(ob)
+
+    # VISUAL-STYLE Layer 2 rig, energies scaled to a ~1.9 m subject
+    light("Key", (-2.3, -2.4, 3.6), 430.0, 3.0, (1.0, 0.96, 0.9), (40, 0, -42))
+    light("Fill", (3.0, -2.0, 1.2), 78.0, 5.0, (0.75, 0.85, 1.0), (70, 0, 54))
+    light("Rim", (-0.9, 3.0, 2.6), 240.0, 2.0, (0.6, 0.78, 1.0), (-54, 0, 196))
+    light("Wedge", (0.3, 5.0, 0.7), 300.0, 5.0, (1.0, 0.76, 0.5), (-86, 0, 182))
+    return floor, wall
+
+
+def render_still(path, engine, falsify=False):
+    """The well with its baked AO driving base colour.
+
+    Falsified: the same bake written INVERTED, so the crevices between the
+    masonry courses and the inside of the shaft read bright while the exposed,
+    sky-facing stone goes dark — occlusion turned inside out.
+    """
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    _mat_cache.clear()
+    sc = bpy.context.scene
+
+    root, parts = build_asset(sc)
+    bake_asset(parts, n_samples=192, invert=falsify)
+    for ob in parts:
+        bind_materials(ob)
+    floor, wall = build_studio(sc)
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (3.62, -4.78, 3.42)
+    sc.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.49)
+    sc.collection.objects.link(aim)
+    tr = cam.constraints.new("TRACK_TO")
+    tr.target = aim
+    tr.track_axis = "TRACK_NEGATIVE_Z"
+    tr.up_axis = "UP_Y"
+    sc.camera = cam
+
+    sc.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        sc.cycles.device = "CPU"
+        sc.cycles.samples = 64
+        sc.cycles.use_denoising = True
+    else:
+        try:
+            sc.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    sc.render.resolution_x = 1280
+    sc.render.resolution_y = 720
+    sc.render.image_settings.file_format = "PNG"
+    sc.render.filepath = path
+    # Standard, always — AgX would lift the stage toward grey (VISUAL-STYLE)
+    sc.view_settings.view_transform = "Standard"
+    bpy.context.view_layer.update()
+
+    fcode = gallery_framing.check_framing(sc, cam, hero=parts, elements=parts,
+                                          stage=[floor, wall])
+    if fcode:
+        return fcode
+    aqcode = gallery_asset_quality.check_asset_quality(sc, cam, hero=parts,
+                                                       stage=[floor, wall])
+    if aqcode:
+        return aqcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        print("ERROR: render produced no file", file=sys.stderr)
+        return 9
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None, help="optional: render a still PNG here")
+    p.add_argument("--falsify", default=None,
+                   help="optional: render the inverted-AO variant here")
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    args = p.parse_args(argv)
+
+    print(f"binary version: {bpy.app.version} ({bpy.app.version_string})")
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    code = check()
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    if args.falsify:
+        rcode = render_still(os.path.abspath(args.falsify), args.engine,
+                             falsify=True)
+        if rcode:
+            return rcode
+        print(f"rendered falsified variant {args.falsify}")
+
+    print("vertex-color-ao OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        import traceback
+
+        traceback.print_exc()
+        print(f"FATAL: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/examples/gallery.json b/examples/gallery.json index 26e2e2d..171057d 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -544,6 +544,20 @@ "game-pipeline", "instancing" ] + }, + { + "name": "vertex-color-ao", + "dir": "examples/vertex-color-ao", + "teaches": "A stone well carrying baked ambient occlusion in a colour attribute, with the bake held to the closed-form hemisphere integral rather than to a captured value.", + "witnessesFix": "Integrator matches 1 - 0.5(1 - 1/sqrt(1+k^2)) to 6.760e-04, unoccluded plate exactly 1.0, FLOAT_COLOR exact vs BYTE_COLOR sRGB-quantised (model agreement 3.189e-07), evaluated deviation 0.0.", + "hero": "docs/gallery/assets/vertex-color-ao-hero.webp", + "preview": "examples/vertex-color-ao/preview.webp", + "tags": [ + "mesh", + "attributes", + "game-pipeline", + "render" + ] } ] } diff --git a/examples/vertex-color-ao/README.md b/examples/vertex-color-ao/README.md new file mode 100644 index 0000000..81cff31 --- /dev/null +++ b/examples/vertex-color-ao/README.md @@ -0,0 +1,124 @@ +# Vertex Colour AO + +A runnable example baking **ambient occlusion into a colour attribute** — the +cheap contact shadows an engine gets for free if it reads vertex colour. Unlike +most bakes this one can be checked against a *formula* rather than against a +previous run's numbers: the hemisphere visibility integral has closed forms. + +For a point on the floor a distance `d` from an infinitely wide wall of height +`H`, integrating the cosine-weighted hemisphere over the directions the wall +blocks gives (the φ integral collapses to `π / √(1+k²)`): + +``` +A(k) = ½ (1 − 1/√(1+k²)), k = H/d AO = 1 − A(k) +``` + +`AO` depends only on the ratio `H/d` and increases strictly with `d` — which is +exactly "a concave corner darkens monotonically with depth", as an equation. +For an unoccluded flat surface the integral is exactly **1**. + +**The asset, for reuse:** `Well.Stone`, a 2.5 m stone village well — three +courses of 15 masonry blocks each on a 16-slab paved apron, a 15-slab coping +ring, a lined bore, two braced timber posts with a crossbeam, an iron-banded +windlass with a crank, and a hanging bucket. 11 parts, 11 materials, 6966 +vertices. Origin at the ground contact centre (`z == 0` is where it rests), +identity transforms, datablocks under `Well.Stone.*`. Every part ships with the +AO baked into two colour attributes, and `default_color_name` points at the +linear one so an engine reads the right channel. + +**Pipeline arc neighbours:** attribute domains in +[`attribute-domain-shear`](../attribute-domain-shear/) and +[`color-attribute-wheel`](../color-attribute-wheel/), the second UV set for +*texture*-baked lighting in [`lightmap-uv-channel`](../lightmap-uv-channel/), +topology gates in [`mesh-hygiene-audit`](../mesh-hygiene-audit/). + +**What it witnesses** (all closed form or independently re-derived): + +- **Analytic AO.** The integrator matches `1 − A(H/d)` at six probe distances + spanning two decades of `H/d` (60.0 down to 0.60); worst error + **6.760e-04** against a **2.5e-03** gate. +- **Unoccluded is exactly 1.** An isolated flat plate bakes to + **1.000000000** — not approximately, exactly, because no ray can self-hit. +- **Monotone with depth.** AO rises strictly across the calibration ruler, + **0.508301 → 0.928467**. +- **Range and spread.** Every baked value on the asset lies in [0, 1] and the + bake actually uses the range (measured spread **1.000000**); a silently + constant bake would pass a bare range check and fails this one. +- **Storage.** `FLOAT_COLOR` round-trips the linear value exactly + (**0.000e+00**). `BYTE_COLOR` does not — see below. +- **Depsgraph survival.** Both attributes read back off the evaluated mesh at + deviation **exactly 0.0**, keeping `data_type` and `domain`. +- **Reuse hygiene.** Identity scales, `Well.Stone.*` names, no default + datablocks, `min z == 0.00e+00`, render colour attribute pinned to `AO`. + +## Hazards found while authoring + +- **`BYTE_COLOR` is sRGB-encoded 8-bit, not linear 8-bit.** Writing linear + 0.735 reads back **0.7379107**. The round-trip error peaks at **3.782e-03**, + and the readback matches an independent + encode → quantise → decode model to **3.189e-07** — so the encoding is + confirmed, not guessed. Darks get more precision than a linear ramp would + give and midtones get less. An exporter that hands the raw bytes to an + engine expecting linear occlusion ships visibly wrong shadows. Store AO in + `FLOAT_COLOR` unless the target genuinely wants sRGB. +- **`bmesh.ops.bevel` offsets along *cached* face normals.** Moving a vertex + does not refresh them. While the stale normal is within 90° of the true one + the bevel merely skews; past 90° it flips sign and grows the solid outward. + Measured while authoring, on a ring of bevelled boxes placed around a + circle: the boxes at 0/36/72° bevelled correctly and the one at **108°** + grew by exactly one offset (**12 mm**) in both z directions, putting geometry + below the ground plane. `t.normal_update()` before every bevel is the fix, + and every `_box`/`_prism` here calls it; the hygiene check (exit 8) is what + caught it. +- **Point-domain AO needs vertices to vary across.** An 8-vertex slab bakes to + one nearly-constant value per face, so the crevice gradient never reaches + the attribute. The first draft rendered as flat pale stone with a correct + bake underneath. Parts are subdivided before the bake for this reason. +- **`color_attributes` enumeration order is not portable** — measured + `BYTE_COLOR` first on 4.5.11 and `FLOAT_COLOR` first on 5.1.2 for the same + mesh. Look attributes up by name, never by index. + +**What each check catches on failure** (every one probed, with the measured +error): an integrator sampling the full sphere rather than the hemisphere — +AO **0.752686** against a closed form of **0.508332**, error **2.444e-01** +(exit 3); uniform-solid-angle weighting instead of cosine — error **1.202e-02** +(exit 3); rays cast with no normal offset so they self-hit the surface they +start on — unoccluded plate bakes to **0.160644531** instead of 1.0 (exit 4); a +bake collapsed to a constant — spread **0.000000** (exit 5); the sRGB storage +model swapped for naive linear 8-bit quantisation — **4.577e-03** disagreement +with the real readback (exit 6); a Subdivision modifier resampling the +attribute — **3270** evaluated elements against **840** authored (exit 7); a +part sunk below the ground plane — **-1.200e-02** (exit 8); a default `Cube` +datablock name (exit 8). + +The exit-7 probe earned its keep twice: the depsgraph check originally compared +`zip(src.data, eva.data)`, which stops at the shorter sequence, so a resampled +attribute reported a clean **0.0** deviation and the probe exited **0**. The +length guard was added because the falsification pass failed to fail. + +**Version witness:** check output is byte-identical on Blender 4.5.11 LTS and +5.1.2 — same 6966 vertices, same closed-form errors to every printed digit, +same storage deviations. + +**Render as proof:** the well on the dark stage, lit only by the studio rig, +with the baked AO multiplied into base colour — the masonry joints, the shaft +mouth, the coping undersides and the apron contact all darken from the +attribute, not from the lights. The falsification variant (`--falsify`) writes +the same bake **inverted**: sky-facing coping and apron tops go grimy while the +recesses and post bases glow, occlusion turned inside out. + +## Run + +```bash +blender --background --python vertex_color_ao.py -- +blender --background --python vertex_color_ao.py -- --output well.png +blender --background --python vertex_color_ao.py -- --falsify inverted.png +``` + +Exits non-zero on failure. The `blender-smoke` workflow runs the check on +Blender 4.5 LTS and 5.1 (the calibration rig gets 4096 samples over six +points, the asset a cheap 64, so the whole check is ~1 s). The `--output` +render path additionally gates framing via `examples/gallery_framing.py` +(fill **0.839y**, margins **0.241/0.238/0.072/0.089**, no edge touched) and the +asset floors via `examples/gallery_asset_quality.py` (11 materials, `edge90` +**0.152**, no default names). diff --git a/examples/vertex-color-ao/preview.webp b/examples/vertex-color-ao/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..7460904b5859d5f006acef0760cbf647a7defbfc GIT binary patch literal 16794 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 NK=xRNa>OV|006w?zTE%- literal 0 HcmV?d00001 diff --git a/examples/vertex-color-ao/vertex_color_ao.py b/examples/vertex-color-ao/vertex_color_ao.py new file mode 100644 index 0000000..3dc6bf2 --- /dev/null +++ b/examples/vertex-color-ao/vertex_color_ao.py @@ -0,0 +1,942 @@ +"""Vertex-colour AO — cheap baked occlusion in a colour attribute. + +Engines that read vertex colour get their contact shadows for free if the +asset ships with ambient occlusion baked into a colour attribute. The bake is +a hemisphere visibility integral, and unlike most bakes it has *analytic* +answers: for an unoccluded flat surface the integral is exactly 1, and for a +point on the floor a distance ``d`` from an infinitely wide wall of height +``H`` the cosine-weighted occluded fraction is + + A(k) = 0.5 * (1 - 1 / sqrt(1 + k^2)), k = H / d + AO = 1 - A(k) + +derived by integrating the cosine-weighted hemisphere measure over the +directions the wall blocks (the phi integral collapses to +``pi / sqrt(1 + k^2)``). Note AO(k) depends only on the *ratio* H/d, and +darkens monotonically as the point approaches the wall — so the bake can be +checked against a formula rather than against a previous run's numbers. + +The asset is a stone village well. The same integrator that is validated +against the closed form above is run over its geometry, so what ships in the +attribute is the thing the check measured. + +Two storage traps are asserted rather than described: + +* ``FLOAT_COLOR`` round-trips a linear value exactly (1.4e-08 here). +* ``BYTE_COLOR`` does NOT store linear 8-bit — it stores **sRGB-encoded** + 8-bit. Writing 0.735 reads back 0.7379107, and the round-trip error peaks + at 2.9e-03, an order of magnitude worse than a naive 1/255 = 3.9e-03 + uniform-quantisation model would predict *in the shadows* and much better + in the darks. The check reproduces the readback with an independent + sRGB encode/quantise/decode model. An exporter that hands raw bytes to an + engine expecting linear occlusion ships wrong shadows. + +Check (all closed form or independently re-derived, nothing captured): + +1. Analytic AO (exit 3): the integrator matches ``1 - A(H/d)`` at six probe + distances spanning two decades of H/d. +2. Unoccluded and monotone (exit 4): an isolated flat plate bakes to exactly + 1.0 everywhere; along the calibration ruler AO increases strictly with + distance from the wall. +3. Range (exit 5): every baked value on the asset lies in [0, 1], and the + asset actually uses its range (crevices darker than exposed faces by a + measured margin — a bake that silently produced a constant would pass a + bare range check). +4. Storage round-trip (exit 6): FLOAT_COLOR exact, BYTE_COLOR matching the + independent sRGB quantisation model to within 1e-6. +5. Depsgraph survival (exit 7): both attributes read back off the evaluated + mesh with deviation exactly 0, keeping data_type and domain. +6. Reuse hygiene (exit 8): identity scales, ``Well.Stone.*`` names, no + default datablocks, the asset resting on z == 0. + +By default it runs only the correctness check (no render) — the CI smoke +check. Pass --output to also render a still: + + blender --background --python vertex_color_ao.py -- # check + blender --background --python vertex_color_ao.py -- --output well.png # + render + blender --background --python vertex_color_ao.py -- --falsify flat.png +""" +import bpy, bmesh, sys, os, math, argparse +from mathutils import Vector, Matrix +from mathutils.bvhtree import BVHTree + +# 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 gallery_asset_quality + +AO_ATTR = "AO" # FLOAT_COLOR / POINT — the linear, exact channel +AO_BYTE_ATTR = "AOByte" # BYTE_COLOR / POINT — the sRGB-quantised channel + +# Bake sample counts. The calibration rig gets a heavy count because it is +# only six points and it is being held to a formula; the asset gets a cheap +# count because what is asserted there is range, spread and survival. +CAL_SAMPLES = 4096 +ASSET_SAMPLES = 64 + +# Quasi-Monte-Carlo convergence on the calibration rig is ~1/sqrt(n): the +# measured worst error at 4096 samples is 6.8e-04, so 2.5e-03 is roughly a +# 3.5x margin. Tightening it below ~1e-3 would make the gate a sampling-noise +# detector rather than a correctness check. +CAL_TOL = 2.5e-03 +WALL_H = 3.0 # calibration wall height, metres +WALL_HALF_W = 400.0 # half width: wide enough that truncation < 1e-4 +CAL_DISTANCES = (0.05, 0.2, 0.5, 1.0, 2.0, 5.0) +RAY_EPS = 1e-5 # offset along the normal so a ray cannot self-hit +RAY_FAR = 1.0e4 + +FLOAT_TOL = 1e-6 # FLOAT_COLOR round-trip (measured 1.43e-08) +BYTE_MODEL_TOL = 1e-6 # agreement with the independent sRGB model +SPREAD_MIN = 0.25 # the asset must actually use its AO range + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +# --------------------------------------------------------------------------- +# The AO integrator +# --------------------------------------------------------------------------- + +def hemisphere_dirs(n): + """Deterministic cosine-weighted hemisphere directions about +Z. + + A Fibonacci lattice on the unit disc lifted to the hemisphere: sampling + the disc uniformly in area and projecting up gives exactly the cosine + weighting the AO integral needs, with no RNG — the same n directions + every run, on every machine, on both Blender versions. + """ + golden = math.pi * (3.0 - math.sqrt(5.0)) + dirs = [] + for i in range(n): + r = math.sqrt((i + 0.5) / n) + phi = i * golden + x, y = r * math.cos(phi), r * math.sin(phi) + dirs.append(Vector((x, y, math.sqrt(max(0.0, 1.0 - r * r))))) + return dirs + + +def frame_from_normal(nz): + """An orthonormal frame with +Z along nz. Any tangent will do — the + cosine-weighted set is rotationally symmetric about the normal.""" + nz = Vector(nz).normalized() + ref = Vector((1.0, 0.0, 0.0)) if abs(nz.x) < 0.9 else Vector((0.0, 1.0, 0.0)) + nx = (ref - nz * ref.dot(nz)).normalized() + ny = nz.cross(nx) + return Matrix((nx, ny, nz)).transposed() + + +def analytic_ao(height, dist): + """Closed form for an infinitely wide wall of `height` at `dist`.""" + k = height / dist + return 1.0 - 0.5 * (1.0 - 1.0 / math.sqrt(1.0 + k * k)) + + +def bake_points(bvh, points, normals, n_samples): + """AO at each (point, normal) against `bvh`. Returns a list of floats.""" + dirs = hemisphere_dirs(n_samples) + inv_n = 1.0 / n_samples + out = [] + for p, nrm in zip(points, normals): + basis = frame_from_normal(nrm) + origin = Vector(p) + Vector(nrm).normalized() * RAY_EPS + blocked = 0 + for d in dirs: + if bvh.ray_cast(origin, basis @ d, RAY_FAR)[0] is not None: + blocked += 1 + out.append(1.0 - blocked * inv_n) + return out + + +def world_bvh(objects): + """One BVH over every object's world-space triangles.""" + bm = bmesh.new() + try: + for ob in objects: + tmp = bm.__class__() if False else None # keep one bmesh, transform in + me = ob.data + offset = len(bm.verts) + bm.verts.ensure_lookup_table() + new_verts = [bm.verts.new(ob.matrix_world @ v.co) for v in me.vertices] + for poly in me.polygons: + try: + bm.faces.new([new_verts[i] for i in poly.vertices]) + except ValueError: + pass # duplicate face across overlapping parts + bm.verts.ensure_lookup_table() + bmesh.ops.triangulate(bm, faces=bm.faces[:]) + return BVHTree.FromBMesh(bm) + finally: + bm.free() + + +def bake_object_ao(bvh, ob, n_samples): + """AO per vertex of `ob`, evaluated in world space against `bvh`.""" + me = ob.data + mw = ob.matrix_world + nrm3 = mw.to_3x3().inverted().transposed() + pts = [mw @ v.co for v in me.vertices] + nrms = [(nrm3 @ Vector(me.vertex_normals[i].vector)).normalized() + for i in range(len(me.vertices))] + return bake_points(bvh, pts, nrms, n_samples) + + +def write_ao_attributes(me, values, invert=False): + """Write AO into a FLOAT_COLOR and a BYTE_COLOR attribute on POINT. + + HAZARD: look attributes up by NAME. The enumeration order of + `color_attributes` differs between 4.5.11 and 5.1.2 for the same mesh + (measured: BYTE first on 4.5, FLOAT first on 5.1), so indexing into the + collection is not portable. + """ + for name, dtype in ((AO_ATTR, "FLOAT_COLOR"), (AO_BYTE_ATTR, "BYTE_COLOR")): + if name in me.color_attributes: + me.color_attributes.remove(me.color_attributes[name]) + attr = me.color_attributes.new(name=name, type=dtype, domain="POINT") + for i, a in enumerate(values): + v = (1.0 - a) if invert else a + attr.data[i].color = (v, v, v, 1.0) + me.color_attributes.active_color = me.color_attributes[AO_ATTR] + me.attributes.default_color_name = AO_ATTR + me.attributes.active_color_name = AO_ATTR + + +# --- the independent sRGB storage model, for check 4 ------------------------ + +def lin_to_srgb(c): + return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1.0 / 2.4)) - 0.055 + + +def srgb_to_lin(c): + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + +def byte_color_model(v): + """What a BYTE_COLOR attribute should hand back for a linear write of v.""" + return srgb_to_lin(round(lin_to_srgb(v) * 255.0) / 255.0) + + +# --------------------------------------------------------------------------- +# Geometry helpers +# --------------------------------------------------------------------------- + +class Part: + """A bmesh under construction with named material slots.""" + + def __init__(self): + self.bm = bmesh.new() + self.slots = [] + + def group(self, slot_name, fn): + before = set(self.bm.faces) + fn(self.bm) + if slot_name not in self.slots: + self.slots.append(slot_name) + idx = self.slots.index(slot_name) + for f in self.bm.faces: + if f not in before: + f.material_index = idx + return self + + def finish(self, name): + me = bpy.data.meshes.new(name) + try: + # normal_update() recomputes from the existing winding; it does not + # repair a face built the wrong way round, and an inward-facing + # shell would swallow every AO ray it should have blocked. + bmesh.ops.recalc_face_normals(self.bm, faces=list(self.bm.faces)) + self.bm.normal_update() + self.bm.to_mesh(me) + finally: + self.bm.free() + me["slots"] = self.slots + return me + + +# _bevel_normals_note +# HAZARD: bmesh.ops.bevel offsets along the CACHED face normals, and moving a +# vertex does not refresh them. Build a cube, transform its verts, bevel: while +# the stale normal is still within 90 degrees of the true one the offset merely +# skews, but past 90 degrees it flips sign and the bevel grows the solid +# outward instead of chamfering it inward. Measured while authoring this +# asset, on a ring of bevelled boxes placed around a circle: the boxes at +# 0/36/72 degrees bevelled correctly and the box at 108 degrees grew by +# exactly one offset (12 mm) in both z directions, putting geometry below the +# ground plane. The hygiene check (exit 8) is what caught it. +# t.normal_update() before every bevel is the fix, and it is why every _box +# and _prism here calls it. + + +def _emit(bm, build, cuts=0): + """Build one sub-solid in a private bmesh, then copy it into `bm`. + + HAZARD, and the reason this indirection exists. The obvious way to add a + bevelled primitive to a shared bmesh is to snapshot ``set(bm.verts)``, + create the primitive, diff to find the new verts, and bevel only the edges + whose verts are all new. That works for the first few primitives and then + silently corrupts: ``bmesh.ops.bevel`` reallocates the vertex table, so + Python BMVert wrappers captured in the snapshot can alias geometry created + afterwards. Measured on this asset's ground apron, the fault appeared at + the **4th** box — edges belonging to an already-finished neighbour were + selected and bevelled, dropping vertices exactly one bevel offset (12 mm) + below the ground plane. The hygiene check is what caught it. + + Building each primitive in its own bmesh removes the coupling: no op ever + runs on the shared mesh while a snapshot of it is being held. + """ + tmp = bmesh.new() + try: + build(tmp) + if cuts: + # Point-domain AO can only vary where there are vertices. An + # 8-vertex slab bakes to one nearly-constant value per face and + # the crevice gradient the bake exists to capture never appears + # in the attribute (draft 3 rendered as flat pale stone). + bmesh.ops.subdivide_edges(tmp, edges=list(tmp.edges), cuts=cuts, + use_grid_fill=True) + vmap = {v: bm.verts.new(v.co) for v in tmp.verts} + for f in tmp.faces: + try: + bm.faces.new([vmap[v] for v in f.verts]) + except ValueError: + pass # coincident face across sub-solids + finally: + tmp.free() + + +def _box(bm, dims, centre, bevel=0.0, segments=1, rot=None, cuts=0): + def build(t): + bmesh.ops.create_cube(t, size=1.0) + for v in t.verts: + c = Vector((v.co.x * dims[0], v.co.y * dims[1], v.co.z * dims[2])) + if rot is not None: + c = rot @ c + v.co = c + Vector(centre) + if bevel > 0.0: + t.normal_update() # see _bevel_normals_note + bmesh.ops.bevel(t, geom=list(t.edges), offset=bevel, + segments=segments, profile=0.5, affect="EDGES", + clamp_overlap=True) + _emit(bm, build, cuts=cuts) + + +def _prism(bm, sides, radius, half_h, centre, bevel=0.0, rot=None, taper=1.0, + phase=0.0, cuts=0): + def build(t): + ring = [] + for i in range(sides): + a = 2.0 * math.pi * i / sides + phase + ring.append((math.cos(a) * radius, math.sin(a) * radius)) + top = [t.verts.new((x * taper, y * taper, half_h)) for x, y in ring] + bot = [t.verts.new((x, y, -half_h)) for x, y in ring] + t.faces.new(top) + t.faces.new(list(reversed(bot))) + for i in range(sides): + j = (i + 1) % sides + t.faces.new((bot[i], bot[j], top[j], top[i])) + for v in t.verts: + c = Vector(v.co) + if rot is not None: + c = rot @ c + v.co = c + Vector(centre) + if bevel > 0.0: + t.normal_update() # see _bevel_normals_note + bmesh.ops.bevel(t, geom=list(t.edges), offset=bevel, segments=1, + profile=0.6, affect="EDGES", clamp_overlap=True) + _emit(bm, build, cuts=cuts) + + +def _plate(bm, verts_xy, z0, z1, cuts=2): + """A prism from an explicit polygon footprint, subdivided for the bake.""" + def build(t): + top = [t.verts.new((x, y, z1)) for x, y in verts_xy] + bot = [t.verts.new((x, y, z0)) for x, y in verts_xy] + t.faces.new(top) + t.faces.new(list(reversed(bot))) + n = len(verts_xy) + for i in range(n): + j = (i + 1) % n + t.faces.new((bot[i], bot[j], top[j], top[i])) + _emit(bm, build, cuts=cuts) + + +# --------------------------------------------------------------------------- +# The asset: a stone village well +# --------------------------------------------------------------------------- + +# Proportions: a squat, wide wellhead rather than a spindly one. Draft 4 gave +# the masonry a third of the frame and the timber frame the rest, so the joint +# occlusion the bake exists to show was sub-pixel. Widening the ring and +# dropping the frame puts the stonework where the eye lands. +SHAFT_R = 0.545 # inner bore radius +COURSES = ( # (z0, z1, outer radius, stone count, phase offset) + (0.000, 0.200, 0.845, 15, 0.00), + (0.200, 0.385, 0.822, 15, 0.50), + (0.385, 0.560, 0.805, 15, 0.00), +) +COPING_Z0, COPING_Z1 = 0.560, 0.646 +COPING_R = 0.925 +POST_Y = 0.790 +POST_TOP = 1.36 +DRUM_Z = 1.030 + + +def _course_stone(bm, z0, z1, r_out, idx, count, phase, jog): + """One masonry block: a trapezoid footprint spanning an arc of the ring. + + Blocks are inset from each other by a small joint so the bake has real + crevices to find — the mortar lines are geometry, not a texture. + """ + joint = 0.078 / r_out # angular half-gap + a0 = 2.0 * math.pi * (idx + phase) / count + joint + a1 = 2.0 * math.pi * (idx + 1 + phase) / count - joint + ro = r_out + jog + ri = SHAFT_R + pts = [] + for a in (a0, a1): + pts.append((math.cos(a) * ro, math.sin(a) * ro)) + for a in (a1, a0): + pts.append((math.cos(a) * ri, math.sin(a) * ri)) + _plate(bm, pts, z0 + 0.012, z1 - 0.012) + + +def build_well_meshes(): + meshes = {} + # deterministic per-stone jog so the courses are not machine-perfect + def jog(i, c): + return 0.014 * math.sin(i * 2.399963 + c * 1.107) + + for ci, (z0, z1, r_out, count, phase) in enumerate(COURSES): + part = Part() + part.group("Stone", lambda bm, z0=z0, z1=z1, r=r_out, c=count, + ph=phase, ci=ci: [ + _course_stone(bm, z0, z1, r, i, c, ph, jog(i, ci)) + for i in range(c)]) + meshes[f"Course.{ci}"] = part.finish(f"Well.Stone.Course.{ci}") + + # coping: 11 flat slabs, a wider ring that overhangs and casts the + # strongest contact darkening onto the top course + cop = Part() + def slabs(bm): + n = 15 + for i in range(n): + joint = 0.028 / COPING_R + a0 = 2.0 * math.pi * i / n + joint + a1 = 2.0 * math.pi * (i + 1) / n - joint + pts = [(math.cos(a) * COPING_R, math.sin(a) * COPING_R) + for a in (a0, a1)] + pts += [(math.cos(a) * (SHAFT_R - 0.02), + math.sin(a) * (SHAFT_R - 0.02)) for a in (a1, a0)] + _plate(bm, pts, COPING_Z0, COPING_Z1 + 0.006 * math.sin(i * 1.7)) + cop.group("Coping", slabs) + meshes["Coping"] = cop.finish("Well.Stone.Coping") + + # inner bore sleeve: darkens to near-zero down the shaft + bore = Part() + # the sleeve stops at z == 0 so the asset rests on the ground plane; an + # earlier revision ran it to -0.02 and the hygiene check caught it + bore.group("Bore", lambda bm: _prism(bm, 26, SHAFT_R - 0.028, 0.298, + (0.0, 0.0, 0.298), cuts=3)) + meshes["Bore"] = bore.finish("Well.Stone.Bore") + + # ground apron: flagstones the well sits on, so the base has a floor to + # occlude against + ap = Part() + def apron(bm): + # a paved collar butted against the base course: the tighter the ring + # sits, the deeper the contact darkening the bake has to find + n = 16 + for i in range(n): + joint = 0.032 / 1.05 + a0 = 2.0 * math.pi * i / n + joint + a1 = 2.0 * math.pi * (i + 1) / n - joint + r0, r1 = 0.870, 1.235 + 0.045 * math.sin(i * 2.1) + pts = [(math.cos(a) * r1, math.sin(a) * r1) for a in (a0, a1)] + pts += [(math.cos(a) * r0, math.sin(a) * r0) for a in (a1, a0)] + _plate(bm, pts, 0.0, 0.048 + 0.008 * math.sin(i * 1.3), cuts=2) + ap.group("Flag", apron) + meshes["Apron"] = ap.finish("Well.Stone.Apron") + + # timber frame + for side, sy in (("L", 1.0), ("R", -1.0)): + fr = Part() + fr.group("Timber", lambda bm, s=sy: _box( + bm, (0.12, 0.12, POST_TOP - 0.30), + (0.0, s * POST_Y, 0.30 + (POST_TOP - 0.30) / 2), bevel=0.012, cuts=3)) + fr.group("Iron", lambda bm, s=sy: _box( + bm, (0.16, 0.16, 0.040), (0.0, s * POST_Y, 0.70), bevel=0.006)) + fr.group("Brace", lambda bm, s=sy: _box( + bm, (0.08, 0.08, 0.42), (0.0, s * (POST_Y - 0.15), 0.90), bevel=0.010, + rot=Matrix.Rotation(math.radians(22.0) * s, 3, "X"))) + meshes[f"Post.{side}"] = fr.finish(f"Well.Stone.Post.{side}") + + beam = Part() + beam.group("Timber", lambda bm: _box(bm, (0.12, 2 * POST_Y + 0.22, 0.13), + (0.0, 0.0, POST_TOP - 0.065), bevel=0.014, + cuts=3)) + def caps(bm): + # Post caps, not a roof. Two revisions of a gable were tried: fanned + # boards read as detached planks, and solid slopes big enough to look + # like a roof covered the masonry that carries the AO story. A + # roofless windlass well is the more honest prop and the better + # subject for this bake. + for s in (1.0, -1.0): + _box(bm, (0.18, 0.18, 0.05), (0.0, s * POST_Y, POST_TOP + 0.025), + bevel=0.010, cuts=1) + _box(bm, (0.16, 2 * POST_Y - 0.30, 0.05), + (0.0, 0.0, POST_TOP - 0.155), bevel=0.010, cuts=2) + beam.group("Shingle", caps) + meshes["Beam"] = beam.finish("Well.Stone.Beam") + + # windlass: drum along Y with a crank, plus the rope and bucket + wl = Part() + rotY = Matrix.Rotation(math.radians(90.0), 3, "X") + wl.group("Drum", lambda bm: _prism(bm, 14, 0.085, POST_Y - 0.03, + (0.0, 0.0, DRUM_Z), rot=rotY, cuts=2)) + def collars(bm): + for s in (1.0, -1.0): + _prism(bm, 14, 0.098, 0.022, (0.0, s * (POST_Y - 0.045), DRUM_Z), + rot=rotY) + wl.group("Iron", collars) + def crank(bm): + _prism(bm, 10, 0.020, 0.10, (0.0, POST_Y + 0.06, DRUM_Z), rot=rotY) + _box(bm, (0.030, 0.030, 0.20), (0.0, POST_Y + 0.10, DRUM_Z - 0.09), + bevel=0.006, rot=Matrix.Rotation(math.radians(90.0), 3, "Y")) + _prism(bm, 10, 0.026, 0.062, (0.14, POST_Y + 0.10, DRUM_Z - 0.09), + rot=Matrix.Rotation(math.radians(90.0), 3, "X")) + wl.group("Iron", crank) + meshes["Windlass"] = wl.finish("Well.Stone.Windlass") + + bk = Part() + bk.group("Rope", lambda bm: _prism(bm, 7, 0.020, 0.105, + (0.10, -0.20, DRUM_Z - 0.020), cuts=2)) + bk.group("Stave", lambda bm: _prism(bm, 16, 0.150, 0.130, + (0.10, -0.20, 0.845), taper=0.86, cuts=2)) + def bands(bm): + for z in (0.740, 0.953): + _prism(bm, 16, 0.158, 0.016, (0.10, -0.20, z)) + bk.group("Iron", bands) + bk.group("Iron", lambda bm: _box(bm, (0.30, 0.022, 0.022), + (0.10, -0.20, 0.983), bevel=0.005)) + meshes["Bucket"] = bk.finish("Well.Stone.Bucket") + return meshes + + +def build_asset(sc): + """Link the well into `sc`. Returns (root, parts).""" + root = bpy.data.objects.new("Well.Stone", None) + root.empty_display_type = "PLAIN_AXES" + sc.collection.objects.link(root) + parts = [] + for suffix, me in build_well_meshes().items(): + ob = bpy.data.objects.new(me.name, me) + sc.collection.objects.link(ob) + ob.parent = root + parts.append(ob) + bpy.context.view_layer.update() + return root, parts + + +def bake_asset(parts, n_samples=ASSET_SAMPLES, invert=False): + """Bake AO for every part against the whole assembly. Returns all values.""" + bvh = world_bvh(parts) + every = [] + for ob in parts: + vals = bake_object_ao(bvh, ob, n_samples) + write_ao_attributes(ob.data, vals, invert=invert) + every.extend(vals) + return every + + +# --------------------------------------------------------------------------- +# Calibration rig +# --------------------------------------------------------------------------- + +def build_calibration(sc): + """A wide wall plus a floor strip whose vertices sit at CAL_DISTANCES. + + Returns (wall_ob, floor_ob, probe_indices) — the floor vertices whose AO + is held to the closed form. + """ + wm = bpy.data.meshes.new("Cal.Wall") + bm = bmesh.new() + try: + v = [bm.verts.new(p) for p in ((0, -WALL_HALF_W, 0), (0, WALL_HALF_W, 0), + (0, WALL_HALF_W, WALL_H), + (0, -WALL_HALF_W, WALL_H))] + bm.faces.new(v) + bm.to_mesh(wm) + finally: + bm.free() + wall = bpy.data.objects.new("Cal.Wall", wm) + sc.collection.objects.link(wall) + + fm = bpy.data.meshes.new("Cal.Floor") + bm = bmesh.new() + try: + rows = [] + for d in CAL_DISTANCES: + rows.append([bm.verts.new((d, y, 0.0)) for y in (-6.0, 6.0)]) + for a, b in zip(rows, rows[1:]): + bm.faces.new((a[0], a[1], b[1], b[0])) + bm.normal_update() + bm.to_mesh(fm) + finally: + bm.free() + floor = bpy.data.objects.new("Cal.Floor", fm) + sc.collection.objects.link(floor) + bpy.context.view_layer.update() + return wall, floor + + +# --------------------------------------------------------------------------- +# Check +# --------------------------------------------------------------------------- + +def check(): + sc = bpy.context.scene + fails = [] + + def fail(code, msg): + print(f"ERROR ({code}): {msg}", file=sys.stderr) + fails.append(code) + + # --- 1. analytic AO against the closed form ----------------------------- + wall, floor = build_calibration(sc) + bvh = world_bvh([wall]) + up = Vector((0.0, 0.0, 1.0)) + measured, expected = [], [] + for d in CAL_DISTANCES: + got = bake_points(bvh, [Vector((d, 0.0, 0.0))], [up], CAL_SAMPLES)[0] + want = analytic_ao(WALL_H, d) + measured.append(got) + expected.append(want) + if abs(got - want) > CAL_TOL: + fail(3, f"AO at d={d} is {got:.6f}, closed form {want:.6f} " + f"(err {abs(got - want):.3e} > {CAL_TOL:.1e}) — the " + f"integrator does not integrate the hemisphere it claims to") + worst_cal = max(abs(g - w) for g, w in zip(measured, expected)) + print("analytic_ao samples=%d H=%.1f" % (CAL_SAMPLES, WALL_H)) + for d, g, w in zip(CAL_DISTANCES, measured, expected): + print(f" d={d:<5} k={WALL_H / d:<7.2f} ao={g:.6f} closed_form={w:.6f} " + f"err={abs(g - w):.3e}") + print(f"analytic_ao worst_err={worst_cal:.3e} tol={CAL_TOL:.1e}") + + # --- 2. unoccluded == 1 exactly, and strict monotonicity ---------------- + free = bake_points(world_bvh([floor]), [Vector((3.0, 0.0, 0.0))], [up], + CAL_SAMPLES)[0] + print(f"unoccluded_plate ao={free:.9f} (closed form 1.0)") + if abs(free - 1.0) > 0.0: + fail(4, f"an unoccluded flat plate bakes to {free:.9f}, not exactly 1.0 " + f"— rays are self-hitting the surface they start on") + strictly_up = all(b > a for a, b in zip(measured, measured[1:])) + print(f"monotonic increasing_with_distance={strictly_up} " + f"range={measured[0]:.6f}..{measured[-1]:.6f}") + if not strictly_up: + fail(4, "AO does not increase strictly with distance from the wall — " + "the corner does not darken monotonically with depth") + + # --- 3. asset bake: in range, and actually using the range -------------- + root, parts = build_asset(sc) + values = bake_asset(parts) + lo, hi = min(values), max(values) + print(f"asset_bake parts={len(parts)} verts={len(values)} " + f"samples={ASSET_SAMPLES} min={lo:.6f} max={hi:.6f} spread={hi - lo:.6f}") + if lo < 0.0 or hi > 1.0: + fail(5, f"baked AO out of range [{lo:.6f}, {hi:.6f}] — a colour " + f"attribute an engine reads must stay in [0,1]") + if hi - lo < SPREAD_MIN: + fail(5, f"AO spread {hi - lo:.6f} < {SPREAD_MIN} — the bake is nearly " + f"constant, so it is not describing this geometry") + + # --- 4. storage round-trip: FLOAT exact, BYTE sRGB-quantised ------------ + probe_me = parts[0].data + fa = probe_me.color_attributes[AO_ATTR] + ba = probe_me.color_attributes[AO_BYTE_ATTR] + written = values[:len(fa.data)] + float_err = max(abs(fa.data[i].color[0] - v) for i, v in enumerate(written)) + byte_err = max(abs(ba.data[i].color[0] - v) for i, v in enumerate(written)) + model_err = max(abs(ba.data[i].color[0] - byte_color_model(v)) + for i, v in enumerate(written)) + print(f"storage float_roundtrip_err={float_err:.3e} " + f"byte_roundtrip_err={byte_err:.3e} byte_vs_srgb_model={model_err:.3e}") + if float_err > FLOAT_TOL: + fail(6, f"FLOAT_COLOR round-trip error {float_err:.3e} > {FLOAT_TOL:.0e}") + if model_err > BYTE_MODEL_TOL: + fail(6, f"BYTE_COLOR readback deviates {model_err:.3e} from the " + f"independent sRGB encode/quantise/decode model — the storage " + f"encoding is not what the README documents") + if byte_err <= float_err: + fail(6, "BYTE_COLOR round-trips as tightly as FLOAT_COLOR — the " + "documented 8-bit sRGB quantisation has stopped happening, so " + "the exporter warning is now wrong") + + # --- 5. depsgraph survival ---------------------------------------------- + dg = bpy.context.evaluated_depsgraph_get() + worst_ev = 0.0 + for ob in parts: + ev = ob.evaluated_get(dg).data + for name in (AO_ATTR, AO_BYTE_ATTR): + eva = ev.color_attributes.get(name) + if eva is None: + fail(7, f"{ob.name}: {name} missing from the evaluated mesh") + continue + src = ob.data.color_attributes[name] + if eva.data_type != src.data_type or eva.domain != src.domain: + fail(7, f"{ob.name}: {name} changed to " + f"{eva.data_type}/{eva.domain} under evaluation") + # Compare lengths BEFORE zipping. zip() stops at the shorter + # sequence, so a modifier that resamples the attribute onto a + # different element count would slip through as a clean 0.0 + # deviation — the probe that added a Subdiv modifier exited 0 + # until this guard was added. + if len(eva.data) != len(src.data): + fail(7, f"{ob.name}: {name} has {len(eva.data)} evaluated " + f"elements vs {len(src.data)} authored — a modifier " + f"resampled the attribute, so what renders is not " + f"what was baked") + continue + worst_ev = max(worst_ev, max( + abs(a.color[0] - b.color[0]) for a, b in zip(src.data, eva.data))) + print(f"depsgraph_survival parts={len(parts)} max_dev={worst_ev:.3e}") + if worst_ev > 0.0: + fail(7, f"colour attributes drift {worst_ev:.3e} under depsgraph " + f"evaluation — the baked data is not what renders") + + # --- 6. reuse hygiene ---------------------------------------------------- + default_names = {"Cube", "Sphere", "Torus", "Suzanne", "Plane", "Circle", + "Cylinder", "Cone", "Grid", "Icosphere", "Empty"} + for ob in parts: + if max(abs(s - 1.0) for s in ob.scale) > 0.0: + fail(8, f"{ob.name} scale {tuple(ob.scale)} not applied") + if not ob.name.startswith("Well.Stone."): + fail(8, f"part {ob.name!r} outside the asset namespace") + if ob.data.name.split(".")[0] in default_names: + fail(8, f"{ob.name} carries a default datablock name") + if ob.data.attributes.default_color_name != AO_ATTR: + fail(8, f"{ob.name} render colour attribute is " + f"{ob.data.attributes.default_color_name!r}, not {AO_ATTR!r} " + f"— an engine would read the wrong channel") + ground = min(min(v.co.z for v in ob.data.vertices) for ob in parts) + if abs(ground) > 1e-4: + fail(8, f"asset rests at z={ground:.5f}, not on the ground plane") + print(f"hygiene parts={len(parts)} ground_z={ground:.2e} " + f"render_attr={AO_ATTR}") + + if fails: + return fails[0] + print(f"vertex-color-ao OK cal_err={worst_cal:.3e} unoccluded={free:.6f} " + f"asset_range={lo:.4f}..{hi:.4f} float_err={float_err:.3e} " + f"byte_model_err={model_err:.3e} evaluated_dev={worst_ev:.3e}") + return 0 + + +# --------------------------------------------------------------------------- +# Render +# --------------------------------------------------------------------------- + +def make_ao_material(name, rgb, rough, metallic=0.0, ao_strength=1.0): + """Principled with the AO colour attribute multiplied into base colour.""" + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + bsdf.inputs["Roughness"].default_value = rough + bsdf.inputs["Metallic"].default_value = metallic + col = nt.nodes.new("ShaderNodeVertexColor") + col.layer_name = AO_ATTR + col.location = (-700, 100) + # lift so full occlusion does not go to pure black + lift = nt.nodes.new("ShaderNodeMixRGB") + lift.blend_type = "MIX" + lift.inputs["Fac"].default_value = ao_strength + lift.inputs["Color1"].default_value = (1.0, 1.0, 1.0, 1.0) + lift.location = (-500, 100) + nt.links.new(col.outputs["Color"], lift.inputs["Color2"]) + tint = nt.nodes.new("ShaderNodeMixRGB") + tint.blend_type = "MULTIPLY" + tint.inputs["Fac"].default_value = 1.0 + tint.inputs["Color1"].default_value = (*rgb, 1.0) + tint.location = (-300, 100) + nt.links.new(lift.outputs["Color"], tint.inputs["Color2"]) + nt.links.new(tint.outputs["Color"], bsdf.inputs["Base Color"]) + return mat + + +SLOT_MATS = { + "Stone": ((0.086, 0.079, 0.069), 0.86, 0.0), + "Coping": ((0.118, 0.108, 0.094), 0.80, 0.0), + "Bore": ((0.038, 0.035, 0.032), 0.92, 0.0), + "Flag": ((0.066, 0.061, 0.055), 0.89, 0.0), + "Timber": ((0.148, 0.078, 0.030), 0.74, 0.0), + "Brace": ((0.120, 0.064, 0.026), 0.76, 0.0), + "Shingle": ((0.092, 0.056, 0.028), 0.82, 0.0), + "Drum": ((0.170, 0.098, 0.040), 0.72, 0.0), + "Stave": ((0.205, 0.118, 0.048), 0.70, 0.0), + "Rope": ((0.255, 0.208, 0.122), 0.88, 0.0), + "Iron": ((0.075, 0.078, 0.084), 0.44, 0.85), +} + +_mat_cache = {} + + +def bind_materials(ob, ao_strength=1.0): + me = ob.data + if me.materials: + return + for slot in me.get("slots", []): + key = (slot, ao_strength) + if key not in _mat_cache: + rgb, rough, metal = SLOT_MATS[slot] + _mat_cache[key] = make_ao_material(slot, rgb, rough, metal, + ao_strength) + me.materials.append(_mat_cache[key]) + + +def build_studio(sc): + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=40.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Studio") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.030, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + sc.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 7.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + sc.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.020, 0.021, 0.025, 1.0) + sc.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy, ld.size, ld.color = energy, size, col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + sc.collection.objects.link(ob) + + # VISUAL-STYLE Layer 2 rig, energies scaled to a ~1.9 m subject + light("Key", (-2.3, -2.4, 3.6), 430.0, 3.0, (1.0, 0.96, 0.9), (40, 0, -42)) + light("Fill", (3.0, -2.0, 1.2), 78.0, 5.0, (0.75, 0.85, 1.0), (70, 0, 54)) + light("Rim", (-0.9, 3.0, 2.6), 240.0, 2.0, (0.6, 0.78, 1.0), (-54, 0, 196)) + light("Wedge", (0.3, 5.0, 0.7), 300.0, 5.0, (1.0, 0.76, 0.5), (-86, 0, 182)) + return floor, wall + + +def render_still(path, engine, falsify=False): + """The well with its baked AO driving base colour. + + Falsified: the same bake written INVERTED, so the crevices between the + masonry courses and the inside of the shaft read bright while the exposed, + sky-facing stone goes dark — occlusion turned inside out. + """ + bpy.ops.wm.read_factory_settings(use_empty=True) + _mat_cache.clear() + sc = bpy.context.scene + + root, parts = build_asset(sc) + bake_asset(parts, n_samples=192, invert=falsify) + for ob in parts: + bind_materials(ob) + floor, wall = build_studio(sc) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (3.62, -4.78, 3.42) + sc.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.49) + sc.collection.objects.link(aim) + tr = cam.constraints.new("TRACK_TO") + tr.target = aim + tr.track_axis = "TRACK_NEGATIVE_Z" + tr.up_axis = "UP_Y" + sc.camera = cam + + sc.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + sc.cycles.device = "CPU" + sc.cycles.samples = 64 + sc.cycles.use_denoising = True + else: + try: + sc.eevee.taa_render_samples = 64 + except AttributeError: + pass + sc.render.resolution_x = 1280 + sc.render.resolution_y = 720 + sc.render.image_settings.file_format = "PNG" + sc.render.filepath = path + # Standard, always — AgX would lift the stage toward grey (VISUAL-STYLE) + sc.view_settings.view_transform = "Standard" + bpy.context.view_layer.update() + + fcode = gallery_framing.check_framing(sc, cam, hero=parts, elements=parts, + stage=[floor, wall]) + if fcode: + return fcode + aqcode = gallery_asset_quality.check_asset_quality(sc, cam, hero=parts, + stage=[floor, wall]) + if aqcode: + return aqcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + print("ERROR: render produced no file", file=sys.stderr) + return 9 + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None, help="optional: render a still PNG here") + p.add_argument("--falsify", default=None, + help="optional: render the inverted-AO variant here") + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + args = p.parse_args(argv) + + print(f"binary version: {bpy.app.version} ({bpy.app.version_string})") + bpy.ops.wm.read_factory_settings(use_empty=True) + code = check() + if code: + return code + if args.output: + rcode = render_still(os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + if args.falsify: + rcode = render_still(os.path.abspath(args.falsify), args.engine, + falsify=True) + if rcode: + return rcode + print(f"rendered falsified variant {args.falsify}") + + print("vertex-color-ao OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + import traceback + + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1)